nnnotes 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. nnnotes/__init__.py +7 -0
  2. nnnotes/__main__.py +4 -0
  3. nnnotes/acb.py +190 -0
  4. nnnotes/addressables.py +413 -0
  5. nnnotes/adv.py +243 -0
  6. nnnotes/advcommand.py +29 -0
  7. nnnotes/advmedia.py +178 -0
  8. nnnotes/advscene.py +113 -0
  9. nnnotes/advui.py +1154 -0
  10. nnnotes/advvideo.py +684 -0
  11. nnnotes/atoms/__init__.py +151 -0
  12. nnnotes/atoms/astc.py +52 -0
  13. nnnotes/atoms/gltf.py +290 -0
  14. nnnotes/atoms/mesh.py +289 -0
  15. nnnotes/atoms/png.py +28 -0
  16. nnnotes/atoms/reader.py +269 -0
  17. nnnotes/atoms/sniff.py +198 -0
  18. nnnotes/atoms/sprite.py +236 -0
  19. nnnotes/atoms/texture.py +80 -0
  20. nnnotes/cache.py +289 -0
  21. nnnotes/catalog.py +373 -0
  22. nnnotes/catalogdb.py +457 -0
  23. nnnotes/census.py +276 -0
  24. nnnotes/cli.py +744 -0
  25. nnnotes/cli_assets.py +1539 -0
  26. nnnotes/config.py +238 -0
  27. nnnotes/contract.py +497 -0
  28. nnnotes/cri.py +371 -0
  29. nnnotes/crikey.py +71 -0
  30. nnnotes/crilips.py +265 -0
  31. nnnotes/cristages.py +558 -0
  32. nnnotes/deckdata.py +462 -0
  33. nnnotes/export.py +1462 -0
  34. nnnotes/gameapi.py +318 -0
  35. nnnotes/jsonio.py +98 -0
  36. nnnotes/languages.py +44 -0
  37. nnnotes/layout.py +596 -0
  38. nnnotes/link.py +418 -0
  39. nnnotes/live.py +115 -0
  40. nnnotes/live2d.py +235 -0
  41. nnnotes/liveaudio.py +331 -0
  42. nnnotes/livenotes.py +291 -0
  43. nnnotes/liveoptions.py +250 -0
  44. nnnotes/livescene.py +450 -0
  45. nnnotes/liveui.py +328 -0
  46. nnnotes/master.py +251 -0
  47. nnnotes/modelstages.py +424 -0
  48. nnnotes/motion.py +131 -0
  49. nnnotes/objexport.py +1564 -0
  50. nnnotes/orchestrate.py +630 -0
  51. nnnotes/plan.py +218 -0
  52. nnnotes/player.py +392 -0
  53. nnnotes/room.py +391 -0
  54. nnnotes/score.py +1139 -0
  55. nnnotes/shader.py +217 -0
  56. nnnotes/spot.py +201 -0
  57. nnnotes/stages.py +285 -0
  58. nnnotes/store.py +518 -0
  59. nnnotes/story.py +142 -0
  60. nnnotes/storyfonts.py +1258 -0
  61. nnnotes/storyhost.py +1190 -0
  62. nnnotes/storysite.py +662 -0
  63. nnnotes/storyvoices.py +102 -0
  64. nnnotes/textstyle.py +355 -0
  65. nnnotes/tmpfont.py +636 -0
  66. nnnotes/typetrees/6000.3.12f1.json +19 -0
  67. nnnotes/unity.py +368 -0
  68. nnnotes/viewrules.json +441 -0
  69. nnnotes/views.py +966 -0
  70. nnnotes/voices.py +1363 -0
  71. nnnotes/web.py +1624 -0
  72. nnnotes/webaudio.py +14 -0
  73. nnnotes/webmodel.py +397 -0
  74. nnnotes-0.1.0.dist-info/METADATA +200 -0
  75. nnnotes-0.1.0.dist-info/RECORD +79 -0
  76. nnnotes-0.1.0.dist-info/WHEEL +5 -0
  77. nnnotes-0.1.0.dist-info/entry_points.txt +2 -0
  78. nnnotes-0.1.0.dist-info/licenses/LICENSE +21 -0
  79. nnnotes-0.1.0.dist-info/top_level.txt +1 -0
nnnotes/export.py ADDED
@@ -0,0 +1,1462 @@
1
+ """Typetree export with every reference resolved.
2
+
3
+ `Exporter` turns Unity objects into JSON-ready values: textures become PNG
4
+ files (or, in deferred mode, references whose texels a `TexelPacker` copies
5
+ later), sprites carry their mesh and UVs (texture-bound or packed in a
6
+ SpriteAtlas), materials carry shader name, keywords and properties, meshes carry
7
+ vertex data (optional), ScriptableObject MonoBehaviours are inlined once, and
8
+ references to GameObjects, Transforms and components become hierarchy paths.
9
+ Prefabs and scenes are exported as ordered node lists (hierarchy order,
10
+ components in GameObject order). AnimationClips carry the Mecanim clip data the
11
+ Animator samples (streamed cubic keys, dense samples, constants) with their
12
+ bindings resolved to relative paths and property names; AnimatorControllers
13
+ carry layers, states, transitions and parameters. `export_key` exports any
14
+ addressable key by the type of its container asset.
15
+
16
+ Clip, binding and controller JSON comes in several shapes (`clip_format`:
17
+ "generic", or "livescene" / "livenotes" for the live scene and live notes exports;
18
+ `clip_curves` and `controller_states` for the ADV UI). All of them are views of one record
19
+ (`clip_record` / `controller_record`), so resolution lives here only.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import io
25
+ import json
26
+ import math
27
+ import re
28
+ import struct
29
+ import zlib
30
+ from pathlib import Path
31
+
32
+ import numpy as np
33
+ from PIL import Image
34
+ from UnityPy.helpers import MeshHelper
35
+
36
+ from . import cache
37
+ from .catalog import Catalog
38
+ from .unity import (DEFAULT_RESOURCES, SceneGraph, check_texture, deref, external_path, is_pptr, load_closure,
39
+ mesh_arrays, script_class, texture_image)
40
+
41
+ HEADER = ("m_GameObject", "m_Script")
42
+ _SCALARS = (int, float, str, bool, type(None))
43
+ _LIBS = ("UnityPy", "Pillow", "texture2ddecoder", "astc-encoder-py", "etcpak", "numpy")
44
+ # decoded textures and their PNG encodings by content (export.py's source and the libraries salt the disk layer)
45
+ PNG = cache.bucket("png", salt=cache.source_salt(__file__, *_LIBS), disk=True)
46
+ PIXELS = cache.bucket("pixels", salt=cache.source_salt(__file__, *_LIBS))
47
+
48
+
49
+ def _decode_inputs(tex) -> tuple:
50
+ """What UnityPy's Texture2D.image depends on (Texture2DConverter.get_image_from_texture2d): image data, size,
51
+ format, version, platform, platform blob."""
52
+ r = tex.object_reader
53
+ blob = getattr(tex, "m_PlatformBlob", None)
54
+ return (bytes(tex.get_image_data()), tex.m_Width, tex.m_Height, int(tex.m_TextureFormat),
55
+ tuple(getattr(r, "version", (0, 0, 0, 0))), int(getattr(r, "platform", 0) or 0),
56
+ None if blob is None else bytes(blob))
57
+
58
+
59
+ def texture_png(tex) -> bytes:
60
+ """PNG bytes of a Texture2D's decoded image as Pillow writes it by default (`tex.image.save(PNG)`), cached by
61
+ the decoder's inputs."""
62
+ check_texture(tex)
63
+ k = PNG.key("texture", _decode_inputs(tex))
64
+ data = PNG.get(k)
65
+ if data is None:
66
+ buf = io.BytesIO()
67
+ tex.image.save(buf, format="PNG")
68
+ data = buf.getvalue()
69
+ PNG.put(k, data)
70
+ return data
71
+
72
+
73
+ def packed_png(rgba: np.ndarray) -> bytes:
74
+ """PNG bytes (Pillow, optimize=True) of an RGBA array (bottom row first), cached by the array's content."""
75
+ rgba = np.ascontiguousarray(rgba, np.uint8)
76
+ k = PNG.key("packed", rgba.shape, rgba.data)
77
+ data = PNG.get(k)
78
+ if data is None:
79
+ buf = io.BytesIO()
80
+ Image.fromarray(rgba[::-1].copy(), "RGBA").save(buf, format="PNG", optimize=True)
81
+ data = buf.getvalue()
82
+ PNG.put(k, data)
83
+ return data
84
+
85
+
86
+ def _safe(s: str) -> str:
87
+ return re.sub(r"[^A-Za-z0-9_.-]+", "_", s)
88
+
89
+
90
+ def _key(o) -> tuple[str, int]:
91
+ return (o.assets_file.name, o.path_id)
92
+
93
+
94
+ def _rid(o, name: str) -> str:
95
+ """Stable id for an object exported into a registry: name + hash of (file, path id)."""
96
+ return f"{name}#{hashlib.sha1(f'{o.assets_file.name}:{o.path_id}'.encode()).hexdigest()[:8]}"
97
+
98
+
99
+ def _crc(s: str) -> int:
100
+ return zlib.crc32(s.encode("utf-8"))
101
+
102
+
103
+ FLT_MAX = 3.4028234663852886e+38
104
+
105
+
106
+ def streamed_frames(words: list[int]) -> list[list]:
107
+ """Mecanim StreamedClip data (uint32 words) -> [[time, [[curve, c0, c1, c2, c3], ...]], ...].
108
+
109
+ A frame holds the cubic segments that start at its time; a curve's value at t
110
+ comes from its last key at or before t: ((c0*x + c1)*x + c2)*x + c3, x = t - time.
111
+ The stream usually opens with a frame at -FLT_MAX (every curve, constant); clips
112
+ whose curves all start at t >= 0 open later. It closes with an empty frame at +inf,
113
+ which is dropped here (JSON has no infinity). An empty stream (no streamed
114
+ curves) gives no frames.
115
+ """
116
+ if not words:
117
+ return []
118
+ buf = struct.pack(f"<{len(words)}I", *words)
119
+ frames, i = [], 0
120
+ while i < len(buf):
121
+ t, n = struct.unpack_from("<fi", buf, i)
122
+ i += 8
123
+ keys = [list(struct.unpack_from("<i4f", buf, i + 20 * k)) for k in range(n)]
124
+ i += 20 * n
125
+ frames.append([t, keys])
126
+ if i != len(buf):
127
+ raise ValueError("streamed clip data does not end on a frame boundary")
128
+ last = frames.pop()
129
+ if last[0] != float("inf") or last[1]:
130
+ raise ValueError(f"streamed clip does not close with an empty +inf frame: {last[0]}")
131
+ return frames
132
+
133
+
134
+ def flat_names(tt, prefix: str = "") -> list[str]:
135
+ """Every field path of a typetree ("m_Color", "m_Color.r", "m_Enabled", ...); lists are leaves."""
136
+ out = []
137
+ if isinstance(tt, dict):
138
+ for k, v in tt.items():
139
+ p = f"{prefix}.{k}" if prefix else k
140
+ out.append(p)
141
+ out.extend(flat_names(v, p))
142
+ return out
143
+
144
+
145
+ # --- clip bindings ------------------------------------------------------------
146
+ # Unity class IDs (GenericBinding.typeID) -> class name
147
+ CLASS_ID = {1: "GameObject", 4: "Transform", 23: "MeshRenderer", 95: "Animator", 198: "ParticleSystem",
148
+ 199: "ParticleSystemRenderer", 212: "SpriteRenderer", 224: "RectTransform", 225: "CanvasGroup",
149
+ 114: "MonoBehaviour", 137: "SkinnedMeshRenderer", 120: "LineRenderer", 96: "TrailRenderer",
150
+ 331: "SpriteMask", 222: "CanvasRenderer"}
151
+ # Transform binding attribute -> (property, curve count) (GenericBinding, typeID 4)
152
+ TRANSFORM_ATTR = {1: ("m_LocalPosition", 3), 2: ("m_LocalRotation", 4), 3: ("m_LocalScale", 3),
153
+ 4: ("localEulerAnglesRaw", 3)}
154
+ # Animatable property names a binding can name (attribute = crc32 of the name) that are not field paths of a
155
+ # component the exporter has seen: common component properties, RectTransform driven fields, and material
156
+ # properties ("material.<name>[.<channel>]", renderer material property blocks).
157
+ ENGINE_PROPS = ("m_IsActive", "m_Enabled", "m_Color.r", "m_Color.g", "m_Color.b", "m_Color.a",
158
+ "m_Size.x", "m_Size.y", "m_FlipX", "m_FlipY", "m_SortingOrder", "m_Sprite", "m_Alpha",
159
+ "m_SizeDelta.x", "m_SizeDelta.y", "m_AnchoredPosition.x", "m_AnchoredPosition.y",
160
+ "m_LocalPosition.x", "m_LocalPosition.y", "m_LocalPosition.z",
161
+ "m_LocalScale.x", "m_LocalScale.y", "m_LocalScale.z")
162
+ RECT_PROPS = ("m_AnchoredPosition.x", "m_AnchoredPosition.y", "m_SizeDelta.x", "m_SizeDelta.y",
163
+ "m_AnchorMin.x", "m_AnchorMin.y", "m_AnchorMax.x", "m_AnchorMax.y", "m_Pivot.x", "m_Pivot.y")
164
+ MATERIAL_PROPS = ("_Color", "_TintColor", "_MainTex_ST", "_BaseColor", "_EmissionColor", "_Alpha", "_Cutoff",
165
+ "_Intensity", "_Offset", "_Scroll", "_ScrollX", "_ScrollY", "_Power", "_Value", "_Glow",
166
+ "_GlowColor", "_AddColor", "_MultiplyColor", "_Progress", "_Fade", "_Brightness",
167
+ "_Dissolve", "_MainTex", "_Speed", "_Rotation")
168
+
169
+
170
+ def material_prop_names(extra) -> list[str]:
171
+ out = []
172
+ for p in sorted(set(MATERIAL_PROPS) | set(extra)):
173
+ out.append(f"material.{p}")
174
+ for c in ("r", "g", "b", "a", "x", "y", "z", "w"):
175
+ out.append(f"material.{p}.{c}")
176
+ return out
177
+
178
+
179
+ def _events(tt: dict) -> list[dict]:
180
+ return [{x: e[x] for x in ("time", "functionName", "data", "floatParameter", "intParameter",
181
+ "messageOptions")} for e in tt["m_Events"]]
182
+
183
+
184
+ # --- texel blocks -> packed textures ---------------------------------------------
185
+ def grow_box(x0: float, y0: float, x1: float, y1: float, margin: int) -> tuple[int, int, int, int]:
186
+ """Texel box covering [x0, x1) x [y0, y1) grown by `margin` texels on every side."""
187
+ return (math.floor(x0) - margin, math.floor(y0) - margin, math.ceil(x1) + margin, math.ceil(y1) + margin)
188
+
189
+
190
+ class TexelPacker:
191
+ """Copies texel blocks of source textures into packed textures.
192
+
193
+ Block coordinates are Unity texel coordinates (origin bottom-left). A block
194
+ [x0, x1) x [y0, y1) is read with the source texture's wrap mode, so texels
195
+ a sampler would fetch across an edge are the ones copied. Blocks keep their
196
+ size; a block's UVs move by the integer translation `offset` (in texels), so
197
+ sampling a packed texture returns the same texel values as the source.
198
+ """
199
+
200
+ WIDTH = 2048
201
+
202
+ def __init__(self, out_dir: Path):
203
+ self.out = Path(out_dir)
204
+ self._pixels: dict[tuple, np.ndarray] = {}
205
+ self.groups: dict[str, list] = {}
206
+
207
+ def pixels(self, tex_obj) -> np.ndarray:
208
+ """The texture's texels as RGBA, bottom row first (read-only; decoded once per content, see PIXELS)."""
209
+ k = _key(tex_obj)
210
+ if k not in self._pixels:
211
+ tt = tex_obj.read_typetree()
212
+ tex = tex_obj.read()
213
+ ck = PIXELS.key(_decode_inputs(tex), tt["m_TextureFormat"] == 1, tt["m_Width"], tt["m_Height"])
214
+ arr = PIXELS.get(ck)
215
+ if arr is None:
216
+ img = texture_image(tex)
217
+ if tt["m_TextureFormat"] == 1: # Alpha8: value in the alpha channel
218
+ if img.mode == "L":
219
+ a = np.asarray(img, np.uint8)
220
+ elif img.mode == "RGBA":
221
+ a = np.asarray(img, np.uint8)[:, :, 3]
222
+ else:
223
+ raise NotImplementedError(f"Alpha8 decoded as {img.mode}")
224
+ arr = np.zeros(a.shape + (4,), np.uint8)
225
+ arr[:, :, 3] = a # rgb 0 (GL alpha texture); TMP reads .a only
226
+ else:
227
+ arr = np.asarray(img.convert("RGBA"), np.uint8)
228
+ if arr.shape[:2] != (tt["m_Height"], tt["m_Width"]):
229
+ raise RuntimeError(f"{tt['m_Name']}: decoded size {arr.shape}")
230
+ arr = arr[::-1].copy() # bottom row first (Unity v = 0)
231
+ arr.setflags(write=False)
232
+ PIXELS.put(ck, arr, arr.nbytes)
233
+ self._pixels[k] = arr
234
+ return self._pixels[k]
235
+
236
+ @staticmethod
237
+ def _wrap(mode: int) -> str:
238
+ if mode == 0:
239
+ return "wrap"
240
+ if mode == 1:
241
+ return "clip"
242
+ raise NotImplementedError(f"texture wrap mode {mode}")
243
+
244
+ def add(self, group: str, tex_obj, x0: int, y0: int, x1: int, y1: int) -> dict:
245
+ """Register a block; returns a handle filled in by build()."""
246
+ tt = tex_obj.read_typetree()
247
+ h = {"tex": tex_obj, "key": _key(tex_obj), "settings": tt["m_TextureSettings"], "box": (x0, y0, x1, y1),
248
+ "srcName": tt["m_Name"], "srcSize": (tt["m_Width"], tt["m_Height"])}
249
+ self.groups.setdefault(group, []).append(h)
250
+ return h
251
+
252
+ def add_array(self, group: str, name: str, rgba: np.ndarray, origin: tuple[int, int], settings: dict) -> dict:
253
+ """Register generated texels (RGBA, bottom row first) covering [origin, origin + shape) of a
254
+ virtual texture as one block; the whole array is copied (no wrap)."""
255
+ ox, oy = origin
256
+ h = {"array": rgba, "key": ("generated", name), "settings": settings,
257
+ "box": (ox, oy, ox + rgba.shape[1], oy + rgba.shape[0]), "srcName": name,
258
+ "srcSize": (rgba.shape[1], rgba.shape[0])}
259
+ self.groups.setdefault(group, []).append(h)
260
+ return h
261
+
262
+ def build(self) -> dict:
263
+ textures = {}
264
+ for group, handles in self.groups.items():
265
+ settings = {json.dumps(h["settings"], sort_keys=True) for h in handles}
266
+ if len(settings) != 1:
267
+ raise RuntimeError(f"{group}: blocks from textures with different sampler settings")
268
+ # dedupe identical blocks of the same texture
269
+ uniq: dict[tuple, dict] = {}
270
+ for h in handles:
271
+ uniq.setdefault((h["key"], h["box"]), h)
272
+ blocks = sorted(uniq.values(), key=lambda h: (-(h["box"][3] - h["box"][1]), h["box"]))
273
+ x = y = shelf = 0
274
+ width = max(self.WIDTH, max(b["box"][2] - b["box"][0] for b in blocks))
275
+ for b in blocks:
276
+ w, hgt = b["box"][2] - b["box"][0], b["box"][3] - b["box"][1]
277
+ if x + w > width:
278
+ x, y, shelf = 0, y + shelf, 0
279
+ b["dst"] = (x, y)
280
+ x += w
281
+ shelf = max(shelf, hgt)
282
+ height = y + shelf
283
+ if len(blocks) == 1: # a single block keeps its own size
284
+ width = blocks[0]["box"][2] - blocks[0]["box"][0]
285
+ out = np.zeros((height, width, 4), np.uint8)
286
+ for b in blocks:
287
+ x0, y0, x1, y1 = b["box"]
288
+ dx, dy = b["dst"]
289
+ if "array" in b:
290
+ out[dy:dy + (y1 - y0), dx:dx + (x1 - x0)] = b["array"]
291
+ continue
292
+ src = self.pixels(b["tex"])
293
+ st = b["settings"]
294
+ rows = np.take(np.arange(src.shape[0]), np.arange(y0, y1), mode=self._wrap(st["m_WrapV"]))
295
+ cols = np.take(np.arange(src.shape[1]), np.arange(x0, x1), mode=self._wrap(st["m_WrapU"]))
296
+ out[dy:dy + (y1 - y0), dx:dx + (x1 - x0)] = src[np.ix_(rows, cols)]
297
+ for h in handles:
298
+ d = uniq[(h["key"], h["box"])]["dst"]
299
+ h["offset"] = (d[0] - h["box"][0], d[1] - h["box"][1])
300
+ h["texture"] = group
301
+ fn = f"textures/{_safe(group)}.png"
302
+ (self.out / "textures").mkdir(parents=True, exist_ok=True)
303
+ (self.out / fn).write_bytes(packed_png(out))
304
+ s = next(iter(handles))["settings"]
305
+ textures[group] = {"texture": fn, "name": group, "width": width, "height": height,
306
+ "mipCount": 1, "settings": s,
307
+ "sources": sorted({h["srcName"] for h in handles}),
308
+ "blocks": len(blocks)}
309
+ return textures
310
+
311
+
312
+ CLIP_FORMATS = ("generic", "livescene", "livenotes")
313
+
314
+
315
+ class Exporter:
316
+ """`player` resolves references into `unity default resources` (PlayerData);
317
+ `inline_meshes=False` emits meshes as name + vertex count; MonoBehaviour
318
+ classes in `stub_assets` (assets exported elsewhere, e.g. motions) are
319
+ emitted as {asset, name} only.
320
+
321
+ `textures="deferred"`: textures and sprites become references
322
+ ({textureRef} / {spriteRef}) and nothing is written; `tex_objs` / `sprite_recs`
323
+ hold the objects, and `pack_sprite` / `packed_sprite` with a `TexelPacker` copy
324
+ only the texel blocks a draw can sample.
325
+ `clip_format`: the JSON shape of clips and bindings ("generic": inlined once,
326
+ bindings {path, typeID, class, attribute}; "livescene": inlined once, bindings with
327
+ crc32 keys, candidates and curve counts; "livenotes": clips and controllers in the
328
+ `clips` / `controllers` registries, referenced by id).
329
+ `follow`: referenced kinds exported instead of named ("AnimatorController",
330
+ "SpriteAtlas").
331
+ `defer_writes` (textures "write"): texture records are made as usual but their PNG files are written only by
332
+ `write_textures`, which can leave out the files a caller would delete.
333
+ `class_of(o)`: the class name of a MonoBehaviour (default: unity.script_class, through its m_Script with the
334
+ script bundle loaded)."""
335
+
336
+ def __init__(self, cat: Catalog, out_dir: Path, player=None, inline_meshes: bool = True,
337
+ stub_assets: tuple[str, ...] = (), textures: str = "write", clip_format: str = "generic",
338
+ follow: tuple[str, ...] = (), defer_writes: bool = False, class_of=None):
339
+ if textures not in ("write", "deferred"):
340
+ raise ValueError(f"textures={textures!r}")
341
+ if clip_format not in CLIP_FORMATS:
342
+ raise ValueError(f"clip_format={clip_format!r}")
343
+ self.cat = cat
344
+ self.player = player
345
+ self.out = Path(out_dir)
346
+ self.inline_meshes = inline_meshes
347
+ self.stub_assets = stub_assets
348
+ self.deferred = textures == "deferred"
349
+ self.clip_format = clip_format
350
+ self.follow = tuple(follow)
351
+ self.defer_writes = defer_writes
352
+ self.script_class = class_of or script_class
353
+ self._unwritten: dict[str, object] = {} # defer_writes: file -> Texture2D
354
+ self.textures: dict[tuple, dict] = {}
355
+ self.shaders: dict[str, object] = {}
356
+ self.tex_objs: dict[str, object] = {} # deferred: "file:pathID" -> Texture2D
357
+ self.sprite_recs: dict[str, dict] = {} # deferred: "file:pathID" -> sprite render record
358
+ self.clips: dict[str, dict] = {} # livenotes format: id -> clip
359
+ self.controllers: dict[str, dict] = {} # livenotes format: id -> controller
360
+ self.material_props: set[str] = set() # float/colour property names of exported materials
361
+ self._graph_of_file: dict[int, SceneGraph] = {}
362
+ self._done: dict[tuple, dict] = {} # MonoBehaviour/clip/controller/atlas -> reference once exported
363
+ self._component_ref: dict[tuple, str] = {}
364
+ self._objects: dict[tuple, object] = {}
365
+ self._go_file: dict[int, str] = {}
366
+ self._loaded: dict[str, tuple] = {}
367
+ self._clip_recs: dict[tuple, dict] = {}
368
+ self._atlas_maps: dict[tuple, tuple] = {}
369
+ self._names_of: dict[tuple, list] = {} # object -> flat field names
370
+ self._names_by_class: dict = {} # typeID | ("MonoBehaviour", class) -> names of exported components
371
+ self._class_of: dict[tuple, str | None] = {}
372
+ self._rel_tables: dict[int, dict] = {}
373
+ self._gos_at_cache: dict[tuple, list] = {}
374
+ self._path_sources: list[tuple[str, dict]] = []
375
+ self._crcs: dict[str, int] = {}
376
+
377
+ # --- loading ---------------------------------------------------------
378
+ def load(self, key: str):
379
+ """(env, graph) of a key's bundle closure, loaded once."""
380
+ if key in self._loaded:
381
+ return self._loaded[key]
382
+ env = load_closure(self.cat.fetch_key(key))
383
+ graph = SceneGraph(env)
384
+ go_file = self._go_file
385
+ for o in env.objects:
386
+ self._graph_of_file.setdefault(id(o.assets_file), graph)
387
+ self._objects[_key(o)] = o
388
+ if o.type.name == "Shader":
389
+ self._shader(o)
390
+ elif o.type.name == "GameObject":
391
+ go_file[o.path_id] = o.assets_file.name
392
+ # Components living on GameObjects are exported where they sit in the
393
+ # hierarchy; references to them elsewhere become {component, gameObject}.
394
+ for go_pid, go in graph.go.items():
395
+ path = graph.path(graph.tf_of_go[go_pid]) if go_pid in graph.tf_of_go else go["m_Name"]
396
+ for c in go["m_Component"]:
397
+ self._component_ref[(go_file[go_pid], c["component"]["m_PathID"])] = path
398
+ self._loaded[key] = (env, graph)
399
+ return env, graph
400
+
401
+ def deref(self, owner, pptr: dict):
402
+ if not pptr["m_PathID"]:
403
+ return None
404
+ if external_path(owner, pptr) == DEFAULT_RESOURCES:
405
+ if self.player is None:
406
+ raise RuntimeError("reference into unity default resources needs player data")
407
+ return self.player.deref(owner, pptr)
408
+ return deref(owner, pptr)
409
+
410
+ def graph(self, o) -> SceneGraph:
411
+ return self._graph_of_file[id(o.assets_file)]
412
+
413
+ # --- values ----------------------------------------------------------
414
+ def value(self, owner, v):
415
+ if is_pptr(v):
416
+ return self.ref(owner, v)
417
+ if isinstance(v, dict):
418
+ return {k: self.value(owner, x) for k, x in v.items()}
419
+ if isinstance(v, list):
420
+ if all(type(x) in _SCALARS for x in v): # numbers / strings: nothing to resolve
421
+ return list(v)
422
+ return [self.value(owner, x) for x in v]
423
+ if isinstance(v, (bytes, bytearray)):
424
+ return v.hex()
425
+ return v
426
+
427
+ def ref(self, owner, pptr: dict):
428
+ o = self.deref(owner, pptr)
429
+ if o is None:
430
+ return None
431
+ t = o.type.name
432
+ if t in ("AnimatorController", "AnimatorOverrideController") and "AnimatorController" in self.follow:
433
+ return self.controller(o)
434
+ if t == "SpriteAtlas" and "SpriteAtlas" in self.follow:
435
+ return self.atlas(o)
436
+ if t == "Texture2D":
437
+ return self.texture(o)
438
+ if t == "Sprite":
439
+ return self.sprite(o)
440
+ if t == "Material":
441
+ return self.material(o)
442
+ if t == "Shader":
443
+ return {"shader": self._shader(o)}
444
+ if t == "Mesh":
445
+ return self.mesh(o)
446
+ if t == "AnimationClip":
447
+ return self.clip(o)
448
+ if t == "GameObject":
449
+ g = self.graph(o)
450
+ return {"gameObject": g.path(g.tf_of_go[o.path_id])}
451
+ if t in ("Transform", "RectTransform"):
452
+ return {"transform": self.graph(o).path(o.path_id)}
453
+ if _key(o) in self._component_ref:
454
+ desc = {"component": t, "gameObject": self._component_ref[_key(o)]}
455
+ if t == "MonoBehaviour":
456
+ desc["class"] = self.script_class(o)
457
+ return desc
458
+ if t == "MonoBehaviour":
459
+ return self.scriptable(o)
460
+ if t == "MonoScript":
461
+ ms = o.read()
462
+ return {"script": f"{ms.m_Namespace}.{ms.m_ClassName}".lstrip(".")}
463
+ return {"object": t, "name": getattr(o.read(), "m_Name", None)}
464
+
465
+ # --- shaders, textures, sprites, materials, meshes ---------------------
466
+ def add_shader(self, o) -> str:
467
+ """Register a Shader object (dumped by the caller from `shaders`); returns its name."""
468
+ name = o.read().m_ParsedForm.m_Name
469
+ self.shaders.setdefault(name, o)
470
+ return name
471
+
472
+ _shader = add_shader
473
+
474
+ def texture(self, o) -> dict:
475
+ tt = o.read_typetree()
476
+ if self.deferred:
477
+ ref = f"{o.assets_file.name}:{o.path_id}"
478
+ self.tex_objs[ref] = o
479
+ return {"textureRef": ref, "name": tt["m_Name"], "width": tt["m_Width"], "height": tt["m_Height"],
480
+ "format": tt["m_TextureFormat"], "mipCount": tt.get("m_MipCount", 1),
481
+ "settings": tt.get("m_TextureSettings")}
482
+ k = _key(o)
483
+ if k not in self.textures:
484
+ tex = o.read()
485
+ h = hashlib.sha1(f"{k[0]}:{k[1]}".encode()).hexdigest()[:8]
486
+ fn = f"textures/{_safe(tt['m_Name'])}-{h}.png"
487
+ (self.out / "textures").mkdir(parents=True, exist_ok=True)
488
+ if self.defer_writes:
489
+ self._unwritten[fn] = tex
490
+ else:
491
+ (self.out / fn).write_bytes(texture_png(tex))
492
+ self.textures[k] = {
493
+ "texture": fn, "name": tt["m_Name"],
494
+ "width": tt["m_Width"], "height": tt["m_Height"],
495
+ "format": tt["m_TextureFormat"], "mipCount": tt.get("m_MipCount", 1),
496
+ "colorSpace": tt.get("m_ColorSpace"),
497
+ "settings": tt.get("m_TextureSettings"),
498
+ }
499
+ return self.textures[k]
500
+
501
+ def write_textures(self, skip=()) -> list[str]:
502
+ """defer_writes: write the PNG file of every texture recorded since the last call, except the files in
503
+ `skip` (paths relative to the output directory, "textures/<name>.png"). Returns the files written."""
504
+ skip, written = set(skip), []
505
+ for fn, tex in sorted(self._unwritten.items()):
506
+ if fn not in skip:
507
+ (self.out / fn).write_bytes(texture_png(tex))
508
+ written.append(fn)
509
+ self._unwritten.clear()
510
+ return written
511
+
512
+ def sprite_render_data(self, o, tt: dict | None = None):
513
+ """(render data, texture owner, atlas typetree) of a Sprite: its own m_RD when it is bound to a
514
+ texture; for a sprite packed into a SpriteAtlas (m_RD.texture null) the atlas' m_RenderDataMap entry
515
+ under the sprite's m_RenderDataKey, which holds texture, textureRect and uvTransform (Unity
516
+ SpriteAtlas runtime binding). (None, None, None) when the sprite has neither."""
517
+ tt = tt or o.read_typetree()
518
+ rd = tt["m_RD"]
519
+ if rd["texture"]["m_PathID"]:
520
+ return rd, o, None
521
+ atlas_o = self.deref(o, tt["m_SpriteAtlas"])
522
+ if atlas_o is None:
523
+ return None, None, None
524
+ k = _key(atlas_o)
525
+ if k not in self._atlas_maps:
526
+ at = atlas_o.read_typetree()
527
+ index: dict[tuple, list] = {}
528
+ for rk, d in at["m_RenderDataMap"]:
529
+ index.setdefault((json.dumps(rk[0], sort_keys=True), rk[1]), []).append(d)
530
+ self._atlas_maps[k] = (at, index)
531
+ at, index = self._atlas_maps[k]
532
+ key = tt["m_RenderDataKey"]
533
+ hits = index.get((json.dumps(key[0], sort_keys=True), key[1]), [])
534
+ if len(hits) != 1:
535
+ raise RuntimeError(f"sprite {tt['m_Name']}: {len(hits)} atlas render data entries")
536
+ return hits[0], atlas_o, at
537
+
538
+ def sprite(self, o) -> dict:
539
+ tt = o.read_typetree()
540
+ src, owner, at = self.sprite_render_data(o, tt)
541
+ if self.deferred:
542
+ return self._deferred_sprite(o, tt, src, owner)
543
+ if src is None:
544
+ return {"sprite": tt["m_Name"], "texture": None, "unboundAtlasTags": tt.get("m_AtlasTags")}
545
+ tex = self.ref(owner, src["texture"])
546
+ rd = tt["m_RD"]
547
+ vd = rd["m_VertexData"]
548
+ raw = vd["m_DataSize"]
549
+ raw = raw if isinstance(raw, (bytes, bytearray)) else bytes(raw)
550
+ n = vd["m_VertexCount"]
551
+ ch0 = vd["m_Channels"][0]
552
+ if ch0["format"] != 0 or ch0["dimension"] != 3 or ch0["stream"] != 0 or ch0["offset"] != 0:
553
+ raise NotImplementedError(f"sprite {tt['m_Name']}: position channel {ch0}")
554
+ pos = np.frombuffer(raw, "<f4", count=n * 3).reshape(n, 3)
555
+ idx = list(np.frombuffer(bytes(rd["m_IndexBuffer"]), "<u2"))
556
+ # Sprite UVs are derived from positions with uvTransform
557
+ # (x: texels per unit, y: pivot texel x, z: texels per unit, w: pivot texel y).
558
+ ut = src["uvTransform"]
559
+ uv = [((p[0] * ut["x"] + ut["y"]) / tex["width"], (p[1] * ut["z"] + ut["w"]) / tex["height"])
560
+ for p in pos]
561
+ out = {"sprite": tt["m_Name"], "texture": tex}
562
+ if at is not None:
563
+ out["atlas"] = at["m_Name"]
564
+ out.update({"rect": tt["m_Rect"], "pivot": tt["m_Pivot"], "border": tt["m_Border"],
565
+ "pixelsToUnits": tt["m_PixelsToUnits"], "offset": tt["m_Offset"],
566
+ "textureRect": src["textureRect"], "textureRectOffset": src["textureRectOffset"]})
567
+ if at is not None:
568
+ out["atlasRectOffset"] = src["atlasRectOffset"]
569
+ out["downscaleMultiplier"] = src["downscaleMultiplier"]
570
+ out.update({"settingsRaw": src["settingsRaw"], "uvTransform": ut,
571
+ "vertices": pos.astype(float).tolist(), "uv": [list(map(float, x)) for x in uv],
572
+ "indices": [int(i) for i in idx]})
573
+ return out
574
+
575
+ def _deferred_sprite(self, o, tt: dict, src, owner) -> dict:
576
+ if src is None:
577
+ raise RuntimeError(f"sprite {tt['m_Name']}: no texture and no atlas")
578
+ tex = self.deref(owner, src["texture"])
579
+ if src["alphaTexture"]["m_PathID"] or src.get("secondaryTextures"):
580
+ raise NotImplementedError(f"sprite {tt['m_Name']}: alpha/secondary textures")
581
+ raw = src["settingsRaw"]
582
+ if (raw >> 2) & 0xF:
583
+ raise NotImplementedError(f"sprite {tt['m_Name']}: packing rotation {(raw >> 2) & 0xF}")
584
+ if src.get("downscaleMultiplier", 1.0) != 1.0:
585
+ raise NotImplementedError(f"sprite {tt['m_Name']}: downscaleMultiplier")
586
+ ref = f"{o.assets_file.name}:{o.path_id}"
587
+ self.tex_objs[f"{tex.assets_file.name}:{tex.path_id}"] = tex
588
+ self.sprite_recs[ref] = {
589
+ "name": tt["m_Name"], "tex": tex, "rect": tt["m_Rect"], "border": tt["m_Border"],
590
+ "pivot": tt["m_Pivot"], "pixelsPerUnit": tt["m_PixelsToUnits"],
591
+ "textureRect": src["textureRect"], "textureRectOffset": src["textureRectOffset"],
592
+ "settingsRaw": raw,
593
+ }
594
+ return {"spriteRef": ref, "name": tt["m_Name"]}
595
+
596
+ def pack_sprite(self, ref: str, packer: TexelPacker, group: str, margin: int = 1) -> None:
597
+ """Deferred mode: register the texels a sprite draw samples (its texture rect grown by `margin`
598
+ texels for bilinear filtering) as a block of `group`."""
599
+ s = self.sprite_recs[ref]
600
+ tr = s["textureRect"]
601
+ s["_block"] = packer.add(group, s["tex"], *grow_box(tr["x"], tr["y"], tr["x"] + tr["width"],
602
+ tr["y"] + tr["height"], margin))
603
+
604
+ def packed_sprite(self, ref: str) -> dict:
605
+ """Deferred mode, after packer.build(): the sprite with its texture rect moved into the packed texture."""
606
+ s = self.sprite_recs[ref]
607
+ b = s.pop("_block")
608
+ tr = s["textureRect"]
609
+ return {"texture": b["texture"], "rect": s["rect"], "border": s["border"], "pivot": s["pivot"],
610
+ "pixelsPerUnit": s["pixelsPerUnit"], "settingsRaw": s["settingsRaw"],
611
+ "textureRect": {"x": tr["x"] + b["offset"][0], "y": tr["y"] + b["offset"][1],
612
+ "width": tr["width"], "height": tr["height"]},
613
+ "textureRectOffset": s["textureRectOffset"], "source": b["srcName"]}
614
+
615
+ def material(self, o) -> dict:
616
+ tt = o.read_typetree()
617
+ sp = tt["m_SavedProperties"]
618
+ m = {
619
+ "material": tt["m_Name"],
620
+ "shader": self.ref(o, tt["m_Shader"]),
621
+ "keywords": tt.get("m_ValidKeywords", []),
622
+ "invalidKeywords": tt.get("m_InvalidKeywords", []),
623
+ "renderQueue": tt.get("m_CustomRenderQueue"),
624
+ "tags": dict(tt.get("stringTagMap", [])),
625
+ "disabledPasses": tt.get("disabledShaderPasses", []),
626
+ "textures": {k: {"texture": self.ref(o, v["m_Texture"]),
627
+ "scale": v["m_Scale"], "offset": v["m_Offset"]}
628
+ for k, v in sp["m_TexEnvs"]},
629
+ "ints": dict(sp.get("m_Ints", [])),
630
+ "floats": dict(sp.get("m_Floats", [])),
631
+ "colors": dict(sp.get("m_Colors", [])),
632
+ }
633
+ self.material_props.update(m["floats"])
634
+ self.material_props.update(m["colors"])
635
+ return m
636
+
637
+ def mesh(self, o) -> dict:
638
+ m = o.read()
639
+ if not self.inline_meshes:
640
+ return {"mesh": m.m_Name, "vertexCount": m.m_VertexData.m_VertexCount}
641
+ arrays = mesh_arrays(m)
642
+ if arrays is None:
643
+ return {"mesh": m.m_Name, "empty": True}
644
+ v, n, uv, tris = arrays
645
+ out = {"mesh": m.m_Name, "vertices": v.tolist(), "normals": n.tolist(),
646
+ "uv0": uv.tolist(), "submeshes": [t.reshape(-1).tolist() for t in tris]}
647
+ tt = o.read_typetree()
648
+ if tt.get("m_BindPose"):
649
+ out.update(self._skin(m, tt, len(v)))
650
+ return out
651
+
652
+ @staticmethod
653
+ def _skin(m, tt: dict, vertex_count: int) -> dict:
654
+ """A skinned mesh's skin as serialized: `m_Skin` (BoneWeights4 per vertex: weight[4], boneIndex[4], from
655
+ the BlendWeight / BlendIndices vertex channels, or m_Skin in older layouts; unused slots 0),
656
+ `m_BindPose` (Matrix4x4f per bone, e00..e33), `m_BoneNameHashes`, `m_RootBoneNameHash`. Bone index i is
657
+ the renderer's m_Bones[i]."""
658
+ h = MeshHelper.MeshHandler(m)
659
+ h.process()
660
+ w, ix = h.m_BoneWeights, h.m_BoneIndices
661
+ if not w or len(w) != vertex_count or len(ix) != vertex_count:
662
+ raise RuntimeError(f"mesh {tt['m_Name']}: {len(w or [])} bone weights for {vertex_count} vertices")
663
+ skin = []
664
+ for ws, bs in zip(w, ix):
665
+ if len(ws) > 4 or len(bs) != len(ws):
666
+ raise NotImplementedError(f"mesh {tt['m_Name']}: {len(ws)} weights / {len(bs)} bone indices per vertex")
667
+ pad = 4 - len(ws)
668
+ skin.append({"weight": [float(x) for x in ws] + [0.0] * pad, "boneIndex": [int(x) for x in bs] + [0] * pad})
669
+ return {"m_Skin": skin, "m_BindPose": tt["m_BindPose"], "m_BoneNameHashes": tt.get("m_BoneNameHashes", []),
670
+ "m_RootBoneNameHash": tt.get("m_RootBoneNameHash")}
671
+
672
+ # --- field names (crc32 resolution) ------------------------------------
673
+ def _crc_of(self, name: str) -> int:
674
+ c = self._crcs.get(name)
675
+ if c is None:
676
+ c = self._crcs[name] = _crc(name)
677
+ return c
678
+
679
+ def _names(self, o, tt: dict | None = None) -> list[str]:
680
+ """Flat field paths of an object's typetree, computed once."""
681
+ k = _key(o)
682
+ if k not in self._names_of:
683
+ self._names_of[k] = flat_names(tt if tt is not None else o.read_typetree())
684
+ return self._names_of[k]
685
+
686
+ def _mono_class(self, o) -> str | None:
687
+ k = _key(o)
688
+ if k not in self._class_of:
689
+ try:
690
+ self._class_of[k] = self.script_class(o)
691
+ except Exception: # script not loadable from this closure
692
+ self._class_of[k] = None
693
+ return self._class_of[k]
694
+
695
+ def _register_names(self, o, tt: dict) -> None:
696
+ """Field paths of an exported component, by class (names a clip binding can target)."""
697
+ k = ("MonoBehaviour", self._mono_class(o)) if o.type.name == "MonoBehaviour" else o.type.value
698
+ self._names_by_class.setdefault(k, set()).update(self._names(o, tt))
699
+
700
+ # --- clip bindings -----------------------------------------------------
701
+ def _rel_table(self, graph: SceneGraph) -> dict[int, set[str]]:
702
+ """crc32 -> transform paths relative to any of their ancestors (or themselves: "").
703
+
704
+ An Animator binding path is relative to the Animator's GameObject, which need not be
705
+ the hierarchy root, and the clip does not say which GameObject plays it."""
706
+ t = self._rel_tables.get(id(graph))
707
+ if t is None:
708
+ t = {0: {""}}
709
+ for tf_pid in graph.tf:
710
+ parts = graph.path(tf_pid).split("/")
711
+ for i in range(1, len(parts)):
712
+ s = "/".join(parts[i:])
713
+ t.setdefault(self._crc_of(s), set()).add(s)
714
+ self._rel_tables[id(graph)] = t
715
+ return t
716
+
717
+ def _all_graphs(self) -> list[SceneGraph]:
718
+ return list({id(g): g for g in self._graph_of_file.values()}.values())
719
+
720
+ def add_path_source(self, key: str, roots: tuple[str, ...] = ()) -> int:
721
+ """Name binding paths from a hierarchy that is not exported (e.g. the scene whose UI a clip animates):
722
+ paths relative to any transform inside the subtrees rooted at transforms named in `roots` (the whole
723
+ hierarchy when empty). Used after the loaded hierarchies; resolved bindings record the key. Returns
724
+ the number of subtrees."""
725
+ env = load_closure(self.cat.fetch_key(key))
726
+ g = SceneGraph(env)
727
+ paths = [g.path(pid) for pid in g.tf]
728
+ bases = [p for p in paths if not roots or p.rsplit("/", 1)[-1] in roots]
729
+ table: dict[int, set[str]] = {}
730
+ for base in bases:
731
+ d = base.count("/")
732
+ for full in paths:
733
+ if full == base or full.startswith(base + "/"):
734
+ parts = full.split("/")
735
+ for i in range(d, len(parts)):
736
+ s = "/".join(parts[i + 1:])
737
+ table.setdefault(self._crc_of(s), set()).add(s)
738
+ self._path_sources.append((key, table))
739
+ return len(bases)
740
+
741
+ def _gos_at(self, graph: SceneGraph, rel: str) -> list[int]:
742
+ """GameObjects a relative binding path can name ("" = any)."""
743
+ k = (id(graph), rel)
744
+ if k not in self._gos_at_cache:
745
+ self._gos_at_cache[k] = [go_pid for go_pid, tf in graph.tf_of_go.items()
746
+ if rel == "" or graph.path(tf) == rel or graph.path(tf).endswith("/" + rel)]
747
+ return self._gos_at_cache[k]
748
+
749
+ def _binding_gos(self, r: dict):
750
+ """(graph, GameObject) pairs a resolved binding path can name: exactly root/path when the binding was
751
+ found below its animated root (bind_clip), else every GameObject whose path ends with it."""
752
+ for g in r["graphs"]:
753
+ if r["root"] is not None:
754
+ full = r["root"] if r["path"] == "" else f"{r['root']}/{r['path']}"
755
+ yield from ((g, go) for go in g.gos_at_path(full))
756
+ else:
757
+ yield from ((g, go) for go in self._gos_at(g, r["path"]))
758
+
759
+ def _bound_names(self, r: dict) -> set[str]:
760
+ """Field paths of the components a resolved binding path can name (and the bound component kind)."""
761
+ tid, names = r["typeID"], set()
762
+ for g, go_pid in self._binding_gos(r):
763
+ if tid == 1:
764
+ r["component"] = "GameObject"
765
+ names.update(flat_names(g.go[go_pid]))
766
+ for c in g.go[go_pid]["m_Component"]:
767
+ co = self._objects.get((self._go_file[go_pid], c["component"]["m_PathID"]))
768
+ if co is None:
769
+ continue
770
+ if tid == 114:
771
+ if (r["script"] is not None and co.type.name == "MonoBehaviour"
772
+ and self._mono_class(co) == r["script"][1]):
773
+ names.update(self._names(co))
774
+ elif co.type.value == tid:
775
+ r["component"] = co.type.name
776
+ names.update(self._names(co))
777
+ return names
778
+
779
+ def _material_attribute(self, r: dict) -> str | None:
780
+ """Attribute of a renderer material property binding (customType 22, RendererMaterial):
781
+ attribute = (crc32(property) & 0x0FFFFFFF) | channel << 28, bit 30 set for colour channels (r, g, b, a),
782
+ clear for vector channels (x, y, z, w) or a float property. The property is one of the materials of the
783
+ renderer the binding names; returns "material.<property>[.<channel>]" (Unity's property path).
784
+ The bit layout is native engine code (not in the C# reference); the layout above is the one the data
785
+ shows: the four _TintColor bindings of the live intro's lane_mesh carry 4..7 << 28 over
786
+ crc32("_TintColor") & 0x0FFFFFFF, with 4..6 constant (0.538, 0.510, 2.040: the effect's _TintColor rgb)
787
+ and 7 animated 0 -> 1 -> 0 (alpha)."""
788
+ a = r["attributeCrc"]
789
+ kinds: dict[str, str] = {}
790
+ for g, go_pid in self._binding_gos(r):
791
+ for c in g.go[go_pid]["m_Component"]:
792
+ co = self._objects.get((self._go_file[go_pid], c["component"]["m_PathID"]))
793
+ if co is None or co.type.value != r["typeID"]:
794
+ continue
795
+ for mp in co.read_typetree().get("m_Materials", []):
796
+ mo = self.deref(co, mp)
797
+ if mo is None:
798
+ continue
799
+ sp = mo.read_typetree()["m_SavedProperties"]
800
+ kinds.update({k: "float" for k, _ in sp.get("m_Floats", [])})
801
+ kinds.update({k: "vector" for k, _ in sp.get("m_Colors", [])})
802
+ hits = [k for k in kinds if self._crc_of(k) & 0x0FFFFFFF == a & 0x0FFFFFFF]
803
+ if len(hits) != 1:
804
+ return None
805
+ name, channel, colour = hits[0], (a >> 28) & 3, (a >> 30) & 1
806
+ if kinds[name] == "float" and not colour and channel == 0:
807
+ return f"material.{name}"
808
+ return f"material.{name}.{('rgba' if colour else 'xyzw')[channel]}"
809
+
810
+ def _class_names(self, r: dict) -> set[str]:
811
+ """Names any binding of the class can target: engine properties, material properties of the
812
+ exported materials, and field paths of every exported component of the class."""
813
+ k = ("MonoBehaviour", r["script"][1]) if r["typeID"] == 114 and r["script"] else r["typeID"]
814
+ return (set(ENGINE_PROPS) | set(RECT_PROPS) | set(material_prop_names(self.material_props))
815
+ | self._names_by_class.get(k, set()))
816
+
817
+ def _path_tiers(self, owner):
818
+ """(graphs, source, crc32 -> paths | None for the union of `graphs`) in resolution order: the clip's
819
+ own hierarchy, every loaded hierarchy, then the path sources (after bind_clip, the animated root's
820
+ subtree comes first: see _resolve)."""
821
+ own = self._graph_of_file.get(id(owner.assets_file))
822
+ if own is not None:
823
+ yield [own], None, self._rel_table(own)
824
+ yield self._all_graphs(), None, None
825
+ for key, table in self._path_sources:
826
+ yield [], key, table
827
+
828
+ def _resolve(self, r: dict) -> None:
829
+ """Name a binding's path and attribute from their crc32: first tier with exactly one hit."""
830
+ crc = r["pathCrc"]
831
+ if r["path"] is None and r["bound"]:
832
+ graph, root = r["bound"]
833
+ cands = self._subtree_table(graph, root).get(crc, set())
834
+ if len(cands) == 1:
835
+ r.update(path=next(iter(cands)), pathSource=None, graphs=[graph], pathCandidates=None, root=root)
836
+ if r["path"] is None:
837
+ first = None
838
+ for graphs, source, table in self._path_tiers(r["owner"]):
839
+ if table is not None:
840
+ cands = table.get(crc, set())
841
+ else:
842
+ cands = set().union(*(self._rel_table(g).get(crc, ()) for g in graphs))
843
+ if cands and first is None:
844
+ first = cands
845
+ if len(cands) == 1:
846
+ r.update(path=next(iter(cands)), pathSource=source, graphs=graphs, pathCandidates=None)
847
+ break
848
+ else:
849
+ r["pathCandidates"] = sorted(first) if first and len(first) > 1 else None
850
+ if r["attribute"] is None and r["customType"] == 22: # RendererMaterial: not a field path crc
851
+ if r["path"] is not None:
852
+ self._bound_names(r) # names the bound component
853
+ r["attribute"] = self._material_attribute(r)
854
+ elif r["attribute"] is None:
855
+ a = r["attributeCrc"]
856
+ hit = [x for x in (self._bound_names(r) if r["path"] is not None else ()) if self._crc_of(x) == a]
857
+ if len(hit) != 1:
858
+ hit = [x for x in self._class_names(r) if self._crc_of(x) == a]
859
+ if len(hit) == 1:
860
+ r["attribute"] = hit[0]
861
+
862
+ def _binding_record(self, owner, b: dict) -> dict:
863
+ tid = b["typeID"]
864
+ r = {"owner": owner, "raw": b, "pathCrc": b["path"], "typeID": tid, "attributeCrc": b["attribute"],
865
+ "pptr": bool(b["isPPtrCurve"]), "int": bool(b["isIntCurve"]), "customType": b.get("customType"),
866
+ "serializeReference": bool(b.get("isSerializeReferenceCurve")),
867
+ "path": None, "pathCandidates": None, "pathSource": None, "graphs": [],
868
+ "attribute": None, "curveCount": 1, "transformAttr": False, "component": None, "script": None,
869
+ "bound": False, "root": None}
870
+ if tid == 4 and b["attribute"] in TRANSFORM_ATTR and not r["pptr"]:
871
+ r["attribute"], r["curveCount"] = TRANSFORM_ATTR[b["attribute"]]
872
+ r["transformAttr"] = True
873
+ if tid == 114:
874
+ so = self.deref(owner, b["script"])
875
+ if so is not None:
876
+ ms = so.read()
877
+ r["script"] = (ms.m_Namespace, ms.m_ClassName)
878
+ self._resolve(r)
879
+ return r
880
+
881
+ def resolve_pending(self) -> int:
882
+ """Resolve bindings left unresolved when their clip was exported (target hierarchy, material or
883
+ component class seen later, path sources added since) and update the emitted binding JSON in
884
+ place. Returns the number of paths and attributes still unresolved."""
885
+ left = 0
886
+ for rec in self._clip_recs.values():
887
+ changed = False
888
+ for r in rec["bindings"]:
889
+ if r["path"] is None or r["attribute"] is None:
890
+ before = (r["path"], r["attribute"], r["component"])
891
+ self._resolve(r)
892
+ changed |= before != (r["path"], r["attribute"], r["component"])
893
+ left += (r["path"] is None) + (r["attribute"] is None)
894
+ if changed:
895
+ self._refresh_views(rec)
896
+ return left
897
+
898
+ @staticmethod
899
+ def _refresh_views(rec: dict) -> None:
900
+ """Re-render a clip's emitted binding JSON in place after its records changed."""
901
+ for view, dicts in rec["views"]:
902
+ for r, d in zip(rec["bindings"], dicts):
903
+ new = view(r)
904
+ if new != d:
905
+ d.clear()
906
+ d.update(new)
907
+
908
+ def _subtree_table(self, graph: SceneGraph, root: str) -> dict[int, set[str]]:
909
+ """crc32 -> paths relative to the transform at `root` of every transform in its subtree ("" = root)."""
910
+ k = ("subtree", id(graph), root)
911
+ if k not in self._rel_tables:
912
+ t: dict[int, set[str]] = {}
913
+ for tf_pid in graph.tf:
914
+ full = graph.path(tf_pid)
915
+ if full == root or full.startswith(root + "/"):
916
+ rel = full[len(root) + 1:]
917
+ t.setdefault(self._crc_of(rel), set()).add(rel)
918
+ if not t:
919
+ raise KeyError(f"animated root {root} not in the hierarchy")
920
+ self._rel_tables[k] = t
921
+ return self._rel_tables[k]
922
+
923
+ def bind_clip(self, clip_o, graph: SceneGraph, root: str) -> dict:
924
+ """The clip is played by the Animator on the GameObject at `root` in `graph` (e.g. the object a timeline
925
+ track is bound to): binding paths are relative to it. A path found exactly once in root's subtree replaces
926
+ the guess of the generic tiers; a path not in the subtree keeps it (objects parented below root at
927
+ runtime). In the livescene format the clip's bindings also get the livenotes binding fields (`class`,
928
+ `curves`). The clip must have been exported. Returns {"subtree": n, "outside": n, "unresolved": n}."""
929
+ rec = self._clip_recs[_key(clip_o)]
930
+ table = self._subtree_table(graph, root)
931
+ counts = {"subtree": 0, "outside": 0, "unresolved": 0}
932
+ for r in rec["bindings"]:
933
+ r["bound"] = (graph, root)
934
+ cands = table.get(r["pathCrc"], set())
935
+ if len(cands) == 1:
936
+ path = next(iter(cands))
937
+ if r["path"] != path: # the generic guess named another object: re-resolve below root
938
+ r.update(component=None, attribute=r["attribute"] if r["transformAttr"] else None)
939
+ r.update(path=path, pathSource=None, graphs=[graph], pathCandidates=None, root=root)
940
+ counts["subtree"] += 1
941
+ self._resolve(r)
942
+ if r["root"] is None:
943
+ counts["outside" if r["path"] is not None else "unresolved"] += 1
944
+ self._refresh_views(rec)
945
+ return counts
946
+
947
+ def timeline_tracks(self, asset_o) -> list[tuple]:
948
+ """(track object, track typetree, [AnimationClip objects]) of every track of a TimelineAsset (depth first
949
+ through group tracks): the clips of its TimelineClips' AnimationPlayableAssets and its infinite clip."""
950
+ out = []
951
+
952
+ def walk(pptrs, owner):
953
+ for p in pptrs:
954
+ t = self.deref(owner, p)
955
+ if t is None:
956
+ continue
957
+ tt = t.read_typetree()
958
+ clips = []
959
+ for c in tt.get("m_Clips", []):
960
+ a = self.deref(t, c["m_Asset"])
961
+ at = a.read_typetree() if a is not None else {}
962
+ if isinstance(at.get("m_Clip"), dict) and at["m_Clip"].get("m_PathID"):
963
+ clips.append(self.deref(a, at["m_Clip"]))
964
+ if isinstance(tt.get("m_AnimClip"), dict) and tt["m_AnimClip"].get("m_PathID"):
965
+ clips.append(self.deref(t, tt["m_AnimClip"]))
966
+ out.append((t, tt, clips))
967
+ walk(tt.get("m_Children", []), t)
968
+
969
+ walk(asset_o.read_typetree()["m_Tracks"], asset_o)
970
+ return out
971
+
972
+ # --- clips -------------------------------------------------------------
973
+ def clip_record(self, o) -> dict:
974
+ """Clip data and resolved binding records, built once (every clip view reads this)."""
975
+ k = _key(o)
976
+ rec = self._clip_recs.get(k)
977
+ if rec is None:
978
+ tt = o.read_typetree()
979
+ if tt["m_Legacy"] or tt["m_Compressed"]:
980
+ raise NotImplementedError(f"clip {tt['m_Name']}: legacy/compressed")
981
+ mc = tt["m_MuscleClip"]
982
+ data = mc["m_Clip"]["data"]
983
+ cbc = tt["m_ClipBindingConstant"]
984
+ rec = {"object": o, "tt": tt, "name": tt["m_Name"], "muscle": mc,
985
+ "streamed": data["m_StreamedClip"], "dense": data["m_DenseClip"],
986
+ "constant": list(data["m_ConstantClip"]["data"]),
987
+ "pptrCurveMapping": list(cbc.get("pptrCurveMapping", [])),
988
+ "bindings": [self._binding_record(o, b) for b in cbc["genericBindings"]], "views": []}
989
+ self._clip_recs[k] = rec
990
+ return rec
991
+
992
+ @staticmethod
993
+ def _curve_total(rec: dict) -> int:
994
+ return rec["streamed"]["curveCount"] + rec["dense"]["m_CurveCount"] + len(rec["constant"])
995
+
996
+ @staticmethod
997
+ def _clip_header(rec: dict) -> dict:
998
+ tt, mc = rec["tt"], rec["muscle"]
999
+ return {"sampleRate": tt["m_SampleRate"], "wrapMode": tt["m_WrapMode"],
1000
+ "startTime": mc["m_StartTime"], "stopTime": mc["m_StopTime"],
1001
+ "loopTime": bool(mc["m_LoopTime"]), "cycleOffset": mc["m_CycleOffset"], "events": _events(tt)}
1002
+
1003
+ @staticmethod
1004
+ def _dense(rec: dict) -> dict:
1005
+ de = rec["dense"]
1006
+ return {"curveCount": de["m_CurveCount"], "frameCount": de["m_FrameCount"],
1007
+ "sampleRate": de["m_SampleRate"], "beginTime": de["m_BeginTime"],
1008
+ "samples": list(de["m_SampleArray"])}
1009
+
1010
+ def clip(self, o) -> dict:
1011
+ """An AnimationClip with the data the Animator samples, in `clip_format`.
1012
+
1013
+ Curve indices run over streamed curves, then dense, then constant curves; each
1014
+ binding takes its curve count of them in `bindings` order."""
1015
+ k = _key(o)
1016
+ if k in self._done:
1017
+ return self._done[k]
1018
+ rec = self.clip_record(o)
1019
+ return {"generic": self._clip_generic, "livescene": self._clip_livescene,
1020
+ "livenotes": self._clip_livenotes}[self.clip_format](rec)
1021
+
1022
+ def _views(self, rec: dict, view) -> list[dict]:
1023
+ dicts = [view(r) for r in rec["bindings"]]
1024
+ rec["views"].append((view, dicts))
1025
+ return dicts
1026
+
1027
+ def _clip_generic(self, rec: dict) -> dict:
1028
+ o, st = rec["object"], rec["streamed"]
1029
+ bindings = self._views(rec, self._binding_generic)
1030
+ floats = sum(r["curveCount"] for r in rec["bindings"] if not r["pptr"])
1031
+ if self._curve_total(rec) != floats:
1032
+ raise RuntimeError(f"clip {rec['name']}: curve count != binding curve count")
1033
+ ref = {"clip": rec["name"]}
1034
+ self._done[_key(o)] = ref
1035
+ streamed = {"curveCount": st["curveCount"], "frames": streamed_frames(st["data"])}
1036
+ if st.get("discreteCurveCount"):
1037
+ streamed["discreteCurveCount"] = st["discreteCurveCount"]
1038
+ out = {**ref, **self._clip_header(rec), "bindings": bindings, "streamed": streamed,
1039
+ "dense": self._dense(rec), "constant": rec["constant"]}
1040
+ if rec["pptrCurveMapping"]:
1041
+ out["pptrCurveMapping"] = [self.ref(o, p) for p in rec["pptrCurveMapping"]]
1042
+ return out
1043
+
1044
+ @staticmethod
1045
+ def _binding_generic(r: dict) -> dict:
1046
+ if r["serializeReference"]:
1047
+ raise NotImplementedError(f"clip binding kind {r['raw']}")
1048
+ tid = r["typeID"]
1049
+ cls = r["script"][1] if tid == 114 and r["script"] else CLASS_ID.get(tid, f"classID {tid}")
1050
+ d = {"path": r["path"], "typeID": tid, "class": cls, "attribute": r["attribute"]}
1051
+ if r["curveCount"] != 1:
1052
+ d["curves"] = r["curveCount"]
1053
+ if r["pptr"]:
1054
+ d["pptr"] = True
1055
+ if r["int"]:
1056
+ d["int"] = True
1057
+ if r["path"] is None:
1058
+ d["pathCrc"] = r["pathCrc"]
1059
+ if r["attribute"] is None:
1060
+ d["attributeCrc"] = r["attributeCrc"]
1061
+ return d
1062
+
1063
+ def _clip_livescene(self, rec: dict) -> dict:
1064
+ o, st = rec["object"], rec["streamed"]
1065
+ bindings = self._views(rec, self._binding_livescene)
1066
+ ref = {"clip": rec["name"]}
1067
+ self._done[_key(o)] = ref
1068
+ float_curves = sum(b["curveCount"] for b in bindings if not b["isPPtrCurve"])
1069
+ pptr = [self.value(o, p) for p in rec["pptrCurveMapping"]]
1070
+ out = {
1071
+ **ref, **self._clip_header(rec),
1072
+ "curveOrder": "streamed, dense, constant (each binding takes curveCount curves in order)",
1073
+ "bindings": bindings,
1074
+ "streamed": {"curveCount": st["curveCount"], "discreteCurveCount": st.get("discreteCurveCount", 0),
1075
+ "frames": streamed_frames(st["data"])},
1076
+ "dense": self._dense(rec), "constant": rec["constant"], "pptrCurveMapping": pptr,
1077
+ }
1078
+ total = self._curve_total(rec)
1079
+ npptr = sum(1 for b in bindings if b["isPPtrCurve"])
1080
+ if total != float_curves + npptr:
1081
+ out["curveCountMismatch"] = {"data": total, "bindings": float_curves, "pptrBindings": npptr}
1082
+ return out
1083
+
1084
+ @staticmethod
1085
+ def _binding_livescene(r: dict) -> dict:
1086
+ tid = r["typeID"]
1087
+ d = {"pathCrc": r["pathCrc"], "path": r["path"], "typeID": tid, "attributeCrc": r["attributeCrc"],
1088
+ "isPPtrCurve": r["pptr"], "isIntCurve": r["int"], "customType": r["customType"]}
1089
+ if r["pathCandidates"]:
1090
+ d["pathCandidates"] = r["pathCandidates"]
1091
+ if r["transformAttr"]:
1092
+ d["attribute"] = r["attribute"]
1093
+ else:
1094
+ if tid == 114 and r["script"]:
1095
+ d["class"] = f"{r['script'][0]}.{r['script'][1]}".lstrip(".")
1096
+ if tid != 114 and r["component"]:
1097
+ d["component"] = r["component"]
1098
+ if r["attribute"] is not None:
1099
+ d["attribute"] = r["attribute"]
1100
+ d["curveCount"] = r["curveCount"]
1101
+ if r["bound"]: # timeline clips also carry the livenotes binding fields (class, curves)
1102
+ if "class" not in d:
1103
+ d["class"] = CLASS_ID.get(tid, f"classID {tid}")
1104
+ d["curves"] = r["curveCount"]
1105
+ return d
1106
+
1107
+ def _clip_livenotes(self, rec: dict) -> dict:
1108
+ o, st = rec["object"], rec["streamed"]
1109
+ bindings = self._views(rec, self._binding_livenotes)
1110
+ float_curves = sum(r["curveCount"] for r in rec["bindings"] if not r["pptr"])
1111
+ de = rec["dense"]
1112
+ if self._curve_total(rec) != float_curves:
1113
+ raise RuntimeError(f"clip {rec['name']}: curve count != binding curve count "
1114
+ f"({st['curveCount']}+{de['m_CurveCount']}+{len(rec['constant'])} vs {float_curves})")
1115
+ pptr = [self.ref(o, p) for p in rec["pptrCurveMapping"]]
1116
+ rid = _rid(o, rec["name"])
1117
+ ref = {"clip": rid}
1118
+ self._done[_key(o)] = ref
1119
+ try:
1120
+ frames = streamed_frames(st["data"])
1121
+ except ValueError as e:
1122
+ raise ValueError(f"clip {rec['name']}: {e}") from None
1123
+ self.clips[rid] = {
1124
+ "name": rec["name"], **self._clip_header(rec), "bindings": bindings,
1125
+ "streamed": {"curveCount": st["curveCount"], "frames": frames},
1126
+ "dense": self._dense(rec), "constant": rec["constant"], "pptrCurveMapping": pptr,
1127
+ "discreteCurveCount": st.get("discreteCurveCount", 0),
1128
+ }
1129
+ return ref
1130
+
1131
+ @staticmethod
1132
+ def _binding_livenotes(r: dict) -> dict:
1133
+ tid = r["typeID"]
1134
+ if tid == 114 and not r["pptr"] and r["script"] and r["path"] is not None and r["attribute"] is not None:
1135
+ d = {"path": r["path"], "typeID": tid, "class": r["script"][1], "attribute": r["attribute"], "curves": 1}
1136
+ else:
1137
+ d = {"path": r["path"] if r["path"] is not None else f"crc32:{r['pathCrc']}", "typeID": tid,
1138
+ "class": CLASS_ID.get(tid, f"classID {tid}")}
1139
+ if r["pptr"]:
1140
+ d["pptr"] = True
1141
+ if r["transformAttr"]:
1142
+ d.update(attribute=r["attribute"], curves=r["curveCount"])
1143
+ else:
1144
+ if tid == 114:
1145
+ d["script"] = r["script"][1] if r["script"] else None
1146
+ d["attribute"] = r["attribute"] if r["attribute"] is not None else f"crc32:{r['attributeCrc']}"
1147
+ d["curves"] = r["curveCount"]
1148
+ if r["int"]:
1149
+ d["int"] = True
1150
+ if r["pathSource"]:
1151
+ d["scene"] = r["pathSource"]
1152
+ return d
1153
+
1154
+ def clip_curves(self, o) -> dict:
1155
+ """A clip as per-property streamed cubic keys, for runtimes that animate RectTransform,
1156
+ CanvasGroup alpha and Transform euler angles of the animated root only (ADV UI)."""
1157
+ rec = self.clip_record(o)
1158
+ tt, mc, st, de = rec["tt"], rec["muscle"], rec["streamed"], rec["dense"]
1159
+ name, const = rec["name"], rec["constant"]
1160
+ if st["discreteCurveCount"] or de["m_CurveCount"]:
1161
+ raise NotImplementedError(f"clip {name}: discrete/dense curves")
1162
+ props = []
1163
+ for r in rec["bindings"]:
1164
+ if r["pathCrc"] != 0:
1165
+ raise NotImplementedError(f"clip {name}: binding below the animated root")
1166
+ if r["pptr"] or r["int"] or r["serializeReference"]:
1167
+ raise NotImplementedError(f"clip {name}: binding kind {r['raw']}")
1168
+ if r["typeID"] == 224 and r["attribute"] in RECT_PROPS:
1169
+ props.append("RectTransform." + r["attribute"])
1170
+ elif r["typeID"] == 225 and r["attribute"] == "m_Alpha":
1171
+ props.append("CanvasGroup.m_Alpha")
1172
+ elif r["typeID"] == 4 and r["attributeCrc"] == 4: # localEulerAnglesRaw (x, y, z)
1173
+ props += ["Transform.localEulerAngles.x", "Transform.localEulerAngles.y",
1174
+ "Transform.localEulerAngles.z"]
1175
+ else:
1176
+ raise NotImplementedError(f"clip {name}: binding {r['raw']}")
1177
+ if st["curveCount"] + len(const) != len(props):
1178
+ raise RuntimeError(f"clip {name}: curve count != bound properties")
1179
+ curves = {p: {"keys": []} for p in props[:st["curveCount"]]}
1180
+ for t, keys in streamed_frames(st["data"]):
1181
+ for ci, c0, c1, c2, c3 in keys:
1182
+ curves[props[ci]]["keys"].append([t, c0, c1, c2, c3])
1183
+ for i, v in enumerate(const):
1184
+ curves[props[st["curveCount"] + i]] = {"constant": v}
1185
+ return {"name": name, "sampleRate": tt["m_SampleRate"],
1186
+ "startTime": mc["m_StartTime"], "stopTime": mc["m_StopTime"], "loopTime": bool(mc["m_LoopTime"]),
1187
+ "events": tt["m_Events"], "curves": curves,
1188
+ "keyFormat": "[time, c0, c1, c2, c3]; value(t) = ((c0*x + c1)*x + c2)*x + c3, x = t - time, "
1189
+ "from the last key with time <= t (first key at -FLT_MAX)"}
1190
+
1191
+ # --- animator controllers, sprite atlases --------------------------------
1192
+ def controller_record(self, o) -> dict:
1193
+ tt = o.read_typetree()
1194
+ if o.type.name != "AnimatorController":
1195
+ raise NotImplementedError(f"{o.type.name} {tt.get('m_Name')}")
1196
+ return {"object": o, "tt": tt, "name": tt["m_Name"], "tos": dict(tt["m_TOS"])}
1197
+
1198
+ def controller(self, o) -> dict:
1199
+ """AnimatorController: layers, state machines (states with speed, loop, blend trees, transitions),
1200
+ parameters and default values; clips exported in `clip_format`. Inlined once, or in the
1201
+ `controllers` registry for the livenotes format."""
1202
+ k = _key(o)
1203
+ if k in self._done:
1204
+ return self._done[k]
1205
+ rec = self.controller_record(o)
1206
+ tt, tos = rec["tt"], rec["tos"]
1207
+ registry = self.clip_format == "livenotes"
1208
+ cid = _rid(o, tt["m_Name"]) if registry else tt["m_Name"]
1209
+ ref = {"controller": cid}
1210
+ self._done[k] = ref
1211
+ clips = [self.ref(o, c) for c in tt["m_AnimationClips"]]
1212
+ c = tt["m_Controller"]
1213
+ machines = []
1214
+ for sm in c["m_StateMachineArray"]:
1215
+ sm = sm["data"]
1216
+ states = []
1217
+ for st in sm["m_StateConstantArray"]:
1218
+ st = st["data"]
1219
+ trees = []
1220
+ for bt in st["m_BlendTreeConstantArray"]:
1221
+ nodes = bt["data"]["m_NodeArray"]
1222
+ trees.append([{"clip": n["data"]["m_ClipID"], "duration": n["data"]["m_Duration"],
1223
+ "blendType": n["data"]["m_BlendType"],
1224
+ "children": n["data"]["m_ChildIndices"]} for n in nodes])
1225
+ states.append({
1226
+ "name": tos.get(st["m_NameID"], st["m_NameID"]), "path": tos.get(st["m_FullPathID"]),
1227
+ "speed": st["m_Speed"], "cycleOffset": st["m_CycleOffset"], "loop": st["m_Loop"],
1228
+ "writeDefaultValues": st["m_WriteDefaultValues"], "mirror": st["m_Mirror"],
1229
+ "speedParam": tos.get(st["m_SpeedParamID"], st["m_SpeedParamID"]),
1230
+ "timeParam": tos.get(st["m_TimeParamID"], st["m_TimeParamID"]),
1231
+ "blendTrees": trees,
1232
+ "transitions": [self._transition(t["data"], tos) for t in st["m_TransitionConstantArray"]],
1233
+ })
1234
+ machines.append({"defaultState": sm["m_DefaultState"], "states": states,
1235
+ "anyStateTransitions": [self._transition(t["data"], tos)
1236
+ for t in sm["m_AnyStateTransitionConstantArray"]]})
1237
+ layers = [{"name": tos.get(l["data"]["m_Binding"]), "stateMachine": l["data"]["m_StateMachineIndex"],
1238
+ "defaultWeight": l["data"]["m_DefaultWeight"],
1239
+ "blending": l["data"]["(int&)m_LayerBlendingMode"]} for l in c["m_LayerArray"]]
1240
+ vals = c["m_Values"]["data"]["m_ValueArray"] if "data" in c["m_Values"] else []
1241
+ params = [{"name": tos.get(v["m_ID"], v["m_ID"]), "type": v["m_Type"], "index": v["m_Index"]} for v in vals]
1242
+ body = {"name": tt["m_Name"], "clips": clips, "layers": layers, "stateMachines": machines,
1243
+ "parameters": params, "defaultValues": self.value(o, c["m_DefaultValues"])}
1244
+ if registry:
1245
+ self.controllers[cid] = body
1246
+ return ref
1247
+ return {**ref, **body}
1248
+
1249
+ @staticmethod
1250
+ def _transition(t: dict, tos: dict) -> dict:
1251
+ return {"destination": t["m_DestinationState"], "name": tos.get(t["m_ID"], t["m_ID"]),
1252
+ "duration": t["m_TransitionDuration"], "offset": t["m_TransitionOffset"],
1253
+ "exitTime": t["m_ExitTime"], "hasExitTime": t["m_HasExitTime"],
1254
+ "fixedDuration": t["m_HasFixedDuration"], "canTransitionToSelf": t["m_CanTransitionToSelf"],
1255
+ "conditions": [{"mode": x["data"]["m_ConditionMode"],
1256
+ "event": tos.get(x["data"]["m_EventID"], x["data"]["m_EventID"]),
1257
+ "threshold": x["data"]["m_EventThreshold"], "exitTime": x["data"]["m_ExitTime"]}
1258
+ for x in t["m_ConditionConstantArray"]]}
1259
+
1260
+ def controller_states(self, o) -> dict:
1261
+ """A single-layer AnimatorController without transitions, blend trees or parameter-driven states
1262
+ as its state list (name, speed, loop, cycle offset, clip name) -- what the ADV UI runtime plays."""
1263
+ rec = self.controller_record(o)
1264
+ tt, tos = rec["tt"], rec["tos"]
1265
+ clips = [self.deref(o, p).read().m_Name for p in tt["m_AnimationClips"]]
1266
+ ctrl = tt["m_Controller"]
1267
+ if len(ctrl["m_StateMachineArray"]) != 1 or len(ctrl["m_LayerArray"]) != 1:
1268
+ raise NotImplementedError(f"controller {tt['m_Name']}: several layers")
1269
+ sm = ctrl["m_StateMachineArray"][0]["data"]
1270
+ states = []
1271
+ for st in sm["m_StateConstantArray"]:
1272
+ d = st["data"]
1273
+ motion = []
1274
+ for bt in d["m_BlendTreeConstantArray"]:
1275
+ for node in bt["data"]["m_NodeArray"]:
1276
+ n = node["data"]
1277
+ if n["m_ChildIndices"]:
1278
+ raise NotImplementedError(f"controller {tt['m_Name']}: blend trees")
1279
+ motion.append(clips[n["m_ClipID"]] if n["m_ClipID"] != 0xFFFFFFFF else None)
1280
+ if d["m_SpeedParamID"] or d["m_TimeParamID"] or d["m_CycleOffsetParamID"]:
1281
+ raise NotImplementedError(f"controller {tt['m_Name']}: parameter-driven state")
1282
+ if d["m_TransitionConstantArray"]:
1283
+ raise NotImplementedError(f"controller {tt['m_Name']}: state transitions")
1284
+ states.append({"name": tos[d["m_NameID"]], "speed": d["m_Speed"], "loop": d["m_Loop"],
1285
+ "cycleOffset": d["m_CycleOffset"], "clip": motion[0] if motion else None})
1286
+ if sm["m_AnyStateTransitionConstantArray"]:
1287
+ raise NotImplementedError(f"controller {tt['m_Name']}: any-state transitions")
1288
+ return {"name": tt["m_Name"], "defaultState": states[sm["m_DefaultState"]]["name"], "states": states}
1289
+
1290
+ def atlas(self, o) -> dict:
1291
+ """SpriteAtlas: every packed sprite (name -> sprite with atlas texture and UVs), inlined once."""
1292
+ k = _key(o)
1293
+ if k in self._done:
1294
+ return self._done[k]
1295
+ tt = o.read_typetree()
1296
+ ref = {"atlas": tt["m_Name"]}
1297
+ self._done[k] = ref
1298
+ sprites = {}
1299
+ for p in tt["m_PackedSprites"]:
1300
+ s = self.ref(o, p)
1301
+ if s is not None:
1302
+ sprites[s["sprite"]] = s
1303
+ return {**ref, "tag": tt.get("m_Tag"), "isVariant": tt.get("m_IsVariant"), "sprites": sprites}
1304
+
1305
+ # --- MonoBehaviours, components ------------------------------------------
1306
+ def scriptable(self, o) -> dict:
1307
+ """A MonoBehaviour that is not a component (ScriptableObject asset), inlined once."""
1308
+ k = _key(o)
1309
+ if k in self._done:
1310
+ return self._done[k]
1311
+ cls = self.script_class(o)
1312
+ tt = o.read_typetree()
1313
+ ref = {"asset": cls, "name": tt.get("m_Name", "")}
1314
+ if cls in self.stub_assets:
1315
+ return ref
1316
+ self._done[k] = ref
1317
+ return {**ref, **self.value(o, {x: y for x, y in tt.items() if x not in HEADER})}
1318
+
1319
+ def component(self, o) -> dict:
1320
+ tt = o.read_typetree()
1321
+ self._register_names(o, tt)
1322
+ body = {k: v for k, v in tt.items() if k not in HEADER}
1323
+ out = {"type": o.type.name}
1324
+ if o.type.name == "MonoBehaviour":
1325
+ out["class"] = self.script_class(o)
1326
+ out.update(self.value(o, body))
1327
+ return out
1328
+
1329
+ # --- hierarchies -----------------------------------------------------
1330
+ def hierarchy(self, env, graph: SceneGraph, root_tf: int) -> list[dict]:
1331
+ by_pid = {}
1332
+ for o in env.objects:
1333
+ by_pid.setdefault(o.path_id, []).append(o)
1334
+ nodes = []
1335
+
1336
+ def walk(tf_pid):
1337
+ t = graph.tf[tf_pid]
1338
+ go = graph.go[t["m_GameObject"]["m_PathID"]]
1339
+ comps = []
1340
+ for c in go["m_Component"]:
1341
+ objs = [x for x in by_pid.get(c["component"]["m_PathID"], [])
1342
+ if x.type.name not in ("GameObject",)]
1343
+ if len(objs) != 1:
1344
+ raise RuntimeError(f"{graph.path(tf_pid)}: component {c} resolves to {len(objs)}")
1345
+ co = objs[0]
1346
+ if co.type.name in ("Transform", "RectTransform"):
1347
+ continue
1348
+ comps.append(self.component(co))
1349
+ node = {
1350
+ "path": graph.path(tf_pid), "name": go["m_Name"],
1351
+ "active": bool(go["m_IsActive"]), "layer": go["m_Layer"], "tag": go.get("m_Tag"),
1352
+ "localPosition": t["m_LocalPosition"], "localRotation": t["m_LocalRotation"],
1353
+ "localScale": t["m_LocalScale"],
1354
+ "components": comps,
1355
+ }
1356
+ if "m_AnchorMin" in t:
1357
+ node["rect"] = {k: t[k] for k in ("m_AnchorMin", "m_AnchorMax", "m_AnchoredPosition",
1358
+ "m_SizeDelta", "m_Pivot")}
1359
+ nodes.append(node)
1360
+ for ch in t["m_Children"]:
1361
+ walk(ch["m_PathID"])
1362
+
1363
+ walk(root_tf)
1364
+ return nodes
1365
+
1366
+ # --- keys --------------------------------------------------------------
1367
+ def _container_asset(self, env, internal_id: str):
1368
+ for o in env.objects:
1369
+ if o.type.name != "AssetBundle":
1370
+ continue
1371
+ tt = o.read_typetree()
1372
+ for path, info in tt["m_Container"]:
1373
+ if path.lower() == internal_id.lower():
1374
+ return self.deref(o, info["asset"])
1375
+ raise KeyError(f"{internal_id} not in any AssetBundle container")
1376
+
1377
+ def key_object(self, key: str):
1378
+ """The object an addressable key names (its bundle's container asset)."""
1379
+ env, _ = self.load(key)
1380
+ return self._container_asset(env, self.cat._entry(key)["internal_id"])
1381
+
1382
+ def prefab(self, key: str, loaded=None) -> dict:
1383
+ """A prefab or scene as a node list; `loaded` = (env, graph) from load(key)."""
1384
+ env, graph = loaded or self.load(key)
1385
+ iid = self.cat._entry(key)["internal_id"]
1386
+ if iid.endswith(".unity"):
1387
+ scene_files = set()
1388
+ for o in env.objects:
1389
+ if o.type.name == "AssetBundle":
1390
+ for path, cab in o.read_typetree().get("m_SceneHashes", []):
1391
+ if path.lower() == iid.lower():
1392
+ scene_files.add(cab.lower())
1393
+ roots = [o.path_id for o in env.objects
1394
+ if o.type.name in ("Transform", "RectTransform")
1395
+ and o.assets_file.name.lower().startswith(tuple(scene_files))
1396
+ and not graph.tf[o.path_id]["m_Father"]["m_PathID"]]
1397
+ if not roots:
1398
+ raise RuntimeError(f"{key}: no scene roots")
1399
+ return {"key": key, "scene": True,
1400
+ "nodes": [n for r in sorted(roots, key=graph.path)
1401
+ for n in self.hierarchy(env, graph, r)]}
1402
+ go = self._container_asset(env, iid)
1403
+ if go is None or go.type.name != "GameObject":
1404
+ raise RuntimeError(f"{key}: container asset is {go and go.type.name}")
1405
+ return {"key": key, "nodes": self.hierarchy(env, graph, graph.tf_of_go[go.path_id])}
1406
+
1407
+ def asset(self, key: str) -> dict:
1408
+ o = self.key_object(key)
1409
+ if o.type.name != "MonoBehaviour":
1410
+ raise RuntimeError(f"{key}: asset is {o.type.name}")
1411
+ return {"key": key, **self.scriptable(o)}
1412
+
1413
+ def export_key(self, key: str) -> dict:
1414
+ """Any addressable key by its container asset: prefab or scene (node list), ScriptableObject,
1415
+ AnimatorController, AnimationClip, SpriteAtlas, Material, Texture2D, Sprite, Mesh; other kinds as
1416
+ {object, value} (typetree with references resolved)."""
1417
+ iid = self.cat._entry(key)["internal_id"]
1418
+ if iid.endswith(".unity"):
1419
+ return self.prefab(key)
1420
+ env, graph = self.load(key)
1421
+ o = self._container_asset(env, iid)
1422
+ t = o.type.name
1423
+ if t == "GameObject":
1424
+ return self.prefab(key, (env, graph))
1425
+ kinds = {"MonoBehaviour": self.scriptable, "AnimatorController": self.controller,
1426
+ "AnimatorOverrideController": self.controller, "AnimationClip": self.clip,
1427
+ "SpriteAtlas": self.atlas, "Material": self.material, "Texture2D": self.texture,
1428
+ "Sprite": self.sprite, "Mesh": self.mesh}
1429
+ if t in kinds:
1430
+ return {"key": key, **kinds[t](o)}
1431
+ return {"key": key, "object": t, "value": self.value(o, o.read_typetree())}
1432
+
1433
+ def key_sprite(self, key: str) -> dict:
1434
+ """The Sprite that a texture key's bundle holds for its texture (Addressables sprite sub-asset)."""
1435
+ env, _ = self.load(key)
1436
+ tex = self.key_object(key)
1437
+ hits = []
1438
+ for o in env.objects:
1439
+ if o.type.name == "Sprite":
1440
+ t = self.deref(o, o.read_typetree()["m_RD"]["texture"])
1441
+ if t is not None and t.path_id == tex.path_id:
1442
+ hits.append(o)
1443
+ if len(hits) != 1:
1444
+ raise RuntimeError(f"{key}: {len(hits)} sprites for the texture")
1445
+ return {"key": key, **self.sprite(hits[0])}
1446
+
1447
+ # --- shaders ---------------------------------------------------------
1448
+ def dump_shaders(self, out_dir: Path, objects: dict | None = None) -> tuple[list, dict]:
1449
+ """Dump shaders (name -> Shader object; default every registered one) in name order with
1450
+ shader.py's layout into `out_dir`; returns (index records, write_index summary)."""
1451
+ from . import shader as shader_mod
1452
+ objects = self.shaders if objects is None else objects
1453
+ index: list = []
1454
+ for name in sorted(objects):
1455
+ shader_mod.dump_objects([objects[name]], out_dir, objects[name].assets_file.name, index)
1456
+ return index, shader_mod.write_index(index, out_dir)
1457
+
1458
+ def closure_shaders(self, key: str):
1459
+ env = load_closure(self.cat.fetch_key(key))
1460
+ for o in env.objects:
1461
+ if o.type.name == "Shader":
1462
+ self._shader(o)