# 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