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/advui.py ADDED
@@ -0,0 +1,1154 @@
1
+ """ADV story UI -> <story>/ui/ (ui.json, textures/, shaders/).
2
+
3
+ Everything a player needs to draw the ADV front canvas (talk window, speaker
4
+ plate, location caption, episode title, rule transition cover, menu entry
5
+ button, menu panel and video buttons, backlog, choices, curtains, flash,
6
+ subtitles, next indicator, the tap areas), the screen canvases of stills,
7
+ frames and videos, the letterbox bands and the dialogs of the ADV screen,
8
+ taken from the game data:
9
+
10
+ nodes RectTransform hierarchy of the drawn parts of UIAdvWidget
11
+ (DRAWN) with the talk windows under UIContainer/TalkView
12
+ (AdvTalkView.SetWindow): the default UIDefaultTalkWindow, then
13
+ the other windows the episode's TalkWindow rows attach, and the
14
+ front canvas' CenterTalkBackdrop when one of them uses the
15
+ backdrop filter (UIAdvTalkWindow._useBackdropFilter); per
16
+ node the uGUI components the runtime needs (Canvas and scaler,
17
+ Image, RawImage, CanvasGroup, UIGradientImage, layout groups,
18
+ DOTweenSequence durations, the views' serialized references as
19
+ node paths, `behaviours`: every serialized field of the other
20
+ game components of BEHAVIOURS, references as node paths and
21
+ sprites as names) and, for a TMP text, its text record `textStyle`
22
+ (textstyle.py: layout, colours, style flags, face / outline /
23
+ underlay values in em, font role)
24
+ videoAndStillCamera the camera the still and video canvases render with
25
+ (UIAdvWidget._videoAndStillCamera) and the screen image showing
26
+ its output (_videoAndStillScreenImage)
27
+ uiCamera the UI camera of the UIManager prefab, which renders the frame
28
+ canvas (its canvas camera is assigned at run time), in the form
29
+ of videoAndStillCamera
30
+ masterIdTexts the AdvMasterIdSettings texts of the skip and video skip
31
+ dialogs and the interruption dialog in the language (with the
32
+ master data)
33
+ dialogs the skip confirm dialog (UIAdvSkipConfirmDialogWidget) and
34
+ CommonDialogManager's dialog (UICommonDialogWidget) as node
35
+ records with text records
36
+ chatWidget the phone of the chat rows (UIAdvChatWidget): its canvas and
37
+ scaler, AdvChatView with its durations and eases, the window
38
+ parent rect
39
+ chatTexts per chat window of the episode (AdvChatWindow prefab), the text
40
+ record of each TMP text in the language, and chatStatusTexts:
41
+ the incoming call / lock screen status texts the windows set
42
+ blur with a backdrop-filter talk window: the UI blur pass settings
43
+ (the UIRendererFeature of renderer 0: Dual Kawase iterations,
44
+ offset, downsample, blend rate maximum, shader)
45
+ sprites sprite geometry (rect, border, pivot, pixels per unit, texture
46
+ rect/offset) remapped into packed textures
47
+ materials UI-Transition, the built-in Default UI Material, the materials
48
+ of the drawn images
49
+ clips Animator clips (NextIndicator, AutoNext, AdvLocation/AdvTitle Play)
50
+ transitions RuleTransitionSettings of every transition the episode or the
51
+ player settings reference
52
+ shaders UI/Default, UI/Transition, the blur shader with `blur`
53
+
54
+ Language (`language`, a catalog language code; default `[catalog] language`):
55
+ the LanguageMode of the localized fonts, materials and line spacing
56
+ (LocalizeText) and the text field of the lines the episode shows.
57
+
58
+ Fonts (`fonts`): "open" (default) writes no font data; a renderer draws the
59
+ text records with fonts of its own. "game" also writes the game's TMP font
60
+ data (tmpfont.py, optional `fonts` extra): per node the serialized TMP text
61
+ settings with the localized font / material (`text`), the TMP font assets
62
+ reached from the localized fonts (face info, style values, fallback chain)
63
+ with the character and glyph tables reduced to the characters the episode
64
+ shows (characters a dynamic font asset would add at runtime are generated the
65
+ way FontEngine renders them, see tmpfont.RuntimeGlyphs), their materials, the
66
+ glyph coverage, the TMP settings and the TextMeshPro/Mobile/Distance Field
67
+ shader.
68
+
69
+ Textures: sprite atlases and SDF font atlases are large; only the texel blocks
70
+ a draw can sample are kept (export.Exporter deferred-texture mode with
71
+ export.TexelPacker). Each block is the sprite's texture rect or a glyph rect
72
+ grown by the widest footprint the shader samples, plus one texel for bilinear
73
+ filtering, read with the source wrap mode, and copied into a packed texture.
74
+ UVs are remapped by an integer translation, so sampling returns the same texel
75
+ values as the full atlas.
76
+ """
77
+ from __future__ import annotations
78
+
79
+ import re
80
+ from pathlib import Path
81
+
82
+ from .catalog import Catalog
83
+ from .export import Exporter, TexelPacker, _safe
84
+ from .jsonio import write_json
85
+ from .player import PlayerData
86
+ from . import adv, languages, textstyle
87
+ from .textstyle import LANGUAGE_LINE_SPACING
88
+
89
+ WIDGET_KEY = "EmbUI/Prefab/UIAdvWidget"
90
+ # UIManager: UIManager._cameraController -> UICameraController._uiCamera / _uiCameraData
91
+ UI_MANAGER_KEY = "EmbCommon/Prefab/UIManager"
92
+ WINDOW_KEY = "EmbUI/Prefab/Parts/Adv/Talk/UIDefaultTalkWindow"
93
+ WINDOW_PARENT = "UIAdvWidget/FrontCanvas/UISafeArea/UIContainer/TalkView"
94
+ # UIAdvWidget._centerTalkBackdropCanvasGroup / _centerTalkBackdropImage: the backdrop of a backdrop-filter talk window
95
+ BACKDROP_REFS = ("_centerTalkBackdropCanvasGroup", "_centerTalkBackdropImage")
96
+ LETTERBOX_KEY = "Textures/letterbox-image"
97
+ TRANSITION_PREFIX = "Adv/Transition/"
98
+ PLAYER_SETTINGS_KEY = "EmbCommon/Adv/Settings/AdvPlayerSettings"
99
+ MASTER_ID_SETTINGS_KEY = "EmbCommon/Adv/Settings/AdvMasterIdSettings"
100
+ CLIP_KEYS = {
101
+ "NextIndicator/Loop": "EmbUI/Animations/NextIndicator/Loop",
102
+ "AutoNext/Loop": "EmbUI/Animations/Auto/Loop",
103
+ "AdvLocation/Play": "EmbUI/Animations/AdvLocation/Play",
104
+ "AdvTitle/Play": "EmbUI/Animations/AdvTitle/Play",
105
+ }
106
+ CONTROLLER_KEYS = {
107
+ "NextIndicator": "EmbUI/Animations/NextIndicator/NextIndicator",
108
+ "AutoNext": "EmbUI/Animations/Auto/AutoNext",
109
+ "AdvLocation": "EmbUI/Animations/AdvLocation/AdvLocation",
110
+ "AdvTitle": "EmbUI/Animations/AdvTitle/AdvTitle",
111
+ }
112
+ # Parts of the widget the runtime draws (and their ancestors). The block canvas
113
+ # is not exported.
114
+ DRAWN = (
115
+ "UIAdvWidget/VideoCanvas",
116
+ "UIAdvWidget/StillCanvas",
117
+ "UIAdvWidget/VideoAndStillRenderScreenCanvas",
118
+ "UIAdvWidget/FrameCanvas",
119
+ "UIAdvWidget/FrontCanvas/FlashView",
120
+ "UIAdvWidget/FrontCanvas/RuleTransition",
121
+ "UIAdvWidget/FrontCanvas/LocationVIew",
122
+ "UIAdvWidget/FrontCanvas/RightCurtain",
123
+ "UIAdvWidget/FrontCanvas/LeftCurtain",
124
+ "UIAdvWidget/FrontCanvas/CancelFullScreenButton",
125
+ "UIAdvWidget/FrontCanvas/TalkLogView",
126
+ "UIAdvWidget/FrontCanvas/UISafeArea/UIContainer/NextButton",
127
+ "UIAdvWidget/FrontCanvas/UISafeArea/UIContainer/ChoiceView",
128
+ "UIAdvWidget/FrontCanvas/UISafeArea/UIContainer/SubtitlesView",
129
+ "UIAdvWidget/FrontCanvas/UISafeArea/UIContainer/TalkView",
130
+ "UIAdvWidget/FrontCanvas/UISafeArea/UIContainer/NextIndicator",
131
+ "UIAdvWidget/FrontCanvas/UISafeArea/UIContainer/MenuView/MenuButtonsParent",
132
+ "UIAdvWidget/FrontCanvas/UISafeArea/UIContainer/MenuView/MenuEntryButton",
133
+ "UIAdvWidget/FrontCanvas/UISafeArea/UIContainer/MenuView/VideoButtons",
134
+ "UIAdvWidget/FrontCanvas/UISafeArea/UIContainer/TitleView",
135
+ "UIAdvWidget/AdvLetterBoxCanvas",
136
+ )
137
+ # the view components of the drawn parts: record key and the serialized references kept (as node paths)
138
+ VIEWS = {
139
+ "AdvVideoView": ("videoView", ("_video", "_maskArea", "_curtainCanvasGroup", "_videoCanvasGroup")),
140
+ "AdvStillView": ("stillView", ("_overlay", "_background", "_target")),
141
+ "AdvFrameView": ("frameView", ()),
142
+ "AdvFlashView": ("flashView", ("_flash",)),
143
+ "AdvSubtitlesView": ("subtitlesView", ("_subtitles", "_subtitlesText")),
144
+ "AdvFrontScreenView": ("frontScreenView", ("_nextButton", "_nextIndicator", "_cancelFullScreenButton",
145
+ "_uiContainer")),
146
+ }
147
+ # views recorded with every serialized field (references as node paths)
148
+ FULL_VIEWS = {"AdvTalkLogView": "talkLogView", "AdvChoiceView": "choiceView"}
149
+ # the other game and uGUI components recorded in `behaviours` (per class, every component of the node in order):
150
+ # buttons and their state and animation parts, raycast targets, colour synchronizers, texts' UIText, safe areas,
151
+ # scrolling, DOTween animations, localized sprites, the backlog entry and choice item views, the dialog parts
152
+ BEHAVIOURS = ("UIButton", "ButtonActiveState", "ButtonAnimationScale", "ButtonSound", "SimpleRaycastTarget",
153
+ "GraphicColorSynchronizer", "UIText", "AspectRatioFitter", "UIToggle", "UIToggleGroup",
154
+ "UIToggleButtonFrame", "UIDecoratedNormalButton", "UIDecoratedNormalButtonFrame", "UIAddressableImage",
155
+ "UISafeArea", "AdvViewportSafeArea", "EnhancedScrollRect", "EnhancedScroller", "ScrollRect", "RectMask2D",
156
+ "Scrollbar", "DOTweenAnimation", "LocalizeSpriteEvent", "AdvTalkLogEntryView", "AdvChoiceItem",
157
+ "DialogButtons", "DialogAnimation", "DialogSizeFitter", "UIAdvSkipConfirmDialogWidget",
158
+ "UICommonDialogWidget")
159
+ SERIALIZED_SKIP = ("type", "class", "m_GameObject", "m_Script", "m_Name", "m_EditorHideFlags",
160
+ "m_EditorClassIdentifier")
161
+ # the dialogs of the ADV screen: the skip confirm dialog and CommonDialogManager's dialog widget
162
+ DIALOG_KEYS = {"UIAdvSkipConfirmDialogWidget": "EmbUI/Prefab/UIAdvSkipConfirmDialogWidget",
163
+ "UICommonDialogWidget": "EmbUI/Prefab/UICommonDialogWidget"}
164
+ CAMERA_FIELDS = ("m_Enabled", "m_ClearFlags", "m_BackGroundColor", "m_NormalizedViewPortRect", "near clip plane",
165
+ "far clip plane", "field of view", "orthographic", "orthographic size", "m_Depth", "m_CullingMask",
166
+ "m_HDR", "m_AllowMSAA")
167
+ CAMERA_DATA_FIELDS = ("m_RenderPostProcessing", "m_VolumeLayerMask", "m_RendererIndex", "m_Antialiasing",
168
+ "m_AntialiasingQuality", "m_RenderShadows", "m_RequiresDepthTextureOption",
169
+ "m_RequiresOpaqueTextureOption", "m_CameraType", "m_VolumeFrameworkUpdateModeOption",
170
+ "m_Dithering", "m_StopNaN")
171
+ # AdvMasterIdSettings text ids the drawn parts show: the video skip dialog (AdvMenuView's skip video button), the
172
+ # skip confirm dialog and the interruption dialog
173
+ MASTER_ID_TEXTS = ("_skipVideoMessageTextId", "_skipButtonTextId", "_cancelButtonTextId", "_skipMessageTextId",
174
+ "_continuousSkipMessageTextId", "_backEpisodeListButtonTextId", "_continueEpisodeButtonTextId",
175
+ "_interruptionTitleTextId", "_interruptionMessageTextId", "_interruptionButtonTextId")
176
+ RUBY = re.compile(r"<r=([^<>]*)>") # ruby reading markup of the text tables: <r=reading>base</r>
177
+ # the phone of the chat rows: UIAdvChatWidget's canvas, AdvChatView and the parent of the attached window
178
+ CHAT_WIDGET_KEY = "EmbUI/Prefab/UIAdvChatWidget"
179
+ CHAT_WIDGET_NODES = ("UIAdvChatWidget/ChatCanvas", "UIAdvChatWidget/ChatCanvas/AdvChatView",
180
+ "UIAdvChatWidget/ChatCanvas/AdvChatView/Target")
181
+ CHAT_VIEW_FIELDS = ("_showEaseDuration", "_hideEaseDuration", "_showEase", "_hideEase", "_scrollDuration",
182
+ "_typingDelay", "_typingTextBoxMinHeight", "_screenModeTransitionDuration",
183
+ "_screenModeTransitionEase", "_incomingCallPositionOffset")
184
+ CHAT_STATUS_KEYS = ("_incomingCallStatusTextKey", "_lockScreenStatusTextKey") # AdvChatWindow.RefreshStatusTexts
185
+ CHAT_COMMANDS = ("ChatWindow", "ChatTalk", "ChatStamp", "ChatRead", "ChatTyping")
186
+ # AdvMenuView: its serialized references (node paths), the fast-forward button sprites (AdvMenuView.SetFastForwardSpeed)
187
+ MENU_VIEW_REFS = ("_menuButtonsParent", "_menuEntryButton", "_skipButton", "_autoButton", "_fastForwardButton",
188
+ "_logButton", "_continuousButton", "_fullScreenButton", "_interruptionButton",
189
+ "_menuButtonsBackground", "_canvasGroup", "_skipVideoButton", "_pauseVideoButton", "_subtitlesButton",
190
+ "_videoButtonParent", "_videoFilterButton", "_fastForwardButtonImage")
191
+ MENU_VIEW_SPRITES = ("_fastForwardNormalSprite", "_fastForwardOnePointFiveSprite", "_fastForwardOnePointSevenSprite",
192
+ "_fastForwardDoubleSprite")
193
+ EMOJI_SPRITE_ASSET_KEY = "Font/SiriusEmoji/SiriusEmojiData" # LocalizeManager.EmojiSpriteAssetAddress
194
+ EMOJI_CLASSES = ("RubyEmojiTextMeshProUGUI",)
195
+ FONT_ADDRESS = "Font/" # LocalizeManager.FontAddressablePath; the embedded fonts are at "Emb" + address
196
+ # TMP_Text.GetUnderlineSpecialCharacter: the underline and the text highlight (DrawTextHighlight) draw with the '_'
197
+ # of the text's font asset (no fallback), sampled on the asset's first atlas page
198
+ UNDERLINE_CHARACTER = 0x5F
199
+
200
+ TMP_CLASSES = ("TextMeshProUGUI", "RubyTextMeshProUGUI", "RubyEmojiTextMeshProUGUI")
201
+ TMP_FIELDS = (
202
+ "m_fontSize", "m_fontSizeBase", "m_fontStyle", "m_fontWeight", "m_HorizontalAlignment", "m_VerticalAlignment",
203
+ "m_characterSpacing", "m_wordSpacing", "m_lineSpacing", "m_paragraphSpacing", "m_characterHorizontalScale",
204
+ "m_TextWrappingMode", "m_overflowMode", "m_enableAutoSizing", "m_fontSizeMin", "m_fontSizeMax",
205
+ "m_enableKerning", "m_ActiveFontFeatures", "m_enableExtraPadding", "m_isRichText", "m_parseCtrlCharacters",
206
+ "m_isOrthographic", "m_overrideHtmlColors", "m_margin", "m_fontColor", "m_Color", "m_colorMode",
207
+ "m_enableVertexGradient", "m_TextStyleHashCode", "m_useMaxVisibleDescender", "m_horizontalMapping",
208
+ "m_verticalMapping", "m_charWidthMaxAdj", "m_lineSpacingMax", "m_isRightToLeft", "m_text",
209
+ "m_monospaceDistEm", # TMP_EmojiTextUGUI (the emoji text classes)
210
+ )
211
+ FONT_MODES = ("open", "game")
212
+ IMAGE_FIELDS = ("m_Enabled", "m_Color", "m_Type", "m_PreserveAspect", "m_FillCenter", "m_FillMethod",
213
+ "m_FillAmount", "m_UseSpriteMesh", "m_PixelsPerUnitMultiplier", "m_Maskable")
214
+
215
+
216
+ def _strip_tags(s: str) -> str:
217
+ return re.sub(r"<[^<>]*>", "", s)
218
+
219
+
220
+ def _ref_path(v) -> str | None:
221
+ """The node path of a serialized reference as the exporter writes it ({component | transform | gameObject})."""
222
+ if not v:
223
+ return None
224
+ return v.get("gameObject") or v.get("transform")
225
+
226
+
227
+ def _plain(v, sprites: list):
228
+ """A serialized value as plain data: a reference as its node path, a sprite as its name (its reference added to
229
+ `sprites`), a ScriptableObject or material as its name, a texture as its name; containers element-wise."""
230
+ if isinstance(v, list):
231
+ return [_plain(x, sprites) for x in v]
232
+ if not isinstance(v, dict):
233
+ return v
234
+ if "spriteRef" in v:
235
+ sprites.append(v["spriteRef"])
236
+ return v["name"]
237
+ if "gameObject" in v or "transform" in v:
238
+ return _ref_path(v)
239
+ if "asset" in v or ("material" in v and "shader" in v):
240
+ return v.get("name") or v.get("material")
241
+ if "textureRef" in v:
242
+ return v.get("name")
243
+ return {k: _plain(x, sprites) for k, x in v.items()}
244
+
245
+
246
+ def _serialized(c: dict, sprites: list) -> dict:
247
+ """Every serialized field of a component (SERIALIZED_SKIP left out), as _plain values."""
248
+ return {k: _plain(v, sprites) for k, v in c.items() if k not in SERIALIZED_SKIP}
249
+
250
+
251
+ def _pictogram(c: dict) -> dict:
252
+ """App.UI.UIPictogram: its key, image and native-size flag and the catalog it draws from (the sprite is
253
+ resolved against the catalog in extract)."""
254
+ return {"m_Enabled": c["m_Enabled"], "_key": c["_key"], "_image": _ref_path(c["_image"]),
255
+ "_setNativeSize": c["_setNativeSize"], "catalog": (c["_spriteCatalog"] or {}).get("name")}
256
+
257
+
258
+ def _layout_components(node: dict) -> dict:
259
+ out: dict = {}
260
+ sprites: list = []
261
+ behaviours: dict = {}
262
+ image_states = [c for c in node["components"] if c.get("class") == "ButtonImageState"]
263
+ for c in node["components"]:
264
+ cls = c.get("class")
265
+ t = c["type"]
266
+ if t == "CanvasGroup":
267
+ out["canvasGroup"] = {k: c[k] for k in ("m_Enabled", "m_Alpha", "m_IgnoreParentGroups")}
268
+ elif t == "Canvas":
269
+ out["canvas"] = {k: c[k] for k in ("m_Enabled", "m_RenderMode", "m_SortingOrder",
270
+ "m_VertexColorAlwaysGammaSpace", "m_AdditionalShaderChannelsFlag",
271
+ "m_OverrideSorting", "m_PixelPerfect")}
272
+ if c["m_RenderMode"] == 1: # screen space - camera: the plane it is drawn at, its camera if set
273
+ out["canvas"]["m_PlaneDistance"] = c["m_PlaneDistance"]
274
+ if c.get("m_Camera"):
275
+ out["canvas"]["camera"] = _ref_path(c["m_Camera"])
276
+ elif t == "Animator":
277
+ out["animator"] = {"controller": (c["m_Controller"] or {}).get("name"),
278
+ "enabled": c["m_Enabled"], "updateMode": c["m_UpdateMode"],
279
+ "keepStateOnDisable": c["m_KeepAnimatorStateOnDisable"]}
280
+ elif cls in ("CanvasScaler", "ClampedCanvasScaler"):
281
+ out["canvasScaler"] = {k: c[k] for k in ("m_Enabled", "m_UiScaleMode", "m_ReferencePixelsPerUnit",
282
+ "m_ScaleFactor", "m_ReferenceResolution", "m_ScreenMatchMode",
283
+ "m_MatchWidthOrHeight")}
284
+ if cls == "ClampedCanvasScaler": # Fwk.UI.ClampedCanvasScaler: the scale clamped past an aspect ratio
285
+ out["canvasScaler"].update({"class": cls, "_maxAspectThreshold": c["_maxAspectThreshold"]})
286
+ elif cls == "Image":
287
+ img = {k: c[k] for k in IMAGE_FIELDS}
288
+ img["sprite"] = c["m_Sprite"]["name"] if c["m_Sprite"] else None
289
+ img["spriteRef"] = c["m_Sprite"]["spriteRef"] if c["m_Sprite"] else None
290
+ img["material"] = c["m_Material"]["material"] if c["m_Material"] else None
291
+ out["image"] = img
292
+ elif cls == "RawImage":
293
+ out["rawImage"] = {k: c[k] for k in ("m_Enabled", "m_Color", "m_UVRect", "m_RaycastTarget", "m_Maskable")}
294
+ out["rawImage"]["texture"] = (c["m_Texture"] or {}).get("name")
295
+ out["rawImage"]["material"] = c["m_Material"]["material"] if c["m_Material"] else None
296
+ elif cls == "UIGradientImage":
297
+ out["gradient"] = {"enabled": c["m_Enabled"], "gradient": c["_gradient"], "angleDeg": c["_angleDeg"],
298
+ "splitAtKeysWhenAxisAligned": c["_splitAtKeysWhenAxisAligned"],
299
+ "blendMode": c["_blendMode"]}
300
+ elif cls in TMP_CLASSES:
301
+ txt = {k: c[k] for k in TMP_FIELDS if k in c}
302
+ txt["class"] = cls
303
+ txt["enabled"] = c["m_Enabled"]
304
+ txt["fontAsset"] = c["m_fontAsset"]["name"]
305
+ txt["material"] = c["m_sharedMaterial"]["material"]
306
+ out["text"] = txt
307
+ elif cls == "LocalizeText":
308
+ out["localizeText"] = {"enabled": c["m_Enabled"], "localizeEnabled": c["_localizeEnabled"]}
309
+ elif cls in ("HorizontalLayoutGroup", "VerticalLayoutGroup"):
310
+ out["layoutGroup"] = {"class": cls, **{k: c[k] for k in c if k.startswith("m_")}}
311
+ elif cls == "ContentSizeFitter":
312
+ out["contentSizeFitter"] = {k: c[k] for k in ("m_Enabled", "m_HorizontalFit", "m_VerticalFit")}
313
+ elif cls == "LayoutElement":
314
+ out["layoutElement"] = {k: c[k] for k in c if k.startswith("m_")}
315
+ elif cls == "DOTweenSequence":
316
+ out["tweenSequence"] = {"list": [{k: e[k] for k in ("_commandType", "_duration") if k in e}
317
+ for e in c["_list"]],
318
+ "raw": {k: c[k] for k in c if k in ("updateType", "isSpeedBased")}}
319
+ elif cls == "SimpleAnimationTrigger":
320
+ out["animationTrigger"] = {"blendTime": c["_blendTime"]}
321
+ elif cls == "UIAdvTalkWindow":
322
+ out["talkWindow"] = {k: c[k] for k in ("_typingDelay", "_safeAreaTalkBackgroundExpansionFactor",
323
+ "_useBackdropFilter", "_backdropFilterColor", "_talkTextColor",
324
+ "_talkTextOutlineColor")}
325
+ elif cls == "ButtonImageState" and len(image_states) > 1:
326
+ behaviours.setdefault(cls, []).append(_serialized(c, sprites))
327
+ elif cls == "ButtonImageState":
328
+ out["buttonImageState"] = {"normal": c["_normaledSprite"]["name"] if c["_normaledSprite"] else None}
329
+ if c["_selectedSprite"]: # ButtonImageState.SetSelected: the sprite of the selected state
330
+ out["buttonImageState"].update({"selected": c["_selectedSprite"]["name"],
331
+ "selectedRef": c["_selectedSprite"]["spriteRef"],
332
+ "target": _ref_path(c["_target"])})
333
+ elif cls == "UISafeAreaEdgeAnchor":
334
+ out["safeAreaEdgeAnchor"] = {"enabled": c["m_Enabled"],
335
+ **{k: c[k] for k in ("_left", "_right", "_top", "_bottom")}}
336
+ elif cls in VIEWS:
337
+ key, refs = VIEWS[cls]
338
+ out[key] = {"enabled": c["m_Enabled"], **{k: _ref_path(c[k]) for k in refs}}
339
+ elif cls == "AdvMenuView":
340
+ out["menuView"] = {"enabled": c["m_Enabled"], **{k: _ref_path(c[k]) for k in MENU_VIEW_REFS},
341
+ **{k: c[k]["name"] if c[k] else None for k in MENU_VIEW_SPRITES},
342
+ "_fadeDuration": c["_fadeDuration"]}
343
+ sprites += [c[k]["spriteRef"] for k in MENU_VIEW_SPRITES if c[k]]
344
+ elif cls in FULL_VIEWS:
345
+ out[FULL_VIEWS[cls]] = {"enabled": c["m_Enabled"], **_serialized(c, sprites)}
346
+ elif cls == "UIPictogram":
347
+ behaviours.setdefault(cls, []).append(_pictogram(c))
348
+ elif cls in BEHAVIOURS:
349
+ behaviours.setdefault(cls, []).append(_serialized(c, sprites))
350
+ elif cls == "AdvChatView":
351
+ out["chatView"] = {"enabled": c["m_Enabled"], **{k: c[k] for k in CHAT_VIEW_FIELDS},
352
+ "_windowParentRect": _ref_path(c["_windowParentRect"])}
353
+ elif cls == "Outline":
354
+ out["outline"] = {"enabled": c["m_Enabled"]}
355
+ if behaviours:
356
+ out["behaviours"] = behaviours
357
+ if sprites:
358
+ out["_spriteRefs"] = sprites # packed into the UI textures, removed in extract
359
+ return out
360
+
361
+
362
+ # --------------------------------------------------------------------------
363
+ # main entry
364
+ # --------------------------------------------------------------------------
365
+ def _node_record(n: dict) -> dict:
366
+ """A node of ui.json: transform, rect and the uGUI component records (_layout_components)."""
367
+ rec = {"path": n["path"], "name": n["name"], "active": n["active"], "localPosition": n["localPosition"],
368
+ "localRotation": n["localRotation"], "localScale": n["localScale"], "rect": n.get("rect")}
369
+ rec.update(_layout_components(n))
370
+ return rec
371
+
372
+
373
+ def _component(node: dict, classes: tuple) -> dict | None:
374
+ hit = [c for c in node["components"] if c.get("class") in classes]
375
+ if len(hit) > 1:
376
+ raise RuntimeError(f"{node['path']}: {len(hit)} components of {classes}")
377
+ return hit[0] if hit else None
378
+
379
+
380
+ def extract(cat: Catalog, player: PlayerData, episode: dict, out_dir: Path, fonts: str = "open", *,
381
+ language: str | None = None, master: Path | None = None) -> dict:
382
+ """Write <out_dir>/ui/. `fonts`: "open" (text records only) or "game" (also the game's TMP font data);
383
+ `language`: the client language (a languages.LANGUAGES code; None: `[catalog] language` of the settings);
384
+ `master`: the decoded master data (MasterText of `masterIdTexts`; None: no `masterIdTexts`)."""
385
+ if fonts not in FONT_MODES:
386
+ raise ValueError(f"fonts must be one of {FONT_MODES}, not {fonts!r}")
387
+ language = languages.check(language) if language is not None else languages.configured()
388
+ mode, field = languages.mode(language), languages.column(language)[1:]
389
+ game = fonts == "game"
390
+ if game:
391
+ from . import tmpfont
392
+ tmpfont.require_extra()
393
+ ui_dir = Path(out_dir) / "ui"
394
+ ui_dir.mkdir(parents=True, exist_ok=True)
395
+ ex = Exporter(cat, ui_dir, player=player, textures="deferred",
396
+ stub_assets=("TMP_FontAsset", "TMP_SpriteAsset", "TMP_StyleSheet"))
397
+ packer = TexelPacker(ui_dir)
398
+
399
+ # -- prefabs -------------------------------------------------------------
400
+ widget = ex.prefab(WIDGET_KEY)["nodes"]
401
+ windows = talk_windows(episode)
402
+ window = window_nodes(ex, windows)
403
+ paths = {n["path"] for n in widget}
404
+ if WINDOW_PARENT not in paths:
405
+ raise RuntimeError(f"{WINDOW_PARENT} not in {WIDGET_KEY}")
406
+ widget_comp = next(c for c in widget[0]["components"] if c.get("class") == "UIAdvWidget")
407
+ drawn = DRAWN + ((backdrop_path(widget, widget_comp),) if uses_backdrop_filter(window) else ())
408
+
409
+ def kept(p: str) -> bool:
410
+ return any(p == d or p.startswith(d + "/") or d.startswith(p + "/") for d in drawn)
411
+
412
+ nodes = []
413
+ for n in widget:
414
+ if n["path"] == "UIAdvWidget" or not kept(n["path"]):
415
+ continue
416
+ nodes.append(n)
417
+ if n["path"] == WINDOW_PARENT:
418
+ nodes += window
419
+ for d in drawn:
420
+ if d not in {n["path"] for n in nodes}:
421
+ raise RuntimeError(f"{d} missing from the prefabs")
422
+ styles = textstyle.TextStyles(ex, player, mode)
423
+ dialog_nodes = {name: ex.prefab(key)["nodes"] for name, key in DIALOG_KEYS.items()}
424
+ catalogs = pictogram_catalogs(widget + window + [n for dn in dialog_nodes.values() for n in dn])
425
+
426
+ def records(prefab_nodes: list, keep_text: bool) -> list:
427
+ out = []
428
+ for n in prefab_nodes:
429
+ rec = _node_record(n)
430
+ if "text" in rec:
431
+ if not (rec.get("localizeText") or {}).get("localizeEnabled"):
432
+ raise NotImplementedError(f"{n['path']}: text without LocalizeText")
433
+ rec["textStyle"] = styles.record(n["path"], _component(n, TMP_CLASSES),
434
+ _component(n, ("LocalizeText",)))
435
+ if not keep_text:
436
+ del rec["text"]
437
+ resolve_pictograms(rec, catalogs)
438
+ out.append(rec)
439
+ return out
440
+ out_nodes = records(nodes, game)
441
+ dialogs = {name: {"key": DIALOG_KEYS[name], "nodes": records(dn, False)} for name, dn in dialog_nodes.items()}
442
+ front = [n for n in widget if n["path"].startswith("UIAdvWidget/FrontCanvas/") and n["path"].count("/") == 2]
443
+ front_order = [n["name"] for n in front]
444
+ widget_rec = widget_canvases(widget, widget_comp)
445
+ camera = _camera(widget, _ref_path(widget_comp["_videoAndStillCamera"]))
446
+ screen_image = _ref_path(widget_comp["_videoAndStillScreenImage"])
447
+ if screen_image not in {n["path"] for n in out_nodes}:
448
+ raise RuntimeError(f"{screen_image} (_videoAndStillScreenImage) is not a drawn node")
449
+ master_ids = ex.asset(MASTER_ID_SETTINGS_KEY) # exported once: a second export is a reference
450
+ id_texts = master_id_texts(master_ids, master, field) if master is not None else None
451
+ chat_nodes = {n["path"]: n for n in ex.prefab(CHAT_WIDGET_KEY)["nodes"]}
452
+ missing = [p for p in CHAT_WIDGET_NODES if p not in chat_nodes]
453
+ if missing:
454
+ raise RuntimeError(f"{missing} missing from {CHAT_WIDGET_KEY}")
455
+ chat_widget = {"nodes": [_node_record(chat_nodes[p]) for p in CHAT_WIDGET_NODES]}
456
+ chat, status_ids = chat_texts(ex, styles.lang, mode, episode)
457
+ status_texts = master_texts(master, status_ids, field) if master is not None and status_ids else None
458
+
459
+ materials: dict = {}
460
+ if game:
461
+ materials, font_doc = _game_fonts(ex, styles, episode, out_nodes, field, master_ids,
462
+ [t["text"] for t in (id_texts or {}).values()]
463
+ + list((status_texts or {}).values()))
464
+ rule = next(n for n in out_nodes if n["path"].endswith("FrontCanvas/RuleTransition"))
465
+ rt_comp = [c for c in next(w for w in widget if w["path"] == rule["path"])["components"]
466
+ if c.get("class") == "UIAdvRuleTransitionView"][0]
467
+ materials[rt_comp["_material"]["material"]] = rt_comp["_material"]
468
+ rule["ruleTransition"] = {"material": rt_comp["_material"]["material"]}
469
+ # Graphic.defaultGraphicMaterial (Canvas.GetDefaultCanvasMaterial): the player
470
+ # ships the built-in UI/Default shader (unity_builtin_extra) but no serialized
471
+ # "Default UI Material", so its values are the shader's property defaults.
472
+ ui_default = player.shader("UI/Default", file="unity_builtin_extra")
473
+ materials["Default UI Material"] = {"material": "Default UI Material", "shader": {"shader": "UI/Default"},
474
+ "keywords": [], "textures": {}, "ints": {}, "floats": {}, "colors": {},
475
+ "shaderDefaults": True}
476
+ # the materials of the drawn images (the video mask and the curtain it masks)
477
+ for n in nodes + [n for dn in dialog_nodes.values() for n in dn]:
478
+ for c in n["components"]:
479
+ if c.get("class") in ("Image", "RawImage") and c["m_Material"]:
480
+ materials.setdefault(c["m_Material"]["material"], c["m_Material"])
481
+
482
+ # -- glyph blocks -------------------------------------------------------------
483
+ if game:
484
+ font_out = tmpfont.export_fonts(ex, font_doc["fonts"], font_doc["primaries"], font_doc["needed"],
485
+ font_doc["runtime"], font_doc["textMaterials"], packer,
486
+ "characters shown by this episode (plus baked control characters); runtime "
487
+ "characters generated from the source font (runtimeGlyphs)")
488
+ for name, mg in font_doc["missingGlyph"].items():
489
+ font_out[name]["missingGlyph"] = mg
490
+
491
+ # -- sprites -------------------------------------------------------------------
492
+ sprite_refs = set()
493
+ all_records = out_nodes + [n for d in dialogs.values() for n in d["nodes"]]
494
+ for n in all_records:
495
+ if n.get("image") and n["image"]["spriteRef"]:
496
+ sprite_refs.add(n["image"]["spriteRef"])
497
+ if (n.get("buttonImageState") or {}).get("selectedRef"):
498
+ sprite_refs.add(n["buttonImageState"]["selectedRef"])
499
+ sprite_refs.update(n.get("_spriteRefs", ()))
500
+ for p in (n.get("behaviours") or {}).get("UIPictogram", ()):
501
+ sprite_refs.update(r for r in (p.pop("_spriteRef", None), p.pop("_defaultRef", None)) if r)
502
+ lb_sprite = ex.key_sprite(LETTERBOX_KEY)
503
+ sprite_refs.add(lb_sprite["spriteRef"])
504
+ sprites_out = {}
505
+ for ref in sorted(sprite_refs):
506
+ s = ex.sprite_recs[ref]
507
+ tex_name = s["tex"].read_typetree()["m_Name"]
508
+ ex.pack_sprite(ref, packer, "sprites" if "FixUiSpriteAtlas" in tex_name else f"sprite_{s['name']}")
509
+ # transitions
510
+ ps = ex.asset(PLAYER_SETTINGS_KEY)
511
+ addresses = {ps["_defaultTransitionAssetAddress"]}
512
+ for c in list(ps["_initializeEpisodes"]) + list(ps["_finalizeEpisodes"]) + episode["commands"]:
513
+ cmd = c.get("Command") if "Command" in c else c.get("raw")
514
+ if cmd in (5, 6) and c.get("TargetAssetName"):
515
+ addresses.add(c["TargetAssetName"])
516
+ transitions = {}
517
+ for addr in sorted(addresses):
518
+ a = ex.asset(TRANSITION_PREFIX + addr)
519
+ tex = a["_texture"]
520
+ if tex is None:
521
+ raise RuntimeError(f"transition {addr}: no rule texture")
522
+ if tex["mipCount"] > 1:
523
+ raise NotImplementedError(f"transition {addr}: mipmapped rule texture")
524
+ o = ex.tex_objs[tex["textureRef"]]
525
+ a["_block"] = packer.add(f"rule_{_safe(addr)}", o, 0, 0, tex["width"], tex["height"])
526
+ transitions[addr] = a
527
+
528
+ textures = packer.build()
529
+
530
+ # resolve blocks
531
+ if game:
532
+ tmpfont.resolve_font_blocks(font_out)
533
+ for ref in sorted(sprite_refs):
534
+ sprites_out[ex.sprite_recs[ref]["name"]] = ex.packed_sprite(ref)
535
+ for n in all_records:
536
+ if n.get("image"):
537
+ n["image"].pop("spriteRef", None)
538
+ if n.get("buttonImageState"):
539
+ n["buttonImageState"].pop("selectedRef", None)
540
+ n.pop("_spriteRefs", None)
541
+ for addr, a in transitions.items():
542
+ b = a.pop("_block")
543
+ t = textures[b["texture"]]
544
+ # rule textures are sampled with screen-space UVs over [0, 1]: they stay whole
545
+ if b["offset"] != (0, 0) or (t["width"], t["height"]) != (a["_texture"]["width"], a["_texture"]["height"]):
546
+ raise RuntimeError(f"transition {addr}: rule texture not copied whole")
547
+ a["_texture"] = {"texture": b["texture"], "name": a["_texture"]["name"]}
548
+ for m in materials.values():
549
+ for k, v in m["textures"].items():
550
+ if v["texture"] is not None:
551
+ v["texture"] = {"name": v["texture"]["name"], "width": v["texture"]["width"],
552
+ "height": v["texture"]["height"], "format": v["texture"]["format"]}
553
+
554
+ # -- clips ----------------------------------------------------------------------
555
+ clips = {}
556
+ for name, key in CLIP_KEYS.items():
557
+ o = ex.key_object(key)
558
+ if o.type.name != "AnimationClip":
559
+ raise RuntimeError(f"{key}: container asset is {o.type.name}")
560
+ clips[name] = ex.clip_curves(o)
561
+ controllers = {name: ex.controller_states(ex.key_object(key)) for name, key in CONTROLLER_KEYS.items()}
562
+
563
+ # -- DOTween defaults (Resources/DOTweenSettings) ------------------------------------
564
+ dts = player.mono(player.resource("DOTweenSettings"))
565
+
566
+ # -- shaders ----------------------------------------------------------------------
567
+ needed = {}
568
+ for name in sorted({m["shader"]["shader"] for m in materials.values()}):
569
+ if name == "UI/Default":
570
+ needed[name] = ui_default
571
+ elif name in ex.shaders:
572
+ needed[name] = ex.shaders[name]
573
+ else:
574
+ raise RuntimeError(f"shader {name} not in the loaded bundles")
575
+ blur = None
576
+ if uses_backdrop_filter(window):
577
+ blur, blur_shader = blur_settings(player)
578
+ needed[blur["shader"]] = blur_shader
579
+ index, shader_summary = ex.dump_shaders(ui_dir / "shaders", needed)
580
+ if blur is not None:
581
+ gles = [v for r in index if r["name"] == blur["shader"] for v in r["variants"]
582
+ if v["platform"] == "gles3" and v["type"] == "GLES3"]
583
+ if not any(v["keywords"] == [] and v["stage"] == "vertex" for v in gles):
584
+ raise RuntimeError(f"{blur['shader']}: no GLES3 variant without keywords (the blur pass)")
585
+ variants_needed = {}
586
+ for m in materials.values():
587
+ sh = m["shader"]["shader"]
588
+ rec = next(r for r in index if r["name"] == sh)
589
+ known = {k for v in rec["variants"] if v["platform"] == "gles3" and v["type"] == "GLES3" for k in v["keywords"]}
590
+ want = sorted(k for k in m["keywords"] if k in known)
591
+ ok = any(v["platform"] == "gles3" and v["type"] == "GLES3" and sorted(v["keywords"]) == want
592
+ and v["stage"] == "vertex" for v in rec["variants"])
593
+ if not ok:
594
+ raise RuntimeError(f"{sh}: no GLES3 variant for material {m['material']} keywords {want}")
595
+ variants_needed[m["material"]] = want
596
+ # read last: loading the UIManager bundles registers their shaders
597
+ ui_cam = ui_camera(ex)
598
+
599
+ doc = {
600
+ "about": about(language, windows),
601
+ "language": language_doc(language, styles if game else None),
602
+ "frontCanvasOrder": front_order,
603
+ "widget": widget_rec,
604
+ "nodes": out_nodes,
605
+ "videoAndStillCamera": camera,
606
+ "uiCamera": ui_cam,
607
+ "videoAndStillScreenImage": screen_image,
608
+ "chatWidget": chat_widget,
609
+ "sprites": sprites_out,
610
+ "letterBoxSprite": lb_sprite["name"],
611
+ "textures": textures,
612
+ "materials": materials,
613
+ "materialKeywords": variants_needed,
614
+ }
615
+ if blur is not None:
616
+ doc["blur"] = blur
617
+ if game:
618
+ tmp_settings = player.names_in(player.resource("TMP Settings"), player.mono(player.resource("TMP Settings")))
619
+ doc["fonts"] = font_out
620
+ doc["tmpSettings"] = {k: tmp_settings[k] for k in ("m_fallbackFontAssets", "m_defaultFontAsset",
621
+ "m_missingGlyphCharacter", "m_matchMaterialPreset",
622
+ "m_enableExtraPadding", "m_warningsDisabled")}
623
+ doc.update({
624
+ "clips": clips,
625
+ "controllers": controllers,
626
+ "transitions": transitions,
627
+ "playerSettings": {k: ps[k] for k in (
628
+ "_defaultTransitionAssetAddress", "_waitAfterVoiceTime", "_waitTalkTextUnitTime",
629
+ "_minTalkDisplayTime", "_isAdvViewportFollowOnResolutionChanged")},
630
+ "dotween": {k: dts[k] for k in ("defaultEaseType", "defaultUpdateType", "defaultTimeScaleIndependent",
631
+ "timeScale", "defaultEaseOvershootOrAmplitude", "defaultEasePeriod")},
632
+ "shaders": {"index": "shaders/shaders.json", "names": shader_summary["names"]},
633
+ })
634
+ if game:
635
+ doc["glyphCoverage"] = font_doc["coverage"]
636
+ if id_texts is not None:
637
+ doc["masterIdTexts"] = id_texts
638
+ doc["dialogs"] = dialogs
639
+ if chat:
640
+ doc["chatTexts"] = chat
641
+ if status_texts:
642
+ doc["chatStatusTexts"] = status_texts
643
+ doc["textStyle"] = styles.summary()
644
+ write_json(ui_dir / "ui.json", doc)
645
+ out = {"textures": {k: (v["width"], v["height"]) for k, v in textures.items()},
646
+ "shaders": shader_summary["names"]}
647
+ if not game:
648
+ return {**out, "fonts": "open", "texts": sum(1 for n in out_nodes if "textStyle" in n)}
649
+ return {**out, "coverage": font_doc["coverage"]["counts"],
650
+ "fonts": sorted(font_out),
651
+ "runtimeGlyphs": {k: {"generated": len(v["runtimeGlyphs"]["generated"]),
652
+ "validation": {kk: v["runtimeGlyphs"]["validation"][kk] for kk in
653
+ ("glyphs", "maxAbsError", "meanAbsError", "exactGlyphs")}}
654
+ for k, v in font_out.items() if v["runtimeGlyphs"]}}
655
+
656
+
657
+ def talk_windows(episode: dict) -> list[str]:
658
+ """The talk windows the story attaches, in the order of their nodes under TalkView: UIDefaultTalkWindow (AdvPlayer
659
+ PlayInitialCommands attaches it), then by name the other talk windows of the episode's resources (the TalkWindow
660
+ rows, adv.closure kind "talkwindow")."""
661
+ names = {r["address"][r["address"].rindex("/") + 1:] for r in episode["resources"] if r["kind"] == "talkwindow"}
662
+ return [adv.DEFAULT_TALK_WINDOW] + sorted(names - {adv.DEFAULT_TALK_WINDOW})
663
+
664
+
665
+ def window_key(cat, name: str) -> str:
666
+ """The prefab key of the talk window `name`: WINDOW_KEY for the default window, else the TalkWindow address
667
+ ("UI/Prefab/Parts/Adv/Talk/" + name) resolved as adv.closure resolves it (embedded content: the "Emb" key when
668
+ the catalog lacks the plain one)."""
669
+ if name == adv.DEFAULT_TALK_WINDOW:
670
+ return WINDOW_KEY
671
+ address = adv.TALK_WINDOW_PREFIX + name
672
+ return address if cat.has(address) else adv.EMBEDDED_PREFIX + address
673
+
674
+
675
+ def window_nodes(ex: Exporter, names: list[str]) -> list[dict]:
676
+ """The prefab nodes of the talk windows `names`, in that order, with their paths under TalkView
677
+ (AdvTalkView.SetWindow parents the attached window there at localPosition 0)."""
678
+ out = []
679
+ for name in names:
680
+ nodes = ex.prefab(window_key(ex.cat, name))["nodes"]
681
+ if not nodes or nodes[0]["path"] != name:
682
+ raise RuntimeError(f"talk window {name}: the prefab root is not {name}")
683
+ out += [{**w, "path": f"{WINDOW_PARENT}/{w['path']}"} for w in nodes]
684
+ return out
685
+
686
+
687
+ def uses_backdrop_filter(window: list[dict]) -> bool:
688
+ """Whether a talk window of the nodes (window_nodes) has UIAdvTalkWindow._useBackdropFilter set."""
689
+ return any(c.get("class") == "UIAdvTalkWindow" and c["_useBackdropFilter"] for n in window for c in n["components"])
690
+
691
+
692
+ def backdrop_path(widget: list[dict], comp: dict) -> str:
693
+ """The node of the centre-talk backdrop (UIAdvWidget BACKDROP_REFS: its CanvasGroup and Image, on one node)."""
694
+ paths = {_ref_path(comp[k]) for k in BACKDROP_REFS}
695
+ if len(paths) != 1 or None in paths:
696
+ raise RuntimeError(f"UIAdvWidget {BACKDROP_REFS}: {sorted(map(str, paths))}")
697
+ path = paths.pop()
698
+ node = next((n for n in widget if n["path"] == path), None)
699
+ if node is None:
700
+ raise RuntimeError(f"{path}: not a widget node")
701
+ if not any(c["type"] == "CanvasGroup" for c in node["components"]) or _component(node, ("Image",)) is None:
702
+ raise RuntimeError(f"{path}: no CanvasGroup or Image")
703
+ return path
704
+
705
+
706
+ def blur_settings(player: PlayerData) -> tuple[dict, object]:
707
+ """ui.json `blur` and its Shader object, for a backdrop-filter talk window (UIAdvWidget.UpdateAdvBlurAndBackdrop ->
708
+ UIManager.UseBlur): the UIRendererFeature of renderer 0 (storyhost.blur_record; CameraManager and
709
+ UICameraController set renderer 0 on the main, sub and UI cameras, and the UI overlay camera runs the pass)."""
710
+ from . import storyhost # storyhost imports this module: resolved at call time
711
+ blur = storyhost.blur_record(player.graphics())
712
+ if not blur["active"]:
713
+ raise NotImplementedError(f"renderer {blur['renderer']}: the UIRendererFeature is not active")
714
+ objs = [o for o in player.renderer_shaders(blur["renderer"]) if o.read().m_ParsedForm.m_Name == blur["shader"]]
715
+ if len(objs) != 1:
716
+ raise RuntimeError(f"renderer {blur['renderer']}: {len(objs)} shaders {blur['shader']}")
717
+ return blur, objs[0]
718
+
719
+
720
+ def pictogram_catalogs(nodes: list) -> dict:
721
+ """{catalog name: its serialized record} of the UIPictogram sprite catalogs the prefab nodes name (the exporter
722
+ writes an asset in full at its first reference)."""
723
+ out = {}
724
+ for n in nodes:
725
+ for c in n["components"]:
726
+ cat = c.get("_spriteCatalog") if c.get("class") == "UIPictogram" else None
727
+ if cat and "_spritesByKey" in cat:
728
+ out.setdefault(cat["name"], cat)
729
+ return out
730
+
731
+
732
+ def resolve_pictograms(rec: dict, catalogs: dict) -> None:
733
+ """The sprite of each UIPictogram record of a node (UISpriteCatalogBase.GetSprite: the catalog's sprite of the
734
+ key, `sprite`; null when the catalog has none) and the catalog's default sprite (`defaultSprite`)."""
735
+ for p in (rec.get("behaviours") or {}).get("UIPictogram", ()):
736
+ cat = catalogs.get(p["catalog"])
737
+ if cat is None:
738
+ raise RuntimeError(f"{rec['path']}: sprite catalog {p['catalog']} not exported")
739
+ by_key = {e["Key"]: e["Value"] for e in cat["_spritesByKey"]["_list"]}
740
+ hit, default = by_key.get(p["_key"]), cat.get("_defaultSprite")
741
+ p["sprite"] = hit["name"] if hit else None
742
+ p["defaultSprite"] = default["name"] if default else None
743
+ p["_spriteRef"] = hit["spriteRef"] if hit else None
744
+ p["_defaultRef"] = default["spriteRef"] if default and not hit else None
745
+
746
+
747
+ def widget_canvases(widget: list, comp: dict) -> dict:
748
+ """widget: UIWidget._canvasSortOrder and the sorting order of each canvas of UIWidget._canvases, in list order
749
+ (UIWidget.TrueCanvasSortOrder reads the first)."""
750
+ by_path = {n["path"]: n for n in widget}
751
+ canvases = []
752
+ for ref in comp["_canvases"]:
753
+ path = _ref_path(ref)
754
+ hit = [c for c in (by_path.get(path) or {"components": []})["components"] if c["type"] == "Canvas"]
755
+ if len(hit) != 1:
756
+ raise RuntimeError(f"_canvases: {path} has {len(hit)} Canvas components")
757
+ canvases.append({"path": path, "sortingOrder": hit[0]["m_SortingOrder"]})
758
+ return {"canvasSortOrder": comp["_canvasSortOrder"], "canvases": canvases}
759
+
760
+
761
+ def emoji_sequence_key(name: str, unicode: int) -> str | None:
762
+ """TMP_EmojiSearchEngine.TryUpdateSequenceLookupTable: the lookup key of a sprite named `name` (a "-" separated
763
+ hex code point name), None when it is a single code point (its name equals `unicode` in hex). The name goes
764
+ through BuildNameInEmojiSurrogateFormat (file name without extension, lower case, each part left-padded with
765
+ "0" to 8 digits); each 8-digit chunk is a code point (char.ConvertFromUtf32). A chunk that is not a scalar value
766
+ (the game appends its 4-digit halves as UTF-16 code units) is not implemented."""
767
+ n = name.rsplit("/", 1)[-1]
768
+ if "." in n:
769
+ n = n[:n.rindex(".")]
770
+ n = n.lower()
771
+ if "-" in n:
772
+ n = "".join(p.rjust(8, "0") for p in n.split("-"))
773
+ if not n or n == f"{unicode & 0xFFFFFFFF:08x}":
774
+ return None
775
+ out = []
776
+ for i in range(0, len(n), 8):
777
+ chunk = n[i:i + 8]
778
+ v = int(chunk, 16)
779
+ if v == 0 or v >= 0x10FFFF or 0xD800 <= v < 0xE000:
780
+ raise NotImplementedError(f"emoji sprite {name}: {chunk} is not a code point")
781
+ out.append(chr(v))
782
+ return "".join(out)
783
+
784
+
785
+ def sprite_asset_record(ex: Exporter, key: str = EMOJI_SPRITE_ASSET_KEY) -> dict:
786
+ """The TMP sprite asset at `key` (LocalizeManager's emoji sprite asset by default) as the text layout reads it:
787
+ `name`, `faceInfo` (m_FaceInfo), `characters` (m_SpriteCharacterTable in order: `index`, `unicode`, `name`,
788
+ `glyph`, `scale`), `glyphs` ({glyph index: metrics, rect (in the sprite sheet), scale, atlasIndex}),
789
+ `sequences` (the entries of the legacy sprite list, spriteInfoList, whose name holds a '-', in order: `name`,
790
+ `unicode`; TMP_EmojiSearchEngine builds its sequence table from them), `sheet` (the sprite sheet's name, width,
791
+ height, mip count and texture settings) and `material` (its material record). Fallback sprite assets are not
792
+ implemented."""
793
+ o = ex.key_object(key)
794
+ tt = o.read_typetree()
795
+ if tt.get("fallbackSpriteAssets"):
796
+ raise NotImplementedError(f"{key}: fallback sprite assets")
797
+ sheet = ex.deref(o, tt["spriteSheet"]).read_typetree()
798
+ chars = [{"index": i, "unicode": c["m_Unicode"], "name": c["m_Name"], "glyph": c["m_GlyphIndex"],
799
+ "scale": c["m_Scale"]} for i, c in enumerate(tt["m_SpriteCharacterTable"])]
800
+ glyphs = {str(g["m_Index"]): {"metrics": g["m_Metrics"], "rect": g["m_GlyphRect"], "scale": g["m_Scale"],
801
+ "atlasIndex": g["m_AtlasIndex"]} for g in tt["m_GlyphTable"]}
802
+ sequences = [{"name": e["name"], "unicode": e["unicode"]} for e in tt.get("spriteInfoList") or []
803
+ if e.get("name") and "-" in e["name"]]
804
+ for e in sequences:
805
+ emoji_sequence_key(e["name"], e["unicode"]) # raises on a name the port does not read
806
+ return {"name": tt["m_Name"], "faceInfo": tt["m_FaceInfo"], "characters": chars, "glyphs": glyphs,
807
+ "sequences": sequences,
808
+ "sheet": {"name": sheet["m_Name"], "width": sheet["m_Width"], "height": sheet["m_Height"],
809
+ "mipCount": sheet.get("m_MipCount", 1), "settings": sheet.get("m_TextureSettings")},
810
+ "material": ex.material(ex.deref(o, tt["m_Material"]))}
811
+
812
+
813
+ def _utf16(s: str) -> tuple[int, ...]:
814
+ b = s.encode("utf-16-le")
815
+ return tuple(int.from_bytes(b[i:i + 2], "little") for i in range(0, len(b), 2))
816
+
817
+
818
+ # TMP_TextUtilities.ToUpperFast's table (k_lookupStringU) for the units below U+0080
819
+ TMP_UPPER = ("-------------------------------- !-#$%&-()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[-]^_`"
820
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZ{|}~-")
821
+
822
+
823
+ def tmp_hash(s: str) -> int:
824
+ """TMP_TextUtilities.GetHashCode: h = ((h << 5) + h) ^ ToUpperFast(unit) over the UTF-16 units, as an unsigned
825
+ 32-bit value (the game's int32 bits); sprite names and tag values are matched by it."""
826
+ h = 0
827
+ for u in _utf16(s):
828
+ h = ((((h << 5) + h) & 0xFFFFFFFF) ^ (ord(TMP_UPPER[u]) if u < len(TMP_UPPER) else u)) & 0xFFFFFFFF
829
+ return h
830
+
831
+
832
+ def emoji_sequence_table(sequences: list[dict]) -> tuple[dict[tuple, str], set[tuple]]:
833
+ """TMP_EmojiSearchEngine.TryUpdateSequenceLookupTable over a sprite asset record's `sequences`: ({key: sprite
834
+ name}, the fast lookup set of every key's prefixes), keys as tuples of UTF-16 code units; the first entry of a key
835
+ wins."""
836
+ table: dict[tuple, str] = {}
837
+ fast: set[tuple] = set()
838
+ for e in sequences:
839
+ key = emoji_sequence_key(e["name"], e["unicode"])
840
+ if not key:
841
+ continue
842
+ units = _utf16(key)
843
+ fast.update(units[:k] for k in range(1, len(units) + 1))
844
+ table.setdefault(units, e["name"])
845
+ return table, fast
846
+
847
+
848
+ def emoji_sequence_names(table: dict[tuple, str], fast: set[tuple], text: str) -> list[str]:
849
+ """The sprite names TMP_EmojiSearchEngine.ParseEmojiCharSequence writes as <sprite name="..."> tags into `text`
850
+ (emoji_sequence_table): from each position the longest run of UTF-16 units whose every prefix is in the fast
851
+ set, no backtracking; a run that is a whole key is consumed, else the scan goes on at the next unit."""
852
+ if not table or not text:
853
+ return []
854
+ u, out, i = _utf16(text), [], 0
855
+ while i < len(u):
856
+ j = i
857
+ while j < len(u) and (j == i or u[i:j] in fast):
858
+ j += 1
859
+ seq = u[i:j]
860
+ if seq and seq not in fast:
861
+ seq, j = seq[:-1], j - 1
862
+ if seq in table:
863
+ out.append(table[seq])
864
+ i = j - 1 if j > i + 1 else i
865
+ i += 1
866
+ return out
867
+
868
+
869
+ def _camera(widget: list, path: str | None, field: str = "_videoAndStillCamera") -> dict:
870
+ """videoAndStillCamera: the Camera (CAMERA_FIELDS) and UniversalAdditionalCameraData (CAMERA_DATA_FIELDS) of the
871
+ node at `path` (UIAdvWidget._videoAndStillCamera; `field` names the reference in errors)."""
872
+ node = next((n for n in widget if n["path"] == path), None)
873
+ if node is None:
874
+ raise RuntimeError(f"{field}: no node {path}")
875
+ cam = [c for c in node["components"] if c["type"] == "Camera"]
876
+ data = [c for c in node["components"] if c.get("class") == "UniversalAdditionalCameraData"]
877
+ if len(cam) != 1 or len(data) != 1:
878
+ raise RuntimeError(f"{path}: {len(cam)} Camera, {len(data)} UniversalAdditionalCameraData")
879
+ return {"path": path, "active": node["active"], "camera": {k: cam[0][k] for k in CAMERA_FIELDS},
880
+ "additionalCameraData": {k: data[0][k] for k in CAMERA_DATA_FIELDS}}
881
+
882
+
883
+ def ui_camera(ex: Exporter) -> dict:
884
+ """uiCamera: the camera UICameraController._uiCamera of the UIManager prefab (UIManager._cameraController), with
885
+ its UniversalAdditionalCameraData (_uiCameraData, on the same node), as _camera's record. The components are read
886
+ from their typetrees, so nothing they reference is exported."""
887
+ go = ex.key_object(UI_MANAGER_KEY)
888
+ if go is None or go.type.name != "GameObject":
889
+ raise RuntimeError(f"{UI_MANAGER_KEY}: container asset is {go and go.type.name}")
890
+ _, graph = ex.load(UI_MANAGER_KEY)
891
+
892
+ def mono(owner, pptr, cls: str, what: str):
893
+ o = ex.deref(owner, pptr) if pptr else None
894
+ if o is None or o.type.name != "MonoBehaviour" or ex.script_class(o) != cls:
895
+ raise RuntimeError(f"{UI_MANAGER_KEY}: {what} is not a {cls}")
896
+ return o
897
+
898
+ comps = [ex.deref(go, c["component"]) for c in go.read_typetree()["m_Component"]]
899
+ managers = [c for c in comps if c is not None and c.type.name == "MonoBehaviour" and ex.script_class(c) == "UIManager"]
900
+ if len(managers) != 1:
901
+ raise RuntimeError(f"{UI_MANAGER_KEY}: {len(managers)} UIManager on the prefab root")
902
+ ctrl = mono(managers[0], managers[0].read_typetree()["_cameraController"], "UICameraController",
903
+ "UIManager._cameraController")
904
+ ctrl_tt = ctrl.read_typetree()
905
+ cam = ex.deref(ctrl, ctrl_tt["_uiCamera"]) if ctrl_tt["_uiCamera"] else None
906
+ if cam is None or cam.type.name != "Camera":
907
+ raise RuntimeError(f"{UI_MANAGER_KEY}: UICameraController._uiCamera is not a Camera")
908
+ data = mono(ctrl, ctrl_tt["_uiCameraData"], "UniversalAdditionalCameraData", "UICameraController._uiCameraData")
909
+ cam_tt, data_tt = cam.read_typetree(), data.read_typetree()
910
+ go_pid = cam_tt["m_GameObject"]["m_PathID"]
911
+ if data_tt["m_GameObject"]["m_PathID"] != go_pid:
912
+ raise RuntimeError(f"{UI_MANAGER_KEY}: _uiCamera and _uiCameraData are on different GameObjects")
913
+ path = graph.path(graph.tf_of_go[go_pid])
914
+ node = {"path": path, "active": bool(graph.go[go_pid]["m_IsActive"]), "components": [
915
+ {"type": "Camera", **cam_tt}, {"type": "MonoBehaviour", "class": "UniversalAdditionalCameraData", **data_tt}]}
916
+ return _camera([node], path, "_uiCamera")
917
+
918
+
919
+ def master_texts(master: Path, ids, field: str) -> dict[str, str]:
920
+ """{MasterText id: its text in the text field `field`} of `ids`; a missing id raises."""
921
+ from . import master as master_mod
922
+ ids = set(ids)
923
+ rows = {r["_id"]: r for r in master_mod.table(Path(master), "MasterText") if r["_id"] in ids}
924
+ missing = sorted(ids - set(rows))
925
+ if missing:
926
+ raise KeyError(f"text ids not in MasterText: {missing}")
927
+ return {i: rows[i].get(f"_{field}") or "" for i in sorted(ids)}
928
+
929
+
930
+ def master_id_texts(settings: dict, master: Path, field: str) -> dict:
931
+ """masterIdTexts: {AdvMasterIdSettings field of MASTER_ID_TEXTS: {id (MasterText id), text (in `field`)}}."""
932
+ ids = {k: settings[k] for k in MASTER_ID_TEXTS}
933
+ texts = master_texts(master, ids.values(), field)
934
+ return {k: {"id": i, "text": texts[i]} for k, i in ids.items()}
935
+
936
+
937
+ def font_key(cat, font_asset: str, name: str) -> str:
938
+ """The address of `name` (a font asset or one of its materials) in the folder of `font_asset`: the embedded copy
939
+ "EmbFont/<font>/" (textstyle.font_dir) when the catalog has it, else LocalizeManager's "Font/<font>/"
940
+ (FONT_ADDRESS; the additional fonts)."""
941
+ key = textstyle.font_dir(font_asset) + name
942
+ if cat.has(key):
943
+ return key
944
+ base = font_asset[:-4] if font_asset.endswith(" SDF") else font_asset
945
+ return f"{FONT_ADDRESS}{base}/{name}"
946
+
947
+
948
+ def _lookup_lang(lang: dict) -> dict:
949
+ """`lang` (textstyle.language_fonts) with the Japanese font names of LocalizeManager.InitJapaneseFontLookUpTable:
950
+ the Japanese font names, then its additional font names (the table TryGetFontIndex searches)."""
951
+ ja = lang["languages"][textstyle.JAPANESE_MODE]
952
+ table = {**ja, "fontNames": ja["fontNames"] + ja["additionalFontNames"]}
953
+ return {**lang, "languages": {**lang["languages"], textstyle.JAPANESE_MODE: table}}
954
+
955
+
956
+ def chat_localize(font_asset: str, material: str, lang: dict, mode: int) -> dict:
957
+ """LocalizeText.Init -> OnFontChanged of a text with _localizeEnabled whose font may be an additional font (the
958
+ chat windows' font): TryGetFontIndex = its index in the lookup table (_lookup_lang), the font of that index among
959
+ the language's loaded fonts (its font names, then the additional fonts LocalizeManager.LoadAdditionalAsset
960
+ appends; an index out of range: font 0), GetFontMaterial of the material type, ApplyLanguageLineSpacing.
961
+ -> {fontAsset, material, lineSpacing}."""
962
+ table = [f"{n} SDF" for n in _lookup_lang(lang)["languages"][textstyle.JAPANESE_MODE]["fontNames"]]
963
+ if font_asset not in table:
964
+ raise NotImplementedError(f"font {font_asset} not in the Japanese font lookup table")
965
+ own = lang["languages"][mode]
966
+ fonts = [f"{n} SDF" for n in own["fontNames"] + own["additionalFontNames"]]
967
+ i = table.index(font_asset)
968
+ font = fonts[i] if i < len(fonts) else fonts[0]
969
+ mat_type = textstyle.material_type(material)
970
+ if mat_type not in lang["materialTypes"]:
971
+ raise NotImplementedError(f"material type {mat_type} not in LocalizeManager._materialTypeList")
972
+ return {"fontAsset": font, "material": f"{font[:-4]} - {mat_type}", "lineSpacing": LANGUAGE_LINE_SPACING[mode]}
973
+
974
+
975
+ def chat_windows(episode: dict) -> dict[str, str]:
976
+ """{window name: address} of the chat windows of the episode's resources ("Adv/Chat/Prefabs/" + name)."""
977
+ return {r["address"][len(adv.CHAT_WINDOW_PREFIX):]: r["address"]
978
+ for r in episode["resources"] if r["kind"] == "chatwindow"}
979
+
980
+
981
+ def chat_text_nodes(ex: Exporter, episode: dict):
982
+ """(window name, node, TMP component, LocalizeText component or None) of every TMP text of the episode's chat
983
+ windows, and the status text ids their AdvChatWindow components set (CHAT_STATUS_KEYS)."""
984
+ out, status = [], set()
985
+ for name, address in sorted(chat_windows(episode).items()):
986
+ for n in ex.prefab(address)["nodes"]:
987
+ win = _component(n, ("AdvChatWindow",))
988
+ if win is not None:
989
+ status |= {win[k] for k in CHAT_STATUS_KEYS if win.get(k)}
990
+ comp = _component(n, TMP_CLASSES)
991
+ if comp is not None:
992
+ out.append((name, n, comp, _component(n, ("LocalizeText",))))
993
+ return out, sorted(status)
994
+
995
+
996
+ def chat_texts(ex: Exporter, lang: dict, mode: int, episode: dict) -> tuple[dict, list[str]]:
997
+ """chatTexts {window: {node path: {textStyle, localizeText}}} of the episode's chat windows in the language `mode`
998
+ (the text record of textstyle.text_style with the font and material chat_localize gives; font roles are the
999
+ slots of the lookup table) and the status text ids of the windows."""
1000
+ table_lang, styles, out = _lookup_lang(lang), {}, {}
1001
+ nodes, status = chat_text_nodes(ex, episode)
1002
+ for name, n, comp, loc in nodes:
1003
+ fa, mat = comp["m_fontAsset"]["name"], comp["m_sharedMaterial"]["material"]
1004
+ if loc and loc["m_Enabled"] and loc["_localizeEnabled"]:
1005
+ loc_font = chat_localize(fa, mat, lang, mode)
1006
+ fa, mat = loc_font["fontAsset"], loc_font["material"]
1007
+ if (fa, mat) not in styles:
1008
+ face = ex.key_object(font_key(ex.cat, fa, fa)).read_typetree()["m_FaceInfo"]
1009
+ styles[(fa, mat)] = textstyle.material_style(ex.material(ex.key_object(font_key(ex.cat, fa, mat))),
1010
+ face["m_PointSize"], face["m_Scale"])
1011
+ out.setdefault(name, {})[n["path"]] = {
1012
+ "textStyle": textstyle.text_style(comp, loc, table_lang, styles[(fa, mat)], mode),
1013
+ "localizeText": {"enabled": loc["m_Enabled"], "localizeEnabled": loc["_localizeEnabled"]} if loc else None}
1014
+ return out, status
1015
+
1016
+
1017
+ def _number(v) -> float | None:
1018
+ try:
1019
+ return float(v)
1020
+ except (TypeError, ValueError):
1021
+ return None
1022
+
1023
+
1024
+ def chat_runtime_texts(episode: dict) -> list[str]:
1025
+ """Texts the chat window formats at run time: the battery percentage of each ChatWindow row ("{0}%" of (int)
1026
+ Parameter1, when numeric), the member count a group chat name gets ("(n)" for Parameter4 2..99,
1027
+ AdvChatWindowNameHelper.Format), the ellipsis of a truncated name (TruncateName) and the read count a group read
1028
+ label appends (" n" of the ChatTalk / ChatStamp / ChatRead Parameter1, AdvChatView.BuildReadText)."""
1029
+ out = ["..."] if any(c["cmd"] in CHAT_COMMANDS for c in episode["commands"]) else []
1030
+ for c in episode["commands"]:
1031
+ if c["cmd"] not in CHAT_COMMANDS or c.get("IgnoreData"):
1032
+ continue
1033
+ p1 = _number(c.get("Parameter1"))
1034
+ if c["cmd"] == "ChatWindow":
1035
+ members = _number(c.get("Parameter4"))
1036
+ if p1 is not None and abs(p1) < 2 ** 31:
1037
+ out.append(f"{int(p1)}%")
1038
+ if members is not None and members == int(members) and 2 <= members <= 99:
1039
+ out.append(f"({int(members)})")
1040
+ elif c["cmd"] in ("ChatTalk", "ChatStamp", "ChatRead") and p1 is not None and p1 == int(p1) and p1 >= 1:
1041
+ out.append(f" {int(p1)}")
1042
+ return out
1043
+
1044
+
1045
+ def about(language: str, windows: list[str] = (adv.DEFAULT_TALK_WINDOW,)) -> str:
1046
+ """ui.json `about`: what the file holds (the widget and its talk windows), in which client language."""
1047
+ return f"ADV front canvas UI data (UIAdvWidget + {', '.join(windows)}), {language}"
1048
+
1049
+
1050
+ def language_doc(language: str, styles: textstyle.TextStyles | None = None) -> dict:
1051
+ """ui.json `language`: the LanguageMode and text field of `language`, with `styles` (fonts game) the
1052
+ LocalizeManager font lists and the font swap, and the line spacing LocalizeText applies."""
1053
+ mode = languages.mode(language)
1054
+ doc = {"mode": mode, "field": languages.column(language)[1:]}
1055
+ if styles is not None:
1056
+ doc.update({"fonts": styles.lang, "fontSwap": styles.swap})
1057
+ doc["lineSpacing"] = LANGUAGE_LINE_SPACING[mode]
1058
+ return doc
1059
+
1060
+
1061
+ def shown_texts(episode: dict, master_ids: dict, field: str) -> list[str]:
1062
+ """The texts the episode shows in the text field `field` (e.g. "traditionalChinese"), tags stripped: Talk,
1063
+ Location and Subtitles lines with their ruby readings (<r=reading>), the speaker names of the Talk rows
1064
+ (TargetTextIDs; the unknown-speaker text for TargetStatus 1, joined names with the split text; none for
1065
+ TargetStatus 2) and the title. `master_ids`: AdvMasterIdSettings."""
1066
+ lines = []
1067
+ for c in episode["commands"]:
1068
+ if c["cmd"] in ("Talk", "Location", "Subtitles", "ChoiceSet") and c.get("lines"):
1069
+ lines.append(c["lines"][field])
1070
+ lines += RUBY.findall(c["lines"][field] or "")
1071
+ names = set()
1072
+ for c in episode["commands"]:
1073
+ if c["cmd"] == "Talk" and c.get("TargetName") and c.get("TargetStatus", 0) != 2 and c.get("AdvTextID"):
1074
+ ids = list(c.get("TargetTextIDs") or [])
1075
+ if c.get("TargetStatus", 0) == 1:
1076
+ ids = [master_ids["_unknownCharacterNameTextId"]]
1077
+ elif len(ids) > 1:
1078
+ ids.append(master_ids["_splitCharacterNameTextId"])
1079
+ for i in ids:
1080
+ row = episode["text"].get(i)
1081
+ if row is None:
1082
+ raise KeyError(f"speaker text id {i} not in the episode text shard")
1083
+ names.add(row[field])
1084
+ for c in episode["commands"]: # chat rows: lines, window / sender names, stamp logs
1085
+ if c["cmd"] not in CHAT_COMMANDS or c.get("IgnoreData"):
1086
+ continue
1087
+ if c.get("lines"):
1088
+ lines.append(c["lines"][field])
1089
+ ids = list(c.get("TargetTextIDs") or [])
1090
+ if c["cmd"] == "ChatStamp" and c.get("TargetAssetName"): # AdvChatStampCommand.GetStampLogText
1091
+ ids.append(c["TargetAssetName"].rsplit("/", 1)[-1] + "_log")
1092
+ for i in ids:
1093
+ row = episode["text"].get(i)
1094
+ if row is not None:
1095
+ names.add(row[field])
1096
+ if any(c["cmd"] in CHAT_COMMANDS for c in episode["commands"]):
1097
+ for k in ("_chatReadTextId", "_chatStampLogTextId"):
1098
+ row = episode["text"].get(master_ids.get(k))
1099
+ if row is not None:
1100
+ names.add(row[field])
1101
+ title = episode.get("title")
1102
+ if not title:
1103
+ raise RuntimeError("episode.json has no title lines (adv.extract)")
1104
+ return [_strip_tags(s) for s in lines + sorted(names) + [title[field]]] + chat_runtime_texts(episode)
1105
+
1106
+
1107
+ def _game_fonts(ex: Exporter, styles: textstyle.TextStyles, episode: dict, out_nodes: list,
1108
+ field: str, master_ids: dict, ui_texts: list[str] = ()) -> tuple[dict, dict]:
1109
+ """`--fonts game`: the localized font / material of every text node (`text.localized`), the TMP font assets,
1110
+ the glyph coverage of the characters the episode shows (in the text field `field`; `master_ids`:
1111
+ AdvMasterIdSettings; `ui_texts`: texts of the UI itself) and the text materials.
1112
+ -> (materials, the inputs of tmpfont.export_fonts + coverage)."""
1113
+ from . import tmpfont
1114
+ fonts = tmpfont.FontSet(ex)
1115
+ used_text_nodes = [n for n in out_nodes if "text" in n]
1116
+ primaries = {}
1117
+ for n in used_text_nodes:
1118
+ t = n["text"]
1119
+ t["localized"] = textstyle.localize_text(n["path"], t["fontAsset"], t["material"], styles.swap, styles.lang,
1120
+ styles.mode)
1121
+ primaries[t["localized"]["fontAsset"]] = None
1122
+ for fname in primaries:
1123
+ primaries[fname] = fonts.by_key(textstyle.font_dir(fname) + fname)
1124
+
1125
+ # -- characters the episode shows ------------------------------------------
1126
+ shown = shown_texts(episode, master_ids, field) + list(ui_texts)
1127
+ chars = sorted({ord(ch) for s in shown for ch in s})
1128
+
1129
+ primary = [primaries[p] for p in primaries]
1130
+ if len(primary) != 1:
1131
+ raise NotImplementedError(f"texts use {len(primary)} primary fonts")
1132
+ primary = primary[0]
1133
+ coverage, needed, runtime_units = tmpfont.glyph_coverage(fonts, primary, chars)
1134
+ # the underline character from the primary asset itself (baked, or generated as its dynamic asset would)
1135
+ hit = fonts.lookup(primary, UNDERLINE_CHARACTER)
1136
+ in_asset = bool(hit and hit[1] is primary and hit[0] in ("baked", "runtime"))
1137
+ if in_asset:
1138
+ (needed if hit[0] == "baked" else runtime_units).setdefault(primary.name, set()).add(UNDERLINE_CHARACTER)
1139
+ coverage["underline"] = {"font": primary.name, "character": chr(UNDERLINE_CHARACTER), "inAsset": in_asset}
1140
+ # the missing glyph (tmpfont.missing_glyph): its substitute from the asset that has it
1141
+ tmpfont.check_glyph_variants(fonts, primary, shown)
1142
+ mg = tmpfont.missing_glyph(fonts, primary, chars, tmpfont.missing_glyph_character(ex.player))
1143
+ if mg:
1144
+ kind, f = fonts.lookup(primary, mg["unicode"])
1145
+ if kind != "synthesized":
1146
+ (needed if kind == "baked" else runtime_units).setdefault(f.name, set()).add(mg["unicode"])
1147
+ coverage["missingGlyph"] = [chr(u) for u in mg["characters"]]
1148
+
1149
+ # -- materials ---------------------------------------------------------------
1150
+ materials, text_materials = tmpfont.text_materials(ex, fonts, [n["text"]["localized"] for n in used_text_nodes],
1151
+ {primary.name}, needed, runtime_units)
1152
+ return materials, {"fonts": fonts, "primaries": {primary.name}, "needed": needed, "runtime": runtime_units,
1153
+ "textMaterials": text_materials, "coverage": coverage,
1154
+ "missingGlyph": {primary.name: mg} if mg else {}}