Minimap support

This commit is contained in:
BruceChen 2026-03-29 18:47:40 +08:00
parent c2475ea9e5
commit 6631180f8a
17 changed files with 5961 additions and 6 deletions

View file

@ -135,6 +135,44 @@ Data source: `https://raw.githubusercontent.com/PrismarineJS/minecraft-data/mast
Uses `curl` with resume (`-C -`) for reliable download over slow connections. Falls back to manual download if retries are exhausted.
## gen_block_color_map.py -- Generate minimap block color JSON
Extracts block-to-MapColor RGB mappings from decompiled Minecraft source for the TUI minimap.
```bash
python3 tools/gen_block_color_map.py MinecraftOfficial/26.1-rc-2-decompiled
# -> MinecraftClient/Tui/MinimapBlockColors.json
```
Parses three files from the decompiled source:
- `MapColor.java` -- extracts the 64 base MapColor constants and their RGB values
- `DyeColor.java` -- maps dye colors to MapColor constants
- `Blocks.java` -- determines each block's assigned MapColor via `.mapColor()` calls
Output: `MinecraftClient/Tui/MinimapBlockColors.json` (embedded as a resource via `.csproj`). Contains color entries, plus lists of transparent, water, and ice materials.
Validates each block name against MCC's `Material.cs` enum. Blocks without a matching enum value are skipped.
## gen_entity_category_map.py -- Generate minimap entity category JSON
Extracts entity-to-MobCategory mappings from decompiled Minecraft source for the TUI minimap.
```bash
python3 tools/gen_entity_category_map.py MinecraftOfficial/26.1-rc-2-decompiled
# -> MinecraftClient/Tui/MinimapEntityCategories.json
```
Parses `EntityType.java` to read each entity's `MobCategory` assignment from the `EntityType.Builder.of(Factory, MobCategory.XXX)` call. Maps Minecraft categories to MCC minimap categories:
- `MONSTER` -> hostile
- `CREATURE`/`AMBIENT`/`AXOLOTLS`/`WATER_*` -> passive
- `MISC` -> non_living
The script maintains manual override lists for:
- **Neutral mobs** (e.g. Enderman, Spider, Wolf, Bee) -- Minecraft has no "neutral" category; these are MONSTER or CREATURE in code but only attack when provoked
- **Passive overrides** (e.g. Villager, WanderingTrader) -- classified as MISC in Minecraft for spawning reasons but should appear as passive on the minimap
Output: `MinecraftClient/Tui/MinimapEntityCategories.json` (embedded as a resource via `.csproj`). Validates each entity name against MCC's `EntityType.cs` enum.
## Recommended workflow
1. Generate server reports (Step 0)
@ -145,6 +183,9 @@ Uses `curl` with resume (`-C -`) for reliable download over slow connections. Fa
- Entities: `gen_entity_palette.py`
- Metadata: `gen_entity_metadata_palette.py`
4. Update block collision shapes: `gen_block_shapes.py`
5. Add any missing enum values to `ItemType.cs`, `Material.cs`, `EntityType.cs`, `EntityMetaDataType.cs`
6. Update version routing (see SKILL.md)
7. Build and test
5. Update minimap data (if blocks or entities changed):
- Block colors: `gen_block_color_map.py`
- Entity categories: `gen_entity_category_map.py`
6. Add any missing enum values to `ItemType.cs`, `Material.cs`, `EntityType.cs`, `EntityMetaDataType.cs`
7. Update version routing (see SKILL.md)
8. Build and test

View file

@ -0,0 +1,268 @@
#!/usr/bin/env python3
"""
Generate MinimapBlockColors.json from decompiled Minecraft source.
Parses MapColor.java for the 62 base map colors (ID -> RGB), then parses
Blocks.java to extract each block's mapColor assignment, and outputs a
JSON mapping from MCC Material enum names (PascalCase) to RGB triples.
Usage:
python3 tools/gen_block_color_map.py <decompiled_root>
Example:
python3 tools/gen_block_color_map.py MinecraftOfficial/26.1-rc-2-decompiled
"""
import json
import re
import sys
from pathlib import Path
OUTPUT_PATH = (Path(__file__).resolve().parent.parent
/ "MinecraftClient" / "Tui" / "MinimapBlockColors.json")
MATERIAL_CS = (Path(__file__).resolve().parent.parent
/ "MinecraftClient" / "Mapping" / "Material.cs")
def mc_name_to_csharp(mc_name: str) -> str:
name = mc_name.removeprefix("minecraft:")
return "".join(word.capitalize() for word in name.split("_"))
def parse_map_colors(map_color_java: Path) -> dict[str, tuple[int, int, int]]:
"""Parse MapColor.java: extract name -> (R, G, B) for each constant."""
text = map_color_java.read_text()
colors: dict[str, tuple[int, int, int]] = {}
pattern = re.compile(
r'public static final MapColor\s+(\w+)\s*=\s*new\s+MapColor\(\s*(\d+)\s*,\s*(\d+)\s*\)')
for m in pattern.finditer(text):
name = m.group(1)
color_int = int(m.group(3))
r = (color_int >> 16) & 0xFF
g = (color_int >> 8) & 0xFF
b = color_int & 0xFF
colors[name] = (r, g, b)
return colors
def parse_dye_to_map_color(dye_color_java: Path) -> dict[str, str]:
"""Parse DyeColor.java: extract DyeColor name -> MapColor name."""
text = dye_color_java.read_text()
mapping: dict[str, str] = {}
pattern = re.compile(
r'(\w+)\(\d+,\s*"[^"]+",\s*\d+,\s*MapColor\.(\w+)')
for m in pattern.finditer(text):
mapping[m.group(1)] = m.group(2)
return mapping
def extract_block_declarations(text: str) -> list[tuple[str, str, str]]:
"""Extract (field_name, block_id, full_register_body) for each block declaration.
Returns list of (FIELD_NAME, "block_name", "register(...) content").
"""
results = []
# Find all "public static final Block FIELD = register(...)" declarations.
# These span multiple lines and end with ");".
# Strategy: find start pattern, then track parens to find matching end.
field_pattern = re.compile(
r'public\s+static\s+final\s+Block\s+(\w+)\s*=\s*register\s*\(')
pos = 0
while pos < len(text):
m = field_pattern.search(text, pos)
if not m:
break
field_name = m.group(1)
paren_start = m.end() - 1 # position of opening '('
# Find matching closing ')' then ';'
depth = 1
i = paren_start + 1
while i < len(text) and depth > 0:
if text[i] == '(':
depth += 1
elif text[i] == ')':
depth -= 1
i += 1
register_body = text[paren_start:i]
# Extract block name string from register call
name_match = re.search(r'(?:BlockIds\.(\w+)|"(\w+)")', register_body)
if name_match:
raw_id = name_match.group(1) or name_match.group(2)
block_id = raw_id.lower() if raw_id.isupper() else raw_id
else:
block_id = field_name.lower()
results.append((field_name, block_id, register_body))
pos = i
return results
def parse_blocks(blocks_java: Path, map_colors: dict[str, tuple[int, int, int]],
dye_to_map: dict[str, str]) -> dict[str, tuple[int, int, int]]:
"""Parse Blocks.java: extract block_name -> (R, G, B)."""
text = blocks_java.read_text()
declarations = extract_block_declarations(text)
print(f" Found {len(declarations)} block register() declarations")
# First pass: assign MapColor name to each block
field_to_block_id: dict[str, str] = {}
block_color_name: dict[str, str] = {}
map_color_direct = re.compile(r'\.mapColor\(MapColor\.(\w+)\)')
map_color_dye = re.compile(r'\.mapColor\(DyeColor\.(\w+)\)')
map_color_ref = re.compile(r'\.mapColor\((\w+)\.defaultMapColor\(\)')
map_color_waterlogged = re.compile(r'\.mapColor\(waterloggedMapColor\(MapColor\.(\w+)\)')
for field_name, block_id, body in declarations:
field_to_block_id[field_name] = block_id
mc = map_color_direct.search(body)
if mc:
block_color_name[block_id] = mc.group(1)
continue
mc = map_color_dye.search(body)
if mc:
dye_name = mc.group(1)
if dye_name in dye_to_map:
block_color_name[block_id] = dye_to_map[dye_name]
continue
mc = map_color_waterlogged.search(body)
if mc:
block_color_name[block_id] = mc.group(1)
continue
mc = map_color_ref.search(body)
if mc:
ref_field = mc.group(1)
ref_block = field_to_block_id.get(ref_field)
if ref_block and ref_block in block_color_name:
block_color_name[block_id] = block_color_name[ref_block]
# Second pass: resolve remaining BLOCK.defaultMapColor() references
for field_name, block_id, body in declarations:
if block_id in block_color_name:
continue
mc = map_color_ref.search(body)
if mc:
ref_field = mc.group(1)
ref_block = field_to_block_id.get(ref_field)
if ref_block and ref_block in block_color_name:
block_color_name[block_id] = block_color_name[ref_block]
result: dict[str, tuple[int, int, int]] = {}
for block_id, color_name in block_color_name.items():
if color_name in map_colors:
cs_name = mc_name_to_csharp(block_id)
result[cs_name] = map_colors[color_name]
return result
def load_known_materials() -> set[str]:
known = set()
if MATERIAL_CS.exists():
with open(MATERIAL_CS) as f:
for line in f:
m = re.match(r'\s+(\w+),?\s*$', line)
if m:
known.add(m.group(1))
return known
TRANSPARENT_BLOCKS = [
"Air", "CaveAir", "VoidAir",
"Glass", "GlassPane",
"WhiteStainedGlass", "OrangeStainedGlass", "MagentaStainedGlass",
"LightBlueStainedGlass", "YellowStainedGlass", "LimeStainedGlass",
"PinkStainedGlass", "GrayStainedGlass", "LightGrayStainedGlass",
"CyanStainedGlass", "PurpleStainedGlass", "BlueStainedGlass",
"BrownStainedGlass", "GreenStainedGlass", "RedStainedGlass",
"BlackStainedGlass",
"WhiteStainedGlassPane", "OrangeStainedGlassPane", "MagentaStainedGlassPane",
"LightBlueStainedGlassPane", "YellowStainedGlassPane", "LimeStainedGlassPane",
"PinkStainedGlassPane", "GrayStainedGlassPane", "LightGrayStainedGlassPane",
"CyanStainedGlassPane", "PurpleStainedGlassPane", "BlueStainedGlassPane",
"BrownStainedGlassPane", "GreenStainedGlassPane", "RedStainedGlassPane",
"BlackStainedGlassPane",
"TintedGlass", "Barrier", "Light", "StructureVoid",
]
WATER_BLOCKS = ["Water"]
ICE_BLOCKS = ["Ice", "PackedIce", "BlueIce", "FrostedIce"]
def main():
if len(sys.argv) != 2:
print(__doc__)
sys.exit(1)
root = Path(sys.argv[1])
if not root.is_dir():
print(f"Error: {root} is not a directory")
sys.exit(1)
map_color_java = root / "net/minecraft/world/level/material/MapColor.java"
dye_color_java = root / "net/minecraft/world/item/DyeColor.java"
blocks_java = root / "net/minecraft/world/level/block/Blocks.java"
for f in [map_color_java, dye_color_java, blocks_java]:
if not f.exists():
print(f"Error: {f} not found")
sys.exit(1)
print("Parsing MapColor.java...")
map_colors = parse_map_colors(map_color_java)
print(f" Found {len(map_colors)} map colors")
print("Parsing DyeColor.java...")
dye_to_map = parse_dye_to_map_color(dye_color_java)
print(f" Found {len(dye_to_map)} dye->map color mappings")
print("Parsing Blocks.java...")
block_colors = parse_blocks(blocks_java, map_colors, dye_to_map)
print(f" Extracted colors for {len(block_colors)} blocks")
known_materials = load_known_materials()
if known_materials:
matched = {k: v for k, v in block_colors.items() if k in known_materials}
unmatched = [k for k in block_colors if k not in known_materials]
if unmatched:
print(f"\n {len(unmatched)} blocks not in Material.cs (will be skipped):")
for name in sorted(unmatched)[:20]:
print(f" {name}")
if len(unmatched) > 20:
print(f" ... and {len(unmatched) - 20} more")
block_colors = matched
print(f" {len(block_colors)} blocks matched to Material.cs entries")
output = {
"version": root.name.replace("-decompiled", "").replace("-client", ""),
"colors": {k: list(v) for k, v in sorted(block_colors.items())},
"transparent": sorted(TRANSPARENT_BLOCKS),
"water": WATER_BLOCKS,
"ice": ICE_BLOCKS,
}
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(OUTPUT_PATH, 'w') as f:
json.dump(output, f, indent=2)
print(f"\nGenerated {OUTPUT_PATH}")
print(f" {len(block_colors)} color entries")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""
Generate MinimapEntityCategories.json from decompiled Minecraft source.
Parses EntityType.java to extract each entity's MobCategory assignment,
then maps them to MCC minimap categories (hostile/passive/neutral/non_living).
Minecraft's MobCategory values:
MONSTER -> hostile (with neutral overrides for conditionally hostile mobs)
CREATURE -> passive (with neutral overrides for conditionally hostile mobs)
AMBIENT -> passive
AXOLOTLS -> passive
WATER_CREATURE -> passive
WATER_AMBIENT -> passive
UNDERGROUND_WATER_CREATURE -> passive
MISC -> non_living
Some mobs classified as MONSTER or CREATURE are actually "neutral" -- they
only attack when provoked. These are listed in NEUTRAL_OVERRIDES below and
should be updated when new conditionally-hostile mobs are added.
Usage:
python3 tools/gen_entity_category_map.py <decompiled_root>
Example:
python3 tools/gen_entity_category_map.py MinecraftOfficial/26.1-rc-2-decompiled
"""
import json
import re
import sys
from pathlib import Path
OUTPUT_PATH = (Path(__file__).resolve().parent.parent
/ "MinecraftClient" / "Tui" / "MinimapEntityCategories.json")
ENTITY_TYPE_CS = (Path(__file__).resolve().parent.parent
/ "MinecraftClient" / "Mapping" / "EntityType.cs")
def mc_name_to_csharp(mc_name: str) -> str:
name = mc_name.removeprefix("minecraft:")
return "".join(word.capitalize() for word in name.split("_"))
# Mobs that Minecraft classifies as MONSTER or CREATURE but behave as
# "neutral" -- they only attack when provoked. This list is maintained
# manually because there is no machine-readable flag in the game data.
NEUTRAL_OVERRIDES = {
"bee", "dolphin", "goat", "iron_golem", "llama", "panda",
"polar_bear", "snow_golem", "trader_llama", "wolf",
"zombified_piglin", "enderman", "spider", "cave_spider",
"copper_golem",
}
# Entities whose MobCategory in the game code doesn't match how they
# should appear on the minimap. For example, Villager and WanderingTrader
# are MISC in MC code (for spawning reasons) but should be passive on the map.
# ZombieHorse is MONSTER but is a rideable passive mob in practice.
PASSIVE_OVERRIDES = {
"villager", "wandering_trader", "zombie_horse",
}
# Player has its own category in MCC -- extracted from MISC to "player".
PLAYER_OVERRIDES = {"player"}
MC_TO_MCC = {
"MONSTER": "hostile",
"CREATURE": "passive",
"AMBIENT": "passive",
"AXOLOTLS": "passive",
"WATER_CREATURE": "passive",
"WATER_AMBIENT": "passive",
"UNDERGROUND_WATER_CREATURE": "passive",
"MISC": "non_living",
}
def extract_entity_categories(entity_type_java: Path) -> list[tuple[str, str, str]]:
"""Extract (entity_id, field_name, MobCategory) from EntityType.java.
Returns list of (entity_id, FIELD_NAME, MobCategory_name).
"""
text = entity_type_java.read_text()
results = []
field_pat = re.compile(
r'public\s+static\s+final\s+EntityType<[^>]+>\s+(\w+)\s*=\s*register\s*\(')
pos = 0
while pos < len(text):
m = field_pat.search(text, pos)
if not m:
break
field_name = m.group(1)
paren_start = m.end() - 1
depth = 1
i = paren_start + 1
while i < len(text) and depth > 0:
if text[i] == '(':
depth += 1
elif text[i] == ')':
depth -= 1
i += 1
body = text[paren_start:i]
name_match = re.search(r'"(\w+)"', body)
entity_id = name_match.group(1) if name_match else field_name.lower()
cat_match = re.search(r'MobCategory\.(\w+)', body)
mob_cat = cat_match.group(1) if cat_match else "MISC"
results.append((entity_id, field_name, mob_cat))
pos = i
return results
def load_known_entity_types() -> set[str]:
known = set()
if ENTITY_TYPE_CS.exists():
with open(ENTITY_TYPE_CS) as f:
for line in f:
m = re.match(r'\s+(\w+),?\s*$', line)
if m:
known.add(m.group(1))
return known
def main():
if len(sys.argv) != 2:
print(__doc__)
sys.exit(1)
root = Path(sys.argv[1])
entity_type_java = root / "net/minecraft/world/entity/EntityType.java"
if not entity_type_java.exists():
print(f"Error: {entity_type_java} not found")
sys.exit(1)
print("Parsing EntityType.java...")
entities = extract_entity_categories(entity_type_java)
print(f" Found {len(entities)} entity type declarations")
known_types = load_known_entity_types()
hostile = []
passive = []
neutral = []
non_living = []
for entity_id, field_name, mob_cat in entities:
cs_name = mc_name_to_csharp(entity_id)
if known_types and cs_name not in known_types:
continue
if entity_id in PLAYER_OVERRIDES:
continue
elif entity_id in NEUTRAL_OVERRIDES:
neutral.append(cs_name)
elif entity_id in PASSIVE_OVERRIDES:
passive.append(cs_name)
elif mob_cat in MC_TO_MCC:
cat = MC_TO_MCC[mob_cat]
if cat == "hostile":
hostile.append(cs_name)
elif cat == "passive":
passive.append(cs_name)
elif cat == "non_living":
non_living.append(cs_name)
else:
non_living.append(cs_name)
else:
non_living.append(cs_name)
output = {
"version": root.name.replace("-decompiled", "").replace("-client", ""),
"hostile": sorted(hostile),
"passive": sorted(passive),
"neutral": sorted(neutral),
"non_living": sorted(non_living),
}
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(OUTPUT_PATH, 'w') as f:
json.dump(output, f, indent=2)
print(f"\nGenerated {OUTPUT_PATH}")
print(f" hostile: {len(hostile)}")
print(f" passive: {len(passive)}")
print(f" neutral: {len(neutral)}")
print(f" non_living: {len(non_living)}")
print(f" total: {len(hostile) + len(passive) + len(neutral) + len(non_living)}")
if __name__ == "__main__":
main()