From e0f234c66767cafc3284911c6dbc47886dc7a338 Mon Sep 17 00:00:00 2001 From: Wojtek Figat Date: Wed, 3 Jun 2026 13:00:43 +0200 Subject: [PATCH] Add enum serialization as string via `EnumString` attribute --- .../Attributes/EnumStringAttribute.cs | 15 +++++ Source/Engine/Scripting/BinaryModule.cpp | 63 ++++++++----------- Source/Engine/Scripting/ScriptingType.h | 6 +- .../ExtendedDefaultContractResolver.cs | 18 ++++-- Source/Engine/Serialization/Serialization.cpp | 56 ++++++++++++++++- Source/Engine/Serialization/Serialization.h | 8 ++- .../Bindings/BindingsGenerator.Cpp.cs | 3 +- 7 files changed, 120 insertions(+), 49 deletions(-) create mode 100644 Source/Engine/Scripting/Attributes/EnumStringAttribute.cs diff --git a/Source/Engine/Scripting/Attributes/EnumStringAttribute.cs b/Source/Engine/Scripting/Attributes/EnumStringAttribute.cs new file mode 100644 index 000000000..aa9222c8b --- /dev/null +++ b/Source/Engine/Scripting/Attributes/EnumStringAttribute.cs @@ -0,0 +1,15 @@ +// Copyright (c) Wojciech Figat. All rights reserved. + +using System; + +namespace FlaxEngine +{ + /// + /// Changes enum serialization to use string names instead of integer values. This makes saved data resilient to enum reordering or changes in values (but not to renaming enums). Deserialization accepts both string names and integer values for backward compatibility. + /// + /// + [AttributeUsage(AttributeTargets.Enum)] + public sealed class EnumStringAttribute : Attribute + { + } +} diff --git a/Source/Engine/Scripting/BinaryModule.cpp b/Source/Engine/Scripting/BinaryModule.cpp index 02358fac0..029a245c3 100644 --- a/Source/Engine/Scripting/BinaryModule.cpp +++ b/Source/Engine/Scripting/BinaryModule.cpp @@ -204,7 +204,7 @@ ScriptingType::ScriptingType(const StringAnsiView& fullname, BinaryModule* modul Struct.SetField = setField; } -ScriptingType::ScriptingType(const StringAnsiView& fullname, BinaryModule* module, int32 size, EnumItem* items) +ScriptingType::ScriptingType(const StringAnsiView& fullname, BinaryModule* module, int32 size, EnumItem* items, bool stringSerialization) : ManagedClass(nullptr) , Module(module) , InitRuntime(DefaultInitRuntime) @@ -215,6 +215,7 @@ ScriptingType::ScriptingType(const StringAnsiView& fullname, BinaryModule* modul , Size(size) { Enum.Items = items; + Enum.StringSerialization = stringSerialization; } ScriptingType::ScriptingType(const StringAnsiView& fullname, BinaryModule* module, InitRuntimeHandler initRuntime, SetupScriptVTableHandler setupScriptVTable, SetupScriptObjectVTableHandler setupScriptObjectVTable, GetInterfaceWrapper getInterfaceWrapper) @@ -270,6 +271,7 @@ ScriptingType::ScriptingType(const ScriptingType& other) break; case ScriptingTypes::Enum: Enum.Items = other.Enum.Items; + Enum.StringSerialization = other.Enum.StringSerialization; break; case ScriptingTypes::Interface: Interface.SetupScriptVTable = other.Interface.SetupScriptVTable; @@ -323,6 +325,7 @@ ScriptingType::ScriptingType(ScriptingType&& other) break; case ScriptingTypes::Enum: Enum.Items = other.Enum.Items; + Enum.StringSerialization = other.Enum.StringSerialization; break; case ScriptingTypes::Interface: Interface.SetupScriptVTable = other.Interface.SetupScriptVTable; @@ -604,71 +607,57 @@ StringAnsiView ScriptingType::GetName() const return Fullname; } +#if BUILD_DEBUG || USE_EDITOR +#define INIT_TYPE(...) \ + module->Types.AddUninitialized(); \ + new(module->Types.Get() + TypeIndex)ScriptingType(fullname, module, ##__VA_ARGS__); \ + if (module->TypeNameToTypeIndex.ContainsKey(fullname)) \ + LOG(Error, "Duplicated native typename {0} from module {1}.", String(fullname), String(module->GetName())); \ + module->TypeNameToTypeIndex[fullname] = TypeIndex; +#else +#define INIT_TYPE(...) \ + module->Types.AddUninitialized(); \ + new(module->Types.Get() + TypeIndex)ScriptingType(fullname, module, ##__VA_ARGS__); \ + module->TypeNameToTypeIndex[fullname] = TypeIndex; +#endif + ScriptingTypeInitializer::ScriptingTypeInitializer(BinaryModule* module, const StringAnsiView& fullname, int32 size, ScriptingType::InitRuntimeHandler initRuntime, ScriptingType::SpawnHandler spawn, ScriptingTypeInitializer* baseType, ScriptingType::SetupScriptVTableHandler setupScriptVTable, ScriptingType::SetupScriptObjectVTableHandler setupScriptObjectVTable, const ScriptingType::InterfaceImplementation* interfaces) : ScriptingTypeHandle(module, module->Types.Count()) { // Script - module->Types.AddUninitialized(); - new(module->Types.Get() + TypeIndex)ScriptingType(fullname, module, size, initRuntime, spawn, baseType, setupScriptVTable, setupScriptObjectVTable, interfaces); -#if BUILD_DEBUG - if (module->TypeNameToTypeIndex.ContainsKey(fullname)) - LOG(Error, "Duplicated native typename {0} from module {1}.", String(fullname), String(module->GetName())); -#endif - module->TypeNameToTypeIndex[fullname] = TypeIndex; + INIT_TYPE(size, initRuntime, spawn, baseType, setupScriptVTable, setupScriptObjectVTable, interfaces); } ScriptingTypeInitializer::ScriptingTypeInitializer(BinaryModule* module, const StringAnsiView& fullname, int32 size, ScriptingType::InitRuntimeHandler initRuntime, ScriptingType::Ctor ctor, ScriptingType::Dtor dtor, ScriptingTypeInitializer* baseType, const ScriptingType::InterfaceImplementation* interfaces) : ScriptingTypeHandle(module, module->Types.Count()) { // Class - module->Types.AddUninitialized(); - new(module->Types.Get() + TypeIndex)ScriptingType(fullname, module, size, initRuntime, ctor, dtor, baseType, interfaces); -#if BUILD_DEBUG - if (module->TypeNameToTypeIndex.ContainsKey(fullname)) - LOG(Error, "Duplicated native typename {0} from module {1}.", String(fullname), String(module->GetName())); -#endif - module->TypeNameToTypeIndex[fullname] = TypeIndex; + INIT_TYPE(size, initRuntime, ctor, dtor, baseType, interfaces); } ScriptingTypeInitializer::ScriptingTypeInitializer(BinaryModule* module, const StringAnsiView& fullname, int32 size, ScriptingType::InitRuntimeHandler initRuntime, ScriptingType::Ctor ctor, ScriptingType::Dtor dtor, ScriptingType::Copy copy, ScriptingType::Box box, ScriptingType::Unbox unbox, ScriptingType::GetField getField, ScriptingType::SetField setField, ScriptingTypeInitializer* baseType, const ScriptingType::InterfaceImplementation* interfaces) : ScriptingTypeHandle(module, module->Types.Count()) { // Structure - module->Types.AddUninitialized(); - new(module->Types.Get() + TypeIndex)ScriptingType(fullname, module, size, initRuntime, ctor, dtor, copy, box, unbox, getField, setField, baseType, interfaces); -#if BUILD_DEBUG - if (module->TypeNameToTypeIndex.ContainsKey(fullname)) - LOG(Error, "Duplicated native typename {0} from module {1}.", String(fullname), String(module->GetName())); -#endif - module->TypeNameToTypeIndex[fullname] = TypeIndex; + INIT_TYPE(size, initRuntime, ctor, dtor, copy, box, unbox, getField, setField, baseType, interfaces); } -ScriptingTypeInitializer::ScriptingTypeInitializer(BinaryModule* module, const StringAnsiView& fullname, int32 size, ScriptingType::EnumItem* items) +ScriptingTypeInitializer::ScriptingTypeInitializer(BinaryModule* module, const StringAnsiView& fullname, int32 size, ScriptingType::EnumItem* items, bool stringSerialization) : ScriptingTypeHandle(module, module->Types.Count()) { // Enum - module->Types.AddUninitialized(); - new(module->Types.Get() + TypeIndex)ScriptingType(fullname, module, size, items); -#if BUILD_DEBUG - if (module->TypeNameToTypeIndex.ContainsKey(fullname)) - LOG(Error, "Duplicated native typename {0} from module {1}.", String(fullname), String(module->GetName())); -#endif - module->TypeNameToTypeIndex[fullname] = TypeIndex; + INIT_TYPE(size, items, stringSerialization); } ScriptingTypeInitializer::ScriptingTypeInitializer(BinaryModule* module, const StringAnsiView& fullname, ScriptingType::InitRuntimeHandler initRuntime, ScriptingType::SetupScriptVTableHandler setupScriptVTable, ScriptingType::SetupScriptObjectVTableHandler setupScriptObjectVTable, ScriptingType::GetInterfaceWrapper getInterfaceWrapper) : ScriptingTypeHandle(module, module->Types.Count()) { // Interface - module->Types.AddUninitialized(); - new(module->Types.Get() + TypeIndex)ScriptingType(fullname, module, initRuntime, setupScriptVTable, setupScriptObjectVTable, getInterfaceWrapper); -#if BUILD_DEBUG - if (module->TypeNameToTypeIndex.ContainsKey(fullname)) - LOG(Error, "Duplicated native typename {0} from module {1}.", String(fullname), String(module->GetName())); -#endif - module->TypeNameToTypeIndex[fullname] = TypeIndex; + INIT_TYPE(initRuntime, setupScriptVTable, setupScriptObjectVTable, getInterfaceWrapper); } +#undef INIT_TYPE + CriticalSection BinaryModule::Locker; BinaryModule::BinaryModulesList& BinaryModule::GetModules() diff --git a/Source/Engine/Scripting/ScriptingType.h b/Source/Engine/Scripting/ScriptingType.h index e1fb3dc04..8a15dd336 100644 --- a/Source/Engine/Scripting/ScriptingType.h +++ b/Source/Engine/Scripting/ScriptingType.h @@ -266,6 +266,8 @@ struct FLAXENGINE_API ScriptingType { // Enum items table (the last item name is null) EnumItem* Items; + // Enum uses string names serialization instead of integer values. + bool StringSerialization; } Enum; struct @@ -290,7 +292,7 @@ struct FLAXENGINE_API ScriptingType ScriptingType(const StringAnsiView& fullname, BinaryModule* module, int32 size, InitRuntimeHandler initRuntime = DefaultInitRuntime, SpawnHandler spawn = DefaultSpawn, ScriptingTypeInitializer* baseType = nullptr, SetupScriptVTableHandler setupScriptVTable = nullptr, SetupScriptObjectVTableHandler setupScriptObjectVTable = nullptr, const InterfaceImplementation* interfaces = nullptr); ScriptingType(const StringAnsiView& fullname, BinaryModule* module, int32 size, InitRuntimeHandler initRuntime, Ctor ctor, Dtor dtor, ScriptingTypeInitializer* baseType, const InterfaceImplementation* interfaces = nullptr); ScriptingType(const StringAnsiView& fullname, BinaryModule* module, int32 size, InitRuntimeHandler initRuntime, Ctor ctor, Dtor dtor, Copy copy, Box box, Unbox unbox, GetField getField, SetField setField, ScriptingTypeInitializer* baseType, const InterfaceImplementation* interfaces = nullptr); - ScriptingType(const StringAnsiView& fullname, BinaryModule* module, int32 size, EnumItem* items); + ScriptingType(const StringAnsiView& fullname, BinaryModule* module, int32 size, EnumItem* items, bool stringSerialization); ScriptingType(const StringAnsiView& fullname, BinaryModule* module, InitRuntimeHandler initRuntime, SetupScriptVTableHandler setupScriptVTable, SetupScriptObjectVTableHandler setupScriptObjectVTable, GetInterfaceWrapper getInterfaceWrapper); ScriptingType(const ScriptingType& other); ScriptingType(ScriptingType&& other); @@ -339,7 +341,7 @@ struct FLAXENGINE_API ScriptingTypeInitializer : ScriptingTypeHandle ScriptingTypeInitializer(BinaryModule* module, const StringAnsiView& fullname, int32 size, ScriptingType::InitRuntimeHandler initRuntime = ScriptingType::DefaultInitRuntime, ScriptingType::SpawnHandler spawn = ScriptingType::DefaultSpawn, ScriptingTypeInitializer* baseType = nullptr, ScriptingType::SetupScriptVTableHandler setupScriptVTable = nullptr, ScriptingType::SetupScriptObjectVTableHandler setupScriptObjectVTable = nullptr, const ScriptingType::InterfaceImplementation* interfaces = nullptr); ScriptingTypeInitializer(BinaryModule* module, const StringAnsiView& fullname, int32 size, ScriptingType::InitRuntimeHandler initRuntime, ScriptingType::Ctor ctor, ScriptingType::Dtor dtor, ScriptingTypeInitializer* baseType = nullptr, const ScriptingType::InterfaceImplementation* interfaces = nullptr); ScriptingTypeInitializer(BinaryModule* module, const StringAnsiView& fullname, int32 size, ScriptingType::InitRuntimeHandler initRuntime, ScriptingType::Ctor ctor, ScriptingType::Dtor dtor, ScriptingType::Copy copy, ScriptingType::Box box, ScriptingType::Unbox unbox, ScriptingType::GetField getField, ScriptingType::SetField setField, ScriptingTypeInitializer* baseType = nullptr, const ScriptingType::InterfaceImplementation* interfaces = nullptr); - ScriptingTypeInitializer(BinaryModule* module, const StringAnsiView& fullname, int32 size, ScriptingType::EnumItem* items); + ScriptingTypeInitializer(BinaryModule* module, const StringAnsiView& fullname, int32 size, ScriptingType::EnumItem* items, bool stringSerialization); ScriptingTypeInitializer(BinaryModule* module, const StringAnsiView& fullname, ScriptingType::InitRuntimeHandler initRuntime, ScriptingType::SetupScriptVTableHandler setupScriptVTable, ScriptingType::SetupScriptObjectVTableHandler setupScriptObjectVTable, ScriptingType::GetInterfaceWrapper getInterfaceWrapper); }; diff --git a/Source/Engine/Serialization/JsonCustomSerializers/ExtendedDefaultContractResolver.cs b/Source/Engine/Serialization/JsonCustomSerializers/ExtendedDefaultContractResolver.cs index b8e07e448..4f2690863 100644 --- a/Source/Engine/Serialization/JsonCustomSerializers/ExtendedDefaultContractResolver.cs +++ b/Source/Engine/Serialization/JsonCustomSerializers/ExtendedDefaultContractResolver.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; using Newtonsoft.Json; +using Newtonsoft.Json.Converters; using Newtonsoft.Json.Serialization; namespace FlaxEngine.Json.JsonCustomSerializers @@ -44,6 +45,13 @@ namespace FlaxEngine.Json.JsonCustomSerializers ((JsonObjectContract)contract).ItemReferenceLoopHandling = ReferenceLoopHandling.Serialize; } + // Check if use enum serialization as string + var type = Nullable.GetUnderlyingType(objectType) ?? objectType; + if (type.IsEnum && type.GetCustomAttribute() != null) + { + contract.Converter = new StringEnumConverter(); + } + return contract; } @@ -53,19 +61,19 @@ namespace FlaxEngine.Json.JsonCustomSerializers var contract = base.CreateDictionaryContract(objectType); // Override contract to save enums keys as integer - if (contract.DictionaryKeyType?.IsEnum ?? false) + var keyType = contract.DictionaryKeyType; + if ((keyType?.IsEnum ?? false) && keyType.GetCustomAttribute() == null) { - var enumType = contract.DictionaryKeyType; contract.DictionaryKeyResolver = name => { try { - var e = Enum.Parse(enumType, name); + var e = Enum.Parse(keyType, name); name = Convert.ToInt32(e).ToString(); } - catch + catch (Exception ex) { - // Ignore errors + Debug.Logger.LogHandler.LogWrite(LogType.Warning, $"Failed to parse enum '{name}' as {keyType.Name}: {ex.Message}"); } return name; }; diff --git a/Source/Engine/Serialization/Serialization.cpp b/Source/Engine/Serialization/Serialization.cpp index 78fbf7ec5..bf3098140 100644 --- a/Source/Engine/Serialization/Serialization.cpp +++ b/Source/Engine/Serialization/Serialization.cpp @@ -49,6 +49,54 @@ void ISerializable::DeserializeIfExists(DeserializeStream& stream, const char* m var = defaultValue;\ } +void Serialization::SerializeEnum(ISerializable::SerializeStream& stream, uint32 v, ScriptingTypeHandle typeHandle) +{ + if (typeHandle) + { + // Check if serialize enum as string + const ScriptingType& type = typeHandle.GetType(); + if (type.Type == ScriptingTypes::Enum && type.Enum.StringSerialization) + { + const auto items = type.Enum.Items; + for (int32 i = 0; items[i].Name; i++) + { + if (items[i].Value == v) + { + stream.String(items[i].Name); + return; + } + } + } + } + stream.Uint(v); +} + +int32 Serialization::DeserializeEnum(ISerializable::DeserializeStream& stream, ScriptingTypeHandle typeHandle) +{ + if (stream.IsString() && typeHandle) + { + // Deserialize enum from string + const ScriptingType& type = typeHandle.GetType(); + if (type.Type == ScriptingTypes::Enum) + { + const auto str = stream.GetStringAnsiView(); + const auto items = type.Enum.Items; + for (int32 i = 0; items[i].Name; i++) + { + if (str == items[i].Name) + { + return (int32)items[i].Value; + } + } + int32 result; + if (!StringUtils::Parse(stream.GetString(), &result)) + return result; + LOG(Warning, "Failed to parse enum '{}' as {}", str.ToString(), type.Fullname.ToString()); + } + } + return DeserializeInt(stream); +} + bool Serialization::ShouldSerialize(const VariantType& v, const void* otherObj) { return !otherObj || v != *(VariantType*)otherObj; @@ -129,7 +177,6 @@ void Serialization::Serialize(ISerializable::SerializeStream& stream, const Vari stream.Int64(v.AsInt64); break; case VariantType::Uint64: - case VariantType::Enum: stream.Uint64(v.AsUint64); break; case VariantType::Float: @@ -222,6 +269,9 @@ void Serialization::Serialize(ISerializable::SerializeStream& stream, const Vari else stream.String("", 0); break; + case VariantType::Enum: + SerializeEnum(stream, (int32)v.AsUint64, v.Type.GetScriptingType()); + break; case VariantType::ManagedObject: case VariantType::Structure: { @@ -276,7 +326,6 @@ void Serialization::Deserialize(ISerializable::DeserializeStream& stream, Varian v.AsInt64 = value.GetInt64(); break; case VariantType::Uint64: - case VariantType::Enum: v.AsUint64 = value.GetUint64(); break; case VariantType::Float: @@ -371,6 +420,9 @@ void Serialization::Deserialize(ISerializable::DeserializeStream& stream, Varian CHECK(value.IsString()); v.SetTypename(value.GetStringAnsiView()); break; + case VariantType::Enum: + v.AsInt64 = DeserializeEnum(value, v.Type.GetScriptingType()); + break; case VariantType::ManagedObject: case VariantType::Structure: { diff --git a/Source/Engine/Serialization/Serialization.h b/Source/Engine/Serialization/Serialization.h index 9af6d7be1..41ae4898a 100644 --- a/Source/Engine/Serialization/Serialization.h +++ b/Source/Engine/Serialization/Serialization.h @@ -38,12 +38,16 @@ namespace Serialization int32 result = 0; if (stream.IsInt()) result = stream.GetInt(); + else if (stream.IsInt64()) + result = (int32)stream.GetInt64(); else if (stream.IsFloat()) result = (int32)stream.GetFloat(); else if (stream.IsString()) StringUtils::Parse(stream.GetString(), &result); return result; } + FLAXENGINE_API void SerializeEnum(ISerializable::SerializeStream& stream, uint32 v, ScriptingTypeHandle typeHandle); + FLAXENGINE_API int32 DeserializeEnum(ISerializable::DeserializeStream& stream, ScriptingTypeHandle typeHandle); // In-build types @@ -226,12 +230,12 @@ namespace Serialization template inline typename TEnableIf::Value>::Type Serialize(ISerializable::SerializeStream& stream, const T& v, const void* otherObj) { - stream.Uint((uint32)v); + SerializeEnum(stream, (uint32)v, StaticType()); } template inline typename TEnableIf::Value>::Type Deserialize(ISerializable::DeserializeStream& stream, T& v, ISerializeModifier* modifier) { - v = (T)DeserializeInt(stream); + v = (T)DeserializeEnum(stream, StaticType()); } // Common types diff --git a/Source/Tools/Flax.Build/Bindings/BindingsGenerator.Cpp.cs b/Source/Tools/Flax.Build/Bindings/BindingsGenerator.Cpp.cs index e67f66cde..c3d2efbd9 100644 --- a/Source/Tools/Flax.Build/Bindings/BindingsGenerator.Cpp.cs +++ b/Source/Tools/Flax.Build/Bindings/BindingsGenerator.Cpp.cs @@ -2761,7 +2761,8 @@ namespace Flax.Build.Bindings contents.Append($"ScriptingTypeInitializer {enumTypeNameInternal}_TypeInitializer((BinaryModule*)GetBinaryModule{moduleInfo.Name}(), "); contents.Append($"StringAnsiView(\"{enumTypeNameManaged}\", {enumTypeNameManaged.Length}), "); contents.Append($"sizeof({enumTypeNameNative}), "); - contents.Append($"{enumTypeNameInternal}Internal::Items);").AppendLine(); + var stringSerialization = enumInfo.Attributes != null && enumInfo.Attributes.Contains("EnumString") ? "true" : "false"; + contents.Append($"{enumTypeNameInternal}Internal::Items, {stringSerialization});").AppendLine(); contents.AppendLine($"template<> {moduleInfo.Name.ToUpperInvariant()}_API ScriptingTypeHandle StaticType<{enumTypeNameNative}>() {{ return {enumTypeNameInternal}_TypeInitializer; }}"); }