Skip to content

Commit 0507d6c

Browse files
committed
fix(rbx): lock services against ClearAllChildren and reparenting too
Completes the service-singleton protection from 36d36c0: the verification pass noted two remaining same-class paths a mod could use to detach/destroy a shared service. - Instance.Parent setter refuses protected singletons (services/Camera) — Roblox locks a service's Parent; reparenting/nil-ing it would detach it so game:GetService stops resolving it. - game:ClearAllChildren() destroys only non-protected children, leaving services/Camera intact (GetChildren is a snapshot, so destroying while iterating is safe). Test extended: reparenting a service errors and ClearAllChildren leaves it resolvable. CoreAI.Mods.Tests 660/660 green.
1 parent 36d36c0 commit 0507d6c

3 files changed

Lines changed: 30 additions & 5 deletions

File tree

Assets/CoreAI/CHANGELOG.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,10 @@ surface. Plus the reasoning/think-block streaming fix and a per-frame allocation
4646
- **Shape visual identified by an owned reference**, not the child name "Shape" — a mod that names one
4747
of its own child instances "Shape" is no longer destroyed (and mistaken for the part's visual) on a
4848
shape switch.
49-
- **Services and the canonical Camera can't be `Destroy()`d or `Clone()`d** from Lua — one mod can no
50-
longer brick `UserInputService`/Lighting/etc. for the whole shared world (Destroy errors, Clone → nil).
49+
- **Services and the canonical Camera are locked against a mod removing them**`Destroy()` errors,
50+
`Clone()` returns nil, `.Parent = …` errors, and `game:ClearAllChildren()` skips them, so one mod can
51+
no longer brick `UserInputService`/Lighting/etc. for the whole shared world. Internal world teardown
52+
still tears them down directly.
5153
- **Full-tier withheld stubs** also register when Full is granted but the Full surface is unwired, so a
5254
`unity_*` call raises the actionable error instead of a bare "attempt to call a nil value".
5355
- **Reasoning promotion (streaming)** is whitespace-aware, matching the non-streaming path — a lone

Assets/CoreAIMods/Runtime/Scripting/LuaCs/LuaCsRobloxInstanceBindings.cs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,15 @@ public static LuaTable BuildInstanceMeta(LuaCsRobloxModContext context)
180180
// WHY: no destroyed pre-check here — the Domain setter raises the exact
181181
// D6 PARENT_LOCKED message for destroyed instances.
182182
context.RequireWorldEdit("setting Instance.Parent");
183+
if (IsProtectedSingleton(self))
184+
{
185+
// WHY: a service's Parent is locked in Roblox — reparenting (or nil-ing)
186+
// it would detach it so game:GetService stops resolving it for the world.
187+
throw RbxError.BadArgument(
188+
self.ClassName + ".Parent is locked — it is a shared singleton",
189+
"services and workspace.CurrentCamera are fixed for the world's lifetime");
190+
}
191+
183192
self.Parent = ReadOptionalInstance(value, "Instance.Parent assignment");
184193
return LuaValue.Nil;
185194
case "Archivable":
@@ -307,7 +316,17 @@ void Method(string name, Func<LuaFunctionExecutionContext, RbxInstance, LuaValue
307316
Method("ClearAllChildren", (_, self) =>
308317
{
309318
context.RequireWorldEdit("Instance:ClearAllChildren");
310-
self.ClearAllChildren();
319+
// WHY: game:ClearAllChildren() must not wipe the world's services (Roblox locks
320+
// them). GetChildren returns a snapshot, so destroying non-protected children while
321+
// iterating is safe; protected singletons (services/Camera) are left intact.
322+
foreach (RbxInstance child in self.GetChildren())
323+
{
324+
if (!IsProtectedSingleton(child))
325+
{
326+
child.Destroy();
327+
}
328+
}
329+
311330
return LuaValue.Nil;
312331
});
313332

Assets/CoreAIMods/Tests/EditMode/RbxApi/LuaBindings/RobloxInputLuaBindingsEditModeTests.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -413,9 +413,13 @@ public void Lua_UserInputService_CannotBeDestroyedOrCloned()
413413
local uis = game:GetService('UserInputService')
414414
local okClone, clone = pcall(function() return uis:Clone() end)
415415
assert(okClone and clone == nil, 'Clone of a service must return nil')
416-
local okDestroy, err = pcall(function() uis:Destroy() end)
416+
local okDestroy = pcall(function() uis:Destroy() end)
417417
assert(not okDestroy, 'Destroy of a service must error')
418-
assert(game:GetService('UserInputService') == uis, 'the service survives the attempt')");
418+
local okParent = pcall(function() uis.Parent = nil end)
419+
assert(not okParent, 'reparenting a service must error')
420+
game:ClearAllChildren()
421+
assert(game:GetService('UserInputService') == uis,
422+
'the service survives Destroy/reparent/ClearAllChildren')");
419423
}
420424

421425
[Test]

0 commit comments

Comments
 (0)