From 4b0ecf325c991be28a59ef2a3629dbc1e4e9d102 Mon Sep 17 00:00:00 2001 From: Frotty Date: Fri, 9 Oct 2026 14:35:06 +0200 Subject: [PATCH 1/3] Extend FastKeyedMap with collision-safe string keys (PoC) --- wurst/data/KeyedMap.wurst | 113 +++++++++++++++++++++++++++++---- wurst/data/KeyedMapTests.wurst | 26 ++++++++ 2 files changed, 127 insertions(+), 12 deletions(-) diff --git a/wurst/data/KeyedMap.wurst b/wurst/data/KeyedMap.wurst index e2e0ba45..2a463561 100644 --- a/wurst/data/KeyedMap.wurst +++ b/wurst/data/KeyedMap.wurst @@ -44,15 +44,13 @@ import ErrorHandling /** * Typed value intrinsic used by FastKeyedMap. Keep the name distinct from keyedMapPut: the legacy * fixed signature must remain unambiguous for existing callers. Lua lowers this to one raw table - * store; Jass specializes int and class values to the existing integer fallback. + * store; Jass specializes int and class values to the handle or string integer fallback. */ -@compilerintrinsic public function keyedMapPutNative(int map, K key, V value) - if key == null - return +@compilerintrinsic public function keyedMapPutNative(int map, K key, V value) error("keyedMapPutNative requires compiler keyed-map intrinsic support") /** Typed get paired with keyedMapPutNative. */ -@compilerintrinsic public function keyedMapGetNative(int map, K key) returns V +@compilerintrinsic public function keyedMapGetNative(int map, K key) returns V error("keyedMapGetNative requires compiler keyed-map intrinsic support") return null @@ -68,26 +66,117 @@ import ErrorHandling /** Frees the map. Lua lowering clears its backing table in place, even if an alias still references it. */ @compilerintrinsic public function keyedMapDestroy(int map) + var entry = stringHeads[map] + stringHeads[map] = null + while entry != null + let next = entry.nextAll + destroy entry + entry = next destroy (map castTo Table) +// Jass owns collision entries. Lua lowers the operations to direct table accesses, so these +// nodes and their ownership list are unreachable there. Bucket links handle StringHash collisions; +// the separate ownership list lets destroy release every entry without enumerating a hashtable. +class StringMapEntry + string key + int value + StringMapEntry nextBucket + StringMapEntry nextAll + StringMapEntry previousAll + + ondestroy + key = "" + nextBucket = null + nextAll = null + previousAll = null + +StringMapEntry array stringHeads + +function findStringEntry(int map, string key) returns StringMapEntry + var entry = (map castTo Table).loadInt(StringHash(key)) castTo StringMapEntry + while entry != null + if entry.key == key + return entry + entry = entry.nextBucket + return null + +@compilerintrinsic public function keyedMapPutString(int map, string key, int value) + let old = findStringEntry(map, key) + if old != null + old.value = value + return + let table = map castTo Table + let hash = StringHash(key) + let entry = new StringMapEntry() + entry.key = key + entry.value = value + entry.nextBucket = table.loadInt(hash) castTo StringMapEntry + entry.nextAll = stringHeads[map] + entry.previousAll = null + if entry.nextAll != null + entry.nextAll.previousAll = entry + stringHeads[map] = entry + table.saveInt(hash, entry castTo int) + +@compilerintrinsic public function keyedMapGetStringInt(int map, string key) returns int + let entry = findStringEntry(map, key) + if entry == null + return 0 + return entry.value + +@compilerintrinsic public function keyedMapHasString(int map, string key) returns boolean + return findStringEntry(map, key) != null + +@compilerintrinsic public function keyedMapRemoveString(int map, string key) + let table = map castTo Table + let hash = StringHash(key) + var entry = table.loadInt(hash) castTo StringMapEntry + StringMapEntry previous = null + while entry != null + if entry.key == key + if previous == null + if entry.nextBucket == null + table.removeInt(hash) + else + table.saveInt(hash, entry.nextBucket castTo int) + else + previous.nextBucket = entry.nextBucket + if entry.previousAll == null + stringHeads[map] = entry.nextAll + else + entry.previousAll.nextAll = entry.nextAll + if entry.nextAll != null + entry.nextAll.previousAll = entry.previousAll + destroy entry + return + previous = entry + entry = entry.nextBucket + +@compilerintrinsic public function keyedMapHasNative(int map, K key) returns boolean + error("keyedMapHasNative requires compiler keyed-map intrinsic support") + return false + +@compilerintrinsic public function keyedMapRemoveNative(int map, K key) + error("keyedMapRemoveNative requires compiler keyed-map intrinsic support") + /** - * High-performance map for handle keys and native int or class-reference values. - * Jass uses Warcraft's hashtable; Lua uses a native table keyed by the handle itself and stores V + * High-performance map for handle or string keys and native int or class-reference values. + * Jass uses Warcraft's hashtable with full-string collision checks; Lua uses a native table and stores V * directly, without boxing. * * This deliberately narrow API avoids key hashing and handle-id conversions on Lua. It is not a - * general-purpose replacement for HashMap: only handle keys are accepted, keys are not iterable, + * general-purpose replacement for HashMap: only handle and string keys are supported, keys are not iterable, * and there is no size counter. Use remove() for absence; reference values must be non-null because * Lua represents nil as a missing table entry. Keep class-reference values alive while stored, and * remove or replace them before destroy: Jass stores their integer object IDs, which can be reused, - * while Lua keeps the original reference. The same holds for keys: remove a key's entry before you + * on both targets. The same holds for handle keys: remove a key's entry before you * destroy that handle. Jass stores the entry under the handle id, which Warcraft hands to a later * handle, so the new handle would read the old value; Lua would keep the destroyed handle referenced. * This requires the compiler's keyed-map intrinsic lowering. * The intrinsic source bodies fail loudly if a compiler understands the generic declarations but * lacks that lowering; they never silently discard writes or return defaults. */ -public class FastKeyedMap +public class FastKeyedMap private int map construct() @@ -103,11 +192,11 @@ public class FastKeyedMap /** Returns whether the key has a stored value. */ function has(K key) returns boolean - return keyedMapHas(map, key) + return keyedMapHasNative(map, key) /** Removes a key and its value. */ function remove(K key) - keyedMapRemove(map, key) + keyedMapRemoveNative(map, key) ondestroy keyedMapDestroy(map) diff --git a/wurst/data/KeyedMapTests.wurst b/wurst/data/KeyedMapTests.wurst index b3826fc9..68bae99a 100644 --- a/wurst/data/KeyedMapTests.wurst +++ b/wurst/data/KeyedMapTests.wurst @@ -1,6 +1,32 @@ package KeyedMapTests import KeyedMap +@Test +function fastStringKeysAreCollisionSafe() + let map = new FastKeyedMap() + let other = new FastKeyedMap() + // StringHash folds ASCII case, but string equality does not. + StringHash("alpha").assertEquals(StringHash("ALPHA")) + map.put("alpha", 42) + map.put("ALPHA", 7) + map.put("", 0) + map.put("alpha", 43) + map.get("alpha").assertEquals(43) + map.get("ALPHA").assertEquals(7) + map.has("").assertEquals(true) + other.has("alpha").assertEquals(false) + map.remove("alpha") + map.remove("missing") + map.has("alpha").assertEquals(false) + map.get("ALPHA").assertEquals(7) + map.remove("ALPHA") + map.has("ALPHA").assertEquals(false) + destroy map + let reused = new FastKeyedMap() + reused.has("").assertEquals(false) + destroy reused + destroy other + class KeyedMapTestData int value From 4523969eb42c46e0d49cc7f6b3c363a9332fad02 Mon Sep 17 00:00:00 2001 From: Frotty Date: Fri, 9 Oct 2026 14:45:15 +0200 Subject: [PATCH 2/3] Clarify class-reference lifetime on both targets --- wurst/data/KeyedMap.wurst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wurst/data/KeyedMap.wurst b/wurst/data/KeyedMap.wurst index 2a463561..064e5db0 100644 --- a/wurst/data/KeyedMap.wurst +++ b/wurst/data/KeyedMap.wurst @@ -168,8 +168,8 @@ function findStringEntry(int map, string key) returns StringMapEntry * general-purpose replacement for HashMap: only handle and string keys are supported, keys are not iterable, * and there is no size counter. Use remove() for absence; reference values must be non-null because * Lua represents nil as a missing table entry. Keep class-reference values alive while stored, and - * remove or replace them before destroy: Jass stores their integer object IDs, which can be reused, - * on both targets. The same holds for handle keys: remove a key's entry before you + * remove or replace them before destroy: both targets store their integer object IDs, which can be + * reused. The same holds for handle keys: remove a key's entry before you * destroy that handle. Jass stores the entry under the handle id, which Warcraft hands to a later * handle, so the new handle would read the old value; Lua would keep the destroyed handle referenced. * This requires the compiler's keyed-map intrinsic lowering. From 5c209d449b29a7e587ba9b30b78287098cfa3fca Mon Sep 17 00:00:00 2001 From: Frotty Date: Sun, 11 Oct 2026 22:07:50 +0200 Subject: [PATCH 3/3] Guard null string keys and avoid duplicate Jass bucket lookups --- wurst/data/KeyedMap.wurst | 33 +++++++++++++++----- wurst/data/KeyedMapTests.wurst | 56 ++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 8 deletions(-) diff --git a/wurst/data/KeyedMap.wurst b/wurst/data/KeyedMap.wurst index 064e5db0..c03871c3 100644 --- a/wurst/data/KeyedMap.wurst +++ b/wurst/data/KeyedMap.wurst @@ -9,6 +9,9 @@ import ErrorHandling index, with no handle-id round trip and no entry retained for a unit after it is removed. + Use each low-level map with one key representation only: mixing handle and + string operations can alias a handle id with a string bucket hash on Jass. + The bodies of the fixed-signature functions (keyedMapPut, keyedMapGetInt and the rest) are the Jass implementation, a Table keyed by the handle id, and they are also what any compiler without the lowering compiles on both @@ -92,8 +95,8 @@ class StringMapEntry StringMapEntry array stringHeads -function findStringEntry(int map, string key) returns StringMapEntry - var entry = (map castTo Table).loadInt(StringHash(key)) castTo StringMapEntry +function findStringEntry(StringMapEntry head, string key) returns StringMapEntry + var entry = head while entry != null if entry.key == key return entry @@ -101,16 +104,19 @@ function findStringEntry(int map, string key) returns StringMapEntry return null @compilerintrinsic public function keyedMapPutString(int map, string key, int value) - let old = findStringEntry(map, key) - if old != null - old.value = value + if key == null return let table = map castTo Table let hash = StringHash(key) + let head = table.loadInt(hash) castTo StringMapEntry + let old = findStringEntry(head, key) + if old != null + old.value = value + return let entry = new StringMapEntry() entry.key = key entry.value = value - entry.nextBucket = table.loadInt(hash) castTo StringMapEntry + entry.nextBucket = head entry.nextAll = stringHeads[map] entry.previousAll = null if entry.nextAll != null @@ -119,15 +125,23 @@ function findStringEntry(int map, string key) returns StringMapEntry table.saveInt(hash, entry castTo int) @compilerintrinsic public function keyedMapGetStringInt(int map, string key) returns int - let entry = findStringEntry(map, key) + if key == null + return 0 + let head = (map castTo Table).loadInt(StringHash(key)) castTo StringMapEntry + let entry = findStringEntry(head, key) if entry == null return 0 return entry.value @compilerintrinsic public function keyedMapHasString(int map, string key) returns boolean - return findStringEntry(map, key) != null + if key == null + return false + let head = (map castTo Table).loadInt(StringHash(key)) castTo StringMapEntry + return findStringEntry(head, key) != null @compilerintrinsic public function keyedMapRemoveString(int map, string key) + if key == null + return let table = map castTo Table let hash = StringHash(key) var entry = table.loadInt(hash) castTo StringMapEntry @@ -173,6 +187,9 @@ function findStringEntry(int map, string key) returns StringMapEntry * destroy that handle. Jass stores the entry under the handle id, which Warcraft hands to a later * handle, so the new handle would read the old value; Lua would keep the destroyed handle referenced. * This requires the compiler's keyed-map intrinsic lowering. + * Jass string operations hash the key and scan its collision bucket; destroy is O(n). + * Each stored string key uses one entry from the shared StringMapEntry allocator, subject to + * the target's class-instance limit. Lua allocates no entries and uses native table lookup. * The intrinsic source bodies fail loudly if a compiler understands the generic declarations but * lacks that lowering; they never silently discard writes or return defaults. */ diff --git a/wurst/data/KeyedMapTests.wurst b/wurst/data/KeyedMapTests.wurst index 68bae99a..8a8d9fe2 100644 --- a/wurst/data/KeyedMapTests.wurst +++ b/wurst/data/KeyedMapTests.wurst @@ -1,5 +1,61 @@ package KeyedMapTests import KeyedMap +import Table + +@Test +public function fastNullStringKeysAreAbsent() + let map = new FastKeyedMap() + // Missing hashtable strings produce a runtime null on both backends. + let table = new Table() + let none = table.loadString(0) + map.put("", 11) + map.put(none, 42) + map.has(none).assertEquals(false) + map.get(none).assertEquals(0) + map.remove(none) + map.get("").assertEquals(11) + destroy map + destroy table + +@Test +public function fastStringCollisionChainsSurviveRemovalAndReuse() + let map = new FastKeyedMap() + let other = new FastKeyedMap() + StringHash("Alpha").assertEquals(StringHash("alpha")) + StringHash("aLPHA").assertEquals(StringHash("alpha")) + for i = 1 to 20 + map.put("alpha", i) + map.put("Alpha", 0) + map.put("aLPHA", -i) + other.put("Alpha", 99) + // Remove the middle, then the bucket head, then the remaining tail. + map.remove("Alpha") + map.get("alpha").assertEquals(i) + map.get("aLPHA").assertEquals(-i) + map.remove("aLPHA") + map.get("alpha").assertEquals(i) + map.put("Alpha", 0) + map.has("Alpha").assertEquals(true) + map.remove("alpha") + map.has("alpha").assertEquals(false) + map.get("Alpha").assertEquals(0) + map.remove("Alpha") + map.has("Alpha").assertEquals(false) + other.get("Alpha").assertEquals(99) + // Destroy a populated chain, then reuse the backing map. + map.put("alpha", 1) + map.put("Alpha", 2) + map.put("aLPHA", 3) + destroy map + let reused = new FastKeyedMap() + reused.has("alpha").assertEquals(false) + reused.has("Alpha").assertEquals(false) + reused.has("aLPHA").assertEquals(false) + reused.put("alpha", 4) + reused.get("alpha").assertEquals(4) + other.get("Alpha").assertEquals(99) + destroy reused + destroy other @Test function fastStringKeysAreCollisionSafe()