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