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/storyhost.py ADDED
@@ -0,0 +1,1190 @@
1
+ """The host screen of an Overlay story (MasterAdv._playbackMode 1) -> <story>/host/ and <language>/ui/simple/.
2
+
3
+ The game plays these episodes with App.Adv.SimpleAdvPlayer inside the screen that starts them, not in the ADV scene:
4
+ SimpleAdvView lays the characters out in the slots of the host's SimpleAdvLayoutRoot (each drawn by its own
5
+ CameraTargetRenderer) and attaches the UISimpleAdvTalkWindow under the root's TalkRoot. The host is the story group
6
+ of the episode: a tap talk (MasterStoryHomeSpotTapTalkEpisode) or an area talk (MasterHomeSpot._advId) of a home
7
+ spot (SpotManager), or the reward phase of a live result (LiveResultDisplayPresenter).
8
+
9
+ host/host.json kind, the capture camera of a slot (CameraTargetRenderer prefab), the SimpleAdv root and its
10
+ SimpleAdvLayoutRoot fields, the sequences the host screen has played when the talk starts,
11
+ and per kind:
12
+ home spot.json + Spine files (spot.extract) and room.glb + room.json
13
+ (room.extract_room) under host/spot/; per placed glb node its object path, its
14
+ state after SpotBackground.Prepare and the SpotSceneRoot.SetObject "floor" render
15
+ queue; the room materials as saved; the "Spot" scene's object root, lights,
16
+ volumes and RenderSettings; the lights of the placed prefabs; the background
17
+ Volume; the Spine materials; the blur of renderer 0's UIRendererFeature; the
18
+ situation's ambient sound; shaders in host/shaders/
19
+ afterlive the result background node and sprite, the reward panel, the substitutes
20
+ host/ui/ui.json the host UI in the advui record format (no text nodes)
21
+ ui/simple/ui.json per language: UISimpleAdvTalkWindow (and UISystemMessageWidget for an area talk) in the
22
+ advui record format with text records, fonts.json and glyph pages (storyfonts format, open
23
+ fonts)
24
+
25
+ Same inputs give the same bytes.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import shutil
31
+ import struct
32
+ from pathlib import Path
33
+
34
+ import numpy as np
35
+ import UnityPy
36
+
37
+ from . import advscene, advui, languages, master, room, spot, storyfonts, textstyle, tmpfont
38
+ from . import shader as shader_mod
39
+ from .export import RECT_PROPS, Exporter, TexelPacker, streamed_frames
40
+ from .jsonio import dumps, write_json
41
+ from .unity import SceneGraph, deref, mesh_arrays, script_class
42
+
43
+ FORMAT = "ournotes.story-host/1"
44
+ OVERLAY = 1 # MasterAdv._playbackMode of the episodes SimpleAdvPlayer plays
45
+ HOST_DIR = "host"
46
+ HOST_DOC = f"{HOST_DIR}/host.json"
47
+ HOST_UI_DOC = f"{HOST_DIR}/ui/ui.json"
48
+ SIMPLE_DIR = "simple"
49
+ SIMPLE_DOC = f"ui/{SIMPLE_DIR}/ui.json"
50
+ FONT_MODES = ("open", "game")
51
+ TMP_STUBS = ("TMP_FontAsset", "TMP_SpriteAsset", "TMP_StyleSheet")
52
+ # game addresses; a missing one is loaded from "Emb" + address (emb_key)
53
+ ADDRESSES = {
54
+ "cameraTarget": "Common/Prefab/CameraTargetRenderer",
55
+ "talkWindow": "UI/Prefab/Parts/Adv/Talk/UISimpleAdvTalkWindow",
56
+ "systemMessage": "UI/Prefab/UISystemMessageWidget",
57
+ "spotWidget": "UI/Prefab/UISpotWidget",
58
+ "spotLetterbox": "UI/Prefab/Parts/Spot/UISpotLetterbox",
59
+ "spotScene": "Scene/Spot",
60
+ "resultWidget": "UI/Prefab/UILiveResultWidget",
61
+ "resultBackground": "UI/Prefab/UILiveResultBackgroundWidget",
62
+ "resultBackgroundSprite": "LowImage/LiveResult/live_result_background",
63
+ }
64
+ KIND_KEYS = {"home": ("spotWidget", "spotLetterbox", "spotScene"),
65
+ "afterlive": ("resultWidget", "resultBackground", "resultBackgroundSprite")}
66
+ # story group kind (storysite.story_groups) -> (host kind, talk)
67
+ HOST_GROUPS = {"spotTalk": ("home", "tap"), "spot": ("home", "area"), "liveResult": ("afterlive", None)}
68
+ SUBSTITUTES = ["musicResultPanel", "rewardItems", "memberCard", "navigation", "gradeUpEffect"]
69
+ CLONE = "(Clone)" # Object.Instantiate's name suffix, removed by SpotBackground.Prepare
70
+ FLOOR_NAME, FLOOR_EXCLUDE, FLOOR_QUEUE = "floor", "shadow", 2000 # SpotSceneRoot.SetObject
71
+ BLUR_FEATURE = "Fwk.UI.Rendering.UIRendererFeature"
72
+ REWARD_PANEL = "RewardPanel"
73
+ COMPONENT_HEADER = ("type", "class", "m_Name")
74
+ NO_EPISODE = {"text": {}, "commands": []} # the episode argument of storyfonts.shown_characters for a UI alone
75
+ DOTWEEN_FIELDS = ("defaultEaseType", "defaultUpdateType", "defaultTimeScaleIndependent", "timeScale",
76
+ "defaultEaseOvershootOrAmplitude", "defaultEasePeriod")
77
+ # components that light the renderers of a scene (lighting_components)
78
+ LIGHTING_TYPES = ("Light", "ReflectionProbe")
79
+ # RenderSettings values of the active scene the renderers' lighting depends on; object references by name (render_settings)
80
+ RENDER_SETTINGS_FIELDS = ("m_Fog", "m_AmbientMode", "m_AmbientSkyColor", "m_AmbientEquatorColor", "m_AmbientGroundColor",
81
+ "m_AmbientIntensity", "m_SubtractiveShadowColor", "m_DefaultReflectionMode",
82
+ "m_DefaultReflectionResolution", "m_ReflectionBounces", "m_ReflectionIntensity",
83
+ "m_UseRadianceAmbientProbe", "m_IndirectSpecularColor")
84
+ RENDER_SETTINGS_REFS = ("m_SkyboxMaterial", "m_CustomReflection", "m_Sun")
85
+
86
+
87
+ # ---------------------------------------------------------------- host, keys
88
+ def host_of(groups: list[dict]) -> dict:
89
+ """The host of an Overlay story from its story groups (storysite.story_groups): a spotTalk group is a home tap
90
+ talk (SpotManager.OnTalkCharacter: spot and tapped SpotCharacter id), a spot group a home area talk
91
+ (SpotManager.PlayAreaTalkAsync), a liveResult group the live result reward phase. Exactly one such group."""
92
+ hits = [g for g in groups if g.get("kind") in HOST_GROUPS]
93
+ if len(hits) != 1:
94
+ raise ValueError(f"an Overlay story needs exactly one spotTalk, spot or liveResult group, not {len(hits)} "
95
+ f"(groups: {[g.get('kind') for g in groups]})")
96
+ g = hits[0]
97
+ kind, talk = HOST_GROUPS[g["kind"]]
98
+ out = {"kind": kind, "group": {"kind": g["kind"], "id": g["id"]}}
99
+ if kind == "home":
100
+ chars = g.get("characters") or []
101
+ out.update(spotId=g["spot"]["id"], talk=talk,
102
+ characterId=chars[0]["id"] if talk == "tap" and chars else None)
103
+ return out
104
+
105
+
106
+ def emb_key(cat, address: str) -> str:
107
+ """The catalog key of a game address: the address itself, else the embedded copy "Emb" + address."""
108
+ for key in (address, f"Emb{address}"):
109
+ if cat.has(key):
110
+ return key
111
+ raise KeyError(f"{address}: neither it nor Emb{address} is in the catalog")
112
+
113
+
114
+ def _parent(path: str) -> str:
115
+ return path.rsplit("/", 1)[0] if "/" in path else ""
116
+
117
+
118
+ def _trs(node: dict) -> dict:
119
+ return {k: node[k] for k in ("localPosition", "localRotation", "localScale")}
120
+
121
+
122
+ def _first(nodes: list[dict], path: str) -> dict:
123
+ hits = [n for n in nodes if n["path"] == path]
124
+ if len(hits) != 1:
125
+ raise RuntimeError(f"{path}: {len(hits)} nodes")
126
+ return hits[0]
127
+
128
+
129
+ # ---------------------------------------------------------------- records
130
+ def camera_target(nodes: list[dict]) -> dict:
131
+ """cameraTarget: the CameraTargetRenderer prefab (SimpleAdvView slot capture): the transforms of the parent of
132
+ `_stage` and `_captureCamera` (Container), of the stage and of the capture camera with its Camera and
133
+ UniversalAdditionalCameraData settings (the slot's RenderTexture, layer and stage offset are code)."""
134
+ crt = advui._component(nodes[0], ("CameraTargetRenderer",))
135
+ if crt is None:
136
+ raise RuntimeError(f"{nodes[0]['path']}: no CameraTargetRenderer")
137
+ stage, cam = advui._ref_path(crt["_stage"]), advui._ref_path(crt["_captureCamera"])
138
+ container = _parent(stage)
139
+ if not stage or not cam or _parent(cam) != container:
140
+ raise RuntimeError("CameraTargetRenderer: stage and capture camera not under one parent")
141
+ node = _first(nodes, cam)
142
+ c = [x for x in node["components"] if x["type"] == "Camera"]
143
+ d = [x for x in node["components"] if x.get("class") == "UniversalAdditionalCameraData"]
144
+ if len(c) != 1 or len(d) != 1:
145
+ raise RuntimeError(f"{cam}: {len(c)} Camera, {len(d)} UniversalAdditionalCameraData")
146
+ c, d = c[0], d[0]
147
+ return {"container": {"path": container, **_trs(_first(nodes, container))},
148
+ "stage": {"path": stage, **_trs(_first(nodes, stage))},
149
+ "camera": {"path": cam, "localPosition": node["localPosition"], "localRotation": node["localRotation"],
150
+ "fieldOfView": c["field of view"], "near": c["near clip plane"], "far": c["far clip plane"],
151
+ "orthographic": bool(c["orthographic"]), "orthographicSize": c["orthographic size"],
152
+ "clearFlags": c["m_ClearFlags"], "backgroundColor": c["m_BackGroundColor"],
153
+ "hdr": bool(c["m_HDR"]), "allowMSAA": bool(c["m_AllowMSAA"]),
154
+ "rendererIndex": d["m_RendererIndex"], "renderPostProcessing": bool(d["m_RenderPostProcessing"]),
155
+ "antialiasing": d["m_Antialiasing"]}}
156
+
157
+
158
+ def _fields(c: dict) -> dict:
159
+ return {k: v for k, v in c.items() if k not in COMPONENT_HEADER}
160
+
161
+
162
+ def layout_root(nodes: list[dict], path: str) -> dict:
163
+ """layoutRoot: the serialized fields of the SimpleAdvLayoutRoot on the node at `path` (the profile
164
+ SimpleAdvLayoutRoot.CreateProfile makes)."""
165
+ c = advui._component(_first(nodes, path), ("SimpleAdvLayoutRoot",))
166
+ if c is None:
167
+ raise RuntimeError(f"{path}: no SimpleAdvLayoutRoot")
168
+ return _fields(c)
169
+
170
+
171
+ def kept(path: str, subtrees=(), singles=()) -> bool:
172
+ """A node kept in a host UI: inside one of `subtrees` (root included), one of `singles`, or an ancestor of
173
+ either."""
174
+ return (any(path == s or path.startswith(s + "/") for s in subtrees)
175
+ or any(s == path or s.startswith(path + "/") for s in (*subtrees, *singles)))
176
+
177
+
178
+ # serialized references kept as node paths: component class -> (record key, fields)
179
+ REF_RECORDS = {
180
+ "UIAdvTalkWindow": ("talkWindowRefs", ("SpeakerText", "TalkText", "TalkArea", "_speakerNameObjects",
181
+ "_talkNextIndicator", "_talkBackground", "_autoIcon", "_fastIcon")),
182
+ "UISystemMessage": ("systemMessage", ("_message", "_showAnimation", "_canvasGroup", "_animator")),
183
+ "SpotLetterboxBands": ("letterboxBands", ("_topBand", "_bottomBand", "_leftBand", "_rightBand")),
184
+ }
185
+ WIDGET_FIELDS = ("_canvasSortOrder", "_isMainWidget", "_useBlur", "_blurDuration", "_useGlassMorphism")
186
+ TWEEN_EVENT = 1 # DOTweenSequence command type of an event entry (persistent calls)
187
+
188
+
189
+ def _plain(v):
190
+ """A value with serialized references ({gameObject | transform}) as node paths."""
191
+ if isinstance(v, list):
192
+ return [_plain(x) for x in v]
193
+ if isinstance(v, dict) and ("gameObject" in v or "transform" in v):
194
+ return advui._ref_path(v)
195
+ return v
196
+
197
+
198
+ def tween_calls(entry: dict) -> list[dict]:
199
+ """The persistent calls of a DOTweenSequence event entry (UnityEvent m_PersistentCalls)."""
200
+ calls = (((entry.get("_tweenEvent") or {}).get("m_PersistentCalls") or {}).get("m_Calls")) or []
201
+ out = []
202
+ for x in calls:
203
+ t, a = x.get("m_Target") or {}, x.get("m_Arguments") or {}
204
+ out.append({"target": advui._ref_path(t), "class": t.get("class") or t.get("component"),
205
+ "method": x.get("m_MethodName"), "string": a.get("m_StringArgument"),
206
+ "float": a.get("m_FloatArgument"), "int": a.get("m_IntArgument"),
207
+ "bool": a.get("m_BoolArgument"), "mode": x.get("m_Mode")})
208
+ return out
209
+
210
+
211
+ def node_record(node: dict) -> dict:
212
+ """The advui record of a prefab node (transform, rect, advui._layout_components) with the records of the
213
+ components the host screens add: aspectRatioFitter, simpleAdvLayoutRoot, safeArea (UISafeArea), the
214
+ references of UIAdvTalkWindow / UISystemMessage / SpotLetterboxBands as node paths, `widget` (the UIManager
215
+ widget fields of a *Widget component) and, per DOTweenSequence event entry, its persistent `calls`."""
216
+ rec = {"path": node["path"], "name": node["name"], "active": node["active"],
217
+ "localPosition": node["localPosition"], "localRotation": node["localRotation"],
218
+ "localScale": node["localScale"], "rect": node.get("rect")}
219
+ rec.update(advui._layout_components(node))
220
+ for c in node["components"]:
221
+ cls = c.get("class")
222
+ if cls == "AspectRatioFitter":
223
+ rec["aspectRatioFitter"] = {k: c[k] for k in ("m_Enabled", "m_AspectMode", "m_AspectRatio")}
224
+ elif cls == "SimpleAdvLayoutRoot":
225
+ rec["simpleAdvLayoutRoot"] = _fields(c)
226
+ elif cls == "UISafeArea":
227
+ rec["safeArea"] = {k: _plain(v) for k, v in _fields(c).items()}
228
+ elif cls in REF_RECORDS:
229
+ key, names = REF_RECORDS[cls]
230
+ rec[key] = {k: _plain(c.get(k)) for k in names}
231
+ elif cls == "DOTweenSequence":
232
+ for entry, out in zip(c["_list"], rec["tweenSequence"]["list"]):
233
+ if entry.get("_commandType") == TWEEN_EVENT:
234
+ out["calls"] = tween_calls(entry)
235
+ elif cls and cls.endswith("Widget") and "_canvasSortOrder" in c:
236
+ rec["widget"] = {"class": cls, **{k: c[k] for k in WIDGET_FIELDS if k in c}}
237
+ return rec
238
+
239
+
240
+ # ---------------------------------------------------------------- clips, shaders
241
+ TRANSFORM_CURVES = {"m_LocalPosition": "xyz", "m_LocalScale": "xyz", "m_LocalRotation": "xyzw"}
242
+
243
+
244
+ def clip_view(ex: Exporter, o, graph, root: str) -> dict:
245
+ """An AnimationClip played by the Animator at `root` as Exporter.clip_curves writes it, bindings below the
246
+ animated root included: their properties are keyed "<path relative to root>:<property>". Properties:
247
+ RectTransform driven fields, CanvasGroup.m_Alpha, Transform local position / rotation / scale / euler angles,
248
+ GameObject.m_IsActive and MonoBehaviour fields ("<class>.<field>"). Streamed curves as keys, dense curves as
249
+ {samples, sampleRate, beginTime} (the dense clip's frames), constant curves as {constant}; the discrete curves
250
+ that follow the streamed float curves in the stream (a sprite reference's "<class>.m_Sprite": the value indexes
251
+ `pptrCurveMapping`, the names of the referenced objects) as {discrete: [[time, value]]}, the value held until
252
+ the next key. A binding whose path names no object of the prefab (the Animator ignores it) is keyed
253
+ "#<path crc32>:<property>" and its crc listed in `unresolvedPaths`."""
254
+ rec = ex.clip_record(o)
255
+ ex.bind_clip(o, graph, root)
256
+ tt, mc, st, de = rec["tt"], rec["muscle"], rec["streamed"], rec["dense"]
257
+ name, const = rec["name"], rec["constant"]
258
+ n_st, n_disc, n_de = st["curveCount"], st.get("discreteCurveCount", 0), de["m_CurveCount"]
259
+ if n_disc and n_de:
260
+ raise NotImplementedError(f"clip {name}: discrete and dense curves")
261
+ props, lost = [], []
262
+ for r in rec["bindings"]:
263
+ if r["int"] or r["serializeReference"]:
264
+ raise NotImplementedError(f"clip {name}: binding {r['raw']}")
265
+ if r["path"] is None:
266
+ lost.append(r["pathCrc"])
267
+ pre = f"#{r['pathCrc']}:" if r["path"] is None else f"{r['path']}:" if r["path"] else ""
268
+ tid, attr = r["typeID"], r["attribute"]
269
+ if tid == 224 and attr in RECT_PROPS:
270
+ names = ["RectTransform." + attr]
271
+ elif tid == 225 and attr == "m_Alpha":
272
+ names = ["CanvasGroup.m_Alpha"]
273
+ elif tid == 4 and r["attributeCrc"] == 4: # localEulerAnglesRaw
274
+ names = [f"Transform.localEulerAngles.{a}" for a in "xyz"]
275
+ elif tid == 4 and r["transformAttr"] and attr in TRANSFORM_CURVES:
276
+ names = [f"Transform.{attr}.{a}" for a in TRANSFORM_CURVES[attr]]
277
+ elif tid == 1 and attr == "m_IsActive":
278
+ names = ["GameObject.m_IsActive"]
279
+ elif tid == 114 and r["script"] and attr:
280
+ names = [f"{r['script'][1]}.{attr}"]
281
+ elif r["pptr"] and attr == "m_Sprite" and tid == 212:
282
+ names = ["SpriteRenderer.m_Sprite"]
283
+ else:
284
+ raise NotImplementedError(f"clip {name}: binding {r['raw']}")
285
+ if r["pptr"] != (n_st <= len(props) < n_st + n_disc):
286
+ raise NotImplementedError(f"clip {name}: reference curve {r['raw']} outside the discrete curves")
287
+ props += [pre + n for n in names]
288
+ if n_st + n_disc + n_de + len(const) != len(props):
289
+ raise RuntimeError(f"clip {name}: curve count != bound properties")
290
+ curves = {p: {"keys": []} for p in props[:n_st]}
291
+ curves.update({p: {"discrete": []} for p in props[n_st:n_st + n_disc]})
292
+ for t, keys in streamed_frames(st["data"]):
293
+ for ci, c0, c1, c2, c3 in keys:
294
+ if ci < n_st:
295
+ curves[props[ci]]["keys"].append([t, c0, c1, c2, c3])
296
+ else:
297
+ curves[props[ci]]["discrete"].append([t, c3])
298
+ samples = list(de["m_SampleArray"])
299
+ for j in range(n_de):
300
+ curves[props[n_st + j]] = {"samples": samples[j::n_de][:de["m_FrameCount"]], "sampleRate": de["m_SampleRate"],
301
+ "beginTime": de["m_BeginTime"]}
302
+ for i, v in enumerate(const):
303
+ curves[props[n_st + n_disc + n_de + i]] = {"constant": v}
304
+ out = {"name": name, "sampleRate": tt["m_SampleRate"], "startTime": mc["m_StartTime"],
305
+ "stopTime": mc["m_StopTime"], "loopTime": bool(mc["m_LoopTime"]), "events": tt["m_Events"],
306
+ "curves": curves,
307
+ "keyFormat": "[time, c0, c1, c2, c3]; value(t) = ((c0*x + c1)*x + c2)*x + c3, x = t - time, "
308
+ "from the last key with time <= t (first key at -FLT_MAX)"}
309
+ if rec["pptrCurveMapping"]:
310
+ refs = [ex.deref(o, p) for p in rec["pptrCurveMapping"]]
311
+ out["pptrCurveMapping"] = [x.read_typetree().get("m_Name") if x is not None else None for x in refs]
312
+ if lost:
313
+ out["unresolvedPaths"] = lost
314
+ return out
315
+
316
+
317
+ def gles3_keywords(index: list, shader: str, keywords, what: str) -> list[str]:
318
+ """The keywords of `keywords` that the GLES3 variants of `shader` use; a GLES3 vertex program with exactly
319
+ those must exist (as advui checks its materials)."""
320
+ rec = next((r for r in index if r["name"] == shader), None)
321
+ if rec is None:
322
+ raise RuntimeError(f"shader {shader} of {what} not dumped")
323
+ gles = [v for v in rec["variants"] if v["platform"] == "gles3" and v["type"] == "GLES3"]
324
+ known = {k for v in gles for k in v["keywords"]}
325
+ want = sorted(k for k in keywords if k in known)
326
+ if not any(sorted(v["keywords"]) == want and v["stage"] == "vertex" for v in gles):
327
+ raise RuntimeError(f"{shader}: no GLES3 variant for {what} keywords {want}")
328
+ return want
329
+
330
+
331
+ def dump_gles3(objects: dict, out_dir: Path) -> tuple[list, dict]:
332
+ """Dump the Shader objects {name: object} in name order (shader.py layout) keeping only the GLES3 programs:
333
+ the index lists those, the other programs are not kept. -> (index, write_index summary)."""
334
+ index: list = []
335
+ for name in sorted(objects):
336
+ shader_mod.dump_objects([objects[name]], out_dir, objects[name].assets_file.name, index)
337
+ for rec in index:
338
+ keep = [v for v in rec["variants"] if v["platform"] == "gles3" and v["type"] == "GLES3"]
339
+ for v in rec["variants"]:
340
+ if v not in keep:
341
+ (Path(out_dir) / v["file"]).unlink(missing_ok=True)
342
+ rec["variants"] = keep
343
+ for d in sorted(Path(out_dir).rglob("*"), reverse=True):
344
+ if d.is_dir() and not any(d.iterdir()):
345
+ d.rmdir()
346
+ return index, shader_mod.write_index(index, out_dir)
347
+
348
+
349
+ # ---------------------------------------------------------------- UI documents
350
+ class UiDoc:
351
+ """A ui.json of prefab parts in the advui record format, written into `out_dir`: node records (node_record;
352
+ a TMP text gets its advui text record `textStyle` in the LanguageMode `mode`, None: no text allowed), the packed
353
+ sprites and textures, the materials with their GLES3 keyword sets, the UI shaders, the DOTween defaults, and
354
+ per Animator its controller (Exporter.controller_states) and clips (clip_view); a node whose controller cannot
355
+ be exported loses its `animator` record and is listed in `animatorsDropped`."""
356
+
357
+ def __init__(self, cat, player, out_dir: Path, mode: int | None = None):
358
+ self.player, self.dir = player, Path(out_dir)
359
+ self.dir.mkdir(parents=True, exist_ok=True)
360
+ self.ex = Exporter(cat, self.dir, player=player, textures="deferred", stub_assets=TMP_STUBS)
361
+ self.packer = TexelPacker(self.dir)
362
+ self.styles = textstyle.TextStyles(self.ex, player, mode) if mode is not None else None
363
+ self.nodes: list[dict] = []
364
+ self.sources: list[tuple[str, dict]] = [] # (key, prefab node) of each record
365
+ self.roots: list[dict] = []
366
+ self.doc: dict | None = None
367
+
368
+ def prefab(self, key: str) -> list[dict]:
369
+ return self.ex.prefab(key)["nodes"]
370
+
371
+ def add(self, key: str, nodes: list[dict], subtrees=None, singles=()) -> list[dict]:
372
+ """Records of the prefab `nodes` of `key` (every node, or those `kept` by subtrees / singles)."""
373
+ out = []
374
+ for n in nodes:
375
+ if subtrees is not None and not kept(n["path"], subtrees, singles):
376
+ continue
377
+ rec = node_record(n)
378
+ if "text" in rec:
379
+ if self.styles is None:
380
+ raise RuntimeError(f"{n['path']}: a text node in a UI without texts")
381
+ if not (rec.get("localizeText") or {}).get("localizeEnabled"):
382
+ raise NotImplementedError(f"{n['path']}: text without LocalizeText")
383
+ rec["textStyle"] = self.styles.record(n["path"], advui._component(n, advui.TMP_CLASSES),
384
+ advui._component(n, ("LocalizeText",)))
385
+ del rec["text"]
386
+ self.nodes.append(rec)
387
+ self.sources.append((key, n))
388
+ out.append(rec)
389
+ self.roots.append({"root": nodes[0]["path"], "key": key})
390
+ return out
391
+
392
+ def sprite(self, key: str) -> dict:
393
+ """{spriteRef, name} of the sprite a key names (a Sprite, or a Texture2D's sprite sub-asset)."""
394
+ o = self.ex.key_object(key)
395
+ return self.ex.sprite(o) if o.type.name == "Sprite" else self.ex.key_sprite(key)
396
+
397
+ def _controller(self, key: str, path: str):
398
+ env, graph = self.ex.load(key)
399
+ hits = []
400
+ for o in env.objects:
401
+ if o.type.name == "Animator":
402
+ tf = graph.tf_of_go.get(o.read_typetree()["m_GameObject"]["m_PathID"])
403
+ if tf is not None and graph.path(tf) == path:
404
+ hits.append(o)
405
+ if len(hits) != 1:
406
+ return None
407
+ ctrl = self.ex.deref(hits[0], hits[0].read_typetree()["m_Controller"])
408
+ if ctrl is None:
409
+ return None
410
+ try:
411
+ states = self.ex.controller_states(ctrl)
412
+ clips = {}
413
+ for p in ctrl.read_typetree()["m_AnimationClips"]:
414
+ c = self.ex.deref(ctrl, p)
415
+ if c is None or c.type.name != "AnimationClip":
416
+ raise NotImplementedError("not an AnimationClip")
417
+ v = clip_view(self.ex, c, graph, path)
418
+ clips[f"{states['name']}/{v['name']}"] = v
419
+ except NotImplementedError:
420
+ return None
421
+ return states, clips
422
+
423
+ def write(self, about: str, head: dict | None = None, tail: dict | None = None) -> dict:
424
+ clips, controllers, dropped = {}, {}, []
425
+ for rec, (key, n) in zip(self.nodes, self.sources):
426
+ if "animator" in rec:
427
+ got = self._controller(key, n["path"])
428
+ if got is None:
429
+ del rec["animator"]
430
+ dropped.append(rec["path"])
431
+ else:
432
+ controllers[got[0]["name"]] = got[0]
433
+ clips.update(got[1])
434
+ ex, refs = self.ex, set()
435
+ for rec in self.nodes:
436
+ if (rec.get("image") or {}).get("spriteRef"):
437
+ refs.add(rec["image"]["spriteRef"])
438
+ if (rec.get("buttonImageState") or {}).get("selectedRef"):
439
+ refs.add(rec["buttonImageState"]["selectedRef"])
440
+ for ref in sorted(refs):
441
+ s = ex.sprite_recs[ref]
442
+ tex = s["tex"].read_typetree()["m_Name"]
443
+ ex.pack_sprite(ref, self.packer, "sprites" if "FixUiSpriteAtlas" in tex else f"sprite_{s['name']}")
444
+ textures = self.packer.build()
445
+ sprites = {ex.sprite_recs[ref]["name"]: ex.packed_sprite(ref) for ref in sorted(refs)}
446
+ for rec in self.nodes:
447
+ (rec.get("image") or {}).pop("spriteRef", None)
448
+ (rec.get("buttonImageState") or {}).pop("selectedRef", None)
449
+ materials = {"Default UI Material": {"material": "Default UI Material", "shader": {"shader": "UI/Default"},
450
+ "keywords": [], "textures": {}, "ints": {}, "floats": {}, "colors": {},
451
+ "shaderDefaults": True}}
452
+ for _, n in self.sources:
453
+ for c in n["components"]:
454
+ if c.get("class") in ("Image", "RawImage") and c.get("m_Material"):
455
+ materials.setdefault(c["m_Material"]["material"], json.loads(dumps(c["m_Material"])))
456
+ for m in materials.values():
457
+ storyfonts._material_textures(m)
458
+ needed = {}
459
+ for name in sorted({m["shader"]["shader"] for m in materials.values()}):
460
+ if name == "UI/Default":
461
+ needed[name] = self.player.shader("UI/Default", file="unity_builtin_extra")
462
+ elif name in ex.shaders:
463
+ needed[name] = ex.shaders[name]
464
+ else:
465
+ raise RuntimeError(f"shader {name} not in the loaded bundles")
466
+ index, summary = ex.dump_shaders(self.dir / "shaders", needed)
467
+ keywords = {k: gles3_keywords(index, m["shader"]["shader"], m["keywords"], f"material {k}")
468
+ for k, m in materials.items()}
469
+ dts = self.player.mono(self.player.resource("DOTweenSettings"))
470
+ doc = {"about": about, **(head or {}), "roots": self.roots, "nodes": self.nodes, "sprites": sprites,
471
+ "textures": textures, "materials": materials, "materialKeywords": keywords, "clips": clips,
472
+ "controllers": controllers, "animatorsDropped": dropped,
473
+ "dotween": {k: dts[k] for k in DOTWEEN_FIELDS},
474
+ "shaders": {"index": "shaders/shaders.json", "names": summary["names"]}}
475
+ if self.styles is not None:
476
+ doc["textStyle"] = self.styles.summary()
477
+ doc.update(tail or {})
478
+ write_json(self.dir / "ui.json", doc)
479
+ self.doc = doc
480
+ return doc
481
+
482
+
483
+ # ---------------------------------------------------------------- home
484
+ def container_go(env, internal_id: str):
485
+ """The GameObject an AssetBundle container entry names (a prefab's root)."""
486
+ for o in env.objects:
487
+ if o.type.name == "AssetBundle":
488
+ for path, info in o.read_typetree()["m_Container"]:
489
+ if path.lower() == internal_id.lower():
490
+ go = deref(o, info["asset"])
491
+ if go is not None and go.type.name == "GameObject":
492
+ return go
493
+ raise KeyError(f"{internal_id}: no GameObject in the AssetBundle containers")
494
+
495
+
496
+ def _go_ref(owner, pptr: dict) -> int | None:
497
+ if not pptr["m_PathID"]:
498
+ return None
499
+ if pptr["m_FileID"] == 0:
500
+ return pptr["m_PathID"]
501
+ o = deref(owner, pptr)
502
+ return o.path_id if o is not None else None
503
+
504
+
505
+ def background(cat, key: str, situation_name: str, ex: Exporter) -> dict:
506
+ """The placed background prefab `key` as SpotSceneRoot.SetObject leaves it, over the nodes and materials of
507
+ room.extract_room (the same bundle load, glb_order): SpotBackground.Prepare(situation_name) on the prefab
508
+ root, the "floor" render queue (FindFirstChildWithName(background, "floor", "shadow"), its
509
+ GetComponentInChildren<MeshRenderer>().material.renderQueue = 2000, before the root is activated), then
510
+ SetActiveFast(background, true). Objects outside the placed prefab (other prefabs of the bundle closure) are
511
+ not placed: inactive."""
512
+ env = UnityPy.load(*[str(p) for p in cat.fetch_key(key)])
513
+ graph = SceneGraph(env)
514
+ mf_by_go, mr_by_go = {}, {}
515
+ for o in env.objects:
516
+ if o.type.name == "MeshFilter":
517
+ mf_by_go[o.read_typetree()["m_GameObject"]["m_PathID"]] = o
518
+ elif o.type.name == "MeshRenderer":
519
+ mr_by_go[o.read_typetree()["m_GameObject"]["m_PathID"]] = o
520
+ root_go = container_go(env, cat._entry(key)["internal_id"]).path_id
521
+ root = graph.tf_of_go[root_go]
522
+ spot_bg, volumes = [], []
523
+ for o in env.objects:
524
+ if o.type.name != "MonoBehaviour":
525
+ continue
526
+ tf = graph.tf_of_go.get(o.read_typetree()["m_GameObject"]["m_PathID"])
527
+ if tf is None or not under(graph, tf, root):
528
+ continue
529
+ cls = script_class(o)
530
+ if cls == "SpotBackground" and tf == root: # background.GetComponent<SpotBackground>()
531
+ spot_bg.append(o)
532
+ elif cls == "Volume":
533
+ volumes.append((o, tf))
534
+ if spot_bg:
535
+ tt = spot_bg[0].read_typetree()
536
+ entries = [(e.get("SituationName"), [_go_ref(spot_bg[0], h) for h in e.get("HideObjects") or []])
537
+ for e in tt.get("_situationObjects") or []]
538
+ prep = prepare(graph, root, situation_name, entries)
539
+ else:
540
+ prep = {"name": situation_name.replace(CLONE, ""), "matched": False, "activate": [], "hide": [],
541
+ "overrides": {}}
542
+ overrides = prep["overrides"]
543
+ floor = find_first_child_with_name(graph, root, FLOOR_NAME, FLOOR_EXCLUDE)
544
+ renderer = None if floor is None else component_in_children(
545
+ graph, floor, lambda t: _go(graph, t) in mr_by_go, lambda t: active_in_hierarchy(graph, t, overrides))
546
+ shown = {**overrides, root_go: True}
547
+ nodes, mats = glb_order(room.drawn_meshes(mf_by_go, mr_by_go, graph, lambda p: None))
548
+ rows = []
549
+ for p in mats:
550
+ mat = p.read()
551
+ sh = mat.m_Shader.read().object_reader
552
+ rows.append((mat.object_reader.read_typetree(), sh.read_typetree()["m_ParsedForm"]["m_Name"], sh))
553
+ tex_index = glb_textures([(tt, name) for tt, name, _ in rows])
554
+ if len(volumes) > 1:
555
+ raise NotImplementedError(f"{key}: {len(volumes)} Volume components")
556
+ volume = None
557
+ if volumes:
558
+ o, tf = volumes[0]
559
+ volume = {"path": graph.path(tf), "active": active_in_hierarchy(graph, tf, shown),
560
+ **volume_record(ex.component(o), graph.go[_go(graph, tf)]["m_Layer"])}
561
+ t = graph.tf[root]
562
+ return {
563
+ "roomRoot": {"path": graph.path(root), "localPosition": t["m_LocalPosition"],
564
+ "localRotation": t["m_LocalRotation"], "localScale": t["m_LocalScale"]},
565
+ "roomNodes": [{"path": graph.path(tf), "placed": under(graph, tf, root),
566
+ "active": under(graph, tf, root) and active_in_hierarchy(graph, tf, shown),
567
+ "renderQueue": FLOOR_QUEUE if tf == renderer else None} for tf, _ in nodes],
568
+ "roomMaterials": [room_material(tt, name, tex_index) for tt, name, _ in rows],
569
+ "situation": {"name": prep["name"], "matched": prep["matched"],
570
+ "activate": [graph.path(x) for x in prep["activate"]],
571
+ "hide": [graph.path(graph.tf_of_go[g]) if g in graph.tf_of_go else None for g in prep["hide"]]},
572
+ "floor": {"object": graph.path(floor) if floor is not None else None,
573
+ "renderer": graph.path(renderer) if renderer is not None else None, "renderQueue": FLOOR_QUEUE},
574
+ "volume": volume,
575
+ "lights": [{"prefab": "background", **r} for r in lighting_components(
576
+ env, graph, root, lambda tf: active_in_hierarchy(graph, tf, shown))],
577
+ "shaders": {name: sh for _, name, sh in reversed(rows)}, # the first material's object
578
+ "check": {"nodes": [n for _, n in nodes], "textures": len(tex_index),
579
+ "mirrored": sum(1 for tf, _ in nodes if under(graph, tf, root)
580
+ and np.linalg.det(graph.world(tf)[:3, :3]) < 0)},
581
+ }
582
+
583
+ def spine_records(env, ex: Exporter) -> tuple[dict, list, dict]:
584
+ """The Spine data of a situation prefab's bundle closure (loaded as spot.extract loads it): the material of
585
+ every atlas page (Exporter.material records by name), per SpotSpineCharacter in spot.json's order its
586
+ SkeletonAnimation's MeshRenderer sorting order / layer and {atlas page: material name} (SpineAtlasAsset
587
+ materials in page order), and the shaders the materials name. -> (materials, characters, {name: shader})."""
588
+ graph = SceneGraph(env)
589
+ by_cls: dict[str, list] = {}
590
+ mr_by_go = {}
591
+ for o in env.objects:
592
+ if o.type.name == "MonoBehaviour":
593
+ by_cls.setdefault(script_class(o), []).append(o)
594
+ elif o.type.name == "MeshRenderer":
595
+ mr_by_go[o.read_typetree()["m_GameObject"]["m_PathID"]] = o
596
+ materials, shaders, pages_of = {}, {}, {}
597
+
598
+ def pages(sda) -> dict:
599
+ if sda.path_id not in pages_of:
600
+ out = {}
601
+ for ap in sda.read_typetree()["atlasAssets"]:
602
+ a = deref(sda, ap)
603
+ if a is None:
604
+ continue
605
+ att = a.read_typetree()
606
+ names = spot._atlas_pages(spot._text_bytes(deref(a, att["atlasFile"]).read()).decode("utf-8"))
607
+ mats = [deref(a, m) for m in att["materials"]]
608
+ if len(names) != len(mats):
609
+ raise RuntimeError(f"atlas {att.get('m_Name')}: {len(names)} pages vs {len(mats)} materials")
610
+ for page, m in zip(names, mats):
611
+ rec = None if m is None else ex.material(m)
612
+ if rec is not None:
613
+ storyfonts._material_textures(rec)
614
+ materials.setdefault(rec["material"], rec)
615
+ sh = rec["shader"]["shader"]
616
+ shaders.setdefault(sh, ex.shaders[sh])
617
+ out[page] = rec and rec["material"]
618
+ pages_of[sda.path_id] = out
619
+ return pages_of[sda.path_id]
620
+ for o in by_cls.get("SkeletonDataAsset", []):
621
+ pages(o)
622
+ chars = []
623
+ for o in by_cls.get("SpotSpineCharacter", []):
624
+ tt = o.read_typetree()
625
+ rec = {"path": graph.path(graph.tf_of_go[tt["m_GameObject"]["m_PathID"]]), "sortingOrder": None,
626
+ "sortingLayer": None, "pageMaterials": {}}
627
+ anim = deref(o, tt["_animation"])
628
+ if anim is not None:
629
+ at = anim.read_typetree()
630
+ mr = mr_by_go.get(at["m_GameObject"]["m_PathID"])
631
+ if mr is not None:
632
+ mt = mr.read_typetree()
633
+ rec.update(sortingOrder=mt.get("m_SortingOrder"), sortingLayer=mt.get("m_SortingLayerID"))
634
+ sda = deref(anim, at["skeletonDataAsset"])
635
+ rec["pageMaterials"] = dict(pages(sda)) if sda is not None else {}
636
+ chars.append(rec)
637
+ return {k: materials[k] for k in sorted(materials)}, chars, shaders
638
+
639
+
640
+ def render_settings(env) -> dict:
641
+ """renderSettings: the scene's RenderSettings (RENDER_SETTINGS_FIELDS; RENDER_SETTINGS_REFS as {"name"} or null) and
642
+ `lightmaps`, the lightmap count of its LightmapSettings. SpotManager.ActiveSpotScene makes the "Spot" scene the
643
+ active one, whose settings light the spot."""
644
+ rs = [o for o in env.objects if o.type.name == "RenderSettings"]
645
+ lm = [o for o in env.objects if o.type.name == "LightmapSettings"]
646
+ if len(rs) != 1 or len(lm) != 1:
647
+ raise RuntimeError(f"scene: {len(rs)} RenderSettings and {len(lm)} LightmapSettings")
648
+ tt = rs[0].read_typetree()
649
+ out = {k: _plain(tt[k]) for k in RENDER_SETTINGS_FIELDS if k in tt}
650
+ for k in RENDER_SETTINGS_REFS:
651
+ ref = tt.get(k)
652
+ if not ref or not ref["m_PathID"]:
653
+ out[k] = None
654
+ continue
655
+ target = deref(rs[0], ref)
656
+ out[k] = {"name": target.read_typetree().get("m_Name") if target is not None else None}
657
+ out["lightmaps"] = len(lm[0].read_typetree().get("m_Lightmaps") or [])
658
+ return out
659
+
660
+
661
+ def lighting_components(env, graph, root_tf: int, active) -> list[dict]:
662
+ """The Light / ReflectionProbe components under `root_tf` in path order: path, type, m_Enabled, `active(tf)`
663
+ (activeInHierarchy as placed) and a Light's m_Type."""
664
+ out = []
665
+ for o in env.objects:
666
+ if o.type.name not in LIGHTING_TYPES:
667
+ continue
668
+ tt = o.read_typetree()
669
+ tf = graph.tf_of_go.get(tt["m_GameObject"]["m_PathID"])
670
+ if tf is None or not under(graph, tf, root_tf):
671
+ continue
672
+ rec = {"path": graph.path(tf), "type": o.type.name, "m_Enabled": int(tt.get("m_Enabled", 1)),
673
+ "active": bool(active(tf))}
674
+ if o.type.name == "Light":
675
+ rec["m_Type"] = tt.get("m_Type")
676
+ out.append(rec)
677
+ return sorted(out, key=lambda r: (r["path"], r["type"]))
678
+
679
+
680
+ def _active_path(nodes: list[dict], path: str) -> bool:
681
+ """activeInHierarchy of the node at `path` in a node list (it and its ancestors active)."""
682
+ return all(n["active"] for n in nodes if n["path"] == path or path.startswith(n["path"] + "/"))
683
+
684
+
685
+ def scene_root(ex: Exporter, key: str | None) -> dict:
686
+ """sceneRoot: the "Spot" scene's SpotSceneRoot: world TRS of `_objRoot` (the parent SetObject puts the
687
+ background and the situation under), its Light / ReflectionProbe components (with `active`, the object's
688
+ activeInHierarchy), its Volumes, the scene camera (`_sceneCamera`, advui camera record) and the scene's
689
+ RenderSettings (render_settings); null / [] when the scene or the component is not there."""
690
+ out = {"key": key, "objRoot": None, "lights": [], "volumes": [], "sceneCamera": None, "renderSettings": None}
691
+ if key is None:
692
+ return out
693
+ nodes = ex.prefab(key)["nodes"]
694
+ out["renderSettings"] = render_settings(ex.load(key)[0])
695
+ roots = [(n, c) for n in nodes for c in n["components"] if c.get("class") == "SpotSceneRoot"]
696
+ if len(roots) != 1:
697
+ return out
698
+ c = roots[0][1]
699
+ obj, cam = advui._ref_path(c.get("_objRoot")), advui._ref_path(c.get("_sceneCamera"))
700
+ out["objRoot"] = {"path": obj, **world_trs(nodes, obj)} if obj else None
701
+ out["lights"] = [{"path": n["path"], "type": x["type"], "active": _active_path(nodes, n["path"]),
702
+ **{k: _plain(v) for k, v in x.items() if k != "type"}}
703
+ for n in nodes for x in n["components"] if x["type"] in LIGHTING_TYPES]
704
+ out["volumes"] = [{"path": n["path"], **volume_record(x, n["layer"])}
705
+ for n in nodes for x in n["components"] if x.get("class") == "Volume"]
706
+ out["sceneCamera"] = advui._camera(nodes, cam) if cam else None
707
+ return out
708
+
709
+
710
+ def home_host(cat, master_dir, player, host: dict, hdir: Path, keys: dict) -> dict:
711
+ """host.json `home`: spot.extract and room.extract_room into host/spot/, the placed background (background),
712
+ the Spine records, the Spot scene root, the blur, the ambient sound and the shaders (host/shaders/, GLES3
713
+ programs only) with each material's GLES3 keyword set."""
714
+ sdir = hdir / "spot"
715
+ doc = spot.extract(cat, Path(master_dir), host["spotId"], sdir)
716
+ row = doc["master"]
717
+ # A tap talk's character need not be among the spot's SpotCharacters (spot.json `characters`): the game then has
718
+ # no tap target that starts the talk (HomeSpotAfterTalkRequestFactory.TrySelectAdv matches the tapped
719
+ # SpotCharacter's _characterId), and the player plays it without the camera focus
720
+ bg_key, sit_key = row["_backgroundAssetPath"], row["_situationAssetPath"]
721
+ room_doc = room.extract_room(cat, bg_key, sdir / "room.glb")
722
+ ex = Exporter(cat, hdir, player=player, textures="deferred", inline_meshes=False)
723
+ sit_env = UnityPy.load(*[str(p) for p in cat.fetch_key(sit_key)])
724
+ sit_go = container_go(sit_env, cat._entry(sit_key)["internal_id"])
725
+ sit_name = sit_go.read_typetree()["m_Name"]
726
+ sit_graph = SceneGraph(sit_env)
727
+ sit_lights = [{"prefab": "situation", **r} for r in lighting_components(
728
+ sit_env, sit_graph, sit_graph.tf_of_go[sit_go.path_id],
729
+ lambda tf: active_in_hierarchy(sit_graph, tf, {sit_go.path_id: True}))]
730
+ bg = background(cat, bg_key, sit_name + CLONE, ex)
731
+ glb = glb_json(sdir / "room.glb")
732
+ if ([n["name"] for n in glb["nodes"]] != bg["check"]["nodes"] or room_doc["meshCount"] != len(bg["roomNodes"])
733
+ or [m["name"] for m in glb.get("materials", [])] != [m["name"] for m in bg["roomMaterials"]]
734
+ or len(glb.get("images", [])) != bg["check"]["textures"]):
735
+ raise RuntimeError(f"{bg_key}: room.glb nodes / materials / textures differ from the background's order")
736
+ spine_mats, spine_chars, spine_shaders = spine_records(sit_env, ex)
737
+ if [c["path"] for c in spine_chars] != [c["path"] for c in doc["spineCharacters"]]:
738
+ raise RuntimeError(f"{sit_key}: Spine characters differ from spot.json's")
739
+ blur = blur_record(player.graphics())
740
+ blur_obj = [o for o in player.renderer_shaders(blur["renderer"]) if o.read().m_ParsedForm.m_Name == blur["shader"]]
741
+ if len(blur_obj) != 1:
742
+ raise RuntimeError(f"renderer {blur['renderer']}: {len(blur_obj)} shaders {blur['shader']}")
743
+ needed = dict(bg["shaders"])
744
+ for name, o in [*spine_shaders.items(), (blur["shader"], blur_obj[0])]:
745
+ needed.setdefault(name, o)
746
+ index, summary = dump_gles3(needed, hdir / "shaders")
747
+ keywords = {"room": [gles3_keywords(index, m["shader"], m["keywords"], f"room material {m['name']}")
748
+ for m in bg["roomMaterials"]],
749
+ "spine": {k: gles3_keywords(index, m["shader"]["shader"], m["keywords"], f"Spine material {k}")
750
+ for k, m in spine_mats.items()}}
751
+ gles3_keywords(index, blur["shader"], [], "the blur pass")
752
+ return {"spotId": host["spotId"], "talk": host["talk"], "characterId": host["characterId"],
753
+ "titleTextId": row.get("_advNameTextId") if host["talk"] == "area" else None,
754
+ "spot": f"{HOST_DIR}/spot/spot.json", "room": f"{HOST_DIR}/spot/room.glb",
755
+ "roomRoot": bg["roomRoot"], "roomNodes": bg["roomNodes"], "roomMaterials": bg["roomMaterials"],
756
+ "situation": bg["situation"], "floor": bg["floor"], "sceneRoot": scene_root(ex, keys.get("spotScene")),
757
+ "volume": bg["volume"], "lights": bg["lights"] + sit_lights,
758
+ "spineMaterials": spine_mats, "spineCharacters": spine_chars,
759
+ "shaders": {"index": f"{HOST_DIR}/shaders/shaders.json", "names": summary["names"]},
760
+ "materialKeywords": keywords, "blur": blur,
761
+ "ambient": ambient_record(doc["situationSettings"], master_dir)}
762
+
763
+
764
+ def home_ui(ui: UiDoc, keys: dict) -> tuple[str, dict, list]:
765
+ """host/ui nodes of a home talk: UISpotWidget's root, View, View/Fade, its DOTweenSequence_* nodes (and the
766
+ nodes their calls target), the SimpleAdv root (UISpotWidget._afterTalkContents) subtree; UISpotLetterbox.
767
+ The opened sequence is _fadeIn: the spot appears through SpotManager.PlayInAnimation -> SpotPresenter.FadeIn
768
+ (the Fade cover from 1 to 0); a spot change (ChangeSpotAsync) covers with _transitionIn and reveals with
769
+ _transitionOut, which ends with the cover at 0 as well.
770
+ -> (overlay root, layout root, opened sequences)."""
771
+ nodes = ui.prefab(keys["spotWidget"])
772
+ w = advui._component(nodes[0], ("UISpotWidget",))
773
+ overlay = advui._ref_path(w["_afterTalkContents"])
774
+ seqs = [advui._ref_path(w[k]) for k in ("_transitionIn", "_transitionOut", "_fadeIn", "_fadeOut") if w.get(k)]
775
+ fade = f"{_parent(overlay)}/Fade"
776
+ ui.add(keys["spotWidget"], nodes, [overlay, fade, *seqs], _call_targets(nodes, seqs))
777
+ ui.add(keys["spotLetterbox"], ui.prefab(keys["spotLetterbox"]))
778
+ return overlay, layout_root(nodes, overlay), [advui._ref_path(w["_fadeIn"])]
779
+
780
+
781
+ def _call_targets(nodes: list[dict], seqs: list[str]) -> list[str]:
782
+ """The nodes the DOTweenSequence event entries of the nodes at `seqs` call."""
783
+ out = []
784
+ for n in nodes:
785
+ if n["path"] in seqs:
786
+ for c in n["components"]:
787
+ if c.get("class") == "DOTweenSequence":
788
+ for e in c["_list"]:
789
+ out += [x["target"] for x in tween_calls(e) if x["target"] and x["target"] not in out]
790
+ return out
791
+
792
+
793
+ def afterlive_ui(ui: UiDoc, keys: dict) -> tuple[str, dict, list, dict]:
794
+ """host/ui nodes of a live result talk: UILiveResultBackgroundWidget (its _backgroundImage showing the
795
+ live_result_background sprite); UILiveResultWidget's chain to the SimpleAdv root (_advSimpleContents) and its
796
+ subtree, the widget's and UISoloLiveMusicResultView's _inAnimation sequences and the nodes their calls target.
797
+ -> (overlay root, layout root, opened sequences, host.json `afterlive`)."""
798
+ bnodes = ui.prefab(keys["resultBackground"])
799
+ bw = advui._component(bnodes[0], ("UILiveResultBackgroundWidget",))
800
+ bg_path = advui._ref_path(bw["_backgroundImage"])
801
+ recs = [r for r in ui.add(keys["resultBackground"], bnodes) if r["path"] == bg_path and r.get("image")]
802
+ if len(recs) != 1:
803
+ raise RuntimeError(f"{bg_path}: {len(recs)} image nodes")
804
+ sprite = ui.sprite(keys["resultBackgroundSprite"])
805
+ recs[0]["image"].update(sprite=sprite["name"], spriteRef=sprite["spriteRef"])
806
+ nodes = ui.prefab(keys["resultWidget"])
807
+ rw = advui._component(nodes[0], ("UILiveResultWidget",))
808
+ overlay = advui._ref_path(rw["_advSimpleContents"])
809
+ view = [(n, c) for n in nodes for c in n["components"] if c.get("class") == "UISoloLiveMusicResultView"
810
+ and overlay.startswith(n["path"] + "/")]
811
+ if len(view) != 1:
812
+ raise RuntimeError(f"{overlay}: {len(view)} UISoloLiveMusicResultView ancestors")
813
+ seqs = [advui._ref_path(bw["_inAnimation"]), advui._ref_path(rw["_inAnimation"]),
814
+ advui._ref_path(view[0][1]["_inAnimation"])]
815
+ ui.add(keys["resultWidget"], nodes, [overlay, *seqs[1:]], _call_targets(nodes, seqs[1:]))
816
+ parts = overlay.split("/")
817
+ reward = next(("/".join(parts[:i + 1]) for i in range(len(parts) - 2, 0, -1) if parts[i] == REWARD_PANEL), None)
818
+ if reward is None:
819
+ raise RuntimeError(f"{overlay}: no {REWARD_PANEL} ancestor")
820
+ return overlay, layout_root(nodes, overlay), seqs, {
821
+ "backgroundNode": bg_path, "backgroundSprite": sprite["name"], "rewardPanel": reward,
822
+ "substitutes": list(SUBSTITUTES)}
823
+
824
+
825
+ # ---------------------------------------------------------------- background (SpotBackground, SpotSceneRoot)
826
+ def _go(graph, tf: int) -> int:
827
+ return graph.tf[tf]["m_GameObject"]["m_PathID"]
828
+
829
+
830
+ def _children(graph, tf: int) -> list[int]:
831
+ return [c["m_PathID"] for c in graph.tf[tf]["m_Children"]]
832
+
833
+
834
+ def prepare(graph, background_tf: int, situation_name: str, entries: list) -> dict:
835
+ """SpotBackground.Prepare(situationName) on the background at `background_tf` (entries: its _situationObjects
836
+ as [(SituationName, [HideObjects GameObject path ids | None])]): "(Clone)" removed from the name; the first
837
+ entry of that name activates every child of transform.GetChild(0) and deactivates its HideObjects; no entry of
838
+ that name keeps the prefab state. -> {name, matched, activate [transform ids], hide [GameObject ids | None],
839
+ overrides {GameObject id: activeSelf}}."""
840
+ name = situation_name.replace(CLONE, "")
841
+ kids = _children(graph, background_tf)
842
+ if not kids:
843
+ raise RuntimeError(f"{graph.path(background_tf)}: SpotBackground without a child (GetChild(0))")
844
+ for entry_name, hide in entries:
845
+ if entry_name == name:
846
+ activate = _children(graph, kids[0])
847
+ overrides = {_go(graph, t): True for t in activate}
848
+ overrides.update({g: False for g in hide if g})
849
+ return {"name": name, "matched": True, "activate": activate, "hide": list(hide), "overrides": overrides}
850
+ return {"name": name, "matched": False, "activate": [], "hide": [], "overrides": {}}
851
+
852
+
853
+ def active_in_hierarchy(graph, tf: int, overrides: dict) -> bool:
854
+ """GameObject.activeInHierarchy of the object at `tf`, with `overrides` {GameObject id: activeSelf} over the
855
+ serialized m_IsActive."""
856
+ while tf in graph.tf:
857
+ go = _go(graph, tf)
858
+ if not overrides.get(go, bool(graph.go.get(go, {}).get("m_IsActive", 1))):
859
+ return False
860
+ tf = graph.tf[tf]["m_Father"]["m_PathID"]
861
+ return True
862
+
863
+
864
+ def under(graph, tf: int, root: int) -> bool:
865
+ """The transform `tf` is `root` or below it."""
866
+ while tf in graph.tf:
867
+ if tf == root:
868
+ return True
869
+ tf = graph.tf[tf]["m_Father"]["m_PathID"]
870
+ return False
871
+
872
+
873
+ def find_first_child_with_name(graph, tf: int, contains: str, excludes: str) -> int | None:
874
+ """SpotSceneRoot.SetObject's FindFirstChildWithName: depth first over the descendants of `tf` (itself
875
+ excluded), children in order, the first whose name contains `contains` and not `excludes` (ordinal)."""
876
+ for c in _children(graph, tf):
877
+ name = graph.go.get(_go(graph, c), {}).get("m_Name", "")
878
+ if contains in name and excludes not in name:
879
+ return c
880
+ hit = find_first_child_with_name(graph, c, contains, excludes)
881
+ if hit is not None:
882
+ return hit
883
+ return None
884
+
885
+
886
+ def component_in_children(graph, tf: int, has, active) -> int | None:
887
+ """GameObject.GetComponentInChildren<T>() (inactive objects skipped): depth first from `tf` itself, the first
888
+ transform whose object is active in the hierarchy (`active(tf)`) and has the component (`has(tf)`)."""
889
+ if active(tf) and has(tf):
890
+ return tf
891
+ for c in _children(graph, tf):
892
+ hit = component_in_children(graph, c, has, active)
893
+ if hit is not None:
894
+ return hit
895
+ return None
896
+
897
+
898
+ def glb_order(drawn, arrays_of=mesh_arrays) -> tuple[list, list]:
899
+ """The nodes and materials room.extract_room writes, in its order, from its room.drawn_meshes items
900
+ [(transform id, Mesh, material PPtrs)]: a mesh without vertex data or triangles, or whose submeshes all lack
901
+ a material, is no node; a material is numbered at its first use (room.submesh_primitives order, by path id).
902
+ -> ([(transform id, mesh name)], [material PPtr])."""
903
+ index: dict[int, int] = {}
904
+ mats, nodes = [], []
905
+
906
+ def material_for(p) -> int:
907
+ if p.path_id not in index:
908
+ index[p.path_id] = len(mats)
909
+ mats.append(p)
910
+ return index[p.path_id]
911
+ for tf, mesh, pptrs in drawn:
912
+ arrays = arrays_of(mesh)
913
+ if arrays is None:
914
+ continue
915
+ tris = arrays[3]
916
+ if not any(len(t) for t in tris):
917
+ continue
918
+ if len(pptrs) < len(tris):
919
+ raise NotImplementedError(f"{mesh.m_Name}: {len(tris)} submeshes, {len(pptrs)} materials")
920
+ if room.submesh_primitives(tris, pptrs, material_for, lambda i: None):
921
+ nodes.append((tf, mesh.m_Name))
922
+ return nodes, mats
923
+
924
+
925
+ def glb_textures(materials: list) -> dict[int, int]:
926
+ """{texture path id: glb texture index} of room.extract_room for its materials [(typetree, shader name)] in
927
+ glb order: the main texture slot of each (room._main_tex_slot), numbered at first use."""
928
+ index: dict[int, int] = {}
929
+ for tt, shader_name in materials:
930
+ slot = room._main_tex_slot(shader_name)
931
+ for key, env in tt["m_SavedProperties"]["m_TexEnvs"]:
932
+ tid = env["m_Texture"]["m_PathID"]
933
+ if key == slot and tid:
934
+ index.setdefault(tid, len(index))
935
+ return index
936
+
937
+
938
+ def room_material(tt: dict, shader_name: str, tex_index: dict) -> dict:
939
+ """A room material as saved (its shader runs as the game's): name, shader, keywords, float / int / colour
940
+ properties, custom render queue, and per texture slot the glb texture index (null when the texture is not in
941
+ the glb), scale and offset."""
942
+ sp = tt["m_SavedProperties"]
943
+ keywords = tt.get("m_ValidKeywords")
944
+ if keywords is None:
945
+ keywords = (tt.get("m_ShaderKeywords") or "").split()
946
+ return {"name": tt["m_Name"], "shader": shader_name, "keywords": list(keywords),
947
+ "floats": dict(sp.get("m_Floats", [])), "ints": dict(sp.get("m_Ints", [])),
948
+ "colors": dict(sp.get("m_Colors", [])), "renderQueue": tt.get("m_CustomRenderQueue"),
949
+ "texEnvs": {k: {"texture": tex_index.get(v["m_Texture"]["m_PathID"]) if v["m_Texture"]["m_PathID"]
950
+ else None,
951
+ "scale": [v["m_Scale"]["x"], v["m_Scale"]["y"]],
952
+ "offset": [v["m_Offset"]["x"], v["m_Offset"]["y"]]} for k, v in sp["m_TexEnvs"]}}
953
+
954
+
955
+ def glb_json(path) -> dict:
956
+ """The JSON chunk of a binary glTF file."""
957
+ data = Path(path).read_bytes()
958
+ (n,) = struct.unpack_from("<I", data, 12)
959
+ if data[:4] != b"glTF" or data[16:20] != b"JSON":
960
+ raise RuntimeError(f"{path}: not a binary glTF file")
961
+ return json.loads(data[20:20 + n])
962
+
963
+
964
+ def volume_record(comp: dict, layer: int) -> dict:
965
+ """A Volume component (Exporter record: sharedProfile and its components inlined) -> enabled, isGlobal,
966
+ weight, priority, blendDistance, layer, profile name and per profile component its class, active flag and the
967
+ parameters it overrides (m_OverrideState set) with their values."""
968
+ prof = comp.get("sharedProfile") or {}
969
+ comps = []
970
+ for c in prof.get("components") or []:
971
+ if not c:
972
+ continue
973
+ over = {k: _plain(v["m_Value"]) for k, v in c.items()
974
+ if isinstance(v, dict) and "m_OverrideState" in v and v["m_OverrideState"]}
975
+ comps.append({"class": c.get("asset"), "active": bool(c.get("active", 1)), **over})
976
+ return {"enabled": bool(comp.get("m_Enabled", 1)), "isGlobal": bool(comp["m_IsGlobal"]), "weight": comp["weight"],
977
+ "priority": comp["priority"], "blendDistance": comp["blendDistance"], "layer": layer,
978
+ "profile": prof.get("name"), "components": comps}
979
+
980
+
981
+ def _qmul(a, b):
982
+ ax, ay, az, aw = a
983
+ bx, by, bz, bw = b
984
+ return (aw * bx + ax * bw + ay * bz - az * by, aw * by - ax * bz + ay * bw + az * bx,
985
+ aw * bz + ax * by - ay * bx + az * bw, aw * bw - ax * bx - ay * by - az * bz)
986
+
987
+
988
+ def _rotate(q, v):
989
+ x, y, z, _ = _qmul(_qmul(q, (v[0], v[1], v[2], 0.0)), (-q[0], -q[1], -q[2], q[3]))
990
+ return (x, y, z)
991
+
992
+
993
+ def world_trs(nodes: list[dict], path: str) -> dict:
994
+ """World position, rotation and (lossy) scale of the node at `path` from the local TRS of it and its
995
+ ancestors (position through the parents' rotation and scale, rotation the product of the local rotations,
996
+ scale the product of the local scales)."""
997
+ parts = path.split("/")
998
+ pos, rot, scl = (0.0, 0.0, 0.0), (0.0, 0.0, 0.0, 1.0), (1.0, 1.0, 1.0)
999
+ for i in range(1, len(parts) + 1):
1000
+ n = _first(nodes, "/".join(parts[:i]))
1001
+ p, r, s = n["localPosition"], n["localRotation"], n["localScale"]
1002
+ d = _rotate(rot, (scl[0] * p["x"], scl[1] * p["y"], scl[2] * p["z"]))
1003
+ pos = (pos[0] + d[0], pos[1] + d[1], pos[2] + d[2])
1004
+ rot = _qmul(rot, (r["x"], r["y"], r["z"], r["w"]))
1005
+ scl = (scl[0] * s["x"], scl[1] * s["y"], scl[2] * s["z"])
1006
+ return {"position": dict(zip("xyz", pos)), "rotation": dict(zip("xyzw", rot)), "scale": dict(zip("xyz", scl))}
1007
+
1008
+
1009
+ def blur_record(graphics: dict) -> dict:
1010
+ """blur: the UIRendererFeature of renderer 0 (UIBlurController.ExecBlur -> UIRenderPass CaptureAndBlur): its
1011
+ Dual Kawase iterations, offset, downsample, blend rate maximum and shader name, read from the player data
1012
+ (renderer 0 of every pipeline must be the same renderer)."""
1013
+ first = {p["m_RendererDataList"][0] for p in graphics["pipelines"].values()}
1014
+ if len(first) != 1:
1015
+ raise NotImplementedError(f"renderer 0 differs between the pipelines: {sorted(first)}")
1016
+ name = advscene.camera_renderer(graphics, graphics["defaultPipeline"], 0)
1017
+ feats = [f for f in graphics["renderers"][name]["m_RendererFeatures"] if f["class"] == BLUR_FEATURE]
1018
+ if len(feats) != 1:
1019
+ raise RuntimeError(f"renderer {name}: {len(feats)} {BLUR_FEATURE}")
1020
+ f = feats[0]
1021
+ return {"renderer": name, "active": bool(f["m_Active"]), "iterations": f["_blurIterations"],
1022
+ "offset": f["_blurOffset"], "downsample": f["_blurDownsample"], "blendRateMax": f["_blurBlendRateMax"],
1023
+ "shader": f["_dualKawaseBlurShader"]}
1024
+
1025
+
1026
+ def _rows(master_dir, name: str) -> dict:
1027
+ try:
1028
+ return {r["_id"]: r for r in master.table(Path(master_dir), name)}
1029
+ except FileNotFoundError:
1030
+ return {}
1031
+
1032
+
1033
+ def ambient_record(settings: dict, master_dir) -> dict | None:
1034
+ """ambient: the situation's ambient sound (SpotSituationSettings._soundId, ambientSoundVolume) with its cue
1035
+ sheet and cue (MasterSound, MasterSoundCueSheet; null when the master data lacks the row); null without one."""
1036
+ sid = settings.get("_soundId") or 0
1037
+ if not sid:
1038
+ return None
1039
+ snd = _rows(master_dir, "MasterSound").get(sid) or {}
1040
+ sheet = _rows(master_dir, "MasterSoundCueSheet").get(snd.get("_soundCueSheetID")) or {}
1041
+ return {"soundId": sid, "volume": settings.get("ambientSoundVolume"), "cueSheet": sheet.get("_cueSheetName"),
1042
+ "cue": snd.get("_cueName")}
1043
+
1044
+
1045
+ # ---------------------------------------------------------------- per language: ui/simple/
1046
+ def text_bindings(ui: UiDoc, lang: dict, mode: int) -> dict[str, dict]:
1047
+ """{node path: text binding} of the text nodes of `ui`, as storyfonts.text_bindings makes them for the story
1048
+ UI: the serialized TextMeshPro fields, storyfonts.text_extras and `localized` (LocalizeText in `mode`)."""
1049
+ swap = textstyle.font_swap(lang, mode)
1050
+ out = {}
1051
+ for rec, (_, n) in zip(ui.nodes, ui.sources):
1052
+ if "textStyle" not in rec:
1053
+ continue
1054
+ path = rec["path"]
1055
+ if path in out:
1056
+ raise RuntimeError(f"{path}: two text nodes")
1057
+ c = advui._component(n, advui.TMP_CLASSES)
1058
+ t = {k: c[k] for k in advui.TMP_FIELDS if k in c}
1059
+ t.update({"class": c["class"], "enabled": c["m_Enabled"], "fontAsset": c["m_fontAsset"]["name"],
1060
+ "material": c["m_sharedMaterial"]["material"]})
1061
+ t.update(storyfonts.text_extras(path, n["components"]))
1062
+ t["localized"] = textstyle.localize_text(path, t["fontAsset"], t["material"], swap, lang, mode)
1063
+ out[path] = t
1064
+ return out
1065
+
1066
+
1067
+ def story_text_materials(story_fonts: dict | None, lang: dict, mode: int) -> dict[str, set]:
1068
+ """{localized game font asset: its localized game text materials} of the story UI's texts (the `texts` of
1069
+ the story's ui/fonts.json, open fonts)."""
1070
+ if not story_fonts or story_fonts.get("source") != "open":
1071
+ return {}
1072
+ swap, out = textstyle.font_swap(lang, mode), {}
1073
+ for path, t in story_fonts.get("texts", {}).items():
1074
+ loc = textstyle.localize_text(path, t["fontAsset"], t["material"], swap, lang, mode)
1075
+ out.setdefault(loc["fontAsset"], set()).add(loc["material"])
1076
+ return out
1077
+
1078
+
1079
+ def open_fonts(ui: UiDoc, cat, player, episode: dict, story_ui: dict, language: str, font, master_dir,
1080
+ texts: list[str], story_fonts: dict | None = None, font_of=storyfonts.no_font_file) -> dict:
1081
+ """ui/simple/fonts.json and ui/simple/fonts/*.png from the font file `font` for the text nodes of `ui`, with the
1082
+ story UI's font logic (storyfonts.open_font_set: the fallback chains, whose other font files `font_of` gives,
1083
+ the missing glyph, the fallback materials): the texts are storyfonts.shown_texts over the story and its own
1084
+ ui/ui.json (`story_ui`), the MasterText rows of the simple UI's text keys and `texts`. The glyph margin also
1085
+ covers the story UI's text materials of the same font (`story_fonts`, the story's ui/fonts.json), so the glyph
1086
+ pages equal the story UI's when the characters do."""
1087
+ tmpfont.require_extra()
1088
+ mode, field = languages.mode(language), languages.column(language)[1:]
1089
+ lang = textstyle.language_fonts(player)
1090
+ bindings = text_bindings(ui, lang, mode)
1091
+ shown = (storyfonts.shown_texts(episode, story_ui, master_dir, field)
1092
+ + storyfonts.shown_texts(NO_EPISODE, ui.doc, master_dir, field) + [s for s in texts if isinstance(s, str)])
1093
+ fset = storyfonts.open_font_set(cat, player, ui.ex, language, font, list(bindings.values()), shown, ui.dir,
1094
+ font_of, margin_materials=story_text_materials(story_fonts, lang, mode))
1095
+ materials = fset["materials"]
1096
+ keywords = storyfonts.text_shaders(ui.ex, ui.dir, materials)
1097
+ chars = {ord(ch) for s in shown for ch in s}
1098
+ coverage = {"characters": len(chars), "missing": [chr(u) for u in sorted(fset["missing"])]}
1099
+ if fset["substituted"]:
1100
+ coverage["missingGlyph"] = [chr(u) for u in sorted(fset["substituted"])]
1101
+ doc = {"format": storyfonts.FONTS_FORMAT, "language": language, "source": "open", "fonts": fset["fonts"],
1102
+ "textures": fset["textures"], "materials": materials, "materialKeywords": keywords, "texts": bindings,
1103
+ "coverage": coverage, "lineBreaking": storyfonts.line_breaking(player)}
1104
+ (ui.dir / storyfonts.FONTS_DOC).write_bytes(
1105
+ dumps(doc, ensure_ascii=False, indent=1, sort_keys=True).encode("utf-8") + b"\n")
1106
+ return {"fonts": sorted(fset["fonts"]), "characters": len(chars), "missing": len(fset["missing"]),
1107
+ "pages": len(fset["textures"])}
1108
+
1109
+
1110
+ def simple_ui(cat, master_dir, player, episode: dict, ldir: Path, language: str, keys: dict, host: dict,
1111
+ title_id: str | None, font, font_of=storyfonts.no_font_file) -> dict:
1112
+ """<ldir>/ui/simple/: ui.json (UISimpleAdvTalkWindow, the window SimpleAdvPlayer.LoadInitialTalkWindow
1113
+ attaches; for an area talk UISystemMessageWidget, which SystemMessageManager.ShowMessageByTextIdAsync shows the
1114
+ spot's title with, and `texts.areaTitle`) and its open fonts."""
1115
+ sdir = Path(ldir) / "ui" / SIMPLE_DIR
1116
+ story_ui = json.loads((Path(ldir) / "ui" / "ui.json").read_text(encoding="utf-8"))
1117
+ fonts_doc = Path(ldir) / "ui" / storyfonts.FONTS_DOC
1118
+ story_fonts = json.loads(fonts_doc.read_text(encoding="utf-8")) if fonts_doc.is_file() else None
1119
+ ui = UiDoc(cat, player, sdir, languages.mode(language))
1120
+ ui.add(keys["talkWindow"], ui.prefab(keys["talkWindow"]))
1121
+ texts = {}
1122
+ if host.get("talk") == "area":
1123
+ ui.add(keys["systemMessage"], ui.prefab(keys["systemMessage"]))
1124
+ field = languages.column(language)
1125
+ row = _rows(master_dir, "MasterText").get(title_id)
1126
+ if row is None:
1127
+ raise KeyError(f"area talk title {title_id} not in MasterText")
1128
+ texts["areaTitle"] = row.get(field) or ""
1129
+ ui.write(f"SimpleAdvPlayer talk window UI data, {language}", head={"language": advui.language_doc(language)},
1130
+ tail={"texts": texts} if texts else None)
1131
+ return open_fonts(ui, cat, player, episode, story_ui, language, font, master_dir, list(texts.values()),
1132
+ story_fonts, font_of)
1133
+
1134
+
1135
+ # ---------------------------------------------------------------- entry
1136
+ def build(cat, master_dir, player, episode: dict, story_dir, language_dirs: dict, groups: list, *,
1137
+ fonts: str = "open", font_files: dict | None = None, font_of=None) -> dict | None:
1138
+ """The host data of `episode` (episode.json of story.build) when it is an Overlay episode (master
1139
+ `_playbackMode` 1; else None and nothing written): <story_dir>/host/ (host.json, ui/, spot/, shaders/) and in
1140
+ each language directory of `language_dirs` {language: dir with the story's ui/ui.json} its ui/simple/.
1141
+ `groups`: the story groups of the episode (storysite.story_groups), which name the host (host_of); `fonts`:
1142
+ "open" with `font_files` {language: storyfonts.FontFile} and `font_of` (language -> FontFile, the fallback
1143
+ chains' other font files; default: those of `font_files`). -> {kind, doc, ui} (the story manifest's `host`)."""
1144
+ if fonts not in FONT_MODES:
1145
+ raise ValueError(f"fonts must be one of {FONT_MODES}, not {fonts!r}")
1146
+ if (episode.get("master") or {}).get("_playbackMode") != OVERLAY:
1147
+ return None
1148
+ host = host_of(groups)
1149
+ if fonts == "game":
1150
+ raise NotImplementedError("the talk window UI of an Overlay story has open fonts only (fonts='open')")
1151
+ font_files = dict(font_files or {})
1152
+ lost = [lang for lang in language_dirs if lang not in font_files]
1153
+ if lost:
1154
+ raise ValueError(f"no font file for {lost}")
1155
+ if font_of is None:
1156
+ def font_of(code):
1157
+ return font_files[code] if code in font_files else storyfonts.no_font_file(code)
1158
+ keys = {k: emb_key(cat, ADDRESSES[k]) for k in ("cameraTarget", "talkWindow")}
1159
+ if host.get("talk") == "area":
1160
+ keys["systemMessage"] = emb_key(cat, ADDRESSES["systemMessage"])
1161
+ for k in KIND_KEYS[host["kind"]]:
1162
+ try:
1163
+ keys[k] = emb_key(cat, ADDRESSES[k])
1164
+ except KeyError:
1165
+ if k != "spotScene":
1166
+ raise
1167
+ keys[k] = None # scene root: null / [] (scene_root)
1168
+ hdir = Path(story_dir) / HOST_DIR
1169
+ shutil.rmtree(hdir, ignore_errors=True)
1170
+ ui = UiDoc(cat, player, hdir / "ui")
1171
+ cam = camera_target(ui.prefab(keys["cameraTarget"]))
1172
+ home = afterlive = None
1173
+ if host["kind"] == "home":
1174
+ overlay, layout, opened = home_ui(ui, keys)
1175
+ home = home_host(cat, master_dir, player, host, hdir, keys)
1176
+ else:
1177
+ overlay, layout, opened, afterlive = afterlive_ui(ui, keys)
1178
+ ui.write(f"host screen UI data of an Overlay story ({host['kind']}), no texts")
1179
+ doc = {"format": FORMAT,
1180
+ "about": "Host screen of an Overlay story (SimpleAdvPlayer): the slot capture camera, the SimpleAdv "
1181
+ "root and its layout, the host UI and the home spot or live result screen it is shown over",
1182
+ "advId": episode.get("advId"), "kind": host["kind"], "group": host["group"], "keys": keys,
1183
+ "cameraTarget": cam, "overlayRoot": overlay, "layoutRoot": layout, "openedSequences": opened,
1184
+ "ui": HOST_UI_DOC, "home": home, "afterlive": afterlive}
1185
+ write_json(hdir / "host.json", doc)
1186
+ for language, ldir in language_dirs.items():
1187
+ shutil.rmtree(Path(ldir) / "ui" / SIMPLE_DIR, ignore_errors=True)
1188
+ simple_ui(cat, master_dir, player, episode, Path(ldir), language, keys, host,
1189
+ (home or {}).get("titleTextId"), font_files[language], font_of)
1190
+ return {"kind": host["kind"], "doc": HOST_DOC, "ui": SIMPLE_DOC}