Preset schema — migration ledger
Preset fields, defaults and the migration ledger.
Assumes you have finished quick-start.
This page records how the kit versions the data it saves into your assets — presets and interactable components — and what it promises about assets you saved with an earlier kit version.
What is versioned
One kit-wide schema version, UK::Interaction::CurrentSchemaVersion, covers all native
fields of all versioned types — the preset base, every concrete preset class, every params
struct, FUKPresetOverrides, FUKInteractableState and UUKInteractableComponent. There is no
per-type version.
(What the three behaviour params structs mean, and which preset carries which, is behaviors.md — this page owns only their versioning.)
(The three stateful preset classes — UUKPreset_Door, UUKPreset_Lever, UUKPreset_Pickup —
and their params structs are covered by the same kit-wide version and ship at SchemaVersion 1.
They are new types, not new fields on a v1 type, so no asset written under v1 can contain them
and the ledger below gains no row. What each one does is presets.md.)
(LockPolicy on the preset base: ExclusiveQueue needs a Lock component beside the interactable — Docs/lock.md › Setting it up.)
Storage of record is the engine custom version FUKInteractionCustomVersion::GUID, registered
with FCustomVersionRegistration. The visible SchemaVersion UPROPERTY is a mirror, written
at the end of migration, whose class default is 0.
The GUID (fixed for the life of the kit — never regenerate it):
FGuid(0x554B4954, 0x45524143, 0x54494F4E, 0x53434831) // 554B4954-45524143-54494F4E-53434831
registered as TEXT("UKInteractionVer") at FUKInteractionCustomVersion::LatestVersion, in
Source/UKInteraction/Private/Interactable/Schema/UKInteractionCustomVersion.cpp — the one
registration site (a second registration of the same GUID asserts inside the engine).
Regenerating it orphans every asset a customer has ever saved: the kit would read them as unstamped and migrate them from v1 a second time. It is the identity every shipped asset carries in its package header, including the Lite
DA_Instant.
A plain property defaulted to
Currentis forbidden. Tagged-property delta serialization against the archetype would never write it, so every legacy asset would silently readCurrentand no migration would ever run. Class default0means "unstamped", and unstamped is a state the kit can detect.
Bump rule
Any added, removed, retyped or renamed non-transient, non-editor-only UPROPERTY on a
versioned type is SchemaVersion + 1. The kit's release tests enforce this mechanically with a
CRC32 fingerprint of every versioned type — a changed fingerprint with an unchanged version, or a
bumped version with no ledger row, fails the release.
Every bump ships all of: a row below, a pure idempotent migration step, and a serialized legacy test blob per affected type.
Retention promise
Every schema version that shipped in a Fab-listed release migrates forward for the life of 1.x.
A migration step is never removed while the major version is 1, and no ledger row is ever marked
removed.
Version ledger
version | kit_version | date | change | affected_types | golden_values | fixtures |
|---|---|---|---|---|---|---|
| v1 | 0.1.0 | 2026-09-06 | initial schema - all 10 MVP versioned types | 10 types | — | legacy/current/UUKPreset_Instant.bin, legacy/unstamped/UUKPreset_Instant.bin, legacy/newer/UUKPreset_Instant.bin (stamped Current + 1), legacy/synthetic/FUKMigrationProbe_v1.bin |
Why v1 has no golden_values and no fixtures. Both columns describe a migration step, and
no step ends at v1 — there is no v0. migration_path(v, Current) is empty for every v in
1 … Current, so there is nothing to migrate yet. The migration machinery itself is exercised by
the kit's tests against a synthetic ledger (Current = 4, three steps).
Migration entry points
| Object kind | Entry point |
|---|---|
| Preset assets | PostLoad |
| Component archetypes (map-serialized instances, Blueprint SCS templates, inherited-component records) | PostLoad |
| Component instances constructed from a template (SCS-spawned, runtime-spawned) | PostInitProperties, guarded by !HasAnyFlags(RF_ClassDefaultObject | RF_NeedLoad | RF_WasLoaded) |
Every step is idempotent and pure: no I/O, no LoadObject / FindObject /
StaticLoadObject, no GetOwner(), no GetWorld(). That is what makes it safe in a cook and on
a dedicated server, and the kit's tests check it mechanically.
PreSave(FObjectPreSaveContext) only ensures that the mirror equals Current, and returns
early when SaveContext.IsCooking(). It never stamps.
(Signature confirmed on the 5.6 headers — see "Engine facts confirmed on 5.6" below.)
Out-of-band versions
| Case | Behaviour |
|---|---|
| Unstamped — no kit custom version in the archive (hand-made or pre-release data) | Treated as version 1, logged once. Not an error. |
Newer — custom version > Current, written by a newer kit | No migration. Unknown properties are dropped by the engine, one Error is logged, UK-VAL-023 (Error) is raised, usability is InvalidSetup. The dropped fields could be the safety-relevant ones, so the kit refuses to guess. |
| Missing chain step | Halt — the kit's release checks make this unreachable in a shipped kit. |
Renames
Renames are avoided: deprecate and migrate instead. Where unavoidable they would ship as
[CoreRedirects] +PropertyRedirects in a plugin config file, plus a ledger row.
Measured on 5.6, 5.7 and 5.8. A [CoreRedirects] block in the plugin's Config/DefaultEngine.ini does NOT reach the host
project on 5.6, 5.7 or 5.8: a probe property renamed behind such a redirect read its default (0),
identical to the no-config control. The same redirect in the plugin's Config/Engine.ini DOES
take effect on all three (the probe read 42, the pre-rename value). The cause is the same one
that applies to Game.ini: FConfigCacheIni::AddPluginsToBranches matches a
plugin config file by its base filename against the config branch name, so DefaultEngine.ini
matches no branch and is inert. The general rule for this kit: a plugin config file must be named
Config/<BranchName>.ini; a Default… prefix is inert.
Consequence — the kit does not rename shipped preset fields. It ships no
[CoreRedirects] in this version, and the only filename that would work is one the kit does not
use. If a rename is ever adopted, it will ship in Config/Engine.ini — never
Config/DefaultEngine.ini — together with a ledger row.
Customer subclasses
You may subclass any concrete preset class (UUKPreset_Instant, UUKPreset_Hold,
UUKPreset_Toggle, …) in Blueprint or in C++. The abstract base UUKInteractionPreset is
NotBlueprintable and is not a subclassing target.
Your subclass binds to the nearest kit concrete ancestor — UUKInteractionPreset::GetBindingClass()
walks your class and its parents and returns the first one that is a kit concrete class, so
BP_MyDoor : UUKPreset_Hold behaves as a Hold with no registration of its own; a preset class with
no kit concrete ancestor binds to nothing and raises UK-VAL-027.
The boundary is simple:
| Owned by | Versioned by the kit? | In this ledger? | |
|---|---|---|---|
| Native fields declared by kit types | the kit | Yes | Yes |
| Blueprint-added variables on your subclass | you | No | No |
Your Blueprint variables are your data. The kit never migrates them, never renames them, never drops them, and never mentions them in this ledger. A kit upgrade that bumps the schema migrates the kit's own fields on your asset and leaves everything you added untouched.
Two consequences worth stating plainly:
- Your subclass keeps working across kit upgrades. When the kit adds a field, your asset is
migrated in place, marked dirty in the editor, and the validator reports
UK-VAL-022(Info: "N presets migrated from schema vX — resave"). Resave and you are done. - The kit cannot help you version your own fields. If you rename a Blueprint variable on your subclass, that is an ordinary Blueprint rename with ordinary Blueprint consequences — the kit's custom version and migration chain have no opinion about it.
Binding class. Your subclass binds behaviour to its nearest kit concrete ancestor, not to
itself: BP_Preset_Hold_Long : UUKPreset_Hold behaves as Hold. A class with no kit concrete
class anywhere in its ancestry has no binding class — UK-VAL-027 (Error) and usability
InvalidSetup.
The first two fields you will edit
The two sections below are the tooltip targets for PromptText and InteractionRange. They
document the fields you are most likely to edit first after the quick start, which is why they live
on the schema page rather than in a behaviour page: both are declared on UUKInteractionPreset
itself, so every preset class has them.
Prompt text
PromptText (FText, default Interact) is the line the prompt shows while the interactable
is in focus. It is authored on the asset and localized through that asset's own text data
— there is no schema-level localization namespace, and the default is an NSLOCTEXT literal
rather than a culture-invariant string precisely so it round-trips through the localization
pipeline.
One preset serves many actors, so the per-instance override is the normal way to differ: tick
Override Prompt Text on the component and set its own PromptText. The resolved value — the
override when its flag is set, otherwise the preset's — is what the prompt reads, and
Get Resolved Prompt Text returns it on every machine. The demo's crate is exactly this shape:
the shipped DA_Instant asset, unmodified, with one instance override.
PromptText is not part of SchemaVersion compatibility beyond being a normal serialized
property: renaming the field would be a rename entry (§ Renames), changing its default is not.
Interaction range
InteractionRange (float, centimetres, default 250, clamped to 10 — 10000) is the
maximum distance at which this interactable can be focused and used. Two systems read it: the
interactor's candidate filter uses it to decide what enters focus, and the server's reachability
check re-tests it on arrival with its own tolerance — so raising it on a client changes nothing
the server will accept.
Range is measured from the interactor's eye position to the interactable's interaction point, not
between actor origins; an interactable on a large mesh usually wants an explicit interaction point
rather than a larger range. Unlike PromptText, it has no per-instance override: the resolved range is
always the preset's value, so change it on the preset (or on a copy of the preset).
Legacy fixtures
Every migration path is tested against serialized blobs, never hand-written data. The kit's test
suite keeps blobs at each shipped version N < Current, blobs stamped at Current + 1 (the
downgrade case, regenerated on every bump), blobs with no kit custom version at all (the unstamped
case), a blob whose values sit outside the field ranges (the PostLoad clamp path), a control blob
stamped at exactly Current, and cook-lane packages saved on the oldest supported engine (5.6)
— package file versions only move forward. At Current = 1 there is no N < Current, so the
per-version replay has nothing to replay yet.
The blobs are not written with FObjectWriter: that pair serializes FNames by per-process
index, so a stored blob would be read with the wrong property tags in any later process. They are
written through FMemoryWriter/FMemoryReader behind FObjectAndNameAsStringProxyArchive, and the
versioned pair prefixes a {magic, stamped version} header because the proxy persists no
custom-version container of its own. The reader always seeds SetCustomVersions — with an
empty container for an unstamped blob, because a loading archive whose versions were never set
is filled from the live registry and would report the kit's latest version for a blob that carries
none. The same applies if you write migration tests of your own.
Engine facts confirmed on 5.6
(Measured 2026-09-06 against the launcher 5.6 headers.)
| Fact | 5.6 header | Result |
|---|---|---|
AGameStateBase::GetServerWorldTimeSeconds() return type | Engine/Classes/GameFramework/GameStateBase.h:72 | double — the kit's double PhaseStartServerTime needs no narrowing |
UObject::PreSave signature | CoreUObject/Public/UObject/Object.h:286 | PreSave(FObjectPreSaveContext) — the deprecated const ITargetPlatform* overload is gone |
UDataAsset UCLASS specifiers | Engine/Classes/Engine/DataAsset.h:19 | abstract, MinimalAPI, Meta = (LoadBehavior = "LazyOnDemand") — no Const. The kit depends on neither answer: the base enforces read-only fields itself via EditAnywhere, BlueprintReadOnly |