X2CommunityCore / X2WOTCCommunityHighlander

https://steamcommunity.com/workshop/filedetails/?id=1134256495
MIT License
60 stars 69 forks source link

Allow mods to MCO another MCO #536

Open Xymanek opened 5 years ago

Xymanek commented 5 years ago

Sometimes mods want to change the MCO introduced by another mod. For example, Peek From Concealment UI Fix does this:

[Engine.Engine]
-ModClassOverrides=(BaseGameClass="XComPathingPawn", ModClass="XComPathingPawn_GA")
+ModClassOverrides=(BaseGameClass="XComPathingPawn", ModClass="XComPathingPawn_PeekFix")

But this approach is reliant on the overriding mod being loaded later, which in X2's case is rather messy. LW2 did another approach in their HL where it would allow mods to make recursive entries and then figure out the exact class at runtime - but that required special UC code in place where the object was instantiated.

Now, someone mentioned on discord that the MCO array is editable at runtime. For reference:

struct native ModClassOverrideEntry
{
    var name BaseGameClass;
    var name ModClass;
};

var config array<ModClassOverrideEntry> ModClassOverrides;

So what if we "flatten"/normalize/whatever that array before calling OnPreTemplatesCreated? Pseudo code:

bMatchedSomething = false
do
{
    foreach ModClassOverrides(entry)
    {
        foreach ModClassOverrides(entry2)
        {
            if (entry.ModClass == entry2.BaseGameClass)
            {
                entry2.BaseGameClass = entry.ModClass;
                entry.ModClass = entry2.ModClass;
                bMatchedSomething = true;
                continue;
            }
        }
    }
}
while(bMatchedSomething)

and then the mods could specify something like

+ModClassOverrides=(BaseGameClass="XComPathingPawn_GA", ModClass="XComPathingPawn_PeekFix")
Musashi1584 commented 4 years ago

I added a feature to remove conflicting ModClassOverrides in RPGO and add RPGOs ModClassOverrides dynamically from a different config location. It seems to work fine. Might be a good starting point / reference for this issue:

var config array<ModClassOverrideEntry> ModClassOverrides;

static function OnPreCreateTemplates()
{
    PatchModClassOverrides();
}

static function PatchModClassOverrides()
{
    local Engine LocalEngine;
    local ModClassOverrideEntry MCO;
    local int Index;

    LocalEngine = class'Engine'.static.GetEngine();
    foreach default.ModClassOverrides(MCO)
    {
        LocalEngine.ModClassOverrides.AddItem(MCO);
        `LOG(GetFuncName() @ "Adding" @ MCO.BaseGameClass @ MCO.ModClass,, 'RPG');
    }

    for (Index =  LocalEngine.ModClassOverrides.Length - 1; Index >= 0; Index--)
    {
        MCO =  LocalEngine.ModClassOverrides[Index];

        if (default.ModClassOverrides.Find('BaseGameClass', MCO.BaseGameClass) != INDEX_NONE &&
            default.ModClassOverrides.Find('ModClass', MCO.ModClass) == INDEX_NONE)
        {
            `LOG(GetFuncName() @ "Found incompatible MCO. Removing" @ MCO.BaseGameClass @ MCO.ModClass @ Index,, 'RPG');
                LocalEngine.ModClassOverrides.Remove(Index, 1);
        }
    }
}
Xymanek commented 4 years ago

Damn, that's a heavy-handed solution.

Regardless of the possible side effect (of messing with "affairs" of other mods), the code above "undermines" AML's reporting of MCOs, which many players rely upon

Iridar commented 4 years ago

I tested this method and can confirm it works. robojumper and I have also discovered a couple of important details.

It doesn't appear to be possible to just remove an MCO. Removing an entry only works if you then add another entry that would MCO the same base class.

However, you can emulate the removal by putting the base class name into the ModClass variable:

for (Index = LocalEngine.ModClassOverrides.Length - 1; Index >= 0; Index--)
{
    if (InStr(LocalEngine.ModClassOverrides[Index].ModClass, "UIShell_MCO") != INDEX_NONE)
    {
        LocalEngine.ModClassOverrides[INDEX].ModClass = name("XComGame.UIShell");
    }
}

I have also confirmed that adding a completely new MCO works as well:

static function OnPreCreateTemplates()
{
    local Engine LocalEngine;
    local ModClassOverrideEntry Entry;

    LocalEngine = class'Engine'.static.GetEngine();

    Entry.BaseGameClass = name("UIShell");
    Entry.ModClass = name("WOTCShowAllClassCounts.UIShell_MCO");

    LocalEngine.ModClassOverrides.AddItem(Entry);
}
Iridar commented 3 years ago

Update: it appears the OnPreCreateTemplates() "hack" doesn't work as expected. Real world case:

[WOTC] XSkin has an MCO to UIArmory_WeaponUpgradeItem , which changes weapon upgrades' inventory icons on the weapon upgrade screen if the weapon is reskinned.

This weapon is actually a reskinned conventional shotgun, and without the MCO the icons on the left would look like weapon upgrades for the shotgun.

Remove Weapon Upgrades also has an MCO to the same class, which adds a small cross button to unequip individual weapon upgrades from weapons.

My MCO is not essential for my mod to work, while it is highly useful for the other mod, so I set up the code to detect this conflict remove my MCO:

static function OnPreCreateTemplates()
{
    local Engine    LocalEngine;
    local int       Index;
    local bool      bOtherModHasOverride;
    local name      OtherModClass;

    LocalEngine = class'Engine'.static.GetEngine();

    for (Index = LocalEngine.ModClassOverrides.Length - 1; Index >= 0; Index--)
    {
        if ((LocalEngine.ModClassOverrides[Index].BaseGameClass == 'UIArmory_WeaponUpgradeItem' || 
            LocalEngine.ModClassOverrides[Index].BaseGameClass == name("XComGame.UIArmory_WeaponUpgradeItem")) &&
            LocalEngine.ModClassOverrides[Index].ModClass != name("WOTCXSkin.UIArmory_WeaponUpgradeItem_Override"))
        {
            OtherModClass = LocalEngine.ModClassOverrides[Index].ModClass;
            LocalEngine.ModClassOverrides.Remove(Index, 1);
            `LOG("WARNING, detecting a conflicting Mod Class Override:" @ LocalEngine.ModClassOverrides[Index].ModClass,, 'XSKIN');
            bOtherModHasOverride = true;
            break;
        }
    }
    if (!bOtherModHasOverride)
        return;

    for (Index = LocalEngine.ModClassOverrides.Length - 1; Index >= 0; Index--)
    {
        if (LocalEngine.ModClassOverrides[Index].BaseGameClass == 'UIArmory_WeaponUpgradeItem' && 
            LocalEngine.ModClassOverrides[Index].ModClass == name("WOTCXSkin.UIArmory_WeaponUpgradeItem_Override"))
        {
            `LOG("Disabling XSkin Mod Class Override for UIArmory_WeaponUpgradeItem, running in compatibility mode. Some of the mod's features will be disabled.",, 'XSKIN');
            LocalEngine.ModClassOverrides[Index].ModClass = OtherModClass;
        }
    }
}

Logs confirm the code executes as expected.

However, in game my MCO works and Reusable Weapon Upgrades' doesn't.

I tried alternative implementation, where the code would not remove my MCO, but rather would remove the other MCO, and replace the ModClass of my MCO with the ModClass of the other MCO, but the end result was exact same: my MCO worked, and the other didn't.

In both cases there was no Geoscape lag.

In a discord discussion, MrNice has suggested that perhaps this meant that MCO configuration was cached natively. robojumper had the following to reply with:

That was the historical assumption for a long time. Amineri cited it as one of the reasons to implement a parallel system for overriding some UI classes in the LW2 Highlander.

For the record, when I ran a test without the MCO disabling code, it appeared that my MCO has won the conflict war, and there was the expected lag on the Geoscape.

So while OnPreCreateTemplates() method can successfully combat Geoscape lag caused by MCO conflicts - by the virtue of getting rid of conflicting MCOs - it cannot be used to dictate which MCO survives in the end.

Iridar commented 3 years ago

It's hard to tell whether attempting to execute this code even earlier than OnPreCreateTemplates() would help. At this point it would probably be better to rely on the Alternative Mod Launcher to resolve MCO conflicts. This is only natural, since AML is already relied upon to detect MCO conflicts in the first place. Perhaps mods could ship their own config that would be read by AML to determine which MCO should win out, and then automatically edit XComEngine.ini of other mods.

This is arguably better than handling MCO conflicts through code anyway, because then AML would be reporting MCO conflicts that don't actually exist, further confusing the mod user.

Xymanek commented 3 years ago

I'm sorry, but the above bunch of text is unparsable for me. If you want to prove that an approach works or doesn't please make a minimum possible example (e.g. blank mod with a single command) rather than linking a bunch of mods, screenshots, code extracts, explanations, etc.

My best understanding is that you are talking about a "competing MCOs" situation, e.g.

Source Target
A B
A C

This is not what this issue is about. This issue covers "transitive" MCOs, e.g.

Source Target
A B
B C

With the idea/goal to make the above

Source Target
A C
B C
Iridar commented 3 years ago

Apologies for the off-topic then, I took this issue as "resolving MCO conflicts through script" and ran away with it, although MCOing another MCO seems considerably less practically useful than giving priority to a specific MCO. I'm sorry you find my report lacking, I thought it was well worded. I linked relevant mods just to provide some context, not to make my test easier to reproduce, particularly considering XSkin doesn't even have that MCO, thanks to your help.

Xymanek commented 3 years ago

MCOing another MCO seems considerably less practically useful

It has its uses though - there is one listed in the OP.