From b9ce4b726747fb26786b2e489950a24acec05695 Mon Sep 17 00:00:00 2001 From: andreiusq Date: Thu, 2 Jul 2026 23:37:22 +0300 Subject: [PATCH] Fix: handle object components in dialog button labels A dialog button whose label contains an "object" component (an atlas sprite or a player head) showed up blank on Bedrock and spammed "Expected tag to be a literal string..." in the console. componentFromNbtTag only handled text and translate components. An object component left the built component null, so the code skipped the block that appends "extra" - which is where the rest of the label ends up once the client normalizes [, " text"] into the sprite with the text moved into extra. That meant the whole label was dropped, not just the sprite. An unrecognized component now falls back to an empty base and still gets its style and extra applied, so the surrounding text survives. The error is still logged for genuinely unknown component types, just skipped for object components since we expect those. Bedrock can't draw a sprite inside text, but a SimpleForm button can show an image next to its label, so the first sprite in a button label is now used as the button icon. The atlas sprite is mapped to the matching vanilla Bedrock texture path (item/x -> textures/items/x, block/x -> textures/blocks/x). Leaf names don't always line up between Java and Bedrock, so an unknown sprite resolves to no icon instead of a wrong one. --- .../geyser/session/dialog/DialogButton.java | 80 ++++++++++++++++++- .../session/dialog/DialogWithButtons.java | 14 +++- .../geyser/session/dialog/NoticeDialog.java | 11 ++- .../translator/text/MessageTranslator.java | 30 ++++--- .../chat/MessageTranslatorTest.java | 52 ++++++++++++ 5 files changed, 170 insertions(+), 17 deletions(-) diff --git a/core/src/main/java/org/geysermc/geyser/session/dialog/DialogButton.java b/core/src/main/java/org/geysermc/geyser/session/dialog/DialogButton.java index 6bb0cf7e41c..bbcd7acd099 100644 --- a/core/src/main/java/org/geysermc/geyser/session/dialog/DialogButton.java +++ b/core/src/main/java/org/geysermc/geyser/session/dialog/DialogButton.java @@ -26,6 +26,7 @@ package org.geysermc.geyser.session.dialog; import org.cloudburstmc.nbt.NbtMap; +import org.geysermc.cumulus.util.FormImage; import org.geysermc.geyser.session.GeyserSession; import org.geysermc.geyser.session.dialog.action.DialogAction; import org.geysermc.geyser.translator.text.MessageTranslator; @@ -34,7 +35,11 @@ import java.util.List; import java.util.Optional; -public record DialogButton(String label, Optional action) { +public record DialogButton(String label, Optional icon, Optional action) { + + public DialogButton(String label, Optional action) { + this(label, Optional.empty(), action); + } public static List readList(Optional session, List tag, Dialog.IdGetter idGetter) { if (tag == null) { @@ -51,6 +56,77 @@ public static Optional read(Optional session, Objec if (!(tag instanceof NbtMap map)) { return Optional.empty(); } - return Optional.of(new DialogButton(MessageTranslator.convertFromNullableNbtTag(session, map.get("label")), DialogAction.read(map.get("action"), idGetter))); + Object label = map.get("label"); + return Optional.of(new DialogButton( + MessageTranslator.convertFromNullableNbtTag(session, label), + iconFromLabel(label), + DialogAction.read(map.get("action"), idGetter))); + } + + /** + * Java allows a button label to embed an atlas sprite (a {@code type: "object"} component). Bedrock cannot render + * a sprite inside text, but a {@link org.geysermc.cumulus.form.SimpleForm} button can show an image next to its + * text, so we surface the first sprite in the label as the button's icon. + */ + private static Optional iconFromLabel(Object label) { + NbtMap sprite = findSprite(label); + if (sprite == null) { + return Optional.empty(); + } + return spriteTexturePath(sprite.getString("atlas", ""), sprite.getString("sprite", "")) + .map(path -> FormImage.of(FormImage.Type.PATH, path)); + } + + /** + * Recursively searches a label component for the first atlas sprite ({@code type: "object"} with a {@code sprite}). + */ + private static NbtMap findSprite(Object tag) { + if (tag instanceof NbtMap map) { + if ("object".equals(map.getString("type", null)) && map.containsKey("sprite")) { + return map; + } + Object extra = map.get("extra"); + if (extra != null) { + return findSprite(extra); + } + } else if (tag instanceof List list) { + for (Object entry : list) { + NbtMap found = findSprite(entry); + if (found != null) { + return found; + } + } + } + return null; + } + + /** + * Best-effort translation of a Java atlas sprite to the equivalent vanilla Bedrock texture path. Java lays item + * and block textures out under {@code textures/item} and {@code textures/block}, while Bedrock uses the pluralized + * {@code textures/items} and {@code textures/blocks}. Leaf names do not always match between the two editions, so + * an unrecognized sprite resolves to no icon (Bedrock simply renders no image) rather than a wrong one. + */ + public static Optional spriteTexturePath(String atlas, String sprite) { + if (!atlas.equals("minecraft:items") && !atlas.equals("minecraft:blocks")) { + return Optional.empty(); + } + // Sprites may be namespaced (e.g. "minecraft:item/barrier"); we only care about the path. + int colon = sprite.indexOf(':'); + if (colon != -1) { + sprite = sprite.substring(colon + 1); + } + int slash = sprite.indexOf('/'); + if (slash == -1) { + return Optional.empty(); + } + String bedrockCategory = switch (sprite.substring(0, slash)) { + case "item" -> "items"; + case "block" -> "blocks"; + default -> null; + }; + if (bedrockCategory == null) { + return Optional.empty(); + } + return Optional.of("textures/" + bedrockCategory + "/" + sprite.substring(slash + 1)); } } diff --git a/core/src/main/java/org/geysermc/geyser/session/dialog/DialogWithButtons.java b/core/src/main/java/org/geysermc/geyser/session/dialog/DialogWithButtons.java index 5ecd0acd6e1..dee3c45adde 100644 --- a/core/src/main/java/org/geysermc/geyser/session/dialog/DialogWithButtons.java +++ b/core/src/main/java/org/geysermc/geyser/session/dialog/DialogWithButtons.java @@ -29,6 +29,7 @@ import org.geysermc.cumulus.component.DropdownComponent; import org.geysermc.cumulus.form.CustomForm; import org.geysermc.cumulus.form.SimpleForm; +import org.geysermc.cumulus.util.FormImage; import org.geysermc.geyser.session.GeyserSession; import org.geysermc.geyser.session.dialog.input.ParsedInputs; import org.geysermc.geyser.text.GeyserLocale; @@ -73,9 +74,9 @@ protected void addCustomComponents(DialogHolder holder, CustomForm.Builder build protected void addCustomComponents(DialogHolder holder, SimpleForm.Builder builder) { List buttons = buttons(holder); for (DialogButton button : buttons) { - builder.button(button.label()); + addButton(builder, button); } - exitAction.ifPresent(button -> builder.button(button.label())); + exitAction.ifPresent(button -> addButton(builder, button)); builder.validResultHandler(response -> { if (response.clickedButtonId() == buttons.size()) { @@ -86,6 +87,15 @@ protected void addCustomComponents(DialogHolder holder, SimpleForm.Builder build }); } + private static void addButton(SimpleForm.Builder builder, DialogButton button) { + Optional icon = button.icon(); + if (icon.isPresent()) { + builder.button(button.label(), icon.get()); + } else { + builder.button(button.label()); + } + } + @Override protected Optional onCancel() { return exitAction; diff --git a/core/src/main/java/org/geysermc/geyser/session/dialog/NoticeDialog.java b/core/src/main/java/org/geysermc/geyser/session/dialog/NoticeDialog.java index 516879a00da..bfb30827bd6 100644 --- a/core/src/main/java/org/geysermc/geyser/session/dialog/NoticeDialog.java +++ b/core/src/main/java/org/geysermc/geyser/session/dialog/NoticeDialog.java @@ -29,6 +29,7 @@ import org.cloudburstmc.nbt.NbtMap; import org.geysermc.cumulus.form.CustomForm; import org.geysermc.cumulus.form.SimpleForm; +import org.geysermc.cumulus.util.FormImage; import org.geysermc.geyser.session.GeyserSession; import org.geysermc.geyser.session.dialog.input.ParsedInputs; import org.geysermc.geyser.util.MinecraftKey; @@ -58,7 +59,13 @@ protected void addCustomComponents(DialogHolder holder, CustomForm.Builder build @Override protected void addCustomComponents(DialogHolder holder, SimpleForm.Builder builder) { - builder.button(button.map(DialogButton::label).orElse("gui.ok")) - .validResultHandler(response -> holder.runButton(button, ParsedInputs.EMPTY)); + String label = button.map(DialogButton::label).orElse("gui.ok"); + Optional icon = button.flatMap(DialogButton::icon); + if (icon.isPresent()) { + builder.button(label, icon.get()); + } else { + builder.button(label); + } + builder.validResultHandler(response -> holder.runButton(button, ParsedInputs.EMPTY)); } } diff --git a/core/src/main/java/org/geysermc/geyser/translator/text/MessageTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/text/MessageTranslator.java index a01c6091619..45b8e54e865 100644 --- a/core/src/main/java/org/geysermc/geyser/translator/text/MessageTranslator.java +++ b/core/src/main/java/org/geysermc/geyser/translator/text/MessageTranslator.java @@ -556,7 +556,7 @@ private static Component componentFromNbtTag(Object nbtTag, Style style) { } else if (nbtTag instanceof List list) { return Component.join(JoinConfiguration.noSeparators(), componentsFromNbtList(list, style)); } else if (nbtTag instanceof NbtMap map) { - Component component = null; + Component component; String text = map.getString("text", map.getString("", null)); if (text != null) { component = Component.text(text); @@ -573,23 +573,31 @@ private static Component componentFromNbtTag(Object nbtTag, Style style) { args.add(componentFromNbtTag(with, style)); } component = Component.translatable(translateKey, fallback, args); + } else { + // No renderable text content. This is expected for "object" components (atlas sprites, player + // heads, ...) which Bedrock cannot render inline; anything else is unexpected and still worth + // logging. Either way we fall back to an empty base so styling and any child components in + // "extra" are still rendered, rather than dropping the whole component (and the text around + // it) on the floor. See GeyserMC/Geyser#6499. + if (!"object".equals(map.getString("type", null))) { + GeyserImpl.getInstance().getLogger().error("Expected tag to be a literal string, a list of components, or a component object with a text/translate key: " + nbtTag); + } + component = Component.empty(); } } - if (component != null) { - Style newStyle = getStyleFromNbtMap(map, style); - component = component.style(newStyle); + Style newStyle = getStyleFromNbtMap(map, style); + component = component.style(newStyle); - Object extra = map.get("extra"); - if (extra != null) { - component = component.append(componentFromNbtTag(extra, newStyle)); - } - - return component; + Object extra = map.get("extra"); + if (extra != null) { + component = component.append(componentFromNbtTag(extra, newStyle)); } + + return component; } - GeyserImpl.getInstance().getLogger().error("Expected tag to be a literal string, a list of components, or a component object with a text/translate key: " + nbtTag); + GeyserImpl.getInstance().getLogger().error("Expected tag to be a literal string, a list of components, or a component object: " + nbtTag); return Component.empty(); } diff --git a/core/src/test/java/org/geysermc/geyser/network/translators/chat/MessageTranslatorTest.java b/core/src/test/java/org/geysermc/geyser/network/translators/chat/MessageTranslatorTest.java index 527cce8aa47..55cc7394749 100644 --- a/core/src/test/java/org/geysermc/geyser/network/translators/chat/MessageTranslatorTest.java +++ b/core/src/test/java/org/geysermc/geyser/network/translators/chat/MessageTranslatorTest.java @@ -27,6 +27,13 @@ import java.util.HashMap; import java.util.Map; +import java.util.Optional; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.cloudburstmc.nbt.NbtList; +import org.cloudburstmc.nbt.NbtMap; +import org.cloudburstmc.nbt.NbtType; +import org.geysermc.geyser.session.dialog.DialogButton; import org.geysermc.geyser.translator.text.MessageTranslator; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; @@ -87,6 +94,51 @@ public void convertMessage() { } } + // https://github.com/GeyserMC/Geyser/issues/6499 - an "object" component (atlas sprite / player head) has no + // textual representation on Bedrock, but it must not swallow the text around it. + @Test + public void objectComponentKeepsSurroundingText() { + // Minecraft normalizes the array [, " Hello world"] into the object component with the text in "extra". + NbtMap normalized = NbtMap.builder() + .putString("type", "object") + .putString("atlas", "minecraft:items") + .putString("sprite", "item/barrier") + .putList("extra", NbtType.STRING, " Hello world") + .build(); + Component normalizedComponent = MessageTranslator.componentFromNbtTag(normalized); + Assertions.assertEquals(" Hello world", PlainTextComponentSerializer.plainText().serialize(normalizedComponent), + "Object component must not drop the text in its extra"); + + // The same content expressed as a plain list of components. + NbtMap object = NbtMap.builder() + .putString("type", "object") + .putString("atlas", "minecraft:items") + .putString("sprite", "item/barrier") + .build(); + NbtMap text = NbtMap.builder().putString("text", " Hello world").build(); + NbtList list = new NbtList<>(NbtType.COMPOUND, object, text); + Component listComponent = MessageTranslator.componentFromNbtTag(list); + Assertions.assertEquals(" Hello world", PlainTextComponentSerializer.plainText().serialize(listComponent), + "Object component in a list must not drop the sibling text"); + } + + // https://github.com/GeyserMC/Geyser#6499 - atlas sprites in a button label become the Bedrock button's icon. + @Test + public void spriteMapsToBedrockTexturePath() { + Assertions.assertEquals(Optional.of("textures/items/barrier"), + DialogButton.spriteTexturePath("minecraft:items", "item/barrier")); + Assertions.assertEquals(Optional.of("textures/blocks/stone"), + DialogButton.spriteTexturePath("minecraft:blocks", "block/stone")); + // Namespaced sprite ids are accepted too. + Assertions.assertEquals(Optional.of("textures/items/apple"), + DialogButton.spriteTexturePath("minecraft:items", "minecraft:item/apple")); + // Atlases and sprite categories we can't predict resolve to no icon rather than a wrong one. + Assertions.assertEquals(Optional.empty(), + DialogButton.spriteTexturePath("minecraft:mob_effects", "effect/speed")); + Assertions.assertEquals(Optional.empty(), + DialogButton.spriteTexturePath("minecraft:items", "barrier")); + } + @Test public void convertMessageLenient() { Assertions.assertEquals("\n\n\n\n", MessageTranslator.convertMessageLenient("\n\n\n\n"), "All newline message is not handled properly");