diff --git a/sources/Avarice/Avarice.uc b/sources/Avarice/Avarice.uc new file mode 100644 index 0000000..f162618 --- /dev/null +++ b/sources/Avarice/Avarice.uc @@ -0,0 +1,172 @@ +/** + * This feature makes it possible to use TCP connection to exchange + * messages (represented by JSON objects) with external applications. + * There are some peculiarities to UnrealEngine's `TCPLink`, so to simplify + * communication process for external applications, they are expected to + * connect to the server through the "Avarice" utility that can accept a stream + * of utf8-encoded JSON messageand feed them to our `TCPLink` (we use child + * class `AvariceTcpStream`) in a way it can receive them. + * Every message sent to us must have the following structure: + * { "s": "", "t": "", "p": } + * where + * * describes a particular source of messages + * (it can be a name of the database or an alias for + * a connected application); + * * simply states the name of a command, for a database it + * can be "get", "set", "delete", etc.. + * * can be an arbitrary json value and can be used to + * pass any additional information along with the message. + * Acedia provides a special treatment for any messages that have their + * service set to "echo" - it always returns them back as-is, except for the + * message type that gets set to "end". + * Copyright 2021 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class Avarice extends Feature + config(AcediaAvarice); + +// The feature itself is dead simple - it simply creates list of +// `AvariceLink` objects, according to its settings, and stores them +var private array createdLinks; + +struct AvariceLinkRecord +{ + var string name; + var string address; +}; +// List all the names (and addresses) of all Avarice instances Acedia must +// connect to. +// `name` parameter is a useful (case-insensitive) identifier that +// can be used in other configs to point at each link. +// `address` must have form "host:port", where "host" is either ip or +// domain name and "port" is a numerical port value. +var private config array link; + +// In case Avarice utility is launched after link started trying open +// the connection - that connection attempt will fail. To fix that link must +// try connecting again. +// This variable sets the time (in seconds), after which link will +// re-attempt opening connection. Setting value too low can prevent any +// connection from opening, setting it too high might make you wait for +// connection too long. +var private config float reconnectTime; + +var private const int TECHO, TEND, TCOLON; + +var private LoggerAPI.Definition errorBadAddress; + +protected function OnEnabled() +{ + local int i; + local Text name; + local MutableText host; + local int port; + local AvariceLink nextLink; + for (i = 0; i < link.length; i += 1) + { + name = _.text.FromString(link[i].name); + if (ParseAddress(link[i].address, host, port)) + { + nextLink = AvariceLink(_.memory.Allocate(class'AvariceLink')); + nextLink.Initialize(name, host, port); + nextLink.StartUp(); + nextLink.OnMessage(self, T(TECHO)).connect = EchoHandler; + createdLinks[createdLinks.length] = nextLink; + } + else { + _.logger.Auto(errorBadAddress).Arg(_.text.FromString(link[i].name)); + } + _.memory.Free(name); + _.memory.Free(host); + } +} + +protected function OnDisabled() +{ + _.memory.FreeMany(createdLinks); +} + +// Reply back any messages from "echo" service +private function EchoHandler(AvariceLink link, AvariceMessage message) +{ + link.SendMessage(T(TECHO), T(TEND), message.parameters); +} + +private final function bool ParseAddress( + string address, + out MutableText host, + out int port) +{ + local bool success; + local Parser parser; + parser = _.text.ParseString(address); + parser.Skip() + .MUntil(host, T(TCOLON).GetCharacter(0)) + .Match(T(TCOLON)) + .MUnsignedInteger(port) + .Skip(); + success = parser.Ok() && parser.GetRemainingLength() == 0; + parser.FreeSelf(); + return success; +} + +/** + * Method that returns all the `AvariceLink` created by this feature. + * + * @return Array of links created by this feature. + * Guaranteed to not contain `none` values. + */ +public final function array GetAllLinks() +{ + local int i; + while (i < createdLinks.length) + { + if (createdLinks[i] == none) { + createdLinks.Remove(i, 1); + } + else { + i += 1; + } + } + return createdLinks; +} + +/** + * Returns its current `reconnectTime` setting that describes amount of time + * between connection attempts. + * + * @return Value of `reconnectTime` config variable. + */ +public final static function float GetReconnectTime() +{ + return default.reconnectTime; +} + +defaultproperties +{ + // Settings + reconnectTime = 10.0 + // `Text` constants + TECHO = 0 + stringConstants(0) = "echo" + TEND = 1 + stringConstants(1) = "end" + TCOLON = 2 + stringConstants(2) = ":" + // Log messages + errorBadAddress = (l=LOG_Error,m="Cannot parse address \"%1\"") +} \ No newline at end of file diff --git a/sources/Avarice/AvariceAPI.uc b/sources/Avarice/AvariceAPI.uc new file mode 100644 index 0000000..ddcdc1d --- /dev/null +++ b/sources/Avarice/AvariceAPI.uc @@ -0,0 +1,76 @@ +/** + * API for Avarice functionality of Acedia. + * Copyright 2020 - 2021 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class AvariceAPI extends AcediaObject; + +/** + * Method that returns all the `AvariceLink` created by `Avarice` feature. + * + * @return Array of links created by this feature. + * Guaranteed to not contain `none` values. + * Empty if `Avarice` feature is currently disabled. + */ +public final function array GetAllLinks() +{ + local Avarice avariceFeature; + local array emptyResult; + avariceFeature = Avarice(class'Avarice'.static.GetInstance()); + if (avariceFeature != none) { + return avariceFeature.GetAllLinks(); + } + return emptyResult; +} + +/** + * Finds and returns `AvariceLink` by its name, specified in "AcediaAvarice" + * config, if it exists. + * + * @param linkName Name of the `AvariceLink` to find. + * @return `AvariceLink` corresponding to name `linkName`. + * If `linkName == none` or `AvariceLink` with such name does not exist - + * returns `none`. + */ +public final function AvariceLink GetLink(Text linkName) +{ + local int i; + local Text nextName; + local array allLinks; + if (linkName == none) { + return none; + } + allLinks = GetAllLinks(); + for (i = 0; i < allLinks.length; i += 1) + { + if (allLinks[i] == none) { + continue; + } + nextName = allLinks[i].GetName(); + if (linkName.Compare(nextName, SCASE_INSENSITIVE)) + { + _.memory.Free(nextName); + return allLinks[i]; + } + _.memory.Free(nextName); + } + return none; +} + +defaultproperties +{ +} \ No newline at end of file diff --git a/sources/Avarice/AvariceLink.uc b/sources/Avarice/AvariceLink.uc new file mode 100644 index 0000000..8012cb4 --- /dev/null +++ b/sources/Avarice/AvariceLink.uc @@ -0,0 +1,437 @@ +/** + * Provide interface for the connection to Avarice application. + * It's parameters are defined in Acedia's config. + * Class provides methods to obtain its configuration information + * (name, address, port), methods to check and change the status of connection, + * signals to handle arriving messages and ways to send messages back. + * Copyright 2021 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class AvariceLink extends AcediaObject; + +/** + * Objects of this class are supposed to be obtained via the + * `AvariceAPI.GetLink()` method. Available links are automatically initialized + * based on the configs and their parameters cannot be changed. + * It is also possible to spawn a link of your own by creating an object of + * this class (`AvariceLink`) and calling `Initialize()` method with + * appropriate parameters. To start the link then simply call `StartUp()`. + * But such links will not appear in the list of available links in + * `AvariceAPI`. + */ + +// Actual work of dealing with network input/output is done in +// the `AvariceTcpStream` `Actor` class that is stored inside this reference +var private NativeActorRef tcpStream; + +// `tcpStream` communicates with this class by informing it about specific +// events. This enum describes all of their types. +enum AvariceNetworkMessage +{ + // Connection with Avarice established - can happen several times in case + // connection is interrupted + ANM_Connected, + // We have lost connection with Avarice, but normally will attempt to + // reconnect back + ANM_Disconnected, + // JSON message received + ANM_Message, + // Connection died: either was manually closed, host address could not + // be resolved or invalid data was received from Avarice + ANM_Death +}; + +// Name of this link, specified in the config +var private Text linkName; +// Host of the Avarice instance we are connecting to +var private Text linkHost; +// Port used by the Avarice instance we are connecting to +var private int linkPort; + +var private SimpleSignal onConnectedSignal; +var private SimpleSignal onDisconnectedSignal; +var private SimpleSignal onDeathSignal; +// We want to have a separate signal for each message "service", since most +// users of `AvariceLink` would only care about one particular service. +// To achieve that we use this array as a "service name" <-> "signal" map. +var private AssociativeArray serviceSignalMap; + +var private const int TSERVICE_PREFIX, TTYPE_PREFIX; +var private const int TPARAMS_PREFIX, TMESSAGE_SUFFIX; + +var private LoggerAPI.Definition fatalCannotSpawn; + +protected function Constructor() +{ + onConnectedSignal = SimpleSignal(_.memory.Allocate(class'SimpleSignal')); + onDisconnectedSignal = SimpleSignal(_.memory.Allocate(class'SimpleSignal')); + onDeathSignal = SimpleSignal(_.memory.Allocate(class'SimpleSignal')); + serviceSignalMap = _.collections.EmptyAssociativeArray(); +} + +protected function Finalizer() +{ + local Actor storedStream; + _.memory.Free(onConnectedSignal); + _.memory.Free(onDisconnectedSignal); + _.memory.Free(onDeathSignal); + _.memory.Free(serviceSignalMap); + _.memory.Free(linkName); + _.memory.Free(linkHost); + onConnectedSignal = none; + onDisconnectedSignal = none; + onDeathSignal = none; + serviceSignalMap = none; + linkName = none; + linkHost = none; + linkPort = 0; + if (tcpStream == none) { + return; + } + storedStream = tcpStream.Get(); + if (storedStream != none) { + storedStream.Destroy(); + } + tcpStream.FreeSelf(); + tcpStream = none; +} + +/** + * Initializes this caller `AvariceLink` with config data. + * + * Can only successfully (for that `name` and `host` must not be `none`) + * be called once. + * + * @param name Alias (case-insensitive) of caller `AvariceLink`. + * Must not be `none`. + * @param host Host of the Avarice instance that caller `AvariceLink` is + * connecting to. Must not be `none`. + * @param name Port used by the Avarice instance that caller `AvariceLink` + * is connecting to. + */ +public final function Initialize(Text name, Text host, int port) +{ + if (tcpStream != none) return; + if (name == none) return; + if (host == none) return; + + linkName = name.Copy(); + linkHost = host.Copy(); + linkPort = port; + tcpStream = _.unreal.ActorRef(none); +} + +/** + * Signal that will be emitted whenever caller `AvariceLink` connects to + * Avarice. This event can be emitted multiple times if case link temporarily + * loses it's TCP connection or if connection is killed off due to errors + * (or manually). + * + * [Signature] + * void () + */ +/* SIGNAL */ +public final function SimpleSlot OnConnected(AcediaObject receiver) +{ + return SimpleSlot(onConnectedSignal.NewSlot(receiver)); +} + +/** + * Signal that will be emitted whenever caller `AvariceLink` disconnects from + * Avarice. Disconnects can temporarily be cause by network issue and + * `AvariceLink` will attempt to restore it's connection. To detect when + * connection was permanently severed use `OnDeath` signal instead. + * + * [Signature] + * void () + */ +/* SIGNAL */ +public final function SimpleSlot OnDisconnected(AcediaObject receiver) +{ + return SimpleSlot(onDisconnectedSignal.NewSlot(receiver)); +} + +/** + * Signal that will be emitted whenever connection is closed and dropped: + * either due to bein unable to resolve host's address, receiving incorrect + * input from Avarice or someone manually closing it. + * + * [Signature] + * void () + */ +/* SIGNAL */ +public final function SimpleSlot OnDeath(AcediaObject receiver) +{ + return SimpleSlot(onDeathSignal.NewSlot(receiver)); +} + +/** + * Signal that will be emitted whenever caller `AvariceLink` disconnects from + * Avarice. Disconnects can temporarily be cause by network issue and + * `AvariceLink` will attempt to restore it's connection. To detect when + * connection was permanently severed use `OnDeath` signal instead. + * + * @param service Name of the service, whos messages one wants to receive. + * `none` will be treated as an empty `Text`. + * + * [Signature] + * void (AvariceLink link, AvariceMessage message) + * @param link Link that has received message. + * @param message Received message. + * Can be any JSON-compatible value (see `JSONAPI.IsCompatible()` + * for more information). + */ +/* SIGNAL */ +public final function Avarice_OnMessage_Slot OnMessage( + AcediaObject receiver, + Text service) +{ + return Avarice_OnMessage_Slot(GetServiceSignal(service).NewSlot(receiver)); +} + +private final function Avarice_OnMessage_Signal GetServiceSignal(Text service) +{ + local Avarice_OnMessage_Signal result; + if (service != none) { + service = service.Copy(); + } + else { + service = Text(_.memory.Allocate(class'Text')); + } + result = Avarice_OnMessage_Signal(serviceSignalMap.GetItem(service)); + if (result == none) + { + result = Avarice_OnMessage_Signal( + _.memory.Allocate(class'Avarice_OnMessage_Signal')); + serviceSignalMap.SetItem(service, result); + } + else { + service.FreeSelf(); + } + return result; +} + +/** + * Starts caller `AvariceLink`, making it attempt to connect to the Avarice + * with parameters that should be first specified by the `Initialize()` call. + * + * Does nothing if the caller `AvariceLink` is either not initialized or + * is already active (`IsActive() == true`). + */ +public final function StartUp() +{ + local AvariceTcpStream newStream; + if (tcpStream == none) return; + if (tcpStream.Get() != none) return; + + newStream = AvariceTcpStream(_.memory.Allocate(class'AvariceTcpStream')); + if (newStream == none) + { + // `linkName` has to be defined if `tcpStream` is defined + _.logger.Auto(fatalCannotSpawn).Arg(linkName.Copy()); + return; + } + tcpStream.Set(newStream); + newStream.StartUp(self, class'Avarice'.static.GetReconnectTime()); +} + +/** + * Shuts down any connections related to the caller `AvariceLink`. + * + * Does nothing if the caller `AvariceLink` is either not initialized or + * is already inactive (`IsActive() == false`). + */ +public final function ShutDown() +{ + local Actor storedStream; + if (tcpStream == none) return; + storedStream = tcpStream.Get(); + if (storedStream == none) return; + + storedStream.Destroy(); + tcpStream.Set(none); +} + +/** + * Checks whether caller `AvariceLink` is currently active: either connected or + * currently attempts to connect to Avarice. + * + * See also `IsConnected()`. + * + * @return `true` if caller `AvariceLink` is either connected or currently + * attempting to connect to Avarice. `false` otherwise. + */ +public final function bool IsActive() +{ + if (tcpStream == none) { + return false; + } + return tcpStream.Get() != none; +} + +/** + * Checks whether caller `AvariceLink` is currently connected or to Avarice. + * + * See also `IsActive()`. + * + * @return `true` iff caller `AvariceLink` is currently connected to Avarice. + */ +public final function bool IsConnected() +{ + local AvariceTcpStream storedStream; + if (tcpStream == none) { + return false; + } + storedStream = AvariceTcpStream(tcpStream.Get()); + return storedStream.linkState == STATE_Connected; +} + +/** + * Returns name caller `AvariceLink` was initialized with. + * Defined through the config files. + * + * @return Name of the caller `AvariceLink`. + * `none` iff caller link was not yet initialized. + */ +public final function Text GetName() +{ + if (linkName != none) { + return linkName.Copy(); + } + // `linkName` cannot be `none` after `Initialize()` call + return none; +} + +/** + * Returns host name (without port number) caller `AvariceLink` was + * initialized with. Defined through the config files. + * + * See `GetPort()` method for port number. + * + * @return Host name of the caller `AvariceLink`. + * `none` iff caller link was not yet initialized. + */ +public final function Text GetHost() +{ + if (linkHost != none) { + return linkHost.Copy(); + } + // `linkName` cannot be `none` after `Initialize()` call + return none; +} + +/** + * Returns port number caller `AvariceLink` was initialized with. + * Defined through the config files. + * + * @return Host name of the caller `AvariceLink`. + * If caller link was not yet initialized, method makes no guarantees + * about returned number. + */ +public final function int GetPort() +{ + return linkPort; +} + +/** + * Send a message to the Avarice that caller `AvariceLink` is connected to. + * + * Message can only be set if caller `AvariceLink` was initialized and is + * currently connected (see `IsConnected()`) to Avarice. + * + * @param service Name of the service this message is addressed. + * As an example, to address a database one would specify its name, + * like "db". Cannot be `none`. + * @param type Name of this message. As an example, to address + * a database, one would specify here WHAT that database must do, + * like "get" to fetch some data. Cannot be `none`. + * @param parameters Parameters of the command. Can be any value that is + * JSON-compatible (see `JSONAPI.IsCompatible()` for details). + * @return `true` if message was successfully sent and `false` otherwise. + * Note that this method returning `true` does not necessarily mean that + * message has arrived (which is impossible to know at this moment), + * instead simply saying that network call to send data was successful. + * Avarice does not provide any mechanism to verify message arrival, so if + * you need that confirmation - it is necessary that service you are + * addressing make a reply. + */ +public final function bool SendMessage( + Text service, + Text type, + AcediaObject parameters) +{ + local Mutabletext parametesAsJSON; + local MutableText message; + local AvariceTcpStream storedStream; + if (tcpStream == none) return false; + if (service == none) return false; + if (type == none) return false; + storedStream = AvariceTcpStream(tcpStream.Get()); + if (storedStream == none) return false; + if (storedStream.linkState != STATE_Connected) return false; + parametesAsJSON = _.json.Print(parameters); + if (parametesAsJSON == none) return false; + + message = _.text.Empty(); + message.Append(T(TSERVICE_PREFIX)) + .Append(_.json.Print(service)) + .Append(T(TTYPE_PREFIX)) + .Append(_.json.Print(type)) + .Append(T(TPARAMS_PREFIX)) + .Append(parametesAsJSON) + .Append(T(TMESSAGE_SUFFIX)); + storedStream.SendMessage(message); + message.FreeSelf(); + parametesAsJSON.FreeSelf(); + return true; +} + +// This is a public method, but it is not a part of +// `AvariceLink` interface. +// It is used as a communication channel with `AvariceTcpStream` and +// should not be called outside of that class. +public final function ReceiveNetworkMessage( + AvariceNetworkMessage message, + optional AvariceMessage avariceMessage) +{ + if (message == ANM_Connected) { + onConnectedSignal.Emit(); + } + else if (message == ANM_Disconnected) { + onDisconnectedSignal.Emit(); + } + else if (message == ANM_Message && avariceMessage != none) { + GetServiceSignal(avariceMessage.service).Emit(self, avariceMessage); + } + else if (message == ANM_Death) { + onDeathSignal.Emit(); + tcpStream.Set(none); + } +} + +defaultproperties +{ + TSERVICE_PREFIX = 0 + stringConstants(0) = "&{\"s\":" + TTYPE_PREFIX = 1 + stringConstants(1) = ",\"t\":" + TPARAMS_PREFIX = 2 + stringConstants(2) = ",\"p\":" + TMESSAGE_SUFFIX = 3 + stringConstants(3) = "&}" + fatalCannotSpawn = (l=LOG_Error,m="Cannot spawn new actor of class `AvariceTcpStream`, avarice link \"%1\" will not be created") +} \ No newline at end of file diff --git a/sources/Avarice/AvariceMessage.uc b/sources/Avarice/AvariceMessage.uc new file mode 100644 index 0000000..2655819 --- /dev/null +++ b/sources/Avarice/AvariceMessage.uc @@ -0,0 +1,97 @@ +/** + * Object that represents a message received from Avarice. + * For performance's sake it does not provide a getter/setter interface and + * exposes public fields instead. However, for Acedia to correctly function + * you are not supposed modify those fields in any way, only using them to + * read necessary data. + * All `AvariceMessage`'s fields will be automatically deallocated, so if + * you need their data - you have to make a copy, instead of simply storing + * a reference to them. + * Copyright 2021 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class AvariceMessage extends AcediaObject; + +// Every message from Avarice has following structure: +// { "s": "", "t": "", "p": } +// Value of the "s" field +var public Text service; +// Value of the "t" field +var public Text type; +// Value of the "p" field +var public AcediaObject parameters; + +var private AssociativeArray messageTemplate; + +var private const int TS, TT, TP; + +public static function StaticConstructor() +{ + if (StaticConstructorGuard()) return; + super.StaticConstructor(); + + default.messageTemplate = __().collections.EmptyAssociativeArray(); + ResetTemplate(default.messageTemplate); +} + +protected function Finalizer() +{ + __().memory.Free(type); + __().memory.Free(service); + __().memory.Free(parameters); + type = none; + service = none; + parameters = none; +} + +private static final function ResetTemplate(AssociativeArray template) +{ + if (template == none) { + return; + } + template.SetItem(T(default.TS), none); + template.SetItem(T(default.TT), none); + template.SetItem(T(default.TP), none); +} + +public final function MutableText ToText() +{ + local MutableText result; + local AssociativeArray template; + if (type == none) return none; + if (service == none) return none; + + template = default.messageTemplate; + ResetTemplate(template); + template.SetItem(T(TT), type); + template.SetItem(T(TS), service); + if (parameters != none) { + template.SetItem(T(TP), parameters); + } + result = _.json.Print(template); + return result; +} + +defaultproperties +{ + TS = 0 + stringConstants(0) = "s" + TT = 1 + stringConstants(1) = "t" + TP = 2 + stringConstants(2) = "p" +} \ No newline at end of file diff --git a/sources/Avarice/AvariceStreamReader.uc b/sources/Avarice/AvariceStreamReader.uc new file mode 100644 index 0000000..5ec474e --- /dev/null +++ b/sources/Avarice/AvariceStreamReader.uc @@ -0,0 +1,149 @@ +/** + * Helper class meant for reading byte stream sent to us by the Avarice + * application. + * Avarice sends us utf8-encoded JSONs one-by-one, prepending each of them + * with 4 bytes (big endian) that encode the length of the following message. + * Copyright 2021 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class AvariceStreamReader extends AcediaObject; + +// Are we currently reading length of the message (`true`) or +// the message itself (`false`)? +var private bool readingLength; +// How many byte we have read so far. +// Resets to zero when we finish reading either length of the message or +// the message itself. +var private int readBytes; +// Expected length of the next message +var private int nextMessageLength; +// Message read so far +var private ByteArrayRef nextMessage; +// All the messages we have fully read, but did not yet return +var private array outputQueue; +// For converting read messages into `MutableText` +var private Utf8Decoder decoder; +// Set to `true` if Avarice input was somehow unacceptable. +// Cannot be recovered from. +var private bool hasFailed; + +// Maximum allowed size of JSON message sent from avarice; +// Anything more than that is treated as a mistake. +// TODO: make this configurable +var private const int MAX_MESSAGE_LENGTH; + +protected function Constructor() +{ + readingLength = true; + nextMessage = ByteArrayRef(_.memory.Allocate(class'ByteArrayRef')); + decoder = Utf8Decoder(_.memory.Allocate(class'Utf8Decoder')); +} + +protected function Finalizer() +{ + _.memory.FreeMany(outputQueue); + _.memory.Free(nextMessage); + _.memory.Free(decoder); + outputQueue.length = 0; + nextMessage = none; + decoder = none; + hasFailed = false; +} + +/** + * Adds next `byte` from the input Avarice stream to the reader. + * + * If input stream signals that message we have to read is too long + * (longer then `MAX_MESSAGE_LENGTH`) - enters a failed state and will + * no longer accept any input. Failed status can be checked with + * `Failed()` method. + * Otherwise cannot fail. + * + * @param nextByte Next byte from the Avarice input stream. + * @return `false` if caller `AvariceStreamReader` is in a failed state + * (including if it entered one after pushing this byte) + * and `true` otherwise. + */ +public final function bool PushByte(byte nextByte) +{ + if (hasFailed) { + return false; + } + if (readingLength) + { + // Make space for the next 8 bits by shifting previously recorded ones + nextMessageLength = nextMessageLength << 8; + nextMessageLength += nextByte; + readBytes += 1; + if (readBytes >= 4) + { + readingLength = false; + readBytes = 0; + } + // Message either too long or so long it overfilled `MaxInt` + if ( nextMessageLength > MAX_MESSAGE_LENGTH + || nextMessageLength < 0) + { + hasFailed = true; + return false; + } + return true; + } + nextMessage.AddItem(nextByte); + readBytes += 1; + if (readBytes >= nextMessageLength) + { + outputQueue[outputQueue.length] = decoder.Decode(nextMessage); + nextMessage.Empty(); + readingLength = true; + readBytes = 0; + nextMessageLength = 0; + } + return true; +} + +/** + * Returns all complete messages read so far. + * + * Even if caller `AvariceStreamReader` entered a failed state - this method + * will return all the messages read before it has failed. + * + * @return aAl complete messages read from Avarice stream so far + */ +public final function array PopMessages() +{ + local array result; + result = outputQueue; + outputQueue.length = 0; + return result; +} + +/** + * Is caller `AvariceStreamReader` in a failed state? + * See `PushByte()` method for details. + * + * @return `true` iff caller `AvariceStreamReader` has failed. + */ +public final function bool Failed() +{ + return hasFailed; +} + +defaultproperties +{ + MAX_MESSAGE_LENGTH = 26214400 // 25 * 1024 * 1024 = 25MB +} \ No newline at end of file diff --git a/sources/Avarice/AvariceTcpStream.uc b/sources/Avarice/AvariceTcpStream.uc new file mode 100644 index 0000000..afc2dc7 Binary files /dev/null and b/sources/Avarice/AvariceTcpStream.uc differ diff --git a/sources/Avarice/Events/Avarice_OnMessage_Signal.uc b/sources/Avarice/Events/Avarice_OnMessage_Signal.uc new file mode 100644 index 0000000..eba6cad --- /dev/null +++ b/sources/Avarice/Events/Avarice_OnMessage_Signal.uc @@ -0,0 +1,38 @@ +/** + * Signal class implementation for `Avarice`'s `OnMessage` signal. + * Copyright 2021 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class Avarice_OnMessage_Signal extends Signal; + +public final function Emit(AvariceLink link, AvariceMessage message) +{ + local Slot nextSlot; + StartIterating(); + nextSlot = GetNextSlot(); + while (nextSlot != none) + { + Avarice_OnMessage_Slot(nextSlot).connect(link, message); + nextSlot = GetNextSlot(); + } + CleanEmptySlots(); +} + +defaultproperties +{ + relatedSlotClass = class'Avarice_OnMessage_Slot' +} \ No newline at end of file diff --git a/sources/Avarice/Events/Avarice_OnMessage_Slot.uc b/sources/Avarice/Events/Avarice_OnMessage_Slot.uc new file mode 100644 index 0000000..32a750f --- /dev/null +++ b/sources/Avarice/Events/Avarice_OnMessage_Slot.uc @@ -0,0 +1,40 @@ +/** + * Slot class implementation for `Avarice`'s `OnMessage` signal. + * Copyright 2021 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class Avarice_OnMessage_Slot extends Slot; + +delegate connect(AvariceLink link, AvariceMessage message) +{ + DummyCall(); +} + +protected function Constructor() +{ + connect = none; +} + +protected function Finalizer() +{ + super.Finalizer(); + connect = none; +} + +defaultproperties +{ +} \ No newline at end of file diff --git a/sources/Avarice/Tests/TEST_AvariceStreamReader.uc b/sources/Avarice/Tests/TEST_AvariceStreamReader.uc new file mode 100644 index 0000000..1adf053 Binary files /dev/null and b/sources/Avarice/Tests/TEST_AvariceStreamReader.uc differ diff --git a/sources/Data/Collections/AssociativeArray.uc b/sources/Data/Collections/AssociativeArray.uc index 7af613f..9dd61b3 100644 --- a/sources/Data/Collections/AssociativeArray.uc +++ b/sources/Data/Collections/AssociativeArray.uc @@ -319,13 +319,23 @@ public final function Entry TakeEntry(AcediaObject key) * Returned value is no longer managed by the `AssociativeArray` (if it was) * and must be deallocated once you do not need it anymore. * - * @param key Key for which to return value. + * @param key Key for which to return value. + * @param freeKey Setting this to `true` will also free the key item was + * stored with. Passed argument `key` will not be deallocated, unless it is + * the exact same object as item's key inside caller collection. * @return Value, stored with given key `key`. If there is no value with * such a key method will return `none`. */ -public final function AcediaObject TakeItem(AcediaObject key) +public final function AcediaObject TakeItem( + AcediaObject key, + optional bool freeKey) { - return TakeEntry(key).value; + local Entry entry; + entry = TakeEntry(key); + if (freeKey) { + _.memory.Free(entry.key); + } + return entry.value; } /** diff --git a/sources/Global.uc b/sources/Global.uc index bf1a4e4..e475a65 100644 --- a/sources/Global.uc +++ b/sources/Global.uc @@ -40,6 +40,7 @@ var public UserAPI users; var public PlayersAPI players; var public JSONAPI json; var public DBAPI db; +var public AvariceAPI avarice; var public KFFrontend kf; @@ -74,6 +75,7 @@ protected function Initialize() players = PlayersAPI(memory.Allocate(class'PlayersAPI')); json = JSONAPI(memory.Allocate(class'JSONAPI')); db = DBAPI(memory.Allocate(class'DBAPI')); + avarice = AvariceAPI(memory.Allocate(class'AvariceAPI')); kf = KFFrontend(memory.Allocate(class'KF1_Frontend')); json.StaticConstructor(); } \ No newline at end of file diff --git a/sources/Manifest.uc b/sources/Manifest.uc index c3a0d96..1ca4c1c 100644 --- a/sources/Manifest.uc +++ b/sources/Manifest.uc @@ -23,6 +23,7 @@ defaultproperties { features(0) = class'Commands_Feature' + features(1) = class'Avarice' commands(0) = class'ACommandHelp' commands(1) = class'ACommandDosh' commands(2) = class'ACommandNick' @@ -56,4 +57,6 @@ defaultproperties testCases(21) = class'TEST_LogMessage' testCases(22) = class'TEST_LocalDatabase' testCases(23) = class'TEST_FeatureConfig' + testCases(24) = class'TEST_UTF8EncoderDecoder' + testCases(25) = class'TEST_AvariceStreamReader' } \ No newline at end of file diff --git a/sources/Text/Codecs/Utf8Decoder.uc b/sources/Text/Codecs/Utf8Decoder.uc new file mode 100644 index 0000000..5f86e8f --- /dev/null +++ b/sources/Text/Codecs/Utf8Decoder.uc @@ -0,0 +1,161 @@ +/** + * Class for decoding UTF8 byte stream into Acedia's `MutableText` value. + * This is a separate object instead of just a method, because it allows + * to make code simpler by storing state variables related to + * the decoding process. + * This implementation should correctly convert any valid UTF8, but it is + * not guaranteed to reject any invalid UTF8. In particular, it accepts + * overlong code point encodings. It does check whether every byte has + * a correct bit prefix and does not attempt to repair input data if it finds + * invalid one. + * See [wiki page](https://en.wikipedia.org/wiki/UTF-8) for details. + * Copyright 2021 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class Utf8Decoder extends AcediaObject; + +// Variables for building a multi-byte code point. +// Stored as a class member variables to avoid copying them between methods. +var private MutableText builtText; +var private int nextCodePoint; +var private int innerBytesLeft; + +// These masks (`maskDropN`) allow to turn into zero first `N` bits in +// the byte with `&` operator. +var private byte maskDrop1, maskDrop2, maskDrop3, maskDrop4, maskDrop5; +// These masks (`maskTakeN`) allow to turn into zero all but first `N` bits +// in the byte with `&` operator. +// `maskTakeN == ~maskDropN`. +var private byte maskTake1, maskTake2, maskTake3, maskTake4, maskTake5; + +/** + * Decodes passed `byte` array (that contains utf8-encoded text) into + * the `MutableText` type. + * + * @param byteStream Byte stream to decode. + * @return `MutableText` that contains `byteStream`'s text data. + * `none` iff either `byteStream == none` or it's contents do not + * correspond to a (valid) utf8-encoded text. + */ +public final function MutableText Decode(ByteArrayRef byteStream) +{ + local int i; + local int length; + local MutableText result; + if (byteStream == none) { + return none; + } + nextCodePoint = 0; + innerBytesLeft = 0; + builtText = _.text.Empty(); + length = byteStream.GetLength(); + for (i = 0; i < length; i += 1) + { + if (!PushByte(byteStream.GetItem(i))) + { + _.memory.Free(builtText); + return none; + } + } + if (innerBytesLeft <= 0) { + result = builtText; + } + else { + _.memory.Free(builtText); + } + builtText = none; + return result; +} + +private final function bool PushByte(byte nextByte) +{ + if (innerBytesLeft > 0) { + return PushInnerByte(nextByte); + } + // Form of 0xxxxxxx means 1 byte per code point + if ((nextByte & maskTake1) == 0) + { + AppendCodePoint(nextByte); + return true; + } + // Form of 110xxxxx means 2 bytes per code point + if ((nextByte & maskTake3) == maskTake2) // maskTake2 == 1 1 0 0 0 0 0 0 + { + nextCodePoint = nextByte & maskDrop3; + innerBytesLeft = 1; + return true; + } + // Form of 1110xxxx means 3 bytes per code point + if ((nextByte & maskTake4) == maskTake3) // maskTake3 == 1 1 1 0 0 0 0 0 + { + nextCodePoint = nextByte & maskDrop4; + innerBytesLeft = 2; + return true; + } + // Form of 11110xxx means 4 bytes per code point + if ((nextByte & maskTake5) == maskTake4) // maskTake4 == 1 1 1 1 0 0 0 0 + { + nextCodePoint = nextByte & maskDrop5; + innerBytesLeft = 3; + return true; + } + // `nextByte` must have has one of the above forms + // (or 10xxxxxx that is handled in `PushInnerByte()`) + return false; +} + +// This method is responsible for pushing "inner" bytes: bytes that come +// after the first one when code point is encoded with multiple bytes. +// All of them are expected to have 10xxxxxx prefix. +// Assumes `innerBytesLeft > 0` to avoid needless checks. +private final function bool PushInnerByte(byte nextByte) +{ + // Fail if `nextByte` does not have an expected form: 10xxxxxx + if ((nextByte & maskTake2) != maskTake1) { + return false; + } + // Since inner bytes have the form of 10xxxxxx, they all carry only 6 bits + // that actually encode code point, so to make space for those bits we must + // shift previously added code points by `6` + nextCodePoint = (nextCodePoint << 6) + (nextByte & maskDrop2); + innerBytesLeft -= 1; + if (innerBytesLeft <= 0) { + AppendCodePoint(nextCodePoint); + } + return true; +} + +private final function AppendCodePoint(int codePoint) +{ + local Text.Character nextCharacter; + nextCharacter.codePoint = codePoint; + builtText.AppendCharacter(nextCharacter); +} + +defaultproperties +{ + maskDrop1 = 127 // 0 1 1 1 1 1 1 1 + maskDrop2 = 63 // 0 0 1 1 1 1 1 1 + maskDrop3 = 31 // 0 0 0 1 1 1 1 1 + maskDrop4 = 15 // 0 0 0 0 1 1 1 1 + maskDrop5 = 7 // 0 0 0 0 0 1 1 1 + maskTake1 = 128 // 1 0 0 0 0 0 0 0 + maskTake2 = 192 // 1 1 0 0 0 0 0 0 + maskTake3 = 224 // 1 1 1 0 0 0 0 0 + maskTake4 = 240 // 1 1 1 1 0 0 0 0 + maskTake5 = 248 // 1 1 1 1 1 0 0 0 +} \ No newline at end of file diff --git a/sources/Text/Codecs/Utf8Encoder.uc b/sources/Text/Codecs/Utf8Encoder.uc new file mode 100644 index 0000000..af0042b --- /dev/null +++ b/sources/Text/Codecs/Utf8Encoder.uc @@ -0,0 +1,122 @@ +/** + * Class for encoding Acedia's `MutableText` value into UTF8 byte + * representation. + * This is a separate object instead of just a method to match design of + * `Utf8Decoder`. + * See [wiki page](https://en.wikipedia.org/wiki/UTF-8) for details. + * Copyright 2021 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class Utf8Encoder extends AcediaObject; + +// Limits on code point values that can be recorded with 1, 2, 3 and 4 bytes +// respectively +var private int utfLimit1, utfLimit2, utfLimit3, utfLimit4; + +// Bit prefixes for UTF8 encoding +var private int utfMask2, utfMask3, utfMask4, utfMaskIn; +// This integer will have only 6 last bits be 1s. +// We need it to zero all but last 6 bits for `int`s (with `&` bit operator). +var private int lastSixBits; + +/** + * Encodes passed `Text` object into UTF8 byte representation. + * + * In case passed `text` is somehow broken and contains invalid Unicode + * code points - this method will return empty array. + * + * @param text `Text` object to encode. + * @return UTF8 representation of passed `text` inside `ByteArrayRef`. + * `none` iff `text == none` or `text` contains invalid Unicode + * code points. + */ +public final function ByteArrayRef Encode(Text text) +{ + local int i, nextCodepoint, textLength; + local ByteArrayRef buffer; + if (__().text.IsEmpty(text)) { + return none; // empty array + } + buffer = ByteArrayRef(_.memory.Allocate(class'ByteArrayRef')); + textLength = text.GetLength(); + for (i = 0; i < textLength; i += 1) + { + nextCodepoint = text.GetCharacter(i).codePoint; + if (nextCodepoint <= utfLimit1) { + buffer.AddItem(nextCodepoint); + } + else if (nextCodepoint <= utfLimit2) + { + // Drop 6 bits that will be recorded inside second byte and + // add 2-byte sequence mask + buffer.AddItem(utfMask2 | (nextCodepoint >> 6)); + // Take only last 6 bits for the second (last) byte + // + add inner-byte sequence mask + buffer.AddItem(utfMaskIn | (nextCodepoint & lastSixBits)); + } + else if (nextCodepoint <= utfLimit3) + { + // Drop 12 bits that will be recorded inside second and third bytes + // and add 3-byte sequence mask + buffer.AddItem(utfMask3 | (nextCodepoint >> 12)); + // Drop 6 bits that will be recorded inside third byte and + // add inner-byte sequence mask + buffer.AddItem(utfMaskIn | ((nextCodepoint >> 6) & lastSixBits)); + // Take only last 6 bits for the third (last) byte + // + add inner-byte sequence mask + buffer.AddItem(utfMaskIn | (nextCodepoint & lastSixBits)); + } + else if (nextCodepoint <= utfLimit4) + { + // Drop 18 bits that will be recorded inside second, third and + // fourth bytes, then add 4-byte sequence mask + buffer.AddItem(utfMask4 | (nextCodepoint >> 18)); + // Drop 12 bits that will be recorded inside third and fourth bytes + // and add inner-byte sequence mask + buffer.AddItem(utfMaskIn | ((nextCodepoint >> 12) & lastSixBits)); + // Drop 6 bits that will be recorded inside fourth byte + // and add inner-byte sequence mask + buffer.AddItem(utfMaskIn | ((nextCodepoint >> 6) & lastSixBits)); + // Take only last 6 bits for the fourth (last) byte + // + add inner-byte sequence mask + buffer.AddItem(utfMaskIn | (nextCodepoint & lastSixBits)); + } + else + { + // Outside of known Unicode range + // Should not be possible, since `Text` is expected to + // contain only correct Unicode + _.memory.Free(buffer); + buffer = none; + break; + } + } + return buffer; +} + +defaultproperties +{ + utfLimit1 = 127 + utfLimit2 = 2047 + utfLimit3 = 65535 + utfLimit4 = 1114111 + utfMask2 = 192 // 1 1 0 0 0 0 0 0 + utfMask3 = 224 // 1 1 1 0 0 0 0 0 + utfMask4 = 240 // 1 1 1 1 0 0 0 0 + utfMaskIn = 128 // 1 0 0 0 0 0 0 0 + lastSixBits = 63 // 0 0 1 1 1 1 1 1 +} \ No newline at end of file diff --git a/sources/Text/Tests/TEST_UTF8EncoderDecoder.uc b/sources/Text/Tests/TEST_UTF8EncoderDecoder.uc new file mode 100644 index 0000000..63daa53 Binary files /dev/null and b/sources/Text/Tests/TEST_UTF8EncoderDecoder.uc differ diff --git a/sources/Unreal/NativeActorRef.uc b/sources/Unreal/NativeActorRef.uc index 65bd227..85fcb2c 100644 --- a/sources/Unreal/NativeActorRef.uc +++ b/sources/Unreal/NativeActorRef.uc @@ -80,13 +80,11 @@ public final function NativeActorRef Set(Actor newValue) public function bool IsEqual(Object other) { - local NativeActorRef otherBox; - local ActorService service; + local NativeActorRef otherBox; otherBox = NativeActorRef(other); - if (otherBox == none) return false; - service = ActorService(class'ActorService'.static.Require()); - if (service == none) return false; - + if (otherBox == none) { + return false; + } return Get() == otherBox.Get(); }