diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml new file mode 100644 index 0000000..7c0ae92 --- /dev/null +++ b/.github/workflows/black.yml @@ -0,0 +1,13 @@ +# https://black.readthedocs.io/en/stable/integrations/github_actions.html +name: Lint + +on: [push, pull_request] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: psf/black@stable + with: + version: 23.7.0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fbfa7d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +**/*.pyc diff --git a/README.md b/README.md index 63be4d2..b5b8fee 100644 --- a/README.md +++ b/README.md @@ -1,149 +1,669 @@ -# Read and write Borderlands 2 save files +# Borderlands 2 / Borderlands: The Pre-Sequel Save File Modification Tool -A simple command line utility to extract player information from a Borderlands -2 save file, or to create a new save file from player information. +This is a simple command line utility to extract and modify player information +from a Borderlands 2 or Borderlands: The Pre-Sequel save file. It offers a +simple way to perform common actions on a character, such as setting money, +unlocking Black Market SDUs, and can also generate a hand-editable JSON file +which can then be converted back into a savegame. + +**Note:** As of July 2023, Github user [loot-midget](https://github.com/loot-midget) +has forked this project and done a lot of really nice cleanup on it, which +you can see at [`https://github.com/loot-midget/borderlands2`](https://github.com/loot-midget/borderlands2). +I'm planning on trying to keep this repository up-to-date with that repo, +but you may want to just head over there and use that repo instead, since +it's likely to end up ahead of this repo. Note the following before trying to use it: * It has no graphical interface and is not easy to use -* It does not provide any mechanisms for creating items or weapons -* It is a proof of concept and will corrupt your save files if used improperly -* It requires a working Python 2 interpreter (2.6 or later, not 3) +* It does not provide any mechanisms for creating items or weapons, or even + editing items/weapons in a useful fashion. +* While I'm not aware of any cases of it happening, this could certainly + corrupt your savegame if something goes weird. **(Take backups of your saves!)** +* It requires a working Python 3 interpreter (if you need the old Python 2 + version, change to the `python2` branch). As of March 5, 2022, the minimum + Python version is 3.9. + +This repository is a fork of the original at https://github.com/pclifford/borderlands2 + +There are several difference between this fork and the original. The most +obvious difference is in the method of specifying modification arguments. +Also, the original version defaulted to writing savegames usable on consoles, +whereas this fork defaults to writing savegames usable on PC. The `-b` or +`--bigendian` argument can be specified to have this fork generate +Console-appropriate files, though note that many of the changes since the fork +have not been tested on consoles. If there are problems with console +integration, contact me and I'd be happy to try and fix it. + +Note too that the JSON generated by this fork isn't going to be compatible +with the JSON generated by the main branch, so don't try to mix between the +two. + +# Table of Contents + +* [Running the program](#running-the-program) +* [Input and Output](#input-and-output) + * [Other Output Formats](#other-output-formats) +* [Modifying Savegames (JSON Method)](#modifying-savegames-json-method) +* [Modifying Savegames (Using Commandline Arguments)](#modifying-savegames-using-commandline-arguments) + * [Character Name](#character-name) + * [Save Game ID](#save-game-id) + * [Character Level](#character-level) + * [Money](#money) + * [Eridium (Borderlands 2 Only)](#eridium-borderlands-2-only) + * [Moonstone (Borderlands: The Pre-Sequel Only)](#moonstone-borderlands-the-pre-sequel-only) + * [Seraph Crystals (Borderlands 2 Only)](#seraph-crystals-borderlands-2-only) + * [Torgue Tokens (Borderlands 2 Only)](#torgue-tokens-borderlands-2-only) + * [Item Levels](#item-levels) + * [Backpack Size](#backpack-size) + * [Bank Size](#bank-size) + * [Gun Slots](#gun-slots) + * [Unlocks](#unlocks) + * [Ammo](#ammo) + * [Challenges](#challenges) + * [True Vault Hunter Mode (playthrough 2)](#true-vault-hunter-mode-playthrough-2) + * [Ultimate Vault Hunter Mode (playthrough 3)](#ultimate-vault-hunter-mode-playthrough-3) + * [Creature Slaughterdome (Borderlands 2 Only)](#creature-slaughterdome-borderlands-2-only) + * [Overpower Levels (Borderlands 2 Only)](#overpower-levels-borderlands-2-only) + * [Ammo](#ammo-1) + * [Challenge Levels](#challenge-levels) + * [Fixing Negative-Number Challenges](#fixing-negative-number-challenges) + * [Copying mission data from NVHM to TVHM+UVHM](#copying-mission-data-from-nvhm-to-tvhmuvhm) + * [Resetting features](#resetting-features) + * [Bad Touch](#bad-touch) + * [Doctor's Orders](#doctors-orders) +* [Getting Savegame Information](#getting-savegame-information) + * [Printing out not-fully-explored levels](#printing-out-not-fully-explored-levels) + * [Challenge Accepted achievement progress](#challenge-accepted-achievement-progress) +* [Combining Commandline Options](#combining-commandline-options) +* [Working with Savegames to/from Consoles](#working-with-savegames-tofrom-consoles) +* [Exporting character items](#exporting-character-items) +* [Importing character items](#importing-character-items) +* [Other commandline options](#other-commandline-options) + * [Quiet Output](#quiet-output) + * [Force Overwrites](#force-overwrites) + * [Help](#help) +* [Credits/Thanks](#creditsthanks) +* [TODO](#todo) + +# Running the program + +There are two executables: `bl2_save_edit.py` and `tps_save_edit.py`, for +Borderlands 2 and Borderlands: The Pre-Sequel, respectively. The functionality +of both is virtually identical, only differing in a few of the modification +options (`--moonstone` instead of `--eridium`, for instance). + +The basic form of the utility is to specify and input and output file. If no +other options are given, the utility effectively just copies the savegame +without making any changes, like so: + + python bl2_save_edit.py save0001.sav save0002.sav + +As of July 2023, there's an option which *just* prints out some savegame info +to the console, so having an output file in that case would be kind of pointless. +In those situations, you can leave out the output filename to have the editor +just print out the info and exit. For instance: + + python bl2_save_edit.py --print-unexplored-levels save0001.sav + +# Input and Output + +By default, the utility saves in a format usable by Borderlands, but you can +specify alternate outputs to use, using the `-o` or `--output` option. The +most useful outputs are: + +* **`savegame`** - This is the default, and the only output usable by Borderlands itself. +* **`json`** - This is the most human-editable format, saved in a text-based + heirarchy in JSON format, which should be fairly reasonable to work with. +* **`items`** - This will save the character's inventory and bank into a text + file which can then be imported into other tools like Gibbed, or imported + into other characters using this tool. -## How do I modify values in a save file? +For example, saving to a JSON file for later hand-editing: -Modify save file data by changing one or more of "level", "skillpoints", -"money", "eridium", "seraph", "tokens", "gunslots", "backpack", "bank", -"unlocks", or "itemlevels": + python bl2_save_edit.py -o json save0001.sav testing.json - python savefile.py -m eridium=99 old.sav new.sav +After hand-editing a JSON file, you can convert it back by specifying the `-j` +or `--json` option, to tell the utility that you're loading from a JSON file, +like so: -Set the levels of all your items and weapons (except those at level 1, which -are left alone) to match your character's level: + python bl2_save_edit.py -j testing.json save0002.sav + python bl2_save_edit.py --json testing.json save0002.sav - python savefile.py -m itemlevels old.sav new.sav +To save the character's inventory to a text file: -Or to a specific level: + python bl2_save_edit.py --output items save0001.save items.txt - python savefile.py -m itemlevels=20 old.sav new.sav +To later import the items in `items.txt` to a savegame, use the `-i` or +`--import-items` argument: -Set the number of guns your character can have equipped to 2, 3, or 4: + python bl2_save_edit.py -i items.txt save0002.sav new.sav + python bl2_save_edit.py --import-items items.txt save0002.sav new.sav - python savefile.py -m gunslots=4 old.sav new.sav +*(note that the savefile with the imported items is `new.sav` - You'd have +to copy that back over to `save0002.sav` afterwards)* -Set the size of your character's backpack, and the corresponding number of -purchased backpack SDUs: +## Other Output Formats - python savefile.py -m backpack=27 old.sav new.sav +There are also a couple other output formats you can specify with `-o`, though +they are primarily only useful to programmers looking to work with the raw data +a little more closely: -Set the size of your character's bank, and the corresponding number of -purchased bank SDUs: +* **`decoded`** - The raw protocol buffer data, after decompression. +* **`decodedjson`** - A midway point between `decoded` and `json`, this will generate + a JSON file, so it'll be technically editable by hand, but most of the internal + data structures will be present as raw protobuf strings. +* **`none`** - This output won't write a file at all. There's generally no need + to specify this manually. If you run the utility without an output file, it'll + switch to this mode automatically (though it will error out if you were also + specifying an option which would change the savefile in some way). - python savefile.py -m bank=16 old.sav new.sav +# Modifying Savegames (JSON Method) -Unlock the Creature Slaughter Dome (Natural Selection Annex): +As mentioned above, one way to edit your characters is to save them out +as a parsed JSON file, edit the JSON by hand (in a text editor), and then +re-export the JSON into a savefile. As always, make sure to take backups +of your savefiles before overwriting them. - python savefile.py -m unlocks=slaughterdome old.sav new.sav +1. `python bl2_save_edit.py -o json save0001.sav to_edit.json` +2. Edit `to_edit.json` in a text editor, to suit +3. `python bl2_save_edit.py -j to_edit.json save0001.sav` -Unlock the True Vault Hunter mode: +# Modifying Savegames (Using Commandline Arguments) - python savefile.py -m unlocks=truevaulthunter old.sav new.sav +Alternatively, you can alter many attributes of your character by just using +commandline options. You can specify as few or as many of these as you want. +Note that if you specify `-o items` to save a character's items to a text +file, the majority of these options will have no effect. -Unlock both at once: +## Character Name - python savefile.py -m unlocks=slaughterdome:truevaulthunter old.sav new.sav +This can be done with the `--name` option: -Or many changes at once, separated by commas: + python bl2_save_edit.py --name "Gregor Samsa" old.sav new.sav - python savefile.py -m level=7,skillpoints=42,money=1234,eridium=12,seraph=120,itemlevels old.sav new.sav +## Save Game ID -Add --little-endian to write the save file in a format that should be readable -by the PC version (the default is to write the data in big-endian format, for -the console versions): +This is probably not actually useful; Borderlands seems to automatically set +this to a value it thinks is appropriate. The ID tends to match the filename, +though, and I personally end up setting it just because it seems to make sense +to. You'll probably be fine if you never touch this option. It can be +changed with `--save-game-id` like so: - python savefile.py -m eridium=99 --little-endian old.sav new.sav + python bl2_save_edit.py --save-game-id 2 save0001.sav save0002.sav -## How do I convert a PC save to work on a console? +## Character Level -A PC save file is automatically detected and read, and the default is to write -in the correct format for a console. If you don't want to make any changes -except to the format: +This will also update your character's XP if needed, and is available with the +`--level` option: - python savefile.py -m "" pc.sav console.sav + python bl2_save_edit.py --level 72 old.sav new.sav -## How do I convert a console save to work on a PC? +## Money -As before, add --little-endian to the command to write the data in a format -suitable for the PC. If you don't want to make any changes except to the -format: +Set money with the `--money` option: - python savefile.py -m "" --little-endian console.sav pc.sav + python bl2_save_edit.py --money 3000000 old.sav new.sav -## How do I take a copy of all my character's items? +## Eridium (Borderlands 2 Only) -All items stored and held in the character's bank or inventory can be exported -to a text file as a list of codes, in a format compatible with Gibbed's save -editor: +Set available Eridium with the `--eridium` option. Note that the game will +reduce this to a maxmimum of 500 if you attempt to add more: - python savefile.py -e items.txt your-save-game.sav + python bl2_save_edit.py --eridium 500 old.sav new.sav -## How do I import those items back into a character? +## Moonstone (Borderlands: The Pre-Sequel Only) -A text file of codes generated as above, or assembled by hand, can be imported -into a character like so: +Set available Moonstone with the `--moonstone` option. Note that the game will +reduce this to a maxmimum of 500 if you attempt to add more: - python savefile.py -i items.txt old.sav new.sav + python tps_save_edit.py --moonstone 500 old.sav new.sav -(Don't forget to add --little-endian if you're creating a save file for the PC -version.) +## Seraph Crystals (Borderlands 2 Only) -By default all items will be inserted into the inventory, but this can be -changed with a line containing "; Bank" to indicate that all following items -should go into the bank, or one of either "; Weapons" or "; Items" to indicate -that all following items should go into the inventory. For example, importing -a file containing the following will put a Vault Hunter's Relic into the -inventory and a Righteous Infinity pistol into the bank: +Set the available Seraph Crystals with the `--seraph` option. The game will +enforce a maximum of 999: - ; Bank - BL2(h0Hd1Z+jY/s2Qy++Zu8Ba9qXoOmjwJ6NhrlsOmhNMX+oJo5CfQns) - ; Items - BL2(B2vuv4tz1zSQCf2pqLJCS5XD/tKN4FXpjRJLnn1v85U=) + python bl2_save_edit.py --seraph 999 old.sav new.sav + +## Torgue Tokens (Borderlands 2 Only) + +Set the available Torgue Tokens with the `--torgue` option. The game will +enforce a maximum of 999: + + python bl2_save_edit.py --torgue 999 old.sav new.sav + +## Item Levels + +The `--itemlevels` argument can be used to set all items in your inventory to +either your character's current level, or to the level you specify. + +To set to the character's level: + + python bl2_save_edit.py --itemlevels 0 old.sav new.sav + +To set to a specific level: + + python bl2_save_edit.py --itemlevels 50 old.sav new.sav + +Note that items of level 1, however, are left alone unless you also specify +the `--forceitemlevels` flag: + + python bl2_save_edit.py --itemlevels 0 --forceitemlevels old.sav new.sav + +## Backpack Size + +The `--backpack` option can be used to set the size of your backpack. To set +the maximum possible size of the backpack, either specify 39 or "max". Note +that the utility will also enforce that the backpack size is a multiple of 3, +and between the range of 12 and 39. + + python bl2_save_edit.py --backpack max old.sav new.sav + python bl2_save_edit.py --backpack 31 old.sav new.sav + +## Bank Size + +Similarly, the `--bank` option can be used to set the size of your bank, and +will round up to multiples of 2, between 6 and 24. To specify the maximum +value, either use 24 or "max". + + python bl2_save_edit.py --bank max old.sav new.sav + python bl2_save_edit.py --bank 16 old.sav new.sav + +## Gun Slots + +The `--gunslots` option can be used to set the total number of open gun slots +(ordinarily unlocked via story missions). Valid values are 2, 3, and 4: + + python bl2_save_edit.py --gunslots 2 old.sav new.sav + python bl2_save_edit.py --gunslots 3 old.sav new.sav + python bl2_save_edit.py --gunslots 4 old.sav new.sav + +## Unlocks + +There are a few things which can be unlocked via this utility, with the `--unlock` +option. This option can be specified more than once to unlock more than one +thing. + +### Ammo + +This option will unlock all ammo SDU upgrades (ordinarily available in the black +market). This will also automatically refill all ammo pools: + + python bl2_save_edit.py --unlock ammo old.sav new.sav + +### Challenges + +Some challenges do not actually appear in the challenge list until certain +prerequisites are met. For instance, the challenge for long-range shotgun +kills doesn't actually appear until the challenge for short-range shotgun +kills has reached level 5. This will unlock all those challenges regardless +of the prerequisites. *(Note: this only applies to non-level-specific +challenges)* + + python bl2_save_edit.py --unlock challenges old.sav new.sav + +### True Vault Hunter Mode (playthrough 2) + +To unlock TVHM: + + python bl2_save_edit.py --unlock tvhm old.sav new.sav + +### Ultimate Vault Hunter Mode (playthrough 3) + +To unlock UVHM (and also TVHM): + + python bl2_save_edit.py --unlock uvhm old.sav new.sav + +### Creature Slaughterdome (Borderlands 2 Only) + +**NOTE:** I'm unsure whether or not this would actually work on a system +without the Creature Slaughterdome explicitly enabled, but it's possible maybe +it does? + +The Creature Slaughterdome might be unlockable with: + + python bl2_save_edit.py --unlock slaughterdome old.sav new.sav + +## Overpower Levels (Borderlands 2 Only) + +To set OP Levels: + + python bl2_save_edit.py --oplevel 8 old.sav new.sav + +This will also trigger an unlock of TVHM/UVHM if the save does not already +have UVHM unlocked. + +## Ammo + +The `--maxammo` option can be used to refill all ammo to its current maximum +level (based on what you've already purchased at the black market). This +is obviously a bit silly ordinarily, given how easy ammo is to come by. + + python bl2_save_edit.py --maxammo old.sav new.sav -## How do I just extract the player data? +To actually increase your total available ammo pool, as you'd do through the +black market, use `--unlock ammo` *(see above)*. Doing so will then also refill +ammo as if `--maxammo` had been specified. -Extract the raw protocol buffer data from a save file: +## Challenge Levels - python savefile.py -d your-save-game.sav player.p +This option is admittedly rather silly, but the `--challenges` argument will +let you set your character's challenge levels to the specified values. The +valid options are: -Extract the data in JSON format (encoded purely to preserve all the raw -information -- not very readable): +* zero +* max +* bonus - python savefile.py -d -j your-save-game.sav player.json +If set to `zero`, the level of all your non-level-specific challenges will be +reset to zero, so you can start accumulating again. (Possibly useful if you +want to start from scratch but haven't completed enough to use the in-game +reset.) -Extract the data in JSON format, applying further parsing to make the data as -readable as possible: + python bl2_save_edit.py --challenges zero old.sav new.sav - python savefile.py -d -j -p your-save-game.sav player.json +If set to `max`, the level of all non-level-specific challenges will be set to +*one under* than their maximum level, possibly making it easier to accrue a +good deal of Badass Rank very quickly. -It may help to copy and paste the contents of the .json file into a site like -http://www.jsoneditoronline.org/ in order to view or modify the contents, to -ensure that the necessary JSON formatting is preserved. + python bl2_save_edit.py --challenges max old.sav new.sav -Note that if you modify any of the data you extract in this way there is a very -high probability that you will corrupt your save file. Please make sure you -have a backup first. +If set to `bonus`, the level of all non-level-specific challenges will be set +to *one under* the levels at which they provide bonus skins/heads, for the +challenges which do so. It will leave all other challenges alone. -## How do I write the player data back to a new save file? + python bl2_save_edit.py --challenges bonus old.sav new.sav -Create a new save file from protocol buffer data: +It's also possible to specify both `max` and `bonus`, in which case all +challenges will be set just under their completion level, except for the ones +which provide bonuses, which will then be set to be primed to receive those +bonuses: - python savefile.py player.p your-new-save-game.sav + python bl2_save_edit.py --challenges max --challenges bonus old.sav new.sav -Create a new save file from the JSON data: +## Fixing Negative-Number Challenges - python savefile.py -j player.json your-new-save-game.sav +Some people find that their game starts showing huge negative numbers for their +challenge variables, caused by the savegame values overflowing the in-game +datatypes. Some threads on the issue: +[one](https://steamcommunity.com/app/49520/discussions/0/1327844097129063344/), +[two](https://steamcommunity.com/app/49520/discussions/0/38596748231645372/). +The `--fix-challenge-overflow` option can fix those up for you, setting them +instead to the max value: + + python bl2_save_edit.py --fix-challenge-overflow old.sav new.sav + +Thanks to [Loot Midget](https://github.com/apocalyptech/borderlands2/pull/5) +for this PR! + +## Copying mission data from NVHM to TVHM+UVHM + +This is a very specific thing which I can't imagine anyone but me would ever +be looking for, but it's in here anyway because I needed it once. Basically, +whatever the mission state is from NVHM will be copied to the other two +playthroughs, so they'll appear to be at the exact same state. This will +also unlock TVHM/UVHM if need be. + + python bl2_save_edit.py --copy-nvhm-missions old.sav new.sav + +## Resetting features + +Option `--reset` helps to reset some game features. + +### Bad Touch + +[Bad Touch](https://borderlands.fandom.com/wiki/Bad_Touch) is Moxxi's SMG given to player one per character. +There is some workaround for obtaining Bad Touch many times +but it could be spoiled by teammate who takes it right from the Moxxi's hands. + +Use `--reset bad-touch` option to restore Bad Touch availability. + +### Doctor's Orders + +Player or teammates could suddenly get one of four items for this mission and it will influence the midgets farming. + +Use `--reset doctors-orders` option to reset Doctor's Orders mission. + +Mission will be reset for first active playthrough in this order: UVHM -> TVHM -> Normal mode. + +If you want reset both spoiled TVHM and UVHM missions then run script twice with same option. + +# Getting Savegame Information + +There's a single option which just shows information about the savegame, instead of +changing anything. If you *only* specify options in this category, you can omit +specifying an output filename, and the utility just print out the requested info +and exit. + +## Printing out not-fully-explored levels + +The utility also includes an option to print out levels which the user has not +fully explored. Since this option doesn't actually change the save in any way, +you can omit the output filename when running it: + + bl2_save_edit.py --print-unexplored-levels old.sav + +That will result something like the following output printed on the console +as the editor processes the save: + + World Traveler - Partially (32/36) + More details: https://www.playstationtrophies.org/forum/topic/161466-complete-world-map-world-traveler-trophy/ + Incomplete maps (4): + Natural Selection Annex + Southern Shelf - Bay + The Highlands + Thousand Cuts + + Arctic Explorer - Complete + Urban Explorer - Complete + Highlands Explorer - Partially (2/4) + More details: https://www.trueachievements.com/a167559/highlands-explorer-achievement + Incomplete maps (2): + The Highlands + Thousand Cuts + + Blight Explorer - Complete + Gadabout - Complete + Been There - Complete + +## Challenge Accepted achievement progress + +To avoid scrolling challenges in game user could use dedicated option +for printing data: + + bl2_save_edit.py --diagnose-challenge-accepted old.sav + +That will result something like the following output printed on the console +as the program processes the save: + + Challenge Accepted achievement progress: + - Miscellaneous: JEEEEENKINSSSSSS!!!: first level is incomplete, progress 0/1 + - Shields: Ammo Eater: first level is incomplete, progress 19/20 + - Vehicle: Blue Sparks: first level is incomplete, progress 2/5 + Challenge Accepted: 3 problems found + +# Combining Commandline Options + +In general, the various options can be combined. To make a few changes to a +savegame but save as parsed JSON: + + python bl2_save_edit.py --name "Laura Palmer" --save-game-id 2 --money 3000000 --output json save0001.sav laura.json + +To take that JSON, unlock TVHM and Challenges, and set challenges to their +primed "bonus" levels, and save as a real savefile: + + python bl2_save_edit.py --json --unlock tvhm --unlock challenges --challenges bonus laura.json save0002.sav + +# Working with Savegames to/from Consoles + +**NOTE:** As mentioned above, this fork has not actually been tested on +Consoles, so it's possible that the generated savegames might not work. Use +at your own risk! + +The safest way to convert a PC savegame to Console, or vice-versa, would be to +use JSON as an intermediate step. For the commands which deal with the +console savegames, be sure to specify the `-b` or `--bigendian` options. For +instance, to convert from a Console savegame to a PC savegame: + + python savegame.py -b -o json xbox.sav pc.json + python savegame.py -j pc.json pc.sav + +Or to convert from a PC savegame to a Console savegame: + + python savegame.py --output json pc.sav xbox.json + python savegame.py --json --bigendian xbox.json xbox.sav + +# Exporting character items + +All items stored and held in the character's bank or inventory can be exported +to a text file as a list of codes, in a format compatible with Gibbed's save +editor. This is accomplished with `-o items` or `--output items` like so: + + python bl2_save_edit.py -o items savegame.sav items.txt + +# Importing character items + +A text file of codes generated as above, or assembled by hand, can be imported +into a character using the `-i` or `--import-items` arguments, like so: + + python bl2_save_edit.py -i items.txt old.sav new.sav + python bl2_save_edit.py --import-items items.txt old.sav new.sav + +By default all items will be inserted into the inventory, but this can be +changed with a line containing "; Bank" to indicate that all following items +should go into the bank, or one of either "; Weapons" or "; Items" to indicate +that all following items should go into the inventory. For example, importing +a file containing the following will put a Vault Hunter's Relic into the +inventory and a Righteous Infinity pistol into the bank: + + ; Bank + BL2(h0Hd1Z+jY/s2Qy++Zu8Ba9qXoOmjwJ6NhrlsOmhNMX+oJo5CfQns) + ; Items + BL2(B2vuv4tz1zSQCf2pqLJCS5XD/tKN4FXpjRJLnn1v85U=) -As before, to write a save file that can be read by the PC version add the ---little-endian flag to one of the above, eg: +# Other commandline options + +There are a few other commandline options available when running the utilities. + +## Quiet Output + +By default, the utility is rather chatty and will tell you what it's doing +at all times. To disable output except for errors, use the `-q` or `--quiet` +option: + + python bl2_save_edit.py -q old.sav new.sav + python bl2_save_edit.py --quiet old.sav new.sav + +## Force Overwrites + +By default, the utility will refuse to overwrite a file without getting +confirmation from the user first. To disable that yes/no prompt and force +the app to overwrite the file automatically, use `-f` or `--force` like so: + + python bl2_save_edit.py -f old.sav new.sav + python bl2_save_edit.py --force old.sav new.sav + +## Help + +The utility will also show you what all of its commandline options are +at the commandline, using the `-h` or `--help` options: + + python bl2_save_edit.py -h + python bl2_save_edit.py --help + +Sample output from that option is shown below, though might get out-of-date +if I forget to update this README after updating the program: + +``` +usage: tps_save_edit.py [-h] [-o {savegame,decoded,decodedjson,json,items}] + [-i IMPORT_ITEMS] [-j] [-b] [-q] [-f] [--name NAME] + [--save-game-id SAVE_GAME_ID] [--level LEVEL] + [--money MONEY] [--moonstone MOONSTONE] + [--itemlevels ITEMLEVELS] [--forceitemlevels] + [--backpack BACKPACK] [--bank BANK] + [--gunslots {2,3,4}] + [--unlock {tvhm,uvhm,challenges,ammo}] + [--challenges {zero,max,bonus}] [--maxammo] + input_filename output_filename + +Modify Borderlands: The Pre-Sequel Save Files + +positional arguments: + input_filename Input filename, can be "-" to specify STDIN + output_filename Output filename, can be "-" to specify STDOUT + +optional arguments: + -h, --help show this help message and exit + -o {savegame,decoded,decodedjson,json,items}, --output {savegame,decoded,decodedjson,json,items} + Output file format. The most useful to humans are: + savegame, json, and items (default: savegame) + -i IMPORT_ITEMS, --import-items IMPORT_ITEMS + read in codes for items and add them to the bank and + inventory (default: None) + -j, --json read savegame data from JSON format, rather than + savegame (default: False) + -b, --bigendian change the output format to big-endian, to write + PS/xbox save files (default: False) + -q, --quiet quiet output (should generate no output unless there + are errors) (default: True) + -f, --force force output file overwrite, if the destination file + exists (default: False) + --name NAME Set the name of the character (default: None) + --save-game-id SAVE_GAME_ID + Set the save game slot ID of the character (probably + not actually needed ever) (default: None) + --level LEVEL Set the character to this level (from 1 to 72) + (default: None) + --money MONEY Money to set for character (default: None) + --moonstone MOONSTONE + Moonstone to set for character (default: None) + --itemlevels ITEMLEVELS + Set item levels (to set to current player level, + specify 0).Skips level 1 items unless + --forceitemlevels is specified too (default: None) + --forceitemlevels Set item levels even if the item is at level 1 + (default: False) + --backpack BACKPACK Set size of backpack (maximum is 39, "max" may be + specified) (default: None) + --bank BANK Set size of bank(maximum is 24, "max" may be + specified) (default: None) + --gunslots {2,3,4} Set number of gun slots open (default: None) + --unlock {tvhm,uvhm,challenges,ammo} + Game features to unlock (default: {}) + --challenges {zero,max,bonus} + Levels to set on challenge data (default: {}) + --maxammo Fill all ammo pools to their maximum (default: False) +``` + +# Credits/Thanks + +Thanks to everyone who's worked on this, helped out, submitted bugs, or otherwise +had anything to do with the creation of this utility. In particular: + +* [Paul Clifford](https://github.com/pclifford/) was the original author of the + utility, and did the actual hard work of building this all up from scratch. +* Thanks to Xcier for occasionally poking at this, and for being patient while + I drag my feet on console-related features. +* "Loot Midget" has contributed some handy new options to the utility. Thanks! + +Apologies to anyone who was missed, which I fear is probably a nonzero number +of people! + +# TODO + +1. Borderlands:TPS has a bunch more data stored in the file that we don't + currently parse, so the JSON generated with `-o json` includes a pretty + large `_raw` section up at the top, for all the data we don't know about. + The Gibbed editor seems to know about some of these, and I'd sort of like to + go through and see if anything's worth decoding for this utility. + +2. The internal `modify_save` function starts off with the raw, decoded + protobuf, and each little snippet in there decodes further as-needed, and + then re-encodes once it's done. I wonder if it'd make more sense to just + unwrap the whole thing to JSON at the beginning and then re-wrap at the end, + rather than doing it piecemeal like that. The current method is sort of + nice in that only the bits of the file that actually *need* to get touched + are processed, but on the other hand, it adds a bunch of unnecessary cruft + in there. - python savefile.py -j --little-endian player.json your-new-save-game.sav diff --git a/bl2_save_edit.py b/bl2_save_edit.py new file mode 100755 index 0000000..834ef7d --- /dev/null +++ b/bl2_save_edit.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python3 +import sys + +from borderlands.base_save_edit import run + +if __name__ == '__main__': + run(game_name='BL2', args=sys.argv[1:]) diff --git a/borderlands/__init__.py b/borderlands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/borderlands/base_save_edit.py b/borderlands/base_save_edit.py new file mode 100755 index 0000000..1adc368 --- /dev/null +++ b/borderlands/base_save_edit.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 + +import sys +import traceback +from typing import List + +from borderlands.bl2 import AppBL2 +from borderlands.bltps import AppTPS +from borderlands.savefile import BaseApp + +MIN_PYTHON = (3, 9) + +ERROR_TEMPLATE = """ +Something went wrong, but please ensure you have the latest +version from https://github.com/apocalyptech/borderlands2 before +reporting a bug. Information useful for a report follows: + +Arguments: {} + +""" + + +def python_version_check(): + if sys.version_info < MIN_PYTHON: + sys.exit( + f'ERROR: Python {MIN_PYTHON[0]}.{MIN_PYTHON[1]} is required to run this utility' + + f' but you use {sys.version_info[0]}.{sys.version_info[1]}' + ) + + +def run(*, game_name: str, args: List[str]) -> None: + python_version_check() + + # noinspection PyBroadException + try: + app: BaseApp + if game_name == 'BL2': + app = AppBL2(args) + elif game_name == 'TPS': + app = AppTPS(args) + else: + raise RuntimeError(f'unknown game: {game_name!r}') + + app.run() + + except Exception: + sys.stdout.flush() + sys.stderr.flush() + print(ERROR_TEMPLATE.format(repr(args)), file=sys.stderr) + traceback.print_exc(None, sys.stderr) + sys.exit(1) diff --git a/borderlands/bl2.py b/borderlands/bl2.py new file mode 100644 index 0000000..8d67525 --- /dev/null +++ b/borderlands/bl2.py @@ -0,0 +1,297 @@ +import argparse +import sys +from typing import List, Dict, Any, Optional + +from borderlands import bl2_data +from borderlands.bl2_explorer_achievements import create_explorer_achievements_report +from borderlands.bl2_routines import get_reset_proc, get_valid_reset_option_values +from borderlands.datautil.common import unwrap_float, wrap_float, unwrap_bytes, wrap_bytes +from borderlands.datautil.data_types import PlayerDict +from borderlands.savefile import BaseApp + + +def bl2_op_level(value: Any) -> Optional[int]: + """ + Helper function for argparse which requires a valid Overpower level + """ + if value is None: + return None + try: + int_val = int(value) + except ValueError: + raise argparse.ArgumentTypeError('OP Levels must be from 0 to 10') + if 0 <= int_val <= 10: + return int_val + raise argparse.ArgumentTypeError('OP Levels must be from 0 to 10') + + +class AppBL2(BaseApp): + """ + Our main application class for Borderlands 2 + """ + + def __init__(self, args: List[str]) -> None: + super().__init__( + args=args, + item_struct_version=7, + game_name='Borderlands 2', + item_prefix='BL2', + max_level=80, + black_market_keys=( + 'rifle', + 'pistol', + 'launcher', + 'shotgun', + 'smg', + 'sniper', + 'grenade', + 'backpack', + 'bank', + ), + black_market_ammo={ + 'grenade': [3, 4, 5, 6, 7, 8, 9, 10], + 'launcher': [12, 15, 18, 21, 24, 27, 30, 33], + 'pistol': [200, 300, 400, 500, 600, 700, 800, 900], + 'rifle': [280, 420, 560, 700, 840, 980, 1120, 1260], + 'shotgun': [80, 100, 120, 140, 160, 180, 200, 220], + 'smg': [360, 540, 720, 900, 1080, 1260, 1440, 1620], + 'sniper': [48, 60, 72, 84, 96, 108, 120, 132], + }, + unlock_choices=['slaughterdome', 'tvhm', 'uvhm', 'challenges', 'ammo'], + challenges=bl2_data.create_bl2_challenges(), + ) + + def create_save_structure(self) -> Dict[int, Any]: + """ + Sets up our main save_structure var which controls how we read the file + """ + return { + 1: "class", + 2: "level", + 3: "experience", + 4: "skill_points", + 6: ("currency", True, 0), + 7: "playthroughs_completed", + 8: ("skills", True, {1: "name", 2: "level", 3: "unknown3", 4: "unknown4"}), + 11: ( + "resources", + True, + {1: "resource", 2: "pool", 3: ("amount", False, (unwrap_float, wrap_float)), 4: "level"}, + ), + 13: ("sizes", False, {1: "inventory", 2: "weapon_slots", 3: "weapon_slots_shown"}), + 15: ("stats", False, (self.unwrap_challenges, self.wrap_challenges)), + 16: ("active_fast_travel", True, None), + 17: "last_fast_travel", + 18: ( + "missions", + True, + { + 1: "playthrough", + 2: "active", + 3: ( + "data", + True, + { + 1: "name", + 2: "status", + 3: "is_from_dlc", + 4: "dlc_id", + 5: ("unknown5", False, (unwrap_bytes, wrap_bytes)), + 6: "unknown6", + 7: ("unknown7", False, (unwrap_bytes, wrap_bytes)), + 8: "unknown8", + 9: "unknown9", + 10: "unknown10", + 11: "level", + }, + ), + }, + ), + 19: ( + "appearance", + False, + { + 1: "name", + 2: ("color1", False, {1: "a", 2: "r", 3: "g", 4: "b"}), + 3: ("color2", False, {1: "a", 2: "r", 3: "g", 4: "b"}), + 4: ("color3", False, {1: "a", 2: "r", 3: "g", 4: "b"}), + }, + ), + 20: "save_game_id", + 21: "mission_number", + 23: ("unlocks", False, (unwrap_bytes, wrap_bytes)), + 24: ("unlock_notifications", False, (unwrap_bytes, wrap_bytes)), + 25: "time_played", + 26: "save_timestamp", + 29: ( + "game_stages", + True, + { + 1: "name", + 2: "level", + 3: "is_from_dlc", + 4: "dlc_id", + 5: "playthrough", + }, + ), + 30: ("areas", True, {1: "name", 2: "unknown2"}), + 34: ( + "id", + False, + { + 1: ("a", False, 5), + 2: ("b", False, 5), + 3: ("c", False, 5), + 4: ("d", False, 5), + }, + ), + 35: ("wearing", True, None), + 36: ("black_market", False, (self.unwrap_black_market, self.wrap_black_market)), + 37: "active_mission", + 38: ("challenges", True, {1: "name", 2: "is_from_dlc", 3: "dlc_id"}), + 41: ( + "bank", + True, + { + 1: ("data", False, (self.unwrap_item_info, self.wrap_item_info)), + }, + ), + 43: ("lockouts", True, {1: "name", 2: "time", 3: "is_from_dlc", 4: "dlc_id"}), + 46: ("explored_areas", True, None), + 49: "active_playthrough", + 53: ( + "items", + True, + { + 1: ("data", False, (self.unwrap_item_info, self.wrap_item_info)), + 2: "unknown2", + 3: "is_equipped", + 4: "star", + }, + ), + 54: ( + "weapons", + True, + { + 1: ("data", False, (self.unwrap_item_info, self.wrap_item_info)), + 2: "slot", + 3: "star", + 4: "unknown4", + }, + ), + 55: "stats_bonuses_disabled", + 56: "bank_size", + } + + @staticmethod + def setup_currency_args(parser) -> None: + """ + Adds the options we're using to control currency + """ + + parser.add_argument( + '--eridium', + type=int, + help='Eridium to set for character', + ) + + parser.add_argument( + '--seraph', + type=int, + help='Seraph crystals to set for character', + ) + + parser.add_argument( + '--torgue', + type=int, + help='Torgue tokens to set for character', + ) + + @staticmethod + def setup_game_specific_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + '--oplevel', + type=bl2_op_level, + dest='op_level', + help='OP Level to unlock (will also unlock TVHM/UVHM if not already unlocked)', + ) + parser.add_argument( + '--diagnose-challenge-accepted', + action='store_true', + help='print challenges that have to be completed up to level 1 in order to finish Challenge Accepted', + ) + parser.add_argument( + '--reset', + dest='reset_key', + type=str, + help='reset specific mission or challenge. Valid options are: %r' + % (sorted(get_valid_reset_option_values()),), + ) + + def report_explorer_achievements_progress(self, player: PlayerDict) -> None: + fully_explored_maps = self.get_fully_explored_areas(player) + report = create_explorer_achievements_report(fully_explored_maps) + for line in report: + self.notice(line) + + def _show_save_info(self, player: PlayerDict) -> None: + super()._show_save_info(player) + + if self.config.diagnose_challenge_accepted: + self._diagnose_challenge_accepted(player) + + def _diagnose_challenge_accepted(self, player: PlayerDict) -> None: + self.notice('Challenge Accepted achievement progress:') + data = self.unwrap_challenges(player[15][0][1]) + + challenges = data['challenges'] + max_signed_int32 = 2147483647 + + problems = [] + for save_challenge in challenges: + if save_challenge['id'] not in self.challenges: + continue + + challenge = self.challenges[save_challenge['id']] + if not challenge.category.bl2_is_in_challenge_accepted: + continue + + current_value = save_challenge['total_value'] + if current_value > max_signed_int32: + message1 = '%s: %s: current value (%d) is too huge / corrupted / will show negative in the game.' % ( + challenge.category.name, + challenge.name, + current_value, + ) + problems.append(message1 + ' Please use --fix-challenge-overflow option to fix it.') + continue + + first_level = challenge.levels[0] + if current_value < first_level: + problems.append( + '%s: %s: first level is incomplete, progress %d/%d' + % (challenge.category.name, challenge.name, current_value, first_level) + ) + continue + + if problems: + problems.sort() + for p in problems: + self.notice('- ' + p) + + self.notice('Challenge Accepted: %d problems found' % len(problems)) + else: + self.notice('Challenge Accepted: no problems found. It looks like Challenge Accepted already achieved.') + + self.notice('') + + def _reset_challenge_or_mission(self, player: PlayerDict) -> None: + reset_key = self.config.reset_key + if reset_key is None: + return + patch_proc = get_reset_proc(reset_key) + if patch_proc is None: + sys.exit(f'--reset: unknown key: {reset_key!r}') + + self.notice('Reset: ' + reset_key) + patch_proc(player, self.config.endian) diff --git a/borderlands/bl2_data.py b/borderlands/bl2_data.py new file mode 100644 index 0000000..cb097bb --- /dev/null +++ b/borderlands/bl2_data.py @@ -0,0 +1,1421 @@ +from typing import Dict + +from borderlands.challenges import ChallengeCategory, Challenge + + +def create_bl2_challenges() -> Dict[int, Challenge]: + # Challenge categories + challenge_cat_dlc4 = ChallengeCategory("Hammerlock's Hunt", 4) + challenge_cat_dlc3 = ChallengeCategory("Campaign of Carnage", 3) + challenge_cat_dlc9 = ChallengeCategory("Dragon Keep", 9) + challenge_cat_dlc1 = ChallengeCategory("Pirate's Booty", 1) + challenge_cat_enemies = ChallengeCategory("Enemies", bl2_is_in_challenge_accepted=True) + challenge_cat_elemental = ChallengeCategory("Elemental", bl2_is_in_challenge_accepted=True) + challenge_cat_loot = ChallengeCategory("Loot", bl2_is_in_challenge_accepted=True) + challenge_cat_money = ChallengeCategory("Money and Trading", bl2_is_in_challenge_accepted=True) + challenge_cat_vehicle = ChallengeCategory("Vehicle", bl2_is_in_challenge_accepted=True) + challenge_cat_health = ChallengeCategory("Health and Recovery", bl2_is_in_challenge_accepted=True) + challenge_cat_grenades = ChallengeCategory("Grenades", bl2_is_in_challenge_accepted=True) + challenge_cat_shields = ChallengeCategory("Shields", bl2_is_in_challenge_accepted=True) + challenge_cat_rockets = ChallengeCategory("Rocket Launcher", bl2_is_in_challenge_accepted=True) + challenge_cat_sniper = ChallengeCategory("Sniper Rifle", bl2_is_in_challenge_accepted=True) + challenge_cat_ar = ChallengeCategory("Assault Rifle", bl2_is_in_challenge_accepted=True) + challenge_cat_smg = ChallengeCategory("SMG", bl2_is_in_challenge_accepted=True) + challenge_cat_shotgun = ChallengeCategory("Shotgun", bl2_is_in_challenge_accepted=True) + challenge_cat_pistol = ChallengeCategory("Pistol", bl2_is_in_challenge_accepted=True) + challenge_cat_melee = ChallengeCategory("Melee", bl2_is_in_challenge_accepted=True) + challenge_cat_combat = ChallengeCategory("General Combat", bl2_is_in_challenge_accepted=True) + challenge_cat_misc = ChallengeCategory("Miscellaneous", bl2_is_in_challenge_accepted=True) + + # There are two possible ways of uniquely identifying challenges in this file: + # via their numeric position in the list, or by what looks like an internal + # ID (though that ID is constructed a little weirdly, so I'm not sure if it's + # actually intended to be used that way or not). + # + # I did run some tests, and it looks like internally, B2 probably does use + # that ID field to identify the challenges... You can mess around with the + # order in which they're saved to the file, but so long as the ID field + # is still pointing to the challenge you want, it'll be read in properly + # (and then when you save your game, they'll be written back out in the + # original order). + # + # Given that, I decided to go ahead and use that probably-ID field as the + # index on this dict, rather than the order. That should be slightly more + # flexible for anyone editing the JSON directly, and theoretically + # shouldn't be a problem in the future since there won't be any new major + # DLC for B2... + # noinspection PyDictCreation + challenges: Dict[int, Challenge] = {} + + # Hammerlock DLC Challenges + challenges[1752] = Challenge( + position=305, + identifier=1752, + id_text="GD_Sage_Challenges.Challenges.Challenge_Sage_KillSavages", + category=challenge_cat_dlc4, + name="Savage Bloody Savage", + description="Kill savages", + levels=(20, 50, 100, 250, 500), + ) + challenges[1750] = Challenge( + position=303, + identifier=1750, + id_text="GD_Sage_Challenges.Challenges.Challenge_Sage_KillDrifters", + category=challenge_cat_dlc4, + name="Harder They Fall", + description="Kill drifters", + levels=(5, 15, 30, 40, 50), + ) + challenges[1751] = Challenge( + position=304, + identifier=1751, + id_text="GD_Sage_Challenges.Challenges.Challenge_Sage_KillFanBoats", + category=challenge_cat_dlc4, + name="Fan Boy", + description="Kill Fan Boats", + levels=(5, 10, 15, 20, 30), + ) + challenges[1753] = Challenge( + position=306, + identifier=1753, + id_text="GD_Sage_Challenges.Challenges.Challenge_Sage_RaidBossA", + category=challenge_cat_dlc4, + name="Voracidous the Invincible", + description="Defeat Voracidous the Invincible", + levels=(1, 3, 5, 10, 15), + ) + challenges[1952] = Challenge( + position=307, + identifier=1952, + id_text="GD_Sage_Challenges.Challenges.Challenge_Sage_KillBoroks", + category=challenge_cat_dlc4, + name="Boroking Around", + description="kill boroks", + levels=(10, 20, 50, 80, 120), + ) + challenges[1953] = Challenge( + position=308, + identifier=1953, + id_text="GD_Sage_Challenges.Challenges.Challenge_Sage_KillScaylions", + category=challenge_cat_dlc4, + name="Stinging Sensation", + description="Kill scaylions", + levels=(10, 20, 50, 80, 120), + ) + + # Torgue DLC Challenges + challenges[1756] = Challenge( + position=310, + identifier=1756, + id_text="GD_Iris_Challenges.Challenges.Challenge_Iris_KillMotorcycles", + category=challenge_cat_dlc3, + name="Bikes Destroyed", + description="Destroy Bikes", + levels=(10, 20, 30, 50, 80), + ) + challenges[1757] = Challenge( + position=311, + identifier=1757, + id_text="GD_Iris_Challenges.Challenges.Challenge_Iris_KillBikers", + category=challenge_cat_dlc3, + name="Bikers Killed", + description="Bikers Killed", + levels=(50, 100, 150, 200, 250), + ) + challenges[1950] = Challenge( + position=316, + identifier=1950, + id_text="GD_Iris_Challenges.Challenges.Challenge_Iris_TorgueTokens", + category=challenge_cat_dlc3, + name="Torgue Tokens Acquired", + description="Acquire Torgue Tokens", + levels=(100, 250, 500, 750, 1000), + ) + challenges[1949] = Challenge( + position=315, + identifier=1949, + id_text="GD_Iris_Challenges.Challenges.Challenge_Iris_BuyTorgueItems", + category=challenge_cat_dlc3, + name="Torgue Items Purchased", + description="Purchase Torgue Items with Tokens", + levels=(2, 5, 8, 12, 15), + ) + challenges[1758] = Challenge( + position=312, + identifier=1758, + id_text="GD_Iris_Challenges.Challenges.Challenge_Iris_CompleteBattles", + category=challenge_cat_dlc3, + name="Battles Completed", + description="Complete All Battles", + levels=(1, 4, 8, 12), + ) + challenges[1759] = Challenge( + position=313, + identifier=1759, + id_text="GD_Iris_Challenges.Challenges.Challenge_Iris_Raid1", + category=challenge_cat_dlc3, + name="Pete The Invincible Defeated", + description="Defeat Pete the Invincible", + levels=(1, 3, 5, 10, 15), + ) + + # Tiny Tina DLC Challenges + challenges[1954] = Challenge( + position=318, + identifier=1954, + id_text="GD_Aster_Challenges.Challenges.Challenge_Aster_KillDwarves", + category=challenge_cat_dlc9, + name="Scot-Free", + description="Kill dwarves", + levels=(50, 100, 150, 200, 250), + ) + challenges[1768] = Challenge( + position=320, + identifier=1768, + id_text="GD_Aster_Challenges.Challenges.Challenge_Aster_KillGolems", + category=challenge_cat_dlc9, + name="Rock Out With Your Rock Out", + description="Kill golems", + levels=(10, 25, 50, 80, 120), + ) + challenges[1769] = Challenge( + position=321, + identifier=1769, + id_text="GD_Aster_Challenges.Challenges.Challenge_Aster_KillKnights", + category=challenge_cat_dlc9, + name="Knighty Knight", + description="Kill knights", + levels=(10, 25, 75, 120, 175), + ) + challenges[1771] = Challenge( + position=323, + identifier=1771, + id_text="GD_Aster_Challenges.Challenges.Challenge_Aster_KillOrcs", + category=challenge_cat_dlc9, + name="Orcs Should Perish", + description="Kill orcs", + levels=(50, 100, 150, 200, 250), + ) + challenges[1772] = Challenge( + position=324, + identifier=1772, + id_text="GD_Aster_Challenges.Challenges.Challenge_Aster_KillSkeletons", + category=challenge_cat_dlc9, + name="Bone Breaker", + description="Kill skeletons", + levels=(50, 100, 150, 200, 250), + ) + challenges[1773] = Challenge( + position=325, + identifier=1773, + id_text="GD_Aster_Challenges.Challenges.Challenge_Aster_KillSpiders", + category=challenge_cat_dlc9, + name="Ew Ew Ew Ew", + description="Kill spiders", + levels=(25, 50, 100, 150, 200), + ) + challenges[1774] = Challenge( + position=326, + identifier=1774, + id_text="GD_Aster_Challenges.Challenges.Challenge_Aster_KillTreants", + category=challenge_cat_dlc9, + name="Cheerful Green Giants", + description="Kill treants", + levels=(10, 20, 50, 80, 120), + ) + challenges[1775] = Challenge( + position=327, + identifier=1775, + id_text="GD_Aster_Challenges.Challenges.Challenge_Aster_KillWizards", + category=challenge_cat_dlc9, + name="Magical Massacre", + description="Kill wizards", + levels=(10, 20, 50, 80, 120), + ) + challenges[1754] = Challenge( + position=317, + identifier=1754, + id_text="GD_Aster_Challenges.Challenges.Challenge_Aster_KillDragons", + category=challenge_cat_dlc9, + name="Fus Roh Die", + description="Kill dragons", + levels=(10, 20, 50, 80, 110), + ) + challenges[1770] = Challenge( + position=322, + identifier=1770, + id_text="GD_Aster_Challenges.Challenges.Challenge_Aster_KillMimics", + category=challenge_cat_dlc9, + name="Can't Fool Me", + description="Kill mimics", + levels=(5, 15, 30, 50, 75), + ) + + # Captain Scarlett DLC Challenges + challenges[1743] = Challenge( + position=298, + identifier=1743, + id_text="GD_Orchid_Challenges.Challenges.Challenge_Orchid_Crystals", + category=challenge_cat_dlc1, + name="In The Pink", + description="Collect Seraph Crystals", + levels=(80, 160, 240, 320, 400), + ) + challenges[1755] = Challenge( + position=299, + identifier=1755, + id_text="GD_Orchid_Challenges.Challenges.Challenge_Orchid_Purchase", + category=challenge_cat_dlc1, + name="Shady Dealings", + description="Purchase Items With Seraph Crystals", + levels=(1, 3, 5, 10, 15), + ) + challenges[1745] = Challenge( + position=294, + identifier=1745, + id_text="GD_Orchid_Challenges.Challenges.Challenge_Orchid_KillWorms", + category=challenge_cat_dlc1, + name="Worm Killer", + description="Kill Sand Worms", + levels=(10, 20, 30, 50, 80), + ) + challenges[1746] = Challenge( + position=295, + identifier=1746, + id_text="GD_Orchid_Challenges.Challenges.Challenge_Orchid_KillBandits", + category=challenge_cat_dlc1, + name="Land Lubber", + description="Kill Pirates", + levels=(50, 100, 150, 200, 250), + ) + challenges[1747] = Challenge( + position=296, + identifier=1747, + id_text="GD_Orchid_Challenges.Challenges.Challenge_Orchid_KillHovercrafts", + category=challenge_cat_dlc1, + name="Hovernator", + description="Destroy Pirate Hovercrafts", + levels=(5, 10, 15, 20, 30), + ) + challenges[1748] = Challenge( + position=297, + identifier=1748, + id_text="GD_Orchid_Challenges.Challenges.Challenge_Orchid_PirateChests", + category=challenge_cat_dlc1, + name="Pirate Booty", + description="Open Pirate Chests", + levels=(25, 75, 150, 250, 375), + ) + challenges[1742] = Challenge( + position=292, + identifier=1742, + id_text="GD_Orchid_Challenges.Challenges.Challenge_Orchid_Raid1", + category=challenge_cat_dlc1, + name="Hyperius the Not-So-Invincible", + description="Divide Hyperius by zero", + levels=(1, 3, 5, 10, 15), + ) + challenges[1744] = Challenge( + position=293, + identifier=1744, + id_text="GD_Orchid_Challenges.Challenges.Challenge_Orchid_Raid3", + category=challenge_cat_dlc1, + name="Master Worm Food", + description="Feed Master Gee to his worms", + levels=(1, 3, 5, 10, 15), + ) + + # Enemies + challenges[1632] = Challenge( + position=24, + identifier=1632, + id_text="GD_Challenges.enemies.Enemies_KillSkags", + category=challenge_cat_enemies, + name="Skags to Riches", + description="Kill skags", + levels=(10, 25, 75, 150, 300), + ) + challenges[1675] = Challenge( + position=84, + identifier=1675, + id_text="GD_Challenges.enemies.Enemies_KillConstructors", + category=challenge_cat_enemies, + name="Constructor Destructor", + description="Kill constructors", + levels=(5, 12, 20, 30, 50), + ) + challenges[1655] = Challenge( + position=80, + identifier=1655, + id_text="GD_Challenges.enemies.Enemies_KillLoaders", + category=challenge_cat_enemies, + name="Load and Lock", + description="Kill loaders", + levels=(20, 100, 500, 1000, 1500), + bonus=3, + ) + challenges[1651] = Challenge( + position=76, + identifier=1651, + id_text="GD_Challenges.enemies.Enemies_KillBullymongs", + category=challenge_cat_enemies, + name="Bully the Bullies", + description="Kill bullymongs", + levels=(25, 50, 150, 300, 750), + ) + challenges[1652] = Challenge( + position=77, + identifier=1652, + id_text="GD_Challenges.enemies.Enemies_KillCrystalisks", + category=challenge_cat_enemies, + name="Crystals are a Girl's Best Friend", + description="Kill crystalisks", + levels=(10, 25, 50, 80, 120), + ) + challenges[1653] = Challenge( + position=78, + identifier=1653, + id_text="GD_Challenges.enemies.Enemies_KillGoliaths", + category=challenge_cat_enemies, + name="WHY SO MUCH HURT?!", + description="Kill goliaths", + levels=(10, 25, 50, 80, 120), + ) + challenges[1654] = Challenge( + position=79, + identifier=1654, + id_text="GD_Challenges.enemies.Enemies_KillEngineers", + category=challenge_cat_enemies, + name="Paingineering", + description="Kill Hyperion personnel", + levels=(10, 25, 75, 150, 300), + ) + challenges[1658] = Challenge( + position=83, + identifier=1658, + id_text="GD_Challenges.enemies.Enemies_KillSurveyors", + category=challenge_cat_enemies, + name="Just a Moment of Your Time...", + description="Kill surveyors", + levels=(10, 25, 75, 150, 300), + ) + challenges[1694] = Challenge( + position=87, + identifier=1694, + id_text="GD_Challenges.enemies.Enemies_KillNomads", + category=challenge_cat_enemies, + name="You (No)Mad, Bro?", + description="Kill nomads", + levels=(10, 25, 75, 150, 300), + ) + challenges[1695] = Challenge( + position=88, + identifier=1695, + id_text="GD_Challenges.enemies.Enemies_KillPsychos", + category=challenge_cat_enemies, + name="Mama's Boys", + description="Kill psychos", + levels=(50, 100, 150, 300, 500), + ) + challenges[1696] = Challenge( + position=89, + identifier=1696, + id_text="GD_Challenges.enemies.Enemies_KillRats", + category=challenge_cat_enemies, + name="You Dirty Rat", + description="Kill rats. Yes, really.", + levels=(10, 25, 75, 150, 300), + ) + challenges[1791] = Challenge( + position=93, + identifier=1791, + id_text="GD_Challenges.enemies.Enemies_KillSpiderants", + category=challenge_cat_enemies, + name="Pest Control", + description="Kill spiderants", + levels=(10, 25, 75, 150, 300), + ) + challenges[1792] = Challenge( + position=94, + identifier=1792, + id_text="GD_Challenges.enemies.Enemies_KillStalkers", + category=challenge_cat_enemies, + name="You're One Ugly Mother...", + description="Kill stalkers", + levels=(10, 25, 75, 150, 300), + ) + challenges[1793] = Challenge( + position=95, + identifier=1793, + id_text="GD_Challenges.enemies.Enemies_KillThreshers", + category=challenge_cat_enemies, + name="Tentacle Obsession", + description="Kill threshers", + levels=(10, 25, 75, 150, 300), + ) + challenges[1693] = Challenge( + position=86, + identifier=1693, + id_text="GD_Challenges.enemies.Enemies_KillMarauders", + category=challenge_cat_enemies, + name="Marauder? I Hardly Know 'Er!", + description="Kill marauders", + levels=(20, 100, 500, 1000, 1500), + bonus=3, + ) + challenges[1794] = Challenge( + position=96, + identifier=1794, + id_text="GD_Challenges.enemies.Enemies_KillVarkid", + category=challenge_cat_enemies, + name="Another Bug Hunt", + description="Kill varkids", + levels=(10, 25, 75, 150, 300), + ) + challenges[1795] = Challenge( + position=97, + identifier=1795, + id_text="GD_Challenges.enemies.Enemies_KillGyros", + category=challenge_cat_enemies, + name="Die in the Friendly Skies", + description="Kill buzzards", + levels=(10, 25, 45, 70, 100), + ) + challenges[1796] = Challenge( + position=98, + identifier=1796, + id_text="GD_Challenges.enemies.Enemies_KillMidgets", + category=challenge_cat_enemies, + name="Little Person, Big Pain", + description="Kill midgets", + levels=(10, 25, 75, 150, 300), + ) + challenges[1895] = Challenge( + position=249, + identifier=1895, + id_text="GD_Challenges.enemies.Enemies_ShootBullymongProjectiles", + category=challenge_cat_enemies, + name="Hurly Burly", + description="Shoot bullymong-tossed projectiles out of midair", + levels=(10, 25, 50, 125, 250), + ) + challenges[1896] = Challenge( + position=250, + identifier=1896, + id_text="GD_Challenges.enemies.Enemies_ReleaseChainedMidgets", + category=challenge_cat_enemies, + name="Short-Chained", + description="Shoot chains to release midgets from shields", + levels=(1, 5, 15, 30, 50), + ) + challenges[1934] = Challenge( + position=99, + identifier=1934, + id_text="GD_Challenges.enemies.Enemies_KillBruisers", + category=challenge_cat_enemies, + name="Cruising for a Bruising", + description="Kill bruisers", + levels=(10, 25, 75, 150, 300), + ) + challenges[1732] = Challenge( + position=91, + identifier=1732, + id_text="GD_Challenges.enemies.Enemies_KillVarkidPods", + category=challenge_cat_enemies, + name="Pod Pew Pew", + description="Kill varkid pods before they hatch", + levels=(10, 25, 45, 70, 100), + ) + + # Elemental + challenges[1873] = Challenge( + position=225, + identifier=1873, + id_text="GD_Challenges.elemental.Elemental_SetEnemiesOnFire", + category=challenge_cat_elemental, + name="Cowering Inferno", + description="Ignite enemies", + levels=(25, 100, 400, 1000, 2000), + ) + challenges[1642] = Challenge( + position=40, + identifier=1642, + id_text="GD_Challenges.elemental.Elemental_KillEnemiesCorrosive", + category=challenge_cat_elemental, + name="Acid Trip", + description="Kill enemies with corrode damage", + levels=(20, 75, 250, 600, 1000), + ) + challenges[1645] = Challenge( + position=43, + identifier=1645, + id_text="GD_Challenges.elemental.Elemental_KillEnemiesExplosive", + category=challenge_cat_elemental, + name="Boom.", + description="Kill enemies with explosive damage", + levels=(20, 75, 250, 600, 1000), + bonus=3, + ) + challenges[1877] = Challenge( + position=229, + identifier=1877, + id_text="GD_Challenges.elemental.Elemental_DealFireDOTDamage", + category=challenge_cat_elemental, + name="I Just Want to Set the World on Fire", + description="Deal burn damage", + levels=(2500, 20000, 100000, 500000, 1000000), + bonus=5, + ) + challenges[1878] = Challenge( + position=230, + identifier=1878, + id_text="GD_Challenges.elemental.Elemental_DealCorrosiveDOTDamage", + category=challenge_cat_elemental, + name="Corroderate", + description="Deal corrode damage", + levels=(2500, 20000, 100000, 500000, 1000000), + ) + challenges[1879] = Challenge( + position=231, + identifier=1879, + id_text="GD_Challenges.elemental.Elemental_DealShockDOTDamage", + category=challenge_cat_elemental, + name='Say "Watt" Again', + description="Deal electrocute damage", + levels=(5000, 20000, 100000, 500000, 1000000), + ) + challenges[1880] = Challenge( + position=232, + identifier=1880, + id_text="GD_Challenges.elemental.Elemental_DealBonusSlagDamage", + category=challenge_cat_elemental, + name="Slag-Licked", + description="Deal bonus damage to Slagged enemies", + levels=(5000, 25000, 150000, 1000000, 5000000), + bonus=3, + ) + + # Loot + challenges[1898] = Challenge( + position=251, + identifier=1898, + id_text="GD_Challenges.Pickups.Inventory_PickupWhiteItems", + category=challenge_cat_loot, + name="Another Man's Treasure", + description="Loot or purchase white items", + levels=(50, 125, 250, 400, 600), + ) + challenges[1899] = Challenge( + position=252, + identifier=1899, + id_text="GD_Challenges.Pickups.Inventory_PickupGreenItems", + category=challenge_cat_loot, + name="It's Not Easy Looting Green", + description="Loot or purchase green items", + levels=(20, 50, 75, 125, 200), + bonus=3, + ) + challenges[1900] = Challenge( + position=253, + identifier=1900, + id_text="GD_Challenges.Pickups.Inventory_PickupBlueItems", + category=challenge_cat_loot, + name="I Like My Treasure Rare", + description="Loot or purchase blue items", + levels=(5, 12, 20, 30, 45), + ) + challenges[1901] = Challenge( + position=254, + identifier=1901, + id_text="GD_Challenges.Pickups.Inventory_PickupPurpleItems", + category=challenge_cat_loot, + name="Purple Reign", + description="Loot or purchase purple items", + levels=(2, 4, 7, 12, 20), + ) + challenges[1902] = Challenge( + position=255, + identifier=1902, + id_text="GD_Challenges.Pickups.Inventory_PickupOrangeItems", + category=challenge_cat_loot, + name="Nothing Rhymes with Orange", + description="Loot or purchase orange items", + levels=(1, 3, 6, 10, 15), + bonus=5, + ) + challenges[1669] = Challenge( + position=108, + identifier=1669, + id_text="GD_Challenges.Loot.Loot_OpenChests", + category=challenge_cat_loot, + name="The Call of Booty", + description="Open treasure chests", + levels=(5, 25, 50, 125, 250), + ) + challenges[1670] = Challenge( + position=109, + identifier=1670, + id_text="GD_Challenges.Loot.Loot_OpenLootables", + category=challenge_cat_loot, + name="Open Pandora's Boxes", + description="Open lootable chests, lockers, and other objects", + levels=(50, 250, 750, 1500, 2500), + bonus=3, + ) + challenges[1630] = Challenge( + position=8, + identifier=1630, + id_text="GD_Challenges.Loot.Loot_PickUpWeapons", + category=challenge_cat_loot, + name="Gun Runner", + description="Pick up or purchase weapons", + levels=(10, 25, 150, 300, 750), + ) + + # Money + challenges[1858] = Challenge( + position=118, + identifier=1858, + id_text="GD_Challenges.Economy.Economy_MoneySaved", + category=challenge_cat_money, + name="For the Hoard!", + description="Save a lot of money", + levels=(10000, 50000, 250000, 1000000, 3000000), + bonus=3, + ) + challenges[1859] = Challenge( + position=119, + identifier=1859, + id_text="GD_Challenges.Economy.General_MoneyFromCashDrops", + category=challenge_cat_money, + name="Dolla Dolla Bills, Y'all", + description="Collect dollars from cash drops", + levels=(5000, 25000, 125000, 500000, 1000000), + ) + challenges[1678] = Challenge( + position=112, + identifier=1678, + id_text="GD_Challenges.Economy.Economy_SellItems", + category=challenge_cat_money, + name="Wholesale", + description="Sell items to vending machines", + levels=(10, 25, 150, 300, 750), + ) + challenges[1860] = Challenge( + position=113, + identifier=1860, + id_text="GD_Challenges.Economy.Economy_PurchaseItemsOfTheDay", + category=challenge_cat_money, + name="Limited-Time Offer", + description="Buy Items of the Day", + levels=(1, 5, 15, 30, 50), + ) + challenges[1810] = Challenge( + position=111, + identifier=1810, + id_text="GD_Challenges.Economy.Economy_BuyItemsWithEridium", + category=challenge_cat_money, + name="Whaddaya Buyin'?", + description="Purchase items with Eridium", + levels=(2, 5, 9, 14, 20), + bonus=4, + ) + challenges[1805] = Challenge( + position=214, + identifier=1805, + id_text="GD_Challenges.Economy.Trade_ItemsWithPlayers", + category=challenge_cat_money, + name="Psst, Hey Buddy...", + description="Trade with other players", + levels=(1, 5, 15, 30, 50), + ) + + # Vehicle + challenges[1640] = Challenge( + position=37, + identifier=1640, + id_text="GD_Challenges.Vehicles.Vehicles_KillByRamming", + category=challenge_cat_vehicle, + name="Hit-and-Fun", + description="Kill enemies by ramming them with a vehicle", + levels=(5, 10, 50, 100, 200), + ) + challenges[1920] = Challenge( + position=275, + identifier=1920, + id_text="GD_Challenges.Vehicles.Vehicles_KillByPowerSlide", + category=challenge_cat_vehicle, + name="Blue Sparks", + description="Kill enemies by power-sliding over them in a vehicle", + levels=(5, 15, 30, 50, 75), + bonus=3, + ) + challenges[1641] = Challenge( + position=38, + identifier=1641, + id_text="GD_Challenges.Vehicles.Vehicles_KillsWithVehicleWeapon", + category=challenge_cat_vehicle, + name="Turret Syndrome", + description="Kill enemies using a turret or vehicle-mounted weapon", + levels=(10, 25, 150, 300, 750), + ) + challenges[1922] = Challenge( + position=277, + identifier=1922, + id_text="GD_Challenges.Vehicles.Vehicles_VehicleKillsVehicle", + category=challenge_cat_vehicle, + name="...One Van Leaves", + description="Kill vehicles while in a vehicle", + levels=(5, 10, 50, 100, 200), + ) + challenges[1919] = Challenge( + position=274, + identifier=1919, + id_text="GD_Challenges.Vehicles.Vehicles_KillsWhilePassenger", + category=challenge_cat_vehicle, + name="Passive Aggressive", + description="Kill enemies while riding as a passenger (not a gunner) in a vehicle", + levels=(1, 10, 50, 100, 200), + ) + + # Health + challenges[1917] = Challenge( + position=270, + identifier=1917, + id_text="GD_Challenges.Player.Player_PointsHealed", + category=challenge_cat_health, + name="Heal Plz", + description="Recover health", + levels=(1000, 25000, 150000, 1000000, 5000000), + ) + challenges[1865] = Challenge( + position=200, + identifier=1865, + id_text="GD_Challenges.Player.Player_SecondWind", + category=challenge_cat_health, + name="I'll Just Help Myself", + description="Get Second Winds by killing an enemy", + levels=(5, 10, 50, 100, 200), + ) + challenges[1866] = Challenge( + position=201, + identifier=1866, + id_text="GD_Challenges.Player.Player_SecondWindFromBadass", + category=challenge_cat_health, + name="Badass Bingo", + description="Get Second Winds by killing a badass enemy", + levels=(1, 5, 15, 30, 50), + bonus=5, + ) + challenges[1868] = Challenge( + position=204, + identifier=1868, + id_text="GD_Challenges.Player.Player_CoopRevivesOfFriends", + category=challenge_cat_health, + name="This Is No Time for Lazy!", + description="Revive a co-op partner", + levels=(5, 10, 50, 100, 200), + bonus=5, + ) + challenges[1834] = Challenge( + position=198, + identifier=1834, + id_text="GD_Challenges.Player.Player_SecondWindFromFire", + category=challenge_cat_health, + name="Death, Wind, and Fire", + description="Get Second Winds by killing enemies with a burn DoT (damage over time)", + levels=(1, 5, 15, 30, 50), + ) + challenges[1833] = Challenge( + position=197, + identifier=1833, + id_text="GD_Challenges.Player.Player_SecondWindFromCorrosive", + category=challenge_cat_health, + name="Green Meanie", + description="Get Second Winds by killing enemies with a corrosive DoT (damage over time)", + levels=(1, 5, 15, 30, 50), + ) + challenges[1835] = Challenge( + position=199, + identifier=1835, + id_text="GD_Challenges.Player.Player_SecondWindFromShock", + category=challenge_cat_health, + name="I'm Back! Shocked?", + description="Get Second Winds by killing enemies with an electrocute DoT (damage over time)", + levels=(1, 5, 15, 30, 50), + ) + + # Grenades + challenges[1639] = Challenge( + position=31, + identifier=1639, + id_text="GD_Challenges.Grenades.Grenade_Kills", + category=challenge_cat_grenades, + name="Pull the Pin", + description="Kill enemies with grenades", + levels=(10, 25, 150, 300, 750), + bonus=3, + ) + challenges[1886] = Challenge( + position=238, + identifier=1886, + id_text="GD_Challenges.Grenades.Grenade_KillsSingularityType", + category=challenge_cat_grenades, + name="Singled Out", + description="Kill enemies with Singularity grenades", + levels=(10, 25, 75, 150, 300), + ) + challenges[1885] = Challenge( + position=237, + identifier=1885, + id_text="GD_Challenges.Grenades.Grenade_KillsMirvType", + category=challenge_cat_grenades, + name="EXPLOOOOOSIONS!", + description="Kill enemies with Mirv grenades", + levels=(10, 25, 75, 150, 300), + bonus=3, + ) + challenges[1883] = Challenge( + position=235, + identifier=1883, + id_text="GD_Challenges.Grenades.Grenade_KillsAoEoTType", + category=challenge_cat_grenades, + name="Chemical Sprayer", + description="Kill enemies with Area-of-Effect grenades", + levels=(10, 25, 75, 150, 300), + ) + challenges[1884] = Challenge( + position=236, + identifier=1884, + id_text="GD_Challenges.Grenades.Grenade_KillsBouncing", + category=challenge_cat_grenades, + name="Whoa, Black Betty", + description="Kill enemies with Bouncing Betty grenades", + levels=(10, 25, 75, 150, 300), + ) + challenges[1918] = Challenge( + position=239, + identifier=1918, + id_text="GD_Challenges.Grenades.Grenade_KillsTransfusionType", + category=challenge_cat_grenades, + name="Health Vampire", + description="Kill enemies with Transfusion grenades", + levels=(10, 25, 75, 150, 300), + ) + + # Shields + challenges[1889] = Challenge( + position=243, + identifier=1889, + id_text="GD_Challenges.Shields.Shields_KillsNova", + category=challenge_cat_shields, + name="Super Novas", + description="Kill enemies with a Nova shield burst", + levels=(5, 10, 50, 100, 200), + bonus=3, + ) + challenges[1890] = Challenge( + position=244, + identifier=1890, + id_text="GD_Challenges.Shields.Shields_KillsRoid", + category=challenge_cat_shields, + name="Roid Rage", + description='Kill enemies while buffed by a "Maylay" shield', + levels=(5, 10, 50, 100, 200), + ) + challenges[1891] = Challenge( + position=245, + identifier=1891, + id_text="GD_Challenges.Shields.Shields_KillsSpikes", + category=challenge_cat_shields, + name="Game of Thorns", + description="Kill enemies with reflected damage from a Spike shield", + levels=(5, 10, 50, 100, 200), + ) + challenges[1892] = Challenge( + position=246, + identifier=1892, + id_text="GD_Challenges.Shields.Shields_KillsImpact", + category=challenge_cat_shields, + name="Amp It Up", + description="Kill enemies while buffed by an Amplify shield", + levels=(5, 10, 50, 100, 200), + ) + challenges[1930] = Challenge( + position=222, + identifier=1930, + id_text="GD_Challenges.Shields.Shields_AbsorbAmmo", + category=challenge_cat_shields, + name="Ammo Eater", + description="Absorb enemy ammo with an Absorption shield", + levels=(20, 75, 250, 600, 1000), + bonus=5, + ) + + # Rocket Launchers + challenges[1762] = Challenge( + position=32, + identifier=1762, + id_text="GD_Challenges.Weapons.Launcher_Kills", + category=challenge_cat_rockets, + name="Rocket and Roll", + description="Kill enemies with rocket launchers", + levels=(10, 50, 100, 250, 500), + bonus=3, + ) + challenges[1828] = Challenge( + position=192, + identifier=1828, + id_text="GD_Challenges.Weapons.Launcher_SecondWinds", + category=challenge_cat_rockets, + name="Gone with the Second Wind", + description="Get Second Winds with rocket launchers", + levels=(2, 5, 15, 30, 50), + ) + challenges[1870] = Challenge( + position=224, + identifier=1870, + id_text="GD_Challenges.Weapons.Launcher_KillsSplashDamage", + category=challenge_cat_rockets, + name="Splish Splash", + description="Kill enemies with rocket launcher splash damage", + levels=(5, 10, 50, 100, 200), + ) + challenges[1869] = Challenge( + position=223, + identifier=1869, + id_text="GD_Challenges.Weapons.Launcher_KillsDirectHit", + category=challenge_cat_rockets, + name="Catch-a-Rocket!", + description="Kill enemies with direct hits from rocket launchers", + levels=(5, 10, 50, 100, 200), + bonus=5, + ) + challenges[1871] = Challenge( + position=54, + identifier=1871, + id_text="GD_Challenges.Weapons.Launcher_KillsFullShieldEnemy", + category=challenge_cat_rockets, + name="Shield Basher", + description="Kill shielded enemies with one rocket each", + levels=(5, 15, 35, 75, 125), + ) + challenges[1808] = Challenge( + position=52, + identifier=1808, + id_text="GD_Challenges.Weapons.Launcher_KillsLongRange", + category=challenge_cat_rockets, + name="Sky Rockets in Flight...", + description="Kill enemies from long range with rocket launchers", + levels=(25, 100, 400, 1000, 2000), + ) + + # Sniper Rifles + challenges[1636] = Challenge( + position=28, + identifier=1636, + id_text="GD_Challenges.Weapons.SniperRifle_Kills", + category=challenge_cat_sniper, + name="Longshot", + description="Kill enemies with sniper rifles", + levels=(20, 100, 500, 2500, 5000), + bonus=3, + ) + challenges[1666] = Challenge( + position=178, + identifier=1666, + id_text="GD_Challenges.Weapons.Sniper_CriticalHits", + category=challenge_cat_sniper, + name="Longshot Headshot", + description="Get critical hits with sniper rifles", + levels=(25, 100, 400, 1000, 2000), + ) + challenges[1824] = Challenge( + position=188, + identifier=1824, + id_text="GD_Challenges.Weapons.Sniper_SecondWinds", + category=challenge_cat_sniper, + name="Leaf on the Second Wind", + description="Get Second Winds with sniper rifles", + levels=(2, 5, 15, 30, 50), + ) + challenges[1844] = Challenge( + position=59, + identifier=1844, + id_text="GD_Challenges.Weapons.Sniper_CriticalHitKills", + category=challenge_cat_sniper, + name="Snipe Hunting", + description="Kill enemies with critical hits using sniper rifles", + levels=(10, 25, 75, 150, 300), + ) + challenges[1798] = Challenge( + position=47, + identifier=1798, + id_text="GD_Challenges.Weapons.SniperRifle_KillsFromHip", + category=challenge_cat_sniper, + name="No Scope, No Problem", + description="Kill enemies with sniper rifles without using ironsights", + levels=(5, 10, 50, 100, 200), + ) + challenges[1881] = Challenge( + position=233, + identifier=1881, + id_text="GD_Challenges.Weapons.SniperRifle_KillsUnaware", + category=challenge_cat_sniper, + name="Surprise!", + description="Kill unaware enemies with sniper rifles", + levels=(5, 10, 50, 100, 200), + ) + challenges[1872] = Challenge( + position=55, + identifier=1872, + id_text="GD_Challenges.Weapons.SniperRifle_KillsFullShieldEnemy", + category=challenge_cat_sniper, + name="Eviscerated", + description="Kill shielded enemies with one shot using sniper rifles", + levels=(5, 15, 35, 75, 125), + bonus=5, + ) + + # Assault Rifles + challenges[1637] = Challenge( + position=29, + identifier=1637, + id_text="GD_Challenges.Weapons.AssaultRifle_Kills", + category=challenge_cat_ar, + name="Aggravated Assault", + description="Kill enemies with assault rifles", + levels=(25, 100, 400, 1000, 2000), + bonus=3, + ) + challenges[1667] = Challenge( + position=179, + identifier=1667, + id_text="GD_Challenges.Weapons.AssaultRifle_CriticalHits", + category=challenge_cat_ar, + name="This Is My Rifle...", + description="Get critical hits with assault rifles", + levels=(25, 100, 400, 1000, 2000), + ) + challenges[1825] = Challenge( + position=189, + identifier=1825, + id_text="GD_Challenges.Weapons.AssaultRifle_SecondWinds", + category=challenge_cat_ar, + name="From My Cold, Dead Hands", + description="Get Second Winds with assault rifles", + levels=(5, 15, 30, 50, 75), + ) + challenges[1845] = Challenge( + position=60, + identifier=1845, + id_text="GD_Challenges.Weapons.AssaultRifle_CriticalHitKills", + category=challenge_cat_ar, + name="...This Is My Gun", + description="Kill enemies with critical hits using assault rifles", + levels=(10, 25, 75, 150, 300), + ) + challenges[1797] = Challenge( + position=46, + identifier=1797, + id_text="GD_Challenges.Weapons.AssaultRifle_KillsCrouched", + category=challenge_cat_ar, + name="Crouching Tiger, Hidden Assault Rifle", + description="Kill enemies with assault rifles while crouched", + levels=(25, 75, 400, 1600, 3200), + bonus=5, + ) + + # SMGs + challenges[1635] = Challenge( + position=27, + identifier=1635, + id_text="GD_Challenges.Weapons.SMG_Kills", + category=challenge_cat_smg, + name="Hail of Bullets", + description="Kill enemies with SMGs", + levels=(25, 100, 400, 1000, 2000), + bonus=3, + ) + challenges[1665] = Challenge( + position=177, + identifier=1665, + id_text="GD_Challenges.Weapons.SMG_CriticalHits", + category=challenge_cat_smg, + name="Constructive Criticism", + description="Get critical hits with SMGs", + levels=(25, 100, 400, 1000, 2000), + ) + challenges[1843] = Challenge( + position=58, + identifier=1843, + id_text="GD_Challenges.Weapons.SMG_CriticalHitKills", + category=challenge_cat_smg, + name="High Rate of Ire", + description="Kill enemies with critical hits using SMGs", + levels=(10, 25, 75, 150, 300), + ) + challenges[1823] = Challenge( + position=187, + identifier=1823, + id_text="GD_Challenges.Weapons.SMG_SecondWinds", + category=challenge_cat_smg, + name="More Like Submachine FUN", + description="Get Second Winds with SMGs", + levels=(2, 5, 15, 30, 50), + ) + + # Shotguns + challenges[1634] = Challenge( + position=26, + identifier=1634, + id_text="GD_Challenges.Weapons.Shotgun_Kills", + category=challenge_cat_shotgun, + name="Shotgun!", + description="Kill enemies with shotguns", + levels=(25, 100, 400, 1000, 2000), + bonus=3, + ) + challenges[1664] = Challenge( + position=176, + identifier=1664, + id_text="GD_Challenges.Weapons.Shotgun_CriticalHits", + category=challenge_cat_shotgun, + name="Faceful of Buckshot", + description="Get critical hits with shotguns", + levels=(50, 250, 1000, 2500, 5000), + ) + challenges[1822] = Challenge( + position=186, + identifier=1822, + id_text="GD_Challenges.Weapons.Shotgun_SecondWinds", + category=challenge_cat_shotgun, + name="Lock, Stock, and...", + description="Get Second Winds with shotguns", + levels=(2, 5, 15, 30, 50), + ) + challenges[1806] = Challenge( + position=50, + identifier=1806, + id_text="GD_Challenges.Weapons.Shotgun_KillsPointBlank", + category=challenge_cat_shotgun, + name="Open Wide!", + description="Kill enemies from point-blank range with shotguns", + levels=(10, 25, 150, 300, 750), + ) + challenges[1807] = Challenge( + position=51, + identifier=1807, + id_text="GD_Challenges.Weapons.Shotgun_KillsLongRange", + category=challenge_cat_shotgun, + name="Shotgun Sniper", + description="Kill enemies from long range with shotguns", + levels=(10, 25, 75, 150, 300), + ) + challenges[1842] = Challenge( + position=57, + identifier=1842, + id_text="GD_Challenges.Weapons.Shotgun_CriticalHitKills", + category=challenge_cat_shotgun, + name="Shotgun Surgeon", + description="Kill enemies with critical hits using shotguns", + levels=(10, 50, 100, 250, 500), + ) + + # Pistols + challenges[1633] = Challenge( + position=25, + identifier=1633, + id_text="GD_Challenges.Weapons.Pistol_Kills", + category=challenge_cat_pistol, + name="The Killer", + description="Kill enemies with pistols", + levels=(25, 100, 400, 1000, 2000), + bonus=3, + ) + challenges[1663] = Challenge( + position=175, + identifier=1663, + id_text="GD_Challenges.Weapons.Pistol_CriticalHits", + category=challenge_cat_pistol, + name="Deadeye", + description="Get critical hits with pistols", + levels=(25, 100, 400, 1000, 2000), + ) + challenges[1821] = Challenge( + position=185, + identifier=1821, + id_text="GD_Challenges.Weapons.Pistol_SecondWinds", + category=challenge_cat_pistol, + name="Hard Boiled", + description="Get Second Winds with pistols", + levels=(2, 5, 15, 30, 50), + ) + challenges[1841] = Challenge( + position=56, + identifier=1841, + id_text="GD_Challenges.Weapons.Pistol_CriticalHitKills", + category=challenge_cat_pistol, + name="Pistolero", + description="Kill enemies with critical hits using pistols", + levels=(10, 25, 75, 150, 300), + ) + challenges[1800] = Challenge( + position=49, + identifier=1800, + id_text="GD_Challenges.Weapons.Pistol_KillsQuickshot", + category=challenge_cat_pistol, + name="Quickdraw", + description="Kill enemies shortly after entering ironsights with a pistol", + levels=(10, 25, 150, 300, 750), + bonus=5, + ) + + # Melee + challenges[1650] = Challenge( + position=75, + identifier=1650, + id_text="GD_Challenges.Melee.Melee_Kills", + category=challenge_cat_melee, + name="Fisticuffs!", + description="Kill enemies with melee attacks", + levels=(25, 100, 400, 1000, 2000), + bonus=3, + ) + challenges[1893] = Challenge( + position=247, + identifier=1893, + id_text="GD_Challenges.Melee.Melee_KillsBladed", + category=challenge_cat_melee, + name="A Squall of Violence", + description="Kill enemies with melee attacks using bladed guns", + levels=(20, 75, 250, 600, 1000), + ) + + # General Combat + challenges[1621] = Challenge( + position=0, + identifier=1621, + id_text="GD_Challenges.GeneralCombat.General_RoundsFired", + category=challenge_cat_combat, + name="Knee-Deep in Brass", + description="Fire a lot of rounds", + levels=(1000, 5000, 10000, 25000, 50000), + bonus=5, + ) + challenges[1702] = Challenge( + position=90, + identifier=1702, + id_text="GD_Challenges.GeneralCombat.Player_KillsWithActionSkill", + category=challenge_cat_combat, + name="...To Pay the Bills", + description="Kill enemies while using your Action Skill", + levels=(20, 75, 250, 600, 1000), + bonus=5, + ) + challenges[1916] = Challenge( + position=269, + identifier=1916, + id_text="GD_Challenges.GeneralCombat.Kills_AtNight", + category=challenge_cat_combat, + name="...I Got to Boogie", + description="Kill enemies at night", + levels=(10, 25, 150, 300, 750), + ) + challenges[1915] = Challenge( + position=268, + identifier=1915, + id_text="GD_Challenges.GeneralCombat.Kills_AtDay", + category=challenge_cat_combat, + name="Afternoon Delight", + description="Kill enemies during the day", + levels=(50, 250, 1000, 2500, 5000), + ) + challenges[1908] = Challenge( + position=261, + identifier=1908, + id_text="GD_Challenges.GeneralCombat.Tediore_KillWithReload", + category=challenge_cat_combat, + name="Boomerbang", + description="Kill enemies with Tediore reloads", + levels=(5, 10, 50, 100, 200), + bonus=5, + ) + challenges[1909] = Challenge( + position=262, + identifier=1909, + id_text="GD_Challenges.GeneralCombat.Tediore_DamageFromReloads", + category=challenge_cat_combat, + name="Gun Slinger", + description="Deal damage with Tediore reloads", + levels=(5000, 20000, 100000, 500000, 1000000), + ) + challenges[1912] = Challenge( + position=265, + identifier=1912, + id_text="GD_Challenges.GeneralCombat.Barrels_KillEnemies", + category=challenge_cat_combat, + name="Not Full of Monkeys", + description="Kill enemies with stationary barrels", + levels=(10, 25, 45, 70, 100), + bonus=3, + ) + challenges[1646] = Challenge( + position=44, + identifier=1646, + id_text="GD_Challenges.GeneralCombat.Kills_FromCrits", + category=challenge_cat_combat, + name="Critical Acclaim", + description="Kill enemies with critical hits. And rainbows.", + levels=(20, 100, 500, 1000, 1500), + ) + + # Miscellaneous + challenges[1659] = Challenge( + position=104, + identifier=1659, + id_text="GD_Challenges.Dueling.DuelsWon_HatersGonnaHate", + category=challenge_cat_misc, + name="Haters Gonna Hate", + description="Win duels", + levels=(1, 5, 15, 30, 50), + ) + challenges[1804] = Challenge( + position=211, + identifier=1804, + id_text="GD_Challenges.Miscellaneous.Missions_SideMissionsCompleted", + category=challenge_cat_misc, + name="Sidejacked", + description="Complete side missions", + levels=(5, 15, 30, 55, 90), + ) + challenges[1803] = Challenge( + position=210, + identifier=1803, + id_text="GD_Challenges.Miscellaneous.Missions_OptionalObjectivesCompleted", + category=challenge_cat_misc, + name="Compl33tionist", + description="Complete optional mission objectives", + levels=(10, 25, 45, 70, 100), + ) + challenges[1698] = Challenge( + position=173, + identifier=1698, + id_text="GD_Challenges.Miscellaneous.Misc_CompleteChallenges", + category=challenge_cat_misc, + name="Yo Dawg I Herd You Like Challenges", + description="Complete many, many challenges", + levels=(5, 25, 50, 100, 200), + ) + challenges[1940] = Challenge( + position=100, + identifier=1940, + id_text="GD_Challenges.Miscellaneous.Misc_JimmyJenkins", + category=challenge_cat_misc, + name="JEEEEENKINSSSSSS!!!", + description="Find and eliminate Jimmy Jenkins", + levels=(1, 3, 6, 10, 15), + bonus=5, + ) + + return challenges diff --git a/borderlands/bl2_explorer_achievements.py b/borderlands/bl2_explorer_achievements.py new file mode 100644 index 0000000..e1e4550 --- /dev/null +++ b/borderlands/bl2_explorer_achievements.py @@ -0,0 +1,190 @@ +import dataclasses +from typing import Final, Dict, List + + +@dataclasses.dataclass(frozen=True) +class ExplorerAchievementInfo: + name: str + explanation: str + guide_url: str + code_to_name_map: Dict[str, str] + + +_BL2_EXPLORER_ACHIEVEMENTS: Final = ( + ExplorerAchievementInfo( + name='World Traveler', + explanation='Discovered all named locations', + guide_url='https://www.playstationtrophies.org/forum/topic/161466-complete-world-map-world-traveler-trophy/', + code_to_name_map={ + 'Ash_P': 'Eridium Blight', + 'BanditSlaughter_P': 'Fink\'s Slaughterhouse', + 'Boss_Cliffs_P': 'The Bunker', + 'Boss_Volcano_P': 'Vault of the Warrior', + 'Cove_P': 'Southern Shelf - Bay', + 'CraterLake_P': 'Sawtooth Cauldron', + 'CreatureSlaughter_P': 'Natural Selection Annex', + 'FinalBossAscent_P': 'Hero\'s Pass', + 'Fridge_P': 'The Fridge', + 'Frost_P': 'Three Horns Valley', + 'Fyrestone_P': 'Arid Nexus - Boneyard', + 'Glacial_P': 'Claptrap\'s Place / Windshear Waste', + 'Grass_Cliffs_P': 'Thousand Cuts', + 'Grass_Lynchwood_P': 'Lynchwood', + 'Grass_P': 'The Highlands', + 'HypInterlude_P': 'Friendship Gulag', + 'HyperionCity_P': 'Opportunity', + 'Ice_P': 'Three Horns Divide', + 'Interlude_P': 'The Dust', + 'Luckys_P': 'The Holy Spirits', + 'Outwash_P': 'The Highlands - Outwash', + 'PandoraPark_P': 'Wildlife Exploitation Preserve', + 'RobotSlaughter_P': 'Ore Chasm', + 'Sanctuary_Hole_P': 'Sanctuary Hole', + 'Sanctuary_P': 'Sanctuary', + 'SouthernShelf_P': 'Southern Shelf', + 'SouthpawFactory_P': 'Southpaw Steam & Power', + 'Stockade_P': 'Arid Nexus - Badlands', + 'ThresherRaid_P': 'Terramorphous Peak', + 'TundraTrain_P': 'End of the Line', + 'VOGChamber_P': 'Control Core Angel', + 'caverns_p': 'Caustic Caverns', + 'dam_p': 'Bloodshot Stronghold', + 'damtop_p': 'Bloodshot Ramparts', + 'icecanyon_p': 'Frostburn Canyon', + 'tundraexpress_p': 'Tundra Express', + }, + ), + ExplorerAchievementInfo( + name='Arctic Explorer', + explanation='Discovered all named locations in Three Horns, Tundra Express, and Frostburn Canyon', + guide_url='https://www.trueachievements.com/a167557/arctic-explorer-achievement', + code_to_name_map={ + 'Frost_P': 'Three Horns Valley', + 'Ice_P': 'Three Horns Divide', + 'icecanyon_p': 'Frostburn Canyon', + 'tundraexpress_p': 'Tundra Express', + }, + ), + ExplorerAchievementInfo( + name='Urban Explorer', + explanation='Discovered all named locations in Sanctuary, Opportunity, and Lynchwood', + guide_url='https://www.trueachievements.com/a167558/urban-explorer-achievement', + code_to_name_map={ + 'Grass_Lynchwood_P': 'Lynchwood', + 'HyperionCity_P': 'Opportunity', + 'Sanctuary_P': 'Sanctuary', + }, + ), + ExplorerAchievementInfo( + name='Highlands Explorer', + explanation='Discovered all named locations in The Highlands, Thousand Cuts and Wildlife Exploitation Preserve', + guide_url='https://www.trueachievements.com/a167559/highlands-explorer-achievement', + code_to_name_map={ + 'Grass_Cliffs_P': 'Thousand Cuts', + 'Grass_P': 'The Highlands', + 'Outwash_P': 'The Highlands - Outwash', + 'PandoraPark_P': 'Wildlife Exploitation Preserve', + }, + ), + ExplorerAchievementInfo( + name='Blight Explorer', + explanation='Discovered all named locations in Eridium Blight, Arid Nexus, and Sawtooth Cauldron', + guide_url='https://www.trueachievements.com/a167560/blight-explorer-achievement', + code_to_name_map={ + 'Ash_P': 'Eridium Blight', + 'CraterLake_P': 'Sawtooth Cauldron', + 'Fyrestone_P': 'Arid Nexus - Boneyard', + 'Stockade_P': 'Arid Nexus - Badlands', + }, + ), + ExplorerAchievementInfo( + name='Gadabout', + explanation='Discovered all named locations in Oasis and the surrounding Pirate\'s Booty areas', + guide_url='https://www.trueachievements.com/a170077/gadabout-achievement', + code_to_name_map={ + 'Orchid_Caves_P': 'Hayter\'s Folly', + 'Orchid_OasisTown_P': 'Oasis', + 'Orchid_Refinery_P': 'Washburne Refinery', + 'Orchid_SaltFlats_P': 'Wurmwater', + 'Orchid_ShipGraveyard_P': 'The Rustyards', + 'Orchid_Spire_P': 'Magnys Lighthouse', + 'Orchid_WormBelly_P': 'The Leviathan\'s Lair', + }, + ), + ExplorerAchievementInfo( + name='Been There', + explanation='Discovered all named locations in Hammerlock\'s Hunt', + guide_url='https://www.trueachievements.com/a199051/been-there-achievement', + code_to_name_map={ + 'Sage_Cliffs_P': 'Candlerakk\'s Crag', + 'Sage_HyperionShip_P': 'H.S.S. Terminus', + 'Sage_PowerStation_P': 'Ardorton Station', + 'Sage_RockForest_P': 'Scylla\'s Grove', + 'Sage_Underground_P': 'Hunter\'s Grotto', + }, + ), +) + +_NOT_EXPLORER_ACHIEVEMENTS_MAP: Final = { + 'BackBurner_P': 'The Backburner', + 'CastleExterior_P': 'Hatred\'s Shadow', + 'CastleKeep_P': 'Dragon Keep', + 'Dark_Forest_P': 'The Forest', + 'Dead_Forest_P': 'Immortal Woods', + 'Distillery_P': 'Rotgut Distillery', + 'Docks_P': 'Unassuming Docks', + 'DungeonRaid_P': 'The Winged Storm', + 'Dungeon_P': 'Lair of Infinite Agony', + 'Easter_P': 'Wam Bam Island', + 'GaiusSanctuary_P': 'Paradise Sanctum', + 'Helios_P': 'Helios Fallen', + 'Hunger_P': 'Gluttony Gulch', + 'Iris_DL1_P': 'Arena', + 'Iris_DL1_TAS_P': 'Arena', + 'Iris_DL2_Interior_P': 'Pyro Pete\'s Bar', + 'Iris_DL2_P': 'The Beatdown', + 'Iris_DL3_P': 'Forge', + 'Iris_Hub2_P': 'Southern Raceway', + 'Iris_Hub_P': 'Badass Crater of Badassitude', + 'Iris_Moxxi_P': 'Badass Crater Bar', + 'Mines_P': 'Mines of Avarice', + 'OldDust_P': 'Dahl Abandon', + 'Pumpkin_Patch_P': 'Hallowed Hollow', + 'ResearchCenter_P': 'Mt. Scarab Research Center', + 'Sage_Cliffs_P': 'Candlerakk\'s Crag', + 'SanctIntro_P': 'Fight for Sanctuary', + 'SandwormLair_P': 'Writhing Deep', + 'Sandworm_P': 'The Burrows', + 'TempleSlaughter_P': 'Murderlin\'s Temple', + 'TestingZone_P': 'The Raid on Digistruct Peak', + 'Village_P': 'Flamerock Refuge', + 'Xmas_P': 'Marcus\'s Mercenary Shop', +} + + +def report_one_explorer_achievement(*, fully_explored_maps: List[str], info: ExplorerAchievementInfo) -> List[str]: + result = [] + all_maps = set(info.code_to_name_map.keys()) + incomplete_maps = all_maps - set(fully_explored_maps) + complete_maps = all_maps - incomplete_maps + if incomplete_maps: + result.append(f'{info.name} - Partially ({len(complete_maps)}/{len(all_maps)})') + result.append(f'More details: {info.guide_url}') + result.append(f'Incomplete maps ({len(incomplete_maps)}):') + pairs = [(info.code_to_name_map[k], k) for k in incomplete_maps] + pairs.sort() + for name, code in pairs: + result.append(f' {name}') + result.append('') + else: + result.append(f'{info.name} - Complete') + + return result + + +def create_explorer_achievements_report(fully_explored_maps: List[str]) -> List[str]: + result = [] + for info in _BL2_EXPLORER_ACHIEVEMENTS: + result.extend(report_one_explorer_achievement(fully_explored_maps=fully_explored_maps, info=info)) + result.append('') + return result diff --git a/borderlands/bl2_routines.py b/borderlands/bl2_routines.py new file mode 100644 index 0000000..8b21030 --- /dev/null +++ b/borderlands/bl2_routines.py @@ -0,0 +1,76 @@ +from typing import Final, Dict, Optional, Set + +from borderlands.challenges import unwrap_challenges, wrap_challenges +from borderlands.datautil.data_types import PlayerDict, PlayerPatchProc +from borderlands.datautil.protobuf import read_protobuf, write_protobuf + +_BL2_MODE_NAMES: Final = {0: 'Normal Mode', 1: 'TVHM', 2: 'UVHM'} + + +def _is_doctor_orders_mission(mission_data: list) -> bool: + if len(mission_data) < 2: + return False + item1 = mission_data[1] + if not isinstance(item1, bytes): + return False + return b'GD_Z2_DoctorsOrders.M_DoctorsOrders' in item1 + + +def _reset_doctors_orders(player: PlayerDict, _endian: str) -> None: + """ + - remove mission entry with name + "GD_Z2_DoctorsOrders.M_DoctorsOrders" + - do it in order: UVHM -> TVHM -> Normal + - exit after first successful reset + """ + player18 = player[18] + len18 = len(player18) + for mode_index in range(len18 - 1, -1, -1): + vh_mode_data = player18[mode_index] + + unpacked = read_protobuf(vh_mode_data[1]) + if 3 not in unpacked: + continue + raw_missions = unpacked[3] + fixed_missions = [x for x in raw_missions if not _is_doctor_orders_mission(x)] + unpacked[3] = fixed_missions + + vh_mode_data[1] = write_protobuf(unpacked) + + player18[mode_index] = vh_mode_data + + if len(raw_missions) != len(fixed_missions): + print(' reset Doctor\'s Orders for', _BL2_MODE_NAMES.get(mode_index, 'Unknown')) + break + + player[18] = player18 + + +def _reset_bad_touch(player: PlayerDict, endian: str) -> None: + """ + Bad Touch SMG flag is one per player for all the Normal/TVHM/UVHM + + It could be easily spoiled by teammate so here is the fix + """ + data2 = unwrap_challenges(data=player[15][0][1], challenges={}, endian=endian) + + for save_challenge in data2['challenges']: + if save_challenge['id'] == 1836: + print('1836:', repr(save_challenge)) + save_challenge['total_value'] = 0 + + player[15][0][1] = wrap_challenges(data=data2, endian=endian) + + +_RESET_OPT_DATA: Final[Dict[str, PlayerPatchProc]] = { + 'bad-touch': _reset_bad_touch, + 'doctors-orders': _reset_doctors_orders, +} + + +def get_reset_proc(key: str) -> Optional[PlayerPatchProc]: + return _RESET_OPT_DATA.get(key) + + +def get_valid_reset_option_values() -> Set[str]: + return set(_RESET_OPT_DATA.keys()) diff --git a/borderlands/bltps.py b/borderlands/bltps.py new file mode 100644 index 0000000..2c42c32 --- /dev/null +++ b/borderlands/bltps.py @@ -0,0 +1,177 @@ +from typing import Any, Dict, List + +from borderlands import bltps_data +from borderlands.datautil.common import unwrap_float, wrap_float, unwrap_bytes, wrap_bytes +from borderlands.savefile import BaseApp + + +class AppTPS(BaseApp): + """ + Our main application class for Borderlands: The Pre-Sequel + """ + + def __init__(self, args: List[str]) -> None: + super().__init__( + args=args, + item_struct_version=10, + game_name='Borderlands: The Pre-Sequel', + item_prefix='BLOZ', + max_level=70, + black_market_keys=( + 'rifle', + 'pistol', + 'launcher', + 'shotgun', + 'smg', + 'sniper', + 'grenade', + 'backpack', + 'bank', + 'laser', + ), + black_market_ammo={ + 'grenade': [3, 4, 5, 6, 7, 8, 9, 10], + 'laser': [500, 620, 740, 860, 980, 1100, 1220, 1340], + 'launcher': [12, 15, 18, 21, 24, 27, 30, 33], + 'pistol': [200, 300, 400, 500, 600, 700, 800, 900], + 'rifle': [280, 420, 560, 700, 840, 980, 1120, 1260], + 'shotgun': [80, 100, 120, 140, 160, 180, 200, 220], + 'smg': [360, 540, 720, 900, 1080, 1260, 1440, 1620], + 'sniper': [48, 60, 72, 84, 96, 108, 120, 132], + }, + unlock_choices=['tvhm', 'uvhm', 'challenges', 'ammo'], + challenges=bltps_data.create_bltps_challenges(), + ) + + @staticmethod + def setup_currency_args(parser) -> None: + """ + Adds the options we're using to control currency + """ + + parser.add_argument( + '--moonstone', + type=int, + help='Moonstone to set for character', + ) + + def create_save_structure(self) -> Dict[int, Any]: + """ + Sets up our main save_structure var which controls how we read the file + """ + return { + 1: "class", + 2: "level", + 3: "experience", + 4: "skill_points", + 6: ("currency", True, 0), + 7: "playthroughs_completed", + 8: ("skills", True, {1: "name", 2: "level", 3: "unknown3", 4: "unknown4"}), + 11: ( + "resources", + True, + {1: "resource", 2: "pool", 3: ("amount", False, (unwrap_float, wrap_float)), 4: "level"}, + ), + 13: ("sizes", False, {1: "inventory", 2: "weapon_slots", 3: "weapon_slots_shown"}), + 15: ("stats", False, (self.unwrap_challenges, self.wrap_challenges)), + 16: ("active_fast_travel", True, None), + 17: "last_fast_travel", + 18: ( + "missions", + True, + { + 1: "playthrough", + 2: "active", + 3: ( + "data", + True, + { + 1: "name", + 2: "status", + 3: "is_from_dlc", + 4: "dlc_id", + 5: ("unknown5", False, (unwrap_bytes, wrap_bytes)), + 6: "unknown6", + 7: ("unknown7", False, (unwrap_bytes, wrap_bytes)), + 8: "unknown8", + 9: "unknown9", + 10: "unknown10", + 11: "level", + }, + ), + }, + ), + 19: ( + "appearance", + False, + { + 1: "name", + 2: ("color1", False, {1: "a", 2: "r", 3: "g", 4: "b"}), + 3: ("color2", False, {1: "a", 2: "r", 3: "g", 4: "b"}), + 4: ("color3", False, {1: "a", 2: "r", 3: "g", 4: "b"}), + }, + ), + 20: "save_game_id", + 21: "mission_number", + 23: ("unlocks", False, (unwrap_bytes, wrap_bytes)), + 24: ("unlock_notifications", False, (unwrap_bytes, wrap_bytes)), + 25: "time_played", + 26: "save_timestamp", + 29: ( + "game_stages", + True, + { + 1: "name", + 2: "level", + 3: "is_from_dlc", + 4: "dlc_id", + 5: "playthrough", + }, + ), + 30: ("areas", True, {1: "name", 2: "unknown2"}), + 34: ( + "id", + False, + { + 1: ("a", False, 5), + 2: ("b", False, 5), + 3: ("c", False, 5), + 4: ("d", False, 5), + }, + ), + 35: ("wearing", True, None), + 36: ("black_market", False, (self.unwrap_black_market, self.wrap_black_market)), + 37: "active_mission", + 38: ("challenges", True, {1: "name", 2: "is_from_dlc", 3: "dlc_id"}), + 41: ( + "bank", + True, + { + 1: ("data", False, (self.unwrap_item_info, self.wrap_item_info)), + }, + ), + 43: ("lockouts", True, {1: "name", 2: "time", 3: "is_from_dlc", 4: "dlc_id"}), + 46: ("explored_areas", True, None), + 49: "active_playthrough", + 53: ( + "items", + True, + { + 1: ("data", False, (self.unwrap_item_info, self.wrap_item_info)), + 2: "unknown2", + 3: "is_equipped", + 4: "star", + }, + ), + 54: ( + "weapons", + True, + { + 1: ("data", False, (self.unwrap_item_info, self.wrap_item_info)), + 2: "slot", + 3: "star", + }, + ), + 55: "stats_bonuses_disabled", + 56: "bank_size", + } diff --git a/borderlands/bltps_data.py b/borderlands/bltps_data.py new file mode 100644 index 0000000..2393570 --- /dev/null +++ b/borderlands/bltps_data.py @@ -0,0 +1,1504 @@ +from typing import Final, Dict + +from borderlands.challenges import Challenge, ChallengeCategory + +# NOTE: unused now +LEVELS_TO_TRAVEL_STATION_MAP: Final = { + 'Access_P': "Tycho's Ribs", + 'CentralTerminal_P': "Hyperion Hub of Heroism", + 'ComFacility_P': "Crisis Scar", + 'DahlFactory_Boss': "Titan Robot Production Plant", + 'DahlFactory_P': "Titan Industrial Facility", + 'Deadsurface_P': "Regolith Range", + 'Digsite_P': "Vorago Solitude", + 'Digsite_Rk5arena_P': "Outfall Pumping Station", + 'Eridian_Slaughter_P': "Holodome", + 'InnerCore_P': "Eleseer", + 'InnerHull_P': "Veins of Helios", + 'JacksOffice_P': "Jack's Office", + 'Laser_P': "Lunar Launching Station", + 'LaserBoss_P': "Eye of Helios", + 'Ma_Deck13_P': "Deck 13 1/2", + 'Ma_FinalBoss_P': "Deck 13.5", + 'Ma_LeftCluster_P': "Cluster 00773 P4ND0R4", + 'Ma_Motherboard_P': "Motherlessboard", + 'Ma_Nexus_P': "Nexus", + 'Ma_RightCluster_P': "Cluster 99002 0V3RL00K", + 'Ma_SubBoss_P': "Cortex", + 'Ma_Subconscious_P': "Subconscious", + 'Meriff_P': "Meriff's Office", + 'Moon_P': "Triton Flats", + 'MoonShotIntro_P': "Helios Station", + 'MoonSlaughter_P': "Abandoned Training Facility", + 'Moonsurface_P': "Serenity's Waste", + 'Outlands_P': "Outlands Spur", + 'Outlands_P2': "Outlands Canyon", + 'RandDFacility_P': "Research and Development", + 'Spaceport_P': "Concordia", + 'StantonsLiver_P': "Stanton's Liver", + 'Sublevel13_P': "Sub-Level 13", + 'Wreck_P': "Pity's Fall", +} + + +def create_bltps_challenges() -> Dict[int, Challenge]: + # Challenge categories + challenge_cat_gravity = ChallengeCategory("Low Gravity") + challenge_cat_grinder = ChallengeCategory("Grinder") + challenge_cat_enemies = ChallengeCategory("Enemies") + challenge_cat_elemental = ChallengeCategory("Elemental") + challenge_cat_loot = ChallengeCategory("Loot") + challenge_cat_money = ChallengeCategory("Money and Trading") + challenge_cat_vehicle = ChallengeCategory("Vehicle") + challenge_cat_health = ChallengeCategory("Health and Recovery") + challenge_cat_grenades = ChallengeCategory("Grenades") + challenge_cat_shields = ChallengeCategory("Shields") + challenge_cat_rockets = ChallengeCategory("Rocket Launcher") + challenge_cat_sniper = ChallengeCategory("Sniper Rifle") + challenge_cat_ar = ChallengeCategory("Assault Rifle") + challenge_cat_laser = ChallengeCategory("Laser") + challenge_cat_smg = ChallengeCategory("SMG") + challenge_cat_shotgun = ChallengeCategory("Shotgun") + challenge_cat_pistol = ChallengeCategory("Pistol") + challenge_cat_melee = ChallengeCategory("Melee") + challenge_cat_combat = ChallengeCategory("General Combat") + challenge_cat_misc = ChallengeCategory("Miscellaneous") + + # There are two possible ways of uniquely identifying challenges in this file: + # via their numeric position in the list, or by what looks like an internal + # ID (though that ID is constructed a little weirdly, so I'm not sure if it's + # actually intended to be used that way or not). + # + # I did run some tests, and it looks like internally, B2 probably does use + # that ID field to identify the challenges... You can mess around with the + # order in which they're saved to the file, but so long as the ID field + # is still pointing to the challenge you want, it'll be read in properly + # (and then when you save your game, they'll be written back out in the + # original order). + # + # Given that, I decided to go ahead and use that probably-ID field as the + # index on this dict, rather than the order. That should be slightly more + # flexible for anyone editing the JSON directly, and theoretically + # shouldn't be a problem in the future since there won't be any new major + # DLC for B2... + # + # New major DLC for TPS seems unlikely too, though time will tell. + # noinspection PyDictCreation + challenges = {} + + # Low Gravity + challenges[2052] = Challenge( + position=468, + identifier=2052, + id_text="GD_Challenges.LowGravity.LowGravity_CriticalsWhileAirborne", + category=challenge_cat_gravity, + name="Eagle Eye", + description="Get critical hits while airborne", + levels=(50, 250, 500, 1500, 3000), + ) + + challenges[2093] = Challenge( + position=507, + identifier=2093, + id_text="GD_Challenges.LowGravity.LowGravity_DoubleJump", + category=challenge_cat_gravity, + name="Boosted", + description="Perform air boosts", + levels=(50, 250, 1000, 2500, 5000), + ) + + challenges[2051] = Challenge( + position=467, + identifier=2051, + id_text="GD_Challenges.LowGravity.LowGravity_KillsWhileAirborne", + category=challenge_cat_gravity, + name="Death from Above", + description="Kill enemies while in the air", + levels=(50, 250, 1000, 2500, 5000), + bonus=5, + ) + + challenges[2055] = Challenge( + position=471, + identifier=2055, + id_text="GD_Challenges.LowGravity.LowGravity_KillsWithSlam", + category=challenge_cat_gravity, + name="Slampage!", + description="Kill enemies with slam attacks", + levels=(5, 10, 50, 100, 200), + ) + + challenges[2053] = Challenge( + position=469, + identifier=2053, + id_text="GD_Challenges.LowGravity.LowGravity_MeleeWhileAirborne", + category=challenge_cat_gravity, + name="Dragon Punch", + description="Deal melee damage while airborne", + levels=(5000, 10000, 25000, 75000, 200000), + ) + + challenges[2054] = Challenge( + position=470, + identifier=2054, + id_text="GD_Challenges.LowGravity.LowGravity_SlamDamage", + category=challenge_cat_gravity, + name="Roger Slamjet", + description="Desc", + levels=(2000, 10000, 50000, 100000, 2500000), + ) + + # Grinder + challenges[2060] = Challenge( + position=476, + identifier=2060, + id_text="GD_Challenges.Grinder.Grinder_GrinderRecipes", + category=challenge_cat_grinder, + name="Master Chef", + description="Discover Grinder recipes", + levels=(2, 5, 10, 20, 34), + bonus=3, + ) + + challenges[2061] = Challenge( + position=477, + identifier=2061, + id_text="GD_Challenges.Grinder.Grinder_MoonstoneAttachments", + category=challenge_cat_grinder, + name="Greater Than the Sum of its Parts", + description="Obtain Luneshine weapons from the Grinder", + levels=(20, 50, 75, 125, 200), + ) + + challenges[2059] = Challenge( + position=475, + identifier=2059, + id_text="GD_Challenges.Grinder.Grinder_MoonstoneGrind", + category=challenge_cat_grinder, + name="This Time for Sure", + description="Perform Moonstone grinds", + levels=(10, 25, 150, 300, 750), + ) + + challenges[2058] = Challenge( + position=474, + identifier=2058, + id_text="GD_Challenges.Grinder.Grinder_StandardGrind", + category=challenge_cat_grinder, + name="The Daily Grind", + description="Perform standard grind", + levels=(50, 250, 750, 1500, 25000), + ) + + # Enemies + challenges[2035] = Challenge( + position=452, + identifier=2035, + id_text="GD_Challenges.Enemies.Enemies_KillDahlBadasses", + category=challenge_cat_enemies, + name="Kiss My Badass", + description="Kill Lost Legion badasses", + levels=(5, 10, 20, 30, 50), + ) + + challenges[2028] = Challenge( + position=445, + identifier=2028, + id_text="GD_Challenges.Enemies.Enemies_KillDahlFlyers", + category=challenge_cat_enemies, + name="Crash & Burn", + description="Destroy Lost Legion jet fighters", + levels=(1, 3, 5, 10, 20), + bonus=3, + ) + + challenges[2027] = Challenge( + position=444, + identifier=2027, + id_text="GD_Challenges.Enemies.Enemies_KillDahlInfantry", + category=challenge_cat_enemies, + name="Get Some!", + description="Kill Lost Legion infantry", + levels=(50, 250, 500, 1000, 2000), + ) + + challenges[2029] = Challenge( + position=446, + identifier=2029, + id_text="GD_Challenges.Enemies.Enemies_KillDahlSuits", + category=challenge_cat_enemies, + name="No Suit for You!", + description="Kill Lost Legion powersuits", + levels=(10, 25, 75, 150, 300), + ) + + challenges[2043] = Challenge( + position=460, + identifier=2043, + id_text="GD_Challenges.Enemies.Enemies_KillGuardianBosses", + category=challenge_cat_enemies, + name="More Where That Came From", + description="Kill Eridian Guardian bosses", + levels=(2, 4, 6, 8, 10), + bonus=3, + ) + + challenges[2031] = Challenge( + position=448, + identifier=2031, + id_text="GD_Challenges.Enemies.Enemies_KillGuardians", + category=challenge_cat_enemies, + name="Not So Tough Now", + description="Kill Eridian Guardians", + levels=(25, 50, 100, 150, 250), + ) + + challenges[2030] = Challenge( + position=447, + identifier=2030, + id_text="GD_Challenges.Enemies.Enemies_KillHyperionInfantry", + category=challenge_cat_enemies, + name="Dose of Death", + description="Kill enemies infected with space hurps", + levels=(10, 50, 125, 250, 500), + ) + + challenges[2034] = Challenge( + position=451, + identifier=2034, + id_text="GD_Challenges.Enemies.Enemies_KillKraggonBadasses", + category=challenge_cat_enemies, + name="Slayer of Titans", + description="Kill kraggon badasses", + levels=(1, 3, 7, 15, 25), + bonus=3, + ) + + challenges[2032] = Challenge( + position=449, + identifier=2032, + id_text="GD_Challenges.Enemies.Enemies_KillKraggons", + category=challenge_cat_enemies, + name="Big Game Hunt", + description="Kill kraggons", + levels=(25, 50, 150, 300, 750), + ) + + challenges[2039] = Challenge( + position=456, + identifier=2039, + id_text="GD_Challenges.Enemies.Enemies_KillLunarLooters", + category=challenge_cat_enemies, + name="Gift That Keeps Giving", + description="Kill swagmen", + levels=(1, 3, 5, 8, 12), + ) + + challenges[1989] = Challenge( + position=406, + identifier=1989, + id_text="GD_Challenges.Enemies.Enemies_KillRathyds", + category=challenge_cat_enemies, + name="Exterminator", + description="Kill rathyds", + levels=(25, 50, 150, 300, 750), + ) + + challenges[2038] = Challenge( + position=455, + identifier=2038, + id_text="GD_Challenges.Enemies.Enemies_KillScavBadasses", + category=challenge_cat_enemies, + name="The One That Says B.A.M.F.", + description="Kill scav badasses", + levels=(10, 25, 50, 100, 250), + ) + + challenges[2037] = Challenge( + position=454, + identifier=2037, + id_text="GD_Challenges.Enemies.Enemies_KillScavFlyers", + category=challenge_cat_enemies, + name="It Wasn't Yours, Anyway", + description="Destroy scav jet fighters", + levels=(1, 3, 5, 10, 20), + ) + + challenges[2036] = Challenge( + position=453, + identifier=2036, + id_text="GD_Challenges.Enemies.Enemies_KillScavSpacemen", + category=challenge_cat_enemies, + name="Space Dead", + description="Kill scav outlaws", + levels=(25, 50, 150, 300, 750), + bonus=3, + ) + + challenges[2033] = Challenge( + position=450, + identifier=2033, + id_text="GD_Challenges.Enemies.Enemies_KillScavs", + category=challenge_cat_enemies, + name="Die, Moon Jerks", + description="Kill scavs", + levels=(75, 250, 750, 2000, 5000), + ) + + challenges[1988] = Challenge( + position=405, + identifier=1988, + id_text="GD_Challenges.Enemies.Enemies_KillShugguraths", + category=challenge_cat_enemies, + name="That Was Unpleasant", + description="Kill shugguraths", + levels=(25, 50, 150, 300, 750), + ) + + challenges[2041] = Challenge( + position=458, + identifier=2041, + id_text="GD_Challenges.Enemies.Enemies_KillTorkBadasses", + category=challenge_cat_enemies, + name="Torkin' Out the Trash", + description="Kill tork badasses", + levels=(5, 10, 20, 30, 50), + ) + + challenges[1974] = Challenge( + position=391, + identifier=1974, + id_text="GD_Challenges.Enemies.Enemies_KillTorks", + category=challenge_cat_enemies, + name="Pest Control", + description="Kill torks", + levels=(25, 75, 150, 500, 1000), + ) + + # This challenge ID is odd, since it identifies itself as a level-specific + # challenge, but lives up in the "Enemies" section along with everything + # else. Unlocking this challenge prematurely will correctly show the + # challenge in the Enemies section, but will also cause an "Eleseer" entry + # to be added to the location-specific area, and a single (undiscovered) + # entry will be there. TODO: I have yet to verify that that doesn't + # cause problems when you do first enter Eleseer. I assume it should be + # fine, though I should doublecheck. + challenges[2257] = Challenge( + position=671, + identifier=2257, + id_text="GD_Challenges.Co_LevelChallenges.InnerCore_AintNobodyGotTime", + category=challenge_cat_enemies, + name="Aim for the Little Ones", + description="Kill Opha's spawned Putti", + levels=(10, 25, 45, 70, 100), + ) + + challenges[1728] = Challenge( + position=331, + identifier=1728, + id_text="GD_Marigold_Challenges.Enemies.Enemies_KillCorruptions", + category=challenge_cat_enemies, + name="Clean Sweep", + description="Kill corrupted enemies inside of Claptrap", + levels=(10, 25, 75, 150, 300), + ) + + challenges[1727] = Challenge( + position=330, + identifier=1727, + id_text="GD_Marigold_Challenges.Enemies.Enemies_KillInsecuityForces", + category=challenge_cat_enemies, + name="Claptomaniac", + description="Kill insecurity forces", + levels=(5, 10, 25, 75, 200), + bonus=3, + ) + + challenges[1733] = Challenge( + position=336, + identifier=1733, + id_text="GD_Marigold_Challenges.Enemies.Enemies_KillsFromBitBombs", + category=challenge_cat_enemies, + name="Volakillity", + description="Kill enemies with Volatile Bits", + levels=(3, 8, 20, 50, 120), + ) + + # Elemental + challenges[1823] = Challenge( + position=226, + identifier=1823, + id_text="GD_Challenges.elemental.Elemental_SetEnemiesOnFire", + category=challenge_cat_elemental, + name="Way of the Enkindling", + description="Light enemies on fire", + levels=(25, 100, 400, 1000, 2000), + ) + + challenges[1592] = Challenge( + position=40, + identifier=1592, + id_text="GD_Challenges.elemental.Elemental_KillEnemiesCorrosive", + category=challenge_cat_elemental, + name="Toxic Takedown", + description="Kill enemies with corrosive damage", + levels=(20, 75, 250, 600, 1000), + ) + + challenges[1595] = Challenge( + position=43, + identifier=1595, + id_text="GD_Challenges.elemental.Elemental_KillEnemiesExplosive", + category=challenge_cat_elemental, + name="Out with a Bang", + description="Kill enemies with explosive damage", + levels=(20, 75, 250, 600, 1000), + ) + + challenges[1827] = Challenge( + position=230, + identifier=1827, + id_text="GD_Challenges.elemental.Elemental_DealFireDOTDamage", + category=challenge_cat_elemental, + name="Some Like It Hot", + description="Deal damage with incindiary DoT (damage-over-time) effects", + levels=(2500, 20000, 100000, 500000, 1000000), + bonus=5, + ) + + challenges[1828] = Challenge( + position=231, + identifier=1828, + id_text="GD_Challenges.elemental.Elemental_DealCorrosiveDOTDamage", + category=challenge_cat_elemental, + name="Chemical Burn", + description="Deal damage with corrosive DoT (damage-over-time) effects", + levels=(2500, 20000, 100000, 500000, 1000000), + ) + + challenges[1977] = Challenge( + position=394, + identifier=1977, + id_text="GD_Challenges.elemental.Elemental_DealIceDOTDamage", + category=challenge_cat_elemental, + name="Frost Bite", + description="Deal damage with cryo DoT (damage-over-time) effects", + levels=(2500, 20000, 100000, 500000, 1000000), + ) + + challenges[1829] = Challenge( + position=232, + identifier=1829, + id_text="GD_Challenges.elemental.Elemental_DealShockDOTDamage", + category=challenge_cat_elemental, + name="Watt's Up?", + description="Deal damage with shock DoT (damage-over-time) effects", + levels=(5000, 20000, 100000, 500000, 1000000), + ) + + # Loot + challenges[1848] = Challenge( + position=252, + identifier=1848, + id_text="GD_Challenges.Pickups.Inventory_PickupWhiteItems", + category=challenge_cat_loot, + name="Junkyard Dog", + description="Loot or purchase white items", + levels=(50, 125, 250, 400, 600), + ) + + challenges[1849] = Challenge( + position=253, + identifier=1849, + id_text="GD_Challenges.Pickups.Inventory_PickupGreenItems", + category=challenge_cat_loot, + name="A Chunk of Purest Green", + description="Loot or purchase green-rarity items", + levels=(25, 75, 125, 250, 500), + bonus=3, + ) + + challenges[1850] = Challenge( + position=254, + identifier=1850, + id_text="GD_Challenges.Pickups.Inventory_PickupBlueItems", + category=challenge_cat_loot, + name="Rare as Rocking Horse...", + description="Loot or purchase blue-rarity items", + levels=(5, 25, 50, 75, 100), + ) + + challenges[1851] = Challenge( + position=255, + identifier=1851, + id_text="GD_Challenges.Pickups.Inventory_PickupPurpleItems", + category=challenge_cat_loot, + name="Purple Haze", + description="Loot or purchase purple-rarity items", + levels=(3, 7, 15, 30, 75), + ) + + challenges[1852] = Challenge( + position=256, + identifier=1852, + id_text="GD_Challenges.Pickups.Inventory_PickupOrangeItems", + category=challenge_cat_loot, + name="The Happiest Color", + description="Loot or purchase legendary items", + levels=(1, 3, 6, 10, 15), + bonus=5, + ) + + challenges[1731] = Challenge( + position=334, + identifier=1731, + id_text="GD_Marigold_Challenges.Loot.Loot_OpenGlitchedChests", + category=challenge_cat_loot, + name="I Like Surprises...", + description="Open Glitched treasure chests", + levels=(1, 3, 5, 8, 12), + ) + + challenges[1730] = Challenge( + position=333, + identifier=1730, + id_text="GD_Marigold_Challenges.Loot.Loot_PickupGlitchedItems", + category=challenge_cat_loot, + name="99 Problems and a Glitch Aint One", + description="Loot or purchase Glitched-rarity items", + levels=(1, 3, 6, 10, 15), + bonus=5, + ) + + challenges[1619] = Challenge( + position=108, + identifier=1619, + id_text="GD_Challenges.Loot.Loot_OpenChests", + category=challenge_cat_loot, + name="Aaaaaand OPEN!", + description="Open treasure chests", + levels=(10, 25, 75, 150, 300), + ) + + challenges[1620] = Challenge( + position=110, + identifier=1620, + id_text="GD_Challenges.Loot.Loot_OpenLootables", + category=challenge_cat_loot, + name="Scrounging Around", + description="Open lootable crates, lockers, and other objects", + levels=(50, 300, 1000, 2000, 3000), + bonus=3, + ) + + challenges[1580] = Challenge( + position=8, + identifier=1580, + id_text="GD_Challenges.Loot.Loot_PickUpWeapons", + category=challenge_cat_loot, + name="One for Every Occasion", + description="Pick up or purchase weapons", + levels=(10, 25, 150, 300, 750), + ) + + # Money + challenges[1808] = Challenge( + position=119, + identifier=1808, + id_text="GD_Challenges.Economy.Economy_MoneySaved", + category=challenge_cat_money, + name="Mom Would Be Proud", + description="Save a lot of money", + levels=(10000, 50000, 250000, 1000000, 3000000), + ) + + challenges[1809] = Challenge( + position=120, + identifier=1809, + id_text="GD_Challenges.Economy.General_MoneyFromCashDrops", + category=challenge_cat_money, + name="Mr. Money Pits", + description="Collect dollars from cash drops", + levels=(5000, 25000, 125000, 500000, 1000000), + ) + + challenges[1628] = Challenge( + position=113, + identifier=1628, + id_text="GD_Challenges.Economy.Economy_SellItems", + category=challenge_cat_money, + name="Pawn Broker", + description="Sell items to vending machines", + levels=(50, 100, 250, 750, 2000), + ) + + challenges[1810] = Challenge( + position=114, + identifier=1810, + id_text="GD_Challenges.Economy.Economy_PurchaseItemsOfTheDay", + category=challenge_cat_money, + name="Impulse Shopper", + description="Buy Items of the Day from vending machines", + levels=(1, 5, 15, 30, 50), + ) + + challenges[1760] = Challenge( + position=112, + identifier=1760, + id_text="GD_Challenges.Economy.Economy_BuyItemsWithMoonstone", + category=challenge_cat_money, + name="Over the Moon", + description="Purchase items with Moonstones", + levels=(25, 50, 125, 250, 500), + bonus=3, + ) + + challenges[1755] = Challenge( + position=215, + identifier=1755, + id_text="GD_Challenges.Economy.Trade_ItemsWithPlayers", + category=challenge_cat_money, + name="Trade Negotiations", + description="Trade with other players", + levels=(1, 5, 15, 30, 50), + ) + + # Vehicle + challenges[1590] = Challenge( + position=37, + identifier=1590, + id_text="GD_Challenges.Vehicles.Vehicles_KillByRamming", + category=challenge_cat_vehicle, + name="Fender Bender", + description="Kill enemies by ramming them with a vehicle", + levels=(5, 10, 50, 100, 200), + ) + + challenges[1870] = Challenge( + position=276, + identifier=1870, + id_text="GD_Challenges.Vehicles.Vehicles_KillByPowerSlide", + category=challenge_cat_vehicle, + name="Splat 'n' Slide", + description="Kill enemies by power-sliding over them in a vehicle", + levels=(1, 5, 10, 25, 50), + bonus=3, + ) + + challenges[1591] = Challenge( + position=38, + identifier=1591, + id_text="GD_Challenges.Vehicles.Vehicles_KillsWithVehicleWeapon", + category=challenge_cat_vehicle, + name="Improvise, Adapt, Overcome", + description="Kill enemies using a turret or vehicle-mounted weapon", + levels=(10, 25, 150, 300, 750), + ) + + challenges[1872] = Challenge( + position=278, + identifier=1872, + id_text="GD_Challenges.Vehicles.Vehicles_VehicleKillsVehicle", + category=challenge_cat_vehicle, + name="Road Warrior", + description="Kill vehicles while in a vehicle", + levels=(5, 10, 20, 40, 75), + ) + + challenges[2044] = Challenge( + position=461, + identifier=2044, + id_text="GD_Challenges.Vehicles.Vehicles_KillByPancaking", + category=challenge_cat_vehicle, + name="Pancakes For Breakfast", + description="Kill enemies with Stingray slams", + levels=(1, 5, 10, 25, 50), + ) + + # Health + challenges[1867] = Challenge( + position=271, + identifier=1867, + id_text="GD_Challenges.Player.Player_PointsHealed", + category=challenge_cat_health, + name="Doctor Feels Good", + description="Recover health", + levels=(1000, 25000, 150000, 1000000, 5000000), + ) + + challenges[1815] = Challenge( + position=201, + identifier=1815, + id_text="GD_Challenges.Player.Player_SecondWind", + category=challenge_cat_health, + name="Better You Than Me", + description="Get Second Winds by killing an enemy", + levels=(10, 25, 75, 150, 300), + ) + + challenges[1816] = Challenge( + position=202, + identifier=1816, + id_text="GD_Challenges.Player.Player_SecondWindFromBadass", + category=challenge_cat_health, + name="There Can Be Only... Me", + description="Get Second Winds by killing badass enemies", + levels=(1, 5, 15, 30, 50), + bonus=5, + ) + + challenges[1818] = Challenge( + position=205, + identifier=1818, + id_text="GD_Challenges.Player.Player_CoopRevivesOfFriends", + category=challenge_cat_health, + name="Up Unt at Zem", + description="Revive a co-op partner", + levels=(5, 10, 50, 100, 200), + bonus=5, + ) + + challenges[1784] = Challenge( + position=199, + identifier=1784, + id_text="GD_Challenges.Player.Player_SecondWindFromFire", + category=challenge_cat_health, + name="The Phoenix", + description="Get Second Winds by killing enemies with an incindiary DoT (damage over time)", + levels=(1, 5, 15, 30, 50), + ) + + challenges[1783] = Challenge( + position=198, + identifier=1783, + id_text="GD_Challenges.Player.Player_SecondWindFromCorrosive", + category=challenge_cat_health, + name="Soup's Up!", + description="Get Second Winds by killing enemies with a corrosive DoT (damage over time)", + levels=(1, 5, 15, 30, 50), + ) + + challenges[1785] = Challenge( + position=200, + identifier=1785, + id_text="GD_Challenges.Player.Player_SecondWindFromShock", + category=challenge_cat_health, + name="Had a Bit of a Shocker", + description="Get Second Winds by killing enemies with a shock DoT (damage over time)", + levels=(1, 5, 15, 30, 50), + ) + + challenges[2057] = Challenge( + position=473, + identifier=2057, + id_text="GD_Challenges.Player.Player_SecondWindFromShatter", + category=challenge_cat_health, + name="Rollin' the Ice", + description="Get Second Winds by shattering frozen enemies", + levels=(1, 5, 15, 30, 50), + ) + + # Grenades + challenges[1589] = Challenge( + position=31, + identifier=1589, + id_text="GD_Challenges.Grenades.Grenade_Kills", + category=challenge_cat_grenades, + name="Home Nade Cookin'", + description="Kill enemies with grenades", + levels=(10, 25, 150, 300, 750), + bonus=3, + ) + + challenges[1836] = Challenge( + position=239, + identifier=1836, + id_text="GD_Challenges.Grenades.Grenade_KillsSingularityType", + category=challenge_cat_grenades, + name="See Ya on the Other Side", + description="Kill enemies with Singularity grenades", + levels=(10, 25, 75, 150, 300), + ) + + challenges[1835] = Challenge( + position=238, + identifier=1835, + id_text="GD_Challenges.Grenades.Grenade_KillsMirvType", + category=challenge_cat_grenades, + name="Big MIRV", + description="Kill enemies with MIRV grenades", + levels=(10, 25, 75, 150, 300), + bonus=3, + ) + + challenges[1833] = Challenge( + position=236, + identifier=1833, + id_text="GD_Challenges.Grenades.Grenade_KillsAoEoTType", + category=challenge_cat_grenades, + name="Sprayowee", + description="Kill enemies with Area-of-Effect grenades", + levels=(25, 50, 125, 250, 500), + ) + + challenges[1834] = Challenge( + position=237, + identifier=1834, + id_text="GD_Challenges.Grenades.Grenade_KillsBouncing", + category=challenge_cat_grenades, + name="Betty Boom", + description="Kill enemies with Bouncing Betty grenades", + levels=(10, 25, 75, 150, 300), + ) + + challenges[1868] = Challenge( + position=240, + identifier=1868, + id_text="GD_Challenges.Grenades.Grenade_KillsTransfusionType", + category=challenge_cat_grenades, + name="Pass the Chianti", + description="Kill enemies with Transfusion grenades", + levels=(10, 25, 75, 150, 300), + ) + + # Shields + challenges[1839] = Challenge( + position=244, + identifier=1839, + id_text="GD_Challenges.Shields.Shields_KillsNova", + category=challenge_cat_shields, + name="Nova Say Die", + description="Kill enemies with a Nova shield burst", + levels=(5, 10, 50, 100, 200), + bonus=3, + ) + + challenges[1840] = Challenge( + position=245, + identifier=1840, + id_text="GD_Challenges.Shields.Shields_KillsRoid", + category=challenge_cat_shields, + name="Wet Work", + description="Kill enemies while buffed by a Maylay shield", + levels=(5, 10, 50, 100, 200), + ) + + challenges[1841] = Challenge( + position=246, + identifier=1841, + id_text="GD_Challenges.Shields.Shields_KillsSpikes", + category=challenge_cat_shields, + name="That'll Learn Ya", + description="Kill enemies with reflected damage from a Spike shield", + levels=(5, 10, 50, 100, 200), + ) + + challenges[1842] = Challenge( + position=247, + identifier=1842, + id_text="GD_Challenges.Shields.Shields_KillsImpact", + category=challenge_cat_shields, + name="Amplitude Killulation", + description="Kill enemies while buffed by an Amplify shield", + levels=(5, 10, 50, 100, 200), + ) + + challenges[1880] = Challenge( + position=223, + identifier=1880, + id_text="GD_Challenges.Shields.Shields_AbsorbAmmo", + category=challenge_cat_shields, + name="Ammo Eater", + description="Absorb enemy ammo with an Absorption shield", + levels=(20, 75, 250, 600, 1000), + bonus=5, + ) + + # Rocket Launchers + challenges[1712] = Challenge( + position=32, + identifier=1712, + id_text="GD_Challenges.Weapons.Launcher_Kills", + category=challenge_cat_rockets, + name="Get a Rocket up Ya", + description="Kill enemies with rocket launchers", + levels=(10, 50, 100, 250, 500), + bonus=3, + ) + + challenges[1778] = Challenge( + position=193, + identifier=1778, + id_text="GD_Challenges.Weapons.Launcher_SecondWinds", + category=challenge_cat_rockets, + name="Magic Missile", + description="Get Second Winds with rocket launchers", + levels=(2, 5, 15, 30, 50), + ) + + challenges[1820] = Challenge( + position=225, + identifier=1820, + id_text="GD_Challenges.Weapons.Launcher_KillsSplashDamage", + category=challenge_cat_rockets, + name="Collateral Damage", + description="Kill enemies with rocket launcher splash damage", + levels=(5, 10, 50, 100, 200), + ) + + challenges[1819] = Challenge( + position=224, + identifier=1819, + id_text="GD_Challenges.Weapons.Launcher_KillsDirectHit", + category=challenge_cat_rockets, + name="Missile Magnet", + description="Kill enemies with direct hits from rocket launchers", + levels=(5, 10, 50, 100, 200), + bonus=5, + ) + + challenges[1821] = Challenge( + position=54, + identifier=1821, + id_text="GD_Challenges.Weapons.Launcher_KillsFullShieldEnemy", + category=challenge_cat_rockets, + name="Punker Buster", + description="Kill shielded enemies with one rocket each", + levels=(5, 15, 35, 75, 125), + ) + + challenges[1758] = Challenge( + position=52, + identifier=1758, + id_text="GD_Challenges.Weapons.Launcher_KillsLongRange", + category=challenge_cat_rockets, + name="Hand of God", + description="Kill enemies from long range with rocket launchers", + levels=(25, 100, 400, 1000, 2000), + ) + + # Sniper Rifles + challenges[1586] = Challenge( + position=28, + identifier=1586, + id_text="GD_Challenges.Weapons.SniperRifle_Kills", + category=challenge_cat_sniper, + name="Sharp Shooter", + description="Kill enemies with sniper rifles", + levels=(20, 100, 500, 2500, 5000), + bonus=3, + ) + + challenges[1616] = Challenge( + position=179, + identifier=1616, + id_text="GD_Challenges.Weapons.Sniper_CriticalHits", + category=challenge_cat_sniper, + name="Melon Splitter", + description="Get critical hits with sniper rifles", + levels=(25, 100, 400, 1000, 2000), + ) + + challenges[1774] = Challenge( + position=189, + identifier=1774, + id_text="GD_Challenges.Weapons.Sniper_SecondWinds", + category=challenge_cat_sniper, + name="Windage Adjustment", + description="Get Second Winds with sniper rifles", + levels=(2, 5, 15, 30, 50), + ) + + challenges[1794] = Challenge( + position=59, + identifier=1794, + id_text="GD_Challenges.Weapons.Sniper_CriticalHitKills", + category=challenge_cat_sniper, + name="Critical Reception", + description="Kill enemies with critical hits using sniper rifles", + levels=(10, 25, 75, 150, 300), + ) + + challenges[1748] = Challenge( + position=47, + identifier=1748, + id_text="GD_Challenges.Weapons.SniperRifle_KillsFromHip", + category=challenge_cat_sniper, + name="Ol' Skool", + description="Kill enemies with sniper rifles without using the scope/ironsights", + levels=(5, 10, 50, 100, 200), + ) + + challenges[1831] = Challenge( + position=234, + identifier=1831, + id_text="GD_Challenges.Weapons.SniperRifle_KillsUnaware", + category=challenge_cat_sniper, + name="Clean and Simple", + description="Kill unaware enemies with sniper rifles", + levels=(5, 10, 50, 100, 200), + ) + + challenges[1822] = Challenge( + position=55, + identifier=1822, + id_text="GD_Challenges.Weapons.SniperRifle_KillsFullShieldEnemy", + category=challenge_cat_sniper, + name="Penetrating Wound", + description="Kill shielded enemies with one shot using sniper rifles", + levels=(5, 15, 35, 75, 125), + bonus=5, + ) + + # Assault Rifles + challenges[1587] = Challenge( + position=29, + identifier=1587, + id_text="GD_Challenges.Weapons.AssaultRifle_Kills", + category=challenge_cat_ar, + name="Assault With a Deadly Weapon", + description="Kill enemies with assault rifles", + levels=(25, 100, 400, 1000, 2000), + bonus=3, + ) + + challenges[1617] = Challenge( + position=180, + identifier=1617, + id_text="GD_Challenges.Weapons.AssaultRifle_CriticalHits", + category=challenge_cat_ar, + name="Aim to Please", + description="Get critical hits with assault rifles", + levels=(25, 100, 400, 1000, 2000), + ) + + challenges[1775] = Challenge( + position=190, + identifier=1775, + id_text="GD_Challenges.Weapons.AssaultRifle_SecondWinds", + category=challenge_cat_ar, + name="Assaulty Dog", + description="Get Second Winds with assault rifles", + levels=(5, 15, 30, 50, 75), + ) + + challenges[1795] = Challenge( + position=60, + identifier=1795, + id_text="GD_Challenges.Weapons.AssaultRifle_CriticalHitKills", + category=challenge_cat_ar, + name="Hot Lead Injection", + description="Kill enemies with critical hits using assault rifles", + levels=(10, 25, 75, 150, 300), + ) + + challenges[1747] = Challenge( + position=46, + identifier=1747, + id_text="GD_Challenges.Weapons.AssaultRifle_KillsCrouched", + category=challenge_cat_ar, + name="Crouch Potato", + description="Kill enemies with assault rifles while crouched", + levels=(25, 75, 400, 1600, 3200), + bonus=5, + ) + + # Laser + challenges[1984] = Challenge( + position=401, + identifier=1984, + id_text="GD_Challenges.Weapons.Laser_CriticalHitKills", + category=challenge_cat_laser, + name="Light 'em Up", + description="Kill enemies with critical hits using laser weapons", + levels=(10, 25, 75, 150, 300), + ) + + challenges[1982] = Challenge( + position=399, + identifier=1982, + id_text="GD_Challenges.Weapons.Laser_CriticalHits", + category=challenge_cat_laser, + name="Aggressive Lasik", + description="Get critical hits with lasers", + levels=(25, 100, 400, 1000, 2000), + ) + + challenges[2045] = Challenge( + position=462, + identifier=2045, + id_text="GD_Challenges.Weapons.Laser_FlyingKills", + category=challenge_cat_laser, + name="Battle Star", + description="Kill flying enemies with laser weapons while airborne", + levels=(10, 25, 150, 300, 750), + bonus=5, + ) + + challenges[1981] = Challenge( + position=398, + identifier=1981, + id_text="GD_Challenges.Weapons.Laser_Kills", + category=challenge_cat_laser, + name="Pew Pew", + description="Kill enemies with laser weapons", + levels=(25, 100, 400, 1000, 2000), + bonus=3, + ) + + challenges[1983] = Challenge( + position=400, + identifier=1983, + id_text="GD_Challenges.Weapons.Laser_SecondWinds", + category=challenge_cat_laser, + name="I See the Light", + description="Get Second Winds with laser weapons", + levels=(2, 5, 15, 30, 50), + ) + + # SMGs + challenges[1585] = Challenge( + position=27, + identifier=1585, + id_text="GD_Challenges.Weapons.SMG_Kills", + category=challenge_cat_smg, + name="Nice Spray Job", + description="Kill enemies with SMGs", + levels=(25, 100, 400, 1000, 2000), + bonus=3, + ) + + challenges[1615] = Challenge( + position=178, + identifier=1615, + id_text="GD_Challenges.Weapons.SMG_CriticalHits", + category=challenge_cat_smg, + name="Bring the Pain", + description="Get critical hits with SMGs", + levels=(25, 100, 400, 1000, 2000), + ) + + challenges[1793] = Challenge( + position=58, + identifier=1793, + id_text="GD_Challenges.Weapons.SMG_CriticalHitKills", + category=challenge_cat_smg, + name="Dinky Death Dealer", + description="Kill enemies with critical hits using SMGs", + levels=(10, 25, 75, 150, 300), + ) + + challenges[1773] = Challenge( + position=188, + identifier=1773, + id_text="GD_Challenges.Weapons.SMG_SecondWinds", + category=challenge_cat_smg, + name="And Stay Down!", + description="Get Second Winds with SMGs", + levels=(2, 5, 15, 30, 50), + ) + + # Shotguns + challenges[1584] = Challenge( + position=26, + identifier=1584, + id_text="GD_Challenges.Weapons.Shotgun_Kills", + category=challenge_cat_shotgun, + name="Boomstick Boogie", + description="Kill enemies with shotguns", + levels=(25, 100, 400, 1000, 2000), + bonus=3, + ) + + challenges[1614] = Challenge( + position=177, + identifier=1614, + id_text="GD_Challenges.Weapons.Shotgun_CriticalHits", + category=challenge_cat_shotgun, + name="Hello Uncle Buckshot", + description="Get critical hits with shotguns", + levels=(50, 250, 1000, 2500, 5000), + ) + + challenges[1772] = Challenge( + position=187, + identifier=1772, + id_text="GD_Challenges.Weapons.Shotgun_SecondWinds", + category=challenge_cat_shotgun, + name="Shotgunning the Breeze", + description="Get Second Winds with shotguns", + levels=(2, 5, 15, 30, 50), + ) + + challenges[1756] = Challenge( + position=50, + identifier=1756, + id_text="GD_Challenges.Weapons.Shotgun_KillsPointBlank", + category=challenge_cat_shotgun, + name="Take It All!", + description="Kill enemies from point-blank range with shotguns", + levels=(10, 25, 150, 300, 750), + ) + + challenges[1757] = Challenge( + position=51, + identifier=1757, + id_text="GD_Challenges.Weapons.Shotgun_KillsLongRange", + category=challenge_cat_shotgun, + name="Over Achiever", + description="Kill enemies from long range with shotguns", + levels=(10, 25, 75, 150, 300), + ) + + challenges[1792] = Challenge( + position=57, + identifier=1792, + id_text="GD_Challenges.Weapons.Shotgun_CriticalHitKills", + category=challenge_cat_shotgun, + name="Shotty Workmanship", + description="Kill enemies with critical hits using shotguns", + levels=(10, 50, 100, 250, 500), + ) + + # Pistols + challenges[1583] = Challenge( + position=25, + identifier=1583, + id_text="GD_Challenges.Weapons.Pistol_Kills", + category=challenge_cat_pistol, + name="Trigger Happy", + description="Kill enemies with pistols", + levels=(25, 100, 400, 1000, 2000), + bonus=3, + ) + + challenges[1613] = Challenge( + position=176, + identifier=1613, + id_text="GD_Challenges.Weapons.Pistol_CriticalHits", + category=challenge_cat_pistol, + name="Pistoleer", + description="Get critical hits with pistols", + levels=(25, 100, 400, 1000, 2000), + ) + + challenges[1771] = Challenge( + position=186, + identifier=1771, + id_text="GD_Challenges.Weapons.Pistol_SecondWinds", + category=challenge_cat_pistol, + name="Pistol Whipped", + description="Get Second Winds with pistols", + levels=(2, 5, 15, 30, 50), + ) + + challenges[1791] = Challenge( + position=56, + identifier=1791, + id_text="GD_Challenges.Weapons.Pistol_CriticalHitKills", + category=challenge_cat_pistol, + name="Magnum Maestro", + description="Kill enemies with critical hits using pistols", + levels=(10, 25, 75, 150, 300), + ) + + challenges[1750] = Challenge( + position=49, + identifier=1750, + id_text="GD_Challenges.Weapons.Pistol_KillsQuickshot", + category=challenge_cat_pistol, + name="Gunslinger", + description="Kill enemies shortly after aiming down the sights with a pistol", + levels=(10, 25, 150, 300, 750), + bonus=5, + ) + + # Melee + challenges[1600] = Challenge( + position=75, + identifier=1600, + id_text="GD_Challenges.Melee.Melee_Kills", + category=challenge_cat_melee, + name="Martial Marhsal", + description="Kill enemies with melee attacks", + levels=(25, 100, 400, 1000, 2000), + bonus=3, + ) + + challenges[1843] = Challenge( + position=248, + identifier=1843, + id_text="GD_Challenges.Melee.Melee_KillsBladed", + category=challenge_cat_melee, + name="Captain Cutty", + description="Kill enemies with melee attacks using bladed guns", + levels=(20, 75, 250, 600, 1000), + ) + + # General Combat + challenges[1571] = Challenge( + position=0, + identifier=1571, + id_text="GD_Challenges.GeneralCombat.General_RoundsFired", + category=challenge_cat_combat, + name="Projectile Proliferation", + description="Fire a lot of rounds", + levels=(5000, 10000, 25000, 50000, 75000), + bonus=5, + ) + + challenges[1652] = Challenge( + position=90, + identifier=1652, + id_text="GD_Challenges.GeneralCombat.Player_KillsWithActionSkill", + category=challenge_cat_combat, + name="Action Hero", + description="Kill enemies while using your Action Skill", + levels=(20, 75, 250, 750, 1500), + ) + + challenges[1866] = Challenge( + position=270, + identifier=1866, + id_text="GD_Challenges.GeneralCombat.Kills_AtNight", + category=challenge_cat_combat, + name="Dark Sider", + description="Kill enemies at night", + levels=(25, 100, 500, 1000, 1500), + ) + + challenges[1865] = Challenge( + position=269, + identifier=1865, + id_text="GD_Challenges.GeneralCombat.Kills_AtDay", + category=challenge_cat_combat, + name="Day of the Dead", + description="Kill enemies during the day", + levels=(250, 1000, 2500, 5000, 7500), + ) + + challenges[1858] = Challenge( + position=262, + identifier=1858, + id_text="GD_Challenges.GeneralCombat.Tediore_KillWithReload", + category=challenge_cat_combat, + name="Throw Me the Money!", + description="Kill enemies with Tediore reloads", + levels=(5, 10, 25, 75, 150), + bonus=5, + ) + + challenges[1859] = Challenge( + position=263, + identifier=1859, + id_text="GD_Challenges.GeneralCombat.Tediore_DamageFromReloads", + category=challenge_cat_combat, + name="One Man's Trash", + description="Deal damage with Tediore reloads", + levels=(5000, 20000, 100000, 500000, 1000000), + ) + + challenges[1862] = Challenge( + position=266, + identifier=1862, + id_text="GD_Challenges.GeneralCombat.Barrels_KillEnemies", + category=challenge_cat_combat, + name="Barrel of Laughs", + description="Kill enemies with stationary barrels", + levels=(10, 25, 50, 100, 200), + bonus=3, + ) + + challenges[1596] = Challenge( + position=44, + identifier=1596, + id_text="GD_Challenges.GeneralCombat.Kills_FromCrits", + category=challenge_cat_combat, + name="Executioner", + description="Kill enemies with critical hits", + levels=(20, 100, 500, 1000, 1500), + ) + + challenges[2046] = Challenge( + position=463, + identifier=2046, + id_text="GD_Challenges.GeneralCombat.Break_Masks", + category=challenge_cat_combat, + name="Having Trouble Breathing?", + description="Shatter enemy oxygen masks", + levels=(25, 100, 250, 500, 1000), + ) + + challenges[2047] = Challenge( + position=464, + identifier=2047, + id_text="GD_Challenges.GeneralCombat.Kills_Asphyxiation", + category=challenge_cat_combat, + name="Last Gasp", + description="Kill enemies by asphyxiation", + levels=(10, 25, 150, 300, 750), + ) + + challenges[2050] = Challenge( + position=466, + identifier=2050, + id_text="GD_Challenges.GeneralCombat.Shatter_With_Falling", + category=challenge_cat_combat, + name="Comet Crash", + description="Shatter frozen enemies with falling damage", + levels=(5, 10, 50, 100, 200), + bonus=5, + ) + + challenges[2049] = Challenge( + position=465, + identifier=2049, + id_text="GD_Challenges.GeneralCombat.Shatter_With_Weapons", + category=challenge_cat_combat, + name="Ice to Meet You", + description="Shatter frozen enemies with weapons", + levels=(20, 75, 250, 600, 1000), + ) + + # Miscellaneous + challenges[1609] = Challenge( + position=105, + identifier=1609, + id_text="GD_Challenges.Dueling.DuelsWon_HatersGonnaHate", + category=challenge_cat_misc, + name="The Duelist", + description="Win duels", + levels=(1, 5, 15, 30, 50), + ) + + challenges[1754] = Challenge( + position=212, + identifier=1754, + id_text="GD_Challenges.Miscellaneous.Missions_SideMissionsCompleted", + category=challenge_cat_misc, + name="Little on the Side", + description="Complete side missions", + levels=(10, 25, 50, 75, 125), + ) + + challenges[1753] = Challenge( + position=211, + identifier=1753, + id_text="GD_Challenges.Miscellaneous.Missions_OptionalObjectivesCompleted", + category=challenge_cat_misc, + name="OC/DC", + description="Complete optional mission objectives", + levels=(5, 10, 15, 20, 30), + ) + + challenges[1648] = Challenge( + position=174, + identifier=1648, + id_text="GD_Challenges.Miscellaneous.Misc_CompleteChallenges", + category=challenge_cat_misc, + name="We Have a Contender", + description="Complete many, many challenges", + levels=(5, 25, 50, 100, 200), + ) + + return challenges diff --git a/borderlands/challenges.py b/borderlands/challenges.py new file mode 100644 index 0000000..f210a97 --- /dev/null +++ b/borderlands/challenges.py @@ -0,0 +1,176 @@ +import io +import struct +from typing import Tuple, Optional, Dict + +from borderlands.datautil.errors import BorderlandsError + + +class ChallengeCategory: + """ + Simple little class to hold information about challenge + categories. Mostly just a glorified dict. + """ + + def __init__(self, name: str, dlc: int = 0, bl2_is_in_challenge_accepted: bool = False) -> None: + self.name = name + self.dlc = dlc + self.bl2_is_in_challenge_accepted = bl2_is_in_challenge_accepted + if self.dlc == 0: + self.is_from_dlc = 0 + else: + self.is_from_dlc = 1 + + +class Challenge: + """ + A simple little object to hold information about our non-level-specific + challenges. This is *mostly* just a glorified dict. + """ + + def __init__( + self, + *, + position: int, + identifier: int, + id_text: str, + category: ChallengeCategory, + name: str, + description: str, + levels: Tuple[int, ...], + bonus: Optional[int] = None, + bl2_is_in_challenge_accepted: bool = False, + ) -> None: + self.position = position + self.identifier = identifier + self.id_text = id_text + self.category = category + self.name = name + self.description = description + self.levels = levels + self.bonus = bonus + self.bl2_is_in_challenge_accepted = bl2_is_in_challenge_accepted + + def get_max(self) -> int: + """ + Returns the point value for the challenge JUST before its maximum level. + """ + return self.levels[-1] - 1 + + def get_bonus(self) -> Optional[int]: + """ + Returns the point value for the challenge JUST before getting the challenge's + bonus reward, if any. Will return None if no bonus is present for the + challenge. + """ + if self.bonus is None: + return None + else: + return self.levels[self.bonus - 1] - 1 + + def __lt__(self, other) -> bool: + return self.id_text.lower() < other.id_text.lower() + + +def unwrap_challenges(*, data: bytes, challenges: Dict[int, Challenge], endian: str) -> dict: + """ + Unwraps our challenge data. The first ten bytes are a header: + + int32: Unknown, is always "4" on my savegames, though. + int32: Size in bytes of all the challenges, plus two more bytes + for the next short + short: Number of challenges + + Each challenge takes up a total of 12 bytes, so num_challenges*12 + should always equal size_in_bytes-2. + + The structure of each challenge is: + + byte: unknown, possibly at least part of an ID, but not unique + on its own + byte: unknown, but is always (on my saves, anyway) 6 or 7. + byte: unknown, but is always 1. + int32: total value of the challenge, across all resets + byte: unknown, but is always 1 + int32: previous, pre-challenge-reset value. Will always be 0 + until challenges have been reset at least once. + + The first two bytes of each challenge can be taken together, and if so, can + serve as a unique identifier for the challenge. I decided to read them in + that way, as a short value. I wasn't able to glean any pattern to whether + a 6 or a 7 shows up in the second byte. + + Once your challenges have been reset in-game, the previous value is copied + into that second int32, but the total value itself remains unchanged, so at + that point you need to subtract previous_value from total_value to find the + actual current state of the challenge (that procedure is obviously true + prior to any resets, too, since previous_value is just zero in that case). + + It's also worth mentioning that challenge data keeps accumulating even + after the challenge itself is completed, so the number displayed in-game + for completed challenges is no longer accurate. + + """ + + unknown, size_in_bytes, num_challenges = struct.unpack(endian + 'IIH', data[:10]) + # Sanity check on size reported + if (size_in_bytes + 8) != len(data): + raise BorderlandsError(f'Challenge data reported as {size_in_bytes} bytes, but {len(data) - 8} bytes found') + + # Sanity check on number of challenges reported + if (num_challenges * 12) != (size_in_bytes - 2): + raise BorderlandsError(f'{num_challenges} challenges reported, but {size_in_bytes - 2} bytes of data found') + + # Now read them in + challenges_result = [] + for challenge in range(num_challenges): + idx = 10 + (challenge * 12) + challenge_dict = dict( + zip( + ['id', 'first_one', 'total_value', 'second_one', 'previous_value'], + struct.unpack(endian + 'HBIBI', data[idx : idx + 12]), + ) + ) + challenges_result.append(challenge_dict) + + if challenge_dict['id'] in challenges: + info = challenges[challenge_dict['id']] + challenge_dict['_id_text'] = info.id_text + challenge_dict['_category'] = info.category.name + challenge_dict['_name'] = info.name + challenge_dict['_description'] = info.description + + return {'unknown': unknown, 'challenges': challenges_result} + + +def wrap_challenges(*, data: dict, endian: str) -> bytes: + """ + Re-wrap our challenge data. See the notes above in unwrap_challenges for + details on the structure. + + Note that we are trusting that the correct number of challenges are present + in our data structure and setting size_in_bytes and num_challenges to match. + Change the number of challenges at your own risk! + """ + + b = io.BytesIO() + b.write( + struct.pack( + endian + 'IIH', + data['unknown'], + (len(data['challenges']) * 12) + 2, + len(data['challenges']), + ) + ) + save_challenges = data['challenges'] + for challenge in save_challenges: + b.write( + struct.pack( + endian + 'HBIBI', + challenge['id'], + challenge['first_one'], + challenge['total_value'], + challenge['second_one'], + challenge['previous_value'], + ) + ) + return b.getvalue() diff --git a/borderlands/config.py b/borderlands/config.py new file mode 100644 index 0000000..eace988 --- /dev/null +++ b/borderlands/config.py @@ -0,0 +1,395 @@ +import argparse +import os +from typing import Union, Optional, List, Callable, Any, Dict + + +def adjust_value(*, prev: Optional[Union[str, int]], min_value: int, max_value: int, label: str) -> Optional[int]: + if prev is None: + return None + + assert min_value <= max_value + + if prev == 'max': + return max_value + + try: + int_value = int(prev) + except ValueError: + raise argparse.ArgumentTypeError(f'{label} value {prev!r} is not a number') + + if int_value > max_value: + return max_value + if int_value < min_value: + return min_value + return int_value + + +class Config(argparse.Namespace): + """ + Class to hold our configuration information. Note that + we're NOT using a separate class for BL2 and BLTPS configs, + since so much of it is the same. + """ + + # Given by the user, booleans + json = False + big_endian = False + verbose = True + force = False + copy_nvhm_missions = False + print_unexplored_levels = False + + # Given by the user, strings + import_items = None + output = 'savegame' + input_filename = '-' + output_filename = '-' + + # Former 'modify' options + name = None + save_game_id = None + level = None + money = None + eridium = None + moonstone = None + seraph = None + torgue = None + item_levels = None + backpack = None + bank = None + gun_slots = None + max_ammo = None + op_level = None + unlock: Dict[str, Any] = {} + challenges: Dict[str, Any] = {} + fix_challenge_overflow = False + + # Config options interpreted from the above + endian = '<' + changes = False + + def finish( + self, + *, + parser: argparse.ArgumentParser, + max_level: int, + min_backpack_size: int, + max_backpack_size: int, + min_bank_size: int, + max_bank_size: int, + ) -> None: + """ + Some extra sanity checks on our options. "parser" should + be an active ArgumentParser object we can use to raise + errors. "app" is an App object which we use for a couple + lookups. + """ + + # byte order + if self.big_endian: + self.endian = '>' + else: + self.endian = '<' + + # If we're unlocking ammo, also set maxammo + if 'ammo' in self.unlock: + self.max_ammo = True + + # Can't read/write to the same file + if ( + self.output_filename is not None + and self.input_filename != '-' + and os.path.abspath(self.input_filename) == os.path.abspath(self.output_filename) + ): + parser.error('input_filename and output_filename cannot be the same file') + + # If the user specified --level, make sure it's from 1 to 80 + if self.level is not None: + if self.level < 1: + parser.error('level must be at least 1') + if self.level > max_level: + parser.error(f'level can be at most {max_level}') + + self.backpack = adjust_value( + prev=self.backpack, + min_value=min_backpack_size, + max_value=max_backpack_size, + label='Backpack', + ) + + self.bank = adjust_value(prev=self.bank, min_value=min_bank_size, max_value=max_bank_size, label='Bank') + + +class DictAction(argparse.Action): + """ + Custom argparse action to put list-like arguments into + a dict (where the value will be True) rather than a list. + This is probably implemented fairly shoddily. + """ + + def __init__(self, option_strings, dest, nargs=None, **kwargs): + """ + Constructor, taken right from https://docs.python.org/2.7/library/argparse.html#action + """ + if nargs is not None: + raise ValueError('nargs is not allowed') + super().__init__(option_strings, dest, **kwargs) + + def __call__(self, parser, namespace, values, option_string=None): + """ + Actually setting a value. Forces the attr into a dict if it isn't already. + """ + arg_value = getattr(namespace, self.dest) + if not isinstance(arg_value, dict): + arg_value = {} + arg_value[values] = True + setattr(namespace, self.dest, arg_value) + + +def parse_args( + *, + args: List[str], + setup_currency_args: Callable[[argparse.ArgumentParser], None], + setup_game_specific_args: Callable[[argparse.ArgumentParser], None], + game_name: str, + max_level: int, + min_backpack_size: int, + max_backpack_size: int, + min_bank_size: int, + max_bank_size: int, + unlock_choices: List[str], +): + """ + Parse our arguments. + """ + + def non_empty_string(s): + if len(s) > 0: + return s + raise argparse.ArgumentTypeError("Value must have length greater than 0") + + def positive_int(value): + try: + result = int(value) + except Exception: + raise argparse.ArgumentTypeError(f'positive integer value required: {value!r}') + + if result > 0: + return result + + raise argparse.ArgumentTypeError(f'positive integer value required: {result!r}') + + # Set up our config object + config = Config() + + parser = argparse.ArgumentParser( + description=f'Modify {game_name} Save Files', + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + + # Optional args + + parser.add_argument( + '-o', + '--output', + choices=['savegame', 'decoded', 'decodedjson', 'json', 'items', 'none'], + default='savegame', + help=""" + Output file format. The most useful to humans are: savegame, json, and items. + If no output file is specified, this will revert to `none`. + """, + ) + + parser.add_argument( + '-i', + '--import-items', + dest='import_items', + help='read in codes for items and add them to the bank and inventory', + ) + + parser.add_argument( + '-j', + '--json', + action='store_true', + help='read savegame data from JSON format, rather than savegame', + ) + + parser.add_argument( + '-b', + '--bigendian', + action='store_true', + dest='big_endian', + help='change the output format to big-endian, to write PS/xbox save files', + ) + + # TODO: rewrite with "-v/--verbose" + parser.add_argument( + '-q', + '--quiet', + dest='verbose', + action='store_false', + help='quiet output (should generate no output unless there are errors)', + ) + + parser.add_argument( + '-f', + '--force', + action='store_true', + help='force output file overwrite, if the destination file exists', + ) + + # More optional args - used to be the "modify" option + + parser.add_argument( + '--name', + type=non_empty_string, + help='Set the name of the character', + ) + + parser.add_argument( + '--save-game-id', + dest='save_game_id', + type=positive_int, + help='Set the save game slot ID of the character (probably not actually needed ever)', + ) + + parser.add_argument( + '--level', + type=int, + help=f'Set the character to this level (from 1 to {max_level})', + ) + + parser.add_argument( + '--money', + type=int, + help='Money to set for character', + ) + + # B2 and TPS have different currency types, so this function is + # implemented in the implementing classes. + setup_currency_args(parser) + + parser.add_argument( + '--itemlevels', + type=int, + dest='item_levels', + help='Set item levels (to set to current player level, specify 0).' + 'Skips level 1 items unless --forceitemlevels is specified too', + ) + + parser.add_argument( + '--forceitemlevels', + action='store_true', + dest='force_item_levels', + help='Set item levels even if the item is at level 1', + ) + + parser.add_argument( + '--backpack', + help=f'Set size of backpack (maximum is {max_backpack_size}, "max" may be specified)', + ) + + parser.add_argument( + '--bank', + help=f'Set size of bank (maximum is {max_bank_size}, "max" may be specified)', + ) + + parser.add_argument( + '--gunslots', + type=int, + choices=[2, 3, 4], + dest='gun_slots', + help='Set number of gun slots open', + ) + + parser.add_argument( + '--copy-nvhm-missions', + dest='copy_nvhm_missions', + action='store_true', + help='Copies NVHM mission state to both TVHM and UVHM modes. Also unlocks TVHM/UVHM', + ) + + parser.add_argument( + '--unlock', + action=DictAction, + choices=unlock_choices, + default={}, + help='Game features to unlock', + ) + + parser.add_argument( + '--challenges', + action=DictAction, + choices=['zero', 'max', 'bonus'], + default={}, + help='Levels to set on challenge data', + ) + + parser.add_argument( + '--maxammo', + action='store_true', + dest='max_ammo', + help='Fill all ammo pools to their maximum', + ) + + parser.add_argument( + '--fix-challenge-overflow', + action='store_true', + help='Fix values for challenges which appear as huge negative numbers', + ) + + parser.add_argument( + '--print-unexplored-levels', + action='store_true', + help='Print level names that are not fully explored by player', + ) + + # Positional args + + parser.add_argument('input_filename', help='Input filename, can be "-" to specify STDIN') + + parser.add_argument( + 'output_filename', + nargs='?', + help=""" + Output filename, can be "-" to specify STDOUT. Can be optional, in + which case no output file is produced. + """, + ) + + # Additional game-specific arguments + setup_game_specific_args(parser) + + # Actually parse the args + parser.parse_args(args, config) + + # Do some extra fiddling + config.finish( + parser=parser, + max_level=max_level, + min_backpack_size=min_backpack_size, + max_backpack_size=max_backpack_size, + min_bank_size=min_bank_size, + max_bank_size=max_bank_size, + ) + + # Some sanity checking with output type and output_filename + if config.output_filename is None: + # NOTE: no more config.changes support: check at the end if data changed + + # If we manually specified an output type, we'll also need an output filename. + # It's possible in this case that the user explicitly set `savegame` as the + # output, rather than just leaving it at the default, but I don't think it's + # worth the shenanigans necessary to detect that. + if config.output not in {'savegame', 'none'}: + parser.error(f"No output_filename was specified, but output type '{config.output}' was specified") + + # If we got here, we're probably good, but force ourselves to `none` output + config.output = 'none' + + else: + # If we have an output filename but `none` output, complain about it. + if config.output == 'none': + parser.error("Output filename specified but with `none` output") + + return config diff --git a/borderlands/datautil/__init__.py b/borderlands/datautil/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/borderlands/datautil/bitstreams.py b/borderlands/datautil/bitstreams.py new file mode 100644 index 0000000..badd392 --- /dev/null +++ b/borderlands/datautil/bitstreams.py @@ -0,0 +1,89 @@ +import copy + + +class ReadBitstream: + def __init__(self, s: bytes) -> None: + self.s = s + self.i = 0 + + def read_bit(self) -> int: + i = self.i + self.i = i + 1 + byte = self.s[i >> 3] + bit = byte >> (7 - (i & 7)) + return bit & 1 + + def read_bits(self, n: int) -> int: + s = self.s + i = self.i + end = i + n + chunk = s[i >> 3 : (end + 7) >> 3] + value = chunk[0] & ~(0xFF00 >> (i & 7)) + for c in chunk[1:]: + value = (value << 8) | c + if (end & 7) != 0: + value = value >> (8 - (end & 7)) + self.i = end + return value + + def read_byte(self) -> int: + i = self.i + self.i = i + 8 + byte = self.s[i >> 3] + if (i & 7) == 0: + return byte + byte = (byte << 8) | self.s[(i >> 3) + 1] + return (byte >> (8 - (i & 7))) & 0xFF + + +class WriteBitstream: + def __init__(self) -> None: + self.s = bytearray() + self.byte = 0 + self.i = 7 + + def write_bit(self, b: int) -> None: + i = self.i + byte = self.byte | (b << i) + if i == 0: + self.s.append(byte) + self.byte = 0 + self.i = 7 + else: + self.byte = byte + self.i = i - 1 + + def write_bits(self, b: int, n: int) -> None: + s = self.s + byte = self.byte + i = self.i + while n >= (i + 1): + shift = n - (i + 1) + n = n - (i + 1) + byte = byte | (b >> shift) + b = b & ~(byte << shift) + s.append(byte) + byte = 0 + i = 7 + if n > 0: + byte = byte | (b << (i + 1 - n)) + i = i - n + self.s = s + self.byte = byte + self.i = i + + def write_byte(self, b: int) -> None: + i = self.i + if i == 7: + self.s.append(b) + else: + self.s.append(self.byte | (b >> (7 - i))) + self.byte = (b << (i + 1)) & 0xFF + + def getvalue(self) -> bytes: + if self.i != 7: + ret_s = copy.copy(self.s) + ret_s.append(self.byte) + return bytes(ret_s) + else: + return bytes(self.s) diff --git a/borderlands/datautil/common.py b/borderlands/datautil/common.py new file mode 100644 index 0000000..3bdd369 --- /dev/null +++ b/borderlands/datautil/common.py @@ -0,0 +1,91 @@ +import binascii +import struct +from typing import Any, Union, List, Dict + + +def wrap_float(v: float) -> List[Union[int, Any]]: + return [5, struct.unpack(" float: + return struct.unpack(" list: + return list(value) + + +def wrap_bytes(value: list) -> bytes: + return bytes(value) + + +def guess_wire_type(value: Any) -> int: + if isinstance(value, (str, bytes)): + return 2 + else: + return 0 + + +def invert_structure(structure: dict) -> dict: + inv: Dict[Any, tuple] = {} + for k, v in structure.items(): + if isinstance(v, tuple): + if isinstance(v[2], dict): # TODO: check len(v) + inv[v[0]] = (k, v[1], invert_structure(v[2])) + else: + inv[v[0]] = (k,) + v[1:] + else: + inv[v] = k + return inv + + +def conv_binary_to_str(data: Any) -> Any: + """ + In Python 2, we can dump to a JSON object directly, but Python 3 + doesn't like that some of the data is binary (since that's invalid in + JSON). Python 2 would just cast those as strings automatically. + So this will loop through and convert everything that's binary + into a string. + """ + if isinstance(data, bytes): + return data.decode('latin1') + elif isinstance(data, dict): + return {k: conv_binary_to_str(v) for k, v in data.items()} + elif isinstance(data, list): + return [conv_binary_to_str(x) for x in data] + else: + return data + + +def rotate_data_right(data: bytes, steps: int) -> bytes: + steps = steps % len(data) + return data[-steps:] + data[:-steps] + + +def rotate_data_left(data: bytes, steps: int) -> bytes: + steps = steps % len(data) + return data[steps:] + data[:steps] + + +def xor_data(data, key: int) -> bytes: + key = key & 0xFFFFFFFF + output = bytearray() + for c in data: + key = (key * 279470273) % 4294967291 + output.append((c ^ key) & 0xFF) + return bytes(output) + + +def create_body(*, item: bytes, header: bytes, key: int) -> bytes: + padding = b"\xff" * (33 - len(item)) + h = binascii.crc32(header + b"\xff\xff" + item + padding) & 0xFFFFFFFF + checksum = struct.pack(">H", ((h >> 16) ^ h) & 0xFFFF) + body = xor_data(rotate_data_left(checksum + item, key & 31), key >> 5) + return body + + +def replace_raw_item_key(data: bytes, key: int) -> bytes: + old_key = struct.unpack(">i", data[1:5])[0] + item = rotate_data_right(xor_data(data[5:], old_key >> 5), old_key & 31)[2:] + header = struct.pack(">Bi", data[0], key) + return header + create_body(item=item, header=header, key=key) diff --git a/borderlands/datautil/data_types.py b/borderlands/datautil/data_types.py new file mode 100644 index 0000000..7cf3a3b --- /dev/null +++ b/borderlands/datautil/data_types.py @@ -0,0 +1,4 @@ +from typing import Dict, Callable + +PlayerDict = Dict[int, list] +PlayerPatchProc = Callable[[PlayerDict, str], None] diff --git a/borderlands/datautil/errors.py b/borderlands/datautil/errors.py new file mode 100644 index 0000000..6a22f89 --- /dev/null +++ b/borderlands/datautil/errors.py @@ -0,0 +1,2 @@ +class BorderlandsError(RuntimeError): + pass diff --git a/borderlands/datautil/huffman.py b/borderlands/datautil/huffman.py new file mode 100644 index 0000000..4a5e8fb --- /dev/null +++ b/borderlands/datautil/huffman.py @@ -0,0 +1,110 @@ +from bisect import insort + +from borderlands.datautil.bitstreams import ReadBitstream, WriteBitstream + + +class HuffmanNode: + """ + This is a bit of a hack because I don't feel like rewriting `make_huffman_tree` + entirely. Basically the current implementation relies on Python 2 behavior + where lists and ints can be compared directly with comparison operators. + Python 3 forbids this, so the call to `bisect.insort()` inside + `make_huffman_tree` fails once it encounters a "regular" two-element list + with two ints, and another whose second element is a nested structure. + Really `make_huffman_tree` should just be rewritten to be sensible, but + rather than doing that I'm doing this hacky thing. C'est la vie. + """ + + def __init__(self, *, weight: int, data) -> None: + self.weight = weight + self.data = data + + def __repr__(self) -> str: + return f'hn({self.weight}, {self.data})' + + def __lt__(self, other) -> bool: + """ + Compare by weight, and then by data. If the data on either + isn't an int, sort it after the other one (as Python 2 + would do) + """ + if self.weight != other.weight: + return self.weight < other.weight + + if isinstance(self.data, int) and isinstance(other.data, int): + return self.data < other.data + else: + return isinstance(self.data, int) + + def to_list(self) -> list: + """ + Returns ourselves as a nested collection of lists, rather than a + nested collection of HuffmanNodes. + """ + if isinstance(self.data, int): + return [self.weight, self.data] + else: + return [self.weight, [d.to_list() for d in self.data]] + + +def read_huffman_tree(b: ReadBitstream): + node_type = b.read_bit() + if node_type == 0: + return None, (read_huffman_tree(b), read_huffman_tree(b)) + else: + return None, b.read_byte() + + +def write_huffman_tree(node, b: WriteBitstream) -> None: + if isinstance(node[1], int): + b.write_bit(1) + b.write_byte(node[1]) + else: + b.write_bit(0) + write_huffman_tree(node[1][0], b) + write_huffman_tree(node[1][1], b) + + +def make_huffman_tree(data) -> list: + frequencies = [0] * 256 + for c in data: + frequencies[c] += 1 + + nodes = [HuffmanNode(weight=f, data=i) for i, f in enumerate(frequencies) if f != 0] + nodes.sort() + + while len(nodes) > 1: + left, right = nodes[:2] + nodes = nodes[2:] + insort(nodes, HuffmanNode(weight=left.weight + right.weight, data=[left, right])) + + return nodes[0].to_list() + + +def invert_tree(node, code=0, bits=0) -> dict: + if isinstance(node[1], int): + return {node[1]: (code, bits)} + else: + result = {} + result.update(invert_tree(node[1][0], code << 1, bits + 1)) + result.update(invert_tree(node[1][1], (code << 1) | 1, bits + 1)) + return result + + +def huffman_decompress(tree, bitstream, size) -> bytes: + output = bytearray() + while len(output) < size: + node = tree + while True: + b = bitstream.read_bit() + node = node[1][b] + if isinstance(node[1], int): + output.append(node[1]) + break + return bytes(output) + + +def huffman_compress(encoding, data, bitstream): + for c in data: + code, nbits = encoding[c] + bitstream.write_bits(code, nbits) diff --git a/borderlands/datautil/lzo1x.py b/borderlands/datautil/lzo1x.py new file mode 100644 index 0000000..30c5330 --- /dev/null +++ b/borderlands/datautil/lzo1x.py @@ -0,0 +1,287 @@ +import sys +from typing import Final, Optional, Tuple + +CLZ_TABLE: Final = ( + 32, + 0, + 1, + 26, + 2, + 23, + 27, + 0, + 3, + 16, + 24, + 30, + 28, + 11, + 0, + 13, + 4, + 7, + 17, + 0, + 25, + 22, + 31, + 15, + 29, + 10, + 12, + 6, + 0, + 21, + 14, + 9, + 5, + 20, + 8, + 19, + 18, +) + + +def expand_zeroes(*, src: bytearray, ip: int, extra: int) -> Tuple[int, int]: + start = ip + # TODO: add check for src.size + while src[ip] == 0: + ip = ip + 1 + v = ((ip - start) * 255) + src[ip] + return v + extra, ip + 1 + + +def copy_earlier(*, b: bytearray, offset: int, chunk_size: int) -> None: + i = len(b) - offset + end = i + chunk_size + while i < end: + chunk = b[i : i + chunk_size] + i = i + len(chunk) + chunk_size = chunk_size - len(chunk) + b.extend(chunk) + + +def read_xor32(src: bytearray, p1: int, p2: int) -> int: + v1 = src[p1] | (src[p1 + 1] << 8) | (src[p1 + 2] << 16) | (src[p1 + 3] << 24) + v2 = src[p2] | (src[p2 + 1] << 8) | (src[p2 + 2] << 16) | (src[p2 + 3] << 24) + return v1 ^ v2 + + +def lzo1x_decompress(s: bytes) -> bytes: + dst = bytearray() + src = bytearray(s) + ip = 5 + + t = src[ip] + ip += 1 + if t > 17: + t = t - 17 + dst.extend(src[ip : ip + t]) + ip += t + t = src[ip] + ip += 1 + elif t < 16: + if t == 0: + t, ip = expand_zeroes(src=src, ip=ip, extra=15) + dst.extend(src[ip : ip + t + 3]) + ip += t + 3 + t = src[ip] + ip += 1 + + while True: + while True: + if t >= 64: + copy_earlier(b=dst, offset=1 + ((t >> 2) & 7) + (src[ip] << 3), chunk_size=(t >> 5) + 1) + ip += 1 + elif t >= 32: + count = t & 31 + if count == 0: + count, ip = expand_zeroes(src=src, ip=ip, extra=31) + t = src[ip] + copy_earlier(b=dst, offset=1 + ((t | (src[ip + 1] << 8)) >> 2), chunk_size=count + 2) + ip += 2 + elif t >= 16: + offset = (t & 8) << 11 + count = t & 7 + if count == 0: + count, ip = expand_zeroes(src=src, ip=ip, extra=7) + t = src[ip] + offset += (t | (src[ip + 1] << 8)) >> 2 + ip += 2 + if offset == 0: + return bytes(dst) + copy_earlier(b=dst, offset=offset + 0x4000, chunk_size=count + 2) + else: + copy_earlier(b=dst, offset=1 + (t >> 2) + (src[ip] << 2), chunk_size=2) + ip += 1 + + t = t & 3 + if t == 0: + break + dst.extend(src[ip : ip + t]) + ip += t + t = src[ip] + ip += 1 + + while True: + t = src[ip] + ip += 1 + if t < 16: + if t == 0: + t, ip = expand_zeroes(src=src, ip=ip, extra=15) + dst.extend(src[ip : ip + t + 3]) + ip += t + 3 + t = src[ip] + ip += 1 + if t < 16: + copy_earlier(b=dst, offset=1 + 0x0800 + (t >> 2) + (src[ip] << 2), chunk_size=3) + ip += 1 + t = t & 3 + if t == 0: + continue + dst.extend(src[ip : ip + t]) + ip += t + t = src[ip] + ip += 1 + break + + +def lzo1x_1_compress_core(*, src: bytearray, dst: bytearray, ti: int, ip_start: int, ip_len: int) -> Optional[int]: + dict_entries = [0] * 16384 + + in_end = ip_start + ip_len + ip_end = ip_start + ip_len - 20 + + ip = ip_start + ii = ip_start + + ip += (4 - ti) if ti < 4 else 0 + ip += 1 + ((ip - ii) >> 5) + while True: + while True: + if ip >= ip_end: + return in_end - (ii - ti) + dv = src[ip : ip + 4] + dindex = dv[0] | (dv[1] << 8) | (dv[2] << 16) | (dv[3] << 24) + dindex = ((0x1824429D * dindex) >> 18) & 0x3FFF + m_pos = ip_start + dict_entries[dindex] + dict_entries[dindex] = (ip - ip_start) & 0xFFFF + if dv == src[m_pos : m_pos + 4]: + break + ip += 1 + ((ip - ii) >> 5) + + ii -= ti + ti = 0 + t = ip - ii + if t != 0: + if t <= 3: + dst[-2] |= t + dst.extend(src[ii : ii + t]) + elif t <= 16: + dst.append(t - 3) + dst.extend(src[ii : ii + t]) + else: + if t <= 18: + dst.append(t - 3) + else: + tt = t - 18 + dst.append(0) + n, tt = divmod(tt, 255) + dst.extend(b"\x00" * n) + dst.append(tt) + dst.extend(src[ii : ii + t]) + ii += t + + m_len = 4 + v = read_xor32(src, ip + m_len, m_pos + m_len) + if v == 0: + while True: + m_len += 4 + v = read_xor32(src, ip + m_len, m_pos + m_len) + if ip + m_len >= ip_end: + break + elif v != 0: + m_len += CLZ_TABLE[(v & -v) % 37] >> 3 + break + else: + m_len += CLZ_TABLE[(v & -v) % 37] >> 3 + + m_off = ip - m_pos + ip += m_len + ii = ip + if m_len <= 8 and m_off <= 0x0800: + m_off -= 1 + dst.append(((m_len - 1) << 5) | ((m_off & 7) << 2)) + dst.append(m_off >> 3) + elif m_off <= 0x4000: + m_off -= 1 + if m_len <= 33: + dst.append(32 | (m_len - 2)) + else: + m_len -= 33 + dst.append(32) + n, m_len = divmod(m_len, 255) + dst.extend(b"\x00" * n) + dst.append(m_len) + dst.append((m_off << 2) & 0xFF) + dst.append((m_off >> 6) & 0xFF) + else: + m_off -= 0x4000 + if m_len <= 9: + dst.append(0xFF & (16 | ((m_off >> 11) & 8) | (m_len - 2))) + else: + m_len -= 9 + dst.append(0xFF & (16 | ((m_off >> 11) & 8))) + n, m_len = divmod(m_len, 255) + dst.extend(b"\x00" * n) + dst.append(m_len) + dst.append((m_off << 2) & 0xFF) + dst.append((m_off >> 6) & 0xFF) + + +def lzo1x_1_compress(s: bytes) -> bytes: + src = bytearray(s) + dst = bytearray() + + ip = 0 + len_ = len(s) + t = 0 + + dst.append(240) + dst.append((len_ >> 24) & 0xFF) + dst.append((len_ >> 16) & 0xFF) + dst.append((len_ >> 8) & 0xFF) + dst.append(len_ & 0xFF) + + while len_ > 20 and t + len_ > 31: + ll = min(49152, len_) + temp = lzo1x_1_compress_core(src=src, dst=dst, ti=t, ip_start=ip, ip_len=ll) + if temp is None: + sys.exit('None from lzo1x_1_compress_core') + t = temp + ip += ll + len_ -= ll + t += len_ + + if t > 0: + ii = len(s) - t + + if len(dst) == 5 and t <= 238: + dst.append(17 + t) + elif t <= 3: + dst[-2] |= t + elif t <= 18: + dst.append(t - 3) + else: + tt = t - 18 + dst.append(0) + n, tt = divmod(tt, 255) + dst.extend(b"\x00" * n) + dst.append(tt) + dst.extend(src[ii : ii + t]) + + dst.append(16 | 1) + dst.append(0) + dst.append(0) + + return bytes(dst) diff --git a/borderlands/datautil/protobuf.py b/borderlands/datautil/protobuf.py new file mode 100644 index 0000000..197f66b --- /dev/null +++ b/borderlands/datautil/protobuf.py @@ -0,0 +1,198 @@ +import io +import struct +from typing import Any + +from borderlands.datautil.common import wrap_bytes, guess_wire_type +from borderlands.datautil.data_types import PlayerDict +from borderlands.datautil.errors import BorderlandsError + + +def remove_structure(data: dict, inv: dict) -> dict: + result = {} + result.update(data.get("_raw", {})) + for k, value in data.items(): + if k == "_raw": + # Fix for Python 3 - these inner lists need to be + # run through wrap_bytes, else they'll be interpreted + # weirdly. + for raw_k, raw_values in value.items(): + for idx, (wire_type, v) in enumerate(raw_values): + if wire_type == 2: + raw_values[idx][1] = wrap_bytes(v) + continue + mapping = inv.get(k) + if mapping is None: + raise BorderlandsError(f"Unknown key {k!r} in data") + elif isinstance(mapping, int): + result[mapping] = [[guess_wire_type(value), value]] + continue + key, repeated, child_inv = mapping + if child_inv is None: + value = [value] if not repeated else value + result[key] = [[guess_wire_type(v), v] for v in value] + elif isinstance(child_inv, int): + if repeated: + b = io.BytesIO() + for v in value: + write_protobuf_value(b=b, wire_type=child_inv, value=v) + result[key] = [[2, b.getvalue()]] + else: + result[key] = [[child_inv, value]] + elif isinstance(child_inv, tuple): + if not repeated: + value = [value] + values = [] + for v in map(child_inv[1], value): + if isinstance(v, list): + values.append(v) + else: + values.append([guess_wire_type(v), v]) + result[key] = values + elif isinstance(child_inv, dict): + value = [value] if not repeated else value + values = [] + for d in [remove_structure(v, child_inv) for v in value]: + values.append([2, write_protobuf(d)]) + result[key] = values + else: + raise Exception(f"Invalid mapping {mapping!r} for {k!r}: {value!r}") + return result + + +def read_varint(f: io.BytesIO) -> int: + value = 0 + offset = 0 + while True: + b = ord(f.read(1)) + value |= (b & 0x7F) << offset + if (b & 0x80) == 0: + break + offset = offset + 7 + return value + + +def write_varint(f: io.BytesIO, i: int) -> None: + while i > 0x7F: + f.write(bytes([0x80 | (i & 0x7F)])) + i = i >> 7 + f.write(bytes([i])) + + +def read_protobuf_value(b: io.BytesIO, wire_type: int) -> Any: + if wire_type == 0: + return read_varint(b) + elif wire_type == 1: + return struct.unpack(" list: + b = io.BytesIO(data) + values = [] + while b.tell() < len(data): + values.append(read_protobuf_value(b, wire_type)) + return values + + +def write_protobuf_value(*, b: io.BytesIO, wire_type: int, value: Any) -> None: + if wire_type == 0: + write_varint(b, value) + elif wire_type == 1: + b.write(struct.pack(" bytes: + b = io.BytesIO() + for value in data: + write_protobuf_value(b=b, wire_type=wire_type, value=value) + return b.getvalue() + + +def read_protobuf(data: bytes) -> PlayerDict: + fields: PlayerDict = {} + end_position = len(data) + bytestream = io.BytesIO(data) + while bytestream.tell() < end_position: + key = read_varint(bytestream) + field_number = key >> 3 + wire_type = key & 7 + value = read_protobuf_value(bytestream, wire_type) + fields.setdefault(field_number, []).append([wire_type, value]) + return fields + + +def apply_structure(pb_data: PlayerDict, s: dict) -> dict: + fields = {} + raw = {} + for k, data in pb_data.items(): + mapping = s.get(k) + if mapping is None: + raw[k] = data + continue + elif isinstance(mapping, str): + fields[mapping] = data[0][1] + continue + key, repeated, child_s = mapping + if child_s is None: + values = [d[1] for d in data] + fields[key] = values if repeated else values[0] + elif isinstance(child_s, int): + if repeated: + fields[key] = read_repeated_protobuf_value(data[0][1], child_s) + else: + fields[key] = data[0][1] + elif isinstance(child_s, tuple): + values = [child_s[0](d[1]) for d in data] + fields[key] = values if repeated else values[0] + elif isinstance(child_s, dict): + values = [apply_structure(read_protobuf(d[1]), child_s) for d in data] + fields[key] = values if repeated else values[0] + else: + raise TypeError(f"Wrong type of child_s: {type(child_s)}. Invalid mapping {mapping!r} for {k!r}: {data!r}") + if len(raw) != 0: + fields["_raw"] = {} + for k, values in raw.items(): + safe_values = [] + for wire_type, v in values: + if wire_type == 2: + v = list(v) + safe_values.append([wire_type, v]) + fields["_raw"][k] = safe_values + return fields + + +def write_protobuf(data: dict) -> bytes: + b = io.BytesIO() + # If the data came from a JSON file the keys will all be strings + data = {int(k): v for k, v in data.items()} + for key, entries in sorted(data.items()): + for wire_type, value in entries: + if isinstance(value, dict): + value = write_protobuf(value) + wire_type = 2 + elif isinstance(value, (list, tuple)) and wire_type != 2: + sub_b = io.BytesIO() + for v in value: + write_protobuf_value(b=sub_b, wire_type=wire_type, value=v) + value = sub_b.getvalue() + wire_type = 2 + write_varint(b, (key << 3) | wire_type) + write_protobuf_value(b=b, wire_type=wire_type, value=value) + return b.getvalue() diff --git a/borderlands/savefile.py b/borderlands/savefile.py new file mode 100644 index 0000000..59afc30 --- /dev/null +++ b/borderlands/savefile.py @@ -0,0 +1,1163 @@ +import argparse +import base64 +import binascii +import dataclasses +import hashlib +import io +import json +import math +import os +import random +import struct +import sys +from typing import List, Tuple, Dict, Any, Optional, IO, Union + +from borderlands.challenges import Challenge, unwrap_challenges, wrap_challenges +from borderlands.config import parse_args +from borderlands.datautil.bitstreams import ReadBitstream, WriteBitstream +from borderlands.datautil.common import conv_binary_to_str, rotate_data_right, xor_data, create_body +from borderlands.datautil.common import invert_structure, replace_raw_item_key +from borderlands.datautil.data_types import PlayerDict +from borderlands.datautil.errors import BorderlandsError +from borderlands.datautil.huffman import ( + read_huffman_tree, + make_huffman_tree, + write_huffman_tree, + invert_tree, + huffman_decompress, + huffman_compress, +) +from borderlands.datautil.lzo1x import lzo1x_decompress, lzo1x_1_compress +from borderlands.datautil.protobuf import ( + read_protobuf_value, + read_repeated_protobuf_value, + write_repeated_protobuf_value, + read_protobuf, + apply_structure, + write_protobuf, + remove_structure, +) + + +@dataclasses.dataclass(frozen=True) +class InputFileData: + filename: str + filehandle: IO + close: bool + + +class BaseApp: + """ + Base application class. + """ + + # These seem to be the same for both BL2 and BLTPS + item_sizes = ( + (8, 17, 20, 11, 7, 7, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16), + (8, 13, 20, 11, 7, 7, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17), + ) + + # Ditto + item_header_sizes = ( + (("type", 8), ("balance", 10), ("manufacturer", 7)), + (("type", 6), ("balance", 10), ("manufacturer", 7)), + ) + + min_backpack_size = 12 + max_backpack_size = 39 + min_bank_size = 6 + max_bank_size = 24 + + # "laser" in here doesn't apply to BL2, but it won't hurt anything + # because we process ammo pools based off the black market values, + # which won't include lasers for BL2 + ammo_resources: Dict[str, Tuple[str, str]] = { + 'rifle': ('D_Resources.AmmoResources.Ammo_Combat_Rifle', 'D_Resourcepools.AmmoPools.Ammo_Combat_Rifle_Pool'), + 'shotgun': ( + 'D_Resources.AmmoResources.Ammo_Combat_Shotgun', + 'D_Resourcepools.AmmoPools.Ammo_Combat_Shotgun_Pool', + ), + 'grenade': ( + 'D_Resources.AmmoResources.Ammo_Grenade_Protean', + 'D_Resourcepools.AmmoPools.Ammo_Grenade_Protean_Pool', + ), + 'smg': ('D_Resources.AmmoResources.Ammo_Patrol_SMG', 'D_Resourcepools.AmmoPools.Ammo_Patrol_SMG_Pool'), + 'pistol': ( + 'D_Resources.AmmoResources.Ammo_Repeater_Pistol', + 'D_Resourcepools.AmmoPools.Ammo_Repeater_Pistol_Pool', + ), + 'launcher': ( + 'D_Resources.AmmoResources.Ammo_Rocket_Launcher', + 'D_Resourcepools.AmmoPools.Ammo_Rocket_Launcher_Pool', + ), + 'sniper': ('D_Resources.AmmoResources.Ammo_Sniper_Rifle', 'D_Resourcepools.AmmoPools.Ammo_Sniper_Rifle_Pool'), + 'laser': ('D_Resources.AmmoResources.Ammo_Combat_Laser', 'D_Resourcepools.AmmoPools.Ammo_Combat_Laser_Pool'), + } + + # An equation for computing the XP required for a given level is + # stated at http://borderlands.wikia.com/wiki/Experience_Points to + # be (in Python terms): + # + # math.ceil(60*(level**2.8) - 60) + # + # That works well for most of the levels in the game but it's not + # perfect - it overshoots a bit towards the higher levels, which can + # result in some annoying error messages in the game if you bring + # a character to level 72 but have slightly too much XP. Changing + # math.ceil() to plain ol' int() works a bit better for that, actually. + # Things get a bit better in Python if we use decimal.Decimal for + # the numbers instead of relying on Python's native floats, but even + # then it's not perfect. I've tried out the calculation for level + # 72 on a few different languages/calculators/platforms, and I think + # the equation is just not exactly correct. + # + # So, what the heck - we'll just hardcode the XP requirements in here. + # + # Update in 2023, btw: I'd looked into this more thoroughly because + # it's the same essential math used in BL3 / Wonderlands (including + # Chaos XP in WL), and I was having the same XP drift over there that + # I'd always seen while trying to compute this stuff in Python. Well, + # it turns out that the essential problem is that Python sort of + # unavoidably does all the calculations using 64-bit doubles, instead + # of 32-bit floats. The difference in precision starts to add up over + # time. It turns out to be *real* difficult to force Python to use + # floats in the background; you've gotta start doing stuff like `ctypes` + # shenanigans to even have a prayer of it, and even then it's tricky + # and might even require custom C extensions. Anyway, I feel quite good + # about these hardcodes. Not worth the trouble in here, for sure. + required_xp = [ + 0, # lvl 1 + 358, # lvl 2 + 1241, # lvl 3 + 2850, # lvl 4 + 5376, # lvl 5 + 8997, # lvl 6 + 13886, # lvl 7 + 20208, # lvl 8 + 28126, # lvl 9 + 37798, # lvl 10 + 49377, # lvl 11 + 63016, # lvl 12 + 78861, # lvl 13 + 97061, # lvl 14 + 117757, # lvl 15 + 141092, # lvl 16 + 167206, # lvl 17 + 196238, # lvl 18 + 228322, # lvl 19 + 263595, # lvl 20 + 302190, # lvl 21 + 344238, # lvl 22 + 389873, # lvl 23 + 439222, # lvl 24 + 492414, # lvl 25 + 549578, # lvl 26 + 610840, # lvl 27 + 676325, # lvl 28 + 746158, # lvl 29 + 820463, # lvl 30 + 899363, # lvl 31 + 982980, # lvl 32 + 1071435, # lvl 33 + 1164850, # lvl 34 + 1263343, # lvl 35 + 1367034, # lvl 36 + 1476041, # lvl 37 + 1590483, # lvl 38 + 1710476, # lvl 39 + 1836137, # lvl 40 + 1967582, # lvl 41 + 2104926, # lvl 42 + 2248285, # lvl 43 + 2397772, # lvl 44 + 2553501, # lvl 45 + 2715586, # lvl 46 + 2884139, # lvl 47 + 3059273, # lvl 48 + 3241098, # lvl 49 + 3429728, # lvl 50 + 3625271, # lvl 51 + 3827840, # lvl 52 + 4037543, # lvl 53 + 4254491, # lvl 54 + 4478792, # lvl 55 + 4710556, # lvl 56 + 4949890, # lvl 57 + 5196902, # lvl 58 + 5451701, # lvl 59 + 5714393, # lvl 60 + 5985086, # lvl 61 + 6263885, # lvl 62 + 6550897, # lvl 63 + 6846227, # lvl 64 + 7149982, # lvl 65 + 7462266, # lvl 66 + 7783184, # lvl 67 + 8112840, # lvl 68 + 8451340, # lvl 69 + 8798786, # lvl 70 + 9155282, # lvl 71 + 9520931, # lvl 72 + 9895837, # lvl 73 + 10280103, # lvl 74 + 10673830, # lvl 75 + 11077120, # lvl 76 + 11490077, # lvl 77 + 11912801, # lvl 78 + 12345393, # lvl 79 + 12787955, # lvl 80 + ] + + def __init__( + self, + *, + args: List[str], + item_struct_version: int, + game_name: str, + item_prefix: str, + max_level: int, # Max char level + black_market_keys: Tuple[str, ...], + black_market_ammo: Dict[str, List[int]], + unlock_choices: List[str], # Available choices for --unlock option + challenges: Dict[int, Challenge], + ) -> None: + # B2 version is 7, TPS version is 10 + # "version" taken from what Gibbed calls it, not sure if that's + # an appropriate descriptor or not. + self.item_struct_version = item_struct_version + + # Item export/import prefix + self.item_prefix = item_prefix + + # The only difference here is that BLTPS has "laser" + self.black_market_keys = black_market_keys + + # Dict to tell us which black market keys are ammo-related, and + # what the max ammo is at each level. Could be computed pretty + # easily, but we may as well just store it. + self.black_market_ammo = black_market_ammo + + # There are two possible ways of uniquely identifying challenges in this file: + # via their numeric position in the list, or by what looks like an internal + # ID (though that ID is constructed a little weirdly, so I'm not sure if it's + # actually intended to be used that way or not). + # + # I did run some tests, and it looks like internally, B2 probably does use + # that ID field to identify the challenges... You can mess around with the + # order in which they're saved to the file, but so long as the ID field + # is still pointing to the challenge you want, it'll be read in properly + # (and then when you save your game, they'll be written back out in the + # original order). + # + # Given that, I decided to go ahead and use that probably-ID field as the + # index on this dict, rather than the order. That should be slightly more + # flexible for anyone editing the JSON directly, and theoretically + # shouldn't be a problem in the future since there won't be any new major + # DLC for B2... + # + # New major DLC for TPS seems unlikely too, though time will tell. + self.challenges = challenges + + # Set up a reverse lookup for our ammo pools + self.ammo_resource_lookup = {} + for shortname, (resource, pool) in self.ammo_resources.items(): + self.ammo_resource_lookup[resource] = shortname + + # Parse Arguments + self.config = parse_args( + args=args, + setup_currency_args=self.setup_currency_args, + setup_game_specific_args=self.setup_game_specific_args, + game_name=game_name, + max_level=max_level, + min_backpack_size=self.min_backpack_size, + max_backpack_size=self.max_backpack_size, + min_bank_size=self.min_bank_size, + max_bank_size=self.max_bank_size, + unlock_choices=unlock_choices, + ) + + # Sets up our main save_structure var which controls how we read the file + # This is implemented in AppBL2 and AppBLTPS + self.save_structure = self.create_save_structure() + + def pack_item_values(self, is_weapon: int, values: list) -> bytes: + i = 0 + item_bytes = bytearray(32) + for value, size in zip(values, self.item_sizes[is_weapon]): + if value is None: + break + j = i >> 3 + value = value << (i & 7) + while value != 0: + item_bytes[j] |= value & 0xFF + value = value >> 8 + j = j + 1 + i = i + size + if (i & 7) != 0: + value = 0xFF << (i & 7) + item_bytes[i >> 3] |= value & 0xFF + return bytes(item_bytes[: (i + 7) >> 3]) + + def unpack_item_values(self, is_weapon: int, data: bytes) -> List[Optional[int]]: + i = 8 + data = b' ' + data + end = len(data) * 8 + result: List[Optional[int]] = [] + for size in self.item_sizes[is_weapon]: + j = i + size + if j > end: + result.append(None) + continue + value = 0 + for b in data[j >> 3 : (i >> 3) - 1 : -1]: + value = (value << 8) | b + result.append((value >> (i & 7)) & ~(0xFF << size)) + i = j + return result + + def wrap_item(self, *, is_weapon: int, values: list, key: int) -> bytes: + item = self.pack_item_values(is_weapon, values) + header = struct.pack(">Bi", (is_weapon << 7) | self.item_struct_version, key) + return header + create_body(item=item, header=header, key=key) + + def unwrap_item(self, data: bytes) -> Tuple[int, List[Optional[int]], int]: + version_type, key = struct.unpack(">Bi", data[:5]) + is_weapon = version_type >> 7 + raw = rotate_data_right(xor_data(data[5:], key >> 5), key & 31) + return is_weapon, self.unpack_item_values(is_weapon, raw[2:]), key + + def unwrap_black_market(self, value: bytes) -> dict: + sdu_list = read_repeated_protobuf_value(value, 0) + return dict(zip(self.black_market_keys, sdu_list)) + + def wrap_black_market(self, value: dict) -> bytes: + sdu_list = [value[k] for k in self.black_market_keys[: len(value)]] + return write_repeated_protobuf_value(sdu_list, 0) + + def unwrap_challenges(self, data: bytes) -> dict: + return unwrap_challenges(data=data, challenges=self.challenges, endian=self.config.endian) + + def wrap_challenges(self, data: dict) -> bytes: + return wrap_challenges(data=data, endian=self.config.endian) + + def unwrap_item_info(self, value: bytes) -> dict: + is_weapon, item, key = self.unwrap_item(value) + + data: Dict[str, Any] = { + 'is_weapon': is_weapon, + 'key': key, + 'set': item[0], + 'level': (item[4], item[5]), # (grade_index, game_stage) + '_base64': base64.b64encode(value), + } + for i, (k, bits) in enumerate(self.item_header_sizes[is_weapon]): + x = item[1 + i] + if x is None: + sys.exit('unwrap_item_info got None instead of int') + lib = x >> bits + asset = x & ~(lib << bits) + data[k] = {"lib": lib, "asset": asset} + bits = 10 + is_weapon + parts: List[Optional[Dict[str, Any]]] = [] + for x in item[6:]: + if x is None: + parts.append(None) + else: + lib = x >> bits + asset = x & ~(lib << bits) + parts.append({"lib": lib, "asset": asset}) + data["parts"] = parts + return data + + def wrap_item_info(self, value: dict) -> bytes: + parts = [value["set"]] + for key, bits in self.item_header_sizes[value["is_weapon"]]: + v = value[key] + parts.append((v["lib"] << bits) | v["asset"]) + parts.extend(value["level"]) # (grade_index, game_stage) + bits = 10 + value["is_weapon"] + for v in value["parts"]: + if v is None: + parts.append(None) + else: + parts.append((v["lib"] << bits) | v["asset"]) + return self.wrap_item(is_weapon=value["is_weapon"], values=parts, key=value["key"]) + + @staticmethod + def unwrap_player_data(data: bytes) -> bytes: + """ + Byte order on the few struct calls here appears to actually be + hardcoded regardless of platform, so we're perhaps just leaving + them, rather than using self.config.endian as we're doing elsewhere. + I suspect this might actually be wrong, though, and just happens to + work. + """ + if data[:4] == "CON ": + raise BorderlandsError( + "You need to use a program like Horizon or Modio to extract the SaveGame.sav file first" + ) + + if data[:20] != hashlib.sha1(data[20:]).digest(): + raise BorderlandsError("Invalid save file") + + data = lzo1x_decompress(b'\xf0' + data[20:]) + size, wsg, version = struct.unpack('>I3sI', data[:11]) + if version != 2 and version != 0x02000000: + raise BorderlandsError(f'Unknown save version {version}') + + if version == 2: + crc, size = struct.unpack(">II", data[11:19]) + else: + crc, size = struct.unpack(" bytes: + """ + There's one call in here which had a hard-coded endian, as with + unwrap_player_data above, so we're leaving that hardcoded for now. + I suspect that it's wrong to be doing so, though. + """ + crc = binascii.crc32(player) & 0xFFFFFFFF + + bitstream = WriteBitstream() + tree = make_huffman_tree(player) + write_huffman_tree(tree, bitstream) + huffman_compress(invert_tree(tree), player, bitstream) + data = bitstream.getvalue() + b"\x00\x00\x00\x00" + + header = struct.pack(">I3s", len(data) + 15, b'WSG') + header += struct.pack(self.config.endian + "III", 2, crc, len(player)) + + data = lzo1x_1_compress(header + data)[1:] + + return hashlib.sha1(data).digest() + data + + def show_save_info(self, data: bytes) -> None: + """ + Shows information from file data, based on our config object. + "data" should be the raw data from a save file. + + Note that if a user is both showing info and making changes, + we're parsing the protobuf twice, since modify_save also does + that. Inefficiency! + """ + player = read_protobuf(self.unwrap_player_data(data)) + self._show_save_info(player) + + def _show_save_info(self, player: PlayerDict) -> None: + if self.config.print_unexplored_levels: + self.report_explorer_achievements_progress(player) + + def _set_level(self, player: PlayerDict) -> None: + if self.config.level is None: + return + + if self.config.level < 1 or self.config.level > len(self.required_xp): + self.error(f'Invalid character level specified: {self.config.level}') + else: + self.debug(f' - Updating to level {self.config.level}') + lower = self.required_xp[self.config.level - 1] + if self.config.level == len(self.required_xp): + if player[3][0][1] != lower: + player[3][0][1] = lower + self.debug(f' - Also updating XP to {lower}') + else: + upper = self.required_xp[self.config.level] + if player[3][0][1] < lower or player[3][0][1] >= upper: + player[3][0][1] = lower + self.debug(f' - Also updating XP to {lower}') + player[2] = [[0, self.config.level]] + + def _set_money(self, player: PlayerDict) -> None: + if all( + x is None + for x in [ + self.config.money, + self.config.eridium, + self.config.moonstone, + self.config.seraph, + self.config.torgue, + ] + ): + return + + raw = player[6][0][1] + b = io.BytesIO(raw) + values = [] + while b.tell() < len(raw): + values.append(read_protobuf_value(b, 0)) + if self.config.money is not None: + self.debug(f' - Setting available money to {self.config.money}') + values[0] = self.config.money + if self.config.eridium is not None: + self.debug(f' - Setting available eridium to {self.config.eridium}') + values[1] = self.config.eridium + if self.config.moonstone is not None: + self.debug(f' - Setting available moonstone to {self.config.moonstone}') + values[1] = self.config.moonstone + if self.config.seraph is not None: + self.debug(f' - Setting available Seraph Crystals to {self.config.seraph}') + values[2] = self.config.seraph + if self.config.torgue is not None: + self.debug(f' - Setting available Torgue Tokens to {self.config.torgue}') + values[4] = self.config.torgue + player[6][0] = [0, values] + + def _set_item_level(self, player: PlayerDict) -> None: + seen_level_1_warning = False + if self.config.item_levels is not None: + if self.config.item_levels > 0: + self.debug(f' - Setting all items to level {self.config.item_levels}') + level = self.config.item_levels + else: + level = player[2][0][1] + self.debug(f' - Setting all items to character level ({level})') + for field_number in (53, 54): + for field in player[field_number]: + field_data = read_protobuf(field[1]) + is_weapon, item, key = self.unwrap_item(field_data[1][0][1]) + item_4 = item[4] + if item_4 is not None: + if self.config.force_item_levels or item_4 > 1: + item = item[:4] + [level, level] + item[6:] + field_data[1][0][1] = self.wrap_item(is_weapon=is_weapon, values=item, key=key) + field[1] = write_protobuf(field_data) + else: + if item_4 == 1 and not seen_level_1_warning: + seen_level_1_warning = True + self.debug(' NOTICE: At least one item is level 1 and will not be updated.') + self.debug(' Use --forceitemlevels to update these items') + + def _set_overpowered_level(self, player: PlayerDict) -> None: + if self.config.op_level is not None: + set_op_level = False + self.debug(f' - Setting OP Level to {self.config.op_level}') + + # Constructing the new value ahead of time since we'll need it + # no matter what else happens below. + # This little signed/unsigned dance is awful, but it lets us put the + # value in as the same format we got it. So: awesome. Byte order + # shouldn't actually matter here so long as it's consistent. + new_field_data = struct.unpack( + '>Q', struct.pack('>q', -(4 | (max(0, min(self.config.op_level, 0x7FFFFF)) << 8))) + )[0] + + # Now actually get on with it + if self.config.op_level > 0: + if player[7][0][1] < 2 and 'uvhm' not in self.config.unlock: + self.config.unlock['uvhm'] = True + self.debug(' - Also unlocking UVHM mode') + for field in player[53]: + field_data = read_protobuf(field[1]) + if 2 in field_data: + is_weapon, item, key = self.unwrap_item(field_data[1][0][1]) + # TODO: refactor condition below to function + if item[0] == 255 and all([val == 0 for val in item[1:]]): + idnum = (-field_data[2][0][1]) & 0xFF + # An ID of 4 is the one we're after + if idnum == 4: + field_data[2][0][1] = new_field_data + field[1] = write_protobuf(field_data) + set_op_level = True + break + if not set_op_level: + # If we didn't find an existing structure, we'll have to add our + # own in + self.debug(' - Creating new OP Level "virtual" item') + # More magic from Gibbed code + base_data = ( + b"\x07\x00\x00\x00\x00\x39\x2a\xff" + + b"\x00\x00\x00\x00\x00\x00\x00\x00" + + b"\x00\x00\x00\x00\x00\x00\x00\x00" + + b"\x00\x00\x00\x00\x00\x00\x00\x00" + + b"\x00\x00\x00\x00\x00\x00\x00\x00" + ) + # noinspection PyDictCreation + entry = {} + entry[1] = [[2, base_data]] + entry[2] = [[0, new_field_data]] + entry[3] = [[0, 0]] + entry[4] = [[0, 0]] + player[53].append([2, write_protobuf(entry)]) + + def _set_backpack(self, player: PlayerDict) -> None: + if self.config.backpack is None: + return + + self.debug(f' - Setting backpack size to {self.config.backpack}') + size = self.config.backpack + sdu_size = int(math.ceil((size - self.min_backpack_size) / 3.0)) + self.debug(f' - Setting SDU size to {sdu_size}') + new_size = self.min_backpack_size + (sdu_size * 3) + if size != new_size: + self.debug(f' - Resetting backpack size to {new_size} to match SDU count') + slots = read_protobuf(player[13][0][1]) + slots[1][0][1] = new_size + player[13][0][1] = write_protobuf(slots) + s = read_repeated_protobuf_value(player[36][0][1], 0) + player[36][0][1] = write_repeated_protobuf_value(s[:7] + [sdu_size] + s[8:], 0) + + def _set_bank(self, player: PlayerDict) -> None: + if self.config.bank is None: + return + + self.debug(f' - Setting bank size to {self.config.bank}') + size = self.config.bank + sdu_size = int(min(255, math.ceil((size - self.min_bank_size) / 2.0))) + self.debug(f' - Setting SDU size to {sdu_size}') + new_size = self.min_bank_size + (sdu_size * 2) + if size != new_size: + self.debug(f' - Resetting bank size to {new_size} to match SDU count') + if 56 in player: + player[56][0][1] = new_size + else: + player[56] = [[0, new_size]] + s = read_repeated_protobuf_value(player[36][0][1], 0) + if len(s) < 9: + s = s + (9 - len(s)) * [0] + player[36][0][1] = write_repeated_protobuf_value(s[:8] + [sdu_size] + s[9:], 0) + + def _set_gun_slots(self, player: PlayerDict) -> None: + if self.config.gun_slots is None: + return + + self.debug(f' - Setting available gun slots to {self.config.gun_slots}') + n = self.config.gun_slots + slots = read_protobuf(player[13][0][1]) + slots[2][0][1] = n + if slots[3][0][1] > n - 2: + slots[3][0][1] = n - 2 + player[13][0][1] = write_protobuf(slots) + + def _copy_nvhm_missions(self, player: PlayerDict) -> None: + if not self.config.copy_nvhm_missions: + return + + self.debug(' - Copying NVHM mission status to TVHM+UVHM') + if 'uvhm' not in self.config.unlock: + self.config.unlock['uvhm'] = True + self.debug(' - Also unlocking UVHM mode') + player[18][1][1] = player[18][0][1] + player[18][2][1] = player[18][0][1] + + def _unlock_slaughterdome(self, player: PlayerDict) -> None: + if 'slaughterdome' not in self.config.unlock: + return + + unlocked, notifications = b'', b'' + if 23 in player: + unlocked = player[23][0][1] + if 24 in player: + notifications = player[24][0][1] + self.debug(' - Unlocking Creature Slaughterdome') + if 1 not in unlocked: + unlocked += b"\x01" + if 1 not in notifications: + notifications += b"\x01" + player[23] = [[2, unlocked]] + player[24] = [[2, notifications]] + + def _unlock_tvhm_uvhm(self, player: PlayerDict) -> None: + if 'uvhm' in self.config.unlock: + self.debug(' - Unlocking UVHM (and TVHM)') + if player[7][0][1] < 2: + player[7][0][1] = 2 + elif 'tvhm' in self.config.unlock: + self.debug(' - Unlocking TVHM') + if player[7][0][1] < 1: + player[7][0][1] = 1 + + def _unlock_base_challenges(self, player: PlayerDict) -> None: + if 'challenges' not in self.config.unlock: + return + + self.debug(' - Unlocking all non-level-specific challenges') + challenge_unlocks = [apply_structure(read_protobuf(d[1]), self.save_structure[38][2]) for d in player[38]] + inverted_structure = invert_structure(self.save_structure[38][2]) + seen_challenges = {} + for unlock in challenge_unlocks: + seen_challenges[unlock['name'].decode('latin1')] = True + for challenge in sorted(self.challenges.values()): + if challenge.id_text in seen_challenges: + continue + player[38].append( + [ + 2, + write_protobuf( + remove_structure( + { + 'dlc_id': challenge.category.dlc, + 'is_from_dlc': challenge.category.is_from_dlc, + 'name': challenge.id_text, + }, + inverted_structure, + ) + ), + ] + ) + + def _unlock_features(self, player: PlayerDict) -> None: + if not self.config.unlock: + return + + self._unlock_slaughterdome(player) + self._unlock_tvhm_uvhm(player) + self._unlock_base_challenges(player) + + if 'ammo' in self.config.unlock: + self.debug(' - Unlocking ammo capacity') + s = read_repeated_protobuf_value(player[36][0][1], 0) + for idx2, (key2, _value) in enumerate(zip(self.black_market_keys, s)): + if key2 in self.black_market_ammo: + s[idx2] = 7 + player[36][0][1] = write_repeated_protobuf_value(s, 0) + + def _set_max_ammo(self, player: PlayerDict) -> None: + if self.config.max_ammo is None: + return + + self.debug(' - Setting ammo pools to maximum') + + # First we got a figure out our black market levels + s = read_repeated_protobuf_value(player[36][0][1], 0) + assert len(self.black_market_keys) == len(s) + bm_levels = dict(zip(self.black_market_keys, s)) + + # Make a dict of what our max ammo is for each of our black market + # ammo pools + max_ammo = {} + for ammo_type, ammo_level in bm_levels.items(): + if ammo_type not in self.black_market_ammo: + continue + + ammo_values = self.black_market_ammo[ammo_type] + if len(ammo_values) - 1 < ammo_level: + max_ammo[ammo_type] = (len(ammo_values) - 1, ammo_values[-1]) + else: + max_ammo[ammo_type] = (ammo_level, ammo_values[ammo_level]) + + # Now loop through our 'resources' structure and modify to + # suit, updating 'amount' and 'level' as we go. + inverted_structure = invert_structure(self.save_structure[11][2]) + seen_ammo = {} + for idx, protobuf in enumerate(player[11]): + data2 = apply_structure(read_protobuf(protobuf[1]), self.save_structure[11][2]) + resource = data2['resource'].decode('latin1') + if resource in self.ammo_resource_lookup: + ammo_type = self.ammo_resource_lookup[resource] + seen_ammo[ammo_type] = True + if ammo_type in max_ammo: + # Set the data in the structure + data2['level'] = max_ammo[ammo_type][0] + data2['amount'] = float(max_ammo[ammo_type][1]) + + # And now convert back into a protobuf + player[11][idx][1] = write_protobuf(remove_structure(data2, inverted_structure)) + + else: + self.error(f'Ammo type "{ammo_type}" / pool "{data2["pool"]}" not found!') + else: + self.error(f'Ammo pool "{resource}" not found!') + + # Also, early in the game there isn't an entry in here for, for instance, + # rocket launchers. So let's make sure that all our known ammo exists. + for ammo_type in bm_levels.keys(): + if ammo_type in self.ammo_resources.keys() and ammo_type not in seen_ammo: + new_struct = { + 'resource': self.ammo_resources[ammo_type][0], + 'pool': self.ammo_resources[ammo_type][1], + 'level': max_ammo[ammo_type][0], + 'amount': float(max_ammo[ammo_type][1]), + } + player[11].append([2, write_protobuf(remove_structure(new_struct, inverted_structure))]) + + def _handle_challenges(self, player: PlayerDict) -> None: + if not self.config.challenges: + return + + data2 = self.unwrap_challenges(player[15][0][1]) + # You can specify multiple options at once. Specifying "max" and + # "bonus" at the same time, for instance, will put everything at its + # max value, and then potentially lower the ones which have bonuses. + do_zero = 'zero' in self.config.challenges + do_max = 'max' in self.config.challenges + do_bonus = 'bonus' in self.config.challenges + + self.debug(' - Working with challenge data:') + if do_zero: + self.debug(' - Setting challenges to 0') + if do_max: + self.debug(' - Setting challenges to max-1') + if do_bonus: + self.debug(' - Setting bonus challenges') + + for save_challenge in data2['challenges']: + if save_challenge['id'] not in self.challenges: + continue + + if do_zero: + save_challenge['total_value'] = save_challenge['previous_value'] + if do_max: + save_challenge['total_value'] = ( + save_challenge['previous_value'] + self.challenges[save_challenge['id']].get_max() + ) + if do_bonus and self.challenges[save_challenge['id']].bonus: + bonus_value = save_challenge['previous_value'] + self.challenges[save_challenge['id']].get_bonus() + if do_max or do_zero or save_challenge['total_value'] < bonus_value: + save_challenge['total_value'] = bonus_value + + player[15][0][1] = self.wrap_challenges(data2) + + def _fix_challenge_overflow(self, player: PlayerDict) -> None: + # TODO: rewrite as standalone function and move to bl2_routines.py + if not self.config.fix_challenge_overflow: + return + + self.notice('Fix challenge overflow') + data2 = self.unwrap_challenges(player[15][0][1]) + + for save_challenge in data2['challenges']: + if save_challenge['id'] in self.challenges: + if save_challenge['total_value'] >= 2000000000: + self.notice(f'fix overflow in: {save_challenge["_name"]}') + save_challenge['total_value'] = self.challenges[save_challenge['id']].get_max() + 1 + + player[15][0][1] = self.wrap_challenges(data2) + + def _set_character_name(self, player: PlayerDict) -> None: + if self.config.name is None: + return + + self.debug(f' - Setting character name to {self.config.name!r}') + data2 = apply_structure(read_protobuf(player[19][0][1]), self.save_structure[19][2]) + data2['name'] = self.config.name + player[19][0][1] = write_protobuf(remove_structure(data2, invert_structure(self.save_structure[19][2]))) + + def _set_save_game_id(self, player: PlayerDict) -> None: + if self.config.save_game_id is None: + return + + self.debug(f' - Setting save slot ID to {self.config.save_game_id}') + player[20][0][1] = self.config.save_game_id + + def modify_save(self, data: bytes) -> bytes: + """ + Performs a set of modifications on file data, based on our + config object. "data" should be the raw data from a save + file. + + Note that if a user is both showing info and making changes, + we're parsing the protobuf twice, since show_save_info also does + that. Inefficiency! + """ + + player = read_protobuf(self.unwrap_player_data(data)) + + self._set_level(player) + self._set_money(player) + + # Note that this block should always come *after* the block which sets + # character level, in case we've been instructed to set items to the + # character's level. + self._set_item_level(player) + + # OP Level is stored in a weird little custom item. + # See Gibbed.Borderlands2.FileFormats/SaveExpansion.cs for a bit more + # rigorous example of how to process those properly. + # Note that this needs to happen before the unlock section, since + # it may trigger an unlock of UVHM if that wasn't already specified. + self._set_overpowered_level(player) + + self._set_backpack(player) + self._set_bank(player) + self._set_gun_slots(player) + self._copy_nvhm_missions(player) + self._unlock_features(player) + + # This should always come after the ammo-unlock section, since our + # max ammo will change if more black market SDUs are unlocked. + self._set_max_ammo(player) + + self._handle_challenges(player) + self._fix_challenge_overflow(player) + self._set_character_name(player) + self._set_save_game_id(player) + + self._reset_challenge_or_mission(player) + + return self.wrap_player_data(write_protobuf(player)) + + def export_items(self, data, output) -> None: + """ + Exports items stored in savegame data 'data' to the open + filehandle 'output' + """ + player = read_protobuf(self.unwrap_player_data(data)) + skipped_count = 0 + for i, name in ((41, "Bank"), (53, "Items"), (54, "Weapons")): + count = 0 + content = player.get(i) + if content is None: + continue + print(f'; {name}', file=output) + for field in content: + raw: bytes = read_protobuf(field[1])[1][0][1] + + # Borderlands uses some sort-of "fake" items to store some DLC + # data. As per the Gibbed sourcecode, this includes: + # 1. "Currency On Hand" (?) + # 2. Last Playthrough Number / Playthroughs completed + # 3. "Has played in UVHM" + # 4. Overpower levels unlocked + # 5. Last Overpower selection + # + # The data for these is stored in the `unknown2` field, by this + # app's data definitions (or the protobuf's [2] index). Regardless, + # these aren't actual items, so we're skipping them. See Gibbed's + # Gibbed.Borderlands2.FileFormats/SaveExpansion.cs for details + # on how to parse the `unknown2` field. + is_weapon, item, key = self.unwrap_item(raw) + if item[0] == 255 and all([val == 0 for val in item[1:]]): + skipped_count += 1 + else: + count += 1 + raw_bytes = replace_raw_item_key(raw, 0) + printable = base64.b64encode(raw_bytes).decode("latin1") + code = f'{self.item_prefix}({printable})' + print(code, file=output) + self.debug(f' - {name} exported: {count}') + # Don't bother reporting on skipped items, actually, since I now + # know what they're actually used for. + # self.debug(f' - Empty items skipped: {skipped_count}') + + def get_fully_explored_areas(self, player: PlayerDict) -> list[str]: + """ + Reuse converting full player data to json + for simpler code + """ + json_data = apply_structure(player, self.save_structure) + if 'explored_areas' not in json_data: + return [] + names = [x.decode('utf-8') for x in json_data['explored_areas']] + return names + + def report_explorer_achievements_progress(self, player: PlayerDict) -> None: + self.notice(f'No explorer achievements info in {self.__class__.__name__}') + + def create_save_structure(self) -> Dict[int, Any]: + raise NotImplementedError() + + @staticmethod + def setup_game_specific_args(parser: argparse.ArgumentParser) -> None: + """Function to add game-specific arguments.""" + pass + + @staticmethod + def notice(message) -> None: + print(message) + + @staticmethod + def error(message: str) -> None: + print(f'ERROR: {message}', file=sys.stderr) + + def debug(self, message: str) -> None: + if self.config.verbose: + self.notice(message) + + def _read_input_file(self) -> Union[str, bytes]: + if self.config.input_filename == '-': + self.debug('Using STDIN for input file') + return sys.stdin.read() + else: + self.debug(f'Opening {self.config.input_filename} for input file') + with open(self.config.input_filename, 'rb') as inp: + return inp.read() + + def _convert_json(self, save_data: Union[str, bytes]) -> Union[str, bytes]: + if not self.config.json: + return save_data + + self.debug('Interpreting JSON data') + data = json.loads(save_data) + if '1' not in data: + # This means the file had been output as 'json' + data = remove_structure(data, invert_structure(self.save_structure)) + return self.wrap_player_data(write_protobuf(data)) + + def _import_items(self, save_data: bytes) -> Union[str, bytes]: + """ + Imports items into savegame data "data" based on the passed-in + item list in "codelist" + """ + if not self.config.import_items: + return save_data + + self.debug(f'Importing items from {self.config.import_items}') + with open(self.config.import_items) as inp: + raw_items_data = inp.read() + + player = read_protobuf(self.unwrap_player_data(save_data)) + + prefix_length = len(self.item_prefix) + 1 + + bank_count = 0 + weapon_count = 0 + item_count = 0 + + to_bank = False + for line in raw_items_data.splitlines(): + line = line.strip() + if line.startswith(";"): + name = line[1:].strip().lower() + if name == "bank": + to_bank = True + elif name in ("items", "weapons"): + to_bank = False + continue + elif not (line.startswith(self.item_prefix + '(') and line.endswith(')')): + continue + + code = line[prefix_length:-1] + try: + code_bytes = base64.b64decode(code) + except binascii.Error: + continue + + key = random.randrange(0x100000000) - 0x80000000 + code_bytes = replace_raw_item_key(code_bytes, key) + if to_bank: + bank_count += 1 + field = 41 + entry = {1: [[2, code_bytes]]} + elif (code_bytes[0] & 0x80) == 0: + item_count += 1 + field = 53 + entry = {1: [[2, code_bytes]], 2: [[0, 1]], 3: [[0, 0]], 4: [[0, 1]]} + else: + weapon_count += 1 + field = 54 + entry = {1: [[2, code_bytes]], 2: [[0, 0]], 3: [[0, 1]]} + + player.setdefault(field, []).append([2, write_protobuf(entry)]) + + self.debug(f' - Bank imported: {bank_count}') + self.debug(f' - Items imported: {item_count}') + self.debug(f' - Weapons imported: {weapon_count}') + + return self.wrap_player_data(write_protobuf(player)) + + def _prepare_output_file(self) -> Optional[Tuple[IO, bool]]: + self.debug('') + outfile = self.config.output_filename + + if outfile == '-': + self.debug('Using STDOUT for output file') + return sys.stdout, False + + self.debug(f'Use {outfile!r} for output file') + if os.path.isdir(outfile): + raise BorderlandsError(f'Output file is an existing directory: {outfile!r}') + elif os.path.isfile(outfile): + if self.config.force: + self.debug(f'Overwriting output file {outfile!r}') + os.unlink(outfile) + else: + # TODO: what is the point of checking input file name? + if self.config.input_filename == '-': + raise BorderlandsError( + f'Output filename {outfile!r}' + ' exists and --force not specified, aborting' + ) + else: + self.notice('') + self.notice(f'Output filename {outfile!r} exists') + sys.stdout.flush() + sys.stderr.flush() + sys.stderr.write('Continue and overwrite? [y|N] ') + sys.stderr.flush() + answer = sys.stdin.readline() + if answer[0].lower() == 'y': + os.unlink(outfile) + else: + self.notice('') + self.notice('Abort.') + return None + if self.config.output in ('savegame', 'decoded'): + mode = 'wb' + else: + mode = 'w' + + output_file = open(outfile, mode) + return output_file, True + + def _reset_challenge_or_mission(self, player: PlayerDict) -> None: + pass + + def run(self): + """ + Main routine - loads data, does things to it, and then writes + out a file. + """ + + self.debug('') + save_data = self._read_input_file() + + # If we're reading from JSON, convert it + save_data = self._convert_json(save_data) + + # If we've been told to import items, do so. + save_data = self._import_items(save_data) + + # Now perform any changes + self.debug('Performing requested changes') + new_data = self.modify_save(save_data) + + self.show_save_info(new_data) + + # If we have an output file, write to it! + if self.config.output_filename is None: + if new_data != save_data: + sys.exit('Changes were made but no output file specified') + + self.debug('No output file specified. Exiting!') + return + + # Open output file + output_file_info = self._prepare_output_file() + if output_file_info is None: + return + output_file, close = output_file_info + + # Now output based on what we've been told to do + if self.config.output == 'items': + self.debug('Exporting items') + self.export_items(new_data, output_file) + elif self.config.output == 'savegame': + self.debug('Writing savegame file') + output_file.write(new_data) + else: + player = self.unwrap_player_data(new_data) + if self.config.output in ('decodedjson', 'json'): + self.debug('Converting to JSON for more human-readable output') + data = read_protobuf(player) + if self.config.output == 'json': + data = apply_structure(data, self.save_structure) + player = json.dumps(conv_binary_to_str(data), sort_keys=True, indent=4) + output_file.write(player) + + if close: + output_file.close() + + self.notice('Done.') + + @staticmethod + def setup_currency_args(parser) -> None: + raise NotImplementedError() diff --git a/py3_port_tests.py b/py3_port_tests.py new file mode 100755 index 0000000..381d2cb --- /dev/null +++ b/py3_port_tests.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python +""" +NOTE: cleanup tests code +""" +import os +import subprocess +import sys +from typing import Optional + + +class Test(object): + games = ['bl2', 'tps'] + executables = { + 'bl2': { + 'two': '/home/pez/git/borderlands2-py2/bl2_save_edit.py', + 'three': '/home/pez/git/borderlands2/bl2_save_edit.py', + }, + 'tps': { + 'two': '/home/pez/git/borderlands2-py2/bltps_save_edit.py', + 'three': '/home/pez/git/borderlands2/bltps_save_edit.py', + }, + } + output_dir = 'output' + suffix = { + 'two': '.2', + 'three': '.3', + } + + def __init__(self, desc, input_file, args, text_output=False, restrict_game: Optional[str] = None): + self.desc = desc + self.input_file = input_file + self.args = args + self.text_output = text_output + self.restrict_game = restrict_game + + def run(self): + for game in self.games: + if self.restrict_game is not None and game != self.restrict_game: + print('{} {}: skipping'.format(game, self.desc)) + continue + output_files = [] + output_data = [] + for version in ['two', 'three']: + executable = self.executables[game][version] + suffix = self.suffix[version] + full_output = os.path.join( + self.output_dir, + '{}-{}{}'.format(game, self.desc, suffix), + ) + output_files.append(full_output) + commandline = [executable] + commandline.extend(self.args) + commandline.append('{}-{}'.format(game, self.input_file)) + commandline.append(full_output) + subprocess.run(commandline, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + if self.text_output: + with open(full_output, 'r') as df: + output_lines = [] + for line in df.readlines(): + output_lines.append(line.strip()) + output_data.append(''.join(output_lines)) + else: + with open(full_output, 'rb') as df: + output_data.append(df.read()) + except FileNotFoundError: + output_data.append('{} {} result does not exist'.format(game, executable)) + if output_data[0] != output_data[1]: + print('{} {}: files {} and {} do not match'.format(game, self.desc, output_files[0], output_files[1])) + else: + print('{} {}: good!'.format(game, self.desc)) + os.unlink(output_files[0]) + os.unlink(output_files[1]) + + +tests = [ + Test('noargs', 'toptier.sav', []), + Test('copy', 'toptier.sav', ['-o', 'savegame']), + Test('decoded', 'toptier.sav', ['-o', 'decoded']), + Test('decodedjson', 'toptier.sav', ['-o', 'decodedjson'], text_output=True), + Test('json', 'toptier.sav', ['-o', 'json'], text_output=True), + Test('items', 'toptier.sav', ['-o', 'items'], text_output=True), + Test('jsoninput', 'toptier.json', ['-j']), + Test('decodedjsoninput', 'toptier_decoded.json', ['-j']), + # Can't actually test this one, item IDs are randomly generated on insert. + # Manually made sure this works by hardcoding IDs, though. + # Test('itemimport', 'early.sav', ['-i', 'items.txt']), + Test('charname', 'early.sav', ['--name', 'Fred']), + Test('saveid', 'early.sav', ['--save-game-id', '42']), + Test('level', 'early.sav', ['--level', '42']), + Test('money', 'early.sav', ['--money', '42000']), + Test('eridium', 'early.sav', ['--eridium', '42'], restrict_game='bl2'), + Test('moonstone', 'early.sav', ['--moonstone', '42'], restrict_game='tps'), + Test('seraph', 'early.sav', ['--seraph', '42'], restrict_game='bl2'), + Test('torgue', 'early.sav', ['--torgue', '42'], restrict_game='bl2'), + Test('itemlevels', 'toptier.sav', ['--itemlevels', '2']), + Test('backpack', 'early.sav', ['--backpack', '30']), + Test('bank', 'early.sav', ['--bank', '20']), + Test('gunslots', 'early.sav', ['--gunslots', '4']), + Test('slaughter', 'early_noslaughterdome.sav', ['--unlock', 'slaughterdome'], restrict_game='bl2'), + Test('tvhm', 'early.sav', ['--unlock', 'tvhm']), + # This test can't be manually tested without adding in the same Challenge + # sorting on the py2 version which we now do in py3. (Not committing that + # on the py2 branch for Reasons.) + # Test('challenges', 'early.sav', ['--unlock', 'challenges']), + Test('ammo', 'early.sav', ['--unlock', 'ammo']), + Test('zerochallenge', 'early.sav', ['--challenges', 'zero']), + Test('maxchallenge', 'early.sav', ['--challenges', 'max']), + Test('bonuschallenge', 'early.sav', ['--challenges', 'bonus']), + Test('maxammo', 'early.sav', ['--maxammo']), +] + + +def main(): + # First clear out our output directory + print('Cleaning output directory') + for filename in os.listdir(Test.output_dir): + os.unlink(os.path.join(Test.output_dir, filename)) + + for test in tests: + test.run() + + +if __name__ == '__main__': + if True: + print('This was just a utility I used when porting to Python 3, to compare') + print('the outputs to the Python 2 version (to ensure that nothing was') + print('subtly broken. Really some unit tests would have been the long-term') + print('better way to go, but whatever. Anyway, not really for general use,') + print('though I figured I would commit it anyway, in case I think of') + print('something else that needs checking.') + sys.exit(0) + + # noinspection PyUnreachableCode + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8da97dc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,4 @@ +[tool.black] +include = '\.py$' +skip-string-normalization = true +line-length = 120 \ No newline at end of file diff --git a/savefile.py b/savefile.py deleted file mode 100755 index da9a7b9..0000000 --- a/savefile.py +++ /dev/null @@ -1,1138 +0,0 @@ -#! /usr/bin/env python - -import binascii -from bisect import insort -from cStringIO import StringIO -import hashlib -import json -import math -import optparse -import random -import struct -import sys - - -class BL2Error(Exception): pass - - -class ReadBitstream(object): - - def __init__(self, s): - self.s = s - self.i = 0 - - def read_bit(self): - i = self.i - self.i = i + 1 - byte = ord(self.s[i >> 3]) - bit = byte >> (7 - (i & 7)) - return bit & 1 - - def read_bits(self, n): - s = self.s - i = self.i - end = i + n - chunk = s[i >> 3: (end + 7) >> 3] - value = ord(chunk[0]) &~ (0xff00 >> (i & 7)) - for c in chunk[1: ]: - value = (value << 8) | ord(c) - if (end & 7) != 0: - value = value >> (8 - (end & 7)) - self.i = end - return value - - def read_byte(self): - i = self.i - self.i = i + 8 - byte = ord(self.s[i >> 3]) - if (i & 7) == 0: - return byte - byte = (byte << 8) | ord(self.s[(i >> 3) + 1]) - return (byte >> (8 - (i & 7))) & 0xff - -class WriteBitstream(object): - - def __init__(self): - self.s = "" - self.byte = 0 - self.i = 7 - - def write_bit(self, b): - i = self.i - byte = self.byte | (b << i) - if i == 0: - self.s += chr(byte) - self.byte = 0 - self.i = 7 - else: - self.byte = byte - self.i = i - 1 - - def write_bits(self, b, n): - s = self.s - byte = self.byte - i = self.i - while n >= (i + 1): - shift = n - (i + 1) - n = n - (i + 1) - byte = byte | (b >> shift) - b = b &~ (byte << shift) - s = s + chr(byte) - byte = 0 - i = 7 - if n > 0: - byte = byte | (b << (i + 1 - n)) - i = i - n - self.s = s - self.byte = byte - self.i = i - - def write_byte(self, b): - i = self.i - if i == 7: - self.s += chr(b) - else: - self.s += chr(self.byte | (b >> (7 - i))) - self.byte = (b << (i + 1)) & 0xff - - def getvalue(self): - if self.i != 7: - return self.s + chr(self.byte) - else: - return self.s - - -def read_huffman_tree(b): - node_type = b.read_bit() - if node_type == 0: - return (None, (read_huffman_tree(b), read_huffman_tree(b))) - else: - return (None, b.read_byte()) - -def write_huffman_tree(node, b): - if type(node[1]) is int: - b.write_bit(1) - b.write_byte(node[1]) - else: - b.write_bit(0) - write_huffman_tree(node[1][0], b) - write_huffman_tree(node[1][1], b) - -def make_huffman_tree(data): - frequencies = [0] * 256 - for c in data: - frequencies[ord(c)] += 1 - - nodes = [[f, i] for (i, f) in enumerate(frequencies) if f != 0] - nodes.sort() - - while len(nodes) > 1: - l, r = nodes[: 2] - nodes = nodes[2: ] - insort(nodes, [l[0] + r[0], [l, r]]) - - return nodes[0] - -def invert_tree(node, code=0, bits=0): - if type(node[1]) is int: - return {chr(node[1]): (code, bits)} - else: - d = {} - d.update(invert_tree(node[1][0], code << 1, bits + 1)) - d.update(invert_tree(node[1][1], (code << 1) | 1, bits + 1)) - return d - -def huffman_decompress(tree, bitstream, size): - output = "" - while len(output) < size: - node = tree - while 1: - b = bitstream.read_bit() - node = node[1][b] - if type(node[1]) is int: - output += chr(node[1]) - break - return output - -def huffman_compress(encoding, data, bitstream): - for c in data: - code, nbits = encoding[c] - bitstream.write_bits(code, nbits) - - -item_sizes = ( - (8, 17, 20, 11, 7, 7, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16), - (8, 13, 20, 11, 7, 7, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17) -) - -def pack_item_values(is_weapon, values): - i = 0 - bytes = [0] * 32 - for value, size in zip(values, item_sizes[is_weapon]): - if value is None: - break - j = i >> 3 - value = value << (i & 7) - while value != 0: - bytes[j] |= value & 0xff - value = value >> 8 - j = j + 1 - i = i + size - if (i & 7) != 0: - value = 0xff << (i & 7) - bytes[i >> 3] |= (value & 0xff) - return "".join(map(chr, bytes[: (i + 7) >> 3])) - -def unpack_item_values(is_weapon, data): - i = 8 - data = " " + data - values = [] - end = len(data) * 8 - for size in item_sizes[is_weapon]: - j = i + size - if j > end: - values.append(None) - continue - value = 0 - for b in data[j >> 3: (i >> 3) - 1: -1]: - value = (value << 8) | ord(b) - values.append((value >> (i & 7)) &~ (0xff << size)) - i = j - return values - -def rotate_data_right(data, steps): - steps = steps % len(data) - return data[-steps: ] + data[: -steps] - -def rotate_data_left(data, steps): - steps = steps % len(data) - return data[steps: ] + data[: steps] - -def xor_data(data, key): - key = key & 0xffffffff - output = "" - for c in data: - key = (key * 279470273) % 4294967291 - output += chr((ord(c) ^ key) & 0xff) - return output - -def wrap_item(is_weapon, values, key): - item = pack_item_values(is_weapon, values) - header = struct.pack(">Bi", (is_weapon << 7) | 7, key) - padding = "\xff" * (33 - len(item)) - h = binascii.crc32(header + "\xff\xff" + item + padding) & 0xffffffff - checksum = struct.pack(">H", ((h >> 16) ^ h) & 0xffff) - body = xor_data(rotate_data_left(checksum + item, key & 31), key >> 5) - return header + body - -def unwrap_item(data): - version_type, key = struct.unpack(">Bi", data[: 5]) - is_weapon = version_type >> 7 - raw = rotate_data_right(xor_data(data[5: ], key >> 5), key & 31) - return is_weapon, unpack_item_values(is_weapon, raw[2: ]), key - -def replace_raw_item_key(data, key): - old_key = struct.unpack(">i", data[1: 5])[0] - item = rotate_data_right(xor_data(data[5: ], old_key >> 5), old_key & 31)[2: ] - header = data[0] + struct.pack(">i", key) - padding = "\xff" * (33 - len(item)) - h = binascii.crc32(header + "\xff\xff" + item + padding) & 0xffffffff - checksum = struct.pack(">H", ((h >> 16) ^ h) & 0xffff) - body = xor_data(rotate_data_left(checksum + item, key & 31), key >> 5) - return header + body - - -def read_varint(f): - value = 0 - offset = 0 - while 1: - b = ord(f.read(1)) - value |= (b & 0x7f) << offset - if (b & 0x80) == 0: - break - offset = offset + 7 - return value - -def write_varint(f, i): - while i > 0x7f: - f.write(chr(0x80 | (i & 0x7f))) - i = i >> 7 - f.write(chr(i)) - -def read_protobuf(data): - fields = {} - end_position = len(data) - bytestream = StringIO(data) - while bytestream.tell() < end_position: - key = read_varint(bytestream) - field_number = key >> 3 - wire_type = key & 7 - value = read_protobuf_value(bytestream, wire_type) - fields.setdefault(field_number, []).append([wire_type, value]) - return fields - -def read_protobuf_value(b, wire_type): - if wire_type == 0: - value = read_varint(b) - elif wire_type == 1: - value = struct.unpack("> 1) - else: - return i >> 1 - - -def apply_structure(pbdata, s): - fields = {} - raw = {} - for k, data in pbdata.items(): - mapping = s.get(k) - if mapping is None: - raw[k] = data - continue - elif type(mapping) is str: - fields[mapping] = data[0][1] - continue - key, repeated, child_s = mapping - if child_s is None: - values = [d[1] for d in data] - fields[key] = values if repeated else values[0] - elif type(child_s) is int: - if repeated: - fields[key] = read_repeated_protobuf_value(data[0][1], child_s) - else: - fields[key] = data[0][1] - elif type(child_s) is tuple: - values = [child_s[0](d[1]) for d in data] - fields[key] = values if repeated else values[0] - elif type(child_s) is dict: - values = [apply_structure(read_protobuf(d[1]), child_s) for d in data] - fields[key] = values if repeated else values[0] - else: - raise Exception("Invalid mapping %r for %r: %r" % (mapping, k, data)) - if len(raw) != 0: - fields["_raw"] = {} - for k, values in raw.items(): - safe_values = [] - for (wire_type, v) in values: - if wire_type == 2: - v = [ord(c) for c in v] - safe_values.append([wire_type, v]) - fields["_raw"][k] = safe_values - return fields - -def remove_structure(data, inv): - pbdata = {} - pbdata.update(data.get("_raw", {})) - for k, value in data.items(): - if k == "_raw": - continue - mapping = inv.get(k) - if mapping is None: - raise BL2Error("Unknown key %r in data" % (k, )) - elif type(mapping) is int: - pbdata[mapping] = [[guess_wire_type(value), value]] - continue - key, repeated, child_inv = mapping - if child_inv is None: - value = [value] if not repeated else value - pbdata[key] = [[guess_wire_type(v), v] for v in value] - elif type(child_inv) is int: - if repeated: - b = StringIO() - for v in value: - write_protobuf_value(b, child_inv, v) - pbdata[key] = [[2, b.getvalue()]] - else: - pbdata[key] = [[child_inv, value]] - elif type(child_inv) is tuple: - value = [value] if not repeated else value - values = [] - for v in map(child_inv[1], value): - if type(v) is list: - values.append(v) - else: - values.append([guess_wire_type(v), v]) - pbdata[key] = values - elif type(child_inv) is dict: - value = [value] if not repeated else value - values = [] - for d in [remove_structure(v, child_inv) for v in value]: - values.append([2, write_protobuf(d)]) - pbdata[key] = values - else: - raise Exception("Invalid mapping %r for %r: %r" % (mapping, k, value)) - return pbdata - -def guess_wire_type(value): - return 2 if isinstance(value, basestring) else 0 - -def invert_structure(structure): - inv = {} - for k, v in structure.items(): - if type(v) is tuple: - if type(v[2]) is dict: - inv[v[0]] = (k, v[1], invert_structure(v[2])) - else: - inv[v[0]] = (k, ) + v[1: ] - else: - inv[v] = k - return inv - -def unwrap_bytes(value): - return [ord(d) for d in value] - -def wrap_bytes(value): - return "".join(map(chr, value)) - -def unwrap_float(v): - return struct.unpack("> bits - asset = item[1 + i] &~ (lib << bits) - data[k] = {"lib": lib, "asset": asset} - bits = 10 + is_weapon - parts = [] - for value in item[6: ]: - if value is None: - parts.append(None) - else: - lib = value >> bits - asset = value &~ (lib << bits) - parts.append({"lib": lib, "asset": asset}) - data["parts"] = parts - return data - -def wrap_item_info(value): - item = [value["set"]] - for key, bits in item_header_sizes[value["is_weapon"]]: - v = value[key] - item.append((v["lib"] << bits) | v["asset"]) - item.extend(value["level"]) - bits = 10 + value["is_weapon"] - for v in value["parts"]: - if v is None: - item.append(None) - else: - item.append((v["lib"] << bits) | v["asset"]) - return wrap_item(value["is_weapon"], item, value["key"]) - -save_structure = { - 1: "class", - 2: "level", - 3: "experience", - 4: "skill_points", - 6: ("currency", True, 0), - 7: "playthroughs_completed", - 8: ("skills", True, { - 1: "name", - 2: "level", - 3: "unknown3", - 4: "unknown4" - }), - 11: ("resources", True, { - 1: "resource", - 2: "pool", - 3: ("amount", False, (unwrap_float, wrap_float)), - 4: "level" - }), - 13: ("sizes", False, { - 1: "inventory", - 2: "weapon_slots", - 3: "weapon_slots_shown" - }), - 15: ("stats", False, (unwrap_bytes, wrap_bytes)), - 16: ("active_fast_travel", True, None), - 17: "last_fast_travel", - 18: ("missions", True, { - 1: "playthrough", - 2: "active", - 3: ("data", True, { - 1: "name", - 2: "status", - 3: "is_from_dlc", - 4: "dlc_id", - 5: ("unknown5", False, (unwrap_bytes, wrap_bytes)), - 6: "unknown6", - 7: ("unknown7", False, (unwrap_bytes, wrap_bytes)), - 8: "unknown8", - 9: "unknown9", - 10: "unknown10", - 11: "level", - }), - }), - 19: ("appearance", False, { - 1: "name", - 2: ("color1", False, {1: "a", 2: "r", 3: "g", 4: "b"}), - 3: ("color2", False, {1: "a", 2: "r", 3: "g", 4: "b"}), - 4: ("color3", False, {1: "a", 2: "r", 3: "g", 4: "b"}), - }), - 20: "save_game_id", - 21: "mission_number", - 23: ("unlocks", False, (unwrap_bytes, wrap_bytes)), - 24: ("unlock_notifications", False, (unwrap_bytes, wrap_bytes)), - 25: "time_played", - 26: "save_timestamp", - 29: ("game_stages", True, { - 1: "name", - 2: "level", - 3: "is_from_dlc", - 4: "dlc_id", - 5: "playthrough", - }), - 30: ("areas", True, { - 1: "name", - 2: "unknown2" - }), - 34: ("id", False, { - 1: ("a", False, 5), - 2: ("b", False, 5), - 3: ("c", False, 5), - 4: ("d", False, 5), - }), - 35: ("wearing", True, None), - 36: ("black_market", False, (unwrap_black_market, wrap_black_market)), - 37: "active_mission", - 38: ("challenges", True, { - 1: "name", - 2: "is_from_dlc", - 3: "dlc_id" - }), - 41: ("bank", True, { - 1: ("data", False, (unwrap_item_info, wrap_item_info)), - }), - 43: ("lockouts", True, { - 1: "name", - 2: "time", - 3: "is_from_dlc", - 4: "dlc_id" - }), - 46: ("explored_areas", True, None), - 49: "active_playthrough", - 53: ("items", True, { - 1: ("data", False, (unwrap_item_info, wrap_item_info)), - 2: "unknown2", - 3: "is_equipped", - 4: "star" - }), - 54: ("weapons", True, { - 1: ("data", False, (unwrap_item_info, wrap_item_info)), - 2: "slot", - 3: "star", - 4: "unknown4", - }), - 55: "stats_bonuses_disabled", - 56: "bank_size", -} - - -def unwrap_player_data(data): - if data[: 4] == "CON ": - raise BL2Error("You need to use a program like Horizon or Modio to extract the SaveGame.sav file first") - - if data[: 20] != hashlib.sha1(data[20: ]).digest(): - raise BL2Error("Invalid save file") - - data = lzo1x_decompress("\xf0" + data[20: ]) - size, wsg, version = struct.unpack(">I3sI", data[: 11]) - if version != 2 and version != 0x02000000: - raise BL2Error("Unknown save version " + str(version)) - - if version == 2: - crc, size = struct.unpack(">II", data[11: 19]) - else: - crc, size = struct.unpack("I3s", len(data) + 15, "WSG") - if endian == 1: - header = header + struct.pack(">III", 2, crc, len(player)) - else: - header = header + struct.pack(" 17: - t = t - 17 - dst.extend(src[ip: ip + t]); ip += t - t = src[ip]; ip += 1 - elif t < 16: - if t == 0: - t, ip = expand_zeroes(src, ip, 15) - dst.extend(src[ip: ip + t + 3]); ip += t + 3 - t = src[ip]; ip += 1 - - while 1: - while 1: - if t >= 64: - copy_earlier(dst, 1 + ((t >> 2) & 7) + (src[ip] << 3), (t >> 5) + 1); ip += 1 - elif t >= 32: - count = t & 31 - if count == 0: - count, ip = expand_zeroes(src, ip, 31) - t = src[ip] - copy_earlier(dst, 1 + ((t | (src[ip + 1] << 8)) >> 2), count + 2); ip += 2 - elif t >= 16: - offset = (t & 8) << 11 - count = t & 7 - if count == 0: - count, ip = expand_zeroes(src, ip, 7) - t = src[ip] - offset += (t | (src[ip + 1] << 8)) >> 2; ip += 2 - if offset == 0: - return str(dst) - copy_earlier(dst, offset + 0x4000, count + 2) - else: - copy_earlier(dst, 1 + (t >> 2) + (src[ip] << 2), 2); ip += 1 - - t = t & 3 - if t == 0: - break - dst.extend(src[ip: ip + t]); ip += t - t = src[ip]; ip += 1 - - while 1: - t = src[ip]; ip += 1 - if t < 16: - if t == 0: - t, ip = expand_zeroes(src, ip, 15) - dst.extend(src[ip: ip + t + 3]); ip += t + 3 - t = src[ip]; ip += 1 - if t < 16: - copy_earlier(dst, 1 + 0x0800 + (t >> 2) + (src[ip] << 2), 3); ip += 1 - t = t & 3 - if t == 0: - continue - dst.extend(src[ip: ip + t]); ip += t - t = src[ip]; ip += 1 - break - - -def read_xor32(src, p1, p2): - v1 = src[p1] | (src[p1 + 1] << 8) | (src[p1 + 2] << 16) | (src[p1 + 3] << 24) - v2 = src[p2] | (src[p2 + 1] << 8) | (src[p2 + 2] << 16) | (src[p2 + 3] << 24) - return v1 ^ v2 - -clz_table = ( - 32, 0, 1, 26, 2, 23, 27, 0, 3, 16, 24, 30, 28, 11, 0, 13, 4, - 7, 17, 0, 25, 22, 31, 15, 29, 10, 12, 6, 0, 21, 14, 9, 5, - 20, 8, 19, 18 -) - -def lzo1x_1_compress_core(src, dst, ti, ip_start, ip_len): - dict_entries = [0] * 16384 - - in_end = ip_start + ip_len - ip_end = ip_start + ip_len - 20 - - ip = ip_start - ii = ip_start - - ip += (4 - ti) if ti < 4 else 0 - ip += 1 + ((ip - ii) >> 5) - while 1: - while 1: - if ip >= ip_end: - return in_end - (ii - ti) - dv = src[ip: ip + 4] - dindex = dv[0] | (dv[1] << 8) | (dv[2] << 16) | (dv[3] << 24) - dindex = ((0x1824429d * dindex) >> 18) & 0x3fff - m_pos = ip_start + dict_entries[dindex] - dict_entries[dindex] = (ip - ip_start) & 0xffff - if dv == src[m_pos: m_pos + 4]: - break - ip += 1 + ((ip - ii) >> 5) - - ii -= ti; ti = 0 - t = ip - ii - if t != 0: - if t <= 3: - dst[-2] |= t - dst.extend(src[ii: ii + t]) - elif t <= 16: - dst.append(t - 3) - dst.extend(src[ii: ii + t]) - else: - if t <= 18: - dst.append(t - 3) - else: - tt = t - 18 - dst.append(0) - n, tt = divmod(tt, 255) - dst.extend("\x00" * n) - dst.append(tt) - dst.extend(src[ii: ii + t]) - ii += t - - m_len = 4 - v = read_xor32(src, ip + m_len, m_pos + m_len) - if v == 0: - while 1: - m_len += 4 - v = read_xor32(src, ip + m_len, m_pos + m_len) - if ip + m_len >= ip_end: - break - elif v != 0: - m_len += clz_table[(v & -v) % 37] >> 3 - break - else: - m_len += clz_table[(v & -v) % 37] >> 3 - - m_off = ip - m_pos - ip += m_len - ii = ip - if m_len <= 8 and m_off <= 0x0800: - m_off -= 1 - dst.append(((m_len - 1) << 5) | ((m_off & 7) << 2)) - dst.append(m_off >> 3) - elif m_off <= 0x4000: - m_off -= 1 - if m_len <= 33: - dst.append(32 | (m_len - 2)) - else: - m_len -= 33 - dst.append(32) - n, m_len = divmod(m_len, 255) - dst.extend("\x00" * n) - dst.append(m_len) - dst.append((m_off << 2) & 0xff) - dst.append((m_off >> 6) & 0xff) - else: - m_off -= 0x4000 - if m_len <= 9: - dst.append(0xff & (16 | ((m_off >> 11) & 8) | (m_len - 2))) - else: - m_len -= 9 - dst.append(0xff & (16 | ((m_off >> 11) & 8))) - n, m_len = divmod(m_len, 255) - dst.extend("\x00" * n) - dst.append(m_len) - dst.append((m_off << 2) & 0xff) - dst.append((m_off >> 6) & 0xff) - -def lzo1x_1_compress(s): - src = bytearray(s) - dst = bytearray() - - ip = 0 - l = len(s) - t = 0 - - dst.append(240) - dst.append((l >> 24) & 0xff) - dst.append((l >> 16) & 0xff) - dst.append((l >> 8) & 0xff) - dst.append( l & 0xff) - - while l > 20 and t + l > 31: - ll = min(49152, l) - t = lzo1x_1_compress_core(src, dst, t, ip, ll) - ip += ll - l -= ll - t += l - - if t > 0: - ii = len(s) - t - - if len(dst) == 5 and t <= 238: - dst.append(17 + t) - elif t <= 3: - dst[-2] |= t - elif t <= 18: - dst.append(t - 3) - else: - tt = t - 18 - dst.append(0) - n, tt = divmod(tt, 255) - dst.extend("\x00" * n) - dst.append(tt) - dst.extend(src[ii: ii + t]) - - dst.append(16 | 1) - dst.append(0) - dst.append(0) - - return str(dst) - - -def modify_save(data, changes, endian=1): - player = read_protobuf(unwrap_player_data(data)) - - if changes.has_key("level"): - level = int(changes["level"]) - lower = int(60 * (level ** 2.8) - 59.2) - upper = int(60 * ((level + 1) ** 2.8) - 59.2) - if player[3][0][1] not in range(lower, upper): - player[3][0][1] = lower - player[2] = [[0, int(changes["level"])]] - - if changes.has_key("skillpoints"): - player[4] = [[0, int(changes["skillpoints"])]] - - if any(map(changes.has_key, ("money", "eridium", "seraph", "tokens"))): - raw = player[6][0][1] - b = StringIO(raw) - values = [] - while b.tell() < len(raw): - values.append(read_protobuf_value(b, 0)) - if changes.has_key("money"): - values[0] = int(changes["money"]) - if changes.has_key("eridium"): - values[1] = int(changes["eridium"]) - if changes.has_key("seraph"): - values[2] = int(changes["seraph"]) - if changes.has_key("tokens"): - values[4] = int(changes["tokens"]) - player[6][0] = [0, values] - - if changes.has_key("itemlevels"): - if changes["itemlevels"]: - level = int(changes["itemlevels"]) - else: - level = player[2][0][1] - for field_number in (53, 54): - for field in player[field_number]: - field_data = read_protobuf(field[1]) - is_weapon, item, key = unwrap_item(field_data[1][0][1]) - if item[4] > 1: - item = item[: 4] + [level, level] + item[6: ] - field_data[1][0][1] = wrap_item(is_weapon, item, key) - field[1] = write_protobuf(field_data) - - if changes.has_key("backpack"): - size = int(changes["backpack"]) - sdus = int(math.ceil((size - 12) / 3.0)) - size = 12 + (sdus * 3) - slots = read_protobuf(player[13][0][1]) - slots[1][0][1] = size - player[13][0][1] = write_protobuf(slots) - s = read_repeated_protobuf_value(player[36][0][1], 0) - player[36][0][1] = write_repeated_protobuf_value(s[: 7] + [sdus] + s[8: ], 0) - - if changes.has_key("bank"): - size = int(changes["bank"]) - sdus = int(min(255, math.ceil((size - 6) / 2.0))) - size = 6 + (sdus * 2) - if player.has_key(56): - player[56][0][1] = size - else: - player[56] = [[0, size]] - s = read_repeated_protobuf_value(player[36][0][1], 0) - if len(s) < 9: - s = s + (9 - len(s)) * [0] - player[36][0][1] = write_repeated_protobuf_value(s[: 8] + [sdus] + s[9: ], 0) - - if changes.get("gunslots", "0") in "234": - n = int(changes["gunslots"]) - slots = read_protobuf(player[13][0][1]) - slots[2][0][1] = n - if slots[3][0][1] > n - 2: - slots[3][0][1] = n - 2 - player[13][0][1] = write_protobuf(slots) - - if changes.has_key("unlocks"): - unlocked, notifications = [], [] - if player.has_key(23): - unlocked = map(ord, player[23][0][1]) - if player.has_key(24): - notifications = map(ord, player[24][0][1]) - unlocks = changes["unlocks"].split(":") - if "slaughterdome" in unlocks: - if 1 not in unlocked: - unlocked.append(1) - if 1 not in notifications: - notifications.append(1) - if unlocked: - player[23] = [[2, "".join(map(chr, unlocked))]] - if notifications: - player[24] = [[2, "".join(map(chr, notifications))]] - if "truevaulthunter" in unlocks: - if player[7][0][1] < 1: - player[7][0][1] = 1 - - return wrap_player_data(write_protobuf(player), endian) - -def export_items(data, output): - player = read_protobuf(unwrap_player_data(data)) - for i, name in ((41, "Bank"), (53, "Items"), (54, "Weapons")): - content = player.get(i) - if content is None: - continue - print >>output, "; " + name - for field in content: - raw = read_protobuf(field[1])[1][0][1] - raw = replace_raw_item_key(raw, 0) - code = "BL2(" + raw.encode("base64").strip() + ")" - print >>output, code - -def import_items(data, codelist, endian=1): - player = read_protobuf(unwrap_player_data(data)) - - to_bank = False - for line in codelist.splitlines(): - line = line.strip() - if line.startswith(";"): - name = line[1: ].strip().lower() - if name == "bank": - to_bank = True - elif name in ("items", "weapons"): - to_bank = False - continue - elif line[: 4] + line[-1: ] != "BL2()": - continue - - code = line[4: -1] - try: - raw = code.decode("base64") - except binascii.Error: - continue - - key = random.randrange(0x100000000) - 0x80000000 - raw = replace_raw_item_key(raw, key) - if to_bank: - field = 41 - entry = {1: [[2, raw]]} - elif (ord(raw[0]) & 0x80) == 0: - field = 53 - entry = {1: [[2, raw]], 2: [[0, 1]], 3: [[0, 0]], 4: [[0, 1]]} - else: - field = 53 - entry = {1: [[2, raw]], 2: [[0, 0]], 3: [[0, 1]]} - - player.setdefault(field, []).append([2, write_protobuf(entry)]) - - return wrap_player_data(write_protobuf(player), endian) - - -def parse_args(): - usage = "usage: %prog [options] [source file] [destination file]" - p = optparse.OptionParser() - p.add_option( - "-d", "--decode", - action="store_true", - help="read from a save game, rather than creating one" - ) - p.add_option( - "-e", "--export-items", metavar="FILENAME", - help="save out codes for all bank and inventory items" - ) - p.add_option( - "-i", "--import-items", metavar="FILENAME", - help="read in codes for items and add them to the bank and inventory" - ) - p.add_option( - "-j", "--json", - action="store_true", - help="read or write save game data in JSON format, rather than raw protobufs" - ) - p.add_option( - "-l", "--little-endian", - action="store_true", - help="change the output format to little endian, to write PC-compatible save files" - ) - p.add_option( - "-m", "--modify", metavar="MODIFICATIONS", - help="comma separated list of modifications to make, eg money=99999999,eridium=99" - ) - p.add_option( - "-p", "--parse", - action="store_true", - help="parse the protocol buffer data further and generate more readable JSON" - ) - return p.parse_args() - -def main(options, args): - if len(args) >= 2 and args[0] != "-" and args[0] == args[1]: - print >>sys.stderr, "Cannot overwrite the save file, please use a different filename for the new save" - return - - if len(args) < 1 or args[0] == "-": - input = sys.stdin - else: - input = open(args[0], "rb") - - if len(args) < 2 or args[1] == "-": - output = sys.stdout - else: - output = open(args[1], "wb") - - if options.little_endian: - endian = 0 - else: - endian = 1 - - if options.modify is not None: - changes = {} - if options.modify: - for m in options.modify.split(","): - k, v = (m.split("=", 1) + [None])[: 2] - changes[k] = v - output.write(modify_save(input.read(), changes, endian)) - elif options.export_items: - output = open(options.export_items, "w") - export_items(input.read(), output) - elif options.import_items: - itemlist = open(options.import_items, "r") - output.write(import_items(input.read(), itemlist.read(), endian)) - elif options.decode: - savegame = input.read() - player = unwrap_player_data(savegame) - if options.json: - data = read_protobuf(player) - if options.parse: - data = apply_structure(data, save_structure) - player = json.dumps(data, encoding="latin1", sort_keys=True, indent=4) - output.write(player) - else: - player = input.read() - if options.json: - data = json.loads(player, encoding="latin1") - if not data.has_key("1"): - data = remove_structure(data, invert_structure(save_structure)) - player = write_protobuf(data) - savegame = wrap_player_data(player, endian) - output.write(savegame) - -if __name__ == "__main__": - options, args = parse_args() - try: - main(options, args) - except: - print >>sys.stderr, ( - "Something went wrong, but please ensure you have the latest " - "version from https://github.com/pclifford/borderlands2 before " - "reporting a bug. Information useful for a report follows:" - ) - print >>sys.stderr, repr(sys.argv) - raise diff --git a/tps_save_edit.py b/tps_save_edit.py new file mode 100755 index 0000000..5fe23e7 --- /dev/null +++ b/tps_save_edit.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python3 +import sys + +from borderlands.base_save_edit import run + +if __name__ == '__main__': + run(game_name='TPS', args=sys.argv[1:])