diff --git a/.github/workflows/run-pytest.yml b/.github/workflows/run-pytest.yml index fad9665..19b47ce 100644 --- a/.github/workflows/run-pytest.yml +++ b/.github/workflows/run-pytest.yml @@ -24,7 +24,7 @@ jobs: run: | python -m pip install --upgrade pip pip install pytest - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + if [ -f tests/requirements.txt ]; then pip install -r tests/requirements.txt; fi - name: Test with pytest run: | pytest diff --git a/.gitignore b/.gitignore index bb9f8b6..7c1f481 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ build/ .vscode/ docs/_build/ examples/test_112.py +venv diff --git a/.readthedocs.yml b/.readthedocs.yml index d5ab4db..cc6080f 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -7,4 +7,4 @@ python: version: 3.7 install: - requirements: docs/requirements.txt - - requirements: requirements.txt + - requirements: tests/requirements.txt diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 443c23c..0000000 --- a/.travis.yml +++ /dev/null @@ -1,5 +0,0 @@ -language: python -python: - - "3.7" -script: - - pytest \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in index aef7716..b09f078 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,2 @@ -include anvil/legacy_blocks.json \ No newline at end of file +include anvil/legacy_blocks.json +include anvil/legacy_biomes.json diff --git a/README.md b/README.md index ecaadbc..dc29292 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,14 @@ -# anvil-parser - -[![CodeFactor](https://www.codefactor.io/repository/github/matcool/anvil-parser/badge/master)](https://www.codefactor.io/repository/github/matcool/anvil-parser/overview/master) +# anvil-parser2 [![Documentation Status](https://readthedocs.org/projects/anvil-parser/badge/?version=latest)](https://anvil-parser.readthedocs.io/en/latest/?badge=latest) -[![Tests](https://github.com/matcool/anvil-parser/actions/workflows/run-pytest.yml/badge.svg)](https://github.com/matcool/anvil-parser/actions/workflows/run-pytest.yml) -[![PyPI - Downloads](https://img.shields.io/pypi/dm/anvil-parser)](https://pypi.org/project/anvil-parser/) +[![Tests](https://github.com/0xTiger/anvil-parser/actions/workflows/run-pytest.yml/badge.svg)](https://github.com/0xTiger/anvil-parser/actions/workflows/run-pytest.yml) +[![PyPI - Downloads](https://img.shields.io/pypi/dm/anvil-parser)](https://pypi.org/project/anvil-parser2/) -Simple parser for the [Minecraft anvil file format](https://minecraft.gamepedia.com/Anvil_file_format) +A parser for the [Minecraft anvil file format](https://minecraft.wiki/w/Anvil_file_format). This package was forked from [matcool's anvil-parser](https://github.com/matcool/anvil-parser) in order to additionally support minecraft versions 1.18 and above. # Installation -This project is available on [PyPI](https://pypi.org/project/anvil-parser/) and can be installed with pip -``` -pip install anvil-parser ``` -or directly from github -``` -pip install git+https://github.com/matcool/anvil-parser.git +pip install anvil-parser2 ``` + # Usage ## Reading ```python @@ -57,9 +51,9 @@ region.save('r.0.0.mca') # Todo *things to do before 1.0.0* - [x] Proper documentation -- [ ] Biomes +- [x] Biomes - [x] CI - [ ] More tests - [ ] Tests for 20w17a+ BlockStates format # Note -Testing done in 1.14.4 and 1.15.2, should work fine for other versions. +Testing done in 1.14.4 - 1.21, should work fine for other versions. diff --git a/anvil/__init__.py b/anvil/__init__.py index 9611b80..2a7c696 100644 --- a/anvil/__init__.py +++ b/anvil/__init__.py @@ -1,5 +1,6 @@ from .chunk import Chunk from .block import Block, OldBlock +from .biome import Biome from .region import Region from .empty_region import EmptyRegion from .empty_chunk import EmptyChunk diff --git a/anvil/biome.py b/anvil/biome.py new file mode 100644 index 0000000..b6d43f0 --- /dev/null +++ b/anvil/biome.py @@ -0,0 +1,75 @@ +from .legacy import LEGACY_BIOMES_ID_MAP + +class Biome: + """ + Represents a minecraft biome. + + Attributes + ---------- + namespace: :class:`str` + Namespace of the biome, most of the time this is ``minecraft`` + id: :class:`str` + ID of the biome, for example: forest, warm_ocean, etc... + """ + __slots__ = ('namespace', 'id') + + def __init__(self, namespace: str, biome_id: str=None): + """ + Parameters + ---------- + namespace + Namespace of the biome. If no biome_id is given, assume this is ``biome_id`` and set namespace to ``"minecraft"`` + biome_id + ID of the biome + """ + if biome_id is None: + self.namespace = 'minecraft' + self.id = namespace + else: + self.namespace = namespace + self.id = biome_id + + def name(self) -> str: + """ + Returns the biome in the ``minecraft:biome_id`` format + """ + return self.namespace + ':' + self.id + + def __repr__(self): + return f'Biome({self.name()})' + + def __eq__(self, other): + if not isinstance(other, Biome): + return False + return self.namespace == other.namespace and self.id == other.id + + def __hash__(self): + return hash(self.name()) + + @classmethod + def from_name(cls, name: str): + """ + Creates a new Biome from the format: ``namespace:biome_id`` + + Parameters + ---------- + name + Biome in said format + """ + namespace, biome_id = name.split(':') + return cls(namespace, biome_id) + + @classmethod + def from_numeric_id(cls, biome_id: int): + """ + Creates a new Biome from the numeric biome_id format + + Parameters + ---------- + biome_id + Numeric ID of the biome + """ + if biome_id not in LEGACY_BIOMES_ID_MAP: + raise KeyError(f'Biome {biome_id} not found') + name = LEGACY_BIOMES_ID_MAP[biome_id] + return cls('minecraft', name) \ No newline at end of file diff --git a/anvil/block.py b/anvil/block.py index a9e0d7e..67a4ca4 100644 --- a/anvil/block.py +++ b/anvil/block.py @@ -79,9 +79,7 @@ def from_palette(cls, tag: nbt.TAG_Compound): Raw tag from a section's palette """ name = tag['Name'].value - properties = tag.get('Properties') - if properties: - properties = dict(properties) + properties = dict(tag.get('Properties', dict())) return cls.from_name(name, properties=properties) @classmethod @@ -96,14 +94,15 @@ def from_numeric_id(cls, block_id: int, data: int=0): data Numeric data, used to represent variants of the block """ - # See https://minecraft.gamepedia.com/Java_Edition_data_value/Pre-flattening - # and https://minecraft.gamepedia.com/Java_Edition_data_value for current values + # See https://minecraft.wiki/w/Java_Edition_data_value/Pre-flattening + # and https://minecraft.wiki/w/Java_Edition_data_value for current values key = f'{block_id}:{data}' if key not in LEGACY_ID_MAP: raise KeyError(f'Block {key} not found') name, properties = LEGACY_ID_MAP[key] return cls('minecraft', name, properties=properties) + class OldBlock: """ Represents a pre 1.13 minecraft block, with a numeric id. diff --git a/anvil/chunk.py b/anvil/chunk.py index daad244..09bcd86 100644 --- a/anvil/chunk.py +++ b/anvil/chunk.py @@ -1,19 +1,12 @@ from typing import Union, Tuple, Generator, Optional from nbt import nbt +from .biome import Biome from .block import Block, OldBlock from .region import Region from .errors import OutOfBoundsCoordinates, ChunkNotFound -import math +from .versions import VERSION_21w43a, VERSION_20w17a, VERSION_19w36a, VERSION_17w47a, VERSION_PRE_15w32a -# This version removes block state value stretching from the storage -# so a block value isn't in multiple elements of the array -_VERSION_20w17a = 2529 - -# This is the version where "The Flattening" (https://minecraft.gamepedia.com/Java_Edition_1.13/Flattening) happened -# where blocks went from numeric ids to namespaced ids (namespace:block_id) -_VERSION_17w47a = 1451 - def bin_append(a, b, length=None): """ Appends number a to the left of b @@ -22,6 +15,7 @@ def bin_append(a, b, length=None): length = length or b.bit_length() return (a << length) | b + def nibble(byte_array, index): value = byte_array[index // 2] if index % 2: @@ -29,6 +23,37 @@ def nibble(byte_array, index): else: return value & 0b1111 + +def _palette_from_section(section: nbt.TAG_Compound) -> nbt.TAG_List: + if 'block_states' in section: + return section["block_states"]["palette"] + else: + return section["Palette"] + + +def _states_from_section(section: nbt.TAG_Compound) -> list: + # BlockStates is an array of 64 bit numbers + # that holds the blocks index on the palette list + if 'block_states' in section: + states = section['block_states']['data'] + else: + states = section['BlockStates'] + + # makes sure the number is unsigned + # by adding 2^64 + # could also use ctypes.c_ulonglong(n).value but that'd require an extra import + + return [state if state >= 0 else states + 2 ** 64 + for state in states.value] + + +def _section_height_range(version: Optional[int]) -> range: + if version > VERSION_17w47a: + return range(-4, 20) + else: + return range(16) + + class Chunk: """ Represents a chunk from a ``.mca`` file. @@ -48,20 +73,25 @@ class Chunk: tile_entities: :class:`nbt.TAG_Compound` ``self.data['TileEntities']`` as an attribute for easier use """ - __slots__ = ('version', 'data', 'x', 'z', 'tile_entities') + + __slots__ = ("version", "data", "x", "z", "tile_entities") def __init__(self, nbt_data: nbt.NBTFile): try: - self.version = nbt_data['DataVersion'].value + self.version = nbt_data["DataVersion"].value except KeyError: # Version is pre-1.9 snapshot 15w32a, so world does not have a Data Version. - # See https://minecraft.fandom.com/wiki/Data_version - self.version = None + # See https://minecraft.wiki/w/Data_version + self.version = VERSION_PRE_15w32a - self.data = nbt_data['Level'] - self.x = self.data['xPos'].value - self.z = self.data['zPos'].value - self.tile_entities = self.data['TileEntities'] + if self.version >= VERSION_21w43a: + self.data = nbt_data + self.tile_entities = self.data["block_entities"] + else: + self.data = nbt_data["Level"] + self.tile_entities = self.data["TileEntities"] + self.x = self.data["xPos"].value + self.z = self.data["zPos"].value def get_section(self, y: int) -> nbt.TAG_Compound: """ @@ -78,16 +108,21 @@ def get_section(self, y: int) -> nbt.TAG_Compound: anvil.OutOfBoundsCoordinates If Y is not in range of 0 to 15 """ - if y < 0 or y > 15: - raise OutOfBoundsCoordinates(f'Y ({y!r}) must be in range of 0 to 15') + section_range = _section_height_range(self.version) + if y not in section_range: + raise OutOfBoundsCoordinates(f"Y ({y!r}) must be in range of " + f"{section_range.start} to {section_range.stop}") - try: - sections = self.data["Sections"] - except KeyError: - return None + if 'sections' in self.data: + sections = self.data["sections"] + else: + try: + sections = self.data["Sections"] + except KeyError: + return None for section in sections: - if section['Y'].value == y: + if section["Y"].value == y: return section def get_palette(self, section: Union[int, nbt.TAG_Compound]) -> Tuple[Block]: @@ -106,9 +141,96 @@ def get_palette(self, section: Union[int, nbt.TAG_Compound]) -> Tuple[Block]: section = self.get_section(section) if section is None: return - return tuple(Block.from_palette(i) for i in section['Palette']) + palette = _palette_from_section(section) + return tuple(Block.from_palette(i) for i in palette) - def get_block(self, x: int, y: int, z: int, section: Union[int, nbt.TAG_Compound]=None, force_new: bool=False) -> Union[Block, OldBlock]: + def get_biome(self, x: int, y: int, z: int) -> Biome: + """ + Returns the biome in the given coordinates + + Parameters + ---------- + int x, y, z + Biome's coordinates in the chunk + + Raises + ------ + anvil.OutOfBoundCoordidnates + If X, Y or Z are not in the proper range + + :rtype: :class:`anvil.Biome` + """ + section_range = _section_height_range(self.version) + if x not in range(16): + raise OutOfBoundsCoordinates(f"X ({x!r}) must be in range of 0 to 15") + if z not in range(16): + raise OutOfBoundsCoordinates(f"Z ({z!r}) must be in range of 0 to 15") + if y // 16 not in section_range: + raise OutOfBoundsCoordinates(f"Y ({y!r}) must be in range of " + f"{section_range.start * 16} to {section_range.stop * 16 - 1}") + + if 'Biomes' not in self.data: + # Each biome index refers to a 4x4x4 volumes here so we do integer division by 4 + section = self.get_section(y // 16) + biomes = section['biomes'] + biomes_palette = biomes['palette'] + if 'data' in biomes: + biomes = biomes['data'] + else: + # When there is only one biome in the section of the palette 'data' + # is not present + return Biome.from_name(biomes_palette[0].value) + + + index = ((y % 16 // 4) * 4 * 4) + (z // 4) * 4 + (x // 4) + bits = (len(biomes_palette) - 1).bit_length() + state = index * bits // 64 + data = biomes[state] + + # shift the number to the right to remove the left over bits + # and shift so the i'th biome is the first one + shifted_data = data >> ((bits * index) % 64) + + # if there aren't enough bits it means the rest are in the next number + if 64 - ((bits * index) % 64) < bits: + data = biomes[state + 1] + + # get how many bits are from a palette index of the next biome + leftover = (bits - ((state + 1) * 64 % bits)) % bits + + # Make sure to keep the length of the bits in the first state + # Example: bits is 5, and leftover is 3 + # Next state Current state (already shifted) + # 0b101010110101101010010 0b01 + # will result in bin_append(0b010, 0b01, 2) = 0b01001 + shifted_data = bin_append( + data & 2**leftover - 1, shifted_data, bits - leftover + ) + + palette_id = shifted_data & 2**bits - 1 + return Biome.from_name(biomes_palette[palette_id].value) + + else: + biomes = self.data["Biomes"] + if self.version < VERSION_19w36a: + # Each biome index refers to a column stored Z then X. + index = z * 16 + x + else: + # https://minecraft.wiki/w/Java_Edition_19w36a + # Get index on the biome list with the order YZX + # Each biome index refers to a 4x4 areas here so we do integer division by 4 + index = (y // 4) * 4 * 4 + (z // 4) * 4 + (x // 4) + biome_id = biomes[index] + return Biome.from_numeric_id(biome_id) + + def get_block( + self, + x: int, + y: int, + z: int, + section: Union[int, nbt.TAG_Compound] = None, + force_new: bool = False, + ) -> Union[Block, OldBlock]: """ Returns the block in the given coordinates @@ -130,34 +252,36 @@ def get_block(self, x: int, y: int, z: int, section: Union[int, nbt.TAG_Compound :rtype: :class:`anvil.Block` """ - if x < 0 or x > 15: - raise OutOfBoundsCoordinates(f'X ({x!r}) must be in range of 0 to 15') - if z < 0 or z > 15: - raise OutOfBoundsCoordinates(f'Z ({z!r}) must be in range of 0 to 15') - if y < 0 or y > 255: - raise OutOfBoundsCoordinates(f'Y ({y!r}) must be in range of 0 to 255') + if x not in range(16): + raise OutOfBoundsCoordinates(f"X ({x!r}) must be in range of 0 to 15") + if z not in range(16): + raise OutOfBoundsCoordinates(f"Z ({z!r}) must be in range of 0 to 15") + section_range = _section_height_range(self.version) + if y // 16 not in section_range: + raise OutOfBoundsCoordinates(f"Y ({y!r}) must be in range of " + f"{section_range.start * 16} to {section_range.stop * 16 - 1}") if section is None: section = self.get_section(y // 16) # global Y to section Y y %= 16 - if self.version is None or self.version < _VERSION_17w47a: - # Explained in depth here https://minecraft.gamepedia.com/index.php?title=Chunk_format&oldid=1153403#Block_format + if self.version < VERSION_17w47a: + # Explained in depth here https://minecraft.wiki/w/index.php?title=Chunk_format&oldid=1153403#Block_format - if section is None or 'Blocks' not in section: + if section is None or "Blocks" not in section: if force_new: - return Block.from_name('minecraft:air') + return Block.from_name("minecraft:air") else: return OldBlock(0) index = y * 16 * 16 + z * 16 + x - block_id = section['Blocks'][index] - if 'Add' in section: - block_id += nibble(section['Add'], index) << 8 + block_id = section["Blocks"][index] + if "Add" in section: + block_id += nibble(section["Add"], index) << 8 - block_data = nibble(section['Data'], index) + block_data = nibble(section["Data"], index) block = OldBlock(block_id, block_data) if force_new: @@ -166,23 +290,23 @@ def get_block(self, x: int, y: int, z: int, section: Union[int, nbt.TAG_Compound return block # If its an empty section its most likely an air block - if section is None or 'BlockStates' not in section: - return Block.from_name('minecraft:air') - + if section is None: + return Block.from_name("minecraft:air") + try: + states = _states_from_section(section) + except KeyError: + return Block.from_name("minecraft:air") + # Number of bits each block is on BlockStates # Cannot be lower than 4 - bits = max((len(section['Palette']) - 1).bit_length(), 4) + palette = _palette_from_section(section) - # Get index on the block list with the order YZX - index = y * 16*16 + z * 16 + x - - # BlockStates is an array of 64 bit numbers - # that holds the blocks index on the palette list - states = section['BlockStates'].value + bits = max((len(palette) - 1).bit_length(), 4) + # Get index on the block list with the order YZX + index = y * 16 * 16 + z * 16 + x # in 20w17a and newer blocks cannot occupy more than one element on the BlockStates array - stretches = self.version is None or self.version < _VERSION_20w17a - # stretches = True + stretches = self.version < VERSION_20w17a # get location in the BlockStates array via the index if stretches: @@ -190,12 +314,7 @@ def get_block(self, x: int, y: int, z: int, section: Union[int, nbt.TAG_Compound else: state = index // (64 // bits) - # makes sure the number is unsigned - # by adding 2^64 - # could also use ctypes.c_ulonglong(n).value but that'd require an extra import data = states[state] - if data < 0: - data += 2**64 if stretches: # shift the number to the right to remove the left over bits @@ -207,8 +326,6 @@ def get_block(self, x: int, y: int, z: int, section: Union[int, nbt.TAG_Compound # if there aren't enough bits it means the rest are in the next number if stretches and 64 - ((bits * index) % 64) < bits: data = states[state + 1] - if data < 0: - data += 2**64 # get how many bits are from a palette index of the next block leftover = (bits - ((state + 1) * 64 % bits)) % bits @@ -218,16 +335,21 @@ def get_block(self, x: int, y: int, z: int, section: Union[int, nbt.TAG_Compound # Next state Current state (already shifted) # 0b101010110101101010010 0b01 # will result in bin_append(0b010, 0b01, 2) = 0b01001 - shifted_data = bin_append(data & 2**leftover - 1, shifted_data, bits-leftover) + shifted_data = bin_append( + data & 2**leftover - 1, shifted_data, bits - leftover + ) # get `bits` least significant bits # which are the palette index palette_id = shifted_data & 2**bits - 1 - - block = section['Palette'][palette_id] - return Block.from_palette(block) - - def stream_blocks(self, index: int=0, section: Union[int, nbt.TAG_Compound]=None, force_new: bool=False) -> Generator[Block, None, None]: + return Block.from_palette(palette[palette_id]) + + def stream_blocks( + self, + index: int = 0, + section: Union[int, nbt.TAG_Compound] = None, + force_new: bool = False, + ) -> Generator[Block, None, None]: """ Returns a generator for all the blocks in given section @@ -254,27 +376,31 @@ def stream_blocks(self, index: int=0, section: Union[int, nbt.TAG_Compound]=None ------ :class:`anvil.Block` """ - if isinstance(section, int) and (section < 0 or section > 16): - raise OutOfBoundsCoordinates(f'section ({section!r}) must be in range of 0 to 15') + + if isinstance(section, int): + section_range = _section_height_range(self.version) + if section not in section_range: + raise OutOfBoundsCoordinates(f"section ({section!r}) must be in range of " + f"{section_range.start} to {section_range.stop}") # For better understanding of this code, read get_block()'s source if section is None or isinstance(section, int): section = self.get_section(section or 0) - if self.version < _VERSION_17w47a: - if section is None or 'Blocks' not in section: - air = Block.from_name('minecraft:air') if force_new else OldBlock(0) - for i in range(4096): + if self.version < VERSION_17w47a: + if section is None or "Blocks" not in section: + air = Block.from_name("minecraft:air") if force_new else OldBlock(0) + for _ in range(4096): yield air return while index < 4096: - block_id = section['Blocks'][index] - if 'Add' in section: - block_id += nibble(section['Add'], index) << 8 + block_id = section["Blocks"][index] + if "Add" in section: + block_id += nibble(section["Add"], index) << 8 - block_data = nibble(section['Data'], index) + block_data = nibble(section["Data"], index) block = OldBlock(block_id, block_data) if force_new: @@ -285,18 +411,22 @@ def stream_blocks(self, index: int=0, section: Union[int, nbt.TAG_Compound]=None index += 1 return - if section is None or 'BlockStates' not in section: - air = Block.from_name('minecraft:air') - for i in range(4096): + air = Block.from_name("minecraft:air") + if section is None: + for _ in range(4096): + yield air + return + try: + states = _states_from_section(section) + except KeyError: + for _ in range(4096): yield air return - states = section['BlockStates'].value - palette = section['Palette'] - + palette = _palette_from_section(section) bits = max((len(palette) - 1).bit_length(), 4) - stretches = self.version < _VERSION_20w17a + stretches = self.version < VERSION_20w17a if stretches: state = index * bits // 64 @@ -304,8 +434,6 @@ def stream_blocks(self, index: int=0, section: Union[int, nbt.TAG_Compound]=None state = index // (64 // bits) data = states[state] - if data < 0: - data += 2**64 bits_mask = 2**bits - 1 @@ -321,8 +449,6 @@ def stream_blocks(self, index: int=0, section: Union[int, nbt.TAG_Compound]=None if data_len < bits: state += 1 new_data = states[state] - if new_data < 0: - new_data += 2**64 if stretches: leftover = data_len @@ -340,7 +466,9 @@ def stream_blocks(self, index: int=0, section: Union[int, nbt.TAG_Compound]=None data >>= bits data_len -= bits - def stream_chunk(self, index: int=0, section: Union[int, nbt.TAG_Compound]=None) -> Generator[Block, None, None]: + def stream_chunk( + self, index: int = 0, section: Union[int, nbt.TAG_Compound] = None + ) -> Generator[Block, None, None]: """ Returns a generator for all the blocks in the chunk @@ -350,7 +478,7 @@ def stream_chunk(self, index: int=0, section: Union[int, nbt.TAG_Compound]=None) ------ :class:`anvil.Block` """ - for section in range(16): + for section in _section_height_range(self.version): for block in self.stream_blocks(section=section): yield block @@ -361,7 +489,7 @@ def get_tile_entity(self, x: int, y: int, z: int) -> Optional[nbt.TAG_Compound]: To iterate through all tile entities in the chunk, use :class:`Chunk.tile_entities` """ for tile_entity in self.tile_entities: - t_x, t_y, t_z = [tile_entity[k].value for k in 'xyz'] + t_x, t_y, t_z = [tile_entity[k].value for k in "xyz"] if x == t_x and y == t_y and z == t_z: return tile_entity @@ -384,5 +512,5 @@ def from_region(cls, region: Union[str, Region], chunk_x: int, chunk_z: int): region = Region.from_file(region) nbt_data = region.chunk_data(chunk_x, chunk_z) if nbt_data is None: - raise ChunkNotFound(f'Could not find chunk ({chunk_x}, {chunk_z})') + raise ChunkNotFound(f"Could not find chunk ({chunk_x}, {chunk_z})") return cls(nbt_data) diff --git a/anvil/empty_chunk.py b/anvil/empty_chunk.py index a31e7f3..d1badd7 100644 --- a/anvil/empty_chunk.py +++ b/anvil/empty_chunk.py @@ -1,8 +1,18 @@ from typing import List from .block import Block +from .biome import Biome from .empty_section import EmptySection from .errors import OutOfBoundsCoordinates, EmptySectionAlreadyExists from nbt import nbt +from .legacy import LEGACY_BIOMES_ID_MAP + + +def _get_legacy_biome_id(biome: Biome) -> int: + for k, v in LEGACY_BIOMES_ID_MAP.items(): + if v == biome.id: + return k + raise ValueError(f'Biome id "{biome.id}" has no legacy equivalent') + class EmptyChunk: """ @@ -19,11 +29,12 @@ class EmptyChunk: version: :class:`int` Chunk's DataVersion """ - __slots__ = ('x', 'z', 'sections', 'version') + __slots__ = ('x', 'z', 'sections', 'biomes', 'version') def __init__(self, x: int, z: int): self.x = x self.z = z self.sections: List[EmptySection] = [None]*16 + self.biomes: List[Biome] = [Biome('ocean')]*16*16 self.version = 1976 def add_section(self, section: EmptySection, replace: bool = True): @@ -68,11 +79,11 @@ def get_block(self, x: int, y: int, z: int) -> Block: Returns ``None`` if the section is empty, meaning the block is most likely an air block. """ - if x < 0 or x > 15: + if x not in range(16): raise OutOfBoundsCoordinates(f'X ({x!r}) must be in range of 0 to 15') - if z < 0 or z > 15: + if z not in range(16): raise OutOfBoundsCoordinates(f'Z ({z!r}) must be in range of 0 to 15') - if y < 0 or y > 255: + if y not in range(256): raise OutOfBoundsCoordinates(f'Y ({y!r}) must be in range of 0 to 255') section = self.sections[y // 16] if section is None: @@ -94,13 +105,12 @@ def set_block(self, block: Block, x: int, y: int, z: int): ------ anvil.OutOfBoundCoordidnates If X, Y or Z are not in the proper range - """ - if x < 0 or x > 15: + if x not in range(16): raise OutOfBoundsCoordinates(f'X ({x!r}) must be in range of 0 to 15') - if z < 0 or z > 15: + if z not in range(16): raise OutOfBoundsCoordinates(f'Z ({z!r}) must be in range of 0 to 15') - if y < 0 or y > 255: + if y not in range(256): raise OutOfBoundsCoordinates(f'Y ({y!r}) must be in range of 0 to 255') section = self.sections[y // 16] if section is None: @@ -108,6 +118,29 @@ def set_block(self, block: Block, x: int, y: int, z: int): self.add_section(section) section.set_block(block, x, y % 16, z) + def set_biome(self, biome: Biome, x: int, z: int): + """ + Sets biome at given coordinates + + Parameters + ---------- + int x, z + In range of 0 to 15 + + Raises + ------ + anvil.OutOfBoundCoordidnates + If X or Z are not in the proper range + + """ + if x not in range(16): + raise OutOfBoundsCoordinates(f'X ({x!r}) must be in range of 0 to 15') + if z not in range(16): + raise OutOfBoundsCoordinates(f'Z ({z!r}) must be in range of 0 to 15') + + index = z * 16 + x + self.biomes[index] = biome + def save(self) -> nbt.NBTFile: """ Saves the chunk data to a :class:`NBTFile` @@ -135,6 +168,9 @@ def save(self) -> nbt.NBTFile: nbt.TAG_String(name='Status', value='full') ]) sections = nbt.TAG_List(name='Sections', type=nbt.TAG_Compound) + biomes = nbt.TAG_Int_Array(name='Biomes') + + biomes.value = [_get_legacy_biome_id(biome) for biome in self.biomes] for s in self.sections: if s: p = s.palette() @@ -144,5 +180,6 @@ def save(self) -> nbt.NBTFile: continue sections.tags.append(s.save()) level.tags.append(sections) + level.tags.append(biomes) root.tags.append(level) return root diff --git a/anvil/empty_region.py b/anvil/empty_region.py index 6dcf675..91b09e4 100644 --- a/anvil/empty_region.py +++ b/anvil/empty_region.py @@ -3,7 +3,9 @@ from .chunk import Chunk from .empty_section import EmptySection from .block import Block +from .biome import Biome from .errors import OutOfBoundsCoordinates +from .versions import VERSION_21w43a from io import BytesIO from nbt import nbt import zlib @@ -46,7 +48,7 @@ def inside(self, x: int, y: int, z: int, chunk: bool=False) -> bool: factor = 32 if chunk else 512 rx = x // factor rz = z // factor - return not (rx != self.x or rz != self.z or y < 0 or y > 255) + return not (rx != self.x or rz != self.z or y not in range(256)) def get_chunk(self, x: int, z: int) -> EmptyChunk: """ @@ -140,6 +142,33 @@ def set_block(self, block: Block, x: int, y: int, z: int): self.add_chunk(chunk) chunk.set_block(block, x % 16, y, z % 16) + def set_biome(self, biome: Biome, x: int, z: int): + """ + Sets biome at given coordinates. + New chunk is made if it doesn't exist. + + Parameters + ---------- + biome: :class:`Biome` + Biome to place + int x, z + Coordinates + + Raises + ------ + anvil.OutOfBoundsCoordinates + If the biome (x, z) is not inside this region + """ + if not self.inside(x, 0, z): + raise OutOfBoundsCoordinates(f'Biome ({x}, {z}) is not inside this region') + cx = x // 16 + cz = z // 16 + chunk = self.get_chunk(cx, cz) + if chunk is None: + chunk = EmptyChunk(cx, cz) + self.add_chunk(chunk) + chunk.set_biome(biome, x % 16, z % 16) + def set_if_inside(self, block: Block, x: int, y: int, z: int): """ Helper function that only sets @@ -155,6 +184,21 @@ def set_if_inside(self, block: Block, x: int, y: int, z: int): if self.inside(x, y, z): self.set_block(block, x, y, z) + def set_biome_if_inside(self, biome: Biome, x: int, z: int): + """ + Helper function that only sets + the biome if ``self.inside(x, 0, z)`` is true + + Parameters + ---------- + biome: :class:`Biome` + Biome to place + int x, z + Coordinates + """ + if self.inside(x, 0, z): + self.set_biome(biome, x, z) + def fill(self, block: Block, x1: int, y1: int, z1: int, x2: int, y2: int, z2: int, ignore_outside: bool=False): """ Fills in blocks from @@ -189,6 +233,40 @@ def fill(self, block: Block, x1: int, y1: int, z1: int, x2: int, y2: int, z2: in self.set_if_inside(block, x, y, z) else: self.set_block(block, x, y, z) + + def fill_biome(self, biome: Biome, x1: int, z1: int, x2: int, z2: int, ignore_outside: bool=False): + """ + Fills in biomes from + ``(x1, z1)`` to ``(x2, z2)`` + in a rectangle. + + Parameters + ---------- + biome: :class:`Biome` + int x1, z1 + Coordinates + int x2, z2 + Coordinates + ignore_outside + Whether to ignore if coordinates are outside the region + + Raises + ------ + anvil.OutOfBoundsCoordinates + If any of the coordinates are outside the region + """ + if not ignore_outside: + if not self.inside(x1, 0, z1): + raise OutOfBoundsCoordinates(f'First coords ({x1}, {z1}) is not inside this region') + if not self.inside(x2, 0, z2): + raise OutOfBoundsCoordinates(f'Second coords ({x2}, {z2}) is not inside this region') + + for z in from_inclusive(z1, z2): + for x in from_inclusive(x1, x2): + if ignore_outside: + self.set_biome_if_inside(biome, x, z) + else: + self.set_biome(biome, x, z) def save(self, file: Union[str, BinaryIO]=None) -> bytes: """ @@ -212,7 +290,12 @@ def save(self, file: Union[str, BinaryIO]=None) -> bytes: if isinstance(chunk, Chunk): nbt_data = nbt.NBTFile() nbt_data.tags.append(nbt.TAG_Int(name='DataVersion', value=chunk.version)) - nbt_data.tags.append(chunk.data) + + if chunk.version >= VERSION_21w43a: + for tag in chunk.data.tags: + nbt_data.tags.append(tag) + else: + nbt_data.tags.append(chunk.data) else: nbt_data = chunk.save() nbt_data.write_file(buffer=chunk_data) diff --git a/anvil/empty_section.py b/anvil/empty_section.py index 315a28d..4e4e448 100644 --- a/anvil/empty_section.py +++ b/anvil/empty_section.py @@ -49,7 +49,7 @@ def inside(x: int, y: int, z: int) -> bool: int x, y, z Coordinates """ - return x >= 0 and x <= 15 and y >= 0 and y <= 15 and z >= 0 and z <= 15 + return x in range(16) and y in range(16) and z in range(16) def set_block(self, block: Block, x: int, y: int, z: int): """ diff --git a/anvil/legacy.py b/anvil/legacy.py index 1dbb28b..df78628 100644 --- a/anvil/legacy.py +++ b/anvil/legacy.py @@ -3,3 +3,6 @@ with open(os.path.join(os.path.dirname(__file__), 'legacy_blocks.json'), 'r') as file: LEGACY_ID_MAP = json.load(file) + +with open(os.path.join(os.path.dirname(__file__), 'legacy_biomes.json'), 'r') as file: + LEGACY_BIOMES_ID_MAP = {int(k):v for k, v in json.load(file).items()} \ No newline at end of file diff --git a/anvil/legacy_biomes.json b/anvil/legacy_biomes.json new file mode 100644 index 0000000..5284d9a --- /dev/null +++ b/anvil/legacy_biomes.json @@ -0,0 +1,81 @@ +{ +"0": "ocean", +"4": "forest", +"7": "river", +"10": "frozen_ocean", +"11": "frozen_river", +"16": "beach", +"24": "deep_ocean", +"25": "stone_shore", +"26": "snowy_beach", +"44": "warm_ocean", +"45": "lukewarm_ocean", +"46": "cold_ocean", +"47": "deep_warm_ocean", +"48": "deep_lukewarm_ocean", +"49": "deep_cold_ocean", +"50": "deep_frozen_ocean", +"18": "wooded_hills", +"132": "flower_forest", +"27": "birch_forest", +"28": "birch_forest_hills", +"155": "tall_birch_forest", +"156": "tall_birch_hills", +"29": "dark_forest", +"157": "dark_forest_hills", +"21": "jungle", +"22": "jungle_hills", +"149": "modified_jungle", +"23": "jungle_edge", +"151": "modified_jungle_edge", +"168": "bamboo_jungle", +"169": "bamboo_jungle_hills", +"5": "taiga", +"19": "taiga_hills", +"133": "taiga_mountains", +"30": "snowy_taiga", +"31": "snowy_taiga_hills", +"158": "snowy_taiga_mountains", +"32": "giant_tree_taiga", +"33": "giant_tree_taiga_hills", +"160": "giant_spruce_taiga", +"161": "giant_spruce_taiga_hills", +"14": "mushroom_fields", +"15": "mushroom_field_shore", +"6": "swamp", +"134": "swamp_hills", +"35": "savanna", +"36": "savanna_plateau", +"163": "shattered_savanna", +"164": "shattered_savanna_plateau", +"1": "plains", +"129": "sunflower_plains", +"2": "desert", +"17": "desert_hills", +"130": "desert_lakes", +"12": "snowy_tundra", +"13": "snowy_mountains", +"140": "ice_spikes", +"3": "mountains", +"34": "wooded_mountains", +"131": "gravelly_mountains", +"162": "modified_gravelly_mountains", +"20": "mountain_edge", +"37": "badlands", +"39": "badlands_plateau", +"167": "modified_badlands_plateau", +"38": "wooded_badlands_plateau", +"166": "modified_wooded_badlands_plateau", +"165": "eroded_badlands", +"8": "nether", +"9": "the_end", +"40": "small_end_islands", +"41": "end_midlands", +"42": "end_highlands", +"43": "end_barrens", +"170": "soul_sand_valley", +"171": "crimson_forest", +"172": "warped_forest", +"127": "the_void", +"173": "basalt_deltas" +} \ No newline at end of file diff --git a/anvil/versions.py b/anvil/versions.py new file mode 100644 index 0000000..aca1b52 --- /dev/null +++ b/anvil/versions.py @@ -0,0 +1,20 @@ +# This version removes the chunk's "Level" NBT tag and moves all contained tags to the top level +# https://minecraft.wiki/w/Java_Edition_21w43a +VERSION_21w43a = 2844 + +# This version removes block state value stretching from the storage +# so a block value isn't in multiple elements of the array +VERSION_20w17a = 2529 + +# This version changes how biomes are stored to allow for biomes at different heights +# https://minecraft.wiki/w/Java_Edition_19w36a +VERSION_19w36a = 2203 + +# This is the version where "The Flattening" (https://minecraft.wiki/w/Java_Edition_1.13/Flattening) happened +# where blocks went from numeric ids to namespaced ids (namespace:block_id) +VERSION_17w47a = 1451 + +# This represents Versions before 1.9 snapshot 15w32a, +# these snapshots do not have a Data Version so we use -1 since -1 is less than any valid data version. +# https://minecraft.wiki/w/Data_version +VERSION_PRE_15w32a = -1 diff --git a/docs/index.rst b/docs/index.rst index 2253f1e..3308ef0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,7 +1,7 @@ anvil-parser ============ -Simple parser for the `Minecraft anvil file format `_ +Simple parser for the `Minecraft anvil file format `_ .. toctree:: :maxdepth: 3 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 2439353..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -NBT==1.5.0 -frozendict==1.2 diff --git a/setup.py b/setup.py index 3bcdb07..4e0ae7e 100644 --- a/setup.py +++ b/setup.py @@ -4,13 +4,13 @@ long_description = file.read() setuptools.setup( - name='anvil-parser', - version='0.9.0', - author='mat', - description='A Minecraft anvil file format parser', + name='anvil-parser2', + version='0.10.6', + author='0xTiger', + description='A parser for the Minecraft anvil file format, supports all Minecraft verions', long_description=long_description, long_description_content_type='text/markdown', - url='https://github.com/matcool/anvil-parser', + url='https://github.com/0xTiger/anvil-parser2', packages=setuptools.find_packages(), classifiers=[ 'Programming Language :: Python :: 3', diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..6954c3b --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,2 @@ +NBT==1.5.1 +frozendict==2.0.2