Handler Template
Reference C++ implementation for creating a new document handler (worked RecipeHandler example).
A handler owns one YAML root type and runs in one fixed EKDFStage. It receives parsed FKDFNode
documents and never sees raw YAML text directly. Third-party mods write handlers when they want
KDataForge’s validation, dry-run preview, diagnostics, and patch records applied to a new root type.
Every handler normally derives from UKDFHandlerBase, which lives in the KDataForgeApi module.
Registration uses UKDFSubsystem from the main KDataForge module, so the registering mod links
both. See
Module Structure for why that module
boundary exists and what else lives on either side of it. See
API Design for the rest of the public contract
surface.
Minimal Skeleton
Section titled “Minimal Skeleton”A handler is normally two files: a header declaring the UCLASS and overrides, and a .cpp
implementing them. The example below is a UMyRecipeHandler that owns a type: recipe document
containing a recipes sequence. Start with the header:
#pragma once
#include "CoreMinimal.h"
#include "KDFHandlerBase.h"
#include "MyRecipeHandler.generated.h"
UCLASS()class MYMOD_API UMyRecipeHandler : public UKDFHandlerBase{ GENERATED_BODY()
public: UMyRecipeHandler();
virtual bool ValidateDocument(const FKDFNode& Document, FKDFValidationContext& Context) override; virtual bool ApplyDocument(const FKDFNode& Document, FKDFApplyContext& Context) override;};The constructor sets the three identity fields that UKDFHandlerBase exposes: mRootType,
mStage, and mPriority. ValidateDocument does cheap structural checks before anything is
written, and ApplyDocument does the actual work. Only ApplyDocument is mandatory.
UKDFHandlerBase’s default ValidateDocument simply returns true:
#include "MyRecipeHandler.h"
UMyRecipeHandler::UMyRecipeHandler(){ mRootType = TEXT("recipe"); // matches `type: recipe` documents mStage = EKDFStage::Recipes; // fixed stage never reordered by priority mPriority = 0; // tie-break within (stage, root type); higher runs earlier}
bool UMyRecipeHandler::ValidateDocument(const FKDFNode& Document, FKDFValidationContext& Context){ if (Document.Find(TEXT("recipes")) == nullptr) { Context.AddError(TEXT("recipe document requires a 'recipes' sequence")); return false; // skips ApplyDocument; never halts the load } return true;}
bool UMyRecipeHandler::ApplyDocument(const FKDFNode& Document, FKDFApplyContext& Context){ const FKDFNode* Recipes = Document.Find(TEXT("recipes")); for (const TSharedRef<FKDFNode>& Recipe : Recipes->Sequence) { const FString Id = Recipe->GetString(TEXT("id"), FString()); if (Id.IsEmpty()) { Context.AddError(TEXT("recipe entry needs an 'id'"), Recipe->Line); continue; // per-entry problems are diagnostics, keep going } if (Context.bDryRun) { continue; // resolve/validate only no writes } // … create/modify the recipe class, write properties via FKDFOpEngine/FKDFValueCodec … ++Context.mAppliedOpCount; if (Context.mPatchRecord != nullptr) { FKDFOpRecord& Op = Context.mPatchRecord->mOps.AddDefaulted_GetRef(); Op.mTargetObjectPath = /* target path */ Id; Op.mOp = EKDFOp::Set; } } return Context.mAppliedOpCount > 0;}Stage order is fixed and shared across every handler, whether built-in or third-party. See
Architecture for the full
GameTags → Assets → Localization → CDOChanges → Items → Recipes → Buildings → … pipeline that your
mStage slots into. mPriority only breaks ties between multiple handlers that share the same
(stage, root type) pair. It never lets a handler jump into a different stage.
Registration (your mod’s game instance module)
Section titled “Registration (your mod’s game instance module)”A handler object is useless until KDataForge knows about it. Registration has a hard timing
requirement: it must happen during the CONSTRUCTION lifecycle phase, because KDataForge’s own
RunInitialLoad() (scan, parse, validate, apply) runs at the later INITIALIZATION phase.
Register too late and your handler simply never sees any documents. Do it from your own game instance module’s lifecycle dispatch:
#include "Module/GameInstanceModuleManager.h"#include "Subsystems/KDFSubsystem.h"
void UMyModGameInstanceModule::DispatchLifecycleEvent(ELifecyclePhase Phase){ Super::DispatchLifecycleEvent(Phase); if (Phase == ELifecyclePhase::CONSTRUCTION) // MUST be CONSTRUCTION — { // KDataForge applies at INITIALIZATION UGameInstanceModuleManager* Modules = GetGameInstance()->GetSubsystem<UGameInstanceModuleManager>(); if (Modules != nullptr) { Modules->EnsureSubsystemInitialized(UKDFSubsystem::StaticClass()); if (UKDFSubsystem* KDF = UKDFSubsystem::Get(GetGameInstance())) { KDF->RegisterHandler(NewObject<UMyRecipeHandler>(KDF)); } } }}Add KDataForgeApi and KDataForge to your module dependencies. The explicit initialization is
required because SML may dispatch lifecycle phases before custom GameInstance subsystems exist.
Contract Checklist
Section titled “Contract Checklist”UKDFHandlerBase is forgiving about what you override, but diagnostics, dry-run preview, reports,
and editor history depend on a few contracts. Run through these before shipping a handler:
-
mRootTypeis lowercase and unique to your mod, or intentionally shared: all handlers for a type run, priority-ordered. - Report diagnostics via
Context.AddError/AddWarning/AddInfo. Never halt, and never callcheck()on user data. - Honor
Context.bDryRun: resolve and validate, but do not write. - Record ops into
Context.mPatchRecord. Reporting and editor history depend on it. - If you write CDO properties yourself, snapshot originals first via
UKDFSubsystem::GetVanillaCache().RecordSnapshot(...)and retain patched objects viaRetainObject(...). - Optional overrides are
ExportObjectfor editor export andGetEditorPanelClassfor a custom editor panel. The current interface has noRevertDocumenthook.
