Item JSON format v2: data changes (issue #47) #48

Open
muse-lantern wants to merge 21 commits from muse-lantern/da-game:object-grip-overhaul into main
Member

Item JSON format v2: data changes (issue #47)

The data half of #47: every item file, both loaders, and the rename blast radius. The editor reads and writes the new shape (minimal adaptation — no redesign, no new features). This branch has since grown past the data build — it now also carries the grip-driver runtime, the gun_bolt rename, the mag-rail fix, grip-pose slice 5 (bolt slam + lock), and the Phase 3 UIX rebuild. The UIX rebuild still needs Zach's in-editor check of the viewport click itself. No merge until item-v2 + toolkit-uix-v2 + grip-pose-v2 all land (Zach, 2026-11-15).

What changed

  • Four sections, in order. Item files are now a_object (controller, name, model, tags, value/weight, colliders) / b_properties (per-type tables: weapon_onehand with accepts_mag / accepts_ammo, data, refpoints, states) / c_assembly (fire_select, bolt, trigger) / d_grips (grip_points).
  • Loaders. ItemDefinition.from_dict() and ItemSpec read the four sections, resolve namespaced paths, and resolve the new controller field through the deadair:controller/... registry (absent = generic_object). The loader resolves extension keys: weapon_onehand counts as weapon for is_type("weapon"). The old shape loaded through a fallback compose layer while the migration landed; the fallback is now removed.
  • Migration. All item JSONs converted (scripted fold: grip_offset / grip_rotate into explicit grip_points primary poses — the fold equals the old composed runtime pose). reload_time and stack dropped. value / weight moved into a_object (optional, default 0). Colliders moved to a_object.colliders (box, mesh, convex, sphere, cylinder). Bolt origin z 0.00004 → 0.0. Travel lives only in assembly — no travel in grip points.
  • Renames. Generic pistol → generic handgun: file, item id, mag id, GLB and textures (devkit:weapon/gun/generic_handgun, devkit:mag/generic_handgun, devkit:ammo.9mm). holster.hip is gone; the slot is devkit:weapon.secondary. Controller is deadair:controller/generic_weapon — the object controller and the grip controller are one thing. gun_slide → gun_bolt (bolt is the general term; slide is reserved for pump-action shotguns with their own handler); assembly part slide → bolt, grip point slide → bolt, driver slide_grip_pose → bolt_grip_pose. The editor's internal slide-sim identifiers (slide_amount, set_slide_amount, play_slide_cycle, _slide_*, SLIDE_*, dock "Slide" header) are renamed to bolt as well.
  • Blast radius. Registry, tests/suites, bridge, start kit, and the editor references follow the renames. examples/example_module/items/example_gun.json was also migrated (its accepts_mag/ammo point at the handgun mag — an inference, see below).
  • Editor (minimal, data-only). Schema, stage, and dock read the four-section shape; the editor round-trips values correctly. No layout or behaviour changes — the redesign is Phase 3.
  • UIX rebuild, Phase 3 (74bb4e4, fbe68b2, 2bf6e7e). (A) Dock rebuilt on Inspector patterns: dark-band bold left-aligned section headers, one slim flat toolbar (Duplicate / Delete / Open staging / Save JSON), click-to-select marker rows with a highlight band (Select button removed), expandable colliders with Vector3 pos/rot/size rows, low-key '+ Add …' rows; Grip preview kept. (B) Click-to-select in the staging scene: item_stage.selectable_for() maps markers, rails, collider gizmos, and assembly-bound model nodes; the dock mirrors EditorSelection. (C) Secondary 'Selected' tab showing the selected thing's item-data properties (never node properties), plus the driver surface in the Grip preview: driver picker from GripDrivers.ids() and a trigger slider that injects the pull into the stage's tree-free evaluation, applied via HandView.set_finger_pose. One bug caught building: a bolt origin edit would have been reverted by collect_item() writing stale bolt-rail ends back — fixed with stage.refresh_bolt_rail().
  • Grip-driver runtime (25b37ca). GripDriver base (tree-free evaluate, default-inheritance state resolution), GripDrivers registry (static_grip_pose / gun_grip_pose / bolt_grip_pose), GunGripDriver (trigger_rest → trigger_full blend by analog pull), HandView.set_finger_pose (per-finger curl + spread + thumb opposition). New tests/grip_driver_check.gd (81 checks, wired into CI). The editor doesn't evaluate drivers yet — the driver surface (picker, trigger slider, grip preview) folds into the UIX rebuild.
  • modtool_meta is editor-only, ignored everywhere (13b93a7, 7b3c6cd). *.modtool_meta.json files carry no game data; the editor scan and the runtime loader both skip them.
  • Mag rail t fix (245eb97, shape b). While a mag is snapped, _snap_t was computed from the hand follower's previous-frame position (one physics tick stale, ~2.5 mm at slide speed). Now HandFollower.desired_global_transform() computes the follower's desired position for the current frame, shared by the follower and _update_snap. Proving test in item_follow_check (drive the grip at 0.15 m/s, assert the mag's rail pose against the hand's current rail projection, <1 mm).
  • Slice 5: bolt slam + lock (f9f92fb). Firearm holds bolt_t 0..1 + bolt_locked; fire_once() drives the bolt to 1 then springs it back (default 250 ms, tunable); on the last round (chamber empty + mag empty) it stays at 1 (locked); rack() releases the lock and chambers from a fresh mag. The bolt node is posed per physics frame as origin + t·travel from c_assembly.bolt, and the bolt grip point is recomputed from the node's live transform every frame. Bolt behaviour is tuned in a new b_properties.weapon.bolt table on the base weapon table (visible to the weapon_onehand/weapon_twohand mirrors via the deep-merged ItemSpec.table()): dry_fire_primes false · snap_back_ms 250.0 · stroke_ms 30.0 · lock_on_empty true · release_on_mag_insert false · rack_empty_cycles true · drop_mid_cycle "finish". New signal bolt_reached_rear fires once per rearward stroke — the hook point for slice 4's casing eject (eject itself not built). New tests/bolt_check.gd (90 checks, wired into tests.yml). Two bugs found by tests and fixed in the same commit: the editor's type_table() returned the bolt-only base table so markers/accepts_mag read null (fixed — merged base+extension view via ItemSpec._merged_type_table, single source of truth, +5 regression checks); a freeze-test race (re-grab + drop one idle frame apart misses the physics frame — test-side 0.2 s settle, no gameplay impact).

Commits (19, on object-grip-overhaul, base f9919ea)

  • d8d61f2 Phase 1: nested loaders with old-shape fallback
  • 16de7d6 Phase 2: migrate all item JSONs to the four-section shape
  • afbb45f Phase 4: rename blast radius — tests, bridge, start kit
  • 8a15d86 Editor: schema/stage/dock + tests read the four-section shape
  • 21d765a Phase 6: remove the old-shape fallback
  • bfd0c90 Phase 6: last stale mag_well_b / grip_poses wording
  • 55a3298 Phase 6: a_object is optional in the v2 loaders
  • 35d0e86 Phase 6: last stale doc references
  • f8146c1 Zach: resave object JSONs in the editor's format (15 files format-only; testgun magwell point → 5 cm segment)
  • 1fe7f44 Revert test gun magwell to a point rail (rail fixtures assume it)
  • 25b37ca Grip-driver runtime (amended; author merith-tk per Zach)
  • 13b93a7 Editor scan fix: modtool_meta.json ignored
  • 56024cd Rename gun_slide → gun_bolt
  • 7b3c6cd Runtime loader ignores *.modtool_meta.json
  • 245eb97 Mag rail t uses the hand's current-frame position (shape b)
  • f9f92fb Slice 5: bolt slams on fire and locks on the last round
  • 74bb4e4 UIX Part A: Inspector-style dock
  • fbe68b2 UIX Part B: click-to-select in the staging scene
  • 2bf6e7e UIX Part C: Selected tab + driver surface

Verification (current head 2bf6e7e)

  • Double --import: 0 ERROR both passes. python3 tools/check.py: 6/6.
  • Full headless battery: 39/41 PASS, 2009 ok. item_editor_check PASS, 359 ok (was 284 at the data build; +25 Part A, +13 Part B, +37 Part C). item_editor_manage_check PASS, 111 ok. grip_driver_check PASS, 81 ok. New tests/bolt_check.gd PASS, 90 checks. item_follow_check PASS.
  • Bridge e2e under xvfb-run (not --headless): PASS, exit 0.
  • Spot-checked the handgun JSON: exactly the #47 v5 shape (controller, namespaced model/tags, weapon_onehand accepts, bolt travel +Z 0.05 with origin z 0.0, primary pose [0, -0.0606891736388206, 0.0534712076187134] rot [90,0,0], bolt secondary pose, no travel in grips). No old-shape leftovers in any item JSON; no generic_pistol refs left in modules code or JSON.
  • (Earlier, at the data build 35d0e86: battery was 36/38 PASS, 1758 ok — same two stack-fallout failures, counts unchanged. At f9f92fb: 35/37 PASS, 1777 ok.)

The two failing suites — stack fallout, not a mag regression

mag_filo_check (31 ok, was 47) and mag_interaction_check (50 ok, was 69) fail on the same mechanism, traced in code and confirmed by re-running both suites:

  • stack is gone per #47, so max_stack is 1 and ItemInstance.create(id, 60) now makes a count-1 round. Magazines.load_from_stack moves mini(room, stack.count(), n) rounds, so a test that loads "2 regular" or "10 regular" from a count-N stack now moves 1, and every downstream check (13 rounds, FILO order, rack counts, the 60-stack eject scenario) fails with it.
  • This is fallout of the decided stack removal, not a broken mag: the mag stores individual rounds (load_from_stack creates one count-1 child per round moved; make_loaded fills with individual instances), and the game spawn path already handles non-stackable items by spawning N separate copies (game.gd). The mag still loads, holds, and ejects individual rounds; FILO order is preserved in the children list.
  • Decided (Zach, 2026-11-14): the in-game ammo source is loose rounds — a round held in the grip, or ejected from a container (hold trigger to show the bullet), brought to the mag opening; mag-to-mag transfer supported; ammo boxes follow the same logic as a mag but carry no gun tag. Volume rule: mags and bullets do no collider checks inside a volume — a loose round in a volume can't be picked up by a mag, and a round can't be inserted into a mag loose in a volume, but a round CAN be inserted into an ammo box loose in a volume (bulk sorting). These two suites get redesigned around that model; that redesign is the biggest remaining item on this branch.

Known gaps and caveats (stated plainly, not hidden)

  • Editor round-trip: values yes, order no — ACCEPTED. Loading the handgun and saving it back produces identical parsed values, but inner key order is re-sorted alphabetically by JSON.stringify (a point's role, object, both comes back both, object, role), and "schema": 1 becomes "schema": 1.0. Zach (2026-11-15): alphabetical order is fine; a byte-identical round-trip via a custom order-preserving serializer is a parked later patch.
  • Bolt tracking: built (slice 5). The bolt grip point follows Handgun_Upper's live transform; the bolt moves on fire and locks on the last round. (Was: static point only.)
  • Driver evaluation: built (25b37ca) + editor surface (Part C). trigger_rest → trigger_full blending by analog pull; HandView.set_finger_pose; driver picker + trigger slider + grip preview in the editor. The viewport click itself still needs Zach's in-editor check — headless cannot prove the click → EditorSelection → dock glue (the mapping and dock-side selection are suite-proven).
  • Docs not regenerated. docs/modding.md, MECHANICS.md, mechanics.json still reference generic_pistol in places.
  • Pistol HUD shows SAFE initially (modes [safe, semi], index 0) — expected from modes-from-file-order.
  • Example gun accepts_ammo is an inference (it takes the handgun mag).

Not in this PR

The mag test redesign around loose rounds, the float cap (0.1 mm round-on-save + normalize-on-load), doc regeneration, the fire-select input-binding change, and casing eject (slice 4; hook bolt_reached_rear wired at t=1). Issue #46 is subsumed by this format — d_grips is its recommended shape.

# Item JSON format v2: data changes (issue #47) The data half of #47: every item file, both loaders, and the rename blast radius. The editor reads and writes the new shape (minimal adaptation — no redesign, no new features). This branch has since grown past the data build — it now also carries the grip-driver runtime, the gun_bolt rename, the mag-rail fix, grip-pose slice 5 (bolt slam + lock), and the Phase 3 UIX rebuild. The UIX rebuild still needs Zach's in-editor check of the viewport click itself. No merge until item-v2 + toolkit-uix-v2 + grip-pose-v2 all land (Zach, 2026-11-15). ## What changed - **Four sections, in order.** Item files are now `a_object` (controller, name, model, tags, value/weight, colliders) / `b_properties` (per-type tables: `weapon_onehand` with `accepts_mag` / `accepts_ammo`, `data`, `refpoints`, `states`) / `c_assembly` (fire_select, bolt, trigger) / `d_grips` (grip_points). - **Loaders.** `ItemDefinition.from_dict()` and `ItemSpec` read the four sections, resolve namespaced paths, and resolve the new `controller` field through the `deadair:controller/...` registry (absent = `generic_object`). The loader resolves extension keys: `weapon_onehand` counts as `weapon` for `is_type("weapon")`. The old shape loaded through a fallback compose layer while the migration landed; the fallback is now removed. - **Migration.** All item JSONs converted (scripted fold: `grip_offset` / `grip_rotate` into explicit `grip_points` primary poses — the fold equals the old composed runtime pose). `reload_time` and `stack` dropped. `value` / `weight` moved into `a_object` (optional, default 0). Colliders moved to `a_object.colliders` (box, mesh, convex, sphere, cylinder). Bolt origin z `0.00004` → `0.0`. Travel lives only in `assembly` — no travel in grip points. - **Renames.** Generic pistol → generic handgun: file, item id, mag id, GLB and textures (`devkit:weapon/gun/generic_handgun`, `devkit:mag/generic_handgun`, `devkit:ammo.9mm`). `holster.hip` is gone; the slot is `devkit:weapon.secondary`. Controller is `deadair:controller/generic_weapon` — the object controller and the grip controller are one thing. `gun_slide` → `gun_bolt` (bolt is the general term; `slide` is reserved for pump-action shotguns with their own handler); assembly part `slide` → `bolt`, grip point `slide` → `bolt`, driver `slide_grip_pose` → `bolt_grip_pose`. The editor's internal slide-sim identifiers (`slide_amount`, `set_slide_amount`, `play_slide_cycle`, `_slide_*`, `SLIDE_*`, dock "Slide" header) are renamed to bolt as well. - **Blast radius.** Registry, tests/suites, bridge, start kit, and the editor references follow the renames. `examples/example_module/items/example_gun.json` was also migrated (its accepts_mag/ammo point at the handgun mag — an inference, see below). - **Editor (minimal, data-only).** Schema, stage, and dock read the four-section shape; the editor round-trips values correctly. No layout or behaviour changes — the redesign is Phase 3. - **UIX rebuild, Phase 3 (`74bb4e4`, `fbe68b2`, `2bf6e7e`).** (A) Dock rebuilt on Inspector patterns: dark-band bold left-aligned section headers, one slim flat toolbar (Duplicate / Delete / Open staging / Save JSON), click-to-select marker rows with a highlight band (Select button removed), expandable colliders with Vector3 pos/rot/size rows, low-key '+ Add …' rows; Grip preview kept. (B) Click-to-select in the staging scene: `item_stage.selectable_for()` maps markers, rails, collider gizmos, and assembly-bound model nodes; the dock mirrors `EditorSelection`. (C) Secondary 'Selected' tab showing the selected thing's item-data properties (never node properties), plus the driver surface in the Grip preview: driver picker from `GripDrivers.ids()` and a trigger slider that injects the pull into the stage's tree-free evaluation, applied via `HandView.set_finger_pose`. One bug caught building: a bolt origin edit would have been reverted by `collect_item()` writing stale bolt-rail ends back — fixed with `stage.refresh_bolt_rail()`. - **Grip-driver runtime (`25b37ca`).** `GripDriver` base (tree-free `evaluate`, default-inheritance state resolution), `GripDrivers` registry (`static_grip_pose` / `gun_grip_pose` / `bolt_grip_pose`), `GunGripDriver` (trigger_rest → trigger_full blend by analog pull), `HandView.set_finger_pose` (per-finger curl + spread + thumb opposition). New `tests/grip_driver_check.gd` (81 checks, wired into CI). The editor doesn't evaluate drivers yet — the driver surface (picker, trigger slider, grip preview) folds into the UIX rebuild. - **modtool_meta is editor-only, ignored everywhere (`13b93a7`, `7b3c6cd`).** `*.modtool_meta.json` files carry no game data; the editor scan and the runtime loader both skip them. - **Mag rail `t` fix (`245eb97`, shape b).** While a mag is snapped, `_snap_t` was computed from the hand follower's previous-frame position (one physics tick stale, ~2.5 mm at slide speed). Now `HandFollower.desired_global_transform()` computes the follower's desired position for the current frame, shared by the follower and `_update_snap`. Proving test in `item_follow_check` (drive the grip at 0.15 m/s, assert the mag's rail pose against the hand's current rail projection, <1 mm). - **Slice 5: bolt slam + lock (`f9f92fb`).** `Firearm` holds `bolt_t` 0..1 + `bolt_locked`; `fire_once()` drives the bolt to 1 then springs it back (default 250 ms, tunable); on the last round (chamber empty + mag empty) it stays at 1 (locked); `rack()` releases the lock and chambers from a fresh mag. The bolt node is posed per physics frame as origin + t·travel from `c_assembly.bolt`, and the bolt grip point is recomputed from the node's live transform every frame. Bolt behaviour is tuned in a new `b_properties.weapon.bolt` table on the base weapon table (visible to the `weapon_onehand`/`weapon_twohand` mirrors via the deep-merged `ItemSpec.table()`): `dry_fire_primes` false · `snap_back_ms` 250.0 · `stroke_ms` 30.0 · `lock_on_empty` true · `release_on_mag_insert` false · `rack_empty_cycles` true · `drop_mid_cycle` "finish". New signal `bolt_reached_rear` fires once per rearward stroke — the hook point for slice 4's casing eject (eject itself not built). New `tests/bolt_check.gd` (90 checks, wired into tests.yml). Two bugs found by tests and fixed in the same commit: the editor's `type_table()` returned the bolt-only base table so markers/`accepts_mag` read null (fixed — merged base+extension view via `ItemSpec._merged_type_table`, single source of truth, +5 regression checks); a freeze-test race (re-grab + drop one idle frame apart misses the physics frame — test-side 0.2 s settle, no gameplay impact). ## Commits (19, on `object-grip-overhaul`, base `f9919ea`) - `d8d61f2` Phase 1: nested loaders with old-shape fallback - `16de7d6` Phase 2: migrate all item JSONs to the four-section shape - `afbb45f` Phase 4: rename blast radius — tests, bridge, start kit - `8a15d86` Editor: schema/stage/dock + tests read the four-section shape - `21d765a` Phase 6: remove the old-shape fallback - `bfd0c90` Phase 6: last stale mag_well_b / grip_poses wording - `55a3298` Phase 6: a_object is optional in the v2 loaders - `35d0e86` Phase 6: last stale doc references - `f8146c1` Zach: resave object JSONs in the editor's format (15 files format-only; testgun magwell point → 5 cm segment) - `1fe7f44` Revert test gun magwell to a point rail (rail fixtures assume it) - `25b37ca` Grip-driver runtime (amended; author merith-tk per Zach) - `13b93a7` Editor scan fix: modtool_meta.json ignored - `56024cd` Rename gun_slide → gun_bolt - `7b3c6cd` Runtime loader ignores *.modtool_meta.json - `245eb97` Mag rail t uses the hand's current-frame position (shape b) - `f9f92fb` Slice 5: bolt slams on fire and locks on the last round - `74bb4e4` UIX Part A: Inspector-style dock - `fbe68b2` UIX Part B: click-to-select in the staging scene - `2bf6e7e` UIX Part C: Selected tab + driver surface ## Verification (current head `2bf6e7e`) - Double `--import`: 0 ERROR both passes. `python3 tools/check.py`: 6/6. - Full headless battery: **39/41 PASS, 2009 ok**. `item_editor_check` PASS, 359 ok (was 284 at the data build; +25 Part A, +13 Part B, +37 Part C). `item_editor_manage_check` PASS, 111 ok. `grip_driver_check` PASS, 81 ok. New `tests/bolt_check.gd` PASS, 90 checks. `item_follow_check` PASS. - Bridge e2e under `xvfb-run` (not `--headless`): **PASS**, exit 0. - Spot-checked the handgun JSON: exactly the #47 v5 shape (controller, namespaced model/tags, `weapon_onehand` accepts, bolt travel +Z 0.05 with origin z 0.0, primary pose `[0, -0.0606891736388206, 0.0534712076187134]` rot `[90,0,0]`, bolt secondary pose, no travel in grips). No old-shape leftovers in any item JSON; no `generic_pistol` refs left in modules code or JSON. - (Earlier, at the data build `35d0e86`: battery was 36/38 PASS, 1758 ok — same two stack-fallout failures, counts unchanged. At `f9f92fb`: 35/37 PASS, 1777 ok.) ## The two failing suites — stack fallout, not a mag regression `mag_filo_check` (31 ok, was 47) and `mag_interaction_check` (50 ok, was 69) fail on the same mechanism, traced in code and confirmed by re-running both suites: - `stack` is gone per #47, so `max_stack` is 1 and `ItemInstance.create(id, 60)` now makes a count-1 round. `Magazines.load_from_stack` moves `mini(room, stack.count(), n)` rounds, so a test that loads "2 regular" or "10 regular" from a count-N stack now moves 1, and every downstream check (13 rounds, FILO order, rack counts, the 60-stack eject scenario) fails with it. - This is fallout of the decided stack removal, not a broken mag: the mag stores individual rounds (`load_from_stack` creates one count-1 child per round moved; `make_loaded` fills with individual instances), and the game spawn path already handles non-stackable items by spawning N separate copies (`game.gd`). The mag still loads, holds, and ejects individual rounds; FILO order is preserved in the children list. - **Decided (Zach, 2026-11-14):** the in-game ammo source is loose rounds — a round held in the grip, or ejected from a container (hold trigger to show the bullet), brought to the mag opening; mag-to-mag transfer supported; ammo boxes follow the same logic as a mag but carry no gun tag. Volume rule: mags and bullets do no collider checks inside a volume — a loose round in a volume can't be picked up by a mag, and a round can't be inserted into a mag loose in a volume, but a round CAN be inserted into an ammo box loose in a volume (bulk sorting). These two suites get redesigned around that model; that redesign is the biggest remaining item on this branch. ## Known gaps and caveats (stated plainly, not hidden) - **Editor round-trip: values yes, order no — ACCEPTED.** Loading the handgun and saving it back produces identical parsed values, but inner key order is re-sorted alphabetically by `JSON.stringify` (a point's `role, object, both` comes back `both, object, role`), and `"schema": 1` becomes `"schema": 1.0`. Zach (2026-11-15): alphabetical order is fine; a byte-identical round-trip via a custom order-preserving serializer is a parked later patch. - **Bolt tracking: built (slice 5).** The bolt grip point follows `Handgun_Upper`'s live transform; the bolt moves on fire and locks on the last round. (Was: static point only.) - **Driver evaluation: built (`25b37ca`) + editor surface (Part C).** trigger_rest → trigger_full blending by analog pull; `HandView.set_finger_pose`; driver picker + trigger slider + grip preview in the editor. The viewport click itself still needs Zach's in-editor check — headless cannot prove the click → `EditorSelection` → dock glue (the mapping and dock-side selection are suite-proven). - **Docs not regenerated.** `docs/modding.md`, `MECHANICS.md`, `mechanics.json` still reference `generic_pistol` in places. - **Pistol HUD shows SAFE initially** (modes `[safe, semi]`, index 0) — expected from modes-from-file-order. - **Example gun accepts_ammo is an inference** (it takes the handgun mag). ## Not in this PR The mag test redesign around loose rounds, the float cap (0.1 mm round-on-save + normalize-on-load), doc regeneration, the fire-select input-binding change, and casing eject (slice 4; hook `bolt_reached_rear` wired at t=1). Issue #46 is subsumed by this format — `d_grips` is its recommended shape.
ItemSpec gains the v2 vocabulary: controller / grip_points /
colliders fields, TYPE_EXTENSIONS (weapon_onehand/twohand resolve
to weapon), namespace-aware has_tag/matches_tag, namespaced path
and controller resolution, and normalize_old_* helpers that fold
flat old tables into the nested {data, refpoints, states} form.
primary_grip_pose resolves from grip_points first (folded pose,
no model offset on top) and keeps the old grip_poses + offset
branch as fallback. WorldItem builds one GrabPoint per secondary
grip point (GrabSecondary_<point>_<side>); Firearm, Magazines and
the mag/consumable readers go through the nested tables; the mag
and bullet base bands now key off the accepted caliber local part
(ammo.9mm), restoring two dead branches.

Old JSONs still load: battery 35/38 green (terrain N/A); the 3
FAILs (firearm, modloader, spawn) are tests reading the flat
table keys, updated in Phase 4.
Scripted migration (parse-back verified, tab indentation, CRLF
preserved per file): a_object / b_properties / c_assembly /
d_grips with fixed grip_points key order. Pistol per the v5
appendix verbatim (Generic Handgun, slide origin z 0.0, trigger
travel rot 30, gun_grip_pose + slide_grip_pose drivers and finger
states); other primary poses folded from the probe-measured
resolved poses; rifle foregrip becomes a secondary grip point;
stack and reload_time dropped (max_stack now 1 everywhere — the
stack fallout is a separate decision); tags namespaced per module
(mag.pistol -> mag.handgun, weapon.pistol -> weapon.onehand,
holster.hip -> weapon.secondary, holster.back -> weapon.primary).

Renames: generic_pistol(.json/.glb/texture) -> generic_handgun,
generic_pistol_mag(.glb/texture) -> generic_handgun_mag, with
.import source_file updates. Slot tags ride along (hip_r ->
weapon.secondary, back -> weapon.primary).

Tests go red here by design (old ids / flat reads in the suites);
they are rewritten in the Phase 4 + editor commits. Import 0
ERROR, check.py 6/6, migration parse-back VERIFY OK.
All item ids/paths now use generic_handgun; test probes rewritten to
the four-section shape (folded d_grips poses, nested b_properties,
a_object.colliders, c_assembly); debug bridge emits grip_points +
controller; example glow_stick registered in the new shape.

Known fallout, all from the dropped stack (no new home invented):
mag_filo loses its multi-round loads (13-round mag, FILO pop order,
rack counts) and mag_interaction loses its 60-stack scenario
(create() clamps to max_stack 1, so the stack is a count-1 round that
the loose-bullet filter also counts; loading its only round frees the
world item and aborts the phase). Decision for Zach, reported in full.
item_schema.gd: markers re-pointed to b_properties.<base>.refpoints / c_assembly / d_grips.grip_points (base segment resolved per item via TYPE_EXTENSIONS); grip functions now operate on grip_points with fixed key order (role, object, mirror, both/right/left; pose, driver, state inside a hand entry); resolved_grip_pose mirrors ItemSpec.resolve_grip_point exactly (right = right??both??default; left = own entry, else mirror(right) when point.mirror ?? centered, else right as-is); templates rewritten to the new shape (weapon_onehand, accepts_mag devkit:mag/generic_handgun, accepts_ammo devkit:ammo.9mm, c_assembly.fire_select + slide travel, folded d_grips poses); colliders read/write a_object.colliders; duplicate keeps the legacy top-level model_path/world_scene branch for old-shape dicts and leaves namespaced a_object.model as-is; looks_like_item_data needs a_object + name.

item_stage.gd: grip/magwell/slide rails and markers use the resolved marker paths; gun_slide rail now builds from c_assembly (origin + origin+travel) with its own collect/build pair; ref_b written as a {pos} dict (the runtime only reads dict ref_b); _accepted_mag_bounds resolves accepts_mag as an item id first, else a tag scan.

Tests: item_editor_check 284 ok (baseline 282: +4 slide-rail checks, -1 merged grip check, -1 merged mag-grip check), item_editor_manage_check 111 ok (baseline 110: +1 no-world_scene check), both 0 FAIL. The manage duplicate fixture stays old-shape on purpose (tests the legacy branch).

Round-trip proof (generic handgun, load -> collect unmoved -> save): values stable except two float32 transform-noise spots (magwell ref_b y -3.7e-9, slide travel z +7.5e-10, both far inside the 1e-4 test tolerance); the saved file is re-sorted alphabetically and ints become floats (schema 1 -> 1.0) — both are the pre-existing save/load path (JSON.stringify sorts keys; Godot JSON parse returns floats), save_item_json/load_item_json untouched by this change. A byte-identical round-trip would need a custom order-preserving serializer (out of scope).
Loaders read the four-section shape only: normalize_old_* deleted from ItemSpec; _from_dict_old deleted from ItemDefinition; _apply_dict_old deleted from GenericItem; grip_poses field, grip_pose_for/_hand and pose_from_dict deleted; primary_grip_pose resolves from grip_points with the default fallback (resolve_grip_point({})). Firearm/Magazines/WorldItem flat-table fallbacks deleted (_data/_refpoints/_mag_data/_mag_refpoints return the nested subtable only); accepts_magazine is id equality only; legacy mag_well/mag_well_b branches deleted; array-form opening branches deleted (v2 opening is a {pos, rot} dict).

Tests: the remaining old-shape apply_dict/from_dict probes rewritten to the new shape (modloader g/g2/g3/g4 + replacer + coded coin + frozen register; module_check fixtures incl. the two-hand sword grip point and the pack item; mag_filo no-offset probe). The manage duplicate fixture stays old-shape on purpose (it tests the legacy duplicate branch, which is kept).

Known fallout (unchanged from Phase 4, decision for Zach): stack is gone from the JSONs, so multi-round loads cannot exist — mag_filo and mag_interaction stack scenarios fail on counts, not on mechanics.
Comment + test-label wording only (stage header doc, two editor check labels). No behaviour change.
The loader builds a sample element with make_element(type, {}, "") once per JSON element to read _path_fields() — the direct d["a_object"] index in _apply_dict_v2/_from_dict_v2 crashed on that empty dict (16 SCRIPT ERRORs per discovery, and the undefined_model probes aborted mid-load). Both v2 entry points now read a_object via .get() with a {} default: a dict without a_object loads as an empty basic item. undefined_model probes rewritten to the new shape (a_object.name/model).
Format v2 Phase 6: last stale doc references
Some checks failed
tests / test (pull_request) Failing after 1m57s
35d0e865cb
Comment-only: _build_slide_travel doc no longer mentions the dropped slide_travel field; template doc generic_pistol -> generic_handgun.
resave object json files
Some checks failed
tests / test (pull_request) Failing after 1m54s
f8146c1c96
to trigger proper orginization and prevent future remakes and changes because of the order being wrong in the files where the game engine saves as a different order

also changed the mag well position on the testgun
Revert test gun magwell to a point rail (rail fixtures assume it)
Some checks failed
tests / test (pull_request) Failing after 1m54s
1fe7f443b5
muse-lantern force-pushed object-grip-overhaul from a7033dcaa7
Some checks failed
tests / test (pull_request) Failing after 16s
to 25b37cab5d
Some checks failed
tests / test (pull_request) Failing after 16s
2026-11-15 04:28:18 +00:00
Compare
Item scan ignores *.modtool_meta.json (fixes item_editor_check count)
Some checks failed
tests / test (pull_request) Failing after 25s
13b93a77d0
The mag's editor metadata file (generic_handgun.modtool_meta.json,
added in 25b37ca) was counted by DAItemSchema.scan_items() as a 17th
item, failing item_editor_check's "scan finds 16 items".
Metadata files are editor-only state, never items: item_id_for_path()
now returns "" for *.modtool_meta.json paths (suffix check, so future
metadata files are excluded too), and is_item_json_path() excludes
them as well, so the editor's file detection no longer claims a
metadata file as an item. No test counts were changed.
- Marker gun_slide -> gun_bolt (item_schema marker def, stage rail
  build/collect, README marker table, editor checks).
- Assembly part slide -> bolt in the handgun JSON (the pistol slide
  IS a bolt); stage assembly_part/origin_pose/part_node calls and
  schema examples follow. GLB node Handgun_Upper untouched; the
  name-matching fallback (upper) still works.
- Grip point slide -> bolt in d_grips; driver slide_grip_pose ->
  bolt_grip_pose in the GripDrivers registry and checks.
- The editor checks manual-assignment test used bolt as an
  arbitrary part name; it now uses rail (free known part) since
  bolt is the real pistol part. mag_release was not used: it is
  special-cased in the stage.
- slide stays in ASSEMBLY_KNOWN_PARTS: it is the future
  pump-action part name.
Runtime loader ignores *.modtool_meta.json (editor-only metadata)
Some checks failed
tests / test (pull_request) Failing after 1m52s
7b3c6cd621
The module loader enumerated the mag's .modtool_meta.json as an
item and rejected its dotted id ("bad element id
mag/generic_handgun.modtool_meta"). Per Zach's decision the meta
files are editor-only; the game ignores them entirely, so the element
scan skips them at the enumeration level.
Mag rail t uses the hand's current-frame position (shape b)
Some checks failed
tests / test (pull_request) Failing after 1m54s
245eb9740e
HandInteractor runs at priority 0, before HandFollower (10), so
_update_snap read the follower's transform — last frame's hand —
and t lagged the hand by one physics tick (~2.5 mm at 0.15 m/s).
HandFollower.desired_global_transform() is the pure computation
(teleport snap + leash clamp) shared by _physics_process and
_update_snap, so t is driven from this frame's hand. The
projection is factored out as HandInteractor.snap_t_for().

item_follow_check gains the proving check: the mag's rail pose
against the hand's CURRENT rail projection (<1 mm). It fails on
the old code (max 0.0025 m) and passes with the fix.
Slice 5: bolt slams on fire and locks on the last round (issue #42)
Some checks failed
tests / test (pull_request) Failing after 2m2s
f9f92fbcb8
The bolt cycle is driven by firing and by the grip (rack), on the
shared desired-position mechanism from Part 1. Geometry stays in
c_assembly.bolt; behaviour is a new b_properties.weapon.bolt table
on the base weapon table.

- ItemSpec.table() deep-merges base + extension (extension wins per
  key), so a weapon_onehand item sees the base bolt knobs and a
  per-handedness table can override them knob-by-knob. With only
  one present the stored dict is returned as-is.
- Firearm: bolt_t 0..1 + bolt_locked; fire_once() drives the bolt to
  1 then springs it back, and locks it at 1 on the last round
  (chamber empty + mag empty); rack() releases the lock (and
  chambers from a fresh mag when release_on_mag_insert). The bolt
  node is posed per frame as origin + t*travel from c_assembly.bolt,
  and the bolt grip points are recomputed from the node's live
  transform every physics frame. bolt_reached_rear fires once per
  rearward stroke (slice 4's casing eject hooks here).
- Bolt knobs (b_properties.weapon.bolt, handgun defaults):
  dry_fire_primes false (a priming pull chambers, no shot, no
  cycle); snap_back_ms 250 (200-300 range); stroke_ms 30;
  lock_on_empty true; release_on_mag_insert false;
  rack_empty_cycles true; drop_mid_cycle "finish"
  ("finish"|"reset"|"freeze").
- Editor: DAItemSchema.type_table() / dict_get_marker_path() now
  see the same merged base+extension view as the runtime (the base
  table previously shadowed the extension's refpoints, breaking the
  muzzle/magwell/chamber markers); marker writes route to the table
  that owns the segment. Naming purge: the editor's slide-sim
  identifiers are now bolt (bolt_amount, set_bolt_amount,
  play_bolt_cycle, BoltForward/Back/Travel gizmos, "Bolt" section);
  the gun_bolt rail visualisation, magwell rail, GLB node
  Handgun_Upper, and 'slide' in ASSEMBLY_KNOWN_PARTS stay.
  _bolt_travel_vec() no longer falls back to the gun_bolt rail.

Tests: new tests/bolt_check.gd (90 checks: merge incl. a synthetic
per-knob override, fire cycle, last-round lock, rack release, dry
fire + priming, rack-empty on/off, drop finish/reset/freeze+resume,
boltless test gun inert). item_editor_check gains 5 merge checks
(289 total). Full battery: 35/37 runnable suites pass (1777 ok);
mag_filo / mag_interaction stay red on the known stack fallout
(unchanged counts); terrain_check is skipped by CI design (no file
on this branch). Bridge e2e PASS under xvfb-run.
UIX Part A: Inspector-style dock (issue #47 Phase 3)
Some checks failed
tests / test (pull_request) Failing after 2m2s
74bb4e482c
Rebuild the DA Mod Editor dock on Inspector patterns:
- Section headers are dark bands with bold left-aligned titles
  (arrow at the left, same toggle text).
- One slim toolbar row for Duplicate / Delete / Open staging /
  Save JSON (flat buttons).
- Marker rows are click-to-select (whole row selects the marker
  in the viewport and marks the dock selection, with a highlight
  band); the separate Select button is gone.
- Colliders expand to Inspector-style Vector3 pos/rot/size rows
  (three SpinBoxes each, not one x,y,z string); edits write
  through without rebuilding the list.
- Add rows are low-key flat "+ Add ..." buttons.
- The marker-row structure change updates the manage suite row
  walk (unwrap the highlight PanelContainer).

item_editor_check 309 ok, item_editor_manage_check 111 ok,
check.py 6/6.
UIX Part B: click-to-select in the staging scene (issue #47 Phase 3)
Some checks failed
tests / test (pull_request) Failing after 2m2s
fbe68b264e
Selection syncs viewport and dock list:
- item_stage.selectable_for(node) maps a viewport-selected node
  to a dock selection descriptor: markers (and their triad
  children), rails (and their end markers and line), collider
  gizmos (by index), and model nodes bound to an assembly part.
  Preview hands, references, and the model root map to nothing.
  marker_node() is public for the dock.
- The dock listens to the editor selection (EditorSelection via
  Engine.get_singleton("EditorInterface"), null-guarded headless)
  and mirrors the first mapped node as its selected thing; an
  empty or unmapped selection clears it. The sync is factored as
  _apply_viewport_selection() so the mapping is headless-provable.
- Component rows are click-to-select too (row gui_input; labels
  ignore mouse input so clicks reach the row) and the selected
  component's part name highlights in the selection colour.

The viewport click itself is editor-interactive and needs
Zach's in-editor check; the suite proves the mapping
(selectable_for) and the dock-side selection, not the click.

item_editor_check 322 ok, item_editor_manage_check 111 ok,
check.py 6/6.
UIX Part C: Selected tab + driver surface (issue #47 Phase 3)
Some checks failed
tests / test (pull_request) Failing after 2m5s
2bf6e7e724
Two tabs: "Editor" (the whole dock) and "Selected" (the
selected thing's item-data properties, never node properties).

Selected tab, per kind:
- Marker: kind, data path, authored driver + states for a grip
  marker, and editable pos/rot rows (through the stage: set the
  node, collect back into the item — the same path dragging the
  marker takes).
- Rail: ref_a / ref_b pos/rot rows, same write-through.
- Collider: summary + mesh name, or editable pos/rot/size rows
  (the same rows and handler as the Colliders section).
- Component: bound object, editable origin/travel rows, mode
  poses read-only. Origin edits start from the resolved pose
  (not zeros) and re-apply to the node; a bolt edit re-derives
  the bolt rail first, or collect_item would write the stale
  rail ends back over the edit. Both pose halves go back as
  arrays (a Vector3 would not survive JSON.stringify).

Driver surface (issue #46, in the Grip preview section):
- Driver picker: "(pose)" + GripDrivers.ids(), defaulting to
  the grip point's authored driver for the previewed side
  (new schema grip_driver_for_side / grip_state_for_side).
  Preview-only — picking a driver does not write the item.
  The pick survives refreshes until the item or side changes.
- Trigger slider (0..1) injects the pull into the stage
  evaluation; disabled (Range.editable) while "(pose)" is
  picked — HSlider has no "disabled" property.
- Stage: set_grip_driver / set_grip_trigger evaluate tree-free
  (GripDrivers.evaluate against the grip's state data) and
  apply via HandView.set_finger_pose. Hands calibrate a frame
  after _ready and set_finger_pose no-ops before that, so the
  apply is deferred to _process until every preview hand is
  built; clearing the driver applies the neutral pose so the
  last driver pose does not stick. grip_preview_pose() is the
  test seam for the applied pose.

item_editor_check 359 ok, item_editor_manage_check 111 ok,
grip_driver_check 81 ok, check.py 6/6. Full battery 39/41
(2009 ok); the 2 reds are the known stack fallout
(mag_filo 31 ok, mag_interaction 50 ok, same counts as
baseline). Bridge e2e PASS.
Grip tiers are primary (first grab), secondary (the two-hand grip)
and tertiary (gun elements — the bolt, a mag). The runtime only knew
primary/secondary, so a tertiary role was silently dropped and
validation flagged it. WorldItem now builds tertiary points as
GrabPoint.Mode.GENERAL (GrabTertiary_<point>_<side>), so the second
hand only falls back to them when there is no secondary point — the
bolt no longer counts for the two-hand condition, but it is still
grabbable for racking, and the Firearm still finds it by point name.
ItemSpec.validate accepts tertiary and flags more than one point of
any tier. The handgun bolt grip point moves secondary -> tertiary;
the bolt behaviour-table comment now points at the item weapon tree.
Handgun carries one property tree: bolt knobs move to weapon_onehand (issue #47 Phase 3)
Some checks are pending
tests / test (pull_request) Has started running
f0b3343a47
weapon, weapon_onehand and weapon_twohand are three trees that
apply the same values — onehand/twohand only add recoil/deviation
math behind them. The handgun split its weapon properties across
two trees (bolt alone on base weapon, everything else on
weapon_onehand); the editor must not author split trees. The bolt
table moves into weapon_onehand and the base weapon table goes
away, so the item has exactly one tree. The runtime and the editor
both read through the merged ItemSpec.table() view, so the firearm
still reads the same knobs — bolt_check 90, grip_driver_check 82,
item_editor_check 359, all green.
Some checks are pending
tests / test (pull_request) Has started running
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u object-grip-overhaul:muse-lantern-object-grip-overhaul
git switch muse-lantern-object-grip-overhaul

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch main
git merge --no-ff muse-lantern-object-grip-overhaul
git switch muse-lantern-object-grip-overhaul
git rebase main
git switch main
git merge --ff-only muse-lantern-object-grip-overhaul
git switch muse-lantern-object-grip-overhaul
git rebase main
git switch main
git merge --no-ff muse-lantern-object-grip-overhaul
git switch main
git merge --squash muse-lantern-object-grip-overhaul
git switch main
git merge --ff-only muse-lantern-object-grip-overhaul
git switch main
git merge muse-lantern-object-grip-overhaul
git push origin main
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
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!48
No description provided.