content manager usage - GoldhawkInteractive/X2-Modding GitHub Wiki

Content Manager & Asset Loading Basics

❗️Not Unity's AssetReference:❗️ Xenonauts 2 development was started before the new asset management system of Unity existed.

As such, all the concepts on this page refer to the custom asset handling framework, not the Unity one. ❗️

Overview: ContentManager

Xenonauts 2 uses a custom ContentManager (working on top of Unity’s default loading system) to manage game assets in a unified way. The ContentManager abstracts away where assets come from – whether they're part of the base game, a mod, a file on disk, or a Unity AssetBundle – and provides a controlled lifecycle for loading and unloading assets. It operates globally (a singleton-like service), accessible via AssetReference.ContentManager. This global ContentManager is initialized at startup and must be active before any asset requests are made. Modders will interact with the ContentManager primarily through asset references and descriptors rather than dealing with file paths or Unity’s Resources APIs directly.

Key responsibilities of ContentManager:

  • Resolve asset descriptors into actual files or asset bundle entries (represented internally by file handles).

    • Which tackles things like respecting overrides on assets based on mod priority.
  • Load assets (and their dependencies) according to a defined loading policy (strict preloading by default).

  • Cache loaded assets and track their state (loaded, unloaded, failed, etc.).

  • Unload assets when no longer needed (often when leaving a game screen or unloading a mod).

  • Provide events and queries to check asset status (e.g. AssetLoaded/AssetUnloaded events, or IsLoaded queries).

Unity Context: Xenonauts 2 is built on Unity 2022.3, but do not use Unity’s direct loading methods for mod assets. Instead, use the ContentManager API as described here. This ensures mod assets are discovered, loaded, and potentially overridden in the same way as core game assets, respecting the game's content packs and mod priority.

Asset References, IReference, and Descriptors

To request or refer to an asset in Xenonauts 2, you will typically use an AssetReference (which implements the IReference interface). An AssetReference is a generic object that points to an asset of a certain type (Texture, GameObject, AudioClip, JSON data, etc.) and contains a Descriptor describing that asset’s location. Modders do not create descriptors manually very often – they are usually created behind the scenes – but it’s useful to understand them:

  • Descriptor: A descriptor encapsulates where and what an asset is. It includes the asset type, a name/path (relative to a content folder), an optional content pack identifier (e.g. a mod folder name), and a Source (e.g. file system vs. asset bundle). It does not directly hold the asset; instead, it can be resolved to a FileHandle (the actual file, memory location or bundle entry that has the asset). For example, an AssetDescriptor might represent “a GameObject named camera/default_camera in the MainMenu screen, from either the default pack or a mod override.”

  • AssetReference (IReference): The modder-facing asset handle. You typically create an AssetReference<T> for the asset type T you need. This reference holds a Descriptor internally. When you call Get() on the reference, it will use the ContentManager to load or retrieve the asset. AssetReferences abstract away the details of which content pack or which source the asset comes from. You just specify the name and context, and let ContentManager figure out if that asset is in the base game files, a mod’s files, or elsewhere.

    • NOTE: By default, assets must be discovered and loaded in the loading phase and are not loaded adhoc.

Creating an AssetReference: Use the AssetReference.Asset<T>(name, screen, source, contentPackPath) factory to create a reference. For example, to reference a Main Menu camera prefab (a Unity GameObject) located at path item/ammo/alien_mag_clip in the MainMenu content folder, you would do:

public static AssetReference<Template> MAIN_MENU_CAMERA 
    = AssetReference.Asset<Template>("item/ammo/alien_mag_clip", GameScreens.GroundCombat);

This static reference (from the Xenonauts 2 code) defines a GameObject asset reference. Here:

  • "item/ammo/alien_mag_clip" is the asset’s path/name (relative to the content folder for that screen).

    • NOTE: You do not need to provide the extension of the file - it will be matched based on the asset type.
  • GameScreens.GroundCombat is an enum value identifying the screen or context (which the ContentManager uses as a subdirectory like "groundcombat" in the core content pack).

  • The source and contentPackPath are omitted in this example, defaulting to Source.Default (meaning “look in the default locations”) and no specific pack (meaning “use any enabled content pack, priority based”).

Under the hood, this creates an AssetDescriptor via AssetDescriptor.Construct, storing the type (GameObject), the name/path, the screen context, and using Source.Default. The Descriptor doesn’t immediately know if this asset is in the base game or a mod – that’s resolved at load time.

Source Abstraction: The Source enum specifies where to load from. By default you will use Source.Default, which lets the ContentManager decide based on configuration (usually checking memory overrides, then asset bundles, then the file system). Other options are Source.Memory, Source.FileSystem, and Source.AssetBundle to force a specific location, but these are advanced – most modders can stick to Default unless you have a special reason.

Content Packs and ContentPackPath: Xenonauts 2 organizes assets into content packs (the core game content pack is called "xenonauts", and each mod is treated as an additional content pack). A Descriptor can include a contentPackPath to explicitly target a pack. If left null, the ContentManager will search all active packs for the asset. In practice, Source.Default plus no explicit pack means the ContentManager will try to find the asset in an enabled mod first (if it overrides that asset) and fall back to the core pack. This is how mods can override base-game assets by using the same path/name – the ContentManager will resolve the Descriptor to the mod’s file if present. Conversely, you can specify contentPackPath to lock a reference to a particular mod or the core pack if needed (not usually necessary for basic usage).

FileHandles: When a descriptor is resolved, the ContentManager produces a FileHandle internally, which represents the actual asset file or asset bundle entry. You typically won’t deal with FileHandle objects directly in mods, but it’s good to know they exist. For example, if an asset is in a mod folder on disk, the FileHandle will contain the absolute file path; if it’s in an AssetBundle, the FileHandle points to that bundle and asset name. The ContentManager’s resolve step finds the correct FileHandle for a Descriptor (accounting for mod overrides, etc.), and stores it in Descriptor.FileHandle. You can check if an asset exists via ContentManager.Exists(descriptor) which essentially tries to resolve and then checks descriptor.FileHandle.Exists().

Gated Loading (Default Behavior)

By default, Xenonauts 2 uses gated loading (a strict loading rule) – assets must be queued or declared for loading before you try to use them. This is done to avoid performance hiccups during gameplay (no surprise disk reads or bundle decompressions in the middle of a frame). In practical terms, you should tell the ContentManager about all assets your mod needs at a given time, so it can load them during a loading screen or appropriate moment. Luckily dependencies are automatically discovered.

For modders, the primary way to do this is by using the static AssetReference pattern in conjunction with the game’s screen system if you want to load in new assets. The game’s screens (states) have a mechanism to gather required assets, including those from mods, and load them when the screen is entering.

Static Asset References and Auto-Pickup

If you declare an AssetReference (or other IReference) as a public static field in a class that is registered as an “asset dependent” of a screen, the ContentManager will automatically pick it up and load it at the appropriate time. The Xenonauts 2 framework uses reflection to scan for these references. For example, in the main menu screen code, there is a list of types that are asset dependents:

private static readonly List<Type> ASSET_DEPENDANTS = new List<Type> {
    typeof(MainMenuScreen),
    typeof(DialogSystem),
    typeof(ToastSystem),
    // ... other types that have static references ...
}.AddAll(MainMenuUISystem.UIElements.Values) /* etc. */;

When the Screen is about to load, it calls GetRequiredAssets() which iterates over all those types and uses reflection to find any public static fields of type IAssetReference or IAssetReferences. For each such field, it pulls the Descriptor and queues it for loading:*

In other words, any static asset reference you’ve declared in those classes will have its descriptor added to the load list. The ContentManager then loads all those assets (likely in a loading screen or asynchronously before the screen fully initializes).

Example: The MainMenuScreen class itself has the MAIN_MENU_CAMERA reference shown above. Because MainMenuScreen is in the asset dependents list for the main menu, the ContentManager will find MainMenuScreen.MAIN_MENU_CAMERA via reflection, grab its Descriptor ("camera/default_camera" in MainMenu), and include it in the assets to load when the main menu is opening. By the time the main menu screen is active, MAIN_MENU_CAMERA.Get() will return the loaded GameObject prefab, ready to use.

This pattern means modders can ensure their assets load at the correct time by hooking into the existing screens or systems:

  • If your mod adds a new UI element or system for the Geoscape (strategy screen), you can add your class type to that screen’s asset dependents list (via a Harmony patch or mod API, if available) so that its static references are preloaded.

  • If adding to an existing type that’s already in the list, simply declaring a new public static AssetReference in that class is enough – it will be picked up.

  • Always initialize static references when declaring them (do not leave them null). The reflection loader will throw an error if it finds a static IAssetReference field that is null (meaning it wasn’t initialized).

Using AssetReference in Code: Once the ContentManager has loaded the asset, using it is straightforward:

  • Call myAssetReference.Get() to retrieve the actual asset object (T). If the asset is not yet loaded (and if by some chance it wasn’t queued), under strict mode this call would throw an error because gated loading is enforced. In normal use (where you’ve queued it properly), Get() will simply fetch the cached asset. For example, MAIN_MENU_CAMERA.Get() returns a GameObject prefab ready to be instantiated.

  • You can check myAssetReference.IsLoaded() to see if the asset is in memory (this calls ContentManager.IsLoaded(descriptor) internally). However, in gated usage you often don’t need to manually check IsLoaded() – you know that by the time the screen or system runs, the asset was loaded during the loading screen. It’s more useful in ad-hoc scenarios (discussed below).

AssetDescriptorAttribute: Another way assets are linked is via the [AssetDescriptor] attribute on classes. This is used in Xenonauts 2 for certain cases like UI classes that need a specific asset. For example:

[AssetDescriptor("AirCombatBase", GameScreens.AirCombat)]
public class AirCombatBaseUIElement : AbstractUIElement { ... }

The attribute essentially attaches an AssetDescriptor to the class. The reflection scan in GetRequiredAssets() checks for this attribute as well. If present, it will construct that descriptor and queue it (if the class inherits IUIElement, the system may treat it specially to load a UIElement asset). For modders, using static AssetReference fields is usually more direct, but be aware the attribute exists – it’s another way the game auto-loads assets for classes marked with it. (It returns an AssetReference<T> via an extension method, as seen in AssetDescriptorAttribute.Asset<T>()).

Best Practices for Gated Loading in Mods

  • Declare references for everything you need upfront. If your modded feature (e.g. a new soldier equipment item) requires a texture, a 3D model prefab, and a JSON data file, create static AssetReference<T> fields for each, ideally in a class that will be scanned. For instance, if it’s used in Ground Combat, you might put them in a class that is added to GroundCombatScreen’s asset dependents. Or, if appropriate, patch that screen’s list to include your class.

  • Utilize existing systems’ asset lists. Many core systems (UI systems, audio systems, etc.) are already in the asset dependents lists for screens. If your mod extends one, simply adding your references to that system class (as static fields) can piggyback on the existing loading. For example, the AchievementSystem might have static references for achievement icons. A mod adding new achievements could add new static AssetReference<Sprite> fields in AchievementSystem.Achievements class, and they would load with the main menu or strategy screen automatically (since those classes are scanned).

  • Avoid heavy loading during gameplay. Try not to load large assets on the fly during an active mission or geoscape update. Instead, load them during transitions (e.g. when opening a menu or entering a mission). The strict gating helps with this by catching unintended lazy loads.

Ad-hoc (On-Demand) Loading for dynamic assets

Sometimes a mod might need to load something dynamically that wasn’t known upfront. For example, a mod could allow players to import a custom image at runtime, or maybe your mod tool wants to preview assets in the mod folder on the fly. In these cases, the ContentManager does support ad-hoc loading.

Ideally, on transition to the Screen, your mod will specify what assets should be loaded

LoadingRule: The behavior of ContentManager.Get() when an asset isn't loaded depends on the LoadingRule configured. The default game configuration uses LoadingRule.Strict, which means any attempt to Get an unloaded asset throws a runtime exception instructing you to call Load first. Other modes are:

  • Lazy: allows Get before explicit load; the ContentManager will block and load that asset on demand, but not any of its dependencies (they would load only if and when they are specifically requested).

  • Immediate: allows Get before load and, when an asset is demanded, loads it and all its dependencies immediately.

For modding, you generally won't switch the entire game to Lazy/Immediate (as that could impact performance globally), but you can override behavior per asset or per scope:

  • Per Asset Reference: Each AssetReference<T> has an optional ruleOverride. You can construct an AssetReference with a different loading rule if you intend to use it ad-hoc. For example, new AssetReference<Texture2D>(name, screen, Source.Default, contentPackPath, ruleOverride: LoadingRule.Lazy) would mark that reference to use lazy loading. Then, calling myRef.Get() will trigger a load of the asset if it’s not already loaded, instead of erroring. Internally, the ContentManager sees the override in AssetReference.ruleOverride and bypasses the strict check. It will effectively call Load(descriptor) on the spot for you.

  • Temporary Rule Scope: You can temporarily relax the ContentManager’s rule using ContentManager.SetLoadingRule(rule) which returns a TemporaryLoadingRule token. When disposed, it will revert to the previous rule. For example, in an editor or console command, you might do:

    using(ContentManager.SetLoadingRule(LoadingRule.Lazy)) {
        // call AssetReference.Get() freely here, assets will load on-demand
    }

    This ensures the rest of the game remains strict outside that block.

  • Explicit Load Calls: The simplest ad-hoc approach is to manually call the ContentManager to load something when needed, rather than relying on an override. You can always do:

    if (!AssetReference.ContentManager.IsLoaded(myRef.Descriptor)) {
        AssetReference.ContentManager.Load(myRef.Descriptor);
    }
    var asset = myRef.Get();

    This is effectively what strict mode enforces – you check and explicitly Load. For instance, the mod loader uses this pattern when loading mod assemblies on the fly: it iterates through assembly files and calls ContentManager.Load(descriptor) for each before using them. In a mod context, you might use this if your mod opens a file mid-game (say, a custom level file) – you’d construct a Descriptor or AssetReference for it, then call Load() and wait for completion (the Load() call is synchronous and will block until the asset and its dependencies are loaded).

Important: Ad-hoc loading will introduce a pause (or at least a frame delay if done asynchronously with QueueLoad) when the asset is first needed. Use it sparingly. A common mod use-case for lazy loading might be a large library of optional assets where loading everything up front is impractical. In such cases, consider showing a loading indicator or otherwise mitigating the delay when something is pulled in on-demand.

Example: On-Demand Loading in a Mod

Imagine a mod that allows the player to click a button to load a custom map during gameplay. The mod doesn’t know which map file will be needed until the player chooses. The mod could do:

AssetReference<MapData> customMapRef = AssetReference.Asset<MapData>("maps/custom/myCoolMap", GameScreens.GroundCombat, 
                                                                     source: Source.FileSystem, 
                                                                     contentPackPath: myModFolder, 
                                                                     ruleOverride: LoadingRule.Lazy);
// ... Later, when player selects the map:
if (!customMapRef.IsLoaded()) {
    // This Get will trigger a load due to Lazy rule override
    MapData data = customMapRef.Get(); 
    // possibly show a loading spinner here because this call may take time
}
// Use the map data...

Here we created the reference with LoadingRule.Lazy specifically. The call to Get() will internally call ContentManager.Load(customMapRef.Descriptor) on the spot (blocking the game briefly) because strict mode was bypassed. In a UI context, you might want to yield control or load asynchronously (using QueueLoad) instead of blocking the main thread.

For asynchronous needs, ContentManager provides QueueLoad(descriptor) which queues the load on its internal loading thread/system. You could register a callback with ContentManager.AssetLoaded event to be notified when done, or use AssetReference.ContentManager.AssetLoad((desc, state, obj) => { ... }) to attach a one-time callback. But note that if you queue loads, you must not call .Get() until the asset is actually loaded (or use IsLoaded to poll), otherwise you might hit the strict mode exception if it’s still pending.

Determining When Assets are Loaded

In gated scenarios, the game’s flow ensures assets are loaded before they are needed (e.g., screen transition waits for ContentManager to finish). However, as a modder you might still want to verify or react to loading:

  • Checking Load State: Use AssetReference.IsLoaded() for single assets or ContentManager.AreLoaded(IEnumerable<Descriptor>) for a batch. For example, after queuing a load, you could poll AreLoaded on the set of descriptors to know when all are ready.

  • ContentManager Events: The ContentManager triggers an AssetLoaded event globally when any asset finishes loading. You can subscribe to AssetReference.ContentManager.AssetLoaded += (Descriptor desc, AssetState state, object asset) => { ... } to handle things once they load. The object asset parameter will be the loaded asset object. Similarly AssetUnloaded fires on unload.

  • Asset Manifests: When a screen loads assets, it tracks them in an AssetManifest (essentially a list of descriptors) attached to that screen. When leaving the screen, the manifest can be unloaded. As a modder you normally don’t handle manifests directly, but be aware that screens will unload their assets on exit by manifest. If you want an asset to persist across screens, you might need to mark it as persistent or use a global system (or reload it in each screen as needed).

Summary of Usage for Modders

  • Use AssetReference to define your mod assets instead of direct file paths or Unity resource references. This lets the ContentManager handle multi-source lookup and caching.

  • For assets needed at a known point (e.g., on game start or on entering a particular screen), set them up to load gated. Typically, add static AssetReference fields in a class that will be scanned by that screen’s loading process. The game will auto-load them for you at the right time.

  • For dynamic or optional assets, decide on a loading strategy: either load them upfront in a loading screen (perhaps via a mod options preload list), or use ad-hoc loading with appropriate user feedback. If you choose ad-hoc, consider using LoadingRule.Lazy on those references or calling ContentManager.Load manually to avoid the strict mode error.

  • Don’t forget dependencies. If your asset has dependencies (e.g., a prefab might depend on textures or meshes), by default strict mode will load them all (since Xenonauts 2 config uses LoadsDependencies). If you use Lazy mode, dependencies won’t auto-load – you’d have to request them when needed. You can force dependency loading by using Immediate mode (though that’s essentially similar to doing a full load upfront).

  • Testing your mod assets: Run the game with logging enabled. The ContentManager will log info about asset resolution and loading. For example, it may log which file a descriptor resolved to (helpful to ensure your mod asset is being picked up instead of the base one). It will also log each static field it finds: “Found an AssetLoad Field: MyModClass.MyTexture” and “Queued: MyModClass.MyTexture: AssetDescriptor(...)". Use this to verify your assets are being queued properly.

By understanding the ContentManager’s workflow and using AssetReferences properly, you can load mod assets safely and efficiently, avoiding the pitfalls of Unity’s default loading in a multi-source (base + mod) environment. In summary: declare what you need, let the ContentManager load it for you, and use ContentManager APIs for any dynamic cases. This ensures your mod integrates with Xenonauts 2’s content pipeline and plays nicely with other mods and the game’s performance expectations.

References

  • Xenonauts 2 ContentManager and references system in code: GHI-common repository – e.g., ContentManager scanning static IAssetReference fields and AssetReference usage in game screens.

  • Xenonauts 2 mod loading example (enabling a mod’s assemblies) – shows explicit IsLoaded check and Load call.

⚠️ **GitHub.com Fallback** ⚠️