Every change to the model can be observed. Listeners are ordinary delegates; the payload is a
UPsDataEvent that knows its type, the data it started from, and (optionally) the old and new value.
PsData is game-thread only. Its event dispatch and scope guard are process-wide statics with no locking.
A property with Event meta broadcasts <Name>Changed every time its value changes. The broadcast
is synchronous: listeners have run by the time the assignment returns. Assigning the value it
already holds broadcasts nothing.
// in the class
DMETA(Event, EventStorage)
DPROP(int32, Health);
// anywhere
// A member: unbinds everything when destroyed
FPsDataBindCollection Binds;
Binds.Add(Character->Health.Bind(
FPsDataDelegate::CreateLambda([Character](UPsDataEvent* Event) {
// Needs EventStorage meta
const auto Storage = Character->Health.GetEventStorage(Event);
UE_LOG(LogTemp, Log, TEXT("Health %d -> %d"),
Storage->GetPreviousValue(), Storage->GetValue());
})));There are three ways to bind, all returning an FPsDataBind:
| Call | Listens to |
|---|---|
Data->Field.Bind(Delegate, Flags) |
that field's changed event |
Data->Bind(EventType, Delegate, Flags) |
any event type by name — UFooData::GetHealthChangedEventName(), UPsDataEvent::Changed, a custom Event = Name |
Data->Collection->Bind(UPsDataEvent::Added / Removed, Delegate, Flags) |
elements entering or leaving that one collection |
Delegates are FPsDataDelegate (any C++ callable) or FPsDataDynamicDelegate (a UFUNCTION,
what Blueprints use). Keep the returned FPsDataBind in an FPsDataBindCollection and call
Unbind() on it in EndPlay/Deinitialize; Data->UnbindAll(Object) removes every listener a
given object owns.
A field without Event is silent — no event object is built, nothing is dispatched. A listener
can opt in with EDataBindFlags::IgnoreFieldMeta:
Binds.Add(Character->Kills.Bind(Delegate, EDataBindFlags::IgnoreFieldMeta));Once at least one such listener exists, the field broadcasts, and every listener of that event
hears it, not only the IgnoreFieldMeta one (PsData.Events.Field pins this down).
With Bubbles, the event travels from the data that changed up through every ancestor. A listener
anywhere above sees it; Event->GetTarget() is the data where it started.
// UFooPointData
DMETA(Event, Bubbles)
DPROP(int32, X);
// one listener for every character's position on the whole battle
Binds.Add(Battle->Bind(UFooPointData::GetXChangedEventName(),
FPsDataDelegate::CreateLambda([](UPsDataEvent* Event) {
const auto Point = Cast<UFooPointData>(Event->GetTarget());
const auto Character = Cast<UFooCharacterData>(Point->GetParent());
})));Event->StopPropagation() stops the event at the current level after its listeners run;
StopImmediatePropagation() also skips the remaining listeners on the current level.
| Type | On | Bubbles | When |
|---|---|---|---|
UPsDataEvent::Added |
the child, then its parent | yes | the child was put into a collection or a data property |
UPsDataEvent::Removed |
the child, then its parent | yes | the child was taken out |
UPsDataEvent::AddedToParent / RemovedFromParent |
the child | no | same moments, child side only |
UPsDataEvent::AddedToRoot / RemovedFromRoot |
the child and every descendant | no | the subtree became reachable / unreachable from a UPsDataRoot |
UPsDataEvent::NameChanged |
the child | no | its key or collection changed |
UPsDataEvent::Changed |
the data whose field changed | no | deferred, see below |
Binding Added/Removed through a collection proxy (Battle->Characters->Bind(...)) filters to
that collection; binding on the owner (Battle->Bind(UPsDataEvent::Added, ...)) hears every
element added anywhere below it, since the event bubbles.
UPsDataEvent::Changed answers "did anything about this object change?" without a listener per
field. It does not fire on each assignment: the first change schedules a game-thread task, and the
task broadcasts once, however many fields changed in between. It is per object — a change in a child
does not mark its parent — and it does not bubble.
Binds.Add(Settings->Bind(UPsDataEvent::Changed,
FPsDataDelegate::CreateUObject(this, &UFooSettingsSubsystem::RequestSave)));
Settings->MasterVolume = 0.5f;
Settings->Language = TEXT("en");
// RequestSave runs once, after this code returns, on the game threadThis is the event to hang persistence, dirty flags and UI refreshes on.
Inside a DEFERRED_EVENT_PROCESSING() scope, listeners are held until the outermost scope ends,
then run in order. Listeners bound with EDataBindFlags::NonDeferred still run inline.
{
DEFERRED_EVENT_PROCESSING();
Character->Health = 0;
Character->Deaths += 1;
Battle->Characters->Remove(Character->GetDataKey());
// Nothing bound without NonDeferred has run yet:
// no listener sees a half-applied state
}
// all of it is delivered hereEvery single assignment already runs inside such a scope, so a listener never observes a property
mid-write. Link caches use NonDeferred internally, which is why a link is correct even inside a
held scope.
FPsDataSimplePromise and TPsDataPromise<T> are one-shot readiness signals: Bind before
Resolve runs on Resolve; Bind after it runs immediately. The plugin's own is
FDataDelegates::PostDataModuleInitPromise, resolved when PsData's reflection is compiled — the
moment from which a model can be built (see model.md).
FPsDataSimplePromise ModelReady; // a member of whatever builds the model
// elsewhere, at any time
ModelReady.Bind(FSimpleDelegate::CreateUObject(this, &UFooHud::Subscribe));