diff --git a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/en.json b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/en.json
index ec390c932a..ca7ff6610a 100644
--- a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/en.json
+++ b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/en.json
@@ -5221,6 +5221,30 @@
{
"key": "settings.ra.title.voiceBuffer",
"value": "Voice Buffer"
+ },
+ {
+ "key": "library.assignTracker",
+ "value": "Assign"
+ },
+ {
+ "key": "library.unbindTracker",
+ "value": "Unbind"
+ },
+ {
+ "key": "library.trackerPicker.title",
+ "value": "Choose Tracker"
+ },
+ {
+ "key": "library.trackerPicker.empty",
+ "value": "No trackers are currently connected."
+ },
+ {
+ "key": "library.trackerPicker.confirm",
+ "value": "Bind"
+ },
+ {
+ "key": "library.trackerPicker.cancel",
+ "value": "Cancel"
}
]
}
diff --git a/Basis/Packages/com.basis.framework/BasisUI/Menus/Library/LibraryProvider.cs b/Basis/Packages/com.basis.framework/BasisUI/Menus/Library/LibraryProvider.cs
index 158927e49e..d47051e2ed 100644
--- a/Basis/Packages/com.basis.framework/BasisUI/Menus/Library/LibraryProvider.cs
+++ b/Basis/Packages/com.basis.framework/BasisUI/Menus/Library/LibraryProvider.cs
@@ -86,6 +86,13 @@ public override void OnReleaseEvent()
private static protected bool IsProtected = false; // we use this to determine if the user is admin for admin related queries on the library provider
public static BasisMenuPanel panel;
+ ///
+ /// Fires per instantiated-object row right after the Select button is built,
+ /// before Teleport/Remove. Subscribers can append buttons to the supplied row
+ /// container — they land between Select and Teleport.
+ ///
+ public static event Action OnInstanceRowCreated;
+
// references to the search query elements
private static PanelTextField searchField; // reference to the search field
private static PanelDropdown dateSorting; // reference to the date sorting dropdown
@@ -1975,9 +1982,11 @@ private static void CreateListEntry(BasisRuntimeSpawnRegistry.SpawnInstance item
// close the menu
BasisMainMenu.Close();
}
-
+
};
+ OnInstanceRowCreated?.Invoke(itemListPanel.TabButtonParent, itemKey);
+
PanelButton TeleportToItem = PanelButton.CreateNew(ButtonStyles.StandardButton, itemListPanel.TabButtonParent);
TeleportToItem.Descriptor.SetTitle(string.Empty);
TeleportToItem.SetIcon(AddressableAssets.Sprites.TeleportTo);
diff --git a/Basis/Packages/com.basis.integration.trackerobjects/LICENSE.md b/Basis/Packages/com.basis.integration.trackerobjects/LICENSE.md
new file mode 100644
index 0000000000..4253a56423
--- /dev/null
+++ b/Basis/Packages/com.basis.integration.trackerobjects/LICENSE.md
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 BasisVR
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/Basis/Packages/com.basis.integration.trackerobjects/LICENSE.md.meta b/Basis/Packages/com.basis.integration.trackerobjects/LICENSE.md.meta
new file mode 100644
index 0000000000..f25ec175b2
--- /dev/null
+++ b/Basis/Packages/com.basis.integration.trackerobjects/LICENSE.md.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 12563aaadbb712e49a94036afb0c2d06
+TextScriptImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Basis/Packages/com.basis.integration.trackerobjects/README.md b/Basis/Packages/com.basis.integration.trackerobjects/README.md
new file mode 100644
index 0000000000..880f2fbac7
--- /dev/null
+++ b/Basis/Packages/com.basis.integration.trackerobjects/README.md
@@ -0,0 +1,34 @@
+# Basis Tracker Objects Integration
+
+Bridges [`com.basis.trackerobjects`](../com.basis.trackerobjects/REQUIREMENTS.md) into the Basis library menu. When a prop has been instantiated and shows up in the library's instantiated-items tab, this package adds an **Assign** button to the row. Clicking it opens a tracker picker; confirming a tracker binds the prop's GameObject to that tracker via `BasisTrackerObjectManager`. Clicking **Unbind** removes the binding.
+
+## Why a separate package
+
+`com.basis.trackerobjects` references `Basis Framework` for the types it needs to drive a transform (`BasisInput`, `BasisLocalPlayer`, `BasisRuntimeSpawnRegistry`). That means `Basis Framework` can't reference `com.basis.trackerobjects` back — the asmdef graph would cycle. This integration package references both and is the only place that can wire a library-menu button into a `BasisTrackerObjectManager.TryCreateBinding` call. Same pattern as `com.basis.integration.audiolink`.
+
+## What it adds
+
+- A `[RuntimeInitializeOnLoadMethod(SubsystemRegistration)]` subscriber on `LibraryProvider.OnInstanceRowCreated`. For every instantiated-object row that `LibraryProvider` builds, this appends a `StandardButton` between the existing Select and Teleport buttons.
+- The button is non-interactable for `SpawnMode.Scene` and `SpawnMethod.Embedded` items — same rule the Select button applies.
+- Clicking **Assign** opens a `DialogBox` modal listing the currently-connected trackers eligible for prop binding. Confirming a row calls `BasisTrackerObjectManager.TryCreateBinding` with the spawn instance's `LoadedNetID` and `GameObject` transform. The picker excludes:
+ - `BasisVirtualMidpointInput` instances (the virtual half of an active pair).
+ - Trackers with `BasisInput.IsLinked == true` (one half of an active pair).
+ - Trackers whose `UniqueDeviceIdentifier` has a `BasisTrackerRoleOverride.TryGetOverride` hit.
+ - Devices the input matcher has pinned to a fixed role (HMD, named controllers, etc.).
+ - Trackers currently driving an avatar bone via calibration. Decalibrate first if you want to reuse a calibrated tracker for a prop.
+- Clicking **Unbind** calls `BasisTrackerObjectManager.TryRemoveBinding` directly — no confirmation dialog. Unbind isn't destructive; the binding just lifts.
+
+## Compile guards
+
+The assembly defines two version constraints:
+
+- `com.basis.framework` → `BASIS_FRAMEWORK_EXISTS`
+- `com.basis.trackerobjects` → `BASIS_TRACKEROBJECTS_EXISTS`
+
+Both must be present for this package to compile. If either is removed from the project, this assembly drops out silently.
+
+## See also
+
+- [`com.basis.trackerobjects/REQUIREMENTS.md`](../com.basis.trackerobjects/REQUIREMENTS.md) — full v1 spec for the binding manager, pose drive, pickup veto, and registry-cleanup contract.
+- `LibraryProvider.OnInstanceRowCreated` — the event this package subscribes to. Lives in `com.basis.framework`.
+- `com.basis.integration.audiolink` — sibling integration package that follows the same bridge pattern.
diff --git a/Basis/Packages/com.basis.integration.trackerobjects/README.md.meta b/Basis/Packages/com.basis.integration.trackerobjects/README.md.meta
new file mode 100644
index 0000000000..ae4a1a4da2
--- /dev/null
+++ b/Basis/Packages/com.basis.integration.trackerobjects/README.md.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: af43d869252215f42a6e073975019f90
+TextScriptImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Basis/Packages/com.basis.integration.trackerobjects/Runtime.meta b/Basis/Packages/com.basis.integration.trackerobjects/Runtime.meta
new file mode 100644
index 0000000000..b7852df074
--- /dev/null
+++ b/Basis/Packages/com.basis.integration.trackerobjects/Runtime.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: d3f6f5a03cdabb849a41d489b099507e
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Basis/Packages/com.basis.integration.trackerobjects/Runtime/Basis.Integration.TrackerObjects.asmdef b/Basis/Packages/com.basis.integration.trackerobjects/Runtime/Basis.Integration.TrackerObjects.asmdef
new file mode 100644
index 0000000000..0cbf7124f9
--- /dev/null
+++ b/Basis/Packages/com.basis.integration.trackerobjects/Runtime/Basis.Integration.TrackerObjects.asmdef
@@ -0,0 +1,34 @@
+{
+ "name": "Basis.Integration.TrackerObjects",
+ "rootNamespace": "Basis.Integration.TrackerObjects",
+ "references": [
+ "Basis Framework",
+ "BasisSDK",
+ "BasisDebug",
+ "BasisTrackerObjects",
+ "Unity.TextMeshPro"
+ ],
+ "includePlatforms": [],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": false,
+ "precompiledReferences": [],
+ "autoReferenced": true,
+ "defineConstraints": [
+ "BASIS_FRAMEWORK_EXISTS",
+ "BASIS_TRACKEROBJECTS_EXISTS"
+ ],
+ "versionDefines": [
+ {
+ "name": "com.basis.framework",
+ "expression": "",
+ "define": "BASIS_FRAMEWORK_EXISTS"
+ },
+ {
+ "name": "com.basis.trackerobjects",
+ "expression": "",
+ "define": "BASIS_TRACKEROBJECTS_EXISTS"
+ }
+ ],
+ "noEngineReferences": false
+}
diff --git a/Basis/Packages/com.basis.integration.trackerobjects/Runtime/Basis.Integration.TrackerObjects.asmdef.meta b/Basis/Packages/com.basis.integration.trackerobjects/Runtime/Basis.Integration.TrackerObjects.asmdef.meta
new file mode 100644
index 0000000000..23ba62f3be
--- /dev/null
+++ b/Basis/Packages/com.basis.integration.trackerobjects/Runtime/Basis.Integration.TrackerObjects.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 14e7fa5f04af2d848a51f1e909da387f
+AssemblyDefinitionImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Basis/Packages/com.basis.integration.trackerobjects/Runtime/BasisTrackerObjectsLibraryHook.cs b/Basis/Packages/com.basis.integration.trackerobjects/Runtime/BasisTrackerObjectsLibraryHook.cs
new file mode 100644
index 0000000000..0b5321d804
--- /dev/null
+++ b/Basis/Packages/com.basis.integration.trackerobjects/Runtime/BasisTrackerObjectsLibraryHook.cs
@@ -0,0 +1,138 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Basis.BasisUI;
+using Basis.Scripts.Avatar;
+using Basis.Scripts.Device_Management;
+using Basis.Scripts.Device_Management.Devices;
+using Basis.Scripts.Device_Management.Devices.Pairing;
+using Basis.Scripts.TransformBinders.BoneControl;
+using Basis.TrackerObjects;
+using UnityEngine;
+
+namespace Basis.Integration.TrackerObjects
+{
+ internal static class BasisTrackerObjectsLibraryHook
+ {
+ private static readonly Vector2 PickerSize = new Vector2(900, 720);
+ private static readonly Vector2 RowSize = new Vector2(80, 80);
+ private static readonly Vector2 PickerRowSize = new Vector2(700, 60);
+
+ [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
+ private static void Subscribe()
+ {
+ LibraryProvider.OnInstanceRowCreated -= OnRowCreated;
+ LibraryProvider.OnInstanceRowCreated += OnRowCreated;
+ }
+
+ private static void OnRowCreated(RectTransform parent, BasisRuntimeSpawnRegistry.SpawnInstance instance)
+ {
+ if (instance == null) return;
+ string netID = instance.LoadedNetID;
+ if (string.IsNullOrEmpty(netID)) return;
+
+ // Scene-mode and embedded instances can't host a tracker binding (no
+ // pickup/rigid surface to drive, and they're not user-owned spawns), so
+ // skip adding the button at all — a disabled fourth button just pushes
+ // the Select/Teleport/Remove row over.
+ if (instance.SpawnMode == BasisRuntimeSpawnRegistry.SpawnMode.Scene) return;
+ if (instance.SpawnMethod == BasisRuntimeSpawnRegistry.SpawnMethod.Embedded) return;
+
+ bool hasBinding = BasisTrackerObjectManager.TryGetBindingByLoadedNetID(netID, out _);
+ PanelButton button = PanelButton.CreateNew(PanelButton.ButtonStyles.StandardButton, parent);
+ button.Descriptor.SetTitle(string.Empty);
+ button.SetIcon(hasBinding ? AddressableAssets.Sprites.Unlink : AddressableAssets.Sprites.Link);
+ button.SetSize(RowSize);
+ // Match the row's left-side status-icon padding (PE Image Simple Square inset).
+ button.Descriptor.IconImage.rectTransform.sizeDelta = new Vector2(-30, -30);
+
+ button.OnClicked += async () =>
+ {
+ if (BasisTrackerObjectManager.TryGetBindingByLoadedNetID(netID, out BasisTrackerBinding existing))
+ {
+ BasisTrackerObjectManager.TryRemoveBinding(existing.Id);
+ button.SetIcon(AddressableAssets.Sprites.Link);
+ return;
+ }
+
+ if (!BasisRuntimeSpawnRegistry.SpawnedGameobjects.TryGetValue(netID, out GameObject go) || go == null)
+ {
+ BasisDebug.LogWarning($"AssignTracker: spawn instance {netID} has no resolved GameObject", BasisDebug.LogTag.TrackerObjects);
+ return;
+ }
+
+ BasisInput chosen = await OpenPickerAsync();
+ if (chosen == null) return;
+
+ if (BasisTrackerObjectManager.TryCreateBinding(chosen, go.transform, netID, out _))
+ {
+ button.SetIcon(AddressableAssets.Sprites.Unlink);
+ }
+ };
+ }
+
+ private static async Task OpenPickerAsync()
+ {
+ DialogBox picker = DialogBox.Create(
+ LibraryProvider.panel,
+ PickerSize,
+ BasisLocalization.Get("library.trackerPicker.title"),
+ description: null,
+ icon: AddressableAssets.Sprites.Information);
+
+ PanelButton cancel = PanelButton.CreateNew(PanelButton.ButtonStyles.ExitButton, picker.Descriptor.Header);
+ cancel.Descriptor.SetTitle(BasisLocalization.Get("library.trackerPicker.cancel"));
+ cancel.rectTransform.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, 125);
+ cancel.rectTransform.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, 50);
+ cancel.OnClicked += () => picker.Cancel(null);
+
+ List candidates = CollectBindableTrackers();
+ if (candidates.Count == 0)
+ {
+ PanelTextField empty = PanelTextField.CreateNew(PanelTextField.TextFieldStyles.Entry, picker.Descriptor.ContentParent);
+ empty._inputField.gameObject.SetActive(false);
+ empty.Descriptor.SetTitle(BasisLocalization.Get("library.trackerPicker.empty"));
+ }
+ else
+ {
+ for (int index = 0; index < candidates.Count; index++)
+ {
+ BasisInput tracker = candidates[index];
+ string roleLabel = tracker.TryGetRole(out BasisBoneTrackedRole role)
+ ? role.ToString()
+ : "Tracker";
+ PanelButton row = PanelButton.CreateNew(PanelButton.ButtonStyles.StandardButton, picker.Descriptor.ContentParent);
+ row.Descriptor.SetTitle($"{roleLabel} — {tracker.UniqueDeviceIdentifier}");
+ row.SetSize(PickerRowSize);
+ row.OnClicked += () => picker.CloseWithResult(tracker);
+ }
+ }
+
+ return await picker.WaitAsync();
+ }
+
+ private static List CollectBindableTrackers()
+ {
+ List result = new List();
+ BasisObservableList devices = BasisDeviceManagement.Instance?.AllInputDevices;
+ if (devices == null) return result;
+
+ for (int i = 0; i < devices.Count; i++)
+ {
+ BasisInput input = devices[i];
+ if (input == null) continue;
+ if (string.IsNullOrEmpty(input.UniqueDeviceIdentifier)) continue;
+ if (input is BasisVirtualMidpointInput) continue;
+ if (input.IsLinked) continue;
+ if (BasisTrackerRoleOverride.TryGetOverride(input.UniqueDeviceIdentifier, out _)) continue;
+ if (input.DeviceMatchSettings != null && input.DeviceMatchSettings.HasTrackedRole) continue;
+ // A tracker already driving a body bone (post-calibration) is excluded so
+ // calibration and prop binding can't fight over the same device. To reuse
+ // a calibrated tracker, decalibrate first.
+ if (input.TryGetRole(out _)) continue;
+
+ result.Add(input);
+ }
+ return result;
+ }
+ }
+}
diff --git a/Basis/Packages/com.basis.integration.trackerobjects/Runtime/BasisTrackerObjectsLibraryHook.cs.meta b/Basis/Packages/com.basis.integration.trackerobjects/Runtime/BasisTrackerObjectsLibraryHook.cs.meta
new file mode 100644
index 0000000000..599af464e2
--- /dev/null
+++ b/Basis/Packages/com.basis.integration.trackerobjects/Runtime/BasisTrackerObjectsLibraryHook.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: a9316928431e2d34baf18718780b977c
\ No newline at end of file
diff --git a/Basis/Packages/com.basis.integration.trackerobjects/package.json b/Basis/Packages/com.basis.integration.trackerobjects/package.json
new file mode 100644
index 0000000000..03510b5382
--- /dev/null
+++ b/Basis/Packages/com.basis.integration.trackerobjects/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "com.basis.integration.trackerobjects",
+ "displayName": "Basis Tracker Objects Integration",
+ "version": "0.0.1",
+ "description": "Wires com.basis.trackerobjects into the Basis library menu. Adds the Assign/Unbind Tracker button and the tracker-picker dialog. Compiles only when both com.basis.framework and com.basis.trackerobjects are present.",
+ "author": {
+ "name": "BasisVR",
+ "url": "https://github.com/BasisVR"
+ }
+}
diff --git a/Basis/Packages/com.basis.integration.trackerobjects/package.json.meta b/Basis/Packages/com.basis.integration.trackerobjects/package.json.meta
new file mode 100644
index 0000000000..dc701f2f78
--- /dev/null
+++ b/Basis/Packages/com.basis.integration.trackerobjects/package.json.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: b2cab1f2262ce9e45a4f029194039de9
+PackageManifestImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Basis/Packages/com.basis.sdk/Scripts/Basis Logger/BasisDebug.cs b/Basis/Packages/com.basis.sdk/Scripts/Basis Logger/BasisDebug.cs
index 6159cc8802..2a645e3011 100644
--- a/Basis/Packages/com.basis.sdk/Scripts/Basis Logger/BasisDebug.cs
+++ b/Basis/Packages/com.basis.sdk/Scripts/Basis Logger/BasisDebug.cs
@@ -141,6 +141,7 @@ public enum LogTag
Shims,
Props,
LocalNetwork,
+ TrackerObjects,
}
public enum MessageType
diff --git a/Basis/Packages/com.basis.trackerobjects/LICENSE.md b/Basis/Packages/com.basis.trackerobjects/LICENSE.md
new file mode 100644
index 0000000000..4253a56423
--- /dev/null
+++ b/Basis/Packages/com.basis.trackerobjects/LICENSE.md
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 BasisVR
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/Basis/Packages/com.basis.trackerobjects/LICENSE.md.meta b/Basis/Packages/com.basis.trackerobjects/LICENSE.md.meta
new file mode 100644
index 0000000000..3c3bf378fd
--- /dev/null
+++ b/Basis/Packages/com.basis.trackerobjects/LICENSE.md.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 8f674a0ab242d304db0a81442e8b4c2c
+TextScriptImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Basis/Packages/com.basis.trackerobjects/Runtime.meta b/Basis/Packages/com.basis.trackerobjects/Runtime.meta
new file mode 100644
index 0000000000..6d8fd9ff05
--- /dev/null
+++ b/Basis/Packages/com.basis.trackerobjects/Runtime.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: b44537b3dcedc2645bd3156ecf9c6db3
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerBinding.cs b/Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerBinding.cs
new file mode 100644
index 0000000000..ccf9d18a81
--- /dev/null
+++ b/Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerBinding.cs
@@ -0,0 +1,22 @@
+using Basis.Scripts.BasisSdk.Interactions;
+using Basis.Scripts.Device_Management.Devices;
+using UnityEngine;
+
+namespace Basis.TrackerObjects
+{
+ public class BasisTrackerBinding
+ {
+ public int Id;
+ public BasisInput Tracker;
+ public Transform Target;
+ public string UniqueDeviceIdentifier;
+ public string LoadedNetID;
+ public Vector3 LocalPositionOffset;
+ public Quaternion LocalRotationOffset;
+
+ public BasisPickupInteractable PickupRef;
+ public Rigidbody RigidRef;
+ public bool PreBindKinematic;
+ public bool HasKinematicCaptured;
+ }
+}
diff --git a/Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerBinding.cs.meta b/Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerBinding.cs.meta
new file mode 100644
index 0000000000..ad5ccdd21f
--- /dev/null
+++ b/Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerBinding.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 967b93cccbc1a3846a11dcec83ef1d7d
\ No newline at end of file
diff --git a/Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerObjectManager.cs b/Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerObjectManager.cs
new file mode 100644
index 0000000000..e583adbf9e
--- /dev/null
+++ b/Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerObjectManager.cs
@@ -0,0 +1,201 @@
+using System;
+using System.Collections.Generic;
+using Basis.Scripts.BasisSdk.Interactions;
+using Basis.Scripts.BasisSdk.Players;
+using Basis.Scripts.Device_Management.Devices;
+using UnityEngine;
+
+namespace Basis.TrackerObjects
+{
+ public static class BasisTrackerObjectManager
+ {
+ public const int RenderPriority = 99;
+
+ public static readonly List Bindings = new List();
+
+ public static event Action OnBindingCreated;
+ public static event Action OnBindingRemoved;
+
+ private static int _nextID = 1;
+ private static bool _subscribed;
+
+ // Single shared deny predicates — each binding lives on a distinct
+ // BasisPickupInteractable (enforced by the LoadedNetID dedup), so the same
+ // delegate instance is added once per pickup list and removed once on unbind.
+ private static readonly Func _denyHover = static _ => false;
+ private static readonly Func _denyInteract = static _ => false;
+
+ [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
+ private static void Initialize()
+ {
+ if (_subscribed)
+ {
+ return;
+ }
+ BasisLocalPlayer.AfterSimulateOnRender.AddAction(RenderPriority, OnAfterSimulateOnRender);
+ BasisRuntimeSpawnRegistry.OnRegistryChanged += OnRegistryChanged;
+ _subscribed = true;
+ BasisDebug.Log("BasisTrackerObjectManager subscribed", BasisDebug.LogTag.TrackerObjects);
+ }
+
+ public static bool TryCreateBinding(BasisInput tracker, Transform target, string loadedNetID, out int id)
+ {
+ id = 0;
+ if (tracker == null || target == null)
+ {
+ BasisDebug.LogError("TryCreateBinding: tracker or target was null", BasisDebug.LogTag.TrackerObjects);
+ return false;
+ }
+ if (string.IsNullOrEmpty(loadedNetID))
+ {
+ BasisDebug.LogError("TryCreateBinding: loadedNetID was null/empty", BasisDebug.LogTag.TrackerObjects);
+ return false;
+ }
+ if (TryGetBindingByLoadedNetID(loadedNetID, out _))
+ {
+ BasisDebug.LogWarning($"TryCreateBinding: a binding for LoadedNetID {loadedNetID} already exists", BasisDebug.LogTag.TrackerObjects);
+ return false;
+ }
+
+ tracker.transform.GetPositionAndRotation(out Vector3 trackerPos, out Quaternion trackerRot);
+ target.GetPositionAndRotation(out Vector3 targetPos, out Quaternion targetRot);
+ Quaternion invRot = Quaternion.Inverse(trackerRot);
+
+ id = _nextID++;
+ BasisTrackerBinding binding = new BasisTrackerBinding
+ {
+ Id = id,
+ Tracker = tracker,
+ Target = target,
+ UniqueDeviceIdentifier = tracker.UniqueDeviceIdentifier,
+ LoadedNetID = loadedNetID,
+ LocalPositionOffset = invRot * (targetPos - trackerPos),
+ LocalRotationOffset = invRot * targetRot,
+ };
+
+ if (target.TryGetComponent(out BasisPickupInteractable pickup))
+ {
+ binding.PickupRef = pickup;
+ pickup.CanHoverInjected.Add(_denyHover);
+ pickup.CanInteractInjected.Add(_denyInteract);
+
+ if (pickup.RigidRef != null)
+ {
+ binding.RigidRef = pickup.RigidRef;
+ binding.PreBindKinematic = pickup.RigidRef.isKinematic;
+ binding.HasKinematicCaptured = true;
+ pickup.RigidRef.isKinematic = true;
+ }
+ }
+
+ if (!target.TryGetComponent(out _))
+ {
+ BasisDebug.LogWarning($"TryCreateBinding: target {target.name} has no BasisNetworkContentBase — local-only motion, remote players will not see the binding move", BasisDebug.LogTag.TrackerObjects);
+ }
+
+ Bindings.Add(binding);
+ BasisDebug.Log($"Created tracker binding {id} for {tracker.UniqueDeviceIdentifier} -> {target.name} (netID {loadedNetID})", BasisDebug.LogTag.TrackerObjects);
+ OnBindingCreated?.Invoke(binding);
+ return true;
+ }
+
+ public static bool TryRemoveBinding(int id)
+ {
+ int count = Bindings.Count;
+ for (int index = 0; index < count; index++)
+ {
+ if (Bindings[index].Id == id)
+ {
+ RemoveAt(index);
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public static bool TryGetBindingByLoadedNetID(string loadedNetID, out BasisTrackerBinding binding)
+ {
+ binding = null;
+ if (string.IsNullOrEmpty(loadedNetID))
+ {
+ return false;
+ }
+ int count = Bindings.Count;
+ for (int index = 0; index < count; index++)
+ {
+ BasisTrackerBinding b = Bindings[index];
+ if (b.LoadedNetID == loadedNetID)
+ {
+ binding = b;
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static void RemoveAt(int index)
+ {
+ BasisTrackerBinding binding = Bindings[index];
+ if (binding.PickupRef != null)
+ {
+ binding.PickupRef.CanHoverInjected.Remove(_denyHover);
+ binding.PickupRef.CanInteractInjected.Remove(_denyInteract);
+ }
+ if (binding.HasKinematicCaptured && binding.RigidRef != null)
+ {
+ binding.RigidRef.isKinematic = binding.PreBindKinematic;
+ }
+ Bindings.RemoveAt(index);
+ BasisDebug.Log($"Removed tracker binding {binding.Id}", BasisDebug.LogTag.TrackerObjects);
+ OnBindingRemoved?.Invoke(binding);
+ }
+
+ private static void OnAfterSimulateOnRender()
+ {
+ int count = Bindings.Count;
+ for (int index = 0; index < count; index++)
+ {
+ BasisTrackerBinding binding = Bindings[index];
+ if (binding.Tracker == null || binding.Target == null)
+ {
+ continue;
+ }
+ // BasisObjectSyncNetworking.Awake and ControlState both flip
+ // isKinematic = false on locally-owned props, and ControlState can
+ // re-fire on ownership-transfer events long after bind. If physics
+ // touches the rigidbody between our writes, Scene view samples those
+ // intermediate frames (out of step with onBeforeRender) and flickers
+ // even when Game view stays clean. Re-asserting kinematic each frame
+ // is cheap and avoids playing whack-a-mole with every external setter.
+ if (binding.HasKinematicCaptured && binding.RigidRef != null)
+ {
+ binding.RigidRef.isKinematic = true;
+ }
+ binding.Tracker.transform.GetPositionAndRotation(out Vector3 trackerPos, out Quaternion trackerRot);
+ binding.Target.SetPositionAndRotation(
+ trackerPos + trackerRot * binding.LocalPositionOffset,
+ trackerRot * binding.LocalRotationOffset);
+ }
+ }
+
+ private static void OnRegistryChanged(BasisRuntimeSpawnRegistry.RegistryChangeType type, BasisRuntimeSpawnRegistry.SpawnInstance instance)
+ {
+ switch (type)
+ {
+ case BasisRuntimeSpawnRegistry.RegistryChangeType.Removed:
+ case BasisRuntimeSpawnRegistry.RegistryChangeType.ClearedUrl:
+ if (instance != null && TryGetBindingByLoadedNetID(instance.LoadedNetID, out BasisTrackerBinding binding))
+ {
+ TryRemoveBinding(binding.Id);
+ }
+ break;
+ case BasisRuntimeSpawnRegistry.RegistryChangeType.ClearedAll:
+ for (int index = Bindings.Count - 1; index >= 0; index--)
+ {
+ RemoveAt(index);
+ }
+ break;
+ }
+ }
+ }
+}
diff --git a/Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerObjectManager.cs.meta b/Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerObjectManager.cs.meta
new file mode 100644
index 0000000000..e1ae0497e6
--- /dev/null
+++ b/Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerObjectManager.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 9e1b6649bd24ed042bd5ca4b44a671cf
\ No newline at end of file
diff --git a/Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerObjects.asmdef b/Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerObjects.asmdef
new file mode 100644
index 0000000000..65d894baa6
--- /dev/null
+++ b/Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerObjects.asmdef
@@ -0,0 +1,19 @@
+{
+ "name": "BasisTrackerObjects",
+ "rootNamespace": "Basis.TrackerObjects",
+ "references": [
+ "Basis Framework",
+ "BasisSDK",
+ "BasisCommon",
+ "BasisDebug"
+ ],
+ "includePlatforms": [],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": false,
+ "precompiledReferences": [],
+ "autoReferenced": true,
+ "defineConstraints": [],
+ "versionDefines": [],
+ "noEngineReferences": false
+}
diff --git a/Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerObjects.asmdef.meta b/Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerObjects.asmdef.meta
new file mode 100644
index 0000000000..e43c139d42
--- /dev/null
+++ b/Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerObjects.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 2be0cf59cd21f8b4bb8bdcf097744459
+AssemblyDefinitionImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Basis/Packages/com.basis.trackerobjects/package.json b/Basis/Packages/com.basis.trackerobjects/package.json
new file mode 100644
index 0000000000..fba46259c1
--- /dev/null
+++ b/Basis/Packages/com.basis.trackerobjects/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "com.basis.trackerobjects",
+ "displayName": "Basis Tracker Objects",
+ "version": "0.0.1",
+ "description": "Bind a SteamVR/OpenXR tracker to a GameObject so its pose drives the transform locally and syncs to remote players over a custom network behaviour. Selection UI + persistence + per-tick pose drive + sync.",
+ "unity": "6000.0",
+ "author": {
+ "name": "BasisVR",
+ "url": "https://github.com/BasisVR"
+ },
+ "license": "MIT"
+}
diff --git a/Basis/Packages/com.basis.trackerobjects/package.json.meta b/Basis/Packages/com.basis.trackerobjects/package.json.meta
new file mode 100644
index 0000000000..55412da0c6
--- /dev/null
+++ b/Basis/Packages/com.basis.trackerobjects/package.json.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: a45415a816141ff4096977b85f13038c
+PackageManifestImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant: