diff --git a/Core/GDCore/Events/Builtin/CommentEvent.cpp b/Core/GDCore/Events/Builtin/CommentEvent.cpp index a0352e7286c5..08fc5479352e 100644 --- a/Core/GDCore/Events/Builtin/CommentEvent.cpp +++ b/Core/GDCore/Events/Builtin/CommentEvent.cpp @@ -6,6 +6,7 @@ #include "CommentEvent.h" #include "GDCore/CommonTools.h" +#include "GDCore/Serialization/Serializer.h" #include "GDCore/Serialization/SerializerElement.h" using namespace std; @@ -29,6 +30,7 @@ bool CommentEvent::ReplaceAllSearchableStrings( } void CommentEvent::SerializeTo(SerializerElement &element) const { + const bool canonical = gd::Serializer::IsCanonicalMode(); element.AddChild("color") .SetAttribute("r", r) .SetAttribute("g", v) @@ -38,7 +40,7 @@ void CommentEvent::SerializeTo(SerializerElement &element) const { .SetAttribute("textB", textB); element.AddChild("comment").SetValue(com1); - if (!com2.empty()) element.AddChild("comment2").SetValue(com2); + if (canonical || !com2.empty()) element.AddChild("comment2").SetValue(com2); } void CommentEvent::UnserializeFrom(gd::Project &project, diff --git a/Core/GDCore/Events/Builtin/ForEachChildVariableEvent.cpp b/Core/GDCore/Events/Builtin/ForEachChildVariableEvent.cpp index aff662a3ae1f..f258ef54dd35 100644 --- a/Core/GDCore/Events/Builtin/ForEachChildVariableEvent.cpp +++ b/Core/GDCore/Events/Builtin/ForEachChildVariableEvent.cpp @@ -6,6 +6,7 @@ #include "ForEachChildVariableEvent.h" #include "GDCore/Events/Serialization.h" +#include "GDCore/Serialization/Serializer.h" #include "GDCore/Serialization/SerializerElement.h" using namespace std; @@ -81,6 +82,7 @@ vector > } void ForEachChildVariableEvent::SerializeTo(SerializerElement& element) const { + const bool canonical = gd::Serializer::IsCanonicalMode(); element.AddChild("iterableVariableName").SetValue(iterableVariableName.GetPlainString()); element.AddChild("valueIteratorVariableName").SetValue(valueIteratorVariableName.GetPlainString()); element.AddChild("keyIteratorVariableName").SetValue(keyIteratorVariableName.GetPlainString()); @@ -89,13 +91,13 @@ void ForEachChildVariableEvent::SerializeTo(SerializerElement& element) const { gd::EventsListSerialization::SerializeInstructionsTo( actions, element.AddChild("actions")); - if (!events.IsEmpty()) + if (canonical || !events.IsEmpty()) gd::EventsListSerialization::SerializeEventsTo(events, element.AddChild("events")); - if (HasVariables()) { + if (canonical || HasVariables()) { variables.SerializeTo(element.AddChild("variables")); } - if (!loopIndexVariableName.empty()) { + if (canonical || !loopIndexVariableName.empty()) { element.AddChild("loopIndexVariable").SetStringValue(loopIndexVariableName); } } diff --git a/Core/GDCore/Events/Builtin/ForEachEvent.cpp b/Core/GDCore/Events/Builtin/ForEachEvent.cpp index 417ba6a350e1..abcc2836ed47 100644 --- a/Core/GDCore/Events/Builtin/ForEachEvent.cpp +++ b/Core/GDCore/Events/Builtin/ForEachEvent.cpp @@ -7,6 +7,7 @@ #include "ForEachEvent.h" #include "GDCore/Events/Serialization.h" #include "GDCore/Events/Tools/EventsCodeNameMangler.h" +#include "GDCore/Serialization/Serializer.h" #include "GDCore/Serialization/SerializerElement.h" using namespace std; @@ -83,25 +84,26 @@ vector > } void ForEachEvent::SerializeTo(SerializerElement& element) const { + const bool canonical = gd::Serializer::IsCanonicalMode(); element.AddChild("object").SetValue(objectsToPick.GetPlainString()); gd::EventsListSerialization::SerializeInstructionsTo( conditions, element.AddChild("conditions")); gd::EventsListSerialization::SerializeInstructionsTo( actions, element.AddChild("actions")); - if (!events.IsEmpty()) + if (canonical || !events.IsEmpty()) gd::EventsListSerialization::SerializeEventsTo(events, element.AddChild("events")); - if (HasVariables()) { + if (canonical || HasVariables()) { variables.SerializeTo(element.AddChild("variables")); } - if (!loopIndexVariableName.empty()) { + if (canonical || !loopIndexVariableName.empty()) { element.AddChild("loopIndexVariable").SetStringValue(loopIndexVariableName); } - if (!orderBy.GetPlainString().empty()) { + if (canonical || !orderBy.GetPlainString().empty()) { element.AddChild("orderBy").SetValue(orderBy.GetPlainString()); element.AddChild("order").SetStringValue(order); - if (!limit.GetPlainString().empty()) { + if (canonical || !limit.GetPlainString().empty()) { element.AddChild("limit").SetValue(limit.GetPlainString()); } } diff --git a/Core/GDCore/Events/Builtin/RepeatEvent.cpp b/Core/GDCore/Events/Builtin/RepeatEvent.cpp index fc5077fcb017..49cb74664a75 100644 --- a/Core/GDCore/Events/Builtin/RepeatEvent.cpp +++ b/Core/GDCore/Events/Builtin/RepeatEvent.cpp @@ -6,6 +6,7 @@ #include "RepeatEvent.h" #include "GDCore/Events/Serialization.h" +#include "GDCore/Serialization/Serializer.h" #include "GDCore/Serialization/SerializerElement.h" using namespace std; @@ -70,6 +71,7 @@ vector > } void RepeatEvent::SerializeTo(SerializerElement& element) const { + const bool canonical = gd::Serializer::IsCanonicalMode(); element.AddChild("repeatExpression") .SetValue(repeatNumberExpression.GetPlainString()); gd::EventsListSerialization::SerializeInstructionsTo( @@ -77,13 +79,13 @@ void RepeatEvent::SerializeTo(SerializerElement& element) const { gd::EventsListSerialization::SerializeInstructionsTo( actions, element.AddChild("actions")); - if (!events.IsEmpty()) + if (canonical || !events.IsEmpty()) gd::EventsListSerialization::SerializeEventsTo(events, element.AddChild("events")); - if (HasVariables()) { + if (canonical || HasVariables()) { variables.SerializeTo(element.AddChild("variables")); } - if (!loopIndexVariableName.empty()) { + if (canonical || !loopIndexVariableName.empty()) { element.AddChild("loopIndexVariable").SetStringValue(loopIndexVariableName); } } diff --git a/Core/GDCore/Events/Builtin/StandardEvent.cpp b/Core/GDCore/Events/Builtin/StandardEvent.cpp index 90127a985687..41b56a634366 100644 --- a/Core/GDCore/Events/Builtin/StandardEvent.cpp +++ b/Core/GDCore/Events/Builtin/StandardEvent.cpp @@ -9,6 +9,7 @@ #include "GDCore/Events/CodeGeneration/EventsCodeGenerationContext.h" #include "GDCore/Events/CodeGeneration/EventsCodeGenerator.h" #include "GDCore/Events/Serialization.h" +#include "GDCore/Serialization/Serializer.h" #include "GDCore/Serialization/SerializerElement.h" using namespace std; @@ -50,15 +51,16 @@ vector StandardEvent::GetAllActionsVectors() { } void StandardEvent::SerializeTo(SerializerElement& element) const { + const bool canonical = gd::Serializer::IsCanonicalMode(); gd::EventsListSerialization::SerializeInstructionsTo( conditions, element.AddChild("conditions")); gd::EventsListSerialization::SerializeInstructionsTo( actions, element.AddChild("actions")); - if (!events.IsEmpty()) + if (canonical || !events.IsEmpty()) gd::EventsListSerialization::SerializeEventsTo(events, element.AddChild("events")); - if (HasVariables()) { + if (canonical || HasVariables()) { variables.SerializeTo(element.AddChild("variables")); } } diff --git a/Core/GDCore/Events/Builtin/WhileEvent.cpp b/Core/GDCore/Events/Builtin/WhileEvent.cpp index 250e07e98ef5..7d197b8f4a04 100644 --- a/Core/GDCore/Events/Builtin/WhileEvent.cpp +++ b/Core/GDCore/Events/Builtin/WhileEvent.cpp @@ -7,6 +7,7 @@ #if defined(GD_IDE_ONLY) #include "WhileEvent.h" #include "GDCore/Events/Serialization.h" +#include "GDCore/Serialization/Serializer.h" #include "GDCore/Serialization/SerializerElement.h" using namespace std; @@ -45,6 +46,7 @@ vector WhileEvent::GetAllActionsVectors() const { } void WhileEvent::SerializeTo(SerializerElement& element) const { + const bool canonical = gd::Serializer::IsCanonicalMode(); element.SetAttribute("infiniteLoopWarning", infiniteLoopWarning); gd::EventsListSerialization::SerializeInstructionsTo( whileConditions, element.AddChild("whileConditions")); @@ -53,13 +55,13 @@ void WhileEvent::SerializeTo(SerializerElement& element) const { gd::EventsListSerialization::SerializeInstructionsTo( actions, element.AddChild("actions")); - if (!events.IsEmpty()) + if (canonical || !events.IsEmpty()) gd::EventsListSerialization::SerializeEventsTo(events, element.AddChild("events")); - if (HasVariables()) { + if (canonical || HasVariables()) { variables.SerializeTo(element.AddChild("variables")); } - if (!loopIndexVariableName.empty()) { + if (canonical || !loopIndexVariableName.empty()) { element.AddChild("loopIndexVariable").SetStringValue(loopIndexVariableName); } } diff --git a/Core/GDCore/Events/Serialization.cpp b/Core/GDCore/Events/Serialization.cpp index ec1a8bc2c6b8..b3af9ee01ee1 100644 --- a/Core/GDCore/Events/Serialization.cpp +++ b/Core/GDCore/Events/Serialization.cpp @@ -230,14 +230,16 @@ void EventsListSerialization::UnserializeEventsFrom( void EventsListSerialization::SerializeEventsTo(const EventsList& list, SerializerElement& events) { + const bool canonical = gd::Serializer::IsCanonicalMode(); events.ConsiderAsArrayOf("event"); for (std::size_t j = 0; j < list.size(); j++) { const gd::BaseEvent& event = list.GetEvent(j); SerializerElement& eventElem = events.AddChild("event"); - if (event.IsDisabled()) + if (canonical || event.IsDisabled()) eventElem.SetAttribute("disabled", event.IsDisabled()); - if (event.IsFolded()) eventElem.SetAttribute("folded", event.IsFolded()); + if (canonical || event.IsFolded()) + eventElem.SetAttribute("folded", event.IsFolded()); if (!event.GetAiGeneratedEventId().empty()) eventElem.SetAttribute("aiGeneratedEventId", event.GetAiGeneratedEventId()); eventElem.AddChild("type").SetValue(event.GetType()); @@ -346,15 +348,16 @@ void gd::EventsListSerialization::UnserializeInstructionsFrom( void gd::EventsListSerialization::SerializeInstructionsTo( const gd::InstructionsList& list, SerializerElement& instructions) { + const bool canonical = gd::Serializer::IsCanonicalMode(); instructions.ConsiderAsArrayOf("instruction"); for (std::size_t k = 0; k < list.size(); k++) { SerializerElement& instruction = instructions.AddChild("instruction"); instruction.AddChild("type").SetAttribute("value", list[k].GetType()); - if (list[k].IsInverted()) - instruction.GetChild("type").SetAttribute("inverted", true); - if (list[k].IsAwaited()) - instruction.GetChild("type").SetAttribute("await", true); + if (canonical || list[k].IsInverted()) + instruction.GetChild("type").SetAttribute("inverted", list[k].IsInverted()); + if (canonical || list[k].IsAwaited()) + instruction.GetChild("type").SetAttribute("await", list[k].IsAwaited()); // Parameters SerializerElement& parameters = instruction.AddChild("parameters"); @@ -376,7 +379,7 @@ void gd::EventsListSerialization::SerializeInstructionsTo( } // Sub instructions - if (!list[k].GetSubInstructions().empty()) { + if (canonical || !list[k].GetSubInstructions().empty()) { SerializeInstructionsTo(list[k].GetSubInstructions(), instruction.AddChild("subInstructions")); } diff --git a/Core/GDCore/Project/Variable.cpp b/Core/GDCore/Project/Variable.cpp index c06540deeb6a..993b761e4a7c 100644 --- a/Core/GDCore/Project/Variable.cpp +++ b/Core/GDCore/Project/Variable.cpp @@ -8,6 +8,7 @@ #include +#include "GDCore/Serialization/Serializer.h" #include "GDCore/Serialization/SerializerElement.h" #include "GDCore/String.h" #include "GDCore/Tools/UUID/UUID.h" @@ -255,10 +256,12 @@ bool Variable::InsertChild(const gd::String& name, }; void Variable::SerializeTo(SerializerElement& element) const { + const bool canonical = gd::Serializer::IsCanonicalMode(); element.SetStringAttribute("type", TypeAsString(GetType())); - if (IsFolded()) element.SetBoolAttribute("folded", true); + if (canonical || IsFolded()) + element.SetBoolAttribute("folded", IsFolded()); - if (!persistentUuid.empty()) + if (canonical || !persistentUuid.empty()) element.SetStringAttribute("persistentUuid", persistentUuid); if (type == Type::String) { diff --git a/Core/GDCore/Serialization/Serializer.cpp b/Core/GDCore/Serialization/Serializer.cpp index 7913ae8997cb..ad4735662ab0 100644 --- a/Core/GDCore/Serialization/Serializer.cpp +++ b/Core/GDCore/Serialization/Serializer.cpp @@ -6,8 +6,10 @@ #include "GDCore/Serialization/Serializer.h" +#include #include #include +#include #include #include #include @@ -22,6 +24,8 @@ using namespace rapidjson; namespace gd { +bool Serializer::s_canonicalMode = false; + gd::String Serializer::ToEscapedXMLString(const gd::String& str) { return str.FindAndReplace("&", "&") .FindAndReplace("'", "'") @@ -95,19 +99,61 @@ void ElementToRapidJson(const gd::SerializerElement& element, const auto& attributes = element.GetAllAttributes(); const auto& children = element.GetAllChildren(); - for (const auto& attribute : attributes) { - Value name(attribute.first.c_str(), - allocator); // Copying the name is required. - Value childValue; - ElementToRapidJson(attribute.second, childValue, allocator); - value.AddMember(name, childValue, allocator); - } - for (const auto& child : children) { - Value name(child.first.c_str(), - allocator); // Copying the name is required. - Value childValue; - ElementToRapidJson(*child.second, childValue, allocator); - value.AddMember(name, childValue, allocator); + if (gd::Serializer::IsCanonicalMode()) { + // In canonical mode, merge attributes and children into a single + // alphabetically-sorted sequence so that the resulting JSON has + // stable, alphabetical key order. Attributes are stored in a + // std::map (already sorted) but children are stored in insertion + // order; we re-sort the union here. + // + // Children with the same name (rare, but allowed) keep their + // relative insertion order thanks to std::multimap stability. + struct Entry { + bool isAttribute; + const SerializerValue* attributeValue; // valid if isAttribute + const gd::SerializerElement* childElement; // valid if !isAttribute + }; + std::multimap sortedEntries; + + for (const auto& attribute : attributes) { + sortedEntries.emplace( + attribute.first, + Entry{true, &attribute.second, nullptr}); + } + for (const auto& child : children) { + sortedEntries.emplace( + child.first, + Entry{false, nullptr, child.second.get()}); + } + + for (const auto& entry : sortedEntries) { + Value name(entry.first.c_str(), allocator); + Value childValue; + if (entry.second.isAttribute) { + // Implicit conversion SerializerValue -> SerializerElement. + ElementToRapidJson( + *entry.second.attributeValue, childValue, allocator); + } else { + ElementToRapidJson( + *entry.second.childElement, childValue, allocator); + } + value.AddMember(name, childValue, allocator); + } + } else { + for (const auto& attribute : attributes) { + Value name(attribute.first.c_str(), + allocator); // Copying the name is required. + Value childValue; + ElementToRapidJson(attribute.second, childValue, allocator); + value.AddMember(name, childValue, allocator); + } + for (const auto& child : children) { + Value name(child.first.c_str(), + allocator); // Copying the name is required. + Value childValue; + ElementToRapidJson(*child.second, childValue, allocator); + value.AddMember(name, childValue, allocator); + } } } } diff --git a/Core/GDCore/Serialization/Serializer.h b/Core/GDCore/Serialization/Serializer.h index d7982e80c90d..b2f1d638357d 100644 --- a/Core/GDCore/Serialization/Serializer.h +++ b/Core/GDCore/Serialization/Serializer.h @@ -52,10 +52,36 @@ class GD_CORE_API Serializer { } ///@} + /** \name Canonical serialization mode. + * When enabled, ToJSON writes object keys in alphabetical order and + * various SerializeTo helpers write default values (false, "", []) for + * properties that would otherwise be omitted. + * + * This makes git diffs minimal and shift-free when toggling boolean + * flags or adding/removing optional sub-structures (e.g. sub-events, + * local variables). + */ + ///@{ + /** + * \brief Enable/disable canonical serialization mode globally. + * + * Affects ToJSON (alphabetical key order) and various SerializeTo helpers + * (always writing default values for optional properties). + */ + static void SetCanonicalMode(bool canonical) { s_canonicalMode = canonical; } + + /** + * \brief Returns true if canonical serialization mode is currently active. + */ + static bool IsCanonicalMode() { return s_canonicalMode; } + ///@} + virtual ~Serializer(){}; private: Serializer(){}; + + static bool s_canonicalMode; }; } // namespace gd diff --git a/GDevelop.js/Bindings/Bindings.idl b/GDevelop.js/Bindings/Bindings.idl index 319db755359a..204edd84825e 100644 --- a/GDevelop.js/Bindings/Bindings.idl +++ b/GDevelop.js/Bindings/Bindings.idl @@ -1641,6 +1641,8 @@ interface SharedPtrSerializerElement { interface Serializer { [Const, Value] DOMString STATIC_ToJSON([Const, Ref] SerializerElement element); [Value] SerializerElement STATIC_FromJSON([Const] DOMString json); + void STATIC_SetCanonicalMode(boolean canonical); + boolean STATIC_IsCanonicalMode(); }; [NoDelete] diff --git a/GDevelop.js/Bindings/Wrapper.cpp b/GDevelop.js/Bindings/Wrapper.cpp index 4f3802aec1e1..b8a177a4954f 100644 --- a/GDevelop.js/Bindings/Wrapper.cpp +++ b/GDevelop.js/Bindings/Wrapper.cpp @@ -606,6 +606,8 @@ typedef std::vector VectorPropertyDescriptorChoice #define STATIC_GetSafeName GetSafeName #define STATIC_ToJSON ToJSON #define STATIC_FromJSON(x) FromJSON(x) +#define STATIC_SetCanonicalMode SetCanonicalMode +#define STATIC_IsCanonicalMode IsCanonicalMode #define STATIC_SerializeTo SerializeTo #define STATIC_IsObject IsObject #define STATIC_IsBehavior IsBehavior diff --git a/GDevelop.js/__tests__/Serializer.js b/GDevelop.js/__tests__/Serializer.js index d0a72e1b0f06..13558061be09 100644 --- a/GDevelop.js/__tests__/Serializer.js +++ b/GDevelop.js/__tests__/Serializer.js @@ -127,6 +127,147 @@ describe('libGD.js object serialization', function () { }); }); + describe('gd.Serializer canonical mode', function () { + afterEach(() => { + // Always reset, even if a test failed mid-way: leaving the flag on + // would silently change the format of every subsequent test. + gd.Serializer.setCanonicalMode(false); + }); + + it('defaults to off', function () { + expect(gd.Serializer.isCanonicalMode()).toBe(false); + }); + + it('toggles on and off', function () { + gd.Serializer.setCanonicalMode(true); + expect(gd.Serializer.isCanonicalMode()).toBe(true); + gd.Serializer.setCanonicalMode(false); + expect(gd.Serializer.isCanonicalMode()).toBe(false); + }); + + const buildElement = () => { + const element = new gd.SerializerElement(); + element.addChild('zeta').setStringValue('z'); + element.addChild('alpha').setStringValue('a'); + element.addChild('mu').setStringValue('m'); + return element; + }; + + it('keeps insertion key order when off', function () { + const element = buildElement(); + const json = gd.Serializer.toJSON(element); + element.delete(); + expect(json).toBe('{"zeta":"z","alpha":"a","mu":"m"}'); + }); + + it('writes JSON keys alphabetically when on', function () { + gd.Serializer.setCanonicalMode(true); + const element = buildElement(); + const json = gd.Serializer.toJSON(element); + element.delete(); + expect(json).toBe('{"alpha":"a","mu":"m","zeta":"z"}'); + }); + + it('round-trips through fromJSON/toJSON in canonical mode', function () { + gd.Serializer.setCanonicalMode(true); + const input = '{"a":1,"b":{"x":[1,2,3],"y":"v"},"c":true}'; + const element = gd.Serializer.fromJSON(input); + const json = gd.Serializer.toJSON(element); + expect(json).toBe(input); + }); + + const serializeLayoutEvent = (canonicalMode) => { + gd.Serializer.setCanonicalMode(canonicalMode); + + const project = new gd.ProjectHelper.createNewGDJSProject(); + const layout = project.insertNewLayout('Scene', 0); + const events = layout.getEvents(); + events.insertNewEvent(project, 'BuiltinCommonInstructions::Standard', 0); + + const element = new gd.SerializerElement(); + events.serializeTo(element); + const json = gd.Serializer.toJSON(element); + element.delete(); + project.delete(); + + return JSON.parse(json); + }; + + it('writes default booleans/empty arrays for an empty StandardEvent when on', function () { + const eventsArray = serializeLayoutEvent(true); + expect(Array.isArray(eventsArray)).toBe(true); + expect(eventsArray.length).toBe(1); + const event = eventsArray[0]; + + expect(event.disabled).toBe(false); + expect(event.folded).toBe(false); + expect(event.events).toEqual([]); + expect(event.variables).toBeDefined(); + + // Keys must be alphabetical. + const keys = Object.keys(event); + const sorted = [...keys].sort(); + expect(keys).toEqual(sorted); + }); + + it('omits default values for an empty StandardEvent when off', function () { + const eventsArray = serializeLayoutEvent(false); + const event = eventsArray[0]; + + expect(event.disabled).toBeUndefined(); + expect(event.folded).toBeUndefined(); + expect(event.events).toBeUndefined(); + expect(event.variables).toBeUndefined(); + }); + + it('produces stable, byte-identical output across two serializations', function () { + gd.Serializer.setCanonicalMode(true); + const project = new gd.ProjectHelper.createNewGDJSProject(); + const layout = project.insertNewLayout('Scene', 0); + layout.getObjects().insertNewObject(project, 'Sprite', 'Object1', 0); + + const elementA = new gd.SerializerElement(); + project.serializeTo(elementA); + const jsonA = gd.Serializer.toJSON(elementA); + elementA.delete(); + + const elementB = new gd.SerializerElement(); + project.serializeTo(elementB); + const jsonB = gd.Serializer.toJSON(elementB); + elementB.delete(); + + expect(jsonA).toBe(jsonB); + + project.delete(); + }); + + it('round-trips through serialize -> deserialize -> serialize', function () { + gd.Serializer.setCanonicalMode(true); + const project = new gd.ProjectHelper.createNewGDJSProject(); + project.insertNewLayout('SceneA', 0); + project.insertNewLayout('SceneB', 1); + + const elementA = new gd.SerializerElement(); + project.serializeTo(elementA); + const jsonA = gd.Serializer.toJSON(elementA); + elementA.delete(); + + const elementB = gd.Serializer.fromJSON(jsonA); + const project2 = new gd.ProjectHelper.createNewGDJSProject(); + project2.unserializeFrom(elementB); + + const elementC = new gd.SerializerElement(); + project2.serializeTo(elementC); + const jsonB = gd.Serializer.toJSON(elementC); + elementC.delete(); + + expect(jsonB).toBe(jsonA); + + project.delete(); + project2.delete(); + }); + }); + describe('gd.BinarySerializer', function () { const serializeToBinarySnapshot = (serializerElement) => { // Create binary snapshot diff --git a/GDevelop.js/types.d.ts b/GDevelop.js/types.d.ts index b941c9f28bf8..30af64a71680 100644 --- a/GDevelop.js/types.d.ts +++ b/GDevelop.js/types.d.ts @@ -1371,6 +1371,8 @@ export class SharedPtrSerializerElement extends EmscriptenObject { export class Serializer extends EmscriptenObject { static toJSON(element: SerializerElement): string; static fromJSON(json: string): SerializerElement; + static setCanonicalMode(canonical: boolean): void; + static isCanonicalMode(): boolean; static fromJSObject(object: Object): gdSerializerElement; static toJSObject(element: gdSerializerElement): any; } diff --git a/GDevelop.js/types/gdserializer.js b/GDevelop.js/types/gdserializer.js index 2ef31d06d372..3286a24a4ee5 100644 --- a/GDevelop.js/types/gdserializer.js +++ b/GDevelop.js/types/gdserializer.js @@ -5,6 +5,8 @@ declare class gdSerializer { static toJSON(element: gdSerializerElement): string; static fromJSON(json: string): gdSerializerElement; + static setCanonicalMode(canonical: boolean): void; + static isCanonicalMode(): boolean; delete(): void; ptr: number; }; \ No newline at end of file diff --git a/newIDE/app/src/MainFrame/Preferences/PreferencesContext.js b/newIDE/app/src/MainFrame/Preferences/PreferencesContext.js index 9771a5593846..717284f11ca6 100644 --- a/newIDE/app/src/MainFrame/Preferences/PreferencesContext.js +++ b/newIDE/app/src/MainFrame/Preferences/PreferencesContext.js @@ -244,6 +244,7 @@ export type PreferencesValues = {| useBackgroundSerializerForSaving: boolean, disableNpmScriptConfirmation: boolean, showJsTypeError: boolean, + canonicalEventSerialization: boolean, |}; /** @@ -367,6 +368,7 @@ export type Preferences = {| setAutomaticallyUseCreditsForAiRequests: (enabled: boolean) => void, setUseBackgroundSerializerForSaving: (enabled: boolean) => void, setShowJsTypeError: (enabled: boolean) => void, + setCanonicalEventSerialization: (enabled: boolean) => void, |}; export const initialPreferences = { @@ -432,6 +434,7 @@ export const initialPreferences = { useBackgroundSerializerForSaving: false, disableNpmScriptConfirmation: false, showJsTypeError: false, + canonicalEventSerialization: false, }, setMultipleValues: () => {}, setLanguage: () => {}, @@ -518,6 +521,7 @@ export const initialPreferences = { setAutomaticallyUseCreditsForAiRequests: (enabled: boolean) => {}, setUseBackgroundSerializerForSaving: (enabled: boolean) => {}, setShowJsTypeError: (enabled: boolean) => {}, + setCanonicalEventSerialization: (enabled: boolean) => {}, }; const PreferencesContext: React.Context = React.createContext( diff --git a/newIDE/app/src/MainFrame/Preferences/PreferencesProvider.js b/newIDE/app/src/MainFrame/Preferences/PreferencesProvider.js index 8c2860d8c039..e9ce9c5198d0 100644 --- a/newIDE/app/src/MainFrame/Preferences/PreferencesProvider.js +++ b/newIDE/app/src/MainFrame/Preferences/PreferencesProvider.js @@ -136,6 +136,7 @@ export const getInitialPreferences = (): { use3DEditor: any, useBackgroundSerializerForSaving: boolean, showJsTypeError: boolean, + canonicalEventSerialization: boolean, useGDJSDevelopmentWatcher: boolean, useShortcutToClosePreviewWindow: boolean, userShortcutMap: {}, @@ -406,6 +407,10 @@ export default class PreferencesProvider extends React.Component { ): any), // $FlowFixMe[method-unbinding] setShowJsTypeError: (this._setShowJsTypeError.bind(this): any), + // $FlowFixMe[method-unbinding] + setCanonicalEventSerialization: (this._setCanonicalEventSerialization.bind( + this + ): any), }; componentDidMount() { @@ -1277,6 +1282,15 @@ export default class PreferencesProvider extends React.Component { ); } + _setCanonicalEventSerialization(newValue: boolean) { + this.setState( + state => ({ + values: { ...state.values, canonicalEventSerialization: newValue }, + }), + () => this._persistValuesToLocalStorage(this.state) + ); + } + _getEditorStateForProject(projectId: string): any { const editorState = this.state.values.editorStateByProject[projectId]; if (!editorState) return null; diff --git a/newIDE/app/src/MainFrame/index.js b/newIDE/app/src/MainFrame/index.js index b3895c2bc4f5..394f1d1cfabe 100644 --- a/newIDE/app/src/MainFrame/index.js +++ b/newIDE/app/src/MainFrame/index.js @@ -4210,6 +4210,8 @@ const MainFrame = (props: Props): React.MixedElement => { skipNewVersionWarning: !!checkedOutVersionStatus || (options && options.skipNewVersionWarning), + canonicalEventSerialization: + preferences.values.canonicalEventSerialization, }; if (cloudProjectRecoveryOpenedVersionId) { saveOptions.previousVersion = cloudProjectRecoveryOpenedVersionId; diff --git a/newIDE/app/src/ProjectsStorage/CloudStorageProvider/CloudProjectWriter.js b/newIDE/app/src/ProjectsStorage/CloudStorageProvider/CloudProjectWriter.js index e566f0ce9331..3bf99f09ee97 100644 --- a/newIDE/app/src/ProjectsStorage/CloudStorageProvider/CloudProjectWriter.js +++ b/newIDE/app/src/ProjectsStorage/CloudStorageProvider/CloudProjectWriter.js @@ -43,17 +43,25 @@ import { getUserPublicProfile } from '../../Utils/GDevelopServices/User'; const zipProject = async ({ project, useBackgroundSerializer, + canonicalEventSerialization, }: { project: gdProject, useBackgroundSerializer: boolean, + canonicalEventSerialization: boolean, }): Promise<{ zippedProject: Blob, projectJson: string }> => { const startTime = Date.now(); let projectJson: string; if (useBackgroundSerializer) { + // Canonical mode is currently not propagated to the background + // serializer worker (which uses its own libGD instance). Background + // serialization is hardcoded off in MainFrame so this is not + // exercised in production yet. projectJson = await serializeToJSONInBackground(project); } else { - projectJson = serializeToJSON(project); + projectJson = serializeToJSON(project, 'serializeTo', { + canonicalEventSerialization, + }); } projectJson = addFinalNewline(projectJson); @@ -108,6 +116,8 @@ const zipAndPrepareProjectVersionForCommit = async ({ zipProject({ project, useBackgroundSerializer: !!options && !!options.useBackgroundSerializer, + canonicalEventSerialization: + !!options && !!options.canonicalEventSerialization, }), ]); diff --git a/newIDE/app/src/ProjectsStorage/LocalFileStorageProvider/LocalProjectWriter.js b/newIDE/app/src/ProjectsStorage/LocalFileStorageProvider/LocalProjectWriter.js index 1bc87e340f5e..c201d2505b6d 100644 --- a/newIDE/app/src/ProjectsStorage/LocalFileStorageProvider/LocalProjectWriter.js +++ b/newIDE/app/src/ProjectsStorage/LocalFileStorageProvider/LocalProjectWriter.js @@ -117,19 +117,27 @@ const writeProjectFiles = async ({ filePath, projectPath, useBackgroundSerializer, + canonicalEventSerialization, }: { project: gdProject, filePath: string, projectPath: string, useBackgroundSerializer: boolean, + canonicalEventSerialization: boolean, }): Promise => { const startTime = Date.now(); let serializedProjectObject; if (useBackgroundSerializer) { + // Canonical mode is currently not propagated to the background + // serializer worker (which uses its own libGD instance). Background + // serialization is hardcoded off in MainFrame so this is not + // exercised in production yet. serializedProjectObject = await serializeToJSObjectInBackground(project); } else { - serializedProjectObject = serializeToJSObject(project); + serializedProjectObject = serializeToJSObject(project, 'serializeTo', { + canonicalEventSerialization, + }); } const serializeEndTime = Date.now(); @@ -224,6 +232,8 @@ export const onSaveProject = async ( projectPath, useBackgroundSerializer: !!saveOptions && !!saveOptions.useBackgroundSerializer, + canonicalEventSerialization: + !!saveOptions && !!saveOptions.canonicalEventSerialization, }); return { wasSaved: true, @@ -358,6 +368,9 @@ export const onSaveProjectAs = async ( filePath, projectPath, useBackgroundSerializer: false, + // SaveAs is a one-off operation: the user will typically save again + // through the normal onSaveProject path, which honors the preference. + canonicalEventSerialization: false, }); return { wasSaved: true, diff --git a/newIDE/app/src/ProjectsStorage/index.js b/newIDE/app/src/ProjectsStorage/index.js index 52e6bb12b834..002175089bdf 100644 --- a/newIDE/app/src/ProjectsStorage/index.js +++ b/newIDE/app/src/ProjectsStorage/index.js @@ -55,6 +55,13 @@ export type SaveProjectOptions = {| restoredFromVersionId?: string, useBackgroundSerializer?: boolean, skipNewVersionWarning?: boolean, + /** + * When true, the serializer writes object keys alphabetically and + * always emits default values for omitted properties (disabled, folded, + * empty events/variables arrays, etc.) so that diffs are minimal and + * shift-free when toggling flags. + */ + canonicalEventSerialization?: boolean, |}; export type SaveAsOptions = {| diff --git a/newIDE/app/src/Utils/ApplyProjectPreferences.js b/newIDE/app/src/Utils/ApplyProjectPreferences.js index 353f5ebb583c..53c3d5e7e785 100644 --- a/newIDE/app/src/Utils/ApplyProjectPreferences.js +++ b/newIDE/app/src/Utils/ApplyProjectPreferences.js @@ -48,6 +48,7 @@ const allowedPreferenceKeys: $ReadOnlyArray< 'takeScreenshotOnPreview', 'showAiAskButtonInTitleBar', 'automaticallyUseCreditsForAiRequests', + 'canonicalEventSerialization', ]; const allowedPreferences: Set = new Set(allowedPreferenceKeys); diff --git a/newIDE/app/src/Utils/ProjectSettingsReader.spec.js b/newIDE/app/src/Utils/ProjectSettingsReader.spec.js index 63011d14d3ba..2330e1229555 100644 --- a/newIDE/app/src/Utils/ProjectSettingsReader.spec.js +++ b/newIDE/app/src/Utils/ProjectSettingsReader.spec.js @@ -138,6 +138,14 @@ preferences: expect(mockSetMultipleValues).not.toHaveBeenCalled(); }); + test('canonicalEventSerialization is in the allowlist', () => { + const filtered = filterAllowedPreferences({ + canonicalEventSerialization: true, + someUnknownPreference: true, + }); + expect(filtered).toEqual({ canonicalEventSerialization: true }); + }); + test('readProjectSettings would return null when preferences section is missing', () => { const yamlContent = ` # Project settings without preferences diff --git a/newIDE/app/src/Utils/Serializer.js b/newIDE/app/src/Utils/Serializer.js index 86c72bce4d8c..6f327102510b 100644 --- a/newIDE/app/src/Utils/Serializer.js +++ b/newIDE/app/src/Utils/Serializer.js @@ -1,6 +1,47 @@ // @flow const gd: libGDevelop = global.gd; +/** + * Options that affect how a project (or any serializable) is converted to JSON. + */ +export type SerializationOptions = {| + /** + * When true, the C++ serializer writes default values for properties that + * would normally be omitted (e.g. `disabled: false`, `folded: false`, + * empty `events`/`variables` arrays, etc.) and writes JSON object keys in + * alphabetical order. This makes git diffs minimal and shift-free when + * toggling flags or adding/removing sub-events. + */ + canonicalEventSerialization?: boolean, +|}; + +/** + * Helper to run a serialization callback with the canonical mode flag + * temporarily enabled in the gd.Serializer. Always resets the flag, + * even if `callback` throws. + * + * The flag is global to the gd.Serializer (and to the C++ side), so we must + * be careful to reset it: leaving it on would silently change the format of + * every subsequent serialization (including ones that don't expect it). + */ +function withSerializationOptions( + options: ?SerializationOptions, + callback: () => T +): T { + const useCanonical = !!(options && options.canonicalEventSerialization); + if (!useCanonical) { + return callback(); + } + + const previous = gd.Serializer.isCanonicalMode(); + gd.Serializer.setCanonicalMode(true); + try { + return callback(); + } finally { + gd.Serializer.setCanonicalMode(previous); + } +} + /** * Tool function to save a serializable object to a JS object. * Most gd.* objects are "serializable", meaning they have a serializeTo @@ -8,30 +49,34 @@ const gd: libGDevelop = global.gd; * * @param {*} serializable * @param {*} methodName The name of the serialization method. "serializeTo" by default + * @param {*} options Optional serialization options (e.g. canonical mode) */ export function serializeToJSObject( serializable: gdSerializable, - methodName: string = 'serializeTo' + methodName: string = 'serializeTo', + options: ?SerializationOptions = undefined ): any { - const serializedElement = new gd.SerializerElement(); - serializable[methodName](serializedElement); - - // JSON.parse + toJSON is 30% faster than gd.Serializer.toJSObject. - const json = gd.Serializer.toJSON(serializedElement); - - try { - const object = JSON.parse(json); + return withSerializationOptions(options, () => { + const serializedElement = new gd.SerializerElement(); + serializable[methodName](serializedElement); - serializedElement.delete(); - return object; - } catch (error) { - serializedElement.delete(); - console.error( - 'Invalid JSON when serializing to JS object. toJSON should always return a valid JSON string.', - { json, error } - ); - throw error; - } + // JSON.parse + toJSON is 30% faster than gd.Serializer.toJSObject. + const json = gd.Serializer.toJSON(serializedElement); + + try { + const object = JSON.parse(json); + + serializedElement.delete(); + return object; + } catch (error) { + serializedElement.delete(); + console.error( + 'Invalid JSON when serializing to JS object. toJSON should always return a valid JSON string.', + { json, error } + ); + throw error; + } + }); } export function serializeObjectWithCleanDefaultBehaviorFlags( @@ -94,19 +139,23 @@ export function serializeToObjectAsset( * * @param {*} serializable * @param {*} methodName The name of the serialization method. "unserializeFrom" by default + * @param {*} options Optional serialization options (e.g. canonical mode) */ export function serializeToJSON( serializable: gdSerializable, - methodName: string = 'serializeTo' + methodName: string = 'serializeTo', + options: ?SerializationOptions = undefined ): string { - const serializedElement = new gd.SerializerElement(); - serializable[methodName](serializedElement); + return withSerializationOptions(options, () => { + const serializedElement = new gd.SerializerElement(); + serializable[methodName](serializedElement); - // toJSON is 20% faster than gd.Serializer.toJSObject + JSON.stringify. - const json = gd.Serializer.toJSON(serializedElement); - serializedElement.delete(); + // toJSON is 20% faster than gd.Serializer.toJSObject + JSON.stringify. + const json = gd.Serializer.toJSON(serializedElement); + serializedElement.delete(); - return json; + return json; + }); } /**