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