Add a blueprint exporter for claude code

This commit is contained in:
2026-02-15 07:13:53 -05:00
parent f88a969ab4
commit 218863d077
13 changed files with 509 additions and 115 deletions

View File

@@ -86,6 +86,10 @@ Blueprints call into Lua via two mechanisms:
Look-at widgets, hotkeys, and menus are built on top of this. The menu system is implemented entirely in "user space" Lua and blueprint code. See `Docs/Displaying Widget Blueprints.md`.
## Blueprint Text Export
Blueprints are automatically exported to readable text files in `Saved/BlueprintExports/` whenever they are saved in the editor. This lets Claude Code read blueprint logic. See `Docs/Blueprint Text Export.md` for format details. Source: `Source/Integration/BlueprintExporter.h/.cpp` and `Source/Integration/Integration.cpp`.
## Key Documentation
- `Docs/Predictive Reexecution.md` — how the four world models stay in sync
@@ -99,9 +103,11 @@ Look-at widgets, hotkeys, and menus are built on top of this. The menu system is
- `Docs/Global Variables.md` — different types of global data and their transmission rules
- `Docs/Correct Implementation of Blocking Operations and NoPredict.md` — how to handle blocking ops
- `Docs/Difference Transmission with Threads.md` — why concurrent diff transmission is hard
- `Docs/Blueprint Text Export.md` — how blueprints are auto-exported to readable text
## Blueprint Coding Conventions
## Coding Conventions
- Do not use static functions in Unreal code. Use class methods or namespace-scoped functions instead.
- When writing UFUNCTIONs that take an `AActor*`, `UObject*`, or similar "self" parameter, add `DefaultToSelf` meta to that pin. Most functions should have this on the obvious pin so the user doesn't have to manually wire it in blueprints.
## Session Startup

Binary file not shown.

View File

@@ -0,0 +1,50 @@
# Blueprint Text Export
Blueprints are stored as binary `.uasset` files that Claude Code cannot read directly. To work around this, a text exporter automatically converts blueprint graphs to readable text files whenever a blueprint is saved in the Unreal editor.
## How It Works
The game module (`FlxIntegrationModuleImpl` in `Source/Integration/Integration.cpp`) hooks into `UPackage::PackageSavedWithContextEvent`. When a blueprint is saved, it iterates each `UEdGraph` in the blueprint and runs `FlxBlueprintExporter` on it. The output is written to `Saved/BlueprintExports/<BlueprintName>/<GraphName>.txt`.
The exporter class (`Source/Integration/BlueprintExporter.h/.cpp`) processes one graph at a time. The constructor runs all passes and the result is available via `GetOutput()`.
## Output Format
Each file has two sections:
**NodeList** maps readable node names to GUIDs:
```
NodeList:
Event_Tick = 44BAE739C72246DD9E9A72803C3B67CA
Set_Tick_Delta_Seconds = 204486800C0C4906A456A378F9F7ADE4
```
**Graph** shows the flow with pins and connections:
```
Graph:
Event_Tick
return Output_Delegate,Delta_Seconds
goto Set_Tick_Delta_Seconds
Set_Tick_Delta_Seconds
Real Tick_Delta_Seconds = Event_Tick.Delta_Seconds
return Output_Get
goto CallFunctionByName
```
- Input data pins: `Type Name = Source` where Source is a `Node.Pin` reference, a literal value, `<self>`, or `<default>`.
- Output data pins: `return Pin1,Pin2`.
- Exec flow: `goto Target` (single output), `goto Target if PinName` (multiple outputs), or `then goto`/`else goto` (branch nodes).
- String defaults are shown in quotes.
- Variable get nodes are inlined (the variable name appears directly at the point of use).
- Comment nodes appear as `// comment text`.
- Knot (reroute) nodes are followed through transparently.
## Node Ordering
Nodes are sorted by a traversal algorithm: find starter nodes (exec output but no exec input), sort them by Y position, then traverse each. The traversal visits a node's inputs first (so data sources appear before their consumers), then emits the node itself, then follows exec outputs. This produces a readable top-down flow. Any unvisited nodes are appended at the end.
## Node Naming
Names are derived from `ENodeTitleType::ListView` titles, sanitized to alphanumeric plus underscores. Duplicates get `_2`, `_3`, etc.

View File

@@ -0,0 +1,340 @@
#if WITH_EDITOR
#include "BlueprintExporter.h"
#include "Engine/Blueprint.h"
#include "EdGraph/EdGraph.h"
#include "EdGraph/EdGraphNode.h"
#include "EdGraph/EdGraphPin.h"
#include "EdGraphSchema_K2.h"
#include "K2Node_Knot.h"
#include "EdGraphNode_Comment.h"
#include "K2Node_VariableGet.h"
#include "K2Node_CallFunction.h"
FlxBlueprintExporter::FlxBlueprintExporter(UEdGraph* InGraph)
: Graph(InGraph)
{
SortNodes();
AssignNodeNames();
EmitNodeList();
EmitGraph();
}
void FlxBlueprintExporter::AssignNodeNames()
{
TMap<FString, int32> NextIndex;
for (UEdGraphNode* Node : SortedNodes)
{
FString Base = SanitizeName(Node->GetNodeTitle(ENodeTitleType::ListView).ToString());
int32& Idx = NextIndex.FindOrAdd(Base, 0);
FString Name = (Idx == 0) ? Base : FString::Printf(TEXT("%s_%d"), *Base, Idx + 1);
NodeNames.Add(Node, Name);
Idx++;
}
}
void FlxBlueprintExporter::Traverse(UEdGraphNode* Node)
{
if (Visited.Contains(Node)) return;
Visited.Add(Node);
// First, traverse input nodes
for (UEdGraphPin* Pin : Node->Pins)
{
if (Pin->Direction != EGPD_Input) continue;
for (UEdGraphPin* LinkedPin : Pin->LinkedTo)
{
Traverse(LinkedPin->GetOwningNode());
}
}
// Add this node to the sorted list.
SortedNodes.Add(Node);
// Then, traverse exec output nodes only.
// Data outputs are not followed — data nodes get pulled in
// through their consumers' input traversal.
for (UEdGraphPin* Pin : Node->Pins)
{
if (Pin->Direction != EGPD_Output) continue;
if (Pin->PinType.PinCategory != UEdGraphSchema_K2::PC_Exec) continue;
for (UEdGraphPin* LinkedPin : Pin->LinkedTo)
{
Traverse(LinkedPin->GetOwningNode());
}
}
}
bool FlxBlueprintExporter::HasExecPin(UEdGraphNode* Node, EEdGraphPinDirection Direction)
{
for (UEdGraphPin* Pin : Node->Pins)
{
if (Pin->PinType.PinCategory == UEdGraphSchema_K2::PC_Exec)
{
if (Pin->Direction == Direction) return true;
}
}
return false;
}
void FlxBlueprintExporter::SortNodes()
{
// Find starter nodes: have exec output but no exec input.
TArray<UEdGraphNode*> Starters;
for (UEdGraphNode* Node : Graph->Nodes)
{
if (HasExecPin(Node, EGPD_Output) && !HasExecPin(Node, EGPD_Input))
{
Starters.Add(Node);
}
}
// Sort starters by Y position.
Starters.Sort([](const UEdGraphNode& A, const UEdGraphNode& B)
{
return A.NodePosY < B.NodePosY;
});
// Traverse from each starter.
for (UEdGraphNode* Starter : Starters)
{
Traverse(Starter);
}
// Traverse all nodes.
for (UEdGraphNode* Node : Graph->Nodes)
{
Traverse(Node);
}
}
void FlxBlueprintExporter::EmitNodeList()
{
Output.Appendf(TEXT("NodeList:\n"));
for (UEdGraphNode* Node : SortedNodes)
{
if (Node->IsA<UK2Node_Knot>()) continue;
if (Node->IsA<UEdGraphNode_Comment>()) continue;
if (Node->IsA<UK2Node_VariableGet>()) continue;
Output.Appendf(TEXT(" %s = %s\n"),
*NodeNames[Node], *Node->NodeGuid.ToString());
}
}
FString FlxBlueprintExporter::GetPinDisplayName(UEdGraphPin *Pin)
{
FString Result = Pin->GetOwningNode()->GetPinDisplayName(Pin).ToString();
if (!Result.IsEmpty()) return Result;
return Pin->PinName.ToString();
}
UEdGraphPin* FlxBlueprintExporter::FindFirstPin(UEdGraphNode* Node, EEdGraphPinDirection Direction)
{
for (UEdGraphPin* Pin : Node->Pins)
{
if (Pin->Direction == Direction) return Pin;
}
return nullptr;
}
UEdGraphPin* FlxBlueprintExporter::GetLinkedTo(UEdGraphPin* Pin)
{
while (true)
{
if (Pin == nullptr) return nullptr;
if (Pin->LinkedTo.IsEmpty()) return nullptr;
UEdGraphPin *LinkedTo = Pin->LinkedTo[0];
if (!LinkedTo->GetOwningNode()->IsA<UK2Node_Knot>()) return LinkedTo;
Pin = FindFirstPin(LinkedTo->GetOwningNode(), Pin->Direction);
}
}
FString FlxBlueprintExporter::FormatPinType(UEdGraphPin* Pin)
{
if (UObject* SubObj = Pin->PinType.PinSubCategoryObject.Get())
{
return SubObj->GetName();
}
FString Type = Pin->PinType.PinCategory.ToString();
Type[0] = FChar::ToUpper(Type[0]);
return Type;
}
FString FlxBlueprintExporter::FormatPinSource(UEdGraphPin* Pin)
{
// If connected, show source node.pin
UEdGraphPin* LinkedTo = GetLinkedTo(Pin);
if (LinkedTo != nullptr)
{
UEdGraphNode* LinkedToNode = LinkedTo->GetOwningNode();
// For variable get nodes, just show the variable name.
if (UK2Node_VariableGet* VarGet = Cast<UK2Node_VariableGet>(LinkedToNode))
return SanitizeName(VarGet->GetVarNameString());
FString PinLabel = SanitizeName(GetPinDisplayName(LinkedTo));
FString* Name = NodeNames.Find(LinkedToNode);
return FString::Printf(TEXT("%s.%s"), **Name, *PinLabel);
}
// String pins: always show in quotes (even if empty).
FName Category = Pin->PinType.PinCategory;
if (Category == UEdGraphSchema_K2::PC_String ||
Category == UEdGraphSchema_K2::PC_Name ||
Category == UEdGraphSchema_K2::PC_Text)
{
return FString::Printf(TEXT("\"%s\""), *Pin->DefaultValue);
}
// If has a non-empty default, show it.
if (!Pin->DefaultValue.IsEmpty())
{
return Pin->DefaultValue;
}
if (Pin->DefaultObject)
{
return Pin->DefaultObject->GetName();
}
if (!Pin->DefaultTextValue.IsEmpty())
{
return FString::Printf(TEXT("\"%s\""), *Pin->DefaultTextValue.ToString());
}
if (IsDefaultToSelf(Pin))
{
return TEXT("<self>");
}
else
{
return TEXT("<default>");
}
}
void FlxBlueprintExporter::EmitNode(UEdGraphNode* Node)
{
if (Node->IsA<UEdGraphNode_Comment>())
{
Output.Appendf(TEXT("\n // %s\n"), *Node->NodeComment);
return;
}
if (Node->IsA<UK2Node_VariableGet>())
return;
Output.Appendf(TEXT("\n %s\n"), *NodeNames[Node]);
// Emit input data pins.
for (UEdGraphPin* Pin : Node->Pins)
{
if (Pin->Direction != EGPD_Input) continue;
if (Pin->PinType.PinCategory == UEdGraphSchema_K2::PC_Exec) continue;
if (Pin->bHidden) continue;
Output.Appendf(TEXT(" %s %s = %s\n"),
*FormatPinType(Pin),
*SanitizeName(GetPinDisplayName(Pin)),
*FormatPinSource(Pin));
}
// Emit output data pins as a return line.
FString ReturnPins;
for (UEdGraphPin* Pin : Node->Pins)
{
if (Pin->Direction != EGPD_Output) continue;
if (Pin->PinType.PinCategory == UEdGraphSchema_K2::PC_Exec) continue;
if (Pin->bHidden) continue;
if (!ReturnPins.IsEmpty()) ReturnPins += TEXT(",");
ReturnPins += SanitizeName(GetPinDisplayName(Pin));
}
if (!ReturnPins.IsEmpty())
Output.Appendf(TEXT(" return %s\n"), *ReturnPins);
// Detect output exec pin patterns.
int32 ExecOutCount = 0;
bool HasElsePin = false;
for (UEdGraphPin* Pin : Node->Pins)
{
if (Pin->Direction != EGPD_Output) continue;
if (Pin->PinType.PinCategory != UEdGraphSchema_K2::PC_Exec) continue;
ExecOutCount++;
if (Pin->PinName == TEXT("Else"))
HasElsePin = true;
}
// Emit output exec pins as goto statements.
for (UEdGraphPin* Pin : Node->Pins)
{
if (Pin->Direction != EGPD_Output) continue;
if (Pin->PinType.PinCategory != UEdGraphSchema_K2::PC_Exec) continue;
UEdGraphPin* LinkedTo = GetLinkedTo(Pin);
if (!LinkedTo) continue;
FString* TargetName = NodeNames.Find(LinkedTo->GetOwningNode());
FString Target = TargetName ? *TargetName : TEXT("?");
if (ExecOutCount == 1)
Output.Appendf(TEXT(" goto %s\n"), *Target);
else if (HasElsePin)
Output.Appendf(TEXT(" %s goto %s\n"), *Pin->PinName.ToString().ToLower(), *Target);
else
Output.Appendf(TEXT(" goto %s if %s\n"), *Target, *Pin->PinName.ToString());
}
}
void FlxBlueprintExporter::EmitGraph()
{
Output.Appendf(TEXT("\nGraph:\n"));
for (UEdGraphNode* Node : SortedNodes)
{
if (Node->IsA<UK2Node_Knot>()) continue;
EmitNode(Node);
}
}
bool FlxBlueprintExporter::IsDefaultToSelf(UEdGraphPin* Pin)
{
// The engine's own IsSelfPin check.
if (Pin->PinName == UEdGraphSchema_K2::PN_Self)
return true;
// Check if the owning node is a function call with DefaultToSelf metadata naming this pin.
UK2Node_CallFunction* CallNode = Cast<UK2Node_CallFunction>(Pin->GetOwningNode());
if (!CallNode) return false;
UFunction* Function = CallNode->GetTargetFunction();
if (!Function) return false;
const FString& DefaultToSelfPinName = Function->GetMetaData(FBlueprintMetadata::MD_DefaultToSelf);
return Pin->PinName.ToString() == DefaultToSelfPinName;
}
FString FlxBlueprintExporter::SanitizeName(const FString& Title)
{
FString Result;
bool PrevWasUnderscore = true; // suppress leading underscore
for (TCHAR Ch : Title)
{
if (FChar::IsAlnum(Ch))
{
Result += Ch;
PrevWasUnderscore = false;
}
else if (!PrevWasUnderscore)
{
Result += TEXT('_');
PrevWasUnderscore = true;
}
}
// Trim trailing underscore
if (Result.Len() > 0 && Result[Result.Len() - 1] == TEXT('_'))
{
Result.LeftChopInline(1);
}
return Result.IsEmpty() ? TEXT("_") : Result;
}
#endif

View File

@@ -0,0 +1,45 @@
#pragma once
#if WITH_EDITOR
#include "CoreMinimal.h"
class UBlueprint;
class UEdGraph;
class UEdGraphNode;
class FlxBlueprintExporter
{
public:
FlxBlueprintExporter(UEdGraph* InGraph);
const FString GetOutput() { return Output.ToString(); }
private:
void SortNodes();
void AssignNodeNames();
void EmitNodeList();
void EmitGraph();
void EmitNode(UEdGraphNode* Node);
void Traverse(UEdGraphNode* Node);
bool HasExecPin(UEdGraphNode* Node, EEdGraphPinDirection Direction);
UEdGraphPin* FindFirstPin(UEdGraphNode* Node, EEdGraphPinDirection Direction);
bool IsDefaultToSelf(UEdGraphPin* Pin);
FString SanitizeName(const FString& Title);
FString FormatPinType(UEdGraphPin* Pin);
FString FormatPinSource(UEdGraphPin* Pin);
UEdGraphPin* GetLinkedTo(UEdGraphPin *Pin);
FString GetPinDisplayName(UEdGraphPin *Pin);
UEdGraph* Graph;
// Data populated by passes.
TMap<UEdGraphNode*, FString> NodeNames;
TArray<UEdGraphNode*> SortedNodes;
TSet<UEdGraphNode*> Visited;
// Output buffer.
TStringBuilder<4096> Output;
};
#endif

View File

@@ -3,4 +3,56 @@
#include "Integration.h"
#include "Modules/ModuleManager.h"
IMPLEMENT_PRIMARY_GAME_MODULE( FDefaultGameModuleImpl, Integration, "Integration" );
#if WITH_EDITOR
#include "Engine/Blueprint.h"
#include "UObject/SavePackage.h"
#include "UObject/ObjectSaveContext.h"
#include "EdGraph/EdGraph.h"
#include "BlueprintExporter.h"
#include "Misc/FileHelper.h"
#endif
IMPLEMENT_PRIMARY_GAME_MODULE(FlxIntegrationModuleImpl, Integration, "Integration");
void FlxIntegrationModuleImpl::StartupModule()
{
#if WITH_EDITOR
OnAssetSavedHandle = UPackage::PackageSavedWithContextEvent.AddRaw(
this, &FlxIntegrationModuleImpl::OnAssetSaved);
#endif
}
void FlxIntegrationModuleImpl::ShutdownModule()
{
#if WITH_EDITOR
UPackage::PackageSavedWithContextEvent.Remove(OnAssetSavedHandle);
#endif
}
#if WITH_EDITOR
void FlxIntegrationModuleImpl::OnAssetSaved(const FString& PackageFilename, UPackage* Package, FObjectPostSaveContext Context)
{
if (!Package) return;
ForEachObjectWithPackage(Package, [&](UObject* Object)
{
if (UBlueprint* BP = Cast<UBlueprint>(Object))
{
FString BPDir = FPaths::ProjectDir() / TEXT("Saved") / TEXT("BlueprintExports") / BP->GetName();
TArray<UEdGraph*> AllGraphs;
BP->GetAllGraphs(AllGraphs);
for (UEdGraph* Graph : AllGraphs)
{
FlxBlueprintExporter Exporter(Graph);
FString FilePath = BPDir / Graph->GetName() + TEXT(".txt");
FFileHelper::SaveStringToFile(Exporter.GetOutput(), *FilePath);
UE_LOG(LogTemp, Warning, TEXT("Blueprint export: %s"), *FilePath);
}
}
return true;
});
}
#endif

View File

@@ -4,3 +4,15 @@
#include "CoreMinimal.h"
class FlxIntegrationModuleImpl : public IModuleInterface
{
public:
virtual void StartupModule() override;
virtual void ShutdownModule() override;
private:
#if WITH_EDITOR
void OnAssetSaved(const FString& PackageFilename, UPackage* Package, FObjectPostSaveContext Context);
FDelegateHandle OnAssetSavedHandle;
#endif
};

View File

@@ -1,34 +0,0 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "SampleActorComponent.h"
// Sets default values for this component's properties
USampleActorComponent::USampleActorComponent()
{
// Set this component to be initialized when the game starts, and to be ticked every frame. You can turn these features
// off to improve performance if you don't need them.
PrimaryComponentTick.bCanEverTick = true;
// ...
}
// Called when the game starts
void USampleActorComponent::BeginPlay()
{
Super::BeginPlay();
// ...
}
// Called every frame
void USampleActorComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
// ...
}

View File

@@ -1,28 +0,0 @@
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "SampleActorComponent.generated.h"
UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) )
class INTEGRATION_API USampleActorComponent : public UActorComponent
{
GENERATED_BODY()
public:
// Sets default values for this component's properties
USampleActorComponent();
protected:
// Called when the game starts
virtual void BeginPlay() override;
public:
// Called every frame
virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
};

View File

@@ -1,12 +0,0 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "SampleEmptyClass.h"
SampleEmptyClass::SampleEmptyClass()
{
}
SampleEmptyClass::~SampleEmptyClass()
{
}

View File

@@ -1,15 +0,0 @@
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
/**
*
*/
class INTEGRATION_API SampleEmptyClass
{
public:
SampleEmptyClass();
~SampleEmptyClass();
};

View File

@@ -1,5 +0,0 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "SampleUObject.h"

View File

@@ -1,17 +0,0 @@
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "UObject/NoExportTypes.h"
#include "SampleUObject.generated.h"
/**
*
*/
UCLASS()
class INTEGRATION_API USampleUObject : public UObject
{
GENERATED_BODY()
};