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/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)
|
nnnotes/atoms/reader.py
ADDED
|
@@ -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)
|