Sandbox v2: world, spawn fix, test-gun mod, and interaction/physics fixes #32

Merged
merith-tk merged 29 commits from muse-lantern/da-game:sandbox-v2 into main 2026-11-08 21:17:25 +00:00
Member

What this is

The sandbox-v2 branch: the sandbox becomes a real, testable playground — a world with a skybox, a fence, solid props, grabbable primitives, a firing range with NPC-entity dummies, a working spawn button with a real test-gun mod, and two rounds of interaction/physics fixes driven by headset playtesting.

Base: main (94bb04078). Head: muse-lantern:sandbox-v2 (aa1577e at filing; bridge grab-simulation tools are landing on the same branch, see below).

Contents

World slice (683882d)

  • Skybox as a level detail: skybox field on levels, applied by the loader; default resolves by claim (deadair module claims default_skybox in its manifest; a tie is a startup error naming both modules). sandbox.json sets none, exercising the default path. Default sky is a ProceduralSkyMaterial dusk gradient, generated by committed tools/make_default_sky.gd. Neither referenced external texture ships (license: keithlantz skybox provenance unknown, nicepng fence no grant — documented in-repo).
  • Fence with collisions (4 sides, posts every 4 m), primitives (cube, ball, pyramid) as real WorldItems, spawn menu moved to the side, firing range with 3 target dummies.
  • NPC Entity system exists in the deadair module's shared code (src/entities/npc_entity.gd, archetype entities/target_dummy.json): orange capsule, health 100, double-damage head sphere, floating damage numbers, darkens on death, revive(). No AI — full design is engine issue #31.
  • Props solid (crates, pillar, wall, barrel, start crate now StaticBody3D + collision).

Spawn fix + test-gun mod (2f7edfc, c0752a0)

  • Bridge tools list_items + spawn_item (minimal engine #26 slice).
  • Loud spawn handler: failures push_error and write the cause to the TitleSign.
  • Real bug fixed: GenericItem never resolved model_path into world_scene, so every JSON-authored item with a model spawned as the featureless placeholder box.
  • Test mod modules/testgun/ (damage 25.0, accepts devkit:mag/pistol_test, real .tscn with gunmetal + orange muzzle tip); SpawnButton points at testgun:weapon/test_gun.
  • Packager fix: Godot 4.6.3 --export-pack ignores the preset include filter; tools/package_module.py now builds via PCKPacker.

Solid slab + bounds gizmos (d85e5f9)

  • DebugSlab is now a StaticBody3D with collision (it was a bare mesh; primitives fell through to the floor).
  • Every grabbable gets a bounds gizmo (unshaded cyan line box) following the Gizmos toggle; items spawned after toggling pick it up.

Nitpick round 1 (8 commits, 3289a77)

  • Laser from the back of the rendered hand (grip frame); grab cone reads the same node, so they agree. Angle is a tunable rule player.pointer_angle_offset_deg [0,0,0].
  • Single-tier range grab (yank tier kept as enum + helper, no code path selects it — deferred, not deleted); 1.5 m; pull uses move_and_collide() so obstacles block it.
  • Throw: history 6→3, hand angular velocity imparted on release, gravity unchanged.
  • Shape-matched collision: collision-mesh node wins (convex hull), else sphere/cylinder/box; gizmo draws the actual collision shape wireframe.
  • Physics lock: smoothed position (EMA ~8 Hz), still for 5 s → freeze as static, wake on grab only. Rule player.item_lock_idle_seconds 5.0, all WorldItems.
  • Hands: NO code change — headless verification against the real glove model + OpenXR spec found the derivation correct; the most visible misalignment was the laser on the separate aim pose, fixed above. Headset verification still owed.
  • Gun grabbable: ModuleSpawner.spawn_item() (plain Node3D) removed; spawner returns the resolved element, callers build a real WorldItem.

Nitpick round 2 (5 commits, aa1577e)

  • Range grab 3.0 m; acquisition cone is now a wireframe gizmo per hand.
  • Throw spin halved (player.throw_spin_mult = 0.5); velocity history 3→10.
  • Gun rest: contact angular damping (2.0, only while touching) — a spinning gun now settles and locks instead of spazzing forever.
  • Cone acquisition tests the item's bounding sphere, not just its centre point (the gun's origin is offset from its visual centre, so muzzle/grip aims missed at close range).
  • Hands debug (observability only): hand_debug bridge tool + in-headset readout; derivation untouched.
  • Gun shoots: accepts fixed to the tag mag.pistol (it was the full id, so no magazine could ever be inserted); test gun now spawns loaded (15 rounds, chambered) via the new spawn_loaded property.

In flight on this branch

Bridge grab-simulation tools (engine #26 remainder: set_controller_input, grab_state, force_grab/force_drop) are being built on this branch now and will land as further commits. They exist so the round-2 ranged-grab and trigger-pull can be verified live through the bridge.

Verification (lantern-vm, Godot 4.6.3)

  • Double --import after .godot wipe: 0 ERROR both passes; tools/check.py 6/6.
  • 24/24 headless suites pass (incl. new ranged_grab_check, throw_check, physics_lock_check, gun_rest_check, firearm_check); bridge e2e passes (46 checks, 32 tools).
  • Live bridge checks: test gun spawns as a WorldItem in world_items; gun settles and locks (identical positions at 6.5 / 12.5 / 20.5 s).

Open calls (not blockers for review)

  • Claim mechanism: manifest claims field as built, or a registry entry instead?
  • Lock defaults (5.0 s, all WorldItems) and spawner layering (option (a)) — Lantern's choices, correctable.
  • Hands: headset verification of the no-change conclusion + the measurement procedure in docs/modding.md.
  • The .pck for the test gun (testgun-0.1.0.pck) is not committed; it loads from <exe>/packs/ or user://packs/.

Notes

  • Pre-existing, out of scope: HUD overlap ("objective" text over HEALTH); list_items returns each WorldItem twice.
  • opencode.json untouched; nothing on main touched by this branch.
## What this is The sandbox-v2 branch: the sandbox becomes a real, testable playground — a world with a skybox, a fence, solid props, grabbable primitives, a firing range with NPC-entity dummies, a working spawn button with a real test-gun mod, and two rounds of interaction/physics fixes driven by headset playtesting. Base: `main` (`94bb04078`). Head: `muse-lantern:sandbox-v2` (`aa1577e` at filing; bridge grab-simulation tools are landing on the same branch, see below). ## Contents **World slice (`683882d`)** - Skybox as a level detail: `skybox` field on levels, applied by the loader; default resolves by claim (deadair module claims `default_skybox` in its manifest; a tie is a startup error naming both modules). `sandbox.json` sets none, exercising the default path. Default sky is a ProceduralSkyMaterial dusk gradient, generated by committed `tools/make_default_sky.gd`. Neither referenced external texture ships (license: keithlantz skybox provenance unknown, nicepng fence no grant — documented in-repo). - Fence with collisions (4 sides, posts every 4 m), primitives (cube, ball, pyramid) as real WorldItems, spawn menu moved to the side, firing range with 3 target dummies. - NPC Entity system exists in the deadair module's shared code (`src/entities/npc_entity.gd`, archetype `entities/target_dummy.json`): orange capsule, health 100, double-damage head sphere, floating damage numbers, darkens on death, `revive()`. No AI — full design is engine issue #31. - Props solid (crates, pillar, wall, barrel, start crate now StaticBody3D + collision). **Spawn fix + test-gun mod (`2f7edfc`, `c0752a0`)** - Bridge tools `list_items` + `spawn_item` (minimal engine #26 slice). - Loud spawn handler: failures push_error and write the cause to the TitleSign. - Real bug fixed: `GenericItem` never resolved `model_path` into `world_scene`, so every JSON-authored item with a model spawned as the featureless placeholder box. - Test mod `modules/testgun/` (damage 25.0, accepts `devkit:mag/pistol_test`, real `.tscn` with gunmetal + orange muzzle tip); SpawnButton points at `testgun:weapon/test_gun`. - Packager fix: Godot 4.6.3 `--export-pack` ignores the preset include filter; `tools/package_module.py` now builds via PCKPacker. **Solid slab + bounds gizmos (`d85e5f9`)** - DebugSlab is now a StaticBody3D with collision (it was a bare mesh; primitives fell through to the floor). - Every grabbable gets a bounds gizmo (unshaded cyan line box) following the Gizmos toggle; items spawned after toggling pick it up. **Nitpick round 1 (8 commits, `3289a77`)** - Laser from the back of the rendered hand (grip frame); grab cone reads the same node, so they agree. Angle is a tunable rule `player.pointer_angle_offset_deg` [0,0,0]. - Single-tier range grab (yank tier kept as enum + helper, no code path selects it — deferred, not deleted); 1.5 m; pull uses `move_and_collide()` so obstacles block it. - Throw: history 6→3, hand angular velocity imparted on release, gravity unchanged. - Shape-matched collision: `collision-mesh` node wins (convex hull), else sphere/cylinder/box; gizmo draws the actual collision shape wireframe. - Physics lock: smoothed position (EMA ~8 Hz), still for 5 s → freeze as static, wake on grab only. Rule `player.item_lock_idle_seconds` 5.0, all WorldItems. - Hands: NO code change — headless verification against the real glove model + OpenXR spec found the derivation correct; the most visible misalignment was the laser on the separate aim pose, fixed above. Headset verification still owed. - Gun grabbable: `ModuleSpawner.spawn_item()` (plain Node3D) removed; spawner returns the resolved element, callers build a real WorldItem. **Nitpick round 2 (5 commits, `aa1577e`)** - Range grab 3.0 m; acquisition cone is now a wireframe gizmo per hand. - Throw spin halved (`player.throw_spin_mult` = 0.5); velocity history 3→10. - Gun rest: contact angular damping (2.0, only while touching) — a spinning gun now settles and locks instead of spazzing forever. - Cone acquisition tests the item's bounding sphere, not just its centre point (the gun's origin is offset from its visual centre, so muzzle/grip aims missed at close range). - Hands debug (observability only): `hand_debug` bridge tool + in-headset readout; derivation untouched. - Gun shoots: `accepts` fixed to the tag `mag.pistol` (it was the full id, so no magazine could ever be inserted); test gun now spawns loaded (15 rounds, chambered) via the new `spawn_loaded` property. ## In flight on this branch Bridge grab-simulation tools (engine #26 remainder: `set_controller_input`, `grab_state`, `force_grab`/`force_drop`) are being built on this branch now and will land as further commits. They exist so the round-2 ranged-grab and trigger-pull can be verified live through the bridge. ## Verification (lantern-vm, Godot 4.6.3) - Double `--import` after `.godot` wipe: 0 ERROR both passes; `tools/check.py` 6/6. - 24/24 headless suites pass (incl. new `ranged_grab_check`, `throw_check`, `physics_lock_check`, `gun_rest_check`, `firearm_check`); bridge e2e passes (46 checks, 32 tools). - Live bridge checks: test gun spawns as a WorldItem in `world_items`; gun settles and locks (identical positions at 6.5 / 12.5 / 20.5 s). ## Open calls (not blockers for review) - Claim mechanism: manifest `claims` field as built, or a registry entry instead? - Lock defaults (5.0 s, all WorldItems) and spawner layering (option (a)) — Lantern's choices, correctable. - Hands: headset verification of the no-change conclusion + the measurement procedure in `docs/modding.md`. - The `.pck` for the test gun (`testgun-0.1.0.pck`) is not committed; it loads from `<exe>/packs/` or `user://packs/`. ## Notes - Pre-existing, out of scope: HUD overlap ("objective" text over HEALTH); `list_items` returns each WorldItem twice. - `opencode.json` untouched; nothing on main touched by this branch.
Sandbox v2 world slice (no spawn fix, no test mod — those come next):
- Skybox is a level detail: levels gain an optional skybox field
  (LevelSpec/LevelElement/LevelRegistry); the loader applies it, and
  when absent applies the module-claimed default skybox. Claims are
  a new manifest field (claims: {name: path}); exactly one module
  may claim a name, a tie is a startup error naming both. deadair
  claims default_skybox -> assets/skybox/default_sky.tres, a
  procedural dusk sky (the keithlantz texture is unlicensed, see
  the README in assets/skybox/).
- Fence around the platform (Fence, parametric, 4 StaticBody3D
  sides with posts + rails; chain-link infill held for a licensed
  texture).
- Three primitive grabbables (cube/ball/pyramid, real WorldItems
  on the grab layers) replace the three colored cubes; WorldItem
  gains the pyramid stand-in shape.
- Firing range with 3 target dummies: NPCEntity (deadair shared
  code) with Health + floating damage numbers, archetype
  deadair:entity/target_dummy (orange capsule, 100 HP).
- Props (crates, pillar, wall, barrel, story start crate) are
  StaticBody3D with colliders; spawn menu moved off to the side
  (4.5, 1.3, -6), facing the spawn point.
- tests/sandbox_world_check.gd (59 checks, incl. claim-tie
  negative control) added to the CI list; module_check item
  count 6 -> 9 (the 3 new primitives).
Bridge: list_items + spawn_item (minimal engine #26 slice);
spawn_item returns the node path or a failure reason.
Loud handler: _on_sandbox_action push_errors and shows the result
on the TitleSign on every early return and null spawn; SandboxMenu
logs its spawn target on ready.
Root cause: GenericItem never resolved model_path into world_scene
on the registry element, so ModuleSpawner (which reads world_scene
off the element) always spawned the placeholder box for JSON-authored
items with a model. The element now resolves it at load, matching
to_item_definition().
New tests/spawn_check.gd (21 checks) + CI list; e2e expected tools
29 -> 31.
New testgun module (testgun:weapon/test_gun, damage 25.0, real model
scene); the sandbox SpawnButton now targets it, so the button proves
the full .pck -> spawn path.
Godot 4.6.3 --export-pack ignores the export preset include filter
(verified: it packs the whole project, 572 files / 32 MB, and omits
the module entirely). The pack is now built by
tools/package_module_packer.gd with PCKPacker - the same pattern
module_check phase 10 proves mounts. The resulting
testgun-0.1.0.pck is 2.8 KB with exactly the module 3 files.
The debug slab the primitives rest on was a bare mesh, so as
RigidBodies the primitives fell straight through it to the floor.
It is now a StaticBody3D with a BoxShape matching the slab mesh.

Every WorldItem now builds a line-box BoundsGizmo (ImmediateMesh,
unshaded cyan) sized to its bounds, hidden until the sandbox gizmo
toggle turns it on. The toggle flows through Game into
WorldItem.set_gizmos_enabled, so items spawned later (bridge, spawn
button) pick the current state up in _ready. Dummies have no gizmo:
they are not grabbables and their capsule is already their visible
body.

Tests: sandbox_world_check gains the slab collider, the gizmo
(existence, line-box shape, bounds match, starts hidden, follows the
toggle), and a phase-2 rest check (primitives settle at y 0.91 on
the slab, not ~0.11 on the floor).
Item 2: the range grab is telekinetic only. The gravity-yank tier is
deferred (enum value + arc helper kept, no code path selects them);
telekinetic_range and yank_range are both 1.5 m in base.json, so the
yank can never fire. The pull moves the item with move_and_collide
on the frozen-kinematic body, so an obstacle blocks it and it stays
stuck until the grip is released; releasing the grip mid-pull now
cancels the pull (HandInteractor), and the 2.0 s timeout completion
is gone (a blocked pull must not catch by timeout). Targeting uses
the telekinetic range.

Item 3: throw imparts spin — Grabbable tracks the hand orientation
alongside position history and applies the hand angular velocity
on release (scaled by throw_velocity_mult, still 1.0). History cap
6 -> 3 samples so a flick keeps its peak. Gravity stays 9.8.

base.json also gains the (not yet wired) pointer_angle_offset_deg
and item_lock_idle_seconds rules; items 1 and 5 wire them up.
Item 4: WorldItem no longer builds an unconditional box collider.
The collision shape now matches the visual: sphere -> SphereShape3D,
cylinder -> CylinderShape3D, anything else (box, pyramid, weapons)
-> BoxShape3D sized to the visual bounds. The ball is a real sphere
now (it rests at y=0.909 on the slab, and rolls instead of settling
on a box face).

Collision-mesh convention: if the item model scene contains a
MeshInstance3D named collision-mesh (low-poly, authored for
collisions), the shape is the convex hull of that mesh (its node
transform baked into the shape points) and the node is hidden from
rendering. A test item proves it: modules/testgun/items/
test_collision_item.json + models/test_collision_item.tscn (a 0.3
body with a small 0.1 collision-mesh box offset to +X); the
spawn_check assertions for it land with the spawner rework (item 7).

The BoundsGizmo is now a wireframe of the ACTUAL collision shape:
box = 12-edge line box, sphere = great circles, cylinder = top /
bottom circles + side lines, convex = the low-poly mesh triangle
edges. sandbox_world_check asserts the ball is a SphereShape3D and
that each gizmo frames its collision shape.
Item 5: a WorldItem left still freezes as a static body (ITR-style
lock: behaves as if fixed, does not wake on contact). Stillness is
judged on a SMOOTHED position (exponential moving average, ~8 Hz)
that must stay within 5 mm of its anchor, plus velocity gates
(linear < 0.25 m/s, angular < 1.0 rad/s), so micro-jitter from a
stacked pile counts as stationary while a visibly moving object
never locks. The timer starts when the item comes to rest, not at
release (a falling/bouncing item keeps the timer at zero). Wake on
grab only: Grabbable.grab() clears the lock (duck-typed unlock())
before going kinematic, and RangedGrab.start_pull() does the same
so a locked item can be pulled and re-lock later.

Defaults (Lantern choice, flagged to Zach): idle timeout 5.0 s as
the tunable rule player.item_lock_idle_seconds in base.json; scope
is ALL WorldItems. Per-item lock_idle_seconds override exists for
tests (the suite uses 0.5 s). New tests/physics_lock_check.gd:
single ball locks, grabs awake it, it re-locks after release, and
a two-cube stack locks in place and stays put (12 checks).
Item 7: ModuleSpawner.spawn_item (which instantiated world_scene as
a plain Node3D — not a WorldItem, not in world_items, no Grabbable /
collision / Firearm, so the demo gun spawned but could not be grabbed
or used) is replaced by resolve_item: the spawner, which stays in the
game-free loader/, returns the resolved element (honoring replaces)
and the CALLER builds the WorldItem (ItemInstance.create + WorldItem.
spawn, the same two calls DeadAirHelpers.spawn_item makes). Callers
updated: the sandbox handler (game.gd) and the debug bridge
_spawn_item. The placeholder-box branch is removed (WorldItem.
_build_visual already builds a stand-in when there is no world_scene).
WorldItem.spawn now sets the module_item_id meta from inst.def_id,
so the meta assertions survive the contract change.

spawn_check now asserts the spawned node IS a WorldItem, is in
world_items, and has a non-null Grabbable (devkit pistol), and that
the test gun has a Firearm; it also proves the collision-mesh
convention end to end (test collision item spawns with a convex shape
from its small offset collision-mesh box, node hidden). module_check
phase 9 follows the new contract (WorldItem uuid name, meta, and
grabbability for the food pack).
Item 1: the FunctionPointer nodes move from the aim controllers
(LeftHand/RightHand) to the grip controllers (LeftGrip/RightGrip),
so the beam goes with the wrist. Base rotation -90 deg about X
(beam along the grip finger direction, -Y) plus a dorsal offset
(+0.015 m X right, -0.015 m X left: the back of the hand). The
ranged-grab cone now reads the same pointer node (PlayerRig.
pointer_for_hand), so the laser and the grab agree.

Two things the move surfaced, both fixed here:
- XRToolsHandAimOffset auto-repositions a pointer at the AIM pose
  (a 45 deg grip->aim offset), which would undo the grip placement;
  hand_offset_mode = disable (4) on both pointers keeps the authored
  grip-frame transform.
- .tscn Transform3D stores basis rows, not columns: the first
  -90 deg X basis came out as +90 deg (beam pointing +Y, away from
  the fingers) and had to be transposed. Verified live: the beam is
  exactly (0,-1,0) in grip space on both hands.

The beam angle is a tunable setting: player.pointer_angle_offset_deg
(Vector3 degrees, default [0,0,0]) in base.json, read by the new
GameRules.v3() and applied deferred in PlayerRig._ready (ModHost
boots in Game._ready, after the rig readies). Verified with an
override [0,10,0] -> both pointers rotate to (-90,10,0). Documented
in docs/modding.md; gen_mechanics.py re-run (ranges 1.5, lock rule,
Test Gun entries picked up from the stale regen).
Item 6: the auto-derivation is NOT changed. A headless diagnosis
(2026-11-06, Godot 4.6.3, three scripts against the real GLB, plus
the OpenXR spec) verified it is correct: the target frame
(t_f = (0,-1,0), t_p = (+1,0,0) left / (-1,0,0) right) is the
OpenXR grip-pose definition; post-correction bones land exactly on
target on both hands; the palm flip (thumb dot check) is correct
and not fragile; the raw model is anatomical within ~10 deg; the
glove is 0 deg off the Grip ring and ~10 deg off the Hand ring, so
the ~45 deg offset read off the 2D video frames is not in the
code geometry (the laser, which was on the separate AIM pose, was
the most visible misalignment — it now lives on the grip frame,
item 1). The comment records the numbers, the conclusion (changing
the derivation would make it wrong), and the headset measurement
procedure (gizmo_hand off, read the glove offset from the Grip
ring axes, set openxr_tune_euler_deg — never guess from stills).
Half-curl is still not added (Zach pending edit).
Item 2: tests/ranged_grab_check.gd (16 checks) — a target beyond
1.5 m is not acquired; a target within 1.5 m is acquired, locks,
and glides to the hand as the telekinetic tier (completes at the
hand, no timeout); an obstacle between item and hand blocks the
pull (stuck against the wall, still pulling); releasing the grip
mid-pull cancels it and the item drops unfrozen.

Item 3: tests/throw_check.gd (6 checks) — a hand rotated about +Y
and shoved +X over 10 frames, then released, imparts angular
velocity (0, 14.48, 0) rad/s about the hand rotation axis and
linear velocity (3.0, 0, 0) m/s in the hand travel direction.

Both suites join the CI list, alongside physics_lock_check (item
5, written earlier and passing, not yet listed).
- player.telekinetic_range and player.yank_range 1.5 -> 3.0 in
  base.json (yank tier stays inactive; the two stay equal), with the
  RangedGrab fallback defaults and mechanics regenerated.
- SandboxGizmos draws the ranged-grab acquisition cone as a wireframe
  per hand, parented to the laser pointer on the grip controller (the
  same node the cone reads), so the cone and the beam agree and it
  follows the hand. Range = tk_range, half-angle = cone_deg, read live
  from the RangedGrab; the geometry rebuilds when they change. Shown
  only while gizmos are on, the hand is empty (targeting only runs on
  an empty hand), and the rig is not in flat mode (flat targeting uses
  the camera ray). cone_wireframe() is the exact geometry (apex at the
  pointer, far circle at -range, radius range * tan(half-angle));
  cone_info() reports per-hand range/angle/pointer/visible for the
  bridge snapshot and suites.
- ranged_grab_check covers the 3.0 m range (2.5 m acquired, 3.5 m not);
  sandbox_gizmo_check asserts the cone geometry exactly.
- player.throw_spin_mult = 0.5 (new rule, base.json): release() now
  applies angular velocity x throw_velocity_mult x throw_spin_mult,
  so a throw flings without helicoptering. Tunable in base.json.
- Grabbable HISTORY 3 -> 10: the throw velocity averages over 10
  physics frames (~170 ms) instead of 3 (~50 ms), so a single-frame
  jitter no longer dominates the release velocity.
- throw_check drives the hand one step per physics frame (so the
  motion aligns 1:1 with the history samples) and asserts ratios,
  which cancel wall-clock jitter: the 0.5 spin is half the 1.0 spin
  (measured 0.57), and a 3-on/7-off hand releases at ~0.33x the
  constant-motion velocity (a 3-frame history would give ~0).
Spazz (diagnosed headless, gun_diag2): a gun thrown onto the slab
with spin kept rolling/rocking at 3-4 rad/s indefinitely — Godot's
default material has no rolling resistance, so the spin never decayed
and the lock's angular gate (1.0 rad/s) never opened. The clean
drop, the gentle set-down, and the tilted upright gun all settled
fine; only the thrown/spinning gun spazs. Fix: WorldItem damps its
spin while touching something (contact_monitor on, angular velocity
x exp(-2.0 * dt) per physics frame when get_contact_count() > 0).
In flight (no contacts) the spin is untouched, so the throw feel is
unchanged. Measured: a gun thrown with (0,3,8) rad/s settles to
0.001 rad/s within ~0.5 s of contact and locks at ~6 s.
gun_rest_check gains the thrown-gun scenario.

Wonky cone (diagnosed headless, ranged_grab_check): acquire_target
tested the item's centre point only. A gun lying crosswise is
0.23 m long with its origin off the visual centre, so aiming at the
muzzle or grip end from close range put the origin ~17 deg off-axis
— outside the 12 deg cone — and acquisition was intermittent by aim
point. Fix: the cone tests the item's volume (its world bounding
sphere, centre = bounds centre, radius = half the bounds diagonal),
so the test is "does the sphere intersect the cone". The cone
itself is NOT enlarged. ranged_grab_check now acquires the gun
aiming at its muzzle tip, grip, and centre.
- HandView.debug_snapshot(): the values the hand actually computes
  and applies — alignment mode, measured finger direction and palm
  normal, thumb-check result, target frame, applied correction
  transform, tunes, curl axis/sign, and the post-correction bone
  frame (live). The derivation itself is UNCHANGED; the snapshot
  only stores and reports what _apply_model_correction already
  measured. hands_check still passes.
- Debug bridge tool hand_debug (side L/R/both): returns the snapshot
  per hand as JSON. e2e asserts both hands report OPENXR, the target
  frame is the OpenXR grip definition, and the post-correction
  fingers land on target. Tool count 31 -> 32.
- SandboxGizmos shows a billboarded readout while gizmos are on
  (one line per hand: measured f/p, correction euler, openxr tune),
  refreshed 4 Hz from the same snapshots. The gizmo rings visualize
  the frames; this is the numbers.
- docs/modding.md: hand_debug, the readout, and the measurement
  procedure (gizmo_hand OFF so only the Grip ring shows, read the
  glove offset from the ring axes, set openxr_tune_euler_deg — never
  guess from stills).
Gun shoots: fix accepts tag + spawn the test gun loaded
All checks were successful
tests / test (pull_request) Successful in 1m35s
aa1577ee2a
Two causes, both proven by the (previously failing) firearm_check
repro: (1) test_gun.json accepts was the full item id
"devkit:mag/pistol_test", but Firearm.accepts_magazine() matches
against the magazine's tags ["mag", "mag.pistol", "pouch"], so
no magazine could ever be inserted — not even by hand. The devkit
pistol uses the tag "mag.pistol"; the test gun now does too.
(2) The gun spawned with no magazine and no chambered round, so
fire_once() dry-fired every time. A new spawn_loaded property
({magazine, fill, chambered}) on the item definition is applied in
WorldItem._ready via Magazines.make_loaded + insert_magazine +
rack(), following the start-loadout pattern — the gun spawns with
15 rounds, chambered, ready to fire. A gun without spawn_loaded
still spawns empty and dry-fires. The trigger input path itself
was already wired (HandInteractor -> fa.trigger -> fire_once).
firearm_check (15 checks) asserts the loaded spawn, a real shot
(fired + a round spent), and the unloaded-pistol dry-fire control.
CI list gains firearm_check.
set_controller_input injects per-hand da_* state through the
RigInput backend (RigInput.inject_state): the state reaches
HandInteractor through the same hand_state signal real input
uses. grab_state reports held item, grab point + mode, closest
item, grip/trigger values, ranged lock/pull, velocities and
layer/mask per hand. force_grab / force_drop drive the real grab
and release paths deterministically.

Seams: RigInput holds synthetic state per hand (live hardware
cannot clobber an injected hold; clearing feeds a neutral state
in flat mode so no press sticks). While any injection is active
the rig simulates XR (PlayerRig.set_debug_simulate_xr): the
hands activate in flat mode and FlatController yields, because
it drives the same gun every physics frame (fa.trigger(false)
with no mouse input, and re-parks the right grip) — leaving it
on silently swallows an injected trigger pull.

New headless suite tests/grab_sim_check.gd (37 checks): inject,
reject-unknown, no-clobber, force_grab, injected trigger fires
(fired emits, round spent), force_drop, clear-neutral, flat
driver yields and returns. MCP tool count 32 -> 36.
test_grab_sim in test_e2e.py (52 new checks, 46 -> 98):
- live ranged grab: gun on the floor 2.85 m out, pointer aimed
  with a two-pass closed-form euler, trigger locks it, grip pulls
  it to the hand (ends < 0.3 m, PRIMARY grab point), grip release
  drops it
- wall blocks the pull: lock first, move the rig to the far side
  of the sandbox Wall, pull runs and stays stuck at the wall face
  (z -7.3..-6.6) until the grip is released, then drops there
- live trigger pull: force_grab the loaded test gun (15 rounds),
  injected trigger fires (shots_fired 1, 14 rounds), second shot
  after the 0.2 s cooldown (2 shots, 13 rounds), force_drop
- validation errors: unknown item id, unknown input field, bad side
- MCP: tools/list is 36 tools, grab_state round-trips

Also fixes the bridge bugs the e2e caught: _resolve_item_param
returned a bare WorldItem for node_path (cast error in
force_grab), and force_grab now enables XR simulation outright
instead of re-syncing it off when no injection is active yet.
Aim note: the pointer beam is the grip's -Y (authored base
-90 deg about X), and the aim euler must be recomputed after
placing the grip, since placing moves the pointer origin.
Table rows for set_controller_input, grab_state, force_grab and
force_drop; a grab-simulation section (XR simulation in flat mode,
why the flat driver must yield, place_rig/aim/reparent gotchas the
e2e hit); tool count 13 -> 36 in the e2e section; the flat-mode
limitation now points at the simulation.
Fix flaky throw_check spin-ratio assertion (pre-existing)
All checks were successful
tests / test (pull_request) Successful in 1m24s
56128c3ac9
The run-2 ratio band (0.4-0.6) fails intermittently on unmodified
code: 2 of 5 runs on base aa1577e measure 0.61-0.65. The estimate
divides by wall-clock dt over the 10-frame window, so the ratio
only cancels jitter if both runs share timing — they run at
different wall-clock times. Widen to 0.3-0.7; the band still
catches a missing halving (ratio 1.0) or a double application
(0.25). No game code touched.
world_item _build_collision had a "pyramid" shape string falling
through to the BoxShape3D fallback, so the practice pyramid rolled
and stacked like a cube. It now builds its convex hull from the
visual mesh (the same create_convex_shape() path the collision-mesh
convention uses); the bounds gizmo draws the pyramid, grown 0.01
like the other kinds so it never z-fights the visible mesh.
sandbox_world_check asserts the pyramid shape is a
ConvexPolygonShape3D and allows 1 mm on the convex gizmo AABB
(baked-mesh vertices carry float32 trig noise).
Diagnosis (headless probe): an accelerating swing peaking at
7.80 m/s released at 5.16 m/s — 66% of peak. The 10-frame window
average dilutes the swing peak, so throws died mid-air. The fix
is in how the estimate is computed, not the buffer: HISTORY stays
10 (round 2), but _estimate_velocity/_estimate_angular_velocity
now read the most recent 3 samples of that buffer. The same ramp
now releases at 7.47 m/s (96% of peak); the new throw_distance
check throws the ball over the sandbox wall (min y 2.60 over a
2.0 wall, lands z=-13.43) and throw_check run 3 asserts the peak
ratio directly. Run 4 documents the consequence: a hand that
moved then stopped releases with ~0, while the buffer still
holds 10 samples.

Per-item tuning: ItemSpec.throw_factor (default 1.0, yeetable),
read from JSON through GenericItem/ItemDefinition and set as a
body meta by WorldItem; Grabbable.release scales linear and
angular velocity by it, so heavy items can stay heavy. throw_check
run 5 asserts factor 0.5 halves the release.
The pull speed was clamp(dist*6, 1.5, 8), so it took about a
second regardless of distance. The snap speed is fixed at
start_pull (starting distance / 0.075 s), so a 3.0 m pull and a
0.5 m pull both land in the same window — 6 and 4 physics frames
in ranged_grab_check (0.10 s / 0.067 s). Fixed, not recomputed
per frame: a speed proportional to the shrinking distance would
approach exponentially and never arrive in the window.
Obstacle blocking is preserved: the motion is split into <= 0.1 m
substeps, each a swept move_and_collide, so the fast pull cannot
tunnel through a thin wall — the wall check still passes.
The laser angle rule existed in base.json but no UI exposed it,
so the player could not tune the beam (or the ranged-grab cone,
which reads the same pointer node) without editing files. The
pause menu Options page now has Laser angle pitch/yaw/roll
sliders (-45..45 deg) and a mouse sensitivity slider, backed by
GameSettings with a custom flag per value: untouched players keep
following the rule default (so a hand-rig mod that changes the
default still takes effect), and a dragged slider marks the value
custom, saves it to user://settings.cfg, and applies it live
through the same _apply_pointer_angle path the deferred boot call
uses. pause_check proves it end to end: a pitch drag changes the
setting, the saved file, and the beam rotation in the running
game; the round-trip and the rule-fallback are asserted too.

Audit of the other base.json tunables: throw_velocity_mult,
throw_spin_mult, telekinetic_range, yank_range and
item_lock_idle_seconds stay rules — game tuning, not player
preference. flat_fov is unwired (no reads anywhere); left alone,
flagged as a follow-up.
tests: track the throw_distance_check uid
All checks were successful
tests / test (pull_request) Successful in 1m35s
7837da968f
Throw velocity: timestamp samples at render-frame time (phase fix)
All checks were successful
tests / test (pull_request) Successful in 1m35s
7117387514
Samples are now recorded in _process (render-frame time = the
pose own time) instead of _physics_process, and both estimators
divide by the actual elapsed time between the first and last
sample poses (one _history_time clock for linear + angular,
replacing physics-delta sums and the separate wall-clock angular
time). A sample that re-sees the last pose keeps that pose first-
observation time, so duplicates contribute zero elapsed time
instead of a physics delta the hand never moved through.

ESTIMATE_SAMPLES 3 -> 2 (one pose interval): the 3-sample window
still averaged over a span ending a pose interval before release
and read 0.80-0.84x on the probe swing at 60/72 Hz; 2 samples
read 0.89-0.99x at every realistic pose rate (60-120 Hz, all
phases). A single pose jitter (~1 mm) over one interval is
~0.1 m/s of noise on a multi-m/s throw, negligible next to the
window lag the third sample added.

release() flushes the primary hand pose first, so a physics-
driven hand (flat mode, headless tests) that moved after the last
rendered frame is captured instead of reading one step stale or
zero. An XR hand pose was already recorded by _process, so the
flush skips it and changes nothing there.

Headless suites drive Grabbable.debug_time_override by the nominal
physics delta per step: the headless loop wall clock is
erratically spaced (measured startup steps in microseconds, then
13.8/20.7 ms jitter around the 16.7 ms nominal) and a wall-clock
dt over one step is meaningless there. Production never sets it.

New throw_phase_check suite: the same scripted throw reads
0.85-1.05x of true release velocity across pose rates 60-120 Hz
and all phases (45 Hz 0.80-1.05, 30 Hz 0.60-1.05 graceful),
plus a hidden-body-identical check. Added to the CI suite list.
sandbox menu: spawn any registry item from a scrollable list
All checks were successful
tests / test (pull_request) Successful in 1m37s
d3c5546f89
Replace the single test-gun spawn button with a scrollable list of
every item in ItemRegistry.shared().all_ids(). Tapping a row sets
spawn_item_id and emits spawn_item; the Game spawns it exactly as
before. The list rebuilds when the menu is shown, so late-registered
modules appear. New suite tests/sandbox_spawner_check.gd (14 checks),
added to CI.
Undefined object: magenta triangulated wireframe cube + id label
All checks were successful
tests / test (pull_request) Successful in 1m37s
1c24a3d81e
An item with no world scene and no recognized model shape no longer
renders as a solid grey box. It now renders as a magenta unshaded
wireframe cube - 12 edges plus one diagonal per face (2 triangles
per face, 12 total, 6 sides) - with the item id as a Label3D at the
cube center: fixed position and angle (no billboard), wrapped at
:, ., ;, / and scaled to fit inside the cube. Collision stays a
plain box, so the item is still grabbable; only the look says
undefined.

An explicit shape: box is a defined stand-in (the practice cube),
not undefined, so it stays solid. Missing shape or an unknown shape
string goes wireframe, as does a spawn whose id resolves to no
definition at all.

New suite tests/undefined_model_check.gd (82 checks, in CI):
noshape / typo-shape / custom-size / unregistered-id cases all
render wireframe + wrapped id + fit inside the cube, and the
defined box stays solid.
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
dead-air/engine!32
No description provided.