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/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)
|