Refactor syncback process and improve code readability

- Reformatted the `SYNCBACK_SERVICES` list for better readability by aligning entries vertically.
- Simplified the `performSyncback` function by removing unnecessary Promise wrapping, enhancing clarity and maintainability.
- Updated the `NotConnectedPage` component to improve the structure of button elements and their associated tooltips.
- Refactored the `hydrate` and `matching` modules to streamline function calls and improve code organization.

These changes enhance the overall readability and maintainability of the syncback process and related components.
This commit is contained in:
Joe 2026-02-18 05:58:17 -06:00
parent 372a422b3d
commit 93ea0bdef6
16 changed files with 546 additions and 375 deletions

View file

@ -5,10 +5,20 @@ local ServerStorage = game:GetService("ServerStorage")
local RunService = game:GetService("RunService") local RunService = game:GetService("RunService")
local SYNCBACK_SERVICES = { local SYNCBACK_SERVICES = {
"Lighting", "MaterialService", "ReplicatedFirst", "ReplicatedStorage", "Lighting",
"ServerScriptService", "ServerStorage", "SoundService", "StarterGui", "MaterialService",
"StarterPack", "StarterPlayer", "Teams", "TextChatService", "ReplicatedFirst",
"VoiceChatService", "Workspace", "ReplicatedStorage",
"ServerScriptService",
"ServerStorage",
"SoundService",
"StarterGui",
"StarterPack",
"StarterPlayer",
"Teams",
"TextChatService",
"VoiceChatService",
"Workspace",
} }
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
@ -628,7 +638,6 @@ function App:performSyncback()
port = Config.defaultPort port = Config.defaultPort
end end
Promise.new(function(resolve, reject)
local services = {} local services = {}
for _, className in SYNCBACK_SERVICES do for _, className in SYNCBACK_SERVICES do
local ok, service = pcall(game.FindService, game, className) local ok, service = pcall(game.FindService, game, className)
@ -650,17 +659,6 @@ function App:performSyncback()
}) })
Http.post(url, body) Http.post(url, body)
:andThen(function(response)
if response.StatusCode >= 200 and response.StatusCode < 300 then
resolve()
else
reject(
`Syncback request failed ({response.StatusCode}): {response.Body or "unknown error"}`
)
end
end)
:catch(reject)
end)
:andThen(function() :andThen(function()
Log.info("Syncback data sent to server.") Log.info("Syncback data sent to server.")
self:addNotification({ self:addNotification({

View file

@ -40,10 +40,8 @@ local function hydrate(instanceMap, virtualInstances, rootId, rootInstance, sess
end end
end end
local result = Matching.matchChildren( local result =
session, validVirtualIds, existingChildren, virtualInstances, Matching.matchChildren(session, validVirtualIds, existingChildren, virtualInstances, rootId, rootInstance)
rootId, rootInstance
)
-- Recursively hydrate matched pairs -- Recursively hydrate matched pairs
for _, pair in result.matched do for _, pair in result.matched do

View file

@ -24,7 +24,7 @@ local RbxDom = require(Packages.RbxDom)
local trueEquals = require(script.Parent.trueEquals) local trueEquals = require(script.Parent.trueEquals)
local UNMATCHED_PENALTY: number = 10000 local UNMATCHED_PENALTY = 10000
-- ================================================================ -- ================================================================
-- Types -- Types
@ -49,21 +49,30 @@ type MatchingSession = {
type VCache = { type VCache = {
props: { [string]: any }, props: { [string]: any },
extraProps: { string }?, extraProps: { string },
tags: { [string]: boolean }?, tags: { [string]: boolean },
attrs: { [string]: any }?, attrs: { [string]: any },
childCount: number, childCount: number,
} }
type SCache = { type SCache = {
instance: Instance, instance: Instance,
props: { [string]: any }, props: { [string]: any },
tags: { [string]: boolean }?, tags: { [string]: boolean },
attrs: { [string]: any }?, attrs: { [string]: any },
children: { Instance }, children: { Instance },
childCount: number, childCount: number,
} }
type VirtualInstance = {
Name: string,
ClassName: string,
Children: { string },
Properties: { [string]: any }?,
}
type VirtualInstances = { [string]: VirtualInstance }
type ScoredPair = { type ScoredPair = {
vi: number, vi: number,
si: number, si: number,
@ -82,17 +91,29 @@ local function newSession(): MatchingSession
} }
end end
-- ================================================================
-- Utilities
-- ================================================================
local function rawIndex(inst: any, key: string): any
return inst[key]
end
local function safeGet(inst: Instance, key: string): (boolean, any)
return pcall(rawIndex, inst :: any, key)
end
-- ================================================================ -- ================================================================
-- Pre-computation helpers (called once per instance per group) -- Pre-computation helpers (called once per instance per group)
-- ================================================================ -- ================================================================
local function cacheVirtual(vInst: any, classKeys: any): VCache local function cacheVirtual(vInst: VirtualInstance, classKeys: any): VCache
local decoded: { [string]: any } = {} local decoded: { [string]: any } = {}
local extraProps: { string }? = nil local extraProps: { string } = {}
local vProps = vInst.Properties local vProps = vInst.Properties
if vProps then if vProps then
for propName, encodedValue in vProps do for propName, encodedValue in pairs(vProps) do
if propName == "Tags" or propName == "Attributes" then if propName == "Tags" or propName == "Attributes" then
continue continue
end end
@ -106,27 +127,23 @@ local function cacheVirtual(vInst: any, classKeys: any): VCache
if pcallOk and decodeOk and value ~= nil then if pcallOk and decodeOk and value ~= nil then
decoded[propName] = value decoded[propName] = value
if not classKeys.propNameSet[propName] then if not classKeys.propNameSet[propName] then
if not extraProps then
extraProps = {}
end
table.insert(extraProps, propName) table.insert(extraProps, propName)
end end
end end
end end
end end
local decodedTags: { [string]: boolean }? = nil local decodedTags: { [string]: boolean } = {}
if vProps and vProps.Tags then if vProps and vProps.Tags then
local ok, tags = RbxDom.EncodedValue.decode(vProps.Tags) local ok, tags = RbxDom.EncodedValue.decode(vProps.Tags)
if ok and type(tags) == "table" then if ok and type(tags) == "table" then
decodedTags = {} for _, tag in ipairs(tags) do
for _, tag in tags do
decodedTags[tag] = true decodedTags[tag] = true
end end
end end
end end
local decodedAttrs: { [string]: any }? = nil local decodedAttrs: { [string]: any } = {}
if vProps and vProps.Attributes then if vProps and vProps.Attributes then
local ok, attrs = RbxDom.EncodedValue.decode(vProps.Attributes) local ok, attrs = RbxDom.EncodedValue.decode(vProps.Attributes)
if ok and type(attrs) == "table" then if ok and type(attrs) == "table" then
@ -143,68 +160,40 @@ local function cacheVirtual(vInst: any, classKeys: any): VCache
} }
end end
local function cacheStudio(studioInstance: Instance, classKeys: any, extraPropNames: { string }?): SCache local function cacheStudio(studioInstance: Instance, classKeys: any, extraPropNames: { string }): SCache
local props: { [string]: any } = {} local props: { [string]: any } = {}
for _, propName in classKeys.propNames do for _, propName in ipairs(classKeys.propNames) do
local ok, value = pcall(function() local ok, value = safeGet(studioInstance, propName)
return (studioInstance :: any)[propName] if ok then
end)
if ok and value ~= nil then
props[propName] = value props[propName] = value
end end
end end
if extraPropNames then for _, propName in ipairs(extraPropNames) do
for _, propName in extraPropNames do
if props[propName] ~= nil then if props[propName] ~= nil then
continue continue
end end
local ok, value = pcall(function() local ok, value = safeGet(studioInstance, propName)
return (studioInstance :: any)[propName] if ok then
end)
if ok and value ~= nil then
props[propName] = value props[propName] = value
end end
end end
end
local tags: { [string]: boolean }? = nil local tags: { [string]: boolean } = {}
local tagsOk, studioTags = pcall(function() for _, tag in ipairs(studioInstance:GetTags()) do
return studioInstance:GetTags()
end)
if tagsOk then
tags = {}
for _, tag in studioTags do
tags[tag] = true tags[tag] = true
end end
end
local attrs: { [string]: any }? = nil local children = studioInstance:GetChildren()
local attrsOk, studioAttrs = pcall(function()
return studioInstance:GetAttributes()
end)
if attrsOk then
attrs = studioAttrs
end
local children: { Instance } = {}
local childCount: number = 0
local childOk, studioKids = pcall(function()
return studioInstance:GetChildren()
end)
if childOk then
children = studioKids
childCount = #studioKids
end
return { return {
instance = studioInstance, instance = studioInstance,
props = props, props = props,
tags = tags, tags = tags,
attrs = attrs, attrs = studioInstance:GetAttributes(),
children = children, children = children,
childCount = childCount, childCount = #children,
} }
end end
@ -213,12 +202,12 @@ end
-- ================================================================ -- ================================================================
local function countOwnDiffs(vCache: VCache, sCache: SCache, classKeys: any): number local function countOwnDiffs(vCache: VCache, sCache: SCache, classKeys: any): number
local cost: number = 0 local cost = 0
local vProps = vCache.props local vProps = vCache.props
local sProps = sCache.props local sProps = sCache.props
local defaults = classKeys.defaults local defaults = classKeys.defaults
for _, propName in classKeys.propNames do for _, propName in ipairs(classKeys.propNames) do
local vVal = vProps[propName] local vVal = vProps[propName]
if vVal == nil then if vVal == nil then
vVal = defaults[propName] vVal = defaults[propName]
@ -229,43 +218,37 @@ local function countOwnDiffs(vCache: VCache, sCache: SCache, classKeys: any): nu
end end
end end
if vCache.extraProps then for _, propName in ipairs(vCache.extraProps) do
for _, propName in vCache.extraProps do
if not trueEquals(vProps[propName], sProps[propName]) then if not trueEquals(vProps[propName], sProps[propName]) then
cost += 1 cost += 1
end end
end end
end
local vTags = vCache.tags local vTags = vCache.tags
local sTags = sCache.tags local sTags = sCache.tags
if vTags and sTags then for tag, _ in pairs(vTags) do
for tag in vTags do
if not sTags[tag] then if not sTags[tag] then
cost += 1 cost += 1
end end
end end
for tag in sTags do for tag, _ in pairs(sTags) do
if not vTags[tag] then if not vTags[tag] then
cost += 1 cost += 1
end end
end end
end
local vAttrs = vCache.attrs local vAttrs = vCache.attrs
local sAttrs = sCache.attrs local sAttrs = sCache.attrs
if vAttrs and sAttrs then for key, vVal in pairs(vAttrs) do
for key, vVal in vAttrs do
if not trueEquals(vVal, sAttrs[key]) then if not trueEquals(vVal, sAttrs[key]) then
cost += 1 cost += 1
end end
end end
for key in sAttrs do for key, _ in pairs(sAttrs) do
if vAttrs[key] == nil then if vAttrs[key] == nil then
cost += 1 cost += 1
end end
end end
end
if vCache.childCount ~= sCache.childCount then if vCache.childCount ~= sCache.childCount then
cost += 1 cost += 1
@ -279,7 +262,7 @@ end
-- ================================================================ -- ================================================================
local function removeMatched(arr: { any }, matchedIndices: { [number]: boolean }): () local function removeMatched(arr: { any }, matchedIndices: { [number]: boolean }): ()
local write: number = 1 local write = 1
for read = 1, #arr do for read = 1, #arr do
if not matchedIndices[read] then if not matchedIndices[read] then
arr[write] = arr[read] arr[write] = arr[read]
@ -301,7 +284,7 @@ local function computePairCost(
session: MatchingSession, session: MatchingSession,
virtualId: string, virtualId: string,
studioInstance: Instance, studioInstance: Instance,
virtualInstances: { [string]: any }, virtualInstances: VirtualInstances,
bestSoFar: number bestSoFar: number
): number ): number
local vc = session.costCache[virtualId] local vc = session.costCache[virtualId]
@ -321,7 +304,7 @@ local function computePairCost(
local vCache = cacheVirtual(vInst, classKeys) local vCache = cacheVirtual(vInst, classKeys)
local sCache = cacheStudio(studioInstance, classKeys, vCache.extraProps) local sCache = cacheStudio(studioInstance, classKeys, vCache.extraProps)
local cost: number = countOwnDiffs(vCache, sCache, classKeys) local cost = countOwnDiffs(vCache, sCache, classKeys)
if cost >= bestSoFar then if cost >= bestSoFar then
return cost return cost
end end
@ -334,23 +317,21 @@ local function computePairCost(
elseif not vChildren or #vChildren == 0 then elseif not vChildren or #vChildren == 0 then
cost += #studioKids * UNMATCHED_PENALTY cost += #studioKids * UNMATCHED_PENALTY
elseif #studioKids == 0 then elseif #studioKids == 0 then
for _, childId in vChildren do for _, childId in ipairs(vChildren) do
if virtualInstances[childId] then if virtualInstances[childId] then
cost += UNMATCHED_PENALTY cost += UNMATCHED_PENALTY
end end
end end
else else
local validVChildren: { string } = {} local validVChildren: { string } = {}
for _, childId in vChildren do for _, childId in ipairs(vChildren) do
if virtualInstances[childId] then if virtualInstances[childId] then
table.insert(validVChildren, childId) table.insert(validVChildren, childId)
end end
end end
if #validVChildren > 0 then if #validVChildren > 0 then
local childResult: MatchResult = matchChildren( local childResult =
session, validVChildren, studioKids, virtualInstances, matchChildren(session, validVChildren, studioKids, virtualInstances, virtualId, studioInstance)
virtualId, studioInstance
)
cost += childResult.totalCost cost += childResult.totalCost
else else
cost += #studioKids * UNMATCHED_PENALTY cost += #studioKids * UNMATCHED_PENALTY
@ -371,7 +352,7 @@ matchChildren = function(
session: MatchingSession, session: MatchingSession,
virtualChildren: { string }, virtualChildren: { string },
studioChildren: { Instance }, studioChildren: { Instance },
virtualInstances: { [string]: any }, virtualInstances: VirtualInstances,
parentVirtualId: string?, parentVirtualId: string?,
parentStudioInstance: Instance? parentStudioInstance: Instance?
): MatchResult ): MatchResult
@ -394,10 +375,10 @@ matchChildren = function(
-- Group by (Name, ClassName) -- direct property access, no pcall -- Group by (Name, ClassName) -- direct property access, no pcall
-- ============================================================ -- ============================================================
local vByKey: { [string]: { number } } = {} local vByKey: { [string]: { number } } = {}
for i, id in remainingVirtual do for i, id in ipairs(remainingVirtual) do
local vInst = virtualInstances[id] local vInst = virtualInstances[id]
if vInst then if vInst then
local key: string = vInst.Name .. "\0" .. vInst.ClassName local key = vInst.Name .. "\0" .. vInst.ClassName
local group = vByKey[key] local group = vByKey[key]
if not group then if not group then
group = {} group = {}
@ -408,8 +389,8 @@ matchChildren = function(
end end
local sByKey: { [string]: { number } } = {} local sByKey: { [string]: { number } } = {}
for i, inst in remainingStudio do for i, inst in ipairs(remainingStudio) do
local key: string = inst.Name .. "\0" .. inst.ClassName local key = inst.Name .. "\0" .. inst.ClassName
local group = sByKey[key] local group = sByKey[key]
if not group then if not group then
group = {} group = {}
@ -424,7 +405,7 @@ matchChildren = function(
local matchedV: { [number]: boolean } = {} local matchedV: { [number]: boolean } = {}
local matchedS: { [number]: boolean } = {} local matchedS: { [number]: boolean } = {}
for key, vIndices in vByKey do for key, vIndices in pairs(vByKey) do
local sIndices = sByKey[key] local sIndices = sByKey[key]
if sIndices and #vIndices == 1 and #sIndices == 1 then if sIndices and #vIndices == 1 and #sIndices == 1 then
local vi, si = vIndices[1], sIndices[1] local vi, si = vIndices[1], sIndices[1]
@ -447,10 +428,10 @@ matchChildren = function(
-- ============================================================ -- ============================================================
if #remainingVirtual > 0 and #remainingStudio > 0 then if #remainingVirtual > 0 and #remainingStudio > 0 then
local vByKey2: { [string]: { number } } = {} local vByKey2: { [string]: { number } } = {}
for i, id in remainingVirtual do for i, id in ipairs(remainingVirtual) do
local vInst = virtualInstances[id] local vInst = virtualInstances[id]
if vInst then if vInst then
local key: string = vInst.Name .. "\0" .. vInst.ClassName local key = vInst.Name .. "\0" .. vInst.ClassName
local group = vByKey2[key] local group = vByKey2[key]
if not group then if not group then
group = {} group = {}
@ -461,8 +442,8 @@ matchChildren = function(
end end
local sByKey2: { [string]: { number } } = {} local sByKey2: { [string]: { number } } = {}
for i, inst in remainingStudio do for i, inst in ipairs(remainingStudio) do
local key: string = inst.Name .. "\0" .. inst.ClassName local key = inst.Name .. "\0" .. inst.ClassName
local group = sByKey2[key] local group = sByKey2[key]
if not group then if not group then
group = {} group = {}
@ -474,7 +455,7 @@ matchChildren = function(
local matchedV2: { [number]: boolean } = {} local matchedV2: { [number]: boolean } = {}
local matchedS2: { [number]: boolean } = {} local matchedS2: { [number]: boolean } = {}
for key, vIndices in vByKey2 do for key, vIndices in pairs(vByKey2) do
local sIndices = sByKey2[key] local sIndices = sByKey2[key]
if not sIndices then if not sIndices then
continue continue
@ -487,8 +468,8 @@ matchChildren = function(
local classKeys = RbxDom.getClassComparisonKeys(firstVInst.ClassName) local classKeys = RbxDom.getClassComparisonKeys(firstVInst.ClassName)
local vCaches: { [number]: VCache } = {} local vCaches: { [number]: VCache } = {}
local allExtraProps: { [string]: boolean }? = nil local allExtraProps: { [string]: boolean } = {}
for _, vi in vIndices do for _, vi in ipairs(vIndices) do
if matchedV2[vi] then if matchedV2[vi] then
continue continue
end end
@ -498,26 +479,18 @@ matchChildren = function(
end end
local vCache = cacheVirtual(vInst, classKeys) local vCache = cacheVirtual(vInst, classKeys)
vCaches[vi] = vCache vCaches[vi] = vCache
if vCache.extraProps then for _, propName in ipairs(vCache.extraProps) do
if not allExtraProps then
allExtraProps = {}
end
for _, propName in vCache.extraProps do
allExtraProps[propName] = true allExtraProps[propName] = true
end end
end end
end
local extraPropNamesArray: { string }? = nil local extraPropNamesArray: { string } = {}
if allExtraProps then for propName, _ in pairs(allExtraProps) do
extraPropNamesArray = {}
for propName in allExtraProps do
table.insert(extraPropNamesArray, propName) table.insert(extraPropNamesArray, propName)
end end
end
local sCaches: { [number]: SCache } = {} local sCaches: { [number]: SCache } = {}
for _, si in sIndices do for _, si in ipairs(sIndices) do
if not matchedS2[si] then if not matchedS2[si] then
sCaches[si] = cacheStudio(remainingStudio[si], classKeys, extraPropNamesArray) sCaches[si] = cacheStudio(remainingStudio[si], classKeys, extraPropNamesArray)
end end
@ -525,10 +498,10 @@ matchChildren = function(
-- Score all (A, B) pairs -- Score all (A, B) pairs
local scoredPairs: { ScoredPair } = {} local scoredPairs: { ScoredPair } = {}
local pairIdx: number = 0 local pairIdx = 0
local bestSoFar: number = math.huge local bestSoFar = math.huge
for _, vi in vIndices do for _, vi in ipairs(vIndices) do
if matchedV2[vi] then if matchedV2[vi] then
continue continue
end end
@ -537,7 +510,7 @@ matchChildren = function(
continue continue
end end
for _, si in sIndices do for _, si in ipairs(sIndices) do
if matchedS2[si] then if matchedS2[si] then
continue continue
end end
@ -548,7 +521,7 @@ matchChildren = function(
pairIdx += 1 pairIdx += 1
local cost: number = countOwnDiffs(vCache, sCache, classKeys) local cost = countOwnDiffs(vCache, sCache, classKeys)
if cost < bestSoFar then if cost < bestSoFar then
local vInst = virtualInstances[remainingVirtual[vi]] local vInst = virtualInstances[remainingVirtual[vi]]
@ -561,22 +534,26 @@ matchChildren = function(
elseif not vChildren or #vChildren == 0 then elseif not vChildren or #vChildren == 0 then
cost += #studioKids * UNMATCHED_PENALTY cost += #studioKids * UNMATCHED_PENALTY
elseif #studioKids == 0 then elseif #studioKids == 0 then
for _, childId in vChildren do for _, childId in ipairs(vChildren) do
if virtualInstances[childId] then if virtualInstances[childId] then
cost += UNMATCHED_PENALTY cost += UNMATCHED_PENALTY
end end
end end
else else
local validVChildren: { string } = {} local validVChildren: { string } = {}
for _, childId in vChildren do for _, childId in ipairs(vChildren) do
if virtualInstances[childId] then if virtualInstances[childId] then
table.insert(validVChildren, childId) table.insert(validVChildren, childId)
end end
end end
if #validVChildren > 0 then if #validVChildren > 0 then
local childResult: MatchResult = matchChildren( local childResult = matchChildren(
session, validVChildren, studioKids, virtualInstances, session,
remainingVirtual[vi], sCache.instance validVChildren,
studioKids,
virtualInstances,
remainingVirtual[vi],
sCache.instance
) )
cost += childResult.totalCost cost += childResult.totalCost
else else
@ -600,7 +577,7 @@ matchChildren = function(
return a.idx < b.idx return a.idx < b.idx
end) end)
for _, pair in scoredPairs do for _, pair in ipairs(scoredPairs) do
if matchedV2[pair.vi] or matchedS2[pair.si] then if matchedV2[pair.vi] or matchedS2[pair.si] then
continue continue
end end
@ -620,15 +597,13 @@ matchChildren = function(
-- ============================================================ -- ============================================================
-- Compute totalCost for ALL matched pairs (session cache helps) -- Compute totalCost for ALL matched pairs (session cache helps)
-- ============================================================ -- ============================================================
local totalCost: number = 0 local totalCost = 0
for _, pair in matched do for _, pair in ipairs(matched) do
totalCost += computePairCost( totalCost += computePairCost(session, pair.virtualId, pair.studioInstance, virtualInstances, math.huge)
session, pair.virtualId, pair.studioInstance, virtualInstances, math.huge
)
end end
totalCost += (#remainingVirtual + #remainingStudio) * UNMATCHED_PENALTY totalCost += (#remainingVirtual + #remainingStudio) * UNMATCHED_PENALTY
local result: MatchResult = { local result = {
matched = matched, matched = matched,
unmatchedVirtual = remainingVirtual, unmatchedVirtual = remainingVirtual,
unmatchedStudio = remainingStudio, unmatchedStudio = remainingStudio,

View file

@ -19,9 +19,7 @@ local function fuzzyEq(a: number, b: number): boolean
end end
local function color3Eq(a: Color3, b: Color3): boolean local function color3Eq(a: Color3, b: Color3): boolean
return math.floor(a.R * 255) == math.floor(b.R * 255) return fuzzyEq(a.R, b.R) and fuzzyEq(a.G, b.G) and fuzzyEq(a.B, b.B)
and math.floor(a.G * 255) == math.floor(b.G * 255)
and math.floor(a.B * 255) == math.floor(b.B * 255)
end end
local function vector2Eq(a: Vector2, b: Vector2): boolean local function vector2Eq(a: Vector2, b: Vector2): boolean
@ -35,10 +33,18 @@ end
local function cframeEq(a: CFrame, b: CFrame): boolean local function cframeEq(a: CFrame, b: CFrame): boolean
local ax, ay, az, aR00, aR01, aR02, aR10, aR11, aR12, aR20, aR21, aR22 = a:GetComponents() local ax, ay, az, aR00, aR01, aR02, aR10, aR11, aR12, aR20, aR21, aR22 = a:GetComponents()
local bx, by, bz, bR00, bR01, bR02, bR10, bR11, bR12, bR20, bR21, bR22 = b:GetComponents() local bx, by, bz, bR00, bR01, bR02, bR10, bR11, bR12, bR20, bR21, bR22 = b:GetComponents()
return fuzzyEq(ax, bx) and fuzzyEq(ay, by) and fuzzyEq(az, bz) return fuzzyEq(ax, bx)
and fuzzyEq(aR00, bR00) and fuzzyEq(aR01, bR01) and fuzzyEq(aR02, bR02) and fuzzyEq(ay, by)
and fuzzyEq(aR10, bR10) and fuzzyEq(aR11, bR11) and fuzzyEq(aR12, bR12) and fuzzyEq(az, bz)
and fuzzyEq(aR20, bR20) and fuzzyEq(aR21, bR21) and fuzzyEq(aR22, bR22) and fuzzyEq(aR00, bR00)
and fuzzyEq(aR01, bR01)
and fuzzyEq(aR02, bR02)
and fuzzyEq(aR10, bR10)
and fuzzyEq(aR11, bR11)
and fuzzyEq(aR12, bR12)
and fuzzyEq(aR20, bR20)
and fuzzyEq(aR21, bR21)
and fuzzyEq(aR22, bR22)
end end
local function trueEquals(a: any, b: any): boolean local function trueEquals(a: any, b: any): boolean
@ -54,11 +60,12 @@ local function trueEquals(a: any, b: any): boolean
end end
local t = typeof(a) local t = typeof(a)
if t ~= typeof(b) then local tb = typeof(b)
if t == "number" and typeof(b) == "EnumItem" then if t ~= tb then
if t == "number" and tb == "EnumItem" then
return a == (b :: EnumItem).Value return a == (b :: EnumItem).Value
end end
if t == "EnumItem" and typeof(b) == "number" then if t == "EnumItem" and tb == "number" then
return (a :: EnumItem).Value == b return (a :: EnumItem).Value == b
end end
return false return false

View file

@ -1350,6 +1350,13 @@ impl JobThreadContext {
Some(new_dir_path.join(file_name)); Some(new_dir_path.join(file_name));
new_ref_segment = new_ref_segment =
Some(slugified_new_name.clone()); Some(slugified_new_name.clone());
if patch_set.stage_ids.contains(&id) {
let old_init = dir_path.join(file_name);
pending_stage_paths
.retain(|p| *p != old_init);
pending_stage_paths
.push(new_dir_path.join(file_name));
}
let old_meta = grandparent.join(format!( let old_meta = grandparent.join(format!(
"{}.meta.json5", "{}.meta.json5",
dir_name dir_name
@ -1511,6 +1518,10 @@ impl JobThreadContext {
} else { } else {
overridden_source_path = Some(new_path.clone()); overridden_source_path = Some(new_path.clone());
new_ref_segment = Some(new_file_name.clone()); new_ref_segment = Some(new_file_name.clone());
if patch_set.stage_ids.contains(&id) {
pending_stage_paths.retain(|p| *p != *path);
pending_stage_paths.push(new_path.clone());
}
let old_meta = parent let old_meta = parent
.join(format!("{}.meta.json5", old_base)); .join(format!("{}.meta.json5", old_base));
let new_meta = parent.join(format!( let new_meta = parent.join(format!(
@ -1689,6 +1700,11 @@ impl JobThreadContext {
); );
} else { } else {
overridden_source_path = Some(new_path.clone()); overridden_source_path = Some(new_path.clone());
if patch_set.stage_ids.contains(&id) {
pending_stage_paths
.retain(|p| *p != actual_file);
pending_stage_paths.push(new_path.clone());
}
// For standalone files, the ref path // For standalone files, the ref path
// segment changes with the extension. // segment changes with the extension.
// For init files, the directory name // For init files, the directory name

View file

@ -72,8 +72,11 @@ impl ServeCommand {
loop { loop {
let (vfs, critical_errors) = Vfs::new_default_with_errors(); let (vfs, critical_errors) = Vfs::new_default_with_errors();
let session = let session = Arc::new(ServeSession::new(
Arc::new(ServeSession::new(vfs, project_path.clone(), Some(critical_errors))?); vfs,
project_path.clone(),
Some(critical_errors),
)?);
let server = LiveServer::new(session); let server = LiveServer::new(session);
log::info!("Listening: http://{}:{}", host, port); log::info!("Listening: http://{}:{}", host, port);
@ -94,7 +97,7 @@ fn run_live_syncback(project_path: &Path, payload: SyncbackPayload) -> anyhow::R
let new_dom = build_dom_from_chunks(payload)?; let new_dom = build_dom_from_chunks(payload)?;
let vfs = Vfs::new_oneshot(); let vfs = Vfs::new_oneshot();
let session_old = ServeSession::new_oneshot(vfs, project_path.to_path_buf())?; let session_old = ServeSession::new_oneshot(vfs, project_path)?;
let mut dom_old = session_old.tree(); let mut dom_old = session_old.tree();
@ -149,8 +152,8 @@ fn build_dom_from_chunks(payload: SyncbackPayload) -> anyhow::Result<WeakDom> {
created_services.insert(chunk.class_name.clone()); created_services.insert(chunk.class_name.clone());
if !chunk.data.is_empty() { if !chunk.data.is_empty() {
let chunk_dom = rbx_binary::from_reader(Cursor::new(&chunk.data)) let chunk_dom =
.with_context(|| { rbx_binary::from_reader(Cursor::new(&chunk.data)).with_context(|| {
format!("Failed to parse rbxm for service {}", chunk.class_name) format!("Failed to parse rbxm for service {}", chunk.class_name)
})?; })?;

View file

@ -33,6 +33,12 @@ pub struct MatchingSession {
cost_cache: RefCell<HashMap<(Ref, Ref), u32>>, cost_cache: RefCell<HashMap<(Ref, Ref), u32>>,
} }
impl Default for MatchingSession {
fn default() -> Self {
Self::new()
}
}
impl MatchingSession { impl MatchingSession {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
@ -116,7 +122,14 @@ pub fn match_forward(
let snap_remaining = snap_matched.iter().filter(|&&m| !m).count(); let snap_remaining = snap_matched.iter().filter(|&&m| !m).count();
let tree_remaining = tree_available.iter().filter(|&&a| a).count(); let tree_remaining = tree_available.iter().filter(|&&a| a).count();
if snap_remaining == 0 || tree_remaining == 0 { if snap_remaining == 0 || tree_remaining == 0 {
return build_result(snap_available, tree_children, &tree_available, matched, tree, session); return build_result(
snap_available,
tree_children,
&tree_available,
matched,
tree,
session,
);
} }
// ================================================================ // ================================================================
@ -186,7 +199,14 @@ pub fn match_forward(
} }
} }
build_result(snap_available, tree_children, &tree_available, matched, tree, session) build_result(
snap_available,
tree_children,
&tree_available,
matched,
tree,
session,
)
} }
/// Build the final result, consuming the snapshot children. /// Build the final result, consuming the snapshot children.
@ -1139,7 +1159,12 @@ mod tests {
make_snapshot_with_props("Wall", "Part", tree_props_b), make_snapshot_with_props("Wall", "Part", tree_props_b),
make_snapshot_with_props("Wall", "Part", tree_props_a), make_snapshot_with_props("Wall", "Part", tree_props_a),
]); ]);
let result = match_forward(vec![snap_a, snap_b], &tree_refs, &tree, &MatchingSession::new()); let result = match_forward(
vec![snap_a, snap_b],
&tree_refs,
&tree,
&MatchingSession::new(),
);
assert_eq!(result.matched.len(), 2, "Both should match"); assert_eq!(result.matched.len(), 2, "Both should match");

View file

@ -235,7 +235,14 @@ fn compute_children_patches(
// Matched pairs: recursively compute patches. // Matched pairs: recursively compute patches.
for (snapshot_child, tree_child_id) in match_result.matched { for (snapshot_child, tree_child_id) in match_result.matched {
compute_patch_set_internal(context, snapshot_child, tree, tree_child_id, patch_set, session); compute_patch_set_internal(
context,
snapshot_child,
tree,
tree_child_id,
patch_set,
session,
);
} }
// Unmatched snapshots: new instances to be added. // Unmatched snapshots: new instances to be added.

View file

@ -30,6 +30,12 @@ pub struct MatchingSession {
cost_cache: RefCell<HashMap<(Ref, Ref), u32>>, cost_cache: RefCell<HashMap<(Ref, Ref), u32>>,
} }
impl Default for MatchingSession {
fn default() -> Self {
Self::new()
}
}
impl MatchingSession { impl MatchingSession {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
@ -122,12 +128,19 @@ pub fn match_children(
let mut total_cost: u32 = 0; let mut total_cost: u32 = 0;
for &(new_ref, old_ref) in &matched { for &(new_ref, old_ref) in &matched {
total_cost = total_cost.saturating_add(compute_change_count( total_cost = total_cost.saturating_add(compute_change_count(
new_ref, old_ref, new_dom, old_dom, new_hashes, old_hashes, u32::MAX, 0, session, new_ref,
old_ref,
new_dom,
old_dom,
new_hashes,
old_hashes,
u32::MAX,
0,
session,
)); ));
} }
total_cost = total_cost.saturating_add( total_cost = total_cost
(remaining_new.len() + remaining_old.len()) as u32 * UNMATCHED_PENALTY, .saturating_add((remaining_new.len() + remaining_old.len()) as u32 * UNMATCHED_PENALTY);
);
return MatchResult { return MatchResult {
matched, matched,
unmatched_new: remaining_new, unmatched_new: remaining_new,
@ -178,7 +191,14 @@ pub fn match_children(
for &new_ref in &avail_new { for &new_ref in &avail_new {
for &old_ref in &avail_old { for &old_ref in &avail_old {
let cost = compute_change_count( let cost = compute_change_count(
new_ref, old_ref, new_dom, old_dom, new_hashes, old_hashes, best_so_far, 0, new_ref,
old_ref,
new_dom,
old_dom,
new_hashes,
old_hashes,
best_so_far,
0,
session, session,
); );
pairs.push((cost, new_ref, old_ref)); pairs.push((cost, new_ref, old_ref));
@ -208,12 +228,19 @@ pub fn match_children(
let mut total_cost: u32 = 0; let mut total_cost: u32 = 0;
for &(new_ref, old_ref) in &matched { for &(new_ref, old_ref) in &matched {
total_cost = total_cost.saturating_add(compute_change_count( total_cost = total_cost.saturating_add(compute_change_count(
new_ref, old_ref, new_dom, old_dom, new_hashes, old_hashes, u32::MAX, 0, session, new_ref,
old_ref,
new_dom,
old_dom,
new_hashes,
old_hashes,
u32::MAX,
0,
session,
)); ));
} }
total_cost = total_cost.saturating_add( total_cost = total_cost
(remaining_new.len() + remaining_old.len()) as u32 * UNMATCHED_PENALTY, .saturating_add((remaining_new.len() + remaining_old.len()) as u32 * UNMATCHED_PENALTY);
);
MatchResult { MatchResult {
matched, matched,
@ -545,7 +572,15 @@ mod tests {
let new_children: Vec<Ref> = new_dom.get_by_ref(new_root).unwrap().children().to_vec(); let new_children: Vec<Ref> = new_dom.get_by_ref(new_root).unwrap().children().to_vec();
let old_children: Vec<Ref> = old_dom.get_by_ref(old_root).unwrap().children().to_vec(); let old_children: Vec<Ref> = old_dom.get_by_ref(old_root).unwrap().children().to_vec();
let result = match_children(&new_children, &old_children, &new_dom, &old_dom, None, None, &MatchingSession::new()); let result = match_children(
&new_children,
&old_children,
&new_dom,
&old_dom,
None,
None,
&MatchingSession::new(),
);
assert_eq!(result.matched.len(), 2); assert_eq!(result.matched.len(), 2);
assert!(result.unmatched_new.is_empty()); assert!(result.unmatched_new.is_empty());
@ -585,7 +620,15 @@ mod tests {
let new_children: Vec<Ref> = new_dom.get_by_ref(new_root).unwrap().children().to_vec(); let new_children: Vec<Ref> = new_dom.get_by_ref(new_root).unwrap().children().to_vec();
let old_children: Vec<Ref> = old_dom.get_by_ref(old_root).unwrap().children().to_vec(); let old_children: Vec<Ref> = old_dom.get_by_ref(old_root).unwrap().children().to_vec();
let result = match_children(&new_children, &old_children, &new_dom, &old_dom, None, None, &MatchingSession::new()); let result = match_children(
&new_children,
&old_children,
&new_dom,
&old_dom,
None,
None,
&MatchingSession::new(),
);
assert_eq!(result.matched.len(), 2); assert_eq!(result.matched.len(), 2);
for (new_ref, old_ref) in &result.matched { for (new_ref, old_ref) in &result.matched {
@ -616,7 +659,15 @@ mod tests {
let new_children: Vec<Ref> = new_dom.get_by_ref(new_root).unwrap().children().to_vec(); let new_children: Vec<Ref> = new_dom.get_by_ref(new_root).unwrap().children().to_vec();
let old_children: Vec<Ref> = old_dom.get_by_ref(old_root).unwrap().children().to_vec(); let old_children: Vec<Ref> = old_dom.get_by_ref(old_root).unwrap().children().to_vec();
let result = match_children(&new_children, &old_children, &new_dom, &old_dom, None, None, &MatchingSession::new()); let result = match_children(
&new_children,
&old_children,
&new_dom,
&old_dom,
None,
None,
&MatchingSession::new(),
);
assert_eq!(result.matched.len(), 1); assert_eq!(result.matched.len(), 1);
assert_eq!(result.unmatched_new.len(), 1); assert_eq!(result.unmatched_new.len(), 1);
@ -638,7 +689,15 @@ mod tests {
let new_children: Vec<Ref> = new_dom.get_by_ref(new_root).unwrap().children().to_vec(); let new_children: Vec<Ref> = new_dom.get_by_ref(new_root).unwrap().children().to_vec();
let old_children: Vec<Ref> = old_dom.get_by_ref(old_root).unwrap().children().to_vec(); let old_children: Vec<Ref> = old_dom.get_by_ref(old_root).unwrap().children().to_vec();
let result = match_children(&new_children, &old_children, &new_dom, &old_dom, None, None, &MatchingSession::new()); let result = match_children(
&new_children,
&old_children,
&new_dom,
&old_dom,
None,
None,
&MatchingSession::new(),
);
assert_eq!(result.matched.len(), 2); assert_eq!(result.matched.len(), 2);
assert!(result.unmatched_new.is_empty()); assert!(result.unmatched_new.is_empty());
assert!(result.unmatched_old.is_empty()); assert!(result.unmatched_old.is_empty());
@ -648,7 +707,15 @@ mod tests {
fn empty_children() { fn empty_children() {
let new_dom = WeakDom::new(InstanceBuilder::new("DataModel")); let new_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
let old_dom = WeakDom::new(InstanceBuilder::new("DataModel")); let old_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
let result = match_children(&[], &[], &new_dom, &old_dom, None, None, &MatchingSession::new()); let result = match_children(
&[],
&[],
&new_dom,
&old_dom,
None,
None,
&MatchingSession::new(),
);
assert!(result.matched.is_empty()); assert!(result.matched.is_empty());
} }
@ -667,8 +734,24 @@ mod tests {
let new_children: Vec<Ref> = new_dom.get_by_ref(new_root).unwrap().children().to_vec(); let new_children: Vec<Ref> = new_dom.get_by_ref(new_root).unwrap().children().to_vec();
let old_children: Vec<Ref> = old_dom.get_by_ref(old_root).unwrap().children().to_vec(); let old_children: Vec<Ref> = old_dom.get_by_ref(old_root).unwrap().children().to_vec();
let r1 = match_children(&new_children, &old_children, &new_dom, &old_dom, None, None, &MatchingSession::new()); let r1 = match_children(
let r2 = match_children(&new_children, &old_children, &new_dom, &old_dom, None, None, &MatchingSession::new()); &new_children,
&old_children,
&new_dom,
&old_dom,
None,
None,
&MatchingSession::new(),
);
let r2 = match_children(
&new_children,
&old_children,
&new_dom,
&old_dom,
None,
None,
&MatchingSession::new(),
);
assert_eq!(r1.matched.len(), r2.matched.len()); assert_eq!(r1.matched.len(), r2.matched.len());
for (a, b) in r1.matched.iter().zip(r2.matched.iter()) { for (a, b) in r1.matched.iter().zip(r2.matched.iter()) {

View file

@ -29,9 +29,9 @@ use crate::{
web::{ web::{
interface::{ interface::{
ErrorResponse, Instance, InstanceMetadata, MessagesPacket, OpenResponse, ReadResponse, ErrorResponse, Instance, InstanceMetadata, MessagesPacket, OpenResponse, ReadResponse,
ServerInfoResponse, SocketPacket, SocketPacketBody, SocketPacketType, ServerInfoResponse, SocketPacket, SocketPacketBody, SocketPacketType, SubscribeMessage,
SubscribeMessage, SyncbackPayload, SyncbackRequest, WriteRequest, WriteResponse, SyncbackPayload, SyncbackRequest, WriteRequest, WriteResponse, PROTOCOL_VERSION,
PROTOCOL_VERSION, SERVER_VERSION, SERVER_VERSION,
}, },
util::{deserialize_msgpack, msgpack, msgpack_ok, serialize_msgpack}, util::{deserialize_msgpack, msgpack, msgpack_ok, serialize_msgpack},
}, },
@ -256,7 +256,7 @@ async fn handle_api_syncback(
}; };
syncback_signal.fire(payload); syncback_signal.fire(payload);
msgpack_ok(&serde_json::json!({"status": "syncback_initiated"})) msgpack_ok(serde_json::json!({"status": "syncback_initiated"}))
} }
pub struct ApiService { pub struct ApiService {

View file

@ -41,12 +41,15 @@ impl SyncbackSignal {
} }
pub fn fire(&self, payload: SyncbackPayload) { pub fn fire(&self, payload: SyncbackPayload) {
*self.payload.lock().unwrap() = Some(payload); *self.payload.lock().unwrap_or_else(|e| e.into_inner()) = Some(payload);
self.notify.notify_one(); self.notify.notify_one();
} }
pub fn take_payload(&self) -> Option<SyncbackPayload> { pub fn take_payload(&self) -> Option<SyncbackPayload> {
self.payload.lock().unwrap().take() self.payload
.lock()
.unwrap_or_else(|e| e.into_inner())
.take()
} }
} }

View file

@ -505,8 +505,10 @@ pub fn make_rbxl_from_chunks(chunks: &[librojo::web_api::ServiceChunk]) -> Vec<u
let chunk_dom = rbx_binary::from_reader(Cursor::new(&chunk.data)) let chunk_dom = rbx_binary::from_reader(Cursor::new(&chunk.data))
.unwrap_or_else(|e| panic!("Failed to parse rbxm for {}: {e}", chunk.class_name)); .unwrap_or_else(|e| panic!("Failed to parse rbxm for {}: {e}", chunk.class_name));
let service_ref = let service_ref = dom.insert(
dom.insert(root_ref, rbx_dom_weak::InstanceBuilder::new(&chunk.class_name)); root_ref,
rbx_dom_weak::InstanceBuilder::new(&chunk.class_name),
);
let mut ref_map: HashMap<Ref, Ref> = HashMap::new(); let mut ref_map: HashMap<Ref, Ref> = HashMap::new();
for &child_ref in chunk_dom.root().children() { for &child_ref in chunk_dom.root().children() {
@ -554,10 +556,8 @@ pub fn make_rbxl_from_chunks(chunks: &[librojo::web_api::ServiceChunk]) -> Vec<u
"VoiceChatService", "VoiceChatService",
"Workspace", "Workspace",
]; ];
let existing: std::collections::HashSet<String> = chunks let existing: std::collections::HashSet<String> =
.iter() chunks.iter().map(|c| c.class_name.clone()).collect();
.map(|c| c.class_name.clone())
.collect();
for &svc in &visible_services { for &svc in &visible_services {
if !existing.contains(svc) { if !existing.contains(svc) {
dom.insert(root_ref, rbx_dom_weak::InstanceBuilder::new(svc)); dom.insert(root_ref, rbx_dom_weak::InstanceBuilder::new(svc));

View file

@ -899,3 +899,53 @@ fn batch_mixed_stage_and_source_write() {
); );
} }
} }
// ===========================================================================
// stage_ids survives rename (Fix 2 from audit)
// ===========================================================================
#[test]
fn stage_ids_stages_new_path_after_rename() {
let mut session = TestServeSession::new_with_git("git_sync_defaults", |path| {
git_commit_all(path, "initial commit");
});
let info = session.wait_to_come_online();
let read = session.get_api_read(info.root_instance_id).unwrap();
let (module_id, _) =
find_instance_by_name(&read.instances, "ModuleA").expect("ModuleA should exist");
let old_path = session.path().join("src/ModuleA.luau");
let new_path = session.path().join("src/RenamedModule.luau");
assert!(old_path.exists(), "ModuleA.luau should exist before rename");
let write_request = WriteRequest {
session_id: info.session_id,
removed: vec![],
added: HashMap::new(),
updated: vec![InstanceUpdate {
id: module_id,
changed_name: Some("RenamedModule".to_string()),
changed_class_name: None,
changed_properties: UstrMap::default(),
changed_metadata: None,
}],
stage_ids: vec![module_id],
};
session.post_api_write(&write_request).unwrap();
thread::sleep(Duration::from_millis(1000));
assert!(
new_path.exists(),
"RenamedModule.luau should exist after rename"
);
assert!(
!old_path.exists(),
"ModuleA.luau should be gone after rename"
);
assert!(
git_is_staged(session.path(), "src/RenamedModule.luau"),
"New file (RenamedModule.luau) should be staged after rename + stage_ids"
);
}

View file

@ -9,11 +9,7 @@ use crate::rojo_test::{
use librojo::web_api::{ServiceChunk, SocketPacketType, PROTOCOL_VERSION}; use librojo::web_api::{ServiceChunk, SocketPacketType, PROTOCOL_VERSION};
fn assert_live_matches_cli( fn assert_live_matches_cli(fixture_name: &str, chunks: &[ServiceChunk], place_id: Option<u64>) {
fixture_name: &str,
chunks: &[ServiceChunk],
place_id: Option<u64>,
) {
run_serve_test(fixture_name, |session, _| { run_serve_test(fixture_name, |session, _| {
session.post_api_syncback(place_id, chunks.to_vec()); session.post_api_syncback(place_id, chunks.to_vec());
session.wait_to_come_back_online(); session.wait_to_come_back_online();
@ -32,10 +28,16 @@ fn parity_scripts() {
vec![ vec![
InstanceBuilder::new("ModuleScript") InstanceBuilder::new("ModuleScript")
.with_name("Utils") .with_name("Utils")
.with_property("Source", rbx_dom_weak::types::Variant::String("return {}".into())), .with_property(
"Source",
rbx_dom_weak::types::Variant::String("return {}".into()),
),
InstanceBuilder::new("Script") InstanceBuilder::new("Script")
.with_name("Main") .with_name("Main")
.with_property("Source", rbx_dom_weak::types::Variant::String("print('hi')".into())) .with_property(
"Source",
rbx_dom_weak::types::Variant::String("print('hi')".into()),
)
.with_property( .with_property(
"RunContext", "RunContext",
rbx_dom_weak::types::Variant::Enum(rbx_dom_weak::types::Enum::from_u32(1)), rbx_dom_weak::types::Variant::Enum(rbx_dom_weak::types::Enum::from_u32(1)),
@ -117,7 +119,10 @@ fn parity_mixed_file_types() {
vec![ vec![
InstanceBuilder::new("StringValue") InstanceBuilder::new("StringValue")
.with_name("MyString") .with_name("MyString")
.with_property("Value", rbx_dom_weak::types::Variant::String("hello world".into())), .with_property(
"Value",
rbx_dom_weak::types::Variant::String("hello world".into()),
),
InstanceBuilder::new("Folder").with_name("Container"), InstanceBuilder::new("Folder").with_name("Container"),
InstanceBuilder::new("ModuleScript") InstanceBuilder::new("ModuleScript")
.with_name("DataModule") .with_name("DataModule")

View file

@ -309,7 +309,8 @@ fn forward_matching_ufowave_textures() {
// Run forward sync matching // Run forward sync matching
let session = librojo::snapshot::matching::MatchingSession::new(); let session = librojo::snapshot::matching::MatchingSession::new();
let result = librojo::snapshot::matching::match_forward(snap_textures, &tree_textures, &tree, &session); let result =
librojo::snapshot::matching::match_forward(snap_textures, &tree_textures, &tree, &session);
assert_eq!( assert_eq!(
result.matched.len(), result.matched.len(),