Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 

Repository files navigation

Minespector

Minecraft world chunk scanner — integrity check + player stash detection.


What It Does

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.


What It Scans

STASH_BLOCKS (54 entries, 3 weight tiers)

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).

Entities (scanned per chunk)

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 (if Items ≥ 1). Also horses/donkeys/mules/llamas with ChestedHorse == 1 (line 575). Stored as chested_entities with type, Y coordinate, and item count.
  • Mob spawners — block_entities entries with id == "minecraft:mob_spawner" are checked for SpawnData.id to 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-only blocks (auto-flag trigger)

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).

Sticky piston (auto-flag trigger)

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).


What It Filters

Dungeons (line 620)

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.

Structure fingerprints (STRUCTURE_FINGERPRINTS, lines 105–183)

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 present
  • not 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).

Village block filter (lines 311–319, 420–422)

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.

Low-weight suppression (lines 424–427)

If max(STASH_BLOCKS.get(b, 0) for all blocks) < 10 and no has_high signal → score returns ("", 0).


Clustering and Scoring

Cluster algorithm (cluster_findings, line 246)

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.

Confidence percentage (_cluster_pct, lines 321–333)

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.

Severity labels (score_group, lines 335–449)

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

Cluster summary (_cluster_summary, lines 268–295)

Per cluster display:

  • tp: center_x center_y center_z — average of all block positions
  • spread: dx×dy×dz — bounding box dimensions
  • dense: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)

Architecture

File: minespector/__init__.py (1041 lines)

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.

NBT parser (lines 24–93)

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 via struct.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.

Region file reader (_scan_region, lines 472–626)

Arguments: (dim_label, mca_path_str, rx, rz, mtime=0)

Steps per call:

  1. Opens file, mmaps entire file (line 481)
  2. Reads offset table: 1024 entries at data[i*4], uint32 BE (lines 487–488)
  3. Reads timestamp table: struct.unpack_from(">1024I", data, 4096) (line 485)
  4. For each non-zero sector offset: extract chunk data, decompress (lines 488–503)
  5. Parse NBT (line 506)
  6. Scan block_entities for containers (lines 511–518)
  7. Scan sections' block_states for stash blocks (lines 520–554)
  8. Scan entities for hostile mobs, chested entities (lines 556–584)
  9. Scan block_entities again for mob_spawner SpawnData (lines 578–584)
  10. Detect biome (lines 588–593)
  11. Detect structure from palette_all (lines 596–610)
  12. 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 gzip

Empty 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.

Block coordinate decoding (_decode_palette_indices, lines 459–469)

Minecraft's compacted long array format:

bits = max(4, (palette_size - 1).bit_length())
mask = (1 << bits) - 1
vals_per_long = 64 // bits

Each 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).


Cache

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.


Two Display Screens

Results screen (lines 720–895)

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_hit is true (line 861) — set whenever even one cached region is loaded

Keys: s → StashResults, d → delete cache, Esc → pop screen.

StashResults screen (lines 897–1019)

Shows clustered palette findings. Features:

  • Live filter — Input widget 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 between bold red and bold white on red every 1.2s via set_interval (line 918, 1017–1019).
  • Fade-in — CSS opacity: 0 → opacity: 1.0 on mount (line 917).
  • Key / focuses filter input (bound as "slash" at line 901).

Controls

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 Persistence

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.


Entry Point

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").


Performance (development benchmarks)

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).


Install

pip install .                     # Requires Python 3.8+
pip install python-isal           # Optional: ISA-L decompression

To run without install: python -m minespector

About

Minecraft world save scanning for stashes.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Contributors

Languages