diff --git a/docs/products.md b/docs/products.md index eb469cf..ca9eaae 100644 --- a/docs/products.md +++ b/docs/products.md @@ -66,6 +66,7 @@ changes, or what it reaches changes. "CAB-…:40": {"status": "exported", "class": "Texture2D", "name": "texture_00", "files": {"image": {"sha256": "…", "size": 812345, "ext": "png"}, "json": {"sha256": "…", "size": 640, "ext": "json"}}}}, + "fenced": {}, "missing": []} ``` @@ -74,6 +75,11 @@ game loads it by. Each object has the status of its item (the most severe when t class and name where known, its files by role (the artifacts of its items), the object whose file holds it (`in`, a contained object), or the reason code of a generic, unsupported or failed object. +`fenced` names the objects that a reached object references across the font fence: the font bundles (the keys under +`EmbFont/` and `Font/`) are not followed from objects outside them, so each such reference is listed with the class and +name of the object it names, while the reference itself carries only its file and path id. A reference to an object of +the table is not listed. + ## Product layout A product layout places products for a player (`export assets --layout product`, `export live`, @@ -365,6 +371,7 @@ A story episode plays with products that several episodes share, made by `nnnote | `story.asset` | a catalog key a story loads: a stage (`Adv/Stage/`), frame, effect, post effect, still, timeline, chat window, chat icon or stamp, transition | `storyassets/.json`, `` the key with `/` as `~` | the key | `asset.json` and the object table | | `story.video` | a video key `Cri/Video/` (the video of a Movie / Clip command) | `videos/.json`, `` the asset with `/` as `~` | the key | `video.json` and `movie.webm` | | `story.chat` | `default` | `storychat/default.json` | the shared chat shards `Adv/Chat/AdvChat-Text`, `Adv/Chat/AdvChat-Sound` and `Adv/Chat/AdvChat-SoundCueSheet` | `chat.json` | +| `story.ui` | a client language (`ja`, `en`, `zh-Hant`, `zh-Hans`, `ko`) | `storyui/.json` | the story UI's catalog keys (the widget, talk windows, chat widget, dialogs, UIManager, letterbox, animations and settings), the built-in `GraphicsSettings` and `LocalizeManager`, and the resources `DOTweenSettings` and `TMP Settings` | `ui.json` and the object table | | `story` | a `MasterAdv` id | `stories/.json` | the episode key `Adv/Episode//` (``: the row's `_advEpisodeAsset`), its shard keys (the key with `-Text`, `-Sound`, `-SoundCueSheet`, `-Video`) and `EmbCommon/Adv/Settings/AdvPlayerSettings` | `episode.json` and `story.json` | `story.sound`, `story.crilips`, `story.video`, `story.chat` and `story` keep no objects: their files are all they @@ -416,6 +423,23 @@ chat message texts), `sounds` (their sound rows) and `cueSheets` (the cue sheet rows of its shard as stored; a shard key without a TextAsset gives `[]`. The `product` layout makes it when the selection holds the three shard keys. +`story.ui` is the ADV story UI in one client language: the ADV front canvas, the talk windows, the chat phone, the +dialogs and the settings, with the text records of their texts in that language. Its input `master` holds the +`MasterText` rows, each text in the language's column (`{"texts": {MasterText id: text}}`, the empty text when the row +has none). `ui.json` has `format` 1 and `language` (`code`, `mode` (the LanguageMode), `field` (the text column), +`lineSpacing`, `fontNames`, `additionalFontNames`, `materialTypes` and `fontSwap`, the Japanese font asset names mapped +to the language's), `widget`, `talkWindows` (each window's name to its object), `chatWidget`, `dialogs`, `uiManager`, +`letterbox`, `clips`, `controllers`, `settings` (`masterIdSettings`, `dotween`, `tmpSettings`, `localizeManager`), +`graphics`, `texts` (each TMP text by its component id: `localized`, the font asset, material and line spacing its +LocalizeText gives it in the language, or null, and `style`, its text record), `textStyle` (the language's field, the +units and the line metrics of the font roles the texts use), `masterIdTexts` (each `AdvMasterIdSettings` text with its +MasterText id and text), `chatStatusTexts` (incoming-call and lock-screen status keys to their texts), `bindings` +and `shaders`. Text styles use settings read from the client's font assets. The input `ui.objects` is the closure +of the UI roots; font assets and chat windows are additional assembly inputs for metrics and status text keys. +The retained object table is the UI closure, so the font assets' files remain in the depot. The input +`font-bundles` names the bundles holding the font assets, and `apk:AndroidManifest.xml` supplies the client version. +A client requests the product for each language it uses. + `story` is one episode. Its input `master` is the episode's master record: its `MasterAdv` row, the `MasterText` row of its title (none when `_titleTextId` is empty) and every `MasterAdvChat` row. `episode.json` is the episode as `export episode` writes it, each resource given by its kind and address: the command list with the lines of every diff --git a/docs/stages.md b/docs/stages.md index 68b59d1..3c6fda4 100644 --- a/docs/stages.md +++ b/docs/stages.md @@ -42,6 +42,7 @@ codec and channel of a USM) and `catalog.index` (version 2: gzip input and Japan | `story.asset` | a catalog key a story loads | `objects`: the object table of what the key reaches | `asset.json` and the object table | | `story.video` | a video key `Cri/Video/` | `objects`: the object table of the key; `usm:` and `movies`: the key's USM and its `cri.movie` files with their facts ([products](products.md#stories)); the `cri.movie` task takes `format` `webm` | `video.json` and `movie.webm` | | `story.chat` | `default` | `objects`: the object table of the shared chat shards | `chat.json` | +| `story.ui` | a client language (`ja`, `en`, `zh-Hant`, `zh-Hans`, `ko`) | `objects`: UI, font and chat-window objects; `ui.objects`: the retained UI closure; `master`: localized `MasterText` rows; `font-bundles`; `apk:AndroidManifest.xml` ([products](products.md#stories)) | `ui.json` (text styles and chat status texts) and the UI object table | | `story` | a `MasterAdv` id | `objects`: the object table of the episode's command list, its shards and the ADV player settings ([products](products.md#stories)); `master`: the episode's master record | `episode.json` and `story.json` | | `link.addresses` | global | catalog index, censuses (the built-in files' too) | key -> objects (a scene key: the objects of the scene's file outside a hierarchy), Resources.Load path -> objects, built-in selector (class, MonoBehaviour script class) -> objects, serialized file -> bundle or built-in file, bundle -> keys | | `view.` | a catalog (`main`) | `master:` per master table it reads, `rules` (the view's rule and the resolvers it uses), `addresses` (the part of the address table it read: the keys looked up and the prefixes listed); no parameters, atoms or context | `view.:#view` (facts: rows, entries, gaps, unreferenced) | diff --git a/scripts/gen_typetrees.py b/scripts/gen_typetrees.py index 6ce8f75..75f3ad3 100644 --- a/scripts/gen_typetrees.py +++ b/scripts/gen_typetrees.py @@ -2,10 +2,11 @@ nnnotes). nnnotes reads a few MonoBehaviours of the APK's boot data (`assets/bin/Data/data.unity3d`): the URP pipeline -assets, renderer data, renderer features and post-process data, DOTween and TextMeshPro settings, and the sound -and app-config defaults (the `apk/data.unity3d` tasks of `unity.export`, and PlayerData). IL2CPP player builds -strip script typetrees from that data, so nnnotes ships the typetrees of exactly these classes (CLASSES below) in -`src/nnnotes/unity/typetrees/.json`, one file per Unity version (read by nnnotes.unity.scripttypes). +assets, renderer data, renderer features and post-process data, DOTween and TextMeshPro settings, the sound and +app-config defaults and the localization manager (the `apk/data.unity3d` tasks of `unity.export`, and PlayerData). +IL2CPP player builds strip script typetrees from that data, so nnnotes ships the typetrees of exactly these classes +(CLASSES below) in `src/nnnotes/unity/typetrees/.json`, one file per Unity version (read by +nnnotes.unity.scripttypes). Each entry holds the typetree nodes as [level, type, name, meta flag] rows and the type hash the serialized file records for the class; the file also records the game version it was generated from. @@ -14,7 +15,9 @@ structs carry their native names (`Vector3f`, `ColorRGBA`, `AABB`, ...), and arrays of them, of strings and of PPtrs are `vector`; the align flag (0x4000, align the stream to 4 bytes after the node) sits where Unity puts it. unity_rows turns the rows of TypeTreeGeneratorAPI's default (AssetsTools) backend into that layout. The other meta -flag bits are editor-only and left 0. +flag bits are editor-only and left 0. A field after which the serialized bytes are aligned, although the generated +tree does not say so, is listed in ALIGNED_FIELDS; generate() sets its align flag and the read check of its class +verifies it. Supporting a new game version ----------------------------- @@ -56,6 +59,7 @@ ("Adv", "AdvSystem.Rendering", "AdvFieldRendererFeature"), ("App.Runtime", "App.Config", "AppConfigDefaultData"), ("DOTween.dll", "DG.Tweening.Core", "DOTweenSettings"), + ("Fwk", "Fwk.Localization", "LocalizeManager"), ("Fwk", "Fwk.Rendering", "ScreenCaptureRendererFeature"), ("Fwk", "Fwk.Sound", "SoundVolumeSettings"), ("Fwk", "Fwk.UI.Rendering", "UIRendererFeature"), @@ -76,6 +80,8 @@ ALIGN = 0x4000 # TransferMetaFlags.kAlignBytesFlag # the MonoBehaviour header: field -> meta flag HEADER = {"m_GameObject": 0, "m_Enabled": ALIGN, "m_Script": 0, "m_Name": 0} +# class -> its root fields after which the serialized bytes are aligned (the flag the generated tree leaves out) +ALIGNED_FIELDS = {"LocalizeManager": ("_dontDestroyOnload",)} # built-in structs the generator names after their C# type -> the native type name Unity serializes NATIVE_TYPES = { "Bounds": "AABB", @@ -196,6 +202,17 @@ def unity_rows(rows: list) -> list: return _rows(root) +def _aligned(rows: list, names: tuple) -> list: + """The rows with the align flag set on the root fields `names` (ALIGNED_FIELDS); ValueError for a missing one.""" + roots = {r[2] for r in rows if r[0] == 1} + if not set(names) <= roots: + raise ValueError(f"no root field {sorted(set(names) - roots)}") + for r in rows: + if r[0] == 1 and r[2] in names: + r[3] |= ALIGN + return rows + + def objects_by_class(player: PlayerData) -> dict[tuple[str, str, str], list]: out: dict[tuple[str, str, str], list] = {} for o in player._by_type.get("MonoBehaviour", []): @@ -222,6 +239,7 @@ def generate(player: PlayerData, gen: TypeTreeGenerator, game_version: str) -> d for n in gen.get_nodes(asm if asm.endswith(".dll") else f"{asm}.dll", name) ] ) + rows = _aligned(rows, ALIGNED_FIELDS.get(cls, ())) except ValueError as e: raise SystemExit(f"{name} ({asm}): {e}") from e node = typetree_node(rows) diff --git a/src/nnnotes/cli/assets.py b/src/nnnotes/cli/assets.py index 952930a..a95798f 100644 --- a/src/nnnotes/cli/assets.py +++ b/src/nnnotes/cli/assets.py @@ -133,6 +133,7 @@ def matches(self, stage: str) -> bool: StageSource("story.asset", "nnnotes.pipeline.story:AssetStage", output=True, product=True), StageSource("story.video", "nnnotes.pipeline.story:VideoStage", output=True, product=True), StageSource("story.chat", "nnnotes.pipeline.story:ChatStage", output=True, product=True), + StageSource("story.ui", "nnnotes.pipeline.story:UiStage", output=True, product=True), StageSource("story", "nnnotes.pipeline.story:StoryStage", output=True, product=True), StageSource("view.", "nnnotes.pipeline.views:stages", output=True), StageSource("view.voices", "nnnotes.pipeline.voices:VoiceStage", output=True), diff --git a/src/nnnotes/content/prefabs.py b/src/nnnotes/content/prefabs.py index 6569466..7d13e3a 100644 --- a/src/nnnotes/content/prefabs.py +++ b/src/nnnotes/content/prefabs.py @@ -49,6 +49,7 @@ "key_object", "mat4", "mesh_roles", + "mono_json", "prefab_ids", "ref_id", "root_game_object", @@ -68,8 +69,12 @@ def ids(self, cls: str | None = None) -> list[str]: ... def key(self, key: str) -> list[str]: ... + def keys(self) -> list[str]: ... + def info(self, oid: str) -> dict: ... + def fenced(self, oid: str) -> dict | None: ... + def bytes(self, oid: str, role: str) -> bytes: ... def json(self, oid: str, role: str = "json") -> Any: ... @@ -94,13 +99,28 @@ def has_json(objects: Objects, oid: str) -> bool: def script_class(objects: Objects, oid: str) -> str | None: - """The script class of a MonoBehaviour outside a hierarchy (its `json`'s `$script`), None without one.""" + """The script class of a MonoBehaviour or a contained component, None without one.""" + if oid in objects and objects.info(oid).get("in"): + file, pid = oid.rsplit(":", 1) + hit = component_ref(objects, "", {"$ref": {"file": file, "pathId": int(pid)}}) + return component_class(hit[2]) if hit is not None else None if not has_json(objects, oid): return None entry = objects.json(oid).get("$script") return entry.rsplit("|", 1)[-1] if isinstance(entry, str) else None +def mono_json(objects: Objects, oid: str) -> dict: + """The serialized fields of a MonoBehaviour, including a component stored in a prefab's document.""" + if objects.info(oid).get("in"): + file, pid = oid.rsplit(":", 1) + hit = component_ref(objects, "", {"$ref": {"file": file, "pathId": int(pid)}}) + if hit is None or hit[2]["type"] != "MonoBehaviour": + raise ValueError(f"{oid}: not a MonoBehaviour component") + return {"$script": hit[2].get("script"), **hit[2]["fields"]} + return objects.json(oid) + + def root_game_object(objects: Objects, what: str) -> str: """The one GameObject among the table's roots (ValueError naming `what` otherwise).""" gos = [o for o in objects.roots if o in objects and objects.info(o).get("class") == "GameObject"] diff --git a/src/nnnotes/content/storyui.py b/src/nnnotes/content/storyui.py new file mode 100644 index 0000000..71204e6 --- /dev/null +++ b/src/nnnotes/content/storyui.py @@ -0,0 +1,308 @@ +"""The ADV story UI of a product: what the ADV front canvas, its talk windows, the chat phone, the dialogs and the +settings draw with in one client language. One product (`ui`, ui.json format UI_FORMAT), read from a product's object +table (`Objects`, pipeline.assemble) the way storyscene reads the story scene: + + widget the GameObject of UIAdvWidget (WIDGET_KEY); its prefab document holds the drawn hierarchy + talkWindows {window name: GameObject} of the talk windows of the catalog (window_keys); the default window + UIDefaultTalkWindow is always one of them + chatWidget the GameObject of the phone of the chat rows (UIAdvChatWidget) + dialogs {name: GameObject} of the skip confirm dialog and the common dialog (DIALOG_KEYS) + uiManager the GameObject of UIManager (its camera controller and the UI camera) + letterbox the Sprite of the letterbox bands + clips {name: AnimationClip} of the ADV animations (CLIP_KEYS) + controllers {name: AnimatorController} of the ADV animations (CONTROLLER_KEYS) + settings the AdvMasterIdSettings, DOTweenSettings, TMP_Settings and LocalizeManager objects + graphics {graphics: GraphicsSettings} + language the client language: its code, LanguageMode, text field and line spacing, the font names and + material types LocalizeManager holds for it (textstyle.language_fonts_json) and the font swap + texts {TMP component id: {localized, style}} of every TMP text of the prefabs above. `localized` is + LocalizeText's font asset, material and line spacing (textstyle.localize_text), null when the + text has no enabled LocalizeText; `style` is the text record (textstyle.text_style) with the font + settings read from the client's font assets + textStyle the language's field, the units and the line metrics of the font roles the texts use + masterIdTexts the AdvMasterIdSettings texts of MASTER_ID_TEXTS: {field: {id, text}} + chatStatusTexts the incoming-call and lock-screen texts: {MasterText key: text} + bindings the animator bindings of the prefabs (livenotes.animator_bindings) + shaders {shader name: object id} (prefabs.shader_table) + +A text's font asset and material are named by the object the table holds for them, or by the class and name the table +fences them with (Objects.fenced): a reference across the font fence carries only its file and path id. + +Of the objects the document keeps all but the MonoScripts; of a shader its parsed form, variant index and the programs +of the player's graphics API; of a Mesh its typetree JSON and its arrays (prefabs.mesh_roles). + +The master record is {"texts": {MasterText id: text in the language's text field}}. +""" + +from __future__ import annotations + +from collections.abc import Iterable + +from ..source import languages +from ..unity import fontsettings, textstyle +from ..unity.ids import object_id, parse_object_id +from . import adv +from .livenotes import animator_bindings +from .prefabs import ( + Objects, + builtin_root, + component_class, + key_object, + mesh_roles, + mono_json, + ref_id, + script_class, + shader_table, +) + +UI_FORMAT = 1 # ui.json "format" +WIDGET_KEY = "EmbUI/Prefab/UIAdvWidget" +WINDOW_KEY = "EmbUI/Prefab/Parts/Adv/Talk/UIDefaultTalkWindow" # the default talk window (embedded address) +CHAT_WIDGET_KEY = "EmbUI/Prefab/UIAdvChatWidget" +UI_MANAGER_KEY = "EmbCommon/Prefab/UIManager" +LETTERBOX_KEY = "Textures/letterbox-image" +MASTER_ID_SETTINGS_KEY = "EmbCommon/Adv/Settings/AdvMasterIdSettings" +DIALOG_KEYS = { + "UIAdvSkipConfirmDialogWidget": "EmbUI/Prefab/UIAdvSkipConfirmDialogWidget", + "UICommonDialogWidget": "EmbUI/Prefab/UICommonDialogWidget", +} +CLIP_KEYS = { + "NextIndicator/Loop": "EmbUI/Animations/NextIndicator/Loop", + "AutoNext/Loop": "EmbUI/Animations/Auto/Loop", + "AdvLocation/Play": "EmbUI/Animations/AdvLocation/Play", + "AdvTitle/Play": "EmbUI/Animations/AdvTitle/Play", +} +CONTROLLER_KEYS = { + "NextIndicator": "EmbUI/Animations/NextIndicator/NextIndicator", + "AutoNext": "EmbUI/Animations/Auto/AutoNext", + "AdvLocation": "EmbUI/Animations/AdvLocation/AdvLocation", + "AdvTitle": "EmbUI/Animations/AdvTitle/AdvTitle", +} +# AdvMasterIdSettings fields of the texts the drawn parts show: the video skip dialog, the skip confirm dialog and the +# interruption dialog +MASTER_ID_TEXTS = ( + "_skipVideoMessageTextId", + "_skipButtonTextId", + "_cancelButtonTextId", + "_skipMessageTextId", + "_continuousSkipMessageTextId", + "_backEpisodeListButtonTextId", + "_continueEpisodeButtonTextId", + "_interruptionTitleTextId", + "_interruptionMessageTextId", + "_interruptionButtonTextId", +) +TEXT_CLASSES = ("TextMeshProUGUI", "RubyTextMeshProUGUI", "RubyEmojiTextMeshProUGUI") +# the built-in objects and the Resources.Load paths of the document (ProductStage.builtins, ProductStage.resources) +BUILTINS = ("GraphicsSettings", "MonoBehaviour:LocalizeManager") +RESOURCES = ("DOTweenSettings", "TMP Settings") +CHAT_STATUS_KEYS = ("_incomingCallStatusTextKey", "_lockScreenStatusTextKey") + + +def window_keys(catalog: Iterable[str]) -> dict[str, str]: + """{talk window name: prefab key} of the talk windows of the catalog keys `catalog`: the names under the talk window + prefix or its embedded form, each with its plain key when the catalog has that and else its embedded key (the + rule of content.advui.window_key), and the default window.""" + keys = set(catalog) + prefixes = (adv.TALK_WINDOW_PREFIX, adv.EMBEDDED_PREFIX + adv.TALK_WINDOW_PREFIX) + names = {k[len(p) :] for k in keys for p in prefixes if k.startswith(p)} | {adv.DEFAULT_TALK_WINDOW} + out = {} + for name in sorted(names): + if name == adv.DEFAULT_TALK_WINDOW: + out[name] = WINDOW_KEY + else: + address = adv.TALK_WINDOW_PREFIX + name + out[name] = address if address in keys else adv.EMBEDDED_PREFIX + address + return out + + +def keys(catalog: Iterable[str]) -> list[str]: + """The catalog keys of the story UI in the catalog `catalog` (its keys): the widget, the chat widget, UIManager, the + letterbox, the master id settings, the dialogs, the animations and the talk windows (window_keys), sorted.""" + return sorted( + { + WIDGET_KEY, + CHAT_WIDGET_KEY, + UI_MANAGER_KEY, + LETTERBOX_KEY, + MASTER_ID_SETTINGS_KEY, + *DIALOG_KEYS.values(), + *CLIP_KEYS.values(), + *CONTROLLER_KEYS.values(), + *window_keys(catalog).values(), + } + ) + + +def font_keys(catalog: Iterable[str]) -> list[str]: + """The keys of font assets and materials under the client's font folders, sorted.""" + return sorted({k for k in catalog if k.startswith((fontsettings.FONT_PREFIX, fontsettings.FONT_ADDRESS))}) + + +def chat_window_keys(catalog: Iterable[str]) -> list[str]: + """The chat-window prefab keys from which the shared status-text keys are read, sorted.""" + return sorted({k for k in catalog if k.startswith(adv.CHAT_WINDOW_PREFIX)}) + + +def chat_status_texts(objects: Objects, texts: dict) -> dict[str, str]: + """The incoming-call and lock-screen texts of the chat windows in `objects`, resolved in `texts`.""" + wanted = set() + for key in chat_window_keys(objects.keys()): + prefab = key_object(objects, key, "GameObject") + for node in objects.json(prefab)["nodes"]: + for comp in node["components"]: + if component_class(comp) == "AdvChatWindow": + wanted.update(str(comp["fields"][k]) for k in CHAT_STATUS_KEYS if comp["fields"].get(k)) + return {k: texts[k] for k in sorted(wanted)} + + +def ui( + objects: Objects, + language: str, + master: dict, + fonts: fontsettings.FontSettings, + *, + status_texts: dict[str, str] | None = None, +) -> tuple[dict, set[str], dict[str, set[str]]]: + """(ui.json, the object ids the player reads, {object id: the roles of its files it reads}) of the story UI in the + client language `language` (module documentation).""" + code = languages.check(language) + mode, field = languages.mode(code), languages.column(code)[1:] + localize = script_root(objects, "LocalizeManager") + lang = textstyle.language_fonts_json(mono_json(objects, localize)) + styles = _Styles(lang, mode, fonts) + widget = key_object(objects, WIDGET_KEY, "GameObject") + windows = {name: key_object(objects, key, "GameObject") for name, key in window_keys(objects.keys()).items()} + chat = key_object(objects, CHAT_WIDGET_KEY, "GameObject") + ui_manager = key_object(objects, UI_MANAGER_KEY, "GameObject") + dialogs = {name: key_object(objects, key, "GameObject") for name, key in DIALOG_KEYS.items()} + texts = {} + for prefab in (widget, *windows.values(), chat, *dialogs.values(), ui_manager): + texts.update(_texts(objects, prefab, styles)) + settings = key_object(objects, MASTER_ID_SETTINGS_KEY, "AdvMasterIdSettings") + graphics = builtin_root(objects, "GraphicsSettings") + always = (ref_id(r) for r in objects.json(graphics).get("m_AlwaysIncludedShaders", ())) + shaders, roles = shader_table(objects, found=[o for o in always if o is not None]) + doc = { + "format": UI_FORMAT, + "language": { + "code": code, + "mode": mode, + "field": field, + "lineSpacing": textstyle.LANGUAGE_LINE_SPACING[mode], + "fontNames": lang["languages"][mode]["fontNames"], + "additionalFontNames": lang["languages"][mode]["additionalFontNames"], + "materialTypes": lang["materialTypes"], + "fontSwap": styles.swap, + }, + "widget": widget, + "talkWindows": windows, + "chatWidget": chat, + "dialogs": dialogs, + "uiManager": ui_manager, + "letterbox": key_object(objects, LETTERBOX_KEY, "Sprite"), + "clips": {name: key_object(objects, key, "AnimationClip") for name, key in CLIP_KEYS.items()}, + "controllers": {name: key_object(objects, key, "AnimatorController") for name, key in CONTROLLER_KEYS.items()}, + "settings": { + "masterIdSettings": settings, + "dotween": script_root(objects, "DOTweenSettings"), + "tmpSettings": script_root(objects, "TMP_Settings"), + "localizeManager": localize, + }, + "graphics": {"graphics": graphics}, + "texts": texts, + "textStyle": styles.summary(), + "masterIdTexts": master_id_texts(objects.json(settings), master["texts"]), + "chatStatusTexts": status_texts if status_texts is not None else chat_status_texts(objects, master["texts"]), + "bindings": animator_bindings(objects), + "shaders": shaders, + } + keep = {o for o in objects.ids() if objects.info(o).get("class") != "MonoScript"} + roles.update(mesh_roles(objects, keep)) + return doc, keep, roles + + +def master_id_texts(settings: dict, texts: dict) -> dict: + """masterIdTexts: {AdvMasterIdSettings field of MASTER_ID_TEXTS: {id (MasterText id), text}}; `texts` is the master + record's {MasterText id: text} (JSON keys are strings). A missing id raises KeyError.""" + return {k: {"id": settings[k], "text": texts[str(settings[k])]} for k in MASTER_ID_TEXTS} + + +class _Styles: + """The text records of one language: LocalizeText's resolution of a text, the style values of its material, and the + line metrics of the font roles the texts use.""" + + def __init__(self, lang: dict, mode: int, fonts: fontsettings.FontSettings): + self.lang, self.mode, self.fonts = lang, mode, fonts + self.swap = textstyle.font_swap(lang, mode) + self.roles: dict[str, dict] = {} + self._styles: dict[tuple, dict] = {} + + def _face(self, font_asset: str) -> tuple[dict, dict]: + f = self.fonts.font(font_asset) + return f["faceInfo"], {k: f[k] for k in ("normalSpacingOffset", "boldSpacing")} + + def record(self, path: str, comp: dict, localize: dict | None) -> dict: + """{localized, style} of the TMP text `comp` (serialized fields, font and material named) with its LocalizeText + `localize` (or None).""" + fa, mat = comp["m_fontAsset"]["name"], comp["m_sharedMaterial"]["material"] + localized = None + if localize and localize["m_Enabled"] and localize["_localizeEnabled"]: + localized = textstyle.localize_text(path, fa, mat, self.swap, self.lang, self.mode) + fa, mat = localized["fontAsset"], localized["material"] + if (fa, mat) not in self._styles: + face, _ = self._face(fa) + self._styles[(fa, mat)] = textstyle.material_style( + self.fonts.material(fa, mat), face["m_PointSize"], face["m_Scale"] + ) + style = textstyle.text_style(comp, localize, self.lang, self._styles[(fa, mat)], self.mode) + if style["fontRole"] not in self.roles: + face, extra = self._face(fa) + self.roles[style["fontRole"]] = textstyle.face_metrics(face, extra) + return {"localized": localized, "style": style} + + def summary(self) -> dict: + return {"language": textstyle.LANGUAGE_FIELDS[self.mode], "units": textstyle.UNITS, "roles": self.roles} + + +def _texts(objects: Objects, prefab: str, styles: _Styles) -> dict[str, dict]: + """{TMP component id: {localized, style}} of the TMP texts of the prefab document of GameObject `prefab`. + A component id is the prefab's file and the component's path id.""" + file, _ = parse_object_id(prefab) + out = {} + for n in objects.json(prefab)["nodes"]: + tmp = [c for c in n["components"] if component_class(c) in TEXT_CLASSES] + loc = [c for c in n["components"] if component_class(c) == "LocalizeText"] + if not tmp: + continue + if len(tmp) > 1 or len(loc) > 1: + raise ValueError(f"{n['path']}: {len(tmp)} TMP texts and {len(loc)} LocalizeText components") + comp = {"class": component_class(tmp[0]), **tmp[0]["fields"]} + comp["m_fontAsset"] = {"name": _name(objects, comp["m_fontAsset"])} + comp["m_sharedMaterial"] = {"material": _name(objects, comp["m_sharedMaterial"])} + localize = {"class": "LocalizeText", **loc[0]["fields"]} if loc else None + out[object_id(file, int(tmp[0]["pathId"]))] = styles.record(n["path"], comp, localize) + return out + + +def _name(objects: Objects, ref) -> str: + """The name of the object a reference names: from the table, or from the class and name the table fences it with.""" + oid = ref_id(ref) + if oid is None: + raise ValueError(f"not a reference to an object: {ref!r}") + hit = objects.info(oid) if oid in objects else objects.fenced(oid) + if hit is None or not hit.get("name"): + raise ValueError(f"{oid}: no name in the object table") + return hit["name"] + + +def script_root(objects: Objects, script: str) -> str: + """The one MonoBehaviour root of the script class `script` (ValueError otherwise).""" + hits = [ + o + for o in objects.roots + if o in objects and objects.info(o).get("class") == "MonoBehaviour" and script_class(objects, o) == script + ] + if len(hits) != 1: + raise ValueError(f"{len(hits)} {script} roots") + return hits[0] diff --git a/src/nnnotes/pipeline/assemble.py b/src/nnnotes/pipeline/assemble.py index 586b943..951013e 100644 --- a/src/nnnotes/pipeline/assemble.py +++ b/src/nnnotes/pipeline/assemble.py @@ -8,10 +8,12 @@ every unit an item of a reached unit references (contract.item `refs`; an atlas reached through a reference does not reach the sprites it packs, units.PACKED), through the unity.export results; a sprite whose atlas is in another bundle has its image from sprite.crop. A reached unit that the request did not export is an error of the request; a -referenced object that no census holds is listed as missing. +referenced object that no census holds is listed as missing. A referenced object of a fenced bundle (units.fences) that +the walk does not reach is listed as fenced. A task's inputs are the object table of what it reaches ("objects", nnnotes.objects/1: every object with its class, -name, item status and files, the contained ones with the object holding them, and the root objects of each of its keys), +name, item status and files, the contained ones with the object holding them, the root objects of each of its keys, +and the font objects the fence cuts off as "fenced"), the APK files the stage names in `APK` ("apk:", from the fact "apk"), the cue sheets of the catalog keys `cue_sheets(subject)` gives, the movies of the catalog keys `movies(subject)` gives and what `more_inputs` adds (a master record as "master": master_input; parameters' data); it depends on the unity.export and sprite.crop tasks it @@ -166,6 +168,7 @@ def reach(env, roots, results: _Results | None = None, keys: dict | None = None) objects: dict[str, dict] = {} read = [] crops: dict[str, list] = {} # atlas object id -> sprite object ids + blocked: dict[str, dict] = {} # objects a reached object references across the font fence: {id: class, name} while todo: s = todo.pop() if s in seen: @@ -186,10 +189,23 @@ def reach(env, roots, results: _Results | None = None, keys: dict | None = None) t = table.subject_of(r) if t is None: missing.add(r) + elif table.fences(s, t): + blocked[r] = table.info[r] elif t not in seen and table.reaches(s, t, s in start): todo.append(t) for atlas, sprites in sorted(crops.items()): s = table.subject_of(atlas) + # a sprite of an unfenced bundle takes no image from a fenced atlas the walk does not reach + cut = { + o + for o in sprites + if s is not None and s not in seen and (src := table.subject_of(o)) is not None and table.fences(src, s) + } + if cut: + blocked[atlas] = table.info[atlas] + sprites = [o for o in sprites if o not in cut] + if not sprites: + continue tid = contract.task_id(CROP, s) if s is not None else None if tid is None or not env.known(tid): # the atlas is not exported: its sprites have no image missing.add(atlas) @@ -205,6 +221,7 @@ def reach(env, roots, results: _Results | None = None, keys: dict | None = None) "roots": sorted(set(roots)), "keys": {k: sorted(v) for k, v in sorted((keys or {}).items())}, "objects": dict(sorted(objects.items())), + "fenced": {r: v for r, v in sorted(blocked.items()) if r not in objects}, "missing": sorted(missing), } size = sum(f["size"] for e in objects.values() for f in e.get("files", {}).values()) @@ -287,6 +304,7 @@ def __init__(self, doc: dict, depot, sheets: dict | None = None, movies: dict | self.roots: list[str] = doc["roots"] self._keys: dict[str, list[str]] = doc["keys"] self._objects: dict = doc["objects"] + self._fenced: dict = doc["fenced"] self._json: dict = {} self._sheets: dict[str, Sheet] = sheets or {} self._movies: dict[str, Movie] = movies or {} @@ -312,6 +330,10 @@ def key(self, key: str) -> list[str]: except KeyError: raise KeyError(f"{key} is not a key of the product") from None + def keys(self) -> list[str]: + """The catalog keys of the product, sorted (each with its root objects, possibly none).""" + return sorted(self._keys) + def __contains__(self, oid: str) -> bool: return oid in self._objects @@ -325,6 +347,11 @@ def info(self, oid: str) -> dict: except KeyError: raise KeyError(f"object {oid} is not in the product's object table") from None + def fenced(self, oid: str) -> dict | None: + """The class and name of the object `oid` when a reached object references it across the font fence (None: + no reached object does).""" + return self._fenced.get(oid) + def bytes(self, oid: str, role: str) -> bytes: files = self.info(oid).get("files") or {} if role not in files: @@ -344,11 +371,13 @@ class Assembly: """What `assemble` makes: the product's files {path: (bytes or Stored, extension, kind)} and what of the object table they use: `objects` the object ids (None: every object of the table), `roles` {object id: the roles of its files the product keeps} for an object of which it uses some files only (a shader's programs for one graphics - API). The product's object table, and so its manifest, holds only those.""" + API). `table` optionally supplies the table of the retained roots when the inputs also include objects read only + during assembly. The product's object table, and so its manifest, holds only those.""" files: dict objects: set | None = None roles: dict = field(default_factory=dict) + table: dict | None = None def _prune(tid: str, objects: dict, made: Assembly) -> dict: @@ -447,7 +476,9 @@ def reach(self, subject: str, env) -> Reach: keys = self.keys(subject, env) named = (self.root_objects(subject, env), self.resources(subject, env), self.builtins(subject, env)) if not keys and not any(named): - return Reach({"schema": contract.OBJECTS, "roots": [], "keys": {}, "objects": {}, "missing": []}) + return Reach( + {"schema": contract.OBJECTS, "roots": [], "keys": {}, "objects": {}, "fenced": {}, "missing": []} + ) roots = units.root_objects(env, keys, *named) by_key = {k: units.root_objects(env, [k]) for k in keys} sig = (tuple(roots), tuple((k, tuple(v)) for k, v in sorted(by_key.items())), id(units.table(env))) @@ -546,7 +577,9 @@ def run(self, task, depot) -> Output: else: content = depot.add(body, ext) arts.append(contract.artifact(aid, content, prov, {"kind": kind})) - if made.objects is not None or made.roles: + if made.table is not None: + doc = made.table + if made.table is not None or made.objects is not None or made.roles: doc = dict(doc, objects=_prune(task.id, doc["objects"], made)) data = contract.encode(doc) aid = contract.artifact_id(task.id, PRODUCT_TABLE) diff --git a/src/nnnotes/pipeline/cristages.py b/src/nnnotes/pipeline/cristages.py index 3991e02..1c1eb8f 100644 --- a/src/nnnotes/pipeline/cristages.py +++ b/src/nnnotes/pipeline/cristages.py @@ -289,7 +289,7 @@ class AudioStage(_CriStage): name = AUDIO version = 1 - after = (EXPORT,) + after = (EXPORT, units.ADDRESSES) CLASS = "ACB" PARAMS = {"flac": {"level": FLAC_LEVEL}} @@ -475,7 +475,7 @@ class MovieStage(_CriStage): name = MOVIE version = 2 - after = (EXPORT,) + after = (EXPORT, units.ADDRESSES) CLASS = "USM" PARAMS = {"format": "mkv", "flac": {"level": FLAC_LEVEL}} diff --git a/src/nnnotes/pipeline/link.py b/src/nnnotes/pipeline/link.py index cf4294a..3e599f6 100644 --- a/src/nnnotes/pipeline/link.py +++ b/src/nnnotes/pipeline/link.py @@ -17,8 +17,8 @@ null when a dependency bundle has no census resources {resource path: [object ids]}: the paths of the ResourceManager container of the built-in files (what Resources.Load finds, as stored), the objects each names in stored order - builtins {selector: [object ids]}: the objects of the built-in files (not MonoScripts, a hierarchy by its root - GameObject alone) by class, "", and a MonoBehaviour by its script's class, + builtins {selector: [object ids]}: the objects of the built-in files (not MonoScripts) by class, "", and + a MonoBehaviour, including a component of a hierarchy, by its script's class, "MonoBehaviour:" (its MonoScript in the built-in files), each list sorted objects {stable address: {object, class, name, byteSize, typeHash}} of the objects a container path, a scene or a resource path names @@ -242,7 +242,7 @@ def builtin_selectors(docs) -> dict[str, list[str]]: for doc in docs: for file, o in census.objects(doc): oid = contract.object_id(file, o["pathId"]) - if o["class"] == "MonoScript" or "unit" in o: + if o["class"] == "MonoScript" or ("unit" in o and o["class"] != "MonoBehaviour"): continue if o["class"] == "MonoBehaviour": s = o.get("script") @@ -335,7 +335,7 @@ class AddressesStage(Stage): skips failed ones. Artifact "link.addresses:#addresses".""" name = "link.addresses" - version = 4 + version = 5 after = ("catalog.index", "unity.census") def subjects(self, env) -> list[str]: diff --git a/src/nnnotes/pipeline/objexport.py b/src/nnnotes/pipeline/objexport.py index 1644ff1..e303232 100644 --- a/src/nnnotes/pipeline/objexport.py +++ b/src/nnnotes/pipeline/objexport.py @@ -382,7 +382,7 @@ class ExportStage(Stage): name = EXPORT version = 2 - after = (CENSUS, SCRIPTS) + after = (CENSUS, SCRIPTS, units.ADDRESSES) PARAMS = {"blobMin": 4096, "png": {"level": 6}} def __init__(self, reader: Impl | None = None): @@ -517,7 +517,7 @@ class SpriteCropStage(Stage): name = CROP version = 1 - after = (EXPORT,) + after = (EXPORT, units.ADDRESSES) PARAMS = {"png": {"level": 6}} CROP_ATOMS = ("png.encode", "sprite.crop") diff --git a/src/nnnotes/pipeline/story.py b/src/nnnotes/pipeline/story.py index 0257911..a9503b9 100644 --- a/src/nnnotes/pipeline/story.py +++ b/src/nnnotes/pipeline/story.py @@ -37,6 +37,15 @@ story.chat subject `default`: "#chat.json" (kind story.chat: content.storychat.chat, the rows of the shared chat shards). Its manifest is storychat/default.json. +story.ui subject a client language (`ja`, `en`, `zh-Hant`, `zh-Hans`, `ko`): "#ui.json" (kind story.ui: + content.storyui.ui, the ADV front canvas, talk windows, chat phone, dialogs and settings with the text + records in that language) and the object table the player reads. Its roots are the catalog keys of the + story UI (storyui.keys), the built-in settings (storyui.BUILTINS) and the resources (storyui.RESOURCES); + its input "master" the MasterText rows, each in the language's text column. Font assets and chat + windows are also read for text styles and status texts; the retained table is the closure of the UI + roots (input "ui.objects"). Its manifest is + storyui/.json. A client requests it for each language it uses. + story subject a MasterAdv id: "#episode.json" (kind story.episode: content.storyepisode.episode_json, the command list with its text, sounds, videos and resource closure) and "#story.json" (kind story: content.storyepisode.story, the episode's chat rows, its effect instances and `parts`, the @@ -56,14 +65,19 @@ import tempfile from pathlib import Path -from ..content import liveaudio, storyasset, storychat, storyepisode, storyscene, storyvideo +from ..content import liveaudio, storyasset, storychat, storyepisode, storyscene, storyui, storyvideo from ..content.livescene import GRAPHICS +from ..content.prefabs import mono_json, script_class from ..formats import acb, crilips from ..formats.encoders import DEFAULT_AUDIO_FORMAT, WEB_AUDIO, mp4_priming, web_encode +from ..source import languages +from ..source.apkset import MANIFEST_IN_APK, manifest_version_name +from ..unity import fontsettings, textstyle from ..unity.atoms import Unsupported -from . import contract -from .assemble import MASTER_INPUT, Assembly, ProductStage, master_input -from .contract import IncompatibleTask +from ..unity.ids import parse_object_id +from . import contract, units +from .assemble import MASTER_INPUT, Assembly, Objects, ProductStage, master_input, reach +from .contract import IncompatibleTask, Input from .layout import escape_segment from .live import encode_atoms from .live2d import MODEL, ModelStage @@ -84,13 +98,15 @@ "SceneStage", "SoundStage", "StoryStage", + "UI", + "UiStage", "VideoStage", "cues", "part_products", ] SOUND, CRILIPS, SCENE, ASSET = "story.sound", "story.crilips", "story.scene", "story.asset" -CHAT, STORY, VIDEO = "story.chat", "story", "story.video" +CHAT, STORY, UI, VIDEO = "story.chat", "story", "story.ui", "story.video" DEFAULT = "default" SHEET_PREFIX = "Cri/Sound/" LIBRARY = crilips.LIB_ENTRY_SUFFIX @@ -317,6 +333,104 @@ def assemble(self, task, objects) -> Assembly: return Assembly({"chat.json": (contract.encode(doc), "json", CHAT)}, keep, roles) +def _language(subject: str) -> str: + if subject not in languages.LANGUAGES: + raise ValueError(f"{subject!r}: not a client language ({', '.join(languages.LANGUAGES)})") + return subject + + +class UiStage(ProductStage): + """story.ui: the ADV story UI in one client language (module documentation).""" + + name = UI + version = 1 + KIND = "StoryUi" + MASTER = ("MasterText",) + APK = (MANIFEST_IN_APK,) + + def ui_keys(self, subject: str, env) -> list[str]: + _language(subject) + return storyui.keys({loc["primaryKey"] for loc in env.fact("index")["locations"]}) + + def keys(self, subject: str, env) -> list[str]: + _language(subject) + catalog = {loc["primaryKey"] for loc in env.fact("index")["locations"]} + return sorted({*self.ui_keys(subject, env), *storyui.font_keys(catalog), *storyui.chat_window_keys(catalog)}) + + def resources(self, subject: str, env) -> list[str]: + _language(subject) + return list(storyui.RESOURCES) + + def builtins(self, subject: str, env) -> list[str]: + _language(subject) + return list(storyui.BUILTINS) + + def record(self, subject: str, env) -> dict: + """The master record of the language: the text of every MasterText row in its text column.""" + column = languages.column(_language(subject)) + rows = master_rows(env, self.MASTER)["MasterText"] + return {"texts": {str(r["_id"]): r.get(column) or "" for r in rows}} + + def more_inputs(self, subject: str, env) -> list: + keys = self.ui_keys(subject, env) + roots = units.root_objects( + env, keys, resources=self.resources(subject, env), builtins=self.builtins(subject, env) + ) + by_key = {k: units.root_objects(env, [k]) for k in keys} + ui_table = reach(env, roots, self._results, by_key).table + objects = Objects(self.reach(subject, env).table, env.depot) + font_files = set(_font_files(objects).values()) + bundles = {} + for catalog in sorted(env.fact("catalogs")): + addresses = units.address_doc(env, catalog) + bundles.update( + {f: addresses["bundles"][b]["name"] for f, b in addresses["files"].items() if f in font_files} + ) + return [ + master_input(env, self.record(subject, env)), + _record_input(env, "ui.objects", ui_table), + _record_input(env, "font-bundles", bundles), + ] + + def manifest(self, subject: str) -> str: + return f"storyui/{_language(subject)}.json" + + def assemble(self, task, objects) -> Assembly: + from ..content import tmpassets + + record = contract.loads(objects.depot.input_bytes(task.input(MASTER_INPUT))) + ui_table = contract.loads(objects.depot.input_bytes(task.input("ui.objects"))) + ui_objects = Objects(ui_table, objects.depot) + localize = storyui.script_root(ui_objects, "LocalizeManager") + lang = textstyle.language_fonts_json(mono_json(ui_objects, localize)) + bundles = contract.loads(objects.depot.input_bytes(task.input("font-bundles"))) + font_files = _font_files(objects) + game_version = manifest_version_name(objects.depot.input_bytes(task.input(f"apk:{MANIFEST_IN_APK}"))) + if game_version is None: + raise ValueError("story.ui: the APK manifest has no version name") + settings = tmpassets.build_font_settings(objects, lang, game_version, lambda name: bundles[font_files[name]]) + status = storyui.chat_status_texts(objects, record["texts"]) + doc, keep, roles = storyui.ui( + ui_objects, task.subject, record, fontsettings.FontSettings(settings), status_texts=status + ) + return Assembly({"ui.json": (contract.encode(doc), "json", UI)}, keep, roles, ui_table) + + +def _record_input(env, role: str, record: dict) -> Input: + """A JSON record read by a product, stored as a content-addressed input.""" + body = contract.encode(record) + return Input(role, env.depot.put(body), len(body), None, ({"kind": "depot"},)) + + +def _font_files(objects: Objects) -> dict[str, str]: + """The serialized file holding each TMP font asset, by asset name.""" + return { + objects.info(o)["name"]: parse_object_id(o)[0] + for o in objects.ids("MonoBehaviour") + if script_class(objects, o) == fontsettings.FONT_CLASS + } + + def _adv_id(subject: str) -> int: if not subject.isdigit(): raise ValueError(f"{subject}: not an episode id") diff --git a/src/nnnotes/pipeline/units.py b/src/nnnotes/pipeline/units.py index 04a4073..e359965 100644 --- a/src/nnnotes/pipeline/units.py +++ b/src/nnnotes/pipeline/units.py @@ -19,6 +19,12 @@ APK's built-in files, compared ignoring case; a selector names objects of the built-in files by class or script class; one without objects adds none) and the objects named. The products of a request start from them (pipeline.assemble). + +The font fence: the bundles holding a catalog key under a font folder (unity.fontsettings: "EmbFont/" and "Font/"; +the bundles `font_bundles` finds in the address tables of link.addresses) are fenced. A reference of a unit of an +unfenced bundle into a fenced one does not reach that unit, neither from a root nor from a unit the walk reaches; a +unit of a fenced bundle reaches what it references as usual, fenced bundles included (a font asset's fallbacks). A +product's object table lists the objects its reached objects reference this way (`fenced`, pipeline.assemble). """ from __future__ import annotations @@ -26,12 +32,14 @@ from collections import Counter from dataclasses import dataclass +from ..unity.fontsettings import FONT_ADDRESS, FONT_PREFIX from . import contract, link, views from .census import CensusStage from .stages import Pending CENSUS = CensusStage.name ADDRESSES = views.ADDRESSES_STAGE +FONT_FOLDERS = (FONT_PREFIX, FONT_ADDRESS) # the catalog keys of the font folders: the bundles holding them are fenced # a unit's root class -> the class of the units it packs, which it does not reach when it is reached itself PACKED = {"SpriteAtlas": "Sprite"} @@ -61,12 +69,23 @@ def classes(self) -> tuple: class Units: """The units of a request: `units` {subject: Unit} of the requested units; `of` {object id: subject} of the objects of every unit of the censuses read; `files` {serialized file: stable bundle name}; `objects` {stable - bundle name: its objects}; `kinds` {subject: root class} of every unit of the censuses read; `per_bundle` the number + bundle name: its objects}; `kinds` {subject: root class} of every unit of the censuses read; `fenced` the subjects + of the units of the fenced bundles and `info` {object id: {class, name}} of their objects; `per_bundle` the number of requested units per bundle; `classes` the objects of the requested units per class.""" - def __init__(self, units: dict, of: dict, files: dict, objects: dict, kinds: dict | None = None): + def __init__( + self, + units: dict, + of: dict, + files: dict, + objects: dict, + kinds: dict | None = None, + fenced: frozenset = frozenset(), + info: dict | None = None, + ): self.units, self.of, self.files, self.objects = units, of, files, objects self.kinds: dict[str, str] = kinds if kinds is not None else {s: u.cls for s, u in units.items()} + self.fenced, self.info = frozenset(fenced), info or {} self.per_bundle = Counter(u.bundle for u in units.values()) self.classes: Counter = Counter() for u in units.values(): @@ -88,13 +107,25 @@ def subject_of(self, oid: str) -> str | None: def reaches(self, source: str, target: str, start: bool) -> bool: """A reference of the unit `source` to the unit `target` makes the target reachable (_reaches).""" - return _reaches(self.kinds, source, target, start) + return _reaches(self.kinds, self.fenced, source, target, start) + + def fences(self, source: str, target: str) -> bool: + """The font fence cuts a reference of the unit `source` to the unit `target` (_fences).""" + return _fences(self.fenced, source, target) + +def _fences(fenced, source: str, target: str) -> bool: + """The font fence: a reference of an unfenced unit into a fenced one does not reach it (`fenced`: the subjects of + the units of the fenced bundles).""" + return target in fenced and source not in fenced -def _reaches(kinds: dict, source: str, target: str, start: bool) -> bool: + +def _reaches(kinds: dict, fenced, source: str, target: str, start: bool) -> bool: """A reference of the unit `source` to the unit `target` ({subject: root class} `kinds`) makes the target - reachable: always from a unit the walk starts from (`start`), else unless the source packs units of the target's - class (PACKED).""" + reachable: never across the font fence (_fences), always from a unit the walk starts from (`start`), else unless + the source packs units of the target's class (PACKED).""" + if _fences(fenced, source, target): + return False return start or PACKED.get(kinds.get(source, "")) != kinds.get(target) @@ -155,16 +186,27 @@ def _census_units(stable: str, doc: dict, need_refs: bool): return units, of, {f["name"]: stable for f in doc["files"]}, refs -def build(censuses, bundles, selected, classes=None, roots=()) -> Units: +def _census_info(doc: dict) -> dict: + """{object id: {class, name}} of the objects of a census document (an object without m_Name has an empty name).""" + return { + contract.object_id(f["name"], o["pathId"]): {"class": o["class"], "name": o.get("name") or ""} + for f in doc["files"] + for o in f["objects"] + } + + +def build(censuses, bundles, selected, classes=None, roots=(), fenced=()) -> Units: """The Units of a request over `censuses` [(stable bundle name, census document)] of the bundles `bundles`, from - the units of the bundles `selected` and of the objects `roots` (module documentation).""" - selected = set(selected) + the units of the bundles `selected` and of the objects `roots`; `fenced` the stable names of the fenced bundles + (module documentation).""" + selected, fenced = set(selected), set(fenced) every = selected >= set(bundles) # no reference can add a unit: none is read units: dict[str, Unit] = {} of: dict[str, str] = {} files: dict[str, str] = {} refs: dict[str, set] = {} objects: dict[str, int] = {} + info: dict[str, dict] = {} for stable, doc in censuses: objects[stable] = int(doc["objects"]) u, o, f, r = _census_units(stable, doc, need_refs=not every) @@ -172,7 +214,10 @@ def build(censuses, bundles, selected, classes=None, roots=()) -> Units: of.update(o) files.update(f) refs.update(r) + if stable in fenced: + info.update(_census_info(doc)) kinds = {s: u.cls for s, u in units.items()} + fenced_units = frozenset(s for s, u in units.items() if u.bundle in fenced) if every: want = set(units) else: @@ -184,30 +229,63 @@ def build(censuses, bundles, selected, classes=None, roots=()) -> Units: source = todo.pop() for oid in refs.get(source, ()): s = of.get(oid) - if s is not None and s not in want and _reaches(kinds, source, s, source in start): + if s is not None and s not in want and _reaches(kinds, fenced_units, source, s, source in start): want.add(s) todo.append(s) if classes is not None: keep = set(classes) want = {s for s in want if units[s].cls in keep} - return Units({s: units[s] for s in sorted(want)}, of, files, objects, kinds) + return Units({s: units[s] for s in sorted(want)}, of, files, objects, kinds, fenced_units, info) _MEMO: list = [] # [(signature, Units)] of the last table -_ADDRESSES: dict = {} # {sha256: AddressIndex} of the last address table read +_DOCS: dict = {} # {sha256: address table} of the last one read +_INDEXES: dict = {} # {sha256: AddressIndex} of the last one indexed +_FENCE: list = [] # [(content ids of the address tables, fenced bundles)] of the last call of font_bundles + + +def address_sha(env, subject: str) -> str: + """The content id of the address table of link.addresses for the catalog `subject` (Pending while not run).""" + return env.artifact(contract.task_id(ADDRESSES, subject), "addresses")["content"]["sha256"] + + +def address_doc(env, subject: str) -> dict: + """The address table (nnnotes.addresses/3) of the catalog `subject` (Pending while link.addresses has not run).""" + sha = address_sha(env, subject) + doc = _DOCS.get(sha) + if doc is None: + doc = contract.loads(env.depot.read(sha)) + _DOCS.clear() + _DOCS[sha] = doc + return doc def address_index(env, subject: str) -> views.AddressIndex: - """The AddressIndex of the result of link.addresses for the catalog `subject` (Pending while it has not run).""" - sha = env.artifact(contract.task_id(ADDRESSES, subject), "addresses")["content"]["sha256"] - index = _ADDRESSES.get(sha) + """The AddressIndex of the address table of the catalog `subject` (Pending while link.addresses has not run).""" + sha = address_sha(env, subject) + index = _INDEXES.get(sha) if index is None: - index = views.AddressIndex.from_addresses(contract.loads(env.depot.read(sha))) - _ADDRESSES.clear() - _ADDRESSES[sha] = index + index = views.AddressIndex.from_addresses(address_doc(env, subject)) + _INDEXES.clear() + _INDEXES[sha] = index return index +def font_bundles(env) -> frozenset: + """The fenced bundles: those whose address table lists a catalog key under a font folder (FONT_FOLDERS), over the + catalogs of the fact "catalogs" (Pending while link.addresses has not run for one of them).""" + subjects = sorted(env.fact("catalogs")) + shas = tuple(address_sha(env, s) for s in subjects) + if not _FENCE or _FENCE[0][0] != shas: + fenced: set[str] = set() + for s in subjects: + for stable, b in address_doc(env, s)["bundles"].items(): + if any(k.startswith(FONT_FOLDERS) for k in b["keys"]): + fenced.add(stable) + _FENCE[:] = [(shas, frozenset(fenced))] + return _FENCE[0][1] + + def root_objects(env, keys=(), objects=(), resources=(), builtins=()) -> list[str]: """The object ids of the catalog `keys`, the resource paths `resources` and the built-in selectors `builtins` (through the address tables of the fact "catalogs") and the `objects`, sorted.""" @@ -227,7 +305,8 @@ def root_objects(env, keys=(), objects=(), resources=(), builtins=()) -> list[st def table(env) -> Units: """The Units of the request of a planning environment: the censuses of the fact "bundles" (Pending while one has not run; a failed one is skipped), the facts "selected", "roots" (Pending while link.addresses has not run, - when it names keys or resource paths) and "classes". Kept for the planning state it was made in.""" + when it names keys or resource paths), "classes" and "catalogs" (the fenced bundles: font_bundles). Kept for the + planning state it was made in.""" bundles = sorted(env.fact("bundles")) keys = [] for b in bundles: @@ -246,12 +325,14 @@ def table(env) -> Units: request.get("resources", ()), request.get("builtins", ()), ) + fenced = font_bundles(env) sig = ( id(env.depot), tuple(keys), tuple(env.fact("selected")), None if classes is None else tuple(classes), tuple(roots), + tuple(sorted(fenced)), ) if _MEMO and _MEMO[0][0] == sig: return _MEMO[0][1] @@ -263,6 +344,6 @@ def censuses(): rec = next(a for a in env.depot.committed(key, tid)["artifacts"] if a["id"] == aid) yield b, contract.loads(env.depot.read(rec["content"]["sha256"])) - t = build(censuses(), bundles, env.fact("selected"), classes, roots) + t = build(censuses(), bundles, env.fact("selected"), classes, roots, fenced) _MEMO[:] = [(sig, t)] return t diff --git a/src/nnnotes/schemas/addresses.schema.json b/src/nnnotes/schemas/addresses.schema.json index 40f6249..1db399e 100644 --- a/src/nnnotes/schemas/addresses.schema.json +++ b/src/nnnotes/schemas/addresses.schema.json @@ -162,7 +162,7 @@ } }, "builtins": { - "description": "Per selector the objects of the APK's built-in files it names (not MonoScripts; a hierarchy by its root GameObject alone): \"\" by class, \"MonoBehaviour:\" a MonoBehaviour by its script's class; sorted.", + "description": "Per selector the objects of the APK's built-in files it names: \"\" by class, \"MonoBehaviour:\" a MonoBehaviour, including a hierarchy's component, by its script's class; sorted.", "type": "object", "additionalProperties": { "type": "array", diff --git a/src/nnnotes/schemas/objects.schema.json b/src/nnnotes/schemas/objects.schema.json index c53b845..fbbeda6 100644 --- a/src/nnnotes/schemas/objects.schema.json +++ b/src/nnnotes/schemas/objects.schema.json @@ -9,6 +9,7 @@ "roots", "keys", "objects", + "fenced", "missing" ], "properties": { @@ -89,6 +90,25 @@ } } }, + "fenced": { + "type": "object", + "description": "Objects a reached object references in a font bundle the product does not reach (the font fence of pipeline.units), with their class and name.", + "additionalProperties": { + "type": "object", + "required": [ + "class", + "name" + ], + "properties": { + "class": { + "type": "string" + }, + "name": { + "type": "string" + } + } + } + }, "missing": { "type": "array", "description": "Objects a reached object references that no census holds.", diff --git a/src/nnnotes/unity/textstyle.py b/src/nnnotes/unity/textstyle.py index a49893e..dba56e8 100644 --- a/src/nnnotes/unity/textstyle.py +++ b/src/nnnotes/unity/textstyle.py @@ -5,9 +5,10 @@ distance-field material turned into face / outline / underlay values in em. `fontRole` names which of the game's localized font slots the text uses, so a renderer maps roles to fonts of its own. - language_fonts Fwk.Localization.LocalizeManager font names per LanguageMode (level0) - localize_text LocalizeText.Init / OnFontChanged: the font asset, material and line spacing a TMP text ends up - with in a language + language_fonts Fwk.Localization.LocalizeManager font names per LanguageMode (level0) + language_fonts_json the same from the object's json (the shipped typetree of the class) + localize_text LocalizeText.Init / OnFontChanged: the font asset, material and line spacing a TMP text ends up + with in a language The language is a Fwk.Localization.LanguageMode (`mode`), the value languages.mode gives for a catalog language code; the callers take it from the client language they export (`[catalog] language`). @@ -20,10 +21,13 @@ from __future__ import annotations import struct +from typing import TYPE_CHECKING from ..source.languages import LANGUAGES -from .export import Exporter -from .player import PlayerData + +if TYPE_CHECKING: # annotations only: the module loads without UnityPy + from .export import Exporter + from .player import PlayerData # Fwk.Localization.LanguageMode / language field names of the text tables. JAPANESE_MODE = 0 @@ -107,11 +111,9 @@ def strings(self) -> list[str]: def language_fonts(player: PlayerData) -> dict: """Fwk.Localization.LocalizeManager (level0): font names per LanguageMode. - Read field by field: the generated typetree for this class omits the - alignment after `_dontDestroyOnload`, so the serialized bytes are parsed - directly (layout: m_GameObject, m_Enabled, m_Script, m_Name, - _dontDestroyOnload, _languageDataList[{_languageMode, _fontNames[], - _additionalFontNames[]}], _materialTypeList[]). + Read field by field from the serialized bytes; language_fonts_json gives the same dictionary from the object's + json. Layout: m_GameObject, m_Enabled, m_Script, m_Name, _dontDestroyOnload, _languageDataList[{_languageMode, + _fontNames[], _additionalFontNames[]}], _materialTypeList[]. """ hits = [ o @@ -140,6 +142,16 @@ def language_fonts(player: PlayerData) -> dict: return {"languages": langs, "materialTypes": materials} +def language_fonts_json(localize: dict) -> dict: + """language_fonts of the LocalizeManager's object json (its shipped type tree: the fields as serialized), the + same {"languages": {mode: {"fontNames", "additionalFontNames"}}, "materialTypes"} dictionary.""" + langs = { + d["_languageMode"]: {"fontNames": list(d["_fontNames"]), "additionalFontNames": list(d["_additionalFontNames"])} + for d in localize["_languageDataList"] + } + return {"languages": langs, "materialTypes": list(localize["_materialTypeList"])} + + # -------------------------------------------------------------------------- # localized font / material of a TMP text (LocalizeText) # -------------------------------------------------------------------------- diff --git a/src/nnnotes/unity/typetrees/6000.3.12f1.json b/src/nnnotes/unity/typetrees/6000.3.12f1.json index 36f83af..c4cde7d 100644 --- a/src/nnnotes/unity/typetrees/6000.3.12f1.json +++ b/src/nnnotes/unity/typetrees/6000.3.12f1.json @@ -2,6 +2,7 @@ "Adv|AdvSystem.Rendering|AdvFieldRendererFeature":{"nodes":[[0,"MonoBehaviour","Base",0],[1,"PPtr","m_GameObject",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0],[1,"UInt8","m_Enabled",16384],[1,"PPtr","m_Script",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0],[1,"string","m_Name",0],[2,"Array","Array",16384],[3,"int","size",0],[3,"char","data",0],[1,"UInt8","m_Active",16384],[1,"PPtr<$Shader>","_alphaBlendShader",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0],[1,"PPtr<$Shader>","_characterAlphaBlendShader",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0],[1,"PPtr<$Shader>","_blurShader",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0],[1,"PPtr<$Shader>","_curvedLensShader",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0],[1,"int","_maxFieldRendererEntryCount",0],[1,"float","_characterBlurIntensityRate",0],[1,"float","_characterBlurScale",0],[1,"float","_backgroundBlurIntensityRate",0],[1,"float","_backgroundBlurScale",0]],"typeHash":"b366a9b62e0f9de433e9ca044d26c9d1"}, "App.Runtime|App.Config|AppConfigDefaultData":{"nodes":[[0,"MonoBehaviour","Base",0],[1,"PPtr","m_GameObject",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0],[1,"UInt8","m_Enabled",16384],[1,"PPtr","m_Script",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0],[1,"string","m_Name",0],[2,"Array","Array",16384],[3,"int","size",0],[3,"char","data",0],[1,"AppConfigDataModel","_defaultData",0],[2,"float","k__BackingField",0],[2,"float","k__BackingField",0],[2,"float","k__BackingField",0],[2,"float","k__BackingField",0],[2,"float","k__BackingField",0],[2,"float","k__BackingField",0],[2,"float","k__BackingField",0],[2,"float","k__BackingField",0]],"typeHash":"2b53ff748caf6baa04ce8616a3f29be9"}, "DOTween.dll|DG.Tweening.Core|DOTweenSettings":{"nodes":[[0,"MonoBehaviour","Base",0],[1,"PPtr","m_GameObject",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0],[1,"UInt8","m_Enabled",16384],[1,"PPtr","m_Script",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0],[1,"string","m_Name",0],[2,"Array","Array",16384],[3,"int","size",0],[3,"char","data",0],[1,"UInt8","useSafeMode",16384],[1,"SafeModeOptions","safeModeOptions",0],[2,"int","logBehaviour",0],[2,"int","nestedTweenFailureBehaviour",0],[1,"float","timeScale",0],[1,"float","unscaledTimeScale",0],[1,"UInt8","useSmoothDeltaTime",16384],[1,"float","maxSmoothUnscaledTime",0],[1,"int","rewindCallbackMode",0],[1,"UInt8","showUnityEditorReport",16384],[1,"int","logBehaviour",0],[1,"UInt8","drawGizmos",16384],[1,"UInt8","defaultRecyclable",16384],[1,"int","defaultAutoPlay",0],[1,"int","defaultUpdateType",0],[1,"UInt8","defaultTimeScaleIndependent",16384],[1,"int","defaultEaseType",0],[1,"float","defaultEaseOvershootOrAmplitude",0],[1,"float","defaultEasePeriod",0],[1,"UInt8","defaultAutoKill",16384],[1,"int","defaultLoopType",0],[1,"UInt8","debugMode",16384],[1,"UInt8","debugStoreTargetId",16384],[1,"UInt8","showPreviewPanel",16384],[1,"int","storeSettingsLocation",0],[1,"ModulesSetup","modules",0],[2,"UInt8","showPanel",16384],[2,"UInt8","audioEnabled",16384],[2,"UInt8","physicsEnabled",16384],[2,"UInt8","physics2DEnabled",16384],[2,"UInt8","spriteEnabled",16384],[2,"UInt8","uiEnabled",16384],[2,"UInt8","textMeshProEnabled",16384],[2,"UInt8","tk2DEnabled",16384],[2,"UInt8","deAudioEnabled",16384],[2,"UInt8","deUnityExtendedEnabled",16384],[2,"UInt8","epoOutlineEnabled",16384],[1,"UInt8","createASMDEF",16384],[1,"UInt8","showPlayingTweens",16384],[1,"UInt8","showPausedTweens",16384]],"typeHash":"4f1dec93fa2a0b0e7b8b47518da31b1c"}, +"Fwk|Fwk.Localization|LocalizeManager":{"nodes":[[0,"MonoBehaviour","Base",0],[1,"PPtr","m_GameObject",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0],[1,"UInt8","m_Enabled",16384],[1,"PPtr","m_Script",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0],[1,"string","m_Name",0],[2,"Array","Array",16384],[3,"int","size",0],[3,"char","data",0],[1,"UInt8","_dontDestroyOnload",16384],[1,"LanguageData","_languageDataList",0],[2,"Array","Array",0],[3,"int","size",0],[3,"LanguageData","data",0],[4,"int","_languageMode",0],[4,"vector","_fontNames",0],[5,"Array","Array",16384],[6,"int","size",0],[6,"string","data",0],[7,"Array","Array",16384],[8,"int","size",0],[8,"char","data",0],[4,"vector","_additionalFontNames",0],[5,"Array","Array",16384],[6,"int","size",0],[6,"string","data",0],[7,"Array","Array",16384],[8,"int","size",0],[8,"char","data",0],[1,"vector","_materialTypeList",0],[2,"Array","Array",16384],[3,"int","size",0],[3,"string","data",0],[4,"Array","Array",16384],[5,"int","size",0],[5,"char","data",0]],"typeHash":"597220768ec4996f80d7cfd5e139645f"}, "Fwk|Fwk.Rendering|ScreenCaptureRendererFeature":{"nodes":[[0,"MonoBehaviour","Base",0],[1,"PPtr","m_GameObject",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0],[1,"UInt8","m_Enabled",16384],[1,"PPtr","m_Script",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0],[1,"string","m_Name",0],[2,"Array","Array",16384],[3,"int","size",0],[3,"char","data",0],[1,"UInt8","m_Active",16384]],"typeHash":"ce87445dd61732c847969290a1daa3b1"}, "Fwk|Fwk.Sound|SoundVolumeSettings":{"nodes":[[0,"MonoBehaviour","Base",0],[1,"PPtr","m_GameObject",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0],[1,"UInt8","m_Enabled",16384],[1,"PPtr","m_Script",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0],[1,"string","m_Name",0],[2,"Array","Array",16384],[3,"int","size",0],[3,"char","data",0],[1,"SoundVolumeData","_collection",0],[2,"Array","Array",0],[3,"int","size",0],[3,"SoundVolumeData","data",0],[4,"string","CategoryName",0],[5,"Array","Array",16384],[6,"int","size",0],[6,"char","data",0],[4,"float","DefaultVolume",0]],"typeHash":"6fb3a84e8c1ec611fde964d8d6005276"}, "Fwk|Fwk.UI.Rendering|UIRendererFeature":{"nodes":[[0,"MonoBehaviour","Base",0],[1,"PPtr","m_GameObject",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0],[1,"UInt8","m_Enabled",16384],[1,"PPtr","m_Script",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0],[1,"string","m_Name",0],[2,"Array","Array",16384],[3,"int","size",0],[3,"char","data",0],[1,"UInt8","m_Active",16384],[1,"BitField","_layerMask",0],[2,"unsigned int","m_Bits",0],[1,"int","_blurIterations",0],[1,"float","_blurOffset",0],[1,"int","_blurDownsample",0],[1,"float","_blurBlendRateMax",0],[1,"PPtr<$Shader>","_dualKawaseBlurShader",0],[2,"int","m_FileID",0],[2,"SInt64","m_PathID",0]],"typeHash":"12463b61d2af82f284b2248ea336f5f6"}, diff --git a/tests/content/test_storyui.py b/tests/content/test_storyui.py new file mode 100644 index 0000000..8042e54 --- /dev/null +++ b/tests/content/test_storyui.py @@ -0,0 +1,203 @@ +"""The story UI document (content.storyui) on a synthetic object table: the talk window and catalog keys, the fields +of the document in a language, the text records with the font asset and material their language draws with, the +master id texts and validation errors.""" + +import pytest + +from nnnotes.content import prefabs, storyui +from nnnotes.pipeline.assemble import Objects +from nnnotes.pipeline.depot import Depot +from tests.helpers import storyui as sui +from tests.helpers.livenotes import comp + + +def ui(objects, language, master): + return storyui.ui(objects, language, master, sui.font_settings()) + + +def story(tmp_path, name: str = "d", **kw) -> Objects: + depot = Depot(tmp_path / name) + return Objects(sui.table_doc(depot, **kw), depot) + + +def test_the_talk_windows_take_the_plain_key_or_the_embedded_one_and_the_default_window(): + assert storyui.window_keys(sui.catalog_keys()) == { + "UIBoth": "UI/Prefab/Parts/Adv/Talk/UIBoth", + "UIDefaultTalkWindow": storyui.WINDOW_KEY, + "UIEmb": "EmbUI/Prefab/Parts/Adv/Talk/UIEmb", + "UIPlain": "UI/Prefab/Parts/Adv/Talk/UIPlain", + } + assert storyui.window_keys([]) == {"UIDefaultTalkWindow": storyui.WINDOW_KEY} + + +def test_the_story_ui_keys_are_the_keys_of_its_table(): + keys = storyui.keys(sui.catalog_keys()) + assert keys == sorted(sui.table_keys()) and len(keys) == 19 + + +def test_the_document_has_the_story_ui_fields_in_the_language(tmp_path): + doc, keep, roles = ui(story(tmp_path), "zh-Hant", sui.MASTER_RECORD) + assert set(doc) == { + "format", + "language", + "widget", + "talkWindows", + "chatWidget", + "dialogs", + "uiManager", + "letterbox", + "clips", + "controllers", + "settings", + "graphics", + "texts", + "textStyle", + "masterIdTexts", + "chatStatusTexts", + "bindings", + "shaders", + } + assert doc["format"] == storyui.UI_FORMAT == 1 + assert doc["language"] == { + "code": "zh-Hant", + "mode": 2, + "field": "traditionalChinese", + "lineSpacing": 5.0, + "fontNames": ["MainT", "Digits"], + "additionalFontNames": ["Extra"], + "materialTypes": ["Default", "Outline"], + "fontSwap": {"MainJ SDF": "MainT SDF", "Digits SDF": "Digits SDF"}, + } + assert doc["widget"] == sui.WIDGET and doc["chatWidget"] == sui.CHAT + assert doc["uiManager"] == sui.UI_MANAGER and doc["letterbox"] == sui.LETTERBOX + assert doc["talkWindows"] == { + "UIBoth": sui.BOTH_UI, + "UIDefaultTalkWindow": sui.DEFAULT, + "UIEmb": sui.EMB, + "UIPlain": sui.PLAIN, + } + assert doc["dialogs"] == {"UIAdvSkipConfirmDialogWidget": sui.DIALOG_A, "UICommonDialogWidget": sui.DIALOG_B} + assert doc["clips"] == dict(zip(storyui.CLIP_KEYS, sui.CLIPS)) + assert doc["controllers"] == dict(zip(storyui.CONTROLLER_KEYS, sui.CONTROLLERS)) + assert doc["settings"] == { + "masterIdSettings": sui.SETTINGS, + "dotween": sui.DOTWEEN, + "tmpSettings": sui.TMP_SETTINGS, + "localizeManager": sui.LOCALIZE, + } + assert doc["graphics"] == {"graphics": sui.GRAPHICS} + assert doc["bindings"] == {} + assert doc["shaders"] == {"UI/Default": sui.SHADER} + assert keep == set(sui.objects()) - {sui.SCRIPT} + assert set(roles) == {sui.SHADER} and {"json", "index"} <= roles[sui.SHADER] + + +def test_a_text_draws_with_the_font_and_material_of_its_language(tmp_path): + doc, _, _ = ui(story(tmp_path), "zh-Hant", sui.MASTER_RECORD) + assert set(doc["texts"]) == {"CAB-ui:101", "CAB-ui:103", "CAB-ui:111"} + talk = doc["texts"]["CAB-ui:101"] + assert talk["localized"] == {"fontAsset": "MainT SDF", "material": "MainT - Outline", "lineSpacing": 5.0} + style = talk["style"] + assert style["class"] == "TextMeshProUGUI" and style["text"] == "talk" and style["localized"] is True + assert style["fontRole"] == "primary" and style["materialType"] == "Outline" and style["textKey"] == "101" + assert style["alignment"] == {"horizontal": "center", "vertical": "middle"} + assert style["lineSpacing"]["applied"] == 5.0 + assert style["outline"]["widthEm"] == 0.03 + plain = doc["texts"]["CAB-ui:103"] + assert plain["localized"] is None and plain["style"]["localized"] is False + assert plain["style"]["textKey"] is None and plain["style"]["materialType"] == "Default" + assert plain["style"]["outline"] is None + window = doc["texts"]["CAB-ui:111"] + assert window["localized"] is None and window["style"]["textKey"] is None + assert doc["textStyle"]["language"] == "traditionalChinese" + assert set(doc["textStyle"]["roles"]) == {"primary"} + + +def test_the_japanese_texts_draw_with_the_japanese_font(tmp_path): + doc, _, _ = ui(story(tmp_path), "ja", sui.MASTER_RECORD) + assert doc["texts"]["CAB-ui:101"]["localized"] == { + "fontAsset": "MainJ SDF", + "material": "MainJ - Outline", + "lineSpacing": 0.0, + } + assert doc["language"]["fontSwap"] == {"MainJ SDF": "MainJ SDF", "Digits SDF": "Digits SDF"} + assert doc["textStyle"]["roles"] == { + "primary": {"lineHeightEm": 1.2, "ascentEm": 0.9, "descentEm": -0.3, "spacingOffset": 0.0, "boldSpacing": 7.0} + } + + +def test_the_master_id_texts_are_the_texts_of_the_settings_ids(tmp_path): + doc, _, _ = ui(story(tmp_path), "zh-Hant", sui.MASTER_RECORD) + assert doc["masterIdTexts"] == { + field: {"id": sui.MASTER_IDS[field], "text": f"zh {sui.MASTER_IDS[field]}"} for field in storyui.MASTER_ID_TEXTS + } + + +def test_a_master_id_without_a_text_is_a_key_error(tmp_path): + with pytest.raises(KeyError): + ui(story(tmp_path, name="missing"), "zh-Hant", {"texts": {}}) + + +def test_a_second_localize_manager_is_refused(tmp_path): + depot = Depot(tmp_path / "d") + objs = {**sui.objects(), "CAB-lm:2": ("MonoBehaviour", "LocalizeManager", {"json": sui.LOCALIZE_JSON})} + doc = sui.table_doc(depot, objs=objs, extra_roots=["CAB-lm:2"]) + with pytest.raises(ValueError, match="2 LocalizeManager roots"): + ui(Objects(doc, depot), "zh-Hant", sui.MASTER_RECORD) + + +def test_localize_manager_fields_are_read_from_its_scene_component(tmp_path): + depot = Depot(tmp_path / "d") + holder = "CAB-lm:20" + fields = {k: v for k, v in sui.LOCALIZE_JSON.items() if k != "$script"} + objs = { + **sui.objects(), + holder: ( + "GameObject", + "Localization", + {"json": sui.prefab("Localization", comp("MonoBehaviour", 1, sui.LOCALIZE_JSON["$script"], **fields))}, + ), + } + doc = sui.table_doc(depot, objs=objs) + doc["objects"][sui.LOCALIZE] = {"class": "MonoBehaviour", "status": "contained", "in": holder} + objects = Objects(doc, depot) + assert prefabs.script_class(objects, sui.LOCALIZE) == "LocalizeManager" + assert prefabs.mono_json(objects, sui.LOCALIZE) == sui.LOCALIZE_JSON + result, _, _ = ui(objects, "zh-Hant", sui.MASTER_RECORD) + assert result["settings"]["localizeManager"] == sui.LOCALIZE + assert result["language"]["fontNames"] == ["MainT", "Digits"] + + +def test_a_font_asset_the_fence_does_not_name_is_refused(tmp_path): + depot = Depot(tmp_path / "d") + doc = sui.table_doc(depot) + doc["fenced"] = {} + with pytest.raises(ValueError, match="CAB-font:5: no name in the object table"): + ui(Objects(doc, depot), "zh-Hant", sui.MASTER_RECORD) + + +def test_the_supplied_font_metrics_and_materials_determine_the_text_style(tmp_path): + settings = sui.font_settings() + settings.fonts["MainT SDF"]["faceInfo"] = {**settings.fonts["MainT SDF"]["faceInfo"], "m_LineHeight": 150.0} + settings.materials["MainT - Outline"]["floats"]["_OutlineWidth"] = 0.4 + doc, _, _ = storyui.ui(story(tmp_path), "zh-Hant", sui.MASTER_RECORD, settings) + assert doc["textStyle"]["roles"]["primary"]["lineHeightEm"] == 1.5 + assert doc["texts"]["CAB-ui:101"]["style"]["outline"]["widthEm"] == 0.06 + + +def test_chat_status_texts_resolve_each_nonempty_key_once(tmp_path): + depot = Depot(tmp_path / "d") + key, oid, entry = sui.chat_window() + objects = Objects( + sui.table_doc(depot, objs={**sui.objects(), oid: entry}, keys={**sui.table_keys(), key: [oid]}), depot + ) + assert storyui.chat_status_texts(objects, sui.MASTER_RECORD["texts"]) == {"301": "zh 301", "302": "zh 302"} + assert storyui.chat_status_texts(story(tmp_path, name="empty"), sui.MASTER_RECORD["texts"]) == {} + with pytest.raises(KeyError, match="302"): + storyui.chat_status_texts(objects, {"301": "incoming"}) + + +def test_font_keys_cover_both_font_folders_and_chat_windows_have_their_own_roots(): + keys = ["Font/Extra/Extra SDF", "EmbFont/MainJ/MainJ - Outline", "Adv/Chat/Prefabs/win_7", "UI/Prefab/UIAdvWidget"] + assert storyui.font_keys(keys) == ["EmbFont/MainJ/MainJ - Outline", "Font/Extra/Extra SDF"] + assert storyui.chat_window_keys(keys) == ["Adv/Chat/Prefabs/win_7"] diff --git a/tests/helpers/live2dmodel.py b/tests/helpers/live2dmodel.py index b54bfcc..436d2ac 100644 --- a/tests/helpers/live2dmodel.py +++ b/tests/helpers/live2dmodel.py @@ -240,4 +240,4 @@ def table(depot, objs: dict | None = None, roots=(ROOT, MASK_MAT, CULL_MAT)) -> ext = ("png" if role == "image" else "bin") if isinstance(body, bytes) else "json" recs[role] = {"sha256": depot.put(data), "size": len(data), "ext": ext} out[oid] = {"status": "exported", "class": cls, "name": name, "files": recs} - return {"schema": contract.OBJECTS, "roots": sorted(roots), "keys": {}, "objects": out, "missing": []} + return {"schema": contract.OBJECTS, "roots": sorted(roots), "keys": {}, "objects": out, "fenced": {}, "missing": []} diff --git a/tests/helpers/livenotes.py b/tests/helpers/livenotes.py index c7754f9..97d676d 100644 --- a/tests/helpers/livenotes.py +++ b/tests/helpers/livenotes.py @@ -182,7 +182,7 @@ def table(depot, keys: dict, objs: dict | None = None) -> dict: recs[role] = {"sha256": depot.put(data), "size": len(data), "ext": ext} out[oid] = {"status": "exported", "class": cls, "name": name, "files": recs} roots = sorted({o for v in keys.values() for o in v}) - return {"schema": contract.OBJECTS, "roots": roots, "keys": keys, "objects": out, "missing": []} + return {"schema": contract.OBJECTS, "roots": roots, "keys": keys, "objects": out, "fenced": {}, "missing": []} def master() -> dict: diff --git a/tests/helpers/livescene.py b/tests/helpers/livescene.py index e2681ce..51e1682 100644 --- a/tests/helpers/livescene.py +++ b/tests/helpers/livescene.py @@ -305,7 +305,7 @@ def table(depot, record: dict, objs: dict | None = None) -> dict: out[oid] = {"status": "contained", "class": "Component", "in": holder} ks = keys(record) roots = sorted({o for v in ks.values() for o in v} | {GRAPHICS, QUALITY, PLAYER}) - return {"schema": contract.OBJECTS, "roots": roots, "keys": ks, "objects": out, "missing": []} + return {"schema": contract.OBJECTS, "roots": roots, "keys": ks, "objects": out, "fenced": {}, "missing": []} def master() -> dict: diff --git a/tests/helpers/spotobjects.py b/tests/helpers/spotobjects.py index 554edd4..982fcdb 100644 --- a/tests/helpers/spotobjects.py +++ b/tests/helpers/spotobjects.py @@ -354,4 +354,4 @@ def table(depot, objs: dict, roots) -> dict: ext = {"image": "png", "mesh": "glb"}.get(role, "bin") if isinstance(body, bytes) else "json" recs[role] = {"sha256": depot.put(data), "size": len(data), "ext": ext} out[oid] = {"status": "exported", "class": cls, "name": name, "files": recs, **extra} - return {"schema": contract.OBJECTS, "roots": sorted(roots), "keys": {}, "objects": out, "missing": []} + return {"schema": contract.OBJECTS, "roots": sorted(roots), "keys": {}, "objects": out, "fenced": {}, "missing": []} diff --git a/tests/helpers/storyui.py b/tests/helpers/storyui.py new file mode 100644 index 0000000..abb9176 --- /dev/null +++ b/tests/helpers/storyui.py @@ -0,0 +1,292 @@ +"""A synthetic story UI as an object table (content.storyui): the ADV widget and its talk windows, the chat phone, the +dialogs, UIManager, the letterbox and the settings, and the built-in LocalizeManager, GraphicsSettings, DOTweenSettings +and TMP_Settings. The font asset of the texts is fenced out of the table; their materials are in it.""" + +from nnnotes.content import storyui +from nnnotes.unity import fontsettings +from tests.helpers import livescene as ls +from tests.helpers.live2dmodel import ref +from tests.helpers.livenotes import comp, node, table + +CAB = "CAB-ui" +WIDGET, PLAIN, BOTH_UI, EMB, DEFAULT, CHAT, UI_MANAGER, DIALOG_A, DIALOG_B = (f"{CAB}:{i}" for i in range(1, 10)) +LETTERBOX = f"{CAB}:10" +CLIPS = [f"{CAB}:{i}" for i in range(11, 15)] +CONTROLLERS = [f"{CAB}:{i}" for i in range(15, 19)] +MAT_JA_DEFAULT, MAT_JA_OUTLINE = f"{CAB}:20", f"{CAB}:21" +FONT_JA = "CAB-font:5" # the font asset of the Japanese texts: in a font bundle, so fenced +SETTINGS = "CAB-st:1" +GRAPHICS, LOCALIZE, DOTWEEN, TMP_SETTINGS = "globalgamemanagers:7", "CAB-lm:1", "CAB-dt:1", "CAB-tmp:1" +SHADER, SCRIPT = "CAB-sh:1", "CAB-m:1" +ADV_SCRIPT = "App.Runtime|App.Adv|" +LOCALIZE_SCRIPT = "App.Runtime|Fwk.Localization|LocalizeText" +WHITE = {"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0} +BLACK = {"r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0} +BUILTIN_ROOTS = [GRAPHICS, LOCALIZE, DOTWEEN, TMP_SETTINGS] +# catalog keys the story UI is planned from that its table does not hold: an embedded talk window whose plain key the +# catalog also has, and a key of another feature +CATALOG_EXTRA = ["EmbUI/Prefab/Parts/Adv/Talk/UIBoth", "Adv/Chat/Prefabs/win_7"] +MASTER_IDS = {k: 101 + i for i, k in enumerate(storyui.MASTER_ID_TEXTS)} +WINDOWS = { # catalog key -> the talk window's GameObject + "UI/Prefab/Parts/Adv/Talk/UIPlain": PLAIN, + "UI/Prefab/Parts/Adv/Talk/UIBoth": BOTH_UI, + "EmbUI/Prefab/Parts/Adv/Talk/UIEmb": EMB, + storyui.WINDOW_KEY: DEFAULT, +} +LOCALIZE_JSON = { + "$script": "Fwk|Fwk.Localization|LocalizeManager", + "_dontDestroyOnload": 1, + "_languageDataList": [ + {"_languageMode": 0, "_fontNames": ["MainJ", "Digits"], "_additionalFontNames": []}, + {"_languageMode": 2, "_fontNames": ["MainT", "Digits"], "_additionalFontNames": ["Extra"]}, + ], + "_materialTypeList": ["Default", "Outline"], +} +# MasterText rows: the texts of the ids of MASTER_IDS in each language, and one row without a traditional Chinese text +MASTER_TEXTS = [ + {"_id": i, "_japanese": f"ja {i}", "_traditionalChinese": f"zh {i}", "_english": f"en {i}"} + for i in (*MASTER_IDS.values(), 301, 302) +] + [{"_id": 999, "_traditionalChinese": None}] +# the master record of the traditional Chinese texts (the texts of the MASTER_IDS ids) +MASTER_RECORD = {"texts": {str(i): f"zh {i}" for i in (*MASTER_IDS.values(), 301, 302)}} + + +def tmp_fields(font: str, material: str, text: str) -> dict: + """The serialized fields of a TMP text, its font asset and material as references (the fields textstyle reads).""" + return { + "m_Enabled": 1, + "m_fontAsset": ref(font), + "m_sharedMaterial": ref(material), + "m_text": text, + "m_isRichText": 1, + "m_parseCtrlCharacters": 1, + "m_fontSize": 36.0, + "m_enableAutoSizing": 0, + "m_fontSizeMin": 18.0, + "m_fontSizeMax": 40.0, + "m_charWidthMaxAdj": 0.0, + "m_lineSpacingMax": 0.0, + "m_fontStyle": 0, + "m_fontWeight": 400, + "m_HorizontalAlignment": 2, + "m_VerticalAlignment": 512, + "m_TextWrappingMode": 1, + "m_overflowMode": 0, + "m_margin": {"x": 0.0, "y": 0.0, "z": 0.0, "w": 0.0}, + "m_lineSpacing": 0.0, + "m_paragraphSpacing": 0.0, + "m_characterSpacing": 0.0, + "m_wordSpacing": 0.0, + "m_enableKerning": 0, + "m_ActiveFontFeatures": [], + "m_isRightToLeft": 0, + "m_isOrthographic": 1, + "m_fontColor": WHITE, + "m_colorMode": 0, + "m_enableVertexGradient": 0, + "m_fontColorGradient": {}, + "m_overrideHtmlColors": 0, + } + + +def material(name: str, outline: bool = False) -> dict: + """A TMP distance-field material record: the values of the font settings' material (and of the Material object).""" + floats = {"_GradientScale": 16.0, "_WeightNormal": 0.0, "_WeightBold": 0.0, "_FaceDilate": 0.0} + colors, keywords = {}, [] + if outline: + floats.update({"_OutlineWidth": 0.2, "_OutlineSoftness": 0.0}) + colors["_OutlineColor"] = BLACK + keywords.append("OUTLINE_ON") + return {"material": name, "keywords": keywords, "floats": floats, "colors": colors, "textures": {}} + + +def prefab(name: str, *components) -> dict: + return {"name": name, "nodes": [node(name, *components)]} + + +def widget() -> dict: + """UIAdvWidget: a text with an enabled LocalizeText (its key 101) and a text without a LocalizeText.""" + return { + "name": "UIAdvWidget", + "nodes": [ + node("UIAdvWidget"), + node( + "UIAdvWidget/Talk", + comp("TextMeshProUGUI", 101, **tmp_fields(FONT_JA, MAT_JA_OUTLINE, "talk")), + comp("MonoBehaviour", 102, LOCALIZE_SCRIPT, m_Enabled=1, _localizeEnabled=1, _masterTextID="101"), + ), + node("UIAdvWidget/Plain", comp("TextMeshProUGUI", 103, **tmp_fields(FONT_JA, MAT_JA_DEFAULT, "plain"))), + ], + } + + +def plain_window() -> dict: + """The plain talk window: its text's LocalizeText is switched off.""" + return { + "name": "UIPlain", + "nodes": [ + node( + "UIPlain", + comp("TextMeshProUGUI", 111, **tmp_fields(FONT_JA, MAT_JA_DEFAULT, "window")), + comp("MonoBehaviour", 112, LOCALIZE_SCRIPT, m_Enabled=1, _localizeEnabled=0, _masterTextID="0"), + ) + ], + } + + +def objects() -> dict: + """{object id: (class, name, {role: JSON document | bytes})} of the story UI and its built-in objects.""" + out = { + WIDGET: ("GameObject", "UIAdvWidget", {"json": widget()}), + PLAIN: ("GameObject", "UIPlain", {"json": plain_window()}), + BOTH_UI: ("GameObject", "UIBoth", {"json": prefab("UIBoth")}), + EMB: ("GameObject", "UIEmb", {"json": prefab("UIEmb")}), + DEFAULT: ("GameObject", "UIDefaultTalkWindow", {"json": prefab("UIDefaultTalkWindow")}), + CHAT: ("GameObject", "UIAdvChatWidget", {"json": prefab("UIAdvChatWidget")}), + UI_MANAGER: ("GameObject", "UIManager", {"json": prefab("UIManager")}), + DIALOG_A: ("GameObject", "UIAdvSkipConfirmDialogWidget", {"json": prefab("UIAdvSkipConfirmDialogWidget")}), + DIALOG_B: ("GameObject", "UICommonDialogWidget", {"json": prefab("UICommonDialogWidget")}), + LETTERBOX: ("Sprite", "letterbox-image", {}), + MAT_JA_DEFAULT: ("Material", "MainJ - Default", {"json": material("MainJ - Default")}), + MAT_JA_OUTLINE: ("Material", "MainJ - Outline", {"json": material("MainJ - Outline", outline=True)}), + SETTINGS: ( + "MonoBehaviour", + "AdvMasterIdSettings", + {"json": {"$script": ADV_SCRIPT + "AdvMasterIdSettings", **MASTER_IDS}}, + ), + GRAPHICS: ("GraphicsSettings", None, {"json": {"m_AlwaysIncludedShaders": [ref(SHADER)]}}), + LOCALIZE: ("MonoBehaviour", "LocalizeManager", {"json": LOCALIZE_JSON}), + DOTWEEN: ( + "MonoBehaviour", + "DOTweenSettings", + {"json": {"$script": "DOTween.dll|DG.Tweening.Core|DOTweenSettings", "timeScale": 1.0}}, + ), + TMP_SETTINGS: ( + "MonoBehaviour", + "TMP Settings", + {"json": {"$script": "Unity.TextMeshPro|TMPro|TMP_Settings", "m_defaultFontAsset": None}}, + ), + SHADER: ls.shader("UI/Default"), + SCRIPT: ("MonoScript", "AdvMasterIdSettings", {"json": {"m_ClassName": "AdvMasterIdSettings"}}), + } + for oid, name in zip(CLIPS, storyui.CLIP_KEYS): + out[oid] = ("AnimationClip", name, {"json": {"name": name, "sampleRate": 60.0, "bindings": []}}) + for oid, name in zip(CONTROLLERS, storyui.CONTROLLER_KEYS): + out[oid] = ("AnimatorController", name, {"json": {"name": name, "clips": []}}) + return out + + +def table_keys() -> dict: + """{catalog key: root object ids} of the story UI's table: the keys storyui.keys gives for the catalog.""" + out = { + storyui.WIDGET_KEY: [WIDGET], + storyui.CHAT_WIDGET_KEY: [CHAT], + storyui.UI_MANAGER_KEY: [UI_MANAGER], + storyui.LETTERBOX_KEY: [LETTERBOX], + storyui.MASTER_ID_SETTINGS_KEY: [SETTINGS], + storyui.DIALOG_KEYS["UIAdvSkipConfirmDialogWidget"]: [DIALOG_A], + storyui.DIALOG_KEYS["UICommonDialogWidget"]: [DIALOG_B], + } + out.update({key: [oid] for key, oid in zip(storyui.CLIP_KEYS.values(), CLIPS)}) + out.update({key: [oid] for key, oid in zip(storyui.CONTROLLER_KEYS.values(), CONTROLLERS)}) + out.update({key: [oid] for key, oid in WINDOWS.items()}) + return out + + +def catalog_keys() -> list[str]: + """The keys of the catalog the story UI is planned from: the keys of the table and CATALOG_EXTRA.""" + return sorted({*table_keys(), *CATALOG_EXTRA}) + + +def index() -> dict: + """The catalog index fact of the planning (its locations' primary keys).""" + return {"locations": [{"primaryKey": key} for key in catalog_keys()]} + + +def fenced() -> dict: + return {FONT_JA: {"class": "TMP_FontAsset", "name": "MainJ SDF"}} + + +def table_doc(depot, objs: dict | None = None, keys: dict | None = None, extra_roots=()) -> dict: + """The object table (nnnotes.objects/1) of the story UI, or of `objs` with the roots of `keys`: the built-in + objects and `extra_roots` are roots as well, and the font asset is fenced.""" + doc = table(depot, table_keys() if keys is None else keys, objects() if objs is None else objs) + doc["roots"] = sorted({*doc["roots"], *BUILTIN_ROOTS, *extra_roots}) + doc["fenced"] = fenced() + return doc + + +def font_settings() -> fontsettings.FontSettings: + """The font settings the texts draw with: the Japanese and the traditional Chinese font assets, and the default and + outline materials of the Japanese font and the outline material of the traditional Chinese one.""" + face = {"m_Scale": 1.0, "m_PointSize": 100.0, "m_LineHeight": 120.0, "m_AscentLine": 90.0, "m_DescentLine": -30.0} + fonts = { + name: {"faceInfo": face, "normalSpacingOffset": 0.0, "boldSpacing": 7.0} for name in ("MainJ SDF", "MainT SDF") + } + materials = { + "MainJ - Default": material("MainJ - Default"), + "MainJ - Outline": material("MainJ - Outline", outline=True), + "MainT - Outline": material("MainT - Outline", outline=True), + } + return fontsettings.FontSettings({"format": fontsettings.FORMAT, "fonts": fonts, "materials": materials}) + + +def font_objects() -> tuple[dict, dict]: + """Synthetic font assets and materials read while building text styles, with their catalog keys.""" + out, keys = {}, {} + font_ids = {"MainJ SDF": FONT_JA, "MainT SDF": "CAB-font:6", "Digits SDF": "CAB-font:7", "Extra SDF": "CAB-font:8"} + for i, (name, oid) in enumerate(font_ids.items()): + base = name[:-4] + mats = {} + for j, kind in enumerate(("Default", "Outline")): + mid = f"CAB-font:{20 + 2 * i + j}" + mat = material(f"{base} - {kind}", outline=kind == "Outline") + mat.update( + {"shader": None, "invalidKeywords": [], "renderQueue": -1, "tags": {}, "disabledPasses": [], "ints": {}} + ) + out[mid] = ("Material", mat["material"], {"json": mat}) + keys[fontsettings.font_dir(name) + mat["material"]] = [mid] + mats[kind] = mid + settings = font_settings().fonts["MainJ SDF"] + doc = { + "$script": "Unity.TextMeshPro|TMPro|TMP_FontAsset", + "m_Name": name, + "m_CharacterTable": [], + "m_GlyphTable": [], + "m_AtlasPopulationMode": 0, + "m_AtlasTextures": [], + "m_Material": ref(mats["Default"]), + "m_FallbackFontAssetTable": [], + "m_FontWeightTable": [], + "m_SourceFontFile": None, + "m_FaceInfo": {"m_FaceIndex": 0, **settings["faceInfo"]}, + "m_AtlasPadding": 15, + "m_AtlasRenderMode": 4134, + **{k: settings.get(k, 0) for k in fontsettings.STYLE_KEYS}, + } + out[oid] = ("MonoBehaviour", name, {"json": doc}) + keys[fontsettings.font_dir(name) + name] = [oid] + return out, keys + + +def chat_window() -> tuple[str, str, tuple]: + """A chat window with two status keys and one empty key.""" + oid = "CAB-chat:1" + doc = prefab( + "win_7", + comp( + "MonoBehaviour", + 2, + ADV_SCRIPT + "AdvChatWindow", + _incomingCallStatusTextKey="301", + _lockScreenStatusTextKey="302", + ), + comp( + "MonoBehaviour", + 3, + ADV_SCRIPT + "AdvChatWindow", + _incomingCallStatusTextKey="301", + _lockScreenStatusTextKey="", + ), + ) + return "Adv/Chat/Prefabs/win_7", oid, ("GameObject", "win_7", {"json": doc}) diff --git a/tests/pipeline/test_assemble.py b/tests/pipeline/test_assemble.py index 5bbb6f2..549e541 100644 --- a/tests/pipeline/test_assemble.py +++ b/tests/pipeline/test_assemble.py @@ -15,9 +15,9 @@ from tests.pipeline.test_objexport import ATLAS_CAB, FAKE_READER, oid, planning_env -def addresses(depot, env, keys: dict, resources=None, builtins=None) -> None: +def addresses(depot, env, keys: dict, resources=None, builtins=None, fonts=()) -> None: """A committed link.addresses result whose table maps `keys` {catalog key: [object ids]}, `resources` - {resource path: [object ids]} and `builtins` {selector: [object ids]}.""" + {resource path: [object ids]} and `builtins` {selector: [object ids]}; the bundles `fonts` hold a font key.""" t = Task("link.addresses", 1, "main", {}, {}, ()) doc = { "schema": link.ADDRESSES, @@ -26,7 +26,7 @@ def addresses(depot, env, keys: dict, resources=None, builtins=None) -> None: "builtins": dict(sorted((builtins or {}).items())), "objects": {}, "files": {}, - "bundles": {}, + "bundles": {b: {"keys": [f"EmbFont/{b}"]} for b in fonts}, } rec = contract.artifact( contract.artifact_id(t.id, "addresses"), @@ -106,11 +106,13 @@ def builtins(self, subject, env): return self.selectors -def env_for(depot, keys=None, names=("all", "atlas"), roots_objects=(), selected=None, resources=None, builtins=None): +def env_for( + depot, keys=None, names=("all", "atlas"), roots_objects=(), selected=None, resources=None, builtins=None, fonts=() +): """A planning environment over the fake bundles with a committed address table and the facts of a request.""" env = planning_env(depot, names, selected=selected) env.facts["catalogs"] = {"main": []} - addresses(depot, env, keys if keys is not None else {}, resources, builtins) + addresses(depot, env, keys if keys is not None else {}, resources, builtins, fonts) subjects = sorted(keys if keys is not None else {}) env.facts["products"] = {"toy.thing": subjects} env.facts["roots"] = {"keys": subjects, "objects": list(roots_objects)} @@ -257,6 +259,44 @@ def test_sprite_images_come_from_the_crop_of_their_atlas(tmp_path): assert f"{t.id}#objects" in {a["id"] for a in doc["artifacts"]} +def test_the_font_fence_cuts_a_reference_from_an_unfenced_bundle(tmp_path): + """The units of an unfenced bundle do not reach a fenced bundle's unit through a reference: the object table lists + the objects its reached objects reference that way as fenced, and the sprite of the unfenced bundle takes no image + from the atlas the product does not reach.""" + depot = Depot(tmp_path / "s") + env = env_for(depot, {"key/a": [oid(13)]}, fonts=["atlas"]) + exported(depot, env) + _, doc = product(depot, env, "m/a", {"m/a": "key/a"}) + table = table_of(depot, doc) + atlas = f"{ATLAS_CAB}:99" + assert table["fenced"] == {atlas: {"class": "SpriteAtlas", "name": "remote"}} + assert atlas not in table["objects"] and atlas not in table["missing"] + assert set(table["objects"][oid(13)]["files"]) == {"json"} + objects = assemble.Objects(table, depot) + assert objects.fenced(atlas) == {"class": "SpriteAtlas", "name": "remote"} and objects.fenced(oid(13)) is None + + +def test_the_font_fence_keeps_a_request_out_of_a_fenced_bundle(tmp_path): + """A request for the unfenced bundle exports its units and not the fenced atlas its sprite references.""" + fenced = env_for(Depot(tmp_path / "f"), {"key/a": [oid(13)]}, selected=["all"], fonts=["atlas"]) + plain = env_for(Depot(tmp_path / "p"), {"key/a": [oid(13)]}, selected=["all"]) + assert "atlas:99" not in ExportStage(FAKE_READER).subjects(fenced) + assert "atlas:99" in ExportStage(FAKE_READER).subjects(plain) + assert units.table(fenced).fences("all:13", "atlas:99") and not units.table(fenced).fences("atlas:99", "all:13") + + +def test_a_fenced_root_reaches_out_of_the_fence_and_takes_the_image_of_its_atlas(tmp_path): + """The objects a product starts from reach what they reference, also out of a fenced bundle: the sprite of the + fenced bundle reaches the unfenced atlas it is packed in, and its image comes from that atlas.""" + depot = Depot(tmp_path / "s") + env = env_for(depot, {}, selected=["all"], roots_objects=[oid(13)], fonts=["all"]) + exported(depot, env) + _, doc = product(depot, env, "m/a", stage=Rooted({}, [oid(13)])) + table = table_of(depot, doc) + assert table["roots"] == [oid(13)] and table["fenced"] == {} + assert f"{ATLAS_CAB}:99" in table["objects"] and set(table["objects"][oid(13)]["files"]) == {"json", "image"} + + def test_depends_names_the_units_it_reads(tmp_path): depot = Depot(tmp_path / "s") env = env_for(depot, {"key/a": [oid(60)]}) @@ -335,6 +375,27 @@ def assemble(self, task, objects): assert list(table_of(depot, doc)["objects"]) == [oid(60)] +def test_a_product_retains_one_root_closure_while_reading_additional_objects(tmp_path): + class Closure(Toy): + def assemble(self, task, objects): + made = super().assemble(task, objects) + retained = contract.loads(objects.depot.input_bytes(task.input("retained"))) + return assemble.Assembly(made.files, table=retained) + + depot = Depot(tmp_path / "s") + env = env_for(depot, {"key/a": [oid(60)], "key/b": [oid(40)]}) + exported(depot, env) + retained = assemble.reach(env, [oid(60)], keys={"key/a": [oid(60)]}).table + stage = Closure({"m/a": "key/a"}) + planned = describe(stage, "m/a", None, env) + data = contract.encode(retained) + extra = contract.Input("retained", depot.put(data), len(data), None, ({"kind": "depot"},)) + task = Task(stage.name, stage.version, "m/a", {}, {}, (*planned.inputs, extra)) + execute(task, depot, registry(stage), force=True) + doc = depot.result(task.key) + assert table_of(depot, doc) == retained + + def test_a_product_names_an_object_the_table_lacks(tmp_path): class Wrong(Toy): def assemble(self, task, objects): diff --git a/tests/pipeline/test_cristages.py b/tests/pipeline/test_cristages.py index 95653db..fee08ce 100644 --- a/tests/pipeline/test_cristages.py +++ b/tests/pipeline/test_cristages.py @@ -119,6 +119,7 @@ def planning(depot, tmp, files: dict, keys=None, boot=True, bundles=()) -> Env: facts = { "raw": raw, "index": catalog_index(keys or {}, bundles), + "catalogs": {}, "boot": boot_input(depot, tmp) if boot else None, } return Env(depot, facts) diff --git a/tests/pipeline/test_link.py b/tests/pipeline/test_link.py index d2bd240..4bc0faf 100644 --- a/tests/pipeline/test_link.py +++ b/tests/pipeline/test_link.py @@ -178,7 +178,7 @@ def test_resource_paths_name_objects_of_the_builtin_files(): def test_builtin_selectors_name_objects_of_the_builtin_files_by_class(): """The builtins table: objects of the built-in files by class and MonoBehaviours by their script's class; no - MonoScript, no member of a hierarchy, nothing of a bundle.""" + MonoBehaviour components are selected by script class as well.""" idx = index(synth.CatalogWriter().build(catalog_entries())) settings = {"file": "globalgamemanagers.assets", "pathId": 20} other = {"file": "globalgamemanagers.assets", "pathId": 21} @@ -195,6 +195,7 @@ def test_builtin_selectors_name_objects_of_the_builtin_files_by_class(): obj(34, "MonoBehaviour", ""), obj(40, "GameObject", "root"), {**obj(41, "Transform"), "unit": "globalgamemanagers.assets:40"}, + obj(42, "MonoBehaviour", "", script=settings, unit="globalgamemanagers.assets:40"), obj(50, "Texture2D", "t"), ], }, @@ -204,7 +205,11 @@ def test_builtin_selectors_name_objects_of_the_builtin_files_by_class(): assert doc["builtins"] == { "GameObject": ["globalgamemanagers.assets:40"], "GraphicsSettings": ["globalgamemanagers:1"], - "MonoBehaviour:SoundSettings": ["globalgamemanagers.assets:30", "globalgamemanagers.assets:31"], + "MonoBehaviour:SoundSettings": [ + "globalgamemanagers.assets:30", + "globalgamemanagers.assets:31", + "globalgamemanagers.assets:42", + ], "Texture2D": ["globalgamemanagers.assets:50"], } ix = views.AddressIndex.from_addresses(doc) @@ -212,6 +217,7 @@ def test_builtin_selectors_name_objects_of_the_builtin_files_by_class(): assert [o.id for o in ix.builtin("MonoBehaviour:SoundSettings")] == [ "globalgamemanagers.assets:30", "globalgamemanagers.assets:31", + "globalgamemanagers.assets:42", ] assert ix.builtin("MonoBehaviour:Unnamed") is None and ix.builtin("MonoScript") is None assert link.addresses(idx, censuses())["builtins"] == {} diff --git a/tests/pipeline/test_live.py b/tests/pipeline/test_live.py index 2e4c78b..19c88f2 100644 --- a/tests/pipeline/test_live.py +++ b/tests/pipeline/test_live.py @@ -114,7 +114,14 @@ def table(depot, monos=()) -> Input: "class": "MonoBehaviour", "files": {"json": {"sha256": depot.put(data), "size": len(data), "ext": "json"}}, } - doc = {"schema": contract.OBJECTS, "roots": sorted(objects), "keys": {}, "objects": objects, "missing": []} + doc = { + "schema": contract.OBJECTS, + "roots": sorted(objects), + "keys": {}, + "objects": objects, + "fenced": {}, + "missing": [], + } return stored(depot, contract.encode(doc), "objects") diff --git a/tests/pipeline/test_objexport.py b/tests/pipeline/test_objexport.py index ba54dbd..1a1cb2b 100644 --- a/tests/pipeline/test_objexport.py +++ b/tests/pipeline/test_objexport.py @@ -632,7 +632,12 @@ def planning_env(depot, names=("all", "atlas"), selected=None, scripts=SCRIPTS, for n in names: data = f"fake:{n}".encode() bundles[n] = Input("bundle", depot.put(data), len(data), f"{n}.bundle", ({"kind": "depot"},)) - facts = {"bundles": bundles, "selected": sorted(selected if selected is not None else names), "classes": classes} + facts = { + "bundles": bundles, + "catalogs": {}, + "selected": sorted(selected if selected is not None else names), + "classes": classes, + } env = Env(depot, facts) for n in names: t = Task(census_mod.CensusStage.name, census_mod.CensusStage.version, n, {}, {}, (bundles[n],)) @@ -1514,6 +1519,21 @@ def test_the_context_holds_the_shipped_type_trees_of_its_scripts(tmp_path, monke assert "typetrees" not in describe(stage, "all:70", None, planning_env(Depot(tmp_path / "t"))).context +def test_the_font_fence_of_a_request(tmp_path): + """units.build: a reference of an unfenced unit into a fenced bundle reaches nothing, from a root or from a unit + the walk reaches; a fenced unit's references reach as usual; `info` holds the objects of the fenced units.""" + censuses = [(n, census_mod.census_env(BUNDLES[n](), shipped=True)) for n in ("all", "atlas")] + atlas = f"{ATLAS_CAB}:99" + t = units.build(censuses, ["all", "atlas"], ["all"], fenced=["atlas"]) + assert "atlas:99" not in t.subjects() and "all:13" in t.subjects() + assert t.fences("all:13", "atlas:99") and not t.reaches("all:13", "atlas:99", True) + assert not t.fences("atlas:99", "all:13") and t.reaches("atlas:99", "all:13", True) + assert t.fenced == {s for s in t.kinds if s.startswith("atlas:")} + assert t.info[atlas] == {"class": "SpriteAtlas", "name": "remote"} and oid(13) not in t.info + r = units.build(censuses, ["all", "atlas"], [], roots=[atlas], fenced=["atlas"]) + assert r.subjects() == ["atlas:97", "atlas:98", "atlas:99"] # the root's references, across no fence + + def test_the_unit_table(tmp_path): env = planning_env(Depot(tmp_path / "s"), ("all", "atlas", "plain"), selected=["all"], classes=["Sprite"]) t = units.table(env) diff --git a/tests/pipeline/test_story.py b/tests/pipeline/test_story.py index dc59de6..8b97523 100644 --- a/tests/pipeline/test_story.py +++ b/tests/pipeline/test_story.py @@ -2,7 +2,7 @@ import pytest -from nnnotes.content import storychat, storyepisode, storyscene +from nnnotes.content import storychat, storyepisode, storyscene, storyui from nnnotes.formats import crilips from nnnotes.pipeline import assemble, contract, cristages, live, story from nnnotes.pipeline.assemble import Objects @@ -12,18 +12,23 @@ from nnnotes.pipeline.live2d import MODEL from nnnotes.pipeline.stages import Env, describe, execute, registry from nnnotes.pipeline.story import ( + PART_STAGES, + UI, AssetStage, ChatStage, CriLipsStage, SceneStage, SoundStage, StoryStage, + UiStage, VideoStage, ) from tests.formats.test_acb_categories import acb_with_categories from tests.formats.test_crilips import _elf_with_rodata, _synthetic_version from tests.formats.test_usm import KEY from tests.helpers import story as sh +from tests.helpers import storyui as sui +from tests.helpers import synth from tests.pipeline.test_assemble import addresses from tests.pipeline.test_cristages import export_result, fake_tools, planning, usm_file from tests.pipeline.test_live import master_record, run, stored, table @@ -364,6 +369,67 @@ def test_a_chat_shard_without_a_text_asset_has_no_rows(tmp_path): assert chat["sounds"] == [] and chat["cueSheets"] == [] +# ---------------------------------------------------------------- the story UI product + + +def test_the_story_ui_product_has_one_subject_per_client_language(tmp_path): + env = Env(Depot(tmp_path / "d"), {"index": sui.index()}) + stage = UiStage() + assert stage.ui_keys("zh-Hant", env) == storyui.keys(sui.catalog_keys()) + assert stage.keys("ja", env) == sorted([*storyui.keys(sui.catalog_keys()), "Adv/Chat/Prefabs/win_7"]) + assert stage.resources("ja", None) == ["DOTweenSettings", "TMP Settings"] + assert stage.builtins("en", None) == ["GraphicsSettings", "MonoBehaviour:LocalizeManager"] + assert stage.manifest("zh-Hant") == "storyui/zh-Hant.json" + assert UI not in PART_STAGES + with pytest.raises(ValueError, match="not a client language"): + stage.manifest("fr") + with pytest.raises(ValueError, match="not a client language"): + stage.keys("fr", env) + + +def test_the_story_ui_master_input_holds_the_texts_of_the_language(tmp_path): + depot = Depot(tmp_path / "d") + env = Env(depot, {"master": sh.master_dir(tmp_path, {"MasterText": sui.MASTER_TEXTS})}) + stage = UiStage() + inp = assemble.master_input(env, stage.record("zh-Hant", env)) + assert inp.role == assemble.MASTER_INPUT + assert contract.loads(depot.input_bytes(inp)) == { + "texts": {**{str(i): f"zh {i}" for i in (*range(101, 111), 301, 302)}, "999": ""} + } + ja = contract.loads(depot.input_bytes(assemble.master_input(env, stage.record("ja", env)))) + assert ja["texts"]["101"] == "ja 101" and ja["texts"]["999"] == "" + + +def test_the_story_ui_product_holds_ui_objects_and_reads_font_assets_and_chat_status_texts(tmp_path): + depot = Depot(tmp_path / "d") + fonts, keys = sui.font_objects() + chat_key, chat_oid, chat = sui.chat_window() + obj_doc = sui.table_doc( + depot, objs={**sui.objects(), **fonts, chat_oid: chat}, keys={**sui.table_keys(), **keys, chat_key: [chat_oid]} + ) + ui_doc = sui.table_doc(depot) + master = stored( + depot, contract.encode({"texts": {str(i): f"zh {i}" for i in (*range(101, 111), 301, 302)}}), "master" + ) + inputs = [ + stored(depot, contract.encode(obj_doc), "objects"), + stored(depot, contract.encode(ui_doc), "ui.objects"), + stored(depot, contract.encode({"CAB-font": "font.bundle"}), "font-bundles"), + stored(depot, synth.axml(["versionName", "manifest", "1.2.3"], 2), "apk:AndroidManifest.xml"), + master, + ] + _, _, arts = run(depot, UiStage(), "zh-Hant", inputs) + assert {p: a["semantics"]["kind"] for p, a in arts.items()} == {"ui.json": "story.ui", "objects": "product.objects"} + doc = read_json(depot, arts["ui.json"]) + assert doc["language"]["mode"] == 2 and doc["widget"] == sui.WIDGET + assert doc["masterIdTexts"]["_skipButtonTextId"] == {"id": 102, "text": "zh 102"} + assert doc["chatStatusTexts"] == {"301": "zh 301", "302": "zh 302"} + assert set(read_json(depot, arts["objects"])["objects"]) == set(sui.objects()) - {sui.SCRIPT} + assert read_json(depot, arts["objects"])["fenced"] == sui.fenced() + assert read_json(depot, arts["objects"])["roots"] == ui_doc["roots"] + assert read_json(depot, arts["objects"])["keys"] == sui.table_keys() + + # ---------------------------------------------------------------- the products a story plays with def test_part_paths_give_the_manifest_of_each_part(): parts = { @@ -454,7 +520,14 @@ def video_table(depot, key=VIDEO_KEY, info=None) -> Input: oid: {"status": "exported", "class": "MonoBehaviour", "files": {"json": {"sha256": depot.put(data), "size": len(data), "ext": "json"}}}, } # fmt: skip - doc = {"schema": contract.OBJECTS, "roots": [oid], "keys": {key: [oid]}, "objects": objects, "missing": []} + doc = { + "schema": contract.OBJECTS, + "roots": [oid], + "keys": {key: [oid]}, + "objects": objects, + "fenced": {}, + "missing": [], + } return stored(depot, contract.encode(doc), "objects") diff --git a/tests/unity/test_textstyle.py b/tests/unity/test_textstyle.py index 6e15b11..0be5a53 100644 --- a/tests/unity/test_textstyle.py +++ b/tests/unity/test_textstyle.py @@ -1,13 +1,18 @@ """textstyle: TMP text records and material style values (synthetic data).""" import json +import struct +from types import SimpleNamespace import pytest +from UnityPy.helpers.TypeTreeHelper import read_typetree +from UnityPy.streams.EndianBinaryReader import EndianBinaryReader from nnnotes.content import advui from nnnotes.source import languages from nnnotes.source.config import ConfigError from nnnotes.unity import fontsettings, textstyle, tmpfont +from nnnotes.unity.scripttypes import load_typetrees, typetree_key, typetree_node WHITE = {"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0} DARK = {"r": 0.2, "g": 0.2, "b": 0.2, "a": 0.8} @@ -369,3 +374,121 @@ def lines(ja, en): assert advui.shown_texts(episode, ids, "japanese")[:2] == ["ja1", "ja2"] with pytest.raises(RuntimeError, match="no title"): advui.shown_texts({**episode, "title": None}, ids, "english") + + +# -------------------------------------------------------------------------- +# LocalizeManager: the serialized bytes, the object json of the shipped type tree +# -------------------------------------------------------------------------- +ALIGN = 0x4000 # the align flag of a type tree node +LOCALIZE_HASH = "597220768ec4996f80d7cfd5e139645f" +LOCALIZE_LANGUAGES = [ + (0, ["A-OTF-Main", "VibeMOPro"], ["NotoSansJP"]), + (2, ["TW-Main", "Digits"], []), +] +LOCALIZE_MATERIALS = ["Default", "Outline"] +EXPECTED_LANGUAGES = { + "languages": { + 0: {"fontNames": ["A-OTF-Main", "VibeMOPro"], "additionalFontNames": ["NotoSansJP"]}, + 2: {"fontNames": ["TW-Main", "Digits"], "additionalFontNames": []}, + }, + "materialTypes": ["Default", "Outline"], +} + + +class _Out: + """Serialized fields as the reader of textstyle reads them: little-endian, each string aligned to 4 bytes.""" + + def __init__(self): + self.raw = bytearray() + + def i32(self, v: int) -> None: + self.raw += struct.pack(" None: + self.raw += struct.pack(" None: + self.raw.append(v) + + def align(self) -> None: + self.raw += b"\0" * (-len(self.raw) % 4) + + def string(self, s: str) -> None: + data = s.encode("utf-8") + self.i32(len(data)) + self.raw += data + self.align() + + def strings(self, values) -> None: + self.i32(len(values)) + for s in values: + self.string(s) + + +def localize_manager_bytes(languages_, materials) -> bytes: + """The serialized LocalizeManager: its MonoBehaviour header, _dontDestroyOnload, languages and materials.""" + out = _Out() + out.i32(0) # m_GameObject + out.i64(0) + out.u8(1) # m_Enabled + out.align() + out.i32(1) # m_Script + out.i64(7) + out.string("LocalizeManager") # m_Name + out.u8(1) # _dontDestroyOnload + out.align() + out.i32(len(languages_)) + for mode, fonts, additional in languages_: + out.i32(mode) + out.strings(fonts) + out.strings(additional) + out.strings(materials) + return bytes(out.raw) + + +class _Boot: + """Boot data with one MonoBehaviour: the LocalizeManager of the serialized bytes `raw`.""" + + def __init__(self, raw: bytes): + self._by_type = {"MonoBehaviour": [SimpleNamespace(get_raw_data=lambda: raw)]} + + def script(self, o) -> tuple: + return ("Fwk", "Fwk.Localization", "LocalizeManager") + + +def test_language_fonts_reads_the_serialized_fields(): + raw = localize_manager_bytes(LOCALIZE_LANGUAGES, LOCALIZE_MATERIALS) + assert textstyle.language_fonts(_Boot(raw)) == EXPECTED_LANGUAGES + + +def test_language_fonts_refuses_trailing_bytes(): + raw = localize_manager_bytes(LOCALIZE_LANGUAGES, LOCALIZE_MATERIALS) + b"\0\0\0\0" + with pytest.raises(RuntimeError, match="4 trailing bytes"): + textstyle.language_fonts(_Boot(raw)) + + +def test_language_fonts_json_is_the_same_dictionary_from_the_object_json(): + doc = { + "_dontDestroyOnload": 1, + "_languageDataList": [ + {"_languageMode": mode, "_fontNames": fonts, "_additionalFontNames": additional} + for mode, fonts, additional in LOCALIZE_LANGUAGES + ], + "_materialTypeList": LOCALIZE_MATERIALS, + } + assert textstyle.language_fonts_json(doc) == EXPECTED_LANGUAGES + + +def test_the_shipped_type_tree_reads_the_localize_manager_bytes(): + entry = load_typetrees("6000.3.12f1")["classes"][typetree_key("Fwk", "Fwk.Localization", "LocalizeManager")] + assert entry["typeHash"] == LOCALIZE_HASH + assert [1, "UInt8", "_dontDestroyOnload", ALIGN] in entry["nodes"] + raw = localize_manager_bytes(LOCALIZE_LANGUAGES, LOCALIZE_MATERIALS) + tt = read_typetree( + typetree_node(entry["nodes"]), + EndianBinaryReader(raw, "<"), + as_dict=True, + byte_size=len(raw), + check_read=True, + ) + assert textstyle.language_fonts_json(tt) == EXPECTED_LANGUAGES == textstyle.language_fonts(_Boot(raw)) diff --git a/tests/unity/test_typetrees.py b/tests/unity/test_typetrees.py index ce285e6..e1a0ceb 100644 --- a/tests/unity/test_typetrees.py +++ b/tests/unity/test_typetrees.py @@ -515,3 +515,16 @@ def test_unity_rows_refuses_what_it_has_no_layout_for(fields, message): gen_typetrees().unity_rows(GEN_HEADER + fields) with pytest.raises(ValueError, match="unexpected MonoBehaviour header"): gen_typetrees().unity_rows(GEN_HEADER[:1] + GEN_HEADER[5:]) + + +def test_aligned_fields_get_the_align_flag_and_missing_ones_are_refused(): + g = gen_typetrees() + rows = [[0, "MonoBehaviour", "Base", 0], [1, "UInt8", "_dontDestroyOnload", 0], [1, "int", "count", 0]] + assert g._aligned(rows, ("_dontDestroyOnload",)) == [ + [0, "MonoBehaviour", "Base", 0], + [1, "UInt8", "_dontDestroyOnload", ALIGN], + [1, "int", "count", 0], + ] + assert g.ALIGNED_FIELDS == {"LocalizeManager": ("_dontDestroyOnload",)} + with pytest.raises(ValueError, match="no root field \\['missing'\\]"): + g._aligned(rows, ("missing",))