Multiplayer Desyncs: Difference between revisions

From Total War Modding
(→‎Speaking of UI!: buffered events cause desync)
m (→‎Speaking of UI!: replaced with lua code blocks)
 
(One intermediate revision by the same user not shown)
Line 22: Line 22:
So, <code>cm:get_local_faction()</code> is a pretty simple function – literally all it does is read what the “local” player’s faction key is. By local player, I mean the player on that instance of the game – the player on that computer.
So, <code>cm:get_local_faction()</code> is a pretty simple function – literally all it does is read what the “local” player’s faction key is. By local player, I mean the player on that instance of the game – the player on that computer.


If I’m playing MP with my buddy, and I’m playing as Reikland, and they’re playing as Hexoatl, and we both run a script that has the following:
If I’m playing MP with my buddy, and I’m playing as Reikland, and they’re playing as Hexoatl, and we both run a script that has the following:<syntaxhighlight lang="lua">
```lua
cm:add_first_tick_callback(function()
cm:add_first_tick_callback(function()
     out(cm:get_local_faction(true))
     out(cm:get_local_faction(true))  
end)
end)
</syntaxhighlight>It would print <code>wh_main_emp_empire</code> on my computer, and <code>whatever_hexoatl's_key_is</code> on my buddy’s. And that’s the end of my explanation of that function.
```
It would print <code>wh_main_emp_empire</code> on my computer, and <code>whatever_hexoatl's_key_is</code> on my buddy’s. And that’s the end of my explanation of that function.


If you read that value, and decide to make a model edit as a result of its return value, it ''will'' desync. See:
If you read that value, and decide to make a model edit as a result of its return value, it ''will'' desync. See:<syntaxhighlight lang="lua">
```lua
cm:add_first_tick_callback(function()
cm:add_first_tick_callback(function()
    if cm:get_local_faction(true) == "wh_main_emp_empire" then
    if cm:get_local_faction(true) == "wh_main_emp_empire" then
        -- spawn a big big army
        -- spawn a big big army
    end
    end
end)
end)
</syntaxhighlight>In this case, my PC would try to spawn a big big army, but my friend’s PC will not want to spawn a big big army, because they aren’t playing as Da Emprah.
```
In this case, my PC would try to spawn a big big army, but my friend’s PC will not want to spawn a big big army, because they aren’t playing as Da Emprah.


Thankfully – it’s incredibly simple to fix! There are two pretty fast ways.
Thankfully – it’s incredibly simple to fix! There are two pretty fast ways.
Line 57: Line 53:
Say you have a button and you want to spawn a unit into an army with it. If all you listen for is the ComponentLClickUp event, the game will desync without batting an eyelash.
Say you have a button and you want to spawn a unit into an army with it. If all you listen for is the ComponentLClickUp event, the game will desync without batting an eyelash.


For a quick for-instance, in Return of the Lichemaster, I have the following code (cut down for simplicity):
For a quick for-instance, in Return of the Lichemaster, I have the following code (cut down for simplicity):<syntaxhighlight lang="lua">
```lua
core:add_listener(
core:add_listener(
    "LichemasterSpawnLegion",
    "LichemasterSpawnLegion",
    "ComponentLClickUp",
    "ComponentLClickUp",
    function(context)
    function(context)
        return context.string == "lichemaster_spawn_button"
        return context.string == "lichemaster_spawn_button"
    end,
    end,
    function(context)
    function(context)
        local selected_cqi = lm:get_character_selected_cqi()
        local selected_cqi = lm:get_character_selected_cqi()
        local key = lm:get_selected_legion()
        local key = lm:get_selected_legion()
        if key == "" then return end
        lm:spawn_ror_for_character(selected_cqi, key)
        if key == "" then
    end,
            return
    false
        end
)
</syntaxhighlight>On <code>lm:spawn_ror_for_character()</code>, I’ve gotta spawn a unit (defined by <code>key</code>, read by which UI component was previously clicked) into a specific army (led by the character defined by <code>selected_cqi</code>). But, since I’m reacting to a UI click, I can’t just directly spawn it – it would immediately desync. So, I do a thing and I send a message to the other PC:<syntaxhighlight lang="lua">
        lm:spawn_ror_for_character(selected_cqi, key)
CampaignUI.TriggerCampaignScriptEvent(selected_cqi, "lichemanager_ror|"..key)
</syntaxhighlight>This function definitely looks like a lot at first glance, but it’s really, really, really simple – all it does is send a message to both PC’s to react to with a traditional listener. The message contains one string and one number. By convention, that number should be a CQI, but sometimes I don’t even use the number while using this function, depending on need.
    end,
    false
)
```
                   
On <code>lm:spawn_ror_for_character()</code>, I’ve gotta spawn a unit (defined by <code>key</code>, read by which UI component was previously clicked) into a specific army (led by the character defined by <code>selected_cqi</code>). But, since I’m reacting to a UI click, I can’t just directly spawn it – it would immediately desync. So, I do a thing and I send a message to the other PC:
 
```lua
CampaignUI.TriggerCampaignScriptEvent(selected_cqi, "lichemanager_ror|"..key)
```
This function definitely looks like a lot at first glance, but it’s really, really, really simple – all it does is send a message to both PC’s to react to with a traditional listener. The message contains one string and one number. By convention, that number should be a CQI, but sometimes I don’t even use the number while using this function, depending on need.


To react to these messages, you can use a listener for a “UITrigger” script event. The context of that event contains the string – <code>context:trigger()</code> – and the number – <code>context:faction_cqi()</code>. It’s called faction_cqi() by convention, since typically that’s how it’s been used by CA, but again, the number can be anything you want.
To react to these messages, you can use a listener for a “UITrigger” script event. The context of that event contains the string – <code>context:trigger()</code> – and the number – <code>context:faction_cqi()</code>. It’s called faction_cqi() by convention, since typically that’s how it’s been used by CA, but again, the number can be anything you want.


To finish out this section, I’ll show how I react to this specific event with a listener (again, cut down to keep it understandable):
To finish out this section, I’ll show how I react to this specific event with a listener (again, cut down to keep it understandable):<syntaxhighlight lang="lua">
```lua
core:add_listener(
core:add_listener(
    "LichemasterLegionOfUndeathSpawn",
    "LichemasterLegionOfUndeathSpawn",
    "UITrigger",
    "UITrigger",
    function(context)
    function(context)
        return context:trigger():starts_with("lichemanager_ror|")
        return context:trigger():starts_with("lichemanager_ror|")
    end,
    end,
    function(context)  
    function(context)
        local str = context:trigger()
        local str = context:trigger()
        local char_cqi = context:faction_cqi()
        local char_cqi = context:faction_cqi()
        local regiment_key = string.gsub(str, "lichemanager_ror|", "")
        local regiment_key = string.gsub(str, "lichemanager_ror|", "")
       
        -- add the unit and charge the -5 NP
        -- add the unit and charge the -5 NP
        cm:grant_unit_to_character("character_cqi:"..char_cqi, regiment_key)
        cm:grant_unit_to_character("character_cqi:"..char_cqi, regiment_key)
        cm:faction_add_pooled_resource(legion, "necropower", "necropower_ror", -5)
        cm:faction_add_pooled_resource(legion, "necropower", "necropower_ror", -5)
    end,
    end,
    true
    true
)
)
</syntaxhighlight>Max length of context:trigger() is 255. TriggerCampaignScriptEvent will only send a cut part of the string (first 255 symbols) and won't send the rest.
```
Max length of context:trigger() is 255. TriggerCampaignScriptEvent will only send a cut part of the string (first 255 symbols) and won't send the rest.


Don't try to send binary data with this method. Same goes to \r \n symbols, you cannot have newlines in your string. Your string will go through some CA buffer handle that is opened in text mode.
Don't try to send binary data with this method. Same goes to \r \n symbols, you cannot have newlines in your string. Your string will go through some CA buffer handle that is opened in text mode.
Line 116: Line 99:
local events = {
local events = {
     "lichemanager_ror|skibidi_key",
     "lichemanager_ror|skibidi_key",
     -- your long list of events
     -- your long list of events (each is no longer than 255)
}
}
-- you need to keep track of how much events you already sent
-- you need to keep track of how many events you already sent
local already_sent = 0
local already_sent = 0
local function send_chunk()
local function send_chunk()
Line 132: Line 115:
     end
     end
end
end
-- in "UITrigger" you wait for previous chunk of events to be send,
-- in "UITrigger" you wait for previous chunk of events to be sent,
-- and then you can start sending next chunk of events.
-- and then you can start sending the next chunk of events.
</syntaxhighlight>And that’s pretty much it!
</syntaxhighlight>And that’s pretty much it!



Latest revision as of 05:55, 8 October 2024

Welcome to this very special tutorial about the dangers of Lua.

Lua is a beautiful piece of machinery that, if used incorrectly, will cause an incredible amount of anger via Steam users, the size of which mob has never before been seen. With some bad luck and bad coding, you can cause a panic through the TW multiplayer campaign community – all six of ’em – and get a deathly riot at your doorstep.

But that’s where this guide you’re already reading comes in! By the end of this wall of text, you might even learn a thing or two.

Why and How Desync

Before we get into how to resolve the desyncs and keep our code from ever being volatile, we should talk about what a desync is, and how they happen.

If you’re one of the aforementioned six multiplayer campaign players who uses mods, you’re probably incredibly familiar with the concept of desyncs. Throughout the course of a multiplayer campaign, there will be an annoying popup box that says “Desync Detected – BEGINNING EXTERMINATION” (paraphrasing). At that point, the game will just kinda stop.

A desync is what happens when the two players’ PC’s decide that different things are happening at the same time – they are no long in sync (not since 2002!). In Total War, both PC’s are playing, technically, different instances of the game, and the game is sending messages back and forth between the two clients to make sure everything is the same on both PC’s. This stuff is usually handled pretty well by the C++ base-code – but, bad Lua coding can cause a desync!

A desync will happen if you cause an edit to the model on one computer without doing the same edit on the other computer. If conditions are different between the two computers, and one performs a model edit and the other doesn’t – kaput, no more synchronization.

Note: A “model” edit is something that makes an edit to the game itself, ie., spawning an army, applying an effect bundle. Doing something like outputting text to a .txt file, or making UI edits will not cause a desync.

Thankfully, there are two really common – and really simple to fix – situations where this might happen: making model edits based on cm:get_local_faction(true)‘s result, and making model edits based on “ComponentX” or “XSelected” events. I’m gonna cover each now and explain how to handle both!

get_local_faction

So, cm:get_local_faction() is a pretty simple function – literally all it does is read what the “local” player’s faction key is. By local player, I mean the player on that instance of the game – the player on that computer.

If I’m playing MP with my buddy, and I’m playing as Reikland, and they’re playing as Hexoatl, and we both run a script that has the following:

cm:add_first_tick_callback(function()
    out(cm:get_local_faction(true))
end)

It would print wh_main_emp_empire on my computer, and whatever_hexoatl's_key_is on my buddy’s. And that’s the end of my explanation of that function. If you read that value, and decide to make a model edit as a result of its return value, it will desync. See:

cm:add_first_tick_callback(function()
    if cm:get_local_faction(true) == "wh_main_emp_empire" then
        -- spawn a big big army
    end
end)

In this case, my PC would try to spawn a big big army, but my friend’s PC will not want to spawn a big big army, because they aren’t playing as Da Emprah.

Thankfully – it’s incredibly simple to fix! There are two pretty fast ways.

1) Use cm:get_local_faction(false) or cm:get_local_faction() when you want stuff to not fire in MP, and cm:get_local_faction(true) only when you’re 100% certain it’s MP-safe (not a model edit).

The optional arg in get_local_faction() determines whether the function will be run in mp or not; if it’s set to false, or nil, then it’ll always return false, causing any if statements based on it to return false.

2) Instead of reading for cm:get_local_faction(true) == "faction_key", use cm:get_faction("faction_key"):is_human(). Instead of checking to see if this PC has this faction, just see if any PC has this faction, and it’s suddenly very MP safe!

You should only really be using cm:get_local_faction() for singleplayer stuff, or for making any sort of UI edits for only specific factions.

Speaking of UI!

The above section is probably gonna cover the vast, vast majority of you. If you’re one of the brave pioneers doing UI modding, this part is pertinent to you. It’s definitely significantly more complicated than the above, but it’s doable!

Essentially – the issue here is that the “ComponentX” events (such as “ComponentLClickUp”, “ComponentMouseOn”) and “XSelected” (such as “SettlementSelected” or “CharacterSelected”) only fire on the PC that the action was taken on. So if I, Emprah Franzipants, click on a component, it’ll only trigger an event on my PC and not my buddy’s. There are many good reasons for this which I don’t really want to get into now, so I won’t.

Say you have a button and you want to spawn a unit into an army with it. If all you listen for is the ComponentLClickUp event, the game will desync without batting an eyelash.

For a quick for-instance, in Return of the Lichemaster, I have the following code (cut down for simplicity):

core:add_listener(
    "LichemasterSpawnLegion",
    "ComponentLClickUp",
    function(context)
        return context.string == "lichemaster_spawn_button"
    end,
    function(context)
        local selected_cqi = lm:get_character_selected_cqi()
        local key = lm:get_selected_legion()
        if key == "" then return end
        lm:spawn_ror_for_character(selected_cqi, key)
    end,
    false
)

On lm:spawn_ror_for_character(), I’ve gotta spawn a unit (defined by key, read by which UI component was previously clicked) into a specific army (led by the character defined by selected_cqi). But, since I’m reacting to a UI click, I can’t just directly spawn it – it would immediately desync. So, I do a thing and I send a message to the other PC:

CampaignUI.TriggerCampaignScriptEvent(selected_cqi, "lichemanager_ror|"..key)

This function definitely looks like a lot at first glance, but it’s really, really, really simple – all it does is send a message to both PC’s to react to with a traditional listener. The message contains one string and one number. By convention, that number should be a CQI, but sometimes I don’t even use the number while using this function, depending on need.

To react to these messages, you can use a listener for a “UITrigger” script event. The context of that event contains the string – context:trigger() – and the number – context:faction_cqi(). It’s called faction_cqi() by convention, since typically that’s how it’s been used by CA, but again, the number can be anything you want.

To finish out this section, I’ll show how I react to this specific event with a listener (again, cut down to keep it understandable):

core:add_listener(
    "LichemasterLegionOfUndeathSpawn",
    "UITrigger",
    function(context)
        return context:trigger():starts_with("lichemanager_ror|")
    end,
    function(context) 
        local str = context:trigger()
        local char_cqi = context:faction_cqi()
        local regiment_key = string.gsub(str, "lichemanager_ror|", "")
        
        -- add the unit and charge the -5 NP
        cm:grant_unit_to_character("character_cqi:"..char_cqi, regiment_key)
        cm:faction_add_pooled_resource(legion, "necropower", "necropower_ror", -5)
    end,
    true
)

Max length of context:trigger() is 255. TriggerCampaignScriptEvent will only send a cut part of the string (first 255 symbols) and won't send the rest.

Don't try to send binary data with this method. Same goes to \r \n symbols, you cannot have newlines in your string. Your string will go through some CA buffer handle that is opened in text mode.

Events are sent approximately every 400-500ms (tested in local sandbox). If you send a lot of data (a lot of events) at the same time, then you need to be careful: CA buffers them, before sending. The estimated length of that buffer is 16384, estimated size of event header is 16 bytes (those are not precise numbers). The buffer size is different for single-player, for some reason. Maybe the buffer size is different, depending on the number of players (this requires extensive testing). If you fill this buffer, then the other client will simply disconnect. This is how to correctly handle it:

local events = {
    "lichemanager_ror|skibidi_key",
    -- your long list of events (each is no longer than 255)
}
-- you need to keep track of how many events you already sent
local already_sent = 0
local function send_chunk()
    local total = 0
    for i = 1+already_sent, #events do
        total = total + 16 + #events[i]
        -- if total > 16384-16-255 then break end
        -- you need to be on the safe side,
        -- because other mods could also be sending events in the same time frame
        if total > 10000 then break end
        CampaignUI.TriggerCampaignScriptEvent(fcqi, events[i])
        already_sent = already_sent + 1
    end
end
-- in "UITrigger" you wait for previous chunk of events to be sent,
-- and then you can start sending the next chunk of events.

And that’s pretty much it!

Conclusions

There are likely some other very rare cases where desyncs might happen, but if you avoid these two specific issues, you’re pretty assured to avoid almost all possible desyncs from your own Lua code. They’re the only two issues I’ve ever hit, and resolving these two issues has made basically all my stuff pretty MP compat!