MCM Advanced Features - schlangster/skyui GitHub Wiki

This guide covers several topics that were not yet covered in the Quickstart guide.
For details on the referenced functions, see the API Reference.

Script Initialization

OnConfigInit

Normally, when working with arrays or other variables that cannot be initialized immediately at the point of declaration, you have to put initialization code in OnInit.

For your config menu, however, you should rather use the OnConfigInit event we provide and avoid OnInit. If that's not possible and you have to use OnInit for some reason, make sure to call parent.OnInit() the original event handler of SKI_ConfigBase is not skipped.

string[] myArray

; WRONG
event OnInit()
	myArray= new myArray[5]
	; ...
endEvent

; Meh... avoid using OnInit()
event OnInit()
	parent.OnInit()
	myArray= new myArray[5]
	; ...
endEvent

; OK
event OnConfigInit()
	myArray= new myArray[5]
	; ...
endEvent

The reason you should avoid OnInit() is that config menus should be designed so that MCM (which is part of SkyUI) can be removed at any time and added again later. If that happens, the variables of your script will be reset, BUT OnInit() won't be called again. This may leave some of variables uninitialized. OnConfigInit() doesn't have this problem.

OnGameReload

There are certain settings you might want to apply after each game reload, for example scripted INI changes or other modifications that persist in memory.

To do this, extend OnGameReload of SKI_ConfigBase. Be aware that this is not part of the base API, so you'll be responsible for calling parent.OnGameReload(). Not doing that will break the menu.

event OnGameReload()
	parent.OnGameReload() ; Don't forget to call the parent!
	
	Utility.SetINIFloat("someSetting", someValue)
endEvent

Pages

If your config menu contains a lot of options, you might want to consider diving them into several categories. Or, even if all options would fit into a single page, it might still be a good idea to separate general from advanced settings.

For this purpose config menus support multiple pages. Setting them up is straightforward.

Page names

Define your page names by setting the Pages property of your config menu. You can either do that in the Creation Kit property editor, just like you set ModName, or you can do it in the OnConfigInit event of your script. An example for the scripted variant:

event OnConfigInit()
	Pages = new string[2]
	Pages[0] = "Page 1"
	Pages[1] = "Page 2"
endEvent

Now, whenever your config menu is active, the list of pages will be shown below the mod name.

Page content

In general, setting up options for multiple pages works exactly the same as using a single page; you add them in OnPageReset. The only difference is that you check the page parameter when deciding which options to add:

event OnPageReset(string page)
	if (page == "Page 1")
		; Add page 1 options
	elseIf (page == "Page 2")
		; Add page 2 options
	endIf
endEvent

By default, after selecting your mod from the main list, no page is active and the page parameter is is "". Since we didn't handle this case in our example, the option list will be blank until the user has chosen a page. On way to fill that void is adding a custom logo of your mod . For further details on this topic, see Loading custom content.

Pages and other events

Since each option ID is unique, for all the other events it won't matter which option a page is on. However, if you have a lot of options, you may want to spread your code over several functions.

In this scenario, it's helpful being able to check the current page even outside of OnPageReset; you do that by accessing the CurrentPage property:

event OnOptionSelect(int option)
	if (CurrentPage == "Gameplay")
		HandleGameplayOptionSelect(option)
	elseIf (CurrentPage == "Immersion")
		HandleImmersionOptionSelect(option)
	endIf
endIf

Be aware that this makes moving around options between several pages, or renaming pages, slightly more tedious, since you have to change more than just OnPageReset when doing so.

Options

While the basics about options have already been covered, there are two more topics that have been left out so far: Option flags and grouped updating.

Option flags

All Add*Option functions have an optional flags parameter. It can be used to enable certain behavior for the option. Accepted flags are

  • OPTION_FLAG_NONE, to clear the flags;
  • OPTION_FLAG_DISABLED, to grey out and disable the option.
int flags
if (isMyOptionDisabled)
	flags = OPTION_FLAG_DISABLED
else
	flags = OPTION_FLAG_NONE
endIf
AddToggleOption("Toggle this", myToggleValue, flags)

To change the flags later in combination with Set*OptionValue functions, use SetOptionFlags(optionID, flags).

Grouped updating

If you use the Set*OptionValue functions to change several options at once, Papyrus may slightly delay function execution at any point in the script. This results in asynchronous updating of the option display.

To prevent this, Set*OptionValue and SetOptionFlags support an optional noUpdate parameter. Example:

SetTextOptionValue(oid1, "Value1", true) ; Don't redraw the list yet
SetTextOptionValue(oid2, "Value2", true) ; ...
SetTextOptionValue(oid3, "Value3", true) ; ...
SetTextOptionValue(oid4, "Value4")       ; Refresh now

Custom Content

It's possible to load content from an external file into the option list area. The original option list will be hidden when that happens.

Supported source file formats are SWF for Flash movies and DDS for images. PNG and some other image formats may work as well, but they will most likely be imported in bad quality.

Here's a practical example used in SkyUI to display the animated logo:

event OnPageReset(string a_page)

	; Load custom .swf for animated logo that's displayed when no page is selected yet.
	if (a_page == "")
		LoadCustomContent("skyui/skyui_splash.swf")
		return
	else
		UnloadCustomContent()
	endIf

	; ... rest of OnPageReset

The path of the loaded file is relative to Data/Interface/.

Note that you have to call UnloadCustomContent() manually, to remove the custom content and show the original option list again. This isn't done automatically so custom content can stay active across several pages.

LoadCustomContent supports two optional parameters for X and Y position offset. (0,0) is the top left corner of the option list area. The dimensions of this area are 770x446, the horizontal center point is at (376,223), which is not exactly at half of the width because the right side holds the scroll bar. To calculate the offsets for a 256x256 image, have a look at this example:

X offset = 376 - (imageWidth / 2) = 376 - 128 = 258
Y offset = 223 - (imageHeight / 2) = 223 - 128 = 95

Localization

To support multiple languages in your config menu, you can utilize the UI localization capabilities provided by SKSE. This section will explain how this works exactly.

The game itself stores the translated strings in Data/Interface/Translate_LANGUAGE.txt, where LANGUAGE is replaced by the current game language, i.e. Translate_ENGLISH.txt. This file contains lines of key/value pairs for the translated strings, separated by a tab stop. Each key has to start with the $ sign. Example:

...
$Back	Back
$Backstabs	Backstabs
$Barters	Barters
...

Whenever the contents of a textfield in UI match a key in this file, it's replaced with the translated value. Even without SKSE, you could add your own translations by modifying the original translates file. The problem is that this would lead to conflicts if multiple mods want to extend this file.

That's why SKSE adds support to load translations from additional files. For each active mod in the load order, Data/Interface/Translations/modname_LANGUAGE.txt is checked for translations, where modname is replaced by the name of the mod data file (i.e. SkyUI_ENGLISH.txt, if the data file is SkyUI.esp). The translation format is the same as described above.

Important: The character encoding of the translation files must be UTF16 LE (aka UCS-2 LE) with BOM. Use a text editor like Sublime or Notepad++ to save with this encoding.

Be aware, however, that these translations only work, if the textfield contents match the translation key exactly. Given

$Hello	Hello

"$Hello" results in "Hello", but "$Hello World" stays "$Hello World".

Loading translation files from inside BSA file is possible. Languages the game supports are CZECH, ENGLISH, FRENCH, GERMAN, ITALIAN, POLISH, RUSSIAN, SPANISH and JAPANESE. Only the file that matches your current language is loaded; there is no fallback to ENGLISH as default.

For simple words or short sentences, you should follow the convention of naming the key the same as the translated string:

$Are you sure?	Are you sure?

For longer strings, rather pick a different key for practical reasons. You should always add a prefix that is unique to your mod in this case, to avoid name collisions with other mods:

$MYPREFIX_QUESTION1	Are you sure?\nAre you ABSOLUTELY sure you want to continue??????

Example

If your mod is named MyMod.esp, localize your page names by adding

$General	General
$Advanced	Advanced
$Help	Help

to Data/Interface/Translations/MyMod_ENGLISH.txt,

$General	Allgemein
$Advanced	Fortgeschritten
$Help	Hilfe

to Data/Interface/Translations/MyMod_GERMAN.txt etc.

Then, in the config menu, name your pages accordingly:

Pages[0] = "$General"
Pages[1] = "$Advanced"
Pages[2] = "$Help"

Key Conflict Management

When using the KeyMap option type to map buttons to custom controls, conflicts may arise because these buttons are already in use. OnOptionKeyMapChange reports these conflicts, so you can react accordingly. The conflictControl string parameter contains the name of the conflicting control, or "" of there was no conflict. conflictName is the name of the mod that owns the control, or "" if it's a part of the regular game.

Reacting to conflicts

In some cases, for example when defining a control that is only used in a certain context, you may choose to ignore any conflicts.

For regular game-play controls, it should be a good idea to display a confirmation dialog.

event OnOptionKeyMapChange(int option, int keyCode, string conflictControl, string conflictName)
	if (option == myKeymapOID)
		bool continue = true
		if (conflictControl != "")
			string msg
			if (conflictName != "")
				msg = "This key is already mapped to:\n\"" + conflictControl + "\"\n(" + conflictName + ")\n\nAre you sure you want to continue?"
			else
				msg = "This key is already mapped to:\n\"" + conflictControl + "\"\n\nAre you sure you want to continue?"
			endIf

			continue = ShowMessage(msg, true, "$Yes", "$No")
		endIf

		if (continue)
			myKey = keyCode
			SetKeymapOptionValue(_option, keyCode)
		endIf
	endIf
endEvent

Reporting conflicts

The mechanism for conflict detection described just now relies on mods reporting their used keys via GetCustomControl. Otherwise, only conflicts with standard controls can be detected.

Implementing GetCustomControl is easy, just check the passed keyCode against all keys you are using and - if matched - return a descriptive name of the control it's assigned to. Otherwise, return "". Example:

string function GetCustomControl(int keyCode)
	if (keyCode == myKey)
		return "Turn 180 degrees"
	else
		return ""
	endIf
endFunction

Script Versioning

As soon as you released the first version of a config menu, you'll have to account for people upgrading to a newer version from an older save. To understand why it can be a problem, have a look at [this article](http://www.creationkit.com/Save_File_Notes_(Papyrus)).

There are several ways you could handle this:

  • Force users of your mod to make a 'clean save' after each new version.
  • Delete the old config menu quest and create a new one.
  • Incrementally upgrade your existing config menu quest at run-time.

Clean saves

The 'clean save' method is tempting, because it doesn't require any action from you as the mod author. But it comes with several major drawbacks:

  • If users don't follow instructions (it happens), you'll end up with undefined behavior. Usually, this means that things break.
  • You'll lose any progress associated with your mod.
  • There have been reported issues where 'clean save' actually results in 'broken save'. Officially, removing mods at runtime is not supported, so there may be all kinds of issues.

One scenario where 'clean saves' may still be used is for alpha/beta testing for a smaller group of users who are supposed to know what they're doing, though even there it's advised to instruct them to keep a save around that is actually clean (i.e. has never been used with your mod before).

Config menu quest replacement

The second method would be deleting your old config menu quest and creating a new one. This is recommended if you're doing major changes to the script and an incremental update would not be feasible. The downside is that all previous settings are reset to their default values.

Incremental upgrading

Based on [the article](http://www.creationkit.com/Save_File_Notes_(Papyrus)) that was linked earlier in this section, when making changes to a script that has already been deployed in a release, we can define a few rules:

  • It's save to change functions and event handlers.
  • Don't remove or rename variables or properties; only add new things.
  • Once you referenced a custom type (a custom script), removing this type will break your script, even if you removed all references to the former.

If there are fundamental problems with these rules when you want to update the script, consider using the previous approach of replacing the old quest by a new one.

More things to consider:

  • If you change default values of variables that have already been initialized, they'll keep their old value. You can't, for example, add new pages by just changing the original property value.
  • OnConfigInit (or OnInit) will not be executed again.

To work around these issues, we already provide an infrastructure for script versioning as part of the ConfigMenu API. First implement GetVersion() to return the current revision of your script. Don't use a variable to hold the version but return a literal number. The default implementation returns 1:

; SCRIPT VERSION
int function GetVersion()
	return 1 ; Default version
endFunction

Each script keeps track of an internal version and detects when it's different from the return value of GetVersion(). If that's the case, the OnVersionUpdate event is triggered. The following example will illustrate things further.

Example

Version 1 is just a regular script. GetVersion() and OnVersionUpdate can be omitted and left at their default value.

; INITIALIZATION
event OnConfigInit()
	Pages = new string[2]
	Pages[0] = "Page 1"
	Pages[1] = "Page 2"
endEvent

Version 2 now adds two more pages.

; SCRIPT VERSION
int function GetVersion()
	return 2
endFunction

; INITIALIZATION
event OnConfigInit()
	Pages = new string[2]
	Pages[0] = "Page 1"
	Pages[1] = "Page 2"
endEvent

event OnVersionUpdate(int a_version)
	; a_version is the new version, CurrentVersion is the old version
	if (a_version >= 2 && CurrentVersion < 2)
		Debug.Trace(self + ": Updating script to version 2")
		Pages = new string[4]
		Pages[0] = "Page 1"
		Pages[1] = "Page 2"
		Pages[2] = "Page 3"
		Pages[3] = "Page 4"
	endIf
endEvent

If this script is run on a save for the first time, it'll execute OnConfigInit, then instantly OnVersionUpdate to version 2. If it's run on a save that already used the first version, only OnVersionUpdate will be executed.

Version 3 adds a couple of variables that have to be initialized.

; SCRIPT VERSION
int function GetVersion()
	return 3
endFunction

; PRIVATE VARIABLES
; -- Version 3 --
int      myVar = 0
string[] myArray

; INITIALIZATION
event OnConfigInit()
	Pages = new string[2]
	Pages[0] = "Page 1"
	Pages[1] = "Page 2"
endEvent

event OnVersionUpdate(int a_version)
	; a_version is the new version, CurrentVersion is the old version
	if (a_version >= 2 && CurrentVersion < 2)
		Debug.Trace(self + ": Updating script to version 2")
		Pages = new string[4]
		Pages[0] = "Page 1"
		Pages[1] = "Page 2"
		Pages[2] = "Page 3"
		Pages[3] = "Page 4"
	endIf

	; a_version is the new version, CurrentVersion is the old version
	if (a_version >= 3 && CurrentVersion < 3)
		Debug.Trace(self + ": Updating script to version 3")
		myVar = Utility.RandomInt(0,100)
		myArray = new string[128]
		; ...
	endIf
endEvent

An alternative version 3 that just runs OnConfigInit again when updating:

; SCRIPT VERSION
int function GetVersion()
	return 3
endFunction

; PRIVATE VARIABLES
; -- Version 3 --
int      myVar = 0
string[] myArray

; INITIALIZATION
event OnConfigInit()
	Pages = new string[4]
	Pages[0] = "Page 1"
	Pages[1] = "Page 2"
	Pages[2] = "Page 3"
	Pages[3] = "Page 4"

	myVar = Utility.RandomInt(0,100)
	myArray = new string[128]
endEvent

event OnVersionUpdate(int a_version)
	if (a_version > 1)
		Debug.Trace(self + ": Updating script to version " + a_version)
		OnConfigInit()
	endIf
endEvent

Note that this is just one way of handling updates. You can always use own preferred method.