From 3329c299f6d530756ef2458e3d785f37ff34d0b8 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Thu, 5 Nov 2026 01:48:25 +0100 Subject: [PATCH 1/3] chore: continue compatibility maintenance From 7f481bfcbb6b511808eaf5c8e4e1e32e9cf28b60 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Thu, 5 Nov 2026 02:27:01 +0100 Subject: [PATCH 2/3] feat(inventory): route native desktop bundle input Bind desktop bundle gestures to published sources, opening generations and a shared request sequence. Resolve physical contents on the backend and retire stale inputs when nested containers change. Route authored slot events without duplicate Java actions, preserve buffered search edits, and allow validated carried bundles and desktop hover views. Fix sparse workbench slot ownership and disabled tooltip pointer consumption. Keep installed routing, sound effects and complete native parity explicitly unverified. --- docs/native-player-inventory.md | 4 +- ...ain-bedrock-creative-catalog-entries.patch | 770 +++++++++++++++++- ...ain-bedrock-creative-catalog-entries.pr.md | 2 + ...9-present-authored-native-containers.patch | 395 ++++++++- ...9-present-authored-native-containers.pr.md | 10 +- 5 files changed, 1101 insertions(+), 80 deletions(-) diff --git a/docs/native-player-inventory.md b/docs/native-player-inventory.md index ef9e231e..de94d892 100644 --- a/docs/native-player-inventory.md +++ b/docs/native-player-inventory.md @@ -216,11 +216,11 @@ Typed overrides and RGB conversion match selected native instruction controls. C Storage bars read a native weight snapshot independently of Java bundle contents. Installed direct and ViaProxy controls insert eight logs into a server-issued empty bundle, extract them, and insert them again. Both routes retain the filled bundle after reopening, with server responses that accept each request. -These checks use ordinary Java 26.3 controls after the storage-tooltip fallback. Left-click inserts items; right-click extracts them. +These installed checks used ordinary Java 26.3 controls after the storage-tooltip fallback. Left-click inserted items; right-click extracted them. The first insertion request from the official Bedrock client remains unverified. Filled-bundle bars still need installed native comparison. Unknown storage snapshots restore ordinary controls. -Hovering or carrying storage items also restores ordinary controls until native tooltips and selection are complete. +Player and workbench screens now route selected desktop bundle gestures through the native input transport. Local regressions cover insertion and default extraction with either cursor or clicked roots, refreshed hover views, stale snapshots, rejection rollback, and serialized search edits. Installed direct/proxy comparisons, sound effects, touch gestures, and complete bundle controller and rendering parity remain unfinished. Unsupported visible Help glyphs, recipe auxiliary actions, and held-selection commands also restore ordinary controls. The Creative catalog-only toolbar binds Shift-left-click and Shift-right-click to full-stack destruction. The action preserves the cursor, requires a current acknowledged opening, and leaves empty slots unchanged. diff --git a/patches/viabedrock/deferred/0012-retain-bedrock-creative-catalog-entries.patch b/patches/viabedrock/deferred/0012-retain-bedrock-creative-catalog-entries.patch index f0db8082..6d9e9a40 100644 --- a/patches/viabedrock/deferred/0012-retain-bedrock-creative-catalog-entries.patch +++ b/patches/viabedrock/deferred/0012-retain-bedrock-creative-catalog-entries.patch @@ -1,4 +1,4 @@ -From fd0c5eb33733fbfe9ddb865f333652f54631e0d5 Mon Sep 17 00:00:00 2001 +From 053ccdc074286bcdd2a6f3e39c7dedbdf7e2ee05 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:55:37 +0200 Subject: [PATCH] feat(viabedrock): retain Bedrock creative catalog entries @@ -499,19 +499,44 @@ source ownership and sound results, without desktop transfer flights. Admission, source/model services and mutation results are supplied; predicate and handler runs are separate. Full controller composition and installed routing remain unfinished. Do not apply state45's input-mode2 restriction to state1. + +Bind desktop bundle inputs to the published player/workbench opening, +generation, revision and shared monotonic command sequence. The optional +channel accepts only typed collection/index sources, primary/secondary input +and tooltip mode. Resolve roots, stacks, physical children and request IDs +from server-owned containers; client item previews cannot authorize transfers. +Retain source and nested dynamic-container snapshots, including missing owners, +and publish a fresh revision when contents or ownership change even if the +recipe presentation stays identical. Retire stale commands before mutation. + +Twenty-four additional original 1.26.60.5 state1 dispatcher controls establish +that secondary input with an empty cursor enters default extraction only for +a storage target with tooltip mode2, on activation in input modes1/3. Modes0, +later phases and non-storage targets do not enter that handler. As in the +previous controls, admission and item/model services are supplied, so these +controls do not establish complete controller composition or packet grouping. + +Keep request-slot ownership independent of backing-array indices: workbench +native slots32..40 occupy array entries1..9. Boundary tests cover both transfer +directions through a real workbench, rejection rollback, replay across recipe +and bundle commands, nested content/owner changes, late dynamic arrival, stale +openings and source whitelists. This transport alone does not enable add-on +bundle gestures; installed direct/proxy and sound behavior remain unverified. --- + .../api/model/NativeBundleAction.java | 76 ++ .../model/NativePlayerInventoryAction.java | 94 +++ .../model/NativePlayerInventoryMessage.java | 334 +++++++++ .../model/NativePlayerInventoryResult.java | 47 ++ .../api/model/container/Container.java | 4 +- - .../container/NativeBundleActionSession.java | 278 ++++++++ + .../container/NativeBundleActionSession.java | 285 +++++++ + .../container/NativeBundleInputSession.java | 185 +++++ .../model/container/NativeRecipeCounter.java | 119 +++ - .../model/container/NativeRecipeCrafting.java | 286 ++++++++ + .../model/container/NativeRecipeCrafting.java | 286 +++++++ .../container/NativeRecipeDescriptor.java | 44 ++ .../model/container/NativeStoragePolicy.java | 103 +++ .../block/CraftingTableContainer.java | 2 +- .../container/dynamic/BundleContainer.java | 17 +- - .../resourcepack/ui/PlayerInventoryUi.java | 402 +++++++++++ + .../resourcepack/ui/PlayerInventoryUi.java | 402 ++++++++++ .../viabedrock/protocol/BedrockProtocol.java | 1 + .../protocol/model/CreativeCatalog.java | 266 +++++++ .../model/CreativeInventoryModel.java | 198 +++++ @@ -520,13 +545,14 @@ unfinished. Do not apply state45's input-mode2 restriction to state1. .../protocol/model/PlayerRecipeContext.java | 96 +++ .../protocol/model/PlayerRecipeModel.java | 125 ++++ .../protocol/packet/InventoryPackets.java | 213 ++++-- - .../protocol/packet/MultiStatePackets.java | 22 + + .../protocol/packet/MultiStatePackets.java | 42 ++ .../protocol/rewriter/ItemRewriter.java | 79 +- .../protocol/storage/CraftingDataTracker.java | 217 +++++- .../storage/InventoryRequestTracker.java | 24 + .../protocol/storage/InventoryTracker.java | 21 + - .../storage/NativePlayerRecipeState.java | 675 ++++++++++++++++++ + .../storage/NativePlayerRecipeState.java | 703 +++++++++++++++++ .../storage/PlayerInventoryScreenTracker.java | 40 +- + .../api/model/NativeBundleActionTest.java | 61 ++ .../NativePlayerInventoryMessageTest.java | 197 +++++ .../container/NativeRecipeCounterTest.java | 84 +++ .../container/NativeRecipeDescriptorTest.java | 24 + @@ -535,21 +561,23 @@ unfinished. Do not apply state45's input-mode2 restriction to state1. .../protocol/model/CreativeCatalogTest.java | 382 ++++++++++ .../model/NativeStoragePolicyTest.java | 113 +++ .../protocol/packet/InventoryPacketsTest.java | 266 +++++-- - .../rewriter/PlayerRecipeModelTest.java | 611 ++++++++++++++++ + .../rewriter/PlayerRecipeModelTest.java | 704 ++++++++++++++++++ ...egisteredPlayerRecipeAvailabilityTest.java | 5 +- .../RegisteredPlayerRecipeSearchTest.java | 3 +- .../storage/BundleInventoryActionTest.java | 61 ++ .../storage/CreativeInventoryActionTest.java | 262 +++++++ .../storage/InventoryRequestTrackerTest.java | 83 +++ - .../NativeBundleActionSessionTest.java | 397 ++++++++++ + .../NativeBundleActionSessionTest.java | 590 +++++++++++++++ .../NativePlayerCreativeAuxiliaryTest.java | 333 +++++++++ .../storage/NativePlayerCreativePickTest.java | 138 ++++ .../storage/NativeRecipeCraftingTest.java | 500 +++++++++++++ - 45 files changed, 7247 insertions(+), 172 deletions(-) + 48 files changed, 7910 insertions(+), 172 deletions(-) + create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeBundleAction.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativePlayerInventoryAction.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativePlayerInventoryMessage.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativePlayerInventoryResult.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/NativeBundleActionSession.java + create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/NativeBundleInputSession.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/NativeRecipeCrafting.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/NativeStoragePolicy.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/PlayerInventoryUi.java @@ -558,6 +586,7 @@ unfinished. Do not apply state45's input-mode2 restriction to state1. create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/model/PlayerRecipeContext.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/model/PlayerRecipeModel.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/storage/NativePlayerRecipeState.java + create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/NativeBundleActionTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/NativePlayerInventoryMessageTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/container/NativeRecipeQuantityTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/PlayerInventoryUiTest.java @@ -570,6 +599,88 @@ unfinished. Do not apply state45's input-mode2 restriction to state1. create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/storage/NativePlayerCreativePickTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/storage/NativeRecipeCraftingTest.java +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/NativeBundleAction.java b/src/main/java/net/raphimc/viabedrock/api/model/NativeBundleAction.java +new file mode 100644 +index 00000000..746c4e44 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/model/NativeBundleAction.java +@@ -0,0 +1,76 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.model; ++ ++import com.viaversion.viaversion.api.type.Type; ++import com.viaversion.viaversion.api.type.Types; ++import io.netty.buffer.ByteBuf; ++import net.raphimc.viabedrock.protocol.data.ProtocolConstants; ++ ++/** Only a displayed source and input are client supplied; stacks and physical contents remain server owned. */ ++public record NativeBundleAction(int menuId, long opening, long generation, long revision, long request, ++ String collection, int index, Kind kind, int tooltipMode) { ++ ++ public static final String CHANNEL = "viabedrock:native_bundle_input"; ++ public static final Type TYPE = new Type<>(NativeBundleAction.class) { ++ @Override ++ public NativeBundleAction read(final ByteBuf buffer) { ++ if (buffer.readableBytes() > 256 || buffer.readUnsignedByte() != 1 ++ || Types.VAR_INT.readPrimitive(buffer) != ProtocolConstants.BEDROCK_PROTOCOL_VERSION) { ++ throw new IllegalArgumentException("Unsupported native bundle input"); ++ } ++ final int menu = Types.VAR_INT.readPrimitive(buffer); ++ final long opening = Types.VAR_LONG.readPrimitive(buffer); ++ final long generation = Types.VAR_LONG.readPrimitive(buffer); ++ final long revision = Types.VAR_LONG.readPrimitive(buffer); ++ final long request = Types.VAR_LONG.readPrimitive(buffer); ++ final String collection = Types.STRING.read(buffer); ++ final int index = Types.VAR_INT.readPrimitive(buffer); ++ final int kind = buffer.readUnsignedByte(); ++ if (kind >= Kind.values().length) { ++ throw new IllegalArgumentException("Unknown native bundle input kind"); ++ } ++ final var action = new NativeBundleAction(menu, opening, generation, revision, request, collection, index, ++ Kind.values()[kind], buffer.readUnsignedByte()); ++ if (buffer.isReadable()) { ++ throw new IllegalArgumentException("Trailing native bundle input"); ++ } ++ return action; ++ } ++ ++ @Override ++ public void write(final ByteBuf buffer, final NativeBundleAction action) { ++ buffer.writeByte(1); ++ Types.VAR_INT.writePrimitive(buffer, ProtocolConstants.BEDROCK_PROTOCOL_VERSION); ++ Types.VAR_INT.writePrimitive(buffer, action.menuId); ++ Types.VAR_LONG.writePrimitive(buffer, action.opening); ++ Types.VAR_LONG.writePrimitive(buffer, action.generation); ++ Types.VAR_LONG.writePrimitive(buffer, action.revision); ++ Types.VAR_LONG.writePrimitive(buffer, action.request); ++ Types.STRING.write(buffer, action.collection); ++ Types.VAR_INT.writePrimitive(buffer, action.index); ++ buffer.writeByte(action.kind.ordinal()).writeByte(action.tooltipMode); ++ } ++ }; ++ ++ public NativeBundleAction { ++ if (menuId < 0 || menuId > 255 || opening < 0 || generation <= 0 || revision <= 0 || request <= 0 ++ || collection == null || !collection.matches("[a-z_]{1,64}") || index < 0 || index > 255 ++ || kind == null || tooltipMode != 0 && tooltipMode != 2) { ++ throw new IllegalArgumentException("Invalid native bundle input"); ++ } ++ } ++ ++ public boolean belongsTo(final NativePlayerInventoryMessage message, final long lastRequest) { ++ return message != null && this.menuId == message.menuId() && this.opening == message.opening() ++ && this.generation == message.generation() && this.revision == message.revision() && this.request > lastRequest; ++ } ++ ++ public enum Kind { ++ PRIMARY, SECONDARY ++ } ++ ++} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/NativePlayerInventoryAction.java b/src/main/java/net/raphimc/viabedrock/api/model/NativePlayerInventoryAction.java new file mode 100644 index 00000000..b3a09f75 @@ -1087,10 +1198,10 @@ index 71856177..1f414860 100644 clickContext.snapshot(slotBundle); diff --git a/src/main/java/net/raphimc/viabedrock/api/model/container/NativeBundleActionSession.java b/src/main/java/net/raphimc/viabedrock/api/model/container/NativeBundleActionSession.java new file mode 100644 -index 00000000..f4430e21 +index 00000000..fd136657 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/model/container/NativeBundleActionSession.java -@@ -0,0 +1,278 @@ +@@ -0,0 +1,285 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -1351,17 +1462,24 @@ index 00000000..f4430e21 + } + + private static boolean owned(final InventoryTracker inventory, final Container owner, final int slot) { -+ if (owner == null || slot < 0 || slot >= owner.size()) { ++ if (owner == null || slot < 0 || owner.itemIndex(slot) < 0 || owner.itemIndex(slot) >= owner.size()) { ++ return false; ++ } ++ try { ++ final int requestSlot = owner.requestSlot(slot); ++ final var name = owner.getFullContainerName(slot); ++ return owner.ownsBedrockSlot(name, requestSlot) && owner.slotFromRequest(requestSlot) == slot ++ && inventory.getContainerFromName(name, requestSlot) == owner; ++ } catch (final IllegalArgumentException invalid) { + return false; + } -+ final int requestSlot = owner.requestSlot(slot); -+ final var name = owner.getFullContainerName(slot); -+ return owner.ownsBedrockSlot(name, requestSlot) && owner.slotFromRequest(requestSlot) == slot -+ && inventory.getContainerFromName(name, requestSlot) == owner; + } + + private static boolean unchanged(final InventoryTracker inventory, final Container owner, final Container snapshot) { -+ return owned(inventory, owner, 0) && owner.serverRevision() == snapshot.serverRevision() ++ final boolean current = owner == inventory.getCurrentContainer() || owner == inventory.getInventoryContainer() ++ || owner == inventory.getHudContainer() || owner == inventory.getArmorContainer() || owner == inventory.getOffhandContainer() ++ || owner instanceof BundleContainer && inventory.getDynamicContainer(owner.getFullContainerName(0)) == owner; ++ return current && owner.serverRevision() == snapshot.serverRevision() + && Arrays.equals(owner.getItems(), snapshot.getItems()); + } + @@ -1369,6 +1487,197 @@ index 00000000..f4430e21 + public record Outcome(boolean successful, int requests) { } + +} +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/container/NativeBundleInputSession.java b/src/main/java/net/raphimc/viabedrock/api/model/container/NativeBundleInputSession.java +new file mode 100644 +index 00000000..d92a02a3 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/model/container/NativeBundleInputSession.java +@@ -0,0 +1,185 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.model.container; ++ ++import com.viaversion.viaversion.api.connection.UserConnection; ++import net.raphimc.viabedrock.api.model.NativeBundleAction; ++import net.raphimc.viabedrock.api.model.NativeItemStorageData; ++import net.raphimc.viabedrock.api.model.NativePlayerInventoryMessage; ++import net.raphimc.viabedrock.api.model.container.block.CraftingTableContainer; ++import net.raphimc.viabedrock.api.model.container.dynamic.BundleContainer; ++import net.raphimc.viabedrock.api.model.container.player.InventoryContainer; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ContainerEnumName; ++import net.raphimc.viabedrock.protocol.model.BedrockItem; ++import net.raphimc.viabedrock.protocol.model.FullContainerName; ++import net.raphimc.viabedrock.protocol.model.ItemRegistry; ++import net.raphimc.viabedrock.protocol.rewriter.ItemRewriter; ++import net.raphimc.viabedrock.protocol.storage.InventoryRequestTracker; ++import net.raphimc.viabedrock.protocol.storage.InventoryTracker; ++ ++import java.util.ArrayDeque; ++import java.util.Arrays; ++import java.util.HashMap; ++import java.util.LinkedHashMap; ++import java.util.Map; ++ ++/** Published physical source values own commands independently of client item previews. */ ++public final class NativeBundleInputSession { ++ ++ private final UserConnection user; ++ private final InventoryTracker inventory; ++ private final ItemRegistry definitions; ++ private final Container menu; ++ private final NativePlayerInventoryMessage publication; ++ private final Map sources = new HashMap<>(); ++ private final Map snapshots = new LinkedHashMap<>(); ++ private final Map dynamic = new HashMap<>(); ++ ++ private NativeBundleInputSession(final UserConnection user, final NativePlayerInventoryMessage publication) { ++ this.user = user; ++ this.inventory = user.get(InventoryTracker.class); ++ this.definitions = user.get(ItemRewriter.class).getItemRegistry(); ++ this.menu = this.inventory.getCurrentContainer(); ++ this.publication = publication; ++ final int playerStart = this.menu instanceof InventoryContainer ? 9 : this.menu.javaSize(); ++ this.add("inventory_items", playerStart, 27); ++ this.add("hotbar_items", playerStart + 27, 9); ++ this.add("container_items", 0, this.menu instanceof InventoryContainer ? 9 : this.menu.javaSize()); ++ this.add("crafting_input_items", 1, this.menu instanceof CraftingTableContainer ? 9 : 4); ++ if (this.menu instanceof InventoryContainer) { ++ this.add("armor_items", 5, 4); ++ this.add("offhand_items", 45, 1); ++ } ++ for (int index = 0; index < 36; index++) { ++ this.sources.put(new Source("combined_hotbar_and_inventory_items", index), new Slot(this.inventory.getInventoryContainer(), index)); ++ } ++ this.sources.put(new Source("cursor_items", 0), new Slot(this.inventory.getHudContainer(), 0)); ++ final var pending = new ArrayDeque(); ++ for (final var source : this.sources.values()) { ++ if (!this.snapshots.containsKey(source.owner)) { ++ this.snapshots.put(source.owner, source.owner.copy()); ++ pending.add(source.owner); ++ } ++ } ++ int slots = 0; ++ while (!pending.isEmpty()) { ++ final var owner = pending.removeFirst(); ++ // Bound the publication snapshot independently of native gameplay limits. ++ slots += owner.size(); ++ if (slots > 4096) { ++ throw new IllegalArgumentException("Native bundle publication is too large"); ++ } ++ for (final var item : this.snapshots.get(owner).getItems()) { ++ final var properties = this.definitions.storageProperties(item); ++ final Integer identity = NativeItemStorageData.containerIdentity(item); ++ if (properties == null || !properties.storage() || identity == null) { ++ continue; ++ } ++ final var name = new FullContainerName(ContainerEnumName.DynamicContainer, identity); ++ final var container = this.inventory.getDynamicContainer(name); ++ this.dynamic.put(name, container); ++ if (container != null && !this.snapshots.containsKey(container)) { ++ this.snapshots.put(container, container.copy()); ++ pending.add(container); ++ } ++ } ++ } ++ } ++ ++ public static NativeBundleInputSession capture(final UserConnection user, final NativePlayerInventoryMessage publication) { ++ final var inventory = user.get(InventoryTracker.class); ++ final var menu = inventory.getCurrentContainer(); ++ if (publication.generation() <= 0 || publication.generation() != inventory.currentContainerGeneration() ++ || !(menu instanceof InventoryContainer) && !(menu instanceof CraftingTableContainer) ++ || Byte.toUnsignedInt(menu.javaContainerId()) != publication.menuId() ++ || user.get(ItemRewriter.class).getItemRegistry() == null) { ++ return null; ++ } ++ try { ++ return new NativeBundleInputSession(user, publication); ++ } catch (final IllegalArgumentException unsupported) { ++ return null; ++ } ++ } ++ ++ public boolean current() { ++ return this.inventory == this.user.get(InventoryTracker.class) ++ && this.definitions == this.user.get(ItemRewriter.class).getItemRegistry() ++ && this.menu == this.inventory.getCurrentContainer() && this.inventory.getPendingCloseContainer() == null ++ && this.sources.get(new Source("cursor_items", 0)).owner == this.inventory.getHudContainer() ++ && this.sources.get(new Source("combined_hotbar_and_inventory_items", 0)).owner == this.inventory.getInventoryContainer() ++ && this.publication.generation() == this.inventory.currentContainerGeneration() ++ && this.dynamic.entrySet().stream().allMatch(entry -> this.inventory.getDynamicContainer(entry.getKey()) == entry.getValue()) ++ && this.snapshots.entrySet().stream().allMatch(entry -> entry.getKey().serverRevision() == entry.getValue().serverRevision() ++ && Arrays.equals(entry.getKey().getItems(), entry.getValue().getItems())); ++ } ++ ++ public NativeBundleActionSession.Outcome execute(final NativeBundleAction action) { ++ final var requests = this.user.get(InventoryRequestTracker.class); ++ if (!action.belongsTo(this.publication, 0) || !this.current() || !requests.canStartRecipePlacement() || requests.isPredictingClick()) { ++ return null; ++ } ++ final var clicked = this.sources.get(new Source(action.collection(), action.index())); ++ if (clicked == null || clicked.owner == this.inventory.getHudContainer() && clicked.index == 0) { ++ return null; ++ } ++ final var cursor = new Slot(this.inventory.getHudContainer(), 0); ++ final var held = cursor.owner.getItem(cursor.index); ++ final var target = clicked.owner.getItem(clicked.index); ++ final boolean heldStorage = this.storage(held); ++ final boolean targetStorage = this.storage(target); ++ final Slot root; ++ final Slot external; ++ if (action.kind() == NativeBundleAction.Kind.PRIMARY) { ++ if (heldStorage && !target.isEmpty()) { ++ root = cursor; ++ external = clicked; ++ } else if (!heldStorage && !held.isEmpty() && targetStorage) { ++ root = clicked; ++ external = cursor; ++ } else { ++ return null; ++ } ++ } else if (heldStorage && target.isEmpty()) { ++ root = cursor; ++ external = clicked; ++ } else if (held.isEmpty() && targetStorage && action.tooltipMode() == 2) { ++ root = clicked; ++ external = cursor; ++ } else { ++ return null; ++ } ++ final var session = NativeBundleActionSession.create(this.user, root.owner, root.index); ++ return session == null ? null : action.kind() == NativeBundleAction.Kind.PRIMARY ++ ? session.insert(external.owner, external.index, null, 0) : session.extractLast(external.owner, external.index, 0); ++ } ++ ++ private boolean storage(final BedrockItem item) { ++ final var properties = this.definitions.storageProperties(item); ++ final Integer identity = NativeItemStorageData.containerIdentity(item); ++ return properties != null && properties.storage() && properties.viewableSlots() != null && identity != null ++ && this.inventory.getDynamicContainer(new FullContainerName(ContainerEnumName.DynamicContainer, identity)) != null; ++ } ++ ++ private void add(final String collection, final int first, final int count) { ++ for (int index = 0; index < count; index++) { ++ final var slot = this.menu.resolveJavaSlot(this.inventory, (short) (first + index)); ++ if (slot == null) { ++ continue; ++ } ++ final var owner = slot.container() instanceof InventoryContainer ? this.inventory.getInventoryContainer() : slot.container(); ++ final var name = owner.getFullContainerName(slot.bedrockSlot()).name(); ++ if (name != ContainerEnumName.CreatedOutputContainer && name != ContainerEnumName.CraftingOutputPreviewContainer) { ++ this.sources.put(new Source(collection, index), new Slot(owner, slot.bedrockSlot())); ++ } ++ } ++ } ++ ++ private record Source(String collection, int index) { } ++ ++ private record Slot(Container owner, int index) { } ++ ++} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/container/NativeRecipeCounter.java b/src/main/java/net/raphimc/viabedrock/api/model/container/NativeRecipeCounter.java index fe874172..96547dc6 100644 --- a/src/main/java/net/raphimc/viabedrock/api/model/container/NativeRecipeCounter.java @@ -3527,23 +3836,43 @@ index 15f46759..9125de3a 100644 public static boolean handleLegacyOffhandClick(final UserConnection user, final Container container, diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java -index 885579ea..d36de2f5 100644 +index 885579ea..49e09b1b 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java -@@ -17,6 +17,9 @@ +@@ -17,6 +17,10 @@ */ package net.raphimc.viabedrock.protocol.packet; +import net.raphimc.viabedrock.api.model.NativePlayerInventoryAction; ++import net.raphimc.viabedrock.api.model.NativeBundleAction; +import net.raphimc.viabedrock.api.model.NativePlayerInventoryResult; +import net.raphimc.viabedrock.protocol.storage.NativePlayerRecipeState; import net.raphimc.viabedrock.api.model.PlayerInventoryScreenRequest; import net.raphimc.viabedrock.protocol.storage.PlayerInventoryScreenTracker; import net.raphimc.viabedrock.protocol.storage.GameSessionStorage; -@@ -215,6 +218,25 @@ public final class MultiStatePackets { +@@ -215,6 +219,44 @@ public final class MultiStatePackets { } return; } ++ if (channel.equals(NativeBundleAction.CHANNEL)) { ++ final var state = wrapper.user().get(NativePlayerRecipeState.class); ++ if (state != null && state.actionsEnabled()) { ++ try { ++ final var action = wrapper.read(NativeBundleAction.TYPE); ++ final boolean accepted = state.handleBundleAction(action); ++ state.tick(); ++ final var message = state.publication(); ++ final var result = PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, wrapper.user()); ++ result.write(Types.STRING, NativePlayerInventoryResult.CHANNEL); ++ result.write(NativePlayerInventoryResult.TYPE, new NativePlayerInventoryResult(action.opening(), action.generation(), ++ action.request(), message == null ? 0 : message.revision(), accepted)); ++ result.send(BedrockProtocol.class); ++ } catch (final IllegalArgumentException | IndexOutOfBoundsException malformed) { ++ // Invalid bundle inputs cannot choose another opening's sources. ++ } ++ } ++ return; ++ } + if (channel.equals(NativePlayerInventoryAction.CHANNEL) || channel.equals(NativePlayerInventoryAction.AUTO_DESTROY_CHANNEL)) { + final var state = wrapper.user().get(NativePlayerRecipeState.class); + if (state != null && state.actionsEnabled()) { @@ -4137,10 +4466,10 @@ index 04ad1756..4a6f0c14 100644 private void updateInventoryOptions() { diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/NativePlayerRecipeState.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/NativePlayerRecipeState.java new file mode 100644 -index 00000000..e31190d4 +index 00000000..ca6e4797 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/NativePlayerRecipeState.java -@@ -0,0 +1,675 @@ +@@ -0,0 +1,703 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -4170,9 +4499,11 @@ index 00000000..e31190d4 +import net.raphimc.viabedrock.ViaBedrock; +import net.raphimc.viabedrock.api.io.NbtUtf8; +import net.raphimc.viabedrock.api.model.NativePlayerInventoryAction; ++import net.raphimc.viabedrock.api.model.NativeBundleAction; +import net.raphimc.viabedrock.api.model.NativePlayerInventoryMessage; +import net.raphimc.viabedrock.api.model.NativePlayerInventoryResult; +import net.raphimc.viabedrock.api.model.container.Container; ++import net.raphimc.viabedrock.api.model.container.NativeBundleInputSession; +import net.raphimc.viabedrock.api.model.container.NativeRecipeCounter; +import net.raphimc.viabedrock.api.model.container.NativeRecipeCrafting; +import net.raphimc.viabedrock.api.model.container.block.CraftingTableContainer; @@ -4217,6 +4548,7 @@ index 00000000..e31190d4 + private InventoryOptions preferences; + private Inputs inputs; + private NativePlayerInventoryMessage publication; ++ private NativeBundleInputSession bundleInputs; + private JsonUiProgram publishedProgram; + private boolean publicationFailed; + private boolean presentationDirty = true; @@ -4337,7 +4669,8 @@ index 00000000..e31190d4 + : this.user().get(PlayerInventoryScreenTracker.class).acknowledgedContainer(this.opening) == null ? 0 : inventory.currentContainerGeneration(); + final var resources = this.user().get(ResourcePackStorage.class); + final var program = resources.getJsonUiProgram(); -+ if (!this.presentationDirty && program == this.publishedProgram && this.publication != null && this.publication.generation() == generation) { ++ final boolean bundleChanged = this.bundleInputs != null && !this.bundleInputs.current(); ++ if (!this.presentationDirty && !bundleChanged && program == this.publishedProgram && this.publication != null && this.publication.generation() == generation) { + return; + } + try { @@ -4352,12 +4685,13 @@ index 00000000..e31190d4 + if (this.publication == null || !message.snapshot().equals(this.publication.snapshot()) + || !message.items().equals(this.publication.items()) + || !message.craftingPreview().equals(this.publication.craftingPreview()) -+ || generation != this.publication.generation() || program != this.publishedProgram) { ++ || generation != this.publication.generation() || program != this.publishedProgram || bundleChanged) { + final var packet = PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, this.user()); + packet.write(Types.STRING, NativePlayerInventoryMessage.CHANNEL); + packet.write(NativePlayerInventoryMessage.TYPE, message); + packet.send(BedrockProtocol.class); + this.publication = message; ++ this.bundleInputs = NativeBundleInputSession.capture(this.user(), message); + } + this.publishedProgram = program; + this.presentationDirty = false; @@ -4382,12 +4716,34 @@ index 00000000..e31190d4 + } + final var packet = PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, this.user()); + packet.write(Types.STRING, available ? "minecraft:register" : "minecraft:unregister"); -+ packet.write(Types.REMAINING_BYTES, (NativePlayerInventoryAction.CHANNEL + "\0" + NativePlayerInventoryAction.AUTO_DESTROY_CHANNEL).getBytes(StandardCharsets.UTF_8)); ++ packet.write(Types.REMAINING_BYTES, (NativePlayerInventoryAction.CHANNEL + "\0" + NativePlayerInventoryAction.AUTO_DESTROY_CHANNEL ++ + "\0" + NativeBundleAction.CHANNEL).getBytes(StandardCharsets.UTF_8)); + packet.send(BedrockProtocol.class); + this.actionsAdvertised = available; + } + + /** Refresh before admitting a command: an older preview cannot authorize a changed source or opening. */ ++ public boolean handleBundleAction(final NativeBundleAction action) { ++ this.tick(); ++ final var message = this.publication(); ++ if (!this.actionsEnabled() || !action.belongsTo(message, this.lastRequest) || !this.canRequest(message)) { ++ return false; ++ } ++ this.lastRequest = action.request(); ++ final var result = this.bundleInputs == null ? null : this.bundleInputs.execute(action); ++ if (result == null) { ++ return false; ++ } ++ this.presentationDirty = true; ++ final var inventory = this.user().get(InventoryTracker.class); ++ PacketFactory.sendJavaContainerSetContent(this.user(), inventory.getCurrentContainer()); ++ if (inventory.getCurrentContainer() != inventory.getInventoryContainer()) { ++ PacketFactory.sendJavaContainerSetContent(this.user(), inventory.getInventoryContainer()); ++ } ++ return result.successful(); ++ } ++ ++ /** Refresh before admitting a command: an older preview cannot authorize a changed source or opening. */ + public boolean handleAction(final NativePlayerInventoryAction action) { + this.tick(); + final var message = this.publication(); @@ -4803,6 +5159,7 @@ index 00000000..e31190d4 + this.preferences = null; + this.inputs = null; + this.publication = null; ++ this.bundleInputs = null; + this.publishedProgram = null; + this.presentationDirty = true; + this.publicationFailed = false; @@ -4928,6 +5285,73 @@ index 9f2ccf71..2db2d17d 100644 private InventoryTracker inventory() { return this.user().get(InventoryTracker.class); } +diff --git a/src/test/java/net/raphimc/viabedrock/api/model/NativeBundleActionTest.java b/src/test/java/net/raphimc/viabedrock/api/model/NativeBundleActionTest.java +new file mode 100644 +index 00000000..e5638c0d +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/api/model/NativeBundleActionTest.java +@@ -0,0 +1,61 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.model; ++ ++import io.netty.buffer.Unpooled; ++import org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.*; ++import static org.mockito.Mockito.*; ++ ++class NativeBundleActionTest { ++ ++ @Test ++ void wireRoundTripPreservesTypedSourceAndPublicationScope() { ++ final var action = new NativeBundleAction(0, 7, 3, 11, 2, "combined_hotbar_and_inventory_items", 35, ++ NativeBundleAction.Kind.SECONDARY, 2); ++ final var buffer = Unpooled.buffer(); ++ try { ++ NativeBundleAction.TYPE.write(buffer, action); ++ final var decoded = NativeBundleAction.TYPE.read(buffer); ++ assertEquals(action, decoded); ++ assertFalse(buffer.isReadable()); ++ final var message = mock(NativePlayerInventoryMessage.class); ++ when(message.menuId()).thenReturn(0); ++ when(message.opening()).thenReturn(7L); ++ when(message.generation()).thenReturn(3L); ++ when(message.revision()).thenReturn(11L); ++ assertTrue(decoded.belongsTo(message, 1)); ++ assertFalse(decoded.belongsTo(message, 2)); ++ when(message.generation()).thenReturn(4L); ++ assertFalse(decoded.belongsTo(message, 1)); ++ } finally { ++ buffer.release(); ++ } ++ } ++ ++ @Test ++ void decoderRejectsTrailingFieldsUnknownKindsAndUnboundedInput() { ++ final var action = new NativeBundleAction(0, 7, 3, 11, 2, "inventory_items", 1, NativeBundleAction.Kind.PRIMARY, 0); ++ for (int mutation = 0; mutation < 4; mutation++) { ++ final var buffer = Unpooled.buffer(); ++ try { ++ NativeBundleAction.TYPE.write(buffer, action); ++ switch (mutation) { ++ case 0 -> buffer.writeByte(0); ++ case 1 -> buffer.setByte(buffer.writerIndex() - 2, 255); ++ case 2 -> buffer.setByte(0, 2); ++ case 3 -> buffer.writeZero(256); ++ default -> throw new AssertionError(); ++ } ++ assertThrows(IllegalArgumentException.class, () -> NativeBundleAction.TYPE.read(buffer)); ++ } finally { ++ buffer.release(); ++ } ++ } ++ } ++ ++} diff --git a/src/test/java/net/raphimc/viabedrock/api/model/NativePlayerInventoryMessageTest.java b/src/test/java/net/raphimc/viabedrock/api/model/NativePlayerInventoryMessageTest.java new file mode 100644 index 00000000..819952fc @@ -6345,10 +6769,10 @@ index 1cfb02f6..d8eb367f 100644 } diff --git a/src/test/java/net/raphimc/viabedrock/protocol/rewriter/PlayerRecipeModelTest.java b/src/test/java/net/raphimc/viabedrock/protocol/rewriter/PlayerRecipeModelTest.java new file mode 100644 -index 00000000..d0df158e +index 00000000..8a2d6eec --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/protocol/rewriter/PlayerRecipeModelTest.java -@@ -0,0 +1,611 @@ +@@ -0,0 +1,704 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -6361,17 +6785,30 @@ index 00000000..d0df158e +import com.viaversion.viaversion.util.GsonUtil; +import com.viaversion.viaversion.api.minecraft.data.StructuredDataContainer; +import com.viaversion.viaversion.api.data.FullMappings; ++import com.viaversion.nbt.tag.CompoundTag; ++import com.viaversion.viaversion.api.protocol.packet.PacketWrapper; ++import com.viaversion.viaversion.api.protocol.packet.PacketType; ++import com.viaversion.viaversion.api.type.types.version.VersionedTypes; ++import io.netty.buffer.Unpooled; ++import io.netty.buffer.ByteBufUtil; +import com.viaversion.viaversion.libs.gson.JsonObject; +import com.viaversion.viaversion.libs.gson.JsonParser; +import net.raphimc.viabedrock.api.io.NbtUtf8; +import net.raphimc.viabedrock.api.model.PlayerInventoryScreenRequest; +import net.raphimc.viabedrock.api.model.NativePlayerInventoryAction; +import net.raphimc.viabedrock.api.model.NativePlayerInventoryMessage; ++import net.raphimc.viabedrock.api.model.NativeBundleAction; ++import net.raphimc.viabedrock.api.model.container.dynamic.BundleContainer; ++import net.raphimc.viabedrock.api.util.PacketFactory; +import net.raphimc.viabedrock.api.model.container.player.InventoryContainer; +import net.raphimc.viabedrock.api.model.container.block.CraftingTableContainer; +import net.raphimc.viabedrock.protocol.model.InventoryOptions; ++import net.raphimc.viabedrock.protocol.model.ItemStorageProperties; ++import net.raphimc.viabedrock.protocol.model.FullContainerName; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ContainerEnumName; +import net.raphimc.viabedrock.protocol.storage.InventoryOptionsStorage; +import net.raphimc.viabedrock.protocol.storage.InventoryRequestTracker; ++import net.raphimc.viabedrock.protocol.storage.InventoryTracker; +import net.raphimc.viabedrock.protocol.storage.NativePlayerRecipeState; +import net.raphimc.viabedrock.protocol.storage.ResourcePackStorage; +import net.raphimc.viabedrock.api.resourcepack.ui.PlayerInventoryUi; @@ -6418,6 +6855,76 @@ index 00000000..d0df158e + private static final int IDENTIFIER = 30001; + + @Test ++ @SuppressWarnings("unchecked") ++ void dynamicBundleContentsRefreshTheInputRevisionWhenRecipePresentationIsUnchanged() throws Exception { ++ final var f = installed(false, false); ++ final var user = f.context.items.user(); ++ final var inventory = f.context.inventory.inventory(); ++ final var registry = spy(f.context.items.getItemRegistry()); ++ doReturn(new ItemStorageProperties(true, 4, 64, 12)).when(registry) ++ .storageProperties(argThat((BedrockItem item) -> item.identifier() == 30010)); ++ doReturn(registry).when(f.context.items).getItemRegistry(); ++ final var preview = javaItem(); ++ doAnswer(call -> preview.copy()).when(f.context.items).javaItem(any(BedrockItem.class)); ++ final var session = mock(GameSessionStorage.class); ++ when(session.isInventoryServerAuthoritative()).thenReturn(true); ++ when(session.getLevelGameType()).thenReturn(GameType.Survival); ++ when(user.get(GameSessionStorage.class)).thenReturn(session); ++ final var resources = mock(ResourcePackStorage.class, RETURNS_DEEP_STUBS); ++ when(resources.getTexts().translate(anyString())).thenAnswer(call -> call.getArgument(0)); ++ when(resources.getJsonUiProgram()).thenReturn(new ResourcePackStorage(List.of()).getJsonUiProgram()); ++ when(user.get(ResourcePackStorage.class)).thenReturn(resources); ++ final var root = new BedrockItem(30010, (short) 0, (byte) 1); ++ root.setNetId(55); ++ root.setTag(new CompoundTag()); ++ root.tag().putInt("bundle_id", 42); ++ inventory.getInventoryContainer().setItem(10, root); ++ final var name = new FullContainerName(ContainerEnumName.DynamicContainer, 42); ++ final var bundle = new BundleContainer(user, name); ++ final var dynamic = InventoryTracker.class.getDeclaredField("dynamicContainerRegistry"); ++ dynamic.setAccessible(true); ++ ((Map) dynamic.get(inventory)).put(name, bundle); ++ final var state = spy(f.state); ++ doReturn(true).when(state).actionsEnabled(); ++ when(user.get(NativePlayerRecipeState.class)).thenReturn(state); ++ final var advertised = NativePlayerRecipeState.class.getDeclaredField("actionsAdvertised"); ++ advertised.setAccessible(true); ++ advertised.setBoolean(state, true); ++ final List sent = new ArrayList<>(); ++ try (var packets = mockStatic(PacketWrapper.class); ++ var output = mockStatic(PacketFactory.class)) { ++ final var packet = mock(PacketWrapper.class); ++ packets.when(() -> PacketWrapper.create(any(PacketType.class), eq(user))).thenReturn(packet); ++ doAnswer(call -> { ++ sent.add(call.getArgument(1)); ++ return null; ++ }).when(packet).write(eq(NativePlayerInventoryMessage.TYPE), any()); ++ f.screens.request(new PlayerInventoryScreenRequest(1, true)); ++ f.acknowledge(); ++ for (int tick = 0; tick < 20; tick++) { ++ state.tick(); ++ } ++ final var before = state.publication(); ++ assertNotNull(before); ++ state.tick(); ++ assertSame(before, state.publication()); ++ bundle.setItem(3, new BedrockItem(IDENTIFIER, (short) 0, (byte) 2)); ++ state.tick(); ++ final var after = state.publication(); ++ assertTrue(after.revision() > before.revision()); ++ assertEquals(before.snapshot(), after.snapshot()); ++ assertEquals(before.items().keySet(), after.items().keySet()); ++ for (final int index : before.items().keySet()) { ++ assertArrayEquals(previewBytes(before.items().get(index)), previewBytes(after.items().get(index))); ++ } ++ assertSame(after, sent.getLast()); ++ assertFalse(state.handleBundleAction(new NativeBundleAction(before.menuId(), before.opening(), before.generation(), before.revision(), ++ 1, "inventory_items", 1, NativeBundleAction.Kind.SECONDARY, 2))); ++ Mockito.verify(user.get(InventoryRequestTracker.class), never()).nextRequestId(); ++ } ++ } ++ ++ @Test + void lockedUnavailableSelectionPublishesOnlyGhostsAndKeepsThemThroughSearchUntilReopening() throws Exception { + final var f = installed(false, false); + final var user = f.context.items.user(); @@ -6947,6 +7454,16 @@ index 00000000..d0df158e + return new StructuredItem(1, 1, data); + } + ++ private static byte[] previewBytes(final Item item) { ++ final var buffer = Unpooled.buffer(); ++ try { ++ VersionedTypes.V26_3.item.write(buffer, item); ++ return ByteBufUtil.getBytes(buffer); ++ } finally { ++ buffer.release(); ++ } ++ } ++ + private record Installed(Context context, PlayerInventoryScreenTracker screens, NativePlayerRecipeState state) { + private void acknowledge() { + final var owner = new InventoryContainer(this.context.items.user(), (byte) 0, null, @@ -7461,10 +7978,10 @@ index b69f1868..037328f2 100644 final BedrockItem current = new BedrockItem(276); diff --git a/src/test/java/net/raphimc/viabedrock/protocol/storage/NativeBundleActionSessionTest.java b/src/test/java/net/raphimc/viabedrock/protocol/storage/NativeBundleActionSessionTest.java new file mode 100644 -index 00000000..c8de7485 +index 00000000..161af240 --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/protocol/storage/NativeBundleActionSessionTest.java -@@ -0,0 +1,397 @@ +@@ -0,0 +1,590 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -7476,10 +7993,16 @@ index 00000000..c8de7485 +import com.viaversion.nbt.tag.ListTag; +import com.viaversion.viaversion.api.connection.UserConnection; +import net.raphimc.viabedrock.ViaBedrock; ++import net.raphimc.viabedrock.api.model.NativeBundleAction; ++import net.raphimc.viabedrock.api.model.NativePlayerInventoryAction; ++import net.raphimc.viabedrock.api.model.NativePlayerInventoryMessage; +import net.raphimc.viabedrock.api.model.container.Container; +import net.raphimc.viabedrock.api.model.container.NativeBundleActionSession; ++import net.raphimc.viabedrock.api.model.container.NativeBundleInputSession; +import net.raphimc.viabedrock.api.model.container.NativeStoragePolicy; +import net.raphimc.viabedrock.api.model.container.dynamic.BundleContainer; ++import net.raphimc.viabedrock.api.model.container.player.InventoryContainer; ++import net.raphimc.viabedrock.api.model.container.block.CraftingTableContainer; +import net.raphimc.viabedrock.api.util.PacketFactory; +import net.raphimc.viabedrock.platform.ViaBedrockPlatform; +import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ContainerEnumName; @@ -7492,6 +8015,7 @@ index 00000000..c8de7485 +import net.raphimc.viabedrock.protocol.model.ItemStoragePolicy; +import net.raphimc.viabedrock.protocol.model.ItemStorageProperties; +import net.raphimc.viabedrock.protocol.model.NativeRecipeRegistry; ++import net.raphimc.viabedrock.protocol.model.PlayerRecipeContext; +import net.raphimc.viabedrock.protocol.model.inventory.ItemStackRequestAction; +import net.raphimc.viabedrock.protocol.model.inventory.ItemStackRequestInfo; +import net.raphimc.viabedrock.protocol.model.inventory.ItemStackResponseContainerInfo; @@ -7515,6 +8039,178 @@ index 00000000..c8de7485 +class NativeBundleActionSessionTest { + + @Test ++ void workbenchInputsRetainSparseNativeSlotsThroughPredictionAndRejection() { ++ for (final boolean rootOnCursor : new boolean[]{false, true}) { ++ try (var f = new Fixture()) { ++ final var root = f.player.getItem(9); ++ f.player.setItem(9, BedrockItem.empty()); ++ f.inventory.setCurrentContainerClosed(false); ++ final var table = new CraftingTableContainer(f.user, (byte) 5, null, null); ++ f.inventory.setCurrentContainer(table); ++ final var cursor = f.inventory.getHudContainer(); ++ table.setItem(32, rootOnCursor ? item(30031, 5, 201) : root); ++ cursor.setItem(0, rootOnCursor ? root : item(30031, 5, 201)); ++ final var published = f.publication(); ++ final var input = NativeBundleInputSession.capture(f.user, published); ++ assertNotNull(input); ++ assertEquals(new NativeBundleActionSession.Outcome(true, 1), input.execute(action(published, 1, ++ "crafting_input_items", 0, NativeBundleAction.Kind.PRIMARY, 2))); ++ final var take = (ItemStackRequestAction.TakeAction) f.sent.getFirst().actions().getFirst(); ++ assertEquals(rootOnCursor ? table.requestSlotInfo(32, 201) : cursor.requestSlotInfo(0, 201), take.source()); ++ assertEquals(item(30031, 5, -3), f.bundle.getItem(0)); ++ f.requests.handleResponse(rejected(f.sent.getFirst())); ++ assertEquals(rootOnCursor ? item(30031, 5, 201) : root, table.getItem(32)); ++ assertEquals(rootOnCursor ? root : item(30031, 5, 201), cursor.getItem(0)); ++ assertTrue(f.bundle.getItem(0).isEmpty()); ++ } ++ } ++ } ++ ++ @Test ++ void publishedPrimaryInputChoosesTheCanonicalRootInEitherCursorDirection() { ++ for (final boolean rootOnCursor : new boolean[]{false, true}) { ++ try (var f = new Fixture()) { ++ final var cursor = f.inventory.getHudContainer(); ++ if (rootOnCursor) { ++ cursor.setItem(0, f.player.getItem(9)); ++ f.player.setItem(9, BedrockItem.empty()); ++ f.player.setItem(10, item(30031, 5, 201)); ++ } else { ++ cursor.setItem(0, item(30031, 5, 201)); ++ } ++ final var published = f.publication(); ++ final var input = NativeBundleInputSession.capture(f.user, published); ++ assertNotNull(input); ++ final var result = input.execute(action(published, 1, "inventory_items", rootOnCursor ? 1 : 0, NativeBundleAction.Kind.PRIMARY, 2)); ++ assertEquals(new NativeBundleActionSession.Outcome(true, 1), result); ++ final var take = (ItemStackRequestAction.TakeAction) f.sent.getFirst().actions().getFirst(); ++ assertEquals(rootOnCursor ? f.player.requestSlotInfo(10, 201) : cursor.requestSlotInfo(0, 201), take.source()); ++ assertEquals(f.bundle.requestSlotInfo(0, 0), take.destination()); ++ assertEquals(item(30031, 5, -3), f.bundle.getItem(0)); ++ assertFalse(input.current()); ++ } ++ } ++ } ++ ++ @Test ++ void publishedSecondaryInputUsesDefaultExtractionAndRequiresHoverForAnEmptyCursor() { ++ for (final boolean rootOnCursor : new boolean[]{false, true}) { ++ try (var f = new Fixture()) { ++ final var cursor = f.inventory.getHudContainer(); ++ f.bundle.setItem(3, item(30032, 5, 103)); ++ if (rootOnCursor) { ++ cursor.setItem(0, f.player.getItem(9)); ++ f.player.setItem(9, BedrockItem.empty()); ++ } ++ final var published = f.publication(); ++ final var input = NativeBundleInputSession.capture(f.user, published); ++ assertNotNull(input); ++ if (!rootOnCursor) { ++ assertNull(input.execute(action(published, 1, "inventory_items", 0, NativeBundleAction.Kind.SECONDARY, 0))); ++ assertTrue(f.sent.isEmpty()); ++ } ++ assertEquals(new NativeBundleActionSession.Outcome(true, 1), input.execute(action(published, 2, ++ "inventory_items", rootOnCursor ? 1 : 0, NativeBundleAction.Kind.SECONDARY, rootOnCursor ? 0 : 2))); ++ final var take = (ItemStackRequestAction.TakeAction) f.sent.getFirst().actions().getFirst(); ++ assertEquals(f.bundle.requestSlotInfo(3, 103), take.source()); ++ assertEquals(rootOnCursor ? f.player.requestSlotInfo(10, 0) : cursor.requestSlotInfo(0, 0), take.destination()); ++ } ++ } ++ } ++ ++ @Test ++ void dynamicChangesAndReplacementOwnersRetirePublishedInputWithoutChangingTheRootStack() { ++ for (final int change : new int[]{0, 1, 2}) { ++ try (var f = new Fixture()) { ++ f.inventory.getHudContainer().setItem(0, item(30031, 5, 201)); ++ final var root = f.player.getItem(9).copy(); ++ final var published = f.publication(); ++ final var input = NativeBundleInputSession.capture(f.user, published); ++ assertNotNull(input); ++ assertTrue(input.current()); ++ switch (change) { ++ case 0 -> f.bundle.setItem(3, item(30032, 5, 103)); ++ case 1 -> f.bundle.markServerUpdate(); ++ case 2 -> f.dynamic.put(f.bundle.getFullContainerName(0), new BundleContainer(f.user, f.bundle.getFullContainerName(0))); ++ default -> throw new AssertionError(); ++ } ++ assertEquals(root, f.player.getItem(9)); ++ assertFalse(input.current()); ++ assertNull(input.execute(action(published, 1, "inventory_items", 0, NativeBundleAction.Kind.PRIMARY, 2))); ++ assertTrue(f.sent.isEmpty()); ++ } ++ } ++ } ++ ++ @Test ++ void nestedContentChangesAndNewlyArrivingModelsInvalidateThePublishedSourceSnapshot() { ++ try (var f = new Fixture()) { ++ final var child = item(30030, 1, 88); ++ child.setTag(new CompoundTag()); ++ child.tag().putInt("bundle_id", 43); ++ f.bundle.setItem(0, child); ++ final var nested = new BundleContainer(f.user, new FullContainerName(ContainerEnumName.DynamicContainer, 43)); ++ f.dynamic.put(nested.getFullContainerName(0), nested); ++ final var input = NativeBundleInputSession.capture(f.user, f.publication()); ++ assertNotNull(input); ++ assertTrue(input.current()); ++ nested.setItem(1, item(30031, 2, 301)); ++ assertFalse(input.current()); ++ f.dynamic.remove(f.bundle.getFullContainerName(0)); ++ final var absent = NativeBundleInputSession.capture(f.user, f.publication()); ++ assertNotNull(absent); ++ assertTrue(absent.current()); ++ f.dynamic.put(f.bundle.getFullContainerName(0), f.bundle); ++ assertFalse(absent.current()); ++ } ++ } ++ ++ @Test ++ void unpublishedCollectionsAndOldOpeningScopesCannotSelectInventoryItems() { ++ try (var f = new Fixture()) { ++ f.inventory.getHudContainer().setItem(0, item(30031, 5, 201)); ++ final var published = f.publication(); ++ final var input = NativeBundleInputSession.capture(f.user, published); ++ assertNotNull(input); ++ assertNull(input.execute(action(published, 1, "recipe_book", 0, NativeBundleAction.Kind.PRIMARY, 2))); ++ assertNull(input.execute(action(published, 1, "inventory_items", 27, NativeBundleAction.Kind.PRIMARY, 2))); ++ f.inventory.setCurrentContainerClosed(false); ++ f.inventory.setCurrentContainer(f.player); ++ assertFalse(input.current()); ++ assertNull(input.execute(action(published, 1, "inventory_items", 0, NativeBundleAction.Kind.PRIMARY, 2))); ++ assertTrue(f.sent.isEmpty()); ++ } ++ } ++ ++ @Test ++ void bundleAndRecipeCommandsShareOneMonotonicRequestSequence() throws Exception { ++ try (var f = new Fixture()) { ++ f.inventory.getHudContainer().setItem(0, item(30031, 5, 201)); ++ final var published = f.publication(); ++ final var state = spy(new NativePlayerRecipeState(f.user)); ++ doNothing().when(state).tick(); ++ doReturn(true).when(state).actionsEnabled(); ++ doReturn(published).when(state).publication(); ++ when(f.user.get(PlayerInventoryScreenTracker.class).acknowledgedContainer(published.opening())).thenReturn(f.player); ++ for (final var field : Map.of("context", mock(PlayerRecipeContext.class), "opening", published.opening(), ++ "bundleInputs", NativeBundleInputSession.capture(f.user, published)).entrySet()) { ++ final var target = NativePlayerRecipeState.class.getDeclaredField(field.getKey()); ++ target.setAccessible(true); ++ target.set(state, field.getValue()); ++ } ++ final var first = action(published, 1, "inventory_items", 0, NativeBundleAction.Kind.PRIMARY, 2); ++ assertTrue(state.handleBundleAction(first)); ++ f.requests.handleResponse(rejected(f.sent.getFirst())); ++ assertFalse(state.handleBundleAction(first)); ++ assertFalse(state.handleAction(new NativePlayerInventoryAction(published.menuId(), published.opening(), published.generation(), ++ published.revision(), 1, NativePlayerInventoryAction.Kind.SEARCH, -1, "stale", null))); ++ assertEquals(1, f.sent.size()); ++ assertTrue(state.handleBundleAction(action(published, 2, "inventory_items", 0, NativeBundleAction.Kind.PRIMARY, 2))); ++ assertEquals(2, f.sent.size()); ++ } ++ } ++ ++ @Test + void defaultExtractionUsesLastPhysicalCellAndKeepsTakeForPartialCursorMerge() { + try (var f = new Fixture()) { + f.bundle.setItem(0, item(30031, 2, 100)); @@ -7756,6 +8452,11 @@ index 00000000..c8de7485 + return new ItemStackResponseSlotInfo((byte) slot, (byte) slot, (byte) amount, id, "", null, 0); + } + ++ private static NativeBundleAction action(final NativePlayerInventoryMessage message, final long request, final String collection, ++ final int index, final NativeBundleAction.Kind kind, final int tooltipMode) { ++ return new NativeBundleAction(message.menuId(), message.opening(), message.generation(), message.revision(), request, collection, index, kind, tooltipMode); ++ } ++ + private static ItemStackResponseInfo rejected(final ItemStackRequestInfo request) { + return new ItemStackResponseInfo(ItemStackNetResult.Error, request.requestId(), List.of()); + } @@ -7775,7 +8476,7 @@ index 00000000..c8de7485 + private final ItemRewriter items = mock(ItemRewriter.class); + private final InventoryTracker inventory; + private final InventoryRequestTracker requests; -+ private final Container player; ++ private final InventoryContainer player; + private final BundleContainer bundle; + private final NativeRecipeRegistry context; + private final List sent = new ArrayList<>(); @@ -7846,6 +8547,15 @@ index 00000000..c8de7485 + return session; + } + ++ private NativePlayerInventoryMessage publication() { ++ final var message = mock(NativePlayerInventoryMessage.class); ++ when(message.menuId()).thenReturn(Byte.toUnsignedInt(this.inventory.getCurrentContainer().javaContainerId())); ++ when(message.opening()).thenReturn(7L); ++ when(message.generation()).thenReturn(this.inventory.currentContainerGeneration()); ++ when(message.revision()).thenReturn(11L); ++ return message; ++ } ++ + private ItemStackResponseContainerInfo info(final Container owner, final ItemStackResponseSlotInfo... slots) { + return new ItemStackResponseContainerInfo(owner.getFullContainerName(Byte.toUnsignedInt(slots[0].slot())), List.of(slots)); + } diff --git a/patches/viabedrock/deferred/0012-retain-bedrock-creative-catalog-entries.pr.md b/patches/viabedrock/deferred/0012-retain-bedrock-creative-catalog-entries.pr.md index 1f52a0fc..4529762c 100644 --- a/patches/viabedrock/deferred/0012-retain-bedrock-creative-catalog-entries.pr.md +++ b/patches/viabedrock/deferred/0012-retain-bedrock-creative-catalog-entries.pr.md @@ -177,3 +177,5 @@ Ordinary Java bundle insertion applies available filters and nesting rules befor The native bundle action session resolves current server-owned roots and physical containers, then submits the native transfer plan through the ordered request tracker. Each transfer captures its intermediate prediction and chains changed-slot request IDs, including emptied slots. A rejected incoming item can still submit preparation requests. Extraction remains independent of insertion weight metadata. Boundary tests cover out-of-order acceptance, rejection, unavailable filters, replacement owners, failed sends, authoritative updates, and reopening with a new crafting grid. Full controller delivery, native wire grouping, and installed direct/proxy validation remain unfinished; this backend does not enable bundle gestures in the add-on. Desktop state 1 uses separate bundle predicates from touch state 45. Original controls cover 288 transition cases and 24 insertion-handler cases for input modes 1/3, tooltip modes 0/2, and absent retained primary sources. They establish activation entry and cursor/clicked source ownership with supplied model and admission services. The session exposes default extraction separately and tests its Take request, partial cursor merge, authoritative response, and full-cursor no-op. Complete controller composition and installed input/rendering remain unverified. + +The optional bundle-input channel accepts a displayed collection/index and input kind against the current opening, generation, revision, and shared recipe-command sequence. All item data and physical container references come from retained server state. Source, nested-content, and owner changes refresh the publication even when recipe previews stay unchanged. Boundary regressions cover stale inputs, late dynamic-container arrival, shared replay protection, and sparse workbench request slots with rejection rollback. Twenty-four additional original state-1 controls establish empty-cursor secondary extraction in tooltip mode 2; supplied admission and model services remain explicit limits. Installed direct/proxy routing, sound effects, and complete controller composition remain unverified. diff --git a/patches/viafabricplus-bedrock/upstreamable/0029-present-authored-native-containers.patch b/patches/viafabricplus-bedrock/upstreamable/0029-present-authored-native-containers.patch index 8e4664a5..d9336fa7 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0029-present-authored-native-containers.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0029-present-authored-native-containers.patch @@ -1,4 +1,4 @@ -From 1ebedcbd2231f05b320d4e99dd25743033ce92e4 Mon Sep 17 00:00:00 2001 +From 0a2c428fac144a0a29db3efdb2caec68aedc4f42 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:55:36 +0200 Subject: [PATCH] feat(inventory): present authored native containers @@ -832,6 +832,38 @@ A screen extraction regression draws count255 as99+ with authored scale and nati Read inventory hover sources from the control event property bag instead of render-only collection/slot annotations. Preserve native typed defaults and integer narrowing, and retain live bag reads rather than copying a source when the event is created. Structural collection annotations still identify render slots. Original Bedrock 1.26.60.5/protocol2225 input producer14192b090 retains the UIControl bag from141c3a4e0; source getter14500e920/140085b60 reads its string #collection_name. Eight native composition controls preserve heap strings, reject boolean values and observe replacement after enqueue. The collection_details initializer14191e310 and original ancestor lookup/enumeration populate #collection_index and prefixed variants, with fifteen execution controls. The shared program prerequisite now exports those final values. Local event regressions use a routed slot event, change its retained bag and reject structural fallback and invalid JSON types. Full inventory FSM and installed parity remain separate gates. + +Route desktop player/workbench bundle activation through the optional typed +native_bundle_input channel. Use each callback's property-bag collection and +index; never derive command authority from renderer or Java slot ordinals. +Match the original 1.26.60.5 state1 controls for ordinary cursor insertion, +held-bundle insertion and default extraction. Empty-cursor secondary extraction +requires the retained hover tooltip mode2. Touch/retained-source gestures and +native sound/controller composition remain separate unfinished work. + +Share the player presentation's monotonic sequence, result channel, timeout +and retirement rules across bundle and recipe/search commands. Queue search +edits during a bundle request and bind the next command to the latest received +publication. Consume handled pointer/keyboard events and their releases so Java +does not send an additional inventory action. Block physical player actions +while a native command is pending. Validate complete carried-bundle snapshots +instead of applying the previous blanket tooltip fallback on player screens. + +Real exported-program pointer regressions exercise primary and secondary +inputs with roots on either the cursor or clicked inventory source, verify the +exact scoped payload, preserve local stacks, and reject a second pending click +without Java dispatch. Presentation regressions cover stale replies, revision +updates, shared sequencing, buffered search, timeout and retired openings. +Installed direct/proxy behavior, sound effects and full native parity remain +unverified by these Java tests. + +Allow the implemented physical desktop hover view to refresh before input. +Disabled native tooltip controls clear their old hover without claiming the +pointer or requiring an action implementation. Geometry invalidation repeats +admission when enablement changes and still rejects enabled unsupported +controls. Regressions exercise a full exported tooltip refresh before all four +bundle gestures and prove that a disabled touch control cannot become active +without validation. Full installed pixels and native traversal remain unverified. --- .../bedrock/ViaFabricPlusBedrock.java | 4 + .../access/IBedrockGuiItemStyle.java | 14 + @@ -845,11 +877,12 @@ Original Bedrock 1.26.60.5/protocol2225 input producer14192b090 retains the UICo .../render/MixinNativeGuiItemState.java | 20 + .../render/MixinNativeGuiOversizedItem.java | 31 + .../container/BedrockFurnacePreferences.java | 60 + - .../BedrockNativeContainerScreen.java | 1511 +++++++++++++++++ + .../BedrockNativeContainerScreen.java | 1538 +++++++++++++++++ .../container/BedrockNativeContainers.java | 283 +++ .../container/BedrockNativeCooking.java | 147 ++ - .../BedrockNativePlayerInventory.java | 173 ++ + .../BedrockNativePlayerInventory.java | 185 ++ .../container/BedrockPlayerInventory.java | 144 ++ + .../container/NativeBundleActionPayload.java | 22 + .../NativeContainerActionPayload.java | 30 + .../NativeContainerCurrentPayload.java | 30 + .../container/NativeContainerDataPayload.java | 30 + @@ -860,7 +893,7 @@ Original Bedrock 1.26.60.5/protocol2225 input producer14192b090 retains the UICo .../container/NativeContainerPayload.java | 22 + .../container/NativeContainerPending.java | 65 + .../NativeContainerPresentation.java | 45 + - ...NativeContainerPresentationController.java | 95 ++ + ...NativeContainerPresentationController.java | 95 + .../container/NativeContainerScroll.java | 143 ++ .../container/NativeContainerSession.java | 37 + .../NativeContainerStatusPayload.java | 29 + @@ -881,12 +914,12 @@ Original Bedrock 1.26.60.5/protocol2225 input producer14192b090 retains the UICo .../container/NativeInventoryItemStyle.java | 55 + .../NativeInventoryPresentation.java | 52 + .../container/NativeInventoryProgress.java | 70 + - .../container/NativeInventorySourceState.java | 164 ++ + .../container/NativeInventorySourceState.java | 181 ++ .../container/NativePlayerHelpText.java | 64 + .../NativePlayerInventoryActionPayload.java | 23 + .../NativePlayerInventoryPayload.java | 22 + .../NativePlayerInventoryResultPayload.java | 22 + - .../container/NativePlayerPresentation.java | 216 +++ + .../container/NativePlayerPresentation.java | 235 +++ .../PlayerInventoryScreenClosePayload.java | 22 + .../PlayerInventoryScreenLifecycle.java | 98 ++ .../PlayerInventoryScreenReadyPayload.java | 22 + @@ -894,10 +927,10 @@ Original Bedrock 1.26.60.5/protocol2225 input producer14192b090 retains the UICo .../settings/impl/BedrockSettings.java | 4 + .../viafabricplus-bedrock/lang/en_us.json | 1 + .../viafabricplus-bedrock.mixins.json | 9 +- - .../BedrockNativeContainerScreenTest.java | 760 +++++++++ + .../BedrockNativeContainerScreenTest.java | 760 ++++++++ .../container/NativeContainerButtonTest.java | 280 +++ .../container/NativeContainerDrawsTest.java | 70 + - .../NativeContainerGeometryTest.java | 389 +++++ + .../NativeContainerGeometryTest.java | 407 +++++ .../container/NativeContainerInputTest.java | 610 +++++++ ...veContainerPresentationControllerTest.java | 91 + .../container/NativeContainerScrollTest.java | 51 + @@ -914,11 +947,11 @@ Original Bedrock 1.26.60.5/protocol2225 input producer14192b090 retains the UICo .../NativeInventorySourceStateTest.java | 178 ++ .../container/NativePlayerHelpTextTest.java | 34 + .../NativePlayerInventoryLifetimeTest.java | 142 ++ - .../NativePlayerPresentationTest.java | 189 +++ - .../container/NativePlayerScreenTest.java | 720 ++++++++ + .../NativePlayerPresentationTest.java | 226 +++ + .../container/NativePlayerScreenTest.java | 835 +++++++++ .../PlayerInventoryAdmissionTest.java | 147 ++ .../PlayerInventoryScreenLifecycleTest.java | 139 ++ - 85 files changed, 12382 insertions(+), 62 deletions(-) + 86 files changed, 12649 insertions(+), 62 deletions(-) create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockGuiItemStyle.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/inventory/MixinNativeContainerGeometry.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/inventory/MixinNativeContainerScreenAdmission.java @@ -935,6 +968,7 @@ Original Bedrock 1.26.60.5/protocol2225 input producer14192b090 retains the UICo create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockNativeCooking.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockNativePlayerInventory.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockPlayerInventory.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeBundleActionPayload.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerActionPayload.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerCurrentPayload.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerDataPayload.java @@ -1470,10 +1504,10 @@ index 0000000..1583ce0 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockNativeContainerScreen.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockNativeContainerScreen.java new file mode 100644 -index 0000000..26e352c +index 0000000..1363618 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockNativeContainerScreen.java -@@ -0,0 +1,1511 @@ +@@ -0,0 +1,1538 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -1740,7 +1774,8 @@ index 0000000..26e352c + throw new IllegalStateException("Native inventory no longer owns the current menu"); + this.renderer.reset(); + this.showTags = this.showTags(); -+ NativeInventoryItemPresentation.requireTooltip(this.menu.getCarried()); ++ if (this.presentation instanceof NativePlayerPresentation) this.tooltipData(NativeInventorySourceState.CURSOR); ++ else NativeInventoryItemPresentation.requireTooltip(this.menu.getCarried()); + final List slots = new ArrayList<>(); + for (final Slot slot : this.menu.slots) slots.add(new ContainerUi.Slot(slot.index, NativeInventoryItemPresentation.values(slot.getItem(), false, this.showTags))); + for (int pass = 0; ; pass++) { @@ -1755,16 +1790,13 @@ index 0000000..26e352c + || !Set.of("button", "toggle").contains(node.type()) && node.properties().has("button_mappings") + && !NativeCookingNavigation.host(node))) + throw new IllegalArgumentException("Unsupported native cooking control"); -+ if (this.authoredController()) { ++ if (this.authoredController() && draw.enabled()) { + for (final var mapping : NativeUiInput.parse(node.properties()).mappings()) { + if (mapping.source() == NativeUiInput.symbol("button.menu_ok") && !this.supportsKeyboardInput(node, mapping)) + throw new IllegalArgumentException("Unsupported native inventory keyboard action on " + node.name() + ": " + mapping.target()); + } + } + final String custom = node.string("renderer", ""); -+ if (custom.equals("bundle_tooltip_renderer") && node.children().stream() -+ .anyMatch(child -> child.flag("visible", true) && !child.flag("ignored", false))) -+ throw new IllegalArgumentException("Native bundle cursor tooltip contents are not supported"); + if (custom.equals("progress_bar_renderer")) + NativeInventoryProgress.quads(draw.box(), draw.alpha(), 1); + } @@ -1856,7 +1888,7 @@ index 0000000..26e352c + for (final var child : node.children()) this.tooltipSlots(child, slots); + } + -+ private NativeInventoryItemPresentation.BundleTooltip tooltipData(final Source source) { ++ private ItemStack sourceItem(final Source source) { + ItemStack item = null; + if (source.equals(NativeInventorySourceState.CURSOR)) item = this.menu.getCarried(); + else { @@ -1869,6 +1901,11 @@ index 0000000..26e352c + } + if (slot != null) item = this.menu.getSlot(slot).getItem(); + } ++ return item; ++ } ++ ++ private NativeInventoryItemPresentation.BundleTooltip tooltipData(final Source source) { ++ final var item = this.sourceItem(source); + if (item == null) return null; + if (item.getCount() != this.tooltipItem.getCount() || !ItemStack.isSameItemSameComponents(item, this.tooltipItem)) { + final var data = NativeInventoryItemPresentation.bundleTooltip(item, @@ -2532,7 +2569,7 @@ index 0000000..26e352c + componentEvent = this.nativeInput.button(componentSource, true, 1, this.clock.getAsLong(), this.componentEnabled()); + if (!this.waitingForPreference()) this.componentEvents(hover); + } -+ if (this.waitingForPreference()) { ++ if (this.waitingForPreference() || this.presentation instanceof NativePlayerPresentation player && !player.idle()) { + this.capturedButtons.add(event.button()); + return true; + } @@ -2546,7 +2583,7 @@ index 0000000..26e352c + return true; + } + final String input = componentSource; -+ if (componentEvent != null && this.componentEvents(componentEvent)) { ++ if (componentEvent != null && (this.componentEvents(componentEvent) || this.bundleEvents(componentEvent))) { + this.nativeButtons.add(event.button()); + return true; + } @@ -2679,6 +2716,28 @@ index 0000000..26e352c + return handled; + } + ++ private boolean bundleEvents(final NativeContainerInput.Result result) { ++ boolean handled = false; ++ for (final var event : result.events()) handled |= this.bundleEvent(event); ++ return handled; ++ } ++ ++ private boolean bundleEvent(final NativeContainerInput.Event event) { ++ if (!(this.presentation instanceof NativePlayerPresentation player) || player.helping() || event.button() == null ++ || !event.button().transition() || event.button().target() != NativeUiInput.symbol("button.container_take_all_place_all") ++ && event.button().target() != NativeUiInput.symbol("button.container_take_half_place_one")) return false; ++ try { ++ final var source = new Source(event.collection(), event.collectionIndex()); ++ final var clicked = this.sourceItem(source); ++ if (clicked == null) return false; ++ final var kind = this.tooltipSources.desktopBundleInput(event, this.tooltipData(NativeInventorySourceState.CURSOR) != null, ++ this.menu.getCarried().isEmpty(), this.tooltipData(source) != null, clicked.isEmpty()); ++ if (kind == null || !BedrockNativePlayerInventory.bundlesAvailable(this.owner, this.menu)) return false; ++ if (player.idle()) BedrockNativePlayerInventory.bundle(this.owner, this.menu, source, kind, this.tooltipSources.mode()); ++ return true; ++ } catch (final RuntimeException unsupported) { this.fallback(unsupported); return true; } ++ } ++ + private FurnaceOptions changePreference(final NativeCookingPreferenceControl control, final NativeUiToggle.Change change, + final FurnaceOptions current) { + if (!(this.presentation instanceof NativeCookingPresentation cooking) || !cooking.idle() @@ -2770,7 +2829,7 @@ index 0000000..26e352c + this.fallback(new IllegalArgumentException("Native recipe drop input is not supported")); + return true; + } -+ if (this.waitingForPreference()) { ++ if (this.waitingForPreference() || this.presentation instanceof NativePlayerPresentation player && !player.idle()) { + if (event.key() == InputConstants.KEY_ESCAPE) this.onClose(); + else { + final String source = keyboardSource(event.key()); @@ -2863,6 +2922,8 @@ index 0000000..26e352c + this.recipePress = new RecipePress(-1, recipe, mode); + handled = true; + } ++ } else if (this.bundleEvent(event)) { ++ handled = true; + } else if (button.transition() && this.presentation instanceof NativePlayerPresentation && !this.helpView && cursorDropButton(button.target()) >= 0) { + this.slotClicked(null, -999, cursorDropButton(button.target()), ContainerInput.PICKUP); + handled = true; @@ -3429,10 +3490,10 @@ index 0000000..3e78d4d +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockNativePlayerInventory.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockNativePlayerInventory.java new file mode 100644 -index 0000000..3f1fe85 +index 0000000..f4a6933 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockNativePlayerInventory.java -@@ -0,0 +1,173 @@ +@@ -0,0 +1,185 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -3457,6 +3518,7 @@ index 0000000..3f1fe85 +import net.minecraft.world.inventory.CraftingMenu; +import net.minecraft.world.item.ItemStack; +import net.raphimc.viabedrock.api.model.NativePlayerInventoryAction; ++import net.raphimc.viabedrock.api.model.NativeBundleAction; +import net.raphimc.viabedrock.api.model.NativePlayerInventoryMessage; +import net.raphimc.viabedrock.protocol.model.InventoryOptions; + @@ -3477,6 +3539,7 @@ index 0000000..3f1fe85 + PayloadTypeRegistry.clientboundPlay().register(NativePlayerInventoryResultPayload.TYPE, NativePlayerInventoryResultPayload.CODEC); + PayloadTypeRegistry.serverboundPlay().register(NativePlayerInventoryActionPayload.TYPE, NativePlayerInventoryActionPayload.CODEC); + PayloadTypeRegistry.serverboundPlay().register(NativePlayerInventoryActionPayload.AUTO_DESTROY_TYPE, NativePlayerInventoryActionPayload.CODEC); ++ PayloadTypeRegistry.serverboundPlay().register(NativeBundleActionPayload.TYPE, NativeBundleActionPayload.CODEC); + ClientPlayConnectionEvents.INIT.register((listener, client) -> { + clear(); + owner = listener; @@ -3581,6 +3644,16 @@ index 0000000..3f1fe85 + && presentation.send(kind, target, "", null, BedrockNativePlayerInventory::send); + } + ++ static boolean bundlesAvailable(final ClientPacketListener listener, final AbstractContainerMenu expected) { ++ return current(listener, expected) && presentation != null && ClientPlayNetworking.canSend(NativeBundleActionPayload.TYPE); ++ } ++ ++ static boolean bundle(final ClientPacketListener listener, final AbstractContainerMenu expected, final NativeInventorySourceState.Source source, ++ final NativeBundleAction.Kind kind, final int tooltipMode) { ++ return bundlesAvailable(listener, expected) && presentation.bundle(source.collection(), source.index(), kind, tooltipMode, ++ action -> ClientPlayNetworking.send(new NativeBundleActionPayload(action))); ++ } ++ + static boolean preferences(final ClientPacketListener listener, final AbstractContainerMenu expected, final InventoryOptions options) { + return current(listener, expected) && presentation != null + && presentation.send(NativePlayerInventoryAction.Kind.PREFERENCES, -1, "", options, BedrockNativePlayerInventory::send); @@ -3756,6 +3829,34 @@ index 0000000..6f7e980 + packet.sendToServer(BedrockProtocol.class); + } +} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeBundleActionPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeBundleActionPayload.java +new file mode 100644 +index 0000000..b4372a9 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeBundleActionPayload.java +@@ -0,0 +1,22 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.container; ++ ++import net.minecraft.network.RegistryFriendlyByteBuf; ++import net.minecraft.network.codec.StreamCodec; ++import net.minecraft.network.protocol.common.custom.CustomPacketPayload; ++import net.minecraft.resources.Identifier; ++import net.raphimc.viabedrock.api.model.NativeBundleAction; ++ ++public record NativeBundleActionPayload(NativeBundleAction action) implements CustomPacketPayload { ++ public static final Type TYPE = new Type<>(Identifier.parse(NativeBundleAction.CHANNEL)); ++ public static final StreamCodec CODEC = CustomPacketPayload.codec( ++ (payload, buffer) -> NativeBundleAction.TYPE.write(buffer, payload.action), ++ buffer -> new NativeBundleActionPayload(NativeBundleAction.TYPE.read(buffer))); ++ ++ @Override ++ public Type type() { return TYPE; } ++} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerActionPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerActionPayload.java new file mode 100644 index 0000000..9817c5b @@ -3944,7 +4045,7 @@ index 0000000..3387649 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerGeometry.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerGeometry.java new file mode 100644 -index 0000000..d88f727 +index 0000000..89969ba --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerGeometry.java @@ -0,0 +1,273 @@ @@ -4009,7 +4110,7 @@ index 0000000..d88f727 + final var recipe = recipe(node, draw.enabled(), recipeActions); + final var preference = cooking ? NativeCookingPreferenceControl.parse(node) : null; + final boolean navigation = cooking && NativeCookingNavigation.mapped(node); -+ validateActions(node, recipe, cooking, reinjected, player); ++ if (draw.enabled()) validateActions(node, recipe, cooking, reinjected, player); + final boolean mappedSlot = node.properties().has("inventory_actions") && node.properties().has("inventory_slot"); + final Set help = player ? helpInputs(node) : Set.of(); + final boolean command = !help.isEmpty() || node.type().equals("button") && (node.string("inventory_command", "").equals("close") || node.flag("help_close", false)); @@ -4223,7 +4324,7 @@ index 0000000..d88f727 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerInput.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerInput.java new file mode 100644 -index 0000000..7de71b6 +index 0000000..d221870 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerInput.java @@ -0,0 +1,521 @@ @@ -4466,7 +4567,7 @@ index 0000000..7de71b6 + if (entry.button != null && result.hover().changed()) entry.button.hover(entry.hovered, active); + if (entry.toggle != null) this.change(entry, entry.toggle.hover(entry.hovered), events); + for (final var button : result.hover().events()) events.add(new Event(entry.node, button, null)); -+ consumed |= result.result() == 1; ++ consumed |= active && result.result() == 1; + } + return this.deliver(consumed, events, mode, nanos, enabled); + } @@ -6532,10 +6633,10 @@ index 0000000..c655dc7 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventorySourceState.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventorySourceState.java new file mode 100644 -index 0000000..04a65e0 +index 0000000..aa4e2c0 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventorySourceState.java -@@ -0,0 +1,164 @@ +@@ -0,0 +1,181 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -6551,6 +6652,8 @@ index 0000000..04a65e0 +import net.raphimc.viabedrock.api.resourcepack.ui.BundleTooltipLayout; +import net.raphimc.viabedrock.api.resourcepack.ui.BundleTooltipUi; +import net.raphimc.viabedrock.api.resourcepack.ui.UiFactoryInstances; ++import net.raphimc.viabedrock.api.model.NativeBundleAction; ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiInput; + +/** Retained native source contexts, separate from recipe hover and Java menu item ordinals. */ +final class NativeInventorySourceState { @@ -6572,6 +6675,21 @@ index 0000000..04a65e0 + long revision() { return this.revision; } + Source bundle() { return this.bundle; } + Source hover() { return this.hover; } ++ int mode() { return this.mode; } ++ ++ /** Desktop state1 activation with no retained primary or secondary source. */ ++ NativeBundleAction.Kind desktopBundleInput(final NativeContainerInput.Event event, final boolean heldStorage, ++ final boolean heldEmpty, final boolean clickedStorage, final boolean clickedEmpty) { ++ final var button = event.button(); ++ if (button == null || !button.transition() || button.mode() != 1 && button.mode() != 3 ++ || this.primary.present() || this.secondarySelected || this.mode == 1 ++ || event.collectionIndex() < 0 || event.collectionIndex() > 255 || event.collection().isEmpty()) return null; ++ if (button.target() == NativeUiInput.symbol("button.container_take_all_place_all") ++ && (heldStorage && !clickedEmpty || !heldEmpty && clickedStorage)) return NativeBundleAction.Kind.PRIMARY; ++ if (button.target() == NativeUiInput.symbol("button.container_take_half_place_one") ++ && (heldStorage && clickedEmpty || heldEmpty && clickedStorage && this.mode == 2)) return NativeBundleAction.Kind.SECONDARY; ++ return null; ++ } + + void inputMode(final int mode) { + if (this.inputMode != mode) { @@ -6857,10 +6975,10 @@ index 0000000..8482173 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativePlayerPresentation.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativePlayerPresentation.java new file mode 100644 -index 0000000..a116b55 +index 0000000..be13299 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativePlayerPresentation.java -@@ -0,0 +1,216 @@ +@@ -0,0 +1,235 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -6881,6 +6999,7 @@ index 0000000..a116b55 +import java.util.function.LongSupplier; +import net.minecraft.world.item.ItemStack; +import net.raphimc.viabedrock.api.model.NativePlayerInventoryAction; ++import net.raphimc.viabedrock.api.model.NativeBundleAction; +import net.raphimc.viabedrock.api.model.NativePlayerInventoryMessage; +import net.raphimc.viabedrock.api.model.NativePlayerInventoryResult; +import net.raphimc.viabedrock.api.resourcepack.ui.ContainerUi; @@ -6901,6 +7020,7 @@ index 0000000..a116b55 + private long revision; + private long sequence; + private NativePlayerInventoryAction pending; ++ private NativeBundleAction pendingBundle; + private long sentAt; + private String queuedQuery; + private boolean retired; @@ -6945,9 +7065,9 @@ index 0000000..a116b55 + + NativePlayerInventoryMessage message() { return this.message; } + PlayerInventoryUi.State state() { return this.state; } -+ boolean idle() { return !this.retired && this.pending == null; } -+ boolean expired() { return this.pending != null && this.clock.getAsLong() - this.sentAt >= 10_000_000_000L; } -+ void retire() { this.retired = true; this.pending = null; this.queuedQuery = null; } ++ boolean idle() { return !this.retired && this.pending == null && this.pendingBundle == null; } ++ boolean expired() { return (this.pending != null || this.pendingBundle != null) && this.clock.getAsLong() - this.sentAt >= 10_000_000_000L; } ++ void retire() { this.retired = true; this.pending = null; this.pendingBundle = null; this.queuedQuery = null; } + String query() { return this.queuedQuery != null ? this.queuedQuery : this.pending != null && this.pending.kind() == NativePlayerInventoryAction.Kind.SEARCH + ? this.pending.query() : this.state.query(); } + @@ -7020,15 +7140,32 @@ index 0000000..a116b55 + } + + boolean result(final NativePlayerInventoryResult result) { -+ if (this.retired || this.pending == null || result.opening() != this.pending.opening() -+ || result.generation() != this.pending.generation() || result.request() != this.pending.request()) return false; -+ if (!result.accepted() && this.pending.kind() == NativePlayerInventoryAction.Kind.SEARCH && this.queuedQuery == null) ++ if (this.retired || this.pending == null && this.pendingBundle == null) return false; ++ final long opening = this.pendingBundle == null ? this.pending.opening() : this.pendingBundle.opening(); ++ final long generation = this.pendingBundle == null ? this.pending.generation() : this.pendingBundle.generation(); ++ final long request = this.pendingBundle == null ? this.pending.request() : this.pendingBundle.request(); ++ if (result.opening() != opening || result.generation() != generation || result.request() != request) return false; ++ if (!result.accepted() && this.pending != null && this.pending.kind() == NativePlayerInventoryAction.Kind.SEARCH && this.queuedQuery == null) + this.queuedQuery = this.pending.query(); + this.pending = null; ++ this.pendingBundle = null; + this.revision++; + return true; + } + ++ boolean bundle(final String collection, final int index, final NativeBundleAction.Kind kind, final int tooltipMode, ++ final Consumer sender) { ++ if (!this.idle() || this.message.generation() <= 0) return false; ++ final var message = this.message; ++ final var action = new NativeBundleAction(message.menuId(), message.opening(), message.generation(), message.revision(), ++ Math.incrementExact(this.sequence), collection, index, kind, tooltipMode); ++ this.sequence = action.request(); ++ this.pendingBundle = action; ++ this.sentAt = this.clock.getAsLong(); ++ sender.accept(action); ++ return true; ++ } ++ + @Override public long revision() { return this.revision; } + + @Override @@ -8475,10 +8612,10 @@ index 0000000..f0c9c26 +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerGeometryTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerGeometryTest.java new file mode 100644 -index 0000000..e120ebc +index 0000000..71d454b --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerGeometryTest.java -@@ -0,0 +1,389 @@ +@@ -0,0 +1,407 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -8490,6 +8627,7 @@ index 0000000..e120ebc + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; ++import com.google.gson.JsonParser; +import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiLayout; +import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiInput; +import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiToggle; @@ -8633,6 +8771,23 @@ index 0000000..e120ebc + } + } + ++ @Test ++ void disabledTooltipControlsStayInactiveAndAreRevalidatedBeforeTheyBecomeActionable() { ++ final var properties = new JsonObject(); ++ properties.add("button_mappings", JsonParser.parseString(""" ++ [{"from_button_id":"button.menu_select","to_button_id":"button.tapped_bundle_tooltip","mapping_type":"pressed"}] ++ """).getAsJsonArray()); ++ final var node = new NativeUiScene.Node("empty_button", "button", properties, List.of(), Integer.MIN_VALUE); ++ final var rect = new NativeUiLayout.Rect(0, 0, 18, 18); ++ final var box = new NativeUiLayout.Box(node, rect, rect, new NativeUiLayout.Size(18, 18), rect, 0, List.of()); ++ final var disabled = new NativeUiPresentation.Draw(box, 1, 1, false); ++ final var enabled = new NativeUiPresentation.Draw(box, 1, 1, true); ++ final var geometry = new NativeContainerGeometry(List.of(disabled), 46, Map.of(), false, true); ++ assertEquals(-1, geometry.hovered(9, 9)); ++ assertFalse(geometry.reusableFor(List.of(enabled))); ++ assertThrows(IllegalArgumentException.class, () -> new NativeContainerGeometry(List.of(enabled), 46, Map.of(), false, true)); ++ } ++ + private static NativeUiPresentation.Draw preference() { + final var properties = com.google.gson.JsonParser.parseString(""" + {"toggle_name":"navigation_tab","toggle_group_forced_index":2,"toggle_state":false, @@ -12792,10 +12947,10 @@ index 0000000..3875605 +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativePlayerPresentationTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativePlayerPresentationTest.java new file mode 100644 -index 0000000..f2916dd +index 0000000..42713cd --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativePlayerPresentationTest.java -@@ -0,0 +1,189 @@ +@@ -0,0 +1,226 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -12827,6 +12982,7 @@ index 0000000..f2916dd +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.raphimc.viabedrock.api.model.NativePlayerInventoryAction; ++import net.raphimc.viabedrock.api.model.NativeBundleAction; +import net.raphimc.viabedrock.api.model.NativePlayerInventoryMessage; +import net.raphimc.viabedrock.api.model.NativePlayerInventoryResult; +import net.raphimc.viabedrock.api.resourcepack.ui.ContainerUi; @@ -12976,6 +13132,42 @@ index 0000000..f2916dd + Map.of("#inventory_stack_count", new JsonPrimitive(0)))).toList(); + } + ++ @Test ++ void bundleRepliesSerializeSearchAndCraftingAgainstTheLatestPublishedRevision() { ++ final var clock = new AtomicLong(); ++ final var presentation = new NativePlayerPresentation(message(false, 1, 8, 1, "", false), Map.of(), Map.of(), clock::get); ++ final List bundles = new ArrayList<>(); ++ final List recipes = new ArrayList<>(); ++ assertTrue(presentation.bundle("inventory_items", 4, NativeBundleAction.Kind.PRIMARY, 2, bundles::add)); ++ assertEquals(new NativeBundleAction(0, 1, 8, 1, 1, "inventory_items", 4, NativeBundleAction.Kind.PRIMARY, 2), bundles.getFirst()); ++ presentation.query("oak", recipes::add); ++ assertTrue(recipes.isEmpty()); ++ assertFalse(presentation.send(NativePlayerInventoryAction.Kind.PRIMARY, 0, "", null, recipes::add)); ++ assertFalse(presentation.result(new NativePlayerInventoryResult(2, 8, 1, 2, true))); ++ assertFalse(presentation.result(new NativePlayerInventoryResult(1, 9, 1, 2, true))); ++ assertFalse(presentation.result(new NativePlayerInventoryResult(1, 8, 2, 2, true))); ++ assertFalse(presentation.idle()); ++ assertTrue(presentation.update(message(false, 1, 8, 2, "", false), Map.of(), Map.of())); ++ assertTrue(presentation.result(new NativePlayerInventoryResult(1, 8, 1, 2, false))); ++ presentation.flush(recipes::add); ++ assertEquals(1, recipes.size()); ++ assertEquals(2, recipes.getFirst().request()); ++ assertEquals(2, recipes.getFirst().revision()); ++ assertEquals("oak", recipes.getFirst().query()); ++ assertFalse(presentation.result(new NativePlayerInventoryResult(1, 8, 1, 2, true))); ++ assertTrue(presentation.result(new NativePlayerInventoryResult(1, 8, 2, 2, true))); ++ assertTrue(presentation.bundle("hotbar_items", 1, NativeBundleAction.Kind.SECONDARY, 0, bundles::add)); ++ assertEquals(3, bundles.getLast().request()); ++ clock.set(10_000_000_001L); ++ assertTrue(presentation.expired()); ++ presentation.retire(); ++ assertFalse(presentation.result(new NativePlayerInventoryResult(1, 8, 3, 2, true))); ++ assertFalse(presentation.bundle("hotbar_items", 1, NativeBundleAction.Kind.SECONDARY, 0, bundles::add)); ++ final var pending = new NativePlayerPresentation(message(false, 2, 0, 1, "", false), Map.of(), Map.of(), clock::get); ++ assertFalse(pending.bundle("inventory_items", 4, NativeBundleAction.Kind.PRIMARY, 0, bundles::add)); ++ assertEquals(2, bundles.size()); ++ } ++ + private static NativePlayerInventoryMessage message(final boolean table, final long opening, final long generation, + final long revision, final String query, final boolean cell) { + final var state = new PlayerInventoryUi.State(table, false, 2, 2, 5, 2, true, query, "Recipes", "Crafting", @@ -12987,10 +13179,10 @@ index 0000000..f2916dd +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativePlayerScreenTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativePlayerScreenTest.java new file mode 100644 -index 0000000..968ec9d +index 0000000..0f7fda4 --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativePlayerScreenTest.java -@@ -0,0 +1,720 @@ +@@ -0,0 +1,835 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -13012,6 +13204,7 @@ index 0000000..968ec9d +import com.viaversion.viaversion.api.minecraft.item.StructuredItem; +import com.viaversion.viaversion.api.minecraft.item.Item; +import java.util.List; ++import java.util.ArrayList; +import java.util.Set; +import java.util.Arrays; +import java.util.stream.Collectors; @@ -13054,6 +13247,10 @@ index 0000000..968ec9d +import net.minecraft.world.item.component.CustomData; +import net.raphimc.viabedrock.api.model.NativePlayerInventoryMessage; +import net.raphimc.viabedrock.api.model.NativePlayerInventoryAction; ++import net.raphimc.viabedrock.api.model.NativeBundleAction; ++import net.raphimc.viabedrock.api.model.NativeBundleTooltipData; ++import net.raphimc.viabedrock.api.model.NativeBundleTooltipItems; ++import net.raphimc.viabedrock.api.model.NativePlayerInventoryResult; +import net.raphimc.viabedrock.api.model.NativeItemStorageData; +import net.raphimc.viabedrock.api.resourcepack.ui.PlayerInventoryUi; +import net.raphimc.viabedrock.protocol.storage.ResourcePackStorage; @@ -13586,6 +13783,116 @@ index 0000000..968ec9d + } + + @ParameterizedTest ++ @ValueSource(ints = {0, 1, 2, 3}) ++ void bundleClicksUseAuthoredSourcesWithoutSendingASecondJavaInventoryAction(final int scenario) throws Exception { ++ final boolean cursorRoot = scenario % 2 == 0; ++ final boolean secondary = scenario >= 2; ++ final var game = mock(Minecraft.class); ++ final var player = mock(LocalPlayer.class); ++ final var inventory = new Inventory(player, new EntityEquipment()); ++ final var menu = new InventoryMenu(inventory, true, player); ++ final var owner = mock(ClientPacketListener.class); ++ final var connection = mock(Connection.class); ++ final var ordinary = mock(AbstractContainerScreen.class); ++ final var options = mock(Options.class); ++ final var font = mock(Font.class); ++ final var clock = new AtomicLong(); ++ game.player = player; ++ game.gameMode = mock(MultiPlayerGameMode.class); ++ player.containerMenu = menu; ++ set(Player.class, player, "inventoryMenu", menu); ++ when(ordinary.getMenu()).thenReturn(menu); ++ when(owner.getConnection()).thenReturn(connection); ++ when(connection.isConnected()).thenReturn(true); ++ when(game.getConnection()).thenReturn(owner); ++ for (final String name : List.of("keyPickItem", "keyDrop", "keyInventory")) ++ set(Options.class, options, name, mock(KeyMapping.class)); ++ set(Minecraft.class, game, "options", options); ++ set(Minecraft.class, game, "gui", mock(Gui.class)); ++ final var ui = new PlayerInventoryUi(new ResourcePackStorage(List.of()).getJsonUiProgram()); ++ final var state = new PlayerInventoryUi.State(false, false, 2, 2, 5, 2, false, "", "Recipes", "Crafting", ++ List.of(0, 0, 0, 0), List.of(), "", -1); ++ final var message = new NativePlayerInventoryMessage(0, 1, 8, 1, ui.exportProgram(false).toString(), ++ GsonUtil.getGson().toJson(state), Map.of()); ++ final var presentation = new NativePlayerPresentation(message, Map.of(), Map.of(), clock::get); ++ set(BedrockNativePlayerInventory.class, null, "owner", owner); ++ set(BedrockNativePlayerInventory.class, null, "menu", menu); ++ set(BedrockNativePlayerInventory.class, null, "presentation", presentation); ++ final List sent = new ArrayList<>(); ++ try (final var singleton = mockStatic(Minecraft.class); ++ final var openings = mockStatic(BedrockPlayerInventory.class); ++ final var network = mockStatic(ClientPlayNetworking.class)) { ++ singleton.when(Minecraft::getInstance).thenReturn(game); ++ openings.when(() -> BedrockPlayerInventory.opening(owner, menu)).thenReturn(1L); ++ network.when(() -> ClientPlayNetworking.canSend(NativePlayerInventoryActionPayload.TYPE)).thenReturn(true); ++ network.when(() -> ClientPlayNetworking.canSend(NativeBundleActionPayload.TYPE)).thenReturn(true); ++ network.when(() -> ClientPlayNetworking.send(any(NativeBundleActionPayload.class))).thenAnswer(call -> { ++ sent.add(((NativeBundleActionPayload) call.getArgument(0)).action()); ++ return null; ++ }); ++ final var bundle = bundle(); ++ final var external = secondary ? ItemStack.EMPTY : new ItemStack(Items.IRON_INGOT, 5); ++ menu.getSlot(9).set(cursorRoot ? external : bundle); ++ menu.setCarried(cursorRoot ? bundle : external); ++ final var screen = new BedrockNativeContainerScreen(owner, ordinary, inventory, Component.empty(), presentation, ++ false, 1, clock::get, () -> true); ++ set(Screen.class, screen, "minecraft", game); ++ set(Screen.class, screen, "font", font); ++ try { ++ screen.prepare(font, 640, 360); ++ clock.set(1_000_000_000); ++ screen.prepare(font, 640, 360); ++ final var target = pointer(screen, draw -> draw.box().node().properties().has("inventory_slot") ++ && draw.box().node().properties().get("inventory_slot").getAsInt() == 9); ++ final var mouse = new MouseButtonEvent(target.x(), target.y(), new MouseButtonInfo( ++ secondary ? InputConstants.MOUSE_BUTTON_RIGHT : InputConstants.MOUSE_BUTTON_LEFT, 0)); ++ screen.mouseMoved(target.x(), target.y()); ++ screen.prepare(font, 640, 360); ++ assertTrue(screen.mouseClicked(mouse, false)); ++ assertEquals(1, sent.size()); ++ assertTrue(screen.mouseReleased(mouse)); ++ assertEquals(new NativeBundleAction(0, 1, 8, 1, 1, "inventory_items", 0, ++ secondary ? NativeBundleAction.Kind.SECONDARY : NativeBundleAction.Kind.PRIMARY, cursorRoot ? 0 : 2), sent.getFirst()); ++ assertFalse(presentation.idle()); ++ clock.set(2_000_000_000); ++ assertTrue(screen.mouseClicked(mouse, false)); ++ assertTrue(screen.mouseReleased(mouse)); ++ assertEquals(1, sent.size()); ++ assertTrue(presentation.result(new NativePlayerInventoryResult(1, 8, 1, 1, true))); ++ assertSame(cursorRoot ? bundle : external, menu.getCarried()); ++ assertSame(cursorRoot ? external : bundle, menu.getSlot(9).getItem()); ++ verifyNoInteractions(game.gameMode); ++ } finally { screen.discard(); } ++ } finally { ++ set(BedrockNativePlayerInventory.class, null, "presentation", null); ++ set(BedrockNativePlayerInventory.class, null, "menu", null); ++ set(BedrockNativePlayerInventory.class, null, "owner", null); ++ } ++ } ++ ++ private static ItemStack bundle() { ++ final var data = new CompoundTag(); ++ final var storage = new CompoundTag(); ++ storage.putInt("version", NativeItemStorageData.VERSION); ++ storage.putInt("modifier", 4); ++ storage.putInt("weight", 0); ++ storage.putInt("capacity", 64); ++ data.put(NativeItemStorageData.TAG, storage); ++ final var tooltip = new CompoundTag(); ++ tooltip.putInt("version", NativeBundleTooltipData.VERSION); ++ tooltip.putInt("identity", 42); ++ tooltip.putInt("viewable_slots", 12); ++ tooltip.putIntArray("amounts", new int[4]); ++ tooltip.putString("primary_name", "Bundle"); ++ tooltip.putString("empty_description", "Empty"); ++ tooltip.putByteArray(NativeBundleTooltipItems.TAG, new NativeBundleTooltipItems(42, 4, Map.of()).encode()); ++ data.put(NativeBundleTooltipData.TAG, tooltip); ++ final var bundle = new ItemStack(Items.BUNDLE); ++ bundle.set(DataComponents.CUSTOM_DATA, CustomData.of(data)); ++ return bundle; ++ } ++ ++ @ParameterizedTest + @ValueSource(ints = {2, 3}) + void creativeScrollingAndResizeKeepTheCompleteCatalogAndOriginalFocusedCell(final int layout) throws Exception { + final var game = mock(Minecraft.class); diff --git a/patches/viafabricplus-bedrock/upstreamable/0029-present-authored-native-containers.pr.md b/patches/viafabricplus-bedrock/upstreamable/0029-present-authored-native-containers.pr.md index c197499a..b93ad448 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0029-present-authored-native-containers.pr.md +++ b/patches/viafabricplus-bedrock/upstreamable/0029-present-authored-native-containers.pr.md @@ -80,7 +80,7 @@ Closed storage-item previews and bars read the core's native weight snapshot. Physical bundle preview tables decode into owned Minecraft stacks and explicit tooltip views without using Java bundle compaction. Identity and counts must match the retained source snapshot. Storage, cooking, player, and workbench presentations accept these views. The decoder requires the core preview API. The renderer consumes physical items and count labels from the same owned snapshot that built the frame. Reference checks reject stale identities, holes, and fractional indices. A screen extraction regression preserves full counts, native opacity/filter state, authored transforms, and the Java menu. Full action lifetime, physical-slot input, and installed comparisons remain unfinished. The default-screen regression retains an empty bundle alongside an inactive recipe. Installed direct and ViaProxy controls retain a server-issued empty bundle in Survival after reopening. -Unknown snapshots restore ordinary controls. Hovering or carrying storage items also restores ordinary controls until native tooltip rendering and selection are complete. +Unknown snapshots restore ordinary controls. Player and workbench screens accept complete carried-bundle snapshots and desktop hover views. Touch selection and the full controller lifetime remain unfinished. Unsupported visible glyphs, recipe auxiliary actions, and held-selection commands also restore ordinary controls. Rejected presentations remain retired until the next opening, so subsequent updates do not repeat the failed installation. Fixed recipe grids expand visible cells and retained keyboard neighbors over the complete catalog. @@ -169,8 +169,10 @@ With one log available, an ordinary recipe click places the real input and shows Bedrock 1.26.60.5 provides the native selection and lifetime reference. Complete native pixels, fallback variant order, variant cycling, and broader recipe families remain unverified. -Container layout supplies the desktop UI pointer to shared cursor placement and invalidates active placement when the pointer moves. Existing bundle admission guards remain until source lifetime, physical-slot rendering and input actions are complete. +Container layout supplies the desktop UI pointer to shared cursor placement and invalidates active placement when the pointer moves. Input validation rejects enabled unsupported controls and repeats validation when a disabled control becomes enabled. -Tooltip sources retain primary, pending, secondary and hover contexts independently of recipe selection. Desktop hover resolves the retained bundle against actual menu slots and native collection aliases. Regressions cover restoration, carried-item priority, source replacement, touch retention, alias leave events and physical-cell selection. Thirteen original source controls and 22 cell-input controls establish the ported state boundaries. Selected physical children request exclusive detail creations with their supplied native hover text. Popup snapshots retain independent lifetime and ignore late retirement of a replaced creation. Client retirement runs after animation traversal, and input reconciliation discards captures from replaced creations. Regressions cover these boundaries. The full action graph, transfer quantity, native detail visibility, child-description formatting, and installed renderer comparison remain open; active tooltip admission stays guarded. +Tooltip sources retain primary, pending, secondary and hover contexts independently of recipe selection. Desktop hover resolves the retained bundle against actual menu slots and native collection aliases. Regressions cover restoration, carried-item priority, source replacement, touch retention, alias leave events and physical-cell selection. Thirteen original source controls and 22 cell-input controls establish the ported state boundaries. Selected physical children request exclusive detail creations with their supplied native hover text. Popup snapshots retain independent lifetime and ignore late retirement of a replaced creation. Client retirement runs after animation traversal, and input reconciliation discards captures from replaced creations. Regressions cover these boundaries. The full action graph, transfer quantity, native detail visibility, child-description formatting, and installed renderer comparison remain open. Enabled unsupported controls still restore ordinary controls. -Inventory hover events now read the evaluated control property bag. A routed slot-event regression distinguishes the event source from render-slot annotations, observes retained updates and rejects missing or wrongly typed fields. Native 1.26.60.5 controls establish the retained bag and typed source getter. This does not enable the unfinished active bundle controller or establish installed input parity. +Inventory hover events read the evaluated control property bag. A routed slot-event regression distinguishes the event source from render-slot annotations, observes retained updates and rejects missing or wrongly typed fields. Native 1.26.60.5 controls establish the retained bag and typed source getter. These typed sources also feed desktop bundle input; installed input parity remains unverified. + +Desktop player/workbench bundle inputs use the optional core transport and share the presentation's request sequence, reply handling, timeout, and retirement with search and crafting. Real exported-program tests exercise insertion and default extraction with either cursor or clicked roots, including a refreshed hover tooltip. They check exact payload scope, buffered search, stale replies, and suppression of duplicate Java actions while a request is pending. Touch and retained-source gestures, sound effects, complete controller composition, and installed direct/proxy comparisons remain unfinished. From bc0aea66346be189b7608125dfde301399e857a0 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Thu, 5 Nov 2026 03:38:28 +0100 Subject: [PATCH 3/3] fix(inventory): preserve native desktop bundle tooltips Validate physical storage snapshots during frame extraction, route filled-tooltip callbacks through their versioned gates, and retire desktop hover when its bundle moves to the cursor. Keep unsupported retained detail input guarded. Extend the read-only inventory audit with request sequence and pending scope so installed controls distinguish native commands from ordinary Java clicks. Direct and ViaProxy runs each complete ten accepted requests, including five native bundle commands, and reopen with a new presentation identity. Native sound, touch, wire grouping and complete rendering parity remain open. --- docs/bedrock-coverage.md | 2 +- docs/native-player-inventory.md | 9 +- docs/server-replay.md | 2 + ...ain-bedrock-creative-catalog-entries.pr.md | 6 +- ...9-present-authored-native-containers.patch | 179 ++++++++++++++++-- ...9-present-authored-native-containers.pr.md | 4 +- .../replay/fabric/NativeFormAudit.java | 24 ++- 7 files changed, 194 insertions(+), 32 deletions(-) diff --git a/docs/bedrock-coverage.md b/docs/bedrock-coverage.md index 3493182d..40581ebf 100644 --- a/docs/bedrock-coverage.md +++ b/docs/bedrock-coverage.md @@ -26,7 +26,7 @@ No complete area has verified Bedrock parity. | World resources | World base-game version selects vanilla metadata, textures, actor graphs, and audio before server overrides. The context survives ViaProxy. Server legacy biome bindings retain unqualified names across versions. | Native current/older/current cat, sound, and remote biome-name controls cover selected paths. Broader pose and audio behavior need comparisons. | | Held block placement | Retained stationary build paths, next-cell ray selection, independent fresh block/air admission, frame retries after rejection, use timing, trapdoor orientation, cancellation, and prediction. | Native, direct, and proxy controls cover selected stationary paths, first rejection, last-stack placement, activation, re-aiming, and release. Moving placement, air/entity dispatch, build-height limits, and broader input scheduling remain open. | | Fonts and UI | Native bitmap metrics and italic geometry, authored scenes, TrueType cache and atlas sampling, and primary font aliases. | Selected bitmap and TrueType controls match at 1280 × 720. [Validation and limits](../patches/viafabricplus-bedrock/upstreamable/0029-share-native-ui-scene-rendering.pr.md). Complete screens, bold and shadowed text, other font families, mixed or scaled aliases, and broader viewports remain open. | -| Player inventory and crafting | Opening ownership, recipe catalogs, Creative catalog actions, Survival bulk and manual crafting actions, retained recipe queries, compressed snapshots, local Help, inactive item lighting, native storage weights, and native stack identities. | Complete E-key/workbench presentation, bundle actions/tooltips, query lifetime across connections, broader Survival recipes, and original live bug reports remain unresolved. See the [inventory reference](native-player-inventory.md). | +| Player inventory and crafting | Opening ownership, recipe catalogs, Creative catalog actions, Survival bulk and manual crafting actions, retained recipe queries, compressed snapshots, local Help, inactive item lighting, native storage weights, desktop bundle insertion/default extraction, and native stack identities. | Complete E-key/workbench presentation, bundle controller and tooltip lifetimes, query lifetime across connections, broader Survival recipes, and original live bug reports remain unresolved. See the [inventory reference](native-player-inventory.md). | | Food use | Native components govern admission and Java food/consumable properties, with Creative and Peaceful exceptions. Local use retains native item identity across Java carriers. | Selected admission, input-deadline, timed-food, held-mode, release, and pause controls pass. Short input edges during active Java use, broader use cycles, and the original intermittent report remain open. See the [food-use evidence](../patches/viabedrock/deferred/0008-signal-sustained-bedrock-item-use.pr.md). | | Cooking | Snapshot-bound recipe actions, transfer batches, preferences, search, and progress. | Default cooking screens retain ordinary controls until authored bindings are complete. See the [cooking reference](native-cooking-actions.md). | | Skins and appearance | Complete skin transport, classic changes, persona assembly, animation graphs, costumes, and attachables. | Broader wardrobe masking, profile/LOD changes, shield rendering, remote appearances, and account/platform flows need further native comparisons. See [skin support](skin-flow.md). | diff --git a/docs/native-player-inventory.md b/docs/native-player-inventory.md index de94d892..22a51372 100644 --- a/docs/native-player-inventory.md +++ b/docs/native-player-inventory.md @@ -214,13 +214,10 @@ Both routes return from Help with a carried stack and confirm its placement thro Inactive item icons use separate lighting and opacity. The default disabled light multiplier is 0.25. Typed overrides and RGB conversion match selected native instruction controls. Complete material and pixel parity remain unverified. Storage bars read a native weight snapshot independently of Java bundle contents. -Installed direct and ViaProxy controls insert eight logs into a server-issued empty bundle, extract them, and insert them again. -Both routes retain the filled bundle after reopening, with server responses that accept each request. -These installed checks used ordinary Java 26.3 controls after the storage-tooltip fallback. Left-click inserted items; right-click extracted them. -The first insertion request from the official Bedrock client remains unverified. -Filled-bundle bars still need installed native comparison. Unknown storage snapshots restore ordinary controls. -Player and workbench screens now route selected desktop bundle gestures through the native input transport. Local regressions cover insertion and default extraction with either cursor or clicked roots, refreshed hover views, stale snapshots, rejection rollback, and serialized search edits. Installed direct/proxy comparisons, sound effects, touch gestures, and complete bundle controller and rendering parity remain unfinished. +Player and workbench screens route selected desktop bundle gestures through the native input transport. Local regressions cover insertion and default extraction with either cursor or clicked roots, filled hover views, moved sources, stale snapshots, rejection rollback, and serialized search edits. +Installed direct and ViaProxy controls each complete ten server-accepted requests, including five native bundle commands. They insert and extract five iron ingots with the bundle in either the clicked slot or the cursor, then retain the filled bundle after reopening. Screen audits distinguish the native commands from ordinary Java pickup and placement through their request sequence. +Native wire grouping, live rejection controls, sound effects, touch and retained detail gestures, and complete bundle controller and rendering parity remain unfinished. Unsupported visible Help glyphs, recipe auxiliary actions, and held-selection commands also restore ordinary controls. The Creative catalog-only toolbar binds Shift-left-click and Shift-right-click to full-stack destruction. The action preserves the cursor, requires a current acknowledged opening, and leaves empty slots unchanged. diff --git a/docs/server-replay.md b/docs/server-replay.md index 48f0a473..4dea6b5c 100644 --- a/docs/server-replay.md +++ b/docs/server-replay.md @@ -177,6 +177,8 @@ For authored forms, the observer reads identity from the form controller and geo The `presentation` field identifies the path. `nativeForm` is true only for an authored form. Shared scenes can belong to another domain. A Java dialog classification does not prove that Bedrock supplied it. The bounded geometry report excludes authored labels, URLs, and input text. +Player and workbench screens also report the request sequence, pending command flags, opening, generation, and revision when those fields are available. +Match sequence changes to input markers and Bedrock request responses to distinguish native presentation commands from ordinary Java clicks. The inventory report excludes query text and item data. Private `movement-audit.jsonl` observations record position, velocity, item use, held item, and selected hotbar slot at each observed movement phase. The `epochMillis` timestamp aligns those observations with external input markers. diff --git a/patches/viabedrock/deferred/0012-retain-bedrock-creative-catalog-entries.pr.md b/patches/viabedrock/deferred/0012-retain-bedrock-creative-catalog-entries.pr.md index 4529762c..34375ab5 100644 --- a/patches/viabedrock/deferred/0012-retain-bedrock-creative-catalog-entries.pr.md +++ b/patches/viabedrock/deferred/0012-retain-bedrock-creative-catalog-entries.pr.md @@ -174,8 +174,8 @@ Storage filter bindings belong to the retained item definition. Name and item-ta Ordinary Java bundle insertion applies available filters and nesting rules before prediction. Regressions cover both insertion directions, accepted and rejected items, descriptor auxiliary values, and retained definitions sharing a numeric ID. -The native bundle action session resolves current server-owned roots and physical containers, then submits the native transfer plan through the ordered request tracker. Each transfer captures its intermediate prediction and chains changed-slot request IDs, including emptied slots. A rejected incoming item can still submit preparation requests. Extraction remains independent of insertion weight metadata. Boundary tests cover out-of-order acceptance, rejection, unavailable filters, replacement owners, failed sends, authoritative updates, and reopening with a new crafting grid. Full controller delivery, native wire grouping, and installed direct/proxy validation remain unfinished; this backend does not enable bundle gestures in the add-on. +The native bundle action session resolves current server-owned roots and physical containers, then submits the native transfer plan through the ordered request tracker. Each transfer captures its intermediate prediction and chains changed-slot request IDs, including emptied slots. A rejected incoming item can still submit preparation requests. Extraction remains independent of insertion weight metadata. Boundary tests cover out-of-order acceptance, rejection, unavailable filters, replacement owners, failed sends, authoritative updates, and reopening with a new crafting grid. The add-on sends desktop gestures through the publication-bound transport below. Full controller composition and native wire grouping remain unfinished. -Desktop state 1 uses separate bundle predicates from touch state 45. Original controls cover 288 transition cases and 24 insertion-handler cases for input modes 1/3, tooltip modes 0/2, and absent retained primary sources. They establish activation entry and cursor/clicked source ownership with supplied model and admission services. The session exposes default extraction separately and tests its Take request, partial cursor merge, authoritative response, and full-cursor no-op. Complete controller composition and installed input/rendering remain unverified. +Desktop state 1 uses separate bundle predicates from touch state 45. Original controls cover 288 transition cases and 24 insertion-handler cases for input modes 1/3, tooltip modes 0/2, and absent retained primary sources. They establish activation entry and cursor/clicked source ownership with supplied model and admission services. The session exposes default extraction separately and tests its Take request, partial cursor merge, authoritative response, and full-cursor no-op. These instruction controls do not establish complete controller composition or rendering parity. -The optional bundle-input channel accepts a displayed collection/index and input kind against the current opening, generation, revision, and shared recipe-command sequence. All item data and physical container references come from retained server state. Source, nested-content, and owner changes refresh the publication even when recipe previews stay unchanged. Boundary regressions cover stale inputs, late dynamic-container arrival, shared replay protection, and sparse workbench request slots with rejection rollback. Twenty-four additional original state-1 controls establish empty-cursor secondary extraction in tooltip mode 2; supplied admission and model services remain explicit limits. Installed direct/proxy routing, sound effects, and complete controller composition remain unverified. +The optional bundle-input channel accepts a displayed collection/index and input kind against the current opening, generation, revision, and shared recipe-command sequence. All item data and physical container references come from retained server state. Source, nested-content, and owner changes refresh the publication even when recipe previews stay unchanged. Boundary regressions cover stale inputs, late dynamic-container arrival, shared replay protection, and sparse workbench request slots with rejection rollback. Twenty-four additional original state-1 controls establish empty-cursor secondary extraction in tooltip mode 2; supplied admission and model services remain explicit limits. Installed direct/proxy controls verify selected desktop gestures; see the [inventory reference](../../../docs/native-player-inventory.md) for scope. Sound effects and complete controller composition remain unverified. diff --git a/patches/viafabricplus-bedrock/upstreamable/0029-present-authored-native-containers.patch b/patches/viafabricplus-bedrock/upstreamable/0029-present-authored-native-containers.patch index d9336fa7..afd23e71 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0029-present-authored-native-containers.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0029-present-authored-native-containers.patch @@ -1,4 +1,4 @@ -From 0a2c428fac144a0a29db3efdb2caec68aedc4f42 Mon Sep 17 00:00:00 2001 +From bc5265c776c92134a7fae32fd5c8cf113c7fc223 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:55:36 +0200 Subject: [PATCH] feat(inventory): present authored native containers @@ -864,6 +864,29 @@ admission when enablement changes and still rejects enabled unsupported controls. Regressions exercise a full exported tooltip refresh before all four bundle gestures and prove that a disabled touch control cannot become active without validation. Full installed pixels and native traversal remain unverified. + +Validate complete physical player/workbench bundle snapshots during frame +extraction with the owned tooltip cache. Do not apply the obsolete blanket +storage guard after the source/controller path has admitted those snapshots. +Keep fallback for unsupported generic screens and recipe/ghost previews. +When a desktop mode2 source stops being storage, repeat the original hover +function's failed-admission path before resolving the view. This retires the +old physical hover when a normal Java prediction moves its bundle to the +cursor, without transferring that hover context to the new owner. + +Route the filled-tooltip hide callback through retained source state. +Original 1.26.60.5 closure14503ba10 changes the hidden flag only for raw +profile2 and returns false for desktop profiles1/3. Admit tapped_bundle_item_icon +from bundle_items: original state52 consumes mode0/2 input without selection +changes. Retained mode1 detail input keeps its explicit fallback until native +description and lifetime production are established. + +Frame-extraction tests retain an actual hovered bundle without retiring its +presentation. Exported-program extraction tests use filled physical previews, +refresh before input, check the desktop cell/hide callbacks, and move the +hovered bundle to the cursor before another refresh. GPU drawing, scissor +submission and item conversion are supplied services. Complete native pixels, +controller composition, touch and retained detail lifetimes remain unverified. --- .../bedrock/ViaFabricPlusBedrock.java | 4 + .../access/IBedrockGuiItemStyle.java | 14 + @@ -877,7 +900,7 @@ without validation. Full installed pixels and native traversal remain unverified .../render/MixinNativeGuiItemState.java | 20 + .../render/MixinNativeGuiOversizedItem.java | 31 + .../container/BedrockFurnacePreferences.java | 60 + - .../BedrockNativeContainerScreen.java | 1538 +++++++++++++++++ + .../BedrockNativeContainerScreen.java | 1555 +++++++++++++++++ .../container/BedrockNativeContainers.java | 283 +++ .../container/BedrockNativeCooking.java | 147 ++ .../BedrockNativePlayerInventory.java | 185 ++ @@ -888,7 +911,7 @@ without validation. Full installed pixels and native traversal remain unverified .../container/NativeContainerDataPayload.java | 30 + .../container/NativeContainerDraws.java | 36 + .../NativeContainerFamiliesPayload.java | 30 + - .../container/NativeContainerGeometry.java | 273 +++ + .../container/NativeContainerGeometry.java | 274 +++ .../container/NativeContainerInput.java | 521 ++++++ .../container/NativeContainerPayload.java | 22 + .../container/NativeContainerPending.java | 65 + @@ -948,10 +971,10 @@ without validation. Full installed pixels and native traversal remain unverified .../container/NativePlayerHelpTextTest.java | 34 + .../NativePlayerInventoryLifetimeTest.java | 142 ++ .../NativePlayerPresentationTest.java | 226 +++ - .../container/NativePlayerScreenTest.java | 835 +++++++++ + .../container/NativePlayerScreenTest.java | 933 ++++++++++ .../PlayerInventoryAdmissionTest.java | 147 ++ .../PlayerInventoryScreenLifecycleTest.java | 139 ++ - 86 files changed, 12649 insertions(+), 62 deletions(-) + 86 files changed, 12765 insertions(+), 62 deletions(-) create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockGuiItemStyle.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/inventory/MixinNativeContainerGeometry.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/inventory/MixinNativeContainerScreenAdmission.java @@ -1504,10 +1527,10 @@ index 0000000..1583ce0 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockNativeContainerScreen.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockNativeContainerScreen.java new file mode 100644 -index 0000000..1363618 +index 0000000..798e773 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockNativeContainerScreen.java -@@ -0,0 +1,1538 @@ +@@ -0,0 +1,1555 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -1821,7 +1844,9 @@ index 0000000..1363618 + } + + private void refreshScene(final List slots) { -+ final var bundle = this.tooltipData(this.tooltipSources.bundle()); ++ final var source = this.tooltipSources.bundle(); ++ final var bundle = this.tooltipData(source); ++ if (bundle == null && this.tooltipSources.mode() == 2) this.tooltipSources.hover(source, false); + final var frame = this.presentation.resolve(this.menu.containerId, this.title.getString(), slots, + NativeInventoryItemPresentation.values(this.menu.getCarried(), true, this.showTags), this.showTags, + this.tooltipSources.view(_ -> bundle, this.showTags)); @@ -1905,7 +1930,10 @@ index 0000000..1363618 + } + + private NativeInventoryItemPresentation.BundleTooltip tooltipData(final Source source) { -+ final var item = this.sourceItem(source); ++ return this.tooltipData(this.sourceItem(source)); ++ } ++ ++ private NativeInventoryItemPresentation.BundleTooltip tooltipData(final ItemStack item) { + if (item == null) return null; + if (item.getCount() != this.tooltipItem.getCount() || !ItemStack.isSameItemSameComponents(item, this.tooltipItem)) { + final var data = NativeInventoryItemPresentation.bundleTooltip(item, @@ -2108,6 +2136,13 @@ index 0000000..1363618 + } else if (event.button() != null && event.button().transition() && event.button().target() == NativeUiInput.symbol("button.help")) { + player.openHelp(); + handled = true; ++ } else if (event.button() != null && event.button().transition() && event.button().target() == NativeUiInput.symbol("button.hide_bundle_tooltip")) { ++ handled |= this.tooltipSources.hide(); ++ } else if (event.button() != null && event.button().pressed() && event.button().previous() != 1 ++ && event.button().target() == NativeUiInput.symbol("button.tapped_bundle_item_icon") && event.collection().equals("bundle_items")) { ++ // Native state52 consumes desktop mode2 cell input without changing selection. ++ if (this.tooltipSources.mode() == 1) this.fallback(new IllegalArgumentException("Retained bundle detail input is not supported")); ++ handled = true; + } else if (event.button() != null && event.button().transition() + && playerUnsupportedHostAction(event.button().target())) { + this.fallback(new IllegalArgumentException("Native inventory host action is not supported")); @@ -2307,7 +2342,10 @@ index 0000000..1363618 + this.updateGeometry(); + } + this.hoveredSlot = this.closing() ? null : this.nativeHoveredSlot(mouseX, mouseY); -+ if (this.hoveredSlot != null) NativeInventoryItemPresentation.requireTooltip(this.hoveredSlot.getItem()); ++ if (this.hoveredSlot != null) { ++ if (this.presentation instanceof NativePlayerPresentation) this.tooltipData(this.hoveredSlot.getItem()); ++ else NativeInventoryItemPresentation.requireTooltip(this.hoveredSlot.getItem()); ++ } + this.previews.clear(); + float layer = Float.NaN; + for (final var draw : this.draws) { @@ -2869,6 +2907,8 @@ index 0000000..1363618 + return this.presentation instanceof NativePlayerPresentation && playerSlotInput(target) != null + || this.nativeActions && this.actionScope.subset() && target == NativeUiInput.symbol("button.container_auto_place_one"); + if (this.presentation instanceof NativePlayerPresentation && target == NativeUiInput.symbol("button.help")) return true; ++ if (this.presentation instanceof NativePlayerPresentation && target == NativeUiInput.symbol("button.hide_bundle_tooltip")) return true; ++ if (this.presentation instanceof NativePlayerPresentation && target == NativeUiInput.symbol("button.tapped_bundle_item_icon")) return true; + if (this.presentation instanceof NativePlayerPresentation && cursorDropButton(target) >= 0) return true; + if (this.presentation instanceof NativePlayerPresentation && node.type().equals("edit_box") + && target == NativeUiInput.symbol("button.search_bar_selected")) return true; @@ -4045,10 +4085,10 @@ index 0000000..3387649 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerGeometry.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerGeometry.java new file mode 100644 -index 0000000..89969ba +index 0000000..408be17 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerGeometry.java -@@ -0,0 +1,273 @@ +@@ -0,0 +1,274 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -4224,7 +4264,8 @@ index 0000000..89969ba + if (recipe != null && player) supported.addAll(RECIPE_AUXILIARY_ACTIONS); + if (player) supported.addAll(PLAYER_FALLBACK_ACTIONS); + if (player && node.properties().has("inventory_slot")) supported.add("button.container_auto_destroy"); -+ if (player) supported.addAll(Set.of("button.help", "button.search_bar_clear", "button.left_panel_tab_increment", "button.left_panel_tab_decrement")); ++ if (player) supported.addAll(Set.of("button.help", "button.search_bar_clear", "button.left_panel_tab_increment", "button.left_panel_tab_decrement", ++ "button.hide_bundle_tooltip", "button.tapped_bundle_item_icon")); + if (node.properties().has("inventory_actions")) { + for (final var action : node.properties().getAsJsonArray("inventory_actions")) + supported.add(action.getAsJsonObject().get("to_button_id").getAsString()); @@ -13179,10 +13220,10 @@ index 0000000..42713cd +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativePlayerScreenTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativePlayerScreenTest.java new file mode 100644 -index 0000000..0f7fda4 +index 0000000..9ca24e6 --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativePlayerScreenTest.java -@@ -0,0 +1,835 @@ +@@ -0,0 +1,933 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -13192,11 +13233,16 @@ index 0000000..0f7fda4 + +import com.mojang.blaze3d.platform.InputConstants; +import com.google.gson.JsonObject; ++import com.google.gson.JsonArray; +import com.viaversion.viafabricplus.bedrock.ViaFabricPlusBedrock; ++import com.viaversion.viafabricplus.bedrock.creative.BedrockCreativeCatalog; +import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiPresentation; +import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiRenderer; +import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiScene; +import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiLayout; ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiAnimationLifecycle; ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiScissor; ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiInput; +import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiFocus; +import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiCollectionWindow; +import com.viaversion.viaversion.util.GsonUtil; @@ -13230,6 +13276,7 @@ index 0000000..0f7fda4 +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.client.input.MouseButtonInfo; +import net.minecraft.client.multiplayer.ClientPacketListener; ++import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.multiplayer.MultiPlayerGameMode; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.network.Connection; @@ -13819,10 +13866,14 @@ index 0000000..0f7fda4 + set(BedrockNativePlayerInventory.class, null, "menu", menu); + set(BedrockNativePlayerInventory.class, null, "presentation", presentation); + final List sent = new ArrayList<>(); ++ game.level = mock(ClientLevel.class); + try (final var singleton = mockStatic(Minecraft.class); + final var openings = mockStatic(BedrockPlayerInventory.class); -+ final var network = mockStatic(ClientPlayNetworking.class)) { ++ final var network = mockStatic(ClientPlayNetworking.class); ++ final var previews = mockStatic(BedrockCreativeCatalog.class)) { + singleton.when(Minecraft::getInstance).thenReturn(game); ++ previews.when(() -> BedrockCreativeCatalog.toNative(any(Item.class), any())) ++ .thenAnswer(call -> new ItemStack(Items.IRON_INGOT, ((Item) call.getArgument(0)).amount())); + openings.when(() -> BedrockPlayerInventory.opening(owner, menu)).thenReturn(1L); + network.when(() -> ClientPlayNetworking.canSend(NativePlayerInventoryActionPayload.TYPE)).thenReturn(true); + network.when(() -> ClientPlayNetworking.canSend(NativeBundleActionPayload.TYPE)).thenReturn(true); @@ -13830,7 +13881,7 @@ index 0000000..0f7fda4 + sent.add(((NativeBundleActionPayload) call.getArgument(0)).action()); + return null; + }); -+ final var bundle = bundle(); ++ final var bundle = bundle(secondary ? 5 : 0); + final var external = secondary ? ItemStack.EMPTY : new ItemStack(Items.IRON_INGOT, 5); + menu.getSlot(9).set(cursorRoot ? external : bundle); + menu.setCarried(cursorRoot ? bundle : external); @@ -13859,8 +13910,40 @@ index 0000000..0f7fda4 + assertTrue(screen.mouseReleased(mouse)); + assertEquals(1, sent.size()); + assertTrue(presentation.result(new NativePlayerInventoryResult(1, 8, 1, 1, true))); ++ if (secondary && !cursorRoot) { ++ final var properties = new JsonObject(); ++ final var bag = new JsonObject(); ++ bag.addProperty("#collection_name", "bundle_items"); ++ bag.addProperty("#collection_index", 0); ++ properties.add("property_bag", bag); ++ final var node = new NativeUiScene.Node("physical_child", "button", properties, List.of(), Integer.MIN_VALUE); ++ final var callbacks = BedrockNativeContainerScreen.class.getDeclaredMethod("componentEvents", NativeContainerInput.Result.class); ++ callbacks.setAccessible(true); ++ final var tap = new NativeUiInput.ButtonEvent(NativeUiInput.symbol("button.menu_select"), ++ NativeUiInput.symbol("button.tapped_bundle_item_icon"), 2, 1, true, 1, NativeUiInput.Scope.DEFAULT, false, false); ++ assertEquals(true, callbacks.invoke(screen, new NativeContainerInput.Result(true, ++ List.of(new NativeContainerInput.Event(node, tap, null))))); ++ final var hide = new NativeUiInput.ButtonEvent(NativeUiInput.symbol("button.menu_secondary_select"), ++ NativeUiInput.symbol("button.hide_bundle_tooltip"), 2, 1, true, 1, NativeUiInput.Scope.DEFAULT, false, false); ++ assertEquals(false, callbacks.invoke(screen, new NativeContainerInput.Result(false, ++ List.of(new NativeContainerInput.Event(node, hide, null))))); ++ assertTrue(presentation.idle()); ++ assertEquals(1, sent.size()); ++ } + assertSame(cursorRoot ? bundle : external, menu.getCarried()); + assertSame(cursorRoot ? external : bundle, menu.getSlot(9).getItem()); ++ if (secondary && !cursorRoot) { ++ menu.getSlot(9).set(ItemStack.EMPTY); ++ menu.setCarried(bundle); ++ assertDoesNotThrow(() -> screen.prepare(font, 640, 360)); ++ final var sources = BedrockNativeContainerScreen.class.getDeclaredField("tooltipSources"); ++ sources.setAccessible(true); ++ final var stateAfterPickup = (NativeInventorySourceState) sources.get(screen); ++ assertEquals(0, stateAfterPickup.mode()); ++ assertEquals(NativeInventorySourceState.NONE, stateAfterPickup.bundle()); ++ assertSame(bundle, menu.getCarried()); ++ assertEquals(1, sent.size()); ++ } + verifyNoInteractions(game.gameMode); + } finally { screen.discard(); } + } finally { @@ -13871,27 +13954,83 @@ index 0000000..0f7fda4 + } + + private static ItemStack bundle() { ++ return bundle(0); ++ } ++ ++ private static ItemStack bundle(final int amount) { + final var data = new CompoundTag(); + final var storage = new CompoundTag(); + storage.putInt("version", NativeItemStorageData.VERSION); + storage.putInt("modifier", 4); -+ storage.putInt("weight", 0); ++ storage.putInt("weight", amount * 4); + storage.putInt("capacity", 64); + data.put(NativeItemStorageData.TAG, storage); + final var tooltip = new CompoundTag(); + tooltip.putInt("version", NativeBundleTooltipData.VERSION); + tooltip.putInt("identity", 42); + tooltip.putInt("viewable_slots", 12); -+ tooltip.putIntArray("amounts", new int[4]); ++ tooltip.putIntArray("amounts", new int[]{amount, 0, 0, 0}); + tooltip.putString("primary_name", "Bundle"); + tooltip.putString("empty_description", "Empty"); -+ tooltip.putByteArray(NativeBundleTooltipItems.TAG, new NativeBundleTooltipItems(42, 4, Map.of()).encode()); ++ final Map previews = amount == 0 ? Map.of() ++ : Map.of(0, new NativeBundleTooltipItems.Preview("Iron Ingot", new StructuredItem(320, amount, new StructuredDataContainer()))); ++ tooltip.putByteArray(NativeBundleTooltipItems.TAG, new NativeBundleTooltipItems(42, 4, previews).encode()); + data.put(NativeBundleTooltipData.TAG, tooltip); + final var bundle = new ItemStack(Items.BUNDLE); + bundle.set(DataComponents.CUSTOM_DATA, CustomData.of(data)); + return bundle; + } + ++ @Test ++ void extractingAHoveredPhysicalBundleKeepsTheNativePlayerScreenInstalled() throws Exception { ++ final var game = mock(Minecraft.class); ++ final var player = mock(LocalPlayer.class); ++ final var inventory = new Inventory(player, new EntityEquipment()); ++ final var menu = new InventoryMenu(inventory, true, player); ++ final var bundle = bundle(); ++ menu.getSlot(9).set(bundle); ++ final var ordinary = mock(AbstractContainerScreen.class); ++ when(ordinary.getMenu()).thenReturn(menu); ++ final var presentation = mock(NativePlayerPresentation.class); ++ final var properties = new JsonObject(); ++ properties.addProperty("inventory_slot", 9); ++ properties.add("inventory_actions", new JsonArray()); ++ final var node = new NativeUiScene.Node("physical_slot", "panel", properties, List.of(), Integer.MIN_VALUE); ++ final var rect = new NativeUiLayout.Rect(0, 0, 18, 18); ++ final var box = new NativeUiLayout.Box(node, rect, rect, new NativeUiLayout.Size(18, 18), rect, 0, List.of()); ++ final var draw = new NativeUiPresentation.Draw(box, 1, 1, true); ++ final var graphics = mock(GuiGraphicsExtractor.class); ++ final var renderer = mock(NativeUiRenderer.class); ++ final var logger = mock(Logger.class); ++ final var impl = mock(ViaFabricPlusBedrock.class); ++ when(impl.logger()).thenReturn(logger); ++ doAnswer(call -> { throw new AssertionError("Native bundle extraction fell back", call.getArgument(1)); }) ++ .when(logger).warn(anyString(), any(Throwable.class)); ++ try (final var singleton = mockStatic(Minecraft.class); ++ final var platform = mockStatic(ViaFabricPlusBedrock.class); ++ final var scissors = mockStatic(NativeUiScissor.class)) { ++ singleton.when(Minecraft::getInstance).thenReturn(game); ++ platform.when(ViaFabricPlusBedrock::impl).thenReturn(impl); ++ final var screen = new BedrockNativeContainerScreen(mock(ClientPacketListener.class), ordinary, inventory, ++ Component.empty(), presentation, false, 1, () -> 0, () -> true); ++ set(Screen.class, screen, "minecraft", game); ++ screen.width = screen.height = 18; ++ set(BedrockNativeContainerScreen.class, screen, "scene", node); ++ set(BedrockNativeContainerScreen.class, screen, "root", box); ++ set(BedrockNativeContainerScreen.class, screen, "geometry", new NativeContainerGeometry(List.of(draw), 46, Map.of(), false, true)); ++ set(BedrockNativeContainerScreen.class, screen, "lifecycle", new NativeUiAnimationLifecycle(node)); ++ set(BedrockNativeContainerScreen.class, screen, "renderer", renderer); ++ assertDoesNotThrow(() -> screen.extractContents(graphics, 9, 9, 0)); ++ final var hovered = AbstractContainerScreen.class.getDeclaredField("hoveredSlot"); ++ hovered.setAccessible(true); ++ assertSame(menu.getSlot(9), hovered.get(screen)); ++ assertSame(bundle, menu.getSlot(9).getItem()); ++ verify(presentation, never()).retire(); ++ verify(renderer).draw(eq(graphics), any(), any()); ++ screen.discard(); ++ } ++ } ++ + @ParameterizedTest + @ValueSource(ints = {2, 3}) + void creativeScrollingAndResizeKeepTheCompleteCatalogAndOriginalFocusedCell(final int layout) throws Exception { diff --git a/patches/viafabricplus-bedrock/upstreamable/0029-present-authored-native-containers.pr.md b/patches/viafabricplus-bedrock/upstreamable/0029-present-authored-native-containers.pr.md index b93ad448..bd2c645c 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0029-present-authored-native-containers.pr.md +++ b/patches/viafabricplus-bedrock/upstreamable/0029-present-authored-native-containers.pr.md @@ -175,4 +175,6 @@ Tooltip sources retain primary, pending, secondary and hover contexts independen Inventory hover events read the evaluated control property bag. A routed slot-event regression distinguishes the event source from render-slot annotations, observes retained updates and rejects missing or wrongly typed fields. Native 1.26.60.5 controls establish the retained bag and typed source getter. These typed sources also feed desktop bundle input; installed input parity remains unverified. -Desktop player/workbench bundle inputs use the optional core transport and share the presentation's request sequence, reply handling, timeout, and retirement with search and crafting. Real exported-program tests exercise insertion and default extraction with either cursor or clicked roots, including a refreshed hover tooltip. They check exact payload scope, buffered search, stale replies, and suppression of duplicate Java actions while a request is pending. Touch and retained-source gestures, sound effects, complete controller composition, and installed direct/proxy comparisons remain unfinished. +Desktop player/workbench bundle inputs use the optional core transport and share the presentation's request sequence, reply handling, timeout, and retirement with search and crafting. Real exported-program tests exercise insertion and default extraction with either cursor or clicked roots, including filled hover views and a bundle moved to the cursor. They check exact payload scope, buffered search, stale replies, desktop cell/hide callbacks, and suppression of duplicate Java actions while a request is pending. A frame-extraction regression covers physical bundle hover. + +Installed direct and ViaProxy controls each complete ten server-accepted requests, including five native bundle commands. Screen audits observe the shared sequence through both insertion/extraction directions and its reset under a new opening after reopening. The screens stay native throughout. Installed add-on hashes match the rebuilt artifact, and the proxy transport classes match the current core. Native wire grouping, live rejections, touch and retained-source gestures, sound effects, full controller composition, and complete pixels remain unfinished. diff --git a/src/replay/fabric/net/pistonmaster/stackanvil/replay/fabric/NativeFormAudit.java b/src/replay/fabric/net/pistonmaster/stackanvil/replay/fabric/NativeFormAudit.java index 5add2c15..5c153a1b 100644 --- a/src/replay/fabric/net/pistonmaster/stackanvil/replay/fabric/NativeFormAudit.java +++ b/src/replay/fabric/net/pistonmaster/stackanvil/replay/fabric/NativeFormAudit.java @@ -15,13 +15,15 @@ import java.util.List; import java.util.Map; -/** Read-only, bounded form geometry observation on the client tick thread. */ +/** Read-only, bounded screen geometry and inventory input observation on the client tick thread. */ public final class NativeFormAudit { private static final String LEGACY_SCREEN = "com.viaversion.viafabricplus.bedrock.screen.form.BedrockNativeFormScreen"; private static final String SCENE_SCREEN = "com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiSceneScreen"; private static final String FORM_CONTROLLER = "com.viaversion.viafabricplus.bedrock.screen.form.BedrockFormController"; private static final String FALLBACK_SCREEN = "com.viaversion.viafabricplus.bedrock.screen.form.BedrockFallbackFormScreen"; private static final String JAVA_DIALOG = "net.minecraft.client.gui.screens.dialog.DialogScreen"; + private static final String CONTAINER_SCREEN = "com.viaversion.viafabricplus.bedrock.screen.container.BedrockNativeContainerScreen"; + private static final String PLAYER_PRESENTATION = "com.viaversion.viafabricplus.bedrock.screen.container.NativePlayerPresentation"; private static final int MAX_NODES = 8192; private static final int MAX_ROWS = 512; private static final int MAX_SCREENS = 32; @@ -88,6 +90,26 @@ static Map observe(Object screen) throws ReflectiveOperationExce report.put("pointer", List.of(finite(field(screen, "pointerX")), finite(field(screen, "pointerY")))); Object root = field(screen, "root"); if (root != null) report.putAll(project(root)); + } else if (screenClass.equals(CONTAINER_SCREEN)) { + Object presentation = field(screen, "presentation"); + if (presentation.getClass().getName().equals(PLAYER_PRESENTATION)) { + report.put("nativeInventory", true); + report.put("presentation", "authored-player-inventory"); + try { + Object message = field(presentation, "message"); + Map input = new LinkedHashMap<>(); + input.put("inventoryRequestSequence", field(presentation, "sequence")); + input.put("inventoryActionPending", field(presentation, "pending") != null); + input.put("inventoryBundlePending", field(presentation, "pendingBundle") != null); + input.put("inventoryOpening", call(message, "opening")); + input.put("inventoryGeneration", call(message, "generation")); + input.put("inventoryRevision", call(message, "revision")); + report.putAll(input); + report.put("inventoryInputAudit", "available"); + } catch (NoSuchFieldException | NoSuchMethodException unsupported) { + report.put("inventoryInputAudit", "unavailable"); + } + } } else if (screenClass.equals(FALLBACK_SCREEN)) { report.put("presentation", "addon-fallback-form"); report.put("formId", field(screen, "formId"));