Preserving comments in ini file after saving

Hi all esteemed experts in this amazing community,

I am trying to create mods controlled by text file (I chose .ini as normal json does not support comment).

At onStart of the gameInstance, the program will read the ini file from specified location using FConfigFile.Read(). Modders can modify via UE5 UI. And then will save the configuration upon quitting.

However, after writing to the config file, all previous comments (for guiding modders who want to change directly in the config file) will disappear.

Is there anyway to save the comments?

If no, is there anyway to make FConfigFile inherit from unordered map (I believe now is from TMAP which is from ordered map)? So that I can use #=XXX for the comments? I need the ini file to be in exact order so that the comment can appear for the correct variable

Thanks in advance
Really appreciate any assistance

homemade experiment

bool IniSaveWithComments(const FString& SourceFile, FConfigFile& Config, const FString& DestFile);
bool ShouldExportQuotedString(const FString& PropertyValue);

void ASomeActor::IniTest()
{
	FString InPath = "D:/Test.ini";
	FString OutPath = "D:/TestOut.ini";

	FConfigFile ConfigData;
	ConfigData.Read(InPath);
	IniSaveWithComments(InPath, ConfigData, OutPath);
}
struct FSectionComments
{
	FString SectionName;
	FString SectionComment;
	TMap<FString, FString> KeyComments;
};

bool IniSaveWithComments(const FString& SourceFile, FConfigFile& Config, const FString& DestFile)
{
	// Step 1 : read original as text
	// -- Code adapted from FConfigFile::Read
	FString Text;
	FString FinalSourceFile = SourceFile;
	const bool bOverride = FConfigFile::OverrideFileFromCommandline(FinalSourceFile);
	if (bOverride)
		FFileHelper::LoadFileToString(Text, &IPlatformFile::GetPlatformPhysical(), *FinalSourceFile);
	else
		FFileHelper::LoadFileToString(Text, *FinalSourceFile);

	// Step 2 : Generate a comments map for Sections & Keys
	TMap<FString, FSectionComments> CommentsMap;
	// -- Code adapted from FConfigFile::ProcessInputFileContents
	const TCHAR* Ptr = Text.Len() > 0 ? *Text : nullptr;
	FSectionComments* CurrentSection = nullptr;
	FString CurrentComment;
	while (Ptr != nullptr && *Ptr != 0)
	{
		// Advance past new line characters
		while (*Ptr == '\r' || *Ptr == '\n')
		{
			Ptr++;
		}
		// read the next line
		FString Line;
		int32 LinesConsumed = 0;
		FParse::LineExtended(&Ptr, Line, LinesConsumed, false);

		Line.TrimStartAndEndInline();

		// Consider lines that start with ; as comments
		if (Line.StartsWith(";"))
		{
			CurrentComment += Line + LINE_TERMINATOR;
			continue;
		}

		// If the first character in the line is [ and last char is ], this line indicates a section name
		if (Line.StartsWith("[") && Line.EndsWith("]"))
		{
			FString SectionName = Line.Mid(1, Line.Len() - 2);
			CurrentSection = &CommentsMap.FindOrAdd(SectionName);
			CurrentSection->SectionName = SectionName;
			CurrentSection->SectionComment = CurrentComment;
			CurrentComment.Empty();
		}
		// Otherwise, if we're currently inside a section, and we haven't reached the end of the stream
		else if (CurrentSection)
		{
			int32 i = Line.Find("=");
			if (i > 0)
			{
				FString Key = Line.Left(i);
				Key.TrimStartAndEndInline();
				FString& KeyComment = CurrentSection->KeyComments.FindOrAdd(Key);
				KeyComment += CurrentComment;
				CurrentComment.Empty();
			}
		}
	}

	// Step 3 = Generate ini text with comments
	FString FinalText;
	for (const auto& Pair : Config)
	{
		const FString& SectionName = Pair.Key;
		const FConfigSection& Section = Pair.Value;

		FinalText.Append(LINE_TERMINATOR); // space between sections

		FSectionComments* Comments = CommentsMap.Find(SectionName);
		if (Comments && !Comments->SectionComment.IsEmpty())
		{
			FinalText.Append(Comments->SectionComment);
		}
		FinalText.Append("[" + SectionName + "]" + LINE_TERMINATOR);

		TSet<FString> AlreadyWrote;;

		for (FConfigSection::TConstIterator It2(Section); It2; ++It2)
		{
			const FString& PropertyName = It2.Key().ToString();

			if (AlreadyWrote.Contains(PropertyName))
				continue;
			AlreadyWrote.Add(PropertyName);

			if (Comments && Comments->KeyComments.Contains(PropertyName))
			{
				FString Comm = Comments->KeyComments.FindAndRemoveChecked(PropertyName);
				if (!Comm.IsEmpty())
				{
					FinalText.Append(Comm);
				}
			}

			TArray<const FConfigValue*> CompletePropertyToWrite;
			Section.MultiFindPointer(FName(*PropertyName), CompletePropertyToWrite, true);
			for (const FConfigValue* ConfigValue : CompletePropertyToWrite)
			{
				// -- FConfigFile::AppendExportedPropertyLine is not exported :(
				const FString& Value = ConfigValue->GetSavedValue();
				FinalText.Append(PropertyName + "=");
				if (ShouldExportQuotedString(Value))
				{
					FinalText.Append("\"" + Value.ReplaceCharWithEscapedChar() + "\"");
				}
				else
				{
					FinalText.Append(Value);
				}
				FinalText.Append(LINE_TERMINATOR);
			}
		}
	}

	// Step 4 = Save file
	// -- Code from SaveConfigFileWrapper (private)
	int32 SavedCount = 0;
	FCoreDelegates::PreSaveConfigFileDelegate.Broadcast(*DestFile, FinalText, SavedCount);
	bool bLocalWriteSucceeded = FFileHelper::SaveStringToFile(FinalText, *DestFile, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM);
	return SavedCount > 0 || bLocalWriteSucceeded;
}
// -- Copy of FConfigFile::ShouldExportQuotedString
bool ShouldExportQuotedString(const FString& PropertyValue)
{
	bool bEscapeNextChar = false;
	bool bIsWithinQuotes = false;

	// The value should be exported as quoted string if...
	const TCHAR* const DataPtr = *PropertyValue;
	for (const TCHAR* CharPtr = DataPtr; *CharPtr; ++CharPtr)
	{
		const TCHAR ThisChar = *CharPtr;
		const TCHAR NextChar = *(CharPtr + 1);

		const bool bIsFirstChar = CharPtr == DataPtr;
		const bool bIsLastChar = NextChar == 0;

		if (ThisChar == TEXT('"') && !bEscapeNextChar)
		{
			bIsWithinQuotes = !bIsWithinQuotes;
		}
		bEscapeNextChar = ThisChar == TEXT('\\') && bIsWithinQuotes && !bEscapeNextChar;

		// ... it begins or ends with a space (which is stripped on import)
		if (ThisChar == TEXT(' ') && (bIsFirstChar || bIsLastChar))
		{
			return true;
		}

		// ... it begins with a '"' (which would be treated as a quoted string)
		if (ThisChar == TEXT('"') && bIsFirstChar)
		{
			return true;
		}

		// ... it ends with a '\' (which would be treated as a line extension)
		if (ThisChar == TEXT('\\') && bIsLastChar)
		{
			return true;
		}

		// ... it contains unquoted '{' or '}' (which are stripped on import)
		if ((ThisChar == TEXT('{') || ThisChar == TEXT('}')) && !bIsWithinQuotes)
		{
			return true;
		}

		// ... it contains unquoted '//' (interpreted as a comment when importing)
		if ((ThisChar == TEXT('/') && NextChar == TEXT('/')) && !bIsWithinQuotes)
		{
			return true;
		}

		// ... it contains an unescaped new-line
		if (!bEscapeNextChar && (NextChar == TEXT('\r') || NextChar == TEXT('\n')))
		{
			return true;
		}
	}

	return false;
}

Hi ,

Thanks for your code. It works well but I had made some modification to suit my needs.

Really appreciate all your help… Thanks once again…

For anyone who have the same needs as me, below are my scripts (Modified heavily from , although there is some major changes). Please ignore my class name as I’m reusing some classes that I use for JSON testing.

Below is my class header file
A_JsonObj.h (1.5 KB)

Below is my class source file
A_JsonObj.cpp (8.4 KB)

Below is my testing ini file
Intro.ini (1.1 KB)

Below is the saved ini file
introOut.ini (1.1 KB)