Skip to content

Instantly share code, notes, and snippets.

@TheSoundDefense
Last active Jul 22, 2022
Embed
What would you like to do?
Tutorial - how to create a LiveSplit component

Creating a LiveSplit component

Since LiveSplit's official documentation is a work in progress, and I couldn't find another guide for this anywhere else, I decided I would write my own. This will hopefully act as a good reference for getting started and making simple components, de-mystifying the process.

Things you will learn:

  • How to set up your Visual Studio project
  • How to set up the code structure of your component
  • How to create a simple InfoTextComponent (which displays a label on the left, and some text on the right)

Things you will not learn:

  • How to code
  • How to code in C#
  • How to use GitHub

You are going to need some basic programming knowledge in order to make a LiveSplit component. If you are not familiar with functions and classes at the very least, you will probably find it difficult to follow along. If you've written in another object-oriented language before, C# is not radically different. You can use your preferred search engine to look up syntax, types, basic API functions and more.

This guide will follow along as I create the Reset Chance component, which displays the chances of resetting the run during the current split, based on historical data.

Setting up Visual Studio

The first thing you need to do is obtain the source code for LiveSplit, as our component will be using parts of that code, and we can't compile without it. The source code can be found on the LiveSplit GitHub page. You can clone it or download it as a ZIP.

The IDE we'll be using is Visual Studio (not Visual Studio Code). You can download it here if you don't have it. You'll want to grab the Community edition. As of the time this guide was written, Visual Studio 2019 was the newest version.

Once you have Visual Studio installed, navigate to the LiveSplit.sln file, open it in Visual Studio, and build it (Build -> Build Solution). I don't know if this is necessary, but it solved a build issue for me down the line, so you might as well.

Now we're going to create a new project. The project template you want is "Class Library (.NET Framework)".

Create a new project

On the next panel, we're going to name our project LiveSplit.ResetChance, to match with convention. Choose to create a new solution, and place the solution and project in the same directory. Most importantly, use .NET Framework 4.6.1 as your framework. This is important, as we have to build our component with the same framework that LiveSplit itself uses.

Configure your new project

Now that our new project has been started, we need to add parts of the LiveSplit source code. In the Solution Explorer, you need to right-click your solution and add two existing projects from the LiveSplit source:

  • LiveSplit\LiveSplit.Core\LiveSplit.Core.csproj
  • LiveSplit\UpdateManager\UpdateManager.csproj

Add an existing project

Lastly, we need to add references to this code into our project. Under the LiveSplit.ResetChance solution in the Solution Explorer, right-click on References and choose Add Reference.... In the Reference Manager window, under the Assemblies tab, add System.Drawing and System.Windows.Forms. Then click on Projects and add both LiveSplit.Core and UpdateManager.

Add reference Add LiveSplit references

Now we're ready to start coding!

Setting up your code

There are three critical classes that we need in our component:

  • ResetChanceComponent - This is the main controller for our component, which contains the logic needed to correctly display what we want it to display.
  • ResetChanceFactory - This is a simple factory file that contains critical information about our new component. It also tells LiveSplit which function to call in order to create our component.
  • ResetChanceSettings - This class controls the Settings menu for our component, and provides access to all of the variables that the Settings menu controls.

Right now, our project only has one C# file in it: Class1.cs. Let's fix that.

ResetChanceComponent

In the Solution Explorer, right-click LiveSplit.ResetChance and add a new folder named UI. Right-click that folder and add another new folder inside it, named Components. Move Class1.cs into the Components folder and rename it ResetChanceComponent.cs.

Right now, the file should contain the following:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace LiveSplit.ResetChance
{
    public class Class1
    {
    }
}

Replace that code with what's below:

using LiveSplit.Model;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Linq;
using System.Windows.Forms;

namespace LiveSplit.UI.Components
{
    public class ResetChanceComponent : IComponent
    {
    }
}

IComponent is the interface that your component class will inherit from, and there are a lot of required properties and functions that our code doesn't currently have (which is why Visual Studio will be angry with you at this point). Let's fill this class in with the needed elements. Anything that's specific to this component will be left blank, and we'll fill it in later. Everything below should be stuff you can just set and forget (with comments to explain what's happening).

public class ResetChanceComponent : IComponent
{
    // This internal component does the actual heavy lifting. Whenever we want to do something
    // like display text, we will call the appropriate function on the internal component.
    protected InfoTextComponent InternalComponent { get; set; }
    // This is how we will access all the settings that the user has set.
    public ResetChanceSettings Settings { get; set; }
    // This object contains all of the current information about the splits, the timer, etc.
    protected LiveSplitState CurrentState { get; set; }

    public string ComponentName => "Reset Chance";

    public float HorizontalWidth => InternalComponent.HorizontalWidth;
    public float MinimumWidth => InternalComponent.MinimumWidth;
    public float VerticalHeight => InternalComponent.VerticalHeight;
    public float MinimumHeight => InternalComponent.MinimumHeight;

    public float PaddingTop => InternalComponent.PaddingTop;
    public float PaddingLeft => InternalComponent.PaddingLeft;
    public float PaddingBottom => InternalComponent.PaddingBottom;
    public float PaddingRight => InternalComponent.PaddingRight;

    // I'm going to be honest, I don't know what this is for, but I know we don't need it.
    public IDictionary<string, Action> ContextMenuControls => null;

    // This function is called when LiveSplit creates your component. This happens when the
    // component is added to the layout, or when LiveSplit opens a layout with this component
    // already added.
    public ResetChanceComponent(LiveSplitState state)
    {

    }

     public void DrawHorizontal(Graphics g, LiveSplitState state, float height, Region clipRegion)
    {
        InternalComponent.NameLabel.HasShadow
            = InternalComponent.ValueLabel.HasShadow
            = state.LayoutSettings.DropShadows;

        InternalComponent.NameLabel.ForeColor = state.LayoutSettings.TextColor;
        InternalComponent.ValueLabel.ForeColor = state.LayoutSettings.TextColor;

        InternalComponent.DrawHorizontal(g, state, height, clipRegion);
    }

    // We will be adding the ability to display the component across two rows in our settings menu.
    public void DrawVertical(Graphics g, LiveSplitState state, float width, Region clipRegion)
    {
        InternalComponent.DisplayTwoRows = Settings.Display2Rows;

        InternalComponent.NameLabel.HasShadow
            = InternalComponent.ValueLabel.HasShadow
            = state.LayoutSettings.DropShadows;

        InternalComponent.NameLabel.ForeColor = state.LayoutSettings.TextColor;
        InternalComponent.ValueLabel.ForeColor = state.LayoutSettings.TextColor;

        InternalComponent.DrawVertical(g, state, width, clipRegion);
    }

    public Control GetSettingsControl(LayoutMode mode)
    {
        Settings.Mode = mode;
        return Settings;
    }

    public System.Xml.XmlNode GetSettings(System.Xml.XmlDocument document)
    {
        return Settings.GetSettings(document);
    }

    public void SetSettings(System.Xml.XmlNode settings)
    {
        Settings.SetSettings(settings);
    }

    // This is the function where we decide what needs to be displayed at this moment in time,
    // and tell the internal component to display it. This function is called hundreds to
    // thousands of times per second.
    public void Update(IInvalidator invalidator, LiveSplitState state, float width, float height, LayoutMode mode)
    {

    }

    // This function is called when the component is removed from the layout, or when LiveSplit
    // closes a layout with this component in it.
    public void Dispose()
    {

    }

    // I do not know what this is for.
    public int GetSettingsHashCode() => Settings.GetSettingsHashCode();
}

This code assumes that the ResetChanceSettings class exists, even though it doesn't, because we haven't created it. We'll get to that shortly.

Our ResetChanceComponent class has been filled in with the bare-bones content that won't be changed. We'll fill it in with the actual component logic later.

ResetChanceFactory

The ResetChanceFactory is a set-it-and-forget-it class that contains some critical information about your component. The information set here will determine how your component will appear in the Layout Editor, as well as critical update information.

Right-click on the UI\Components directory and select Add -> New Item.... Create a new Class, and name it ResetChanceFactory.cs.

Add new class

Once you've created it, paste the following code into it:

using LiveSplit.Model;
using System;

namespace LiveSplit.UI.Components
{
    public class ResetChanceFactory : IComponentFactory
    {
        // The displayed name of the component in the Layout Editor.
        public string ComponentName => "Reset Chance";

        public string Description => "Displays the likelihood of the run being reset during this split.";

        // The sub-menu this component will appear under when adding the component to the layout.
        public ComponentCategory Category => ComponentCategory.Information;

        public IComponent Create(LiveSplitState state) => new ResetChanceComponent(state);

        public string UpdateName => ComponentName;

        // Fill in this empty string with the URL of the repository where your component is hosted.
        // This should be the raw content version of the repository. If you're not uploading this
        // to GitHub or somewhere, you can ignore this.
        public string UpdateURL => "";

        // Fill in this empty string with the path of the XML file containing update information.
        // Check other LiveSplit components for examples of this. If you're not uploading this to
        // GitHub or somewhere, you can ignore this.
        public string XMLURL => UpdateURL + "";

        public Version Version => Version.Parse("1.0.0");
    }
}

Once this has been set, the only thing you should have to do is change the version number when you're updating.

AssemblyInfo

Before we work on our settings menu, we have another change to make. In the Solution Explorer, expand the Properties underneath LiveSplit.ResetChance, and open up AssemblyInfo.cs. At the top of this file, add the following line:

using LiveSplit.UI.Components;

At the bottom of this file, add the following line:

[assembly: ComponentFactory(typeof(ResetChanceFactory))]

Now you never have to think about this file again (unless you want to update the assembly versions).

ResetChanceSettings

We are going to use a WYSIWYG (What You See Is What You Get) editor to create our Settings menu. Later, we'll go in and do some coding to make it work correctly.

Right-click on your Components folder in the Solution Explorer and select Add -> User Control (Windows Forms).... In the menu, name the file ResetChanceSettings.cs.

Add settings form

This will open up the Settings menu in the Design view, which shows the gray box where your settings will appear. In the Solution Explorer, if you dive down into ResetChanceSettings.cs until you get to ResetChanceSettings(), double-clicking that function name will open up ResetChanceSettings.cs in a code view, which will allow us to update the component logic later.

For this tutorial, we're going to keep the settings menu very simple. (In my final component, I added a bunch of extra settings for changing text and background colors, which I stole from the Sum of Best component.) Our settings menu will only offer two options:

  • Display the component across two rows instead of one
  • Control the number of decimal points shown on the reset chance percentage

Overall layout

Go to the ResetChanceSettings.cs [Design] tab, which is the WYSIWYG editor for our settings menu. First, we're going to change the menu layout so it will automatically grow to fit the controls inside it. Take a look at the Properties window in the bottom-right corner. Scroll down to where it says Layout, and set AutoSize to True.

AutoSize properties

While we're in the Properties window, we'll take care of something else that's very important. Click on the lightning bolt near the top to switch from the Properties tab to the Events tab, which is where we can assign functions to certain actions. Scroll down to Load, which is under Behavior, and double-click the empty space to the right. This will automatically create a function named ResetChanceSettings_Load in our code file, which we are going to use later to perform some initialization.

ResetChanceSettings events

You may be asking "can't I just write my own ResetChanceSettings_Load function in the settings file?" The answer is no. Creating this function through the Events menu will connect the ResetChanceSettings_Load function to the actual act of loading the settings. If you write the function yourself, it will never actually get called.

Next, we need to add some visual components onto our settings menu. Go back to the [Design] view. On the far left of the screen, click on the Toolbox to view all the visual components you can add. The first thing we need is a TableLayoutPanel, which is used to organize elements into table layouts. We're going to use one top-level one to organize the entire settings menu. In the Toolbox, you can search for a TableLayoutPanel and then double-click to automatically add it.

You should see a 2x2 box appear on your settings menu; this is the TableLayoutPanel you just added. Click on it to select it. This will change the Properties window so that it now displays the properties of the TableLayoutPanel instead of the overall settings menu. We're going to do three things here:

  1. Scroll down to Design and change the (Name) to topLevelLayoutPanel. (This will do nothing, but it's good to give each of your elements a descriptive name, so you remember what they're for. You can give it a different name if you want.)
  2. Scroll down to Layout and change AutoSize to True.
  3. In Layout, change the ColumnCount to 1.

Now we're going to fill this table layout with our settings controls.

Display two rows

We're going to have a CheckBox that will allow the user to indicate if they want this component displayed over two columns or not. Head to the Toolbox and click on CheckBox one time, then click away from the Toolbox to close it. (If you click the X in the top-right corner, you'll remove the Toolbox from the left bar, which is inconvenient.) You're now "holding" a checkbox on your mouse, waiting to place it. Click on the top box of our layout panel to place the checkbox inside it.

First off, it doesn't look great stuck in the top-right corner. Let's give it some better alignment. Click on it and scroll down to Layout in the Properties menu. The Anchor setting is currently set to Top, Left, which means the item will glue itself to the top and left edges of the box. Instead, click on Anchor and click on the dropdown arrow to bring up a quick layout menu that looks like this:

Anchor properties

The smaller rectangles on the top and left are filled in. Click on the top rectangle to unselect it, and click on the right one instead. This will anchor the checkbox to the left and right edges instead, which will automatically center it. Already things are looking better.

The next two steps we're going to take:

  • Under Appearance, change the Text to Display 2 Rows.
  • Under Design, change the (Name) to chkTwoRows. (This is important, as this name will come up in our coding later.)

We're done laying this out for now.

Decimal points

The second row in our overall table layout panel is going to house the radio buttons that control the number of decimal points displayed. We're going to contain them in a GroupBox, which will create a nice box around the radio buttons with a label. Open the Toolbox again and add a GroupBox into the bottom row of the topLevelLayoutPanel. In the Properties, under Appearance, change the text to Decimal Points. Under Design, change the (Name) to decimalGroupBox or whatever you like. Lastly, change the Anchor to Top, Bottom, Left, Right, and the AutoSize to True.

Next, add another TableLayoutPanel inside the GroupBox we just made. Rename it to something like decimalLayoutPanel, and change AutoSize to True so it will grow to fit the buttons we place inside it. Here, instead of using the Anchor property, we're going to use the Dock property. Scroll to Layout and click on Dock to bring up the dock menu:

Dock properties

Click on the large rectangle in the center. This will set the dock property to Fill, making the layout panel completely fill the space that's containing it.

Next, change the ColumnCount to 3, and the RowCount to 1. This will give us a single row, and three... unevenly-spaced columns. To fix this, we need to click on the Columns field and open up the menu there.

Column and row styles

For all three columns, change the Size Type to Percent, and the Value to 33.33. Now the columns are evenly spaced.

Next, let's add our radio buttons. Click on the Toolbox and grab a RadioButton, and add it to the first column. Change the (Name) to rdoDecimalZero, and the Text to Zero (1%). Anchor it to Left, Right.

Right now, the text will probably look cut off. I don't really know why this is, but the fix I went with was to increase the MinimumSize, under Layout. Open it up and increase the Width until the text appears. I went with 150.

Now we need to make this radio button interactive. On the Properties window, click on the lightning bolt icon to go to the Events tab. Scroll down to Misc, and double-click the empty space next to CheckedChanged. This will automatically create a function called rdoDecimalZero_CheckedChanged and insert it into your settings code. We'll fill this in later.

Radio button events

Do this with two more radio buttons, one for each of the other two columns. Name the second one rdoDecimalOne, and set its text to One (1.1%). Name the third one rdoDecimalTwo and set its text to Two (1.11%).

Complete settings panel

Now the layout of your settings menu is complete! It may be a bit large, though. You can go into the properties of the layout panels and group boxes and change the settings to try and condense things. If you change the AutoSizeMode to GrowAndShrink, you can get the panels to shrink to their smallest possible size; try this on the topLevelLayoutPanel to collapse the whole thing immediately. If you find this too small, try adding some Margin to the checkboxes or radio buttons, or increasing their minimum sizes.

Creating the component logic

Now that we have the framework of our component in place, it's time to start doing some coding. Let's make this thing actually function!

ResetChanceSettings

The first thing we should do is make sure our settings menu works correctly. Before we can start work on the component logic, the ResetChanceSettings class has to be providing us with accurate settings information, and it has to update itself appropriately. It also needs to be able to store and retrieve its state, or else the settings will disappear every time LiveSplit is closed.

There are two things we should do first, before we start implementing our settings logic. Open up ResetChanceSettings.cs (the one that shows actual code, as well as all the radio button functions that were made for us) and change the imports to the following:

using System;
using System.Drawing;
using System.Windows.Forms;
using System.Xml;

Next, change the namespace from LiveSplit.ResetChance.UI.Components to LiveSplit.UI.Components. This will place the settings class in the same namespace as the component class, giving them access to each other. It also gives us access to other LiveSplit-related classes, like LayoutMode.

Display two rows

We first need to add a couple of variables to our ResetChanceSettings class. Add these inside the ResetChanceSettings class:

public bool Display2Rows { get; set; }

public LayoutMode Mode { get; set; }

The first variable is self-explanatory; we've seen it before, in our DrawVertical function in ResetChanceComponent. The second one stores the current layout mode (either horizontal or vertical), and we've also seen it before, in our GetSettingsControl function. In fact, that's where the LayoutMode gets set in the first place. It's one of those variables that LiveSplit assumes will be there, so we better maintain it.

Next, in the ResetChanceSettings() constructor, we're going to initialize our Display2Rows variable. Add this line below the call to InitializeComponent() (don't worry, this value will be quickly changed):

Display2Rows = false;

Lastly (for now), we need to bind the value of our variable to the checkbox we made (chkTwoRows is what we named it). We'll bind the variable to the checkbox in the ResetChanceSettings_Load function we created earlier.

private void ResetChanceSettings_Load(object sender, EventArgs e)
{
    if (Mode == LayoutMode.Horizontal)
    {
        chkTwoRows.Enabled = false;
        chkTwoRows.DataBindings.Clear();
        chkTwoRows.Checked = true;
    }
    else
    {
        chkTwoRows.Enabled = true;
        chkTwoRows.DataBindings.Clear();
        chkTwoRows.DataBindings.Add("Checked", this, "Display2Rows", false, DataSourceUpdateMode.OnPropertyChanged);
    }
}

The first part of the if is basically disabling the "display two rows" option if LiveSplit is in horizontal mode. The second part is enabling it for vertical mode, and creating a data binding between the checkbox and the Display2Rows variable we made, which will update whenever the checkbox value changes.

Decimal points

Next, we need to get our accuracy settings working. To store our accuracy variable, we're going to create a new enum. Add the following enum and variable to your class:

public enum ResetChanceAccuracy
{
    ZeroDecimal,
    OneDecimal,
    TwoDecimal
}
public ResetChanceAccuracy Accuracy { get; set; }

Next, go back to the constructor and initialize the variable (once again, the value will be quickly changed):

Accuracy = ResetChanceAccuracy.ZeroDecimal;

After that, go back to ResetChanceSettings_Load and add the following code, which will initialize the state of the radio buttons based on the value of Accuracy:

rdoDecimalZero.Checked = Accuracy == ResetChanceAccuracy.ZeroDecimal;
rdoDecimalOne.Checked = Accuracy == ResetChanceAccuracy.OneDecimal;
rdoDecimalTwo.Checked = Accuracy == ResetChanceAccuracy.TwoDecimal;

Lastly (for now), we need to add some logic that will change the value of Accuracy when the user clicks on a different radio button. We're going to create a new UpdateAccuracy() function that each of our radio buttons will call:

private void UpdateAccuracy()
{
    if (rdoDecimalZero.Checked)
        Accuracy = ResetChanceAccuracy.ZeroDecimal;
    else if (rdoDecimalOne.Checked)
        Accuracy = ResetChanceAccuracy.OneDecimal;
    else
        Accuracy = ResetChanceAccuracy.TwoDecimal;
}

Now, have all three of our CheckedChanged functions call this function.

private void rdoDecimalZero_CheckedChanged(object sender, EventArgs e)
{
    UpdateAccuracy();
}

private void rdoDecimalOne_CheckedChanged(object sender, EventArgs e)
{
    UpdateAccuracy();
}

private void rdoDecimalTwo_CheckedChanged(object sender, EventArgs e)
{
    UpdateAccuracy();
}

The accuracy will now update in response to the radio buttons.

Save and restore state

The last thing we need to do is create some functions that will save our chosen settings to XML, and restore them from XML. First, the CreateSettingsNode function will store our current settings:

private int CreateSettingsNode(XmlDocument document, XmlElement parent)
{
    return SettingsHelper.CreateSetting(document, parent, "Version", "1.0") ^
        SettingsHelper.CreateSetting(document, parent, "Accuracy", Accuracy) ^
        SettingsHelper.CreateSetting(document, parent, "Display2Rows", Display2Rows);
}

Two more functions will make use of this new function:

public XmlNode GetSettings(XmlDocument document)
{
    var parent = document.CreateElement("Settings");
    CreateSettingsNode(document, parent);
    return parent;
}

public int GetSettingsHashCode()
{
    return CreateSettingsNode(null, null);
}

Lastly, we need a function to restore our given settings based on stored XML:

public void SetSettings(XmlNode node)
{
    var element = (XmlElement)node;
    Accuracy = SettingsHelper.ParseEnum<ResetChanceAccuracy>(element["Accuracy"]);
    Display2Rows = SettingsHelper.ParseBool(element["Display2Rows"], false);
}

Our settings now work correctly! Time to use them in the actual component.

ResetChanceComponent

We left a few functions empty when we first placed code into our ResetChanceComponent.cs class. We're going to fill them in now. Let's start by adding some things into our constructor.

public ResetChanceComponent(LiveSplitState state)
{
    Settings = new ResetChanceSettings();
    InternalComponent = new InfoTextComponent("Reset Chance", "0%");

    CurrentState = state;
}

The settings class is something we just went over. The InternalComponent is what will do all of the really hard work for us; we tell the internal component to draw something, and it will draw for us. The CurrentState stores all of the information about the timer, splits, etc. We'll be using that to obtain all the information we need.

Reacting to user actions

The next thing we want to do is set ourselves up to react when the user starts the timer, splits, undoes a split, skips a split, etc. To do this, we're going to create a handful of functions, and then add them to the state's list of functions to execute in response to user actions. Create the following empty functions in the ResetChanceComponent class:

void state_OnStart(object sender, EventArgs e)
{

}

void state_OnSplitChange(object sender, EventArgs e)
{

}

void state_OnReset(object sender, TimerPhase e)
{

}

In the constructor, we will assign these functions to user actions. Change the constructor to look like this:

public ResetChanceComponent(LiveSplitState state)
{
    Settings = new ResetChanceSettings();
    InternalComponent = new InfoTextComponent("Reset Chance", "0%");

    state.OnStart += state_OnStart;
    state.OnSplit += state_OnSplitChange;
    state.OnSkipSplit += state_OnSplitChange;
    state.OnUndoSplit += state_OnSplitChange;
    state.OnReset += state_OnReset;
    CurrentState = state;
}

Now these functions are on the list of functions to call when the user splits, starts, the timer, etc. The last thing we need to do is remove these functions again when the component is disposed of. Change the Dispose() function at the bottom of the file to look like this:

public void Dispose()
{
    CurrentState.OnStart -= state_OnStart;
    CurrentState.OnSplit -= state_OnSplitChange;
    CurrentState.OnSkipSplit -= state_OnSplitChange;
    CurrentState.OnUndoSplit -= state_OnSplitChange;
    CurrentState.OnReset -= state_OnReset;
}

Remember to always clean up after yourselves.

Understanding the LiveSplitState and the IRun

All of the critical information we need is in the LiveSplitState object. In my experience, the best way to explore it is in Visual Studio. If you Ctrl+click on the LiveSplitState keyword anywhere in your ResetChanceComponent.cs file, it will open up LiveSplitState.cs for you to explore. You can continue Ctrl+clicking to dive further into the various types that make up the LiveSplitState.

We've already used some of the properties in here. We dove into the LayoutSettings in order to obtain text colors, in our DrawHorizontal and DrawVertical functions. We're also going to be making use of the CurrentSplitIndex, in order to figure out where we are in the splits. All of the really juicy information, however, is in the Run property.

Ctrl+click into the IRun class and take a look. At the same time, open up a well-used splits file in Notepad or some other text editor. You'll be able to see clear similarities between the two. The following LiveSplitState properties can be easily seen in the splits file:

  • GameIcon
  • GameName
  • CategoryName
  • Metadata
  • Offset
  • AttemptCount
  • AttemptHistory
  • AutoSplitterSettings (look at the bottom of the splits file)

So what about all those Segment objects in the splits file? Why don't we see those in the IRun class? Well, look at the definition of the interface:

public interface IRun : IList<ISegment>, ICloneable, INotifyPropertyChanged

It turns out that IRun inherits from IList<ISegment>, so an IRun is effectively a list of ISegment objects. That's where all of our Segment objects are. We should now have enough information to calculate what we want to calculate.

Calculating and displaying the reset chance

Here's our game plan:

  1. Calculate the reset chances for all splits up front.
  2. Retrieve the pre-calculated reset chance for the current split.
  3. Display it.
  4. If the user changes the current split, go to step 2.
  5. If the user resets and starts again, go to step 1.

The benefit of this approach is that we perform all of the potentially expensive operations as infrequently as possible. Most calls to Update() will do essentially nothing. This will prevent LiveSplit from lagging unnecessarily.

We're going to start by creating a function that will create our list of reset chances for each split. Our math is going to look like this:

Chance of reset = (1 - (number of times we completed this split / number of times we attempted this split)) * 100

The one issue we have here is that "number of times we attempted this split" is not a piece of data that LiveSplit tracks, technically. That's no problem, as we can use this instead:

Chance of reset = (1 - (number of times we completed this split / number of times we completed the previous split)) * 100

This works for every split except the first one. For that split, our denominator will be the total number of attempted runs.

We're going to store the reset chances for each split in an array. Create the following variables in the ResetChanceComponent class:

// The list that stores the chance of resetting on each split.
protected List<float> ResetChances { get; set; }
// The reset chance of the current split.
protected float CurrentResetChance { get; set; }

Next, we need to write a function that will populate this list. Add the following to your class:

// This is the function where we calculate the chances of resetting for each individual
// split.
List<float> CalculateResetChances(LiveSplitState state)
{
    List<float> chances = new List<float>();

    for (int i = 0; i < state.Run.Count(); i++)
    {
        // The IRun class is also a list of ISegment objects, so we can iterate over it.
        ISegment currentSegment = state.Run[i];
        // Each completed attempt at a split adds an entry to the SegmentHistory.
        float numCompletions = currentSegment.SegmentHistory.Count;
        // For the first segment, the number of attempts is the number of overall run
        // attempts. Otherwise, it's the number of completions for the previous split.
        float numAttempts = i == 0
            ? state.Run.AttemptHistory.Count
            : state.Run[i - 1].SegmentHistory.Count;

        // We'll use -1 as an initial value.
        float resetChance = -1;
        // Don't calculate a reset chance if either of these numbers are missing.
        if (numCompletions > 0 && numAttempts > 0)
        {
            resetChance = (float)((1 - (numCompletions / numAttempts)) * 100.0);
        }

        // Add this reset chance to our list.
        chances.Add(resetChance);
    }

    return chances;
}

Some things to note:

  1. In an ISegment's SegmentHistory, an entry is only added when a split is completed. Thus, the SegmentHistory contains a number of entries equal to the number of times this split is completed. (Take a look at the <SegmentHistory> of the first split in your splits file - you'll notice some attempts don't show up, if you've ever reset on the first split. Don't lie, we all know you have. We all have.)
  2. We are not using state.Run.AttemptCount. This number can be changed easily, and I've found it to be less accurate than state.Run.AttemptHistory.Count. The latter is a count of all the actually recorded attempts in your splits.
  3. If the number of attempts or number of completions for a split is 0, we're going to return -1. This will indicate that we need to display a special value for these cases. If you want, you can change this so that -1 is only returned if the number of attempts is 0. This would cause the reset chance to appear as 100% for any splits the player has never finished, which is technically correct when based on historical data, but it seems mean.

So now that we've written this CalculateResetChances() function, where do we call it? That would be in the Update() function. Go ahead and update that function:

public void Update(IInvalidator invalidator, LiveSplitState state, float width, float height, LayoutMode mode)
{
    // If necessary, recalculate the reset chances.
    ResetChances = CalculateResetChances(state);

    // If necessary, obtain the reset chance for the current split.
    CurrentResetChance = ResetChances[state.CurrentSplitIndex];
}

Now that we have the data we need, we just need to display it. We'll create a new string to display, feed it to the InternalComponent, and then tell it to update itself. The format of the string will depend on the current value of Accuracy in our ResetChanceSettings.

public void Update(IInvalidator invalidator, LiveSplitState state, float width, float height, LayoutMode mode)
{
    // If necessary, recalculate the reset chances.
    ResetChances = CalculateResetChances(state);

    // If necessary, obtain the reset chance for the current split, and create a new display
    // string.
    CurrentResetChance = ResetChances[state.CurrentSplitIndex];

    // Format with no decimal points.
    string resetChanceFormat = $"{CurrentResetChance:0}%";
    if (Settings.Accuracy.Equals(ResetChanceSettings.ResetChanceAccuracy.OneDecimal))
    {
        // Format with up to one decimal point.
        resetChanceFormat = $"{CurrentResetChance:0.#}%";
    }
    else if (Settings.Accuracy.Equals(ResetChanceSettings.ResetChanceAccuracy.TwoDecimal))
    {
        // Format with up to two decimal points.
        resetChanceFormat = $"{CurrentResetChance:0.##}%";
    }
    // If we can't make an estimate, just display "?".
    InternalComponent.InformationValue = CurrentResetChance >= 0 ? resetChanceFormat : "?";

    InternalComponent.Update(invalidator, state, width, height, mode);
}

So now we're done, right? Well, no, not quite. With the way we have it set up now, we're going to recalculate the reset chances hundreds to thousands of times per second, which could make LiveSplit start lagging (division is expensive to a computer). Remember the game plan: we only want to recalculate this data if we have to. Let's get that set up.

Changing the currently displayed reset chance

In order to implement steps 4 and 5, we are going to use a strategy of invalidation. In your ResetChanceComponent class, create the following booleans:

protected bool ResetChancesValid { get; set; }
protected bool CurrentSplitValid { get; set; }

In the ever-growing constructor, set these variables to false.

public ResetChanceComponent(LiveSplitState state)
{
    Settings = new ResetChanceSettings();
    InternalComponent = new InfoTextComponent("Reset Chance", "0%");

    state.OnStart += state_OnStart;
    state.OnSplit += state_OnSplitChange;
    state.OnSkipSplit += state_OnSplitChange;
    state.OnUndoSplit += state_OnSplitChange;
    CurrentState = state;

    ResetChancesValid = false;
    CurrentSplitValid = false;
}

Now we're going to use our on start/split functions to control these variables. Fill in those functions like so:

void state_OnStart(object sender, EventArgs e)
{
    // Invalidate the list of reset chances so we can recalculate them.
    ResetChancesValid = false;
    // Invalidate the current split, so we recalculate the current split.
    CurrentSplitValid = false;
}

void state_OnSplitChange(object sender, EventArgs e)
{
    // Invalidate the current split, so we recalculate the current split.
    CurrentSplitValid = false;
}

void state_OnReset(object sender, TimerPhase e)
{
    // Invalidate the current split, so we recalculate the current split.
    CurrentSplitValid = false;
}

These functions will now tell our LiveSplit component that 1) the split we were using before is no longer valid, and/or 2) the long list of reset chances that we calculated is no longer valid. We will now know that we need to rebuild them before we use them again. We do that by changing the Update() function like so:

public void Update(IInvalidator invalidator, LiveSplitState state, float width, float height, LayoutMode mode)
{
    // If necessary, recalculate the reset chances.
    if (!ResetChancesValid)
    {
        ResetChancesValid = true;
        ResetChances = CalculateResetChances(state);
    }

    // If necessary, obtain the reset chance for the current split, and create a new
    // display string.
    if (!CurrentSplitValid)
    {
        CurrentSplitValid = true;
        CurrentResetChance = ResetChances[state.CurrentSplitIndex];

        // Format with no decimal points.
        string resetChanceFormat = $"{CurrentResetChance:0}%";
        if (Settings.Accuracy.Equals(ResetChanceSettings.ResetChanceAccuracy.OneDecimal))
        {
            // Format with up to one decimal point.
            resetChanceFormat = $"{CurrentResetChance:0.#}%";
        }
        else if (Settings.Accuracy.Equals(ResetChanceSettings.ResetChanceAccuracy.TwoDecimal))
        {
            // Format with up to two decimal points.
            resetChanceFormat = $"{CurrentResetChance:0.##}%";
        }
        // If we can't make an estimate, just display "?".
        InternalComponent.InformationValue = CurrentResetChance >= 0 ? resetChanceFormat : "?";
    }

    InternalComponent.Update(invalidator, state, width, height, mode);
}

We're nearly there, but we can do a little bit better. If the run is finished, or if it has just been reset and not restarted, we should display 0% instead of whatever would be there last. We can figure this out by looking at the CurrentPhase in the LiveSplitState. Replace the following line in Update():

CurrentResetChance = ResetChances[state.CurrentSplitIndex];

With the following:

CurrentResetChance = GetResetChance(state);

And then write the following function:

// Get the current reset chance to display, taking into account the timer state.
float GetResetChance(LiveSplitState state)
{
    // If the timer has finished, or has been reset and not started, we won't fetch a reset
    // chance.
    bool noResetChance = state.CurrentPhase.Equals(TimerPhase.NotRunning)
        || state.CurrentPhase.Equals(TimerPhase.Ended);
    return noResetChance ? 0 : ResetChances[state.CurrentSplitIndex];
}

Now we should be all done. It's time to test!

Building and testing the component

I'm sure there's a way to test this new component entirely inside Visual Studio, but I don't know what it is. Here's what I do instead.

If you did not build the LiveSplit project before you started, this step is likely to fail. If you didn't do that earlier, save and close your project right now. Open up the LiveSplit source code in Visual Studio and build it with Build -> Build Solution. Now you can go back to your component project.

In Visual Studio, go to Build -> Build Solution. At the bottom of the screen, it will show the build log. If the build succeeds (and hopefully it does, if I wrote this document correctly), it will output the directory where the debug version of the DLL was built. Go to that directory, copy the DLL file, and paste it into the Components folder of your local LiveSplit.

To test it out, open LiveSplit and right-click on it, then go to Edit Layout.... Hit the +, go to the Information menu, find the Reset Chance component, and add it to your layout. Now open up a well-used splits file and start it up, while watching the Reset Chance component to make sure everything displays correctly.

I tried it out myself, just to make sure it all looks right. Let's open up my Sonic 3 & Knuckles splits and see the reset chances for the very first split...

Reset chance: 79%

Actually, that's a bad example. Let's try the next split...

Reset chance: 6%

Perfect! Everything looks like it's working great!

When you're developing your own component, though, everything may not work out so great. If something goes wrong and your component throws an error, LiveSplit isn't going to display any error messages. In order to see these messages, you should download a Microsoft-made program called DebugView (download it here). If you open up this program and then run LiveSplit until your component crashes, the error messages should appear in the DebugView panel. That will help you to figure out what went wrong.

Releasing the component

Finally, your component is done. The last thing we need to do is create a release build for it. Go to Build -> Configuration Manager... and change the Active solution configuration from Debug to Release.

Configuration manager

Now build your project again. (You may have to open up the LiveSplit project again and build a release candidate for that.) You'll see that the DLL is now being built in a Release folder instead of a Debug folder. You can now use that DLL wherever you please. Add it to your own LiveSplit, share it with your friends, or host it on someplace like GitHub so the whole speedrunning community can benefit.

Good luck and happy building!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment