diff --git a/docs/native-player-inventory.md b/docs/native-player-inventory.md index 07d770e4..ef9e231e 100644 --- a/docs/native-player-inventory.md +++ b/docs/native-player-inventory.md @@ -126,6 +126,12 @@ An inactive editor must stay inactive after opening or resizing. Native 1.26.60.5 classic-screen controls distinguish recipe selection from crafting. A normal recipe click fills the grid. Taking its output sends a separate craft request. +Selecting an unavailable recipe shows ingredient and output previews without moving items or sending an inventory request. +The previews cover empty crafting slots. Real items and active drag previews take precedence. +Search edits preserve the previews. Closing and reopening clears them. +Candidate selection uses current ingredient allocations and the native priority order. +Tagged ingredient previews prefer a wood family from the hotbar and inventory, with oak as the default. +Exact fallback variant order and complete native pixels remain unverified. A workbench recipe Shift-click uses `CraftRecipeAuto` with explicit ingredient descriptors, followed by results, consumption, and placement actions. Core plans this request for shaped and shapeless recipes from their server-supplied results. The planner uses the first eligible ingredient allocation. 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 e170de55..f66d491a 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 0f7f45ee295492060deca48056bb21f95cd73106 Mon Sep 17 00:00:00 2001 +From 87780334745a8b05155dab9657d066e253946407 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 @@ -369,35 +369,88 @@ calling the native counter. Its admission requires a nonempty ingredient. A placeholder must not abort the candidate loop and suppress the inventory result. The regression ignores a placeholder, plans the following valid recipe, and retains the untouched source inventory. + +Retain an opening-scoped crafting preview independently of source stacks +and search selection. Official Bedrock 1.26.60.5/BDS 1.26.60.6 controls on +protocol2225 show unavailable Oak Planks and Stick ingredients/output +without inventory transactions or embedded/standalone stack requests. +The preview survives search edits and blank-slot focus, then clears on +reopening. A craftable control sends one accepted stack request. + +Rank admitted recipes by their first ingredient-count round, preferring +the greatest minimum count and then partial ingredient coverage when no +candidate is craftable. Preserve native priority order for ties. Resolve +preview variants from source-family preferences: logs, planks, wooden +slabs and bamboo blocks in the combined player inventory contribute +prefixes before the first underscore; choose the first native string-tree +entry, or the original oak default. Concrete preview construction keeps +Block selection separate from ordinary Item construction. Original native +ranking instructions, variant callbacks and default initialization support +these rules with explicit source/constructor boundaries. Native fallback +member traversal depends on runtime Item pointers; the port retains its +connection tag-index traversal, without claiming identical fallback pixels. + +Transport crafting previews separately from recipe-cell items. Version2 +adds bounded crafting-slot snapshots; readers also accept version1, and +empty-preview publications retain version1 framing. A disabled primary +selection can update only this preview. It cannot authorize a placement +request or change actual inventory slots, stack IDs or the cursor. Retire +previews on opening/catalog replacement and keep them through query edits. +Boundary regressions cover owned wire snapshots, stale commands, no source +mutation/request, source-family choice and reopening. + +Select ghost candidates independently of recipe unlocks. The original +1.26.60.5 candidate builder143a51800 enumerates output and station matches +through143694600/143694360, and callback143a85280 appends each match without +consulting the player unlock collection. Search exposes locked recipes. +Keep availability and request admission separate from this read-only +selection. An installed fresh Survival player with recipesUnlock=true and +no unlocked oak-planks recipes exposed the previous extra filter. + +The boundary regression searches a locked recipe with an empty inventory, +publishes its ghosts, and verifies that neither a request ID nor an item +request is allocated. It also retains search/reopening and replay guards. + +Keep optional crafting previews out of generation-zero publications. Pass +the exact generation captured by publish into snapshot construction so a +retiring or pending opening cannot invalidate the complete message. Skip +unrepresentable preview slots and omit a preview whose output is missing. +Retain recipe rows and search while unsupported display items remain absent. + +Ignore placeholder candidates without any nonempty ingredient before +counting. Tests preserve later candidates and the no-selection result, +and cover generation-zero snapshots, unmapped preview inputs/output, +search persistence, reopening and no request allocation. --- .../model/NativePlayerInventoryAction.java | 94 +++ - .../model/NativePlayerInventoryMessage.java | 271 ++++++++ + .../model/NativePlayerInventoryMessage.java | 334 +++++++++ .../model/NativePlayerInventoryResult.java | 47 ++ - .../model/container/NativeRecipeCounter.java | 74 ++ + .../model/container/NativeRecipeCounter.java | 119 +++ .../model/container/NativeRecipeCrafting.java | 286 ++++++++ - .../container/NativeRecipeDescriptor.java | 10 + + .../container/NativeRecipeDescriptor.java | 44 ++ .../block/CraftingTableContainer.java | 2 +- - .../resourcepack/ui/PlayerInventoryUi.java | 397 +++++++++++ + .../resourcepack/ui/PlayerInventoryUi.java | 397 ++++++++++ .../viabedrock/protocol/BedrockProtocol.java | 1 + .../protocol/model/CreativeCatalog.java | 266 +++++++ - .../model/CreativeInventoryModel.java | 198 ++++++ + .../model/CreativeInventoryModel.java | 198 +++++ .../protocol/model/PlayerRecipeContext.java | 96 +++ .../protocol/model/PlayerRecipeModel.java | 125 ++++ .../protocol/packet/InventoryPackets.java | 213 ++++-- .../protocol/packet/MultiStatePackets.java | 22 + - .../protocol/rewriter/ItemRewriter.java | 62 +- - .../protocol/storage/CraftingDataTracker.java | 134 +++- + .../protocol/rewriter/ItemRewriter.java | 74 +- + .../protocol/storage/CraftingDataTracker.java | 217 +++++- .../storage/InventoryRequestTracker.java | 24 + .../protocol/storage/InventoryTracker.java | 21 + - .../storage/NativePlayerRecipeState.java | 654 ++++++++++++++++++ + .../storage/NativePlayerRecipeState.java | 675 ++++++++++++++++++ .../storage/PlayerInventoryScreenTracker.java | 40 +- - .../NativePlayerInventoryMessageTest.java | 179 +++++ - .../container/NativeRecipeCounterTest.java | 27 + + .../NativePlayerInventoryMessageTest.java | 197 +++++ + .../container/NativeRecipeCounterTest.java | 84 +++ + .../container/NativeRecipeDescriptorTest.java | 24 + .../container/NativeRecipeQuantityTest.java | 61 ++ .../ui/PlayerInventoryUiTest.java | 126 ++++ .../protocol/model/CreativeCatalogTest.java | 382 ++++++++++ .../protocol/packet/InventoryPacketsTest.java | 266 +++++-- - .../rewriter/PlayerRecipeModelTest.java | 517 ++++++++++++++ + .../rewriter/PlayerRecipeModelTest.java | 611 ++++++++++++++++ ...egisteredPlayerRecipeAvailabilityTest.java | 5 +- .../RegisteredPlayerRecipeSearchTest.java | 3 +- .../storage/CreativeInventoryActionTest.java | 262 +++++++ @@ -405,7 +458,7 @@ recipe, and retains the untouched source inventory. .../NativePlayerCreativeAuxiliaryTest.java | 333 +++++++++ .../storage/NativePlayerCreativePickTest.java | 138 ++++ .../storage/NativeRecipeCraftingTest.java | 500 +++++++++++++ - 35 files changed, 5748 insertions(+), 171 deletions(-) + 36 files changed, 6201 insertions(+), 169 deletions(-) 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 @@ -528,10 +581,10 @@ index 00000000..b3a09f75 +} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/NativePlayerInventoryMessage.java b/src/main/java/net/raphimc/viabedrock/api/model/NativePlayerInventoryMessage.java new file mode 100644 -index 00000000..842721ba +index 00000000..68fdb32c --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/model/NativePlayerInventoryMessage.java -@@ -0,0 +1,271 @@ +@@ -0,0 +1,334 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -566,7 +619,8 @@ index 00000000..842721ba + +/** Complete player presentations are compressed within Java's custom-payload limit. */ +public record NativePlayerInventoryMessage(int menuId, long opening, long generation, long revision, -+ String program, String snapshot, Map items) { ++ String program, String snapshot, Map items, ++ Map craftingPreview) { + + public static final String CHANNEL = "viabedrock:native_player_inventory"; + public static final int MAX_PAYLOAD_BYTES = 1 << 20; @@ -577,7 +631,11 @@ index 00000000..842721ba + return new Type<>(NativePlayerInventoryMessage.class) { + @Override + public NativePlayerInventoryMessage read(final ByteBuf buffer) { -+ if (buffer.readableBytes() > MAX_PAYLOAD_BYTES || Types.VAR_INT.readPrimitive(buffer) != 1 ++ if (buffer.readableBytes() > MAX_PAYLOAD_BYTES) { ++ throw new IllegalArgumentException("Unsupported player inventory message"); ++ } ++ final int version = Types.VAR_INT.readPrimitive(buffer); ++ if ((version != 1 && version != 2) + || Types.VAR_INT.readPrimitive(buffer) != ProtocolConstants.BEDROCK_PROTOCOL_VERSION) { + throw new IllegalArgumentException("Unsupported player inventory message"); + } @@ -626,10 +684,30 @@ index 00000000..842721ba + } + items.put(id, item); + } ++ final Map craftingPreview = new HashMap<>(); ++ if (version == 2) { ++ final int previewCount = Types.VAR_INT.readPrimitive(decoded); ++ if (previewCount < 0 || previewCount > 10) { ++ throw new IllegalArgumentException("Invalid player crafting preview count"); ++ } ++ for (int index = 0; index < previewCount; index++) { ++ final int slot = Types.VAR_INT.readPrimitive(decoded); ++ final int size = Types.VAR_INT.readPrimitive(decoded); ++ if (slot < 0 || slot > 9 || craftingPreview.containsKey(slot) || size <= 0 || size > decoded.readableBytes()) { ++ throw new IllegalArgumentException("Invalid player crafting preview boundary"); ++ } ++ final ByteBuf itemBytes = decoded.readSlice(size); ++ final Item item = itemType.get().read(itemBytes); ++ if (itemBytes.isReadable()) { ++ throw new IllegalArgumentException("Trailing player crafting preview data"); ++ } ++ craftingPreview.put(slot, item); ++ } ++ } + if (decoded.isReadable()) { + throw new IllegalArgumentException("Trailing player inventory state"); + } -+ return new NativePlayerInventoryMessage(menu, opening, generation, revision, program, state, items); ++ return new NativePlayerInventoryMessage(menu, opening, generation, revision, program, state, items, craftingPreview); + } catch (final DataFormatException malformed) { + throw new IllegalArgumentException("Invalid player inventory compression", malformed); + } finally { @@ -662,7 +740,21 @@ index 00000000..842721ba + preview.release(); + } + } -+ Types.VAR_INT.writePrimitive(encoded, 1); ++ if (!message.craftingPreview.isEmpty()) { ++ Types.VAR_INT.writePrimitive(state, message.craftingPreview.size()); ++ for (final int slot : message.craftingPreview.keySet().stream().sorted().toList()) { ++ Types.VAR_INT.writePrimitive(state, slot); ++ final ByteBuf preview = Unpooled.buffer(256, MAX_STATE_BYTES); ++ try { ++ itemType.get().write(preview, message.craftingPreview.get(slot)); ++ Types.VAR_INT.writePrimitive(state, preview.readableBytes()); ++ state.writeBytes(preview); ++ } finally { ++ preview.release(); ++ } ++ } ++ } ++ Types.VAR_INT.writePrimitive(encoded, message.craftingPreview.isEmpty() ? 1 : 2); + Types.VAR_INT.writePrimitive(encoded, ProtocolConstants.BEDROCK_PROTOCOL_VERSION); + Types.VAR_INT.writePrimitive(encoded, state.readableBytes()); + final byte[] raw = new byte[state.readableBytes()]; @@ -689,9 +781,14 @@ index 00000000..842721ba + }; + } + ++ public NativePlayerInventoryMessage(final int menuId, final long opening, final long generation, final long revision, ++ final String program, final String snapshot, final Map items) { ++ this(menuId, opening, generation, revision, program, snapshot, items, Map.of()); ++ } ++ + public NativePlayerInventoryMessage { + if (menuId < 0 || menuId > 255 || opening < 0 || generation < 0 || revision <= 0 -+ || opening == 0 && generation == 0 || program == null || snapshot == null || items == null) { ++ || opening == 0 && generation == 0 || program == null || snapshot == null || items == null || craftingPreview == null) { + throw new IllegalArgumentException("Invalid player inventory identity"); + } + if ((long) program.getBytes(StandardCharsets.UTF_8).length + snapshot.getBytes(StandardCharsets.UTF_8).length > MAX_STATE_BYTES) { @@ -715,6 +812,18 @@ index 00000000..842721ba + owned.put(id, item.copy()); + }); + items = Map.copyOf(owned); ++ final Map ghosts = new HashMap<>(); ++ craftingPreview.forEach((slot, item) -> { ++ if (slot == null || slot < 0 || slot > (state.workbench() ? 9 : 4) || state.creative() || generation == 0 ++ || item == null || item.isEmpty() || item.amount() <= 0 || item.amount() > 255) { ++ throw new IllegalArgumentException("Invalid player crafting preview"); ++ } ++ ghosts.put(slot, item.copy()); ++ }); ++ if (!ghosts.isEmpty() && !ghosts.containsKey(0)) { ++ throw new IllegalArgumentException("Missing player crafting preview output"); ++ } ++ craftingPreview = Map.copyOf(ghosts); + } + + public PlayerInventoryUi.State state() { @@ -728,6 +837,13 @@ index 00000000..842721ba + return Map.copyOf(copy); + } + ++ @Override ++ public Map craftingPreview() { ++ final Map copy = new HashMap<>(); ++ this.craftingPreview.forEach((slot, item) -> copy.put(slot, item.copy())); ++ return Map.copyOf(copy); ++ } ++ + private static PlayerInventoryUi.State decode(final String snapshot) { + final JsonObject value = JsonParser.parseString(snapshot).getAsJsonObject(); + fields(value, Set.of("workbench", "creative", "filtering"), Set.of("inventoryLayout", "craftingLayout", "leftTab", "rightTab", "selectedIndex"), @@ -857,13 +973,58 @@ index 00000000..e69bf410 + +} 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..3ccb1532 100644 +index fe874172..96547dc6 100644 --- a/src/main/java/net/raphimc/viabedrock/api/model/container/NativeRecipeCounter.java +++ b/src/main/java/net/raphimc/viabedrock/api/model/container/NativeRecipeCounter.java -@@ -150,6 +150,76 @@ public final class NativeRecipeCounter { +@@ -150,6 +150,121 @@ public final class NativeRecipeCounter { return partial || selectedOutput.getAsBoolean() ? 2 : 3; } ++ /** Candidate order is already the recipe owner's native priority order. */ ++ public static int select(final List candidates, final boolean workbench, final List sources, ++ final ToIntFunction descriptorHash) { ++ int bestCount = -1; ++ int bestPartial = -1; ++ int counted = -1; ++ int partial = -1; ++ for (int index = 0; index < candidates.size(); index++) { ++ final var candidate = candidates.get(index); ++ if (candidate.requiresCraftingTable && !workbench) { ++ continue; ++ } ++ boolean hasIngredient = false; ++ for (int position = 0; position < candidate.recipe.ingredientCount(); position++) { ++ if (!candidate.recipe.emptyIngredient(position)) { ++ hasIngredient = true; ++ break; ++ } ++ } ++ if (!hasIngredient) { ++ continue; ++ } ++ final var round = count(candidate.recipe, sources, descriptorHash).rounds.getFirst(); ++ int minimum = Integer.MAX_VALUE; ++ int matched = 0; ++ for (int position = 0; position < round.size(); position++) { ++ if (!candidate.recipe.emptyIngredient(position)) { ++ minimum = Math.min(minimum, round.get(position).amount); ++ if (round.get(position).amount > 0) { ++ matched++; ++ } ++ } ++ } ++ if (minimum > bestCount) { ++ bestCount = minimum; ++ counted = index; ++ } ++ if (matched > bestPartial) { ++ bestPartial = matched; ++ partial = index; ++ } ++ } ++ return bestCount > 0 ? counted : partial; ++ } ++ + /** Count outputs across candidates without spending the same source descriptor twice. */ + public static Quantity quantity(final List candidates, + final List sources, final ToIntFunction descriptorHash) { @@ -937,7 +1098,7 @@ index fe874172..3ccb1532 100644 /** Output lookup owns candidate admission, concrete grid requirements and priority. */ public record Candidate(int priority, boolean requiresCraftingTable, Recipe recipe) { public Candidate { -@@ -164,6 +234,10 @@ public final class NativeRecipeCounter { +@@ -164,6 +279,10 @@ public final class NativeRecipeCounter { boolean emptyIngredient(int index); boolean matchesIngredient(int index, BedrockItem item); @@ -1241,13 +1402,45 @@ index 00000000..381dcbb2 + +} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/container/NativeRecipeDescriptor.java b/src/main/java/net/raphimc/viabedrock/api/model/container/NativeRecipeDescriptor.java -index 271d9fad..5aad3b96 100644 +index 271d9fad..ca2d004b 100644 --- a/src/main/java/net/raphimc/viabedrock/api/model/container/NativeRecipeDescriptor.java +++ b/src/main/java/net/raphimc/viabedrock/api/model/container/NativeRecipeDescriptor.java -@@ -64,6 +64,16 @@ public sealed interface NativeRecipeDescriptor permits NativeRecipeDescriptor.Em +@@ -64,6 +64,48 @@ public sealed interface NativeRecipeDescriptor permits NativeRecipeDescriptor.Em boolean isEmpty(Registry registry); ++ /** Preview descriptors never carry a source stack or its network identity. */ ++ default Preview preview(final Registry registry, final String preferred, final int amount) { ++ return switch (this) { ++ case Empty _, Molang _ -> null; ++ case Deferred deferred -> deferred.value(registry).preview(registry, preferred, amount); ++ case Ordinary ordinary -> new Preview(new ItemRecipeDescriptor(ordinary.item, ordinary.auxiliary), (short) amount); ++ case Tag tag -> variant(registry.tagMembers(tag.name), preferred); ++ case ComplexAlias alias -> variant(registry.aliasMembers(alias.name).stream().map(registry::aliasMember) ++ .filter(Objects::nonNull).toList(), preferred); ++ }; ++ } ++ ++ private static Preview variant(final Iterable> members, final String preferred) { ++ NativeItemDefinitions.Definition first = null; ++ for (final var member : members) { ++ if (first == null) { ++ first = member; ++ } ++ if (familyPrefix(member.identifier()).equals(preferred)) { ++ return new Preview(new ItemRecipeDescriptor(member, Short.MAX_VALUE), 1); ++ } ++ } ++ return first == null ? null : new Preview(new ItemRecipeDescriptor(first, Short.MAX_VALUE), 1); ++ } ++ ++ /** The native Item path excludes its namespace and stops at its first underscore. */ ++ static String familyPrefix(final String identifier) { ++ final int start = identifier.indexOf(':') + 1; ++ final int end = identifier.indexOf('_', start); ++ return end < 0 ? "" : identifier.substring(start, end); ++ } ++ + /** Ordinary descriptors retain recipe metadata; predicates choose a concrete source. */ + default ItemRecipeDescriptor craftDescriptor(final ItemRecipeDescriptor source, final Registry registry) { + return switch (this) { @@ -1261,6 +1454,15 @@ index 271d9fad..5aad3b96 100644 /** Match the default concrete descriptor produced from a source stack after native source guards. */ default boolean matchesSource(final BedrockItem source, final Registry registry, final Function descriptor) { +@@ -105,6 +147,8 @@ public sealed interface NativeRecipeDescriptor permits NativeRecipeDescriptor.Em + }; + } + ++ record Preview(ItemRecipeDescriptor descriptor, int amount) { } ++ + /** These are native registry/block interfaces, not a fallback policy for unavailable data. */ + interface Registry { + boolean hasComplexAlias(String name); diff --git a/src/main/java/net/raphimc/viabedrock/api/model/container/block/CraftingTableContainer.java b/src/main/java/net/raphimc/viabedrock/api/model/container/block/CraftingTableContainer.java index cb10d8f5..93ee9a52 100644 --- a/src/main/java/net/raphimc/viabedrock/api/model/container/block/CraftingTableContainer.java @@ -2725,7 +2927,7 @@ index 885579ea..d36de2f5 100644 if (NativeCookingUiState.actionsEnabled(wrapper.user())) { try { diff --git a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemRewriter.java b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemRewriter.java -index 1165eb37..e1489130 100644 +index 1165eb37..27d14c7d 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemRewriter.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemRewriter.java @@ -48,7 +48,6 @@ import net.raphimc.viabedrock.api.model.container.NativeRecipeCounter; @@ -2827,7 +3029,26 @@ index 1165eb37..e1489130 100644 } public BedrockItem[] bedrockItems(final Item[] javaItems) { -@@ -672,11 +679,18 @@ public class ItemRewriter extends StoredObject implements NetworkItemStackContex +@@ -622,6 +629,18 @@ public class ItemRewriter extends StoredObject implements NetworkItemStackContex + (selected, auxiliary) -> this.associatedBlock(blocks, selected, auxiliary)); + } + ++ /** The native preview constructor chooses an actual Block before the ordinary Item branch. */ ++ public BedrockItem createRecipePreview(final ItemRecipeDescriptor descriptor, final int amount) { ++ final var blocks = this.user().get(BlockStateRewriter.class); ++ final var block = this.associatedBlock(blocks, descriptor.definition(), descriptor.metadata()); ++ if (block == null) { ++ return this.createDefinitionItem(descriptor.definition(), descriptor.metadata(), amount); ++ } ++ return block.numericItemId() == null ? BedrockItem.empty() ++ : this.registry.createNetworkItem(block.numericItemId(), (short) 0, (byte) Math.max(0, amount), block, ++ (definition, auxiliary) -> this.associatedBlock(blocks, definition, auxiliary)); ++ } ++ + public BedrockItem createNamedItem(final String name, final int metadata, final int amount) { + final var blocks = this.user().get(BlockStateRewriter.class); + return this.registry.createNamedItem(name, metadata, amount, +@@ -672,11 +691,18 @@ public class ItemRewriter extends StoredObject implements NetworkItemStackContex public int playerRecipeAvailability(final Supplier> candidates, final BedrockItem output, final BedrockItem selectedOutput, final Container owner, final boolean filtering, final boolean includeCursor) { @@ -2849,14 +3070,18 @@ index 1165eb37..e1489130 100644 final ItemRecipeKey key = this.recipeKey(item); return key != null ? key.descriptorHash() : 0; diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/CraftingDataTracker.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/CraftingDataTracker.java -index 1b68e72e..2073bd20 100644 +index 1b68e72e..22d95e55 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/CraftingDataTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/CraftingDataTracker.java -@@ -27,14 +27,15 @@ import net.raphimc.viabedrock.api.util.Fnv1; +@@ -25,16 +25,19 @@ import net.raphimc.viabedrock.api.model.container.NativeRecipeCounter; + import net.raphimc.viabedrock.api.model.container.player.InventoryContainer; + import net.raphimc.viabedrock.api.util.Fnv1; import net.raphimc.viabedrock.api.util.NativeStringMap; ++import net.raphimc.viabedrock.api.util.NativeSort; import net.raphimc.viabedrock.protocol.BedrockProtocol; import net.raphimc.viabedrock.protocol.data.enums.bedrock.AbilitiesIndex; -import net.raphimc.viabedrock.api.model.container.block.CraftingTableContainer; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ContainerEnumName; import net.raphimc.viabedrock.api.resourcepack.ui.NativeRecipeSearch; import net.raphimc.viabedrock.api.io.NbtUtf8; import net.raphimc.viabedrock.protocol.model.BedrockItem; @@ -2869,15 +3094,18 @@ index 1b68e72e..2073bd20 100644 import net.raphimc.viabedrock.protocol.rewriter.BlockStateRewriter; import java.util.ArrayList; -@@ -47,6 +48,7 @@ import java.util.Set; +@@ -47,6 +50,10 @@ import java.util.Set; import java.util.IdentityHashMap; import java.util.Collections; import java.util.Collection; +import java.util.Comparator; ++import java.util.Arrays; ++import java.util.TreeSet; ++import java.util.Objects; import java.util.function.Predicate; import java.util.function.ToIntFunction; import java.util.function.Supplier; -@@ -67,6 +69,10 @@ public final class CraftingDataTracker extends StoredObject { +@@ -67,6 +74,10 @@ public final class CraftingDataTracker extends StoredObject { super(user); } @@ -2888,7 +3116,7 @@ index 1b68e72e..2073bd20 100644 public List getCraftingDataList() { return this.recipes; } -@@ -143,6 +149,11 @@ public final class CraftingDataTracker extends StoredObject { +@@ -143,6 +154,11 @@ public final class CraftingDataTracker extends StoredObject { */ public int playerRecipeAvailability(final BedrockItem output, final BedrockItem selectedOutput, final Container owner, final boolean filtering, final boolean includeCursor) { @@ -2900,7 +3128,7 @@ index 1b68e72e..2073bd20 100644 return this.user().get(ItemRewriter.class).playerRecipeAvailability( () -> this.playerRecipeCandidates(output, owner), output, selectedOutput, owner, filtering, includeCursor); } -@@ -150,8 +161,13 @@ public final class CraftingDataTracker extends StoredObject { +@@ -150,8 +166,13 @@ public final class CraftingDataTracker extends StoredObject { /** Evaluate a player category using current abilities, unlocks and every output candidate's grid requirement. */ public int playerRecipeCategoryAvailability(final BedrockItem output, final BedrockItem selectedOutput, final Container owner, final boolean filtering, final boolean includeCursor) { @@ -2915,7 +3143,7 @@ index 1b68e72e..2073bd20 100644 return 3; } if (!filtering && !this.playerRecipeCandidatesFit(output, owner)) { -@@ -175,9 +191,9 @@ public final class CraftingDataTracker extends StoredObject { +@@ -175,9 +196,9 @@ public final class CraftingDataTracker extends StoredObject { return this.user().get(EntityTracker.class).getClientPlayer().abilities().getBooleanValue(AbilitiesIndex.Instabuild); } @@ -2928,7 +3156,7 @@ index 1b68e72e..2073bd20 100644 } private boolean requiresCraftingTable(final CraftingDataStorage data) { -@@ -185,28 +201,76 @@ public final class CraftingDataTracker extends StoredObject { +@@ -185,28 +206,142 @@ public final class CraftingDataTracker extends StoredObject { ? shaped.getWidth() >= 3 || shaped.getHeight() >= 3 : this.recipeIngredients(data).size() >= 5; } @@ -2972,10 +3200,85 @@ index 1b68e72e..2073bd20 100644 + return key == null ? 0 : key.descriptorHash(); + }).outputCount(); + } -+ + +- @Override +- public boolean emptyIngredient(final int index) { +- return positions.get(index).isEmpty(CraftingDataTracker.this.recipeRegistry()); +- } + private List playerRecipeCandidates(final BedrockItem output, final PlayerRecipeContext owner) { + return this.outputCandidates(output, owner.recipeTags(this)).stream().map(this::playerRecipeCandidate).toList(); + } + +- @Override +- public boolean matchesIngredient(final int index, final BedrockItem item) { +- return CraftingDataTracker.this.matchesRetainedIngredient(positions.get(index), item); ++ /** Select and snapshot a recipe without moving any source or granting it a request identity. */ ++ public RecipePreview playerRecipePreview(final BedrockItem output, final PlayerRecipeContext owner) { ++ owner.requireCurrent(); ++ final var candidates = NativeSort.sorted(this.outputCandidates(output, owner.recipeTags(this)).stream() ++ .filter(data -> data.recipe() instanceof ShapedRecipe || data.recipe() instanceof ShapelessRecipe) ++ .toList(), Comparator.comparingInt(data -> data.recipe().getPriority())); ++ final var items = this.user().get(ItemRewriter.class); ++ final var sources = owner.sources(true); ++ final int selected = NativeRecipeCounter.select(candidates.stream().map(this::playerRecipeCandidate).toList(), ++ owner.workbench(), sources, item -> { ++ final var key = items.recipeKey(item); ++ return key == null ? 0 : key.descriptorHash(); ++ }); ++ if (selected < 0) { ++ return null; ++ } ++ final var data = candidates.get(selected); ++ final var descriptors = this.recipeIngredients(data); ++ final List ingredients = data.recipe() instanceof ShapedRecipe shaped ++ ? shaped.getIngredients() : ((ShapelessRecipe) data.recipe()).getIngredients(); ++ final int size = owner.workbench() ? 3 : 2; ++ final int width = data.recipe() instanceof ShapedRecipe shaped ? shaped.getWidth() : size; ++ final String preferred = this.preferredRecipeFamily(sources); ++ final var registry = this.recipeRegistry(); ++ final Map slots = new HashMap<>(); ++ slots.put(0, Objects.requireNonNull(firstOutput(data.recipe())).copy()); ++ for (int index = 0; index < descriptors.size(); index++) { ++ final var descriptor = descriptors.get(index); ++ if (descriptor.isEmpty(registry)) { ++ continue; ++ } ++ final var preview = descriptor.preview(registry, preferred, ingredients.get(index).amount()); ++ if (preview == null) { ++ continue; ++ } ++ final var stack = items.createRecipePreview(preview.descriptor(), preview.amount()); ++ if (!stack.isEmpty()) { ++ slots.put(1 + index / width * size + index % width, stack); ++ } ++ } ++ return new RecipePreview(data, slots); ++ } ++ ++ private String preferredRecipeFamily(final List sources) { ++ final var items = this.user().get(ItemRewriter.class).getItemRegistry(); ++ final Set tags = Set.of("minecraft:logs", "minecraft:planks", "minecraft:wooden_slabs"); ++ final var prefixes = new TreeSet((first, second) -> Arrays.compareUnsigned(NbtUtf8.encode(first), NbtUtf8.encode(second))); ++ for (final var source : sources) { ++ if (source.collection() != ContainerEnumName.InventoryContainer && source.collection() != ContainerEnumName.HotbarContainer) { ++ continue; ++ } ++ for (final var stack : source.items()) { ++ final var definition = items.definition(stack); ++ if (stack.isEmpty() || definition == null) { ++ continue; + } +- })); ++ final String prefix = NativeRecipeDescriptor.familyPrefix(definition.identifier()); ++ if (!prefix.isEmpty() && ("minecraft:bamboo_block".equals(items.associatedBlock(definition)) ++ || items.tags(definition).stream().anyMatch(tags::contains))) { ++ prefixes.add(prefix); ++ } ++ } + } +- return List.copyOf(candidates); ++ return prefixes.isEmpty() ? "oak" : prefixes.first(); ++ } + + public NativeRecipeCounter.Candidate playerRecipeCandidate(final CraftingDataStorage data) { + final List positions = this.recipeIngredients(data); @@ -2990,23 +3293,12 @@ index 1b68e72e..2073bd20 100644 + public int ingredientCount() { + return positions.size(); + } - -- @Override -- public boolean emptyIngredient(final int index) { -- return positions.get(index).isEmpty(CraftingDataTracker.this.recipeRegistry()); -- } ++ + @Override + public boolean emptyIngredient(final int index) { + return positions.get(index).isEmpty(CraftingDataTracker.this.recipeRegistry()); + } - -- @Override -- public boolean matchesIngredient(final int index, final BedrockItem item) { -- return CraftingDataTracker.this.matchesRetainedIngredient(positions.get(index), item); -- } -- })); -- } -- return List.copyOf(candidates); ++ + @Override + public boolean matchesIngredient(final int index, final BedrockItem item) { + return CraftingDataTracker.this.matchesRetainedIngredient(positions.get(index), item); @@ -3025,7 +3317,7 @@ index 1b68e72e..2073bd20 100644 } private static BedrockItem firstOutput(final Recipe recipe) { -@@ -864,6 +928,11 @@ public final class CraftingDataTracker extends StoredObject { +@@ -864,6 +999,11 @@ public final class CraftingDataTracker extends StoredObject { /** Bind both real item producers; the search preserves their native short-circuit order. */ public int availability(final BedrockItem output, final BedrockItem selectedOutput, final Container owner, final boolean filtering, final boolean includeCursor) { @@ -3037,7 +3329,7 @@ index 1b68e72e..2073bd20 100644 return this.availability(output, selectedOutput, owner, filtering, includeCursor, () -> NbtUtf8.encode(CraftingDataTracker.this.user().get(ItemRewriter.class).itemSecondaryDescription(output))); } -@@ -871,6 +940,11 @@ public final class CraftingDataTracker extends StoredObject { +@@ -871,6 +1011,11 @@ public final class CraftingDataTracker extends StoredObject { /** Bind the connection's selected-definition name while leaving secondary description production lazy. */ public int availability(final BedrockItem output, final BedrockItem selectedOutput, final Container owner, final boolean filtering, final boolean includeCursor, final Supplier secondaryDescription) { @@ -3049,7 +3341,7 @@ index 1b68e72e..2073bd20 100644 return this.availability(output, selectedOutput, owner, filtering, includeCursor, () -> NbtUtf8.encode(CraftingDataTracker.this.user().get(ItemRewriter.class).itemPrimaryName(output)), secondaryDescription); } -@@ -879,6 +953,12 @@ public final class CraftingDataTracker extends StoredObject { +@@ -879,6 +1024,12 @@ public final class CraftingDataTracker extends StoredObject { public int availability(final BedrockItem output, final BedrockItem selectedOutput, final Container owner, final boolean filtering, final boolean includeCursor, final Supplier primaryName, final Supplier secondaryDescription) { @@ -3062,7 +3354,7 @@ index 1b68e72e..2073bd20 100644 if (!this.instabuild && !this.unlocked(output, owner)) { final var rules = CraftingDataTracker.this.user().get(GameRulesStorage.class); if (rules != null && Boolean.TRUE.equals(rules.getGameRule("recipesUnlock")) -@@ -906,8 +986,8 @@ public final class CraftingDataTracker extends StoredObject { +@@ -906,8 +1057,8 @@ public final class CraftingDataTracker extends StoredObject { return searchState == 0 || countState == 0 ? 0 : 1; } @@ -3073,6 +3365,27 @@ index 1b68e72e..2073bd20 100644 } } +@@ -920,4 +1071,20 @@ public final class CraftingDataTracker extends StoredObject { + + public record RecipeMatch(CraftingDataStorage data, int[] amounts) { } + ++ public record RecipePreview(CraftingDataStorage recipe, Map slots) { ++ public RecipePreview { ++ Objects.requireNonNull(recipe); ++ final Map owned = new HashMap<>(); ++ slots.forEach((slot, item) -> owned.put(slot, item.copy())); ++ slots = Map.copyOf(owned); ++ } ++ ++ @Override ++ public Map slots() { ++ final Map copy = new HashMap<>(); ++ this.slots.forEach((slot, item) -> copy.put(slot, item.copy())); ++ return Map.copyOf(copy); ++ } ++ } ++ + } diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryRequestTracker.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryRequestTracker.java index 3dcb9b50..cb23fe56 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryRequestTracker.java @@ -3170,10 +3483,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..cde67af9 +index 00000000..e31190d4 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/NativePlayerRecipeState.java -@@ -0,0 +1,654 @@ +@@ -0,0 +1,675 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -3255,6 +3568,7 @@ index 00000000..cde67af9 + private boolean presentationDirty = true; + private boolean actionsAdvertised; + private long lastRequest; ++ private CraftingDataTracker.RecipePreview craftingPreview; + + public NativePlayerRecipeState(final UserConnection user) { + super(user); @@ -3318,6 +3632,7 @@ index 00000000..cde67af9 + this.catalog = items.creativeCatalog(); + this.model.replaceCatalog(items); + this.inputs = null; ++ this.craftingPreview = null; + } + if (preferences.filtering() != this.preferences.filtering()) { + this.model.reinstallPolicies(preferences.filtering()); @@ -3333,6 +3648,10 @@ index 00000000..cde67af9 + rules != null && Boolean.TRUE.equals(rules.getGameRule("doLimitedCrafting")), preferences, + player.gameType(), session == null ? null : session.getLevelGameType()); + if (!Objects.equals(this.inputs, inputs)) { ++ if (this.inputs != null && (this.inputs.recipes != inputs.recipes || this.inputs.gameType != inputs.gameType ++ || this.inputs.levelGameType != inputs.levelGameType)) { ++ this.craftingPreview = null; ++ } + this.model.collections().invalidateAll(); + this.inputs = inputs; + this.presentationDirty = true; @@ -3368,16 +3687,17 @@ index 00000000..cde67af9 + return; + } + try { -+ final var snapshot = this.snapshot(); ++ final var snapshot = this.snapshot(generation); + final int menuId = this.context.workbench() ? Byte.toUnsignedInt(inventory.getCurrentContainer().javaContainerId()) : 0; + final String exported = program == this.publishedProgram && this.publication != null + ? this.publication.program() : new PlayerInventoryUi(program).exportProgram(this.context.workbench()).toString(); + final var message = new NativePlayerInventoryMessage(menuId, this.opening, generation, + this.publication == null ? 1 : Math.incrementExact(this.publication.revision()), exported, -+ GsonUtil.getGson().toJson(snapshot.state), snapshot.items); ++ GsonUtil.getGson().toJson(snapshot.state), snapshot.items, snapshot.craftingPreview); + this.context.requireCurrent(); + 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) { + final var packet = PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, this.user()); + packet.write(Types.STRING, NativePlayerInventoryMessage.CHANNEL); @@ -3442,7 +3762,7 @@ index 00000000..cde67af9 + && this.destroyCreativeHotbar(action.target() - 36); + } + if (action.target() >= state.cells().size() || state.cells().get(action.target()).id() != action.target() -+ || state.cells().get(action.target()).disabled()) { ++ || state.cells().get(action.target()).disabled() && action.kind() != NativePlayerInventoryAction.Kind.PRIMARY) { + return false; + } + final var view = Arrays.stream(CreativeInventoryModel.View.values()).filter(value -> value.leftTab() == state.leftTab()).findFirst().orElse(null); @@ -3455,7 +3775,7 @@ index 00000000..cde67af9 + } + final var row = collection.snapshot().rows().get(action.target()); + if (row instanceof RecipeCollection.Header header) { -+ if (action.kind() != NativePlayerInventoryAction.Kind.PRIMARY) { ++ if (state.cells().get(action.target()).disabled() || action.kind() != NativePlayerInventoryAction.Kind.PRIMARY) { + return false; + } + final boolean changed = collection.toggleHeader(header.group()) != null; @@ -3485,21 +3805,21 @@ index 00000000..cde67af9 + return false; + } + final var recipes = this.user().get(CraftingDataTracker.class); -+ for (final var candidate : NativeSort.sorted(recipes.outputCandidates(entry.item(), this.context.recipeTags(recipes)), -+ Comparator.comparingInt(data -> data.recipe().getPriority()))) { -+ final var rules = this.user().get(GameRulesStorage.class); -+ if (rules != null && Boolean.TRUE.equals(rules.getGameRule("recipesUnlock")) && !recipes.isRecipeUnlocked(candidate)) { -+ continue; -+ } -+ final boolean placed = owner instanceof InventoryContainer player ? player.handleRecipePlacement(candidate, true) -+ : owner instanceof CraftingTableContainer table && table.handleRecipePlacement(candidate, true); -+ if (placed) { -+ PacketFactory.sendJavaContainerSetContent(this.user(), owner); -+ PacketFactory.sendJavaContainerSetContent(this.user(), inventory.getInventoryContainer()); -+ return true; -+ } ++ final var preview = recipes.playerRecipePreview(entry.item(), this.context); ++ if (preview == null) { ++ return false; + } -+ return false; ++ this.craftingPreview = preview; ++ if (state.cells().get(action.target()).disabled()) { ++ return true; ++ } ++ final boolean placed = owner instanceof InventoryContainer player ? player.handleRecipePlacement(preview.recipe(), true) ++ : owner instanceof CraftingTableContainer table && table.handleRecipePlacement(preview.recipe(), true); ++ if (placed) { ++ PacketFactory.sendJavaContainerSetContent(this.user(), owner); ++ PacketFactory.sendJavaContainerSetContent(this.user(), inventory.getInventoryContainer()); ++ } ++ return true; + } + + private boolean canRequest(final NativePlayerInventoryMessage message) { @@ -3709,7 +4029,7 @@ index 00000000..cde67af9 + return true; + } + -+ private Presentation snapshot() { ++ private Presentation snapshot(final long generation) { + final var items = this.user().get(ItemRewriter.class); + final var recipes = this.user().get(CraftingDataTracker.class); + final var resources = this.user().get(ResourcePackStorage.class); @@ -3799,7 +4119,20 @@ index 00000000..cde67af9 + this.preferences.inventoryLayout(), this.preferences.craftingLayout(), + tab, this.preferences.rightTab(), filtering, NbtUtf8.decode(this.model.query()), + key.isEmpty() ? "" : resources.getTexts().translate(key), resources.getTexts().translate(label), counts, cells, "", -1); -+ return new Presentation(state, previews); ++ final Map crafting = new HashMap<>(); ++ if (!creative && generation != 0 && this.craftingPreview != null) { ++ this.craftingPreview.slots().forEach((slot, item) -> { ++ final var translated = items.javaItem(item); ++ if (translated.isEmpty()) { ++ return; ++ } ++ crafting.put(slot, translated); ++ }); ++ if (!crafting.containsKey(0)) { ++ crafting.clear(); ++ } ++ } ++ return new Presentation(state, previews, crafting); + } + + private void clear() { @@ -3809,6 +4142,7 @@ index 00000000..cde67af9 + } + this.opening = 0; + this.lastRequest = 0; ++ this.craftingPreview = null; + this.context = null; + this.model = null; + this.catalog = null; @@ -3820,7 +4154,7 @@ index 00000000..cde67af9 + this.publicationFailed = false; + } + -+ private record Presentation(PlayerInventoryUi.State state, Map items) { } ++ private record Presentation(PlayerInventoryUi.State state, Map items, Map craftingPreview) { } + + private record Inputs(List sources, List tags, long recipes, + boolean instabuild, boolean unlocks, boolean limitedCrafting, InventoryOptions preferences, GameType gameType, GameType levelGameType) { } @@ -3942,10 +4276,10 @@ index 9f2ccf71..2db2d17d 100644 } 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..ab5e2214 +index 00000000..819952fc --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/api/model/NativePlayerInventoryMessageTest.java -@@ -0,0 +1,179 @@ +@@ -0,0 +1,197 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -3963,6 +4297,7 @@ index 00000000..ab5e2214 +import com.viaversion.viaversion.api.type.types.item.ItemType1_20_5; +import com.viaversion.viaversion.api.type.types.item.StructuredDataType; +import com.viaversion.viaversion.api.minecraft.item.StructuredItem; ++import com.viaversion.viaversion.api.minecraft.item.Item; +import com.viaversion.viaversion.api.type.Types; +import com.viaversion.viaversion.libs.gson.JsonParser; +import com.viaversion.viaversion.util.GsonUtil; @@ -4049,15 +4384,25 @@ index 00000000..ab5e2214 + final var previous = source.state(); + final var state = new PlayerInventoryUi.State(false, false, 2, 0, 5, 2, true, "oak", "Recipes", "Crafting", + previous.categoryCounts(), List.of(new PlayerInventoryUi.Cell(0, -1, -1, 0, 1, 44, false, false, "", Map.of())), "", -1); -+ final var message = new NativePlayerInventoryMessage(0, source.opening(), 0, 1, source.program(), GsonUtil.getGson().toJson(state), Map.of(0, stack)); ++ final var output = stack.copy(); ++ output.setAmount(4); ++ final var ingredient = stack.copy(); ++ ingredient.setAmount(1); ++ final var message = new NativePlayerInventoryMessage(0, source.opening(), 2, 1, source.program(), GsonUtil.getGson().toJson(state), ++ Map.of(0, stack), Map.of(0, output, 1, ingredient)); + tag.putInt("variant", 29); + message.items().get(0).setAmount(1); ++ output.setAmount(99); ++ message.craftingPreview().get(0).setAmount(99); + final var wire = Unpooled.buffer(); + try { + codec.write(wire, message); + final var decoded = codec.read(wire); + assertEquals(255, decoded.items().get(0).amount()); + assertEquals(17, decoded.items().get(0).dataContainer().get(StructuredDataKey.CUSTOM_DATA).getInt("variant")); ++ assertEquals(4, decoded.craftingPreview().get(0).amount()); ++ assertEquals(1, decoded.craftingPreview().get(1).amount()); ++ assertEquals(17, decoded.craftingPreview().get(0).dataContainer().get(StructuredDataKey.CUSTOM_DATA).getInt("variant")); + assertFalse(wire.isReadable()); + } finally { + wire.release(); @@ -4122,14 +4467,85 @@ index 00000000..ab5e2214 + value.program(), value.snapshot(), Map.of())); + assertThrows(IllegalArgumentException.class, () -> new NativePlayerInventoryMessage(0, 1, 1, 1, + value.program(), value.snapshot(), Map.of(0, StructuredItem.empty()))); ++ final var preview = new StructuredItem(1, 4, new StructuredDataContainer()); ++ for (final var slots : List.of(Map.of(0, preview, 5, preview), Map.of(1, preview))) { ++ assertThrows(IllegalArgumentException.class, () -> new NativePlayerInventoryMessage(0, 1, 1, 1, ++ value.program(), value.snapshot(), Map.of(), slots)); ++ } ++ assertThrows(IllegalArgumentException.class, () -> new NativePlayerInventoryMessage(0, 1, 0, 1, ++ value.program(), value.snapshot(), Map.of(), Map.of(0, preview))); + } + +} diff --git a/src/test/java/net/raphimc/viabedrock/api/model/container/NativeRecipeCounterTest.java b/src/test/java/net/raphimc/viabedrock/api/model/container/NativeRecipeCounterTest.java -index 56088325..bd687eb2 100644 +index 56088325..f655dd19 100644 --- a/src/test/java/net/raphimc/viabedrock/api/model/container/NativeRecipeCounterTest.java +++ b/src/test/java/net/raphimc/viabedrock/api/model/container/NativeRecipeCounterTest.java -@@ -44,6 +44,33 @@ class NativeRecipeCounterTest { +@@ -16,6 +16,63 @@ import static org.junit.jupiter.api.Assertions.assertThrows; + + class NativeRecipeCounterTest { + ++ @Test ++ void selectionPrefersMoreAvailableCraftsThenPartialIngredientsAndKeepsTiesInOwnerOrder() { ++ final var sources = List.of(new NativeRecipeCounter.Source(ContainerEnumName.HotbarContainer, ++ List.of(new BedrockItem(1, (short) 0, (byte) 6)))); ++ final var twoPositions = new NativeRecipeCounter.Candidate(0, false, recipe(2, false)); ++ final var onePosition = new NativeRecipeCounter.Candidate(0, false, recipe(1, false)); ++ assertEquals(1, NativeRecipeCounter.select(List.of(twoPositions, onePosition), false, sources, BedrockItem::identifier)); ++ assertEquals(0, NativeRecipeCounter.select(List.of(onePosition, onePosition), false, sources, BedrockItem::identifier)); ++ final var unavailable = new NativeRecipeCounter.Candidate(0, false, new NativeRecipeCounter.Recipe() { ++ @Override ++ public int ingredientCount() { ++ return 2; ++ } ++ ++ @Override ++ public boolean emptyIngredient(final int index) { ++ return false; ++ } ++ ++ @Override ++ public boolean matchesIngredient(final int index, final BedrockItem item) { ++ return index == 0; ++ } ++ ++ }); ++ final var none = new NativeRecipeCounter.Candidate(0, false, new NativeRecipeCounter.Recipe() { ++ @Override ++ public int ingredientCount() { ++ return 1; ++ } ++ ++ @Override ++ public boolean emptyIngredient(final int index) { ++ return false; ++ } ++ ++ @Override ++ public boolean matchesIngredient(final int index, final BedrockItem item) { ++ return false; ++ } ++ ++ }); ++ assertEquals(1, NativeRecipeCounter.select(List.of(none, unavailable), false, sources, BedrockItem::identifier)); ++ final var large = new NativeRecipeCounter.Candidate(0, true, recipe(1, false)); ++ assertEquals(0, NativeRecipeCounter.select(List.of(unavailable, large), false, sources, BedrockItem::identifier)); ++ assertEquals(1, NativeRecipeCounter.select(List.of(unavailable, large), true, sources, BedrockItem::identifier)); ++ assertEquals(6, sources.getFirst().items().getFirst().amount()); ++ } ++ ++ @Test ++ void selectionSkipsPlaceholderRecipesWithoutNonemptyIngredients() { ++ final var placeholder = new NativeRecipeCounter.Candidate(0, false, recipe(2, true)); ++ final var valid = new NativeRecipeCounter.Candidate(0, false, recipe(1, false)); ++ assertEquals(1, NativeRecipeCounter.select(List.of(placeholder, valid), false, List.of(), BedrockItem::identifier)); ++ assertEquals(-1, NativeRecipeCounter.select(List.of(placeholder), false, List.of(), BedrockItem::identifier)); ++ } ++ + @Test + void sharesOneDescriptorAcrossIngredientsAndRetainsTheFinalPartialRound() { + final var recipe = recipe(3, false); +@@ -44,6 +101,33 @@ class NativeRecipeCounterTest { assertThrows(IllegalArgumentException.class, () -> NativeRecipeCounter.count(recipe(2, true), List.of(), ignored -> 0)); } @@ -4163,6 +4579,48 @@ index 56088325..bd687eb2 100644 private static NativeRecipeCounter.Recipe recipe(final int count, final boolean empty) { return new NativeRecipeCounter.Recipe() { @Override +diff --git a/src/test/java/net/raphimc/viabedrock/api/model/container/NativeRecipeDescriptorTest.java b/src/test/java/net/raphimc/viabedrock/api/model/container/NativeRecipeDescriptorTest.java +index 14d72266..ee6954fc 100644 +--- a/src/test/java/net/raphimc/viabedrock/api/model/container/NativeRecipeDescriptorTest.java ++++ b/src/test/java/net/raphimc/viabedrock/api/model/container/NativeRecipeDescriptorTest.java +@@ -32,6 +32,7 @@ import java.util.Set; + import java.util.HashMap; + import java.util.stream.IntStream; + import java.util.Map; ++import java.util.LinkedHashSet; + import java.util.concurrent.atomic.AtomicReference; + import java.util.zip.GZIPInputStream; + +@@ -39,6 +40,29 @@ import static org.junit.jupiter.api.Assertions.*; + + class NativeRecipeDescriptorTest { + ++ @Test ++ void taggedAndAliasedPreviewsPreferTheSourceFamilyWithoutRetainingSourceCounts() { ++ final NativeItemDefinitions.Definition oak = Mockito.mock(NativeItemDefinitions.Definition.class); ++ final NativeItemDefinitions.Definition birch = Mockito.mock(NativeItemDefinitions.Definition.class); ++ Mockito.when(oak.identifier()).thenReturn("minecraft:oak_planks"); ++ Mockito.when(birch.identifier()).thenReturn("minecraft:birch_planks"); ++ final var registry = Mockito.mock(NativeRecipeDescriptor.Registry.class); ++ Mockito.when(registry.tagMembers("test:planks")).thenReturn(new LinkedHashSet<>(List.of(birch, oak))); ++ final var tag = NativeRecipeDescriptor.resolveTag("test:planks", registry); ++ assertSame(oak, tag.preview(registry, "oak", 64).descriptor().definition()); ++ assertEquals(1, tag.preview(registry, "oak", 64).amount()); ++ assertSame(birch, tag.preview(registry, "missing", 64).descriptor().definition()); ++ Mockito.when(registry.aliasMembers("test:alias")).thenReturn(List.of("birch", "oak")); ++ Mockito.doReturn(birch).when(registry).aliasMember("birch"); ++ Mockito.doReturn(oak).when(registry).aliasMember("oak"); ++ final var alias = new NativeRecipeDescriptor.ComplexAlias("test:alias", oak); ++ assertSame(birch, alias.preview(registry, "birch", 64).descriptor().definition()); ++ final var ordinary = new NativeRecipeDescriptor.Ordinary(oak, (short) 7).preview(registry, "birch", 4); ++ assertSame(oak, ordinary.descriptor().definition()); ++ assertEquals(7, ordinary.descriptor().metadata()); ++ assertEquals(4, ordinary.amount()); ++ } ++ + @Test + void admittedTagsKeepMatchingIndependentlyOfLaterIndexEmptiness() { + final var backing = new Registry(new JsonObject()); diff --git a/src/test/java/net/raphimc/viabedrock/api/model/container/NativeRecipeQuantityTest.java b/src/test/java/net/raphimc/viabedrock/api/model/container/NativeRecipeQuantityTest.java new file mode 100644 index 00000000..f6d3290d @@ -5087,10 +5545,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..df62b14f +index 00000000..d0df158e --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/protocol/rewriter/PlayerRecipeModelTest.java -@@ -0,0 +1,517 @@ +@@ -0,0 +1,611 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -5099,16 +5557,21 @@ index 00000000..df62b14f +package net.raphimc.viabedrock.protocol.rewriter; + +import com.viaversion.viaversion.api.minecraft.item.StructuredItem; ++import com.viaversion.viaversion.api.minecraft.item.Item; ++import com.viaversion.viaversion.util.GsonUtil; +import com.viaversion.viaversion.api.minecraft.data.StructuredDataContainer; +import com.viaversion.viaversion.api.data.FullMappings; +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.container.player.InventoryContainer; +import net.raphimc.viabedrock.api.model.container.block.CraftingTableContainer; +import net.raphimc.viabedrock.protocol.model.InventoryOptions; +import net.raphimc.viabedrock.protocol.storage.InventoryOptionsStorage; ++import net.raphimc.viabedrock.protocol.storage.InventoryRequestTracker; +import net.raphimc.viabedrock.protocol.storage.NativePlayerRecipeState; +import net.raphimc.viabedrock.protocol.storage.ResourcePackStorage; +import net.raphimc.viabedrock.api.resourcepack.ui.PlayerInventoryUi; @@ -5132,6 +5595,7 @@ index 00000000..df62b14f +import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.GameType; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; ++import org.mockito.Mockito; + +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; @@ -5140,6 +5604,8 @@ index 00000000..df62b14f +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; ++import java.util.Map; ++import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.zip.GZIPInputStream; @@ -5152,6 +5618,92 @@ index 00000000..df62b14f + private static final int IDENTIFIER = 30001; + + @Test ++ void lockedUnavailableSelectionPublishesOnlyGhostsAndKeepsThemThroughSearchUntilReopening() throws Exception { ++ final var f = installed(false, false); ++ final var user = f.context.items.user(); ++ final var session = mock(GameSessionStorage.class); ++ when(user.get(GameSessionStorage.class)).thenReturn(session); ++ when(session.getLevelGameType()).thenReturn(GameType.Survival); ++ when(user.get(EntityTracker.class).getClientPlayer().gameType()).thenReturn(GameType.Survival); ++ final var resources = mock(ResourcePackStorage.class, RETURNS_DEEP_STUBS); ++ when(user.get(ResourcePackStorage.class)).thenReturn(resources); ++ when(resources.getTexts().translate(anyString())).thenAnswer(call -> call.getArgument(0)); ++ doAnswer(call -> { ++ final var result = javaItem(); ++ result.setAmount(((BedrockItem) call.getArgument(0)).amount()); ++ return result; ++ }).when(f.context.items).javaItem(any(BedrockItem.class)); ++ doAnswer(call -> new BedrockItem(IDENTIFIER + 1, (short) 0, (byte) (int) call.getArgument(1))) ++ .when(f.context.items).createRecipePreview(any(), anyInt()); ++ user.get(CraftingDataTracker.class).updateCraftingDataList(List.of(new CraftingDataStorage(RecipeType.SHAPED, 1, ++ new ShapedRecipe("a", new UUID(0, 1), "crafting_table", 0, 1, 1, ++ List.of(new ItemDescriptor.ItemNameDescriptor("test:ordinary_1", 0, 1)), ++ List.of(new BedrockItem(IDENTIFIER, (short) 0, (byte) 4)), false), false)), true); ++ f.context.inventory.inventory().getInventoryContainer().setItem(9, BedrockItem.empty()); ++ user.get(CraftingDataTracker.class).updateUnlockedRecipes(4, List.of()); ++ f.screens.request(new PlayerInventoryScreenRequest(1, true)); ++ f.acknowledge(); ++ for (int tick = 0; tick < 20; tick++) { ++ f.state.tick(); ++ } ++ f.state.model().updateQuery("stone"); ++ for (int tick = 0; tick < 20; tick++) { ++ f.state.tick(); ++ } ++ final var state = spy(f.state); ++ when(user.get(NativePlayerRecipeState.class)).thenReturn(state); ++ doNothing().when(state).tick(); ++ doReturn(true).when(state).actionsEnabled(); ++ final var requests = mock(InventoryRequestTracker.class); ++ when(user.get(InventoryRequestTracker.class)).thenReturn(requests); ++ when(requests.canStartRecipePlacement()).thenReturn(true); ++ final var ui = new PlayerInventoryUi(new ResourcePackStorage(List.of()).getJsonUiProgram()); ++ final var source = new PlayerInventoryUi.State(false, false, 2, 2, 5, 2, false, "stone", "Recipes", "Crafting", ++ List.of(0, 0, 0, 0), List.of(new PlayerInventoryUi.Cell(0, 1, -1, 0, 2, 0, true, false, "", Map.of())), "", -1); ++ final long generation = f.context.inventory.inventory().currentContainerGeneration(); ++ final var message = new NativePlayerInventoryMessage(0, 1, generation, 1, ui.exportProgram(false).toString(), ++ GsonUtil.getGson().toJson(source), Map.of(0, javaItem())); ++ doReturn(message).when(state).publication(); ++ final var before = f.context.inventory.inventory().playerCraftingSources(true); ++ final var select = new NativePlayerInventoryAction(0, 1, generation, 1, 1, ++ NativePlayerInventoryAction.Kind.PRIMARY, 0, "", null); ++ assertTrue(state.handleAction(select)); ++ assertEquals(Set.of(0, 1), craftingPreview(state, generation).keySet()); ++ assertTrue(craftingPreview(state, 0).isEmpty()); ++ assertEquals(4, craftingPreview(state, generation).get(0).amount()); ++ assertEquals(before, f.context.inventory.inventory().playerCraftingSources(true)); ++ Mockito.verify(requests, never()).addRequest(any()); ++ Mockito.verify(requests, never()).nextRequestId(); ++ assertFalse(state.handleAction(select)); ++ assertTrue(state.handleAction(new NativePlayerInventoryAction(0, 1, generation, 1, 2, ++ NativePlayerInventoryAction.Kind.SEARCH, -1, "no results", null))); ++ assertEquals(Set.of(0, 1), craftingPreview(state, generation).keySet()); ++ doReturn(StructuredItem.empty()).when(f.context.items).javaItem(argThat((BedrockItem item) -> item.identifier() == IDENTIFIER + 1)); ++ assertEquals(Set.of(0), craftingPreview(state, generation).keySet()); ++ doReturn(StructuredItem.empty()).when(f.context.items).javaItem(argThat((BedrockItem item) -> item.identifier() == IDENTIFIER && item.amount() == 4)); ++ assertTrue(craftingPreview(state, generation).isEmpty()); ++ f.screens.request(new PlayerInventoryScreenRequest(1, false)); ++ f.screens.request(new PlayerInventoryScreenRequest(2, true)); ++ doCallRealMethod().when(state).tick(); ++ doReturn(false).when(state).actionsEnabled(); ++ for (int tick = 0; tick < 20; tick++) { ++ state.tick(); ++ } ++ assertTrue(craftingPreview(state, generation).isEmpty()); ++ assertArrayEquals(NbtUtf8.encode("no results"), state.model().query()); ++ } ++ ++ @SuppressWarnings("unchecked") ++ private static Map craftingPreview(final NativePlayerRecipeState state, final long generation) throws Exception { ++ final var snapshot = NativePlayerRecipeState.class.getDeclaredMethod("snapshot", long.class); ++ snapshot.setAccessible(true); ++ final var value = snapshot.invoke(state, generation); ++ final var previews = value.getClass().getDeclaredMethod("craftingPreview"); ++ previews.setAccessible(true); ++ return (Map) previews.invoke(value); ++ } ++ ++ @Test + void outputCountReadsCurrentSourcesAndGameModeIndependentlyOfInstabuild() throws Exception { + final var context = context(false, false); + final var user = context.items.user(); @@ -5195,9 +5747,9 @@ index 00000000..df62b14f + for (int tick = 0; tick < 20; tick++) { + f.state.tick(); + } -+ final var snapshot = NativePlayerRecipeState.class.getDeclaredMethod("snapshot"); ++ final var snapshot = NativePlayerRecipeState.class.getDeclaredMethod("snapshot", long.class); + snapshot.setAccessible(true); -+ final var presentation = snapshot.invoke(f.state); ++ final var presentation = snapshot.invoke(f.state, 0L); + final var state = presentation.getClass().getDeclaredMethod("state"); + state.setAccessible(true); + final var creative = (PlayerInventoryUi.State) state.invoke(presentation); @@ -5207,7 +5759,7 @@ index 00000000..df62b14f + .get("#container_item_background_texture").getAsString()); + when(player.gameType()).thenReturn(GameType.Survival); + f.state.tick(); -+ final var survival = (PlayerInventoryUi.State) state.invoke(snapshot.invoke(f.state)); ++ final var survival = (PlayerInventoryUi.State) state.invoke(snapshot.invoke(f.state, 0L)); + assertTrue(survival.cells().stream().allMatch(PlayerInventoryUi.Cell::disabled)); + assertEquals(creative.cells().getFirst().availability(), survival.cells().getFirst().availability()); + } diff --git a/patches/viafabricplus-bedrock/upstreamable/0010-show-server-creative-items-in-a-bedrock-tab.patch b/patches/viafabricplus-bedrock/upstreamable/0010-show-server-creative-items-in-a-bedrock-tab.patch index 3fddbcd2..deb9c299 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0010-show-server-creative-items-in-a-bedrock-tab.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0010-show-server-creative-items-in-a-bedrock-tab.patch @@ -1,4 +1,4 @@ -From 0c21eb2fadd7dddc75e18b04be262fbde10c9fdb Mon Sep 17 00:00:00 2001 +From 7f2490372e92e4c42dbe21e5e982d49f1e7a4764 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:55:35 +0200 Subject: [PATCH] feat(bedrock): show server creative items in a Bedrock tab @@ -6,8 +6,16 @@ Subject: [PATCH] feat(bedrock): show server creative items in a Bedrock tab Build the Bedrock tab from the connected server catalog and hide unavailable item types in vanilla category tabs. Carry catalog entry identity and converted item model data through the Java creative UI so duplicate fallback items select the intended Bedrock stack. Minecraft keeps the display-items callback protected, so a small package bridge registers the tab without reflection. Retain the exact native catalog entry and complete item snapshot through Java creative tabs. Match recipe-book tab entries by item definition before component parsing changes equality; derive group representatives without inventing variants. Respect the native maximum creative stack and avoid ambiguous same-Java-item transfer fallbacks. Catalog tests cover representative selection, exact identity and variant ambiguity. + +Preserve stack quantities in the shared wire-to-Minecraft conversion. +Apply the one-item Creative tab display policy only to CreativeCatalog.Entry +conversion. Native player and cooking previews use the shared codec and +must retain their published output quantities. The installed 1.26.60.5 +Oak Planks control publishes four in core, while the former generic clamp +rendered one. The codec regression retains the wire count alongside +complete potion components and creative identity without mutating input. --- - .../creative/BedrockCreativeCatalog.java | 82 +++++++++ + .../creative/BedrockCreativeCatalog.java | 84 +++++++++ .../MixinCreativeModeInventoryScreen.java | 26 +++ ...xinCreativeModeTab_ItemDisplayBuilder.java | 74 ++++++++ .../creative/MixinCreativeModeTabs.java | 60 +++++++ @@ -15,7 +23,7 @@ Retain the exact native catalog entry and complete item snapshot through Java cr .../viafabricplus-bedrock/lang/en_us.json | 2 + .../viafabricplus-bedrock.mixins.json | 3 + .../creative/BedrockCreativeCatalogTest.java | 170 ++++++++++++++++++ - 8 files changed, 472 insertions(+) + 8 files changed, 474 insertions(+) create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/creative/BedrockCreativeCatalog.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/creative/MixinCreativeModeInventoryScreen.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/creative/MixinCreativeModeTab_ItemDisplayBuilder.java @@ -25,10 +33,10 @@ Retain the exact native catalog entry and complete item snapshot through Java cr diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/creative/BedrockCreativeCatalog.java b/src/main/java/com/viaversion/viafabricplus/bedrock/creative/BedrockCreativeCatalog.java new file mode 100644 -index 0000000..c50c8b4 +index 0000000..190e86b --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/creative/BedrockCreativeCatalog.java -@@ -0,0 +1,82 @@ +@@ -0,0 +1,84 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/florianreuth/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -43,6 +51,7 @@ index 0000000..c50c8b4 + +import com.viaversion.viafabricplus.ViaFabricPlus; +import com.viaversion.viaversion.api.connection.UserConnection; ++import com.viaversion.viaversion.api.minecraft.item.Item; +import com.viaversion.viaversion.api.type.types.version.VersionedTypes; +import io.netty.buffer.Unpooled; +import net.minecraft.client.Minecraft; @@ -89,10 +98,12 @@ index 0000000..c50c8b4 + if (connection == null) { + return ItemStack.EMPTY; + } -+ return toNative(entry.javaItem(), connection.registryAccess()); ++ final ItemStack result = toNative(entry.javaItem(), connection.registryAccess()); ++ result.setCount(1); ++ return result; + } + -+ public static ItemStack toNative(final com.viaversion.viaversion.api.minecraft.item.Item item, ++ public static ItemStack toNative(final Item item, + final RegistryAccess registries) { + final var buffer = new RegistryFriendlyByteBuf(Unpooled.buffer(), registries); + try { @@ -102,7 +113,6 @@ index 0000000..c50c8b4 + if (buffer.isReadable()) { + throw new IllegalArgumentException("Trailing creative item data"); + } -+ result.setCount(1); + return result; + } catch (final Exception exception) { + throw new IllegalArgumentException("Cannot decode creative item data", exception); @@ -379,7 +389,7 @@ index e10a7c1..8563627 100644 "features.misc.MixinInventoryScreen", diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/creative/BedrockCreativeCatalogTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/creative/BedrockCreativeCatalogTest.java new file mode 100644 -index 0000000..852bf81 +index 0000000..cf0ba2c --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/creative/BedrockCreativeCatalogTest.java @@ -0,0 +1,170 @@ @@ -440,7 +450,7 @@ index 0000000..852bf81 + } + + @Test -+ void convertsCompletePotionAndCreativeIdentityUsingMinecraftWireCodec() throws Exception { ++ void preservesPreviewCountAndCompleteComponentsUsingMinecraftWireCodec() throws Exception { + final var registries = RegistryAccess.fromRegistryOfRegistries(BuiltInRegistries.REGISTRY); + final var custom = new CompoundTag(); + custom.putInt("viabedrock:creative_entry", 72); @@ -456,7 +466,7 @@ index 0000000..852bf81 + final var result = BedrockCreativeCatalog.toNative(input, + registries); + assertSame(Items.LINGERING_POTION, result.getItem()); -+ assertEquals(1, result.getCount()); ++ assertEquals(64, result.getCount()); + assertEquals(1, result.getMaxStackSize()); + assertEquals(0x23AABC, result.get(DataComponents.POTION_CONTENTS).customColor().orElseThrow()); + assertEquals("test", result.get(DataComponents.POTION_CONTENTS).customName().orElseThrow()); 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 a18759e8..e71ec159 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 be6b58cfbdf17ee129e39a0b885ee97c9331f4e0 Mon Sep 17 00:00:00 2001 +From dae7b4abdd6fc01d39ddced40c09423b3c40d406 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 @@ -762,6 +762,25 @@ or slot retires that admission. Respect authored handle_select and handle_deselect flags, and release SDL input when the editor disappears or its screen exits. Full Screen lifecycle regressions retain Ctrl+A selection and exercise blur, re-entry, another control and closing. + +Render opening-scoped crafting previews independently of recipe cells and +actual menu contents. Use the authored unavailable-item background, counts +and hover tooltips only over empty slots. Real items and active drag +previews retain precedence. Preserve previews through search and retire +them when the player inventory opening changes. + +Official Bedrock 1.26.60.5 controls show an unavailable oak-planks recipe +with a log ghost and four-plank output, no inventory request, persistence +through search and blank focus, and clearing after reopening. Focused +presentation tests cover ownership, real-slot precedence and lifetime. +These tests do not establish complete native GPU or variant parity. + +Resolve count and durability controls from the eligible crafting ghost +when no item draw has populated the per-frame preview. Authored painter +order must not decide the display stack. Share the same empty-slot and +active-drag admission with item rendering, while retaining real stacks +and vanilla draw overrides. A renderer-boundary test draws count first, +then verifies real-item precedence and suppression during drag selection. --- .../bedrock/ViaFabricPlusBedrock.java | 4 + .../access/IBedrockGuiItemStyle.java | 14 + @@ -775,10 +794,10 @@ selection and exercise blur, re-entry, another control and closing. .../render/MixinNativeGuiItemState.java | 20 + .../render/MixinNativeGuiOversizedItem.java | 31 + .../container/BedrockFurnacePreferences.java | 60 + - .../BedrockNativeContainerScreen.java | 1374 ++++++++++++++++ + .../BedrockNativeContainerScreen.java | 1410 +++++++++++++++++ .../container/BedrockNativeContainers.java | 283 ++++ .../container/BedrockNativeCooking.java | 147 ++ - .../BedrockNativePlayerInventory.java | 171 ++ + .../BedrockNativePlayerInventory.java | 173 ++ .../container/BedrockPlayerInventory.java | 144 ++ .../NativeContainerActionPayload.java | 30 + .../NativeContainerCurrentPayload.java | 30 + @@ -809,13 +828,13 @@ selection and exercise blur, re-entry, another control and closing. .../container/NativeInventoryFlights.java | 65 + .../NativeInventoryItemPresentation.java | 154 ++ .../container/NativeInventoryItemStyle.java | 55 + - .../NativeInventoryPresentation.java | 36 + + .../NativeInventoryPresentation.java | 45 + .../container/NativeInventoryProgress.java | 70 + .../container/NativePlayerHelpText.java | 64 + .../NativePlayerInventoryActionPayload.java | 23 + .../NativePlayerInventoryPayload.java | 22 + .../NativePlayerInventoryResultPayload.java | 22 + - .../container/NativePlayerPresentation.java | 195 +++ + .../container/NativePlayerPresentation.java | 215 +++ .../PlayerInventoryScreenClosePayload.java | 22 + .../PlayerInventoryScreenLifecycle.java | 98 ++ .../PlayerInventoryScreenReadyPayload.java | 22 + @@ -834,7 +853,7 @@ selection and exercise blur, re-entry, another control and closing. .../container/NativeCookingActionsTest.java | 346 ++++ ...tiveCookingPresentationControllerTest.java | 216 +++ .../NativeCookingPresentationTest.java | 144 ++ - .../container/NativeCookingScreenTest.java | 1409 +++++++++++++++++ + .../container/NativeCookingScreenTest.java | 1409 ++++++++++++++++ .../NativeFurnacePreferenceStateTest.java | 45 + .../container/NativeInventoryFlightsTest.java | 70 + .../NativeInventoryItemPresentationTest.java | 126 ++ @@ -842,11 +861,11 @@ selection and exercise blur, re-entry, another control and closing. .../NativeInventoryProgressTest.java | 83 + .../container/NativePlayerHelpTextTest.java | 34 + .../NativePlayerInventoryLifetimeTest.java | 142 ++ - .../NativePlayerPresentationTest.java | 153 ++ - .../container/NativePlayerScreenTest.java | 670 ++++++++ + .../NativePlayerPresentationTest.java | 189 +++ + .../container/NativePlayerScreenTest.java | 720 +++++++++ .../PlayerInventoryAdmissionTest.java | 147 ++ .../PlayerInventoryScreenLifecycleTest.java | 139 ++ - 83 files changed, 11373 insertions(+), 62 deletions(-) + 83 files changed, 11526 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 @@ -1396,10 +1415,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..725f185 +index 0000000..aaba67f --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockNativeContainerScreen.java -@@ -0,0 +1,1374 @@ +@@ -0,0 +1,1410 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -1509,6 +1528,7 @@ index 0000000..725f185 + private final NativeContainerDraws presentationDraws = new NativeContainerDraws(); + private long previousPresentationRevision = -1; + private Map recipePreviews = Map.of(); ++ private Map craftingPreview = Map.of(); + private Map> recipeActions = Map.of(); + private final Map pointerSources = new HashMap<>(); + private long cookingUpdate = -1; @@ -1707,6 +1727,7 @@ index 0000000..725f185 + NativeInventoryItemPresentation.values(this.menu.getCarried(), true, this.showTags), this.showTags); + final var resolved = frame.scene(); + this.recipePreviews = frame.recipes(); ++ this.craftingPreview = frame.craftingPreview(); + this.recipeActions = frame.recipeActions(); + this.cookingUpdate = this.presentation instanceof NativeCookingPresentation cooking ? cooking.message().updateSequence() : -1; + this.actionScope = @@ -2181,6 +2202,19 @@ index 0000000..725f185 + throw new IllegalArgumentException("Inventory item has no actual slot"); + final Slot slot = + this.menu.slots.get(box.node().properties().get("inventory_slot").getAsInt()); ++ final var ghost = this.craftingGhost(slot); ++ if (!ghost.isEmpty()) { ++ if (box.visibleBounds().contains(mouseX, mouseY)) NativeInventoryItemPresentation.requireTooltip(ghost); ++ graphics.pose().pushMatrix(); ++ try { ++ graphics.pose().translate(box.bounds().x(), box.bounds().y()); ++ graphics.pose().scale(box.bounds().width() / 16, box.bounds().height() / 16); ++ NativeInventoryItemStyle.extract((IBedrockGuiItemStyle) graphics, NativeInventoryItemStyle.from(box.node(), alpha), ++ () -> graphics.item(ghost, 0, 0)); ++ this.previews.put(slot.index, new Preview(ghost.copy(), null)); ++ } finally { graphics.pose().popMatrix(); } ++ return; ++ } + this.drawingSlot = slot; + graphics.pose().pushMatrix(); + try { @@ -2197,9 +2231,32 @@ index 0000000..725f185 + if (this.drawingSlot != null) this.previews.put(this.drawingSlot.index, new Preview(stack.copy(), count)); + } + ++ @Override ++ protected void extractTooltip(final GuiGraphicsExtractor graphics, final int mouseX, final int mouseY) { ++ final var ghost = this.hoveredSlot == null || this.hoveredSlot.hasItem() ? null : this.craftingPreview.get(this.hoveredSlot.index); ++ if (ghost != null && !this.isQuickCrafting && !this.closing()) { ++ NativeInventoryItemPresentation.requireTooltip(ghost); ++ graphics.setTooltipForNextFrame(this.font, ghost, mouseX, mouseY); ++ } else super.extractTooltip(graphics, mouseX, mouseY); ++ } ++ ++ private ItemStack craftingGhost(final Slot slot) { ++ final var ghost = this.craftingPreview.get(slot.index); ++ return ghost != null && !slot.hasItem() && !(this.isQuickCrafting && this.quickCraftSlots.contains(slot)) ++ ? ghost : ItemStack.EMPTY; ++ } ++ ++ private Preview displayPreview(final int index) { ++ final var rendered = this.previews.get(index); ++ if (rendered != null) return rendered; ++ final var slot = this.menu.slots.get(index); ++ final var ghost = this.craftingGhost(slot); ++ return new Preview(ghost.isEmpty() ? slot.getItem() : ghost, null); ++ } ++ + private void count(final GuiGraphicsExtractor graphics, final NativeUiLayout.Box box, final float alpha) { + final int slot = box.node().properties().get("inventory_slot").getAsInt(); -+ final var preview = this.previews.getOrDefault(slot, new Preview(this.menu.slots.get(slot).getItem(), null)); ++ final var preview = this.displayPreview(slot); + final String count = NativeInventoryItemPresentation.countText(preview.stack.getCount(), preview.count); + if (count.isEmpty()) return; + this.renderer.label(graphics, box, alpha, this.renderer.styled(box.node(), Component.literal(count))); @@ -2211,19 +2268,17 @@ index 0000000..725f185 + if (bag != null && bag.has("is_durability") && bag.get("is_durability").getAsBoolean() + && box.node().properties().has("inventory_slot")) { + final var preview = -+ this.previews.get(box.node().properties().get("inventory_slot").getAsInt()); -+ if (preview != null) { -+ final var props = box.node().properties().deepCopy(); -+ props.addProperty( -+ "touch_progress_bar_visible", -+ preview.stack.isDamageableItem() && preview.stack.isDamaged()); -+ props.addProperty("progress_bar_total_amount", preview.stack.getMaxDamage()); -+ props.addProperty( -+ "progress_bar_current_amount", -+ preview.stack.getMaxDamage() - preview.stack.getDamageValue()); -+ final var node = new NativeUiScene.Node(box.node().name(), box.node().type(), props, box.node().children(), box.node().action()); -+ progress = new NativeUiLayout.Box(node, box.bounds(), box.clip(), box.content(), box.scrollViewport(), box.layer(), box.children()); -+ } ++ this.displayPreview(box.node().properties().get("inventory_slot").getAsInt()); ++ final var props = box.node().properties().deepCopy(); ++ props.addProperty( ++ "touch_progress_bar_visible", ++ preview.stack.isDamageableItem() && preview.stack.isDamaged()); ++ props.addProperty("progress_bar_total_amount", preview.stack.getMaxDamage()); ++ props.addProperty( ++ "progress_bar_current_amount", ++ preview.stack.getMaxDamage() - preview.stack.getDamageValue()); ++ final var node = new NativeUiScene.Node(box.node().name(), box.node().type(), props, box.node().children(), box.node().action()); ++ progress = new NativeUiLayout.Box(node, box.bounds(), box.clip(), box.content(), box.scrollViewport(), box.layer(), box.children()); + } + for (final var quad : NativeInventoryProgress.quads(progress, alpha, (float) this.minecraft.getWindow().getGuiScale())) { + graphics.pose().pushMatrix(); @@ -3218,10 +3273,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..6f46cb7 +index 0000000..3f1fe85 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockNativePlayerInventory.java -@@ -0,0 +1,171 @@ +@@ -0,0 +1,173 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -3314,13 +3369,15 @@ index 0000000..6f46cb7 + try { + final Map previews = new HashMap<>(); + message.items().forEach((id, item) -> previews.put(id, BedrockCreativeCatalog.toNative(item, client.player.registryAccess()))); ++ final Map craftingPreview = new HashMap<>(); ++ message.craftingPreview().forEach((slot, item) -> craftingPreview.put(slot, BedrockCreativeCatalog.toNative(item, client.player.registryAccess()))); + if (menu == expected && presentation != null && presentation.message().opening() == message.opening() + && (presentation.message().generation() == 0 || presentation.message().generation() == message.generation())) { -+ presentation.update(message, previews); ++ presentation.update(message, previews, craftingPreview); + } else { + if (presentation != null) presentation.retire(); + menu = expected; -+ presentation = new NativePlayerPresentation(message, previews, System::nanoTime); ++ presentation = new NativePlayerPresentation(message, previews, craftingPreview, System::nanoTime); + if (retainedQuery != null) presentation.query(retainedQuery, BedrockNativePlayerInventory::send); + } + } catch (final RuntimeException unsupported) { @@ -6084,10 +6141,10 @@ index 0000000..98ed38d +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryPresentation.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryPresentation.java new file mode 100644 -index 0000000..4c791fc +index 0000000..973ce0e --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryPresentation.java -@@ -0,0 +1,36 @@ +@@ -0,0 +1,45 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -6111,7 +6168,13 @@ index 0000000..4c791fc + + long revision(); + -+ record Frame(JsonObject scene, int containerSlots, Map recipes, Map> recipeActions) { ++ record Frame(JsonObject scene, int containerSlots, Map recipes, Map> recipeActions, ++ Map craftingPreview) { ++ public Frame(final JsonObject scene, final int containerSlots, final Map recipes, ++ final Map> recipeActions) { ++ this(scene, containerSlots, recipes, recipeActions, Map.of()); ++ } ++ + public Frame { + if (scene == null || containerSlots < 0) throw new IllegalArgumentException("Invalid native inventory frame"); + final Map stacks = new HashMap<>(); @@ -6121,6 +6184,9 @@ index 0000000..4c791fc + final Map> actions = new HashMap<>(); + recipeActions.forEach((id, values) -> actions.put(id, Set.copyOf(values))); + recipeActions = Map.copyOf(actions); ++ final Map ghosts = new HashMap<>(); ++ craftingPreview.forEach((slot, item) -> ghosts.put(slot, item.copy())); ++ craftingPreview = Map.copyOf(ghosts); + } + } +} @@ -6357,10 +6423,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..c64ae5c +index 0000000..0c4a0a5 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativePlayerPresentation.java -@@ -0,0 +1,195 @@ +@@ -0,0 +1,215 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -6371,6 +6437,7 @@ index 0000000..c64ae5c +import com.viaversion.viaversion.libs.gson.JsonElement; +import com.viaversion.viaversion.libs.gson.JsonObject; +import com.viaversion.viaversion.libs.gson.JsonParser; ++import com.viaversion.viaversion.libs.gson.JsonPrimitive; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; @@ -6394,6 +6461,7 @@ index 0000000..c64ae5c + private PlayerInventoryUi program; + private PlayerInventoryUi.State state; + private Map previews; ++ private Map craftingPreview; + private Map windows = Map.of(); + private long revision; + private long sequence; @@ -6406,29 +6474,37 @@ index 0000000..c64ae5c + private int helpTopic = -1; + private boolean helpCreative; + -+ NativePlayerPresentation(final NativePlayerInventoryMessage message, final Map previews, final LongSupplier clock) { ++ NativePlayerPresentation(final NativePlayerInventoryMessage message, final Map previews, ++ final Map craftingPreview, final LongSupplier clock) { + this.clock = clock; -+ this.replace(message, previews); ++ this.replace(message, previews, craftingPreview); + } + -+ private void replace(final NativePlayerInventoryMessage message, final Map previews) { ++ private void replace(final NativePlayerInventoryMessage message, final Map previews, ++ final Map craftingPreview) { + if (!previews.keySet().equals(message.items().keySet()) || previews.values().stream().anyMatch(ItemStack::isEmpty)) + throw new IllegalArgumentException("Player previews differ from the retained collection"); + final Map owned = new HashMap<>(); + previews.forEach((id, item) -> owned.put(id, item.copy())); ++ if (!craftingPreview.keySet().equals(message.craftingPreview().keySet()) || craftingPreview.values().stream().anyMatch(ItemStack::isEmpty)) ++ throw new IllegalArgumentException("Crafting previews differ from the retained recipe"); ++ final Map ghosts = new HashMap<>(); ++ craftingPreview.forEach((slot, item) -> ghosts.put(slot, item.copy())); + this.program = PlayerInventoryUi.fromProgram(JsonParser.parseString(message.program()).getAsJsonObject()); + if (this.message != null && !this.message.state().cells().equals(message.state().cells())) this.select("", -1); + this.message = message; + this.state = message.state(); + this.previews = Map.copyOf(owned); ++ this.craftingPreview = Map.copyOf(ghosts); + this.revision++; + } + -+ boolean update(final NativePlayerInventoryMessage message, final Map previews) { ++ boolean update(final NativePlayerInventoryMessage message, final Map previews, ++ final Map craftingPreview) { + if (this.retired || message.menuId() != this.message.menuId() || message.opening() != this.message.opening() + || message.state().workbench() != this.message.state().workbench() || message.revision() <= this.message.revision() + || this.message.generation() != 0 && message.generation() != this.message.generation()) return false; -+ this.replace(message, previews); ++ this.replace(message, previews, craftingPreview); + return true; + } + @@ -6534,11 +6610,21 @@ index 0000000..c64ae5c + }).toList(); + final var state = new PlayerInventoryUi.State(source.workbench(), source.creative(), source.inventoryLayout(), source.craftingLayout(), source.leftTab(), source.rightTab(), + source.filtering(), this.query(), source.tabLabel(), source.craftingLabel(), source.categoryCounts(), cells, this.selectedCollection, this.selectedIndex); -+ final var scene = this.program.resolve(new PlayerInventoryUi.MenuView(menuId, state, slots, cursor), this.windows); ++ final Map ghosts = new HashMap<>(); ++ final var displayedSlots = slots.stream().map(slot -> { ++ final var ghost = this.craftingPreview.get(slot.javaSlot()); ++ if (ghost == null || slot.presentation().get("#inventory_stack_count").getAsInt() != 0) return slot; ++ final Map values = new HashMap<>(slot.presentation()); ++ values.putAll(NativeInventoryItemPresentation.values(ghost, false, showTags)); ++ values.put("#container_item_background", new JsonPrimitive(3)); ++ ghosts.put(slot.javaSlot(), ghost); ++ return new ContainerUi.Slot(slot.javaSlot(), values); ++ }).toList(); ++ final var scene = this.program.resolve(new PlayerInventoryUi.MenuView(menuId, state, displayedSlots, cursor), this.windows); + final Map> actions = new HashMap<>(); + final Map visible = new HashMap<>(); + this.scope(scene.getAsJsonObject("root"), actions, visible); -+ return new Frame(scene, 46, visible, actions); ++ return new Frame(scene, 46, visible, actions, ghosts); + } + + private void scope(final JsonObject node, final Map> actions, final Map visible) { @@ -11783,10 +11869,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..d3902fa +index 0000000..f2916dd --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativePlayerPresentationTest.java -@@ -0,0 +1,153 @@ +@@ -0,0 +1,189 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -11797,6 +11883,7 @@ index 0000000..d3902fa +import com.viaversion.viaversion.api.minecraft.data.StructuredDataContainer; +import com.viaversion.viaversion.api.minecraft.data.StructuredDataKey; +import com.viaversion.viaversion.api.minecraft.item.StructuredItem; ++import com.viaversion.viaversion.api.minecraft.item.Item; +import com.viaversion.viaversion.libs.gson.JsonPrimitive; +import com.viaversion.viaversion.util.GsonUtil; +import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiImages; @@ -11839,15 +11926,50 @@ index 0000000..d3902fa + } + + @Test ++ void craftingPreviewsStaySeparateFromRealSlotsAndSurviveSearchWithoutCrossingOpenings() { ++ final var source = message(false, 1, 8, 1, "plank", false); ++ final var wireGhosts = Map.of( ++ 0, new StructuredItem(320, 4, new StructuredDataContainer()), ++ 1, new StructuredItem(321, 1, new StructuredDataContainer())); ++ final var selected = new NativePlayerInventoryMessage(0, 1, 8, 2, source.program(), source.snapshot(), Map.of(), wireGhosts); ++ final var output = new ItemStack(Items.IRON_INGOT, 4); ++ final var ghosts = Map.of(0, output, 1, new ItemStack(Items.IRON_INGOT)); ++ final var presentation = new NativePlayerPresentation(selected, Map.of(), ghosts, () -> 0); ++ output.setCount(64); ++ final var actual = new ArrayList<>(slots()); ++ final var first = presentation.resolve(0, "", actual, Map.of(), false); ++ assertEquals(Set.of(0, 1), first.craftingPreview().keySet()); ++ assertEquals(4, first.craftingPreview().get(0).getCount()); ++ assertEquals(0, actual.get(1).presentation().get("#inventory_stack_count").getAsInt()); ++ assertTrue(first.recipes().isEmpty()); ++ actual.set(1, new ContainerUi.Slot(1, Map.of("#inventory_stack_count", new JsonPrimitive(3)))); ++ assertEquals(Set.of(0), presentation.resolve(0, "", actual, Map.of(), false).craftingPreview().keySet()); ++ final var searched = message(false, 1, 8, 3, "no results", false); ++ assertTrue(presentation.update(new NativePlayerInventoryMessage(0, 1, 8, 3, searched.program(), searched.snapshot(), ++ Map.of(), wireGhosts), Map.of(), Map.of(0, new ItemStack(Items.IRON_INGOT, 4), 1, new ItemStack(Items.IRON_INGOT)))); ++ assertEquals(Set.of(0, 1), presentation.resolve(0, "", slots(), Map.of(), false).craftingPreview().keySet()); ++ presentation.openHelp(); ++ assertTrue(presentation.resolve(0, "", slots(), Map.of(), false).craftingPreview().isEmpty()); ++ presentation.closeHelp(); ++ assertEquals(Set.of(0, 1), presentation.resolve(0, "", slots(), Map.of(), false).craftingPreview().keySet()); ++ final var reopened = message(false, 2, 8, 1, "no results", false); ++ assertFalse(presentation.update(reopened, Map.of(), Map.of())); ++ assertTrue(new NativePlayerPresentation(reopened, Map.of(), Map.of(), () -> 0) ++ .resolve(0, "", slots(), Map.of(), false).craftingPreview().isEmpty()); ++ presentation.retire(); ++ assertThrows(IllegalArgumentException.class, () -> presentation.resolve(0, "", slots(), Map.of(), false)); ++ } ++ ++ @Test + void typingSurvivesPendingCommandsAndAcknowledgementWithoutTransferringToAReopenedInventory() { + final var clock = new AtomicLong(); -+ final var presentation = new NativePlayerPresentation(message(false, 1, 0, 1, "", false), Map.of(), clock::get); ++ final var presentation = new NativePlayerPresentation(message(false, 1, 0, 1, "", false), Map.of(), Map.of(), clock::get); + final List sent = new ArrayList<>(); + presentation.query("o", sent::add); + presentation.query("oak", sent::add); + assertEquals(1, sent.size()); + assertEquals("oak", presentation.query()); -+ assertTrue(presentation.update(message(false, 1, 8, 2, "o", false), Map.of())); ++ assertTrue(presentation.update(message(false, 1, 8, 2, "o", false), Map.of(), Map.of())); + assertFalse(presentation.result(new NativePlayerInventoryResult(2, 0, 1, 2, true))); + assertFalse(presentation.idle()); + assertTrue(presentation.result(new NativePlayerInventoryResult(1, 0, 1, 2, true))); @@ -11856,8 +11978,8 @@ index 0000000..d3902fa + assertEquals("oak", sent.getLast().query()); + assertEquals(8, sent.getLast().generation()); + assertEquals(2, sent.getLast().revision()); -+ assertFalse(presentation.update(message(false, 2, 8, 3, "", false), Map.of())); -+ assertFalse(presentation.update(message(false, 1, 9, 3, "", false), Map.of())); ++ assertFalse(presentation.update(message(false, 2, 8, 3, "", false), Map.of(), Map.of())); ++ assertFalse(presentation.update(message(false, 1, 9, 3, "", false), Map.of(), Map.of())); + clock.set(10_000_000_001L); + assertTrue(presentation.expired()); + presentation.retire(); @@ -11868,7 +11990,7 @@ index 0000000..d3902fa + + @Test + void aStaleHoverExitCannotClearTheNewlySelectedSlot() { -+ final var presentation = new NativePlayerPresentation(message(false, 1, 0, 1, "", false), Map.of(), () -> 0); ++ final var presentation = new NativePlayerPresentation(message(false, 1, 0, 1, "", false), Map.of(), Map.of(), () -> 0); + presentation.select("recipe_book", 0); + presentation.select("inventory_items", 2); + final long revision = presentation.revision(); @@ -11881,7 +12003,7 @@ index 0000000..d3902fa + void defaultPlayerAndWorkbenchScenesBindAllRealSlotsAndRouteTheAuthoredHelpControl() { + for (final boolean table : new boolean[]{false, true}) { + final var presentation = new NativePlayerPresentation(message(table, table ? 0 : 1, 8, 1, "oak", true), -+ Map.of(0, new ItemStack(Items.IRON_INGOT)), System::nanoTime); ++ Map.of(0, new ItemStack(Items.IRON_INGOT)), Map.of(), System::nanoTime); + final var slots = slots(); + final var frame = presentation.resolve(table ? 7 : 0, "", slots, Map.of(), false); + assertEquals(Set.of(0), frame.recipes().keySet()); @@ -11906,15 +12028,15 @@ index 0000000..d3902fa + @Test + void helpRetainsTheOpeningAndPendingSearchWhileSourceSnapshotsAdvance() { + final var source = message(false, 1, 8, 1, "", true); -+ final var presentation = new NativePlayerPresentation(source, Map.of(0, new ItemStack(Items.IRON_INGOT)), () -> 0); ++ final var presentation = new NativePlayerPresentation(source, Map.of(0, new ItemStack(Items.IRON_INGOT)), Map.of(), () -> 0); + final List sent = new ArrayList<>(); + presentation.query("oak", sent::add); + presentation.openHelp(); + assertTrue(presentation.helping()); + final var guide = presentation.resolve(0, "", slots(), Map.of(), false); -+ assertFalse(guide.scene().equals(new NativePlayerPresentation(source, Map.of(0, new ItemStack(Items.IRON_INGOT)), () -> 0) ++ assertFalse(guide.scene().equals(new NativePlayerPresentation(source, Map.of(0, new ItemStack(Items.IRON_INGOT)), Map.of(), () -> 0) + .resolve(0, "", slots(), Map.of(), false).scene())); -+ assertTrue(presentation.update(message(false, 1, 8, 2, "oak", true), Map.of(0, new ItemStack(Items.IRON_INGOT)))); ++ assertTrue(presentation.update(message(false, 1, 8, 2, "oak", true), Map.of(0, new ItemStack(Items.IRON_INGOT)), Map.of())); + assertTrue(presentation.result(new NativePlayerInventoryResult(1, 8, 1, 2, true))); + assertTrue(presentation.helping()); + assertTrue(presentation.closeHelp()); @@ -11942,10 +12064,10 @@ index 0000000..d3902fa +} 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..d547656 +index 0000000..968ec9d --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativePlayerScreenTest.java -@@ -0,0 +1,670 @@ +@@ -0,0 +1,720 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -11954,8 +12076,11 @@ index 0000000..d547656 +package com.viaversion.viafabricplus.bedrock.screen.container; + +import com.mojang.blaze3d.platform.InputConstants; ++import com.google.gson.JsonObject; +import com.viaversion.viafabricplus.bedrock.ViaFabricPlusBedrock; +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.NativeUiFocus; +import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiCollectionWindow; @@ -11964,6 +12089,7 @@ index 0000000..d547656 +import com.viaversion.viaversion.api.minecraft.item.StructuredItem; +import com.viaversion.viaversion.api.minecraft.item.Item; +import java.util.List; ++import java.util.Set; +import java.util.Arrays; +import java.util.stream.Collectors; +import java.util.stream.IntStream; @@ -11979,6 +12105,7 @@ index 0000000..d547656 +import net.minecraft.client.Options; +import net.minecraft.client.StringSplitter; +import net.minecraft.client.gui.Font; ++import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.Gui; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; @@ -11997,6 +12124,7 @@ index 0000000..d547656 +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.InventoryMenu; ++import net.minecraft.world.inventory.Slot; +import net.minecraft.world.inventory.ContainerInput; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; @@ -12023,6 +12151,50 @@ index 0000000..d547656 + NativePlayerPresentationTest.bootstrap(); + } + ++ @Test ++ @SuppressWarnings("unchecked") ++ void countControlsReadGhostsBeforeTheItemDrawWhileRealItemsAndDraggingKeepPrecedence() 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 ordinary = mock(AbstractContainerScreen.class); ++ when(ordinary.getMenu()).thenReturn(menu); ++ try (final var singleton = mockStatic(Minecraft.class)) { ++ singleton.when(Minecraft::getInstance).thenReturn(game); ++ final var screen = new BedrockNativeContainerScreen(mock(ClientPacketListener.class), ordinary, inventory, ++ Component.empty(), mock(NativePlayerPresentation.class), false, 1, () -> 0, () -> true); ++ final var ghost = new ItemStack(Items.IRON_INGOT, 4); ++ set(BedrockNativeContainerScreen.class, screen, "craftingPreview", Map.of(0, ghost)); ++ final var renderer = mock(NativeUiRenderer.class); ++ when(renderer.styled(any(), any(Component.class))).thenAnswer(call -> call.getArgument(1)); ++ set(BedrockNativeContainerScreen.class, screen, "renderer", renderer); ++ final var properties = new JsonObject(); ++ properties.addProperty("inventory_slot", 0); ++ final var node = new NativeUiScene.Node("count", "label", properties, List.of(), Integer.MIN_VALUE); ++ final var bounds = new NativeUiLayout.Rect(0, 0, 16, 16); ++ final var box = new NativeUiLayout.Box(node, bounds, bounds, new NativeUiLayout.Size(16, 16), bounds, 0, List.of()); ++ final var graphics = mock(GuiGraphicsExtractor.class); ++ final var count = BedrockNativeContainerScreen.class.getDeclaredMethod("count", GuiGraphicsExtractor.class, NativeUiLayout.Box.class, float.class); ++ count.setAccessible(true); ++ count.invoke(screen, graphics, box, 1F); ++ verify(renderer).label(eq(graphics), eq(box), eq(1F), argThat(text -> text.getString().equals("4"))); ++ menu.getSlot(0).set(new ItemStack(Items.IRON_INGOT, 2)); ++ count.invoke(screen, graphics, box, 1F); ++ verify(renderer).label(eq(graphics), eq(box), eq(1F), argThat(text -> text.getString().equals("2"))); ++ menu.getSlot(0).set(ItemStack.EMPTY); ++ set(AbstractContainerScreen.class, screen, "isQuickCrafting", true); ++ final var quickCraftSlots = AbstractContainerScreen.class.getDeclaredField("quickCraftSlots"); ++ quickCraftSlots.setAccessible(true); ++ ((Set) quickCraftSlots.get(screen)).add(menu.getSlot(0)); ++ clearInvocations(renderer); ++ count.invoke(screen, graphics, box, 1F); ++ verifyNoInteractions(renderer); ++ assertEquals(4, ghost.getCount()); ++ assertTrue(menu.getSlot(0).getItem().isEmpty()); ++ } ++ } ++ + private static IntStream catalogButtons() { + return IntStream.of(InputConstants.MOUSE_BUTTON_LEFT, InputConstants.MOUSE_BUTTON_RIGHT); + } @@ -12063,7 +12235,7 @@ index 0000000..d547656 + List.of(1, 0, 0, 0), List.of(cell), "", -1); + final var source = new NativePlayerInventoryMessage(0, 1, 8, 1, ui.exportProgram(false).toString(), + GsonUtil.getGson().toJson(state), Map.of(0, new StructuredItem(320, 1, new StructuredDataContainer()))); -+ final var presentation = new NativePlayerPresentation(source, Map.of(0, new ItemStack(Items.IRON_INGOT)), clock::get); ++ final var presentation = new NativePlayerPresentation(source, Map.of(0, new ItemStack(Items.IRON_INGOT)), Map.of(), clock::get); + final var screen = new BedrockNativeContainerScreen(owner, ordinary, inventory, Component.empty(), presentation, + false, 1, clock::get, () -> true); + set(Screen.class, screen, "minecraft", game); @@ -12139,7 +12311,7 @@ index 0000000..d547656 + List.of(1, 0, 0, 0), List.of(cell), "", -1); + final var source = new NativePlayerInventoryMessage(0, 1, 8, 1, ui.exportProgram(false).toString(), + GsonUtil.getGson().toJson(state), Map.of(0, new StructuredItem(320, 1, new StructuredDataContainer()))); -+ final var presentation = new NativePlayerPresentation(source, Map.of(0, new ItemStack(Items.IRON_INGOT)), clock::get); ++ final var presentation = new NativePlayerPresentation(source, Map.of(0, new ItemStack(Items.IRON_INGOT)), Map.of(), clock::get); + final var screen = new BedrockNativeContainerScreen(owner, ordinary, inventory, Component.empty(), presentation, + false, 1, clock::get, () -> true); + set(Screen.class, screen, "minecraft", game); @@ -12214,7 +12386,7 @@ index 0000000..d547656 + List.of(1, 0, 0, 0), List.of(cell), "", -1); + final var source = new NativePlayerInventoryMessage(0, 1, 8, 1, ui.exportProgram(false).toString(), + GsonUtil.getGson().toJson(state), Map.of(0, new StructuredItem(320, 1, new StructuredDataContainer()))); -+ final var presentation = new NativePlayerPresentation(source, Map.of(0, new ItemStack(Items.IRON_INGOT)), clock::get); ++ final var presentation = new NativePlayerPresentation(source, Map.of(0, new ItemStack(Items.IRON_INGOT)), Map.of(), clock::get); + final var screen = new BedrockNativeContainerScreen(owner, ordinary, inventory, Component.empty(), presentation, + false, 1, clock::get, () -> true); + set(Screen.class, screen, "minecraft", game); @@ -12270,7 +12442,7 @@ index 0000000..d547656 + List.of(1, 0, 0, 0), List.of(cell), "", -1); + final var source = new NativePlayerInventoryMessage(0, 1, 8, 1, ui.exportProgram(false).toString(), + GsonUtil.getGson().toJson(state), Map.of(0, new StructuredItem(320, 1, new StructuredDataContainer()))); -+ final var presentation = new NativePlayerPresentation(source, Map.of(0, new ItemStack(Items.IRON_INGOT)), clock::get); ++ final var presentation = new NativePlayerPresentation(source, Map.of(0, new ItemStack(Items.IRON_INGOT)), Map.of(), clock::get); + final var screen = new BedrockNativeContainerScreen(owner, ordinary, inventory, Component.empty(), presentation, + false, 1, clock::get, () -> true); + set(Screen.class, screen, "minecraft", game); @@ -12326,7 +12498,7 @@ index 0000000..d547656 + List.of(1, 0, 0, 0), List.of(cell), "", -1); + final var source = new NativePlayerInventoryMessage(0, 1, 8, 1, ui.exportProgram(false).toString(), + GsonUtil.getGson().toJson(state), Map.of(0, new StructuredItem(320, 1, new StructuredDataContainer()))); -+ final var presentation = new NativePlayerPresentation(source, Map.of(0, new ItemStack(Items.IRON_INGOT)), clock::get); ++ final var presentation = new NativePlayerPresentation(source, Map.of(0, new ItemStack(Items.IRON_INGOT)), Map.of(), clock::get); + final var screen = new BedrockNativeContainerScreen(owner, ordinary, inventory, Component.empty(), presentation, + false, 1, clock::get, () -> true); + set(Screen.class, screen, "minecraft", game); @@ -12375,7 +12547,7 @@ index 0000000..d547656 + 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(), () -> 0L); ++ final var presentation = new NativePlayerPresentation(message, Map.of(), Map.of(), () -> 0L); + set(BedrockNativePlayerInventory.class, null, "owner", owner); + set(BedrockNativePlayerInventory.class, null, "menu", menu); + set(BedrockNativePlayerInventory.class, null, "presentation", presentation); @@ -12440,7 +12612,7 @@ index 0000000..d547656 + storage.putInt("capacity", 64); + data.put(NativeItemStorageData.TAG, storage); + bundle.set(DataComponents.CUSTOM_DATA, CustomData.of(data)); -+ final var presentation = new NativePlayerPresentation(source, Map.of(0, new ItemStack(Items.IRON_INGOT), 1, bundle), clock::get); ++ final var presentation = new NativePlayerPresentation(source, Map.of(0, new ItemStack(Items.IRON_INGOT), 1, bundle), Map.of(), clock::get); + final var screen = new BedrockNativeContainerScreen(owner, ordinary, inventory, Component.empty(), presentation, + false, 1, clock::get, () -> true); + set(Screen.class, screen, "minecraft", game); @@ -12529,7 +12701,7 @@ index 0000000..d547656 + final var previews = IntStream.range(0, 2000).boxed().collect(Collectors.toMap(id -> id, id -> new ItemStack(Items.IRON_INGOT))); + final var source = new NativePlayerInventoryMessage(0, 1, 8, 1, ui.exportProgram(false).toString(), + GsonUtil.getGson().toJson(state), items); -+ final var presentation = new NativePlayerPresentation(source, previews, clock::get); ++ final var presentation = new NativePlayerPresentation(source, previews, Map.of(), clock::get); + final var screen = new BedrockNativeContainerScreen(owner, ordinary, inventory, Component.empty(), presentation, + false, 1, clock::get, () -> true); + set(Screen.class, screen, "minecraft", game); 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 c003412b..43967f6b 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 @@ -158,3 +158,12 @@ The selected native glyph comparisons cover static form controls. Complete conta Container labels share the renderer's height admission and retain inherited parent scissors. The [shared renderer notes](0029-share-native-ui-scene-rendering.pr.md) describe the native controls and remaining font limits. + +Unavailable recipes retain separate ingredient and output previews for the current opening. +Real items and active drag previews take precedence over empty-slot previews. +Core and client tests cover request rejection, search persistence, reopening, item ownership, pending publication, and count controls before item draws. +Installed direct and ViaProxy controls show four-plank and four-stick outputs over empty real crafting slots without allocating inventory requests. +Search edits and blank-slot focus preserve the previews. Reopening clears them and retains the query. +With one log available, an ordinary recipe click places the real input and shows four output planks. +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.