From 8b7c33eab5bf05a7f15f1c5ff2857d4c67beea3a Mon Sep 17 00:00:00 2001 From: Frotty Date: Tue, 25 Aug 2026 17:19:18 +0200 Subject: [PATCH 1/6] Add spatial queries for destructables and rects --- .../SpatialIndexForDestructables.wurst | 34 +++ wurst/closures/SpatialIndexForUnits.wurst | 10 +- wurst/data/SparseSet.wurst | 8 + wurst/util/DestructableSpatialIndex.wurst | 203 ++++++++++++++++++ .../util/DestructableSpatialIndexTests.wurst | 20 ++ 5 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 wurst/closures/SpatialIndexForDestructables.wurst create mode 100644 wurst/util/DestructableSpatialIndex.wurst create mode 100644 wurst/util/DestructableSpatialIndexTests.wurst diff --git a/wurst/closures/SpatialIndexForDestructables.wurst b/wurst/closures/SpatialIndexForDestructables.wurst new file mode 100644 index 00000000..bcfdb960 --- /dev/null +++ b/wurst/closures/SpatialIndexForDestructables.wurst @@ -0,0 +1,34 @@ +package SpatialIndexForDestructables +import SparseSet +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 + * 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 + return new SparseSet(DESTRUCTABLE_SPARSE_SET_KEY) + +public function destructablesInRect(rect area) returns SparseSet + let result = newDestructableResult() + 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 + let result = newDestructableResult() + 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 diff --git a/wurst/closures/SpatialIndexForUnits.wurst b/wurst/closures/SpatialIndexForUnits.wurst index db4764eb..3b8d475f 100644 --- a/wurst/closures/SpatialIndexForUnits.wurst +++ b/wurst/closures/SpatialIndexForUnits.wurst @@ -1,6 +1,7 @@ package SpatialIndexForUnits import SparseSet import UnitSpatialIndex +import Rect /** * Native-less Lua spatial queries. @@ -40,7 +41,14 @@ public function unitsInBox(vec2 boxMin, vec2 boxMax) returns SparseSet spatialIndexEndQuery() return result -/** Returns currently indexed units owned by owner. */ +/** Returns units matching GroupEnumUnitsInRect semantics for the given rect. */ +public function unitsInRect(rect area) returns SparseSet + // 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())) + +/** 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 let result = newUnitResult() if isLua and USE_UNIT_SPATIAL_INDEX diff --git a/wurst/data/SparseSet.wurst b/wurst/data/SparseSet.wurst index 8cd7f74b..96b47cfc 100644 --- a/wurst/data/SparseSet.wurst +++ b/wurst/data/SparseSet.wurst @@ -154,3 +154,11 @@ public class UnitSparseSetKey implements SparseSetKey /** Reusable key provider for SparseSet. */ public constant SparseSetKey UNIT_SPARSE_SET_KEY = new UnitSparseSetKey() +/** Key provider for destructable sets. */ +public class DestructableSparseSetKey implements SparseSetKey + override function getKey(destructable value) returns int + return value.getTCHandleId() + +/** Reusable key provider for SparseSet. */ +public constant SparseSetKey DESTRUCTABLE_SPARSE_SET_KEY = new DestructableSparseSetKey() + diff --git a/wurst/util/DestructableSpatialIndex.wurst b/wurst/util/DestructableSpatialIndex.wurst new file mode 100644 index 00000000..20b66f6e --- /dev/null +++ b/wurst/util/DestructableSpatialIndex.wurst @@ -0,0 +1,203 @@ +package DestructableSpatialIndex +import NoWurst +import Vectors +import MapBounds +import HashMap +import TypeCasting + +/** + * Static Lua spatial index for destructables. + * + * Destructables do not have an observable movement event, so this index only needs registration + * and removal hooks. Preplaced destructables are discovered once during initialization. Runtime + * destructables created through CreateDestructable must call registerSpatialIndex() before they + * can be queried. + */ + +/** Master switch; the package remains inert on Jass or when disabled. */ +@configurable public constant USE_DESTRUCTABLE_SPATIAL_INDEX = true + +@configurable public constant DESTRUCTABLE_SPATIAL_INDEX_CELL_SIZE = 256. + +int array cellHead +int array nextInCell +int array prevInCell +int array cellOfDestructable +real array destructableX +real array destructableY +destructable array indexedDestructable +let spatialIndexByDestructable = new HashMap +var nextSpatialIndex = 1 +int array freeSpatialIndex +var freeSpatialIndexCount = 0 +var trackedDestructables = 0 + +real gridOriginX = 0. +real gridOriginY = 0. +var gridWidth = 0 +var gridHeight = 0 +var indexActive = false + +destructable array snapshotStack +int array queryBase +var snapshotTop = 0 +var queryDepth = 0 + +@inline function cellCoordX(real x) returns int + return max(0, min(gridWidth - 1, ((x - gridOriginX) / DESTRUCTABLE_SPATIAL_INDEX_CELL_SIZE).floor())) + +@inline function cellCoordY(real y) returns int + return max(0, min(gridHeight - 1, ((y - gridOriginY) / DESTRUCTABLE_SPATIAL_INDEX_CELL_SIZE).floor())) + +@inline function cellAt(real x, real y) returns int + return cellCoordX(x) + cellCoordY(y) * gridWidth + +function linkIntoCell(int idx, int cell, real x, real y) + let head = cellHead[cell] + prevInCell[idx] = 0 + nextInCell[idx] = head + if head != 0 + prevInCell[head] = idx + cellHead[cell] = idx + cellOfDestructable[idx] = cell + 1 + destructableX[idx] = x + destructableY[idx] = y + +function unlinkFromCell(int idx) + let stored = cellOfDestructable[idx] + if stored == 0 + return + let prev = prevInCell[idx] + let next = nextInCell[idx] + if prev != 0 + nextInCell[prev] = next + else + cellHead[stored - 1] = next + if next != 0 + prevInCell[next] = prev + cellOfDestructable[idx] = 0 + nextInCell[idx] = 0 + prevInCell[idx] = 0 + +/** Registers one destructable in the static index. */ +public function destructable.registerSpatialIndex() + if not isLua or not USE_DESTRUCTABLE_SPATIAL_INDEX or this == null or not indexActive + return + if spatialIndexByDestructable.get(this) != 0 + return + var idx = nextSpatialIndex + if freeSpatialIndexCount > 0 + freeSpatialIndexCount-- + idx = freeSpatialIndex[freeSpatialIndexCount] + freeSpatialIndex[freeSpatialIndexCount] = 0 + else + nextSpatialIndex++ + spatialIndexByDestructable.put(this, idx) + indexedDestructable[idx] = this + let posX = GetDestructableX(this) + let posY = GetDestructableY(this) + linkIntoCell(idx, cellAt(posX, posY), posX, posY) + trackedDestructables++ + +/** Removes one destructable from the static index before RemoveDestructable is called. */ +public function destructable.unregisterSpatialIndex() + let idx = spatialIndexByDestructable.get(this) + if idx == 0 + return + spatialIndexByDestructable.remove(this) + unlinkFromCell(idx) + indexedDestructable[idx] = null + destructableX[idx] = 0. + destructableY[idx] = 0. + freeSpatialIndex[freeSpatialIndexCount] = idx + freeSpatialIndexCount++ + trackedDestructables-- + +function seedDestructable(destructable d) + d.registerSpatialIndex() + +/** Rebuilds the grid around the current membership. Useful after changing the configured map bounds. */ +public function rebuildDestructableSpatialIndexGrid(vec2 worldMin, vec2 worldMax) + for cell = 0 to gridWidth * gridHeight - 1 + cellHead[cell] = 0 + gridOriginX = worldMin.x + gridOriginY = worldMin.y + gridWidth = ((worldMax.x - worldMin.x) / DESTRUCTABLE_SPATIAL_INDEX_CELL_SIZE).ceil() + 1 + gridHeight = ((worldMax.y - worldMin.y) / DESTRUCTABLE_SPATIAL_INDEX_CELL_SIZE).ceil() + 1 + for idx = 1 to nextSpatialIndex - 1 + cellOfDestructable[idx] = 0 + nextInCell[idx] = 0 + prevInCell[idx] = 0 + let d = indexedDestructable[idx] + if d != null + linkIntoCell(idx, cellAt(destructableX[idx], destructableY[idx]), + destructableX[idx], destructableY[idx]) + +@inline function pushMatch(destructable d) + snapshotStack[snapshotTop] = d + snapshotTop++ + +/** Collects indexed destructables whose cached position is inside the axis-aligned box. */ +public function destructableSpatialIndexBeginBoxQuery(vec2 boxMin, vec2 boxMax) returns int + queryBase[queryDepth] = snapshotTop + queryDepth++ + if not indexActive or boxMin.x > boxMax.x or boxMin.y > boxMax.y + return 0 + + let minCx = cellCoordX(boxMin.x) + let maxCx = cellCoordX(boxMax.x) + let minCy = cellCoordY(boxMin.y) + let maxCy = cellCoordY(boxMax.y) + var cy = minCy + while cy <= maxCy + let rowBase = cy * gridWidth + var cx = minCx + while cx <= maxCx + var idx = cellHead[rowBase + cx] + while idx != 0 + let next = nextInCell[idx] + if destructableX[idx] >= boxMin.x and destructableX[idx] <= boxMax.x + and destructableY[idx] >= boxMin.y and destructableY[idx] <= boxMax.y + let d = indexedDestructable[idx] + if d != null + pushMatch(d) + idx = next + cx++ + cy++ + return snapshotTop - queryBase[queryDepth - 1] + +/** Collects indexed destructables in the square used by forDestructablesInRange. */ +public function destructableSpatialIndexBeginRangeQuery(vec2 center, real range) returns int + return destructableSpatialIndexBeginBoxQuery( + vec2(center.x - range, center.y - range), vec2(center.x + range, center.y + range)) + +@inline public function destructableSpatialIndexQuery(int i) returns destructable + return snapshotStack[queryBase[queryDepth - 1] + i] + +public function destructableSpatialIndexEndQuery() + queryDepth-- + let base = queryBase[queryDepth] + for i = base to snapshotTop - 1 + snapshotStack[i] = null + snapshotTop = base + +public function destructableSpatialIndexHealthy() returns boolean + return isLua and indexActive + +public function destructableSpatialIndexTracked() returns int + return trackedDestructables + +public function destructableSpatialIndexCellOf(vec2 pos) returns int + return cellAt(pos.x, pos.y) + +public function destructableSpatialIndexGridWidth() returns int + return gridWidth + +public function destructableSpatialIndexGridHeight() returns int + return gridHeight + +init + if isLua and USE_DESTRUCTABLE_SPATIAL_INDEX + rebuildDestructableSpatialIndexGrid(boundMin, boundMax) + indexActive = true + EnumDestructablesInRect(boundRect, null, function seedDestructable(GetEnumDestructable())) diff --git a/wurst/util/DestructableSpatialIndexTests.wurst b/wurst/util/DestructableSpatialIndexTests.wurst new file mode 100644 index 00000000..1fea1dad --- /dev/null +++ b/wurst/util/DestructableSpatialIndexTests.wurst @@ -0,0 +1,20 @@ +package DestructableSpatialIndexTests +import DestructableSpatialIndex + +@Test function gridCoversTheRequestedExtent() + rebuildDestructableSpatialIndexGrid(vec2(-2048., -2048.), vec2(2048., 2048.)) + destructableSpatialIndexGridWidth().assertEquals(17) + destructableSpatialIndexGridHeight().assertEquals(17) + +@Test function cellsAdvanceOneStepPerCellSize() + rebuildDestructableSpatialIndexGrid(vec2(-2048., -2048.), vec2(2048., 2048.)) + destructableSpatialIndexCellOf(vec2(-2048., -2048.)).assertEquals(0) + destructableSpatialIndexCellOf(vec2(-2048. + 256., -2048.)).assertEquals(1) + destructableSpatialIndexCellOf(vec2(-2048., -2048. + 256.)).assertEquals( + destructableSpatialIndexGridWidth()) + +@Test function coordinatesOutsideTheWorldClampIntoTheGrid() + rebuildDestructableSpatialIndexGrid(vec2(-2048., -2048.), vec2(2048., 2048.)) + let lastCell = destructableSpatialIndexGridWidth() * destructableSpatialIndexGridHeight() - 1 + destructableSpatialIndexCellOf(vec2(-999999., -999999.)).assertEquals(0) + destructableSpatialIndexCellOf(vec2(999999., 999999.)).assertEquals(lastCell) From 0f36120e2d3ae6aa9845a85d997c7316b07aa959 Mon Sep 17 00:00:00 2001 From: Frotty Date: Tue, 25 Aug 2026 17:22:20 +0200 Subject: [PATCH 2/6] Fix destructable index compiler syntax --- wurst/util/DestructableSpatialIndex.wurst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/wurst/util/DestructableSpatialIndex.wurst b/wurst/util/DestructableSpatialIndex.wurst index 20b66f6e..983e7f73 100644 --- a/wurst/util/DestructableSpatialIndex.wurst +++ b/wurst/util/DestructableSpatialIndex.wurst @@ -14,7 +14,7 @@ import TypeCasting * can be queried. */ -/** Master switch; the package remains inert on Jass or when disabled. */ +// Master switch; the package remains inert on Jass or when disabled. @configurable public constant USE_DESTRUCTABLE_SPATIAL_INDEX = true @configurable public constant DESTRUCTABLE_SPATIAL_INDEX_CELL_SIZE = 256. @@ -200,4 +200,5 @@ init if isLua and USE_DESTRUCTABLE_SPATIAL_INDEX rebuildDestructableSpatialIndexGrid(boundMin, boundMax) indexActive = true - EnumDestructablesInRect(boundRect, null, function seedDestructable(GetEnumDestructable())) + EnumDestructablesInRect(boundRect, null) -> + seedDestructable(GetEnumDestructable()) From 15931e0ec7d6a24081444d859c3f87be32d5bf87 Mon Sep 17 00:00:00 2001 From: Frotty Date: Tue, 25 Aug 2026 17:28:52 +0200 Subject: [PATCH 3/6] Import spatial index runtime helpers --- wurst/util/DestructableSpatialIndex.wurst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/wurst/util/DestructableSpatialIndex.wurst b/wurst/util/DestructableSpatialIndex.wurst index 983e7f73..b33184aa 100644 --- a/wurst/util/DestructableSpatialIndex.wurst +++ b/wurst/util/DestructableSpatialIndex.wurst @@ -1,6 +1,8 @@ package DestructableSpatialIndex import NoWurst import Vectors +import Maths +import MagicFunctions import MapBounds import HashMap import TypeCasting From 9c523c0bed1801e4fcac0606b2c8260b121ab9ca Mon Sep 17 00:00:00 2001 From: Frotty Date: Tue, 25 Aug 2026 17:37:43 +0200 Subject: [PATCH 4/6] Use containers for destructable spatial storage --- wurst/util/DestructableSpatialIndex.wurst | 185 ++++++++++------------ 1 file changed, 81 insertions(+), 104 deletions(-) diff --git a/wurst/util/DestructableSpatialIndex.wurst b/wurst/util/DestructableSpatialIndex.wurst index b33184aa..f22b442f 100644 --- a/wurst/util/DestructableSpatialIndex.wurst +++ b/wurst/util/DestructableSpatialIndex.wurst @@ -5,7 +5,7 @@ import Maths import MagicFunctions import MapBounds import HashMap -import TypeCasting +import ArrayList /** * Static Lua spatial index for destructables. @@ -21,18 +21,24 @@ import TypeCasting @configurable public constant DESTRUCTABLE_SPATIAL_INDEX_CELL_SIZE = 256. -int array cellHead -int array nextInCell -int array prevInCell -int array cellOfDestructable -real array destructableX -real array destructableY -destructable array indexedDestructable -let spatialIndexByDestructable = new HashMap -var nextSpatialIndex = 1 -int array freeSpatialIndex -var freeSpatialIndexCount = 0 -var trackedDestructables = 0 +/** One cached destructable position and its current cell. */ +class DestructableSpatialRecord + destructable value + real x + real y + int cell + + construct(destructable value, real x, real y, int cell) + this.value = value + this.x = x + this.y = y + this.cell = cell + +// ArrayList keeps the hot query path dense and indexed. The outer list owns one list per grid cell; +// empty cells still cost only a null list reference. +let cellContents = new ArrayList>() +let allRecords = new ArrayList() +let recordByDestructable = new HashMap real gridOriginX = 0. real gridOriginY = 0. @@ -40,10 +46,10 @@ var gridWidth = 0 var gridHeight = 0 var indexActive = false -destructable array snapshotStack -int array queryBase -var snapshotTop = 0 -var queryDepth = 0 +// Query snapshots are lists rather than raw JASS arrays, so nested queries and caller-side +// mutation do not expose the storage used by the index. +let snapshotStack = new ArrayList() +let queryBases = new ArrayList() @inline function cellCoordX(real x) returns int return max(0, min(gridWidth - 1, ((x - gridOriginX) / DESTRUCTABLE_SPATIAL_INDEX_CELL_SIZE).floor())) @@ -54,95 +60,68 @@ var queryDepth = 0 @inline function cellAt(real x, real y) returns int return cellCoordX(x) + cellCoordY(y) * gridWidth -function linkIntoCell(int idx, int cell, real x, real y) - let head = cellHead[cell] - prevInCell[idx] = 0 - nextInCell[idx] = head - if head != 0 - prevInCell[head] = idx - cellHead[cell] = idx - cellOfDestructable[idx] = cell + 1 - destructableX[idx] = x - destructableY[idx] = y - -function unlinkFromCell(int idx) - let stored = cellOfDestructable[idx] - if stored == 0 - return - let prev = prevInCell[idx] - let next = nextInCell[idx] - if prev != 0 - nextInCell[prev] = next - else - cellHead[stored - 1] = next - if next != 0 - prevInCell[next] = prev - cellOfDestructable[idx] = 0 - nextInCell[idx] = 0 - prevInCell[idx] = 0 - -/** Registers one destructable in the static index. */ +function destroyCellLists() + for i = 0 to cellContents.size() - 1 + destroy cellContents.get(i) + cellContents.clear() + +function createCellLists() + for i = 0 to gridWidth * gridHeight - 1 + cellContents.add(new ArrayList(4)) + +function addRecordToCell(DestructableSpatialRecord record) + cellContents.get(record.cell).add(record) + +/** Registers one destructable in the index. */ public function destructable.registerSpatialIndex() if not isLua or not USE_DESTRUCTABLE_SPATIAL_INDEX or this == null or not indexActive return - if spatialIndexByDestructable.get(this) != 0 + if recordByDestructable.get(this) != null return - var idx = nextSpatialIndex - if freeSpatialIndexCount > 0 - freeSpatialIndexCount-- - idx = freeSpatialIndex[freeSpatialIndexCount] - freeSpatialIndex[freeSpatialIndexCount] = 0 - else - nextSpatialIndex++ - spatialIndexByDestructable.put(this, idx) - indexedDestructable[idx] = this - let posX = GetDestructableX(this) - let posY = GetDestructableY(this) - linkIntoCell(idx, cellAt(posX, posY), posX, posY) - trackedDestructables++ - -/** Removes one destructable from the static index before RemoveDestructable is called. */ + let x = GetDestructableX(this) + let y = GetDestructableY(this) + let record = new DestructableSpatialRecord(this, x, y, cellAt(x, y)) + recordByDestructable.put(this, record) + allRecords.add(record) + addRecordToCell(record) + +/** Removes one destructable from the index before RemoveDestructable is called. */ public function destructable.unregisterSpatialIndex() - let idx = spatialIndexByDestructable.get(this) - if idx == 0 + let record = recordByDestructable.get(this) + if record == null return - spatialIndexByDestructable.remove(this) - unlinkFromCell(idx) - indexedDestructable[idx] = null - destructableX[idx] = 0. - destructableY[idx] = 0. - freeSpatialIndex[freeSpatialIndexCount] = idx - freeSpatialIndexCount++ - trackedDestructables-- + recordByDestructable.remove(this) + let cell = cellContents.get(record.cell) + let cellSlot = cell.indexOf(record) + if cellSlot >= 0 + cell.removeAtUnordered(cellSlot) + let recordSlot = allRecords.indexOf(record) + if recordSlot >= 0 + allRecords.removeAtUnordered(recordSlot) + destroy record function seedDestructable(destructable d) d.registerSpatialIndex() /** Rebuilds the grid around the current membership. Useful after changing the configured map bounds. */ public function rebuildDestructableSpatialIndexGrid(vec2 worldMin, vec2 worldMax) - for cell = 0 to gridWidth * gridHeight - 1 - cellHead[cell] = 0 + destroyCellLists() gridOriginX = worldMin.x gridOriginY = worldMin.y gridWidth = ((worldMax.x - worldMin.x) / DESTRUCTABLE_SPATIAL_INDEX_CELL_SIZE).ceil() + 1 gridHeight = ((worldMax.y - worldMin.y) / DESTRUCTABLE_SPATIAL_INDEX_CELL_SIZE).ceil() + 1 - for idx = 1 to nextSpatialIndex - 1 - cellOfDestructable[idx] = 0 - nextInCell[idx] = 0 - prevInCell[idx] = 0 - let d = indexedDestructable[idx] - if d != null - linkIntoCell(idx, cellAt(destructableX[idx], destructableY[idx]), - destructableX[idx], destructableY[idx]) - -@inline function pushMatch(destructable d) - snapshotStack[snapshotTop] = d - snapshotTop++ + createCellLists() + for i = 0 to allRecords.size() - 1 + let record = allRecords.get(i) + record.cell = cellAt(record.x, record.y) + addRecordToCell(record) + +function pushMatch(destructable d) + snapshotStack.add(d) /** Collects indexed destructables whose cached position is inside the axis-aligned box. */ public function destructableSpatialIndexBeginBoxQuery(vec2 boxMin, vec2 boxMax) returns int - queryBase[queryDepth] = snapshotTop - queryDepth++ + queryBases.add(snapshotStack.size()) if not indexActive or boxMin.x > boxMax.x or boxMin.y > boxMax.y return 0 @@ -155,18 +134,15 @@ public function destructableSpatialIndexBeginBoxQuery(vec2 boxMin, vec2 boxMax) let rowBase = cy * gridWidth var cx = minCx while cx <= maxCx - var idx = cellHead[rowBase + cx] - while idx != 0 - let next = nextInCell[idx] - if destructableX[idx] >= boxMin.x and destructableX[idx] <= boxMax.x - and destructableY[idx] >= boxMin.y and destructableY[idx] <= boxMax.y - let d = indexedDestructable[idx] - if d != null - pushMatch(d) - idx = next + let cell = cellContents.get(rowBase + cx) + for i = 0 to cell.size() - 1 + let record = cell.get(i) + if record.x >= boxMin.x and record.x <= boxMax.x + and record.y >= boxMin.y and record.y <= boxMax.y + pushMatch(record.value) cx++ cy++ - return snapshotTop - queryBase[queryDepth - 1] + return snapshotStack.size() - queryBases.get(queryBases.size() - 1) /** Collects indexed destructables in the square used by forDestructablesInRange. */ public function destructableSpatialIndexBeginRangeQuery(vec2 center, real range) returns int @@ -174,20 +150,21 @@ public function destructableSpatialIndexBeginRangeQuery(vec2 center, real range) vec2(center.x - range, center.y - range), vec2(center.x + range, center.y + range)) @inline public function destructableSpatialIndexQuery(int i) returns destructable - return snapshotStack[queryBase[queryDepth - 1] + i] + let base = queryBases.get(queryBases.size() - 1) + return snapshotStack.get(base + i) public function destructableSpatialIndexEndQuery() - queryDepth-- - let base = queryBase[queryDepth] - for i = base to snapshotTop - 1 - snapshotStack[i] = null - snapshotTop = base + let lastBase = queryBases.size() - 1 + let base = queryBases.get(lastBase) + queryBases.removeAtUnordered(lastBase) + while snapshotStack.size() > base + snapshotStack.removeAtUnordered(snapshotStack.size() - 1) public function destructableSpatialIndexHealthy() returns boolean return isLua and indexActive public function destructableSpatialIndexTracked() returns int - return trackedDestructables + return allRecords.size() public function destructableSpatialIndexCellOf(vec2 pos) returns int return cellAt(pos.x, pos.y) From d38e5298a9c9591d1196fae18c0924dea1ee490d Mon Sep 17 00:00:00 2001 From: Frotty Date: Tue, 25 Aug 2026 17:45:44 +0200 Subject: [PATCH 5/6] Allocate destructable cell buckets lazily --- wurst/util/DestructableSpatialIndex.wurst | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/wurst/util/DestructableSpatialIndex.wurst b/wurst/util/DestructableSpatialIndex.wurst index f22b442f..9c8b8186 100644 --- a/wurst/util/DestructableSpatialIndex.wurst +++ b/wurst/util/DestructableSpatialIndex.wurst @@ -62,15 +62,21 @@ let queryBases = new ArrayList() function destroyCellLists() for i = 0 to cellContents.size() - 1 - destroy cellContents.get(i) + let cell = cellContents.get(i) + if cell != null + destroy cell cellContents.clear() function createCellLists() for i = 0 to gridWidth * gridHeight - 1 - cellContents.add(new ArrayList(4)) + cellContents.add(null) function addRecordToCell(DestructableSpatialRecord record) - cellContents.get(record.cell).add(record) + let cell = cellContents.get(record.cell) + if cell == null + cell = new ArrayList(4) + cellContents.set(record.cell, cell) + cell.add(record) /** Registers one destructable in the index. */ public function destructable.registerSpatialIndex() @@ -135,11 +141,12 @@ public function destructableSpatialIndexBeginBoxQuery(vec2 boxMin, vec2 boxMax) var cx = minCx while cx <= maxCx let cell = cellContents.get(rowBase + cx) - for i = 0 to cell.size() - 1 - let record = cell.get(i) - if record.x >= boxMin.x and record.x <= boxMax.x - and record.y >= boxMin.y and record.y <= boxMax.y - pushMatch(record.value) + if cell != null + for i = 0 to cell.size() - 1 + let record = cell.get(i) + if record.x >= boxMin.x and record.x <= boxMax.x + and record.y >= boxMin.y and record.y <= boxMax.y + pushMatch(record.value) cx++ cy++ return snapshotStack.size() - queryBases.get(queryBases.size() - 1) From 3a399c9b1f82ed3e50bf9b622e42b2a29b940daf Mon Sep 17 00:00:00 2001 From: Frotty Date: Tue, 25 Aug 2026 17:53:18 +0200 Subject: [PATCH 6/6] Fix lazy bucket initialization --- wurst/util/DestructableSpatialIndex.wurst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wurst/util/DestructableSpatialIndex.wurst b/wurst/util/DestructableSpatialIndex.wurst index 9c8b8186..f53e7f98 100644 --- a/wurst/util/DestructableSpatialIndex.wurst +++ b/wurst/util/DestructableSpatialIndex.wurst @@ -72,7 +72,7 @@ function createCellLists() cellContents.add(null) function addRecordToCell(DestructableSpatialRecord record) - let cell = cellContents.get(record.cell) + var cell = cellContents.get(record.cell) if cell == null cell = new ArrayList(4) cellContents.set(record.cell, cell)