Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commit61a33237e0

README.md

assetType.shared.settings

Resolves four questions about an asset's typed settings, all answered from its type's type.yaml — nothing here is found by convention.

local Settings = require("@builtin::assetTypes.assetType.shared.settings")
-- or, from inside the assetType runtime:
local Settings = require("@builtin::assetTypes.assetType.shared").settings

Where does a type's schema live?

type.yaml names it in a settings: block:

settings:
  values: "settings.yaml"           # where an instance's own values live
  schema: { type: "settings.luau" } # one schema for every asset of this type

or, for a type whose settings differ per instance (an importer):

settings:
  values: "settings.yaml"
  schema: { instance: "settings.luau" }

or, for a type whose schema is a table its instance's own source declares and whose values live elsewhere (a component: its public fields, valued on each entity it is on):

settings:
  schema: { instance: "init.luau", export: "public" }

Whether an instance may lack its schema file is what the type's files: block says of that file. Listed under files.optional, it may be absent, and an instance without it declares no settings — the importer type lists it there, since most importers take none. Listed under files.required, or not listed at all, it must load, and an instance missing it is a broken declaration and is logged.

schema.type is required at the folder relative to the TYPE's own .assetType/ directory (one schema shared by every instance). schema.instance is required relative to each INSTANCE's own folder (init resolves the instance's own identity; anything else is a sibling module under it). Without schema.export, the file is required as a module and its return value IS the schema. With schema.export, the file is run in an environment of its own and export is read from a returned table carrying it, or else from the globals the file assigned — a component's public is such a global. That environment reads through to _G and takes every write itself; its own _G is itself, its declare and computed record into it, and its task records spawned, deferred and delayed work instead of starting it. Every other global is the real one, so a call the file makes through one runs as it does anywhere. The schema is the declared table's Field descriptors. A schema that resolves to nothing says why through Settings.schemaProblemOf(ref): the file could not be read, compiled or run, or declared no such table. An instance schema is read again when its file, its type's type.yaml, or the entry file of any module the asset requires changes. Settings.blockFor(typeName) returns the raw block; Settings.schemaForType(typeName) and Settings.schemaOfInstance(ref) resolve the two schema shapes respectively; Settings.schemaFor(ref) tries the type-level schema first, falling back to the instance-level one. ref naming an assetType asset itself — e.g. asset.resolve("texture", "assetType"), rather than an instance of it — reports that type's own declared schema, the same one every instance of it reports.

Where do an asset's values live?

settings.values names a file living beside the asset's own content — Settings.valuesPathOf(ref) joins it onto the asset's path. Settings.valuesOf(ref) / Settings.writeValues(ref, values) read and write it; this module only names where it is. A type that declares its schema with no settings.values keeps no values in the asset — a component's live on each entity it is on — so valuesOf answers nil for it, asset.create refuses a settings table for it, and asset.setSettings refuses a write.

Settings.recordedWrite(path, text) writes a file the way every values file is written, and an editor uses it for any other file it writes. It writes the whole contents, records the write into the open undo operation when one is open (so undoing it restores what the file held), and raises when the write does not land.

In what order are a schema's fields declared?

Every Field.* constructor stamps its descriptor with declaredOrder, one more than the last. Luau evaluates a table constructor's entries in the order they are written, so Settings.orderedNames(schema) lists a schema's fields — and a struct's subfields — in the order the source writes them.

A values file, or a legacy .metadata settings block, may hold an older spelling — a renamed field, an older enum member. Every read passes what is stored through the type's normalizeSettings (Settings.normalizeFor), so it answers in the fields the schema declares today, and writes nothing. The next local edit of that asset rewrites the file in the declared spelling (Settings.healLegacyIfNeeded).

asset.setSettings(ref, patch) writes a partial patch: Settings.resolvePatch(ref, patch) normalises it, merges it over the current values and applies the type's derived defaults for the fields it changed, and Settings.writeValues(ref, values, patch) applies the corrections, checks the schema and writes the file.

How is a new asset's settings resolved at creation?

asset.create calls Settings.resolveForCreate(typeName, name, destPath, creationOpts, callerSettings, importerSettings) before running the type's onCreate, checks the result against the schema, writes it to the values file, and hands onCreate a COPY of it as opts.settings (Settings. cloneValues) — never the same table the values file gets, so nothing onCreate does to its own copy can reach what was stamped. onCreate reads settings from opts.settings and never writes to it. asset.create also seeds the change baseline with the stamped values (Settings.seedChangeBaseline), so the values file's own first dispatch reports nothing changed and the payload onCreate encoded is the only encode a create runs.

The layers, lowest priority first, each overriding the ones before it field by field:

  1. The schema's own declared defaults (Settings.defaultsOf).
  2. The type's own settingsDefaults(name, opts, asked) hook, when its behavior.luau exports one. asked holds what layers 3 to 5 name, so a default can follow a field one of them sets.
  3. The Preset Manager's matching row (Settings.presetLayerFor).
  4. The creating importer's settings for this type.
  5. The settings table the caller passed to asset.create.
  6. The type's own settingsDerivedDefaults(values, explicit, changed) hook, when its behavior.luau exports one — a field whose default follows another field, set only when no layer from 2 to 5 named it.
  7. The type's own settingsCorrections(values, name, opts) hook, when its behavior.luau exports one — applied last, once every other layer has merged.

Layers 2 to 5 are each read through the type's normalizeSettings, so a layer naming a field in an older spelling sets the field it names today. asset.create's top-level settings alias accepts those older names too (Settings.acceptedFieldNames).

Layer 7 is Settings.applyCorrections(typeName, values, explicit, context), the one corrections path every settings write takes — this create chain, and Settings.writeValues for every later write (asset.setSettings, a type's first-encode seed, the legacy heal). explicit names the fields the writer asked for: the caller's settings table here, the patch for asset.setSettings. A correction lands SILENTLY on a field the writer did not name — nobody asked for that value, so overriding it just states a fact. A correction that contradicts a field the writer named REFUSES the write, naming the field, the value asked for, and why it cannot hold — the same as every other place this codebase refuses an explicit request it cannot satisfy (a Field.checkSchema violation, a BC7 alignment error) rather than silently rewriting it into something else. So asset.setSettings(tex, { compression = "rgba16" }) on a mipped texture stores generateMipmaps = false beside it, while asset.setSettings(tex, { compression = "rgba16", generateMipmaps = true }) is refused.

Field.checkSchema runs once, over the result AFTER the corrections hook has applied — a bad value nothing corrects refuses the creation.

Four hooks, four different jobs

Every hook is an optional export on a type's behavior.luau, alongside onCreate / onChange. None is ever handed a table to mutate — each reads what it's given and RETURNS a table of what it wants changed (or nil); resolveForCreate, resolvePatch, applyDerivedDefaults and applyCorrections are the only places that write to the values, by merging what a hook returns the same way every other layer merges.

  • normalizeSettings(values) -> values maps every older spelling a stored file or a write may carry to the field and member the schema declares now, returning a new table; a table naming both an older field and its replacement keeps the replacement. The companion export normalizedSettingNames = { ... } lists the older field names it accepts beyond the schema, so a write naming one is not refused as an undeclared field. texture.assetType's maps the fields its earlier schema stored to colorSpace + compression, and the older filter keywords to linear / nearest.

  • settingsDerivedDefaults(values, explicit, changed) -> partial? answers the values of fields whose default follows another field. At creation changed holds every field; on a write it holds the fields the write moved. A returned field applies only where explicit — the fields the writer named — does not name it.

  • settingsDefaults(name, opts, asked) -> partial? supplies a DEFAULT the schema itself has no syntax for — something only knowable at creation time, like a naming convention, a fact read off a payload the caller handed in, or a value that follows a field a higher layer sets (asked holds what the Preset Manager, the importer and the caller name, the higher one's value where two name a field). It runs early (layer 2 above), so any later layer — Preset Manager, importer, caller — freely overrides it. texture.assetType's returns the textureType the name suggests (a _normal-suffixed name is a normalMap), compression = "uastc" and maxDimension = 4096, with rgba8 for a textureType of sprite that a higher layer names; for a pre-encoded ZTEX payload it returns the colour space, compression and sampler the payload's own header carries. Either way, an explicit settings = { ... } from the caller still wins.

  • settingsCorrections(values, name, opts) -> (partial?, reasons?) enforces a CROSS-FIELD CONSTRAINT the schema itself has no syntax for — a fact about one field that depends on another field's resolved value, true regardless of which layer produced that value. It runs last (layer 7 above), over the fully merged values, and again on every later write of the values file. Nothing is left unmet in what gets validated and stamped — but a value the caller explicitly asked for is never silently rewritten to satisfy it; applyCorrections refuses instead (see above). The optional second return, { [field]: reason }, supplies the "why" for that refusal message; a field with no reason falls back to a generic one. texture.assetType's returns { generateMipmaps = false, maxDimension = 8192, colorSpace = "linear" } whenever the RESOLVED compression is high-precision (the encoder stores one verbatim, linear, and refuses a mip chain or a downsample for it outright), paired with a reason naming the compression, and nil otherwise.

What does a declarer declare?

Field.settingsOf(identity, mode) embeds another declarer's schema as a struct field without restating it. The identity names one of two kinds of declarer, and Settings.schemaOfDeclarer(identity) tells them apart by what identity resolves to:

  • An assetType (e.g. "@builtin::assetTypes.texture") — embeds the schema every instance of that type carries, resolved via schemaForType. This is the type-level shared schema from the section above, not a per-instance one — an assetType has no settings of its own to declare per instance.
  • Anything else (an importer, or any other asset that declares settings of its own) — embeds that asset's own schema, resolved via schemaOfInstance.

Every schema this module serves has already run its Field.settingsOf fields through this resolution — a caller reading schema.someField.fields never sees an unresolved declarer with no fields. Resolution descends into an already-literal struct's fields and a list's element the same way, so a declarer embedded several fields deep is still reached. A declarer that embeds itself, directly or through another declarer, stops one level short of the loop instead of recursing forever.

What are the defaults?

Settings.defaultsOf(schema) reads every field's declared default into a plain table — a field declared without one is absent from the result rather than holding a nil value.