Compare commits

...

2 Commits

Author SHA1 Message Date
d737879ed6 More slash command stuff 2026-05-26 18:42:48 -04:00
46051526e6 New slash command parser 2026-05-26 18:20:35 -04:00
8 changed files with 596 additions and 6 deletions

Binary file not shown.

View File

@@ -317,6 +317,12 @@ FFormatArgumentData UlxFormatDataLibrary::FormatArgumentDataEnum(uint8 Value, co
return Result; return Result;
} }
FText UlxFormatDataLibrary::FormatMessageInternal(const FString &InPattern, TArray<FFormatArgumentData> InArgs)
{
FText InPatternText(FText::FromString(InPattern));
return FTextFormatter::Format(MoveTemp(InPatternText), MoveTemp(InArgs), false, false);
}
void UlxFormatDataLibrary::FormatLogMessageInternal(UObject *Context, ElxFormatLogVerbosity Verbosity, const FString &InPattern, TArray<FFormatArgumentData> InArgs) void UlxFormatDataLibrary::FormatLogMessageInternal(UObject *Context, ElxFormatLogVerbosity Verbosity, const FString &InPattern, TArray<FFormatArgumentData> InArgs)
{ {
// For throttled verbosity levels, suppress repeated messages with the // For throttled verbosity levels, suppress repeated messages with the
@@ -338,8 +344,7 @@ void UlxFormatDataLibrary::FormatLogMessageInternal(UObject *Context, ElxFormatL
// Generate the formatted string. // Generate the formatted string.
// //
FText InPatternText(FText::FromString(InPattern)); FText Message = FormatMessageInternal(InPattern, MoveTemp(InArgs));
FText Message = FTextFormatter::Format(MoveTemp(InPatternText), MoveTemp(InArgs), false, false);
FString MessageString = Message.ToString(); FString MessageString = Message.ToString();
// Get the blueprint name. // Get the blueprint name.

View File

@@ -105,6 +105,13 @@ public:
// //
static UFunction* GetConverterForPinType(const UEdGraphSchema_K2 *Schema, const FEdGraphPinType& PinType, bool AllowWild); static UFunction* GetConverterForPinType(const UEdGraphSchema_K2 *Schema, const FEdGraphPinType& PinType, bool AllowWild);
// Format a message using FTextFormatter::Format.
// Meant to be used internally by the Format Message K2Node,
// which needs an impure wrapper around formatting.
//
UFUNCTION(BlueprintCallable, meta=(BlueprintInternalUseOnly = "true"))
static FText FormatMessageInternal(const FString &InPattern, TArray<FFormatArgumentData> InArgs);
// Format a message using FTextFormatter::Format, and send // Format a message using FTextFormatter::Format, and send
// it to UE_LOG. The Context object's name is used as the // it to UE_LOG. The Context object's name is used as the
// log category. Meant to be used internally by the Format // log category. Meant to be used internally by the Format

View File

@@ -250,7 +250,7 @@ void UK2Node_FormatMessage::ExpandNode(class FKismetCompilerContext& CompilerCon
} }
else else
{ {
FormatFunction = UKismetTextLibrary::StaticClass()->FindFunctionByName(GET_MEMBER_NAME_CHECKED(UKismetTextLibrary, Format)); FormatFunction = UlxFormatDataLibrary::StaticClass()->FindFunctionByName(GET_MEMBER_NAME_CHECKED(UlxFormatDataLibrary, FormatMessageInternal));
} }
// This is the node that does all the Format work and outputs the message. // This is the node that does all the Format work and outputs the message.

View File

@@ -0,0 +1,226 @@
#include "ReadSlashCommand.h"
#include "BlueprintActionDatabaseRegistrar.h"
#include "BlueprintNodeSpawner.h"
#include "EdGraphSchema_K2.h"
#include "K2Node_CallFunction.h"
#include "KismetCompiler.h"
#include "LuaCall.h"
#include "SlashCommand.h"
#define LOCTEXT_NAMESPACE "ReadSlashCommand"
const FName UK2Node_ReadSlashCommand::PrototypePinName(TEXT("Prototype"));
const FName UK2Node_ReadSlashCommand::InputValuesPinName(TEXT("Input Values"));
const FName UK2Node_ReadSlashCommand::ErrorPinName(TEXT("Error"));
bool UK2Node_ReadSlashCommand::ParsePrototype(const FString &Prototype, TArray<ParsingStep>& Steps)
{
TArray<FString> Words;
Prototype.ParseIntoArrayWS(Words);
// The command name must be a slash followed by alphanumerics.
if (Words.Num() == 0 || !UlxSlashCommand::IsSlashCommand(Words[0]))
{
SetErrorMsg(TEXT("The prototype must start with a command name, e.g. \"/foo\"."));
Steps.Empty();
return false;
}
Steps.SetNum(Words.Num());
Steps[0].Word = Words[0];
TSet<FName> UsedNames;
for (int32 i = 1; i < Words.Num(); i++)
{
const FString& Word = Words[i];
FString FuncName = FString(TEXT("Read")) + Word;
UFunction* ReadFunc = UlxSlashCommand::StaticClass()->FindFunctionByName(FName(*FuncName));
if (ReadFunc == nullptr)
{
SetErrorMsg(FString::Printf(TEXT("Unknown value type: %s"), *Word));
Steps.Empty();
return false;
}
FName PinName = AddPrefix(Word, 'R');
for (int Suffix = 2; UsedNames.Contains(PinName); Suffix++)
{
PinName = AddPrefix(FString::Printf(TEXT("%s%d"), *Word, Suffix), 'R');
}
Steps[i].Word = Word;
Steps[i].PinName = PinName;
Steps[i].ReadFunction = ReadFunc;
UsedNames.Add(PinName);
}
return true;
}
FText UK2Node_ReadSlashCommand::GetTooltipText() const
{
static FText Tooltip = FText::FromString(TEXT(
"Parse a slash command.\n"
"\n"
"The prototype must be a hardwired string. The first word\n"
"is the command name; each remaining word names a value to\n"
"read and becomes an output pin.\n"
"\n"
"For example:\n"
"\n"
" /command Integer Float\n"
"\n"
"Supported types: Token, Integer, Float, Rest\n"));
return Tooltip;
}
void UK2Node_ReadSlashCommand::ReconstructNode()
{
// Save the value of the Prototype Pin before it gets reconstructed.
UEdGraphPin* PrototypePin = FindPin(PrototypePinName);
if (PrototypePin != nullptr)
{
ValuePrototype = PrototypePin->DefaultValue;
}
Super::ReconstructNode();
}
void UK2Node_ReadSlashCommand::AllocateDefaultPins()
{
Pins.Reset();
Super::AllocateDefaultPins();
TArray<ParsingStep> Steps;
ParsePrototype(ValuePrototype, Steps);
CreatePin(EGPD_Input, UEdGraphSchema_K2::PC_Exec, UEdGraphSchema_K2::PN_Execute);
CreatePin(EGPD_Output, UEdGraphSchema_K2::PC_Exec, UEdGraphSchema_K2::PN_Then);
CreatePin(EGPD_Output, UEdGraphSchema_K2::PC_Exec, ErrorPinName);
UEdGraphPin *PrototypePin = CreatePin(EGPD_Input, UEdGraphSchema_K2::PC_String, PrototypePinName);
PrototypePin->DefaultValue = ValuePrototype;
CreatePin(EGPD_Input, UEdGraphSchema_K2::PC_Object, UlxSlashCommand::StaticClass(), InputValuesPinName);
// Create an output pin for each value word.
for (int32 i = 1; i < Steps.Num(); i++)
{
// The value comes back through the function's "Result" out-parameter.
CreatePin(EGPD_Output, PropertyToPinType(Steps[i].ReadFunction->FindPropertyByName(TEXT("Result"))), Steps[i].PinName);
}
}
FText UK2Node_ReadSlashCommand::GetNodeTitle(ENodeTitleType::Type TitleType) const
{
return LOCTEXT("ReadSlashCommand_Title", "Read Slash Command");
}
FText UK2Node_ReadSlashCommand::GetPinDisplayName(const UEdGraphPin* Pin) const
{
// These pins don't need labels.
if ((Pin->PinName == UEdGraphSchema_K2::PN_Execute) ||
(Pin->PinName == UEdGraphSchema_K2::PN_Then) ||
(Pin->PinName == PrototypePinName))
{
return FText::GetEmpty();
}
// Return the pin name, removing R: prefix if present.
return FText::FromName(RemovePrefix(Pin->PinName));
}
void UK2Node_ReadSlashCommand::PinDefaultValueChanged(UEdGraphPin* Pin)
{
if ((Pin->PinName == PrototypePinName) && (Pin->DefaultValue != ValuePrototype))
{
ReconstructNode();
}
}
void UK2Node_ReadSlashCommand::ExpandNode(class FKismetCompilerContext& CompilerContext, UEdGraph* SourceGraph)
{
Super::ExpandNode(CompilerContext, SourceGraph);
TArray<ParsingStep> Steps;
if (!ParsePrototype(ValuePrototype, Steps))
{
CompilerContext.MessageLog.Error(*ErrorMsg);
BreakAllNodeLinks();
return;
}
UEdGraphPin *InputSlashCommandPin = FindPinChecked(InputValuesPinName);
UEdGraphPin *ErrorExecPin = FindPinChecked(ErrorPinName);
UFunction *CheckCommandFunc = UlxSlashCommand::StaticClass()->FindFunctionByName(TEXT("CheckCommand"));
UK2Node_CallFunction *CheckCommandNode = MakeCallFunctionNode(CompilerContext, SourceGraph, CheckCommandFunc);
CompilerContext.CopyPinLinksToIntermediate(*InputSlashCommandPin, *CheckCommandNode->FindPinChecked(UEdGraphSchema_K2::PN_Self));
CompilerContext.MovePinLinksToIntermediate(*GetExecPin(), *CheckCommandNode->GetExecPin());
CheckCommandNode->FindPinChecked(TEXT("Literal"))->DefaultValue = Steps[0].Word;
CheckCommandNode->FindPinChecked(TEXT("Prototype"))->DefaultValue = ValuePrototype;
CompilerContext.CopyPinLinksToIntermediate(*ErrorExecPin, *CheckCommandNode->FindPinChecked(TEXT("Error")));
UEdGraphPin *ThenPin = CheckCommandNode->FindPinChecked(TEXT("Success"));
for (int32 i = 1; i < Steps.Num(); i++)
{
UK2Node_CallFunction *ReadNode = MakeCallFunctionNode(CompilerContext, SourceGraph, Steps[i].ReadFunction);
CompilerContext.CopyPinLinksToIntermediate(*InputSlashCommandPin, *ReadNode->FindPinChecked(UEdGraphSchema_K2::PN_Self));
CompilerContext.CopyPinLinksToIntermediate(*ErrorExecPin, *ReadNode->FindPinChecked(TEXT("Error")));
UEdGraphPin *OutputPin = FindPinChecked(Steps[i].PinName);
CompilerContext.MovePinLinksToIntermediate(*OutputPin, *ReadNode->FindPinChecked(TEXT("Result")));
ThenPin = ChainExecPin(ThenPin, ReadNode, TEXT("Success"));
}
CompilerContext.MovePinLinksToIntermediate(*GetThenPin(), *ThenPin);
BreakAllNodeLinks();
}
UK2Node::ERedirectType UK2Node_ReadSlashCommand::DoPinsMatchForReconstruction(const UEdGraphPin* NewPin, int32 NewPinIndex, const UEdGraphPin* OldPin, int32 OldPinIndex) const
{
if (IsTemplate() || (GetGraph() == nullptr)) return ERedirectType_None;
if ((NewPin->PinName == OldPin->PinName) &&
(NewPin->Direction == OldPin->Direction) &&
(NewPin->PinType == OldPin->PinType))
{
return ERedirectType_Name;
}
return ERedirectType_None;
}
bool UK2Node_ReadSlashCommand::IsConnectionDisallowed(const UEdGraphPin* MyPin, const UEdGraphPin* OtherPin, FString& OutReason) const
{
// The prototype pin cannot be connected.
if (MyPin->PinName == PrototypePinName)
{
OutReason = LOCTEXT("Error_PrototypeMustBeHardwired", "Value prototype must be a hardwired constant.").ToString();
return true;
}
return Super::IsConnectionDisallowed(MyPin, OtherPin, OutReason);
}
void UK2Node_ReadSlashCommand::GetMenuActions(FBlueprintActionDatabaseRegistrar& ActionRegistrar) const
{
UClass* ActionKey = GetClass();
if (ActionRegistrar.IsOpenForRegistration(ActionKey))
{
UBlueprintNodeSpawner* NodeSpawner = UBlueprintNodeSpawner::Create(GetClass());
check(NodeSpawner != nullptr);
ActionRegistrar.AddBlueprintAction(ActionKey, NodeSpawner);
}
}
FText UK2Node_ReadSlashCommand::GetMenuCategory() const
{
return FText::FromString(FString(TEXT("Luprex|Slash Commands")));
}
#undef LOCTEXT_NAMESPACE

View File

@@ -0,0 +1,74 @@
////////////////////////////////////////////////////////////
//
// ReadSlashCommand.h
//
// K2Node that reads typed values from a UlxLuaValues array.
// Takes a prototype string like "string x, float y, int z"
// and creates output pins with the appropriate types.
//
////////////////////////////////////////////////////////////
#pragma once
#include "LuprexK2Node.h"
#include "ReadSlashCommand.generated.h"
class FBlueprintActionDatabaseRegistrar;
class FString;
class UEdGraph;
class UObject;
UCLASS(MinimalAPI)
class UK2Node_ReadSlashCommand : public UlxK2Node
{
GENERATED_BODY()
public:
//~ Begin UEdGraphNode Interface.
virtual void AllocateDefaultPins() override;
virtual FText GetNodeTitle(ENodeTitleType::Type TitleType) const override;
virtual bool ShouldShowNodeProperties() const override { return true; }
virtual void PinDefaultValueChanged(UEdGraphPin* Pin) override;
virtual FText GetTooltipText() const override;
virtual FText GetPinDisplayName(const UEdGraphPin* Pin) const override;
//~ End UEdGraphNode Interface.
//~ Begin UK2Node Interface.
virtual bool IsNodePure() const override { return false; }
virtual void ReconstructNode() override;
virtual bool NodeCausesStructuralBlueprintChange() const override { return true; }
virtual void ExpandNode(class FKismetCompilerContext& CompilerContext, UEdGraph* SourceGraph) override;
virtual ERedirectType DoPinsMatchForReconstruction(const UEdGraphPin* NewPin, int32 NewPinIndex, const UEdGraphPin* OldPin, int32 OldPinIndex) const override;
virtual bool IsConnectionDisallowed(const UEdGraphPin* MyPin, const UEdGraphPin* OtherPin, FString& OutReason) const override;
virtual void GetMenuActions(FBlueprintActionDatabaseRegistrar& ActionRegistrar) const override;
virtual FText GetMenuCategory() const override;
virtual int32 GetNodeRefreshPriority() const override { return EBaseNodeRefreshPriority::Low_UsesDependentWildcard; }
//~ End UK2Node Interface.
private:
static const FName PrototypePinName;
static const FName InputValuesPinName;
static const FName ErrorPinName;
struct ParsingStep
{
FString Word;
FName PinName;
UFunction *ReadFunction;
};
bool ParsePrototype(const FString &Prototype, TArray<ParsingStep>& Steps);
private:
// Whenever the prototype pin value changes, we call
// ReconstructNode, which backs up the value into this
// property. This cache is needed because during
// ReconstructNode, we blow away the prototype pin. The
// prototype pin is also absent when the node is first
// created.
//
UPROPERTY()
FString ValuePrototype = TEXT("/command Integer Float");
};

View File

@@ -0,0 +1,157 @@
#include "SlashCommand.h"
#include "CoreMinimal.h"
#include "Containers/StringView.h"
#include "Misc/Char.h"
#include "Misc/DefaultValueHelper.h"
//////////////////////////////////////////////////////////////
//
// SlashCommand
//
// A command line plus a cursor, exposed to blueprint.
//
//////////////////////////////////////////////////////////////
UlxSlashCommand* UlxSlashCommand::MakeSlashCommand(const FString& CommandLine)
{
UlxSlashCommand* Result = NewObject<UlxSlashCommand>();
Result->CommandLine = CommandLine;
return Result;
}
bool UlxSlashCommand::IsSlashCommand(const FString& Command)
{
if (Command.Len() < 2 || Command[0] != TEXT('/'))
{
return false;
}
// Every character after the slash must be alphanumeric.
for (int32 i = 1; i < Command.Len(); i++)
{
if (!FChar::IsAlnum(Command[i]))
{
return false;
}
}
return true;
}
FStringView UlxSlashCommand::FetchToken()
{
const TCHAR* Data = *CommandLine;
int32 Len = CommandLine.Len();
// Skip leading whitespace.
while (Cursor < Len && FChar::IsWhitespace(Data[Cursor]))
{
Cursor++;
}
// Read characters up to the next whitespace.
int32 Start = Cursor;
while (Cursor < Len && !FChar::IsWhitespace(Data[Cursor]))
{
Cursor++;
}
return FStringView(Data + Start, Cursor - Start);
}
ElxSuccessOrError UlxSlashCommand::CheckCommand(const FString& Literal, const FString& Prototype)
{
KnownCommands.Add(Literal);
// Checking the command always starts a fresh parse.
Cursor = 0;
FStringView Token = FetchToken();
if (Token.Equals(Literal, ESearchCase::IgnoreCase))
{
MatchingPrototypes.Add(Prototype);
return ElxSuccessOrError::Success;
}
return ElxSuccessOrError::Error;
}
ElxSuccessOrError UlxSlashCommand::ReadToken(FString& Result)
{
FStringView Token = FetchToken();
if (Token.IsEmpty())
{
Result.Empty();
return ElxSuccessOrError::Error;
}
Result = FString(Token);
return ElxSuccessOrError::Success;
}
ElxSuccessOrError UlxSlashCommand::ReadInteger(int32& Result)
{
// ParseInt validates the whole token and converts it, so "12abc"
// is rejected rather than read as a partial number.
FStringView Token = FetchToken();
if (!FDefaultValueHelper::ParseInt(FString(Token), Result))
{
Result = 0;
return ElxSuccessOrError::Error;
}
return ElxSuccessOrError::Success;
}
ElxSuccessOrError UlxSlashCommand::ReadFloat(double& Result)
{
// ParseDouble validates the whole token and converts it, so
// "12abc" is rejected rather than read as a partial number.
FStringView Token = FetchToken();
if (!FDefaultValueHelper::ParseDouble(FString(Token), Result))
{
Result = 0.0;
return ElxSuccessOrError::Error;
}
return ElxSuccessOrError::Success;
}
ElxSuccessOrError UlxSlashCommand::ReadRest(FString& Result)
{
const TCHAR* Data = *CommandLine;
int32 Len = CommandLine.Len();
// Everything from the cursor to the end of the line, trimmed.
Result = FString(FStringView(Data + Cursor, Len - Cursor));
Result.TrimStartAndEndInline();
Cursor = Len;
return ElxSuccessOrError::Success;
}
FString UlxSlashCommand::GetErrorMessage() const
{
if (MatchingPrototypes.Num() == 0)
{
TArray<FString> Commands;
for (const FString& Command : KnownCommands)
{
Commands.Add(Command);
}
Commands.Sort();
return FString(TEXT("No such slash command. Valid slash commands: ")) + FString::Join(Commands, TEXT(", "));
}
FString Result = TEXT("Invalid parameters. Valid parameters:\n");
for (const FString& Prototype : MatchingPrototypes)
{
Result += Prototype;
Result += TEXT("\n");
}
return Result;
}

View File

@@ -0,0 +1,121 @@
////////////////////////////////////////////////////////////
//
// SlashCommand.h
//
// A command line plus a cursor, exposed to blueprint.
//
////////////////////////////////////////////////////////////
#pragma once
#include "Containers/Array.h"
#include "Containers/Set.h"
#include "Containers/UnrealString.h"
#include "Containers/StringFwd.h"
#include "Common.h"
#include "SlashCommand.generated.h"
////////////////////////////////////////////////////////////
//
// UlxSlashCommand
//
// Holds a command line (a string the user has typed) together
// with a cursor marking the current position within it. This
// is the object that wraps Unreal's FParse facilities so that
// blueprint can parse a typed command piece by piece, with the
// cursor advancing as each token is consumed.
//
////////////////////////////////////////////////////////////
UCLASS(BlueprintType)
class UlxSlashCommand : public UObject
{
GENERATED_BODY()
private:
// The full command line that we are parsing.
//
FString CommandLine;
// The current parse position: an index into CommandLine.
//
int32 Cursor = 0;
// Known command names that have been registered while exploring
// possible parses for this line.
//
TSet<FString> KnownCommands;
// Prototypes whose command name matches this line.
//
TArray<FString> MatchingPrototypes;
// Skip leading whitespace at the cursor, then read characters up
// to (but not including) the next whitespace. The token is
// returned as a view into CommandLine, and the cursor is advanced
// past it.
//
// Returns an empty view if there is no nonwhitespace input left.
//
FStringView FetchToken();
public:
// Construct a slash command from a command line string.
// The cursor starts at the beginning.
//
UFUNCTION(BlueprintCallable)
static UlxSlashCommand* MakeSlashCommand(const FString& CommandLine);
// Return true if the string is a slash followed by one or more
// alphanumeric characters, and nothing else (e.g. "/foo").
//
static bool IsSlashCommand(const FString& Command);
// Reset the cursor to the start, then read the first token and
// check whether it matches the given literal, case-insensitively.
// The command name is recorded in KnownCommands either way. If it
// matched, the prototype is added to MatchingPrototypes. The token
// is consumed either way. Returns Success if it matched, Error
// otherwise.
//
UFUNCTION(BlueprintCallable, meta = (BlueprintInternalUseOnly = "true", ExpandEnumAsExecs = "ReturnValue"))
ElxSuccessOrError CheckCommand(const FString& Literal, const FString& Prototype);
// Read the next whitespace-delimited word from the command line.
//
// This is the blueprint-callable form of FetchToken: it returns
// the token as an FString. Returns Error (with an empty Result)
// if there is no nonwhitespace input left, Success otherwise.
//
UFUNCTION(BlueprintCallable, meta = (BlueprintInternalUseOnly = "true", ExpandEnumAsExecs = "ReturnValue"))
ElxSuccessOrError ReadToken(FString& Result);
// Read the next token and interpret it as an integer, using C++
// number syntax (optional sign; decimal, 0x hex, or leading-0
// octal). The whole token must be valid; "12abc" is rejected.
// Returns Error (with Result 0) on failure, Success otherwise.
//
UFUNCTION(BlueprintCallable, meta = (BlueprintInternalUseOnly = "true", ExpandEnumAsExecs = "ReturnValue"))
ElxSuccessOrError ReadInteger(int32& Result);
// Read the next token and interpret it as a floating-point number,
// using C++ number syntax (optional sign, digits, dot, e/E
// exponent, trailing f). The whole token must be valid; "12abc"
// is rejected. Returns Error (with Result 0) on failure, Success
// otherwise.
//
UFUNCTION(BlueprintCallable, meta = (BlueprintInternalUseOnly = "true", ExpandEnumAsExecs = "ReturnValue"))
ElxSuccessOrError ReadFloat(double& Result);
// Read the rest of the command line, from the cursor to the end,
// trimmed of leading and trailing whitespace. The cursor is
// advanced to the end. Always returns Success.
//
UFUNCTION(BlueprintCallable, meta = (BlueprintInternalUseOnly = "true", ExpandEnumAsExecs = "ReturnValue"))
ElxSuccessOrError ReadRest(FString& Result);
// Return a user-facing error message describing why parsing failed.
//
UFUNCTION(BlueprintCallable)
FString GetErrorMessage() const;
};