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", + ]