Skip to content

Commit ad7c376

Browse files
committed
perf(mods): bounded, stack-safe world query walker shared by both VMs (F-15)
Extracts the duplicated recursive scene walk from the MoonSharp and Lua-CSharp query bindings into WorldQuerySceneWalker.CollectByName: explicit Stack traversal (no deep-hierarchy recursion risk) with a MaxVisitedNodes=10000 budget — a no-match query no longer walks an unbounded scene on the main thread; truncation logs a one-shot warning without changing the Lua result shape. +tests: 15k-wide no-match truncation, 5k-deep stack safety.
1 parent 1ded0a6 commit ad7c376

4 files changed

Lines changed: 196 additions & 62 deletions

File tree

Assets/CoreAIMods/Runtime/WorldBindings/CoreAiWorldQueryLuaBindings.cs

Lines changed: 8 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,13 @@ public void RegisterGameplayApis(LuaApiRegistry registry)
6363
GameObject[] rootObjects = SceneManager.GetActiveScene().GetRootGameObjects();
6464
List<object> results = new();
6565

66-
for (int i = 0; i < rootObjects.Length; i++)
66+
bool truncated = WorldQuerySceneWalker.CollectByName(
67+
rootObjects, searchPattern, MaxFindResults, MatchesPattern, results);
68+
if (truncated)
6769
{
68-
CollectObjectsRecursive(rootObjects[i], searchPattern, results);
69-
if (results.Count >= MaxFindResults)
70-
{
71-
break;
72-
}
70+
Debug.LogWarning(
71+
$"coreai_world_find: visited-node budget ({WorldQuerySceneWalker.MaxVisitedNodes}) " +
72+
"reached before the scene walk finished; results may be incomplete.");
7373
}
7474

7575
return results;
@@ -128,32 +128,9 @@ public void RegisterGameplayApis(LuaApiRegistry registry)
128128
}));
129129
}
130130

131-
private static void CollectObjectsRecursive(GameObject parent, string searchPattern, List<object> results)
131+
private static bool MatchesPattern(string objectName, string searchPattern)
132132
{
133-
if (parent == null || results.Count >= MaxFindResults)
134-
{
135-
return;
136-
}
137-
138-
if (string.IsNullOrEmpty(searchPattern) ||
139-
parent.name.IndexOf(searchPattern, StringComparison.OrdinalIgnoreCase) >= 0)
140-
{
141-
results.Add(parent.name);
142-
}
143-
144-
if (results.Count >= MaxFindResults)
145-
{
146-
return;
147-
}
148-
149-
for (int i = 0; i < parent.transform.childCount; i++)
150-
{
151-
CollectObjectsRecursive(parent.transform.GetChild(i).gameObject, searchPattern, results);
152-
if (results.Count >= MaxFindResults)
153-
{
154-
return;
155-
}
156-
}
133+
return objectName.IndexOf(searchPattern, StringComparison.OrdinalIgnoreCase) >= 0;
157134
}
158135

159136
private static bool IsFinite(double value)

Assets/CoreAIMods/Runtime/WorldBindings/LuaCsWorldQueryBindings.cs

Lines changed: 8 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,13 @@ public void RegisterGameplayApis(LuaCsApiRegistry registry)
6868
GameObject[] rootObjects = SceneManager.GetActiveScene().GetRootGameObjects();
6969
List<object> results = new();
7070

71-
for (int i = 0; i < rootObjects.Length; i++)
71+
bool truncated = WorldQuerySceneWalker.CollectByName(
72+
rootObjects, searchPattern, MaxFindResults, MatchesPattern, results);
73+
if (truncated)
7274
{
73-
CollectObjectsRecursive(rootObjects[i], searchPattern, results);
74-
if (results.Count >= MaxFindResults)
75-
{
76-
break;
77-
}
75+
Debug.LogWarning(
76+
$"coreai_world_find: visited-node budget ({WorldQuerySceneWalker.MaxVisitedNodes}) " +
77+
"reached before the scene walk finished; results may be incomplete.");
7878
}
7979

8080
return results;
@@ -133,32 +133,9 @@ public void RegisterGameplayApis(LuaCsApiRegistry registry)
133133
}));
134134
}
135135

136-
private static void CollectObjectsRecursive(GameObject parent, string searchPattern, List<object> results)
136+
private static bool MatchesPattern(string objectName, string searchPattern)
137137
{
138-
if (parent == null || results.Count >= MaxFindResults)
139-
{
140-
return;
141-
}
142-
143-
if (string.IsNullOrEmpty(searchPattern) ||
144-
parent.name.IndexOf(searchPattern, StringComparison.OrdinalIgnoreCase) >= 0)
145-
{
146-
results.Add(parent.name);
147-
}
148-
149-
if (results.Count >= MaxFindResults)
150-
{
151-
return;
152-
}
153-
154-
for (int i = 0; i < parent.transform.childCount; i++)
155-
{
156-
CollectObjectsRecursive(parent.transform.GetChild(i).gameObject, searchPattern, results);
157-
if (results.Count >= MaxFindResults)
158-
{
159-
return;
160-
}
161-
}
138+
return objectName.IndexOf(searchPattern, StringComparison.OrdinalIgnoreCase) >= 0;
162139
}
163140

164141
private static bool IsFinite(double value)
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
using System.Collections.Generic;
2+
using UnityEngine;
3+
4+
namespace CoreAI.Infrastructure.World
5+
{
6+
/// <summary>
7+
/// Shared iterative scene-hierarchy walker used by both the MoonSharp
8+
/// (<see cref="CoreAiWorldQueryLuaBindings"/>) and Lua-CSharp (<c>LuaCsWorldQueryBindings</c>)
9+
/// world query bindings. Bounds traversal with a visited-node budget so a no-match query over a
10+
/// deep or wide scene cannot walk the entire hierarchy on the main thread, and uses an explicit
11+
/// stack instead of recursion to avoid stack-overflow risk on deep hierarchies.
12+
/// </summary>
13+
public static class WorldQuerySceneWalker
14+
{
15+
/// <summary>
16+
/// Maximum number of GameObjects visited by <see cref="CollectByName"/> before the walk is
17+
/// truncated, regardless of how many matches (if any) have been found so far.
18+
/// </summary>
19+
public const int MaxVisitedNodes = 10_000;
20+
21+
/// <summary>Matches a candidate object's name against a search pattern.</summary>
22+
public delegate bool NameMatch(string objectName, string searchPattern);
23+
24+
/// <summary>
25+
/// Walks <paramref name="rootObjects"/> and their descendants depth-first using an explicit
26+
/// stack, appending the name of every object matched by <paramref name="match"/> to
27+
/// <paramref name="results"/> until either <paramref name="maxResults"/> matches are collected
28+
/// or <see cref="MaxVisitedNodes"/> objects have been visited.
29+
/// </summary>
30+
/// <returns>
31+
/// <c>true</c> if the walk stopped early because the visited-node budget was exhausted
32+
/// (results may be incomplete); <c>false</c> if it finished the reachable hierarchy or
33+
/// stopped only because enough matches were already found.
34+
/// </returns>
35+
public static bool CollectByName(
36+
IReadOnlyList<GameObject> rootObjects,
37+
string searchPattern,
38+
int maxResults,
39+
NameMatch match,
40+
List<object> results)
41+
{
42+
Stack<GameObject> stack = new();
43+
for (int i = rootObjects.Count - 1; i >= 0; i--)
44+
{
45+
if (rootObjects[i] != null)
46+
{
47+
stack.Push(rootObjects[i]);
48+
}
49+
}
50+
51+
int visited = 0;
52+
while (stack.Count > 0)
53+
{
54+
if (results.Count >= maxResults)
55+
{
56+
return false;
57+
}
58+
59+
if (visited >= MaxVisitedNodes)
60+
{
61+
return true;
62+
}
63+
64+
GameObject current = stack.Pop();
65+
if (current == null)
66+
{
67+
continue;
68+
}
69+
70+
visited++;
71+
72+
if (string.IsNullOrEmpty(searchPattern) || match(current.name, searchPattern))
73+
{
74+
results.Add(current.name);
75+
}
76+
77+
Transform t = current.transform;
78+
for (int i = t.childCount - 1; i >= 0; i--)
79+
{
80+
stack.Push(t.GetChild(i).gameObject);
81+
}
82+
}
83+
84+
return false;
85+
}
86+
}
87+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
using System.Collections.Generic;
2+
using System.Diagnostics;
3+
using CoreAI.Infrastructure.World;
4+
using NUnit.Framework;
5+
using UnityEngine;
6+
7+
namespace CoreAI.Tests.EditMode
8+
{
9+
/// <summary>
10+
/// Covers F-15: the shared world query scene walker must bound main-thread work with a
11+
/// visited-node budget instead of traversing an entire large/deep hierarchy on a no-match query.
12+
/// </summary>
13+
public sealed class WorldQuerySceneWalkerEditModeTests
14+
{
15+
private readonly List<GameObject> _created = new();
16+
17+
[TearDown]
18+
public void TearDown()
19+
{
20+
for (int i = 0; i < _created.Count; i++)
21+
{
22+
if (_created[i] != null)
23+
{
24+
Object.DestroyImmediate(_created[i]);
25+
}
26+
}
27+
28+
_created.Clear();
29+
}
30+
31+
[Test]
32+
public void CollectByName_WideHierarchyNoMatch_TruncatesAndCompletesFast()
33+
{
34+
const int childCount = 15_000;
35+
GameObject root = CreateObject("WideRoot");
36+
for (int i = 0; i < childCount; i++)
37+
{
38+
GameObject child = CreateObject("Leaf");
39+
child.transform.SetParent(root.transform, worldPositionStays: false);
40+
}
41+
42+
List<object> results = new();
43+
Stopwatch stopwatch = Stopwatch.StartNew();
44+
45+
bool truncated = WorldQuerySceneWalker.CollectByName(
46+
new[] { root },
47+
"NoSuchObjectNameAnywhere",
48+
CoreAiWorldQueryLuaBindings.MaxFindResults,
49+
(name, pattern) => name.IndexOf(pattern, System.StringComparison.OrdinalIgnoreCase) >= 0,
50+
results);
51+
52+
stopwatch.Stop();
53+
54+
Assert.IsTrue(truncated,
55+
"A no-match query over more nodes than MaxVisitedNodes must report truncation.");
56+
Assert.AreEqual(0, results.Count);
57+
Assert.Less(stopwatch.ElapsedMilliseconds, 2000,
58+
"The visited-node budget should keep a no-match walk fast even over a wide hierarchy.");
59+
}
60+
61+
[Test]
62+
public void CollectByName_DeepHierarchy_DoesNotOverflowStack()
63+
{
64+
const int depth = 5_000;
65+
GameObject root = CreateObject("DeepRoot");
66+
Transform current = root.transform;
67+
for (int i = 0; i < depth; i++)
68+
{
69+
GameObject child = CreateObject("DeepChild");
70+
child.transform.SetParent(current, worldPositionStays: false);
71+
current = child.transform;
72+
}
73+
74+
List<object> results = new();
75+
76+
Assert.DoesNotThrow(() => WorldQuerySceneWalker.CollectByName(
77+
new[] { root },
78+
"DeepChild",
79+
CoreAiWorldQueryLuaBindings.MaxFindResults,
80+
(name, pattern) => name.IndexOf(pattern, System.StringComparison.OrdinalIgnoreCase) >= 0,
81+
results));
82+
83+
Assert.AreEqual(CoreAiWorldQueryLuaBindings.MaxFindResults, results.Count);
84+
}
85+
86+
private GameObject CreateObject(string name)
87+
{
88+
GameObject go = new(name);
89+
_created.Add(go);
90+
return go;
91+
}
92+
}
93+
}

0 commit comments

Comments
 (0)