Created
May 23, 2026 18:44
-
-
Save StoneLabs/68d3fb02d84d19bd946ebf71bbf0da07 to your computer and use it in GitHub Desktop.
Minecraft skyblock mca world clearing script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import os | |
| import sys | |
| import struct | |
| import zlib | |
| import gzip | |
| import tempfile | |
| import io | |
| import math | |
| from nbt import nbt | |
| # ---------- Configuration ---------- | |
| SPAWN_X = 0 | |
| SPAWN_Z = 0 | |
| DISTANCE_THRESHOLD = 480 # blocks | |
| SECTOR_SIZE = 4096 | |
| # ----------------------------------- | |
| def read_region_header(file_path): | |
| """Reads location & timestamp tables from .mca file.""" | |
| locations, timestamps = [], [] | |
| with open(file_path, 'rb') as f: | |
| loc_data = f.read(4096) | |
| if len(loc_data) < 4096: | |
| raise ValueError("File too short for location table") | |
| for i in range(1024): | |
| entry = struct.unpack('>I', loc_data[i*4:(i+1)*4])[0] | |
| locations.append(((entry >> 8) & 0xFFFFFF, entry & 0xFF)) | |
| ts_data = f.read(4096) | |
| if len(ts_data) < 4096: | |
| raise ValueError("File too short for timestamp table") | |
| for i in range(1024): | |
| timestamps.append(struct.unpack('>I', ts_data[i*4:(i+1)*4])[0]) | |
| return locations, timestamps | |
| def read_chunk_data(file_path, sector_offset, sector_count): | |
| if sector_offset == 0 or sector_count == 0: | |
| return None | |
| with open(file_path, 'rb') as f: | |
| f.seek(sector_offset * SECTOR_SIZE) | |
| length = struct.unpack('>I', f.read(4))[0] | |
| compression = struct.unpack('b', f.read(1))[0] | |
| if compression == 2: | |
| return zlib.decompress(f.read(length - 1)) | |
| elif compression == 1: | |
| print(" [WARN] GZip compression – chunk skipped") | |
| return None | |
| else: | |
| print(f" [WARN] Unknown compression {compression} – chunk skipped") | |
| return None | |
| def chunk_distance(x_pos, z_pos): | |
| # Use chunk center in block coords for a fair distance check. | |
| # Corner (x_pos * 16) would miss the far half of boundary chunks. | |
| cx = x_pos * 16 + 8 | |
| cz = z_pos * 16 + 8 | |
| return math.sqrt(cx ** 2 + cz ** 2) | |
| def get_block_range(x_pos, z_pos): | |
| return (x_pos * 16, z_pos * 16), (x_pos * 16 + 15, z_pos * 16 + 15) | |
| def create_zero_heightmaps(): | |
| """ | |
| Modern (1.18+) Heightmaps: a compound containing a | |
| MOTION_BLOCKING long array of 37 zeros (16x16 area). | |
| """ | |
| hm_compound = nbt.TAG_Compound(name='Heightmaps') | |
| mot_array = nbt.TAG_Long_Array(name='MOTION_BLOCKING') | |
| mot_array.value = [0] * 37 | |
| hm_compound.tags.append(mot_array) | |
| return hm_compound | |
| _debug_printed = False | |
| def _get_sections(level): | |
| """ | |
| FIX: In 1.18+ the sections list is 'sections' (lowercase). | |
| In 1.17 and below it was 'Sections' under the Level compound. | |
| Try both so the script works regardless of world version. | |
| """ | |
| if 'sections' in level: | |
| return level['sections'] | |
| if 'Sections' in level: | |
| return level['Sections'] | |
| return [] | |
| def clear_chunk(chunk_nbt): | |
| global _debug_printed | |
| level = chunk_nbt.get('Level', chunk_nbt) | |
| # Debug output (first non-empty chunk only) | |
| if not _debug_printed: | |
| sections = _get_sections(level) | |
| if len(sections) > 0: | |
| try: | |
| sec0 = sections[0] | |
| if 'block_states' in sec0 and 'palette' in sec0['block_states']: | |
| pal0 = sec0['block_states']['palette'] | |
| names = [entry['Name'].value for entry in pal0] | |
| print(f" [DEBUG] First palette entries before: {names[:5]}...") | |
| else: | |
| print(" [DEBUG] No palette in first section") | |
| except Exception: | |
| print(" [DEBUG] Could not read initial palette") | |
| else: | |
| print(" [DEBUG] Chunk has no sections (already empty)") | |
| _debug_printed = True | |
| level['Status'] = nbt.TAG_String(name='Status', value='minecraft:full') | |
| # FIX: Use _get_sections() to handle both 'sections' (1.18+) and 'Sections' (1.17-) | |
| for section in _get_sections(level): | |
| air_block = nbt.TAG_Compound() | |
| air_block.tags.append(nbt.TAG_String(name='Name', value='minecraft:air')) | |
| palette = nbt.TAG_List(name='palette', type=nbt.TAG_Compound) | |
| palette.tags.append(air_block) | |
| if 'block_states' not in section: | |
| section['block_states'] = nbt.TAG_Compound(name='block_states') | |
| bs = section['block_states'] | |
| bs['palette'] = palette | |
| if 'data' in bs: | |
| del bs['data'] | |
| for tag_name in ('BlockLight', 'SkyLight'): | |
| if tag_name in section: | |
| del section[tag_name] | |
| # Remove all metadata that references non-air blocks. | |
| # FIX: Include lowercase 1.18+ names for 'lights' and 'post_processing' | |
| for tag in ('block_entities', 'block_ticks', 'fluid_ticks', | |
| 'entities', 'Lights', 'lights', 'PostProcessing', 'post_processing'): | |
| if tag in level: | |
| del level[tag] | |
| # Correct Heightmaps: a compound with MOTION_BLOCKING = zeros | |
| level['Heightmaps'] = create_zero_heightmaps() | |
| if not _debug_printed: | |
| _debug_printed = True | |
| sections = _get_sections(level) | |
| if len(sections) > 0: | |
| try: | |
| sec0 = sections[0] | |
| if 'block_states' in sec0 and 'palette' in sec0['block_states']: | |
| pal0 = sec0['block_states']['palette'] | |
| names = [entry['Name'].value for entry in pal0] | |
| print(f" [DEBUG] First palette entries after: {names}") | |
| except Exception: | |
| print(" [DEBUG] Could not read final palette") | |
| def build_chunk_payload(chunk_nbt): | |
| """ | |
| Serialise chunk NBT to a Zlib-compressed region entry. | |
| Writes to a temporary GZip file, decompresses to get raw NBT, | |
| then Zlib-compresses (Minecraft's region format). | |
| """ | |
| with tempfile.NamedTemporaryFile(delete=False, suffix='.nbt') as tmp: | |
| tmp_name = tmp.name | |
| try: | |
| chunk_nbt.write_file(tmp_name) # GZip compressed file | |
| with gzip.open(tmp_name, 'rb') as f: | |
| raw_nbt = f.read() # uncompressed NBT | |
| finally: | |
| os.unlink(tmp_name) | |
| compressed = zlib.compress(raw_nbt) | |
| # length includes the compression type byte | |
| payload = struct.pack('>I', len(compressed) + 1) | |
| payload += struct.pack('b', 2) # Zlib | |
| payload += compressed | |
| return payload | |
| def update_region_file(file_path, locations, timestamps, chunk_payloads): | |
| new_locs = [] | |
| blocks = [] | |
| offset = 2 # sectors after headers | |
| for i in range(1024): | |
| if chunk_payloads[i] is not None: | |
| sectors = math.ceil(len(chunk_payloads[i]) / SECTOR_SIZE) | |
| new_locs.append((offset, sectors)) | |
| blocks.append(chunk_payloads[i]) | |
| offset += sectors | |
| else: | |
| new_locs.append((0, 0)) | |
| blocks.append(None) | |
| with open(file_path, 'wb') as f: | |
| for off, cnt in new_locs: | |
| f.write(struct.pack('>I', (off << 8) | (cnt & 0xFF))) | |
| for ts in timestamps: | |
| f.write(struct.pack('>I', ts)) | |
| for b in blocks: | |
| if b is not None: | |
| f.write(b) | |
| pad = SECTOR_SIZE - (len(b) % SECTOR_SIZE) | |
| if pad != SECTOR_SIZE: | |
| f.write(b'\x00' * pad) | |
| remaining = SECTOR_SIZE - (f.tell() % SECTOR_SIZE) | |
| if remaining != SECTOR_SIZE: | |
| f.write(b'\x00' * remaining) | |
| def process_region_file(folder, filename): | |
| file_path = os.path.join(folder, filename) | |
| try: | |
| print(f" Processing {filename}...") | |
| locs, timestamps = read_region_header(file_path) | |
| payloads = [None] * 1024 | |
| modified = False | |
| checked = cleared = 0 | |
| for i in range(1024): | |
| off, cnt = locs[i] | |
| if off == 0 and cnt == 0: | |
| continue | |
| raw = read_chunk_data(file_path, off, cnt) | |
| if raw is None: | |
| continue | |
| try: | |
| chunk = nbt.NBTFile(buffer=io.BytesIO(raw)) | |
| level = chunk.get('Level', chunk) | |
| x = level['xPos'].value | |
| z = level['zPos'].value | |
| dist = chunk_distance(x, z) | |
| if dist > DISTANCE_THRESHOLD: | |
| clear_chunk(chunk) | |
| payloads[i] = build_chunk_payload(chunk) | |
| cleared += 1 | |
| modified = True | |
| else: | |
| payloads[i] = build_chunk_payload(chunk) | |
| checked += 1 | |
| except Exception as e: | |
| print(f" [ERROR] chunk {i}: {e}") | |
| if raw: | |
| try: | |
| payloads[i] = build_chunk_payload( | |
| nbt.NBTFile(buffer=io.BytesIO(raw))) | |
| except Exception: | |
| pass | |
| if modified: | |
| update_region_file(file_path, locs, timestamps, payloads) | |
| new_size = os.path.getsize(file_path) | |
| print(f" => File size after update: {new_size} bytes") | |
| print(f" => Updated {filename} ({cleared}/{checked} chunks cleared)") | |
| else: | |
| print(f" => No chunks needed clearing ({checked} checked)") | |
| except Exception as e: | |
| print(f" !! Failed to process {filename}: {e} – skipping file") | |
| def main(): | |
| if len(sys.argv) < 2: | |
| print("Usage: python clear_distant_chunks.py <region_folder>") | |
| sys.exit(1) | |
| folder = sys.argv[1] | |
| if not os.path.isdir(folder): | |
| print(f"'{folder}' is not a directory.") | |
| sys.exit(1) | |
| files = sorted(f for f in os.listdir(folder) if f.endswith('.mca')) | |
| total_files = len(files) | |
| if not files: | |
| print("No .mca files found.") | |
| return | |
| print(f"Found {total_files} region files. Threshold: {DISTANCE_THRESHOLD} blocks " | |
| f"from spawn ({SPAWN_X}, {SPAWN_Z})\n") | |
| for i, f in enumerate(files, 1): | |
| percentage = (i / total_files) * 100 | |
| print(f"[{i}/{total_files} - {percentage:.1f}%] {f}") | |
| process_region_file(folder, f) | |
| print() | |
| print("🎉 All done!") | |
| if __name__ == '__main__': | |
| main() |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Usage:
-> Generate chunks using a mod (i generate chunky)
-> Run the script against the folder containing the mca files.
-> It will clear anything outside further than DISTANCE_THRESHOLD from (spawn_x, spawn_y). If you want to clear the entire world, set it to 0