From e02437c09eda4c938ac08512387d97c02ef0218a Mon Sep 17 00:00:00 2001 From: Frotty Date: Sun, 2 Aug 2026 12:58:52 +0200 Subject: [PATCH 1/9] Update UnitIndexer.wurst --- wurst/util/UnitIndexer.wurst | 41 ++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/wurst/util/UnitIndexer.wurst b/wurst/util/UnitIndexer.wurst index 63990ccf..aa6b41ec 100644 --- a/wurst/util/UnitIndexer.wurst +++ b/wurst/util/UnitIndexer.wurst @@ -1,5 +1,6 @@ package UnitIndexer import OnUnitEnterLeave +import HashMap /* UnitIndexer @@ -7,12 +8,16 @@ import OnUnitEnterLeave as well as to standardize handling of class instances when units are created and destroyed. - WARNING: Other systems that use SetUnitUserData functionality are going to - BREAK THIS SYSTEM since it depends on being the only system to use it. - Conversely, it is very likely that this system is going to break them, too. - Always make sure you only have one system using SetUnitUserData. + WARNING: On the Jass target, this system stores the index in the unit's + native user-data slot, so other systems that use SetUnitUserData are going + to BREAK THIS SYSTEM since it depends on being the only system to use it, + and conversely, it is very likely that this system is going to break them, + too. Always make sure you only have one system using SetUnitUserData. The maximum number of indexed units at any time is 32760, so if you have more units than that at any time, the system will not be able to index them. + On the Lua target the index is kept in a HashMap instead (see + getStoredIndex/setStoredIndex below), so this conflict and the 32760 cap + both only apply to the Jass target. ---------------------------------------------------------------------------- @@ -115,6 +120,24 @@ function pushUnit(unit u) function popUnit() tempUnitsCount-- +// On the Jass target the native per-unit user-data slot is the fastest option +// available and is kept as-is. On Lua, use a HashMap so index lookups avoid +// the user-data natives. Both paths default an unindexed unit to 0. +let luaUnitIndices = new HashMap + +function unit.getStoredIndex() returns int + if isLua + return luaUnitIndices.get(this) + return this.getUserData() + +function unit.setStoredIndex(int value) + if isLua + if value == 0 + luaUnitIndices.remove(this) + else + luaUnitIndices.put(this, value) + else + this.setUserData(value) @configurable public function shouldIndex(unit _u) returns boolean return true @@ -123,11 +146,11 @@ function popUnit() Can be configured by the user to supply a custom unit indexer which won't break other stdlib components. */ @configurable public function unit.getIndex() returns int - return this.getUserData() + return this.getStoredIndex() /** Returns the UnitIndex associated with this unit, creating a new one if necessary. */ @configurable public function unit.toUnitIndex() returns UnitIndex - UnitIndex instance = this.getUserData() castTo UnitIndex + UnitIndex instance = this.getStoredIndex() castTo UnitIndex if (instance == null) instance = new UnitIndex(this) @@ -155,7 +178,7 @@ public function onUnitIndex(code func) /** Deindexes a unit. Returns whether the unit was originally indexed. */ public function unit.deindex() returns bool - if this.getUserData() == 0 + if this.getStoredIndex() == 0 return false else destroy this.toUnitIndex() @@ -172,7 +195,7 @@ public class UnitIndex construct(unit whichUnit) this._unit = whichUnit - this._unit.setUserData(this castTo int) + this._unit.setStoredIndex(this castTo int) pushUnit(whichUnit) onIndexTrigger.evaluate() popUnit() @@ -181,7 +204,7 @@ public class UnitIndex pushUnit(this._unit) onDeindexTrigger.evaluate() popUnit() - this._unit.setUserData(0) + this._unit.setStoredIndex(0) // Auto instanciation/destruction init From 8442c9c800b4d78a85a129808d8ef7b2c93b1016 Mon Sep 17 00:00:00 2001 From: Frotty Date: Sun, 2 Aug 2026 14:58:04 +0200 Subject: [PATCH 2/9] Add generic sparse set --- wurst/data/SparseSet.wurst | 150 ++++++++++++++++++++++++++++++++ wurst/data/SparseSetTests.wurst | 62 +++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 wurst/data/SparseSet.wurst create mode 100644 wurst/data/SparseSetTests.wurst diff --git a/wurst/data/SparseSet.wurst b/wurst/data/SparseSet.wurst new file mode 100644 index 00000000..8235d0d7 --- /dev/null +++ b/wurst/data/SparseSet.wurst @@ -0,0 +1,150 @@ +package SparseSet + +import ArrayList +import ErrorHandling +import public UnitIndexer + +/** Supplies the stable, unique integer key used by a SparseSet. */ +public interface SparseSetKey + function getKey(T value) returns int + +/** + * A set with O(1) membership checks, insertion, and unordered removal. + * + * Elements are stored in a dense typed array. The sparse index maps the key + * supplied by SparseSetKey to the element's dense index. Keys must be unique, + * non-negative, and stable for the lifetime of the element in the set. + * + * Removal swaps the last element into the removed element's slot, so dense + * iteration order is not preserved. + */ +public class SparseSet + private ArrayList dense + private ArrayList denseKeys + private ArrayList sparse + private SparseSetKey keyProvider + + construct(SparseSetKey keyProvider) + this.keyProvider = keyProvider + dense = new ArrayList() + denseKeys = new ArrayList() + sparse = new ArrayList() + + /** Adds an element and returns whether it was newly inserted. */ + function add(T value) returns boolean + let key = keyProvider.getKey(value) + ensureKey(key) + if sparse.get(key) != 0 + return false + + dense.add(value) + denseKeys.add(key) + sparse.set(key, dense.size()) + return true + + /** Adds every element from another set. */ + function addAll(SparseSet other) + for i = 0 to other.size() - 1 + add(other.get(i)) + + /** Returns whether the set contains the given element. */ + function has(T value) returns boolean + return hasKey(keyProvider.getKey(value)) + + /** Returns whether the set contains an element under the given key. */ + function hasKey(int key) returns boolean + return key >= 0 and key < sparse.size() and sparse.get(key) != 0 + + /** Returns the dense index of an element, or -1 when it is absent. */ + function indexOf(T value) returns int + let key = keyProvider.getKey(value) + if not hasKey(key) + return -1 + return sparse.get(key) - 1 + + /** Removes an element and returns whether it was present. */ + function remove(T value) returns boolean + let key = keyProvider.getKey(value) + if not hasKey(key) + return false + removeAt(sparse.get(key) - 1) + return true + + /** Removes the element at a dense index without preserving order. */ + function removeAt(int index) returns T + if index < 0 or index >= dense.size() + error("SparseSet: Index out of bounds: " + index.toString()) + + let lastIndex = dense.size() - 1 + let removed = dense.get(index) + let removedKey = denseKeys.get(index) + + if index != lastIndex + let moved = dense.get(lastIndex) + let movedKey = denseKeys.get(lastIndex) + dense.set(index, moved) + denseKeys.set(index, movedKey) + sparse.set(movedKey, index + 1) + + dense.removeAtUnordered(lastIndex) + denseKeys.removeAtUnordered(lastIndex) + sparse.set(removedKey, 0) + return removed + + /** Removes all elements while retaining the set object. */ + function clear() + dense.clear() + denseKeys.clear() + sparse.clear() + + /** Returns the number of elements in the set. */ + function size() returns int + return dense.size() + + /** Returns whether the set contains no elements. */ + function isEmpty() returns boolean + return dense.isEmpty() + + /** Returns an element by its dense index. */ + function get(int index) returns T + return dense.get(index) + + /** Returns an element by sparse key, or null when the key is absent. */ + function getByKey(int key) returns T + if not hasKey(key) + return null + return dense.get(sparse.get(key) - 1) + + /** Returns a shallow copy of this set. */ + function copy() returns SparseSet + let result = new SparseSet(keyProvider) + result.addAll(this) + return result + + private function ensureKey(int key) + if key < 0 + error("SparseSet: Key must be non-negative: " + key.toString()) + while sparse.size() <= key + sparse.add(0) + + ondestroy + destroy dense + destroy denseKeys + destroy sparse + +/** + * Key provider for unit sets. + * + * Units must be indexed before they are added. Importing SparseSet imports + * UnitIndexer, which indexes map units automatically; units created before + * that initialization should be indexed with unit.toUnitIndex() first. + */ +public class UnitSparseSetKey implements SparseSetKey + function getKey(unit value) returns int + let key = value.getIndex() + if key == 0 + error("SparseSet: Unit is not indexed") + return key + +/** Reusable key provider for SparseSet. */ +public constant SparseSetKey UNIT_SPARSE_SET_KEY = new UnitSparseSetKey() diff --git a/wurst/data/SparseSetTests.wurst b/wurst/data/SparseSetTests.wurst new file mode 100644 index 00000000..84560836 --- /dev/null +++ b/wurst/data/SparseSetTests.wurst @@ -0,0 +1,62 @@ +package SparseSetTests + +import SparseSet + +class IntSparseSetKey implements SparseSetKey + function getKey(int value) returns int + return value + +@Test +function testAddAndMembership() + let set = new SparseSet(new IntSparseSetKey()) + set.add(4).assertTrue() + set.add(9).assertTrue() + set.add(4).assertFalse() + + set.size().assertEquals(2) + set.has(4).assertTrue() + set.has(9).assertTrue() + set.has(7).assertFalse() + set.getByKey(9).assertEquals(9) + + destroy set + +@Test +function testUnorderedRemovalKeepsMembership() + let set = new SparseSet(new IntSparseSetKey()) + set.add(1) + set.add(2) + set.add(3) + + set.remove(2).assertTrue() + set.remove(2).assertFalse() + set.size().assertEquals(2) + set.has(1).assertTrue() + set.has(3).assertTrue() + set.has(2).assertFalse() + + set.removeAt(0).assertEquals(1) + set.has(3).assertTrue() + set.size().assertEquals(1) + + destroy set + +@Test +function testClearAndCopy() + let original = new SparseSet(new IntSparseSetKey()) + original.add(2) + original.add(5) + + let copy = original.copy() + copy.size().assertEquals(2) + copy.has(2).assertTrue() + copy.remove(2).assertTrue() + original.has(2).assertTrue() + + original.clear() + original.isEmpty().assertTrue() + original.add(8).assertTrue() + original.has(8).assertTrue() + + destroy copy + destroy original From e13188604b140a0e88c499d5cbb915e55ad831f3 Mon Sep 17 00:00:00 2001 From: Frotty Date: Sun, 2 Aug 2026 15:46:30 +0200 Subject: [PATCH 3/9] Address sparse set review feedback --- wurst/data/SparseSet.wurst | 67 ++++++++++++++++++--------------- wurst/data/SparseSetTests.wurst | 39 +++++++++++++++++++ 2 files changed, 76 insertions(+), 30 deletions(-) diff --git a/wurst/data/SparseSet.wurst b/wurst/data/SparseSet.wurst index 8235d0d7..bd613d63 100644 --- a/wurst/data/SparseSet.wurst +++ b/wurst/data/SparseSet.wurst @@ -2,7 +2,8 @@ package SparseSet import ArrayList import ErrorHandling -import public UnitIndexer +import Table +import public TypeCasting /** Supplies the stable, unique integer key used by a SparseSet. */ public interface SparseSetKey @@ -12,8 +13,10 @@ public interface SparseSetKey * A set with O(1) membership checks, insertion, and unordered removal. * * Elements are stored in a dense typed array. The sparse index maps the key - * supplied by SparseSetKey to the element's dense index. Keys must be unique, - * non-negative, and stable for the lifetime of the element in the set. + * supplied by SparseSetKey to the element's dense index. Keys must be unique + * and stable for the lifetime of an element in the set. A key may be reused + * after its old value is gone; add() validates the stored value before + * accepting the new one. * * Removal swaps the last element into the removed element's slot, so dense * iteration order is not preserved. @@ -21,25 +24,29 @@ public interface SparseSetKey public class SparseSet private ArrayList dense private ArrayList denseKeys - private ArrayList sparse + private Table sparse private SparseSetKey keyProvider construct(SparseSetKey keyProvider) this.keyProvider = keyProvider dense = new ArrayList() denseKeys = new ArrayList() - sparse = new ArrayList() + sparse = new Table() /** Adds an element and returns whether it was newly inserted. */ function add(T value) returns boolean let key = keyProvider.getKey(value) - ensureKey(key) - if sparse.get(key) != 0 - return false + let existingIndex = indexForKey(key) + if existingIndex >= 0 + if dense.get(existingIndex) == value + return false + // The key was reused by a different value. This is valid for + // handles whose old value has been destroyed or deindexed. + removeAt(existingIndex) dense.add(value) denseKeys.add(key) - sparse.set(key, dense.size()) + sparse.saveInt(key, dense.size()) return true /** Adds every element from another set. */ @@ -49,25 +56,26 @@ public class SparseSet /** Returns whether the set contains the given element. */ function has(T value) returns boolean - return hasKey(keyProvider.getKey(value)) + return indexOf(value) >= 0 /** Returns whether the set contains an element under the given key. */ function hasKey(int key) returns boolean - return key >= 0 and key < sparse.size() and sparse.get(key) != 0 + return key >= 0 and sparse.hasInt(key) /** Returns the dense index of an element, or -1 when it is absent. */ function indexOf(T value) returns int let key = keyProvider.getKey(value) - if not hasKey(key) + let index = indexForKey(key) + if index < 0 or dense.get(index) != value return -1 - return sparse.get(key) - 1 + return index /** Removes an element and returns whether it was present. */ function remove(T value) returns boolean - let key = keyProvider.getKey(value) - if not hasKey(key) + let index = indexOf(value) + if index < 0 return false - removeAt(sparse.get(key) - 1) + removeAt(index) return true /** Removes the element at a dense index without preserving order. */ @@ -84,18 +92,18 @@ public class SparseSet let movedKey = denseKeys.get(lastIndex) dense.set(index, moved) denseKeys.set(index, movedKey) - sparse.set(movedKey, index + 1) + sparse.saveInt(movedKey, index + 1) dense.removeAtUnordered(lastIndex) denseKeys.removeAtUnordered(lastIndex) - sparse.set(removedKey, 0) + sparse.removeInt(removedKey) return removed /** Removes all elements while retaining the set object. */ function clear() dense.clear() denseKeys.clear() - sparse.clear() + sparse.flush() /** Returns the number of elements in the set. */ function size() returns int @@ -113,7 +121,7 @@ public class SparseSet function getByKey(int key) returns T if not hasKey(key) return null - return dense.get(sparse.get(key) - 1) + return dense.get(indexForKey(key)) /** Returns a shallow copy of this set. */ function copy() returns SparseSet @@ -121,11 +129,12 @@ public class SparseSet result.addAll(this) return result - private function ensureKey(int key) + private function indexForKey(int key) returns int if key < 0 error("SparseSet: Key must be non-negative: " + key.toString()) - while sparse.size() <= key - sparse.add(0) + if not sparse.hasInt(key) + return -1 + return sparse.loadInt(key) - 1 ondestroy destroy dense @@ -135,16 +144,14 @@ public class SparseSet /** * Key provider for unit sets. * - * Units must be indexed before they are added. Importing SparseSet imports - * UnitIndexer, which indexes map units automatically; units created before - * that initialization should be indexed with unit.toUnitIndex() first. + * This deliberately uses the native handle identity rather than UnitIndexer + * IDs. That matches native groups: membership is not automatically removed + * when a unit is deindexed. SparseSet validates the stored unit when a key is + * reused, so a new unit cannot silently inherit stale membership. */ public class UnitSparseSetKey implements SparseSetKey function getKey(unit value) returns int - let key = value.getIndex() - if key == 0 - error("SparseSet: Unit is not indexed") - return key + return value.getTCHandleId() /** Reusable key provider for SparseSet. */ public constant SparseSetKey UNIT_SPARSE_SET_KEY = new UnitSparseSetKey() diff --git a/wurst/data/SparseSetTests.wurst b/wurst/data/SparseSetTests.wurst index 84560836..e7b99fb7 100644 --- a/wurst/data/SparseSetTests.wurst +++ b/wurst/data/SparseSetTests.wurst @@ -6,6 +6,18 @@ class IntSparseSetKey implements SparseSetKey function getKey(int value) returns int return value +class ReusedKeyValue + int id + int payload + + construct(int id, int payload) + this.id = id + this.payload = payload + +class ReusedKeyProvider implements SparseSetKey + function getKey(ReusedKeyValue value) returns int + return value.id + @Test function testAddAndMembership() let set = new SparseSet(new IntSparseSetKey()) @@ -60,3 +72,30 @@ function testClearAndCopy() destroy copy destroy original + +@Test +function testReusedKeyReplacesStaleValue() + let set = new SparseSet(new ReusedKeyProvider()) + let oldValue = new ReusedKeyValue(17, 1) + let newValue = new ReusedKeyValue(17, 2) + + set.add(oldValue).assertTrue() + set.has(newValue).assertFalse() + set.add(newValue).assertTrue() + + set.size().assertEquals(1) + set.has(oldValue).assertFalse() + set.has(newValue).assertTrue() + set.get(0).payload.assertEquals(2) + + destroy set + destroy oldValue + destroy newValue + +@Test +function testLargeKeyDoesNotMaterializeGaps() + let set = new SparseSet(new IntSparseSetKey()) + set.add(1000000).assertTrue() + set.has(1000000).assertTrue() + set.size().assertEquals(1) + destroy set From 0f7a7b6147c551d678fac7b6d98c3b576d57c788 Mon Sep 17 00:00:00 2001 From: Frotty Date: Sun, 2 Aug 2026 16:54:51 +0200 Subject: [PATCH 4/9] Add repeatable sparse set benchmark --- wurst/data/SparseSet.wurst | 2 +- wurst/data/SparseSetBenchmark.wurst | 189 ++++++++++++++++++++++++++++ wurst/data/SparseSetTests.wurst | 4 +- 3 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 wurst/data/SparseSetBenchmark.wurst diff --git a/wurst/data/SparseSet.wurst b/wurst/data/SparseSet.wurst index bd613d63..e085830b 100644 --- a/wurst/data/SparseSet.wurst +++ b/wurst/data/SparseSet.wurst @@ -150,7 +150,7 @@ public class SparseSet * reused, so a new unit cannot silently inherit stale membership. */ public class UnitSparseSetKey implements SparseSetKey - function getKey(unit value) returns int + override function getKey(unit value) returns int return value.getTCHandleId() /** Reusable key provider for SparseSet. */ diff --git a/wurst/data/SparseSetBenchmark.wurst b/wurst/data/SparseSetBenchmark.wurst new file mode 100644 index 00000000..871b3f93 --- /dev/null +++ b/wurst/data/SparseSetBenchmark.wurst @@ -0,0 +1,189 @@ +package SparseSetBenchmark + +import ClosureTimers +import HashSet +import SparseSet + +/* + Manual in-game benchmark for the Lua target. + + This uses the classic Warcraft III method: a fixed workload is repeated + from a short-period timer while the player watches the game's FPS counter. + Compare the lowest FPS reached by the HashSet and SparseSet phases. The + benchmark is deliberately not a unit test and is not run automatically. + + HOW TO USE + =========== + Import SparseSetBenchmark into a test map and type -sparsebench in game. + Keep the map, camera, graphics settings, and other running systems the + same for every build. Each phase has a short recovery gap before the next + one. The workload is identical within each HashSet/SparseSet pair. + + A timer callback can be delayed by a heavy workload. That is expected: the + resulting FPS drop is the measurement. Increase the per-tick round + constants if the phases do not visibly affect FPS on the target machine. +*/ + +constant MEMBERSHIP_ELEMENTS = 256 +constant MEMBERSHIP_ROUNDS_PER_TICK = 250 +constant REMOVE_ELEMENTS = 64 +constant REMOVE_ROUNDS_PER_TICK = 40 +constant ITERATION_ELEMENTS = 512 +constant ITERATION_ROUNDS_PER_TICK = 100 + +constant BENCHMARK_INTERVAL = 0.03 +constant PHASE_TICKS = 120 +constant START_DELAY = 2. +constant RECOVERY_DELAY = 2. + +class BenchmarkIntKey implements SparseSetKey + override function getKey(int value) returns int + return value + +constant benchmarkKey = new BenchmarkIntKey() + +var phase = 0 +var phaseTick = 0 +var sink = 0 +var phaseStartSink = 0 + +function runHashSetMembership() + let set = new HashSet + for i = 0 to MEMBERSHIP_ELEMENTS - 1 + set.add(i) + + for round = 0 to MEMBERSHIP_ROUNDS_PER_TICK - 1 + for i = 0 to MEMBERSHIP_ELEMENTS - 1 + if set.has(i) + sink++ + if not set.has(i + MEMBERSHIP_ELEMENTS) + sink++ + + destroy set + +function runSparseSetMembership() + let set = new SparseSet(benchmarkKey) + for i = 0 to MEMBERSHIP_ELEMENTS - 1 + set.add(i) + + for round = 0 to MEMBERSHIP_ROUNDS_PER_TICK - 1 + for i = 0 to MEMBERSHIP_ELEMENTS - 1 + if set.has(i) + sink++ + if not set.has(i + MEMBERSHIP_ELEMENTS) + sink++ + + destroy set + +function runHashSetRemove() + let set = new HashSet + for round = 0 to REMOVE_ROUNDS_PER_TICK - 1 + for i = 0 to REMOVE_ELEMENTS - 1 + set.add(i) + for i = 0 to REMOVE_ELEMENTS - 1 + if set.remove(i) + sink++ + + destroy set + +function runSparseSetRemove() + let set = new SparseSet(benchmarkKey) + for round = 0 to REMOVE_ROUNDS_PER_TICK - 1 + for i = 0 to REMOVE_ELEMENTS - 1 + set.add(i) + for i = 0 to REMOVE_ELEMENTS - 1 + if set.remove(i) + sink++ + + destroy set + +function runHashSetIteration() + let set = new HashSet + for i = 0 to ITERATION_ELEMENTS - 1 + set.add(i) + + for round = 0 to ITERATION_ROUNDS_PER_TICK - 1 + for i = 0 to set.size() - 1 + sink += set.get(i) + + destroy set + +function runSparseSetIteration() + let set = new SparseSet(benchmarkKey) + for i = 0 to ITERATION_ELEMENTS - 1 + set.add(i) + + for round = 0 to ITERATION_ROUNDS_PER_TICK - 1 + for i = 0 to set.size() - 1 + sink += set.get(i) + + destroy set + +function phaseName() returns string + if phase == 1 + return "HashSet membership" + else if phase == 2 + return "SparseSet membership" + else if phase == 3 + return "HashSet remove/re-add" + else if phase == 4 + return "SparseSet remove/re-add" + else if phase == 5 + return "HashSet iteration" + else if phase == 6 + return "SparseSet iteration" + return "unknown phase" + +function runCurrentWorkload() + if phase == 1 + runHashSetMembership() + else if phase == 2 + runSparseSetMembership() + else if phase == 3 + runHashSetRemove() + else if phase == 4 + runSparseSetRemove() + else if phase == 5 + runHashSetIteration() + else if phase == 6 + runSparseSetIteration() + + phaseTick++ + if phaseTick == PHASE_TICKS + print("Finished " + phaseName() + ". Sink delta: " + (sink - phaseStartSink).toString()) + doAfter(RECOVERY_DELAY) -> + runNextPhase() + +function startPhaseWorkload() + phaseTick = 0 + phaseStartSink = sink + print("Running " + phaseName() + ": " + PHASE_TICKS.toString() + + " ticks at " + BENCHMARK_INTERVAL.toString() + " seconds") + doPeriodicallyCounted(BENCHMARK_INTERVAL, PHASE_TICKS) (CallbackCounted _cb) -> + runCurrentWorkload() + +function startPhase() + print("Starting " + phaseName() + " in " + START_DELAY.toString() + + " seconds; watch the lowest FPS.") + doAfter(START_DELAY) -> + startPhaseWorkload() + +function runNextPhase() + phase++ + if phase <= 6 + startPhase() + else + print("SparseSet benchmark complete. Final sink: " + sink.toString()) + +public function startSparseSetBenchmark() + if phase != 0 + print("SparseSet benchmark is already running or complete.") + return + print("SparseSet benchmark: compare equal repeated Lua workloads.") + runNextPhase() + +init + let benchmarkTrigger = CreateTrigger() + for i = 0 to bj_MAX_PLAYER_SLOTS - 1 + benchmarkTrigger.registerPlayerChatEvent(players[i], "-sparsebench", true) + benchmarkTrigger.addAction(function startSparseSetBenchmark) diff --git a/wurst/data/SparseSetTests.wurst b/wurst/data/SparseSetTests.wurst index e7b99fb7..fe0ce1d1 100644 --- a/wurst/data/SparseSetTests.wurst +++ b/wurst/data/SparseSetTests.wurst @@ -3,7 +3,7 @@ package SparseSetTests import SparseSet class IntSparseSetKey implements SparseSetKey - function getKey(int value) returns int + override function getKey(int value) returns int return value class ReusedKeyValue @@ -15,7 +15,7 @@ class ReusedKeyValue this.payload = payload class ReusedKeyProvider implements SparseSetKey - function getKey(ReusedKeyValue value) returns int + override function getKey(ReusedKeyValue value) returns int return value.id @Test From 9ea9fc754cfc6d8c55325bb05abf21490ff6ef4d Mon Sep 17 00:00:00 2001 From: Frotty Date: Sun, 2 Aug 2026 17:01:46 +0200 Subject: [PATCH 5/9] Tame benchmark workload and recovery --- wurst/data/SparseSetBenchmark.wurst | 42 ++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/wurst/data/SparseSetBenchmark.wurst b/wurst/data/SparseSetBenchmark.wurst index 871b3f93..45b76718 100644 --- a/wurst/data/SparseSetBenchmark.wurst +++ b/wurst/data/SparseSetBenchmark.wurst @@ -16,8 +16,10 @@ import SparseSet =========== Import SparseSetBenchmark into a test map and type -sparsebench in game. Keep the map, camera, graphics settings, and other running systems the - same for every build. Each phase has a short recovery gap before the next - one. The workload is identical within each HashSet/SparseSet pair. + same for every build. After each phase the temporary set is destroyed and + the benchmark waits for the player to confirm that FPS has recovered. + Type -sparsebench-next to start the next phase. The workload is identical + within each HashSet/SparseSet pair. A timer callback can be delayed by a heavy workload. That is expected: the resulting FPS drop is the measurement. Increase the per-tick round @@ -25,16 +27,15 @@ import SparseSet */ constant MEMBERSHIP_ELEMENTS = 256 -constant MEMBERSHIP_ROUNDS_PER_TICK = 250 +constant MEMBERSHIP_ROUNDS_PER_TICK = 25 constant REMOVE_ELEMENTS = 64 -constant REMOVE_ROUNDS_PER_TICK = 40 +constant REMOVE_ROUNDS_PER_TICK = 5 constant ITERATION_ELEMENTS = 512 -constant ITERATION_ROUNDS_PER_TICK = 100 +constant ITERATION_ROUNDS_PER_TICK = 10 -constant BENCHMARK_INTERVAL = 0.03 -constant PHASE_TICKS = 120 +constant BENCHMARK_INTERVAL = 0.05 +constant PHASE_TICKS = 60 constant START_DELAY = 2. -constant RECOVERY_DELAY = 2. class BenchmarkIntKey implements SparseSetKey override function getKey(int value) returns int @@ -46,6 +47,7 @@ var phase = 0 var phaseTick = 0 var sink = 0 var phaseStartSink = 0 +var phaseRunning = false function runHashSetMembership() let set = new HashSet @@ -150,13 +152,14 @@ function runCurrentWorkload() phaseTick++ if phaseTick == PHASE_TICKS + phaseRunning = false print("Finished " + phaseName() + ". Sink delta: " + (sink - phaseStartSink).toString()) - doAfter(RECOVERY_DELAY) -> - runNextPhase() + print("Set cleaned up. Wait for FPS to return to normal, then type -sparsebench-next.") function startPhaseWorkload() phaseTick = 0 phaseStartSink = sink + phaseRunning = true print("Running " + phaseName() + ": " + PHASE_TICKS.toString() + " ticks at " + BENCHMARK_INTERVAL.toString() + " seconds") doPeriodicallyCounted(BENCHMARK_INTERVAL, PHASE_TICKS) (CallbackCounted _cb) -> @@ -175,6 +178,16 @@ function runNextPhase() else print("SparseSet benchmark complete. Final sink: " + sink.toString()) +public function continueSparseSetBenchmark() + if phase == 0 + print("Start the benchmark first with -sparsebench.") + else if phaseRunning + print("The current benchmark phase is still running.") + else if phase < 6 + runNextPhase() + else + print("SparseSet benchmark complete. Reload the map to run it again.") + public function startSparseSetBenchmark() if phase != 0 print("SparseSet benchmark is already running or complete.") @@ -183,7 +196,10 @@ public function startSparseSetBenchmark() runNextPhase() init - let benchmarkTrigger = CreateTrigger() + let startTrigger = CreateTrigger() + let nextTrigger = CreateTrigger() for i = 0 to bj_MAX_PLAYER_SLOTS - 1 - benchmarkTrigger.registerPlayerChatEvent(players[i], "-sparsebench", true) - benchmarkTrigger.addAction(function startSparseSetBenchmark) + startTrigger.registerPlayerChatEvent(players[i], "-sparsebench", true) + nextTrigger.registerPlayerChatEvent(players[i], "-sparsebench-next", true) + startTrigger.addAction(function startSparseSetBenchmark) + nextTrigger.addAction(function continueSparseSetBenchmark) From daaa10223bfb099386bf0b26e7fbbb14f15ead80 Mon Sep 17 00:00:00 2001 From: Frotty Date: Sun, 2 Aug 2026 17:12:32 +0200 Subject: [PATCH 6/9] Benchmark native group removal --- wurst/data/SparseSetBenchmark.wurst | 88 +++++++++++++++++++---------- 1 file changed, 58 insertions(+), 30 deletions(-) diff --git a/wurst/data/SparseSetBenchmark.wurst b/wurst/data/SparseSetBenchmark.wurst index 45b76718..01dfbea5 100644 --- a/wurst/data/SparseSetBenchmark.wurst +++ b/wurst/data/SparseSetBenchmark.wurst @@ -16,10 +16,10 @@ import SparseSet =========== Import SparseSetBenchmark into a test map and type -sparsebench in game. Keep the map, camera, graphics settings, and other running systems the - same for every build. After each phase the temporary set is destroyed and - the benchmark waits for the player to confirm that FPS has recovered. - Type -sparsebench-next to start the next phase. The workload is identical - within each HashSet/SparseSet pair. + same for every build. After each phase the temporary set or group is + destroyed and the benchmark waits 2.5 seconds for FPS to recover before + starting the next phase. The workload is identical within each comparison + pair. A timer callback can be delayed by a heavy workload. That is expected: the resulting FPS drop is the measurement. Increase the per-tick round @@ -32,22 +32,26 @@ constant REMOVE_ELEMENTS = 64 constant REMOVE_ROUNDS_PER_TICK = 5 constant ITERATION_ELEMENTS = 512 constant ITERATION_ROUNDS_PER_TICK = 10 +constant PHASE_COUNT = 7 constant BENCHMARK_INTERVAL = 0.05 constant PHASE_TICKS = 60 constant START_DELAY = 2. +constant RECOVERY_DELAY = 2.5 class BenchmarkIntKey implements SparseSetKey override function getKey(int value) returns int return value constant benchmarkKey = new BenchmarkIntKey() +constant benchmarkUnitKey = new UnitSparseSetKey() var phase = 0 var phaseTick = 0 var sink = 0 var phaseStartSink = 0 var phaseRunning = false +unit array benchmarkUnits function runHashSetMembership() let set = new HashSet @@ -78,27 +82,54 @@ function runSparseSetMembership() destroy set function runHashSetRemove() - let set = new HashSet + ensureBenchmarkUnits() + let set = new HashSet for round = 0 to REMOVE_ROUNDS_PER_TICK - 1 for i = 0 to REMOVE_ELEMENTS - 1 - set.add(i) + set.add(benchmarkUnits[i]) for i = 0 to REMOVE_ELEMENTS - 1 - if set.remove(i) + if set.remove(benchmarkUnits[i]) sink++ destroy set function runSparseSetRemove() - let set = new SparseSet(benchmarkKey) + ensureBenchmarkUnits() + let set = new SparseSet(benchmarkUnitKey) for round = 0 to REMOVE_ROUNDS_PER_TICK - 1 for i = 0 to REMOVE_ELEMENTS - 1 - set.add(i) + set.add(benchmarkUnits[i]) for i = 0 to REMOVE_ELEMENTS - 1 - if set.remove(i) + if set.remove(benchmarkUnits[i]) sink++ destroy set +function runGroupRemove() + ensureBenchmarkUnits() + let set = CreateGroup() + for round = 0 to REMOVE_ROUNDS_PER_TICK - 1 + for i = 0 to REMOVE_ELEMENTS - 1 + set.add(benchmarkUnits[i]) + for i = 0 to REMOVE_ELEMENTS - 1 + if set.remove(benchmarkUnits[i]) > 0 + sink++ + + set.destr() + +function ensureBenchmarkUnits() + if benchmarkUnits[0] == null + for i = 0 to REMOVE_ELEMENTS - 1 + benchmarkUnits[i] = createUnit(DUMMY_PLAYER, 'hfoo', vec2(0., 0.), 0 .fromDeg()) + benchmarkUnits[i].hide() + benchmarkUnits[i].pause() + +function destroyBenchmarkUnits() + if benchmarkUnits[0] != null + for i = 0 to REMOVE_ELEMENTS - 1 + benchmarkUnits[i].remove() + benchmarkUnits[i] = null + function runHashSetIteration() let set = new HashSet for i = 0 to ITERATION_ELEMENTS - 1 @@ -131,8 +162,10 @@ function phaseName() returns string else if phase == 4 return "SparseSet remove/re-add" else if phase == 5 - return "HashSet iteration" + return "Native group remove/re-add" else if phase == 6 + return "HashSet iteration" + else if phase == 7 return "SparseSet iteration" return "unknown phase" @@ -146,15 +179,21 @@ function runCurrentWorkload() else if phase == 4 runSparseSetRemove() else if phase == 5 - runHashSetIteration() + runGroupRemove() else if phase == 6 + runHashSetIteration() + else if phase == 7 runSparseSetIteration() phaseTick++ if phaseTick == PHASE_TICKS + if phase == 5 + destroyBenchmarkUnits() phaseRunning = false print("Finished " + phaseName() + ". Sink delta: " + (sink - phaseStartSink).toString()) - print("Set cleaned up. Wait for FPS to return to normal, then type -sparsebench-next.") + print("Workload cleaned up. Waiting " + RECOVERY_DELAY.toString() + " seconds for FPS recovery.") + doAfter(RECOVERY_DELAY) -> + runNextPhase() function startPhaseWorkload() phaseTick = 0 @@ -166,6 +205,8 @@ function startPhaseWorkload() runCurrentWorkload() function startPhase() + if phase == 3 + ensureBenchmarkUnits() print("Starting " + phaseName() + " in " + START_DELAY.toString() + " seconds; watch the lowest FPS.") doAfter(START_DELAY) -> @@ -173,21 +214,11 @@ function startPhase() function runNextPhase() phase++ - if phase <= 6 + if phase <= PHASE_COUNT startPhase() else print("SparseSet benchmark complete. Final sink: " + sink.toString()) -public function continueSparseSetBenchmark() - if phase == 0 - print("Start the benchmark first with -sparsebench.") - else if phaseRunning - print("The current benchmark phase is still running.") - else if phase < 6 - runNextPhase() - else - print("SparseSet benchmark complete. Reload the map to run it again.") - public function startSparseSetBenchmark() if phase != 0 print("SparseSet benchmark is already running or complete.") @@ -196,10 +227,7 @@ public function startSparseSetBenchmark() runNextPhase() init - let startTrigger = CreateTrigger() - let nextTrigger = CreateTrigger() + let benchmarkTrigger = CreateTrigger() for i = 0 to bj_MAX_PLAYER_SLOTS - 1 - startTrigger.registerPlayerChatEvent(players[i], "-sparsebench", true) - nextTrigger.registerPlayerChatEvent(players[i], "-sparsebench-next", true) - startTrigger.addAction(function startSparseSetBenchmark) - nextTrigger.addAction(function continueSparseSetBenchmark) + benchmarkTrigger.registerPlayerChatEvent(players[i], "-sparsebench", true) + benchmarkTrigger.addAction(function startSparseSetBenchmark) From cfcbff1cf453c12d2886591bc8d7b7cf60556950 Mon Sep 17 00:00:00 2001 From: Frotty Date: Sun, 2 Aug 2026 17:29:31 +0200 Subject: [PATCH 7/9] Add generic priority queue --- wurst/data/ArrayList.wurst | 6 +- wurst/data/PriorityQueue.wurst | 117 ++++++++++++++++++++++++++++ wurst/data/PriorityQueueTests.wurst | 47 +++++++++++ 3 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 wurst/data/PriorityQueue.wurst create mode 100644 wurst/data/PriorityQueueTests.wurst diff --git a/wurst/data/ArrayList.wurst b/wurst/data/ArrayList.wurst index 5286cad9..aa4a62aa 100644 --- a/wurst/data/ArrayList.wurst +++ b/wurst/data/ArrayList.wurst @@ -745,15 +745,15 @@ public interface Comparator // SPECIALIZED SORT FUNCTIONS // ============================================================================ -constant Comparator intComparator = (i1, i2) -> i1 < i2 ? -1 : (i1 > i2 ? 1 : 0) +public constant Comparator intComparator = (i1, i2) -> i1 < i2 ? -1 : (i1 > i2 ? 1 : 0) public function ArrayList.sort() this.sortWith(intComparator) -constant Comparator realComparator = (r1, r2) -> r1 < r2 ? -1 : (r1 > r2 ? 1 : 0) +public constant Comparator realComparator = (r1, r2) -> r1 < r2 ? -1 : (r1 > r2 ? 1 : 0) public function ArrayList.sort() this.sortWith(realComparator) -constant Comparator stringComparator = (s1, s2) -> stringCompare(s1, s2) +public constant Comparator stringComparator = (s1, s2) -> stringCompare(s1, s2) public function ArrayList.sort() this.sortWith(stringComparator) diff --git a/wurst/data/PriorityQueue.wurst b/wurst/data/PriorityQueue.wurst new file mode 100644 index 00000000..176c421a --- /dev/null +++ b/wurst/data/PriorityQueue.wurst @@ -0,0 +1,117 @@ +package PriorityQueue + +import ArrayList + +/* + * Usage: + * let queue = new PriorityQueue(intComparator) + * queue.add(5) + * queue.add(1) + * let next = queue.poll() // 1 + * destroy queue + */ +/** + * A binary heap priority queue. + * + * The comparator defines priority: a negative result means that the first + * value has higher priority, so ArrayList.intComparator creates a min-heap. + * Reverse the comparator to create a max-heap. Equal-priority elements are + * not stable. + * + * Adding and removing the highest-priority element are O(log n). Peeking is + * O(1). The queue does not support changing an element's priority in place; + * remove and re-add it after changing any fields used by the comparator. + */ +public class PriorityQueue + private ArrayList values + private Comparator comparator + + construct(Comparator comparator) + this(comparator, 16) + + construct(Comparator comparator, int initialCapacity) + this.comparator = comparator + values = new ArrayList(initialCapacity > 0 ? initialCapacity : 16) + + /** Adds an element to the queue. */ + function add(T value) + values.add(value) + siftUp(values.size() - 1) + + /** Alias for add(). */ + function offer(T value) + add(value) + + /** Returns the highest-priority element, or null if empty. */ + function peek() returns T + if values.size() == 0 + return null + return values.get(0) + + /** Removes and returns the highest-priority element, or null if empty. */ + function poll() returns T + if values.size() == 0 + return null + + let result = values.get(0) + let lastIndex = values.size() - 1 + if lastIndex == 0 + values.removeAtUnordered(0) + return result + + let last = values.removeAtUnordered(lastIndex) + values.set(0, last) + siftDown(0) + return result + + /** Alias for poll(). */ + function remove() returns T + return poll() + + /** Returns the number of queued elements. */ + function size() returns int + return values.size() + + /** Returns whether the queue is empty. */ + function isEmpty() returns boolean + return values.size() == 0 + + /** Removes all elements while retaining the backing storage. */ + function clear() + values.clear() + + private function isHigherPriority(int left, int right) returns boolean + return comparator.compare(values.get(left), values.get(right)) < 0 + + private function siftUp(int index) + var current = index + while current > 0 + let parent = (current - 1) div 2 + if not isHigherPriority(current, parent) + return + swap(current, parent) + current = parent + + private function siftDown(int index) + var current = index + let count = values.size() + while true + let left = current * 2 + 1 + if left >= count + return + let right = left + 1 + var child = left + if right < count and isHigherPriority(right, left) + child = right + if not isHigherPriority(child, current) + return + swap(current, child) + current = child + + private function swap(int left, int right) + let value = values.get(left) + values.set(left, values.get(right)) + values.set(right, value) + + ondestroy + destroy values diff --git a/wurst/data/PriorityQueueTests.wurst b/wurst/data/PriorityQueueTests.wurst new file mode 100644 index 00000000..65d0163e --- /dev/null +++ b/wurst/data/PriorityQueueTests.wurst @@ -0,0 +1,47 @@ +package PriorityQueueTests + +import ArrayList +import PriorityQueue + +constant Comparator maxIntComparator = (a, b) -> a > b ? -1 : (a < b ? 1 : 0) + +@Test +function priorityQueueReturnsSmallestFirst() + let queue = new PriorityQueue(intComparator) + queue.add(7) + queue.add(2) + queue.add(5) + queue.add(2) + + queue.peek().assertEquals(2) + queue.size().assertEquals(4) + queue.poll().assertEquals(2) + queue.poll().assertEquals(2) + queue.poll().assertEquals(5) + queue.poll().assertEquals(7) + queue.isEmpty().assertTrue() + destroy queue + +@Test +function priorityQueueSupportsMaxHeapComparator() + let queue = new PriorityQueue(maxIntComparator) + queue.offer(3) + queue.offer(9) + queue.offer(1) + + queue.remove().assertEquals(9) + queue.remove().assertEquals(3) + queue.remove().assertEquals(1) + destroy queue + +@Test +function priorityQueueClearRetainsUsability() + let queue = new PriorityQueue(intComparator, 2) + queue.add(4) + queue.add(1) + queue.clear() + queue.isEmpty().assertTrue() + queue.add(6) + queue.peek().assertEquals(6) + queue.size().assertEquals(1) + destroy queue From 3ed319ef6dbd14a668866c1cc03b482b0deea697 Mon Sep 17 00:00:00 2001 From: Frotty Date: Sun, 2 Aug 2026 17:56:06 +0200 Subject: [PATCH 8/9] Expand priority queue coverage --- wurst/data/PriorityQueueTests.wurst | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/wurst/data/PriorityQueueTests.wurst b/wurst/data/PriorityQueueTests.wurst index 65d0163e..e7796915 100644 --- a/wurst/data/PriorityQueueTests.wurst +++ b/wurst/data/PriorityQueueTests.wurst @@ -45,3 +45,19 @@ function priorityQueueClearRetainsUsability() queue.peek().assertEquals(6) queue.size().assertEquals(1) destroy queue + +@Test +function priorityQueueHandlesEmptyAndGrowth() + let queue = new PriorityQueue(intComparator, 1) + queue.isEmpty().assertTrue() + queue.size().assertEquals(0) + queue.peek() + queue.poll() + + for i = 0 to 31 + queue.add((i * 17) mod 32) + queue.size().assertEquals(32) + for expected = 0 to 31 + queue.poll().assertEquals(expected) + queue.isEmpty().assertTrue() + destroy queue From 9002520786b793e32b03e10809414187c4b4c6fb Mon Sep 17 00:00:00 2001 From: Frotty Date: Sun, 2 Aug 2026 18:27:26 +0200 Subject: [PATCH 9/9] Support signed sparse set keys --- wurst/data/SparseSet.wurst | 4 +--- wurst/data/SparseSetTests.wurst | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/wurst/data/SparseSet.wurst b/wurst/data/SparseSet.wurst index e085830b..713f2b5a 100644 --- a/wurst/data/SparseSet.wurst +++ b/wurst/data/SparseSet.wurst @@ -60,7 +60,7 @@ public class SparseSet /** Returns whether the set contains an element under the given key. */ function hasKey(int key) returns boolean - return key >= 0 and sparse.hasInt(key) + return sparse.hasInt(key) /** Returns the dense index of an element, or -1 when it is absent. */ function indexOf(T value) returns int @@ -130,8 +130,6 @@ public class SparseSet return result private function indexForKey(int key) returns int - if key < 0 - error("SparseSet: Key must be non-negative: " + key.toString()) if not sparse.hasInt(key) return -1 return sparse.loadInt(key) - 1 diff --git a/wurst/data/SparseSetTests.wurst b/wurst/data/SparseSetTests.wurst index fe0ce1d1..0a22eb7d 100644 --- a/wurst/data/SparseSetTests.wurst +++ b/wurst/data/SparseSetTests.wurst @@ -99,3 +99,18 @@ function testLargeKeyDoesNotMaterializeGaps() set.has(1000000).assertTrue() set.size().assertEquals(1) destroy set + +@Test +function testSignedKeys() + let set = new SparseSet(new IntSparseSetKey()) + set.add(-17).assertTrue() + set.add(23).assertTrue() + + set.has(-17).assertTrue() + set.hasKey(-17).assertTrue() + set.getByKey(-17).assertEquals(-17) + set.remove(-17).assertTrue() + set.has(-17).assertFalse() + set.hasKey(-17).assertFalse() + + destroy set