Skip to content

Prevent NBTBlock read access from creating empty chunk PDC compounds#351

Open
Kepchyk1101 wants to merge 1 commit intotr7zw:masterfrom
Kepchyk1101:bugfix/nbtblock-create-on-read
Open

Prevent NBTBlock read access from creating empty chunk PDC compounds#351
Kepchyk1101 wants to merge 1 commit intotr7zw:masterfrom
Kepchyk1101:bugfix/nbtblock-create-on-read

Conversation

@Kepchyk1101
Copy link
Copy Markdown

Summary

This PR prevents NBTBlock#getData() from creating persistent chunk PDC compounds during read-only access.

Currently, simply checking a tag like this:

new NBTBlock(block).getData().hasTag("SomeKey");

creates the backing blocks compound and the block-location compound inside the chunk PersistentDataContainer, even when no data exists and the caller only wanted to read.

On larger servers, plugins may check many block locations. This can cause unnecessary persistent chunk data growth from empty compounds.

Expected Behavior

Read-only access should not mutate chunk PDC data.

new NBTBlock(block).getData().hasTag("SomeKey");

should only check whether the tag exists.

Previous Behavior

NBTBlock#getData() used:

getOrCreateCompound("blocks")
    .getOrCreateCompound(blockKey)

so even read-only access created persistent data.

Changes

  • NBTBlock#getData() now returns a lazy block compound wrapper.
  • Missing block data is only created when a real write happens.
  • Existing write behavior is preserved.
  • Empty block compounds are cleaned up after removing the last key.
  • Added a runtime test to ensure hasTag and removeKey on missing data do not create persistent block compounds.

Compatibility

Existing write usage continues to work:

new NBTBlock(block).getData().setString("SomeKey", "SomeValue");

The only behavior change is that read-only access no longer creates missing persistent compounds.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 23, 2026

Walkthrough

The changes implement lazy storage creation and smart cleanup for block-specific NBT data. A new prepareWrite() hook is added to NBTCompound to allow subclasses to initialize backing data before mutations. An NBTBlockCompound wrapper is introduced in NBTBlock that defers storage creation until needed and removes empty compounds to prevent unnecessary persistence. Tests verify that reading/removing missing block data does not create unwanted persistent entries.

Changes

Cohort / File(s) Summary
Core Hook Mechanism
item-nbt-api/src/main/java/de/tr7zw/changeme/nbtapi/NBTCompound.java, item-nbt-api/src/main/java/de/tr7zw/changeme/nbtapi/NBTReflectionUtil.java
Added protected prepareWrite() no-op hook in NBTCompound and integrated calls to it before mutations (addNBTTagCompound, mergeOtherNBTCompound, set, setData) in NBTReflectionUtil.
Block Storage Implementation
item-nbt-api/src/main/java/de/tr7zw/changeme/nbtapi/NBTBlock.java
Introduced NBTBlockCompound wrapper class that overrides getResolvedObject()/getCompound() to return null when per-block compound is missing, implements prepareWrite() for lazy storage creation, and removes block entries (and parent "blocks" container if empty) when setting null or empty compounds. Empty detection uses reflection-based key enumeration.
Block Storage Tests
item-nbt-plugin/src/main/java/de/tr7zw/nbtapi/plugin/tests/blocks/BlockNBTTest.java
Extended test to verify that empty NBTBlock data and missing key removals do not create or persist the "blocks" compound entry in chunk persistent storage.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: preventing NBTBlock read access from creating empty chunk PDC compounds, which aligns directly with the primary objective of the PR.
Description check ✅ Passed The description is well-related to the changeset, providing clear context on the problem, expected behavior, changes made, and compatibility considerations that directly correspond to the modifications in the code.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@sonarqubecloud
Copy link
Copy Markdown

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
item-nbt-plugin/src/main/java/de/tr7zw/nbtapi/plugin/tests/blocks/BlockNBTTest.java (1)

28-55: Good coverage for the read/remove-on-missing paths; consider also asserting the cleanup-after-write path.

The new assertions cover the two regressions this PR fixes (read does not create, remove-missing does not create). One gap that matches the PR description ("Empty block compounds are removed after the last key is deleted") is not actually exercised end-to-end: after setString("Too","Bar") + a subsequent removeKey("Too"), we never verify that the "blocks" compound is fully cleaned up from the chunk PDC.

🧪 Suggested extra assertion at the end of the test
                     comp.getData().setString("Too", "Bar");
                     if (!new NBTBlock(block).getData().getString("Too").equals("Bar")) {
                         throw new NbtApiException("Custom Data did not save to a Block!");
                     }
+                    comp.getData().removeKey("Too");
+                    blocks = persistentData.getCompound("blocks");
+                    if (blocks != null && blocks.hasTag(blockKey)) {
+                        throw new NbtApiException("Removing the last Block key did not clean up the persistent block compound!");
+                    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@item-nbt-plugin/src/main/java/de/tr7zw/nbtapi/plugin/tests/blocks/BlockNBTTest.java`
around lines 28 - 55, Add an assertion that verifies the "blocks" compound is
removed after writing then deleting the block key: after
comp.getData().setString("Too","Bar") and confirming the write via new
NBTBlock(block).getData().getString("Too"), call comp.getData().removeKey("Too")
(or new NBTBlock(block).getData().removeKey("Too")) and then re-check the chunk
persistent data (via new NBTChunk(chunk).getPersistentDataContainer()) to ensure
persistentData.getCompound("blocks") is null or does not contain blockKey,
throwing an NbtApiException if the cleanup did not occur. Ensure you reference
the same blockKey/persistentData/blocks variables so the test exercises the
empty-compound removal path end-to-end.
item-nbt-api/src/main/java/de/tr7zw/changeme/nbtapi/NBTBlock.java (1)

44-114: Design looks solid; a couple of small robustness notes.

Nice approach overall — making NBTBlockCompound act as a virtual root (parent=null, name=null) means NBTReflectionUtil.getToCompount leaves the tag unchanged and getCompound()/getResolvedObject() correctly return the per-block NMS compound. The self-cleaning setCompound also handles the merge-empty-source and remove-last-key cases cleanly, so there's no path I could find that leaves an orphaned empty block compound.

A few minor observations:

  1. Write path does redundant work. On every write, prepareWrite() walks getOrCreateCompound("blocks").getOrCreateCompound(blockKey) to materialize the entry, and then setCompound(compound) immediately writes the same reference back via persistentDataContainer.getOrCreateCompound("blocks").set(blockKey, compound). It's correct (the set on the same reference is effectively a no-op on modern NMS), but the two navigations + two saveCompound() propagations per write are avoidable. Not blocking — just something to keep in mind if this becomes a hot path.

  2. isEmptyCompound mirrors established codebase patterns. ReflectionMethod.COMPOUND_GET_KEYS.run(compound) is used identically in NBTItem.java:170 and NBTReflectionUtil.java:764 without null-checks, reflecting consistent reliance on the contract that getKeys(), getAllKeys(), and keySet() return non-null across all supported NMS versions (1.7–1.21+). Current implementation is aligned with that pattern.

  3. readOnly is not propagated. super(null, null) always constructs with readOnly=false, regardless of whether nbtChunk.getPersistentDataContainer() is writable. Current API always returns a writable PDC, so this isn't a bug today, but it's worth noting for any future API that would add a read-only chunk PDC.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@item-nbt-api/src/main/java/de/tr7zw/changeme/nbtapi/NBTBlock.java` around
lines 44 - 114, The NBTBlockCompound currently does duplicate navigation on
writes and always constructs as writable; fix by (1) eliminating the redundant
getOrCreateCompound call in prepareWrite()/setCompound: have prepareWrite() call
getOrCreateData() and store the resulting NBTCompound in a transient field
(e.g., cachedWriteTarget) that setCompound() uses to write or remove (clear the
cache after use), so you don't re-traverse
persistentDataContainer.getOrCreateCompound("blocks").getOrCreateCompound(blockKey);
and (2) propagate the persistentDataContainer's readOnly state into the
NBTBlockCompound constructor instead of always calling super(null, null) as
writable (e.g., call super with the persistentDataContainer's readOnly flag or
otherwise pass its mutability) so read-only PDCs remain read-only.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@item-nbt-api/src/main/java/de/tr7zw/changeme/nbtapi/NBTBlock.java`:
- Around line 44-114: The NBTBlockCompound currently does duplicate navigation
on writes and always constructs as writable; fix by (1) eliminating the
redundant getOrCreateCompound call in prepareWrite()/setCompound: have
prepareWrite() call getOrCreateData() and store the resulting NBTCompound in a
transient field (e.g., cachedWriteTarget) that setCompound() uses to write or
remove (clear the cache after use), so you don't re-traverse
persistentDataContainer.getOrCreateCompound("blocks").getOrCreateCompound(blockKey);
and (2) propagate the persistentDataContainer's readOnly state into the
NBTBlockCompound constructor instead of always calling super(null, null) as
writable (e.g., call super with the persistentDataContainer's readOnly flag or
otherwise pass its mutability) so read-only PDCs remain read-only.

In
`@item-nbt-plugin/src/main/java/de/tr7zw/nbtapi/plugin/tests/blocks/BlockNBTTest.java`:
- Around line 28-55: Add an assertion that verifies the "blocks" compound is
removed after writing then deleting the block key: after
comp.getData().setString("Too","Bar") and confirming the write via new
NBTBlock(block).getData().getString("Too"), call comp.getData().removeKey("Too")
(or new NBTBlock(block).getData().removeKey("Too")) and then re-check the chunk
persistent data (via new NBTChunk(chunk).getPersistentDataContainer()) to ensure
persistentData.getCompound("blocks") is null or does not contain blockKey,
throwing an NbtApiException if the cleanup did not occur. Ensure you reference
the same blockKey/persistentData/blocks variables so the test exercises the
empty-compound removal path end-to-end.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 05ab2a56-8b96-4abf-8b0a-b12a521502e2

📥 Commits

Reviewing files that changed from the base of the PR and between 5776be4 and 96dfe8b.

📒 Files selected for processing (4)
  • item-nbt-api/src/main/java/de/tr7zw/changeme/nbtapi/NBTBlock.java
  • item-nbt-api/src/main/java/de/tr7zw/changeme/nbtapi/NBTCompound.java
  • item-nbt-api/src/main/java/de/tr7zw/changeme/nbtapi/NBTReflectionUtil.java
  • item-nbt-plugin/src/main/java/de/tr7zw/nbtapi/plugin/tests/blocks/BlockNBTTest.java


@Override
protected void prepareWrite() {
getOrCreateData();
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This logic is a bit wonky. Before a write, it getOrCreates the tag so it's implicitly there for the later getCompound call. Trying to think if there's some cleaner way. Issue is, this api is not correctly changed to the interface, otherwise could have some "empty" impl for known empty data. But can't do that without issues at this point.

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.

2 participants