Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 35 additions & 32 deletions wurst/StdlibIngameTests.wurst
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import Execute
import ErrorHandling
import UnitSpatialIndex
import SpatialIndexForUnits
import SparseSet
import ArrayList
import UnitIndexer
import OnUnitEnterLeave

Expand Down Expand Up @@ -467,22 +467,26 @@ function testGroupNesting()
cases (hidden, locust, corpses, boundary distances) need no engine behaviour to be hardcoded.

These checks are Lua-index checks only. On Jass or with the index disabled, the native-less API
returns an empty SparseSet by contract, so there is no meaningful parity assertion to run. */
leaves the provided result empty by contract, so there is no meaningful parity assertion to run. */

constant SPATIAL_TEST_POS = vec2(-2000, 2000)
unit spatialInitProbe = null
var creatingSpatialInitProbe = false
var spatialInitProbeEnterEvents = 0
let spatialResultScratch = new ArrayList<unit>(64)
let spatialOuterScratch = new ArrayList<unit>(64)
let spatialMiddleScratch = new ArrayList<unit>(64)
let spatialInnerScratch = new ArrayList<unit>(64)
constant UnitSpatialFilter spatialPlayerZeroFilter = u -> u.getOwner() == players[0]

function countSpatialInitProbeEnter()
if creatingSpatialInitProbe or getEnterLeaveUnit() == spatialInitProbe
spatialInitProbeEnterEvents++

function collectViaSparseSet(vec2 pos, real radius, bool collisionFiltering, group into)
let matches = unitsInRange(pos, radius, collisionFiltering)
for i = 0 to matches.size() - 1
into.add(matches.get(i))
destroy matches
function collectViaSpatialIndex(vec2 pos, real radius, bool collisionFiltering, group into)
unitsInRange(spatialResultScratch, pos, radius, collisionFiltering)
for i = 0 to spatialResultScratch.size() - 1
into.add(spatialResultScratch.get(i))

function collectNative(vec2 pos, real radius, bool collisionFiltering, group into)
if collisionFiltering
Expand Down Expand Up @@ -510,7 +514,7 @@ function describeDifference(group actual, group expected) returns string
function checkRangeParity(vec2 pos, real radius, bool collisionFiltering, string label)
let actual = CreateGroup()
let expected = CreateGroup()
collectViaSparseSet(pos, radius, collisionFiltering, actual)
collectViaSpatialIndex(pos, radius, collisionFiltering, actual)
collectNative(pos, radius, collisionFiltering, expected)
var equal = actual.size() == expected.size()
if equal
Expand Down Expand Up @@ -560,6 +564,11 @@ function testSpatialIndexParity()
checkRangeParity(SPATIAL_TEST_POS, 121., false, "tight radius on a probe boundary")
checkRangeParity(SPATIAL_TEST_POS, 2000., false, "wide radius")

let foreign = createUnit(players[1], UnitIds.footman, SPATIAL_TEST_POS, angle(0))
unitsInRange(spatialResultScratch, SPATIAL_TEST_POS, 300., false, spatialPlayerZeroFilter)
check(not spatialResultScratch.has(foreign), "query filter excludes a spatial match in one pass")
foreign.remove()

// Manual UnitIndexer deindexing does not remove a live unit from native enumeration, so the
// independent spatial registry must retain it until OnUnitEnterLeave observes a real leave.
probes[0].deindex()
Expand All @@ -585,11 +594,10 @@ function testSpatialIndexParity()
SPATIAL_TEST_POS.x + 200., SPATIAL_TEST_POS.y + 200.)
let actualRect = CreateGroup()
let expectedRect = CreateGroup()
let rectMatches = unitsInBox(vec2(r.getMinX() + 32., r.getMinY() + 32.),
unitsInBox(spatialResultScratch, vec2(r.getMinX() + 32., r.getMinY() + 32.),
vec2(r.getMaxX(), r.getMaxY()))
for i = 0 to rectMatches.size() - 1
actualRect.add(rectMatches.get(i))
destroy rectMatches
for i = 0 to spatialResultScratch.size() - 1
actualRect.add(spatialResultScratch.get(i))
GroupEnumUnitsInRect(expectedRect, r, null)
var rectEqual = actualRect.size() == expectedRect.size()
if rectEqual
Expand Down Expand Up @@ -669,9 +677,9 @@ function testSpatialIndexReentrancy()
// readout which caches the group size and indexes into it: groups silently drop removed units,
// so every later index shifts down and a unit is skipped. A snapshot is immune.
reentrancyVisited = 0
let removalSnapshot = unitsInRange(SPATIAL_TEST_POS.add(96., 0.), 400.)
for i = 0 to removalSnapshot.size() - 1
let u = removalSnapshot.get(i)
unitsInRange(spatialOuterScratch, SPATIAL_TEST_POS.add(96., 0.), 400.)
for i = 0 to spatialOuterScratch.size() - 1
let u = spatialOuterScratch.get(i)
reentrancyVisited++
if reentrancyVisited == 1
for j = 0 to 3
Expand All @@ -681,7 +689,6 @@ function testSpatialIndexReentrancy()
break
check(reentrancyVisited >= 4, "removing a unit mid-iteration skips nobody ("
+ reentrancyVisited + " visited)")
destroy removalSnapshot

for i = 0 to 3
if reentrancyProbes[i] != null
Expand All @@ -695,22 +702,19 @@ function testSpatialIndexReentrancy()
// pushes above the outer one and must not disturb it.
reentrancyVisited = 0
reentrancyNested = 0
let outerSnapshot = unitsInRange(SPATIAL_TEST_POS.add(96., 0.), 400.)
for outerIndex = 0 to outerSnapshot.size() - 1
let outerUnit = outerSnapshot.get(outerIndex)
unitsInRange(spatialOuterScratch, SPATIAL_TEST_POS.add(96., 0.), 400.)
for outerIndex = 0 to spatialOuterScratch.size() - 1
let outerUnit = spatialOuterScratch.get(outerIndex)
reentrancyVisited++
let middleSnapshot = unitsInRange(outerUnit.getPos(), 300.)
for middleIndex = 0 to middleSnapshot.size() - 1
let middleUnit = middleSnapshot.get(middleIndex)
unitsInRange(spatialMiddleScratch, outerUnit.getPos(), 300.)
for middleIndex = 0 to spatialMiddleScratch.size() - 1
let middleUnit = spatialMiddleScratch.get(middleIndex)
reentrancyNested++
let innerSnapshot = unitsInRange(middleUnit.getPos(), 100.)
for innerIndex = 0 to innerSnapshot.size() - 1
let innerUnit = innerSnapshot.get(innerIndex)
unitsInRange(spatialInnerScratch, middleUnit.getPos(), 100.)
for innerIndex = 0 to spatialInnerScratch.size() - 1
let innerUnit = spatialInnerScratch.get(innerIndex)
if innerUnit == null
reentrancyNested--
destroy innerSnapshot
destroy middleSnapshot
destroy outerSnapshot
check(reentrancyVisited == 4, "outer enumeration unaffected by nesting ("
+ reentrancyVisited + ")")
check(reentrancyNested > 0, "nested enumerations ran (" + reentrancyNested + ")")
Expand All @@ -719,13 +723,12 @@ function testSpatialIndexReentrancy()
// that is already running, and must not corrupt the iteration.
reentrancyVisited = 0
spawnedDuringQuery = null
let creationSnapshot = unitsInRange(SPATIAL_TEST_POS.add(96., 0.), 400.)
for i = 0 to creationSnapshot.size() - 1
let _u = creationSnapshot.get(i)
unitsInRange(spatialOuterScratch, SPATIAL_TEST_POS.add(96., 0.), 400.)
for i = 0 to spatialOuterScratch.size() - 1
let _u = spatialOuterScratch.get(i)
reentrancyVisited++
if spawnedDuringQuery == null
spawnedDuringQuery = createUnit(players[0], UnitIds.footman, SPATIAL_TEST_POS, angle(0))
destroy creationSnapshot
check(reentrancyVisited == 4, "creating a unit mid-iteration does not extend it ("
+ reentrancyVisited + ")")
spawnedDuringQuery?.remove()
Expand Down
67 changes: 47 additions & 20 deletions wurst/closures/SpatialIndexForDestructables.wurst
Original file line number Diff line number Diff line change
@@ -1,34 +1,61 @@
package SpatialIndexForDestructables
import SparseSet
import ArrayList
import DestructableSpatialIndex
import Rect

/**
* Native-less Lua spatial queries for destructables.
*
* Each result is owned by the caller and must be destroyed. The range query intentionally matches
* Pass a reusable ArrayList<destructable> and the query fills it. The target is reset before it is
* populated, so one scratch list serves every call. To narrow what comes back, pass a filter rather
* than collecting everything and testing membership afterwards.
* Filters are caller-owned, and nested queries must use separate result collections.
* The range query intentionally matches
* ClosureForGroups.forDestructablesInRange: it returns the square that encloses the circle rather
* than applying a second distance test. Runtime-created destructables must be registered explicitly.
*/

function newDestructableResult() returns SparseSet<destructable>
return new SparseSet<destructable>(DESTRUCTABLE_SPARSE_SET_KEY)
// Caller-owned predicate evaluated once for each spatial match before it enters the result.
public interface DestructableSpatialFilter
function matches(destructable whichDestructable) returns boolean

public function destructablesInRect(rect area) returns SparseSet<destructable>
let result = newDestructableResult()
function appendMatches(ArrayList<destructable> result, int matched, DestructableSpatialFilter filter)
// Copy the snapshot out and close it before any caller code runs. A filter is caller code: it
// can fail, and it can yield. Failing leaves the snapshot open for good, along with the unit
// handles it holds; yielding lets another query overlap this one, and the two then read and
// close each other's snapshot.
let firstNew = result.size()
result.reserve(firstNew + matched)
for i = 0 to matched - 1
result.unsafeAdd(destructableSpatialIndexQuery(i))
destructableSpatialIndexEndQuery()

if filter != null
// Compact in place, so filtering needs no second collection. Nested queries already have
// to use separate result lists, which is what makes writing back into this one safe.
var kept = firstNew
for i = firstNew to result.size() - 1
let match = result.get(i)
if filter.matches(match)
result.set(kept, match)
kept++
result.truncate(kept)

public function destructablesInRect(ArrayList<destructable> result, rect area,
DestructableSpatialFilter filter)
Comment thread
Frotty marked this conversation as resolved.
result.reset()
if isLua and USE_DESTRUCTABLE_SPATIAL_INDEX
let matched = destructableSpatialIndexBeginBoxQuery(
vec2(area.getMinX(), area.getMinY()), vec2(area.getMaxX(), area.getMaxY()))
for i = 0 to matched - 1
result.add(destructableSpatialIndexQuery(i))
destructableSpatialIndexEndQuery()
return result

public function destructablesInRange(vec2 center, real range) returns SparseSet<destructable>
let result = newDestructableResult()
appendMatches(result, destructableSpatialIndexBeginBoxQuery(
vec2(area.getMinX(), area.getMinY()), vec2(area.getMaxX(), area.getMaxY())), filter)

public function destructablesInRect(ArrayList<destructable> result, rect area)
destructablesInRect(result, area, null)

public function destructablesInRange(ArrayList<destructable> result, vec2 center, real range,
DestructableSpatialFilter filter)
result.reset()
if isLua and USE_DESTRUCTABLE_SPATIAL_INDEX
let matched = destructableSpatialIndexBeginRangeQuery(center, range)
for i = 0 to matched - 1
result.add(destructableSpatialIndexQuery(i))
destructableSpatialIndexEndQuery()
return result
appendMatches(result, destructableSpatialIndexBeginRangeQuery(center, range), filter)

public function destructablesInRange(ArrayList<destructable> result, vec2 center, real range)
destructablesInRange(result, center, range, null)
117 changes: 78 additions & 39 deletions wurst/closures/SpatialIndexForUnits.wurst
Original file line number Diff line number Diff line change
@@ -1,59 +1,98 @@
package SpatialIndexForUnits
import SparseSet
import ArrayList
import UnitSpatialIndex
import Rect

/**
* Native-less Lua spatial queries.
*
* Each call returns an owned SparseSet<unit>. The result is independent of Warcraft groups and must
* be destroyed by the caller. These APIs are intentionally Lua-oriented: on Jass or while disabled,
* they return an empty set because no native fallback is hidden behind the generic result type.
* Pass a reusable ArrayList<unit> and the query fills it. The target is reset before it is
* populated, so one scratch list serves every call on a hot path. No Warcraft group, result
* collection, or filter is allocated or destroyed by these overloads.
*
* A query result is something you walk, so a list is the shape it wants. To narrow what comes back,
* pass a UnitSpatialFilter rather than collecting everything and testing membership afterwards -
* the filter runs once per match, before the result is built.
*
* Filters are caller-owned so one instance can be reused across queries. A capturing lambda creates
* a closure at its declaration site; keep it outside hot loops when allocation matters.
* Nested queries must use separate result collections because each call resets its target.
*
* These APIs are intentionally Lua-oriented: on Jass or while disabled, the target remains empty
* because no native fallback is hidden behind the generic result type.
*/

function newUnitResult() returns SparseSet<unit>
return new SparseSet<unit>(UNIT_SPARSE_SET_KEY)
// Caller-owned predicate evaluated once for each spatial match before it enters the result.
public interface UnitSpatialFilter
function matches(unit whichUnit) returns boolean

function addRangeMatches(SparseSet<unit> result, vec2 center, real radius, boolean collisionFiltering)
let matched = spatialIndexBeginQuery(center, radius, collisionFiltering)
function appendMatches(ArrayList<unit> result, int matched, UnitSpatialFilter filter)
// Copy the snapshot out and close it before any caller code runs. A filter is caller code: it
// can fail, and it can yield. Failing leaves the snapshot open for good, along with the unit
// handles it holds; yielding lets another query overlap this one, and the two then read and
// close each other's snapshot.
let firstNew = result.size()
result.reserve(firstNew + matched)
for i = 0 to matched - 1
result.add(spatialIndexQueryUnit(i))
result.unsafeAdd(spatialIndexQueryUnit(i))
spatialIndexEndQuery()

/** Returns units whose origins are within radius of center. */
public function unitsInRange(vec2 center, real radius) returns SparseSet<unit>
return unitsInRange(center, radius, false)
if filter != null
// Compact in place, so filtering needs no second collection. Nested queries already have
// to use separate result lists, which is what makes writing back into this one safe.
var kept = firstNew
for i = firstNew to result.size() - 1
let match = result.get(i)
if filter.matches(match)
result.set(kept, match)
kept++
result.truncate(kept)

/** Replaces result with units whose origins are within radius of center. */
public function unitsInRange(ArrayList<unit> result, vec2 center, real radius)
unitsInRange(result, center, radius, false, null)
Comment thread
Frotty marked this conversation as resolved.

/** Replaces result with filtered units whose origins are within radius of center. */
public function unitsInRange(ArrayList<unit> result, vec2 center, real radius,
UnitSpatialFilter filter)
unitsInRange(result, center, radius, false, filter)

/** Returns units in range, optionally applying collision-size filtering. */
public function unitsInRange(vec2 center, real radius, boolean collisionFiltering) returns SparseSet<unit>
let result = newUnitResult()
/** Replaces result with units in range, optionally applying collision-size filtering. */
public function unitsInRange(ArrayList<unit> result, vec2 center, real radius,
boolean collisionFiltering)
unitsInRange(result, center, radius, collisionFiltering, null)

/** Replaces result with filtered units in range, optionally applying collision-size filtering. */
public function unitsInRange(ArrayList<unit> result, vec2 center, real radius,
boolean collisionFiltering, UnitSpatialFilter filter)
result.reset()
if isLua and USE_UNIT_SPATIAL_INDEX
addRangeMatches(result, center, radius, collisionFiltering)
return result
appendMatches(result, spatialIndexBeginQuery(center, radius, collisionFiltering), filter)

/** Returns units whose origins are inside the axis-aligned box. */
public function unitsInBox(vec2 boxMin, vec2 boxMax) returns SparseSet<unit>
let result = newUnitResult()
/** Replaces result with filtered units whose origins are inside the axis-aligned box. */
public function unitsInBox(ArrayList<unit> result, vec2 boxMin, vec2 boxMax,
UnitSpatialFilter filter)
result.reset()
if isLua and USE_UNIT_SPATIAL_INDEX
let matched = spatialIndexBeginBoxQuery(boxMin, boxMax)
for i = 0 to matched - 1
result.add(spatialIndexQueryUnit(i))
spatialIndexEndQuery()
return result

/** Returns units matching GroupEnumUnitsInRect semantics for the given rect. */
public function unitsInRect(rect area) returns SparseSet<unit>
appendMatches(result, spatialIndexBeginBoxQuery(boxMin, boxMax), filter)

public function unitsInBox(ArrayList<unit> result, vec2 boxMin, vec2 boxMax)
unitsInBox(result, boxMin, boxMax, null)

public function unitsInRect(ArrayList<unit> result, rect area, UnitSpatialFilter filter)
// Warcraft's native unit rect enum starts 32 units above the requested minimum edge.
return unitsInBox(vec2(area.getMinX() + 32., area.getMinY() + 32.),
vec2(area.getMaxX(), area.getMaxY()))
unitsInBox(result, vec2(area.getMinX() + 32., area.getMinY() + 32.),
vec2(area.getMaxX(), area.getMaxY()), filter)

/** Returns currently indexed units owned by owner. This is a linear registry scan; per-player
secondary sets are intentionally not maintained in this first iteration. */
public function unitsOfPlayer(player owner) returns SparseSet<unit>
let result = newUnitResult()
public function unitsInRect(ArrayList<unit> result, rect area)
unitsInRect(result, area, null)

/** Replaces result with currently indexed units owned by owner. This is a linear registry scan;
per-player secondary sets are intentionally not maintained. */
public function unitsOfPlayer(ArrayList<unit> result, player owner, UnitSpatialFilter filter)
result.reset()
if isLua and USE_UNIT_SPATIAL_INDEX
let matched = spatialIndexBeginPlayerQuery(owner)
for i = 0 to matched - 1
result.add(spatialIndexQueryUnit(i))
spatialIndexEndQuery()
return result
appendMatches(result, spatialIndexBeginPlayerQuery(owner), filter)

public function unitsOfPlayer(ArrayList<unit> result, player owner)
unitsOfPlayer(result, owner, null)
Loading