Overhaul everything

This commit is contained in:
random-geek 2020-06-26 16:33:17 -07:00
parent 3796be4ecb
commit 2e1c7455e5
7 changed files with 1622 additions and 752 deletions

187
README.md
View File

@ -6,87 +6,194 @@ Map database editor for Minetest
MapEdit is a command-line tool written in Python for relatively fast manipulation of Minetest map database files. Functionally, it is similar to WorldEdit, but it is designed for handling very large tasks which would be unfeasible for doing with WorldEdit. MapEdit is a command-line tool written in Python for relatively fast manipulation of Minetest map database files. Functionally, it is similar to WorldEdit, but it is designed for handling very large tasks which would be unfeasible for doing with WorldEdit.
The tool is currently in the beta stage; it is not complete and likely contains bugs. Use it at your own risk. MapEdit is currently in the beta stage, and like any code, it may have bugs. Use it at your own risk.
## Requirements ## Requirements
MapEdit requires Python 3. All other required packages should already be bundled with Python. Only sqlite database files are supported at the moment, but support for more formats may be added in the future. - Python 3 (If you don't already have it, download it from [python.org](https://www.python.org).)
- NumPy, which can be installed with `pip install numpy`.
## Usage ## Usage
**A note about mapblocks** #### About mapblocks
MapEdit's area selection only operates on whole mapblocks. A single mapblock is a 16x16x16 node area of the map, similar to Minecraft's chunks. The lower southwestern corner of a mapblock is always at coordinates which are evenly divisible by 16, e.g. (32, 64, -48) or the like. Minetest stores and transfers map data in *mapblocks*, which are similar to Minecraft's *chunks*. A single mapblock is a cubical, 16x16x16 node area of the map. The lower southwestern corner (-X, -Y, -Z) of a mapblock is always at coordinates divisible by 16, e.g. (0, 16, -48) or the like.
**A note about parameters** Mapblocks are stored in a *map database*, usually `map.sqlite`.
Most commands require mapblocks to be already generated to work. This can be acheived by either exploring the area in-game, or by using Minetest's built-in `/emergeblocks` command.
All string-like parameters can safely be surrounded with quotes if they happen to contain spaces. #### General usage
**General usage** `python mapedit.py [-h] -f <file> [--no-warnings] <command>`
`python mapedit.py [-h] -f <file> [-s <file>] [--p1 x y z] [--p2 x y z] [--inverse] [--silencewarnings] <command>` #### Arguments
**Parameters** - **`-h`**: Show a help message and exit.
- **`-f <file>`**: Path to primary map file. This should be the `map.sqlite` file in the world directory. Note that only SQLite databases are currently supported. This file will be modified, so *always* shut down the game/server before executing the command.
- **`--no-warnings`**: Don't show safety warnings or confirmation prompts. For those who feel brave.
- **`<command>`**: Command to execute. See "Commands" section below.
**`-h`**: Show a help message and exit. #### Common command arguments
**`-f <file>`**: Path to primary map file. This should be the `map.sqlite` file in the world dircetory. This file will be modified, so *always* shut down the game/server before executing the command. - **`--p1, --p2`**: Used to select an area with corners at `p1` and `p2`, similar to how WorldEdit's area selection works. It doesn't matter what what sides of the area p1 and p2 are on, as long as they are opposite each other.
- **Node/item names**: includes `searchnode`, `replacenode`, etc. Must be the full name, e.g. "default:stone", not just "stone".
**`-s <file>`**: Path to secondary map file. This is used by the `overlayblocks` command. #### Other tips
**`--p1 x y z --p2 x y z`**: This selects an area with corners at `p1` and `p2`, similar to how WorldEdit's area selection works. Only mapblocks which are fully contained within the area will be selected. Currently, this only applies to the cloneblocks, deleteblocks, fillblocks, and overlayblocks commands. String-like arguments can be surrounded with quotes if they contain spaces.
**`--inverse`**: Invert the selection. All mapblocks will be selected except those *fully* within the selected area. MapEdit will often leave lighting glitches. To fix these, use Minetest's built-in `/fixlight` command, or the equivalent WorldEdit `//fixlight` command.
**`--silencewarnings`**: Silence all safety warnings.
**`<command>`**: Command to execute.
## Commands ## Commands
**`cloneblocks --offset x y z`** ### `clone`
Clones (copies) the given area and moves it by `offset`. The new cloned mapblocks will replace any mapblocks which already existed in that area. Note: the value of `offset` is *rounded down* to the nearest whole number of mapblocks. **Usage:** `clone --p1 x y z --p2 x y z --offset x y z [--blockmode]`
**`deleteblocks`** Clone (copy) the given area to a new location. By default, nothing will be copied into mapblocks that are not yet generated.
Deletes all mapblocks within the given area. Note: Deleting mapblocks is *not* the same as replacing them with air. Mapgen will be invoked where the blocks were deleted, and this sometimes causes terrain glitches. Arguments:
**`fillblocks <name>`** - **`--p1, --p2`**: Area to copy from.
- **`--offset`**: Offset to shift the area by. For example, to copy an area 50 nodes upward (positive Y direction), use `--offset 0 50 0`.
- **`--blockmode`**: If present, only blocks *fully* inside the area will be cloned, and `offset` will be rounded to the nearest multiple of 16. In this mode, mapblocks may also be copied into non-generated areas. May be signifigantly faster for large areas.
Fills all mapblocks within the given area with node `name`, similar to WorldEdit's `set` command. Currently, fillblocks only operates on existing mapblocks and does not actually generate new ones. It also usually causes lighting glitches. ### `overlay`
**`overlayblocks`** **Usage:** `overlay [--p1 x y z] [--p2 x y z] [--invert] [--offset x y z] [--blockmode] <input_file>`
Selects all mapblocks within the given area in the secondary map file, and copies them to the same location in the primary map file. The cloned mapblocks will replace existing ones. Copy part or all of an input map file into the primary file. By default, nothing will be copied into mapblocks that are not yet generated.
**`replacenodes <searchname> <replacename>`** Arguments:
Replaces all nodes of name `searchname` with node `replacename`, without affecting lighting, param2, metadata, or node timers. To delete the node entirely, use `air` as the replace name. This can take a long time for large map files or very common nodes, e.g. dirt. - **`input_file`**: Path to input map file.
- **`--p1, --p2`**: Area to copy from. If not specified, MapEdit will try to copy everything from the input map file.
- **`--invert`**: If present, copy everything *outside* the given area.
- **`--offset`**: Offset to move nodes by when copying; default is no offset. This currently cannot be used with an inverted selection.
- **`--blockmode`**: If present, copy whole mapblocks instead of node regions. Only blocks fully inside or fully outside the given area will be copied, depending on whether `--invert` is used. In addition, `offset` will be rounded to the nearest multiple of 16. May be signifigantly faster for large areas.
**`setparam2 <searchname> <value>`** ### `deleteblocks`
Set the param2 value of all nodes with name `searchname` to `value`. **Usage:** `deleteblocks --p1 x y z --p2 x y z [--invert]`
**`deletemeta <searchname>`** Deletes all mapblocks in the given area.
Delete all metadata of nodes with name `searchname`. This includes node inventories as well. **Note:** Deleting mapblocks is *not* the same as filling them with air! Mapgen will be invoked where the blocks were deleted, and this sometimes causes terrain glitches.
**`setmetavar <searchname> <key> <value>`** Arguments:
Set the metadata variable `key` to `value` of all nodes with name `searchname`. This only affects nodes which already have the given variable in their metadata. - **`--p1, --p2`**: Area to delete from. Only mapblocks fully inside this area will be deleted.
- **`--invert`**: Delete only mapblocks that are fully *outside* the given area.
**`replaceininv <searchname> <searchitem> <replaceitem> [--deletemeta]`** ### `fill`
Replaces all items with name `searchitem` with item `replaceitem` in the inventories of nodes with name `searchname`. To delete an item entirely, *do not* replace it with air—instead, use the keyword `Empty` (capitalized). Include the `--deletemeta` flag to delete the item's metadata when replacing it. **Usage:** `fill --p1 x y z --p2 x y z [--invert] [--blockmode] <replacenode>`
**`deletetimers <searchname>`** Fills the given area with one node. The affected mapblocks must be already generated for fill to work.
This command does not currently affect param2, node metadata, etc.
Delete all node timers of nodes with name `searchname`. Arguments:
**`deleteobjects [--item] <searchname>`** - **`replacenode`**: Name of node to fill the area with.
- **`--p1, --p2`**: Area to fill.
- **`--invert`**: Fill everything *outside* the given area.
- **`--blockmode`**: Fill whole mapblocks instead of node regions. Only mapblocks fully inside the region (or fully outside, if `--invert` is used) will be filled. This option currenly has little effect.
Delete all objects (entities) with name `searchname`. To delete dropped items of a specific name, use `--item` followed by the name of the item. To delete *all* dropped items, exclude the `--item` flag and instead use the keyword `__builtin:item` (with two underscores) as the search name. ### `replacenodes`
**Usage:** `replacenodes [--p1 x y z] [--p2 x y z] [--invert] <searchnode> <replacenode>`
Replace all of one node with another node. Can be used to swap out a node that changed names or was deleted.
This command does not currently affect param2, node metadata, etc.
Arguments:
- **`searchnode`**: Name of node to search for.
- **`replacenode`**: Name of node to replace with.
- **`--p1, --p2`**: Area in which to replace nodes. If not specified, nodes will be replaced across the entire map.
- **`--invert`**: Only replace nodes *outside* the given area.
### `setparam2`
**Usage:** `setparam2 [--searchnode <searchnode>] [--p1 x y z] [--p2 x y z] [--invert] <paramval>`
Set param2 values of a certain node and/or within a certain area.
Arguments:
- **`paramval`**: Param2 value to set, between 0 and 255.
- **`--searchnode`**: Name of node to search for. If not specified, the param2 of all nodes will be set.
- **`--p1, --p2`**: Area in which to set param2. Required if `searchnode` is not specified.
- **`--invert`**: Only set param2 *outside* the given area.
### `deletemeta`
**Usage:** `deletemeta [--searchnode <searchnode>] [--p1 x y z] [--p2 x y z] [--invert]`
Delete metadata of a certain node and/or within a certain area. This includes node inventories as well.
Arguments:
- **`--searchnode`**: Name of node to search for. If not specified, the metadata of all nodes will be deleted.
- **`--p1, --p2`**: Area in which to delete metadata. Required if `searchnode` is not specified.
- **`--invert`**: Only delete metadata *outside* the given area.
### `setmetavar`
**Usage:** `setmetavar [--searchnode <searchnode>] [--p1 x y z] [--p2 x y z] [--invert] <metakey> <metavalue>`
Set a variable in node metadata. This only works on metadata where the variable is already set.
Arguments:
- **`metakey`**: Name of variable to set, e.g. `infotext`, `formspec`, etc.
- **`metavalue`**: Value to set variable to. This should be a string.
- **`--searchnode`**: Name of node to search for. If not specified, the variable will be set for all nodes that have it.
- **`--p1, --p2`**: Area in which to search. Required if `searchnode` is not specified.
- **`--invert`**: Only search for nodes *outside* the given area.
### `replaceininv`
**Usage:** ` replaceininv [--deletemeta] [--searchnode <searchnode>] [--p1 x y z] [--p2 x y z] [--invert] <searchitem> <replaceitem>`
Replace a certain item with another in node inventories.
To delete items instead of replacing them, use "Empty" (with a capital E) for `replacename`.
Arguments:
- **`searchitem`**: Item to search for in node inventories.
- **`replaceitem`**: Item to replace with in node inventories.
- **`--deletemeta`**: Delete metadata of replaced items. If not specified, any item metadata will remain unchanged.
- **`--searchnode`**: Name of node to to replace in. If not specified, the item will be replaced in all node inventories.
- **`--p1, --p2`**: Area in which to search for nodes. If not specified, items will be replaced across the entire map.
- **`--invert`**: Only search for nodes *outside* the given area.
**Tip:** To only delete metadata without replacing the nodes, use the `--deletemeta` flag, and make `replaceitem` the same as `searchitem`.
### `deletetimers`
**Usage:** `deletetimers [--searchnode <searchnode>] [--p1 x y z] [--p2 x y z] [--invert]`
Delete node timers of a certain node and/or within a certain area.
Arguments:
- **`--searchnode`**: Name of node to search for. If not specified, the node timers of all nodes will be deleted.
- **`--p1, --p2`**: Area in which to delete node timers. Required if `searchnode` is not specified.
- **`--invert`**: Only delete node timers *outside* the given area.
### `deleteobjects`
**Usage:** `deleteobjects [--searchobj <searchobj>] [--items] [--p1 x y z] [--p2 x y z] [--invert]`
Delete static objects of a certain name and/or within a certain area.
Arguments:
- **`--searchobj`**: Name of object to search for, e.g. "boats:boat". If not specified, all objects will be deleted.
- **`--items`**: Search for only item entities (dropped items). `searchobj` determines the item name, if specified.
- **`--p1, --p2`**: Area in which to delete objects. If not specified, objects will be deleted across the entire map.
- **`--invert`**: Only delete objects *outside* the given area.
## Acknowledgments ## Acknowledgments

View File

@ -1,27 +1,113 @@
import numpy as np
import struct import struct
from . import utils
def deserialize_metadata_vars(blob, numVars, version):
def clean_nimap(nimap, nodeData):
"""Removes unused or duplicate name-id mappings."""
for nid, name in utils.SafeEnum(nimap):
delete = False
firstOccur = nimap.index(name)
if firstOccur < nid:
# Name is a duplicate, since we are iterating backwards.
nodeData[nodeData == nid] = firstOccur
delete = True
if delete or np.all(nodeData != nid):
del nimap[nid]
nodeData[nodeData > nid] -= 1
class MapblockMerge:
"""Used to layer multiple mapblock fragments onto another block."""
def __init__(self, base):
self.base = base
self.layers = []
self.fromAreas = []
self.toAreas = []
def add_layer(self, mapBlock, fromArea, toArea):
self.layers.append(mapBlock)
self.fromAreas.append(fromArea)
self.toAreas.append(toArea)
def merge(self):
(baseND, baseParam1, baseParam2) = self.base.deserialize_node_data()
baseNimap = self.base.deserialize_nimap()
baseMetadata = self.base.deserialize_metadata()
baseTimers = self.base.deserialize_node_timers()
for i, layer in enumerate(self.layers):
fromArea = self.fromAreas[i]
toArea = self.toAreas[i]
fromSlices = fromArea.to_array_slices()
toSlices = toArea.to_array_slices()
(layerND, layerParam1, layerParam2) = layer.deserialize_node_data()
layerNimap = layer.deserialize_nimap()
layerND += len(baseNimap)
baseNimap.extend(layerNimap)
baseND[toSlices] = layerND[fromSlices]
baseParam1[toSlices] = layerParam1[fromSlices]
baseParam2[toSlices] = layerParam2[fromSlices]
areaOffset = toArea.p1 - fromArea.p1
for mIdx, meta in utils.SafeEnum(baseMetadata):
pos = utils.Vec3.from_u16_key(meta["pos"])
if toArea.contains(pos):
del baseMetadata[mIdx]
layerMetadata = layer.deserialize_metadata()
for meta in layerMetadata:
pos = utils.Vec3.from_u16_key(meta["pos"])
if fromArea.contains(pos):
meta["pos"] = (pos + areaOffset).to_u16_key()
baseMetadata.append(meta)
for tIdx, timer in utils.SafeEnum(baseTimers):
pos = utils.Vec3.from_u16_key(timer["pos"])
if toArea.contains(pos):
del baseTimers[tIdx]
# Clean up duplicate and unused name-id mappings
clean_nimap(baseNimap, baseND)
self.base.serialize_node_data(baseND, baseParam1, baseParam2)
self.base.serialize_nimap(baseNimap)
self.base.serialize_metadata(baseMetadata)
self.base.serialize_node_timers(baseTimers)
return self.base
def deserialize_metadata_vars(blob, count, metaVersion):
varList = {} varList = {}
c = 0 c = 0
for i in range(numVars): for i in range(count):
strLen = struct.unpack(">H", blob[c:c+2])[0] strLen = struct.unpack(">H", blob[c:c+2])[0]
key = blob[c+2:c+2+strLen] key = blob[c+2:c+2+strLen]
c += 2 + strLen c += 2 + strLen
strLen = struct.unpack(">I", blob[c:c+4])[0] strLen = struct.unpack(">I", blob[c:c+4])[0]
value = blob[c+4:c+4+strLen] value = blob[c+4:c+4+strLen]
c += 4 + strLen c += 4 + strLen
# Account for extra "is private" variable.
if version >= 2:
private = blob[c:c+1]
c += 1
varList[key] = [value, private] if metaVersion >= 2:
isPrivate = blob[c]
c += 1
else:
isPrivate = 0
varList[key] = (value, isPrivate)
return varList return varList
def serialize_metadata_vars(varList, version): def serialize_metadata_vars(varList, metaVersion):
blob = b"" blob = b""
for key, data in varList.items(): for key, data in varList.items():
@ -29,7 +115,9 @@ def serialize_metadata_vars(varList, version):
blob += key blob += key
blob += struct.pack(">I", len(data[0])) blob += struct.pack(">I", len(data[0]))
blob += data[0] blob += data[0]
if version >= 2: blob += data[1]
if metaVersion >= 2:
blob += struct.pack("B", data[1])
return blob return blob

File diff suppressed because it is too large Load Diff

View File

@ -1,205 +0,0 @@
import sqlite3
import math
import time
import sys
class DatabaseHandler:
"""Handles the Sqlite database and provides useful functions."""
def __init__(self, filename, type):
if not filename:
throw_error("Please specify a map file ({:s}).".format(type))
try:
tempFile = open(filename, 'r')
tempFile.close()
except:
throw_error("Map file does not exist ({:s}).".format(type))
self.database = sqlite3.connect(filename)
self.cursor = self.database.cursor()
try:
self.cursor.execute("SELECT * FROM blocks")
except sqlite3.DatabaseError:
throw_error("File is not a valid map file ({:s}).".format(type))
def get_block(self, pos):
self.cursor.execute("SELECT data FROM blocks WHERE pos = ?", (pos,))
return self.cursor.fetchone()[0]
def delete_block(self, pos):
self.cursor.execute("DELETE FROM blocks WHERE pos = ?", (pos,))
def set_block(self, pos, data, force = False):
if force:
self.cursor.execute(
"INSERT OR REPLACE INTO blocks (pos, data) VALUES (?, ?)",
(pos, data))
else:
self.cursor.execute("UPDATE blocks SET data = ? WHERE pos = ?",
(data, pos))
def close(self, commit = False):
if commit:
self.database.commit()
self.database.close()
class Progress:
"""Prints a progress bar with time elapsed."""
def __init__(self):
self.last_total = 0
self.start_time = time.time()
def __del__(self):
self.print_bar(self.last_total, self.last_total)
def print_bar(self, completed, total):
self.last_total = total
if completed % 100 == 0 or completed == total:
if total > 0:
percent = round(completed / total * 100, 1)
else:
percent = 100
progress = math.floor(percent/2)
hours, remainder = divmod(int(time.time() - self.start_time), 3600)
minutes, seconds = divmod(remainder, 60)
print("|" + ('=' * progress) + (' ' * (50 - progress)) + "| " +
str(percent) + "% completed (" + str(completed) + "/" +
str(total) + " mapblocks) Elapsed: " +
"{:0>2}:{:0>2}:{:0>2}".format(hours, minutes, seconds),
end='\r')
class safeEnum:
"""Enumerates backwards over a list. This prevents items from being skipped
when deleting them."""
def __init__(self, list):
self.list = list
self.max = len(list)
def __iter__(self):
self.n = self.max
return self
def __next__(self):
if self.n > 0:
self.n -= 1
return self.n, self.list[self.n]
else:
raise StopIteration
def unsigned_to_signed(num, max_positive):
if num < max_positive:
return num
return num - (max_positive * 2)
def unhash_pos(num):
pos = [0, 0, 0]
pos[0] = unsigned_to_signed(num % 4096, 2048) # x value
num = (num - pos[0]) >> 12
pos[1] = unsigned_to_signed(num % 4096, 2048) # y value
num = (num - pos[1]) >> 12
pos[2] = unsigned_to_signed(num % 4096, 2048) # z value
return pos
def hash_pos(pos):
return (pos[0] +
pos[1] * 0x1000 +
pos[2] * 0x1000000)
def is_in_range(num, area):
p1, p2 = area[0], area[1]
x = unsigned_to_signed(num % 4096, 2048)
if x < p1[0] or x > p2[0]:
return False
num = (num - x) >> 12
y = unsigned_to_signed(num % 4096, 2048)
if y < p1[1] or y > p2[1]:
return False
num = (num - y) >> 12
z = unsigned_to_signed(num % 4096, 2048)
if z < p1[2] or z > p2[2]:
return False
return True
def get_mapblocks(database, area = None, name = None, inverse = False):
batch = []
list = []
while True:
batch = database.cursor.fetchmany(1000)
# Exit if we run out of database entries.
if len(batch) == 0:
break
for pos, data in batch:
# If an area is specified, check if it is in the area.
if area and is_in_range(pos, area) == inverse:
continue
# If a node name is specified, check if the name is in the data.
if name and data.find(name) < 0:
continue
# If checks pass, append item.
list.append(pos)
print("Building index, please wait... " + str(len(list)) +
" mapblocks found.", end="\r")
print("\nPerforming operation on about " + str(len(list)) + " mapblocks.")
return list
def args_to_mapblocks(p1, p2):
for i in range(3):
# Swap values so p1's values are always greater.
if p2[i] < p1[i]:
temp = p1[i]
p1[i] = p2[i]
p2[i] = temp
# Convert to mapblock coordinates
p1 = [math.ceil(n/16) for n in p1]
p2 = [math.floor((n + 1)/16) - 1 for n in p2]
return p1, p2
def verify_file(filename, msg):
try:
tempFile = open(filename, 'r')
tempFile.close()
except:
throw_error(msg)
def throw_error(msg):
print("ERROR: " + msg)
sys.exit()

View File

@ -1,16 +1,41 @@
import struct import numpy as np
import zlib import zlib
import struct
from . import utils
class MapBlock: MIN_BLOCK_VER = 25
"""Stores a parsed version of a mapblock.""" MAX_BLOCK_VER = 28
def is_valid_generated(blob):
"""Returns true if a raw mapblock is valid and fully generated."""
return (blob and
len(blob) > 2 and
MIN_BLOCK_VER <= blob[0] <= MAX_BLOCK_VER and
blob[1] & 0x08 == 0)
class MapblockParseError(Exception):
"""Error parsing mapblock."""
pass
class Mapblock:
"""Stores a parsed version of a mapblock.
For the Minetest C++ implementation, see the serialize/deserialize
methods in minetest/src/mapblock.cpp, as well as the related
functions called by those methods.
"""
def __init__(self, blob): def __init__(self, blob):
self.version = struct.unpack("B", blob[0:1])[0] self.version = blob[0]
if self.version < 25 or self.version > 28: if self.version < MIN_BLOCK_VER or self.version > MAX_BLOCK_VER:
return raise MapblockParseError(
f"Unsupported mapblock version: {self.version}")
self.flags = blob[1:2] self.flags = blob[1]
if self.version >= 27: if self.version >= 27:
self.lighting_complete = blob[2:4] self.lighting_complete = blob[2:4]
@ -19,16 +44,16 @@ class MapBlock:
self.lighting_complete = 0xFFFF self.lighting_complete = 0xFFFF
c = 2 c = 2
self.content_width = struct.unpack("B", blob[c:c+1])[0] self.content_width = blob[c]
self.params_width = struct.unpack("B", blob[c+1:c+2])[0] self.params_width = blob[c+1]
if self.content_width != 2 or self.params_width != 2: if self.content_width != 2 or self.params_width != 2:
return raise MapblockParseError("Unsupported content and/or param width")
# Decompress node data. This stores a node type id, param1 and param2 # Decompress node data. This stores a node type id, param1 and param2
# for each node. # for each node.
decompresser = zlib.decompressobj() decompresser = zlib.decompressobj()
self.node_data = decompresser.decompress(blob[c+2:]) self.node_data_raw = decompresser.decompress(blob[c+2:])
c = len(blob) - len(decompresser.unused_data) c = len(blob) - len(decompresser.unused_data)
# Decompress node metadata. # Decompress node metadata.
@ -37,7 +62,7 @@ class MapBlock:
c = len(blob) - len(decompresser.unused_data) c = len(blob) - len(decompresser.unused_data)
# Parse static objects. # Parse static objects.
self.static_object_version = struct.unpack("B", blob[c:c+1])[0] self.static_object_version = blob[c]
self.static_object_count = struct.unpack(">H", blob[c+1:c+3])[0] self.static_object_count = struct.unpack(">H", blob[c+1:c+3])[0]
c += 3 c += 3
c2 = c c2 = c
@ -55,7 +80,11 @@ class MapBlock:
self.timestamp = struct.unpack(">I", blob[c:c+4])[0] self.timestamp = struct.unpack(">I", blob[c:c+4])[0]
# Parse name-id mappings. # Parse name-id mappings.
self.nimap_version = struct.unpack("B", blob[c+4:c+5])[0] self.nimap_version = blob[c+4]
if self.nimap_version != 0:
raise MapblockParseError(
f"Unsupported nimap version: {self.nimap_version}")
self.nimap_count = struct.unpack(">H", blob[c+5:c+7])[0] self.nimap_count = struct.unpack(">H", blob[c+5:c+7])[0]
c += 7 c += 7
c2 = c c2 = c
@ -70,42 +99,56 @@ class MapBlock:
self.nimap_raw = blob[c:c2] self.nimap_raw = blob[c:c2]
c = c2 c = c2
# Get raw node timers. # Get raw node timers. Includes version and count.
self.node_timers_count = struct.unpack(">H", blob[c+1:c+3])[0] self.node_timers_raw = blob[c:]
self.node_timers_raw = blob[c+3:]
def serialize(self): def serialize(self):
blob = b"" blob = b""
blob += struct.pack("B", self.version) blob += struct.pack("BB", self.version, self.flags)
blob += self.flags
if self.version >= 27: if self.version >= 27:
blob += self.lighting_complete blob += self.lighting_complete
blob += struct.pack("B", self.content_width) blob += struct.pack("BB", self.content_width, self.params_width)
blob += struct.pack("B", self.params_width)
blob += zlib.compress(self.node_data) blob += zlib.compress(self.node_data_raw)
blob += zlib.compress(self.node_metadata) blob += zlib.compress(self.node_metadata)
blob += struct.pack("B", self.static_object_version) blob += struct.pack(">BH",
blob += struct.pack(">H", self.static_object_count) self.static_object_version, self.static_object_count)
blob += self.static_objects_raw blob += self.static_objects_raw
blob += struct.pack(">I", self.timestamp) blob += struct.pack(">I", self.timestamp)
blob += struct.pack("B", self.nimap_version) blob += struct.pack(">BH", self.nimap_version, self.nimap_count)
blob += struct.pack(">H", self.nimap_count)
blob += self.nimap_raw blob += self.nimap_raw
blob += b"\x0A" # The timer data length is basically unused.
blob += struct.pack(">H", self.node_timers_count)
blob += self.node_timers_raw blob += self.node_timers_raw
return blob return blob
def get_raw_content(self, idx):
"""Get the raw 2-byte ID of a node at a given index."""
return self.node_data_raw[idx * self.content_width :
(idx + 1) * self.content_width]
def deserialize_node_data(self):
nodeData = np.frombuffer(self.node_data_raw,
count=4096, dtype=">u2")
param1 = np.frombuffer(self.node_data_raw,
offset=8192, count=4096, dtype="u1")
param2 = np.frombuffer(self.node_data_raw,
offset=12288, count=4096, dtype="u1")
return tuple(np.reshape(arr, (16, 16, 16)).copy()
for arr in (nodeData, param1, param2))
def serialize_node_data(self, nodeData, param1, param2):
self.node_data_raw = (
nodeData.tobytes() +
param1.tobytes() +
param2.tobytes()
)
def deserialize_nimap(self): def deserialize_nimap(self):
nimapList = [None] * self.nimap_count nimapList = [None] * self.nimap_count
@ -113,53 +156,49 @@ class MapBlock:
for i in range(self.nimap_count): for i in range(self.nimap_count):
# Parse node id and node name length. # Parse node id and node name length.
id = struct.unpack(">H", self.nimap_raw[c:c+2])[0] (nid, strSize) = struct.unpack(">HH", self.nimap_raw[c:c+4])
strSize = struct.unpack(">H", self.nimap_raw[c+2:c+4])[0]
# Parse node name # Parse node name
c += 4 c += 4
name = self.nimap_raw[c:c+strSize] name = self.nimap_raw[c:c+strSize]
c += strSize c += strSize
nimapList[id] = name nimapList[nid] = name
return nimapList return nimapList
def serialize_nimap(self, nimapList): def serialize_nimap(self, nimapList):
blob = b"" blob = b""
for i in range(len(nimapList)): for nid in range(len(nimapList)):
blob += struct.pack(">H", i) blob += struct.pack(">HH", nid, len(nimapList[nid]))
blob += struct.pack(">H", len(nimapList[i])) blob += nimapList[nid]
blob += nimapList[i]
self.nimap_count = len(nimapList) self.nimap_count = len(nimapList)
self.nimap_raw = blob self.nimap_raw = blob
def deserialize_metadata(self): def deserialize_metadata(self):
metaList = [] metaList = []
self.metadata_version = struct.unpack("B", self.node_metadata[0:1])[0] self.metadata_version = self.node_metadata[0]
# A version number of 0 indicates no metadata is present. # A version number of 0 indicates no metadata is present.
if self.metadata_version == 0: if self.metadata_version == 0:
return metaList return metaList
elif self.metadata_version > 2: elif self.metadata_version > 2:
helpers.throw_error("ERROR: Metadata version not supported.") raise MapblockParseError(
f"Unsupported metadata version: {self.metadata_version}")
count = struct.unpack(">H", self.node_metadata[1:3])[0] count = struct.unpack(">H", self.node_metadata[1:3])[0]
c = 3 c = 3
for i in range(count): for i in range(count):
metaList.append({}) meta = {}
metaList[i]["pos"] = struct.unpack(">H",
self.node_metadata[c:c+2])[0] (meta["pos"], meta["numVars"]) = struct.unpack(">HI",
metaList[i]["numVars"] = struct.unpack(">I", self.node_metadata[c:c+6])
self.node_metadata[c+2:c+6])[0]
c += 6 c += 6
c2 = c c2 = c
for a in range(metaList[i]["numVars"]): for a in range(meta["numVars"]):
strLen = struct.unpack(">H", self.node_metadata[c2:c2+2])[0] strLen = struct.unpack(">H", self.node_metadata[c2:c2+2])[0]
c2 += 2 + strLen c2 += 2 + strLen
strLen = struct.unpack(">I", self.node_metadata[c2:c2+4])[0] strLen = struct.unpack(">I", self.node_metadata[c2:c2+4])[0]
@ -167,14 +206,15 @@ class MapBlock:
# Account for extra "is private" variable. # Account for extra "is private" variable.
c2 += 1 if self.metadata_version >= 2 else 0 c2 += 1 if self.metadata_version >= 2 else 0
metaList[i]["vars"] = self.node_metadata[c:c2] meta["vars"] = self.node_metadata[c:c2]
c = c2 c = c2
c2 = self.node_metadata.find(b"EndInventory\n", c) + 13 c2 = self.node_metadata.find(b"EndInventory\n", c) + 13
metaList[i]["inv"] = self.node_metadata[c:c2] meta["inv"] = self.node_metadata[c:c2]
c = c2 c = c2
return metaList metaList.append(meta)
return metaList
def serialize_metadata(self, metaList): def serialize_metadata(self, metaList):
blob = b"" blob = b""
@ -183,70 +223,91 @@ class MapBlock:
self.node_metadata = b"\x00" self.node_metadata = b"\x00"
return return
else: else:
# Metadata version is just determined from the block version.
self.metadata_version = 2 if self.version > 27 else 1
blob += struct.pack("B", self.metadata_version) blob += struct.pack("B", self.metadata_version)
blob += struct.pack(">H", len(metaList)) blob += struct.pack(">H", len(metaList))
for meta in metaList: for meta in metaList:
blob += struct.pack(">H", meta["pos"]) blob += struct.pack(">HI", meta["pos"], meta["numVars"])
blob += struct.pack(">I", meta["numVars"])
blob += meta["vars"] blob += meta["vars"]
blob += meta["inv"] blob += meta["inv"]
self.node_metadata = blob self.node_metadata = blob
def deserialize_static_objects(self): def deserialize_static_objects(self):
objectList = [] objectList = []
c = 0 c = 0
for i in range(self.static_object_count): for i in range(self.static_object_count):
type = struct.unpack("B", self.static_objects_raw[c:c+1])[0] objType = self.static_objects_raw[c]
pos = self.static_objects_raw[c+1:c+13] pos = self.static_objects_raw[c+1:c+13]
strLen = struct.unpack(">H", self.static_objects_raw[c+13:c+15])[0] strLen = struct.unpack(">H", self.static_objects_raw[c+13:c+15])[0]
c += 15 c += 15
data = self.static_objects_raw[c:c+strLen] data = self.static_objects_raw[c:c+strLen]
c += strLen c += strLen
objectList.append({"type": type, "pos": pos, "data": data}) objectList.append({"type": objType, "pos": pos, "data": data})
return objectList return objectList
def serialize_static_objects(self, objectList): def serialize_static_objects(self, objectList):
blob = b"" blob = b""
for object in objectList: for sObject in objectList:
blob += struct.pack("B", object["type"]) blob += struct.pack("B", sObject["type"])
blob += object["pos"] blob += sObject["pos"]
blob += struct.pack(">H", len(object["data"])) blob += struct.pack(">H", len(sObject["data"]))
blob += object["data"] blob += sObject["data"]
self.static_objects_raw = blob self.static_objects_raw = blob
self.static_object_count = len(objectList) self.static_object_count = len(objectList)
def deserialize_node_timers(self): def deserialize_node_timers(self):
timerList = [] timerList = []
c = 0
for i in range(self.node_timers_count): # The first byte changed from version to data length, for some reason.
pos = struct.unpack(">H", self.node_timers_raw[c:c+2])[0] if self.version == 24:
timeout = struct.unpack(">I", self.node_timers_raw[c+2:c+6])[0] version = self.node_timers_raw[0]
elapsed = struct.unpack(">I", self.node_timers_raw[c+6:c+10])[0] if version == 0:
return timerList
elif version != 1:
raise MapblockParseError(
f"Unsupported node timer version: {version}")
elif self.version >= 25:
datalen = self.node_timers_raw[0]
if datalen != 10:
raise MapblockParseError(
f"Unsupported node timer data length: {datalen}")
count = struct.unpack(">H", self.node_timers_raw[1:3])[0]
c = 3
for i in range(count):
(pos, timeout, elapsed) = struct.unpack(">HII",
self.node_timers_raw[c:c+10])
c += 10 c += 10
timerList.append({"pos": pos, "timeout": timeout, timerList.append({"pos": pos, "timeout": timeout,
"elapsed": elapsed}) "elapsed": elapsed})
return timerList return timerList
def serialize_node_timers(self, timerList): def serialize_node_timers(self, timerList):
blob = b"" blob = b""
count = len(timerList)
if self.version == 24:
if count == 0:
blob += b"\x00"
else:
blob += b"\x01"
blob += struct.pack(">H", count)
elif self.version >= 25:
blob += b"\x0A"
blob += struct.pack(">H", count)
for i, timer in enumerate(timerList): for i, timer in enumerate(timerList):
blob += struct.pack(">H", timer["pos"]) blob += struct.pack(">HII",
blob += struct.pack(">I", timer["timeout"]) timer["pos"], timer["timeout"], timer["elapsed"])
blob += struct.pack(">I", timer["elapsed"])
self.node_timers_raw = blob self.node_timers_raw = blob
self.node_timers_count = len(timerList)

305
lib/utils.py Normal file
View File

@ -0,0 +1,305 @@
import sqlite3
from typing import NamedTuple
import struct
import math
import time
class Vec3(NamedTuple):
"""Vector to store 3D coordinates."""
x: int = 0
y: int = 0
z: int = 0
@classmethod
def from_block_key(cls, key):
(key, x) = divmod(key + 0x800, 0x1000)
(z, y) = divmod(key + 0x800, 0x1000)
return cls(x - 0x800, y - 0x800, z)
def to_block_key(self):
return (self.x * 0x1 +
self.y * 0x1000 +
self.z * 0x1000000)
def is_valid_block_pos(self):
"""Determines if a block position is valid and usable.
Block positions up to 2048 can still be converted to a
mapblock key, but Minetest only loads blocks within 31000
nodes.
"""
limit = 31000 // 16
return (-limit <= self.x <= limit and
-limit <= self.y <= limit and
-limit <= self.z <= limit)
@classmethod
def from_u16_key(cls, key):
return cls(key % 16,
(key >> 4) % 16,
(key >> 8) % 16)
def to_u16_key(self):
return self.x + self.y * 16 + self.z * 256
@classmethod
def from_v3f1000(cls, pos):
# *10 accounts for block size, so it's not really 1000x.
fac = 1000.0 * 10
(x, y, z) = struct.unpack(">iii", pos)
return cls(x / fac, y / fac, z / fac)
def map(self, func):
return Vec3(*(func(n) for n in self))
def __add__(self, other):
return Vec3(self.x + other.x,
self.y + other.y,
self.z + other.z)
def __sub__(self, other):
return Vec3(self.x - other.x,
self.y - other.y,
self.z - other.z)
def __mul__(self, other):
if type(other) == Vec3:
return Vec3(self.x * other.x,
self.y * other.y,
self.z * other.z)
elif type(other) == int:
return Vec3(*(n * other for n in self))
else:
return NotImplemented
class Area(NamedTuple):
"""Area defined by two corner Vec3's.
All of p1's coordinates must be less than or equal to p2's.
"""
p1: Vec3
p2: Vec3
@classmethod
def from_args(cls, p1, p2):
pMin = Vec3(min(p1[0], p2[0]), min(p1[1], p2[1]), min(p1[2], p2[2]))
pMax = Vec3(max(p1[0], p2[0]), max(p1[1], p2[1]), max(p1[2], p2[2]))
return cls(pMin, pMax)
def to_array_slices(self):
"""Convert area to tuple of slices for NumPy array indexing."""
return (slice(self.p1.z, self.p2.z + 1),
slice(self.p1.y, self.p2.y + 1),
slice(self.p1.x, self.p2.x + 1))
def contains(self, pos):
return (self.p1.x <= pos.x <= self.p2.x and
self.p1.y <= pos.y <= self.p2.y and
self.p1.z <= pos.z <= self.p2.z)
def is_full_mapblock(self):
return self.p1 == Vec3(0, 0, 0) and self.p2 == Vec3(15, 15, 15)
def __iter__(self):
for x in range(self.p1.x, self.p2.x + 1):
for y in range(self.p1.y, self.p2.y + 1):
for z in range(self.p1.z, self.p2.z + 1):
yield Vec3(x, y, z)
def __add__(self, offset):
return Area(self.p1 + offset, self.p2 + offset)
def __sub__(self, offset):
return Area(self.p1 - offset, self.p2 - offset)
def get_block_overlap(blockPos, area, relative=False):
cornerPos = blockPos * 16
relArea = area - cornerPos
relOverlap = Area(
relArea.p1.map(lambda n: max(n, 0)),
relArea.p2.map(lambda n: min(n, 15))
)
if (relOverlap.p1.x > relOverlap.p2.x or
relOverlap.p1.y > relOverlap.p2.y or
relOverlap.p1.z > relOverlap.p2.z):
# p1 is greater than p2, meaning there is no overlap.
return None
if relative:
return relOverlap
else:
return relOverlap + cornerPos
def get_overlap_slice(blockPos, area):
return get_block_overlap(blockPos, area, relative=True).to_array_slices()
class DatabaseHandler:
"""Handles an SQLite database and provides useful methods."""
def __init__(self, filename):
try:
open(filename, 'r').close()
except FileNotFoundError:
raise
self.database = sqlite3.connect(filename)
self.cursor = self.database.cursor()
try:
self.cursor.execute("SELECT pos, data FROM blocks")
except sqlite3.DatabaseError:
raise
def get_block(self, key):
self.cursor.execute("SELECT data FROM blocks WHERE pos = ?", (key,))
if data := self.cursor.fetchone():
return data[0]
else:
return None
def get_many(self, num):
return self.cursor.fetchmany(num)
def delete_block(self, key):
self.cursor.execute("DELETE FROM blocks WHERE pos = ?", (key,))
def set_block(self, key, data, force=False):
# TODO: Remove force?
if force:
self.cursor.execute(
"INSERT OR REPLACE INTO blocks (pos, data) VALUES (?, ?)",
(key, data))
else:
self.cursor.execute("UPDATE blocks SET data = ? WHERE pos = ?",
(data, key))
def is_modified(self):
return self.database.in_transaction
def close(self, commit=False):
if self.is_modified() and commit:
self.database.commit()
self.database.close()
def get_mapblock_area(area, invert=False, includePartial=False):
"""Get "positive" area.
If the area is inverted, only mapblocks outside this area should be
modified.
"""
if invert == includePartial:
# Partial mapblocks are excluded.
return Area(area.p1.map(lambda n: (n + 15) // 16),
area.p2.map(lambda n: (n - 15) // 16))
else:
# Partial mapblocks are included.
return Area(area.p1.map(lambda n: n // 16),
area.p2.map(lambda n: n // 16))
def get_mapblocks(database, searchData=None, area=None, invert=False,
includePartial=False):
"""Returns a list of all mapblocks that fit the given criteria."""
keys = []
if area:
blockArea = get_mapblock_area(area, invert=invert,
includePartial=includePartial)
else:
blockArea = None
while True:
batch = database.get_many(1000)
# Exit if we run out of database entries.
if len(batch) == 0:
break
for key, data in batch:
# Make sure the block is inside/outside the area as specified.
if (blockArea and
blockArea.contains(Vec3.from_block_key(key)) == invert):
continue
# Specifies a node name or other string to search for.
if searchData and data.find(searchData) == -1:
continue
# If checks pass, add the key to the list.
keys.append(key)
print(f"\rBuilding index... {len(keys)} mapblocks found.", end="")
print()
return keys
class Progress:
"""Prints a progress bar with time elapsed."""
PRINT_INTERVAL = 0.25
BAR_LEN = 50
def __init__(self):
self.start_time = None
self.last_total = 0
self.last_time = 0
def _print_bar(self, completed, total, timeNow):
fProgress = completed / total if total > 0 else 1.0
numBars = math.floor(fProgress * self.BAR_LEN)
percent = fProgress * 100
remMinutes, seconds = divmod(int(timeNow - self.start_time), 60)
hours, minutes = divmod(remMinutes, 60)
print(f"\r|{'=' * numBars}{' ' * (self.BAR_LEN - numBars)}| "
f"{percent:.1f}% completed ({completed}/{total} mapblocks) "
f"{hours:0>2}:{minutes:0>2}:{seconds:0>2}",
end="")
self.last_time = timeNow
def set_start(self):
self.start_time = time.time()
def update_bar(self, completed, total):
self.last_total = total
timeNow = time.time()
if timeNow - self.last_time > self.PRINT_INTERVAL:
self._print_bar(completed, total, timeNow)
def update_final(self):
if self.start_time:
self._print_bar(self.last_total, self.last_total, time.time())
print()
class SafeEnum:
"""Enumerates backwards over a list.
This prevents items from being skipped when deleting them.
"""
def __init__(self, iterable):
self.iterable = iterable
self.max = len(iterable)
def __iter__(self):
self.n = self.max
return self
def __next__(self):
if self.n > 0:
self.n -= 1
return self.n, self.iterable[self.n]
else:
raise StopIteration

View File

@ -1,182 +1,163 @@
#!/usr/bin/env python3
import argparse import argparse
import sys from lib import commands
import re # TODO: Fix file structure, add setuptools?
from lib import commands, helpers
inputFile = "" ARGUMENT_DEFS = {
outputFile = "" "p1": {
"always_opt": True,
"params": {
"type": int,
"nargs": 3,
"metavar": ("x", "y", "z"),
"help": "Corner position 1 of area",
}
},
"p2": {
"always_opt": True,
"params": {
"type": int,
"nargs": 3,
"metavar": ("x", "y", "z"),
"help": "Corner position 2 of area",
}
},
"invert": {
"params": {
"action": "store_true",
"help": "Select everything OUTSIDE the given area."
}
},
"blockmode": {
"params": {
"action": "store_true",
"help": "Work on whole mapblocks instead of node regions. "
"May be considerably faster in some cases."
}
},
"offset": {
"always_opt": True,
"params": {
"type": int,
"nargs": 3,
"metavar": ("x", "y", "z"),
"help": "Vector to move area by",
}
},
"searchnode": {
"params": {
"metavar": "<searchnode>",
"help": "Name of node to search for"
}
},
"replacenode": {
"params": {
"metavar": "<replacenode>",
"help": "Name of node to replace with"
}
},
"searchitem": {
"params": {
"metavar": "<searchitem>",
"help": "Name of item to search for"
}
},
"replaceitem": {
"params": {
"metavar": "<replaceitem>",
"help": "Name of item to replace with"
}
},
"metakey": {
"params": {
"metavar": "<metakey>",
"help": "Name of variable to set"
}
},
"metavalue": {
"params": {
"metavar": "<metavalue>",
"help": "Value to set variable to"
}
},
"searchobj": {
"params": {
"metavar": "<searchobj>",
"help": "Name of object to search for"
}
},
"paramval": {
"params": {
"type": int,
"metavar": "<paramval>",
"help": "Value to set param2 to."
}
},
"input_file": {
"params": {
"metavar": "<input_file>",
"help": "Path to secondary (input) map file"
}
},
"deletemeta": {
"params": {
"action": "store_true",
"help": "Delete item metadata when replacing items."
}
},
"items": {
"params": {
"action": "store_true",
"help": "Search for item entities (dropped items)."
}
},
}
# Initialize parsers.
# Parse arguments
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Edit Minetest map and player database files.") description="Edit Minetest map database files.",
epilog="Run `mapedit.py <command> -h` for command-specific help.")
parser.add_argument("-f", parser.add_argument("-f",
required=True, required=True,
dest="file",
metavar="<file>", metavar="<file>",
help="Path to primary map file") help="Path to primary map file")
parser.add_argument("-s", parser.add_argument("--no-warnings",
required=False, dest="no_warnings",
metavar="<file>",
help="Path to secondary (input) map file")
parser.add_argument("--p1",
type=int,
nargs=3,
metavar=("x", "y", "z"),
help="Position 1 (specified in nodes)")
parser.add_argument("--p2",
type=int,
nargs=3,
metavar=("x", "y", "z"),
help="Position 2 (specified in nodes)")
parser.add_argument("--inverse",
action="store_true", action="store_true",
help="Select all mapblocks NOT in the given area.") help="Don't show warnings or confirmation prompts.")
parser.add_argument("--silencewarnings", subparsers = parser.add_subparsers(dest="command", required=True,
action="store_true")
subparsers = parser.add_subparsers(dest="command",
help="Command (see README.md for more information)") help="Command (see README.md for more information)")
# Initialize basic mapblock-based commands. for cmdName, cmdDef in commands.COMMAND_DEFS.items():
parser_cloneblocks = subparsers.add_parser("cloneblocks", subparser = subparsers.add_parser(cmdName, help=cmdDef["help"])
help="Clone the given area to a new location on the map.")
parser_cloneblocks.set_defaults(func=commands.clone_blocks)
parser_deleteblocks = subparsers.add_parser("deleteblocks", for arg, required in cmdDef["args"].items():
help="Delete all mapblocks in the given area.") argsToAdd = ("p1", "p2") if arg == "area" else (arg,)
parser_deleteblocks.set_defaults(func=commands.delete_blocks)
parser_fillblocks = subparsers.add_parser("fillblocks", for argToAdd in argsToAdd:
help="Fill the given area with a certain type of node.") argDef = ARGUMENT_DEFS[argToAdd]
parser_fillblocks.set_defaults(func=commands.fill_blocks)
parser_overlayblocks = subparsers.add_parser("overlayblocks", if "always_opt" in argDef and argDef["always_opt"]:
help="Overlay any mapblocks from secondary file into given area.") # Always use an option flag, even if not required.
parser_overlayblocks.set_defaults(func=commands.overlay_blocks) subparser.add_argument("--" + argToAdd, required=required,
**argDef["params"])
else:
if required:
subparser.add_argument(argToAdd, **argDef["params"])
else:
subparser.add_argument("--" + argToAdd, required=False,
**argDef["params"])
parser_cloneblocks.add_argument("--offset", # Handle the actual command.
required=True,
type=int,
nargs=3,
metavar=("x", "y", "z"),
help="Vector to move area by (specified in nodes)")
parser_fillblocks.add_argument("replacename",
metavar="<name>",
help="Name of node to fill area with")
# Initialize node-based commands. args = commands.MapEditArgs()
parser_replacenodes = subparsers.add_parser("replacenodes", parser.parse_args(namespace=args)
help="Replace all of one type of node with another.") inst = commands.MapEditInstance()
parser_replacenodes.set_defaults(func=commands.replace_nodes) inst.run(args)
parser_setparam2 = subparsers.add_parser("setparam2",
help="Set param2 values of all of a certain type of node.")
parser_setparam2.set_defaults(func=commands.set_param2)
parser_deletemeta = subparsers.add_parser("deletemeta",
help="Delete metadata of all of a certain type of node.")
parser_deletemeta.set_defaults(func=commands.delete_meta)
parser_setmetavar = subparsers.add_parser("setmetavar",
help="Set a value in the metadata of all of a certain type of node.")
parser_setmetavar.set_defaults(func=commands.set_meta_var)
parser_replaceininv = subparsers.add_parser("replaceininv",
help="Replace one item with another in inventories certain nodes.")
parser_replaceininv.set_defaults(func=commands.replace_in_inv)
parser_deletetimers = subparsers.add_parser("deletetimers",
help="Delete node timers of all of a certain type of node.")
parser_deletetimers.set_defaults(func=commands.delete_timers)
for command in (parser_replacenodes, parser_setparam2, parser_deletemeta,
parser_setmetavar, parser_replaceininv, parser_deletetimers):
command.add_argument("searchname",
metavar="<searchname>",
help="Name of node to search for")
parser_replacenodes.add_argument("replacename",
metavar="<replacename>",
help="Name of node to replace with")
parser_setparam2.add_argument("value",
type=int,
metavar="<value>",
help="Param2 value to replace with (0 for non-directional nodes)")
parser_setmetavar.add_argument("key",
metavar="<key>",
help="Name of variable to set")
parser_setmetavar.add_argument("value",
metavar="<value>",
help="Value to set variable to")
parser_replaceininv.add_argument("searchitem",
metavar="<searchitem>",
help="Name of item to search for")
parser_replaceininv.add_argument("replaceitem",
metavar="<replaceitem>",
help="Name of item to replace with")
parser_replaceininv.add_argument("--deletemeta",
action="store_true",
help="Delete item metadata when replacing items.")
# Initialize miscellaneous commands.
parser_deleteobjects = subparsers.add_parser("deleteobjects",
help="Delete all objects with the specified name.")
parser_deleteobjects.set_defaults(func=commands.delete_objects)
parser_deleteobjects.add_argument("--item",
action="store_true",
help="Search for item entities (dropped items).")
parser_deleteobjects.add_argument("searchname",
metavar="<searchname>",
help="Name of object to search for")
# Begin handling the command.
args = parser.parse_args()
if not args.command:
helpers.throw_error("No command specified.")
# Verify area coordinates.
if args.command in ("cloneblocks", "deleteblocks", "fillblocks",
"overlayblocks"):
if not args.p1 or not args.p2:
helpers.throw_error("Command requires --p1 and --p2 arguments.")
# Verify any node/item names.
nameFormat = re.compile("^[a-zA-Z0-9_]+:[a-zA-Z0-9_]+$")
for param in ("searchname", "replacename", "searchitem", "replaceitem"):
if hasattr(args, param):
value = getattr(args, param)
if (nameFormat.match(value) == None and value != "air" and not
(param == "replaceitem" and value == "Empty")):
helpers.throw_error("Invalid node name ({:s}).".format(param))
# Attempt to open database.
db = helpers.DatabaseHandler(args.f, "primary")
if not args.silencewarnings and input(
"WARNING: Using this tool can potentially cause permanent\n"
"damage to your map database. Please SHUT DOWN the game/server\n"
"and BACK UP the map before proceeding. To continue this\n"
"operation, type 'yes'.\n"
"> ") != "yes":
sys.exit()
if args.command == "overlayblocks":
if args.s == args.f:
helpers.throw_error("Primary and secondary map files are the same.")
sDb = helpers.DatabaseHandler(args.s, "secondary")
args.func(db, sDb, args)
sDb.close()
else:
args.func(db, args)
print("\nSaving file...")
db.close(commit=True)
print("Done.")