From e8bd41737f1a9b178308d0cbf954c095a4de4547 Mon Sep 17 00:00:00 2001 From: Frotty Date: Thu, 10 Sep 2026 16:27:51 +0200 Subject: [PATCH 1/7] Add a new-generic Set backed by a native Lua table. --- wurst/data/KeyedTable.wurst | 35 +++++++++++++++ wurst/data/Set.wurst | 85 +++++++++++++++++++++++++++++++++++++ wurst/data/SetTests.wurst | 73 +++++++++++++++++++++++++++++++ 3 files changed, 193 insertions(+) create mode 100644 wurst/data/KeyedTable.wurst create mode 100644 wurst/data/Set.wurst create mode 100644 wurst/data/SetTests.wurst diff --git a/wurst/data/KeyedTable.wurst b/wurst/data/KeyedTable.wurst new file mode 100644 index 00000000..5c2f54af --- /dev/null +++ b/wurst/data/KeyedTable.wurst @@ -0,0 +1,35 @@ +package KeyedTable + +/** +Lua-native keyed membership: a table whose keys are the elements themselves. + +These four functions are compiler intrinsics. On Lua each one lowers to a single table +operation - `{}`, `t[k] = true`, `t[k] ~= nil`, `t[k] = nil` - so membership costs one hashed +index and Lua does the hashing. The key is a `T:` type parameter, which new generics erase on +Lua rather than routing through `castTo int` the way the old `` containers do, so the element +itself is the key. That is what makes native hashing possible: an integer index would defeat it. + +**These are Lua-only.** Jass has no hashing, so there is no sensible fallback at this level - +guard every call with `isLua` and provide a Jass path yourself. `Set` does exactly that, and is +what you should normally use. +*/ + +constant LUA_ONLY = "KeyedTable is Lua-only. Guard calls with isLua and use Set for portable code." + +/** A new, empty keyed table. */ +@compilerintrinsic public function keyedTableCreate() returns int + error(LUA_ONLY) + return 0 + +/** Adds `key`. Adding a key that is already present has no effect. */ +@compilerintrinsic public function keyedTableAdd(int keyedTable, T key) + error(LUA_ONLY) + +/** Whether `key` is present. */ +@compilerintrinsic public function keyedTableContains(int keyedTable, T key) returns boolean + error(LUA_ONLY) + return false + +/** Removes `key`. Removing a key that is absent has no effect. */ +@compilerintrinsic public function keyedTableRemove(int keyedTable, T key) + error(LUA_ONLY) diff --git a/wurst/data/Set.wurst b/wurst/data/Set.wurst new file mode 100644 index 00000000..e659518b --- /dev/null +++ b/wurst/data/Set.wurst @@ -0,0 +1,85 @@ +package Set +import public ArrayList +import KeyedTable + +/** +A set with O(1) membership on Lua, intended as a replacement for using a `group` purely to answer +"is this unit in here?". + +On Lua the elements are the keys of a native table, so `contains` is one hashed index and Lua does +the hashing. This is why the class uses new generics (`T:`): they are erased on Lua, so the element +arrives as itself. The old `` containers - `HashSet`, `HashList`, `HashMap` - erase to `int` +instead, which means every element round-trips through `castTo int` and back, and an integer index +cannot be a native Lua key at all. Those types also reach the Jass hashtable natives, which the +Jass-Lua shim emulates. + +Jass is a working fallback, not a fast one: it keeps a dense `ArrayList` and scans it, so membership +is O(n) there. That is deliberate - this type exists for Lua projects, and `isLua` picks the branch +at compile time so neither backend carries the other's code. + +**There is no iteration.** Enumerating a Lua table needs `pairs()`, whose order follows internal +hash layout and so differs between clients, which desyncs a lockstep game. If you need to iterate, +keep your own `ArrayList` alongside, or use `SparseSet`, whose dense half exists for exactly that. +*/ +public class Set + // Exactly one of these is live, chosen at compile time; dead-code elimination drops the other. + private int luaKeys = 0 + private ArrayList jassItems = null + private int count = 0 + + construct() + if isLua + luaKeys = keyedTableCreate() + else + jassItems = new ArrayList() + + /** Adds `value`. Returns whether it was newly inserted. */ + function add(T value) returns boolean + if contains(value) + return false + if isLua + keyedTableAdd(luaKeys, value) + else + jassItems.add(value) + count += 1 + return true + + /** Whether `value` is present. */ + function contains(T value) returns boolean + if isLua + return keyedTableContains(luaKeys, value) + else + return jassItems.has(value) + + /** Removes `value`. Returns whether it was present. */ + function remove(T value) returns boolean + if not contains(value) + return false + if isLua + keyedTableRemove(luaKeys, value) + else + jassItems.removeUnordered(value) + count -= 1 + return true + + /** + * How many elements are in the set. + * + * Tracked in a counter rather than measured: counting a Lua table would mean iterating it. + */ + function size() returns int + return count + + function isEmpty() returns boolean + return count == 0 + + function clear() + if isLua + luaKeys = keyedTableCreate() + else + jassItems.clear() + count = 0 + + ondestroy + if not isLua + destroy jassItems diff --git a/wurst/data/SetTests.wurst b/wurst/data/SetTests.wurst new file mode 100644 index 00000000..46a8103e --- /dev/null +++ b/wurst/data/SetTests.wurst @@ -0,0 +1,73 @@ +package SetTests + +import Set + +class Marker + int id + + construct(int id) + this.id = id + +@Test +function testAddAndMembership() + let set = new Set() + set.contains(4).assertFalse() + set.add(4).assertTrue() + set.add(9).assertTrue() + set.contains(4).assertTrue() + set.contains(9).assertTrue() + set.contains(5).assertFalse() + set.size().assertEquals(2) + destroy set + +@Test +function testAddIsIdempotent() + let set = new Set() + set.add(7).assertTrue() + set.add(7).assertFalse() + set.size().assertEquals(1) + set.contains(7).assertTrue() + destroy set + +@Test +function testRemoval() + let set = new Set() + set.add(1) + set.add(2) + set.remove(1).assertTrue() + set.contains(1).assertFalse() + set.contains(2).assertTrue() + set.size().assertEquals(1) + // Removing something absent is not an error and does not move the count. + set.remove(1).assertFalse() + set.size().assertEquals(1) + destroy set + +@Test +function testClear() + let set = new Set() + set.add(1) + set.add(2) + set.clear() + set.isEmpty().assertTrue() + set.size().assertEquals(0) + set.contains(1).assertFalse() + set.add(1).assertTrue() + set.size().assertEquals(1) + destroy set + +/** Reference identity, which is what a group-style membership set wants. */ +@Test +function testReferenceIdentity() + let set = new Set() + let a = new Marker(1) + let b = new Marker(1) + set.add(a).assertTrue() + set.contains(a).assertTrue() + // Same contents, different object: a distinct member. + set.contains(b).assertFalse() + set.add(b).assertTrue() + set.size().assertEquals(2) + destroy set + destroy a + destroy b From 820a230572361df7381df10557f13a96daf49773 Mon Sep 17 00:00:00 2001 From: Frotty Date: Thu, 10 Sep 2026 16:28:47 +0200 Subject: [PATCH 2/7] Import ErrorHandling in KeyedTable. --- wurst/data/KeyedTable.wurst | 1 + 1 file changed, 1 insertion(+) diff --git a/wurst/data/KeyedTable.wurst b/wurst/data/KeyedTable.wurst index 5c2f54af..ca31a3fd 100644 --- a/wurst/data/KeyedTable.wurst +++ b/wurst/data/KeyedTable.wurst @@ -1,4 +1,5 @@ package KeyedTable +import ErrorHandling /** Lua-native keyed membership: a table whose keys are the elements themselves. From ecbcac8fd289561947acba14bf78846688735725 Mon Sep 17 00:00:00 2001 From: Frotty Date: Thu, 10 Sep 2026 17:48:13 +0200 Subject: [PATCH 3/7] Rename Set to KeyedSet and name its fields by role. --- wurst/data/KeyedSet.wurst | 85 +++++++++++++++++++ .../{SetTests.wurst => KeyedSetTests.wurst} | 14 +-- wurst/data/Set.wurst | 85 ------------------- 3 files changed, 92 insertions(+), 92 deletions(-) create mode 100644 wurst/data/KeyedSet.wurst rename wurst/data/{SetTests.wurst => KeyedSetTests.wurst} (87%) delete mode 100644 wurst/data/Set.wurst diff --git a/wurst/data/KeyedSet.wurst b/wurst/data/KeyedSet.wurst new file mode 100644 index 00000000..b64d4109 --- /dev/null +++ b/wurst/data/KeyedSet.wurst @@ -0,0 +1,85 @@ +package KeyedSet +import public ArrayList +import KeyedTable + +/** +A set with O(1) membership on Lua, for replacing a `group` used purely to answer "is this unit in +here?". + +On Lua the elements are the keys of a native table, so `contains` is one hashed index and Lua does +the hashing. That is why this uses new generics (`T:`): they are erased on Lua, so the element +arrives as itself. The old `` containers - `HashSet`, `HashList`, `HashMap` - erase to `int` +instead, so every element round-trips through `castTo int` and back, and an integer index cannot be +a native Lua key at all. Those types also reach the Jass hashtable natives, which the Jass-Lua shim +emulates. + +Jass is a working fallback, not a fast one: it scans a dense list, so membership is O(n) there. +That is deliberate - this type exists for Lua projects, and `isLua` picks the branch at compile +time so neither backend carries the other's code. + +**There is no iteration.** Enumerating a Lua table needs `pairs()`, whose order follows internal +hash layout and so differs between clients, which desyncs a lockstep game. If you need to iterate, +keep your own `ArrayList` alongside, or use `SparseSet`, whose dense half exists for exactly that. +*/ +public class KeyedSet + // Exactly one of these is live, chosen at compile time; dead-code elimination drops the other + // branch's code. They cannot share a slot: doing so would mean casting the list to `int`, and + // avoiding that cast is the whole reason this class exists. + /** Lua: the native table whose keys are the elements. */ + private int keyTable = 0 + /** Jass: the dense list that `contains` scans. */ + private ArrayList scanList = null + /** Tracked rather than measured - counting a Lua table would mean iterating it. */ + private int count = 0 + + construct() + if isLua + keyTable = keyedTableCreate() + else + scanList = new ArrayList() + + /** Adds `value`. Returns whether it was newly inserted. */ + function add(T value) returns boolean + if contains(value) + return false + if isLua + keyedTableAdd(keyTable, value) + else + scanList.add(value) + count += 1 + return true + + /** Whether `value` is present. */ + function contains(T value) returns boolean + if isLua + return keyedTableContains(keyTable, value) + else + return scanList.has(value) + + /** Removes `value`. Returns whether it was present. */ + function remove(T value) returns boolean + if not contains(value) + return false + if isLua + keyedTableRemove(keyTable, value) + else + scanList.removeUnordered(value) + count -= 1 + return true + + function size() returns int + return count + + function isEmpty() returns boolean + return count == 0 + + function clear() + if isLua + keyTable = keyedTableCreate() + else + scanList.clear() + count = 0 + + ondestroy + if not isLua + destroy scanList diff --git a/wurst/data/SetTests.wurst b/wurst/data/KeyedSetTests.wurst similarity index 87% rename from wurst/data/SetTests.wurst rename to wurst/data/KeyedSetTests.wurst index 46a8103e..b1607348 100644 --- a/wurst/data/SetTests.wurst +++ b/wurst/data/KeyedSetTests.wurst @@ -1,6 +1,6 @@ -package SetTests +package KeyedSetTests -import Set +import KeyedSet class Marker int id @@ -10,7 +10,7 @@ class Marker @Test function testAddAndMembership() - let set = new Set() + let set = new KeyedSet() set.contains(4).assertFalse() set.add(4).assertTrue() set.add(9).assertTrue() @@ -22,7 +22,7 @@ function testAddAndMembership() @Test function testAddIsIdempotent() - let set = new Set() + let set = new KeyedSet() set.add(7).assertTrue() set.add(7).assertFalse() set.size().assertEquals(1) @@ -31,7 +31,7 @@ function testAddIsIdempotent() @Test function testRemoval() - let set = new Set() + let set = new KeyedSet() set.add(1) set.add(2) set.remove(1).assertTrue() @@ -45,7 +45,7 @@ function testRemoval() @Test function testClear() - let set = new Set() + let set = new KeyedSet() set.add(1) set.add(2) set.clear() @@ -59,7 +59,7 @@ function testClear() /** Reference identity, which is what a group-style membership set wants. */ @Test function testReferenceIdentity() - let set = new Set() + let set = new KeyedSet() let a = new Marker(1) let b = new Marker(1) set.add(a).assertTrue() diff --git a/wurst/data/Set.wurst b/wurst/data/Set.wurst deleted file mode 100644 index e659518b..00000000 --- a/wurst/data/Set.wurst +++ /dev/null @@ -1,85 +0,0 @@ -package Set -import public ArrayList -import KeyedTable - -/** -A set with O(1) membership on Lua, intended as a replacement for using a `group` purely to answer -"is this unit in here?". - -On Lua the elements are the keys of a native table, so `contains` is one hashed index and Lua does -the hashing. This is why the class uses new generics (`T:`): they are erased on Lua, so the element -arrives as itself. The old `` containers - `HashSet`, `HashList`, `HashMap` - erase to `int` -instead, which means every element round-trips through `castTo int` and back, and an integer index -cannot be a native Lua key at all. Those types also reach the Jass hashtable natives, which the -Jass-Lua shim emulates. - -Jass is a working fallback, not a fast one: it keeps a dense `ArrayList` and scans it, so membership -is O(n) there. That is deliberate - this type exists for Lua projects, and `isLua` picks the branch -at compile time so neither backend carries the other's code. - -**There is no iteration.** Enumerating a Lua table needs `pairs()`, whose order follows internal -hash layout and so differs between clients, which desyncs a lockstep game. If you need to iterate, -keep your own `ArrayList` alongside, or use `SparseSet`, whose dense half exists for exactly that. -*/ -public class Set - // Exactly one of these is live, chosen at compile time; dead-code elimination drops the other. - private int luaKeys = 0 - private ArrayList jassItems = null - private int count = 0 - - construct() - if isLua - luaKeys = keyedTableCreate() - else - jassItems = new ArrayList() - - /** Adds `value`. Returns whether it was newly inserted. */ - function add(T value) returns boolean - if contains(value) - return false - if isLua - keyedTableAdd(luaKeys, value) - else - jassItems.add(value) - count += 1 - return true - - /** Whether `value` is present. */ - function contains(T value) returns boolean - if isLua - return keyedTableContains(luaKeys, value) - else - return jassItems.has(value) - - /** Removes `value`. Returns whether it was present. */ - function remove(T value) returns boolean - if not contains(value) - return false - if isLua - keyedTableRemove(luaKeys, value) - else - jassItems.removeUnordered(value) - count -= 1 - return true - - /** - * How many elements are in the set. - * - * Tracked in a counter rather than measured: counting a Lua table would mean iterating it. - */ - function size() returns int - return count - - function isEmpty() returns boolean - return count == 0 - - function clear() - if isLua - luaKeys = keyedTableCreate() - else - jassItems.clear() - count = 0 - - ondestroy - if not isLua - destroy jassItems From da748419dee3dcb9260eed76c30248f2f8427930 Mon Sep 17 00:00:00 2001 From: Frotty Date: Fri, 11 Sep 2026 08:48:37 +0200 Subject: [PATCH 4/7] Make KeyedSet Lua-only instead of carrying a Jass scan fallback. --- wurst/data/KeyedSet.wurst | 73 +++++++++++++--------------------- wurst/data/KeyedSetTests.wurst | 10 +++++ 2 files changed, 38 insertions(+), 45 deletions(-) diff --git a/wurst/data/KeyedSet.wurst b/wurst/data/KeyedSet.wurst index b64d4109..7fbc3719 100644 --- a/wurst/data/KeyedSet.wurst +++ b/wurst/data/KeyedSet.wurst @@ -1,69 +1,53 @@ package KeyedSet -import public ArrayList import KeyedTable /** -A set with O(1) membership on Lua, for replacing a `group` used purely to answer "is this unit in +A Lua-only set with O(1) membership, for replacing a `group` used purely to answer "is this unit in here?". -On Lua the elements are the keys of a native table, so `contains` is one hashed index and Lua does -the hashing. That is why this uses new generics (`T:`): they are erased on Lua, so the element -arrives as itself. The old `` containers - `HashSet`, `HashList`, `HashMap` - erase to `int` -instead, so every element round-trips through `castTo int` and back, and an integer index cannot be -a native Lua key at all. Those types also reach the Jass hashtable natives, which the Jass-Lua shim -emulates. +The elements are the keys of a native Lua table, so `contains` is one hashed index and Lua does the +hashing. That is why this uses new generics (`T:`): they are erased on Lua, so the element arrives +as itself. The old `` containers - `HashSet`, `HashList`, `HashMap` - erase to `int` instead, so +every element round-trips through `castTo int` and back, and an integer index cannot be a native Lua +key at all. Those types also reach the Jass hashtable natives, which the Jass-Lua shim emulates. -Jass is a working fallback, not a fast one: it scans a dense list, so membership is O(n) there. -That is deliberate - this type exists for Lua projects, and `isLua` picks the branch at compile -time so neither backend carries the other's code. +**This is Lua-only.** Jass has no hashing, so the only thing a fallback could offer is a linear +scan - which is what you would write yourself, minus the pretence that the two are interchangeable. +Constructing one on Jass errors rather than quietly performing O(n) work behind an O(1) name. Use +`HashSet` or `SparseSet` for portable code, and guard with `isLua` if a package must serve both. **There is no iteration.** Enumerating a Lua table needs `pairs()`, whose order follows internal -hash layout and so differs between clients, which desyncs a lockstep game. If you need to iterate, -keep your own `ArrayList` alongside, or use `SparseSet`, whose dense half exists for exactly that. +hash layout and so differs between clients, which desyncs a lockstep game. `ipairs()` is safe but +only walks consecutive integer keys from 1, so it sees nothing in a table keyed by elements. If you +need to iterate, keep an `ArrayList` alongside and walk that, or use `SparseSet`, whose dense half +exists for exactly this. */ public class KeyedSet - // Exactly one of these is live, chosen at compile time; dead-code elimination drops the other - // branch's code. They cannot share a slot: doing so would mean casting the list to `int`, and - // avoiding that cast is the whole reason this class exists. - /** Lua: the native table whose keys are the elements. */ + /** The native Lua table whose keys are the elements. */ private int keyTable = 0 - /** Jass: the dense list that `contains` scans. */ - private ArrayList scanList = null /** Tracked rather than measured - counting a Lua table would mean iterating it. */ private int count = 0 construct() - if isLua - keyTable = keyedTableCreate() - else - scanList = new ArrayList() + keyTable = keyedTableCreate() /** Adds `value`. Returns whether it was newly inserted. */ function add(T value) returns boolean - if contains(value) + if keyedTableContains(keyTable, value) return false - if isLua - keyedTableAdd(keyTable, value) - else - scanList.add(value) + keyedTableAdd(keyTable, value) count += 1 return true /** Whether `value` is present. */ function contains(T value) returns boolean - if isLua - return keyedTableContains(keyTable, value) - else - return scanList.has(value) + return keyedTableContains(keyTable, value) /** Removes `value`. Returns whether it was present. */ function remove(T value) returns boolean - if not contains(value) + if not keyedTableContains(keyTable, value) return false - if isLua - keyedTableRemove(keyTable, value) - else - scanList.removeUnordered(value) + keyedTableRemove(keyTable, value) count -= 1 return true @@ -73,13 +57,12 @@ public class KeyedSet function isEmpty() returns boolean return count == 0 + /** + * Empties the set. + * + * Replaces the table rather than clearing it in place: emptying a Lua table means visiting its + * keys, and that needs `pairs()`. The old table is garbage, which is the cheaper trade. + */ function clear() - if isLua - keyTable = keyedTableCreate() - else - scanList.clear() + keyTable = keyedTableCreate() count = 0 - - ondestroy - if not isLua - destroy scanList diff --git a/wurst/data/KeyedSetTests.wurst b/wurst/data/KeyedSetTests.wurst index b1607348..aee20f60 100644 --- a/wurst/data/KeyedSetTests.wurst +++ b/wurst/data/KeyedSetTests.wurst @@ -10,6 +10,8 @@ class Marker @Test function testAddAndMembership() + if not isLua + return let set = new KeyedSet() set.contains(4).assertFalse() set.add(4).assertTrue() @@ -22,6 +24,8 @@ function testAddAndMembership() @Test function testAddIsIdempotent() + if not isLua + return let set = new KeyedSet() set.add(7).assertTrue() set.add(7).assertFalse() @@ -31,6 +35,8 @@ function testAddIsIdempotent() @Test function testRemoval() + if not isLua + return let set = new KeyedSet() set.add(1) set.add(2) @@ -45,6 +51,8 @@ function testRemoval() @Test function testClear() + if not isLua + return let set = new KeyedSet() set.add(1) set.add(2) @@ -59,6 +67,8 @@ function testClear() /** Reference identity, which is what a group-style membership set wants. */ @Test function testReferenceIdentity() + if not isLua + return let set = new KeyedSet() let a = new Marker(1) let b = new Marker(1) From 6291b15f95a3fcb8222f6998c7343761542d3b8b Mon Sep 17 00:00:00 2001 From: Frotty Date: Fri, 11 Sep 2026 12:20:05 +0200 Subject: [PATCH 5/7] Give a keyed set a working Jass fallback. Both types were Lua-only, erroring on Jass because a `T:` type parameter cannot be projected to an integer key in Wurst. The compiler now supplies that projection after generic elimination, so KeyedTable gets real bodies: the element is keyed through wurstKeyOf and stored in a Table. That is a hashtable native per operation and much slower than the Lua path, where each operation is a single index, but the semantics match, so a package using a keyed set works on both backends. A keyed set is now disposable. Replacing the table on clear() is still how emptying works - emptying a Lua table needs pairs(), which desyncs - but the old one is destroyed rather than orphaned, and ondestroy frees the set's own, so the Jass fallback no longer burns a Table instance per clear and per set. The tests drop their isLua guards and now run on both backends. --- wurst/data/KeyedSet.wurst | 41 +++++++++-------- wurst/data/KeyedSetTests.wurst | 10 ----- wurst/data/KeyedTable.wurst | 80 ++++++++++++++++++++++++++-------- 3 files changed, 86 insertions(+), 45 deletions(-) diff --git a/wurst/data/KeyedSet.wurst b/wurst/data/KeyedSet.wurst index 7fbc3719..0d3eb056 100644 --- a/wurst/data/KeyedSet.wurst +++ b/wurst/data/KeyedSet.wurst @@ -2,28 +2,30 @@ package KeyedSet import KeyedTable /** -A Lua-only set with O(1) membership, for replacing a `group` used purely to answer "is this unit in -here?". +A set with O(1) membership, for replacing a `group` used purely to answer "is this unit in here?". -The elements are the keys of a native Lua table, so `contains` is one hashed index and Lua does the -hashing. That is why this uses new generics (`T:`): they are erased on Lua, so the element arrives -as itself. The old `` containers - `HashSet`, `HashList`, `HashMap` - erase to `int` instead, so -every element round-trips through `castTo int` and back, and an integer index cannot be a native Lua -key at all. Those types also reach the Jass hashtable natives, which the Jass-Lua shim emulates. +On Lua the elements are the keys of a native Lua table, so `contains` is one hashed index and Lua +does the hashing. That is why this uses new generics (`T:`): they are erased on Lua, so the +element arrives as itself. The old `` containers - `HashSet`, `HashList`, `HashMap` - erase to +`int` instead, so every element round-trips through `castTo int` and back, and an integer index +cannot be a native Lua key at all. Those types also reach the Jass hashtable natives, which the +Jass-Lua shim emulates, paying for a hashtable on a runtime that already is one. -**This is Lua-only.** Jass has no hashing, so the only thing a fallback could offer is a linear -scan - which is what you would write yourself, minus the pretence that the two are interchangeable. -Constructing one on Jass errors rather than quietly performing O(n) work behind an O(1) name. Use -`HashSet` or `SparseSet` for portable code, and guard with `isLua` if a package must serve both. +On Jass it falls back to a `Table` keyed by an integer projection of the element. That is a +hashtable native per operation and much slower than the Lua path, but the semantics are the same, +so a package using this works on both backends. Element types with no stable integer key - real, +boolean, string, code, tuples - are a compile error on Jass rather than a lossy key. **There is no iteration.** Enumerating a Lua table needs `pairs()`, whose order follows internal hash layout and so differs between clients, which desyncs a lockstep game. `ipairs()` is safe but -only walks consecutive integer keys from 1, so it sees nothing in a table keyed by elements. If you -need to iterate, keep an `ArrayList` alongside and walk that, or use `SparseSet`, whose dense half -exists for exactly this. +only walks consecutive integer keys from 1, so it sees nothing in a table keyed by elements. If +you need to iterate, keep an `ArrayList` alongside and walk that, or use `SparseSet`, whose dense +half exists for exactly this. + +**Null is not a valid element** - see `KeyedTable`. */ public class KeyedSet - /** The native Lua table whose keys are the elements. */ + /** The keyed table whose keys are the elements. */ private int keyTable = 0 /** Tracked rather than measured - counting a Lua table would mean iterating it. */ private int count = 0 @@ -31,6 +33,9 @@ public class KeyedSet construct() keyTable = keyedTableCreate() + ondestroy + keyedTableDestroy(keyTable) + /** Adds `value`. Returns whether it was newly inserted. */ function add(T value) returns boolean if keyedTableContains(keyTable, value) @@ -60,9 +65,11 @@ public class KeyedSet /** * Empties the set. * - * Replaces the table rather than clearing it in place: emptying a Lua table means visiting its - * keys, and that needs `pairs()`. The old table is garbage, which is the cheaper trade. + * Replaces the table rather than clearing it in place: emptying a Lua table means visiting + * its keys, and that needs `pairs()`. The old table is destroyed, which on Jass returns its + * `Table` to the pool and on Lua leaves it to the collector. */ function clear() + keyedTableDestroy(keyTable) keyTable = keyedTableCreate() count = 0 diff --git a/wurst/data/KeyedSetTests.wurst b/wurst/data/KeyedSetTests.wurst index aee20f60..b1607348 100644 --- a/wurst/data/KeyedSetTests.wurst +++ b/wurst/data/KeyedSetTests.wurst @@ -10,8 +10,6 @@ class Marker @Test function testAddAndMembership() - if not isLua - return let set = new KeyedSet() set.contains(4).assertFalse() set.add(4).assertTrue() @@ -24,8 +22,6 @@ function testAddAndMembership() @Test function testAddIsIdempotent() - if not isLua - return let set = new KeyedSet() set.add(7).assertTrue() set.add(7).assertFalse() @@ -35,8 +31,6 @@ function testAddIsIdempotent() @Test function testRemoval() - if not isLua - return let set = new KeyedSet() set.add(1) set.add(2) @@ -51,8 +45,6 @@ function testRemoval() @Test function testClear() - if not isLua - return let set = new KeyedSet() set.add(1) set.add(2) @@ -67,8 +59,6 @@ function testClear() /** Reference identity, which is what a group-style membership set wants. */ @Test function testReferenceIdentity() - if not isLua - return let set = new KeyedSet() let a = new Marker(1) let b = new Marker(1) diff --git a/wurst/data/KeyedTable.wurst b/wurst/data/KeyedTable.wurst index ca31a3fd..d0b89ce5 100644 --- a/wurst/data/KeyedTable.wurst +++ b/wurst/data/KeyedTable.wurst @@ -1,36 +1,80 @@ package KeyedTable -import ErrorHandling +import Table /** -Lua-native keyed membership: a table whose keys are the elements themselves. +Keyed membership: a table whose keys are the elements themselves. -These four functions are compiler intrinsics. On Lua each one lowers to a single table -operation - `{}`, `t[k] = true`, `t[k] ~= nil`, `t[k] = nil` - so membership costs one hashed -index and Lua does the hashing. The key is a `T:` type parameter, which new generics erase on -Lua rather than routing through `castTo int` the way the old `` containers do, so the element -itself is the key. That is what makes native hashing possible: an integer index would defeat it. +These are compiler intrinsics, and each backend gets the representation that suits it. -**These are Lua-only.** Jass has no hashing, so there is no sensible fallback at this level - -guard every call with `isLua` and provide a Jass path yourself. `Set` does exactly that, and is -what you should normally use. -*/ +On **Lua** every operation lowers to a single table operation - `{}`, `t[k] = true`, +`t[k] ~= nil`, `t[k] = nil` - so membership costs one hashed index and Lua does the hashing. +The key is a `T:` type parameter, which new generics erase on Lua rather than routing through +`castTo int` the way the old `` containers do, so the element arrives as itself and becomes +the table key directly. An integer index would defeat native hashing entirely. + +On **Jass** there is no hashing, so the bodies below run instead: the element is projected to an +integer key by `wurstKeyOf` and stored in a `Table`. That costs a hashtable native per operation, +which is far slower than the Lua path - but it works, which is what a fallback has to do. -constant LUA_ONLY = "KeyedTable is Lua-only. Guard calls with isLua and use Set for portable code." +**There is no iteration.** Enumerating a Lua table needs `pairs()`, whose order follows internal +hash layout and so differs between clients, which desyncs a lockstep game. Anything that must be +iterated needs a separately maintained insertion-ordered array - see `SparseSet`. + +**Null is not a valid key.** On Lua `nil` cannot be a table key at all, so adding one is a +runtime error there; on Jass it would collide with the key reserved for absence. Neither backend +is asked to invent a meaning for it, and no check is added on the membership path to look for it. +*/ /** A new, empty keyed table. */ @compilerintrinsic public function keyedTableCreate() returns int - error(LUA_ONLY) - return 0 + return (new Table()) castTo int /** Adds `key`. Adding a key that is already present has no effect. */ @compilerintrinsic public function keyedTableAdd(int keyedTable, T key) - error(LUA_ONLY) + (keyedTable castTo Table).saveBoolean(wurstKeyOf(key), true) /** Whether `key` is present. */ @compilerintrinsic public function keyedTableContains(int keyedTable, T key) returns boolean - error(LUA_ONLY) - return false + return (keyedTable castTo Table).loadBoolean(wurstKeyOf(key)) /** Removes `key`. Removing a key that is absent has no effect. */ @compilerintrinsic public function keyedTableRemove(int keyedTable, T key) - error(LUA_ONLY) + (keyedTable castTo Table).removeBoolean(wurstKeyOf(key)) + +/** +Frees the keyed table. + +On Lua this is a no-op: the table is garbage once the last reference is dropped. On Jass it +releases the `Table` instance, which comes from a finite pool - without this, every discarded +keyed structure would burn one permanently. +*/ +@compilerintrinsic public function keyedTableDestroy(int keyedTable) + destroy (keyedTable castTo Table) + +// --------------------------------------------------------------------------------------------- +// Key projection. Compiler-owned: these exist so the Jass bodies above have something to call, +// and are not meant to be called directly. +// --------------------------------------------------------------------------------------------- + +/** +The integer key of `value` on Jass. + +A `T:` type parameter cannot be projected to an integer in Wurst - that is why `SparseSet` has to +ask its caller for a `SparseSetKey`. The compiler fills this in after generic elimination, when +each specialisation's element type is concrete, choosing one of the projections below. Element +types with no stable integer key - real, boolean, string, code, tuples - are rejected there with +a compile error rather than keyed on something lossy. + +On Lua this is never reached: the operations above are replaced wholesale and the element is its +own key, so there is no projection to make. +*/ +@compilerintrinsic public function wurstKeyOf(T value) returns int + return 0 + +/** The projection chosen for ints and for class instances, which are integers by that point. */ +@compilerintrinsic public function keyOfInt(int value) returns int + return value + +/** The projection chosen for handles, whose id is their key. */ +@compilerintrinsic public function keyOfHandle(handle value) returns int + return GetHandleId(value) From 46fdcd24c7c0eff8f5bb9d1aac482128fa4eafc6 Mon Sep 17 00:00:00 2001 From: Frotty Date: Fri, 11 Sep 2026 12:23:34 +0200 Subject: [PATCH 6/7] Attach the KeyedTable overview to nothing, as a plain comment. --- wurst/data/KeyedTable.wurst | 48 ++++++++++++++++++------------------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/wurst/data/KeyedTable.wurst b/wurst/data/KeyedTable.wurst index d0b89ce5..0f41650f 100644 --- a/wurst/data/KeyedTable.wurst +++ b/wurst/data/KeyedTable.wurst @@ -1,29 +1,27 @@ package KeyedTable import Table -/** -Keyed membership: a table whose keys are the elements themselves. - -These are compiler intrinsics, and each backend gets the representation that suits it. - -On **Lua** every operation lowers to a single table operation - `{}`, `t[k] = true`, -`t[k] ~= nil`, `t[k] = nil` - so membership costs one hashed index and Lua does the hashing. -The key is a `T:` type parameter, which new generics erase on Lua rather than routing through -`castTo int` the way the old `` containers do, so the element arrives as itself and becomes -the table key directly. An integer index would defeat native hashing entirely. - -On **Jass** there is no hashing, so the bodies below run instead: the element is projected to an -integer key by `wurstKeyOf` and stored in a `Table`. That costs a hashtable native per operation, -which is far slower than the Lua path - but it works, which is what a fallback has to do. - -**There is no iteration.** Enumerating a Lua table needs `pairs()`, whose order follows internal -hash layout and so differs between clients, which desyncs a lockstep game. Anything that must be -iterated needs a separately maintained insertion-ordered array - see `SparseSet`. - -**Null is not a valid key.** On Lua `nil` cannot be a table key at all, so adding one is a -runtime error there; on Jass it would collide with the key reserved for absence. Neither backend -is asked to invent a meaning for it, and no check is added on the membership path to look for it. -*/ +// Keyed membership: a table whose keys are the elements themselves. +// +// These are compiler intrinsics, and each backend gets the representation that suits it. +// +// On **Lua** every operation lowers to a single table operation - `{}`, `t[k] = true`, +// `t[k] ~= nil`, `t[k] = nil` - so membership costs one hashed index and Lua does the hashing. +// The key is a `T:` type parameter, which new generics erase on Lua rather than routing through +// `castTo int` the way the old `` containers do, so the element arrives as itself and becomes +// the table key directly. An integer index would defeat native hashing entirely. +// +// On **Jass** there is no hashing, so the bodies below run instead: the element is projected to an +// integer key by `wurstKeyOf` and stored in a `Table`. That costs a hashtable native per operation, +// which is far slower than the Lua path - but it works, which is what a fallback has to do. +// +// **There is no iteration.** Enumerating a Lua table needs `pairs()`, whose order follows internal +// hash layout and so differs between clients, which desyncs a lockstep game. Anything that must be +// iterated needs a separately maintained insertion-ordered array - see `SparseSet`. +// +// **Null is not a valid key.** On Lua `nil` cannot be a table key at all, so adding one is a +// runtime error there; on Jass it would collide with the key reserved for absence. Neither backend +// is asked to invent a meaning for it, and no check is added on the membership path to look for it. /** A new, empty keyed table. */ @compilerintrinsic public function keyedTableCreate() returns int @@ -51,10 +49,10 @@ keyed structure would burn one permanently. @compilerintrinsic public function keyedTableDestroy(int keyedTable) destroy (keyedTable castTo Table) -// --------------------------------------------------------------------------------------------- +// --- // Key projection. Compiler-owned: these exist so the Jass bodies above have something to call, // and are not meant to be called directly. -// --------------------------------------------------------------------------------------------- +// --- /** The integer key of `value` on Jass. From 59d415381a73f18fc821fdca247e71aa99a25923 Mon Sep 17 00:00:00 2001 From: Frotty Date: Fri, 11 Sep 2026 20:56:14 +0200 Subject: [PATCH 7/7] Say that an element must leave a keyed set before it is destroyed. --- wurst/data/KeyedSet.wurst | 4 +++- wurst/data/KeyedTable.wurst | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/wurst/data/KeyedSet.wurst b/wurst/data/KeyedSet.wurst index 0d3eb056..2cb91a96 100644 --- a/wurst/data/KeyedSet.wurst +++ b/wurst/data/KeyedSet.wurst @@ -22,7 +22,9 @@ only walks consecutive integer keys from 1, so it sees nothing in a table keyed you need to iterate, keep an `ArrayList` alongside and walk that, or use `SparseSet`, whose dense half exists for exactly this. -**Null is not a valid element** - see `KeyedTable`. +**Null is not a valid element**, and **an element must be removed before it is destroyed** - see +`KeyedTable` for why the Jass fallback cannot detect a handle id which has been reused since. This +is the same discipline a `group` needs. */ public class KeyedSet /** The keyed table whose keys are the elements. */ diff --git a/wurst/data/KeyedTable.wurst b/wurst/data/KeyedTable.wurst index 0f41650f..f20c22e3 100644 --- a/wurst/data/KeyedTable.wurst +++ b/wurst/data/KeyedTable.wurst @@ -22,6 +22,17 @@ import Table // **Null is not a valid key.** On Lua `nil` cannot be a table key at all, so adding one is a // runtime error there; on Jass it would collide with the key reserved for absence. Neither backend // is asked to invent a meaning for it, and no check is added on the membership path to look for it. +// +// **Remove a handle before destroying it.** Warcraft reuses handle ids. On Jass a key is that id +// and the table stores only that the id is present, so a new handle which inherits the id of a +// destroyed member is reported as a member. Validating that would mean storing the element next to +// its key, and `Table` offers no way to store an arbitrary `T` - only `saveUnit`, `saveItem` and +// the rest, one per concrete type. That is the reason `SparseSet` asks for a `SparseSetKey` and +// keeps its elements in a dense list: it can compare the stored element and this cannot. +// +// The discipline is the same one a `group` needs, and the same one `SparseSet` documents: take an +// element out before it is destroyed. What is not claimed is that the two backends agree about a +// member which was destroyed and whose id has since been reused. /** A new, empty keyed table. */ @compilerintrinsic public function keyedTableCreate() returns int