diff --git a/patches/viabedrock/upstreamable/0095-share-native-ui-programs-and-resources.patch b/patches/viabedrock/upstreamable/0095-share-native-ui-programs-and-resources.patch index cd325d5e..003fa55f 100644 --- a/patches/viabedrock/upstreamable/0095-share-native-ui-programs-and-resources.patch +++ b/patches/viabedrock/upstreamable/0095-share-native-ui-programs-and-resources.patch @@ -1,4 +1,4 @@ -From 0e0579dd8583d16bf07eeef88308e461676b1aa3 Mon Sep 17 00:00:00 2001 +From 0483e2cf00dcd4739a0a7775de6e770976d175b6 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(ui): share bounded native UI programs and resource @@ -499,6 +499,42 @@ that their renderer rejects. Preserve the shared 64-level and 8192-node scene limits so the client can retain the intact original form when projection metadata is invalid. A regression rejects an overdeep tree before caption traversal or fallback-field access. + +Choose a TrueType font only when its Unicode cmap covers the complete +formatted label. Native Bedrock 1.26.60.5, protocol 2225, switches the +MinecraftTen label to bitmap for newline, tab, Han, and snowman characters. +Carriage return maps to a real glyph and retains TrueType; formatting +codes do not force fallback. Match the captured font bytes and exclude +TrueType line metrics when the selected fallback is Unicode. + +Read bounded cmap formats 0, 4, 6, 12, and 13 using OpenType's Unicode +selection and missing-glyph rules. Preserve zero glyphs before delta +adjustment and bound all offsets to the selected subtable. Prefer the +full-repertoire map when present. Format 4 is exercised by the target +captures; supplementary transport regressions use format 12. The reference +is https://learn.microsoft.com/en-us/typography/opentype/spec/cmap. + +Cache coverage per winning font file and invalidate it on layer replacement. +Transport a compressed bounded Unicode bitset with the existing family +identifiers, so binding refresh makes the same decision on both routes. +Older programs without coverage retain their previous font behavior. +Reject malformed coverage and limit decompression to the Unicode range. +Complete TrueType raster parity and other native font-selection paths +remain unverified. + +Native 1.26.60.5 controls retain raw bitmap C0 atlas cells alongside +their printable mappings. Preserve both aliases instead of routing these +characters to Java's missing glyph. SOH, TAB, CR, and CR/LF controls match +the raw first-page cells, while Unicode fallback retains its own glyph +pages. Keep these aliases before Unicode providers and preserve native +texture metadata. Advance the conversion namespace so previous converted +fonts cannot hide the new aliases. + +Native 1.26.60.5 NUL controls terminate visible text before selecting a +font. An unavailable suffix after NUL does not force fallback. Truncate +the translated display property before component and font resolution, +while retaining the controller's original binding values and declarations. +The native controls include newline, Unicode and formatting suffixes. --- build.gradle | 11 + .../api/model/NativeContainerAction.java | 93 + @@ -532,7 +568,7 @@ before caption traversal or fallback-field access. .../api/resourcepack/ui/HudTitleUi.java | 28 + .../resourcepack/ui/InventoryItemFilter.java | 43 + .../resourcepack/ui/InventoryStackCount.java | 21 + - .../api/resourcepack/ui/JsonUiProgram.java | 1720 +++++++++++++++++ + .../api/resourcepack/ui/JsonUiProgram.java | 1730 +++++++++++++++++ .../resourcepack/ui/NativeRecipeSearch.java | 124 ++ .../ui/NativeSearchNormalization.java | 189 ++ .../api/resourcepack/ui/NpcDialogueUi.java | 316 +++ @@ -542,9 +578,10 @@ before caption traversal or fallback-field access. .../api/resourcepack/ui/ServerFormUi.java | 271 +++ .../resourcepack/ui/UiAsepriteMetadata.java | 95 + .../api/resourcepack/ui/UiBindings.java | 79 + - .../api/resourcepack/ui/UiBitmapFonts.java | 282 +++ + .../api/resourcepack/ui/UiBitmapFonts.java | 303 +++ .../api/resourcepack/ui/UiExpression.java | 306 +++ - .../api/resourcepack/ui/UiFonts.java | 286 +++ + .../api/resourcepack/ui/UiFontGlyphs.java | 235 +++ + .../api/resourcepack/ui/UiFonts.java | 326 ++++ .../api/resourcepack/ui/UiTextureLimits.java | 24 + .../api/resourcepack/ui/UiTextures.java | 101 + .../viabedrock/api/util/NativeSort.java | 168 ++ @@ -575,28 +612,29 @@ before caption traversal or fallback-field access. .../resourcepack/ui/CookingProgressTest.java | 47 + .../ui/CookingRecipeOrderTest.java | 82 + .../ui/CookingSearchTextTest.java | 90 + - .../api/resourcepack/ui/CookingUiTest.java | 646 +++++++ + .../api/resourcepack/ui/CookingUiTest.java | 646 ++++++ .../ui/FormCollectionActivationTest.java | 97 + .../api/resourcepack/ui/HudRootUiTest.java | 90 + .../ui/InventoryItemFilterTest.java | 33 + .../resourcepack/ui/JsonUiProgramTest.java | 615 ++++++ .../ui/NativeRecipeSearchTest.java | 110 ++ .../ui/NativeSearchNormalizationTest.java | 46 + - .../resourcepack/ui/NpcDialogueUiTest.java | 108 ++ + .../resourcepack/ui/NpcDialogueUiTest.java | 108 + .../resourcepack/ui/NpcEditorStateTest.java | 209 ++ .../resourcepack/ui/RecipeCollectionTest.java | 227 +++ .../ui/ServerCustomFormUiTest.java | 146 ++ .../ui/ServerFormUiSliderStateTest.java | 126 ++ .../api/resourcepack/ui/ServerFormUiTest.java | 486 +++++ .../ui/UiAsepriteMetadataTest.java | 112 ++ - .../resourcepack/ui/UiBitmapFontsTest.java | 285 +++ + .../resourcepack/ui/UiBitmapFontsTest.java | 313 +++ .../ui/UiCollectionWindowTest.java | 131 ++ .../api/resourcepack/ui/UiExpressionTest.java | 77 + - .../api/resourcepack/ui/UiFontsTest.java | 192 ++ + .../api/resourcepack/ui/UiFontGlyphsTest.java | 60 + + .../api/resourcepack/ui/UiFontsTest.java | 289 +++ .../api/resourcepack/ui/UiTexturesTest.java | 231 +++ .../ui/native-inventory-item-filter.json | 311 +++ .../generator/SearchUnicodeDataGenerator.java | 126 ++ - 96 files changed, 14824 insertions(+), 60 deletions(-) + 98 files changed, 15315 insertions(+), 60 deletions(-) create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeContainerAction.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeContainerData.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeContainerMessage.java @@ -639,6 +677,7 @@ before caption traversal or fallback-field access. create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/UiBindings.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/UiBitmapFonts.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/UiExpression.java + create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/UiFontGlyphs.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/UiFonts.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/UiTextureLimits.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/UiTextures.java @@ -683,6 +722,7 @@ before caption traversal or fallback-field access. create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/UiBitmapFontsTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/UiCollectionWindowTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/UiExpressionTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/UiFontGlyphsTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/UiFontsTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/UiTexturesTest.java create mode 100644 src/test/resources/ui/native-inventory-item-filter.json @@ -2211,7 +2251,7 @@ index 00000000..f7b0cb2a + +} diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCache.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCache.java -index f7460be9..7c48ba83 100644 +index f7460be9..ccb0cc7c 100644 --- a/src/main/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCache.java +++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCache.java @@ -17,6 +17,7 @@ @@ -2227,7 +2267,7 @@ index f7460be9..7c48ba83 100644 public class ConvertedResourcePackCache { - private static final String CONVERSION_VERSION = "native-appearance-rendering-metadata-and-offsets-11"; -+ private static final String CONVERSION_VERSION = "native-appearance-rendering-metadata-offsets-and-forms-13"; ++ private static final String CONVERSION_VERSION = "native-appearance-rendering-metadata-offsets-and-forms-14"; // Advance when the set or format of emitted resources changes independently of converter profiles. - private static final int RESOURCE_FORMAT_VERSION = 7; + private static final int RESOURCE_FORMAT_VERSION = 10; @@ -4066,10 +4106,10 @@ index 00000000..5dfbb3f8 +} diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/JsonUiProgram.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/JsonUiProgram.java new file mode 100644 -index 00000000..05f99df4 +index 00000000..022cbcea --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/JsonUiProgram.java -@@ -0,0 +1,1720 @@ +@@ -0,0 +1,1730 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -4280,6 +4320,9 @@ index 00000000..05f99df4 + if (program.has("font_ids")) { + ui.fonts.importIds(program.getAsJsonObject("font_ids")); + } ++ if (program.has("font_glyph_coverage")) { ++ ui.fonts.importGlyphCoverage(program.getAsJsonObject("font_glyph_coverage")); ++ } + if (program.has("font_line_heights")) { + ui.fonts.importLineHeights(program.getAsJsonObject("font_line_heights")); + } @@ -4553,6 +4596,9 @@ index 00000000..05f99df4 + final JsonObject fonts = new JsonObject(); + this.fonts.ids().forEach(fonts::addProperty); + result.add("font_ids", fonts); ++ final JsonObject coverage = new JsonObject(); ++ this.fonts.glyphCoverage().forEach(coverage::addProperty); ++ result.add("font_glyph_coverage", coverage); + result.addProperty("bitmap_font_characters", this.fonts.bitmapCharacters()); + final JsonObject lineHeights = new JsonObject(); + this.fonts.lineHeights().forEach(lineHeights::addProperty); @@ -5263,6 +5309,10 @@ index 00000000..05f99df4 + if (!node.has("localize") || UiExpression.truth(node.get("localize"))) { + text = JsonUiProgram.this.translator.apply(text); + } ++ final int terminator = text.indexOf(0); ++ if (terminator >= 0) { ++ text = text.substring(0, terminator); ++ } + node.addProperty(textProperty, text); + node.addProperty("localize", false); + node.add(textProperty + "_component", TextUtil.textComponentToGson(TextUtil.stringToTextComponent(text, JsonUiProgram.this.formattingColors))); @@ -5271,7 +5321,7 @@ index 00000000..05f99df4 + if (font != null) { + node.addProperty("font", font); + final Double lineHeight = JsonUiProgram.this.fonts.lineHeight(string(node, "font_type")); -+ if (lineHeight != null) { ++ if (lineHeight != null && font.equals(JsonUiProgram.this.fonts.id(string(node, "font_type")))) { + node.addProperty("font_line_height", lineHeight); + } + } @@ -7837,10 +7887,10 @@ index 00000000..03dd0797 +} diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/UiBitmapFonts.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/UiBitmapFonts.java new file mode 100644 -index 00000000..f8051c4b +index 00000000..e7137d37 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/UiBitmapFonts.java -@@ -0,0 +1,282 @@ +@@ -0,0 +1,303 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -7915,6 +7965,7 @@ index 00000000..f8051c4b + final Map result = new LinkedHashMap<>(); + for (final String family : new String[] {"default8", "ascii_sga"}) { + final JsonArray providers = new JsonArray(); ++ final JsonArray controlProviders = new JsonArray(); + final Atlas firstAtlas = this.atlases.get("font/" + family + ".png"); + final int pages = firstAtlas == null ? 0 : firstAtlas.layout().pages(); + this.atlases.forEach((path, atlas) -> { @@ -7979,6 +8030,25 @@ index 00000000..f8051c4b + } + provider.add("chars", chars); + providers.add(provider); ++ if (atlas.page() == 0) { ++ // Native bitmap lookup retains raw C0 cells alongside their printable aliases. ++ final JsonObject controls = provider.deepCopy(); ++ final JsonArray controlCharacters = new JsonArray(); ++ for (int row = 0; row < rows; row++) { ++ final StringBuilder characters = new StringBuilder(COLUMNS); ++ for (int column = 0; column < COLUMNS; column++) { ++ final int character = row * COLUMNS + column; ++ final char mapped = character > 0 && character < 32 ? (char) character : 0; ++ characters.append(mapped); ++ if (mapped != 0 && family.equals("default8")) { ++ available.append(mapped); ++ } ++ } ++ controlCharacters.add(characters.toString()); ++ } ++ controls.add("chars", controlCharacters); ++ controlProviders.add(controls); ++ } + if (family.equals("default8")) { + for (final var row : chars) { + row.getAsString().chars().filter(character -> character != 0).forEach(character -> available.append((char) character)); @@ -7988,6 +8058,7 @@ index 00000000..f8051c4b + // An unusable optional atlas must not disable the accepted pack's other resources. + } + }); ++ controlProviders.forEach(providers::add); + if (family.equals("default8")) { + final JsonArray unicodeProviders = new JsonArray(); + final JsonObject space = new JsonObject(); @@ -8435,12 +8506,253 @@ index 00000000..2cc280f6 + } + +} +diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/UiFontGlyphs.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/UiFontGlyphs.java +new file mode 100644 +index 00000000..808216eb +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/UiFontGlyphs.java +@@ -0,0 +1,235 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.resourcepack.ui; ++ ++import java.io.ByteArrayInputStream; ++import java.io.ByteArrayOutputStream; ++import java.io.IOException; ++import java.nio.ByteBuffer; ++import java.nio.ByteOrder; ++import java.util.Base64; ++import java.util.BitSet; ++import java.util.zip.DeflaterOutputStream; ++import java.util.zip.InflaterInputStream; ++ ++/** Bounded Unicode cmap coverage, shared with clients without transporting font files. */ ++final class UiFontGlyphs { ++ ++ private static final int MAX_BYTES = (Character.MAX_CODE_POINT + 1) / 8; ++ private static final UiFontGlyphs EMPTY = new UiFontGlyphs(new BitSet()); ++ private final BitSet characters; ++ private String encoded; ++ ++ private UiFontGlyphs(final BitSet characters) { ++ this.characters = characters; ++ } ++ ++ boolean supports(final String text) { ++ int offset = 0; ++ while (offset < text.length()) { ++ final int character = text.codePointAt(offset); ++ offset += Character.charCount(character); ++ if (character == '§' && offset < text.length()) { ++ offset += Character.charCount(text.codePointAt(offset)); ++ } else if (!this.characters.get(character)) { ++ return false; ++ } ++ } ++ return true; ++ } ++ ++ String encode() { ++ if (this.encoded == null) { ++ final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); ++ try (DeflaterOutputStream compressed = new DeflaterOutputStream(bytes)) { ++ compressed.write(this.characters.toByteArray()); ++ } catch (final IOException exception) { ++ throw new IllegalStateException("Cannot encode UI font coverage", exception); ++ } ++ this.encoded = Base64.getEncoder().encodeToString(bytes.toByteArray()); ++ } ++ return this.encoded; ++ } ++ ++ static UiFontGlyphs decode(final String encoded) { ++ if (encoded.length() > (MAX_BYTES + 1024) * 4 / 3) { ++ throw new IllegalArgumentException("UI font coverage exceeds Unicode bounds"); ++ } ++ try (InflaterInputStream stream = new InflaterInputStream(new ByteArrayInputStream(Base64.getDecoder().decode(encoded)))) { ++ final byte[] bytes = stream.readNBytes(MAX_BYTES + 1); ++ if (bytes.length > MAX_BYTES) { ++ throw new IllegalArgumentException("UI font coverage exceeds Unicode bounds"); ++ } ++ final UiFontGlyphs result = new UiFontGlyphs(BitSet.valueOf(bytes)); ++ result.encoded = encoded; ++ return result; ++ } catch (final IOException exception) { ++ throw new IllegalArgumentException("Invalid UI font coverage", exception); ++ } ++ } ++ ++ static UiFontGlyphs read(final byte[] font) { ++ if (font == null || font.length < 12) { ++ return EMPTY; ++ } ++ try { ++ final ByteBuffer bytes = ByteBuffer.wrap(font).order(ByteOrder.BIG_ENDIAN); ++ final int count = unsigned(bytes, 4); ++ if (bytes.getInt(0) != 0x00010000 || count > 256 || 12L + count * 16L > bytes.limit()) { ++ return EMPTY; ++ } ++ for (int i = 0; i < count; i++) { ++ final int record = 12 + i * 16; ++ if (bytes.getInt(record) != 0x636D6170) { ++ continue; ++ } ++ final long offset = Integer.toUnsignedLong(bytes.getInt(record + 8)); ++ final long length = Integer.toUnsignedLong(bytes.getInt(record + 12)); ++ if (offset + length > bytes.limit() || length < 4) { ++ return EMPTY; ++ } ++ return readCmap(bytes.slice((int) offset, (int) length).order(ByteOrder.BIG_ENDIAN)); ++ } ++ } catch (final IndexOutOfBoundsException | IllegalArgumentException ignored) { ++ // An unusable optional cmap selects the ordinary bitmap fallback. ++ } ++ return EMPTY; ++ } ++ ++ private static UiFontGlyphs readCmap(final ByteBuffer bytes) { ++ final int count = unsigned(bytes, 2); ++ if (unsigned(bytes, 0) != 0 || 4L + count * 8L > bytes.limit()) { ++ return EMPTY; ++ } ++ ByteBuffer selected = null; ++ int priority = -1; ++ for (int i = 0; i < count; i++) { ++ final int record = 4 + i * 8; ++ final int platform = unsigned(bytes, record); ++ final int encoding = unsigned(bytes, record + 2); ++ if (platform != 0 && !(platform == 3 && (encoding == 1 || encoding == 10)) || platform == 0 && encoding == 5) { ++ continue; ++ } ++ final long offset = Integer.toUnsignedLong(bytes.getInt(record + 4)); ++ if (offset + 4 > bytes.limit()) { ++ return EMPTY; ++ } ++ final int format = unsigned(bytes, (int) offset); ++ final int rank = switch (format) { ++ case 12, 13 -> 2; ++ case 0, 4, 6 -> 1; ++ default -> -1; ++ }; ++ if (rank < 0 || rank < priority) { ++ continue; ++ } ++ final long length = format >= 12 ? Integer.toUnsignedLong(bytes.getInt((int) offset + 4)) : unsigned(bytes, (int) offset + 2); ++ if (length < 6 || offset + length > bytes.limit()) { ++ return EMPTY; ++ } ++ selected = bytes.slice((int) offset, (int) length).order(ByteOrder.BIG_ENDIAN); ++ priority = rank; ++ } ++ if (selected == null) { ++ return EMPTY; ++ } ++ final BitSet result = new BitSet(); ++ switch (unsigned(selected, 0)) { ++ case 0 -> { ++ if (selected.limit() < 262) { ++ return EMPTY; ++ } ++ for (int c = 0; c < 256; c++) { ++ if (selected.get(6 + c) != 0) { ++ result.set(c); ++ } ++ } ++ } ++ case 4 -> { ++ final int segments = unsigned(selected, 6) / 2; ++ if (segments == 0 || unsigned(selected, 6) % 2 != 0 || 16L + segments * 8L > selected.limit()) { ++ return EMPTY; ++ } ++ int previous = -1; ++ for (int i = 0; i < segments; i++) { ++ final int end = unsigned(selected, 14 + i * 2); ++ final int start = unsigned(selected, 16 + segments * 2 + i * 2); ++ final int delta = unsigned(selected, 16 + segments * 4 + i * 2); ++ final int word = 16 + segments * 6 + i * 2; ++ final int offset = unsigned(selected, word); ++ if (start > end || start <= previous) { ++ return EMPTY; ++ } ++ for (int c = start; c <= end; c++) { ++ int glyph; ++ if (offset == 0) { ++ glyph = (c + delta) & 0xFFFF; ++ } else { ++ final int address = word + offset + 2 * (c - start); ++ if (address < 16 + segments * 8 || address + 2 > selected.limit()) { ++ return EMPTY; ++ } ++ glyph = unsigned(selected, address); ++ if (glyph != 0) { ++ glyph = (glyph + delta) & 0xFFFF; ++ } ++ } ++ if (glyph != 0) { ++ result.set(c); ++ } ++ } ++ previous = end; ++ } ++ if (previous != 0xFFFF) { ++ return EMPTY; ++ } ++ } ++ case 6 -> { ++ final int start = unsigned(selected, 6); ++ final int entries = unsigned(selected, 8); ++ if (start + entries > 65536 || 10L + entries * 2L > selected.limit()) { ++ return EMPTY; ++ } ++ for (int i = 0; i < entries; i++) { ++ if (unsigned(selected, 10 + i * 2) != 0) { ++ result.set(start + i); ++ } ++ } ++ } ++ case 12, 13 -> { ++ final boolean constant = unsigned(selected, 0) == 13; ++ final long groups = Integer.toUnsignedLong(selected.getInt(12)); ++ if (16 + groups * 12 > selected.limit()) { ++ return EMPTY; ++ } ++ long previous = -1; ++ for (int i = 0; i < groups; i++) { ++ final int record = 16 + i * 12; ++ final long start = Integer.toUnsignedLong(selected.getInt(record)); ++ final long end = Integer.toUnsignedLong(selected.getInt(record + 4)); ++ final long glyph = Integer.toUnsignedLong(selected.getInt(record + 8)); ++ if (start <= previous || start > end || end > Character.MAX_CODE_POINT || glyph > 65535 || !constant && glyph + end - start > 65535) { ++ return EMPTY; ++ } ++ final int first = (int) start + (!constant && glyph == 0 ? 1 : 0); ++ if ((!constant || glyph != 0) && first <= end) { ++ result.set(first, (int) end + 1); ++ } ++ previous = end; ++ } ++ } ++ default -> { ++ return EMPTY; ++ } ++ } ++ return new UiFontGlyphs(result); ++ } ++ ++ private static int unsigned(final ByteBuffer bytes, final int offset) { ++ return Short.toUnsignedInt(bytes.getShort(offset)); ++ } ++ ++} diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/UiFonts.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/UiFonts.java new file mode 100644 -index 00000000..8715b892 +index 00000000..97d7793f --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/UiFonts.java -@@ -0,0 +1,286 @@ +@@ -0,0 +1,326 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -8476,6 +8788,8 @@ index 00000000..8715b892 + private final Map renderHeights = new LinkedHashMap<>(); + private final Map importedIds = new LinkedHashMap<>(); + private final Map importedLineHeights = new LinkedHashMap<>(); ++ private final Map fileGlyphs = new LinkedHashMap<>(); ++ private final Map importedGlyphs = new LinkedHashMap<>(); + + void load(final Map layer) { + this.bitmaps.load(layer); @@ -8483,6 +8797,7 @@ index 00000000..8715b892 + if (path.startsWith("font/") && JsonUiProgram.safePath(path) + && path.endsWith(".ttf") && bytes.length <= MAX_FONT_BYTES && (this.files.containsKey(path) || this.files.size() < MAX_FONTS)) { + this.files.put(path, bytes); ++ this.fileGlyphs.remove(path); + } + }); + final byte[] metadata = layer.get("font/font_metadata.json"); @@ -8534,8 +8849,12 @@ index 00000000..8715b892 + + String id(final String name, final String text) { + final String declared = this.id(name); -+ if (declared != null || !name.isEmpty() && !name.equals("default")) { -+ return declared; ++ if (declared != null) { ++ final UiFontGlyphs glyphs = this.glyphs(name); ++ // Older transported programs have identifiers without coverage metadata. ++ if (glyphs == null || glyphs.supports(text)) { ++ return declared; ++ } + } + final String characters = this.bitmapCharacters(); + if (characters.isEmpty()) { @@ -8555,6 +8874,39 @@ index 00000000..8715b892 + return null; + } + ++ private UiFontGlyphs glyphs(final String name) { ++ final String path = this.families.get(name); ++ return path == null || metrics(this.files.get(path), this.renderHeights.getOrDefault(name, 64)) == null ++ ? this.importedGlyphs.get(name) ++ : this.fileGlyphs.computeIfAbsent(path, value -> UiFontGlyphs.read(this.files.get(value))); ++ } ++ ++ Map glyphCoverage() { ++ final Map result = new LinkedHashMap<>(); ++ this.ids().keySet().forEach(name -> { ++ final UiFontGlyphs glyphs = this.glyphs(name); ++ if (glyphs != null) { ++ result.put(name, glyphs.encode()); ++ } ++ }); ++ return Collections.unmodifiableMap(result); ++ } ++ ++ void importGlyphCoverage(final JsonObject coverage) { ++ if (coverage.size() > MAX_FONTS) { ++ throw new IllegalArgumentException("Too many UI font coverage entries"); ++ } ++ final Map parsed = new LinkedHashMap<>(); ++ coverage.entrySet().forEach(entry -> { ++ if (!this.importedIds.containsKey(entry.getKey()) || !entry.getValue().isJsonPrimitive() ++ || !entry.getValue().getAsJsonPrimitive().isString()) { ++ throw new IllegalArgumentException("Invalid UI font coverage entry"); ++ } ++ parsed.put(entry.getKey(), UiFontGlyphs.decode(entry.getValue().getAsString())); ++ }); ++ this.importedGlyphs.putAll(parsed); ++ } ++ + String bitmapCharacters() { + final String characters = this.bitmaps.characters(); + return characters.isEmpty() ? this.importedBitmapCharacters : characters; @@ -15632,10 +15984,10 @@ index 00000000..f191690a +} diff --git a/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/UiBitmapFontsTest.java b/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/UiBitmapFontsTest.java new file mode 100644 -index 00000000..11028d9b +index 00000000..1030ec34 --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/UiBitmapFontsTest.java -@@ -0,0 +1,285 @@ +@@ -0,0 +1,313 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -15652,6 +16004,7 @@ index 00000000..11028d9b +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; ++import java.util.stream.IntStream; +import java.util.zip.CRC32; +import javax.imageio.ImageIO; +import org.junit.jupiter.api.Test; @@ -15667,7 +16020,7 @@ index 00000000..11028d9b + "font/default8.png", atlas, "font/default8_1.png", atlas, "font/default8_2.png", atlas)); + final Map resources = fonts.resources(); + final JsonArray providers = providers(resources); -+ assertEquals(3, providers.size()); ++ assertEquals(4, providers.size()); + for (int page = 0; page < 3; page++) { + final JsonObject provider = providers.get(page).getAsJsonObject(); + assertEquals(12, provider.get("height").getAsInt()); @@ -15763,10 +16116,12 @@ index 00000000..11028d9b + fonts.load(Map.of("font/glyph_FF.png", atlas(1, 0xFF654321))); + final Map resources = fonts.resources(); + final JsonArray providers = providers(resources); -+ assertEquals(3, providers.size()); -+ final int lastPage = java.util.stream.IntStream.range(1, providers.size()) ++ assertEquals(4, providers.size()); ++ final int lastPage = IntStream.range(1, providers.size()) + .filter(page -> codePoint(providers, page, 0) == 0xFF00).findFirst().orElseThrow(); -+ final int firstPage = lastPage == 1 ? 2 : 1; ++ final int firstPage = IntStream.range(1, providers.size()) ++ .filter(page -> providers.get(page).getAsJsonObject().get("file").getAsString().endsWith("glyph_00.png")) ++ .findFirst().orElseThrow(); + assertEquals(0, codePoint(providers, firstPage, 32)); + final JsonObject unicode = providers.get(lastPage).getAsJsonObject(); + assertEquals(0xFFFF, codePoint(providers, lastPage, 255)); @@ -15806,6 +16161,30 @@ index 00000000..11028d9b + } + + @Test ++ void retainsRawControlCellsAlongsidePrintableGlyphsAndKeepsThemOutOfUnicodeFallback() throws IOException { ++ final UiBitmapFonts fonts = new UiBitmapFonts(); ++ fonts.load(Map.of("font/default8.json", metadata(true, null, null), "font/default8.png", atlas(1, 0xFF12AB34), ++ "font/glyph_00.png", atlas(1, 0xFF654321))); ++ final var resources = fonts.resources(); ++ final var providers = providers(resources); ++ final var ordinary = providers.get(0).getAsJsonObject(); ++ final var controls = providers.get(1).getAsJsonObject(); ++ // Native 1.26.60.5 captures raw SOH, TAB, CR and CR/LF, in bitmap and Unicode labels. ++ for (final int character : new int[] {1, 9, 13}) { ++ assertEquals(character, codePoint(providers, 1, character)); ++ assertEquals(ordinary.get("file"), controls.get("file")); ++ assertEquals(image(resources, ordinary).getRGB(character * 8, 0), image(resources, controls).getRGB(character * 8, 0)); ++ } ++ assertEquals(0x00C1, codePoint(providers, 0, 1)); ++ assertEquals(0x00D5, codePoint(providers, 0, 9)); ++ assertEquals(0x00F5, codePoint(providers, 0, 13)); ++ final var unicode = JsonParser.parseString(new String(resources.get("assets/viabedrock/font/unicode.json"), StandardCharsets.UTF_8)) ++ .getAsJsonObject().getAsJsonArray("providers"); ++ assertEquals(1, codePoint(unicode, 1, 1)); ++ assertEquals(0xFF654321, image(resources, unicode.get(1).getAsJsonObject()).getRGB(8, 0)); ++ } ++ ++ @Test + void selectsWholeLabelFallbackFromResolvedPackMappingsAndRetainsItAcrossTransport() throws IOException { + final UiFonts fonts = new UiFonts(); + final byte[] atlas = atlas(1, 0xFFFFFFFF); @@ -15816,7 +16195,8 @@ index 00000000..11028d9b + assertEquals("viabedrock:unicode", fonts.id("default", "A B ÀćŴ жЯ αΩ 日本語")); + assertEquals("viabedrock:unicode", fonts.id("", "A B ἀ")); + assertNull(fonts.id("", "§aA\nB§r")); -+ assertNull(fonts.id("MinecraftTen", "A 日本語")); ++ assertNull(fonts.id("", "W" + (char) 1 + "W")); ++ assertEquals("viabedrock:unicode", fonts.id("MinecraftTen", "A 日本語")); + final UiFonts remote = new UiFonts(); + remote.importBitmapCharacters(fonts.bitmapCharacters()); + assertEquals(fonts.id("", "A B ἀ"), remote.id("", "A B ἀ")); @@ -15866,7 +16246,7 @@ index 00000000..11028d9b + "font/default8_1.png", atlas, "font/default8_2.png", atlas)); + assertNull(fonts.id("", "A B ÀćŴ жЯ αΩ")); + fonts.load(Map.of("font/default8.png", atlas)); -+ assertEquals(1, providers(fonts.bitmapResources()).size()); ++ assertEquals(2, providers(fonts.bitmapResources()).size()); + assertNull(fonts.id("", "A B À αΩ")); + assertEquals("viabedrock:unicode", fonts.id("", "A B ÀćŴ жЯ αΩ")); + } @@ -16141,12 +16521,78 @@ index 00000000..72cb7cd7 + } + +} +diff --git a/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/UiFontGlyphsTest.java b/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/UiFontGlyphsTest.java +new file mode 100644 +index 00000000..1fb2286c +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/UiFontGlyphsTest.java +@@ -0,0 +1,60 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.resourcepack.ui; ++ ++import java.io.ByteArrayOutputStream; ++import java.nio.ByteBuffer; ++import java.util.Base64; ++import java.util.zip.DeflaterOutputStream; ++import org.junit.jupiter.api.Test; ++import static org.junit.jupiter.api.Assertions.*; ++ ++final class UiFontGlyphsTest { ++ ++ @Test ++ void rejectsMissingGlyphsBeforeAndAfterSegmentDeltaAndBoundsTheGlyphArray() { ++ final ByteBuffer table = ByteBuffer.allocate(38); ++ table.putShort(0, (short) 4).putShort(2, (short) 38).putShort(6, (short) 4); ++ table.putShort(8, (short) 4).putShort(10, (short) 1); ++ table.putShort(14, (short) 'Y').putShort(16, (short) 65535); ++ table.putShort(20, (short) 'W').putShort(22, (short) 65535); ++ table.putShort(24, (short) -1).putShort(26, (short) 1); ++ table.putShort(28, (short) 4); ++ table.putShort(32, (short) 1).putShort(34, (short) 0).putShort(36, (short) 3); ++ final var glyphs = UiFontGlyphs.read(UiFontsTest.faceWithCmap(UiFontsTest.cmap(table.array(), 1))); ++ assertFalse(glyphs.supports("W")); // Nonzero glyph plus delta wraps to the missing glyph. ++ assertFalse(glyphs.supports("X")); // A zero glyph must not receive the delta. ++ assertTrue(glyphs.supports("Y")); ++ table.putShort(28, (short) 65534); ++ assertFalse(UiFontGlyphs.read(UiFontsTest.faceWithCmap(UiFontsTest.cmap(table.array(), 1))).supports("Y")); ++ } ++ ++ @Test ++ void transportsSupplementaryCoverageAndKeepsMissingSequentialGlyphsAbsent() { ++ final ByteBuffer table = ByteBuffer.allocate(28); ++ table.putShort(0, (short) 12).putInt(4, 28).putInt(12, 1); ++ table.putInt(16, 0x1F600).putInt(20, 0x1F601).putInt(24, 0); ++ final var glyphs = UiFontGlyphs.read(UiFontsTest.faceWithCmap(UiFontsTest.cmap(table.array(), 10))); ++ final var decoded = UiFontGlyphs.decode(glyphs.encode()); ++ assertFalse(decoded.supports("😀")); ++ assertTrue(decoded.supports("😁")); ++ assertFalse(decoded.supports("😁W")); ++ table.putShort(0, (short) 13); ++ assertFalse(UiFontGlyphs.read(UiFontsTest.faceWithCmap(UiFontsTest.cmap(table.array(), 10))).supports("😁")); ++ } ++ ++ @Test ++ void rejectsTruncatedAndOversizedTransportCoverage() throws Exception { ++ assertThrows(IllegalArgumentException.class, () -> UiFontGlyphs.decode("not base64")); ++ assertThrows(IllegalArgumentException.class, () -> UiFontGlyphs.decode("eJw=")); ++ final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); ++ try (var stream = new DeflaterOutputStream(bytes)) { ++ stream.write(new byte[139265]); ++ } ++ assertThrows(IllegalArgumentException.class, () -> UiFontGlyphs.decode(Base64.getEncoder().encodeToString(bytes.toByteArray()))); ++ } ++ ++} diff --git a/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/UiFontsTest.java b/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/UiFontsTest.java new file mode 100644 -index 00000000..b83ce92b +index 00000000..6cf17b80 --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/UiFontsTest.java -@@ -0,0 +1,192 @@ +@@ -0,0 +1,289 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -16161,6 +16607,10 @@ index 00000000..b83ce92b +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Map; ++import java.util.HashMap; ++import java.util.List; ++import java.util.Set; ++import com.viaversion.viaversion.libs.gson.JsonPrimitive; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + @@ -16256,13 +16706,15 @@ index 00000000..b83ce92b + void resolvesAuthoredFontTypesAfterControllerProgramRoundTrip() { + final JsonUiProgram server = new JsonUiProgram(java.util.List.of(Map.of( + "font/font_metadata.json", metadata("Title", "ttf", "font/title"), -+ "font/title.ttf", face(1000, 990, -250), -+ "ui/example.json", "{\"namespace\":\"example\",\"title\":{\"type\":\"label\",\"text\":\"Heading\",\"font_type\":\"Title\"}}" ++ "font/title.ttf", faceWithCmap(bitmapCmap()), ++ "ui/example.json", "{\"namespace\":\"example\",\"title\":{\"type\":\"label\",\"text\":\"$label\",\"font_type\":\"Title\"}}" + .getBytes(StandardCharsets.UTF_8)))); ++ final Map values = new HashMap<>(); ++ values.put("$label", new JsonPrimitive("WWW")); + final UiBindings bindings = new UiBindings() { + @Override + public Map globals() { -+ return Map.of(); ++ return values; + } + + @Override @@ -16304,11 +16756,102 @@ index 00000000..b83ce92b + assertTrue(before.root().has("font")); + assertEquals(11.8125, before.root().get("font_line_height").getAsDouble()); + final JsonUiProgram client = JsonUiProgram.fromProgram(server.export(before.requiredDefinitions(), new JsonObject())); -+ assertEquals(before.root(), client.resolve("example.title", bindings).root()); ++ final String titleFont = before.root().get("font").getAsString(); ++ for (final String text : List.of("WWW", "W\nW", "W\tW", "W\rW", "W\r\nW", "§cWWW", "W一W", "W☃W", "WéW", "W😀W", "W" + (char) 0 + "一", "W" + (char) 0 + "\nW", ++ "一W" + (char) 0 + "W", "§cW" + (char) 0 + "§aW", "WWW")) { ++ values.put("$label", new JsonPrimitive(text)); ++ final var expected = server.resolve("example.title", bindings).root(); ++ final var actual = client.resolve("example.title", bindings).root(); ++ assertEquals(expected, actual, text); ++ final int terminator = text.indexOf(0); ++ final String display = terminator < 0 ? text : text.substring(0, terminator); ++ assertEquals(display, actual.get("text").getAsString()); ++ assertEquals(text, values.get("$label").getAsString()); ++ if (Set.of("WWW", "W\rW", "§cWWW", "WéW", "W", "§cW").contains(display)) { ++ assertEquals(titleFont, actual.get("font").getAsString(), text); ++ assertEquals(11.8125, actual.get("font_line_height").getAsDouble(), text); ++ } else { ++ assertTrue(!actual.has("font") || !titleFont.equals(actual.get("font").getAsString()), text); ++ assertFalse(actual.has("font_line_height"), text); ++ } ++ } + assertEquals(2, server.fontResources().size()); + assertTrue(client.fontResources().isEmpty()); + } + ++ @Test ++ void fallsBackForWholeStringsAndInvalidatesCoverageWhenTheWinningFileChanges() { ++ final UiFonts fonts = new UiFonts(); ++ fonts.importBitmapCharacters("W"); ++ fonts.load(Map.of("font/font_metadata.json", metadata("Title", "ttf", "font/title"), ++ "font/title.ttf", faceWithCmap(bitmapCmap()))); ++ final String title = fonts.id("Title"); ++ assertEquals(title, fonts.id("Title", "W\rW")); ++ assertEquals(title, fonts.id("Title", "§cWWW")); ++ assertNull(fonts.id("Title", "W\nW")); ++ assertNull(fonts.id("Title", "W\tW")); ++ assertEquals("viabedrock:unicode", fonts.id("Title", "W一W")); ++ assertEquals("viabedrock:unicode", fonts.id("Title", "W😀W")); ++ assertEquals("viabedrock:unicode", fonts.id("Unknown", "W一W")); ++ fonts.load(Map.of("font/title.ttf", faceWithCmap(supplementaryCmap()))); ++ assertEquals(title, fonts.id("Title", "W😀W")); ++ final var ids = new JsonObject(); ++ fonts.ids().forEach(ids::addProperty); ++ final var coverage = new JsonObject(); ++ fonts.glyphCoverage().forEach(coverage::addProperty); ++ final var client = new UiFonts(); ++ client.importIds(ids); ++ client.importBitmapCharacters("W"); ++ client.importGlyphCoverage(coverage); ++ assertEquals(title, client.id("Title", "W😀W")); ++ assertNull(client.id("Title", "W\nW")); ++ coverage.addProperty("Unknown", coverage.get("Title").getAsString()); ++ assertThrows(IllegalArgumentException.class, () -> client.importGlyphCoverage(coverage)); ++ assertEquals(title, client.id("Title", "W😀W")); ++ } ++ ++ static byte[] bitmapCmap() { ++ final ByteBuffer table = ByteBuffer.allocate(48); ++ table.putShort(0, (short) 4).putShort(2, (short) 48).putShort(6, (short) 8); ++ table.putShort(8, (short) 8).putShort(10, (short) 2); ++ final int[] starts = {13, 32, 233, 65535}; ++ final int[] ends = {13, 126, 233, 65535}; ++ for (int i = 0; i < 4; i++) { ++ table.putShort(14 + i * 2, (short) ends[i]); ++ table.putShort(24 + i * 2, (short) starts[i]); ++ table.putShort(32 + i * 2, (short) 1); ++ } ++ return cmap(table.array(), 1); ++ } ++ ++ static byte[] supplementaryCmap() { ++ final ByteBuffer table = ByteBuffer.allocate(52); ++ table.putShort(0, (short) 12).putInt(4, 52).putInt(12, 3); ++ table.putInt(16, 13).putInt(20, 13).putInt(24, 2); ++ table.putInt(28, 32).putInt(32, 126).putInt(36, 3); ++ table.putInt(40, 0x1F600).putInt(44, 0x1F601).putInt(48, 100); ++ return cmap(table.array(), 10); ++ } ++ ++ static byte[] cmap(final byte[] subtable, final int encoding) { ++ final ByteBuffer bytes = ByteBuffer.allocate(12 + subtable.length); ++ bytes.putShort(2, (short) 1).putShort(4, (short) 3).putShort(6, (short) encoding).putInt(8, 12); ++ bytes.position(12); ++ bytes.put(subtable); ++ return bytes.array(); ++ } ++ ++ static byte[] faceWithCmap(final byte[] cmap) { ++ final byte[] original = face(1000, 990, -250); ++ final ByteBuffer bytes = ByteBuffer.allocate(88 + cmap.length); ++ bytes.put(original, 0, 44); ++ bytes.putShort(4, (short) 3).putInt(20, 60).putInt(36, 80); ++ bytes.putInt(44, 0x636D6170).putInt(52, 88).putInt(56, cmap.length); ++ bytes.position(60); ++ bytes.put(original, 44, 28).put(cmap); ++ return bytes.array(); ++ } ++ + private static byte[] metadata(final String name, final String format, final String file) { + final JsonObject root = new JsonObject(); + final JsonObject font = new JsonObject(); diff --git a/patches/viabedrock/upstreamable/0095-share-native-ui-programs-and-resources.pr.md b/patches/viabedrock/upstreamable/0095-share-native-ui-programs-and-resources.pr.md index 898d012a..4253fb15 100644 --- a/patches/viabedrock/upstreamable/0095-share-native-ui-programs-and-resources.pr.md +++ b/patches/viabedrock/upstreamable/0095-share-native-ui-programs-and-resources.pr.md @@ -53,6 +53,27 @@ The scene node budget remains unchanged. Program compilation, resource, and transport tests cover supported content. Transport acceptance does not establish screen interaction or visual parity. +TrueType labels use one font for the visible formatted string. +NUL ends that string before font selection, while the original binding values remain intact. +Missing glyphs select the bitmap or Unicode fallback and exclude the declared TrueType line metrics. +Compressed Unicode coverage accompanies exported font identifiers, so bound-text refresh retains this decision on both routes. +Native Bedrock 1.26.60.5 MinecraftTen controls cover newline, tab, carriage return, space, formatting, accent, Han, and snowman text. +The parser matches all eight captured selections with the original font bytes, including after coverage transport. + +Native controls also cover all 32 C0 inputs in bitmap and MinecraftTen labels, plus distinguishing NUL suffixes. +Bitmap conversion retains raw control cells alongside printable aliases. CR/LF keeps the CR glyph on the first line. +The conversion namespace invalidates older fonts without these aliases. + +The installed add-on matches all 63 selected bitmap glyph masks at 1280 × 720 and native UI scale 2. +Java GUI scales 1, 2, 3, and return to 1 retain these masks. +The complete 180-second replay preserves the source scene payload and verifies the installed JAR. + +Regressions cover bound-text changes across program transport, winning-file replacement, missing glyphs before and after delta adjustment, and supplementary coverage. +Malformed tables and truncated or oversized coverage are rejected or select the bitmap fallback. +Disabling coverage selection makes the binding-refresh regression fail. +The target native font uses cmap format 4. Supplementary format-12 behavior has synthetic coverage and still needs matching native controls. +Complete TrueType raster appearance and native font-type aliases remain open. + Dynamic domain text uses the same native palette as ordinary labels. Help regressions cover translated components and exported program round trips. 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 a9362eab..870543ce 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 ae86da7da7391a427c99bddc0d41034e66bc4ce3 Mon Sep 17 00:00:00 2001 +From b29d270a60bb1422195d36d5fdcec2908ae11317 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 @@ -744,6 +744,11 @@ path. Preserve fractional layout bounds and native parent intersections instead of rounding them to Java GUI units before extraction. Container items, labels, portraits and nested inputs retain their existing ownership and share the same bounded render-state clipping lifecycle. + +Use inherited scissors for admitted container labels, matching the shared +scene renderer. The shared renderer limits complete text rows and fits the +last-row marker. Native Bedrock 1.26.60.5 MinecraftTen glyphs can extend +past a label's own height while retaining the parent clip. --- .../bedrock/ViaFabricPlusBedrock.java | 4 + .../access/IBedrockGuiItemStyle.java | 14 + @@ -757,7 +762,7 @@ and share the same bounded render-state clipping lifecycle. .../render/MixinNativeGuiItemState.java | 20 + .../render/MixinNativeGuiOversizedItem.java | 31 + .../container/BedrockFurnacePreferences.java | 60 + - .../BedrockNativeContainerScreen.java | 1331 ++++++++++++++++ + .../BedrockNativeContainerScreen.java | 1330 ++++++++++++++++ .../container/BedrockNativeContainers.java | 283 ++++ .../container/BedrockNativeCooking.java | 147 ++ .../BedrockNativePlayerInventory.java | 171 ++ @@ -828,7 +833,7 @@ and share the same bounded render-state clipping lifecycle. .../container/NativePlayerScreenTest.java | 576 +++++++ .../PlayerInventoryAdmissionTest.java | 147 ++ .../PlayerInventoryScreenLifecycleTest.java | 139 ++ - 83 files changed, 11236 insertions(+), 62 deletions(-) + 83 files changed, 11235 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 @@ -1378,10 +1383,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..21b1379 +index 0000000..dbee3db --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockNativeContainerScreen.java -@@ -0,0 +1,1331 @@ +@@ -0,0 +1,1330 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -2080,8 +2085,7 @@ index 0000000..21b1379 + graphics.nextStratum(); + layer = box.layer(); + } -+ final var clip = -+ box.node().type().equals("label") ? box.visibleBounds() : box.clip(); ++ final var clip = box.clip(); + NativeUiScissor.enable(graphics, clip); + try { + final String custom = box.node().string("renderer", ""); 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 d2cd74fc..818adb2a 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 @@ -146,3 +146,6 @@ The [owning patch](0029-present-authored-native-containers.patch) retains implem Authored container draws use the [shared physical-pixel scissor path](0029-share-native-ui-scene-rendering.pr.md). This preserves fractional layout bounds for labels, items, portraits, and nested inputs. The selected native glyph comparisons cover static form controls. Complete container GPU parity remains open. + +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. diff --git a/patches/viafabricplus-bedrock/upstreamable/0029-share-native-ui-scene-rendering.patch b/patches/viafabricplus-bedrock/upstreamable/0029-share-native-ui-scene-rendering.patch index a974e3ef..2a82e1b2 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0029-share-native-ui-scene-rendering.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0029-share-native-ui-scene-rendering.patch @@ -1,4 +1,4 @@ -From 69d6456e9fe03b3362b9f2c5a1e6463e625af71a Mon Sep 17 00:00:00 2001 +From 0e8dda882ad4bd0256d97d5c10903ec8374719aa 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(ui): share native scene rendering and GUI actors @@ -816,6 +816,34 @@ Native glyph-mask fixtures and a batch-boundary regression cover the conversion. Short authored label heights suppress the native text in a separate control; that text-layout rule and complete screen appearance remain outside this scissor correction. + +Native Bedrock 1.26.60.5 with BDS 1.26.60.6, protocol 2225, admits +bitmap label rows at heights 8, 18, and 28 times the font scale. A captured +MinecraftTen pack admits rows at 10, 22, and 34, including the adjacent +lower floats at scales 0.5, 1, and 1.5. Admission rounds the line metric, +while glyph placement retains the fractional 11.8125-unit advance. Keep +these operations separate and avoid float addition that rounds a rejected +height onto a boundary. Measure the full wrapped text independently. + +Append a width-fitted three-dot marker to the last admitted line. Preserve +the original line-ending style, including empty trailing formatting runs, +and omit a marker that cannot fit. Cache this display with the wrapped +text so unchanged frames do not repeat splitting or glyph-width work. +Admitted glyphs use the inherited parent scissor rather than clipping to +the label's own height. Native MinecraftTen pixels cross that local edge. +Whole-string font fallback, carriage-return handling, other TrueType +families, and complete authored-screen parity remain separate open work. + +Keep carriage return as a label glyph, including before newline. The +matching MinecraftTen cmap maps CR to a blank glyph, and native Bedrock +1.26.60.5 keeps W/CR/W on one line. Split label paragraphs only at newline +for both measurement and drawing. Preserve the separate tooltip CR/CRLF +normalization contract. + +Terminate label component traversal at NUL across styled runs. Native +1.26.60.5 paints only the prefix and ignores formatting changes after +the terminator. Apply the same prefix to intrinsic measurement without +changing tooltip paragraph normalization. --- .../bedrock/ViaFabricPlusBedrock.java | 4 + .../appearance/BedrockBoneAnimation.java | 3 + @@ -868,7 +896,7 @@ remain outside this scissor correction. .../bedrock/screen/ui/NativeUiDimension.java | 93 ++ .../bedrock/screen/ui/NativeUiFlipbook.java | 47 + .../bedrock/screen/ui/NativeUiFocus.java | 329 ++++++ - .../bedrock/screen/ui/NativeUiFont.java | 58 + + .../bedrock/screen/ui/NativeUiFont.java | 66 ++ .../bedrock/screen/ui/NativeUiGraphics.java | 56 + .../screen/ui/NativeUiHurtPayload.java | 22 + .../bedrock/screen/ui/NativeUiImageCache.java | 229 ++++ @@ -881,14 +909,14 @@ remain outside this scissor correction. .../screen/ui/NativeUiMultilineInput.java | 267 +++++ .../screen/ui/NativeUiPlayerCamera.java | 72 ++ .../screen/ui/NativeUiPresentation.java | 162 +++ - .../bedrock/screen/ui/NativeUiRenderer.java | 157 +++ + .../bedrock/screen/ui/NativeUiRenderer.java | 182 +++ .../bedrock/screen/ui/NativeUiScene.java | 107 ++ .../screen/ui/NativeUiSceneScreen.java | 822 ++++++++++++++ .../bedrock/screen/ui/NativeUiScissor.java | 73 ++ .../bedrock/screen/ui/NativeUiScreen.java | 13 + .../bedrock/screen/ui/NativeUiScroll.java | 69 ++ .../bedrock/screen/ui/NativeUiSlider.java | 123 ++ - .../bedrock/screen/ui/NativeUiText.java | 64 ++ + .../bedrock/screen/ui/NativeUiText.java | 114 ++ .../bedrock/screen/ui/NativeUiToggle.java | 94 ++ .../bedrock/screen/ui/NativeUiViewport.java | 32 + .../bedrock/settings/base/NativeUiScale.java | 27 + @@ -932,19 +960,19 @@ remain outside this scissor correction. .../screen/ui/NativeUiMultilineInputTest.java | 210 ++++ .../screen/ui/NativeUiPlayerCameraTest.java | 132 +++ .../screen/ui/NativeUiPresentationTest.java | 258 +++++ - .../screen/ui/NativeUiRendererTest.java | 85 ++ + .../screen/ui/NativeUiRendererTest.java | 173 +++ .../NativeUiSceneScreenInteractionTest.java | 359 ++++++ .../bedrock/screen/ui/NativeUiSceneTest.java | 40 + .../screen/ui/NativeUiScissorTest.java | 69 ++ .../ui/NativeUiScrollbarLayoutTest.java | 107 ++ .../bedrock/screen/ui/NativeUiSliderTest.java | 120 ++ - .../bedrock/screen/ui/NativeUiTextTest.java | 56 + + .../bedrock/screen/ui/NativeUiTextTest.java | 100 ++ .../ui/NativeUiTextureAnimationTest.java | 108 ++ .../bedrock/screen/ui/NativeUiToggleTest.java | 86 ++ .../screen/ui/NativeUiViewportTest.java | 95 ++ .../native-scrollbar-visibility.json | 162 +++ .../resources/native-ui/scissor-2225.json | 622 ++++++++++ - 127 files changed, 14726 insertions(+), 37 deletions(-) + 127 files changed, 14941 insertions(+), 37 deletions(-) create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockGuiActorAppearance.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockGuiActorRenderer.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockGuiScissor.java @@ -5359,10 +5387,10 @@ index 0000000..0d64363 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiFont.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiFont.java new file mode 100644 -index 0000000..73691f2 +index 0000000..1960ce6 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiFont.java -@@ -0,0 +1,58 @@ +@@ -0,0 +1,66 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -5410,6 +5438,14 @@ index 0000000..73691f2 + return height; + } + ++ /** Native labels admit rounded rows with a two-unit allowance on the final row. */ ++ public static int visibleLines(final NativeUiScene.Node node, final float height) { ++ final float scale = scale(node); ++ if (scale == 0 || height < 0) return 0; ++ // Retain precision at the preceding float: float addition can round it onto the boundary. ++ return Math.max(0, (int) Math.floor(((double) height / scale + 2) / Math.ceil(lineHeight(node)))); ++ } ++ + public static NativeUiLayout.Size intrinsic(final NativeUiScene.Node node, final float width, + final int lines) { + final float scale = scale(node); @@ -7625,10 +7661,10 @@ index 0000000..99d79fd +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiRenderer.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiRenderer.java new file mode 100644 -index 0000000..0480b87 +index 0000000..476c650 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiRenderer.java -@@ -0,0 +1,157 @@ +@@ -0,0 +1,182 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -7638,6 +7674,7 @@ index 0000000..0480b87 + +import com.mojang.serialization.JsonOps; +import java.util.IdentityHashMap; ++import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; @@ -7686,7 +7723,7 @@ index 0000000..0480b87 + @Override + public NativeUiLayout.Size constrained(final NativeUiScene.Node node, final float width) { + if (!node.type().equals("label")) return this.intrinsic(node); -+ final int lines = this.lines(node, this.text(node), NativeUiFont.wrapWidth(node, width)).size(); ++ final int lines = this.lines(node, this.text(node), NativeUiFont.wrapWidth(node, width)).lines.size(); + return NativeUiFont.constrained(node, width, lines); + } + @@ -7700,7 +7737,7 @@ index 0000000..0480b87 + public NativeUiLayout.Size intrinsic(final NativeUiScene.Node node) { + return switch (node.type()) { + case "label" -> { -+ final var paragraphs = NativeUiText.paragraphs(this.text(node)); ++ final var paragraphs = NativeUiText.labelParagraphs(this.text(node)); + final int width = paragraphs.stream().mapToInt(this.font::width).max().orElse(0); + yield NativeUiFont.intrinsic(node, width, paragraphs.size()); + } @@ -7723,7 +7760,7 @@ index 0000000..0480b87 + final float scale = NativeUiFont.scale(node); + if (scale == 0) return; + final int width = NativeUiFont.wrapWidth(node, box.bounds().width()); -+ final var lines = this.lines(node, text, width); ++ final var lines = this.lines(node, text, width).visible(this.font, NativeUiFont.visibleLines(node, box.bounds().height())); + final String alignment = node.string("text_alignment", "left"); + final int uiScale = NativeUiViewport.current().scale(); + float y = box.bounds().y(); @@ -7743,15 +7780,39 @@ index 0000000..0480b87 + } + } + -+ private List lines(final NativeUiScene.Node node, final Component text, final int width) { ++ private WrappedText lines(final NativeUiScene.Node node, final Component text, final int width) { + final var previous = this.wrapped.get(node); -+ if (previous != null && previous.text == text && previous.width == width) return previous.lines; -+ final var lines = NativeUiText.tooltipLines(this.font, text, width); -+ this.wrapped.put(node, new WrappedText(text, width, lines)); -+ return lines; ++ if (previous != null && previous.text == text && previous.width == width) return previous; ++ final var value = new WrappedText(text, width, NativeUiText.labelLines(this.font, text, width)); ++ this.wrapped.put(node, value); ++ return value; + } + -+ private record WrappedText(Component text, int width, List lines) {} ++ private static final class WrappedText { ++ final Component text; ++ final int width; ++ final List lines; ++ private int visible = -1; ++ private List display = List.of(); ++ ++ WrappedText(final Component text, final int width, final List lines) { ++ this.text = text; ++ this.width = width; ++ this.lines = lines; ++ } ++ ++ List visible(final Font font, final int limit) { ++ final int count = Math.min(limit, this.lines.size()); ++ if (this.visible == count) return this.display; ++ final List result = new ArrayList<>(count); ++ for (int i = 0; i < count; i++) { ++ final var line = this.lines.get(i); ++ result.add(i + 1 == count && count < this.lines.size() ? line.ellipsis(font, this.width) : line.text()); ++ } ++ this.visible = count; ++ return this.display = List.copyOf(result); ++ } ++ } + + public Component text(final NativeUiScene.Node node) { + return this.texts.computeIfAbsent(node, value -> { @@ -7901,7 +7962,7 @@ index 0000000..e0ed675 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiSceneScreen.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiSceneScreen.java new file mode 100644 -index 0000000..20c0bd6 +index 0000000..05f9080 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiSceneScreen.java @@ -0,0 +1,822 @@ @@ -8119,7 +8180,7 @@ index 0000000..20c0bd6 + NativeUiScene.Node tooltip = null; + for (final NativeUiPresentation.Draw draw : draws) { + final var box = draw.box(); -+ final var clip = box.node().type().equals("label") ? box.visibleBounds() : box.clip(); ++ final var clip = box.clip(); + if (draw.enabled() && draw.alpha() > 0 && box.node().type().equals("custom") + && box.node().string("renderer", "").equals("hover_text_renderer") + && !box.node().string("hover_text", "").isEmpty()) tooltip = box.node(); @@ -9031,10 +9092,10 @@ index 0000000..9726a32 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiText.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiText.java new file mode 100644 -index 0000000..f1f207d +index 0000000..c21e081 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiText.java -@@ -0,0 +1,64 @@ +@@ -0,0 +1,114 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -9068,7 +9129,53 @@ index 0000000..f1f207d + return List.copyOf(result); + } + ++ static List labelLines(final Font font, final Component text, final int width) { ++ final List result = new ArrayList<>(); ++ for (final var paragraph : labelParagraphs(text)) { ++ final var wrapped = font.split(paragraph, width); ++ // paragraphs() retains an empty styled run after a trailing formatting change. ++ final Style ending = paragraph.getSiblings().isEmpty() ? paragraph.getStyle() ++ : paragraph.getSiblings().getLast().getStyle(); ++ if (wrapped.isEmpty()) result.add(new Line(FormattedCharSequence.EMPTY, ending)); ++ for (int i = 0; i < wrapped.size(); i++) { ++ final var line = wrapped.get(i); ++ final Style[] style = {ending}; ++ if (i + 1 < wrapped.size()) line.accept((index, current, codePoint) -> { style[0] = current; return true; }); ++ result.add(new Line(line, style[0])); ++ } ++ } ++ return List.copyOf(result); ++ } ++ ++ record Line(FormattedCharSequence text, Style ending) { ++ FormattedCharSequence ellipsis(final Font font, final int width) { ++ final var marker = FormattedCharSequence.forward("...", this.ending); ++ final float markerWidth = font.getSplitter().stringWidth(marker); ++ if (markerWidth > width) return FormattedCharSequence.EMPTY; ++ final List parts = new ArrayList<>(); ++ final float[] remaining = {width - markerWidth}; ++ this.text.accept((index, style, codePoint) -> { ++ final var glyph = FormattedCharSequence.codepoint(codePoint, style); ++ final float advance = font.getSplitter().stringWidth(glyph); ++ if (advance > remaining[0]) return false; ++ remaining[0] -= advance; ++ parts.add(glyph); ++ return true; ++ }); ++ parts.add(marker); ++ return FormattedCharSequence.composite(parts); ++ } ++ } ++ ++ static List labelParagraphs(final Component text) { ++ return paragraphs(text, false); ++ } ++ + static List paragraphs(final Component text) { ++ return paragraphs(text, true); ++ } ++ ++ private static List paragraphs(final Component text, final boolean normalizeCarriageReturns) { + final List lines = new ArrayList<>(); + final MutableComponent[] current = {Component.empty()}; + final boolean[] carriageReturn = {false}; @@ -9076,12 +9183,16 @@ index 0000000..f1f207d + final StringBuilder run = new StringBuilder(); + for (int i = 0; i < value.length(); i++) { + final char character = value.charAt(i); -+ if (carriageReturn[0] && character == '\n') { ++ if (!normalizeCarriageReturns && character == 0) { ++ current[0].append(Component.literal(run.toString()).withStyle(style)); ++ return Optional.of(Boolean.TRUE); ++ } ++ if (normalizeCarriageReturns && carriageReturn[0] && character == '\n') { + carriageReturn[0] = false; + continue; + } -+ carriageReturn[0] = character == '\r'; -+ if (character == '\n' || character == '\r') { ++ carriageReturn[0] = normalizeCarriageReturns && character == '\r'; ++ if (character == '\n' || normalizeCarriageReturns && character == '\r') { + current[0].append(Component.literal(run.toString()).withStyle(style)); + run.setLength(0); + lines.add(current[0]); @@ -15025,10 +15136,10 @@ index 0000000..d8bc9ec +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiRendererTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiRendererTest.java new file mode 100644 -index 0000000..f38690b +index 0000000..5bf1740 --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiRendererTest.java -@@ -0,0 +1,85 @@ +@@ -0,0 +1,173 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -15039,6 +15150,12 @@ index 0000000..f38690b +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.util.List; ++import java.util.ArrayList; ++import net.minecraft.client.StringSplitter; ++import net.minecraft.client.gui.GuiGraphicsExtractor; ++import net.minecraft.locale.Language; ++import net.minecraft.network.chat.Style; ++import org.joml.Matrix3x2fStack; +import java.util.UUID; +import net.minecraft.SharedConstants; +import net.minecraft.client.gui.Font; @@ -15072,6 +15189,9 @@ index 0000000..f38690b + final var escaped = label(first + "\\n" + second); + assertEquals(Math.ceil((first.length() + second.length() + 2) * 6.5F), renderer.intrinsic(escaped).width()); + assertEquals(10, renderer.intrinsic(escaped).height()); ++ final var terminated = label("WW" + (char) 0 + "WWW\nWWW"); ++ assertEquals(13, renderer.intrinsic(terminated).width()); ++ assertEquals(10, renderer.intrinsic(terminated).height()); + } + + @Test @@ -15108,6 +15228,85 @@ index 0000000..f38690b + assertEquals(0x0000FF19, NativeUiRenderer.color(node, -.1F)); + } + ++ @Test ++ void submitsOnlyTheNativeAdmittedLinesAtAdjacentFloatBoundaries() { ++ // Native 1.26.60.5 bitmap and MinecraftTen captures, including scaled second and third rows. ++ final float[][] samples = { ++ {10, .5F, 4, 1}, {10, 1, 8, 1}, {10, 1.5F, 12, 1}, ++ {10, 1, 18, 2}, {10, 1, 28, 3}, ++ {11.8125F, .5F, 5, 1}, {11.8125F, .5F, 11, 2}, {11.8125F, .5F, 17, 3}, ++ {11.8125F, 1, 10, 1}, {11.8125F, 1, 22, 2}, {11.8125F, 1, 34, 3}, ++ {11.8125F, 1.5F, 15, 1}, {11.8125F, 1.5F, 33, 2}, {11.8125F, 1.5F, 51, 3} ++ }; ++ try (final var viewport = mockStatic(NativeUiViewport.class)) { ++ viewport.when(NativeUiViewport::current).thenReturn(new NativeUiViewport(640, 360, 2, 2)); ++ for (final var sample : samples) { ++ final var node = label("WWW\nWWW\nWWW"); ++ node.properties().addProperty("font_line_height", sample[0]); ++ node.properties().addProperty("font_scale_factor", sample[1]); ++ final var font = drawingFont(); ++ final var renderer = new NativeUiRenderer(font, mock(NativeUiImages.class)); ++ final var submitted = new ArrayList(); ++ final var graphics = graphics(font, submitted, new ArrayList<>()); ++ renderer.label(graphics, box(node, Math.nextDown(sample[2])), 1); ++ assertEquals((int) sample[3] - 1, submitted.size(), "preceding float: " + sample[2]); ++ submitted.clear(); ++ renderer.label(graphics, box(node, sample[2]), 1); ++ assertEquals((int) sample[3], submitted.size(), "exact boundary: " + sample[2]); ++ if (sample[3] < 3) assertTrue(submitted.getLast().endsWith("...")); ++ else assertEquals(List.of("WWW", "WWW", "WWW"), submitted); ++ // Height admission does not reduce the complete wrapped measurement or rebuild it on resize. ++ assertEquals(3 * sample[0] * sample[1], renderer.constrained(node, 70).height()); ++ verify(font, times(3)).split(any(FormattedText.class), anyInt()); ++ } ++ } ++ } ++ ++ @Test ++ void keepsFractionalGlyphSpacingWhenAdmissionUsesRoundedRows() { ++ try (final var viewport = mockStatic(NativeUiViewport.class)) { ++ viewport.when(NativeUiViewport::current).thenReturn(new NativeUiViewport(640, 360, 2, 2)); ++ final var node = label("WWW\nWWW\nWWW"); ++ node.properties().addProperty("font_line_height", 11.8125F); ++ final var font = drawingFont(); ++ final var positions = new ArrayList(); ++ new NativeUiRenderer(font, mock(NativeUiImages.class)).label( ++ graphics(font, new ArrayList<>(), positions), box(node, 34), 1); ++ assertEquals(List.of(1F, 12.5F, 24.5F), positions); ++ } ++ } ++ ++ private static NativeUiLayout.Box box(final NativeUiScene.Node node, final float height) { ++ final var bounds = new NativeUiLayout.Rect(0, 0, 70, height); ++ return new NativeUiLayout.Box(node, bounds, new NativeUiLayout.Rect(0, 0, 640, 360), ++ new NativeUiLayout.Size(70, 30), bounds, 0, List.of()); ++ } ++ ++ private static Font drawingFont() { ++ final var font = mock(Font.class); ++ final var splitter = new StringSplitter((codePoint, style) -> codePoint == '.' ? 2 : 6); ++ when(font.getSplitter()).thenReturn(splitter); ++ when(font.width(any(FormattedCharSequence.class))).thenAnswer(call -> (int) Math.ceil(splitter.stringWidth((FormattedCharSequence) call.getArgument(0)))); ++ when(font.split(any(FormattedText.class), anyInt())).thenAnswer(call -> splitter ++ .splitLines((FormattedText) call.getArgument(0), (int) call.getArgument(1), Style.EMPTY).stream() ++ .map(Language.getInstance()::getVisualOrder).toList()); ++ return font; ++ } ++ ++ private static GuiGraphicsExtractor graphics(final Font font, final List text, final List positions) { ++ final var graphics = mock(GuiGraphicsExtractor.class); ++ final var pose = new Matrix3x2fStack(16); ++ when(graphics.pose()).thenReturn(pose); ++ doAnswer(call -> { ++ final StringBuilder value = new StringBuilder(); ++ ((FormattedCharSequence) call.getArgument(1)).accept((index, style, codePoint) -> { value.appendCodePoint(codePoint); return true; }); ++ text.add(value.toString()); ++ positions.add(pose.m21()); ++ return null; ++ }).when(graphics).text(eq(font), any(FormattedCharSequence.class), eq(0), eq(0), anyInt(), eq(false)); ++ return graphics; ++ } ++ + private static NativeUiScene.Node label(final String value) { + final var properties = new JsonObject(); + properties.addProperty("text", value); @@ -15841,10 +16040,10 @@ index 0000000..4e7a747 +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiTextTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiTextTest.java new file mode 100644 -index 0000000..9ebdb57 +index 0000000..3e8c2f8 --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiTextTest.java -@@ -0,0 +1,56 @@ +@@ -0,0 +1,100 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -15886,6 +16085,50 @@ index 0000000..9ebdb57 + assertEquals(1, NativeUiText.paragraphs(Component.literal("literal\\ntext")).size()); + } + ++ @Test ++ void truncationRetainsOriginalLineEndingStyleAndReservesTheWholeMarker() { ++ final var font = mock(Font.class); ++ final var splitter = new StringSplitter((codePoint, style) -> codePoint == '.' ? 2 : 6); ++ when(font.getSplitter()).thenReturn(splitter); ++ when(font.split(any(FormattedText.class), anyInt())).thenAnswer(call -> splitter ++ .splitLines((FormattedText) call.getArgument(0), (int) call.getArgument(1), Style.EMPTY).stream() ++ .map(Language.getInstance()::getVisualOrder).toList()); ++ final var text = Component.literal("WWW").withStyle(ChatFormatting.RED) ++ .append(Component.literal("\nWWW").withStyle(ChatFormatting.GREEN)); ++ final var line = NativeUiText.labelLines(font, text, 70).getFirst(); ++ assertEquals("WW...", plain(line.ellipsis(font, 20))); ++ final var styles = new ArrayList