Source code for bacommon.workspace.assetsv1

# Released under the MIT License. See LICENSE for details.
#
"""Public types for assets-v1 workspaces.

While this module is currently only used server-side, its source code
can be useful as reference when setting workspace config data by hand or
for use in client-side workspace modification tools. There may be
advanced settings that are not accessible through the UI/etc.
"""

from __future__ import annotations  # Docs-generation hack.

# This is the hand-written schema module for the whole assets-v1
# workspace format -- one cohesive set of types that callers import
# together -- so it legitimately runs long. (Not an _implN spill; there
# is nothing to split out to.)
# pylint: disable=too-many-lines

import datetime
from enum import Enum
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Annotated, override, assert_never

from efro.dataclassio import ioprepped, IOAttrs, IOMultiType
from bacommon.locale import Locale
from bacommon.langstr import WrapParams
from bacommon.loctext import StringSelector

if TYPE_CHECKING:
    pass


[docs] class WrapperType(Enum): """Python wrapper-module flavor for an asset-package version. Selects which feature-set's loader API the generated wrapper delegates to. Members today correspond 1:1 with feature-sets, but the type is deliberately named ``WrapperType`` (not ``WrapperFeatureset``) to leave room for non-featureset-shaped variants (e.g. tooling-only or future loader APIs) without a rename. """ BASCENEV1 = 'bascenev1' BAUIV1 = 'bauiv1' #: Strings-and-sounds wrapper for the babase layer. String leaves #: emit the same native ``babase.LangStr`` accessors as the #: featureset forms; sound leaves emit ``SimpleSoundHandle``, whose #: ``.get()`` loads a ``babase.SimpleSound`` -- the one classic #: asset loader API babase has (there is no babase texture or mesh #: equivalent, so those kinds are skipped). Exists so #: pre-featureset machinery (e.g. construct-mode's bring-up UI, and #: the app/plugin/account paths that play ui feedback sounds before #: any feature-set is up) can consume package strings and sounds. BABASE = 'babase'
[docs] class ConventionsMode(Enum): """Conventions-check enforcement level for an assets_v1 workspace. ``STRICT`` blocks test/prod publishes while conventions findings exist (dev-track resolves are never gated); ``RELAXED`` (the default) surfaces findings as informational hints only. """ RELAXED = 'relaxed' STRICT = 'strict'
[docs] class PackageResolveAccess(Enum): """Who may resolve a package's *prod* versions. Orthogonal to the version track: a track says how released a version is, this says who may have it. ``PRIVATE`` applies the same owner-or-dev-team check that dev/test versions always get, so resolve access only ever *adds* restriction to prod -- it can never make a dev/test version public. ``PUBLIC`` is the default and what every package had before this existed. Exists for packages nothing fetches at runtime -- content the master evaluates itself, or build-time sources embedded into other packages -- not as a general publishing control. Gates the *resolve*, not the content. A private package's entries can still reach clients verbatim -- formatter components are embedded by value into each consuming package's language blobs at build time -- so this says "nothing fetches this package at runtime", never "these strings are secret". """ PUBLIC = 'public' PRIVATE = 'private'
[docs] class PackageSourceSharing(Enum): """Who can start a new workspace from a package's source. Governs *source* availability only -- who may copy the exporting workspace's snapshot as the starting point for a workspace of their own. It says nothing about who can *use* the published assets; that's the track plus :class:`PackageResolveAccess`. Package-wide (not per-version): sharing intent belongs to the package, and a per-version value meant every republish silently reset it. """ PRIVATE = 'private' DEV_TEAM_ONLY = 'devteam' PUBLIC = 'public' @property def pretty(self) -> str: """Human-facing display name (use in UIs; not wire values).""" cls = PackageSourceSharing if self is cls.PRIVATE: return 'Private' if self is cls.DEV_TEAM_ONLY: return 'Dev Team Only' if self is cls.PUBLIC: return 'Public' assert_never(self)
[docs] @ioprepped @dataclass class AssetsV1GlobalVals: """Global values for an assets_v1 workspace.""" base_assets: str | None = None base_assets_filter: str = '' #: Optional free-form workspace documentation, appended to the #: generated wrapper module's docstring (after the auto-generated #: summary line). Empty string means none. docs: str = '' #: Dev-team id granting resolve access to this workspace's #: dev/test asset-package versions. None (unset) means owner-only #: access — matching the semantics of the asset-package doc's #: ``dev_team_id`` (see ``AssetPackage.account_has_access``). dev_team: str | None = None #: The asset-package name this workspace publishes under. None #: means it is derived from the workspace's display name (see #: :func:`derive_asset_package_name`); set explicitly to decouple #: the published name from the display name (e.g. to keep a #: package lineage across a workspace rename, or to have a new #: workspace take over publishing an existing package name). asset_package_name: str | None = None #: Conventions-check enforcement level (see #: :class:`ConventionsMode`). First-party workspaces set strict #: (see the asset-packages design doc). Set by hand in #: ``workspace.json`` -- deliberately not exposed in the UI. #: Unknown stored values fall back to relaxed so older servers #: never over-enforce. conventions: ConventionsMode = ConventionsMode.RELAXED #: Who may resolve this package's *prod* versions (see #: :class:`PackageResolveAccess`). Set by hand in ``workspace.json`` #: -- deliberately not exposed in the UI, same as ``conventions``, #: since its use is limited to server-side packages. Unknown stored #: values fall back to public: failing closed here would break asset #: resolves for every client, and private packages are private from #: birth and reached by their owner (who short-circuits before this #: is ever consulted). resolve_access: PackageResolveAccess = PackageResolveAccess.PUBLIC #: Who may start a workspace of their own from this package's source #: (see :class:`PackageSourceSharing`). Package-wide policy, resolved #: live -- it moved here from a per-version field in 2026-07-27, #: which republishing silently reset each time. source_sharing: PackageSourceSharing = PackageSourceSharing.PRIVATE
[docs] def derive_asset_package_name(workspace_name: str) -> str: """Derive a default asset-package name from a workspace name. Lowercases and strips spaces ('My Awesome Assets' -> 'myawesomeassets'). The single source for this rule — publish paths, collision checks, and UI previews must all route through it. Note the result is not guaranteed to be a *valid* asset-package name (the workspace name may contain characters with no valid mapping); consumers validate at point of use. """ return workspace_name.lower().replace(' ', '')
[docs] class AssetsV1StringFileTypeID(Enum): """Type ID for each of our subclasses.""" V1 = 'v1'
[docs] class AssetsV1StringFile(IOMultiType[AssetsV1StringFileTypeID]): """Top level class for our multitype."""
[docs] @override @classmethod def get_type_id_storage_name(cls) -> str: return 'string_file_version'
[docs] @override @classmethod def get_type_id(cls) -> AssetsV1StringFileTypeID: # Require child classes to supply this themselves. If we did a # full type registry/lookup here it would require us to import # everything and would prevent lazy loading. raise NotImplementedError()
[docs] @override @classmethod def get_type( cls, type_id: AssetsV1StringFileTypeID ) -> type[AssetsV1StringFile]: """Return the subclass for each of our type-ids.""" # pylint: disable=cyclic-import t = AssetsV1StringFileTypeID if type_id is t.V1: return AssetsV1StringFileV1 # Important to make sure we provide all types. assert_never(type_id)
[docs] @ioprepped @dataclass class AssetsV1StringFileV1(AssetsV1StringFile): """Our initial version of string file data."""
[docs] class StylePreset(Enum): """Preset for general styling in translated strings.""" NONE = 'none' TITLE = 'title' LOUD = 'loud' SOFT = 'soft'
[docs] class TranslationEffort(Enum): """How much model effort a string's translations warrant. Deliberately describes *intent*, not a model or a vendor setting: the server maps these onto whatever (model, thinking level) pair is current, so retuning that never touches stored ``.bstr`` data or restales translations. ``AUTO`` is the right answer for nearly every string -- short UI labels translate identically at any effort. It runs cheap first and escalates on its own when the brief looks structurally hard or when a generated attempt fails validation. Reach for ``HIGH`` only for *semantic* subtlety no heuristic can see: wordplay, brand voice, a line whose tone has to land. """ #: Server decides -- cheap by default, escalating when warranted. AUTO = 'auto' #: Always translate at maximum effort. HIGH = 'high'
[docs] class LayoutPreset(Enum): """What kind of slot a string occupies, and how it may size. (Named ``FitPreset`` until 2026-07-27; the stored key stays ``fit_preset``. Renamed because the values describe the *slot* -- a narrow tab, a standard button, a body paragraph -- and only some of them are a size constraint at all.) Mirrors ``StylePreset``: passed to the translator with UI context, so localized output respects both the space available and the register the slot implies. The CHARS_* budgets are display-width in *Latin* characters -- wide-glyph scripts (CJK) target roughly half the character count -- and are aims, not hard caps (soft enforcement with generous slack; see ``char_budget``). """ #: Unset -- no slot declared and no size constraint. Note this #: is the *absence* of a choice, which is why the authoring #: check nags on a long English string that is still NONE: use #: PROSE to say "unbounded on purpose". NONE = 'none' #: Aim for ~20 characters - narrow buttons, tabs, column #: headings. CHARS_20 = 'chars_20' #: Aim for ~40 characters - standard buttons and labels. CHARS_40 = 'chars_40' #: Aim for ~80 characters / one concise line - transient #: messages, status lines, and the like. CHARS_80 = 'chars_80' #: Body prose - paragraphs in a document or web page. No size #: constraint, but unlike NONE that is a deliberate statement, #: and it tells the translator to write flowing multi-sentence #: text rather than terse UI wording. PROSE = 'prose' @property def char_budget(self) -> int | None: """The preset's rough character budget (None if unbounded).""" cls = type(self) return { cls.NONE: None, cls.CHARS_20: 20, cls.CHARS_40: 40, cls.CHARS_80: 80, cls.PROSE: None, }[self]
[docs] @override @classmethod def get_type_id(cls) -> AssetsV1StringFileTypeID: return AssetsV1StringFileTypeID.V1
[docs] @dataclass class Output: """Represents a single localized output.""" #: When this output was last changed. modtime: datetime.datetime #: The localized output -- a plain string, or a render-time #: :class:`~bacommon.loctext.StringSelector` (plural/select) #: whose final form is chosen at display time. (A type-disjoint #: dataclassio union; selectors ride the wire as dicts.) value: str | StringSelector = ''
input: str input_modtime: datetime.datetime style_preset: StylePreset = StylePreset.NONE #: Optional free-form usage docs describing where this string #: appears and how it is used. Feeds both the generated wrapper #: accessor's docstring #: and the translation prompt (as usage context). Lives in the #: ``.bstr`` itself so edits restale translations via the file's #: content-id; when an edit doesn't warrant regeneration, use the #: UI's mark-translations-clean action. docs: str = '' #: Which kind of slot this string occupies (see #: ``LayoutPreset``). The stored key remains ``fit_preset`` from #: before the rename -- values on disk must not move. Passed to #: the translator so localized output respects the UI space #: available. layout_preset: LayoutPreset = LayoutPreset.NONE #: How much model effort this string's translations warrant (see #: ``TranslationEffort``). Unlike the other presets this is folded #: into the translation digest only when it is *not* ``AUTO``, so #: adding the field left every existing entry's digest byte-identical #: rather than restaling the whole corpus. translation_effort: TranslationEffort = TranslationEffort.AUTO outputs: dict[Locale, Output] = field( default_factory=dict )
[docs] class AssetsV1AprefFileTypeID(Enum): """Type ID for each of our subclasses.""" V1 = 'v1'
[docs] class AssetsV1AprefFile(IOMultiType[AssetsV1AprefFileTypeID]): """Top level class for our multitype. An ``<name>.apref`` source in an assets_v1 workspace is an asset-package reference: a pin to a published asset-package version. String briefs can then reference the pinned package's translations via cross-package term refs (``{@<apref-logical-path>:<entry-path>}``). """
[docs] @override @classmethod def get_type_id_storage_name(cls) -> str: return 'apref_file_version'
[docs] @override @classmethod def get_type_id(cls) -> AssetsV1AprefFileTypeID: # Require child classes to supply this themselves. If we did a # full type registry/lookup here it would require us to import # everything and would prevent lazy loading. raise NotImplementedError()
[docs] @override @classmethod def get_type( cls, type_id: AssetsV1AprefFileTypeID ) -> type[AssetsV1AprefFile]: """Return the subclass for each of our type-ids.""" # pylint: disable=cyclic-import t = AssetsV1AprefFileTypeID if type_id is t.V1: return AssetsV1AprefFileV1 # Important to make sure we provide all types. assert_never(type_id)
[docs] @ioprepped @dataclass class AssetsV1AprefFileV1(AssetsV1AprefFile): """Our initial version of asset-package-ref file data."""
[docs] @override @classmethod def get_type_id(cls) -> AssetsV1AprefFileTypeID: return AssetsV1AprefFileTypeID.V1
#: The pinned asset-package-version id #: (``<account>.<package>.<version-segment>``). Always a concrete #: version — including on the dev track (a specific ``devN`` #: segment, never the bare ``dev`` pseudo-id); pins only move via #: the explicit update/switch-track actions in the workspace UI. apverid: str
#: Placeholder value for a string with no generated output in its own locale #: *or* in English. We deliberately do NOT fall back to the brief ``input`` #: here: that's the author's description of what the string should say (a #: translator prompt), often a long-winded sentence -- not display text -- so #: rendering it is worse than an obvious "untranslated" marker. STRING_NOT_TRANSLATED = '<NOT-TRANSLATED>'
[docs] def complete_locale_values( string_files: dict[str, AssetsV1StringFileV1], locale: Locale ) -> dict[str, str | StringSelector]: """English-completed per-locale values for a set of string files. Maps each string's logical name to its value for ``locale``: the locale's own output, else the English output, else the ``STRING_NOT_TRANSLATED`` placeholder. So every locale's map carries the **complete key set** with graceful English fallback -- an untranslated string still renders (in English where available, else an obvious ``<NOT-TRANSLATED>`` marker) rather than failing, and every locale's key set is identical. The brief ``input`` is intentionally never used as a value: it's the author's prompt/description, not display text. The shared value-selection both the asset-build string recipe and the `langstr vendor` command route through (paired with :func:`~bacommon.langstr.serialize_language_blob`) so the built and vendored blobs can't drift. """ out: dict[str, str | StringSelector] = {} for name, sfile in string_files.items(): output = sfile.outputs.get(locale) if output is None: output = sfile.outputs.get(Locale.ENGLISH) out[name] = STRING_NOT_TRANSLATED if output is None else output.value return out
[docs] def display_param_kinds( string_files: dict[str, AssetsV1StringFileV1], ) -> dict[str, dict[str, str]]: """Per-string ``{param: kind}`` for params the display side must know. A spec'd brief param (``{size|data_size}``) renders through logic the translated text cannot describe -- the text holds only a ``{size}`` token -- so its kind has to travel to the evaluator in the language blob. Plain text subs and the plural pivot are omitted: text is the default, and the pivot is not a named output token at all (it renders as the ICU ``#`` count placeholder inside each form). Entries with nothing to declare are absent, so a package using no spec'd params serializes byte-identically to before this existed. The shared derivation both the asset-build string recipe and the `langstr vendor` command route through, alongside :func:`complete_locale_values`, so the built and vendored blobs can't drift on this either. Briefs that don't parse contribute nothing rather than failing the build -- consistent with how broken briefs degrade everywhere else. """ out: dict[str, dict[str, str]] = {} for name, sfile in string_files.items(): try: kinds = display_param_kinds_for_brief(sfile.input) except Exception: # pylint: disable=broad-except continue if kinds: out[name] = kinds return out
[docs] def display_param_kinds_for_brief(brief: str) -> dict[str, str]: """Per-param display kinds for a single brief. The one-entry unit :func:`display_param_kinds` aggregates; see it for what qualifies as a display kind. Values are display-kind *expressions*: the bare kind for an argless spec, else the kind plus its spec args in canonical form (``'bytes(compact=true)'``) -- see :attr:`~bacommon.strbrief.BriefTag.display_kind`. Raises on a malformed brief -- callers that must degrade softly (listing renders, builds) wrap it, matching how broken briefs degrade everywhere else. """ from bacommon.strbrief import parse_brief sig = parse_brief(brief) return { tag.name: tag.display_kind for tag in sig.token_params if tag.param_kind != 'text' }
[docs] class AssetsV1PathValsTypeID(Enum): """Types of vals we can store for paths.""" TEX_V1 = 'tex_v1' STR_V1 = 'str_v1' AUDIO_V1 = 'audio_v1' MESH_V1 = 'mesh_v1' GROUP_V1 = 'group_v1' CUBE_MAP_V1 = 'cube_map_v1' APREF_V1 = 'apref_v1'
[docs] class AssetsV1PathVals(IOMultiType[AssetsV1PathValsTypeID]): """Top level class for path vals classes."""
[docs] @override @classmethod def get_type_id_storage_name(cls) -> str: return 'type'
[docs] @override @classmethod def get_type_id(cls) -> AssetsV1PathValsTypeID: # Require child classes to supply this themselves. If we # did a full type registry/lookup here it would require us # to import everything and would prevent lazy loading. raise NotImplementedError()
[docs] @override @classmethod def get_type( cls, type_id: AssetsV1PathValsTypeID ) -> type[AssetsV1PathVals]: # pylint: disable=cyclic-import,too-many-return-statements t = AssetsV1PathValsTypeID if type_id is t.TEX_V1: return AssetsV1PathValsTexV1 if type_id is t.STR_V1: return AssetsV1PathValsStrV1 if type_id is t.AUDIO_V1: return AssetsV1PathValsAudioV1 if type_id is t.MESH_V1: return AssetsV1PathValsMeshV1 if type_id is t.GROUP_V1: return AssetsV1PathValsGroupV1 if type_id is t.CUBE_MAP_V1: return AssetsV1PathValsCubeMapV1 if type_id is t.APREF_V1: return AssetsV1PathValsAprefV1 # Important to make sure we provide all types. assert_never(type_id)
[docs] class TextureQuality(Enum): """Per-texture authoring quality knob (decision #19). ``DEFAULT`` is the normal case (the vast majority of textures); ``LOW`` and ``HIGH`` are deliberate per-texture overrides for special cases (e.g. ``HIGH`` for a hero texture that must stay crisp, ``LOW`` for one that can afford to be cheap). Named ``DEFAULT`` rather than ``MEDIUM`` to communicate that — it's the baseline, not a middle setting you'd routinely reach past. ``LOW``/``DEFAULT``/``HIGH`` are blanket settings that map to a sensible value for whichever encoder a profile uses (ASTC block size on mobile, BC7 RDO lambda on desktop). ``CUSTOM`` instead defers to the per-format :class:`AstcSettings` / :class:`Bc7Settings` so a texture can be tuned independently per encoder (e.g. ASTC ``HIGH`` while BC7 ``DEFAULT``). Distinct from the bucket-level ``TextureTier``. """ LOW = 'low' DEFAULT = 'default' HIGH = 'high' CUSTOM = 'custom'
[docs] class Role(Enum): """What a texture is for (its authoring intent). Drives mip-filtering math and encoder flags (asset-packages initiative decisions #19/#23). Intent-based rather than a bundle of low-level mechanical flags — the recipe maps each role to a concrete filtering/encoding behavior. ``normal_map`` / ``data`` are reserved slots for when such content (and the compressed- profile recipes) land. """ #: sRGB color with straight opacity alpha. The pipeline #: premultiplies it by its alpha for storage (decision #23): #: premult-weighted, halo-free mip filtering in the requested #: render_space, premult output bytes, ``ALPHA_PREMULTIPLIED`` #: DFD flag set. The common case for color sprites. Renders #: correctly only with premult-blend (the renderer wiring lands #: in a later step; until then ``DEFAULT`` output shows darkened #: edges under the legacy straight-blend path). DEFAULT = 'default' #: sRGB color whose SOURCE RGB is already premultiplied by its #: alpha (e.g. glow sprites — they carry additive ``RGB > alpha`` #: values that straight alpha cannot represent). The pipeline does #: NOT re-multiply; mips filter the premultiplied RGB directly (in #: the requested render_space) and the flag is set. Renders #: identically to ``DEFAULT`` (both premult-blend); they differ #: only in whether the pipeline applies the multiply. SOURCE_PREMULTIPLIED = 'source_premultiplied' #: sRGB color with straight alpha whose RGB channels carry #: meaningful color even in transparent regions, so they must be #: preserved (decision #23). The pipeline does NOT premultiply: #: mips filter RGB and alpha INDEPENDENTLY (color still filtered #: in the requested render_space, but with no premult round-trip, #: which would zero — and fail to recover — the transparent-region #: color). Straight output bytes; ``ALPHA_PREMULTIPLIED`` flag #: clear. Renders with ordinary straight-alpha blending. STRAIGHT_ALPHA = 'straight_alpha'
[docs] class AstcBlockSize(Enum): """ASTC square block size — the mobile bitrate lever. Smaller block = more bits per texel = higher quality + larger output. Consulted only when an :class:`AstcSettings` has its ``texture_quality`` set to ``CUSTOM``; otherwise the blanket ``LOW``/``DEFAULT``/``HIGH`` map to a value in this range (``LOW`` = ``TWELVE_BY_TWELVE``, ``HIGH`` = ``FOUR_BY_FOUR``). """ FOUR_BY_FOUR = '4x4' FIVE_BY_FIVE = '5x5' SIX_BY_SIX = '6x6' EIGHT_BY_EIGHT = '8x8' TEN_BY_TEN = '10x10' TWELVE_BY_TWELVE = '12x12'
[docs] class Bc7Rdo(Enum): """BC7 RDO (rate-distortion optimization) lambda — the desktop lever. BC7 is a fixed 8bpp block format, so its size lever is RDO: higher lambda steers the encoder toward more zlib/LZ-compressible output (smaller on-disk) at the cost of quality. ``OFF`` disables RDO (best quality, largest). Consulted only when a :class:`Bc7Settings` has its ``texture_quality`` set to ``CUSTOM``; otherwise the blanket ``LOW``/``DEFAULT``/``HIGH`` map to a value in this range (``LOW`` = ``FOUR``, ``HIGH`` = ``OFF``). """ OFF = 'off' ZERO_POINT_ONE_TWO_FIVE = '0.125' ZERO_POINT_TWO_FIVE = '0.25' ZERO_POINT_FIVE = '0.5' ONE = '1' TWO = '2' FOUR = '4'
[docs] @ioprepped @dataclass class AstcSettings: """Per-texture ASTC (mobile) encode settings. Consulted only when the texture's top-level ``texture_quality`` is ``CUSTOM``. Its own ``texture_quality`` may in turn be ``CUSTOM`` to use the explicit ``block_size``; otherwise ``LOW``/``DEFAULT``/ ``HIGH`` map to the encoder's block-size range. Fully defaulted so a texture never has to store it explicitly. """ texture_quality: TextureQuality = TextureQuality.DEFAULT block_size: AstcBlockSize = ( AstcBlockSize.SIX_BY_SIX )
[docs] @ioprepped @dataclass class Bc7Settings: """Per-texture BC7 (desktop) encode settings. Consulted only when the texture's top-level ``texture_quality`` is ``CUSTOM``. Its own ``texture_quality`` may in turn be ``CUSTOM`` to use the explicit ``rdo`` lambda; otherwise ``LOW``/``DEFAULT``/ ``HIGH`` map to the encoder's RDO range. Fully defaulted so a texture never has to store it explicitly. """ texture_quality: TextureQuality = TextureQuality.DEFAULT rdo: Bc7Rdo = Bc7Rdo.ONE
[docs] @ioprepped @dataclass class AssetsV1PathValsTexV1(AssetsV1PathVals): """Path-specific values for an assets_v1 workspace path. The per-texture quality knobs (:class:`TextureQuality`, :class:`Role`, :class:`AstcSettings`, :class:`Bc7Settings`) are module-level types in this module. """ texture_quality: TextureQuality = TextureQuality.DEFAULT texture_role: Role = Role.DEFAULT #: Per-format encode settings, consulted only when #: ``texture_quality`` is ``CUSTOM``. Fully defaulted so a texture #: never has to store them explicitly. astc_settings: AstcSettings = field(default_factory=AstcSettings) bc7_settings: Bc7Settings = field(default_factory=Bc7Settings) #: Optional free-form documentation for this asset, surfaced as a #: comment above the asset in generated wrapper modules (and in the #: Sphinx docs). Empty string means no docs. docs: str = '' #: Halve the fallback flavor's level0 downsize divisor (2 instead #: of 4) so this asset's fallback carries a higher-res top mip. For #: the rare asset whose fallback bytes get consumed directly rather #: than just serving as a universal render fallback -- e.g. the #: engine cursor texture feeding OS hardware cursors, which wants a #: retina-res mip. Deliberately not exposed in the workspace web UI #: (it would be noise there); edit workspace.json directly for the #: odd asset that needs it. fallback_high_res: bool = False
[docs] @override @classmethod def get_type_id(cls) -> AssetsV1PathValsTypeID: return AssetsV1PathValsTypeID.TEX_V1
[docs] def normalize(self) -> None: """Reset redundant/unused settings to defaults, in place. A pure tidiness pass to run before storing: it never changes the resolved result, only drops dead data so ``store_default=False`` can strip it from workspace.json. Resolution consults the per-format settings only when the top-level ``texture_quality`` is ``CUSTOM``, and a format's explicit ``block_size``/``rdo`` only when that format's own ``texture_quality`` is ``CUSTOM`` -- so anything outside those paths is unused and gets cleared here. """ astc_defaults = AstcSettings() bc7_defaults = Bc7Settings() # A non-CUSTOM format quality ignores the explicit value: clear it. if self.astc_settings.texture_quality is not TextureQuality.CUSTOM: self.astc_settings.block_size = astc_defaults.block_size if self.bc7_settings.texture_quality is not TextureQuality.CUSTOM: self.bc7_settings.rdo = bc7_defaults.rdo # Both formats on the same non-CUSTOM blanket value is identical to # just setting the top-level knob: collapse to it. if ( self.texture_quality is TextureQuality.CUSTOM and self.astc_settings.texture_quality is self.bc7_settings.texture_quality and self.astc_settings.texture_quality is not TextureQuality.CUSTOM ): self.texture_quality = self.astc_settings.texture_quality # A non-CUSTOM top-level quality never consults per-format settings: # clear them entirely. if self.texture_quality is not TextureQuality.CUSTOM: self.astc_settings = astc_defaults self.bc7_settings = bc7_defaults
[docs] @ioprepped @dataclass class AssetsV1StrTermDeps: """Cached term-ref info for a ``.bstr``, keyed to its content. Term refs (``{@term}`` / ``{@pkg:term}`` in the brief) are a pure function of the ``.bstr`` file's content, which is pinned by its content-addressed ``file_id`` -- so this record stays valid exactly as long as ``file_id`` matches the entry's current file. Consumers (dep-aware staleness calcs) use it to skip reading the file; on mismatch they fall back to reading that one file. Maintained automatically by the string save/translate paths; do not hand-edit (a wrong ``local``/``cross`` list with a matching ``file_id`` would be trusted). """ #: Content-id of the ``.bstr`` file these refs were extracted from. file_id: str #: Digest of the entry's *translation inputs* (brief, docs, style #: and layout presets) -- everything that shapes what the model #: produces, and nothing else. Notably NOT the entry's own outputs #: or modtimes: staleness folds this in per locale, so including #: outputs would mean writing one locale's translation restaled #: every other locale. Empty on records predating the field, which #: consumers treat as a cache miss. inputs_digest: str = '' #: Retired -- same-package term refs, which no longer exist. Kept #: only so stored records carrying it still parse; a non-empty #: value means the record predates the removal, and consumers #: treat that as a cache miss and re-extract. Never populate it. local: list[str] = field( default_factory=list ) #: Term-ref targets, whole (``<apref-path>:<entry-path>``, no #: extensions) -- consumers key staleness on the individual #: referenced term, not just its package. (Historical note: this #: briefly held bare apref paths; consumers ignore any entry #: lacking the ``:`` half and re-extract from the file.) cross: list[str] = field( default_factory=list ) #: Sorted unique display-param kinds this entry's brief uses #: (``'bytes'`` etc.; the union over #: :func:`display_param_kinds_for_brief`). What the master consults #: to decide which formatter components a package build must embed, #: without reading the file. ``None`` on records predating the #: field, which consumers treat as a cache miss so the record gets #: repaired (mirroring ``inputs_digest``); an extracted brief using #: no spec'd params stores ``[]``. kinds: list[str] | None = None
[docs] @ioprepped @dataclass class AssetsV1StrState: """A ``.bstr``'s up-to-date state, resolved per locale. A locale's output is a pure function of the entry's own content plus, for each ``{@…}`` term it references, that term's translated **value for that locale** in the pinned version. So staleness is per-locale: fixing one locale of a shared term must cost its dependents that one locale, not all ~41. Two shapes, because per-locale resolution is only ever needed by the minority of entries that reference terms: - ``uniform`` -- one state covering every locale. Used when the entry has no term refs, so nothing about its translation inputs varies by locale. - ``per_locale`` -- one state per locale. Used when it does. Storing per-locale for everything would be far larger than the rest of ``workspace.json`` combined (BaClassicAssets: 32 of 1128 entries carry refs), hence the split. Read through :meth:`for_locale`, which hides it. """ #: State shared by every locale (entries with no term refs). uniform: str | None = None #: Per-locale states (entries with term refs). per_locale: dict[Locale, str] = field(default_factory=dict)
[docs] def for_locale(self, locale: Locale) -> str | None: """This entry's state for one locale, or None if unstamped.""" if self.uniform is not None: return self.uniform return self.per_locale.get(locale)
[docs] def locales_stamped(self) -> bool: """Whether anything is stamped at all.""" return self.uniform is not None or bool(self.per_locale)
[docs] @ioprepped @dataclass class AssetsV1StrConvCache: """Cached conventions findings for a ``.bstr``, keyed to its inputs. Per-entry conventions findings are a pure function of the ``.bstr`` file's content plus the workspace's cross-package term environment (its ``.apref`` files' content-ids) plus the checks' own version -- all folded into ``state``. Consumers (the conventions lint) use it to skip reading the file; on mismatch they fall back to reading and re-checking that one entry. Maintained automatically by the string save/translate paths; do not hand-edit (wrong ``findings`` with a matching ``state`` would be trusted). """ #: Token pinning the inputs these findings were computed from (see #: class docs). Opaque; produced by the conventions module. state: str #: The entry's findings (human-readable one-liners), empty if clean. findings: list[str] = ( field(default_factory=list) )
[docs] @ioprepped @dataclass class AssetsV1PathValsStrV1(AssetsV1PathVals): """Path-specific values for an assets_v1 workspace path.""" #: Retired -- the single whole-entry up-to-date state, superseded by #: the per-locale :attr:`state` below. Kept only so stored records #: carrying it still parse; never read, and cleared on the next #: stamp. Never populate it. #: #: (Historical note: string author docs briefly lived here as a #: ``docs`` path-val to avoid restaling translations; they moved #: into the ``.bstr`` itself once docs began feeding the translation #: prompt, with the UI's mark-translations-clean action as the #: no-regeneration-needed escape hatch.) up_to_date_state: str | None = None #: Per-locale up-to-date state (see :class:`AssetsV1StrState`). #: Stamped by the translate / mark-clean paths for exactly the #: locales they brought current; a locale absent here (or whose #: stamp no longer matches a fresh calc) needs regenerating. state: AssetsV1StrState | None = None #: Optional definition-time line-wrapping hints (decision D-t in #: the language-string-context initiative): applied automatically #: at evaluation everywhere this string displays. Locale-invariant. #: Lives HERE (not in the ``.bstr``) deliberately: the ``.bstr`` is #: by definition the translation input, so its content hash is the #: translation-staleness key, and display-side metadata like this #: must not restale translations (the same reasoning as the docs #: history above, in reverse -- wrap does not feed the translation #: prompt). wrap: WrapParams | None = ( None ) #: Retired -- cached term-ref info, now held in a content-addressed #: Valkey group instead (``assetsv1str.term_deps_group``). Kept only #: so stored records carrying it still parse; never read or #: written. It moved because a cache keyed by immutable content #: needs no home in the snapshot -- and living here meant only #: write paths could fill it, since a path-vals write mints a #: snapshot and races user saves. It was also ~23% of a large #: workspace's ``workspace.json``. deps: AssetsV1StrTermDeps | None = None #: Retired -- cached conventions findings, now held in a #: content-addressed Valkey group instead #: (``assetsv1conventions.conv_findings_group``). Same reasoning as #: :attr:`deps` above; it was a further ~16%. conv: AssetsV1StrConvCache | None = None
[docs] @override @classmethod def get_type_id(cls) -> AssetsV1PathValsTypeID: return AssetsV1PathValsTypeID.STR_V1
[docs] class AudioRole(Enum): """A sound's channel/encode contract (asset-packages decision #25). Names the *technical* contract, not a content category — "music" deliberately does not exist as a build-time concept (volume routing stays a runtime play-flag; streaming is a length-derived engine policy). - ``DEFAULT`` — spatialization-ready: downmixed to mono at encode (OpenAL only spatializes mono; a hard requirement, not a size optimization). The vast majority of sounds. - ``PRE_MIXED`` — an authored mix: channels preserved (≤2) and the sound always plays listener-space. The recipe stamps a ``BA_ROLE=pre_mixed`` vorbis comment tag so the engine knows at load time (channel count alone can't carry the bit — a mono pre-mixed source stays mono). Music, plus any intentionally stereo (or otherwise authored-mix) sound. """ DEFAULT = 'default' PRE_MIXED = 'pre_mixed'
[docs] class AudioQuality(Enum): """Per-sound authoring quality knob (asset-packages decision #25). Mirrors the texture knob's LOW/DEFAULT/HIGH pattern. Defined from day one as the escape hatch for content whose default encode budget doesn't fit (e.g. a short pre-mixed UI sound sharing music's bitrate), but nothing consumes it yet — the recipe carries it in its cache key only, so wiring it up later rebuilds correctly. """ LOW = 'low' DEFAULT = 'default' HIGH = 'high'
[docs] @ioprepped @dataclass class AssetsV1PathValsAudioV1(AssetsV1PathVals): """Path-specific values for an audio source in an assets_v1 workspace. The per-sound authoring knobs (:class:`AudioRole`, :class:`AudioQuality`) are module-level types in this module. """ audio_role: AudioRole = AudioRole.DEFAULT audio_quality: AudioQuality = AudioQuality.DEFAULT #: Optional free-form documentation for this asset, surfaced as a #: comment above the asset in generated wrapper modules (and in the #: Sphinx docs). Empty string means no docs. docs: str = ''
[docs] @override @classmethod def get_type_id(cls) -> AssetsV1PathValsTypeID: return AssetsV1PathValsTypeID.AUDIO_V1
[docs] class MeshRole(Enum): """What a mesh ``.obj`` source builds (asset-packages decision #26). - ``DEFAULT`` — a display mesh: compiled to the engine's binary ``.bob`` format (welded/quantized verts, vertex-cache-optimized index order) and served from the flavor-varying ``meshes`` bucket (headless builds get none). - ``COLLISION`` — a collision mesh: compiled to the engine's binary ``.cob`` format (positions + indices for the physics trimesh) and served from the flavor-invariant ``constant`` bucket — every build including headless gets it, and the bytes are identical across all flavors (networked sims/replays must agree on collision geometry). """ DEFAULT = 'default' COLLISION = 'collision'
[docs] @ioprepped @dataclass class AssetsV1PathValsMeshV1(AssetsV1PathVals): """Path-specific values for a mesh source in an assets_v1 workspace. The per-mesh authoring knob (:class:`MeshRole`) is a module-level type in this module. """ mesh_role: MeshRole = MeshRole.DEFAULT #: Optional free-form documentation for this asset, surfaced as a #: comment above the asset in generated wrapper modules (and in the #: Sphinx docs). Empty string means no docs. docs: str = ''
[docs] @override @classmethod def get_type_id(cls) -> AssetsV1PathValsTypeID: return AssetsV1PathValsTypeID.MESH_V1
[docs] @ioprepped @dataclass class AssetsV1PathValsGroupV1(AssetsV1PathVals): """Path-specific values for a group (directory) in a workspace. A group builds no asset of its own; this exists purely to carry optional ``docs`` (decision #28) that become the generated wrapper group class's docstring. Keyed in ``workspace.json``'s ``path`` dict by the directory path (e.g. ``textures`` or ``mydir/subdir``). """ #: Optional free-form documentation for this group, used as the #: generated wrapper group class's docstring (a trailing "See source #: for the full asset list." is always appended). Empty string means #: fall back to the auto-generated docstring. docs: str = ''
[docs] @override @classmethod def get_type_id(cls) -> AssetsV1PathValsTypeID: return AssetsV1PathValsTypeID.GROUP_V1
[docs] @ioprepped @dataclass class AssetsV1PathValsCubeMapV1(AssetsV1PathVals): """Path-specific values for a cube map (``.cubemap`` dir) in a workspace. Cube maps are reflection textures with no Python API (decision #24), so they aren't wrapper-visible. This currently carries only optional ``docs`` -- stored for completeness/consistency with other asset kinds, but not yet consumed by anything (it'll have a home if/when cube maps gain a Python surface). Keyed in ``workspace.json``'s ``path`` dict by the ``.cubemap`` directory path. """ #: Optional free-form documentation for this cube map. Stored but not #: yet surfaced anywhere (cube maps have no wrapper entry). Empty #: string means no docs. docs: str = ''
[docs] @override @classmethod def get_type_id(cls) -> AssetsV1PathValsTypeID: return AssetsV1PathValsTypeID.CUBE_MAP_V1
[docs] @ioprepped @dataclass class AssetsV1PathValsAprefV1(AssetsV1PathVals): """Path-specific values for an ``.apref`` asset-package ref. Keyed in ``workspace.json``\'s ``path`` dict by the ``.apref`` file\'s path. Carries per-pin settings that are *not* part of the pin itself -- the pinned apverid lives in the ``.apref`` file, since that is content the workspace owns and syncs. NOTE: adding a member to :class:`AssetsV1PathValsTypeID` is a wire change with a cross-repo rollout. Workspace compiles parse this map via ``WorkspaceCompileInput.parse_path_config_data``, which raises ``PermanentBuildError`` on an entry it cannot decode -- and those compiles run on basn nodes carrying their own copy of this file. So a node that predates this type hard-fails any build of a workspace using it. Rolling one out means: define here -> ``make efrosync`` -> deploy basn -> bump ``CLOUD_BUILD_MIN_BASN_VERSION`` -> only then let bamaster start writing it. """ #: Whether pending updates should include bumping this pin to the #: newest version on its own track. See #: ``docs/initiatives/pin_keep_up_to_date.md``. #: #: **On by default**, to encourage modders to keep what they depend #: on current. With ``store_default=False`` that means the absence #: of this field reads as enabled, so every existing workspace #: inherits the behavior with no migration and the common case #: costs no bytes. The trade is that "never set" and "explicitly #: enabled" are indistinguishable -- fine here, but it does mean an #: explicit opt-*out* is the only thing that leaves a trace. keep_up_to_date: bool = ( True )
[docs] @override @classmethod def get_type_id(cls) -> AssetsV1PathValsTypeID: return AssetsV1PathValsTypeID.APREF_V1
# Docs-generation hack; import some stuff that we likely only forward-declared # in our actual source code so that docs tools can find it. from typing import (Coroutine, Any, Literal, Callable, Generator, Awaitable, Sequence, Self) import asyncio from concurrent.futures import Future from pathlib import Path from enum import Enum