class AnvilWorldAdapter(object): """ Provides an interface to AnvilWorldFolder/RevisionHistory that is usable by WorldEditor This interface is the base used for all adapter classes. When writing a new adapter, make sure to implement all required methods and attributes. Required methods and attrs are the ones with docstrings. """ minHeight = 0 maxHeight = 256 blocktypes = pc_blocktypes hasLights = True EntityRef = PCEntityRef TileEntityRef = PCTileEntityRef def __init__(self, filename=None, create=False, readonly=False, resume=None): """ Load a Minecraft for PC level (Anvil format) from the given filename. It can point to either a level.dat or a folder containing one. If create is True, it will also create the world using a randomly selected seed. If you try to create an existing world, IOError will be raised. Uses a RevisionHistory to manage undo history. Upon creation, the world is read-only until createRevision() is called. Call createRevision() to create a new revision, or selectRevision() to revert to an earlier revision. Older revisions are read-only, so createRevision() must be called again to make further changes. Call writeAllChanges() to write all changes into the original world. :type filename: str or unicode :type create: bool :type readonly: bool :rtype: AnvilWorldAdapter """ self.lockTime = 0 assert not (create and readonly) if os.path.basename(filename) in ("level.dat", "level.dat_old"): filename = os.path.dirname(filename) if not os.path.exists(filename): if not create: raise IOError('File not found') os.mkdir(filename) else: if create: if not os.path.isdir(filename) or os.path.exists(os.path.join(filename, "level.dat")): raise IOError('File exists!') if not os.path.isdir(filename): raise IOError('File is not a Minecraft Anvil world') if readonly: self.revisionHistory = AnvilWorldFolder(filename) self.selectedRevision = self.revisionHistory else: self.revisionHistory = RevisionHistory(filename, resume) self.selectedRevision = self.revisionHistory.getHead() self.filename = filename self.readonly = readonly if not readonly: self.acquireSessionLock() if create: self._createMetadataTag() self.selectedRevision.writeFile("level.dat", self.metadata.metadataTag.save()) else: self.loadMetadata() def __repr__(self): return "AnvilWorldAdapter(%r)" % self.filename # --- Create, save, close --- def loadMetadata(self): try: metadataTag = nbt.load(buf=self.selectedRevision.readFile("level.dat")) self.metadata = AnvilWorldMetadata(metadataTag) self.loadFMLMapping() except (EnvironmentError, zlib.error) as e: log.info("Error loading level.dat, trying level.dat_old ({0})".format(e)) try: metadataTag = nbt.load(buf=self.selectedRevision.readFile("level.dat_old")) self.metadata = AnvilWorldMetadata(metadataTag) log.info("level.dat restored from backup.") self.saveChanges() except Exception as e: traceback.print_exc() log.info("%r while loading level.dat_old. Initializing with defaults.", e) self._createMetadataTag() assert self.metadata.version == VERSION_ANVIL, "Pre-Anvil world formats are not supported (for now)" def loadFMLMapping(self): metadataTag = self.metadata.metadataTag fml = metadataTag.get('FML') if fml is None: return mid = fml.get('ModItemData') # MC 1.6 if mid is not None: log.info("Adding block IDs from FML for MC 1.6") blocktypes = PCBlockTypeSet() for entry in mid: ID = entry['ItemId'].value meta = entry['ordinal'].value name = entry['ItemType'].value if (ID, 0) not in blocktypes.statesByID: blocktypes.IDsByState[name] = ID, meta blocktypes.statesByID[ID, meta] = name blocktypes.blockJsons[name] = { 'displayName':name, 'internalName':name, 'blockState':'', } blocktypes.allBlocks.append(BlockType(ID, meta, blocktypes)) blocktypes.allBlocks.sort() self.blocktypes = blocktypes itemdata = fml.get('ItemData') # MC 1.7 if itemdata is not None: count = 0 log.info("Adding block IDs from FML for MC 1.7") blocktypes = PCBlockTypeSet() for entry in itemdata: ID = entry['V'].value name = entry['K'].value if (ID, 0) not in blocktypes.statesByID: count += 1 blocktypes.IDsByState[name] = ID, 0 blocktypes.statesByID[ID, 0] = name blocktypes.blockJsons[name] = { 'displayName':name, 'internalName':name, 'blockState':'', } blocktypes.allBlocks.append(BlockType(ID, 0, blocktypes)) blocktypes.allBlocks.sort() log.info("Added %d blocks.", count) self.blocktypes = blocktypes def _createMetadataTag(self, random_seed=None): """ Create a level.dat for a newly created world or a world found with damaged level.dat/.dat_old (xxx repair in WorldEditor?) :param random_seed: :type random_seed: :return: :rtype: """ metadataTag = nbt.TAG_Compound() metadataTag["Data"] = nbt.TAG_Compound() metadataTag["Data"]["SpawnX"] = nbt.TAG_Int(0) metadataTag["Data"]["SpawnY"] = nbt.TAG_Int(2) metadataTag["Data"]["SpawnZ"] = nbt.TAG_Int(0) last_played = long(time.time() * 1000) if random_seed is None: random_seed = long(random.random() * 0xffffffffffffffffL) - 0x8000000000000000L metadataTag["Data"]['version'] = nbt.TAG_Int(VERSION_ANVIL) self.metadata = AnvilWorldMetadata(metadataTag) self.metadata.LastPlayed = long(last_played) self.metadata.RandomSeed = long(random_seed) self.metadata.SizeOnDisk = 0 self.metadata.Time = 1 self.metadata.LevelName = os.path.basename(self.filename) def syncToDisk(self): """ Write cached items (metadata from level.dat and players in players/ folder) to the current revision. :return: :rtype: """ if self.metadata.dirty: self.selectedRevision.writeFile("level.dat", self.metadata.metadataTag.save()) self.metadata.dirty = False def saveChanges(self): """ Write all changes from all revisions into the world folder. :return: :rtype: None """ if self.readonly: raise IOError("World is opened read only.") self.checkSessionLock() self.revisionHistory.writeAllChanges(self.selectedRevision) self.selectedRevision = self.revisionHistory.getHead() def close(self): """ Close the world, deleting temporary files and freeing resources. Operations on a closed world are undefined. :return: :rtype: None """ self.revisionHistory.close() pass # do what here??? # --- Undo revisions --- def requireRevisions(self): """ Enforce the creation of new revisions by making the world folder's revision read-only. :return: :rtype: """ self.revisionHistory.rootNode.readOnly = True def createRevision(self): """ Create a new undo revision. Subsequent changes should be stored in the new revision. :return: :rtype: """ self.selectedRevision = self.revisionHistory.createRevision(self.selectedRevision) def closeRevision(self): """ Close the current revision and mark it read-only. Subsequent edits will not be possible until createRevision is called. :return: :rtype: """ self.revisionHistory.closeRevision() def setRevisionInfo(self, info): """ Attach some arbitrary JSON-serializable data to the current revision :param info: JSON-serializable data :type info: list | dict | str | unicode | int """ self.selectedRevision.setRevisionInfo(info) def getRevisionInfo(self): """ Return JSON-serializable data attached previously to the current revision via setRevisionInfo, or None if no data is attached. :return: :rtype: list | dict | str | unicode | int | None """ return self.selectedRevision.getRevisionInfo() def selectRevision(self, index): """ Select the current revision by index. Return changes between the previous revision and this one. If the index is invalid, returns None and does nothing. (XXX use an ID instead of index?) :param index: :type index: :return: :rtype: RevisionChanges """ if index < 0 or index >= len(self.revisionHistory.nodes): return None newRevision = self.revisionHistory.getRevision(index) changes = self.revisionHistory.getRevisionChanges(self.selectedRevision, newRevision) self.selectedRevision = newRevision self.loadMetadata() return changes def listRevisions(self): """ List the revision indexes and infos as (index, info) tuples. Info is JSON-serializable data previously attached with setRevisionInfo, or None. :return: :rtype: iterator[(int, list | dict | str | unicode | int)] """ for ID, node in enumerate(self.revisionHistory.nodes): yield ID, node.getRevisionInfo() # --- Session lock --- def acquireSessionLock(self): """ Acquire the world's session.lock. Formats without this file may do nothing. :return: :rtype: """ lockfile = self.revisionHistory.rootFolder.getFilePath("session.lock") self.lockTime = int(time.time() * 1000) with file(lockfile, "wb") as f: f.write(struct.pack(">q", self.lockTime)) f.flush() os.fsync(f.fileno()) def checkSessionLock(self): """ Make sure the lock previously acquired by acquireSessionLock is still valid. Raise SessionLockLost if it is not. Raising the exception will abort any writes done to the main world folder. :return: :rtype: """ if self.readonly: raise SessionLockLost("World is opened read only.") lockfile = self.revisionHistory.rootFolder.getFilePath("session.lock") try: (lock, ) = struct.unpack(">q", file(lockfile, "rb").read()) except struct.error: lock = -1 if lock != self.lockTime: raise SessionLockLost("Session lock lost. This world is being accessed from another location.") # --- Format detection --- @classmethod def canOpenFile(cls, filename): """ Ask this adapter if it can open the given file. :param filename: File to identify :type filename: str | unicode :return: :rtype: boolean """ if os.path.exists(os.path.join(filename, "chunks.dat")): return False # exclude Pocket Edition folders if not os.path.isdir(filename): f = os.path.basename(filename) if f not in ("level.dat", "level.dat_old"): return False filename = os.path.dirname(filename) files = os.listdir(filename) if "level.dat" in files or "level.dat_old" in files: return True return False # --- Dimensions --- def listDimensions(self): """ List the names of all dimensions in this world. :return: :rtype: iterator of str """ return self.selectedRevision.listDimensions() # --- Chunks --- def chunkCount(self, dimName): """ Count the chunks in the given dimension :param dimName: :type dimName: str :return: :rtype: int """ return self.selectedRevision.chunkCount(dimName) def chunkPositions(self, dimName): """ List the chunk positions (cx, cz) in the given dimension. :type dimName: unicode or str :return: :rtype: Iterator of (int, int) """ return iter(self.selectedRevision.chunkPositions(dimName)) def containsChunk(self, cx, cz, dimName): """ Return whether the given chunk is present in the given dimension :type cx: int or dtype :type cz: int or dtype :type dimName: str :return: :rtype: bool """ return self.selectedRevision.containsChunk(cx, cz, dimName) def readChunk(self, cx, cz, dimName): """ Return chunk (cx, cz) in the given dimension as an AnvilChunkData. Raise ChunkNotPresent if not found. :type cx: int or dtype :type cz: int or dtype :type dimName: str :return: :rtype: AnvilChunkData """ try: data = self.selectedRevision.readChunkBytes(cx, cz, dimName) chunkTag = nbt.load(buf=data) log.debug("_getChunkData: Chunk %s loaded (%s bytes)", (cx, cz), len(data)) chunkData = AnvilChunkData(self, cx, cz, dimName, chunkTag) except ChunkNotPresent: raise except (KeyError, IndexError, zlib.error) as e: # Missing nbt keys, lists too short, decompression failure raise AnvilChunkFormatError("Error loading chunk: %r" % e) return chunkData def writeChunk(self, chunk): """ Write the given AnvilChunkData to the current revision. :type chunk: mceditlib.anvil.adapter.AnvilChunkData """ tag = chunk.buildNBTTag() self.selectedRevision.writeChunkBytes(chunk.cx, chunk.cz, chunk.dimName, tag.save(compressed=False)) def createChunk(self, cx, cz, dimName): """ Create a new empty chunk at the given position in the given dimension. :type cx: int :type cz: int :type dimName: str :return: :rtype: AnvilChunkData """ if self.selectedRevision.containsChunk(cx, cz, dimName): raise ValueError("Chunk %s already exists in dim %s", (cx, cz), dimName) chunk = AnvilChunkData(self, cx, cz, dimName, create=True) self.selectedRevision.writeChunkBytes(cx, cz, dimName, chunk.buildNBTTag().save(compressed=False)) return chunk def deleteChunk(self, cx, cz, dimName): """ Delete the chunk at the given position in the given dimension. :type cx: int :type cz: int :type dimName: str """ self.selectedRevision.deleteChunk(cx, cz, dimName) # --- Players --- def listPlayers(self): """ List the names of all players in this world (XXX players folder in dimension folders??) :return: :rtype: Iterator of [str] """ for f in self.selectedRevision.listFolder("playerdata"): if f.endswith(".dat"): yield f[11:-4] if "Player" in self.metadata.rootTag: yield "" def getPlayer(self, playerUUID=""): return AnvilPlayerRef(self, playerUUID) def getPlayerTag(self, playerUUID=""): """ Return the root NBT tag for the named player. Raise PlayerNotFound if not present. :param playerUUID: :type playerUUID: unicode :return: :rtype: PCPlayer """ if playerUUID == "": if "Player" in self.metadata.rootTag: # single-player world playerTag = self.metadata.rootTag["Player"] return playerTag raise PlayerNotFound(playerUUID) else: playerFilePath = "playerdata/%s.dat" % playerUUID if self.selectedRevision.containsFile(playerFilePath): # multiplayer world, found this player playerTag = nbt.load(buf=self.selectedRevision.readFile(playerFilePath)) return playerTag else: raise PlayerNotFound(playerUUID) def savePlayerTag(self, tag, playerUUID): if playerUUID == "": # sync metadata? self.metadata.dirty = True else: self.selectedRevision.writeFile("playerdata/%s.dat" % playerUUID, tag.save()) def createPlayer(self, playerUUID=""): """ Create a new player with the given name and return the PlayerRef. Raises some kind of IOError if the player could not be created. :param playerUUID: :type playerUUID: str :return: :rtype: PCPlayer """ if self.readonly: raise IOError("World is opened read only.") playerFilePath = "playerdata/%s.dat" % playerUUID if playerUUID == "": if "Player" in self.metadata.rootTag["Data"]: raise IOError("Single-player player already exists.") playerTag = nbt.TAG_Compound() self.metadata.rootTag["Data"]["Player"] = playerTag else: if self.selectedRevision.containsFile(playerFilePath): raise ValueError("Cannot create player %s: already exists.") playerTag = nbt.TAG_Compound() player = AnvilPlayerRef(playerTag, self) nbtattr.SetNBTDefaults(player) if playerUUID != "Player": self.checkSessionLock() self.selectedRevision.writeFile(playerFilePath, playerTag.save()) return self.getPlayer(playerUUID)
class AnvilWorldAdapter(object): """ Provides an interface to AnvilWorldFolder/RevisionHistory that is usable by WorldEditor This interface is the base used for all adapter classes. When writing a new adapter, make sure to implement all required methods and attributes. Required methods and attrs are the ones with docstrings. """ minHeight = 0 maxHeight = 256 hasLights = True def __init__(self, filename=None, create=False, readonly=False, resume=None): """ Load a Minecraft for PC level (Anvil format) from the given filename. It can point to either a level.dat or a folder containing one. If create is True, it will also create the world using a randomly selected seed. If you try to create an existing world, IOError will be raised. Uses a RevisionHistory to manage undo history. Upon creation, the world is read-only until createRevision() is called. Call createRevision() to create a new revision, or selectRevision() to revert to an earlier revision. Older revisions are read-only, so createRevision() must be called again to make further changes. Call writeAllChanges() to write all changes into the original world. :type filename: str or unicode :type create: bool :type readonly: bool :rtype: AnvilWorldAdapter """ self.lockTime = 0 self.EntityRef = PCEntityRef self.TileEntityRef = PCTileEntityRef assert not (create and readonly) if os.path.basename(filename) in ("level.dat", "level.dat_old"): filename = os.path.dirname(filename) if not os.path.exists(filename): if not create: raise IOError('File not found') os.mkdir(filename) else: if create: if not os.path.isdir(filename) or os.path.exists(os.path.join(filename, "level.dat")): raise IOError('File exists!') if not os.path.isdir(filename): raise IOError('File is not a Minecraft Anvil world') if readonly: self.revisionHistory = AnvilWorldFolder(filename) self.selectedRevision = self.revisionHistory else: self.revisionHistory = RevisionHistory(filename, resume) self.selectedRevision = self.revisionHistory.getHead() self.filename = filename self.readonly = readonly if not readonly: self.acquireSessionLock() if create: self._createMetadataTag() self.selectedRevision.writeFile("level.dat", self.metadata.metadataTag.save()) else: self.loadMetadata() def __repr__(self): return "AnvilWorldAdapter(%r)" % self.filename # --- Create, save, close --- def loadMetadata(self): try: metadataTag = nbt.load(buf=self.selectedRevision.readFile("level.dat")) self.metadata = AnvilWorldMetadata(metadataTag) self.loadBlockMapping() except (EnvironmentError, zlib.error, NBTFormatError) as e: log.info("Error loading level.dat, trying level.dat_old ({0})".format(e)) try: metadataTag = nbt.load(buf=self.selectedRevision.readFile("level.dat_old")) self.metadata = AnvilWorldMetadata(metadataTag) self.metadata.dirty = True log.info("level.dat restored from backup.") except Exception as e: traceback.print_exc() log.info("%r while loading level.dat_old. Initializing with defaults.", e) self._createMetadataTag() assert self.metadata.version == VERSION_ANVIL, "Pre-Anvil world formats are not supported (for now)" def loadBlockMapping(self): if self.metadata.is1_8World(): itemStackVersion = VERSION_1_8 else: itemStackVersion = VERSION_1_7 blocktypes = PCBlockTypeSet(itemStackVersion) self.blocktypes = blocktypes metadataTag = self.metadata.metadataTag fml = metadataTag.get('FML') if fml is None: return itemTypes = blocktypes.itemTypes itemdata = fml.get('ItemData') # MC 1.7 if itemdata is not None: count = 0 log.info("Adding block IDs from FML for MC 1.7") replacedIDs = [] for entry in itemdata: ID = entry['V'].value name = entry['K'].value magic, name = name[0], name[1:] if magic == u'\x01': # 0x01 = blocks if not name.startswith("minecraft:"): # we load 1.8 block IDs and mappings by default # FML IDs should be allowed to override some of them for 1.8 blocks not in 1.7. count += 1 replacedIDs.append(ID) fakeState = '[0]' nameAndState = name + fakeState log.debug("FML1.7: Adding %s = %d", name, ID) for vanillaMeta in range(15): # Remove existing Vanilla defs vanillaNameAndState = blocktypes.statesByID.get((ID, vanillaMeta)) blocktypes.blockJsons.pop(vanillaNameAndState, None) blocktypes.IDsByState[nameAndState] = ID, 0 blocktypes.statesByID[ID, 0] = nameAndState blocktypes.IDsByName[name] = ID blocktypes.namesByID[ID] = name blocktypes.defaultBlockstates[name] = fakeState blocktypes.blockJsons[nameAndState] = { 'displayName': name, 'internalName': name, 'blockState': '[0]', 'unknown': True, } if magic == u'\x02': # 0x02 = items if not name.startswith("minecraft:"): itemTypes.addFMLIDMapping(name, ID) replacedIDsSet = set(replacedIDs) blocktypes.allBlocks[:] = [b for b in blocktypes if b.ID not in replacedIDsSet] blocktypes.allBlocks.extend(BlockType(newID, 0, blocktypes) for newID in replacedIDs) blocktypes.allBlocks.sort() log.info("Added %d blocks.", count) def _createMetadataTag(self, random_seed=None): """ Create a level.dat for a newly created world or a world found with damaged level.dat/.dat_old (xxx repair in WorldEditor?) :param random_seed: :type random_seed: :return: :rtype: """ metadataTag = nbt.TAG_Compound() metadataTag["Data"] = nbt.TAG_Compound() metadataTag["Data"]["SpawnX"] = nbt.TAG_Int(0) metadataTag["Data"]["SpawnY"] = nbt.TAG_Int(2) metadataTag["Data"]["SpawnZ"] = nbt.TAG_Int(0) last_played = long(time.time() * 1000) if random_seed is None: random_seed = long(random.random() * 0xffffffffffffffffL) - 0x8000000000000000L metadataTag["Data"]['version'] = nbt.TAG_Int(VERSION_ANVIL) self.metadata = AnvilWorldMetadata(metadataTag) self.metadata.LastPlayed = long(last_played) self.metadata.RandomSeed = long(random_seed) self.metadata.SizeOnDisk = 0 self.metadata.Time = 1 self.metadata.LevelName = os.path.basename(self.filename) def syncToDisk(self): """ Write cached items (metadata from level.dat and players in players/ folder) to the current revision. :return: :rtype: """ if self.metadata.dirty: self.selectedRevision.writeFile("level.dat", self.metadata.metadataTag.save()) self.metadata.dirty = False def saveChanges(self): """ Write all changes from all revisions into the world folder. :return: :rtype: None """ if self.readonly: raise IOError("World is opened read only.") self.checkSessionLock() self.revisionHistory.writeAllChanges(self.selectedRevision) self.selectedRevision = self.revisionHistory.getHead() def close(self): """ Close the world, deleting temporary files and freeing resources. Operations on a closed world are undefined. :return: :rtype: None """ self.revisionHistory.close() pass # do what here??? # --- Undo revisions --- def requireRevisions(self): """ Enforce the creation of new revisions by making the world folder's revision read-only. :return: :rtype: """ self.revisionHistory.rootNode.readOnly = True def createRevision(self): """ Create a new undo revision. Subsequent changes should be stored in the new revision. :return: :rtype: """ self.selectedRevision = self.revisionHistory.createRevision(self.selectedRevision) def closeRevision(self): """ Close the current revision and mark it read-only. Subsequent edits will not be possible until createRevision is called. :return: :rtype: """ self.revisionHistory.closeRevision() def setRevisionInfo(self, info): """ Attach some arbitrary JSON-serializable data to the current revision :param info: JSON-serializable data :type info: list | dict | str | unicode | int """ self.selectedRevision.setRevisionInfo(info) def getRevisionInfo(self): """ Return JSON-serializable data attached previously to the current revision via setRevisionInfo, or None if no data is attached. :return: :rtype: list | dict | str | unicode | int | None """ return self.selectedRevision.getRevisionInfo() def selectRevision(self, index): """ Select the current revision by index. Return changes between the previous revision and this one. If the index is invalid, returns None and does nothing. (XXX use an ID instead of index?) :param index: :type index: :return: :rtype: RevisionChanges """ if index < 0 or index >= len(self.revisionHistory.nodes): return None newRevision = self.revisionHistory.getRevision(index) changes = self.revisionHistory.getRevisionChanges(self.selectedRevision, newRevision) self.selectedRevision = newRevision self.loadMetadata() return changes def listRevisions(self): """ List the revision indexes and infos as (index, info) tuples. Info is JSON-serializable data previously attached with setRevisionInfo, or None. :return: :rtype: iterator[(int, list | dict | str | unicode | int)] """ for ID, node in enumerate(self.revisionHistory.nodes): yield ID, node.getRevisionInfo() # --- Session lock --- def acquireSessionLock(self): """ Acquire the world's session.lock. Formats without this file may do nothing. :return: :rtype: """ lockfile = self.revisionHistory.rootFolder.getFilePath("session.lock") self.lockTime = int(time.time() * 1000) with file(lockfile, "wb") as f: f.write(struct.pack(">q", self.lockTime)) f.flush() os.fsync(f.fileno()) def checkSessionLock(self): """ Make sure the lock previously acquired by acquireSessionLock is still valid. Raise SessionLockLost if it is not. Raising the exception will abort any writes done to the main world folder. :return: :rtype: """ if self.readonly: raise SessionLockLost("World is opened read only.") lockfile = self.revisionHistory.rootFolder.getFilePath("session.lock") try: (lock, ) = struct.unpack(">q", file(lockfile, "rb").read()) except struct.error: lock = -1 if lock != self.lockTime: raise SessionLockLost("Session lock lost. This world is being accessed from another location.") # --- Format detection --- @classmethod def canOpenFile(cls, filename): """ Ask this adapter if it can open the given file. :param filename: File to identify :type filename: str | unicode :return: :rtype: boolean """ if os.path.exists(os.path.join(filename, "chunks.dat")): return False # exclude Pocket Edition folders if not os.path.isdir(filename): f = os.path.basename(filename) if f not in ("level.dat", "level.dat_old"): return False filename = os.path.dirname(filename) files = os.listdir(filename) if "level.dat" in files or "level.dat_old" in files: return True return False # --- Dimensions --- def listDimensions(self): """ List the names of all dimensions in this world. :return: :rtype: iterator of str """ return self.selectedRevision.listDimensions() # --- Chunks --- def chunkCount(self, dimName): """ Count the chunks in the given dimension :param dimName: :type dimName: str :return: :rtype: int """ return self.selectedRevision.chunkCount(dimName) def chunkPositions(self, dimName): """ List the chunk positions (cx, cz) in the given dimension. :type dimName: unicode or str :return: :rtype: Iterator of (int, int) """ return iter(self.selectedRevision.chunkPositions(dimName)) def containsChunk(self, cx, cz, dimName): """ Return whether the given chunk is present in the given dimension :type cx: int or dtype :type cz: int or dtype :type dimName: str :return: :rtype: bool """ return self.selectedRevision.containsChunk(cx, cz, dimName) def readChunk(self, cx, cz, dimName): """ Return chunk (cx, cz) in the given dimension as an AnvilChunkData. Raise ChunkNotPresent if not found. :type cx: int or dtype :type cz: int or dtype :type dimName: str :return: :rtype: AnvilChunkData """ try: data = self.selectedRevision.readChunkBytes(cx, cz, dimName) chunkTag = nbt.load(buf=data) log.debug("_getChunkData: Chunk %s loaded (%s bytes)", (cx, cz), len(data)) chunkData = AnvilChunkData(self, cx, cz, dimName, chunkTag) except ChunkNotPresent: raise except (KeyError, IndexError, zlib.error) as e: # Missing nbt keys, lists too short, decompression failure raise AnvilChunkFormatError("Error loading chunk: %r" % e) return chunkData def writeChunk(self, chunk): """ Write the given AnvilChunkData to the current revision. :type chunk: mceditlib.anvil.adapter.AnvilChunkData """ tag = chunk.buildNBTTag() self.selectedRevision.writeChunkBytes(chunk.cx, chunk.cz, chunk.dimName, tag.save(compressed=False)) def createChunk(self, cx, cz, dimName): """ Create a new empty chunk at the given position in the given dimension. :type cx: int :type cz: int :type dimName: str :return: :rtype: AnvilChunkData """ if self.selectedRevision.containsChunk(cx, cz, dimName): raise ValueError("Chunk %s already exists in dim %s", (cx, cz), dimName) chunk = AnvilChunkData(self, cx, cz, dimName, create=True) self.selectedRevision.writeChunkBytes(cx, cz, dimName, chunk.buildNBTTag().save(compressed=False)) return chunk def deleteChunk(self, cx, cz, dimName): """ Delete the chunk at the given position in the given dimension. :type cx: int :type cz: int :type dimName: str """ self.selectedRevision.deleteChunk(cx, cz, dimName) # --- Players --- def listPlayers(self): """ List the names of all players in this world (XXX players folder in dimension folders??) :return: :rtype: Iterator of [str] """ for f in self.selectedRevision.listFolder("playerdata"): if f.endswith(".dat"): yield f[11:-4] if "Player" in self.metadata.rootTag: yield "" def getPlayer(self, playerUUID=""): return AnvilPlayerRef(self, playerUUID) def getPlayerTag(self, playerUUID=""): """ Return the root NBT tag for the named player. Raise PlayerNotFound if not present. :param playerUUID: :type playerUUID: unicode :return: :rtype: PCPlayer """ if playerUUID == "": if "Player" in self.metadata.rootTag: # single-player world playerTag = self.metadata.rootTag["Player"] return playerTag raise PlayerNotFound(playerUUID) else: playerFilePath = "playerdata/%s.dat" % playerUUID if self.selectedRevision.containsFile(playerFilePath): # multiplayer world, found this player playerTag = nbt.load(buf=self.selectedRevision.readFile(playerFilePath)) return playerTag else: raise PlayerNotFound(playerUUID) def savePlayerTag(self, tag, playerUUID): if playerUUID == "": # sync metadata? self.metadata.dirty = True else: self.selectedRevision.writeFile("playerdata/%s.dat" % playerUUID, tag.save()) def createPlayer(self, playerUUID=""): """ Create a new player with the given name and return the PlayerRef. Raises some kind of IOError if the player could not be created. :param playerUUID: :type playerUUID: str :return: :rtype: PCPlayer """ if self.readonly: raise IOError("World is opened read only.") playerFilePath = "playerdata/%s.dat" % playerUUID if playerUUID == "": if "Player" in self.metadata.rootTag["Data"]: raise IOError("Single-player player already exists.") playerTag = nbt.TAG_Compound() self.metadata.rootTag["Data"]["Player"] = playerTag else: if self.selectedRevision.containsFile(playerFilePath): raise ValueError("Cannot create player %s: already exists.") playerTag = nbt.TAG_Compound() player = AnvilPlayerRef(playerTag, self) nbtattr.SetNBTDefaults(player) if playerUUID != "Player": self.checkSessionLock() self.selectedRevision.writeFile(playerFilePath, playerTag.save()) return self.getPlayer(playerUUID)
class AnvilWorldAdapter(object): """ Provides an interface to AnvilWorldFolder/RevisionHistory that is usable by WorldEditor This interface is the base used for all adapter classes. When writing a new adapter, make sure to implement all required methods and attributes. Required methods and attrs are the ones with docstrings. """ minHeight = 0 maxHeight = 256 hasLights = True def __init__(self, filename=None, create=False, readonly=False, resume=None): """ Load a Minecraft for PC level (Anvil format) from the given filename. It can point to either a level.dat or a folder containing one. If create is True, it will also create the world using a randomly selected seed. If you try to create an existing world, IOError will be raised. Uses a RevisionHistory to manage undo history. Upon creation, the world is read-only until createRevision() is called. Call createRevision() to create a new revision, or selectRevision() to revert to an earlier revision. Older revisions are read-only, so createRevision() must be called again to make further changes. Call writeAllChanges() to write all changes into the original world. :type filename: str or unicode :type create: bool :type readonly: bool :rtype: AnvilWorldAdapter """ self.lockTime = 0 self.EntityRef = PCEntityRef self.TileEntityRef = PCTileEntityRef assert not (create and readonly) if os.path.basename(filename) in ("level.dat", "level.dat_old"): filename = os.path.dirname(filename) if not os.path.exists(filename): if not create: raise IOError('File not found') os.mkdir(filename) else: if create: if not os.path.isdir(filename) or os.path.exists( os.path.join(filename, "level.dat")): raise IOError('File exists!') if not os.path.isdir(filename): raise IOError('File is not a Minecraft Anvil world') if readonly: self.revisionHistory = AnvilWorldFolder(filename, readonly=True) self.selectedRevision = self.revisionHistory else: self.revisionHistory = RevisionHistory(filename, resume) self.selectedRevision = self.revisionHistory.getHead() self.filename = filename self.readonly = readonly if not readonly: self.acquireSessionLock() if create: self._createMetadataTag() self.selectedRevision.writeFile("level.dat", self.metadata.metadataTag.save()) else: self.loadMetadata() self.loadBlockMapping() def __repr__(self): return "AnvilWorldAdapter(%r)" % self.filename # --- Summary info --- @classmethod def getWorldInfo(cls, filename, displayNameLimit=40): try: if os.path.isdir(filename): folderName = os.path.basename(filename) levelDat = os.path.join(filename, "level.dat") else: folderName = os.path.basename(os.path.dirname(filename)) levelDat = filename levelTag = nbt.load(levelDat) try: displayName = levelTag['Data']['LevelName'].value if len(displayName) > displayNameLimit: displayName = displayName[:displayNameLimit] + "..." if len(folderName) > displayNameLimit: folderName = folderName[:displayNameLimit] + "..." if folderName != displayName: displayName = "%s (%s)" % (displayName, folderName) except Exception as e: log.warn("Failed to get display name for level.", exc_info=1) displayName = folderName try: lastPlayedTime = levelTag['Data']['LastPlayed'].value except Exception as e: log.warn("Failed to get last-played time for level.", exc_info=1) lastPlayedTime = 0 version = "Unknown Version" try: try: metadata = AnvilWorldMetadata(levelTag) versionTag = metadata.Version if versionTag.Snapshot: version = "Minecraft Snapshot " + versionTag.Name else: version = "Minecraft " + versionTag.Name except Exception as e: stackVersion = VERSION_1_8 if metadata.is1_8World( ) else VERSION_1_7 if stackVersion == VERSION_1_7: version = "Minecraft 1.7" if "FML" in metadata.metadataTag: version = "MinecraftForge 1.7" if stackVersion == VERSION_1_8: version = "Minecraft 1.8+" except Exception as e: log.warn("Failed to get version info for %s: %r", filename, e, exc_info=1) return WorldInfo(displayName, lastPlayedTime, version) except Exception as e: log.error("Failed getting world info for %s: %r", filename, e) return WorldInfo(str(e), 0, "") # --- Create, save, close --- def loadMetadata(self): try: metadataTag = nbt.load( buf=self.selectedRevision.readFile("level.dat")) self.metadata = AnvilWorldMetadata(metadataTag) except (EnvironmentError, zlib.error, NBTFormatError) as e: log.info( "Error loading level.dat, trying level.dat_old ({0})".format( e)) try: metadataTag = nbt.load( buf=self.selectedRevision.readFile("level.dat_old")) self.metadata = AnvilWorldMetadata(metadataTag) self.metadata.dirty = True log.info("level.dat restored from backup.") except Exception as e: traceback.print_exc() log.info( "%r while loading level.dat_old. Initializing with defaults.", e) self._createMetadataTag() if self.metadata.version != VERSION_ANVIL: raise LevelFormatError( "Pre-Anvil world formats are not supported (for now)") def loadBlockMapping(self): if self.metadata.is1_8World(): itemStackVersion = VERSION_1_8 else: itemStackVersion = VERSION_1_7 blocktypes = PCBlockTypeSet(itemStackVersion) self.blocktypes = blocktypes metadataTag = self.metadata.metadataTag fml = metadataTag.get('FML') if fml is None: return itemTypes = blocktypes.itemTypes itemdata = fml.get('ItemData') # MC 1.7 if itemdata is not None: count = 0 log.info("Adding block IDs from FML for MC 1.7") replacedIDs = [] for entry in itemdata: ID = entry['V'].value name = entry['K'].value magic, name = name[0], name[1:] if magic == u'\x01': # 0x01 = blocks if not name.startswith("minecraft:"): # we load 1.9 block IDs and mappings by default # FML IDs should be allowed to override some of them for 1.8 blocks not in 1.7. count += 1 replacedIDs.append(ID) fakeState = '[meta=0]' nameAndState = name + fakeState log.debug("FML1.7: Adding %s = %d", name, ID) for vanillaMeta in range(15): # Remove existing Vanilla defs vanillaNameAndState = blocktypes.statesByID.get( (ID, vanillaMeta)) blocktypes.blockJsons.pop(vanillaNameAndState, None) # Also remove Vanilla name<->state mapping blocktypes.IDsByState.pop(vanillaNameAndState, None) vanillaName = blocktypes.namesByID.get(ID) blocktypes.IDsByName.pop(vanillaName, None) blocktypes.defaultBlockstates.pop( vanillaName, None) blocktypes.IDsByState[nameAndState] = ID, 0 blocktypes.statesByID[ID, 0] = nameAndState blocktypes.IDsByName[name] = ID blocktypes.namesByID[ID] = name blocktypes.defaultBlockstates[name] = fakeState blocktypes.blockJsons[nameAndState] = { 'displayName': name, 'internalName': name, 'blockState': '[meta=0]', 'unknown': True, } if magic == u'\x02': # 0x02 = items if not name.startswith("minecraft:"): itemTypes.addFMLIDMapping(name, ID) replacedIDsSet = set(replacedIDs) blocktypes.discardIDs(replacedIDsSet) blocktypes.addBlocktypes( BlockType(newID, 0, blocktypes) for newID in replacedIDs) log.info("Added %d blocks.", count) def _createMetadataTag(self, random_seed=None): """ Create a level.dat for a newly created world or a world found with damaged level.dat/.dat_old (xxx repair in WorldEditor?) :param random_seed: :type random_seed: :return: :rtype: """ metadataTag = nbt.TAG_Compound() metadataTag["Data"] = nbt.TAG_Compound() metadataTag["Data"]["SpawnX"] = nbt.TAG_Int(0) metadataTag["Data"]["SpawnY"] = nbt.TAG_Int(2) metadataTag["Data"]["SpawnZ"] = nbt.TAG_Int(0) last_played = long(time.time() * 1000) if random_seed is None: random_seed = long( random.random() * 0xffffffffffffffffL) - 0x8000000000000000L metadataTag["Data"]['version'] = nbt.TAG_Int(VERSION_ANVIL) self.metadata = AnvilWorldMetadata(metadataTag) self.metadata.LastPlayed = long(last_played) self.metadata.RandomSeed = long(random_seed) self.metadata.SizeOnDisk = 0 self.metadata.Time = 1 self.metadata.LevelName = os.path.basename(self.filename) def syncToDisk(self): """ Write cached items (metadata from level.dat and players in players/ folder) to the current revision. :return: :rtype: """ if self.metadata.dirty: self.selectedRevision.writeFile("level.dat", self.metadata.metadataTag.save()) self.metadata.dirty = False def saveChanges(self): exhaust(self.saveChangesIter()) def saveChangesIter(self): """ Write all changes from all revisions into the world folder. :return: :rtype: None """ if self.readonly: raise IOError("World is opened read only.") self.checkSessionLock() index = self.revisionHistory.nodes.index(self.selectedRevision) for status in self.revisionHistory.writeAllChangesIter( self.selectedRevision): yield status self.selectedRevision = self.revisionHistory.nodes[index] yield def close(self): """ Close the world, deleting temporary files and freeing resources. Operations on a closed world are undefined. :return: :rtype: None """ self.revisionHistory.close() pass # do what here??? # --- Undo revisions --- def requireRevisions(self): """ Enforce the creation of new revisions by making the world folder's revision read-only. :return: :rtype: """ self.revisionHistory.rootNode.readOnly = True def createRevision(self): """ Create a new undo revision. Subsequent changes should be stored in the new revision. :return: :rtype: """ self.selectedRevision = self.revisionHistory.createRevision( self.selectedRevision) def closeRevision(self): """ Close the current revision and mark it read-only. Subsequent edits will not be possible until createRevision is called. :return: :rtype: """ self.revisionHistory.closeRevision() def setRevisionInfo(self, info): """ Attach some arbitrary JSON-serializable data to the current revision :param info: JSON-serializable data :type info: list | dict | str | unicode | int """ self.selectedRevision.setRevisionInfo(info) def getRevisionInfo(self): """ Return JSON-serializable data attached previously to the current revision via setRevisionInfo, or None if no data is attached. :return: :rtype: list | dict | str | unicode | int | None """ return self.selectedRevision.getRevisionInfo() def selectRevision(self, index): """ Select the current revision by index. Return changes between the previous revision and this one. If the index is invalid, returns None and does nothing. (XXX use an ID instead of index?) :param index: :type index: :return: :rtype: RevisionChanges """ if index < 0 or index >= len(self.revisionHistory.nodes): return None newRevision = self.revisionHistory.getRevision(index) changes = self.revisionHistory.getRevisionChanges( self.selectedRevision, newRevision) self.selectedRevision = newRevision self.loadMetadata() return changes def listRevisions(self): """ List the revision indexes and infos as (index, info) tuples. Info is JSON-serializable data previously attached with setRevisionInfo, or None. :return: :rtype: iterator[(int, list | dict | str | unicode | int)] """ for ID, node in enumerate(self.revisionHistory.nodes): yield ID, node.getRevisionInfo() def getRevisionChanges(self, oldRevision, newRevision): return self.revisionHistory.getRevisionChanges(oldRevision, newRevision) # --- Session lock --- def acquireSessionLock(self): """ Acquire the world's session.lock. Formats without this file may do nothing. :return: :rtype: """ lockfile = self.revisionHistory.rootFolder.getFilePath("session.lock") self.lockTime = int(time.time() * 1000) with open(lockfile, "wb") as f: f.write(struct.pack(">q", self.lockTime)) f.flush() os.fsync(f.fileno()) def checkSessionLock(self): """ Make sure the lock previously acquired by acquireSessionLock is still valid. Raise SessionLockLost if it is not. Raising the exception will abort any writes done to the main world folder. :return: :rtype: """ if self.readonly: raise SessionLockLost("World is opened read only.") lockfile = self.revisionHistory.rootFolder.getFilePath("session.lock") try: (lock, ) = struct.unpack(">q", open(lockfile, "rb").read()) except struct.error: lock = -1 if lock != self.lockTime: raise SessionLockLost( "Session lock lost. This world is being accessed from another location." ) def stealSessionLock(self): if self.readonly: raise IOError("World is opened read only.") self.acquireSessionLock() # --- Format detection --- @classmethod def canOpenFile(cls, filename): """ Ask this adapter if it can open the given file. :param filename: File to identify :type filename: str | unicode :return: :rtype: boolean """ if os.path.exists(os.path.join(filename, "chunks.dat")): return False # exclude Pocket Edition folders if not os.path.isdir(filename): f = os.path.basename(filename) if f not in ("level.dat", "level.dat_old"): return False filename = os.path.dirname(filename) files = os.listdir(filename) if "level.dat" in files or "level.dat_old" in files: return True return False # --- Dimensions --- def listDimensions(self): """ List the names of all dimensions in this world. :return: :rtype: iterator of str """ return self.selectedRevision.listDimensions() # --- Chunks --- def chunkCount(self, dimName): """ Count the chunks in the given dimension :param dimName: :type dimName: str :return: :rtype: int """ return self.selectedRevision.chunkCount(dimName) def chunkPositions(self, dimName): """ List the chunk positions (cx, cz) in the given dimension. :type dimName: unicode or str :return: :rtype: Iterator of (int, int) """ return iter(self.selectedRevision.chunkPositions(dimName)) def containsChunk(self, cx, cz, dimName): """ Return whether the given chunk is present in the given dimension :type cx: int or dtype :type cz: int or dtype :type dimName: str :return: :rtype: bool """ return self.selectedRevision.containsChunk(cx, cz, dimName) def readChunk(self, cx, cz, dimName): """ Return chunk (cx, cz) in the given dimension as an AnvilChunkData. Raise ChunkNotPresent if not found. :type cx: int or dtype :type cz: int or dtype :type dimName: str :return: :rtype: AnvilChunkData """ try: data = self.selectedRevision.readChunkBytes(cx, cz, dimName) chunkTag = nbt.load(buf=data) log.debug("_getChunkData: Chunk %s loaded (%s bytes)", (cx, cz), len(data)) chunkData = AnvilChunkData(self, cx, cz, dimName, chunkTag) except ChunkNotPresent: raise except ( KeyError, IndexError, zlib.error, UnicodeError ) as e: # Missing nbt keys, lists too short, decompression failure, unknown NBT tags raise AnvilChunkFormatError("Error loading chunk: %r" % e, None, sys.exc_info()[2]) return chunkData def writeChunk(self, chunk): """ Write the given AnvilChunkData to the current revision. :type chunk: mceditlib.anvil.adapter.AnvilChunkData """ tag = chunk.buildNBTTag() self.selectedRevision.writeChunkBytes(chunk.cx, chunk.cz, chunk.dimName, tag.save(compressed=False)) def createChunk(self, cx, cz, dimName): """ Create a new empty chunk at the given position in the given dimension. :type cx: int :type cz: int :type dimName: str :return: :rtype: AnvilChunkData """ if self.selectedRevision.containsChunk(cx, cz, dimName): raise ValueError("Chunk %s already exists in dim %r" % ((cx, cz), dimName)) chunk = AnvilChunkData(self, cx, cz, dimName, create=True) self.selectedRevision.writeChunkBytes( cx, cz, dimName, chunk.buildNBTTag().save(compressed=False)) return chunk def deleteChunk(self, cx, cz, dimName): """ Delete the chunk at the given position in the given dimension. :type cx: int :type cz: int :type dimName: str """ self.selectedRevision.deleteChunk(cx, cz, dimName) # --- Players --- def listPlayers(self): """ List the names/UUIDs of all players in this world (XXX players folder in dimension folders??) :return: :rtype: Iterator of unicode """ for f in self.selectedRevision.listFolder("playerdata"): if f.endswith(".dat"): yield f[11:-4] if "Player" in self.metadata.rootTag: yield "" def getPlayer(self, playerUUID=""): return AnvilPlayerRef(self, playerUUID) def getPlayerTag(self, playerUUID=""): """ Return the root NBT tag for the named player. Raise PlayerNotFound if not present. Parameters ---------- playerUUID : unicode The player ID returned from :ref:`listPlayers` Returns ------- player : AnvilPlayerRef """ if playerUUID == "": if "Player" in self.metadata.rootTag: # single-player world playerTag = self.metadata.rootTag["Player"] return playerTag raise PlayerNotFound(playerUUID) else: playerFilePath = "playerdata/%s.dat" % playerUUID if self.selectedRevision.containsFile(playerFilePath): # multiplayer world, found this player playerTag = nbt.load( buf=self.selectedRevision.readFile(playerFilePath)) return playerTag else: raise PlayerNotFound(playerUUID) def savePlayerTag(self, tag, playerUUID): if playerUUID == "": # sync metadata? self.metadata.dirty = True else: self.selectedRevision.writeFile("playerdata/%s.dat" % playerUUID, tag.save()) def createPlayer(self, playerUUID=""): """ Create a new player with the given name and return the PlayerRef. Raises some kind of IOError if the player could not be created. :param playerUUID: :type playerUUID: str :return: :rtype: PCPlayer """ if self.readonly: raise IOError("World is opened read only.") playerFilePath = "playerdata/%s.dat" % playerUUID if playerUUID == "": if "Player" in self.metadata.rootTag["Data"]: raise IOError("Single-player player already exists.") playerTag = nbt.TAG_Compound() self.metadata.rootTag["Data"]["Player"] = playerTag else: if self.selectedRevision.containsFile(playerFilePath): raise ValueError("Cannot create player %s: already exists.") playerTag = nbt.TAG_Compound() player = AnvilPlayerRef(playerTag, self) nbtattr.SetNBTDefaults(player) if playerUUID != "Player": self.checkSessionLock() self.selectedRevision.writeFile(playerFilePath, playerTag.save()) return self.getPlayer(playerUUID) # --- Maps --- def listMaps(self): """ Return a list of map IDs for this world's map items. :return: list[object] """ mapRE = re.compile(r'map_(\d+)\.dat') for filename in self.selectedRevision.listFolder("data"): basename = filename.split("/")[-1] match = mapRE.match(basename) if match is None or len(match.groups()) == 0: continue mapID = match.group(1) yield int(mapID) def getMap(self, mapID): return AnvilMapData(self.getMapTag(mapID), mapID, self) def _getMapPath(self, mapID): return "data/map_%s.dat" % mapID def getMapTag(self, mapID): mapPath = self._getMapPath(mapID) if not self.selectedRevision.containsFile(mapPath): raise KeyError("Map %s not found" % mapID) mapData = self.selectedRevision.readFile(mapPath) mapNBT = nbt.load(buf=mapData) return mapNBT def saveMapTag(self, mapID, mapTag): self.selectedRevision.writeFile(self._getMapPath(mapID), mapTag.save()) def createMap(self): # idcounts.dat should hold the ID number of the last created map # but we can't trust it because of bugs in the old map import filters mapIDs = list(self.listMaps()) if len(mapIDs): maximumID = max(mapIDs) mapID = maximumID + 1 else: mapID = 0 idcountsTag = nbt.TAG_Compound() idcountsTag["map"] = nbt.TAG_Short(mapID) # idcounts.dat is not compressed. self.selectedRevision.writeFile("data/idcounts.dat", idcountsTag.save(compressed=False)) mapData = AnvilMapData.create(mapID, self) mapData.save() return mapData def deleteMap(self, mapID): self.selectedRevision.deleteFile(self._getMapPath(mapID)) # --- Metadata --- def getWorldVersionInfo(self): versionTag = self.metadata.Version return VersionInfo('java', versionTag.Id, versionTag.Name, versionTag.Snapshot)