Skip to content

Wand / Brushes / mcstructures#8

Open
MakiTazo wants to merge 6 commits into
iciency:mainfrom
MakiTazo:wand-stack
Open

Wand / Brushes / mcstructures#8
MakiTazo wants to merge 6 commits into
iciency:mainfrom
MakiTazo:wand-stack

Conversation

@MakiTazo

Copy link
Copy Markdown

Added all this features

TODO:

  • Block Data (Endstone API still read-only)

@qodo-code-review

Copy link
Copy Markdown

Review Summary by Qodo

Add brush system, mcstructure loading, stack command

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Added brush system with sphere, cylinder, cube shapes
• Implemented mask command for brush filtering
• Added mcstructure file loading support
• Added stack command for cloning selections
• Enhanced wand tool with NBT identification
Diagram
flowchart LR
  A["Wand Tool<br/>NBT Identification"] --> B["Brush System<br/>Sphere/Cylinder/Cube"]
  B --> C["Mask Command<br/>Block Filtering"]
  B --> D["Brush Execution<br/>on Right Click"]
  E["MCStructure<br/>File Reader"] --> F["Load Command<br/>Place Blocks"]
  G["Stack Command<br/>Clone Selection"] --> H["Direction Detection<br/>Based on Yaw"]
  D --> I["Plugin Event<br/>Handler Integration"]
  F --> I
  H --> I
Loading

Grey Divider

File Changes

1. src/endstone_worldedit/commands/brush.py ✨ Enhancement +272/-0

Brush system with multiple shape types

• New brush command system supporting sphere, cylinder, cube shapes
• Brush binding to items with NBT metadata and display names
• Execute brush function with hollow and mask support
• Raycast targeting for brush placement

src/endstone_worldedit/commands/brush.py


2. src/endstone_worldedit/commands/mask.py ✨ Enhancement +71/-0

Brush mask filtering command

• New mask command to set block filtering for brushes
• Updates brush NBT data with mask information
• Updates item lore to display mask status

src/endstone_worldedit/commands/mask.py


3. src/endstone_worldedit/commands/mcs.py ✨ Enhancement +178/-0

MCStructure file loading and placement

• MCStructureReader class to parse .mcstructure NBT files
• Load and list subcommands for structure management
• Two-pass block placement for solid and dependent blocks
• Undo history support for structure placement

src/endstone_worldedit/commands/mcs.py


View more (3)
4. src/endstone_worldedit/commands/stack.py ✨ Enhancement +96/-0

Selection cloning in player direction

• New stack command to clone selections in player direction
• Direction detection based on player yaw angle
• Support for multiple stacks with count parameter
• Async task support for large operations

src/endstone_worldedit/commands/stack.py


5. src/endstone_worldedit/commands/wand.py ✨ Enhancement +12/-3

Wand tool NBT identification system

• Added NBT tag to wand item for identification
• Enhanced wand with custom display name and lore
• Prevents vanilla wooden axe from being used as wand

src/endstone_worldedit/commands/wand.py


6. src/endstone_worldedit/plugin.py ✨ Enhancement +31/-15

Plugin integration for brushes and wand NBT

• Updated API version to 0.11
• Added mcstructures directory creation and config path
• Enhanced wand detection using NBT tags instead of item type
• Added brush item handling in player interact event
• Integrated brush execution with raycast targeting
• Removed redundant comments and code cleanup

src/endstone_worldedit/plugin.py


Grey Divider

Qodo Logo

@qodo-code-review

qodo-code-review Bot commented May 17, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0)

Grey Divider


Action required

1. MCStructure path traversal ✓ Resolved 🐞 Bug ⛨ Security
Description
/mcs load builds the file path from raw user input via os.path.join without validating the name, so
".." segments can escape the configured mcstructures directory and load arbitrary accessible
*.mcstructure files. This is an authorization boundary bypass on the server filesystem.
Code

src/endstone_worldedit/commands/mcs.py[R79-84]

+        name = args[1]
+        file_path = os.path.join(mcstructures_path, f"{name}.mcstructure")
+
+        if not os.path.exists(file_path):
+            sender.send_message(f"Structure '{name}.mcstructure' not found.")
+            return False
Evidence
The loader uses args[1] directly in os.path.join(...) and only checks existence, which does not
prevent ../ traversal to paths outside the base directory.

src/endstone_worldedit/commands/mcs.py[74-85]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`/mcs load <name>` allows directory traversal because `<name>` is inserted directly into a path.

### Issue Context
Current logic:
- `file_path = os.path.join(mcstructures_path, f"{name}.mcstructure")`
- Only `os.path.exists(file_path)` is checked.

### Fix Focus Areas
- src/endstone_worldedit/commands/mcs.py[74-85]

### What to change
- Restrict `name` to a safe filename pattern (e.g. `^[A-Za-z0-9._-]+$`) and reject path separators.
- Additionally, enforce directory containment:
 - `base = os.path.abspath(mcstructures_path)`
 - `candidate = os.path.abspath(os.path.join(base, f"{name}.mcstructure"))`
 - verify `candidate.startswith(base + os.sep)` before loading.
- Keep the friendly "not found" message for invalid names.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Item NBT overwritten 🐞 Bug ☼ Reliability
Description
/brush (bind/unbind) and /mask replace the entire item.nbt CompoundTag, which can wipe unrelated
item data (e.g., enchantments/durability/custom NBT) and cause irreversible inventory data loss.
Unbinding with CompoundTag() also clears everything rather than removing only WorldEdit keys.
Code

src/endstone_worldedit/commands/brush.py[R26-33]

+    if brush_type == "none":
+        item = sender.inventory.item_in_main_hand
+        if item and item.nbt and "worldedit" in item.nbt and item.nbt["worldedit"].value == "brush":
+            item.nbt = CompoundTag()
+            sender.send_message("Brush unbound from current item.")
+        else:
+            sender.send_message("No brush bound to current item.")
+        return True
Evidence
The code explicitly assigns a brand-new CompoundTag to item.nbt in multiple places, discarding any
pre-existing NBT on that item.

src/endstone_worldedit/commands/brush.py[26-33]
src/endstone_worldedit/commands/brush.py[101-117]
src/endstone_worldedit/commands/mask.py[48-55]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
WorldEdit brush/mask operations currently overwrite the entire `ItemStack.nbt`, which can delete unrelated NBT data.

### Issue Context
- `/brush none` sets `item.nbt = CompoundTag()`.
- Brush binding sets `item.nbt = CompoundTag({"worldedit": ..., "brush": ...})`.
- `/mask` similarly overwrites `item.nbt`.

### Fix Focus Areas
- src/endstone_worldedit/commands/brush.py[26-33]
- src/endstone_worldedit/commands/brush.py[101-117]
- src/endstone_worldedit/commands/mask.py[48-55]

### What to change
- When binding/updating, start from existing NBT:
 - `nbt = item.nbt or CompoundTag()`
 - set/update only `nbt["worldedit"]` and `nbt["brush"]`
 - assign back `item.nbt = nbt`
- When unbinding, delete only WorldEdit keys:
 - `del nbt["worldedit"]` / `del nbt["brush"]` if present (or set them to neutral values)
 - do **not** replace the entire tag with an empty CompoundTag.
- Consider also restoring/clearing display name/lore when unbinding to avoid leaving misleading metadata.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Invalid block ID prefix ✓ Resolved 🐞 Bug ≡ Correctness
Description
execute_brush() unconditionally prefixes the stored block with "minecraft:", so namespaced inputs
(e.g. "minecraft:stone") become invalid IDs like "minecraft:minecraft:stone" and brush placement
fails or places nothing. /mask similarly forces a "minecraft:" prefix and can create masks that
never match existing blocks.
Code

src/endstone_worldedit/commands/brush.py[R141-178]

+def execute_brush(plugin, player, block, brush_data):
+    brush_type = brush_data["type"].value
+    radius = brush_data["radius"].value
+    block_type = brush_data["block"].value
+    hollow = brush_data["hollow"].value == "true"
+    mask = None
+    if "mask" in brush_data and brush_data["mask"].value != "none":
+        mask = brush_data["mask"].value
+    if "height" in brush_data:
+        height = brush_data["height"].value
+    else:
+        height = 1
+
+    player_uuid = player.unique_id
+    dimension = player.dimension
+    cx, cy, cz = block.x, block.y, block.z
+    blocks_to_change = []
+    if brush_type == "sphere":
+        for x in range(cx - radius, cx + radius + 1):
+            for y in range(cy - radius, cy + radius + 1):
+                for z in range(cz - radius, cz + radius + 1):
+                    distance = math.sqrt((x - cx) ** 2 + (y - cy) ** 2 + (z - cz) ** 2)
+                    if hollow:
+                        if not (radius - 1 < distance <= radius):
+                            continue
+                    elif distance > radius:
+                        continue
+                    
+                    if mask:
+                        try:
+                            existing = dimension.get_block_at(x, y, z)
+                            if str(existing.type) != mask:
+                                continue
+                        except RuntimeError:
+                            continue
+                    
+                    blocks_to_change.append((x, y, z, f"minecraft:{block_type}", None))
+
Evidence
Brush execution always constructs a new ID by prefixing, and mask storage does the same, so any
already-prefixed input becomes double-prefixed. The repo already contains an example of correct
prefixing logic in replace.py.

src/endstone_worldedit/commands/brush.py[141-199]
src/endstone_worldedit/commands/mask.py[23-33]
src/endstone_worldedit/commands/replace.py[17-19]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
Brush placement and mask matching break when the user provides already-namespaced block IDs, because the code always prepends `minecraft:`.

### Issue Context
- `execute_brush()` uses `f"minecraft:{block_type}"` for placement.
- `/mask` stores `f"minecraft:{mask_type}"`.
- Other commands in this repo use a safer pattern: only prefix when `":" not in value`.

### Fix Focus Areas
- src/endstone_worldedit/commands/brush.py[141-219]
- src/endstone_worldedit/commands/mask.py[23-33]
- src/endstone_worldedit/commands/replace.py[17-19]

### What to change
- Introduce a small helper (local or shared) like:
 - `def normalize_block_id(s: str) -> str: return s if ":" in s else f"minecraft:{s}"`
- Apply it consistently for:
 - brush bind storage (store normalized ID), and/or
 - brush execution (normalize before `set_type`), and
 - mask storage (normalize unless `none`).
- Ensure comparisons use the same normalized form (compare `str(existing.type)` to normalized mask).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
4. MCS runs fully sync ✓ Resolved 🐞 Bug ☼ Reliability
Description
/mcs load checks async-threshold but both branches execute synchronously, so large structures will
run the full placement loop on the main thread and can freeze the server tick. This defeats the
purpose of async-threshold and differs from the existing /schem implementation that schedules
plugin.tasks.
Code

src/endstone_worldedit/commands/mcs.py[R154-173]

+        def execute_pass(blocks_pass):
+            for x, y, z, block_type, states in blocks_pass:
+                try:
+                    block = dimension.get_block_at(x, y, z)
+                    block.set_type(block_type)
+                    if states:
+                        state_str = ",".join(f'"{k}":{v}' for k, v in states.items())
+                        command = f"setblock {x} {y} {z} {block_type} [{state_str}]"
+                        plugin.server.dispatch_command(plugin.silent_sender, command)
+                except RuntimeError as e:
+                    plugin.logger.error(f"Skipping block '{block_type}': {e}")
+                    continue
+
+        if len(blocks_to_change) > plugin.plugin_config["async-threshold"]:
+            execute_pass(solid_pass)
+            execute_pass(dependent_pass)
+        else:
+            execute_pass(solid_pass)
+            execute_pass(dependent_pass)
+
Evidence
The async-threshold conditional is currently a no-op (identical bodies), while schem.py uses
plugin.tasks when a pass is large.

src/endstone_worldedit/commands/mcs.py[154-173]
src/endstone_worldedit/commands/schem.py[186-206]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`/mcs load` never schedules async tasks even when the block count exceeds `async-threshold`.

### Issue Context
- Both branches under the threshold check call `execute_pass()` directly.
- `/schem load` already demonstrates the intended pattern: schedule `plugin.tasks[player_uuid]` for large passes.

### Fix Focus Areas
- src/endstone_worldedit/commands/mcs.py[154-173]
- src/endstone_worldedit/commands/schem.py[186-206]

### What to change
- Make `execute_pass()` mirror `/schem`:
 - if `len(blocks_pass) > async-threshold`, store into `plugin.tasks[player_uuid]` (or a queued multi-pass structure) and message the user.
 - otherwise execute inline.
- Ensure dependent_pass runs after solid_pass when using tasks (may require a small queue/state machine per player).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

5. Unsafe state command build ✓ Resolved 🐞 Bug ≡ Correctness
Description
/mcs load converts NBT 'states' into a setblock command string without quoting/escaping string
values, so generated commands can be malformed and state application will fail or behave
unpredictably. Because the command is built from external file content and dispatched, this also
increases risk from unexpected characters in keys/values.
Code

src/endstone_worldedit/commands/mcs.py[R117-163]

+                    states = {}
+                    if 'states' in block_info:
+                        for state_key, state_val in block_info['states'].items():
+                            try:
+                                states[state_key] = int(state_val)
+                            except (TypeError, ValueError):
+                                states[state_key] = str(state_val)
+
+                    blocks_to_change.append((target_x, target_y, target_z, block_name, states))
+
+        if not blocks_to_change:
+            sender.send_message("Structure is empty or only contains air.")
+            return True
+
+        sender.send_message(f"Placing {len(blocks_to_change)} blocks...")
+
+        dependent_blocks = [
+            "flower", "sapling", "mushroom", "torch", "rail", "redstone_wire", "repeater", "comparator",
+            "sign", "door", "lever", "button", "pressure_plate", "tripwire_hook", "tripwire", "banner"
+        ]
+
+        solid_pass = [b for b in blocks_to_change if not any(d in b[3] for d in dependent_blocks)]
+        dependent_pass = [b for b in blocks_to_change if any(d in b[3] for d in dependent_blocks)]
+
+        full_undo_entry = []
+        for x, y, z, _, _ in blocks_to_change:
+            try:
+                old = dimension.get_block_at(x, y, z)
+                full_undo_entry.append((x, y, z, str(old.type), old.data))
+            except RuntimeError:
+                continue
+
+        if player_uuid not in plugin.undo_history:
+            plugin.undo_history[player_uuid] = []
+        plugin.undo_history[player_uuid].append(full_undo_entry)
+        plugin.redo_history[player_uuid] = []
+
+        def execute_pass(blocks_pass):
+            for x, y, z, block_type, states in blocks_pass:
+                try:
+                    block = dimension.get_block_at(x, y, z)
+                    block.set_type(block_type)
+                    if states:
+                        state_str = ",".join(f'"{k}":{v}' for k, v in states.items())
+                        command = f"setblock {x} {y} {z} {block_type} [{state_str}]"
+                        plugin.server.dispatch_command(plugin.silent_sender, command)
+                except RuntimeError as e:
Evidence
The code explicitly stores non-integer state values as strings, but later interpolates them into the
command without quotes or escaping, producing malformed command text for typical string-valued
states.

src/endstone_worldedit/commands/mcs.py[117-123]
src/endstone_worldedit/commands/mcs.py[154-163]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
State serialization for `setblock` is not safely/consistently encoded:
- string state values are stored as `str(state_val)` but later emitted without quoting.
- keys/values are not escaped.

### Issue Context
- `states[state_key] = str(state_val)` for non-int values.
- `state_str = ",".join(f'"{k}":{v}' ...)` emits unquoted strings.
- The resulting string is dispatched as a server command.

### Fix Focus Areas
- src/endstone_worldedit/commands/mcs.py[117-123]
- src/endstone_worldedit/commands/mcs.py[159-163]

### What to change
- If block state application is not reliable yet, consider removing the per-block `dispatch_command` path (place only `set_type`) until a correct encoder exists.
- Otherwise, implement a strict encoder:
 - whitelist allowed state key characters
 - quote/escape string values
 - generate the exact syntax expected by the server command parser
- Add error handling/logging for failed dispatches (currently only RuntimeError is caught).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Unhandled get_block_at error 🐞 Bug ☼ Reliability
Description
/stack reads all blocks in the selection with dimension.get_block_at() without catching
RuntimeError, so an invalid or partially out-of-bounds selection can crash the command before it can
report a user-friendly error. Later code does catch RuntimeError for placement bounds, so this is
inconsistent and avoidable.
Code

src/endstone_worldedit/commands/stack.py[R48-52]

+    for x in range(int(min_x), int(max_x) + 1):
+        for y in range(int(min_y), int(max_y) + 1):
+            for z in range(int(min_z), int(max_z) + 1):
+                block = dimension.get_block_at(x, y, z)
+                original_blocks.append((x, y, z, block.type, block.data))
Evidence
The selection read loop has no error handling, while later placement validation explicitly catches
RuntimeError and aborts, showing the API can throw and the command already expects that possibility.

src/endstone_worldedit/commands/stack.py[47-53]
src/endstone_worldedit/commands/stack.py[72-78]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`/stack` does not handle RuntimeError when reading the source selection blocks.

### Issue Context
- Source read loop uses `dimension.get_block_at(x, y, z)` with no exception handling.
- Placement pre-check later *does* catch RuntimeError and aborts cleanly.

### Fix Focus Areas
- src/endstone_worldedit/commands/stack.py[47-53]
- src/endstone_worldedit/commands/stack.py[72-78]

### What to change
- Wrap the source read (`get_block_at`) in try/except RuntimeError.
- If any source block is out-of-bounds, abort early with a clear message (similar to the placement abort), or skip invalid blocks depending on desired semantics.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment thread src/endstone_worldedit/commands/brush.py
Comment on lines +26 to +33
if brush_type == "none":
item = sender.inventory.item_in_main_hand
if item and item.nbt and "worldedit" in item.nbt and item.nbt["worldedit"].value == "brush":
item.nbt = CompoundTag()
sender.send_message("Brush unbound from current item.")
else:
sender.send_message("No brush bound to current item.")
return True

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Item nbt overwritten 🐞 Bug ☼ Reliability

/brush (bind/unbind) and /mask replace the entire item.nbt CompoundTag, which can wipe unrelated
item data (e.g., enchantments/durability/custom NBT) and cause irreversible inventory data loss.
Unbinding with CompoundTag() also clears everything rather than removing only WorldEdit keys.
Agent Prompt
### Issue description
WorldEdit brush/mask operations currently overwrite the entire `ItemStack.nbt`, which can delete unrelated NBT data.

### Issue Context
- `/brush none` sets `item.nbt = CompoundTag()`.
- Brush binding sets `item.nbt = CompoundTag({"worldedit": ..., "brush": ...})`.
- `/mask` similarly overwrites `item.nbt`.

### Fix Focus Areas
- src/endstone_worldedit/commands/brush.py[26-33]
- src/endstone_worldedit/commands/brush.py[101-117]
- src/endstone_worldedit/commands/mask.py[48-55]

### What to change
- When binding/updating, start from existing NBT:
  - `nbt = item.nbt or CompoundTag()`
  - set/update only `nbt["worldedit"]` and `nbt["brush"]`
  - assign back `item.nbt = nbt`
- When unbinding, delete only WorldEdit keys:
  - `del nbt["worldedit"]` / `del nbt["brush"]` if present (or set them to neutral values)
  - do **not** replace the entire tag with an empty CompoundTag.
- Consider also restoring/clearing display name/lore when unbinding to avoid leaving misleading metadata.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread src/endstone_worldedit/commands/mcs.py
Comment thread src/endstone_worldedit/commands/mcs.py Outdated
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant