Skip to content

feat: item-backed container menus (Phase 3) - #14

Merged
KP2048 merged 4 commits into
1.21.xfrom
feature-item-container-menus
Aug 8, 2026
Merged

feat: item-backed container menus (Phase 3)#14
KP2048 merged 4 commits into
1.21.xfrom
feature-item-container-menus

Conversation

@KP2048

@KP2048 KP2048 commented Aug 7, 2026

Copy link
Copy Markdown
Member

Phase 3 of the pre-release storage/GUI plan: item-backed container menus

Adds ComposeItemContainerMenu, an ItemStack-backed sibling of the existing BlockEntity-backed
menu (renamed ComposeContainerMenuComposeBlockContainerMenu to match) — e.g. for a
backpack/bag with its own GUI.

3a — base-class extraction

Extracted ComposeContainerMenuBase<SELF> from ComposeContainerMenu, holding the ~95% of slot
layout/registration/quickMoveStack machinery that's already fully holder-agnostic.
ComposeBlockContainerMenu keeps tile/blockEntityState/BlockEntityStateManager
registration; new onMenuOpened()/onMenuClosed(player) hooks let each subclass register
whatever state-tracking its holder needs. Added isPlayerSlotExcluded(index) to freeze one
player-inventory slot in place (mayPlace/mayPickup false, special-cased in quickMoveStack
too) — used by ComposeItemContainerMenu to stop a backpack's own inventory slot accepting
placement or being shift-clicked into itself while its GUI is open. ComposeContainerScreen drops
its second (BlockEntity) type parameter, now <T : ComposeContainerMenuBase<T>>.

Caught and fixed before it shipped: calling onMenuOpened() from
ComposeContainerMenuBase's own init block dispatched into the subclass's override before
the subclass's own constructor-parameter properties (tile/itemAccess) were assigned — a
"leaking this during construction" NPE trap. Each concrete subclass now calls it from its own
init, after its own state is ready.

3b — @Sync wiring for item-backed NBTHolder fields

New SyncedItemHolder interface. ItemStackNBTHolderImpl's field declarations now push through
it the same way NBTHolderImpl already does for BlockEntity, and getSyncTag() is implemented
for real (was a stub returning an empty tag).

Also fixed a real, pre-existing bug this surfaced (not introduced by this change, but first
actually exercised by it): ItemStackNBTHolderImpl's init { loadFromStack() } runs before any
field is declared, so loadFromTag's itemStorage/fluidStorage/energyStorage re-hydration
loop always iterated an empty map — itemField/fluidField/energyField never actually loaded a
previously-saved stack's data into the freshly-constructed storage. Fixed by hydrating each
storage directly from already-loaded raw data at declaration time instead.

3c — the menu itself

ItemContainerAccess (+ PlayerInventoryItemAccess) locates the backing stack, re-resolved fresh
every call since the underlying reference can be swapped out from under the menu.
ComposeItemState/ItemStatePacket/ItemUpdatePacket mirror the BlockEntity state-sync path,
reusing BlockEntityStatePacket.SerializedValue directly; routing needs no position-keyed lookup
on either side since an item-backed menu is inherently 1:1 with one player's session —
context.player.containerMenu is always "the current menu." ItemStateManager drives per-tick
dirty-property sync and force-closes a menu whose itemAccess.stillValid(player) goes false.

Verification

New ComposeItemContainerMenuTests (server, direct construction against
makeMockServerPlayerInLevel — no network round trip) covers slot-content persistence,
stillValid, @Sync serializer registration at declaration time, and ItemStateManager's real
tick-driven force-close. New ComposeItemContainerMenuClientTests (client, real
MenuRegistry.openExtendedMenu + a real TestItemContainerScreen layout pass) covers the
excluded-slot mayPlace/mayPickup/quickMoveStack behavior, which needs updateSlotData to
have actually run — unreachable from a server-only GameTest since packet registration itself is
skipped there (Archie.kt's own gating, unrelated to this feature).

[GameTest] PASS archie:composeitemcontainermenutests.testslotcontentpersiststhroughholder
[GameTest] PASS archie:composeitemcontainermenutests.testitemstatemanagerforceclosesinvalidmenu
[GameTest] PASS archie:composeitemcontainermenutests.teststillvalidreflectsitemaccess
[GameTest] PASS archie:composeitemcontainermenutests.testsyncedfieldregistersserializer
[ClientGameTest] PASS archie_test:testscreengametest.testshowcasescreenopensandconfirmdialogroundtrips
[ClientGameTest] PASS archie_test:composeitemcontainermenuclienttests.testexcludedslotbehaviorviarealmenuopen

Not covered — manual-only, matching precedent (full Compose rendering + real player interaction
isn't GameTest-covered anywhere else in this codebase either): the actual on-screen backpack
open/close/shift-click flow beyond what the client test above exercises, and copyOnDeath-style
cross-mod capability-lookup interop combining Phase 1's exposeItemStorage with a
Phase-3-backed backpack.

Docs: docs/gui.md gets a new ComposeBlockContainerMenu/ComposeItemContainerMenu section plus
an expanded state-sync paragraph covering observeItemProperty; docs/transfer.md's "Wiring
storage into a menu" section now shows both menu kinds.

Final piece of the three-feature pre-release plan (capability lookup / data attachments /
item-backed container menus) — #12 and #13 cover the first two.

Rebased onto latest 1.21.x (picking up #10/#11) before opening; both GameTest suites re-verified
green afterward.

🤖 Generated with Claude Code

KP2048 and others added 4 commits August 7, 2026 17:41
Add ComposeItemContainerMenu, an ItemStack-backed sibling of the existing
BlockEntity-backed menu (renamed ComposeContainerMenu -> ComposeBlockContainerMenu
to match) - e.g. for a backpack/bag with its own GUI.

## 3a: base-class extraction

Extract ComposeContainerMenuBase<SELF> from ComposeContainerMenu, holding the
~95% of slot layout/registration/quickMoveStack machinery that's already fully
holder-agnostic. ComposeBlockContainerMenu keeps tile/blockEntityState/
BlockEntityStateManager registration; new onMenuOpened()/onMenuClosed(player)
hooks let each subclass register whatever state-tracking its holder needs.
Add isPlayerSlotExcluded(index) to freeze one player-inventory slot in place
(mayPlace/mayPickup false, special-cased in quickMoveStack too) - used by
ComposeItemContainerMenu to stop a backpack's own inventory slot accepting
placement or being shift-clicked into itself while its GUI is open.
ComposeContainerScreen drops its second (BlockEntity) type parameter, now
`<T : ComposeContainerMenuBase<T>>`; provides LocalBlockEntityState/LocalItemState
conditionally based on which concrete menu type is actually open.

Caught and fixed before it shipped: calling onMenuOpened() from
ComposeContainerMenuBase's own init block dispatched into the subclass's
override before the subclass's own constructor-parameter properties (tile/
itemAccess) were assigned - a "leaking this during construction" NPE trap.
Each concrete subclass now calls it from its own init, after its own state
is ready.

## 3b: @sync wiring for item-backed NBTHolder fields

New SyncedItemHolder interface (registerSyncedProperty at declaration time +
onSyncedPropertyChanged on every write). ItemStackNBTHolderImpl's field/
listField/mapField/itemField/fluidField/energyField now push through it the
same way NBTHolderImpl already does for BlockEntity, and getSyncTag() is
implemented for real (was a stub returning an empty tag).

Also fixed a real, pre-existing bug this surfaced (not introduced by this
change, but first actually exercised by it): ItemStackNBTHolderImpl's
init { loadFromStack() } runs before any field is declared, so loadFromTag's
itemStorage/fluidStorage/energyStorage re-hydration loop always iterated an
empty map - itemField/fluidField/energyField never actually loaded a
previously-saved stack's data into the freshly-constructed storage. Fixed by
hydrating each storage directly from already-loaded raw data at declaration
time instead of relying on that loop.

## 3c: the menu itself

ItemContainerAccess (+ PlayerInventoryItemAccess) locates the backing stack,
re-resolved fresh every call since the underlying reference can be swapped
out from under the menu. ComposeItemState/ItemStatePacket/ItemUpdatePacket
mirror the BlockEntity state-sync path, reusing BlockEntityStatePacket.SerializedValue
directly; routing needs no position-keyed lookup on either side since an
item-backed menu is inherently 1:1 with one player's session - context.player.containerMenu
is always "the current menu." ItemStateManager drives per-tick dirty-property
sync and force-closes a menu whose itemAccess.stillValid(player) goes false.

## Verification

New ComposeItemContainerMenuTests (server, direct construction against
makeMockServerPlayerInLevel - no network round trip) covers slot-content
persistence, stillValid, @sync serializer registration at declaration time,
and ItemStateManager's real tick-driven force-close. New
ComposeItemContainerMenuClientTests (client, real MenuRegistry.openExtendedMenu
+ real TestItemContainerScreen layout pass) covers the excluded-slot mayPlace/
mayPickup/quickMoveStack behavior, which needs updateSlotData to have actually
run - unreachable from a server-only GameTest since packet registration itself
is skipped there (Archie.kt's own gating, unrelated to this feature).

All 4 server tests and both client tests (the new one plus the existing
TestScreenGameTest smoke test) pass under real fabric-test:runGametest /
runGametestClient runs.

Not covered - manual-only, matching precedent (full Compose rendering + real
player interaction isn't GameTest-covered anywhere else in this codebase
either): the actual on-screen backpack open/close/shift-click flow beyond
what the client test above exercises, and copyOnDeath-style cross-mod
capability-lookup interop combining Phase 1's exposeItemStorage with a
Phase-3-backed backpack.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ADeferredRegistryHolder.initClient() scheduled on Architectury's
LifecycleEvent.SETUP (Common Setup on NeoForge), which fires after
RegisterMenuScreensEvent - the event MenuRegistry.registerScreenFactory
itself listens for internally. Screens silently never registered on
NeoForge as a result.

Adds scheduleEarlyClientRegistration, an expect/actual that runs
immediately on Fabric (no staged registry events to race) and hooks
RegisterMenuScreensEvent directly on NeoForge, and removes the manual
per-mod workaround this previously required in Archie-Test.

Also fixes two build issues surfaced while verifying this end-to-end:
actualizer's stubUnfulfilledExpects() stub was leaking into common's
published jar/sourcesJar and neoforge's dev jar, which could win
Kotlin's actual-resolution over the real actual and (for any
non-trivial actual body) crash at runtime with "never actualized".
- ItemStackNBTHolderImpl.updateProperty(): storage-backed fields
  (item/fluid/energy) wrote into `data` but never the live storage
  object, so a client-sent edit was immediately discarded by
  saveToTag()'s unconditional re-derivation from the untouched live
  storage. Now writes through via readSnapshot().
- field()/listField()/mapField()/itemField()/fluidField()/energyField():
  a @sync property whose value pre-existed on the stack never announced
  its initial value, so a menu opened against pre-existing data started
  out unsynced until some unrelated future write touched it.
- ComposeItemState.updateProperty(): a property packet arriving before
  any composable observed it stuck a bare mutableStateOf() in place of
  the send-forwarding PropertyState wrapper, silently breaking that
  property's client-to-server sync for the rest of the session.
- ItemStateManager: isolate exceptions per-menu in the tick sync loop
  so one broken menu can't starve every other open menu of syncing.
- TestItemMenu.kt: fix stale KDoc link to a test class that was never
  created.
- docs/gametest.md: fix stale ComposeContainerMenu/ComposeContainerScreen
  signatures after the ComposeContainerMenuBase extraction.
@KP2048
KP2048 marked this pull request as ready for review August 8, 2026 15:36
Copilot AI lite review requested due to automatic review settings August 8, 2026 15:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@KP2048
KP2048 merged commit cfe4353 into 1.21.x Aug 8, 2026
3 of 5 checks passed
@KP2048
KP2048 deleted the feature-item-container-menus branch August 8, 2026 15:36
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