diff --git a/src/main/java/com/projectswg/common/data/customization/CustomizationString.java b/src/main/java/com/projectswg/common/data/customization/CustomizationString.java
deleted file mode 100644
index 4aebe3d..0000000
--- a/src/main/java/com/projectswg/common/data/customization/CustomizationString.java
+++ /dev/null
@@ -1,329 +0,0 @@
-/***********************************************************************************
- * Copyright (c) 2023 /// Project SWG /// www.projectswg.com *
- * *
- * ProjectSWG is the first NGE emulator for Star Wars Galaxies founded on *
- * July 7th, 2011 after SOE announced the official shutdown of Star Wars Galaxies. *
- * Our goal is to create an emulator which will provide a server for players to *
- * continue playing a game similar to the one they used to play. We are basing *
- * it on the final publish of the game prior to end-game events. *
- * *
- * This file is part of PSWGCommon. *
- * *
- * --------------------------------------------------------------------------------*
- * *
- * PSWGCommon is free software: you can redistribute it and/or modify *
- * it under the terms of the GNU Affero General Public License as *
- * published by the Free Software Foundation, either version 3 of the *
- * License, or (at your option) any later version. *
- * *
- * PSWGCommon is distributed in the hope that it will be useful, *
- * but WITHOUT ANY WARRANTY; without even the implied warranty of *
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
- * GNU Affero General Public License for more details. *
- * *
- * You should have received a copy of the GNU Affero General Public License *
- * along with PSWGCommon. If not, see . *
- ***********************************************************************************/
-package com.projectswg.common.data.customization;
-
-import com.projectswg.common.data.encodables.mongo.MongoData;
-import com.projectswg.common.data.encodables.mongo.MongoPersistable;
-import com.projectswg.common.encoding.Encodable;
-import com.projectswg.common.network.NetBuffer;
-import me.joshlarson.jlcommon.log.Log;
-import org.jetbrains.annotations.NotNull;
-
-import java.io.*;
-import java.nio.ByteBuffer;
-import java.nio.charset.StandardCharsets;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.LinkedHashMap;
-import java.util.Map;
-import java.util.Map.Entry;
-import java.util.function.BiConsumer;
-
-/**
- * The Customization string is used to set special properties
- * on objects. This can be lightsaber color, vehicle speed,
- * facial hair style...
- */
-public class CustomizationString implements Encodable, MongoPersistable {
-
- private static final Map VAR_NAME_TO_ID = new HashMap<>();
- private static final Map VAR_ID_TO_NAME = new HashMap<>();
-
- static {
- try (BufferedInputStream bis = new BufferedInputStream(CustomizationString.class.getResourceAsStream("customization_variables.sdb"))) {
- StringBuilder buffer = new StringBuilder();
- String key = null;
- int c;
- while ((c = bis.read()) != -1) {
- switch (c) {
- case '\t':
- key = buffer.toString();
- buffer.setLength(0);
- break;
- //noinspection HardcodedLineSeparator
- case '\r':
- //noinspection HardcodedLineSeparator
- case '\n':
- if (buffer.length() <= 0)
- continue;
- assert key != null;
- VAR_NAME_TO_ID.put(key, Short.valueOf(buffer.toString()));
- VAR_ID_TO_NAME.put(Short.valueOf(buffer.toString()), key);
- buffer.setLength(0);
- break;
- default:
- buffer.append((char) c);
- break;
- }
- }
- if (buffer.length() > 0) {
- assert key != null;
- VAR_NAME_TO_ID.put(key, Short.valueOf(buffer.toString()));
- VAR_ID_TO_NAME.put(Short.valueOf(buffer.toString()), key);
- }
- } catch (IOException e) {
- throw new RuntimeException("could not load customization variables from resources", e);
- }
- }
-
- private final Map variables;
-
- public CustomizationString() {
- this.variables = Collections.synchronizedMap(new LinkedHashMap<>()); // Ordered and synchronized
- }
-
- boolean isEmpty() {
- return variables.isEmpty();
- }
-
- @Override
- public void saveMongo(MongoData data) {
- data.putMap("variables", variables);
- }
-
- @Override
- public void readMongo(MongoData data) {
- variables.clear();
- variables.putAll(data.getMap("variables", String.class, Integer.class));
- }
-
- public Integer put(String name, int value) {
- return variables.put(name, value);
- }
-
- public Integer remove(String name) {
- return variables.remove(name);
- }
-
- public Integer get(String name) {
- return variables.get(name);
- }
-
- public Map getVariables() {
- return Collections.unmodifiableMap(variables);
- }
-
- public void forEach(BiConsumer super String, ? super Integer> consumer) {
- variables.forEach(consumer);
- }
-
- public void clear() {
- variables.clear();
- }
-
- @Override
- public String toString() {
- StringBuilder str = new StringBuilder();
- boolean first = true;
- for (Entry e : variables.entrySet()) {
- if (!first)
- str.append(", ");
- first = false;
- str.append(e.getKey()).append('=').append(e.getValue());
- }
- return str.toString();
- }
-
- @Override
- public void decode(NetBuffer data) {
- byte[] stringData = data.getArray();
-
- if (stringData.length == 0) {
- return;
- }
-
- String string = new String(stringData, StandardCharsets.UTF_8);
- int[] codePoints = string.codePoints().toArray(); // Replaces 0xC3BF with 0xFF, because 0xFF is reserved as an escape flag
- int position = 0;
- byte startOfText = (byte) codePoints[position++];
-
- if (startOfText != 0x02) {
- Log.w("Expected UTF8 start-of-text in CustomizationString, assuming corruption!");
- return;
- }
-
- short variableCount = (short) codePoints[position++];
-
- if (variableCount == 0xFF) {
- // SOE decided not to include a variable count for the Twi'lek lekku. No object has anywhere near 255 variables, so we'll be alright.
- return;
- }
-
- for (short i = 0; i < variableCount; i++) {
- short combinedVariableInfo = (short) codePoints[position++];
- int variableSize = ((combinedVariableInfo & 0x80) != 0) ? 2 : 1;
- short variableId = (short) (combinedVariableInfo & 0x7F);
- int current = codePoints[position++]; // Read 8-bit value
- int variable;
-
- switch (variableSize) {
- case 2:
- // Read 16-bit value
- byte lowByte = (byte) current;
- byte hiByte = (byte) codePoints[position++];
- position++; // Skip escape
-
- current = ((hiByte << 8) | lowByte) & 0xFF;
- break;
- }
-
- if (current == 0xFF) { // This marks an escaped character to follow
- int next = codePoints[position++];
-
- switch (next) {
- case 0x01: // Value is 0
- default:
- variable = 0;
- break;
- case 0x02: // Value is 255
- variable = 0xFF;
- break;
- case 0x03: // We shouldn't be meeting an end here. Malformed input.
- Log.w("Unexpected end of text in CustomizationString, assuming corruption!");
- clear(); // In this case, we'll want to clear whatever we've loaded as it might be corrupted
- return;
- }
- } else {
- variable = current;
- }
-
- String variableName = VAR_ID_TO_NAME.get(variableId);
-
- if (variableName == null) { // Variable ID matched no variable name.
- Log.w("Variable ID %d had no name associated", variableId);
- position++; // Skip the associated value
- continue;
- }
-
- variables.put(variableName, variable);
- }
-
- if ((position + 2) < codePoints.length) {
- Log.w("Too much data remaining in CustomizationString, assuming corruption!");
- clear(); // In this case, we'll want to clear whatever we've loaded as it might be corrupted
- return;
- }
-
- int escapeFlag = codePoints[position++];
- int endOfText = codePoints[position];
-
- if (escapeFlag == 0xFF && endOfText != 0x03) {
- Log.w("Invalid UTF-8 ending for CustomizationString, assuming corruption!");
- clear(); // In this case, we'll want to clear whatever we've loaded as it might be corrupted
- }
- }
-
- @Override
- public byte @NotNull [] encode() {
- if (isEmpty()) {
- // No need to send more than an empty array in this case
- return ByteBuffer.allocate(Short.BYTES).array();
- }
-
- int encodableLength = getLength();
- ByteArrayOutputStream out = new ByteArrayOutputStream(encodableLength - Short.BYTES);
-
- try {
- out.write(2); // Marks start of text
- addCustomizationStringByte(out, variables.size());
-
- variables.forEach((variableName, variableValue) -> {
- int variableId = VAR_NAME_TO_ID.get(variableName);
- boolean variableValueOneByte = variableValue >= 0 && variableValue < 128;
- if (!variableValueOneByte)
- variableId |= 0x80;
-
- try {
- out.write(variableId);
- if (variableValueOneByte) {
- addCustomizationStringByte(out, variableValue);
- } else {
- addCustomizationStringByte(out, variableValue & 0xFF);
- addCustomizationStringByte(out, (variableValue >> 8) & 0xFF);
- }
- } catch (Exception e) {
- Log.e(e);
- }
- });
-
- out.write(0xFF); // Escape
- out.write(3); // Marks end of text
- out.flush();
-
- byte[] result = out.toByteArray();
- NetBuffer data = NetBuffer.allocate(encodableLength);
-
- data.addArray(result); // This will add the array length in little endian order
-
- return data.array();
- } catch (IOException e) {
- Log.e(e);
- return NetBuffer.allocate(Short.SIZE).array(); // Returns an array of 0x00, 0x00 indicating that it's empty
- }
- }
-
- private void addCustomizationStringByte(ByteArrayOutputStream out, int c) throws IOException {
- assert(c >= 0 && c <= 0xFF);
- switch (c) {
- case 0x00 -> {
- out.write(0xFF); // Escape
- out.write(0x01); // Put variable value
- }
- case 0xFF -> {
- out.write(0xFF); // Escape
- out.write(0x02); // Put variable value
- }
- default -> out.write(c);
- }
- }
-
- @Override
- public int getLength() {
- int length = Short.BYTES; // Array size declaration field
-
- length += 3; // UTF-8 start of text, escape, UTF-8 end of text
- length += getValueLength(variables.size()); // variable count
- for (Integer i : variables.values()) {
- length += 1; // variableId
- int firstByte = i & 0xFF;
- int secondByte = (i >> 8) & 0xFF;
- length += getValueLength(firstByte);
- if (i < 0 || i >= 128) { // signed short
- length += getValueLength(secondByte);
- }
- }
-
- return length;
- }
-
- private static int getValueLength(int c) {
- if (c == 0x00 || c == 0xFF)
- return 2;
- return 1;
- }
-
-}
diff --git a/src/main/java/com/projectswg/common/data/customization/CustomizationString.kt b/src/main/java/com/projectswg/common/data/customization/CustomizationString.kt
new file mode 100644
index 0000000..6f3ef7f
--- /dev/null
+++ b/src/main/java/com/projectswg/common/data/customization/CustomizationString.kt
@@ -0,0 +1,239 @@
+/***********************************************************************************
+ * Copyright (c) 2023 /// Project SWG /// www.projectswg.com *
+ * *
+ * ProjectSWG is the first NGE emulator for Star Wars Galaxies founded on *
+ * July 7th, 2011 after SOE announced the official shutdown of Star Wars Galaxies. *
+ * Our goal is to create an emulator which will provide a server for players to *
+ * continue playing a game similar to the one they used to play. We are basing *
+ * it on the final publish of the game prior to end-game events. *
+ * *
+ * This file is part of PSWGCommon. *
+ * *
+ * --------------------------------------------------------------------------------*
+ * *
+ * PSWGCommon is free software: you can redistribute it and/or modify *
+ * it under the terms of the GNU Affero General Public License as *
+ * published by the Free Software Foundation, either version 3 of the *
+ * License, or (at your option) any later version. *
+ * *
+ * PSWGCommon is distributed in the hope that it will be useful, *
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of *
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
+ * GNU Affero General Public License for more details. *
+ * *
+ * You should have received a copy of the GNU Affero General Public License *
+ * along with PSWGCommon. If not, see //www.gnu.org/licenses/>. *
+ */
+package com.projectswg.common.data.customization
+
+import com.projectswg.common.data.encodables.mongo.MongoData
+import com.projectswg.common.data.encodables.mongo.MongoPersistable
+import com.projectswg.common.encoding.Encodable
+import com.projectswg.common.network.NetBuffer
+import me.joshlarson.jlcommon.log.Log
+import java.io.BufferedReader
+import java.io.ByteArrayOutputStream
+import java.io.IOException
+import java.io.InputStreamReader
+import java.util.*
+import java.util.function.BiConsumer
+
+/**
+ * The Customization string is used to set special properties
+ * on objects. This can be lightsaber color, vehicle speed,
+ * facial hair, etc.
+ */
+class CustomizationString : Encodable, MongoPersistable {
+ private val _variables: MutableMap = Collections.synchronizedMap(LinkedHashMap()) // Ordered and synchronized
+ val variables: Map = Collections.unmodifiableMap(_variables)
+
+ val isEmpty: Boolean
+ get() = _variables.isEmpty()
+
+ override fun saveMongo(data: MongoData) {
+ data.putMap("variables", _variables)
+ }
+
+ override fun readMongo(data: MongoData) {
+ _variables.clear()
+ _variables.putAll(data.getMap("variables", String::class.java, Int::class.java))
+ }
+
+ fun put(name: String, value: Int): Int? {
+ assert(name in VAR_NAME_TO_ID)
+ return _variables.put(name, value)
+ }
+
+ fun remove(name: String?): Int? {
+ return _variables.remove(name)
+ }
+
+ fun get(name: String?): Int? {
+ return _variables[name]
+ }
+
+ fun forEach(consumer: BiConsumer?) {
+ _variables.forEach(consumer!!)
+ }
+
+ fun clear() {
+ _variables.clear()
+ }
+
+ override fun toString(): String {
+ val str = StringBuilder()
+ var first = true
+ for ((key, value) in _variables) {
+ if (!first) str.append(", ")
+ first = false
+ str.append(key).append('=').append(value)
+ }
+ return str.toString()
+ }
+
+ override fun decode(data: NetBuffer) {
+ val str = NetBuffer.wrap(data.array)
+ if (str.size() == 0 || str.byte.toInt() != 0x02) return // Start of Text
+
+ val variableCount = getCustomizationStringByte(str)
+ for (i in 0 until variableCount) {
+ val combinedVariableId = str.byte
+ val variableId = VAR_ID_TO_NAME[(combinedVariableId.toInt() and 0x7F).toByte()]
+ var value = getCustomizationStringByte(str) and 0xFF
+ if ((combinedVariableId.toInt() and 0x80) != 0) {
+ value = value or ((getCustomizationStringByte(str) shl 8) and 0xFF00)
+ }
+ if (variableId == null) {
+ Log.w("CustomizationString: Unparsed variableId %d with value %d", (combinedVariableId.toInt() and 0x7F), value)
+ } else {
+ _variables[variableId] = value and 0xFFFF
+ }
+ }
+
+ // str.byte; 0xFF - Escape
+ // str.byte; 0x03 - End of Text
+ }
+
+ override fun encode(): ByteArray {
+ if (isEmpty) {
+ // No need to send more than an empty array in this case
+ return ByteArray(java.lang.Short.BYTES)
+ }
+
+ val encodableLength = length
+ val out = ByteArrayOutputStream(encodableLength - java.lang.Short.BYTES)
+
+ try {
+ out.write(2) // Marks start of text
+ addCustomizationStringByte(out, _variables.size)
+
+ _variables.forEach { (variableName: String, variableValue: Int) ->
+ var variableId = VAR_NAME_TO_ID[variableName]!!.toInt()
+ val variableValueOneByte = variableValue in 0..127
+ if (!variableValueOneByte) variableId = variableId or 0x80
+ try {
+ out.write(variableId)
+ if (variableValueOneByte) {
+ addCustomizationStringByte(out, variableValue)
+ } else {
+ addCustomizationStringByte(out, variableValue and 0xFF)
+ addCustomizationStringByte(out, (variableValue shr 8) and 0xFF)
+ }
+ } catch (e: Exception) {
+ Log.e(e)
+ }
+ }
+
+ out.write(0xFF) // Escape
+ out.write(3) // Marks end of text
+ out.flush()
+
+ val result = out.toByteArray()
+ val data = NetBuffer.allocate(encodableLength)
+
+ data.addArray(result) // This will add the array length in little endian order
+
+ return data.array()
+ } catch (e: IOException) {
+ Log.e(e)
+ return NetBuffer.allocate(java.lang.Short.SIZE).array() // Returns an array of 0x00, 0x00 indicating that it's empty
+ }
+ }
+
+ @Throws(IOException::class)
+ private fun addCustomizationStringByte(out: ByteArrayOutputStream, c: Int) {
+ assert(c in 0..0xFF)
+ when (c) {
+ 0x00 -> {
+ out.write(0xFF) // Escape
+ out.write(0x01) // Put variable value
+ }
+
+ 0xFF -> {
+ out.write(0xFF) // Escape
+ out.write(0x02) // Put variable value
+ }
+
+ else -> out.write(c)
+ }
+ }
+
+ private fun getCustomizationStringByte(out: NetBuffer): Int {
+ val c = out.byte
+
+ if (c == 0xFF.toByte()) {
+ return when (out.byte) {
+ 0x01.toByte() -> 0x00
+ 0x02.toByte() -> 0xFF
+ else -> 0x00
+ }
+ }
+
+ return c.toInt()
+ }
+
+ override val length: Int
+ get() {
+ var length = java.lang.Short.BYTES // Array size declaration field
+
+ length += 3 // UTF-8 start of text, escape, UTF-8 end of text
+ length += getValueLength(_variables.size) // variable count
+ for (i in _variables.values) {
+ length += 1 // variableId
+ val firstByte = i and 0xFF
+ val secondByte = (i shr 8) and 0xFF
+ length += getValueLength(firstByte)
+ if (i < 0 || i >= 128) { // signed short
+ length += getValueLength(secondByte)
+ }
+ }
+
+ return length
+ }
+
+ companion object {
+ private val VAR_NAME_TO_ID: MutableMap = HashMap()
+ private val VAR_ID_TO_NAME: MutableMap = HashMap()
+
+ init {
+ try {
+ BufferedReader(InputStreamReader(Objects.requireNonNull(CustomizationString::class.java.getResourceAsStream("customization_variables.sdb")))).use { reader ->
+ reader.lines().forEach { line: String ->
+ val tab = line.indexOf('\t')
+ val key = line.substring(0, tab)
+ val value = line.substring(tab + 1).toByte()
+ VAR_NAME_TO_ID[key] = value
+ VAR_ID_TO_NAME[value] = key
+ }
+ }
+ } catch (e: IOException) {
+ throw RuntimeException("could not load customization variables from resources", e)
+ }
+ }
+
+ private fun getValueLength(c: Int): Int {
+ if (c == 0x00 || c == 0xFF) return 2
+ return 1
+ }
+ }
+}
diff --git a/src/test/java/com/projectswg/common/data/customization/TestCustomizationString.kt b/src/test/java/com/projectswg/common/data/customization/TestCustomizationString.kt
index 792812a..654746b 100644
--- a/src/test/java/com/projectswg/common/data/customization/TestCustomizationString.kt
+++ b/src/test/java/com/projectswg/common/data/customization/TestCustomizationString.kt
@@ -34,7 +34,7 @@ class TestCustomizationString {
@Test
fun testPut() {
val string = CustomizationString()
- val key = "test"
+ val key = "/private/index_color_pattern"
assertNull(string.put(key, 0)) // Nothing should be replaced because string's empty
assertEquals(0, string.put(key, 1)) // Same key, so the variable we put earlier should be replaced
}
@@ -42,7 +42,7 @@ class TestCustomizationString {
@Test
fun testRemove() {
val string = CustomizationString()
- val key = "test"
+ val key = "/private/index_color_pattern"
string.put(key, 0)
assertEquals(0, string.remove(key)) // Same key, so the variable we put earlier should be returned
}
@@ -50,7 +50,7 @@ class TestCustomizationString {
@Test
fun testIsEmpty() {
val string = CustomizationString()
- val key = "test"
+ val key = "/private/index_color_pattern"
assertTrue(string.isEmpty)
string.put(key, 0)
assertFalse(string.isEmpty)
@@ -71,16 +71,15 @@ class TestCustomizationString {
}
@Test
- fun decoding() {
+ fun encodeDecode() {
val string = CustomizationString()
string.put("/private/index_color_1", 237)
string.put("/private/index_color_2", 4)
- val input = byteArrayOf(10, 0, 2, 2, 2, -61, -83, 1, 4, -61, -65, 3)
-
- string.decode(NetBuffer.wrap(input))
+ val decoded = CustomizationString()
+ decoded.decode(NetBuffer.wrap(string.encode()))
assertEquals(2, string.variables.size)
- assertEquals(string.get("/private/index_color_1"), 237)
- assertEquals(string.get("/private/index_color_2"), 4)
+ assertEquals(decoded.get("/private/index_color_1"), 237)
+ assertEquals(decoded.get("/private/index_color_2"), 4)
}
}