Making your first scripted mod: High Ranked Chaos Armies

Modding guides & discussions for the latest, wonkiest game made by CA yet
Locked
User avatar
FiftyTifty
Site Admin
Posts: 31
Joined: 27 Jun 2023, 12:50

Making your first scripted mod: High Ranked Chaos Armies

Post by FiftyTifty »

Never done coding before? Completely confused by functions, listeners, variables? What even are functions? The hell is Lua?

Then this tutorial is for you. The only things that will be assumed are:

1. That you've downloaded Rusted Pack File Manager and followed the instructions on setting that up.
2. Same for Visual Studio Code

That's it. I will make a post detailing the setup of those two tools at a later date. For now, let's get the interesting stuff noted down.

The general process is:
  • Create a .pack file
    Create the standard folder path so our script will be loaded for all campaigns
    Create a uniquely named .lua file in that folder
    Create & Add the lua code to it
First off, launch RPFM. You'll be greeted with the following interface:

Image

First, we need to create a .pack file. This file is the container for all of your mod's data, and is what will be uploaded to the Steam Workshop once the mod is finished. To do this, navigate to: File -> New Packfile. This will create a blank .pack file called unknown.pack.

Image

Next, right-click on unknown.pack -> Create -> Folder -> script -> New Folder

Image

Image

Repeat the above process, to create the following folder path: script\campaign\mod

Image

Right-click on the folder mod -> Create -> Create Text -> Name the script whatever you want. For this tutorial we'll give it some generic name, but you should name it something unique. For the original mod that I made, I called it fyty_high_ranked_chaos_armies.lua. Then click Ok to create the .lua file.

Image

Image

Now click on the .lua file we created, and RPFM should now look like this, if you did things properly:

Image
User avatar
FiftyTifty
Site Admin
Posts: 31
Joined: 27 Jun 2023, 12:50

Re: Making your first scripted mod: High Ranked Chaos Armies

Post by FiftyTifty »

Now we actually need to add the code. Daunting? Yep! But we'll get it done, and I'll add comments throughout. So first things first, let's make the basic framework which we'll pad out in the next post.

Since this is a lot for a first time modder, I've made sure to comment everything so it's as clear as possible. Don't worry if you don't completely understand, as coding is a skill like any other. Reading can only take you so far; you'll do most of your learning by actually doing the thing, not reading the thing.

Lastly, the code below is not easy to read outside of an IDE, so I recommend copying the text over to VS Code. Or Notepad++, at least, as it will highlight the syntax to make things a bit more readable.

Below the commented version, is one without the comments.

Code: Select all

--We define a variable, that can only be referenced inside this .lua file
--Unless you know what you're doing and have a good reason, ALWAYS use local variables
local factionList

local function LevelChaosArmies()

    --Put the code here

end

--After the callback, this is the next part of our code that will be run
local function fyty_high_ranked_chaos_armies_listeners()

		--We set this variable to actually have data, which is the list of factions in the game
		--This will only be run once per session, as it's not inside the listener, but the actual function which THEN creates the listener
    factionList = cm:model():world():faction_list()

		--Now we add a listener. They all follow this format.
		--The first string is our unique name for this listener
		--The second is the event we want to listen for. In this case, the start of every turn. So we use WorldStartRound.
		--Next is a condition function. If this passes, the next function is called. Otherwise, it doesn't fire, and will be checked again next turn.
		--The next function is the code we actually want to run. Where we put our intensive stuff.
		--Lastly, is a true/false value. If false, the event will not persist after successfully running (condition function passes)
		--If it's true, it will always run every time the event is called.
    core:add_listener(
        "fyty_high_ranked_chaos_armies_WorldStartRound",
        "WorldStartRound",
        function(context)

						--If the current turn is greater than 0 (the game is actually set up and running)
            return cm:turn_number() > 0

        end,
        function(context)

						--Output the following message to the console log, so we can see if this function is ever called
            out("FyTyHighRankedChaosArmies - Event!")

						--Now call our other function, which does all the actual game-changing stuff we want to do.
            LevelChaosArmies()

        end,

    true
    );


end

--[[
This is one of the few functions defined by CA, which actually run on their own.
These types of functions are called "callbacks". Basically it's something we call, and the game then runs the code we put inside of it.
So what are we doing here? We're adding a call to our event listener, inside this callback.

If we just defined the above functions without calling them inside the callback bellow?
Nothing would happen. No code would be run.
--]]

--Internal game function
cm:add_first_tick_callback(function()
	fyty_high_ranked_chaos_armies_listeners() --Our script's listener function
end);

Code: Select all

local factionList

local function LevelChaosArmies()

end

local function fyty_high_ranked_chaos_armies_listeners()

    factionList = cm:model():world():faction_list()

    core:add_listener(
        "fyty_high_ranked_chaos_armies_WorldStartRound",
        "WorldStartRound",
        function(context)

            return cm:turn_number() > 0

        end,
        function(context)

            out("FyTyHighRankedChaosArmies - Event!")

            LevelChaosArmies()

        end,

    true
    );


end

cm:add_first_tick_callback(function()
	fyty_high_ranked_chaos_armies_listeners()
end);
User avatar
FiftyTifty
Site Admin
Posts: 31
Joined: 27 Jun 2023, 12:50

Re: Making your first scripted mod: High Ranked Chaos Armies

Post by FiftyTifty »

Now the next step is to copy it over to your pack file. Although our code won't actually change anything, it will output a message to the console log file that's created every time you play the game. Let's do that now.

Copy the script into your my_new_script.lua file, by using the good ol' copy-paste.
Then save the pack file, which will allow you to give it an actual name. See the images below:

Image

Image

Image
User avatar
FiftyTifty
Site Admin
Posts: 31
Joined: 27 Jun 2023, 12:50

Re: Making your first scripted mod: High Ranked Chaos Armies

Post by FiftyTifty »

When you save it to your data folder, you will need to look at your mod manager (either a 3rd party one, or the launcher built into the game) and enable the mod.

But before you click that big play button, we should enable the console log so we can see our script actually run. To do this:

In Steam, right-click Total War Warhammer III -> Manage -> Browse Local Files

Image

This will open the folder where TWW3 is installed. There, you will find a folder called "Data". Double click it, and you'll see something that looks like this:

Image

Right-click -> New -> Folder -> Name the folder script

Image

Go inside the script folder, and create a new .txt file.

Image

Call it enable_console_logging and make sure to remove the extension, so it's now just a plain file with no type.

Image

Image

(When I want to toggle script logging, I just rename the folder to something like scriptb to turn it off. And then to turn it on, put it back to script)

Now you can launch the game. Start a campaign, let it load, and wait until your turn has started. If you go back to the game's root folder (where the TWW3 executable is), you'll see a large .txt file has been created with a name similar to script_log_280623_1946.txt

This is the script log, and will contain thousands upon thousands of lines. If you did everything properly, we should find the text we printed to the log in there. Remember this line in the script?

Code: Select all

out("FyTyHighRankedChaosArmies - Event!")
Somewhere in that text file, is the text inside those brackets: FyTyHighRankedChaosArmies - Event!

Open it up in your favourite text editor (Notepad is the default Windows text editor, but it's bad. I recommend downloading Notepad++ instead), and search for that text. You'll find a match, in amongst all the different lines of text.

What does this mean? We've just kinda-sorta debugged our script. We created a basic framework, enabled script logging, added a log entry, and found it in the debug log. Sounds like a lot, and it is for people new to this sort of thing. But as you continue making mods and making scripts, this gets easier and more intuitive. Just like any skill innit.

Onto the next stage!
User avatar
FiftyTifty
Site Admin
Posts: 31
Joined: 27 Jun 2023, 12:50

Re: Making your first scripted mod: High Ranked Chaos Armies

Post by FiftyTifty »

Now that we have:

1. Created a .pack file
2. Properly added a script to it
3. Created a basic framework for our code
4. Added logs to make sure our code is running
5. Enabled logging
6. Checked the logs to see if the code is actually running

It's time to create our Effect Bundle!

These are a type of game data, which contains various effects. We can create an effect bundle, and assign to it things such as disable/enable/decrease/increase casualty replenishment, +/- leadership, things like that. And we don't have to touch any code for that, outside of adding the effect bundle to the faction (targets can be anything; units, characters, regions, settlements, etc) itself.

What we are going to do, is something nifty. We're going to create an effect bundle with no effects attached, as a way to tell if we've already i

To do this, we need to create 2 db tables:
effects_bundles
And
effect_bundles_to_effect_junctions

To do this: Right-click on the .pack file -> Create... -> Create DB -> Type in effect_bundles in the bottom text entry bit, to filter out 99% of the table names -> Select effect_bundles_tables from the drop-down

Image

Image

This will create the effect_bundles db table. The name by default is the same as your .pack file. Select the db table, and you'll see it on the right-hand pane. There will be nothing in it, so you can either add a blank row (right-click -> Add Row) and copy-paste the text below into it. Or just copy the text and press ctrl+shift+v in rpfm to add a new row and paste the code at the same time.

Image

Image

Code: Select all

aaafyty_high_ranked_chaos_armies		0	 	1		true	true	false
Notice the tab spacing in that line of text. The db table is basically a collection of strings, with each column's string being separated by a tab. If we select a row (or even multiple rows!) we copy that exact formatting. And we can paste data back with the same formatting too. Which saves having to manually enter everything.

Keep note of the effect bundle key. In my case, I have it as aaafyty_high_ranked_chaos_armies but you may have something different. We will be using this in our code when applying the effect bundle to the chaos factions.

Now, time to do the same process but for the effect_bundles_to_effect_junctions db table.

Image

And here's the text, to save you some time:

Code: Select all

aaafyty_high_ranked_chaos_armies	wh_main_effect_force_all_campaign_experience_base_all	faction_to_force_own	9.0000	start_turn_completed
Looks really arcane, huh? Let's break it down.

The first entry is the key of the effect bundle we created earlier. The second is the key of the effect we want. There are lots of effects, and you can see them by double-clicking on the Effect Key entry, which will present a drop-down list you can pick from.

Image

The third key is the Effect Scope. It's a jargon term for source -> destination. Our effect bundle will be applied to a faction, and we want it to effect every army in that same faction. So we use the scope of faction to force own: Add the bundle to the faction -> Apply the effect to the faction's army forces.

Finally, we move onto the last stage.
User avatar
FiftyTifty
Site Admin
Posts: 31
Joined: 27 Jun 2023, 12:50

Re: Making your first scripted mod: High Ranked Chaos Armies

Post by FiftyTifty »

Now it's time to make use of our effect bundle.

Remember that script we made earlier? Here's it with the code to add our effect bundle to chaos factions with specific subcultures.

Commented version:

Code: Select all

--We define a variable, that can only be referenced inside this .lua file
--Unless you know what you're doing and have a good reason, ALWAYS use local variables

--By assigning the variable to {}, we are setting that variable to a table.
--A table is a very versatile way of storing data, like a big collection of variables.
--For our use case, we add an entry to the table by declaring a string "key", and giving it the value of "true"
--Notice what the string key we set actually is: The subcultures of various chaos factions
--By doing that, we can simply get a faction's subculture, throw it at the table, and if we have a value of "true"..
--We know that the faction is one of the chaos subcultures we want.
local tableChaosSubCultures = {}

--See: factions_tables in RPFM to find which factions have which subculture
tableChaosSubCultures["wh3_main_pro_sc_kho_khorne"] = true
tableChaosSubCultures["wh3_main_pro_sc_tze_tzeentch"] = true
tableChaosSubCultures["wh3_main_sc_dae_daemons"] = true
tableChaosSubCultures["wh3_main_sc_kho_khorne"] = true
tableChaosSubCultures["wh3_main_sc_nur_nurgle"] = true
tableChaosSubCultures["wh3_main_sc_sla_slaanesh"] = true
tableChaosSubCultures["wh3_main_sc_tze_tzeentch"] = true
tableChaosSubCultures["wh_main_sc_chs_chaos"] = true

--This is the key of the effect bundle, which we apply to all chaos factions
local strBundle = "aaafyty_high_ranked_chaos_armies"

--Define the variable at the beginning of the script, as it will be used in both the listeners, and the LevelChaosArmies function
--We don't assign it anything yet, as the game has not created all the faction data yet
local factionList

--We call this from our event listener, and it will do the actual work.
--By isolating the code to a function, it's tidier and easier to make changes without accidentally screwing up the listener
local function LevelChaosArmies()

		--As we assigned factionList in the listener, we can now use it here
		--We start at 0, because we're accessing C++ game stuff here, and arrays start at 0 in C++
		--So the Item 1 is at index 0, and (for example) the last item 269 is at index 268.
		--Because C++ arrays start at 0.
		--So! Let's iterate through every faction in the game. To try and make it more human english:
		--
		--for FactionIndexNumber (starts at 0), get the total number of factions and subtract 1 to get the index,
		--and increase FactionIndexNumber by 1, after we finish with the current faction
		--So we can get the next faction in the list
    for iCounter = 0, factionList:num_items() - 1, 1 do

				--We get the current faction by using the index assigned to iCounter, and put it into a variable
        local factionCurrent = factionList:item_at(iCounter)

				--Make sure the current faction is alive and a valid game faction (weirdness happens when accessing factions that don't exist in both campaigns
				--And also check the subculture, if it's in the table we made earlier, we'll get "true", so the if check passes
        if (factionCurrent ~= nil and factionCurrent:is_null_interface() == false)
        and (tableChaosSubCultures[factionCurrent:subculture()]) then

						--Now get all the characters (heroes, generals, etc) of the current faction
            local characterList = factionCurrent:character_list()

						--Also see if we've given the faction the buff yet. If not
            if factionCurrent:has_effect_bundle(strBundle) == false then

								--Add the buff
                cm:apply_effect_bundle(strBundle, factionCurrent:name(), -1)
								--Output to the log that we've applied the buff
                out("FyTyHighRankedChaosArmies - Applied effect bundle!")

            end

						--Now we iterate through every character for the current faction
            for iSubCounter = 0, characterList:num_items() - 1, 1 do

								--Get the current character
                local charCurrent = characterList:item_at(iSubCounter)

								--Generate a "Lookup String", which is a string to identify the character in a way that some functions require
								--Some need a look up string, others need a command_queue_index, and some others need the actual character data itself
								--But the two functions we are using need a Lookup String to identify the character
								--So we pass the character data to the game's cm:char_lookup_str() function
                local strLookup = cm:char_lookup_str(charCurrent)

								--Output to the log so we can check if we got this far in the code
                out("FyTyHighRankedChaosArmies - Giving bonus!")
                out("FyTyHighRankedChaosArmies - " ..strLookup)

								--Add 9 ranks to all the units in the army (cap is 9, so if we add nine to 8, the unit will be rank 9, not rank 17)
                cm:add_experience_to_units_commanded_by_character(strLookup, 9)
								
								--And add 50 levels (cap is 50) to the current character
                cm:add_agent_experience(strLookup, 50, true)

            end            

        end

    end

end

--After the callback, this is the next part of our code that will be run
local function fyty_high_ranked_chaos_armies_listeners()

		--We set this variable to actually have data, which is the list of factions in the game
		--This will only be run once per session, as it's not inside the listener, but the actual function which THEN creates the listener
    factionList = cm:model():world():faction_list()

		--Now we add a listener. They all follow this format.
		--The first string is our unique name for this listener
		--The second is the event we want to listen for. In this case, the start of every turn. So we use WorldStartRound.
		--Next is a condition function. If this passes, the next function is called. Otherwise, it doesn't fire, and will be checked again next turn.
		--The next function is the code we actually want to run. Where we put our intensive stuff.
		--Lastly, is a true/false value. If false, the event will not persist after successfully running (condition function passes)
		--If it's true, it will always run every time the event is called.
    core:add_listener(
        "fyty_high_ranked_chaos_armies_WorldStartRound",
        "WorldStartRound",
        function(context)

						--If the current turn is greater than 0 (the game is actually set up and running)
            return cm:turn_number() > 0

        end,
        function(context)

						--Output the following message to the console log, so we can see if this function is ever called
            out("FyTyHighRankedChaosArmies - Event!")

						--Now call our other function, which does all the actual game-changing stuff we want to do.
            LevelChaosArmies()

        end,

    true
    );


end

--[[
This is one of the few functions defined by CA, which actually run on their own.
These types of functions are called "callbacks". Basically it's something we call, and the game then runs the code we put inside of it.
So what are we doing here? We're adding a call to our event listener, inside this callback.

If we just defined the above functions without calling them inside the callback bellow?
Nothing would happen. No code would be run.
--]]

--Internal game function
cm:add_first_tick_callback(function()
	fyty_high_ranked_chaos_armies_listeners() --Our script's listener function
end);
Uncommented version:

Code: Select all

local tableChaosSubCultures = {}

tableChaosSubCultures["wh3_main_pro_sc_kho_khorne"] = true
tableChaosSubCultures["wh3_main_pro_sc_tze_tzeentch"] = true
tableChaosSubCultures["wh3_main_sc_dae_daemons"] = true
tableChaosSubCultures["wh3_main_sc_kho_khorne"] = true
tableChaosSubCultures["wh3_main_sc_nur_nurgle"] = true
tableChaosSubCultures["wh3_main_sc_sla_slaanesh"] = true
tableChaosSubCultures["wh3_main_sc_tze_tzeentch"] = true
tableChaosSubCultures["wh_main_sc_chs_chaos"] = true

local strBundle = "aaafyty_high_ranked_chaos_armies"

local factionList

local function LevelChaosArmies()

    for iCounter = 0, factionList:num_items() - 1, 1 do

        local factionCurrent = factionList:item_at(iCounter)

        if (factionCurrent ~= nil and factionCurrent:is_null_interface() == false)
        and (tableChaosSubCultures[factionCurrent:subculture()]) then

            local characterList = factionCurrent:character_list()

            if factionCurrent:has_effect_bundle(strBundle) == false then

                cm:apply_effect_bundle(strBundle, factionCurrent:name(), -1)
                out("FyTyHighRankedChaosArmies - Applied effect bundle!")

            end

            for iSubCounter = 0, characterList:num_items() - 1, 1 do

                local charCurrent = characterList:item_at(iSubCounter)

                local strLookup = cm:char_lookup_str(charCurrent)

                out("FyTyHighRankedChaosArmies - Giving bonus!")
                out("FyTyHighRankedChaosArmies - " ..strLookup)

                cm:add_experience_to_units_commanded_by_character(strLookup, 9)
                cm:add_agent_experience(strLookup, 50, true)

            end            

        end

    end

end

function fyty_high_ranked_chaos_armies()

    factionList = cm:model():world():faction_list()

    core:add_listener(
        "fyty_high_ranked_chaos_armies_WorldStartRound",
        "WorldStartRound",
        function(context)

            return cm:turn_number() > 0

        end,
        function(context)

            out("FyTyHighRankedChaosArmies - Event!")

            LevelChaosArmies()

        end,

    false
    );


end

cm:add_first_tick_callback(function() fyty_high_ranked_chaos_armies() end);
At this point, you may be wondering how the hell I know which functions to call, what to give to them, and how to access properties. Complex, cryptic, and unintuitive stuff? Yep, it's code. Fortunately, we have some lovely documentation from the Total War modding community!

For those lovely cm:whatever_function() functions, this doc is a non-exhaustive list of the cool stuff: https://chadvandy.github.io/tw_modding_ ... nager.html

And for that property stuff, like listFactions:item_at() ? We have those here: https://chadvandy.github.io/tw_modding_ ... g_doc.html

Yes, it's a lot. No, I'm not going to be an egotistical programmer and say you have to read every single line, along with the wikipedia article on the complete history of programming, and that you should go to University before you ever get to touch the sweet precious of programming that's only for the cool boy kids club and no icky-casuals or girls allowed because yadayadayadayada

(No, I'm not bitter. Why do you ask?)

What I will recommend, is looking up some of the things used in the script I've shared, so you can see what I'm using (and how!) to achieve my goal of giving chaos factions rank 9 units and level 50 characters every turn.
User avatar
FiftyTifty
Site Admin
Posts: 31
Joined: 27 Jun 2023, 12:50

Re: Making your first scripted mod: High Ranked Chaos Armies

Post by FiftyTifty »

And with that, all that is left is to create an image for the mod before you can upload it to the workshop. Pretty simple, it just needs to have the same name as your .pack file. It has to be a pretty small image, as anything over a couple hundred KB will cause the launcher to throw an error.

Lucky for me, I can't be bothered (most of the time, anyway) to make a fancy-pants image. I just screenshot Wordpad after typing some words in. Lo', and behold:

Image

And with that, all that's left is to open the launcher, and upload your mod.
Locked