diff --git a/sources/Commands/Commands_Feature.uc b/sources/Commands/Commands_Feature.uc index c65023f..ef8b7ce 100644 --- a/sources/Commands/Commands_Feature.uc +++ b/sources/Commands/Commands_Feature.uc @@ -28,6 +28,10 @@ var private array commandDelimiters; // Keys should be deallocated when their entry is removed. var private AssociativeArray registeredCommands; +// When this flag is set to true, mutate input becomes available +// despite `useMutateInput` flag to allow to unlock server in case of an error +var private bool emergencyEnabledMutate; + // Setting this to `true` enables players to input commands right in the chat // by prepending them with `chatCommandPrefix`. // Default is `true`. @@ -54,6 +58,12 @@ protected function OnEnabled() commandDelimiters[2] = P("["); // Negation of the selector commandDelimiters[3] = P("!"); + // `SwapConfig()` will no longer touch `_.unreal.mutator.OnMutate(self)` + // with `emergencyEnabledMutate` set to `true`, so we need to give + // access here + if (emergencyEnabledMutate) { + _.unreal.mutator.OnMutate(self).connect = HandleMutate; + } } protected function OnDisabled() @@ -96,7 +106,9 @@ protected function SwapConfig(FeatureConfig config) _.chat.OnMessage(self).Disconnect(); } } - if (useMutateInput != newConfig.useMutateInput) + // Do not make any modifications here in case "mutate" was + // emergency-enabled + if (useMutateInput != newConfig.useMutateInput && !emergencyEnabledMutate) { useMutateInput = newConfig.useMutateInput; if (newConfig.useMutateInput) { @@ -108,6 +120,87 @@ protected function SwapConfig(FeatureConfig config) } } +/** + * `Command_Feature` is a critical command to have running on your server and, + * if disabled by accident, there will be no way of starting it again without + * restarting the level or even editing configs. + * + * This method allows to enable it along with "mutate" input in case something + * goes wrong. + */ +public final static function EmergencyEnable() +{ + local Text autoConfig; + local Commands_Feature feature; + if (!IsEnabled()) + { + autoConfig = GetAutoEnabledConfig(); + EnableMe(autoConfig); + __().memory.Free(autoConfig); + } + feature = Commands_Feature(GetInstance()); + if ( !feature.emergencyEnabledMutate + && !feature.IsUsingMutateInput() && !feature.IsUsingChatInput()) + { + default.emergencyEnabledMutate = true; + feature.emergencyEnabledMutate = true; + __().unreal.mutator.OnMutate(feature).connect = HandleMutate; + } +} + +/** + * Checks if `Commands_Feature` currently uses chat as input. + * If `Commands_Feature` is not enabled, then it does not use anything + * as input. + * + * @return `true` if `Commands_Feature` is currently enabled and is using chat + * as input and `false` otherwise. + */ +public final static function bool IsUsingChatInput() +{ + local Commands_Feature instance; + instance = Commands_Feature(GetInstance()); + if (instance != none) { + return instance.useChatInput; + } + return false; +} + +/** + * Checks if `Commands_Feature` currently uses mutate command as input. + * If `Commands_Feature` is not enabled, then it does not use anything + * as input. + * + * @return `true` if `Commands_Feature` is currently enabled and is using + * mutate command as input and `false` otherwise. + */ +public final static function bool IsUsingMutateInput() +{ + local Commands_Feature instance; + instance = Commands_Feature(GetInstance()); + if (instance != none) { + return instance.useMutateInput; + } + return false; +} + +/** + * Returns prefix that will indicate that chat message is intended to be + * a command. By default "!". + * + * @return Prefix that indicates that chat message is intended to be a command. + * If `Commands_Feature` is disabled, always returns `false`. + */ +public final static function Text GetChatPrefix() +{ + local Commands_Feature instance; + instance = Commands_Feature(GetInstance()); + if (instance != none && instance.chatCommandPrefix != none) { + return instance.chatCommandPrefix.Copy(); + } + return none; +} + /** * Registers given command class, making it available for usage. * diff --git a/sources/Console/ConsoleAPI.uc b/sources/Console/ConsoleAPI.uc index 66ed7e0..377d797 100644 --- a/sources/Console/ConsoleAPI.uc +++ b/sources/Console/ConsoleAPI.uc @@ -177,7 +177,7 @@ public final function Color GetDefaultColor(int newMaxTotalLineWidth) } /** - * Sets current global default color for console output., + * Sets current global default color for console output. * * Instances of `ConsoleWriter` are initialized with this value, * but can later change this value independently. @@ -193,7 +193,6 @@ public final function SetDefaultColor(Color newDefaultColor) /** * Returns new `ConsoleWriter` instance that will write into * consoles of all players. - * Should be freed after use. * * @return ConsoleWriter New `ConsoleWriter` instance, configured to * write into consoles of all players. @@ -212,13 +211,12 @@ public final function ConsoleWriter ForAll() /** * Returns new `ConsoleWriter` instance that will write into * console of the given player. - * Should be freed after use. * * @param targetPlayer Player, to whom console we want to write. * If `none` - returned `ConsoleWriter` would be configured to * throw messages away. * @return New `ConsoleWriter` instance, configured to - * write into consoles of all players. + * write into console of `targetPlayer`. * Guaranteed to not be `none`. */ public final function ConsoleWriter For(EPlayer targetPlayer) @@ -231,6 +229,27 @@ public final function ConsoleWriter For(EPlayer targetPlayer) .Initialize(globalSettings).ForPlayer(targetPlayer); } +/** + * Returns new `ConsoleWriter` instance that will write into + * console of the given player. + * + * @param targetPlayer Player, to whom console we want to write. + * If `none` - returned `ConsoleWriter` would be configured to + * throw messages away. + * @return New `ConsoleWriter` instance, configured to + * write into console of `targetPlayer`. + * Guaranteed to not be `none`. + */ +public final function ConsoleWriter ForController(PlayerController targetPlayer) +{ + local EPlayer wrapper; + local ConsoleWriter result; + wrapper = _.players.FromController(targetPlayer); + result = For(wrapper); + _.memory.Free(wrapper); + return result; +} + defaultproperties { defaultColor = (R=255,G=255,B=255,A=255) diff --git a/sources/CoreService.uc b/sources/CoreService.uc index 4e3828e..14a6f46 100644 --- a/sources/CoreService.uc +++ b/sources/CoreService.uc @@ -52,7 +52,7 @@ var private LoggerAPI.Definition errorNoManifest, errorCannotRunTests; // We do not implement `OnShutdown()`, because total Acedia's clean up // is supposed to happen before that event. -protected function OnCreated() +protected function OnLaunch() { BootUp(); default.packagesToLoad.length = 0; @@ -161,6 +161,8 @@ private final function BootUp() if (class'TestingService'.default.runTestsOnStartUp) { RunStartUpTests(); } + class'InfoQueryHandler'.static.StaticConstructor(); + _.unreal.mutator.OnMutate(_self).connect = EnableCommandsFeature; } private final function LoadManifest(class<_manifest> manifestClass) @@ -243,6 +245,15 @@ private final function RunStartUpTests() } } +private final function EnableCommandsFeature( + string command, + PlayerController sendingPlayer) +{ + if (command ~= "acediacommands") { + class'Commands_Feature'.static.EmergencyEnable(); + } +} + /** * Registers class derived from `AcediaObject` for clean up when * Acedia shuts down. diff --git a/sources/Features/Feature.uc b/sources/Features/Feature.uc index 4fdd479..4f7ba2b 100644 --- a/sources/Features/Feature.uc +++ b/sources/Features/Feature.uc @@ -56,6 +56,7 @@ var public const class configClass; // Only a default value is ever used. var protected bool blockSpawning; +// TODO: remove this? // Setting that tells Acedia whether or not to enable this feature // during initialization. // Only it's default value is ever used. @@ -124,9 +125,10 @@ protected function Finalizer() default.currentConfigName = none; currentConfigName = none; currentConfig = none; - default.activeInstance = none; + default.activeInstance = none; } +// TODO: free `newConfigName`? /** * Changes config for the caller `Feature` class. * @@ -142,9 +144,6 @@ private final function ApplyConfig(BaseText newConfigName) { local Text configNameCopy; local FeatureConfig newConfig; - if (newConfigName == none) { - return; - } newConfig = FeatureConfig(configClass.static.GetConfigInstance(newConfigName)); if (newConfig == none) @@ -174,15 +173,6 @@ private final function ApplyConfig(BaseText newConfigName) */ public final static function Feature GetInstance() { - if (default.activeInstance == none) { - return none; - } - if ( default.activeInstance.GetLifeVersion() - != default.activeInstanceLifeVersion) - { - default.activeInstance = none; - return none; - } return default.activeInstance; } @@ -246,14 +236,11 @@ public static final function bool IsEnabled() /** * Enables the feature and returns it's active instance. * - * Does nothing if passed `configName` is `none`. - * * Cannot fail as long as `configName != none`. Any checks on whether it's * appropriate to enable `Feature` must be done separately, before calling * this method. * - * If `Feature` is already enabled - changes its config to `configName` - * (unless it's `none`). + * If `Feature` is already enabled - changes its config to `configName`. * * @param configName Name of the config to use for this `Feature`. * Passing `none` will make caller `Feature` use "default" config. @@ -262,16 +249,18 @@ public static final function bool IsEnabled() public static final function Feature EnableMe(BaseText configName) { local Feature myInstance; - if (configName == none) { - return none; - } myInstance = GetInstance(); if (myInstance != none) { myInstance.ApplyConfig(configName); return myInstance; } - default.currentConfigName = configName.Copy(); + if (configName != none) { + default.currentConfigName = configName.Copy(); + } + else { + default.currentConfigName = none; + } default.blockSpawning = false; myInstance = Feature(__().memory.Allocate(default.class)); default.activeInstance = myInstance; diff --git a/sources/InfoQueryHandler/Events/InfoQueryHandler_OnQuery_Signal.uc b/sources/InfoQueryHandler/Events/InfoQueryHandler_OnQuery_Signal.uc new file mode 100644 index 0000000..1a80cbc --- /dev/null +++ b/sources/InfoQueryHandler/Events/InfoQueryHandler_OnQuery_Signal.uc @@ -0,0 +1,47 @@ +/** + * Signal class implementation for `InfoQueryHandler`'s signals. + * Copyright 2022 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 InfoQueryHandler_OnQuery_Signal extends Signal; + +public final function Emit(ConsoleWriter writer) +{ + local Text nextHeader; + local Slot nextSlot; + local InfoQueryHandler_OnQuery_Slot nextQuerySlot; + StartIterating(); + nextSlot = GetNextSlot(); + while (nextSlot != none) + { + nextQuerySlot = InfoQueryHandler_OnQuery_Slot(nextSlot); + // Output slot info + nextHeader = nextQuerySlot.GetHeader(); + class'InfoQueryHandler'.static.AddHeader(nextHeader); + _.memory.Free(nextHeader); + nextQuerySlot.connect(writer); + class'InfoQueryHandler'.static.AddSeparator(); + // gg go next + nextSlot = GetNextSlot(); + } + CleanEmptySlots(); +} + +defaultproperties +{ + relatedSlotClass = class'InfoQueryHandler_OnQuery_Slot' +} \ No newline at end of file diff --git a/sources/InfoQueryHandler/Events/InfoQueryHandler_OnQuery_Slot.uc b/sources/InfoQueryHandler/Events/InfoQueryHandler_OnQuery_Slot.uc new file mode 100644 index 0000000..9a4cf1f --- /dev/null +++ b/sources/InfoQueryHandler/Events/InfoQueryHandler_OnQuery_Slot.uc @@ -0,0 +1,65 @@ +/** + * Slot class implementation for `InfoQueryHandler`'s signals. + * Copyright 2022 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 InfoQueryHandler_OnQuery_Slot extends Slot; + +var private Text linkedHeader; + +delegate connect(ConsoleWriter writer) +{ + DummyCall(); +} + +protected function Constructor() +{ + connect = none; +} + +protected function Finalizer() +{ + super.Finalizer(); + connect = none; + _.memory.Free(linkedHeader); + linkedHeader = none; +} + +public final function InitializeHeader(BaseText header) +{ + if (linkedHeader != none) { + return; + } + if (header != none) { + linkedHeader = header.Copy(); + } + else { + linkedHeader = P("").Copy(); + } +} + +public final function Text GetHeader() +{ + if (linkedHeader != none) { + return linkedHeader.Copy(); + } + return none; +} + +defaultproperties +{ +} \ No newline at end of file diff --git a/sources/InfoQueryHandler/InfoQueryHandler.uc b/sources/InfoQueryHandler/InfoQueryHandler.uc new file mode 100644 index 0000000..35504e7 --- /dev/null +++ b/sources/InfoQueryHandler/InfoQueryHandler.uc @@ -0,0 +1,312 @@ +/** + * Utility that help AcediaCore and its `Feature`s to add information to + * console queries like "help", "status", etc. in a more unified way. + * In Killing Floor this corresponds to "mutate" command. + * Copyright 2022 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 InfoQueryHandler extends AcediaObject + abstract; + +var private ServiceAnchor anchor; +var private ConsoleWriter currentOutput; +var private InfoQueryHandler_OnQuery_Signal onHelpSignal; +var private InfoQueryHandler_OnQuery_Signal onStatusSignal; +var private InfoQueryHandler_OnQuery_Signal onVersionSignal; +var private InfoQueryHandler_OnQuery_Signal onCreditsSignal; + +var private const int TACEDIA_HEADER, TACEDIA_SUBHEADER, TACEDIA_HELP; +var private const int TACEDIA_HELP_COMMANDS_CHAT, TACEDIA_HELP_COMMANDS_CONSOLE; +var private const int TACEDIA_HELP_COMMANDS_CHAT_AND_CONSOLE; +var private const int TACEDIA_HELP_COMMANDS_NO, TACEDIA_HELP_COMMANDS_USELESS; +var private const int TACEDIA_RUNNING, TACEDIA_VERSION, TACEDIA_CREDITS; +var private const int TACEDIA_ACKNOWLEDGMENT, TPREFIX, TSEPARATOR; + +public static function StaticConstructor() +{ + if (StaticConstructorGuard()) { + return; + } + default.anchor = ServiceAnchor(__().memory.Allocate(class'ServiceAnchor')); + default.onHelpSignal = InfoQueryHandler_OnQuery_Signal( + __().memory.Allocate(class'InfoQueryHandler_OnQuery_Signal')); + default.onStatusSignal = InfoQueryHandler_OnQuery_Signal( + __().memory.Allocate(class'InfoQueryHandler_OnQuery_Signal')); + default.onVersionSignal = InfoQueryHandler_OnQuery_Signal( + __().memory.Allocate(class'InfoQueryHandler_OnQuery_Signal')); + default.onCreditsSignal = InfoQueryHandler_OnQuery_Signal( + __().memory.Allocate(class'InfoQueryHandler_OnQuery_Signal')); + // We cannot make an instance of an abstract `InfoQueryHandler` class, + // use created `ConsoleWriter` to connect + __().unreal.mutator.OnMutate(default.anchor).connect = HandleMutate; +} + +/** + * Called when user uses appropriate tools to request "help" via console query. + * + * [Signature] + * () + */ +/* SIGNAL */ +public final static function InfoQueryHandler_OnQuery_Slot OnHelp( + AcediaObject receiver, + Text header) +{ + local InfoQueryHandler_OnQuery_Slot newSlot; + StaticConstructor(); + newSlot = InfoQueryHandler_OnQuery_Slot( + default.onHelpSignal.NewSlot(receiver)); + newSlot.InitializeHeader(header); + return newSlot; +} + +/** + * Called when user uses appropriate tools to request "status" via console + * query. + * + * [Signature] + * () + */ +/* SIGNAL */ +public final static function InfoQueryHandler_OnQuery_Slot OnStatus( + AcediaObject receiver, + Text header) +{ + local InfoQueryHandler_OnQuery_Slot newSlot; + StaticConstructor(); + newSlot = InfoQueryHandler_OnQuery_Slot( + default.onStatusSignal.NewSlot(receiver)); + newSlot.InitializeHeader(header); + return newSlot; +} + + +/** + * Called when user uses appropriate tools to request "version" via console + * query. + * + * [Signature] + * () + */ +/* SIGNAL */ +public final static function InfoQueryHandler_OnQuery_Slot OnVersion( + AcediaObject receiver, + Text header) +{ + local InfoQueryHandler_OnQuery_Slot newSlot; + StaticConstructor(); + newSlot = InfoQueryHandler_OnQuery_Slot( + default.onVersionSignal.NewSlot(receiver)); + newSlot.InitializeHeader(header); + return newSlot; +} + + +/** + * Called when user uses appropriate tools to request "credits" via console + * query. + * + * [Signature] + * () + */ +/* SIGNAL */ +public final static function InfoQueryHandler_OnQuery_Slot OnCredits( + AcediaObject receiver, + Text header) +{ + local InfoQueryHandler_OnQuery_Slot newSlot; + StaticConstructor(); + newSlot = InfoQueryHandler_OnQuery_Slot( + default.onCreditsSignal.NewSlot(receiver)); + newSlot.InitializeHeader(header); + return newSlot; +} + +/** + * Adds header for a component of Acedia named `headerText` to the current + * output. Implemented to only work during `InfoQueryHandler`'s signals' + * propagation. + * + * @param headerText Name of the Acedia's component to print header for. + */ +public final static function AddHeader(Text headerText) +{ + if (default.currentOutput == none) { + return; + } + AddSeparator(); + default.currentOutput + .Write(T(default.TACEDIA_SUBHEADER)) + .UseColorOnce(__().color.yellow) + .WriteLine(headerText); + AddSeparator(); +} + + +/** + * Adds standard line separator to the current output. Implemented to only work + * during `InfoQueryHandler`'s signals' propagation. + */ +public final static function AddSeparator() +{ + if (default.currentOutput == none) { + return; + } + default.currentOutput + .Flush() + .UseColorOnce(__().color.white) + .WriteLine(T(default.TSEPARATOR)); +} + +private final static function HandleMutate( + string command, + PlayerController sendingPlayer) +{ + if (!( command ~= "help" + || command ~= "status" + || command ~= "version" + || command ~= "credits")) + { + return; + } + StartOutput(sendingPlayer); + AddSeparator(); + default.currentOutput.WriteLine(T(default.TACEDIA_HEADER)); + AddSeparator(); + if (command ~= "help") + { + OutAcediaHelp(); + default.onHelpSignal.Emit(default.currentOutput); + } + else if (command ~= "status") + { + OutAcediaStatus(); + default.onStatusSignal.Emit(default.currentOutput); + } + else if (command ~= "version") + { + OutAcediaVersion(); + default.onVersionSignal.Emit(default.currentOutput); + } + else if (command ~= "credits") + { + OutAcediaCredits(); + default.onCreditsSignal.Emit(default.currentOutput); + } + AddSeparator(); + StopOutput(); +} + +private final static function StartOutput(PlayerController targetPlayer) +{ + default.currentOutput = __().console.ForController(targetPlayer); +} + +private final static function StopOutput() +{ + __().memory.Free(default.currentOutput); + default.currentOutput = none; +} + +private final static function OutAcediaHelp() +{ + local Text prefix; + local MutableText builder; + default.currentOutput + .Flush() + .WriteLine(T(default.TACEDIA_HELP)); + prefix = class'Commands_Feature'.static.GetChatPrefix(); + if (!class'Commands_Feature'.static.IsEnabled()) { + default.currentOutput.WriteLine(T(default.TACEDIA_HELP_COMMANDS_NO)); + } + else if ( class'Commands_Feature'.static.IsUsingChatInput() + && class'Commands_Feature'.static.IsUsingMutateInput()) + { + builder = + T(default.TACEDIA_HELP_COMMANDS_CHAT_AND_CONSOLE).MutableCopy(); + builder.Replace(T(default.TPREFIX), prefix); + default.currentOutput.WriteLine(builder); + __().memory.Free(builder); + } + else if (class'Commands_Feature'.static.IsUsingChatInput()) + { + builder = + T(default.TACEDIA_HELP_COMMANDS_CHAT).MutableCopy(); + builder.Replace(T(default.TPREFIX), prefix); + default.currentOutput.WriteLine(builder); + __().memory.Free(builder); + } + else if (class'Commands_Feature'.static.IsUsingMutateInput()) + { + default.currentOutput + .WriteLine(T(default.TACEDIA_HELP_COMMANDS_CONSOLE)); + } + else + { + default.currentOutput + .WriteLine(T(default.TACEDIA_HELP_COMMANDS_USELESS)); + } + __().memory.Free(prefix); +} + +private final static function OutAcediaStatus() +{ + default.currentOutput.WriteLine(T(default.TACEDIA_RUNNING)); +} + +private final static function OutAcediaVersion() +{ + default.currentOutput.WriteLine(T(default.TACEDIA_VERSION)); +} + +private final static function OutAcediaCredits() +{ + default.currentOutput.WriteLine(T(default.TACEDIA_CREDITS)); + default.currentOutput.WriteLine(T(default.TACEDIA_ACKNOWLEDGMENT)); +} + +defaultproperties +{ + TACEDIA_HEADER = 0 + stringConstants(0) = "{$red Acedia Framework}" + TACEDIA_SUBHEADER = 1 + stringConstants(1) = "{$red Acedia Framework}{$white / }" + TACEDIA_HELP = 2 + stringConstants(2) = "Acedia always supports four commands: {$TextEmphasis help}, {$TextEmphasis status}, {$TextEmphasis version} and {$TextEmphasis credits}" + TACEDIA_HELP_COMMANDS_CHAT = 3 + stringConstants(3) = "To get detailed information about available to you commands, please type {$TextEmphasis %PREFIX%help -l} in chat" + TACEDIA_HELP_COMMANDS_CONSOLE = 4 + stringConstants(4) = "To get detailed information about available to you commands, please type {$TextEmphasis mutate help -l} in console" + TACEDIA_HELP_COMMANDS_CHAT_AND_CONSOLE = 5 + stringConstants(5) = "To get detailed information about available to you commands, please type {$TextEmphasis %PREFIX%help -l} in chat or {$TextEmphasis mutate help -l} in console" + TACEDIA_HELP_COMMANDS_NO = 6 + stringConstants(6) = "Unfortunately other commands aren't available right now. To enable them please type {$TextEmphasis mutate acediacommands} in console if you have enough rights to reenable them." + TACEDIA_HELP_COMMANDS_USELESS = 7 + stringConstants(7) = "Unfortunately every known way to access other command is disabled on this server. To enable them please type {$TextEmphasis mutate acediacommands} in console if you have enough rights to reenable them." + TACEDIA_RUNNING = 8 + stringConstants(8) = "AcediaCore is running" + TACEDIA_VERSION = 9 + stringConstants(9) = "AcediaCore version 0.1.dev6 - this is a development version, bugs and issues are expected" + TACEDIA_CREDITS = 10 + stringConstants(10) = "AcediaCore was developed by dkanus, 2019 - 2022" + TACEDIA_ACKNOWLEDGMENT = 11 + stringConstants(11) = "Special thanks for NikC- and Chaos for suggestions, testing and discussion" + TPREFIX = 12 + stringConstants(12) = "{$TextEmphasis %PREFIX%}" + TSEPARATOR = 13 + stringConstants(13) = "=============================" +} \ No newline at end of file