From 34b4acaf3d7c48c0e1fbb7f12f72be5d384db078 Mon Sep 17 00:00:00 2001 From: mriise Date: Fri, 12 Dec 2025 02:02:26 -0800 Subject: [PATCH 1/2] feat: Add Avatar State & XProp addressing - add Avatar State as a new package - introduce XProp addressing --- .../com.basis.avatar-state/CHANGELOG.md | 14 + .../com.basis.avatar-state/CHANGELOG.md.meta | 7 + .../com.basis.avatar-state/Documentation.meta | 8 + .../Documentation/avatar-state.md | 169 ++++ .../Documentation/avatar-state.md.meta | 7 + .../Packages/com.basis.avatar-state/README.md | 6 + .../com.basis.avatar-state/README.md.meta | 7 + .../com.basis.avatar-state/Runtime.meta | 8 + .../Runtime/Basis.AvatarState.asmdef | 6 + .../Runtime/Basis.AvatarState.asmdef.meta | 7 + .../com.basis.avatar-state/Runtime/XProp.meta | 8 + .../Runtime/XProp/XPropFacetValidators.cs | 581 ++++++++++++ .../XProp/XPropFacetValidators.cs.meta | 2 + .../Runtime/XProp/XPropParser.cs | 851 ++++++++++++++++++ .../Runtime/XProp/XPropParser.cs.meta | 2 + .../Runtime/XProp/XPropPropertyMapping.cs | 183 ++++ .../XProp/XPropPropertyMapping.cs.meta | 2 + .../Runtime/XProp/XPropRef.cs | 301 +++++++ .../Runtime/XProp/XPropRef.cs.meta | 2 + .../Runtime/XProp/XPropSpec.md | 797 ++++++++++++++++ .../Runtime/XProp/XPropSpec.md.meta | 7 + .../com.basis.avatar-state/Tests.meta | 8 + .../com.basis.avatar-state/Tests/Runtime.meta | 8 + .../Runtime/Basis.AvatarState.Tests.asmdef | 11 + .../Basis.AvatarState.Tests.asmdef.meta | 7 + .../com.basis.avatar-state/package.json | 19 + .../com.basis.avatar-state/package.json.meta | 7 + 27 files changed, 3035 insertions(+) create mode 100644 Basis/Packages/com.basis.avatar-state/CHANGELOG.md create mode 100644 Basis/Packages/com.basis.avatar-state/CHANGELOG.md.meta create mode 100644 Basis/Packages/com.basis.avatar-state/Documentation.meta create mode 100644 Basis/Packages/com.basis.avatar-state/Documentation/avatar-state.md create mode 100644 Basis/Packages/com.basis.avatar-state/Documentation/avatar-state.md.meta create mode 100644 Basis/Packages/com.basis.avatar-state/README.md create mode 100644 Basis/Packages/com.basis.avatar-state/README.md.meta create mode 100644 Basis/Packages/com.basis.avatar-state/Runtime.meta create mode 100644 Basis/Packages/com.basis.avatar-state/Runtime/Basis.AvatarState.asmdef create mode 100644 Basis/Packages/com.basis.avatar-state/Runtime/Basis.AvatarState.asmdef.meta create mode 100644 Basis/Packages/com.basis.avatar-state/Runtime/XProp.meta create mode 100644 Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropFacetValidators.cs create mode 100644 Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropFacetValidators.cs.meta create mode 100644 Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropParser.cs create mode 100644 Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropParser.cs.meta create mode 100644 Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropPropertyMapping.cs create mode 100644 Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropPropertyMapping.cs.meta create mode 100644 Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropRef.cs create mode 100644 Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropRef.cs.meta create mode 100644 Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropSpec.md create mode 100644 Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropSpec.md.meta create mode 100644 Basis/Packages/com.basis.avatar-state/Tests.meta create mode 100644 Basis/Packages/com.basis.avatar-state/Tests/Runtime.meta create mode 100644 Basis/Packages/com.basis.avatar-state/Tests/Runtime/Basis.AvatarState.Tests.asmdef create mode 100644 Basis/Packages/com.basis.avatar-state/Tests/Runtime/Basis.AvatarState.Tests.asmdef.meta create mode 100644 Basis/Packages/com.basis.avatar-state/package.json create mode 100644 Basis/Packages/com.basis.avatar-state/package.json.meta diff --git a/Basis/Packages/com.basis.avatar-state/CHANGELOG.md b/Basis/Packages/com.basis.avatar-state/CHANGELOG.md new file mode 100644 index 000000000..bb4f89aae --- /dev/null +++ b/Basis/Packages/com.basis.avatar-state/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +All notable changes to this package will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) +and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). + +## Unreleased + +Introduce XProp for parameter addressing. + +## [0.1.0] - 2025-12-12 + +### This is the first release of *\*. diff --git a/Basis/Packages/com.basis.avatar-state/CHANGELOG.md.meta b/Basis/Packages/com.basis.avatar-state/CHANGELOG.md.meta new file mode 100644 index 000000000..3695d9479 --- /dev/null +++ b/Basis/Packages/com.basis.avatar-state/CHANGELOG.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: ce77c64d97ec075448fe84a9a46235df +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Basis/Packages/com.basis.avatar-state/Documentation.meta b/Basis/Packages/com.basis.avatar-state/Documentation.meta new file mode 100644 index 000000000..86ac6e3a6 --- /dev/null +++ b/Basis/Packages/com.basis.avatar-state/Documentation.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2987ce8886962e741b83e1f73c440179 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Basis/Packages/com.basis.avatar-state/Documentation/avatar-state.md b/Basis/Packages/com.basis.avatar-state/Documentation/avatar-state.md new file mode 100644 index 000000000..6f65ddd11 --- /dev/null +++ b/Basis/Packages/com.basis.avatar-state/Documentation/avatar-state.md @@ -0,0 +1,169 @@ +>>> +**_Package Documentation Template_** + +Use this template to create preliminary, high-level documentation meant to introduce users to the feature and the sample files included in this package. When writing your documentation, do the following: + +1. Follow instructions in blockquotes. + +2. Replace angle brackets with the appropriate text. For example, replace "<package name>" with the official name of the package. + +3. Delete sections that do not apply to your package. For example, a package containing only sample files does not have a "Using <package_name>" section, so this section can be removed. + +4. After documentation is completed, make sure you delete all instructions and examples in blockquotes including this preamble and its title: + + ``` + >>> + Delete all of the text between pairs of blockquote markdown. + >>> + ``` +>>> + +# About <package name> + +>>> +Name the heading of the first topic after the **displayName** of the package as it appears in the package manifest. + +This first topic includes a brief, high-level explanation of the package and, if applicable, provides links to Unity Manual topics. + +There are two types of packages: + + - Packages that include features that augment the Unity Editor or Runtime. + - Packages that include sample files. + +Choose one of the following introductory paragraphs that best fits the package: +>>> + +Use the <package name> package to <list of the main uses for the package>. For example, use <package name> to create/generate/extend/capture <mention major use case, or a good example of what the package can be used for>. The <package name> package also includes <other relevant features or uses>. + +> *or* + +The <package name> package includes examples of <name of asset type, model, prefabs, and/or other GameObjects in the package>. For more information, see <xref to topic in the Unity Manual>. + +>>> +**_Examples:_** + +Here are some examples for reference only. Do not include these in the final documentation file: + +*Use the Unity Recorder package to capture and save in-game data. For example, use Unity Recorder to record an mp4 file during a game session. The Unity Recorder package also includes an interface for setting-up and triggering recording sessions.* + +*The Timeline Examples package includes examples of Timeline assets, Timeline Instances, animation, GameObjects, and scripts that illustrate how to use Unity's Timeline. For more information, see [ Unity's Timeline](https://docs.unity3d.com/Manual/TimelineSection.html) in the [Unity Manual](https://docs.unity3d.com). For licensing and usage, see Package Licensing.* +>>> + +# Installing <package name> +>>> +Begin this section with a cross-reference to the official Unity Manual topic on how to install packages. If the package requires special installation instructions, include these steps in this section. +>>> + +To install this package, follow the instructions in the [Package Manager documentation](https://docs.unity3d.com/Packages/com.unity.package-manager-ui@latest/index.html). + +>>> +For some packages, there may be additional steps to complete the setup. You can add those here. +>>> + +In addition, you need to install the following resources: + + - <name of resource>: To install, open *Window > <name of menu item>*. The resource appears <at this location>. + - <name of sample>: To install, open *Window > <name of menu item>*. The new sample folder appears <at this location>. + + + +# Using <package name> +>>> +The contents of this section depends on the type of package. + +For packages that augment the Unity Editor with additional features, this section should include workflow and/or reference documentation: + +* At a minimum, this section should include reference documentation that describes the windows, editors, and properties that the package adds to Unity. This reference documentation should include screen grabs (see how to add screens below), a list of settings, an explanation of what each setting does, and the default values of each setting. +* Ideally, this section should also include a workflow: a list of steps that the user can easily follow that demonstrates how to use the feature. This list of steps should include screen grabs (see how to add screens below) to better describe how to use the feature. + +For packages that include sample files, this section may include detailed information on how the user can use these sample files in their projects and scenes. However, workflow diagrams or illustrations could be included if deemed appropriate. + +## How to add images + +*(This section is for reference. Do not include in the final documentation file)* + +If the [Using <package name>](#UsingPackageName) section includes screen grabs or diagrams, a link to the image must be added to this MD file, before or after the paragraph with the instruction or description that references the image. In addition, a caption should be added to the image link that includes the name of the screen or diagram. All images must be PNG files with underscores for spaces. No animated GIFs. + +An example is included below: + +![A cinematic in the Timeline Editor window.](images/example.png) + +Notice that the example screen shot is included in the images folder. All screen grabs and/or diagrams must be added and referenced from the images folder. + +For more on the Unity documentation standards for creating and adding screen grabs, see this confluence page: https://confluence.hq.unity3d.com/pages/viewpage.action?pageId=13500715 +>>> + + + +# Technical details +## Requirements +>>> +This subtopic includes a bullet list with the compatible versions of Unity. This subtopic may also include additional requirements or recommendations for 3rd party software or hardware. An example includes a dependency on other packages. If you need to include references to non-Unity products, make sure you refer to these products correctly and that all references include the proper trademarks (tm or r) +>>> + +This version of <package name> is compatible with the following versions of the Unity Editor: + +* 2018.1 and later (recommended) + +To use this package, you must have the following 3rd party products: + +* <product name and version with trademark or registered trademark.> +* <product name and version with trademark or registered trademark.> +* <product name and version with trademark or registered trademark.> + +## Known limitations +>>> +This section lists the known limitations with this version of the package. If there are no known limitations, or if the limitations are trivial, exclude this section. An example is provided. +>>> + +<package name> version <package version> includes the following known limitations: + +* <brief one-line description of first limitation.> +* <brief one-line description of second limitation.> +* <and so on> + +>>> +*Example (For reference. Do not include in the final documentation file):* + +The Unity Recorder version 1.0 has the following limitations:* + +* The Unity Recorder does not support sound. +* The Recorder window and Recorder properties are not available in standalone players. +* MP4 encoding is only available on Windows. +>>> + +## Package contents +>>> +This section includes the location of important files you want the user to know about. For example, if this is a sample package containing textures, models, and materials separated by sample group, you may want to provide the folder location of each group. +>>> + +The following table indicates the <describe the breakdown you used here>: + +|Location|Description| +|---|---| +|``|Contains <describe what the folder contains>.| +|``|Contains <describe what the file represents or implements>.| + +>>> +*Example (For reference. Do not include in the final documentation file):* + +The following table indicates the root folder of each type of sample in this package. Each sample's root folder contains its own Materials, Models, or Textures folders: + +|Folder Location|Description| +|---|---| +|`WoodenCrate_Orange`|Root folder containing the assets for the orange crates.| +|`WoodenCrate_Mahogany`|Root folder containing the assets for the mahogany crates.| +|`WoodenCrate_Shared`|Root folder containing any material assets shared by all crates.| +>>> + +## Document revision history +>>> +This section includes the revision history of the document. The revision history tracks when a document is created, edited, and updated. If you create or update a document, you must add a new row describing the revision. The Documentation Team also uses this table to track when a document is edited and its editing level. An example is provided: + +|Date|Reason| +|---|---| +|Sept 12, 2017|Unedited. Published to package.| +|Sept 10, 2017|Document updated for package version 1.1.
New features:
  • audio support for capturing MP4s.
  • Instructions on saving Recorder prefabs| +|Sept 5, 2017|Limited edit by Documentation Team. Published to package.| +|Aug 25, 2017|Document created. Matches package version 1.0.| +>>> \ No newline at end of file diff --git a/Basis/Packages/com.basis.avatar-state/Documentation/avatar-state.md.meta b/Basis/Packages/com.basis.avatar-state/Documentation/avatar-state.md.meta new file mode 100644 index 000000000..612de87ea --- /dev/null +++ b/Basis/Packages/com.basis.avatar-state/Documentation/avatar-state.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 3ef59c5cc29df4c4d941ada3c1136d51 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Basis/Packages/com.basis.avatar-state/README.md b/Basis/Packages/com.basis.avatar-state/README.md new file mode 100644 index 000000000..0a5eff2e6 --- /dev/null +++ b/Basis/Packages/com.basis.avatar-state/README.md @@ -0,0 +1,6 @@ + +# Basis Avatar State + +## !!Experimental!! + +Library and tooling to transmit, store, apply changes to an avatar. diff --git a/Basis/Packages/com.basis.avatar-state/README.md.meta b/Basis/Packages/com.basis.avatar-state/README.md.meta new file mode 100644 index 000000000..34ee19e81 --- /dev/null +++ b/Basis/Packages/com.basis.avatar-state/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a1195bb0aef91af438402c74988768bd +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Basis/Packages/com.basis.avatar-state/Runtime.meta b/Basis/Packages/com.basis.avatar-state/Runtime.meta new file mode 100644 index 000000000..a334e2ab9 --- /dev/null +++ b/Basis/Packages/com.basis.avatar-state/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ed2f62d379359a841941882368d3bfa4 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Basis/Packages/com.basis.avatar-state/Runtime/Basis.AvatarState.asmdef b/Basis/Packages/com.basis.avatar-state/Runtime/Basis.AvatarState.asmdef new file mode 100644 index 000000000..3dc8cf46c --- /dev/null +++ b/Basis/Packages/com.basis.avatar-state/Runtime/Basis.AvatarState.asmdef @@ -0,0 +1,6 @@ +{ + "name": "Basis.AvatarState", + "references": [], + "includePlatforms": [], + "excludePlatforms": [] +} diff --git a/Basis/Packages/com.basis.avatar-state/Runtime/Basis.AvatarState.asmdef.meta b/Basis/Packages/com.basis.avatar-state/Runtime/Basis.AvatarState.asmdef.meta new file mode 100644 index 000000000..9367d212b --- /dev/null +++ b/Basis/Packages/com.basis.avatar-state/Runtime/Basis.AvatarState.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 8a96821cbe51a984d80a5c85922fc59f +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Basis/Packages/com.basis.avatar-state/Runtime/XProp.meta b/Basis/Packages/com.basis.avatar-state/Runtime/XProp.meta new file mode 100644 index 000000000..340d1223d --- /dev/null +++ b/Basis/Packages/com.basis.avatar-state/Runtime/XProp.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 13e04801fc76c5c47aae492dd954be91 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropFacetValidators.cs b/Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropFacetValidators.cs new file mode 100644 index 000000000..fb007d559 --- /dev/null +++ b/Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropFacetValidators.cs @@ -0,0 +1,581 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +namespace Jinxxy.Item.XProp +{ + /// + /// Provides default validators for the reserved XProp facets defined in the v0.1.0 specification + /// + public static class XPropFacetValidators + { + + /// + /// Defines a material property with its shader name and valid type hints + /// + private struct XPropMatProperty + { + public string InterfaceName { get; set; } + public string EngineName { get; set; } + public string[] ValidTypeHints { get; set; } + } + + public struct XPropProperty + { + public string Name; + public string[] ValidTypeHints; + public bool AllowAccessors; + + + public XPropProperty(string name, string[] validTypeHints, bool allowAccessors = true) + { + Name = name; + ValidTypeHints = validTypeHints; + AllowAccessors = allowAccessors; + } + + public XPropProperty(string name) : this(name, Array.Empty(), true) { } + public XPropProperty(string name, string validType) : this(name, new[] { validType }, true) { } + + } + + /// + /// Defines which component accessors are valid for each type + /// + private static readonly Dictionary TypeAccessors = new() + { + { "color", new[] { "r", "g", "b", "a" } }, + { "float2", new[] { "x", "y" } }, + { "float3", new[] { "x", "y", "z", "r", "g", "b" } }, // Allow both xyz and rgb for compatibility + { "float4", new[] { "x", "y", "z", "w", "r", "g", "b", "a" } }, + { "quat", new[] { "x", "y", "z", "w" } } + }; + + // Valid property names for each reserved facet + private static readonly XPropProperty[] XformProperties = new XPropProperty[] + { + new("position","float3"), + new("rotation", "quat"), + new("rotation_euler", "float3"), + new("scale", "float3"), + new("scale_uniform", "float"), + new("world_position", "float3"), + new("world_rotation", "quat") + }; + + private static readonly XPropProperty[] RenderProperties = new XPropProperty[] + { + new("visible", "bool"), + }; + // 1:1 mapping: InterfaceName -> UnityShaderName + private static readonly Dictionary MatInterfaceToShaderName = new() + { + { "unity_standard", "Standard" }, + { "unity_unlit", "Unlit" }, + { "urp_lit", "Universal Render Pipeline/Lit" }, + { "urp_unlit", "Universal Render Pipeline/Unlit" } + }; + + // ShaderInterface -> Properties with type information + private static readonly Dictionary MatShaderInterfacesTyped = + new() + { + { + "unity_standard", new XPropMatProperty[] + { + new() { InterfaceName = "color", EngineName = "_Color", ValidTypeHints = new[] { "rgba", "float4" } }, + new() { InterfaceName = "emission", EngineName = "_EmissionColor", ValidTypeHints = new[] { "float3", "rgb" } }, + new() { InterfaceName = "emissionIntensity", EngineName = "_EmissionIntensity", ValidTypeHints = new[] { "float" } }, + new() { InterfaceName = "metallic", EngineName = "_Metallic", ValidTypeHints = new[] { "float" } }, + new() { InterfaceName = "roughness", EngineName = "_Roughness", ValidTypeHints = new[] { "float" } }, + new() { InterfaceName = "smoothness", EngineName = "_Smoothness", ValidTypeHints = new[] { "float" } }, + new() { InterfaceName = "opacity", EngineName = "_Alpha", ValidTypeHints = new[] { "float" } }, + new() { InterfaceName = "cutoff", EngineName = "_Cutoff", ValidTypeHints = new[] { "float" } }, + new() { InterfaceName = "tiling", EngineName = "_MainTex_ST", ValidTypeHints = new[] { "float2" } }, + new() { InterfaceName = "offset", EngineName = "_MainTex_ST", ValidTypeHints = new[] { "float2" } } + } + }, + { + "unity_unlit", new XPropMatProperty[] + { + new() { InterfaceName = "color", EngineName = "_Color", ValidTypeHints = new[] { "rgba", "float4" } }, + new() { InterfaceName = "emission", EngineName = "_EmissionColor", ValidTypeHints = new[] { "float3", "rgb" } }, + new() { InterfaceName = "tiling", EngineName = "_MainTex_ST", ValidTypeHints = new[] { "float2" } }, + new() { InterfaceName = "offset", EngineName = "_MainTex_ST", ValidTypeHints = new[] { "float2" } } + } + }, + { + "urp_lit", new XPropMatProperty[] + { + new() { InterfaceName = "color", EngineName = "_BaseColor", ValidTypeHints = new[] { "rgba", "float4" } }, + new() { InterfaceName = "emission", EngineName = "_EmissionColor", ValidTypeHints = new[] { "float3", "rgb" } }, + new() { InterfaceName = "emissionIntensity", EngineName = "_EmissionIntensity", ValidTypeHints = new[] { "float" } }, + new() { InterfaceName = "metallic", EngineName = "_Metallic", ValidTypeHints = new[] { "float" } }, + new() { InterfaceName = "smoothness", EngineName = "_Smoothness", ValidTypeHints = new[] { "float" } }, + new() { InterfaceName = "opacity", EngineName = "_BaseColor", ValidTypeHints = new[] { "float" } }, // Alpha channel + new() { InterfaceName = "cutoff", EngineName = "_Cutoff", ValidTypeHints = new[] { "float" } }, + new() { InterfaceName = "tiling", EngineName = "_BaseMap_ST", ValidTypeHints = new[] { "float2" } }, + new() { InterfaceName = "offset", EngineName = "_BaseMap_ST", ValidTypeHints = new[] { "float2" } } + } + }, + { + "urp_unlit", new XPropMatProperty[] + { + new() { InterfaceName = "color", EngineName = "_BaseColor", ValidTypeHints = new[] { "rgba", "float4" } }, + new() { InterfaceName = "tiling", EngineName = "_BaseMap_ST", ValidTypeHints = new[] { "float2" } }, + new() { InterfaceName = "offset", EngineName = "_BaseMap_ST", ValidTypeHints = new[] { "float2" } } + } + } + }; + + // ShaderInterface -> StandardProperty -> ActualPropertyName + private static readonly Dictionary> MatShaderInterfaces = new(); + + // Pre-computed flat set of all standard material properties (for fast validation) + private static readonly HashSet MatStandardProperties; + + // Pre-computed interface -> property set mapping (for fast containment checks) + private static readonly Dictionary> MatInterfacePropertySets; + + // Type hints + private static readonly string[] TypeFloat = { "float" }; + private static readonly string[] TypeFloat3 = { "float3" }; + private static readonly string[] TypeQuat = { "quat" }; + private static readonly string[] TypeBool = { "bool" }; + + private static readonly Dictionary XformTypeHints = new() + { + { "position", TypeFloat3 }, { "position.", TypeFloat }, + { "rotation", TypeQuat }, { "rotation.", TypeFloat }, + { "rotation_euler", TypeFloat3 }, { "rotation_euler.", TypeFloat }, + { "scale", TypeFloat3 }, { "scale.", TypeFloat }, + { "scale_uniform", TypeFloat }, + { "world_position", TypeFloat3 }, { "world_position.", TypeFloat }, + { "world_rotation", TypeQuat }, { "world_rotation.", TypeFloat }, + }; + + private static readonly Dictionary RenderTypeHints = new() + { + { "visible", TypeBool }, + }; + + // Auto-generated type hints from MatShaderInterfacesTyped (populated in static constructor) + private static readonly Dictionary MatTypeHints; + + /// + /// Static constructor to pre-compute optimized lookup tables from typed material property definitions + /// + static XPropFacetValidators() + { + MatStandardProperties = new HashSet(); + MatInterfacePropertySets = new Dictionary>(); + MatTypeHints = new Dictionary(); + + foreach (var (interfaceName, properties) in MatShaderInterfacesTyped) + { + var propertyMap = new Dictionary(); + var propertySet = new HashSet(); + + foreach (var prop in properties) + { + var name = prop.InterfaceName; + propertyMap[name] = prop.EngineName; + propertySet.Add(name); + MatStandardProperties.Add(name); + + // Store type hints directly (no HashSet overhead) + if (!MatTypeHints.ContainsKey(name)) + MatTypeHints[name] = prop.ValidTypeHints; + + // Generate component accessors from primary type + if (TypeAccessors.TryGetValue(prop.ValidTypeHints[0], out var accessors)) + { + foreach (var accessor in accessors) + { + var fullPath = name + "." + accessor; + MatStandardProperties.Add(fullPath); + propertySet.Add(fullPath); + if (!MatTypeHints.ContainsKey(fullPath)) + MatTypeHints[fullPath] = TypeFloat; + } + + var pattern = name + "."; + if (!MatTypeHints.ContainsKey(pattern)) + MatTypeHints[pattern] = TypeFloat; + } + } + + MatShaderInterfaces[interfaceName] = propertyMap; + MatInterfacePropertySets[interfaceName] = propertySet; + } + } + + /// + /// Validator for the 'xform' facet (transform/spatial properties) + /// + public static bool ValidateXform(string facet, string[] qualifier, string typeHint, string propertyPath) + => ValidateNoQualifierFacet(qualifier, propertyPath, typeHint, XformProperties, XformTypeHints); + + /// + /// Validator for the 'mat' facet (material/surface properties) + /// - Qualifier: (slot) or (slot,...) where slot is 0-based integer + /// - Properties: color, emission, metallic, roughness, opacity, tiling, offset, etc. + /// - Type hints: color, float3, float2, float + /// + public static bool ValidateMat(string facet, string[] qualifier, string typeHint, string propertyPath) + { + // mat must have at least one qualifier (material slot index) + if (qualifier == null || qualifier.Length == 0) + { + return false; + } + + // First qualifier must be a valid non-negative integer (slot index) + if (!int.TryParse(qualifier[0], out int slot) || slot < 0) + { + return false; + } + + string shaderInterface = qualifier.Length >= 2 ? qualifier[1] : null; + + // Additional qualifier parameters are application-defined (shader hints, etc.) + // We don't validate them here + + // Fast standard property check using optimized lookup + bool isStandardProperty = IsValidMatProperty(shaderInterface, propertyPath); + + // If it's a standard property, validate the type hint + if (isStandardProperty && !string.IsNullOrEmpty(typeHint)) + { + return ValidateTypeHintForProperty(propertyPath, typeHint, MatTypeHints); + } + + // Allow shader-specific properties with any type hint + // This supports "Applications MAY support shader-specific properties via native names" + return true; + } + + /// + /// Validator for the 'script' facet (component/behavior properties) + /// - Qualifier: (Type) or (Type,index) where Type is component name, index is optional integer + /// - Properties: any (arbitrary nested with array indices) + /// - Type hints: any + /// + public static bool ValidateScript(string facet, string[] qualifier, string typeHint, string propertyPath) + { + // script must have at least one qualifier (component type name) + if (qualifier == null || qualifier.Length == 0) + { + return false; + } + + // First qualifier is the component type name - must not be empty + if (string.IsNullOrWhiteSpace(qualifier[0])) + { + return false; + } + + // Second qualifier (if present) must be a valid non-negative integer (instance index) + if (qualifier.Length >= 2) + { + if (!int.TryParse(qualifier[1], out int index) || index < 0) + { + return false; + } + } + + // More than 2 qualifiers is invalid for script facet + if (qualifier.Length > 2) + { + return false; + } + + // Property path can be arbitrary for script facet + // Type hint can be any valid type + // No further validation needed - applications define their own properties + + return true; + } + + /// + /// Validator for the 'render' facet (visibility and render state) + /// + public static bool ValidateRender(string facet, string[] qualifier, string typeHint, string propertyPath) + => ValidateNoQualifierFacet(qualifier, propertyPath, typeHint, RenderProperties, RenderTypeHints); + + /// + /// Common validation for facets that don't allow qualifiers + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool ValidateNoQualifierFacet(string[] qualifier, string propertyPath, string typeHint, + XPropProperty[] allowedProperties, Dictionary typeHints) + { + if (qualifier is { Length: > 0 }) return false; + if (!ValidatePropertyInSet(propertyPath, allowedProperties)) return false; + return string.IsNullOrEmpty(typeHint) || ValidateTypeHintForProperty(propertyPath, typeHint, typeHints); + } + + /// + /// Creates a FacetValidationOptions instance with all reserved facet validators + /// + public static FacetValidationOptions CreateReservedFacetValidators() + { + var options = new FacetValidationOptions + { + AllowUnknownFacets = true // Allow extension facets by default + }; + + options.AddFacet("xform", ValidateXform); + options.AddFacet("mat", ValidateMat); + options.AddFacet("script", ValidateScript); + options.AddFacet("render", ValidateRender); + + return options; + } + + /// + /// Creates a FacetValidationOptions instance with only reserved facets (no extensions) + /// + public static FacetValidationOptions CreateStrictReservedFacetValidators() + { + var options = new FacetValidationOptions + { + AllowUnknownFacets = false // Only allow reserved facets + }; + + options.AddFacet("xform", ValidateXform); + options.AddFacet("mat", ValidateMat); + options.AddFacet("script", ValidateScript); + options.AddFacet("render", ValidateRender); + + return options; + } + + /// + /// Fast lookup to check if a property is valid for a specific material shader interface + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsValidMatProperty(string shaderInterface, string propertyPath) + { + // If no interface specified, check against all standard properties + if (string.IsNullOrEmpty(shaderInterface)) + { + return MatStandardProperties.Contains(propertyPath); + } + + // Check specific interface + if (MatInterfacePropertySets.TryGetValue(shaderInterface, out var props)) + { + // Handle component accessors (e.g., "color.r") + if (props.Contains(propertyPath)) + return true; + + int dotIdx = propertyPath.IndexOf('.'); + if (dotIdx > 0) + { + string baseProp = propertyPath.Substring(0, dotIdx); + return props.Contains(baseProp); + } + } + + return false; + } + + /// + /// Fast lookup to get actual shader property name for a standard property + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryGetActualPropertyName(string shaderInterface, string shaderName, out string actualName) + { + actualName = null; + + if (string.IsNullOrEmpty(shaderInterface)) + { + // No interface specified - try first interface that has this property + foreach (var kvp in MatShaderInterfaces) + { + if (kvp.Value.TryGetValue(shaderName, out actualName)) + return true; + } + return false; + } + + // Specific interface + if (MatShaderInterfaces.TryGetValue(shaderInterface, out var props)) + { + return props.TryGetValue(shaderName, out actualName); + } + + return false; + } + + /// + /// Helper method to validate a property path against a set of allowed properties. + /// Supports multi-level accessors (e.g., "position.x", "rotation.euler.y") and respects + /// the AllowAccessors flag on each property. + /// + private static bool ValidatePropertyInSet(string propertyPath, XPropProperty[] allowedProperties) + { + if (string.IsNullOrEmpty(propertyPath)) + { + return false; + } + + // Try exact match first (most common case) + for (int i = 0; i < allowedProperties.Length; i++) + { + if (allowedProperties[i].Name == propertyPath) + { + return true; + } + } + + // No dots = not an accessor pattern + int firstDotIndex = propertyPath.IndexOf('.'); + if (firstDotIndex <= 0) + { + return false; + } + + // Check all possible prefixes from longest to shortest + // e.g., for "rotation.euler.x", check: "rotation.euler", then "rotation" + int dotIndex = propertyPath.LastIndexOf('.'); + int depth = 0; + while (dotIndex > 0 && depth < XPropParser.MAX_PROPERTY_DEPTH) + { + string basePath = propertyPath[..dotIndex]; + + // Check if this base path matches an allowed property with accessors enabled + for (int i = 0; i < allowedProperties.Length; i++) + { + if (allowedProperties[i].Name == basePath && allowedProperties[i].AllowAccessors) + { + return true; + } + } + + // Move to next shorter prefix + dotIndex = propertyPath.LastIndexOf('.', dotIndex - 1); + depth++; + } + + return false; + } + + /// + /// Helper method to validate type hint for a given property + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool ValidateTypeHintForProperty(string propertyPath, string typeHint, Dictionary typeHintMap) + { + if (string.IsNullOrEmpty(typeHint)) return true; + + // Try exact match first + if (typeHintMap.TryGetValue(propertyPath, out var allowed) && ArrayContains(allowed, typeHint)) + return true; + + // Try pattern match for component accessors (e.g., "position.x" -> "position.") + int lastDot = propertyPath.LastIndexOf('.'); + if (lastDot > 0) + { + if (typeHintMap.TryGetValue(propertyPath[..(lastDot + 1)], out allowed) && ArrayContains(allowed, typeHint)) + return true; + if (typeHintMap.TryGetValue(propertyPath[..lastDot], out allowed) && ArrayContains(allowed, typeHint)) + return true; + } + + return false; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool ArrayContains(string[] arr, string value) + { + for (int i = 0; i < arr.Length; i++) + if (arr[i] == value) return true; + return false; + } + + /// + /// Gets a validator function for a specific reserved facet by name + /// + public static FacetValidator GetValidatorForFacet(string facetName) + { + return facetName switch + { + "xform" => ValidateXform, + "mat" => ValidateMat, + "script" => ValidateScript, + "render" => ValidateRender, + _ => null + }; + } + + /// + /// Checks if a facet name is a reserved facet + /// + public static bool IsReservedFacet(string facetName) + { + return facetName == "xform" || facetName == "mat" || + facetName == "script" || facetName == "render"; + } + + /// + /// Gets the Unity shader name for a given material interface name + /// + /// The interface name (e.g., "standard", "urp") + /// The Unity shader name (e.g., "Standard", "Universal Render Pipeline/Lit") + /// True if the interface name is known + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryGetShaderName(string interfaceName, out string shaderName) + { + return MatInterfaceToShaderName.TryGetValue(interfaceName, out shaderName); + } + + /// + /// Gets all registered material interface names + /// + public static IEnumerable GetRegisteredMaterialInterfaces() + { + return MatInterfaceToShaderName.Keys; + } + + /// + /// Checks if a material interface name is registered + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsRegisteredMaterialInterface(string interfaceName) + { + return MatInterfaceToShaderName.ContainsKey(interfaceName); + } + + /// + /// Gets valid type hints for a material property. Returns null if property is unknown. + /// + /// The property path (e.g., "color", "color.r", "tiling") + /// Array of valid type hint strings, or null if property is not recognized + public static string[] GetValidTypeHintsForMatProperty(string propertyPath) + { + if (string.IsNullOrEmpty(propertyPath)) return null; + + if (MatTypeHints.TryGetValue(propertyPath, out var hints)) + return hints; + + int lastDot = propertyPath.LastIndexOf('.'); + if (lastDot > 0 && MatTypeHints.TryGetValue(propertyPath[..(lastDot + 1)], out hints)) + return hints; + + return null; + } + + /// + /// Gets all standard material properties with their valid type hints + /// + public static Dictionary GetAllMatPropertyTypeHints() + { + var result = new Dictionary(); + foreach (var (key, value) in MatTypeHints) + if (!key.EndsWith('.')) result[key] = value; + return result; + } + } +} diff --git a/Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropFacetValidators.cs.meta b/Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropFacetValidators.cs.meta new file mode 100644 index 000000000..ceb6c41b0 --- /dev/null +++ b/Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropFacetValidators.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f338d5351600f0545970ece295545a2f \ No newline at end of file diff --git a/Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropParser.cs b/Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropParser.cs new file mode 100644 index 000000000..ece36f513 --- /dev/null +++ b/Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropParser.cs @@ -0,0 +1,851 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.RegularExpressions; + +namespace Jinxxy.Item.XProp +{ + public enum XPropErrorCode + { + None, + + // Parsing errors + ParseError, // General syntax error + LimitExceeded, // Exceeded length/depth/count limits + + // Validation errors (used during facet validation) + FacetUnknown, // Facet not in allowed list + QualifierInvalid, // Qualifier failed validation + + // Runtime errors (for use by implementations, not parser) + NodeNotFound, // Referenced node doesn't exist + PropertyNotFound, // Referenced property doesn't exist + TypeMismatch, // Value type doesn't match expected type + } + + public class XPropException : Exception + { + public XPropErrorCode ErrorCode { get; } + + public XPropException(XPropErrorCode code, string message) + : base(message) + { + ErrorCode = code; + } + } + + /// + /// Delegate for validating facets during parsing + /// + /// The facet name to validate + /// The qualifier parameters (if any) + /// The type hint (if any) + /// True if the facet is valid, false otherwise + public delegate bool FacetValidator(string facet, string[] qualifier, string typeHint, string propertyPath); + + /// + /// Options for facet validation + /// + public class FacetValidationOptions + { + /// + /// Per-facet validators. Key is facet name, value is validator function. + /// + public Dictionary FacetValidators = new(); + + /// + /// If true, unknown facets will be allowed even if AllowedFacets is set + /// + public bool AllowUnknownFacets = true; + + /// + /// Default validation options - no restrictions, syntax-only validation + /// + public static FacetValidationOptions Default = new(); + + private static readonly FacetValidator _alwaysAllow = static (_, _, _, _) => true; + /// + /// Adds a facet with its validator + /// + public void AddFacet(string facet, FacetValidator validator = null) + { + if (string.IsNullOrEmpty(facet)) + throw new ArgumentException("Facet cannot be null or empty.", nameof(facet)); + + // Add validator if provided + FacetValidators ??= new Dictionary(); + FacetValidators[facet] = validator ?? _alwaysAllow; + + } + + /// + /// Adds multiple facets without validators + /// + public void AddFacets(params (string, FacetValidator)[] facets) + { + foreach (var (facet, validator) in facets) + AddFacet(facet, validator); + } + + public void RemoveFacet(string facet) + { + FacetValidators?.Remove(facet); + } + } + + /// + /// Parser for XProp v0.1.0 specification + /// + public static class XPropParser + { + private static readonly Regex Tokenizer = new(@"^(?:(?\.\/(?(?:[^:\[\]]+|\[[^\]]*\])*))|(?#\/(?(?:[^:\[\]]+|\[[^\]]*\])*))|(?\/(?(?:[^:\[\]]+|\[[^\]]*\])+)))::(?[a-z0-9_]+)(?:\((?[^)]*)\))?(?:<(?[A-Za-z][A-Za-z0-9_]*)>)?:(?.+)$", + RegexOptions.Compiled); + + // Limits from spec + public const int MAX_LENGTH = 1024; + public const int MAX_PATH_SEGMENTS = 32; + public const int MAX_SEGMENT_LENGTH = 128; + public const int MAX_PROPERTY_DEPTH = 16; + public const int MAX_ARRAY_INDEX = 65535; + public const int MAX_QUALIFIER_PARAMS = 8; + public const int MAX_QUALIFIER_PARAM_LENGTH = 64; + + // Character class checks + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsLowerAlphaNumUnderscore(char c) => (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_'; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsAlphaNumUnderscore(char c) => (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_'; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsAlpha(char c) => (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsAlphaOrUnderscore(char c) => IsAlpha(c) || c == '_'; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsDigit(char c) => c >= '0' && c <= '9'; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsPropertyChar(char c) => IsAlphaNumUnderscore(c) || c == '-'; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool ContainsWhitespace(string s) + { + foreach (char c in s) + { + if (char.IsWhiteSpace(c)) + return true; + } + return false; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool MatchesAll(string s, Func predicate) + { + if (string.IsNullOrEmpty(s)) return false; + foreach (char c in s) if (!predicate(c)) return false; + return true; + } + + // Escape lookup tables: index = char - '0', value = decoded char (or 0 if invalid) + private static readonly char[] PathEscapes = { '~', ']', '[' }; // ~0 ~1 ~2 + private static readonly char[] QualifierEscapes = { ',', '(', ')' }; // ~3 ~4 ~5 + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool TryDecodeEscape(char code, char[] table, int offset, out char decoded) + { + int idx = code - '0' - offset; + if (idx >= 0 && idx < table.Length) + { + decoded = table[idx]; + return true; + } + decoded = default; + return false; + } + + /// + /// Parse an XProp reference string + /// + /// When true, falls back to manual slicing to produce more specific parse errors + public static XPropRef Parse(string input, bool enableDetailedErrors = true) + { + if (string.IsNullOrEmpty(input)) + throw new XPropException(XPropErrorCode.ParseError, "Input cannot be null or empty"); + + if (input.Length > MAX_LENGTH) + throw new XPropException(XPropErrorCode.LimitExceeded, + $"Reference exceeds maximum length of {MAX_LENGTH}"); + + // Quick prefix and delimiter checks to keep error messages consistent + PathType pathType; + int prefixLength; + if (input.StartsWith("./")) + { + pathType = PathType.ContextRelative; + prefixLength = 2; + } + else if (input.StartsWith("#/")) + { + pathType = PathType.ScopeRelative; + prefixLength = 2; + } + else if (input.StartsWith("/")) + { + pathType = PathType.Absolute; + prefixLength = 1; + } + else + { + throw new XPropException(XPropErrorCode.ParseError, + "Invalid path prefix: must start with ./, #/, or /"); + } + + int facetDelimiter = FindFacetDelimiter(input, prefixLength); + if (facetDelimiter == -1) + throw new XPropException(XPropErrorCode.ParseError, "Missing :: delimiter"); + + int propertyDelimiter = FindPropertyDelimiter(input, facetDelimiter + 2); + if (propertyDelimiter == -1) + throw new XPropException(XPropErrorCode.ParseError, "Missing : delimiter"); + + string pathStr; + string facetBlock; + string propertyPath; + + // Regex-based tokenization with optional fallback to manual slicing to preserve detailed errors + var match = Tokenizer.Match(input); + if (match.Success) + { + pathStr = pathType switch + { + PathType.ContextRelative => match.Groups["ctx_path"].Value, + PathType.ScopeRelative => match.Groups["scope_path"].Value, + _ => match.Groups["abs_path"].Value + }; + + facetBlock = match.Groups["facet"].Value; + if (match.Groups["qualifier"].Success) + facetBlock += "(" + match.Groups["qualifier"].Value + ")"; + if (match.Groups["type"].Success) + facetBlock += "<" + match.Groups["type"].Value + ">"; + + propertyPath = match.Groups["property"].Value; + } + else + { + if (!enableDetailedErrors) + throw new XPropException(XPropErrorCode.ParseError, "Invalid XProp format"); + // Fallback: slice using known delimiters to allow Parse* helpers to emit specific errors + pathStr = input.Substring(prefixLength, facetDelimiter - prefixLength); + facetBlock = input.Substring(facetDelimiter + 2, propertyDelimiter - facetDelimiter - 2); + propertyPath = input.Substring(propertyDelimiter + 1); + } + + string[] hierarchy; + if (string.IsNullOrEmpty(pathStr)) + { + if (pathType == PathType.Absolute) + { + throw new XPropException(XPropErrorCode.ParseError, + "Absolute path requires at least one segment"); + } + hierarchy = Array.Empty(); + } + else + { + hierarchy = ParseHierarchy(pathStr); + } + + if (hierarchy.Length > MAX_PATH_SEGMENTS) + throw new XPropException(XPropErrorCode.LimitExceeded, + $"Path exceeds maximum depth of {MAX_PATH_SEGMENTS}"); + + ParseFacetPart(facetBlock, out string facet, out string[] qualifier, out string typeHint); + + if (string.IsNullOrEmpty(propertyPath)) + throw new XPropException(XPropErrorCode.ParseError, "Empty property path"); + + ValidatePropertyPath(propertyPath); + + return new XPropRef(pathType, hierarchy, facet, qualifier, typeHint, propertyPath); + } + + /// + /// Tries to parse an XProp reference, returning false on error + /// + public static bool TryParse(string input, out XPropRef result, out string errorMessage) + { + try + { + result = Parse(input); + errorMessage = null; + return true; + } + catch (XPropException ex) + { + result = default; + errorMessage = ex.Message; + return false; + } + } + + /// + /// Check if a string is a valid XProp reference + /// + public static bool IsValid(string input) + { + return TryParse(input, out _, out _); + } + + /// + /// Parse an XProp reference with facet validation + /// + /// The XProp reference string to parse + /// Options for validating the facet + /// Parsed XPropRef if valid + /// Thrown if parsing fails or facet validation fails + public static XPropRef ParseWithValidation(string input, FacetValidationOptions validationOptions) + { + if (validationOptions == null) + throw new ArgumentNullException(nameof(validationOptions)); + + // Parse normally first + var result = Parse(input); + + // Validate the facet + ValidateFacet(result.Facet, result.Qualifiers, result.TypeHint, result.PropertyPath, validationOptions); + + return result; + } + + /// + /// Tries to parse an XProp reference with facet validation + /// + public static bool TryParseWithValidation(string input, FacetValidationOptions validationOptions, + out XPropRef result, out string errorMessage) + { + try + { + result = ParseWithValidation(input, validationOptions); + errorMessage = null; + return true; + } + catch (XPropException ex) + { + result = default; + errorMessage = ex.Message; + return false; + } + catch (ArgumentNullException ex) + { + result = default; + errorMessage = ex.Message; + return false; + } + } + + /// + /// Parse an XProp reference with a simple set of allowed facets + /// + /// The XProp reference string to parse + /// Array of allowed facet names + /// Parsed XPropRef if valid + public static XPropRef ParseWithAllowedFacets(string input, params string[] allowedFacets) + { + if (allowedFacets == null || allowedFacets.Length == 0) + throw new ArgumentException("Must provide at least one allowed facet", nameof(allowedFacets)); + + var options = new FacetValidationOptions + { + AllowUnknownFacets = false + }; + + foreach (var facet in allowedFacets) + options.AddFacet(facet, null); + + return ParseWithValidation(input, options); + } + + /// + /// Parse an XProp reference with per-facet validators + /// + /// The XProp reference string to parse + /// Dictionary mapping facet names to their validators + /// Parsed XPropRef if valid + public static XPropRef ParseWithValidators(string input, Dictionary facetValidators) + { + if (facetValidators == null) + throw new ArgumentNullException(nameof(facetValidators)); + + var options = new FacetValidationOptions + { + FacetValidators = facetValidators, + AllowUnknownFacets = false + }; + + return ParseWithValidation(input, options); + } + + /// + /// Validates a facet against the provided validation options + /// + /// The facet name + /// The qualifier parameters + /// The type hint + /// The property path + /// Validation options + /// Thrown if validation fails + public static void ValidateFacet(string facet, string[] qualifier, string typeHint, string propertyPath, FacetValidationOptions options) + { + if (options == null) + throw new ArgumentNullException(nameof(options)); + + // Check if facet has a registered validator + FacetValidator validator = null; + bool hasValidator = options.FacetValidators != null && options.FacetValidators.TryGetValue(facet, out validator); + + // If unknown facets are not allowed and this facet has no validator, reject it + if (!hasValidator && !options.AllowUnknownFacets) + { + string allowedList = options.FacetValidators != null + ? string.Join(", ", options.FacetValidators.Keys) + : "(none)"; + throw new XPropException(XPropErrorCode.FacetUnknown, + $"Unknown facet '{facet}'. Allowed facets: {allowedList}"); + } + + // Run validator if registered + if (hasValidator && validator != null) + { + if (!validator(facet, qualifier, typeHint, propertyPath)) + { + throw new XPropException(XPropErrorCode.QualifierInvalid, + $"Facet validation failed for '{facet}' with qualifier [{string.Join(", ", qualifier ?? Array.Empty())}]"); + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int FindFacetDelimiter(string s, int start) + { + int i = start; + while (i < s.Length) + { + if (s[i] == '[') + { + // Skip to closing bracket, handling escapes + i++; + while (i < s.Length && s[i] != ']') + { + if (s[i] == '~') + { + if (i + 1 >= s.Length || !IsDigit(s[i + 1])) + throw new XPropException(XPropErrorCode.ParseError, "Incomplete escape sequence"); + i += 2; // Skip escape sequence + } + else + i++; + } + if (i >= s.Length) + throw new XPropException(XPropErrorCode.ParseError, + "Unclosed bracket in path segment"); + i++; // Skip the ] + } + else if (i + 1 < s.Length && s[i] == ':' && s[i + 1] == ':') + { + return i; + } + else + { + i++; + } + } + return -1; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int FindPropertyDelimiter(string s, int start) + { + int i = start; + while (i < s.Length) + { + if (s[i] == '(') + { + // Skip to closing paren + int depth = 1; + i++; + while (i < s.Length && depth > 0) + { + if (s[i] == '(') + depth++; + else if (s[i] == ')') + depth--; + i++; + } + } + else if (s[i] == '<') + { + // Skip to closing angle bracket + i++; + while (i < s.Length && s[i] != '>') + i++; + if (i < s.Length) + i++; // Skip the > + } + else if (s[i] == ':') + { + return i; + } + else + { + i++; + } + } + return -1; + } + + private static string[] ParseHierarchy(string pathStr) + { + var segments = new List(); + int i = 0; + + while (i < pathStr.Length) + { + string segment; + + if (pathStr[i] == '[') + { + // Quoted segment + i++; + var segChars = new StringBuilder(); + while (i < pathStr.Length && pathStr[i] != ']') + { + if (pathStr[i] == '~') + { + if (i + 1 >= pathStr.Length) + throw new XPropException(XPropErrorCode.ParseError, "Incomplete escape sequence"); + + if (!TryDecodeEscape(pathStr[i + 1], PathEscapes, 0, out char decoded)) + throw new XPropException(XPropErrorCode.ParseError, $"Unknown escape sequence in path: ~{pathStr[i + 1]}"); + + segChars.Append(decoded); + i += 2; + } + else + { + segChars.Append(pathStr[i++]); + } + } + + if (i >= pathStr.Length) + throw new XPropException(XPropErrorCode.ParseError, + "Unclosed bracket in path segment"); + + segment = segChars.ToString(); + i++; // Skip ] + } + else + { + // Unquoted segment + int j = i; + while (j < pathStr.Length && pathStr[j] != '/' && pathStr[j] != '[') + j++; + segment = pathStr.Substring(i, j - i); + i = j; + + if (string.IsNullOrEmpty(segment)) + throw new XPropException(XPropErrorCode.ParseError, "Empty path segment"); + + // Check for parent traversal before character validation + if (segment == "..") + throw new XPropException(XPropErrorCode.ParseError, + "Parent traversal (..) is not permitted"); + + // Validate unquoted segment characters + if (!MatchesAll(segment, IsAlphaNumUnderscore)) + throw new XPropException(XPropErrorCode.ParseError, + $"Invalid characters in unquoted segment: {segment}"); + } + + if (segment.Length > MAX_SEGMENT_LENGTH) + throw new XPropException(XPropErrorCode.LimitExceeded, + $"Segment exceeds maximum length of {MAX_SEGMENT_LENGTH}"); + + segments.Add(segment); + + // Skip separator + if (i < pathStr.Length) + { + if (pathStr[i] == '/') + { + i++; + if (i >= pathStr.Length) + throw new XPropException(XPropErrorCode.ParseError, + "Trailing slash in path"); + } + } + } + + return segments.ToArray(); + } + + private static void ParseFacetPart(string facetStr, out string facet, out string[] qualifier, out string typeHint) + { + typeHint = null; + qualifier = null; + + // Extract type hint from end + if (TryExtractDelimited(ref facetStr, '<', '>', out string typeContent)) + { + if (string.IsNullOrEmpty(typeContent)) + throw new XPropException(XPropErrorCode.ParseError, "Empty type hint"); + if (!IsAlpha(typeContent[0]) || !MatchesAll(typeContent, IsAlphaNumUnderscore)) + throw new XPropException(XPropErrorCode.ParseError, $"Invalid type name '{typeContent}': must start with letter"); + typeHint = typeContent; + } + + // Extract qualifier (params) + if (TryExtractDelimited(ref facetStr, '(', ')', out string qualContent)) + { + qualifier = string.IsNullOrEmpty(qualContent) ? Array.Empty() : ParseAndValidateQualifier(qualContent); + } + + // Validate facet name + if (string.IsNullOrEmpty(facetStr)) + throw new XPropException(XPropErrorCode.ParseError, "Empty facet name"); + if (!MatchesAll(facetStr, IsLowerAlphaNumUnderscore)) + throw new XPropException(XPropErrorCode.ParseError, $"Invalid facet name '{facetStr}': must be lowercase ASCII letters, digits, or underscores"); + + facet = facetStr; + } + + private static bool TryExtractDelimited(ref string str, char open, char close, out string content) + { + int closeIdx = str.IndexOf(close); + if (closeIdx == -1) { content = null; return false; } + + int openIdx = str.LastIndexOf(open, closeIdx); + if (openIdx == -1) + throw new XPropException(XPropErrorCode.ParseError, $"Malformed: found '{close}' without '{open}'"); + + content = str.Substring(openIdx + 1, closeIdx - openIdx - 1); + str = str[..openIdx]; + return true; + } + + private static string[] ParseAndValidateQualifier(string content) + { + var rawParams = ParseQualifierParams(content); + + if (rawParams.Length > MAX_QUALIFIER_PARAMS) + throw new XPropException(XPropErrorCode.LimitExceeded, $"Qualifier exceeds maximum of {MAX_QUALIFIER_PARAMS} params"); + + // Trim and validate each parameter + var result = new string[rawParams.Length]; + for (int i = 0; i < rawParams.Length; i++) + { + var raw = rawParams[i]; + + // Check for excessive leading whitespace (more than one space) + if (raw.Length > 0 && char.IsWhiteSpace(raw[0])) + { + // Count leading whitespace + int wsCount = 0; + for (int j = 0; j < raw.Length && char.IsWhiteSpace(raw[j]); j++) + wsCount++; + + if (wsCount > 1) + throw new XPropException(XPropErrorCode.ParseError, "Whitespace not permitted in qualifier parameters"); + } + + // Check for excessive trailing whitespace (more than one space) + if (raw.Length > 0 && char.IsWhiteSpace(raw[raw.Length - 1])) + { + // Count trailing whitespace + int wsCount = 0; + for (int j = raw.Length - 1; j >= 0 && char.IsWhiteSpace(raw[j]); j--) + wsCount++; + + if (wsCount > 1) + throw new XPropException(XPropErrorCode.ParseError, "Whitespace not permitted in qualifier parameters"); + } + + var trimmed = raw.Trim(); + + if (trimmed.Length > MAX_QUALIFIER_PARAM_LENGTH) + throw new XPropException(XPropErrorCode.LimitExceeded, $"Qualifier param exceeds maximum length of {MAX_QUALIFIER_PARAM_LENGTH}"); + + if (ContainsWhitespace(trimmed)) + throw new XPropException(XPropErrorCode.ParseError, "Whitespace not permitted in qualifier parameters"); + + result[i] = trimmed; + } + + return result; + } + + private static string[] ParseQualifierParams(string content) + { + var result = new List(); + var current = new StringBuilder(); + + for (int i = 0; i < content.Length; i++) + { + char c = content[i]; + if (c == '~' && i + 1 < content.Length) + { + if (TryDecodeEscape(content[i + 1], QualifierEscapes, 3, out char decoded)) + { + current.Append(decoded); + i++; // Skip next char (escape code) + } + else + { + // Lenient: treat unknown as literal + current.Append(c); + } + } + else if (c == ',') + { + result.Add(current.ToString()); + current.Clear(); + } + else + { + current.Append(c); + } + } + + result.Add(current.ToString()); + return result.ToArray(); + } + + private static void ValidatePropertyPath(string propPath) + { + // Split into segments by dot, but handle array indices + var segments = new List(); + var currentSegment = new StringBuilder(); + int i = 0; + int depth = 0; + + while (i < propPath.Length) + { + if (propPath[i] == '.') + { + if (currentSegment.Length > 0) + { + string seg = currentSegment.ToString(); + ValidatePropertySegment(seg); + segments.Add(seg); + depth++; + currentSegment.Clear(); + } + else + { + throw new XPropException(XPropErrorCode.ParseError, + "Empty property segment (consecutive dots)"); + } + i++; + } + else if (propPath[i] == '[') + { + // Array index - must follow a segment name + if (currentSegment.Length == 0) + throw new XPropException(XPropErrorCode.ParseError, + "Array index without preceding property name"); + + // Find closing bracket + int j = i + 1; + while (j < propPath.Length && propPath[j] != ']') + j++; + + if (j >= propPath.Length) + throw new XPropException(XPropErrorCode.ParseError, + "Unclosed array index bracket"); + + string indexStr = propPath.Substring(i + 1, j - i - 1); + ValidateArrayIndex(indexStr); + + // Check for multidimensional access + if (j + 1 < propPath.Length && propPath[j + 1] == '[') + throw new XPropException(XPropErrorCode.ParseError, + "Multidimensional array access is not supported"); + + currentSegment.Append('['); + currentSegment.Append(indexStr); + currentSegment.Append(']'); + i = j + 1; + } + else + { + currentSegment.Append(propPath[i]); + i++; + } + } + + // Handle final segment + if (currentSegment.Length > 0) + { + string seg = currentSegment.ToString(); + ValidatePropertySegment(seg); + segments.Add(seg); + depth++; + } + else + { + throw new XPropException(XPropErrorCode.ParseError, "Property path ends with dot"); + } + + if (depth == 0) + throw new XPropException(XPropErrorCode.ParseError, + "Property path must contain at least one segment"); + + if (depth > MAX_PROPERTY_DEPTH) + throw new XPropException(XPropErrorCode.LimitExceeded, + $"Property path exceeds maximum depth of {MAX_PROPERTY_DEPTH}"); + } + + private static void ValidatePropertySegment(string seg) + { + // Remove array index if present + string namePart = seg; + if (seg.Contains("[")) + { + namePart = seg.Substring(0, seg.IndexOf('[')); + } + + if (string.IsNullOrEmpty(namePart)) + throw new XPropException(XPropErrorCode.ParseError, "Empty property segment name"); + + // v0.8.0: Property names must start with letter or underscore + if (!IsAlphaOrUnderscore(namePart[0])) + throw new XPropException(XPropErrorCode.ParseError, + $"Invalid property segment name '{namePart}': must start with letter or underscore"); + + // v0.8.0: Property names can contain letters, digits, underscores, and hyphens + for (int i = 1; i < namePart.Length; i++) + { + if (!IsPropertyChar(namePart[i])) + throw new XPropException(XPropErrorCode.ParseError, + $"Invalid property segment name '{namePart}': contains invalid character '{namePart[i]}'"); + } + } + + private static void ValidateArrayIndex(string indexStr) + { + if (string.IsNullOrEmpty(indexStr)) + throw new XPropException(XPropErrorCode.ParseError, "Empty array index"); + + if (!MatchesAll(indexStr, IsDigit)) + throw new XPropException(XPropErrorCode.ParseError, + $"Invalid array index (non-numeric): {indexStr}"); + + // Check for leading zeros + if (indexStr.Length > 1 && indexStr[0] == '0') + throw new XPropException(XPropErrorCode.ParseError, + $"Leading zeros not permitted in array index: [{indexStr}]"); + + int indexVal = int.Parse(indexStr); + if (indexVal > MAX_ARRAY_INDEX) + throw new XPropException(XPropErrorCode.LimitExceeded, + $"Array index {indexVal} exceeds maximum of {MAX_ARRAY_INDEX}"); + } + } +} diff --git a/Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropParser.cs.meta b/Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropParser.cs.meta new file mode 100644 index 000000000..def899539 --- /dev/null +++ b/Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropParser.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a83ae51c7afe0dc4d8da5df457f4ba91 \ No newline at end of file diff --git a/Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropPropertyMapping.cs b/Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropPropertyMapping.cs new file mode 100644 index 000000000..496f7abe2 --- /dev/null +++ b/Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropPropertyMapping.cs @@ -0,0 +1,183 @@ +using System.Collections.Generic; + +namespace Jinxxy.Item.XProp +{ + /// + /// Maps XProp standard property names to Unity-specific shader property names + /// and provides reverse mapping for serialization + /// + public static class XPropPropertyMapping + { + /// + /// XProp standard material property names to common Unity shader property names + /// + private static readonly Dictionary StandardToUnityMaterial = new Dictionary + { + // Color properties + { "color", new[] { "_Color", "_BaseColor", "_MainColor", "_Main_Color" } }, + + // Emission properties + { "emission", new[] { "_EmissionColor", "_Emission" } }, + { "emissionIntensity", new[] { "_EmissionIntensity", "_EmissionStrength" } }, + + // PBR properties + { "metallic", new[] { "_Metallic", "_MetallicGlossMap" } }, + { "roughness", new[] { "_Roughness" } }, + { "smoothness", new[] { "_Smoothness", "_Glossiness" } }, + + // Transparency + { "opacity", new[] { "_Alpha", "_Opacity" } }, + { "cutoff", new[] { "_Cutoff", "_AlphaCutoff" } }, + + // UV properties + { "tiling", new[] { "_MainTex_ST", "_BaseMap_ST" } }, // Note: ST = Scale/Tiling + { "offset", new[] { "_MainTex_ST", "_BaseMap_ST" } }, // Note: ST contains both + }; + + /// + /// Unity shader property names to XProp standard names + /// + private static readonly Dictionary UnityToStandardMaterial; + + static XPropPropertyMapping() + { + // Build reverse mapping + UnityToStandardMaterial = new Dictionary(); + foreach (var kvp in StandardToUnityMaterial) + { + foreach (var unityName in kvp.Value) + { + if (!UnityToStandardMaterial.ContainsKey(unityName)) + { + UnityToStandardMaterial[unityName] = kvp.Key; + } + } + } + } + + /// + /// Tries to resolve an XProp standard property name to a Unity shader property name + /// by checking if the material has any of the known Unity equivalents + /// + public static bool TryResolveToUnityProperty(string xpropPropertyName, UnityEngine.Material material, out string unityPropertyName) + { + unityPropertyName = null; + + // Direct match first + if (material.HasProperty(xpropPropertyName)) + { + unityPropertyName = xpropPropertyName; + return true; + } + + // Try standard mappings + if (StandardToUnityMaterial.TryGetValue(xpropPropertyName, out var candidates)) + { + foreach (var candidate in candidates) + { + if (material.HasProperty(candidate)) + { + unityPropertyName = candidate; + return true; + } + } + } + + return false; + } + + /// + /// Tries to convert a Unity shader property name to XProp standard name + /// + public static bool TryConvertToStandardProperty(string unityPropertyName, out string xpropPropertyName) + { + // Check if it's already a standard name + if (StandardToUnityMaterial.ContainsKey(unityPropertyName)) + { + xpropPropertyName = unityPropertyName; + return true; + } + + // Try reverse mapping + if (UnityToStandardMaterial.TryGetValue(unityPropertyName, out xpropPropertyName)) + { + return true; + } + + // Not a standard property, keep Unity name + xpropPropertyName = unityPropertyName; + return false; + } + + /// + /// Gets the appropriate property name for serialization. + /// Prefers XProp standard names when available, otherwise uses Unity-specific names. + /// + public static string GetSerializedPropertyName(string unityPropertyName) + { + TryConvertToStandardProperty(unityPropertyName, out string result); + return result; + } + + /// + /// Checks if a property name is an XProp standard property + /// + public static bool IsStandardProperty(string propertyName) + { + return StandardToUnityMaterial.ContainsKey(propertyName); + } + + /// + /// Maps XProp component accessors to their indices + /// + public static bool TryParseComponentAccessor(string accessor, out int componentIndex, out bool isColorChannel) + { + componentIndex = -1; + isColorChannel = false; + + switch (accessor.ToLower()) + { + case ".x": + case ".r": + componentIndex = 0; + isColorChannel = accessor == ".r"; + return true; + case ".y": + case ".g": + componentIndex = 1; + isColorChannel = accessor == ".g"; + return true; + case ".z": + case ".b": + componentIndex = 2; + isColorChannel = accessor == ".b"; + return true; + case ".w": + case ".a": + componentIndex = 3; + isColorChannel = accessor == ".a"; + return true; + default: + return false; + } + } + + /// + /// Gets the component accessor string from index + /// + public static string GetComponentAccessor(int componentIndex, bool isColorChannel) + { + if (componentIndex < 0 || componentIndex > 3) + return string.Empty; + + if (isColorChannel) + { + return new[] { ".r", ".g", ".b", ".a" }[componentIndex]; + } + else + { + return new[] { ".x", ".y", ".z", ".w" }[componentIndex]; + } + } + } +} diff --git a/Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropPropertyMapping.cs.meta b/Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropPropertyMapping.cs.meta new file mode 100644 index 000000000..aa53b3044 --- /dev/null +++ b/Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropPropertyMapping.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 471e413489e68224782a36140d232b25 \ No newline at end of file diff --git a/Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropRef.cs b/Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropRef.cs new file mode 100644 index 000000000..05ca8efbf --- /dev/null +++ b/Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropRef.cs @@ -0,0 +1,301 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Jinxxy.Item.XProp +{ + public enum PathType + { + ContextRelative, // ./... + ScopeRelative, // #/... + Absolute // /... + } + + /// + /// Represents a parsed XProp reference according to v0.1.0 specification + /// + public readonly struct XPropRef : IEquatable + { + public readonly PathType PathType; + public readonly string[] Hierarchy; + public readonly string Facet; + public readonly string[] Qualifiers; + public readonly string TypeHint; + public readonly string PropertyPath; + + public XPropRef( + PathType pathType, + string[] hierarchy, + string facet, + string[] qualifier, + string typeHint, + string propertyPath) + { + PathType = pathType; + Hierarchy = hierarchy ?? Array.Empty(); + Facet = facet ?? throw new ArgumentNullException(nameof(facet)); + Qualifiers = qualifier; + TypeHint = typeHint; + PropertyPath = propertyPath ?? throw new ArgumentNullException(nameof(propertyPath)); + } + + /// + /// Constructor overload with optional typeHint (defaults to null) + /// + public XPropRef( + PathType pathType, + string[] hierarchy, + string facet, + string[] qualifier, + string propertyPath) + : this(pathType, hierarchy, facet, qualifier, null, propertyPath) + { + } + + /// + /// Reconstructs the XProp reference string (normalized form) + /// + public override string ToString() + { + var sb = new StringBuilder(); + + // Path prefix and hierarchy + switch (PathType) + { + case PathType.ContextRelative: + sb.Append("./"); + if (Hierarchy.Length > 0) + { + sb.Append(EncodeHierarchy()); + } + break; + case PathType.ScopeRelative: + sb.Append("#/"); + if (Hierarchy.Length > 0) + { + sb.Append(EncodeHierarchy()); + } + break; + case PathType.Absolute: + sb.Append("/"); + sb.Append(EncodeHierarchy()); + break; + } + + // Facet delimiter and facet + sb.Append("::"); + sb.Append(Facet); + + // Qualifier + if (Qualifiers != null && Qualifiers.Length > 0) + { + sb.Append('('); + for (int i = 0; i < Qualifiers.Length; i++) + { + if (i > 0) sb.Append(", "); + sb.Append(EncodeQualifierParam(Qualifiers[i])); + } + sb.Append(')'); + } + + // Type hint + if (!string.IsNullOrEmpty(TypeHint)) + { + sb.Append('<'); + sb.Append(TypeHint); + sb.Append('>'); + } + + // Property + sb.Append(':'); + sb.Append(PropertyPath); + + return sb.ToString(); + } + + private string EncodeHierarchy() + { + var parts = new List(); + foreach (var seg in Hierarchy) + { + if (IsUnquotedSegment(seg)) + { + parts.Add(seg); + } + else + { + // Need to quote - apply escapes + var escaped = seg + .Replace("~", "~0") + .Replace("]", "~1") + .Replace("[", "~2"); + parts.Add($"[{escaped}]"); + } + } + return string.Join("/", parts); + } + + private static bool IsUnquotedSegment(string seg) + { + if (string.IsNullOrEmpty(seg)) return false; + foreach (char c in seg) + { + if (!char.IsLetterOrDigit(c) && c != '_') + return false; + } + return true; + } + + private static string EncodeQualifierParam(string param) + { + // Escape delimiters + var result = new StringBuilder(); + foreach (char c in param) + { + switch (c) + { + case ',': + result.Append("~3"); + break; + case '(': + result.Append("~4"); + break; + case ')': + result.Append("~5"); + break; + default: + result.Append(c); + break; + } + } + return result.ToString(); + } + + /// + /// Gets the first qualifier parameter, or null if no qualifiers + /// + public readonly string PrimaryQualifier => Qualifiers != null && Qualifiers.Length > 0 ? Qualifiers[0] : null; + + /// + /// Tries to parse the primary qualifier as an integer (e.g., material slot index) + /// + public readonly bool TryGetIntQualifier(out int value) + { + value = 0; + string primary = PrimaryQualifier; + if (primary == null) return false; + return int.TryParse(primary, out value); + } + + /// + /// Creates an XPropRef from a path string and components + /// + /// The type of path (scope-relative, context-relative, etc.) + /// The hierarchy path as a string (e.g., "Armature/Hips/Spine"). Empty for self-reference. + /// The facet name (e.g., "mesh", "material") + /// Optional qualifier parameters + /// Optional type hint + /// The property path (defaults to empty string) + /// A new XPropRef instance + public static XPropRef FromPath( + PathType pathType, + string path, + string facet, + string[] qualifiers = null, + string typeHint = null, + string propertyPath = "") + { + if (string.IsNullOrEmpty(facet)) + { + throw new ArgumentException("Facet cannot be null or empty.", nameof(facet)); + } + + // Parse path into hierarchy segments + string[] hierarchy; + if (string.IsNullOrEmpty(path)) + { + hierarchy = Array.Empty(); + } + else + { + hierarchy = path.Split('/'); + } + + return new XPropRef( + pathType, + hierarchy, + facet, + qualifiers, + typeHint, + propertyPath ?? string.Empty + ); + } + + /// + /// Creates an XPropRef from hierarchy segments and components + /// + public static XPropRef Create( + PathType pathType, + string[] hierarchy, + string facet, + string[] qualifiers = null, + string typeHint = null, + string propertyPath = "") + { + if (string.IsNullOrEmpty(facet)) + { + throw new ArgumentException("Facet cannot be null or empty.", nameof(facet)); + } + + return new XPropRef( + pathType, + hierarchy, + facet, + qualifiers, + typeHint, + propertyPath ?? string.Empty + ); + } + + public static XPropRef Parse(string xpropString) + { + return XPropParser.Parse(xpropString); + } + + /// + /// Checks equality of two XPropRef instances, comparing array elements + /// + public bool Equals(XPropRef other) + { + return PathType == other.PathType && + ArrayEquals(Hierarchy, other.Hierarchy) && + Facet == other.Facet && + ArrayEquals(Qualifiers, other.Qualifiers) && + TypeHint == other.TypeHint && + PropertyPath == other.PropertyPath; + } + + public override bool Equals(object obj) + { + return obj is XPropRef other && Equals(other); + } + + public override int GetHashCode() + { + return ToString().GetHashCode(); + } + + public static bool operator ==(XPropRef left, XPropRef right) => left.Equals(right); + public static bool operator !=(XPropRef left, XPropRef right) => !left.Equals(right); + + private static bool ArrayEquals(string[] a, string[] b) + { + if (a == null && b == null) return true; + if (a == null || b == null) return false; + if (a.Length != b.Length) return false; + for (int i = 0; i < a.Length; i++) + if (a[i] != b[i]) return false; + return true; + } + } +} diff --git a/Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropRef.cs.meta b/Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropRef.cs.meta new file mode 100644 index 000000000..7b3b175d9 --- /dev/null +++ b/Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropRef.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 29e9687829fd5c340b57163d16977ded \ No newline at end of file diff --git a/Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropSpec.md b/Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropSpec.md new file mode 100644 index 000000000..d44b87ef7 --- /dev/null +++ b/Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropSpec.md @@ -0,0 +1,797 @@ +# XProp: Cross-Engine Property Reference + +## **Version v0.1.0** + +Date: December 2025 + +## **Status:** + +Draft + +## Abstract + +XProp is a string syntax for addressing properties in game engine scene graphs. It provides sandboxed, cross-engine references with explicit path scoping for user-generated content (UGC) platforms. + +--- + +## 1. Introduction + +### 1.1 Purpose + +XProp provides: + +- **Hierarchical addressing**: Reference properties in scene graph nodes by path +- **Sandboxed access**: Prevent unauthorized references outside allowed boundaries +- **Cross-engine compatibility**: Works across Blender, Unity, Godot, and others +- **Extensibility**: Application-defined facets for custom systems + +### 1.2 Scope + +XProp defines **syntax only**. Applications implement: + +- Resolution behavior (get/set) +- Permission models +- Extension facets +- Serialization format + +### 1.3 Design Principles + +1. **Explicit scoping**: Path prefix determines resolution path root +2. **Minimal escaping**: Simple escape sequences only where needed +3. **Unambiguous parsing**: Left-to-right, deterministic +4. **Extensible**: Support for custom facets and types + +--- + +## 2. Terminology + +| Term | Definition | +|------|------------| +| **Context Node** | Node evaluating the reference (e.g., script's node) | +| **Scope Root** | Sandbox boundary (e.g., avatar root, mod container) | +| **Scene Root** | Global scene hierarchy root | +| **Facet** | Property category (`xform`, `mat`, or custom) | +| **Qualifier** | Facet parameters specifying slot/component | + +--- + +## 3. Syntax + +### 3.1 Structure + +```txt +prefix[path]::facet[(qualifier)]:property +``` + +### 3.2 Path Prefixes + +The prefix determines where resolution starts: + +| Form | Resolves From | Sandboxed | Use Case | +|------|---------------|-----------|----------| +| `./::` | Context node itself | Yes | Reference self | +| `./path::` | Context node + path | Yes | Reference children/descendants | +| `#/::` | Scope root itself | Yes | Reference sandbox boundary | +| `#/path::` | Scope root + path | Yes | Reference within sandbox | +| `/path::` | Scene root + path | No | Global reference (requires permission) | + +**Examples:** + +```xprop +./::xform:position ; my own position +./Hand::xform:position ; my child "Hand" +#/::render:visible ; scope root visibility +#/Body/Head::xform:rotation ; node within scope +/World/Sun::xform:rotation ; global reference +``` + +### 3.3 Path Canonicalization + +Paths are case-sensitive and must match node names exactly. Implementations SHOULD normalize: + +- Trailing slashes: `#/Body/` -> `#/Body` +- Redundant slashes: `#/Body//Head` -> `#/Body/Head` +- Empty segments removed + +Implementations MUST NOT normalize case or apply fuzzy matching. + +### 3.4 Unicode in Paths + +Path segments support Unicode. Use brackets `[...]` for names with special characters: + +```xprop +#/[玩家]::xform:position ; Chinese +#/[Игрок]::xform:position ; Cyrillic +#/[Player™]::render:visible ; Symbols +#/[My Node 🎮]::script(Health):current ; Emoji + spaces +``` + +**Rules:** + +- Unquoted segments: `[A-Za-z0-9_]+` only +- Bracketed segments: Any Unicode sequence except `]` (use `~1` to escape) +- Structural elements (facets, properties, delimiters): ASCII-only + +### 3.5 ABNF Grammar + +```abnf +xprop = path-part "::" facet-part ":" property-part + +; === Path === +path-part = context-rel / scope-rel / absolute +context-rel = "./" [hierarchy] ; empty hierarchy = context node itself +scope-rel = "#/" [hierarchy] ; empty hierarchy = scope root itself +absolute = "/" hierarchy ; absolute requires at least one segment + +hierarchy = segment *("/" segment) +segment = unquoted / quoted +unquoted = 1*name-char +quoted = "[" 1*(safe-char / escape) "]" + +; === Facet === +facet-part = facet [qualifier] [type-hint] +facet = 1*LC-ALNUM ; lowercase ASCII only +qualifier = "(" param *("," ?" " param) ")" +param = *(qchar / qescape) +qchar = %x21-27 / %x2A-2B / %x2D-7E ; printable except whitespace,(,),comma +qescape = "~3" / "~4" / "~5" ; escape sequences for , ( ) +; Note: ~X where X is not 3,4,5 is valid input, treated as literal "~X" + +; === Type Hint === +type-hint = "<" type-name ">" +type-name = ALPHA *name-char ; must start with letter + +; === Property === +property-part = prop-segment *("." prop-segment) +prop-segment = prop-name [index] +prop-name = prop-start *prop-char +prop-start = ALPHA / "_" ; must start with letter or underscore +prop-char = ALPHA / DIGIT / "_" ; letters, digits, underscore, hyphen +index = "[" array-index "]" +array-index = "0" / (NON-ZERO *DIGIT) ; no leading zeros + +; === Characters === +name-char = ALPHA / DIGIT / "_" +LC-ALNUM = %x61-7A / DIGIT / "_" ; a-z, 0-9, _ +safe-char = %x20-5A / %x5C / %x5E-7E ; printable except [ ] ~ +escape = "~0" / "~1" / "~2" ; path: ~0=~ ~1=] ~2=[ + +ALPHA = %x41-5A / %x61-7A +DIGIT = %x30-39 +NON-ZERO = %x31-39 +``` + +### 3.6 Limits + +Implementations MUST enforce these limits: + +| Limit | Value | Rationale | +|-------|-------|-----------| +| Total reference length | 1024 chars | Fits in reasonable buffers | +| Path segments | 32 | Deeper hierarchies are rare | +| Segment name length | 128 chars | Node names shouldn't be excessive | +| Property depth | 16 levels | `a.b.c...` nesting limit (including accessors) | +| Array index | 0–65535 | 16-bit range covers practical cases | +| Qualifier params | 8 | Sufficient for any facet | +| Qualifier param length | 64 chars | Component type names | + +Implementations MUST reject references exceeding these limits with `LIMIT_EXCEEDED` error. + +### 3.7 Facet Names + +Facet names MUST be lowercase ASCII: `[a-z0-9_]+` + +**Reserved facets:** `xform`, `mat` + +Facets are logical identifiers, not engine type names. The lowercase restriction applies to facet names only—qualifier parameters may use any case (e.g., `script(Rigidbody)` for Unity). + +### 3.8 Escape Sequences + +#### Path Escapes (inside `[...]` only) + +Used to include special characters in node names: + +| Escape | Character | Example | +|--------|-----------|---------| +| `~0` | `~` | `[node~0name]` -> "node~name" | +| `~1` | `]` | `[bracket~1test]` -> "bracket]test" | +| `~2` | `[` | `[open~2close]` -> "open[close" | + +**Strict parsing:** Unknown escapes (`~X` where X ≠ 0,1,2) cause `PARSE_ERROR`. + +**Common cases:** + +```xprop +#/[My Node]::xform:position ; spaces allowed +#/[path/to/node]::render:visible ; slashes literal (single segment) +#/[user@email]::script(Profile):id ; special chars +``` + +#### Qualifier Escapes + +Used to include delimiters in qualifier parameters: + +| Escape | Character | Example | +|--------|-----------|---------| +| `~3` | `,` | `script(file~3v2.gd)` -> "file,v2.gd" | +| `~4` | `(` | `script(fn~4x~5)` -> "fn(x)" | +| `~5` | `)` | (same as above) | + +**Lenient parsing:** Unknown escapes are literal. `my~file.gd` stays as `my~file.gd`. + +### 3.9 Property Names and Array Access + +Property paths use dot notation (`.`) for nested properties and brackets (`[index]`) for arrays. + +#### Property Name Rules + +Property names must: + +- Start with letter or underscore: `[A-Za-z_]` +- Contain only: `[A-Za-z0-9_-]` (letters, digits, underscore, hyphen) +- Not contain: brackets `[]` (reserved for indexing), dots `.` (reserved for nesting) + +**Valid:** + +```xprop +./::script(data):position ; simple +./::script(data):_privateField ; underscore prefix +./::script(data):player-2_health ; hyphens and underscores +``` + +**Invalid:** + +```xprop +./::script(data):2player ; starts with digit +./::script(data):-invalid ; starts with hyphen +./::script(data):some property ; spaces not allowed +``` + +#### Array Indexing + +Use `[index]` immediately after property name: + +```xprop +./::script(inventory):slots[0].count +./::script(data):items[42].name +``` + +**Rules:** + +- Range: `[0-65535]` (16-bit) +- No leading zeros: `[007]` is invalid +- No multidimensional: `[0][1]` is invalid, use `[0].col[1]` + +#### Bracket Disambiguation + +Brackets serve different purposes in different parts of the reference: + +| Location | Purpose | Example | +|----------|---------|---------| +| Before `::` (path) | Quote node names | `#/[Node Name]::...` | +| After `:` (property) | Array indexing | `...:items[0]` | + +No ambiguity exists because property names cannot contain brackets. + +### 3.10 Parent Traversal + +Parent traversal (`..`) is **not supported**. Segments named `..` cause `PARSE_ERROR`. This prevents sandbox escapes. + +```xprop +./Child/..::xform:position ; INVALID +#/../Sibling::render:visible ; INVALID +``` + +### 3.11 Qualifiers + +Qualifiers provide facet-specific parameters: `(param1, param2, ...)` + +**One whitespace allowed per item.** Multiple whitespaces causes `PARSE_ERROR`. Use escape sequences for special characters. + +**Examples:** + +| Facet | Qualifier | Example | +|-------|-----------|---------| +| `xform` | none | `./::xform:position` | +| `mat` | `(slot)` or `(slot,shader)` | `./::mat(0):color` | +| `script` | `(Type)` or `(Type,index)` | `./::script(Health):current` | +| `render` | none | `./::render:visible` | + +### 3.12 Type Hints + +Optional type annotation: `` + +Type names must start with a letter and contain only `[A-Za-z0-9_]`. + +**Built-in types:** + +| Type | Format | Accessors | Notes | +|------|--------|-----------|-------| +| `bool` | `true`/`false` | - | Boolean | +| `int` | integer | - | Signed 32-bit | +| `float` | number | - | 64-bit double | +| `string` | `"..."` | - | Unicode Sequence | +| `float2` | `[x, y]` | `.x` `.y` | 2D vector | +| `float3` | `[x, y, z]` | `.x` `.y` `.z` | 3D vector | +| `float4` | `[x, y, z, w]` | `.x` `.y` `.z` `.w` | 4D vector | +| `quat` | `[x, y, z, w]` | `.x` `.y` `.z` `.w` | Quaternion (use slerp) | +| `rgb` | `[r, g, b]` | `.r` `.g` `.b` | Color [0-1] | +| `rgba` | `[r, g, b, a]` | `.r` `.g` `.b` `.a` | Color + alpha [0-1] | + +Separate types for colors and quaternions enable correct interpolation (sRGB blending for colors, slerp for quaternions, linear for vectors). + +Applications may define custom types. + +--- + +## 4. Facets + +### 4.1 Reserved Facets + +Four facets have standardized semantics: + +| Facet | Purpose | +|-------|---------| +| `xform` | Position, rotation, scale | +| `mat` | Surface/shader properties | + +#### 4.1.1 `xform` + +Spatial transformation. No qualifier. + +| Property | Type | Description | +|----------|------|-------------| +| `position` | float3 | Local position | +| `position.x` `.y` `.z` | float | Components | +| `rotation` | quat | Local rotation (x, y, z, w) | +| `rotation.x` `.y` `.z` `.w` | float | Quaternion components | +| `rotation_euler` | float3 | Euler angles (degrees) | +| `rotation_euler.x` `.y` `.z` | float | Pitch, yaw, roll | +| `scale` | float3 | Local scale | +| `scale.x` `.y` `.z` | float | Components | +| `scale_uniform` | float | Uniform scale factor | +| `world_position` | float3 | World-space position | +| `world_rotation` | quat | World-space rotation | + +#### 4.1.2 `mat` + +Material/surface properties. Qualifier: `(slot)` or `(slot, shader)`. + +**Slot** is the material index (0-based). Additional qualifier param is application-defined shader interface (e.g. unity_unlit). + +`urp_unlit` shader interface + +| Property | Type | Description | +|----------|------|-------------| +| `color` | rgba | Base color RGBA | +| `color.r` `.g` `.b` `.a` | float | Components [0-1] | +| `emission` | bool | Enable emission | +| `emission_color` | float3 | Emission RGB | +| `emission_color.r` `.g` `.b` | float | Components | + +Applications MAY support shader-specific properties via native names. + +#### 4.1.3 `script` + +Component/behavior properties. Qualifier: `(type)` or `(type,index)`. + +- **type**: Component type identifier. May use PascalCase to match engine type names directly (e.g., `Rigidbody`, `AudioSource` in Unity), or lowercase with implementation-defined mapping. +- **index**: Instance index when multiple exist (default: 0) + +Supports arbitrary nested properties: + +```txt +./::script(Inventory):slots[0].item.count +./::script(QuestLog):quests[0].objectives[2].completed +``` + +**Engine mapping:** + +| Engine | Maps To | +|--------|---------| +| Unity | `MonoBehaviour` / `Component` (use PascalCase type names directly) | +| Unreal | `ActorComponent` | +| Godot | Script properties on Node | +| Blender | `Modifier` / `Constraint` by name | + +Implementations MAY accept qualifier type names verbatim (recommended for Unity) or apply case-insensitive matching. + +### 4.2 Extension Facets + +Any facet name not in the reserved set is an extension: + +```txt +./::audio(source,0):volume +./::physics(rigidbody):velocity +./::anim(animator):speed +./::particles(system):maxCount +``` + +Applications define qualifier syntax and properties for extensions. + +--- + +## 5. Resolution + +### 5.1 Algorithm + +```txt +resolve(ref, context, scopeRoot, sceneRoot, permissions): + parsed = parse(ref) + + base = match parsed.pathType: + ContextRelative -> + if parsed.hierarchy is empty: + context + else: + navigate(context, parsed.hierarchy) + ScopeRelative -> + if parsed.hierarchy is empty: + scopeRoot + else: + navigate(scopeRoot, parsed.hierarchy) + Absolute -> + if not permissions.allowAbsolute: + error(PERMISSION_DENIED) + navigate(sceneRoot, parsed.hierarchy) + + if base is null: + error(NODE_NOT_FOUND) + + if parsed.pathType in [ContextRelative, ScopeRelative]: + boundary = context if ContextRelative else scopeRoot + if not isDescendantOrSelf(base, boundary): + error(SANDBOX_ESCAPE) + + handler = getHandler(parsed.facet) + if handler is null: + error(FACET_UNKNOWN) + + value = handler.resolve(base, parsed.qualifier, parsed.property) + + if parsed.typeHint and not matches(value, parsed.typeHint): + error(TYPE_MISMATCH) + + return value + +isDescendantOrSelf(node, boundary): + ; Returns true if node is the boundary itself, or a descendant of boundary + current = node + while current is not null: + if current == boundary: + return true + current = current.parent + return false +``` + +### 5.2 Errors + +| Code | Cause | +|------|-------| +| `PARSE_ERROR` | Invalid syntax, uppercase facet, unknown path escape, parent traversal, whitespace in qualifier | +| `LIMIT_EXCEEDED` | Reference exceeds size limits | +| `PERMISSION_DENIED` | Absolute path without permission | +| `SANDBOX_ESCAPE` | Path escaped allowed boundary | +| `NODE_NOT_FOUND` | Hierarchy unresolvable | +| `FACET_UNKNOWN` | Unrecognized facet | +| `QUALIFIER_INVALID` | Malformed qualifier | +| `COMPONENT_NOT_FOUND` | Component not on node | +| `PROPERTY_NOT_FOUND` | Property doesn't exist | +| `INDEX_OUT_OF_BOUNDS` | Array index invalid | +| `TYPE_MISMATCH` | Value doesn't match hint | + +--- + +## 6. Engine Mappings + +### 6.1 Unity + +| XProp | C# | +|-------|-----| +| `./Child::` | `transform.Find("Child")` | +| `#/Path/Node::` | `scopeRoot.transform.Find("Path/Node")` | +| `/Path/Node::` | `GameObject.Find("/Path/Node")` | +| `./::xform:position` | `transform.localPosition` | +| `./::xform:rotation.euler.y` | `transform.localEulerAngles.y` | +| `./::mat(0):color` | `renderer.materials[0].color` | +| `./::script(T):prop` | `GetComponent().prop` | +| `./::script(T,1):prop` | `GetComponents()[1].prop` | +| `./::render:visible` | `renderer.enabled` | + +**Example Unity behaviours (using PascalCase type names):** + +```txt +; Physics +./::script(Rigidbody):velocity +./::script(Rigidbody):mass +./::script(Rigidbody):useGravity + +; Audio +./::script(AudioSource):volume +./::script(AudioSource):pitch +./::script(AudioSource):mute + +; Rendering +./::script(Light):color +./::script(Light):intensity +./::script(Camera):fieldOfView + +; Animation +./::script(Animator):speed +./::script(Animator):applyRootMotion + +; UI +./::script(Slider):value +./::script(Toggle):isOn +./::script(InputField):text +``` + +### 6.2 Blender + +TODO + +### 6.3 Godot + +TODO + +--- + +## 7. Security + +### 7.1 Sandboxing + +Implementations MUST: + +1. Reject paths escaping their boundary +2. Require explicit permission for absolute paths +3. Validate syntax before resolution +4. Enforce size limits +5. Reject parent traversal segments (`..`) + +### 7.2 Script and Extension Facet Access + +For `@script` and custom extension facets, implementations MUST NOT rely solely on engine serialization/visibility attributes for access control. In untrusted UGC contexts: + +- Implementations SHOULD maintain an explicit allowlist of exposed properties +- Properties not on the allowlist MUST NOT be accessible +- Allowlists SHOULD be defined per-component-type + +This prevents accidental exposure of sensitive serialized fields (API keys, internal state, etc.) that happen to be marked serializable for editor purposes. + +Reserved facets (`xform`, `mat`, `render`) expose well-defined property sets and do not require additional allowlisting. + +--- + +## 9. Regular Expression + +For implementations that prefer regex-based tokenization, the following pattern captures the major tokens. Note that this regex performs initial extraction only; implementations MUST still validate limits, escape sequences, and detailed syntax rules. + +EDITOR NOTE: this is a wip, use carefully. + +```regex +^(?:(?\.\/(?(?:[^:\[\]]+|\[[^\]]*\])*))|(?#\/(?(?:[^:\[\]]+|\[[^\]]*\])*))|(?\/(?(?:[^:\[\]]+|\[[^\]]*\])+)))::(?[a-z0-9_]+)(?:\((?[^)]*)\))?(?:<(?[A-Za-z][A-Za-z0-9_]*)>)?:(?.+)$ +``` + +**Named capture groups:** + +| Group | Description | +|-------|-------------| +| `ctx_rel` | Full context-relative prefix (`./...`) | +| `ctx_path` | Path portion of context-relative reference (may be empty) | +| `scope_rel` | Full scope-relative prefix (`#/...`) | +| `scope_path` | Path portion of scope-relative reference (may be empty) | +| `abs` | Full absolute prefix (`/...`) | +| `abs_path` | Path portion of absolute reference (must not be empty) | +| `facet` | Facet name (lowercase) | +| `qualifier` | Raw qualifier content (without parens, may contain escapes) | +| `type` | Type hint name | +| `property` | Property path | + +**Limitations:** + +- Does not validate bracketed segment escapes +- Does not validate qualifier parameter escapes +- Does not validate array index format +- Does not enforce limits +- Does not validate property path structure +- Does not detect invalid path segments like `..` (parent traversal) +- Will match structurally-correct but semantically-invalid references + +--- + +## 10. Examples + +### 10.1 Self-Reference + +```txt +./::xform:position ; my position +./::xform:scale.uniform ; my uniform scale +./::render:visible ; am I visible? +./::script(Health):current ; my health (PascalCase component) +``` + +### 10.2 Children of Context + +```txt +./Hand::xform:rotation +./Hand/Index/Tip::xform:position +./Effects/Glow::render:visible +./Mesh::mat(0):opacity +``` + +### 10.3 Scope-Relative (UGC Sandbox) + +```txt +#/Body/Head::xform:rotation.euler +#/Body/LeftArm/Hand::mat(0):color +#/Scripts::script(AvatarController):speed +#/::render:visible ; scope root itself +``` + +### 10.4 Absolute (Privileged) + +```txt +/World/Sun::xform:rotation.euler +/UI/HUD/HealthBar::script(Slider):value +/GameManager::script(GameState):isPaused +``` + +### 10.5 Complex Properties + +```txt +./::script(Inventory):slots[0].count +./::script(Inventory):slots[0].item.name +./::script(Inventory):slots[0].item.stats.damage +./::script(QuestLog):quests[0].objectives[2].done +``` + +### 10.6 Escaped Path Names + +```txt +#/[My Node]::xform:position ; "My Node" +#/[path/to/thing]::render:visible ; "path/to/thing" (single segment) +#/[user@email.com]::script(Profile):id +#/[bracket~1test]::render:visible ; "bracket]test" +#/[open~2close]::render:visible ; "open[close" +#/[tilde~0here]::render:visible ; "tilde~here" +``` + +### 10.7 Escaped Qualifier Parameters + +```txt +./::script(res://scripts/player.gd):speed ; Godot resource path (no escapes needed) +./::script(My~3Component):value ; Component named "My,Component" +./::script(Func~4x~5):result ; Component named "Func(x)" +``` + +### 10.8 Unity Behaviours + +```txt +; Physics +./::script(Rigidbody):velocity +./::script(Rigidbody):mass +./::script(Rigidbody):useGravity + +; Audio +./::script(AudioSource):volume +./::script(AudioSource):pitch +./::script(AudioSource):mute + +; Rendering +./::script(Light):color +./::script(Light):intensity +./::script(Camera):fieldOfView + +; Animation +./::script(Animator):speed +./::script(Animator):applyRootMotion + +; UI +./::script(Slider):value +./::script(Toggle):isOn +./::script(InputField):text +``` + +--- + +## Appendix A: Implementation Guidance + +### A.1 Reference Caching + +Implementations SHOULD cache parsed references and resolved bindings. XProp references are typically static strings that resolve to the same targets throughout a session. + +### A.2 Error Handling + +Implementations should distinguish between: + +1. **Parse-time errors**: Detected during `parse()`, independent of scene state +2. **Resolution-time errors**: Detected during `resolve()`, dependent on scene state + +Parse-time errors indicate malformed references or incompatable facets and are typically programming errors. Resolution-time errors may be transient (node not yet loaded) or permanent (node deleted). + +### A.3 Type Coercion + +When the resolved value type differs from the type hint, implementations MAY perform safe coercions: + +| From | To | Coercion | +|------|----|----------| +| `int` | `float` | Widen | +| `float` | `int` | Truncate (with warning) | +| `float3` | `float4` | Extend with w=0 | + +Implementations SHOULD NOT silently coerce between incompatible types (e.g., `string` to `float`). + +### A.4 Roundtrip Normalization + +Implementations MAY normalize references during roundtrip (`parse` -> `str`). The following normalizations are semantically equivalent and permitted: + +- Removing unnecessary quoting: `#/[Simple]@...` -> `#/Simple@...` +- Canonicalizing escape sequences + +Escaped characters inside bracketed segments are unescaped during parsing and re-escaped (if needed) during serialization. An implementation that parses `#/[test~1node]@...` will store the segment as `test]node` internally, and may serialize it back as `#/[test~1node]@...`. + +--- + +## Appendix B: Quick Reference + +```txt +┌─────────────────────────────────────────────────────────────────┐ +│ XProp v0.1.0 │ +├─────────────────────────────────────────────────────────────────┤ +│ SYNTAX │ +│ prefix[path]::facet(qualifier):property[idx].sub │ +│ │ +│ PATH PREFIX │ +│ ./::... context node (self) │ +│ ./path::... relative to context │ +│ #/::... scope root itself │ +│ #/path::... relative to scope root │ +│ /path::... absolute (needs permission) │ +│ │ +│ FACETS (reserved, lowercase only) │ +│ xform position, rotation, scale │ +│ mat(slot) color, metallic, roughness... │ +│ script(Type,idx) component properties │ +│ render visible, pickable... │ +│ │ +│ QUALIFIER PARAMETERS │ +│ May use PascalCase for engine type names (e.g., Rigidbody) │ +│ │ +│ PATH ESCAPES (inside [...] only) │ +│ ~0 = ~ ~1 = ] ~2 = [ │ +│ │ +│ QUALIFIER ESCAPES │ +│ ~3 = , ~4 = ( ~5 = ) │ +│ │ +│ PROPERTY NAMES │ +│ Must start with: letter or underscore │ +│ Can contain: letters, digits, underscores, hyphens │ +│ Cannot contain: brackets [ ] or dots . │ +│ Examples: position, my-property, _private, player2 │ +│ │ +│ TYPES (must start with letter) │ +│ bool int float string │ +│ float2 float3 float4 quat rgba rgb │ +│ │ +│ ACCESSORS │ +│ Vectors: .x .y .z .w │ +│ Colors: .r .g .b .a │ +│ Arrays: [0] [1] [2] (no leading zeros, no multidimensional) │ +│ │ +│ LIMITS │ +│ Total length: 1024 Path depth: 32 Segment: 128 │ +│ Property depth: 16 Array index: 65535 │ +│ │ +│ NOT PERMITTED │ +│ Uppercase facets Parent traversal (..) Leading zeros │ +│ Multidimensional arrays [0][1] Brackets in property names │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Changelog + +### v0.1.0 + +- Initial draft diff --git a/Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropSpec.md.meta b/Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropSpec.md.meta new file mode 100644 index 000000000..439ad3fe5 --- /dev/null +++ b/Basis/Packages/com.basis.avatar-state/Runtime/XProp/XPropSpec.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: f3bc61359c0d878439d1afe3a65669a0 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Basis/Packages/com.basis.avatar-state/Tests.meta b/Basis/Packages/com.basis.avatar-state/Tests.meta new file mode 100644 index 000000000..00ddc7bf3 --- /dev/null +++ b/Basis/Packages/com.basis.avatar-state/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e4d3e5b2c66d87e41a261b5682ccb0ff +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Basis/Packages/com.basis.avatar-state/Tests/Runtime.meta b/Basis/Packages/com.basis.avatar-state/Tests/Runtime.meta new file mode 100644 index 000000000..5c3d2e568 --- /dev/null +++ b/Basis/Packages/com.basis.avatar-state/Tests/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 912c55c8dc4e5ca459ce35bb8262b5de +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Basis/Packages/com.basis.avatar-state/Tests/Runtime/Basis.AvatarState.Tests.asmdef b/Basis/Packages/com.basis.avatar-state/Tests/Runtime/Basis.AvatarState.Tests.asmdef new file mode 100644 index 000000000..80f7ceb1e --- /dev/null +++ b/Basis/Packages/com.basis.avatar-state/Tests/Runtime/Basis.AvatarState.Tests.asmdef @@ -0,0 +1,11 @@ +{ + "name": "Basis.AvatarState.Tests", + "references": [ + "Basis.AvatarState" + ], + "optionalUnityReferences": [ + "TestAssemblies" + ], + "includePlatforms": [], + "excludePlatforms": [] +} diff --git a/Basis/Packages/com.basis.avatar-state/Tests/Runtime/Basis.AvatarState.Tests.asmdef.meta b/Basis/Packages/com.basis.avatar-state/Tests/Runtime/Basis.AvatarState.Tests.asmdef.meta new file mode 100644 index 000000000..03a9589b4 --- /dev/null +++ b/Basis/Packages/com.basis.avatar-state/Tests/Runtime/Basis.AvatarState.Tests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: dd21d601380896f428b90b604d0f0e9c +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Basis/Packages/com.basis.avatar-state/package.json b/Basis/Packages/com.basis.avatar-state/package.json new file mode 100644 index 000000000..254962b4c --- /dev/null +++ b/Basis/Packages/com.basis.avatar-state/package.json @@ -0,0 +1,19 @@ +{ + "name": "com.basis.avatar-state", + "displayName":"Basis Avatar State", + "version": "0.1.0", + "unity": "6000.3", + "unityRelease": "0f1", + "description": "Replace this with your own description of the package. \n\nFor best results, use this text to summarize: \n\u25AA What the package does \n\u25AA How it can benefit the user \n\nNote: Special formatting characters are supported, including line breaks ('\\n') and bullets ('\\u25AA').", + "dependencies": { + "com.unity.test-framework": "1.6.0" + }, + "author": { + "name": "mriise", + "url": "http://www.example.com", + "email": "me@mriise.net" + }, + "changelogUrl": "https://example.com/changelog.html", + "documentationUrl": "https://example.com/", + "licensesUrl": "https://example.com/licensing.html" +} diff --git a/Basis/Packages/com.basis.avatar-state/package.json.meta b/Basis/Packages/com.basis.avatar-state/package.json.meta new file mode 100644 index 000000000..6f4432023 --- /dev/null +++ b/Basis/Packages/com.basis.avatar-state/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 35e3049ab6611c449bad10eedd856919 +PackageManifestImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From e687f020ce75640635d47b87d1cf8274f9fbacc9 Mon Sep 17 00:00:00 2001 From: mriise Date: Fri, 12 Dec 2025 03:32:19 -0800 Subject: [PATCH 2/2] chore: add goals to readme --- Basis/Packages/com.basis.avatar-state/README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Basis/Packages/com.basis.avatar-state/README.md b/Basis/Packages/com.basis.avatar-state/README.md index 0a5eff2e6..490c7df4a 100644 --- a/Basis/Packages/com.basis.avatar-state/README.md +++ b/Basis/Packages/com.basis.avatar-state/README.md @@ -4,3 +4,19 @@ ## !!Experimental!! Library and tooling to transmit, store, apply changes to an avatar. + +## Goals of Avatar State + +### No Complex Behavior + +It is a non-goal to handle complex behavior. Complex behavior should be written in a script or some other dynamic, application or future system. It will handle value updates on various aspects of an avatar and not much more. + +### Readable and Portable Serialization + +Avatar authors should easily be able to debug and modify properties on their clothing, textures, scripts, and shaders. This means _what_ is being modified should be easily understood, which unity animation name is not. (see XProp) + +Ideally authors should be able to build these directly in the context of third party tools like blender. This means much of the serialized values should be stored as JSON (or easily converted to/from). + +### Sparse Wire Format + +Avatar state is shared sparsely (deltas) to keep bandwidth usage low. Avatar state should include a manifest of what _can_ be synced/modified. Syncing should only be sending updates by reference to that manifest and the new value. This means late joiners or lost events requires server stored state.