Borderlands 3 Item and Weapon Parts - BLCM/BLCMods GitHub Wiki

Weapons and Items in Borderlands 3 are constructed a little differently than their BL2/TPS counterparts. BL2 and TPS had very distinct part slots/categories which were the same for all weapons, or the same for all shields, etc, whereas BL3 supports a much more dynamic system which allows for any number of part categories. This is how CoV guns have a separate category for their engine-starter, and Vladof guns have a separate category for their underbarrel attachment.

Additionally, for modding purposes at least, the part lists are all defined in one easy object, instead of potentially being split out across multiple objects (as they were for shields, for instance). The definitions for gear are also specified the same way regardless of whether it's a weapon or an item, which is nice from a parsing perspective.

Most of this is pretty straightforward and intuitive -- for the most part you can probably just start looking at the data and get a feel for what's there. A few of the interactions between the various objects may not be obvious, though, so it makes sense to go through it all anyway.

Accessing this Data

The data that this page uses has been taken from JohnWickParse serializations of unpacked BL3 data. The wiki page on Accessing Borderlands 3 Data describes how to do the unpacking and get the serializations, in case you wanted to look through yourself. Fortunately, the objects that we need to look at for gear construction tend to serialize pretty well.

InventoryBalanceData Objects

The "starting point" for digging into how gear is generated, and the main object that you'd need to be editing to do modding-type activity on the gear, is the InventoryBalanceData object. This object contains the runtime parts list for the weapon or item, as well as the categories which all the parts are sorted into. This page will mostly be looking at data from the Lyuda sniper rifle, whose Balance can be found at /Game/Gear/Weapons/SniperRifles/Vladof/_Shared/_Design/_Unique/Lyuda/Balance/Balance_VLA_SR_Lyuda.

The main attribute that we're generally concerned with is RuntimePartList, which has a few sub-attributes of its own. One of them, AllParts, contains the full list of parts which are valid for the given balance. For instance, the Lyuda's RuntimePartList.AllParts list starts out like this (though I've trimmed out some unnecessary info and simplified the Weight attribute):

[
  {
    "PartData": [
      "Part_SR_VLA_Body",
      "/Game/Gear/Weapons/SniperRifles/Vladof/_Shared/_Design/Parts/Body/Part_SR_VLA_Body"
    ],
    "Weight": 1,
  },
  {
    "PartData": [
      "Part_SR_VLA_Body_A",
      "/Game/Gear/Weapons/SniperRifles/Vladof/_Shared/_Design/Parts/Body/Part_SR_VLA_Body_A"
    ],
    "Weight": 1,
  },
  {
    "PartData": [
      "Part_SR_VLA_Body_B",
      "/Game/Gear/Weapons/SniperRifles/Vladof/_Shared/_Design/Parts/Body/Part_SR_VLA_Body_B"
    ],
    "Weight": 1,
  },
  {
    "PartData": [
      "Part_SR_VLA_Body_C",
      "/Game/Gear/Weapons/SniperRifles/Vladof/_Shared/_Design/Parts/Body/Part_SR_VLA_Body_C"
    ],
    "Weight": 1,
  },
  {
    "PartData": [
      "Part_SR_VLA_Barrel_Lyuda",
      "/Game/Gear/Weapons/SniperRifles/Vladof/_Shared/_Design/_Unique/Lyuda/Parts/Part_SR_VLA_Barrel_Lyuda"
    ],
    "Weight": 1,
  },
  {
    "PartData": [
      "Part_SR_VLA_Barrel_03_C",
      "/Game/Gear/Weapons/SniperRifles/Vladof/_Shared/_Design/Parts/Barrel/Barrel_03/Part_SR_VLA_Barrel_03_C"
    ],
    "Weight": 1,
  },
  ...
]

So the possible parts include a standard Vladof body, three Vladof body augments/mods, the Lyuda barrel (which provides the Lyuda's special abilities, just like BL2/TPS barrels generally do), and one possible barrel augment/mod.

The RuntimePartList.AllParts list itself doesn't actually contain any information about how those parts should be grouped, though. We can look at it as a human and see some obvious patterns, but the game itself needs things laid out a bit more explicitly.

The structure which does that is also in RuntimePartList, and is called PartTypeTOC. This Table of Contents attribute starts out like so:

[
  {
    "StartIndex": 0,
    "NumParts": 1
  },
  {
    "StartIndex": 1,
    "NumParts": 3
  },
  {
    "StartIndex": 4,
    "NumParts": 1
  },
  {
    "StartIndex": 5,
    "NumParts": 1
  },
  ...
]

Arrays/lists in BL3 start with an index of 0, just like they did in BL2/TPS. So that first StartIndex/NumParts pair is saying that starting with the first part in the list, the category is comprised of a single part. That'll be the Vladof body we mentioned above.

The next section starts at index 1 (so, the second entry in RuntimePartList.AllParts), and contains three total parts. So, those are the body mods/augments that we mentioned above. Then so on down the list: one barrel, one barrel mod, and so on.

So, now we've got a method to parse out the parts and find out what groupings there are. However, the Balance object does not give us information about how those parts are selected, such as how many parts are allowed to be selected in the category, if the Weight attributes should be considered, etc. For that, we need to look at a PartSet object.

PartSet Objects

In the Balance object, you'll see an attribute named PartSetData, which in the Lyuda's case points us to the object /Game/Gear/Weapons/SniperRifles/Vladof/_Shared/_Design/_Unique/Lyuda/Balance/PartSet_VLA_SR_Lyuda. This is the PartSet object which provides the extra information that we'll need.

If we take a look at the JWP serialization for that object, one attribute in particular stands out: ActorPartLists. If we take a look at the serialization for that object (again, with various things trimmed out and simplified for clarity's sake), it looks like this:

[
  {
    "PartType": 0,
    "bCanSelectMultipleParts": false,
    "bUseWeightWithMultiplePartSelection": false,
    "MultiplePartSelectionRange": { "Min": 0, "Max": 0 },
    "bEnabled": true,
    "Parts": [
      {
        "PartData": [ 
          "Part_SR_VLA_Body",
          "/Game/Gear/Weapons/SniperRifles/Vladof/_Shared/_Design/Parts/Body/Part_SR_VLA_Body"
        ],
        "Weight": 1
      }
    ]
  },
  {
    "PartType": 1,
    "bCanSelectMultipleParts": true,
    "bUseWeightWithMultiplePartSelection": false,
    "MultiplePartSelectionRange": { "Min": 3, "Max": 3 },
    "bEnabled": true,
    "Parts": [
      {
        "PartData": [
          "Part_SR_VLA_Body_A",
          "/Game/Gear/Weapons/SniperRifles/Vladof/_Shared/_Design/Parts/Body/Part_SR_VLA_Body_A"
        ],
        "Weight": 1
      },
      {
        "PartData": [
          "Part_SR_VLA_Body_B",
          "/Game/Gear/Weapons/SniperRifles/Vladof/_Shared/_Design/Parts/Body/Part_SR_VLA_Body_B"
        ],
        "Weight": 1
      },
      {
        "PartData": [
          "Part_SR_VLA_Body_C",
          "/Game/Gear/Weapons/SniperRifles/Vladof/_Shared/_Design/Parts/Body/Part_SR_VLA_Body_C"
        ],
        "Weight": 1
      }
    ]
  },
  {
    "PartType": 2,
    "bCanSelectMultipleParts": false,
    "bUseWeightWithMultiplePartSelection": false,
    "MultiplePartSelectionRange": { "Min": 0, "Max": 0 },
    "bEnabled": true,
    "Parts": [
      {
        "PartData": [
          "Part_SR_VLA_Barrel_Lyuda",
          "/Game/Gear/Weapons/SniperRifles/Vladof/_Shared/_Design/_Unique/Lyuda/Parts/Part_SR_VLA_Barrel_Lyuda"
        ],
        "Weight": 1
      }
    ]
  },
  {
    "PartType": 3,
    "bCanSelectMultipleParts": true,
    "bUseWeightWithMultiplePartSelection": false,
    "MultiplePartSelectionRange": { "Min": 2, "Max": 2 },
    "bEnabled": true,
    "Parts": [
      {
        "PartData": [
          "Part_SR_VLA_Barrel_03_C",
          "/Game/Gear/Weapons/SniperRifles/Vladof/_Shared/_Design/Parts/Barrel/Barrel_03/Part_SR_VLA_Barrel_03_C"
        ],
        "Weight": 1
      }
    ]
  },
  ...
]

A few things stand out right away. One is that it looks like this is a more convenient way to look at the valid parts: they're already arranged into groups in a nice convenient way. Unfortunately, the actual parts listed inside the PartSet object are basically ignored by the game by the time we can look at them in-game.

From a technical perspective, what happens when the game loads these objects is that the PartSet is used to dynamically generate the RuntimePartList attribute of the Balance. The on-disk versions of the RuntimePartList arrays happen to match the results of this process in 99% of cases, so they can generally be trusted. By the time we have any access to the objects ingame (such as via getall on the console, or with hotfix modding), this dynamic generation has already taken place, so altering the Parts lists inside the PartSet object doesn't actually accomplish anything -- at that point, you've got to take a look at RuntimePartList on the Balance instead. See below for some details on the generation process, if you're interested, because it jumps through a few hoops to do so.

The other thing that probably stands out is that the PartSet does contain all the extra informaion that we'd need about the groupings. For instance, the Body and Barrel categories both specify a bCanSelectMultipleParts of False, meaning that only one part can be selected from that group. (In this case, there's only one part in the category anyway, but for other guns/categories there could be more.)

Additionally, all the categories shown above have bUseWeightWithMultiplePartSelection set to False, meaning that the weights specified on the parts are completely ignored when selecting multiple parts. In the Lyuda's case this wouldn't matter anyway, since everything has a weight of 1, but in some cases it could be important. Remember that when the part weights are used, they're using the weight found in the Balance, not the PartSet. The PartSet part lists are always ignored by the game.

Next up, there's MultiplePartSelectionRange, which tells the game how many parts from the category can be selected. Note that the same part cannot be chosen twice out of the pool. So even though the barrel mod category says that there should be exactly 2 parts chosen from the category, the gun will still only receive one Part_SR_VLA_Barrel_03_C. Likewise, the Lyuda will always receive one of each body mod/augment, because the MultiplePartSelectionRange specifies that there must be exactly 3 parts, and there are only 3 parts in that parts list. If bCanSelectMultipleParts is False for a category, then this MultiplePartSelectionRange is ignored.

Items which do show up as having more than one of the same part while in-game (such as grenade mods and shields) do this by actually specifying the same parts more than once in the category. So a shield which has three "Brimming" aguments actually had that attribute three times in the part pool.

You may notice the bEnabled flag in there as well, but that flag is only used by the engine while the PartSet data is getting transformed into the Balance's RuntimePartList attribute, as the objects get loaded. Changing this value with hotfix modding or the like won't actually accomplish anything, since that process has already finished by that time.

So, we nearly know everything we need to know about how gear is constructed, but there's one more wrinkle, in the part objects themselves.

Part Objects: Dependencies and Excluders

The final wrinkle to gear creation is that each Part object might specify some additional requirements in order to be added (or not added) to a weapon or item. As one example, we can look down to the Lyuda's Scope Accessory category, which has six total options in it. The entry in the PartSet looks like this (the parts listed in the Balance are identical, so we're just showing the PartSet for simplicity's sake):

{
  "PartType": 8,
  "bCanSelectMultipleParts": true,
  "bUseWeightWithMultiplePartSelection": false,
  "MultiplePartSelectionRange": { "Min": 2, "Max": 2 },
  "bEnabled": true,
  "Parts": [
    {
      "PartData": [
        "Part_SR_VLA_Scope_01_A",
        "/Game/Gear/Weapons/SniperRifles/Vladof/_Shared/_Design/Parts/Scope/Scope_01/Part_SR_VLA_Scope_01_A"
      ],
      "Weight": 1
    },
    {
      "PartData": [
        "Part_SR_VLA_Scope_01_B",
        "/Game/Gear/Weapons/SniperRifles/Vladof/_Shared/_Design/Parts/Scope/Scope_01/Part_SR_VLA_Scope_01_B"
      ],
      "Weight": 1
    },
    {
      "PartData": [
        "Part_SR_VLA_Scope_02_A",
        "/Game/Gear/Weapons/SniperRifles/Vladof/_Shared/_Design/Parts/Scope/Scope_02/Part_SR_VLA_Scope_02_A"
      ],
      "Weight": 1
    },
    {
      "PartData": [
        "Part_SR_VLA_Scope_02_B",
        "/Game/Gear/Weapons/SniperRifles/Vladof/_Shared/_Design/Parts/Scope/Scope_02/Part_SR_VLA_Scope_02_B"
      ],
      "Weight": 1
    },
    {
      "PartData": [
        "Part_SR_VLA_Scope_03_A",
        "/Game/Gear/Weapons/SniperRifles/Vladof/_Shared/_Design/Parts/Scope/Scope_03/Part_SR_VLA_Scope_03_A"
      ],
      "Weight": 1
    },
    {
      "PartData": [
        "Part_SR_VLA_Scope_03_B",
        "/Game/Gear/Weapons/SniperRifles/Vladof/_Shared/_Design/Parts/Scope/Scope_03/Part_SR_VLA_Scope_03_B"
      ],
      "Weight": 1
    }
  ]
},

At first glance, it would look like there's lots of possible combinations there. The category specifies exactly two parts, and there's six total, which would lead to fifteen total combinations of parts. However, if we look at the serialization for, say, Part_SR_VLA_Scope_01_A, we'll see this among its attributes:

"Dependencies": [
  [
    "Part_SR_VLA_Scope_01",
    "/Game/Gear/Weapons/SniperRifles/Vladof/_Shared/_Design/Parts/Scope/Scope_01/Part_SR_VLA_Scope_01"
  ]
],

This means that Part_SR_VLA_Scope_01_A will only ever spawn if the gun already has the part Part_SR_VLA_Scope_01. The other scope accessory objects call have the same situation: accessories with 01 require the 01 scope, accessories with 02 require the 02 scope, and so on. So really, for each scope that the Lyuda can spawn with, there's only one possible combination of scope accessories, since there are two per scope, and the ActorPartLists object wants exactly two. This is and extremely common scenario which you'll see pretty frequently.

The other attribute which might show up in a Part object is Excluders, which does the opposite: if a part in its Excluders list is already assigned to the weapon/item, then that part will not be a valid part on that bit of gear. The Lyuda technically has one part which does, this. Specifically, its single barrel accessory Part_SR_VLA_Barrel_03_C has the following three parts in its Excluders list:

  • Part_SR_VLA_Barrel_01
  • Part_SR_VLA_Barrel_02
  • Part_SR_VLA_Barrel_ETech

Those Excluders will never come into play on the Lyuda itself, because the only valid barrel for a Lyuda is Part_SR_VLA_Barrel_Lyuda. This does bring up a point worth mentioning, though: Dependencies and Excluders are defined on the parts themselves, and the parts might show up in many different guns. So the Dependencies and Excluders you see on a part may not ever apply on a specific gun, as with this example.

Anointments / Generic Part List Attributes

Anointments on gear don't show up anywhere in the attributes we've talked about already. Instead, those are defined in a special attribute on the Balance object named RuntimeGenericPartList. That will basically have a big ol' PartList sub-attribute which is a list of all the anointments which can exist on the weapon/item. The PartSet object also has a GenericParts attribute which often contains the anointments as well, but as with the standard gun/item parts, only the Balance part definitions matter here. The first part in this structure is generally always Att_EndGame_NoneChanceGuns, which is the part which means that the item will have no anointment.

Weights for the anointments are somewhat interesting. All generic (non-character-specific) anointments have a weight of 1, and all character-specific anointments have a weight of 0.15. When a character joins the game, though (such as the character you're playing, or when a co-op partner joins), the character-specific anointment weight matching that character goes up by 0.85. So if you're playing a solo Siren, the Siren-specific anointments will be as likely to spawn as a generic anointment, and the other characters' anointments will be less likely. If two Sirens are present in the game, I believe the weight of the Siren anoints will be 1.85, so they'll be more likely than the generic ones. The weights of the no-anointment part has been in flux for awhile now -- current hotfixed valuse set them pretty low: from 2.3 when playing without Mayhem mode, to 0.5 when playing in Mayhem 3 or 4. Those values seem likely to change again at some point in the future, so don't take them as gospel.

As BL3 gets patched, Gearbox sometimes adds in more anointments, like they did during the Bloody Harvest event, or the new anointments added by the Maliwan Takedown / Mayhem 4 update. These are all defined in GPartExpansion objects. We won't go into great detail about them here, other than to say that they specify more anointments and get applied to nearly all gear which is ordinarily able to have anointments. (There are a few exceptions -- in order to trace those out you'd have to loop through the InventoryBalanceCollection and ParentCollection links in the expansion objects, and make a note of any gear not found via that method.)

The expansion objects currently in-use are:

  • /Game/PatchDLC/Raid1/Gear/_GearExtension/GParts/GPartExpansion_Weapons_Raid1
  • /Game/PatchDLC/Raid1/Gear/_GearExtension/GParts/GPartExpansion_Shields_Raid1
  • /Game/PatchDLC/Raid1/Gear/_GearExtension/GParts/GPartExpansion_Grenades_Raid1

The anointments added by the Revenge of the Cartels event were made permanent additions by Gearbox, so the following expansion objects are also in-use:

  • /Game/PatchDLC/Event2/Gear/_Design/_GearExtension/GParts/GPartExpansion_Weapons_Event2
  • /Game/PatchDLC/Event2/Gear/_Design/_GearExtension/GParts/GPartExpansion_Shields_Event2
  • /Game/PatchDLC/Event2/Gear/_Design/_GearExtension/GParts/GPartExpansion_Grenades_Event2

The Bloody Harvest expansions (which are only active while the event is active) are:

  • /Game/PatchDLC/BloodyHarvest/Gear/_Design/_GearExtension/GParts/GPartExpansion_Weapons_BloodyHarvest
  • /Game/PatchDLC/BloodyHarvest/Gear/_Design/_GearExtension/GParts/GPartExpansion_Shields_BloodyHarvest
  • /Game/PatchDLC/BloodyHarvest/Gear/_Design/_GearExtension/GParts/GPartExpansion_Grenades_BloodyHarvest

There are similar objects for each released story DLC (Moxxi's Heist; Guns, Love, and Tentacles; Bounty of Blood), but they don't actually add any new anointment parts, so they can be safely ignored.

Manufacturers

The Balance object also has a Manufacturers attribute. For nearly all gear, this will just contain a single manufacturer, but grenade mods can sometimes spawn in a variety of manufacturers. This appears to be just a straightforward weight-based pool, so not much more needs to be said about that.

Summary / How Gear Is Built

So, after all that, here's a more streamlined and general version of how the game engine builds gear. The order of the main steps might not be accurate, of course, and it's probably a bit more streamlined than I've written down here. (It probably just parses the structures as it goes along, for instance, rather than doing it first.)

  1. Pick a manufacturer from the Balance's Manufacturers list.
  2. Parse the Balance's RuntimePartList.PartTypeTOC structure, in conjunction with RuntimePartList.AllParts, to know what part categories are available.
  3. Associate those categories with the extra category information found in the PartSetData object
  4. Take a look at the first category, and trim out the parts whose Excluders and Dependencies aren't valid currently.
  5. Then use the PartSet parameters to pick however many parts from the category are required.
  6. Repeat starting at step 4 for each remaining part category. (So Barrel Augments will always end up being a category after Barrels, so that their dependencies can be processed.)
  7. Choose a part from the Balance's RuntimeGenericPartList; this will be the anointment.

Extracted Data

There's a couple Google Sheets out there which have made use of all this to programmatically extract a bunch of data from the game. These should include the last-updated-date in both the sheet name, and a changelog on their main sheet, so you can tell if they've been updated for recent patches or not.

How PartSet Data Is Turned Into Balances

As mentioned above, in nearly all cases, you can look at the on-disk JWP serializations of the InventoryBalanceData objects, specifically in the RuntimePartList attribute, to know what parts can spawn on a gun. That attribute is technically reconstructed at runtime as the objects are loaded into the game, though, and there are a couple of cases where the on-disk JWP serializations don't match what the game actually uses. Specifically:

  • On the June 25, 2020 update (with the third story DLC, Bounty of Blood), Gearbox added some new parts for Class Mods which buff up Action Skill Damage. These parts will not show up in the disk serializations of the Balances.
  • It turns out that the Balance references to a couple of Artifact parts are wrong. Specifically, some of them reference Artifact_Part_Stats_FireDamage and/or Artifact_Part_Stats_CryoDamage, but the actual object name for those both have a _2 suffix at the end (Artifact_Part_Stats_FireDamage_2, for instance).

As of July 16, 2020, those are the only differences we're aware of, but if you're looking to programmatically look at part data, you might need to know how to construct the "proper" part lists out of the PartSet objects, just like BL3 does when loading the objects. As before, altering the part lists found in the PartSet objects is pointless, since the engine has already done the translation, but it may help to know anyway. (The spreadsheets listed above this section needed to know this to accurately report on the parts, for instance.)

Finding the PartSet Objects To Use

So far, we've only been looking at a single InventoryBalanceData object, which points us at a single PartSet object. In reality, there's a bit of a tree structure in place, which is important if you're trying to replicate the RuntimePartList construction behavior. Specifically, the InventoryBalanceData is likely to have a BaseSelectionData attribute which will point you at another InventoryBalanceData object. This can chain multiple times, though I don't think the game ever goes beyond three total Balance objects. Weapons, in general, don't seem to do this very often, but other item types do. For instance, if we take a look at the Back Ham shield Balance, we'll find that it "chains" to one additional Balance. The two will be:

  • /Game/Gear/Shields/_Design/_Uniques/BackHam/Balance/InvBalD_Shield_BackHam
  • /Game/Gear/Shields/_Design/InvBalance/InvBalD_Shield_Anshin

Legendary class mods will often be three deep, such as the Phasezerker Balance, which looks like this:

  • /Game/PatchDLC/Raid1/Gear/ClassMods/Siren/InvBalD_ClassMod_Siren_Phasezerker
  • /Game/Gear/ClassMods/_Design/BalanceDefs/InvBalD_ClassMod_Siren
  • /Game/Gear/ClassMods/_Design/BalanceDefs/InvBalD_ClassMod

As in the beginning, when we associated a Balance object to its PartSet, each of these balances will have a PartSetData attribute which points at a PartSet. For the Back Ham, they'd be:

  • /Game/Gear/Shields/_Design/_Uniques/BackHam/Balance/PartSet_Shield_BackHam
  • /Game/Gear/Shields/_Design/PartSets/PartSet_Shield_Anshin

Or for that Phasezerker COM, we'd be looking at:

  • /Game/PatchDLC/Raid1/Gear/ClassMods/Siren/PartSet_ClassMod_Siren_Phasezerker
  • /Game/Gear/ClassMods/_Design/PartSets/PartSet_ClassMod_Siren
  • /Game/Gear/ClassMods/_Design/PartSets/PartSet_ClassMod

Processing the PartSet Objects

Now that we've got a list of all the PartSet objects which are used to construct the Balance's eventual RuntimePartList attribute, we've got to loop through them and process their ActorPartLists attributes. We'd do so by starting at the "bottom" PartSet and moving up. So for instance on the Phasezerker COM, we'd start with PartSet_ClassMod first, then PartSet_ClassMod_Siren, and finally PartSet_ClassMod_Siren_Phasezerker.

One important attribute to look at first is ActorPartReplacementMode, a top-level attribute inside the PartSet. That will be one of three values, which determines how exactly to process the ActorPartLists array:

  • Complete - In this case, the PartSet completely defines the set of parts, and will totally overwrite any prior PartSets which have been processed before this point. If an ActorPartLists entry has bEnabled of False at this point, that part category will just be an empty list which won't have parts in it.
  • Selective - In this case, each ActorPartLists entry will overwrite previously-seen categories, but only if its bEnabled attribute is True. If a category's bEnabled is False at this point, any previous parts listed in that category will remain in place.
  • Additive - In this case, if an ActorPartLists entry's bEnabled is True, any parts specified will be added to any previous parts lists defined in this category.

So, knowing the mode, you'd start at the "bottom" PartSet object and start adding parts to the part categories. Then take the next PartSet and alter your part category lists as instructed by the ActorPartReplacementMode. And just keep going until you've done the "final" PartSet.

Keep in mind that the part weights are part of this as well, so a Selective PartSet might change the weight of a previously-defined part in its category.

Once you go through this process of looping through all relevant PartSets, you'll have the collection of parts which the game will put into the Balance's RuntimePartList attribute (dynamically constructing the PartTypeTOC in addition to the AllParts list). As mentioned above, currently there's only a few cases where this process will give you different data than just looking at the cached RuntimePartList attributes on-disk, but if you want to be 100% sure you've got the right parts, just from the on-disk data, you'll have to do this processing.

Final Considerations

You might wonder, after going through the PartSet-to-Balance conversion process, about the other ActorPartLists attributes like bCanSelectMultipleParts, bUseWeightWithMultiplePartSelection, and MultiplePartSelectionRange, and if the game does similar processing to find those. Fortunately, the game only appears to look at the "top" level PartSet when looking for those attributes. So for that Phasezerker COM, even though there are three PartSet objects involved in the construction of the Balance's RuntimePartList, only the top-level InvBalD_ClassMod_Siren_Phasezerker object is used by the game to determine how the multi-part selection is processed.

Unrelatedly, the Artifact_Part_Stats_FireDamage_2 and Artifact_Part_Stats_CryoDamage_2 artifact attributes mentioned above continue to be a little bit weird, even after going through this process. When looking at Artifacts' cached (on-disk) RuntimePartList attributes, they will basically all omit the _2 suffixes, so they'll be technically incorrect. If you go through this PartSet-to-Balance construction process, nearly all of those errors will be fixed, except for two artifacts: Unleash the Dragon and Phoenix Tears. Something inside the game engine "fixes" those dynamically, though, to include the _2 suffix. So that's one hardcoded fix you'll have to remember to make.

Wonderlands Expansion Objects

Tiny Tina's Wonderlands introduces one further wrinkle to the gear-construction system, namely InventoryPartSetExpansionData and InventoryExcludersExpansionData objects. These were introduced with DLC4 (Shattering Spectreglass), to handle the new parts required to support the Blightcaller class (specifically for Armor and Amulets).

These expansion objects aren't actually linked-to by anything; the engine must just know to load them and process them dynamically, which means that anyone looking through game data for Balance/PartSet info just has to be aware that they exist. As of August 2022, all known examples of these objects start with EXPD_ in their object name, and live under one of these four paths:

  • /Game/PatchDLC/Indigo4/Gear/_Design/Amulets/_Shared/_Design/PartSet/ExpansionData
  • /Game/PatchDLC/Indigo4/Gear/Pauldrons/_Shared/_Design/Parts/Passive/Other/Skills
  • /Game/PatchDLC/Indigo4/Gear/Pauldrons/_Shared/_Design/Parts/PlayerStat/Other
  • /Game/PatchDLC/Indigo4/Gear/Pauldrons/_Shared/_Design/PartSet/ExpansionData

There are also some ItemPoolExpansionData objects which are prefixed by EXPD_, which have been used across all Wonderlands DLC to expand itempools, but those obviously don't have any bearing on gear construction.

InventoryExcludersExpansionData Objects

These objects are straightforward enough -- they simply add more Dependencies or Excluders to a specified set of parts. These expansion objects will have an array named TargetParts which defines which parts the expansion applies to, and then a Dependencies and/or Excluders attribute which lists the extra constraints. As mentioned above, there's no link from the Balance/PartSet/Part over to these expansion objects, so you'll just have to look through them to find out if any of them apply.

InventoryPartSetExpansionData Objects

These are objects which alter the part selection for gear, and they end up having a pretty noticeable change on gear definitions, for modders. They're basically used to add in the necessary Blightcaller parts to existing armor and amulets. As with InventoryExcludersExpansionData objects, there isn't a link from the Balance/PartSet over to these expansion objects, so you'll just have to look through them all to know whether one applies or not.

The objects themselves look fairly innocuous -- there's a top-level InventoryPartSet attribute which defines which PartSet the expansion acts on, and then a PartLists structure which is identical to the ActorPartLists structure found in regular PartSet objects. Inside each of the categories is a Parts array which might define more parts to be added in to that category. The structure does include all the other usual ActorPartLists attributes like bCanSelectMultipleParts, MultiplePartSelectionRange, etc, but those appear to be ignored by the game. Some attributes like PartTypeEnum and PartType might still be required for the structure to work properly, but the only one that's important to look at for our purposes is Parts.

The main wrinkle that these introduce is that for objects with InventoryPartSetExpansionData expansions, the Balance itself ends up being useless for hotfix modding (as opposed to ordinarily, where the Balance is the only thing useful for hotfix modding, for part lists). Basically, the early object-loading workflow ends up looking like this:

  1. Objects get loaded by the game, and the PartSet's ActorPartLists struct gets processed over to the Balance's RuntimePartList struct as usual.
  2. Hotfixes are processed
  3. InventoryPartSetExpansionData objects get processed, which has the following effects:
    1. The PartSet's ActorPartLists[x].Parts structures are merged with the expansion object's PartLists[x].Parts into the a new ActorPartLists[x].RuntimeParts on the PartSet itself
    2. The Balance's RuntimePartList might be updated as well, again, but that sort of doesn't matter anymore.
  4. When gear is dropped, if ActorPartLists[x].RuntimeParts exists on the PartSet, the Balance is completely ignored for part-picking purposes, and only the PartSet's ActorpartLists[x].RuntimeParts is used. (If RuntimeParts is absent, then the Balance is used for part lists, as described above.)

It's actually a more sensible approach than the usual method -- this way, all the decisions about which parts to spawn on a bit of gear come from the PartSet, instead of splitting it up between the Balance (for the part list) and PartSet (for all the "meta" params about how to pick the parts). It does lead for a somewhat frustrating situation where the behavior depends entirely on whether or not one of these expansion objects is in effect, though, and there's no way to really know that without looping through all available expansion objects to find out if any apply.

In terms of modding, for InventoryPartSetExpansionData-expanded gear, you can use hotfixes on both the PartSet and InventoryPartSetExpansionData objects themselves without problems, due to the order of operations when applying hotfixes. So the Balance object can be completely ignored for these. One potential wrinkle is that you might have to set the entire ActorPartLists structure rather than cherry-picking individual components. Some testing indicates that drilling in too far might lead to the changes not applying properly.

Miscellaneous

One final point of interest here: this exact same system is what's used to spawn enemy vehicles in maps, though the actual object attributes are sometimes differently-named. But, for instance, SpawnOptions_CotV_Outrunner has an associated Outrunner_VehiclePartSet_Enemy_COTV, and the relationship between the SpawnOptions' eventual RuntimePartList attribute and the PartSet ActorPartLists attribute is just the same as it is for items.