Minecraft world chunk scanner — integrity check + player stash detection.
Reads .mca region files directly from a Minecraft save directory (no world
load required). Two independent detection paths:
1. Container item scan — Iterates block_entities (or legacy
TileEntities) looking for containers with Items ≥ 1 and no LootTable
tag. Reports type, coordinates, and item stack count. Shown on the first
results screen.
2. Block palette scan — Iterates every chunk's sections, reads the
block_states.palette, and checks for any block name in STASH_BLOCKS.
Decodes block_states.data (compacted long array) to extract exact (x, y, z) positions per block. Shown on the second results screen (s key)
with clustering and confidence scoring.
| Block | Weight | Line |
|---|---|---|
ender_chest |
100 | 186 |
shulker_box + 15 color variants |
95 | 187–203 |
sticky_piston |
95 | 218 |
barrel |
25 | 204 |
anvil, chipped_anvil, damaged_anvil |
20 | 205–207 |
furnace, blast_furnace, smoker |
15 | 208–210 |
chest, trapped_chest |
10 | 211–212 |
| Redstone components (wires, repeaters, comparators, pistons, observers, dispensers, droppers, hoppers, torches, blocks, lamps, rails, etc.) | 8 | 214–242 |
crafting_table |
3 | 213 |
tripwire |
3 | 243 |
Weight 8 items: redstone_wire, repeater, comparator, piston,
observer, dispenser, dropper, hopper, redstone_torch,
redstone_block, daylight_detector, lectern, grindstone,
stonecutter, loom, cartography_table, fletching_table,
smithing_table, lever, redstone_lamp, note_block, target,
copper_bulb, crafter, tripwire_hook, powered_rail,
detector_rail, activator_rail. All weight 8 (line 214–242).
Weight is used only in clustering confidence — detection itself is binary (present in palette or not).
Three entity signals are checked (lines 556–584):
- Hostile mobs —
zombie(excluding zombie_villager),spider(excluding cave_spider),skeleton. Used for dungeon detection. - Chested entities —
chest_minecart,chest_boat,chest_raft(ifItems≥ 1). Also horses/donkeys/mules/llamas withChestedHorse == 1(line 575). Stored aschested_entitieswith type, Y coordinate, and item count. - Mob spawners —
block_entitiesentries withid == "minecraft:mob_spawner"are checked forSpawnData.idto identify zombie/spider/skeleton spawners for dungeon detection (lines 578–584).
Entity IDs are stored in the finding as entities (sorted hostile mob
list, or first 5 entity IDs if no hostiles found) (line 622).
PLAYER_BLOCKS (lines 298–302):
enchanting_table, anvil, chipped_anvil, damaged_anvil,
respawn_anchor, lodestone, jukebox
If any chunk in a cluster has any of these in its palette_all set →
STASH! (multi-chunk cluster, confidence 95) or STASH (single chunk,
confidence 88) — no further scoring (line 364–367).
If any chunk in a cluster has minecraft:sticky_piston in its blocks
dict → STASH! (multi-chunk, 98) or STASH (single, 95) (line 370–373).
A chunk is flagged as dungeon (skipped in clustering) when:
(hostile_mobs is non-empty OR mossy_cobblestone in palette_all)
AND chest in found
Where hostile_mobs = any of zombie, spider, skeleton detected from
entity list or mob_spawner SpawnData.
Each structure entry: (signature_blocks, y_range, biomes, natural_blocks).
Detection in _scan_region (lines 601–610):
sig.issubset(palette_all)— all signature blocks must be presentnot biomes or biome in biomes— biome must match if biomes are specified
Suppression in score_group (lines 351–357): if a chunk has a detected
structure AND min_y of its blocks falls within y_range AND all found
blocks are a subset of natural_blocks → chunk is removed from scoring.
If ANY chunk in a cluster has a detected structure, that structure type
propagates to adjacent chunks without a detection, so they're filtered
against the same natural_blocks set (line 341–345).
| Structure | Signature blocks | Y range | Biomes | Lines |
|---|---|---|---|---|
| Village (plains) | dirt_path, oak_planks, cobblestone | 60–70 | plains, sunflower_plains | 106–110 |
| Village (desert) | smooth_sandstone, cut_sandstone, terracotta | 60–70 | desert | 111–115 |
| Village (savanna) | dirt_path, acacia_planks, cobblestone | 60–70 | savanna | 116–120 |
| Village (taiga) | dirt_path, spruce_planks, cobblestone | 60–70 | taiga, old_growth_pine_taiga, old_growth_spruce_taiga | 121–125 |
| Village (snowy) | dirt_path, spruce_planks, cobblestone | 60–70 | snowy_plains, snowy_taiga | 126–130 |
| Desert pyramid | blue_terracotta, orange_terracotta, cut_sandstone | 60–70 | desert | 131–135 |
| Igloo | light_gray_carpet, snow_block | 62–70 | snowy_plains, snowy_taiga, ice_spikes, frozen_river | 136–140 |
| Jungle temple | mossy_cobblestone, chiseled_stone_bricks, vine | 50–80 | jungle, bamboo_jungle | 141–145 |
| Pillager outpost | dark_oak_log, dark_oak_planks, white_wool | 62–72 | plains, desert, savanna, taiga, snowy_plains, snowy_taiga | 146–150 |
| Shipwreck | oak_fence, spruce_planks, spruce_stairs | 35–65 | ocean variants, beach | 151–155 |
| Ruined portal | obsidian, lava, stone_brick_stairs | 20–80 | (any) | 156–160 |
| Trial chambers | tuff_bricks | −40–40 | (any) | 161–165 |
| End city | purpur_block, purpur_pillar, end_rod | 60–150 | end_midlands, end_highlands | 166–170 |
| Ocean monument | prismarine | 30–55 | deep_cold_ocean, deep_ocean, deep_lukewarm_ocean, cold_ocean, deep_frozen_ocean, lukewarm_ocean, warm_ocean, ocean | 171–183 |
Trial chambers has a fallback detection in deep_dark biome: if biome is
deep_dark and all found blocks are in trial_chambers natural_blocks
set and Y is within −40–40, it's flagged as trial_chambers (lines 605–610).
If all_blocks.issubset(VILLAGE_BLOCKS) and no has_high signal → score
returns ("", 0) (suppressed).
VILLAGE_BLOCKS (lines 314–319) = NATURAL_STRUCTURE_BLOCKS ∪
grindstone, fletching_table, smoker, blast_furnace, barrel,
smithing_table, stonecutter, loom, composter, lectern,
cartography_table, note_block.
NATURAL_STRUCTURE_BLOCKS (lines 306–309) = chest, trapped_chest,
furnace, crafting_table.
If max(STASH_BLOCKS.get(b, 0) for all blocks) < 10 and no has_high
signal → score returns ("", 0).
O(n²) distance check. Two chunks are in the same cluster if Manhattan distance in chunk coordinates ≤ radius 3:
abs(f["chunk_x"] - g["chunk_x"]) <= 3
AND
abs(f["chunk_z"] - g["chunk_z"]) <= 3
(radius 3 chunks ≈ 48 blocks). Clusters with only 1 member become "lone chunks" instead.
base_score = min(100, max_block_weight × 4)
+15 if multi-chunk cluster
+10 if density > 0.3
+15 if has_high (ender_chest, shulker_box, or donkey/mule entity)
+25 if any chunk has player_blocks
+30 if any chunk has sticky_piston in blocks
+5 if ≥ 3 distinct block types
−25 if all blocks are chest/trapped_chest only
clamp(0, 99)
has_high (lines 413–417) = any block in all_blocks that starts with
minecraft:ender_chest, minecraft:shulker_box, contains _shulker_box,
or is a virtual entity block (§-prefixed) containing donkey or mule.
Density = block_count / ((dx+1) × (dy+1) × (dz+1)) (line 404).
Volume uses the bounding box of all block positions. dense = density > 0.3
(line 404), which upgrades severity labels.
| Label | Meaning |
|---|---|
STASH! |
Multi-chunk cluster + high signals, or single chunk with player_blocks/sticky_piston |
STASH |
Multi-chunk with moderate signals, or single chunk with has_high |
STASH? |
Single chunk, chest count ≥ 3, or lone high-weight block at extreme Y |
maybe |
Lone medium-weight block, or single chest at extreme Y |
"" |
Suppressed — filtered as natural generation |
Per cluster display:
tp: center_x center_y center_z— average of all block positionsspread: dx×dy×dz— bounding box dimensionsdense:value— if density > 0.3 (styled green)multi-level— if dy ≥ 10 (styled cyan)tight— if dx ≤ 3 and dz ≤ 3 (styled green)has ender chest— if ender_chest in seen_blocks (styled bold magenta)player: block1,block2— player blocks found in the cluster (styled bold green)
One-file design. NBT parser, region decoder, scanner, clustering engine, scoring functions, structure fingerprint database, cache layer, and Textual TUI all in a single module.
Custom parser (in-code comment lines 17–20 explains why: RapidNBT forces full-tree Python dict conversion, custom parser builds dicts only for tags the scanner touches).
Supported tag types:
| Tag | Type | Parser behavior |
|---|---|---|
| 0 | TAG_End | Returns (None, None) |
| 1 | TAG_Byte | struct.unpack_from("b", ...) |
| 2 | TAG_Short | struct.unpack_from(">h", ...) |
| 3 | TAG_Int | struct.unpack_from(">i", ...) (i4 helper) |
| 4 | TAG_Long | struct.unpack_from(">q", ...) (i8 helper) |
| 5 | TAG_Float | struct.unpack_from(">f", ...) |
| 6 | TAG_Double | struct.unpack_from(">d", ...) |
| 7 | TAG_Byte_Array | Returns _LazyArray(data, offset, length, 1) — defers list conversion |
| 8 | TAG_String | 2-byte length prefix + UTF-8 decode |
| 9 | TAG_List | Element type byte + 4-byte count, eagerly builds Python list |
| 10 | TAG_Compound | Recursive dict build until TAG_End |
| 11 | TAG_Int_Array | Returns _LazyArray(data, offset, count, 4) — defers list conversion |
| 12 | TAG_String | Returns _LazyArray(data, offset, count, 8) — defers list conversion (yes the comment says TAG_String but the code says 12=long_array at line 90) |
_LazyArray class (lines 26–52):
to_list()— batch unpack viastruct.unpack_from(single call per array type). For elsize=1 uses slice copy.__iter__— yields one element at a time via struct.unpack_from__getitem__— computes offset and unpacks one element__len__— returns stored count
Read helpers use a mutable off = [0] list to pass offset by reference
through closures.
Arguments: (dim_label, mca_path_str, rx, rz, mtime=0)
Steps per call:
- Opens file, mmaps entire file (line 481)
- Reads offset table: 1024 entries at
data[i*4], uint32 BE (lines 487–488) - Reads timestamp table:
struct.unpack_from(">1024I", data, 4096)(line 485) - For each non-zero sector offset: extract chunk data, decompress (lines 488–503)
- Parse NBT (line 506)
- Scan block_entities for containers (lines 511–518)
- Scan sections' block_states for stash blocks (lines 520–554)
- Scan entities for hostile mobs, chested entities (lines 556–584)
- Scan block_entities again for mob_spawner SpawnData (lines 578–584)
- Detect biome (lines 588–593)
- Detect structure from palette_all (lines 596–610)
- Return 7-tuple:
(dim_label, name, chunk_count, errors, stashes, palette_findings, mtime)(line 626)
Compression handling (line 502):
{1: gzip.decompress, 2: zlib.decompress, 3: lambda x: x}ISA-L acceleration via try/except ImportError (lines 5–10):
try:
import isal_zlib as zlib
import igzip as gzip
except ImportError:
import zlib
import gzipEmpty file handling: st_size == 0 returns early with empty results
(line 476–477).
Error cases logged: offset past EOF, chunk overruns file, chunk length
exceeds sector claim, decompression failure, NBT parse failure (lines
492–508). Each error includes region_name [local_x,local_z]: message.
Minecraft's compacted long array format:
bits = max(4, (palette_size - 1).bit_length())
mask = (1 << bits) - 1
vals_per_long = 64 // bitsEach 64-bit long contains vals_per_long block indices, least significant
bits first. Result truncated to 4096 (section size).
Block position from index (lines 545–549):
x = chunk_x * 16 + (index & 0xF) # bits 0–3
y = section_y * 16 + (index >> 8) # bits 8+
z = chunk_z * 16 + ((index >> 4) & 0xF) # bits 4–7(YZX order: Y is the 9th bit and above, Z is bits 4–7, X is bits 0–3).
If no block_states.data field (empty section), positions are
approximated as chunk origin + section Y (line 553–554).
File: .minespector_cache.json in the world directory (line 747).
Key format: "{dim_label}/{mca_filename}" (line 807) — dimension
prefix avoids collisions between Overworld, Nether, and End.
Value stored (lines 839–840):
{"mtime": float, "chunk_count": int,
"errors": list, "stashes": list,
"palette_findings": list}Hit condition (line 809): cached["mtime"] == file.stat().st_mtime
Cache save: After all regions scanned, json.dumps(cache) written to
cache file (lines 845–848). Exception silently caught.
Cache delete: d key on Results screen (line 723 binding, lines
743–754 implementation). Uses Path.unlink(missing_ok=True). Blocked
during active scan.
Old format migration (_normalize_finding_blocks, lines 451–457): If
a cached finding has blocks values that are int or float (old format
stored {name: min_y}), converts to {name: [(x, y, z)]} using chunk
origin as approximate position.
Shows after scan completes. Displays:
- World path (shortened to last 3 segments) (line 860)
- Per-dimension: region count, chunk count, error count, stash count
- Container stash list:
[x, y, z] type items stack(s) region - Error list with BAD prefix
- "World Loaded Through Caching" in bold red if any cache hit occurred
- Cached hit flag set at line 817:
self._cache_hit = True - Cache hit shown if
self._cache_hitis true (line 861) — set whenever even one cached region is loaded
Keys: s → StashResults, d → delete cache, Esc → pop screen.
Shows clustered palette findings. Features:
- Live filter —
Inputwidget at top,/to focus (line 920–921). Filters on block name substring (case-insensitive), entity type, and entity ID (lines 927–936). - Clusters rendered as
═══ Cluster #N (M chunks, LABEL, P%) ═══sorted by severity rank then cluster size descending (lines 983–984). Rank: STASH! > STASH > STASH? > maybe. - Lone chunks rendered as
── label (P%) ── - Empty filter shows
No stashes were found :(in bold red (line 1013). - Pulse animation —
STASH!label alternates betweenbold redandbold white on redevery 1.2s viaset_interval(line 918, 1017–1019). - Fade-in — CSS
opacity: 0→opacity: 1.0on mount (line 917). - Key
/focuses filter input (bound as "slash" at line 901).
| Key | Screen | Action | Implementation |
|---|---|---|---|
↑ |
DimPicker | Move cursor up | action_move_up (lines 707) |
↓ |
DimPicker | Move cursor down | action_move_down (lines 708) |
Space |
DimPicker | Toggle dimension | action_toggle (lines 710–713) |
Enter |
DimPicker | Scan selected | action_scan (lines 714–716) |
s |
Results | Open palette scan | action_stash (lines 891–895) |
d |
Results | Delete cache | action_delete_cache (lines 743–754) |
/ |
StashResults | Focus filter | action_focus_filter (lines 920–921) |
Esc |
Picker | Quit | Bound to "quit" (line 631) |
Esc |
DimPicker/Results/StashResults | Back | Bound to "app.pop_screen" (lines 644, 722, 900) |
q |
All | Quit | Lines 631, 644, 722, 900 |
Theme saved to ~/.minespector_theme.json (line 1022). On app mount
reads theme name from file, defaults to "textual-dark" (line 1026–1027).
_watch_theme watcher (lines 1032–1035) saves on every theme change
(including Command Palette changes). Uses Textual's App.theme property.
pyproject.toml (14 lines, lines 1–14):
- Build system: setuptools
- Console script:
minespector = "minespector:main" __main__.py(2 lines):from . import main; main()
main() (lines 1037–1041): argparse with optional positional argument
worlds_dir, defaulting to ~/.minecraft/saves. Calls
MinespectorApp().run().
MinespectorApp.on_mount (lines 1025–1031): Lists world directories
that have a level.dat file. If multiple found, shows a Picker screen
(select world). If one found, shows DimPicker directly. If none found,
calls self.exit("No Minecraft saves found").
Measured during development on a 1024-chunk region with populated chunks:
| Change | Per-region time | Speedup |
|---|---|---|
| Baseline (stdlib zlib, no mmap, ProcessPoolExecutor) | ~3.8s | 1× |
mmap for region files |
~2.8s | 1.27× |
python-isal (ISA-L) decompression |
~1.9s | 2.0× |
Custom NBT with _LazyArray (defers LongArray/IntArray) |
~1.8s | 2.1× |
Parallelism: ProcessPoolExecutor(max_workers=2) — 1.9× wall-clock
speedup vs sequential. Capped at 2 to avoid memory crash from spawning
8+ Python processes (each worker holds a decompressed region in memory).
Per-region time varies with chunk population — an empty region (0 chunks) returns in ~0.01s (line 476–477 early exit).
pip install . # Requires Python 3.8+
pip install python-isal # Optional: ISA-L decompressionTo run without install: python -m minespector