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/shader.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Shader extraction.
|
|
2
|
+
|
|
3
|
+
Dumps every Shader object found in the given bundles:
|
|
4
|
+
- `<name>.json` parsed form: properties, subshaders, passes
|
|
5
|
+
(tags, render state), keyword names
|
|
6
|
+
- `<name>/<platform>/s<S>p<P>_<stage>_<N>.<ext>`
|
|
7
|
+
each compiled player sub-program (variant)
|
|
8
|
+
- `shaders.json` index with per-variant keywords
|
|
9
|
+
|
|
10
|
+
Blob layout (Unity 2021.2+ / 6000.x): per platform, an LZ4 blob holding a table
|
|
11
|
+
of (offset, length, segment) entries. Code entries and parameter-binding entries
|
|
12
|
+
are separate; the parsed form's `m_PlayerSubPrograms` names, per variant, the
|
|
13
|
+
code `m_BlobIndex`, its `m_KeywordIndices` and `m_GpuProgramType`. A code entry
|
|
14
|
+
is: version, programType, 4 x int, keyword count + aligned strings, then the
|
|
15
|
+
program bytes. GLES programs are GLSL ES text holding both stages under
|
|
16
|
+
`#ifdef VERTEX` / `#ifdef FRAGMENT`. Vulkan programs are Unity's packed SPIR-V
|
|
17
|
+
container and are written as-is (`.vkprog`).
|
|
18
|
+
|
|
19
|
+
No shader is filtered by name; a viewer decides what it reimplements.
|
|
20
|
+
|
|
21
|
+
A Shader object's dump depends on its serialized bytes only: it is made once per process (cache bucket
|
|
22
|
+
"shaders", keyed by the object's bytes, type and Unity version) and written again from there.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import re
|
|
28
|
+
import struct
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
import UnityPy
|
|
32
|
+
from UnityPy.enums import ShaderGpuProgramType
|
|
33
|
+
from UnityPy.export.ShaderConverter import CompressionHelper
|
|
34
|
+
|
|
35
|
+
from . import cache
|
|
36
|
+
from .jsonio import dumps, write_json
|
|
37
|
+
|
|
38
|
+
DUMPS = cache.bucket("shaders", salt=cache.source_salt(__file__, "UnityPy"), disk=True)
|
|
39
|
+
|
|
40
|
+
# UnityEngine.Rendering ShaderCompilerPlatform value -> tag
|
|
41
|
+
PLATFORM = {
|
|
42
|
+
0: "gl", 4: "d3d11", 5: "gles20", 9: "gles3", 14: "metal",
|
|
43
|
+
15: "glcore", 18: "vulkan", 19: "switch",
|
|
44
|
+
}
|
|
45
|
+
# which GPU program types belong to which platform
|
|
46
|
+
PLATFORM_TYPES = {
|
|
47
|
+
9: {"GLES3", "GLES31", "GLES31AEP"},
|
|
48
|
+
5: {"GLES"},
|
|
49
|
+
15: {"GLCore32", "GLCore41", "GLCore43"},
|
|
50
|
+
18: {"SPIRV"},
|
|
51
|
+
14: {"MetalVS", "MetalFS"},
|
|
52
|
+
}
|
|
53
|
+
TEXT_TYPES = {"GLES", "GLES3", "GLES31", "GLES31AEP", "GLCore32", "GLCore41", "GLCore43",
|
|
54
|
+
"MetalVS", "MetalFS"}
|
|
55
|
+
STAGES = ("progVertex", "progFragment", "progGeometry", "progHull", "progDomain")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _safe(name: str) -> str:
|
|
59
|
+
return re.sub(r"[^0-9A-Za-z._-]", "_", name)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _entry(arr, i):
|
|
63
|
+
x = arr[i]
|
|
64
|
+
return x[0] if isinstance(x, list) else x
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _type_name(v: int) -> str:
|
|
68
|
+
return ShaderGpuProgramType(v).name.replace("kShaderGpuProgram", "")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _blob_table(raw: bytes) -> list[bytes]:
|
|
72
|
+
n = struct.unpack_from("<i", raw, 0)[0]
|
|
73
|
+
out = []
|
|
74
|
+
for i in range(n):
|
|
75
|
+
off, length, _seg = struct.unpack_from("<3i", raw, 4 + 12 * i)
|
|
76
|
+
out.append(raw[off:off + length])
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _code_of(entry: bytes) -> bytes:
|
|
81
|
+
"""Program bytes of a code blob entry."""
|
|
82
|
+
pos = 4 + 4 + 16 # version, programType, 4 x int
|
|
83
|
+
nkw = struct.unpack_from("<i", entry, pos)[0]
|
|
84
|
+
pos += 4
|
|
85
|
+
for _ in range(nkw):
|
|
86
|
+
n = struct.unpack_from("<i", entry, pos)[0]
|
|
87
|
+
pos += 4 + n
|
|
88
|
+
pos = (pos + 3) & ~3
|
|
89
|
+
n = struct.unpack_from("<i", entry, pos)[0]
|
|
90
|
+
return entry[pos + 4:pos + 4 + n]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def platform_blobs(shader) -> dict[int, list[bytes]]:
|
|
94
|
+
blob = bytes(shader.compressedBlob or b"")
|
|
95
|
+
out = {}
|
|
96
|
+
n = min(len(shader.platforms), len(shader.compressedLengths), len(shader.decompressedLengths))
|
|
97
|
+
for i in range(n):
|
|
98
|
+
off = _entry(shader.offsets, i)
|
|
99
|
+
raw = CompressionHelper.decompress_lz4(
|
|
100
|
+
blob[off:off + _entry(shader.compressedLengths, i)],
|
|
101
|
+
_entry(shader.decompressedLengths, i))
|
|
102
|
+
out[shader.platforms[i]] = _blob_table(raw)
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def variants(tt: dict, blobs: dict[int, list[bytes]]):
|
|
107
|
+
"""Yield (platform, subshader, pass, stage, n, gpuType, keywords, code)."""
|
|
108
|
+
pf = tt["m_ParsedForm"]
|
|
109
|
+
names = pf.get("m_KeywordNames", [])
|
|
110
|
+
for si, ss in enumerate(pf["m_SubShaders"]):
|
|
111
|
+
for pi, ps in enumerate(ss["m_Passes"]):
|
|
112
|
+
for stage in STAGES:
|
|
113
|
+
prog = ps.get(stage) or {}
|
|
114
|
+
n = 0
|
|
115
|
+
for group in prog.get("m_PlayerSubPrograms", []):
|
|
116
|
+
for sp in group:
|
|
117
|
+
t = _type_name(sp["m_GpuProgramType"])
|
|
118
|
+
for plat, table in blobs.items():
|
|
119
|
+
if t not in PLATFORM_TYPES.get(plat, ()):
|
|
120
|
+
continue
|
|
121
|
+
code = _code_of(table[sp["m_BlobIndex"]])
|
|
122
|
+
kws = [names[k] for k in sp.get("m_KeywordIndices", []) if k < len(names)]
|
|
123
|
+
yield plat, si, pi, stage[4:].lower(), n, t, kws, code
|
|
124
|
+
n += 1
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _parsed_summary(tt: dict) -> dict:
|
|
128
|
+
pf = tt["m_ParsedForm"]
|
|
129
|
+
return {
|
|
130
|
+
"name": pf.get("m_Name"),
|
|
131
|
+
"properties": pf.get("m_PropInfo", {}).get("m_Props", []),
|
|
132
|
+
"keywords": pf.get("m_KeywordNames", []),
|
|
133
|
+
"subShaders": [{
|
|
134
|
+
"tags": ss.get("m_Tags"), "lod": ss.get("m_LOD"),
|
|
135
|
+
"passes": [{"name": ps.get("m_State", {}).get("m_Name") or ps.get("m_Name"),
|
|
136
|
+
"tags": ps.get("m_Tags"), "state": ps.get("m_State")}
|
|
137
|
+
for ps in ss["m_Passes"]],
|
|
138
|
+
} for ss in pf["m_SubShaders"]],
|
|
139
|
+
"fallback": pf.get("m_FallbackName"),
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _render(o, tt: dict, name: str) -> tuple[str, list[dict], list[bytes]]:
|
|
144
|
+
"""(parsed summary JSON text, variant records, variant program bytes) of one Shader object."""
|
|
145
|
+
safe = _safe(name)
|
|
146
|
+
text = dumps(_parsed_summary(tt), indent=1, ensure_ascii=False, default=str)
|
|
147
|
+
recs, codes = [], []
|
|
148
|
+
for plat, si, pi, stage, n, t, kws, code in variants(tt, platform_blobs(o.read())):
|
|
149
|
+
tag = PLATFORM.get(plat, str(plat))
|
|
150
|
+
ext = "glsl" if t in TEXT_TYPES and not t.startswith("Metal") else "metal" if t.startswith("Metal") else "vkprog"
|
|
151
|
+
fn = f"s{si}p{pi}_{stage}_{n}.{ext}"
|
|
152
|
+
recs.append({"file": f"{safe}/{tag}/{fn}", "platform": tag, "subShader": si, "pass": pi, "stage": stage,
|
|
153
|
+
"type": t, "keywords": kws})
|
|
154
|
+
codes.append(bytes(code))
|
|
155
|
+
return text, recs, codes
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _dump_key(o) -> str:
|
|
159
|
+
st = o.serialized_type
|
|
160
|
+
type_hash = bytes(st.old_type_hash) if st is not None and st.old_type_hash else b""
|
|
161
|
+
return DUMPS.key(o.get_raw_data(), type_hash, int(o.class_id), o.assets_file.unity_version,
|
|
162
|
+
int(getattr(o, "platform", 0) or 0))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def dump_objects(shaders, out_dir: Path, source: str, index: list | None = None) -> list:
|
|
166
|
+
"""Write parsed summaries + every compiled variant of Shader objects.
|
|
167
|
+
|
|
168
|
+
`index` accumulates records across calls; a shader already present (same
|
|
169
|
+
name) is not written twice.
|
|
170
|
+
"""
|
|
171
|
+
out_dir = Path(out_dir)
|
|
172
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
index = [] if index is None else index
|
|
174
|
+
done = {r["name"] for r in index}
|
|
175
|
+
for o in shaders:
|
|
176
|
+
k = _dump_key(o)
|
|
177
|
+
hit = DUMPS.get(k)
|
|
178
|
+
if hit is not None:
|
|
179
|
+
meta, codes = cache.unpack(hit)
|
|
180
|
+
name, text, recs = json.loads(meta)
|
|
181
|
+
else:
|
|
182
|
+
tt = o.read_typetree()
|
|
183
|
+
named = tt.get("m_ParsedForm", {}).get("m_Name")
|
|
184
|
+
name = named or f"shader_{o.path_id}"
|
|
185
|
+
if name in done:
|
|
186
|
+
continue
|
|
187
|
+
text, recs, codes = _render(o, tt, name)
|
|
188
|
+
if named: # a name from the path id is not a function of the bytes
|
|
189
|
+
DUMPS.put(k, cache.pack(json.dumps([name, text, recs], ensure_ascii=False).encode("utf-8"), codes))
|
|
190
|
+
if name in done:
|
|
191
|
+
continue
|
|
192
|
+
done.add(name)
|
|
193
|
+
safe = _safe(name)
|
|
194
|
+
(out_dir / f"{safe}.json").write_text(text, encoding="utf-8", newline="\n") # as write_json writes it
|
|
195
|
+
rec = {"name": name, "source": source, "parsed": f"{safe}.json", "variants": []}
|
|
196
|
+
for v, code in zip(recs, codes):
|
|
197
|
+
pdir = out_dir / v["file"].rsplit("/", 1)[0]
|
|
198
|
+
pdir.mkdir(parents=True, exist_ok=True)
|
|
199
|
+
(out_dir / v["file"]).write_bytes(code) # the program text or binary exactly as the game stores it
|
|
200
|
+
rec["variants"].append(v)
|
|
201
|
+
index.append(rec)
|
|
202
|
+
return index
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def write_index(index: list, out_dir: Path) -> dict:
|
|
206
|
+
write_json(Path(out_dir) / "shaders.json", index)
|
|
207
|
+
return {"count": len(index), "names": sorted(r["name"] for r in index),
|
|
208
|
+
"variants": sum(len(r["variants"]) for r in index)}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def dump(bundles: list, out_dir: Path) -> dict:
|
|
212
|
+
index: list = []
|
|
213
|
+
for bpath in bundles:
|
|
214
|
+
env = UnityPy.load(str(bpath))
|
|
215
|
+
dump_objects([o for o in env.objects if o.type.name == "Shader"], out_dir,
|
|
216
|
+
Path(bpath).name, index)
|
|
217
|
+
return write_index(index, out_dir)
|
nnnotes/spot.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Spot (explorable 2.5D scene) extractor -> spot.json + Spine assets.
|
|
2
|
+
|
|
3
|
+
A spot = background prefab (cardboard room, see room.py) + situation prefab:
|
|
4
|
+
SpotSituationSettings (camera/interaction parameters), SpotCharacterController,
|
|
5
|
+
SpotCharacter x N (tap targets with focus transforms) and SpotSpineCharacter x M,
|
|
6
|
+
each driving a Spine.Unity SkeletonAnimation. Components are identified by
|
|
7
|
+
their MonoScript class (Catalog must be opened with `apk=`).
|
|
8
|
+
|
|
9
|
+
A reference the prefab leaves empty is written as null, not filled in: a
|
|
10
|
+
SpotSpineCharacter with no `_animation` has null `skeletonData`, `animation`
|
|
11
|
+
and `world`; a SpotCharacter with no `_focus` has a null `focusWorld`.
|
|
12
|
+
|
|
13
|
+
Spine files are written from the SkeletonDataAsset reference chain (write_skeleton):
|
|
14
|
+
SkeletonDataAsset.skeletonJSON -> skeleton (.json or binary .skel)
|
|
15
|
+
SkeletonDataAsset.atlasAssets[i].atlasFile -> <name>.atlas
|
|
16
|
+
SpineAtlasAsset.materials[j]._MainTex -> PNG named after atlas page j
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import io
|
|
21
|
+
import json
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
import UnityPy
|
|
25
|
+
|
|
26
|
+
from .catalog import Catalog
|
|
27
|
+
from .config import apk_missing
|
|
28
|
+
from .jsonio import write_json
|
|
29
|
+
from .unity import SceneGraph, script_class, strip_pptrs, texture_image
|
|
30
|
+
|
|
31
|
+
LANGS = ("_japanese", "_english", "_traditionalChinese", "_simplifiedChinese", "_korean")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _master(md: Path, name: str) -> list[dict]:
|
|
35
|
+
return json.loads((md / f"{name}.json").read_text(encoding="utf-8"))["_allData"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _mat4(m) -> list[float]:
|
|
39
|
+
return [float(x) for x in m.T.reshape(-1)] # column-major
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _atlas_pages(text: str) -> list[str]:
|
|
43
|
+
"""Page file names of a Spine atlas (a page line is followed by 'size:')."""
|
|
44
|
+
lines = [ln.strip() for ln in text.splitlines()]
|
|
45
|
+
return [ln for i, ln in enumerate(lines)
|
|
46
|
+
if ln and ":" not in ln and i + 1 < len(lines) and lines[i + 1].startswith("size:")]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _text_bytes(ta) -> bytes:
|
|
50
|
+
s = ta.m_Script
|
|
51
|
+
return s.encode("utf-8", "surrogateescape") if isinstance(s, str) else bytes(s)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _target(ref):
|
|
55
|
+
"""The object a PPtr refers to, None for an empty reference (path id 0)."""
|
|
56
|
+
return ref.read() if ref.path_id else None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def write_skeleton(o, write, written_atlases: dict[int, str] | None = None) -> dict:
|
|
60
|
+
"""The Spine files of the SkeletonDataAsset object `o` (module docstring), each given to `write(file name,
|
|
61
|
+
bytes)` in this order: the skeleton, then for each of its atlases not in `written_atlases` ({SpineAtlasAsset
|
|
62
|
+
path id: atlas file name}, updated here) the atlas text as stored and one PNG per page. Returns the skeleton's
|
|
63
|
+
record of spot.json: name, skeleton file, atlas files, scale, default mix, mixes."""
|
|
64
|
+
written = {} if written_atlases is None else written_atlases
|
|
65
|
+
sda = o.read_typetree()
|
|
66
|
+
sk = o.read().skeletonJSON.read()
|
|
67
|
+
raw = _text_bytes(sk)
|
|
68
|
+
is_json = raw.lstrip()[:1] == b"{"
|
|
69
|
+
ext = ".json" if is_json else ".skel"
|
|
70
|
+
sk_file = sk.m_Name if sk.m_Name.endswith(ext) else f"{sk.m_Name}{ext}" # as the atlas below
|
|
71
|
+
write(sk_file, raw)
|
|
72
|
+
atlases = []
|
|
73
|
+
for ap in o.read().atlasAssets:
|
|
74
|
+
atlas_asset = ap.read()
|
|
75
|
+
if ap.path_id not in written:
|
|
76
|
+
af = atlas_asset.atlasFile.read()
|
|
77
|
+
araw = _text_bytes(af)
|
|
78
|
+
atext = araw.decode("utf-8")
|
|
79
|
+
a_file = af.m_Name if af.m_Name.endswith(".atlas") else f"{af.m_Name}.atlas"
|
|
80
|
+
write(a_file, araw) # the atlas text exactly as the game stores it
|
|
81
|
+
pages = _atlas_pages(atext)
|
|
82
|
+
mats = list(atlas_asset.materials)
|
|
83
|
+
if len(mats) != len(pages):
|
|
84
|
+
raise RuntimeError(f"{a_file}: {len(pages)} pages vs {len(mats)} materials")
|
|
85
|
+
for page, mp in zip(pages, mats):
|
|
86
|
+
mat = mp.read()
|
|
87
|
+
tex = next(e.m_Texture.read() for k, e in mat.m_SavedProperties.m_TexEnvs
|
|
88
|
+
if k == "_MainTex")
|
|
89
|
+
buf = io.BytesIO(); texture_image(tex).save(buf, format="PNG")
|
|
90
|
+
write(page, buf.getvalue())
|
|
91
|
+
written[ap.path_id] = a_file
|
|
92
|
+
atlases.append(written[ap.path_id])
|
|
93
|
+
return {
|
|
94
|
+
"name": sda["m_Name"], "skeleton": sk_file, "atlases": atlases,
|
|
95
|
+
"scale": sda["scale"], "defaultMix": sda["defaultMix"],
|
|
96
|
+
"mixes": [{"from": f, "to": t, "duration": d} for f, t, d in
|
|
97
|
+
zip(sda.get("fromAnimation", []), sda.get("toAnimation", []), sda.get("duration", []))],
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def spine_characters(objs: list, graph, skeletons: dict, world_of) -> list[dict]:
|
|
102
|
+
"""SpotSpineCharacter components: object path, the SkeletonAnimation they drive (its skeleton data name,
|
|
103
|
+
initial animation settings and world matrix). `skeletons`: SkeletonDataAsset path id -> skeleton record. One
|
|
104
|
+
whose `_animation` is empty has those three null."""
|
|
105
|
+
out = []
|
|
106
|
+
for o in objs:
|
|
107
|
+
ssc = o.read_typetree()
|
|
108
|
+
path = graph.path(graph.tf_of_go[ssc["m_GameObject"]["m_PathID"]])
|
|
109
|
+
anim_o = _target(o.read()._animation)
|
|
110
|
+
if anim_o is None:
|
|
111
|
+
out.append({"path": path, "skeletonData": None, "animation": None, "world": None})
|
|
112
|
+
continue
|
|
113
|
+
at = anim_o.object_reader.read_typetree()
|
|
114
|
+
out.append({
|
|
115
|
+
"path": path,
|
|
116
|
+
"skeletonData": skeletons[anim_o.skeletonDataAsset.path_id]["name"],
|
|
117
|
+
"animation": {k: at[k] for k in ("_animationName", "loop", "timeScale",
|
|
118
|
+
"initialSkinName", "initialFlipX", "initialFlipY",
|
|
119
|
+
"pmaVertexColors", "tintBlack", "zSpacing")},
|
|
120
|
+
"world": world_of(at["m_GameObject"]),
|
|
121
|
+
})
|
|
122
|
+
return out
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def tap_targets(objs: list, graph, world_of) -> list[dict]:
|
|
126
|
+
"""SpotCharacter components (tap targets) by character id: object path, their fields, world matrix and the world
|
|
127
|
+
matrix of their `_focus` transform (null when the reference is empty)."""
|
|
128
|
+
out = []
|
|
129
|
+
for o in objs:
|
|
130
|
+
sc = o.read_typetree()
|
|
131
|
+
focus_o = _target(o.read()._focus)
|
|
132
|
+
out.append({
|
|
133
|
+
"path": graph.path(graph.tf_of_go[sc["m_GameObject"]["m_PathID"]]),
|
|
134
|
+
**strip_pptrs({k: v for k, v in sc.items() if k != "_focus"}),
|
|
135
|
+
"world": world_of(sc["m_GameObject"]),
|
|
136
|
+
"focusWorld": None if focus_o is None else world_of(focus_o.object_reader.read_typetree()["m_GameObject"]),
|
|
137
|
+
})
|
|
138
|
+
out.sort(key=lambda c: c["_characterId"])
|
|
139
|
+
return out
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def extract(cat: Catalog, md: Path, spot_id: int, out_dir: Path) -> dict:
|
|
143
|
+
if cat.apk is None:
|
|
144
|
+
raise apk_missing("resolving the Spot / Spine component classes")
|
|
145
|
+
spots = {r["_id"]: r for r in _master(md, "MasterHomeSpot")}
|
|
146
|
+
sp = spots[spot_id]
|
|
147
|
+
txt = {r["_id"]: r for r in _master(md, "MasterText")}
|
|
148
|
+
taps: dict[int, list[int]] = {}
|
|
149
|
+
for r in _master(md, "MasterStoryHomeSpotTapTalkEpisode"):
|
|
150
|
+
if r.get("_spotId") == spot_id:
|
|
151
|
+
taps.setdefault(r["_characterId"], []).append(r["_advId"])
|
|
152
|
+
|
|
153
|
+
out_dir = Path(out_dir)
|
|
154
|
+
spine_dir = out_dir / "spine"
|
|
155
|
+
spine_dir.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
|
|
157
|
+
env = UnityPy.load(*[str(p) for p in cat.fetch_key(sp["_situationAssetPath"])])
|
|
158
|
+
graph = SceneGraph(env)
|
|
159
|
+
by_cls: dict[str, list] = {}
|
|
160
|
+
for o in env.objects:
|
|
161
|
+
if o.type.name == "MonoBehaviour":
|
|
162
|
+
by_cls.setdefault(script_class(o), []).append(o)
|
|
163
|
+
|
|
164
|
+
def world_of(go_pptr) -> list[float]:
|
|
165
|
+
return _mat4(graph.world_of_go(go_pptr["m_PathID"]))
|
|
166
|
+
|
|
167
|
+
settings = strip_pptrs(by_cls["SpotSituationSettings"][0].read_typetree())
|
|
168
|
+
controller_o = by_cls["SpotCharacterController"][0]
|
|
169
|
+
controller = strip_pptrs(controller_o.read_typetree())
|
|
170
|
+
|
|
171
|
+
# Spine skeleton data -> files
|
|
172
|
+
def write(name: str, data: bytes) -> None:
|
|
173
|
+
(spine_dir / name).write_bytes(data)
|
|
174
|
+
|
|
175
|
+
skeleton_files: dict[int, dict] = {}
|
|
176
|
+
written_atlases: dict[int, str] = {}
|
|
177
|
+
for o in by_cls.get("SkeletonDataAsset", []):
|
|
178
|
+
skeleton_files[o.path_id] = write_skeleton(o, write, written_atlases)
|
|
179
|
+
|
|
180
|
+
spine_chars = spine_characters(by_cls.get("SpotSpineCharacter", []), graph, skeleton_files, world_of)
|
|
181
|
+
characters = tap_targets(by_cls.get("SpotCharacter", []), graph, world_of)
|
|
182
|
+
|
|
183
|
+
doc = {
|
|
184
|
+
"spotId": spot_id,
|
|
185
|
+
"master": sp,
|
|
186
|
+
"name": {lang[1:]: txt.get(sp.get("_nameTextId"), {}).get(lang, "") for lang in LANGS},
|
|
187
|
+
"tapTalk": {str(k): v for k, v in sorted(taps.items())},
|
|
188
|
+
"situationSettings": settings,
|
|
189
|
+
"controller": {k: v for k, v in controller.items()
|
|
190
|
+
if k not in ("_spotSpineCharacters", "_characters")},
|
|
191
|
+
"characters": characters,
|
|
192
|
+
"spineCharacters": spine_chars,
|
|
193
|
+
"skeletons": list(skeleton_files.values()),
|
|
194
|
+
"resources": {
|
|
195
|
+
"background": [b.name for b in cat.resolve(sp["_backgroundAssetPath"])],
|
|
196
|
+
"situation": [b.name for b in cat.resolve(sp["_situationAssetPath"])],
|
|
197
|
+
},
|
|
198
|
+
"matrixConvention": "Unity world space (left-handed), 4x4 column-major",
|
|
199
|
+
}
|
|
200
|
+
write_json(out_dir / "spot.json", doc)
|
|
201
|
+
return doc
|