diff --git a/wurst/data/IntMap.wurst b/wurst/data/IntMap.wurst new file mode 100644 index 00000000..4d55b5f5 --- /dev/null +++ b/wurst/data/IntMap.wurst @@ -0,0 +1,96 @@ +package IntMap +import NoWurst +import ArrayList +import Table + +/** + * O(1) integer-keyed map with compiler-specialized value storage. + * + * JASS hashtables use integer child keys, so fixing the key type avoids an artificial generic + * hash adapter while `V:` keeps strings, reals, booleans, handles, tuples, and class references + * in typed arrays. Removal is unordered. The map does not own stored values. + */ +public class IntMap + private let keys = new ArrayList() + private let values = new ArrayList() + private let indexByKey = new Table() + + /** Returns whether a value exists under the given key. */ + function has(int key) returns boolean + return indexByKey.loadInt(key) != 0 + + /** Inserts or replaces a value. Existing key order is retained. */ + function put(int key, V value) + let stored = indexByKey.loadInt(key) + if stored != 0 + values.set(stored - 1, value) + return + + keys.add(key) + values.add(value) + indexByKey.saveInt(key, keys.size()) + + /** Returns the stored value, or the type's null/default value when absent. */ + function get(int key) returns V + let stored = indexByKey.loadInt(key) + if stored == 0 + return null + return values.get(stored - 1) + + /** Removes a key/value pair and returns whether it was present. */ + function remove(int key) returns boolean + let stored = indexByKey.loadInt(key) + if stored == 0 + return false + + removeAt(stored - 1) + return true + + /** Retrieves a value and removes its key/value pair. */ + function getAndRemove(int key) returns V + let stored = indexByKey.loadInt(key) + if stored == 0 + return null + + let value = values.get(stored - 1) + removeAt(stored - 1) + return value + + /** Removes every entry while retaining allocated list capacity. */ + function clear() + keys.clear() + values.clear() + indexByKey.flush() + + /** Returns the number of entries. */ + function size() returns int + return keys.size() + + /** Returns whether the map is empty. */ + function isEmpty() returns boolean + return keys.isEmpty() + + /** Returns a key by dense index. Removal may change this order. */ + function keyAt(int index) returns int + return keys.get(index) + + /** Returns a value by dense index. Removal may change this order. */ + function valueAt(int index) returns V + return values.get(index) + + private function removeAt(int index) + let removedKey = keys.get(index) + let lastIndex = keys.size() - 1 + + if index != lastIndex + let movedKey = keys.get(lastIndex) + indexByKey.saveInt(movedKey, index + 1) + + keys.removeAtUnordered(index) + values.removeAtUnordered(index) + indexByKey.removeInt(removedKey) + + ondestroy + destroy keys + destroy values + destroy indexByKey diff --git a/wurst/data/IntMapTests.wurst b/wurst/data/IntMapTests.wurst new file mode 100644 index 00000000..b471f213 --- /dev/null +++ b/wurst/data/IntMapTests.wurst @@ -0,0 +1,70 @@ +package IntMapTests +import IntMap + +class IntMapTestValue + int value + + construct(int value) + this.value = value + +tuple IntMapTestTuple(int number, string label) + +@Test +function storesSpecializedPrimitiveValues() + let strings = new IntMap() + strings.put(1, "one") + strings.put(2, "two") + + strings.get(1).assertEquals("one") + strings.get(2).assertEquals("two") + (strings.get(3) == null).assertTrue() + destroy strings + +@Test +function insertsReplacesAndRemovesValues() + let map = new IntMap() + let first = new IntMapTestValue(1) + let replacement = new IntMapTestValue(2) + let moved = new IntMapTestValue(3) + + map.put(10, first) + map.put(20, moved) + map.put(10, replacement) + map.size().assertEquals(2) + (map.get(10) == replacement).assertTrue() + + map.remove(10).assertTrue() + map.remove(10).assertFalse() + map.size().assertEquals(1) + (map.get(20) == moved).assertTrue() + map.keyAt(0).assertEquals(20) + (map.valueAt(0) == moved).assertTrue() + + destroy first + destroy replacement + destroy moved + destroy map + +@Test +function clearsAndReusesStorage() + let map = new IntMap() + map.put(1, true) + map.put(2, false) + map.clear() + + map.isEmpty().assertTrue() + map.has(1).assertFalse() + map.put(3, true) + map.getAndRemove(3).assertTrue() + map.isEmpty().assertTrue() + destroy map + +@Test +function storesSpecializedTupleValues() + let map = new IntMap() + map.put(7, IntMapTestTuple(42, "answer")) + + let loaded = map.get(7) + loaded.number.assertEquals(42) + loaded.label.assertEquals("answer") + destroy map diff --git a/wurst/file/SerializableTests.wurst b/wurst/file/SerializableTests.wurst index ed3aedf6..5910a7f6 100644 --- a/wurst/file/SerializableTests.wurst +++ b/wurst/file/SerializableTests.wurst @@ -188,6 +188,33 @@ function schemaMigrationCanRenameFields() destroy data destroy writer +@Test +function readerMutationsReplaceAndMoveFieldOwnership() + let writer = new FieldSerializationWriter(1) + int oldValue = 10 + int existingValue = 20 + writer.write("old", oldValue) + writer.write("existing", existingValue) + let data = writer.finish() + let reader = new FieldSerializationReader(data) + int replacementValue = 11 + int defaultValue = 0 + + reader.set("old", replacementValue) + reader.read("old", defaultValue).assertEquals(11) + + reader.renameField("old", "moved") + reader.hasField("old").assertFalse() + reader.read("moved", defaultValue).assertEquals(11) + + reader.renameField("moved", "existing") + reader.hasField("moved").assertFalse() + reader.read("existing", defaultValue).assertEquals(20) + + destroy reader + destroy data + destroy writer + @Test function integrityKeyRejectsEditedOrForeignDataWithoutMutation() let writer = new FieldSerializationWriter(1, 12345) diff --git a/wurst/file/StructuredSerializationCore.wurst b/wurst/file/StructuredSerializationCore.wurst index 56345dc5..642e6450 100644 --- a/wurst/file/StructuredSerializationCore.wurst +++ b/wurst/file/StructuredSerializationCore.wurst @@ -1,6 +1,6 @@ package StructuredSerializationCore import ChunkedString -import HashMap +import IntMap import ErrorHandling import Bitwise @@ -329,10 +329,20 @@ class SerializationCursor ondestroy destroy hasher +class SerializedField + string token + ChunkedString payload + + construct(string token, ChunkedString payload) + this.token = token + this.payload = payload + + ondestroy + destroy payload + /** Parsed field table. Unknown fields and wire tokens are retained and safely skippable. */ public class FieldSerializationReader - private let tokenMap = new HashMap() - private let payloadMap = new IterableMap() + private let fields = new IntMap() private int schemaVersion = 0 private int integrityKey private boolean valid = false @@ -358,12 +368,11 @@ public class FieldSerializationReader let token = cursor.read(1) let length = cursor.readVarUInt() let payload = cursor.readChunked(length) - if token.length() != 1 or tokenMap.has(fieldId) + if token.length() != 1 or fields.has(fieldId) cursor.valid = false destroy payload else - tokenMap.put(fieldId, token) - payloadMap.put(fieldId, payload) + fields.put(fieldId, new SerializedField(token, payload)) if cursor.valid and cursor.pointer == cursor.bodyEnd let expected = input.getUnsafeSubString(cursor.bodyEnd, input.length()) @@ -380,25 +389,25 @@ public class FieldSerializationReader return integrityKey function hasField(string name) returns boolean - return valid and payloadMap.has(serializationFieldId(name)) + return valid and fields.has(serializationFieldId(name)) function hasRaw(string name, string token) returns boolean - let fieldId = serializationFieldId(name) - return valid and tokenMap.has(fieldId) and tokenMap.get(fieldId) == token + let field = fields.get(serializationFieldId(name)) + return valid and field != null and field.token == token function readRaw(string name, string token, string oldValue) returns string - let fieldId = serializationFieldId(name) - return valid and tokenMap.has(fieldId) and tokenMap.get(fieldId) == token ? payloadMap.get(fieldId).getUnsafeString() : oldValue + let field = fields.get(serializationFieldId(name)) + return valid and field != null and field.token == token ? field.payload.getUnsafeString() : oldValue /** Returns an independent chunked copy of a raw payload, or `null` when the field/token is absent. The caller owns and must destroy the returned value. */ function readRawChunked(string name, string token) returns ChunkedString - let fieldId = serializationFieldId(name) - if not valid or not tokenMap.has(fieldId) or tokenMap.get(fieldId) != token + let field = fields.get(serializationFieldId(name)) + if not valid or field == null or field.token != token return null - return copyChunkedString(payloadMap.get(fieldId)) + return copyChunkedString(field.payload) function read(string name, int oldValue) returns int let payload = readRaw(name, INT_TOKEN, "") @@ -420,10 +429,13 @@ public class FieldSerializationReader if not valid or token.length() != 1 return let fieldId = serializationFieldId(name) - if payloadMap.has(fieldId) - destroy payloadMap.get(fieldId) - tokenMap.put(fieldId, token) - payloadMap.put(fieldId, new ChunkedString()..append(payload)) + let field = fields.get(fieldId) + if field == null + fields.put(fieldId, new SerializedField(token, new ChunkedString()..append(payload))) + else + destroy field.payload + field.token = token + field.payload = new ChunkedString()..append(payload) function set(string name, int value) setRaw(name, INT_TOKEN, encodeFixedInt(value)) @@ -439,10 +451,10 @@ public class FieldSerializationReader function removeField(string name) let fieldId = serializationFieldId(name) - if payloadMap.has(fieldId) - destroy payloadMap.get(fieldId) - tokenMap.remove(fieldId) - payloadMap.remove(fieldId) + let field = fields.get(fieldId) + if field != null + destroy field + fields.remove(fieldId) /** Mutates an existing opted-in child object when a valid nested envelope is present. */ function readInto(string name, FieldSerializable oldValue) returns boolean @@ -455,10 +467,10 @@ public class FieldSerializationReader /** Returns a nested reader borrowed from this reader, or an invalid reader when absent. */ function readObject(string name) returns FieldSerializationReader - let fieldId = serializationFieldId(name) - if not valid or not tokenMap.has(fieldId) or tokenMap.get(fieldId) != OBJECT_TOKEN + let field = fields.get(serializationFieldId(name)) + if not valid or field == null or field.token != OBJECT_TOKEN return new FieldSerializationReader() - return new FieldSerializationReader(payloadMap.get(fieldId), integrityKey) + return new FieldSerializationReader(field.payload, integrityKey) /** Moves a persisted value from an old field name to a new one. Call this from a migration before @@ -469,19 +481,21 @@ public class FieldSerializationReader let newId = serializationFieldId(newName) if oldId == newId return - if tokenMap.has(oldId) and not tokenMap.has(newId) - tokenMap.put(newId, tokenMap.get(oldId)) - payloadMap.put(newId, payloadMap.get(oldId)) - else if payloadMap.has(oldId) - destroy payloadMap.get(oldId) - tokenMap.remove(oldId) - payloadMap.remove(oldId) + + let oldField = fields.get(oldId) + if oldField == null + return + + fields.remove(oldId) + if fields.has(newId) + destroy oldField + else + fields.put(newId, oldField) ondestroy - for fieldId in payloadMap - destroy payloadMap.get(fieldId) - destroy tokenMap - destroy payloadMap + for i = 0 to fields.size() - 1 + destroy fields.valueAt(i) + destroy fields /** Type-directed field readers used by the compiler-expanded mapper. */ public function int.readSerializedField(FieldSerializationReader reader, string name) returns int