Description

Affects: 5.8 (verified on Release-5.8), Windows, Editor

Summary

Adding fx.Niagara.DataChannels.Enabled=true to an ini file makes the Niagara editor assert when a Data Channel Read is created. The same operation does not assert when the cvar is left untouched.

Steps to Reproduce

Add the following to <Project>/Config/DefaultEngine.ini:

[ConsoleVariables]
fx.Niagara.DataChannels.Enabled=true

Then:

  1. Launch the editor and open (or create) a Niagara System / Emitter.
  2. Create a Data Channel Read.
  3. The editor hits the checkf described below.

Observed Result

Assertion at Engine/Plugins/FX/Niagara/Source/Niagara/Private/NiagaraTypeRegistry.cpp:331:

bNeedsRegistration = !OldType.IsValid();
checkf(bNeedsRegistration || (NewType == OldType),
    TEXT("bNeedsRegistration: %d, NewType: %s, OldType: %s NewTypeHash: %x OldTypeHash: %x"), ...);

NewType and OldType are two different types that resolved to the same RegisteredTypeDefIndex, and the NewTypeHash / OldTypeHash values printed by the assert are identical.

Expected Result

Creating a Data Channel Read does not assert, regardless of whether the cvar was set from an ini file.

Analysis

1. Registration of the two NDC data interfaces happens only inside the cvar change callback

Engine/Plugins/FX/Niagara/Source/Niagara/Private/NiagaraModule.cpp:168

void INiagaraModule::OnDataChannelsEnabledChanged(IConsoleVariable* Variable)
{
    ...
    FNiagaraTypeRegistry::Register(UNiagaraDataInterfaceDataChannelWrite::StaticClass(), Flags);
    FNiagaraTypeRegistry::Register(UNiagaraDataInterfaceDataChannelRead::StaticClass(), Flags);

These are the only FNiagaraTypeRegistry::Register call sites for these two classes in the engine — there is no registration in StartupModule or anywhere else.

The cvar default is already true (bool INiagaraModule::bDataChannelsEnabled = true;), so writing =true in ini does not change the value, but the ini apply path still calls Set() and fires the change callback. The callback therefore runs during early startup, at a point where the UClass objects are not yet fully initialized and StaticClass()->GetFName() returns NAME_None.

2. The runtime type hash is derived purely from the FName chain

Engine/Plugins/FX/Niagara/Source/Niagara/Private/NiagaraModule.cpp:1659

void FNiagaraTypeDefinition::GeneratePathNameHash()
{
    if (ClassStructOrEnum)
    {
        UnderlyingTypePathNameHash = GetTypeHash(ClassStructOrEnum->GetFName());
        const UObject* Parent = ClassStructOrEnum;
        while ((Parent = Parent->GetOuter()) != nullptr)
        {
            UnderlyingTypePathNameHash = HashCombineFast(UnderlyingTypePathNameHash, GetTypeHash(Parent->GetFName()));
        }
    }
    else
    {
        UnderlyingTypePathNameHash = GetTypeHash(NAME_None);
    }
}

Engine/Plugins/FX/Niagara/Source/Niagara/Public/NiagaraTypes.h:1195

friend inline uint32 GetTypeHash(const FNiagaraTypeDefinition& Type)
{
    return HashCombine(HashCombine(Type.UnderlyingTypePathNameHash, GetTypeHash(Type.UnderlyingType)), GetTypeHash(Type.GetFlags()));
}

The hash has only three inputs. When GetFName() returns NAME_None the name chain contributes nothing distinguishing, and UnderlyingType and Flags are identical for the two data interfaces (the same Flags value is passed at both call sites). All three inputs therefore match, so UNiagaraDataInterfaceDataChannelRead and UNiagaraDataInterfaceDataChannelWrite produce the same type hash.

3. The registry dedupes on that hash, so the second registration silently aliases onto the first

Engine/Plugins/FX/Niagara/Source/Niagara/Private/NiagaraTypeRegistry.cpp:290

const uint32 TypeHash = GetTypeHash(NewType);
...
if (const int32* ExistingIndex = RegisteredTypeIndexMap.Find(TypeHash))
{
    NewType.RegisteredTypeDefIndex = *ExistingIndex;
}

Both classes end up sharing a single RegisteredTypeDefIndex, i.e. a single entry in RegisteredTypes.

4. The assert fires when a correctly-named type later reaches the shared index

When a properly initialized Data Channel Read type reaches RegisterTypeInternal, it resolves onto that shared/stale index. OldType is valid so bNeedsRegistration is false, NewType != OldType, and the checkf at NiagaraTypeRegistry.cpp:331 fires.

Additional Notes

  • GetPersistentTypeHash() in the same header uses GetPathNameSafe(Type.ClassStructOrEnum) rather than GetFName(), so it does not degrade to NAME_None. The runtime GetTypeHash() does.
  • The checkf message already prints NewTypeHash / OldTypeHash, which makes the collision directly visible in the assert output.
  • The same callback additionally runs {{FNiagaraWorldManager::ForAllWorldManagers([](FNiagaraWorldManager& WorldMan) { WorldMan.GetDataChannelManager().Init(); }

    )}} and FNiagaraSystemUpdateContext Context; Context.AddAll(true); (commented "Re-init everything for now") during this early startup invocation, even though the cvar value did not actually change.

Have Comments or More Details?

There's no existing public thread on this issue, so head over to Questions & Answers just mention UE-399252 in the post.

0
Login to Vote

Unresolved
ComponentUE - Rendering - Architecture - Niagara
Affects Versions5.8
Target Fix6.0
CreatedSep 28, 2026
UpdatedSep 30, 2026
View Jira Issue