Add default properties caching and update matching logic

- Implemented a caching mechanism for default properties per class in `rbx_dom_lua/init.lua`, allowing for efficient retrieval of encoded default values, including inherited defaults.
- Updated the matching logic in `Reconciler/matching.lua` to account for properties present only on the Studio side, ensuring accurate comparisons against default values during reconciliation.
- Added new test cases to validate the handling of default-omitted properties, enhancing the robustness of the reconciliation process.

These changes improve the efficiency and accuracy of property matching, addressing issues related to default values in the reconciliation workflow.
This commit is contained in:
Joe 2026-02-17 07:52:29 -06:00
parent e54f5ef4e3
commit 9c6966acff
30 changed files with 1648 additions and 0 deletions

View file

@ -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),
}

View file

@ -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

View file

@ -0,0 +1,3 @@
{
className: "UIAspectRatioConstraint"
}

View file

@ -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
]
]
}
}
}

View file

@ -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
}
}

View file

@ -0,0 +1,6 @@
{
className: "UIStroke",
properties: {
Thickness: 2
}
}

View file

@ -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
}
}

View file

@ -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
]
]
}
}
}

View file

@ -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"
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -0,0 +1,15 @@
{
className: "Texture",
properties: {
Color3: [
0.41568630933761597,
0,
1
],
Face: "Top",
StudsPerTileU: 200,
StudsPerTileV: 200,
TextureContent: "rbxassetid://10025668350",
Transparency: 0.6000000238418579
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -0,0 +1,15 @@
{
className: "Texture",
name: "Texture",
properties: {
Color3: [
0.41568630933761597,
0,
1
],
StudsPerTileU: 200,
StudsPerTileV: 200,
TextureContent: "rbxassetid://10025668350",
Transparency: 0.6000000238418579
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -0,0 +1,15 @@
{
className: "Texture",
name: "Texture",
properties: {
Color3: [
0.40000003576278687,
0,
1
],
StudsPerTileU: 200,
StudsPerTileV: 200,
TextureContent: "rbxassetid://10023635072",
Transparency: 0.6499999761581421
}
}

View file

@ -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
}
}

View file

@ -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
}
]
}
}
}
}

View file

@ -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
}
]
}
}
}
}

View file

@ -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
}
]
}
}
}
}

View file

@ -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
}
}

View file

@ -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
]
}
}
}
}

Binary file not shown.

View file

@ -739,6 +739,449 @@ mod tests {
}
}
/// Helper: builds a RojoTree from child snapshots (with properties).
fn make_tree_from_snapshots(children: Vec<InstanceSnapshot>) -> (RojoTree, Vec<Ref>) {
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<Ref> = 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<InstanceSnapshot> = 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<InstanceSnapshot> = 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<Enum> = 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.