From d52e68c5e23d5b00174932b35f3fd35b71f42e64 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Thu, 15 Oct 2026 10:40:45 +0800 Subject: [PATCH] feat(unity): name clip bindings in the hierarchy that plays the clip nnnotes.unity.bindings.resolve gives, for the bindings of an AnimationClip document, the path and the attribute each names in a prefab.json hierarchy played by the Animator on a given GameObject: paths relative to that GameObject by their crc32, attributes among the field paths of the bound component (a MonoBehaviour's by its script), the properties of a renderer's materials for a material binding, and the engine property names. A binding that no name, or several, fit stays unresolved; several fitting paths are listed. The binding name tables, field path listing and the material attribute bit layout live in this module, and the typetree exporter uses them from there. Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 2 +- src/nnnotes/content/storyhost.py | 3 +- src/nnnotes/unity/bindings.py | 251 +++++++++++++++++++++++++++++++ src/nnnotes/unity/export.py | 117 +------------- tests/unity/test_bindings.py | 149 ++++++++++++++++++ 5 files changed, 408 insertions(+), 114 deletions(-) create mode 100644 src/nnnotes/unity/bindings.py create mode 100644 tests/unity/test_bindings.py diff --git a/docs/architecture.md b/docs/architecture.md index c55d9c0..21d43e3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,7 +12,7 @@ subpackage of `nnnotes` and imports only the layers below it. | Products | `nnnotes.site`, `nnnotes.musicdata` | the ournotes-player site (charts, models, stories, indexes); the music data file and its deck statistics | | Pipeline | `nnnotes.pipeline` | stages, task descriptions and results, the content-addressed depot, plans, layouts, the catalog versions, the views of the master data | | Content | `nnnotes.content` | the game's subjects as exports: ADV episodes and stories, lives and scores, spots, rooms, the UI library | -| Unity objects | `nnnotes.unity` | bundle reading, object ids, the atoms (the pure functions objects are converted with: texture decoding, sprite images, mesh arrays, glTF, PNG, file sniffing), the converters (one per Unity class, `nnnotes.unity.convert`), the type trees shipped for the player's script classes (`nnnotes.unity.scripttypes`), the typetree exporter, the player data, Live2D prefabs, text styles and TextMesh Pro fonts | +| Unity objects | `nnnotes.unity` | bundle reading, object ids, the atoms (the pure functions objects are converted with: texture decoding, sprite images, mesh arrays, glTF, PNG, file sniffing), the converters (one per Unity class, `nnnotes.unity.convert`), clip bindings named in the hierarchy that plays a clip (`nnnotes.unity.bindings`), the type trees shipped for the player's script classes (`nnnotes.unity.scripttypes`), the typetree exporter, the player data, Live2D prefabs, text styles and TextMesh Pro fonts | | File formats | `nnnotes.formats` | formats read from bytes: CRI ACB/AWB and HCA, USM movies, CRI Lips data, shaders, motions | | Sources | `nnnotes.source` | the configuration, the APK, Addressables catalogs and bundles, the game API, master data, languages, `Session` | | Core | `nnnotes.core` | the one JSON writer, canonical JSON and its hashes, media types, the process caches and the downloads cache | diff --git a/src/nnnotes/content/storyhost.py b/src/nnnotes/content/storyhost.py index 9c51813..462af37 100644 --- a/src/nnnotes/content/storyhost.py +++ b/src/nnnotes/content/storyhost.py @@ -39,8 +39,9 @@ from ..formats import shader as shader_mod from ..source import languages, master from ..unity import fontsettings, textstyle, tmpfont +from ..unity.bindings import RECT_PROPS from ..unity.convert.animation import streamed_frames -from ..unity.export import RECT_PROPS, Exporter, TexelPacker +from ..unity.export import Exporter, TexelPacker from ..unity.reader import SceneGraph, deref, mesh_arrays, referent, script_class from . import advui, room, spot, storyfonts diff --git a/src/nnnotes/unity/bindings.py b/src/nnnotes/unity/bindings.py new file mode 100644 index 0000000..7c8cc5d --- /dev/null +++ b/src/nnnotes/unity/bindings.py @@ -0,0 +1,251 @@ +"""Clip bindings named in the hierarchy that plays the clip. + +A binding of an AnimationClip (clip.json `bindings`, convert.animation.clip_doc) names what it animates by crc32 +values: `pathCrc`, the path of the animated object relative to the GameObject whose Animator plays the clip, and +`attributeCrc`, the animated property. Only that hierarchy names them. `resolve` takes the bindings, the hierarchy +(a prefab.json document: its nodes with their paths and components) and the path of the Animator's GameObject in it, +and gives per binding the path and the attribute it names, or None where no name, or several, fit. + +Path: the crc32 of the path, relative to the Animator's GameObject, of a transform below it ("" for itself). A path +no transform below it has stays unresolved: the Animator leaves such a binding unbound (an object parented there at +run time aside). + +Attribute, from the bound component (the components of the binding's class on the GameObjects at its path; for a +MonoBehaviour those of its script): +- a Transform binding's attribute the clip names already (convert.animation.TRANSFORM_ATTR); +- a renderer material binding (customType 22) names a property of the renderer's materials (`material_attribute`); +- any other binding names a field path of the bound component (`flat_names` of its fields), else one of the engine + properties (ENGINE_PROPS, RECT_PROPS), a material property (`material_prop_names`) or a field path of another + component of its class in the hierarchy. +""" + +from __future__ import annotations + +import zlib +from collections.abc import Callable, Iterable + +from UnityPy.enums import ClassIDType + +__all__ = [ + "ENGINE_PROPS", + "MATERIAL_PROPS", + "RECT_PROPS", + "RENDERER_MATERIAL", + "crc", + "flat_names", + "material_attribute", + "material_prop_names", + "resolve", +] + +RENDERER_MATERIAL = 22 # GenericBinding.customType of a renderer's material property +# Animatable property names a binding can name that are no field path of a component's fields: common component +# properties and RectTransform driven fields. +ENGINE_PROPS = ( + "m_IsActive", + "m_Enabled", + "m_Color.r", + "m_Color.g", + "m_Color.b", + "m_Color.a", + "m_Size.x", + "m_Size.y", + "m_FlipX", + "m_FlipY", + "m_SortingOrder", + "m_Sprite", + "m_Alpha", + "m_SizeDelta.x", + "m_SizeDelta.y", + "m_AnchoredPosition.x", + "m_AnchoredPosition.y", + "m_LocalPosition.x", + "m_LocalPosition.y", + "m_LocalPosition.z", + "m_LocalScale.x", + "m_LocalScale.y", + "m_LocalScale.z", +) +RECT_PROPS = ( + "m_AnchoredPosition.x", + "m_AnchoredPosition.y", + "m_SizeDelta.x", + "m_SizeDelta.y", + "m_AnchorMin.x", + "m_AnchorMin.y", + "m_AnchorMax.x", + "m_AnchorMax.y", + "m_Pivot.x", + "m_Pivot.y", +) +# material properties a binding can name as "material.[.]" (renderer material property blocks) +MATERIAL_PROPS = ( + "_Color", + "_TintColor", + "_MainTex_ST", + "_BaseColor", + "_EmissionColor", + "_Alpha", + "_Cutoff", + "_Intensity", + "_Offset", + "_Scroll", + "_ScrollX", + "_ScrollY", + "_Power", + "_Value", + "_Glow", + "_GlowColor", + "_AddColor", + "_MultiplyColor", + "_Progress", + "_Fade", + "_Brightness", + "_Dissolve", + "_MainTex", + "_Speed", + "_Rotation", +) +_CHANNELS = ("r", "g", "b", "a", "x", "y", "z", "w") + + +def crc(s: str) -> int: + """The crc32 a binding names a path or an attribute by.""" + return zlib.crc32(s.encode("utf-8")) + + +def flat_names(tt, prefix: str = "") -> list[str]: + """Every field path of a typetree or of an object's fields ("m_Color", "m_Color.r", "m_Enabled", ...); lists are + leaves.""" + out = [] + if isinstance(tt, dict): + for k, v in tt.items(): + p = f"{prefix}.{k}" if prefix else k + out.append(p) + out.extend(flat_names(v, p)) + return out + + +def material_prop_names(extra: Iterable[str] = ()) -> list[str]: + """The attributes of material properties (MATERIAL_PROPS and `extra`): "material." and every channel.""" + out = [] + for p in sorted(set(MATERIAL_PROPS) | set(extra)): + out.append(f"material.{p}") + out.extend(f"material.{p}.{c}" for c in _CHANNELS) + return out + + +def material_attribute(attribute: int, kinds: dict[str, str]) -> str | None: + """The attribute a renderer material binding (customType 22) names, "material.[.]", among + the properties of the renderer's materials `kinds` {name: "float" | "vector"} (a colour is a vector); None when + no property, or several, fit. The attribute is (crc32(property) & 0x0FFFFFFF) | channel << 28 with bit 30 set + for a colour channel (r, g, b, a) and clear for a vector channel (x, y, z, w) or a float property.""" + hits = [k for k in kinds if crc(k) & 0x0FFFFFFF == attribute & 0x0FFFFFFF] + if len(hits) != 1: + return None + name, channel, colour = hits[0], (attribute >> 28) & 3, (attribute >> 30) & 1 + if kinds[name] == "float" and not colour and channel == 0: + return f"material.{name}" + return f"material.{name}.{('rgba' if colour else 'xyzw')[channel]}" + + +def _class_name(type_id: int) -> str | None: + try: + return ClassIDType(type_id).name + except ValueError: + return None + + +class _Hierarchy: + """A prefab document's nodes by path and the crc32 tables of paths relative to a GameObject.""" + + def __init__(self, prefab: dict): + self.nodes: dict[str, list[dict]] = {} + for n in prefab["nodes"]: + self.nodes.setdefault(n["path"], []).append(n) + self._tables: dict[str, dict[int, set[str]]] = {} + self._names: dict[object, set[str]] = {} + + def relative(self, root: str) -> dict[int, set[str]]: + t = self._tables.get(root) + if t is None: + if root not in self.nodes: + raise KeyError(f"the Animator's GameObject {root!r} is not in the hierarchy") + t = {} + for path in self.nodes: + if path == root or path.startswith(root + "/"): + rel = path[len(root) + 1 :] + t.setdefault(crc(rel), set()).add(rel) + self._tables[root] = t + return t + + def class_names(self, match: Callable[[dict], bool], key) -> set[str]: + """The field paths of every component of the hierarchy `match` accepts (cached by `key`).""" + names = self._names.get(key) + if names is None: + names = self._names[key] = { + x + for ns in self.nodes.values() + for n in ns + for c in n["components"] + if match(c) + for x in flat_names(c.get("fields")) + } + return names + + +def resolve( + bindings: list[dict], prefab: dict, root: str, material: Callable[[object], dict | None] = lambda ref: None +) -> list[dict]: + """[{path, attribute}] of clip bindings (module documentation) in the hierarchy of `prefab` (a prefab.json + document) played by the Animator on the GameObject at `root` (a node path); `pathCandidates` (sorted) where + several paths fit. `material(ref)`: the material.json document of a reference a renderer's m_Materials holds (None + when unknown).""" + h = _Hierarchy(prefab) + table = h.relative(root) + out = [] + for b in bindings: + cands = table.get(b["pathCrc"], set()) + path = next(iter(cands)) if len(cands) == 1 else None + r: dict = {"path": path, "attribute": b.get("attribute")} + if len(cands) > 1: + r["pathCandidates"] = sorted(cands) + if r["attribute"] is None: + r["attribute"] = _attribute(h, b, root, path, material) + out.append(r) + return out + + +def _attribute(h: _Hierarchy, b: dict, root: str, path: str | None, material) -> str | None: + tid = b["typeID"] + if tid == 114: + script = b.get("scriptClass") + + def match(c: dict) -> bool: + return c["type"] == "MonoBehaviour" and script is not None and c.get("script") == script + + key: object = ("script", script) + else: + cls = b.get("class") or _class_name(tid) + + def match(c: dict) -> bool: + return c["type"] == cls + + key = cls + nodes = h.nodes.get(root if path == "" else f"{root}/{path}", []) if path is not None else [] + bound = [c for n in nodes for c in n["components"] if match(c)] + if b.get("customType") == RENDERER_MATERIAL: + kinds: dict[str, str] = {} + for c in bound: + for ref in (c.get("fields") or {}).get("m_Materials") or (): + m = material(ref) if ref is not None else None + if m is not None: + kinds.update({k: "float" for k in m["floats"]}) + kinds.update({k: "vector" for k in m["colors"]}) + return material_attribute(b["attributeCrc"], kinds) + a = b["attributeCrc"] + hit = {x for c in bound for x in flat_names(c.get("fields")) if crc(x) == a} + if len(hit) != 1: + names = set(ENGINE_PROPS) | set(RECT_PROPS) | set(material_prop_names()) | h.class_names(match, key) + hit = {x for x in names if crc(x) == a} + return next(iter(hit)) if len(hit) == 1 else None diff --git a/src/nnnotes/unity/export.py b/src/nnnotes/unity/export.py index 4b0eb68..2f8b1cc 100644 --- a/src/nnnotes/unity/export.py +++ b/src/nnnotes/unity/export.py @@ -26,7 +26,6 @@ import json import math import re -import zlib from pathlib import Path from typing import Literal @@ -37,6 +36,7 @@ from ..core import cache from ..source.catalog import Catalog +from .bindings import ENGINE_PROPS, RECT_PROPS, crc, flat_names, material_attribute, material_prop_names from .convert.animation import CLASS_ID, TRANSFORM_ATTR, controller_doc, streamed_frames from .convert.material import material_doc from .reader import ( @@ -116,100 +116,6 @@ def _rid(o, name: str) -> str: return f"{name}#{hashlib.sha1(f'{o.assets_file.name}:{o.path_id}'.encode()).hexdigest()[:8]}" -def _crc(s: str) -> int: - return zlib.crc32(s.encode("utf-8")) - - -def flat_names(tt, prefix: str = "") -> list[str]: - """Every field path of a typetree ("m_Color", "m_Color.r", "m_Enabled", ...); lists are leaves.""" - out = [] - if isinstance(tt, dict): - for k, v in tt.items(): - p = f"{prefix}.{k}" if prefix else k - out.append(p) - out.extend(flat_names(v, p)) - return out - - -# --- clip bindings ------------------------------------------------------------ -# Animatable property names a binding can name (attribute = crc32 of the name) that are not field paths of a -# component the exporter has seen: common component properties, RectTransform driven fields, and material -# properties ("material.[.]", renderer material property blocks). -ENGINE_PROPS = ( - "m_IsActive", - "m_Enabled", - "m_Color.r", - "m_Color.g", - "m_Color.b", - "m_Color.a", - "m_Size.x", - "m_Size.y", - "m_FlipX", - "m_FlipY", - "m_SortingOrder", - "m_Sprite", - "m_Alpha", - "m_SizeDelta.x", - "m_SizeDelta.y", - "m_AnchoredPosition.x", - "m_AnchoredPosition.y", - "m_LocalPosition.x", - "m_LocalPosition.y", - "m_LocalPosition.z", - "m_LocalScale.x", - "m_LocalScale.y", - "m_LocalScale.z", -) -RECT_PROPS = ( - "m_AnchoredPosition.x", - "m_AnchoredPosition.y", - "m_SizeDelta.x", - "m_SizeDelta.y", - "m_AnchorMin.x", - "m_AnchorMin.y", - "m_AnchorMax.x", - "m_AnchorMax.y", - "m_Pivot.x", - "m_Pivot.y", -) -MATERIAL_PROPS = ( - "_Color", - "_TintColor", - "_MainTex_ST", - "_BaseColor", - "_EmissionColor", - "_Alpha", - "_Cutoff", - "_Intensity", - "_Offset", - "_Scroll", - "_ScrollX", - "_ScrollY", - "_Power", - "_Value", - "_Glow", - "_GlowColor", - "_AddColor", - "_MultiplyColor", - "_Progress", - "_Fade", - "_Brightness", - "_Dissolve", - "_MainTex", - "_Speed", - "_Rotation", -) - - -def material_prop_names(extra) -> list[str]: - out = [] - for p in sorted(set(MATERIAL_PROPS) | set(extra)): - out.append(f"material.{p}") - for c in ("r", "g", "b", "a", "x", "y", "z", "w"): - out.append(f"material.{p}.{c}") - return out - - def _events(tt: dict) -> list[dict]: return [ {x: e[x] for x in ("time", "functionName", "data", "floatParameter", "intParameter", "messageOptions")} @@ -822,7 +728,7 @@ def _skin(m, tt: dict, vertex_count: int) -> dict: def _crc_of(self, name: str) -> int: c = self._crcs.get(name) if c is None: - c = self._crcs[name] = _crc(name) + c = self._crcs[name] = crc(name) return c def _names(self, o, tt: dict | None = None) -> list[str]: @@ -932,15 +838,8 @@ def _bound_names(self, r: dict) -> set[str]: return names def _material_attribute(self, r: dict) -> str | None: - """Attribute of a renderer material property binding (customType 22, RendererMaterial): - attribute = (crc32(property) & 0x0FFFFFFF) | channel << 28, bit 30 set for colour channels (r, g, b, a), - clear for vector channels (x, y, z, w) or a float property. The property is one of the materials of the - renderer the binding names; returns "material.[.]" (Unity's property path). - The bit layout is native engine code (not in the C# reference); the layout above is the one the data - shows: the four _TintColor bindings of the live intro's lane_mesh carry 4..7 << 28 over - crc32("_TintColor") & 0x0FFFFFFF, with 4..6 constant (0.538, 0.510, 2.040: the effect's _TintColor rgb) - and 7 animated 0 -> 1 -> 0 (alpha).""" - a = r["attributeCrc"] + """Attribute of a renderer material property binding (customType 22): a property of the materials of the + renderer the binding names (bindings.material_attribute).""" kinds: dict[str, str] = {} for g, go_pid in self._binding_gos(r): for c in g.go[go_pid]["m_Component"]: @@ -954,13 +853,7 @@ def _material_attribute(self, r: dict) -> str | None: sp = mo.read_typetree()["m_SavedProperties"] kinds.update({k: "float" for k, _ in sp.get("m_Floats", [])}) kinds.update({k: "vector" for k, _ in sp.get("m_Colors", [])}) - hits = [k for k in kinds if self._crc_of(k) & 0x0FFFFFFF == a & 0x0FFFFFFF] - if len(hits) != 1: - return None - name, channel, colour = hits[0], (a >> 28) & 3, (a >> 30) & 1 - if kinds[name] == "float" and not colour and channel == 0: - return f"material.{name}" - return f"material.{name}.{('rgba' if colour else 'xyzw')[channel]}" + return material_attribute(r["attributeCrc"], kinds) def _class_names(self, r: dict) -> set[str]: """Names any binding of the class can target: engine properties, material properties of the diff --git a/tests/unity/test_bindings.py b/tests/unity/test_bindings.py new file mode 100644 index 0000000..c10f216 --- /dev/null +++ b/tests/unity/test_bindings.py @@ -0,0 +1,149 @@ +"""Clip bindings named in a synthetic prefab hierarchy (nnnotes.unity.bindings).""" + +import pytest + +from nnnotes.unity import bindings +from nnnotes.unity.bindings import crc + +PARAM = "Live2D.Cubism|Live2D.Cubism.Core|CubismParameter" +PART = "Live2D.Cubism|Live2D.Cubism.Core|CubismPart" +MAT = {"$ref": {"file": "CAB-m", "pathId": 7, "class": "Material"}} + + +def mono(script, **fields): + return {"type": "MonoBehaviour", "pathId": 1, "script": script, "fields": fields} + + +def node(path, *components): + return {"path": path, "name": path.rsplit("/", 1)[-1], "components": list(components)} + + +def prefab(): + return { + "name": "model", + "nodes": [ + node("model", mono("Live2D.Cubism|Live2D.Cubism.Rendering|CubismRenderController", Opacity=1.0)), + node("model/Parameters"), + node("model/Parameters/ParamAngleX", mono(PARAM, Value=0.0, MinimumValue=-30.0)), + node("model/Parameters/ParamAngleY", mono(PARAM, Value=0.0, MinimumValue=-30.0)), + node("model/Parts"), + node("model/Parts/PartArm", mono(PART, Opacity=1.0)), + node("model/Drawables"), + node( + "model/Drawables/ArtMesh0", + {"type": "MeshRenderer", "pathId": 2, "fields": {"m_Enabled": 1, "m_Materials": [MAT, None]}}, + ), + node("model/Twin"), + node("model/Twin"), + node("other"), + ], + } + + +def binding(path, attr, type_id=114, script=None, **kw): + b = {"pathCrc": crc(path), "attributeCrc": crc(attr) if isinstance(attr, str) else attr} + b.update(typeID=type_id, customType=0, pptr=False, int=False, serializeReference=False) + if script is not None: + b.update(script={"$ref": {"file": "CAB-s", "pathId": 3}}, scriptClass=script) + b.update(kw) + return b + + +def test_paths_are_relative_to_the_animators_game_object(): + out = bindings.resolve( + [ + binding("Parameters/ParamAngleX", "Value", script=PARAM), + binding("Parts/PartArm", "Opacity", script=PART), + binding("", "Opacity", script="Live2D.Cubism|Live2D.Cubism.Rendering|CubismRenderController"), + binding("Parameters/ParamGone", "Value", script=PARAM), + ], + prefab(), + "model", + ) + assert out == [ + {"path": "Parameters/ParamAngleX", "attribute": "Value"}, + {"path": "Parts/PartArm", "attribute": "Opacity"}, + {"path": "", "attribute": "Opacity"}, + {"path": None, "attribute": "Value"}, # not below the Animator: a field of the class in the hierarchy + ] + below = bindings.resolve([binding("ParamAngleY", "Value", script=PARAM)], prefab(), "model/Parameters") + assert below == [{"path": "ParamAngleY", "attribute": "Value"}] + + +def test_a_path_two_transforms_share_names_them_both(): + (r,) = bindings.resolve([binding("Twin", "m_IsActive", type_id=1)], prefab(), "model") + assert r == {"path": "Twin", "attribute": "m_IsActive"} # an engine property + + +def test_the_bound_component_is_the_bindings_script(): + other = "Game|Game|Other" + (r,) = bindings.resolve([binding("Parameters/ParamAngleX", "MinimumValue", script=other)], prefab(), "model") + assert r == {"path": "Parameters/ParamAngleX", "attribute": None} # no Other there, nor anywhere + + +def test_a_transform_bindings_attribute_is_the_clips(): + b = binding("Parts", 2, type_id=4, attribute="m_LocalRotation", curves=4) + assert bindings.resolve([b], prefab(), "model") == [{"path": "Parts", "attribute": "m_LocalRotation"}] + + +def test_a_renderer_material_binding_names_a_property_of_its_materials(): + tint = crc("_TintColor") & 0x0FFFFFFF + docs = {7: {"floats": {"_Cutoff": 0.5}, "colors": {"_TintColor": {"r": 1, "g": 1, "b": 1, "a": 1}}}} + + def material(ref): + return docs.get(ref["$ref"]["pathId"]) + + out = bindings.resolve( + [ + binding( + "Drawables/ArtMesh0", tint | 3 << 28 | 1 << 30, type_id=23, customType=22, **{"class": "MeshRenderer"} + ), + binding("Drawables/ArtMesh0", tint | 1 << 28, type_id=23, customType=22), + binding("Drawables/ArtMesh0", crc("_Cutoff") & 0x0FFFFFFF, type_id=23, customType=22), + binding("Drawables/ArtMesh0", crc("_Other") & 0x0FFFFFFF, type_id=23, customType=22), + ], + prefab(), + "model", + material, + ) + assert [r["attribute"] for r in out] == ["material._TintColor.a", "material._TintColor.y", "material._Cutoff", None] + assert bindings.resolve([binding("Drawables/ArtMesh0", tint, type_id=23, customType=22)], prefab(), "model") == [ + {"path": "Drawables/ArtMesh0", "attribute": None} # its materials unknown + ] + + +def test_a_field_of_the_bound_renderer(): + (r,) = bindings.resolve([binding("Drawables/ArtMesh0", "m_Enabled", type_id=23)], prefab(), "model") + assert r == {"path": "Drawables/ArtMesh0", "attribute": "m_Enabled"} + + +def test_an_ambiguous_path_lists_its_candidates(monkeypatch): + monkeypatch.setattr(bindings, "crc", lambda s: 1 if s in ("Parts", "Drawables") else 0x7FFFFFFF) + (r,) = bindings.resolve([{"pathCrc": 1, "attributeCrc": 5, "typeID": 1}], prefab(), "model") + assert r == {"path": None, "attribute": None, "pathCandidates": ["Drawables", "Parts"]} + + +def test_the_animators_game_object_is_in_the_hierarchy(): + with pytest.raises(KeyError, match="not in the hierarchy"): + bindings.resolve([], prefab(), "nope") + + +def test_material_attribute_layout(): + kinds = {"_Color": "vector", "_Alpha": "float"} + c = crc("_Color") & 0x0FFFFFFF + assert bindings.material_attribute(c | 0 << 28 | 1 << 30, kinds) == "material._Color.r" + assert bindings.material_attribute(c | 2 << 28, kinds) == "material._Color.z" + assert bindings.material_attribute(crc("_Alpha") & 0x0FFFFFFF, kinds) == "material._Alpha" + assert bindings.material_attribute(crc("_Alpha") & 0x0FFFFFFF | 1 << 30, kinds) == "material._Alpha.r" + assert bindings.material_prop_names(["_Extra"])[:3] == [ + "material._AddColor", + "material._AddColor.r", + "material._AddColor.g", + ] + assert "material._Extra.w" in bindings.material_prop_names(["_Extra"]) + assert bindings.flat_names({"m_Color": {"r": 1, "g": 0}, "list": [1]}) == [ + "m_Color", + "m_Color.r", + "m_Color.g", + "list", + ]