Skip to content

EN-063: web target runs full-3D games (the shooter port)#118

Open
proggeramlug wants to merge 2 commits into
mainfrom
feat/en-063-web-3d-shooter
Open

EN-063: web target runs full-3D games (the shooter port)#118
proggeramlug wants to merge 2 commits into
mainfrom
feat/en-063-web-3d-shooter

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

The web crate could already compile the whole renderer for wasm32; what it could not do was run a real 3D game. This lands everything the Bloom Shooter port needed, with native paths unchanged except two deliberate fixes (closeWindow, and log_error reaching the browser console).

The three the player actually sees

  • Everything was ~a stop too dark. Every WebGPU canvas offers only bgra8/rgba8unorm, but the tonemapper writes LINEAR and relies on hardware encode-on-store into an sRGB target — which native surfaces provide. The renderer now separates output_format (what frames are RENDERED in — an sRGB view of the swapchain, registered via view_formats) from surface_config.format (what the surface is CONFIGURED with). Native is untouched: there they're equal.
  • Trees rendered as torn opaque sheets. bloom_draw_model_rotated still used the OLD immediate path (draw_model_mesh_tinted_rotated) — no alpha cutout, no materials, no wind. Round 5 moved native to the cached scene pipeline precisely because that path renders cutout foliage as opaque cards; the web copy never got the fix.
  • Characters animated wrongly. bloom_draw_model never checked is_model_skinned, so skinned models took the UNSKINNED cached draw and each primitive read joint offset 0 — another model's matrices — instead of its own staged pose.

The same drift class, found by auditing all 330 native vs 376 web exports

Function Consequence
bloom_get_gamepad_axis, is_gamepad_button_*, is_gamepad_available, get_gamepad_axis_count Carried a leading gamepad param the manifest never declared → the game's one argument landed in it, the real one arrived undefined→NaN→as usize = 0. Every axis read axis 0; every button read button 0.
bloom_get_touch_x/_y Dropped the index, hardcoded slot 0 → every finger reported the first finger's coords while is_touch_active(i) honoured the index. Both thumbsticks tracked one thumb.
bloom_inject_key_down/_up Wrote keys_down directly instead of staging via inject_key_* → the edge was folded into prev before begin_frame computed it, so isKeyPressed never fired.
bloom_scene_attach_model Dropped set_material_pbr + alpha_cutoff → attached foliage opaque, everything pinned at roughness 0.8.
bloom_scene_set_transform16 Its 17-arg body was wired to the 2-arg name bloom_scene_set_transformsetSceneNodeTransform resolved to nothing and Perry auto-stubbed it. A silent no-op with the working implementation one rename away.
bloom_update_model_animation Empty stub carrying the pre-fix 9-arg (rot_sin, rot_cos) signature native abandoned.
bloom_unload_model Skipped evict_model_cache → leaked GPU buffers; a slot-reusing model rendered stale geometry.
bloom_commit_model Used register_texture + base-colour-only remap → stripped normal/MR/emissive maps (the round-5 bug, still live here).
bloom_physics_vehicle_create Declared a 38th param (trailing, so no shift; arrived NaN).

Root cause, fixed: tools/validate-ffi.js checked arity on all 7 native platforms but web was "name coverage only for now". It now checks arity for the 452 all-f64 mirror functions — signatures whose web ABI is deliberately different (_str/_bytes/_floats slices) are skipped, which is exactly where drift is a bug rather than a design. The gap allowlist was stale too (it listed 29 functions this branch implements): 41 → 13 honest gaps.

Also here

  • Web FFI parity (parity_ffi.rs, ragdoll_ffi.rs): scratch mesh/instance/texture-array builders, staged-model bytes, texture-array-from-files (JS fetches, engine decodes), ragdolls (shared pose math + a new six-DOF constraint on the Jolt bridge), splat impulses, GI-only flags, env-HDR bytes, profiler text, mouse/touch/wheel/char injection.
  • maxBindGroups = 4 is a hard browser cap and the material ABI used 5 → scene-inputs folded into per_frame on wasm32 (@group(4) rewritten at compile time; WASM_SCENE_INPUTS_BASE keeps layout/builder/dispatch in agreement). One invalid pipeline poisons the frame's whole command buffer: black canvas, zero errors.
  • Device limits: request the adapter's per-stage texture/sampler limits (the ABI samples 19; the default 16 rejected every user material).
  • Browser WGSL strictness: textureSampleLevel(0) on mipless RTs, textureSampleCompareLevel for shadow cascades, derivatives hoisted out of non-uniform flow. Pixel-identical, naga-validated. The depth prepass is skipped on wasm — its Equal-test pairing needs bit-identical depths from two pipelines, which Tint doesn't guarantee through the foliage-wind chain.
  • jolt_bridge.js vs npm JoltPhysics.js (written against an older API generation, each failure silent and fatal): CharacterVirtual's 4-arg ctor (the 5-arg number form binds 0 as the PhysicsSystem and dies frames later in ExtendedUpdate with "null function"); query filters from the JoltInterface getters, cached per (world, layer) — the character path leaked three emscripten objects per frame; collector-form closest-hit CastRay; heightfield samples written into the emscripten heap; u64 userData as Number.
  • bloom_glue.js: engine pre-init + adapter probe before Perry boots, asset prefetch with progress (assets_manifest.json authoritative for fileExists — no blocking-404 storms), keyboard map corrected to the actual Key enum (it carried GLFW codes — menus were dead while WASD worked), queue+flush input with deferred key-ups, pointer lock with ESC-exit → injected Key.ESCAPE, per-FFI error attribution.
  • closeWindow() sets should_close on windows/linux/macos — it was an empty stub, so a game's quit path kept running headless.
  • Engine diagnostics reach the browser: log_error's eprintln! is a black hole on wasm, so every engine warning was dropped on the one platform whose users can't attach a debugger. Routed to console.error.
  • Manifest: web target uses the Perry 0.5 prebuilt schema (./native/web/pkg/ — the ./ is load-bearing).

Verification

  • cargo check --release (windows) + cargo check --target wasm32-unknown-unknown + wasm-pack release: clean.
  • bun tools/validate-ffi.js: 0 failures, 0 warnings (was: web unchecked for arity).
  • Native COMBATSHOT regression: identical combat numbers, clean exit at frame 260.
  • The shooter boots, renders and plays in headless Edge at ~59 FPS with 0 console errors (see the SH-054 shooter PR).

Requires the Perry wasm-codegen fixes on fix/wasm-new-array-sized (local branch) to compile a working game wasm.

Summary by CodeRabbit

  • New Features
    • Added stageModelsSync for synchronous model staging.
    • Added in-memory HDR environment loading (set_env_clear_from_hdr_bytes).
    • Expanded web support for ragdolls, profiler overlays, texture-array workflows, impulses, and staged model assets.
  • Bug Fixes
    • Window-close requests now reliably terminate the game loop on Linux, macOS, and Windows.
    • Improved WebGPU/shader compatibility on wasm32 (including safer bind-group usage and explicit LOD sampling).
    • Refreshed web input handling for mouse/touch/keyboard/pointer-lock with more reliable event injection and touch-slot coordinates.

The web crate was a near-complete FFI port whose gaps only showed the
moment a real 3D game booted. Everything native is byte-identical
except one deliberate fix (closeWindow, below).

Web FFI parity (native/web/parity_ffi.rs + ragdoll_ffi.rs):
- The scratch-stream builders every TS wrapper actually uses:
  mesh scratch, create_mesh_scratch, instance-buffer scratch, spline
  ribbon, texture-array scratch, set_material_params_scratch. Without
  them every generated mesh silently came back 0 on web.
- Texture-array-from-files split for a host with no filesystem:
  glue fetches each path, bloom_texture_array_files_push decodes
  engine-side (same image codecs as native), commit assembles.
- bloom_stage_model_bytes: the sync web half of staged model loading.
- Ragdolls: the shared plan/attach/apply math compiles on wasm (gate
  lifted - it never needed Jolt types); the body/constraint half rides
  jolt_bridge.js, incl. a new constraintSixDofLockedTranslation.
- splat_impulse, scene_set_gi_only, env-HDR-from-bytes (+ the shared
  renderer twin set_env_clear_from_hdr_bytes), profiler text FFIs,
  present-mode/screenshot stubs, mouse/touch/wheel/char injection.
- commit_model synced with native: kind-aware register_texture_kind +
  the FULL aux-texture remap (the web copy still had the round-5 bug
  that strips normal/MR/emissive maps).
- WebGPU device now requests the adapter's per-stage texture/sampler
  limits (the material ABI samples 19 textures; the default 16 made
  every user material invalid, and one invalid pipeline poisons the
  whole frame's command buffer - black screen, zero errors).

Scene-inputs bind group folded into per_frame on wasm32 only:
browsers hard-cap maxBindGroups at 4, the material ABI used 5. The
expanded WGSL's @group(4) declarations are rewritten to group 0
bindings 1..7 at compile_material time; layout, bind-group builder,
and the dispatch site agree via WASM_SCENE_INPUTS_BASE.

Browser WGSL strictness (Tint enforces what naga waves through), all
pixel-identical: textureSampleLevel(0) on mipless render targets,
textureSampleCompareLevel for the shadow cascades, dpdx/dpdy hoisted
out of non-uniform control flow (SSR early-outs, scene compute_tbn).

jolt_bridge.js vs npm JoltPhysics.js builds (written against an older
API generation): CharacterVirtual is the 4-arg ctor there - the 5-arg
number-userData form binds 0 as the PhysicsSystem and dies later with
"null function"; query filters come from the JoltInterface getters;
closest-hit CastRay only exists in collector form; heightfield samples
must be written into the emscripten heap; u64 userData accepts Number,
not BigInt. Filters are now cached per (world, layer) - the character
path leaked three emscripten objects per frame.

bloom_glue.js: engine pre-init + WebGPU adapter probe before the game
boots (Perry main is sync, wgpu init is async), asset prefetch cache
from assets_manifest.json (manifest is authoritative for fileExists -
no blocking-404 storms), readFile falls back to the served asset tree,
keyboard map corrected to the ACTUAL Key enum (it was GLFW codes -
Enter/Escape/arrows/F-keys never matched, menus were dead while WASD
worked), queue+flush input with one-frame deferred key-ups, pointer
lock with ESC-exit translated to an injected Key.ESCAPE, mouse/touch/
wheel wiring, per-FFI error attribution.

closeWindow() now sets should_close on windows/linux/macos - it was an
empty stub, so a game's quit path kept running headless. The shooter's
runGame refactor turns its `break` exits into closeWindow() calls;
verified native with a COMBATSHOT run (identical numbers, clean exit).

package.json web target uses the Perry 0.5 schema: prebuilt ->
./native/web/pkg/ (the old crate/buildTool/output shape fails
"crate and lib must be declared together"; the ./ prefix is load-
bearing - a bare path parses as a node-module reference).

stageModelsSync in the TS API: Perry's parallelMap workers are their
own WASM instances with no engine FFI on web.
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds wasm32 renderer, model, profiler, input, physics, and ragdoll integration; updates browser startup and asset handling; changes shader sampling and derivative flow; adds byte-backed asset APIs; and makes native close-window calls signal engine shutdown.

Changes

Web runtime and parity

Layer / File(s) Summary
Web bootstrap, input, and game loop
native/web/bloom_glue.js, native/web/src/input_ffi.rs, native/web/src/lib.rs
Browser initialization, manifest-aware loading, DOM input injection, pointer-lock handling, FFI coercion, audio lifecycle, frame error handling, and close-loop behavior are updated.
Web parity exports and staged assets
native/web/src/parity_ffi.rs, native/web/src/lib.rs, native/web/src/material_ffi.rs, native/web/bloom_glue.js, native/shared/src/textures.rs, native/shared/src/renderer/mod.rs
Wasm exports add scratch builders, byte-backed textures and models, environment and profiler helpers, model animation, material updates, and staged-model texture remapping.

Web physics and ragdolls

Layer / File(s) Summary
Wasm ragdoll lifecycle
native/shared/src/lib.rs, native/shared/src/engine.rs, native/web/src/ragdoll_ffi.rs, native/web/src/physics_ffi.rs
Wasm builds expose ragdoll state and provide ragdoll creation, activation, impulses, pose updates, and resource release.
Jolt bridge compatibility and queries
native/web/jolt_bridge.js
Cached collision filters, collector-based casts, numeric compatibility fallbacks, heightfield heap writes, character compatibility, and six-DOF constraints are added.

Renderer ABI and shader sampling

Layer / File(s) Summary
Wasm32 SceneInputs bind-group folding
native/shared/src/renderer/material_pipeline.rs, native/shared/src/renderer/material_system.rs
SceneInputs shader declarations and resources are folded into the wasm32 per-frame group, avoiding a separate group 4.
Renderer output formats and byte-backed environments
native/shared/src/renderer/mod.rs
Render targets, pipelines, views, and post-processing use the computed output format, and HDR data can be loaded from memory.
Explicit shader sampling and derivatives
native/shared/src/renderer/shaders/*, native/shared/src/renderer/scene_pass.rs
AO, post-processing, SSR, shadow comparison, normal-mapping, and wasm scene-pass paths use explicit sampling and target-specific derivative/depth behavior.

Cross-platform APIs and shutdown

Layer / File(s) Summary
Shutdown, logging, and byte-backed assets
native/linux/src/lib.rs, native/macos/src/lib.rs, native/windows/src/lib.rs, native/shared/Cargo.toml, native/shared/src/ffi.rs, native/shared/src/textures.rs
Native close-window functions set engine().should_close, wasm errors use browser console logging, and in-memory image decoding is available.
Synchronous model staging API
src/models/index.ts, src/index.ts, src/math/index.ts
A sequential model-staging helper is added and re-exported, and vec3 uses explicit property assignments.
Web FFI contract validation
tools/validate-ffi.js
Web FFI validation now checks exported all-f64 mirror arity and updates manifest gap allowlists.
Prebuilt web packaging
package.json
The web target now consumes the prebuilt wasm package directory.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is specific and accurately captures the main web-target full-3D game enablement.
Description check ✅ Passed It includes a detailed summary, verification bullets, and reviewer notes, covering the required content despite not matching the template verbatim.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/en-063-web-3d-shooter

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@native/web/jolt_bridge.js`:
- Around line 657-662: Update the ray and overlap query paths around the
closest-hit, all-hit, and overlap methods to honor the supplied layerMask
instead of discarding it or hardcoding layer 1. Build and reuse the appropriate
object/body filter from layerMask for every query, including the locations
corresponding to CastRay and overlap calls, while preserving each query’s
existing hit-collection behavior.
- Around line 1088-1108: Update destroyWorld to clear all queryFilterCache
entries matching the destroyed world’s worldH, calling J.destroy on each cached
bp, obj, body, and shape before removing the entry. Keep unrelated worlds’
cached filters intact and perform cleanup before releasing the world’s Jolt
resources.
- Around line 927-950: Update the SixDOF constraint creation flow around the
local settings object and ctx.release(): destroy the temporary
J.SixDOFConstraintSettings instance with J.destroy(settings), and move
ctx.release() into a finally block so it executes exactly once on success or
when Create() or registerConstraint() throws. Apply the same cleanup and
finally-based release pattern to the matching heightfield helper.

In `@native/web/src/parity_ffi.rs`:
- Around line 30-343: The web FFI manifest is missing declarations for newly
exported texture-array, staged-model, HDR environment, and input-injection
functions. Update package.json to add entries for
bloom_texture_array_files_reset, bloom_texture_array_files_push,
bloom_texture_array_files_commit, bloom_stage_model_bytes,
bloom_set_env_clear_from_hdr_bytes, and every bloom_inject_* wrapper in
native/web/src/input_ffi.rs:188-240; no direct source changes are needed in
either Rust file.

In `@native/web/src/ragdoll_ffi.rs`:
- Around line 63-146: Make the ragdoll setup transactional before the
body-creation loop: validate the `rag` handle and required animation before
creating physics objects. Treat any failed `jb_body_create` or
`jb_constraint_six_dof` result as a setup failure, destroy every body and
constraint created during this call, and return `0.0`; only call `r.attach`
after all handles are valid so no partial ragdoll becomes active.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cc3de5bc-6241-4adf-a3fb-d94d0bc53645

📥 Commits

Reviewing files that changed from the base of the PR and between 57de189 and 643037d.

📒 Files selected for processing (24)
  • native/linux/src/lib.rs
  • native/macos/src/lib.rs
  • native/shared/src/engine.rs
  • native/shared/src/lib.rs
  • native/shared/src/renderer/material_pipeline.rs
  • native/shared/src/renderer/material_system.rs
  • native/shared/src/renderer/mod.rs
  • native/shared/src/renderer/shaders/ao.rs
  • native/shared/src/renderer/shaders/core.rs
  • native/shared/src/renderer/shaders/post.rs
  • native/shared/src/renderer/shaders/ssgi.rs
  • native/shared/src/textures.rs
  • native/web/bloom_glue.js
  • native/web/jolt_bridge.js
  • native/web/src/input_ffi.rs
  • native/web/src/lib.rs
  • native/web/src/parity_ffi.rs
  • native/web/src/physics_ffi.rs
  • native/web/src/ragdoll_ffi.rs
  • native/windows/src/lib.rs
  • package.json
  • src/index.ts
  • src/math/index.ts
  • src/models/index.ts

Comment thread native/web/jolt_bridge.js
Comment on lines +657 to 662
const qf = queryFilters(w, worldH, 1);
const rcSettings = new J.RayCastSettings();
const collector = new J.CastRayClosestHitCollisionCollector();
w.system.GetNarrowPhaseQuery().CastRay(ray, rcSettings, collector, qf.bp, qf.obj, qf.body, qf.shape);
const hit = collector.HadHit();
void layerMask; // layer filtering is handled by ObjectLayerFilter; Tier 2 adds mask support

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Honor layerMask instead of hardcoding the pass-all layer.

All ray and overlap queries now use filters for layer 1 while explicitly discarding layerMask. Callers therefore receive bodies from excluded layers. Build an object/body filter from the supplied mask and use it consistently across closest-hit, all-hit, and overlap queries.

Also applies to: 694-697, 755-755, 768-768, 789-789

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@native/web/jolt_bridge.js` around lines 657 - 662, Update the ray and overlap
query paths around the closest-hit, all-hit, and overlap methods to honor the
supplied layerMask instead of discarding it or hardcoding layer 1. Build and
reuse the appropriate object/body filter from layerMask for every query,
including the locations corresponding to CastRay and overlap calls, while
preserving each query’s existing hit-collection behavior.

Comment thread native/web/jolt_bridge.js
Comment on lines +927 to +950
try {
const settings = new J.SixDOFConstraintSettings();
settings.mSpace = worldSpace ? J.EConstraintSpace_WorldSpace : J.EConstraintSpace_LocalToBodyCOM;
settings.mPosition1 = rvec3(ax, ay, az);
settings.mPosition2 = rvec3(bx, by, bz);
const AX = [
[J.SixDOFConstraintSettings_EAxis_TranslationX, 1, -1],
[J.SixDOFConstraintSettings_EAxis_TranslationY, 1, -1],
[J.SixDOFConstraintSettings_EAxis_TranslationZ, 1, -1],
[J.SixDOFConstraintSettings_EAxis_RotationX, rxMin, rxMax],
[J.SixDOFConstraintSettings_EAxis_RotationY, ryMin, ryMax],
[J.SixDOFConstraintSettings_EAxis_RotationZ, rzMin, rzMax],
];
for (const [axis, lo, hi] of AX) {
if (lo >= hi) settings.MakeFixedAxis(axis);
else settings.SetLimitedAxis(axis, lo, hi);
}
const c = settings.Create(ctx.body1, ctx.body2);
ctx.release();
return registerConstraint(state.bodies.get(bodyA).world, c);
} catch (e) {
ctx.release();
console.warn('[jolt_bridge] SixDOF constraint unavailable:', e);
return 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the relevant regions with line numbers
sed -n '900,980p' native/web/jolt_bridge.js
printf '\n----\n'
sed -n '1020,1085p' native/web/jolt_bridge.js

printf '\n==== search for J.destroy / release patterns ====\n'
rg -n "J\.destroy\(|ctx\.release\(|new J\.[A-Za-z0-9_]+Settings\(|Create\(ctx\.body1, ctx\.body2\)" native/web/jolt_bridge.js

Repository: Bloom-Engine/engine

Length of output: 8554


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find similar helper functions in the file and display surrounding code
rg -n "Create\(ctx\.body1, ctx\.body2\)|ctx\.release\(\)|J\.destroy\(settings\)|new J\.[A-Za-z0-9_]+Settings\(" native/web/jolt_bridge.js

printf '\n---- around other Create helpers ----\n'
for n in $(rg -n "Create\(ctx\.body1, ctx\.body2\)" native/web/jolt_bridge.js | cut -d: -f1); do
  start=$((n-20))
  end=$((n+25))
  sed -n "${start},${end}p" native/web/jolt_bridge.js
  printf '\n----\n'
done

Repository: Bloom-Engine/engine

Length of output: 14911


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the constraint context type and its release method
rg -n "release\s*[:=]\s*function|release\(\)\s*{" native/web/jolt_bridge.js
rg -n "getConstraintContext|ConstraintContext|ctx =" native/web/jolt_bridge.js

printf '\n==== surrounding code for the referenced regions ====\n'
sed -n '860,980p' native/web/jolt_bridge.js
printf '\n----\n'
sed -n '1010,1085p' native/web/jolt_bridge.js

Repository: Bloom-Engine/engine

Length of output: 303


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the constraint body resolver and any release implementation using simpler searches
rg -n "resolveConstraintBodies|ReleaseLock|release: \(\) =>|release:.*=>" native/web/jolt_bridge.js

printf '\n---- surrounding resolver code ----\n'
sed -n '760,835p' native/web/jolt_bridge.js

Repository: Bloom-Engine/engine

Length of output: 5027


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# List one-off wrapper allocations that are explicitly destroyed vs left for GC
rg -n "new J\.[A-Za-z0-9_]+Settings\(|J\.destroy\(.*Settings\)|J\.destroy\(" native/web/jolt_bridge.js

Repository: Bloom-Engine/engine

Length of output: 1635


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the resolver and the exact release semantics
rg -n "resolveConstraintBodies|ReleaseLock|release: \(\) =>|release:\s*\(\)\s*=>" native/web/jolt_bridge.js

printf '\n---- surrounding resolver code ----\n'
sed -n '760,835p' native/web/jolt_bridge.js

Repository: Bloom-Engine/engine

Length of output: 5027


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Compare explicit destruction patterns for settings wrappers in this file
rg -n "new J\.[A-Za-z0-9_]+Settings\(|J\.destroy\(.*Settings\)|J\.destroy\(" native/web/jolt_bridge.js

Repository: Bloom-Engine/engine

Length of output: 1635


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '420,455p' native/web/jolt_bridge.js
printf '\n----\n'
sed -n '652,684p' native/web/jolt_bridge.js
printf '\n----\n'
sed -n '1160,1182p' native/web/jolt_bridge.js

Repository: Bloom-Engine/engine

Length of output: 3762


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1036,1078p' native/web/jolt_bridge.js

Repository: Bloom-Engine/engine

Length of output: 1951


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Look for manual destruction of settings-like wrappers across the repo
rg -n "J\.destroy\([^)]*settings[^)]*\)|J\.destroy\([^)]*Settings[^)]*\)" .

printf '\n---- all constraint helpers with cleanup ----\n'
rg -n "Create\(ctx\.body1, ctx\.body2\)|ctx\.release\(\)" native/web/jolt_bridge.js

Repository: Bloom-Engine/engine

Length of output: 990


🌐 Web query:

JoltPhysics.js J.destroy settings wrapper create destroy manual ownership docs

💡 Result:

In JoltPhysics.js, because WebAssembly and Emscripten do not provide automatic garbage collection for C++ objects, you must manually manage memory for any object created using the 'new' keyword [1][2]. The 'Jolt.destroy(object)' function is the standard mechanism to free the memory of these objects [1][2]. Key Principles for Memory Management: 1. Manual Destruction: Any object created with 'new Jolt.XXX' must eventually be manually destroyed using 'Jolt.destroy(instance)' if it is not otherwise managed [1][2]. 2. Reference Counting: Many Jolt classes (inheriting from RefTarget) use internal reference counting [2][3]. - If you create a reference-counted object and pass it to another object (e.g., passing a ShapeSettings to a CompoundShapeSettings or a Shape to a Body), the receiving object typically takes ownership by incrementing the reference count [1][2]. In such cases, you do not need to call AddRef or maintain your own reference, and you can generally destroy your initial instance after passing it to the owner [1][2][4]. - If you need to manually control the lifetime, you can use 'object.AddRef' to increment the count and 'object.Release' to decrement it [1][2]. When the count reaches zero, the object is destroyed [1][2]. 3. Special Case: The 'Body' Class: You should not use 'Jolt.destroy' directly on Body objects. Instead, they must be destroyed through the BodyInterface using 'bodyInterface.DestroyBody(body.GetID)', which internally handles the necessary cleanup [5][1][6]. 4. Practical Workflow: - For simple data objects (like Vec3, Quat, or settings objects), call 'Jolt.destroy(object)' immediately after passing them to the required API or finishing their use [5][2][4]. - Follow the patterns in the official repository's 'proper_cleanup.html' example to ensure all interfaces, allocators, and objects are cleaned up in the correct order at the end of the application lifecycle [5][2]. Top Results: [5][1][2]

Citations:


Destroy the temporary settings objects and move lock release into finally J.destroy(settings) is missing here and in the matching heightfield helper, and ctx.release() should run once from finally so the body locks are always released exactly once if Create() or registerConstraint() throws.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@native/web/jolt_bridge.js` around lines 927 - 950, Update the SixDOF
constraint creation flow around the local settings object and ctx.release():
destroy the temporary J.SixDOFConstraintSettings instance with
J.destroy(settings), and move ctx.release() into a finally block so it executes
exactly once on success or when Create() or registerConstraint() throws. Apply
the same cleanup and finally-based release pattern to the matching heightfield
helper.

Comment thread native/web/jolt_bridge.js
Comment on lines +1088 to +1108
const queryFilterCache = new Map(); // "worldH:layer" → { bp, obj, body, shape }
function queryFilters(w, worldH, layer) {
const key = worldH + ':' + (layer | 0);
let f = queryFilterCache.get(key);
if (f) return f;
const J = JoltModule;
let bp, obj;
if (typeof w.system.GetDefaultBroadPhaseLayerFilter === 'function') {
bp = w.system.GetDefaultBroadPhaseLayerFilter(layer);
obj = w.system.GetDefaultLayerFilter(layer);
} else {
// npm/CDN builds: the canonical form from the JoltPhysics.js examples —
// the filter getters live on the JoltInterface, and constructing the
// Default* filters from the raw tables instead trips a null virtual
// inside ExtendedUpdate.
bp = new J.DefaultBroadPhaseLayerFilter(w.jolt.GetObjectVsBroadPhaseLayerFilter(), layer);
obj = new J.DefaultObjectLayerFilter(w.jolt.GetObjectLayerPairFilter(), layer);
}
f = { bp, obj, body: new J.BodyFilter(), shape: new J.ShapeFilter() };
queryFilterCache.set(key, f);
return f;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
ast-grep outline native/web/jolt_bridge.js --items all --match 'destroyWorld|queryFilters'
rg -n -C5 '\b(queryFilterCache\.(delete|clear)|destroyWorld\s*\()' native/web/jolt_bridge.js

Repository: Bloom-Engine/engine

Length of output: 605


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- destroyWorld region ---'
sed -n '218,320p' native/web/jolt_bridge.js

echo
echo '--- queryFilters region ---'
sed -n '1080,1135p' native/web/jolt_bridge.js

Repository: Bloom-Engine/engine

Length of output: 6179


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C3 'queryFilterCache|J\.destroy\(|destroyWorld\s*\(' native/web/jolt_bridge.js

Repository: Bloom-Engine/engine

Length of output: 1905


Clear queryFilterCache in destroyWorld destroyWorld tears down bodies, constraints, and w.jolt, but leaves the per-world queryFilterCache entries behind. Evict matching keys and J.destroy the cached bp, obj, body, and shape objects to avoid leaking native memory on repeated world creation/destruction.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@native/web/jolt_bridge.js` around lines 1088 - 1108, Update destroyWorld to
clear all queryFilterCache entries matching the destroyed world’s worldH,
calling J.destroy on each cached bp, obj, body, and shape before removing the
entry. Keep unrelated worlds’ cached filters intact and perform cleanup before
releasing the world’s Jolt resources.

Comment on lines +30 to +343
#[wasm_bindgen]
pub fn bloom_mesh_scratch_reset() {
engine().models.mesh_scratch_reset();
}

#[wasm_bindgen]
pub fn bloom_mesh_scratch_push_f32(v: f64) {
engine().models.mesh_scratch_push_f32(v as f32);
}

#[wasm_bindgen]
pub fn bloom_mesh_scratch_push_u32(v: f64) {
engine().models.mesh_scratch_push_u32(v as u32);
}

#[wasm_bindgen]
pub fn bloom_create_mesh_scratch(vertex_count: f64, index_count: f64) -> f64 {
engine()
.models
.create_mesh_from_scratch(vertex_count as u32, index_count as u32)
}

#[wasm_bindgen]
pub fn bloom_gen_mesh_spline_ribbon_scratch(point_count: f64, width_count: f64) -> f64 {
let n = point_count as usize;
let wn = width_count as usize;
let (points, widths) = {
let scratch = engine().models.scratch_floats();
if n < 2 || wn == 0 || scratch.len() < n * 3 + wn {
return 0.0;
}
(
scratch[..n * 3].to_vec(),
scratch[n * 3..n * 3 + wn].to_vec(),
)
};
engine().models.gen_mesh_spline_ribbon(&points, &widths)
}

#[wasm_bindgen]
pub fn bloom_create_instance_buffer_scratch(instance_count: f64) -> f64 {
let eng = engine();
let count = instance_count as u32;
let need = (count as usize) * 9;
if count == 0 || eng.models.scratch_f32.len() < need {
return 0.0;
}
let data: Vec<f32> = eng.models.scratch_f32[..need].to_vec();
eng.models.mesh_scratch_reset();
eng.renderer.create_instance_buffer(&data, count) as f64
}

/// Params arrive via the mesh scratch (reset + push_f32 × N) — the idiom the
/// TS `setMaterialParams` wrapper uses everywhere (Perry 0.5.x rejects JS
/// arrays in pointer params). Forwards to the existing floats path.
#[wasm_bindgen]
pub fn bloom_set_material_params_scratch(handle: f64, param_count: f64) {
let count = param_count as usize;
let params: Vec<f32> = {
let eng = engine();
if eng.models.scratch_f32.len() < count {
return;
}
let p = eng.models.scratch_f32[..count].to_vec();
eng.models.mesh_scratch_reset();
p
};
crate::material_ffi::bloom_set_material_params_floats(handle, &params);
}

// ============================================================
// Texture arrays (mirrors ffi_core/game_loop.rs)
// ============================================================

#[wasm_bindgen]
pub fn bloom_create_texture_array_scratch(
width: f64,
height: f64,
layer_count: f64,
format: f64,
mip_levels: f64,
) -> f64 {
let w = width as u32;
let h = height as u32;
if w == 0 || h == 0 {
return 0.0;
}
let layers_count = (layer_count as u32)
.min(bloom_shared::renderer::material_system::MAX_TEXTURE_ARRAY_LAYERS);
if layers_count == 0 {
return 0.0;
}

let texels = (w as usize) * (h as usize) * (layers_count as usize);
// Scoped so the scratch borrow ends before the renderer borrow.
let bytes: Vec<u8> = {
let eng = engine();
if eng.models.scratch_u32.len() < texels {
// Short buffer: refuse rather than upload uninitialised memory.
return 0.0;
}
eng.models.scratch_u32[..texels]
.iter()
.flat_map(|p| p.to_le_bytes())
.collect()
};
let layer_size = (w as usize) * (h as usize) * 4;
let mut layers: Vec<(&[u8], u32, u32)> = Vec::with_capacity(layers_count as usize);
for i in 0..(layers_count as usize) {
let start = i * layer_size;
let end = start + layer_size;
if end > bytes.len() {
break;
}
layers.push((&bytes[start..end], w, h));
}
engine()
.renderer
.create_texture_array_ex(&layers, format as u32, mip_levels as u32) as f64
}

// The from-files path can't read files on wasm, so it is split: JS glue
// fetches each path in the comma-separated list and pushes the raw file
// bytes here (decoded engine-side, same as native's `image::open`), then
// commits. Layers must share dimensions; first file's size wins.
thread_local! {
static TEXARRAY_FILES: RefCell<Vec<(Vec<u8>, u32, u32)>> = const { RefCell::new(Vec::new()) };
}

#[wasm_bindgen]
pub fn bloom_texture_array_files_reset() {
TEXARRAY_FILES.with(|f| f.borrow_mut().clear());
}

#[wasm_bindgen]
pub fn bloom_texture_array_files_push(data: &[u8]) -> f64 {
match bloom_shared::textures::TextureManager::decode_rgba8(data) {
Some((bytes, w, h)) => {
TEXARRAY_FILES.with(|f| f.borrow_mut().push((bytes, w, h)));
1.0
}
None => 0.0,
}
}

#[wasm_bindgen]
pub fn bloom_texture_array_files_commit(format: f64, mip_levels: f64) -> f64 {
let decoded = TEXARRAY_FILES.with(|f| f.borrow_mut().split_off(0));
if decoded.is_empty() {
return 0.0;
}
let (w, h) = (decoded[0].1, decoded[0].2);
let mut layers: Vec<(&[u8], u32, u32)> = Vec::with_capacity(decoded.len());
for (bytes, lw, lh) in decoded.iter() {
if *lw != w || *lh != h {
crate::console_warn(&format!(
"[texarray] layer size {}x{} != {}x{}; skipped",
lw, lh, w, h
));
continue;
}
layers.push((bytes.as_slice(), w, h));
}
if layers.is_empty() {
return 0.0;
}
engine()
.renderer
.create_texture_array_ex(&layers, format as u32, mip_levels as u32) as f64
}

// ============================================================
// Staged model loading (bytes half; JS glue fetches the file)
// ============================================================

#[wasm_bindgen]
pub fn bloom_stage_model_bytes(data: &[u8]) -> f64 {
match bloom_shared::models::load_gltf_staged(data) {
Some(staged) => bloom_shared::staging::stage_model(staged),
None => 0.0,
}
}

// ============================================================
// Environment / water / scene odds and ends
// ============================================================

#[cfg(feature = "image-extras")]
#[wasm_bindgen]
pub fn bloom_set_env_clear_from_hdr_bytes(data: &[u8]) {
engine().renderer.set_env_clear_from_hdr_bytes(data);
}

#[wasm_bindgen]
pub fn bloom_splat_impulse(x: f64, z: f64, radius: f64, strength: f64) {
engine().renderer.impulse_field.submit_splat(
x as f32,
z as f32,
radius as f32,
strength as f32,
);
}

#[wasm_bindgen]
pub fn bloom_scene_set_gi_only(handle: f64, gi_only: f64) {
engine().scene.set_gi_only(handle, gi_only != 0.0);
}

// ============================================================
// Profiler text (mirrors ffi_core/game_loop.rs; GPU column is 0 on
// web — no TIMESTAMP_QUERY in the WebGPU spec at wgpu 29)
// ============================================================

#[wasm_bindgen]
pub fn bloom_profiler_overlay_text() -> String {
let snap = engine().profiler.snapshot();
let mut s = String::with_capacity(snap.len() * 48);
for (label, cpu, gpu) in &snap {
s.push_str(label);
s.push('|');
s.push_str(&format!("{:.2}", cpu));
s.push('|');
match gpu {
Some(g) => s.push_str(&format!("{:.2}", g)),
None => s.push_str("-1"),
}
s.push('\n');
}
s
}

#[wasm_bindgen]
pub fn bloom_profiler_frame_history() -> String {
let hist = engine().profiler.frame_history();
let mut s = String::with_capacity(hist.len() * 24);
for (cpu, gpu) in &hist {
s.push_str(&format!("{:.2}|{:.2}\n", cpu, gpu));
}
s
}

#[wasm_bindgen]
pub fn bloom_profiler_row_count() -> f64 {
engine().profiler.snapshot().len() as f64
}

#[wasm_bindgen]
pub fn bloom_profiler_row_label(i: f64) -> String {
engine()
.profiler
.snapshot()
.get(i as usize)
.map(|(label, _, _)| label.to_string())
.unwrap_or_default()
}

#[wasm_bindgen]
pub fn bloom_profiler_row_cpu_us(i: f64) -> f64 {
engine()
.profiler
.snapshot()
.get(i as usize)
.map(|r| r.1)
.unwrap_or(0.0)
}

#[wasm_bindgen]
pub fn bloom_profiler_row_gpu_us(i: f64) -> f64 {
match engine().profiler.snapshot().get(i as usize) {
Some((_, _, Some(g))) => *g,
_ => -1.0,
}
}

#[wasm_bindgen]
pub fn bloom_profiler_hist_count() -> f64 {
engine().profiler.frame_history().len() as f64
}

#[wasm_bindgen]
pub fn bloom_profiler_hist_cpu_us(i: f64) -> f64 {
engine()
.profiler
.frame_history()
.get(i as usize)
.map(|(cpu, _)| *cpu)
.unwrap_or(0.0)
}

#[wasm_bindgen]
pub fn bloom_profiler_hist_gpu_us(i: f64) -> f64 {
engine()
.profiler
.frame_history()
.get(i as usize)
.map(|(_, gpu)| *gpu)
.unwrap_or(0.0)
}

// ============================================================
// Host stubs — features a browser page cannot provide, exported so
// the calls stay cheap typed no-ops
// ============================================================

/// The browser owns presentation (rAF + compositor); Fifo-equivalent
/// behaviour is all a canvas surface can do, so the mode is fixed.
#[wasm_bindgen]
pub fn bloom_set_present_mode(_mode: f64) {}

/// Screenshot readback needs a blocking `device.poll(Wait)`, which a
/// single-threaded wasm host cannot do. Right-click-save or the DOM
/// `canvas.toBlob` path (from JS) are the web equivalents.
#[wasm_bindgen]
pub fn bloom_take_screenshot(_path: f64) {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd package.json | while read -r f; do echo "=== $f ==="; jq -r '.perry.nativeLibrary.functions[]?' "$f" 2>/dev/null; done > /tmp/declared_fns.txt
cat /tmp/declared_fns.txt | sort -u > /tmp/declared_sorted.txt
for fn in bloom_mesh_scratch_reset bloom_mesh_scratch_push_f32 bloom_mesh_scratch_push_u32 \
  bloom_create_mesh_scratch bloom_gen_mesh_spline_ribbon_scratch bloom_create_instance_buffer_scratch \
  bloom_set_material_params_scratch bloom_create_texture_array_scratch bloom_texture_array_files_reset \
  bloom_texture_array_files_push bloom_texture_array_files_commit bloom_stage_model_bytes \
  bloom_set_env_clear_from_hdr_bytes bloom_splat_impulse bloom_scene_set_gi_only \
  bloom_profiler_overlay_text bloom_profiler_frame_history bloom_profiler_row_count \
  bloom_profiler_row_label bloom_profiler_row_cpu_us bloom_profiler_row_gpu_us \
  bloom_profiler_hist_count bloom_profiler_hist_cpu_us bloom_profiler_hist_gpu_us \
  bloom_set_present_mode bloom_take_screenshot bloom_inject_mouse_move bloom_inject_mouse_delta \
  bloom_inject_mouse_button_down bloom_inject_mouse_button_up bloom_inject_mouse_wheel \
  bloom_inject_touch bloom_inject_touch_release bloom_inject_char; do
  grep -qx "$fn" /tmp/declared_sorted.txt && echo "OK: $fn" || echo "MISSING: $fn"
done

Repository: Bloom-Engine/engine

Length of output: 1394


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== package.json files =="
fd -a '^package\.json$' .

echo
echo "== perry.nativeLibrary.functions declarations =="
rg -n --hidden --glob 'package.json' '"nativeLibrary"|perry\.nativeLibrary\.functions|bloom_(mesh|create|gen|texture_array|stage_model|set_env|splat|scene|profiler|set_present_mode|take_screenshot|inject_)' .

echo
echo "== native/web/src/parity_ffi.rs exports =="
rg -n '^\s*#\[wasm_bindgen\]\s*$|^\s*pub fn bloom_' native/web/src/parity_ffi.rs

echo
echo "== native/web/src/input_ffi.rs exports =="
rg -n '^\s*#\[wasm_bindgen\]\s*$|^\s*pub fn bloom_' native/web/src/input_ffi.rs

Repository: Bloom-Engine/engine

Length of output: 11796


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "package.json files:"
fd -a '^package\.json$' .

echo
echo "manifest key locations:"
rg -n --hidden --glob 'package.json' 'perry\.nativeLibrary\.functions|nativeLibrary|bloom_mesh_scratch_reset|bloom_inject_mouse_move' .

Repository: Bloom-Engine/engine

Length of output: 1545


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== root package.json: nativeLibrary section =="
sed -n '98,220p' package.json

echo
echo "== root package.json: relevant export entries around parity/input =="
sed -n '980,1160p' package.json
echo
sed -n '1518,1655p' package.json
echo
sed -n '1968,2020p' package.json
echo
sed -n '2368,2415p' package.json

Repository: Bloom-Engine/engine

Length of output: 11867


🏁 Script executed:

#!/bin/bash
set -euo pipefail

names=(
  bloom_mesh_scratch_reset
  bloom_mesh_scratch_push_f32
  bloom_mesh_scratch_push_u32
  bloom_create_mesh_scratch
  bloom_gen_mesh_spline_ribbon_scratch
  bloom_create_instance_buffer_scratch
  bloom_set_material_params_scratch
  bloom_create_texture_array_scratch
  bloom_texture_array_files_reset
  bloom_texture_array_files_push
  bloom_texture_array_files_commit
  bloom_stage_model_bytes
  bloom_set_env_clear_from_hdr_bytes
  bloom_splat_impulse
  bloom_scene_set_gi_only
  bloom_profiler_overlay_text
  bloom_profiler_frame_history
  bloom_profiler_row_count
  bloom_profiler_row_label
  bloom_profiler_row_cpu_us
  bloom_profiler_row_gpu_us
  bloom_profiler_hist_count
  bloom_profiler_hist_cpu_us
  bloom_profiler_hist_gpu_us
  bloom_set_present_mode
  bloom_take_screenshot
  bloom_inject_mouse_move
  bloom_inject_mouse_delta
  bloom_inject_mouse_button_down
  bloom_inject_mouse_button_up
  bloom_inject_mouse_wheel
  bloom_inject_touch
  bloom_inject_touch_release
  bloom_inject_char
)

for n in "${names[@]}"; do
  if rg -n --hidden --glob 'package.json' "\"name\": \"$n\"" . >/dev/null; then
    echo "PRESENT $n"
  else
    echo "MISSING $n"
  fi
done

Repository: Bloom-Engine/engine

Length of output: 1360


Add the remaining web FFI manifest entries. native/web/src/parity_ffi.rs#L159-L236 still adds bloom_texture_array_files_reset/push/commit, bloom_stage_model_bytes, and bloom_set_env_clear_from_hdr_bytes without package.json entries; native/web/src/input_ffi.rs#L179-L237 adds the new bloom_inject_* wrappers as well. The other new exports in this patch are already declared.

📍 Affects 2 files
  • native/web/src/parity_ffi.rs#L30-L343 (this comment)
  • native/web/src/input_ffi.rs#L188-L240
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@native/web/src/parity_ffi.rs` around lines 30 - 343, The web FFI manifest is
missing declarations for newly exported texture-array, staged-model, HDR
environment, and input-injection functions. Update package.json to add entries
for bloom_texture_array_files_reset, bloom_texture_array_files_push,
bloom_texture_array_files_commit, bloom_stage_model_bytes,
bloom_set_env_clear_from_hdr_bytes, and every bloom_inject_* wrapper in
native/web/src/input_ffi.rs:188-240; no direct source changes are needed in
either Rust file.

Source: Coding guidelines

Comment on lines +63 to +146
// --- bodies (layer 1 = MOVING, motion type 2 = DYNAMIC)
let mut bodies: Vec<f64> = Vec::with_capacity(builds.len());
for b in builds.iter() {
let shape = jb_shape_capsule(b.half_height as f64, b.radius as f64);
if shape == 0.0 {
bodies.push(0.0);
continue;
}
let q = bloom_shared::ragdoll::quat_from_mat(&b.world);
let body = jb_body_create(
world,
shape,
2.0,
b.world[3][0] as f64,
b.world[3][1] as f64,
b.world[3][2] as f64,
q[0] as f64,
q[1] as f64,
q[2] as f64,
q[3] as f64,
1.0,
);
if body != 0.0 {
jb_body_set_friction(body, 0.8); // corpses do not skate
jb_body_set_restitution(body, 0.02); // they do not bounce
jb_body_set_linear_damping(body, 0.20);
// Angular damping does most of the work of making this read as a
// body rather than a rag.
jb_body_set_angular_damping(body, 0.75);
}
bodies.push(body);
}

// --- joints. Tight-ish; twist (y) held hardest — an over-twisted limb is
// the single most obviously WRONG thing a ragdoll can do.
let rot_limits: [f64; 6] = [-0.8, 0.8, -0.25, 0.25, -0.8, 0.8];
let mut constraints: Vec<f64> = Vec::new();
for (i, b) in builds.iter().enumerate() {
if b.parent_bone == usize::MAX {
continue;
}
let pa = bodies.get(b.parent_bone).copied().unwrap_or(0.0);
let pb = bodies.get(i).copied().unwrap_or(0.0);
if pa == 0.0 || pb == 0.0 {
continue;
}
let c = jb_constraint_six_dof(
pa,
pb,
b.anchor[0] as f64,
b.anchor[1] as f64,
b.anchor[2] as f64,
b.anchor[0] as f64,
b.anchor[1] as f64,
b.anchor[2] as f64,
rot_limits[0],
rot_limits[1],
rot_limits[2],
rot_limits[3],
rot_limits[4],
rot_limits[5],
1.0, // anchors given in world space
);
if c != 0.0 {
constraints.push(c);
}
}

let Some(a) = eng.models.get_animation(anim) else {
return 0.0;
};
let Some(r) = eng.ragdolls.get_mut(rag as u32) else {
return 0.0;
};
r.attach(
&builds,
&bodies,
constraints,
a,
scale as f32,
[px as f32, py as f32, pz as f32],
rot_y as f32,
);
1.0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== File outline ==\n'
ast-grep outline native/web/src/ragdoll_ffi.rs --view expanded || true

printf '\n== Relevant lines in native/web/src/ragdoll_ffi.rs ==\n'
nl -ba native/web/src/ragdoll_ffi.rs | sed -n '1,260p'

printf '\n== Search for ragdoll attach / cleanup ==\n'
rg -n "fn attach|struct Ragdoll|destroy|jb_body_destroy|jb_constraint_destroy|ragdoll" native/web/src native -g '!**/target/**' || true

Repository: Bloom-Engine/engine

Length of output: 576


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== ragdoll attach implementation ==\n'
rg -n "fn attach\\(|impl .*attach|struct Ragdoll|destroy.*body|destroy.*constraint|jb_body_destroy|jb_constraint_destroy" native/web/src native -g '!**/target/**' || true

printf '\n== file list around ragdoll code ==\n'
fd -a "ragdoll" native/web/src native -t f || true

Repository: Bloom-Engine/engine

Length of output: 3997


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== native/web/src/ragdoll_ffi.rs (lines 1-280) ==\n'
sed -n '1,280p' native/web/src/ragdoll_ffi.rs | cat -n

printf '\n== native/shared/src/ragdoll.rs (attach/release-related sections) ==\n'
sed -n '1,360p' native/shared/src/ragdoll.rs | cat -n

printf '\n== native/shared/src/ffi_core/ragdoll_ffi.rs (activation/release sections) ==\n'
sed -n '1,320p' native/shared/src/ffi_core/ragdoll_ffi.rs | cat -n

Repository: Bloom-Engine/engine

Length of output: 40111


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== native/shared/src/ragdoll.rs (190-260) ==\n'
sed -n '190,260p' native/shared/src/ragdoll.rs | cat -n

printf '\n== native/web/src/ragdoll_ffi.rs (1-280) ==\n'
sed -n '1,280p' native/web/src/ragdoll_ffi.rs | cat -n

printf '\n== native/shared/src/ffi_core/ragdoll_ffi.rs (200-260) ==\n'
sed -n '200,260p' native/shared/src/ffi_core/ragdoll_ffi.rs | cat -n

Repository: Bloom-Engine/engine

Length of output: 15826


Make ragdoll activation transactional. Validate rag before creating bodies/constraints, and destroy any handles created in this call if a body or joint creation fails. As written, a bad handle leaks physics objects, and zero-valued bodies/constraints still produce an active partial ragdoll.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@native/web/src/ragdoll_ffi.rs` around lines 63 - 146, Make the ragdoll setup
transactional before the body-creation loop: validate the `rag` handle and
required animation before creating physics objects. Treat any failed
`jb_body_create` or `jb_constraint_six_dof` result as a setup failure, destroy
every body and constraint created during this call, and return `0.0`; only call
`r.attach` after all handles are valid so no partial ragdoll becomes active.

The web crate hand-mirrors the shared define_core_ffi! macro, and
"name coverage only" in the validator let real drift accumulate. Found
by porting a 3D game: trees rendered as torn opaque sheets and every
character animated wrongly, while native was correct.

Renders (the user-visible three):

- bloom_draw_model_rotated still used the OLD immediate path
  (draw_model_mesh_tinted_rotated): no alpha cutout, no materials, no
  wind, CPU-transformed verts. Round 5 moved native to the cached scene
  pipeline precisely because that path renders cutout foliage as opaque
  cards; the web copy never got it. Now mirrors native, including the
  skinned branch.
- bloom_draw_model never checked is_model_skinned, so skinned models took
  the UNSKINNED cached draw: each primitive read joint offset 0 — another
  model's matrices — instead of the model's own staged pose. That is why
  characters didn't animate correctly on web.
- Non-sRGB surfaces (every WebGPU canvas offers only bgra8/rgba8unorm)
  displayed the whole game about a stop dark: the tonemapper writes LINEAR
  and relies on hardware encode-on-store into an sRGB target, which native
  surfaces provide. The renderer now separates `output_format` (what frames
  are RENDERED in — an sRGB VIEW of the swapchain, registered via
  view_formats) from surface_config.format (what the surface is CONFIGURED
  with). Native is unchanged: there output_format == surface_config.format.

Also drifted (found by an audit of all 330 native vs 376 web exports):

- bloom_get_gamepad_axis / is_gamepad_button_{pressed,down,released} /
  is_gamepad_available / get_gamepad_axis_count carried a leading `gamepad`
  param the manifest never declared: the game's single argument landed in
  it, the real one arrived undefined -> NaN -> `as usize` = 0. Every axis
  read axis 0, every button read button 0.
- bloom_get_touch_x/_y dropped their index and hardcoded slot 0, so every
  finger reported the first finger's coordinates while is_touch_active(i)
  honoured the index — both thumbsticks tracked one thumb.
- bloom_inject_key_down/_up wrote keys_down directly instead of staging via
  inject_key_* , so the edge was folded into `prev` before begin_frame
  computed it and isKeyPressed never fired.
- bloom_scene_attach_model dropped set_material_pbr + alpha_cutoff:
  attached foliage opaque, everything pinned at roughness 0.8.
- bloom_scene_set_transform16's 17-arg body was wired to the 2-arg name
  bloom_scene_set_transform, so setSceneNodeTransform resolved to nothing
  and Perry's FFI proxy auto-stubbed it — a silent no-op with the working
  implementation one rename away.
- bloom_update_model_animation was an empty stub carrying the pre-fix 9-arg
  (rot_sin, rot_cos) signature native abandoned.
- bloom_unload_model skipped evict_model_cache: leaked GPU buffers, and a
  slot-reusing model rendered stale geometry.
- bloom_physics_vehicle_create declared a 38th param (trailing, so nothing
  shifted; it arrived NaN).

Root cause, fixed: tools/validate-ffi.js checked arity on all 7 native
platforms but web was "name coverage only". It now checks arity for the 452
all-f64 mirror functions — signatures whose web ABI is deliberately
different (_str/_bytes/_floats slices) are skipped, which is exactly where
drift is a bug rather than a design. The gap allowlist was stale too (it
listed 29 functions this branch implements); pruned 41 -> 13 honest gaps.

Engine diagnostics now reach the browser: log_error's eprintln! is a black
hole on wasm, so every engine warning — missing texture-array layers,
surface-acquire failures, feature-off warnings — was silently dropped on
the one platform whose users cannot attach a debugger. Routed to
console.error.

WGSL: the depth prepass is skipped on wasm (its Equal-test pairing needs
bit-identical depths from two pipelines, which Tint does not guarantee
through the foliage-wind chain); game materials get @invariant on their
clip position.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
native/shared/src/renderer/scene_pass.rs (1)

78-88: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid creating unused prepass pipelines on Web.

Since scene_depth_pipeline and scene_pipeline_prepassed are intentionally skipped during the render loop on wasm32, their initialization in mod.rs (which appears to be unconditional based on the context snippets) is wasted effort. Compiling heavy unused WGSL shaders into pipelines on the web adds significant startup latency and wastes memory.

Consider applying #[cfg(not(target_arch = "wasm32"))] to the corresponding pipeline fields in Renderer and skipping their creation entirely on the web target.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@native/shared/src/renderer/scene_pass.rs` around lines 78 - 88, Conditionally
compile the prepass-only Renderer fields scene_depth_pipeline and
scene_pipeline_prepassed, along with their initialization in Renderer
construction, using #[cfg(not(target_arch = "wasm32"))]. Keep all non-WASM
render-loop usage gated consistently so Web builds neither create nor reference
these unused pipelines.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tools/validate-ffi.js`:
- Around line 169-184: Strip line and block comments from the Rust source and
Jolt bridge contents before the regex scans in the validation flow. Use the
cleaned Rust text for both bloom function matches and signature parsing, and the
cleaned bridge text for physics export detection, preserving string literals as
needed so commented-out functions and exports are ignored while parameter
comments do not affect allF64 or arity.

---

Nitpick comments:
In `@native/shared/src/renderer/scene_pass.rs`:
- Around line 78-88: Conditionally compile the prepass-only Renderer fields
scene_depth_pipeline and scene_pipeline_prepassed, along with their
initialization in Renderer construction, using #[cfg(not(target_arch =
"wasm32"))]. Keep all non-WASM render-loop usage gated consistently so Web
builds neither create nor reference these unused pipelines.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e2414a57-7674-44c4-93d6-2d56e07f33f3

📥 Commits

Reviewing files that changed from the base of the PR and between 643037d and 49eb1a2.

⛔ Files ignored due to path filters (2)
  • native/web/Cargo.lock is excluded by !**/*.lock
  • native/windows/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (9)
  • native/shared/Cargo.toml
  • native/shared/src/ffi.rs
  • native/shared/src/renderer/mod.rs
  • native/shared/src/renderer/scene_pass.rs
  • native/web/src/input_ffi.rs
  • native/web/src/lib.rs
  • native/web/src/material_ffi.rs
  • native/web/src/physics_ffi.rs
  • tools/validate-ffi.js

Comment thread tools/validate-ffi.js
Comment on lines 169 to 184
const webSrc = readDirRust(path.join(ROOT, 'native/web/src'));
const names = new Set();
// name -> {arity, allF64} for the Rust exports (the jolt bridge's JS
// functions are name-only; physics arity is covered by its own manifest
// generation).
const webSigs = new Map();
for (const m of webSrc.matchAll(/pub (?:async )?fn (bloom_[a-z0-9_]+)\s*\(([^)]*)\)/g)) {
const [, fname, params] = m;
names.add(fname);
const parts = params.split(',').map((s) => s.trim()).filter(Boolean);
const allF64 = parts.every((p) => /:\s*f64$/.test(p));
webSigs.set(fname, { arity: parts.length, allF64 });
}
for (const m of webSrc.matchAll(/pub (?:async )?fn (bloom_[a-z0-9_]+)/g)) names.add(m[1]);
const bridge = fs.readFileSync(path.join(ROOT, 'native/web/jolt_bridge.js'), 'utf8');
for (const m of bridge.matchAll(/(bloom_physics_[a-z0-9_]+)\s*[:(=]/g)) names.add(m[1]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Strip comments before parsing to prevent regex misfires.

The current raw string parsing has three failure modes related to source code comments:

  1. False CI failures: A commented-out function (e.g., // pub fn bloom_old(...)) will be matched and can overwrite the correct signature in the webSigs map if it appears later in the file.
  2. Silent validation bypasses: Trailing comments within the parameter list (e.g., x: f64 // parameter desc) will cause the allF64 check to fail because the captured string ends with the comment text, not f64.
  3. Masked missing exports: Commented-out lines in jolt_bridge.js will incorrectly add names to the names set, making the validator believe a missing export is implemented.

I recommend stripping line and block comments from the source strings before running the regex matches.

💻 Proposed fix
-  const webSrc = readDirRust(path.join(ROOT, 'native/web/src'));
+  const webSrc = readDirRust(path.join(ROOT, 'native/web/src'))
+    .replace(/\/\*[\s\S]*?\*\//g, '')
+    .replace(/\/\/.*/g, '');
   const names = new Set();
   // name -> {arity, allF64} for the Rust exports (the jolt bridge's JS
   // functions are name-only; physics arity is covered by its own manifest
   // generation).
   const webSigs = new Map();
   for (const m of webSrc.matchAll(/pub (?:async )?fn (bloom_[a-z0-9_]+)\s*\(([^)]*)\)/g)) {
     const [, fname, params] = m;
     names.add(fname);
     const parts = params.split(',').map((s) => s.trim()).filter(Boolean);
     const allF64 = parts.every((p) => /:\s*f64$/.test(p));
     webSigs.set(fname, { arity: parts.length, allF64 });
   }
   for (const m of webSrc.matchAll(/pub (?:async )?fn (bloom_[a-z0-9_]+)/g)) names.add(m[1]);
-  const bridge = fs.readFileSync(path.join(ROOT, 'native/web/jolt_bridge.js'), 'utf8');
+  const bridge = fs.readFileSync(path.join(ROOT, 'native/web/jolt_bridge.js'), 'utf8')
+    .replace(/\/\*[\s\S]*?\*\//g, '')
+    .replace(/\/\/.*/g, '');
   for (const m of bridge.matchAll(/(bloom_physics_[a-z0-9_]+)\s*[:(=]/g)) names.add(m[1]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const webSrc = readDirRust(path.join(ROOT, 'native/web/src'));
const names = new Set();
// name -> {arity, allF64} for the Rust exports (the jolt bridge's JS
// functions are name-only; physics arity is covered by its own manifest
// generation).
const webSigs = new Map();
for (const m of webSrc.matchAll(/pub (?:async )?fn (bloom_[a-z0-9_]+)\s*\(([^)]*)\)/g)) {
const [, fname, params] = m;
names.add(fname);
const parts = params.split(',').map((s) => s.trim()).filter(Boolean);
const allF64 = parts.every((p) => /:\s*f64$/.test(p));
webSigs.set(fname, { arity: parts.length, allF64 });
}
for (const m of webSrc.matchAll(/pub (?:async )?fn (bloom_[a-z0-9_]+)/g)) names.add(m[1]);
const bridge = fs.readFileSync(path.join(ROOT, 'native/web/jolt_bridge.js'), 'utf8');
for (const m of bridge.matchAll(/(bloom_physics_[a-z0-9_]+)\s*[:(=]/g)) names.add(m[1]);
const webSrc = readDirRust(path.join(ROOT, 'native/web/src'))
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/.*/g, '');
const names = new Set();
// name -> {arity, allF64} for the Rust exports (the jolt bridge's JS
// functions are name-only; physics arity is covered by its own manifest
// generation).
const webSigs = new Map();
for (const m of webSrc.matchAll(/pub (?:async )?fn (bloom_[a-z0-9_]+)\s*\(([^)]*)\)/g)) {
const [, fname, params] = m;
names.add(fname);
const parts = params.split(',').map((s) => s.trim()).filter(Boolean);
const allF64 = parts.every((p) => /:\s*f64$/.test(p));
webSigs.set(fname, { arity: parts.length, allF64 });
}
for (const m of webSrc.matchAll(/pub (?:async )?fn (bloom_[a-z0-9_]+)/g)) names.add(m[1]);
const bridge = fs.readFileSync(path.join(ROOT, 'native/web/jolt_bridge.js'), 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/.*/g, '');
for (const m of bridge.matchAll(/(bloom_physics_[a-z0-9_]+)\s*[:(=]/g)) names.add(m[1]);
🧰 Tools
🪛 ast-grep (0.44.1)

[warning] 182-182: Filesystem path is not a string literal; a request-/variable-derived path can enable path traversal. Validate and normalize the path before use.
Context: fs.readFileSync(path.join(ROOT, 'native/web/jolt_bridge.js'), 'utf8')
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').

(detect-non-literal-fs-filename)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/validate-ffi.js` around lines 169 - 184, Strip line and block comments
from the Rust source and Jolt bridge contents before the regex scans in the
validation flow. Use the cleaned Rust text for both bloom function matches and
signature parsing, and the cleaned bridge text for physics export detection,
preserving string literals as needed so commented-out functions and exports are
ignored while parameter comments do not affect allF64 or arity.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Heads-up, two things from the EN-063 build-web fix (PR #119):

  1. Ticket-number collision: docs/tickets.md registered EN-063 yesterday (PR docs: 2026-07-16 full audit - sync every doc to as-built engine reality #117) for the build-web regression, which PR fix(web): EN-063 - bloom-shared compiles without models3d again #119 now fixes. This PR's commits also use EN-063 for the web-3D shooter port — it needs the next free number (EN-064) before merge so the registry stays coherent.

  2. Your CodeRabbit-review commit fa8e9d4 (Jolt bridge + FFI validator fixes) accidentally landed on PR fix(web): EN-063 - bloom-shared compiles without models3d again #119's branch and was removed in its rebuild — it's preserved on rescue/fa8e9d4-coderabbit-jolt-fix, ready to cherry-pick here.

  3. FYI: this branch currently grows renderer/mod.rs past its grandfathered 13,058 lines and material_system.rs past the 2,000 limit — the file-line gate will fail until they're split or shrunk. And note PR fix(web): EN-063 - bloom-shared compiles without models3d again #119's models.rs split (new models_gltf.rs, models3d-gated) will need a rebase here since both touch models.rs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant