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
-
-[](https://www.codefactor.io/repository/github/matcool/anvil-parser/overview/master)
+# anvil-parser2
[](https://anvil-parser.readthedocs.io/en/latest/?badge=latest)
-[](https://github.com/matcool/anvil-parser/actions/workflows/run-pytest.yml)
-[](https://pypi.org/project/anvil-parser/)
+[](https://github.com/0xTiger/anvil-parser/actions/workflows/run-pytest.yml)
+[](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