Fixed a bug in CustomizationString#getLength calculation

This commit is contained in:
Ziggy
2023-04-05 19:22:33 +02:00
parent 1ecfa78357
commit c545f23960
2 changed files with 133 additions and 108 deletions

View File

@@ -1,5 +1,5 @@
/***********************************************************************************
* Copyright (c) 2018 /// Project SWG /// www.projectswg.com *
* 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. *
@@ -30,8 +30,8 @@ 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 com.projectswg.common.network.NetBufferStream;
import me.joshlarson.jlcommon.log.Log;
import org.jetbrains.annotations.NotNull;
import java.io.*;
import java.nio.ByteBuffer;
@@ -49,10 +49,10 @@ import java.util.function.BiConsumer;
* facial hair style...
*/
public class CustomizationString implements Encodable, MongoPersistable {
private static final Map<String, Short> VAR_NAME_TO_ID = new HashMap<>();
private static final Map<Short, String> VAR_ID_TO_NAME = new HashMap<>();
static {
try (BufferedInputStream bis = new BufferedInputStream(CustomizationString.class.getResourceAsStream("customization_variables.sdb"))) {
StringBuilder buffer = new StringBuilder();
@@ -66,7 +66,7 @@ public class CustomizationString implements Encodable, MongoPersistable {
break;
//noinspection HardcodedLineSeparator
case '\r':
//noinspection HardcodedLineSeparator
//noinspection HardcodedLineSeparator
case '\n':
if (buffer.length() <= 0)
continue;
@@ -89,60 +89,71 @@ public class CustomizationString implements Encodable, MongoPersistable {
throw new RuntimeException("could not load customization variables from resources", e);
}
}
private final Map<String, Integer> variables;
public CustomizationString() {
this.variables = Collections.synchronizedMap(new LinkedHashMap<>()); // Ordered and synchronized
this.variables = Collections.synchronizedMap(new LinkedHashMap<>()); // Ordered and synchronized
}
boolean isEmpty() {
return variables.isEmpty();
}
/**
*
* @return the amount of characters that would require escaping to be valid UTF-8
*/
int valuesToEscape() {
return (int) variables.values().stream().filter(CustomizationString::isReserved).count();
}
int valueLengths() {
return variables.values().stream().mapToInt(value -> {
boolean singleByte = value <= Byte.MAX_VALUE && value >= Byte.MIN_VALUE;
if (singleByte) {
return 1;
} else {
return 2;
}
}).sum();
}
@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<String, Integer> 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();
@@ -155,170 +166,169 @@ public class CustomizationString implements Encodable, MongoPersistable {
}
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[] 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 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
position++; // Skip escape
current = ((hiByte << 8) | lowByte) & 0xFF;
break;
}
if (current == 0xFF) { // This marks an escaped character to follow
if (current == 0xFF) { // This marks an escaped character to follow
int next = codePoints[position++];
switch (next) {
case 0x01: // Value is 0
case 0x01: // Value is 0
default:
variable = 0;
break;
case 0x02: // Value is 255
case 0x02: // Value is 255
variable = 0xFF;
break;
case 0x03: // We shouldn't be meeting an end here. Malformed input.
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
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.
if (variableName == null) { // Variable ID matched no variable name.
Log.w("Variable ID %d had no name associated", variableId);
position++; // Skip the associated value
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
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
clear(); // In this case, we'll want to clear whatever we've loaded as it might be corrupted
}
}
@Override
public byte[] encode() {
public byte @NotNull [] encode() {
if (isEmpty()) {
// No need to send more than an empty array in this case
return ByteBuffer.allocate(Short.BYTES).array();
}
ByteArrayOutputStream out = new ByteArrayOutputStream(getLength());
int encodableLength = getLength();
ByteArrayOutputStream out = new ByteArrayOutputStream(encodableLength - Short.BYTES);
Writer writer = new OutputStreamWriter(out, StandardCharsets.UTF_8);
try {
out.write(2); // Marks start of text
out.write(variables.size());
variables.forEach((variableName, variable) -> {
short combinedVariable = VAR_NAME_TO_ID.get(variableName);
writer.write(2); // Marks start of text
writer.write(variables.size());
variables.forEach((variableName, variableValue) -> {
short variableId = VAR_NAME_TO_ID.get(variableName);
try {
writer.write(combinedVariable); // Put variable
switch (variable) {
case 0x00:
writer.write(variableId);
switch (variableValue) {
case 0x00 -> {
writer.write(0xFF); // Escape
writer.write(0x01); // Put variable value
break;
case 0xFF:
}
case 0xFF -> {
writer.write(0xFF); // Escape
writer.write(0x02); // Put variable value
break;
default:
writer.write(variable);
break;
}
default -> writer.write(variableValue);
}
} catch (Exception e) {
Log.e(e);
}
});
writer.write(0x0FF); // Escape
writer.write(3); // Marks end of text
writer.write(0x0FF); // Escape
writer.write(3); // Marks end of text
writer.flush();
byte[] result = out.toByteArray();
NetBuffer data = NetBuffer.allocate(Short.BYTES + result.length);
data.addArray(result); // This will add the array length in little endian order
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
return NetBuffer.allocate(Short.SIZE).array(); // Returns an array of 0x00, 0x00 indicating that it's empty
}
}
@Override
public int getLength() {
int length = Short.BYTES; // Array size declaration field
if (isEmpty()) { // No need to send more than an empty array in this case
int length = Short.BYTES; // Array size declaration field
if (isEmpty()) { // No need to send more than an empty array in this case
return length;
}
length += 1; // UTF-8 start of text
length += 1; // Variable count
length += variables.size() * 2; // Amount of variable IDs and their value
length += valuesToEscape() * 2; // If there are escaped values in there, there will be 0xC3 0xBF to indicate escape
length += 2; // Escape flag
length += 1; // UTF-8 end of text
length += 1; // UTF-8 start of text
length += 1; // Variable count
length += variables.size(); // Variable IDs
length += valuesToEscape() * Short.BYTES; // If there are escaped values in there, there will be 0xC3 0xBF to indicate escape
length += valueLengths(); // Variable value
length += Short.BYTES; // Escape flag
length += 1; // UTF-8 end of text
return length;
}
/**
*
* @param value the value to check
* @return {@code true} if the value is reserved by UTF-8 and escaping it
* would be necessary for proper compatibility.
@@ -326,5 +336,5 @@ public class CustomizationString implements Encodable, MongoPersistable {
private static boolean isReserved(int value) {
return value == 0x00 || value == 0xFF;
}
}

View File

@@ -26,7 +26,8 @@
***********************************************************************************/
package com.projectswg.common.data.customization
import org.junit.jupiter.api.Assertions
import com.projectswg.common.network.NetBuffer
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
class TestCustomizationString {
@@ -34,8 +35,8 @@ class TestCustomizationString {
fun testPut() {
val string = CustomizationString()
val key = "test"
Assertions.assertNull(string.put(key, 0)) // Nothing should be replaced because string's empty
Assertions.assertEquals(0, string.put(key, 1)) // Same key, so the variable we put earlier should be replaced
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
}
@Test
@@ -43,29 +44,43 @@ class TestCustomizationString {
val string = CustomizationString()
val key = "test"
string.put(key, 0)
Assertions.assertEquals(0, string.remove(key)) // Same key, so the variable we put earlier should be returned
assertEquals(0, string.remove(key)) // Same key, so the variable we put earlier should be returned
}
@Test
fun testIsEmpty() {
val string = CustomizationString()
val key = "test"
Assertions.assertTrue(string.isEmpty)
assertTrue(string.isEmpty)
string.put(key, 0)
Assertions.assertFalse(string.isEmpty)
assertFalse(string.isEmpty)
string.remove(key)
Assertions.assertTrue(string.isEmpty)
assertTrue(string.isEmpty)
}
@Test
fun testGetLength() {
fun encoding() {
val string = CustomizationString()
Assertions.assertEquals(Short.SIZE_BYTES, string.length) // Should be an empty array at this point
string.put("first", 7)
var expected = Short.SIZE_BYTES + 7
Assertions.assertEquals(expected, string.length)
string.put("second", 0xFF)
expected += 4 // Two escape characters, an ID and a value
Assertions.assertEquals(expected, string.length)
string.put("/private/index_color_1", 237)
string.put("/private/index_color_2", 4)
val expected = byteArrayOf(10, 0, 2, 2, 2, -61, -83, 1, 4, -61, -65, 3)
val actual = string.encode()
assertArrayEquals(expected, actual)
}
@Test
fun decoding() {
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))
assertEquals(2, string.variables.size)
assertEquals(string.get("/private/index_color_1"), 237)
assertEquals(string.get("/private/index_color_2"), 4)
}
}