mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-24 15:01:38 +00:00
Implement new recursive change-count matching algorithm for instance reconciliation
- Introduced a comprehensive rewrite of the matching algorithm, replacing the previous multi-pass approach with a recursive change-count scoring function and greedy assignment strategy. - Updated the `hydrate.lua` and `matching.lua` files to utilize the new algorithm, enhancing the efficiency of instance matching during hydration. - Documented the new algorithm's structure, including fast-paths for Ref pins and (Name, ClassName) grouping, which significantly improve matching accuracy and performance. - Ensured that the new implementation maintains compatibility with existing systems while providing a more robust solution for handling ambiguous instances. These changes enhance the clarity and efficiency of the reconciliation process, paving the way for improved project management and user experience.
This commit is contained in:
parent
4cb45cd607
commit
1a1d9ce886
14 changed files with 1047 additions and 1108 deletions
|
|
@ -2,10 +2,9 @@
|
|||
Defines the process of "hydration" -- matching up a virtual DOM with
|
||||
concrete instances and assigning them IDs.
|
||||
|
||||
Uses the 3-pass matching algorithm to handle duplicate-named instances:
|
||||
Pass 1: unique name + ClassName narrowing
|
||||
Pass 2: Ref property discriminators (using pre-matched UIDs)
|
||||
Pass 3: structural fingerprinting + pairwise similarity
|
||||
Uses the change-count matching algorithm to handle duplicate-named
|
||||
instances: groups by (Name, ClassName), then greedy-assigns pairs
|
||||
by fewest total reconciler changes (recursive into subtrees).
|
||||
]]
|
||||
|
||||
local Packages = script.Parent.Parent.Parent.Packages
|
||||
|
|
@ -42,12 +41,7 @@ local function hydrate(instanceMap, virtualInstances, rootId, rootInstance)
|
|||
end
|
||||
|
||||
-- Use the 3-pass matching algorithm to pair virtual ↔ studio children
|
||||
local result = Matching.matchChildren(
|
||||
validVirtualIds,
|
||||
existingChildren,
|
||||
virtualInstances,
|
||||
instanceMap
|
||||
)
|
||||
local result = Matching.matchChildren(validVirtualIds, existingChildren, virtualInstances, instanceMap)
|
||||
|
||||
-- Recursively hydrate matched pairs
|
||||
for _, pair in result.matched do
|
||||
|
|
|
|||
|
|
@ -1,32 +1,115 @@
|
|||
--[[
|
||||
3-pass instance matching algorithm for the plugin.
|
||||
Instance matching algorithm for the plugin.
|
||||
|
||||
Pairs server virtual instances to Studio instances during hydration.
|
||||
Same algorithm pattern as the Rust syncback/forward sync matchers:
|
||||
Pairs server virtual instances to Studio instances during hydration
|
||||
by minimizing total reconciler changes.
|
||||
|
||||
Pass 1: Unique name matching + ClassName narrowing
|
||||
Pass 2: Ref property discriminators (using pre-matched UIDs)
|
||||
Pass 3: Structural fingerprinting with XXH32 + pairwise similarity
|
||||
Algorithm per parent:
|
||||
1. Ref pin: parent's Ref properties confirm identity of specific children
|
||||
2. Group remaining by (Name, ClassName) -- 1:1 groups instant-match
|
||||
3. Ambiguous groups: recursive change-count scoring + greedy assignment
|
||||
|
||||
Returns: matched (array of {virtualId, studioInstance}),
|
||||
unmatchedVirtual (array of virtualId),
|
||||
unmatchedStudio (array of studioInstance)
|
||||
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.
|
||||
]]
|
||||
|
||||
local Packages = script.Parent.Parent.Parent.Packages
|
||||
local RbxDom = require(Packages.RbxDom)
|
||||
|
||||
local UNMATCHED_PENALTY = 10000
|
||||
|
||||
-- Value equality check. This MUST match the trueEquals function in diff.lua
|
||||
-- exactly. If matching says "equal" but diff says "different", we get phantom
|
||||
-- changes from cross-paired instances.
|
||||
local function fuzzyEq(a: number, b: number, epsilon: number): boolean
|
||||
local diff = math.abs(a - b)
|
||||
local maxVal = math.max(math.abs(a), math.abs(b), 1)
|
||||
return diff < epsilon or diff < maxVal * epsilon
|
||||
end
|
||||
|
||||
local function trueEquals(a, b): boolean
|
||||
if a == b then
|
||||
return true
|
||||
end
|
||||
|
||||
if
|
||||
(a == nil and type(b) == "table" and b.Ref == "00000000000000000000000000000000")
|
||||
or (b == nil and type(a) == "table" and a.Ref == "00000000000000000000000000000000")
|
||||
then
|
||||
return true
|
||||
end
|
||||
|
||||
local typeA, typeB = typeof(a), typeof(b)
|
||||
|
||||
if typeA == "table" and typeB == "table" then
|
||||
local checkedKeys = {}
|
||||
for key, value in a do
|
||||
checkedKeys[key] = true
|
||||
if not trueEquals(value, b[key]) then
|
||||
return false
|
||||
end
|
||||
end
|
||||
for key, value in b do
|
||||
if checkedKeys[key] then
|
||||
continue
|
||||
end
|
||||
if not trueEquals(value, a[key]) then
|
||||
return false
|
||||
end
|
||||
end
|
||||
return true
|
||||
elseif a ~= a and b ~= b then
|
||||
return true
|
||||
elseif typeA == "number" and typeB == "number" then
|
||||
return fuzzyEq(a, b, 0.0001)
|
||||
elseif typeA == "number" and typeB == "EnumItem" then
|
||||
return a == b.Value
|
||||
elseif typeA == "EnumItem" and typeB == "number" then
|
||||
return a.Value == b
|
||||
elseif typeA == "Color3" and typeB == "Color3" then
|
||||
local aR, aG, aB = math.floor(a.R * 255), math.floor(a.G * 255), math.floor(a.B * 255)
|
||||
local bR, bG, bB = math.floor(b.R * 255), math.floor(b.G * 255), math.floor(b.B * 255)
|
||||
return aR == bR and aG == bG and aB == bB
|
||||
elseif typeA == "CFrame" and typeB == "CFrame" then
|
||||
local aComponents, bComponents = { a:GetComponents() }, { b:GetComponents() }
|
||||
for i, aComponent in aComponents do
|
||||
if not fuzzyEq(aComponent, bComponents[i], 0.0001) then
|
||||
return false
|
||||
end
|
||||
end
|
||||
return true
|
||||
elseif typeA == "Vector3" and typeB == "Vector3" then
|
||||
local aComponents, bComponents = { a.X, a.Y, a.Z }, { b.X, b.Y, b.Z }
|
||||
for i, aComponent in aComponents do
|
||||
if not fuzzyEq(aComponent, bComponents[i], 0.0001) then
|
||||
return false
|
||||
end
|
||||
end
|
||||
return true
|
||||
elseif typeA == "Vector2" and typeB == "Vector2" then
|
||||
local aComponents, bComponents = { a.X, a.Y }, { b.X, b.Y }
|
||||
for i, aComponent in aComponents do
|
||||
if not fuzzyEq(aComponent, bComponents[i], 0.0001) then
|
||||
return false
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
local Matching = {}
|
||||
|
||||
--[[
|
||||
Run the 3-pass matching algorithm.
|
||||
Entry point. Match virtual children to Studio children under a parent.
|
||||
|
||||
virtualChildren: array of virtual instance IDs (strings from server)
|
||||
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 (for Ref resolution)
|
||||
instanceMap: bidirectional ID ↔ Instance mapping
|
||||
|
||||
Returns a table with:
|
||||
matched: array of {virtualId: string, studioInstance: Instance}
|
||||
unmatchedVirtual: array of string (virtual IDs)
|
||||
unmatchedStudio: array of Instance
|
||||
Returns {matched, unmatchedVirtual, unmatchedStudio}
|
||||
]]
|
||||
function Matching.matchChildren(
|
||||
virtualChildren: { string },
|
||||
|
|
@ -38,12 +121,73 @@ function Matching.matchChildren(
|
|||
local remainingVirtual: { string } = table.clone(virtualChildren)
|
||||
local remainingStudio: { Instance } = table.clone(studioChildren)
|
||||
|
||||
-- Pass 1: Unique name matching + ClassName narrowing
|
||||
Matching._pass1NameAndClass(
|
||||
remainingVirtual, remainingStudio, virtualInstances, matched
|
||||
)
|
||||
-- ============================================================
|
||||
-- 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
|
||||
-- is empty, so explicit Ref pinning at this stage is a no-op.
|
||||
-- Ref correctness is maintained by the change-count scoring which
|
||||
-- counts Ref property diffs.)
|
||||
|
||||
-- Fast path: if all matched, skip Passes 2 and 3
|
||||
-- ============================================================
|
||||
-- Fast-path 2: Group by (Name, ClassName) -- 1:1 instant match
|
||||
-- ============================================================
|
||||
local vByKey: { [string]: { number } } = {}
|
||||
for i, id in remainingVirtual do
|
||||
local vInst = virtualInstances[id]
|
||||
if vInst then
|
||||
local key = vInst.Name .. "\0" .. vInst.ClassName
|
||||
if not vByKey[key] then
|
||||
vByKey[key] = {}
|
||||
end
|
||||
table.insert(vByKey[key], i)
|
||||
end
|
||||
end
|
||||
|
||||
local sByKey: { [string]: { number } } = {}
|
||||
for i, inst in remainingStudio do
|
||||
local ok, name, class = pcall(function()
|
||||
return inst.Name, inst.ClassName
|
||||
end)
|
||||
if ok then
|
||||
local key = name .. "\0" .. class
|
||||
if not sByKey[key] then
|
||||
sByKey[key] = {}
|
||||
end
|
||||
table.insert(sByKey[key], i)
|
||||
end
|
||||
end
|
||||
|
||||
-- Track which indices to remove (collected, then removed in reverse)
|
||||
local matchedV: { [number]: boolean } = {}
|
||||
local matchedS: { [number]: boolean } = {}
|
||||
|
||||
-- 1:1 groups: instant match
|
||||
for key, vIndices in vByKey do
|
||||
local sIndices = sByKey[key]
|
||||
if sIndices and #vIndices == 1 and #sIndices == 1 then
|
||||
local vi, si = vIndices[1], sIndices[1]
|
||||
if not matchedV[vi] and not matchedS[si] then
|
||||
table.insert(matched, {
|
||||
virtualId = remainingVirtual[vi],
|
||||
studioInstance = remainingStudio[si],
|
||||
})
|
||||
matchedV[vi] = true
|
||||
matchedS[si] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Remove 1:1 matched from remaining lists
|
||||
Matching._removeMatched(remainingVirtual, matchedV)
|
||||
Matching._removeMatched(remainingStudio, matchedS)
|
||||
|
||||
-- Early exit if nothing left
|
||||
if #remainingVirtual == 0 or #remainingStudio == 0 then
|
||||
return {
|
||||
matched = matched,
|
||||
|
|
@ -52,14 +196,95 @@ function Matching.matchChildren(
|
|||
}
|
||||
end
|
||||
|
||||
-- Pass 2: Ref property discriminators (placeholder)
|
||||
-- TODO: Use pre-resolved Refs from instanceMap to differentiate
|
||||
-- candidates within same-name groups.
|
||||
-- ============================================================
|
||||
-- Ambiguous groups: change-count scoring + greedy assignment
|
||||
-- ============================================================
|
||||
-- Rebuild groups from remaining (only ambiguous ones are left)
|
||||
local vByKey2: { [string]: { number } } = {}
|
||||
for i, id in remainingVirtual do
|
||||
local vInst = virtualInstances[id]
|
||||
if vInst then
|
||||
local key = vInst.Name .. "\0" .. vInst.ClassName
|
||||
if not vByKey2[key] then
|
||||
vByKey2[key] = {}
|
||||
end
|
||||
table.insert(vByKey2[key], i)
|
||||
end
|
||||
end
|
||||
|
||||
-- Pass 3: Similarity scoring
|
||||
Matching._pass3Similarity(
|
||||
remainingVirtual, remainingStudio, virtualInstances, matched
|
||||
)
|
||||
local sByKey2: { [string]: { number } } = {}
|
||||
for i, inst in remainingStudio do
|
||||
local ok, name, class = pcall(function()
|
||||
return inst.Name, inst.ClassName
|
||||
end)
|
||||
if ok then
|
||||
local key = name .. "\0" .. class
|
||||
if not sByKey2[key] then
|
||||
sByKey2[key] = {}
|
||||
end
|
||||
table.insert(sByKey2[key], i)
|
||||
end
|
||||
end
|
||||
|
||||
local matchedV2: { [number]: boolean } = {}
|
||||
local matchedS2: { [number]: boolean } = {}
|
||||
|
||||
for key, vIndices in vByKey2 do
|
||||
local sIndices = sByKey2[key]
|
||||
if not sIndices then
|
||||
continue
|
||||
end
|
||||
|
||||
-- Build all (A, B) pairs with change counts.
|
||||
-- Pairs are built in child order (virtual outer, studio inner).
|
||||
local pairs: { { vi: number, si: number, cost: number, idx: number } } = {}
|
||||
local pairIdx = 0
|
||||
local bestSoFar = math.huge
|
||||
|
||||
for _, vi in vIndices do
|
||||
if matchedV2[vi] then
|
||||
continue
|
||||
end
|
||||
for _, si in sIndices do
|
||||
if matchedS2[si] then
|
||||
continue
|
||||
end
|
||||
pairIdx += 1
|
||||
local cost =
|
||||
Matching._computeChangeCount(remainingVirtual[vi], remainingStudio[si], virtualInstances, bestSoFar)
|
||||
table.insert(pairs, { vi = vi, si = si, cost = cost, idx = pairIdx })
|
||||
if cost < bestSoFar then
|
||||
bestSoFar = cost
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Stable sort by cost ascending (Luau sort is not stable,
|
||||
-- so break ties by insertion index to preserve child order)
|
||||
table.sort(pairs, function(a, b)
|
||||
if a.cost ~= b.cost then
|
||||
return a.cost < b.cost
|
||||
end
|
||||
return a.idx < b.idx
|
||||
end)
|
||||
|
||||
-- Greedy assign
|
||||
for _, pair in pairs do
|
||||
if matchedV2[pair.vi] or matchedS2[pair.si] then
|
||||
continue
|
||||
end
|
||||
table.insert(matched, {
|
||||
virtualId = remainingVirtual[pair.vi],
|
||||
studioInstance = remainingStudio[pair.si],
|
||||
})
|
||||
matchedV2[pair.vi] = true
|
||||
matchedS2[pair.si] = true
|
||||
end
|
||||
end
|
||||
|
||||
-- Remove matched from remaining
|
||||
Matching._removeMatched(remainingVirtual, matchedV2)
|
||||
Matching._removeMatched(remainingStudio, matchedS2)
|
||||
|
||||
return {
|
||||
matched = matched,
|
||||
|
|
@ -69,256 +294,204 @@ function Matching.matchChildren(
|
|||
end
|
||||
|
||||
--[[
|
||||
Pass 1: Match instances with unique names, then narrow by ClassName.
|
||||
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._pass1NameAndClass(
|
||||
remainingVirtual: { string },
|
||||
remainingStudio: { Instance },
|
||||
function Matching._computeChangeCount(
|
||||
virtualId: string,
|
||||
studioInstance: Instance,
|
||||
virtualInstances: { [string]: any },
|
||||
matched: { { virtualId: string, studioInstance: Instance } }
|
||||
)
|
||||
-- Build name → indices maps for both sides
|
||||
local virtualByName: { [string]: { number } } = {}
|
||||
for i, id in remainingVirtual do
|
||||
local vInst = virtualInstances[id]
|
||||
if vInst then
|
||||
local name = vInst.Name
|
||||
if not virtualByName[name] then
|
||||
virtualByName[name] = {}
|
||||
end
|
||||
table.insert(virtualByName[name], i)
|
||||
end
|
||||
bestSoFar: number
|
||||
): number
|
||||
local vInst = virtualInstances[virtualId]
|
||||
if not vInst then
|
||||
return UNMATCHED_PENALTY
|
||||
end
|
||||
|
||||
local studioByName: { [string]: { number } } = {}
|
||||
for i, inst in remainingStudio do
|
||||
local success, name = pcall(function()
|
||||
return inst.Name
|
||||
-- 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 success and name then
|
||||
if not studioByName[name] then
|
||||
studioByName[name] = {}
|
||||
end
|
||||
table.insert(studioByName[name], i)
|
||||
if ok then
|
||||
cost += #studioKids * UNMATCHED_PENALTY
|
||||
end
|
||||
return cost
|
||||
end
|
||||
|
||||
-- Collect matches (indices to remove, processed in reverse order)
|
||||
local matchedVirtualIndices: { number } = {}
|
||||
local matchedStudioIndices: { number } = {}
|
||||
|
||||
for name, vIndices in virtualByName do
|
||||
local sIndices = studioByName[name]
|
||||
if not sIndices then
|
||||
continue
|
||||
end
|
||||
|
||||
-- Case 1: exactly one on each side → instant match
|
||||
if #vIndices == 1 and #sIndices == 1 then
|
||||
table.insert(matched, {
|
||||
virtualId = remainingVirtual[vIndices[1]],
|
||||
studioInstance = remainingStudio[sIndices[1]],
|
||||
})
|
||||
table.insert(matchedVirtualIndices, vIndices[1])
|
||||
table.insert(matchedStudioIndices, sIndices[1])
|
||||
continue
|
||||
end
|
||||
|
||||
-- Case 2: Try ClassName narrowing within this name group
|
||||
local virtualByClass: { [string]: { number } } = {}
|
||||
for _, vi in vIndices do
|
||||
local vInst = virtualInstances[remainingVirtual[vi]]
|
||||
if vInst then
|
||||
local class = vInst.ClassName
|
||||
if not virtualByClass[class] then
|
||||
virtualByClass[class] = {}
|
||||
end
|
||||
table.insert(virtualByClass[class], vi)
|
||||
end
|
||||
end
|
||||
|
||||
local studioByClass: { [string]: { number } } = {}
|
||||
for _, si in sIndices do
|
||||
local success, class = pcall(function()
|
||||
return remainingStudio[si].ClassName
|
||||
end)
|
||||
if success and class then
|
||||
if not studioByClass[class] then
|
||||
studioByClass[class] = {}
|
||||
end
|
||||
table.insert(studioByClass[class], si)
|
||||
end
|
||||
end
|
||||
|
||||
for class, vClassIndices in virtualByClass do
|
||||
local sClassIndices = studioByClass[class]
|
||||
if sClassIndices and #vClassIndices == 1 and #sClassIndices == 1 then
|
||||
table.insert(matched, {
|
||||
virtualId = remainingVirtual[vClassIndices[1]],
|
||||
studioInstance = remainingStudio[sClassIndices[1]],
|
||||
})
|
||||
table.insert(matchedVirtualIndices, vClassIndices[1])
|
||||
table.insert(matchedStudioIndices, sClassIndices[1])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Remove matched items from remaining lists (in reverse order to preserve indices)
|
||||
table.sort(matchedVirtualIndices, function(a, b)
|
||||
return a > b
|
||||
end)
|
||||
table.sort(matchedStudioIndices, function(a, b)
|
||||
return a > b
|
||||
local ok, studioKids = pcall(function()
|
||||
return studioInstance:GetChildren()
|
||||
end)
|
||||
if not ok then
|
||||
return cost
|
||||
end
|
||||
|
||||
-- Deduplicate indices (in case of overlapping matches)
|
||||
local seenV: { [number]: boolean } = {}
|
||||
local seenS: { [number]: boolean } = {}
|
||||
for _, idx in matchedVirtualIndices do
|
||||
if not seenV[idx] then
|
||||
seenV[idx] = true
|
||||
table.remove(remainingVirtual, idx)
|
||||
-- Filter valid virtual children
|
||||
local validVChildren: { string } = {}
|
||||
for _, childId in vChildren do
|
||||
if virtualInstances[childId] then
|
||||
table.insert(validVChildren, childId)
|
||||
end
|
||||
end
|
||||
for _, idx in matchedStudioIndices do
|
||||
if not seenS[idx] then
|
||||
seenS[idx] = true
|
||||
table.remove(remainingStudio, idx)
|
||||
|
||||
-- Recursive: match children and sum costs
|
||||
local childResult = Matching.matchChildren(validVChildren, studioKids, virtualInstances, nil)
|
||||
|
||||
-- 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
|
||||
|
||||
--[[
|
||||
Pass 3: Match remaining instances within same-name groups.
|
||||
|
||||
Strategy:
|
||||
1. Narrow by ClassName within each name group.
|
||||
2. Within same-name+class sub-groups, use POSITIONAL MATCHING as the
|
||||
primary signal. The server sends virtual children in DOM order and
|
||||
Studio's GetChildren() preserves insertion order -- these typically
|
||||
align. The Nth virtual "Line" matches the Nth Studio "Line".
|
||||
3. When group sizes differ (adds/deletes), fall back to property-based
|
||||
similarity scoring to pick the best remaining match.
|
||||
|
||||
This preserves the ordering behavior of the original greedy hydration
|
||||
algorithm while handling ambiguous paths correctly.
|
||||
Count own property diffs between a virtual instance and Studio instance.
|
||||
Each differing property = +1. Tags and Attributes counted granularly.
|
||||
]]
|
||||
function Matching._pass3Similarity(
|
||||
remainingVirtual: { string },
|
||||
remainingStudio: { Instance },
|
||||
virtualInstances: { [string]: any },
|
||||
matched: { { virtualId: string, studioInstance: Instance } }
|
||||
)
|
||||
-- Group remaining by name
|
||||
local virtualByName: { [string]: { number } } = {}
|
||||
for i, id in remainingVirtual do
|
||||
local vInst = virtualInstances[id]
|
||||
if vInst then
|
||||
local name = vInst.Name
|
||||
if not virtualByName[name] then
|
||||
virtualByName[name] = {}
|
||||
end
|
||||
table.insert(virtualByName[name], i)
|
||||
end
|
||||
end
|
||||
function Matching._countOwnDiffs(vInst: any, studioInstance: Instance): number
|
||||
local cost = 0
|
||||
|
||||
local studioByName: { [string]: { number } } = {}
|
||||
for i, inst in remainingStudio do
|
||||
local success, name = pcall(function()
|
||||
return inst.Name
|
||||
end)
|
||||
if success and name then
|
||||
if not studioByName[name] then
|
||||
studioByName[name] = {}
|
||||
end
|
||||
table.insert(studioByName[name], i)
|
||||
end
|
||||
end
|
||||
|
||||
local matchedVirtualIndices: { [number]: boolean } = {}
|
||||
local matchedStudioIndices: { [number]: boolean } = {}
|
||||
|
||||
for name, vIndices in virtualByName do
|
||||
local sIndices = studioByName[name]
|
||||
if not sIndices then
|
||||
continue
|
||||
end
|
||||
|
||||
-- Narrow by ClassName within this name group.
|
||||
local virtualByClass: { [string]: { number } } = {}
|
||||
for _, vi in vIndices do
|
||||
local vInst = virtualInstances[remainingVirtual[vi]]
|
||||
if vInst and not matchedVirtualIndices[vi] then
|
||||
local class = vInst.ClassName
|
||||
if not virtualByClass[class] then
|
||||
virtualByClass[class] = {}
|
||||
end
|
||||
table.insert(virtualByClass[class], vi)
|
||||
end
|
||||
end
|
||||
|
||||
local studioByClass: { [string]: { number } } = {}
|
||||
for _, si in sIndices do
|
||||
if matchedStudioIndices[si] then
|
||||
-- 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
|
||||
local success, class = pcall(function()
|
||||
return remainingStudio[si].ClassName
|
||||
|
||||
-- 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 success and class then
|
||||
if not studioByClass[class] then
|
||||
studioByClass[class] = {}
|
||||
end
|
||||
table.insert(studioByClass[class], si)
|
||||
end
|
||||
end
|
||||
|
||||
-- Within each same-name+class sub-group, do positional matching.
|
||||
-- The Nth virtual instance matches the Nth Studio instance.
|
||||
-- This preserves the original child ordering which is the strongest
|
||||
-- signal for duplicate-named instances (e.g., Parts in a Model).
|
||||
for class, vClassIndices in virtualByClass do
|
||||
local sClassIndices = studioByClass[class]
|
||||
if not sClassIndices then
|
||||
if not readOk then
|
||||
-- Can't read: assume different
|
||||
cost += 1
|
||||
continue
|
||||
end
|
||||
|
||||
local matchCount = math.min(#vClassIndices, #sClassIndices)
|
||||
for idx = 1, matchCount do
|
||||
local vi = vClassIndices[idx]
|
||||
local si = sClassIndices[idx]
|
||||
if not matchedVirtualIndices[vi] and not matchedStudioIndices[si] then
|
||||
table.insert(matched, {
|
||||
virtualId = remainingVirtual[vi],
|
||||
studioInstance = remainingStudio[si],
|
||||
})
|
||||
matchedVirtualIndices[vi] = true
|
||||
matchedStudioIndices[si] = true
|
||||
-- Compare values using the same equality check as diff.lua
|
||||
if not trueEquals(decodedValue, 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
|
||||
|
||||
-- Remove matched items from remaining lists (reverse order)
|
||||
local vToRemove: { number } = {}
|
||||
local sToRemove: { number } = {}
|
||||
for idx in matchedVirtualIndices do
|
||||
table.insert(vToRemove, idx)
|
||||
-- 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
|
||||
for idx in matchedStudioIndices do
|
||||
table.insert(sToRemove, idx)
|
||||
|
||||
-- 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
|
||||
table.sort(vToRemove, function(a, b)
|
||||
|
||||
return cost
|
||||
end
|
||||
|
||||
--[[
|
||||
Remove items from an array by index set (removes in reverse order).
|
||||
]]
|
||||
function Matching._removeMatched(arr: { any }, matchedIndices: { [number]: boolean })
|
||||
local toRemove: { number } = {}
|
||||
for idx in matchedIndices do
|
||||
table.insert(toRemove, idx)
|
||||
end
|
||||
table.sort(toRemove, function(a, b)
|
||||
return a > b
|
||||
end)
|
||||
table.sort(sToRemove, function(a, b)
|
||||
return a > b
|
||||
end)
|
||||
for _, idx in vToRemove do
|
||||
table.remove(remainingVirtual, idx)
|
||||
end
|
||||
for _, idx in sToRemove do
|
||||
table.remove(remainingStudio, idx)
|
||||
for _, idx in toRemove do
|
||||
table.remove(arr, idx)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -31,33 +31,40 @@ local function XXH32(Message: buffer, Seed: number?): number
|
|||
|
||||
local Temp1 = bit32.lrotate(Accumulator1 + Mult1, 13)
|
||||
local AHigh1_2, ALow1_2 = bit32.rshift(Temp1, 16), bit32.band(Temp1, 65535)
|
||||
Accumulator1 = bit32.lshift((AHigh1_2 * PRIME_1_LOW) + (ALow1_2 * PRIME_1_HIGH), 16) + (ALow1_2 * PRIME_1_LOW)
|
||||
Accumulator1 = bit32.lshift((AHigh1_2 * PRIME_1_LOW) + (ALow1_2 * PRIME_1_HIGH), 16)
|
||||
+ (ALow1_2 * PRIME_1_LOW)
|
||||
|
||||
local AHigh2, ALow2 = bit32.rshift(Word2, 16), bit32.band(Word2, 65535)
|
||||
local Mult2 = bit32.lshift((AHigh2 * PRIME_2_LOW) + (ALow2 * PRIME_2_HIGH), 16) + (ALow2 * PRIME_2_LOW)
|
||||
|
||||
local Temp2 = bit32.lrotate(Accumulator2 + Mult2, 13)
|
||||
local AHigh2_2, ALow2_2 = bit32.rshift(Temp2, 16), bit32.band(Temp2, 65535)
|
||||
Accumulator2 = bit32.lshift((AHigh2_2 * PRIME_1_LOW) + (ALow2_2 * PRIME_1_HIGH), 16) + (ALow2_2 * PRIME_1_LOW)
|
||||
Accumulator2 = bit32.lshift((AHigh2_2 * PRIME_1_LOW) + (ALow2_2 * PRIME_1_HIGH), 16)
|
||||
+ (ALow2_2 * PRIME_1_LOW)
|
||||
|
||||
local AHigh3, ALow3 = bit32.rshift(Word3, 16), bit32.band(Word3, 65535)
|
||||
local Mult3 = bit32.lshift((AHigh3 * PRIME_2_LOW) + (ALow3 * PRIME_2_HIGH), 16) + (ALow3 * PRIME_2_LOW)
|
||||
|
||||
local Temp3 = bit32.lrotate(Accumulator3 + Mult3, 13)
|
||||
local AHigh3_2, ALow3_2 = bit32.rshift(Temp3, 16), bit32.band(Temp3, 65535)
|
||||
Accumulator3 = bit32.lshift((AHigh3_2 * PRIME_1_LOW) + (ALow3_2 * PRIME_1_HIGH), 16) + (ALow3_2 * PRIME_1_LOW)
|
||||
Accumulator3 = bit32.lshift((AHigh3_2 * PRIME_1_LOW) + (ALow3_2 * PRIME_1_HIGH), 16)
|
||||
+ (ALow3_2 * PRIME_1_LOW)
|
||||
|
||||
local AHigh4, ALow4 = bit32.rshift(Word4, 16), bit32.band(Word4, 65535)
|
||||
local Mult4 = bit32.lshift((AHigh4 * PRIME_2_LOW) + (ALow4 * PRIME_2_HIGH), 16) + (ALow4 * PRIME_2_LOW)
|
||||
|
||||
local Temp4 = bit32.lrotate(Accumulator4 + Mult4, 13)
|
||||
local AHigh4_2, ALow4_2 = bit32.rshift(Temp4, 16), bit32.band(Temp4, 65535)
|
||||
Accumulator4 = bit32.lshift((AHigh4_2 * PRIME_1_LOW) + (ALow4_2 * PRIME_1_HIGH), 16) + (ALow4_2 * PRIME_1_LOW)
|
||||
Accumulator4 = bit32.lshift((AHigh4_2 * PRIME_1_LOW) + (ALow4_2 * PRIME_1_HIGH), 16)
|
||||
+ (ALow4_2 * PRIME_1_LOW)
|
||||
|
||||
CurrentOffset += 16
|
||||
end
|
||||
|
||||
Digest = bit32.lrotate(Accumulator1, 1) + bit32.lrotate(Accumulator2, 7) + bit32.lrotate(Accumulator3, 12) + bit32.lrotate(Accumulator4, 18)
|
||||
Digest = bit32.lrotate(Accumulator1, 1)
|
||||
+ bit32.lrotate(Accumulator2, 7)
|
||||
+ bit32.lrotate(Accumulator3, 12)
|
||||
+ bit32.lrotate(Accumulator4, 18)
|
||||
else
|
||||
Digest = UsedSeed + PRIME_5
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue