Gun fixes: magwell snap-slider, real renders, eject pose, grip preview, hand posing #40

Closed
opened 2026-11-11 22:12:59 +00:00 by muse-lantern · 1 comment
Member

DEAD AIR — Gun fix plan (headset test 2026-11-11)

Status: BUILD AUTHORIZED by Zach 2026-11-11 ("store this as an issue we can
track and then get to work!"), with the decisions in the Decisions section
below. Tracking issue: dead-air/engine #40
(#40) — the full text of this
plan is the issue body, per Zach's rule that tracking issues carry the full
depth, not a summary.
Source: Zach's in-headset test of the generic pistol, 2026-11-11 13:38
(video + image). Branch: editor-addon, tip b792f03 at time of writing.
Code traces verified on that tip; line numbers below are from it.

Related docs: dead-air-mod-toolkit-editor-plan.md (Phase 1 open decision (d)
asks whether the mag-rail behavior rides with Phase 1 or stays in the rebuild;
this plan answers it as its first decision), dead-air-rebuild-change-list.md
(mag rail behavior + internal mag, Phase 2, design only, NOT built).

The five problems, as Zach reported them

  1. The mag well is not a snap-slider. Intended: bring the mag opening to the
    bottom of the magwell, the mag snaps to that WHILE STILL HELD, slide it up
    into the magwell, it "clicks in" and is deparented from the hand.
  2. Snapped objects still use placeholder renders: bullets in the mag render
    the old simple cylinder bullet, not the loaded bullet model; the gun still
    renders the old simple box mag, not the mag GLB.
  3. No way to adjust the "bullet eject" visual rotation/position: position is
    right, but the ejected bullet's tip points directly into the mag.
  4. No visual way to reference/check the hand grip pose in the editor: the
    staging scene shows no hand at the grip.
  5. Hand posing is STILL BROKEN when holding a grip.

Two standing notes. First, the real-mag-GLB render was HELD by Zach on
2026-11-10 (unfiled, not to be re-raised) — he has now raised it himself as
problem 2, so the hold is lifted by him; this plan records that and does not
re-litigate it. Second, problem 5 is investigation-first by rule: his photos
and video are user-reported symptoms only, never proof of live positions or
build state. Nothing in §5 asserts a cause from them; it lays out how to
reproduce and measure the fault through the bridge instead.

Decisions (Zach, 2026-11-11) — applied to this plan

  • (1a) Snap-slider rides with the toolkit (Phase 1). The toolkit plan's
    open decision (d) is answered.
  • (1b) Slide input is hand motion on the rail axis — the mag should look
    like it slides in at the motion of the hand. The disconnect between object
    physics and hand physics (for now) should make this easy. He has plans to
    fix that disconnect to a degree, but that is for LATER, not this plan.
  • (1c) Rail definition: point A + vector direction to point B + distance.
    Point A is outside (the collider check for the mag's opening reference
    point); the vector direction points from A to point B, and the distance
    gives the internal stop position. (This replaces the two-absolute-points
    a/b framing in §1 below — same segment, parametric form.)
  • (1d) Eject-hold behavior, part of §1: holding eject holds the mag so
    you can grab it; the mag only drops when the eject button is released.
    Holding eject drops the mag about 30% of the rail length (out along
    the rail from the inside), where it holds as a grabbable entity.
  • (1e) Collider authoring, part of §1: the gun still uses a bounding-box
    collider, and there is no way in the editor to change that to a separate
    collider mesh in the model (or assembled primitive meshes for the
    colliders). The build must add a way to adjust the gun's colliders in the
    editor — a collider mesh from the model, or assembled primitives. This is
    needed for the rail's collider check at point A, so it rides with §1.
  • (2a) Real renders use the contained object. Use what is "contained" in
    the snapper point — it is already a value that is there, the physical
    object of the mag itself — so no model-swap logic is needed, since the
    object instance is still right there. Likewise the first-out bullet uses
    the model of the object that is first-out for the mag. That makes it
    functional and important for the systems as a whole.
  • (2b) Cyan slab: Zach's correction. He has zero clue what the "cyan
    slab" is and suggests Lantern may be mistaken by the gun model or his hand
    model. The plan's §2 unknown is reframed: the shape in the video frame is
    most likely the gun model's or the glove's own geometry/material — the
    identification step checks the GLB materials first, before treating it as
    a mystery object. (Show-all decision (2b) is still open — he did not
    answer it; first-out only stands until he says otherwise.)
  • (3a) Eject pose is fix shape B: a mag_eject field containing pos and
    rot, just like other elements — the eject pose is literally the opening
    pose, raised a little bit in a specific direction, defined in the editor
    just like other elements. The +90° pitch correction is baked into the
    default so the tip points forward.
  • (4a) Grip preview is fix shape A (stage instances a HandView).
    Toggle between left and right hand; both share the same pose/rot, so the
    toggle is purely a visual helper.
  • (4b) Ghosted and solid, toggle in the editor preview (non-persistent
    setting).
  • (4c) He wants to be able to pose the hand grip via this as well.
    The attached discussion with a different AI agent (Claude, "Per-finger
    pose modding API design", 2026-11-09, file
    ~/workspace/user/files/Claude-Per-finger_pose_modding_API_design-20261111-1408.md)
    is close to what he wants: per-finger curl 0–1 (0 = hand model resting,
    1 = fist), named base poses (pistol_grip), curl + spread, thumb
    curl + opposition, a separate hand-to-item offset, per-joint additive
    escape hatch (not absolute), GDScript owning the per-joint curves and
    left-hand mirroring, clamps in code, a pose preview with sliders that
    exports the JSON, versioned schema. This is a LATER iteration — iterate
    and discuss in more detail after the rest of the basic core work is done.
    It is stored in the tracking issue, not built in this round.

Data the plan stands on

  • Pistol generic_pistol.json: mag_well {pos (0, 0, 0.06), rot (−12, 0, 0)},
    mag_well_b (0, −0.0489, 0.0704), chamber (0, 0.0109, 0.0382),
    muzzle (0, 0.0109, −0.113), grip_offset (0, −0.03, 0.01),
    grip_rotate [18, 0, 0]. Assembly: slide origin (0, 0, 0.00004)/(0, 90, 0),
    travel (0, 0, 0.05); fire_select origin
    (0.00005, −0.006296, 0.010929)/(0, 90, 0), modes safe + semi, offsets zeroed.
  • Mag generic_pistol.json: opening {pos (0, 0.07216, −0.02078),
    rot (−97.274, 0, 0)}, capacity 15, model_path generic_pistol_mag.glb.
  • Bullet bullet_9mm.json: model_path bullet_9mm.glb, grip_rotate [90, 0, 0].
  • GLB probe (headless, read-only): bullet is one mesh Handgun_Bullet, node
    rot (0, 89.98, 0); mesh-local long axis X, fat end (radius 5.5 mm) at low X,
    tip (radius 2.3 mm) at high X — in the instantiated scene the bullet's tip
    points along −Z, base at +Z. Mag is one mesh Handgun_Magazine, node rot
    (85, 0, −90), mesh 0.065×0.025×0.137 m. Pistol is 5 nodes, all (0, 90, 0).

1. Mag-well snap-slider

Current state

There is no snap, no slide, and no held-while-sliding state anywhere
in-game. Insert is a proximity check run every physics frame while a mag is
held (modules/deadair/src/interaction/hand_interactor.gd:269-271 calls
_try_insert_magazine):

# hand_interactor.gd:364-379
if item.global_position.distance_to(fa.mag_well_position()) > MAG_INSERT_RADIUS:
    return
...
rig.inventory.release_hand(hand_id)
item.grabbable.release_all(false)
item.queue_free()
fa.insert_magazine(inst)

MAG_INSERT_RADIUS := 0.09 (line 30). The check uses the mag WorldItem's
origin, not its opening — the opening can be ~16 cm from the well when it
fires. Firearm.insert_magazine (modules/deadair/src/weapons/firearm.gd: 114-120) is a pure data op (item().add_child_item(mag)) — no pose, no
reparenting, no rail. Eject is the mirror image
(hand_interactor.gd:355-361, _drop_magazine, on BUTTON_PRIMARY press at
:255-256): the mag spawns 8 cm below the magwell and falls free; it is not a
grabbable entity held at any rail end.

Two more facts the fix must respect. mag_well_b is read nowhere in-game
— only the editor schema (addons/da_item_editor/item_schema.gd:29) and the
stage reference it. And mag_well_position() (firearm.gd:110-111) uses
WorldItem._pos(...), which extracts pos only and drops rot
(modules/deadair/src/items/world_item.gd:910-913) — so the −12° magwell
rotation is ignored in-game today. There is no rail-following logic in-game:
grep -i rail over modules/deadair/src/ returns only fence rails, and
there is no slide at all in firearm.gd or hand_interactor.gd.

The closest existing mechanism is Snapper
(modules/deadair/src/inventory/snapper.gd:54-65): snap() releases the item
and re-grabs it with the snapper node as the "hand" (wi.grabbable.grab(self, ...)), so the item hangs off the snapper by its primary grab point using the
same Grabbable follow. But it holds a fixed pose only — nothing moves an
item along a segment while held — and snapping happens on release
(hand_interactor.gd:319-324 checks BodySlots.slot_for_drop), not on
approach.

Fix shape

New magwell-snap state in HandInteractor (or a MagwellSnapper node at rail
end b, reusing the Snapper.grab pattern), in four pieces. The rail is
defined per Zach's decision (1c) as **point A + vector direction to point B

  • distance**: A is outside (the collider check for the mag's opening
    reference point), the direction points from A to B, and the distance gives
    the internal stop position. (The existing mag_well/mag_well_b two-point
    data is the same segment in another form — A is the outside end, B the
    inside end; the build settles the JSON shape, keeping Zach's authored values.)
  • (a) Trigger on opening → point A proximity/collider check, not
    mag-origin → magwell point. The check is the opening reference point
    against the collider at A.
  • (b) Snapped state: the mag stays held, its follow target constrained to
    the rail segment A → B (project the hand position onto the segment; the
    segment is ~5.0 cm gun-local, a (0, 0, 0.06) to b (0, −0.0489, 0.0704)).
    Slide input is hand motion on the rail axis (decision 1b) — the mag looks
    like it slides in at the motion of the hand.
  • (c) At t = 1 (reached B), run the existing insert_magazine + deparent
    from the hand.
  • (d) Eject, per decision (1d): holding eject drops the mag about 30% of
    the rail length
    out along the rail, where it holds as a separate
    grabbable entity so you can grab it; the mag only drops (falls free) when
    the eject button is released. This replaces _drop_magazine's free spawn.

The mag visual must also start honoring the magwell rotation during the
slide (see §2 — today it doesn't). A shared magwell-basis helper belongs
here: mag_well_position() drops rot, and both the slide and the visual
need the full basis.

Collider authoring, per decision (1e), rides with this section: the gun
still uses a bounding-box collider, and the editor has no way to change it
to a separate collider mesh in the model or assembled primitive meshes. The
build must add a way to adjust the gun's colliders in the editor — a
collider mesh from the model, or assembled primitives — since the rail's
collider check at point A needs a real collider on the gun, not just a
bounding box. (This is the toolkit plan's Phase 3 / old Phase C colliders
work, pulled forward into §1 because the snap trigger depends on it.)

Decisions needed from Zach

  • (1a–1e) Answered 2026-11-11 (see Decisions above). Remaining for the
    build to settle: the JSON shape for the A + direction + distance rail
    (against the existing mag_well/mag_well_b values), and the collider
    authoring UI shape (collider mesh from the model vs assembled primitives,
    or both).

Verification

Headless: new rail-follow suite — snap at b while still held → slide to
a → click-in deparents; eject holds at b — alongside existing
tests/mag_interaction_check.gd. Bridge e2e: extend the mag test in
addons/da_debug_bridge/mcp_server/test_e2e.py (place_controller drives
the mag opening to b, then along the rail; grab_state asserts still-held
mid-slide, then inserted). In-headset feel check.

2. Real renders for the snapped bullet and mag

Current state

The inserted mag is a procedural box, firearm.gd:271-286
(_refresh_mag_visual):

var size := WorldItem._v3(def.model.get("size") if def != null else null, Vector3(0.03, 0.1, 0.04))
_mag_visual.mesh = WorldItem._box(size)
mat.albedo_color = Color(0.12, 0.12, 0.12)
_mag_visual.position = WorldItem._pos(table.get("mag_well"), Vector3.ZERO) + Vector3(0, -size.y * 0.4, 0)

The mag JSON carries no model.size, so it is always the default 30×100×40
mm dark-gray box, with no rotation
(magwell −12° ignored), at the magwell
dropped 40% of its height. The real mag GLB is 24.6×142.4×77.1 mm. Bullets
in a loaded mag are a procedural gold cylinder, world_item.gd:625-641
(_build_first_out_bullet): _cyl(rs.x, rs.y, false) with
Magazines.round_size → pistol default Vector2(0.0045, 0.029)
(modules/deadair/src/weapons/magazines.gd:44-51), posed at the opening with
the opening rotation, visible only while the mag holds ≥1 round
(_refresh_first_out_bullet, world_item.gd:253-256). Only the first-out
round renders
— show-all stays parked (comment at world_item.gd:620-624).
The bullet GLB is never instanced for it.

What exists to build on: model_path is already resolved to a loaded
PackedScene on both the element and the definition
(modules/deadair/types/item.gd:102-103 and :60-61), so
mag.definition().world_scene is the mag GLB, available to
_refresh_mag_visual today. _build_visual (world_item.gd:486-500) already
instantiates world_scene for the mag's own WorldItem — and still builds the
first-out bullet for GLB mags (line 500). The editor already solved the
posing math the game would need: the magwell ghost uses orient basis =
magwell basis with the wrapper positioned by measured bounds
(addons/da_item_editor/item_stage.gd:1508-1515).

Fix shape

Per decision (2a): use what is contained in the snapper point. The mag
object instance is already there — insert_magazine does
item().add_child_item(mag), so the physical mag is a child of the gun —
so no model-swap logic is needed: the visual shows the contained mag
instance itself instead of building a procedural box. Likewise the first-out
bullet uses the model of the object that is first-out for the mag — the
contained bullet's model — which makes it functional and important for the
systems as a whole.

  • (a) _refresh_mag_visual: show the contained mag instance (its own
    world_scene/visual) at the game's insert pose (magwell pos + the 0.4
    drop, with the magwell rotation applied — needs the magwell-basis
    helper from §1), accounting for the GLB's own node rotation (mag mesh
    node is rotated (85, 0, −90) in the file; mirror the editor's
    orient/bounds math, don't re-derive it). Keep the box as fallback when
    the contained mag has no model.
  • (b) First-out bullet: render the contained first-out bullet object's
    model (the bullet GLB) instead of the cylinder, applying the same +90°
    pitch correction the editor uses (see §3) because the GLB's nose is at
    −Z. Keep the cylinder as fallback when the bullet has no model.
  • Side effect to carry: switching to a GLB loses the procedural colored base
    ring (world_item.gd:596-615, bullet band is procedural-path only; same
    was true for the mag band at :568-591).

Unknown — the cyan slab (reframed by Zach)

The authored mag visual is dark gray (0.12, 0.12, 0.12), so it does not
explain the flat cyan shape on the side of the grip in the video's last
frame. Zach's correction (2026-11-11): he has zero clue what the "cyan
slab" is and suggests Lantern may be mistaken by the gun model or his hand
model. The identification step therefore checks the GLB materials first —
the pistol, mag, and glove models' own geometry and materials — before
treating it as a mystery object. If it is part of the gun or hand model,
there is nothing to fix here beyond the real renders above; if live bridge
identification (list_items + snapshot + a render of the held loaded gun)
shows something else, that finding goes into the build. The only other cyan
in-game objects found are the BoundsGizmo wireframe
(world_item.gd:372-378, Color(0.2, 0.9, 1.0), built on every item but
gizmos_enabled defaults false, line 69 — and it is 1 px lines, not a
slab) and the VolumeContainer
(modules/deadair/src/inventory/volume_container.gd:37 WIRE_COLOR cyan
(0, 0.8, 1.0), :39 SOLID_COLOR teal (0, 0.35, 0.38)) — which is fixed in
the sandbox, not attached to the gun.

Decisions needed from Zach

  • (2a) Answered 2026-11-11 (contained object, no model-swap logic).
  • (2b) Still open: first-out only, or lift the parked show-all too?

Verification

Headless mag_filo_check / firearm_check (visual node type + pose
assertions); xvfb render of the inserted mag (GLB silhouette, not a box)
and the first-out bullet; bridge e2e (spawn loaded gun, list_items +
screenshot); in-headset check.

3. Bullet-eject visual pose

Current state, end to end

_eject_bullet (hand_interactor.gd:411-430):

var wi := WorldItem.spawn(round, level, Transform3D(mag_wi.global_transform.basis * mag_wi.magazine_opening_basis(), _float_position(mag_wi)))

_float_position (:435-437) = magazine_opening_position() + mag_global_up * FLOAT_HEIGHT, FLOAT_HEIGHT := 0.04 (line 35).
_update_floating_bullet (:443-461) re-applies the same transform every
physics frame
while the trigger is held, and on release unfreezes the
bullet and clears tracking (:456-460). magazine_opening_basis()
(world_item.gd:241-243) is just Basis.from_euler(opening rot) — for this
mag, R_x(−97.274°), with no GLB correction. The spawned round is a real
WorldItem, so it renders the bullet GLB.

Why the tip points into the mag — measured, not guessed. The editor ghost
applies BULLET_OPENING_PITCH_CORRECTION := 90.0
(item_stage.gd:92: "The correction maps the GLB bullet's nose onto +Y. The
opening rotation is then composed on top") at :1507 and :1550. The game
path never applies it. With the probe's finding (GLB nose at −Z): opening
rot R_x(−97.274°) maps nose −Z → (0, −0.992, +0.127) in mag-local space —
nose pointing down into the mag body, exactly the reported symptom. With
the editor's correction applied first (nose −Z → +Y), the same opening rot
maps +Y → (0, −0.127, −0.992) — nose forward (−Z), slightly down, matching
the first-out intent stated at world_item.gd:202-204 ("base at the back
(+Z), nose forward (−Z)"). The procedural first-out cylinder never showed
this because a Y-axis cylinder is symmetric — the bug only bites the GLB
bullet, i.e. exactly the eject path. Position is right because
_float_position is correct; only the rotation is wrong.

Fix shape

Per decision (3a), fix shape B is what Zach wants: a mag_eject field
containing pos and rot, just like other elements. The eject pose is
literally the opening pose, raised a little bit in a specific direction,
defined in the editor just like other elements. So: a new magazine-table
field mag_eject: {pos, rot}; the eject float pose
(_eject_bullet / _update_floating_bullet) uses it; the editor authors
it like opening (a marker/ghost for the floating bullet's pose). The
default is the opening pose raised a little in the eject direction, with
the +90° pitch correction baked in so the tip points forward, not into the
mag. (The minimal (A) correction is subsumed: the correction is part of the
default, not a separate code path. The build must still check the callers
of magazine_opening_basis() before touching the shared function — the
first-out cylinder path uses the sibling magazine_opening_rotation() and
must stay untouched.)

The one remaining e2e failure, "bullet stays held in L after the trigger
release" (test_e2e.py:467), exercises this same path: eject pops the bullet
(:455), L force-grabs it (:460), R releases the trigger (:464), check
L still holds (:467). The release branch is _update_floating_bullet: 443-461: if L has grabbed it, is_held() clears tracking (:452-455);
otherwise not trigger_down unfreezes it and clears (:456-460). The
failure means L's held is None after release. Candidate mechanisms,
hypotheses only: the unfreeze-on-release firing while the grab is mid-flight;
force_grab racing the per-frame transform rewrite at :461 while L's
_follow is also driving the bullet; frozen-body + move_and_collide
interplay while held. It needs a bridge run with grab_state traces to
distinguish. The plan treats the eject-pose fix and this failure as the
same verification gate (re-run the e2e mag test), and if it still fails, it
is a separate grab/freeze bug, not the pose.

Decisions needed from Zach

  • (3a) Fixed correction only, or an adjustable eject-pose field in the
    magazine table (+ editor support)?

Verification

Headless mag_interaction_check / mag_filo_check (eject pose assertion:
bullet nose basis vs opening); bridge e2e mag test (the "eject pops a loose
bullet" + "stays held" checks); side render of the floating bullet showing
the tip forward, not into the mag.

4. Grip-pose preview in the editor

Current state

The stage's entire grip visualization is a 6 cm RGB axes triad.
_add_grip_marker (item_stage.gd:882-895) builds a Marker3D named
gun_grip at grip_default pos + grip_offset, rotation drot + grip_rotate, whose only child is _make_axes(GRIP_AXIS_LEN) (:1665-1686,
GRIP_AXIS_LEN := 0.06, line 63) — unshaded ImmediateMesh lines. A grep for
glove|HandView|hand_model|vr_glove in item_stage.gd returns zero
hits
. The secondary grip is the same story: a gun_foregrip marker exists
(:607, and the game builds GrabSecondary from weapon.foregrip at
world_item.gd:145-150 — only rifle_test.json carries it), but no hand is
shown there either.

What exists to reuse: (a) the ghost/reference pattern: _load_item_model_node
(item_stage.gd:1558-1570) loads world_scene, else model_path resolved
against the item JSON's directory, else the procedural stand-in;
_build_references_for (:1414-1450) wraps it in a wrapper Node3D + orient Node3D pair positioned by measured bounds (_position_reference,
:1500-1515). A grip preview would instance a hand model the same way: a
wrapper at the gun_grip marker pose with an orient node. (b) The hand
asset: modules/deadair/assets/player/hands/vr_glove.glb (Valve vr_glove,
mono right-hand model; HandView.model_path default, hand_view.gd:50).
(c) A curl driver already exists: HandView.set_debug_curl
(hand_view.gd:783-786, via the bridge's set_hand_curl).

The catch: instancing the raw GLB is not the same as the game's hand.
HandView (modules/deadair/src/player/hand_view.gd) builds the model with
its OPENXR alignment correction measured from the skeleton's rest geometry
(_apply_model_correction, :481-544; _openxr_alignment, :576-602),
which needs a processed frame to calibrate (_build_and_calibrate awaits
one frame, :119-125). A raw GLB in the stage would sit in uncorrected
model orientation. So the preview must either reuse HandView itself (it
builds under any plain Node3D parent) or share the correction math — not
re-derive it.

Fix shape

Per decisions (4a–4c), fix shape A is what Zach wants: the stage
instances a HandView at the gun_grip pose — reusing the game's own
alignment, showing exactly what the hand looks like on the grip — with a
left/right toggle (both hands share the same pose/rot, so the toggle is
purely a visual helper) and a ghosted/solid toggle in the editor preview
(non-persistent setting, not saved to JSON or metadata).

Per-finger posing is a LATER iteration, not this build. Zach wants to be
able to pose the hand grip via this as well, and the attached Claude
discussion ("Per-finger pose modding API design", 2026-11-09) is close to
what he wants: per-finger curl 0–1 (0 = hand model resting, 1 = fist),
named base poses (pistol_grip), curl + spread, thumb curl + opposition, a
separate hand-to-item offset, a per-joint additive escape hatch (not
absolute), GDScript owning the per-joint curves and left-hand mirroring,
clamps in code, a pose preview with sliders that exports the JSON, versioned
schema. Iterate and discuss in more detail after the rest of the basic core
work is done. It is stored in the tracking issue, not built in this round.
(The foregrip second-hand question from the old (4c) is folded into that
later iteration unless the basic preview makes it trivial.)

The pose the stage would show is the same one the game computes
(world_item.gd:140-173 uses the same grip_default + grip_offset / grip_rotate values), so the preview's value is precisely seeing HandView's
correction + curl against the authored grip.

Decisions needed from Zach

  • (4a–4c) Answered 2026-11-11 (shape A, L/R toggle, ghosted/solid toggle,
    per-finger posing later). Remaining for the build: the toggle placement in
    the dock (near the slide sim / reference slots area) and whether the
    preview defaults on or off.

Verification

Editor render (glove on the pistol grip at the authored pose, fingers wrapped
vs floating); headless suite (stage builds a grip-preview node at the marker
transform; the dock already instantiates headless in item_editor_check).

5. Hand posing when holding a grip — investigation first

Nothing in this section is asserted from Zach's screenshots or video — they
are symptoms only. This is the code chain plus how to measure the fault.

The chain, with lines

  1. Controller: LeftGrip/RightGrip are XRController3D with pose="grip"
    (modules/deadair/src/player/player_rig.gd:18-30, :83-86). Each carries
    a HandView (Valve vr_glove, mono model mirrored for the left).
  2. Glove alignment: HandView defaults to Alignment.OPENXR
    (hand_view.gd:73), correction measured from skeleton rest geometry in
    _apply_model_correction (:481-544) via _openxr_alignment
    (:576-602); openxr_tune_* are zero by default (:76-77). The
    LEGACY_TUNE path with tune_euler_deg (45, −10, 100) (:88) exists but
    is not the default — and tune_enabled := true (:83) only affects
    the LEGACY path (:559-561). Target frame for OPENXR: finger direction →
    (0,−1,0), palm → (±1,0,0) (:583-584), fist centre placed a little
    wrist-side of the middle knuckle (:591-593).
  3. Item follows the hand: Grabbable._follow
    (modules/deadair/src/interaction/grabbable.gd:319-351); single-hand
    target = h0.global_transform * p0.affine_inverse() (line 350), where
    p0 is the GrabPoint.grip_transform()
    (modules/deadair/src/interaction/grab_point.gd:30-36). So the item hangs
    off the hand by the inverse grip pose — the glove orientation is the
    hand node's orientation.
  4. Grip pose authoring: world_item.gd:140-173 — weapon base rotation
    (90 − rake, 0, 0) = (72, 0, 0) for the pistol (rake 18.0), authored
    grip_poses override (the pistol JSON has none), then grip_offset
    added to position (:167) and grip_rotate added to rotation_degrees
    (:173): total pitch 72 + 18 = 90°.
  5. Laser is a separate frame: FunctionPointer on the grip controller
    with base −90° about X (player_rig.gd:100-109, _apply_pointer_angle
    :403-424). The beam is the pointer's −Z after its own base rotation;
    the glove follows the grip controller's basis. They only agree if both
    derivations are right — consistent with the 2026-10-28 finding that the
    laser was the most visibly misaligned element while the headless check
    found the glove derivation correct (glove 0° off the Grip ring).
  6. Curl: set_curl (hand_view.gd:765-777) — index = trigger amount, others
    = grip amount, thumb = grip × 0.7, REST_CURL 0.12, JOINT_CURL [1.35, 1.75, 1.05, 0.30]; axis/sign auto-calibrated from rest geometry
    (_calibrate_curl, :687+). No half-curl for non-thumb exists — the
    amount *= 0.5 change is Zach's pending edit; do not propose applying it.

Measurement surface (bridge, already built)

hand_debug (addons/da_debug_bridge/debug_bridge.gd:940-963 →
HandView.debug_snapshot, hand_view.gd:193-218) reports alignment, tune
values, curl_axis/curl_sign, measured f_dir/palm, thumb_dot,
palm_flipped, target_f/target_p, and the correction origin/euler. Plus
grab_state, force_grab, set_controller_input, list_items
(addons/da_debug_bridge/mcp_server/server.py:250-549),
place_controller/place_rig, and set_pose ("fists"/"open",
debug_bridge.gd:985-1000, which drives the live HandViews via
set_debug_curl).

Reproduction protocol (no photos)

  1. Spawn the pistol, force_grab in R (the e2e pattern,
    test_e2e.py:411-419).
  2. Read grab_state (hand pose + held item pose) and hand_debug
    (measured frame + correction).
  3. Compute numerically: angle between the glove's finger direction and the
    barrel direction; angle between the laser beam and the barrel; glove basis
    vs GrabPoint basis. Expected: barrel level with the beam per the
    grip_rotate [18,0,0] fix; glove 0° off the Grip ring per the 2026-10-28
    headless finding.
  4. Repeat with set_pose "fists" to isolate curl from orientation, and
    one-handed vs two-handed (see H6).

Hypotheses, each with its killing measurement

  • H1 — GripPoint pitch wrong for this model. The (90 − rake) + grip_rotate = 90° total assumes the Fab pistol's grip rake is 18°; if its
    actual grip angle differs, the barrel won't sit level with the hand's
    natural pointing direction. Measure: barrel direction vs
    grip-controller forward with the gun force-grabbed (grab_state +
    list_items poses), against the model's own geometry (probe: barrel along
    −Z after the (0, 90, 0) node rotation).
  • H2 — HandView OPENXR correction mis-measured on this glove import. The
    correction derives f_dir/palm from skeleton rest positions; wrong bone
    mapping (RigChainTypes) or a bad rest pose rotates the glove relative to
    the grip frame. Measure: hand_debug measured f_dir/palm vs
    targets ((0, −1, 0) / (±1, 0, 0)) and the correction euler — is the
    measured frame sane?
  • H3 — Left/right mirror error. The left hand mirrors the mono
    right-hand model (hand_view.gd:44-48, _MIRROR_BASIS). Measure:
    hand_debug both sides; compare thumb_dot / palm_flipped L vs R.
  • H4 — Laser/pointer vs grip frame disagreement (glove itself correct).
    The beam comes from the FunctionPointer with its own −90° X base while
    the glove follows the grip controller. Measure: pointer global basis vs
    grip global basis vs muzzle basis, numerically via grab_state +
    snapshot.
  • H5 — Curl calibration sign/axis wrong. Fingers curl away from the palm
    or the thumb opposes wrong. Measure: set_hand_curl to full grip via
    bridge and read fingertip positions — note hand_debug does not
    report fingertip positions, so this may need a new bridge readout; flag
    it.
  • H6 — Two-hand _follow branch. For a two-handed hold, _follow
    (grabbable.gd:334-348) does full basis alignment (primary→secondary axis
    onto the palm-to-palm line) — the known pivot gap (rifles = pitch/yaw-only
    steering, pistols = secondary hand snaps to the gun; not yet built). If
    Zach held the pistol two-handed, the twist could be this branch, not the
    grip pose. Measure: reproduce one-handed vs two-handed via bridge and
    compare glove/item bases.

Decisions needed from Zach

  • (5a) Which hand(s)/poses to reproduce (presumably one-handed pistol grip
    in R); whether to include the two-handed case; the half-curl call stays
    his.

Verification

Bridge-driven numeric comparison (angles printed, not eyeballed) + debug-cam
renders (yaw 180 = front) showing glove vs grip ring vs laser; a headless
suite asserting the measured angles once the cause is found. Re-measure
rather than re-derive — the 2026-10-28 headless diagnosis found the
derivation correct, so the error may be in the model import, the mirror, the
pointer, or the two-hand branch.

Suggested sequencing

  1. §3 eject pose first. Smallest, best-understood fix (the rotation math
    is verified, not guessed), and it shares its verification gate with the
    one remaining e2e failure. If the e2e still fails after it, the failure is
    a separate grab/freeze bug — split it out then, don't bundle it.
  2. §2 real renders, starting with the cyan shape's identity. Check the
    GLB materials first (Zach's correction), then live bridge identification
    if needed, before changing the visual. Builds the magwell-basis helper
    §1 also needs.
  3. §1 snap-slider + collider authoring. Uses the §2 visual +
    magwell-basis helper; biggest piece. Rail is A + direction + distance;
    eject holds at 30% of rail length.
  4. §4 grip preview. Editor-only, independent — can run in parallel with
    any of the above. Basic core work only (HandView + toggles); per-finger
    posing is a later iteration.
  5. §5 hand posing. Investigation first (bridge protocol above), fix
    after the cause is measured. Do not bundle its fix with §1–§4 — different
    evidence, different risk.

Decisions needed from Zach (collected)

Answered 2026-11-11 (see Decisions section): (1a) rides with the toolkit;
(1b) hand motion on the rail axis; (1c) rail = point A + direction to B +
distance; (1d) eject holds at 30% of rail length, drops on release; (1e)
collider authoring rides with §1; (2a) contained object, no model-swap
logic; (3a) mag_eject {pos, rot} field, shape B; (4a) HandView, L/R
toggle (visual only); (4b) ghosted/solid toggle, non-persistent; (4c)
per-finger posing is a later iteration.

Still open: (2b) first-out only, or lift the parked show-all too? (5a)
hand-posing reproduction scope: one-handed R, two-handed too?

Unknowns the plan flags

  1. The cyan shape's identity (§2) — reframed by Zach: most likely the
    gun model's or the glove's own geometry/material; check the GLB materials
    first, then live bridge identification if needed, before changing the
    visual.
  2. The e2e "bullet stays held in L" root cause (§3) — same code path as
    the eject pose, but the failure is about held-state after release, not
    pose; needs a bridge run with grab_state traces.
  3. magazine_opening_basis() callers (§3) — the eject path uses it, but
    the callers were not exhaustively listed; the build must check them before
    editing the shared function.
  4. Whether a stage HandView calibrates headlessly (§4) — it awaits a
    process frame; the dock instantiates headlessly in item_editor_check,
    but whether the editor _process ticks it enough to calibrate needs a
    probe during the build.
  5. The JSON shape for the A + direction + distance rail (§1) — against
    the existing mag_well/mag_well_b values; the build settles it, keeping
    Zach's authored values.
  6. Context for the still-open gun_bolt question (not one of the five, but
    adjacent): the rebuild doc already lists marker names as "gun_slide
    (slide + bolt)" — i.e. the standing design treats gun_slide as covering
    both, which bears on Zach's "drop gun_slide in favor of gun_bolt" line.

Out of scope / held

  • Show-all bullets in a mag stays parked unless Zach lifts it (decision 2b).
  • Half-curl for non-thumb fingers is Zach's pending edit, not this plan's.
  • Per-finger hand posing (the Claude "hold pose" API discussion) is a LATER
    iteration, after the basic core work — stored in the tracking issue, not
    built in this round.
  • The pivot split (rifles pitch/yaw-only, pistols snap the secondary hand)
    is a known gap that H6 may implicate — it is NOT in this plan unless the
    §5 measurement points at it; if it does, it becomes its own plan.
  • Nothing here touches the held-off items that are still held: none — the
    real-mag render hold was lifted by Zach raising problem 2 himself.

Appendix — Per-finger hold-pose modding API (later iteration, full design)

This is the later iteration from decision (4c): Zach wants to be able to
pose the hand grip via the grip preview as well. Source: a discussion with
a different AI agent (Claude), "Per-finger pose modding API design",
2026-11-09. The design below is reproduced in full so this issue is
self-contained; it is NOT part of the current build — iterate and discuss in
more detail after the rest of the basic core work is done.

The question (Zach, 2026-11-09)

For a modding API system where users can slap in a model, generate some
JSON, and boom have a new item/gun, I want to make it easy for people to
mod in IK poses for the items being held. I'm thinking a per-finger curl 0
to 1 float %, zero is hands model resting position, 1 is fist. Should I
allow them to do per-joint pos/rot/% sliding in the JSON? Or only expose
the 0 to 1 value in the JSON and have the per-joint be built into GDScript?

The answer: do both, in layers

Make the 0-1 curl the primary interface, and let the per-joint override be
an optional escape hatch that most modders never touch.

Why curl-first

  • Modders think "trigger finger extended, other three wrapped, thumb over
    the top," not "index proximal is -42 deg on X."
  • The JSON describes intent rather than skeleton details. If you later swap
    the hand rig, change bone orientations, or add a second hand model, every
    existing mod still works.
  • Curl 0 to 1 doesn't care about each bone's local axis conventions. Raw
    per-joint rotations in JSON are tied to your rig's bone axes, so they
    break the moment you touch the rig.

What GDScript owns

  • The per-finger curve: each joint's min/max rotation, and how curl
    distributes across the proximal, intermediate and distal joints. Real
    fingers don't curl evenly, and the distal joint usually follows the
    intermediate one at roughly 2/3 ratio. A tuned curve looks much better
    than linear interpolation.
  • Thumb special-casing. A single 0-1 doesn't cover the thumb, because it
    also swings across the palm. Give it curl plus an opposition value,
    or just treat it as two floats.
  • Mirroring for the left hand, so modders author one pose.

What the JSON exposes

"hold": {
  "pose": "pistol_grip",
  "fingers": {
    "thumb":  { "curl": 0.6, "opposition": 0.8 },
    "index":  { "curl": 0.1 },
    "middle": { "curl": 0.9 },
    "ring":   { "curl": 0.95 },
    "pinky":  { "curl": 1.0 }
  },
  "spread": 0.2,
  "offset": { "pos": [0, 0, 0], "rot": [0, 0, 0] },
  "off_hand": { "pose": "foregrip", "offset": { "pos": [0, 0.02, -0.1], "rot": [0, 0, 0] } }
}

Three things in there are worth copying:

  • Named base poses (pose) that the game ships and the modder tweaks.
    Most items can just say "pose": "pistol_grip" and be done, which is the
    real "easy" path.
  • Curl and spread as the only per-finger values by default.
  • A separate hand-to-item offset, because most "the hand looks wrong"
    problems are placement, not finger angles.

The escape hatch

Add an optional "joints" block that applies additive offsets in degrees
on top of the curl result:

"index": { "curl": 0.1, "joints": { "distal": [0, 0, -15] } }

Make it additive instead of absolute. The curl still does 95% of the work,
and a modder can nudge one knuckle without re-authoring the whole finger.
If you let them set absolute joint rotations, they will, and then you can
never change the rig.

Practical notes

  • Clamp everything in code so a bad value can't fold fingers through the
    palm.
  • Ship a small in-game or editor pose preview with sliders that exports the
    JSON. Authoring numbers blind is the real friction, more than the schema.
  • Keep the schema versioned ("schema": 1) so you can change the
    underlying hand model later without breaking mods.
# DEAD AIR — Gun fix plan (headset test 2026-11-11) Status: BUILD AUTHORIZED by Zach 2026-11-11 ("store this as an issue we can track and then get to work!"), with the decisions in the Decisions section below. Tracking issue: dead-air/engine #40 (https://git.merith.xyz/dead-air/engine/issues/40) — the full text of this plan is the issue body, per Zach's rule that tracking issues carry the full depth, not a summary. Source: Zach's in-headset test of the generic pistol, 2026-11-11 13:38 (video + image). Branch: `editor-addon`, tip `b792f03` at time of writing. Code traces verified on that tip; line numbers below are from it. Related docs: `dead-air-mod-toolkit-editor-plan.md` (Phase 1 open decision (d) asks whether the mag-rail behavior rides with Phase 1 or stays in the rebuild; this plan answers it as its first decision), `dead-air-rebuild-change-list.md` (mag rail behavior + internal mag, Phase 2, design only, NOT built). ## The five problems, as Zach reported them 1. The mag well is not a snap-slider. Intended: bring the mag opening to the bottom of the magwell, the mag snaps to that WHILE STILL HELD, slide it up into the magwell, it "clicks in" and is deparented from the hand. 2. Snapped objects still use placeholder renders: bullets in the mag render the old simple cylinder bullet, not the loaded bullet model; the gun still renders the old simple box mag, not the mag GLB. 3. No way to adjust the "bullet eject" visual rotation/position: position is right, but the ejected bullet's tip points directly into the mag. 4. No visual way to reference/check the hand grip pose in the editor: the staging scene shows no hand at the grip. 5. Hand posing is STILL BROKEN when holding a grip. Two standing notes. First, the real-mag-GLB render was HELD by Zach on 2026-11-10 (unfiled, not to be re-raised) — he has now raised it himself as problem 2, so the hold is lifted by him; this plan records that and does not re-litigate it. Second, problem 5 is investigation-first by rule: his photos and video are user-reported symptoms only, never proof of live positions or build state. Nothing in §5 asserts a cause from them; it lays out how to reproduce and measure the fault through the bridge instead. ## Decisions (Zach, 2026-11-11) — applied to this plan - (1a) **Snap-slider rides with the toolkit (Phase 1).** The toolkit plan's open decision (d) is answered. - (1b) **Slide input is hand motion on the rail axis** — the mag should look like it slides in at the motion of the hand. The disconnect between object physics and hand physics (for now) should make this easy. He has plans to fix that disconnect to a degree, but that is for LATER, not this plan. - (1c) **Rail definition: point A + vector direction to point B + distance.** Point A is outside (the collider check for the mag's opening reference point); the vector direction points from A to point B, and the distance gives the internal stop position. (This replaces the two-absolute-points `a`/`b` framing in §1 below — same segment, parametric form.) - (1d) **Eject-hold behavior, part of §1:** holding eject holds the mag so you can grab it; the mag only drops when the eject button is released. Holding eject drops the mag about **30% of the rail length** (out along the rail from the inside), where it holds as a grabbable entity. - (1e) **Collider authoring, part of §1:** the gun still uses a bounding-box collider, and there is no way in the editor to change that to a separate collider mesh in the model (or assembled primitive meshes for the colliders). The build must add a way to adjust the gun's colliders in the editor — a collider mesh from the model, or assembled primitives. This is needed for the rail's collider check at point A, so it rides with §1. - (2a) **Real renders use the contained object.** Use what is "contained" in the snapper point — it is already a value that is there, the physical object of the mag itself — so no model-swap logic is needed, since the object instance is still *right there*. Likewise the first-out bullet uses the model of the object that is first-out for the mag. That makes it functional and important for the systems as a whole. - (2b) **Cyan slab: Zach's correction.** He has zero clue what the "cyan slab" is and suggests Lantern may be mistaken by the gun model or his hand model. The plan's §2 unknown is reframed: the shape in the video frame is most likely the gun model's or the glove's own geometry/material — the identification step checks the GLB materials first, before treating it as a mystery object. (Show-all decision (2b) is still open — he did not answer it; first-out only stands until he says otherwise.) - (3a) **Eject pose is fix shape B:** a `mag_eject` field containing pos and rot, just like other elements — the eject pose is literally the `opening` pose, raised a little bit in a specific direction, defined in the editor just like other elements. The +90° pitch correction is baked into the default so the tip points forward. - (4a) **Grip preview is fix shape A** (stage instances a `HandView`). Toggle between left and right hand; both share the same pose/rot, so the toggle is purely a visual helper. - (4b) **Ghosted and solid**, toggle in the editor preview (non-persistent setting). - (4c) **He wants to be able to *pose* the hand grip via this as well.** The attached discussion with a different AI agent (Claude, "Per-finger pose modding API design", 2026-11-09, file `~/workspace/user/files/Claude-Per-finger_pose_modding_API_design-20261111-1408.md`) is close to what he wants: per-finger curl 0–1 (0 = hand model resting, 1 = fist), named base poses (`pistol_grip`), curl + spread, thumb curl + opposition, a separate hand-to-item offset, per-joint additive escape hatch (not absolute), GDScript owning the per-joint curves and left-hand mirroring, clamps in code, a pose preview with sliders that exports the JSON, versioned schema. **This is a LATER iteration** — iterate and discuss in more detail after the rest of the basic core work is done. It is stored in the tracking issue, not built in this round. ## Data the plan stands on - Pistol `generic_pistol.json`: `mag_well` {pos (0, 0, 0.06), rot (−12, 0, 0)}, `mag_well_b` (0, −0.0489, 0.0704), `chamber` (0, 0.0109, 0.0382), `muzzle` (0, 0.0109, −0.113), `grip_offset` (0, −0.03, 0.01), `grip_rotate` [18, 0, 0]. Assembly: slide origin (0, 0, 0.00004)/(0, 90, 0), travel (0, 0, 0.05); fire_select origin (0.00005, −0.006296, 0.010929)/(0, 90, 0), modes safe + semi, offsets zeroed. - Mag `generic_pistol.json`: `opening` {pos (0, 0.07216, −0.02078), rot (−97.274, 0, 0)}, capacity 15, `model_path` generic_pistol_mag.glb. - Bullet `bullet_9mm.json`: `model_path` bullet_9mm.glb, `grip_rotate` [90, 0, 0]. - GLB probe (headless, read-only): bullet is one mesh `Handgun_Bullet`, node rot (0, 89.98, 0); mesh-local long axis X, fat end (radius 5.5 mm) at low X, tip (radius 2.3 mm) at high X — in the instantiated scene the bullet's tip points along −Z, base at +Z. Mag is one mesh `Handgun_Magazine`, node rot (85, 0, −90), mesh 0.065×0.025×0.137 m. Pistol is 5 nodes, all (0, 90, 0). ## 1. Mag-well snap-slider ### Current state There is no snap, no slide, and no held-while-sliding state anywhere in-game. Insert is a proximity check run every physics frame while a mag is held (`modules/deadair/src/interaction/hand_interactor.gd:269-271` calls `_try_insert_magazine`): ```gdscript # hand_interactor.gd:364-379 if item.global_position.distance_to(fa.mag_well_position()) > MAG_INSERT_RADIUS: return ... rig.inventory.release_hand(hand_id) item.grabbable.release_all(false) item.queue_free() fa.insert_magazine(inst) ``` `MAG_INSERT_RADIUS := 0.09` (line 30). The check uses the mag WorldItem's **origin**, not its opening — the opening can be ~16 cm from the well when it fires. `Firearm.insert_magazine` (`modules/deadair/src/weapons/firearm.gd: 114-120`) is a pure data op (`item().add_child_item(mag)`) — no pose, no reparenting, no rail. Eject is the mirror image (`hand_interactor.gd:355-361`, `_drop_magazine`, on BUTTON_PRIMARY press at :255-256): the mag spawns 8 cm below the magwell and falls free; it is not a grabbable entity held at any rail end. Two more facts the fix must respect. `mag_well_b` is read **nowhere in-game** — only the editor schema (`addons/da_item_editor/item_schema.gd:29`) and the stage reference it. And `mag_well_position()` (`firearm.gd:110-111`) uses `WorldItem._pos(...)`, which extracts **pos only and drops rot** (`modules/deadair/src/items/world_item.gd:910-913`) — so the −12° magwell rotation is ignored in-game today. There is no rail-following logic in-game: `grep -i rail` over `modules/deadair/src/` returns only fence rails, and there is no `slide` at all in `firearm.gd` or `hand_interactor.gd`. The closest existing mechanism is `Snapper` (`modules/deadair/src/inventory/snapper.gd:54-65`): `snap()` releases the item and re-grabs it with the snapper node as the "hand" (`wi.grabbable.grab(self, ...)`), so the item hangs off the snapper by its primary grab point using the same `Grabbable` follow. But it holds a **fixed pose only** — nothing moves an item along a segment while held — and snapping happens on release (`hand_interactor.gd:319-324` checks `BodySlots.slot_for_drop`), not on approach. ### Fix shape New magwell-snap state in `HandInteractor` (or a `MagwellSnapper` node at rail end `b`, reusing the `Snapper.grab` pattern), in four pieces. The rail is defined per Zach's decision (1c) as **point A + vector direction to point B + distance**: A is outside (the collider check for the mag's opening reference point), the direction points from A to B, and the distance gives the internal stop position. (The existing `mag_well`/`mag_well_b` two-point data is the same segment in another form — A is the outside end, B the inside end; the build settles the JSON shape, keeping Zach's authored values.) - (a) Trigger on **opening → point A proximity/collider check**, not mag-origin → magwell point. The check is the opening reference point against the collider at A. - (b) Snapped state: the mag stays held, its follow target constrained to the rail segment A → B (project the hand position onto the segment; the segment is ~5.0 cm gun-local, `a` (0, 0, 0.06) to `b` (0, −0.0489, 0.0704)). Slide input is hand motion on the rail axis (decision 1b) — the mag looks like it slides in at the motion of the hand. - (c) At `t = 1` (reached B), run the existing `insert_magazine` + deparent from the hand. - (d) Eject, per decision (1d): holding eject drops the mag about **30% of the rail length** out along the rail, where it holds as a separate grabbable entity so you can grab it; the mag only drops (falls free) when the eject button is released. This replaces `_drop_magazine`'s free spawn. The mag visual must also start honoring the magwell rotation during the slide (see §2 — today it doesn't). A shared magwell-basis helper belongs here: `mag_well_position()` drops rot, and both the slide and the visual need the full basis. Collider authoring, per decision (1e), rides with this section: the gun still uses a bounding-box collider, and the editor has no way to change it to a separate collider mesh in the model or assembled primitive meshes. The build must add a way to adjust the gun's colliders in the editor — a collider mesh from the model, or assembled primitives — since the rail's collider check at point A needs a real collider on the gun, not just a bounding box. (This is the toolkit plan's Phase 3 / old Phase C colliders work, pulled forward into §1 because the snap trigger depends on it.) ### Decisions needed from Zach - (1a–1e) Answered 2026-11-11 (see Decisions above). Remaining for the build to settle: the JSON shape for the A + direction + distance rail (against the existing `mag_well`/`mag_well_b` values), and the collider authoring UI shape (collider mesh from the model vs assembled primitives, or both). ### Verification Headless: new rail-follow suite — snap at `b` while still held → slide to `a` → click-in deparents; eject holds at `b` — alongside existing `tests/mag_interaction_check.gd`. Bridge e2e: extend the mag test in `addons/da_debug_bridge/mcp_server/test_e2e.py` (`place_controller` drives the mag opening to `b`, then along the rail; `grab_state` asserts still-held mid-slide, then inserted). In-headset feel check. ## 2. Real renders for the snapped bullet and mag ### Current state The inserted mag is a procedural box, `firearm.gd:271-286` (`_refresh_mag_visual`): ```gdscript var size := WorldItem._v3(def.model.get("size") if def != null else null, Vector3(0.03, 0.1, 0.04)) _mag_visual.mesh = WorldItem._box(size) mat.albedo_color = Color(0.12, 0.12, 0.12) _mag_visual.position = WorldItem._pos(table.get("mag_well"), Vector3.ZERO) + Vector3(0, -size.y * 0.4, 0) ``` The mag JSON carries no `model.size`, so it is always the default **30×100×40 mm dark-gray box, with no rotation** (magwell −12° ignored), at the magwell dropped 40% of its height. The real mag GLB is 24.6×142.4×77.1 mm. Bullets in a loaded mag are a procedural gold cylinder, `world_item.gd:625-641` (`_build_first_out_bullet`): `_cyl(rs.x, rs.y, false)` with `Magazines.round_size` → pistol default `Vector2(0.0045, 0.029)` (`modules/deadair/src/weapons/magazines.gd:44-51`), posed at the opening with the opening rotation, visible only while the mag holds ≥1 round (`_refresh_first_out_bullet`, `world_item.gd:253-256`). **Only the first-out round renders** — show-all stays parked (comment at `world_item.gd:620-624`). The bullet GLB is never instanced for it. What exists to build on: `model_path` is already resolved to a loaded `PackedScene` on both the element and the definition (`modules/deadair/types/item.gd:102-103` and `:60-61`), so `mag.definition().world_scene` **is** the mag GLB, available to `_refresh_mag_visual` today. `_build_visual` (`world_item.gd:486-500`) already instantiates `world_scene` for the mag's own WorldItem — and still builds the first-out bullet for GLB mags (line 500). The editor already solved the posing math the game would need: the magwell ghost uses orient basis = magwell basis with the wrapper positioned by measured bounds (`addons/da_item_editor/item_stage.gd:1508-1515`). ### Fix shape Per decision (2a): use what is **contained** in the snapper point. The mag object instance is already there — `insert_magazine` does `item().add_child_item(mag)`, so the physical mag is a child of the gun — so **no model-swap logic is needed**: the visual shows the contained mag instance itself instead of building a procedural box. Likewise the first-out bullet uses the model of the object that is first-out for the mag — the contained bullet's model — which makes it functional and important for the systems as a whole. - (a) `_refresh_mag_visual`: show the contained mag instance (its own `world_scene`/visual) at the game's insert pose (magwell pos + the 0.4 drop, **with the magwell rotation applied** — needs the magwell-basis helper from §1), accounting for the GLB's own node rotation (mag mesh node is rotated (85, 0, −90) in the file; mirror the editor's orient/bounds math, don't re-derive it). Keep the box as fallback when the contained mag has no model. - (b) First-out bullet: render the contained first-out bullet object's model (the bullet GLB) instead of the cylinder, applying the same +90° pitch correction the editor uses (see §3) because the GLB's nose is at −Z. Keep the cylinder as fallback when the bullet has no model. - Side effect to carry: switching to a GLB loses the procedural colored base ring (`world_item.gd:596-615`, bullet band is procedural-path only; same was true for the mag band at `:568-591`). ### Unknown — the cyan slab (reframed by Zach) The authored mag visual is dark gray (0.12, 0.12, 0.12), so it does not explain the flat cyan shape on the side of the grip in the video's last frame. Zach's correction (2026-11-11): he has zero clue what the "cyan slab" is and suggests Lantern may be mistaken by the gun model or his hand model. The identification step therefore checks the GLB materials first — the pistol, mag, and glove models' own geometry and materials — before treating it as a mystery object. If it is part of the gun or hand model, there is nothing to fix here beyond the real renders above; if live bridge identification (`list_items` + snapshot + a render of the held loaded gun) shows something else, that finding goes into the build. The only other cyan in-game objects found are the `BoundsGizmo` wireframe (`world_item.gd:372-378`, `Color(0.2, 0.9, 1.0)`, built on every item but `gizmos_enabled` defaults `false`, line 69 — and it is 1 px lines, not a slab) and the `VolumeContainer` (`modules/deadair/src/inventory/volume_container.gd:37` `WIRE_COLOR` cyan (0, 0.8, 1.0), `:39` `SOLID_COLOR` teal (0, 0.35, 0.38)) — which is fixed in the sandbox, not attached to the gun. ### Decisions needed from Zach - (2a) Answered 2026-11-11 (contained object, no model-swap logic). - (2b) Still open: first-out only, or lift the parked show-all too? ### Verification Headless `mag_filo_check` / `firearm_check` (visual node type + pose assertions); xvfb render of the inserted mag (GLB silhouette, not a box) and the first-out bullet; bridge e2e (spawn loaded gun, `list_items` + screenshot); in-headset check. ## 3. Bullet-eject visual pose ### Current state, end to end `_eject_bullet` (`hand_interactor.gd:411-430`): ```gdscript var wi := WorldItem.spawn(round, level, Transform3D(mag_wi.global_transform.basis * mag_wi.magazine_opening_basis(), _float_position(mag_wi))) ``` `_float_position` (`:435-437`) = `magazine_opening_position() + mag_global_up * FLOAT_HEIGHT`, `FLOAT_HEIGHT := 0.04` (line 35). `_update_floating_bullet` (`:443-461`) re-applies the **same transform every physics frame** while the trigger is held, and on release unfreezes the bullet and clears tracking (`:456-460`). `magazine_opening_basis()` (`world_item.gd:241-243`) is just `Basis.from_euler(opening rot)` — for this mag, `R_x(−97.274°)`, **with no GLB correction**. The spawned round is a real `WorldItem`, so it renders the bullet GLB. Why the tip points into the mag — measured, not guessed. The editor ghost applies `BULLET_OPENING_PITCH_CORRECTION := 90.0` (`item_stage.gd:92`: "The correction maps the GLB bullet's nose onto +Y. The opening rotation is then composed on top") at `:1507` and `:1550`. The game path never applies it. With the probe's finding (GLB nose at −Z): opening rot `R_x(−97.274°)` maps nose −Z → (0, −0.992, +0.127) in mag-local space — **nose pointing down into the mag body**, exactly the reported symptom. With the editor's correction applied first (nose −Z → +Y), the same opening rot maps +Y → (0, −0.127, −0.992) — nose forward (−Z), slightly down, matching the first-out intent stated at `world_item.gd:202-204` ("base at the back (+Z), nose forward (−Z)"). The procedural first-out cylinder never showed this because a Y-axis cylinder is symmetric — the bug only bites the GLB bullet, i.e. exactly the eject path. Position is right because `_float_position` is correct; only the rotation is wrong. ### Fix shape Per decision (3a), **fix shape B is what Zach wants**: a `mag_eject` field containing pos and rot, just like other elements. The eject pose is literally the `opening` pose, raised a little bit in a specific direction, defined in the editor just like other elements. So: a new magazine-table field `mag_eject: {pos, rot}`; the eject float pose (`_eject_bullet` / `_update_floating_bullet`) uses it; the editor authors it like `opening` (a marker/ghost for the floating bullet's pose). The default is the opening pose raised a little in the eject direction, with the +90° pitch correction baked in so the tip points forward, not into the mag. (The minimal (A) correction is subsumed: the correction is part of the default, not a separate code path. The build must still check the callers of `magazine_opening_basis()` before touching the shared function — the first-out cylinder path uses the sibling `magazine_opening_rotation()` and must stay untouched.) ### E2E link The one remaining e2e failure, "bullet stays held in L after the trigger release" (`test_e2e.py:467`), exercises this same path: eject pops the bullet (`:455`), L force-grabs it (`:460`), R releases the trigger (`:464`), check L still holds (`:467`). The release branch is `_update_floating_bullet: 443-461`: if L has grabbed it, `is_held()` clears tracking (`:452-455`); otherwise `not trigger_down` unfreezes it and clears (`:456-460`). The failure means L's held is `None` after release. Candidate mechanisms, hypotheses only: the unfreeze-on-release firing while the grab is mid-flight; `force_grab` racing the per-frame transform rewrite at `:461` while L's `_follow` is also driving the bullet; frozen-body + `move_and_collide` interplay while held. It needs a bridge run with `grab_state` traces to distinguish. **The plan treats the eject-pose fix and this failure as the same verification gate (re-run the e2e mag test), and if it still fails, it is a separate grab/freeze bug, not the pose.** ### Decisions needed from Zach - (3a) Fixed correction only, or an adjustable eject-pose field in the magazine table (+ editor support)? ### Verification Headless `mag_interaction_check` / `mag_filo_check` (eject pose assertion: bullet nose basis vs opening); bridge e2e mag test (the "eject pops a loose bullet" + "stays held" checks); side render of the floating bullet showing the tip forward, not into the mag. ## 4. Grip-pose preview in the editor ### Current state The stage's entire grip visualization is a 6 cm RGB axes triad. `_add_grip_marker` (`item_stage.gd:882-895`) builds a `Marker3D` named `gun_grip` at `grip_default pos + grip_offset`, rotation `drot + grip_rotate`, whose only child is `_make_axes(GRIP_AXIS_LEN)` (`:1665-1686`, `GRIP_AXIS_LEN := 0.06`, line 63) — unshaded ImmediateMesh lines. A grep for `glove|HandView|hand_model|vr_glove` in `item_stage.gd` returns **zero hits**. The secondary grip is the same story: a `gun_foregrip` marker exists (`:607`, and the game builds `GrabSecondary` from `weapon.foregrip` at `world_item.gd:145-150` — only `rifle_test.json` carries it), but no hand is shown there either. What exists to reuse: (a) the ghost/reference pattern: `_load_item_model_node` (`item_stage.gd:1558-1570`) loads `world_scene`, else `model_path` resolved against the item JSON's directory, else the procedural stand-in; `_build_references_for` (`:1414-1450`) wraps it in a `wrapper Node3D + orient Node3D` pair positioned by measured bounds (`_position_reference`, `:1500-1515`). A grip preview would instance a hand model the same way: a wrapper at the `gun_grip` marker pose with an orient node. (b) The hand asset: `modules/deadair/assets/player/hands/vr_glove.glb` (Valve vr_glove, mono right-hand model; `HandView.model_path` default, `hand_view.gd:50`). (c) A curl driver already exists: `HandView.set_debug_curl` (`hand_view.gd:783-786`, via the bridge's `set_hand_curl`). The catch: instancing the raw GLB is not the same as the game's hand. `HandView` (`modules/deadair/src/player/hand_view.gd`) builds the model with its OPENXR alignment correction measured from the skeleton's rest geometry (`_apply_model_correction`, `:481-544`; `_openxr_alignment`, `:576-602`), which needs a processed frame to calibrate (`_build_and_calibrate` awaits one frame, `:119-125`). A raw GLB in the stage would sit in uncorrected model orientation. So the preview must either reuse `HandView` itself (it builds under any plain `Node3D` parent) or share the correction math — not re-derive it. ### Fix shape Per decisions (4a–4c), **fix shape A is what Zach wants**: the stage instances a `HandView` at the `gun_grip` pose — reusing the game's own alignment, showing exactly what the hand looks like on the grip — with a **left/right toggle** (both hands share the same pose/rot, so the toggle is purely a visual helper) and a **ghosted/solid toggle** in the editor preview (non-persistent setting, not saved to JSON or metadata). **Per-finger posing is a LATER iteration, not this build.** Zach wants to be able to *pose* the hand grip via this as well, and the attached Claude discussion ("Per-finger pose modding API design", 2026-11-09) is close to what he wants: per-finger curl 0–1 (0 = hand model resting, 1 = fist), named base poses (`pistol_grip`), curl + spread, thumb curl + opposition, a separate hand-to-item offset, a per-joint additive escape hatch (not absolute), GDScript owning the per-joint curves and left-hand mirroring, clamps in code, a pose preview with sliders that exports the JSON, versioned schema. Iterate and discuss in more detail after the rest of the basic core work is done. It is stored in the tracking issue, not built in this round. (The foregrip second-hand question from the old (4c) is folded into that later iteration unless the basic preview makes it trivial.) The pose the stage would show is the same one the game computes (`world_item.gd:140-173` uses the same `grip_default + grip_offset / grip_rotate` values), so the preview's value is precisely seeing `HandView`'s correction + curl against the authored grip. ### Decisions needed from Zach - (4a–4c) Answered 2026-11-11 (shape A, L/R toggle, ghosted/solid toggle, per-finger posing later). Remaining for the build: the toggle placement in the dock (near the slide sim / reference slots area) and whether the preview defaults on or off. ### Verification Editor render (glove on the pistol grip at the authored pose, fingers wrapped vs floating); headless suite (stage builds a grip-preview node at the marker transform; the dock already instantiates headless in `item_editor_check`). ## 5. Hand posing when holding a grip — investigation first Nothing in this section is asserted from Zach's screenshots or video — they are symptoms only. This is the code chain plus how to measure the fault. ### The chain, with lines 1. Controller: `LeftGrip`/`RightGrip` are `XRController3D` with `pose="grip"` (`modules/deadair/src/player/player_rig.gd:18-30`, `:83-86`). Each carries a `HandView` (Valve vr_glove, mono model mirrored for the left). 2. Glove alignment: `HandView` defaults to `Alignment.OPENXR` (`hand_view.gd:73`), correction measured from skeleton rest geometry in `_apply_model_correction` (`:481-544`) via `_openxr_alignment` (`:576-602`); `openxr_tune_*` are zero by default (`:76-77`). The `LEGACY_TUNE` path with `tune_euler_deg (45, −10, 100)` (`:88`) exists but is **not** the default — and `tune_enabled := true` (`:83`) only affects the LEGACY path (`:559-561`). Target frame for OPENXR: finger direction → `(0,−1,0)`, palm → `(±1,0,0)` (`:583-584`), fist centre placed a little wrist-side of the middle knuckle (`:591-593`). 3. Item follows the hand: `Grabbable._follow` (`modules/deadair/src/interaction/grabbable.gd:319-351`); single-hand target = `h0.global_transform * p0.affine_inverse()` (line 350), where `p0` is the `GrabPoint.grip_transform()` (`modules/deadair/src/interaction/grab_point.gd:30-36`). So the item hangs off the hand by the inverse grip pose — the glove orientation *is* the hand node's orientation. 4. Grip pose authoring: `world_item.gd:140-173` — weapon base rotation `(90 − rake, 0, 0)` = `(72, 0, 0)` for the pistol (rake 18.0), authored `grip_poses` override (the pistol JSON has none), then `grip_offset` added to position (`:167`) and `grip_rotate` added to `rotation_degrees` (`:173`): total pitch 72 + 18 = 90°. 5. Laser is a **separate frame**: `FunctionPointer` on the grip controller with base −90° about X (`player_rig.gd:100-109`, `_apply_pointer_angle` `:403-424`). The beam is the pointer's −Z after its own base rotation; the glove follows the grip controller's basis. They only agree if both derivations are right — consistent with the 2026-10-28 finding that the laser was the most visibly misaligned element while the headless check found the glove derivation correct (glove 0° off the Grip ring). 6. Curl: `set_curl` (`hand_view.gd:765-777`) — index = trigger amount, others = grip amount, thumb = grip × 0.7, `REST_CURL 0.12`, `JOINT_CURL [1.35, 1.75, 1.05, 0.30]`; axis/sign auto-calibrated from rest geometry (`_calibrate_curl`, `:687+`). **No half-curl for non-thumb exists** — the `amount *= 0.5` change is Zach's pending edit; do not propose applying it. ### Measurement surface (bridge, already built) `hand_debug` (`addons/da_debug_bridge/debug_bridge.gd:940-963` → `HandView.debug_snapshot`, `hand_view.gd:193-218`) reports alignment, tune values, `curl_axis`/`curl_sign`, measured `f_dir`/`palm`, `thumb_dot`, `palm_flipped`, `target_f`/`target_p`, and the correction origin/euler. Plus `grab_state`, `force_grab`, `set_controller_input`, `list_items` (`addons/da_debug_bridge/mcp_server/server.py:250-549`), `place_controller`/`place_rig`, and `set_pose` ("fists"/"open", `debug_bridge.gd:985-1000`, which drives the live HandViews via `set_debug_curl`). ### Reproduction protocol (no photos) 1. Spawn the pistol, `force_grab` in R (the e2e pattern, `test_e2e.py:411-419`). 2. Read `grab_state` (hand pose + held item pose) and `hand_debug` (measured frame + correction). 3. Compute numerically: angle between the glove's finger direction and the barrel direction; angle between the laser beam and the barrel; glove basis vs `GrabPoint` basis. Expected: barrel level with the beam per the `grip_rotate [18,0,0]` fix; glove 0° off the Grip ring per the 2026-10-28 headless finding. 4. Repeat with `set_pose "fists"` to isolate curl from orientation, and one-handed vs two-handed (see H6). ### Hypotheses, each with its killing measurement - **H1 — GripPoint pitch wrong for this model.** The `(90 − rake) + grip_rotate` = 90° total assumes the Fab pistol's grip rake is 18°; if its actual grip angle differs, the barrel won't sit level with the hand's natural pointing direction. *Measure:* barrel direction vs grip-controller forward with the gun force-grabbed (`grab_state` + `list_items` poses), against the model's own geometry (probe: barrel along −Z after the (0, 90, 0) node rotation). - **H2 — HandView OPENXR correction mis-measured on this glove import.** The correction derives `f_dir`/`palm` from skeleton rest positions; wrong bone mapping (`RigChainTypes`) or a bad rest pose rotates the glove relative to the grip frame. *Measure:* `hand_debug` measured `f_dir`/`palm` vs targets ((0, −1, 0) / (±1, 0, 0)) and the correction euler — is the measured frame sane? - **H3 — Left/right mirror error.** The left hand mirrors the mono right-hand model (`hand_view.gd:44-48`, `_MIRROR_BASIS`). *Measure:* `hand_debug` both sides; compare `thumb_dot` / `palm_flipped` L vs R. - **H4 — Laser/pointer vs grip frame disagreement (glove itself correct).** The beam comes from the `FunctionPointer` with its own −90° X base while the glove follows the grip controller. *Measure:* pointer global basis vs grip global basis vs muzzle basis, numerically via `grab_state` + snapshot. - **H5 — Curl calibration sign/axis wrong.** Fingers curl away from the palm or the thumb opposes wrong. *Measure:* `set_hand_curl` to full grip via bridge and read fingertip positions — note `hand_debug` does **not** report fingertip positions, so this may need a new bridge readout; flag it. - **H6 — Two-hand `_follow` branch.** For a two-handed hold, `_follow` (`grabbable.gd:334-348`) does full basis alignment (primary→secondary axis onto the palm-to-palm line) — the known pivot gap (rifles = pitch/yaw-only steering, pistols = secondary hand snaps to the gun; not yet built). If Zach held the pistol two-handed, the twist could be this branch, not the grip pose. *Measure:* reproduce one-handed vs two-handed via bridge and compare glove/item bases. ### Decisions needed from Zach - (5a) Which hand(s)/poses to reproduce (presumably one-handed pistol grip in R); whether to include the two-handed case; the half-curl call stays his. ### Verification Bridge-driven numeric comparison (angles printed, not eyeballed) + debug-cam renders (yaw 180 = front) showing glove vs grip ring vs laser; a headless suite asserting the measured angles once the cause is found. Re-measure rather than re-derive — the 2026-10-28 headless diagnosis found the derivation correct, so the error may be in the model import, the mirror, the pointer, or the two-hand branch. ## Suggested sequencing 1. **§3 eject pose first.** Smallest, best-understood fix (the rotation math is verified, not guessed), and it shares its verification gate with the one remaining e2e failure. If the e2e still fails after it, the failure is a separate grab/freeze bug — split it out then, don't bundle it. 2. **§2 real renders, starting with the cyan shape's identity.** Check the GLB materials first (Zach's correction), then live bridge identification if needed, before changing the visual. Builds the magwell-basis helper §1 also needs. 3. **§1 snap-slider + collider authoring.** Uses the §2 visual + magwell-basis helper; biggest piece. Rail is A + direction + distance; eject holds at 30% of rail length. 4. **§4 grip preview.** Editor-only, independent — can run in parallel with any of the above. Basic core work only (HandView + toggles); per-finger posing is a later iteration. 5. **§5 hand posing.** Investigation first (bridge protocol above), fix after the cause is measured. Do not bundle its fix with §1–§4 — different evidence, different risk. ## Decisions needed from Zach (collected) Answered 2026-11-11 (see Decisions section): (1a) rides with the toolkit; (1b) hand motion on the rail axis; (1c) rail = point A + direction to B + distance; (1d) eject holds at 30% of rail length, drops on release; (1e) collider authoring rides with §1; (2a) contained object, no model-swap logic; (3a) `mag_eject` {pos, rot} field, shape B; (4a) HandView, L/R toggle (visual only); (4b) ghosted/solid toggle, non-persistent; (4c) per-finger posing is a later iteration. Still open: (2b) first-out only, or lift the parked show-all too? (5a) hand-posing reproduction scope: one-handed R, two-handed too? ## Unknowns the plan flags 1. **The cyan shape's identity** (§2) — reframed by Zach: most likely the gun model's or the glove's own geometry/material; check the GLB materials first, then live bridge identification if needed, before changing the visual. 2. **The e2e "bullet stays held in L" root cause** (§3) — same code path as the eject pose, but the failure is about held-state after release, not pose; needs a bridge run with `grab_state` traces. 3. **`magazine_opening_basis()` callers** (§3) — the eject path uses it, but the callers were not exhaustively listed; the build must check them before editing the shared function. 4. **Whether a stage `HandView` calibrates headlessly** (§4) — it awaits a process frame; the dock instantiates headlessly in `item_editor_check`, but whether the editor `_process` ticks it enough to calibrate needs a probe during the build. 5. **The JSON shape for the A + direction + distance rail** (§1) — against the existing `mag_well`/`mag_well_b` values; the build settles it, keeping Zach's authored values. 6. **Context for the still-open gun_bolt question** (not one of the five, but adjacent): the rebuild doc already lists marker names as "`gun_slide` (slide + bolt)" — i.e. the standing design treats `gun_slide` as covering both, which bears on Zach's "drop gun_slide in favor of gun_bolt" line. ## Out of scope / held - Show-all bullets in a mag stays parked unless Zach lifts it (decision 2b). - Half-curl for non-thumb fingers is Zach's pending edit, not this plan's. - Per-finger hand posing (the Claude "hold pose" API discussion) is a LATER iteration, after the basic core work — stored in the tracking issue, not built in this round. - The pivot split (rifles pitch/yaw-only, pistols snap the secondary hand) is a known gap that H6 may implicate — it is NOT in this plan unless the §5 measurement points at it; if it does, it becomes its own plan. - Nothing here touches the held-off items that are still held: none — the real-mag render hold was lifted by Zach raising problem 2 himself. ## Appendix — Per-finger hold-pose modding API (later iteration, full design) This is the later iteration from decision (4c): Zach wants to be able to *pose* the hand grip via the grip preview as well. Source: a discussion with a different AI agent (Claude), "Per-finger pose modding API design", 2026-11-09. The design below is reproduced in full so this issue is self-contained; it is NOT part of the current build — iterate and discuss in more detail after the rest of the basic core work is done. ### The question (Zach, 2026-11-09) For a modding API system where users can slap in a model, generate some JSON, and boom have a new item/gun, I want to make it *easy* for people to mod in IK poses for the items being held. I'm thinking a per-finger curl 0 to 1 float %, zero is hands model resting position, 1 is fist. Should I allow them to do per-joint pos/rot/% sliding in the JSON? Or only expose the 0 to 1 value in the JSON and have the per-joint be built into GDScript? ### The answer: do both, in layers Make the 0-1 curl the primary interface, and let the per-joint override be an optional escape hatch that most modders never touch. **Why curl-first** - Modders think "trigger finger extended, other three wrapped, thumb over the top," not "index proximal is -42 deg on X." - The JSON describes intent rather than skeleton details. If you later swap the hand rig, change bone orientations, or add a second hand model, every existing mod still works. - Curl 0 to 1 doesn't care about each bone's local axis conventions. Raw per-joint rotations in JSON are tied to your rig's bone axes, so they break the moment you touch the rig. **What GDScript owns** - The per-finger curve: each joint's min/max rotation, and how curl distributes across the proximal, intermediate and distal joints. Real fingers don't curl evenly, and the distal joint usually follows the intermediate one at roughly 2/3 ratio. A tuned curve looks much better than linear interpolation. - Thumb special-casing. A single 0-1 doesn't cover the thumb, because it also swings across the palm. Give it `curl` plus an `opposition` value, or just treat it as two floats. - Mirroring for the left hand, so modders author one pose. **What the JSON exposes** ```json "hold": { "pose": "pistol_grip", "fingers": { "thumb": { "curl": 0.6, "opposition": 0.8 }, "index": { "curl": 0.1 }, "middle": { "curl": 0.9 }, "ring": { "curl": 0.95 }, "pinky": { "curl": 1.0 } }, "spread": 0.2, "offset": { "pos": [0, 0, 0], "rot": [0, 0, 0] }, "off_hand": { "pose": "foregrip", "offset": { "pos": [0, 0.02, -0.1], "rot": [0, 0, 0] } } } ``` Three things in there are worth copying: - **Named base poses** (`pose`) that the game ships and the modder tweaks. Most items can just say `"pose": "pistol_grip"` and be done, which is the real "easy" path. - **Curl and spread** as the only per-finger values by default. - **A separate hand-to-item offset**, because most "the hand looks wrong" problems are placement, not finger angles. **The escape hatch** Add an optional `"joints"` block that applies additive offsets in degrees on top of the curl result: ```json "index": { "curl": 0.1, "joints": { "distal": [0, 0, -15] } } ``` Make it additive instead of absolute. The curl still does 95% of the work, and a modder can nudge one knuckle without re-authoring the whole finger. If you let them set absolute joint rotations, they will, and then you can never change the rig. **Practical notes** - Clamp everything in code so a bad value can't fold fingers through the palm. - Ship a small in-game or editor pose preview with sliders that exports the JSON. Authoring numbers blind is the real friction, more than the schema. - Keep the schema versioned (`"schema": 1`) so you can change the underlying hand model later without breaking mods.
Author
Member

Closing: all five phases built and merged in PR #41 (merge commit a5838000d4) — eject pose, real renders, snap-slider + eject-hold + collider authoring, grip preview, and the hand-posing investigation. The hands remainder (H5 curl calibration, H6 pivot split) now lives in #42, with merith's decoupled-hands direction folded into that issue.

Closing: all five phases built and merged in PR #41 (merge commit `a5838000d4`) — eject pose, real renders, snap-slider + eject-hold + collider authoring, grip preview, and the hand-posing investigation. The hands remainder (H5 curl calibration, H6 pivot split) now lives in #42, with merith's decoupled-hands direction folded into that issue.
Sign in to join this conversation.
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#40
No description provided.