diff --git a/sources/BaseAPI/API/Commands/BuiltInCommands/ACommandSideEffects.uc b/sources/BaseAPI/API/Commands/BuiltInCommands/ACommandSideEffects.uc
new file mode 100644
index 0000000..e28845f
--- /dev/null
+++ b/sources/BaseAPI/API/Commands/BuiltInCommands/ACommandSideEffects.uc
@@ -0,0 +1,193 @@
+/**
+ * Author: dkanus
+ * Home repo: https://www.insultplayers.ru/git/AcediaFramework/AcediaCore
+ * License: GPL
+ * Copyright 2023 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 ACommandSideEffects extends Command;
+
+// Maps `UserID` to `ArrayList` with side effects listed for that player last time
+var private HashTable displayedLists;
+
+protected function Constructor() {
+ super.Constructor();
+ displayedLists = _.collections.EmptyHashTable();
+}
+
+protected function Finalizer() {
+ super.Finalizer();
+ _.memory.Free(displayedLists);
+ displayedLists = none;
+}
+
+protected function BuildData(CommandDataBuilder builder) {
+ builder.Name(P("sideeffects"));
+ builder.Group(P("core"));
+ builder.Summary(P("Displays information about current side effects."));
+ builder.Describe(P("This command allows to display current side effects, optionally filtering"
+ @ "them by specified package names."));
+ builder.OptionalParams();
+ builder.ParamTextList(P("package_names"));
+
+ builder.SubCommand(P("show"));
+ builder.Describe(P("This sub-command is only usable after side effects have been shown"
+ @ "at least once. It takes an index from the last displayed list and displays a verbose"
+ @ "information about it."));
+ builder.ParamInteger(P("side_effect_number"));
+
+ builder.Option(P("verbose"));
+ builder.Describe(P("Display verbose information about each side effect."));
+}
+
+protected function Executed(CallData arguments, EPlayer instigator) {
+ local UserID playerID;
+ local array relevantSideEffects;
+ local ArrayList packagesList, storedSideEffectsList;
+
+ playerID = instigator.GetUserID();
+ if (arguments.subCommandName.IsEmpty()) {
+ relevantSideEffects = _.sideEffects.GetAll();
+ packagesList = arguments.parameters.GetArrayList(P("package_names"));
+ FilterSideEffects(/*out*/ relevantSideEffects, packagesList);
+ _.memory.Free(packagesList);
+ DisplaySideEffects(relevantSideEffects, arguments.options.HasKey(P("verbose")));
+ // Store new side effect list
+ storedSideEffectsList = _.collections.NewArrayList(relevantSideEffects);
+ displayedLists.SetItem(playerID, storedSideEffectsList);
+ _.memory.FreeMany(relevantSideEffects);
+ _.memory.Free(storedSideEffectsList);
+ } else {
+ ShowInfoFor(playerID, arguments.parameters.GetInt(P("side_effect_number")));
+ }
+ _.memory.Free(playerID);
+}
+
+private function FilterSideEffects(out array sideEffects, ArrayList allowedPackages) {
+ local int i, j;
+ local int packagesLength;
+ local bool matchedPackage;
+ local Text nextSideEffectPackage, nextAllowedPackage;
+
+ if (allowedPackages == none) return;
+ if (allowedPackages.GetLength() <= 0) return;
+
+ packagesLength = allowedPackages.GetLength();
+ while (i < sideEffects.length) {
+ nextSideEffectPackage = sideEffects[i].GetPackage();
+ matchedPackage = false;
+ for (j = 0; j < packagesLength; j += 1) {
+ nextAllowedPackage = allowedPackages.GetText(j);
+ if (nextAllowedPackage.Compare(nextSideEffectPackage, SCASE_INSENSITIVE)) {
+ matchedPackage = true;
+ _.memory.Free(nextAllowedPackage);
+ break;
+ }
+ _.memory.Free(nextAllowedPackage);
+ }
+ if (!matchedPackage) {
+ sideEffects.Remove(i, 1);
+ } else {
+ i += 1;
+ }
+ _.memory.Free(nextSideEffectPackage);
+ }
+}
+
+private function DisplaySideEffects(array toDisplay, bool verbose) {
+ local int i;
+ local MutableText nextPrefix;
+
+ if (toDisplay.length <= 0) {
+ callerConsole.Write(F("List of side effects is {$TextNeutral empty}."));
+ }
+ for (i = 0; i < toDisplay.length; i += 1) {
+ nextPrefix = _.text.FromIntM(i + 1);
+ nextPrefix.Append(P("."));
+ DisplaySideEffect(toDisplay[i], nextPrefix, verbose);
+ _.memory.Free(nextPrefix);
+ }
+}
+
+private function DisplaySideEffect(SideEffect toDisplay, BaseText prefix, bool verbose) {
+ local Text effectName, effectDescription, effectPackage, effectSource, effectStatus;
+
+ if (toDisplay == none) {
+ return;
+ }
+ if (prefix != none) {
+ callerConsole.Write(prefix);
+ callerConsole.Write(P(" "));
+ }
+ effectName = toDisplay.GetName();
+ effectPackage = toDisplay.GetPackage();
+ effectSource = toDisplay.GetSource();
+ effectStatus = toDisplay.GetStatus();
+ callerConsole.UseColor(_.color.TextEmphasis);
+ callerConsole.Write(P("["));
+ callerConsole.Write(effectPackage);
+ callerConsole.Write(P(" \\ "));
+ callerConsole.Write(effectSource);
+ callerConsole.Write(P("] "));
+ callerConsole.ResetColor();
+ callerConsole.Write(effectName);
+ callerConsole.Write(P(" {"));
+ callerConsole.Write(effectStatus);
+ callerConsole.WriteLine(P("}"));
+ if (verbose) {
+ effectDescription = toDisplay.GetDescription();
+ callerConsole.WriteBlock(effectDescription);
+ }
+ _.memory.Free5(effectName, effectDescription, effectPackage, effectSource, effectStatus);
+}
+
+private function ShowInfoFor(UserID playerID, int sideEffectIndex) {
+ local SideEffect toDisplay;
+ local ArrayList sideEffectList;
+
+ if (playerID == none) {
+ return;
+ }
+ if (sideEffectIndex <= 0) {
+ callerConsole.WriteLine(F("Specified side effect index {$TextNegative isn't positive}!"));
+ return;
+ }
+ sideEffectList = displayedLists.GetArrayList(playerID);
+ if (sideEffectList == none) {
+ callerConsole.WriteLine(F("{$TextNegative Cannot display} side effect by index without"
+ @ "first listing them. Call {$TextEmphasis sideeffects} command without"
+ @ "{$TextEmphasis show} subcommand first."));
+ return;
+ }
+ if (sideEffectIndex > sideEffectList.GetLength()) {
+ callerConsole.WriteLine(F("Specified side effect index is {$TextNegative out of bounds}."));
+ _.memory.Free(sideEffectList);
+ return;
+ }
+ // Above we checked that `sideEffectIndex` lies within `[0; sideEffectList.GetLength()]` segment
+ // This means that `sideEffectIndex - 1` points at non-`none` value
+ toDisplay = SideEffect(sideEffectList.GetItem(sideEffectIndex - 1));
+ if (!_.sideEffects.IsRegistered(toDisplay)) {
+ callerConsole.UseColorOnce(_.color.TextWarning);
+ callerConsole.WriteLine(P("Selected side effect is no longer active!"));
+ }
+ DisplaySideEffect(toDisplay, none, true);
+ _.memory.Free2(toDisplay, sideEffectList);
+}
+
+defaultproperties {
+}
\ No newline at end of file
diff --git a/sources/BaseAPI/API/Commands/Commands_Feature.uc b/sources/BaseAPI/API/Commands/Commands_Feature.uc
index 7480bcd..df540a4 100644
--- a/sources/BaseAPI/API/Commands/Commands_Feature.uc
+++ b/sources/BaseAPI/API/Commands/Commands_Feature.uc
@@ -110,6 +110,7 @@ protected function OnEnabled() {
RegisterCommand(class'ACommandHelp');
RegisterCommand(class'ACommandNotify');
RegisterCommand(class'ACommandVote');
+ RegisterCommand(class'ACommandSideEffects');
if (_.environment.IsDebugging()) {
RegisterCommand(class'ACommandFakers');
}