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