Grip pose system: grip_points with per-hand drivers, states, and finger poses #46

Open
opened 2026-11-12 23:28:39 +00:00 by muse-lantern · 0 comments
Member

Grip pose system: grip_points with per-hand drivers, states, and finger poses

Status: DESIGN IN DISCUSSION (Zach + Lantern, 2026-11-12). Nothing built. This issue files the full discussion so it can be reviewed in one place.

Related: #42 (hands thread — glove curl calibration H5, two-handed pivot split H6, decoupled hands, per-hand grips slice 1 in PR #45), #4 (data-driven item definitions — this proposal revises #4's Grips section), #33 (weapon handedness), #40 (per-finger pose API appendix). Plan doc: dead-air-grip-pose-plan.md (slices 1–5).

The goal

Elements should be grabbable on objects. Ranged grab always grabs the primary grip point. Foregrips and grip attachments are secondary grip points — places you can physically grab, such as the slide on a gun, or the second hold of a two-grip hold. Hand grips also store finger poses, including state-based code for controllers: on Quest controllers, when the finger is not touching the trigger it stays straight out (as on a gun), and when it touches the trigger it curls onto the gun trigger, moving a % based on trigger pull. Those values are authored in the editor mod toolkit.

The data shape (current revision)

grip_points keyed by mesh/point name (any named part can be a grip point: slide, foregrip, mag body). Each point has a role (primary / secondary), and per hand (left / right) a pose, a driver, and a state table. Values below are illustrative, not the pistol's live numbers.

{
	"grip_points": {
		"schema": 1,
		"Handgun_Body_Lower": {
			"role": "primary",
			"right": {
				"pose": {
					"pos": [0, -0.03, 0.0535],
					"rot": [18, 0, 0]
				},
				"driver": "gun_grip_pose",
				"state": {
					"default": {
						"fingers": {
							"thumb": { "curl": 0.6, "opposition": 0.8 },
							"index": { "curl": 0.1, "joints": { "distal": [0, 0, -15] } },
							"middle": { "curl": 0.9 },
							"ring": { "curl": 0.95 },
							"pinky": { "curl": 1.0 }
						},
						"spread": 0.2
					},
					"trigger_rest": {
						"fingers": {
							"index": { "curl": 0.0 }
						}
					},
					"trigger_full": {
						"fingers": {
							"index": { "curl": 0.9 }
						}
					}
				}
			}
		},
		"Handgun_Upper": {
			"role": "secondary",
			"right": {
				"pose": {
					"pos": [0, 0.01, -0.05],
					"rot": [0, 0, 0]
				},
				"driver": "slide_grip_pose",
				"state": {
					"default": {
						"fingers": {
							"thumb": { "curl": 0.5, "opposition": 0.6 },
							"index": { "curl": 0.8 },
							"middle": { "curl": 0.8 },
							"ring": { "curl": 0.8 },
							"pinky": { "curl": 0.8 }
						},
						"spread": 0.1
					}
				}
			}
		}
	}
}

Rules the shape bakes in

  • Point keyed by mesh name. Any named part can be a grip point — slide, foregrip, mag body. role lives on the point: primary is what ranged grab always takes, secondary is physically grabbable (slide rack, second hand).
  • Pose per hand, next to state. That is the old model.grip_offset / grip_rotate, folded in — slice 1's grip_poses is subsumed by grip_points[<point>].<hand>.pose. Slice 1's mirror rule and scale rule carry over unchanged: left omitted means mirror of right (for centered objects; non-centered left defaults to right, flagged not-mirrored); poses are authored in model space and scale with the item's content scale at load.
  • States inherit from default. A state only lists what changes (the trigger states only touch the index finger). default is the fallback; everything else is code-selected or driven. state is singular, matching the proposal's notation; hand keys are lowercase left / right, matching slice 1's hands: ["left"] convention.
  • Finger data is the per-finger pose API design (Claude discussion 2026-11-09, appendix in #40): per-finger curl 0–1 (0 = hand model resting position, 1 = fist), thumb curl + opposition, spread, and an additive per-joint joints escape hatch on individual knuckles. GDScript owns the per-joint curves, clamps, and left-hand mirroring. schema: 1 versions it.
  • The driver interprets; the JSON never decides. driver sits at hand level (per hand, next to pose and state) and names the registry id of the driver controlling the hand pose for those grip states. The states and pose data are provided to the driver, and the driver interprets the values.
  • Drivers are tree-free, inputs injected. A driver takes (state table, live inputs) and returns finger values. It never touches the scene tree or reads XR input directly. In game, live_inputs comes from the XR action state; in the editor, the dock feeds the same dict from sliders (trigger pull slider, touch toggle). The driver cannot tell the difference — that is what makes it runnable in the viewport, so the author can see how it actually works.
  • Left hand gets mirrored data from the slice-1 rule at the data layer; the driver evaluates it the same way for either hand.

The GDScript sketch

Sketch, not the real API — method names like apply_finger_pose are illustrative.

# grip_pose_driver.gd — base class. Drivers take (state table, live inputs)
# and return finger values. They never touch the scene tree or read XR
# input directly, so the same code runs in-game and in the editor viewport.
class_name GripPoseDriver
extends RefCounted

# State names this driver reads, so the editor dock can offer them.
func states_needed() -> Array[String]:
	return ["default"]

# Merge a state's fingers over default. States are partial overrides:
# a state only lists what changes, per finger, per key.
func merged_fingers(state_data: Dictionary, state_name: String) -> Dictionary:
	var out: Dictionary = {}
	var base: Dictionary = state_data.get("default", {}).get("fingers", {})
	for finger in base:
		out[finger] = (base[finger] as Dictionary).duplicate()
	var overlay: Dictionary = state_data.get(state_name, {}).get("fingers", {})
	for finger in overlay:
		var merged: Dictionary = (out.get(finger, {}) as Dictionary).duplicate()
		for key in overlay[finger]:
			merged[key] = overlay[finger][key]
		out[finger] = merged
	return out

# inputs: {"trigger_pull": 0.0-1.0, "trigger_touch": bool, ...}
# Returns {"fingers": {finger: {"curl": float, "opposition": float, ...}}, "spread": float}
func evaluate(state_data: Dictionary, inputs: Dictionary) -> Dictionary:
	return {
		"fingers": merged_fingers(state_data, "default"),
		"spread": float(state_data.get("default", {}).get("spread", 0.0)),
	}
# gun_grip_pose_driver.gd — the shipped gun driver. Knows the trigger
# pull blends trigger_rest into trigger_full; the JSON only has to
# provide those two states. Missing states fall back to default.
class_name GunGripPoseDriver
extends GripPoseDriver

func states_needed() -> Array[String]:
	return ["default", "trigger_rest", "trigger_full"]

func evaluate(state_data: Dictionary, inputs: Dictionary) -> Dictionary:
	var pull: float = clampf(float(inputs.get("trigger_pull", 0.0)), 0.0, 1.0)
	var rest: Dictionary = merged_fingers(state_data, "trigger_rest")
	var full: Dictionary = merged_fingers(state_data, "trigger_full")
	var fingers: Dictionary = {}
	for finger in rest:
		var a: Dictionary = rest[finger]
		var b: Dictionary = full.get(finger, a)
		var entry: Dictionary = {
			"curl": lerpf(float(a.get("curl", 0.0)), float(b.get("curl", 0.0)), pull),
			"opposition": lerpf(float(a.get("opposition", 0.0)), float(b.get("opposition", 0.0)), pull),
		}
		if b.has("joints"):
			entry["joints"] = b["joints"]
		elif a.has("joints"):
			entry["joints"] = a["joints"]
		fingers[finger] = entry
	return {"fingers": fingers, "spread": float(state_data.get("default", {}).get("spread", 0.0))}
# grip_pose_drivers.gd — registry, extends+register like the other
# moddable methods. A modder ships a new driver by extending
# GripPoseDriver and registering it under a new id.
class_name GripPoseDrivers

static var _drivers: Dictionary = {}

static func register(driver_id: String, driver: GripPoseDriver) -> void:
	_drivers[driver_id] = driver

static func get_driver(driver_id: String) -> GripPoseDriver:
	return _drivers.get(driver_id, null)

# Shipped drivers register themselves on load:
#   GripPoseDrivers.register("gun_grip_pose", GunGripPoseDriver.new())
#   GripPoseDrivers.register("slide_grip_pose", SlideGripPoseDriver.new())

Call site, the same in both worlds:

var driver: GripPoseDriver = GripPoseDrivers.get_driver(hand_data["driver"])
var pose: Dictionary = driver.evaluate(hand_data["state"], live_inputs)
hand_view.apply_finger_pose(pose)  # curls -> per-joint curves, clamps, mirroring

How the design evolved (revisions)

  1. First shape (2026-11-12): grip_points[mesh/point name].{Left/Right}.state["default", ...] with a base field holding a named pose (e.g. pistol_grip) and a driver entry inside state blending between two states on an analog input. Finger data from the per-finger pose API design.
  2. Revision 1 — base as code control: base doubles as a code control field. pistol_grip renamed to gun_grip_pose; GDScript attaches to that name to control how the fingers are arranged and controlled from the configured states.
  3. Revision 2 — generic + custom via extends+registry: all wiring lives in the controller, full stop. A modder who wants different behavior extends the pose-controller class and registers their own base by name, like the engine's other moddable methods. The JSON driver override was dropped at this step; JSON stays pure data, code stays in code. The editor never has to understand a controller's logic, just the state names each registered controller declares.
  4. Revision 3 (current) — driver data stays in JSON, base renamed to driver: the driver data (states, pose values, everything) can still live in the JSON; the base field is renamed to driver, and it controls which driver is controlling the hand pose for those grip states. The states and pose data are provided to the driver, and the driver interprets the values. The driver must be able to run in the viewport so the author can see how it works. This supersedes revision 2's "driver override dropped" — the override is gone, but the data stays in JSON and the driver field selects the interpreter.

Relationship to existing work

  • Slice 1 (PR #45, built, awaiting merge call): per-hand primary grips — model.grip_offset / grip_rotate as the base/right pose, grip_poses entries overriding per side, two primary GrabPoints (GrabPrimaryR / GrabPrimaryL), mirror rule (lateral axis = item-local X, 5 mm centered epsilon), scale rule, editor side/mirror/render toggles, gun_grip → grip rename. This proposal subsumes slice 1's data: grip_offset/grip_rotate and grip_poses fold into grip_points[<point>].<hand>.pose (+ driver + state).
  • #40 appendix (per-finger pose API): per-finger curl 0–1, named base poses, curl + spread, thumb curl + opposition, separate hand-to-item offset, per-joint additive escape hatch, GDScript owns per-joint curves + left-hand mirroring, clamps in code, pose preview with sliders exporting JSON, versioned schema. The finger data in this proposal is that design, unchanged; the base/driver field is the new part.
  • #4 Grips section (revised by this proposal): the old shape was per-grip data tables with a root pos/rot plus a bones table, side-less, mirrored by the engine. This proposal replaces it with per-point, per-hand pose + driver + state.
  • #33 handedness + the generic pistol two-grip hold: the two-grip hold is still borked on the generic pistol. Zach's hypothesis: it still uses the weapon property table instead of weapon_one_hand. Verified: the pistol declares kind_tags: ['weapon', 'weapon.pistol', 'holster.hip'] and its stats live in properties.weapon — no weapon_one_hand anywhere, and no type_tables key at all. So it gets the generic weapon treatment, consistent with the hypothesis. Caveat: the table governs the handedness multipliers, but the two-hand pivot alignment is the separate H6 gap — _follow's two-hand branch does full basis alignment for any two-handed grab regardless of table. Switching the table fixes the multipliers, not the pivot; the pivot split is still its own work (plan in the rebuild doc §2d.5, waiting on the #43 spike results per Zach's sequencing).

Open questions / decisions for Zach

  • Placement: is grip_points top-level, or under model? The proposal wrote object.grip_points — object read as the item's object/model table.
  • Registry location: where do drivers register (autoload? static class? module claim?).
  • Real names: the apply_finger_pose method on HandView, and the real da_* input name for trigger pull (da_trigger_pull in the sketch is illustrative).
  • Driver id naming convention: gun_grip_pose, slide_grip_pose, … — confirm the _pose suffix convention.
  • Pistol table: does the generic pistol move to weapon_one_hand (data change), or stay generic weapon?
  • Sequencing: this is the design behind slices 2–5 of the grip-pose plan (secondary grips; multi-grip + event-driven extensions + built-in rack/bolt sliding; rack eject pose; slide slam on fire + slide lock). Build order is Zach's call.

What is NOT in this issue

Nothing is built — design only, no code changes, no item JSON changes. The editor side (dock offering each registered driver's declared states, sliders feeding simulated inputs in the viewport) is described, not built. The per-finger curl calibration rework (H5) and the pivot split (H6) stay in #42.

# Grip pose system: grip_points with per-hand drivers, states, and finger poses **Status: DESIGN IN DISCUSSION** (Zach + Lantern, 2026-11-12). Nothing built. This issue files the full discussion so it can be reviewed in one place. Related: #42 (hands thread — glove curl calibration H5, two-handed pivot split H6, decoupled hands, per-hand grips slice 1 in PR #45), #4 (data-driven item definitions — this proposal revises #4's Grips section), #33 (weapon handedness), #40 (per-finger pose API appendix). Plan doc: `dead-air-grip-pose-plan.md` (slices 1–5). ## The goal Elements should be grabbable on objects. Ranged grab always grabs the **primary** grip point. Foregrips and grip attachments are **secondary** grip points — places you can physically grab, such as the slide on a gun, or the second hold of a two-grip hold. Hand grips also store **finger poses**, including **state-based code for controllers**: on Quest controllers, when the finger is not touching the trigger it stays straight out (as on a gun), and when it touches the trigger it curls onto the gun trigger, moving a % based on trigger pull. Those values are authored in the editor mod toolkit. ## The data shape (current revision) `grip_points` keyed by mesh/point name (any named part can be a grip point: slide, foregrip, mag body). Each point has a `role` (`primary` / `secondary`), and per hand (`left` / `right`) a `pose`, a `driver`, and a `state` table. Values below are illustrative, not the pistol's live numbers. ```json { "grip_points": { "schema": 1, "Handgun_Body_Lower": { "role": "primary", "right": { "pose": { "pos": [0, -0.03, 0.0535], "rot": [18, 0, 0] }, "driver": "gun_grip_pose", "state": { "default": { "fingers": { "thumb": { "curl": 0.6, "opposition": 0.8 }, "index": { "curl": 0.1, "joints": { "distal": [0, 0, -15] } }, "middle": { "curl": 0.9 }, "ring": { "curl": 0.95 }, "pinky": { "curl": 1.0 } }, "spread": 0.2 }, "trigger_rest": { "fingers": { "index": { "curl": 0.0 } } }, "trigger_full": { "fingers": { "index": { "curl": 0.9 } } } } } }, "Handgun_Upper": { "role": "secondary", "right": { "pose": { "pos": [0, 0.01, -0.05], "rot": [0, 0, 0] }, "driver": "slide_grip_pose", "state": { "default": { "fingers": { "thumb": { "curl": 0.5, "opposition": 0.6 }, "index": { "curl": 0.8 }, "middle": { "curl": 0.8 }, "ring": { "curl": 0.8 }, "pinky": { "curl": 0.8 } }, "spread": 0.1 } } } } } } ``` ## Rules the shape bakes in - **Point keyed by mesh name.** Any named part can be a grip point — slide, foregrip, mag body. `role` lives on the point: `primary` is what ranged grab always takes, `secondary` is physically grabbable (slide rack, second hand). - **Pose per hand, next to state.** That is the old `model.grip_offset` / `grip_rotate`, folded in — slice 1's `grip_poses` is subsumed by `grip_points[<point>].<hand>.pose`. Slice 1's mirror rule and scale rule carry over unchanged: left omitted means mirror of right (for centered objects; non-centered left defaults to right, flagged not-mirrored); poses are authored in model space and scale with the item's content scale at load. - **States inherit from `default`.** A state only lists what changes (the trigger states only touch the index finger). `default` is the fallback; everything else is code-selected or driven. `state` is singular, matching the proposal's notation; hand keys are lowercase `left` / `right`, matching slice 1's `hands: ["left"]` convention. - **Finger data is the per-finger pose API design** (Claude discussion 2026-11-09, appendix in #40): per-finger curl 0–1 (0 = hand model resting position, 1 = fist), thumb curl + opposition, `spread`, and an additive per-joint `joints` escape hatch on individual knuckles. GDScript owns the per-joint curves, clamps, and left-hand mirroring. `schema: 1` versions it. - **The driver interprets; the JSON never decides.** `driver` sits at hand level (per hand, next to `pose` and `state`) and names the registry id of the driver controlling the hand pose for those grip states. The states and pose data are provided *to* the driver, and the driver interprets the values. - **Drivers are tree-free, inputs injected.** A driver takes (state table, live inputs) and returns finger values. It never touches the scene tree or reads XR input directly. In game, `live_inputs` comes from the XR action state; in the editor, the dock feeds the same dict from sliders (trigger pull slider, touch toggle). The driver cannot tell the difference — that is what makes it runnable in the viewport, so the author can see how it actually works. - **Left hand gets mirrored data** from the slice-1 rule at the data layer; the driver evaluates it the same way for either hand. ## The GDScript sketch Sketch, not the real API — method names like `apply_finger_pose` are illustrative. ```gdscript # grip_pose_driver.gd — base class. Drivers take (state table, live inputs) # and return finger values. They never touch the scene tree or read XR # input directly, so the same code runs in-game and in the editor viewport. class_name GripPoseDriver extends RefCounted # State names this driver reads, so the editor dock can offer them. func states_needed() -> Array[String]: return ["default"] # Merge a state's fingers over default. States are partial overrides: # a state only lists what changes, per finger, per key. func merged_fingers(state_data: Dictionary, state_name: String) -> Dictionary: var out: Dictionary = {} var base: Dictionary = state_data.get("default", {}).get("fingers", {}) for finger in base: out[finger] = (base[finger] as Dictionary).duplicate() var overlay: Dictionary = state_data.get(state_name, {}).get("fingers", {}) for finger in overlay: var merged: Dictionary = (out.get(finger, {}) as Dictionary).duplicate() for key in overlay[finger]: merged[key] = overlay[finger][key] out[finger] = merged return out # inputs: {"trigger_pull": 0.0-1.0, "trigger_touch": bool, ...} # Returns {"fingers": {finger: {"curl": float, "opposition": float, ...}}, "spread": float} func evaluate(state_data: Dictionary, inputs: Dictionary) -> Dictionary: return { "fingers": merged_fingers(state_data, "default"), "spread": float(state_data.get("default", {}).get("spread", 0.0)), } ``` ```gdscript # gun_grip_pose_driver.gd — the shipped gun driver. Knows the trigger # pull blends trigger_rest into trigger_full; the JSON only has to # provide those two states. Missing states fall back to default. class_name GunGripPoseDriver extends GripPoseDriver func states_needed() -> Array[String]: return ["default", "trigger_rest", "trigger_full"] func evaluate(state_data: Dictionary, inputs: Dictionary) -> Dictionary: var pull: float = clampf(float(inputs.get("trigger_pull", 0.0)), 0.0, 1.0) var rest: Dictionary = merged_fingers(state_data, "trigger_rest") var full: Dictionary = merged_fingers(state_data, "trigger_full") var fingers: Dictionary = {} for finger in rest: var a: Dictionary = rest[finger] var b: Dictionary = full.get(finger, a) var entry: Dictionary = { "curl": lerpf(float(a.get("curl", 0.0)), float(b.get("curl", 0.0)), pull), "opposition": lerpf(float(a.get("opposition", 0.0)), float(b.get("opposition", 0.0)), pull), } if b.has("joints"): entry["joints"] = b["joints"] elif a.has("joints"): entry["joints"] = a["joints"] fingers[finger] = entry return {"fingers": fingers, "spread": float(state_data.get("default", {}).get("spread", 0.0))} ``` ```gdscript # grip_pose_drivers.gd — registry, extends+register like the other # moddable methods. A modder ships a new driver by extending # GripPoseDriver and registering it under a new id. class_name GripPoseDrivers static var _drivers: Dictionary = {} static func register(driver_id: String, driver: GripPoseDriver) -> void: _drivers[driver_id] = driver static func get_driver(driver_id: String) -> GripPoseDriver: return _drivers.get(driver_id, null) # Shipped drivers register themselves on load: # GripPoseDrivers.register("gun_grip_pose", GunGripPoseDriver.new()) # GripPoseDrivers.register("slide_grip_pose", SlideGripPoseDriver.new()) ``` Call site, the same in both worlds: ```gdscript var driver: GripPoseDriver = GripPoseDrivers.get_driver(hand_data["driver"]) var pose: Dictionary = driver.evaluate(hand_data["state"], live_inputs) hand_view.apply_finger_pose(pose) # curls -> per-joint curves, clamps, mirroring ``` ## How the design evolved (revisions) 1. **First shape (2026-11-12):** `grip_points[mesh/point name].{Left/Right}.state["default", ...]` with a `base` field holding a named pose (e.g. `pistol_grip`) and a `driver` entry inside `state` blending between two states on an analog input. Finger data from the per-finger pose API design. 2. **Revision 1 — base as code control:** `base` doubles as a code control field. `pistol_grip` renamed to `gun_grip_pose`; GDScript attaches to that name to control how the fingers are arranged and controlled from the configured states. 3. **Revision 2 — generic + custom via extends+registry:** all wiring lives in the controller, full stop. A modder who wants different behavior extends the pose-controller class and registers their own base by name, like the engine's other moddable methods. The JSON `driver` override was dropped at this step; JSON stays pure data, code stays in code. The editor never has to understand a controller's logic, just the state names each registered controller declares. 4. **Revision 3 (current) — driver data stays in JSON, base renamed to driver:** the driver data (states, pose values, everything) *can* still live in the JSON; the `base` field is renamed to `driver`, and it controls *which* driver is controlling the hand pose for those grip states. The states and pose data are provided to the driver, and the driver interprets the values. The driver must be able to run in the viewport so the author can see how it works. This supersedes revision 2's "driver override dropped" — the override is gone, but the *data* stays in JSON and the driver field selects the interpreter. ## Relationship to existing work - **Slice 1 (PR #45, built, awaiting merge call):** per-hand primary grips — `model.grip_offset` / `grip_rotate` as the base/right pose, `grip_poses` entries overriding per side, two primary GrabPoints (`GrabPrimaryR` / `GrabPrimaryL`), mirror rule (lateral axis = item-local X, 5 mm centered epsilon), scale rule, editor side/mirror/render toggles, `gun_grip` → `grip` rename. This proposal subsumes slice 1's data: `grip_offset`/`grip_rotate` and `grip_poses` fold into `grip_points[<point>].<hand>.pose` (+ `driver` + `state`). - **#40 appendix (per-finger pose API):** per-finger curl 0–1, named base poses, curl + spread, thumb curl + opposition, separate hand-to-item offset, per-joint additive escape hatch, GDScript owns per-joint curves + left-hand mirroring, clamps in code, pose preview with sliders exporting JSON, versioned schema. The finger data in this proposal is that design, unchanged; the `base`/`driver` field is the new part. - **#4 Grips section (revised by this proposal):** the old shape was per-grip data tables with a `root` pos/rot plus a `bones` table, side-less, mirrored by the engine. This proposal replaces it with per-point, per-hand `pose` + `driver` + `state`. - **#33 handedness + the generic pistol two-grip hold:** the two-grip hold is still borked on the generic pistol. Zach's hypothesis: it still uses the `weapon` property table instead of `weapon_one_hand`. Verified: the pistol declares `kind_tags: ['weapon', 'weapon.pistol', 'holster.hip']` and its stats live in `properties.weapon` — no `weapon_one_hand` anywhere, and no `type_tables` key at all. So it gets the generic `weapon` treatment, consistent with the hypothesis. Caveat: the table governs the handedness multipliers, but the two-hand *pivot* alignment is the separate H6 gap — `_follow`'s two-hand branch does full basis alignment for any two-handed grab regardless of table. Switching the table fixes the multipliers, not the pivot; the pivot split is still its own work (plan in the rebuild doc §2d.5, waiting on the #43 spike results per Zach's sequencing). ## Open questions / decisions for Zach - **Placement:** is `grip_points` top-level, or under `model`? The proposal wrote `object.grip_points` — `object` read as the item's object/model table. - **Registry location:** where do drivers register (autoload? static class? module claim?). - **Real names:** the `apply_finger_pose` method on HandView, and the real da_* input name for trigger pull (`da_trigger_pull` in the sketch is illustrative). - **Driver id naming convention:** `gun_grip_pose`, `slide_grip_pose`, … — confirm the `_pose` suffix convention. - **Pistol table:** does the generic pistol move to `weapon_one_hand` (data change), or stay generic `weapon`? - **Sequencing:** this is the design behind slices 2–5 of the grip-pose plan (secondary grips; multi-grip + event-driven extensions + built-in rack/bolt sliding; rack eject pose; slide slam on fire + slide lock). Build order is Zach's call. ## What is NOT in this issue Nothing is built — design only, no code changes, no item JSON changes. The editor side (dock offering each registered driver's declared states, sliders feeding simulated inputs in the viewport) is described, not built. The per-finger curl calibration rework (H5) and the pivot split (H6) stay in #42.
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#46
No description provided.