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/objexport.py ADDED
@@ -0,0 +1,1564 @@
1
+ """Stages `unity.export` (one bundle -> an artifact per object) and `sprite.crop` (the images of the sprites whose atlas
2
+ is in another bundle).
3
+
4
+ unity.export reads one bundle alone (atom `reader`) and gives every object exactly one item (contract.item):
5
+ `exported` (its artifacts), `contained` (part of another artifact: the objects of a hierarchy in its root's prefab,
6
+ the MonoScripts in `scripts.json`, the AssetBundle objects in `bundle.json`), `generic` (the object's typetree JSON,
7
+ with the reason its converter gave for not making the specialized form), `unsupported` (a class this stage does not
8
+ convert; its typetree JSON is written all the same) or `failed` (not even the typetree JSON could be made). The
9
+ converters (CONVERTERS; each has its own version, and a bundle's task key includes only the converters of the classes
10
+ its census lists):
11
+
12
+ Texture2D tex.png `image`: PNG of the first mip level (HDR ASTC: the .astc file)
13
+ Sprite sprite.png `image`: PNG on the sprite's rect; `meta`: JSON
14
+ Mesh mesh.glb `mesh`: binary glTF in the mesh's local space
15
+ GameObject (a root) prefab.json `prefab`: the hierarchy below it with its components
16
+ Material material.json `json`
17
+ Shader shader.json `json`: the parsed form as `nnnotes shader` writes it; `program:<platform>/
18
+ s<S>p<P>_<stage>_<N>`: each compiled sub-program as stored; `index`: the
19
+ variants (platform, pass, stage, GPU program type, keywords); `typetree`
20
+ AnimationClip clip.json `json`
21
+ AnimatorController controller.json `json`
22
+ MonoBehaviour mono.json `json` (a MonoBehaviour that is not part of a hierarchy); a cue sheet asset
23
+ also `acb`: its ACB (kind cri.acb, the cri.audio stage decodes it); with
24
+ converter cri.usm, a movie asset also `usm`: its USM (kind cri.usm, the
25
+ cri.movie stage converts it)
26
+ TextAsset data `data`: the stored bytes
27
+ Font font `font`: the font file
28
+ MonoScript scripts.json "<task id>#scripts.json": every MonoScript of the bundle
29
+ AssetBundle bundle.json "<task id>#bundle.json": serialized files, externals, AssetBundle objects
30
+ any other class generic.json `json`: the object's typetree
31
+
32
+ Object JSON (canon/1 in the object's own field order):
33
+ - a reference is {"$ref": {"file", "pathId"}}, with "class" and "name" when the target is in the bundle; a null
34
+ reference is null. Inside a prefab, a GameObject, Transform or component of the same prefab is named by its path:
35
+ {"gameObject": path}, {"transform": path}, {"component": class, "gameObject": path};
36
+ - a byte array (vector of UInt8 / SInt8 / char, TypelessData) shorter than `blobMin` bytes is {"$hex": "..."}; a
37
+ longer one is {"$blob": {"sha256", "size", "format"}} with its bytes in the artifact
38
+ "<object id>#blob:<field path>" (field names and array indices joined by "/", the format from atom `sniff`);
39
+ - a string whose stored bytes are not UTF-8 is {"$hex": "<the stored bytes>"};
40
+ - NaN is {"$float": "nan"}, infinities 1e999 / -1e999.
41
+
42
+ A cue sheet held in a bundle is a MonoBehaviour in one of two layouts (cri.py): a SplitAcbData (`_cueSheetName`,
43
+ `_chunks`: TextAssets of this bundle, joined and unmasked into the ACB) or a CriWare.Assets asset named like the sheet
44
+ whose `implementation` managed reference is a CriSerializedBytesAssetImpl (its `data.data` bytes are the ACB: the
45
+ field's {"$blob"} names the `acb` artifact, whatever its size). The artifact's facts name the cue sheet.
46
+ A movie asset (CriWare.Assets CriManaUsmAsset) whose `implementation` is a CriSerializedBytesAssetImpl holds its USM
47
+ the same way: with converter `cri.usm` (in the key of a bundle whose script context names that class) its
48
+ `data.data` bytes are the `usm` artifact, whatever their size, the facts naming the asset.
49
+
50
+ A converter's documented limit (atoms.Unsupported) gives the typetree JSON with its reason code (status `generic`);
51
+ any other error of a converter gives it with `generic.error` and the error's message. The reason codes are listed in
52
+ docs/stages.md.
53
+
54
+ sprite.crop: a sprite whose SpriteAtlas is in another bundle gets its `meta` from unity.export (with the sprite's
55
+ mesh and object facts); one sprite.crop task per atlas then reads the atlas' JSON, the images of its textures and the
56
+ metas, and makes "<sprite object id>#image" exactly as unity.export makes the image of a sprite whose atlas is in its
57
+ own bundle.
58
+ """
59
+ from __future__ import annotations
60
+
61
+ import io
62
+ import json
63
+ import re
64
+ from collections import defaultdict
65
+ from functools import lru_cache
66
+ from pathlib import Path
67
+ from types import SimpleNamespace
68
+
69
+ from . import census as census_mod, contract, link
70
+ from .atoms import ATOMS as ATOM_REGISTRY, Impl, Unsupported, atom_id, resolve
71
+ from .contract import Cost, IncompatibleTask, Input
72
+ from .stages import Output, Pending, Stage
73
+
74
+ EXPORT, CROP, CENSUS, SCRIPTS = "unity.export", "sprite.crop", "unity.census", "link.scripts"
75
+ SCRIPTS_TASK = contract.task_id(SCRIPTS, "all")
76
+
77
+ # converter -> version
78
+ CONVERTERS = {"tex.png": 1, "sprite.png": 1, "mesh.glb": 1, "prefab.json": 1, "material.json": 1, "clip.json": 1,
79
+ "controller.json": 1, "mono.json": 2, "data": 1, "font": 1, "scripts.json": 1, "bundle.json": 1,
80
+ "shader.json": 1, "generic.json": 1, "cri.usm": 1}
81
+ # class -> the converters its objects may use (every class may fall back to generic.json)
82
+ CLASS_CONVERTERS = {"Texture2D": ("tex.png",), "Sprite": ("sprite.png",), "Mesh": ("mesh.glb",),
83
+ "GameObject": ("prefab.json",), "Material": ("material.json",), "AnimationClip": ("clip.json",),
84
+ "AnimatorController": ("controller.json",), "AnimatorOverrideController": ("controller.json",),
85
+ "MonoBehaviour": ("mono.json",), "TextAsset": ("data",), "Font": ("font",),
86
+ "MonoScript": ("scripts.json",), "AssetBundle": ("bundle.json",), "Shader": ("shader.json",)}
87
+ ALWAYS = ("generic.json",)
88
+ BASE_ATOMS = ("reader", "sniff")
89
+ CONVERTER_ATOMS = {"tex.png": ("texture.decode", "png.encode", "astc.container"),
90
+ "sprite.png": ("texture.decode", "png.encode", "sprite.crop", "mesh.arrays"),
91
+ "mesh.glb": ("mesh.arrays", "gltf.write")}
92
+ UNSUPPORTED_CLASSES = ("AudioClip", "VideoClip", "MovieTexture")
93
+ LATE_CLASSES = ("AnimationClip", "AnimatorController", "AnimatorOverrideController")
94
+ FONT_FORMATS = ("ttf", "otf", "ttc", "woff", "woff2")
95
+ # every reason code this module gives (the atoms give more; docs/stages.md lists them all)
96
+ REASONS = ("generic.error", "generic.clip.legacy", "generic.controller.override", "script.unresolved",
97
+ "script.missing", "no_data.sprite", "no_data.font", "unsupported.sprite.external_texture",
98
+ "unsupported.cri.awb_external", "failed.read", "failed.export",
99
+ *(f"unsupported.class.{c}" for c in UNSUPPORTED_CLASSES))
100
+ ACB_KIND = "cri.acb"
101
+ SPLIT_ACB, EMBEDDED_ACB = "split", "embedded"
102
+ EMBEDDED_ACB_IMPL = "CriSerializedBytesAssetImpl"
103
+ # a movie asset holding its USM: converter cri.usm, only in the key of a bundle whose script context names the class
104
+ USM_KIND, USM_CONVERTER = "cri.usm", "cri.usm"
105
+ USM_ASSET_SCRIPT = "CriMw.CriWare.Assets.Runtime|CriWare.Assets|CriManaUsmAsset"
106
+
107
+ _BYTE_TYPES = frozenset({"UInt8", "SInt8", "char"})
108
+ _SCALARS = (int, float, str, bool, type(None))
109
+ _ADDRESS = re.compile(r"0x[0-9a-fA-F]{6,}")
110
+ _EXT = re.compile(r"[0-9a-z]+")
111
+ _NO_SCRIPT = "\0none" # a MonoBehaviour whose m_Script is null
112
+ ATOM_NAMES = frozenset(ATOM_REGISTRY)
113
+
114
+
115
+ def converter_id(name: str) -> str:
116
+ return f"{name}/{CONVERTERS[name]}"
117
+
118
+
119
+ def converters_for(classes) -> list[str]:
120
+ """The converters a bundle holding objects of `classes` may use."""
121
+ out = set(ALWAYS)
122
+ for c in classes:
123
+ out.update(CLASS_CONVERTERS.get(c, ()))
124
+ return sorted(out)
125
+
126
+
127
+ def export_atoms(classes, reader_id: str | None = None) -> dict:
128
+ """The `atoms` part of a unity.export task for a bundle holding `classes`: the atoms and converters it uses."""
129
+ convs = converters_for(classes)
130
+ names = set(BASE_ATOMS)
131
+ for c in convs:
132
+ names.update(CONVERTER_ATOMS.get(c, ()))
133
+ out = {n: (reader_id if n == "reader" and reader_id else atom_id(n)) for n in names}
134
+ out.update((c, converter_id(c)) for c in convs)
135
+ return dict(sorted(out.items()))
136
+
137
+
138
+ def _message(e: BaseException) -> str:
139
+ return _label(_ADDRESS.sub("0x?", f"{type(e).__name__}: {e}")[:500])
140
+
141
+
142
+ def _label(s):
143
+ """A name for a record: the string, with bytes that are not UTF-8 shown as U+FFFD."""
144
+ if not isinstance(s, str) or s.isascii():
145
+ return s
146
+ try:
147
+ s.encode("utf-8")
148
+ return s
149
+ except UnicodeEncodeError:
150
+ return s.encode("utf-8", "surrogateescape").decode("utf-8", "replace")
151
+
152
+
153
+ def _stored(v):
154
+ """`v` with every string that is not UTF-8 (read with surrogateescape) as {"$hex": its stored bytes}."""
155
+ if isinstance(v, str):
156
+ try:
157
+ v.encode("utf-8")
158
+ return v
159
+ except UnicodeEncodeError:
160
+ return {"$hex": v.encode("utf-8", "surrogateescape").hex()}
161
+ if isinstance(v, dict):
162
+ return {_label(k): _stored(x) for k, x in v.items()}
163
+ if isinstance(v, (list, tuple)):
164
+ return [_stored(x) for x in v]
165
+ return v
166
+
167
+
168
+ def encode_object(doc) -> bytes:
169
+ """canon/1 bytes of an object document (its own key order)."""
170
+ try:
171
+ return contract.encode(doc, sort_keys=False)
172
+ except UnicodeEncodeError:
173
+ return contract.encode(_stored(doc), sort_keys=False)
174
+
175
+
176
+ def _canon(v) -> str:
177
+ return json.dumps(v, sort_keys=True, ensure_ascii=True)
178
+
179
+
180
+ def _join(path: str, name: str) -> str:
181
+ return f"{path}/{name}" if path else name
182
+
183
+
184
+ def _file_name(path: str) -> str:
185
+ return path.replace("\\", "/").rsplit("/", 1)[-1]
186
+
187
+
188
+ def _container_ext(path: str | None) -> str | None:
189
+ if not path:
190
+ return None
191
+ last = path.rsplit("/", 1)[-1]
192
+ if "." not in last:
193
+ return None
194
+ ext = last.rsplit(".", 1)[1].lower()
195
+ return ext if _EXT.fullmatch(ext) else None
196
+
197
+
198
+ def acb_asset(tt: dict) -> dict | None:
199
+ """Which cue sheet layout a MonoBehaviour's typetree holds (cri.py): {"layout": "split", "cueSheet", "chunks"}
200
+ for a SplitAcbData, {"layout": "embedded", "cueSheet", "ref": index in references.RefIds, "impl"} for an asset
201
+ whose `implementation` is a CriSerializedBytesAssetImpl; None for any other. SplitAcbData first."""
202
+ sheet = tt.get("_cueSheetName")
203
+ if isinstance(sheet, str) and isinstance(tt.get("_chunks"), list):
204
+ return {"layout": SPLIT_ACB, "cueSheet": sheet, "chunks": tt["_chunks"]}
205
+ ref = embedded_bytes(tt)
206
+ return None if ref is None else {"layout": EMBEDDED_ACB, "cueSheet": tt.get("m_Name"), "ref": ref[0],
207
+ "impl": ref[1]}
208
+
209
+
210
+ def embedded_bytes(tt: dict) -> tuple[int, dict] | None:
211
+ """(index in references.RefIds, the reference) of a CriWare.Assets asset's `implementation` when it is a
212
+ CriSerializedBytesAssetImpl (bytes held in the asset), else None."""
213
+ impl = tt.get("implementation")
214
+ if not isinstance(impl, dict) or "rid" not in impl:
215
+ return None
216
+ ids = ((tt.get("references") or {}).get("RefIds")) or []
217
+ for i, r in enumerate(ids):
218
+ typ = r.get("type") if isinstance(r, dict) else None
219
+ if isinstance(typ, dict) and r.get("rid") == impl["rid"] and typ.get("class") == EMBEDDED_ACB_IMPL:
220
+ return i, r
221
+ return None
222
+
223
+
224
+ def _ref_type_node(ref: dict, assets_file):
225
+ """The type tree of a managed reference's data (UnityPy's get_ref_type_node over the file's ref types)."""
226
+ typ = ref.get("type") or {}
227
+ cls = typ.get("class") if isinstance(typ, dict) else getattr(typ, "class", None)
228
+ if not cls:
229
+ return None
230
+ ns = typ.get("ns") if isinstance(typ, dict) else typ.ns
231
+ asm = typ.get("asm") if isinstance(typ, dict) else typ.asm
232
+ for rt in getattr(assets_file, "ref_types", None) or ():
233
+ if rt.m_ClassName == cls and rt.m_NameSpace == ns and rt.m_AssemblyName == asm:
234
+ return rt.node
235
+ raise ValueError(f"referenced type {asm}|{ns}|{cls} is not among the file's reference types")
236
+
237
+
238
+ class _ReadError(Exception):
239
+ """An object's typetree could not be read."""
240
+
241
+
242
+ class _Script:
243
+ """A MonoScript outside the bundle, known from the task's script table: what Exporter code reads of it."""
244
+ type = SimpleNamespace(name="MonoScript", value=115)
245
+
246
+ def __init__(self, file: str, path_id: int, entry: str):
247
+ self.assets_file = SimpleNamespace(name=file)
248
+ self.path_id = path_id
249
+ asm, ns, cls = (entry.split("|", 2) + ["", ""])[:3]
250
+ self._ms = SimpleNamespace(m_AssemblyName=asm, m_Namespace=ns, m_ClassName=cls, m_Name=cls)
251
+
252
+ def read(self):
253
+ return self._ms
254
+
255
+
256
+ # ---------------------------------------------------------------- the exporter
257
+ class RefExporter:
258
+ """unity.export on one bundle. Combined with export.Exporter (exporter_type), whose material, clip and controller
259
+ code runs here in reference mode: `ref` gives $ref values, `deref` resolves inside the bundle only (a MonoScript
260
+ of another bundle from the script table), and MonoBehaviour classes come from the task's script table."""
261
+
262
+ def __init__(self, view, task, store, fns: dict):
263
+ super().__init__(None, Path("."), class_of=self._script_name)
264
+ self.view, self.task, self.store, self.fns = view, task, store, fns
265
+ self.blob_min = int(task.params["blobMin"])
266
+ self.png_level = int(task.params["png"]["level"])
267
+ self.scripts = dict(task.context.get("scripts", {}))
268
+ self.infos = view.objects()
269
+ self.info = {(i.file, i.path_id): i for i in self.infos}
270
+ self.readers = {k: view.reader(i) for k, i in self.info.items()}
271
+ self._objects = self.readers
272
+ self.files = view.files()
273
+ self.ext_files = {f: [_file_name(p) for p in view.externals(f)] for f in self.files}
274
+ self.versions = {f: view.unity_version(f) for f in self.files}
275
+ self.containers: dict[tuple, str] = {}
276
+ for path, file, pid in view.container():
277
+ k = (file, int(pid))
278
+ if k not in self.containers or path < self.containers[k]:
279
+ self.containers[k] = path
280
+ self.items: dict[str, dict] = {}
281
+ self.artifacts: list[dict] = []
282
+ self.emitted: set[str] = set()
283
+ self.names: dict[tuple, str | None] = {}
284
+ self.script_entry: dict[tuple, str | None] = {}
285
+ self.special_memo: dict[int, tuple] = {}
286
+ self.children_memo: dict[int, tuple] = {}
287
+ self.heads: dict[int, tuple] = {}
288
+ self.atlas_entries: dict[tuple, list] = {}
289
+ self.graphs: dict[str, object] = {}
290
+ self.member: dict[tuple, tuple] = {}
291
+ self.comp_go: dict[tuple, int] = {}
292
+ self.roots: list[tuple] = []
293
+ self.scene_of: dict[str, str] = {}
294
+ self.go_clash = False
295
+ self.current: tuple | None = None
296
+ self.staged: list[dict] = []
297
+ self.blob_ids: list[str] = []
298
+ self.conv = "generic.json"
299
+ self.acb_field: tuple | None = None # (object key, field path, facts) of the embedded ACB being walked
300
+ self.usm_field: tuple | None = None # the same for the USM of a movie asset
301
+ self.usm_assets = USM_CONVERTER in task.atoms
302
+ self.atoms_of: dict[str, dict] = {}
303
+ self.unresolved = 0
304
+ classes = task.params.get("classes")
305
+ self.selected = None if classes is None else frozenset(classes)
306
+ self.has_clips = any(i.class_name == "AnimationClip" for i in self.infos) and self._selected("AnimationClip")
307
+
308
+ # ---------------------------------------------------------------- identities, names, scripts
309
+ @staticmethod
310
+ def _key(o) -> tuple:
311
+ return (o.assets_file.name, int(o.path_id))
312
+
313
+ def _file_of(self, owner_file: str, fid: int):
314
+ if fid == 0:
315
+ return owner_file
316
+ ext = self.ext_files.get(owner_file, ())
317
+ return ext[fid - 1] if 0 < fid <= len(ext) else None
318
+
319
+ def _target(self, owner, pptr: dict):
320
+ """(file, pathId) a PPtr of `owner` names; None for a null reference or an unknown file index."""
321
+ pid = int(pptr.get("m_PathID", 0) or 0)
322
+ if not pid:
323
+ return None
324
+ file = self._file_of(owner.assets_file.name, int(pptr.get("m_FileID", 0) or 0))
325
+ return None if file is None else (file, pid)
326
+
327
+ def _peek(self, r) -> str | None:
328
+ """An object's m_Name, read with its type tree cut after that field."""
329
+ node = r._get_typetree_node()
330
+ hit = self.heads.get(id(node))
331
+ if hit is None or hit[0] is not node:
332
+ names = [c.m_Name for c in node.m_Children]
333
+ cut = None
334
+ if "m_Name" in names:
335
+ from UnityPy.helpers.TypeTreeNode import TypeTreeNode
336
+ cut = TypeTreeNode(node.m_Level, node.m_Type, node.m_Name, node.m_ByteSize, node.m_Version,
337
+ m_MetaFlag=node.m_MetaFlag, m_Children=node.m_Children[:names.index("m_Name") + 1])
338
+ hit = self.heads[id(node)] = (node, cut)
339
+ if hit[1] is None:
340
+ return None
341
+ name = r.read_typetree(hit[1], check_read=False).get("m_Name")
342
+ return name if isinstance(name, str) else None
343
+
344
+ def _name_of(self, key: tuple) -> str | None:
345
+ if key not in self.names:
346
+ r = self.readers.get(key)
347
+ name = None
348
+ if r is not None:
349
+ try:
350
+ name = self._peek(r)
351
+ except Exception:
352
+ name = None
353
+ self.names[key] = name
354
+ return self.names[key]
355
+
356
+ def _tt(self, key: tuple) -> dict:
357
+ try:
358
+ tt = self.view.typetree(self.info[key])
359
+ except Exception as e:
360
+ raise _ReadError(_message(e)) from None
361
+ name = tt.get("m_Name")
362
+ self.names[key] = name if isinstance(name, str) else None
363
+ return tt
364
+
365
+ def _note_script(self, key: tuple, tt: dict):
366
+ s = tt.get("m_Script")
367
+ if not isinstance(s, dict) or not s.get("m_PathID"):
368
+ entry = _NO_SCRIPT
369
+ else:
370
+ file = self._file_of(key[0], int(s.get("m_FileID", 0) or 0))
371
+ entry = None if file is None else self.scripts.get(contract.object_id(file, int(s["m_PathID"])))
372
+ self.script_entry[key] = entry
373
+ return entry
374
+
375
+ def _script_name(self, o) -> str | None:
376
+ """The class name of a MonoBehaviour from the task's script table (None when unknown)."""
377
+ key = self._key(o)
378
+ if key not in self.script_entry:
379
+ self._note_script(key, self._tt(key))
380
+ e = self.script_entry[key]
381
+ return e.rsplit("|", 1)[-1] if e and e != _NO_SCRIPT else None
382
+
383
+ # ---------------------------------------------------------------- references and values
384
+ def _dref(self, key: tuple) -> dict:
385
+ file, pid = key
386
+ d = {"file": _label(file), "pathId": pid}
387
+ r = self.readers.get(key)
388
+ if r is not None:
389
+ d["class"] = r.type.name
390
+ name = self._name_of(key)
391
+ if name is not None:
392
+ d["name"] = _label(name)
393
+ return {"$ref": d}
394
+
395
+ def _local(self, key: tuple) -> dict:
396
+ file, pid = key
397
+ o, g = self.readers[key], self.graphs[file]
398
+ t = o.type.name
399
+ if t == "GameObject":
400
+ return {"gameObject": g.path(g.tf_of_go[pid])}
401
+ if t in ("Transform", "RectTransform"):
402
+ return {"transform": g.path(pid)}
403
+ d = {"component": t, "gameObject": g.path(g.tf_of_go[self.comp_go[key]])}
404
+ if t == "MonoBehaviour":
405
+ d["class"] = self._script_name(o)
406
+ return d
407
+
408
+ def ref(self, owner, pptr: dict):
409
+ pid = int(pptr.get("m_PathID", 0) or 0)
410
+ if not pid:
411
+ return None
412
+ fid = int(pptr.get("m_FileID", 0) or 0)
413
+ file = self._file_of(owner.assets_file.name, fid)
414
+ if file is None:
415
+ return {"$ref": {"fileId": fid, "pathId": pid}}
416
+ key = (file, pid)
417
+ if self.current is not None and self.member.get(key) == self.current:
418
+ return self._local(key)
419
+ return self._dref(key)
420
+
421
+ def deref(self, owner, pptr: dict):
422
+ key = self._target(owner, pptr)
423
+ if key is None:
424
+ return None
425
+ r = self.readers.get(key)
426
+ if r is not None:
427
+ return r
428
+ entry = self.scripts.get(contract.object_id(*key))
429
+ return None if entry is None else _Script(key[0], key[1], entry)
430
+
431
+ def graph(self, o):
432
+ return self.graphs[o.assets_file.name]
433
+
434
+ def value(self, owner, v):
435
+ if isinstance(v, dict):
436
+ if v.keys() == {"m_FileID", "m_PathID"}:
437
+ return self.ref(owner, v)
438
+ return {k: self.value(owner, x) for k, x in v.items()}
439
+ if isinstance(v, (list, tuple)):
440
+ if all(type(x) in _SCALARS for x in v):
441
+ return list(v)
442
+ return [self.value(owner, x) for x in v]
443
+ if isinstance(v, (bytes, bytearray)):
444
+ return {"$hex": bytes(v).hex()}
445
+ return v
446
+
447
+ def _child(self, node, name: str):
448
+ m = self.children_memo.get(id(node))
449
+ if m is None or m[0] is not node:
450
+ m = self.children_memo[id(node)] = (node, {c.m_Name: c for c in node.m_Children})
451
+ return m[1].get(name)
452
+
453
+ def _special(self, node) -> bool:
454
+ """Whether values of `node` need rewriting: references, byte arrays, managed references below it."""
455
+ hit = self.special_memo.get(id(node))
456
+ if hit is not None and hit[0] is node:
457
+ return hit[1]
458
+ t, ch = node.m_Type, node.m_Children
459
+ if t.startswith("PPtr<") or t in ("TypelessData", "ReferencedObject", "ManagedReferencesRegistry"):
460
+ r = True
461
+ elif t == "string" or not ch:
462
+ r = False
463
+ elif ch[0].m_Type == "Array" and len(ch[0].m_Children) > 1:
464
+ sub = ch[0].m_Children[1]
465
+ r = sub.m_Type in _BYTE_TYPES or self._special(sub)
466
+ else:
467
+ r = any(self._special(c) for c in ch)
468
+ self.special_memo[id(node)] = (node, r)
469
+ return r
470
+
471
+ def tree(self, owner, tt: dict):
472
+ """The object JSON of `owner`'s typetree `tt` (read with owner's type tree)."""
473
+ return self._walk(owner, owner._get_typetree_node(), tt, "")
474
+
475
+ def _walk(self, owner, node, v, path: str):
476
+ if not self._special(node):
477
+ return v
478
+ t = node.m_Type
479
+ if t.startswith("PPtr<"):
480
+ return self.ref(owner, v) if isinstance(v, dict) else v
481
+ if t == "TypelessData":
482
+ return self._bytes_value(owner, v, path)
483
+ ch = node.m_Children
484
+ if t == "ReferencedObject":
485
+ out = {}
486
+ for k, x in v.items():
487
+ c = self._child(node, k)
488
+ if c is not None and c.m_Type == "ReferencedObjectData":
489
+ rn = _ref_type_node(v, owner.assets_file)
490
+ out[k] = x if rn is None else self._walk(owner, rn, x, _join(path, k))
491
+ else:
492
+ out[k] = x if c is None else self._walk(owner, c, x, _join(path, k))
493
+ return out
494
+ if t != "string" and ch and ch[0].m_Type == "Array":
495
+ sub = ch[0].m_Children[1]
496
+ if sub.m_Type in _BYTE_TYPES:
497
+ return self._bytes_value(owner, v, path, sub.m_Type == "SInt8")
498
+ return [self._walk(owner, sub, x, _join(path, str(i))) for i, x in enumerate(v)]
499
+ if t == "pair":
500
+ return [self._walk(owner, ch[0], v[0], _join(path, "0")), self._walk(owner, ch[1], v[1], _join(path, "1"))]
501
+ if not isinstance(v, dict):
502
+ return v
503
+ out = {}
504
+ for k, x in v.items():
505
+ c = self._child(node, k)
506
+ out[k] = x if c is None else self._walk(owner, c, x, _join(path, k))
507
+ return out
508
+
509
+ def _bytes_value(self, owner, v, path: str, signed: bool = False) -> dict:
510
+ if isinstance(v, (bytes, bytearray, memoryview)):
511
+ data = bytes(v)
512
+ elif signed:
513
+ import numpy as np
514
+ data = np.asarray(v, np.int16).astype(np.uint8).tobytes()
515
+ else:
516
+ data = bytes(v)
517
+ if self.acb_field is not None and self.acb_field[:2] == (self._key(owner), path):
518
+ from . import cri
519
+ if data[:4] != cri.ACB_SIGNATURE:
520
+ raise ValueError(f"{path}: the cue sheet data is not an ACB (@UTF): {data[:4].hex()}")
521
+ aid, content = self._emit(self._key(owner), "acb", data, "acb", ACB_KIND, "acb", self.acb_field[2])
522
+ self.blob_ids.append(aid)
523
+ return {"$blob": {"sha256": content["sha256"], "size": content["size"], "format": "acb"}}
524
+ if self.usm_field is not None and self.usm_field[:2] == (self._key(owner), path):
525
+ if data[:4] != b"CRID":
526
+ raise ValueError(f"{path}: the movie data is not a USM (CRID): {data[:4].hex()}")
527
+ aid, content = self._emit(self._key(owner), "usm", data, "usm", USM_KIND, "usm", self.usm_field[2])
528
+ self.blob_ids.append(aid)
529
+ return {"$blob": {"sha256": content["sha256"], "size": content["size"], "format": "usm"}}
530
+ if len(data) < self.blob_min:
531
+ return {"$hex": data.hex()}
532
+ s = self.fns["sniff"](data)
533
+ aid, content = self._emit(self._key(owner), "blob:" + path, data, s.ext, "blob", s.format, {"field": path})
534
+ self.blob_ids.append(aid)
535
+ return {"$blob": {"sha256": content["sha256"], "size": content["size"], "format": s.format}}
536
+
537
+ # ---------------------------------------------------------------- artifacts and items
538
+ def _atoms(self, conv: str) -> dict:
539
+ a = self.atoms_of.get(conv)
540
+ if a is None:
541
+ names = (*BASE_ATOMS, *CONVERTER_ATOMS.get(conv, ()), conv)
542
+ a = self.atoms_of[conv] = {n: self.task.atoms[n] for n in sorted(names) if n in self.task.atoms}
543
+ return a
544
+
545
+ def _facts(self, key: tuple) -> dict:
546
+ i = self.info[key]
547
+ d = {"file": _label(i.file), "pathId": i.path_id, "classId": i.class_id, "class": i.class_name}
548
+ name = self._name_of(key)
549
+ if name is not None:
550
+ d["name"] = _label(name)
551
+ if i.type_hash:
552
+ d["typeHash"] = i.type_hash
553
+ d["unityVersion"] = self.versions.get(i.file)
554
+ c = self.containers.get(key)
555
+ if c:
556
+ d["container"] = _label(c)
557
+ return d
558
+
559
+ def _emit(self, key, role: str, data: bytes, ext: str, kind: str, fmt: str, facts: dict, *, issues=(),
560
+ refs=(), owner: str | None = None) -> tuple[str, dict]:
561
+ """Store `data` and stage the artifact "<owner>#<role>" (owner: the object `key`, else a task-level name)."""
562
+ aid = contract.artifact_id(owner or contract.object_id(*key), role)
563
+ if aid in self.emitted or any(a["id"] == aid for a in self.staged):
564
+ raise ValueError(f"artifact {aid} made twice")
565
+ content = self.store.add(data, ext)
566
+ prov = contract.provenance(self.task, atoms=self._atoms(self.conv), obj=None if owner else self._facts(key))
567
+ sem = {"kind": kind, "format": fmt, "facts": facts}
568
+ if issues:
569
+ sem["issues"] = [{"code": c, "message": m} for c, m in issues]
570
+ if refs:
571
+ sem["refs"] = list(refs)
572
+ self.staged.append(contract.artifact(aid, content, prov, sem))
573
+ return aid, content
574
+
575
+ def _begin(self, conv: str) -> None:
576
+ self.staged, self.blob_ids, self.conv = [], [], conv
577
+
578
+ def _commit(self, info, status: str, ids, why: dict | None = None) -> None:
579
+ for a in self.staged:
580
+ self.emitted.add(a["id"])
581
+ self.artifacts.extend(self.staged)
582
+ self.staged = []
583
+ self.items[info.id] = contract.item(info.id, status, artifacts=list(ids), why=why, cls=info.class_name)
584
+
585
+ def _need(self, conv: str) -> None:
586
+ if conv not in self.task.atoms:
587
+ raise IncompatibleTask(f"task {self.task.id}: converter {conv} is not in the task (its census lacks "
588
+ f"the class)")
589
+
590
+ def _convert(self, info, conv: str, fn, *args) -> bool:
591
+ """Run a converter on one object: its artifacts (exported), else the typetree JSON with the reason."""
592
+ if info.id in self.items:
593
+ return False
594
+ self._need(conv)
595
+ self._begin(conv)
596
+ try:
597
+ ids = fn(info, *args)
598
+ except Unsupported as e:
599
+ self._fallback(info, e.code, e.message)
600
+ return False
601
+ except Exception as e:
602
+ self._fallback(info, "generic.error", _message(e))
603
+ return False
604
+ self._commit(info, "exported", [*ids, *self.blob_ids])
605
+ return True
606
+
607
+ def _fallback(self, info, code: str, message: str, status: str = "generic") -> None:
608
+ self._begin("generic.json")
609
+ try:
610
+ aid = self.conv_generic(info)[0]
611
+ except Exception as e:
612
+ self._begin("generic.json")
613
+ what = "failed.read" if isinstance(e, _ReadError) else "failed.export"
614
+ detail = str(e) if isinstance(e, _ReadError) else _message(e)
615
+ self.items[info.id] = contract.item(info.id, "failed", why=contract.reason(
616
+ what, _label(f"{code}: {message}; typetree JSON: {detail}")), cls=info.class_name)
617
+ return
618
+ self._commit(info, status, [aid, *self.blob_ids], contract.reason(code, _label(message)))
619
+
620
+ def _generic(self, info) -> None:
621
+ """An object of a class without a specialized converter: its typetree JSON."""
622
+ if info.id in self.items:
623
+ return
624
+ self._begin("generic.json")
625
+ try:
626
+ ids = self.conv_generic(info)
627
+ except Exception as e:
628
+ self._begin("generic.json")
629
+ what = "failed.read" if isinstance(e, _ReadError) else "failed.export"
630
+ self.items[info.id] = contract.item(info.id, "failed", why=contract.reason(
631
+ what, str(e) if isinstance(e, _ReadError) else _message(e)), cls=info.class_name)
632
+ return
633
+ self._commit(info, "exported", [*ids, *self.blob_ids])
634
+
635
+ def _selected(self, cls: str) -> bool:
636
+ return self.selected is None or cls in self.selected
637
+
638
+ def _of(self, cls: str) -> list:
639
+ """The objects of `cls` this task converts (none when the task's `classes` leave the class out)."""
640
+ if not self._selected(cls):
641
+ return []
642
+ return [i for i in self.infos if i.class_name == cls]
643
+
644
+ # ---------------------------------------------------------------- the bundle
645
+ def run_bundle(self) -> tuple[list, list]:
646
+ self._index()
647
+ self._textures_sprites()
648
+ for i in self._of("Mesh"):
649
+ self._convert(i, "mesh.glb", self.conv_mesh)
650
+ for i in self._of("TextAsset"):
651
+ self._convert(i, "data", self.conv_text)
652
+ for i in self._of("Font"):
653
+ self._convert(i, "font", self.conv_font)
654
+ for i in self._of("Material"):
655
+ self._convert(i, "material.json", self.conv_material)
656
+ for i in self._of("Shader"):
657
+ self._convert(i, "shader.json", self.conv_shader)
658
+ self._prefabs()
659
+ for i in self._of("MonoBehaviour"):
660
+ if i.id not in self.items:
661
+ self._convert(i, "mono.json", self.conv_mono)
662
+ self._loose("AssetBundle", "bundle.json")
663
+ self._loose("MonoScript", "scripts.json")
664
+ for i in self.infos:
665
+ if i.id in self.items or i.class_name in LATE_CLASSES or not self._selected(i.class_name):
666
+ continue
667
+ if i.class_name in UNSUPPORTED_CLASSES:
668
+ self._fallback(i, f"unsupported.class.{i.class_name}", f"{i.class_name} is not converted",
669
+ status="unsupported")
670
+ else:
671
+ self._generic(i)
672
+ for i in self._of("AnimationClip"):
673
+ self._convert(i, "clip.json", self.conv_clip)
674
+ for cls in ("AnimatorController", "AnimatorOverrideController"):
675
+ for i in self._of(cls):
676
+ self._convert(i, "controller.json", self.conv_controller)
677
+ for i in self.infos:
678
+ if i.id not in self.items and self._selected(i.class_name):
679
+ self.items[i.id] = contract.item(i.id, "failed", why=contract.reason(
680
+ "failed.export", "no converter took the object"), cls=i.class_name)
681
+ return self.artifacts, list(self.items.values())
682
+
683
+ def _index(self) -> None:
684
+ """Hierarchies per serialized file (their roots, the objects each root's prefab holds), scene files."""
685
+ from .unity import SceneGraph
686
+ by_file = defaultdict(list)
687
+ for i in self.infos:
688
+ by_file[i.file].append(i)
689
+ for f in self.files:
690
+ objs = [self.readers[(i.file, i.path_id)] for i in by_file[f]
691
+ if i.class_name in ("GameObject", "Transform", "RectTransform")]
692
+ try:
693
+ g = SceneGraph(SimpleNamespace(objects=objs))
694
+ except Exception:
695
+ continue # the file's objects are exported one by one
696
+ self.graphs[f] = g
697
+ if by_file[f]:
698
+ self._graph_of_file[id(self.readers[(f, by_file[f][0].path_id)].assets_file)] = g
699
+ for go_pid in g.go:
700
+ if self._go_file.get(go_pid, f) != f:
701
+ self.go_clash = True
702
+ self._go_file[go_pid] = f
703
+ for tf_pid, t in g.tf.items():
704
+ father = int(t["m_Father"]["m_PathID"] or 0)
705
+ if father and father in g.tf:
706
+ continue
707
+ go_pid = int(t["m_GameObject"]["m_PathID"] or 0)
708
+ members, comps, error = self._subtree(f, g, tf_pid)
709
+ self.roots.append((f, tf_pid, go_pid, members, error))
710
+ if error is None:
711
+ for k in members:
712
+ self.member[k] = (f, go_pid)
713
+ self.comp_go.update(comps)
714
+ for i in self._of("AssetBundle"):
715
+ try:
716
+ tt = self._tt((i.file, i.path_id))
717
+ except _ReadError:
718
+ continue
719
+ for pair in tt.get("m_SceneHashes") or ():
720
+ scene, cab = pair[0], str(pair[1]).lower()
721
+ for f in self.files:
722
+ if cab and f.lower().startswith(cab):
723
+ self.scene_of.setdefault(f, scene)
724
+
725
+ def _subtree(self, f: str, g, root_tf: int):
726
+ """(member keys in hierarchy order, {component key: GameObject pathId}, error) of the hierarchy at root_tf."""
727
+ members, comps, seen, stack = [], {}, set(), [root_tf]
728
+ try:
729
+ while stack:
730
+ tf = stack.pop()
731
+ if tf in seen:
732
+ raise ValueError(f"transform {tf} appears twice in the hierarchy")
733
+ if tf not in g.tf:
734
+ raise ValueError(f"child transform {tf} is not in the file")
735
+ seen.add(tf)
736
+ t = g.tf[tf]
737
+ go_pid = int(t["m_GameObject"]["m_PathID"] or 0)
738
+ if go_pid not in g.go:
739
+ raise ValueError(f"transform {tf}: GameObject {go_pid} is not in the file")
740
+ keys = [(f, go_pid), (f, tf)]
741
+ for c in g.go[go_pid]["m_Component"]:
742
+ p = c["component"]
743
+ pid = int(p["m_PathID"] or 0)
744
+ if not pid or pid == tf:
745
+ continue
746
+ if int(p.get("m_FileID", 0) or 0) or (f, pid) not in self.readers:
747
+ raise ValueError(f"GameObject {go_pid}: component {pid} is not in the file")
748
+ keys.append((f, pid))
749
+ comps[(f, pid)] = go_pid
750
+ for k in keys:
751
+ if k in self.member:
752
+ raise ValueError(f"object {k[1]} belongs to two hierarchies")
753
+ members.extend(keys)
754
+ stack.extend(reversed([int(ch["m_PathID"]) for ch in t["m_Children"]]))
755
+ except Exception as e:
756
+ return members, comps, _message(e)
757
+ return members, comps, None
758
+
759
+ # ---------------------------------------------------------------- textures and sprites
760
+ def _textures_sprites(self) -> None:
761
+ waiting = defaultdict(list)
762
+ for s in self._of("Sprite"):
763
+ self._need("sprite.png")
764
+ self._begin("sprite.png")
765
+ try:
766
+ plan = self._sprite_plan((s.file, s.path_id))
767
+ except Unsupported as e:
768
+ self._fallback(s, e.code, e.message)
769
+ continue
770
+ except Exception as e:
771
+ self._fallback(s, "generic.error", _message(e))
772
+ continue
773
+ if plan["external"]:
774
+ self._convert(s, "sprite.png", self.conv_sprite_external, plan)
775
+ else:
776
+ waiting[plan["texture"]].append((s, plan))
777
+ export_textures = self._selected("Texture2D")
778
+ for t in self.infos:
779
+ if t.class_name != "Texture2D":
780
+ continue
781
+ sprites = waiting.pop((t.file, t.path_id), ())
782
+ if not sprites and not export_textures:
783
+ continue
784
+ holder: dict = {}
785
+ if export_textures:
786
+ self._convert(t, "tex.png", self.conv_texture, holder)
787
+ else:
788
+ self._decode_into(t, holder)
789
+ for s, plan in sprites:
790
+ self._convert(s, "sprite.png", self.conv_sprite, plan, holder)
791
+ holder.clear()
792
+ for tkey, rest in waiting.items():
793
+ for s, _plan in rest:
794
+ self._fallback(s, "no_data.sprite", f"texture {contract.object_id(*tkey)} is not decoded")
795
+
796
+ def _atlas_entry(self, akey: tuple, rd_key) -> dict:
797
+ m = self.atlas_entries.get(akey)
798
+ if m is None:
799
+ at = self._tt(akey)
800
+ m = self.atlas_entries[akey] = [(_canon(k), d) for k, d in at.get("m_RenderDataMap") or ()]
801
+ want = _canon(rd_key)
802
+ for k, d in m:
803
+ if k == want:
804
+ return d
805
+ raise ValueError(f"atlas {contract.object_id(*akey)} has no render data for the sprite's key")
806
+
807
+ def _sprite_plan(self, key: tuple) -> dict:
808
+ """Where a sprite's image comes from (UnityPy's get_image_from_sprite): its SpriteAtlas' render data entry
809
+ when it names an atlas (an atlas of another bundle: the sprite.crop stage), else the atlas of its file
810
+ named by its first atlas tag, else its own render data."""
811
+ r = self.readers[key]
812
+ tt = self._tt(key)
813
+ atlas_key = None
814
+ sa = tt.get("m_SpriteAtlas")
815
+ if isinstance(sa, dict) and sa.get("m_PathID"):
816
+ akey = self._target(r, sa)
817
+ if akey is None:
818
+ raise ValueError("m_SpriteAtlas names an unknown file")
819
+ if akey not in self.readers:
820
+ return {"tt": tt, "external": True, "atlasKey": akey}
821
+ atlas_key = akey
822
+ elif tt.get("m_AtlasTags"):
823
+ tag = tt["m_AtlasTags"][0]
824
+ for i in self.infos:
825
+ if i.file == key[0] and i.class_name == "SpriteAtlas" and self._name_of((i.file, i.path_id)) == tag:
826
+ atlas_key = (i.file, i.path_id)
827
+ break
828
+ if atlas_key is not None:
829
+ src, owner = self._atlas_entry(atlas_key, tt["m_RenderDataKey"]), self.readers[atlas_key]
830
+ else:
831
+ src, owner = tt["m_RD"], r
832
+ tex = src.get("texture") or {}
833
+ if not tex.get("m_PathID"):
834
+ raise Unsupported("no_data.sprite", "the sprite's render data names no texture")
835
+ tkey = self._texture_key(owner, tex)
836
+ alpha = src.get("alphaTexture") or {}
837
+ akey = self._texture_key(owner, alpha) if alpha.get("m_PathID") else None
838
+ return {"tt": tt, "external": False, "src": src, "owner": owner, "atlasKey": atlas_key, "texture": tkey,
839
+ "alpha": akey}
840
+
841
+ def _texture_key(self, owner, pptr: dict) -> tuple:
842
+ k = self._target(owner, pptr)
843
+ if k is None or k not in self.info:
844
+ raise Unsupported("unsupported.sprite.external_texture",
845
+ f"texture {pptr.get('m_FileID')}:{pptr.get('m_PathID')} is outside the bundle")
846
+ if self.info[k].class_name != "Texture2D":
847
+ raise Unsupported("no_data.sprite", f"the texture reference names a {self.info[k].class_name}")
848
+ return k
849
+
850
+ def conv_texture(self, info, holder: dict) -> list:
851
+ from .atoms.texture import ASTC_BLOCKS, format_name, is_hdr_astc
852
+ key = (info.file, info.path_id)
853
+ try:
854
+ tt = self._tt(key)
855
+ inp = self.view.texture_input(info)
856
+ except Exception as e:
857
+ holder["error"] = e
858
+ raise
859
+ fmt, w, h = int(inp.format), int(inp.width), int(inp.height)
860
+ facts = {"width": w, "height": h, "textureFormat": format_name(fmt),
861
+ "mipCount": int(tt.get("m_MipCount", 1) or 1), "exportedMips": [0]}
862
+ if w and h and is_hdr_astc(fmt):
863
+ holder["error"] = Unsupported("unsupported.texture.format",
864
+ f"{format_name(fmt)} (HDR ASTC; the texture is exported as .astc)")
865
+ data = self.fns["astc.container"](inp.data, w, h, ASTC_BLOCKS[fmt])
866
+ return [self._emit(key, "image", data, "astc", "texture.image", "astc", facts)[0]]
867
+ try:
868
+ img = self.fns["texture.decode"](inp)
869
+ except Exception as e:
870
+ holder["error"] = e
871
+ raise
872
+ holder["image"] = img
873
+ facts["mode"] = img.mode
874
+ data = self.fns["png.encode"](img, self.png_level)
875
+ return [self._emit(key, "image", data, "png", "texture.image", "png", facts)[0]]
876
+
877
+ def _decode_into(self, info, holder: dict) -> None:
878
+ """The texture's image for its sprites (a task whose `classes` leave Texture2D out)."""
879
+ from .atoms.texture import format_name, is_hdr_astc
880
+ try:
881
+ inp = self.view.texture_input(info)
882
+ if inp.width and inp.height and is_hdr_astc(inp.format):
883
+ raise Unsupported("unsupported.texture.format", f"{format_name(inp.format)} (HDR ASTC)")
884
+ holder["image"] = self.fns["texture.decode"](inp)
885
+ except Exception as e:
886
+ holder["error"] = e
887
+
888
+ def _decoded(self, key: tuple):
889
+ return self.fns["texture.decode"](self.view.texture_input(self.info[key]))
890
+
891
+ def conv_sprite(self, info, plan: dict, holder: dict) -> list:
892
+ from .atoms import sprite as sprite_atom
893
+ err = holder.get("error")
894
+ if err is not None:
895
+ if isinstance(err, Unsupported):
896
+ raise Unsupported(err.code, f"texture: {err.message}")
897
+ raise RuntimeError(f"texture: {_message(err)}")
898
+ if "image" not in holder:
899
+ raise RuntimeError("texture image not decoded")
900
+ key = (info.file, info.path_id)
901
+ r, tt, src = self.readers[key], plan["tt"], plan["src"]
902
+ alpha = self._decoded(plan["alpha"]) if plan["alpha"] else None
903
+ raw = int(src["settingsRaw"])
904
+ st = sprite_atom.settings(raw)
905
+ mesh = self.fns["mesh.arrays"](self.view.mesh_input(info)) if st.packing_mode == sprite_atom.TIGHT else None
906
+ downscale = float(src.get("downscaleMultiplier", 1.0))
907
+ img = self.fns["sprite.crop"](holder["image"], src["textureRect"], raw, mesh, float(tt["m_PixelsToUnits"]),
908
+ alpha, downscale=downscale,
909
+ canvas={"rect": tt["m_Rect"], "offset": src["textureRectOffset"],
910
+ "pivot": tt["m_Pivot"]})
911
+ data = self.fns["png.encode"](img, self.png_level)
912
+ tex_oid = contract.object_id(*plan["texture"])
913
+ refs = [{"rel": "texture", "object": tex_oid}]
914
+ if plan["atlasKey"] is not None:
915
+ refs.append({"rel": "atlas", "object": contract.object_id(*plan["atlasKey"])})
916
+ image_id = self._emit(key, "image", data, "png", "sprite.image", "png",
917
+ {"width": img.width, "height": img.height, "mode": img.mode}, refs=refs)[0]
918
+ meta = {"sprite": self.tree(r, tt),
919
+ "image": {"source": "atlas" if plan["atlasKey"] is not None else "renderData",
920
+ "atlas": None if plan["atlasKey"] is None else self._dref(plan["atlasKey"]),
921
+ "texture": self._dref(plan["texture"]),
922
+ "alphaTexture": None if plan["alpha"] is None else self._dref(plan["alpha"]),
923
+ "textureRect": src["textureRect"], "textureRectOffset": src["textureRectOffset"],
924
+ "settingsRaw": raw, "packed": st.packed, "packingMode": st.packing_mode,
925
+ "packingRotation": st.packing_rotation, "downscaleMultiplier": downscale,
926
+ "width": img.width, "height": img.height}}
927
+ meta_id = self._emit(key, "meta", encode_object(meta), "json", "sprite.meta", "json", {}, refs=refs)[0]
928
+ return [image_id, meta_id]
929
+
930
+ def conv_sprite_external(self, info, plan: dict) -> list:
931
+ from .atoms import sprite as sprite_atom
932
+ key = (info.file, info.path_id)
933
+ m = self.fns["mesh.arrays"](self.view.mesh_input(info))
934
+ mesh = sprite_atom.mesh_values(m) if "position" in m.channels else None
935
+ atlas_oid = contract.object_id(*plan["atlasKey"])
936
+ meta = {"object": self._facts(key), "sprite": self.tree(self.readers[key], plan["tt"]),
937
+ "image": {"source": "atlas", "atlas": self._dref(plan["atlasKey"]), "stage": CROP}, "mesh": mesh}
938
+ return [self._emit(key, "meta", encode_object(meta), "json", "sprite.meta", "json",
939
+ {"crop": CROP, "atlas": atlas_oid}, refs=[{"rel": "atlas", "object": atlas_oid}])[0]]
940
+
941
+ # ---------------------------------------------------------------- other converters
942
+ def conv_mesh(self, info) -> list:
943
+ key = (info.file, info.path_id)
944
+ m = self.fns["mesh.arrays"](self.view.mesh_input(info))
945
+ glb = self.fns["gltf.write"]([m])
946
+ facts = {"vertexCount": int(m.vertex_count), "submeshes": len(m.submeshes), "channels": list(m.channels),
947
+ "skinned": m.skin is not None}
948
+ return [self._emit(key, "mesh", glb, "glb", "mesh.gltf", "glb", facts, issues=m.issues)[0]]
949
+
950
+ def conv_text(self, info) -> list:
951
+ key = (info.file, info.path_id)
952
+ s = self._tt(key).get("m_Script", "")
953
+ data = s.encode("utf-8", "surrogateescape") if isinstance(s, str) else bytes(s or b"")
954
+ container = self.containers.get(key)
955
+ sn = self.fns["sniff"](data, container)
956
+ ext = _container_ext(container) or sn.ext
957
+ return [self._emit(key, "data", data, ext, "text", sn.format, {"size": len(data)})[0]]
958
+
959
+ def conv_font(self, info) -> list:
960
+ key = (info.file, info.path_id)
961
+ fd = self._tt(key).get("m_FontData") or b""
962
+ data = bytes(fd)
963
+ if not data:
964
+ raise Unsupported("no_data.font", "the font has no font data")
965
+ sn = self.fns["sniff"](data)
966
+ ext = sn.ext if sn.format in FONT_FORMATS else (_container_ext(self.containers.get(key)) or sn.ext)
967
+ return [self._emit(key, "font", data, ext, "font", sn.format, {"size": len(data)})[0]]
968
+
969
+ def conv_material(self, info) -> list:
970
+ key = (info.file, info.path_id)
971
+ doc = self.material(self.readers[key])
972
+ return [self._emit(key, "json", encode_object(doc), "json", "material", "json", {})[0]]
973
+
974
+ def conv_shader(self, info) -> list:
975
+ """The parsed form (the bytes `nnnotes shader` writes as <name>.json), every compiled sub-program as stored
976
+ (`program:<platform>/s<S>p<P>_<stage>_<N>`), the variant index and the typetree JSON."""
977
+ from . import shader
978
+ key = (info.file, info.path_id)
979
+ r, tt = self.readers[key], self._tt(key)
980
+ name = (tt.get("m_ParsedForm") or {}).get("m_Name") or f"shader_{info.path_id}"
981
+ text, recs, codes = shader._render(r, tt, name)
982
+ variants, ids = [], []
983
+ for rec, code in zip(recs, codes):
984
+ fn = rec["file"].rsplit("/", 1)[-1]
985
+ stem, ext = fn.rsplit(".", 1)
986
+ role = f"program:{rec['platform']}/{stem}"
987
+ facts = {k: rec[k] for k in ("platform", "subShader", "pass", "stage", "type", "keywords")}
988
+ ids.append(self._emit(key, role, code, ext, "shader.program", ext, facts)[0])
989
+ variants.append({**rec, "role": role})
990
+ platforms = sorted({v["platform"] for v in variants})
991
+ facts = {"name": _label(name), "variants": len(variants), "platforms": platforms}
992
+ ids.append(self._emit(key, "json", text.encode("utf-8"), "json", "shader", "json", facts)[0])
993
+ ids.append(self._emit(key, "index", encode_object({"name": name, "variants": variants}), "json",
994
+ "shader.index", "json", {"variants": len(variants)})[0])
995
+ ids.append(self._emit(key, "typetree", encode_object(self.tree(r, tt)), "json", "unity.object", "json",
996
+ {})[0])
997
+ return ids
998
+
999
+ def conv_clip(self, info) -> list:
1000
+ key = (info.file, info.path_id)
1001
+ if self.go_clash:
1002
+ raise RuntimeError("GameObject path ids repeat across the bundle's serialized files")
1003
+ try:
1004
+ doc = self.clip(self.readers[key])
1005
+ except NotImplementedError as e:
1006
+ if "legacy/compressed" in str(e):
1007
+ raise Unsupported("generic.clip.legacy", str(e)) from None
1008
+ raise
1009
+ return [self._emit(key, "json", encode_object(doc), "json", "animation.clip", "json", {})[0]]
1010
+
1011
+ def conv_controller(self, info) -> list:
1012
+ key = (info.file, info.path_id)
1013
+ if info.class_name != "AnimatorController":
1014
+ raise Unsupported("generic.controller.override", f"{info.class_name} is exported as its typetree")
1015
+ doc = self.controller(self.readers[key])
1016
+ return [self._emit(key, "json", encode_object(doc), "json", "animator.controller", "json", {})[0]]
1017
+
1018
+ def conv_mono(self, info) -> list:
1019
+ key = (info.file, info.path_id)
1020
+ tt = self._tt(key)
1021
+ entry = self._note_script(key, tt)
1022
+ if entry == _NO_SCRIPT:
1023
+ raise Unsupported("script.missing", "m_Script is null")
1024
+ if entry is None:
1025
+ s = tt.get("m_Script") or {}
1026
+ raise Unsupported("script.unresolved", f"script {s.get('m_FileID')}:{s.get('m_PathID')} is not in the "
1027
+ f"task's script table")
1028
+ movie = embedded_bytes(tt) if self.usm_assets and entry == USM_ASSET_SCRIPT else None
1029
+ acb = acb_asset(tt) if movie is None else None
1030
+ if movie is not None:
1031
+ self.usm_field = (key, f"references/RefIds/{movie[0]}/data/data", {"movie": _label(tt.get("m_Name"))})
1032
+ if acb is not None and acb["layout"] == EMBEDDED_ACB:
1033
+ if ((tt.get("awb") or {}).get("m_PathID")):
1034
+ raise Unsupported("unsupported.cri.awb_external", f"cue sheet {acb['cueSheet']}: its AWB is another "
1035
+ f"object")
1036
+ path = f"references/RefIds/{acb['ref']}/data/data"
1037
+ self.acb_field = (key, path, {"cueSheet": _label(acb["cueSheet"]), "layout": EMBEDDED_ACB})
1038
+ try:
1039
+ doc = {"$script": entry, **self.tree(self.readers[key], tt)}
1040
+ finally:
1041
+ self.acb_field = self.usm_field = None
1042
+ ids = [self._emit(key, "json", encode_object(doc), "json", "mono.json", "json", {"script": entry})[0]]
1043
+ if movie is not None and not any(a["id"] == contract.artifact_id(info.id, "usm") for a in self.staged):
1044
+ raise ValueError(f"movie {tt.get('m_Name')}: no USM bytes in its {EMBEDDED_ACB_IMPL}")
1045
+ if acb is not None and acb["layout"] == EMBEDDED_ACB and not any(
1046
+ a["id"] == contract.artifact_id(info.id, "acb") for a in self.staged):
1047
+ raise ValueError(f"cue sheet {acb['cueSheet']}: no ACB bytes in its {EMBEDDED_ACB_IMPL}")
1048
+ if acb is not None and acb["layout"] == SPLIT_ACB:
1049
+ ids.append(self._split_acb(key, acb))
1050
+ return ids
1051
+
1052
+ def _split_acb(self, key: tuple, acb: dict) -> str:
1053
+ """The `acb` artifact of a SplitAcbData: its chunk TextAssets (of this bundle) joined and unmasked."""
1054
+ from . import cri
1055
+ owner, parts = self.readers[key], []
1056
+ for c in acb["chunks"]:
1057
+ k = self._target(owner, c) if isinstance(c, dict) else None
1058
+ if k is None or k not in self.info or self.info[k].class_name != "TextAsset":
1059
+ raise ValueError(f"cue sheet {acb['cueSheet']}: chunk {c} is not a TextAsset of the bundle")
1060
+ parts.append(cri.text_bytes(self._tt(k).get("m_Script", "")))
1061
+ data = cri.split_acb(parts, acb["cueSheet"])
1062
+ facts = {"cueSheet": _label(acb["cueSheet"]), "layout": SPLIT_ACB, "chunks": len(parts)}
1063
+ return self._emit(key, "acb", data, "acb", ACB_KIND, "acb", facts,
1064
+ refs=[{"rel": "chunk", "object": contract.object_id(*self._target(owner, c))}
1065
+ for c in acb["chunks"]])[0]
1066
+
1067
+ def conv_generic(self, info) -> list:
1068
+ key = (info.file, info.path_id)
1069
+ doc = self.tree(self.readers[key], self._tt(key))
1070
+ return [self._emit(key, "json", encode_object(doc), "json", "unity.object", "json", {})[0]]
1071
+
1072
+ def component(self, o) -> dict:
1073
+ key = self._key(o)
1074
+ tt = self._tt(key)
1075
+ out = {"type": o.type.name, "pathId": key[1]}
1076
+ if o.type.name == "MonoBehaviour":
1077
+ entry = self._note_script(key, tt)
1078
+ out["script"] = entry if entry and entry != _NO_SCRIPT else None
1079
+ if entry is None:
1080
+ self.unresolved += 1
1081
+ if self.has_clips:
1082
+ self._register_names(o, tt)
1083
+ body = self.tree(o, tt)
1084
+ out["fields"] = {k: v for k, v in body.items() if k != "m_GameObject"}
1085
+ return out
1086
+
1087
+ def conv_prefab(self, info, root_tf: int, members: list) -> list:
1088
+ key = (info.file, info.path_id)
1089
+ g = self.graphs[info.file]
1090
+ self.unresolved = 0
1091
+ nodes, stack = [], [root_tf]
1092
+ while stack:
1093
+ tf = stack.pop()
1094
+ t = g.tf[tf]
1095
+ go_pid = int(t["m_GameObject"]["m_PathID"])
1096
+ go = g.go[go_pid]
1097
+ comps = []
1098
+ for c in go["m_Component"]:
1099
+ pid = int(c["component"]["m_PathID"] or 0)
1100
+ if pid and pid != tf:
1101
+ comps.append(self.component(self.readers[(info.file, pid)]))
1102
+ node = {"path": g.path(tf), "name": go["m_Name"], "pathId": go_pid, "transformPathId": tf,
1103
+ "active": bool(go["m_IsActive"]), "layer": go["m_Layer"], "tag": go.get("m_Tag"),
1104
+ "localPosition": t["m_LocalPosition"], "localRotation": t["m_LocalRotation"],
1105
+ "localScale": t["m_LocalScale"]}
1106
+ if "m_AnchorMin" in t:
1107
+ node["rect"] = {k: t[k] for k in ("m_AnchorMin", "m_AnchorMax", "m_AnchoredPosition", "m_SizeDelta",
1108
+ "m_Pivot")}
1109
+ node["components"] = comps
1110
+ nodes.append(node)
1111
+ stack.extend(reversed([int(ch["m_PathID"]) for ch in t["m_Children"]]))
1112
+ doc = {"name": nodes[0]["name"]}
1113
+ if info.file in self.scene_of:
1114
+ doc["scene"] = self.scene_of[info.file]
1115
+ doc["nodes"] = nodes
1116
+ issues = [("script.unresolved", f"{self.unresolved} MonoBehaviours whose script is not in the task's "
1117
+ f"script table")] if self.unresolved else []
1118
+ return [self._emit(key, "prefab", encode_object(doc), "json", "prefab", "json",
1119
+ {"nodes": len(nodes), "objects": len(members)}, issues=issues)[0]]
1120
+
1121
+ def _prefabs(self) -> None:
1122
+ if not self._selected("GameObject"):
1123
+ return
1124
+ for f, tf_pid, go_pid, members, error in self.roots:
1125
+ gkey = (f, go_pid)
1126
+ info = self.info.get(gkey)
1127
+ if error is not None or info is None or info.class_name != "GameObject":
1128
+ why = error or f"transform {tf_pid}: no GameObject"
1129
+ for k in members:
1130
+ if k in self.info and self.info[k].id not in self.items:
1131
+ self._fallback(self.info[k], "generic.error", f"hierarchy at transform {tf_pid}: {why}")
1132
+ continue
1133
+ self.current = gkey
1134
+ try:
1135
+ ok = self._convert(info, "prefab.json", self.conv_prefab, tf_pid, members)
1136
+ finally:
1137
+ self.current = None
1138
+ if ok:
1139
+ within = contract.artifact_id(info.id, "prefab")
1140
+ for k in members:
1141
+ i = self.info[k]
1142
+ if i.id != info.id:
1143
+ self.items[i.id] = contract.item(i.id, "contained", within=within, cls=i.class_name)
1144
+ else:
1145
+ why = self.items[info.id].get("reason") or {"code": "generic.error", "message": ""}
1146
+ for k in members:
1147
+ i = self.info[k]
1148
+ if i.id not in self.items:
1149
+ self._fallback(i, why["code"], f"hierarchy of {info.id}: {why['message']}")
1150
+
1151
+ def _loose(self, cls: str, conv: str) -> None:
1152
+ """Objects of `cls` in one task-level artifact "<task id>#<conv>" (contained in it)."""
1153
+ infos = [i for i in self._of(cls) if i.id not in self.items]
1154
+ if not infos:
1155
+ return
1156
+ self._need(conv)
1157
+ self._begin(conv)
1158
+ entries, done, failed = {}, [], []
1159
+ for i in infos:
1160
+ mark = (len(self.staged), len(self.blob_ids))
1161
+ key = (i.file, i.path_id)
1162
+ try:
1163
+ entries[i.id] = self.tree(self.readers[key], self._tt(key))
1164
+ done.append(i)
1165
+ except Exception as e:
1166
+ del self.staged[mark[0]:], self.blob_ids[mark[1]:]
1167
+ failed.append((i, e))
1168
+ if done:
1169
+ if conv == "bundle.json":
1170
+ doc = {"files": [{"name": f, "unityVersion": self.versions.get(f),
1171
+ "externals": list(self.view.externals(f))} for f in self.files],
1172
+ "assetBundles": entries}
1173
+ kind = "unity.bundle"
1174
+ else:
1175
+ doc = {"scripts": entries}
1176
+ kind = "unity.scripts"
1177
+ aid = self._emit(None, conv, encode_object(doc), "json", kind, "json", {"objects": len(done)},
1178
+ owner=self.task.id)[0]
1179
+ for a in self.staged:
1180
+ self.emitted.add(a["id"])
1181
+ self.artifacts.extend(self.staged)
1182
+ self.staged = []
1183
+ for i in done:
1184
+ self.items[i.id] = contract.item(i.id, "contained", within=aid, cls=i.class_name)
1185
+ for i, e in failed:
1186
+ if isinstance(e, _ReadError):
1187
+ self.items[i.id] = contract.item(i.id, "failed", why=contract.reason("failed.read", str(e)),
1188
+ cls=i.class_name)
1189
+ else:
1190
+ self._fallback(i, "generic.error", _message(e))
1191
+
1192
+
1193
+ @lru_cache(maxsize=None)
1194
+ def exporter_type():
1195
+ """RefExporter combined with export.Exporter (imported here: it loads UnityPy)."""
1196
+ from .export import Exporter
1197
+ return type("ObjectExporter", (RefExporter, Exporter), {})
1198
+
1199
+
1200
+ # ---------------------------------------------------------------- stages
1201
+ def _png_params(p: dict | None) -> dict:
1202
+ png = dict(p or {})
1203
+ unknown = sorted(set(png) - {"level"})
1204
+ if unknown:
1205
+ raise ValueError(f"unknown png parameters {', '.join(unknown)}")
1206
+ level = png.get("level", 6)
1207
+ if not isinstance(level, int) or isinstance(level, bool) or not 0 <= level <= 9:
1208
+ raise ValueError(f"png level {level!r}: expected 0-9")
1209
+ return {"level": level}
1210
+
1211
+
1212
+ def _check_atoms(task, reader: Impl | None = None) -> None:
1213
+ """IncompatibleTask when this installation would run the task with other atoms or converters than it names."""
1214
+ for n, v in task.atoms.items():
1215
+ if n in CONVERTERS:
1216
+ want = converter_id(n)
1217
+ elif n == "reader" and reader is not None:
1218
+ want = reader.id
1219
+ elif n in ATOM_NAMES:
1220
+ want = atom_id(n)
1221
+ else:
1222
+ raise IncompatibleTask(f"task {task.id}: unknown atom or converter {n!r}")
1223
+ if v != want:
1224
+ raise IncompatibleTask(f"task {task.id}: {n} is {want} here, the task names {v}")
1225
+
1226
+
1227
+ class _Docs:
1228
+ """A few JSON documents of the store by content id (the planning reads each census several times)."""
1229
+
1230
+ def __init__(self, size: int = 64):
1231
+ self.size, self.docs = size, {}
1232
+
1233
+ def get(self, store, sha: str) -> dict:
1234
+ doc = self.docs.get(sha)
1235
+ if doc is None:
1236
+ doc = contract.loads(store.read(sha))
1237
+ if len(self.docs) >= self.size:
1238
+ self.docs.pop(next(iter(self.docs)))
1239
+ self.docs[sha] = doc
1240
+ return doc
1241
+
1242
+
1243
+ class ExportStage(Stage):
1244
+ """unity.export: one bundle -> an artifact per object, one item per object (see the module documentation).
1245
+
1246
+ Subjects: the selected bundles (the fact "selected", stable bundle names) and, when a selected bundle holds
1247
+ sprites, the bundles of the fact "bundles" (the selection's closure) that hold a SpriteAtlas: a sprite reaches its
1248
+ atlas texture through the atlas' bundle, whose export makes the texture's image (sprite.crop reads it). Inputs:
1249
+ "bundles" {subject: Input (role "bundle")}, as unity.census. It reads the census of its bundle
1250
+ (unity.census:<subject>) for the classes (the converters and atoms of its key) and the MonoScripts its objects
1251
+ reference, and the script table (link.scripts:all) for the entries of those: its context
1252
+ {"scripts": {object id: "assembly|namespace|class"}}. Parameters: `blobMin` (bytes; byte arrays of this size or
1253
+ more are blobs), `png.level` (zlib level of the PNG files). `reader`: another implementation of the reader atom
1254
+ (its id enters the keys)."""
1255
+ name = EXPORT
1256
+ version = 1
1257
+ after = (CENSUS, SCRIPTS)
1258
+ PARAMS = {"blobMin": 4096, "classes": None, "png": {"level": 6}}
1259
+ LAYOUT_ROOT = "_bundles/{subject}"
1260
+
1261
+ def __init__(self, reader: Impl | None = None):
1262
+ self.reader = reader
1263
+ self._docs = _Docs()
1264
+
1265
+ def __getstate__(self):
1266
+ return {"reader": self.reader}
1267
+
1268
+ def __setstate__(self, state):
1269
+ self.__init__(state["reader"])
1270
+
1271
+ @property
1272
+ def ATOMS(self) -> dict:
1273
+ return dict(sorted({**export_atoms(CLASS_CONVERTERS, self.reader_id()),
1274
+ USM_CONVERTER: converter_id(USM_CONVERTER)}.items()))
1275
+
1276
+ def reader_id(self) -> str:
1277
+ return self.reader.id if self.reader is not None else atom_id("reader")
1278
+
1279
+ def normalize(self, params: dict | None) -> dict:
1280
+ p = dict(params or {})
1281
+ unknown = sorted(set(p) - set(self.PARAMS))
1282
+ if unknown:
1283
+ raise ValueError(f"stage {self.name}: unknown parameters {', '.join(unknown)}")
1284
+ blob = p.get("blobMin", 4096)
1285
+ if not isinstance(blob, int) or isinstance(blob, bool) or blob < 1:
1286
+ raise ValueError(f"stage {self.name}: blobMin {blob!r}: expected a positive integer")
1287
+ classes = p.get("classes")
1288
+ if classes is not None:
1289
+ if isinstance(classes, str) or not all(isinstance(c, str) and c for c in classes):
1290
+ raise ValueError(f"stage {self.name}: classes {classes!r}: expected a list of class names")
1291
+ classes = sorted(set(classes))
1292
+ return {"blobMin": blob, "classes": classes, "png": _png_params(p.get("png"))}
1293
+
1294
+ def subjects(self, env) -> list[str]:
1295
+ selected = set(env.fact("selected"))
1296
+ out = set(selected)
1297
+ if any("Sprite" in _census_classes(env, s) for s in sorted(selected)):
1298
+ out.update(s for s in env.fact("bundles") if s not in out and "SpriteAtlas" in _census_classes(env, s))
1299
+ return sorted(out)
1300
+
1301
+ def inputs(self, subject: str, env) -> list[Input]:
1302
+ inp = env.fact("bundles")[subject]
1303
+ if inp.role != "bundle":
1304
+ raise ValueError(f"{self.name} {subject}: input role {inp.role!r}, expected 'bundle'")
1305
+ return [inp]
1306
+
1307
+ def census(self, subject: str, env) -> dict:
1308
+ inp = env.input_of(contract.task_id(CENSUS, subject), "census")
1309
+ return self._docs.get(env.store, inp.sha256)
1310
+
1311
+ def uses(self, subject: str, env) -> dict:
1312
+ """The atoms and converters of the classes the bundle's census lists; with the task's `classes` (env.params
1313
+ while the task is described), of those of them only; converter cri.usm when a MonoBehaviour of the bundle is
1314
+ a movie asset (its script context names the class)."""
1315
+ classes = list(self.census(subject, env)["classes"])
1316
+ only = (getattr(env, "params", None) or {}).get("classes")
1317
+ if only is not None:
1318
+ keep = set(only)
1319
+ classes = [c for c in classes if c in keep]
1320
+ atoms = export_atoms(classes, self.reader_id())
1321
+ if "MonoBehaviour" in classes and USM_ASSET_SCRIPT in self.context(subject, env).get("scripts", {}).values():
1322
+ atoms = dict(sorted({**atoms, USM_CONVERTER: converter_id(USM_CONVERTER)}.items()))
1323
+ return atoms
1324
+
1325
+ def context(self, subject: str, env) -> dict:
1326
+ refs = census_mod.script_refs(self.census(subject, env))
1327
+ if not refs:
1328
+ return {}
1329
+ table = self._docs.get(env.store, env.input_of(SCRIPTS_TASK, "scripts").sha256)["scripts"]
1330
+ return {"scripts": link.script_context(table, refs)}
1331
+
1332
+ def depends(self, subject: str, env) -> list[str]:
1333
+ return [contract.task_id(CENSUS, subject), SCRIPTS_TASK]
1334
+
1335
+ def estimate(self, subject: str, env, inputs: list[Input]) -> Cost:
1336
+ """From the bundle's size, its census' object count and the size of its resource files (least squares over
1337
+ a whole catalog: CPU seconds; peak resident bytes of a worker process, which grow with the objects)."""
1338
+ size = sum(i.size for i in inputs)
1339
+ try:
1340
+ doc = self.census(subject, env)
1341
+ n = int(doc["objects"])
1342
+ res = sum(int(r.get("size") or 0) for r in doc.get("resources") or ())
1343
+ except Exception:
1344
+ n = res = 0
1345
+ return Cost(0.12 + size * 3.3e-7 + n * 8.8e-5 + res * 6.6e-8, (80 << 20) + 80 * size + 17500 * n)
1346
+
1347
+ def run(self, task, store) -> Output:
1348
+ reader = self.reader if self.reader is not None else resolve("reader")
1349
+ _check_atoms(task, reader)
1350
+ view = reader.fn(store.input_bytes(task.input("bundle")))
1351
+ fns = {n: resolve(n).fn for n in task.atoms if n in ATOM_NAMES and n != "reader"}
1352
+ arts, items = exporter_type()(view, task, store, fns).run_bundle()
1353
+ return Output(arts, items)
1354
+
1355
+
1356
+ def _census_classes(env, subject: str) -> dict:
1357
+ """{class: objects} of a bundle's census ({} when its census has no result)."""
1358
+ try:
1359
+ return env.artifact(contract.task_id(CENSUS, subject), "census")["semantics"]["facts"]["classes"]
1360
+ except (Pending, KeyError):
1361
+ return {}
1362
+
1363
+
1364
+ def _store_input(role: str, record: dict) -> Input:
1365
+ c = record["content"]
1366
+ return Input(role, c["sha256"], c["size"], None, ({"kind": "store"},))
1367
+
1368
+
1369
+ def _pptr_oid(v) -> str | None:
1370
+ """The object id of a {"$ref": {...}} value (None for null or an unresolved file)."""
1371
+ if not isinstance(v, dict) or "$ref" not in v or "file" not in v["$ref"]:
1372
+ return None
1373
+ return contract.object_id(v["$ref"]["file"], v["$ref"]["pathId"])
1374
+
1375
+
1376
+ class SpriteCropStage(Stage):
1377
+ """sprite.crop: the images of the sprites whose SpriteAtlas is in another bundle. One task per atlas, subject
1378
+ "<unity.export subject of the atlas' bundle>:<atlas pathId>"; inputs: "atlas" (the atlas' JSON), "sprite:<object
1379
+ id>" (each sprite's meta), "texture:<object id>" (the image of each atlas texture the sprites use), all artifacts
1380
+ of unity.export results. Waits for unity.export tasks that have not run. Artifacts "<sprite object id>#image"."""
1381
+ name = CROP
1382
+ version = 1
1383
+ after = (EXPORT,)
1384
+ PARAMS = {"png": {"level": 6}}
1385
+ CROP_ATOMS = ("png.encode", "sprite.crop")
1386
+
1387
+ def __init__(self):
1388
+ self._memo = None
1389
+ self._docs = _Docs(256)
1390
+
1391
+ def __getstate__(self):
1392
+ return {}
1393
+
1394
+ def __setstate__(self, state):
1395
+ self.__init__()
1396
+
1397
+ @property
1398
+ def ATOMS(self) -> dict:
1399
+ return {n: atom_id(n) for n in self.CROP_ATOMS}
1400
+
1401
+ def normalize(self, params: dict | None) -> dict:
1402
+ p = dict(params or {})
1403
+ unknown = sorted(set(p) - set(self.PARAMS))
1404
+ if unknown:
1405
+ raise ValueError(f"stage {self.name}: unknown parameters {', '.join(unknown)}")
1406
+ return {"png": _png_params(p.get("png"))}
1407
+
1408
+ @staticmethod
1409
+ def _may_hold(env, tid: str) -> bool:
1410
+ subject = contract.parse_task_id(tid, EXPORT)[1]
1411
+ try:
1412
+ classes = env.artifact(contract.task_id(CENSUS, subject), "census")["semantics"]["facts"]["classes"]
1413
+ except Exception:
1414
+ return True
1415
+ return "Sprite" in classes or "SpriteAtlas" in classes
1416
+
1417
+ def index(self, env) -> dict:
1418
+ """{subjects: {subject: (atlas object id, [(sprite object id, export task id, meta record)])}, atlases,
1419
+ textures, missing: atlas object ids that sprites name but no result holds}."""
1420
+ pending = env.pending(EXPORT)
1421
+ if pending:
1422
+ raise Pending(pending[0])
1423
+ tids = env.tasks(EXPORT)
1424
+ sig = tuple(env.key(t) for t in tids)
1425
+ if self._memo is not None and self._memo[0] == sig:
1426
+ return self._memo[1]
1427
+ atlases, textures, requests = {}, {}, defaultdict(list)
1428
+ for tid in tids:
1429
+ if not self._may_hold(env, tid):
1430
+ continue
1431
+ for a in env.result(tid)["artifacts"]:
1432
+ owner, role = contract.parse_artifact_id(a["id"])
1433
+ sem, obj = a["semantics"], a["provenance"].get("object") or {}
1434
+ facts = sem.get("facts") or {}
1435
+ if sem["kind"] == "sprite.meta" and facts.get("crop") == CROP:
1436
+ requests[facts["atlas"]].append((owner, tid, a))
1437
+ elif role == "json" and obj.get("class") == "SpriteAtlas":
1438
+ atlases[owner] = (tid, a)
1439
+ elif role == "image" and sem["kind"] == "texture.image":
1440
+ textures[owner] = (tid, a)
1441
+ subjects = {}
1442
+ for atlas, reqs in requests.items():
1443
+ if atlas in atlases:
1444
+ tid = atlases[atlas][0]
1445
+ subject = f"{contract.parse_task_id(tid, EXPORT)[1]}:{contract.parse_object_id(atlas)[1]}"
1446
+ subjects[subject] = (atlas, sorted(reqs, key=lambda r: r[0]))
1447
+ idx = {"subjects": subjects, "atlases": atlases, "textures": textures,
1448
+ "missing": sorted(set(requests) - set(atlases))}
1449
+ self._memo = (sig, idx)
1450
+ return idx
1451
+
1452
+ def subjects(self, env) -> list[str]:
1453
+ return sorted(self.index(env)["subjects"])
1454
+
1455
+ def _subject(self, subject: str, env):
1456
+ idx = self.index(env)
1457
+ if subject not in idx["subjects"]:
1458
+ raise ValueError(f"{self.name}: no sprites wait for atlas {subject}")
1459
+ return idx, *idx["subjects"][subject]
1460
+
1461
+ def inputs(self, subject: str, env) -> list[Input]:
1462
+ idx, atlas, reqs = self._subject(subject, env)
1463
+ rec = idx["atlases"][atlas][1]
1464
+ ins = [_store_input("atlas", rec)]
1465
+ entries = _entries(self._docs.get(env.store, rec["content"]["sha256"]))
1466
+ needed = set()
1467
+ for owner, _tid, meta in reqs:
1468
+ ins.append(_store_input("sprite:" + owner, meta))
1469
+ doc = self._docs.get(env.store, meta["content"]["sha256"])
1470
+ entry = _entry(entries, doc["sprite"].get("m_RenderDataKey"))
1471
+ if entry is not None:
1472
+ needed.update(t for t in (_pptr_oid(entry.get("texture")), _pptr_oid(entry.get("alphaTexture")))
1473
+ if t)
1474
+ ins += [_store_input("texture:" + t, idx["textures"][t][1]) for t in sorted(needed) if t in idx["textures"]]
1475
+ return ins
1476
+
1477
+ def uses(self, subject: str, env) -> dict:
1478
+ return dict(self.ATOMS)
1479
+
1480
+ def depends(self, subject: str, env) -> list[str]:
1481
+ idx, atlas, reqs = self._subject(subject, env)
1482
+ return sorted({idx["atlases"][atlas][0], *(tid for _o, tid, _m in reqs)})
1483
+
1484
+ def estimate(self, subject: str, env, inputs: list[Input]) -> Cost:
1485
+ tex = sum(i.size for i in inputs if i.role.startswith("texture:"))
1486
+ n = sum(1 for i in inputs if i.role.startswith("sprite:"))
1487
+ return Cost(0.05 + n * 0.01 + tex * 5e-8, (72 << 20) + 40 * tex)
1488
+
1489
+ def run(self, task, store) -> Output:
1490
+ from PIL import Image
1491
+ from .atoms import sprite as sprite_atom
1492
+ _check_atoms(task)
1493
+ crop, encode = resolve("sprite.crop").fn, resolve("png.encode").fn
1494
+ level = int(task.params["png"]["level"])
1495
+ entries = _entries(contract.loads(store.input_bytes(task.input("atlas"))))
1496
+ images = {}
1497
+
1498
+ def image(oid: str):
1499
+ if oid not in images:
1500
+ try:
1501
+ inp = task.input("texture:" + oid)
1502
+ except KeyError:
1503
+ raise Unsupported("no_data.sprite", f"no image of the atlas texture {oid}") from None
1504
+ im = Image.open(io.BytesIO(store.input_bytes(inp)))
1505
+ im.load()
1506
+ images[oid] = im
1507
+ return images[oid]
1508
+
1509
+ arts, items = [], []
1510
+ for inp in task.inputs:
1511
+ if not inp.role.startswith("sprite:"):
1512
+ continue
1513
+ oid = inp.role[len("sprite:"):]
1514
+ meta = contract.loads(store.input_bytes(inp))
1515
+ try:
1516
+ img = _crop_meta(meta, entries, image, crop, sprite_atom)
1517
+ aid = contract.artifact_id(oid, "image")
1518
+ arts.append(contract.artifact(aid, store.add(encode(img, level), "png"),
1519
+ contract.provenance(task, obj=meta.get("object")),
1520
+ {"kind": "sprite.image", "format": "png",
1521
+ "facts": {"width": img.width, "height": img.height,
1522
+ "mode": img.mode}}))
1523
+ items.append(contract.item(oid, "exported", artifacts=[aid], cls="Sprite"))
1524
+ except Unsupported as e:
1525
+ items.append(contract.item(oid, "unsupported", why=contract.reason(e.code, _label(e.message)),
1526
+ cls="Sprite"))
1527
+ except Exception as e:
1528
+ items.append(contract.item(oid, "failed", why=contract.reason("failed.export", _message(e)),
1529
+ cls="Sprite"))
1530
+ return Output(arts, items)
1531
+
1532
+
1533
+ def _entries(atlas_doc: dict) -> list:
1534
+ return [(_canon(k), d) for k, d in atlas_doc.get("m_RenderDataMap") or ()]
1535
+
1536
+
1537
+ def _entry(entries: list, key):
1538
+ want = _canon(key)
1539
+ return next((d for k, d in entries if k == want), None)
1540
+
1541
+
1542
+ def _crop_meta(meta: dict, entries: list, image, crop, sprite_atom):
1543
+ """A cross-bundle sprite's image from its meta, its atlas' render data entry and the atlas texture's image."""
1544
+ s = meta["sprite"]
1545
+ entry = _entry(entries, s.get("m_RenderDataKey"))
1546
+ if entry is None:
1547
+ raise ValueError("the atlas has no render data for the sprite's key")
1548
+ tex = _pptr_oid(entry.get("texture"))
1549
+ if tex is None:
1550
+ raise Unsupported("no_data.sprite", "the atlas entry names no texture")
1551
+ alpha_oid = _pptr_oid(entry.get("alphaTexture"))
1552
+ raw = int(entry["settingsRaw"])
1553
+ mesh = None
1554
+ if sprite_atom.settings(raw).packing_mode == sprite_atom.TIGHT:
1555
+ if not meta.get("mesh"):
1556
+ raise Unsupported("unsupported.sprite.tight_mask", "tightly packed sprite without a mesh")
1557
+ mesh = sprite_atom.mesh_from_values(meta["mesh"])
1558
+ return crop(image(tex), entry["textureRect"], raw, mesh, float(s["m_PixelsToUnits"]),
1559
+ None if alpha_oid is None else image(alpha_oid),
1560
+ downscale=float(entry.get("downscaleMultiplier", 1.0)),
1561
+ canvas={"rect": s["m_Rect"], "offset": entry["textureRectOffset"], "pivot": s["m_Pivot"]})
1562
+
1563
+
1564
+ STAGES = (ExportStage, SpriteCropStage)