Tools for manipulating save files of the mobile game 'the blockheads'
LMDB fails if you are trying to use 64-bit python to load a 32-bit save file and vise versa. You will see something like this:
MDB_INVALID: File is not an LMDB file
To avoid this issue, we have provided a Dockerfile
with i386 python 3.12 in it. You can easily set them up by executing the following commands:
$ docker build -t bh_tool_env:0.0.1 .
$ docker run -v $(pwd)/test_data:/app/test_data -it bh_tool_env:0.0.1
In case you need to debug a script, you can uncomment these lines in the Dockerfile
:
RUN pip install 'git+https://github.com/bretello/pdbpp@master'
RUN pip install pytest
CMD ["python3", "-m", "pdb", "your_script.py"]
These would provide you access to CLI debuggers.
During development I used test_data
as the folder to store all save files. You may find more save files in ./test_data/saves/
.
If you just want to see what the tool could do, please copy test_data/out/
into your game folder. (It's not showcase though)
In case your game save is 64 bit lmdb, please modify the first line of Dockerfile
to this:
FROM --platform=linux/amd64 python:alpine3.19
>>> from gameSave import GameSave
>>> gs = GameSave.load("./test_data/saves/c8185...a9229/")
For example, if you want to change block at 12384, 372:
>>> b = gs.get_block(12384, 372)
And you would like to change it to time crystal:
>>> from blockType import BlockType
>>> b.set("first_layer_id", BlockType.TIME_CRYSTAL)
First, get the chunk you want to change:
>>> info = gs.get_info()
>>> start_chunk_pos = [_ >> 5 for _ in info["start_portal_pos"]]
>>> start_chunk_pos[1] += 1
>>> c = gs.get_chunk(*start_chunk_pos)
Then modify blocks in it!
>>> for x in range(32):
... for y in range(32):
... b = c.get_block(x, y)
... b.set("first_layer_id", BlockType.LUMINOUS_PLASTER)
The code above would set every block in that chunk to luminous plaster.
>>> gs.save("./test_data/saves/out/")
>>> bh = gs.get_blockheads()
>>> inv = gs.get_inventory(bh[0])
>>> print(inv)
[
0: 'item 1' * 1
1: 'item 12' * 1
2: 'item 12' * 1
3: ['item 12': {'s': [[], [], [], 'item 1049' * 28]}]
4: ['item 1043': {'d': {'pos_x': 14914, 'pos_y': 537, 'chestType': 0, 'flipped': False, 'interactionObjectType': 2, 'saveItemSlots': [['item 12': {'s': [[], 'item 4' * 3, 'item 3' * 3, 'item 12'
* 1]}], 'item 6' * 1, 'item 3' * 1, 'item 12' * 1, 'item 53' * 9, ['item 12': {'s': [[], [], 'item 25' * 1, 'item 6' * 1]}], 'item 16' * 1, 'item 1' * 1, 'item 2' * 1, 'item 4' * 1, 'item 5' * 1, 'item 0' *
1, [], [], [], 'item 12' * 1], 'uniqueID': 3523, 'floatPos': [14914.5, 537.0], 'ownerID': 'server', 'paintColor': 0, 'saveTime': 3463.5894579589367, 'isInUse': False}}]
5: empty
6: empty
7: empty
]
The result looks scary, because there are containers that are inside another container.
The basic information of a blockhead and its corresponding inventory are splitted, and their LCA is world_db.main
, so you have to call GameSave.get_inventory(Blockhead)
to get inventory.
It is possible to implement bh[0].get_inventory()
by passing the reference of GameSave._data["world_db"]["main"]
to the Blockhead
object, but I don't think it is that worthy.
>>> inv[1].set_id(1049) # wood
>>> inv[1].set_count(1919)
>>> print(inv[1])
'item 1049' * 1919
Note that it is possible to set the count over 99, and the game will not crash.
If inv[1]
is a basket, and you want to get the first item:
>>> item = inv[1].get(0)
If inv[3]
is a chest, and you want to get the first item in the second row:
>>> item = inv[3].get(1, 0)
The above get
method is a shortcut. In fact, getting item from containers is hard, since the amount of item in the blockheads is not stored in bytes, but stored in a list.
For example, if there are 3 dirts stacked in one slot, that slot would look like this:
['\x18\x04\x00\x00\x00\x00\x00\x0c', '\x18\x04\x00\x00\x00\x00\x00\x00',
'\x18\x04\x00\x00\x00\x00\x00\x00']
When several tools or containers are stacked, where each item's damage or container information is different, this kind of storage is necessary.
But this makes getting items from containers obfuscating. If you want to get the first item in a basket, you have to use command like this:
item = inv[basket_index][0]['s'][-1][0]
Here, the inv[basket_index][0]
means the first item in inv[basket_index]
. If there are several baskets stacked in this slot, then you can use inv[basket_index][i]
to get i-th basket.
The basket is described by a dictionary, where the key s
in it stores a list of base64-encoded items. The equivalent key in chest is saveItemSlot
. So we have to use ['s']
to get the storage part in the basket, or ['saveItemSlot']
in the chest.
Though we are getting the first item, however, the storage order is reversed. Therefore, you have to use [-1]
to get the first item list in the basket. Finally, [0]
returns the first item in that list.
>>> item = inv[6].get(0)
>>> item.set_id(ItemType.GOLDEN_BED)
This would change the first item in the 7-th basket in inventory to a golden bed.
>>> item.set_count(893)
This would change the amount of that item to 893. In game you would see number 893
in that slot.
>>> item.set_damage(randint(0, 16383))
You can change the damage value of a tool. If you set it to 0
, the tool will be repaired. If you set it to 16383
, then the next time you use it, it will be instantly destroyed.
Note that it is possible to set value over 16383, and the game will not crash.
Paint, cloth, and bed can be dyed, and you can easily change their color (not in RGB!):
>>> item.set_color(1, 1, 2)
The call above would set the color of that item to white + white + black.
You shall pass 1 ~ 3 parameters.
>>> item1.set_color(2)
>>> item2.set_color(2, 5)
>>> item3.set_color(2, 5, 8)
Here's the table between numbers and colors:
Color | Number |
---|---|
transparent | 0 |
marble white | 1 |
carbon black | 2 |
red ochre | 3 |
indian yellow | 4 |
ultramarine blue | 5 |
emerald green | 6 |
tyrian purple | 7 |
copper blue | 8 |
An empty basket would not contain extra information. In order to store things in it, you have to initialize that basket first:
>>> inv[1].init_extra(ItemExtra.BASKET)
The parameter is a dictionary, looks like:
{
"s": [[], [], [], []]
}
Since preparing such dictionaries is annoying, so I put them into a enumerate class ItemExtra
. However, I may change this usage in the future, since this is so hard to use.
>>> inv[1].remove_extra()