Builtin Library
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:
- The schema's own declared defaults (
Settings.defaultsOf). - The type's own
settingsDefaults(name, opts, asked)hook, when itsbehavior.luauexports one.askedholds what layers 3 to 5 name, so a default can follow a field one of them sets. - The Preset Manager's matching row (
Settings.presetLayerFor). - The creating importer's settings for this type.
- The
settingstable the caller passed toasset.create. - The type's own
settingsDerivedDefaults(values, explicit, changed)hook, when itsbehavior.luauexports one — a field whose default follows another field, set only when no layer from 2 to 5 named it. - The type's own
settingsCorrections(values, name, opts)hook, when itsbehavior.luauexports 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) -> valuesmaps 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 exportnormalizedSettingNames = { ... }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 tocolorSpace+compression, and the olderfilterkeywords tolinear/nearest. -
settingsDerivedDefaults(values, explicit, changed) -> partial?answers the values of fields whose default follows another field. At creationchangedholds every field; on a write it holds the fields the write moved. A returned field applies only whereexplicit— 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 (askedholds 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 thetextureTypethe name suggests (a_normal-suffixed name is anormalMap),compression = "uastc"andmaxDimension = 4096, withrgba8for atextureTypeofspritethat 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 explicitsettings = { ... }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 mergedvalues, 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;applyCorrectionsrefuses 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 RESOLVEDcompressionis 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, andnilotherwise.
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 viaschemaForType. 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.