Understanding BL3 Item Serial Numbers - BLCM/BLCMods GitHub Wiki

This is modding-adjacent at best, but it comes up frequently whenever folks start looking into writing their own save editors. Rather than hash it over in various chat rooms where the conversation is apt to get lost to the vagaries of time, let's get it documented here.

This document assumes a certain amount of prior knowledge of dealing with data in code, and knowing how to read into the BL3 protobuf structures, if you're looking to write a new editor of some sort. Note that the serial structure is nearly identical between BL3 and Wonderlands -- there's just one extra bit of data in the Wonderlands serials.

Conventions:

  • Whenever I say "byte" on its own, interpret those 8 bits as an unsigned value (so: 0 to 255).
  • ... I guess that's it, really.

Table of Contents

Getting Raw Serial Numbers

If you're reading the raw protobufs already, this should be pretty straightforward - they're already in binary format. If you're using JSON serializations of savegames, then they're probably stored in base64, so you'll have to un-base64 it to work with it.

If you're looking at an item code provided in a BL3() syntax, that is similarly base64-encoded inside the BL3() string, so un-base64 it and then you've got the raw data.

Initial Header And Encryption/Obfuscation

The very first byte of the serial will be 3 for savegames saved prior to the 2021-04-08 patch, and 4 for savegames written from that point forward. Wonderlands serials will always have a version of 5. (As of August 28, 2022, those are the only options.) I refer to this as the "Serial Version" -- keep track of it, but you won't need to refer to it until basically the very end of this.

The next four bytes are an unsigned 32-bit integer which is the random "seed" used to encrypt/obfuscate the rest of the serial number. This is a scheme that's been present in Borderlands savegames since at least BL2. (And possibly earlier?) The encryption is quite basic and is really more like obfuscation than actual encryption.

Note: If this random seed is 0, then the rest of the serial is already unencrypted, so you can skip doing the unobfuscation/decryption.

So all the data starting from the fifth byte needs to be run through this process (assuming the seed isn't 0). You can find examples of doing this in gibbed's BL2 editor (in C#), Raptor's editor (in Golang), HackerSmacker's editor (in C), or apocalyptech's editor (in Python).

Keep in mind that in addition to the main XOR-based loop, there's also a "rotation" at the end which shifts the data around a bit so that it starts in the correct spot.

Post-Decryption Checksum

Once you've done that, you'll have the data returned by the decryption routine. The first two bytes of that decrypted data are a checksum which can be used to verify that your decryption process has completed properly. (I'd recommend you do so, to be sure that everything's good.)

The binary data that you'll want to checksum will be the original five bytes of the serial number (so, the initial 3 or 4 followed by the four bytes of the initial seed), followed by two bytes of 0xFF (which takes the place of the checksum itself), followed by the rest of the decrypted data. The checksum itself is pretty straightforward. Examples can be found in Raptor's editor, apocalyptech's editor, or gibbed's BL2 editor.

Assuming that your computed two-byte checksum matches the original two byte checksum, you're good to go.

Reading the Arbitrary-Bit-Length Data

Now that you've decrypted the serial and your checksum matches, the remainder of the data is ready to be read. One wrinkle here is that the data you'll be reading has arbitrary bit lengths which will rarely (if ever) line up with byte boundaries. Implementations for this will vary -- the one used by Gibbed's editors is probably a good place to start for an implementation based on bit shifting and the like. Logically, though, you can think of it like this:

Say you've got four total bytes of data, which in binary look like this:

11111110 00000001 11110000 00001111

Pretend that you write out those bytes in reverse order, and smoosh them all together so you're ignoring the byte boundaries:

00001111111100000000000111111110

Now, if you need to read nine bytes from this structure, chop off the last nine bytes and treat them as an unsigned number. In this case, those nine bytes would be 111111110 (or 510 in decimal). The remaining data would then look like:

00001111111100000000000

Reading an eight byte value would result in 0, chopping off those last eight zeroes, to leave:

000011111111000

Now if you need to read a five-byte value, that'll give you 11000, or 24 in decimal. Which leave us with:

0000111111

And so on. As I say, there's plenty of ways to implement this. For Apocalyptech's bl3-cli-saveedit, what I just described was basically used verbatim. Gibbed's, linked above, is probably a lot more efficient and fast.

Regardless, from now on, that's how you'll be reading data from the serial.

Initial Data and the Inventory Serial Number Database

So, given the remainder of the data and what we know about reading arbitrary-bit-length values, from it, here's how we start out:

  1. Read 8 bits. This should always yield decimal 128. We've yet to see any other data but 128 there.
  2. Read 7 bits. This is a "database version" (not to be confused with the "Serial Version" from the very beginning of the process).

At this point, we've got to talk about the Inventory Serial Number Database. This is a file that's found inside the BL3 data and gets updated whenever there's new items or gear. You can see what should be the current version of the file from the link above.

You can also find it yourself in the game data if you want -- it'll be found at /Game/InventorySerialNumberDatabase.dat. If you do, though, you'll have to process it yourself since it's sort-of encrypted (though using even weaker methods than the serial number "encryption") and in a binary format. The JSONified version from gibbed's repo should be good enough.

In JSON form, the top of this file looks like so:

{
  "InventoryData": {
    "versions": [
      {"version":1,"bits":7},
      {"version":17,"bits":8},
      {"version":21,"bits":9},
      {"version":22,"bits":10},
      {"version":37,"bits":11}
    ],
    "assets": [
      "/Game/Enemies/DropShipTurret/COV/_Design/Weapon/InvData_DropShipTurretCOV.InvData_DropShipTurretCOV",
      "/Game/Enemies/DropShipTurret/_Shared/_Design/Weapon/InvData_DropShipTurret.InvData_DropShipTurret",
      ...

The versions structure there relates to the "database version" we just read from the serial. Basically, if you have a serial number whose database version is 1 to 16 (inclusive), reading a InventoryData will take 7 bits. If the database version is 17 to 20 (inclusive), though, it will take 8 bits instead. And so on, up to (as of writing) versions 37 and higher, which will require reading 11 bytes instead.

The file is composed of multiple sections like this. For instance, the category for Dahl SMGs currently looks like this:

  "BPInvPart_Dahl_SMG_C": {
    "versions": [
      {"version":1,"bits":6},
      {"version":16,"bits":7}
    ],
    "assets": [
      "/Game/Gear/Weapons/SMGs/Dahl/_Shared/_Design/Parts/Barrel/Barrel_01/Part_SM_DAL_Barrel_01.Part_SM_DAL_Barrel_01",
      "/Game/Gear/Weapons/SMGs/Dahl/_Shared/_Design/Parts/Barrel/Barrel_01/Part_SM_DAL_Barrel_01_A.Part_SM_DAL_Barrel_01_A",
      ...

So while reading part lists in the BPInvPart_Dahl_SMG_C Dahl SMG section, database versions 1 through 15 require reading 6 bits, whereas database versions 16 and higher will read 7 bits.

For each part that you read, in a particular category, you'll get one number each, which corresponds to a part in that assets list. The indexing starts at 1, not 0, so if you read an InventoryData part and the value is 2, it means that you got InvData_DropShipTurret. If a BPInvPart_Dahl_SMG_C part's value is 2, though, it means Part_SM_DAL_Barrel_01_A.

So from now on, in addition to reading arbitrary bit-length data, you'll also be using your "database version" to do lookups in this inventory serial database in order to find out how many bits to read for each part, and what the numbers mean that you read in.

I recommend keeping track of the maximum version seen in the inventory serial database file, when you read it in, and check serial numbers to make sure that you don't try and parse a serial whose database version is higher than the maximum version seen in your serial DB file. If it is, you may end up reading incorrect data from the rest of the serial. In that case, you'd need to update the serial DB with the latest data from the game.

Finally, On To Some Real Data

So, we read in the initial 8 bits (128), and we read in 7 bits to tell us the database version, and we've got the serial DB in hand. We can finally get to the meat of the serial.

  1. Read a value from the InventoryBalanceData category (using the serial DB and database version to know how many bits to read)
  2. Read a value from InventoryData category (again, using the serial DB and database version)
  3. Read a value from the ManufacturerData category (you get the picture)
  4. Read 7 bits to get the item level. (This one doesn't use the serial DB; it's literally just 7 bits.)
  5. Read 6 bits to tell you how many parts are in the item/weapon.

So now you've got some basic information about the item. InventoryBalanceData gives you the balance, InventoryData gives you some more generalized categories, and ManufacturerData gives you manufacturer information (plus you've got the item level). And you know how many parts are in the item. How do you know which category to pull the parts from, though?

Honestly, I don't know the best way to do this. The way Apocalyptech's bl3-cli-saveedit does it is by making use of a balance-to-category mapping file which is generated by this script, which in turn makes use of the BL3 Object Refs DB. It might be that it makes more sense to make use of InventoryData instead of InventoryBalanceData? I honestly couldn't say. Raptor's editor I think uses basically the same method as Apocalyptech's, but I don't think that's the only option.

Regardless, you'll have to discern some way of figuring out which category to read from. For instance, if you've found yourself with a Dahl SMG, you'll need to read from the BPInvPart_Dahl_SMG_C category.

So you've read the 6 bits telling you how many parts there are, and you've determined wich category to pull from, so continuing:

  1. Read as many parts as specified, from the proper category.
  2. Read 4 bits to tell you how many "generic" parts to read (anointments, mayhem-level parts)
  3. Read as many generic parts as specified, from the InventoryGenericPartData category.
  4. Read 8 bits to tell you how many extra bytes of "additional" data should be read. Nobody has figured out exactly what this data represents, but one thing we know is in there is some exceedingly subtle "wear and tear" graphics on weapons. It's basically safe to ignore this data and just copy it verbatim, or just set this number to zero so there's none to read, when creating new items.
  5. Read as many 8-bit values as specified previously.
  6. Read 4 bits, which should always be zero. It appears that this value used to be used to specify the number of customizations attached to a weapon, which must have originally been the plan for trinkets and weapon skins. By the time the game was released, though, those functions were pulled out elsewhere, and we have only ever seen 0 for this number. If you ever get anything other than 0, you may want to abort processing, since we have no idea what kind of bit lengths might follow.
  7. Now, remember the "serial" version from the very beginning of this process, which was either a 3, 4, or 5? Well, if that value is 4 or 5, read an additional 8 bits, which will tell you how many times this item has had its anointment/enchantment rerolled (either by the machine in Sanctuary for BL3, or the machine in Brighthoof / Chaos Chamber in WL). This value does not overflow past 8 bits; if it reaches 255 and more rerolls happen, the value just stays at 255.
  8. Finally, if the serial version was 5 (which will only be the case for Wonderlands serials), read an additional 7 bits. This will tell you the "chaos level" of the item - 0 for regular, 1 for Chaotic, 2 for Volatile, 3 for Primordial, and 4 for Ascended. (7 bits is an awful lot for a number that only goes up to 4, but that seems to be what's being used.)

Now, you've finally reached the end of the serial processing! There's one more bit of verification I'd recommend you do at this point, though it's not really required. Namely: given the nature of these variable-bit-length fields, it's pretty unlikely that you've "finished" right on a byte boundary. I'd recommend you verify that you have fewer than 8 bits of data left to read in the serial, and that all of the bits remaining are zeroes. That has been the case for all serials that I've personally come across, but if that's ever not the case, then something odd might be going on.

Summary

Okay, a much-more-condensed version follows:

  1. Read in Serial Database, make a note of the maximum version found across all categories.
  2. 1 byte: Serial Version (3 or 4, currently)
  3. 4 bytes: Encryption Seed
  • If this isn't 0, apply decryption to the rest of the serial
  1. 2 bytes: Checksum of the decrypted version, with 0xFFFF in place of the checksum
  2. 1 byte: Always 128
  3. 7 bits: Database Version
  • If this is higher than the version you found in step 0, abort.
  1. n bits: InventoryBalanceData category
  2. n bits: InventoryData category
  3. n bits: ManufacturerData category
  4. 7 bits: Item Level
  5. 6 bits: Number of parts
  6. Read that number of parts, from the proper category
  7. 4 bits: Number of "generic" parts
  8. Read that number of parts, from the InventoryGenericPartData category
  9. 8 bits: Number of extra data bytes
  10. Read that number of 8-bit values (unknown contents)
  11. 4 bits: Should always be 0
  • If this isn't 0, abort processing here; we don't know how to interpret it, otherwise.
  1. If the Serial Version (from step 1) is 4 or 5, read 8 bits, for the number of times this item's had its anointment rerolled.
  2. If the Serial Version (from step 1) is 5, read 7 bits, for the chaos level of the gear.
  3. Check that you have fewer than 8 bits remaining, and that they are all zeroes.

That's It!

Easy peasy, yes? Well, maybe not, but once you've got it coded you probably won't have to think about it much anymore.

Reversing the process is pretty straightforward, and I'll leave that as an exercise for the reader. You can find some code examples for doing so in the various projects that we've linked to already. I will recommend that you set the encryption seed to 0 when writing out serials -- that way, they'd even be readable (albiet with a lot of effort) by a human just looking at a hex/binary dump. The seeds chosen by the engine aren't in any way important; they get randomzed with literally every save. With a seed of 0, it's more likely that identical items will have identical serials, which is nice (although updates to the serial DB, or that mysterious "wear and tear" data might still get in the way of that).

Regardless, that's all!

Cheers!