Skip to content

Instantly share code, notes, and snippets.

@Solessfir
Last active November 30, 2025 14:47
Show Gist options
  • Select an option

  • Save Solessfir/e94214e648c69bf5167497e108ab9580 to your computer and use it in GitHub Desktop.

Select an option

Save Solessfir/e94214e648c69bf5167497e108ab9580 to your computer and use it in GitHub Desktop.
C++23 std::print-style Single Header Logging library for Unreal Engine 4 - 5.1
/**
* Copyright (c) Solessfir under MIT license
*
* #define EASY_LOG_CATEGORY LogMyGame // Optional (must be defined before include)
* #include "EasyLog.h"
*
* Usage Examples:
*
* // Basic
* LOG_DISPLAY("Value is {}", MyInt);
*
* // Extended (Key, Duration)
* LOG_DISPLAY_EX(-1, 5.f, "Value is {}", MyInt);
*
* // Positional args
* LOG_DISPLAY("Value is {1}, expected {0}", Foo, Bar); // Value is Bar, expected Foo
*
* // Formatting Specifiers
* LOG_DISPLAY("Int: {:03}", 7); // Output: Int: 007
* LOG_DISPLAY("Float: {:.2}", 3.14159); // Output: Float: 3.14
*
* // Hex and Binary
* int32 Val = 255;
* LOG_DISPLAY("Hex: {:#x}", Val); // Output: Hex: 0xff
* LOG_DISPLAY("Bin: {:#b}", Val); // Output: Bin: 0b11111111
*
* // Pointer Address
* LOG_DISPLAY("Ptr: {:#x}", this); // Output: Ptr: 0x00...
*
* // Container Support (TArray, TSet)
* TArray<float> Values = {1.11f, 2.22f};
* LOG_DISPLAY("Values: {:.1}", Values); // Output: Values: [1.1, 2.2]
*
* // UEnum and FGameplayTag Support
* LOG_DISPLAY("State: {}", EMyEnum::Walking);
* LOG_DISPLAY("Tag: {}", MyTag);
*
* // Conditional Logging
* CLOG_ERROR(Health <= 0, "Actor {} died!", this);
*
* For UE4 - add these lines to your .Target.cs:
* bOverrideBuildEnvironment = true;
* CppStandard = CppStandardVersion.Cpp17;
*/
#pragma once
#include "CoreMinimal.h"
#include "Engine/Engine.h"
#include <type_traits>
#include <cstdarg>
#ifndef EASY_LOG_CATEGORY
#define EASY_LOG_CATEGORY EasyLog
#endif
// Helper to expand macro before definition
#define EASY_LOG_DEFINE_CATEGORY_INTERNAL(Category) DEFINE_LOG_CATEGORY_STATIC(Category, Log, All)
EASY_LOG_DEFINE_CATEGORY_INTERNAL(EASY_LOG_CATEGORY);
#undef EASY_LOG_DEFINE_CATEGORY_INTERNAL
// Helper to expand macro for Usage
#define EASY_UE_LOG_EXPAND(Category, Verbosity, Format, ...) UE_LOG(Category, Verbosity, Format, ##__VA_ARGS__)
#if defined(_MSC_VER)
#define EASY_FUNC_SIG __FUNCTION__
#else
#define EASY_FUNC_SIG __PRETTY_FUNCTION__
#endif
#if !(UE_BUILD_SHIPPING || UE_BUILD_TEST)
#define GET_LOG_LOC Easy::FormatLocation(EASY_FUNC_SIG, __LINE__)
#define LOG_KEY_HASH static_cast<int32>((FCrc::MemCrc32(__FUNCTION__, sizeof(__FUNCTION__) - 1) ^ (static_cast<uint32>(__LINE__) << 15)) & 0x7FFFFFFF)
#define LOG_DISPLAY(Fmt, ...) Easy::Dispatch<ELogVerbosity::Display>(LOG_KEY_HASH, 10.f, GET_LOG_LOC, Fmt, ##__VA_ARGS__)
#define LOG_WARNING(Fmt, ...) Easy::Dispatch<ELogVerbosity::Warning>(LOG_KEY_HASH, 10.f, GET_LOG_LOC, Fmt, ##__VA_ARGS__)
#define LOG_ERROR(Fmt, ...) Easy::Dispatch<ELogVerbosity::Error>(LOG_KEY_HASH, 10.f, GET_LOG_LOC, Fmt, ##__VA_ARGS__)
#define CLOG_DISPLAY(Cond, Fmt, ...) if (Cond) LOG_DISPLAY(Fmt, ##__VA_ARGS__)
#define CLOG_WARNING(Cond, Fmt, ...) if (Cond) LOG_WARNING(Fmt, ##__VA_ARGS__)
#define CLOG_ERROR(Cond, Fmt, ...) if (Cond) LOG_ERROR(Fmt, ##__VA_ARGS__)
#define LOG_DISPLAY_EX(Key, Duration, Format, ...) Easy::Dispatch<ELogVerbosity::Display>(Key, Duration, GET_LOG_LOC, Format, ##__VA_ARGS__)
#define LOG_WARNING_EX(Key, Duration, Format, ...) Easy::Dispatch<ELogVerbosity::Warning>(Key, Duration, GET_LOG_LOC, Format, ##__VA_ARGS__)
#define LOG_ERROR_EX(Key, Duration, Format, ...) Easy::Dispatch<ELogVerbosity::Error>(Key, Duration, GET_LOG_LOC, Format, ##__VA_ARGS__)
#else
#define LOG_DISPLAY(...)
#define LOG_WARNING(...)
#define LOG_ERROR(...)
#define CLOG_DISPLAY(...)
#define CLOG_WARNING(...)
#define CLOG_ERROR(...)
#define LOG_DISPLAY_EX(...)
#define LOG_WARNING_EX(...)
#define LOG_ERROR_EX(...)
#endif
namespace Easy
{
template <typename T> using Decayed = std::decay_t<T>;
// SFINAE Helpers
template <typename T, typename = void> struct THasToString : std::false_type {};
template <typename T> struct THasToString<T, std::void_t<decltype(std::declval<T>().ToString())>> : std::true_type {};
template <typename T, typename = void> struct TIsContainer : std::false_type {};
template <typename T> struct TIsContainer<T, std::void_t<decltype(std::declval<T>().begin()), decltype(std::declval<T>().end())>> : std::true_type {};
// Location Helper
inline FString FormatLocation(const char* InFunc, const int32 Line)
{
FString Result(InFunc);
if (Result.Contains(TEXT("("))) Result = Result.Left(Result.Find(TEXT("(")));
int32 SpaceIdx = -1;
if (Result.FindLastChar(TEXT(' '), SpaceIdx)) Result = Result.RightChop(SpaceIdx + 1);
return FString::Printf(TEXT("%s:%d"), *Result, Line);
}
struct FParsedFormat
{
FString FixedString;
TArray<FString> Specifiers;
};
struct FormatStringHelper
{
static FParsedFormat Parse(const FString& InFormat)
{
if (!InFormat.Contains(TEXT("{")))
{
return { InFormat, {} };
}
FParsedFormat Result;
Result.FixedString.Reserve(InFormat.Len() + 16);
int32 ArgIndex = 0;
const int32 Len = InFormat.Len();
for (int32 i = 0; i < Len; ++i)
{
const TCHAR Char = InFormat[i];
if (Char == '{')
{
if (i + 1 < Len && InFormat[i + 1] == '{')
{
Result.FixedString.AppendChar('{'); Result.FixedString.AppendChar('{'); i++;
continue;
}
int32 CloseIdx = -1;
for (int32 j = i + 1; j < Len; ++j)
{
if (InFormat[j] == '}')
{
CloseIdx = j;
break;
}
}
if (CloseIdx != -1)
{
FString Content = InFormat.Mid(i + 1, CloseIdx - (i + 1));
FString Specifier;
int32 ColonIdx;
// Extract specifier (e.g. "02" from "{:02}" or "{0:02}")
if (Content.FindChar(TEXT(':'), ColonIdx))
{
Specifier = Content.RightChop(ColonIdx + 1);
Specifier.TrimStartAndEndInline();
Content = Content.Left(ColonIdx);
}
if (Content.IsEmpty())
{
// Case: {} or {:spec} -> Auto-Increment Index
Result.FixedString.AppendChar('{'); Result.FixedString.AppendInt(ArgIndex); Result.FixedString.AppendChar('}');
if (Result.Specifiers.Num() <= ArgIndex) Result.Specifiers.SetNum(ArgIndex + 1);
Result.Specifiers[ArgIndex] = Specifier;
ArgIndex++;
}
else if (Content.IsNumeric())
{
// Case: {0} or {0:spec} -> Explicit Index
Result.FixedString.AppendChar('{'); Result.FixedString += Content; Result.FixedString.AppendChar('}');
const int32 ExplicitIdx = FCString::Atoi(*Content);
if (Result.Specifiers.Num() <= ExplicitIdx) Result.Specifiers.SetNum(ExplicitIdx + 1);
Result.Specifiers[ExplicitIdx] = Specifier;
}
else
{
// Unsupported Case: {Name} -> Treat as literal text
// Append the entire original block including braces
Result.FixedString.AppendChars(&InFormat[i], CloseIdx - i + 1);
}
i = CloseIdx;
continue;
}
}
Result.FixedString.AppendChar(Char);
}
return Result;
}
};
struct SafeFormatter
{
static FString RunFormat(const TCHAR* Fmt, ...)
{
TCHAR Buffer[128];
va_list Ap;
va_start(Ap, Fmt);
FCString::GetVarArgs(Buffer, 128, Fmt, Ap);
va_end(Ap);
return FString(Buffer);
}
static FString ToBinary(const int64 Value, const bool bUsePrefix)
{
FString Result;
uint64 UVal = static_cast<uint64>(Value);
if (UVal == 0)
{
Result = TEXT("0");
}
else
{
while (UVal > 0)
{
Result = (UVal & 1 ? TEXT("1") : TEXT("0")) + Result;
UVal >>= 1;
}
}
return bUsePrefix ? TEXT("0b") + Result : Result;
}
static FString FormatInt(const int64 Value, const FString& Spec)
{
if (Spec.EndsWith(TEXT("b"), ESearchCase::IgnoreCase))
{
return ToBinary(Value, Spec.Contains(TEXT("#")));
}
if (Spec.EndsWith(TEXT("x"), ESearchCase::IgnoreCase))
{
FString HexSpec = Spec;
if (HexSpec.Contains(TEXT("x"))) HexSpec.ReplaceInline(TEXT("x"), TEXT("llx"));
else if (HexSpec.Contains(TEXT("X"))) HexSpec.ReplaceInline(TEXT("X"), TEXT("llX"));
const FString Fmt = TEXT("%") + HexSpec;
return RunFormat(*Fmt, Value);
}
const FString Fmt = TEXT("%") + Spec + TEXT("lld");
return RunFormat(*Fmt, Value);
}
static FString FormatFloat(const double Value, const FString& Spec)
{
const FString Fmt = TEXT("%") + Spec + TEXT("f");
return RunFormat(*Fmt, Value);
}
};
// Native FStringArg
template <typename T, typename std::enable_if_t<std::is_constructible<FStringFormatArg, T>::value && !TIsContainer<Decayed<T>>::value, int> = 0>
void Append(FStringFormatOrderedArguments& Args, const T& Val, const FString& Spec)
{
if constexpr (std::is_pointer<Decayed<T>>::value)
{
// Check for explicit format (x, p, etc) - This catches 'this' pointers too!
if (!Spec.IsEmpty()) { Args.Emplace(SafeFormatter::FormatInt(reinterpret_cast<int64>(Val), Spec)); return; }
// TCHAR* safety
if constexpr (std::is_same<typename std::remove_pointer<Decayed<T>>::type, TCHAR>::value)
{
if (Val == nullptr)
{
Args.Emplace(TEXT("(NullString)"));
return;
}
}
}
if constexpr (std::is_floating_point<Decayed<T>>::value)
{
if (!Spec.IsEmpty()) Args.Emplace(SafeFormatter::FormatFloat(static_cast<double>(Val), Spec));
else Args.Emplace(FString::SanitizeFloat(Val));
}
else if constexpr (std::is_integral<Decayed<T>>::value)
{
if (!Spec.IsEmpty()) Args.Emplace(SafeFormatter::FormatInt(static_cast<int64>(Val), Spec));
else Args.Emplace(Val);
}
else
{
Args.Emplace(Val);
}
}
// Literal nullptr
inline void Append(FStringFormatOrderedArguments& Args, std::nullptr_t, const FString&)
{
Args.Emplace(TEXT("nullptr"));
}
// ToString()
template <typename T, typename std::enable_if_t<THasToString<Decayed<T>>::value && !std::is_constructible<FStringFormatArg, T>::value, int> = 0>
void Append(FStringFormatOrderedArguments& Args, const T& Val, const FString&)
{
Args.Emplace(Val.ToString());
}
// UObject/Actor
template <typename T, typename std::enable_if_t<std::is_pointer<T>::value && std::is_base_of<UObject, std::remove_pointer_t<T>>::value, int> = 0>
void Append(FStringFormatOrderedArguments& Args, T Val, const FString& Spec)
{
// If formatting is present (e.g. {:#x}), treat as raw pointer address
if (!Spec.IsEmpty())
{
Args.Emplace(SafeFormatter::FormatInt(reinterpret_cast<int64>(Val), Spec));
return;
}
if (IsInGameThread() && IsValid(Val)) Args.Emplace(Val->GetName());
else Args.Emplace(IsValid(Val) ? TEXT("[AsyncUObject]") : TEXT("None"));
}
// Enums
template <typename T, typename std::enable_if_t<std::is_enum<Decayed<T>>::value, int> = 0>
void Append(FStringFormatOrderedArguments& Args, T Val, const FString& Spec)
{
// If formatting is present, treat as int
if (!Spec.IsEmpty())
{
Args.Emplace(SafeFormatter::FormatInt(static_cast<int64>(Val), Spec));
return;
}
const UEnum* EnumPtr = StaticEnum<Decayed<T>>();
if (EnumPtr) Args.Emplace(EnumPtr->GetNameStringByValue(static_cast<int64>(Val)));
else Args.Emplace(static_cast<int64>(Val));
}
// Containers
template <typename T, typename std::enable_if_t<TIsContainer<Decayed<T>>::value && !std::is_same<Decayed<T>, FString>::value, int> = 0>
void Append(FStringFormatOrderedArguments& Args, const T& Val, const FString& Spec)
{
FString Result = TEXT("[");
int32 Count = 0;
constexpr int32 MaxElements = 15;
for (const auto& Elem : Val)
{
if (Count > 0) Result += TEXT(", ");
if (Count >= MaxElements) { Result += TEXT("..."); break; }
if constexpr (std::is_floating_point<Decayed<decltype(Elem)>>::value)
{
if (!Spec.IsEmpty()) Result += SafeFormatter::FormatFloat(static_cast<double>(Elem), Spec);
else Result += FString::SanitizeFloat(Elem);
}
else if constexpr (std::is_integral<Decayed<decltype(Elem)>>::value)
{
if (!Spec.IsEmpty()) Result += SafeFormatter::FormatInt(static_cast<int64>(Elem), Spec);
else Result += LexToString(Elem);
}
else
{
Result += LexToString(Elem);
}
Count++;
}
Result += TEXT("]");
Args.Emplace(Result);
}
// Fallback
inline void Append(FStringFormatOrderedArguments& Args, ...) { Args.Emplace(TEXT("[?]")); }
inline void Fill(FStringFormatOrderedArguments&, const TArray<FString>&, int32) {}
template<typename FirstArgType, typename... RestArgsType>
void Fill(FStringFormatOrderedArguments& Args, const TArray<FString>& Specs, const int32 Index, FirstArgType&& First, RestArgsType&&... Rest)
{
FString CurrentSpec = Specs.IsValidIndex(Index) ? Specs[Index] : FString();
Append(Args, Forward<FirstArgType>(First), CurrentSpec);
Fill(Args, Specs, Index + 1, Forward<RestArgsType>(Rest)...);
}
template <ELogVerbosity::Type Verbosity, typename... ArgsType>
void Dispatch(const int32 Key, const float Duration, const FString& Location, const FString& Format, ArgsType&&... Arguments)
{
const FParsedFormat Parsed = FormatStringHelper::Parse(Format);
FStringFormatOrderedArguments OrderedArgs;
Fill(OrderedArgs, Parsed.Specifiers, 0, Forward<ArgsType>(Arguments)...);
const FString UserMessage = FString::Format(*Parsed.FixedString, MoveTemp(OrderedArgs));
const FString FullMessage = FString::Printf(TEXT("%s | %s"), *UserMessage, *Location);
switch (Verbosity)
{
case ELogVerbosity::Fatal: EASY_UE_LOG_EXPAND(EASY_LOG_CATEGORY, Fatal, TEXT("%s"), *FullMessage); break;
case ELogVerbosity::Error: EASY_UE_LOG_EXPAND(EASY_LOG_CATEGORY, Error, TEXT("%s"), *FullMessage); break;
case ELogVerbosity::Warning: EASY_UE_LOG_EXPAND(EASY_LOG_CATEGORY, Warning, TEXT("%s"), *FullMessage); break;
case ELogVerbosity::Display: EASY_UE_LOG_EXPAND(EASY_LOG_CATEGORY, Display, TEXT("%s"), *FullMessage); break;
case ELogVerbosity::Verbose: EASY_UE_LOG_EXPAND(EASY_LOG_CATEGORY, Verbose, TEXT("%s"), *FullMessage); break;
default: EASY_UE_LOG_EXPAND(EASY_LOG_CATEGORY, Log, TEXT("%s"), *FullMessage); break;
}
if (GEngine && Duration > 0.f)
{
const FColor Color = Verbosity == ELogVerbosity::Error ? FColor::Red : Verbosity == ELogVerbosity::Warning ? FColor::Orange : FColor::White;
GEngine->AddOnScreenDebugMessage(Key, Duration, Color, UserMessage);
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment