From d45b1c5b948d3c5116006144ad60270a92a8459b Mon Sep 17 00:00:00 2001 From: Joe Date: Tue, 17 Feb 2026 08:07:46 -0600 Subject: [PATCH] Enhance property comparison and caching mechanisms - Introduced a new function `getClassComparisonKeys` in `rbx_dom_lua/init.lua` to pre-compute and cache comparison keys for classes, improving efficiency in property matching. - Updated the `Matching` module in `Reconciler/matching.lua` to utilize pre-computed caches for virtual and Studio instances, optimizing the reconciliation process. - Enhanced handling of excluded properties and types during comparisons, ensuring accurate matching while reducing unnecessary computations. These changes significantly improve the performance and accuracy of property reconciliation, addressing previous inefficiencies in the matching logic. --- plugin/rbx_dom_lua/init.lua | 109 ++++- plugin/src/Reconciler/matching.lua | 649 ++++++++++++++++++----------- 2 files changed, 516 insertions(+), 242 deletions(-) diff --git a/plugin/rbx_dom_lua/init.lua b/plugin/rbx_dom_lua/init.lua index ed6ab91d..c18cf077 100644 --- a/plugin/rbx_dom_lua/init.lua +++ b/plugin/rbx_dom_lua/init.lua @@ -1,11 +1,8 @@ local database = require(script.database) local Error = require(script.Error) local PropertyDescriptor = require(script.PropertyDescriptor) +local EncodedValue = require(script.EncodedValue) --- Returns a class descriptor with all property names and metadata (including inherited ones) --- 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 } } = {} @@ -43,6 +40,107 @@ local function findDefaultProperties(className: string): { [string]: any } return defaults end +-- Property names excluded from comparison (handled separately). +local EXCLUDED_PROP_NAMES: { [string]: boolean } = { + Tags = true, + Attributes = true, +} + +-- Encoded value type keys that cannot be compared during matching. +local EXCLUDED_ENCODED_TYPES: { [string]: boolean } = { + Ref = true, + UniqueId = true, + SharedString = true, + Region3 = true, +} + +-- Scriptabilities that allow reading from Studio instances. +local READABLE_SCRIPTABILITY: { [string]: boolean } = { + ReadWrite = true, + Read = true, +} + +-- Cache for class comparison keys (propNames + decoded defaults). +-- Computed once per ClassName, lives for the plugin's lifetime. +local classComparisonKeysCache: { [string]: any } = {} + +-- Returns a pre-computed comparison key set for a class: +-- propNames: ordered array of comparable property names +-- propNameSet: O(1) lookup set of the same names +-- defaults: map of propName → decoded native default value +-- +-- Filtered to: canonical, scriptable (readable), serializable, and +-- decodable. Excludes Tags, Attributes, Ref, UniqueId, SharedString. +-- All defaults are pre-decoded to native Roblox values. +local function getClassComparisonKeys(className: string) + local cached = classComparisonKeysCache[className] + if cached then + return cached + end + + local propNames: { string } = {} + local propNameSet: { [string]: boolean } = {} + local seenProps: { [string]: boolean } = {} + + -- Walk the class hierarchy for property metadata + local currentClassName: string? = className + repeat + local currentClass = database.Classes[currentClassName] + if currentClass == nil then + break + end + + for propertyName, propertyData in currentClass.Properties do + if seenProps[propertyName] then + continue + end + seenProps[propertyName] = true + + if EXCLUDED_PROP_NAMES[propertyName] then + continue + end + if not propertyData.Kind or not propertyData.Kind.Canonical then + continue + end + if not READABLE_SCRIPTABILITY[propertyData.Scriptability] then + continue + end + if propertyData.Kind.Canonical.Serialization == "DoesNotSerialize" then + continue + end + + table.insert(propNames, propertyName) + propNameSet[propertyName] = true + end + + currentClassName = currentClass.Superclass + until currentClassName == nil + + -- Pre-decode defaults for every comparable property + local defaults: { [string]: any } = {} + local encodedDefaults = findDefaultProperties(className) + for _, propName in propNames do + local encoded = encodedDefaults[propName] + if encoded then + local ty = next(encoded) + if not EXCLUDED_ENCODED_TYPES[ty] then + local pcallOk, decodeOk, decoded = pcall(EncodedValue.decode, encoded) + if pcallOk and decodeOk and decoded ~= nil then + defaults[propName] = decoded + end + end + end + end + + local result = table.freeze({ + propNames = table.freeze(propNames), + propNameSet = table.freeze(propNameSet), + defaults = table.freeze(defaults), + }) + classComparisonKeysCache[className] = result + return result +end + local function findClassDescriptor(className) local classData = database.Classes[className] if classData == nil then @@ -149,6 +247,7 @@ return { findCanonicalPropertyDescriptor = findCanonicalPropertyDescriptor, findClassDescriptor = findClassDescriptor, findDefaultProperties = findDefaultProperties, + getClassComparisonKeys = getClassComparisonKeys, Error = Error, - EncodedValue = require(script.EncodedValue), + EncodedValue = EncodedValue, } diff --git a/plugin/src/Reconciler/matching.lua b/plugin/src/Reconciler/matching.lua index 45d8823f..bb5cd58c 100644 --- a/plugin/src/Reconciler/matching.lua +++ b/plugin/src/Reconciler/matching.lua @@ -11,6 +11,10 @@ The change count for a pair = how many things the reconciler would need to touch to turn instance A into instance B, including the entire subtree. + + Performance: all property decoding and Studio reads are pre-computed + BEFORE the N×M scoring loop. The inner comparison is pure table + lookups + trueEquals, with zero pcall/decode in the hot path. ]] local Packages = script.Parent.Parent.Parent.Packages @@ -22,13 +26,337 @@ local UNMATCHED_PENALTY = 10000 local Matching = {} +-- ================================================================ +-- Pre-computation helpers (called once per instance per group) +-- ================================================================ + --[[ - Entry point. Match virtual children to Studio children under a parent. + Pre-decode all virtual instance properties into native Roblox values. + Called once per virtual instance, reused across all M studio comparisons. + + Returns: { + props: { [propName]: decodedValue }, + extraProps: { propName, ... } | nil, -- props not in classKeys (rare) + tags: { [tag]: true } | nil, + attrs: { [key]: value } | nil, + childCount: number, + } +]] +function Matching._cacheVirtual(vInst: any, classKeys: any) + local decoded: { [string]: any } = {} + local extraProps: { string }? = nil + local vProps = vInst.Properties + + if vProps then + for propName, encodedValue in vProps do + if propName == "Tags" or propName == "Attributes" then + continue + end + + local ty = next(encodedValue) + if ty == "Ref" then + continue + end + + local pcallOk, decodeOk, value = pcall(RbxDom.EncodedValue.decode, encodedValue) + if pcallOk and decodeOk and value ~= nil then + decoded[propName] = value + if not classKeys.propNameSet[propName] then + if not extraProps then + extraProps = {} + end + table.insert(extraProps, propName) + end + end + end + end + + -- Pre-decode tags into a set + local decodedTags: { [string]: boolean }? = nil + if vProps and vProps.Tags then + local ok, tags = RbxDom.EncodedValue.decode(vProps.Tags) + if ok and type(tags) == "table" then + decodedTags = {} + for _, tag in tags do + decodedTags[tag] = true + end + end + end + + -- Pre-decode attributes + local decodedAttrs: { [string]: any }? = nil + if vProps and vProps.Attributes then + local ok, attrs = RbxDom.EncodedValue.decode(vProps.Attributes) + if ok and type(attrs) == "table" then + decodedAttrs = attrs + end + end + + return { + props = decoded, + extraProps = extraProps, + tags = decodedTags, + attrs = decodedAttrs, + childCount = if vInst.Children then #vInst.Children else 0, + } +end + +--[[ + Pre-read all comparable properties from a Studio instance. + Called once per Studio instance, reused across all N virtual comparisons. + + Returns: { + props: { [propName]: value }, + readable: { [propName]: true }, -- tracks which reads succeeded + tags: { [tag]: true } | nil, + attrs: { [key]: value } | nil, + children: { Instance } | nil, + childCount: number, + } +]] +function Matching._cacheStudio(studioInstance: Instance, classKeys: any, extraPropNames: { string }?) + local props: { [string]: any } = {} + + -- Read all comparison-key properties + for _, propName in classKeys.propNames do + local ok, value = pcall(function() + return (studioInstance :: any)[propName] + end) + if ok and value ~= nil then + props[propName] = value + end + end + + -- Read any extra properties the virtual side has (not in classKeys) + if extraPropNames then + for _, propName in extraPropNames do + if props[propName] ~= nil then + continue + end + local ok, value = pcall(function() + return (studioInstance :: any)[propName] + end) + if ok and value ~= nil then + props[propName] = value + end + end + end + + -- Read tags + local tags: { [string]: boolean }? = nil + local tagsOk, studioTags = pcall(function() + return studioInstance:GetTags() + end) + if tagsOk then + tags = {} + for _, tag in studioTags do + tags[tag] = true + end + end + + -- Read attributes + local attrs: { [string]: any }? = nil + local attrsOk, studioAttrs = pcall(function() + return studioInstance:GetAttributes() + end) + if attrsOk then + attrs = studioAttrs + end + + -- Read children + local children: { Instance }? = nil + local childCount = 0 + local childOk, studioKids = pcall(function() + return studioInstance:GetChildren() + end) + if childOk then + children = studioKids + childCount = #studioKids + end + + return { + props = props, + tags = tags, + attrs = attrs, + children = children, + childCount = childCount, + } +end + +-- ================================================================ +-- Hot-path scoring (ZERO pcall, ZERO decode, ZERO reflection lookup) +-- ================================================================ + +--[[ + Count own property diffs between pre-computed virtual and Studio caches. + Single loop over the class's comparable properties. Pure table lookups + and trueEquals calls -- no pcall, no decode, no reflection queries. +]] +function Matching._countOwnDiffsCached(vCache: any, sCache: any, classKeys: any): number + local cost = 0 + local vProps = vCache.props + local sProps = sCache.props + local defaults = classKeys.defaults + + -- Single loop over all comparable property names for this class. + -- For each property, the effective virtual value is the explicit value + -- if present, otherwise the class default (syncback strips defaults). + for _, propName in classKeys.propNames do + local vVal = vProps[propName] + if vVal == nil then + vVal = defaults[propName] + end + + if not trueEquals(vVal, sProps[propName]) then + cost += 1 + end + end + + -- Handle virtual properties not in the class comparison set (rare: + -- unknown class or properties from a newer API version). + if vCache.extraProps then + for _, propName in vCache.extraProps do + if not trueEquals(vProps[propName], sProps[propName]) then + cost += 1 + end + end + end + + -- Tags: symmetric set diff (pre-computed sets) + local vTags = vCache.tags + local sTags = sCache.tags + if vTags and sTags then + for tag in vTags do + if not sTags[tag] then + cost += 1 + end + end + for tag in sTags do + if not vTags[tag] then + cost += 1 + end + end + end + + -- Attributes: symmetric map diff (pre-decoded / pre-read) + local vAttrs = vCache.attrs + local sAttrs = sCache.attrs + if vAttrs and sAttrs then + for key, vVal in vAttrs do + if not trueEquals(vVal, sAttrs[key]) then + cost += 1 + end + end + for key in sAttrs do + if vAttrs[key] == nil then + cost += 1 + end + end + end + + -- Children count diff + if vCache.childCount ~= sCache.childCount then + cost += 1 + end + + return cost +end + +-- ================================================================ +-- Recursive scoring (children matching) +-- ================================================================ + +--[[ + Compute the recursive children cost for a matched pair. + Uses sCache.children (pre-read) to avoid repeated GetChildren(). +]] +function Matching._computeChildrenCost( + vInst: any, + sCache: any, + virtualInstances: { [string]: any }, + bestSoFar: number +): number + local vChildren = vInst.Children + local studioKids = sCache.children + + if not vChildren or #vChildren == 0 then + if studioKids then + return #studioKids * UNMATCHED_PENALTY + end + return 0 + end + + if not studioKids then + return 0 + end + + local validVChildren: { string } = {} + for _, childId in vChildren do + if virtualInstances[childId] then + table.insert(validVChildren, childId) + end + end + + local childResult = Matching.matchChildren(validVChildren, studioKids, virtualInstances) + + local cost = 0 + for _, pair in childResult.matched do + cost += Matching._computeChangeCount( + pair.virtualId, pair.studioInstance, virtualInstances, bestSoFar - cost + ) + if cost >= bestSoFar then + return cost + end + end + + cost += (#childResult.unmatchedVirtual + #childResult.unmatchedStudio) * UNMATCHED_PENALTY + return cost +end + +--[[ + Compute total change count between a virtual instance and a Studio instance. + Includes own property diffs + recursive children cost. + Early-exits if cost reaches or exceeds bestSoFar. + + Used for recursive 1:1 scoring (matched child pairs). For the N×M + ambiguous-group loop, matchChildren uses pre-computed caches directly. +]] +function Matching._computeChangeCount( + virtualId: string, + studioInstance: Instance, + virtualInstances: { [string]: any }, + bestSoFar: number +): number + local vInst = virtualInstances[virtualId] + if not vInst then + return UNMATCHED_PENALTY + end + + -- Build caches inline (1:1 call, no N×M savings needed here, + -- but classKeys is still cached per class so it's free). + local classKeys = RbxDom.getClassComparisonKeys(vInst.ClassName) + local vCache = Matching._cacheVirtual(vInst, classKeys) + local sCache = Matching._cacheStudio(studioInstance, classKeys, vCache.extraProps) + + local cost = Matching._countOwnDiffsCached(vCache, sCache, classKeys) + if cost >= bestSoFar then + return cost + end + + cost += Matching._computeChildrenCost(vInst, sCache, virtualInstances, bestSoFar - cost) + return cost +end + +-- ================================================================ +-- Entry point +-- ================================================================ + +--[[ + Match virtual children to Studio children under a parent. virtualChildren: array of virtual instance IDs studioChildren: array of Studio Instance objects virtualInstances: map of virtual ID → virtual instance data - instanceMap: bidirectional ID ↔ Instance mapping Returns {matched, unmatchedVirtual, unmatchedStudio} ]] @@ -44,9 +372,6 @@ function Matching.matchChildren( -- ============================================================ -- Fast-path 1: Ref pin (confirmed identity, highest priority) -- ============================================================ - -- Scan virtual parent for Ref-typed properties that point to - -- virtual children. If the corresponding Studio parent property - -- points to a Studio child, pin them as a confirmed match. -- (Ref pins are handled implicitly during hydration -- the parent -- is already matched, and its Ref properties resolve via instanceMap -- after children are matched. For initial hydration the instanceMap @@ -155,8 +480,64 @@ function Matching.matchChildren( continue end - -- Build all (A, B) pairs with change counts. - -- Pairs are built in child order (virtual outer, studio inner). + -- -------------------------------------------------------- + -- Pre-compute caches for this ambiguous group. + -- All instances share the same ClassName (grouped by key). + -- -------------------------------------------------------- + local firstVInst = virtualInstances[remainingVirtual[vIndices[1]]] + if not firstVInst then + continue + end + local classKeys = RbxDom.getClassComparisonKeys(firstVInst.ClassName) + + -- Pre-decode all virtual instances in this group + local vCaches: { [number]: any } = {} + local allExtraProps: { [string]: boolean }? = nil + for _, vi in vIndices do + if matchedV2[vi] then + continue + end + local vInst = virtualInstances[remainingVirtual[vi]] + if not vInst then + continue + end + local vCache = Matching._cacheVirtual(vInst, classKeys) + vCaches[vi] = vCache + -- Collect extra prop names across all virtuals for studio reads + if vCache.extraProps then + if not allExtraProps then + allExtraProps = {} + end + for _, propName in vCache.extraProps do + allExtraProps[propName] = true + end + end + end + + -- Convert extra props set to array for studio caching + local extraPropNamesArray: { string }? = nil + if allExtraProps then + extraPropNamesArray = {} + for propName in allExtraProps do + table.insert(extraPropNamesArray, propName) + end + end + + -- Pre-read all Studio instances in this group + local sCaches: { [number]: any } = {} + for _, si in sIndices do + if not matchedS2[si] then + sCaches[si] = Matching._cacheStudio( + remainingStudio[si], classKeys, extraPropNamesArray + ) + end + end + + -- -------------------------------------------------------- + -- Score all (A, B) pairs using pre-computed caches. + -- Own diffs = pure table lookups (FAST). + -- Children cost = recursive (only if own diffs < bestSoFar). + -- -------------------------------------------------------- local pairs: { { vi: number, si: number, cost: number, idx: number } } = {} local pairIdx = 0 local bestSoFar = math.huge @@ -165,13 +546,35 @@ function Matching.matchChildren( if matchedV2[vi] then continue end + local vCache = vCaches[vi] + if not vCache then + continue + end + for _, si in sIndices do if matchedS2[si] then continue end + local sCache = sCaches[si] + if not sCache then + continue + end + pairIdx += 1 - local cost = - Matching._computeChangeCount(remainingVirtual[vi], remainingStudio[si], virtualInstances, bestSoFar) + + -- Own diffs: pure table lookups + trueEquals + local cost = Matching._countOwnDiffsCached(vCache, sCache, classKeys) + + if cost < bestSoFar then + -- Recursive children cost + local vInst = virtualInstances[remainingVirtual[vi]] + if vInst then + cost += Matching._computeChildrenCost( + vInst, sCache, virtualInstances, bestSoFar - cost + ) + end + end + table.insert(pairs, { vi = vi, si = si, cost = cost, idx = pairIdx }) if cost < bestSoFar then bestSoFar = cost @@ -213,234 +616,6 @@ function Matching.matchChildren( } end ---[[ - Compute total change count between a virtual instance and a Studio instance. - Includes own property diffs + recursive children cost. - Early-exits if cost reaches or exceeds bestSoFar. -]] -function Matching._computeChangeCount( - virtualId: string, - studioInstance: Instance, - virtualInstances: { [string]: any }, - bestSoFar: number -): number - local vInst = virtualInstances[virtualId] - if not vInst then - return UNMATCHED_PENALTY - end - - -- Cheap: own property diffs - local cost = Matching._countOwnDiffs(vInst, studioInstance) - if cost >= bestSoFar then - return cost - end - - -- Expensive: recursive children matching - local vChildren = vInst.Children - if not vChildren or #vChildren == 0 then - -- No virtual children: any Studio children are unmatched - local ok, studioKids = pcall(function() - return studioInstance:GetChildren() - end) - if ok then - cost += #studioKids * UNMATCHED_PENALTY - end - return cost - end - - local ok, studioKids = pcall(function() - return studioInstance:GetChildren() - end) - if not ok then - return cost - end - - -- Filter valid virtual children - local validVChildren: { string } = {} - for _, childId in vChildren do - if virtualInstances[childId] then - table.insert(validVChildren, childId) - end - end - - -- Recursive: match children and sum costs - local childResult = Matching.matchChildren(validVChildren, studioKids, virtualInstances) - - -- Matched children: recursively score each pair - for _, pair in childResult.matched do - cost += Matching._computeChangeCount(pair.virtualId, pair.studioInstance, virtualInstances, bestSoFar - cost) - if cost >= bestSoFar then - return cost - end - end - - -- Unmatched children: penalty per instance - local unmatchedCount = #childResult.unmatchedVirtual + #childResult.unmatchedStudio - cost += unmatchedCount * UNMATCHED_PENALTY - - return cost -end - ---[[ - Count own property diffs between a virtual instance and Studio instance. - Each differing property = +1. Tags and Attributes counted granularly. -]] -function Matching._countOwnDiffs(vInst: any, studioInstance: Instance): number - local cost = 0 - - -- Compare properties from the virtual side - local vProps = vInst.Properties - if vProps then - for propName, encodedValue in vProps do - -- Skip Tags and Attributes here (counted separately below) - if propName == "Tags" or propName == "Attributes" then - continue - end - - -- Try to decode the virtual value and compare to Studio - local ty = next(encodedValue) - if ty == "Ref" then - -- Ref properties: can't easily compare during hydration - -- (instanceMap not yet populated). Skip for now. - continue - end - - -- Decode the encoded value to a native Roblox type - local decodeOk, decodedValue = RbxDom.EncodedValue.decode(encodedValue) - if not decodeOk then - -- Can't decode: assume different (conservative) - cost += 1 - continue - end - - -- Read the Studio property - local readOk, studioValue = pcall(function() - return (studioInstance :: any)[propName] - end) - if not readOk then - -- Can't read: assume different - cost += 1 - continue - end - - -- Compare values using the same equality check as diff.lua - if not trueEquals(decodedValue, studioValue) then - cost += 1 - end - 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 - local decodeOk, decodedTags = RbxDom.EncodedValue.decode(vTags) - if decodeOk and type(decodedTags) == "table" then - local readOk, studioTags = pcall(function() - return studioInstance:GetTags() - end) - if readOk then - local vSet: { [string]: boolean } = {} - for _, tag in decodedTags do - vSet[tag] = true - end - local sSet: { [string]: boolean } = {} - for _, tag in studioTags do - sSet[tag] = true - end - -- Count symmetric difference - for tag in vSet do - if not sSet[tag] then - cost += 1 - end - end - for tag in sSet do - if not vSet[tag] then - cost += 1 - end - end - end - end - end - - -- Attributes: count individual adds/removes/changes - local vAttrs = vInst.Properties and vInst.Properties.Attributes - if vAttrs then - local decodeOk, decodedAttrs = RbxDom.EncodedValue.decode(vAttrs) - if decodeOk and type(decodedAttrs) == "table" then - local readOk, studioAttrs = pcall(function() - return studioInstance:GetAttributes() - end) - if readOk then - -- Count diffs in attribute maps - for key, vVal in decodedAttrs do - local sVal = studioAttrs[key] - if sVal == nil or not trueEquals(vVal, sVal) then - cost += 1 - end - end - for key in studioAttrs do - if decodedAttrs[key] == nil then - cost += 1 - end - end - end - end - end - - -- Children count diff - local vChildCount = if vInst.Children then #vInst.Children else 0 - local readOk, studioChildCount = pcall(function() - return #studioInstance:GetChildren() - end) - if readOk and vChildCount ~= studioChildCount then - cost += 1 - end - - return cost -end - --[[ Remove items from an array by index set (removes in reverse order). ]]