UE代码分析:GConfig的加载

The UE4 provides a mature configuration mechanism for INI files, and the engine uses ini as configuration files for both the engine and projects. This article will briefly analyze the loading of GConfig in the engine.

There are a set of global ini files defined in UE:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Runtime/Core/Private/Misc/CoreGlobals.cpp
FString GEngineIni; /* Engine ini filename */

/** Editor ini file locations - stored per engine version (shared across all projects). Migrated between versions on first run. */
FString GEditorIni; /* Editor ini filename */
FString GEditorKeyBindingsIni; /* Editor Key Bindings ini file */
FString GEditorLayoutIni; /* Editor UI Layout ini filename */
FString GEditorSettingsIni; /* Editor Settings ini filename */

/** Editor per-project ini files - stored per project. */
FString GEditorPerProjectIni; /* Editor User Settings ini filename */

FString GCompatIni;
FString GLightmassIni; /* Lightmass settings ini filename */
FString GScalabilityIni; /* Scalability settings ini filename */
FString GHardwareIni; /* Hardware ini filename */
FString GInputIni; /* Input ini filename */
FString GGameIni; /* Game ini filename */
FString GGameUserSettingsIni; /* User Game Settings ini filename */

However, there is no direct hard-coded specification of where the ini files are located. The loading process occurs in FEngineLoop::AppInit (LaunchEngineLoop.cpp) by calling FConfigCacheIni::InitializeConfigSystem, defined in ConfigCacheIni.cpp:

FConfigCacheIni::InitializeConfigSystem

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
// --------------------------------
// # FConfigCacheIni::InitializeConfigSystem declaration
/**
* Creates GConfig, loads the standard global ini files (Engine, Editor, etc),
* fills out GEngineIni, etc. and marks GConfig as ready for use
*/
static void InitializeConfigSystem();
// --------------------------------
void FConfigCacheIni::InitializeConfigSystem()
{
// Perform any upgrade we need before we load any configuration files
FConfigManifest::UpgradeFromPreviousVersions();

// create GConfig
GConfig = new FConfigCacheIni(EConfigCacheType::DiskBacked);

// load the main .ini files (unless we're running a program or a gameless UE4Editor.exe, DefaultEngine.ini is required).
const bool bIsGamelessExe = !FApp::HasProjectName();
const bool bDefaultEngineIniRequired = !bIsGamelessExe && (GIsGameAgnosticExe || FApp::IsProjectNameEmpty());
bool bEngineConfigCreated = FConfigCacheIni::LoadGlobalIniFile(GEngineIni, TEXT("Engine"), nullptr, bDefaultEngineIniRequired);

if ( !bIsGamelessExe )
{
// Now check and see if our game is correct if this is a game agnostic binary
if (GIsGameAgnosticExe && !bEngineConfigCreated)
{
const FText AbsolutePath = FText::FromString( IFileManager::Get().ConvertToAbsolutePathForExternalAppForRead(*FPaths::GetPath(GEngineIni)) );
//@todo this is too early to localize
const FText Message = FText::Format( NSLOCTEXT("Core", "FirstCmdArgMustBeGameName", "'{0}' must exist and contain a DefaultEngine.ini."), AbsolutePath );
if (!GIsBuildMachine)
{
FMessageDialog::Open(EAppMsgType::Ok, Message);
}
FApp::SetProjectName(TEXT("")); // this disables part of the crash reporter to avoid writing log files to a bogus directory
if (!GIsBuildMachine)
{
exit(1);
}
UE_LOG(LogInit, Fatal,TEXT("%s"), *Message.ToString());
}
}

FConfigCacheIni::LoadGlobalIniFile(GGameIni, TEXT("Game"));
FConfigCacheIni::LoadGlobalIniFile(GInputIni, TEXT("Input"));
#if WITH_EDITOR
// load some editor specific .ini files

FConfigCacheIni::LoadGlobalIniFile(GEditorIni, TEXT("Editor"));

// Upgrade editor user settings before loading the editor per project user settings
FConfigManifest::MigrateEditorUserSettings();
FConfigCacheIni::LoadGlobalIniFile(GEditorPerProjectIni, TEXT("EditorPerProjectUserSettings"));

// Project agnostic editor ini files
static const FString EditorSettingsDir = FPaths::Combine(*FPaths::GameAgnosticSavedDir(), TEXT("Config")) + TEXT("/");
FConfigCacheIni::LoadGlobalIniFile(GEditorSettingsIni, TEXT("EditorSettings"), nullptr, false, false, true, *EditorSettingsDir);
FConfigCacheIni::LoadGlobalIniFile(GEditorLayoutIni, TEXT("EditorLayout"), nullptr, false, false, true, *EditorSettingsDir);
FConfigCacheIni::LoadGlobalIniFile(GEditorKeyBindingsIni, TEXT("EditorKeyBindings"), nullptr, false, false, true, *EditorSettingsDir);

#endif
#if PLATFORM_DESKTOP
// load some desktop only .ini files
FConfigCacheIni::LoadGlobalIniFile(GCompatIni, TEXT("Compat"));
FConfigCacheIni::LoadGlobalIniFile(GLightmassIni, TEXT("Lightmass"));
#endif

// Load scalability settings.
FConfigCacheIni::LoadGlobalIniFile(GScalabilityIni, TEXT("Scalability"));
// Load driver blacklist
FConfigCacheIni::LoadGlobalIniFile(GHardwareIni, TEXT("Hardware"));

// Load user game settings .ini, allowing merging. This also updates the user .ini if necessary.
FConfigCacheIni::LoadGlobalIniFile(GGameUserSettingsIni, TEXT("GameUserSettings"));

// now we can make use of GConfig
GConfig->bIsReadyForUse = true;
FCoreDelegates::ConfigReadyForUse.Broadcast();
}

FConfigCacheIni::LoadGlobalIniFile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// --------------------------------
// # FConfigCacheIni::LoadGlobalIniFile declaration
/**
* Loads and generates a destination ini file and adds it to GConfig:
* - Looking on commandline for override source/dest .ini filenames
* - Generating the name for the engine to refer to the ini
* - Loading a source .ini file hierarchy
* - Filling out an FConfigFile
* - Save the generated ini
* - Adds the FConfigFile to GConfig
*
* @param FinalIniFilename The output name of the generated .ini file (in Game\Saved\Config)
* @param BaseIniName The "base" ini name, with no extension (ie, Engine, Game, etc)
* @param Platform The platform to load the .ini for (if NULL, uses current)
* @param bForceReload If true, the destination .in will be regenerated from the source, otherwise this will only process if the dest isn't in GConfig
* @param bRequireDefaultIni If true, the Default*.ini file is required to exist when generating the final ini file.
* @param bAllowGeneratedIniWhenCooked If true, the engine will attempt to load the generated/user INI file when loading cooked games
* @param GeneratedConfigDir The location where generated config files are made.
* @return true if the final ini was created successfully.
*/
static bool LoadGlobalIniFile(FString& FinalIniFilename, const TCHAR* BaseIniName, const TCHAR* Platform=NULL, bool bForceReload=false, bool bRequireDefaultIni=false, bool bAllowGeneratedIniWhenCooked=true, const TCHAR* GeneratedConfigDir = *FPaths::GeneratedConfigDir());
// --------------------------------

bool FConfigCacheIni::LoadGlobalIniFile(FString& FinalIniFilename, const TCHAR* BaseIniName, const TCHAR* Platform, bool bForceReload, bool bRequireDefaultIni, bool bAllowGeneratedIniWhenCooked, const TCHAR* GeneratedConfigDir)
{
// figure out where the end ini file is
FinalIniFilename = GetDestIniFilename(BaseIniName, Platform, GeneratedConfigDir);

// Start the loading process for the remote config file when appropriate
if (FRemoteConfig::Get()->ShouldReadRemoteFile(*FinalIniFilename))
{
FRemoteConfig::Get()->Read(*FinalIniFilename, BaseIniName);
}

FRemoteConfigAsyncIOInfo* RemoteInfo = FRemoteConfig::Get()->FindConfig(*FinalIniFilename);
if (RemoteInfo && (!RemoteInfo->bWasProcessed || !FRemoteConfig::Get()->IsFinished(*FinalIniFilename)))
{
// Defer processing this remote config file to until it has finished its IO operation
return false;
}

// need to check to see if the file already exists in the GConfigManager's cache
// if it does exist then we are done, nothing else to do
if (!bForceReload && GConfig->FindConfigFile(*FinalIniFilename) != nullptr)
{
//UE_LOG(LogConfig, Log, TEXT( "Request to load a config file that was already loaded: %s" ), GeneratedIniFile );
return true;
}

// make a new entry in GConfig (overwriting what's already there)
FConfigFile& NewConfigFile = GConfig->Add(FinalIniFilename, FConfigFile());

return LoadExternalIniFile(NewConfigFile, BaseIniName, *FPaths::EngineConfigDir(), *FPaths::SourceConfigDir(), true, Platform, bForceReload, true, bAllowGeneratedIniWhenCooked, GeneratedConfigDir);
}

FConfigCacheIni::LoadLocalIniFile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// --------------------------------
// # FConfigCacheIni::LoadLocalIniFile declaration
/**
* Load an ini file directly into an FConfigFile, and nothing is written to GConfig or disk.
* The passed in .ini name can be a "base" (Engine, Game) which will be modified by platform and/or commandline override,
* or it can be a full ini filename (ie WrangleContent) loaded from the Source config directory
*
* @param ConfigFile The output object to fill
* @param IniName Either a Base ini name (Engine) or a full ini name (WrangleContent). NO PATH OR EXTENSION SHOULD BE USED!
* @param bIsBaseIniName true if IniName is a Base name, which can be overridden on commandline, etc.
* @param Platform The platform to use for Base ini names, NULL means to use the current platform
* @param bForceReload force reload the ini file from disk this is required if you make changes to the ini file not using the config system as the hierarchy cache will not be updated in this case
* @return true if the ini file was loaded successfully
*/
static bool LoadLocalIniFile(FConfigFile& ConfigFile, const TCHAR* IniName, bool bIsBaseIniName, const TCHAR* Platform=NULL, bool bForceReload=false);
// --------------------------------
bool FConfigCacheIni::LoadLocalIniFile(FConfigFile& ConfigFile, const TCHAR* IniName, bool bIsBaseIniName, const TCHAR* Platform, bool bForceReload )
{
DECLARE_SCOPE_CYCLE_COUNTER( TEXT( "FConfigCacheIni::LoadLocalIniFile" ), STAT_FConfigCacheIni_LoadLocalIniFile, STATGROUP_LoadTime );

FString EngineConfigDir = FPaths::EngineConfigDir();
FString SourceConfigDir = FPaths::SourceConfigDir();

if (bIsBaseIniName)
{
FConfigFile* BaseConfig = GConfig->FindConfigFileWithBaseName(IniName);
// If base ini, try to use an existing GConfig file to set the config directories instead of assuming defaults

if (BaseConfig)
{
FIniFilename* EngineFilename = BaseConfig->SourceIniHierarchy.Find(EConfigFileHierarchy::EngineDirBase);
if (EngineFilename)
{
EngineConfigDir = FPaths::GetPath(EngineFilename->Filename) + TEXT("/");
}

FIniFilename* GameFilename = BaseConfig->SourceIniHierarchy.Find(EConfigFileHierarchy::GameDirDefault);
if (GameFilename)
{
SourceConfigDir = FPaths::GetPath(GameFilename->Filename) + TEXT("/");
}
}

}

return LoadExternalIniFile(ConfigFile, IniName, *EngineConfigDir, *SourceConfigDir, bIsBaseIniName, Platform, bForceReload, false);
}

FConfigCacheIni::LoadExternalIniFile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// --------------------------------
// # FConfigCacheIni::LoadExternalIniFile declaration
/**
* Load an ini file directly into an FConfigFile from the specified config folders, optionally writing to disk.
* The passed in .ini name can be a "base" (Engine, Game) which will be modified by platform and/or commandline override,
* or it can be a full ini filename (ie WrangleContent) loaded from the Source config directory
*
* @param ConfigFile The output object to fill
* @param IniName Either a Base ini name (Engine) or a full ini name (WrangleContent). NO PATH OR EXTENSION SHOULD BE USED!
* @param EngineConfigDir Engine config directory.
* @param SourceConfigDir Game config directory.
* @param bIsBaseIniName true if IniName is a Base name, which can be overridden on commandline, etc.
* @param Platform The platform to use for Base ini names
* @param bForceReload force reload the ini file from disk this is required if you make changes to the ini file not using the config system as the hierarchy cache will not be updated in this case
* @param bWriteDestIni write out a destination ini file to the Saved folder, only valid if bIsBaseIniName is true
* @param bAllowGeneratedIniWhenCooked If true, the engine will attempt to load the generated/user INI file when loading cooked games
* @param GeneratedConfigDir The location where generated config files are made.
* @return true if the ini file was loaded successfully
*/
static bool LoadExternalIniFile(FConfigFile& ConfigFile, const TCHAR* IniName, const TCHAR* EngineConfigDir, const TCHAR* SourceConfigDir, bool bIsBaseIniName, const TCHAR* Platform=NULL, bool bForceReload=false, bool bWriteDestIni=false, bool bAllowGeneratedIniWhenCooked = true, const TCHAR* GeneratedConfigDir = *FPaths::GeneratedConfigDir());
// --------------------------------
bool FConfigCacheIni::LoadExternalIniFile(FConfigFile& ConfigFile, const TCHAR* IniName, const TCHAR* EngineConfigDir, const TCHAR* SourceConfigDir, bool bIsBaseIniName, const TCHAR* Platform, bool bForceReload, bool bWriteDestIni, bool bAllowGeneratedIniWhenCooked, const TCHAR* GeneratedConfigDir)
{
// if bIsBaseIniName is false, that means the .ini is a ready-to-go .ini file, and just needs to be loaded into the FConfigFile
if (!bIsBaseIniName)
{
// generate path to the .ini file (not a Default ini, IniName is the complete name of the file, without path)
FString SourceIniFilename = FString::Printf(TEXT("%s/%s.ini"), SourceConfigDir, IniName);

// load the .ini file straight up
LoadAnIniFile(*SourceIniFilename, ConfigFile);

ConfigFile.Name = IniName;
}
else
{
FString DestIniFilename = GetDestIniFilename(IniName, Platform, GeneratedConfigDir);

GetSourceIniHierarchyFilenames( IniName, Platform, EngineConfigDir, SourceConfigDir, ConfigFile.SourceIniHierarchy, false );

if ( bForceReload )
{
ClearHierarchyCache( IniName );
}

// Keep a record of the original settings
ConfigFile.SourceConfigFile = new FConfigFile();

// now generate and make sure it's up to date (using IniName as a Base for an ini filename)
const bool bAllowGeneratedINIs = true;
bool bNeedsWrite = GenerateDestIniFile(ConfigFile, DestIniFilename, ConfigFile.SourceIniHierarchy, bAllowGeneratedIniWhenCooked, true);

ConfigFile.Name = IniName;

// don't write anything to disk in cooked builds - we will always use re-generated INI files anyway.
if (bWriteDestIni && (!FPlatformProperties::RequiresCookedData() || bAllowGeneratedIniWhenCooked)
// We shouldn't save config files when in multiprocess mode,
// otherwise we get file contention in XGE shader builds.
&& !FParse::Param(FCommandLine::Get(), TEXT("Multiprocess")))
{
// Check the config system for any changes made to defaults and propagate through to the saved.
ConfigFile.ProcessSourceAndCheckAgainstBackup();

if (bNeedsWrite)
{
// if it was dirtied during the above function, save it out now
ConfigFile.Write(DestIniFilename);
}
}
}

// GenerateDestIniFile returns true if nothing is loaded, so check if we actually loaded something
return ConfigFile.Num() > 0;
}

The most important part of this function is the call to GetSourceIniHierarchyFilenames, which collects all ini files of the current baseName under the Engine and project.

This is why we see many ini files in GConfig that are in Saved/Config and contain empty content, yet how do we retrieve the settings from the project? This is because UE merges multiple ini files:

The content of these ini files will all be loaded.

This part is implemented in FConfigFile::AddStaticLayersToHierarchy, which traverses all ini files in GConfigLayers with rules:

ConfigCacheIni.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
struct FConfigLayer
{
// Used by the editor to display in the ini-editor
const TCHAR* EditorName;
// Path to the ini file (with variables)
const TCHAR* Path;
// Special flag
EConfigLayerFlags Flag;

} GConfigLayers[] =
{
/**************************************************
**** CRITICAL NOTES
**** If you change this array, you need to also change EnumerateConfigFileLocations() in ConfigHierarchy.cs!!!
**** And maybe UObject::GetDefaultConfigFilename(), UObject::GetGlobalUserConfigFilename()
**************************************************/

// Engine/Base.ini
{ TEXT("AbsoluteBase"), TEXT("{ENGINE}/Config/Base.ini"), EConfigLayerFlags::Required | EConfigLayerFlags::NoExpand},

// Engine/Base*.ini
{ TEXT("Base"), TEXT("{ENGINE}/Config/Base{TYPE}.ini") },
// Engine/Platform/BasePlatform*.ini
{ TEXT("BasePlatform"), TEXT("{ENGINE}/Config/{PLATFORM}/Base{PLATFORM}{TYPE}.ini") },
// Project/Default*.ini
{ TEXT("ProjectDefault"), TEXT("{PROJECT}/Config/Default{TYPE}.ini"), EConfigLayerFlags::AllowCommandLineOverride | EConfigLayerFlags::GenerateCacheKey },
// Engine/Platform/Platform*.ini
{ TEXT("EnginePlatform"), TEXT("{ENGINE}/Config/{PLATFORM}/{PLATFORM}{TYPE}.ini") },
// Project/Platform/Platform*.ini
{ TEXT("ProjectPlatform"), TEXT("{PROJECT}/Config/{PLATFORM}/{PLATFORM}{TYPE}.ini") },

// UserSettings/.../User*.ini
{ TEXT("UserSettingsDir"), TEXT("{USERSETTINGS}Unreal Engine/Engine/Config/User{TYPE}.ini"), EConfigLayerFlags::NoExpand },
// UserDir/.../User*.ini
{ TEXT("UserDir"), TEXT("{USER}Unreal Engine/Engine/Config/User{TYPE}.ini"), EConfigLayerFlags::NoExpand },
// Project/User*.ini
{ TEXT("GameDirUser"), TEXT("{PROJECT}/Config/User{TYPE}.ini"), EConfigLayerFlags::GenerateCacheKey | EConfigLayerFlags::NoExpand },
};

During usage, replacements will be performed:

ConfigCacheIni.cpp
1
2
3
4
5
6
7
8
static FString PerformBasicReplacements(const FString& InString, const TCHAR* BaseIniName)
{
FString OutString = InString.Replace(TEXT("{TYPE}"), BaseIniName, ESearchCase::CaseSensitive);
OutString = OutString.Replace(TEXT("{USERSETTINGS}"), FPlatformProcess::UserSettingsDir(), ESearchCase::CaseSensitive);
OutString = OutString.Replace(TEXT("{USER}"), FPlatformProcess::UserDir(), ESearchCase::CaseSensitive);

return OutString;
}

For example, if I want to load the ini for Windows platform Game, it will actually include:

1
2
3
4
5
6
7
8
9
../../../Engine/Config/Base.ini
../../../Engine/Config/BaseGame.ini
../../../Engine/Config/Windows/BaseWindowsGame.ini
../../../ProjectName/Config/DefaultGame.ini
../../../Engine/Config/Windows/WindowsGame.ini
../../../ProjectName/Config/Windows/WindowsGame.ini
C:/Users/lipengzha/AppData/Local/Unreal Engine/Engine/Config/UserGame.ini
C:/Users/lipengzha/Documents/Unreal Engine/Engine/Config/UserGame.ini
../../../ProjectName/Config/UserGame.ini

Note: The last element of GConfigLayers has the EConfigLayerFlags::GenerateCacheKey FLAG, when processing, it concatenates all ini paths as a Key:

FConfigFile::AddStaticLayersToHierarchy
1
2
3
4
5
6
// add this to the list!
SourceIniHierarchy.AddStaticLayer(
FIniFilename(PlatformPath, bIsRequired, bGenerateCacheKey ?
GenerateHierarchyCacheKey(SourceIniHierarchy, PlatformPath, InBaseIniName) :
FString(TEXT(""))),
LayerIndex, ExpansionIndex, PlatformIndex);

Finally, the list of paths for all loadable ini files will be executed in the GenerateDestIniFile function during the actual loading behavior (for the Engine example):

The loading stack of ini files:

Reading will occur in the order of the ini path list, indicating that the priorities of ini files are also according to the loading order.

GetDestIniFilename

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// Runtime/Source/Core/Private/Misc/ConfigCacheIni.cpp

/**
* Calculates the name of a dest (generated) .ini file for a given base (ie Engine, Game, etc)
*
* @param IniBaseName Base name of the .ini (Engine, Game)
* @param PlatformName Name of the platform to get the .ini path for (nullptr means to use the current platform)
* @param GeneratedConfigDir The base folder that will contain the generated config files.
*
* @return Standardized .ini filename
*/
static FString GetDestIniFilename(const TCHAR* BaseIniName, const TCHAR* PlatformName, const TCHAR* GeneratedConfigDir)
{
// figure out what to look for on the commandline for an override
FString CommandLineSwitch = FString::Printf(TEXT("%sINI="), BaseIniName);

// if it's not found on the commandline, then generate it
FString IniFilename;
if (FParse::Value(FCommandLine::Get(), *CommandLineSwitch, IniFilename) == false)
{
FString Name(PlatformName ? PlatformName : ANSI_TO_TCHAR(FPlatformProperties::PlatformName()));

FString BaseIniNameString = BaseIniName;
if (BaseIniNameString.Contains(GeneratedConfigDir))
{
IniFilename = BaseIniNameString;
}
else
{
// put it all together
IniFilename = FString::Printf(TEXT("%s%s/%s.ini"), GeneratedConfigDir, *Name, BaseIniName);
}
}

// standardize it!
FPaths::MakeStandardFilename(IniFilename);
return IniFilename;
}

And FPaths::GeneratedConfigDir retrieves the path under the current project’s Saved Config:

1
2
3
4
5
6
7
8
9
// Paths.cpp
FString FPaths::GeneratedConfigDir()
{
#if PLATFORM_MAC
return FPlatformProcess::UserPreferencesDir();
#else
return FPaths::ProjectSavedDir() + TEXT("Config/");
#endif
}

That is, the global G*Ini files read when the project starts on the Windows platform are all under Saved/Config/Windows .ini.
Moreover, in the implementation of GetDestIniFilename, it can also be seen that the configuration of G*Ini can be passed in through the CommandLine, allowing the default .ini under Saved/Config/Platform to be overridden:

1
2
// Using the specified Engine.ini
UE4Editor.exe uprojectPath -EngineINI="D:\\CustomEngine.ini"


Further Reading

The article is finished. If you have any questions, please comment and communicate.

Scan the QR code on WeChat and follow me.

Title:UE代码分析:GConfig的加载
Author:LIPENGZHA
Publish Date:2019/05/27 22:57
Update Date:2020/05/28 18:02
Word Count:2.1k Words
Link:https://en.imzlp.com/posts/2386/
License: CC BY-NC-SA 4.0
Reprinting of the full article is prohibited.
Your donation will encourage me to keep creating!