azul-sync 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitattributes +1 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +31 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
- package/README.md +142 -0
- package/dist/build.d.ts +19 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +92 -0
- package/dist/build.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +397 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +26 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +105 -0
- package/dist/config.js.map +1 -0
- package/dist/fs/fileWriter.d.ts +100 -0
- package/dist/fs/fileWriter.d.ts.map +1 -0
- package/dist/fs/fileWriter.js +342 -0
- package/dist/fs/fileWriter.js.map +1 -0
- package/dist/fs/treeManager.d.ts +84 -0
- package/dist/fs/treeManager.d.ts.map +1 -0
- package/dist/fs/treeManager.js +365 -0
- package/dist/fs/treeManager.js.map +1 -0
- package/dist/fs/watcher.d.ts +39 -0
- package/dist/fs/watcher.d.ts.map +1 -0
- package/dist/fs/watcher.js +120 -0
- package/dist/fs/watcher.js.map +1 -0
- package/dist/index.d.ts +61 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +349 -0
- package/dist/index.js.map +1 -0
- package/dist/ipc/httpPolling.d.ts +56 -0
- package/dist/ipc/httpPolling.d.ts.map +1 -0
- package/dist/ipc/httpPolling.js +171 -0
- package/dist/ipc/httpPolling.js.map +1 -0
- package/dist/ipc/messages.d.ts +112 -0
- package/dist/ipc/messages.d.ts.map +1 -0
- package/dist/ipc/messages.js +5 -0
- package/dist/ipc/messages.js.map +1 -0
- package/dist/ipc/server.d.ts +50 -0
- package/dist/ipc/server.d.ts.map +1 -0
- package/dist/ipc/server.js +168 -0
- package/dist/ipc/server.js.map +1 -0
- package/dist/pack.d.ts +19 -0
- package/dist/pack.d.ts.map +1 -0
- package/dist/pack.js +225 -0
- package/dist/pack.js.map +1 -0
- package/dist/push.d.ts +43 -0
- package/dist/push.d.ts.map +1 -0
- package/dist/push.js +532 -0
- package/dist/push.js.map +1 -0
- package/dist/rojo.d.ts +9 -0
- package/dist/rojo.d.ts.map +1 -0
- package/dist/rojo.js +114 -0
- package/dist/rojo.js.map +1 -0
- package/dist/snapshot/rojo.d.ts +39 -0
- package/dist/snapshot/rojo.d.ts.map +1 -0
- package/dist/snapshot/rojo.js +364 -0
- package/dist/snapshot/rojo.js.map +1 -0
- package/dist/snapshot.d.ts +23 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +132 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/sourcemap/generator.d.ts +78 -0
- package/dist/sourcemap/generator.d.ts.map +1 -0
- package/dist/sourcemap/generator.js +351 -0
- package/dist/sourcemap/generator.js.map +1 -0
- package/dist/sourcemap/propertyLoader.d.ts +19 -0
- package/dist/sourcemap/propertyLoader.d.ts.map +1 -0
- package/dist/sourcemap/propertyLoader.js +131 -0
- package/dist/sourcemap/propertyLoader.js.map +1 -0
- package/dist/util/id.d.ts +9 -0
- package/dist/util/id.d.ts.map +1 -0
- package/dist/util/id.js +14 -0
- package/dist/util/id.js.map +1 -0
- package/dist/util/log.d.ts +13 -0
- package/dist/util/log.d.ts.map +1 -0
- package/dist/util/log.js +51 -0
- package/dist/util/log.js.map +1 -0
- package/docs/assets/azul-logo.pdn +0 -0
- package/docs/assets/logo-200px.png +0 -0
- package/docs/assets/logo.png +0 -0
- package/docs/assets/plugin/toolbox.png +0 -0
- package/docs/assets/synced.png +0 -0
- package/package.json +41 -0
- package/plugin/README.md +54 -0
- package/plugin/sourcemap.json +264 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/Actor/AzulSync.server.luau +905 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/AzulService.luau +1010 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/Config.luau +29 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/Enums.luau +11 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/CollapsibleTitledSection.luau +214 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/ColorPicker.luau +360 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/CustomTextButton.luau +170 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/DropdownMenu.luau +363 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/HorizontalLine.luau +43 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/ImageButtonWithText.luau +181 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledCheckbox.luau +295 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledColorInputPicker.luau +294 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledMultiChoice.luau +163 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledNumberInput.luau +312 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledRadioButton.luau +55 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledSlider.luau +151 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledTextInput.luau +222 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledToggleButton.luau +73 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/StatefulImageButton.luau +125 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/VerticalScrollingFrame.luau +100 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/VerticalSpacer.luau +35 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/VerticallyScalingListFrame.luau +107 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/GuiUtilities.luau +429 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/RbxGui.luau +4363 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/UI.luau +425 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/WebSocketClient.luau +161 -0
- package/src/build.ts +120 -0
- package/src/cli.ts +496 -0
- package/src/config.ts +170 -0
- package/src/fs/fileWriter.ts +414 -0
- package/src/fs/treeManager.ts +458 -0
- package/src/fs/watcher.ts +142 -0
- package/src/index.ts +450 -0
- package/src/ipc/httpPolling.ts +214 -0
- package/src/ipc/messages.ts +159 -0
- package/src/ipc/server.ts +196 -0
- package/src/pack.ts +309 -0
- package/src/push.ts +726 -0
- package/src/snapshot/rojo.ts +467 -0
- package/src/snapshot.ts +161 -0
- package/src/sourcemap/generator.ts +504 -0
- package/src/sourcemap/propertyLoader.ts +195 -0
- package/src/util/id.ts +15 -0
- package/src/util/log.ts +94 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,1010 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
local AzulService = {}
|
|
3
|
+
|
|
4
|
+
local ScriptEditorService = game:GetService("ScriptEditorService")
|
|
5
|
+
|
|
6
|
+
-- Services
|
|
7
|
+
|
|
8
|
+
-- Modules
|
|
9
|
+
local parent = script.Parent
|
|
10
|
+
if not parent then error("AzulService: missing parent container") end
|
|
11
|
+
local CONFIG = require("./Config")
|
|
12
|
+
local Enums = require("./Enums")
|
|
13
|
+
local HttpService = game:GetService("HttpService")
|
|
14
|
+
local ReflectionService = nil
|
|
15
|
+
|
|
16
|
+
do
|
|
17
|
+
local ok, service = pcall(function()
|
|
18
|
+
return game:GetService("ReflectionService")
|
|
19
|
+
end)
|
|
20
|
+
if ok then ReflectionService = service end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
-- Logging helpers
|
|
24
|
+
local function debugPrint(...)
|
|
25
|
+
if CONFIG.SILENT_MODE or not CONFIG.DEBUG_MODE then return end
|
|
26
|
+
print(`[🐛 AzulService]:`, ...)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
local function infoPrint(...)
|
|
30
|
+
if CONFIG.SILENT_MODE then return end
|
|
31
|
+
print(`[AzulService]:`, ...)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
local function isScript(instance)
|
|
35
|
+
return instance:IsA("Script") or instance:IsA("LocalScript") or instance:IsA("ModuleScript")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
local FALLBACK_SERIALIZABLE_PROPERTIES = {
|
|
39
|
+
"Archivable",
|
|
40
|
+
"Enabled",
|
|
41
|
+
"RunContext",
|
|
42
|
+
"LinkedSource",
|
|
43
|
+
"Disabled",
|
|
44
|
+
"Visible",
|
|
45
|
+
"Active",
|
|
46
|
+
"AutomaticSize",
|
|
47
|
+
"AnchorPoint",
|
|
48
|
+
"Position",
|
|
49
|
+
"Rotation",
|
|
50
|
+
"Size",
|
|
51
|
+
"Text",
|
|
52
|
+
"TextColor3",
|
|
53
|
+
"TextSize",
|
|
54
|
+
"BackgroundColor3",
|
|
55
|
+
"BackgroundTransparency",
|
|
56
|
+
"Image",
|
|
57
|
+
"ImageColor3",
|
|
58
|
+
"ImageTransparency",
|
|
59
|
+
"LayoutOrder",
|
|
60
|
+
"ZIndex",
|
|
61
|
+
"AutoButtonColor",
|
|
62
|
+
"RichText",
|
|
63
|
+
"TextScaled",
|
|
64
|
+
"TextWrapped",
|
|
65
|
+
"FontFace",
|
|
66
|
+
"Transparency",
|
|
67
|
+
"Color",
|
|
68
|
+
"Material",
|
|
69
|
+
"Reflectance",
|
|
70
|
+
"CanCollide",
|
|
71
|
+
"CanTouch",
|
|
72
|
+
"CanQuery",
|
|
73
|
+
"CastShadow",
|
|
74
|
+
"Locked",
|
|
75
|
+
"Massless",
|
|
76
|
+
"Shape",
|
|
77
|
+
"SizeConstraint",
|
|
78
|
+
"CFrame",
|
|
79
|
+
"Orientation",
|
|
80
|
+
"PivotOffset",
|
|
81
|
+
"BrickColor",
|
|
82
|
+
"TopSurface",
|
|
83
|
+
"BottomSurface",
|
|
84
|
+
"LeftSurface",
|
|
85
|
+
"RightSurface",
|
|
86
|
+
"FrontSurface",
|
|
87
|
+
"BackSurface",
|
|
88
|
+
"MeshId",
|
|
89
|
+
"TextureID",
|
|
90
|
+
"DoubleSided",
|
|
91
|
+
"Offset",
|
|
92
|
+
"Scale",
|
|
93
|
+
"SoundId",
|
|
94
|
+
"Volume",
|
|
95
|
+
"PlaybackSpeed",
|
|
96
|
+
"Looped",
|
|
97
|
+
"RollOffMode",
|
|
98
|
+
"RollOffMaxDistance",
|
|
99
|
+
"RollOffMinDistance",
|
|
100
|
+
"PlayOnRemove",
|
|
101
|
+
"AnimationId",
|
|
102
|
+
"Value",
|
|
103
|
+
"CurrentCamera",
|
|
104
|
+
"FieldOfView",
|
|
105
|
+
"Ambient",
|
|
106
|
+
"Brightness",
|
|
107
|
+
"ClockTime",
|
|
108
|
+
"FogColor",
|
|
109
|
+
"FogEnd",
|
|
110
|
+
"FogStart",
|
|
111
|
+
"GlobalShadows",
|
|
112
|
+
"OutdoorAmbient",
|
|
113
|
+
"Technology",
|
|
114
|
+
"PrimaryPart",
|
|
115
|
+
"WorldPivot",
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
local FALLBACK_SERIALIZABLE_PROPERTY_SET: { [string]: boolean } = {}
|
|
119
|
+
for _, propertyName in ipairs(FALLBACK_SERIALIZABLE_PROPERTIES) do
|
|
120
|
+
FALLBACK_SERIALIZABLE_PROPERTY_SET[propertyName] = true
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
local SERIALIZABLE_PROPERTIES_CACHE: { string }? = nil
|
|
124
|
+
local SERIALIZABLE_PROPERTY_SET_CACHE: { [string]: boolean }? = nil
|
|
125
|
+
|
|
126
|
+
local function buildSerializablePropertiesFromReflection(): { string }
|
|
127
|
+
if not ReflectionService then return FALLBACK_SERIALIZABLE_PROPERTIES end
|
|
128
|
+
|
|
129
|
+
local classOk, classes = pcall(function()
|
|
130
|
+
return ReflectionService:GetClasses()
|
|
131
|
+
end)
|
|
132
|
+
|
|
133
|
+
if not classOk or type(classes) ~= "table" then return FALLBACK_SERIALIZABLE_PROPERTIES end
|
|
134
|
+
|
|
135
|
+
local propertySet: { [string]: boolean } = {}
|
|
136
|
+
|
|
137
|
+
for _, classInfo in ipairs(classes) do
|
|
138
|
+
local className = classInfo.Name
|
|
139
|
+
if type(className) ~= "string" then continue end
|
|
140
|
+
|
|
141
|
+
local propsOk, reflectedProperties = pcall(function()
|
|
142
|
+
return ReflectionService:GetPropertiesOfClass(className, {
|
|
143
|
+
ExcludeDisplay = true,
|
|
144
|
+
})
|
|
145
|
+
end)
|
|
146
|
+
|
|
147
|
+
if not propsOk or type(reflectedProperties) ~= "table" then continue end
|
|
148
|
+
|
|
149
|
+
for _, reflected in ipairs(reflectedProperties) do
|
|
150
|
+
local reflectedName = reflected.Name
|
|
151
|
+
if type(reflectedName) ~= "string" then continue end
|
|
152
|
+
|
|
153
|
+
if
|
|
154
|
+
reflected.Serialized == true
|
|
155
|
+
and reflectedName ~= "Name"
|
|
156
|
+
and reflectedName ~= "Parent"
|
|
157
|
+
and reflectedName ~= "Source"
|
|
158
|
+
then
|
|
159
|
+
propertySet[reflectedName] = true
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
for _, fallbackProperty in ipairs(FALLBACK_SERIALIZABLE_PROPERTIES) do
|
|
165
|
+
propertySet[fallbackProperty] = true
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
local properties = {}
|
|
169
|
+
for propertyName in pairs(propertySet) do
|
|
170
|
+
table.insert(properties, propertyName)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
table.sort(properties)
|
|
174
|
+
|
|
175
|
+
if #properties == 0 then return FALLBACK_SERIALIZABLE_PROPERTIES end
|
|
176
|
+
|
|
177
|
+
return properties
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
local function getSerializableProperties(): { string }
|
|
181
|
+
local cached = SERIALIZABLE_PROPERTIES_CACHE
|
|
182
|
+
if cached then return cached end
|
|
183
|
+
|
|
184
|
+
local built = buildSerializablePropertiesFromReflection()
|
|
185
|
+
SERIALIZABLE_PROPERTIES_CACHE = built
|
|
186
|
+
return built
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
local writablePropertiesByClass: { [string]: { [string]: boolean } } = {}
|
|
190
|
+
local defaultSerializedPropertiesByClass: { [string]: { [string]: any } } = {}
|
|
191
|
+
local canonicalPropertyByLowerByClass: { [string]: { [string]: string } } = {}
|
|
192
|
+
|
|
193
|
+
local function toSet(items: { string }): { [string]: boolean }
|
|
194
|
+
local set = {}
|
|
195
|
+
for _, item in ipairs(items) do
|
|
196
|
+
set[item] = true
|
|
197
|
+
end
|
|
198
|
+
return set
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
local function getSerializablePropertySet(): { [string]: boolean }
|
|
202
|
+
local cached = SERIALIZABLE_PROPERTY_SET_CACHE
|
|
203
|
+
if cached then return cached end
|
|
204
|
+
|
|
205
|
+
local built = toSet(getSerializableProperties())
|
|
206
|
+
SERIALIZABLE_PROPERTY_SET_CACHE = built
|
|
207
|
+
return built
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
local function getWritableSerializablePropertySetForClass(className: string): { [string]: boolean }
|
|
211
|
+
local cached = writablePropertiesByClass[className]
|
|
212
|
+
if cached then return cached end
|
|
213
|
+
|
|
214
|
+
local serializablePropertySet = getSerializablePropertySet()
|
|
215
|
+
local fallback = {}
|
|
216
|
+
for propertyName in pairs(serializablePropertySet) do
|
|
217
|
+
fallback[propertyName] = true
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
if not ReflectionService then
|
|
221
|
+
writablePropertiesByClass[className] = fallback
|
|
222
|
+
return fallback
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
local ok, reflectedProperties = pcall(function()
|
|
226
|
+
return ReflectionService:GetPropertiesOfClass(className)
|
|
227
|
+
end)
|
|
228
|
+
|
|
229
|
+
if not ok or type(reflectedProperties) ~= "table" then
|
|
230
|
+
writablePropertiesByClass[className] = fallback
|
|
231
|
+
return fallback
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
local filtered = {}
|
|
235
|
+
for _, reflected in ipairs(reflectedProperties) do
|
|
236
|
+
local reflectedName = reflected.Name
|
|
237
|
+
if type(reflectedName) ~= "string" then continue end
|
|
238
|
+
|
|
239
|
+
if not serializablePropertySet[reflectedName] then continue end
|
|
240
|
+
|
|
241
|
+
local includedByFallback = FALLBACK_SERIALIZABLE_PROPERTY_SET[reflectedName] == true
|
|
242
|
+
if reflected.Serialized ~= true and not includedByFallback then continue end
|
|
243
|
+
|
|
244
|
+
filtered[reflectedName] = true
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
if next(filtered) == nil then filtered = fallback end
|
|
248
|
+
|
|
249
|
+
writablePropertiesByClass[className] = filtered
|
|
250
|
+
return filtered
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
local function getCanonicalPropertyByLowerForClass(className: string): { [string]: string }
|
|
254
|
+
local cached = canonicalPropertyByLowerByClass[className]
|
|
255
|
+
if cached then return cached end
|
|
256
|
+
|
|
257
|
+
local allowed = getWritableSerializablePropertySetForClass(className)
|
|
258
|
+
local byLower = {}
|
|
259
|
+
for propertyName in pairs(allowed) do
|
|
260
|
+
byLower[string.lower(propertyName)] = propertyName
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
canonicalPropertyByLowerByClass[className] = byLower
|
|
264
|
+
return byLower
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
local function resolveWritablePropertyName(instance: Instance, propertyName: string): string?
|
|
268
|
+
local allowed = getWritableSerializablePropertySetForClass(instance.ClassName)
|
|
269
|
+
if allowed[propertyName] then return propertyName end
|
|
270
|
+
|
|
271
|
+
local canonicalByLower = getCanonicalPropertyByLowerForClass(instance.ClassName)
|
|
272
|
+
return canonicalByLower[string.lower(propertyName)]
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
local function serializedValuesEqual(left, right): boolean
|
|
276
|
+
if type(left) ~= type(right) then return false end
|
|
277
|
+
|
|
278
|
+
if type(left) ~= "table" then return left == right end
|
|
279
|
+
|
|
280
|
+
for key, value in pairs(left) do
|
|
281
|
+
if not serializedValuesEqual(value, right[key]) then return false end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
for key in pairs(right) do
|
|
285
|
+
if left[key] == nil then return false end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
return true
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
local serializeValue
|
|
292
|
+
|
|
293
|
+
local function readAndSerializeProperty(instance: Instance, propertyName: string)
|
|
294
|
+
local ok, value = pcall(function()
|
|
295
|
+
return (instance :: any)[propertyName]
|
|
296
|
+
end)
|
|
297
|
+
if not ok then return nil end
|
|
298
|
+
|
|
299
|
+
return serializeValue(value)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
local function getDefaultSerializedPropertiesForClass(className: string): { [string]: any }
|
|
303
|
+
local cached = defaultSerializedPropertiesByClass[className]
|
|
304
|
+
if cached then return cached end
|
|
305
|
+
|
|
306
|
+
local defaults = {}
|
|
307
|
+
local ok, tempInstance = pcall(function()
|
|
308
|
+
return Instance.new(className)
|
|
309
|
+
end)
|
|
310
|
+
|
|
311
|
+
if not ok or not tempInstance then
|
|
312
|
+
defaultSerializedPropertiesByClass[className] = defaults
|
|
313
|
+
return defaults
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
local allowedProperties = getWritableSerializablePropertySetForClass(className)
|
|
317
|
+
for propertyName in pairs(allowedProperties) do
|
|
318
|
+
local serialized = readAndSerializeProperty(tempInstance, propertyName)
|
|
319
|
+
if serialized ~= nil then defaults[propertyName] = serialized end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
tempInstance:Destroy()
|
|
323
|
+
defaultSerializedPropertiesByClass[className] = defaults
|
|
324
|
+
return defaults
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
serializeValue = function(value: any): any
|
|
328
|
+
local kind = typeof(value)
|
|
329
|
+
|
|
330
|
+
if kind == "nil" then return nil end
|
|
331
|
+
if kind == "boolean" or kind == "string" or kind == "number" then return value end
|
|
332
|
+
|
|
333
|
+
if kind == "Color3" then
|
|
334
|
+
local color = value :: Color3
|
|
335
|
+
return { __type = "Color3", r = color.R, g = color.G, b = color.B }
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
if kind == "Vector2" then
|
|
339
|
+
local vector = value :: Vector2
|
|
340
|
+
return { __type = "Vector2", x = vector.X, y = vector.Y }
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
if kind == "Vector3" then
|
|
344
|
+
local vector = value :: Vector3
|
|
345
|
+
return { __type = "Vector3", x = vector.X, y = vector.Y, z = vector.Z }
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
if kind == "UDim" then
|
|
349
|
+
local u = value :: UDim
|
|
350
|
+
return { __type = "UDim", scale = u.Scale, offset = u.Offset }
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
if kind == "UDim2" then
|
|
354
|
+
local u = value :: UDim2
|
|
355
|
+
return {
|
|
356
|
+
__type = "UDim2",
|
|
357
|
+
xScale = u.X.Scale,
|
|
358
|
+
xOffset = u.X.Offset,
|
|
359
|
+
yScale = u.Y.Scale,
|
|
360
|
+
yOffset = u.Y.Offset,
|
|
361
|
+
}
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
if kind == "Rect" then
|
|
365
|
+
local rect = value :: Rect
|
|
366
|
+
return {
|
|
367
|
+
__type = "Rect",
|
|
368
|
+
minX = rect.Min.X,
|
|
369
|
+
minY = rect.Min.Y,
|
|
370
|
+
maxX = rect.Max.X,
|
|
371
|
+
maxY = rect.Max.Y,
|
|
372
|
+
}
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
if kind == "CFrame" then
|
|
376
|
+
local cf = value :: CFrame
|
|
377
|
+
return { __type = "CFrame", components = { cf:GetComponents() } }
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
if kind == "BrickColor" then
|
|
381
|
+
local brick = value :: BrickColor
|
|
382
|
+
return { __type = "BrickColor", number = brick.Number }
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
if kind == "EnumItem" then
|
|
386
|
+
local enumItem = value :: EnumItem
|
|
387
|
+
return {
|
|
388
|
+
__type = "EnumItem",
|
|
389
|
+
enumType = tostring(enumItem.EnumType),
|
|
390
|
+
name = enumItem.Name,
|
|
391
|
+
}
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
if kind == "NumberRange" then
|
|
395
|
+
local range = value :: NumberRange
|
|
396
|
+
return { __type = "NumberRange", min = range.Min, max = range.Max }
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
if kind == "NumberSequence" then
|
|
400
|
+
local sequence = value :: NumberSequence
|
|
401
|
+
local keypoints = {}
|
|
402
|
+
for _, keypoint in ipairs(sequence.Keypoints) do
|
|
403
|
+
table.insert(keypoints, {
|
|
404
|
+
time = keypoint.Time,
|
|
405
|
+
value = keypoint.Value,
|
|
406
|
+
envelope = keypoint.Envelope,
|
|
407
|
+
})
|
|
408
|
+
end
|
|
409
|
+
return { __type = "NumberSequence", keypoints = keypoints }
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
if kind == "ColorSequence" then
|
|
413
|
+
local sequence = value :: ColorSequence
|
|
414
|
+
local keypoints = {}
|
|
415
|
+
for _, keypoint in ipairs(sequence.Keypoints) do
|
|
416
|
+
table.insert(keypoints, {
|
|
417
|
+
time = keypoint.Time,
|
|
418
|
+
color = serializeValue(keypoint.Value),
|
|
419
|
+
})
|
|
420
|
+
end
|
|
421
|
+
return { __type = "ColorSequence", keypoints = keypoints }
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
if kind == "Font" then
|
|
425
|
+
local font = value :: Font
|
|
426
|
+
return {
|
|
427
|
+
__type = "Font",
|
|
428
|
+
family = font.Family,
|
|
429
|
+
weight = font.Weight and font.Weight.Value or nil,
|
|
430
|
+
style = font.Style and font.Style.Value or nil,
|
|
431
|
+
}
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
if kind == "PhysicalProperties" then
|
|
435
|
+
local props = value :: PhysicalProperties
|
|
436
|
+
return {
|
|
437
|
+
__type = "PhysicalProperties",
|
|
438
|
+
density = props.Density,
|
|
439
|
+
friction = props.Friction,
|
|
440
|
+
elasticity = props.Elasticity,
|
|
441
|
+
frictionWeight = props.FrictionWeight,
|
|
442
|
+
elasticityWeight = props.ElasticityWeight,
|
|
443
|
+
}
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
return nil
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
local function deserializeValue(value: any): any
|
|
450
|
+
if type(value) ~= "table" then return value end
|
|
451
|
+
|
|
452
|
+
local record = value :: { [string]: any }
|
|
453
|
+
local valueType = record.__type
|
|
454
|
+
if type(valueType) ~= "string" then return value end
|
|
455
|
+
|
|
456
|
+
if valueType == "Color3" then return Color3.new(record.r, record.g, record.b) end
|
|
457
|
+
if valueType == "Vector2" then return Vector2.new(record.x, record.y) end
|
|
458
|
+
if valueType == "Vector3" then return Vector3.new(record.x, record.y, record.z) end
|
|
459
|
+
if valueType == "UDim" then return UDim.new(record.scale, record.offset) end
|
|
460
|
+
if valueType == "UDim2" then return UDim2.new(record.xScale, record.xOffset, record.yScale, record.yOffset) end
|
|
461
|
+
if valueType == "Rect" then return Rect.new(record.minX, record.minY, record.maxX, record.maxY) end
|
|
462
|
+
if valueType == "CFrame" and type(record.components) == "table" then
|
|
463
|
+
return CFrame.new(unpack(record.components))
|
|
464
|
+
end
|
|
465
|
+
if valueType == "BrickColor" then return BrickColor.new(record.number) end
|
|
466
|
+
if valueType == "EnumItem" then
|
|
467
|
+
if type(record.enumType) ~= "string" or type(record.name) ~= "string" then return nil end
|
|
468
|
+
local enumName = record.enumType:match("Enum%.(.+)") or record.enumType
|
|
469
|
+
local enum = Enum[enumName]
|
|
470
|
+
if enum then return enum[record.name] end
|
|
471
|
+
return nil
|
|
472
|
+
end
|
|
473
|
+
if valueType == "NumberRange" then return NumberRange.new(record.min, record.max) end
|
|
474
|
+
if valueType == "NumberSequence" then
|
|
475
|
+
local keypoints = {}
|
|
476
|
+
for _, keypoint in ipairs(record.keypoints or {}) do
|
|
477
|
+
table.insert(keypoints, NumberSequenceKeypoint.new(keypoint.time, keypoint.value, keypoint.envelope or 0))
|
|
478
|
+
end
|
|
479
|
+
if #keypoints > 0 then return NumberSequence.new(keypoints) end
|
|
480
|
+
return nil
|
|
481
|
+
end
|
|
482
|
+
if valueType == "ColorSequence" then
|
|
483
|
+
local keypoints = {}
|
|
484
|
+
for _, keypoint in ipairs(record.keypoints or {}) do
|
|
485
|
+
local color = deserializeValue(keypoint.color)
|
|
486
|
+
if typeof(color) == "Color3" then
|
|
487
|
+
table.insert(keypoints, ColorSequenceKeypoint.new(keypoint.time, color))
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
if #keypoints > 0 then return ColorSequence.new(keypoints) end
|
|
491
|
+
return nil
|
|
492
|
+
end
|
|
493
|
+
if valueType == "Font" then
|
|
494
|
+
local weight = Enum.FontWeight.Regular
|
|
495
|
+
local style = Enum.FontStyle.Normal
|
|
496
|
+
if type(record.weight) == "number" then
|
|
497
|
+
for _, item in ipairs(Enum.FontWeight:GetEnumItems()) do
|
|
498
|
+
if item.Value == record.weight then
|
|
499
|
+
weight = item
|
|
500
|
+
break
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
if type(record.style) == "number" then
|
|
505
|
+
for _, item in ipairs(Enum.FontStyle:GetEnumItems()) do
|
|
506
|
+
if item.Value == record.style then
|
|
507
|
+
style = item
|
|
508
|
+
break
|
|
509
|
+
end
|
|
510
|
+
end
|
|
511
|
+
end
|
|
512
|
+
return Font.new(record.family, weight, style)
|
|
513
|
+
end
|
|
514
|
+
if valueType == "PhysicalProperties" then
|
|
515
|
+
return PhysicalProperties.new(
|
|
516
|
+
record.density,
|
|
517
|
+
record.friction,
|
|
518
|
+
record.elasticity,
|
|
519
|
+
record.frictionWeight,
|
|
520
|
+
record.elasticityWeight
|
|
521
|
+
)
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
return nil
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
local function collectSerializedProperties(instance: Instance): { [string]: any }
|
|
528
|
+
local properties = {}
|
|
529
|
+
local className = instance.ClassName
|
|
530
|
+
local allowedProperties = getWritableSerializablePropertySetForClass(className)
|
|
531
|
+
local defaultProperties = getDefaultSerializedPropertiesForClass(className)
|
|
532
|
+
local serializableProperties = getSerializableProperties()
|
|
533
|
+
|
|
534
|
+
for _, propertyName in ipairs(serializableProperties) do
|
|
535
|
+
if not allowedProperties[propertyName] then continue end
|
|
536
|
+
|
|
537
|
+
if propertyName == "Name" or propertyName == "Parent" then continue end
|
|
538
|
+
|
|
539
|
+
local serialized = readAndSerializeProperty(instance, propertyName)
|
|
540
|
+
if serialized ~= nil then
|
|
541
|
+
local defaultSerialized = defaultProperties[propertyName]
|
|
542
|
+
if defaultSerialized == nil or not serializedValuesEqual(serialized, defaultSerialized) then
|
|
543
|
+
properties[propertyName] = serialized
|
|
544
|
+
end
|
|
545
|
+
end
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
return properties
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
local function applySerializedProperties(instance: Instance, properties: { [string]: any }?)
|
|
552
|
+
if type(properties) ~= "table" then return end
|
|
553
|
+
|
|
554
|
+
for propertyName, serialized in pairs(properties) do
|
|
555
|
+
if propertyName ~= "Name" and propertyName ~= "Parent" and propertyName ~= "Source" then
|
|
556
|
+
local resolvedProperty = resolveWritablePropertyName(instance, propertyName)
|
|
557
|
+
if not resolvedProperty then continue end
|
|
558
|
+
|
|
559
|
+
local deserialized = deserializeValue(serialized)
|
|
560
|
+
if deserialized ~= nil then
|
|
561
|
+
pcall(function()
|
|
562
|
+
(instance :: any)[resolvedProperty] = deserialized
|
|
563
|
+
end)
|
|
564
|
+
end
|
|
565
|
+
end
|
|
566
|
+
end
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
local function applySerializedAttributes(instance: Instance, attributes: { [string]: any }?)
|
|
570
|
+
if type(attributes) ~= "table" then return end
|
|
571
|
+
|
|
572
|
+
for key, value in pairs(attributes) do
|
|
573
|
+
pcall(function()
|
|
574
|
+
instance:SetAttribute(key, value)
|
|
575
|
+
end)
|
|
576
|
+
end
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
--[=[
|
|
580
|
+
Utility: Rebuild service set from config
|
|
581
|
+
@param serviceSet { [string]: boolean } - The service set to rebuild
|
|
582
|
+
]=]
|
|
583
|
+
function AzulService.rebuildServiceSet(serviceSet)
|
|
584
|
+
table.clear(serviceSet)
|
|
585
|
+
for _, name in ipairs(CONFIG.SERVICE_LIST) do
|
|
586
|
+
serviceSet[name] = true
|
|
587
|
+
end
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
--- Utility: Clear all GUID attributes
|
|
591
|
+
--- This is for legacy support; we now use :GetDebugId() for stable GUIDs
|
|
592
|
+
function AzulService.clearAllGUIDAttributes()
|
|
593
|
+
local guidAttributeName = plugin:GetSetting("GUID_ATTRIBUTE") or "AzulSyncGUID"
|
|
594
|
+
|
|
595
|
+
for _, instance in game:GetDescendants() do
|
|
596
|
+
if instance:GetAttribute(guidAttributeName) then instance:SetAttribute(guidAttributeName, nil) end
|
|
597
|
+
end
|
|
598
|
+
infoPrint("Cleared all legacy GUID attributes from instances.")
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
--- Utility: Check if instance should be excluded from sync
|
|
602
|
+
function AzulService.isExcluded(instance: Instance, serviceSet: { [string]: boolean })
|
|
603
|
+
if not instance then return true end
|
|
604
|
+
|
|
605
|
+
local fullName = instance:GetFullName()
|
|
606
|
+
for _, ancestorName in CONFIG.EXCLUDED_PARENTS do
|
|
607
|
+
if fullName:find(ancestorName) then return true end
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
-- Walk up to the service
|
|
611
|
+
local current: Instance? = instance
|
|
612
|
+
while current do
|
|
613
|
+
if current.Parent == game then
|
|
614
|
+
local inList = serviceSet[current.Name] ~= nil
|
|
615
|
+
|
|
616
|
+
if CONFIG.LIST_TYPE == Enums.listType.WHITELIST then
|
|
617
|
+
-- Whitelist: only allow services in the list
|
|
618
|
+
-- debugPrint(`Included {instance} in whitelist`)
|
|
619
|
+
return not inList
|
|
620
|
+
else
|
|
621
|
+
-- Blacklist: exclude services in the list
|
|
622
|
+
-- debugPrint(`Excluded {instance} in blacklist`)
|
|
623
|
+
return inList
|
|
624
|
+
end
|
|
625
|
+
end
|
|
626
|
+
current = current.Parent
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
-- Not under DataModel
|
|
630
|
+
return true
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
--- Utility: Get or create instance by path segments
|
|
634
|
+
function AzulService.getOrCreatePath(pathSegments: { string }): Instance
|
|
635
|
+
local current: Instance = game
|
|
636
|
+
for index, segment in ipairs(pathSegments) do
|
|
637
|
+
local nextNode: Instance? = current:FindFirstChild(segment)
|
|
638
|
+
|
|
639
|
+
if not nextNode then
|
|
640
|
+
if index == 1 then
|
|
641
|
+
local ok, service = pcall(function()
|
|
642
|
+
return game:GetService(segment)
|
|
643
|
+
end)
|
|
644
|
+
if ok and service then nextNode = service end
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
if not nextNode then
|
|
648
|
+
local newFolder = Instance.new("Folder")
|
|
649
|
+
newFolder.Name = segment
|
|
650
|
+
newFolder.Parent = current
|
|
651
|
+
nextNode = newFolder
|
|
652
|
+
end
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
current = nextNode :: Instance
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
return current
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
function AzulService.isProtectedRobloxContainer(instance: Instance?): boolean
|
|
662
|
+
if not instance then return false end
|
|
663
|
+
|
|
664
|
+
-- Top-level services cannot be destroyed or reparented
|
|
665
|
+
local okService = pcall(function()
|
|
666
|
+
return game:GetService(instance.Name)
|
|
667
|
+
end)
|
|
668
|
+
if instance.Parent == game and okService then return true end
|
|
669
|
+
|
|
670
|
+
-- Certain StarterPlayer children are locked (StarterPlayerScripts, StarterCharacterScripts, StarterGear)
|
|
671
|
+
local parent = instance.Parent
|
|
672
|
+
if parent and parent.ClassName == "StarterPlayer" then
|
|
673
|
+
local name = instance.Name
|
|
674
|
+
if name == "StarterPlayerScripts" or name == "StarterCharacterScripts" or name == "StarterGear" then
|
|
675
|
+
return true
|
|
676
|
+
end
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
return false
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
--- Utility: Generate or retrieve GUID for instance (use Roblox debug IDs to avoid attribute churn)
|
|
683
|
+
function AzulService.getOrCreateGUID(
|
|
684
|
+
instance,
|
|
685
|
+
trackedInstances: { [Instance]: string },
|
|
686
|
+
guidMap: { [string]: Instance },
|
|
687
|
+
usedGuids: { [string]: boolean }
|
|
688
|
+
): string
|
|
689
|
+
local cached = trackedInstances[instance]
|
|
690
|
+
if cached then return cached end
|
|
691
|
+
|
|
692
|
+
local guid = instance:GetDebugId(0)
|
|
693
|
+
trackedInstances[instance] = guid
|
|
694
|
+
guidMap[guid] = instance
|
|
695
|
+
usedGuids[guid] = true
|
|
696
|
+
return guid
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
--- Utility: Get instance path
|
|
700
|
+
function AzulService.getInstancePath(instance: Instance): { string }?
|
|
701
|
+
local path = {}
|
|
702
|
+
local current: Instance? = instance
|
|
703
|
+
|
|
704
|
+
while current and current ~= game do
|
|
705
|
+
table.insert(path, 1, current.Name)
|
|
706
|
+
current = current.Parent
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
-- If current became nil, the instance is no longer under DataModel
|
|
710
|
+
if current ~= game then return nil end
|
|
711
|
+
|
|
712
|
+
return path
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
--- Convert instance to data format
|
|
716
|
+
function AzulService.instanceToData(
|
|
717
|
+
instance,
|
|
718
|
+
trackedInstances: { [Instance]: string },
|
|
719
|
+
guidMap: { [string]: Instance },
|
|
720
|
+
usedGuids: { [string]: boolean },
|
|
721
|
+
options: { includeProperties: boolean? }?
|
|
722
|
+
): { [string]: any }?
|
|
723
|
+
local guid = AzulService.getOrCreateGUID(instance, trackedInstances, guidMap, usedGuids)
|
|
724
|
+
local path = AzulService.getInstancePath(instance)
|
|
725
|
+
if not path then return nil end
|
|
726
|
+
|
|
727
|
+
local parentGuid: string? = nil
|
|
728
|
+
local parent = instance.Parent
|
|
729
|
+
if parent then
|
|
730
|
+
if parent == game then
|
|
731
|
+
parentGuid = "root"
|
|
732
|
+
else
|
|
733
|
+
parentGuid = AzulService.getOrCreateGUID(parent, trackedInstances, guidMap, usedGuids)
|
|
734
|
+
end
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
local data: { [string]: any } = {
|
|
738
|
+
guid = guid,
|
|
739
|
+
className = instance.ClassName,
|
|
740
|
+
name = instance.Name,
|
|
741
|
+
path = path,
|
|
742
|
+
parentGuid = parentGuid,
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
if isScript(instance) then data.source = (instance :: Script).Source end
|
|
746
|
+
|
|
747
|
+
if options and options.includeProperties then
|
|
748
|
+
local properties = collectSerializedProperties(instance)
|
|
749
|
+
if next(properties) ~= nil then data.properties = properties end
|
|
750
|
+
|
|
751
|
+
local attributes = instance:GetAttributes()
|
|
752
|
+
if next(attributes) ~= nil then data.attributes = attributes end
|
|
753
|
+
end
|
|
754
|
+
|
|
755
|
+
return data
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
-- Utility: set script source safely (handles large sources via ScriptEditorService)
|
|
759
|
+
function AzulService.setScriptSource(scriptInstance: Script | LocalScript | ModuleScript, source: string)
|
|
760
|
+
local ok, err = pcall(function()
|
|
761
|
+
if ScriptEditorService and ScriptEditorService.UpdateSourceAsync then
|
|
762
|
+
ScriptEditorService:UpdateSourceAsync(scriptInstance, function(...)
|
|
763
|
+
return source
|
|
764
|
+
end)
|
|
765
|
+
else
|
|
766
|
+
(scriptInstance :: Script).Source = source
|
|
767
|
+
end
|
|
768
|
+
end)
|
|
769
|
+
|
|
770
|
+
if not ok then warn(`[AzulService]: Failed to set script source for {scriptInstance}`, err) end
|
|
771
|
+
|
|
772
|
+
return ok
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
--- Create/update instance data with dedup tracking (returns data if changed, nil if duplicate)
|
|
776
|
+
function AzulService.instanceToDataDedup(
|
|
777
|
+
instance: Instance,
|
|
778
|
+
trackedInstances: { [Instance]: string },
|
|
779
|
+
guidMap: { [string]: Instance },
|
|
780
|
+
usedGuids: { [string]: boolean },
|
|
781
|
+
lastInstanceUpdate: { [string]: { key: string, t: number } }
|
|
782
|
+
): { [string]: any }?
|
|
783
|
+
local data = AzulService.instanceToData(instance, trackedInstances, guidMap, usedGuids)
|
|
784
|
+
if not data then return nil end
|
|
785
|
+
|
|
786
|
+
local guid = data.guid :: string
|
|
787
|
+
local parentKey = (data.parentGuid :: string?) or ""
|
|
788
|
+
local key = table.concat(data.path :: { string }, "/")
|
|
789
|
+
.. "|"
|
|
790
|
+
.. (data.className :: string)
|
|
791
|
+
.. "|"
|
|
792
|
+
.. (data.name :: string)
|
|
793
|
+
.. "|"
|
|
794
|
+
.. parentKey
|
|
795
|
+
local now = tick()
|
|
796
|
+
local last = lastInstanceUpdate[guid]
|
|
797
|
+
if last and last.key == key and now - last.t < 0.05 then
|
|
798
|
+
-- Ignore duplicate bursts from multiple property signals firing at once
|
|
799
|
+
return nil
|
|
800
|
+
end
|
|
801
|
+
lastInstanceUpdate[guid] = { key = key, t = now }
|
|
802
|
+
|
|
803
|
+
return data
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
--- Apply snapshot instances to the workspace
|
|
807
|
+
function AzulService.applySnapshotInstances(instances: { any }): (number, number)
|
|
808
|
+
table.sort(instances, function(a, b)
|
|
809
|
+
return #a.path < #b.path
|
|
810
|
+
end)
|
|
811
|
+
|
|
812
|
+
local siblingOrdinalByPathClass: { [string]: number } = {}
|
|
813
|
+
|
|
814
|
+
local lockedContainerParents = {
|
|
815
|
+
StarterPlayerScripts = "StarterPlayer",
|
|
816
|
+
StarterCharacterScripts = "StarterPlayer",
|
|
817
|
+
StarterGear = "StarterPlayer",
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
local created = 0
|
|
821
|
+
local updated = 0
|
|
822
|
+
|
|
823
|
+
for _, item in ipairs(instances) do
|
|
824
|
+
local adjustedPath = {}
|
|
825
|
+
for i, segment in ipairs(item.path) do
|
|
826
|
+
adjustedPath[i] = segment
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
local protectedContainerName: string? = nil
|
|
830
|
+
local first = adjustedPath[1]
|
|
831
|
+
local maybeParent = lockedContainerParents[first]
|
|
832
|
+
if maybeParent and maybeParent ~= first then
|
|
833
|
+
table.insert(adjustedPath, 1, maybeParent)
|
|
834
|
+
protectedContainerName = adjustedPath[2]
|
|
835
|
+
end
|
|
836
|
+
|
|
837
|
+
if not protectedContainerName and #adjustedPath >= 2 then
|
|
838
|
+
local second = adjustedPath[2]
|
|
839
|
+
if lockedContainerParents[second] == adjustedPath[1] then protectedContainerName = second end
|
|
840
|
+
end
|
|
841
|
+
|
|
842
|
+
local serviceCandidate: Instance? = nil
|
|
843
|
+
if #adjustedPath == 1 then
|
|
844
|
+
local okService, service = pcall(function()
|
|
845
|
+
return game:GetService(adjustedPath[1])
|
|
846
|
+
end)
|
|
847
|
+
if okService then serviceCandidate = service end
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
local parentPath = {}
|
|
851
|
+
for i = 1, #adjustedPath - 1 do
|
|
852
|
+
parentPath[i] = adjustedPath[i]
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
local parent = AzulService.getOrCreatePath(parentPath)
|
|
856
|
+
local existing: Instance? = serviceCandidate
|
|
857
|
+
if not existing then
|
|
858
|
+
local targetName = adjustedPath[#adjustedPath]
|
|
859
|
+
local targetClassName = item.className
|
|
860
|
+
local occurrenceKey = table.concat(adjustedPath, "\u{1F}") .. "::" .. tostring(targetClassName)
|
|
861
|
+
local occurrenceOrdinal = (siblingOrdinalByPathClass[occurrenceKey] or 0) + 1
|
|
862
|
+
siblingOrdinalByPathClass[occurrenceKey] = occurrenceOrdinal
|
|
863
|
+
|
|
864
|
+
local sameNameChildren = {}
|
|
865
|
+
for _, child in ipairs(parent:GetChildren()) do
|
|
866
|
+
if child.Name == targetName then table.insert(sameNameChildren, child) end
|
|
867
|
+
end
|
|
868
|
+
|
|
869
|
+
existing = sameNameChildren[occurrenceOrdinal]
|
|
870
|
+
end
|
|
871
|
+
local instance = existing
|
|
872
|
+
|
|
873
|
+
local function isScriptClass(className: string)
|
|
874
|
+
return className == "Script" or className == "LocalScript" or className == "ModuleScript"
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
if existing and existing.ClassName ~= item.className then
|
|
878
|
+
local targetIsScript = isScriptClass(item.className)
|
|
879
|
+
if targetIsScript then
|
|
880
|
+
if AzulService.isProtectedRobloxContainer(existing) then
|
|
881
|
+
instance = existing
|
|
882
|
+
else
|
|
883
|
+
existing:Destroy()
|
|
884
|
+
instance = nil
|
|
885
|
+
end
|
|
886
|
+
else
|
|
887
|
+
instance = existing
|
|
888
|
+
end
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
if not instance and protectedContainerName then
|
|
892
|
+
local protectedParentName = lockedContainerParents[protectedContainerName]
|
|
893
|
+
if protectedParentName then
|
|
894
|
+
local okParent, protectedParent = pcall(function()
|
|
895
|
+
return game:GetService(protectedParentName)
|
|
896
|
+
end)
|
|
897
|
+
if okParent and protectedParent then
|
|
898
|
+
local protectedExisting = protectedParent:FindFirstChild(protectedContainerName)
|
|
899
|
+
if protectedExisting then instance = protectedExisting end
|
|
900
|
+
end
|
|
901
|
+
end
|
|
902
|
+
end
|
|
903
|
+
|
|
904
|
+
if not instance then
|
|
905
|
+
local newInstance
|
|
906
|
+
local okNew, createdInstance = pcall(function()
|
|
907
|
+
return Instance.new(item.className)
|
|
908
|
+
end)
|
|
909
|
+
if okNew and createdInstance then
|
|
910
|
+
newInstance = createdInstance
|
|
911
|
+
else
|
|
912
|
+
newInstance = Instance.new("Folder")
|
|
913
|
+
end
|
|
914
|
+
newInstance.Name = adjustedPath[#adjustedPath]
|
|
915
|
+
newInstance.Parent = parent
|
|
916
|
+
instance = newInstance
|
|
917
|
+
created += 1
|
|
918
|
+
else
|
|
919
|
+
if not serviceCandidate and not AzulService.isProtectedRobloxContainer(instance) then
|
|
920
|
+
instance.Name = adjustedPath[#adjustedPath]
|
|
921
|
+
end
|
|
922
|
+
end
|
|
923
|
+
|
|
924
|
+
if instance then
|
|
925
|
+
applySerializedProperties(instance, item.properties)
|
|
926
|
+
applySerializedAttributes(instance, item.attributes)
|
|
927
|
+
end
|
|
928
|
+
|
|
929
|
+
if isScriptClass(item.className) and item.source and instance then
|
|
930
|
+
AzulService.setScriptSource(instance :: Script, item.source)
|
|
931
|
+
updated += 1
|
|
932
|
+
end
|
|
933
|
+
end
|
|
934
|
+
|
|
935
|
+
return created, updated
|
|
936
|
+
end
|
|
937
|
+
|
|
938
|
+
--- Find the push config ModuleScript
|
|
939
|
+
function AzulService.findPushConfigModule(pushConfigPath: { string }): ModuleScript?
|
|
940
|
+
local current: Instance = game
|
|
941
|
+
for index, segment in ipairs(pushConfigPath) do
|
|
942
|
+
if index == 1 then
|
|
943
|
+
local ok, service = pcall(function()
|
|
944
|
+
return game:GetService(segment)
|
|
945
|
+
end)
|
|
946
|
+
if not ok or not service then return nil end
|
|
947
|
+
current = service
|
|
948
|
+
else
|
|
949
|
+
local nextNode = current:FindFirstChild(segment)
|
|
950
|
+
if not nextNode then return nil end
|
|
951
|
+
current = nextNode
|
|
952
|
+
end
|
|
953
|
+
end
|
|
954
|
+
|
|
955
|
+
if current and current:IsA("ModuleScript") then return current :: ModuleScript end
|
|
956
|
+
|
|
957
|
+
return nil
|
|
958
|
+
end
|
|
959
|
+
|
|
960
|
+
--- Read and validate push config from ModuleScript
|
|
961
|
+
function AzulService.readPushConfig(pushConfigPath: { string }): ({ [string]: any }?, string?)
|
|
962
|
+
local module = AzulService.findPushConfigModule(pushConfigPath)
|
|
963
|
+
if not module then return nil, "Push config ModuleScript not found" end
|
|
964
|
+
|
|
965
|
+
-- Clone module to force require to refresh data
|
|
966
|
+
local moduleClone = module:Clone()
|
|
967
|
+
moduleClone.Parent = module.Parent
|
|
968
|
+
module:Destroy()
|
|
969
|
+
module = moduleClone
|
|
970
|
+
--
|
|
971
|
+
|
|
972
|
+
local ok, data = pcall(require, module)
|
|
973
|
+
if not ok then return nil, `Failed to require push config: {data}` end
|
|
974
|
+
|
|
975
|
+
if type(data) ~= "table" then return nil, "Push config is not a table" end
|
|
976
|
+
|
|
977
|
+
debugPrint(`Push config loaded from {module}: {HttpService:JSONEncode(data)}`)
|
|
978
|
+
|
|
979
|
+
local mappings = {}
|
|
980
|
+
if type(data.pushMappings) == "table" then
|
|
981
|
+
for _, entry in ipairs(data.pushMappings) do
|
|
982
|
+
if type(entry) == "table" and type(entry.source) == "string" and type(entry.destination) == "table" then
|
|
983
|
+
local dest = {}
|
|
984
|
+
for _, seg in ipairs(entry.destination) do
|
|
985
|
+
if type(seg) == "string" and seg ~= "" then table.insert(dest, seg) end
|
|
986
|
+
end
|
|
987
|
+
|
|
988
|
+
if #dest > 0 then
|
|
989
|
+
table.insert(mappings, {
|
|
990
|
+
source = entry.source,
|
|
991
|
+
destination = dest,
|
|
992
|
+
destructive = entry.destructive == true,
|
|
993
|
+
rojoMode = entry.rojoMode == true,
|
|
994
|
+
})
|
|
995
|
+
end
|
|
996
|
+
end
|
|
997
|
+
end
|
|
998
|
+
end
|
|
999
|
+
|
|
1000
|
+
local configPayload = {
|
|
1001
|
+
mappings = mappings,
|
|
1002
|
+
port = data.port,
|
|
1003
|
+
debugMode = data.debugMode,
|
|
1004
|
+
deleteOrphansOnConnect = data.deleteOrphansOnConnect,
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
return configPayload, nil
|
|
1008
|
+
end
|
|
1009
|
+
|
|
1010
|
+
return AzulService
|