// Copyright Steve Streeting 2022 // Released under the MIT license https://opensource.org/license/MIT/ #include "SUDSScriptImporter.h" #include "SUDSEditorSettings.h" #include "SUDSExpression.h" #include "SUDSMessageLogger.h" #include "SUDSScript.h" #include "SUDSScriptNode.h" #include "SUDSScriptNodeEvent.h" #include "SUDSScriptNodeGosub.h" #include "SUDSScriptNodeSet.h" #include "SUDSScriptNodeText.h" #include "Internationalization/Regex.h" #include "Internationalization/StringTable.h" #include "Internationalization/StringTableCore.h" class USUDSEditorSettings; const FString FSUDSScriptImporter::EndGotoLabel = "end"; const FString FSUDSScriptImporter::TreePathSeparator = "/"; DEFINE_LOG_CATEGORY(LogSUDSImporter) bool FSUDSScriptImporter::ImportFromBuffer(const TCHAR *Start, int32 Length, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent) { static const TCHAR* LineEndings[] = { TEXT("\r\n"), TEXT("\r"), TEXT("\n"), }; constexpr int32 NumDelims = UE_ARRAY_COUNT(LineEndings); int LineNumber = 1; HeaderTree.Reset(); BodyTree.Reset(); PersistentMetadata.Empty(); TransientMetadata.Empty(); UserMetadata.Empty(); bHeaderDone = false; bHeaderInProgress = false; bTooLateForHeader = false; bool bImportedOK = true; ChoiceUniqueId = 0; TextIDHighestNumber = 0; bOverrideGenerateSpeakerLineForChoice.Reset(); OverrideChoiceSpeakerID.Reset(); ReferencedSpeakers.Empty(); if (Start) { int32 SubstringBeginIndex = 0; // Iterate through string. for(int32 i = 0; i < Length;) { int32 SubstringEndIndex = INDEX_NONE; int32 DelimiterLength = 0; // Attempt each delimiter. for(int32 DelimIndex = 0; DelimIndex < NumDelims; ++DelimIndex) { DelimiterLength = FCString::Strlen(LineEndings[DelimIndex]); // If we found a delimiter... if (FCString::Strncmp(Start + i, LineEndings[DelimIndex], DelimiterLength) == 0) { // Mark the end of the substring. SubstringEndIndex = i; break; } } if (SubstringEndIndex != INDEX_NONE) { const int32 SubstringLength = SubstringEndIndex - SubstringBeginIndex; FStringView Line = FStringView(Start + SubstringBeginIndex, SubstringLength); if (!ParseLine(Line, LineNumber++, NameForErrors, Logger, bSilent)) { // Abort, error bImportedOK = false; break; } // Next substring begins at the end of the discovered delimiter. SubstringBeginIndex = SubstringEndIndex + DelimiterLength; i = SubstringBeginIndex; } else { ++i; } } // Add any remaining characters after the last delimiter. const int32 SubstringLength = Length - SubstringBeginIndex; const FStringView Line = FStringView(Start + SubstringBeginIndex, SubstringLength); bImportedOK = ParseLine(Line, LineNumber++, NameForErrors, Logger, bSilent) && bImportedOK; } ConnectRemainingNodes(HeaderTree, NameForErrors, Logger, bSilent); ConnectRemainingNodes(BodyTree, NameForErrors, Logger, bSilent); GenerateTextIDs(HeaderTree); GenerateTextIDs(BodyTree); bImportedOK = PostImportSanityCheck(NameForErrors, Logger, bSilent) && bImportedOK; return bImportedOK; } bool FSUDSScriptImporter::ParseLine(const FStringView& Line, int LineNo, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent) { // Trim off any whitespace, but record how much of it there is since it can be relevant int IndentLevel; const FStringView TrimmedLine = TrimLine(Line, IndentLevel); if (TrimmedLine.Len() == 0 && !bTextInProgress) { // We will skip any blank lines that aren't inside text if (!bSilent) UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:00: BLANK %s"), LineNo, *FString(Line)); return true; } if (IsCommentLine(TrimmedLine)) { // Skip over comment lines if (!bSilent) UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: COMMENT %s"), LineNo, IndentLevel, *FString(Line)); // May be metadata in the comment though ParseCommentMetadataLine(TrimmedLine, IndentLevel, LineNo, NameForErrors, Logger, bSilent); return true; } // Check for headers static const FStringView HeaderPrefix(TEXT("===")); if (TrimmedLine.StartsWith(HeaderPrefix)) { if (bHeaderDone) { if (!bSilent) Logger->Logf(ELogVerbosity::Error, TEXT("Failed to parse %s Line %d: Duplicate header section"), *NameForErrors, LineNo); return false; } else if (bTooLateForHeader) { if (!bSilent) Logger->Logf(ELogVerbosity::Error, TEXT("Failed to parse %s Line %d: Header section must be at start"), *NameForErrors, LineNo); return false; } if (bHeaderInProgress) { // End of header bHeaderInProgress = false; bHeaderDone = true; } else { bHeaderInProgress = true; } return true; } else if (bHeaderInProgress) { return ParseHeaderLine(TrimmedLine, IndentLevel, LineNo, NameForErrors, Logger, bSilent); } // Process body return ParseBodyLine(TrimmedLine, IndentLevel, LineNo, NameForErrors, Logger, bSilent); } bool FSUDSScriptImporter::ParseCommentMetadataLine(const FStringView& Line, int IndentLevel, int LineNo, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent) { // Translator comments // Comment metadata starts with: // #= [Key:] Single Use Metadata (next line only) // #+ [Key:] Persistent Metadata (apply to all lines until reset) // [Key:] is optional; if omitted the key is "Comment" // Persistent Metadata is reset when: // - The same key is set again (can be set to blank to reset to empty) // - A line that is more outdented than the source of the key is encountered if (Line.StartsWith(TEXT("#=")) || Line.StartsWith(TEXT("#+"))) { FString LineStr(Line); const FRegexPattern MetaPattern(TEXT("^#([\\=\\+])\\s*(?:(\\S*)\\s*:\\s*)?(.*)$")); FRegexMatcher MetaRegex(MetaPattern, LineStr); if (MetaRegex.FindNext()) { if (!bSilent) UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: META : %s"), LineNo, IndentLevel, *FString(Line)); const bool bIsPersistent = MetaRegex.GetCaptureGroup(1) == "+"; // There is no "count" of capture groups, test highest to detect if key used FString KeyStr = MetaRegex.GetCaptureGroup(2); const FName Key = FName(KeyStr.IsEmpty() ? "Comment" :KeyStr); const FString Value = MetaRegex.GetCaptureGroup(3).TrimStartAndEnd(); if (bIsPersistent) { TArray* pStack = PersistentMetadata.Find(Key); if (!pStack) { // Only bother creating if non-empty // If we find a blank and there's a stack there already, we do add an entry since blank overrides others in scope if (!Value.IsEmpty()) { pStack = &PersistentMetadata.Add(Key); } } if (pStack) { // First we need to check if this line is less or equal indented; if so we have to strip out existing stack items while (!pStack->IsEmpty() && IndentLevel <= pStack->Top().IndentLevel) { pStack->Pop(); } pStack->Push(ParsedMetadata(Key, Value, IndentLevel)); } } else { if (Value.IsEmpty()) { // Reset TransientMetadata.Remove(Key); } else { TransientMetadata.Add(Key, ParsedMetadata(Key, Value, IndentLevel)); } } return true; } else { Logger->Logf(ELogVerbosity::Warning, TEXT( "%s: Malformed translator comment on line %d, ignoring"), *NameForErrors, LineNo); return false; } } else if (Line.StartsWith(TEXT("#%"))) { // User custom metadata - always applies to next line only // Similar syntax to set lines, except text isn't allowed (this is not player visible) // #% Key = Value // #% Key Value FString LineStr(Line); const FRegexPattern UserMetaPattern(TEXT("^#\\%\\s+(\\S+)\\s+(?:=\\s+)?(\\S.*)$")); FRegexMatcher UserMetaRegex(UserMetaPattern, LineStr); if (UserMetaRegex.FindNext()) { if (!bSilent) UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: USERMETA : %s"), LineNo, IndentLevel, *FString(Line)); FString Name = UserMetaRegex.GetCaptureGroup(1); FString ExprStr = UserMetaRegex.GetCaptureGroup(2).TrimStartAndEnd(); // trim because capture accepts spaces in quotes FSUDSExpression Expr; { FString ParseError; if (Expr.ParseFromString(ExprStr, &ParseError)) { if (Expr.IsTextLiteral()) { if (!bSilent) Logger->Logf(ELogVerbosity::Warning, TEXT("Error in %s line %d: Text values not allowed in user metadata"), *NameForErrors, LineNo); return false; } else { UserMetadata.Add(FName(Name), Expr); return true; } } else { if (!bSilent) Logger->Logf(ELogVerbosity::Warning, TEXT("Error in %s line %d: %s"), *NameForErrors, LineNo, *ParseError); return false; } } } else { // Only a warning, in case other types of comment accidentally clash Logger->Logf(ELogVerbosity::Warning, TEXT( "%s: Malformed user metadata comment on line %d, ignoring"), *NameForErrors, LineNo); return false; } } return false; } TMap FSUDSScriptImporter::GetTextMetadataForNextEntry(int CurrentLineIndent) { TMap Ret; // For each key for (auto It = PersistentMetadata.CreateIterator(); It; ++It) { auto& Stack = It->Value; // Use top of stack, so long as equally or less indented than current line while (!Stack.IsEmpty() && CurrentLineIndent < Stack.Top().IndentLevel) { Stack.Pop(); } if (Stack.IsEmpty()) { // Remove key entry if there's no values left on the stack It.RemoveCurrent(); } else { Ret.Add(It->Key, Stack.Top().Value); } } // Add transient after so they override persistent for (auto& Pair: TransientMetadata) { // Always apply transient ones to next case, don't check indent Ret.Add(Pair.Key, Pair.Value.Value); } TransientMetadata.Empty(); return Ret; } TMap FSUDSScriptImporter::ConsumeUserMetadata() { TMap Ret = UserMetadata; UserMetadata.Empty(); return Ret; } bool FSUDSScriptImporter::ParseHeaderLine(const FStringView& Line, int IndentLevel, int LineNo, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent) { if (!bSilent) UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:00: HEADER: %s"), LineNo, *FString(Line)); // Header can still have indenting, only a very limited set of functions though (conditionals) while (HeaderTree.IndentLevelStack.Num() > 1 && IndentLevel < HeaderTree.IndentLevelStack.Top().ThresholdIndent) { // Pop as much from the stack as necessary to return to this indent level PopIndent(HeaderTree); } if (HeaderTree.IndentLevelStack.IsEmpty()) { // Must be the first body line encountered. Add 1 indent level for the root PushIndent(HeaderTree, -1, 0, ""); } // Header lines include set / conditionals // We ignore every other type of line if (Line.StartsWith(TEXT('['))) { bool bParsed = ParseSetLine(Line, HeaderTree, 0, LineNo, NameForErrors, Logger, bSilent); if (!bParsed) bParsed = ParseImportSettingLine(Line, HeaderTree, 0, LineNo, NameForErrors, Logger, bSilent);; } return true; } bool FSUDSScriptImporter::ParseBodyLine(const FStringView& Line, int IndentLevel, int LineNo, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent) { // Once we've had anything other than comments or blanks and non-headers, it's too late for headers bTooLateForHeader = true; // Body indenting matters // If less than "threshold indent", pop contexts from stack until that's no longer the case // If more than threshold indent, may be a child of previous line. Increase threshold indent to this level // ONLY a new context level IF previous was condition or choice. If previous was text then just a continuation // This is why "threshold indent" remains as the outermost indent in this context // If same as threshold indent, continuation of current context while (BodyTree.IndentLevelStack.Num() > 1 && IndentLevel < BodyTree.IndentLevelStack.Top().ThresholdIndent) { // Pop as much from the stack as necessary to return to this indent level PopIndent(BodyTree); } if (BodyTree.IndentLevelStack.IsEmpty()) { // Must be the first body line encountered. Add 1 indent level for the root PushIndent(BodyTree, -1, 0, ""); } if (Line.StartsWith(TEXT('*'))) { return ParseChoiceLine(Line, BodyTree, IndentLevel, LineNo, NameForErrors, Logger, bSilent); } else if (Line.StartsWith(TEXT(':'))) { return ParseGotoLabelLine(Line, BodyTree, IndentLevel, LineNo, NameForErrors, Logger, bSilent); } else if (Line.StartsWith(TEXT('['))) { bool bParsed = ParseConditionalLine(Line, BodyTree, IndentLevel, LineNo, NameForErrors, Logger, bSilent); if (!bParsed) bParsed = ParseGotoLine(Line, BodyTree, IndentLevel, LineNo, NameForErrors, Logger, bSilent); if (!bParsed) bParsed = ParseSetLine(Line, BodyTree, IndentLevel, LineNo, NameForErrors, Logger, bSilent); if (!bParsed) bParsed = ParseEventLine(Line, BodyTree, IndentLevel, LineNo, NameForErrors, Logger, bSilent); if (!bParsed) bParsed = ParseGosubLine(Line, BodyTree, IndentLevel, LineNo, NameForErrors, Logger, bSilent); if (!bParsed) bParsed = ParseReturnLine(Line, BodyTree, IndentLevel, LineNo, NameForErrors, Logger, bSilent); if (!bParsed) bParsed = ParseImportSettingLine(Line, BodyTree, IndentLevel, LineNo, NameForErrors, Logger, bSilent); if (!bParsed) bParsed = ParseRandomLine(Line, BodyTree, IndentLevel, LineNo, NameForErrors, Logger, bSilent); if (!bParsed) { if (!bSilent) { UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: CMD : %s"), LineNo, IndentLevel, *FString(Line)); Logger->Logf(ELogVerbosity::Warning, TEXT("%s Line %d: Unrecognised command. Ignoring!"), *NameForErrors, LineNo); } // We still return true because we don't want to fail the entire import } return true; } else { return ParseTextLine(Line, BodyTree, IndentLevel, LineNo, NameForErrors, Logger, bSilent); } } bool FSUDSScriptImporter::IsLastNodeOfType(const FSUDSScriptImporter::ParsedTree& Tree, ESUDSParsedNodeType Type) { auto& Ctx = Tree.IndentLevelStack.Top(); return Tree.Nodes.IsValidIndex(Ctx.LastNodeIdx) && Tree.Nodes[Ctx.LastNodeIdx].NodeType == Type; } int FSUDSScriptImporter::FindLastChoiceNode(const ParsedTree& Tree, int IndentLevel) { int Ret = FindLastChoiceNode(Tree, IndentLevel,Tree.Nodes.Num() - 1, GetCurrentTreeConditionalPath(Tree)); // if (Ret == -1) // { // // Fallback to try to find from previous text // auto& Ctx = Tree.IndentLevelStack.Top(); // // if (Tree.Nodes.IsValidIndex(Ctx.LastTextNodeIdx)) // { // return FindChoiceAfterTextNode(Tree, Ctx.LastTextNodeIdx); // } // } return Ret; } int FSUDSScriptImporter::FindChoiceAfterTextNode(const FSUDSScriptImporter::ParsedTree& Tree, int TextNodeIdx, const FString& ConditionalPath) { const auto& TextNode = Tree.Nodes[TextNodeIdx]; int RootChoiceIdx = -1; if (TextNode.Edges.Num() == 1 && Tree.Nodes.IsValidIndex(TextNode.Edges[0].TargetNodeIdx)) { int NextNodeIdx = TextNode.Edges[0].TargetNodeIdx; while (Tree.Nodes.IsValidIndex(NextNodeIdx)) { auto& Node = Tree.Nodes[NextNodeIdx]; switch (Node.NodeType) { case ESUDSParsedNodeType::Select: case ESUDSParsedNodeType::Text: // Didn't find break; case ESUDSParsedNodeType::Choice: RootChoiceIdx = NextNodeIdx; NextNodeIdx = -1; // to break out of loop break; case ESUDSParsedNodeType::SetVariable: case ESUDSParsedNodeType::Goto: case ESUDSParsedNodeType::Event: // Cascade through events to find the first choice if (Node.Edges.Num() == 1) { NextNodeIdx = Node.Edges[0].TargetNodeIdx; } else { NextNodeIdx = -1; } break; default: ; }; } if (RootChoiceIdx == -1) { return -1; } // Now we've found the first choice after the text node // BUT if there are conditional choices, there are select nodes underneath this, with // more choice nodes. We want to find the deepest matching one and use the choice under that. return RecurseChoiceNodeFindDeepest(Tree, RootChoiceIdx, ConditionalPath); } return -1; } int FSUDSScriptImporter::RecurseChoiceNodeFindDeepest(const ParsedTree& Tree, int FromChoiceIdx, const FString& ConditionalPath) { // We need to recurse from the Choice and any sequence of S->C->S->C is valid // so long as the condition path matches. The deepest one is the best // So here we're a Choice, and we can recurse into any Selects, and any Choices under that // where the edge condition is a subset of ConditionalPath int Ret = FromChoiceIdx; if (Tree.Nodes.IsValidIndex(FromChoiceIdx)) { const auto& Choice = Tree.Nodes[FromChoiceIdx]; for (int i = 0; i < Choice.Edges.Num(); ++i) { const auto& Edge = Choice.Edges[i]; if (Edge.Text.IsEmpty() && Tree.Nodes.IsValidIndex(Edge.TargetNodeIdx) && Tree.Nodes[Edge.TargetNodeIdx].NodeType == ESUDSParsedNodeType::Select) { int ChildIdx = RecurseSelectNodeFindDeepestChoice(Tree, Edge.TargetNodeIdx, ConditionalPath); // There should be only one that matches conditional if (ChildIdx != -1) { Ret = ChildIdx; break; } } } } return Ret; } int FSUDSScriptImporter::RecurseSelectNodeFindDeepestChoice(const ParsedTree& Tree, int FromSelectNode, const FString& ConditionalPath) { // Default to not having found a choice that matches conditional int Ret = -1; if (Tree.Nodes.IsValidIndex(FromSelectNode)) { const auto& Select = Tree.Nodes[FromSelectNode]; for (int i = 0; i < Select.Edges.Num(); ++i) { const auto& Edge = Select.Edges[i]; if (Tree.Nodes.IsValidIndex(Edge.TargetNodeIdx) && Tree.Nodes[Edge.TargetNodeIdx].NodeType == ESUDSParsedNodeType::Choice) { // It might be this choice node, IF edge matches the conditional path FString ChoiceConditionalPath = GetTreeConditionalPath(Tree, Edge.TargetNodeIdx); if (ConditionalPath.StartsWith(ChoiceConditionalPath)) { // But also we recurse from here because there may be other nested Selects which match closer int ChildIdx = RecurseChoiceNodeFindDeepest(Tree, Edge.TargetNodeIdx, ConditionalPath); // There should be only one that matches conditional if (ChildIdx != -1) { Ret = ChildIdx; break; } } } } } return Ret; } int FSUDSScriptImporter::FindLastChoiceNode(const ParsedTree& Tree, int IndentLevel, int FromIndex, const FString& ConditionPath) { // Scan backwards from end of tree looking for a choice node which has the same or higher indent and condition state as current // But abort if we hit text nodes on that path for (int i = FromIndex; i >= 0; --i) { auto& Node = Tree.Nodes[i]; if (ConditionPath.StartsWith(Node.ConditionalPath) && Node.NodeType == ESUDSParsedNodeType::Text && Node.OriginalIndent <= IndentLevel) { // We hit a parent text node, we can't go back any further return -1; } // Only consider nodes on the same conditional path // Note: NOT containing the conditional path. We don't want to skip over an intervening select node if (ConditionPath == Node.ConditionalPath) { // note that we allow indents < as well as == // This is so that if you choose to aesthetically indent choices it still works if (Node.NodeType == ESUDSParsedNodeType::Choice && Node.OriginalIndent <= IndentLevel) { return i; } } } return -1; } bool FSUDSScriptImporter::ParseChoiceLine(const FStringView& Line, FSUDSScriptImporter::ParsedTree& Tree, int IndentLevel, int LineNo, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent) { if (Line.StartsWith('*')) { if (!bSilent) UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: CHOICE: %s"), LineNo, IndentLevel, *FString(Line)); auto& Ctx = Tree.IndentLevelStack.Top(); // Find a previous choice node to join with int ChoiceNodeIdx = - 1; // however, if there are pending goto labels, they MUST split the choice if (Tree.PendingGotoLabels.Num() == 0) { ChoiceNodeIdx = FindLastChoiceNode(Tree, IndentLevel); } // If the current indent context node is NOT a choice, create a choice node, and connect to previous node (using pending edge if needed) if (ChoiceNodeIdx == -1) { // Didn't find a choice node we can connect to, so create one // However, before we do that, if our parent is a select node, we must ensure that its parent is a choice node // This is just so that we have a choice node outside the select that any choices that come after the [endif] // can attach to. If a non-conditional choice was encountered first, it'll already be there. But if the first // choice was conditional, the select node will have been created first. // We still create a choice node here too, underneath the select, to act as a holder for potentially multiple // choices within this select condition EnsureChoiceNodeExistsAboveSelect(Tree, IndentLevel, LineNo); ChoiceNodeIdx = AppendNode(Tree, FSUDSParsedNode(ESUDSParsedNodeType::Choice, IndentLevel, LineNo)); } if (Tree.EdgeInProgressNodeIdx != -1) { // Must already have been a choice node but previous pending edge wasn't resolved // This means it's a fallthrough, will be resolved at end Tree.EdgeInProgressNodeIdx = Tree.EdgeInProgressEdgeIdx = -1; } auto& ChoiceNode = Tree.Nodes[ChoiceNodeIdx]; // Inside each choice, everything should be indented at least as much as 1 character inside the * // We provide the edge with context C001, C002 etc for fallthrough PushIndent(Tree, ChoiceNodeIdx, IndentLevel + 1, FString::Printf(TEXT("C%03d"), ++ChoiceUniqueId)); // Do we want to generate a speaker line for this choice bool bGenerateSpeakerLine = false; FString GeneratedSpeakerID; if (const auto Settings = GetDefault()) { bGenerateSpeakerLine = Settings->AlwaysGenerateSpeakerLinesFromChoices; GeneratedSpeakerID = Settings->SpeakerIdForGeneratedLinesFromChoices; } if (bOverrideGenerateSpeakerLineForChoice.IsSet()) { bGenerateSpeakerLine = bOverrideGenerateSpeakerLineForChoice.GetValue(); } if (OverrideChoiceSpeakerID.IsSet()) { GeneratedSpeakerID = OverrideChoiceSpeakerID.GetValue(); } int ChoiceTextStart = 1; if (Line.Len() > 1 && Line[1] == '-') { // *- prefix, override speaker line bGenerateSpeakerLine = false; ChoiceTextStart = 2; } // Add a pending edge, with the choice text // Following things fill in the edge details, the next node to be parsed will finalise the destination FString ChoiceTextID; auto ChoiceTextView = Line.SubStr(ChoiceTextStart, Line.Len() - ChoiceTextStart).TrimStart(); // TextID might be blank after this, but that's OK, we fix later after we know what IDs are in use RetrieveAndRemoveTextID(ChoiceTextView, ChoiceTextID); const FString ChoiceText = FString(ChoiceTextView); auto ChoiceTextMeta = GetTextMetadataForNextEntry(IndentLevel); auto ChoiceUserMeta = ConsumeUserMetadata(); const int EdgeIdx = ChoiceNode.Edges.Add(FSUDSParsedEdge(ChoiceNodeIdx, -1, LineNo, ChoiceText, ChoiceTextID, ChoiceTextMeta, ChoiceUserMeta)); Tree.EdgeInProgressNodeIdx = ChoiceNodeIdx; Tree.EdgeInProgressEdgeIdx = EdgeIdx; if (bGenerateSpeakerLine) { // We use the same text & ID so this is just one localisation entry Ctx.LastTextNodeIdx = AppendNode(Tree, FSUDSParsedNode(GeneratedSpeakerID, ChoiceText, ChoiceTextID, ChoiceTextMeta, ChoiceUserMeta, IndentLevel + 1, LineNo)); ReferencedSpeakers.AddUnique(GeneratedSpeakerID); } return true; } return false; } FSUDSParsedEdge* FSUDSScriptImporter::GetEdgeInProgress(ParsedTree& Tree) { if (Tree.Nodes.IsValidIndex(Tree.EdgeInProgressNodeIdx)) { auto& N = Tree.Nodes[Tree.EdgeInProgressNodeIdx]; if (N.Edges.IsValidIndex(Tree.EdgeInProgressEdgeIdx)) { return &N.Edges[Tree.EdgeInProgressEdgeIdx]; } } return nullptr; } void FSUDSScriptImporter::EnsureChoiceNodeExistsAboveSelect(ParsedTree& Tree, int IndentLevel, int LineNo) { auto& Ctx = Tree.IndentLevelStack.Top(); int ParentIdx = -1; // Inserting is going to mess up EdgeInProgress if (auto E = GetEdgeInProgress(Tree)) { ParentIdx = E->SourceNodeIdx; } else { ParentIdx = Ctx.LastNodeIdx; } // Nothing to do if immediate parent isn't select if (!Tree.Nodes.IsValidIndex(ParentIdx) || Tree.Nodes[ParentIdx].NodeType != ESUDSParsedNodeType::Select) return; int TopSelectIdx = ParentIdx; // Find the node above the top of potentially nested select chain while (Tree.Nodes.IsValidIndex(ParentIdx) && Tree.Nodes[ParentIdx].NodeType == ESUDSParsedNodeType::Select) { TopSelectIdx = ParentIdx; ParentIdx = Tree.Nodes[ParentIdx].ParentNodeIdx; } if (Tree.Nodes.IsValidIndex(ParentIdx) && Tree.Nodes[ParentIdx].NodeType == ESUDSParsedNodeType::Choice) { // OK we found a choice above the select, this is OK return; } if (ParentIdx == -1) { // This means we hit the top of the chain without finding choice or select // We have to trust that FindLastChoiceNode will find it & it's valid (previous placement should resolve) // Connect top select to choice under last text if (Tree.Nodes.IsValidIndex(TopSelectIdx) && Tree.Nodes.IsValidIndex(Ctx.LastTextNodeIdx)) { // Choice node might not be directly underneath const int ChoiceIdx = FindChoiceAfterTextNode(Tree, Ctx.LastTextNodeIdx, Tree.Nodes[TopSelectIdx].ConditionalPath); if (ChoiceIdx != -1) { auto& SelNode = Tree.Nodes[TopSelectIdx]; auto& ChoiceNode = Tree.Nodes[ChoiceIdx]; FSUDSParsedEdge NewEdge(LineNo); NewEdge.SourceNodeIdx = ChoiceIdx; NewEdge.TargetNodeIdx = TopSelectIdx; ChoiceNode.Edges.Add(NewEdge); SelNode.ParentNodeIdx = ChoiceIdx; } } return; } // If we got here, we navigated to the point above the select(s) and didn't end up on a root choice // So we need to insert one, after this index const int InsertIdx = ParentIdx + 1; // this happens to return 0 for having hit the start of the tree (-1), which is fine Tree.Nodes.Insert(FSUDSParsedNode(ESUDSParsedNodeType::Choice, IndentLevel, LineNo), InsertIdx); auto& NewChoice = Tree.Nodes[InsertIdx]; auto& SelectNode = Tree.Nodes[InsertIdx + 1]; // Add edge to the select, fixup the parent nodes for both NewChoice.Edges.Add(FSUDSParsedEdge(InsertIdx, InsertIdx + 1, LineNo)); NewChoice.ParentNodeIdx = SelectNode.ParentNodeIdx; NewChoice.ChoicePath = SelectNode.ChoicePath; NewChoice.ConditionalPath = SelectNode.ConditionalPath; // Now for every other node after this, we have to fix up indexes that are >= InsertIdx // We don't fix up anything before, because we want things that pointed forward to the select to now point at the choice for (int i = InsertIdx + 1; i < Tree.Nodes.Num(); ++i) { if (i != InsertIdx) { auto& N = Tree.Nodes[i]; if (N.ParentNodeIdx >= InsertIdx) ++N.ParentNodeIdx; for (auto& E : N.Edges) { if (E.SourceNodeIdx >= InsertIdx) ++E.SourceNodeIdx; if (E.TargetNodeIdx >= InsertIdx) ++E.TargetNodeIdx; } } } // Also fix indent contexts for (auto& I : Tree.IndentLevelStack) { if (I.LastNodeIdx >= InsertIdx) ++I.LastNodeIdx; if (I.LastTextNodeIdx >= InsertIdx) ++I.LastTextNodeIdx; } // Also conditional blocks for (auto& CB : Tree.ConditionalBlocks) { if (CB.SelectNodeIdx >= InsertIdx) ++CB.SelectNodeIdx; } // Fixup edge in progress if (Tree.EdgeInProgressNodeIdx >= InsertIdx) ++Tree.EdgeInProgressNodeIdx; // Manually change the select node parent index afterwards (if we change it before it'll get adjusted again) SelectNode.ParentNodeIdx = InsertIdx; SetFallthroughForNewNode(Tree, NewChoice); } bool FSUDSScriptImporter::IsConditionalLine(const FStringView& Line) { return Line.StartsWith(TEXT("[if")) || Line.StartsWith(TEXT("[else")) || Line.StartsWith(TEXT("[endif")); } bool FSUDSScriptImporter::ParseElseLine(const FStringView& Line, FSUDSScriptImporter::ParsedTree& Tree, int IndentLevel, int LineNo, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent) { if (Tree.ConditionalBlocks.IsValidIndex(Tree.CurrentConditionalBlockIdx)) { // "else" changes the current "if" or "elseif" block to "else" & creates a new condition-free edge // Select or choice node should already be there // Select node may be turned into a choice later if choice is the first thing encountered auto& Block = Tree.ConditionalBlocks[Tree.CurrentConditionalBlockIdx]; if (Block.Stage != EConditionalStage::ElseStage) { Block.Stage = EConditionalStage::ElseStage; // We need to give each else a unique condition string, otherwise sibling else's can be considered // equivalent, when they in fact originate from different if's // ID by select node index Block.ConditionPathElement = MakeElseConditionPathElement(Block.SelectNodeIdx); const int NodeIdx = Block.SelectNodeIdx; auto& SelectNode = Tree.Nodes[NodeIdx]; const int EdgeIdx = SelectNode.Edges.Add(FSUDSParsedEdge(NodeIdx, -1, LineNo)); Tree.EdgeInProgressNodeIdx = NodeIdx; Tree.EdgeInProgressEdgeIdx = EdgeIdx; // Wind back the last node to select auto& Ctx = Tree.IndentLevelStack.Top(); Ctx.LastNodeIdx = NodeIdx; } else { if (!bSilent) Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: cannot have more than one 'else'"), *NameForErrors, LineNo); } } else { if (!bSilent) Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: 'else' with no matching 'if'"), *NameForErrors, LineNo); } if (!bSilent) UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: ELSE : %s"), LineNo, IndentLevel, *FString(Line)); return true; } FString FSUDSScriptImporter::MakeIfConditionPathElement(int SelectNodeIdx, const FString& ConditionStr) { return ConditionStr; } FString FSUDSScriptImporter::MakeElseIfConditionPathElement(int SelectNodeIdx, const FString& ConditionStr) { return FString::Printf(TEXT("elseif-%d %s"), SelectNodeIdx, *ConditionStr); } FString FSUDSScriptImporter::MakeElseConditionPathElement(int SelectNodeIdx) { return FString::Printf(TEXT("else-%d"), SelectNodeIdx); } bool FSUDSScriptImporter::ParseEndIfLine(const FStringView& Line, FSUDSScriptImporter::ParsedTree& Tree, int IndentLevel, int LineNo, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent) { if (Tree.ConditionalBlocks.IsValidIndex(Tree.CurrentConditionalBlockIdx)) { // Endif finishes the current block const auto& Block = Tree.ConditionalBlocks[Tree.CurrentConditionalBlockIdx]; Tree.CurrentConditionalBlockIdx = Block.PreviousBlockIdx; // We must also clear the indent last node pointer, because we never want to auto-connect to conditionals // We'll let the final fallthrough pass connect things auto& Ctx = Tree.IndentLevelStack.Top(); Ctx.LastNodeIdx = -1; } else { if (!bSilent) Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: 'endif' with no matching 'if'"), *NameForErrors, LineNo); } if (!bSilent) UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: ENDIF : %s"), LineNo, IndentLevel, *FString(Line)); return true; } bool FSUDSScriptImporter::ParseIfLine(const FStringView& Line, FSUDSScriptImporter::ParsedTree& Tree, const FString& ConditionStr, int IndentLevel, int LineNo, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent) { if (!bSilent) UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: IF : %s"), LineNo, IndentLevel, *FString(Line)); // New if level always creates select node const int NewNodeIdx = AppendNode(Tree, FSUDSParsedNode(ESUDSParsedNodeType::Select, IndentLevel, LineNo)); auto& SelectNode = Tree.Nodes[NewNodeIdx]; const int EdgeIdx = SelectNode.Edges.Add(FSUDSParsedEdge(NewNodeIdx, -1, LineNo)); auto E = &SelectNode.Edges[EdgeIdx]; { FString ParseError; if (!E->ConditionExpression.ParseFromString(ConditionStr, &ParseError)) { if (!bSilent) Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: %s"), *NameForErrors, LineNo, *ParseError); } } Tree.EdgeInProgressNodeIdx = NewNodeIdx; Tree.EdgeInProgressEdgeIdx = EdgeIdx; Tree.CurrentConditionalBlockIdx = Tree.ConditionalBlocks.Add( ConditionalContext(NewNodeIdx, Tree.CurrentConditionalBlockIdx, EConditionalStage::IfStage, MakeIfConditionPathElement(NewNodeIdx, ConditionStr))); return true; } bool FSUDSScriptImporter::ParseElseIfLine(const FStringView& Line, ParsedTree& Tree, const FString& ConditionStr, int IndentLevel, int LineNo, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent) { if (!bSilent) UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: ELSEIF: %s"), LineNo, IndentLevel, *FString(Line)); // "elseif" changes the current block state // Select or choice node should already be there // Select node may be turned into a choice later if choice is the first thing encountered if (Tree.ConditionalBlocks.IsValidIndex(Tree.CurrentConditionalBlockIdx)) { auto& Block = Tree.ConditionalBlocks[Tree.CurrentConditionalBlockIdx]; if (Block.Stage != EConditionalStage::ElseStage) { Block.Stage = EConditionalStage::ElseIfStage; // For the purposes of the block, the condition isn't just the condition contained here, // it's also the negation of the original "if". This is to prevent multiple sibling // elseifs merging if they contain the same condition but are attached to different ifs Block.ConditionPathElement = MakeElseIfConditionPathElement(Block.SelectNodeIdx, ConditionStr); const int NodeIdx = Block.SelectNodeIdx; auto& SelectOrChoiceNode = Tree.Nodes[NodeIdx]; const int EdgeIdx = SelectOrChoiceNode.Edges.Add(FSUDSParsedEdge(NodeIdx, -1, LineNo)); auto E = &SelectOrChoiceNode.Edges[EdgeIdx]; { FString ParseError; if (!E->ConditionExpression.ParseFromString(ConditionStr, &ParseError)) { if (!bSilent) Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: %s"), *NameForErrors, LineNo, *ParseError); } } Tree.EdgeInProgressNodeIdx = NodeIdx; Tree.EdgeInProgressEdgeIdx = EdgeIdx; } else { if (!bSilent) Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: 'elseif' occurs after 'else'"), *NameForErrors, LineNo); } } else { if (!bSilent) Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: 'elseif' with no matching 'if'"), *NameForErrors, LineNo); } return true; } bool FSUDSScriptImporter::ParseConditionalLine(const FStringView& Line, FSUDSScriptImporter::ParsedTree& Tree, int IndentLevel, int LineNo, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent) { if (!IsConditionalLine(Line)) return false; if (Line.Equals(TEXT("[else]"))) { return ParseElseLine(Line, Tree, IndentLevel, LineNo, NameForErrors, Logger, bSilent); } else if (Line.Equals(TEXT("[endif]"))) { return ParseEndIfLine(Line, Tree, IndentLevel, LineNo, NameForErrors, Logger, bSilent); } else { const FString LineStr(Line); const FRegexPattern IfPattern(TEXT("^\\[if\\s+(.+)\\]$")); FRegexMatcher IfRegex(IfPattern, LineStr); if (IfRegex.FindNext()) { const FString ConditionStr = IfRegex.GetCaptureGroup(1); return ParseIfLine(Line, Tree, ConditionStr, IndentLevel, LineNo, NameForErrors, Logger, bSilent); } else { const FRegexPattern ElseIfPattern(TEXT("^\\[elseif\\s+(.+)\\]$")); FRegexMatcher ElseIfRegex(ElseIfPattern, LineStr); if (ElseIfRegex.FindNext()) { const FString ConditionStr = ElseIfRegex.GetCaptureGroup(1); return ParseElseIfLine(Line, Tree, ConditionStr, IndentLevel, LineNo, NameForErrors, Logger, bSilent); } } } return false; } bool FSUDSScriptImporter::ParseRandomLine(const FStringView& Line, ParsedTree& Tree, int IndentLevel, int LineNo, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent) { if (!IsRandomLine(Line)) return false; else if (Line.Equals(TEXT("[random]"))) { return ParseBeginRandomLine(Line, Tree, IndentLevel, LineNo, NameForErrors, Logger, bSilent); } else if (Line.Equals(TEXT("[or]"))) { return ParseRandomOptionLine(Line, Tree, IndentLevel, LineNo, NameForErrors, Logger, bSilent); } else if (Line.Equals(TEXT("[endrandom]"))) { return ParseEndRandomLine(Line, Tree, IndentLevel, LineNo, NameForErrors, Logger, bSilent); } return false; } bool FSUDSScriptImporter::IsRandomLine(const FStringView& Line) { return Line.StartsWith(TEXT("[random")) || Line.StartsWith(TEXT("[or")) || Line.StartsWith(TEXT("[endrandom")); } bool FSUDSScriptImporter::ParseBeginRandomLine(const FStringView& Line, ParsedTree& Tree, int IndentLevel, int LineNo, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent) { if (!bSilent) UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: RANDOM: %s"), LineNo, IndentLevel, *FString(Line)); // New random level always creates node const int NewNodeIdx = AppendNode(Tree, FSUDSParsedNode(ESUDSParsedNodeType::Select, IndentLevel, LineNo)); auto& SelectNode = Tree.Nodes[NewNodeIdx]; const int EdgeIdx = SelectNode.Edges.Add(FSUDSParsedEdge(NewNodeIdx, -1, LineNo)); FString ConditionStr = FString::Printf(TEXT("{%hs} == 0"), SUDS_RANDOMITEM_VAR); auto E = &SelectNode.Edges[EdgeIdx]; { // Set the condition automatically based on internal random number var FString ParseError; if (!E->ConditionExpression.ParseFromString(ConditionStr, &ParseError)) { if (!bSilent) Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: %s"), *NameForErrors, LineNo, *ParseError); } } Tree.EdgeInProgressNodeIdx = NewNodeIdx; Tree.EdgeInProgressEdgeIdx = EdgeIdx; Tree.CurrentConditionalBlockIdx = Tree.ConditionalBlocks.Add( ConditionalContext(NewNodeIdx, Tree.CurrentConditionalBlockIdx, EConditionalStage::RandomStage, ConditionStr)); return true; } bool FSUDSScriptImporter::ParseRandomOptionLine(const FStringView& Line, ParsedTree& Tree, int IndentLevel, int LineNo, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent) { // This is kind of like an auto-generated elseif if (!bSilent) UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: OR: %s"), LineNo, IndentLevel, *FString(Line)); // "elseif" changes the current block state // Select or choice node should already be there // Select node may be turned into a choice later if choice is the first thing encountered if (Tree.ConditionalBlocks.IsValidIndex(Tree.CurrentConditionalBlockIdx)) { auto& Block = Tree.ConditionalBlocks[Tree.CurrentConditionalBlockIdx]; if (Block.Stage == EConditionalStage::RandomStage || Block.Stage == EConditionalStage::RandomOptionStage) { Block.Stage = EConditionalStage::RandomOptionStage; const int NodeIdx = Block.SelectNodeIdx; auto& SelectOrChoiceNode = Tree.Nodes[NodeIdx]; // Generate condition based on auto-generated random item choice Block.ConditionPathElement = FString::Printf(TEXT("{%hs} == %d"), SUDS_RANDOMITEM_VAR, SelectOrChoiceNode.Edges.Num()); const int EdgeIdx = SelectOrChoiceNode.Edges.Add(FSUDSParsedEdge(NodeIdx, -1, LineNo)); auto E = &SelectOrChoiceNode.Edges[EdgeIdx]; { FString ParseError; if (!E->ConditionExpression.ParseFromString(Block.ConditionPathElement, &ParseError)) { if (!bSilent) Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: %s"), *NameForErrors, LineNo, *ParseError); } } Tree.EdgeInProgressNodeIdx = NodeIdx; Tree.EdgeInProgressEdgeIdx = EdgeIdx; } else { if (!bSilent) Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: 'or' must occur after 'random'"), *NameForErrors, LineNo); } } else { if (!bSilent) Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: 'or' with no matching 'random'"), *NameForErrors, LineNo); } return true; } bool FSUDSScriptImporter::ParseEndRandomLine(const FStringView& Line, ParsedTree& Tree, int IndentLevel, int LineNo, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent) { // Similar to "endif"; except that we need to turn the last option into an "else", otherwise at end of // parsing we'll add an extra else edge if (Tree.ConditionalBlocks.IsValidIndex(Tree.CurrentConditionalBlockIdx)) { // Endrandom finishes the current block const auto& Block = Tree.ConditionalBlocks[Tree.CurrentConditionalBlockIdx]; if (Block.Stage == EConditionalStage::RandomStage || Block.Stage == EConditionalStage::RandomOptionStage) { const int NodeIdx = Block.SelectNodeIdx; auto& SelectNode = Tree.Nodes[NodeIdx]; if (SelectNode.Edges.Num() > 0) { auto& LastEdge = SelectNode.Edges[SelectNode.Edges.Num()-1]; LastEdge.ConditionExpression.Reset(); } Tree.CurrentConditionalBlockIdx = Block.PreviousBlockIdx; // We must also clear the indent last node pointer, because we never want to auto-connect to conditionals // We'll let the final fallthrough pass connect things auto& Ctx = Tree.IndentLevelStack.Top(); Ctx.LastNodeIdx = -1; } else { if (!bSilent) Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: 'endrandom' with no matching 'random'"), *NameForErrors, LineNo); } } else { if (!bSilent) Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: 'endrandom' with no matching 'random'"), *NameForErrors, LineNo); } if (!bSilent) UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: ENDRANDOM : %s"), LineNo, IndentLevel, *FString(Line)); return true; } bool FSUDSScriptImporter::ParseGotoLabelLine(const FStringView& Line, FSUDSScriptImporter::ParsedTree& Tree, int IndentLevel, int LineNo, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent) { // We've already established that line starts with ':' // There should not be any spaces in the label const FString LineStr(Line); const FRegexPattern LabelPattern(TEXT("^\\:\\s*(\\w+)$")); FRegexMatcher LabelRegex(LabelPattern, LineStr); if (LabelRegex.FindNext()) { if (!bSilent) UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: LABEL : %s"), LineNo, IndentLevel, *FString(Line)); // lowercase goto labels so case insensitive FString Label = LabelRegex.GetCaptureGroup(1).ToLower(); if (Label == EndGotoLabel) { if (!bSilent) Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: Label 'end' is reserved and cannot be used in the script, ignoring"), *NameForErrors, LineNo); } else { Tree.PendingGotoLabels.Add(Label); // This will be connected to the next node created // Is a list since multiple labels may resolve to the same place } } else { if (!bSilent) Logger->Logf(ELogVerbosity::Warning, TEXT("Error in %s line %d: Badly formed goto label"), *NameForErrors, LineNo); } // Always return true to carry on, may not be used return true; } bool FSUDSScriptImporter::ParseGotoLine(const FStringView& Line, FSUDSScriptImporter::ParsedTree& Tree, int IndentLevel, int LineNo, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent) { // Unfortunately FRegexMatcher doesn't support FStringView const FString LineStr(Line); // Allow both 'goto' and 'go to' const FRegexPattern GotoPattern(TEXT("^\\[go[ ]?to\\s+(\\w+)\\s*\\]$")); FRegexMatcher GotoRegex(GotoPattern, LineStr); if (GotoRegex.FindNext()) { if (!bSilent) UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: GOTO : %s"), LineNo, IndentLevel, *FString(Line)); // lower case label so case insensitive const FString Label = GotoRegex.GetCaptureGroup(1).ToLower(); // note that we do NOT try to resolve the goto label here, to allow forward jumps. const auto& Ctx = Tree.IndentLevelStack.Top(); // A goto is an edge from the current node to another node // That means if we had pending labels that didn't hit a node before now, they're just aliases to THIS label for (auto PendingLabel : Tree.PendingGotoLabels) { Tree.AliasedGotoLabels.Add(PendingLabel, Label); } Tree.PendingGotoLabels.Reset(); AppendNode(Tree, FSUDSParsedNode(Label, IndentLevel, LineNo)); return true; } return false; } bool FSUDSScriptImporter::ParseGosubLine(const FStringView& InLine, ParsedTree& Tree, int IndentLevel, int LineNo, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent) { // Attempt to find existing ID // We need these in order to save the return stack FString GosubID; FStringView Line = InLine; // If this is a continuation line, we shouldn't generate one, but we need to trim it off if it's there bool bFoundID = RetrieveAndRemoveGosubID(Line, GosubID); // Unfortunately FRegexMatcher doesn't support FStringView const FString LineStr(Line); // Allow both 'gosub' and 'go sub' const FRegexPattern GosubPattern(TEXT("^\\[go[ ]?sub\\s+(\\w+)\\s*\\]$")); FRegexMatcher GosubRegex(GosubPattern, LineStr); if (GosubRegex.FindNext()) { if (!bSilent) UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: GOSUB : %s"), LineNo, IndentLevel, *FString(Line)); // lower case label so case insensitive const FString Label = GosubRegex.GetCaptureGroup(1).ToLower(); // You CANNOT "gosub end" if (Label == EndGotoLabel) { if (!bSilent) Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: You cannot 'gosub end', will never return. Did you mean goto?"), *NameForErrors, LineNo); } else { // note that we do NOT try to resolve the label here, to allow forward jumps. const auto& Ctx = Tree.IndentLevelStack.Top(); // A gosub will become a node of its own in the final runtime // Therefore we don't need to alias labels like we do with gotos AppendNode(Tree, FSUDSParsedNode(Label, GosubID, IndentLevel, LineNo)); } return true; } return false; } bool FSUDSScriptImporter::ParseReturnLine(const FStringView& Line, ParsedTree& Tree, int IndentLevel, int LineNo, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent) { // Unfortunately FRegexMatcher doesn't support FStringView const FString LineStr(Line); const FRegexPattern ReturnPattern(TEXT("^\\[return\\s*\\]$")); FRegexMatcher ReturnRegex(ReturnPattern, LineStr); if (ReturnRegex.FindNext()) { if (!bSilent) UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: RETURN : %s"), LineNo, IndentLevel, *FString(Line)); AppendNode(Tree, FSUDSParsedNode(ESUDSParsedNodeType::Return, IndentLevel, LineNo)); return true; } return false; } bool FSUDSScriptImporter::ParseSetLine(const FStringView& InLine, FSUDSScriptImporter::ParsedTree& Tree, int IndentLevel, int LineNo, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent) { // Attempt to find existing text ID, for string literals // For multiple lines, may not be present until last line (but in fact can be on any line) // We generate anyway, because it can be overriden by later lines, but makes sure we have one always FString TextID; FStringView Line = InLine; // TextID may be blank after this, that's OK - we fix at the end once we know what IDs are used RetrieveAndRemoveTextID(Line, TextID); // Unfortunately FRegexMatcher doesn't support FStringView const FString LineStr(Line); // Accept forms: // [set Var Expression] // [set Var = Expression] (more readable in the case of non-trivial expressions) const FRegexPattern SetPattern(TEXT("^\\[set\\s+(\\S+)\\s+(?:=\\s+)?([^\\]]+)\\]$")); FRegexMatcher SetRegex(SetPattern, LineStr); if (SetRegex.FindNext()) { if (!bSilent) UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: SET : %s"), LineNo, IndentLevel, *FString(Line)); FString Name = SetRegex.GetCaptureGroup(1); FString ExprStr = SetRegex.GetCaptureGroup(2).TrimStartAndEnd(); // trim because capture accepts spaces in quotes FSUDSExpression Expr; { FString ParseError; if (Expr.ParseFromString(ExprStr, &ParseError)) { if (Expr.IsTextLiteral()) { AppendNode(Tree, FSUDSParsedNode(Name, Expr, TextID, IndentLevel, LineNo)); } else { AppendNode(Tree, FSUDSParsedNode(Name, Expr, IndentLevel, LineNo)); } return true; } else { if (!bSilent) Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: %s"), *NameForErrors, LineNo, *ParseError); } } } return false; } bool FSUDSScriptImporter::ParseImportSettingLine(const FStringView& Line, ParsedTree& Tree, int IndentLevel, int LineNo, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent) { // Unfortunately FRegexMatcher doesn't support FStringView const FString LineStr(Line); const FRegexPattern ImportSetPattern(TEXT("^\\[importsetting\\s+(\\S+)\\s+(?:=\\s+)?([^\\]]+)\\]$")); FRegexMatcher ImportSetRegex(ImportSetPattern, LineStr); if (ImportSetRegex.FindNext()) { if (!bSilent) UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: IMPORTSETTING: %s"), LineNo, IndentLevel, *FString(Line)); const FString Name = ImportSetRegex.GetCaptureGroup(1); const FString ExprStr = ImportSetRegex.GetCaptureGroup(2).TrimStartAndEnd(); // trim because capture accepts spaces in quotes FSUDSExpression Expr; { FString ParseError; if (Expr.ParseFromString(ExprStr, &ParseError)) { if (Expr.IsLiteral()) { // Import settings affect importer state directly, no nodes if (Name.Compare("GenerateSpeakerLinesFromChoices", ESearchCase::IgnoreCase) == 0) { if (Expr.GetLiteralValue().GetType() == ESUDSValueType::Boolean) { bOverrideGenerateSpeakerLineForChoice = Expr.GetBooleanLiteralValue(); } else { if (!bSilent) Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: [importsetting GenerateSpeakerLinesFromChoices ...] requires a boolean literal"), *NameForErrors, LineNo); } } else if (Name.Compare("SpeakerIDForGeneratedLinesFromChoices", ESearchCase::IgnoreCase) == 0) { if (Expr.GetLiteralValue().GetType() == ESUDSValueType::Name) { // Speaker IDs are strings, but we go via FName to avoid translation OverrideChoiceSpeakerID = Expr.GetNameLiteralValue().ToString(); } else { if (!bSilent) Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: [importsetting SpeakerIDForGeneratedLinesFromChoices ...] requires a Name literal e.g. (`Value`)"), *NameForErrors, LineNo); } } return true; } else { if (!bSilent) Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: importsetting only accepts literal values"), *NameForErrors, LineNo); } } else { if (!bSilent) Logger->Logf(ELogVerbosity::Error, TEXT("Error in %s line %d: %s"), *NameForErrors, LineNo, *ParseError); } } } return false; } bool FSUDSScriptImporter::ParseEventLine(const FStringView& Line, FSUDSScriptImporter::ParsedTree& Tree, int IndentLevel, int LineNo, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent) { const FString LineStr(Line); const FRegexPattern EventPattern(TEXT("^\\[event\\s+([\\w\\.]+)([^\\]]*)\\]$")); FRegexMatcher EventRegex(EventPattern, LineStr); if (EventRegex.FindNext()) { if (!bSilent) UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: EVENT : %s"), LineNo, IndentLevel, *FString(Line)); FSUDSParsedNode Node(ESUDSParsedNodeType::Event, IndentLevel, LineNo); Node.Identifier = EventRegex.GetCaptureGroup(1); if (EventRegex.GetCaptureGroupBeginning(2) != INDEX_NONE) { // Has arguments, all lumped together // Capture using a sub-regex which can detect quoted strings, split by commas FString AllArgs = EventRegex.GetCaptureGroup(2).TrimStartAndEnd(); const FRegexPattern ArgPattern(TEXT("((\\\"[^\\\"]*\\\"|[^,\\\"]+))")); FRegexMatcher ArgRegex(ArgPattern, AllArgs); while (ArgRegex.FindNext()) { // then process the quote FString ArgStr = ArgRegex.GetCaptureGroup(1).TrimStartAndEnd(); if (ArgStr.Len() == 0) continue; FSUDSExpression Expr; FString ParseError; if (Expr.ParseFromString(ArgStr, &ParseError)) { // note: no localisation of event literals, they're just strings // we assume the receiver of the event will set localised text to variables if they want Node.EventArgs.Add(Expr); } else { if (!bSilent) Logger->Logf(ELogVerbosity::Warning, TEXT("Error in %s line %d: Literal argument %d ('%s') invalid: %s"), *NameForErrors, LineNo, Node.EventArgs.Num() + 1, *ArgStr, *ParseError); } } } AppendNode(Tree, Node); return true; } return false; } bool FSUDSScriptImporter::ParseTextLine(const FStringView& InLine, FSUDSScriptImporter::ParsedTree& Tree, int IndentLevel, int LineNo, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent) { auto& Ctx = Tree.IndentLevelStack.Top(); // Attempt to find existing text ID // For multiple lines, may not be present until last line (but in fact can be on any line) FString TextID; FStringView Line = InLine; // Retrieve, but don't generate text ID at this point // If this is a continuation line, we shouldn't generate one, but we need to trim it off if it's there // TextID may be blank after this, that's OK - we fix at the end once we know what IDs are used RetrieveAndRemoveTextID(Line, TextID); const FString LineStr(Line); const FRegexPattern SpeakerPattern(TEXT("^(\\S+)\\:\\s*(.+)$")); FRegexMatcher SpeakerRegex(SpeakerPattern, LineStr); if (SpeakerRegex.FindNext()) { // OK this is a speaker line, in which case this is a new text node const FString Speaker = SpeakerRegex.GetCaptureGroup(1); const FString Text = SpeakerRegex.GetCaptureGroup(2); if (!bSilent) UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: TEXT : %s"), LineNo, IndentLevel, *FString(Line)); // New text node // Text nodes can never introduce another indent context // We've already backed out to the outer indent in caller Ctx.LastTextNodeIdx = AppendNode(Tree, FSUDSParsedNode(Speaker, Text, TextID, GetTextMetadataForNextEntry(IndentLevel), ConsumeUserMetadata(), IndentLevel, LineNo)); ReferencedSpeakers.AddUnique(Speaker); return true; } // If we fell through, this line is appended to the last text node if (!bSilent) UE_LOG(LogSUDSImporter, VeryVerbose, TEXT("%3d:%2d: TEXT+ : %s"), LineNo, IndentLevel, *FString(Line)); if (Tree.Nodes.IsValidIndex(Ctx.LastNodeIdx)) { auto& Node = Tree.Nodes[Ctx.LastNodeIdx]; if (Node.NodeType == ESUDSParsedNodeType::Text) { Node.Text.Appendf(TEXT("\n%s"), *LineStr); } else { if (!bSilent) Logger->Logf(ELogVerbosity::Warning, TEXT("Error in %s line %d: Text newline continuation is not immediately after a speaker line. Ignoring."), *NameForErrors, LineNo); // We still return true to allow continue } } return true; } bool FSUDSScriptImporter::RetrieveAndRemoveTextID(FStringView& InOutLine, FString& OutTextID) { FString LineWithout; int Number; if (RetrieveTextIDFromLine(InOutLine, OutTextID, Number)) { TextIDHighestNumber = FMath::Max(TextIDHighestNumber, Number); return true; } return false; } bool FSUDSScriptImporter::RetrieveTextIDFromLine(FStringView& InOutLine, FString& OutTextID, int& OutNumber) { const FString LineStr(InOutLine); const FRegexPattern TextIDPattern(TEXT("(\\@([0-9a-fA-F]+)\\@)")); FRegexMatcher TextIDRegex(TextIDPattern, LineStr); if (TextIDRegex.FindNext()) { OutTextID = TextIDRegex.GetCaptureGroup(1); // Chop the incoming string to the left of the TextID InOutLine = InOutLine.Left(TextIDRegex.GetCaptureGroupBeginning(1)); // Also trim right InOutLine = InOutLine.TrimEnd(); // FDefaultValueHelper::ParseInt requires an "0x" prefix but we're not using that // Plus does extra checking we don't need OutNumber = FCString::Strtoi(*TextIDRegex.GetCaptureGroup(2), nullptr, 16); return true; } return false; } bool FSUDSScriptImporter::RetrieveAndRemoveGosubID(FStringView& InOutLine, FString& OutTextID) { FString LineWithout; int Number; if (RetrieveGosubIDFromLine(InOutLine, OutTextID, Number)) { GosubIDHighestNumber = Number; return true; } return false; } bool FSUDSScriptImporter::RetrieveGosubIDFromLine(FStringView& InOutLine, FString& OutID, int& OutNumber) { const FString LineStr(InOutLine); const FRegexPattern IDPattern(TEXT("(\\@GS([0-9a-fA-F]+)\\@)")); FRegexMatcher IDRegex(IDPattern, LineStr); if (IDRegex.FindNext()) { OutID = IDRegex.GetCaptureGroup(1); // Chop the incoming string to the left of the TextID InOutLine = InOutLine.Left(IDRegex.GetCaptureGroupBeginning(1)); // Also trim right InOutLine = InOutLine.TrimEnd(); // FDefaultValueHelper::ParseInt requires an "0x" prefix but we're not using that // Plus does extra checking we don't need OutNumber = FCString::Strtoi(*IDRegex.GetCaptureGroup(2), nullptr, 16); return true; } return false; } FString FSUDSScriptImporter::GenerateTextID() { // Generate a new text ID just based on ascending numbers // We don't actually base this on the line but we have it for future possible use // Since it's a string, format exactly as in the sud file return FString::Printf(TEXT("@%04x@"), ++TextIDHighestNumber); } FString FSUDSScriptImporter::GetCurrentTreePath(const FSUDSScriptImporter::ParsedTree& Tree) { // This is just a path of all the choice / select nodes AND their edges leading to this point, for fallthrough // * Choice (/C000/) // * Nested choice (/C000/C001/) // Fallthrough from here // * Choice (/C002/C003/) // Do NOT fallthrough to here // Fallthrough to here instead (/) #if ENGINE_MAJOR_VERSION ==5 && ENGINE_MINOR_VERSION >= 8 TStringBuilder<256> B; #else FStringBuilderBase B; #endif for (auto Indent : Tree.IndentLevelStack) { B.Appendf(TEXT("%s%s"), *Indent.PathEntry, *TreePathSeparator); } return B.ToString(); } FString FSUDSScriptImporter::GetCurrentTreeConditionalPath(const FSUDSScriptImporter::ParsedTree& Tree) { // Like GetCurrentTreePath, but for conditional blocks // Cannot fall through to blocks that aren't on the same conditional path #if ENGINE_MAJOR_VERSION ==5 && ENGINE_MINOR_VERSION >= 8 TStringBuilder<256> B; #else FStringBuilderBase B; #endif int BlockIdx = Tree.CurrentConditionalBlockIdx; B.Append(TreePathSeparator); // work backwards, hence prepend while (BlockIdx != -1) { const FString& ConditionStr = Tree.ConditionalBlocks[BlockIdx].ConditionPathElement; // Note: add the "/" even if ConditionStr is empty, because it means it's an else level // Not including it can cause an if block to fall through to its own else B.Prepend(FString::Printf(TEXT("%s%s"), *TreePathSeparator, *ConditionStr)); BlockIdx = Tree.ConditionalBlocks[BlockIdx].PreviousBlockIdx; } return B.ToString(); } FString FSUDSScriptImporter::GetTreeConditionalPath(const FSUDSScriptImporter::ParsedTree& Tree, int NodeIndex) { // Like GetCurrentTreeConditionalPath, but we're not in the block context. Follow the nodes back up #if ENGINE_MAJOR_VERSION ==5 && ENGINE_MINOR_VERSION >= 8 TStringBuilder<256> B; #else FStringBuilderBase B; #endif B.Append(TreePathSeparator); while (Tree.Nodes.IsValidIndex(NodeIndex)) { const auto Node = Tree.Nodes[NodeIndex]; if (Tree.Nodes.IsValidIndex(Node.ParentNodeIdx)) { const auto& Parent = Tree.Nodes[Node.ParentNodeIdx]; if (Parent.NodeType == ESUDSParsedNodeType::Select) { const int SelectNodeIdx = Node.ParentNodeIdx; const int EdgeIdx = FindEdge(Tree, SelectNodeIdx, NodeIndex); if (Parent.Edges.IsValidIndex(EdgeIdx)) { const auto& Edge = Parent.Edges[EdgeIdx]; const bool bHasCondition = !Edge.ConditionExpression.IsEmpty(); FString ConditionalPathElem; if (EdgeIdx == 0) { // First edge is always if ConditionalPathElem = MakeIfConditionPathElement(SelectNodeIdx, Edge.ConditionExpression.GetSourceString()); } else { if (bHasCondition) { // non-zero index edge with a condition is elseif ConditionalPathElem = MakeElseIfConditionPathElement(SelectNodeIdx, Edge.ConditionExpression.GetSourceString()); } else { ConditionalPathElem = MakeElseConditionPathElement(SelectNodeIdx); } } // working backwards, hence prepend B.Prepend(FString::Printf(TEXT("%s%s"), *TreePathSeparator, *ConditionalPathElem)); } } } NodeIndex = Node.ParentNodeIdx; } return B.ToString(); } int FSUDSScriptImporter::FindEdge(const FSUDSScriptImporter::ParsedTree& Tree, int ParentNodeIdx, int TargetNodeIndex) { if (Tree.Nodes.IsValidIndex(ParentNodeIdx)) { const auto& Parent = Tree.Nodes[ParentNodeIdx]; for (int i = 0; i < Parent.Edges.Num(); ++i) { if (Parent.Edges[i].TargetNodeIdx == TargetNodeIndex) { return i; } } } return -1; } void FSUDSScriptImporter::SetFallthroughForNewNode(FSUDSScriptImporter::ParsedTree& Tree, FSUDSParsedNode& NewNode) { // Choice nodes are allowed to be falled through to now if (NewNode.NodeType == ESUDSParsedNodeType::Choice) { // But, disable fallthrough for any parent select nodes all the way up the chain int PrevIdx = NewNode.ParentNodeIdx; while (Tree.Nodes.IsValidIndex(PrevIdx) && Tree.Nodes[PrevIdx].NodeType == ESUDSParsedNodeType::Select) { Tree.Nodes[PrevIdx].AllowFallthrough = false; PrevIdx = Tree.Nodes[PrevIdx].ParentNodeIdx; } } else { if (Tree.Nodes.IsValidIndex(NewNode.ParentNodeIdx)) { auto& PrevNode = Tree.Nodes[NewNode.ParentNodeIdx]; // Do not allow fallthrough to this node for selects that are themselves children of other selects or choices // It means they're compound conditionals / choices and we should only fall through to the root of that if (NewNode.NodeType == ESUDSParsedNodeType::Select && (PrevNode.NodeType == ESUDSParsedNodeType::Choice || PrevNode.NodeType == ESUDSParsedNodeType::Select)) { NewNode.AllowFallthrough = false; } } } } int FSUDSScriptImporter::AppendNode(FSUDSScriptImporter::ParsedTree& Tree, const FSUDSParsedNode& InNode) { auto& Ctx = Tree.IndentLevelStack.Top(); const int NewIndex = Tree.Nodes.Add(InNode); // Set the tree path of the node (post-add) auto& NewNode = Tree.Nodes[NewIndex]; NewNode.ChoicePath = GetCurrentTreePath(Tree); NewNode.ConditionalPath = GetCurrentTreeConditionalPath(Tree); // Use pending edge if present; that could be because this is under a choice node, or a condition if (auto E = GetEdgeInProgress(Tree)) { E->TargetNodeIdx = NewIndex; const int PrevNodeIdx = E->SourceNodeIdx; NewNode.ParentNodeIdx = PrevNodeIdx; SetFallthroughForNewNode(Tree, NewNode); Tree.EdgeInProgressNodeIdx = -1; Tree.EdgeInProgressEdgeIdx = -1; } else { const int PrevNodeIdx = Ctx.LastNodeIdx; if (Tree.Nodes.IsValidIndex(PrevNodeIdx)) { // Append this node onto the last one auto& PrevNode = Tree.Nodes[PrevNodeIdx]; // Auto-connect new nodes to previous nodes // Valid for nodes with only one output node // A new node with no pending edge following any other type may be connected via fallthrough at // the end of parsing // Don't allow connection of fallthrough nodes to choices, unless they're to another select node if (PrevNode.NodeType != ESUDSParsedNodeType::Choice || (NewNode.NodeType == ESUDSParsedNodeType::Select)) { PrevNode.Edges.Add(FSUDSParsedEdge(PrevNodeIdx, NewIndex, InNode.SourceLineNo)); NewNode.ParentNodeIdx = PrevNodeIdx; } // Don't throw an error otherwise, because prev index can be a choice due to fallthrough // This will be connected up at the end } SetFallthroughForNewNode(Tree, NewNode); } // All goto labels in scope at this point now point to this node for (auto Label : Tree.PendingGotoLabels) { Tree.GotoLabelList.Add(Label, NewIndex); } Tree.PendingGotoLabels.Reset(); Ctx.LastNodeIdx = NewIndex; Ctx.ThresholdIndent = FMath::Min(Ctx.ThresholdIndent, InNode.OriginalIndent); return NewIndex; } bool FSUDSScriptImporter::SelectNodeIsMissingElsePath(const FSUDSScriptImporter::ParsedTree& Tree, const FSUDSParsedNode& Node) { for (auto& E : Node.Edges) { if (E.ConditionExpression.IsEmpty()) { // This is an else return false; } } // If the first node the select points to (other than a nested select) is a choice, we never add an else // note: you should never have an if/else where one branch has a set/text node and the other has a choice node, this is badly formed // perhaps we should validate that if (Node.Edges.Num() > 0) { int NextIdx = Node.Edges[0].TargetNodeIdx; // While so that we can follow nested selects to first resolved while (Tree.Nodes.IsValidIndex(NextIdx)) { auto N = Tree.Nodes[NextIdx]; if (N.NodeType == ESUDSParsedNodeType::Select) { // Nested select, cascade down if (N.Edges.Num() > 0) { NextIdx = N.Edges[0].TargetNodeIdx; } else { NextIdx = -1; } } else { if (N.NodeType == ESUDSParsedNodeType::Choice) { // We never add fallthrough else paths for selection between choices return false; } NextIdx = -1; } } } return true; } void FSUDSScriptImporter::PopIndent(FSUDSScriptImporter::ParsedTree& Tree) { Tree.IndentLevelStack.Pop(); // We *could* reset the pending goto list if there are labels at higher indents than current that never resolved to a node // but I'm choosing not to right now and letting them fall through } void FSUDSScriptImporter::PushIndent(FSUDSScriptImporter::ParsedTree& Tree, int NodeIdx, int Indent, const FString& Path) { Tree.IndentLevelStack.Push(IndentContext(NodeIdx, Indent, Path)); } bool FSUDSScriptImporter::IsCommentLine(const FStringView& TrimmedLine) { return TrimmedLine.StartsWith('#'); } FStringView FSUDSScriptImporter::TrimLine(const FStringView& Line, int& OutIndentLevel) const { OutIndentLevel = 0; int32 SkippedChars = 0; for (const TCHAR Char : Line) { if (!TChar::IsWhitespace(Char)) { break; } ++SkippedChars; if (Char == '\t') OutIndentLevel += TabIndentValue; else ++OutIndentLevel; } FStringView LeftTrimmed = FStringView(Line.GetData() + SkippedChars, Line.Len() - SkippedChars); // Trim end, don't need to know how much it was return LeftTrimmed.TrimEnd(); } void FSUDSScriptImporter::ConnectRemainingNodes(FSUDSScriptImporter::ParsedTree& Tree, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent) { // Now we go through all nodes, resolving gotos, and finding links that don't go anywhere & making // them fall through to the next appropriate outdented node (or the end) // Firstly turn all the alias gotos into final gotos for (auto& Alias : Tree.AliasedGotoLabels) { if (int* pIdx = Tree.GotoLabelList.Find(Alias.Value)) { Tree.GotoLabelList.Add(Alias.Key, *pIdx); } } Tree.AliasedGotoLabels.Reset(); // We go through top-to-bottom, which is the order of lines in the file as well // We don't need to cascade for this for (int i = 0; i < Tree.Nodes.Num(); ++i) { auto& Node = Tree.Nodes[i]; // We check for dead-end nodes, and for select nodes with no "else" (in case all conditions fail) const bool bIsSelectNodeMissingElse = Node.NodeType == ESUDSParsedNodeType::Select && SelectNodeIsMissingElsePath(Tree, Node); if (Node.Edges.IsEmpty() || bIsSelectNodeMissingElse) { if (Node.NodeType == ESUDSParsedNodeType::Goto) { // Try to resolve goto now that we've parsed all labels // We don't actually create edges here, the label is enough so long as it leads somewhere // Check aliases first FString Label = Node.Identifier; // Special case 'end' which needs no further checking if (Label != EndGotoLabel) { const int GotoNodeIdx = GetGotoTargetNodeIndex(Tree, Node.Identifier); if (GotoNodeIdx == -1) { if (!bSilent) Logger->Logf(ELogVerbosity::Warning, TEXT("Error in %s line %d: Goto label '%s' was not found, references to it will goto End"), *NameForErrors, Node.SourceLineNo, *Node.Identifier); } } } // Return nodes never fall through, they're always going back // Gosubs DO come through here though, fallthrough is AFTER the return else if (Node.NodeType != ESUDSParsedNodeType::Return) { // Find the next node which is at a higher indent level than this // For a select node missing else, treat the indent as 1 inward, since it's really falling through from a nested part of the select const int IndentLessThan = bIsSelectNodeMissingElse ? Node.OriginalIndent + 1 : Node.OriginalIndent; const auto FallthroughIdx = FindFallthroughNodeIndex(Tree, i+1, Node.ChoicePath, Node.ConditionalPath); if (Tree.Nodes.IsValidIndex(FallthroughIdx)) { Node.Edges.Add(FSUDSParsedEdge(i, FallthroughIdx, Node.SourceLineNo)); } else { // If no node to fallthrough to, default will be to end } } } else { for (auto& Edge : Node.Edges) { if (!Tree.Nodes.IsValidIndex(Edge.TargetNodeIdx)) { // Usually this is a choice line without anything under it, or a condition with nothing in it const auto FallthroughIdx = FindFallthroughNodeIndex(Tree, i+1, Node.ChoicePath, Node.ConditionalPath); if (Tree.Nodes.IsValidIndex(FallthroughIdx)) { Edge.TargetNodeIdx = FallthroughIdx; } else { // If no node to fallthrough to, goto end Edge.TargetNodeIdx = -1; } } } } } } void FSUDSScriptImporter::GenerateTextIDs(ParsedTree& Tree) { // This is where we generate any missing TextIDs for Text nodes, Set nodes (optionally) and Choice edges // We don't want to do it as we go along, because if a script has some lines with TextIDs and new inserted lines // in between, we won't know from a top-down scan which IDs have already been used. Later explicit TextIDs in // the script could cause an ID clash with generated ones for inserted lines for (auto& Node : Tree.Nodes) { switch (Node.NodeType) { case ESUDSParsedNodeType::Text: if (Node.TextID.IsEmpty()) { Node.TextID = GenerateTextID(); } break; case ESUDSParsedNodeType::Choice: // Text for choices is on edges { for (auto& Edge : Node.Edges) { if (!Edge.Text.IsEmpty() && Edge.TextID.IsEmpty()) { Edge.TextID = GenerateTextID(); } // If this choice generated a speaker line, we need to give that the same text ID // It will always be directly after the choice edge if (Tree.Nodes.IsValidIndex(Edge.TargetNodeIdx)) { auto& NextNode = Tree.Nodes[Edge.TargetNodeIdx]; if (NextNode.NodeType == ESUDSParsedNodeType::Text && NextNode.TextID.IsEmpty() && NextNode.Text == Edge.Text) { NextNode.TextID = Edge.TextID; } } } } break; case ESUDSParsedNodeType::SetVariable: if (Node.Expression.IsTextLiteral()) { // Text must be localised if (Node.TextID.IsEmpty()) { Node.TextID = GenerateTextID(); } } break; default: break; } } } bool FSUDSScriptImporter::PostImportSanityCheck(const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent) { bool bOK = true; for (int i = 0; i < BodyTree.Nodes.Num(); ++i) { auto& Node = BodyTree.Nodes[i]; if (Node.NodeType == ESUDSParsedNodeType::Choice) { // Check all of them so we can report all errors, rather than early-out bOK = ChoiceNodeCheckPaths(Node, NameForErrors, Logger, bSilent) && bOK; } } // check for unfinished conditional blocks if (BodyTree.ConditionalBlocks.IsValidIndex(BodyTree.CurrentConditionalBlockIdx)) { auto& Block = BodyTree.ConditionalBlocks[BodyTree.CurrentConditionalBlockIdx]; auto SelectNode = BodyTree.Nodes.IsValidIndex(Block.SelectNodeIdx) ? &BodyTree.Nodes[Block.SelectNodeIdx] : nullptr; if (!bSilent && SelectNode) { FString BlockStartDesc; FString BlockEndDesc; switch (Block.Stage) { case EConditionalStage::IfStage: case EConditionalStage::ElseIfStage: case EConditionalStage::ElseStage: BlockStartDesc = "if"; BlockEndDesc = "endif"; break; case EConditionalStage::RandomStage: case EConditionalStage::RandomOptionStage: BlockStartDesc = "random"; BlockEndDesc = "endrandom"; break; } Logger->Logf(ELogVerbosity::Error, TEXT( "%s: '%s' block started on line %d is missing '%s'"), *NameForErrors, *BlockStartDesc, SelectNode->SourceLineNo, *BlockEndDesc); } } // Now check everything is referenced TArray ReferencedNodes; ReferencedNodes.SetNumZeroed(BodyTree.Nodes.Num()); if (!ReferencedNodes.IsEmpty()) { // First node is always reachable ReferencedNodes[0] = true; } for (const auto& Goto : BodyTree.GotoLabelList) { // Goto creates a reference ReferencedNodes[Goto.Value] = true; } for (const auto& Node : BodyTree.Nodes) { for (const auto& Edge : Node.Edges) { if (ReferencedNodes.IsValidIndex(Edge.TargetNodeIdx)) { ReferencedNodes[Edge.TargetNodeIdx] = true; } } } for (int i = 0; i < ReferencedNodes.Num(); ++i) { if (!ReferencedNodes[i]) { Logger->Logf(ELogVerbosity::Warning, TEXT( "%s: Line %d is unreachable. Check your indenting particularly under choice lines."), *NameForErrors, BodyTree.Nodes[i].SourceLineNo); } } return bOK; } bool FSUDSScriptImporter::ChoiceNodeCheckPaths(const FSUDSParsedNode& ChoiceNode, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent) { return RecurseChoiceNodeCheckPaths(ChoiceNode, ChoiceNode, NameForErrors, Logger, bSilent); } bool FSUDSScriptImporter::RecurseChoiceNodeCheckPaths(const FSUDSParsedNode& OrigChoiceNode, const FSUDSParsedNode& CurrChoiceNode, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent) { bool bOK = true; for (const auto& Edge: CurrChoiceNode.Edges) { // We want to make sure that every choice path leads to a speaker line, before it leads to another choice // A choice that leads directly to another choice can't be properly represented in dialogue; choices have to // be anchored by speaker lines so proceeding to another choice directly after a choice is made is wrong // Usually this will be caused by a bad goto but could also be just bad nesting // Every path has to lead to a speaker node before a choice bOK = RecurseChoiceNodeCheckPaths(OrigChoiceNode, Edge, NameForErrors, Logger, bSilent) && bOK; } return bOK; } bool FSUDSScriptImporter::RecurseChoiceNodeCheckPaths(const FSUDSParsedNode& ChoiceNode, const FSUDSParsedEdge& Edge, const FString& NameForErrors, FSUDSMessageLogger* Logger, bool bSilent) { const FSUDSParsedNode* TargetNode = GetNode(Edge.TargetNodeIdx); while (TargetNode) { switch (TargetNode->NodeType) { case ESUDSParsedNodeType::Text: // We're OK, found a speaker line return true; case ESUDSParsedNodeType::Choice: // Definitely not ok, we found a choice node before a speaker node Logger->Logf(ELogVerbosity::Error, TEXT( "%s: Choice '%s' on line %d needs a speaker line between it and the next choice at line %d. Choices MUST show another speaker line before the next choice."), *NameForErrors, *Edge.Text, Edge.SourceLineNo, TargetNode->SourceLineNo); return false; case ESUDSParsedNodeType::Select: { // Recurse selects & randoms; but in this case we can have nested choices underneath bool bOK = true; for (const auto& SelEdge : TargetNode->Edges) { const auto SelTarget = GetNode(SelEdge.TargetNodeIdx); if (SelTarget) { if (SelTarget->NodeType == ESUDSParsedNodeType::Choice) { // First level of nested choices is OK; they will be combined with the original choice // We don't need to recurse here since this choice will itself } else { bOK = RecurseChoiceNodeCheckPaths(ChoiceNode, SelEdge, NameForErrors, Logger, bSilent); } } } return bOK; } case ESUDSParsedNodeType::SetVariable: case ESUDSParsedNodeType::Event: // Continue (linear) if (TargetNode->Edges.Num() > 0) { TargetNode = GetNode(TargetNode->Edges[0].TargetNodeIdx); } else { // This can happen if the event is the last line, but also nested in a choice return true; } break; case ESUDSParsedNodeType::Gosub: case ESUDSParsedNodeType::Return: // We can't really check the gosub/return statically, this will be a runtime error return true; case ESUDSParsedNodeType::Goto: { // Follow the goto (if end, will result in null) const int GotoIdx = GetGotoTargetNodeIndex(BodyTree, TargetNode->Identifier); TargetNode = GetNode(GotoIdx); break; } } } return true; } int FSUDSScriptImporter::FindFallthroughNodeIndex(FSUDSScriptImporter::ParsedTree& Tree, int StartNodeIndex, const FString& FromChoicePath, const FString& FromConditionalPath) { // In order to be a valid fallthrough, also needs to be on the same choice (or select) path // E.g. it's possible to have: // // * Choice (C1) // * Nested choice (C1.1) // Fallthrough from here (F1) // * Choice (C2) // Do NOT fallthrough to here (T1) // Fallthrough to here instead (T2) // // Just testing the indent would fallthrough to T1 which is incorrect because it's not on the same choice path // We need to check that the fallthrough point is on the same choice path (not just a common parent) // Fallthrough from is on path /C1/C1.1 // Point T1 is on /C2 which is NOT a subset of /C1/C1.1 so not OK // Point T2 is on /, which is a subset of /C1/C1/1 so OK // // Also need to deal with this case: // // * Choice (C1) // * Nested choice (C1.1) // Fallthrough from here (F1) // Fallthrough to here (T1) then from here (F2) // * Choice (C2) // Do NOT fallthrough to here (T2) // Finally fall through to here (T3) // // In this case, fallthrough should first go to T1, then to T3 // - Fallthrough F1 is on path /C1/C1.1 // - Point T1 is on /C1 which IS a subset of /C1/C1.1 so OK // - Fallthrough F2 is on path /C1 // - Point T2 is on /C2 which is NOT a subset of /C1 so not OK // - Point T3 is on / which is a subset of /C1 so OK // We'll form these paths just from node indexes rather than C1/C2 etc. Nesting can be for a choice or a select for (int i = StartNodeIndex; i < Tree.Nodes.Num(); ++i) { auto N = Tree.Nodes[i]; // We used to require that N.OriginalIndent < IndentLessThan here // However, this is actually not needed, since indentation only controls association with choice paths, otherwise // it's irrelevant. And we already check that things only fall through if they're on the same choice/conditional // path (or a superset of it). if (N.AllowFallthrough && FromChoicePath.StartsWith(N.ChoicePath) && FromConditionalPath.StartsWith(N.ConditionalPath)) { return i; } } return -1; } const FSUDSParsedNode* FSUDSScriptImporter::GetNode(const FSUDSScriptImporter::ParsedTree& Tree, int Index) { if (Tree.Nodes.IsValidIndex(Index)) { return &Tree.Nodes[Index]; } return nullptr; } const FSUDSParsedNode* FSUDSScriptImporter::GetHeaderNode(int Index) { return GetNode(HeaderTree, Index); } const FSUDSParsedNode* FSUDSScriptImporter::GetNode(int Index) { return GetNode(BodyTree, Index); } int FSUDSScriptImporter::GetGotoTargetNodeIndex(const FString& InLabel) { // Assume Body for this public version return GetGotoTargetNodeIndex(BodyTree, InLabel); } int FSUDSScriptImporter::GetGotoTargetNodeIndex(const ParsedTree& Tree, const FString& InLabel) { FString Label = InLabel; if (const FString* AliasLabel = Tree.AliasedGotoLabels.Find(Label)) { Label = *AliasLabel; } // Resolve using goto list if (const int *pGotoIdx = Tree.GotoLabelList.Find(Label)) { return *pGotoIdx; } return -1; } void FSUDSScriptImporter::PopulateAsset(USUDSScript* Asset, UStringTable* StringTable) { // This is only called if the parsing was successful // Populate the runtime asset TArray> *pOutNodes = nullptr; TArray> *pOutHeaderNodes = nullptr; TMap *pOutLabels = nullptr; TMap *pOutHeaderLabels = nullptr; TArray *pOutSpeakers = nullptr; Asset->StartImport(&pOutNodes, &pOutHeaderNodes, &pOutLabels, &pOutHeaderLabels, &pOutSpeakers); pOutSpeakers->Append(ReferencedSpeakers); PopulateAssetFromTree(Asset, HeaderTree, pOutHeaderNodes, pOutHeaderLabels, StringTable); PopulateAssetFromTree(Asset, BodyTree, pOutNodes, pOutLabels, StringTable); Asset->FinishImport(); } FMD5Hash FSUDSScriptImporter::CalculateHash(const TCHAR* Buffer, int32 Len) { FMD5Hash Hash; FMD5 MD5; MD5.Update((uint8*)Buffer, Len * sizeof(TCHAR)); Hash.Set(MD5); return Hash; } void FSUDSScriptImporter::PopulateAssetFromTree(USUDSScript* Asset, const FSUDSScriptImporter::ParsedTree& Tree, TArray>* pOutNodes, TMap* pOutLabels, UStringTable* StringTable) { if (pOutNodes && pOutLabels) { TArray IndexRemap; int OutIndex = 0; // First pass, create all the nodes for (const auto& InNode : Tree.Nodes) { // Gotos are dealt with in the node that references them, so ignore them // We're going to be removing Goto nodes in the parse structure, because they were useful while parsing // (letting you fallthrough to a goto node) but in the final runtime we just want them to be edges // So firstly we need to figure out what the indexes of other nodes are going to be with them removed if (InNode.NodeType == ESUDSParsedNodeType::Goto) { // note that this one goes nowhere, and don't increment dest index IndexRemap.Add(-1); } else { IndexRemap.Add(OutIndex++); USUDSScriptNode* Node = nullptr; switch (InNode.NodeType) { case ESUDSParsedNodeType::Text: { #if ENGINE_MAJOR_VERSION ==5 && ENGINE_MINOR_VERSION >= 8 StringTable->GetMutableStringTable()->SetSourceString(InNode.TextID, InNode.Text, ""); #else StringTable->GetMutableStringTable()->SetSourceString(InNode.TextID, InNode.Text); #endif // Always include speaker metadata StringTable->GetMutableStringTable()->SetMetaData(InNode.TextID, FName("Speaker"), InNode.Identifier); // Other metadata for (auto Pair : InNode.TextMetadata) { StringTable->GetMutableStringTable()->SetMetaData(InNode.TextID, Pair.Key, Pair.Value); } auto TextNode = NewObject(Asset); TextNode->Init(InNode.Identifier, FText::FromStringTable (StringTable->GetStringTableId(), InNode.TextID), InNode.SourceLineNo); TextNode->SetUserMetadata(InNode.UserMetadata); Node = TextNode; break; } case ESUDSParsedNodeType::Choice: { auto ChoiceNode = NewObject(Asset); ChoiceNode->InitChoice(InNode.SourceLineNo); Node = ChoiceNode; break; } case ESUDSParsedNodeType::Select: { auto SelectNode = NewObject(Asset); SelectNode->InitSelect(InNode.SourceLineNo); Node = SelectNode; break; } case ESUDSParsedNodeType::SetVariable: { auto SetNode = NewObject(Asset); // For text literals, re-point to string table FSUDSExpression Expr = InNode.Expression; if (Expr.IsTextLiteral()) { #if ENGINE_MAJOR_VERSION ==5 && ENGINE_MINOR_VERSION >= 8 StringTable->GetMutableStringTable()->SetSourceString(InNode.TextID, Expr.GetTextLiteralValue().ToString(), ""); #else StringTable->GetMutableStringTable()->SetSourceString(InNode.TextID, Expr.GetTextLiteralValue().ToString()); #endif Expr.SetTextLiteralValue(FText::FromStringTable (StringTable->GetStringTableId(), InNode.TextID)); } SetNode->Init(InNode.Identifier, Expr, InNode.SourceLineNo); Node = SetNode; break; } case ESUDSParsedNodeType::Event: { auto EvtNode = NewObject(Asset); EvtNode->Init(InNode.Identifier, InNode.EventArgs, InNode.SourceLineNo); Node = EvtNode; break; } case ESUDSParsedNodeType::Gosub: { // Validate gosub label at this point auto GosubNode = NewObject(Asset); GosubNode->Init(InNode.Identifier, InNode.TextID, InNode.SourceLineNo); Node = GosubNode; break; } case ESUDSParsedNodeType::Return: { auto ReturnNode = NewObject(Asset); ReturnNode->InitReturn(InNode.SourceLineNo); Node = ReturnNode; break; } case ESUDSParsedNodeType::Goto: // Gotos do not become nodes, just fixed edges default: ; break; } pOutNodes->Add(Node); } } // Second pass, create edges between nodes now that we know where everything is for (int i = 0; i < Tree.Nodes.Num(); ++i) { const FSUDSParsedNode& InNode = Tree.Nodes[i]; if (InNode.NodeType != ESUDSParsedNodeType::Goto) { USUDSScriptNode* Node = (*pOutNodes)[IndexRemap[i]]; // Edges if (InNode.Edges.Num() == 0) { // This normally happens with the final node in the script // Make it an edge to nullptr for consistency Node->AddEdge(FSUDSScriptEdge(nullptr, ESUDSEdgeType::Continue, InNode.SourceLineNo)); } else { for (auto& InEdge : InNode.Edges) { ESUDSEdgeType NewEdgeType; const FSUDSParsedNode *InTargetNode = GetNode(Tree, InEdge.TargetNodeIdx); switch (InNode.NodeType) { case ESUDSParsedNodeType::Text: if (InTargetNode && InTargetNode->NodeType == ESUDSParsedNodeType::Choice) { // Text -> Choice is chained NewEdgeType = ESUDSEdgeType::Chained; } else { NewEdgeType = ESUDSEdgeType::Continue; } break; case ESUDSParsedNodeType::Choice: if (InEdge.Text.IsEmpty() && InTargetNode && InTargetNode->NodeType == ESUDSParsedNodeType::Select) { // Choice->Select with no text is chained NewEdgeType = ESUDSEdgeType::Chained; } else { NewEdgeType = ESUDSEdgeType::Decision; } break; case ESUDSParsedNodeType::Select: // All edges under selects are conditions NewEdgeType = ESUDSEdgeType::Condition; break; default: case ESUDSParsedNodeType::SetVariable: case ESUDSParsedNodeType::Goto: case ESUDSParsedNodeType::Event: NewEdgeType = ESUDSEdgeType::Continue; break; }; USUDSScriptNode* TargetNode = nullptr; if (InTargetNode) { if (InTargetNode->NodeType == ESUDSParsedNodeType::Goto) { // Resolve GOTOs immediately, point them directly at node goto points to int Idx = GetGotoTargetNodeIndex(Tree, InTargetNode->Identifier); // -1 means "Goto end", leave target null in that case if (Idx != -1) { const int NewTargetIndex = IndexRemap[Idx]; TargetNode = (*pOutNodes)[NewTargetIndex]; } } else { const int NewTargetIndex = IndexRemap[InEdge.TargetNodeIdx]; TargetNode = (*pOutNodes)[NewTargetIndex]; } } FSUDSScriptEdge NewEdge(TargetNode, NewEdgeType, InEdge.SourceLineNo); NewEdge.SetCondition(InEdge.ConditionExpression); NewEdge.SetTargetNode(TargetNode); if (!InEdge.TextID.IsEmpty() && !InEdge.Text.IsEmpty()) { #if ENGINE_MAJOR_VERSION ==5 && ENGINE_MINOR_VERSION >= 8 StringTable->GetMutableStringTable()->SetSourceString(InEdge.TextID, InEdge.Text, ""); #else StringTable->GetMutableStringTable()->SetSourceString(InEdge.TextID, InEdge.Text); #endif NewEdge.SetText(FText::FromStringTable(StringTable->GetStringTableId(), InEdge.TextID)); // Always include speaker metadata, always the player in a choice // Identify that it's a choice so translators know that there may be more limited space StringTable->GetMutableStringTable()->SetMetaData(InEdge.TextID, FName("Speaker"), "Player (Choice)"); // Other metadata for (auto Pair : InEdge.TextMetadata) { StringTable->GetMutableStringTable()->SetMetaData(InEdge.TextID, Pair.Key, Pair.Value); } } NewEdge.SetUserMetadata(InEdge.UserMetadata); Node->AddEdge(NewEdge); } } } } // Add labels, so that dialogue can be entered at any label // Aliases have already been resolved for (auto& Elem : Tree.GotoLabelList) { int NewIndex = IndexRemap[Elem.Value]; pOutLabels->Add(FName(Elem.Key), NewIndex); } } }