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/atoms/mesh.py ADDED
@@ -0,0 +1,289 @@
1
+ """Atom `mesh.arrays`: every vertex channel, the primitives and the skin of a Mesh (or a Sprite's render mesh).
2
+
3
+ The input (`MeshInput`, made by a reader) carries the vertex and index buffers as stored. Vertex buffers of Unity
4
+ 2018.2 and later are decoded here: channel formats (VertexFormat; VertexFormat2017 before 2019) and dimensions come
5
+ from the channel table, streams are laid out as Unity lays them (each stream's vertices packed, streams 16-byte
6
+ aligned). A channel keeps its stored component type and its dimension (the low four bits of the stored dimension
7
+ byte: a normal stored as four half floats has 4 float16 components); UNorm / SNorm channels are marked normalized.
8
+ The stored channel table, dimension bytes included, is kept in `MeshArrays.layout`. Meshes of other layouts (older
9
+ versions, compressed meshes) come already decoded by the reader (`MeshInput.decoded`, UnityPy's MeshHandler).
10
+
11
+ Primitives: one per submesh, with the submesh's indices plus its baseVertex. Triangle lists are (n, 3) arrays in
12
+ Unity's winding; quads become two triangles each and triangle strips a triangle list (degenerate triangles dropped,
13
+ every other triangle's winding swapped), as UnityPy's MeshHandler.get_triangles does; lines, line strips and points
14
+ stay what they are. A mesh without an index buffer has one `points` primitive without indices (every vertex).
15
+
16
+ Skin: the BlendIndices / BlendWeight channels as four joints and four weights per vertex (unused slots 0); bind
17
+ poses, bone name hashes and the root bone hash as serialized. What this atom does not convert is listed in
18
+ `MeshArrays.issues` as (reason code, message): skins with more than four weights per vertex or inconsistent
19
+ channels (`partial.mesh.skin`), blend shapes (`unsupported.mesh.blendshapes`).
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass, field
24
+
25
+ import numpy as np
26
+
27
+ from . import Unsupported, estimate
28
+
29
+ # channel index (Unity 2018 and later) -> name
30
+ CHANNELS = ("position", "normal", "tangent", "color", "uv0", "uv1", "uv2", "uv3", "uv4", "uv5", "uv6", "uv7",
31
+ "blendWeight", "blendIndices")
32
+ # vertex format -> (numpy dtype, normalized)
33
+ FORMATS_2019 = {0: ("<f4", False), 1: ("<f2", False), 2: ("u1", True), 3: ("i1", True), 4: ("<u2", True),
34
+ 5: ("<i2", True), 6: ("u1", False), 7: ("i1", False), 8: ("<u2", False), 9: ("<i2", False),
35
+ 10: ("<u4", False), 11: ("<i4", False)}
36
+ FORMATS_2017 = {0: ("<f4", False), 1: ("<f2", False), 2: ("u1", True), 3: ("u1", True), 4: ("i1", True),
37
+ 5: ("<u2", True), 6: ("<i2", True), 7: ("u1", False), 8: ("i1", False), 9: ("<u2", False),
38
+ 10: ("<i2", False), 11: ("<u4", False), 12: ("<i4", False)}
39
+ TOPOLOGIES = {0: "triangles", 1: "triangle_strip", 2: "quads", 3: "lines", 4: "line_strip", 5: "points"}
40
+ RAW_LAYOUT_SINCE = (2018, 2)
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Submesh:
45
+ first_byte: int
46
+ index_count: int
47
+ topology: int = 0
48
+ base_vertex: int = 0
49
+ first_vertex: int = 0
50
+ vertex_count: int = 0
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class MeshInput:
55
+ """A mesh as stored. `channels`: the channel table ((stream, offset, format, dimension) per channel index),
56
+ `vertex_data` the vertex buffer (the streamed resource data when the mesh streams it); `index_buffer` raw bytes
57
+ of `index_bits`-bit indices, or a sequence of ints when the reader decoded it; `decoded`: {channel name:
58
+ (array (vertices, components), normalized)} for meshes whose buffers the reader decoded; `bind_poses`:
59
+ Matrix4x4f typetrees (e00 .. e33)."""
60
+ name: str
61
+ version: tuple
62
+ vertex_count: int
63
+ channels: tuple = ()
64
+ vertex_data: bytes = b""
65
+ index_buffer: object = b""
66
+ index_bits: int = 16
67
+ submeshes: tuple = ()
68
+ decoded: dict | None = None
69
+ bind_poses: tuple = ()
70
+ bone_name_hashes: tuple = ()
71
+ root_bone_name_hash: int | None = None
72
+ blend_shapes: int = 0
73
+ variable_bone_weights: int = 0
74
+
75
+
76
+ @dataclass
77
+ class Channel:
78
+ data: np.ndarray # (vertices, components), the stored component type
79
+ normalized: bool = False # integers standing for [0, 1] (unsigned) or [-1, 1] (signed)
80
+
81
+ def as_float(self) -> np.ndarray:
82
+ """The values as float32 (normalized integers dequantized as Unity does: v / max, signed clamped to -1)."""
83
+ a = self.data
84
+ if a.dtype.kind == "f":
85
+ return a.astype(np.float32)
86
+ if not self.normalized:
87
+ return a.astype(np.float32)
88
+ top = float(np.iinfo(a.dtype).max)
89
+ f = a.astype(np.float64) / top
90
+ return (np.maximum(f, -1.0) if a.dtype.kind == "i" else f).astype(np.float32)
91
+
92
+
93
+ @dataclass
94
+ class Primitive:
95
+ topology: str # triangles | lines | line_strip | points
96
+ indices: np.ndarray | None # triangles (n, 3), others flat; absolute vertex indices; None: every vertex
97
+ submesh: int | None = None # submesh index (None: the mesh has no index buffer)
98
+
99
+
100
+ @dataclass
101
+ class Skin:
102
+ joints: np.ndarray # (vertices, 4) uint16
103
+ weights: Channel # (vertices, 4)
104
+
105
+
106
+ @dataclass
107
+ class MeshArrays:
108
+ name: str
109
+ vertex_count: int
110
+ channels: dict = field(default_factory=dict) # name -> Channel, in channel order
111
+ primitives: list = field(default_factory=list)
112
+ submeshes: list = field(default_factory=list) # Submesh per submesh, as stored
113
+ skin: Skin | None = None
114
+ bind_poses: np.ndarray = field(default_factory=lambda: np.zeros((0, 4, 4), np.float32)) # row-major, Unity space
115
+ bone_name_hashes: list = field(default_factory=list)
116
+ root_bone_name_hash: int | None = None
117
+ layout: list = field(default_factory=list) # the stored channel table (vertex buffers decoded here)
118
+ issues: list = field(default_factory=list) # (reason code, message)
119
+
120
+
121
+ def _formats(version: tuple) -> dict:
122
+ return FORMATS_2019 if tuple(version)[:1] >= (2019,) else FORMATS_2017
123
+
124
+
125
+ def decode_vertex_data(inp: MeshInput) -> dict:
126
+ """{channel name: Channel} of a Unity 2018.2+ vertex buffer."""
127
+ if tuple(inp.version)[:2] < RAW_LAYOUT_SINCE:
128
+ raise ValueError(f"mesh {inp.name!r}: vertex layout of Unity {inp.version[:2]} is decoded by the reader")
129
+ count = int(inp.vertex_count)
130
+ if not count or not inp.channels:
131
+ return {}
132
+ formats = _formats(inp.version)
133
+ chans = []
134
+ for i, (stream, offset, fmt, dim) in enumerate(inp.channels):
135
+ dim = int(dim) & 0xF
136
+ if not dim:
137
+ continue
138
+ if i >= len(CHANNELS):
139
+ raise ValueError(f"mesh {inp.name!r}: channel {i} of {len(inp.channels)}")
140
+ if int(fmt) not in formats:
141
+ raise ValueError(f"mesh {inp.name!r}: channel {CHANNELS[i]}: vertex format {fmt}")
142
+ dtype, norm = formats[int(fmt)]
143
+ chans.append((i, int(stream), int(offset), np.dtype(dtype), norm, dim))
144
+ streams, pos = {}, 0
145
+ for s in range(1 + max(int(c[0]) for c in inp.channels)):
146
+ stride = sum(dt.itemsize * dim for _, st, _, dt, _, dim in chans if st == s)
147
+ streams[s] = (pos, stride)
148
+ pos = (pos + count * stride + 15) & ~15
149
+ raw = np.frombuffer(bytes(inp.vertex_data), np.uint8)
150
+ out = {}
151
+ for i, s, offset, dt, norm, dim in chans:
152
+ start, stride = streams[s]
153
+ end = start + count * stride
154
+ if end > raw.size or offset + dt.itemsize * dim > stride:
155
+ raise ValueError(f"mesh {inp.name!r}: channel {CHANNELS[i]} outside the vertex data "
156
+ f"({raw.size} bytes, stream {s} ends at {end})")
157
+ cols = raw[start:end].reshape(count, stride)[:, offset:offset + dt.itemsize * dim]
158
+ values = np.ascontiguousarray(cols).view(dt).reshape(count, dim)
159
+ out[CHANNELS[i]] = Channel(values.astype(dt.newbyteorder("=")), norm)
160
+ return out
161
+
162
+
163
+ def _indices(inp: MeshInput) -> np.ndarray:
164
+ ib = inp.index_buffer
165
+ if isinstance(ib, (bytes, bytearray, memoryview)):
166
+ if inp.index_bits not in (16, 32):
167
+ raise ValueError(f"mesh {inp.name!r}: {inp.index_bits}-bit indices")
168
+ return np.frombuffer(bytes(ib), "<u2" if inp.index_bits == 16 else "<u4").astype(np.int64)
169
+ return np.asarray(list(ib), np.int64)
170
+
171
+
172
+ def _strip(idx: np.ndarray) -> np.ndarray:
173
+ tris = []
174
+ for i in range(len(idx) - 2):
175
+ a, b, c = (int(x) for x in idx[i:i + 3])
176
+ if a == b or a == c or b == c:
177
+ continue
178
+ tris.append((b, a, c) if i & 1 else (a, b, c))
179
+ return np.asarray(tris, np.int64).reshape(-1, 3)
180
+
181
+
182
+ def _primitives(inp: MeshInput, count: int) -> list[Primitive]:
183
+ idx = _indices(inp)
184
+ if not idx.size or not inp.submeshes:
185
+ return [Primitive("points", None)]
186
+ per = 2 if inp.index_bits == 16 else 4
187
+ prims = []
188
+ for n, sm in enumerate(inp.submeshes):
189
+ first = int(sm.first_byte) // per
190
+ part = idx[first:first + int(sm.index_count)]
191
+ if len(part) != int(sm.index_count):
192
+ raise ValueError(f"mesh {inp.name!r}: submesh {n} indices {first}..{first + sm.index_count} of "
193
+ f"{len(idx)}")
194
+ topology = TOPOLOGIES.get(int(sm.topology or 0))
195
+ if topology is None:
196
+ raise ValueError(f"mesh {inp.name!r}: submesh {n} topology {sm.topology}")
197
+ if topology == "triangles":
198
+ if len(part) % 3:
199
+ raise ValueError(f"mesh {inp.name!r}: submesh {n}: {len(part)} triangle indices")
200
+ part = part.reshape(-1, 3)
201
+ elif topology == "triangle_strip":
202
+ part, topology = _strip(part), "triangles"
203
+ elif topology == "quads":
204
+ if len(part) % 4:
205
+ raise ValueError(f"mesh {inp.name!r}: submesh {n}: {len(part)} quad indices")
206
+ q = part.reshape(-1, 4)
207
+ part, topology = np.stack([q[:, [0, 1, 2]], q[:, [0, 2, 3]]], 1).reshape(-1, 3), "triangles"
208
+ part = part + int(sm.base_vertex or 0)
209
+ if part.size and (part.min() < 0 or part.max() >= count):
210
+ raise ValueError(f"mesh {inp.name!r}: submesh {n} index {int(part.max())} of {count} vertices")
211
+ prims.append(Primitive(topology, part, n))
212
+ return prims
213
+
214
+
215
+ def _skin(channels: dict, inp: MeshInput, issues: list) -> Skin | None:
216
+ ix, w = channels.get("blendIndices"), channels.get("blendWeight")
217
+ if inp.variable_bone_weights:
218
+ issues.append(("partial.mesh.skin", f"{inp.variable_bone_weights} variable bone count weights (more than "
219
+ f"four bones per vertex) not converted; four per vertex kept"))
220
+ if ix is None:
221
+ if w is not None:
222
+ issues.append(("partial.mesh.skin", "BlendWeight without BlendIndices"))
223
+ return None
224
+ count, k = ix.data.shape
225
+ if w is None:
226
+ if k != 1:
227
+ issues.append(("partial.mesh.skin", f"{k} BlendIndices per vertex without BlendWeight"))
228
+ return None
229
+ weights = Channel(np.zeros((count, 4), np.float32))
230
+ weights.data[:, 0] = 1.0
231
+ else:
232
+ if w.data.shape[1] != k:
233
+ issues.append(("partial.mesh.skin", f"{w.data.shape[1]} BlendWeight / {k} BlendIndices per vertex"))
234
+ return None
235
+ wd = np.zeros((count, 4), w.data.dtype)
236
+ wd[:, :k] = w.data
237
+ weights = Channel(wd, w.normalized)
238
+ if ix.data.dtype.kind == "f" or (ix.data.size and (ix.data.min() < 0 or ix.data.max() > 0xFFFF)):
239
+ issues.append(("partial.mesh.skin", "BlendIndices outside 0..65535"))
240
+ return None
241
+ joints = np.zeros((count, 4), np.uint16)
242
+ joints[:, :k] = ix.data
243
+ return Skin(joints, weights)
244
+
245
+
246
+ def _matrix(m: dict) -> list:
247
+ return [[m[f"e{r}{c}"] for c in range(4)] for r in range(4)]
248
+
249
+
250
+ def arrays(inp: MeshInput) -> MeshArrays:
251
+ count = int(inp.vertex_count)
252
+ if inp.decoded is not None:
253
+ channels = {}
254
+ for name in CHANNELS:
255
+ if name in inp.decoded:
256
+ data, norm = inp.decoded[name]
257
+ a = np.asarray(data)
258
+ a = a.reshape(len(a), -1) if a.ndim == 1 and count and a.size == count else a
259
+ if a.ndim != 2 or a.shape[0] != count:
260
+ raise ValueError(f"mesh {inp.name!r}: decoded {name} shape {a.shape} for {count} vertices")
261
+ channels[name] = Channel(a, bool(norm))
262
+ else:
263
+ channels = decode_vertex_data(inp)
264
+ issues: list = []
265
+ out = MeshArrays(inp.name, count, channels, _primitives(inp, count) if count else [], list(inp.submeshes),
266
+ _skin(channels, inp, issues), bone_name_hashes=[int(h) for h in inp.bone_name_hashes],
267
+ root_bone_name_hash=inp.root_bone_name_hash, issues=issues)
268
+ if inp.decoded is None:
269
+ out.layout = [{"channel": CHANNELS[i] if i < len(CHANNELS) else str(i), "stream": int(st),
270
+ "offset": int(off), "format": int(fmt), "dimension": int(dim)}
271
+ for i, (st, off, fmt, dim) in enumerate(inp.channels) if int(dim) & 0xF]
272
+ if inp.bind_poses:
273
+ out.bind_poses = np.asarray([_matrix(m) for m in inp.bind_poses], np.float32).reshape(-1, 4, 4)
274
+ if inp.blend_shapes:
275
+ issues.append(("unsupported.mesh.blendshapes", f"{inp.blend_shapes} blend shapes not converted"))
276
+ return out
277
+
278
+
279
+ def require_vertices(m: MeshArrays) -> None:
280
+ """Unsupported (`empty.mesh`) for a mesh without vertices."""
281
+ if not m.vertex_count or "position" not in m.channels:
282
+ raise Unsupported("empty.mesh", f"mesh {m.name!r} has no vertices")
283
+
284
+
285
+ def cost(facts: dict) -> dict:
286
+ """facts: vertexCount, indexCount, vertexBytes."""
287
+ v, i = int(facts.get("vertexCount", 0)), int(facts.get("indexCount", 0))
288
+ vb = int(facts.get("vertexBytes", v * 48))
289
+ return estimate(2e-5 + v * 1e-7 + i * 2e-8, vb * 4 + i * 16)
nnnotes/atoms/png.py ADDED
@@ -0,0 +1,28 @@
1
+ """Atom `png.encode`: PNG bytes of a PIL image, written by Pillow.
2
+
3
+ Level 6 (the default) gives the bytes Pillow writes without options (`image.save(f, "PNG")`), which the other
4
+ commands write for textures; other levels (0-9) change the bytes, never the pixels.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import io
9
+
10
+ from . import estimate
11
+
12
+ DEFAULT_LEVEL = 6
13
+
14
+
15
+ def encode(image, level: int = DEFAULT_LEVEL) -> bytes:
16
+ """PNG bytes of `image` (any mode Pillow writes as PNG) at zlib level `level`."""
17
+ if not isinstance(level, int) or isinstance(level, bool) or not 0 <= level <= 9:
18
+ raise ValueError(f"PNG level {level!r}: expected 0-9")
19
+ buf = io.BytesIO()
20
+ image.save(buf, format="PNG", compress_level=level)
21
+ return buf.getvalue()
22
+
23
+
24
+ def cost(facts: dict) -> dict:
25
+ """facts: width, height, channels (default 4), level (default 6)."""
26
+ raw = int(facts.get("width", 0)) * int(facts.get("height", 0)) * int(facts.get("channels", 4))
27
+ per_byte = {0: 1e-9, 1: 4e-9, 2: 4e-9, 3: 5e-9}.get(int(facts.get("level", DEFAULT_LEVEL)), 1.2e-8)
28
+ return estimate(5e-5 + raw * per_byte, raw * 2)
@@ -0,0 +1,269 @@
1
+ """Atom `reader`: one bundle's serialized files, objects and the inputs of the other atoms.
2
+
3
+ `BundleView` is what a stage reads a bundle through; `UnityBundle` is the reference implementation (UnityPy, the
4
+ bundle loaded alone from its bytes: references into other bundles are not followed, resource files inside the
5
+ bundle -- `.resS` -- are read). Objects are named by `ObjectInfo` (serialized file name and path id), never by
6
+ library objects, so another implementation can stand in.
7
+
8
+ `texture_input` and `mesh_input` collect exactly what `texture.decode` and `mesh.arrays` need: a Texture2D's image
9
+ data (the streamed resource data when it is streamed), size, format, Unity version, build target and platform blob;
10
+ a Mesh's (or a Sprite's render data's) vertex buffer, channel table, index buffer and submeshes as stored. Meshes of
11
+ layouts `mesh.arrays` does not decode itself (compressed meshes, Unity before 2018.2) are decoded here by UnityPy's
12
+ MeshHandler.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+ from typing import Protocol
18
+
19
+ from . import estimate
20
+ from .mesh import CHANNELS, RAW_LAYOUT_SINCE, MeshInput, Submesh
21
+ from .texture import TextureInput
22
+ from .. import contract
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ObjectInfo:
27
+ file: str # serialized file name (CAB-...)
28
+ path_id: int
29
+ class_id: int
30
+ class_name: str
31
+ byte_size: int
32
+ type_hash: str | None = None # the type's stored hash (hex), None when the file does not store it
33
+
34
+ @property
35
+ def id(self) -> str:
36
+ return contract.object_id(self.file, self.path_id)
37
+
38
+
39
+ class BundleView(Protocol):
40
+ def files(self) -> list[str]:
41
+ """Serialized file names, in bundle order."""
42
+
43
+ def objects(self) -> list[ObjectInfo]:
44
+ """Every object, file by file in bundle order, each file's objects in stored order."""
45
+
46
+ def raw(self, obj: ObjectInfo) -> bytes:
47
+ """The object's serialized bytes."""
48
+
49
+ def typetree(self, obj: ObjectInfo) -> dict:
50
+ """The object read through its typetree, fields in stored order."""
51
+
52
+ def container(self) -> list[tuple[str, str, int]]:
53
+ """(container path, serialized file, path id) of every container entry, in stored order."""
54
+
55
+ def externals(self, file: str) -> list[str]:
56
+ """The paths of a serialized file's externals (PPtr file id i names externals[i - 1])."""
57
+
58
+ def unity_version(self, file: str) -> str:
59
+ """The Unity version a serialized file was written by."""
60
+
61
+ def texture_input(self, obj: ObjectInfo) -> TextureInput:
62
+ ...
63
+
64
+ def mesh_input(self, obj: ObjectInfo) -> MeshInput:
65
+ ...
66
+
67
+
68
+ def _bytes(v) -> bytes:
69
+ return v if isinstance(v, bytes) else bytes(v or b"")
70
+
71
+
72
+ def _file_name(path: str) -> str:
73
+ """The serialized file an external path names (archive:/CAB-x/CAB-x -> CAB-x)."""
74
+ return path.replace("\\", "/").rsplit("/", 1)[-1]
75
+
76
+
77
+ def submeshes(tt_submeshes) -> tuple:
78
+ return tuple(Submesh(int(s["firstByte"]), int(s["indexCount"]), int(s.get("topology", 0) or 0),
79
+ int(s.get("baseVertex", 0) or 0), int(s.get("firstVertex", 0) or 0),
80
+ int(s.get("vertexCount", 0) or 0))
81
+ for s in tt_submeshes or ())
82
+
83
+
84
+ def mesh_input_from_typetree(tt: dict, version: tuple, resource=None) -> MeshInput:
85
+ """MeshInput of a Mesh typetree (or a Sprite typetree: its m_RD) of a Unity 2018.2+ file. `resource(path,
86
+ offset, size)` reads streamed vertex data (m_StreamData)."""
87
+ sprite = "m_RD" in tt
88
+ src = tt["m_RD"] if sprite else tt
89
+ vd = src["m_VertexData"]
90
+ data = _bytes(vd.get("m_DataSize"))
91
+ stream = None if sprite else tt.get("m_StreamData")
92
+ if stream and stream.get("path"):
93
+ if resource is None:
94
+ raise ValueError(f"mesh {tt.get('m_Name')!r}: streamed vertex data and no resource reader")
95
+ data = resource(stream["path"], int(stream["offset"]), int(stream["size"]))
96
+ if sprite:
97
+ bits = 16
98
+ elif "m_Use16BitIndices" in tt:
99
+ bits = 16 if tt["m_Use16BitIndices"] else 32
100
+ else:
101
+ bits = 32 if tt.get("m_IndexFormat", 0) == 1 else 16
102
+ shapes = tt.get("m_Shapes")
103
+ n_shapes = len(shapes.get("channels", ()) or shapes.get("shapes", ())) if isinstance(shapes, dict) else 0
104
+ vbw = tt.get("m_VariableBoneCountWeights")
105
+ return MeshInput(
106
+ name=tt.get("m_Name", ""), version=tuple(version), vertex_count=int(vd.get("m_VertexCount", 0)),
107
+ channels=tuple((int(c["stream"]), int(c["offset"]), int(c["format"]), int(c["dimension"]))
108
+ for c in vd.get("m_Channels") or ()),
109
+ vertex_data=data, index_buffer=_bytes(src.get("m_IndexBuffer")), index_bits=bits,
110
+ submeshes=submeshes(src.get("m_SubMeshes")),
111
+ bind_poses=tuple(src.get("m_Bindpose" if sprite else "m_BindPose") or ()),
112
+ bone_name_hashes=() if sprite else tuple(tt.get("m_BoneNameHashes") or ()),
113
+ root_bone_name_hash=None if sprite else tt.get("m_RootBoneNameHash"),
114
+ blend_shapes=n_shapes, variable_bone_weights=len((vbw or {}).get("m_Data") or ()))
115
+
116
+
117
+ def needs_handler(tt: dict, version: tuple) -> bool:
118
+ """True when UnityPy's MeshHandler decodes the mesh (older layouts, compressed meshes)."""
119
+ if tuple(version)[:2] < RAW_LAYOUT_SINCE:
120
+ return True
121
+ cm = tt.get("m_CompressedMesh")
122
+ return bool(cm) and int(cm["m_Vertices"]["m_NumItems"]) > 0
123
+
124
+
125
+ _HANDLER_FIELDS = {"position": "m_Vertices", "normal": "m_Normals", "tangent": "m_Tangents", "color": "m_Colors",
126
+ **{f"uv{i}": f"m_UV{i}" for i in range(8)}, "blendWeight": "m_BoneWeights",
127
+ "blendIndices": "m_BoneIndices"}
128
+
129
+
130
+ def handler_input(src, tt: dict, version: tuple) -> MeshInput:
131
+ """MeshInput decoded by UnityPy's MeshHandler from `src` (a Mesh object, or a Sprite's m_RD)."""
132
+ import numpy as np
133
+ from UnityPy.helpers.MeshHelper import MeshHandler
134
+ h = MeshHandler(src, tuple(version))
135
+ h.process()
136
+ decoded = {}
137
+ for name in CHANNELS:
138
+ values = getattr(h, _HANDLER_FIELDS[name], None)
139
+ if not values:
140
+ continue
141
+ a = np.asarray(values)
142
+ if a.dtype.kind in "iu":
143
+ a = a.astype(np.uint32 if name == "blendIndices" else np.uint8)
144
+ decoded[name] = (a, name != "blendIndices")
145
+ else:
146
+ decoded[name] = (a.astype(np.float32), False)
147
+ base = mesh_input_from_typetree({**tt, "m_StreamData": None} if "m_RD" not in tt else tt, version)
148
+ ib = h.m_IndexBuffer
149
+ return MeshInput(
150
+ name=base.name, version=base.version, vertex_count=int(h.m_VertexCount or 0), decoded=decoded,
151
+ index_buffer=[] if not ib else list(ib) if not isinstance(ib, (bytes, bytearray)) else bytes(ib),
152
+ index_bits=16 if h.m_Use16BitIndices else 32, submeshes=base.submeshes, bind_poses=base.bind_poses,
153
+ bone_name_hashes=base.bone_name_hashes, root_bone_name_hash=base.root_bone_name_hash,
154
+ blend_shapes=base.blend_shapes, variable_bone_weights=base.variable_bone_weights)
155
+
156
+
157
+ def _resource(assets_file, path: str, offset: int, size: int) -> bytes:
158
+ """`size` bytes at `offset` of the resource file `path` (a .resS / .resource inside the same bundle). Unlike
159
+ UnityPy's own lookup, nothing outside the loaded bundle is searched."""
160
+ import ntpath
161
+ base = ntpath.basename(path)
162
+ stem = ntpath.splitext(base)[0]
163
+ env = assets_file.environment
164
+ for name in (base, f"{stem}.resource", f"{stem}.assets.resS", f"{stem}.resS"):
165
+ reader = env.get_cab(name)
166
+ if reader is not None:
167
+ reader.Position = offset
168
+ data = reader.read_bytes(size)
169
+ if len(data) != size:
170
+ raise ValueError(f"resource {base}: {len(data)} of {size} bytes at {offset}")
171
+ return bytes(data)
172
+ raise FileNotFoundError(f"resource file {base} is not in the bundle")
173
+
174
+
175
+ class UnityBundle:
176
+ """BundleView of a UnityPy environment holding one bundle (open_bundle loads it from the bundle's bytes)."""
177
+
178
+ def __init__(self, env):
179
+ self.env = env
180
+ self._files = {f.name: f for f in self.env.assets}
181
+ self._readers: dict[tuple[str, int], object] = {}
182
+ self._infos: list[ObjectInfo] = []
183
+ for name, f in self._files.items():
184
+ for pid, r in f.objects.items():
185
+ st = r.serialized_type
186
+ h = getattr(st, "old_type_hash", None) if st is not None else None
187
+ self._readers[(name, pid)] = r
188
+ self._infos.append(ObjectInfo(name, int(pid), int(r.class_id), r.type.name, int(r.byte_size),
189
+ h.hex() if h else None))
190
+
191
+ def _reader(self, obj: ObjectInfo):
192
+ return self._readers[(obj.file, obj.path_id)]
193
+
194
+ def reader(self, obj: ObjectInfo):
195
+ """The library's object reader of `obj` (UnityPy's ObjectReader), for code that works on library objects."""
196
+ return self._readers[(obj.file, obj.path_id)]
197
+
198
+ def files(self) -> list[str]:
199
+ return list(self._files)
200
+
201
+ def objects(self) -> list[ObjectInfo]:
202
+ return list(self._infos)
203
+
204
+ def raw(self, obj: ObjectInfo) -> bytes:
205
+ return self._reader(obj).get_raw_data()
206
+
207
+ def typetree(self, obj: ObjectInfo) -> dict:
208
+ return self._reader(obj).read_typetree()
209
+
210
+ def container(self) -> list[tuple[str, str, int]]:
211
+ out = []
212
+ for name, f in self._files.items():
213
+ for path, info in f.container.container:
214
+ pptr = info.asset
215
+ fid = int(pptr.m_FileID)
216
+ target = name if fid == 0 else _file_name(f.externals[fid - 1].path)
217
+ out.append((path, target, int(pptr.m_PathID)))
218
+ return out
219
+
220
+ def externals(self, file: str) -> list[str]:
221
+ return [e.path for e in self._files[file].externals]
222
+
223
+ def unity_version(self, file: str) -> str:
224
+ f = self._files[file]
225
+ v = getattr(f, "unity_version", None)
226
+ return v if isinstance(v, str) else ".".join(str(x) for x in f.version)
227
+
228
+ def texture_input(self, obj: ObjectInfo) -> TextureInput:
229
+ r = self._reader(obj)
230
+ tex = r.read()
231
+ w, h = int(tex.m_Width), int(tex.m_Height)
232
+ blob = getattr(tex, "m_PlatformBlob", None)
233
+ # the arguments UnityPy's Texture2D.image passes to parse_image_data (as export._decode_inputs); a texture
234
+ # without pixels has no image data to read
235
+ data = b""
236
+ if w and h:
237
+ stream = getattr(tex, "m_StreamData", None)
238
+ if tex.image_data:
239
+ data = bytes(tex.image_data)
240
+ elif stream is not None and stream.path:
241
+ data = _resource(r.assets_file, stream.path, int(stream.offset), int(stream.size))
242
+ else:
243
+ raise ValueError(f"{obj.id}: texture without image data")
244
+ return TextureInput(data, w, h, int(tex.m_TextureFormat), tuple(getattr(r, "version", (0, 0, 0, 0))),
245
+ int(getattr(r, "platform", 0) or 0), None if blob is None else bytes(blob))
246
+
247
+ def mesh_input(self, obj: ObjectInfo) -> MeshInput:
248
+ r = self._reader(obj)
249
+ if obj.class_name not in ("Mesh", "Sprite"):
250
+ raise TypeError(f"{obj.id}: {obj.class_name} has no mesh")
251
+ tt = r.read_typetree()
252
+ version = tuple(r.version)
253
+ if needs_handler(tt, version):
254
+ o = r.read()
255
+ return handler_input(o.m_RD if obj.class_name == "Sprite" else o, tt, version)
256
+ return mesh_input_from_typetree(tt, version, lambda path, offset, size: _resource(r.assets_file, path,
257
+ offset, size))
258
+
259
+
260
+ def open_bundle(data: bytes) -> UnityBundle:
261
+ """The BundleView of a bundle's bytes (decrypted UnityFS), loaded alone."""
262
+ from ..unity import load_bytes
263
+ return UnityBundle(load_bytes(bytes(data)))
264
+
265
+
266
+ def cost(facts: dict) -> dict:
267
+ """facts: size (bundle bytes), objects (count)."""
268
+ size, n = int(facts.get("size", 0)), int(facts.get("objects", 0))
269
+ return estimate(0.01 + size * 1.5e-8 + n * 1.2e-4, 7e7 + size * 8)