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.
- nnnotes/__init__.py +7 -0
- nnnotes/__main__.py +4 -0
- nnnotes/acb.py +190 -0
- nnnotes/addressables.py +413 -0
- nnnotes/adv.py +243 -0
- nnnotes/advcommand.py +29 -0
- nnnotes/advmedia.py +178 -0
- nnnotes/advscene.py +113 -0
- nnnotes/advui.py +1154 -0
- nnnotes/advvideo.py +684 -0
- nnnotes/atoms/__init__.py +151 -0
- nnnotes/atoms/astc.py +52 -0
- nnnotes/atoms/gltf.py +290 -0
- nnnotes/atoms/mesh.py +289 -0
- nnnotes/atoms/png.py +28 -0
- nnnotes/atoms/reader.py +269 -0
- nnnotes/atoms/sniff.py +198 -0
- nnnotes/atoms/sprite.py +236 -0
- nnnotes/atoms/texture.py +80 -0
- nnnotes/cache.py +289 -0
- nnnotes/catalog.py +373 -0
- nnnotes/catalogdb.py +457 -0
- nnnotes/census.py +276 -0
- nnnotes/cli.py +744 -0
- nnnotes/cli_assets.py +1539 -0
- nnnotes/config.py +238 -0
- nnnotes/contract.py +497 -0
- nnnotes/cri.py +371 -0
- nnnotes/crikey.py +71 -0
- nnnotes/crilips.py +265 -0
- nnnotes/cristages.py +558 -0
- nnnotes/deckdata.py +462 -0
- nnnotes/export.py +1462 -0
- nnnotes/gameapi.py +318 -0
- nnnotes/jsonio.py +98 -0
- nnnotes/languages.py +44 -0
- nnnotes/layout.py +596 -0
- nnnotes/link.py +418 -0
- nnnotes/live.py +115 -0
- nnnotes/live2d.py +235 -0
- nnnotes/liveaudio.py +331 -0
- nnnotes/livenotes.py +291 -0
- nnnotes/liveoptions.py +250 -0
- nnnotes/livescene.py +450 -0
- nnnotes/liveui.py +328 -0
- nnnotes/master.py +251 -0
- nnnotes/modelstages.py +424 -0
- nnnotes/motion.py +131 -0
- nnnotes/objexport.py +1564 -0
- nnnotes/orchestrate.py +630 -0
- nnnotes/plan.py +218 -0
- nnnotes/player.py +392 -0
- nnnotes/room.py +391 -0
- nnnotes/score.py +1139 -0
- nnnotes/shader.py +217 -0
- nnnotes/spot.py +201 -0
- nnnotes/stages.py +285 -0
- nnnotes/store.py +518 -0
- nnnotes/story.py +142 -0
- nnnotes/storyfonts.py +1258 -0
- nnnotes/storyhost.py +1190 -0
- nnnotes/storysite.py +662 -0
- nnnotes/storyvoices.py +102 -0
- nnnotes/textstyle.py +355 -0
- nnnotes/tmpfont.py +636 -0
- nnnotes/typetrees/6000.3.12f1.json +19 -0
- nnnotes/unity.py +368 -0
- nnnotes/viewrules.json +441 -0
- nnnotes/views.py +966 -0
- nnnotes/voices.py +1363 -0
- nnnotes/web.py +1624 -0
- nnnotes/webaudio.py +14 -0
- nnnotes/webmodel.py +397 -0
- nnnotes-0.1.0.dist-info/METADATA +200 -0
- nnnotes-0.1.0.dist-info/RECORD +79 -0
- nnnotes-0.1.0.dist-info/WHEEL +5 -0
- nnnotes-0.1.0.dist-info/entry_points.txt +2 -0
- nnnotes-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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)
|