diff --git a/plugin/rbx_dom_lua/init.lua b/plugin/rbx_dom_lua/init.lua index e26e3c2a..ed6ab91d 100644 --- a/plugin/rbx_dom_lua/init.lua +++ b/plugin/rbx_dom_lua/init.lua @@ -6,6 +6,43 @@ local PropertyDescriptor = require(script.PropertyDescriptor) -- Returns nil if the class doesn't exist in the database -- The properties table maps property names to metadata (scriptability, serialization status) -- To get a full PropertyDescriptor for encoding, use findCanonicalPropertyDescriptor +-- Cache for default properties per class (includes inherited defaults). +local defaultPropertiesCache: { [string]: { [string]: any } } = {} + +-- Returns a table mapping property names to their encoded default values +-- for a given class, including inherited defaults from parent classes. +-- Subclass overrides take precedence. Results are cached per class. +local function findDefaultProperties(className: string): { [string]: any } + local cached = defaultPropertiesCache[className] + if cached then + return cached + end + + local defaults: { [string]: any } = {} + local currentClassName: string? = className + + repeat + local currentClass = database.Classes[currentClassName] + if currentClass == nil then + break + end + + local classDefaults = currentClass.DefaultProperties + if classDefaults then + for propName, encodedValue in classDefaults do + if defaults[propName] == nil then + defaults[propName] = encodedValue + end + end + end + + currentClassName = currentClass.Superclass + until currentClassName == nil + + defaultPropertiesCache[className] = defaults + return defaults +end + local function findClassDescriptor(className) local classData = database.Classes[className] if classData == nil then @@ -111,6 +148,7 @@ return { writeProperty = writeProperty, findCanonicalPropertyDescriptor = findCanonicalPropertyDescriptor, findClassDescriptor = findClassDescriptor, + findDefaultProperties = findDefaultProperties, Error = Error, EncodedValue = require(script.EncodedValue), } diff --git a/plugin/src/Reconciler/matching.lua b/plugin/src/Reconciler/matching.lua index 9eb7c0dd..45d8823f 100644 --- a/plugin/src/Reconciler/matching.lua +++ b/plugin/src/Reconciler/matching.lua @@ -330,6 +330,48 @@ function Matching._countOwnDiffs(vInst: any, studioInstance: Instance): number end end + -- Properties present only on the Studio side (not in virtual). + -- Syncback strips default-valued properties from model files, so virtual + -- instances may omit properties that Studio has at non-default values. + -- Without this check, a virtual instance missing e.g. Face scores equally + -- against all Studio instances in the group, causing wrong pairings. + local defaults = RbxDom.findDefaultProperties(vInst.ClassName) + if defaults then + for propName, encodedDefault in defaults do + if propName == "Tags" or propName == "Attributes" then + continue + end + + -- Skip properties already compared from the virtual side + if vProps and vProps[propName] ~= nil then + continue + end + + -- Skip Ref/UniqueId types (not comparable during hydration) + local ty = next(encodedDefault) + if ty == "Ref" or ty == "UniqueId" then + continue + end + + -- pcall guards against unsupported types (e.g. SharedString) + local pcallOk, decodeOk, defaultValue = pcall(RbxDom.EncodedValue.decode, encodedDefault) + if not pcallOk or not decodeOk then + continue + end + + local readOk, studioValue = pcall(function() + return (studioInstance :: any)[propName] + end) + if not readOk then + continue + end + + if not trueEquals(defaultValue, studioValue) then + cost += 1 + end + end + end + -- Tags: count individual adds/removes local vTags = vInst.Properties and vInst.Properties.Tags if vTags then diff --git a/rojo-test/syncback-tests/UFOWave_matching/expected/Hitbox/BillboardGui/Frame/Icon/UIAspectRatioConstraint.model.json5 b/rojo-test/syncback-tests/UFOWave_matching/expected/Hitbox/BillboardGui/Frame/Icon/UIAspectRatioConstraint.model.json5 new file mode 100644 index 00000000..cace7a97 --- /dev/null +++ b/rojo-test/syncback-tests/UFOWave_matching/expected/Hitbox/BillboardGui/Frame/Icon/UIAspectRatioConstraint.model.json5 @@ -0,0 +1,3 @@ +{ + className: "UIAspectRatioConstraint" +} diff --git a/rojo-test/syncback-tests/UFOWave_matching/expected/Hitbox/BillboardGui/Frame/Icon/init.meta.json5 b/rojo-test/syncback-tests/UFOWave_matching/expected/Hitbox/BillboardGui/Frame/Icon/init.meta.json5 new file mode 100644 index 00000000..f121b2cc --- /dev/null +++ b/rojo-test/syncback-tests/UFOWave_matching/expected/Hitbox/BillboardGui/Frame/Icon/init.meta.json5 @@ -0,0 +1,46 @@ +{ + className: "ImageLabel", + properties: { + AnchorPoint: [ + 0.5, + 0 + ], + BackgroundColor3: [ + 1, + 1, + 1 + ], + BackgroundTransparency: 1, + BorderColor3: [ + 0, + 0, + 0 + ], + BorderSizePixel: 0, + ImageContent: "rbxassetid://131974996946194", + Position: { + UDim2: [ + [ + 0.5, + 0 + ], + [ + 0.5, + 0 + ] + ] + }, + Size: { + UDim2: [ + [ + 0.699999988079071, + 0 + ], + [ + 0.699999988079071, + 0 + ] + ] + } + } +} diff --git a/rojo-test/syncback-tests/UFOWave_matching/expected/Hitbox/BillboardGui/Frame/Info/UIGradient.model.json5 b/rojo-test/syncback-tests/UFOWave_matching/expected/Hitbox/BillboardGui/Frame/Info/UIGradient.model.json5 new file mode 100644 index 00000000..197d4910 --- /dev/null +++ b/rojo-test/syncback-tests/UFOWave_matching/expected/Hitbox/BillboardGui/Frame/Info/UIGradient.model.json5 @@ -0,0 +1,36 @@ +{ + className: "UIGradient", + properties: { + Color: { + ColorSequence: { + keypoints: [ + { + color: [ + 0.1882352977991104, + 0.11372549086809158, + 1 + ], + time: 0 + }, + { + color: [ + 0.5490196347236633, + 0, + 1 + ], + time: 0.608996570110321 + }, + { + color: [ + 0.501960813999176, + 0, + 0.9176470637321472 + ], + time: 1 + } + ] + } + }, + Rotation: -90 + } +} diff --git a/rojo-test/syncback-tests/UFOWave_matching/expected/Hitbox/BillboardGui/Frame/Info/UIStroke.model.json5 b/rojo-test/syncback-tests/UFOWave_matching/expected/Hitbox/BillboardGui/Frame/Info/UIStroke.model.json5 new file mode 100644 index 00000000..2afd1ec6 --- /dev/null +++ b/rojo-test/syncback-tests/UFOWave_matching/expected/Hitbox/BillboardGui/Frame/Info/UIStroke.model.json5 @@ -0,0 +1,6 @@ +{ + className: "UIStroke", + properties: { + Thickness: 2 + } +} diff --git a/rojo-test/syncback-tests/UFOWave_matching/expected/Hitbox/BillboardGui/Frame/Info/init.meta.json5 b/rojo-test/syncback-tests/UFOWave_matching/expected/Hitbox/BillboardGui/Frame/Info/init.meta.json5 new file mode 100644 index 00000000..bb5f1ca4 --- /dev/null +++ b/rojo-test/syncback-tests/UFOWave_matching/expected/Hitbox/BillboardGui/Frame/Info/init.meta.json5 @@ -0,0 +1,60 @@ +{ + className: "TextLabel", + properties: { + AnchorPoint: [ + 0.5, + 0 + ], + BackgroundColor3: [ + 1, + 1, + 1 + ], + BackgroundTransparency: 1, + BorderColor3: [ + 0, + 0, + 0 + ], + BorderSizePixel: 0, + FontFace: { + cachedFaceId: null, + family: "rbxasset://fonts/families/LuckiestGuy.json", + style: "Normal", + weight: "Regular" + }, + Position: { + UDim2: [ + [ + 0.5, + 0 + ], + [ + 0, + 0 + ] + ] + }, + Size: { + UDim2: [ + [ + 1, + 0 + ], + [ + 0.5, + 0 + ] + ] + }, + Text: "UFO", + TextColor3: [ + 1, + 1, + 1 + ], + TextScaled: true, + TextSize: 14, + TextWrapped: true + } +} diff --git a/rojo-test/syncback-tests/UFOWave_matching/expected/Hitbox/BillboardGui/Frame/init.meta.json5 b/rojo-test/syncback-tests/UFOWave_matching/expected/Hitbox/BillboardGui/Frame/init.meta.json5 new file mode 100644 index 00000000..00b3efd0 --- /dev/null +++ b/rojo-test/syncback-tests/UFOWave_matching/expected/Hitbox/BillboardGui/Frame/init.meta.json5 @@ -0,0 +1,45 @@ +{ + className: "Frame", + properties: { + AnchorPoint: [ + 0.5, + 0.5 + ], + BackgroundColor3: [ + 1, + 1, + 1 + ], + BackgroundTransparency: 1, + BorderColor3: [ + 0, + 0, + 0 + ], + BorderSizePixel: 0, + Position: { + UDim2: [ + [ + 0.5, + 0 + ], + [ + 0.5, + 0 + ] + ] + }, + Size: { + UDim2: [ + [ + 1, + 0 + ], + [ + 1, + 0 + ] + ] + } + } +} diff --git a/rojo-test/syncback-tests/UFOWave_matching/expected/Hitbox/BillboardGui/init.meta.json5 b/rojo-test/syncback-tests/UFOWave_matching/expected/Hitbox/BillboardGui/init.meta.json5 new file mode 100644 index 00000000..7262f7a0 --- /dev/null +++ b/rojo-test/syncback-tests/UFOWave_matching/expected/Hitbox/BillboardGui/init.meta.json5 @@ -0,0 +1,26 @@ +{ + className: "BillboardGui", + properties: { + Active: true, + LightInfluence: 1, + ResetOnSpawn: false, + Size: { + UDim2: [ + [ + 60, + 0 + ], + [ + 40, + 0 + ] + ] + }, + StudsOffset: [ + 0, + 75, + 0 + ], + ZIndexBehavior: "Sibling" + } +} diff --git a/rojo-test/syncback-tests/UFOWave_matching/expected/Hitbox/Water.model.json5 b/rojo-test/syncback-tests/UFOWave_matching/expected/Hitbox/Water.model.json5 new file mode 100644 index 00000000..696e6de6 --- /dev/null +++ b/rojo-test/syncback-tests/UFOWave_matching/expected/Hitbox/Water.model.json5 @@ -0,0 +1,16 @@ +{ + attributes: { + Rojo_Ref_SoundGroup: "ServerStorage/__server_muted.model.json5" + }, + className: "Sound", + properties: { + AudioContent: "rbxassetid://9120549564", + Looped: true, + Playing: true, + SourceAssetId: 9120549564, + Tags: [ + "SoundGroupEffects" + ], + Volume: 3 + } +} diff --git a/rojo-test/syncback-tests/UFOWave_matching/expected/Hitbox/init.meta.json5 b/rojo-test/syncback-tests/UFOWave_matching/expected/Hitbox/init.meta.json5 new file mode 100644 index 00000000..afc3c11f --- /dev/null +++ b/rojo-test/syncback-tests/UFOWave_matching/expected/Hitbox/init.meta.json5 @@ -0,0 +1,29 @@ +{ + className: "Part", + properties: { + Anchored: true, + BottomSurface: "Smooth", + CFrame: [ + 789.7578125, + 37.375, + 0.00000762939453125, + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1 + ], + CanCollide: false, + Size: [ + 25.515625, + 70.75, + 260 + ], + TopSurface: "Smooth", + Transparency: 1 + } +} diff --git a/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/Texture.model.json5 b/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/Texture.model.json5 new file mode 100644 index 00000000..5e975d2a --- /dev/null +++ b/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/Texture.model.json5 @@ -0,0 +1,15 @@ +{ + className: "Texture", + properties: { + Color3: [ + 0.41568630933761597, + 0, + 1 + ], + Face: "Top", + StudsPerTileU: 200, + StudsPerTileV: 200, + TextureContent: "rbxassetid://10025668350", + Transparency: 0.6000000238418579 + } +} diff --git a/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/Texture~1.model.json5 b/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/Texture~1.model.json5 new file mode 100644 index 00000000..11e28cd8 --- /dev/null +++ b/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/Texture~1.model.json5 @@ -0,0 +1,16 @@ +{ + className: "Texture", + name: "Texture", + properties: { + Color3: [ + 0.41568630933761597, + 0, + 1 + ], + Face: "Right", + StudsPerTileU: 200, + StudsPerTileV: 200, + TextureContent: "rbxassetid://10025668350", + Transparency: 0.6000000238418579 + } +} diff --git a/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/Texture~10.model.json5 b/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/Texture~10.model.json5 new file mode 100644 index 00000000..dc4b910d --- /dev/null +++ b/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/Texture~10.model.json5 @@ -0,0 +1,16 @@ +{ + className: "Texture", + name: "Texture", + properties: { + Color3: [ + 0.40000003576278687, + 0, + 1 + ], + Face: "Right", + StudsPerTileU: 200, + StudsPerTileV: 200, + TextureContent: "rbxassetid://10023635072", + Transparency: 0.6499999761581421 + } +} diff --git a/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/Texture~11.model.json5 b/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/Texture~11.model.json5 new file mode 100644 index 00000000..df4f761f --- /dev/null +++ b/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/Texture~11.model.json5 @@ -0,0 +1,16 @@ +{ + className: "Texture", + name: "Texture", + properties: { + Color3: [ + 0.40000003576278687, + 0, + 1 + ], + Face: "Top", + StudsPerTileU: 200, + StudsPerTileV: 200, + TextureContent: "rbxassetid://10023635072", + Transparency: 0.6499999761581421 + } +} diff --git a/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/Texture~2.model.json5 b/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/Texture~2.model.json5 new file mode 100644 index 00000000..9f5fd1c3 --- /dev/null +++ b/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/Texture~2.model.json5 @@ -0,0 +1,16 @@ +{ + className: "Texture", + name: "Texture", + properties: { + Color3: [ + 0.41568630933761597, + 0, + 1 + ], + Face: "Left", + StudsPerTileU: 200, + StudsPerTileV: 200, + TextureContent: "rbxassetid://10025668350", + Transparency: 0.6000000238418579 + } +} diff --git a/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/Texture~3.model.json5 b/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/Texture~3.model.json5 new file mode 100644 index 00000000..cc60c294 --- /dev/null +++ b/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/Texture~3.model.json5 @@ -0,0 +1,15 @@ +{ + className: "Texture", + name: "Texture", + properties: { + Color3: [ + 0.41568630933761597, + 0, + 1 + ], + StudsPerTileU: 200, + StudsPerTileV: 200, + TextureContent: "rbxassetid://10025668350", + Transparency: 0.6000000238418579 + } +} diff --git a/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/Texture~4.model.json5 b/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/Texture~4.model.json5 new file mode 100644 index 00000000..cec50fee --- /dev/null +++ b/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/Texture~4.model.json5 @@ -0,0 +1,16 @@ +{ + className: "Texture", + name: "Texture", + properties: { + Color3: [ + 0.41568630933761597, + 0, + 1 + ], + Face: "Bottom", + StudsPerTileU: 200, + StudsPerTileV: 200, + TextureContent: "rbxassetid://10025668350", + Transparency: 0.6000000238418579 + } +} diff --git a/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/Texture~5.model.json5 b/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/Texture~5.model.json5 new file mode 100644 index 00000000..30761828 --- /dev/null +++ b/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/Texture~5.model.json5 @@ -0,0 +1,16 @@ +{ + className: "Texture", + name: "Texture", + properties: { + Color3: [ + 0.41568630933761597, + 0, + 1 + ], + Face: "Back", + StudsPerTileU: 200, + StudsPerTileV: 200, + TextureContent: "rbxassetid://10025668350", + Transparency: 0.6000000238418579 + } +} diff --git a/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/Texture~6.model.json5 b/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/Texture~6.model.json5 new file mode 100644 index 00000000..015bfdce --- /dev/null +++ b/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/Texture~6.model.json5 @@ -0,0 +1,16 @@ +{ + className: "Texture", + name: "Texture", + properties: { + Color3: [ + 0.40000003576278687, + 0, + 1 + ], + Face: "Back", + StudsPerTileU: 200, + StudsPerTileV: 200, + TextureContent: "rbxassetid://10023635072", + Transparency: 0.6499999761581421 + } +} diff --git a/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/Texture~7.model.json5 b/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/Texture~7.model.json5 new file mode 100644 index 00000000..c9f537bd --- /dev/null +++ b/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/Texture~7.model.json5 @@ -0,0 +1,16 @@ +{ + className: "Texture", + name: "Texture", + properties: { + Color3: [ + 0.40000003576278687, + 0, + 1 + ], + Face: "Bottom", + StudsPerTileU: 200, + StudsPerTileV: 200, + TextureContent: "rbxassetid://10023635072", + Transparency: 0.6499999761581421 + } +} diff --git a/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/Texture~8.model.json5 b/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/Texture~8.model.json5 new file mode 100644 index 00000000..869ecc79 --- /dev/null +++ b/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/Texture~8.model.json5 @@ -0,0 +1,15 @@ +{ + className: "Texture", + name: "Texture", + properties: { + Color3: [ + 0.40000003576278687, + 0, + 1 + ], + StudsPerTileU: 200, + StudsPerTileV: 200, + TextureContent: "rbxassetid://10023635072", + Transparency: 0.6499999761581421 + } +} diff --git a/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/Texture~9.model.json5 b/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/Texture~9.model.json5 new file mode 100644 index 00000000..470357ae --- /dev/null +++ b/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/Texture~9.model.json5 @@ -0,0 +1,16 @@ +{ + className: "Texture", + name: "Texture", + properties: { + Color3: [ + 0.40000003576278687, + 0, + 1 + ], + Face: "Left", + StudsPerTileU: 200, + StudsPerTileV: 200, + TextureContent: "rbxassetid://10023635072", + Transparency: 0.6499999761581421 + } +} diff --git a/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/glow.model.json5 b/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/glow.model.json5 new file mode 100644 index 00000000..f62c6a95 --- /dev/null +++ b/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/glow.model.json5 @@ -0,0 +1,185 @@ +{ + attributes: { + Size_Curve: { + BinaryString: "AAAAAAAAgD8AAAAAyEWiPWTpPj9Q1747AACAPwAAAAAAAAAAAAAAAAAAAAAAAAAA" + }, + Size_End: 60, + Size_Envelope: 5, + Size_Start: 45 + }, + className: "ParticleEmitter", + properties: { + Color: { + ColorSequence: { + keypoints: [ + { + color: [ + 0.6666666865348816, + 0, + 1 + ], + time: 0 + }, + { + color: [ + 0.6666666865348816, + 0, + 1 + ], + time: 1 + } + ] + } + }, + Lifetime: { + NumberRange: [ + 0.25, + 0.6000000238418579 + ] + }, + LightEmission: 1, + LockedToPart: true, + Size: { + NumberSequence: { + keypoints: [ + { + envelope: 5, + time: 0, + value: 45 + }, + { + envelope: 5, + time: 0.001580123440362513, + value: 46.08238983154297 + }, + { + envelope: 5, + time: 0.006548703648149967, + value: 47.16343307495117 + }, + { + envelope: 5, + time: 0.015281829982995987, + value: 48.24135971069336 + }, + { + envelope: 5, + time: 0.028204884380102158, + value: 49.313777923583984 + }, + { + envelope: 5, + time: 0.045797109603881836, + value: 50.37750244140625 + }, + { + envelope: 5, + time: 0.06858612596988678, + value: 51.42829513549805 + }, + { + envelope: 5, + time: 0.09713879972696304, + value: 52.4605827331543 + }, + { + envelope: 5, + time: 0.13203296065330505, + value: 53.46717071533203 + }, + { + envelope: 5, + time: 0.17381449043750763, + value: 54.438995361328125 + }, + { + envelope: 5, + time: 0.22292612493038177, + value: 55.36511993408203 + }, + { + envelope: 5, + time: 0.27961885929107666, + value: 56.23305892944336 + }, + { + envelope: 5, + time: 0.34385624527931213, + value: 57.029701232910156 + }, + { + envelope: 5, + time: 0.4152511656284332, + value: 57.74271774291992 + }, + { + envelope: 5, + time: 0.49307429790496826, + value: 58.362186431884766 + }, + { + envelope: 5, + time: 0.5763297080993652, + value: 58.88185501098633 + }, + { + envelope: 5, + time: 0.6638965010643005, + value: 59.29954147338867 + }, + { + envelope: 5, + time: 0.7546600699424744, + value: 59.6165657043457 + }, + { + envelope: 5, + time: 0.8476089239120483, + value: 59.83663558959961 + }, + { + envelope: 5, + time: 1, + value: 59.964595794677734 + } + ] + } + }, + Speed: { + NumberRange: [ + 0.0010000000474974513, + 0.0010000000474974513 + ] + }, + Tags: [ + "_VFX" + ], + Texture: "rbxassetid://77024587778878", + Transparency: { + NumberSequence: { + keypoints: [ + { + envelope: 0, + time: 0, + value: 1 + }, + { + envelope: 0, + time: 0.09989999979734421, + value: 0.75 + }, + { + envelope: 0, + time: 0.6000000238418579, + value: 0.75 + }, + { + envelope: 0, + time: 1, + value: 1 + } + ] + } + } + } +} diff --git a/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/glow~1.model.json5 b/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/glow~1.model.json5 new file mode 100644 index 00000000..943be0f1 --- /dev/null +++ b/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/glow~1.model.json5 @@ -0,0 +1,186 @@ +{ + attributes: { + Size_Curve: { + BinaryString: "AAAAAAAAgD8AAAAAyEWiPWTpPj9Q1747AACAPwAAAAAAAAAAAAAAAAAAAAAAAAAA" + }, + Size_End: 60, + Size_Envelope: 5, + Size_Start: 45 + }, + className: "ParticleEmitter", + name: "glow", + properties: { + Color: { + ColorSequence: { + keypoints: [ + { + color: [ + 0.364705890417099, + 0.11372549086809158, + 1 + ], + time: 0 + }, + { + color: [ + 0.364705890417099, + 0.11372549086809158, + 1 + ], + time: 1 + } + ] + } + }, + Lifetime: { + NumberRange: [ + 0.25, + 0.6000000238418579 + ] + }, + LightEmission: 1, + LockedToPart: true, + Size: { + NumberSequence: { + keypoints: [ + { + envelope: 5, + time: 0, + value: 45 + }, + { + envelope: 5, + time: 0.001580123440362513, + value: 46.08238983154297 + }, + { + envelope: 5, + time: 0.006548703648149967, + value: 47.16343307495117 + }, + { + envelope: 5, + time: 0.015281829982995987, + value: 48.24135971069336 + }, + { + envelope: 5, + time: 0.028204884380102158, + value: 49.313777923583984 + }, + { + envelope: 5, + time: 0.045797109603881836, + value: 50.37750244140625 + }, + { + envelope: 5, + time: 0.06858612596988678, + value: 51.42829513549805 + }, + { + envelope: 5, + time: 0.09713879972696304, + value: 52.4605827331543 + }, + { + envelope: 5, + time: 0.13203296065330505, + value: 53.46717071533203 + }, + { + envelope: 5, + time: 0.17381449043750763, + value: 54.438995361328125 + }, + { + envelope: 5, + time: 0.22292612493038177, + value: 55.36511993408203 + }, + { + envelope: 5, + time: 0.27961885929107666, + value: 56.23305892944336 + }, + { + envelope: 5, + time: 0.34385624527931213, + value: 57.029701232910156 + }, + { + envelope: 5, + time: 0.4152511656284332, + value: 57.74271774291992 + }, + { + envelope: 5, + time: 0.49307429790496826, + value: 58.362186431884766 + }, + { + envelope: 5, + time: 0.5763297080993652, + value: 58.88185501098633 + }, + { + envelope: 5, + time: 0.6638965010643005, + value: 59.29954147338867 + }, + { + envelope: 5, + time: 0.7546600699424744, + value: 59.6165657043457 + }, + { + envelope: 5, + time: 0.8476089239120483, + value: 59.83663558959961 + }, + { + envelope: 5, + time: 1, + value: 59.964595794677734 + } + ] + } + }, + Speed: { + NumberRange: [ + 0.0010000000474974513, + 0.0010000000474974513 + ] + }, + Tags: [ + "_VFX" + ], + Texture: "rbxassetid://77024587778878", + Transparency: { + NumberSequence: { + keypoints: [ + { + envelope: 0, + time: 0, + value: 1 + }, + { + envelope: 0, + time: 0.09989999979734421, + value: 0.75 + }, + { + envelope: 0, + time: 0.6000000238418579, + value: 0.75 + }, + { + envelope: 0, + time: 1, + value: 1 + } + ] + } + } + } +} diff --git a/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/glow~2.model.json5 b/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/glow~2.model.json5 new file mode 100644 index 00000000..25ec3941 --- /dev/null +++ b/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/glow~2.model.json5 @@ -0,0 +1,186 @@ +{ + attributes: { + Size_Curve: { + BinaryString: "AAAAAAAAgD8AAAAAyEWiPWTpPj9Q1747AACAPwAAAAAAAAAAAAAAAAAAAAAAAAAA" + }, + Size_End: 60, + Size_Envelope: 5, + Size_Start: 45 + }, + className: "ParticleEmitter", + name: "glow", + properties: { + Color: { + ColorSequence: { + keypoints: [ + { + color: [ + 0.6666666865348816, + 0, + 1 + ], + time: 0 + }, + { + color: [ + 0.6666666865348816, + 0, + 1 + ], + time: 1 + } + ] + } + }, + Lifetime: { + NumberRange: [ + 0.25, + 0.6000000238418579 + ] + }, + LightEmission: 1, + LockedToPart: true, + Size: { + NumberSequence: { + keypoints: [ + { + envelope: 5, + time: 0, + value: 45 + }, + { + envelope: 5, + time: 0.001580123440362513, + value: 46.08238983154297 + }, + { + envelope: 5, + time: 0.006548703648149967, + value: 47.16343307495117 + }, + { + envelope: 5, + time: 0.015281829982995987, + value: 48.24135971069336 + }, + { + envelope: 5, + time: 0.028204884380102158, + value: 49.313777923583984 + }, + { + envelope: 5, + time: 0.045797109603881836, + value: 50.37750244140625 + }, + { + envelope: 5, + time: 0.06858612596988678, + value: 51.42829513549805 + }, + { + envelope: 5, + time: 0.09713879972696304, + value: 52.4605827331543 + }, + { + envelope: 5, + time: 0.13203296065330505, + value: 53.46717071533203 + }, + { + envelope: 5, + time: 0.17381449043750763, + value: 54.438995361328125 + }, + { + envelope: 5, + time: 0.22292612493038177, + value: 55.36511993408203 + }, + { + envelope: 5, + time: 0.27961885929107666, + value: 56.23305892944336 + }, + { + envelope: 5, + time: 0.34385624527931213, + value: 57.029701232910156 + }, + { + envelope: 5, + time: 0.4152511656284332, + value: 57.74271774291992 + }, + { + envelope: 5, + time: 0.49307429790496826, + value: 58.362186431884766 + }, + { + envelope: 5, + time: 0.5763297080993652, + value: 58.88185501098633 + }, + { + envelope: 5, + time: 0.6638965010643005, + value: 59.29954147338867 + }, + { + envelope: 5, + time: 0.7546600699424744, + value: 59.6165657043457 + }, + { + envelope: 5, + time: 0.8476089239120483, + value: 59.83663558959961 + }, + { + envelope: 5, + time: 1, + value: 59.964595794677734 + } + ] + } + }, + Speed: { + NumberRange: [ + 0.0010000000474974513, + 0.0010000000474974513 + ] + }, + Tags: [ + "_VFX" + ], + Texture: "rbxassetid://77024587778878", + Transparency: { + NumberSequence: { + keypoints: [ + { + envelope: 0, + time: 0, + value: 1 + }, + { + envelope: 0, + time: 0.09989999979734421, + value: 0.75 + }, + { + envelope: 0, + time: 0.6000000238418579, + value: 0.75 + }, + { + envelope: 0, + time: 1, + value: 1 + } + ] + } + } + } +} diff --git a/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/init.meta.json5 b/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/init.meta.json5 new file mode 100644 index 00000000..017fe065 --- /dev/null +++ b/rojo-test/syncback-tests/UFOWave_matching/expected/TsunamiWave/init.meta.json5 @@ -0,0 +1,60 @@ +{ + className: "UnionOperation", + properties: { + Anchored: true, + AssetId: "http://www.roblox.com//asset/?id=212226088", + CFrame: [ + 801.715576171875, + 36.375, + 0.00000762939453125, + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1 + ], + CanCollide: false, + CanQuery: false, + CanTouch: false, + CastShadow: false, + Color: { + Color3uint8: [ + 48, + 12, + 71 + ] + }, + FormFactor: "Symmetric", + InitialSize: [ + 18.943885803222656, + 6.7999587059021, + 39.99999237060547 + ], + Size: [ + 49.43058776855469, + 72.75, + 260 + ], + UnscaledCofm: [ + -1.2760223150253296, + -0.917913019657135, + 0.000006928649327164749 + ], + UnscaledVolInertiaDiags: [ + 321672.25, + 343861.1875, + 37455.859375 + ], + UnscaledVolInertiaOffDiags: [ + 5263.43994140625, + 0.014713685028254986, + -0.0022491957060992718 + ], + UnscaledVolume: 2355.29296875, + UsePartColor: true + } +} diff --git a/rojo-test/syncback-tests/UFOWave_matching/expected/init.meta.json5 b/rojo-test/syncback-tests/UFOWave_matching/expected/init.meta.json5 new file mode 100644 index 00000000..36ad4609 --- /dev/null +++ b/rojo-test/syncback-tests/UFOWave_matching/expected/init.meta.json5 @@ -0,0 +1,52 @@ +{ + attributes: { + Rojo_Ref_PrimaryPart: "ServerStorage/UFOWave/TsunamiWave", + Speed: 10, + ZonesPassed: { + Float32: 0 + } + }, + className: "Model", + properties: { + ModelMeshCFrame: [ + -740.2847900390625, + -71.00001525878906, + -369.2483825683594, + -2.6309862732887268e-8, + 0, + -1.000003695487976, + 0, + 1, + 0, + 1.000003695487976, + 0, + -2.6309862732887268e-8 + ], + WorldPivotData: { + OptionalCFrame: { + orientation: [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ], + [ + 0, + 0, + 1 + ] + ], + position: [ + 801.715576171875, + 36.375, + 0.00000762939453125 + ] + } + } + } +} diff --git a/rojo-test/syncback-tests/UFOWave_matching/input.rbxm b/rojo-test/syncback-tests/UFOWave_matching/input.rbxm new file mode 100644 index 00000000..d51f387d Binary files /dev/null and b/rojo-test/syncback-tests/UFOWave_matching/input.rbxm differ diff --git a/src/snapshot/matching.rs b/src/snapshot/matching.rs index 4fba9b39..42f36918 100644 --- a/src/snapshot/matching.rs +++ b/src/snapshot/matching.rs @@ -739,6 +739,449 @@ mod tests { } } + /// Helper: builds a RojoTree from child snapshots (with properties). + fn make_tree_from_snapshots(children: Vec) -> (RojoTree, Vec) { + let root_snap = InstanceSnapshot { + snapshot_id: Ref::none(), + metadata: InstanceMetadata::default(), + name: Cow::Borrowed("DataModel"), + class_name: ustr("DataModel"), + properties: Default::default(), + children, + }; + let tree = RojoTree::new(root_snap); + let root_id = tree.get_root_id(); + let child_ids: Vec = tree.get_instance(root_id).unwrap().children().to_vec(); + (tree, child_ids) + } + + // ================================================================ + // Default-omission matching tests + // + // These reproduce the scenario where syncback strips default-valued + // properties from model files, creating an asymmetry between the + // snapshot (sparse) and tree (full properties from Studio). + // + // The matching must still pair instances correctly despite some + // snapshots missing properties that the tree has at their defaults. + // ================================================================ + + #[test] + fn reflection_database_has_expected_defaults() { + // Validates the reflection database has the defaults we rely on. + use rbx_dom_weak::types::Variant; + + let db = rbx_reflection_database::get().expect("reflection database should load"); + let texture = db.classes.get("Texture").expect("Texture class should exist"); + let face_default = texture + .default_properties + .get("Face") + .expect("Texture should have Face default"); + match face_default { + Variant::Enum(e) => assert_eq!(e.to_u32(), 5, "Face default should be Front (5)"), + other => panic!("Face default should be Enum, got {:?}", other), + } + + let part = db.classes.get("Part").expect("Part class should exist"); + let anchored_default = part + .default_properties + .get("Anchored") + .expect("Part should have Anchored default"); + assert_eq!( + anchored_default, + &Variant::Bool(false), + "Anchored default should be false" + ); + } + + #[test] + fn texture_face_six_instances_default_omitted() { + // Exact reproduction of the Texture Face bug: + // 6 Textures all named "Texture", each with a different Face. + // Snapshot side: 5 have explicit Face, 1 omits Face (Front=default). + // Tree side: all 6 have explicit Face (populated from Studio). + // + // Without the default-aware fix, the snapshot without Face scores + // equally against all tree instances, steals one by iteration order, + // and displaces the correct match. + use rbx_dom_weak::types::{Enum, Variant}; + + let faces = [ + ("Top", Enum::from_u32(1)), + ("Right", Enum::from_u32(0)), + ("Left", Enum::from_u32(3)), + ("Bottom", Enum::from_u32(4)), + ("Back", Enum::from_u32(2)), + ]; + let face_front = Enum::from_u32(5); + + let shared_props = vec![("Transparency", Variant::Float32(0.65))]; + + // Snapshots: 5 with explicit Face + 1 without (Front default) + let mut snaps: Vec = faces + .iter() + .map(|(_, face_enum)| { + let mut props = shared_props.clone(); + props.push(("Face", Variant::Enum(face_enum.clone()))); + make_snapshot_with_props("Texture", "Texture", props) + }) + .collect(); + // The Front one: NO Face property (default omitted by syncback) + snaps.push(make_snapshot_with_props( + "Texture", + "Texture", + shared_props.clone(), + )); + + // Tree: all 6 with explicit Face (Studio always has all properties) + let mut tree_children: Vec = faces + .iter() + .map(|(_, face_enum)| { + let mut props = shared_props.clone(); + props.push(("Face", Variant::Enum(face_enum.clone()))); + make_snapshot_with_props("Texture", "Texture", props) + }) + .collect(); + tree_children.push(make_snapshot_with_props( + "Texture", + "Texture", + { + let mut props = shared_props.clone(); + props.push(("Face", Variant::Enum(face_front.clone()))); + props + }, + )); + + let (tree, tree_refs) = make_tree_from_snapshots(tree_children); + let result = match_forward(snaps, &tree_refs, &tree); + + assert_eq!(result.matched.len(), 6); + assert!(result.unmatched_snapshot.is_empty()); + assert!(result.unmatched_tree.is_empty()); + + // Verify each pair has matching Face values. + for (snap, tree_ref) in &result.matched { + let inst = tree.get_instance(*tree_ref).unwrap(); + let snap_face = snap.properties.get(&ustr("Face")); + let tree_face = inst.properties().get(&ustr("Face")).unwrap(); + + match snap_face { + Some(sv) => { + assert!( + variant_eq(sv, tree_face), + "Face mismatch: snap={:?}, tree={:?}", + sv, + tree_face + ); + } + None => { + // Snapshot omitted Face = must match tree Face=Front (5) + assert!( + variant_eq(tree_face, &Variant::Enum(face_front.clone())), + "Snapshot without Face should match Front, got {:?}", + tree_face + ); + } + } + } + } + + #[test] + fn texture_face_twelve_instances_two_groups() { + // Exact reproduction of the user's real scenario: + // 12 Textures split into two groups of 6 by TextureContent. + // Each group covers all 6 faces, with Front omitted (default). + // Tree has all 12 with explicit Face. + use rbx_dom_weak::types::{Enum, Variant}; + + let face_values: Vec = vec![ + Enum::from_u32(1), // Top + Enum::from_u32(0), // Right + Enum::from_u32(3), // Left + Enum::from_u32(4), // Bottom + Enum::from_u32(2), // Back + ]; + let face_front = Enum::from_u32(5); + + let mut snaps = Vec::new(); + let mut tree_children = Vec::new(); + + for group_transparency in [0.6_f32, 0.65_f32] { + // 5 with explicit Face + for face in &face_values { + snaps.push(make_snapshot_with_props( + "Texture", + "Texture", + vec![ + ("Transparency", Variant::Float32(group_transparency)), + ("Face", Variant::Enum(face.clone())), + ], + )); + tree_children.push(make_snapshot_with_props( + "Texture", + "Texture", + vec![ + ("Transparency", Variant::Float32(group_transparency)), + ("Face", Variant::Enum(face.clone())), + ], + )); + } + // 1 without Face (Front default) + snaps.push(make_snapshot_with_props( + "Texture", + "Texture", + vec![("Transparency", Variant::Float32(group_transparency))], + )); + tree_children.push(make_snapshot_with_props( + "Texture", + "Texture", + vec![ + ("Transparency", Variant::Float32(group_transparency)), + ("Face", Variant::Enum(face_front.clone())), + ], + )); + } + + // Reverse tree order to make it harder + tree_children.reverse(); + + let (tree, tree_refs) = make_tree_from_snapshots(tree_children); + let result = match_forward(snaps, &tree_refs, &tree); + + assert_eq!(result.matched.len(), 12); + assert!(result.unmatched_snapshot.is_empty()); + assert!(result.unmatched_tree.is_empty()); + + for (snap, tree_ref) in &result.matched { + let inst = tree.get_instance(*tree_ref).unwrap(); + let snap_face = snap.properties.get(&ustr("Face")); + let tree_face = inst.properties().get(&ustr("Face")).unwrap(); + + // Transparency must match (distinguishes the two groups) + let snap_t = snap.properties.get(&ustr("Transparency")).unwrap(); + let tree_t = inst.properties().get(&ustr("Transparency")).unwrap(); + assert!( + variant_eq(snap_t, tree_t), + "Group mismatch: snap Transparency={:?}, tree={:?}", + snap_t, + tree_t + ); + + match snap_face { + Some(sv) => assert!( + variant_eq(sv, tree_face), + "Face mismatch: snap={:?}, tree={:?}", + sv, + tree_face + ), + None => assert!( + variant_eq(tree_face, &Variant::Enum(face_front.clone())), + "Omitted Face should match Front, got {:?}", + tree_face + ), + } + } + } + + #[test] + fn part_anchored_default_omitted() { + // 4 Parts named "Block": 3 have Anchored=true (explicit), + // 1 omits Anchored (false is the default). Tree has all 4. + use rbx_dom_weak::types::Variant; + + let mut snaps = Vec::new(); + let mut tree_children = Vec::new(); + + for i in 0..3 { + let props = vec![ + ("Transparency", Variant::Float32(i as f32 * 0.1)), + ("Anchored", Variant::Bool(true)), + ]; + snaps.push(make_snapshot_with_props("Block", "Part", props.clone())); + tree_children.push(make_snapshot_with_props("Block", "Part", props)); + } + // The one with default Anchored=false: snapshot omits it + snaps.push(make_snapshot_with_props( + "Block", + "Part", + vec![("Transparency", Variant::Float32(0.3))], + )); + tree_children.push(make_snapshot_with_props( + "Block", + "Part", + vec![ + ("Transparency", Variant::Float32(0.3)), + ("Anchored", Variant::Bool(false)), + ], + )); + + tree_children.reverse(); + + let (tree, tree_refs) = make_tree_from_snapshots(tree_children); + let result = match_forward(snaps, &tree_refs, &tree); + + assert_eq!(result.matched.len(), 4); + + for (snap, tree_ref) in &result.matched { + let inst = tree.get_instance(*tree_ref).unwrap(); + let snap_t = snap.properties.get(&ustr("Transparency")).unwrap(); + let tree_t = inst.properties().get(&ustr("Transparency")).unwrap(); + assert!( + variant_eq(snap_t, tree_t), + "Transparency mismatch: snap={:?}, tree={:?}", + snap_t, + tree_t + ); + } + } + + #[test] + fn multiple_defaults_omitted_all_at_once() { + // Tree instances have MANY extra properties all at their class defaults. + // Snapshots omit all of them. The match should not be thrown off. + use rbx_dom_weak::types::Variant; + + // Two sparse snapshots differing only by Transparency + let snap_a = make_snapshot_with_props( + "Wall", + "Part", + vec![("Transparency", Variant::Float32(0.0))], + ); + let snap_b = make_snapshot_with_props( + "Wall", + "Part", + vec![("Transparency", Variant::Float32(1.0))], + ); + + // Tree has Transparency + many known Part defaults from Studio. + // These are all at their default values, so count_own_diffs should + // skip them (not inflate match cost). + let extra_defaults: Vec<(&str, Variant)> = vec![ + ("Anchored", Variant::Bool(false)), + ("CastShadow", Variant::Bool(true)), + ("CanCollide", Variant::Bool(true)), + ("CanTouch", Variant::Bool(true)), + ("Locked", Variant::Bool(false)), + ("Massless", Variant::Bool(false)), + ]; + + let mut tree_props_a = vec![("Transparency", Variant::Float32(0.0))]; + tree_props_a.extend(extra_defaults.iter().cloned()); + let mut tree_props_b = vec![("Transparency", Variant::Float32(1.0))]; + tree_props_b.extend(extra_defaults.iter().cloned()); + + // Tree in reversed order + let (tree, tree_refs) = make_tree_from_snapshots(vec![ + make_snapshot_with_props("Wall", "Part", tree_props_b), + make_snapshot_with_props("Wall", "Part", tree_props_a), + ]); + let result = match_forward(vec![snap_a, snap_b], &tree_refs, &tree); + + assert_eq!(result.matched.len(), 2, "Both should match"); + + for (snap, tree_ref) in &result.matched { + let inst = tree.get_instance(*tree_ref).unwrap(); + let snap_t = snap.properties.get(&ustr("Transparency")).unwrap(); + let tree_t = inst.properties().get(&ustr("Transparency")).unwrap(); + assert!( + variant_eq(snap_t, tree_t), + "Part with 6 extra default props mismatched: snap={:?}, tree={:?}", + snap_t, + tree_t + ); + } + } + + #[test] + fn ten_ambiguous_textures_stress() { + // 10 Textures: 8 with unique Face values (some repeated across + // groups), 2 with Front omitted. Two distinguishing groups by + // Transparency. The matcher must handle this without mis-pairing. + use rbx_dom_weak::types::{Enum, Variant}; + + let faces_group_a = [ + Some(Enum::from_u32(1)), // Top + Some(Enum::from_u32(0)), // Right + Some(Enum::from_u32(3)), // Left + None, // Front (default, omitted) + Some(Enum::from_u32(4)), // Bottom + ]; + let faces_group_b = [ + Some(Enum::from_u32(2)), // Back + Some(Enum::from_u32(4)), // Bottom + None, // Front (default, omitted) + Some(Enum::from_u32(1)), // Top + Some(Enum::from_u32(3)), // Left + ]; + let face_front = Enum::from_u32(5); + + let mut snaps = Vec::new(); + let mut tree_children = Vec::new(); + + for (group_t, faces) in [(0.5_f32, &faces_group_a[..]), (0.8_f32, &faces_group_b[..])] { + for face_opt in faces { + let mut snap_props = vec![("Transparency", Variant::Float32(group_t))]; + let mut tree_props = vec![("Transparency", Variant::Float32(group_t))]; + + if let Some(face) = face_opt { + snap_props.push(("Face", Variant::Enum(face.clone()))); + tree_props.push(("Face", Variant::Enum(face.clone()))); + } else { + // Snapshot omits Face; tree has Front + tree_props.push(("Face", Variant::Enum(face_front.clone()))); + } + + snaps.push(make_snapshot_with_props("Texture", "Texture", snap_props)); + tree_children.push(make_snapshot_with_props( + "Texture", + "Texture", + tree_props, + )); + } + } + + // Shuffle tree order (reverse) + tree_children.reverse(); + + let (tree, tree_refs) = make_tree_from_snapshots(tree_children); + let result = match_forward(snaps, &tree_refs, &tree); + + assert_eq!(result.matched.len(), 10); + assert!(result.unmatched_snapshot.is_empty()); + assert!(result.unmatched_tree.is_empty()); + + for (snap, tree_ref) in &result.matched { + let inst = tree.get_instance(*tree_ref).unwrap(); + + // Transparency must match (group discriminator) + let snap_t = snap.properties.get(&ustr("Transparency")).unwrap(); + let tree_t = inst.properties().get(&ustr("Transparency")).unwrap(); + assert!( + variant_eq(snap_t, tree_t), + "Group mismatch: snap={:?}, tree={:?}", + snap_t, + tree_t + ); + + // Face must match + let snap_face = snap.properties.get(&ustr("Face")); + let tree_face = inst.properties().get(&ustr("Face")).unwrap(); + match snap_face { + Some(sv) => assert!( + variant_eq(sv, tree_face), + "Face mismatch: snap={:?}, tree={:?}", + sv, + tree_face + ), + None => assert!( + variant_eq(tree_face, &Variant::Enum(face_front.clone())), + "Omitted Face should match Front, got {:?}", + tree_face + ), + } + } + } + #[test] fn depth_limit_completes_quickly() { // Deeply nested tree (depth=6) with same-named instances at each level.