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 Current is forbidden. Tagged-property delta serialization against the archetype would never write it, so every legacy asset would silently read Current and no migration would ever run. Class default 0 means "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

versionkit_versiondatechangeaffected_typesgolden_valuesfixtures
v10.1.02026-09-06initial schema - all 10 MVP versioned types10 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 kindEntry point
Preset assetsPostLoad
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

CaseBehaviour
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 kitNo 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 stepHalt — 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 byVersioned by the kit?In this ledger?
Native fields declared by kit typesthe kitYesYes
Blueprint-added variables on your subclassyouNoNo

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:

  1. 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.
  2. 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.)

Fact5.6 headerResult
AGameStateBase::GetServerWorldTimeSeconds() return typeEngine/Classes/GameFramework/GameStateBase.h:72double — the kit's double PhaseStartServerTime needs no narrowing
UObject::PreSave signatureCoreUObject/Public/UObject/Object.h:286PreSave(FObjectPreSaveContext) — the deprecated const ITargetPlatform* overload is gone
UDataAsset UCLASS specifiersEngine/Classes/Engine/DataAsset.h:19abstract, MinimalAPI, Meta = (LoadBehavior = "LazyOnDemand") — no Const. The kit depends on neither answer: the base enforces read-only fields itself via EditAnywhere, BlueprintReadOnly