Skip to content

Cooperative Preloading

Morgan Touverey Quilling edited this page Jul 14, 2024 · 6 revisions

Created by: @toverux
Authors: @toverux
SDKs needed: UI (npm) and .NET (NuGet)

Overview

Let's say your mod has work to do before the user loads a save game, like loading or patching assets. You would like to freeze the "Load Game" and "New Game" buttons, you painfully write an UI extension for the main menu and set the disabled attribute to true.
But now a second mod wants to do the same, and now both mods are setting inconsistent disabled value to the buttons.
Also, how do you handle users clicking on "Continue" in the Paradox Launcher if your preloading logic takes place in the main menu?

Cooperative Preloading is a C# and UI feature that allows to implement preloading in a way that plays nice with the game, the user and other mods using it, with only a few lines of code.

Here is a short YouTube video demonstrating the different use cases supported. Pay attention to the notification text that adapts to the different situations.

What it does:

  • Lock the following buttons while any mod is preloading: Continue Game, Load Game, New Game.
  • Display a spinner icon on these buttons while mods are preloading, and a warning icon and a popup on the button if any operation fails.
  • Handle uncaught errors in your preloading tasks to display an error message dialog.
  • Display a notification to the user indicating which mods are loading and overall progress.
  • Inhibit the Continue button from the launcher and redirects the user to the main menu, and then resume the autoloading process when mods are ready.

What it does not do:

  • Handle progress notifications or logic for you, you are free to give feedback to the user how you like, typically in the notifications section, for example Asset Packs Manager has a three-step process and displays exactly what's going on.
  • Allow you to customize exactly how the cooperative preloading works, since it is shared between all mods that use it, as such it has to be consistent and "Vanilla-like".

Installation

On the UI side, you will need to register the feature (you'll have to create one just for that if you don't have a UI mod). This will bundle the code necessary to disable buttons, display spinner and warning icons.

import { cooperativePreloading } from '@csmodding/urbandevkit';
import type { ModRegistrar } from 'cs2/modding';

const register: ModRegistrar = moduleRegistry => {
    cooperativePreloading.register(moduleRegistry);
};

export default register;

You might wonder: in case multiple mods use the Cooperative Preloading feature, the mod embedding the latest version of the feature wins the registration process, making cooperativePreloading.register() a no-op for older versions.

You're all set up to add your preloading logic!

Usage

Declaring Preloaders

The first thing to do is to declare your preloaders (you can have multiple ones) at mod initialization, i.e either as static fields, or in the static constructor, or in your constructor or OnLoad() method, storing it in an instance field if you prefer that.

The idea is that preloaders need to be registered as soon as possible, so UDK knows which and how many preloading operations should be run and tracked before unlocking the load game buttons.

You can notice that each register method takes in the mod name (use nameof(YourNamespace) or your actual mod name) and a name for the operation (use nameof(YourMethod) or an actual descriptive name). Both are used for logging purposes and may be shown to the user.

Task Preloader

Register method overload that takes a Task factory and automatically tracks the task for completion or error (see Error handling section).

This is the recommended way for simple use cases, and by default it will run the preloading task in a thread, improving performance.

using UrbanDevKit.CooperativePreloading;

private static readonly PreloadingOperation<Task> TaskPreloader =
    Preloader.RegisterPreloader(nameof(MyMod), "Preload with Task", Mod.PreloadTask);

private static async Task PreloadTask() {
    // Wait between 1 and 3 seconds
    await Task.Delay(new Random().Next(3000, 6000));
}

You can also pass the optional argument Preloader.RegisterPreloader(..., ensureThreadPool: false) to run the Task on the thread Start() was called, ex. the main thread if you need your logic to run on it.

Coroutine Preloader

If you still use Unity coroutines in 2024, there is an overload for that.

Internally, it wraps the coroutine in a Task with CoroutineRunner, so the returned PreloadingOperation is indeed a Task bearer.

Unity coroutines always run on the main thread.

using UrbanDevKit.CooperativePreloading;

private static readonly PreloadingOperation<Task> CoroutinePreloader =
    Preloader.RegisterPreloader(nameof(MyMod), "Preload with Coroutine", Mod.PreloadCoroutine());

private static IEnumerator PreloadCoroutine() {
    // Wait between 1 and 3 seconds
    yield return new WaitForSeconds(new Random().Next(3, 6));
}

IDisposable Preloader

This is the most flexible solution as it allows you to control exactly how you start and stop the preloading operation, handle errors, etc., so this pattern can support anything more complex than a simple Task or coroutine.

But this also means it does a little safeguarding less for you, ex. you will need to make sure that the operation's Dispose(Exception?) is called in all circumstances whether the mod preloading succeeds or fails (write good try-catches!), or the user will be stuck with disabled buttons.

using UrbanDevKit.CooperativePreloading;

private static readonly PreloadingOperation<IDisposableWithException> DisposablePreloader =
    Preloader.RegisterPreloader(nameof(MyMod), "Preload with IDisposable");

Starting Preloading Operations

You are then responsible of Start()ing the preloading logic whenever you want but before a save game is loaded, of course:

  • In your mod OnLoad(),
  • In a System in OnCreate(),
  • In a System in OnGameLoadingComplete(Purpose purpose, GameMode mode) when mode == GameMode.MainMenu (don't forget that this can be executed multiple times),
  • Similarly with GameManager.instance.onGameLoadingComplete += YourPreloadOperation,
  • Anything you can think of that executes before a save game is loaded.

Task and coroutine preloaders

Both return a PreloadingOperation<Task> that you can .Start() anywhere without needing to await it, although it does return a Task if you want to do things with it.

public void OnLoad(UpdateSystem updateSystem) {
    Mod.CoroutinePreloader.Start();
    Mod.TaskPreloader.Start();
}

Disposable preloader

As it returns an implementation of IDisposable, it can be used for simple preloading operations with a using block:

// This method is just an example, you better run this asynchronously
// or you will block the main thread and therefore the UI.
private IEnumerator MyPreloadCoroutine() {
    // The advantage is that you're sure that Dispose() is always called whether this code runs or throws.
    using (Mod.DisposablePreloader.Start()) {
        // ...do things...
    }
}

For more complex scenarios and proper error handling, you can call Dispose() yourself:

private IDisposableWithException preloading;

private void MyPreload() {
    this.preloading = Mod.DisposablePreloader.Start();

    MyPreloader.StartSomethingInBackgroundThatEmitsEvents();

    MyPreloader.OnSuccess += MyPreloadOnSuccess;
    MyPreloader.OnError += MyPreloadOnError;
}

private void MyPreloadOnSuccess() {
    MyPreloader.OnSuccess -= MyPreloadOnSuccess;

    this.notificationUiSystem.AddOrUpdateNotification(nameof(MyMod), "My Mod", "Preloading complete!");
    this.preloading.Dispose();
}

private void MyPreloadOnError(Exception ex) {
    MyPreloader.OnError -= MyPreloadOnError;

    this.notificationUiSystem.AddOrUpdateNotification(nameof(MyMod), "My Mod", "Preloading failed =(");
    this.preloading.Dispose(ex);
}

Error handling

An operation is marked as failed if:

  • A wrapped Task or coroutine operation throws an exception,
  • An IDisposableWithException operation is disposed with Dispose(Exception).

In case of failure this will happen:

  • The buttons will be reenabled but a warning icon and popup will be shown,
  • The cooperative preloading notification will display that a mod failed to load,
  • The error will be logged,
  • By default, the exception will be shown in an error dialog box.

However, that error dialog can be prevented if you wish to handle error feedback yourself, ex. update your own notification or display your own dialog.

For this, attach an exception listener to your preloading operation with the chaining method CatchExceptions():

public static readonly PreloadingOperation<Task> TaskPreloader =
    Preloader.RegisterPreloader(
            nameof(MyMod),
            nameof(Mod.PreloadTask),
            Mod.PreloadTask)
        .CatchExceptions(Mod.OnPreloadTaskError);

private static void OnPreloadTaskError(Exception ex) {
    // do something
}

Directly attaching the exception event also works:

static Mod() {
    Mod.TaskPreloader.OnException += OnPreloadTaskError;
}

Combining them all

This launches two preload operations, and wraps them into a third preloading operation.
It does not make that much sense but it shows you some possibilities with the API.

using System;
using System.Collections;
using System.Threading.Tasks;
using Colossal.Logging;
using Colossal.Serialization.Entities;
using Game;
using Game.Modding;
using Game.SceneFlow;
using UnityEngine;
using UrbanDevKit.CooperativePreloading;
using Random = System.Random;

namespace MyMod;

public sealed class Mod : IMod {
    public static readonly ILog Log = LogManager
        .GetLogger(nameof(MyMod))
        .SetShowsErrorsInUI(true);

    public static readonly PreloadingOperation<IDisposableWithException> DisposablePreloader =
        Preloader.RegisterPreloader(
                nameof(MyMod),
                "WrapPreloadingOperations")
            .CatchExceptions(Mod.NotifyFailedPreloading);

    public static readonly PreloadingOperation<Task> CoroutinePreloader =
        Preloader.RegisterPreloader(
                nameof(MyMod),
                nameof(Mod.PreloadCoroutine),
                Mod.PreloadCoroutine())
            .CatchExceptions(Mod.NotifyFailedPreloading);

    public static readonly PreloadingOperation<Task> TaskPreloader =
        Preloader.RegisterPreloader(
                nameof(MyMod),
                nameof(Mod.PreloadTask),
                Mod.PreloadTask)
            .CatchExceptions(Mod.NotifyFailedPreloading);

    public void OnLoad(UpdateSystem updateSystem) {
        GameManager.instance.onGameLoadingComplete += OnGameLoadingComplete;

        async void OnGameLoadingComplete(Purpose purpose, GameMode mode) {
            if (
                mode != GameMode.MainMenu ||
                Mod.DisposablePreloader.State == OperationState.Running) {
                return;
            }

            var disposable = Mod.DisposablePreloader.Start();

            await Task.WhenAll(
                    Mod.CoroutinePreloader.Start(),
                    Mod.TaskPreloader.Start())
                // If task.Exception is null, Dispose() consider the preload operation to be a success
                .ContinueWith(task => disposable.Dispose(task.Exception));
        }
    }

    private static IEnumerator PreloadCoroutine() {
        // Wait between 1 and 3 seconds
        yield return new WaitForSeconds(new Random().Next(3, 6));
    }

    private static async Task PreloadTask() {
        await Task.Delay(new Random().Next(3000, 6000));
    }

    private static void NotifyFailedPreloading(Exception ex) {
        Mod.Log.Error(ex, "Failed a preloading operation, please contact me at [email protected]!");
    }
}

Features & Helpers

.NET-only Features

  • Shared State Share state between assemblies without linking
  • Coroutine Runner Helper to start coroutines from anywhere or wrap them into Tasks.

UI-only Features

.NET+UI Features

Contributing

Clone this wiki locally