# This file is part of the Minecraft Overviewer. # # Minecraft Overviewer is free software: you can redistribute it and/or # modify it under the terms of the GNU General Public License as published # by the Free Software Foundation, either version 3 of the License, or (at # your option) any later version. # # Minecraft Overviewer is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General # Public License for more details. # # You should have received a copy of the GNU General Public License along # with the Overviewer. If not, see . import functools import os import os.path from glob import glob import logging import numpy import nbt import cache """ This module has routines for extracting information about available worlds """ class ChunkDoesntExist(Exception): pass class BiomeDataDoesntExist(Exception): pass def log_other_exceptions(func): """A decorator that prints out any errors that are not ChunkDoesntExist or BiomeDataDoesntExist errors. This decorates get_chunk because the C code is likely to swallow exceptions, so this will at least make them visible. """ functools.wraps(func) def newfunc(*args): try: return func(*args) except ChunkDoesntExist: raise except BiomeDataDoesntExist: raise except Exception, e: logging.exception("%s raised this exception", func.func_name) raise return newfunc class World(object): """Encapsulates the concept of a Minecraft "world". A Minecraft world is a level.dat file, a players directory with info about each player, a data directory with info about that world's maps, and one or more "dimension" directories containing a set of region files with the actual world data. This class deals with reading all the metadata about the world. Reading the actual world data for each dimension from the region files is handled by a RegionSet object. Note that vanilla Minecraft servers and single player games have a single world with multiple dimensions: one for the overworld, the nether, etc. On Bukkit enabled servers, to support "multiworld," the server creates multiple Worlds, each with a single dimension. In this file, the World objects act as an interface for RegionSet objects. The RegionSet objects are what's really important and are used for reading block data for rendering. A RegionSet object will always correspond to a set of region files, or what is colloquially referred to as a "world," or more accurately as a dimension. The only thing this class actually stores is a list of RegionSet objects and the parsed level.dat data """ def __init__(self, worlddir): self.worlddir = worlddir # This list, populated below, will hold RegionSet files that are in # this world self.regionsets = [] # The level.dat file defines a minecraft world, so assert that this # object corresponds to a world on disk if not os.path.exists(os.path.join(self.worlddir, "level.dat")): raise ValueError("level.dat not found in %s" % self.worlddir) # Hard-code this to only work with format version 19133, "Anvil" data = nbt.load(os.path.join(self.worlddir, "level.dat"))[1]['Data'] if not ('version' in data and data['version'] == 19133): logging.critical("Sorry, This version of Minecraft-Overviewer only works with the 'Anvil' chunk format") raise ValueError("World at %s is not compatible with Overviewer" % self.worlddir) # This isn't much data, around 15 keys and values for vanilla worlds. self.leveldat = data # Scan worlddir to try to identify all region sets. Since different # server mods like to arrange regions differently and there does not # seem to be any set standard on what dimensions are in each world, # just scan the directory heirarchy to find a directory with .mca # files. for root, dirs, files in os.walk(self.worlddir): # any .mcr files in this directory? mcas = [x for x in files if x.endswith(".mca")] if mcas: # construct a regionset object for this rset = RegionSet(root) if root == os.path.join(self.worlddir, "region"): self.regionsets.insert(0, rset) else: self.regionsets.append(rset) # TODO move a lot of the following code into the RegionSet try: # level.dat should have the LevelName attribute so we'll use that self.name = data['LevelName'] except KeyError: # but very old ones might not? so we'll just go with the world dir name if they don't self.name = os.path.basename(os.path.realpath(self.worlddir)) # TODO figure out where to handle regionlists def get_regionsets(self): return self.regionsets def get_regionset(self, index): if type(index) == int: return self.regionsets[index] else: # assume a string constant if index == "default": return self.regionsets[0] else: candids = [x for x in self.regionsets if x.get_type() == index] if len(candids) > 0: return candids[0] else: return None def get_level_dat_data(self): # Return a copy return dict(self.data) def find_true_spawn(self): """Returns the spawn point for this world. Since there is one spawn point for a world across all dimensions (RegionSets), this method makes sense as a member of the World class. Returns (x, y, z) """ # The spawn Y coordinate is almost always the default of 64. Find the # first air block above the stored spawn location for the true spawn # location ## read spawn info from level.dat data = self.data disp_spawnX = spawnX = data['SpawnX'] spawnY = data['SpawnY'] disp_spawnZ = spawnZ = data['SpawnZ'] ## The chunk that holds the spawn location chunkX = spawnX//16 chunkZ = spawnZ//16 ## clamp spawnY to a sane value, in-chunk value if spawnY < 0: spawnY = 0 if spawnY > 127: spawnY = 127 # Open up the chunk that the spawn is in regionset = self.get_regionset(0) try: chunk = regionset.get_chunk(chunkX, chunkZ) except ChunkDoesntExist: return (spawnX, spawnY, spawnZ) blockArray = chunk['Blocks'] ## The block for spawn *within* the chunk inChunkX = spawnX - (chunkX*16) inChunkZ = spawnZ - (chunkZ*16) ## find the first air block while (blockArray[inChunkX, inChunkZ, spawnY] != 0) and spawnY < 127: spawnY += 1 return spawnX, spawnY, spawnZ class RegionSet(object): """This object is the gateway to a particular Minecraft dimension within a world. It corresponds to a set of region files containing the actual world data. This object has methods for parsing and returning data from the chunks from its regions. See the docs for the World object for more information on the difference between Worlds and RegionSets. """ def __init__(self, regiondir, cachesize=16): """Initialize a new RegionSet to access the region files in the given directory. regiondir is a path to a directory containing region files. cachesize, if specified, is the number of chunks to keep parsed and in-memory. """ self.regiondir = os.path.normpath(regiondir) logging.debug("Scanning regions") # This is populated below. It is a mapping from (x,y) region coords to filename self.regionfiles = {} for x, y, regionfile in self._iterate_regionfiles(): # regionfile is a pathname self.regionfiles[(x,y)] = regionfile self.empty_chunk = [None,None] logging.debug("Done scanning regions") # Caching implementaiton: a simple LRU cache # Decorate the getter methods with the cache decorator self._get_biome_data_for_region = cache.lru_cache(cachesize)(self._get_biome_data_for_region) self.get_chunk = cache.lru_cache(cachesize)(self.get_chunk) # Re-initialize upon unpickling def __getstate__(self): return self.regiondir __setstate__ = __init__ def __repr__(self): return "" % self.regiondir def get_type(self): """Attempts to return a string describing the dimension represented by this regionset. Either "nether", "end" or "overworld" """ # path will be normalized in __init__ if self.regiondir.endswith(os.path.normpath("/DIM-1/region")): return "nether" elif self.regiondir.endswith(os.path.normpath("/DIM1/region")): return "end" elif self.regiondir.endswith(os.path.normpath("/region")): return "overworld" else: raise Exception("Woah, what kind of dimension is this! %r" % self.regiondir) # this is decorated with cache.lru_cache in __init__(). Be aware! @log_other_exceptions def _get_biome_data_for_region(self, regionx, regionz): """Get the block of biome data for an entire region. Biome data is in the format output by Minecraft Biome Extractor: http://code.google.com/p/minecraft-biome-extractor/""" # biomes only make sense for the overworld, right now if self.get_type() != "overworld": raise BiomeDataDoesntExist("Biome data is not available for '%s'." % (self.get_type(),)) # biomes are, unfortunately, in a different place than regiondir biomefile = os.path.split(self.regiondir)[0] biomefile = os.path.join(biomefile, 'biomes', 'b.%d.%d.biome' % (regionx, regionz)) try: with open(biomefile, 'rb') as f: data = f.read() if not len(data) == 512 * 512 * 2: raise BiomeDataDoesntExist("File `%s' does not have correct size." % (biomefile,)) data = numpy.frombuffer(data, dtype=numpy.dtype(">u2")) # reshape and transpose to get [x, z] indices return numpy.transpose(numpy.reshape(data, (512, 512))) except IOError: raise BiomeDataDoesntExist("File `%s' could not be read." % (biomefile,)) @log_other_exceptions def get_biome_data(self, x, z): """Get the block of biome data for the given chunk. Biome data is returned as a 16x16 numpy array of indices into the corresponding biome color images.""" regionx = x // 32 regionz = z // 32 blockx = (x % 32) * 16 blockz = (z % 32) * 16 region_biomes = self._get_biome_data_for_region(regionx, regionz) return region_biomes[blockx:blockx+16,blockz:blockz+16] # this is decorated with cache.lru_cache in __init__(). Be aware! @log_other_exceptions def get_chunk(self, x, z): """Returns a dictionary object representing the "Level" NBT Compound structure for a chunk given its x, z coordinates. The coordinates given are chunk coordinates. Raises ChunkDoesntExist exception if the given chunk does not exist. The returned dictionary corresponds to the "Level" structure in the chunk file, with a few changes: * The Biomes array is transformed into a 16x16 numpy array * For each chunk section: * The "Blocks" byte string is transformed into a 16x16x16 numpy array * The AddBlocks array, if it exists, is bitshifted left 8 bits and added into the Blocks array * The "SkyLight" byte string is transformed into a 16x16x128 numpy array * The "BlockLight" byte string is transformed into a 16x16x128 numpy array * The "Data" byte string is transformed into a 16x16x128 numpy array Warning: the returned data may be cached and thus should not be modified, lest it affect the return values of future calls for the same chunk. """ regionfile = self._get_region_path(x, z) if regionfile is None: raise ChunkDoesntExist("Chunk %s,%s doesn't exist (and neither does its region)" % (x,z)) region = nbt.load_region(regionfile) data = region.load_chunk(x, z) region.close() if data is None: raise ChunkDoesntExist("Chunk %s,%s doesn't exist" % (x,z)) level = data[1]['Level'] chunk_data = level # Turn the Biomes array into a 16x16 numpy array biomes = numpy.frombuffer(section['Biomes'], dtype=numpy.uint8) biomes = biomes.reshape((16,16)) section['Biomes'] = biomes for section in chunk_data['Sections']: # Turn the Blocks array into a 16x16x16 numpy matrix of shorts, # adding in the additional block array if included. blocks = numpy.frombuffer(section['Blocks'], dtype=numpy.uint8) # Cast up to uint16, blocks can have up to 12 bits of data blocks = blocks.astype(numpy.uint16) blocks.reshape((16,16,16)) if "AddBlocks" in section: # This section has additional bits to tack on to the blocks # array. AddBlocks is a packed array with 4 bits per slot, so # it needs expanding additional = numpy.frombuffer(section['AddBlocks'], dtype=numpy.uint8) additional = additional.astype(numpy.uint16).reshape((16,16,8)) additional_expanded = numpy.empty((16,16,16), dtype=numpy.uint16) additional_expanded[:,:,::2] = (additional & 0x0F) << 8 additional_expanded[:,:,1::2] = (additional & 0xF0) << 4 blocks += additional_expanded del additional del additional_expanded del section['AddBlocks'] # Save some memory section['Blocks'] = blocks # Turn the skylight array into a 16x16x16 matrix. The array comes # packed 2 elements per byte, so we need to expand it. skylight = numpy.frombuffer(section['SkyLight'], dtype=numpy.uint8) skylight = skylight.reshape((16,16,8)) skylight_expanded = numpy.empty((16,16,16), dtype=numpy.uint8) skylight_expanded[:,:,::2] = skylight & 0x0F skylight_expanded[:,:,1::2] = (skylight & 0xF0) >> 4 del skylight section['SkyLight'] = skylight_expanded # Turn the BlockLight array into a 16x16x16 matrix, same as SkyLight blocklight = numpy.frombuffer(section['BlockLight'], dtype=numpy.uint8) blocklight = blocklight.reshape((16,16,8)) blocklight_expanded = numpy.empty((16,16,16), dtype=numpy.uint8) blocklight_expanded[:,:,::2] = blocklight & 0x0F blocklight_expanded[:,:,1::2] = (blocklight & 0xF0) >> 4 del blocklight section['BlockLight'] = blocklight_expanded # Turn the Data array into a 16x16x16 matrix, same as SkyLight data = numpy.frombuffer(section['Data'], dtype=numpy.uint8) data = data.reshape((16,16,8)) data_expanded = numpy.empty((16,16,16), dtype=numpy.uint8) data_expanded[:,:,::2] = data & 0x0F data_expanded[:,:,1::2] = (data & 0xF0) >> 4 del data section['Data'] = data_expanded return chunk_data def rotate(self, north_direction): return RotatedRegionSet(self.regiondir, north_direction) def iterate_chunks(self): """Returns an iterator over all chunk metadata in this world. Iterates over tuples of integers (x,z,mtime) for each chunk. Other chunk data is not returned here. """ for (regionx, regiony), regionfile in self.regionfiles.iteritems(): mcr = nbt.load_region(regionfile) for chunkx, chunky in mcr.get_chunks(): yield chunkx+32*regionx, chunky+32*regiony, mcr.get_chunk_timestamp(chunkx, chunky) def get_chunk_mtime(self, x, z): """Returns a chunk's mtime, or False if the chunk does not exist. This is therefore a dual purpose method. It corrects for the given north direction as described in the docs for get_chunk() """ regionfile = self._get_region_path(x,z) if regionfile is None: return None data = nbt.load_region(regionfile) if data.chunk_exists(x,z): return data.get_chunk_timestamp(x,z) return None def _get_region_path(self, chunkX, chunkY): """Returns the path to the region that contains chunk (chunkX, chunkY) Coords can be either be global chunk coords, or local to a region """ regionfile = self.regionfiles.get((chunkX//32, chunkY//32),None) return regionfile def _iterate_regionfiles(self): """Returns an iterator of all of the region files, along with their coordinates Returns (regionx, regiony, filename)""" logging.debug("regiondir is %s", self.regiondir) for path in glob(self.regiondir + "/r.*.*.mca"): dirpath, f = os.path.split(path) p = f.split(".") x = int(p[1]) y = int(p[2]) yield (x, y, path) # see RegionSet.rotate. These values are chosen so that they can be # passed directly to rot90; this means that they're the number of # times to rotate by 90 degrees CCW UPPER_LEFT = 0 ## - Return the world such that north is down the -Z axis (no rotation) UPPER_RIGHT = 1 ## - Return the world such that north is down the +X axis (rotate 90 degrees counterclockwise) LOWER_RIGHT = 2 ## - Return the world such that north is down the +Z axis (rotate 180 degrees) LOWER_LEFT = 3 ## - Return the world such that north is down the -X axis (rotate 90 degrees clockwise) class RotatedRegionSet(RegionSet): """A regionset, only rotated such that north points in the given direction """ # some class-level rotation constants _NO_ROTATION = lambda x,z: (x,z) _ROTATE_CLOCKWISE = lambda x,z: (-z,x) _ROTATE_COUNTERCLOCKWISE = lambda x,z: (z,-x) _ROTATE_180 = lambda x,z: (-x,-z) # These take rotated coords and translate into un-rotated coords _unrotation_funcs = { 0: _NO_ROTATION, 1: _ROTATE_COUNTERCLOCKWISE, 2: _ROTATE_180, 3: _ROTATE_CLOCKWISE, } # These translate un-rotated coordinates into rotated coordinates _rotation_funcs = { 0: _NO_ROTATION, 1: _ROTATE_CLOCKWISE, 2: _ROTATE_180, 3: _ROTATE_COUNTERCLOCKWISE, } def __init__(self, regiondir, north_dir): self.north_dir = north_dir self.unrotate = self._unrotation_funcs[north_dir] self.rotate = self._rotation_funcs[north_dir] super(RotatedRegionSet, self).__init__(regiondir) # Re-initialize upon unpickling def __getstate__(self): return (self.regiondir, self.north_dir) def __setstate__(self, args): self.__init__(args[0], args[1]) def get_biome_data(self, x, z): x,z = self.unrotate(x,z) biome_data = super(RotatedRegionSet, self).get_biome_data(x,z) return numpy.rot90(biome_data, self.north_dir) def get_chunk(self, x, z): x,z = self.unrotate(x,z) chunk_data = super(RotatedRegionSet, self).get_chunk(x,z) chunk_data['Blocks'] = numpy.rot90(chunk_data['Blocks'], self.north_dir) chunk_data['Data'] = numpy.rot90(chunk_data['Data'], self.north_dir) chunk_data['SkyLight'] = numpy.rot90(chunk_data['SkyLight'], self.north_dir) chunk_data['BlockLight'] = numpy.rot90(chunk_data['BlockLight'], self.north_dir) return chunk_data def get_chunk_mtime(self, x, z): x,z = self.unrotate(x,z) return super(RotatedRegionSet, self).get_chunk_mtime(x, z) def iterate_chunks(self): for x,z,mtime in super(RotatedRegionSet, self).iterate_chunks(): x,z = self.rotate(x,z) yield x,z,mtime def get_save_dir(): """Returns the path to the local saves directory * On Windows, at %APPDATA%/.minecraft/saves/ * On Darwin, at $HOME/Library/Application Support/minecraft/saves/ * at $HOME/.minecraft/saves/ """ savepaths = [] if "APPDATA" in os.environ: savepaths += [os.path.join(os.environ['APPDATA'], ".minecraft", "saves")] if "HOME" in os.environ: savepaths += [os.path.join(os.environ['HOME'], "Library", "Application Support", "minecraft", "saves")] savepaths += [os.path.join(os.environ['HOME'], ".minecraft", "saves")] for path in savepaths: if os.path.exists(path): return path def get_worlds(): "Returns {world # or name : level.dat information}" ret = {} save_dir = get_save_dir() # No dirs found - most likely not running from inside minecraft-dir if save_dir is None: return None for dir in os.listdir(save_dir): world_dat = os.path.join(save_dir, dir, "level.dat") if not os.path.exists(world_dat): continue info = nbt.load(world_dat)[1] info['Data']['path'] = os.path.join(save_dir, dir) if dir.startswith("World") and len(dir) == 6: try: world_n = int(dir[-1]) ret[world_n] = info['Data'] except ValueError: pass if 'LevelName' in info['Data'].keys(): ret[info['Data']['LevelName']] = info['Data'] return ret