diff --git a/.gitattributes b/.gitattributes index 72a8316e..c88459d0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,3 +2,4 @@ *.patch text eol=lf -whitespace *.json text eol=lf *.ts text eol=lf +native/oreui-shaper/COPYING text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 507c5f7c..55a73e09 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,9 @@ permissions: contents: read jobs: + oreui_shaper: + uses: ./.github/workflows/oreui-shaper.yml + replay_permissions: strategy: fail-fast: false @@ -61,7 +64,13 @@ jobs: - run: cargo test --locked --manifest-path native/capture-x11/Cargo.toml --target-dir .stackanvil/tools/capture-x11-build -- --include-ignored build: - runs-on: ubuntu-latest + needs: oreui_shaper + runs-on: ubuntu-24.04 + env: + STACKANVIL_NATIVE_FONT_GPU_TESTS: "1" + EGL_PLATFORM: surfaceless + LIBGL_ALWAYS_SOFTWARE: "1" + MESA_LOADER_DRIVER_OVERRIDE: llvmpipe steps: - uses: actions/checkout@v7 - uses: oven-sh/setup-bun@v2 @@ -72,6 +81,13 @@ jobs: 17 25 - run: bun install --frozen-lockfile + - name: Install software font rendering + run: sudo apt-get update && sudo apt-get install -y libegl1 libgl1-mesa-dri + - uses: actions/download-artifact@v8 + with: + pattern: oreui-shaper-* + merge-multiple: true + path: .stackanvil/tools/oreui-shaper-resources - run: bun run validate build - name: Summarize build validation if: always() diff --git a/.github/workflows/oreui-shaper.yml b/.github/workflows/oreui-shaper.yml new file mode 100644 index 00000000..efb5a854 --- /dev/null +++ b/.github/workflows/oreui-shaper.yml @@ -0,0 +1,55 @@ +name: Build OreUI shaping engine + +on: + workflow_call: + workflow_dispatch: + +permissions: + contents: read + +jobs: + native: + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-22.04 + target: linux-x64 + arguments: "" + - os: ubuntu-22.04-arm + target: linux-arm64 + arguments: "" + - os: windows-2025 + target: windows-x64 + arguments: -A x64 + - os: windows-11-arm + target: windows-arm64 + arguments: -A ARM64 + - os: macos-15-intel + target: macos-x64 + arguments: -DCMAKE_OSX_DEPLOYMENT_TARGET=13.0 + - os: macos-15 + target: macos-arm64 + arguments: -DCMAKE_OSX_DEPLOYMENT_TARGET=13.0 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + - uses: oven-sh/setup-bun@v2 + - name: Configure compatible engine + shell: bash + run: cmake -S native/oreui-shaper -B .stackanvil/tools/oreui-shaper-build -DCMAKE_BUILD_TYPE=Release ${{ matrix.arguments }} + - name: Build and test shaping + shell: bash + run: | + cmake --build .stackanvil/tools/oreui-shaper-build --config Release --target oreui-shaping-test --parallel 2 + ctest --test-dir .stackanvil/tools/oreui-shaper-build -C Release --output-on-failure + - name: Install and verify resources + shell: bash + run: | + cmake --install .stackanvil/tools/oreui-shaper-build --config Release --prefix "$PWD/.stackanvil/tools/oreui-shaper-resources" --component OreUiShaper + bun src/oreui-shaper.ts .stackanvil/tools/oreui-shaper-resources ${{ matrix.target }} + - uses: actions/upload-artifact@v7 + with: + name: oreui-shaper-${{ matrix.target }} + path: .stackanvil/tools/oreui-shaper-resources/ + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f30cc27e..6030d6b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,8 +13,17 @@ concurrency: cancel-in-progress: false jobs: + oreui_shaper: + uses: ./.github/workflows/oreui-shaper.yml + build: - runs-on: ubuntu-latest + needs: oreui_shaper + runs-on: ubuntu-24.04 + env: + STACKANVIL_NATIVE_FONT_GPU_TESTS: "1" + EGL_PLATFORM: surfaceless + LIBGL_ALWAYS_SOFTWARE: "1" + MESA_LOADER_DRIVER_OVERRIDE: llvmpipe steps: - uses: actions/checkout@v7 - uses: oven-sh/setup-bun@v2 @@ -25,6 +34,13 @@ jobs: 17 25 - run: bun install --frozen-lockfile + - name: Install software font rendering + run: sudo apt-get update && sudo apt-get install -y libegl1 libgl1-mesa-dri + - uses: actions/download-artifact@v8 + with: + pattern: oreui-shaper-* + merge-multiple: true + path: .stackanvil/tools/oreui-shaper-resources - run: bun run validate release - name: Summarize release validation if: always() diff --git a/assets/bedrock/1.26.60.5/assets/viafabricplus-bedrock/builtin_oreui.zip b/assets/bedrock/1.26.60.5/assets/viafabricplus-bedrock/builtin_oreui.zip new file mode 100644 index 00000000..f0579eac Binary files /dev/null and b/assets/bedrock/1.26.60.5/assets/viafabricplus-bedrock/builtin_oreui.zip differ diff --git a/assets/bedrock/1.26.60.5/assets/viafabricplus-bedrock/builtin_oreui.zip.manifest.json b/assets/bedrock/1.26.60.5/assets/viafabricplus-bedrock/builtin_oreui.zip.manifest.json new file mode 100644 index 00000000..d723e97e --- /dev/null +++ b/assets/bedrock/1.26.60.5/assets/viafabricplus-bedrock/builtin_oreui.zip.manifest.json @@ -0,0 +1,6 @@ +{ + "format": 1, + "version": "1.26.60.5", + "size": 26192330, + "sha256": "d2578bdc1ddf57dc88df48c9451c75dac75491cddfd188d7d1cccc196242ee9a" +} diff --git a/docs/bedrock-coverage.md b/docs/bedrock-coverage.md index 4039a9ed..395bf703 100644 --- a/docs/bedrock-coverage.md +++ b/docs/bedrock-coverage.md @@ -25,15 +25,15 @@ No complete area has verified Bedrock parity. | Protocol 2225 | Updated packet layouts, explicit enum values, stonecutter notifications, cursor drags, skin identity, and sound unions. | Block seating, signed content playback, matchmaking UI, and environment layers remain partial or unsupported. | | World resources | World base-game version selects vanilla metadata, textures, actor graphs, and audio before server overrides. The context survives ViaProxy. Server legacy biome bindings retain unqualified names across versions. | Native current/older/current cat, sound, and remote biome-name controls cover selected paths. Broader pose and audio behavior need comparisons. | | Held block placement | Retained stationary build paths, next-cell ray selection, independent fresh block/air admission, frame retries after rejection, use timing, trapdoor orientation, cancellation, and prediction. | Native, direct, and proxy controls cover selected stationary paths, first rejection, last-stack placement, activation, re-aiming, and release. Moving placement, air/entity dispatch, build-height limits, and broader input scheduling remain open. | -| Fonts and UI | Native bitmap metrics and italic geometry, authored scenes, TrueType cache and atlas sampling, and primary font aliases. | Selected bitmap and TrueType controls match at 1280 × 720. [Validation and limits](../patches/viafabricplus-bedrock/upstreamable/0029-share-native-ui-scene-rendering.pr.md). Complete screens, bold and shadowed text, other font families, mixed or scaled aliases, and broader viewports remain open. | +| Fonts and UI | Native bitmap metrics and italic geometry, authored scenes, TrueType cache and atlas sampling, primary font aliases, and OreUI shaping, glyph pipelines, and original animation decoding. [Death reasons follow the visibility game rule](../patches/viabedrock/upstreamable/0111-honor-death-message-visibility.pr.md) and [retain the current screen](../patches/viafabricplus-bedrock/upstreamable/0042-retain-bedrock-death-screen-lifetimes.pr.md). | Selected bitmap and TrueType controls match at 1280 × 720. [Validation and limits](../patches/viafabricplus-bedrock/upstreamable/0029-share-native-ui-scene-rendering.pr.md). Complete screens, OreUI layout and fallback, bold and shadowed text, other font families, mixed or scaled aliases, and broader viewports remain open. | | Player inventory and crafting | Opening ownership, recipe catalogs, Creative catalog actions, Survival bulk and manual crafting actions, retained recipe queries, compressed snapshots, local Help, inactive item lighting, native storage weights, desktop bundle insertion/default extraction, and native stack identities. | Complete E-key/workbench presentation, bundle controller and tooltip lifetimes, query lifetime across connections, broader Survival recipes, and original live bug reports remain unresolved. See the [inventory reference](native-player-inventory.md). | -| Food use | Native components govern admission and Java food/consumable properties, with Creative and Peaceful exceptions. Local use retains native item identity across Java carriers. | Selected admission, input-deadline, timed-food, held-mode, release, and pause controls pass. Short input edges during active Java use, broader use cycles, and the original intermittent report remain open. See the [food-use evidence](../patches/viabedrock/deferred/0008-signal-sustained-bedrock-item-use.pr.md). | -| Cooking | Snapshot-bound recipe actions, transfer batches, preferences, search, and progress. | Default cooking screens retain ordinary controls until authored bindings are complete. See the [cooking reference](native-cooking-actions.md). | -| Skins and appearance | Complete skin transport, classic changes, persona assembly, animation graphs, costumes, and attachables. | Broader wardrobe masking, profile/LOD changes, shield rendering, remote appearances, and account/platform flows need further native comparisons. See [skin support](skin-flow.md). | -| Camera | Core-resolved poses, follow targets, blends, FOV commands, shake, fades, and native ordinary player FOV. See [camera validation](../patches/viafabricplus-bedrock/upstreamable/0023-render-core-resolved-camera-movement.pr.md). | Selected stationary projections match native. Dynamic swim expiry, fluid/death composition, portrait scaling, transition timing, collision, aim assistance, and complete visual parity remain open. | -| Movement and effects | Completed physics frames, retained prediction history, correction replay, terrain queries, and authoritative impulses. [Local modes survive profile refreshes and physics replay](../patches/viafabricplus-bedrock/upstreamable/0025-send-completed-local-physics-to-viabedrock.pr.md). [Remote-player live and sparse movement origins remain separate](../patches/viabedrock/upstreamable/0106-align-retained-remote-spawn-origins.pr.md). [Negotiated remote movement retains native float steps, pitch history, and head targets](../patches/viafabricplus-bedrock/upstreamable/0041-interpolate-native-remote-player-movement.pr.md). Overworld air blindness attenuates night-vision strength and fog color together. Both Java routes match native water-boundary fall controls. | Selected reset, head, body, and queued-movement controls match native after packet/tick state alignment. Full trajectories and pose parity remain open. Readiness, ground state, exact timing, and mounted behavior remain unverified. Native Fancy controls establish the tick-based expiry blend and distance curve at noon and midnight. Other fluid movement, vision combinations and media, mount variants, network conditions, and broader replay behavior remain open. | +| Food use | Native food components govern admission and use. Local completion predicts hunger while inventory counts remain server-owned. | Selected admission and use controls pass. Correction replay preserves live hunger on both Java routes. Broader movement timing and the original intermittent report remain open. See the [food-use evidence](../patches/viabedrock/deferred/0008-signal-sustained-bedrock-item-use.pr.md). | +| Cooking | Snapshot-bound recipe and bundle actions, transfer batches, preferences, search, and progress. | Default cooking screens retain ordinary controls until authored bindings are complete. See the [cooking reference](native-cooking-actions.md). | +| Skins and appearance | Skin transport, classic changes, persona assembly, animation graphs, costumes, attachables, and [native patterned shields](../patches/viafabricplus-bedrock/upstreamable/0017-render-equipped-native-pack-models.pr.md). | Broader wardrobe masking, profile/LOD changes, remote appearances, and account/platform flows need further native comparisons. See [skin support](skin-flow.md). | +| Camera | Core-resolved poses, follow targets, blends, FOV commands, shake, fades, native ordinary player FOV, and the default death view. See [camera validation](../patches/viafabricplus-bedrock/upstreamable/0023-render-core-resolved-camera-movement.pr.md). | Selected stationary projections, swim-boost expiry, and settled death views have native comparisons. Death activation timing, varied angles and postures, native death-screen UI, other fluid effects, portrait scaling, transition timing, collision, aim assistance, and complete visual parity remain open. | +| Movement and effects | Completed physics frames, retained prediction history, correction replay, terrain queries, and authoritative impulses. [Local modes survive profile refreshes and physics replay](../patches/viafabricplus-bedrock/upstreamable/0025-send-completed-local-physics-to-viabedrock.pr.md). [Remote-player live and sparse movement origins remain separate](../patches/viabedrock/upstreamable/0106-align-retained-remote-spawn-origins.pr.md). [Negotiated remote movement retains native float steps, pitch history, and head targets](../patches/viafabricplus-bedrock/upstreamable/0041-interpolate-native-remote-player-movement.pr.md). Overworld air blindness attenuates night-vision strength and fog color together. Both Java routes match native water-boundary fall controls. | Selected reset, head, body, and queued-movement controls match native after packet/tick state alignment. Full trajectories and pose parity remain open. Readiness, ground state, exact timing, and mounted trajectories still need comparisons with the native client. Native Fancy controls establish the tick-based expiry blend and distance curve at noon and midnight. Other fluid movement, vision combinations and media, mount variants, network conditions, and broader replay behavior remain open. | | Fog and picking | Fog commands, inherited legacy biome bindings, pack-defined defaults, Overworld clock modulation, End dragon distance rules, native actor picking transport, and native float camera rays. Disabling the daylight rule freezes explicit Overworld clocks. | Both routes match selected native fog colors and distances, three non-cardinal rays, and paired armor-stand and block reach controls. Eye precision, other actor shapes, multipart target ordering, spatial blending, timed command transitions, directional sunrise tint, sky sampling, and other render modes remain open. | -| Custom content | Resource-defined actors, materials, particles, sound, block geometry, lighting, and collision. | Unsupported material methods, missing assets, carrier capacity, and visual differences remain. Live-server admission does not establish parity. | +| Custom content | Resource-defined actors, materials, particles, sound, block geometry, lighting, and collision. Verified base-factory blocks and hardcoded primary providers retain [camera bounds without visual models](../patches/viafabricplus-bedrock/upstreamable/0017-apply-custom-block-shapes.pr.md). | Unsupported material methods, missing assets, carrier capacity, and visual differences remain. Live-server admission does not establish parity. | | Platforms | Linux build, replay, and selected native comparisons. | Windows and macOS launcher/account/rendering paths need their own results. See [platform testing](platform-testing.md). | ## Reported regressions @@ -46,9 +46,9 @@ Retain these reports until a matching reproduction establishes the result: - An update from 0.3.61 to 0.4.2 caused reported inventory lag spikes and overlapping menus. - The oversized shield and distorted custom-skin pose in the confirmed 0.4.3 screenshots remain unresolved. - The 0.4.3 bed-floor report matches a reproduced [MoreCulling interaction](../patches/viafabricplus-bedrock/upstreamable/0040-preserve-native-occlusion-with-moreculling.pr.md). Current direct and proxy controls preserve the floor. The affected player's mod list remains unknown. -- An ender pearl followed by a wind charge can cause repeated rollbacks. Selected [current native and Java controls](../patches/viafabricplus-bedrock/upstreamable/0025-send-completed-local-physics-to-viabedrock.pr.md) settle, including walking projectile combinations with added incoming delay. Timing for short presses and the original persistent loop remain unverified. +- An ender pearl followed by a wind charge can cause repeated rollbacks. Selected [current native and Java controls](../patches/viafabricplus-bedrock/upstreamable/0025-send-completed-local-physics-to-viabedrock.pr.md) settle, including walking projectile combinations with added incoming delay. A [local projectile input correction](../patches/viafabricplus-bedrock/upstreamable/0038-predict-held-trapdoor-placement.pr.md#evidence-and-validation) restores native admission and continuation in scoped comparisons. Installed direct and proxy controls now cover selected hold routes and [cooldown inventory reconciliation](../patches/viabedrock/deferred/0016-synchronize-glide-rockets-with-auth-input.pr.md). The persistent loop remains unmatched, and this correction does not establish its cause. - Food can repeat its use animation without feeding the player. -- The locator bar can be absent. +- The locator bar can be absent. Current native, direct, and ViaProxy [player controls](../patches/viafabricplus-bedrock/upstreamable/0022-render-native-locator-groups.pr.md) display markers and remove or restore them after rule changes. The reported server remains unmatched. - A Realms disconnect report shows NetherNet connectivity loss and repeated STUN 401 responses. Current networking updates still need a matching reproduction. Release fixes and successful local controls do not close an unmatched report. diff --git a/docs/development.md b/docs/development.md index efb04ad0..49a6d04d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -7,6 +7,8 @@ Use this guide to build the four patch targets with pinned ViaFabricPlus and sta Install Bun, Git, JDK 17, and JDK 25. ViaBedrock, ViaProxy, and the Bedrock add-on require Java 25. CubeConverter still builds with JDK 17. Set `STACKANVIL_JAVA_17` and `STACKANVIL_JAVA_25` if the JDKs are not in the standard paths. +Prepare the six [OreUI shaping libraries](../native/oreui-shaper/README.md) before building the add-on. +The CI and release workflows build these libraries automatically. Install Xvfb, `xauth`, and `pactl` for focus free local tests. On Fedora, the packages include `xorg-x11-server-Xvfb`, `xorg-x11-xauth`, and `pulseaudio-utils`. Then run: @@ -35,11 +37,21 @@ ViaProxy includes it through its core dependency. The Fabric add-on includes it The stack build verifies its version, checksum, required templates, textures, and referenced font faces. Missing or incomplete assets stop the build. Each finished JAR must contain the exact verified bundle before publication. +The add-on also embeds `assets/viafabricplus-bedrock/builtin_oreui.zip` for the OreUI screen port. +It contains the gameplay stylesheet, OpenType and TrueType faces, animation images, and OreUI translations from the same game version. +Its generated `oreui.json` records the stylesheet's font families, variants, and explicit `coh-font-sdf:off` declarations. +Faces without that property retain `default` in the descriptor. +The descriptor also lists the acquired GIF paths. Image access requires a registered path and returns a copy of its bytes. +These resources remain separate from classic JSON UI font registrations and their display transform. +The build verifies the bundle's checksum, referenced faces, and languages before and after packaging. +See [compatibility status](bedrock-coverage.md) for rendering support. + To regenerate the bundle from a matching installed game, run: ```bash bun scripts/bundle-bedrock-assets.ts /path/to/bedrock-game 1.26.60.5 bun scripts/bundle-bedrock-ui.ts /path/to/bedrock-game 1.26.60.5 .stackanvil/builtin_ui.zip +bun scripts/bundle-bedrock-oreui.ts /path/to/bedrock-game 1.26.60.5 .stackanvil/builtin_oreui.zip ``` The game directory must contain `data/resource_packs/`. @@ -51,6 +63,11 @@ The UI extractor requires a fresh output path and writes a checksum manifest bes After reviewing the output, replace both tracked files under `assets/bedrock/1.26.60.5/assets/viabedrock/`. Keep the native copyright notice in the ZIP. Commit both files with changes to the version pin. +The OreUI extractor also reads `data/gui/dist/hbui/` and `data/resource_packs/oreui/`. +It keeps the original font, stylesheet, animation, and language bytes and prefers loose language files over archive entries. +Review its preview and copy the ZIP and checksum manifest to `assets/bedrock/1.26.60.5/assets/viafabricplus-bedrock/`. +It uses the same requirement for fresh output paths as the JSON UI extractor. + The normal stack build also derives an image-only bundle from the verified add-on archives. It writes `builtin_images.zip` under `.stackanvil/builtin-images//` and embeds it in the core. ViaProxy receives the same native images through that dependency. @@ -61,6 +78,7 @@ Generated image bundles remain outside Git. The normal stack build passes the resource directory to Gradle. For a direct add-on Gradle build, pass `-PbedrockBuiltinAssets=/absolute/path/to/assets/bedrock/1.26.60.5`. +Also pass `-PbedrockOreUiShaper=/absolute/path/to/combined-resources` for the [OreUI shaping libraries](../native/oreui-shaper/README.md). For a direct core Gradle build, pass `-PbedrockBuiltinUi=/absolute/path/to/assets/bedrock/1.26.60.5/assets/viabedrock/builtin_ui.zip`. To include the required image bundle in a direct core Gradle build, also pass `-PbedrockBuiltinImages=/absolute/path/to/builtin_images.zip`. When changing the target Bedrock version, regenerate the bundle and update the pin together. diff --git a/docs/native-cooking-actions.md b/docs/native-cooking-actions.md index d7d37535..5a9558d4 100644 --- a/docs/native-cooking-actions.md +++ b/docs/native-cooking-actions.md @@ -19,6 +19,104 @@ Input-mode differences affect focus, selection, and held-item admission. Do not replace those rules with a universal click handler. Keep selection, ingredient placement, repeated placement, and ingredient return as separate operations. +Keep held quantity selection separate from the initial pickup and its source. +Entering quantity selection retains a secondary source and starts its own timer. +The native View starts a separate 0.3-second countdown after controller admission. +It subtracts float frame durations and emits a hold event only when the countdown falls below zero. +That event retains the starting pointer; displacement greater than five native units on either axis blocks quantity-selection entry. +The View countdown and the quantity-selection timer have different owners and start times. +Pointer exit can finish the partial pickup before physical release. +A second button press preserves quantity selection. +Its delivered slot release completes selection while the initiating button remains held. +An unmapped button release cannot complete another button's selection. +Route that completion through the inventory request owner before clearing the controller state. +Core retains the starting frame while ordinary Place and Take requests return the cursor and pick up the selected quantity. +Completion waits for the return response and uses its renewed network stack ID. +Rejection, reopening, or transport retirement cancels queued completion. + +Desktop Return activates the slot through its authored confirmation mapping. +Its separate Return callback retains keyboard-held state and completes the matching quantity selection on release. +The same inventory request owner handles pointer and Return pickup, source return, and partial pickup. +Repeated Return presses cannot start another pickup while that hold remains active. +Return callbacks use raw held-input edges independently of the activation flag. +Only a release after that component received a press can clear Return or complete its quantity owner. +An unmatched release from a newly visible mapping preserves the original source and timer. +Installed direct and ViaProxy controls cover a mapping that becomes visible during a physical Return hold. +Both routes keep quantity selection active through its unmatched release and finish once on the real Return release. +Space does not activate an ordinary desktop cooking slot. + +Cooking reset uses the raw phase retained by the authored component. +Every pressed phase updates the empty-source context, including repeated presses. +A released phase finishes an operation only when its previous phase differs from pressed. +A release after that component received its own press does not finish the operation. +Keep component consumption active while the controller waits for inventory replies. +A child can consume the press before a parent receives the release. +Recipe, quantity, and drag completion retain their existing request owners and original sources. +A repeated release cannot send another completion or retire the opening. +Declined preference changes restore their model-bound toggles without changing this input history. + +Desktop drag distribution includes the initially pressed slot before the first pointer movement. +Use the ordinary slot admission checks for that cell and later destinations. +Native distribution changes the real inventory as each destination is entered, before pointer release. +A nine-item desktop drag places nine in the first cell, then redistributes to four in each of two cells, then three in each of three cells. +Closing with Escape while the button is still down retains the placed items. +The negotiated Java path sends each destination through the inventory request owner before release. +The client sends ordered click phases without Java menu prediction, which would distribute the same items again on release. +It retains the original budget after a placement empties the cursor and uses renewed stack IDs after server replies. +A close waits for earlier drag inputs and their replies. New input cannot extend that closing operation. +Consecutive queued destinations share one pointer path and retain their first-visit order. +Repeated visits do not consume more queue entries. Release and a new press separate gestures. +Resolve destinations through the complete menu, including player slots appended to a cooking container. +Rejection discards unaccepted visits and closes with reconciled items. +A retired close cannot affect a replacement menu or opening. +Unnegotiated clients and bundle extraction retain their existing paths. +Drag distribution and held quantity selection have different lifetimes. +Touch distribution retains the selected inventory slot as its source and keeps the cursor empty. +It retains a separate quantity budget instead of picking that quantity up onto the cursor first. +Redistribution can return items to the original source while preserving items outside that budget. +For a four-item selection from nine, three destinations receive one each and the source retains six. +Repeated visits do not add a destination or transfer more items. +Source-selection release clears the previous drawing destination while retaining the selected item source. +The first drawing event retains its destination without transferring items. +The second event enters distribution with that retained destination, then distributes to the current destination. +An arrow hold blocks this entry but preserves the latest drawing destination. +After arrow release, distribution starts with that destination and the next entered destination. +The Java keyboard route forwards the authored raw arrow aliases alongside ordinary focus input and matches their releases. +The arrow callback also controls persistent Touch hover text, which expires after six seconds. +Scrollbar capture delivers its authored press before taking pointer ownership. +Moving that held pointer over an item cannot start source hover quantity selection. A fresh contact after release can select the item. +Ordinary selected-source drawing keeps the cursor empty and sends no cursor-drag start signal. +Release preserves placed items and clears the selected source and distribution allocation. +Native component, controller, and legacy-model controls establish these results for hotbar, inventory, and furnace ingredient sources. +They do not establish physical touch delivery, native multiplayer request grouping, or complete screen behavior. +The negotiated Java path supports cursor-origin desktop distribution and selected-source taps and drawing. +Selected-source commands retain the displayed frame and opening identity through the shared inventory request owner. +The optional Touch control style uses mouse contact to exercise source selection and quantity input. +Selection and quantity feedback leave the real source and cursor unchanged until a transfer starts. +Authored selected-item controls display the retained source and count independently of the real cursor. +The default trigger is 525 milliseconds; configurable trigger time and physical touchscreen delivery remain unfinished. +Release over an empty recipe panel can retain the drag allocation. +Pointer entry then redistributes items without a held mouse button. +A delivered slot release or furnace-root reset retires that allocation. +During a retained primary drag, a secondary press preserves the allocation. +Its delivered slot release ends the original operation, even though another button started it. +Use the delivered authored callback to resolve this boundary. Physical release alone does not establish a reset. + +Route bundle insertion and extraction through the shared physical transfer owner. +Cooking bundle commands retain all six source fields and share request ordering with search, recipes, and held-item input. +Resolve selected display cells against the published bundle contents before extracting a physical slot. +Dynamic bundle changes replace the publication even when the root stack and container revisions stay unchanged. +A reply states whether the original authority remains current. Wait for a new snapshot only when it retires that authority. +Retain newer search text while a bundle command waits. Late replies cannot release another opening or play its sound. +Pending item commands do not clear physical hover. +Retain the bundle source through mouse release and renewed snapshots without requiring pointer movement. + +Keep hover separate from the selected source used by `#is_recipe_selected_slot`. +Ordinary recipe hover preserves selection. +The native source controller clears selected bundle context during its admitted desktop transitions. +An empty-source press preserves bundle hover when the primary source is already empty. +Retain a local source reset through publications with unchanged selected-source data. +Native layout and tab callbacks clear hover while core retains ownership of the selected source. Read transfer quantities from native descriptors and current stack identity. Respect item locks, source permissions, destination capacity, and carried stacks. Reconcile ordinary transfer batches and recipe requests with the authoritative response owner. @@ -30,12 +128,51 @@ Retain raw input independently of normalized search text. Search changes retire selection according to the native callback contract. Resolve names through the connection registry, language context, item definition, and component updates. Do not use Java display names as ingredient identity. + +Cooking search retains separate raw text for each station family within the connection. +Closing and reopening a menu retains that text. +Only the Search tab applies the query. Category tabs retain their own lists and counts. +The optional search transport binds edits to the displayed opening and inventory snapshot. +Search and recipe commands share request ordering. A changed query retires the old recipe-cell authority. +The client queues the latest edit while an earlier edit or recipe gesture waits for completion. +A late reply cannot erase newer text. +The connection retains an unsent edit or explicit clear for the next opening. Disconnect clears this client state. +An acknowledgment without a replacement snapshot still reaches the response timeout. +The native editor retains its property-field name separately from bound display text. +Its embedded X follows raw text, including whitespace. +During active editing, X clears the query and preserves typing, including after the first Escape. +A reopened inactive editor retains its query when X is clicked. +Only a clear button owned by the admitted editor can use this behavior. +Search hover and exit use the shared [editor input lifetime](native-player-inventory.md#actions-and-presentation). +Local text updates while an older edit waits for a reply; recipe authority remains tied to the server snapshot. Shared naming and recipe APIs belong in core rather than screen-specific copies. ## Progress and controls Keep cooking progress and preferences independent of recipe requests. +Cooking recipe cells use the shared inventory drop input owner. +With a carried stack, the desktop drop key drops one item and the whole-stack drop key leaves that stack alone. +With an empty cursor, neither key takes an item from the recipe preview. +A held drop key acts once until release, including when its modifier changes. +Cooking Help opens the Encyclopedia at Furnace for all three station families. +Help retains the inventory opening, carried items, and pending search while its own view receives input. +Back and Escape return to that inventory without closing its container. +The shared Help renderer uses the localized encyclopedia heading and scrolls the selected topic into view. +Help starts with its selected topic focused. +Moving the pointer hides the keyboard highlight. +Hovering another topic changes the focus without changing the chapter. +Arrow keys resume from that topic, and confirmation selects its chapter. +Installed direct and ViaProxy controls retain nine carried items through Back and Escape, then retain all nine after reopening. Use authored component flags for visibility, focus, input, and toggle behavior. +Check controller registrations before treating an authored mapping as a required cooking action. +Shared screen definitions can name callbacks that belong to another controller. +Preserve the component's input consumption when the cooking controller has no callback. +Layout increment and decrement use the current native layout transition. +Forced recipe-book layouts consume these actions without changing preferences. +Layout changes retain the carried stack and wait for a matching profile snapshot. +The current profile tab is separate from the controller's retained Inventory tab. +Admitted pointer hosts use the shared [cursor-drop behavior](native-player-inventory.md#actions-and-presentation). + The available bindings do not establish complete support for the default authored cooking program. Preserve ordinary controls when required native controls are unavailable. @@ -46,6 +183,65 @@ Use real slot and cursor updates when testing a container adapter. Use [test packs](bedrock-test-packs.md) and the [capture lab](capture-lab.md) for physical input and live transfers. Record both direct and ViaProxy results where the changed behavior crosses that boundary. +Official Bedrock 1.26.60.5 desktop controls establish search filtering, category transitions, clearing, and retention after reopening a furnace. +Separate smoker text does not replace the furnace query. +These private integrated-world controls do not establish controller input, exact timing, or retention across connections. + +Official 1.26.60.5 desktop controls also establish held quantity selection in hotbar and furnace ingredient slots. +A nine-item source produces a partial pickup during a hold and retains the remaining items in its original slot. +Read-only observations of the live controller distinguish initial pickup, quantity selection, and completion on release or pointer exit. +Core regressions cover delayed responses, renewed stack IDs, rejection recovery, source capacity, and opening retirement. +The Java desktop controller now retains an ordinary primary pickup through held quantity selection. +It waits for a newer pickup snapshot and keeps the selected source through delayed replies. +Source and cursor previews do not change the real menu items. +Release or pointer exit submits the last displayed quantity through the inventory request owner. + +Installed direct and ViaProxy controls use an admitted authored furnace fixture with the normal admission guards. +A short press picks up nine items. A 600-millisecond hold picks up three, and a longer hold picks up all nine. +Pointer exit completes a partial pickup before physical release without another request on release. +The hotbar and ingredient controls conserve all nine items through 18 successful inventory responses on each route. +These controls do not establish default cooking-screen support or exact native frame timing. + +The incremental drag and interrupted-close reference uses the official 1.26.60.5 integrated world. +Read-only script observations establish real inventory changes before physical Escape and mouse release. +Installed direct and ViaProxy controls match the nine-item sequence before release and retain three items per destination after reopening. +Both routes also close visibly while incoming replies wait for about three seconds, then reconcile the earlier destinations before sending the native close. +Each complete journal contains 18 successful requests: 14 across the ordinary and delayed controls, plus four for secondary dragging. +Secondary dragging places one item in each visited destination before release. Release preserves those three placements and the six carried items. +The official 1.26.60.5 client also distributes 64 items across all 36 player slots before release. +The held native model and visible items agree on one item per destination and 28 carried items. +Installed direct and ViaProxy controls retain this allocation through delayed replies, Escape while held, and a fresh opening. +Each journal contains 173 accepted transfers. Replies finish before the native close. +Server inventory, response data, and the reopened menu agree. All 64 items remain accounted for, with no dropped items. +These controls use an admitted authored furnace fixture. Default cooking hosts retain their admission guards. +Native multiplayer transfer grouping and delayed or rejected native drag behavior remain unverified. + +Installed direct and ViaProxy selected-source controls retain seven selected items from a nine-item source without sending an inventory request. +A tap transfers seven and leaves two in the source. Drawing across two destinations places three in each and leaves three in the source. +Both routes preserve that distribution after closing with incoming replies held for about three seconds and reopening. +A server replacement renews the source identity while its updates wait in the relay. +The stale tap receives a real rejection, restores all nine items, and retires the selected preview without replacing the screen or menu. +A fresh selection then transfers seven successfully. Both complete fault journals contain five matched replies, including the rejection. +These controls use an admitted authored furnace fixture, mouse contact in Touch style, and software rendering. +They do not establish native multiplayer grouping, native rejection recovery, physical touch input, or complete visual parity. + +Official 1.26.60.5 controls distinguish release over the empty recipe panel from release over the furnace root. +After the recipe-panel release, an unheld pointer entry redistributes ten items from three stacks of three to four stacks of two. +The cursor changes from one item to two. Read-only allocation fields and inspected screenshots agree. +Installed direct and ViaProxy controls retain this behavior while replies wait. +Both routes preserve the allocation during a secondary hold and stop further visits after its slot release. +Each complete journal contains eleven successful transfers and preserves all ten items after reopening. +Official 1.26.60.5 controls also complete quantity selection after secondary release while the primary button remains held. +Read-only controller observations and physical button masks establish that event order. +Installed direct and ViaProxy controls retain selection during the second press and complete it before primary release. +Each journal contains four accepted transfers and retains all nine items after reopening. +These controls establish the input lifetime, not exact frame timing or equal selected counts at matching requested durations. +Installed direct and ViaProxy reset controls retain drag allocation after a release whose previous phase is pressed. +A later qualifying reset ends the allocation once. Pointer entry cannot redistribute items after that completion. +Each journal contains seven accepted transfers and one drag start/end pair. +Server inventory and reopened menus retain three stacks of three, without dropped items. +Other input modes, broader source selection, and same-frame pointer and press admission still need matching controls. + Earlier controlled references execute parts of Bedrock 1.26.51.1, build 51061372, protocol 2193. Those references establish their supplied boundaries and require a version check before reuse for 26.60. Keep binaries, native addresses, transcripts, and repeated probe results private. @@ -54,5 +250,11 @@ The owning patch contains the durable rationale. ## Remaining limits Complete authored control bindings, remaining input branches, and native parity remain unfinished. +Default hosts still require complete input bindings and admission checks. +The current default furnace, smoker, and blast-furnace programs stop at the unsupported `coalesce_stack` keyboard action. +Broader source-selection transitions and other input modes remain unfinished. +The shared bundle controller retains padded selection through publication-bound extraction and replies. +Native physical bundle navigation bindings remain unverified. +Installed selected-cell extraction uses an admitted authored fixture. See the [container validation](../patches/viafabricplus-bedrock/upstreamable/0029-present-authored-native-containers.pr.md). Native state registration and isolated callback execution do not establish live controller delivery. A local request test does not establish native graphical behavior or complete server transaction recovery. diff --git a/docs/native-player-inventory.md b/docs/native-player-inventory.md index d538ef96..001c65f4 100644 --- a/docs/native-player-inventory.md +++ b/docs/native-player-inventory.md @@ -123,6 +123,28 @@ In Bedrock 1.26.60.5, Escape first removes the search highlight. A second Escape Typing after Escape or a blank-area click resumes editing. Selecting another recipe or inventory slot stops search input. Retain the editor and its selection through screen rebuilds, viewport changes, and GUI-scale changes. An inactive editor must stay inactive after opening or resizing. +An authored search hover temporarily admits text without selecting the editor. +A first character starts editing. Leaving before typing retires the temporary text input. +The inventory key enters text while search is hovered and closes the screen outside an inactive editor. +Escape closes an inactive editor without starting a text session. + +Pointer cursor drops follow the authored host bindings. +Primary input drops the carried stack. Secondary input drops one carried item per press. +Holding secondary input does not repeat the drop. Blank recipe panels retain the cursor. +Player pointer and keyboard drops share the ordinary inventory request owner. +Empty cursors, pending native commands, and the transition into Help do not start a cursor-drop request. +Admitted cooking pointer hosts use this same path. Partial container scopes retain ordinary controls when they require cursor drops. +Installed direct and ViaProxy E-key controls each receive three successful replies: pickup of nine, secondary drop of one, and primary drop of eight. +Both routes retain the same screen, menu, and presentation, conserve all nine ingots on the server, and send no further requests with an empty cursor. + +Bedrock 1.26.60.5 desktop drop keys follow the authored bindings for recipe, Creative catalog, and real inventory slots. +With a held stack, Q and Shift+Q drop one carried item. Ctrl+Q leaves the cursor and pointed slot unchanged. +With an empty cursor, Q drops one item from a real slot and Ctrl+Q drops its whole stack. +Empty-cursor drops over recipe or catalog rows do nothing. +Plain panels and areas outside controls ignore these keys. + +A held drop key acts once until release. +Search editing and pending native commands suppress these actions. 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. @@ -216,6 +238,10 @@ Typed overrides and RGB conversion match selected native instruction controls. C Storage bars read a native weight snapshot independently of Java bundle contents. Unknown storage snapshots restore ordinary controls. Player and workbench screens route selected desktop bundle gestures through the native input transport. Local regressions cover insertion and default extraction with either cursor or clicked roots, filled hover views, moved sources, stale snapshots, rejection rollback, and serialized search edits. Matching bundle replies route operation sounds through current native resources and captions. Stale commands and locally unsuccessful extraction attempts stay silent. +Retained bundle navigation uses padded display cells. Directional callbacks stop at row edges, while previous and next callbacks wrap over item cells. +A separate optional channel carries selected-cell extraction against the published source snapshot. Core resolves the physical slot and rejects padding, overflow labels, and stale contents. +Physical input bindings remain unfinished. Installed selected-cell extraction remains unverified. +Cooking uses the same transfer owner with its [complete source contract](native-cooking-actions.md). Installed direct and ViaProxy controls each complete fourteen server-accepted item requests. They insert and extract iron ingots with the bundle in either the clicked slot or the cursor, retain a filled bundle after reopening, and reject insertion into a full bundle without an item request. Screen audits distinguish native commands from ordinary Java clicks. Sound audits observe seven matching level events and decoded PCM channel starts on each route. Bundle hover survives temporary source removal and returns when the source becomes available, without another pointer event. Source regressions cover independent selection and touch behavior. Screen regressions retain hover while a pending command blocks duplicate actions. diff --git a/native/oreui-shaper/CMakeLists.txt b/native/oreui-shaper/CMakeLists.txt new file mode 100644 index 00000000..c70b01cc --- /dev/null +++ b/native/oreui-shaper/CMakeLists.txt @@ -0,0 +1,81 @@ +cmake_minimum_required(VERSION 3.24) +project(StackAnvilOreUiShaper VERSION 1.0 LANGUAGES C CXX) + +include(FetchContent) +include(CTest) +find_package(Git REQUIRED) + +file(READ "${CMAKE_CURRENT_SOURCE_DIR}/source.json" source_pin) +string(JSON HARFBUZZ_VERSION GET "${source_pin}" version) +string(JSON HARFBUZZ_SHA256 GET "${source_pin}" sha256) +string(JSON source_url GET "${source_pin}" url) +set(STACKANVIL_HARFBUZZ_ARCHIVE + "${source_url}" + CACHE STRING "Pinned HarfBuzz archive URL or local archive path") +file(SHA256 "${CMAKE_CURRENT_SOURCE_DIR}/compatibility.patch" COMPATIBILITY_SHA256) + +# Keep the engine independent of the game's FreeType library. Java supplies font callbacks. +set(BUILD_SHARED_LIBS ON CACHE BOOL "" FORCE) +foreach(option IN ITEMS HB_BUILD_SUBSET HB_BUILD_RASTER HB_BUILD_VECTOR HB_BUILD_GPU + HB_BUILD_UTILS HB_HAVE_FREETYPE HB_HAVE_GLIB HB_HAVE_ICU HB_HAVE_CAIRO + HB_HAVE_GOBJECT HB_HAVE_INTROSPECTION) + set(${option} OFF CACHE BOOL "" FORCE) +endforeach() +set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") + +FetchContent_Declare(harfbuzz + URL "${STACKANVIL_HARFBUZZ_ARCHIVE}" + URL_HASH "SHA256=${HARFBUZZ_SHA256}" + DOWNLOAD_EXTRACT_TIMESTAMP TRUE + PATCH_COMMAND "${CMAKE_COMMAND}" + "-DGIT_EXECUTABLE=${GIT_EXECUTABLE}" + "-DPATCH=${CMAKE_CURRENT_SOURCE_DIR}/compatibility.patch" + "-DPATCH_SHA256=${COMPATIBILITY_SHA256}" + -P "${CMAKE_CURRENT_SOURCE_DIR}/apply-patch.cmake") +FetchContent_MakeAvailable(harfbuzz) +target_compile_definitions(harfbuzz PRIVATE HB_EXPERIMENTAL_API STACKANVIL_OREUI_COMPATIBILITY) +set_target_properties(harfbuzz PROPERTIES OUTPUT_NAME stackanvil_oreui_harfbuzz) +set_property(TARGET harfbuzz PROPERTY VERSION) +set_property(TARGET harfbuzz PROPERTY SOVERSION) + +string(TOLOWER "${CMAKE_SYSTEM_PROCESSOR}" processor) +if(MSVC AND CMAKE_VS_PLATFORM_NAME) + # Visual Studio can target ARM64 while CMake itself runs as an x64 process. + string(TOLOWER "${CMAKE_VS_PLATFORM_NAME}" processor) +endif() +if(processor MATCHES "^(amd64|x86_64|x64)$") + set(architecture x64) +elseif(processor MATCHES "^(aarch64|arm64)$") + set(architecture arm64) +else() + message(FATAL_ERROR "Unsupported OreUI shaper architecture: ${CMAKE_SYSTEM_PROCESSOR}") +endif() +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + set(platform linux) +elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows") + set(platform windows) +elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin") + set(platform macos) +else() + message(FATAL_ERROR "Unsupported OreUI shaper platform: ${CMAKE_SYSTEM_NAME}") +endif() + +set(resource "assets/viafabricplus-bedrock/oreui-shaper/${platform}-${architecture}") +install(FILES "$" "${harfbuzz_SOURCE_DIR}/COPYING" + DESTINATION "${resource}" COMPONENT OreUiShaper) +file(GENERATE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/manifest-$.cmake" CONTENT +"set(library_name \"$\")\ninclude(\"${CMAKE_CURRENT_BINARY_DIR}/write-manifest.cmake\")\n") +configure_file(write-manifest.cmake.in write-manifest.cmake @ONLY) +install(SCRIPT "${CMAKE_CURRENT_BINARY_DIR}/manifest-$.cmake" COMPONENT OreUiShaper) + +if(BUILD_TESTING) + add_executable(oreui-shaping-test tests/shaping.cpp) + target_compile_features(oreui-shaping-test PRIVATE cxx_std_11) + target_include_directories(oreui-shaping-test PRIVATE "${harfbuzz_SOURCE_DIR}/src" + "${harfbuzz_BINARY_DIR}/src") + target_link_libraries(oreui-shaping-test PRIVATE harfbuzz) + # Windows resolves a linked DLL beside the executable. + add_custom_command(TARGET oreui-shaping-test POST_BUILD COMMAND "${CMAKE_COMMAND}" -E + copy_if_different "$" "$") + add_test(NAME oreui-shaping COMMAND oreui-shaping-test) +endif() diff --git a/native/oreui-shaper/COPYING b/native/oreui-shaper/COPYING new file mode 100644 index 00000000..1dd917e9 --- /dev/null +++ b/native/oreui-shaper/COPYING @@ -0,0 +1,42 @@ +HarfBuzz is licensed under the so-called "Old MIT" license. Details follow. +For parts of HarfBuzz that are licensed under different licenses see individual +files names COPYING in subdirectories where applicable. + +Copyright © 2010-2022 Google, Inc. +Copyright © 2015-2020 Ebrahim Byagowi +Copyright © 2019,2020 Facebook, Inc. +Copyright © 2012,2015 Mozilla Foundation +Copyright © 2011 Codethink Limited +Copyright © 2008,2010 Nokia Corporation and/or its subsidiary(-ies) +Copyright © 2009 Keith Stribley +Copyright © 2011 Martin Hosken and SIL International +Copyright © 2007 Chris Wilson +Copyright © 2005,2006,2020,2021,2022,2023 Behdad Esfahbod +Copyright © 2004,2007,2008,2009,2010,2013,2021,2022,2023 Red Hat, Inc. +Copyright © 1998-2005 David Turner and Werner Lemberg +Copyright © 2016 Igalia S.L. +Copyright © 2022 Matthias Clasen +Copyright © 2018,2021 Khaled Hosny +Copyright © 2018,2019,2020 Adobe, Inc +Copyright © 2013-2015 Alexei Podtelezhnikov + +For full copyright notices consult the individual files in the package. + + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. diff --git a/native/oreui-shaper/README.md b/native/oreui-shaper/README.md new file mode 100644 index 00000000..26df73de --- /dev/null +++ b/native/oreui-shaper/README.md @@ -0,0 +1,45 @@ +# OreUI shaping engine + +This build applies the Bedrock 1.26.60.5 OreUI layout policy to HarfBuzz 14.3.0. +It rounds signed anchors separately and preserves native variation-device admission and scaling. +The source archive and compatibility patch have separate SHA-256 identities. +The library does not link FreeType. The Java adapter must supply callbacks from the game's FreeType library. + +Install CMake 3.24 or newer, Git, and a C++ compiler on the build host. +From the repository root, build and test the engine: + +```bash +cmake -S native/oreui-shaper -B .stackanvil/tools/oreui-shaper-build -DCMAKE_BUILD_TYPE=Release +cmake --build .stackanvil/tools/oreui-shaper-build --config Release --target oreui-shaping-test --parallel 2 +ctest --test-dir .stackanvil/tools/oreui-shaper-build -C Release --output-on-failure +cmake --install .stackanvil/tools/oreui-shaper-build --config Release --prefix "$PWD/.stackanvil/tools/oreui-shaper-resources" --component OreUiShaper +bun src/oreui-shaper.ts .stackanvil/tools/oreui-shaper-resources linux-x64 +``` + +Replace `linux-x64` with the host's platform and architecture. +The workflow builds Linux, Windows, and macOS libraries for x64 and arm64. +Linux workflow builds use Ubuntu 22.04. macOS workflow builds target macOS 13 or newer. +A local build uses the host's system libraries and does not establish compatibility with older systems. +For an existing source archive, set `-DSTACKANVIL_HARFBUZZ_ARCHIVE=/absolute/path/to/archive.tar.gz`. +The same archive checksum applies to local files and downloads. + +The installation contains the library, its license, and a manifest under `assets/viafabricplus-bedrock/oreui-shaper/-/`. +The verifier checks the source and patch identities, binary architecture, library checksum, and license. +The authored tests exercise mark positioning and a constant variation delta through the public shaping API. +They do not contain game fonts or captures. + +The normal stack build requires all six platform directories in `.stackanvil/tools/oreui-shaper-resources/`. +The CI and release workflows build them through [the shared native workflow](../../.github/workflows/oreui-shaper.yml). +For a local build, combine its six `oreui-shaper-*` artifacts into that directory, with each artifact's `assets/` tree at the root. +Set `STACKANVIL_OREUI_SHAPER_RESOURCES` to use another directory. +A single host installation contains only its own platform. +Run `bun src/oreui-shaper.ts /path/to/combined-resources` to check all six installations. +The stack build checks them again and compares every library, manifest, and license with the finished add-on JAR. +For a direct add-on Gradle build, pass `-PbedrockOreUiShaper=/path/to/combined-resources`. + +The Java text backend loads and checks this library. +Set `STACKANVIL_NATIVE_FONT_GPU_TESTS=1` to run the optional EGL font shader tests. +The CI and release builds run these tests with Mesa software rendering. +Complete font fallback, layout, sampling, and native screen integration remain unfinished. +The selected native comparisons remain private under `.stackanvil/research/`. +This helper uses the repository's GPL-3.0-or-later license. HarfBuzz retains its bundled [license](COPYING). diff --git a/native/oreui-shaper/apply-patch.cmake b/native/oreui-shaper/apply-patch.cmake new file mode 100644 index 00000000..ce36ce21 --- /dev/null +++ b/native/oreui-shaper/apply-patch.cmake @@ -0,0 +1,15 @@ +execute_process(COMMAND "${GIT_EXECUTABLE}" apply --reverse --check "${PATCH}" + RESULT_VARIABLE already_applied OUTPUT_QUIET ERROR_QUIET) +if(already_applied EQUAL 0) + return() +endif() +execute_process(COMMAND "${GIT_EXECUTABLE}" apply --check "${PATCH}" + RESULT_VARIABLE check_result ERROR_VARIABLE check_error) +if(NOT check_result EQUAL 0) + message(FATAL_ERROR "Cannot apply OreUI compatibility patch: ${check_error}") +endif() +execute_process(COMMAND "${GIT_EXECUTABLE}" apply "${PATCH}" + RESULT_VARIABLE apply_result ERROR_VARIABLE apply_error) +if(NOT apply_result EQUAL 0) + message(FATAL_ERROR "Cannot apply OreUI compatibility patch: ${apply_error}") +endif() diff --git a/native/oreui-shaper/compatibility.patch b/native/oreui-shaper/compatibility.patch new file mode 100644 index 00000000..a91d647e --- /dev/null +++ b/native/oreui-shaper/compatibility.patch @@ -0,0 +1,191 @@ +--- a/src/OT/Layout/GPOS/AnchorFormat1.hh ++++ b/src/OT/Layout/GPOS/AnchorFormat1.hh +@@ -1,5 +1,7 @@ + #ifndef OT_LAYOUT_GPOS_ANCHORFORMAT1_HH + #define OT_LAYOUT_GPOS_ANCHORFORMAT1_HH ++ ++#include "NativeIntegerAnchor.hh" + + namespace OT { + namespace Layout { +@@ -24,8 +26,13 @@ + float *x, float *y) const + { + hb_font_t *font = c->font; ++#ifdef STACKANVIL_OREUI_COMPATIBILITY ++ *x = native_integer_anchor (font, xCoordinate, font->x_scale); ++ *y = native_integer_anchor (font, yCoordinate, font->y_scale); ++#else + *x = font->em_fscale_x (xCoordinate); + *y = font->em_fscale_y (yCoordinate); ++#endif + } + + AnchorFormat1* copy (hb_serialize_context_t *c) const +--- a/src/OT/Layout/GPOS/AnchorFormat2.hh ++++ b/src/OT/Layout/GPOS/AnchorFormat2.hh +@@ -1,5 +1,7 @@ + #ifndef OT_LAYOUT_GPOS_ANCHORFORMAT2_HH + #define OT_LAYOUT_GPOS_ANCHORFORMAT2_HH ++ ++#include "NativeIntegerAnchor.hh" + + namespace OT { + namespace Layout { +@@ -28,8 +30,13 @@ + hb_font_t *font = c->font; + + #ifdef HB_NO_HINTING ++#ifdef STACKANVIL_OREUI_COMPATIBILITY ++ *x = native_integer_anchor (font, xCoordinate, font->x_scale); ++ *y = native_integer_anchor (font, yCoordinate, font->y_scale); ++#else + *x = font->em_fscale_x (xCoordinate); + *y = font->em_fscale_y (yCoordinate); ++#endif + return; + #endif + +@@ -40,8 +47,13 @@ + + ret = (x_ppem || y_ppem) && + font->get_glyph_contour_point_for_origin (glyph_id, anchorPoint, HB_DIRECTION_LTR, &cx, &cy); ++#ifdef STACKANVIL_OREUI_COMPATIBILITY ++ *x = ret && x_ppem ? cx : native_integer_anchor (font, xCoordinate, font->x_scale); ++ *y = ret && y_ppem ? cy : native_integer_anchor (font, yCoordinate, font->y_scale); ++#else + *x = ret && x_ppem ? cx : font->em_fscale_x (xCoordinate); + *y = ret && y_ppem ? cy : font->em_fscale_y (yCoordinate); ++#endif + } + + AnchorFormat2* copy (hb_serialize_context_t *c) const +--- a/src/OT/Layout/GPOS/AnchorFormat3.hh ++++ b/src/OT/Layout/GPOS/AnchorFormat3.hh +@@ -1,5 +1,7 @@ + #ifndef OT_LAYOUT_GPOS_ANCHORFORMAT3_HH + #define OT_LAYOUT_GPOS_ANCHORFORMAT3_HH ++ ++#include "NativeIntegerAnchor.hh" + + namespace OT { + namespace Layout { +@@ -34,15 +36,25 @@ + float *x, float *y) const + { + hb_font_t *font = c->font; ++#ifdef STACKANVIL_OREUI_COMPATIBILITY ++ *x = native_integer_anchor (font, xCoordinate, font->x_scale); ++ *y = native_integer_anchor (font, yCoordinate, font->y_scale); ++#else + *x = font->em_fscale_x (xCoordinate); + *y = font->em_fscale_y (yCoordinate); ++#endif + +- if ((font->x_ppem || font->has_nonzero_coords) && xDeviceTable.sanitize (&c->sanitizer, this)) ++#ifdef STACKANVIL_OREUI_COMPATIBILITY ++ bool variation_coordinates_present = font->num_coords != 0; ++#else ++ bool variation_coordinates_present = font->has_nonzero_coords; ++#endif ++ if ((font->x_ppem || variation_coordinates_present) && xDeviceTable.sanitize (&c->sanitizer, this)) + { + hb_barrier (); + *x += (this+xDeviceTable).get_x_delta (font, c->var_store, c->var_store_cache); + } +- if ((font->y_ppem || font->has_nonzero_coords) && yDeviceTable.sanitize (&c->sanitizer, this)) ++ if ((font->y_ppem || variation_coordinates_present) && yDeviceTable.sanitize (&c->sanitizer, this)) + { + hb_barrier (); + *y += (this+yDeviceTable).get_y_delta (font, c->var_store, c->var_store_cache); +--- /dev/null ++++ b/src/OT/Layout/GPOS/NativeIntegerAnchor.hh +@@ -0,0 +1,16 @@ ++#ifndef OT_LAYOUT_GPOS_NATIVEINTEGERANCHOR_HH ++#define OT_LAYOUT_GPOS_NATIVEINTEGERANCHOR_HH ++ ++#ifdef STACKANVIL_OREUI_COMPATIBILITY ++namespace OT { namespace Layout { namespace GPOS_impl { ++// Bedrock 1.26.60.5 rounds each signed anchor before subtracting mark positions. ++static inline float native_integer_anchor (hb_font_t *font, int16_t coordinate, int32_t scale) ++{ ++ int64_t upem = font->face->get_upem (); ++ int64_t product = int64_t (coordinate) * scale; ++ return float ((product + (product < 0 ? -(upem / 2) : upem / 2)) / upem); ++} ++}}} ++#endif ++ ++#endif +--- a/src/OT/Layout/GPOS/ValueFormat.hh ++++ b/src/OT/Layout/GPOS/ValueFormat.hh +@@ -116,8 +116,13 @@ + + if (!has_device ()) return ret; + ++#ifdef STACKANVIL_OREUI_COMPATIBILITY ++ bool use_x_device = font->x_ppem || font->num_coords; ++ bool use_y_device = font->y_ppem || font->num_coords; ++#else + bool use_x_device = font->x_ppem || font->has_nonzero_coords; + bool use_y_device = font->y_ppem || font->has_nonzero_coords; ++#endif + + if (!use_x_device && !use_y_device) return ret; + +--- a/src/hb-ot-layout-common.hh ++++ b/src/hb-ot-layout-common.hh +@@ -2648,7 +2648,11 @@ + const VarRegionAxis *axes = axesZ.arrayZ + (region_index * axisCount); + float v = 1.f; + ++#ifdef STACKANVIL_OREUI_COMPATIBILITY ++ unsigned int count = hb_min (unsigned (axisCount), coord_len); ++#else + unsigned int count = axisCount; ++#endif + for (unsigned int i = 0; i < count; i++) + { + int coord = i < coord_len ? coords[i] : 0; +@@ -4958,12 +4962,26 @@ + hb_position_t get_x_delta (hb_font_t *font, + const ItemVariationStore &store, + hb_scalar_cache_t *store_cache = nullptr) const +- { return !font->has_nonzero_coords ? 0 : font->em_scalef_x (get_delta (font, store, store_cache)); } ++ { ++#ifdef STACKANVIL_OREUI_COMPATIBILITY ++ float delta = get_delta (font, store, store_cache); ++ return (hb_position_t) ((float (font->x_scale) * delta) / float (font->face->get_upem ())); ++#else ++ return !font->has_nonzero_coords ? 0 : font->em_scalef_x (get_delta (font, store, store_cache)); ++#endif ++ } + + hb_position_t get_y_delta (hb_font_t *font, + const ItemVariationStore &store, + hb_scalar_cache_t *store_cache = nullptr) const +- { return !font->has_nonzero_coords ? 0 : font->em_scalef_y (get_delta (font, store, store_cache)); } ++ { ++#ifdef STACKANVIL_OREUI_COMPATIBILITY ++ float delta = get_delta (font, store, store_cache); ++ return (hb_position_t) ((float (font->y_scale) * delta) / float (font->face->get_upem ())); ++#else ++ return !font->has_nonzero_coords ? 0 : font->em_scalef_y (get_delta (font, store, store_cache)); ++#endif ++ } + + VariationDevice* copy (hb_serialize_context_t *c, + const hb_hashmap_t> *layout_variation_idx_delta_map) const +--- a/src/hb-font.cc ++++ b/src/hb-font.cc +@@ -2129,8 +2129,10 @@ + { + if (face->index >> 16) + hb_font_set_var_named_instance (font, (face->index >> 16) - 1); ++#ifndef STACKANVIL_OREUI_COMPATIBILITY + else + hb_font_set_variations (font, nullptr, 0); ++#endif + } + #endif + diff --git a/native/oreui-shaper/source.json b/native/oreui-shaper/source.json new file mode 100644 index 00000000..6de711bb --- /dev/null +++ b/native/oreui-shaper/source.json @@ -0,0 +1,5 @@ +{ + "version": "14.3.0", + "url": "https://codeload.github.com/harfbuzz/harfbuzz/tar.gz/refs/tags/14.3.0", + "sha256": "566e996a1b40486954fb7110ffe6eb88a0f7958bb466cdb023b0302618acea4a" +} diff --git a/native/oreui-shaper/tests/shaping.cpp b/native/oreui-shaper/tests/shaping.cpp new file mode 100644 index 00000000..66b8ece5 --- /dev/null +++ b/native/oreui-shaper/tests/shaping.cpp @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include + +#include +#include +#include +#include +#include + +namespace { +struct Bytes { + std::vector data; + Bytes &u16(int value) { + const auto bits = static_cast(value); + data.push_back(static_cast(bits >> 8)); + data.push_back(static_cast(bits & 255)); + return *this; + } + Bytes &u32(uint32_t value) { return u16(value >> 16).u16(value & 65535); } + Bytes &tag(const char *value) { + data.insert(data.end(), value, value + 4); + return *this; + } + Bytes &append(const Bytes &other) { + data.insert(data.end(), other.data.begin(), other.data.end()); + return *this; + } +}; + +// An authored two-glyph layout fixture. It contains no game fonts or captured tables. +struct Fixture { + Bytes gpos, gdef, fvar; + + Fixture(int base, int mark, bool variation = false) { + Bytes scripts; + scripts.u16(1).tag("DFLT").u16(8) + .u16(4).u16(0) // Script: default language only. + .u16(0).u16(65535).u16(1).u16(0); + Bytes features; + features.u16(1).tag("mark").u16(8).u16(0).u16(1).u16(0); + Bytes subtable; + subtable.u16(1).u16(12).u16(18).u16(1).u16(24).u16(36) + .u16(1).u16(1).u16(2) // Mark coverage: glyph 2. + .u16(1).u16(1).u16(1) // Base coverage: glyph 1. + .u16(1).u16(0).u16(6).u16(1).u16(mark).u16(mark) + .u16(1).u16(4); + if (variation) { + // Format 3 anchor: X variation device, no Y device. + subtable.u16(3).u16(base).u16(base).u16(10).u16(0) + .u16(0).u16(0).u16(0x8000); + gdef.u32(0x00010003).u16(0).u16(0).u16(0).u16(0).u16(0).u32(18) + .u16(1).u32(12).u16(1).u32(22) // ItemVariationStore. + .u16(1).u16(1).u16(0).u16(0).u16(0) // Constant region. + .u16(1).u16(1).u16(1).u16(0).u16(3); // One delta of 3 design units. + fvar.u32(0x00010000).u16(16).u16(2).u16(1).u16(20).u16(0).u16(8) + .tag("wght").u32(100u << 16).u32(400u << 16).u32(900u << 16) + .u16(0).u16(256); + } else { + subtable.u16(1).u16(base).u16(base); + } + Bytes lookups; + lookups.u16(1).u16(4).u16(4).u16(0).u16(1).u16(8).append(subtable); + gpos.u32(0x00010000).u16(10).u16(10 + scripts.data.size()) + .u16(10 + scripts.data.size() + features.data.size()) + .append(scripts).append(features).append(lookups); + } +}; + +hb_blob_t *table(hb_face_t *, hb_tag_t tag, void *data) { + const auto &fixture = *static_cast(data); + const Bytes *bytes = tag == HB_TAG('G', 'P', 'O', 'S') ? &fixture.gpos + : tag == HB_TAG('G', 'D', 'E', 'F') ? &fixture.gdef + : tag == HB_TAG('f', 'v', 'a', 'r') ? &fixture.fvar : nullptr; + return bytes && !bytes->data.empty() + ? hb_blob_create(bytes->data.data(), static_cast(bytes->data.size()), + HB_MEMORY_MODE_DUPLICATE, nullptr, nullptr) + : hb_blob_get_empty(); +} + +hb_bool_t nominal(hb_font_t *, void *, hb_codepoint_t character, hb_codepoint_t *glyph, void *) { + *glyph = character == 'a' ? 1 : character == 0x301 ? 2 : 0; + return *glyph != 0; +} + +hb_position_t advance(hb_font_t *, void *, hb_codepoint_t glyph, void *) { + return glyph == 1 ? 500 : 0; +} + +void shape(const std::string &name, Fixture &fixture, int expected_x, int expected_y, + bool explicit_zero_coordinate = false) { + hb_face_t *face = hb_face_create_for_tables(table, &fixture, nullptr); + hb_face_set_upem(face, 1000); + hb_face_set_glyph_count(face, 3); + hb_font_t *font = hb_font_create(face); + hb_font_set_scale(font, 500, 500); + hb_font_funcs_t *functions = hb_font_funcs_create(); + hb_font_funcs_set_nominal_glyph_func(functions, nominal, nullptr, nullptr); + hb_font_funcs_set_glyph_h_advance_func(functions, advance, nullptr, nullptr); + hb_font_set_funcs(font, functions, nullptr, nullptr); + hb_font_funcs_destroy(functions); + if (explicit_zero_coordinate) { + const int coordinate = 0; + hb_font_set_var_coords_normalized(font, &coordinate, 1); + } + hb_buffer_t *buffer = hb_buffer_create(); + hb_buffer_add_utf8(buffer, "a\xcc\x81", -1, 0, -1); + hb_buffer_guess_segment_properties(buffer); + hb_shape(font, buffer, nullptr, 0); + unsigned count = 0; + const hb_glyph_info_t *glyphs = hb_buffer_get_glyph_infos(buffer, &count); + const hb_glyph_position_t *positions = hb_buffer_get_glyph_positions(buffer, nullptr); + const bool matched = count == 2 && glyphs[0].codepoint == 1 && glyphs[1].codepoint == 2 + && positions[0].x_advance == 500 && positions[1].x_advance == 0 + && positions[1].x_offset == expected_x && positions[1].y_offset == expected_y; + if (!matched) { + std::cerr << name << ": expected mark offset " << expected_x << "," << expected_y; + if (count == 2) std::cerr << "; got " << positions[1].x_offset << "," << positions[1].y_offset; + std::cerr << "; glyph count " << count << '\n'; + } + hb_buffer_destroy(buffer); + hb_font_destroy(font); + hb_face_destroy(face); + if (!matched) throw std::runtime_error(name); +} +} // namespace + +int main() { + Fixture positive(1, 2), negative(-1, -2), mixed(1, -1), variable(0, 0, true); + struct Case { + const char *name; + Fixture *fixture; + int x, y; + bool zero_coordinate; + }; + const Case cases[] = { + {"positive anchors round separately", &positive, -500, 0, false}, + {"negative half anchors round away from zero", &negative, -500, 0, false}, + {"opposite-sign anchors retain both rounded positions", &mixed, -498, 2, false}, + {"default variable font retains absent coordinates", &variable, -500, 0, false}, + {"explicit zero coordinates admit constant variation delta", &variable, -499, 0, true}, + }; + unsigned failures = 0; + for (const auto &control : cases) { + try { shape(control.name, *control.fixture, control.x, control.y, control.zero_coordinate); } + catch (const std::exception &) { failures++; } + } + if (failures) std::cerr << failures << " OreUI shaping controls failed\n"; + else std::cout << "OreUI shaping controls passed\n"; + return failures ? 1 : 0; +} diff --git a/native/oreui-shaper/write-manifest.cmake.in b/native/oreui-shaper/write-manifest.cmake.in new file mode 100644 index 00000000..e945f3f7 --- /dev/null +++ b/native/oreui-shaper/write-manifest.cmake.in @@ -0,0 +1,4 @@ +set(directory "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/@resource@") +file(SHA256 "${directory}/${library_name}" library_sha256) +file(WRITE "${directory}/manifest.json" +"{\n \"format\": 1,\n \"engine\": \"HarfBuzz\",\n \"version\": \"@HARFBUZZ_VERSION@\",\n \"target\": \"Bedrock 1.26.60.5 OreUI\",\n \"platform\": \"@platform@\",\n \"architecture\": \"@architecture@\",\n \"sourceSha256\": \"@HARFBUZZ_SHA256@\",\n \"patchSha256\": \"@COMPATIBILITY_SHA256@\",\n \"library\": \"${library_name}\",\n \"sha256\": \"${library_sha256}\"\n}\n") diff --git a/patches/viabedrock/deferred/0001-translate-bedrock-actor-events-to-java-statuses.patch b/patches/viabedrock/deferred/0001-translate-bedrock-actor-events-to-java-statuses.patch index b3edc79e..cdea4b67 100644 --- a/patches/viabedrock/deferred/0001-translate-bedrock-actor-events-to-java-statuses.patch +++ b/patches/viabedrock/deferred/0001-translate-bedrock-actor-events-to-java-statuses.patch @@ -1,4 +1,4 @@ -From ceae6155574f1147d94a6e814a2bf37c40dfe82e Mon Sep 17 00:00:00 2001 +From 43b813832a3a2dc09cccb7f23f0af08aca64d20b 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(entity-events): translate Bedrock actor events to Java @@ -327,7 +327,7 @@ index a4b3ec09..ae173e3f 100644 levelSoundEvent.send(BedrockProtocol.class, false); } diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java -index 6f65acbc..b91f27d8 100644 +index dde3d4b1..39d02453 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java @@ -41,6 +41,7 @@ import net.raphimc.viabedrock.api.model.ActorMotionCommand; @@ -338,8 +338,8 @@ index 6f65acbc..b91f27d8 100644 import net.raphimc.viabedrock.api.model.entity.ClientPlayerEntity; import net.raphimc.viabedrock.api.model.entity.CustomEntity; import net.raphimc.viabedrock.api.model.entity.Entity; -@@ -52,10 +53,12 @@ import net.raphimc.viabedrock.api.util.RegistryUtil; - import net.raphimc.viabedrock.api.util.TextUtil; +@@ -51,10 +52,12 @@ import net.raphimc.viabedrock.api.util.PacketFactory; + import net.raphimc.viabedrock.api.util.RegistryUtil; import net.raphimc.viabedrock.protocol.BedrockProtocol; import net.raphimc.viabedrock.protocol.ClientboundBedrockPackets; +import net.raphimc.viabedrock.protocol.data.BedrockMappingData; @@ -351,7 +351,7 @@ index 6f65acbc..b91f27d8 100644 import net.raphimc.viabedrock.protocol.data.enums.java.Relative; import net.raphimc.viabedrock.protocol.data.enums.java.generated.EquipmentSlot; import net.raphimc.viabedrock.protocol.data.generated.java.EntityDataFields; -@@ -64,6 +67,9 @@ import net.raphimc.viabedrock.protocol.model.*; +@@ -63,6 +66,9 @@ import net.raphimc.viabedrock.protocol.model.*; import net.raphimc.viabedrock.protocol.rewriter.EntityMetadataRewriter; import net.raphimc.viabedrock.protocol.rewriter.ItemRewriter; import net.raphimc.viabedrock.protocol.storage.EntityPropertyTracker; @@ -361,7 +361,7 @@ index 6f65acbc..b91f27d8 100644 import net.raphimc.viabedrock.protocol.storage.ChannelStorage; import net.raphimc.viabedrock.protocol.storage.EntityTracker; import net.raphimc.viabedrock.protocol.storage.PlayerPredictionStorage; -@@ -74,6 +80,7 @@ import net.raphimc.viabedrock.protocol.types.BedrockTypes; +@@ -73,6 +79,7 @@ import net.raphimc.viabedrock.protocol.types.BedrockTypes; import java.util.ArrayList; import java.util.List; @@ -369,7 +369,7 @@ index 6f65acbc..b91f27d8 100644 import java.util.logging.Level; public final class EntityPackets { -@@ -512,6 +519,43 @@ public final class EntityPackets { +@@ -511,6 +518,43 @@ public final class EntityPackets { hurt.send(BedrockProtocol.class); } switch (event) { @@ -413,7 +413,7 @@ index 6f65acbc..b91f27d8 100644 case HURT -> { final CompoundTag damageTypeRegistry = gameSession.getJavaRegistries().getCompoundTag(RegistryKeys.DAMAGE_TYPE); final SharedTypes_Legacy_ActorDamageCause damageCause = SharedTypes_Legacy_ActorDamageCause.getByValue(data, SharedTypes_Legacy_ActorDamageCause.Override); -@@ -541,12 +585,17 @@ public final class EntityPackets { +@@ -537,12 +581,17 @@ public final class EntityPackets { } if (entity != entityTracker.getClientPlayer()) { entity.playSound("death"); @@ -434,7 +434,7 @@ index 6f65acbc..b91f27d8 100644 } } }); -@@ -794,6 +843,34 @@ public final class EntityPackets { +@@ -790,6 +839,34 @@ public final class EntityPackets { return entity; } diff --git a/patches/viabedrock/deferred/0003-translate-server-authoritative-bedrock-requests.patch b/patches/viabedrock/deferred/0003-translate-server-authoritative-bedrock-requests.patch index a45b7518..e5f8fec3 100644 --- a/patches/viabedrock/deferred/0003-translate-server-authoritative-bedrock-requests.patch +++ b/patches/viabedrock/deferred/0003-translate-server-authoritative-bedrock-requests.patch @@ -1,4 +1,4 @@ -From 3d4d74b5f6e933e1a0f4f0525bfec56b8f199bd3 Mon Sep 17 00:00:00 2001 +From 4bd04b223ebc688babf61df6e1fdfe38d3d0580f 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(inventory): translate server-authoritative Bedrock @@ -818,13 +818,65 @@ Only successful replies capture container revisions and filter buffered slots. Error replies retain their status for ordered rollback. Batch regressions decode the real error shape and cover complete rejection, accepted predecessors, crafting, and newer authoritative contents. + +Expose ordinary native cursor return and explicit-quantity pickup through +the existing transfer request owner. Held quantity selection in the target +1.26.60.5 client returns its cursor to a retained source before the final +pickup. Java repeated clicks cannot preserve that request ownership. + +Resolve Java slots against their native containers, check source and cursor +permissions, capacity, merge compatibility and current authority, and keep +the original prediction snapshots for response reconciliation. Use one +ordinary Place or Take request for each admitted phase. This also preserves +renewed server stack identities when a later phase waits for its reply. + +Negotiate incremental ordinary-stack drag input over the existing quick-craft +phases. The 1.26.60.5 integrated native client changes actual inventory at +each destination before release: nine items become 9, then 4/4, then +3/3/3. Escape while held retains those items. Keep the initial budget and +each destination contribution independently of the live cursor, and pass +redistribution through ordered, separately predicted transfer requests. +Reuse response reconciliation, renewed IDs, rejection recovery and opening +ownership. Preserve release-time bundle extraction and unnegotiated clients. +These controls do not establish native multiplayer request grouping. + +Retain pre-close incremental drag visits while authoritative replies are pending, including a start queued behind pickup. Reject subsequent input and fresh player openings until the exact old menu finishes closing. Rejection discards unaccepted visits but still closes with reconciled items. Bind the close barrier to the container object and opening generation so an old reply cannot close a replacement. Keep temporary-input return behavior in the container close override. Regression tests cover delayed replies, queued pickup, rejection, menu replacement and player opening admission. Native delayed/rejected multiplayer drag timing remains unverified. + +Queue consecutive negotiated drag destinations as one bounded pointer path instead of charging every phase against the ordinary 32-interaction limit. A full player inventory has 36 destinations; delayed responses previously caused the 33rd queued visit to discard earlier input. Retain first-visit order and per-input revisions, deduplicate revisits, and drain one destination after each response while preserving the existing menu-generation, rejection and close owners. Release, new starts and unrelated interactions remain barriers. Regressions cover a 36-destination delayed drag through close and two consecutive gestures with different distribution modes. + +Admit queued destinations through the real Java-slot resolver, including player inventory appended to a furnace. javaSize describes only a container's own slots and cannot bound its complete menu. Parameterize the 36-destination regression over chest and furnace layouts so local-slot and appended-slot paths both remain covered. + +Retain selected-source drag budgets in their original ordinary slot. Native +1.26.60.5/protocol 2225 controller and legacy-model controls distribute a +nine-item source as 9, then 4/4 with one returned, then 3/3/3. A four-item +budget from nine leaves six in the source after three destinations. +Redistribute previous contributions directly before using the unallocated +budget, then return excess to the original slot. Keep the HUD cursor empty +and preserve the native absence of cursor-drag Start/Stop signals. + +Share the existing incremental transfer queue, per-request predictions, +renewed stack identities, rejection reconciliation and opening generation. +Admit only an idle current opening with an empty cursor and a valid ordinary +source. Source selection and physical input remain separate client owners. +Tests cover source slot mappings, full and partial budgets, capacity, +queued close, complete and partial rejection, and source replacement. +The native controls declare supplied atomic transfer services and do not +establish native multiplayer grouping or complete touch input parity. + +Route a completed ordinary source tap through the same transfer owner and +retire its allocation immediately. Unlike source drawing, native source +taps send one cursor-drag Start/Stop pair even when a full target moves +nothing. The original 1.26.60.5 count-selector instructions distinguish all, +half and one modes; secondary source placement requests exactly one item, +independently of a retained partial quantity. Keep the source and target +snapshots for rejection recovery without inventing a cursor pickup. --- build.gradle | 3 +- .../api/model/PlayerInventoryScreenClose.java | 46 + .../api/model/PlayerInventoryScreenReady.java | 46 + .../model/PlayerInventoryScreenRequest.java | 53 + .../api/model/container/ChestContainer.java | 66 +- - .../api/model/container/Container.java | 1117 ++- + .../api/model/container/Container.java | 1521 +++- .../model/container/ContainerOpenContext.java | 19 + .../api/model/container/HorseContainer.java | 239 + .../NativeContainerActionBackend.java | 85 + @@ -874,9 +926,9 @@ accepted predecessors, crafting, and newer authoritative contents. .../protocol/model/recipe/ItemRecipeKey.java | 35 + .../protocol/packet/ClientPlayerPackets.java | 60 +- .../protocol/packet/InteractionPackets.java | 51 +- - .../protocol/packet/InventoryPackets.java | 1517 +++- - .../protocol/packet/JoinPackets.java | 2 +- - .../protocol/packet/MultiStatePackets.java | 34 +- + .../protocol/packet/InventoryPackets.java | 1549 ++++- + .../protocol/packet/JoinPackets.java | 3 +- + .../protocol/packet/MultiStatePackets.java | 40 +- .../protocol/packet/WorldPackets.java | 12 + .../protocol/rewriter/ItemRewriter.java | 281 +- .../rewriter/item/BundleItemRewriter.java | 65 +- @@ -884,9 +936,9 @@ accepted predecessors, crafting, and newer authoritative contents. .../protocol/storage/CraftingDataTracker.java | 206 + .../storage/InventoryRequestRecovery.java | 80 + .../storage/InventoryRequestStorage.java | 232 + - .../storage/InventoryRequestTracker.java | 801 +++ + .../storage/InventoryRequestTracker.java | 895 +++ .../protocol/storage/InventoryTracker.java | 282 +- - .../storage/PlayerInventoryScreenTracker.java | 254 + + .../storage/PlayerInventoryScreenTracker.java | 269 + .../protocol/types/item/BedrockItemType.java | 68 +- .../types/item/NetworkItemStackContext.java | 18 + .../item/NetworkItemStackDescriptorType.java | 79 +- @@ -939,8 +991,9 @@ accepted predecessors, crafting, and newer authoritative contents. .../ItemRewriterBlockEntityItemTest.java | 85 + .../rewriter/item/BundleItemRewriterTest.java | 296 + .../storage/BundleHolderPacketTest.java | 450 ++ - .../storage/BundleInventoryActionTest.java | 673 ++ + .../storage/BundleInventoryActionTest.java | 679 ++ .../CraftedItemSynchronizationTest.java | 287 + + .../storage/IncrementalInventoryDragTest.java | 659 ++ .../InventoryContainerGenerationTest.java | 118 + .../storage/InventoryMergeActionTest.java | 837 +++ .../storage/InventoryRequestRecoveryTest.java | 68 + @@ -952,13 +1005,13 @@ accepted predecessors, crafting, and newer authoritative contents. .../NativeContainerActionBackendTest.java | 95 + .../storage/NativeContainerResponseTest.java | 287 + .../PlayerInventoryScreenCodecTest.java | 71 + - .../PlayerInventoryScreenTrackerTest.java | 345 + + .../PlayerInventoryScreenTrackerTest.java | 382 + .../storage/StonecutterRecipePacketTest.java | 53 + .../types/inventory/ItemStackCodecTest.java | 12 +- .../item/NetworkItemConstructionTest.java | 171 + .../types/item/NetworkItemCountTest.java | 35 +- .../types/item/NetworkItemStackFixtures.java | 69 + - 139 files changed, 29224 insertions(+), 2050 deletions(-) + 140 files changed, 30476 insertions(+), 2052 deletions(-) create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/PlayerInventoryScreenClose.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/PlayerInventoryScreenReady.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/PlayerInventoryScreenRequest.java @@ -1039,6 +1092,7 @@ accepted predecessors, crafting, and newer authoritative contents. create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/storage/BundleHolderPacketTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/storage/BundleInventoryActionTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/storage/CraftedItemSynchronizationTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/storage/IncrementalInventoryDragTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/storage/InventoryContainerGenerationTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/storage/InventoryMergeActionTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/storage/InventoryRequestRecoveryTest.java @@ -1315,10 +1369,10 @@ index 913255f8..d535541b 100644 } diff --git a/src/main/java/net/raphimc/viabedrock/api/model/container/Container.java b/src/main/java/net/raphimc/viabedrock/api/model/container/Container.java -index 26f17241..71856177 100644 +index 26f17241..1ba16ef3 100644 --- a/src/main/java/net/raphimc/viabedrock/api/model/container/Container.java +++ b/src/main/java/net/raphimc/viabedrock/api/model/container/Container.java -@@ -21,18 +21,50 @@ import com.viaversion.viaversion.api.connection.UserConnection; +@@ -21,18 +21,56 @@ import com.viaversion.viaversion.api.connection.UserConnection; import com.viaversion.viaversion.api.minecraft.BlockPosition; import com.viaversion.viaversion.api.minecraft.item.Item; import com.viaversion.viaversion.libs.mcstructs.text.TextComponent; @@ -1341,9 +1395,11 @@ index 26f17241..71856177 100644 import net.raphimc.viabedrock.protocol.rewriter.ItemRewriter; +import net.raphimc.viabedrock.protocol.rewriter.BlockStateRewriter; +import net.raphimc.viabedrock.protocol.storage.ChunkTracker; ++import net.raphimc.viabedrock.protocol.storage.ChannelStorage; +import net.raphimc.viabedrock.protocol.storage.InventoryRequestStorage; +import net.raphimc.viabedrock.protocol.storage.InventoryRequestTracker; +import net.raphimc.viabedrock.protocol.storage.InventoryTracker; ++import net.raphimc.viabedrock.protocol.storage.GameSessionStorage; +import java.util.ArrayList; import java.util.Arrays; @@ -1351,6 +1407,7 @@ index 26f17241..71856177 100644 +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.LinkedHashSet; ++import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import java.util.Set; @@ -1358,6 +1415,9 @@ index 26f17241..71856177 100644 public abstract class Container { ++ /** Negotiates incremental ordinary-stack input using the existing quick-craft phases. */ ++ public static final String INCREMENTAL_DRAG_CHANNEL = "viabedrock:incremental_drag_v1"; ++ + private static final Set FURNACE_FUEL_ITEMS = Set.of( + "minecraft:coal_block", "minecraft:blaze_rod", "minecraft:lava_bucket", + "minecraft:dried_kelp_block", "minecraft:stick", "minecraft:bamboo", @@ -1369,7 +1429,7 @@ index 26f17241..71856177 100644 protected final UserConnection user; protected final byte containerId; protected final ContainerType type; -@@ -40,8 +72,20 @@ public abstract class Container { +@@ -40,8 +78,21 @@ public abstract class Container { protected final BlockPosition position; protected final BedrockItem[] items; protected final Set validBlockTags; @@ -1378,6 +1438,7 @@ index 26f17241..71856177 100644 + private final Map data = new HashMap<>(); + private long serverDataRevision; + private QuickCraftState quickCraftState; ++ private NativeDrag nativeDrag; - public Container(final UserConnection user, final byte containerId, final ContainerType type, final TextComponent title, final BlockPosition position, final int size, final String... validBlockTags) { + public Container( @@ -1391,7 +1452,7 @@ index 26f17241..71856177 100644 this.user = user; this.containerId = containerId; this.type = type; -@@ -49,9 +93,17 @@ public abstract class Container { +@@ -49,9 +100,17 @@ public abstract class Container { this.position = position; this.items = BedrockItem.emptyArray(size); this.validBlockTags = Set.of(validBlockTags); @@ -1410,7 +1471,7 @@ index 26f17241..71856177 100644 this.user = user; this.containerId = containerId; this.type = type; -@@ -59,9 +111,775 @@ public abstract class Container { +@@ -59,9 +118,1139 @@ public abstract class Container { this.position = position; this.items = items; this.validBlockTags = validBlockTags; @@ -1425,6 +1486,15 @@ index 26f17241..71856177 100644 + if (action != ContainerInput.QUICK_CRAFT) { + this.cancelQuickCraft(); + } ++ if (action == ContainerInput.QUICK_CRAFT && (this.nativeDrag != null ++ || (button == 0 || button == 4) && this.supportsIncrementalDrag())) { ++ try { ++ return this.handleNativeDrag(revision, javaSlot, button); ++ } catch (final RuntimeException error) { ++ this.cancelQuickCraft(); ++ throw error; ++ } ++ } + if (action == ContainerInput.QUICK_CRAFT && this.handleQuickCraftPhase(javaSlot, button)) { + return true; + } @@ -1473,6 +1543,85 @@ index 26f17241..71856177 100644 + inventoryRequestTracker.setPredictingClick(false); + } + ++ return this.sendClickRequest(clickContext, revision, itemActions); ++ } ++ ++ /** Copy the native item behind a Java slot without exposing mutable authoritative storage. */ ++ public BedrockItem getItemInJavaSlot(final short javaSlot) { ++ final var source = javaSlot < 0 ? null : this.resolveJavaSlot(this.user.get(InventoryTracker.class), javaSlot); ++ return source == null ? null : source.container().getItem(source.bedrockSlot()).copy(); ++ } ++ ++ /** Complete a native held quantity without approximating it with repeated Java clicks. */ ++ public boolean handleNativeQuantityPickup(final int revision, final short javaSlot, final int amount) { ++ final var inventory = this.user.get(InventoryTracker.class); ++ final var requests = this.user.get(InventoryRequestTracker.class); ++ if (javaSlot < 0 || amount < 1 || amount > 255 || inventory.getCurrentContainer() != this ++ || !this.user.get(GameSessionStorage.class).isInventoryServerAuthoritative() ++ || !requests.canStartRecipePlacement() || requests.isPredictingClick() ++ || !inventory.getHudContainer().getItem(0).isEmpty()) { ++ return false; ++ } ++ final var source = this.resolveJavaSlot(inventory, javaSlot); ++ if (source == null) { ++ return false; ++ } ++ final var item = source.container().getItem(source.bedrockSlot()); ++ if (item.isEmpty() || item.netId() == null || item.netId() <= 0 || item.amount() < amount ++ || !source.container().canTakeItem(source.bedrockSlot(), amount) ++ || !inventory.getHudContainer().canPlaceItem(0, item, amount)) { ++ return false; ++ } ++ this.cancelQuickCraft(); ++ final var context = new ClickContext(source.container(), source.bedrockSlot(), inventory, requests); ++ context.snapshot(source.container()); ++ requests.setPredictingClick(true); ++ final ItemStackRequestAction take; ++ try { ++ take = this.handlePickupTake(context, source.container(), source.bedrockSlot(), amount, item); ++ } finally { ++ requests.setPredictingClick(false); ++ } ++ return this.sendClickRequest(context, revision, this.singletonAction(take)); ++ } ++ ++ /** Return the whole cursor to its retained source before native quantity selection starts. */ ++ public boolean handleNativeCursorReturn(final int revision, final short javaSlot) { ++ final var inventory = this.user.get(InventoryTracker.class); ++ final var requests = this.user.get(InventoryRequestTracker.class); ++ if (javaSlot < 0 || inventory.getCurrentContainer() != this ++ || !this.user.get(GameSessionStorage.class).isInventoryServerAuthoritative() ++ || !requests.canStartRecipePlacement() || requests.isPredictingClick()) { ++ return false; ++ } ++ final var source = this.resolveJavaSlot(inventory, javaSlot); ++ if (source == null) { ++ return false; ++ } ++ final var cursor = inventory.getHudContainer().getItem(0); ++ final var item = source.container().getItem(source.bedrockSlot()); ++ if (cursor.isEmpty() || cursor.netId() == null || cursor.netId() <= 0 ++ || !item.isEmpty() && !this.user.get(ItemRewriter.class).canMerge(cursor, item) ++ || cursor.amount() + item.amount() > source.container().maxStackSize(source.bedrockSlot(), cursor) ++ || !inventory.getHudContainer().canTakeItem(0, cursor.amount()) ++ || !source.container().canPlaceItem(source.bedrockSlot(), cursor, cursor.amount())) { ++ return false; ++ } ++ this.cancelQuickCraft(); ++ final var context = new ClickContext(source.container(), source.bedrockSlot(), inventory, requests); ++ context.snapshot(source.container()); ++ requests.setPredictingClick(true); ++ final ItemStackRequestAction place; ++ try { ++ place = this.handlePickupPlace(context, source.container(), source.bedrockSlot(), (byte) 0, cursor, item); ++ } finally { ++ requests.setPredictingClick(false); ++ } ++ return this.sendClickRequest(context, revision, this.singletonAction(place)); ++ } ++ ++ private boolean sendClickRequest(final ClickContext clickContext, final int revision, ++ final List itemActions) { + if (itemActions.isEmpty()) { + return false; + } @@ -1561,6 +1710,12 @@ index 26f17241..71856177 100644 + final byte button, + final BedrockItem item) { + final int amountToTake = (button == 0 ? NativeTransferQuantity.ALL : NativeTransferQuantity.HALF).count(item.amount()); ++ return this.handlePickupTake(clickContext, container, bedrockSlot, amountToTake, item); ++ } ++ ++ private ItemStackRequestAction handlePickupTake( ++ final ClickContext clickContext, final Container container, final int bedrockSlot, ++ final int amountToTake, final BedrockItem item) { + if (!container.canTakeItem(bedrockSlot, amountToTake)) { + return null; + } @@ -1807,15 +1962,284 @@ index 26f17241..71856177 100644 + } + + return this.dropItem(container, source.bedrockSlot(), button == 0 ? 1 : item.amount()); - } - -- public boolean handleClick(final int revision, final short slot, final byte button, final ContainerInput action) { ++ } ++ ++ /** Close after retained input is reconciled. Temporary input containers return items first. */ ++ public void beginClose() { ++ this.user.get(InventoryTracker.class).markPendingClose(this); ++ PacketFactory.sendBedrockContainerClose(this.user, this.containerId, ContainerType.NONE); ++ } ++ + public void cancelQuickCraft() { -+ if (this.quickCraftState == null) { ++ if (this.quickCraftState == null && this.nativeDrag == null) { + return; + } + this.user.get(InventoryTracker.class).setCursorItemDrag(false); + this.quickCraftState = null; ++ this.nativeDrag = null; ++ } ++ ++ /** Retain accepted pointer input until its server replies settle before closing this opening. */ ++ public boolean hasIncrementalDrag() { ++ return this.nativeDrag != null; ++ } ++ ++ /** A retired source publication must not cancel a later cursor-origin gesture. */ ++ public boolean hasSelectedSourceDrag() { ++ return this.nativeDrag != null && !this.nativeDrag.cursorSource; ++ } ++ ++ public boolean supportsIncrementalDrag() { ++ return this.incrementalDragEnabled() ++ && !this.user.get(ItemRewriter.class).isBundle(this.user.get(InventoryTracker.class).getHudContainer().getItem(0)); ++ } ++ ++ private boolean incrementalDragEnabled() { ++ final var channels = this.user.get(ChannelStorage.class); ++ final var game = this.user.get(GameSessionStorage.class); ++ return channels != null && channels.hasChannel(INCREMENTAL_DRAG_CHANNEL) ++ && game != null && game.isInventoryServerAuthoritative(); ++ } ++ ++ /** Complete an ordinary selected-source tap without manufacturing a cursor pickup. */ ++ public boolean handleNativeSourceTap(final int revision, final short sourceSlot, ++ final short destinationSlot, final int quantity) { ++ final var inventory = this.user.get(InventoryTracker.class); ++ if (destinationSlot < 0 || sourceSlot == destinationSlot ++ || this.resolveJavaSlot(inventory, destinationSlot) == null ++ || !this.beginNativeSourceDrag(sourceSlot, quantity)) { ++ return false; ++ } ++ try { ++ // Native ordinary taps signal an operation even when a full destination moves nothing. ++ // Drawing uses the same transfer owner without these Start/Stop signals. ++ inventory.setCursorItemDrag(true); ++ return this.handleNativeDrag(revision, destinationSlot, (byte) 1); ++ } finally { ++ this.cancelQuickCraft(); ++ } ++ } ++ ++ /** Start an admitted selected-source gesture without moving its budget onto the cursor. */ ++ public boolean beginNativeSourceDrag(final short javaSlot, final int quantity) { ++ final var inventory = this.user.get(InventoryTracker.class); ++ final var requests = this.user.get(InventoryRequestTracker.class); ++ if (!this.incrementalDragEnabled() || this.nativeDrag != null || this.quickCraftState != null ++ || requests.hasPending() || requests.isPredictingClick() || requests.isRecovering() ++ || requests.isWaitingForInventoryOpen() || requests.isWaitingForDragClose() ++ || inventory.getContainerServerbound(this.javaContainerId()) != this ++ || !inventory.getHudContainer().getItem(0).isEmpty()) { ++ return false; ++ } ++ final var source = javaSlot < 0 ? null : this.resolveJavaSlot(inventory, javaSlot); ++ final var context = new ClickContext(this, 0, inventory, requests); ++ if (source == null || !this.isOrdinaryItemSlot(context, source)) { ++ return false; ++ } ++ final var item = source.container().getItem(source.bedrockSlot()); ++ if (item.isEmpty() || item.netId() == null || item.netId() <= 0 ++ || quantity <= 0 || quantity > item.amount() ++ || !source.container().canTakeItem(source.bedrockSlot(), quantity) ++ || this.user.get(ItemRewriter.class).isBundle(item)) { ++ return false; ++ } ++ this.nativeDrag = new NativeDrag(0, item.copy(), inventory.currentContainerGeneration(), source, quantity, false); ++ return true; ++ } ++ ++ private boolean handleNativeDrag(final int revision, final short javaSlot, final byte button) { ++ final int mode = button >> 2; ++ final int phase = button & 3; ++ final var inventory = this.user.get(InventoryTracker.class); ++ final var requests = this.user.get(InventoryRequestTracker.class); ++ if (phase == 0) { ++ this.cancelQuickCraft(); ++ final var cursor = inventory.getHudContainer().getItem(0); ++ if (cursor.isEmpty() || cursor.netId() == null || cursor.netId() <= 0 ++ || inventory.getContainerServerbound(this.javaContainerId()) != this) { ++ return false; ++ } ++ this.nativeDrag = new NativeDrag(mode, cursor.copy(), inventory.currentContainerGeneration(), ++ new SlotRef(inventory.getHudContainer(), 0), cursor.amount(), true); ++ inventory.setCursorItemDrag(true); ++ return true; ++ } ++ final var drag = this.nativeDrag; ++ if (drag == null || drag.mode != mode || !this.incrementalDragEnabled() ++ || inventory.getContainerServerbound(this.javaContainerId()) != this ++ || inventory.currentContainerGeneration() != drag.generation) { ++ this.cancelQuickCraft(); ++ return false; ++ } ++ if (phase == 2) { ++ this.cancelQuickCraft(); ++ return true; ++ } ++ if (phase != 1 || requests.hasPending() || requests.isRecovering() || requests.isWaitingForInventoryOpen()) { ++ return false; ++ } ++ final var destination = javaSlot < 0 ? null : this.resolveJavaSlot(inventory, javaSlot); ++ if (destination == null || destination.equals(drag.source) || drag.destinations.containsKey(destination)) { ++ return true; ++ } ++ final var context = new ClickContext(this, 0, inventory, requests); ++ final var cursorSlot = new SlotRef(inventory.getHudContainer(), 0); ++ if (!this.matchesDragItem(drag.source, drag.expectedSource) ++ || !drag.cursorSource && !cursorSlot.container().getItem(0).isEmpty()) { ++ this.cancelQuickCraft(); ++ return false; ++ } ++ for (final var entry : drag.destinations.entrySet()) { ++ if (!this.matchesDragItem(entry.getKey(), entry.getValue().expected)) { ++ this.cancelQuickCraft(); ++ return false; ++ } ++ } ++ if (drag.destinations.size() >= drag.item.amount() || !this.canDistributeTo(context, destination, drag.item)) { ++ return true; ++ } ++ final var allocations = new LinkedHashMap<>(drag.destinations); ++ allocations.put(destination, new DragAllocation(destination.container().getItem(destination.bedrockSlot()).amount(), 0, ++ destination.container().getItem(destination.bedrockSlot()).copy())); ++ final int share = quickCraftAmount(drag.item.amount(), allocations.size(), drag.mode); ++ final var changes = new LinkedHashMap(); ++ final var returns = new ArrayList(); ++ final var placements = new ArrayList(); ++ for (final var entry : allocations.entrySet()) { ++ final var slot = entry.getKey(); ++ final var allocation = entry.getValue(); ++ final int count = Math.min(share, slot.container().maxStackSize(slot.bedrockSlot(), drag.item) - allocation.base); ++ if (count < 0) { ++ this.cancelQuickCraft(); ++ return false; ++ } ++ final int delta = count - allocation.placed; ++ if (delta < 0) { ++ if (!slot.container().canTakeItem(slot.bedrockSlot(), -delta) ++ || drag.cursorSource && !cursorSlot.container().canPlaceItem(0, drag.item, -delta)) { ++ return false; ++ } ++ returns.add(new DragTransfer(slot, drag.source, -delta)); ++ } else if (delta > 0) { ++ if (!slot.container().canPlaceItem(slot.bedrockSlot(), drag.item, delta)) { ++ return false; ++ } ++ placements.add(new DragTransfer(drag.source, slot, delta)); ++ } ++ changes.put(slot, count); ++ } ++ final List transfers; ++ if (drag.cursorSource) { ++ returns.addAll(placements); ++ transfers = returns; ++ } else { ++ // Native source distribution redistributes prior contributions directly. Only the ++ // unallocated part of the selected budget can come from the retained source. ++ transfers = sourceDragTransfers(drag, returns, placements); ++ for (final var transfer : transfers) { ++ if (!transfer.source.container().canTakeItem(transfer.source.bedrockSlot(), transfer.count) ++ || !transfer.destination.container().canPlaceItem(transfer.destination.bedrockSlot(), drag.item, transfer.count)) { ++ return false; ++ } ++ } ++ } ++ if (transfers.isEmpty()) { ++ return true; ++ } ++ // Validate identities before prediction, including destinations which may be used later. ++ for (final var transfer : transfers) { ++ for (final var slot : List.of(transfer.source, transfer.destination)) { ++ final var item = slot.container().getItem(slot.bedrockSlot()); ++ if (!item.isEmpty() && (item.netId() == null || item.netId() <= 0)) { ++ return false; ++ } ++ } ++ } ++ final var batch = new ArrayList(); ++ requests.setPredictingClick(true); ++ try { ++ for (final var transfer : transfers) { ++ final var before = new ClickContext(this, 0, inventory, requests); ++ before.snapshot(this); ++ if (transfer.source.container() != cursorSlot.container()) { ++ before.snapshot(transfer.source.container()); ++ } ++ if (transfer.destination.container() != cursorSlot.container()) { ++ before.snapshot(transfer.destination.container()); ++ } ++ final var source = transfer.source.container().getItem(transfer.source.bedrockSlot()); ++ final var target = transfer.destination.container().getItem(transfer.destination.bedrockSlot()); ++ final int id = requests.nextRequestId(); ++ final var from = transfer.source.container().requestSlotInfo(transfer.source.bedrockSlot(), source.netId()); ++ final var to = transfer.destination.container().requestSlotInfo(transfer.destination.bedrockSlot(), target.isEmpty() ? 0 : target.netId()); ++ final ItemStackRequestAction action = transfer.destination.equals(cursorSlot) ++ ? new ItemStackRequestAction.TakeAction(transfer.count, from, to) ++ : new ItemStackRequestAction.PlaceAction(transfer.count, from, to); ++ final var remaining = this.itemAfterRemovingAmount(source, transfer.count); ++ if (!remaining.isEmpty()) { ++ remaining.setNetId(id); ++ } ++ final var placed = target.isEmpty() ? source.copy() : target.copy(); ++ placed.setAmount(target.amount() + transfer.count); ++ placed.setNetId(id); ++ transfer.source.container().setItem(transfer.source.bedrockSlot(), remaining); ++ transfer.destination.container().setItem(transfer.destination.bedrockSlot(), placed); ++ final var storage = new InventoryRequestStorage(new ItemStackRequestInfo(id, List.of(action), List.of(), TextProcessingEventOrigin.unknown), ++ revision, before.prevCursorContainer, before.prevContainers); ++ storage.capturePrediction(inventory); ++ batch.add(storage); ++ } ++ } finally { ++ requests.setPredictingClick(false); ++ } ++ requests.sendTransferBatch(batch); ++ if (this.nativeDrag == drag) { ++ drag.retain(allocations, changes, drag.source.container().getItem(drag.source.bedrockSlot())); ++ } ++ PacketFactory.sendJavaContainerSetContent(this.user, this); ++ PacketFactory.sendJavaContainerSetContent(this.user, inventory.getInventoryContainer()); ++ return true; ++ } ++ ++ private static List sourceDragTransfers(final NativeDrag drag, final List excess, ++ final List placements) { ++ final var transfers = new ArrayList(); ++ final int[] remaining = excess.stream().mapToInt(DragTransfer::count).toArray(); ++ int index = 0; ++ int available = drag.item.amount() - drag.destinations.values().stream().mapToInt(DragAllocation::placed).sum(); ++ for (final var placement : placements) { ++ int needed = placement.count; ++ while (needed > 0 && index < excess.size()) { ++ final int count = Math.min(needed, remaining[index]); ++ if (count > 0) { ++ transfers.add(new DragTransfer(excess.get(index).source, placement.destination, count)); ++ remaining[index] -= count; ++ needed -= count; ++ } ++ if (remaining[index] == 0) { ++ index++; ++ } ++ } ++ if (needed > available) { ++ throw new IllegalStateException("Native drag exceeds its retained source budget"); ++ } ++ if (needed > 0) { ++ transfers.add(new DragTransfer(drag.source, placement.destination, needed)); ++ available -= needed; ++ } ++ } ++ for (; index < excess.size(); index++) { ++ if (remaining[index] > 0) { ++ transfers.add(new DragTransfer(excess.get(index).source, drag.source, remaining[index])); ++ } ++ } ++ return transfers; ++ } ++ ++ private boolean matchesDragItem(final SlotRef slot, final BedrockItem expected) { ++ final var item = slot.container().getItem(slot.bedrockSlot()); ++ return expected.isEmpty() ? item.isEmpty() : !item.isEmpty() && item.amount() == expected.amount() ++ && this.user.get(ItemRewriter.class).canMerge(item, expected); + } + + private boolean handleQuickCraftPhase(final short javaSlot, final byte button) { @@ -2181,13 +2605,14 @@ index 26f17241..71856177 100644 + bundle.toggleSelectedItem(selectedItem); + } + } -+ } -+ + } + +- public boolean handleClick(final int revision, final short slot, final byte button, final ContainerInput action) { + public boolean handleButtonClick(final int button) { return false; } -@@ -76,11 +894,62 @@ public abstract class Container { +@@ -76,11 +1265,62 @@ public abstract class Container { } public Item[] getJavaItems() { @@ -2252,7 +2677,7 @@ index 26f17241..71856177 100644 } public BedrockItem[] getItems() { -@@ -88,29 +957,175 @@ public abstract class Container { +@@ -88,29 +1328,175 @@ public abstract class Container { } public boolean setItem(final int slot, final BedrockItem item) { @@ -2305,10 +2730,10 @@ index 26f17241..71856177 100644 + if (!previous[i].equals(items[i])) { + this.onSlotChanged(i, previous[i], items[i]); + } - } - return true; - } - ++ } ++ return true; ++ } ++ + public FullContainerName getFullContainerName(final int slot) { + return new FullContainerName(ContainerEnumName.LevelEntityContainer, null); + } @@ -2411,11 +2836,11 @@ index 26f17241..71856177 100644 + final Integer previous = this.data.put(id, value); + if (previous != null && previous == value) { + return false; -+ } + } + this.serverDataRevision++; -+ return true; -+ } -+ + return true; + } + + public Integer getData(final int id) { + return this.data.get(id); + } @@ -2435,7 +2860,7 @@ index 26f17241..71856177 100644 public int javaSlot(final int slot) { return slot; } -@@ -123,6 +1138,10 @@ public abstract class Container { +@@ -123,6 +1509,10 @@ public abstract class Container { return this.items.length; } @@ -2446,7 +2871,7 @@ index 26f17241..71856177 100644 public byte containerId() { return this.containerId; } -@@ -139,6 +1158,28 @@ public abstract class Container { +@@ -139,6 +1529,28 @@ public abstract class Container { return this.position; } @@ -2475,7 +2900,7 @@ index 26f17241..71856177 100644 public boolean isValidBlockTag(final String tag) { if (tag == null) { return false; -@@ -147,7 +1188,57 @@ public abstract class Container { +@@ -147,7 +1559,90 @@ public abstract class Container { } } @@ -2530,6 +2955,39 @@ index 26f17241..71856177 100644 + + private QuickCraftState(final int mode) { + this.mode = mode; ++ } ++ } ++ ++ private record DragAllocation(int base, int placed, BedrockItem expected) { } ++ ++ private record DragTransfer(SlotRef source, SlotRef destination, int count) { } ++ ++ private static final class NativeDrag { ++ private final int mode; ++ private final BedrockItem item; ++ private final long generation; ++ private final Map destinations = new LinkedHashMap<>(); ++ private final SlotRef source; ++ private final boolean cursorSource; ++ private BedrockItem expectedSource; ++ ++ private NativeDrag(final int mode, final BedrockItem item, final long generation, ++ final SlotRef source, final int quantity, final boolean cursorSource) { ++ this.mode = mode; ++ this.expectedSource = item.copy(); ++ this.item = item.copy(); ++ this.item.setAmount(quantity); ++ this.generation = generation; ++ this.source = source; ++ this.cursorSource = cursorSource; ++ } ++ ++ private void retain(final Map allocations, final Map changes, ++ final BedrockItem source) { ++ this.destinations.clear(); ++ changes.forEach((slot, count) -> this.destinations.put(slot, ++ new DragAllocation(allocations.get(slot).base, count, slot.container().getItem(slot.bedrockSlot()).copy()))); ++ this.expectedSource = source.copy(); + } } @@ -6352,7 +6810,7 @@ index f8282f25..c0b6e452 100644 public ItemUseModifiers useModifiers() { diff --git a/src/main/java/net/raphimc/viabedrock/api/util/PacketFactory.java b/src/main/java/net/raphimc/viabedrock/api/util/PacketFactory.java -index c51ffd40..642d5f28 100644 +index bdea523c..8ed1d090 100644 --- a/src/main/java/net/raphimc/viabedrock/api/util/PacketFactory.java +++ b/src/main/java/net/raphimc/viabedrock/api/util/PacketFactory.java @@ -21,6 +21,7 @@ import com.viaversion.nbt.tag.Tag; @@ -6363,17 +6821,19 @@ index c51ffd40..642d5f28 100644 import com.viaversion.viaversion.api.protocol.packet.PacketWrapper; import com.viaversion.viaversion.api.protocol.packet.Direction; import com.viaversion.viaversion.exception.CancelException; -@@ -28,7 +29,9 @@ import com.viaversion.viaversion.api.type.Types; +@@ -28,9 +29,11 @@ import com.viaversion.viaversion.api.type.Types; import com.viaversion.viaversion.api.type.types.version.VersionedTypes; import com.viaversion.viaversion.libs.gson.JsonNull; import com.viaversion.viaversion.protocols.v26_2to26_3.packet.ClientboundPackets26_3; + + import net.raphimc.viabedrock.api.model.NativeActorMessage; + import net.raphimc.viabedrock.api.model.NativeDeathText; import net.raphimc.viabedrock.api.model.container.Container; +import net.raphimc.viabedrock.api.model.container.dynamic.BundleContainer; import net.raphimc.viabedrock.api.model.entity.Entity; import net.raphimc.viabedrock.protocol.BedrockProtocol; import net.raphimc.viabedrock.protocol.ServerboundBedrockPackets; -@@ -39,13 +42,25 @@ import net.raphimc.viabedrock.protocol.data.enums.java.EntityEvent; +@@ -41,12 +44,19 @@ import net.raphimc.viabedrock.protocol.data.enums.java.EntityEvent; import net.raphimc.viabedrock.protocol.data.enums.java.GameEventType; import net.raphimc.viabedrock.protocol.data.enums.java.generated.CustomChatCompletionsAction; import net.raphimc.viabedrock.protocol.model.Position3f; @@ -6381,6 +6841,10 @@ index c51ffd40..642d5f28 100644 +import net.raphimc.viabedrock.protocol.rewriter.ItemRewriter; +import net.raphimc.viabedrock.protocol.storage.InventoryRequestTracker; import net.raphimc.viabedrock.protocol.storage.InventoryTracker; + import net.raphimc.viabedrock.protocol.storage.EntityTracker; + import net.raphimc.viabedrock.protocol.storage.ChannelStorage; + import net.raphimc.viabedrock.protocol.storage.GameRulesStorage; + import net.raphimc.viabedrock.protocol.storage.GameSessionStorage; import net.raphimc.viabedrock.protocol.types.BedrockTypes; +import net.raphimc.viabedrock.protocol.types.array.ArrayType; +import net.raphimc.viabedrock.protocol.types.inventory.ItemStackRequestType; @@ -6389,6 +6853,10 @@ index c51ffd40..642d5f28 100644 public final class PacketFactory { +@@ -73,8 +83,13 @@ public final class PacketFactory { + death.send(BedrockProtocol.class); + } + - public static void sendJavaBlockDestroyProgress(final UserConnection user, final int id, final BlockPosition position, final int stage) { - final PacketWrapper blockDestruction = PacketWrapper.create(ClientboundPackets26_3.BLOCK_DESTRUCTION, user); + public static void sendJavaBlockDestroyProgress( @@ -6401,7 +6869,7 @@ index c51ffd40..642d5f28 100644 blockDestruction.write(Types.VAR_INT, id); // id blockDestruction.write(Types.BLOCK_POSITION1_14, position); // position blockDestruction.write(Types.BYTE, (byte) stage); // destroy stage -@@ -53,89 +68,175 @@ public final class PacketFactory { +@@ -82,89 +97,175 @@ public final class PacketFactory { } public static void sendJavaSystemChat(final UserConnection user, final Tag message) { @@ -6465,10 +6933,8 @@ index c51ffd40..642d5f28 100644 + containerSetContent.send(BedrockProtocol.class); + sendJavaCursorForHudUpdate(user, container); + } - } - -- public static void sendJavaGameEvent(final UserConnection user, final GameEventType event, final float value) { -- final PacketWrapper gameEvent = PacketWrapper.create(ClientboundPackets26_3.GAME_EVENT, user); ++ } ++ + public static void sendJavaCursorForHudUpdate(final UserConnection user, final Container container) { + final InventoryTracker tracker = user.get(InventoryTracker.class); + if (container != tracker.getHudContainer() || tracker.getCurrentContainer() == null @@ -6494,8 +6960,10 @@ index c51ffd40..642d5f28 100644 + final PacketWrapper packet = PacketWrapper.create(ServerboundBedrockPackets.ITEM_STACK_REQUEST, user); + packet.write(new ArrayType<>(new ItemStackRequestType(user.get(ItemRewriter.class).stackRequestResultType()), BedrockTypes.UNSIGNED_VAR_INT), requests); + packet.sendToServer(BedrockProtocol.class); -+ } -+ + } + +- public static void sendJavaGameEvent(final UserConnection user, final GameEventType event, final float value) { +- final PacketWrapper gameEvent = PacketWrapper.create(ClientboundPackets26_3.GAME_EVENT, user); + public static void sendJavaGameEvent( + final UserConnection user, final GameEventType event, final float value) { + final PacketWrapper gameEvent = @@ -6600,7 +7068,7 @@ index c51ffd40..642d5f28 100644 loadingScreen.write(BedrockTypes.VAR_INT, type.getValue()); // type loadingScreen.write(Types.BOOLEAN, loadingScreenId != null); // has loading screen id if (loadingScreenId != null) { -@@ -146,20 +247,53 @@ public final class PacketFactory { +@@ -175,20 +276,53 @@ public final class PacketFactory { public static void writeJavaDisconnect(final PacketWrapper wrapper, final String reason) { switch (wrapper.getPacketType().state()) { @@ -8675,7 +9143,7 @@ index 00000000..19e0148c + +} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/model/ItemDefinitionProperties.java b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemDefinitionProperties.java -index 4d1d9d28..65becabc 100644 +index 5547a49e..1a17ac5c 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/model/ItemDefinitionProperties.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemDefinitionProperties.java @@ -5,14 +5,18 @@ @@ -8697,7 +9165,7 @@ index 4d1d9d28..65becabc 100644 import java.util.function.Function; /** Compiled state belongs to a constructed definition, independently of its network declarations. */ -@@ -26,24 +30,37 @@ final class ItemDefinitionProperties { +@@ -26,16 +30,22 @@ final class ItemDefinitionProperties { private final Kind kind; private final BuiltinItemFactories.Definition builtin; private final String block; @@ -8705,6 +9173,7 @@ index 4d1d9d28..65becabc 100644 private final List updates = new ArrayList<>(); private ItemStackingProperties stacking; private ItemStorageProperties storage = ItemStorageProperties.NONE; + private ItemAirUseProperties airUse = ItemAirUseProperties.NONE; + private ItemCookingProperties cooking; private ItemBlockPlacer blockPlacer; + private Integer wearableSlot; @@ -8720,7 +9189,10 @@ index 4d1d9d28..65becabc 100644 this.identifier = identifier; this.kind = kind; this.builtin = builtin; - this.block = block; +@@ -43,11 +53,18 @@ final class ItemDefinitionProperties { + if (kind == Kind.BUILTIN) { + this.airUse = ItemAirUseProperties.builtinConstructor(identifier); + } + this.minimumBaseGameVersion = minimumBaseGameVersion; + this.tags = switch (kind) { + case BUILTIN, BLOCK, SLAB, DOOR -> BuiltinItemFactories.tags(identifier); @@ -8736,8 +9208,8 @@ index 4d1d9d28..65becabc 100644 this.description = builtin != null ? builtin.componentDescription() : kind == Kind.COMPONENT ? ordinaryDescription(identifier) : null; } -@@ -52,10 +69,58 @@ final class ItemDefinitionProperties { - this.updates.add(components.copy()); +@@ -57,10 +74,58 @@ final class ItemDefinitionProperties { + this.airUse = this.airUse.withComponentData(components, this.component()); this.stacking = this.stacking.withComponentData(components); this.storage = this.storage.withComponentData(components); + this.cooking = this.component() ? this.cooking.withComponentItemData(components, this.identifier, resolveBlock) @@ -8795,7 +9267,7 @@ index 4d1d9d28..65becabc 100644 if (components.get("minecraft:block_placer") instanceof CompoundTag placer) { this.blockPlacer = this.blockPlacer.withNetworkData(placer, this.identifier, resolveBlock); } -@@ -66,10 +131,26 @@ final class ItemDefinitionProperties { +@@ -71,10 +136,26 @@ final class ItemDefinitionProperties { } } @@ -8822,7 +9294,7 @@ index 4d1d9d28..65becabc 100644 BuiltinItemFactories.Definition builtin() { return this.builtin; } -@@ -82,10 +163,32 @@ final class ItemDefinitionProperties { +@@ -91,10 +172,32 @@ final class ItemDefinitionProperties { return this.blockPlacer; } @@ -8856,7 +9328,7 @@ index 4d1d9d28..65becabc 100644 return this.category; } diff --git a/src/main/java/net/raphimc/viabedrock/protocol/model/ItemRegistry.java b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemRegistry.java -index 1c5d6065..46435634 100644 +index 9612f9e3..ea6afa9d 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/model/ItemRegistry.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemRegistry.java @@ -6,9 +6,15 @@ @@ -8979,7 +9451,7 @@ index 1c5d6065..46435634 100644 public ItemStackingProperties stackingProperties(final BedrockItem item) { final var definition = this.definition(item); return definition != null ? ((ItemDefinitionProperties) definition.receiver()).stacking() : null; -@@ -133,6 +204,75 @@ public final class ItemRegistry { +@@ -141,6 +212,75 @@ public final class ItemRegistry { return BuiltinItemAliases.rewrite(query); } @@ -9788,15 +10260,16 @@ index 223db3d9..d8e307df 100644 } chunkTracker.trackBlockPrediction(sequence, position, placedPosition); diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/InventoryPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/InventoryPackets.java -index 7b3138e1..6c1434ed 100644 +index 7b3138e1..fdc8fd76 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/InventoryPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/InventoryPackets.java -@@ -17,26 +17,28 @@ +@@ -17,26 +17,31 @@ */ package net.raphimc.viabedrock.protocol.packet; -import net.raphimc.viabedrock.protocol.storage.GameSessionStorage; -- ++import java.nio.charset.StandardCharsets; + import com.viaversion.viaversion.libs.gson.JsonParser; import net.raphimc.viabedrock.api.resourcepack.ui.ServerFormUi; import net.raphimc.viabedrock.api.model.NativeFormMessage; @@ -9811,6 +10284,7 @@ index 7b3138e1..6c1434ed 100644 +import com.viaversion.nbt.tag.ListTag; import com.viaversion.nbt.tag.StringTag; import com.viaversion.viaversion.api.connection.UserConnection; ++import com.viaversion.viaversion.api.protocol.packet.State; import com.viaversion.viaversion.api.minecraft.BlockPosition; import com.viaversion.viaversion.api.minecraft.Holder; +import com.viaversion.viaversion.api.minecraft.item.Item; @@ -9823,7 +10297,7 @@ index 7b3138e1..6c1434ed 100644 import com.viaversion.viaversion.libs.fastutil.ints.IntObjectPair; import com.viaversion.viaversion.libs.mcstructs.converter.impl.v1_21_5.NbtConverter_v1_21_5; import com.viaversion.viaversion.libs.mcstructs.core.Identifier; -@@ -58,6 +60,9 @@ import com.viaversion.viaversion.libs.mcstructs.text.components.StringComponent; +@@ -58,6 +63,9 @@ import com.viaversion.viaversion.libs.mcstructs.text.components.StringComponent; import com.viaversion.viaversion.libs.mcstructs.text.components.TranslationComponent; import com.viaversion.viaversion.protocols.v26_2to26_3.packet.ClientboundPackets26_3; import com.viaversion.viaversion.protocols.v26_2to26_3.packet.ServerboundPackets26_3; @@ -9833,7 +10307,7 @@ index 7b3138e1..6c1434ed 100644 import net.lenni0451.mcstructs_bedrock.forms.Form; import net.lenni0451.mcstructs_bedrock.forms.elements.*; import net.lenni0451.mcstructs_bedrock.forms.serializer.FormSerializer; -@@ -66,14 +71,16 @@ import net.lenni0451.mcstructs_bedrock.forms.types.CustomForm; +@@ -66,14 +74,16 @@ import net.lenni0451.mcstructs_bedrock.forms.types.CustomForm; import net.lenni0451.mcstructs_bedrock.forms.types.ModalForm; import net.lenni0451.mcstructs_bedrock.text.utils.BedrockTextUtils; import net.raphimc.viabedrock.ViaBedrock; @@ -9853,7 +10327,7 @@ index 7b3138e1..6c1434ed 100644 import net.raphimc.viabedrock.api.model.container.player.InventoryContainer; import net.raphimc.viabedrock.api.model.entity.Entity; import net.raphimc.viabedrock.api.util.PacketFactory; -@@ -81,18 +88,28 @@ import net.raphimc.viabedrock.api.util.TextUtil; +@@ -81,18 +91,28 @@ import net.raphimc.viabedrock.api.util.TextUtil; import net.raphimc.viabedrock.protocol.BedrockProtocol; import net.raphimc.viabedrock.protocol.ClientboundBedrockPackets; import net.raphimc.viabedrock.protocol.ServerboundBedrockPackets; @@ -9883,7 +10357,7 @@ index 7b3138e1..6c1434ed 100644 import java.util.ArrayList; import java.util.List; -@@ -102,11 +119,65 @@ public final class InventoryPackets { +@@ -102,11 +122,95 @@ public final class InventoryPackets { private static final int DIALOG_BUTTON_WIDTH = 200; private static final int DIALOG_FAKE_BUTTON_WIDTH = 300; @@ -9892,6 +10366,36 @@ index 7b3138e1..6c1434ed 100644 + "This is not actually a button, but has to be one because dialogs don't support adding" + + " text only elements. Clicking it has the same effect as closing the dialog."; + ++ public static void handleContainerClose(final UserConnection user, final Container container) { ++ if (!user.get(InventoryRequestTracker.class).deferDragClose(container, () -> closeContainer(user, container))) { ++ closeContainer(user, container); ++ } ++ } ++ ++ private static void closeContainer(final UserConnection user, final Container container) { ++ final var inventory = user.get(InventoryTracker.class); ++ if (container instanceof LecternContainer lectern) { ++ lectern.sendPageUpdate(); ++ inventory.setCurrentContainerClosed(false); ++ } else { ++ container.beginClose(); ++ } ++ } ++ ++ static void sendIncrementalDragCapability(final UserConnection user) { ++ final var game = user.get(GameSessionStorage.class); ++ final var channels = user.get(ChannelStorage.class); ++ if (user.getProtocolInfo().getClientState() != State.PLAY ++ || game == null || !game.isInventoryServerAuthoritative() ++ || channels == null || !channels.hasChannel(Container.INCREMENTAL_DRAG_CHANNEL)) { ++ return; ++ } ++ final var packet = PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, user); ++ packet.write(Types.STRING, "minecraft:register"); ++ packet.write(Types.REMAINING_BYTES, Container.INCREMENTAL_DRAG_CHANNEL.getBytes(StandardCharsets.UTF_8)); ++ packet.send(BedrockProtocol.class); ++ } ++ + static void handleStonecutterSelection(final PacketWrapper wrapper) { + wrapper.cancel(); + final long playerId = wrapper.read(BedrockTypes.VAR_LONG); @@ -9952,7 +10456,7 @@ index 7b3138e1..6c1434ed 100644 final CommandBlockSession command = wrapper.user().get(CommandBlockSession.class); if (command != null && command.matches((byte) (int) wrapper.get(Types.VAR_INT, 0))) { -@@ -116,20 +187,33 @@ public final class InventoryPackets { +@@ -116,20 +220,33 @@ public final class InventoryPackets { return; } @@ -9990,7 +10494,7 @@ index 7b3138e1..6c1434ed 100644 wrapper.cancel(); } } -@@ -208,347 +292,863 @@ public final class InventoryPackets { +@@ -208,347 +325,858 @@ public final class InventoryPackets { } public static void register(final BedrockProtocol protocol) { @@ -10012,34 +10516,17 @@ index 7b3138e1..6c1434ed 100644 - ViaBedrock.getPlatform().getLogger().log(Level.WARNING, "Unknown ContainerType: " + rawType); - wrapper.cancel(); - return; -+ protocol.registerClientbound(ClientboundBedrockPackets.CREATIVE_CONTENT, null, wrapper -> { -+ wrapper.cancel(); -+ final ItemRewriter itemRewriter = wrapper.user().get(ItemRewriter.class); -+ final int groups = wrapper.read(BedrockTypes.UNSIGNED_VAR_INT); -+ for (int i = 0; i < groups; i++) { -+ wrapper.read(Types.UNSIGNED_BYTE); // category -+ wrapper.read(BedrockTypes.STRING); // name -+ readCreativeItem(wrapper, itemRewriter); // icon - } +- } - final BlockPosition position = wrapper.read(BedrockTypes.BLOCK_POSITION); // position - final long actorId = wrapper.read(BedrockTypes.VAR_LONG); // entity unique id - final Entity actor = actorId == -1 ? null : wrapper.user().get(EntityTracker.class).getEntityByUid(actorId); - +- - if (inventoryTracker.isAnyScreenOpen()) { - ViaBedrock.getPlatform().getLogger().log(Level.WARNING, "Server tried to open container while another container is open"); - PacketFactory.sendBedrockContainerClose(wrapper.user(), (byte) -1, ContainerType.NONE); - wrapper.cancel(); - return; -+ itemRewriter.clearCreativeItems(); -+ final int entries = wrapper.read(BedrockTypes.UNSIGNED_VAR_INT); -+ for (int i = 0; i < entries; i++) { -+ wrapper.read(BedrockTypes.UNSIGNED_VAR_INT); // creative net ID -+ final BedrockItem item = readCreativeItem(wrapper, itemRewriter); -+ wrapper.read(BedrockTypes.UNSIGNED_VAR_INT); // group index -+ if (!item.isEmpty()) { -+ itemRewriter.addCreativeItem(item); -+ } - } +- } - final BedrockBlockEntity blockEntity = actorId == -1 ? chunkTracker.getBlockEntity(position) : null; - final ContainerTitle openingTitle = containerTitle(wrapper.user(), type, actor, blockEntity, - new TranslationComponent(switch (type) { @@ -10049,40 +10536,7 @@ index 7b3138e1..6c1434ed 100644 - default -> "container." + blockStateRewriter.tag(chunkTracker.getBlockState(position)); - })); - final TextComponent title = openingTitle.caption(); -+ }); -+ protocol.registerClientbound( -+ ClientboundBedrockPackets.CONTAINER_OPEN, -+ ClientboundPackets26_3.OPEN_SCREEN, -+ wrapper -> { -+ final ChunkTracker chunkTracker = wrapper.user().get(ChunkTracker.class); -+ final BlockStateRewriter blockStateRewriter = -+ wrapper.user().get(BlockStateRewriter.class); -+ final InventoryTracker inventoryTracker = -+ wrapper.user().get(InventoryTracker.class); -+ final byte containerId = wrapper.read(Types.BYTE); // container id -+ final byte rawType = wrapper.read(Types.BYTE); // type -+ final ContainerType type = ContainerType.getByValue(rawType); -+ if (type == null) { -+ ViaBedrock.getPlatform() -+ .getLogger() -+ .log(Level.WARNING, "Unknown ContainerType: " + rawType); -+ wrapper.cancel(); -+ return; -+ } -+ final BlockPosition position = -+ wrapper.read(BedrockTypes.BLOCK_POSITION); // position -+ final long entityUniqueId = -+ wrapper.read(BedrockTypes.VAR_LONG); // entity unique id -+ final ContainerOpenContext openContext = new ContainerOpenContext(rawType, position, entityUniqueId); -+ final Entity containerEntity = -+ wrapper.user() -+ .get(EntityTracker.class) -+ .getEntityByUid(entityUniqueId); -+ final ContainerType screenType = -+ vehicleScreenType(type, containerEntity); -+ final BlockPosition containerPosition = -+ screenType != type || entityUniqueId != -1 ? null : position; - +- - final Container container; - switch (type) { - case INVENTORY -> { @@ -10098,10 +10552,7 @@ index 7b3138e1..6c1434ed 100644 - wrapper.cancel(); - if (!WorldPackets.openNativeCommandBlock(wrapper.user(), containerId, blockEntity)) { - PacketFactory.sendBedrockContainerClose(wrapper.user(), containerId, ContainerType.NONE); -+ if (screenType == ContainerType.HORSE) { -+ openHorseContainer(wrapper, containerId, containerEntity, null, openContext); -+ return; - } +- } - return; - } - case NONE, CAULDRON, JUKEBOX, ARMOR, HAND, HUD, DECORATED_POT -> { // Bedrock client can't open these containers @@ -10117,7 +10568,7 @@ index 7b3138e1..6c1434ed 100644 - } - } - inventoryTracker.setCurrentContainer(container); - +- - wrapper.write(Types.VAR_INT, (int) containerId); // container id - final int javaMenuId = BedrockProtocol.MAPPINGS.getBedrockToJavaContainers().get(type); - wrapper.write(Types.VAR_INT, javaMenuId); // type @@ -10132,11 +10583,97 @@ index 7b3138e1..6c1434ed 100644 - } - }); - protocol.registerClientbound(ClientboundBedrockPackets.INVENTORY_CONTENT, ClientboundPackets26_3.CONTAINER_SET_CONTENT, wrapper -> { -- final ItemRewriter itemRewriter = wrapper.user().get(ItemRewriter.class); ++ protocol.registerClientbound(ClientboundBedrockPackets.CREATIVE_CONTENT, null, wrapper -> { ++ wrapper.cancel(); + final ItemRewriter itemRewriter = wrapper.user().get(ItemRewriter.class); - final int containerId = wrapper.read(BedrockTypes.UNSIGNED_VAR_INT); // container id - final BedrockItem[] items = wrapper.read(itemRewriter.newItemArrayType()); // items - final FullContainerName containerName = wrapper.read(BedrockTypes.FULL_CONTAINER_NAME); // container name - final BedrockItem storageItem = wrapper.read(itemRewriter.newItemType()); // storage item +- +- final InventoryTracker inventoryTracker = wrapper.user().get(InventoryTracker.class); +- final Container container = inventoryTracker.getContainerClientbound((byte) containerId, containerName, storageItem); +- if (container != null && container.setItems(items)) { +- PacketFactory.writeJavaContainerSetContent(wrapper, container); +- } else { +- wrapper.cancel(); ++ final int groups = wrapper.read(BedrockTypes.UNSIGNED_VAR_INT); ++ for (int i = 0; i < groups; i++) { ++ wrapper.read(Types.UNSIGNED_BYTE); // category ++ wrapper.read(BedrockTypes.STRING); // name ++ readCreativeItem(wrapper, itemRewriter); // icon + } +- }); +- protocol.registerClientbound(ClientboundBedrockPackets.INVENTORY_SLOT, ClientboundPackets26_3.CONTAINER_SET_SLOT, wrapper -> { +- final ItemRewriter itemRewriter = wrapper.user().get(ItemRewriter.class); +- final int containerId = wrapper.read(BedrockTypes.UNSIGNED_VAR_INT); // container id +- final int slot = wrapper.read(BedrockTypes.UNSIGNED_VAR_INT); // slot +- final FullContainerName containerName = wrapper.read(BedrockTypes.OPTIONAL_FULL_CONTAINER_NAME); // container name +- final BedrockItem storageItem = wrapper.read(itemRewriter.optionalNewItemType()); // storage item +- final BedrockItem item = wrapper.read(itemRewriter.newItemType()); // item +- +- final InventoryTracker inventoryTracker = wrapper.user().get(InventoryTracker.class); +- final Container container = inventoryTracker.getContainerClientbound((byte) containerId, containerName, storageItem); +- if (container != null && container.setItem(slot, item)) { +- if (container.type() == ContainerType.HUD && slot == 0) { // cursor item +- wrapper.setPacketType(ClientboundPackets26_3.SET_CURSOR_ITEM); +- } else { +- wrapper.write(Types.VAR_INT, (int) container.javaContainerId()); // container id +- wrapper.write(Types.VAR_INT, 0); // revision +- wrapper.write(Types.SHORT, (short) container.javaSlot(slot)); // slot ++ ++ itemRewriter.clearCreativeItems(); ++ final int entries = wrapper.read(BedrockTypes.UNSIGNED_VAR_INT); ++ for (int i = 0; i < entries; i++) { ++ wrapper.read(BedrockTypes.UNSIGNED_VAR_INT); // creative net ID ++ final BedrockItem item = readCreativeItem(wrapper, itemRewriter); ++ wrapper.read(BedrockTypes.UNSIGNED_VAR_INT); // group index ++ if (!item.isEmpty()) { ++ itemRewriter.addCreativeItem(item); + } +- wrapper.write(VersionedTypes.V26_3.item, container.getJavaItem(slot)); // item +- } else { +- wrapper.cancel(); + } + }); ++ protocol.registerClientbound( ++ ClientboundBedrockPackets.CONTAINER_OPEN, ++ ClientboundPackets26_3.OPEN_SCREEN, ++ wrapper -> { ++ final ChunkTracker chunkTracker = wrapper.user().get(ChunkTracker.class); ++ final BlockStateRewriter blockStateRewriter = ++ wrapper.user().get(BlockStateRewriter.class); ++ final InventoryTracker inventoryTracker = ++ wrapper.user().get(InventoryTracker.class); ++ final byte containerId = wrapper.read(Types.BYTE); // container id ++ final byte rawType = wrapper.read(Types.BYTE); // type ++ final ContainerType type = ContainerType.getByValue(rawType); ++ if (type == null) { ++ ViaBedrock.getPlatform() ++ .getLogger() ++ .log(Level.WARNING, "Unknown ContainerType: " + rawType); ++ wrapper.cancel(); ++ return; ++ } ++ final BlockPosition position = ++ wrapper.read(BedrockTypes.BLOCK_POSITION); // position ++ final long entityUniqueId = ++ wrapper.read(BedrockTypes.VAR_LONG); // entity unique id ++ final ContainerOpenContext openContext = new ContainerOpenContext(rawType, position, entityUniqueId); ++ final Entity containerEntity = ++ wrapper.user() ++ .get(EntityTracker.class) ++ .getEntityByUid(entityUniqueId); ++ final ContainerType screenType = ++ vehicleScreenType(type, containerEntity); ++ final BlockPosition containerPosition = ++ screenType != type || entityUniqueId != -1 ? null : position; ++ ++ if (screenType == ContainerType.HORSE) { ++ openHorseContainer(wrapper, containerId, containerEntity, null, openContext); ++ return; ++ } ++ + if (inventoryTracker.isAnyScreenOpen()) { + ViaBedrock.getPlatform() + .getLogger() @@ -10297,22 +10834,7 @@ index 7b3138e1..6c1434ed 100644 + } + container.resolveBlockBinding(); + inventoryTracker.setCurrentContainer(container, openContext); - -- final InventoryTracker inventoryTracker = wrapper.user().get(InventoryTracker.class); -- final Container container = inventoryTracker.getContainerClientbound((byte) containerId, containerName, storageItem); -- if (container != null && container.setItems(items)) { -- PacketFactory.writeJavaContainerSetContent(wrapper, container); -- } else { -- wrapper.cancel(); -- } -- }); -- protocol.registerClientbound(ClientboundBedrockPackets.INVENTORY_SLOT, ClientboundPackets26_3.CONTAINER_SET_SLOT, wrapper -> { -- final ItemRewriter itemRewriter = wrapper.user().get(ItemRewriter.class); -- final int containerId = wrapper.read(BedrockTypes.UNSIGNED_VAR_INT); // container id -- final int slot = wrapper.read(BedrockTypes.UNSIGNED_VAR_INT); // slot -- final FullContainerName containerName = wrapper.read(BedrockTypes.OPTIONAL_FULL_CONTAINER_NAME); // container name -- final BedrockItem storageItem = wrapper.read(itemRewriter.optionalNewItemType()); // storage item -- final BedrockItem item = wrapper.read(itemRewriter.newItemType()); // item ++ + wrapper.write(Types.VAR_INT, (int) containerId); // container id + final int javaMenuId = screenType == ContainerType.CONTAINER && size == 54 + ? BedrockProtocol.MAPPINGS.getJavaMenuId( @@ -10352,22 +10874,7 @@ index 7b3138e1..6c1434ed 100644 + wrapper.read(BedrockTypes.FULL_CONTAINER_NAME); // container name + final BedrockItem storageItem = + wrapper.read(itemRewriter.newItemType()); // storage item - -- final InventoryTracker inventoryTracker = wrapper.user().get(InventoryTracker.class); -- final Container container = inventoryTracker.getContainerClientbound((byte) containerId, containerName, storageItem); -- if (container != null && container.setItem(slot, item)) { -- if (container.type() == ContainerType.HUD && slot == 0) { // cursor item -- wrapper.setPacketType(ClientboundPackets26_3.SET_CURSOR_ITEM); -- } else { -- wrapper.write(Types.VAR_INT, (int) container.javaContainerId()); // container id -- wrapper.write(Types.VAR_INT, 0); // revision -- wrapper.write(Types.SHORT, (short) container.javaSlot(slot)); // slot -- } -- wrapper.write(VersionedTypes.V26_3.item, container.getJavaItem(slot)); // item -- } else { -- wrapper.cancel(); -- } -- }); ++ + final InventoryTracker inventoryTracker = + wrapper.user().get(InventoryTracker.class); + final Container container = @@ -10536,24 +11043,6 @@ index 7b3138e1..6c1434ed 100644 - return; - } - final short damage = wrapper.read(BedrockTypes.SHORT_LE); // damage -- -- final BedrockItem item = armorSlot.getValue() < armorContainer.size() ? armorContainer.getItem(armorSlot.getValue()) : BedrockItem.empty(); -- if (item.tag() == null) { -- item.setTag(new CompoundTag()); -- } -- item.tag().putInt("Damage", damage); -- -- final EquipmentSlot equipmentSlot = switch (armorSlot) { -- case Head -> EquipmentSlot.HEAD; -- case Torso -> EquipmentSlot.CHEST; -- case Legs -> EquipmentSlot.LEGS; -- case Feet -> EquipmentSlot.FEET; -- case Body -> EquipmentSlot.BODY; -- }; -- wrapper.write(Types.BYTE, (byte) (equipmentSlot.ordinal() | (i < (size - 1) ? Byte.MIN_VALUE : 0))); // slot -- wrapper.write(VersionedTypes.V26_3.item, wrapper.user().get(ItemRewriter.class).javaItem(item)); // item -- } -- }); + final InventoryTracker inventoryTracker = + wrapper.user().get(InventoryTracker.class); + final FullContainerName[] removedContainers = @@ -10636,13 +11125,23 @@ index 7b3138e1..6c1434ed 100644 + final Container armorContainer = + wrapper.user().get(InventoryTracker.class).getArmorContainer(); -- protocol.registerServerbound(ServerboundPackets26_3.CONTAINER_CLICK, null, wrapper -> { -- wrapper.cancel(); -- final int containerId = wrapper.read(Types.VAR_INT); // container id -- final int revision = wrapper.read(Types.VAR_INT); // revision -- final short slot = wrapper.read(Types.SHORT); // slot -- final byte button = wrapper.read(Types.BYTE); // button -- final ContainerInput action = ContainerInput.values()[wrapper.read(Types.VAR_INT)]; // action +- final BedrockItem item = armorSlot.getValue() < armorContainer.size() ? armorContainer.getItem(armorSlot.getValue()) : BedrockItem.empty(); +- if (item.tag() == null) { +- item.setTag(new CompoundTag()); +- } +- item.tag().putInt("Damage", damage); +- +- final EquipmentSlot equipmentSlot = switch (armorSlot) { +- case Head -> EquipmentSlot.HEAD; +- case Torso -> EquipmentSlot.CHEST; +- case Legs -> EquipmentSlot.LEGS; +- case Feet -> EquipmentSlot.FEET; +- case Body -> EquipmentSlot.BODY; +- }; +- wrapper.write(Types.BYTE, (byte) (equipmentSlot.ordinal() | (i < (size - 1) ? Byte.MIN_VALUE : 0))); // slot +- wrapper.write(VersionedTypes.V26_3.item, wrapper.user().get(ItemRewriter.class).javaItem(item)); // item +- } +- }); + wrapper.write( + Types.VAR_INT, + wrapper.user() @@ -10667,6 +11166,14 @@ index 7b3138e1..6c1434ed 100644 + } + final short damage = wrapper.read(BedrockTypes.SHORT_LE); // damage +- protocol.registerServerbound(ServerboundPackets26_3.CONTAINER_CLICK, null, wrapper -> { +- wrapper.cancel(); +- final int containerId = wrapper.read(Types.VAR_INT); // container id +- final int revision = wrapper.read(Types.VAR_INT); // revision +- final short slot = wrapper.read(Types.SHORT); // slot +- final byte button = wrapper.read(Types.BYTE); // button +- final ContainerInput action = ContainerInput.values()[wrapper.read(Types.VAR_INT)]; // action +- - final InventoryTracker inventoryTracker = wrapper.user().get(InventoryTracker.class); - if (inventoryTracker.getPendingCloseContainer() != null) { - wrapper.cancel(); @@ -10803,6 +11310,9 @@ index 7b3138e1..6c1434ed 100644 + } + final Container container = + inventoryTracker.getContainerServerbound((byte) containerId); ++ if (wrapper.user().get(InventoryRequestTracker.class).isWaitingForDragClose()) { ++ return; ++ } + final var nativeContainer = wrapper.user().get(NativeContainerUiState.class); + if (nativeContainer != null && nativeContainer.blocksVanillaClicks()) { + if (container != null) { @@ -11140,16 +11650,8 @@ index 7b3138e1..6c1434ed 100644 + return; + } + -+ if (container.javaContainerId() != container.containerId()) { -+ wrapper.set(Types.BYTE, 0, container.containerId()); -+ } -+ if (container instanceof LecternContainer lectern) { -+ wrapper.cancel(); -+ lectern.sendPageUpdate(); -+ inventoryTracker.setCurrentContainerClosed(false); -+ return; -+ } -+ inventoryTracker.markPendingClose(container); ++ wrapper.cancel(); ++ handleContainerClose(wrapper.user(), container); + }); } - inventoryTracker.markPendingClose(container); @@ -11159,7 +11661,7 @@ index 7b3138e1..6c1434ed 100644 protocol.registerServerbound( ServerboundPackets26_3.EDIT_BOOK, null, -@@ -599,33 +1199,55 @@ public final class InventoryPackets { +@@ -599,33 +1227,55 @@ public final class InventoryPackets { updatedTag.put("pages", updatedPages); book.setTag(updatedTag); }); @@ -11237,7 +11739,7 @@ index 7b3138e1..6c1434ed 100644 if (type == ContainerType.CONTAINER && actor != null) { final String filtered = actorTitle(actor, ActorDataIds.FILTERED_NAME); final String name = filtered.isEmpty() ? actorTitle(actor, ActorDataIds.NAME) : filtered; -@@ -637,7 +1259,7 @@ public final class InventoryPackets { +@@ -637,7 +1287,7 @@ public final class InventoryPackets { final String raw = customName.getValue(); return new ContainerTitle(TextUtil.stringToTextComponent(user.get(ResourcePackStorage.class).getTexts().translate(raw)), raw, false); } @@ -11246,7 +11748,7 @@ index 7b3138e1..6c1434ed 100644 } private static String actorTitle(final Entity actor, final ActorDataIds field) { -@@ -663,21 +1285,23 @@ public final class InventoryPackets { +@@ -663,21 +1313,23 @@ public final class InventoryPackets { case HOPPER, MINECART_HOPPER -> size == 5 ? ContainerUi.Kind.HOPPER : null; case DISPENSER -> size == 9 ? ContainerUi.Kind.DISPENSER : null; case DROPPER -> size == 9 ? ContainerUi.Kind.DROPPER : null; @@ -11274,7 +11776,7 @@ index 7b3138e1..6c1434ed 100644 return; } final var resources = wrapper.user().get(ResourcePackStorage.class); -@@ -686,22 +1310,33 @@ public final class InventoryPackets { +@@ -686,22 +1338,33 @@ public final class InventoryPackets { } final var state = wrapper.user().get(NativeContainerUiState.class); final var ui = resources.getContainerUi(); @@ -11312,7 +11814,7 @@ index 7b3138e1..6c1434ed 100644 private static Dialog createFormDialog(final UserConnection user, final int id, final Form form, final ServerFormUi.Presentation presentation) { final ResourcePackStorage resourcePackStorage = user.get(ResourcePackStorage.class); -@@ -992,13 +1627,148 @@ public final class InventoryPackets { +@@ -992,13 +1655,148 @@ public final class InventoryPackets { } } @@ -11463,7 +11965,7 @@ index 7b3138e1..6c1434ed 100644 for (String line : BedrockTextUtils.split(text, "\n")) { final TextInput textInput = new TextInput(TextUtil.stringToTextComponent(line)); textInput.setMaxLength(Integer.MAX_VALUE); -@@ -1006,14 +1776,53 @@ public final class InventoryPackets { +@@ -1006,14 +1804,53 @@ public final class InventoryPackets { dialog.getInputs().add(new Input("dummy", textInput)); } } else { // VB compatibility @@ -11520,10 +12022,10 @@ index 7b3138e1..6c1434ed 100644 private ContainerUi.Title presentation() { diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/JoinPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/JoinPackets.java -index 5af0eca2..fa556209 100644 +index 71902c4e..ea61d2d6 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/JoinPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/JoinPackets.java -@@ -447,7 +447,7 @@ public final class JoinPackets { +@@ -450,7 +450,7 @@ public final class JoinPackets { itemDefinitions.remove(entry.identifier()); } } @@ -11532,11 +12034,19 @@ index 5af0eca2..fa556209 100644 if (itemEntry.componentData() != null && itemEntry.version() == ItemVersion.DataDriven && itemRewriter.getComponentItems().contains(itemEntry.identifier())) { itemDefinitions.addFromNetworkTag(itemEntry.identifier(), itemEntry.componentData()); } +@@ -581,6 +581,7 @@ public final class JoinPackets { + user.get(CameraEffectsStorage.class).sendSnapshot(); + + WorldPackets.sendNativeCommandCapability(user); ++ InventoryPackets.sendIncrementalDragCapability(user); + SkinChangeStorage.refreshCapability(user); + + clientPlayer.createTeam(); diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java -index 67129d78..c04231bd 100644 +index 3b5821f5..6a3aae6a 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java -@@ -17,17 +17,14 @@ +@@ -17,17 +17,16 @@ */ package net.raphimc.viabedrock.protocol.packet; @@ -11552,6 +12062,8 @@ index 67129d78..c04231bd 100644 +import net.raphimc.viabedrock.protocol.storage.PlayerInventoryScreenTracker; import net.raphimc.viabedrock.protocol.storage.NativeContainerUiState; - ++import net.raphimc.viabedrock.protocol.storage.InventoryTracker; ++import net.raphimc.viabedrock.api.model.container.Container; +import net.raphimc.viabedrock.api.model.NativeContainerAction; +import net.raphimc.viabedrock.api.model.NativeContainerStatus; +import net.raphimc.viabedrock.api.model.NativeContainerMessage; @@ -11560,7 +12072,7 @@ index 67129d78..c04231bd 100644 import com.viaversion.viaversion.api.protocol.remapper.PacketHandler; import com.viaversion.viaversion.api.type.Types; import com.viaversion.viaversion.protocols.base.ClientboundLoginPackets; -@@ -196,6 +193,17 @@ public final class MultiStatePackets { +@@ -197,6 +196,17 @@ public final class MultiStatePackets { } return; } @@ -11578,7 +12090,17 @@ index 67129d78..c04231bd 100644 if (channel.equals(CameraAimAssistActivation.Control.CHANNEL)) { if (wrapper.user().getProtocolInfo().getClientState() == State.PLAY) { final var input = io.netty.buffer.Unpooled.wrappedBuffer(wrapper.read(Types.REMAINING_BYTES)); -@@ -265,6 +273,10 @@ public final class MultiStatePackets { +@@ -255,6 +265,9 @@ public final class MultiStatePackets { + if (channel.equals("minecraft:unregister")) { + final List channels = Arrays.asList(new String(wrapper.read(Types.SERVERBOUND_CUSTOM_PAYLOAD_DATA), StandardCharsets.UTF_8).split("\0")); + wrapper.user().get(ChannelStorage.class).removeChannels(channels); ++ if (channels.contains(Container.INCREMENTAL_DRAG_CHANNEL)) { ++ wrapper.user().get(InventoryTracker.class).cancelQuickCraft(); ++ } + if (channels.contains(PlayerBlockUse.CHANNEL) || channels.contains(BlockUsePrediction.CHANNEL) + || channels.contains(BlockUsePrediction.AIR_CHANNEL) + || channels.contains(PlayerInputPermissions.CHANNEL)) { +@@ -269,6 +282,10 @@ public final class MultiStatePackets { if (channels.contains(EmoteProvider.CHANNEL) || channels.contains(EmoteAction.Policy.CHANNEL)) { EmoteProvider.refreshActionCapability(wrapper.user()); } @@ -11589,7 +12111,7 @@ index 67129d78..c04231bd 100644 if (channels.contains(PlayerInputPermissions.CHANNEL) && wrapper.user().has(PlayerInputStorage.class)) { wrapper.user().get(PlayerInputStorage.class).withdrawRawInput(); } -@@ -353,6 +365,10 @@ public final class MultiStatePackets { +@@ -368,7 +385,12 @@ public final class MultiStatePackets { if (channels.contains(EmoteProvider.CHANNEL) || channels.contains(EmoteAction.Policy.CHANNEL)) { EmoteProvider.refreshActionCapability(wrapper.user()); } @@ -11598,8 +12120,10 @@ index 67129d78..c04231bd 100644 + screens.refreshCapability(); + } WorldPackets.sendNativeCommandCapability(wrapper.user()); ++ InventoryPackets.sendIncrementalDragCapability(wrapper.user()); if (channels.contains(LocatorBarSnapshot.CHANNEL) && wrapper.user().has(PlayerWaypointTracker.class)) { wrapper.user().get(PlayerWaypointTracker.class).sendSnapshot(); + } diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/WorldPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/WorldPackets.java index f737c0b2..31341a0e 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/WorldPackets.java @@ -11631,7 +12155,7 @@ index f737c0b2..31341a0e 100644 final BlockEntity javaBlockEntity = BlockEntityRewriter.toJava(wrapper.user(), chunkTracker.getBlockState(bedrockBlockEntity.position()), bedrockBlockEntity); 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 36b2cf55..a5193c10 100644 +index c261dc25..9b074bc1 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemRewriter.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemRewriter.java @@ -35,10 +35,13 @@ import com.viaversion.viaversion.libs.fastutil.ints.Int2ObjectMap; @@ -11880,13 +12404,13 @@ index 36b2cf55..a5193c10 100644 + final ItemAnimationData animation = this.animationData.getOrDefault(identifier, ItemAnimationData.EMPTY); javaItem.dataContainer().set(StructuredDataKey.CUSTOM_DATA, ItemDataRewriter.withBedrockItemData(javaItem.dataContainer().get(StructuredDataKey.CUSTOM_DATA), -- identifier, this.animationData.getOrDefault(identifier, ItemAnimationData.EMPTY), nativeStack)); +- identifier, this.animationData.getOrDefault(identifier, ItemAnimationData.EMPTY), nativeStack, + identifier, new ItemAnimationData(animation.useDurationTicks(), this.registry.tags(bedrockItem), -+ animation.swingDurationTicks(), animation.kineticWeapon()), nativeStack)); ++ animation.swingDurationTicks(), animation.kineticWeapon()), nativeStack, + this.registry.airUseProperties(bedrockItem).retainsAirUse())); if (this.registry.storageProperties(bedrockItem).storage()) { final var storageSource = new NativeItemStorageData.Source() { - @Override -@@ -389,7 +450,8 @@ public class ItemRewriter extends StoredObject { +@@ -390,7 +451,8 @@ public class ItemRewriter extends StoredObject { return null; } final CompoundTag javaTag = new CompoundTag(); @@ -11896,7 +12420,7 @@ index 36b2cf55..a5193c10 100644 javaTag.put("id", new StringTag(javaId)); javaTag.put("count", new IntTag(javaItem.amount())); if (javaItem.tag() != null) { -@@ -403,28 +465,49 @@ public class ItemRewriter extends StoredObject { +@@ -404,28 +466,49 @@ public class ItemRewriter extends StoredObject { return null; } @@ -11965,7 +12489,7 @@ index 36b2cf55..a5193c10 100644 } /** Resolve primary text through the selected definition and current language sources. */ -@@ -448,7 +531,33 @@ public class ItemRewriter extends StoredObject { +@@ -449,7 +532,33 @@ public class ItemRewriter extends StoredObject { } public BedrockItem bedrockItem(final Item javaItem) { @@ -12000,7 +12524,7 @@ index 36b2cf55..a5193c10 100644 } public BedrockItem[] bedrockItems(final Item[] javaItems) { -@@ -471,8 +580,72 @@ public class ItemRewriter extends StoredObject { +@@ -472,8 +581,72 @@ public class ItemRewriter extends StoredObject { return this.registry.identifier(item); } @@ -12075,7 +12599,7 @@ index 36b2cf55..a5193c10 100644 } /** Match network-loaded stack queries while their connection registry remains current. */ -@@ -518,6 +691,17 @@ public class ItemRewriter extends StoredObject { +@@ -519,6 +692,17 @@ public class ItemRewriter extends StoredObject { return properties == null ? 0 : properties.maximum(); } @@ -12093,7 +12617,7 @@ index 36b2cf55..a5193c10 100644 public Type itemType() { return this.itemType; } -@@ -542,10 +726,13 @@ public class ItemRewriter extends StoredObject { +@@ -543,10 +727,13 @@ public class ItemRewriter extends StoredObject { return this.newItemArrayType; } @@ -12885,10 +13409,10 @@ index 00000000..5fddc92e +} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryRequestTracker.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryRequestTracker.java new file mode 100644 -index 00000000..8ae83ff7 +index 00000000..d5f075f5 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryRequestTracker.java -@@ -0,0 +1,801 @@ +@@ -0,0 +1,895 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2023-2025 RK_01/RaphiMC and contributors @@ -12966,6 +13490,7 @@ index 00000000..8ae83ff7 + private CraftedItemSynchronization craftedItemSynchronization; + private TransferBatch transferBatch; + private Set batchChanges; ++ private DeferredDragClose deferredDragClose; + + public InventoryRequestTracker(final UserConnection user) { + super(user); @@ -13124,7 +13649,21 @@ index 00000000..8ae83ff7 + + public boolean queueClick(final Container container, final int revision, final short slot, + final byte button, final ContainerInput action) { -+ return this.queueInteraction(new QueuedClick(container, revision, slot, button, action)); ++ final var click = new QueuedClick(container, revision, slot, button, action); ++ if (action == ContainerInput.QUICK_CRAFT && (button == 1 || button == 5) ++ && !this.recovery.isActive() && !this.isWaitingForDragClose() ++ && container.supportsIncrementalDrag() && container.getItemInJavaSlot(slot) != null) { ++ final long generation = this.user().get(InventoryTracker.class).currentContainerGeneration(); ++ if (this.queuedInteractions.peekLast() instanceof QueuedDragVisits visits ++ && visits.container() == container && visits.button() == button && visits.generation() == generation) { ++ visits.add(click); ++ return true; ++ } ++ final var visits = new QueuedDragVisits(container, button, generation); ++ visits.add(click); ++ return this.queueInteraction(visits); ++ } ++ return this.queueInteraction(click); + } + + public boolean isWaitingForInventoryOpen() { @@ -13157,7 +13696,7 @@ index 00000000..8ae83ff7 + } + + void closeTemporaryInventoryIfIdle() { -+ if (!this.closeTemporaryInventory || this.hasPending() ++ if (!this.closeTemporaryInventory || this.hasPending() || this.isWaitingForDragClose() + || this.isWaitingForInventoryOpen() || !this.queuedInteractions.isEmpty()) { + return; + } @@ -13201,11 +13740,55 @@ index 00000000..8ae83ff7 + }); + } + ++ /** Preserve only input received before this close, on the same native drag opening. */ ++ public boolean deferDragClose(final Container container, final Runnable close) { ++ final var inventory = this.user().get(InventoryTracker.class); ++ if (this.deferredDragClose != null) { ++ return this.deferredDragClose.container() == container ++ && this.deferredDragClose.generation() == inventory.currentContainerGeneration(); ++ } ++ final boolean queuedStart = container != null && container.supportsIncrementalDrag() ++ && this.queuedInteractions.stream().anyMatch(interaction -> interaction instanceof QueuedClick click ++ && click.container() == container && click.action() == ContainerInput.QUICK_CRAFT ++ && (click.button() == 0 || click.button() == 4)); ++ if (container == null || !container.hasIncrementalDrag() && !queuedStart ++ || inventory.getCurrentContainer() != container ++ || !this.hasPending() && this.queuedInteractions.isEmpty()) { ++ return false; ++ } ++ this.deferredDragClose = new DeferredDragClose(container, inventory.currentContainerGeneration(), close); ++ return true; ++ } ++ ++ public boolean isWaitingForDragClose() { ++ return this.deferredDragClose != null; ++ } ++ ++ private void finishDragClose() { ++ final var close = this.deferredDragClose; ++ if (close == null) { ++ return; ++ } ++ final var inventory = this.user().get(InventoryTracker.class); ++ if (inventory.getCurrentContainer() != close.container() ++ || inventory.currentContainerGeneration() != close.generation()) { ++ this.deferredDragClose = null; ++ return; ++ } ++ if (!this.hasPending() && this.queuedInteractions.isEmpty() && !this.recovery.isActive()) { ++ this.deferredDragClose = null; ++ close.close().run(); ++ } ++ } ++ + public boolean queueButtonClick(final Container container, final int button) { + return this.queueInteraction(new QueuedButtonClick(container, button)); + } + + boolean queueInteraction(final QueuedInteraction interaction) { ++ if (this.deferredDragClose != null && interaction.isScreenInteraction()) { ++ return false; ++ } + if (this.recovery.isActive() || this.queuedInteractions.size() >= MAX_QUEUED_INTERACTIONS) { + this.queuedInteractions.clear(); + return false; @@ -13252,6 +13835,7 @@ index 00000000..8ae83ff7 + } + + public void tick() { ++ this.finishDragClose(); + if (this.pendingInventoryOpenClicks.expire( + System.nanoTime(), RESPONSE_TIMEOUT_NANOS)) { + this.clearQueuedInteractions(); @@ -13387,6 +13971,7 @@ index 00000000..8ae83ff7 + this.drainQueuedInteractions(); + } + ++ this.finishDragClose(); + for (Container container : changed) { + PacketFactory.sendJavaContainerSetContent(this.user(), container); + } @@ -13422,6 +14007,7 @@ index 00000000..8ae83ff7 + if (batch.responses().values().stream().allMatch(response -> response.response().result() == ItemStackNetResult.Success)) { + this.drainQueuedInteractions(); + } ++ this.finishDragClose(); + for (final var container : changed) { + PacketFactory.sendJavaContainerSetContent(this.user(), container); + } @@ -13568,14 +14154,21 @@ index 00000000..8ae83ff7 + final QueuedInteraction interaction = this.queuedInteractions.removeFirst(); + if (!interaction.isValid(tracker) || !interaction.apply()) { + this.clearQueuedInteractions(); ++ this.finishDragClose(); + final Container current = tracker.getCurrentContainer(); + PacketFactory.sendJavaContainerSetContent(this.user(), + current != null ? current : tracker.getInventoryContainer()); + return; + } ++ if (interaction instanceof QueuedDragVisits visits && !visits.clicks().isEmpty()) { ++ this.queuedInteractions.addFirst(visits); ++ } + } ++ this.finishDragClose(); + } + ++ private record DeferredDragClose(Container container, long generation, Runnable close) { } ++ + static final class PendingInventoryOpenClicks { + + private final ArrayDeque clicks = new ArrayDeque<>(); @@ -13681,6 +14274,31 @@ index 00000000..8ae83ff7 + } + } + ++ /** One pointer path remains one queued interaction, bounded by the menu's unique Java slots. */ ++ private record QueuedDragVisits(Container container, byte button, long generation, ArrayDeque clicks, ++ Set slots) implements QueuedInteraction { ++ ++ private QueuedDragVisits(final Container container, final byte button, final long generation) { ++ this(container, button, generation, new ArrayDeque<>(), new HashSet<>()); ++ } ++ ++ private void add(final QueuedClick click) { ++ if (this.slots.add(click.slot())) { ++ this.clicks.addLast(click); ++ } ++ } ++ ++ @Override ++ public boolean isValid(final InventoryTracker tracker) { ++ return QueuedInteraction.super.isValid(tracker) && tracker.currentContainerGeneration() == this.generation; ++ } ++ ++ @Override ++ public boolean apply() { ++ return this.clicks.removeFirst().apply(); ++ } ++ } ++ + private record QueuedButtonClick(Container container, int button) implements QueuedInteraction { + + @Override @@ -14113,10 +14731,10 @@ index 7472880e..19ffffe3 100644 } diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/PlayerInventoryScreenTracker.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/PlayerInventoryScreenTracker.java new file mode 100644 -index 00000000..9f2ccf71 +index 00000000..39383eac --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/PlayerInventoryScreenTracker.java -@@ -0,0 +1,254 @@ +@@ -0,0 +1,269 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -14218,6 +14836,11 @@ index 00000000..9f2ccf71 + } + return; + } ++ if (this.requests().isWaitingForDragClose()) { ++ this.latestOpening = request.opening(); ++ this.sendClose(this.latestOpening); ++ return; ++ } + this.requests().clearQueuedScreenInteractions(); + this.latestOpening = request.opening(); + this.requested = true; @@ -14232,8 +14855,11 @@ index 00000000..9f2ccf71 + if (request.opening() != this.latestOpening || !this.requested) { + return; + } ++ final var owner = this.acknowledgedContainer(request.opening()); + this.requested = false; -+ this.requests().clearQueuedScreenInteractions(); ++ if (!this.requests().deferDragClose(owner, this::service)) { ++ this.requests().clearQueuedScreenInteractions(); ++ } + } + this.service(); + } @@ -14284,10 +14910,7 @@ index 00000000..9f2ccf71 + if (this.requested) { + this.requested = false; + if (this.enabled()) { -+ final var packet = PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, this.user()); -+ packet.write(Types.STRING, PlayerInventoryScreenClose.CHANNEL); -+ packet.write(PlayerInventoryScreenClose.TYPE, new PlayerInventoryScreenClose(this.latestOpening)); -+ packet.send(BedrockProtocol.class); ++ this.sendClose(this.latestOpening); + } + } + this.requests().clearQueuedScreenInteractions(); @@ -14308,7 +14931,17 @@ index 00000000..9f2ccf71 + this.service(); + } + ++ private void sendClose(final long opening) { ++ final var packet = PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, this.user()); ++ packet.write(Types.STRING, PlayerInventoryScreenClose.CHANNEL); ++ packet.write(PlayerInventoryScreenClose.TYPE, new PlayerInventoryScreenClose(opening)); ++ packet.send(BedrockProtocol.class); ++ } ++ + private void service() { ++ if (this.requests().isWaitingForDragClose()) { ++ return; ++ } + final var inventory = this.inventory(); + if (this.nativeOpening != 0 && inventory.getCurrentContainer() instanceof InventoryContainer + && (!this.requested || this.nativeOpening != this.latestOpening)) { @@ -36859,10 +37492,10 @@ index 00000000..27a645bc +} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/storage/BundleInventoryActionTest.java b/src/test/java/net/raphimc/viabedrock/protocol/storage/BundleInventoryActionTest.java new file mode 100644 -index 00000000..bb1e3d35 +index 00000000..4b5143ca --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/protocol/storage/BundleInventoryActionTest.java -@@ -0,0 +1,673 @@ +@@ -0,0 +1,679 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -36964,6 +37597,12 @@ index 00000000..bb1e3d35 + @Test + void singleSlotSecondaryQuickCraftExtractsCursorBundleAfterDuplicateAdds() throws Exception { + final Fixture f = new Fixture(true); ++ final var channels = new ChannelStorage(); ++ channels.addChannels(List.of(Container.INCREMENTAL_DRAG_CHANNEL)); ++ f.storage.put(ChannelStorage.class, channels); ++ final var game = mock(GameSessionStorage.class); ++ when(game.isInventoryServerAuthoritative()).thenReturn(true); ++ f.storage.put(GameSessionStorage.class, game); + final BundleContainer bundle = f.bundle(0); + bundle.setItem(0, item(306, 4, 4)); + f.inventory.getHudContainer().setItem(0, bundleItem(0)); @@ -37829,6 +38468,671 @@ index 00000000..628f6ced + } + +} +diff --git a/src/test/java/net/raphimc/viabedrock/protocol/storage/IncrementalInventoryDragTest.java b/src/test/java/net/raphimc/viabedrock/protocol/storage/IncrementalInventoryDragTest.java +new file mode 100644 +index 00000000..d3a62ca2 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/protocol/storage/IncrementalInventoryDragTest.java +@@ -0,0 +1,659 @@ ++/* ++ * 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.protocol.storage; ++ ++import com.viaversion.viaversion.api.connection.ProtocolInfo; ++import com.viaversion.viaversion.api.connection.UserConnection; ++import com.viaversion.viaversion.api.protocol.packet.State; ++import net.raphimc.viabedrock.ViaBedrock; ++import net.raphimc.viabedrock.api.model.container.ChestContainer; ++import net.raphimc.viabedrock.api.model.container.block.FurnaceContainer; ++import net.raphimc.viabedrock.api.model.container.Container; ++import net.raphimc.viabedrock.api.util.PacketFactory; ++import net.raphimc.viabedrock.platform.ViaBedrockPlatform; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ItemStackNetResult; ++import net.raphimc.viabedrock.protocol.data.enums.java.generated.ContainerInput; ++import net.raphimc.viabedrock.protocol.model.BedrockItem; ++import net.raphimc.viabedrock.protocol.packet.InventoryPackets; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ContainerType; ++import net.raphimc.viabedrock.protocol.model.inventory.ItemStackRequestAction; ++import net.raphimc.viabedrock.protocol.model.inventory.ItemStackRequestInfo; ++import net.raphimc.viabedrock.protocol.model.inventory.ItemStackRequestSlotInfo; ++import net.raphimc.viabedrock.protocol.model.inventory.ItemStackResponseContainerInfo; ++import net.raphimc.viabedrock.protocol.model.inventory.ItemStackResponseInfo; ++import net.raphimc.viabedrock.protocol.model.inventory.ItemStackResponseSlotInfo; ++import net.raphimc.viabedrock.protocol.rewriter.ItemRewriter; ++import org.junit.jupiter.api.Test; ++import org.junit.jupiter.params.ParameterizedTest; ++import org.junit.jupiter.params.provider.CsvSource; ++import org.junit.jupiter.params.provider.ValueSource; ++import org.mockito.MockedStatic; ++ ++import java.util.ArrayList; ++import java.util.HashMap; ++import java.util.List; ++import java.util.Map; ++import java.util.logging.Logger; ++ ++import static org.junit.jupiter.api.Assertions.*; ++import static org.mockito.Mockito.*; ++ ++class IncrementalInventoryDragTest { ++ ++ @ParameterizedTest ++ @CsvSource({"9,0,9", "4,0,4", "1,0,1", "9,60,4", "4,64,0"}) ++ void selectedSourceTapSignalsOnceAndTransfersOnlyTheAdmittedQuantity( ++ final int quantity, final int destinationCount, final int transferred) { ++ try (var f = new Fixture()) { ++ f.source(8, 9); ++ if (destinationCount > 0) { ++ f.menu.setItem(9, item(destinationCount, 301)); ++ } ++ assertTrue(f.menu.handleNativeSourceTap(0, (short) 8, (short) 9, quantity)); ++ assertFalse(f.menu.hasIncrementalDrag()); ++ assertEquals(9 - transferred, f.menu.getItem(8).amount()); ++ f.counts(destinationCount + transferred, 0, 0, 0); ++ assertEquals(transferred == 0 ? 0 : 1, f.sent.size()); ++ if (transferred > 0) { ++ f.transfers(new int[]{8, 9, transferred}); ++ f.accept(); ++ assertEquals(9 - transferred, f.menu.getItem(8).amount()); ++ f.counts(destinationCount + transferred, 0, 0, 0); ++ } ++ assertFalse(f.click(10, 1)); ++ f.packets.verify(() -> PacketFactory.sendBedrockCursorItemDrag(f.user, true), times(1)); ++ f.packets.verify(() -> PacketFactory.sendBedrockCursorItemDrag(f.user, false), times(1)); ++ } ++ } ++ ++ @Test ++ void rejectedSelectedSourceTapRestoresBothSlotsWithoutLeavingDrawingState() { ++ try (var f = new Fixture()) { ++ f.source(8, 9); ++ f.menu.setItem(9, item(60, 301)); ++ assertTrue(f.menu.handleNativeSourceTap(0, (short) 8, (short) 9, 9)); ++ f.requests.handleResponse(new ItemStackResponseInfo(ItemStackNetResult.getByValue(49), f.batch.getFirst().requestId(), null)); ++ assertEquals(9, f.menu.getItem(8).amount()); ++ assertEquals(300, f.menu.getItem(8).netId()); ++ f.counts(60, 0, 0, 0); ++ assertEquals(301, f.menu.getItem(9).netId()); ++ assertFalse(f.menu.hasIncrementalDrag()); ++ assertFalse(f.requests.hasPending()); ++ assertFalse(f.click(10, 1)); ++ assertEquals(1, f.sent.size()); ++ } ++ } ++ ++ @ParameterizedTest ++ @ValueSource(ints = {9, 4}) ++ void selectedBudgetRedistributesDirectlyWithoutPickingUpTheSource(final int budget) { ++ try (var f = new Fixture()) { ++ f.source(8, 9); ++ assertTrue(f.menu.beginNativeSourceDrag((short) 8, budget)); ++ assertTrue(f.sent.isEmpty()); ++ assertTrue(f.click(8, 1)); ++ assertTrue(f.click(9, 1)); ++ f.counts(budget, 0, 0, 0); ++ assertEquals(9 - budget, f.menu.getItem(8).amount()); ++ f.transfers(new int[]{8, 9, budget}); ++ f.accept(); ++ final int renewed = f.menu.getItem(9).netId(); ++ ++ assertTrue(f.click(10, 1)); ++ final int half = budget / 2; ++ f.counts(half, half, 0, 0); ++ if (budget == 9) { ++ f.transfers(new int[]{9, 10, 4}, new int[]{9, 8, 1}); ++ } else { ++ f.transfers(new int[]{9, 10, 2}); ++ } ++ assertEquals(renewed, ((ItemStackRequestAction.PlaceAction) f.batch.getFirst().actions().getFirst()).source().stackNetworkId()); ++ f.accept(); ++ assertTrue(f.click(11, 1)); ++ if (budget == 9) { ++ f.transfers(new int[]{9, 11, 1}, new int[]{10, 11, 1}, new int[]{8, 11, 1}); ++ } else { ++ f.transfers(new int[]{9, 11, 1}, new int[]{10, 8, 1}); ++ } ++ f.accept(); ++ final int third = budget / 3; ++ f.counts(third, third, third, 0); ++ assertEquals(9 - third * 3, f.menu.getItem(8).amount()); ++ assertTrue(f.click(-999, 2)); ++ assertFalse(f.click(12, 1)); ++ assertEquals(3, f.sent.size()); ++ verify(f.inventory, never()).setCursorItemDrag(true); ++ f.packets.verify(() -> PacketFactory.sendBedrockCursorItemDrag(eq(f.user), anyBoolean()), never()); ++ } ++ } ++ ++ @ParameterizedTest ++ @CsvSource({"false,8,9", "true,0,3", "true,31,3", "true,3,4"}) ++ void sourceDistributionRetainsItsOwnerAcrossContainerInventoryAndHotbarSlots( ++ final boolean furnace, final int source, final int first) { ++ try (var f = new Fixture(furnace)) { ++ f.source(source, 9); ++ assertTrue(f.menu.beginNativeSourceDrag((short) source, 9)); ++ f.input(first, 1); ++ f.input(first + 1, 1); ++ f.input(first + 2, 1); ++ f.input(-999, 2); ++ for (int reply = 0; reply < 3; reply++) { ++ f.accept(); ++ } ++ assertFalse(f.requests.hasPending()); ++ assertTrue(f.menu.getItemInJavaSlot((short) source).isEmpty()); ++ for (int slot = first; slot < first + 3; slot++) { ++ assertEquals(3, f.menu.getItemInJavaSlot((short) slot).amount()); ++ } ++ assertTrue(f.inventory.getHudContainer().getItem(0).isEmpty()); ++ } ++ } ++ ++ @Test ++ void sourceDistributionReturnsOnlyItsOwnContributionFromNearlyFullDestinations() { ++ try (var f = new Fixture()) { ++ f.source(8, 9); ++ f.menu.setItem(9, item(63, 301)); ++ f.menu.setItem(10, item(60, 302)); ++ assertTrue(f.menu.beginNativeSourceDrag((short) 8, 9)); ++ f.input(9, 1); ++ f.accept(); ++ f.input(10, 1); ++ f.accept(); ++ f.input(11, 1); ++ f.accept(); ++ f.counts(64, 63, 3, 0); ++ assertEquals(2, f.menu.getItem(8).amount()); ++ assertEquals(132, f.menu.getItem(8).amount() + f.menu.getItem(9).amount() ++ + f.menu.getItem(10).amount() + f.menu.getItem(11).amount()); ++ } ++ } ++ ++ @Test ++ void rejectedSourceRedistributionRestoresAcceptedItemsAndDiscardsVisitsBeforeClosing() { ++ try (var f = new Fixture()) { ++ f.source(8, 9); ++ assertTrue(f.menu.beginNativeSourceDrag((short) 8, 4)); ++ f.input(9, 1); ++ f.accept(); ++ f.input(10, 1); ++ f.input(11, 1); ++ f.input(-999, 2); ++ InventoryPackets.handleContainerClose(f.user, f.menu); ++ for (final var request : List.copyOf(f.batch)) { ++ f.requests.handleResponse(new ItemStackResponseInfo(ItemStackNetResult.getByValue(49), request.requestId(), null)); ++ } ++ f.counts(4, 0, 0, 0); ++ assertEquals(5, f.menu.getItem(8).amount()); ++ assertSame(f.menu, f.inventory.getPendingCloseContainer()); ++ assertFalse(f.requests.hasPending()); ++ assertFalse(f.menu.hasIncrementalDrag()); ++ assertEquals(2, f.sent.size()); ++ } ++ } ++ ++ @Test ++ void partialSourceRedistributionRejectionKeepsItsAcceptedPredecessorAndStopsTheGesture() { ++ try (var f = new Fixture()) { ++ f.source(8, 9); ++ assertTrue(f.menu.beginNativeSourceDrag((short) 8, 9)); ++ f.input(9, 1); ++ f.accept(); ++ f.input(10, 1); ++ final var replies = f.responses(); ++ // The second transfer returns one remainder to the source. Reject it before ++ // the first transfer's successful reply to exercise ordered reconciliation. ++ f.requests.handleResponse(new ItemStackResponseInfo(ItemStackNetResult.getByValue(49), replies.get(1).requestId(), null)); ++ f.requests.handleResponse(replies.getFirst()); ++ f.counts(5, 4, 0, 0); ++ assertTrue(f.menu.getItem(8).isEmpty()); ++ assertFalse(f.requests.hasPending()); ++ assertFalse(f.menu.hasIncrementalDrag()); ++ assertFalse(f.click(11, 1)); ++ assertEquals(2, f.sent.size()); ++ } ++ } ++ ++ @Test ++ void sourceChangesCancelDistributionWithoutConsumingTheReplacementStack() { ++ try (var f = new Fixture()) { ++ f.source(8, 9); ++ assertTrue(f.menu.beginNativeSourceDrag((short) 8, 4)); ++ f.input(9, 1); ++ f.accept(); ++ f.menu.setItem(8, item(6, 999)); ++ assertFalse(f.click(10, 1)); ++ f.counts(4, 0, 0, 0); ++ assertEquals(6, f.menu.getItem(8).amount()); ++ assertFalse(f.menu.hasIncrementalDrag()); ++ assertEquals(1, f.sent.size()); ++ } ++ } ++ ++ @Test ++ void eachDestinationRedistributesBeforeReleaseAndUsesRenewedStackIds() { ++ try (var f = new Fixture()) { ++ assertTrue(f.click(-999, 0)); ++ assertTrue(f.click(9, 1)); ++ f.counts(9, 0, 0, 0); ++ f.accept(); ++ final int renewed = f.menu.getItem(9).netId(); ++ assertTrue(f.click(10, 1)); ++ f.counts(4, 4, 0, 1); ++ final var returned = assertInstanceOf(ItemStackRequestAction.TakeAction.class, f.batch.getFirst().actions().getFirst()); ++ assertEquals(renewed, returned.source().stackNetworkId()); ++ final var placed = assertInstanceOf(ItemStackRequestAction.PlaceAction.class, f.batch.getLast().actions().getFirst()); ++ assertEquals(f.batch.getFirst().requestId(), placed.source().stackNetworkId()); ++ f.accept(); ++ assertTrue(f.click(11, 1)); ++ f.counts(3, 3, 3, 0); ++ assertTrue(f.click(-999, 2)); ++ f.accept(); ++ f.counts(3, 3, 3, 0); ++ assertFalse(f.click(12, 1)); ++ assertEquals(3, f.sent.size()); ++ verify(f.inventory, times(1)).setCursorItemDrag(false); ++ f.packets.verify(() -> PacketFactory.sendBedrockCursorItemDrag(f.user, true), times(1)); ++ f.packets.verify(() -> PacketFactory.sendBedrockCursorItemDrag(f.user, false), times(1)); ++ } ++ } ++ ++ @ParameterizedTest ++ @ValueSource(booleans = {false, true}) ++ void largeDragKeepsEveryUniqueDestinationUntilRepliesSettleBeforeClose(final boolean furnace) { ++ try (var f = new Fixture(furnace)) { ++ final int first = furnace ? 3 : 0; ++ f.inventory.getHudContainer().setItem(0, item(64, 11)); ++ f.input(-999, 0); ++ for (int slot = first; slot < first + 36; slot++) { ++ f.input(slot, 1); ++ } ++ // Re-entering queued destinations must not consume another queue entry or share. ++ for (int repeat = 0; repeat < 40; repeat++) { ++ f.input(10, 1); ++ } ++ f.input(-999, 2); ++ InventoryPackets.handleContainerClose(f.user, f.menu); ++ int replies = 0; ++ while (f.requests.hasPending() && replies++ < 40) { ++ f.accept(); ++ } ++ assertFalse(f.requests.hasPending()); ++ assertEquals(36, f.sent.size()); ++ for (short slot = (short) first; slot < first + 36; slot++) { ++ assertEquals(1, f.menu.getItemInJavaSlot(slot).amount(), "Destination " + slot); ++ } ++ assertEquals(28, f.inventory.getHudContainer().getItem(0).amount()); ++ assertSame(f.menu, f.inventory.getPendingCloseContainer()); ++ } ++ } ++ ++ @Test ++ void queuedVisitsCannotCrossTheReleaseAndStartOfAnotherDrag() { ++ try (var f = new Fixture()) { ++ f.input(-999, 4); ++ f.input(9, 5); ++ f.input(10, 5); ++ f.input(-999, 6); ++ f.input(-999, 0); ++ f.input(11, 1); ++ f.input(12, 1); ++ f.input(-999, 2); ++ int replies = 0; ++ while (f.requests.hasPending() && replies++ < 10) { ++ f.accept(); ++ } ++ assertFalse(f.requests.hasPending()); ++ assertEquals(List.of(1, 1, 3, 3), List.of(f.menu.getItem(9).amount(), f.menu.getItem(10).amount(), ++ f.menu.getItem(11).amount(), f.menu.getItem(12).amount())); ++ assertEquals(1, f.inventory.getHudContainer().getItem(0).amount()); ++ } ++ } ++ ++ @Test ++ void visitsAndReleaseWaitForResponsesWithoutLosingTheOriginalBudget() { ++ try (var f = new Fixture()) { ++ f.input(-999, 0); ++ f.input(9, 1); ++ f.input(10, 1); ++ f.input(11, 1); ++ f.input(-999, 2); ++ assertEquals(1, f.sent.size()); ++ f.counts(9, 0, 0, 0); ++ f.accept(); ++ f.counts(4, 4, 0, 1); ++ f.accept(); ++ f.counts(3, 3, 3, 0); ++ f.accept(); ++ assertFalse(f.requests.hasPending()); ++ assertEquals(3, f.sent.size()); ++ verify(f.inventory, times(1)).setCursorItemDrag(false); ++ } ++ } ++ ++ @Test ++ void closeWaitsForEarlierDragVisitsAndRejectsNewInput() { ++ try (var f = new Fixture()) { ++ f.input(-999, 0); ++ f.input(9, 1); ++ f.input(10, 1); ++ f.input(11, 1); ++ f.input(-999, 2); ++ InventoryPackets.handleContainerClose(f.user, f.menu); ++ InventoryPackets.handleContainerClose(f.user, f.menu); ++ assertTrue(f.requests.isWaitingForDragClose()); ++ assertNull(f.inventory.getPendingCloseContainer()); ++ assertFalse(f.requests.queueClick(f.menu, 0, (short) 12, (byte) 1, ContainerInput.QUICK_CRAFT)); ++ f.accept(); ++ f.counts(4, 4, 0, 1); ++ assertNull(f.inventory.getPendingCloseContainer()); ++ f.accept(); ++ f.counts(3, 3, 3, 0); ++ assertNull(f.inventory.getPendingCloseContainer()); ++ f.accept(); ++ f.counts(3, 3, 3, 0); ++ assertSame(f.menu, f.inventory.getPendingCloseContainer()); ++ assertFalse(f.requests.isWaitingForDragClose()); ++ f.packets.verify(() -> PacketFactory.sendBedrockContainerClose(f.user, f.menu.containerId(), ContainerType.NONE), times(1)); ++ } ++ } ++ ++ @Test ++ void closeRetainsTheDragThatStartsWhileTheInitialPickupAwaitsItsReply() { ++ try (var f = new Fixture()) { ++ f.menu.setItem(12, item(9, 300)); ++ f.inventory.getHudContainer().setItem(0, BedrockItem.empty()); ++ assertTrue(f.menu.handleClick(0, (short) 12, (byte) 0, ContainerInput.PICKUP)); ++ f.input(-999, 0); ++ f.input(9, 1); ++ f.input(10, 1); ++ f.input(11, 1); ++ f.input(-999, 2); ++ InventoryPackets.handleContainerClose(f.user, f.menu); ++ assertTrue(f.requests.isWaitingForDragClose()); ++ for (int index = 0; index < 4; index++) { ++ f.accept(); ++ } ++ f.counts(3, 3, 3, 0); ++ assertTrue(f.menu.getItem(12).isEmpty()); ++ assertSame(f.menu, f.inventory.getPendingCloseContainer()); ++ assertEquals(4, f.sent.size()); ++ } ++ } ++ ++ @Test ++ void rejectedRedistributionStillClosesWithTheLastAcceptedItems() { ++ try (var f = new Fixture()) { ++ f.input(-999, 0); ++ f.input(9, 1); ++ f.accept(); ++ f.input(10, 1); ++ f.input(11, 1); ++ f.input(-999, 2); ++ InventoryPackets.handleContainerClose(f.user, f.menu); ++ for (final var request : List.copyOf(f.batch)) { ++ f.requests.handleResponse(new ItemStackResponseInfo(ItemStackNetResult.getByValue(49), request.requestId(), null)); ++ } ++ f.counts(9, 0, 0, 0); ++ assertSame(f.menu, f.inventory.getPendingCloseContainer()); ++ assertFalse(f.requests.hasPending()); ++ assertFalse(f.requests.isWaitingForDragClose()); ++ assertEquals(2, f.sent.size()); ++ } ++ } ++ ++ @Test ++ void retiredDragCloseCannotCloseTheReplacementWithTheSameJavaMenuId() { ++ try (var f = new Fixture()) { ++ f.input(-999, 0); ++ f.input(9, 1); ++ f.input(10, 1); ++ InventoryPackets.handleContainerClose(f.user, f.menu); ++ f.inventory.setCurrentContainerClosed(false); ++ final var replacement = new ChestContainer(f.user, f.menu.containerId(), null, null, 27); ++ f.inventory.setCurrentContainer(replacement); ++ replacement.setItem(10, item(7, 300)); ++ f.accept(); ++ assertSame(replacement, f.inventory.getCurrentContainer()); ++ assertNull(f.inventory.getPendingCloseContainer()); ++ assertEquals(7, replacement.getItem(10).amount()); ++ assertEquals(300, replacement.getItem(10).netId()); ++ assertFalse(f.requests.isWaitingForDragClose()); ++ f.packets.verify(() -> PacketFactory.sendBedrockContainerClose(eq(f.user), anyByte(), any()), never()); ++ } ++ } ++ ++ @Test ++ void rejectionRestoresTheLastAcceptedDistributionAndDiscardsQueuedVisits() { ++ try (var f = new Fixture()) { ++ f.click(-999, 0); ++ f.click(9, 1); ++ f.accept(); ++ f.click(10, 1); ++ f.input(11, 1); ++ for (final var request : List.copyOf(f.batch)) { ++ f.requests.handleResponse(new ItemStackResponseInfo(ItemStackNetResult.getByValue(49), request.requestId(), null)); ++ } ++ assertFalse(f.requests.hasPending()); ++ f.counts(9, 0, 0, 0); ++ assertFalse(f.click(11, 1)); ++ assertEquals(2, f.sent.size()); ++ } ++ } ++ ++ @Test ++ void duplicateSlotsAndLaterServerEditsCannotBeCollectedAsDragContributions() { ++ try (var f = new Fixture()) { ++ f.menu.setItem(9, item(63, 10)); ++ f.click(-999, 0); ++ f.click(9, 1); ++ f.counts(64, 0, 0, 8); ++ f.accept(); ++ f.click(10, 1); ++ f.counts(64, 4, 0, 4); ++ f.accept(); ++ assertTrue(f.click(9, 1)); ++ assertEquals(2, f.sent.size()); ++ f.menu.setItem(10, item(3, 999)); ++ assertFalse(f.click(11, 1)); ++ f.counts(64, 3, 0, 4); ++ assertEquals(2, f.sent.size()); ++ } ++ } ++ ++ @Test ++ void anUnnegotiatedClientRetainsReleaseTimeQuickCraft() { ++ try (var f = new Fixture()) { ++ f.channels.removeChannels(List.of(Container.INCREMENTAL_DRAG_CHANNEL)); ++ f.click(-999, 0); ++ f.click(9, 1); ++ f.click(10, 1); ++ assertTrue(f.sent.isEmpty()); ++ f.counts(0, 0, 0, 9); ++ assertTrue(f.click(-999, 2)); ++ f.counts(4, 4, 0, 1); ++ } ++ } ++ ++ @Test ++ void secondaryDragPlacesOnePerUniqueDestinationAndStopsAfterCapabilityWithdrawal() { ++ try (var f = new Fixture()) { ++ f.click(-999, 4); ++ f.click(9, 5); ++ f.accept(); ++ f.click(10, 5); ++ f.accept(); ++ f.click(11, 5); ++ f.counts(1, 1, 1, 6); ++ f.accept(); ++ assertTrue(f.sent.stream().flatMap(List::stream).allMatch(request -> ++ request.actions().getFirst() instanceof ItemStackRequestAction.PlaceAction place && place.count() == 1)); ++ f.channels.removeChannels(List.of(Container.INCREMENTAL_DRAG_CHANNEL)); ++ assertFalse(f.click(12, 5)); ++ f.counts(1, 1, 1, 6); ++ assertEquals(3, f.sent.size()); ++ } ++ } ++ ++ @Test ++ void replacingTheMenuRetiresItsQueuedDragWithoutTouchingTheNewOpening() { ++ try (var f = new Fixture()) { ++ f.input(-999, 0); ++ f.input(9, 1); ++ f.input(10, 1); ++ final var replacement = new ChestContainer(f.user, (byte) 6, null, null, 27); ++ f.inventory.setCurrentContainerClosed(false); ++ f.inventory.setCurrentContainer(replacement); ++ replacement.setItem(10, item(7, 300)); ++ f.accept(); ++ assertEquals(7, replacement.getItem(10).amount()); ++ assertEquals(300, replacement.getItem(10).netId()); ++ assertEquals(1, f.sent.size()); ++ assertFalse(f.click(11, 1)); ++ } ++ } ++ ++ private static BedrockItem item(final int amount, final int id) { ++ final var item = new BedrockItem(2); ++ item.setAmount(amount); ++ item.setNetId(id); ++ return item; ++ } ++ ++ private static final class Fixture implements AutoCloseable { ++ private final Map, Object> storage = new HashMap<>(); ++ private final UserConnection user = mock(UserConnection.class); ++ private final MockedStatic packets = mockStatic(PacketFactory.class); ++ private final MockedStatic platform = mockStatic(ViaBedrock.class); ++ private final InventoryTracker inventory; ++ private final InventoryRequestTracker requests; ++ private final ChannelStorage channels = new ChannelStorage(); ++ private final Container menu; ++ private final List> sent = new ArrayList<>(); ++ private List batch; ++ private int nextNetworkId = 100; ++ ++ private Fixture() { ++ this(false); ++ } ++ ++ private Fixture(final boolean furnace) { ++ when(this.user.get(any())).thenAnswer(call -> this.storage.computeIfAbsent(call.getArgument(0), key -> mock(key))); ++ final var api = mock(ViaBedrockPlatform.class); ++ when(api.getLogger()).thenReturn(Logger.getAnonymousLogger()); ++ this.platform.when(ViaBedrock::getPlatform).thenReturn(api); ++ final var info = mock(ProtocolInfo.class); ++ when(this.user.getProtocolInfo()).thenReturn(info); ++ when(info.getServerState()).thenReturn(State.CONFIGURATION); ++ this.inventory = spy(new InventoryTracker(this.user)); ++ this.requests = new InventoryRequestTracker(this.user); ++ this.storage.put(InventoryTracker.class, this.inventory); ++ this.storage.put(InventoryRequestTracker.class, this.requests); ++ this.storage.put(ChannelStorage.class, this.channels); ++ this.channels.addChannels(List.of(Container.INCREMENTAL_DRAG_CHANNEL)); ++ when(this.user.get(GameSessionStorage.class).isInventoryServerAuthoritative()).thenReturn(true); ++ final var items = this.user.get(ItemRewriter.class); ++ when(items.maxStackSize(any())).thenReturn(64); ++ when(items.canMerge(any(), any())).thenAnswer(call -> !((BedrockItem) call.getArgument(0)).isDifferent(call.getArgument(1))); ++ this.requests.setPredictingClick(true); ++ this.menu = furnace ? new FurnaceContainer(this.user, (byte) 6, null, null) ++ : new ChestContainer(this.user, (byte) 6, null, null, 27); ++ this.inventory.setCurrentContainer(this.menu); ++ this.inventory.getHudContainer().setItem(0, item(9, 11)); ++ this.requests.setPredictingClick(false); ++ this.packets.when(() -> PacketFactory.sendBedrockInventoryRequest(eq(this.user), any())).thenAnswer(call -> { ++ this.batch = List.of((ItemStackRequestInfo[]) call.getArgument(1)); ++ for (final var request : this.batch) { ++ final var retained = this.requests.getRequest(request.requestId()); ++ if (!retained.hasPrediction()) { ++ retained.capturePrediction(this.inventory); ++ } ++ } ++ this.sent.add(this.batch); ++ return null; ++ }); ++ } ++ ++ private boolean click(final int slot, final int button) { ++ return this.menu.handleClick(0, (short) slot, (byte) button, ContainerInput.QUICK_CRAFT); ++ } ++ ++ private void source(final int javaSlot, final int amount) { ++ this.inventory.getHudContainer().setItem(0, BedrockItem.empty()); ++ if (javaSlot < this.menu.javaSize()) { ++ this.menu.setItem(this.menu.bedrockSlot(javaSlot), item(amount, 300)); ++ } else { ++ final var owner = this.inventory.getInventoryContainer(); ++ owner.setItem(owner.bedrockSlot(javaSlot - this.menu.javaSize() + 9), item(amount, 300)); ++ } ++ } ++ ++ private void transfers(final int[]... expected) { ++ assertEquals(expected.length, this.batch.size()); ++ for (int index = 0; index < expected.length; index++) { ++ final var action = assertInstanceOf(ItemStackRequestAction.PlaceAction.class, ++ this.batch.get(index).actions().getFirst()); ++ assertEquals(this.menu.getFullContainerName(expected[index][0]), action.source().container()); ++ assertEquals(expected[index][0], Byte.toUnsignedInt(action.source().slot())); ++ assertEquals(this.menu.getFullContainerName(expected[index][1]), action.destination().container()); ++ assertEquals(expected[index][1], Byte.toUnsignedInt(action.destination().slot())); ++ assertEquals(expected[index][2], action.count()); ++ } ++ } ++ ++ private void input(final int slot, final int button) { ++ if (this.requests.hasPending()) { ++ assertTrue(this.requests.queueClick(this.menu, 0, (short) slot, (byte) button, ContainerInput.QUICK_CRAFT)); ++ } else { ++ assertTrue(this.click(slot, button)); ++ } ++ } ++ ++ private List responses() { ++ final var responses = new ArrayList(); ++ for (final var request : this.batch) { ++ final var action = request.actions().getFirst(); ++ final List slots; ++ if (action instanceof ItemStackRequestAction.TakeAction take) { ++ slots = List.of(take.source(), take.destination()); ++ } else { ++ final var place = assertInstanceOf(ItemStackRequestAction.PlaceAction.class, action); ++ slots = List.of(place.source(), place.destination()); ++ } ++ final var updates = new ArrayList(); ++ for (final var slot : slots) { ++ final var owner = this.inventory.getContainerFromName(slot.container(), Byte.toUnsignedInt(slot.slot())); ++ final var predicted = this.requests.getRequest(request.requestId()).predictedItem(owner, owner.slotFromRequest(Byte.toUnsignedInt(slot.slot()))); ++ updates.add(new ItemStackResponseContainerInfo(slot.container(), List.of(new ItemStackResponseSlotInfo(slot.slot(), slot.slot(), ++ (byte) predicted.amount(), predicted.isEmpty() ? 0 : this.nextNetworkId++, "", null, 0)))); ++ } ++ responses.add(new ItemStackResponseInfo(ItemStackNetResult.Success, request.requestId(), updates)); ++ } ++ return responses; ++ } ++ ++ private void accept() { ++ final var responses = this.responses(); ++ // The response owner must reconcile a transfer batch in request order. ++ for (final var response : responses.reversed()) { ++ this.requests.handleResponse(response); ++ } ++ } ++ ++ private void counts(final int first, final int second, final int third, final int cursor) { ++ assertEquals(List.of(first, second, third, cursor), List.of(this.menu.getItem(9).amount(), this.menu.getItem(10).amount(), ++ this.menu.getItem(11).amount(), this.inventory.getHudContainer().getItem(0).amount())); ++ } ++ ++ @Override ++ public void close() { ++ this.packets.close(); ++ this.platform.close(); ++ } ++ } ++ ++} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/storage/InventoryContainerGenerationTest.java b/src/test/java/net/raphimc/viabedrock/protocol/storage/InventoryContainerGenerationTest.java new file mode 100644 index 00000000..bb59fd16 @@ -40533,10 +41837,10 @@ index 00000000..66e78fc9 +} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/storage/PlayerInventoryScreenTrackerTest.java b/src/test/java/net/raphimc/viabedrock/protocol/storage/PlayerInventoryScreenTrackerTest.java new file mode 100644 -index 00000000..702c97f7 +index 00000000..708c0bcd --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/protocol/storage/PlayerInventoryScreenTrackerTest.java -@@ -0,0 +1,345 @@ +@@ -0,0 +1,382 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -40572,6 +41876,43 @@ index 00000000..702c97f7 +class PlayerInventoryScreenTrackerTest { + + @Test ++ void aNewOpeningCannotDiscardTheDragRetainedByThePreviousClose() throws Exception { ++ try (var f = new Fixture()) { ++ f.request(1, true); ++ f.acknowledge(); ++ final var owner = f.inventory.getCurrentContainer(); ++ doReturn(true).when(owner).hasIncrementalDrag(); ++ final var visited = new ArrayList(); ++ assertTrue(f.requests.queueInteraction(new InventoryRequestTracker.QueuedInteraction() { ++ @Override ++ public Container container() { ++ return owner; ++ } ++ ++ @Override ++ public boolean apply() { ++ visited.add(1); ++ return true; ++ } ++ })); ++ f.request(1, false); ++ assertTrue(f.requests.isWaitingForDragClose()); ++ f.request(2, true); ++ assertEquals(List.of(2L), f.closedScreens()); ++ assertEquals(1, f.opens()); ++ assertTrue(visited.isEmpty()); ++ f.requests.drainQueuedInteractions(); ++ assertEquals(List.of(1), visited); ++ assertFalse(f.requests.isWaitingForDragClose()); ++ assertSame(owner, f.inventory.getPendingCloseContainer()); ++ f.inventory.setCurrentContainerClosed(false); ++ assertEquals(1, f.opens()); ++ f.request(3, true); ++ assertEquals(2, f.opens()); ++ } ++ } ++ ++ @Test + void deferredScreenBecomesReadyOnlyAfterItsNativeAcknowledgement() throws Exception { + try (var f = new Fixture()) { + f.user.get(ChannelStorage.class).addChannels(List.of(PlayerInventoryScreenReady.CHANNEL)); @@ -40848,7 +42189,7 @@ index 00000000..702c97f7 + } + + private InventoryContainer newInventory() { -+ return new InventoryContainer(this.user, (byte) 0, null, this.inventory.getInventoryContainer()); ++ return spy(new InventoryContainer(this.user, (byte) 0, null, this.inventory.getInventoryContainer())); + } + + private void acknowledge() { diff --git a/patches/viabedrock/deferred/0003-translate-server-authoritative-bedrock-requests.pr.md b/patches/viabedrock/deferred/0003-translate-server-authoritative-bedrock-requests.pr.md index a2da5925..0699d987 100644 --- a/patches/viabedrock/deferred/0003-translate-server-authoritative-bedrock-requests.pr.md +++ b/patches/viabedrock/deferred/0003-translate-server-authoritative-bedrock-requests.pr.md @@ -61,3 +61,19 @@ Transfers reject missing network IDs before changing predicted items or selectio The new sparse request sequences still need installed server controls and a matching native capture. The [owning patch](0003-translate-server-authoritative-bedrock-requests.patch) retains implementation rationale and versioned evidence. + +Held quantity selection uses ordinary Place and explicit-count Take requests with the existing prediction and response owner. +Transfers validate source permissions, cursor contents, capacity, and network stack IDs before prediction. +Packet/state regressions cover renewed IDs and rejected partial pickup without item loss. + +Negotiated ordinary-stack drags retain destinations and an original budget independently of the live cursor. +Each visit uses the existing transfer prediction and response owner. Later transfers use renewed stack IDs. +A close retains earlier inputs until their replies settle and rejects subsequent input or a new player opening. +Rejection closes with reconciled items. An old close cannot affect a replacement menu, including one with the same Java ID. +Regressions cover queued pickup, delayed and reordered responses, rejection, capacity, bundle compatibility, and opening replacement. +A 36-destination regression covers chest slots and player slots appended to a furnace. +Queued revisits preserve first-visit order. Release and a new press keep consecutive gestures separate. +Installed direct and ViaProxy controls retain three-slot and full-inventory distributions through Escape while held and delayed replies. +The full-inventory control reconciles every destination before closing and conserves all 64 items after reopening. +Native multiplayer grouping and delayed or rejected native behavior remain unverified. +See the [cooking reference](../../../docs/native-cooking-actions.md#recipe-input) for the versioned visible control and host limits. diff --git a/patches/viabedrock/deferred/0004-expose-bedrock-recipes-and-place-ingredients.patch b/patches/viabedrock/deferred/0004-expose-bedrock-recipes-and-place-ingredients.patch index f4962e9c..002dcd0e 100644 --- a/patches/viabedrock/deferred/0004-expose-bedrock-recipes-and-place-ingredients.patch +++ b/patches/viabedrock/deferred/0004-expose-bedrock-recipes-and-place-ingredients.patch @@ -1,4 +1,4 @@ -From 86c464e5216432fe19b04b51177f5c8d2778aa52 Mon Sep 17 00:00:00 2001 +From 504444ab93ec6cfe890ebe266d9f85516affff19 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(crafting): expose Bedrock recipes and place ingredients @@ -888,98 +888,156 @@ once after the complete batch instead of once per request. Keep rollback refreshing the restored model. Player and workbench regressions retain linked request identities and require one Java refresh for a two-request Shift-click. The Bedrock request payload and admission remain unchanged. + +Admit only recipes with a nonempty ingredient before availability captures +source collections. Registered placeholders otherwise throw and interrupt +inventory population, or read sources from a retired owner unnecessarily. +Preserve selected-output fallback and later valid candidates. Retain the +admitted ingredient count for the raw counter so source capture does not +re-read recipe shape. The raw counter continues to reject empty recipes. + +A registered-recipe regression fails before this admission guard. Existing +original 1.26.51.1 availability fixtures retain their valid-recipe results. +This is defensive Java handling of retained placeholders, not a claim of +new native 1.26.60 availability or live screen parity. + +Retain raw cooking search per station family across menu openings. Apply +the shared native name search only on the Search tab, leaving category +counts and category lists independent. Bind optional query messages to +the exact displayed menu, opening, revisions, and one monotonically +ordered request stream shared with recipe input. Publish even repeated +equal edits, retire old cell authority, and reject stale or withdrawn +query input without issuing a transfer. + +Official Bedrock 1.26.60.5 private desktop controls show iron narrowing +the search list, category tabs ignoring the retained query, Search +restoring it, clear restoring all recipes, and furnace text surviving +close/reopen independently of smoker text. These integrated-world +controls do not establish other input modes or cross-connection query +lifetime. Keep ordinary controls until complete authored bindings are +admitted; isolated controller references alone do not prove live parity. + +Retain native held-quantity transactions independently of recipe gestures. +The 1.26.60.5 client keeps a secondary source and starts its quantity timer +after the initial pickup. Pointer exit can complete a partial pickup before +physical release. Route the cursor return and final Take through the +ordinary inventory request owner rather than clearing local UI state. + +Queue phases behind their preceding replies and preserve the original +opening, source and request sequence. Check the confirmed items while +accepting renewed server network IDs. Rejection restores source and cursor +through ordinary prediction reconciliation. Reopening, capability loss and +explicit transport retirement cancel queued completion. + +Regression controls cover both station and hotbar sources, delayed initial +pickup and return replies, renewed IDs, rejection of either phase, invalid +source merging or capacity, and retired openings. The capability remains +separate from recipe input. Complete default cooking host admission, +exact native timing and complete native visual comparisons remain unfinished. + +Admit selected-source cooking taps and drawing through the existing +inventory transfer owner. Validate the displayed frame, opening generation, +capabilities and shared command sequence before the first transfer. Keep +other recipe, bundle, held and search commands behind the active drag. +Retire source drawing by its original action identity, even after item +frames advance. Queue that terminal phase behind earlier transfers; a +late retirement cannot cancel a newer cursor-origin gesture. Capability +withdrawal retires only the selected-source allocation it owns. +The native 1.26.60.5 source controls establish the retained budget and +Start/Stop distinction. Exact native multiplayer grouping and physical +input delivery still require separate controls. --- - .../api/model/NativeFurnaceOptions.java | 44 + - .../container/CookingRecipeAvailability.java | 30 + - .../model/container/CookingRecipeCatalog.java | 81 ++ - .../model/container/CookingRecipeInputs.java | 67 ++ - .../model/container/CookingRecipeSources.java | 80 ++ - .../model/container/CraftingPlacement.java | 229 +++++ - .../container/FurnaceRecipePlacement.java | 221 +++++ - .../NativeCookingRecipeSelection.java | 205 +++++ - .../model/container/NativeCraftingOutput.java | 272 ++++++ - .../model/container/NativeRecipeCounter.java | 202 ++++ - .../container/NativeRecipeDescriptor.java | 60 +- - .../block/CraftingTableContainer.java | 116 +-- - .../container/block/FurnaceContainer.java | 209 +++++ - .../model/container/player/HudContainer.java | 3 + - .../container/player/InventoryContainer.java | 114 ++- - .../net/raphimc/viabedrock/api/util/Fnv1.java | 9 + - .../viabedrock/api/util/PacketFactory.java | 2 + - .../viabedrock/protocol/BedrockProtocol.java | 2 + - .../protocol/ServerboundBedrockPackets.java | 1 + - .../protocol/model/FurnaceOptions.java | 17 + - .../protocol/model/InventoryOptions.java | 10 + - .../protocol/model/ItemRegistry.java | 12 + - .../protocol/model/NativeCraftingContext.java | 34 + - .../protocol/model/NativeRecipeRegistry.java | 11 + - .../protocol/model/recipe/ItemDescriptor.java | 8 +- - .../protocol/model/recipe/MultiRecipe.java | 46 +- - .../model/recipe/RecipeMolangExpression.java | 279 ++++++ - .../model/recipe/RecipeOutputKey.java | 19 + - .../protocol/model/recipe/ShapedRecipe.java | 65 +- - .../model/recipe/ShapelessRecipe.java | 6 +- - .../protocol/model/recipe/SmithingRecipe.java | 4 +- - .../protocol/packet/InventoryPackets.java | 160 +++- - .../protocol/packet/JoinPackets.java | 1 + - .../protocol/packet/MultiStatePackets.java | 55 ++ - .../protocol/rewriter/ItemRewriter.java | 188 +++- - .../protocol/storage/CraftingDataStorage.java | 3 +- - .../protocol/storage/CraftingDataTracker.java | 859 ++++++++++++++++-- - .../storage/InventoryOptionsStorage.java | 186 ++++ - .../storage/InventoryRequestTracker.java | 7 + - .../protocol/storage/InventoryTracker.java | 47 + - .../storage/NativeCookingUiState.java | 502 ++++++++++ - .../protocol/types/BedrockTypes.java | 2 + - .../types/item/BedrockRecipeResultType.java | 53 ++ - .../types/item/RecipeResultContext.java | 16 + - .../types/model/FurnaceOptionsType.java | 34 + - .../types/model/InventoryOptionsType.java | 35 + - .../types/recipe/CraftingDataReader.java | 66 +- - .../api/model/NativeFurnaceOptionsTest.java | 57 ++ - .../CookingRecipeAvailabilityTest.java | 94 ++ - .../container/CookingRecipeInputsTest.java | 229 +++++ - .../container/CookingRecipeSourcesTest.java | 146 +++ - .../container/CraftingPlacementTest.java | 161 ++++ - .../container/FurnaceRecipePlacementTest.java | 418 +++++++++ - .../NativeRecipeCounterFixtureTest.java | 132 +++ - .../container/NativeRecipeCounterTest.java | 66 ++ - .../container/NativeRecipeDescriptorTest.java | 356 ++++++++ - .../protocol/model/ItemPrimarySearchTest.java | 173 ++++ - .../protocol/model/ItemRegistryTagsTest.java | 20 +- - .../model/ItemSecondaryDescriptionTest.java | 179 ++++ - .../model/NativeCraftingContextTest.java | 106 +++ - .../model/RecipeOutputConstructionTest.java | 69 ++ - .../recipe/RecipeMolangExpressionTest.java | 149 +++ - .../model/recipe/RecipeOwnershipTest.java | 52 ++ - .../packet/FurnaceOptionsPacketTest.java | 162 ++++ - .../packet/InventoryOptionsPacketTest.java | 211 +++++ - .../protocol/packet/InventoryPacketsTest.java | 49 +- - .../NativeFurnaceOptionsPacketTest.java | 214 +++++ - .../NativeRecipeConcreteCounterTest.java | 165 ++++ - .../rewriter/NativeRecipePaletteTest.java | 203 +++++ - .../PlayerRecipeAvailabilityTest.java | 296 ++++++ - ...egisteredPlayerRecipeAvailabilityTest.java | 372 ++++++++ - .../RegisteredPlayerRecipeSearchTest.java | 268 ++++++ - .../ResolvedRecipeIngredientTest.java | 283 ++++++ - .../storage/CookingRecipeBookTest.java | 194 ++++ - .../protocol/storage/CraftingCursorTest.java | 364 ++++++++ - .../storage/CraftingDataTrackerTest.java | 407 +++++++++ - .../storage/CraftingRecipeSourcesTest.java | 128 +++ - .../storage/FurnaceRecipeRequestTest.java | 797 ++++++++++++++++ - .../storage/NativeCookingUiStateTest.java | 625 +++++++++++++ - .../storage/NativeRecipeContextTest.java | 100 ++ - .../NativeRecipeDescriptorCounterTest.java | 206 +++++ - .../storage/NativeRecipeOutputIndexTest.java | 162 ++++ - .../storage/NativeRecipeOutputUnlockTest.java | 180 ++++ - ...NativeRecipeRegistrationReferenceTest.java | 104 +++ - .../storage/NativeRecipeRetentionTest.java | 133 +++ - .../item/BedrockRecipeBlockResultTest.java | 129 +++ - .../item/BedrockRecipeResultTypeTest.java | 229 +++++ - .../types/model/FurnaceOptionsTypeTest.java | 75 ++ - .../types/model/InventoryOptionsTypeTest.java | 46 + - .../types/recipe/CraftingDataReaderTest.java | 84 +- - 90 files changed, 13031 insertions(+), 234 deletions(-) + .../api/model/NativeFurnaceOptions.java | 44 + + .../container/CookingRecipeAvailability.java | 30 + + .../model/container/CookingRecipeCatalog.java | 81 ++ + .../model/container/CookingRecipeInputs.java | 67 + + .../model/container/CookingRecipeSources.java | 80 ++ + .../model/container/CraftingPlacement.java | 229 ++++ + .../container/FurnaceRecipePlacement.java | 221 +++ + .../NativeCookingRecipeSelection.java | 205 +++ + .../model/container/NativeCraftingOutput.java | 272 ++++ + .../model/container/NativeRecipeCounter.java | 219 +++ + .../container/NativeRecipeDescriptor.java | 60 +- + .../block/CraftingTableContainer.java | 116 +- + .../container/block/FurnaceContainer.java | 209 +++ + .../model/container/player/HudContainer.java | 3 + + .../container/player/InventoryContainer.java | 114 +- + .../net/raphimc/viabedrock/api/util/Fnv1.java | 9 + + .../viabedrock/api/util/PacketFactory.java | 2 + + .../viabedrock/protocol/BedrockProtocol.java | 2 + + .../protocol/ServerboundBedrockPackets.java | 1 + + .../protocol/model/FurnaceOptions.java | 17 + + .../protocol/model/InventoryOptions.java | 10 + + .../protocol/model/ItemRegistry.java | 12 + + .../protocol/model/NativeCraftingContext.java | 34 + + .../protocol/model/NativeRecipeRegistry.java | 11 + + .../protocol/model/recipe/ItemDescriptor.java | 8 +- + .../protocol/model/recipe/MultiRecipe.java | 46 +- + .../model/recipe/RecipeMolangExpression.java | 279 ++++ + .../model/recipe/RecipeOutputKey.java | 19 + + .../protocol/model/recipe/ShapedRecipe.java | 65 +- + .../model/recipe/ShapelessRecipe.java | 6 +- + .../protocol/model/recipe/SmithingRecipe.java | 4 +- + .../protocol/packet/InventoryPackets.java | 160 ++- + .../protocol/packet/JoinPackets.java | 1 + + .../protocol/packet/MultiStatePackets.java | 117 ++ + .../protocol/rewriter/ItemRewriter.java | 188 ++- + .../protocol/storage/CraftingDataStorage.java | 3 +- + .../protocol/storage/CraftingDataTracker.java | 859 +++++++++++- + .../storage/InventoryOptionsStorage.java | 186 +++ + .../storage/InventoryRequestTracker.java | 10 + + .../protocol/storage/InventoryTracker.java | 47 + + .../storage/NativeCookingUiState.java | 841 ++++++++++++ + .../protocol/types/BedrockTypes.java | 2 + + .../types/item/BedrockRecipeResultType.java | 53 + + .../types/item/RecipeResultContext.java | 16 + + .../types/model/FurnaceOptionsType.java | 34 + + .../types/model/InventoryOptionsType.java | 35 + + .../types/recipe/CraftingDataReader.java | 66 +- + .../api/model/NativeFurnaceOptionsTest.java | 57 + + .../CookingRecipeAvailabilityTest.java | 94 ++ + .../container/CookingRecipeInputsTest.java | 229 ++++ + .../container/CookingRecipeSourcesTest.java | 146 ++ + .../container/CraftingPlacementTest.java | 161 +++ + .../container/FurnaceRecipePlacementTest.java | 418 ++++++ + .../NativeRecipeCounterFixtureTest.java | 132 ++ + .../container/NativeRecipeCounterTest.java | 66 + + .../container/NativeRecipeDescriptorTest.java | 356 +++++ + .../protocol/model/ItemPrimarySearchTest.java | 173 +++ + .../protocol/model/ItemRegistryTagsTest.java | 20 +- + .../model/ItemSecondaryDescriptionTest.java | 179 +++ + .../model/NativeCraftingContextTest.java | 106 ++ + .../model/RecipeOutputConstructionTest.java | 69 + + .../recipe/RecipeMolangExpressionTest.java | 149 ++ + .../model/recipe/RecipeOwnershipTest.java | 52 + + .../packet/FurnaceOptionsPacketTest.java | 162 +++ + .../packet/InventoryOptionsPacketTest.java | 211 +++ + .../protocol/packet/InventoryPacketsTest.java | 49 +- + .../NativeFurnaceOptionsPacketTest.java | 214 +++ + .../NativeRecipeConcreteCounterTest.java | 165 +++ + .../rewriter/NativeRecipePaletteTest.java | 203 +++ + .../PlayerRecipeAvailabilityTest.java | 296 ++++ + ...egisteredPlayerRecipeAvailabilityTest.java | 389 ++++++ + .../RegisteredPlayerRecipeSearchTest.java | 268 ++++ + .../ResolvedRecipeIngredientTest.java | 283 ++++ + .../storage/CookingRecipeBookTest.java | 194 +++ + .../protocol/storage/CraftingCursorTest.java | 364 +++++ + .../storage/CraftingDataTrackerTest.java | 407 ++++++ + .../storage/CraftingRecipeSourcesTest.java | 128 ++ + .../storage/FurnaceRecipeRequestTest.java | 797 +++++++++++ + .../storage/NativeCookingUiStateTest.java | 1203 +++++++++++++++++ + .../storage/NativeRecipeContextTest.java | 100 ++ + .../NativeRecipeDescriptorCounterTest.java | 206 +++ + .../storage/NativeRecipeOutputIndexTest.java | 162 +++ + .../storage/NativeRecipeOutputUnlockTest.java | 180 +++ + ...NativeRecipeRegistrationReferenceTest.java | 104 ++ + .../storage/NativeRecipeRetentionTest.java | 133 ++ + .../item/BedrockRecipeBlockResultTest.java | 129 ++ + .../item/BedrockRecipeResultTypeTest.java | 229 ++++ + .../types/model/FurnaceOptionsTypeTest.java | 75 + + .../types/model/InventoryOptionsTypeTest.java | 46 + + .../types/recipe/CraftingDataReaderTest.java | 84 +- + 90 files changed, 14047 insertions(+), 234 deletions(-) create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeFurnaceOptions.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/CookingRecipeAvailability.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/CookingRecipeCatalog.java @@ -2327,10 +2385,10 @@ index 00000000..1e69ee4c +} 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 new file mode 100644 -index 00000000..fe874172 +index 00000000..06746721 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/model/container/NativeRecipeCounter.java -@@ -0,0 +1,202 @@ +@@ -0,0 +1,219 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -2363,7 +2421,11 @@ index 00000000..fe874172 + */ + public static Result count(final Recipe recipe, final List sources, + final ToIntFunction descriptorHash) { -+ final int ingredientCount = recipe.ingredientCount(); ++ return count(recipe, recipe.ingredientCount(), sources, descriptorHash); ++ } ++ ++ private static Result count(final Recipe recipe, final int ingredientCount, final List sources, ++ final ToIntFunction descriptorHash) { + final Map byHash = new HashMap<>(); + final List groups = new ArrayList<>(); + for (final Source source : sources) { @@ -2468,7 +2530,11 @@ index 00000000..fe874172 + if (candidate.requiresCraftingTable && !workbench) { + continue; + } -+ final Result result = count(candidate.recipe, sources.get(), descriptorHash); ++ final int ingredientCount = candidate.recipe.ingredientCount(); ++ if (!hasIngredient(candidate.recipe, ingredientCount)) { ++ continue; ++ } ++ final Result result = count(candidate.recipe, ingredientCount, sources.get(), descriptorHash); + if (result.count > 0) { + return filtering ? 0 : 1; + } @@ -2483,6 +2549,15 @@ index 00000000..fe874172 + return partial || selectedOutput.getAsBoolean() ? 2 : 3; + } + ++ private static boolean hasIngredient(final Recipe recipe, final int ingredientCount) { ++ for (int position = 0; position < ingredientCount; position++) { ++ if (!recipe.emptyIngredient(position)) { ++ return true; ++ } ++ } ++ return false; ++ } ++ + /** Output lookup owns candidate admission, concrete grid requirements and priority. */ + public record Candidate(int priority, boolean requiresCraftingTable, Recipe recipe) { + public Candidate { @@ -3257,18 +3332,18 @@ index 84d17e3b..0e8f147a 100644 } diff --git a/src/main/java/net/raphimc/viabedrock/api/util/PacketFactory.java b/src/main/java/net/raphimc/viabedrock/api/util/PacketFactory.java -index 642d5f28..b1f82a80 100644 +index 8ed1d090..cb4e71fe 100644 --- a/src/main/java/net/raphimc/viabedrock/api/util/PacketFactory.java +++ b/src/main/java/net/raphimc/viabedrock/api/util/PacketFactory.java -@@ -44,6 +44,7 @@ import net.raphimc.viabedrock.protocol.data.enums.java.generated.CustomChatCompl +@@ -46,6 +46,7 @@ import net.raphimc.viabedrock.protocol.data.enums.java.generated.CustomChatCompl import net.raphimc.viabedrock.protocol.model.Position3f; import net.raphimc.viabedrock.protocol.model.inventory.ItemStackRequestInfo; import net.raphimc.viabedrock.protocol.rewriter.ItemRewriter; +import net.raphimc.viabedrock.protocol.storage.NativeCookingUiState; import net.raphimc.viabedrock.protocol.storage.InventoryRequestTracker; import net.raphimc.viabedrock.protocol.storage.InventoryTracker; - import net.raphimc.viabedrock.protocol.types.BedrockTypes; -@@ -119,6 +120,7 @@ public final class PacketFactory { + import net.raphimc.viabedrock.protocol.storage.EntityTracker; +@@ -148,6 +149,7 @@ public final class PacketFactory { if (!containerSetContent.isCancelled()) { containerSetContent.send(BedrockProtocol.class); sendJavaCursorForHudUpdate(user, container); @@ -3348,10 +3423,10 @@ index 00000000..a3d828be +public record InventoryOptions(int leftTab, int rightTab, boolean filtering, int inventoryLayout, int craftingLayout) { +} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/model/ItemRegistry.java b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemRegistry.java -index 46435634..a906b932 100644 +index ea6afa9d..5a941292 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/model/ItemRegistry.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemRegistry.java -@@ -225,6 +225,18 @@ public final class ItemRegistry { +@@ -233,6 +233,18 @@ public final class ItemRegistry { this::definition, this::canonicalDefinition)); } @@ -3987,10 +4062,10 @@ index 11c4d002..9dc86539 100644 } diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/InventoryPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/InventoryPackets.java -index 6c1434ed..1cb31d42 100644 +index fdc8fd76..f56110b9 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/InventoryPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/InventoryPackets.java -@@ -93,6 +93,7 @@ import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.*; +@@ -96,6 +96,7 @@ import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.*; import net.raphimc.viabedrock.protocol.data.enums.java.generated.ContainerInput; import net.raphimc.viabedrock.protocol.data.enums.java.generated.EquipmentSlot; import net.raphimc.viabedrock.protocol.model.BedrockItem; @@ -3998,7 +4073,7 @@ index 6c1434ed..1cb31d42 100644 import net.raphimc.viabedrock.protocol.model.ItemCooldown; import net.raphimc.viabedrock.protocol.model.FullContainerName; import net.raphimc.viabedrock.protocol.model.inventory.BedrockInventoryTransaction; -@@ -109,6 +110,7 @@ import net.raphimc.viabedrock.protocol.types.entitydata.EntityDataTypesBedrock; +@@ -112,6 +113,7 @@ import net.raphimc.viabedrock.protocol.types.entitydata.EntityDataTypesBedrock; import net.raphimc.viabedrock.protocol.types.BedrockTypes; import net.raphimc.viabedrock.protocol.types.InventoryTypes; import net.raphimc.viabedrock.protocol.types.item.BedrockItemType; @@ -4006,7 +4081,7 @@ index 6c1434ed..1cb31d42 100644 import net.raphimc.viabedrock.protocol.types.recipe.CraftingDataReader; import java.util.ArrayList; -@@ -165,11 +167,69 @@ public final class InventoryPackets { +@@ -198,11 +200,69 @@ public final class InventoryPackets { final short javaId = container.translateContainerData(id); if (javaId < 0) { wrapper.cancel(); @@ -4076,7 +4151,7 @@ index 6c1434ed..1cb31d42 100644 } static void handleContainerClose(final PacketWrapper wrapper) { -@@ -531,7 +591,13 @@ public final class InventoryPackets { +@@ -564,7 +624,13 @@ public final class InventoryPackets { if (container instanceof LecternContainer lectern) { lectern.sendInitialState(); } @@ -4091,7 +4166,7 @@ index 6c1434ed..1cb31d42 100644 }); protocol.registerClientbound( ClientboundBedrockPackets.CONTAINER_CLOSE, -@@ -568,6 +634,9 @@ public final class InventoryPackets { +@@ -601,6 +667,9 @@ public final class InventoryPackets { container.markServerUpdate(); wrapper.user().get(InventoryRequestTracker.class) .handleAuthoritativeContent(container); @@ -4101,7 +4176,7 @@ index 6c1434ed..1cb31d42 100644 if (container instanceof StonecutterContainer stonecutter) { stonecutter.refreshInput(); wrapper.cancel(); -@@ -575,6 +644,7 @@ public final class InventoryPackets { +@@ -608,6 +677,7 @@ public final class InventoryPackets { } PacketFactory.writeJavaContainerSetContent(wrapper, container); PacketFactory.sendJavaCursorForHudUpdate(wrapper.user(), container); @@ -4109,7 +4184,7 @@ index 6c1434ed..1cb31d42 100644 } else { wrapper.cancel(); } -@@ -603,6 +673,12 @@ public final class InventoryPackets { +@@ -636,6 +706,12 @@ public final class InventoryPackets { container.markServerUpdate(); wrapper.user().get(InventoryRequestTracker.class) .handleAuthoritativeSlot(container, slot); @@ -4122,7 +4197,7 @@ index 6c1434ed..1cb31d42 100644 if (container instanceof StonecutterContainer stonecutter && slot == 3) { stonecutter.refreshInput(); wrapper.cancel(); -@@ -626,6 +702,7 @@ public final class InventoryPackets { +@@ -659,6 +735,7 @@ public final class InventoryPackets { } wrapper.write( VersionedTypes.V26_3.item, container.getJavaItem(slot)); // item @@ -4130,7 +4205,7 @@ index 6c1434ed..1cb31d42 100644 } else { wrapper.cancel(); } -@@ -687,6 +764,14 @@ public final class InventoryPackets { +@@ -720,6 +797,14 @@ public final class InventoryPackets { inventoryTracker.removeDynamicContainer(containerName); } }); @@ -4145,7 +4220,7 @@ index 6c1434ed..1cb31d42 100644 protocol.registerClientbound( ClientboundBedrockPackets.CRAFTING_DATA, null, -@@ -696,7 +781,27 @@ public final class InventoryPackets { +@@ -729,7 +814,27 @@ public final class InventoryPackets { wrapper.user().get(CraftingDataTracker.class); final CraftingDataReader.Data data = CraftingDataReader.read(wrapper); tracker.updateCraftingDataList(data.recipes(), data.clearRecipes()); @@ -4173,7 +4248,7 @@ index 6c1434ed..1cb31d42 100644 }); protocol.registerClientbound( ClientboundBedrockPackets.CONTAINER_SET_DATA, -@@ -909,6 +1014,46 @@ public final class InventoryPackets { +@@ -945,6 +1050,46 @@ public final class InventoryPackets { PacketFactory.sendJavaContainerSetContent(wrapper.user(), container); } }); @@ -4220,7 +4295,7 @@ index 6c1434ed..1cb31d42 100644 protocol.registerServerbound( ServerboundPackets26_3.CONTAINER_BUTTON_CLICK, null, -@@ -1732,10 +1877,10 @@ public final class InventoryPackets { +@@ -1760,10 +1905,10 @@ public final class InventoryPackets { private static BedrockItem readCreativeItem(final PacketWrapper wrapper, final ItemRewriter itemRewriter) throws InformativeException { @@ -4233,7 +4308,7 @@ index 6c1434ed..1cb31d42 100644 throws InformativeException { final int id = wrapper.read(BedrockTypes.VAR_INT); final int amount = wrapper.read(BedrockTypes.UNSIGNED_SHORT_LE); -@@ -1745,13 +1890,12 @@ public final class InventoryPackets { +@@ -1773,13 +1918,12 @@ public final class InventoryPackets { if (id == 0) { return BedrockItem.empty(); } @@ -4252,10 +4327,10 @@ index 6c1434ed..1cb31d42 100644 buffer.release(); } diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/JoinPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/JoinPackets.java -index fa556209..5d9f0fb3 100644 +index ea61d2d6..dd14f5c0 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/JoinPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/JoinPackets.java -@@ -569,6 +569,7 @@ public final class JoinPackets { +@@ -572,6 +572,7 @@ public final class JoinPackets { user.get(EntityTracker.class).sendNativeCustomEntitySnapshot(); user.get(PlayerInputStorage.class).markJoined(); user.get(PlayerInputStorage.class).sendSnapshot(); @@ -4264,28 +4339,90 @@ index fa556209..5d9f0fb3 100644 user.get(FogStorage.class).markJoined(); user.get(FogStorage.class).sendSnapshot(); diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java -index c04231bd..ac448e28 100644 +index 6a3aae6a..3968fa62 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java -@@ -20,7 +20,14 @@ package net.raphimc.viabedrock.protocol.packet; - import net.raphimc.viabedrock.api.model.PlayerInventoryScreenRequest; - import net.raphimc.viabedrock.protocol.storage.PlayerInventoryScreenTracker; +@@ -22,7 +22,20 @@ import net.raphimc.viabedrock.protocol.storage.PlayerInventoryScreenTracker; import net.raphimc.viabedrock.protocol.storage.NativeContainerUiState; + import net.raphimc.viabedrock.protocol.storage.InventoryTracker; + import net.raphimc.viabedrock.api.model.container.Container; +import net.raphimc.viabedrock.protocol.storage.NativeCookingUiState; +import net.raphimc.viabedrock.api.model.NativeCookingMessage; +import net.raphimc.viabedrock.api.model.NativeCookingProgress; import net.raphimc.viabedrock.api.model.NativeContainerAction; +import net.raphimc.viabedrock.api.model.NativeCookingAction; ++import net.raphimc.viabedrock.api.model.NativeCookingHoldAction; ++import net.raphimc.viabedrock.api.model.NativeCookingHoldResult; ++import net.raphimc.viabedrock.api.model.NativeCookingSourceAction; ++import net.raphimc.viabedrock.api.model.NativeCookingSourceResult; ++import net.raphimc.viabedrock.api.model.NativeCookingQuery; ++import net.raphimc.viabedrock.api.model.NativeCookingQueryResult; +import net.raphimc.viabedrock.api.model.NativeCookingActionResult; +import net.raphimc.viabedrock.api.model.NativeFurnaceOptions; +import net.raphimc.viabedrock.protocol.storage.InventoryOptionsStorage; import net.raphimc.viabedrock.api.model.NativeContainerStatus; import net.raphimc.viabedrock.api.model.NativeContainerMessage; import com.viaversion.viaversion.api.protocol.packet.State; -@@ -204,6 +211,23 @@ public final class MultiStatePackets { +@@ -207,6 +220,79 @@ public final class MultiStatePackets { } return; } ++ if (channel.equals(NativeCookingQuery.CHANNEL)) { ++ if (NativeCookingUiState.queriesEnabled(wrapper.user())) { ++ try { ++ final var query = wrapper.read(NativeCookingQuery.TYPE); ++ final var state = wrapper.user().get(NativeCookingUiState.class); ++ final boolean accepted = state.handleQuery(query); ++ NativeCookingUiState.sendUpdate(wrapper.user()); ++ final var result = PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, wrapper.user()); ++ result.write(Types.STRING, NativeCookingQueryResult.CHANNEL); ++ result.write(NativeCookingQueryResult.TYPE, new NativeCookingQueryResult(query, accepted)); ++ result.send(BedrockProtocol.class); ++ } catch (final IllegalArgumentException | IndexOutOfBoundsException malformed) { ++ // Search never authorizes a transfer or selects another opening. ++ } ++ } ++ return; ++ } ++ if (channel.equals(NativeCookingSourceAction.CHANNEL)) { ++ if (NativeCookingUiState.sourcesEnabled(wrapper.user())) { ++ try { ++ final var action = wrapper.read(NativeCookingSourceAction.TYPE); ++ final var state = wrapper.user().get(NativeCookingUiState.class); ++ final boolean accepted = state.handleSourceAction(action); ++ NativeCookingUiState.sendUpdate(wrapper.user()); ++ final var result = PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, wrapper.user()); ++ result.write(Types.STRING, NativeCookingSourceResult.CHANNEL); ++ result.write(NativeCookingSourceResult.TYPE, new NativeCookingSourceResult(action, accepted, state.current(action.source()))); ++ result.send(BedrockProtocol.class); ++ } catch (final IllegalArgumentException | IndexOutOfBoundsException malformed) { ++ // A malformed source command cannot start or replace a transfer gesture. ++ } ++ } ++ return; ++ } ++ if (channel.equals(NativeCookingHoldAction.CHANNEL)) { ++ if (wrapper.user().getProtocolInfo().getClientState() == State.PLAY ++ && NativeCookingUiState.holdsEnabled(wrapper.user())) { ++ try { ++ final var user = wrapper.user(); ++ final var action = wrapper.read(NativeCookingHoldAction.TYPE); ++ user.get(NativeCookingUiState.class).handleHoldAction(action, reply -> { ++ if (user.getProtocolInfo().getClientState() != State.PLAY) { ++ return; ++ } ++ final var result = PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, user); ++ result.write(Types.STRING, NativeCookingHoldResult.CHANNEL); ++ result.write(NativeCookingHoldResult.TYPE, reply); ++ result.send(BedrockProtocol.class); ++ }); ++ NativeCookingUiState.sendUpdate(wrapper.user()); ++ } catch (final IllegalArgumentException | IndexOutOfBoundsException malformed) { ++ // Invalid hold phases cannot mutate inventory. ++ } ++ } ++ return; ++ } + if (channel.equals(NativeCookingAction.CHANNEL)) { + if (NativeCookingUiState.actionsEnabled(wrapper.user())) { + try { @@ -4306,7 +4443,7 @@ index c04231bd..ac448e28 100644 if (channel.equals(CameraAimAssistActivation.Control.CHANNEL)) { if (wrapper.user().getProtocolInfo().getClientState() == State.PLAY) { final var input = io.netty.buffer.Unpooled.wrappedBuffer(wrapper.read(Types.REMAINING_BYTES)); -@@ -238,6 +262,20 @@ public final class MultiStatePackets { +@@ -241,6 +327,20 @@ public final class MultiStatePackets { } return; } @@ -4327,7 +4464,7 @@ index c04231bd..ac448e28 100644 if (channel.equals(NativeContainerMessage.CURRENT_CHANNEL)) { if (wrapper.user().getProtocolInfo().getClientState() == State.PLAY && wrapper.user().get(ChannelStorage.class).hasChannel(channel) -@@ -277,6 +315,12 @@ public final class MultiStatePackets { +@@ -286,6 +386,12 @@ public final class MultiStatePackets { if (screens != null) { screens.refreshCapability(); } @@ -4340,7 +4477,7 @@ index c04231bd..ac448e28 100644 if (channels.contains(PlayerInputPermissions.CHANNEL) && wrapper.user().has(PlayerInputStorage.class)) { wrapper.user().get(PlayerInputStorage.class).withdrawRawInput(); } -@@ -289,6 +333,11 @@ public final class MultiStatePackets { +@@ -298,6 +404,11 @@ public final class MultiStatePackets { && wrapper.user().has(NativeContainerUiState.class)) { wrapper.user().get(NativeContainerUiState.class).clearActions(); } @@ -4352,7 +4489,7 @@ index c04231bd..ac448e28 100644 if (wrapper.user().has(VehiclePredictionStorage.class)) { wrapper.user().get(VehiclePredictionStorage.class).refresh(); } -@@ -369,6 +418,12 @@ public final class MultiStatePackets { +@@ -389,6 +500,12 @@ public final class MultiStatePackets { if (screens != null) { screens.refreshCapability(); } @@ -4363,10 +4500,10 @@ index c04231bd..ac448e28 100644 + wrapper.user().get(InventoryOptionsStorage.class).sendNativeSnapshot(wrapper.user()); + } WorldPackets.sendNativeCommandCapability(wrapper.user()); + InventoryPackets.sendIncrementalDragCapability(wrapper.user()); if (channels.contains(LocatorBarSnapshot.CHANNEL) && wrapper.user().has(PlayerWaypointTracker.class)) { - wrapper.user().get(PlayerWaypointTracker.class).sendSnapshot(); 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 a5193c10..a6a4073d 100644 +index 9b074bc1..8f8aad75 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemRewriter.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemRewriter.java @@ -40,8 +40,6 @@ import net.raphimc.viabedrock.ViaBedrock; @@ -4443,7 +4580,7 @@ index a5193c10..a6a4073d 100644 this.optionalItemType = new OptionalType<>(this.itemType); this.itemArrayType = new ArrayType<>(this.itemType, BedrockTypes.UNSIGNED_VAR_INT); this.newItemType = -@@ -516,6 +533,27 @@ public class ItemRewriter extends StoredObject implements NetworkItemStackContex +@@ -517,6 +534,27 @@ public class ItemRewriter extends StoredObject implements NetworkItemStackContex (key, arguments) -> this.user().get(ResourcePackStorage.class).getTexts().translate(key, arguments.toArray())); } @@ -4471,7 +4608,7 @@ index a5193c10..a6a4073d 100644 private ItemNameContext itemNameContext() { return new ItemNameContext((block, metadata) -> this.user().get(BlockStateRewriter.class).blockItemNameKey(block, metadata), key -> this.user().get(ResourcePackStorage.class).getTexts().translate(key), -@@ -594,6 +632,14 @@ public class ItemRewriter extends StoredObject implements NetworkItemStackContex +@@ -595,6 +633,14 @@ public class ItemRewriter extends StoredObject implements NetworkItemStackContex (definition, auxiliary) -> this.associatedBlock(blocks, definition, auxiliary)); } @@ -4486,7 +4623,7 @@ index a5193c10..a6a4073d 100644 public BedrockItem createDefinitionItem(final NativeItemDefinitions.Definition definition, final int metadata, final int amount) { final var blocks = this.user().get(BlockStateRewriter.class); return this.registry.createDefinitionItem(definition, metadata, amount, -@@ -636,6 +682,89 @@ public class ItemRewriter extends StoredObject implements NetworkItemStackContex +@@ -637,6 +683,89 @@ public class ItemRewriter extends StoredObject implements NetworkItemStackContex ? this.user().get(BlockStateRewriter.class).blockItemRecipeDescriptor(item.blockRuntimeId()) : null); } @@ -4576,7 +4713,7 @@ index a5193c10..a6a4073d 100644 public boolean matchesRecipeItem(final String identifier, final int metadata, final BedrockItem item) { if (item == null) { return false; -@@ -644,6 +773,37 @@ public class ItemRewriter extends StoredObject implements NetworkItemStackContex +@@ -645,6 +774,37 @@ public class ItemRewriter extends StoredObject implements NetworkItemStackContex return NativeRecipeDescriptor.resolveName(identifier, metadata, context).matchesStack(item, context, this::recipeDescriptor); } @@ -4614,7 +4751,7 @@ index a5193c10..a6a4073d 100644 public ItemRegistry getItemRegistry() { return this.registry; } -@@ -666,6 +826,22 @@ public class ItemRewriter extends StoredObject implements NetworkItemStackContex +@@ -667,6 +827,22 @@ public class ItemRewriter extends StoredObject implements NetworkItemStackContex return NativeItemStackMatcher.matchesIdentity(query, candidate, blocks::blockState, blocks::restrictionBlocks); } @@ -4637,7 +4774,7 @@ index a5193c10..a6a4073d 100644 public boolean canMerge(final BedrockItem source, final BedrockItem destination) { if (destination == null || destination.isEmpty() || !this.registry.sameDefinition(source, destination)) { return false; -@@ -706,6 +882,14 @@ public class ItemRewriter extends StoredObject implements NetworkItemStackContex +@@ -707,6 +883,14 @@ public class ItemRewriter extends StoredObject implements NetworkItemStackContex return this.itemType; } @@ -5828,21 +5965,24 @@ index 00000000..f13adecf + +} 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 8ae83ff7..7bb2c3db 100644 +index d5f075f5..160e4a2e 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryRequestTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryRequestTracker.java -@@ -223,6 +223,10 @@ public class InventoryRequestTracker extends StoredObject { +@@ -224,6 +224,13 @@ public class InventoryRequestTracker extends StoredObject { return !this.requests.isEmpty() || this.craftedItemSynchronization != null || this.batchChanges != null; } + public boolean canStartRecipePlacement() { -+ return !this.hasPending() && !this.recovery.isActive() && !this.isWaitingForInventoryOpen(); ++ final var inventory = this.user().get(InventoryTracker.class); ++ final var container = inventory == null ? null : inventory.getCurrentContainer(); ++ return !this.hasPending() && !this.recovery.isActive() && !this.isWaitingForInventoryOpen() ++ && (container == null || !container.hasIncrementalDrag()); + } + public boolean isPredictingClick() { return this.predictingClick; } -@@ -435,6 +439,7 @@ public class InventoryRequestTracker extends StoredObject { +@@ -495,6 +502,7 @@ public class InventoryRequestTracker extends StoredObject { this.transferBatch = null; this.craftedItemSynchronization = null; this.resetRecovery(); @@ -5850,7 +5990,7 @@ index 8ae83ff7..7bb2c3db 100644 PacketFactory.sendJavaContainerSetContent(this.user(), tracker.getInventoryContainer()); } } -@@ -492,6 +497,7 @@ public class InventoryRequestTracker extends StoredObject { +@@ -552,6 +560,7 @@ public class InventoryRequestTracker extends StoredObject { this.batchChanges.addAll(changed); return; } @@ -5858,7 +5998,7 @@ index 8ae83ff7..7bb2c3db 100644 if (response.result() == ItemStackNetResult.Success) { this.drainQueuedInteractions(); } -@@ -528,6 +534,7 @@ public class InventoryRequestTracker extends StoredObject { +@@ -589,6 +598,7 @@ public class InventoryRequestTracker extends StoredObject { this.predictingClick = wasPredicting; this.batchChanges = null; } @@ -5976,10 +6116,10 @@ index 19ffffe3..04ad1756 100644 public long currentContainerGeneration() { diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/NativeCookingUiState.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/NativeCookingUiState.java new file mode 100644 -index 00000000..0cedc72a +index 00000000..f760707e --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/NativeCookingUiState.java -@@ -0,0 +1,502 @@ +@@ -0,0 +1,841 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -5997,6 +6137,12 @@ index 00000000..0cedc72a +import com.viaversion.viaversion.libs.gson.JsonElement; +import com.viaversion.viaversion.libs.gson.JsonPrimitive; +import net.raphimc.viabedrock.api.model.NativeCookingControl; ++import net.raphimc.viabedrock.api.model.NativeCookingSourceAction; ++import net.raphimc.viabedrock.api.model.NativeCookingSourceResult; ++import net.raphimc.viabedrock.api.model.container.Container; ++import net.raphimc.viabedrock.protocol.data.enums.java.generated.ContainerInput; ++import net.raphimc.viabedrock.api.model.NativeCookingQuery; ++import net.raphimc.viabedrock.api.model.NativeCookingQueryResult; +import net.raphimc.viabedrock.api.model.NativeCookingAction; +import net.raphimc.viabedrock.api.model.NativeCookingActionResult; +import net.raphimc.viabedrock.api.model.NativeCookingMessage; @@ -6007,6 +6153,7 @@ index 00000000..0cedc72a +import net.raphimc.viabedrock.api.resourcepack.ui.CookingNavigation; +import net.raphimc.viabedrock.api.resourcepack.ui.CookingRecipeOrder; +import net.raphimc.viabedrock.api.resourcepack.ui.CookingUi; ++import net.raphimc.viabedrock.api.resourcepack.ui.NativeRecipeSearch; +import net.raphimc.viabedrock.protocol.model.BedrockItem; +import net.raphimc.viabedrock.protocol.model.FurnaceOptions; +import net.raphimc.viabedrock.protocol.model.ItemCookingProperties; @@ -6019,6 +6166,7 @@ index 00000000..0cedc72a +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; ++import java.util.EnumMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; @@ -6026,6 +6174,9 @@ index 00000000..0cedc72a +import java.util.Set; +import java.util.logging.Level; +import java.util.function.Supplier; ++import java.util.function.Consumer; ++import net.raphimc.viabedrock.api.model.NativeCookingHoldAction; ++import net.raphimc.viabedrock.api.model.NativeCookingHoldResult; + +/** Publishes one exact menu's authoritative cooking state; progress does not replace item authority. */ +public final class NativeCookingUiState extends StoredObject { @@ -6052,6 +6203,13 @@ index 00000000..0cedc72a + private CookingUi.Selection selection = new CookingUi.Selection("", -1); + private CookingUi.Selection publishedSelection; + private boolean actionsAdvertised; ++ private boolean queriesAdvertised; ++ private boolean searchDirty; ++ private HeldQuantity heldQuantity; ++ private boolean holdsAdvertised; ++ private boolean sourcesAdvertised; ++ private NativeCookingSourceAction sourceDrawing; ++ private final Map queries = new EnumMap<>(CookingUi.Family.class); + + public NativeCookingUiState(final UserConnection user) { + super(user); @@ -6069,7 +6227,77 @@ index 00000000..0cedc72a + return enabled(user) && user.get(ChannelStorage.class).hasChannel(NativeCookingActionResult.CHANNEL); + } + ++ public static boolean queriesEnabled(final UserConnection user) { ++ return actionsEnabled(user) && user.get(ChannelStorage.class).hasChannel(NativeCookingQueryResult.CHANNEL); ++ } ++ ++ public static boolean holdsEnabled(final UserConnection user) { ++ return actionsEnabled(user) && user.get(ChannelStorage.class).hasChannel(NativeCookingHoldResult.CHANNEL); ++ } ++ ++ public static boolean sourcesEnabled(final UserConnection user) { ++ return actionsEnabled(user) && user.get(ChannelStorage.class).hasChannel(NativeCookingSourceResult.CHANNEL) ++ && user.get(ChannelStorage.class).hasChannel(Container.INCREMENTAL_DRAG_CHANNEL); ++ } ++ ++ private void refreshSourceCapability() { ++ final boolean available = sourcesEnabled(this.user()); ++ if (!available) { ++ this.clearSourceDrawing(); ++ } ++ if (this.user().getProtocolInfo().getClientState() != State.PLAY) { ++ this.sourcesAdvertised = false; ++ return; ++ } ++ if (available == this.sourcesAdvertised) { ++ return; ++ } ++ final var packet = PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, this.user()); ++ packet.write(Types.STRING, available ? "minecraft:register" : "minecraft:unregister"); ++ packet.write(Types.REMAINING_BYTES, NativeCookingSourceAction.CHANNEL.getBytes(StandardCharsets.UTF_8)); ++ packet.send(BedrockProtocol.class); ++ this.sourcesAdvertised = available; ++ } ++ ++ private void refreshHoldCapability() { ++ final boolean available = holdsEnabled(this.user()); ++ if (!available) { ++ this.cancelHeldQuantity(); ++ } ++ if (this.user().getProtocolInfo().getClientState() != State.PLAY) { ++ this.holdsAdvertised = false; ++ return; ++ } ++ if (available == this.holdsAdvertised) { ++ return; ++ } ++ final var packet = PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, this.user()); ++ packet.write(Types.STRING, available ? "minecraft:register" : "minecraft:unregister"); ++ packet.write(Types.REMAINING_BYTES, NativeCookingHoldAction.CHANNEL.getBytes(StandardCharsets.UTF_8)); ++ packet.send(BedrockProtocol.class); ++ this.holdsAdvertised = available; ++ } ++ ++ private void refreshQueryCapability() { ++ final boolean available = queriesEnabled(this.user()); ++ if (this.user().getProtocolInfo().getClientState() != State.PLAY) { ++ this.queriesAdvertised = false; ++ return; ++ } ++ if (available == this.queriesAdvertised) { ++ return; ++ } ++ final var packet = PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, this.user()); ++ packet.write(Types.STRING, available ? "minecraft:register" : "minecraft:unregister"); ++ packet.write(Types.REMAINING_BYTES, NativeCookingQuery.CHANNEL.getBytes(StandardCharsets.UTF_8)); ++ packet.send(BedrockProtocol.class); ++ this.queriesAdvertised = available; ++ } ++ + public void refreshActionCapability() { ++ this.refreshSourceCapability(); ++ this.refreshQueryCapability(); ++ this.refreshHoldCapability(); + if (this.user().getProtocolInfo().getClientState() != State.PLAY) { + this.actionsAdvertised = false; + this.clearActions(); @@ -6090,6 +6318,8 @@ index 00000000..0cedc72a + } + + private void clearActions() { ++ this.clearSourceDrawing(); ++ this.cancelHeldQuantity(); + this.gesture = null; + this.focus = null; + this.gestureCommands = Set.of(); @@ -6177,6 +6407,7 @@ index 00000000..0cedc72a + } + + public Publication update() { ++ this.flushHeldQuantity(); + if (!this.ownsMenu()) { + this.clear(); + return null; @@ -6209,6 +6440,187 @@ index 00000000..0cedc72a + action.menuRevision(), action.inventoryRevision(), action.cursorRevision(), NativeCookingControl.Mode.ACTIVATE)); + } + ++ /** Bind the first transfer to the shown frame; later drawing visits retain its native budget. */ ++ public boolean handleSourceAction(final NativeCookingSourceAction action) { ++ if (this.handlingAction || this.publishing || !sourcesEnabled(this.user()) ++ || !this.ownsMenu() || action.requestSequence() <= this.lastRequestSequence) { ++ return false; ++ } ++ final var requests = this.user().get(InventoryRequestTracker.class); ++ if (action.kind() == NativeCookingSourceAction.Kind.RETIRE) { ++ if (!action.continues(this.sourceDrawing)) { ++ return false; ++ } ++ this.sourceDrawing = null; ++ this.lastRequestSequence = action.requestSequence(); ++ if (!this.container.hasSelectedSourceDrag()) { ++ return true; ++ } ++ if (requests.hasPending()) { ++ return requests.queueClick(this.container, 0, (short) -999, (byte) 2, ContainerInput.QUICK_CRAFT); ++ } ++ return this.container.handleClick(0, (short) -999, (byte) 2, ContainerInput.QUICK_CRAFT); ++ } ++ if (!this.current(action.source()) || this.gesture != null || this.focus != null || this.heldQuantity != null) { ++ return false; ++ } ++ if (!requests.canStartRecipePlacement() || requests.isPredictingClick()) { ++ return false; ++ } ++ this.handlingAction = true; ++ this.lastRequestSequence = action.requestSequence(); ++ final var owner = this.container; ++ final long generation = this.generation; ++ final long sequence = this.sequence; ++ try { ++ final boolean accepted; ++ if (action.kind() == NativeCookingSourceAction.Kind.TAP) { ++ accepted = owner.handleNativeSourceTap(0, (short) action.sourceSlot(), (short) action.destinationSlot(), action.quantity()); ++ } else if (owner.beginNativeSourceDrag((short) action.sourceSlot(), action.quantity())) { ++ this.sourceDrawing = action; ++ accepted = owner.handleClick(0, (short) action.destinationSlot(), (byte) 1, ContainerInput.QUICK_CRAFT); ++ if (!accepted) { ++ this.clearSourceDrawing(); ++ } ++ } else { ++ accepted = false; ++ } ++ return accepted && this.ownsMenu() && this.container == owner && this.generation == generation && this.sequence == sequence; ++ } finally { ++ this.handlingAction = false; ++ } ++ } ++ ++ private void clearSourceDrawing() { ++ final boolean active = this.sourceDrawing != null; ++ this.sourceDrawing = null; ++ if (active && this.ownsMenu() && this.container.hasSelectedSourceDrag()) { ++ this.container.cancelQuickCraft(); ++ } ++ } ++ ++ /** Keep completion ordered behind the return request, using the server's renewed stack identity. */ ++ public void handleHoldAction(final NativeCookingHoldAction action, final Consumer reply) { ++ Objects.requireNonNull(reply); ++ if (this.handlingAction || this.publishing || !holdsEnabled(this.user()) || !this.ownsMenu() ++ || action.requestSequence() <= this.lastRequestSequence) { ++ reply.accept(new NativeCookingHoldResult(action, false)); ++ return; ++ } ++ if (action.phase() != NativeCookingHoldAction.Phase.START) { ++ final var held = this.heldQuantity; ++ if (held == null || !action.continues(held.start) ++ || action.quantity() > held.sourceBefore.amount() + held.cursorBefore.amount() ++ || action.phase() == NativeCookingHoldAction.Phase.COMPLETE && held.completion() != null) { ++ reply.accept(new NativeCookingHoldResult(action, false)); ++ return; ++ } ++ this.lastRequestSequence = action.requestSequence(); ++ if (action.phase() == NativeCookingHoldAction.Phase.RETIRE) { ++ this.cancelHeldQuantity(); ++ reply.accept(new NativeCookingHoldResult(action, true)); ++ return; ++ } ++ held.queueCompletion(action, reply); ++ this.flushHeldQuantity(); ++ return; ++ } ++ final var requests = this.user().get(InventoryRequestTracker.class); ++ if (this.heldQuantity != null || this.gesture != null || this.focus != null ++ || !this.current(action.source()) || requests.isPredictingClick()) { ++ reply.accept(new NativeCookingHoldResult(action, false)); ++ return; ++ } ++ final var source = this.container.getItemInJavaSlot((short) action.slot()); ++ final var cursor = this.inventoryOwner.getHudContainer().getItem(0); ++ if (source == null || cursor.isEmpty()) { ++ reply.accept(new NativeCookingHoldResult(action, false)); ++ return; ++ } ++ this.heldQuantity = new HeldQuantity(action, source, cursor, reply); ++ this.lastRequestSequence = action.requestSequence(); ++ this.flushHeldQuantity(); ++ } ++ ++ private void flushHeldQuantity() { ++ final var held = this.heldQuantity; ++ if (held == null || this.handlingAction) { ++ return; ++ } ++ if (!holdsEnabled(this.user()) || !this.ownsMenu() ++ || this.sequence != held.start.source().sequence() ++ || Byte.toUnsignedInt(this.container.javaContainerId()) != held.start.source().menuId()) { ++ this.cancelHeldQuantity(); ++ return; ++ } ++ final var requests = this.user().get(InventoryRequestTracker.class); ++ if (!requests.canStartRecipePlacement() || requests.isPredictingClick()) { ++ return; ++ } ++ if (held.item == null) { ++ final var source = this.container.getItemInJavaSlot((short) held.start.slot()); ++ final var cursor = this.inventoryOwner.getHudContainer().getItem(0); ++ if (!this.matchesHeldItem(source, held.sourceBefore) || !this.matchesHeldItem(cursor, held.cursorBefore)) { ++ this.cancelHeldQuantity(); ++ return; ++ } ++ this.handlingAction = true; ++ final boolean accepted; ++ try { ++ accepted = this.container.handleNativeCursorReturn(0, (short) held.start.slot()); ++ } finally { ++ this.handlingAction = false; ++ } ++ if (!accepted) { ++ this.cancelHeldQuantity(); ++ return; ++ } ++ if (this.heldQuantity != held) { ++ return; ++ } ++ held.item = this.container.getItemInJavaSlot((short) held.start.slot()); ++ final var startReply = held.startReply; ++ held.startReply = null; ++ startReply.accept(new NativeCookingHoldResult(held.start, true)); ++ return; ++ } ++ if (held.completion() == null) { ++ return; ++ } ++ final var item = this.container.getItemInJavaSlot((short) held.start.slot()); ++ final var cursor = this.inventoryOwner.getHudContainer().getItem(0); ++ this.heldQuantity = null; ++ boolean accepted = false; ++ if (item != null && !item.isEmpty() && item.amount() == held.item.amount() ++ && this.user().get(ItemRewriter.class).canMerge(item, held.item) && cursor.isEmpty()) { ++ this.handlingAction = true; ++ try { ++ accepted = this.container.handleNativeQuantityPickup(0, (short) held.start.slot(), held.completion().quantity()); ++ } finally { ++ this.handlingAction = false; ++ } ++ } ++ held.reply(accepted); ++ } ++ ++ private void cancelHeldQuantity() { ++ final var held = this.heldQuantity; ++ this.heldQuantity = null; ++ if (held == null) { ++ return; ++ } ++ if (held.startReply != null) { ++ held.startReply.accept(new NativeCookingHoldResult(held.start, false)); ++ } ++ held.reply(false); ++ } ++ ++ private boolean matchesHeldItem(final BedrockItem current, final BedrockItem expected) { ++ return current != null && (current.isEmpty() ? expected.isEmpty() ++ : !expected.isEmpty() && current.amount() == expected.amount() ++ && this.user().get(ItemRewriter.class).canMerge(current, expected)); ++ } ++ + /** Admit one exact authored cell. Follow-up phases retire the original gesture without transferring again. */ + public boolean handleAction(final NativeCookingAction action) { + if (this.handlingAction || this.publishing) { @@ -6222,8 +6634,26 @@ index 00000000..0cedc72a + } + } + ++ /** Search and recipe commands share one ordered input stream for this opening. */ ++ public boolean handleQuery(final NativeCookingQuery query) { ++ if (this.handlingAction || this.publishing || !queriesEnabled(this.user()) ++ || !this.current(query.source()) || query.requestSequence() <= this.lastRequestSequence ++ || this.gesture != null || this.focus != null || this.heldQuantity != null) { ++ return false; ++ } ++ final var requests = this.user().get(InventoryRequestTracker.class); ++ if (!requests.canStartRecipePlacement() || requests.isPredictingClick()) { ++ return false; ++ } ++ this.queries.put(this.family(), query.text()); ++ this.searchDirty = true; ++ this.clearActions(); ++ this.lastRequestSequence = query.requestSequence(); ++ return true; ++ } ++ + private boolean applyAction(final NativeCookingAction action) { -+ if (!this.user().get(GameSessionStorage.class).isInventoryServerAuthoritative() ++ if (this.heldQuantity != null || !this.user().get(GameSessionStorage.class).isInventoryServerAuthoritative() + || !this.ownsMenu() || action.requestSequence() <= this.lastRequestSequence) { + return false; + } @@ -6312,12 +6742,13 @@ index 00000000..0cedc72a + return false; + } + final var inventory = this.user().get(InventoryTracker.class); -+ return this.container.serverRevision() == this.message.menuRevision() ++ return !this.searchDirty && this.container.serverRevision() == this.message.menuRevision() + && inventory.getInventoryContainer().serverRevision() == this.message.inventoryRevision() + && inventory.getHudContainer().serverRevision() == this.message.cursorRevision() + && Arrays.equals(this.stationItems, this.container.getItems()) + && Arrays.equals(this.playerItems, inventory.getInventoryContainer().getItems()) + && Arrays.equals(this.cursorItems, inventory.getHudContainer().getItems()) ++ && Objects.equals(this.message.view().book().searchText(), this.queries.getOrDefault(this.family(), "")) + && Objects.equals(this.publishedSelection, this.selection) + && Objects.equals(this.options, this.preferences()) + && this.ui == this.user().get(ResourcePackStorage.class).getCookingUi(); @@ -6332,6 +6763,8 @@ index 00000000..0cedc72a + final var catalog = this.container.recipeCatalog(); + final var ui = this.user().get(ResourcePackStorage.class).getCookingUi(); + final var options = this.preferences(); ++ final String query = this.queries.getOrDefault(this.family(), ""); ++ final var search = new NativeRecipeSearch(query, NativeRecipeSearch.Mode.COOKING); + final var selectionBefore = this.selection; + final var gesture = this.gesture; + final BedrockItem[] stationItems = copy(this.container.getItems()); @@ -6344,6 +6777,7 @@ index 00000000..0cedc72a + final List recipes = new ArrayList<>(); + final Map previews = new HashMap<>(); + final Map categories = new HashMap<>(); ++ final Map outputs = new HashMap<>(); + for (final var entry : catalog.entries()) { + final var recipe = entry.data().recipe(); + final BedrockItem output = ((ShapelessRecipe) recipe).getResults().getFirst(); @@ -6354,6 +6788,7 @@ index 00000000..0cedc72a + Map.of("#container_item_background", new JsonPrimitive(evaluation)))); + previews.put(entry.id(), items.javaItem(output.copy())); + categories.put(entry.id(), items.cookingProperties(output).category()); ++ outputs.put(entry.id(), output); + } + final var ordered = CookingRecipeOrder.bookOrder(recipes, CookingUi.Recipe::uniqueId, CookingUi.Recipe::unlocked, + CookingUi.Recipe::outputName, CookingUi.Recipe::priority); @@ -6367,7 +6802,10 @@ index 00000000..0cedc72a + } + final Set visible = new HashSet<>(); + for (final var recipe : ordered) { -+ if (tab == 4 || tab >= 1 && tab <= 3 && categoryTab(categories.get(recipe.id())) == tab) { ++ if (tab == 4 ? search.matches( ++ () -> items.itemPrimaryName(outputs.get(recipe.id())).getBytes(StandardCharsets.UTF_8), ++ () -> items.itemSecondaryDescription(outputs.get(recipe.id())).getBytes(StandardCharsets.UTF_8)) ++ : tab >= 1 && tab <= 3 && categoryTab(categories.get(recipe.id())) == tab) { + visible.add(recipe.id()); + } + } @@ -6383,7 +6821,7 @@ index 00000000..0cedc72a + } + } + final var book = new CookingUi.Book(options == null ? 2 : options.layout(), false, tab, filtering, -+ food, ordinary, blocks, CookingNavigation.tabLabel(tab, family, filtering), "", selection, recipes, visible); ++ food, ordinary, blocks, CookingNavigation.tabLabel(tab, family, filtering), query, selection, recipes, visible); + final List slots = new ArrayList<>(39); + for (int slot = 0; slot < 3; slot++) { + slots.add(new ContainerUi.Slot(slot, count(stationItems[slot], false))); @@ -6401,6 +6839,7 @@ index 00000000..0cedc72a + || cursorRevision != inventory.getHudContainer().serverRevision() + || !Arrays.equals(stationItems, this.container.getItems()) || !Arrays.equals(playerItems, inventory.getInventoryContainer().getItems()) + || !Arrays.equals(cursorItems, inventory.getHudContainer().getItems()) || !Objects.equals(options, this.preferences()) ++ || !query.equals(this.queries.getOrDefault(this.family(), "")) + || gesture != this.gesture || !selectionBefore.equals(this.selection) + || ui != this.user().get(ResourcePackStorage.class).getCookingUi()) { + throw new IllegalStateException("Cooking authority changed during item conversion"); @@ -6415,6 +6854,7 @@ index 00000000..0cedc72a + this.cursorItems = cursorItems; + this.dataRevision = this.container.serverDataRevision(); + this.message = next; ++ this.searchDirty = false; + return next; + } + @@ -6457,6 +6897,7 @@ index 00000000..0cedc72a + } + + public void clear() { ++ this.clearSourceDrawing(); + this.inventoryOwner = null; + this.container = null; + this.title = null; @@ -6470,6 +6911,7 @@ index 00000000..0cedc72a + this.clearActions(); + this.lastRequestSequence = 0; + this.publishedSelection = null; ++ this.searchDirty = false; + } + + public record Publication(NativeCookingMessage message, NativeCookingProgress progress) { @@ -6481,6 +6923,43 @@ index 00000000..0cedc72a + } + } + ++ private static final class HeldQuantity { ++ private final NativeCookingHoldAction start; ++ private final BedrockItem sourceBefore; ++ private final BedrockItem cursorBefore; ++ private BedrockItem item; ++ private Consumer startReply; ++ private NativeCookingHoldAction completion; ++ private Consumer reply; ++ ++ private NativeCookingHoldAction completion() { ++ return this.completion; ++ } ++ ++ private void queueCompletion(final NativeCookingHoldAction action, final Consumer callback) { ++ this.completion = action; ++ this.reply = callback; ++ } ++ ++ private void reply(final boolean accepted) { ++ final var callback = this.reply; ++ final var action = this.completion; ++ this.reply = null; ++ this.completion = null; ++ if (callback != null) { ++ callback.accept(new NativeCookingHoldResult(action, accepted)); ++ } ++ } ++ ++ private HeldQuantity(final NativeCookingHoldAction start, final BedrockItem source, final BedrockItem cursor, ++ final Consumer reply) { ++ this.start = start; ++ this.sourceBefore = source.copy(); ++ this.cursorBefore = cursor.copy(); ++ this.startReply = reply; ++ } ++ } ++ +} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/types/BedrockTypes.java b/src/main/java/net/raphimc/viabedrock/protocol/types/BedrockTypes.java index d83e920a..5ac1c773 100644 @@ -10686,10 +11165,10 @@ index 00000000..ed9829fd +} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/rewriter/RegisteredPlayerRecipeAvailabilityTest.java b/src/test/java/net/raphimc/viabedrock/protocol/rewriter/RegisteredPlayerRecipeAvailabilityTest.java new file mode 100644 -index 00000000..de622a23 +index 00000000..7b1f3805 --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/protocol/rewriter/RegisteredPlayerRecipeAvailabilityTest.java -@@ -0,0 +1,372 @@ +@@ -0,0 +1,389 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -10746,6 +11225,23 @@ index 00000000..de622a23 + private static final int IDENTIFIER_BASE = 30000; + + @Test ++ void emptyRegisteredTemplatesDoNotAbortAvailabilityOrReadRetiredSources() throws Exception { ++ final var context = PlayerRecipeAvailabilityTest.context(false, IDENTIFIER_BASE); ++ final var tracker = new CraftingDataTracker(context.items().user()); ++ final var output = item(1); ++ final var placeholders = List.of(recipe(RecipeType.SHAPELESS, 1, 0, 0, List.of()), ++ recipe(RecipeType.SHAPED, 2, 1, 1, List.of(new ItemDescriptor.InvalidDescriptor()))); ++ tracker.updateCraftingDataList(placeholders, true); ++ assertEquals(3, tracker.playerRecipeAvailability(output, BedrockItem.empty(), context.owner(), false, true)); ++ assertEquals(3, tracker.playerRecipeAvailability(output, BedrockItem.empty(), context.owner().copy(), false, true)); ++ assertEquals(2, tracker.playerRecipeAvailability(output, output, context.owner().copy(), false, true)); ++ tracker.updateCraftingDataList(List.of(recipe(RecipeType.SHAPED, 3, 1, 1, List.of(name(1, 1)))), false); ++ context.inventory().getInventoryContainer().setItem(9, output.copy()); ++ assertEquals(1, tracker.playerRecipeAvailability(output, BedrockItem.empty(), context.owner(), false, true)); ++ assertEquals(0, tracker.playerRecipeAvailability(output, BedrockItem.empty(), context.owner(), true, true)); ++ } ++ ++ @Test + void retainedOwnersKeepFirstNameResolutionAndTagAdmission() throws Exception { + for (final ItemDescriptor late : List.of(new ItemDescriptor.ItemNameDescriptor("test:late", 0), + new ItemDescriptor.ItemTagDescriptor("test:late"))) { @@ -13547,10 +14043,10 @@ index 00000000..07c3331f +} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/storage/NativeCookingUiStateTest.java b/src/test/java/net/raphimc/viabedrock/protocol/storage/NativeCookingUiStateTest.java new file mode 100644 -index 00000000..3797b7c5 +index 00000000..f37388aa --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/protocol/storage/NativeCookingUiStateTest.java -@@ -0,0 +1,625 @@ +@@ -0,0 +1,1203 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -13570,8 +14066,24 @@ index 00000000..3797b7c5 +import com.viaversion.viaversion.protocols.v26_2to26_3.packet.ClientboundPackets26_3; +import io.netty.buffer.Unpooled; +import net.raphimc.viabedrock.api.model.NativeCookingControl; ++import net.raphimc.viabedrock.api.model.NativeCookingQuery; ++import net.raphimc.viabedrock.api.model.NativeCookingQueryResult; +import net.raphimc.viabedrock.api.model.NativeCookingAction; +import net.raphimc.viabedrock.api.model.NativeCookingActionResult; ++import net.raphimc.viabedrock.api.model.NativeCookingHoldAction; ++import net.raphimc.viabedrock.api.model.NativeCookingHoldResult; ++import net.raphimc.viabedrock.api.model.NativeCookingSourceAction; ++import net.raphimc.viabedrock.api.model.NativeCookingSourceResult; ++import net.raphimc.viabedrock.api.model.container.Container; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ItemStackNetResult; ++import net.raphimc.viabedrock.protocol.model.inventory.ItemStackRequestAction; ++import net.raphimc.viabedrock.protocol.model.inventory.ItemStackResponseInfo; ++import net.raphimc.viabedrock.protocol.model.inventory.ItemStackResponseContainerInfo; ++import net.raphimc.viabedrock.protocol.model.inventory.ItemStackResponseSlotInfo; ++import net.raphimc.viabedrock.ViaBedrock; ++import net.raphimc.viabedrock.platform.ViaBedrockPlatform; ++import java.util.logging.Logger; ++import net.raphimc.viabedrock.protocol.data.enums.java.generated.ContainerInput; +import net.raphimc.viabedrock.api.util.PacketFactory; +import net.raphimc.viabedrock.protocol.packet.MultiStatePackets; +import net.raphimc.viabedrock.protocol.packet.WorldPackets; @@ -13601,11 +14113,13 @@ index 00000000..3797b7c5 +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; ++import org.mockito.MockedStatic; + +import java.lang.reflect.Proxy; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.ArrayList; ++import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Set; @@ -13617,6 +14131,566 @@ index 00000000..3797b7c5 +class NativeCookingUiStateTest { + + @Test ++ void sourceTransportBindsItsFirstDestinationAndWithdrawalStopsFurtherDrawing() throws Exception { ++ final var f = sourceFixture(); ++ final var frame = f.state.begin(new ContainerUi.Title("container.furnace", true)).message(); ++ final var action = new NativeCookingSourceAction(NativeCookingControl.forMessage(frame, NativeCookingControl.Mode.ACTIVATE), ++ 0, 4, 9, NativeCookingSourceAction.Kind.DRAW, 1); ++ final var outbound = new ArrayList(); ++ try (var world = mockStatic(WorldPackets.class); ++ var packets = mockStatic(PacketFactory.class); ++ var factory = mockStatic(PacketWrapper.class)) { ++ factory.when(() -> PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, f.user)).thenAnswer(_ -> { ++ final var packet = spy(new PacketWrapperImpl(ClientboundPackets26_3.CUSTOM_PAYLOAD, null, f.user)); ++ doNothing().when(packet).send(BedrockProtocol.class); ++ outbound.add(packet); ++ return packet; ++ }); ++ f.state.refreshActionCapability(); ++ assertTrue(outbound.stream().anyMatch(packet -> packet.get(Types.STRING, 0).equals("minecraft:register") ++ && Arrays.equals(NativeCookingSourceAction.CHANNEL.getBytes(StandardCharsets.UTF_8), packet.get(Types.REMAINING_BYTES, 0)))); ++ sourcePacket(f, action); ++ assertEquals(new NativeCookingSourceResult(action, true, false), outbound.getLast().get(NativeCookingSourceResult.TYPE, 0)); ++ assertTrue(f.station.getItem(0).isEmpty()); ++ assertEquals(9, f.station.getItemInJavaSlot((short) 4).amount()); ++ assertTrue(f.inventory.getHudContainer().getItem(0).isEmpty()); ++ assertTrue(f.station.hasIncrementalDrag()); ++ channels(f, "minecraft:unregister", NativeCookingSourceResult.CHANNEL); ++ assertFalse(f.station.hasIncrementalDrag()); ++ assertTrue(f.user.get(InventoryRequestTracker.class).hasPending()); ++ final int withdrawn = outbound.size(); ++ sourcePacket(f, action); ++ assertEquals(withdrawn, outbound.size()); ++ packets.verify(() -> PacketFactory.sendBedrockCursorItemDrag(eq(f.user), anyBoolean()), never()); ++ } ++ } ++ ++ @Test ++ void staleAndReplayedSourceTapsCannotChangeTheCurrentOpening() { ++ final var f = sourceFixture(); ++ final var frame = f.state.begin(new ContainerUi.Title("container.furnace", true)).message(); ++ final var action = new NativeCookingSourceAction(NativeCookingControl.forMessage(frame, NativeCookingControl.Mode.ACTIVATE), ++ 0, 4, 4, NativeCookingSourceAction.Kind.TAP, 1); ++ f.station.markServerUpdate(); ++ final var newer = f.state.update().message(); ++ try (var packets = mockStatic(PacketFactory.class)) { ++ assertFalse(f.state.handleSourceAction(action)); ++ assertEquals(9, f.station.getItem(0).amount()); ++ assertTrue(f.station.getItemInJavaSlot((short) 4).isEmpty()); ++ packets.verifyNoInteractions(); ++ final var current = new NativeCookingSourceAction(NativeCookingControl.forMessage(newer, NativeCookingControl.Mode.ACTIVATE), ++ 0, 4, 4, NativeCookingSourceAction.Kind.TAP, 2); ++ assertTrue(f.state.handleSourceAction(current)); ++ assertFalse(f.state.handleSourceAction(current)); ++ assertEquals(5, f.station.getItem(0).amount()); ++ assertEquals(4, f.station.getItemInJavaSlot((short) 4).amount()); ++ assertTrue(f.inventory.getHudContainer().getItem(0).isEmpty()); ++ packets.verify(() -> PacketFactory.sendBedrockInventoryRequest(eq(f.user), any()), times(1)); ++ } ++ } ++ ++ @Test ++ void settledSourceDrawingBlocksSearchUntilItsDeliveredCompletion() { ++ final var f = sourceFixture(); ++ f.user.get(ChannelStorage.class).addChannels(List.of(NativeCookingQueryResult.CHANNEL)); ++ final var frame = f.state.begin(new ContainerUi.Title("container.furnace", true)).message(); ++ final var requests = f.user.get(InventoryRequestTracker.class); ++ try (var packets = mockStatic(PacketFactory.class)) { ++ assertTrue(f.state.handleSourceAction(new NativeCookingSourceAction( ++ NativeCookingControl.forMessage(frame, NativeCookingControl.Mode.ACTIVATE), 0, 4, 9, NativeCookingSourceAction.Kind.DRAW, 1))); ++ acceptSourcePlacement(f, packets, 9); ++ assertFalse(requests.hasPending()); ++ final var update = f.state.update().message(); ++ final var query = NativeCookingQuery.forMessage(update, 2, "food"); ++ assertFalse(f.state.handleQuery(query)); ++ assertTrue(f.station.handleClick(0, (short) -999, (byte) 2, ContainerInput.QUICK_CRAFT)); ++ assertTrue(f.state.handleQuery(query)); ++ } ++ } ++ ++ @Test ++ void retiringOldSourcePublicationPreservesNewerCursorOriginDrag() { ++ final var f = sourceFixture(); ++ final var frame = f.state.begin(new ContainerUi.Title("container.furnace", true)).message(); ++ final var requests = f.user.get(InventoryRequestTracker.class); ++ try (var packets = mockStatic(PacketFactory.class)) { ++ assertTrue(f.state.handleSourceAction(new NativeCookingSourceAction( ++ NativeCookingControl.forMessage(frame, NativeCookingControl.Mode.ACTIVATE), 0, 4, 9, NativeCookingSourceAction.Kind.DRAW, 1))); ++ acceptSourcePlacement(f, packets, 9); ++ assertTrue(f.station.handleClick(0, (short) -999, (byte) 2, ContainerInput.QUICK_CRAFT)); ++ requests.setPredictingClick(true); ++ final var cursor = new BedrockItem(2, (short) 0, (byte) 3); ++ cursor.setNetId(555); ++ f.inventory.getHudContainer().setItem(0, cursor); ++ requests.setPredictingClick(false); ++ assertTrue(f.station.handleClick(0, (short) -999, (byte) 0, ContainerInput.QUICK_CRAFT)); ++ f.state.clear(); ++ assertTrue(f.station.hasIncrementalDrag()); ++ assertTrue(f.station.handleClick(0, (short) 5, (byte) 1, ContainerInput.QUICK_CRAFT)); ++ assertEquals(3, f.station.getItemInJavaSlot((short) 5).amount()); ++ } ++ } ++ ++ @Test ++ void sourceRetirementUsesItsOriginalFrameAndWaitsForTheEarlierTransfer() { ++ final var f = sourceFixture(); ++ final var frame = f.state.begin(new ContainerUi.Title("container.furnace", true)).message(); ++ final var start = new NativeCookingSourceAction(NativeCookingControl.forMessage(frame, NativeCookingControl.Mode.ACTIVATE), ++ 0, 4, 9, NativeCookingSourceAction.Kind.DRAW, 1); ++ final var requests = f.user.get(InventoryRequestTracker.class); ++ try (var packets = mockStatic(PacketFactory.class)) { ++ assertTrue(f.state.handleSourceAction(start)); ++ assertTrue(f.state.update().message().updateSequence() > frame.updateSequence()); ++ assertFalse(f.state.current(start.source())); ++ final var retire = new NativeCookingSourceAction(start.source(), 0, 4, 9, NativeCookingSourceAction.Kind.RETIRE, 2); ++ assertTrue(f.state.handleSourceAction(retire)); ++ assertTrue(f.station.hasIncrementalDrag()); ++ acceptSourcePlacement(f, packets, 9); ++ assertFalse(requests.hasPending()); ++ assertFalse(f.station.hasIncrementalDrag()); ++ assertEquals(9, f.station.getItemInJavaSlot((short) 4).amount()); ++ requests.setPredictingClick(true); ++ final var cursor = new BedrockItem(2, (short) 0, (byte) 3); ++ cursor.setNetId(555); ++ f.inventory.getHudContainer().setItem(0, cursor); ++ requests.setPredictingClick(false); ++ assertTrue(f.station.handleClick(0, (short) -999, (byte) 0, ContainerInput.QUICK_CRAFT)); ++ assertFalse(f.state.handleSourceAction(new NativeCookingSourceAction(start.source(), 0, 4, 9, NativeCookingSourceAction.Kind.RETIRE, 3))); ++ assertTrue(f.station.hasIncrementalDrag()); ++ } ++ } ++ ++ private static Fixture sourceFixture() { ++ final var f = heldFixture(); ++ f.user.get(ChannelStorage.class).addChannels(List.of(NativeCookingSourceResult.CHANNEL, Container.INCREMENTAL_DRAG_CHANNEL)); ++ final var requests = f.user.get(InventoryRequestTracker.class); ++ requests.setPredictingClick(true); ++ final var source = f.inventory.getHudContainer().getItem(0).copy(); ++ f.inventory.getHudContainer().setItem(0, BedrockItem.empty()); ++ f.station.setItem(0, source); ++ requests.setPredictingClick(false); ++ f.station.markServerUpdate(); ++ return f; ++ } ++ ++ private static void sourcePacket(final Fixture f, final NativeCookingSourceAction action) throws Exception { ++ final var buffer = Unpooled.buffer(); ++ try { ++ Types.STRING.write(buffer, NativeCookingSourceAction.CHANNEL); ++ NativeCookingSourceAction.TYPE.write(buffer, action); ++ MultiStatePackets.CUSTOM_PAYLOAD_HANDLER.handle(new PacketWrapperImpl(ServerboundPackets26_3.CUSTOM_PAYLOAD, buffer, f.user)); ++ } finally { ++ buffer.release(); ++ } ++ } ++ ++ private static void acceptSourcePlacement(final Fixture f, final MockedStatic packets, final int amount) { ++ final var requests = f.user.get(InventoryRequestTracker.class); ++ final var sent = ArgumentCaptor.forClass(ItemStackRequestInfo[].class); ++ packets.verify(() -> PacketFactory.sendBedrockInventoryRequest(eq(f.user), sent.capture())); ++ final var request = sent.getValue()[0]; ++ final var place = (ItemStackRequestAction.PlaceAction) request.actions().getFirst(); ++ requests.handleResponse(new ItemStackResponseInfo(ItemStackNetResult.Success, request.requestId(), List.of( ++ new ItemStackResponseContainerInfo(place.source().container(), List.of( ++ new ItemStackResponseSlotInfo(place.source().slot(), place.source().slot(), (byte) 0, 0, "", null, 0))), ++ new ItemStackResponseContainerInfo(place.destination().container(), List.of( ++ new ItemStackResponseSlotInfo(place.destination().slot(), place.destination().slot(), (byte) amount, 222, "", null, 0)))))); ++ } ++ ++ @Test ++ void heldCursorReturnCannotSwapAnIncompatibleSourceOrOverflowItsCapacity() { ++ for (final boolean incompatible : new boolean[]{true, false}) { ++ final var f = heldFixture(); ++ final var requests = f.user.get(InventoryRequestTracker.class); ++ requests.setPredictingClick(true); ++ final var source = new BedrockItem(incompatible ? 4 : 2, (short) 0, (byte) (incompatible ? 1 : 60)); ++ source.setNetId(44); ++ f.station.setItem(0, source); ++ requests.setPredictingClick(false); ++ final var frame = f.state.begin(new ContainerUi.Title("container.furnace", true)).message(); ++ final var start = new NativeCookingHoldAction(NativeCookingControl.forMessage(frame, NativeCookingControl.Mode.ACTIVATE), ++ 0, NativeCookingHoldAction.Phase.START, 0, 1); ++ final var results = new ArrayList(); ++ try (var packets = mockStatic(PacketFactory.class)) { ++ f.state.handleHoldAction(start, results::add); ++ assertEquals(List.of(new NativeCookingHoldResult(start, false)), results); ++ assertEquals(source, f.station.getItem(0)); ++ assertEquals(9, f.inventory.getHudContainer().getItem(0).amount()); ++ assertFalse(requests.hasPending()); ++ packets.verifyNoInteractions(); ++ } ++ } ++ } ++ ++ @Test ++ void transportRetirementCancelsTheQueuedPickupBeforeTheReturnReply() { ++ final var f = heldFixture(); ++ final var requests = f.user.get(InventoryRequestTracker.class); ++ final var frame = f.state.begin(new ContainerUi.Title("container.furnace", true)).message(); ++ final var start = new NativeCookingHoldAction(NativeCookingControl.forMessage(frame, NativeCookingControl.Mode.ACTIVATE), ++ 0, NativeCookingHoldAction.Phase.START, 0, 1); ++ final var complete = new NativeCookingHoldAction(start.source(), 0, NativeCookingHoldAction.Phase.COMPLETE, 3, 2); ++ final var retire = new NativeCookingHoldAction(start.source(), 0, NativeCookingHoldAction.Phase.RETIRE, 0, 3); ++ final var results = new ArrayList(); ++ final var sent = new ArrayList(); ++ try (var world = mockStatic(WorldPackets.class); ++ var packets = mockStatic(PacketFactory.class)) { ++ packets.when(() -> PacketFactory.sendBedrockInventoryRequest(eq(f.user), any())).thenAnswer(call -> { ++ for (final var request : (ItemStackRequestInfo[]) call.getArgument(1)) { ++ requests.getRequest(request.requestId()).capturePrediction(f.inventory); ++ sent.add(request); ++ } ++ return null; ++ }); ++ f.state.handleHoldAction(start, results::add); ++ f.state.handleHoldAction(complete, results::add); ++ f.state.handleHoldAction(retire, results::add); ++ assertEquals(List.of(new NativeCookingHoldResult(start, true), new NativeCookingHoldResult(complete, false), ++ new NativeCookingHoldResult(retire, true)), results); ++ final var place = assertInstanceOf(ItemStackRequestAction.PlaceAction.class, sent.getFirst().actions().getFirst()); ++ requests.handleResponse(new ItemStackResponseInfo(ItemStackNetResult.Success, sent.getFirst().requestId(), List.of( ++ new ItemStackResponseContainerInfo(place.destination().container(), List.of( ++ new ItemStackResponseSlotInfo((byte) 0, (byte) 0, (byte) 9, 222, "", "", 0))), ++ new ItemStackResponseContainerInfo(place.source().container(), List.of( ++ new ItemStackResponseSlotInfo((byte) 0, (byte) 0, (byte) 0, 0, "", "", 0)))))); ++ f.state.update(); ++ assertEquals(1, sent.size()); ++ assertEquals(9, f.station.getItem(0).amount()); ++ assertTrue(f.inventory.getHudContainer().getItem(0).isEmpty()); ++ } ++ } ++ ++ @Test ++ void aHoldReleasedBeforeTheInitialPickupReplyPreservesAllThreeRequestOwners() { ++ final var f = heldFixture(); ++ final var requests = f.user.get(InventoryRequestTracker.class); ++ final var sent = new ArrayList(); ++ final var results = new ArrayList(); ++ requests.setPredictingClick(true); ++ f.station.setItem(0, f.inventory.getHudContainer().getItem(0).copy()); ++ f.inventory.getHudContainer().setItem(0, BedrockItem.empty()); ++ requests.setPredictingClick(false); ++ try (var world = mockStatic(WorldPackets.class); ++ var packets = mockStatic(PacketFactory.class)) { ++ packets.when(() -> PacketFactory.sendBedrockInventoryRequest(eq(f.user), any())).thenAnswer(call -> { ++ for (final var request : (ItemStackRequestInfo[]) call.getArgument(1)) { ++ requests.getRequest(request.requestId()).capturePrediction(f.inventory); ++ sent.add(request); ++ } ++ return null; ++ }); ++ assertTrue(f.station.handleClick(0, (short) 0, (byte) 0, ++ ContainerInput.PICKUP)); ++ final var frame = f.state.begin(new ContainerUi.Title("container.furnace", true)).message(); ++ final var start = new NativeCookingHoldAction(NativeCookingControl.forMessage(frame, NativeCookingControl.Mode.ACTIVATE), ++ 0, NativeCookingHoldAction.Phase.START, 0, 1); ++ final var complete = new NativeCookingHoldAction(start.source(), 0, NativeCookingHoldAction.Phase.COMPLETE, 3, 2); ++ f.state.handleHoldAction(start, results::add); ++ f.state.handleHoldAction(complete, results::add); ++ assertTrue(results.isEmpty()); ++ assertEquals(1, sent.size()); ++ final var pickup = assertInstanceOf(ItemStackRequestAction.TakeAction.class, sent.getFirst().actions().getFirst()); ++ requests.handleResponse(new ItemStackResponseInfo(ItemStackNetResult.Success, sent.getFirst().requestId(), List.of( ++ new ItemStackResponseContainerInfo(pickup.source().container(), List.of( ++ new ItemStackResponseSlotInfo((byte) 0, (byte) 0, (byte) 0, 0, "", "", 0))), ++ new ItemStackResponseContainerInfo(pickup.destination().container(), List.of( ++ new ItemStackResponseSlotInfo((byte) 0, (byte) 0, (byte) 9, 333, "", "", 0)))))); ++ f.state.update(); ++ assertEquals(List.of(new NativeCookingHoldResult(start, true)), results); ++ assertEquals(2, sent.size()); ++ final var returned = assertInstanceOf(ItemStackRequestAction.PlaceAction.class, sent.getLast().actions().getFirst()); ++ assertEquals(333, returned.source().stackNetworkId()); ++ requests.handleResponse(new ItemStackResponseInfo(ItemStackNetResult.Success, sent.getLast().requestId(), List.of( ++ new ItemStackResponseContainerInfo(returned.source().container(), List.of( ++ new ItemStackResponseSlotInfo((byte) 0, (byte) 0, (byte) 0, 0, "", "", 0))), ++ new ItemStackResponseContainerInfo(returned.destination().container(), List.of( ++ new ItemStackResponseSlotInfo((byte) 0, (byte) 0, (byte) 9, 444, "", "", 0)))))); ++ f.state.update(); ++ assertEquals(List.of(new NativeCookingHoldResult(start, true), new NativeCookingHoldResult(complete, true)), results); ++ assertEquals(3, sent.size()); ++ final var completion = assertInstanceOf(ItemStackRequestAction.TakeAction.class, sent.getLast().actions().getFirst()); ++ assertEquals(444, completion.source().stackNetworkId()); ++ assertEquals(3, completion.count()); ++ assertEquals(6, f.station.getItem(0).amount()); ++ assertEquals(3, f.inventory.getHudContainer().getItem(0).amount()); ++ } ++ } ++ ++ @Test ++ void rejectedCursorReturnCancelsTheQueuedPickupWithoutAnotherRequest() { ++ final var f = heldFixture(); ++ final var requests = f.user.get(InventoryRequestTracker.class); ++ final var frame = f.state.begin(new ContainerUi.Title("container.furnace", true)).message(); ++ final var start = new NativeCookingHoldAction(NativeCookingControl.forMessage(frame, NativeCookingControl.Mode.ACTIVATE), ++ 0, NativeCookingHoldAction.Phase.START, 0, 1); ++ final var complete = new NativeCookingHoldAction(start.source(), 0, NativeCookingHoldAction.Phase.COMPLETE, 3, 2); ++ final var results = new ArrayList(); ++ final var sent = new ArrayList(); ++ try (var world = mockStatic(WorldPackets.class); ++ var packets = mockStatic(PacketFactory.class); ++ var via = mockStatic(ViaBedrock.class)) { ++ final var platform = mock(ViaBedrockPlatform.class); ++ when(platform.getLogger()).thenReturn(Logger.getAnonymousLogger()); ++ via.when(ViaBedrock::getPlatform).thenReturn(platform); ++ packets.when(() -> PacketFactory.sendBedrockInventoryRequest(eq(f.user), any())).thenAnswer(call -> { ++ for (final var request : (ItemStackRequestInfo[]) call.getArgument(1)) { ++ requests.getRequest(request.requestId()).capturePrediction(f.inventory); ++ sent.add(request); ++ } ++ return null; ++ }); ++ f.state.handleHoldAction(start, results::add); ++ f.state.handleHoldAction(complete, results::add); ++ assertEquals(1, results.size()); ++ requests.handleResponse(new ItemStackResponseInfo(ItemStackNetResult.Error, sent.getFirst().requestId(), List.of())); ++ f.state.update(); ++ assertEquals(List.of(new NativeCookingHoldResult(start, true), new NativeCookingHoldResult(complete, false)), results); ++ assertEquals(1, sent.size()); ++ assertEquals(9, f.inventory.getHudContainer().getItem(0).amount()); ++ assertTrue(f.station.getItem(0).isEmpty()); ++ } ++ } ++ ++ @Test ++ void reopeningRetiresQueuedHeldPickupBeforeTheSameMenuIdIsReused() { ++ final var f = heldFixture(); ++ final var requests = f.user.get(InventoryRequestTracker.class); ++ final var frame = f.state.begin(new ContainerUi.Title("container.furnace", true)).message(); ++ final var start = new NativeCookingHoldAction(NativeCookingControl.forMessage(frame, NativeCookingControl.Mode.ACTIVATE), ++ 0, NativeCookingHoldAction.Phase.START, 0, 1); ++ final var complete = new NativeCookingHoldAction(start.source(), 0, NativeCookingHoldAction.Phase.COMPLETE, 3, 2); ++ final var results = new ArrayList(); ++ final var sent = new ArrayList(); ++ try (var world = mockStatic(WorldPackets.class); ++ var packets = mockStatic(PacketFactory.class)) { ++ packets.when(() -> PacketFactory.sendBedrockInventoryRequest(eq(f.user), any())).thenAnswer(call -> { ++ for (final var request : (ItemStackRequestInfo[]) call.getArgument(1)) { ++ requests.getRequest(request.requestId()).capturePrediction(f.inventory); ++ sent.add(request); ++ } ++ return null; ++ }); ++ f.state.handleHoldAction(start, results::add); ++ f.state.handleHoldAction(complete, results::add); ++ final var reopened = f.state.begin(new ContainerUi.Title("container.furnace", true)).message(); ++ assertEquals(List.of(new NativeCookingHoldResult(start, true), new NativeCookingHoldResult(complete, false)), results); ++ assertTrue(reopened.sequence() > frame.sequence()); ++ f.state.handleHoldAction(new NativeCookingHoldAction(start.source(), 0, NativeCookingHoldAction.Phase.COMPLETE, 3, 3), results::add); ++ assertFalse(results.getLast().accepted()); ++ assertEquals(1, sent.size()); ++ assertEquals(9, f.station.getItem(0).amount()); ++ assertTrue(f.inventory.getHudContainer().getItem(0).isEmpty()); ++ } ++ } ++ ++ private static Fixture heldFixture() { ++ final var f = fixture(CookingUi.Family.FURNACE); ++ prepareActions(f); ++ f.user.get(ChannelStorage.class).addChannels(List.of(NativeCookingMessage.CHANNEL, NativeCookingProgress.CHANNEL, ++ NativeCookingActionResult.CHANNEL, NativeCookingHoldResult.CHANNEL)); ++ final var requests = f.user.get(InventoryRequestTracker.class); ++ requests.setPredictingClick(true); ++ f.inventory.getInventoryContainer().setSelectedHotbarSlot(8); ++ final var cursor = new BedrockItem(2, (short) 0, (byte) 9); ++ cursor.setNetId(91); ++ f.inventory.getHudContainer().setItem(0, cursor); ++ requests.setPredictingClick(false); ++ f.station.markServerUpdate(); ++ return f; ++ } ++ ++ @Test ++ void heldQuantityWaitsForTheReturnedStackIdentityAndRejectionRestoresAllNineItems() { ++ for (final int slot : new int[]{0, 30}) { ++ final var f = fixture(CookingUi.Family.FURNACE); ++ prepareActions(f); ++ f.user.get(ChannelStorage.class).addChannels(List.of(NativeCookingMessage.CHANNEL, ++ NativeCookingProgress.CHANNEL, NativeCookingActionResult.CHANNEL, NativeCookingHoldResult.CHANNEL)); ++ final var requests = f.user.get(InventoryRequestTracker.class); ++ f.inventory.getInventoryContainer().setSelectedHotbarSlot(8); ++ requests.setPredictingClick(true); ++ f.inventory.getInventoryContainer().setItem(0, BedrockItem.empty()); ++ final var carried = new BedrockItem(2, (short) 0, (byte) 9); ++ carried.setNetId(91); ++ f.inventory.getHudContainer().setItem(0, carried); ++ requests.setPredictingClick(false); ++ f.station.markServerUpdate(); ++ final var frame = f.state.begin(new ContainerUi.Title("container.furnace", true)).message(); ++ final var start = new NativeCookingHoldAction(NativeCookingControl.forMessage(frame, NativeCookingControl.Mode.ACTIVATE), ++ slot, NativeCookingHoldAction.Phase.START, 0, 1); ++ final var finish = new NativeCookingHoldAction(start.source(), slot, NativeCookingHoldAction.Phase.COMPLETE, 3, 2); ++ final var results = new ArrayList(); ++ final var sent = new ArrayList(); ++ try (var world = mockStatic(WorldPackets.class); ++ var packets = mockStatic(PacketFactory.class); ++ var via = mockStatic(ViaBedrock.class)) { ++ final var platform = mock(ViaBedrockPlatform.class); ++ when(platform.getLogger()).thenReturn(Logger.getAnonymousLogger()); ++ via.when(ViaBedrock::getPlatform).thenReturn(platform); ++ packets.when(() -> PacketFactory.sendBedrockInventoryRequest(eq(f.user), any())).thenAnswer(call -> { ++ for (final var request : (ItemStackRequestInfo[]) call.getArgument(1)) { ++ requests.getRequest(request.requestId()).capturePrediction(f.inventory); ++ sent.add(request); ++ } ++ return null; ++ }); ++ f.state.handleHoldAction(start, results::add); ++ assertEquals(List.of(new NativeCookingHoldResult(start, true)), results); ++ assertEquals(1, sent.size()); ++ final var place = assertInstanceOf(ItemStackRequestAction.PlaceAction.class, sent.getFirst().actions().getFirst()); ++ assertEquals(9, place.count()); ++ assertTrue(f.inventory.getHudContainer().getItem(0).isEmpty()); ++ assertEquals(9, f.station.getItemInJavaSlot((short) slot).amount()); ++ f.state.handleHoldAction(finish, results::add); ++ assertEquals(1, results.size(), "Completion waits for the return response"); ++ assertEquals(1, sent.size()); ++ requests.handleResponse(new ItemStackResponseInfo(ItemStackNetResult.Success, sent.getFirst().requestId(), List.of( ++ new ItemStackResponseContainerInfo(place.destination().container(), List.of( ++ new ItemStackResponseSlotInfo((byte) place.destination().slot(), (byte) place.destination().slot(), ++ (byte) 9, 222, "", "", 0))), ++ new ItemStackResponseContainerInfo(place.source().container(), List.of( ++ new ItemStackResponseSlotInfo((byte) 0, (byte) 0, (byte) 0, 0, "", "", 0)))))); ++ f.state.update(); ++ assertEquals(List.of(new NativeCookingHoldResult(start, true), new NativeCookingHoldResult(finish, true)), results); ++ assertEquals(2, sent.size()); ++ final var take = assertInstanceOf(ItemStackRequestAction.TakeAction.class, sent.getLast().actions().getFirst()); ++ assertEquals(3, take.count()); ++ assertEquals(222, take.source().stackNetworkId()); ++ assertEquals(6, f.station.getItemInJavaSlot((short) slot).amount()); ++ assertEquals(3, f.inventory.getHudContainer().getItem(0).amount()); ++ requests.handleResponse(new ItemStackResponseInfo(ItemStackNetResult.Error, sent.getLast().requestId(), List.of())); ++ assertEquals(9, f.station.getItemInJavaSlot((short) slot).amount()); ++ assertTrue(f.inventory.getHudContainer().getItem(0).isEmpty()); ++ assertFalse(requests.hasPending()); ++ } ++ } ++ } ++ ++ @Test ++ void searchFiltersOnlyTheSearchTabAndRetainsRawTextWhenTheFurnaceReopens() { ++ final var f = fixture(CookingUi.Family.FURNACE); ++ prepareActions(f); ++ f.user.get(ChannelStorage.class).addChannels(List.of(NativeCookingMessage.CHANNEL, NativeCookingProgress.CHANNEL, ++ NativeCookingActionResult.CHANNEL, NativeCookingQueryResult.CHANNEL)); ++ when(f.items.itemPrimaryName(any())).thenAnswer(call -> switch (((BedrockItem) call.getArgument(0)).identifier()) { ++ case 4 -> "Cooked Food"; ++ case 5 -> "Smooth Stone"; ++ default -> "Iron Ingot"; ++ }); ++ when(f.items.itemSecondaryDescription(any())).thenReturn(""); ++ f.user.get(InventoryOptionsStorage.class).apply(new FurnaceOptions(1, 4, false, 2)); ++ f.station.markServerUpdate(); ++ final var first = f.state.begin(new ContainerUi.Title("container.furnace", true)).message(); ++ assertEquals(3, first.view().book().visibleRecipes().size()); ++ assertTrue(f.state.handleQuery(NativeCookingQuery.forMessage(first, 1, "stone"))); ++ final var publication = f.state.update(); ++ assertNotNull(publication, "An accepted edit must publish its search state"); ++ final var searched = publication.message(); ++ assertEquals("stone", searched.view().book().searchText()); ++ assertEquals(List.of("block"), searched.view().book().recipes().stream() ++ .filter(recipe -> searched.view().book().visibleRecipes().contains(recipe.id())).map(CookingUi.Recipe::outputName).toList()); ++ assertTrue(f.state.handleQuery(NativeCookingQuery.forMessage(searched, 2, "stone"))); ++ final var repeated = f.state.update().message(); ++ assertTrue(repeated.updateSequence() > searched.updateSequence()); ++ assertEquals("stone", repeated.view().book().searchText()); ++ assertEquals(1, searched.view().book().blockCount()); ++ assertEquals(1, searched.view().book().foodCount()); ++ f.user.get(InventoryOptionsStorage.class).apply(new FurnaceOptions(1, 1, false, 2)); ++ final var food = f.state.update().message(); ++ assertEquals("stone", food.view().book().searchText()); ++ assertEquals(List.of("output_food"), food.view().book().recipes().stream() ++ .filter(recipe -> food.view().book().visibleRecipes().contains(recipe.id())).map(CookingUi.Recipe::outputName).toList()); ++ final var reopened = f.state.begin(new ContainerUi.Title("container.furnace", true)).message(); ++ assertEquals("stone", reopened.view().book().searchText()); ++ assertTrue(reopened.sequence() > first.sequence()); ++ assertFalse(f.state.handleQuery(NativeCookingQuery.forMessage(food, 2, "old opening"))); ++ assertTrue(f.state.handleQuery(NativeCookingQuery.forMessage(reopened, 1, ""))); ++ final var cleared = f.state.update().message(); ++ assertEquals("", cleared.view().book().searchText()); ++ assertFalse(f.state.handleQuery(NativeCookingQuery.forMessage(reopened, 1, "replayed"))); ++ } ++ ++ @Test ++ void queryPacketsRetireOldRecipeAuthorityAndWithdrawalStopsFurtherEdits() throws Exception { ++ final var f = fixture(CookingUi.Family.FURNACE); ++ prepareActions(f); ++ when(f.items.itemPrimaryName(any())).thenReturn("Cooked Food"); ++ when(f.items.itemSecondaryDescription(any())).thenReturn(""); ++ f.user.get(ChannelStorage.class).addChannels(List.of(NativeCookingMessage.CHANNEL, NativeCookingProgress.CHANNEL, ++ NativeCookingActionResult.CHANNEL)); ++ f.user.get(InventoryOptionsStorage.class).apply(new FurnaceOptions(1, 4, false, 2)); ++ f.station.markServerUpdate(); ++ final var first = f.state.begin(new ContainerUi.Title("container.furnace", true)).message(); ++ final var outbound = new ArrayList(); ++ try (var world = mockStatic(WorldPackets.class); ++ var packets = mockStatic(PacketFactory.class); ++ var factory = mockStatic(PacketWrapper.class)) { ++ factory.when(() -> PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, f.user)).thenAnswer(_ -> { ++ final var packet = spy(new PacketWrapperImpl(ClientboundPackets26_3.CUSTOM_PAYLOAD, null, f.user)); ++ doNothing().when(packet).send(BedrockProtocol.class); ++ outbound.add(packet); ++ return packet; ++ }); ++ channels(f, "minecraft:register", NativeCookingQueryResult.CHANNEL); ++ assertTrue(outbound.stream().anyMatch(packet -> "minecraft:register".equals(packet.get(Types.STRING, 0)) ++ && java.util.Arrays.equals(NativeCookingQuery.CHANNEL.getBytes(StandardCharsets.UTF_8), packet.get(Types.REMAINING_BYTES, 0)))); ++ final var edit = NativeCookingQuery.forMessage(first, 1, "no match"); ++ queryPacket(f, edit); ++ assertEquals(new NativeCookingQueryResult(edit, true), outbound.getLast().get(NativeCookingQueryResult.TYPE, 0)); ++ final var searched = outbound.stream().filter(packet -> NativeCookingMessage.CHANNEL.equals(packet.get(Types.STRING, 0))) ++ .map(packet -> packet.get(NativeCookingMessage.TYPE, 0)).findFirst().orElseThrow(); ++ assertTrue(searched.view().book().visibleRecipes().isEmpty()); ++ assertFalse(f.state.handleAction(action(first, first.view().book().visibleRecipes().iterator().next(), 0, 2))); ++ queryPacket(f, edit); ++ assertFalse(outbound.getLast().get(NativeCookingQueryResult.TYPE, 0).accepted()); ++ queryPacket(f, NativeCookingQuery.forMessage(searched, 2, "")); ++ assertTrue(outbound.getLast().get(NativeCookingQueryResult.TYPE, 0).accepted()); ++ channels(f, "minecraft:unregister", NativeCookingQueryResult.CHANNEL); ++ assertEquals("minecraft:unregister", outbound.getLast().get(Types.STRING, 0)); ++ assertArrayEquals(NativeCookingQuery.CHANNEL.getBytes(StandardCharsets.UTF_8), outbound.getLast().get(Types.REMAINING_BYTES, 0)); ++ final int count = outbound.size(); ++ queryPacket(f, NativeCookingQuery.forMessage(searched, 3, "withdrawn")); ++ assertEquals(count, outbound.size()); ++ packets.verifyNoInteractions(); ++ } ++ } ++ ++ @Test ++ void furnaceAndSmokerQueriesRemainIndependentAcrossReplacementOpenings() { ++ final var f = fixture(CookingUi.Family.FURNACE); ++ prepareActions(f); ++ f.user.get(ChannelStorage.class).addChannels(List.of(NativeCookingMessage.CHANNEL, NativeCookingProgress.CHANNEL, ++ NativeCookingActionResult.CHANNEL, NativeCookingQueryResult.CHANNEL)); ++ f.station.markServerUpdate(); ++ final var first = f.state.begin(new ContainerUi.Title("container.furnace", true)).message(); ++ assertTrue(f.state.handleQuery(NativeCookingQuery.forMessage(first, 1, "stone"))); ++ final var smoker = new SmokerContainer(f.user, (byte) 5, null, null); ++ f.inventory.setCurrentContainerClosed(false); ++ f.inventory.setCurrentContainer(smoker); ++ smoker.markServerUpdate(); ++ final var second = f.state.begin(new ContainerUi.Title("container.smoker", true)).message(); ++ assertEquals("", second.view().book().searchText()); ++ assertFalse(f.state.handleQuery(NativeCookingQuery.forMessage(first, 2, "wrong family"))); ++ assertTrue(f.state.handleQuery(NativeCookingQuery.forMessage(second, 1, "beef"))); ++ f.inventory.setCurrentContainerClosed(false); ++ f.inventory.setCurrentContainer(f.station); ++ final var reopened = f.state.begin(new ContainerUi.Title("container.furnace", true)).message(); ++ assertEquals("stone", reopened.view().book().searchText()); ++ assertFalse(f.state.handleQuery(NativeCookingQuery.forMessage(second, 2, "wrong opening"))); ++ } ++ ++ private static void queryPacket(final Fixture fixture, final NativeCookingQuery query) throws Exception { ++ final var buffer = Unpooled.buffer(); ++ try { ++ Types.STRING.write(buffer, NativeCookingQuery.CHANNEL); ++ NativeCookingQuery.TYPE.write(buffer, query); ++ MultiStatePackets.CUSTOM_PAYLOAD_HANDLER.handle(new PacketWrapperImpl(ServerboundPackets26_3.CUSTOM_PAYLOAD, buffer, fixture.user)); ++ } finally { ++ buffer.release(); ++ } ++ } ++ ++ @Test + void receiverNegotiationAdvertisesTheServerboundChannelAndWithdrawalRetiresHeldInput() throws Exception { + final var f = fixture(CookingUi.Family.FURNACE); + prepareActions(f); diff --git a/patches/viabedrock/deferred/0004-expose-bedrock-recipes-and-place-ingredients.pr.md b/patches/viabedrock/deferred/0004-expose-bedrock-recipes-and-place-ingredients.pr.md index 70eff3e0..bfeb11b5 100644 --- a/patches/viabedrock/deferred/0004-expose-bedrock-recipes-and-place-ingredients.pr.md +++ b/patches/viabedrock/deferred/0004-expose-bedrock-recipes-and-place-ingredients.pr.md @@ -12,6 +12,11 @@ Resolve native ingredient descriptors and connection-owned item definitions befo Regression coverage includes `NativeFurnaceOptionsTest`, `CookingRecipeAvailabilityTest`, `CookingRecipeInputsTest`, `CookingRecipeSourcesTest`, `CraftingCursorTest`, and `InventoryTransferBatchTest`. +Registered zero-slot and all-empty recipe templates are excluded before availability reads source collections. +They preserve selected-output fallback and later valid candidates without accessing retired inventory sources. +The regression fails before the guard. Existing 1.26.51.1 executable availability fixtures retain their valid-recipe results. +This defensive Java admission does not establish new 1.26.60 live availability or screen parity. + Official Bedrock 1.26.60.5/BDS 1.26.60.6 captures establish manual output pickup and Shift-click behavior for planks, cake, and honey blocks. Manual requests keep CraftResults times one, select primary output before returned containers, and use Take for cursor output. Right-click crafts once. Shift-click fills inventory through linked stack-sized requests in one packet. @@ -35,3 +40,15 @@ Ghidra connects these fields to their named NBT keys, controller reads, and pack Four original-instruction controls reproduce the writes and retain surrounding bytes. Java filter edits preserve the native tabs and automatic layouts. Later packets retain their explicit raw values. + +Cooking search retains raw text per station family and filters only the Search tab through native item names. +Optional query messages share recipe request ordering and reject stale openings, snapshots, and withdrawn capabilities without transfers. +Core regressions cover filtered publication, repeated edits, category lists, clearing, reopening, family independence, and old recipe authority. +Official Bedrock 1.26.60.5 integrated-world desktop controls establish the observed search and retention behavior. +Other input modes, cross-connection query lifetime, and complete installed cooking screens remain unverified. + +Held quantity selection retains its source across the initial pickup, cursor return, and partial pickup. +Core waits for pending inventory responses and uses the confirmed stack IDs for each transfer. +Regressions cover delayed initial and return replies, rejection, capacity, reopening, and explicit retirement. +The full core build passes 2,173 tests with 177 skipped tests. +Client hold timing, previews, and installed direct/proxy controls remain unfinished. diff --git a/patches/viabedrock/deferred/0008-signal-sustained-bedrock-item-use.patch b/patches/viabedrock/deferred/0008-signal-sustained-bedrock-item-use.patch index 747337d6..d46bfe45 100644 --- a/patches/viabedrock/deferred/0008-signal-sustained-bedrock-item-use.patch +++ b/patches/viabedrock/deferred/0008-signal-sustained-bedrock-item-use.patch @@ -1,4 +1,4 @@ -From d78d819393f2664b05deb799a227deb54fd4d840 Mon Sep 17 00:00:00 2001 +From c1d3c1d91eb67f480671e8378aaf06a86e346dd6 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] fix(interaction): signal sustained Bedrock item use @@ -91,44 +91,66 @@ air payload and continue block mode. Already admitted air mode still sends retries after hunger becomes full. Registered packet regressions cover both payload presence and absent StartUsingItem without changing the canonical held count or offhand resynchronization. + +Honor the server inventory Use callback independently of packet 142. +Official 1.26.60.5/protocol 2225 routes ItemReleaseTransaction action Use +through 1429b5e70 to Actor::completeUsingItem, even during a new local +cycle. Selective omission of 142 retains that callback, while omission of +the transaction removes it. The local countdown still completes normally. + +Negotiate an empty viabedrock:item_use_stop notification for clients with +the native use lifecycle. Send it after authoritative inventory actions. +The receiver stops the active cycle without local consumption feedback. +Native HUD and passive controls preserve five hunger across one local +completion and a server callback with 31 ticks remaining when hunger +attribute packets are withheld. Local zero-counter feedback remains a +separate path before Actor::completeUsingItem. Do not duplicate it on +server Use, debit inventory again, or reinterpret packet 142 as this +callback. Clients without the channel keep their existing route. + +The registered packet regression covers negotiated and older clients, +Release versus Use, legacy rejection, authoritative counts, and update +ordering before the stop notification. --- .../model/ItemDefinitionProperties.java | 6 + .../protocol/model/ItemFoodProperties.java | 57 ++++++ .../protocol/model/ItemRegistry.java | 6 + - .../protocol/packet/InteractionPackets.java | 55 +++++- + .../protocol/packet/InteractionPackets.java | 68 ++++++- .../protocol/rewriter/ItemRewriter.java | 20 +- .../model/ItemFoodPropertiesTest.java | 122 ++++++++++++ - .../packet/CompletedItemUsePacketTest.java | 98 ++++++++++ - .../packet/FoodUseAdmissionPacketTest.java | 183 ++++++++++++++++++ + .../packet/CompletedItemUsePacketTest.java | 98 +++++++++ + .../packet/FoodUseAdmissionPacketTest.java | 186 ++++++++++++++++++ .../packet/InteractionPacketsTest.java | 31 +++ - 9 files changed, 574 insertions(+), 4 deletions(-) + .../packet/ServerItemUseTransactionTest.java | 124 ++++++++++++ + 10 files changed, 714 insertions(+), 4 deletions(-) create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/model/ItemFoodProperties.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/model/ItemFoodPropertiesTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/packet/CompletedItemUsePacketTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/packet/FoodUseAdmissionPacketTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/packet/InteractionPacketsTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/packet/ServerItemUseTransactionTest.java diff --git a/src/main/java/net/raphimc/viabedrock/protocol/model/ItemDefinitionProperties.java b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemDefinitionProperties.java -index 65becabc..5b4769aa 100644 +index 1a17ac5c..4c8c8a2d 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/model/ItemDefinitionProperties.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemDefinitionProperties.java -@@ -35,6 +35,7 @@ final class ItemDefinitionProperties { - private ItemStackingProperties stacking; +@@ -36,6 +36,7 @@ final class ItemDefinitionProperties { private ItemStorageProperties storage = ItemStorageProperties.NONE; + private ItemAirUseProperties airUse = ItemAirUseProperties.NONE; private ItemCookingProperties cooking; + private ItemFoodProperties food = ItemFoodProperties.NONE; private ItemBlockPlacer blockPlacer; private Integer wearableSlot; private Boolean offhandComponent; -@@ -67,6 +68,7 @@ final class ItemDefinitionProperties { - +@@ -72,6 +73,7 @@ final class ItemDefinitionProperties { void read(final CompoundTag components, final Function resolveBlock) { this.updates.add(components.copy()); + this.airUse = this.airUse.withComponentData(components, this.component()); + this.food = this.food.withComponentData(components, this.component()); this.stacking = this.stacking.withComponentData(components); this.storage = this.storage.withComponentData(components); this.cooking = this.component() ? this.cooking.withComponentItemData(components, this.identifier, resolveBlock) -@@ -163,6 +165,10 @@ final class ItemDefinitionProperties { +@@ -172,6 +174,10 @@ final class ItemDefinitionProperties { return this.blockPlacer; } @@ -203,10 +225,10 @@ index 00000000..23415b47 + +} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/model/ItemRegistry.java b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemRegistry.java -index a906b932..03ebaf51 100644 +index 5a941292..82ae5ede 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/model/ItemRegistry.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemRegistry.java -@@ -326,6 +326,12 @@ public final class ItemRegistry { +@@ -334,6 +334,12 @@ public final class ItemRegistry { definition.identifier(), item, creative, context, this::isEmpty); } @@ -220,10 +242,10 @@ index a906b932..03ebaf51 100644 return this.definitions.definitions().stream().filter(definition -> definition.receiver().component()) .map(NativeItemDefinitions.Definition::identifier).collect(Collectors.toUnmodifiableSet()); diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/InteractionPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/InteractionPackets.java -index d8e307df..b1ffa95c 100644 +index d8e307df..a1d9558a 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/InteractionPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/InteractionPackets.java -@@ -46,10 +46,14 @@ import net.raphimc.viabedrock.protocol.ServerboundBedrockPackets; +@@ -46,23 +46,37 @@ import net.raphimc.viabedrock.protocol.ServerboundBedrockPackets; import net.raphimc.viabedrock.protocol.data.enums.Direction; import net.raphimc.viabedrock.protocol.data.enums.bedrock.ComplexInventoryTransaction_Type; import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.*; @@ -238,7 +260,10 @@ index d8e307df..b1ffa95c 100644 import net.raphimc.viabedrock.protocol.model.Position3f; import net.raphimc.viabedrock.protocol.storage.BlockUseStorage; import net.raphimc.viabedrock.protocol.storage.BlockInteractionSoundTracker; -@@ -59,10 +63,17 @@ import net.raphimc.viabedrock.protocol.storage.InventoryTracker; + import net.raphimc.viabedrock.protocol.storage.ChunkTracker; ++import net.raphimc.viabedrock.protocol.storage.ChannelStorage; + import net.raphimc.viabedrock.protocol.storage.EntityTracker; + import net.raphimc.viabedrock.protocol.storage.InventoryTracker; import net.raphimc.viabedrock.protocol.types.BedrockTypes; import java.util.List; @@ -247,6 +272,8 @@ index d8e307df..b1ffa95c 100644 public final class InteractionPackets { ++ private static final String ITEM_USE_STOP_CHANNEL = "viabedrock:item_use_stop"; ++ + private static final Set CONTINUOUS_USE_ITEMS = Set.of( + "minecraft:bow", "minecraft:crossbow", "minecraft:trident", + "minecraft:potion", "minecraft:milk_bucket", "minecraft:ominous_bottle", @@ -256,7 +283,7 @@ index d8e307df..b1ffa95c 100644 private InteractionPackets() { } -@@ -257,7 +268,6 @@ public final class InteractionPackets { +@@ -257,7 +271,6 @@ public final class InteractionPackets { wrapper.read(Types.FLOAT); // yaw wrapper.read(Types.FLOAT); // pitch @@ -264,7 +291,7 @@ index d8e307df..b1ffa95c 100644 if (javaHand < 0 || javaHand >= InteractionHand.values().length) { wrapper.cancel(); return; -@@ -278,8 +288,15 @@ public final class InteractionPackets { +@@ -278,8 +291,15 @@ public final class InteractionPackets { openBook.send(BedrockProtocol.class); return; } @@ -281,7 +308,22 @@ index d8e307df..b1ffa95c 100644 if (transaction == null) { wrapper.cancel(); if (hand == InteractionHand.OFF_HAND && !"minecraft:shield".equals(itemIdentifier)) { -@@ -371,6 +388,24 @@ public final class InteractionPackets { +@@ -366,11 +386,39 @@ public final class InteractionPackets { + case NormalTransaction -> { + break; // Nothing to do here for now + } ++ case ItemReleaseTransaction -> { ++ final var release = (InventoryTransactionData.ReleaseItemTransactionData) inventoryTransaction.transactionData(); ++ if (release.actionType() == ItemReleaseActionType.Use ++ && wrapper.user().get(ChannelStorage.class).hasChannel(ITEM_USE_STOP_CHANNEL)) { ++ // Native applies the server callback to the current local use, without local food feedback. ++ final PacketWrapper stop = PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, wrapper.user()); ++ stop.write(Types.STRING, ITEM_USE_STOP_CHANNEL); ++ stop.send(BedrockProtocol.class); ++ } ++ } + default -> { + ViaBedrock.getPlatform().getLogger().log(Level.WARNING, "Received unsupported inventory transaction type: " + inventoryTransaction.transactionType()); } } }); @@ -306,7 +348,7 @@ index d8e307df..b1ffa95c 100644 protocol.registerClientbound(ClientboundBedrockPackets.SET_ENTITY_LINK, null, wrapper -> { wrapper.cancel(); final EntityTracker entityTracker = wrapper.user().get(EntityTracker.class); -@@ -380,6 +415,17 @@ public final class InteractionPackets { +@@ -380,6 +428,17 @@ public final class InteractionPackets { } @@ -324,7 +366,7 @@ index d8e307df..b1ffa95c 100644 static void sendPassengerUpdates(final PacketWrapper source, final EntityTracker entityTracker) { for (Entity vehicle : entityTracker.drainPassengerUpdates()) { final PacketWrapper passengersPacket = PacketWrapper.create(ClientboundPackets26_3.SET_PASSENGERS, -@@ -401,7 +447,7 @@ public final class InteractionPackets { +@@ -401,7 +460,7 @@ public final class InteractionPackets { static BedrockInventoryTransaction beginItemUse(final ClientPlayerEntity player, final InteractionHand hand, final int hotbarSlot, final BedrockItem item, @@ -333,7 +375,7 @@ index d8e307df..b1ffa95c 100644 if (item.isEmpty()) { return null; } -@@ -413,6 +459,9 @@ public final class InteractionPackets { +@@ -413,6 +472,9 @@ public final class InteractionPackets { return null; } player.setShieldBlockingHand(null); @@ -344,7 +386,7 @@ index d8e307df..b1ffa95c 100644 0, null, null, ComplexInventoryTransaction_Type.ItemUseTransaction, new InventoryTransactionData.UseItemTransactionData( 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 a6a4073d..28413a56 100644 +index 8f8aad75..0dbe13dc 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemRewriter.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemRewriter.java @@ -80,6 +80,7 @@ import net.raphimc.viabedrock.protocol.model.recipe.RecipeOutputKey; @@ -639,10 +681,10 @@ index 00000000..45f4a6a3 +} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/packet/FoodUseAdmissionPacketTest.java b/src/test/java/net/raphimc/viabedrock/protocol/packet/FoodUseAdmissionPacketTest.java new file mode 100644 -index 00000000..8ff80429 +index 00000000..4973971c --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/protocol/packet/FoodUseAdmissionPacketTest.java -@@ -0,0 +1,183 @@ +@@ -0,0 +1,186 @@ +/* + * This file is part of ViaBedrock - https://github.com/ViaVersionAddons/ViaBedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -675,6 +717,7 @@ index 00000000..8ff80429 +import net.raphimc.viabedrock.protocol.storage.EntityTracker; +import net.raphimc.viabedrock.protocol.storage.InventoryRequestTracker; +import net.raphimc.viabedrock.protocol.storage.InventoryTracker; ++import net.raphimc.viabedrock.protocol.storage.PlayerInputStorage; +import io.netty.buffer.Unpooled; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; @@ -707,6 +750,7 @@ index 00000000..8ff80429 + }); + final var inventory = new InventoryTracker(user); + stores.put(InventoryTracker.class, inventory); ++ stores.put(PlayerInputStorage.class, new PlayerInputStorage(user)); + final var held = new BedrockItem(261); + held.setAmount(8); + inventory.getInventoryContainer().setItem(0, held); @@ -762,6 +806,7 @@ index 00000000..8ff80429 + stores.put(InventoryRequestTracker.class, requests); + final var inventory = new InventoryTracker(user); + stores.put(InventoryTracker.class, inventory); ++ stores.put(PlayerInputStorage.class, new PlayerInputStorage(user)); + final var held = new BedrockItem(1); + held.setAmount(8); + inventory.getInventoryContainer().setItem(0, held); @@ -863,5 +908,135 @@ index 00000000..0bf1b705 + } + +} +diff --git a/src/test/java/net/raphimc/viabedrock/protocol/packet/ServerItemUseTransactionTest.java b/src/test/java/net/raphimc/viabedrock/protocol/packet/ServerItemUseTransactionTest.java +new file mode 100644 +index 00000000..0a1ce5e0 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/protocol/packet/ServerItemUseTransactionTest.java +@@ -0,0 +1,124 @@ ++/* ++ * 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.protocol.packet; ++ ++import com.viaversion.viaversion.api.connection.UserConnection; ++import com.viaversion.viaversion.api.protocol.packet.PacketWrapper; ++import com.viaversion.viaversion.api.protocol.packet.State; ++import com.viaversion.viaversion.api.type.Types; ++import com.viaversion.viaversion.protocol.packet.PacketWrapperImpl; ++import com.viaversion.viaversion.protocols.v26_2to26_3.packet.ClientboundPackets26_3; ++import io.netty.buffer.Unpooled; ++import net.raphimc.viabedrock.api.util.PacketFactory; ++import net.raphimc.viabedrock.protocol.BedrockProtocol; ++import net.raphimc.viabedrock.protocol.ClientboundBedrockPackets; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.ComplexInventoryTransaction_Type; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.HandSlot; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.InventorySourceFlags; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.InventorySourceType; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ItemReleaseActionType; ++import net.raphimc.viabedrock.protocol.model.BedrockItem; ++import net.raphimc.viabedrock.protocol.model.Position3f; ++import net.raphimc.viabedrock.protocol.model.inventory.BedrockInventoryTransaction; ++import net.raphimc.viabedrock.protocol.model.inventory.InventoryActionData; ++import net.raphimc.viabedrock.protocol.model.inventory.InventorySource; ++import net.raphimc.viabedrock.protocol.model.inventory.InventoryTransactionData; ++import net.raphimc.viabedrock.protocol.rewriter.InventoryTransactionRewriter; ++import net.raphimc.viabedrock.protocol.rewriter.ItemRewriter; ++import net.raphimc.viabedrock.protocol.storage.ChannelStorage; ++import net.raphimc.viabedrock.protocol.storage.InventoryRequestTracker; ++import net.raphimc.viabedrock.protocol.storage.InventoryTracker; ++import net.raphimc.viabedrock.protocol.types.item.NetworkItemStackDescriptorType; ++import net.raphimc.viabedrock.protocol.types.item.NetworkItemStackFixtures; ++import org.junit.jupiter.api.Test; ++ ++import java.lang.reflect.Proxy; ++import java.util.HashMap; ++import java.util.List; ++import java.util.Map; ++ ++import static org.junit.jupiter.api.Assertions.*; ++import static org.mockito.Mockito.*; ++ ++class ServerItemUseTransactionTest { ++ ++ @Test ++ void stopsNegotiatedUseAfterApplyingAuthorityWithoutRepeatingConsumption() throws Exception { ++ for (boolean negotiated : new boolean[]{false, true}) { ++ for (boolean legacy : new boolean[]{false, true}) { ++ for (ItemReleaseActionType action : ItemReleaseActionType.values()) { ++ final Map, Object> stores = new HashMap<>(); ++ final UserConnection user = (UserConnection) Proxy.newProxyInstance(UserConnection.class.getClassLoader(), ++ new Class[]{UserConnection.class}, (proxy, method, args) -> method.getName().equals("get") ? stores.get(args[0]) : null); ++ stores.put(InventoryRequestTracker.class, new InventoryRequestTracker(user) { ++ @Override ++ public boolean isPredictingClick() { ++ return true; ++ } ++ }); ++ final var inventory = new InventoryTracker(user); ++ stores.put(InventoryTracker.class, inventory); ++ final var channels = new ChannelStorage(); ++ if (negotiated) { ++ channels.addChannels(List.of("viabedrock:item_use_stop")); ++ } ++ stores.put(ChannelStorage.class, channels); ++ final var items = mock(ItemRewriter.class); ++ when(items.newItemType()).thenReturn(new NetworkItemStackDescriptorType(0, false, NetworkItemStackFixtures.CONTEXT)); ++ stores.put(ItemRewriter.class, items); ++ final var transactions = new InventoryTransactionRewriter(user); ++ stores.put(InventoryTransactionRewriter.class, transactions); ++ final var before = new BedrockItem(261, (short) 0, (byte) 8); ++ final var after = new BedrockItem(261, (short) 0, (byte) 7); ++ inventory.getInventoryContainer().setItem(0, before); ++ final var update = new InventoryActionData(new InventorySource(InventorySourceType.Container_Inventory, 0, ++ InventorySourceFlags.No_Flag), 0, before, after); ++ final var transaction = new BedrockInventoryTransaction(legacy ? -2 : 0, List.of(), List.of(update), ++ ComplexInventoryTransaction_Type.ItemReleaseTransaction, ++ new InventoryTransactionData.ReleaseItemTransactionData(action, 0, before, new Position3f(0, 0, 0), HandSlot.Mainhand)); ++ final var input = Unpooled.buffer(); ++ final PacketWrapper stop = mock(PacketWrapper.class); ++ try (var packets = mockStatic(PacketWrapper.class); ++ var contents = mockStatic(PacketFactory.class)) { ++ packets.when(() -> PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, user)).thenReturn(stop); ++ doAnswer(invocation -> { ++ assertEquals(7, inventory.getInventoryContainer().getSelectedHotbarItem().amount()); ++ return null; ++ }).when(stop).send(BedrockProtocol.class); ++ transactions.getInventoryTransactionType().write(input, transaction); ++ final PacketWrapper packet = new PacketWrapperImpl(ClientboundBedrockPackets.INVENTORY_TRANSACTION, input, user); ++ new TestProtocol().translate(packet); ++ assertTrue(packet.isCancelled()); ++ assertFalse(input.isReadable()); ++ assertEquals(legacy ? 8 : 7, inventory.getInventoryContainer().getSelectedHotbarItem().amount()); ++ if (negotiated && !legacy && action == ItemReleaseActionType.Use) { ++ verify(stop).write(Types.STRING, "viabedrock:item_use_stop"); ++ verify(stop).send(BedrockProtocol.class); ++ verifyNoMoreInteractions(stop); ++ } else { ++ verifyNoInteractions(stop); ++ } ++ } finally { ++ input.release(); ++ } ++ } ++ } ++ } ++ } ++ ++ private static final class TestProtocol extends BedrockProtocol { ++ private TestProtocol() { ++ InteractionPackets.register(this); ++ } ++ ++ private void translate(final PacketWrapper packet) throws Exception { ++ final var mapping = this.clientboundMappings.mappedPacket(State.PLAY, packet.getId()); ++ mapping.applyType(packet); ++ mapping.handler().handle(packet); ++ } ++ } ++ ++} -- 2.56.0 diff --git a/patches/viabedrock/deferred/0008-signal-sustained-bedrock-item-use.pr.md b/patches/viabedrock/deferred/0008-signal-sustained-bedrock-item-use.pr.md index c2ea90e7..a8efca58 100644 --- a/patches/viabedrock/deferred/0008-signal-sustained-bedrock-item-use.pr.md +++ b/patches/viabedrock/deferred/0008-signal-sustained-bedrock-item-use.pr.md @@ -17,9 +17,15 @@ Consume effects and canonical inventory counts remain server-owned. Admitted food sets `StartUsingItem` even without an optional food tag. `COMPLETED_USING_ITEM` supplies a signed item ID and a use method. -Matching held-item completions produce Java's `USE_ITEM_COMPLETE` event without changing canonical inventory counts. -Replaced items and methods outside zero through sixteen cannot complete the current use. -The patch body retains the versioned completion-handler evidence and current admission rationale. +Core forwards matching held-item notifications as Java's `USE_ITEM_COMPLETE` event without changing canonical inventory counts. +It rejects replaced items and methods outside zero through sixteen. +The integrated Bedrock client completes its local cycle independently and ignores that translated notification. +The [client lifecycle evidence](../../viafabricplus-bedrock/upstreamable/0025-send-completed-local-physics-to-viabedrock.pr.md) covers inventory refreshes, suppressed and late acknowledgments, release, and zero-duration food. +The patch body retains the versioned handler evidence and admission rationale. + +Native 1.26.60.5 consumes food without packet 142 and continues local use attempts while server authority is delayed. +Its packet handler resolves the item registry and emits an actor-use notification independently of the selected inventory slot. +These declared controls establish acknowledgment independence. They do not establish the cause of the original live report or ordinary transport loss. ## Native evidence @@ -46,7 +52,6 @@ Native controls establish the combined behavior for these fixtures; they do not Seventeen focused tests cover installed component retention, constructor-specific admission, Java component wire properties, difficulty changes, food-use flags, offhand resynchronization, and completion boundaries. Admission tests retain the held quantity and verify refused-use payloads with `StartUsingItem` clear. -The full four-project build passes 3,299 tests, with 325 skipped and no failures or errors. Installed direct and ViaProxy controls cover eleven cases each. Both routes match the selected native admission, hunger, timed consumption, and zero-duration outcomes. @@ -70,7 +75,7 @@ Other initial use families and live entity transitions still need native compari Presence of `StartUsingItem` is verified; exact packet phase counts and complete cycle timing remain open. Competing non-food use handlers, other consumable animations and effects, ability exceptions, and all inventory authority modes need further comparison. -Late completion for a new use of the same item also needs verification. +The selected one-second late acknowledgment control passes on both integrated Java routes. The [client input evidence](../../viafabricplus-bedrock/upstreamable/0038-predict-held-trapdoor-placement.pr.md) also covers short release/repress controls and the correction for a release missed between game ticks. Broader consumable timing still needs comparison. The original intermittent animation-without-feeding report remains open. This patch remains deferred behind [ViaBedrock #276](https://github.com/ViaVersionAddons/ViaBedrock/pull/276). @@ -80,3 +85,10 @@ The current timed-to-instant custom-food switch cancels the old definition inste Early switches consume none of the old food; late switches retain one completion. The long-hold control retains three completions and reaches full hunger. The client input sidecar records the remaining placement timing and dispatch gaps. + +Server inventory `Use` completion is separate from packet 142. +Native 1.26.60.5 applies that callback to a new active cycle, but skips the local countdown's hunger feedback. +Send a negotiated stop notification after authoritative inventory actions. Preserve the existing path for clients without the channel. +The packet regression covers negotiation, Release versus Use, legacy rejection, counts, and notification ordering. +Native attribute-omission controls retain five hunger and seven bread after one local completion and a server callback with 31 ticks remaining. +The client movement sidecar records installed verification and remaining timing limits. 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 dfac6441..f17fb68e 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 addf49fbfe303874cf52002221c1bb1ef8e8ea28 Mon Sep 17 00:00:00 2001 +From dded1ada772609f507af112cc507c29c439fbe99 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 @@ -536,61 +536,100 @@ at its float AABB center with data-1 and an empty selector. Protocol2225 LevelSoundEvent explicit x-enum-binary-value maps insertion446, removal447 and failed insertion533. Operation admission/mutation and final level dispatch remain supplied boundaries; native bank selection and installed audio are separate. + +Apply the same nonempty-ingredient admission to output quantity counting +as preview selection. Zero-slot and all-empty placeholders contribute no +output and cannot abort later valid candidates. A regression reproduces +the inventory count failure before this guard and retains later outputs. + +Selected bundle extraction uses a separately advertised protocol-2225 channel. +The original input codec remains version 1. The version-2 codec binds a +displayed cell to the same opening, publication revision and request sequence. +Resolve cells through the server-owned BundleTooltipLayout, including sparse +physical slots, leading padding and the hidden-items label. Reuse the existing +physical extraction planner and authoritative rejection recovery. + +Original Bedrock 1.26.60.5 navigation instructions establish padded cell +coordinates independently of compact Java bundle ordinals. A full grid can +contain display cells beyond physical slot 63. Stale snapshots and non-item +cells cannot authorize a transfer. + +Extend physical bundle inputs to cooking publications without projecting +them into player-inventory identities. The optional cooking channel retains +all six NativeCookingControl fields and the opening shared request sequence. +Capture exact station aliases, player/cursor sources, nested containers and +owner generations. Reject stale sources before prediction. + +Dynamic bundle changes refresh cooking publications even when the root +stack and its three container revisions stay unchanged. Use the existing +physical transfer and rollback owner for insertion and selected extraction. +Native 1.26.60.5 source and navigation controls remain the behavioral basis. + +Cooking bundle replies also state whether the original authority remains +current. A successful nonmutating plan needs no replacement publication. +Clients must distinguish that case from a transfer whose item snapshot +requires a newer update before another command. --- - .../api/model/NativeBundleAction.java | 76 ++ - .../api/model/NativeBundleResult.java | 72 ++ - .../model/NativePlayerInventoryAction.java | 94 +++ - .../model/NativePlayerInventoryMessage.java | 334 +++++++++ - .../model/NativePlayerInventoryResult.java | 47 ++ + .../api/model/NativeBundleAction.java | 107 +++ + .../api/model/NativeBundleResult.java | 76 ++ + .../api/model/NativeCookingBundleAction.java | 70 ++ + .../api/model/NativeCookingBundleResult.java | 52 ++ + .../model/NativePlayerInventoryAction.java | 94 ++ + .../model/NativePlayerInventoryMessage.java | 334 +++++++ + .../model/NativePlayerInventoryResult.java | 47 + .../api/model/container/Container.java | 4 +- - .../container/NativeBundleActionSession.java | 285 +++++++ - .../container/NativeBundleInputSession.java | 185 +++++ - .../model/container/NativeRecipeCounter.java | 119 +++ - .../model/container/NativeRecipeCrafting.java | 286 +++++++ - .../container/NativeRecipeDescriptor.java | 44 ++ + .../container/NativeBundleActionSession.java | 285 ++++++ + .../container/NativeBundleInputSession.java | 251 +++++ + .../model/container/NativeRecipeCounter.java | 117 +++ + .../model/container/NativeRecipeCrafting.java | 286 ++++++ + .../container/NativeRecipeDescriptor.java | 44 + .../model/container/NativeStoragePolicy.java | 103 +++ .../block/CraftingTableContainer.java | 2 +- .../container/dynamic/BundleContainer.java | 17 +- - .../resourcepack/ui/PlayerInventoryUi.java | 402 ++++++++++ + .../resourcepack/ui/PlayerInventoryUi.java | 271 ++++++ .../viabedrock/protocol/BedrockProtocol.java | 1 + - .../protocol/model/CreativeCatalog.java | 266 +++++++ - .../model/CreativeInventoryModel.java | 198 +++++ + .../protocol/model/CreativeCatalog.java | 266 ++++++ + .../model/CreativeInventoryModel.java | 198 ++++ .../model/ItemDefinitionProperties.java | 31 + .../protocol/model/ItemRegistry.java | 8 + - .../protocol/model/PlayerRecipeContext.java | 96 +++ - .../protocol/model/PlayerRecipeModel.java | 125 ++++ - .../protocol/packet/InventoryPackets.java | 213 ++++-- - .../protocol/packet/MultiStatePackets.java | 48 ++ + .../protocol/model/PlayerRecipeContext.java | 96 ++ + .../protocol/model/PlayerRecipeModel.java | 125 +++ + .../protocol/packet/InventoryPackets.java | 213 +++-- + .../protocol/packet/MultiStatePackets.java | 68 ++ .../protocol/rewriter/ItemRewriter.java | 79 +- - .../protocol/storage/CraftingDataTracker.java | 217 +++++- + .../protocol/storage/CraftingDataTracker.java | 217 ++++- .../storage/InventoryRequestTracker.java | 24 + .../protocol/storage/InventoryTracker.java | 21 + - .../storage/NativePlayerRecipeState.java | 704 ++++++++++++++++++ + .../storage/NativeCookingUiState.java | 58 +- + .../storage/NativePlayerRecipeState.java | 704 ++++++++++++++ .../storage/PlayerInventoryScreenTracker.java | 40 +- - .../api/model/NativeBundleActionTest.java | 61 ++ + .../api/model/NativeBundleActionTest.java | 87 ++ .../api/model/NativeBundleResultTest.java | 56 ++ - .../NativePlayerInventoryMessageTest.java | 197 +++++ - .../container/NativeRecipeCounterTest.java | 84 +++ + .../model/NativeCookingBundleActionTest.java | 38 + + .../NativePlayerInventoryMessageTest.java | 197 ++++ + .../container/NativeRecipeCounterTest.java | 84 ++ .../container/NativeRecipeDescriptorTest.java | 24 + - .../container/NativeRecipeQuantityTest.java | 61 ++ + .../container/NativeRecipeQuantityTest.java | 69 ++ .../ui/PlayerInventoryUiTest.java | 153 ++++ - .../protocol/model/CreativeCatalogTest.java | 382 ++++++++++ + .../protocol/model/CreativeCatalogTest.java | 382 ++++++++ .../model/NativeStoragePolicyTest.java | 113 +++ - .../protocol/packet/InventoryPacketsTest.java | 266 +++++-- - .../packet/NativeBundleResultPacketTest.java | 69 ++ - .../rewriter/PlayerRecipeModelTest.java | 704 ++++++++++++++++++ + .../protocol/packet/InventoryPacketsTest.java | 266 +++++- + .../packet/NativeBundleResultPacketTest.java | 107 +++ + .../rewriter/PlayerRecipeModelTest.java | 704 ++++++++++++++ ...egisteredPlayerRecipeAvailabilityTest.java | 5 +- .../RegisteredPlayerRecipeSearchTest.java | 3 +- .../storage/BundleInventoryActionTest.java | 61 ++ - .../storage/CreativeInventoryActionTest.java | 262 +++++++ - .../storage/InventoryRequestTrackerTest.java | 83 +++ - .../NativeBundleActionSessionTest.java | 630 ++++++++++++++++ - .../NativePlayerCreativeAuxiliaryTest.java | 333 +++++++++ - .../storage/NativePlayerCreativePickTest.java | 138 ++++ - .../storage/NativeRecipeCraftingTest.java | 500 +++++++++++++ - 51 files changed, 8154 insertions(+), 172 deletions(-) + .../storage/CreativeInventoryActionTest.java | 262 ++++++ + .../storage/InventoryRequestTrackerTest.java | 83 ++ + .../NativeBundleActionSessionTest.java | 865 ++++++++++++++++++ + .../NativePlayerCreativeAuxiliaryTest.java | 333 +++++++ + .../storage/NativePlayerCreativePickTest.java | 138 +++ + .../storage/NativeRecipeCraftingTest.java | 500 ++++++++++ + 55 files changed, 8666 insertions(+), 173 deletions(-) create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeBundleAction.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeBundleResult.java + create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeCookingBundleAction.java + create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeCookingBundleResult.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativePlayerInventoryAction.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativePlayerInventoryMessage.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativePlayerInventoryResult.java @@ -606,6 +645,7 @@ remain supplied boundaries; native bank selection and installed audio are separa create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/storage/NativePlayerRecipeState.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/NativeBundleActionTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/NativeBundleResultTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/NativeCookingBundleActionTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/NativePlayerInventoryMessageTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/container/NativeRecipeQuantityTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/PlayerInventoryUiTest.java @@ -621,10 +661,10 @@ remain supplied boundaries; native bank selection and installed audio are separa diff --git a/src/main/java/net/raphimc/viabedrock/api/model/NativeBundleAction.java b/src/main/java/net/raphimc/viabedrock/api/model/NativeBundleAction.java new file mode 100644 -index 00000000..746c4e44 +index 00000000..e3e2bb01 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/model/NativeBundleAction.java -@@ -0,0 +1,76 @@ +@@ -0,0 +1,107 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -639,58 +679,89 @@ index 00000000..746c4e44 + +/** Only a displayed source and input are client supplied; stacks and physical contents remain server owned. */ +public record NativeBundleAction(int menuId, long opening, long generation, long revision, long request, -+ String collection, int index, Kind kind, int tooltipMode) { ++ String collection, int index, Kind kind, int tooltipMode, int selectedCell) { + + public static final String CHANNEL = "viabedrock:native_bundle_input"; -+ public static final Type TYPE = new Type<>(NativeBundleAction.class) { -+ @Override -+ public NativeBundleAction read(final ByteBuf buffer) { -+ if (buffer.readableBytes() > 256 || buffer.readUnsignedByte() != 1 -+ || Types.VAR_INT.readPrimitive(buffer) != ProtocolConstants.BEDROCK_PROTOCOL_VERSION) { -+ throw new IllegalArgumentException("Unsupported native bundle input"); -+ } -+ final int menu = Types.VAR_INT.readPrimitive(buffer); -+ final long opening = Types.VAR_LONG.readPrimitive(buffer); -+ final long generation = Types.VAR_LONG.readPrimitive(buffer); -+ final long revision = Types.VAR_LONG.readPrimitive(buffer); -+ final long request = Types.VAR_LONG.readPrimitive(buffer); -+ final String collection = Types.STRING.read(buffer); -+ final int index = Types.VAR_INT.readPrimitive(buffer); -+ final int kind = buffer.readUnsignedByte(); -+ if (kind >= Kind.values().length) { -+ throw new IllegalArgumentException("Unknown native bundle input kind"); -+ } -+ final var action = new NativeBundleAction(menu, opening, generation, revision, request, collection, index, -+ Kind.values()[kind], buffer.readUnsignedByte()); -+ if (buffer.isReadable()) { -+ throw new IllegalArgumentException("Trailing native bundle input"); -+ } -+ return action; -+ } ++ public static final String SELECTION_CHANNEL = "viabedrock:native_bundle_selection"; ++ public static final Type TYPE = codec(false); ++ public static final Type SELECTION_TYPE = codec(true); + -+ @Override -+ public void write(final ByteBuf buffer, final NativeBundleAction action) { -+ buffer.writeByte(1); -+ Types.VAR_INT.writePrimitive(buffer, ProtocolConstants.BEDROCK_PROTOCOL_VERSION); -+ Types.VAR_INT.writePrimitive(buffer, action.menuId); -+ Types.VAR_LONG.writePrimitive(buffer, action.opening); -+ Types.VAR_LONG.writePrimitive(buffer, action.generation); -+ Types.VAR_LONG.writePrimitive(buffer, action.revision); -+ Types.VAR_LONG.writePrimitive(buffer, action.request); -+ Types.STRING.write(buffer, action.collection); -+ Types.VAR_INT.writePrimitive(buffer, action.index); -+ buffer.writeByte(action.kind.ordinal()).writeByte(action.tooltipMode); -+ } -+ }; ++ public NativeBundleAction(final int menuId, final long opening, final long generation, final long revision, final long request, ++ final String collection, final int index, final Kind kind, final int tooltipMode) { ++ this(menuId, opening, generation, revision, request, collection, index, kind, tooltipMode, -1); ++ } + + public NativeBundleAction { -+ if (menuId < 0 || menuId > 255 || opening < 0 || generation <= 0 || revision <= 0 || request <= 0 -+ || collection == null || !collection.matches("[a-z_]{1,64}") || index < 0 || index > 255 -+ || kind == null || tooltipMode != 0 && tooltipMode != 2) { ++ if (menuId < 0 || menuId > 255 || opening < 0 || generation <= 0 || revision <= 0 || request <= 0) { ++ throw new IllegalArgumentException("Invalid native bundle authority"); ++ } ++ validateInput(collection, index, kind, tooltipMode, selectedCell); ++ } ++ ++ static void validateInput(final String collection, final int index, final Kind kind, final int tooltipMode, final int selectedCell) { ++ if (collection == null || !collection.matches("[a-z_]{1,64}") || index < 0 || index > 255 ++ || kind == null || tooltipMode != 0 && tooltipMode != 2 || selectedCell < -1 || selectedCell > 255 ++ || selectedCell >= 0 && (kind != Kind.SECONDARY || tooltipMode != 2)) { + throw new IllegalArgumentException("Invalid native bundle input"); + } + } + ++ private static Type codec(final boolean selection) { ++ return new Type<>(NativeBundleAction.class) { ++ @Override ++ public NativeBundleAction read(final ByteBuf buffer) { ++ if (buffer.readableBytes() > 256 || buffer.readUnsignedByte() != (selection ? 2 : 1) ++ || Types.VAR_INT.readPrimitive(buffer) != ProtocolConstants.BEDROCK_PROTOCOL_VERSION) { ++ throw new IllegalArgumentException("Unsupported native bundle input"); ++ } ++ final int menu = Types.VAR_INT.readPrimitive(buffer); ++ final long opening = Types.VAR_LONG.readPrimitive(buffer); ++ final long generation = Types.VAR_LONG.readPrimitive(buffer); ++ final long revision = Types.VAR_LONG.readPrimitive(buffer); ++ final long request = Types.VAR_LONG.readPrimitive(buffer); ++ final String collection = Types.STRING.read(buffer); ++ final int index = Types.VAR_INT.readPrimitive(buffer); ++ final int kind = buffer.readUnsignedByte(); ++ if (kind >= Kind.values().length) { ++ throw new IllegalArgumentException("Unknown native bundle input kind"); ++ } ++ final var action = new NativeBundleAction(menu, opening, generation, revision, request, collection, index, ++ Kind.values()[kind], buffer.readUnsignedByte(), selection ? Types.VAR_INT.readPrimitive(buffer) : -1); ++ if (selection && action.selectedCell < 0) { ++ throw new IllegalArgumentException("Missing native bundle selection"); ++ } ++ if (buffer.isReadable()) { ++ throw new IllegalArgumentException("Trailing native bundle input"); ++ } ++ return action; ++ } ++ ++ @Override ++ public void write(final ByteBuf buffer, final NativeBundleAction action) { ++ if (selection != (action.selectedCell >= 0)) { ++ throw new IllegalArgumentException("Native bundle input requires its negotiated channel"); ++ } ++ buffer.writeByte(selection ? 2 : 1); ++ Types.VAR_INT.writePrimitive(buffer, ProtocolConstants.BEDROCK_PROTOCOL_VERSION); ++ Types.VAR_INT.writePrimitive(buffer, action.menuId); ++ Types.VAR_LONG.writePrimitive(buffer, action.opening); ++ Types.VAR_LONG.writePrimitive(buffer, action.generation); ++ Types.VAR_LONG.writePrimitive(buffer, action.revision); ++ Types.VAR_LONG.writePrimitive(buffer, action.request); ++ Types.STRING.write(buffer, action.collection); ++ Types.VAR_INT.writePrimitive(buffer, action.index); ++ buffer.writeByte(action.kind.ordinal()).writeByte(action.tooltipMode); ++ if (selection) { ++ Types.VAR_INT.writePrimitive(buffer, action.selectedCell); ++ } ++ } ++ }; ++ } ++ ++ public String channel() { ++ return this.selectedCell >= 0 ? SELECTION_CHANNEL : CHANNEL; ++ } ++ + public boolean belongsTo(final NativePlayerInventoryMessage message, final long lastRequest) { + return message != null && this.menuId == message.menuId() && this.opening == message.opening() + && this.generation == message.generation() && this.revision == message.revision() && this.request > lastRequest; @@ -703,10 +774,10 @@ index 00000000..746c4e44 +} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/NativeBundleResult.java b/src/main/java/net/raphimc/viabedrock/api/model/NativeBundleResult.java new file mode 100644 -index 00000000..c2489735 +index 00000000..a4089461 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/model/NativeBundleResult.java -@@ -0,0 +1,72 @@ +@@ -0,0 +1,76 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -758,12 +829,16 @@ index 00000000..c2489735 + + /** Null means no operation ran, including a stale publication or unavailable source. */ + public static NativeBundleResult reply(final NativeBundleAction action, final long revision, final Boolean successful) { -+ final Sound sound = successful == null ? Sound.NONE : action.kind() == NativeBundleAction.Kind.PRIMARY -+ ? successful ? Sound.INSERT : Sound.INSERT_FAIL : successful ? Sound.REMOVE_ONE : Sound.NONE; ++ final Sound sound = sound(action.kind(), successful); + return new NativeBundleResult(new NativePlayerInventoryResult(action.opening(), action.generation(), action.request(), revision, + Boolean.TRUE.equals(successful)), sound); + } + ++ public static Sound sound(final NativeBundleAction.Kind kind, final Boolean successful) { ++ return successful == null ? Sound.NONE : kind == NativeBundleAction.Kind.PRIMARY ++ ? successful ? Sound.INSERT : Sound.INSERT_FAIL : successful ? Sound.REMOVE_ONE : Sound.NONE; ++ } ++ + public enum Sound { + NONE(""), INSERT("bundle.insert"), INSERT_FAIL("bundle.insert_fail"), REMOVE_ONE("bundle.remove_one"); + @@ -779,6 +854,140 @@ index 00000000..c2489735 + } + +} +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/NativeCookingBundleAction.java b/src/main/java/net/raphimc/viabedrock/api/model/NativeCookingBundleAction.java +new file mode 100644 +index 00000000..6fb3337f +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/model/NativeCookingBundleAction.java +@@ -0,0 +1,70 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.model; ++ ++import com.viaversion.viaversion.api.type.Type; ++import com.viaversion.viaversion.api.type.Types; ++import io.netty.buffer.ByteBuf; ++import net.raphimc.viabedrock.protocol.data.ProtocolConstants; ++ ++/** Bundle commands retain the complete cooking publication identity and shared request order. */ ++public record NativeCookingBundleAction(NativeCookingControl source, String collection, int index, ++ NativeBundleAction.Kind kind, int tooltipMode, int selectedCell, long requestSequence) { ++ ++ public static final String CHANNEL = "viabedrock:native_cooking_bundle_v1"; ++ public static final int MAX_ENCODED_BYTES = 256; ++ public static final Type TYPE = new Type<>(NativeCookingBundleAction.class) { ++ @Override ++ public NativeCookingBundleAction read(final ByteBuf buffer) { ++ if (buffer.readableBytes() > MAX_ENCODED_BYTES || buffer.readUnsignedByte() != 1 ++ || Types.VAR_INT.readPrimitive(buffer) != ProtocolConstants.BEDROCK_PROTOCOL_VERSION) { ++ throw new IllegalArgumentException("Unsupported native cooking bundle input"); ++ } ++ final var source = new NativeCookingControl(Types.VAR_INT.readPrimitive(buffer), ++ Types.VAR_LONG.readPrimitive(buffer), Types.VAR_LONG.readPrimitive(buffer), ++ Types.VAR_LONG.readPrimitive(buffer), Types.VAR_LONG.readPrimitive(buffer), ++ Types.VAR_LONG.readPrimitive(buffer), NativeCookingControl.Mode.ACTIVATE); ++ final String collection = Types.STRING.read(buffer); ++ final int index = Types.VAR_INT.readPrimitive(buffer); ++ final int kind = buffer.readUnsignedByte(); ++ if (kind >= NativeBundleAction.Kind.values().length) { ++ throw new IllegalArgumentException("Unknown native cooking bundle input kind"); ++ } ++ final var action = new NativeCookingBundleAction(source, collection, index, NativeBundleAction.Kind.values()[kind], ++ buffer.readUnsignedByte(), Types.VAR_INT.readPrimitive(buffer), Types.VAR_LONG.readPrimitive(buffer)); ++ if (buffer.isReadable()) { ++ throw new IllegalArgumentException("Trailing native cooking bundle input"); ++ } ++ return action; ++ } ++ ++ @Override ++ public void write(final ByteBuf buffer, final NativeCookingBundleAction action) { ++ buffer.writeByte(1); ++ Types.VAR_INT.writePrimitive(buffer, ProtocolConstants.BEDROCK_PROTOCOL_VERSION); ++ final var source = action.source; ++ Types.VAR_INT.writePrimitive(buffer, source.menuId()); ++ Types.VAR_LONG.writePrimitive(buffer, source.sequence()); ++ Types.VAR_LONG.writePrimitive(buffer, source.updateSequence()); ++ Types.VAR_LONG.writePrimitive(buffer, source.menuRevision()); ++ Types.VAR_LONG.writePrimitive(buffer, source.inventoryRevision()); ++ Types.VAR_LONG.writePrimitive(buffer, source.cursorRevision()); ++ Types.STRING.write(buffer, action.collection); ++ Types.VAR_INT.writePrimitive(buffer, action.index); ++ buffer.writeByte(action.kind.ordinal()).writeByte(action.tooltipMode); ++ Types.VAR_INT.writePrimitive(buffer, action.selectedCell); ++ Types.VAR_LONG.writePrimitive(buffer, action.requestSequence); ++ } ++ }; ++ ++ public NativeCookingBundleAction { ++ if (source == null || source.mode() != NativeCookingControl.Mode.ACTIVATE || requestSequence <= 0) { ++ throw new IllegalArgumentException("Invalid native cooking bundle authority"); ++ } ++ NativeBundleAction.validateInput(collection, index, kind, tooltipMode, selectedCell); ++ } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/NativeCookingBundleResult.java b/src/main/java/net/raphimc/viabedrock/api/model/NativeCookingBundleResult.java +new file mode 100644 +index 00000000..0bbc2b28 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/model/NativeCookingBundleResult.java +@@ -0,0 +1,52 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.model; ++ ++import com.viaversion.viaversion.api.type.Type; ++import io.netty.buffer.ByteBuf; ++import java.util.Objects; ++ ++/** Replies retire only the exact cooking bundle command and its operation sound. */ ++public record NativeCookingBundleResult(NativeCookingBundleAction action, boolean accepted, boolean current, NativeBundleResult.Sound sound) { ++ ++ public static final String CHANNEL = "viabedrock:native_cooking_bundle_result_v1"; ++ public static final Type TYPE = new Type<>(NativeCookingBundleResult.class) { ++ @Override ++ public NativeCookingBundleResult read(final ByteBuf buffer) { ++ if (buffer.readableBytes() < 4 || buffer.readableBytes() > NativeCookingBundleAction.MAX_ENCODED_BYTES + 3) { ++ throw new IllegalArgumentException("Invalid native cooking bundle result length"); ++ } ++ final var action = NativeCookingBundleAction.TYPE.read(buffer.readSlice(buffer.readableBytes() - 3)); ++ final int accepted = buffer.readUnsignedByte(); ++ final int current = buffer.readUnsignedByte(); ++ final int sound = buffer.readUnsignedByte(); ++ if (accepted > 1 || current > 1 || sound >= NativeBundleResult.Sound.values().length) { ++ throw new IllegalArgumentException("Invalid native cooking bundle result flags"); ++ } ++ return new NativeCookingBundleResult(action, accepted == 1, current == 1, NativeBundleResult.Sound.values()[sound]); ++ } ++ ++ @Override ++ public void write(final ByteBuf buffer, final NativeCookingBundleResult result) { ++ NativeCookingBundleAction.TYPE.write(buffer, result.action); ++ buffer.writeBoolean(result.accepted).writeBoolean(result.current).writeByte(result.sound.ordinal()); ++ } ++ }; ++ ++ public NativeCookingBundleResult { ++ Objects.requireNonNull(action); ++ Objects.requireNonNull(sound); ++ if (accepted ? sound != NativeBundleResult.sound(action.kind(), true) ++ : sound != NativeBundleResult.Sound.NONE && sound != NativeBundleResult.sound(action.kind(), false)) { ++ throw new IllegalArgumentException("Native cooking bundle sound does not match its result"); ++ } ++ } ++ ++ public static NativeCookingBundleResult reply(final NativeCookingBundleAction action, final Boolean successful, final boolean current) { ++ return new NativeCookingBundleResult(action, Boolean.TRUE.equals(successful), current, NativeBundleResult.sound(action.kind(), successful)); ++ } ++ ++} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/NativePlayerInventoryAction.java b/src/main/java/net/raphimc/viabedrock/api/model/NativePlayerInventoryAction.java new file mode 100644 index 00000000..b3a09f75 @@ -1273,10 +1482,10 @@ index 00000000..e69bf410 + +} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/container/Container.java b/src/main/java/net/raphimc/viabedrock/api/model/container/Container.java -index 71856177..1f414860 100644 +index 1ba16ef3..d4fcbb44 100644 --- a/src/main/java/net/raphimc/viabedrock/api/model/container/Container.java +++ b/src/main/java/net/raphimc/viabedrock/api/model/container/Container.java -@@ -213,7 +213,7 @@ public abstract class Container { +@@ -308,7 +308,7 @@ public abstract class Container { if (cursorBundle != null && ((button == 0 && !item.isEmpty()) || (button == 1 && item.isEmpty()))) { clickContext.snapshot(cursorBundle); if (button == 0) { @@ -1285,7 +1494,7 @@ index 71856177..1f414860 100644 } final BedrockItem extracted = cursorBundle.selectedItem(false); return container.canPlaceItem(bedrockSlot, extracted, extracted.amount()) -@@ -223,7 +223,7 @@ public abstract class Container { +@@ -318,7 +318,7 @@ public abstract class Container { if (slotBundle != null) { if (button == 0 && !cursorItem.isEmpty()) { clickContext.snapshot(slotBundle); @@ -1587,10 +1796,10 @@ index 00000000..fd136657 +} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/container/NativeBundleInputSession.java b/src/main/java/net/raphimc/viabedrock/api/model/container/NativeBundleInputSession.java new file mode 100644 -index 00000000..d92a02a3 +index 00000000..2c54f3b3 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/model/container/NativeBundleInputSession.java -@@ -0,0 +1,185 @@ +@@ -0,0 +1,251 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -1600,6 +1809,12 @@ index 00000000..d92a02a3 + +import com.viaversion.viaversion.api.connection.UserConnection; +import net.raphimc.viabedrock.api.model.NativeBundleAction; ++import net.raphimc.viabedrock.api.model.NativeCookingBundleAction; ++import net.raphimc.viabedrock.api.model.NativeCookingControl; ++import net.raphimc.viabedrock.api.model.NativeCookingMessage; ++import net.raphimc.viabedrock.api.model.container.block.FurnaceContainer; ++import net.raphimc.viabedrock.api.model.NativeBundleTooltipData; ++import net.raphimc.viabedrock.api.resourcepack.ui.BundleTooltipLayout; +import net.raphimc.viabedrock.api.model.NativeItemStorageData; +import net.raphimc.viabedrock.api.model.NativePlayerInventoryMessage; +import net.raphimc.viabedrock.api.model.container.block.CraftingTableContainer; @@ -1627,21 +1842,32 @@ index 00000000..d92a02a3 + private final ItemRegistry definitions; + private final Container menu; + private final NativePlayerInventoryMessage publication; ++ private final NativeCookingControl cookingPublication; ++ private final long generation; + private final Map sources = new HashMap<>(); + private final Map snapshots = new LinkedHashMap<>(); + private final Map dynamic = new HashMap<>(); + -+ private NativeBundleInputSession(final UserConnection user, final NativePlayerInventoryMessage publication) { ++ private NativeBundleInputSession(final UserConnection user, final NativePlayerInventoryMessage publication, ++ final NativeCookingControl cookingPublication) { + this.user = user; + this.inventory = user.get(InventoryTracker.class); + this.definitions = user.get(ItemRewriter.class).getItemRegistry(); + this.menu = this.inventory.getCurrentContainer(); + this.publication = publication; ++ this.cookingPublication = cookingPublication; ++ this.generation = publication == null ? this.inventory.currentContainerGeneration() : publication.generation(); + final int playerStart = this.menu instanceof InventoryContainer ? 9 : this.menu.javaSize(); + this.add("inventory_items", playerStart, 27); + this.add("hotbar_items", playerStart + 27, 9); -+ this.add("container_items", 0, this.menu instanceof InventoryContainer ? 9 : this.menu.javaSize()); -+ this.add("crafting_input_items", 1, this.menu instanceof CraftingTableContainer ? 9 : 4); ++ if (this.menu instanceof FurnaceContainer) { ++ this.add("furnace_ingredient_items", 0, 1); ++ this.add("furnace_fuel_items", 1, 1); ++ this.add("furnace_output_items", 2, 1); ++ } else { ++ this.add("container_items", 0, this.menu instanceof InventoryContainer ? 9 : this.menu.javaSize()); ++ this.add("crafting_input_items", 1, this.menu instanceof CraftingTableContainer ? 9 : 4); ++ } + if (this.menu instanceof InventoryContainer) { + this.add("armor_items", 5, 4); + this.add("offhand_items", 45, 1); @@ -1692,7 +1918,24 @@ index 00000000..d92a02a3 + return null; + } + try { -+ return new NativeBundleInputSession(user, publication); ++ return new NativeBundleInputSession(user, publication, null); ++ } catch (final IllegalArgumentException unsupported) { ++ return null; ++ } ++ } ++ ++ public static NativeBundleInputSession capture(final UserConnection user, final NativeCookingMessage publication) { ++ final var inventory = user.get(InventoryTracker.class); ++ final var menu = inventory.getCurrentContainer(); ++ if (!(menu instanceof FurnaceContainer) || Byte.toUnsignedInt(menu.javaContainerId()) != publication.menuId() ++ || menu.serverRevision() != publication.menuRevision() ++ || inventory.getInventoryContainer().serverRevision() != publication.inventoryRevision() ++ || inventory.getHudContainer().serverRevision() != publication.cursorRevision() ++ || user.get(ItemRewriter.class).getItemRegistry() == null) { ++ return null; ++ } ++ try { ++ return new NativeBundleInputSession(user, null, NativeCookingControl.forMessage(publication, NativeCookingControl.Mode.ACTIVATE)); + } catch (final IllegalArgumentException unsupported) { + return null; + } @@ -1704,18 +1947,29 @@ index 00000000..d92a02a3 + && this.menu == this.inventory.getCurrentContainer() && this.inventory.getPendingCloseContainer() == null + && this.sources.get(new Source("cursor_items", 0)).owner == this.inventory.getHudContainer() + && this.sources.get(new Source("combined_hotbar_and_inventory_items", 0)).owner == this.inventory.getInventoryContainer() -+ && this.publication.generation() == this.inventory.currentContainerGeneration() ++ && this.generation == this.inventory.currentContainerGeneration() + && this.dynamic.entrySet().stream().allMatch(entry -> this.inventory.getDynamicContainer(entry.getKey()) == entry.getValue()) + && this.snapshots.entrySet().stream().allMatch(entry -> entry.getKey().serverRevision() == entry.getValue().serverRevision() + && Arrays.equals(entry.getKey().getItems(), entry.getValue().getItems())); + } + + public NativeBundleActionSession.Outcome execute(final NativeBundleAction action) { ++ return this.publication != null && action.belongsTo(this.publication, 0) ++ ? this.execute(action.collection(), action.index(), action.kind(), action.tooltipMode(), action.selectedCell()) : null; ++ } ++ ++ public NativeBundleActionSession.Outcome execute(final NativeCookingBundleAction action) { ++ return action.source().equals(this.cookingPublication) ++ ? this.execute(action.collection(), action.index(), action.kind(), action.tooltipMode(), action.selectedCell()) : null; ++ } ++ ++ private NativeBundleActionSession.Outcome execute(final String collection, final int index, final NativeBundleAction.Kind kind, ++ final int tooltipMode, final int selectedCell) { + final var requests = this.user.get(InventoryRequestTracker.class); -+ if (!action.belongsTo(this.publication, 0) || !this.current() || !requests.canStartRecipePlacement() || requests.isPredictingClick()) { ++ if (!this.current() || !requests.canStartRecipePlacement() || requests.isPredictingClick()) { + return null; + } -+ final var clicked = this.sources.get(new Source(action.collection(), action.index())); ++ final var clicked = this.sources.get(new Source(collection, index)); + if (clicked == null || clicked.owner == this.inventory.getHudContainer() && clicked.index == 0) { + return null; + } @@ -1726,7 +1980,7 @@ index 00000000..d92a02a3 + final boolean targetStorage = this.storage(target); + final Slot root; + final Slot external; -+ if (action.kind() == NativeBundleAction.Kind.PRIMARY) { ++ if (kind == NativeBundleAction.Kind.PRIMARY) { + if (heldStorage && !target.isEmpty()) { + root = cursor; + external = clicked; @@ -1739,15 +1993,36 @@ index 00000000..d92a02a3 + } else if (heldStorage && target.isEmpty()) { + root = cursor; + external = clicked; -+ } else if (held.isEmpty() && targetStorage && action.tooltipMode() == 2) { ++ } else if (held.isEmpty() && targetStorage && tooltipMode == 2) { + root = clicked; + external = cursor; + } else { + return null; + } + final var session = NativeBundleActionSession.create(this.user, root.owner, root.index); -+ return session == null ? null : action.kind() == NativeBundleAction.Kind.PRIMARY -+ ? session.insert(external.owner, external.index, null, 0) : session.extractLast(external.owner, external.index, 0); ++ if (session == null) { ++ return null; ++ } ++ if (kind == NativeBundleAction.Kind.PRIMARY) { ++ return session.insert(external.owner, external.index, null, 0); ++ } ++ if (selectedCell < 0) { ++ return session.extractLast(external.owner, external.index, 0); ++ } ++ final var item = this.snapshots.get(root.owner).getItem(root.index); ++ final var contents = NativeBundleTooltipData.resolve(item, this.definitions.storageProperties(item), identity -> { ++ final var owner = this.dynamic.get(new FullContainerName(ContainerEnumName.DynamicContainer, identity)); ++ final var snapshot = this.snapshots.get(owner); ++ return snapshot == null ? null : snapshot.getItems(); ++ }); ++ if (contents == null) { ++ return null; ++ } ++ final var cells = BundleTooltipLayout.resolve(contents).sourceSlots(); ++ if (selectedCell >= cells.size() || cells.get(selectedCell) < 0) { ++ return null; ++ } ++ return session.extract(external.owner, external.index, cells.get(selectedCell), 0); + } + + private boolean storage(final BedrockItem item) { @@ -1777,10 +2052,10 @@ index 00000000..d92a02a3 + +} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/container/NativeRecipeCounter.java b/src/main/java/net/raphimc/viabedrock/api/model/container/NativeRecipeCounter.java -index fe874172..96547dc6 100644 +index 06746721..63c3102c 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,121 @@ public final class NativeRecipeCounter { +@@ -158,6 +158,45 @@ public final class NativeRecipeCounter { return partial || selectedOutput.getAsBoolean() ? 2 : 3; } @@ -1796,17 +2071,11 @@ index fe874172..96547dc6 100644 + 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) { ++ final int ingredientCount = candidate.recipe.ingredientCount(); ++ if (!hasIngredient(candidate.recipe, ingredientCount)) { + continue; + } -+ final var round = count(candidate.recipe, sources, descriptorHash).rounds.getFirst(); ++ final var round = count(candidate.recipe, ingredientCount, sources, descriptorHash).rounds.getFirst(); + int minimum = Integer.MAX_VALUE; + int matched = 0; + for (int position = 0; position < round.size(); position++) { @@ -1829,6 +2098,13 @@ index fe874172..96547dc6 100644 + return bestCount > 0 ? counted : partial; + } + + private static boolean hasIngredient(final Recipe recipe, final int ingredientCount) { + for (int position = 0; position < ingredientCount; position++) { + if (!recipe.emptyIngredient(position)) { +@@ -167,6 +206,80 @@ public final class NativeRecipeCounter { + return false; + } + + /** Count outputs across candidates without spending the same source descriptor twice. */ + public static Quantity quantity(final List candidates, + final List sources, final ToIntFunction descriptorHash) { @@ -1849,7 +2125,11 @@ index fe874172..96547dc6 100644 + for (final Production production : NativeSort.sorted(candidates, + Comparator.comparingInt(value -> value.candidate.priority))) { + final Candidate candidate = production.candidate; -+ for (final List round : count(candidate.recipe, sources, descriptorHash).rounds) { ++ final int ingredientCount = candidate.recipe.ingredientCount(); ++ if (!hasIngredient(candidate.recipe, ingredientCount)) { ++ continue; ++ } ++ for (final List round : count(candidate.recipe, ingredientCount, sources, descriptorHash).rounds) { + int crafts = Integer.MAX_VALUE; + final Map positions = new HashMap<>(); + final Map consumption = new HashMap<>(); @@ -1902,7 +2182,7 @@ index fe874172..96547dc6 100644 /** Output lookup owns candidate admission, concrete grid requirements and priority. */ public record Candidate(int priority, boolean requiresCraftingTable, Recipe recipe) { public Candidate { -@@ -164,6 +279,10 @@ public final class NativeRecipeCounter { +@@ -181,6 +294,10 @@ public final class NativeRecipeCounter { boolean emptyIngredient(int index); boolean matchesIngredient(int index, BedrockItem item); @@ -2432,10 +2712,10 @@ index dff59408..b6e78f0b 100644 final Set visited = new HashSet<>(); diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/PlayerInventoryUi.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/PlayerInventoryUi.java new file mode 100644 -index 00000000..d3c9f229 +index 00000000..7c6e606b --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/PlayerInventoryUi.java -@@ -0,0 +1,402 @@ +@@ -0,0 +1,271 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -2474,39 +2754,15 @@ index 00000000..d3c9f229 + } + + public JsonObject exportProgram(final boolean workbench) { -+ final JsonObject result = this.program.export(Set.of(host(workbench), "how_to_play.how_to_play_screen", "encyclopedia.encyclopedia_screen"), new JsonObject()); ++ final JsonObject result = HelpUi.exportProgram(this.program, Set.of(host(workbench)), new JsonObject()); + if (!result.getAsJsonObject("definitions").has(host(workbench))) { + throw new IllegalArgumentException("Missing authored player inventory host"); + } + result.getAsJsonObject("translations").addProperty(BundleTooltipUi.EMPTY_DESCRIPTION, this.program.translate(BundleTooltipUi.EMPTY_DESCRIPTION)); + result.addProperty("player_workbench", workbench); -+ this.exportHelpTranslations(result.get("definitions"), result.getAsJsonObject("translations")); + return result; + } + -+ private void exportHelpTranslations(final JsonElement value, final JsonObject translations) { -+ if (value.isJsonArray()) { -+ for (final var child : value.getAsJsonArray()) { -+ this.exportHelpTranslations(child, translations); -+ } -+ } else if (value.isJsonObject()) { -+ for (final var entry : value.getAsJsonObject().entrySet()) { -+ final var child = entry.getValue(); -+ if (Set.of("$section_topic", "$category").contains(entry.getKey()) && child.isJsonPrimitive() && child.getAsJsonPrimitive().isString()) { -+ final String name = child.getAsString(); -+ if (!name.isEmpty() && !name.startsWith("$") && !name.startsWith("(")) { -+ final String key = (entry.getKey().equals("$section_topic") ? "howtoplay." : "howtoplay.category.") + name; -+ translations.addProperty(key, this.program.translate(key)); -+ if (entry.getKey().equals("$section_topic")) { -+ translations.addProperty(key + ".title", this.program.translate(key + ".title")); -+ } -+ } -+ } -+ this.exportHelpTranslations(child, translations); -+ } -+ } -+ } -+ + public static PlayerInventoryUi fromProgram(final JsonObject program) { + final JsonElement family = program.get("player_workbench"); + if (family == null || !family.isJsonPrimitive() || !family.getAsJsonPrimitive().isBoolean()) { @@ -2547,24 +2803,7 @@ index 00000000..d3c9f229 + + /** Resolve the actual guide topics without replacing the inventory's owner or source collections. */ + public JsonObject help(final boolean creative, final int selected) { -+ if (selected < 0) { -+ throw new IllegalArgumentException("Invalid native Help topic"); -+ } -+ final String host = creative ? "encyclopedia.encyclopedia_screen" : "how_to_play.how_to_play_screen"; -+ final var bindings = new HelpBindings(this.program, selected, ""); -+ this.program.resolve(host, bindings); -+ if (bindings.title == null) { -+ throw new IllegalArgumentException("Missing authored Help topic"); -+ } -+ final var resolved = this.program.resolve(host, new HelpBindings(this.program, selected, bindings.title)); -+ if (resolved.root() == null || !resolved.missingTemplates().isEmpty()) { -+ throw new IllegalArgumentException("Incomplete authored Help scene"); -+ } -+ final JsonObject scene = new JsonObject(); -+ scene.addProperty("format", 1); -+ scene.add("root", resolved.root()); -+ scene.add("missing_templates", new JsonArray()); -+ return scene; ++ return HelpUi.resolve(this.program, creative, selected); + } + + public record Cell(int id, int sourceId, int groupIndex, int modifier, int availability, int quantity, @@ -2623,96 +2862,6 @@ index 00000000..d3c9f229 + } + } + -+ private static final class HelpBindings implements UiBindings { -+ -+ private final JsonUiProgram program; -+ private final int selected; -+ private final Map globals; -+ private String title; -+ -+ private HelpBindings(final JsonUiProgram program, final int selected, final String title) { -+ this.program = program; -+ this.selected = selected; -+ this.globals = Map.of( -+ "#keyboard_being_used", new JsonPrimitive(true), "#gamepad_being_used", new JsonPrimitive(false), -+ "#touch_being_used", new JsonPrimitive(false), "#help_selected_index", new JsonPrimitive(selected), -+ "$toggle_binding_type", new JsonPrimitive("global"), -+ "$toggle_state_binding_name", new JsonPrimitive("($toggle_group_forced_index = #help_selected_index)"), -+ "#section_title", new JsonPrimitive(title)); -+ } -+ -+ @Override -+ public Map globals() { -+ return this.globals; -+ } -+ -+ @Override -+ public String defaultCollection() { -+ return ""; -+ } -+ -+ @Override -+ public boolean ownsCollection(final String name) { -+ return false; -+ } -+ -+ @Override -+ public Collection collection(final String name, final Scope parent) { -+ throw new IllegalArgumentException("Unsupported Help collection " + name); -+ } -+ -+ @Override -+ public Map variables(final Scope scope) { -+ return this.globals; -+ } -+ -+ @Override -+ public Map bindings(final Scope scope) { -+ return this.globals; -+ } -+ -+ @Override -+ public String factoryKey() { -+ return ""; -+ } -+ -+ @Override -+ public void decorate(final JsonObject node, final JsonObject control, final Map resolved, final Scope scope) { -+ if (JsonUiProgram.string(node, "type").equals("toggle") && node.has("toggle_group_forced_index")) { -+ final int index = node.get("toggle_group_forced_index").getAsInt(); -+ node.addProperty("help_topic", index); -+ if (index == this.selected && resolved.containsKey("$section_topic")) { -+ this.title = this.program.translate("howtoplay." + resolved.get("$section_topic").getAsString() + ".title"); -+ } -+ } -+ final String text = JsonUiProgram.string(node, "text"); -+ if (text.startsWith("#pre_process_")) { -+ final String translated = this.program.translate(text.substring("#pre_process_".length())); -+ node.addProperty("text", translated); -+ node.add("text_component", this.program.textComponent(translated)); -+ node.addProperty("localize", false); -+ } -+ if (node.has("button_mappings")) { -+ for (final var value : node.getAsJsonArray("button_mappings")) { -+ final var mapping = value.getAsJsonObject(); -+ for (final String field : List.of("from_button_id", "to_button_id", "mapping_type")) { -+ if (mapping.has(field)) { -+ mapping.addProperty(field, UiExpression.text(JsonUiProgram.resolveValue(mapping.get(field), resolved, 0))); -+ } -+ } -+ if (mapping.has("ignored")) { -+ mapping.addProperty("ignored", UiExpression.truth(JsonUiProgram.resolveValue(mapping.get("ignored"), resolved, 0))); -+ } -+ if (JsonUiProgram.string(node, "type").equals("button") && JsonUiProgram.string(mapping, "to_button_id").equals("button.menu_exit")) { -+ node.addProperty("help_close", true); -+ node.addProperty("action", 0); -+ } -+ } -+ } -+ } -+ -+ } -+ + private static final class Controller extends ContainerUi.Controller { + private static final Set RECIPE_ACTIONS = Set.of("button.recipe_select", "button.recipe_secondary", "button.recipe_tertiary"); + private final State state; @@ -3327,7 +3476,7 @@ index 00000000..e0ba0220 + +} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/model/ItemDefinitionProperties.java b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemDefinitionProperties.java -index 5b4769aa..46d6e088 100644 +index 4c8c8a2d..5e8c2d82 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/model/ItemDefinitionProperties.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemDefinitionProperties.java @@ -11,12 +11,15 @@ import com.viaversion.nbt.tag.IntTag; @@ -3346,10 +3495,10 @@ index 5b4769aa..46d6e088 100644 import java.util.function.Function; /** Compiled state belongs to a constructed definition, independently of its network declarations. */ -@@ -34,6 +37,10 @@ final class ItemDefinitionProperties { - private final List updates = new ArrayList<>(); +@@ -35,6 +38,10 @@ final class ItemDefinitionProperties { private ItemStackingProperties stacking; private ItemStorageProperties storage = ItemStorageProperties.NONE; + private ItemAirUseProperties airUse = ItemAirUseProperties.NONE; + private ItemStoragePolicy storagePolicySource; + private NativeStoragePolicy storagePolicy; + private Integer storagePolicyVersion; @@ -3357,7 +3506,7 @@ index 5b4769aa..46d6e088 100644 private ItemCookingProperties cooking; private ItemFoodProperties food = ItemFoodProperties.NONE; private ItemBlockPlacer blockPlacer; -@@ -203,6 +210,30 @@ final class ItemDefinitionProperties { +@@ -212,6 +219,30 @@ final class ItemDefinitionProperties { return this.storage; } @@ -3389,7 +3538,7 @@ index 5b4769aa..46d6e088 100644 this.category = category & 0xFF; } diff --git a/src/main/java/net/raphimc/viabedrock/protocol/model/ItemRegistry.java b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemRegistry.java -index 03ebaf51..3562cb65 100644 +index 82ae5ede..96a30b6e 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/model/ItemRegistry.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemRegistry.java @@ -7,6 +7,8 @@ package net.raphimc.viabedrock.protocol.model; @@ -3401,7 +3550,7 @@ index 03ebaf51..3562cb65 100644 import net.raphimc.viabedrock.protocol.model.recipe.ItemRecipeKey; import net.raphimc.viabedrock.protocol.model.recipe.ItemRecipeDescriptor; import net.raphimc.viabedrock.api.model.NativeItemNames; -@@ -176,6 +178,12 @@ public final class ItemRegistry { +@@ -184,6 +186,12 @@ public final class ItemRegistry { return definition != null ? ((ItemDefinitionProperties) definition.receiver()).storage() : ItemStorageProperties.NONE; } @@ -3648,10 +3797,10 @@ index 00000000..5b3cdb3b + +} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/InventoryPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/InventoryPackets.java -index 15f46759..9125de3a 100644 +index 7d61b8f3..39fef1ff 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/InventoryPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/InventoryPackets.java -@@ -22,6 +22,7 @@ import net.raphimc.viabedrock.api.resourcepack.ui.ServerFormUi; +@@ -24,6 +24,7 @@ import net.raphimc.viabedrock.api.resourcepack.ui.ServerFormUi; import net.raphimc.viabedrock.api.model.NativeFormMessage; import net.raphimc.viabedrock.api.model.NativeContainerMessage; import net.raphimc.viabedrock.api.model.NativeContainerData; @@ -3659,7 +3808,7 @@ index 15f46759..9125de3a 100644 import net.raphimc.viabedrock.protocol.data.generated.bedrock.CustomBlockTags; import net.raphimc.viabedrock.api.resourcepack.ui.ContainerUi; -@@ -92,14 +93,20 @@ import net.raphimc.viabedrock.protocol.data.enums.bedrock.ComplexInventoryTransa +@@ -95,14 +96,20 @@ import net.raphimc.viabedrock.protocol.data.enums.bedrock.ComplexInventoryTransa import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.*; import net.raphimc.viabedrock.protocol.data.enums.java.generated.ContainerInput; import net.raphimc.viabedrock.protocol.data.enums.java.generated.EquipmentSlot; @@ -3680,7 +3829,7 @@ index 15f46759..9125de3a 100644 import net.raphimc.viabedrock.protocol.model.recipe.EnchantData; import net.raphimc.viabedrock.protocol.rewriter.BlockStateRewriter; import net.raphimc.viabedrock.protocol.rewriter.ItemRewriter; -@@ -115,6 +122,7 @@ import net.raphimc.viabedrock.protocol.types.recipe.CraftingDataReader; +@@ -118,6 +125,7 @@ import net.raphimc.viabedrock.protocol.types.recipe.CraftingDataReader; import java.util.ArrayList; import java.util.List; @@ -3688,7 +3837,7 @@ index 15f46759..9125de3a 100644 import java.util.logging.Level; public final class InventoryPackets { -@@ -363,22 +371,33 @@ public final class InventoryPackets { +@@ -396,22 +404,33 @@ public final class InventoryPackets { wrapper.cancel(); final ItemRewriter itemRewriter = wrapper.user().get(ItemRewriter.class); final int groups = wrapper.read(BedrockTypes.UNSIGNED_VAR_INT); @@ -3729,7 +3878,7 @@ index 15f46759..9125de3a 100644 }); protocol.registerClientbound( ClientboundBedrockPackets.CONTAINER_OPEN, -@@ -1153,41 +1172,7 @@ public final class InventoryPackets { +@@ -1189,41 +1208,7 @@ public final class InventoryPackets { protocol.registerServerbound( ServerboundPackets26_3.SET_CREATIVE_MODE_SLOT, null, @@ -3772,7 +3921,7 @@ index 15f46759..9125de3a 100644 protocol.registerServerbound( ServerboundPackets26_3.CUSTOM_CLICK_ACTION, ServerboundBedrockPackets.MODAL_FORM_RESPONSE, -@@ -1299,7 +1284,7 @@ public final class InventoryPackets { +@@ -1335,7 +1320,7 @@ public final class InventoryPackets { if (container == null) { if (containerId == ContainerID.CONTAINER_ID_INVENTORY.getValue()) { wrapper.user().get(InventoryRequestTracker.class) @@ -3781,7 +3930,7 @@ index 15f46759..9125de3a 100644 } wrapper.cancel(); return; -@@ -1872,31 +1857,129 @@ public final class InventoryPackets { +@@ -1900,31 +1885,129 @@ public final class InventoryPackets { return "container." + name; } @@ -3934,30 +4083,50 @@ index 15f46759..9125de3a 100644 public static boolean handleLegacyOffhandClick(final UserConnection user, final Container container, diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java -index 885579ea..edc50011 100644 +index 2d9b81e0..f6529be4 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java -@@ -17,6 +17,11 @@ +@@ -17,6 +17,13 @@ */ package net.raphimc.viabedrock.protocol.packet; +import net.raphimc.viabedrock.api.model.NativePlayerInventoryAction; +import net.raphimc.viabedrock.api.model.NativeBundleAction; ++import net.raphimc.viabedrock.api.model.NativeCookingBundleAction; ++import net.raphimc.viabedrock.api.model.NativeCookingBundleResult; +import net.raphimc.viabedrock.api.model.NativeBundleResult; +import net.raphimc.viabedrock.api.model.NativePlayerInventoryResult; +import net.raphimc.viabedrock.protocol.storage.NativePlayerRecipeState; import net.raphimc.viabedrock.api.model.PlayerInventoryScreenRequest; import net.raphimc.viabedrock.protocol.storage.PlayerInventoryScreenTracker; import net.raphimc.viabedrock.protocol.storage.GameSessionStorage; -@@ -215,6 +220,49 @@ public final class MultiStatePackets { +@@ -258,6 +265,67 @@ public final class MultiStatePackets { } return; } -+ if (channel.equals(NativeBundleAction.CHANNEL)) { ++ if (channel.equals(NativeCookingBundleAction.CHANNEL)) { ++ if (NativeCookingUiState.bundlesEnabled(wrapper.user())) { ++ try { ++ final var action = wrapper.read(NativeCookingBundleAction.TYPE); ++ final var state = wrapper.user().get(NativeCookingUiState.class); ++ final var outcome = state.handleBundleAction(action); ++ NativeCookingUiState.sendUpdate(wrapper.user()); ++ final var result = PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, wrapper.user()); ++ result.write(Types.STRING, NativeCookingBundleResult.CHANNEL); ++ result.write(NativeCookingBundleResult.TYPE, ++ NativeCookingBundleResult.reply(action, outcome == null ? null : outcome.successful(), state.current(action.source()))); ++ result.send(BedrockProtocol.class); ++ } catch (final IllegalArgumentException | IndexOutOfBoundsException malformed) { ++ // Bundle inputs cannot replace the cooking publication's source authority. ++ } ++ } ++ return; ++ } ++ if (channel.equals(NativeBundleAction.CHANNEL) || channel.equals(NativeBundleAction.SELECTION_CHANNEL)) { + final var state = wrapper.user().get(NativePlayerRecipeState.class); + if (state != null && state.actionsEnabled()) { + try { -+ final var action = wrapper.read(NativeBundleAction.TYPE); ++ final var action = wrapper.read(channel.equals(NativeBundleAction.SELECTION_CHANNEL) ? NativeBundleAction.SELECTION_TYPE : NativeBundleAction.TYPE); + final var outcome = state.handleBundleAction(action); + state.tick(); + final var message = state.publication(); @@ -3996,11 +4165,11 @@ index 885579ea..edc50011 100644 + } + return; + } - if (channel.equals(NativeCookingAction.CHANNEL)) { - if (NativeCookingUiState.actionsEnabled(wrapper.user())) { - try { + if (channel.equals(NativeCookingHoldAction.CHANNEL)) { + if (wrapper.user().getProtocolInfo().getClientState() == State.PLAY + && NativeCookingUiState.holdsEnabled(wrapper.user())) { 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 28413a56..be6a1b36 100644 +index 0dbe13dc..9c4e13e8 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemRewriter.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemRewriter.java @@ -51,9 +51,9 @@ import com.viaversion.nbt.tag.ByteArrayTag; @@ -4054,7 +4223,7 @@ index 28413a56..be6a1b36 100644 static { // TODO: Add missing item nbt rewriters -@@ -591,29 +597,31 @@ public class ItemRewriter extends StoredObject implements NetworkItemStackContex +@@ -592,29 +598,31 @@ public class ItemRewriter extends StoredObject implements NetworkItemStackContex "Translating Java items to Bedrock is not yet supported"); } @@ -4105,7 +4274,7 @@ index 28413a56..be6a1b36 100644 } public BedrockItem[] bedrockItems(final Item[] javaItems) { -@@ -664,6 +672,18 @@ public class ItemRewriter extends StoredObject implements NetworkItemStackContex +@@ -665,6 +673,18 @@ public class ItemRewriter extends StoredObject implements NetworkItemStackContex (selected, auxiliary) -> this.associatedBlock(blocks, selected, auxiliary)); } @@ -4124,7 +4293,7 @@ index 28413a56..be6a1b36 100644 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, -@@ -714,11 +734,18 @@ public class ItemRewriter extends StoredObject implements NetworkItemStackContex +@@ -715,11 +735,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) { @@ -4145,7 +4314,7 @@ index 28413a56..be6a1b36 100644 item -> { final ItemRecipeKey key = this.recipeKey(item); return key != null ? key.descriptorHash() : 0; -@@ -826,6 +853,10 @@ public class ItemRewriter extends StoredObject implements NetworkItemStackContex +@@ -827,6 +854,10 @@ public class ItemRewriter extends StoredObject implements NetworkItemStackContex return this.registry; } @@ -4474,7 +4643,7 @@ index 1b68e72e..22d95e55 100644 + } 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 cf41f205..658cdfff 100644 +index e5caf756..9d060644 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryRequestTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryRequestTracker.java @@ -20,6 +20,7 @@ package net.raphimc.viabedrock.protocol.storage; @@ -4485,7 +4654,7 @@ index cf41f205..658cdfff 100644 import com.viaversion.viaversion.api.protocol.packet.PacketWrapper; import com.viaversion.viaversion.api.type.Types; import com.viaversion.nbt.tag.IntTag; -@@ -412,6 +413,10 @@ public class InventoryRequestTracker extends StoredObject { +@@ -471,6 +472,10 @@ public class InventoryRequestTracker extends StoredObject { return this.queueInteraction(new QueuedButtonClick(container, button)); } @@ -4494,9 +4663,9 @@ index cf41f205..658cdfff 100644 + } + boolean queueInteraction(final QueuedInteraction interaction) { - if (this.recovery.isActive() || this.queuedInteractions.size() >= MAX_QUEUED_INTERACTIONS) { - this.queuedInteractions.clear(); -@@ -1003,6 +1008,25 @@ public class InventoryRequestTracker extends StoredObject { + if (this.deferredDragClose != null && interaction.isScreenInteraction()) { + return false; +@@ -1075,6 +1080,25 @@ public class InventoryRequestTracker extends StoredObject { boolean apply(); } @@ -4568,9 +4737,127 @@ index 04ad1756..4a6f0c14 100644 } private void updateInventoryOptions() { +diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/NativeCookingUiState.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/NativeCookingUiState.java +index f760707e..bc0ed77f 100644 +--- a/src/main/java/net/raphimc/viabedrock/protocol/storage/NativeCookingUiState.java ++++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/NativeCookingUiState.java +@@ -19,6 +19,11 @@ import net.raphimc.viabedrock.api.model.NativeCookingSourceAction; + import net.raphimc.viabedrock.api.model.NativeCookingSourceResult; + import net.raphimc.viabedrock.api.model.container.Container; + import net.raphimc.viabedrock.protocol.data.enums.java.generated.ContainerInput; ++import net.raphimc.viabedrock.api.model.NativeCookingBundleAction; ++import net.raphimc.viabedrock.api.model.NativeCookingBundleResult; ++import net.raphimc.viabedrock.api.model.container.NativeBundleInputSession; ++import net.raphimc.viabedrock.api.model.container.NativeBundleActionSession; ++import net.raphimc.viabedrock.api.util.PacketFactory; + import net.raphimc.viabedrock.api.model.NativeCookingQuery; + import net.raphimc.viabedrock.api.model.NativeCookingQueryResult; + import net.raphimc.viabedrock.api.model.NativeCookingAction; +@@ -87,6 +92,8 @@ public final class NativeCookingUiState extends StoredObject { + private boolean holdsAdvertised; + private boolean sourcesAdvertised; + private NativeCookingSourceAction sourceDrawing; ++ private boolean bundlesAdvertised; ++ private NativeBundleInputSession bundleInputs; + private final Map queries = new EnumMap<>(CookingUi.Family.class); + + public NativeCookingUiState(final UserConnection user) { +@@ -137,6 +144,26 @@ public final class NativeCookingUiState extends StoredObject { + this.sourcesAdvertised = available; + } + ++ public static boolean bundlesEnabled(final UserConnection user) { ++ return actionsEnabled(user) && user.get(ChannelStorage.class).hasChannel(NativeCookingBundleResult.CHANNEL); ++ } ++ ++ private void refreshBundleCapability() { ++ final boolean available = bundlesEnabled(this.user()); ++ if (this.user().getProtocolInfo().getClientState() != State.PLAY) { ++ this.bundlesAdvertised = false; ++ return; ++ } ++ if (available == this.bundlesAdvertised) { ++ return; ++ } ++ final var packet = PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, this.user()); ++ packet.write(Types.STRING, available ? "minecraft:register" : "minecraft:unregister"); ++ packet.write(Types.REMAINING_BYTES, NativeCookingBundleAction.CHANNEL.getBytes(StandardCharsets.UTF_8)); ++ packet.send(BedrockProtocol.class); ++ this.bundlesAdvertised = available; ++ } ++ + private void refreshHoldCapability() { + final boolean available = holdsEnabled(this.user()); + if (!available) { +@@ -174,6 +201,7 @@ public final class NativeCookingUiState extends StoredObject { + + public void refreshActionCapability() { + this.refreshSourceCapability(); ++ this.refreshBundleCapability(); + this.refreshQueryCapability(); + this.refreshHoldCapability(); + if (this.user().getProtocolInfo().getClientState() != State.PLAY) { +@@ -512,6 +540,31 @@ public final class NativeCookingUiState extends StoredObject { + } + } + ++ public NativeBundleActionSession.Outcome handleBundleAction(final NativeCookingBundleAction action) { ++ if (this.handlingAction || this.publishing || !bundlesEnabled(this.user()) ++ || !this.current(action.source()) || action.requestSequence() <= this.lastRequestSequence ++ || this.gesture != null || this.focus != null || this.heldQuantity != null) { ++ return null; ++ } ++ final var requests = this.user().get(InventoryRequestTracker.class); ++ if (!requests.canStartRecipePlacement() || requests.isPredictingClick()) { ++ return null; ++ } ++ this.handlingAction = true; ++ this.lastRequestSequence = action.requestSequence(); ++ try { ++ final var outcome = this.bundleInputs == null ? null : this.bundleInputs.execute(action); ++ if (outcome != null) { ++ final var inventory = this.user().get(InventoryTracker.class); ++ PacketFactory.sendJavaContainerSetContent(this.user(), this.container); ++ PacketFactory.sendJavaContainerSetContent(this.user(), inventory.getInventoryContainer()); ++ } ++ return outcome; ++ } finally { ++ this.handlingAction = false; ++ } ++ } ++ + /** Search and recipe commands share one ordered input stream for this opening. */ + public boolean handleQuery(final NativeCookingQuery query) { + if (this.handlingAction || this.publishing || !queriesEnabled(this.user()) +@@ -620,7 +673,8 @@ public final class NativeCookingUiState extends StoredObject { + return false; + } + final var inventory = this.user().get(InventoryTracker.class); +- return !this.searchDirty && this.container.serverRevision() == this.message.menuRevision() ++ return !this.searchDirty && (this.bundleInputs == null || this.bundleInputs.current()) ++ && this.container.serverRevision() == this.message.menuRevision() + && inventory.getInventoryContainer().serverRevision() == this.message.inventoryRevision() + && inventory.getHudContainer().serverRevision() == this.message.cursorRevision() + && Arrays.equals(this.stationItems, this.container.getItems()) +@@ -732,6 +786,7 @@ public final class NativeCookingUiState extends StoredObject { + this.cursorItems = cursorItems; + this.dataRevision = this.container.serverDataRevision(); + this.message = next; ++ this.bundleInputs = NativeBundleInputSession.capture(this.user(), next); + this.searchDirty = false; + return next; + } +@@ -783,6 +838,7 @@ public final class NativeCookingUiState extends StoredObject { + this.ui = null; + this.options = null; + this.message = null; ++ this.bundleInputs = null; + this.stationItems = null; + this.playerItems = null; + this.cursorItems = null; 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..ae1d94b2 +index 00000000..dd026f2a --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/NativePlayerRecipeState.java @@ -0,0 +1,704 @@ @@ -4822,7 +5109,7 @@ index 00000000..ae1d94b2 + final var packet = PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, this.user()); + packet.write(Types.STRING, available ? "minecraft:register" : "minecraft:unregister"); + packet.write(Types.REMAINING_BYTES, (NativePlayerInventoryAction.CHANNEL + "\0" + NativePlayerInventoryAction.AUTO_DESTROY_CHANNEL -+ + "\0" + NativeBundleAction.CHANNEL).getBytes(StandardCharsets.UTF_8)); ++ + "\0" + NativeBundleAction.CHANNEL + "\0" + NativeBundleAction.SELECTION_CHANNEL).getBytes(StandardCharsets.UTF_8)); + packet.send(BedrockProtocol.class); + this.actionsAdvertised = available; + } @@ -5279,7 +5566,7 @@ index 00000000..ae1d94b2 + +} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/PlayerInventoryScreenTracker.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/PlayerInventoryScreenTracker.java -index 9f2ccf71..2db2d17d 100644 +index 39383eac..81f061e9 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/PlayerInventoryScreenTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/PlayerInventoryScreenTracker.java @@ -31,6 +31,8 @@ public final class PlayerInventoryScreenTracker extends StoredObject { @@ -5291,7 +5578,7 @@ index 9f2ccf71..2db2d17d 100644 private boolean advertised; private boolean deferredAdvertised; private boolean deferred; -@@ -109,11 +111,16 @@ public final class PlayerInventoryScreenTracker extends StoredObject { +@@ -114,12 +116,17 @@ public final class PlayerInventoryScreenTracker extends StoredObject { return; } this.requestedSince = System.nanoTime(); @@ -5303,12 +5590,13 @@ index 9f2ccf71..2db2d17d 100644 if (request.opening() != this.latestOpening || !this.requested) { return; } + final var owner = this.acknowledgedContainer(request.opening()); this.requested = false; + this.closePresentation(); - this.requests().clearQueuedScreenInteractions(); - } - this.service(); -@@ -130,7 +137,22 @@ public final class PlayerInventoryScreenTracker extends StoredObject { + if (!this.requests().deferDragClose(owner, this::service)) { + this.requests().clearQueuedScreenInteractions(); + } +@@ -138,7 +145,22 @@ public final class PlayerInventoryScreenTracker extends StoredObject { public boolean acceptsClicks() { return this.requested && (this.inFlight == this.latestOpening @@ -5332,7 +5620,7 @@ index 9f2ccf71..2db2d17d 100644 } /** Called after the native container is installed, before any queued Java clicks are replayed. */ -@@ -141,6 +163,8 @@ public final class PlayerInventoryScreenTracker extends StoredObject { +@@ -149,6 +171,8 @@ public final class PlayerInventoryScreenTracker extends StoredObject { final long opening = this.inFlight; this.inFlight = 0; this.nativeOpening = opening; @@ -5341,7 +5629,7 @@ index 9f2ccf71..2db2d17d 100644 if (this.enabled() && this.requested && opening == this.latestOpening) { this.requests().keepPlayerInventoryOpen(); this.ready(); -@@ -155,6 +179,8 @@ public final class PlayerInventoryScreenTracker extends StoredObject { +@@ -163,6 +187,8 @@ public final class PlayerInventoryScreenTracker extends StoredObject { public void closed(final boolean serverInitiated) { final long opening = this.nativeOpening; this.nativeOpening = 0; @@ -5350,15 +5638,15 @@ index 9f2ccf71..2db2d17d 100644 if (serverInitiated && opening != 0 && opening == this.latestOpening) { this.retire(); } -@@ -164,6 +190,7 @@ public final class PlayerInventoryScreenTracker extends StoredObject { +@@ -172,6 +198,7 @@ public final class PlayerInventoryScreenTracker extends StoredObject { public void retire() { if (this.requested) { this.requested = false; + this.closePresentation(); if (this.enabled()) { - final var packet = PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, this.user()); - packet.write(Types.STRING, PlayerInventoryScreenClose.CHANNEL); -@@ -178,6 +205,8 @@ public final class PlayerInventoryScreenTracker extends StoredObject { + this.sendClose(this.latestOpening); + } +@@ -183,6 +210,8 @@ public final class PlayerInventoryScreenTracker extends StoredObject { public void recovering() { this.retire(); this.nativeOpening = 0; @@ -5367,7 +5655,7 @@ index 9f2ccf71..2db2d17d 100644 } public void tick(final long now) { -@@ -213,6 +242,8 @@ public final class PlayerInventoryScreenTracker extends StoredObject { +@@ -228,6 +257,8 @@ public final class PlayerInventoryScreenTracker extends StoredObject { if (inventory.getCurrentContainer() instanceof InventoryContainer) { // A temporary offhand opening can become the actual installed player screen. this.nativeOpening = this.latestOpening; @@ -5376,7 +5664,7 @@ index 9f2ccf71..2db2d17d 100644 this.ready(); return; } -@@ -243,6 +274,13 @@ public final class PlayerInventoryScreenTracker extends StoredObject { +@@ -258,6 +289,13 @@ public final class PlayerInventoryScreenTracker extends StoredObject { packet.send(BedrockProtocol.class); } @@ -5392,10 +5680,10 @@ index 9f2ccf71..2db2d17d 100644 } diff --git a/src/test/java/net/raphimc/viabedrock/api/model/NativeBundleActionTest.java b/src/test/java/net/raphimc/viabedrock/api/model/NativeBundleActionTest.java new file mode 100644 -index 00000000..e5638c0d +index 00000000..94381305 --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/api/model/NativeBundleActionTest.java -@@ -0,0 +1,61 @@ +@@ -0,0 +1,87 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -5436,6 +5724,32 @@ index 00000000..e5638c0d + } + + @Test ++ void selectedExtractionRequiresTheNegotiatedCodecAndPreservesTheDisplayedCell() { ++ final var action = new NativeBundleAction(0, 7, 3, 11, 2, "inventory_items", 1, ++ NativeBundleAction.Kind.SECONDARY, 2, 3); ++ final var buffer = Unpooled.buffer(); ++ try { ++ assertThrows(IllegalArgumentException.class, () -> NativeBundleAction.TYPE.write(buffer, action)); ++ assertEquals(0, buffer.writerIndex()); ++ NativeBundleAction.SELECTION_TYPE.write(buffer, action); ++ assertEquals(action, NativeBundleAction.SELECTION_TYPE.read(buffer)); ++ buffer.readerIndex(0); ++ assertThrows(IllegalArgumentException.class, () -> NativeBundleAction.TYPE.read(buffer)); ++ buffer.clear(); ++ NativeBundleAction.TYPE.write(buffer, new NativeBundleAction(0, 7, 3, 11, 2, ++ "inventory_items", 1, NativeBundleAction.Kind.SECONDARY, 2)); ++ assertThrows(IllegalArgumentException.class, () -> NativeBundleAction.SELECTION_TYPE.read(buffer)); ++ assertEquals(NativeBundleAction.SELECTION_CHANNEL, action.channel()); ++ } finally { ++ buffer.release(); ++ } ++ assertThrows(IllegalArgumentException.class, () -> new NativeBundleAction(0, 7, 3, 11, 2, ++ "inventory_items", 1, NativeBundleAction.Kind.PRIMARY, 2, 3)); ++ assertThrows(IllegalArgumentException.class, () -> new NativeBundleAction(0, 7, 3, 11, 2, ++ "inventory_items", 1, NativeBundleAction.Kind.SECONDARY, 0, 3)); ++ } ++ ++ @Test + void decoderRejectsTrailingFieldsUnknownKindsAndUnboundedInput() { + final var action = new NativeBundleAction(0, 7, 3, 11, 2, "inventory_items", 1, NativeBundleAction.Kind.PRIMARY, 0); + for (int mutation = 0; mutation < 4; mutation++) { @@ -5519,6 +5833,50 @@ index 00000000..35c75743 + } + +} +diff --git a/src/test/java/net/raphimc/viabedrock/api/model/NativeCookingBundleActionTest.java b/src/test/java/net/raphimc/viabedrock/api/model/NativeCookingBundleActionTest.java +new file mode 100644 +index 00000000..97482a6a +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/api/model/NativeCookingBundleActionTest.java +@@ -0,0 +1,38 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.model; ++ ++import io.netty.buffer.Unpooled; ++import org.junit.jupiter.api.Test; ++import static org.junit.jupiter.api.Assertions.*; ++ ++class NativeCookingBundleActionTest { ++ ++ @Test ++ void cookingBundleRoundTripRetainsItsFullSourceCellAndResultSound() { ++ final var action = new NativeCookingBundleAction(new NativeCookingControl(5, 7, 11, 12, 13, 14, ++ NativeCookingControl.Mode.ACTIVATE), "furnace_ingredient_items", 0, NativeBundleAction.Kind.SECONDARY, 2, 65, 17); ++ final var buffer = Unpooled.buffer(); ++ try { ++ NativeCookingBundleAction.TYPE.write(buffer, action); ++ assertEquals(action, NativeCookingBundleAction.TYPE.read(buffer)); ++ buffer.clear(); ++ final var result = NativeCookingBundleResult.reply(action, true, false); ++ NativeCookingBundleResult.TYPE.write(buffer, result); ++ assertEquals(result, NativeCookingBundleResult.TYPE.read(buffer)); ++ assertEquals(NativeBundleResult.Sound.REMOVE_ONE, result.sound()); ++ buffer.clear(); ++ NativeCookingBundleAction.TYPE.write(buffer, action); ++ buffer.writeByte(0); ++ assertThrows(IllegalArgumentException.class, () -> NativeCookingBundleAction.TYPE.read(buffer)); ++ assertThrows(IllegalArgumentException.class, () -> new NativeCookingBundleResult(action, true, false, NativeBundleResult.Sound.INSERT)); ++ assertThrows(IllegalArgumentException.class, () -> new NativeCookingBundleResult(action, false, true, NativeBundleResult.Sound.REMOVE_ONE)); ++ } finally { ++ buffer.release(); ++ } ++ } ++ ++} diff --git a/src/test/java/net/raphimc/viabedrock/api/model/NativePlayerInventoryMessageTest.java b/src/test/java/net/raphimc/viabedrock/api/model/NativePlayerInventoryMessageTest.java new file mode 100644 index 00000000..819952fc @@ -5868,10 +6226,10 @@ index 14d72266..ee6954fc 100644 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 +index 00000000..2b43dfcc --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/api/model/container/NativeRecipeQuantityTest.java -@@ -0,0 +1,61 @@ +@@ -0,0 +1,69 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -5891,6 +6249,14 @@ index 00000000..f6d3290d +class NativeRecipeQuantityTest { + + @Test ++ void outputCountingSkipsEmptyPlaceholdersAndRetainsLaterValidRecipes() { ++ final var placeholders = List.of(production(0, -1, Set.of(), 1), production(1, 0, Set.of(), 1)); ++ assertEquals(new NativeRecipeCounter.Quantity(0, 0), quantity(placeholders, 8, 3)); ++ assertEquals(new NativeRecipeCounter.Quantity(32, 8), quantity( ++ List.of(placeholders.get(0), placeholders.get(1), production(1, -1, Set.of(1), 4)), 8, 3)); ++ } ++ ++ @Test + void alternativeLogRecipesMatchNativeDisplayedPlankQuantity() { + // Official 1.26.60.5 classic player inventory: eight logs and three stripped logs show 44 planks. + final var candidates = List.of( @@ -6936,10 +7302,10 @@ index 1cfb02f6..d8eb367f 100644 } diff --git a/src/test/java/net/raphimc/viabedrock/protocol/packet/NativeBundleResultPacketTest.java b/src/test/java/net/raphimc/viabedrock/protocol/packet/NativeBundleResultPacketTest.java new file mode 100644 -index 00000000..7569ddfb +index 00000000..a3451b96 --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/protocol/packet/NativeBundleResultPacketTest.java -@@ -0,0 +1,69 @@ +@@ -0,0 +1,107 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -6953,6 +7319,10 @@ index 00000000..7569ddfb +import com.viaversion.viaversion.protocol.packet.PacketWrapperImpl; +import com.viaversion.viaversion.protocols.v26_2to26_3.packet.ClientboundPackets26_3; +import net.raphimc.viabedrock.api.model.NativeBundleAction; ++import net.raphimc.viabedrock.api.model.NativeCookingBundleAction; ++import net.raphimc.viabedrock.api.model.NativeCookingBundleResult; ++import net.raphimc.viabedrock.api.model.NativeCookingControl; ++import net.raphimc.viabedrock.protocol.storage.NativeCookingUiState; +import net.raphimc.viabedrock.api.model.NativeBundleResult; +import net.raphimc.viabedrock.api.model.NativePlayerInventoryMessage; +import net.raphimc.viabedrock.api.model.NativePlayerInventoryResult; @@ -6968,8 +7338,40 @@ index 00000000..7569ddfb +class NativeBundleResultPacketTest { + + @Test ++ void cookingRepliesDistinguishUnchangedAuthorityFromCommandsThatRequireNewFrames() throws Exception { ++ for (final boolean current : new boolean[]{false, true}) { ++ final var user = mock(UserConnection.class); ++ final var state = mock(NativeCookingUiState.class); ++ when(user.get(NativeCookingUiState.class)).thenReturn(state); ++ final var source = new NativeCookingControl(5, 7, 11, 12, 13, 14, NativeCookingControl.Mode.ACTIVATE); ++ final var command = new NativeCookingBundleAction(source, "inventory_items", 0, NativeBundleAction.Kind.SECONDARY, 2, 3, 1); ++ when(state.handleBundleAction(command)).thenReturn(new NativeBundleActionSession.Outcome(true, current ? 0 : 1)); ++ when(state.current(source)).thenReturn(current); ++ final var request = new PacketWrapperImpl(ClientboundPackets26_3.CUSTOM_PAYLOAD, null, user); ++ request.write(Types.STRING, NativeCookingBundleAction.CHANNEL); ++ request.write(NativeCookingBundleAction.TYPE, command); ++ request.resetReader(); ++ final var reply = spy(new PacketWrapperImpl(ClientboundPackets26_3.CUSTOM_PAYLOAD, null, user)); ++ doNothing().when(reply).send(BedrockProtocol.class); ++ try (var factory = mockStatic(PacketWrapper.class); ++ var cooking = mockStatic(NativeCookingUiState.class)) { ++ cooking.when(() -> NativeCookingUiState.bundlesEnabled(user)).thenReturn(true); ++ factory.when(() -> PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, user)).thenReturn(reply); ++ MultiStatePackets.CUSTOM_PAYLOAD_HANDLER.handle(request); ++ assertTrue(request.isCancelled()); ++ verify(state).handleBundleAction(command); ++ assertEquals(NativeCookingBundleResult.CHANNEL, reply.get(Types.STRING, 0)); ++ assertEquals(NativeCookingBundleResult.reply(command, true, current), reply.get(NativeCookingBundleResult.TYPE, 0)); ++ verify(reply).send(BedrockProtocol.class); ++ } ++ } ++ } ++ ++ @Test + void bundleRepliesUseTheOptionalEnvelopeOnlyForClientsThatAdvertiseIt() throws Exception { -+ for (final boolean extended : new boolean[]{false, true}) { ++ for (int mode = 0; mode < 4; mode++) { ++ final boolean extended = mode % 2 != 0; ++ final boolean selection = mode >= 2; + for (final Boolean success : new Boolean[]{null, false, true}) { + final var user = mock(UserConnection.class); + final var state = mock(NativePlayerRecipeState.class); @@ -6981,11 +7383,12 @@ index 00000000..7569ddfb + when(state.actionsEnabled()).thenReturn(true); + when(state.publication()).thenReturn(published); + when(published.revision()).thenReturn(12L); -+ final var command = new NativeBundleAction(0, 7, 3, 11, 2, "inventory_items", 0, NativeBundleAction.Kind.PRIMARY, 2); ++ final var command = new NativeBundleAction(0, 7, 3, 11, 2, "inventory_items", 0, ++ selection ? NativeBundleAction.Kind.SECONDARY : NativeBundleAction.Kind.PRIMARY, 2, selection ? 3 : -1); + when(state.handleBundleAction(command)).thenReturn(success == null ? null : new NativeBundleActionSession.Outcome(success, 0)); + final var request = new PacketWrapperImpl(ClientboundPackets26_3.CUSTOM_PAYLOAD, null, user); -+ request.write(Types.STRING, NativeBundleAction.CHANNEL); -+ request.write(NativeBundleAction.TYPE, command); ++ request.write(Types.STRING, command.channel()); ++ request.write(selection ? NativeBundleAction.SELECTION_TYPE : NativeBundleAction.TYPE, command); + request.resetReader(); + final var reply = spy(new PacketWrapperImpl(ClientboundPackets26_3.CUSTOM_PAYLOAD, null, user)); + doNothing().when(reply).send(BedrockProtocol.class); @@ -6997,7 +7400,8 @@ index 00000000..7569ddfb + if (extended) { + assertEquals(NativeBundleResult.CHANNEL, reply.get(Types.STRING, 0)); + final var result = reply.get(NativeBundleResult.TYPE, 0); -+ assertEquals(success == null ? "" : success ? "bundle.insert" : "bundle.insert_fail", result.sound().event()); ++ assertEquals(success == null ? "" : selection ? success ? "bundle.remove_one" : "" ++ : success ? "bundle.insert" : "bundle.insert_fail", result.sound().event()); + assertEquals(new NativePlayerInventoryResult(7, 3, 2, 12, Boolean.TRUE.equals(success)), result.result()); + } else { + assertEquals(NativePlayerInventoryResult.CHANNEL, reply.get(Types.STRING, 0)); @@ -7720,7 +8124,7 @@ index 00000000..af54a6a1 + +} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/rewriter/RegisteredPlayerRecipeAvailabilityTest.java b/src/test/java/net/raphimc/viabedrock/protocol/rewriter/RegisteredPlayerRecipeAvailabilityTest.java -index de622a23..dc9f36f4 100644 +index 7b1f3805..b8633b1a 100644 --- a/src/test/java/net/raphimc/viabedrock/protocol/rewriter/RegisteredPlayerRecipeAvailabilityTest.java +++ b/src/test/java/net/raphimc/viabedrock/protocol/rewriter/RegisteredPlayerRecipeAvailabilityTest.java @@ -19,6 +19,7 @@ import net.raphimc.viabedrock.protocol.model.PlayerAbilities; @@ -7731,7 +8135,7 @@ index de622a23..dc9f36f4 100644 import net.raphimc.viabedrock.protocol.model.BedrockItem; import net.raphimc.viabedrock.protocol.model.ItemEntry; import net.raphimc.viabedrock.protocol.model.ItemRegistry; -@@ -113,7 +114,7 @@ class RegisteredPlayerRecipeAvailabilityTest { +@@ -130,7 +131,7 @@ class RegisteredPlayerRecipeAvailabilityTest { final var context = PlayerRecipeAvailabilityTest.context(false, IDENTIFIER_BASE); final var tracker = new CraftingDataTracker(context.items().user()); final var output = item(1); @@ -7740,7 +8144,7 @@ index de622a23..dc9f36f4 100644 assertEquals(3, tracker.playerRecipeAvailability(output, BedrockItem.empty(), context.owner().copy(), false, true)); tracker.updateCraftingDataList(List.of(recipe(RecipeType.SHAPED, 1, 3, 1, List.of(name(1, 1), new ItemDescriptor.InvalidDescriptor(), new ItemDescriptor.InvalidDescriptor()))), true); -@@ -261,7 +262,7 @@ class RegisteredPlayerRecipeAvailabilityTest { +@@ -278,7 +279,7 @@ class RegisteredPlayerRecipeAvailabilityTest { abilities.getOrCreateLayer(SerializedAbilitiesData_SerializedAbilitiesLayer.Base).setAbility(AbilitiesIndex.Instabuild, false); when(rules.getGameRule("recipesUnlock")).thenReturn(false); // The disabled rule skips context production and the invalid output skips source capture. @@ -7771,7 +8175,7 @@ index 5f911215..ed7bae20 100644 } diff --git a/src/test/java/net/raphimc/viabedrock/protocol/storage/BundleInventoryActionTest.java b/src/test/java/net/raphimc/viabedrock/protocol/storage/BundleInventoryActionTest.java -index bb1e3d35..f36b4fa1 100644 +index 4b5143ca..0d704592 100644 --- a/src/test/java/net/raphimc/viabedrock/protocol/storage/BundleInventoryActionTest.java +++ b/src/test/java/net/raphimc/viabedrock/protocol/storage/BundleInventoryActionTest.java @@ -15,6 +15,7 @@ import com.viaversion.nbt.tag.CompoundTag; @@ -8220,10 +8624,10 @@ index b69f1868..037328f2 100644 final BedrockItem current = new BedrockItem(276); diff --git a/src/test/java/net/raphimc/viabedrock/protocol/storage/NativeBundleActionSessionTest.java b/src/test/java/net/raphimc/viabedrock/protocol/storage/NativeBundleActionSessionTest.java new file mode 100644 -index 00000000..6ac467d8 +index 00000000..26d762b0 --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/protocol/storage/NativeBundleActionSessionTest.java -@@ -0,0 +1,630 @@ +@@ -0,0 +1,865 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -8236,6 +8640,21 @@ index 00000000..6ac467d8 +import com.viaversion.viaversion.api.connection.UserConnection; +import net.raphimc.viabedrock.ViaBedrock; +import net.raphimc.viabedrock.api.model.NativeBundleAction; ++import net.raphimc.viabedrock.api.model.NativeCookingBundleAction; ++import net.raphimc.viabedrock.api.model.NativeCookingBundleResult; ++import net.raphimc.viabedrock.api.model.NativeCookingControl; ++import net.raphimc.viabedrock.api.model.NativeCookingMessage; ++import net.raphimc.viabedrock.api.model.NativeCookingProgress; ++import net.raphimc.viabedrock.api.model.NativeCookingActionResult; ++import net.raphimc.viabedrock.api.model.NativeCookingQuery; ++import net.raphimc.viabedrock.api.model.NativeCookingQueryResult; ++import net.raphimc.viabedrock.api.model.container.block.FurnaceContainer; ++import net.raphimc.viabedrock.api.model.container.block.SmokerContainer; ++import net.raphimc.viabedrock.api.model.container.block.BlastFurnaceContainer; ++import net.raphimc.viabedrock.api.resourcepack.ui.CookingUi; ++import net.raphimc.viabedrock.api.resourcepack.ui.ContainerUi; ++import com.viaversion.viaversion.api.connection.ProtocolInfo; ++import com.viaversion.viaversion.api.protocol.packet.State; +import net.raphimc.viabedrock.api.model.NativeBundleResult; +import net.raphimc.viabedrock.api.model.NativePlayerInventoryAction; +import net.raphimc.viabedrock.api.model.NativePlayerInventoryMessage; @@ -8283,6 +8702,116 @@ index 00000000..6ac467d8 +class NativeBundleActionSessionTest { + + @Test ++ void cookingBundleRequestsRetainAllSnapshotFieldsAndShareSearchReplayProtection() { ++ try (var f = new Fixture()) { ++ final var state = f.cookingState(); ++ f.bundle.setItem(0, item(30031, 2, 100)); ++ f.bundle.setItem(3, item(30032, 5, 103)); ++ f.bundle.setItem(7, item(30031, 9, 107)); ++ final var message = state.begin(new ContainerUi.Title("container.furnace", true)).message(); ++ final var source = NativeCookingControl.forMessage(message, NativeCookingControl.Mode.ACTIVATE); ++ for (int changed = 0; changed < 6; changed++) { ++ final var wrong = new NativeCookingControl(changed == 0 ? source.menuId() + 1 : source.menuId(), ++ source.sequence() + (changed == 1 ? 1 : 0), source.updateSequence() + (changed == 2 ? 1 : 0), ++ source.menuRevision() + (changed == 3 ? 1 : 0), source.inventoryRevision() + (changed == 4 ? 1 : 0), ++ source.cursorRevision() + (changed == 5 ? 1 : 0), NativeCookingControl.Mode.ACTIVATE); ++ assertNull(state.handleBundleAction(new NativeCookingBundleAction(wrong, "inventory_items", 0, ++ NativeBundleAction.Kind.SECONDARY, 2, 2, 1))); ++ } ++ for (final String absent : List.of("crafting_input_items", "container_items")) { ++ assertNull(state.handleBundleAction(new NativeCookingBundleAction(source, absent, 0, ++ NativeBundleAction.Kind.SECONDARY, 2, 2, 1))); ++ } ++ assertTrue(f.sent.isEmpty()); ++ final var input = new NativeCookingBundleAction(source, "inventory_items", 0, ++ NativeBundleAction.Kind.SECONDARY, 2, 2, 2); ++ assertEquals(new NativeBundleActionSession.Outcome(true, 1), state.handleBundleAction(input)); ++ final var take = (ItemStackRequestAction.TakeAction) f.sent.getFirst().actions().getFirst(); ++ assertEquals(f.bundle.requestSlotInfo(3, 103), take.source()); ++ assertEquals(f.inventory.getHudContainer().requestSlotInfo(0, 0), take.destination()); ++ assertTrue(state.update().message().updateSequence() > message.updateSequence()); ++ assertNull(state.handleBundleAction(input)); ++ assertFalse(state.handleQuery(new NativeCookingQuery(source, 2, "late"))); ++ f.requests.handleResponse(rejected(f.sent.getFirst())); ++ assertEquals(item(30032, 5, 103), f.bundle.getItem(3)); ++ assertTrue(f.inventory.getHudContainer().getItem(0).isEmpty()); ++ final var replacement = state.update().message(); ++ assertTrue(replacement.updateSequence() > message.updateSequence()); ++ assertNull(state.handleBundleAction(new NativeCookingBundleAction(source, "inventory_items", 0, ++ NativeBundleAction.Kind.SECONDARY, 2, 2, 3))); ++ assertTrue(state.handleQuery(NativeCookingQuery.forMessage(replacement, 3, "fresh"))); ++ assertEquals(1, f.sent.size()); ++ } ++ } ++ ++ @Test ++ void cookingStationAliasesUseThePhysicalBundleAndPreserveBothSourcesAfterRejection() { ++ for (final var family : CookingUi.Family.values()) { ++ try (var f = new Fixture()) { ++ final var state = f.cookingState(family); ++ final var station = f.inventory.getCurrentContainer(); ++ station.setItem(0, f.player.getItem(9)); ++ f.player.setItem(9, BedrockItem.empty()); ++ f.inventory.getHudContainer().setItem(0, item(30031, 5, 201)); ++ final var frame = state.begin(new ContainerUi.Title("container.furnace", true)).message(); ++ final var action = new NativeCookingBundleAction(NativeCookingControl.forMessage(frame, NativeCookingControl.Mode.ACTIVATE), ++ "furnace_ingredient_items", 0, NativeBundleAction.Kind.PRIMARY, 2, -1, 1); ++ assertEquals(new NativeBundleActionSession.Outcome(true, 1), state.handleBundleAction(action), family.toString()); ++ final var take = (ItemStackRequestAction.TakeAction) f.sent.getFirst().actions().getFirst(); ++ assertEquals(f.inventory.getHudContainer().requestSlotInfo(0, 201), take.source()); ++ assertEquals(f.bundle.requestSlotInfo(0, 0), take.destination()); ++ f.requests.handleResponse(rejected(f.sent.getFirst())); ++ assertEquals(item(30031, 5, 201), f.inventory.getHudContainer().getItem(0)); ++ assertTrue(f.bundle.getItem(0).isEmpty()); ++ assertEquals(30030, station.getItem(0).identifier()); ++ f.player.setItem(10, f.inventory.getHudContainer().getItem(0)); ++ f.inventory.getHudContainer().setItem(0, station.getItem(0)); ++ station.setItem(0, BedrockItem.empty()); ++ f.bundle.setItem(3, item(30032, 5, 103)); ++ // Declare destination refusal independently of a station's fuel metadata. ++ final var refusing = spy(station); ++ doReturn(false).when(refusing).canPlaceItem(eq(1), any(), anyInt()); ++ f.inventory.setCurrentContainerClosed(false); ++ f.inventory.setCurrentContainer(refusing); ++ final var unchanged = state.begin(new ContainerUi.Title("container.furnace", true)).message(); ++ final var nonmutating = new NativeCookingBundleAction(NativeCookingControl.forMessage(unchanged, ++ NativeCookingControl.Mode.ACTIVATE), "furnace_fuel_items", 0, NativeBundleAction.Kind.SECONDARY, 2, 3, 1); ++ assertEquals(new NativeBundleActionSession.Outcome(true, 0), state.handleBundleAction(nonmutating)); ++ assertTrue(state.current(nonmutating.source()), "A refused destination does not require a new item publication"); ++ assertNull(state.update()); ++ assertEquals(1, f.sent.size()); ++ assertEquals(item(30032, 5, 103), f.bundle.getItem(3)); ++ } ++ } ++ } ++ ++ @Test ++ void cookingDynamicContentsRepublishWithoutRootChangesAndAnOldOpeningCannotExtract() { ++ try (var f = new Fixture()) { ++ final var state = f.cookingState(); ++ f.bundle.setItem(3, item(30032, 5, 103)); ++ final var first = state.begin(new ContainerUi.Title("container.furnace", true)).message(); ++ final var source = NativeCookingControl.forMessage(first, NativeCookingControl.Mode.ACTIVATE); ++ f.bundle.setItem(3, item(30032, 4, 104)); ++ final var changed = state.update(); ++ assertNotNull(changed, "Dynamic bundle contents must publish new cooking authority"); ++ final var next = changed.message(); ++ assertEquals(first.menuRevision(), next.menuRevision()); ++ assertEquals(first.inventoryRevision(), next.inventoryRevision()); ++ assertEquals(first.cursorRevision(), next.cursorRevision()); ++ assertTrue(next.updateSequence() > first.updateSequence()); ++ assertNull(state.handleBundleAction(new NativeCookingBundleAction(source, "inventory_items", 0, ++ NativeBundleAction.Kind.SECONDARY, 2, 3, 1))); ++ final var reopened = state.begin(new ContainerUi.Title("container.furnace", true)).message(); ++ assertTrue(reopened.sequence() > next.sequence()); ++ assertNull(state.handleBundleAction(new NativeCookingBundleAction(NativeCookingControl.forMessage(next, ++ NativeCookingControl.Mode.ACTIVATE), "inventory_items", 0, NativeBundleAction.Kind.SECONDARY, 2, 3, 1))); ++ assertTrue(f.sent.isEmpty()); ++ assertEquals(item(30032, 4, 104), f.bundle.getItem(3)); ++ } ++ } ++ ++ @Test + void workbenchInputsRetainSparseNativeSlotsThroughPredictionAndRejection() { + for (final boolean rootOnCursor : new boolean[]{false, true}) { + try (var f = new Fixture()) { @@ -8363,6 +8892,84 @@ index 00000000..6ac467d8 + } + + @Test ++ void selectedPublishedCellExtractsItsSparsePhysicalSlotAndRejectionRestoresAllItems() { ++ for (final boolean rootOnCursor : new boolean[]{false, true}) { ++ try (var f = new Fixture()) { ++ f.bundle.setItem(0, item(30031, 2, 100)); ++ f.bundle.setItem(3, item(30032, 5, 103)); ++ f.bundle.setItem(7, item(30031, 9, 107)); ++ final var cursor = f.inventory.getHudContainer(); ++ if (rootOnCursor) { ++ cursor.setItem(0, f.player.getItem(9)); ++ f.player.setItem(9, BedrockItem.empty()); ++ } ++ final var published = f.publication(); ++ final var input = NativeBundleInputSession.capture(f.user, published); ++ assertNotNull(input); ++ final var action = new NativeBundleAction(published.menuId(), published.opening(), published.generation(), ++ published.revision(), 1, "inventory_items", rootOnCursor ? 1 : 0, ++ NativeBundleAction.Kind.SECONDARY, 2, 2); ++ assertEquals(new NativeBundleActionSession.Outcome(true, 1), input.execute(action)); ++ final var take = (ItemStackRequestAction.TakeAction) f.sent.getFirst().actions().getFirst(); ++ assertEquals(f.bundle.requestSlotInfo(3, 103), take.source()); ++ assertEquals(rootOnCursor ? f.player.requestSlotInfo(10, 0) : cursor.requestSlotInfo(0, 0), take.destination()); ++ assertTrue(f.bundle.getItem(3).isEmpty()); ++ assertEquals(item(30031, 9, 107), f.bundle.getItem(7)); ++ f.requests.handleResponse(rejected(f.sent.getFirst())); ++ assertEquals(item(30031, 2, 100), f.bundle.getItem(0)); ++ assertEquals(item(30032, 5, 103), f.bundle.getItem(3)); ++ assertEquals(item(30031, 9, 107), f.bundle.getItem(7)); ++ assertTrue(rootOnCursor ? f.player.getItem(10).isEmpty() : cursor.getItem(0).isEmpty()); ++ } ++ } ++ } ++ ++ @Test ++ void lastCellInFullBundleResolvesBeyondPhysicalSlots() { ++ try (var f = new Fixture()) { ++ for (int slot = 0; slot < 64; slot++) { ++ f.bundle.setItem(slot, item(slot == 0 ? 30032 : 30031, 1, 100 + slot)); ++ } ++ final var published = f.publication(); ++ final var input = NativeBundleInputSession.capture(f.user, published); ++ assertNotNull(input); ++ final var outcome = input.execute(new NativeBundleAction(published.menuId(), published.opening(), published.generation(), ++ published.revision(), 1, "inventory_items", 0, NativeBundleAction.Kind.SECONDARY, 2, 65)); ++ assertNotNull(outcome); ++ assertTrue(outcome.successful()); ++ final var take = (ItemStackRequestAction.TakeAction) f.sent.getFirst().actions().getFirst(); ++ assertEquals(f.bundle.requestSlotInfo(0, 100), take.source()); ++ assertEquals(30032, f.inventory.getHudContainer().getItem(0).identifier()); ++ assertEquals(1, f.inventory.getHudContainer().getItem(0).amount()); ++ assertEquals(63, Arrays.stream(f.bundle.getItems()).mapToInt(BedrockItem::amount).sum()); ++ } ++ } ++ ++ @Test ++ void selectedInputRejectsPaddingOverflowLabelsAndChangedPublishedBundles() { ++ for (final int count : new int[]{3, 13}) { ++ try (var f = new Fixture(12)) { ++ for (int slot = 0; slot < count; slot++) { ++ f.bundle.setItem(slot, item(30031, 1, 100 + slot)); ++ } ++ final var published = f.publication(); ++ final var input = NativeBundleInputSession.capture(f.user, published); ++ assertNotNull(input); ++ for (final int cell : count == 3 ? new int[]{0, 4, 63} : new int[]{0, 2, 11, 12}) { ++ assertNull(input.execute(new NativeBundleAction(published.menuId(), published.opening(), published.generation(), ++ published.revision(), 1, "inventory_items", 0, NativeBundleAction.Kind.SECONDARY, 2, cell))); ++ } ++ assertTrue(f.sent.isEmpty()); ++ f.bundle.setItem(1, item(30032, 2, 999)); ++ assertNull(input.execute(new NativeBundleAction(published.menuId(), published.opening(), published.generation(), ++ published.revision(), 2, "inventory_items", 0, NativeBundleAction.Kind.SECONDARY, 2, 3))); ++ assertTrue(f.sent.isEmpty()); ++ assertTrue(f.inventory.getHudContainer().getItem(0).isEmpty()); ++ } ++ } ++ } ++ ++ @Test + void dynamicChangesAndReplacementOwnersRetirePublishedInputWithoutChangingTheRootStack() { + for (final int change : new int[]{0, 1, 2}) { + try (var f = new Fixture()) { @@ -8749,6 +9356,10 @@ index 00000000..6ac467d8 + private final List sent = new ArrayList<>(); + + private Fixture() { ++ this(64); ++ } ++ ++ private Fixture(final int viewableSlots) { + when(this.user.get(any())).thenAnswer(call -> this.storage.computeIfAbsent(call.getArgument(0), key -> mock(key))); + final var platform = mock(ViaBedrockPlatform.class); + when(platform.getLogger()).thenReturn(Logger.getAnonymousLogger()); @@ -8781,7 +9392,7 @@ index 00000000..6ac467d8 + for (final var field : Map.of("minecraft:storage_weight_modifier", "weight_in_storage_item", + "minecraft:storage_weight_limit", "max_weight_limit", "minecraft:bundle_interaction", "num_viewable_slots").entrySet()) { + final var value = new CompoundTag(); -+ value.putInt(field.getValue(), field.getValue().equals("weight_in_storage_item") ? 4 : 64); ++ value.putInt(field.getValue(), field.getValue().equals("weight_in_storage_item") ? 4 : field.getValue().equals("num_viewable_slots") ? viewableSlots : 64); + components.put(field.getKey(), value); + } + final var data = new CompoundTag(); @@ -8808,6 +9419,34 @@ index 00000000..6ac467d8 + }); + } + ++ private NativeCookingUiState cookingState() { ++ return this.cookingState(CookingUi.Family.FURNACE); ++ } ++ ++ private NativeCookingUiState cookingState(final CookingUi.Family family) { ++ final var info = mock(ProtocolInfo.class); ++ when(info.getClientState()).thenReturn(State.PLAY); ++ when(this.user.getProtocolInfo()).thenReturn(info); ++ final var channels = new ChannelStorage(); ++ channels.addChannels(List.of(NativeCookingMessage.CHANNEL, NativeCookingProgress.CHANNEL, ++ NativeCookingActionResult.CHANNEL, NativeCookingBundleResult.CHANNEL, NativeCookingQueryResult.CHANNEL)); ++ this.storage.put(ChannelStorage.class, channels); ++ this.storage.put(ResourcePackStorage.class, new ResourcePackStorage(List.of())); ++ this.storage.put(InventoryOptionsStorage.class, new InventoryOptionsStorage()); ++ this.storage.put(CraftingDataTracker.class, new CraftingDataTracker(this.user)); ++ this.inventory.setCurrentContainerClosed(false); ++ final var station = switch (family) { ++ case FURNACE -> new FurnaceContainer(this.user, (byte) 5, null, null); ++ case SMOKER -> new SmokerContainer(this.user, (byte) 5, null, null); ++ case BLAST_FURNACE -> new BlastFurnaceContainer(this.user, (byte) 5, null, null); ++ }; ++ this.inventory.setCurrentContainer(station); ++ station.markServerUpdate(); ++ final var state = new NativeCookingUiState(this.user); ++ this.storage.put(NativeCookingUiState.class, state); ++ return state; ++ } ++ + private NativePlayerRecipeState state(final NativePlayerInventoryMessage published) throws Exception { + final var state = spy(new NativePlayerRecipeState(this.user)); + doNothing().when(state).tick(); diff --git a/patches/viabedrock/deferred/0012-retain-bedrock-creative-catalog-entries.pr.md b/patches/viabedrock/deferred/0012-retain-bedrock-creative-catalog-entries.pr.md index dff1db06..9dee7c70 100644 --- a/patches/viabedrock/deferred/0012-retain-bedrock-creative-catalog-entries.pr.md +++ b/patches/viabedrock/deferred/0012-retain-bedrock-creative-catalog-entries.pr.md @@ -99,7 +99,9 @@ Player snapshots include both real Help graphs and their translations. The current native callback selects How to Play topic 5 for Survival and Encyclopedia topic 47 for Creative. A physical 1.26.60.5 capture confirms the Survival Your First Craft topic. Translated Help paragraphs replace both plain text and rendered components, preserving the native color palette. -Program round trips retain these components and navigation labels from authored topic and category variables. Full installed Help lifetimes and glyph appearance remain unverified. +Program round trips retain these components and navigation labels from authored topic and category variables. +Player inventory delegates export and resolution to the [shared Help contract](../upstreamable/0095-share-native-ui-programs-and-resources.pr.md). +Complete glyph appearance and broader Help lifetimes remain unverified. Complete installed action and screen parity remain unfinished. The Survival bulk validation below covers selected recipe boundaries. The retained model exposes output quantity separately from availability and action admission. @@ -153,6 +155,8 @@ Native 1.26.60.5 and BDS 1.26.60.6 controls establish capacity admission before With one free inventory item-space, native drops an incompatible or full held stack after partial placement and takes the remainder. Request/state regressions cover descriptors, changed-slot references, grid sources, capacity, cursor preservation, and rejection rollback. A placeholder-recipe regression retains the following valid plan and leaves the source inventory unchanged. +Output quantity counting also skips zero-slot and all-empty placeholders, preserving later valid candidates instead of interrupting recipe-cell construction. +The quantity regression fails with the previous unguarded counter call. Ordinary named ingredients retain resolved recipe metadata. Tag and Molang ingredients use the selected source descriptor. A real registry regression covers named wildcard logs and sticks alongside concrete tag-selected planks. Installed direct and ViaProxy controls each complete 11 server-accepted requests with matching normalized structures across routes. @@ -179,3 +183,11 @@ The native bundle action session resolves current server-owned roots and physica Desktop state 1 uses separate bundle predicates from touch state 45. Original controls cover 288 transition cases and 24 insertion-handler cases for input modes 1/3, tooltip modes 0/2, and absent retained primary sources. They establish activation entry and cursor/clicked source ownership with supplied model and admission services. The session exposes default extraction separately and tests its Take request, partial cursor merge, authoritative response, and full-cursor no-op. These instruction controls do not establish complete controller composition or rendering parity. The optional bundle-input channel accepts a displayed collection/index and input kind against the current opening, generation, revision, and shared recipe-command sequence. All item data and physical container references come from retained server state. Source, nested-content, and owner changes refresh the publication even when recipe previews stay unchanged. Boundary regressions cover stale inputs, late dynamic-container arrival, shared replay protection, and sparse workbench request slots with rejection rollback. Twenty-four additional original state-1 controls establish empty-cursor secondary extraction in tooltip mode 2; supplied admission and model services remain explicit limits. Installed direct/proxy controls verify selected desktop gestures; see the [inventory reference](../../../docs/native-player-inventory.md) for scope. The optional bundle-result reply preserves the original v1 player-result format for older clients. It distinguishes an attempted insertion failure from stale or unavailable input, so only the former requests failure audio. Successful insertion and default extraction request their native events; failed extraction stays silent. Protocol 2225 metadata supplies explicit binary values 446 (`bundle.insert`), 447 (`bundle.remove_one`), and 533 (`bundle.insert_fail`). Original Bedrock 1.26.60.5 handler, sound-helper, and position-helper controls establish level dispatch at the actor AABB center. Packet regressions cover negotiation and unavailable, failed, and successful outcomes. Installed direct and ViaProxy controls each observe seven matching native level events and decoded PCM channel starts, including a full-bundle insertion failure. Their fourteen submitted item requests receive successful server responses; the local capacity rejection submits no item request. Audible comparison, live server rejection rollback, and complete controller composition remain unverified. + +Selected extraction uses a separately negotiated version-2 channel. The original version-1 input remains unchanged. +Core resolves the displayed cell through the published bundle layout and retains physical slot identities through the existing request owner. +Regressions cover sparse slots, padding, the hidden-items label, full-grid cells beyond index 63, stale contents, and rejection recovery. +Cooking bundle commands retain the complete six-field source and share search, recipe, and hold request ordering. +Station aliases resolve physical slots without admitting crafting collections. Dynamic bundle contents retire old publications. +Regressions cover all source fields, rejection restoration, replacement openings, request replay, and replies that retain current authority. +Installed selected-cell extraction and native multiplayer grouping remain unverified. diff --git a/patches/viabedrock/deferred/0013-embed-completed-break-inventory-actions.patch b/patches/viabedrock/deferred/0013-embed-completed-break-inventory-actions.patch index 9338be5b..042a1bf6 100644 --- a/patches/viabedrock/deferred/0013-embed-completed-break-inventory-actions.patch +++ b/patches/viabedrock/deferred/0013-embed-completed-break-inventory-actions.patch @@ -1,4 +1,4 @@ -From 240f2b689bf42980800aceb4d0e812e684b849f4 Mon Sep 17 00:00:00 2001 +From f5b055e78a5bc1902bcb27bead6e5251fe9c5839 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] fix(mining): reconcile completed and creative block breaks @@ -368,7 +368,7 @@ index 16803a78..531a753c 100644 } 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 7bb2c3db..a20b0344 100644 +index 1c0a53d7..01b24d94 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryRequestTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryRequestTracker.java @@ -22,6 +22,7 @@ import com.viaversion.viaversion.api.connection.StoredObject; @@ -379,15 +379,15 @@ index 7bb2c3db..a20b0344 100644 import com.viaversion.viaversion.libs.fastutil.ints.Int2ObjectOpenHashMap; import net.raphimc.viabedrock.ViaBedrock; -@@ -75,6 +76,7 @@ public class InventoryRequestTracker extends StoredObject { - private CraftedItemSynchronization craftedItemSynchronization; +@@ -76,6 +77,7 @@ public class InventoryRequestTracker extends StoredObject { private TransferBatch transferBatch; private Set batchChanges; + private DeferredDragClose deferredDragClose; + private MineBlockRequest mineBlockRequest; public InventoryRequestTracker(final UserConnection user) { super(user); -@@ -235,6 +237,19 @@ public class InventoryRequestTracker extends StoredObject { +@@ -236,6 +238,19 @@ public class InventoryRequestTracker extends StoredObject { this.predictingClick = predictingClick; } @@ -406,8 +406,8 @@ index 7bb2c3db..a20b0344 100644 + public boolean queueClick(final Container container, final int revision, final short slot, final byte button, final ContainerInput action) { - return this.queueInteraction(new QueuedClick(container, revision, slot, button, action)); -@@ -371,6 +386,11 @@ public class InventoryRequestTracker extends StoredObject { + final var click = new QueuedClick(container, revision, slot, button, action); +@@ -431,6 +446,11 @@ public class InventoryRequestTracker extends StoredObject { PacketFactory.sendJavaContainerSetContent(this.user(), this.user().get(InventoryTracker.class).getInventoryContainer()); } @@ -419,7 +419,7 @@ index 7bb2c3db..a20b0344 100644 if (!this.hasPending()) { return; } -@@ -450,6 +470,13 @@ public class InventoryRequestTracker extends StoredObject { +@@ -510,6 +530,13 @@ public class InventoryRequestTracker extends StoredObject { } public void handleResponse(final ItemStackResponseInfo response) { @@ -433,7 +433,7 @@ index 7bb2c3db..a20b0344 100644 if (this.recovery.isActive() && this.requests.containsKey(response.requestId())) { // The original screen has been closed. Its response cannot restore a complete // player inventory or cursor, and its slot IDs may now refer to another screen. -@@ -676,6 +703,56 @@ public class InventoryRequestTracker extends StoredObject { +@@ -738,6 +765,56 @@ public class InventoryRequestTracker extends StoredObject { return item; } @@ -490,9 +490,9 @@ index 7bb2c3db..a20b0344 100644 void drainQueuedInteractions() { final InventoryTracker tracker = this.user().get(InventoryTracker.class); while (!this.hasPending() && tracker.getPendingCloseContainer() == null -@@ -692,6 +769,9 @@ public class InventoryRequestTracker extends StoredObject { - } - } +@@ -761,6 +838,9 @@ public class InventoryRequestTracker extends StoredObject { + + private record DeferredDragClose(Container container, long generation, Runnable close) { } + private record MineBlockRequest(int requestId, int hotbarSlot, int stackNetworkId, + long sentAtNanos) { } diff --git a/patches/viabedrock/deferred/0014-translate-bedrock-merchant-inventories-in-core.patch b/patches/viabedrock/deferred/0014-translate-bedrock-merchant-inventories-in-core.patch index bd04a0e8..18b2c5b9 100644 --- a/patches/viabedrock/deferred/0014-translate-bedrock-merchant-inventories-in-core.patch +++ b/patches/viabedrock/deferred/0014-translate-bedrock-merchant-inventories-in-core.patch @@ -1,4 +1,4 @@ -From 8bfba3bea4d5861d026fac79c1f2f67094639c34 Mon Sep 17 00:00:00 2001 +From 254277ac40db03a04451a605cf67f800af039380 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(trading): translate Bedrock merchant inventories in core @@ -36,7 +36,7 @@ owner and remove the duplicate merchant decoder. .../container/entity/MerchantContainer.java | 364 ++++++++++++++++++ .../viabedrock/protocol/BedrockProtocol.java | 1 + .../viabedrock/protocol/model/TradeOffer.java | 109 ++++++ - .../protocol/packet/InventoryPackets.java | 11 + + .../protocol/packet/InventoryPackets.java | 5 + .../protocol/packet/TradePackets.java | 164 ++++++++ .../storage/InventoryRequestTracker.java | 37 ++ .../protocol/storage/InventoryTracker.java | 19 + @@ -45,7 +45,7 @@ owner and remove the duplicate merchant decoder. .../protocol/model/TradeOfferTest.java | 108 ++++++ .../protocol/packet/TradePacketTest.java | 171 ++++++++ .../storage/InventoryRequestTrackerTest.java | 7 +- - 15 files changed, 1486 insertions(+), 5 deletions(-) + 15 files changed, 1480 insertions(+), 5 deletions(-) create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/InputContainer.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/InventoryPlacement.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/entity/MerchantContainer.java @@ -57,10 +57,10 @@ owner and remove the duplicate merchant decoder. create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/packet/TradePacketTest.java diff --git a/src/main/java/net/raphimc/viabedrock/api/model/container/Container.java b/src/main/java/net/raphimc/viabedrock/api/model/container/Container.java -index 1f414860..41d0a0e7 100644 +index 3e05f7c0..159fd77a 100644 --- a/src/main/java/net/raphimc/viabedrock/api/model/container/Container.java +++ b/src/main/java/net/raphimc/viabedrock/api/model/container/Container.java -@@ -723,7 +723,6 @@ public abstract class Container { +@@ -992,7 +992,6 @@ public abstract class Container { return action == null ? List.of() : List.of(action); } @@ -68,7 +68,7 @@ index 1f414860..41d0a0e7 100644 protected List quickMoveRanges(final short javaSlot, final SlotRef source) { final InventoryTracker inventoryTracker = this.user.get(InventoryTracker.class); final InventoryContainer inventory = inventoryTracker.getInventoryContainer(); -@@ -1225,10 +1224,12 @@ public abstract class Container { +@@ -1494,10 +1493,12 @@ public abstract class Container { } } @@ -884,18 +884,10 @@ index 00000000..ea4d9561 + +} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/InventoryPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/InventoryPackets.java -index 9125de3a..207470d4 100644 +index 39fef1ff..1d74b815 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/InventoryPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/InventoryPackets.java -@@ -80,6 +80,7 @@ import net.raphimc.viabedrock.api.model.container.NativeStorageContainers; - import net.raphimc.viabedrock.api.model.container.Container; - import net.raphimc.viabedrock.api.model.container.ContainerOpenContext; - import net.raphimc.viabedrock.api.model.container.dynamic.BundleContainer; -+import net.raphimc.viabedrock.api.model.container.InputContainer; - import net.raphimc.viabedrock.api.model.container.HorseContainer; - import net.raphimc.viabedrock.api.model.container.block.*; - import net.raphimc.viabedrock.api.model.container.player.InventoryContainer; -@@ -437,6 +438,11 @@ public final class InventoryPackets { +@@ -470,6 +470,11 @@ public final class InventoryPackets { return; } @@ -907,18 +899,6 @@ index 9125de3a..207470d4 100644 if (inventoryTracker.isAnyScreenOpen()) { ViaBedrock.getPlatform() .getLogger() -@@ -1299,6 +1305,11 @@ public final class InventoryPackets { - inventoryTracker.setCurrentContainerClosed(false); - return; - } -+ if (container instanceof InputContainer temporary) { -+ wrapper.cancel(); -+ temporary.beginClose(); -+ return; -+ } - inventoryTracker.markPendingClose(container); - }); - } diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/TradePackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/TradePackets.java new file mode 100644 index 00000000..cc516ae8 @@ -1090,7 +1070,7 @@ index 00000000..cc516ae8 + +} 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 658cdfff..91c611d4 100644 +index 8ba85e57..b3dd95e2 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryRequestTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryRequestTracker.java @@ -28,6 +28,8 @@ import com.viaversion.viaversion.libs.fastutil.ints.Int2ObjectOpenHashMap; @@ -1110,8 +1090,8 @@ index 658cdfff..91c611d4 100644 import net.raphimc.viabedrock.protocol.types.BedrockTypes; import java.util.ArrayDeque; -@@ -409,6 +412,14 @@ public class InventoryRequestTracker extends StoredObject { - }); +@@ -451,6 +454,14 @@ public class InventoryRequestTracker extends StoredObject { + } } + public boolean queueContainerClose(final InputContainer container) { @@ -1125,7 +1105,7 @@ index 658cdfff..91c611d4 100644 public boolean queueButtonClick(final Container container, final int button) { return this.queueInteraction(new QueuedButtonClick(container, button)); } -@@ -636,6 +647,16 @@ public class InventoryRequestTracker extends StoredObject { +@@ -682,6 +693,16 @@ public class InventoryRequestTracker extends StoredObject { } } @@ -1142,7 +1122,7 @@ index 658cdfff..91c611d4 100644 if (this.batchChanges != null) { this.batchChanges.addAll(changed); return; -@@ -1038,6 +1059,22 @@ public class InventoryRequestTracker extends StoredObject { +@@ -1090,6 +1111,22 @@ public class InventoryRequestTracker extends StoredObject { } } diff --git a/patches/viabedrock/deferred/0015-translate-bedrock-loom-inventories-in-core.patch b/patches/viabedrock/deferred/0015-translate-bedrock-loom-inventories-in-core.patch index fecf70a3..a91a90dc 100644 --- a/patches/viabedrock/deferred/0015-translate-bedrock-loom-inventories-in-core.patch +++ b/patches/viabedrock/deferred/0015-translate-bedrock-loom-inventories-in-core.patch @@ -1,4 +1,4 @@ -From 31a60bc7458a65a9996fa179f44258fc6fcbf723 Mon Sep 17 00:00:00 2001 +From bc4fb87ba7418d395f50fbfc0d6349f0e553d049 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(inventory): translate Bedrock loom inventories in core @@ -15,19 +15,42 @@ CraftResults actions, consumption of the banner and dye, and placement of CreatedOutput slot 50 into the cursor. Native hh and cre crafts succeed. Close returns inputs and the cursor before sending ContainerClose using the shared acknowledged input-container lifecycle. + +Project shield Base and Patterns through the same item-data path as banners. +Map Bedrock dye IDs to Java base_color and ordered banner_patterns without +changing the retained native stack. Leave plain shields without a dyed base. + +An owned 1.26.60.6 BDS fixture supplies Base int 1 and a white bottom stripe +to official Bedrock 1.26.60.5/protocol 2225. Native shows the red patterned +front and restores the plain appearance when the tags are removed. Both +Java routes previously received those tags with no base_color and empty +banner_patterns. Three regressions exercise the actual item-data handler, +registry order, native-tag preservation, ordinary banners, plain shields, +unrelated items and invalid base colors. Native atlas/material rendering +remains a separate add-on responsibility. + +Current 1.26.60.5 native functions 141aefa60, 146e9bcc0 and 146e9c960 +establish the shield-specific decoding rules: inspect the first six NBT +pattern entries, resolve unknown patterns as base, treat Base -1 as white, +use black when Base is absent, and map out-of-range byte color IDs to +purple. Preserve those rules in the shield projection while retaining the +existing banner API. Native registry and read-only draw observations +supply the pattern names and colors independently of Java components. --- .../model/container/block/LoomContainer.java | 219 ++++++++++++++++++ .../model/container/block/LoomPatterns.java | 89 +++++++ .../generated/bedrock/CustomBlockTags.java | 2 + .../protocol/packet/InventoryPackets.java | 1 + - .../protocol/rewriter/ItemDataRewriter.java | 27 +++ + .../protocol/rewriter/ItemDataRewriter.java | 42 ++++ .../viabedrock/data/custom/block_tags.json | 3 + .../assets/viabedrock/data/java/tags.nbt | Bin 20287 -> 19725 bytes .../container/block/LoomPatternsTest.java | 177 ++++++++++++++ - 8 files changed, 518 insertions(+) + .../protocol/rewriter/ShieldItemDataTest.java | 146 ++++++++++++ + 9 files changed, 679 insertions(+) create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/block/LoomContainer.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/block/LoomPatterns.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/container/block/LoomPatternsTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/rewriter/ShieldItemDataTest.java diff --git a/src/main/java/net/raphimc/viabedrock/api/model/container/block/LoomContainer.java b/src/main/java/net/raphimc/viabedrock/api/model/container/block/LoomContainer.java new file mode 100644 @@ -380,7 +403,7 @@ index 207470d4..05c17d96 100644 container = new StonecutterContainer( diff --git a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemDataRewriter.java b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemDataRewriter.java -index 1baab221..cf4d3aa8 100644 +index 19799a9c..23d8e3d3 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemDataRewriter.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemDataRewriter.java @@ -26,6 +26,9 @@ import com.viaversion.viaversion.api.minecraft.data.StructuredDataKey; @@ -393,20 +416,28 @@ index 1baab221..cf4d3aa8 100644 import com.viaversion.viaversion.api.minecraft.item.data.DyedColor; import com.viaversion.viaversion.api.minecraft.item.data.Enchantments; import com.viaversion.viaversion.api.minecraft.item.data.EnumTypes; -@@ -237,6 +240,24 @@ public final class ItemDataRewriter { +@@ -238,6 +241,32 @@ public final class ItemDataRewriter { }; } + public static BannerPatternLayer[] bannerPatterns(final CompoundTag tag, final CompoundTag registry, + final Map mappings) { ++ return bannerPatterns(tag, registry, mappings, false); ++ } ++ ++ private static BannerPatternLayer[] bannerPatterns(final CompoundTag tag, final CompoundTag registry, ++ final Map mappings, final boolean shield) { + final ListTag patterns = tag.getListTag("Patterns", CompoundTag.class); + if (patterns == null) { + return new BannerPatternLayer[0]; + } + final List result = new ArrayList<>(); -+ for (final CompoundTag layer : patterns) { -+ final String name = mappings.get(layer.getString("Pattern", "")); -+ final DyeColor color = DyeColor.getByBedrockId(layer.getInt("Color", DyeColor.BLACK.bedrockId())); ++ for (int index = 0; index < (shield ? Math.min(6, patterns.size()) : patterns.size()); index++) { ++ final CompoundTag layer = patterns.get(index); ++ final String code = layer.getString("Pattern", ""); ++ final String name = shield ? mappings.getOrDefault(code, "minecraft:base") : mappings.get(code); ++ final int value = layer.getInt("Color", DyeColor.BLACK.bedrockId()); ++ final DyeColor color = shield ? DyeColor.getByBedrockId(value & 255, DyeColor.PURPLE) : DyeColor.getByBedrockId(value); + final int id = name == null ? -1 : registryIndex(registry, name); + if (id >= 0 && color != null) { + result.add(new BannerPatternLayer(Holder.of(id), color.javaId())); @@ -418,14 +449,21 @@ index 1baab221..cf4d3aa8 100644 // BedrockTag can be null public static void handleItem(final UserConnection user, final String identifier, final BedrockItem bedrockItem, final CompoundTag bedrockTag, final Item javaItem) { -@@ -266,6 +287,12 @@ public final class ItemDataRewriter { +@@ -267,6 +296,19 @@ public final class ItemDataRewriter { } } -+ if (identifier.equals("minecraft:banner")) { ++ if (identifier.equals("minecraft:banner") || identifier.equals("minecraft:shield")) { + javaItem.dataContainer().set(StructuredDataKey.BANNER_PATTERNS, + bannerPatterns(bedrockTag, BedrockProtocol.MAPPINGS.getJavaRegistries().getCompoundTag(RegistryKeys.BANNER_PATTERN), -+ BedrockProtocol.MAPPINGS.getBedrockToJavaBannerPatterns())); ++ BedrockProtocol.MAPPINGS.getBedrockToJavaBannerPatterns(), identifier.equals("minecraft:shield"))); ++ final var patterns = bedrockTag.getListTag("Patterns", CompoundTag.class); ++ if (identifier.equals("minecraft:shield") && (bedrockTag.get("Base") instanceof IntTag ++ || patterns != null && !patterns.isEmpty())) { ++ final int base = bedrockTag.getInt("Base", 0); ++ final DyeColor color = DyeColor.getByBedrockId(base == -1 ? 15 : base & 255, DyeColor.PURPLE); ++ javaItem.dataContainer().set(StructuredDataKey.BASE_COLOR, (int) color.javaId()); ++ } + } + if (DYED_LEATHER_ARMOR_ITEMS.contains(identifier) @@ -1398,5 +1436,157 @@ index 00000000..ab6bc3cb + } + +} +diff --git a/src/test/java/net/raphimc/viabedrock/protocol/rewriter/ShieldItemDataTest.java b/src/test/java/net/raphimc/viabedrock/protocol/rewriter/ShieldItemDataTest.java +new file mode 100644 +index 00000000..ab93628f +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/protocol/rewriter/ShieldItemDataTest.java +@@ -0,0 +1,146 @@ ++/* ++ * 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.protocol.rewriter; ++ ++import com.google.common.collect.ImmutableBiMap; ++import com.viaversion.nbt.tag.CompoundTag; ++import com.viaversion.nbt.tag.ListTag; ++import com.viaversion.viaversion.api.data.FullMappings; ++import com.viaversion.viaversion.api.minecraft.data.StructuredDataContainer; ++import com.viaversion.viaversion.api.minecraft.data.StructuredDataKey; ++import com.viaversion.viaversion.api.minecraft.item.Item; ++import com.viaversion.viaversion.api.minecraft.item.StructuredItem; ++import net.raphimc.viabedrock.protocol.BedrockProtocol; ++import net.raphimc.viabedrock.protocol.model.BedrockItem; ++import org.junit.jupiter.api.AfterEach; ++import org.junit.jupiter.api.BeforeEach; ++import org.junit.jupiter.api.Test; ++ ++import java.lang.reflect.Field; ++import java.lang.reflect.Proxy; ++import java.util.LinkedHashMap; ++import java.util.Map; ++ ++import static org.junit.jupiter.api.Assertions.assertEquals; ++import static org.junit.jupiter.api.Assertions.assertNull; ++ ++class ShieldItemDataTest { ++ ++ private final Map previousMappings = new LinkedHashMap<>(); ++ ++ @BeforeEach ++ void installRegistry() throws Exception { ++ final CompoundTag patterns = new CompoundTag(); ++ patterns.put("minecraft:stripe_top", new CompoundTag()); ++ patterns.put("minecraft:stripe_bottom", new CompoundTag()); ++ patterns.put("minecraft:base", new CompoundTag()); ++ final CompoundTag registries = new CompoundTag(); ++ registries.put("minecraft:banner_pattern", patterns); ++ this.mapping("javaRegistries", registries); ++ this.mapping("bedrockToJavaBannerPatterns", ImmutableBiMap.of( ++ "bs", "minecraft:stripe_bottom", "ts", "minecraft:stripe_top")); ++ } ++ ++ @AfterEach ++ void restoreRegistry() throws Exception { ++ for (final var entry : this.previousMappings.entrySet()) { ++ entry.getKey().set(BedrockProtocol.MAPPINGS, entry.getValue()); ++ } ++ } ++ ++ @Test ++ void shieldCarriesItsBaseColorAndOrderedPatternsWithoutChangingNativeData() throws Exception { ++ // The 1.26.60.5 native fixture shows a red front with a white bottom stripe. ++ final CompoundTag tag = patternedTag(); ++ final CompoundTag before = tag.copy(); ++ final Item result = convert("minecraft:shield", tag); ++ assertEquals(14, result.dataContainer().get(StructuredDataKey.BASE_COLOR)); ++ final var layers = result.dataContainer().get(StructuredDataKey.BANNER_PATTERNS); ++ assertEquals(2, layers.length); ++ assertEquals(1, layers[0].pattern().id()); ++ assertEquals(0, layers[0].dyeColor()); ++ assertEquals(0, layers[1].pattern().id()); ++ assertEquals(11, layers[1].dyeColor()); ++ assertEquals(before, tag); ++ } ++ ++ @Test ++ void plainShieldsAndUnrelatedItemsKeepTheirUndyedBase() throws Exception { ++ assertNull(convert("minecraft:shield", null).dataContainer().get(StructuredDataKey.BASE_COLOR)); ++ final Item plain = convert("minecraft:shield", new CompoundTag()); ++ assertNull(plain.dataContainer().get(StructuredDataKey.BASE_COLOR)); ++ assertEquals(0, plain.dataContainer().get(StructuredDataKey.BANNER_PATTERNS).length); ++ final Item other = convert("minecraft:stone", patternedTag()); ++ assertNull(other.dataContainer().get(StructuredDataKey.BASE_COLOR)); ++ assertNull(other.dataContainer().get(StructuredDataKey.BANNER_PATTERNS)); ++ final Item banner = convert("minecraft:banner", patternedTag()); ++ assertNull(banner.dataContainer().get(StructuredDataKey.BASE_COLOR)); ++ assertEquals(2, banner.dataContainer().get(StructuredDataKey.BANNER_PATTERNS).length); ++ } ++ ++ @Test ++ void matchesNativeSentinelDefaultAndInvalidColorsBeforeLimitingSourcePatterns() throws Exception { ++ final CompoundTag tag = patternedTag(); ++ tag.putInt("Base", -1); ++ assertEquals(0, convert("minecraft:shield", tag).dataContainer().get(StructuredDataKey.BASE_COLOR)); ++ tag.putInt("Base", 16); ++ assertEquals(10, convert("minecraft:shield", tag).dataContainer().get(StructuredDataKey.BASE_COLOR)); ++ tag.remove("Base"); ++ assertEquals(15, convert("minecraft:shield", tag).dataContainer().get(StructuredDataKey.BASE_COLOR)); ++ final ListTag patterns = new ListTag<>(CompoundTag.class); ++ for (int i = 0; i < 7; i++) { ++ final var layer = new CompoundTag(); ++ layer.putString("Pattern", i == 0 ? "unknown" : "bs"); ++ layer.putInt("Color", i == 0 ? 16 : 15); ++ patterns.add(layer); ++ } ++ tag.put("Patterns", patterns); ++ final var layers = convert("minecraft:shield", tag).dataContainer().get(StructuredDataKey.BANNER_PATTERNS); ++ assertEquals(6, layers.length); ++ assertEquals(2, layers[0].pattern().id()); ++ assertEquals(10, layers[0].dyeColor()); ++ for (int i = 1; i < layers.length; i++) { ++ assertEquals(1, layers[i].pattern().id()); ++ assertEquals(0, layers[i].dyeColor()); ++ } ++ } ++ ++ private void mapping(final String name, final Object value) throws Exception { ++ final Field field = BedrockProtocol.MAPPINGS.getClass().getDeclaredField(name); ++ field.setAccessible(true); ++ this.previousMappings.put(field, field.get(BedrockProtocol.MAPPINGS)); ++ field.set(BedrockProtocol.MAPPINGS, value); ++ } ++ ++ private static Item convert(final String identifier, final CompoundTag tag) throws Exception { ++ final StructuredDataContainer data = new StructuredDataContainer(); ++ final FullMappings mappings = (FullMappings) Proxy.newProxyInstance(FullMappings.class.getClassLoader(), ++ new Class[]{FullMappings.class}, (proxy, method, arguments) -> 0); ++ final Field lookup = StructuredDataContainer.class.getDeclaredField("lookup"); ++ lookup.setAccessible(true); ++ lookup.set(data, mappings); ++ final Item result = new StructuredItem(1, 1, data); ++ ItemDataRewriter.handleItem(null, identifier, new BedrockItem(358, (short) 0, (byte) 1, tag), tag, result); ++ return result; ++ } ++ ++ private static CompoundTag patternedTag() { ++ final CompoundTag tag = new CompoundTag(); ++ tag.putInt("Base", 1); ++ final ListTag layers = new ListTag<>(CompoundTag.class); ++ final CompoundTag bottom = new CompoundTag(); ++ bottom.putString("Pattern", "bs"); ++ bottom.putInt("Color", 15); ++ layers.add(bottom); ++ final CompoundTag top = new CompoundTag(); ++ top.putString("Pattern", "ts"); ++ top.putInt("Color", 4); ++ layers.add(top); ++ tag.put("Patterns", layers); ++ return tag; ++ } ++ ++} -- 2.56.0 diff --git a/patches/viabedrock/deferred/0015-translate-bedrock-loom-inventories-in-core.pr.md b/patches/viabedrock/deferred/0015-translate-bedrock-loom-inventories-in-core.pr.md index 539112af..db494483 100644 --- a/patches/viabedrock/deferred/0015-translate-bedrock-loom-inventories-in-core.pr.md +++ b/patches/viabedrock/deferred/0015-translate-bedrock-loom-inventories-in-core.pr.md @@ -31,3 +31,14 @@ A live Java client through ViaProxy confirmed screen opening, colored previews, All four build targets passed before the loom mapping corrections. ViaBedrock and ViaProxy were rebuilt and tested after those corrections. Native comparison of the layer limit, legacy pattern-item variants, and ominous banner rendering remains pending. This patch does not complete the other workstation screens or broader inventory parity. This change extends the inventory implementation covered by [ViaBedrock #276](https://github.com/ViaVersionAddons/ViaBedrock/pull/276). + +## Shield appearance data + +The same item-data handler projects shield base colors and ordered pattern layers without changing the retained native stack. +An owned BDS 1.26.60.6 fixture delivers a red base and white bottom stripe to native 1.26.60.5 and both Java routes. +Installed component observations preserve the plain, patterned, and plain sequence. + +Current native decoding inspects six source patterns. Unknown names select the base pattern, and invalid byte colors select purple. +A missing base selects black when patterns exist. The native -1 base sentinel selects white. +Handler regressions cover these rules and preserve ordinary banner behavior. +The [add-on equipment patch](../../viafabricplus-bedrock/upstreamable/0017-render-equipped-native-pack-models.pr.md) owns native atlas and material rendering. diff --git a/patches/viabedrock/deferred/0016-synchronize-glide-rockets-with-auth-input.patch b/patches/viabedrock/deferred/0016-synchronize-glide-rockets-with-auth-input.patch index 830db6a8..5de713f1 100644 --- a/patches/viabedrock/deferred/0016-synchronize-glide-rockets-with-auth-input.patch +++ b/patches/viabedrock/deferred/0016-synchronize-glide-rockets-with-auth-input.patch @@ -1,4 +1,4 @@ -From b4d843ac3da79844de4cdf07cdc0ebd092c85b09 Mon Sep 17 00:00:00 2001 +From 46e5d41e7ce4f26c7a717047cda1177c614444be 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] fix(interaction): preserve native air-use prediction and @@ -31,8 +31,8 @@ Allocate legacy even request IDs from the inventory owner shared with native block placement. Keep stack-request IDs independent and verify interleaved placement/rocket allocation without a second legacy counter. -Extend the same legacy consumption envelope to pearl and wind-charge air -use. Native Bedrock 1.26.60.5 protocol 2225 captures against local BDS 1.26.60.6 +Extend the same legacy consumption envelope to locally consumed pearl +and wind-charge air use. Native Bedrock 1.26.60.5 protocol 2225 captures against local BDS 1.26.60.6 carry a negative even request ID, inventory slot declaration and one-item debit for both. Held and action descriptors omit network IDs. Retain the Unknown trigger, Failure prediction and Off cooldown fields from native. @@ -62,16 +62,56 @@ A repeated-use control with descriptor prediction alone sent 64-to-63 twice and the server accepted only two of three attempted throws. The packet-to- inventory regression now checks repeated debits, preserved local identity, final-stack removal and send-before-commit ordering. + +Accept a sequence-owned local stack-consumption report before ordinary +Java item use. Emit a legacy debit only when local prediction consumed +the item. Keep air use, omitted descriptor network IDs, and Off cooldown +independent of that debit. An absent report preserves older client +behavior. Reports for another sequence cannot select a later prediction. + +Original Bedrock 1.26.60.5 GameMode controls record four-to-three and +three-to-two inventory actions on successful pearl use. Cooldown-refused +calls still dispatch but record no action or legacy slot declaration. +Java also sends its use packet while its local cooldown rejects the +callback. An unconditional debit lets BDS accept a near-expiry request +without an inventory response while Java retains its old count. Match +the native local action boundary instead of predicting every attempt. + +The regression keeps a refused request unpredicted without advancing +the legacy counter, then verifies the next successful native debit. +It fails before the correction and retains the older-client fallback, +flight ordering, descriptor identity and final-stack tests. + +Publish each immediate core-owned throwable debit to the Java inventory. +Java 26.3 MultiPlayerGameMode sends its use packet even when the local +cooldown gate skips item consumption. BDS can accept +the native legacy prediction without returning another quantity packet. +Updating only core then leaves Java's count stale until unrelated authority. + +A complete protocol2225/BDS1.26.60.6 direct capture records a final3-to-2 +pearl declaration, a renewed cooldown and no quantity update for ten +seconds. BDS settles at2 while Java retains3. The packet regression starts +with that retained Java stack and fails before the publication fix. It +also covers an exhausted final stack. Preserve transaction-before-debit +ordering and original stack identity, and let later server updates replace +predictions. Input continuation and native cooldown fields stay independent. + +Retain quantity publication for real debits and clients without the new +local prediction report. A modern cooldown-refused report carries no +action, so it must neither debit core nor publish a temporary lower Java +quantity. The regression also guards this no-publication boundary. --- .../api/model/entity/ClientPlayerEntity.java | 5 + .../protocol/packet/ClientPlayerPackets.java | 21 +- - .../protocol/packet/InteractionPackets.java | 115 ++++++--- + .../protocol/packet/InteractionPackets.java | 135 +++++--- .../InventoryTransactionRewriter.java | 10 +- - .../InventoryTransactionPacketType.java | 50 ++-- - .../packet/ClientPlayerPacketsTest.java | 90 +++++++ - .../packet/InteractionPacketsTest.java | 236 ++++++++++++++++++ - .../InventoryTransactionPacketTypeTest.java | 143 +++++++++++ - 8 files changed, 615 insertions(+), 55 deletions(-) + .../InventoryTransactionPacketType.java | 50 +-- + .../packet/ClientPlayerPacketsTest.java | 90 ++++++ + .../packet/InteractionPacketsTest.java | 287 ++++++++++++++++++ + .../packet/ThrowablePredictionPacketTest.java | 100 ++++++ + .../InventoryTransactionPacketTypeTest.java | 143 +++++++++ + 9 files changed, 785 insertions(+), 56 deletions(-) + create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/packet/ThrowablePredictionPacketTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/types/inventory/InventoryTransactionPacketTypeTest.java diff --git a/src/main/java/net/raphimc/viabedrock/api/model/entity/ClientPlayerEntity.java b/src/main/java/net/raphimc/viabedrock/api/model/entity/ClientPlayerEntity.java @@ -151,20 +191,28 @@ index 531a753c..eed50df5 100644 protocol.registerServerbound(ServerboundPackets26_3.PLAYER_ABILITIES, null, wrapper -> { wrapper.cancel(); diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/InteractionPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/InteractionPackets.java -index b1ffa95c..567b27df 100644 +index a1d9558a..9b4b804a 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/InteractionPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/InteractionPackets.java -@@ -21,6 +21,7 @@ import com.viaversion.viaversion.api.minecraft.BlockFace; +@@ -21,11 +21,15 @@ import com.viaversion.viaversion.api.minecraft.BlockFace; import com.viaversion.viaversion.api.minecraft.BlockPosition; import com.viaversion.viaversion.api.connection.UserConnection; import com.viaversion.viaversion.api.protocol.packet.PacketWrapper; +import com.viaversion.viaversion.api.type.Type; import com.viaversion.viaversion.api.type.Types; ++import com.viaversion.viaversion.api.type.types.version.VersionedTypes; import com.viaversion.viaversion.protocols.v26_2to26_3.packet.ClientboundPackets26_3; import com.viaversion.viaversion.protocols.v26_2to26_3.packet.ServerboundPackets26_3; -@@ -68,6 +69,9 @@ import java.util.logging.Level; + import net.raphimc.viabedrock.ViaBedrock; + import net.raphimc.viabedrock.api.model.BlockUsePrediction; ++import net.raphimc.viabedrock.api.model.ItemUsePrediction; ++import net.raphimc.viabedrock.protocol.storage.PlayerInputStorage; + import net.raphimc.viabedrock.api.model.container.Container; + import net.raphimc.viabedrock.api.model.container.player.InventoryContainer; + import net.raphimc.viabedrock.api.model.entity.ClientPlayerEntity; +@@ -71,6 +75,9 @@ public final class InteractionPackets { - public final class InteractionPackets { + private static final String ITEM_USE_STOP_CHANNEL = "viabedrock:item_use_stop"; + private static final Set CONSUMING_AIR_USE_ITEMS = Set.of( + "minecraft:ender_pearl", "minecraft:wind_charge"); @@ -172,7 +220,17 @@ index b1ffa95c..567b27df 100644 private static final Set CONTINUOUS_USE_ITEMS = Set.of( "minecraft:bow", "minecraft:crossbow", "minecraft:trident", "minecraft:potion", "minecraft:milk_bucket", "minecraft:ominous_bottle", -@@ -294,7 +298,8 @@ public final class InteractionPackets { +@@ -267,7 +274,8 @@ public final class InteractionPackets { + final InventoryTransactionRewriter inventoryTransactionRewriter = wrapper.user().get(InventoryTransactionRewriter.class); + + final int javaHand = wrapper.read(Types.VAR_INT); // hand +- wrapper.read(Types.VAR_INT); // sequence ++ final int sequence = wrapper.read(Types.VAR_INT); // sequence ++ final ItemUsePrediction prediction = wrapper.user().get(PlayerInputStorage.class).takeItemUsePrediction(sequence); + wrapper.read(Types.FLOAT); // yaw + wrapper.read(Types.FLOAT); // pitch + +@@ -297,9 +305,10 @@ public final class InteractionPackets { ? canStartFoodUse(clientPlayer, food, wrapper.user().get(GameSessionStorage.class)) : isContinuousUseItem(itemIdentifier, itemIdentifier == null ? Set.of() : BedrockProtocol.MAPPINGS.getBedrockItemTags().getOrDefault(itemIdentifier, Set.of())); @@ -180,9 +238,12 @@ index b1ffa95c..567b27df 100644 + final BedrockInventoryTransaction transaction = beginItemUse(clientPlayer, + wrapper.user().get(InventoryTracker.class), hand, inventoryContainer.getSelectedHotbarSlot(), usedItem, itemIdentifier, - startUsingItem); +- startUsingItem); ++ startUsingItem, prediction); if (transaction == null) { -@@ -304,7 +309,7 @@ public final class InteractionPackets { + wrapper.cancel(); + if (hand == InteractionHand.OFF_HAND && !"minecraft:shield".equals(itemIdentifier)) { +@@ -307,7 +316,7 @@ public final class InteractionPackets { } return; } @@ -191,7 +252,7 @@ index b1ffa95c..567b27df 100644 }); protocol.registerServerbound(ServerboundPackets26_3.USE_ITEM_ON, null, wrapper -> { -@@ -328,22 +333,20 @@ public final class InteractionPackets { +@@ -331,22 +340,20 @@ public final class InteractionPackets { PacketFactory.sendJavaBlockChangedAck(wrapper.user(), sequence); return; } @@ -217,7 +278,7 @@ index b1ffa95c..567b27df 100644 final InventoryTracker inventoryTracker = wrapper.user().get(InventoryTracker.class); final BedrockItem usedItem = inventoryTracker.getInventoryContainer().getSelectedHotbarItem(); if (usedItem.hasBlockState()) { -@@ -422,31 +425,23 @@ public final class InteractionPackets { +@@ -435,32 +442,37 @@ public final class InteractionPackets { return hunger == null || food.allowsUse(hunger.computeClampedValue(), hunger.computeMaxValue(), ignoresHunger); } @@ -231,16 +292,6 @@ index b1ffa95c..567b27df 100644 - source.user()); - writeJavaPassengers(passengersPacket, entityTracker, vehicle); - passengersPacket.send(BedrockProtocol.class); -- } -- } -- -- private static void writeJavaPassengers(final PacketWrapper wrapper, -- final EntityTracker entityTracker, final Entity vehicle) { -- final List passengers = entityTracker.getTrackedPassengers(vehicle); -- wrapper.write(Types.VAR_INT, vehicle.javaId()); // vehicle -- wrapper.write(Types.VAR_INT, passengers.size()); // number of passengers -- for (Entity passenger : passengers) { -- wrapper.write(Types.VAR_INT, passenger.javaId()); // passenger id + static void writeItemUse(final PacketWrapper wrapper, final Type transactionType, + final BedrockInventoryTransaction transaction, final InventoryContainer inventory) { + wrapper.write(transactionType, transaction); @@ -254,27 +305,51 @@ index b1ffa95c..567b27df 100644 + final BedrockItem remainder = inventory.getItem(debit.slot()).copy(); + remainder.setAmount(debit.toItem().amount()); + inventory.setItem(debit.slot(), remainder.isEmpty() ? BedrockItem.empty() : remainder); ++ // Java can send use while cooldown suppresses its local debit. Publish the core-owned prediction. ++ final PacketWrapper quantity = PacketWrapper.create(ClientboundPackets26_3.CONTAINER_SET_SLOT, wrapper.user()); ++ quantity.write(Types.VAR_INT, (int) inventory.javaContainerId()); ++ quantity.write(Types.VAR_INT, 0); ++ quantity.write(Types.SHORT, (short) inventory.javaSlot(debit.slot())); ++ quantity.write(VersionedTypes.V26_3.item, inventory.getJavaItem(debit.slot())); ++ quantity.send(BedrockProtocol.class); + } + +- private static void writeJavaPassengers(final PacketWrapper wrapper, +- final EntityTracker entityTracker, final Entity vehicle) { +- final List passengers = entityTracker.getTrackedPassengers(vehicle); +- wrapper.write(Types.VAR_INT, vehicle.javaId()); // vehicle +- wrapper.write(Types.VAR_INT, passengers.size()); // number of passengers +- for (Entity passenger : passengers) { +- wrapper.write(Types.VAR_INT, passenger.javaId()); // passenger id +- } ++ static BedrockInventoryTransaction beginItemUse(final ClientPlayerEntity player, ++ final InventoryTracker inventory, final InteractionHand hand, final int hotbarSlot, final BedrockItem item, ++ final String identifier, final boolean startUsingItem) { ++ return beginItemUse(player, inventory, hand, hotbarSlot, item, identifier, startUsingItem, null); } static BedrockInventoryTransaction beginItemUse(final ClientPlayerEntity player, - final InteractionHand hand, final int hotbarSlot, final BedrockItem item, +- final String identifier, final boolean startUsingItem) { + final InventoryTracker inventory, final InteractionHand hand, final int hotbarSlot, final BedrockItem item, - final String identifier, final boolean startUsingItem) { ++ final String identifier, final boolean startUsingItem, final ItemUsePrediction prediction) { if (item.isEmpty()) { return null; -@@ -462,13 +457,67 @@ public final class InteractionPackets { + } +@@ -475,13 +487,68 @@ public final class InteractionPackets { if (startUsingItem) { player.addAuthInputData(PlayerAuthInputData.StartUsingItem); } - return new BedrockInventoryTransaction( - 0, null, null, ComplexInventoryTransaction_Type.ItemUseTransaction, + final boolean flightRocket = isFlightRocket(identifier, player.isGliding()); -+ final boolean predictsConsumption = flightRocket || (identifier != null && CONSUMING_AIR_USE_ITEMS.contains(identifier)); ++ final boolean consumingAirUse = flightRocket || (identifier != null && CONSUMING_AIR_USE_ITEMS.contains(identifier)); ++ final boolean predictsConsumption = consumingAirUse && (prediction == null || prediction.consumed()); + int legacyRequestId = 0; + List legacySlots = null; + List actions = null; + final BedrockItem heldItem = item.copy(); -+ if (predictsConsumption) { ++ if (consumingAirUse) { + heldItem.setNetId(null); + } + if (predictsConsumption && player.javaGameMode() != GameMode.CREATIVE) { @@ -648,14 +723,19 @@ index 28f67a41..cde0169a 100644 + } diff --git a/src/test/java/net/raphimc/viabedrock/protocol/packet/InteractionPacketsTest.java b/src/test/java/net/raphimc/viabedrock/protocol/packet/InteractionPacketsTest.java -index 0bf1b705..1cdc17f1 100644 +index 0bf1b705..2ae204e0 100644 --- a/src/test/java/net/raphimc/viabedrock/protocol/packet/InteractionPacketsTest.java +++ b/src/test/java/net/raphimc/viabedrock/protocol/packet/InteractionPacketsTest.java -@@ -9,11 +9,39 @@ +@@ -9,15 +9,82 @@ */ package net.raphimc.viabedrock.protocol.packet; +import com.viaversion.viaversion.api.connection.UserConnection; ++import com.viaversion.viaversion.api.minecraft.data.StructuredDataContainer; ++import com.viaversion.viaversion.api.minecraft.data.StructuredDataKey; ++import com.viaversion.viaversion.api.minecraft.item.Item; ++import com.viaversion.viaversion.api.minecraft.item.StructuredItem; ++import com.viaversion.viaversion.protocols.v26_2to26_3.packet.ClientboundPackets26_3; +import com.viaversion.viaversion.api.protocol.packet.PacketWrapper; +import net.raphimc.viabedrock.api.model.container.player.InventoryContainer; +import net.raphimc.viabedrock.protocol.model.inventory.BedrockInventoryTransaction; @@ -677,6 +757,7 @@ index 0bf1b705..1cdc17f1 100644 +import net.raphimc.viabedrock.protocol.storage.InventoryRequestTracker; +import net.raphimc.viabedrock.protocol.storage.InventoryTracker; +import net.raphimc.viabedrock.api.model.BlockUsePrediction; ++import net.raphimc.viabedrock.api.model.ItemUsePrediction; import org.junit.jupiter.api.Test; +import java.lang.reflect.Proxy; @@ -689,9 +770,46 @@ index 0bf1b705..1cdc17f1 100644 +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; ++import static org.mockito.Mockito.mock; ++import static org.mockito.Mockito.mockStatic; class InteractionPacketsTest { -@@ -28,4 +56,212 @@ class InteractionPacketsTest { + ++ @Test ++ void refusedLocalPredictionSendsAirUseWithoutDebitingOrAllocatingLegacyIds() { ++ final ClientPlayerEntity player = player(); ++ final InventoryTracker inventory = new InventoryTracker(user()); ++ final BedrockItem item = new BedrockItem(1); ++ item.setAmount(4); ++ item.setNetId(27); ++ final var refused = InteractionPackets.beginItemUse(player, inventory, InteractionHand.MAIN_HAND, ++ 0, item, "minecraft:ender_pearl", false, new ItemUsePrediction(7, false)); ++ assertEquals(0, refused.legacyRequestId()); ++ assertNull(refused.actions()); ++ assertNull(refused.legacySlots()); ++ final var held = (InventoryTransactionData.UseItemTransactionData) refused.transactionData(); ++ assertEquals(4, held.itemInHand().amount()); ++ assertNull(held.itemInHand().netId()); ++ assertEquals(4, item.amount()); ++ assertEquals(27, item.netId()); ++ final PacketWrapper nativePacket = mock(PacketWrapper.class); ++ try (var javaPackets = mockStatic(PacketWrapper.class)) { ++ InteractionPackets.writeItemUse(nativePacket, new InventoryTransactionPacketType(() -> null, null), ++ refused, inventory.getInventoryContainer()); ++ javaPackets.verifyNoInteractions(); ++ } ++ ++ final var accepted = InteractionPackets.beginItemUse(player, inventory, InteractionHand.MAIN_HAND, ++ 0, item, "minecraft:ender_pearl", false, new ItemUsePrediction(8, true)); ++ assertEquals(-4, accepted.legacyRequestId()); ++ assertEquals(4, accepted.actions().getFirst().fromItem().amount()); ++ assertEquals(3, accepted.actions().getFirst().toItem().amount()); ++ } ++ + @Test + void sustainedUseSignalsCoverFoodAndChargeItemsWithoutInstantRockets() { + assertTrue(InteractionPackets.isContinuousUseItem("minecraft:apple", Set.of("minecraft:is_food"))); +@@ -28,4 +95,224 @@ class InteractionPacketsTest { assertFalse(InteractionPackets.isContinuousUseItem(null, Set.of())); } @@ -819,77 +937,89 @@ index 0bf1b705..1cdc17f1 100644 + } + + @Test -+ void throwableUsePreservesNativeConsumptionWhenSwitchingHotbarSlots() { -+ final ClientPlayerEntity player = player(); -+ final InventoryTracker inventory = new InventoryTracker(user()); -+ final InventoryContainer contents = new InventoryContainer(user()) { -+ @Override -+ protected void onSlotChanged(final int slot, final BedrockItem previous, final BedrockItem current) { -+ // Observe local quantities without the fixture emitting equipment packets. -+ } -+ }; -+ final List amountsAtSend = new ArrayList<>(); -+ final BedrockInventoryTransaction[] written = new BedrockInventoryTransaction[1]; -+ final PacketWrapper packet = (PacketWrapper) Proxy.newProxyInstance(PacketWrapper.class.getClassLoader(), -+ new Class[]{PacketWrapper.class}, (proxy, method, args) -> { -+ if (method.getName().equals("write")) { -+ written[0] = (BedrockInventoryTransaction) args[1]; -+ } -+ if (method.getName().equals("sendToServer")) { -+ final var use = (InventoryTransactionData.UseItemTransactionData) written[0].transactionData(); -+ amountsAtSend.add(contents.getItem(use.hotbarSlot()).amount()); -+ } -+ return null; -+ }); -+ final var type = new InventoryTransactionPacketType(() -> null, null); -+ final BedrockItem pearl = new BedrockItem(425); -+ pearl.setAmount(16); -+ pearl.setNetId(27); -+ contents.setItem(0, pearl); -+ final var first = InteractionPackets.beginItemUse(player, inventory, InteractionHand.MAIN_HAND, -+ 0, pearl, "minecraft:ender_pearl", false); -+ assertEquals(-4, first.legacyRequestId()); -+ assertEquals(ContainerEnumName.InventoryContainer, first.legacySlots().getFirst().container()); -+ assertArrayEquals(new byte[]{0}, first.legacySlots().getFirst().slots()); -+ assertEquals(0, first.actions().getFirst().slot()); -+ assertEquals(16, first.actions().getFirst().fromItem().amount()); -+ assertEquals(15, first.actions().getFirst().toItem().amount()); -+ assertNull(first.actions().getFirst().fromItem().netId()); -+ assertNull(first.actions().getFirst().toItem().netId()); -+ assertNull(((InventoryTransactionData.UseItemTransactionData) first.transactionData()).itemInHand().netId()); ++ void throwableUsePreservesNativeConsumptionWhenSwitchingHotbarSlots() throws ClassNotFoundException { ++ Class.forName(StructuredDataKey.class.getName()); ++ try (var packets = mockStatic(PacketWrapper.class)) { ++ packets.when(() -> PacketWrapper.create(ClientboundPackets26_3.CONTAINER_SET_SLOT, (UserConnection) null)) ++ .thenReturn(mock(PacketWrapper.class)); ++ final ClientPlayerEntity player = player(); ++ final InventoryTracker inventory = new InventoryTracker(user()); ++ final InventoryContainer contents = new InventoryContainer(user()) { ++ @Override ++ public Item getJavaItem(final int slot) { ++ final var item = this.getItem(slot); ++ return item.isEmpty() ? StructuredItem.empty() ++ : new StructuredItem(1, item.amount(), new StructuredDataContainer()); ++ } ++ ++ @Override ++ protected void onSlotChanged(final int slot, final BedrockItem previous, final BedrockItem current) { ++ // Observe local quantities without the fixture emitting equipment packets. ++ } ++ }; ++ final List amountsAtSend = new ArrayList<>(); ++ final BedrockInventoryTransaction[] written = new BedrockInventoryTransaction[1]; ++ final PacketWrapper packet = (PacketWrapper) Proxy.newProxyInstance(PacketWrapper.class.getClassLoader(), ++ new Class[]{PacketWrapper.class}, (proxy, method, args) -> { ++ if (method.getName().equals("write")) { ++ written[0] = (BedrockInventoryTransaction) args[1]; ++ } ++ if (method.getName().equals("sendToServer")) { ++ final var use = (InventoryTransactionData.UseItemTransactionData) written[0].transactionData(); ++ amountsAtSend.add(contents.getItem(use.hotbarSlot()).amount()); ++ } ++ return null; ++ }); ++ final var type = new InventoryTransactionPacketType(() -> null, null); ++ final BedrockItem pearl = new BedrockItem(425); ++ pearl.setAmount(16); ++ pearl.setNetId(27); ++ contents.setItem(0, pearl); ++ final var first = InteractionPackets.beginItemUse(player, inventory, InteractionHand.MAIN_HAND, ++ 0, pearl, "minecraft:ender_pearl", false); ++ assertEquals(-4, first.legacyRequestId()); ++ assertEquals(ContainerEnumName.InventoryContainer, first.legacySlots().getFirst().container()); ++ assertArrayEquals(new byte[]{0}, first.legacySlots().getFirst().slots()); ++ assertEquals(0, first.actions().getFirst().slot()); ++ assertEquals(16, first.actions().getFirst().fromItem().amount()); ++ assertEquals(15, first.actions().getFirst().toItem().amount()); ++ assertNull(first.actions().getFirst().fromItem().netId()); ++ assertNull(first.actions().getFirst().toItem().netId()); ++ assertNull(((InventoryTransactionData.UseItemTransactionData) first.transactionData()).itemInHand().netId()); + -+ InteractionPackets.writeItemUse(packet, type, first, contents); -+ assertEquals(15, contents.getItem(0).amount()); -+ assertEquals(27, contents.getItem(0).netId()); ++ InteractionPackets.writeItemUse(packet, type, first, contents); ++ assertEquals(15, contents.getItem(0).amount()); ++ assertEquals(27, contents.getItem(0).netId()); + -+ final BedrockItem windCharge = new BedrockItem(882); -+ windCharge.setNetId(28); -+ contents.setItem(1, windCharge); -+ final var second = InteractionPackets.beginItemUse(player, inventory, InteractionHand.MAIN_HAND, -+ 1, windCharge, "minecraft:wind_charge", false); -+ assertEquals(-6, second.legacyRequestId()); -+ assertArrayEquals(new byte[]{1}, second.legacySlots().getFirst().slots()); -+ assertEquals(1, second.actions().getFirst().slot()); -+ assertEquals(1, second.actions().getFirst().fromItem().amount()); -+ assertTrue(second.actions().getFirst().toItem().isEmpty()); -+ assertEquals(1, ((InventoryTransactionData.UseItemTransactionData) second.transactionData()).hotbarSlot()); -+ InteractionPackets.writeItemUse(packet, type, second, contents); -+ assertTrue(contents.getItem(1).isEmpty()); -+ final var repeated = InteractionPackets.beginItemUse(player, inventory, InteractionHand.MAIN_HAND, -+ 0, contents.getItem(0), "minecraft:ender_pearl", false); -+ assertEquals(-8, repeated.legacyRequestId()); -+ assertEquals(15, repeated.actions().getFirst().fromItem().amount()); -+ assertEquals(14, repeated.actions().getFirst().toItem().amount()); -+ InteractionPackets.writeItemUse(packet, type, repeated, contents); -+ assertEquals(14, contents.getItem(0).amount()); -+ assertEquals(27, contents.getItem(0).netId()); -+ assertEquals(List.of(16, 1, 15), amountsAtSend); -+ assertTrue(player.pendingFlightItemUses().isEmpty()); -+ assertTrue(player.authInputData().isEmpty()); -+ assertEquals(16, pearl.amount()); -+ assertEquals(27, pearl.netId()); -+ assertEquals(1, windCharge.amount()); -+ assertEquals(28, windCharge.netId()); ++ final BedrockItem windCharge = new BedrockItem(882); ++ windCharge.setNetId(28); ++ contents.setItem(1, windCharge); ++ final var second = InteractionPackets.beginItemUse(player, inventory, InteractionHand.MAIN_HAND, ++ 1, windCharge, "minecraft:wind_charge", false); ++ assertEquals(-6, second.legacyRequestId()); ++ assertArrayEquals(new byte[]{1}, second.legacySlots().getFirst().slots()); ++ assertEquals(1, second.actions().getFirst().slot()); ++ assertEquals(1, second.actions().getFirst().fromItem().amount()); ++ assertTrue(second.actions().getFirst().toItem().isEmpty()); ++ assertEquals(1, ((InventoryTransactionData.UseItemTransactionData) second.transactionData()).hotbarSlot()); ++ InteractionPackets.writeItemUse(packet, type, second, contents); ++ assertTrue(contents.getItem(1).isEmpty()); ++ final var repeated = InteractionPackets.beginItemUse(player, inventory, InteractionHand.MAIN_HAND, ++ 0, contents.getItem(0), "minecraft:ender_pearl", false); ++ assertEquals(-8, repeated.legacyRequestId()); ++ assertEquals(15, repeated.actions().getFirst().fromItem().amount()); ++ assertEquals(14, repeated.actions().getFirst().toItem().amount()); ++ InteractionPackets.writeItemUse(packet, type, repeated, contents); ++ assertEquals(14, contents.getItem(0).amount()); ++ assertEquals(27, contents.getItem(0).netId()); ++ assertEquals(List.of(16, 1, 15), amountsAtSend); ++ assertTrue(player.pendingFlightItemUses().isEmpty()); ++ assertTrue(player.authInputData().isEmpty()); ++ assertEquals(16, pearl.amount()); ++ assertEquals(27, pearl.netId()); ++ assertEquals(1, windCharge.amount()); ++ assertEquals(28, windCharge.netId()); ++ } + } + + private static UserConnection user() { @@ -904,6 +1034,112 @@ index 0bf1b705..1cdc17f1 100644 + } + } +diff --git a/src/test/java/net/raphimc/viabedrock/protocol/packet/ThrowablePredictionPacketTest.java b/src/test/java/net/raphimc/viabedrock/protocol/packet/ThrowablePredictionPacketTest.java +new file mode 100644 +index 00000000..6e130dc2 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/protocol/packet/ThrowablePredictionPacketTest.java +@@ -0,0 +1,100 @@ ++/* ++ * 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.protocol.packet; ++ ++import com.viaversion.viaversion.api.connection.UserConnection; ++import com.viaversion.viaversion.api.minecraft.data.StructuredDataContainer; ++import com.viaversion.viaversion.api.minecraft.data.StructuredDataKey; ++import com.viaversion.viaversion.api.minecraft.item.Item; ++import com.viaversion.viaversion.api.minecraft.item.StructuredItem; ++import com.viaversion.viaversion.api.protocol.packet.PacketWrapper; ++import com.viaversion.viaversion.api.type.Types; ++import com.viaversion.viaversion.api.type.types.version.VersionedTypes; ++import com.viaversion.viaversion.protocol.packet.PacketWrapperImpl; ++import com.viaversion.viaversion.protocols.v26_2to26_3.packet.ClientboundPackets26_3; ++import io.netty.buffer.Unpooled; ++import net.raphimc.viabedrock.api.model.container.player.InventoryContainer; ++import net.raphimc.viabedrock.api.model.entity.ClientPlayerEntity; ++import net.raphimc.viabedrock.protocol.BedrockProtocol; ++import net.raphimc.viabedrock.protocol.data.enums.java.generated.InteractionHand; ++import net.raphimc.viabedrock.protocol.model.BedrockItem; ++import net.raphimc.viabedrock.protocol.model.PlayerAbilities; ++import net.raphimc.viabedrock.protocol.storage.InventoryTracker; ++import net.raphimc.viabedrock.protocol.types.inventory.InventoryTransactionPacketType; ++import org.junit.jupiter.api.Test; ++ ++import java.util.UUID; ++import java.util.concurrent.atomic.AtomicInteger; ++ ++import static org.junit.jupiter.api.Assertions.assertEquals; ++import static org.mockito.Mockito.doAnswer; ++import static org.mockito.Mockito.mock; ++import static org.mockito.Mockito.mockStatic; ++import static org.mockito.Mockito.spy; ++import static org.mockito.Mockito.when; ++ ++class ThrowablePredictionPacketTest { ++ ++ @Test ++ void publishesNativeDebitWhenJavaCooldownRetainsTheLocalStack() throws Exception { ++ Class.forName(StructuredDataKey.class.getName()); ++ for (int count : new int[]{3, 1}) { ++ final UserConnection user = mock(UserConnection.class); ++ final var inventory = new InventoryContainer(user) { ++ @Override ++ protected void onSlotChanged(final int slot, final BedrockItem previous, final BedrockItem current) { ++ // This fixture observes the Java quantity update independently of Bedrock equipment transport. ++ } ++ ++ @Override ++ public Item getJavaItem(final int slot) { ++ final var item = this.getItem(slot); ++ return item.isEmpty() ? StructuredItem.empty() ++ : new StructuredItem(425, item.amount(), new StructuredDataContainer()); ++ } ++ }; ++ final var pearl = new BedrockItem(425, (short) 0, (byte) count); ++ pearl.setNetId(27); ++ inventory.setItem(0, pearl); ++ final var player = new ClientPlayerEntity(user, 1, UUID.randomUUID(), ++ new PlayerAbilities(1, (byte) 0, (byte) 0)); ++ final var transaction = InteractionPackets.beginItemUse(player, new InventoryTracker(user), ++ InteractionHand.MAIN_HAND, 0, pearl, "minecraft:ender_pearl", false); ++ final var request = mock(PacketWrapper.class); ++ when(request.user()).thenReturn(user); ++ doAnswer(invocation -> { ++ assertEquals(count, inventory.getItem(0).amount()); ++ return null; ++ }).when(request).sendToServer(BedrockProtocol.class); ++ // A Java cooldown can emit the request without locally consuming its held stack. ++ final var displayedCount = new AtomicInteger(count); ++ final var update = spy(new PacketWrapperImpl(ClientboundPackets26_3.CONTAINER_SET_SLOT, null, user)); ++ doAnswer(invocation -> { ++ final var output = Unpooled.buffer(); ++ try { ++ update.writeToBuffer(output); ++ assertEquals(ClientboundPackets26_3.CONTAINER_SET_SLOT.getId(), Types.VAR_INT.read(output)); ++ assertEquals(0, Types.VAR_INT.read(output)); ++ assertEquals(0, Types.VAR_INT.read(output)); ++ assertEquals(36, Types.SHORT.read(output).intValue()); ++ displayedCount.set(VersionedTypes.V26_3.item.read(output).amount()); ++ assertEquals(0, output.readableBytes()); ++ } finally { ++ output.release(); ++ } ++ return null; ++ }).when(update).send(BedrockProtocol.class); ++ try (var packets = mockStatic(PacketWrapper.class)) { ++ packets.when(() -> PacketWrapper.create(ClientboundPackets26_3.CONTAINER_SET_SLOT, user)).thenReturn(update); ++ InteractionPackets.writeItemUse(request, new InventoryTransactionPacketType(() -> null, null), ++ transaction, inventory); ++ } ++ assertEquals(count - 1, inventory.getItem(0).amount()); ++ assertEquals(count - 1, displayedCount.get()); ++ } ++ } ++ ++} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/types/inventory/InventoryTransactionPacketTypeTest.java b/src/test/java/net/raphimc/viabedrock/protocol/types/inventory/InventoryTransactionPacketTypeTest.java new file mode 100644 index 00000000..f6f97e37 diff --git a/patches/viabedrock/deferred/0016-synchronize-glide-rockets-with-auth-input.pr.md b/patches/viabedrock/deferred/0016-synchronize-glide-rockets-with-auth-input.pr.md new file mode 100644 index 00000000..8c4ac08f --- /dev/null +++ b/patches/viabedrock/deferred/0016-synchronize-glide-rockets-with-auth-input.pr.md @@ -0,0 +1,17 @@ +# Native air-use prediction + +Preserve native legacy consumption envelopes for immediate pearl and wind-charge use. Queue flight rockets behind their establishing movement frame. Keep inventory prediction separate from held input ownership. + +The add-on reports actual local stack consumption before the concluding Java use packet. Core pairs that report with the Java sequence and declares a debit only when local prediction consumed the item. A cooldown-refused request still sends air use, with omitted descriptor network IDs, but carries no inventory action or legacy slot declaration. A mismatched report cannot affect a later use. + +Original Bedrock 1.26.60.5 client controls establish this distinction across eleven GameMode calls. Successful pearl callbacks record four-to-three and three-to-two actions. Cooldown-refused callbacks dispatch without inventory actions. These controls use an owned local world and do not establish current native network or trajectory parity. + +Java 26.3 still sends a use packet when its local cooldown skips consumption. A declared debit can be accepted by BDS without a quantity response, leaving Java with a stale count. Core publishes each immediate declared debit to Java and preserves the local stack identity. Clients without the optional consumption report retain this fallback. Rejected retries on that fallback can briefly lower the Java count before authority restores it. + +A complete protocol-2225 baseline reproduces two pearls on BDS and three on Java after a declared 450 ms incoming delay. With the consumption report, the second request carries no predicted debit. BDS accepts that use and supplies an authoritative count of two. The delay changes delivery timing only; complete peer and client journals retain packet bytes and order. + +Rebuilt direct and ViaProxy clients each complete nine controls in a 210-second recording against BDS 1.26.60.6. The controls cover delayed acceptance, rejected retries, held and fresh hotbar transitions, final pearl and wind-charge removal, and food/block transitions. All selected held-item counts match BDS after settlement, and both routes settle at the server position. Installed receipts identify the diagnostic add-on and full proxy build. The diagnostic retains unrelated private presentation work. All 39 item-input classes match the separately rebuilt exported add-on, and all 1,692 embedded core classes match the tested core. + +Full local builds pass 2,157 core tests, 1,416 exported add-on tests, and four proxy tests, with 176 core and 154 add-on skips. The add-on run includes five software font GPU checks. Both complete patch stacks replay to the tested source trees. The refused-prediction regression verifies an empty action envelope, no Java quantity publication, and an unchanged legacy counter before the next successful debit. `ThrowablePredictionPacketTest` retains encoded Java quantities and final-stack removal for the fallback. + +Exact physical input timing, current native networking, broader item families, and complete inventory parity remain unverified. The original persistent rollback report remains open. diff --git a/patches/viabedrock/deferred/0021-confirm-survival-offhand-transfers.patch b/patches/viabedrock/deferred/0021-confirm-survival-offhand-transfers.patch index 4e8c0f14..ad058d01 100644 --- a/patches/viabedrock/deferred/0021-confirm-survival-offhand-transfers.patch +++ b/patches/viabedrock/deferred/0021-confirm-survival-offhand-transfers.patch @@ -1,4 +1,4 @@ -From c3d621d43e52c9bf28c115004be6563fd170c27b Mon Sep 17 00:00:00 2001 +From e436b228bb3926d229038eb3230713f5e7d568d9 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] fix(inventory): confirm survival offhand transfers from @@ -40,21 +40,21 @@ and the distinction between incoming and removal lock policies. Codec, server acceptance and recovery remain separate controls. --- .../protocol/packet/InventoryPackets.java | 152 ++++++++++++++++- - .../protocol/packet/MultiStatePackets.java | 18 +- - .../storage/InventoryRequestTracker.java | 155 +++++++++++++++++- + .../protocol/packet/MultiStatePackets.java | 18 ++- + .../storage/InventoryRequestTracker.java | 153 +++++++++++++++++- .../protocol/packet/InventoryPacketsTest.java | 66 ++++++++ .../packet/LegacyOffhandAdmissionTest.java | 131 +++++++++++++++ .../NativeContainerOffhandGuardTest.java | 95 +++++++++++ .../storage/InventoryRequestTrackerTest.java | 75 +++++++++ - 7 files changed, 674 insertions(+), 18 deletions(-) + 7 files changed, 673 insertions(+), 17 deletions(-) create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/packet/LegacyOffhandAdmissionTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/packet/NativeContainerOffhandGuardTest.java diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/InventoryPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/InventoryPackets.java -index 1cb31d42..15f46759 100644 +index f56110b9..7d61b8f3 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/InventoryPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/InventoryPackets.java -@@ -624,7 +624,6 @@ public final class InventoryPackets { +@@ -657,7 +657,6 @@ public final class InventoryPackets { wrapper.read(BedrockTypes.FULL_CONTAINER_NAME); // container name final BedrockItem storageItem = wrapper.read(itemRewriter.newItemType()); // storage item @@ -62,7 +62,7 @@ index 1cb31d42..15f46759 100644 final InventoryTracker inventoryTracker = wrapper.user().get(InventoryTracker.class); final Container container = -@@ -634,6 +633,10 @@ public final class InventoryPackets { +@@ -667,6 +666,10 @@ public final class InventoryPackets { container.markServerUpdate(); wrapper.user().get(InventoryRequestTracker.class) .handleAuthoritativeContent(container); @@ -73,7 +73,7 @@ index 1cb31d42..15f46759 100644 if (container.type() == ContainerType.HUD) { inventoryTracker.getInventoryContainer().updateCraftingOutput(); } -@@ -663,7 +666,6 @@ public final class InventoryPackets { +@@ -696,7 +699,6 @@ public final class InventoryPackets { final BedrockItem storageItem = wrapper.read(itemRewriter.optionalNewItemType()); // storage item final BedrockItem item = wrapper.read(itemRewriter.newItemType()); // item @@ -81,7 +81,7 @@ index 1cb31d42..15f46759 100644 final InventoryTracker inventoryTracker = wrapper.user().get(InventoryTracker.class); final Container container = -@@ -673,6 +675,20 @@ public final class InventoryPackets { +@@ -706,6 +708,20 @@ public final class InventoryPackets { container.markServerUpdate(); wrapper.user().get(InventoryRequestTracker.class) .handleAuthoritativeSlot(container, slot); @@ -102,16 +102,16 @@ index 1cb31d42..15f46759 100644 if (container.type() == ContainerType.HUD && ((slot >= 28 && slot <= 31) || slot == 50)) { inventoryTracker.getInventoryContainer().refreshCraftingOutput(); -@@ -966,6 +982,8 @@ public final class InventoryPackets { +@@ -1002,6 +1018,8 @@ public final class InventoryPackets { + if (wrapper.user().get(InventoryRequestTracker.class).isWaitingForDragClose()) { + return; } - final Container container = - inventoryTracker.getContainerServerbound((byte) containerId); + final InventoryRequestTracker requestTracker = + wrapper.user().get(InventoryRequestTracker.class); final var nativeContainer = wrapper.user().get(NativeContainerUiState.class); if (nativeContainer != null && nativeContainer.blocksVanillaClicks()) { if (container != null) { -@@ -973,10 +991,11 @@ public final class InventoryPackets { +@@ -1009,10 +1027,11 @@ public final class InventoryPackets { } return; } @@ -125,7 +125,7 @@ index 1cb31d42..15f46759 100644 final boolean requestOpen = !requestTracker.isWaitingForInventoryOpen(); if (!requestTracker.queueInventoryOpenClick( revision, slot, button, action)) { -@@ -999,13 +1018,15 @@ public final class InventoryPackets { +@@ -1035,13 +1054,15 @@ public final class InventoryPackets { wrapper.cancel(); return; } @@ -143,7 +143,7 @@ index 1cb31d42..15f46759 100644 if (!container.handleClick(revision, slot, button, action)) { if (container.type() != ContainerType.INVENTORY) { PacketFactory.sendJavaContainerSetContent( -@@ -1070,7 +1091,10 @@ public final class InventoryPackets { +@@ -1106,7 +1127,10 @@ public final class InventoryPackets { return; } final InventoryRequestTracker requestTracker = wrapper.user().get(InventoryRequestTracker.class); @@ -155,7 +155,7 @@ index 1cb31d42..15f46759 100644 if (!requestTracker.queueButtonClick(container, button)) { PacketFactory.sendJavaContainerSetContent(wrapper.user(), container); } -@@ -1875,6 +1899,120 @@ public final class InventoryPackets { +@@ -1903,6 +1927,120 @@ public final class InventoryPackets { new InventoryTransactionData.NormalTransactionData()); } @@ -277,7 +277,7 @@ index 1cb31d42..15f46759 100644 throws InformativeException { return readCreativeItem(wrapper, itemRewriter.getItems().getOrDefault("minecraft:shield", 0), itemRewriter); diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java -index ac448e28..885579ea 100644 +index 3968fa62..2d9b81e0 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java @@ -19,6 +19,10 @@ package net.raphimc.viabedrock.protocol.packet; @@ -289,9 +289,9 @@ index ac448e28..885579ea 100644 +import net.raphimc.viabedrock.protocol.storage.InventoryRequestTracker; + import net.raphimc.viabedrock.protocol.storage.NativeContainerUiState; - import net.raphimc.viabedrock.protocol.storage.NativeCookingUiState; - import net.raphimc.viabedrock.api.model.NativeCookingMessage; -@@ -282,8 +286,18 @@ public final class MultiStatePackets { + import net.raphimc.viabedrock.protocol.storage.InventoryTracker; + import net.raphimc.viabedrock.api.model.container.Container; +@@ -347,8 +351,18 @@ public final class MultiStatePackets { && wrapper.user().get(ChannelStorage.class).hasChannel(NativeContainerStatus.CHANNEL)) { try { final var action = wrapper.read(NativeContainerAction.TYPE); @@ -313,7 +313,7 @@ index ac448e28..885579ea 100644 final var status = PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, wrapper.user()); status.write(Types.STRING, NativeContainerStatus.CHANNEL); 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 a20b0344..cf41f205 100644 +index 0be99f8b..e5caf756 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryRequestTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryRequestTracker.java @@ -46,6 +46,7 @@ import net.raphimc.viabedrock.protocol.model.BedrockItem; @@ -324,9 +324,9 @@ index a20b0344..cf41f205 100644 import net.raphimc.viabedrock.protocol.types.BedrockTypes; import java.util.ArrayDeque; -@@ -77,6 +78,10 @@ public class InventoryRequestTracker extends StoredObject { - private TransferBatch transferBatch; +@@ -78,6 +79,10 @@ public class InventoryRequestTracker extends StoredObject { private Set batchChanges; + private DeferredDragClose deferredDragClose; private MineBlockRequest mineBlockRequest; + private PendingLegacyOffhand pendingLegacyOffhand; + private PendingLegacyCursorPlacement pendingLegacyCursorPlacement; @@ -335,17 +335,16 @@ index a20b0344..cf41f205 100644 public InventoryRequestTracker(final UserConnection user) { super(user); -@@ -226,13 +231,85 @@ public class InventoryRequestTracker extends StoredObject { - } - - public boolean canStartRecipePlacement() { -- return !this.hasPending() && !this.recovery.isActive() && !this.isWaitingForInventoryOpen(); -+ return !this.hasPending() && !this.recovery.isActive() && !this.isWaitingForInventoryOpen() +@@ -230,6 +235,8 @@ public class InventoryRequestTracker extends StoredObject { + final var inventory = this.user().get(InventoryTracker.class); + final var container = inventory == null ? null : inventory.getCurrentContainer(); + return !this.hasPending() && !this.recovery.isActive() && !this.isWaitingForInventoryOpen() + && !this.isWaitingForLegacyOffhand() && !this.isRecoveringLegacyOffhand() -+ && !this.hasLegacyOffhandCursor(); ++ && !this.hasLegacyOffhandCursor() + && (container == null || !container.hasIncrementalDrag()); } - public boolean isPredictingClick() { +@@ -237,6 +244,76 @@ public class InventoryRequestTracker extends StoredObject { return this.predictingClick; } @@ -422,16 +421,16 @@ index a20b0344..cf41f205 100644 public void setPredictingClick(final boolean predictingClick) { this.predictingClick = predictingClick; } -@@ -252,7 +329,7 @@ public class InventoryRequestTracker extends StoredObject { +@@ -256,7 +333,7 @@ public class InventoryRequestTracker extends StoredObject { public boolean queueClick(final Container container, final int revision, final short slot, final byte button, final ContainerInput action) { -- return this.queueInteraction(new QueuedClick(container, revision, slot, button, action)); -+ return this.queueInteraction(new QueuedClick(this.user(), container, revision, slot, button, action)); - } - - public boolean isWaitingForInventoryOpen() { -@@ -298,15 +375,17 @@ public class InventoryRequestTracker extends StoredObject { +- final var click = new QueuedClick(container, revision, slot, button, action); ++ final var click = new QueuedClick(this.user(), container, revision, slot, button, action); + if (action == ContainerInput.QUICK_CRAFT && (button == 1 || button == 5) + && !this.recovery.isActive() && !this.isWaitingForDragClose() + && container.supportsIncrementalDrag() && container.getItemInJavaSlot(slot) != null) { +@@ -316,15 +393,17 @@ public class InventoryRequestTracker extends StoredObject { public void handleInventoryOpen(final Container container) { for (final InventoryOpenClick click : this.pendingInventoryOpenClicks.drain()) { @@ -452,10 +451,10 @@ index a20b0344..cf41f205 100644 PacketFactory.sendJavaContainerSetContent(this.user(), container); } } -@@ -380,6 +459,31 @@ public class InventoryRequestTracker extends StoredObject { - } +@@ -443,6 +522,31 @@ public class InventoryRequestTracker extends StoredObject { public void tick() { + this.finishDragClose(); + if ((this.pendingLegacyOffhand != null + && System.nanoTime() - this.pendingLegacyOffhand.sentAtNanos() + >= RESPONSE_TIMEOUT_NANOS) @@ -484,7 +483,7 @@ index a20b0344..cf41f205 100644 if (this.pendingInventoryOpenClicks.expire( System.nanoTime(), RESPONSE_TIMEOUT_NANOS)) { this.clearQueuedInteractions(); -@@ -392,6 +496,11 @@ public class InventoryRequestTracker extends StoredObject { +@@ -455,6 +559,11 @@ public class InventoryRequestTracker extends StoredObject { this.mineBlockRequest = null; } if (!this.hasPending()) { @@ -496,7 +495,7 @@ index a20b0344..cf41f205 100644 return; } final long now = System.nanoTime(); -@@ -450,7 +559,7 @@ public class InventoryRequestTracker extends StoredObject { +@@ -513,7 +622,7 @@ public class InventoryRequestTracker extends StoredObject { this.closeTemporaryInventoryIfIdle(); } } @@ -505,7 +504,7 @@ index a20b0344..cf41f205 100644 return; } final InventoryTracker tracker = this.user().get(InventoryTracker.class); -@@ -458,6 +567,8 @@ public class InventoryRequestTracker extends StoredObject { +@@ -521,6 +630,8 @@ public class InventoryRequestTracker extends StoredObject { this.requests.clear(); this.transferBatch = null; this.craftedItemSynchronization = null; @@ -514,7 +513,7 @@ index a20b0344..cf41f205 100644 this.resetRecovery(); tracker.getInventoryContainer().updateCraftingOutput(); PacketFactory.sendJavaContainerSetContent(this.user(), tracker.getInventoryContainer()); -@@ -755,7 +866,8 @@ public class InventoryRequestTracker extends StoredObject { +@@ -820,7 +931,8 @@ public class InventoryRequestTracker extends StoredObject { void drainQueuedInteractions() { final InventoryTracker tracker = this.user().get(InventoryTracker.class); @@ -524,7 +523,7 @@ index a20b0344..cf41f205 100644 && !this.isWaitingForInventoryOpen() && !this.queuedInteractions.isEmpty()) { final QueuedInteraction interaction = this.queuedInteractions.removeFirst(); -@@ -772,6 +884,29 @@ public class InventoryRequestTracker extends StoredObject { +@@ -844,6 +956,29 @@ public class InventoryRequestTracker extends StoredObject { private record MineBlockRequest(int requestId, int hotbarSlot, int stackNetworkId, long sentAtNanos) { } @@ -554,7 +553,7 @@ index a20b0344..cf41f205 100644 static final class PendingInventoryOpenClicks { private final ArrayDeque clicks = new ArrayDeque<>(); -@@ -868,12 +1003,14 @@ public class InventoryRequestTracker extends StoredObject { +@@ -940,12 +1075,14 @@ public class InventoryRequestTracker extends StoredObject { boolean apply(); } diff --git a/patches/viabedrock/series.json b/patches/viabedrock/series.json index 30e8982b..db85a993 100644 --- a/patches/viabedrock/series.json +++ b/patches/viabedrock/series.json @@ -7,6 +7,30 @@ "file": "0001-update-bedrock-to-1-26-60.patch", "title": "fix(protocol): complete Bedrock 1.26.60 compatibility" }, + { + "file": "0111-honor-death-message-visibility.patch", + "title": "fix(hud): honor death message visibility", + "prerequisites": [ + { + "project": "viabedrock", + "patch": "0001-update-bedrock-to-1-26-60.patch", + "reason": "Provide the local JVM test configuration.", + "testOnly": true + }, + { + "project": "viabedrock", + "patch": "0015-transport-native-resources-and-sound-commands.patch", + "reason": "Provide Mockito for the local packet regression.", + "testOnly": true + }, + { + "project": "viabedrock", + "patch": "0092-retain-completed-client-prediction-frames.patch", + "reason": "Install the local Mockito test agent.", + "testOnly": true + } + ] + }, { "file": "0109-keep-resource-image-codecs-in-memory.patch", "title": "fix(resources): keep resource image codecs in memory" @@ -92,7 +116,14 @@ }, { "file": "0014-initialize-and-preserve-bedrock-clock-time.patch", - "title": "fix(world): preserve native Bedrock world clock semantics" + "title": "fix(world): preserve native Bedrock world clock semantics", + "prerequisites": [ + { + "project": "viabedrock", + "patch": "0111-honor-death-message-visibility.patch", + "reason": "Apply the shared death-message rule handler before extending GameRulesStorage with clock updates." + } + ] }, { "file": "0015-decode-native-sound-packet-layouts.patch", @@ -382,6 +413,16 @@ "project": "viabedrock", "patch": "0018-preserve-sparse-actor-spatial-updates.patch", "reason": "Retain complete received spatial state before local-control admission. The registered vehicle comparisons require this cache independently of the live actor pose." + }, + { + "project": "viabedrock", + "patch": "0015-transport-native-resources-and-sound-commands.patch", + "reason": "Provide the authoritative native block dictionary and world mirror before movement-property transport." + }, + { + "project": "viabedrock", + "patch": "0054-render-native-block-state-geometry-and-materials.patch", + "reason": "Resolve custom block components independently of visual compilation before selecting explicit friction." } ] }, @@ -417,6 +458,11 @@ "project": "viabedrock", "patch": "0054-render-native-block-state-geometry-and-materials.patch", "reason": "Land canonical block auxiliary descriptors and restriction lookup before native subtype-aware merge checks." + }, + { + "project": "viabedrock", + "patch": "0078-preserve-native-identifiers-in-java-item-data.patch", + "reason": "Provide immutable native item predicates and their Java item-context transport." } ] }, @@ -603,6 +649,22 @@ "reason": "Retain actor spatial admission and reset handling without changing their independent collision history." } ] + }, + { + "file": "0112-retain-native-death-text.patch", + "title": "feat(ui): retain native death text source", + "prerequisites": [ + { + "project": "viabedrock", + "patch": "0111-honor-death-message-visibility.patch", + "reason": "Reuse local death projection and current visibility rules." + }, + { + "project": "viabedrock", + "patch": "0064-evaluate-named-actor-properties.patch", + "reason": "Bind negotiated death text to native actor lifetimes and registered actor-state channels." + } + ] } ], "deferred": [ diff --git a/patches/viabedrock/upstreamable/0002-preserve-sparse-properties-and-composed-metadata.patch b/patches/viabedrock/upstreamable/0002-preserve-sparse-properties-and-composed-metadata.patch index 8ffae213..f3351f01 100644 --- a/patches/viabedrock/upstreamable/0002-preserve-sparse-properties-and-composed-metadata.patch +++ b/patches/viabedrock/upstreamable/0002-preserve-sparse-properties-and-composed-metadata.patch @@ -1,4 +1,4 @@ -From 647da9bd992fdb8fccca1c9380a95539847c78c9 Mon Sep 17 00:00:00 2001 +From 242ff686b4852bc639f112f36c9f1cf7b55e8d03 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] fix(metadata): preserve sparse properties and composed actor @@ -155,8 +155,15 @@ it does not establish movement simulation, complete body rotation, or the original local custom-skin report. Regressions cover remote sneak/stand, sparse flag composition, other established posture mappings, and retaining local prediction outside crawling transitions. + +Original 1.26.60.5 typed Float metadata admission retains finite negative +Scale (38). The complete native size updater clamps its product to 0.005F +before checking the upper bound, including negative overflow. Preserve +this collapsed physical box instead of treating the dimensions as absent. +Use the shared dimension calculation for metadata projection and native +state consumers so their scale validation and float rounding agree. --- - .../viabedrock/api/model/entity/Entity.java | 51 ++ + .../viabedrock/api/model/entity/Entity.java | 50 ++ .../api/model/entity/LivingEntity.java | 4 +- .../api/model/entity/PlayerEntity.java | 43 +- .../entity/properties/EntityProperty.java | 20 +- @@ -178,7 +185,7 @@ local prediction outside crawling transitions. .../rewriter/EntityMetadataRewriterTest.java | 318 ++++++++++ .../rewriter/EntityNameFormattingTest.java | 256 ++++++++ .../rewriter/PotionActorMetadataTest.java | 107 ++++ - 22 files changed, 2204 insertions(+), 210 deletions(-) + 22 files changed, 2203 insertions(+), 210 deletions(-) create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/entity/EntityPropertyRenderTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/entity/PlayerCrawlingPoseTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/entity/properties/EntityPropertyListTest.java @@ -190,7 +197,7 @@ local prediction outside crawling transitions. create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/rewriter/PotionActorMetadataTest.java diff --git a/src/main/java/net/raphimc/viabedrock/api/model/entity/Entity.java b/src/main/java/net/raphimc/viabedrock/api/model/entity/Entity.java -index 28991e6a..4c89abb2 100644 +index 28991e6a..b66e06fc 100644 --- a/src/main/java/net/raphimc/viabedrock/api/model/entity/Entity.java +++ b/src/main/java/net/raphimc/viabedrock/api/model/entity/Entity.java @@ -94,10 +94,12 @@ public class Entity { @@ -206,7 +213,7 @@ index 28991e6a..4c89abb2 100644 public Entity(final UserConnection user, final long uniqueId, final long runtimeId, final String type, final int javaId, final UUID javaUuid, final EntityTypes26_3 javaType) { this.user = user; -@@ -115,6 +117,24 @@ public class Entity { +@@ -115,6 +117,23 @@ public class Entity { return scaleData == null ? 1F : ((Number) scaleData.value()).floatValue(); } @@ -220,18 +227,17 @@ index 28991e6a..4c89abb2 100644 + if (data == null || !(data.value() instanceof Number number)) { + return Optional.empty(); + } -+ final float baseValue = number.floatValue(); -+ final float scale = this.scale(); -+ final float value = baseValue * scale; -+ // The native AABB producer clamps collapsed dimensions to .005 blocks. -+ return Float.isFinite(baseValue) && baseValue >= 0F && Float.isFinite(scale) && scale >= 0F -+ && Float.isFinite(value) && value < 1E9F ? Optional.of(Math.max(0.005F, value)) : Optional.empty(); ++ try { ++ return Optional.of(NativeActorBoundsMessage.scaledDimension(number.floatValue(), this.scale())); ++ } catch (final IllegalArgumentException unavailableDimensions) { ++ return Optional.empty(); ++ } + } + public final Map entityProperties() { return Map.copyOf(this.entityProperties); } -@@ -159,6 +179,18 @@ public class Entity { +@@ -159,6 +178,18 @@ public class Entity { setEntityData.send(BedrockProtocol.class); } @@ -250,7 +256,7 @@ index 28991e6a..4c89abb2 100644 public final void updateEntityData(final EntityData[] entityData, final List javaEntityData) { for (EntityData data : entityData) { final ActorDataIds dataId = ActorDataIds.getByValue(data.id()); -@@ -265,6 +297,14 @@ public class Entity { +@@ -265,6 +296,14 @@ public class Entity { return this.javaType; } @@ -265,7 +271,7 @@ index 28991e6a..4c89abb2 100644 public Position3f position() { return this.position; } -@@ -453,6 +493,14 @@ public class Entity { +@@ -453,6 +492,14 @@ public class Entity { return this.mountRuntimeId; } @@ -280,7 +286,7 @@ index 28991e6a..4c89abb2 100644 public final int getJavaEntityDataIndex(final String fieldName) { final int index = BedrockProtocol.MAPPINGS.getJavaEntityDataFields().get(this.javaType).indexOf(fieldName); if (index == -1) { -@@ -605,4 +653,7 @@ public class Entity { +@@ -605,4 +652,7 @@ public class Entity { payload.send(BedrockProtocol.class); } @@ -560,7 +566,7 @@ index b98af526..3514995f 100644 CRAWLING(114), TIMER_FLAG_1(115), diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java -index 9c4b8cec..1c23b759 100644 +index 8a8c6f74..d6ea7d51 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java @@ -20,9 +20,6 @@ package net.raphimc.viabedrock.protocol.packet; @@ -591,7 +597,7 @@ index 9c4b8cec..1c23b759 100644 import net.raphimc.viabedrock.api.resourcepack.definition.EntityDefinitions; import net.raphimc.viabedrock.api.util.MathUtil; import net.raphimc.viabedrock.api.util.PacketFactory; -@@ -55,6 +52,7 @@ import net.raphimc.viabedrock.api.util.TextUtil; +@@ -54,6 +51,7 @@ import net.raphimc.viabedrock.api.util.RegistryUtil; import net.raphimc.viabedrock.protocol.BedrockProtocol; import net.raphimc.viabedrock.protocol.ClientboundBedrockPackets; import net.raphimc.viabedrock.protocol.data.enums.Direction; @@ -599,7 +605,7 @@ index 9c4b8cec..1c23b759 100644 import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.*; import net.raphimc.viabedrock.protocol.data.enums.java.AnimateAction; import net.raphimc.viabedrock.protocol.data.enums.java.Relative; -@@ -62,6 +60,7 @@ import net.raphimc.viabedrock.protocol.data.enums.java.generated.EquipmentSlot; +@@ -61,6 +59,7 @@ import net.raphimc.viabedrock.protocol.data.enums.java.generated.EquipmentSlot; import net.raphimc.viabedrock.protocol.data.generated.java.EntityDataFields; import net.raphimc.viabedrock.protocol.data.generated.java.RegistryKeys; import net.raphimc.viabedrock.protocol.model.*; @@ -607,7 +613,7 @@ index 9c4b8cec..1c23b759 100644 import net.raphimc.viabedrock.protocol.rewriter.ItemRewriter; import net.raphimc.viabedrock.protocol.storage.EntityPropertyTracker; import net.raphimc.viabedrock.protocol.storage.ChannelStorage; -@@ -81,6 +80,12 @@ public final class EntityPackets { +@@ -80,6 +79,12 @@ public final class EntityPackets { private static final int INTERPOLATION_STEP_TICKS = 3; private static final float PAINTING_POS_OFFSET = -0.46875F; private static final SwingAnimation DEFAULT_SWING_ANIMATION = new SwingAnimation(EnumTypes.SWING_ANIMATION1_21_11.idFromName("whack"), 6); @@ -620,7 +626,7 @@ index 9c4b8cec..1c23b759 100644 static Position3f mergePosition(final Position3f current, final boolean hasX, final float x, final boolean hasY, final float y, final boolean hasZ, final float z) { -@@ -144,7 +149,7 @@ public final class EntityPackets { +@@ -143,7 +148,7 @@ public final class EntityPackets { attributes[i] = new EntityAttribute(name, currentValue, minValue, maxValue); } final EntityData[] entityData = wrapper.read(BedrockTypes.ENTITY_DATA_ARRAY); // entity data @@ -629,7 +635,7 @@ index 9c4b8cec..1c23b759 100644 final EntityLink[] links = wrapper.read(BedrockTypes.ENTITY_LINK_ARRAY); // entity links wrapper.read(BedrockTypes.OPTIONAL_PASSENGER_OF_BLOCKS); // passenger of blocks -@@ -193,7 +198,7 @@ public final class EntityPackets { +@@ -192,7 +197,7 @@ public final class EntityPackets { if (entity instanceof LivingEntity livingEntity) { livingEntity.updateAttributes(attributes); } @@ -638,7 +644,7 @@ index 9c4b8cec..1c23b759 100644 for (EntityLink link : links) { entityTracker.applyEntityLink(link); } -@@ -596,53 +601,6 @@ public final class EntityPackets { +@@ -592,53 +597,6 @@ public final class EntityPackets { wrapper.write(Types.VAR_INT, entity.javaId()); // entity id wrapper.write(VersionedTypes.V26_3.entityDataList, javaEntityData); // entity data }); @@ -692,7 +698,7 @@ index 9c4b8cec..1c23b759 100644 protocol.registerClientbound(ClientboundBedrockPackets.MOB_EFFECT, ClientboundPackets26_3.UPDATE_MOB_EFFECT, wrapper -> { final long entityRuntimeId = wrapper.read(BedrockTypes.UNSIGNED_VAR_LONG); // entity runtime id final MobEffectPacketPayload_Event event = MobEffectPacketPayload_Event.getByValue(wrapper.read(Types.BYTE), MobEffectPacketPayload_Event.Invalid); // event id -@@ -747,8 +705,18 @@ public final class EntityPackets { +@@ -743,8 +701,18 @@ public final class EntityPackets { wrapper.write(Types.BYTE, (byte) EquipmentSlot.MAINHAND.ordinal()); // slot wrapper.write(VersionedTypes.V26_3.item, itemRewriter.javaItem(item)); // item } else if (containerId == ContainerID.CONTAINER_ID_OFFHAND.getValue()) { @@ -711,7 +717,7 @@ index 9c4b8cec..1c23b759 100644 } else { wrapper.cancel(); } -@@ -768,6 +736,10 @@ public final class EntityPackets { +@@ -764,6 +732,10 @@ public final class EntityPackets { wrapper.write(Types.VAR_INT, collectorEntity.javaId()); // collector entity id wrapper.write(Types.VAR_INT, 0); // amount }); @@ -723,10 +729,10 @@ index 9c4b8cec..1c23b759 100644 private static void writeTickedEntityMotion(final PacketWrapper wrapper, final long runtimeId, final Position3f motion, final long tick) { diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/JoinPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/JoinPackets.java -index cb61ffc5..9eedc5c5 100644 +index 579f61fd..9e3495bf 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/JoinPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/JoinPackets.java -@@ -294,7 +294,7 @@ public final class JoinPackets { +@@ -295,7 +295,7 @@ public final class JoinPackets { wrapper.read(BedrockTypes.STRING); // multiplayer correlation id final boolean inventoryServerAuthoritative = wrapper.read(Types.BOOLEAN); // server authoritative inventories final String serverEngine = wrapper.read(BedrockTypes.STRING); // server engine diff --git a/patches/viabedrock/upstreamable/0002-preserve-sparse-properties-and-composed-metadata.pr.md b/patches/viabedrock/upstreamable/0002-preserve-sparse-properties-and-composed-metadata.pr.md index 01b06a2e..029f3888 100644 --- a/patches/viabedrock/upstreamable/0002-preserve-sparse-properties-and-composed-metadata.pr.md +++ b/patches/viabedrock/upstreamable/0002-preserve-sparse-properties-and-composed-metadata.pr.md @@ -16,6 +16,8 @@ Prerequisites: Regression coverage includes `EntityPropertyRenderTest`, `PlayerCrawlingPoseTest`, `EntityPropertyListTest`, `ActorFlagsMappingTest`. +Physical dimensions use the [shared native scale calculation](0064-evaluate-named-actor-properties.pr.md#validation-and-remaining-limits), including its collapsed-box behavior. + Remote player updates emit their resolved Java pose even without an earlier crawling transition. Name-only updates do not synthesize posture before native flags arrive. The local player retains prediction outside the existing crawling correction. Regressions cover sneak/stand, sparse flag words, other established posture mappings, and local ownership. A synthetic colored classic-skin peer uses Bedrock 1.26.60.5/BDS 1.26.60.6 on protocol 2225. Native crouches when its sneaking flag changes; the pre-fix direct Java client remains standing. Slim/wide/slim skin replacements isolate posture from texture changes. These controls do not establish movement simulation, complete body rotation, or the original local custom-skin report. diff --git a/patches/viabedrock/upstreamable/0005-suppress-redundant-pick-item-overlay.patch b/patches/viabedrock/upstreamable/0005-suppress-redundant-pick-item-overlay.patch index 18a7927b..aec299d9 100644 --- a/patches/viabedrock/upstreamable/0005-suppress-redundant-pick-item-overlay.patch +++ b/patches/viabedrock/upstreamable/0005-suppress-redundant-pick-item-overlay.patch @@ -1,4 +1,4 @@ -From 0d6e4cab95eeada276ea3aea8ea52e8dfe40df6a Mon Sep 17 00:00:00 2001 +From e94ec8c5b1f8eea12f2e8081e05084e2f20f01ad Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:55:33 +0200 Subject: [PATCH] fix(input): suppress redundant pick-item overlay @@ -16,12 +16,12 @@ server-selected hotbar slot translation. 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java -index 1e094252..82795b85 100644 +index 4b40f209..a8721512 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java -@@ -451,18 +451,6 @@ public final class HudPackets { - playerCombatKill.send(BedrockProtocol.class); - } +@@ -445,18 +445,6 @@ public final class HudPackets { + gameSession.setDeathMessage(TextUtil.stringToTextComponent(BedrockTranslator.translate(message, translator, parameters))); + PacketFactory.sendJavaDeathMessage(wrapper.user()); }); - protocol.registerClientbound(ClientboundBedrockPackets.GUI_DATA_PICK_ITEM, ClientboundPackets26_3.SYSTEM_CHAT, wrapper -> { - final String itemName = wrapper.read(BedrockTypes.STRING); // item name diff --git a/patches/viabedrock/upstreamable/0007-forward-local-game-mode-updates.patch b/patches/viabedrock/upstreamable/0007-forward-local-game-mode-updates.patch index 47799601..6fa544c9 100644 --- a/patches/viabedrock/upstreamable/0007-forward-local-game-mode-updates.patch +++ b/patches/viabedrock/upstreamable/0007-forward-local-game-mode-updates.patch @@ -1,4 +1,4 @@ -From 436fec0895e3d98387414cbbde8d21d3d0ca690c Mon Sep 17 00:00:00 2001 +From d85896f802b7cd9c8029442872ff2d8c39e0b7a5 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:55:33 +0200 Subject: [PATCH] fix(players): refresh local and inherited game modes @@ -95,7 +95,7 @@ index 6197d5f0..65919a9d 100644 } }); diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java -index 82795b85..eea1b264 100644 +index a8721512..7635a534 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java @@ -143,12 +143,15 @@ public final class HudPackets { diff --git a/patches/viabedrock/upstreamable/0008-discard-stale-passenger-links.patch b/patches/viabedrock/upstreamable/0008-discard-stale-passenger-links.patch index 4b842d69..3ece630f 100644 --- a/patches/viabedrock/upstreamable/0008-discard-stale-passenger-links.patch +++ b/patches/viabedrock/upstreamable/0008-discard-stale-passenger-links.patch @@ -1,4 +1,4 @@ -From e91a6b486f4aac421ec8c79679707f2e7bbce523 Mon Sep 17 00:00:00 2001 +From 46f05d6369c91e93cc02cd301cc3ce6e271295ce Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:55:33 +0200 Subject: [PATCH] fix(entities): resolve and order Bedrock passenger links @@ -43,17 +43,25 @@ for that raw component. Typed component stores and storage factories are fixture inputs; unrelated position, notification and event effects remain boundaries. Queue timing, derived vehicle overrides, full cycle handling, removal lifetimes and mounted-camera rendering remain separate work. + +Schedule a local dismount correction only when the unlink names the +player's current vehicle. An old vehicle's unlink already leaves the new +mount and position intact, but its unconditional Java correction still +reset teleport confirmation. Duplicate unlinks also sent extra corrections +after a valid dismount. Keep the position correction under the same mount +identity check. The regression covers a replacement mount, a stale unlink, +a valid unlink and a repeated valid unlink. --- .../api/model/entity/ClientPlayerEntity.java | 17 + .../viabedrock/api/model/entity/Entity.java | 15 + .../protocol/PlayerActionPacketFactory.java | 9 +- .../protocol/packet/EntityPackets.java | 12 +- - .../protocol/packet/InteractionPackets.java | 70 ++--- + .../protocol/packet/InteractionPackets.java | 70 ++-- .../protocol/packet/OtherPlayerPackets.java | 7 +- - .../protocol/storage/EntityTracker.java | 126 +++++++- - .../api/model/entity/EntityRiderSeatTest.java | 36 +++ - .../protocol/storage/EntityTrackerTest.java | 293 ++++++++++++++++++ - 9 files changed, 531 insertions(+), 54 deletions(-) + .../protocol/storage/EntityTracker.java | 127 ++++++- + .../api/model/entity/EntityRiderSeatTest.java | 36 ++ + .../protocol/storage/EntityTrackerTest.java | 329 ++++++++++++++++++ + 9 files changed, 568 insertions(+), 54 deletions(-) create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/entity/EntityRiderSeatTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/storage/EntityTrackerTest.java @@ -148,10 +156,10 @@ index 0ae576c6..9909bd21 100644 setPassengersPacket.send(BedrockProtocol.class); } diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java -index 39c8bf5f..c45e07d7 100644 +index 63b06dce..11e82603 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java -@@ -95,7 +95,7 @@ public final class EntityPackets { +@@ -94,7 +94,7 @@ public final class EntityPackets { } final EntityData[] entityData = wrapper.read(BedrockTypes.ENTITY_DATA_ARRAY); // entity data wrapper.read(BedrockTypes.ENTITY_PROPERTIES); // entity properties @@ -160,7 +168,7 @@ index 39c8bf5f..c45e07d7 100644 wrapper.read(BedrockTypes.OPTIONAL_PASSENGER_OF_BLOCKS); // passenger of blocks final Entity entity; -@@ -143,6 +143,10 @@ public final class EntityPackets { +@@ -142,6 +142,10 @@ public final class EntityPackets { livingEntity.updateAttributes(attributes); } entity.updateEntityData(entityData); @@ -171,7 +179,7 @@ index 39c8bf5f..c45e07d7 100644 }); protocol.registerClientbound(ClientboundBedrockPackets.ADD_ITEM_ENTITY, ClientboundPackets26_3.ADD_ENTITY, wrapper -> { final EntityTracker entityTracker = wrapper.user().get(EntityTracker.class); -@@ -353,9 +357,15 @@ public final class EntityPackets { +@@ -352,9 +356,15 @@ public final class EntityPackets { wrapper.cancel(); return; } @@ -312,7 +320,7 @@ index 3361cbac..109beb06 100644 protocol.registerClientbound(ClientboundBedrockPackets.MOVE_PLAYER, ClientboundPackets26_3.ENTITY_POSITION_SYNC, wrapper -> { final EntityTracker entityTracker = wrapper.user().get(EntityTracker.class); diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/EntityTracker.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/EntityTracker.java -index aebd19b6..6b6fb7ff 100644 +index aebd19b6..6ad6fe27 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/EntityTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/EntityTracker.java @@ -43,11 +43,18 @@ import net.raphimc.viabedrock.api.chunk.BedrockBlockEntity; @@ -369,7 +377,7 @@ index aebd19b6..6b6fb7ff 100644 return entity; } -@@ -119,9 +131,118 @@ public class EntityTracker extends StoredObject { +@@ -119,9 +131,119 @@ public class EntityTracker extends StoredObject { this.entities.remove(entity.uniqueId()); this.runtimeIdToUniqueId.remove(entity.runtimeId()); this.javaIdToUniqueId.remove(entity.javaId()); @@ -412,7 +420,8 @@ index aebd19b6..6b6fb7ff 100644 + passenger.setMountEntityRuntimeId(-1); + } + if (vehicle != null && this.clientPlayerEntity != null -+ && link.toEntityUniqueId() == this.clientPlayerEntity.uniqueId()) { ++ && link.toEntityUniqueId() == this.clientPlayerEntity.uniqueId() ++ && this.clientPlayerEntity.mountEntityRuntimeId() == vehicle.runtimeId()) { + this.clientPlayerEntity.dismountVehicle(vehicle); + this.clientPlayerEntity.scheduleDismountPositionCorrection(); + } @@ -488,7 +497,7 @@ index aebd19b6..6b6fb7ff 100644 public void spawnItemFrame(final BlockPosition position, final BlockState blockState) { this.removeItemFrame(position); -@@ -240,6 +361,9 @@ public class EntityTracker extends StoredObject { +@@ -240,6 +362,9 @@ public class EntityTracker extends StoredObject { return this.idCounter.getAndIncrement(); } @@ -542,10 +551,10 @@ index 00000000..25f75218 +} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/storage/EntityTrackerTest.java b/src/test/java/net/raphimc/viabedrock/protocol/storage/EntityTrackerTest.java new file mode 100644 -index 00000000..77b9559d +index 00000000..85498d35 --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/protocol/storage/EntityTrackerTest.java -@@ -0,0 +1,293 @@ +@@ -0,0 +1,329 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -689,6 +698,42 @@ index 00000000..77b9559d + } + + @Test ++ void staleVehicleUnlinkCannotTeleportPlayerOffReplacementMount() { ++ final UserConnection user = user(); ++ final EntityTracker tracker = new EntityTracker(user); ++ final int[] corrections = {0}; ++ final ClientPlayerEntity player = new ClientPlayerEntity(user, 1, UUID.randomUUID(), ++ new PlayerAbilities(1, (byte) 0, (byte) 0)) { ++ @Override ++ public void scheduleDismountPositionCorrection() { ++ corrections[0]++; ++ } ++ }; ++ tracker.addEntity(player, false); ++ final Entity first = tracker.addEntity(10, 100, EntityTypes26_3.OAK_BOAT.identifier(), EntityTypes26_3.OAK_BOAT); ++ final Entity second = tracker.addEntity(20, 200, EntityTypes26_3.OAK_BOAT.identifier(), EntityTypes26_3.OAK_BOAT); ++ second.setPosition(new Position3f(12F, 64F, 8F)); ++ final Position3f position = new Position3f(3F, 75F, 4F); ++ player.setPosition(position); ++ tracker.applyEntityLink(link(first.uniqueId(), player.uniqueId(), ActorLinkType.Riding)); ++ tracker.applyEntityLink(link(second.uniqueId(), player.uniqueId(), ActorLinkType.Riding)); ++ tracker.drainPassengerUpdates(); ++ ++ tracker.applyEntityLink(link(first.uniqueId(), player.uniqueId(), ActorLinkType.None)); ++ ++ assertEquals(second.runtimeId(), player.mountEntityRuntimeId()); ++ assertEquals(position, player.position()); ++ assertEquals(0, corrections[0]); ++ assertEquals(List.of(), tracker.drainPassengerUpdates()); ++ ++ tracker.applyEntityLink(link(second.uniqueId(), player.uniqueId(), ActorLinkType.None)); ++ tracker.applyEntityLink(link(second.uniqueId(), player.uniqueId(), ActorLinkType.None)); ++ assertEquals(-1, player.mountEntityRuntimeId()); ++ assertEquals(1, corrections[0]); ++ assertEquals(List.of(), second.passengers()); ++ } ++ ++ @Test + void repeatedRiderLinkPreservesExistingSeatOwnership() { + final UserConnection user = user(); + final EntityTracker tracker = new EntityTracker(user); diff --git a/patches/viabedrock/upstreamable/0008-discard-stale-passenger-links.pr.md b/patches/viabedrock/upstreamable/0008-discard-stale-passenger-links.pr.md index a091f84a..e87ac3d9 100644 --- a/patches/viabedrock/upstreamable/0008-discard-stale-passenger-links.pr.md +++ b/patches/viabedrock/upstreamable/0008-discard-stale-passenger-links.pr.md @@ -1,89 +1,11 @@ -Resolve links received before their actors, preserve native player seat priority, and clear passenger membership and mount state when links or actors disappear. Remounting removes the passenger from its previous vehicle; a stale unlink cannot clear a newer remote mount. +Resolve links received before their actors, preserve native player seat priority, and clear passenger membership when links or actors disappear. Remounting removes the passenger from its previous vehicle. A stale unlink cannot clear a newer mount or schedule a local player teleport. A valid local unlink schedules one position correction; repeating it does not resend that correction. -Unlinking also removes a stale unique ID when its passenger actor is already gone. -A regression retains the other live passenger and verifies the resulting vehicle update. +Expose the optional finite native rider vector at metadata 56 without replacing absent anchors with a zero vector. Saved Bedrock 1.26.51.1 protocol 2193 chair captures place the player eye at the vehicle position plus this vector rotated by negative vehicle yaw. Java adapters subtract the passenger eye offset once. Standalone Java clients retain Java attachment placement. -Expose the optional finite native rider vector at metadata 56 for client adapters. Bedrock 1.26.51.1 protocol 2193 packets provide this vector on mounted actors. Two saved player/chair pairs independently place the player eye at vehicle position plus the vector rotated by negative vehicle yaw, within 1e-7 world units. The vector uses native actor-origin units; a Java adapter subtracts the passenger eye offset once. +Saved 1.26.51.1 native instruction fixtures cover 160 complete membership-consumer cases, 224 Actor/Mob insertions, constructor flag segments, and four self-mount controls. Core matches 40 resolved membership decisions and 448 insertions across both accepted link roles. Component mutations execute natively. Typed stores, actor lookup, allocation, and notification effects remain declared fixture boundaries. The owning commit records the executable and implementation references. -The earlier core checkpoint passed 293 tests with no failures, errors or skips. Targeted tests cover finite and absent vectors, pending links, stale unlinks, remounts and vehicle removal. Ordinary Java clients behind ViaProxy retain Java attachment placement unless a native client adapter receives the metadata. +The canonical 109-patch core stack builds with 2,232 tests passing and 177 optional skips. Main, test, and tool Checkstyle checks pass. The stale-local-unlink regression fails before the guard and passes after it. It also checks valid dismount and duplicate-unlink behavior. Optional native comparison fixtures are separate from the mandatory regression. +A private installed BDS 1.26.60.6 control compares two same-tick kill/spawn/remount sequences with two delayed replacements. Both same-tick replacements receive a BDS detach before any subsequent client request. Both replacements delayed by three ticks retain their rider and accept movement. No client dismount request occurs. This distinguishes server fixture timing from client mount admission; it does not exercise the stale-unlink guard directly. -## Native membership decisions - -Unknown link roles now leave pending and resolved relationships intact. -A repeated link for an existing passenger preserves its seat and mount state. -It emits no passenger update. -This prevents an unknown role from creating a passenger or replacing a valid pending link. - -The pinned 1.26.51.1 client establishes these predicates through packet factory `1416b5540`, dispatcher `141733530`, and receiver `1413456e0`. -The receiver appends the raw link to `ActorLinkQueueComponent`. -Consumer `145947f80` resolves both actors and checks existing membership with the native unique ID and vehicle component getters. -Only roles one and two can start riding. -An existing member skips start-riding, angular-velocity changes, and the passenger locator update. -Other nonzero roles perform no action. - -A private instruction fixture executes 160 complete consumer cases. -It covers actor resolution, existing membership, supported and unknown roles, unlink flags, and queue retirement. -The real typed getters and membership scan execute unchanged. -World actor lookup, mounting and removal side effects, locator updates, and iteration notifications are fixture boundaries. -A Java comparison covers all 40 resolved-actor cases from that fixture. -Separate regressions cover seat ownership and unknown roles around deferred actor arrival. -All three regressions fail before the fix and pass after it. - -The native `has_rider` getter also passes 288 executable contexts. -It tests whether the vehicle component is nonempty, including entries with unresolved or invalid passenger handles. -Java's resolved passenger list does not establish that component's full state. - -Those membership comparisons do not establish component mutations, queue timing, or mounted-camera rendering. The insertion comparisons below cover common seat ordering. -The native consumer clears unresolved links when it processes a level, while core retains pending links until actors arrive. -That scheduling difference and the native locator provider still need separate integration work. - -The membership checkpoint core build passed 1,488 tests, with 90 optional skips and no failures or errors. -Main, test, and tool Checkstyle checks pass. -The occupied-stack, cooking identity, and native NBT comparison fixtures remain enabled during this build. - - -## Native passenger insertion - -Both accepted link roles now use the same seat insertion rule. -A new player moves before the first passenger when that passenger resolves to a non-player. -An existing first player keeps its seat. -An unresolved first passenger also keeps its place. -Other new passengers append in arrival order. -The wire role no longer promotes a non-player or displaces a player. -Self-mount links cannot enter pending or resolved membership. - -Native consumer `145947f80` sends both roles through the same start-riding call. -Base Actor insertion `1419fe0f0` and Mob wrapper `14207d700` establish the common ordering rule. -The actual Player constructor sets the tested player flag; the Actor constructors clear it. -The fixture executes those flag-writing instruction segments before insertion. -It does not execute the complete constructors. - -A private fixture executes 224 complete Actor/Mob insertion calls. -The actual native routines write the passenger back-reference and vehicle vector. -They cover zero through 257 existing entries, unresolved first passengers, and empty, full, or spare capacity. -Native allocation and growth cross the aligned-allocation boundary. -The shared counter returns to its initial value after each call. -Four complete Actor/Mob start-riding controls reject self-mounts. - -Core matches all 224 insertion results for each accepted role, giving 448 comparisons. -Mandatory regressions cover mixed actor types, preserved player ownership, repeated membership, and self-mounts before and after actor arrival. -The old role-based implementation fails four targeted checks. -All twelve tracker tests pass after the change, including both native comparison fixtures. - -Typed component stores and storage factories remain fixture inputs. -Position, notification, changed-flag, and event callbacks remain side-effect boundaries. -The ordering logic and both component mutations execute in native code. -Derived vehicle overrides, complete cycle handling, queue timing, removal lifetimes, and mounted-camera rendering remain separate work. - - -The insertion checkpoint core build passes 1,491 tests, with 90 optional skips and no failures or errors. -Main, test, and tool Checkstyle checks and Maven POM generation pass. -All 104 exported patches replay to the identical tested source tree. -The four inventory comparison fixtures remain enabled alongside both passenger fixtures. -All four patch health and prerequisite tests pass. - -A fresh mounted-scene capture remains unverified. -The recorder's copy-space preflight rejects the current 2.4 GiB of free space. -It requires the private profile size plus a 5 GiB reserve. -No client or server starts during this preflight. +Native queue retirement differs from core's pending-link retention. Full removal lifetimes, derived vehicle overrides, cycle handling, and mounted-camera rendering remain separate work. These checks do not establish complete native-client trajectory or pixel parity. diff --git a/patches/viabedrock/upstreamable/0013-translate-remote-player-locations.patch b/patches/viabedrock/upstreamable/0013-translate-remote-player-locations.patch index a03e740c..bcfad147 100644 --- a/patches/viabedrock/upstreamable/0013-translate-remote-player-locations.patch +++ b/patches/viabedrock/upstreamable/0013-translate-remote-player-locations.patch @@ -1,4 +1,4 @@ -From a282442dec513ccd726c3fa76d86e0f24165877e Mon Sep 17 00:00:00 2001 +From 6a8ee278d4bab9fb507904ed0d873e3648b78d72 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:55:34 +0200 Subject: [PATCH] feat(hud): preserve native Bedrock locator updates @@ -341,7 +341,7 @@ index 00000000..52b621c8 + +} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java -index eea1b264..80c97cff 100644 +index 7635a534..bb514a85 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java @@ -129,6 +129,7 @@ public final class HudPackets { diff --git a/patches/viabedrock/upstreamable/0013-translate-remote-player-locations.pr.md b/patches/viabedrock/upstreamable/0013-translate-remote-player-locations.pr.md new file mode 100644 index 00000000..da9d2a66 --- /dev/null +++ b/patches/viabedrock/upstreamable/0013-translate-remote-player-locations.pr.md @@ -0,0 +1,30 @@ +# Preserve native player locations and locator groups + +## Purpose + +Decode legacy player locations and native locator groups for client integrations. +Retain visibility, position, texture, size, color, actor binding, and position authority. +Publish complete snapshots after join and when a listener registers late. + +## Design constraints + +Keep locator groups across dimension changes. +Ignore duplicate additions and updates for unknown groups. +Preserve the native rules for omitted fields instead of clearing every absent value. +Publish removal before replacement of the same group ID so clients can reset smoothing. +Coalesce ordinary updates and preserve unrelated groups. +Keep published snapshots immutable and bound decoding to the payload size. + +## Validation and remaining limits + +Native Bedrock 1.26.51.1, build 51061372 and protocol 2193, supplies packet and lifecycle evidence. +Captured controls establish the field masks and distinguish smoothed updates from immediate group replacement. +The owning patch records those versioned observations. + +Local regressions cover packet discriminators, signed actor IDs, optional fields, group merging, removal and replacement ordering, late listeners, and snapshot round trips. +The latest full core build passes these tests. +Tests remain in the local stack and are excluded from the upstream PR. + +These checks establish decoding and state transport. +The [companion renderer controls](../../viafabricplus-bedrock/upstreamable/0022-render-native-locator-groups.pr.md) cover current native, direct, and ViaProxy player markers. +Complete locator HUD parity remains unverified. diff --git a/patches/viabedrock/upstreamable/0014-initialize-and-preserve-bedrock-clock-time.patch b/patches/viabedrock/upstreamable/0014-initialize-and-preserve-bedrock-clock-time.patch index ef2e1d4f..6662972c 100644 --- a/patches/viabedrock/upstreamable/0014-initialize-and-preserve-bedrock-clock-time.patch +++ b/patches/viabedrock/upstreamable/0014-initialize-and-preserve-bedrock-clock-time.patch @@ -1,4 +1,4 @@ -From 562ea4fb7abc4e46ccfeb28f8fec7bac7be0a6aa Mon Sep 17 00:00:00 2001 +From 7b2afbc5ae0f74e9c4e6810dac4c3c4328373c52 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:55:34 +0200 Subject: [PATCH] fix(world): preserve native Bedrock world clock semantics @@ -292,7 +292,7 @@ index 5f991ba9..5f2db346 100644 + } diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/GameRulesStorage.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/GameRulesStorage.java -index 9dcfed5b..e1c58934 100644 +index 9d4df851..8285bf9c 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/GameRulesStorage.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/GameRulesStorage.java @@ -27,6 +27,7 @@ import net.raphimc.viabedrock.protocol.BedrockProtocol; @@ -303,8 +303,8 @@ index 9dcfed5b..e1c58934 100644 import java.util.HashMap; import java.util.Locale; -@@ -63,6 +64,8 @@ public class GameRulesStorage extends StoredObject { - PacketFactory.sendJavaGameEvent(this.user(), GameEventType.IMMEDIATE_RESPAWN, (boolean) gameRule.value() ? 1F : 0F); +@@ -65,6 +66,8 @@ public class GameRulesStorage extends StoredObject { + PacketFactory.sendJavaDeathMessage(this.user()); } else if (gameRule.name().equalsIgnoreCase("doLimitedCrafting")) { PacketFactory.sendJavaGameEvent(this.user(), GameEventType.LIMITED_CRAFTING, (boolean) gameRule.value() ? 1F : 0F); + } else if (gameRule.name().equalsIgnoreCase("doDayLightCycle")) { diff --git a/patches/viabedrock/upstreamable/0018-preserve-sparse-actor-spatial-updates.patch b/patches/viabedrock/upstreamable/0018-preserve-sparse-actor-spatial-updates.patch index 53e0471f..aa4429db 100644 --- a/patches/viabedrock/upstreamable/0018-preserve-sparse-actor-spatial-updates.patch +++ b/patches/viabedrock/upstreamable/0018-preserve-sparse-actor-spatial-updates.patch @@ -1,4 +1,4 @@ -From e77122ebb64527652a49f91e7bf97707b50360d7 Mon Sep 17 00:00:00 2001 +From 4a82a8af711a6a19cf750d1fc089979e9b97104e Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:55:34 +0200 Subject: [PATCH] fix(entities): preserve Bedrock spatial updates @@ -157,10 +157,10 @@ index 5addfdbd..163c5267 100644 return this.onGround; } diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java -index c45e07d7..34e1466f 100644 +index 11e82603..74d910d8 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java -@@ -73,6 +73,11 @@ public final class EntityPackets { +@@ -72,6 +72,11 @@ public final class EntityPackets { private static final float PAINTING_POS_OFFSET = -0.46875F; private static final SwingAnimation DEFAULT_SWING_ANIMATION = new SwingAnimation(EnumTypes.SWING_ANIMATION1_21_11.idFromName("whack"), 6); @@ -172,7 +172,7 @@ index c45e07d7..34e1466f 100644 public static void register(final BedrockProtocol protocol) { protocol.registerClientbound(ClientboundBedrockPackets.ADD_ENTITY, ClientboundPackets26_3.ADD_ENTITY, wrapper -> { final EntityTracker entityTracker = wrapper.user().get(EntityTracker.class); -@@ -204,6 +209,8 @@ public final class EntityPackets { +@@ -203,6 +208,8 @@ public final class EntityPackets { return; } @@ -181,7 +181,7 @@ index c45e07d7..34e1466f 100644 if (entity == entityTracker.getClientPlayer()) { if (!teleported && !forceMoveLocalEntity) { wrapper.cancel(); -@@ -265,8 +272,8 @@ public final class EntityPackets { +@@ -264,8 +271,8 @@ public final class EntityPackets { final boolean onGround = wrapper.read(Types.BOOLEAN); final boolean teleported = wrapper.read(Types.BOOLEAN); // If the position shouldn't be interpolated final boolean forceMoveLocalEntity = wrapper.read(Types.BOOLEAN); @@ -192,7 +192,7 @@ index c45e07d7..34e1466f 100644 final Entity entity = entityTracker.getEntityByRid(entityRuntimeId); if (entity == null) { -@@ -274,14 +281,23 @@ public final class EntityPackets { +@@ -273,14 +280,23 @@ public final class EntityPackets { return; } @@ -219,7 +219,7 @@ index c45e07d7..34e1466f 100644 if (teleported) { wrapper.setPacketType(ClientboundPackets26_3.PLAYER_POSITION); entityTracker.getClientPlayer().writePlayerPositionPacketToClient(wrapper, Relative.union(Relative.ROTATION, Relative.VELOCITY), true); -@@ -300,23 +316,9 @@ public final class EntityPackets { +@@ -299,23 +315,9 @@ public final class EntityPackets { return; } @@ -245,7 +245,7 @@ index c45e07d7..34e1466f 100644 PacketFactory.sendJavaRotateHead(wrapper.user(), entity); } entity.setOnGround(onGround); -@@ -333,20 +335,19 @@ public final class EntityPackets { +@@ -332,20 +334,19 @@ public final class EntityPackets { wrapper.write(Types.BOOLEAN, entity.isOnGround()); // on ground }); protocol.registerClientbound(ClientboundBedrockPackets.SET_ENTITY_MOTION, ClientboundPackets26_3.SET_ENTITY_MOTION, wrapper -> { @@ -275,7 +275,7 @@ index c45e07d7..34e1466f 100644 }); protocol.registerClientbound(ClientboundBedrockPackets.REMOVE_ENTITY, ClientboundPackets26_3.REMOVE_ENTITIES, wrapper -> { final EntityTracker entityTracker = wrapper.user().get(EntityTracker.class); -@@ -707,6 +708,17 @@ public final class EntityPackets { +@@ -703,6 +704,17 @@ public final class EntityPackets { }); } diff --git a/patches/viabedrock/upstreamable/0020-forward-predicted-boat-and-horse-movement.patch b/patches/viabedrock/upstreamable/0020-forward-predicted-boat-and-horse-movement.patch index bf9a152f..91aff306 100644 --- a/patches/viabedrock/upstreamable/0020-forward-predicted-boat-and-horse-movement.patch +++ b/patches/viabedrock/upstreamable/0020-forward-predicted-boat-and-horse-movement.patch @@ -1,4 +1,4 @@ -From 10a53b2544c110749b6ad50130e7be1d127aeafd Mon Sep 17 00:00:00 2001 +From 0708a80106dba9b4105bfeaca3be3e4c33b55405 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:55:34 +0200 Subject: [PATCH] fix(vehicles): forward predicted boat and horse movement @@ -187,10 +187,10 @@ index 163c5267..767c62d6 100644 if (this.lastReceivedRotation == null) { this.lastReceivedRotation = new Position3f(receivedAngle(rotation.x()), receivedAngle(rotation.y()), receivedAngle(rotation.z())); diff --git a/src/main/java/net/raphimc/viabedrock/api/util/PacketFactory.java b/src/main/java/net/raphimc/viabedrock/api/util/PacketFactory.java -index f67b7f7d..4bdf42be 100644 +index 68fa4fac..38463948 100644 --- a/src/main/java/net/raphimc/viabedrock/api/util/PacketFactory.java +++ b/src/main/java/net/raphimc/viabedrock/api/util/PacketFactory.java -@@ -101,7 +101,7 @@ public final class PacketFactory { +@@ -117,7 +117,7 @@ public final class PacketFactory { public static void sendJavaRotateHead(final UserConnection user, final Entity entity) { final PacketWrapper rotateHead = PacketWrapper.create(ClientboundPackets26_3.ROTATE_HEAD, user); rotateHead.write(Types.VAR_INT, entity.javaId()); // entity id @@ -378,10 +378,10 @@ index 50758f19..dd053c7a 100644 wrapper.write(BedrockTypes.POSITION_2F, MathUtil.calculateMovementDirections(clientPlayer.authInputData(), false)); // raw move vector diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java -index 34e1466f..dcd49bf6 100644 +index 74d910d8..4103bd32 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java -@@ -134,12 +134,12 @@ public final class EntityPackets { +@@ -133,12 +133,12 @@ public final class EntityPackets { wrapper.write(Types.UUID, entity.javaUuid()); // uuid wrapper.write(Types.VAR_INT, entity.javaType().getId()); // type id wrapper.write(Types.DOUBLE, (double) position.x()); // x @@ -398,7 +398,7 @@ index 34e1466f..dcd49bf6 100644 wrapper.write(Types.VAR_INT, 0); // data wrapper.send(BedrockProtocol.class); wrapper.cancel(); -@@ -226,11 +226,11 @@ public final class EntityPackets { +@@ -225,11 +225,11 @@ public final class EntityPackets { wrapper.write(Types.VAR_INT, 1); // Stepped wrapper.write(Types.VAR_INT, 1); // 1 step wrapper.write(Types.DOUBLE, (double) entity.position().x()); // x @@ -413,7 +413,7 @@ index 34e1466f..dcd49bf6 100644 wrapper.write(Types.BOOLEAN, entity.isOnGround()); // on ground } return; -@@ -244,10 +244,10 @@ public final class EntityPackets { +@@ -243,10 +243,10 @@ public final class EntityPackets { wrapper.write(Types.VAR_INT, 1); // Stepped wrapper.write(Types.VAR_INT, 1); // 1 step wrapper.write(Types.DOUBLE, (double) position.x()); // x @@ -426,7 +426,7 @@ index 34e1466f..dcd49bf6 100644 wrapper.write(Types.FLOAT, pitch); // pitch wrapper.write(Types.BOOLEAN, entity.isOnGround()); // on ground -@@ -306,11 +306,11 @@ public final class EntityPackets { +@@ -305,11 +305,11 @@ public final class EntityPackets { wrapper.write(Types.VAR_INT, 1); // Stepped wrapper.write(Types.VAR_INT, 1); // 1 step wrapper.write(Types.DOUBLE, (double) entity.position().x()); // x @@ -441,7 +441,7 @@ index 34e1466f..dcd49bf6 100644 wrapper.write(Types.BOOLEAN, entity.isOnGround()); // on ground } return; -@@ -327,11 +327,11 @@ public final class EntityPackets { +@@ -326,11 +326,11 @@ public final class EntityPackets { wrapper.write(Types.VAR_INT, 1); // Stepped wrapper.write(Types.VAR_INT, 1); // 1 step wrapper.write(Types.DOUBLE, (double) entity.position().x()); // x diff --git a/patches/viabedrock/upstreamable/0026-remove-score-ids-across-objectives.patch b/patches/viabedrock/upstreamable/0026-remove-score-ids-across-objectives.patch index ceab3b4b..d89093a4 100644 --- a/patches/viabedrock/upstreamable/0026-remove-score-ids-across-objectives.patch +++ b/patches/viabedrock/upstreamable/0026-remove-score-ids-across-objectives.patch @@ -1,4 +1,4 @@ -From 4af13c3a2eb0aaec43caba8a36a6aec611b7a647 Mon Sep 17 00:00:00 2001 +From 49935d0b8b8587a33e595e177c066b71f1744f19 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:55:34 +0200 Subject: [PATCH] fix(scoreboard): remove score IDs across objectives @@ -11,7 +11,7 @@ Bedrock can remove a score ID without naming an objective. Clear every tracked o create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/packet/HudPacketsTest.java diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java -index 80c97cff..48cadcff 100644 +index bb514a85..0b9b34b1 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java @@ -18,6 +18,7 @@ @@ -51,7 +51,7 @@ index 80c97cff..48cadcff 100644 } final ScoreboardObjective objective = scoreboardTracker.getObjective(objectiveName); -@@ -457,6 +456,21 @@ public final class HudPackets { +@@ -451,6 +450,21 @@ public final class HudPackets { }); } diff --git a/patches/viabedrock/upstreamable/0043-load-and-apply-editable-gamerules.patch b/patches/viabedrock/upstreamable/0043-load-and-apply-editable-gamerules.patch index 11e29bbc..924a9185 100644 --- a/patches/viabedrock/upstreamable/0043-load-and-apply-editable-gamerules.patch +++ b/patches/viabedrock/upstreamable/0043-load-and-apply-editable-gamerules.patch @@ -1,4 +1,4 @@ -From 62ec1bf62e24e82ac42b3e55483eef2c2899262b Mon Sep 17 00:00:00 2001 +From 0027c4f7e5015c91cc40bdcdfdd202002a9b7372 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] fix(world): load and apply editable gamerules in Java options @@ -211,7 +211,7 @@ index 00000000..f4df37f9 + +} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/GameRulesStorage.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/GameRulesStorage.java -index e1c58934..5940a168 100644 +index 8285bf9c..634a5704 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/GameRulesStorage.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/GameRulesStorage.java @@ -39,6 +39,7 @@ public class GameRulesStorage extends StoredObject { @@ -230,7 +230,7 @@ index e1c58934..5940a168 100644 final Object previousValue = this.gameRules.put(gameRule.name().toLowerCase(Locale.ROOT), gameRule.value()); if (previousValue == null) { ViaBedrock.getPlatform().getLogger().log(Level.WARNING, "Received unknown game rule: " + gameRule.name()); -@@ -76,4 +78,12 @@ public class GameRulesStorage extends StoredObject { +@@ -78,4 +80,12 @@ public class GameRulesStorage extends StoredObject { return (T) this.gameRules.get(name.toLowerCase(Locale.ROOT)); } diff --git a/patches/viabedrock/upstreamable/0047-encode-current-bedrock-skin-packets.patch b/patches/viabedrock/upstreamable/0047-encode-current-bedrock-skin-packets.patch index 245cfa99..0fe72120 100644 --- a/patches/viabedrock/upstreamable/0047-encode-current-bedrock-skin-packets.patch +++ b/patches/viabedrock/upstreamable/0047-encode-current-bedrock-skin-packets.patch @@ -1,4 +1,4 @@ -From 88243458440d7fc5e28a0738eb56b725b3e63e02 Mon Sep 17 00:00:00 2001 +From 0039c9bff45942aeb85d9e546e538e9c1f0ec5f7 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] fix(skins): encode current Bedrock skin packets @@ -380,7 +380,7 @@ index 00000000..be644704 + +} diff --git a/src/main/java/net/raphimc/viabedrock/api/util/PacketFactory.java b/src/main/java/net/raphimc/viabedrock/api/util/PacketFactory.java -index 4bdf42be..c51ffd40 100644 +index 38463948..f9fec82b 100644 --- a/src/main/java/net/raphimc/viabedrock/api/util/PacketFactory.java +++ b/src/main/java/net/raphimc/viabedrock/api/util/PacketFactory.java @@ -22,6 +22,8 @@ import com.viaversion.viaversion.api.connection.UserConnection; @@ -392,7 +392,7 @@ index 4bdf42be..c51ffd40 100644 import com.viaversion.viaversion.api.type.Types; import com.viaversion.viaversion.api.type.types.version.VersionedTypes; import com.viaversion.viaversion.libs.gson.JsonNull; -@@ -174,6 +176,21 @@ public final class PacketFactory { +@@ -190,6 +192,21 @@ public final class PacketFactory { wrapper.write(Types.VAR_INT, 0); // Default randomization type } @@ -451,7 +451,7 @@ index 070d83f4..3a5f8a94 100644 public record AnimationData(BufferedImage image, int type, float frames, int expression) { } diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java -index 48cadcff..c717576d 100644 +index 0b9b34b1..ae020452 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java @@ -99,9 +99,8 @@ public final class HudPackets { diff --git a/patches/viabedrock/upstreamable/0054-render-native-block-state-geometry-and-materials.patch b/patches/viabedrock/upstreamable/0054-render-native-block-state-geometry-and-materials.patch index 1f239e30..c4183a70 100644 --- a/patches/viabedrock/upstreamable/0054-render-native-block-state-geometry-and-materials.patch +++ b/patches/viabedrock/upstreamable/0054-render-native-block-state-geometry-and-materials.patch @@ -1,4 +1,4 @@ -From f8171bd71728c31689f4c04fabf72e32d56d3126 Mon Sep 17 00:00:00 2001 +From cd316915e064251e31c49219028bfae51044cdfe 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(blocks): render Bedrock state geometry and materials @@ -137,14 +137,14 @@ Keep both cases distinct in compiled participation metadata. Omit faces whose cutout or blended material has no visible pixels after alpha conversion. Java 26.3 assigns zero-area UV ranges to opaque rendering, which showed dark faces for fully transparent neighbors beside the pinned native culling fixture. Keep opaque materials visible and compile authored culling participation before omitting any faces, so invisible geometry retains its physical boxes and voxel coverage. Resolve the actual sprite on copied back faces and mixed material elements. -Treat zero-volume collision and selection boxes as empty physical shapes. -Do this before rotation so an enclosing AABB cannot make an empty shape -solid, and after transformation for collapsed axes. Canonical metadata -removes empty boxes without rejecting resource-pack conversion or its -cache fingerprint. Nonfinite and inverted metadata bounds remain errors. -Regression tests cover all three empty axes, rotated models, metadata -round trips and cache identity. The reported server values still need -a capture before attributing its exact failure to this case. +Retain degenerate collision boxes through component conversion and physical +metadata. Native primary shapes include them in their camera envelope, and +native AABB transformation can expand a plane into positive volume. Filter +empty volumes when movement queries append the translated bounds. Preserve +selection conversion separately. Metadata still rejects nonfinite and inverted +bounds. Keep retained planes in the converted-pack fingerprint because they +can change the camera envelope. Regression tests cover flat bounds, rotations, +physical queries, metadata round trips and cache invalidation. The original reported server remains unmatched. Retain position-seeded random offsets in visual deduplication, accepted server-pack metadata and cache identity. Match the 1.26.51.1 build 51061372 @@ -153,11 +153,36 @@ operation order. The production sampler matches 8,192 native outputs and 16,384 height-invariance comparisons without differences. Matching-build BDS sends valid ranges in pixel units and rejects fixture -ranges that move collision outside its accepted bounds. Divide endpoints -by 16 at component loading without inventing a client clamp. Keep floored +ranges that move collision outside its accepted bounds. Divide offset range endpoints +by 16 at component loading without adding a range clamp. Keep floored world component collision separate from continuous selection and camera translation; native wrappers use different paths. Metadata format 2 makes those definitions available through ViaProxy as well as direct sessions. + +Combine local component boxes before translating the camera envelope to world +floats. Filtering each translated box first loses the native envelope when +narrow boxes collapse separately at large coordinates. Two disjoint one-pixel +boxes within one block reproduce this at X/Z 1,048,576. Native 1.26.60.5 primary +shape dispatch combines cached component bounds before its continuous offset +and world translation. Original full shape, state/shared offset lookup and +boat friction-probe controls retain this order. The regression keeps camera +volume while selection and movement collision preserve their own paths. +These controls supply component storage and do not establish every vanilla +shape override, authored component decoding or full camera trajectories. + +Decode collision bounds from their protocol-2225 component form. The original +1.26.60.5 description decoder accepts a Byte enabled flag and Float coordinates; +missing or differently typed values decode to false and zero. Description +application clamps X/Z endpoints to 0..16 pixels and Y to 0..24, scales and +normalizes endpoints, and supplies a unit box for an enabled empty list. Apply +this normalization before existing model transformations. Keep selection on +its separate conversion path. Native NBT lookup, component application, typed +cache lookup and primary shape execute together under supplied storage. +Retain the original Block finalizer and cache initializer in comparison controls +so decoded component pointers and readiness follow the original path. Mixed +flat/solid camera bounds and supplied-matrix plane transformations establish +why degenerate bounds cannot be removed early. Exact transformed float order, +complete world publication and installed camera trajectories remain open. This supplies custom component shapes, not a complete native movement solver or vanilla per-type collision provider. @@ -395,6 +420,70 @@ as the source asset instead of maintaining a derived JSON list and a conversion tool. Bundle its Apache-2.0 license and document the source, checksum, and update procedure in Data Asset Sources.md. Runtime palette selection still depends on the current StartGame registrations. + +Reproduce the protocol-2225 physical transformation decoder and matrix +application separately from model vertices. Require integer quarter turns +and Float scale, translation and pivot fields, with native missing-field +defaults. Build the native quaternion matrix with float products, center +it on the block, and project bounds using the original paired sums. +Do not transform Legacy fallback bounds when no physical component exists. + +Original 1.26.60 NBT decode, typed component application, Block finalizer +and primary shape controls expose reversed rotation and float-boundary +errors in the previous double corner path. Retain the separate visual +transform and existing selection-component decode policy; their complete +parity is outside these physical-bound controls. + +Decode selection origin/size independently from collision endpoints. The +1.26.60 native decoder requires a Byte enabled flag and Float list items, +defaults missing entries to zero, clamps every axis to one block, reflects +X through the rounded center/half extent, and retains flat bounds before +physical transformation. Skip malformed descriptions lacking a required +vector instead of turning them into full selection blocks. + +An actual BDS pack and installed BlockState hooks expose the prior X-side +error. Original selection decode, typed application and Block finalization +controls cover all quarter-turn combinations, clamping and truncated lists. +Complete native picking and cursor trajectories still require validation. + +Resolve physical collision and offset data before visual compilation and Java carrier assignment. Preserve the native base-factory default separately from unresolved archetypes, and share camera envelope/float translation with converted-pack metadata. Windows 1.26.60.5 native import, class admission, description application and finalized primary queries establish strict collision decoding, absent-component defaults and empty/planar bounds. + +Publish primary bounds for hardcoded slabs and inherited slab providers in +Windows 1.26.60.5, protocol 2225. Full original built-in name initialization +and main registration reproduce the exact 1,382 pinned hardcoded names. +Select the 138 types sharing the original slab local method and common +primary method, including 16 copper types created by derived factories. +A direct-factory-only catalog misses these inherited providers. + +Original property construction, subset registry enumeration/NBT hashing +and finalization match all 276 pinned slab states. Original primary and +boat-friction queries retain half bounds, ignore half for double slabs, +and preserve float collapse at far coordinates. The subset retains actual +main-created Legacy objects; post-registration aliases, unrelated state +enumeration and loaded native world publication remain outside this scope. + +Select only these registered classes and exact decoded half values. Leave +unresolved classes with the existing provider. Carry the slab geometry in +the same nonvisual world dictionary and use the accepted-level camera hook. +Other shape providers and complete boat integration remain separate work. + +Resolve additional vanilla primary providers from the original Windows +1.26.60.5 class dispatch and finalized native caches. The exact 203 classes +whose primary method returns zero ignore collision and offset components. +The 710 classes with the common primary method and constant local getter +use their cached collision component before the Legacy fallback; lectern +therefore has a 0.9-block-high box, and dirt keeps its unit component bounds. +Their 4,475 states have no offset component and retain 18 fixed profiles. + +Complete original initialization, main/post registration, direction-string +initialization and full enumeration construct 22,543 hardcoded states. +22,542 hashes match the pinned palette; the unknown class computes 147887818 +while the palette uses fallback -2. Keep that independent ID difference +explicit. All 8,219 selected states match 32,876 original primary/friction +queries, including negative coordinates and world-float collapse. Carry +this geometry through the existing registered-state dictionary instead +of borrowing Java shapes. Unsupported provider classes retain their +existing fallback, and custom namespaces do not inherit vanilla bounds. --- Data Asset Sources.md | 53 +- .../chunk/blockstate/BlockItemDescriptor.java | 29 + @@ -407,8 +496,11 @@ selection still depends on the current StartGame registrations. .../api/model/CustomBlockFaceLighting.java | 98 ++ .../api/model/CustomBlockLighting.java | 59 + .../api/model/CustomBlockOffset.java | 97 ++ - .../api/model/CustomBlockProperties.java | 150 +++ + .../api/model/CustomBlockProperties.java | 146 +++ .../api/model/CustomBlockVisual.java | 27 + + .../api/model/NativeBlockGeometry.java | 79 ++ + .../api/model/NativeBlockPrimaryGeometry.java | 297 +++++ + .../api/model/NativeSlabGeometry.java | 82 ++ .../definition/BlockCullingDefinitions.java | 86 ++ .../definition/BlockDefinitions.java | 17 +- .../definition/CustomBlockDefinition.java | 73 ++ @@ -422,12 +514,15 @@ selection still depends on the current StartGame registrations. .../protocol/packet/JoinPackets.java | 31 +- .../protocol/packet/ResourcePackPackets.java | 47 +- .../protocol/packet/VoxelShapePackets.java | 35 + - .../protocol/rewriter/BlockStateRewriter.java | 285 +++-- + .../protocol/rewriter/BlockStateRewriter.java | 308 +++-- .../rewriter/ResourcePackRewriter.java | 2 + .../CustomBlockCullingCompiler.java | 162 +++ - .../resourcepack/CustomBlockModel.java | 587 +++++++++ + .../resourcepack/CustomBlockModel.java | 556 ++++++++ .../CustomBlockResourceRewriter.java | 237 ++++ .../CustomBlockStateDefinitions.java | 225 ++++ + .../resourcepack/NativeBlockCollision.java | 82 ++ + .../resourcepack/NativeBlockSelection.java | 54 + + .../resourcepack/NativeBlockTransform.java | 189 +++ .../protocol/storage/ChunkTracker.java | 40 +- .../storage/CustomBlockPackStorage.java | 37 + .../protocol/storage/ResourcePackStorage.java | 44 +- @@ -447,15 +542,19 @@ selection still depends on the current StartGame registrations. .../api/model/CustomBlockCullingTest.java | 54 + .../api/model/CustomBlockLightingTest.java | 171 +++ .../api/model/CustomBlockOffsetTest.java | 80 ++ - .../api/model/CustomBlockPropertiesTest.java | 69 + + .../api/model/CustomBlockPropertiesTest.java | 92 ++ + .../api/model/NativeBlockGeometryTest.java | 31 + + .../model/NativeBlockPrimaryGeometryTest.java | 46 + + .../api/model/NativeSlabGeometryTest.java | 48 + .../definition/TextureDefinitionsTest.java | 42 + - .../http/ConvertedResourcePackCacheTest.java | 29 + + .../http/ConvertedResourcePackCacheTest.java | 34 + .../protocol/model/BlockPropertiesTest.java | 85 ++ .../protocol/model/BlockTraitStatesTest.java | 291 +++++ .../protocol/model/CustomBlockStatesTest.java | 139 ++ .../rewriter/CustomBlockBoneGeometryTest.java | 213 ++++ - .../CustomBlockModelSelectionTest.java | 512 ++++++++ + .../CustomBlockModelSelectionTest.java | 723 +++++++++++ .../CustomBlockPlaneGeometryTest.java | 170 +++ + .../NativeBlockGeometryRegistrationTest.java | 145 +++ .../CustomBlockCullingCompilerTest.java | 140 ++ .../CustomBlockMaterialVisibilityTest.java | 106 ++ .../CustomBlockStateDefinitionsTest.java | 286 +++++ @@ -464,7 +563,7 @@ selection still depends on the current StartGame registrations. .../storage/VoxelShapeRegistryTest.java | 130 ++ .../storage/WorldPacketBufferTest.java | 230 ++++ .../protocol/task/KeepAliveTaskTest.java | 78 ++ - 68 files changed, 9962 insertions(+), 129 deletions(-) + 78 files changed, 11244 insertions(+), 127 deletions(-) create mode 100644 src/main/java/net/raphimc/viabedrock/api/chunk/blockstate/BlockItemDescriptor.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/chunk/blockstate/BlockItemDescriptors.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/chunk/blockstate/BlockItemStates.java @@ -477,6 +576,9 @@ selection still depends on the current StartGame registrations. create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/CustomBlockOffset.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/CustomBlockProperties.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/CustomBlockVisual.java + create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeBlockGeometry.java + create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeBlockPrimaryGeometry.java + create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeSlabGeometry.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/definition/BlockCullingDefinitions.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/definition/CustomBlockDefinition.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/model/BlockTraitStates.java @@ -486,6 +588,9 @@ selection still depends on the current StartGame registrations. create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/rewriter/resourcepack/CustomBlockModel.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/rewriter/resourcepack/CustomBlockResourceRewriter.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/rewriter/resourcepack/CustomBlockStateDefinitions.java + create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/rewriter/resourcepack/NativeBlockCollision.java + create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/rewriter/resourcepack/NativeBlockSelection.java + create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/rewriter/resourcepack/NativeBlockTransform.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/storage/CustomBlockPackStorage.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/storage/VoxelShapeRegistry.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/storage/WorldPacketBuffer.java @@ -503,6 +608,9 @@ selection still depends on the current StartGame registrations. create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/CustomBlockLightingTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/CustomBlockOffsetTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/CustomBlockPropertiesTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/NativeBlockGeometryTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/NativeBlockPrimaryGeometryTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/NativeSlabGeometryTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/definition/TextureDefinitionsTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/model/BlockPropertiesTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/model/BlockTraitStatesTest.java @@ -510,6 +618,7 @@ selection still depends on the current StartGame registrations. create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/rewriter/CustomBlockBoneGeometryTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/rewriter/CustomBlockModelSelectionTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/rewriter/CustomBlockPlaneGeometryTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/rewriter/NativeBlockGeometryRegistrationTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/rewriter/resourcepack/CustomBlockCullingCompilerTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/rewriter/resourcepack/CustomBlockMaterialVisibilityTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/rewriter/resourcepack/CustomBlockStateDefinitionsTest.java @@ -1672,10 +1781,10 @@ index 00000000..e3b4c919 +} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/CustomBlockProperties.java b/src/main/java/net/raphimc/viabedrock/api/model/CustomBlockProperties.java new file mode 100644 -index 00000000..8abffa1f +index 00000000..a8805d8e --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/model/CustomBlockProperties.java -@@ -0,0 +1,150 @@ +@@ -0,0 +1,146 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -1729,9 +1838,9 @@ index 00000000..8abffa1f + return this.translated(this.selectionBoxes, x, y, z, false); + } + -+ /** Camera collision uses the continuous envelope of the component boxes. */ ++ /** Native camera bounds combine local boxes before world-float translation can collapse them. */ + public List cameraBoxesAt(final int x, final int y, final int z) { -+ return this.translated(this.collisionBoxes, x, y, z, false); ++ return NativeBlockGeometry.cameraBoxesAt(this.collisionBoxes, this.randomOffset, x, y, z); + } + + private List translated(final List boxes, final int x, final int y, final int z, @@ -1811,18 +1920,14 @@ index 00000000..8abffa1f + if (boxes == null || boxes.size() > MAX_BOXES) { + throw new IllegalArgumentException("Too many custom block boxes"); + } -+ final List nonempty = new ArrayList<>(); + for (CustomBlockVisual.Box box : boxes) { + if (box == null || !Double.isFinite(box.minX()) || !Double.isFinite(box.minY()) || !Double.isFinite(box.minZ()) + || !Double.isFinite(box.maxX()) || !Double.isFinite(box.maxY()) || !Double.isFinite(box.maxZ()) + || box.minX() > box.maxX() || box.minY() > box.maxY() || box.minZ() > box.maxZ()) { + throw new IllegalArgumentException("Invalid custom block box"); + } -+ if (box.minX() < box.maxX() && box.minY() < box.maxY() && box.minZ() < box.maxZ()) { -+ nonempty.add(box); -+ } + } -+ return List.copyOf(nonempty); ++ return List.copyOf(boxes); + } + +} @@ -1859,6 +1964,482 @@ index 00000000..59498966 + } + +} +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/NativeBlockGeometry.java b/src/main/java/net/raphimc/viabedrock/api/model/NativeBlockGeometry.java +new file mode 100644 +index 00000000..4acaaa8c +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/model/NativeBlockGeometry.java +@@ -0,0 +1,79 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.model; ++ ++import java.util.ArrayList; ++import java.util.List; ++import java.util.Objects; ++ ++/** Native physical bounds and offsets, independent of a Java block carrier or resource pack. */ ++public record NativeBlockGeometry(List collisionBoxes, CustomBlockOffset randomOffset) { ++ ++ public static final int MAX_BOXES = 512; ++ public static final NativeBlockGeometry EMPTY = new NativeBlockGeometry(List.of(), CustomBlockOffset.NONE); ++ private static final CustomBlockVisual.Box ZERO = new CustomBlockVisual.Box(0, 0, 0, 0, 0, 0); ++ ++ public NativeBlockGeometry { ++ Objects.requireNonNull(randomOffset, "randomOffset"); ++ if (collisionBoxes == null || collisionBoxes.size() > MAX_BOXES) { ++ throw new IllegalArgumentException("Too many native block boxes"); ++ } ++ final List normalized = new ArrayList<>(collisionBoxes.size()); ++ for (final CustomBlockVisual.Box box : collisionBoxes) { ++ if (box == null || !Float.isFinite((float) box.minX()) || !Float.isFinite((float) box.minY()) ++ || !Float.isFinite((float) box.minZ()) || !Float.isFinite((float) box.maxX()) ++ || !Float.isFinite((float) box.maxY()) || !Float.isFinite((float) box.maxZ()) ++ || box.minX() > box.maxX() || box.minY() > box.maxY() || box.minZ() > box.maxZ()) { ++ throw new IllegalArgumentException("Invalid native block box"); ++ } ++ normalized.add(new CustomBlockVisual.Box((float) box.minX(), (float) box.minY(), (float) box.minZ(), ++ (float) box.maxX(), (float) box.maxY(), (float) box.maxZ())); ++ } ++ collisionBoxes = List.copyOf(normalized); ++ } ++ ++ /** Native primary bounds retain collapsed planes; disabled collision returns the untranslated zero box. */ ++ public CustomBlockVisual.Box primaryBoundsAt(final int x, final int y, final int z) { ++ return primaryBoundsAt(this.collisionBoxes, this.randomOffset, x, y, z); ++ } ++ ++ public List cameraBoxesAt(final int x, final int y, final int z) { ++ return cameraBoxesAt(this.collisionBoxes, this.randomOffset, x, y, z); ++ } ++ ++ public static List cameraBoxesAt(final List boxes, final CustomBlockOffset randomOffset, ++ final int x, final int y, final int z) { ++ final CustomBlockVisual.Box box = primaryBoundsAt(boxes, randomOffset, x, y, z); ++ return box.minX() < box.maxX() && box.minY() < box.maxY() && box.minZ() < box.maxZ() ? List.of(box) : List.of(); ++ } ++ ++ private static CustomBlockVisual.Box primaryBoundsAt(final List boxes, final CustomBlockOffset randomOffset, ++ final int x, final int y, final int z) { ++ if (boxes.isEmpty()) { ++ return ZERO; ++ } ++ float minX = Float.MAX_VALUE; ++ float minY = Float.MAX_VALUE; ++ float minZ = Float.MAX_VALUE; ++ float maxX = -Float.MAX_VALUE; ++ float maxY = -Float.MAX_VALUE; ++ float maxZ = -Float.MAX_VALUE; ++ for (final CustomBlockVisual.Box box : boxes) { ++ minX = Math.min(minX, (float) box.minX()); ++ minY = Math.min(minY, (float) box.minY()); ++ minZ = Math.min(minZ, (float) box.minZ()); ++ maxX = Math.max(maxX, (float) box.maxX()); ++ maxY = Math.max(maxY, (float) box.maxY()); ++ maxZ = Math.max(maxZ, (float) box.maxZ()); ++ } ++ final CustomBlockOffset.Offset offset = randomOffset.at(x, y, z); ++ final float dx = x + offset.x(); ++ final float dy = y + offset.y(); ++ final float dz = z + offset.z(); ++ return new CustomBlockVisual.Box(minX + dx, minY + dy, minZ + dz, maxX + dx, maxY + dy, maxZ + dz); ++ } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/NativeBlockPrimaryGeometry.java b/src/main/java/net/raphimc/viabedrock/api/model/NativeBlockPrimaryGeometry.java +new file mode 100644 +index 00000000..e50e1d0d +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/model/NativeBlockPrimaryGeometry.java +@@ -0,0 +1,297 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.model; ++ ++import java.util.List; ++ ++/** Primary bounds from the pinned native class providers, independent of Java block shapes. */ ++public final class NativeBlockPrimaryGeometry { ++ ++ private static final NativeBlockGeometry FULL = geometry(0.0F, 0.0F, 0.0F, 1.0F, 1.0F, 1.0F); ++ private static final NativeBlockGeometry CAMPFIRE = geometry(0.0F, 0.0F, 0.0F, 1.0F, 0.4375F, 1.0F); ++ private static final NativeBlockGeometry GOLEM_STATUE = geometry(0.1875F, 0.0F, 0.1875F, 0.8125F, 0.875F, 0.8125F); ++ private static final NativeBlockGeometry TURTLE_EGG = geometry(0.19999998807907104F, 0.0F, 0.19999998807907104F, 0.800000011920929F, 0.45000001788139343F, 0.800000011920929F); ++ private static final NativeBlockGeometry QUARTER = geometry(0.0F, 0.0F, 0.0F, 1.0F, 0.25F, 1.0F); ++ private static final NativeBlockGeometry BED = geometry(0.0F, 0.0F, 0.0F, 1.0F, 0.5625F, 1.0F); ++ private static final NativeBlockGeometry HEAVY_CORE = geometry(0.25F, 0.0F, 0.25F, 0.75F, 0.5F, 0.75F); ++ private static final NativeBlockGeometry CHEST = geometry(0.02500000037252903F, 0.0F, 0.02500000037252903F, 0.9750000238418579F, 0.9500000476837158F, 0.9750000238418579F); ++ private static final NativeBlockGeometry SNIFFER_EGG = geometry(0.0625F, 0.0F, 0.125F, 0.9375F, 1.0F, 0.875F); ++ private static final NativeBlockGeometry LECTERN = geometry(0.0F, 0.0F, 0.0F, 1.0F, 0.8999999761581421F, 1.0F); ++ private static final NativeBlockGeometry ENCHANTING_TABLE = geometry(0.0F, 0.0F, 0.0F, 1.0F, 0.75F, 1.0F); ++ private static final NativeBlockGeometry DECORATED_POT = geometry(0.0625F, 0.0F, 0.0625F, 0.9375F, 1.0F, 0.9375F); ++ private static final NativeBlockGeometry REDSTONE_DEVICE = geometry(0.0F, 0.0F, 0.0F, 1.0F, 0.125F, 1.0F); ++ private static final NativeBlockGeometry END_PORTAL_FRAME = geometry(0.0F, 0.0F, 0.0F, 1.0F, 0.8125F, 1.0F); ++ private static final NativeBlockGeometry SCULK = geometry(0.0F, 0.0F, 0.0F, 1.0F, 0.5F, 1.0F); ++ private static final NativeBlockGeometry DAYLIGHT_DETECTOR = geometry(0.0F, 0.0F, 0.0F, 1.0F, 0.375F, 1.0F); ++ private static final NativeBlockGeometry DRIED_GHAST = geometry(0.1875F, 0.0F, 0.1875F, 0.8125F, 0.625F, 0.8125F); ++ private static final NativeBlockGeometry FLOWER_POT = geometry(0.3125F, 0.0F, 0.3125F, 0.6875F, 0.375F, 0.6875F); ++ ++ private NativeBlockPrimaryGeometry() { ++ } ++ ++ /** Null preserves unresolved native providers. Call only for a registered native state. */ ++ public static NativeBlockGeometry forState(final BlockState state) { ++ // Windows 1.26.60.5: exact-zero primary methods override collision and offset components. ++ // Constant providers use their finalized collision component before the Legacy fallback. ++ return switch (state.namespacedIdentifier()) { ++ case "minecraft:acacia_pressure_plate", "minecraft:acacia_sapling", "minecraft:acacia_standing_sign", "minecraft:acacia_wall_sign", ++ "minecraft:activator_rail", "minecraft:air", "minecraft:allium", "minecraft:azure_bluet", "minecraft:bamboo_pressure_plate", ++ "minecraft:bamboo_sapling", "minecraft:bamboo_standing_sign", "minecraft:bamboo_wall_sign", "minecraft:beetroot", ++ "minecraft:birch_pressure_plate", "minecraft:birch_sapling", "minecraft:birch_standing_sign", "minecraft:birch_wall_sign", ++ "minecraft:blue_orchid", "minecraft:brain_coral", "minecraft:brain_coral_fan", "minecraft:brain_coral_wall_fan", ++ "minecraft:brown_mushroom", "minecraft:bubble_column", "minecraft:bubble_coral", "minecraft:bubble_coral_fan", ++ "minecraft:bubble_coral_wall_fan", "minecraft:bush", "minecraft:cactus_flower", "minecraft:carrots", "minecraft:cave_vines", ++ "minecraft:cave_vines_body_with_berries", "minecraft:cave_vines_head_with_berries", "minecraft:chalkboard", ++ "minecraft:cherry_pressure_plate", "minecraft:cherry_sapling", "minecraft:cherry_standing_sign", ++ "minecraft:cherry_wall_sign", "minecraft:closed_eyeblossom", "minecraft:colored_torch_blue", ++ "minecraft:colored_torch_green", "minecraft:colored_torch_purple", "minecraft:colored_torch_red", "minecraft:copper_torch", ++ "minecraft:cornflower", "minecraft:crimson_fungus", "minecraft:crimson_pressure_plate", "minecraft:crimson_roots", ++ "minecraft:crimson_standing_sign", "minecraft:crimson_wall_sign", "minecraft:dandelion", ++ "minecraft:dark_oak_pressure_plate", "minecraft:dark_oak_sapling", "minecraft:darkoak_standing_sign", ++ "minecraft:darkoak_wall_sign", "minecraft:dead_brain_coral", "minecraft:dead_brain_coral_fan", ++ "minecraft:dead_brain_coral_wall_fan", "minecraft:dead_bubble_coral", "minecraft:dead_bubble_coral_fan", ++ "minecraft:dead_bubble_coral_wall_fan", "minecraft:dead_fire_coral", "minecraft:dead_fire_coral_fan", ++ "minecraft:dead_fire_coral_wall_fan", "minecraft:dead_horn_coral", "minecraft:dead_horn_coral_fan", ++ "minecraft:dead_horn_coral_wall_fan", "minecraft:dead_tube_coral", "minecraft:dead_tube_coral_fan", ++ "minecraft:dead_tube_coral_wall_fan", "minecraft:deadbush", "minecraft:detector_rail", "minecraft:fern", "minecraft:fire", ++ "minecraft:fire_coral", "minecraft:fire_coral_fan", "minecraft:fire_coral_wall_fan", "minecraft:firefly_bush", ++ "minecraft:flowing_lava", "minecraft:flowing_water", "minecraft:frame", "minecraft:frog_spawn", "minecraft:glow_frame", ++ "minecraft:glow_lichen", "minecraft:golden_dandelion", "minecraft:golden_rail", "minecraft:hanging_roots", ++ "minecraft:heavy_weighted_pressure_plate", "minecraft:horn_coral", "minecraft:horn_coral_fan", ++ "minecraft:horn_coral_wall_fan", "minecraft:jungle_pressure_plate", "minecraft:jungle_sapling", ++ "minecraft:jungle_standing_sign", "minecraft:jungle_wall_sign", "minecraft:kelp", "minecraft:large_fern", "minecraft:lava", ++ "minecraft:leaf_litter", "minecraft:lever", "minecraft:light_block_0", "minecraft:light_block_1", ++ "minecraft:light_block_10", "minecraft:light_block_11", "minecraft:light_block_12", "minecraft:light_block_13", ++ "minecraft:light_block_14", "minecraft:light_block_15", "minecraft:light_block_2", "minecraft:light_block_3", ++ "minecraft:light_block_4", "minecraft:light_block_5", "minecraft:light_block_6", "minecraft:light_block_7", ++ "minecraft:light_block_8", "minecraft:light_block_9", "minecraft:light_weighted_pressure_plate", "minecraft:lilac", ++ "minecraft:lily_of_the_valley", "minecraft:mangrove_pressure_plate", "minecraft:mangrove_propagule", ++ "minecraft:mangrove_standing_sign", "minecraft:mangrove_wall_sign", "minecraft:melon_stem", "minecraft:nether_sprouts", ++ "minecraft:nether_wart", "minecraft:oak_sapling", "minecraft:open_eyeblossom", "minecraft:orange_tulip", ++ "minecraft:oxeye_daisy", "minecraft:pale_hanging_moss", "minecraft:pale_oak_pressure_plate", "minecraft:pale_oak_sapling", ++ "minecraft:pale_oak_standing_sign", "minecraft:pale_oak_wall_sign", "minecraft:peony", "minecraft:pink_petals", ++ "minecraft:pink_tulip", "minecraft:pitcher_plant", "minecraft:polished_blackstone_pressure_plate", ++ "minecraft:poplar_pressure_plate", "minecraft:poplar_sapling", "minecraft:poplar_standing_sign", ++ "minecraft:poplar_wall_sign", "minecraft:poppy", "minecraft:portal", "minecraft:potatoes", "minecraft:pumpkin_stem", ++ "minecraft:rail", "minecraft:red_mushroom", "minecraft:red_tulip", "minecraft:redstone_torch", "minecraft:redstone_wire", ++ "minecraft:reeds", "minecraft:resin_clump", "minecraft:rose_bush", "minecraft:sculk_vein", "minecraft:sea_pickle", ++ "minecraft:seagrass", "minecraft:short_dry_grass", "minecraft:short_grass", "minecraft:small_dripleaf_block", ++ "minecraft:soul_fire", "minecraft:soul_torch", "minecraft:spore_blossom", "minecraft:spruce_pressure_plate", ++ "minecraft:spruce_sapling", "minecraft:spruce_standing_sign", "minecraft:spruce_wall_sign", "minecraft:standing_banner", ++ "minecraft:standing_sign", "minecraft:stone_pressure_plate", "minecraft:structure_void", "minecraft:sunflower", ++ "minecraft:sweet_berry_bush", "minecraft:tall_dry_grass", "minecraft:tall_grass", "minecraft:torch", ++ "minecraft:torchflower", "minecraft:torchflower_crop", "minecraft:trip_wire", "minecraft:tripwire_hook", ++ "minecraft:tube_coral", "minecraft:tube_coral_fan", "minecraft:tube_coral_wall_fan", "minecraft:twisting_vines", ++ "minecraft:underwater_torch", "minecraft:unlit_redstone_torch", "minecraft:vine", "minecraft:wall_banner", ++ "minecraft:wall_sign", "minecraft:warped_fungus", "minecraft:warped_pressure_plate", "minecraft:warped_roots", ++ "minecraft:warped_standing_sign", "minecraft:warped_wall_sign", "minecraft:water", "minecraft:web", ++ "minecraft:weeping_vines", "minecraft:wheat", "minecraft:white_tulip", "minecraft:wildflowers", "minecraft:wither_rose", ++ "minecraft:wooden_pressure_plate" -> NativeBlockGeometry.EMPTY; ++ case "minecraft:acacia_leaves", "minecraft:acacia_log", "minecraft:acacia_planks", "minecraft:acacia_stairs", ++ "minecraft:acacia_wood", "minecraft:allow", "minecraft:amethyst_block", "minecraft:ancient_debris", "minecraft:andesite", ++ "minecraft:andesite_stairs", "minecraft:azalea_leaves", "minecraft:azalea_leaves_flowered", "minecraft:bamboo_block", ++ "minecraft:bamboo_mosaic", "minecraft:bamboo_mosaic_stairs", "minecraft:bamboo_planks", "minecraft:bamboo_stairs", ++ "minecraft:barrel", "minecraft:barrier", "minecraft:basalt", "minecraft:beacon", "minecraft:bedrock", "minecraft:bee_nest", ++ "minecraft:beehive", "minecraft:birch_leaves", "minecraft:birch_log", "minecraft:birch_planks", "minecraft:birch_stairs", ++ "minecraft:birch_wood", "minecraft:black_concrete", "minecraft:black_concrete_powder", "minecraft:black_glazed_terracotta", ++ "minecraft:black_stained_glass", "minecraft:black_terracotta", "minecraft:black_wool", "minecraft:blackstone", ++ "minecraft:blackstone_stairs", "minecraft:blast_furnace", "minecraft:blue_concrete", "minecraft:blue_concrete_powder", ++ "minecraft:blue_glazed_terracotta", "minecraft:blue_ice", "minecraft:blue_stained_glass", "minecraft:blue_terracotta", ++ "minecraft:blue_wool", "minecraft:bone_block", "minecraft:bookshelf", "minecraft:brain_coral_block", ++ "minecraft:brick_block", "minecraft:brick_stairs", "minecraft:brown_concrete", "minecraft:brown_concrete_powder", ++ "minecraft:brown_glazed_terracotta", "minecraft:brown_mushroom_block", "minecraft:brown_stained_glass", ++ "minecraft:brown_terracotta", "minecraft:brown_wool", "minecraft:bubble_coral_block", "minecraft:budding_amethyst", ++ "minecraft:calcite", "minecraft:camera", "minecraft:cartography_table", "minecraft:carved_pumpkin", "minecraft:cauldron", ++ "minecraft:chain_command_block", "minecraft:chemical_heat", "minecraft:cherry_leaves", "minecraft:cherry_log", ++ "minecraft:cherry_planks", "minecraft:cherry_stairs", "minecraft:cherry_wood", "minecraft:chiseled_bookshelf", ++ "minecraft:chiseled_cinnabar", "minecraft:chiseled_copper", "minecraft:chiseled_deepslate", ++ "minecraft:chiseled_nether_bricks", "minecraft:chiseled_polished_blackstone", "minecraft:chiseled_quartz_block", ++ "minecraft:chiseled_red_sandstone", "minecraft:chiseled_resin_bricks", "minecraft:chiseled_sandstone", ++ "minecraft:chiseled_stone_bricks", "minecraft:chiseled_sulfur", "minecraft:chiseled_tuff", "minecraft:chiseled_tuff_bricks", ++ "minecraft:chorus_flower", "minecraft:cinnabar", "minecraft:cinnabar_brick_stairs", "minecraft:cinnabar_bricks", ++ "minecraft:cinnabar_stairs", "minecraft:clay", "minecraft:client_request_placeholder_block", "minecraft:coal_block", ++ "minecraft:coal_ore", "minecraft:coarse_dirt", "minecraft:cobbled_deepslate", "minecraft:cobbled_deepslate_stairs", ++ "minecraft:cobblestone", "minecraft:command_block", "minecraft:composter", "minecraft:compound_creator", ++ "minecraft:copper_block", "minecraft:copper_bulb", "minecraft:copper_grate", "minecraft:copper_ore", ++ "minecraft:cracked_deepslate_bricks", "minecraft:cracked_deepslate_tiles", "minecraft:cracked_nether_bricks", ++ "minecraft:cracked_polished_blackstone_bricks", "minecraft:cracked_stone_bricks", "minecraft:crafter", ++ "minecraft:crafting_table", "minecraft:creaking_heart", "minecraft:crimson_hyphae", "minecraft:crimson_nylium", ++ "minecraft:crimson_planks", "minecraft:crimson_stairs", "minecraft:crimson_stem", "minecraft:crying_obsidian", ++ "minecraft:cut_copper", "minecraft:cut_copper_stairs", "minecraft:cut_red_sandstone", "minecraft:cut_sandstone", ++ "minecraft:cyan_concrete", "minecraft:cyan_concrete_powder", "minecraft:cyan_glazed_terracotta", ++ "minecraft:cyan_stained_glass", "minecraft:cyan_terracotta", "minecraft:cyan_wool", "minecraft:dark_oak_leaves", ++ "minecraft:dark_oak_log", "minecraft:dark_oak_planks", "minecraft:dark_oak_stairs", "minecraft:dark_oak_wood", ++ "minecraft:dark_prismarine", "minecraft:dark_prismarine_stairs", "minecraft:dead_brain_coral_block", ++ "minecraft:dead_bubble_coral_block", "minecraft:dead_fire_coral_block", "minecraft:dead_horn_coral_block", ++ "minecraft:dead_tube_coral_block", "minecraft:deepslate", "minecraft:deepslate_brick_stairs", "minecraft:deepslate_bricks", ++ "minecraft:deepslate_coal_ore", "minecraft:deepslate_copper_ore", "minecraft:deepslate_diamond_ore", ++ "minecraft:deepslate_emerald_ore", "minecraft:deepslate_gold_ore", "minecraft:deepslate_iron_ore", ++ "minecraft:deepslate_lapis_ore", "minecraft:deepslate_redstone_ore", "minecraft:deepslate_tile_stairs", ++ "minecraft:deepslate_tiles", "minecraft:deny", "minecraft:deprecated_purpur_block_1", "minecraft:deprecated_purpur_block_2", ++ "minecraft:diamond_block", "minecraft:diamond_ore", "minecraft:diorite", "minecraft:diorite_stairs", "minecraft:dirt", ++ "minecraft:dirt_with_roots", "minecraft:disc_press", "minecraft:dispenser", "minecraft:dragon_egg", ++ "minecraft:dried_kelp_block", "minecraft:dripstone_block", "minecraft:dropper", "minecraft:element_0", ++ "minecraft:element_1", "minecraft:element_10", "minecraft:element_100", "minecraft:element_101", "minecraft:element_102", ++ "minecraft:element_103", "minecraft:element_104", "minecraft:element_105", "minecraft:element_106", "minecraft:element_107", ++ "minecraft:element_108", "minecraft:element_109", "minecraft:element_11", "minecraft:element_110", "minecraft:element_111", ++ "minecraft:element_112", "minecraft:element_113", "minecraft:element_114", "minecraft:element_115", "minecraft:element_116", ++ "minecraft:element_117", "minecraft:element_118", "minecraft:element_12", "minecraft:element_13", "minecraft:element_14", ++ "minecraft:element_15", "minecraft:element_16", "minecraft:element_17", "minecraft:element_18", "minecraft:element_19", ++ "minecraft:element_2", "minecraft:element_20", "minecraft:element_21", "minecraft:element_22", "minecraft:element_23", ++ "minecraft:element_24", "minecraft:element_25", "minecraft:element_26", "minecraft:element_27", "minecraft:element_28", ++ "minecraft:element_29", "minecraft:element_3", "minecraft:element_30", "minecraft:element_31", "minecraft:element_32", ++ "minecraft:element_33", "minecraft:element_34", "minecraft:element_35", "minecraft:element_36", "minecraft:element_37", ++ "minecraft:element_38", "minecraft:element_39", "minecraft:element_4", "minecraft:element_40", "minecraft:element_41", ++ "minecraft:element_42", "minecraft:element_43", "minecraft:element_44", "minecraft:element_45", "minecraft:element_46", ++ "minecraft:element_47", "minecraft:element_48", "minecraft:element_49", "minecraft:element_5", "minecraft:element_50", ++ "minecraft:element_51", "minecraft:element_52", "minecraft:element_53", "minecraft:element_54", "minecraft:element_55", ++ "minecraft:element_56", "minecraft:element_57", "minecraft:element_58", "minecraft:element_59", "minecraft:element_6", ++ "minecraft:element_60", "minecraft:element_61", "minecraft:element_62", "minecraft:element_63", "minecraft:element_64", ++ "minecraft:element_65", "minecraft:element_66", "minecraft:element_67", "minecraft:element_68", "minecraft:element_69", ++ "minecraft:element_7", "minecraft:element_70", "minecraft:element_71", "minecraft:element_72", "minecraft:element_73", ++ "minecraft:element_74", "minecraft:element_75", "minecraft:element_76", "minecraft:element_77", "minecraft:element_78", ++ "minecraft:element_79", "minecraft:element_8", "minecraft:element_80", "minecraft:element_81", "minecraft:element_82", ++ "minecraft:element_83", "minecraft:element_84", "minecraft:element_85", "minecraft:element_86", "minecraft:element_87", ++ "minecraft:element_88", "minecraft:element_89", "minecraft:element_9", "minecraft:element_90", "minecraft:element_91", ++ "minecraft:element_92", "minecraft:element_93", "minecraft:element_94", "minecraft:element_95", "minecraft:element_96", ++ "minecraft:element_97", "minecraft:element_98", "minecraft:element_99", "minecraft:element_constructor", ++ "minecraft:emerald_block", "minecraft:emerald_ore", "minecraft:end_brick_stairs", "minecraft:end_bricks", ++ "minecraft:end_gateway", "minecraft:end_stone", "minecraft:exposed_chiseled_copper", "minecraft:exposed_copper", ++ "minecraft:exposed_copper_bulb", "minecraft:exposed_copper_grate", "minecraft:exposed_cut_copper", ++ "minecraft:exposed_cut_copper_stairs", "minecraft:fire_coral_block", "minecraft:fletching_table", "minecraft:frosted_ice", ++ "minecraft:furnace", "minecraft:gilded_blackstone", "minecraft:glass", "minecraft:glowingobsidian", "minecraft:glowstone", ++ "minecraft:gold_block", "minecraft:gold_ore", "minecraft:granite", "minecraft:granite_stairs", "minecraft:grass_block", ++ "minecraft:gravel", "minecraft:gray_concrete", "minecraft:gray_concrete_powder", "minecraft:gray_glazed_terracotta", ++ "minecraft:gray_stained_glass", "minecraft:gray_terracotta", "minecraft:gray_wool", "minecraft:green_concrete", ++ "minecraft:green_concrete_powder", "minecraft:green_glazed_terracotta", "minecraft:green_stained_glass", ++ "minecraft:green_terracotta", "minecraft:green_wool", "minecraft:hard_black_stained_glass", ++ "minecraft:hard_blue_stained_glass", "minecraft:hard_brown_stained_glass", "minecraft:hard_cyan_stained_glass", ++ "minecraft:hard_glass", "minecraft:hard_gray_stained_glass", "minecraft:hard_green_stained_glass", ++ "minecraft:hard_light_blue_stained_glass", "minecraft:hard_light_gray_stained_glass", "minecraft:hard_lime_stained_glass", ++ "minecraft:hard_magenta_stained_glass", "minecraft:hard_orange_stained_glass", "minecraft:hard_pink_stained_glass", ++ "minecraft:hard_purple_stained_glass", "minecraft:hard_red_stained_glass", "minecraft:hard_white_stained_glass", ++ "minecraft:hard_yellow_stained_glass", "minecraft:hardened_clay", "minecraft:hay_block", "minecraft:honeycomb_block", ++ "minecraft:hopper", "minecraft:horn_coral_block", "minecraft:ice", "minecraft:infested_chiseled_stone_bricks", ++ "minecraft:infested_cobblestone", "minecraft:infested_cracked_stone_bricks", "minecraft:infested_deepslate", ++ "minecraft:infested_mossy_stone_bricks", "minecraft:infested_stone", "minecraft:infested_stone_bricks", ++ "minecraft:info_update", "minecraft:info_update2", "minecraft:invisible_bedrock", "minecraft:iron_block", ++ "minecraft:iron_ore", "minecraft:jigsaw", "minecraft:jukebox", "minecraft:jungle_leaves", "minecraft:jungle_log", ++ "minecraft:jungle_planks", "minecraft:jungle_stairs", "minecraft:jungle_wood", "minecraft:lab_table", ++ "minecraft:lapis_block", "minecraft:lapis_ore", "minecraft:light_blue_concrete", "minecraft:light_blue_concrete_powder", ++ "minecraft:light_blue_glazed_terracotta", "minecraft:light_blue_stained_glass", "minecraft:light_blue_terracotta", ++ "minecraft:light_blue_wool", "minecraft:light_gray_concrete", "minecraft:light_gray_concrete_powder", ++ "minecraft:light_gray_stained_glass", "minecraft:light_gray_terracotta", "minecraft:light_gray_wool", ++ "minecraft:lime_concrete", "minecraft:lime_concrete_powder", "minecraft:lime_glazed_terracotta", ++ "minecraft:lime_stained_glass", "minecraft:lime_terracotta", "minecraft:lime_wool", "minecraft:lit_blast_furnace", ++ "minecraft:lit_deepslate_redstone_ore", "minecraft:lit_furnace", "minecraft:lit_pumpkin", "minecraft:lit_redstone_lamp", ++ "minecraft:lit_redstone_ore", "minecraft:lit_smoker", "minecraft:lodestone", "minecraft:loom", "minecraft:magenta_concrete", ++ "minecraft:magenta_concrete_powder", "minecraft:magenta_glazed_terracotta", "minecraft:magenta_stained_glass", ++ "minecraft:magenta_terracotta", "minecraft:magenta_wool", "minecraft:magma", "minecraft:mangrove_leaves", ++ "minecraft:mangrove_log", "minecraft:mangrove_planks", "minecraft:mangrove_roots", "minecraft:mangrove_stairs", ++ "minecraft:mangrove_wood", "minecraft:material_reducer", "minecraft:melon_block", "minecraft:moss_block", ++ "minecraft:mossy_cobblestone", "minecraft:mossy_cobblestone_stairs", "minecraft:mossy_stone_brick_stairs", ++ "minecraft:mossy_stone_bricks", "minecraft:mud_brick_stairs", "minecraft:mud_bricks", "minecraft:muddy_mangrove_roots", ++ "minecraft:mushroom_stem", "minecraft:mycelium", "minecraft:nether_brick", "minecraft:nether_brick_stairs", ++ "minecraft:nether_gold_ore", "minecraft:nether_wart_block", "minecraft:netherite_block", "minecraft:netherrack", ++ "minecraft:netherreactor", "minecraft:normal_stone_stairs", "minecraft:noteblock", "minecraft:oak_leaves", ++ "minecraft:oak_log", "minecraft:oak_planks", "minecraft:oak_stairs", "minecraft:oak_wood", "minecraft:observer", ++ "minecraft:obsidian", "minecraft:ochre_froglight", "minecraft:orange_concrete", "minecraft:orange_concrete_powder", ++ "minecraft:orange_glazed_terracotta", "minecraft:orange_poplar_leaves", "minecraft:orange_stained_glass", ++ "minecraft:orange_terracotta", "minecraft:orange_wool", "minecraft:oxidized_chiseled_copper", "minecraft:oxidized_copper", ++ "minecraft:oxidized_copper_bulb", "minecraft:oxidized_copper_grate", "minecraft:oxidized_cut_copper", ++ "minecraft:oxidized_cut_copper_stairs", "minecraft:packed_ice", "minecraft:packed_mud", "minecraft:pale_moss_block", ++ "minecraft:pale_oak_leaves", "minecraft:pale_oak_log", "minecraft:pale_oak_planks", "minecraft:pale_oak_stairs", ++ "minecraft:pale_oak_wood", "minecraft:pearlescent_froglight", "minecraft:pink_concrete", "minecraft:pink_concrete_powder", ++ "minecraft:pink_glazed_terracotta", "minecraft:pink_stained_glass", "minecraft:pink_terracotta", "minecraft:pink_wool", ++ "minecraft:piston", "minecraft:podzol", "minecraft:polished_andesite", "minecraft:polished_andesite_stairs", ++ "minecraft:polished_basalt", "minecraft:polished_blackstone", "minecraft:polished_blackstone_brick_stairs", ++ "minecraft:polished_blackstone_bricks", "minecraft:polished_blackstone_stairs", "minecraft:polished_cinnabar", ++ "minecraft:polished_cinnabar_stairs", "minecraft:polished_deepslate", "minecraft:polished_deepslate_stairs", ++ "minecraft:polished_diorite", "minecraft:polished_diorite_stairs", "minecraft:polished_granite", ++ "minecraft:polished_granite_stairs", "minecraft:polished_sulfur", "minecraft:polished_sulfur_stairs", ++ "minecraft:polished_tuff", "minecraft:polished_tuff_stairs", "minecraft:poplar_log", "minecraft:poplar_planks", ++ "minecraft:poplar_stairs", "minecraft:poplar_wood", "minecraft:potent_sulfur", "minecraft:prismarine", ++ "minecraft:prismarine_bricks", "minecraft:prismarine_bricks_stairs", "minecraft:prismarine_stairs", "minecraft:pumpkin", ++ "minecraft:purple_concrete", "minecraft:purple_concrete_powder", "minecraft:purple_glazed_terracotta", ++ "minecraft:purple_stained_glass", "minecraft:purple_terracotta", "minecraft:purple_wool", "minecraft:purpur_block", ++ "minecraft:purpur_pillar", "minecraft:purpur_stairs", "minecraft:quartz_block", "minecraft:quartz_bricks", ++ "minecraft:quartz_ore", "minecraft:quartz_pillar", "minecraft:quartz_stairs", "minecraft:raw_copper_block", ++ "minecraft:raw_gold_block", "minecraft:raw_iron_block", "minecraft:red_concrete", "minecraft:red_concrete_powder", ++ "minecraft:red_glazed_terracotta", "minecraft:red_mushroom_block", "minecraft:red_nether_brick", ++ "minecraft:red_nether_brick_stairs", "minecraft:red_poplar_leaves", "minecraft:red_sand", "minecraft:red_sandstone", ++ "minecraft:red_sandstone_stairs", "minecraft:red_stained_glass", "minecraft:red_terracotta", "minecraft:red_wool", ++ "minecraft:redstone_block", "minecraft:redstone_lamp", "minecraft:redstone_ore", "minecraft:reinforced_deepslate", ++ "minecraft:repeating_command_block", "minecraft:reserved6", "minecraft:resin_block", "minecraft:resin_brick_stairs", ++ "minecraft:resin_bricks", "minecraft:respawn_anchor", "minecraft:sand", "minecraft:sandstone", "minecraft:sandstone_stairs", ++ "minecraft:sculk", "minecraft:sculk_catalyst", "minecraft:sea_lantern", "minecraft:shroomlight", ++ "minecraft:silver_glazed_terracotta", "minecraft:slime", "minecraft:smithing_table", "minecraft:smoker", ++ "minecraft:smooth_basalt", "minecraft:smooth_quartz", "minecraft:smooth_quartz_stairs", "minecraft:smooth_red_sandstone", ++ "minecraft:smooth_red_sandstone_stairs", "minecraft:smooth_sandstone", "minecraft:smooth_sandstone_stairs", ++ "minecraft:smooth_stone", "minecraft:snow", "minecraft:soul_soil", "minecraft:sponge", "minecraft:spruce_leaves", ++ "minecraft:spruce_log", "minecraft:spruce_planks", "minecraft:spruce_stairs", "minecraft:spruce_wood", ++ "minecraft:sticky_piston", "minecraft:stone", "minecraft:stone_brick_stairs", "minecraft:stone_bricks", ++ "minecraft:stone_stairs", "minecraft:stonecutter", "minecraft:stripped_acacia_log", "minecraft:stripped_acacia_wood", ++ "minecraft:stripped_bamboo_block", "minecraft:stripped_birch_log", "minecraft:stripped_birch_wood", ++ "minecraft:stripped_cherry_log", "minecraft:stripped_cherry_wood", "minecraft:stripped_crimson_hyphae", ++ "minecraft:stripped_crimson_stem", "minecraft:stripped_dark_oak_log", "minecraft:stripped_dark_oak_wood", ++ "minecraft:stripped_jungle_log", "minecraft:stripped_jungle_wood", "minecraft:stripped_mangrove_log", ++ "minecraft:stripped_mangrove_wood", "minecraft:stripped_oak_log", "minecraft:stripped_oak_wood", ++ "minecraft:stripped_pale_oak_log", "minecraft:stripped_pale_oak_wood", "minecraft:stripped_poplar_log", ++ "minecraft:stripped_poplar_wood", "minecraft:stripped_spruce_log", "minecraft:stripped_spruce_wood", ++ "minecraft:stripped_warped_hyphae", "minecraft:stripped_warped_stem", "minecraft:structure_block", "minecraft:sulfur", ++ "minecraft:sulfur_brick_stairs", "minecraft:sulfur_bricks", "minecraft:sulfur_stairs", "minecraft:suspicious_gravel", ++ "minecraft:suspicious_sand", "minecraft:target", "minecraft:tinted_glass", "minecraft:tnt", "minecraft:tube_coral_block", ++ "minecraft:tuff", "minecraft:tuff_brick_stairs", "minecraft:tuff_bricks", "minecraft:tuff_stairs", ++ "minecraft:underwater_tnt", "minecraft:unknown", "minecraft:vault", "minecraft:verdant_froglight", ++ "minecraft:warped_hyphae", "minecraft:warped_nylium", "minecraft:warped_planks", "minecraft:warped_stairs", ++ "minecraft:warped_stem", "minecraft:warped_wart_block", "minecraft:waxed_chiseled_copper", "minecraft:waxed_copper", ++ "minecraft:waxed_copper_bulb", "minecraft:waxed_copper_grate", "minecraft:waxed_cut_copper", ++ "minecraft:waxed_cut_copper_stairs", "minecraft:waxed_exposed_chiseled_copper", "minecraft:waxed_exposed_copper", ++ "minecraft:waxed_exposed_copper_bulb", "minecraft:waxed_exposed_copper_grate", "minecraft:waxed_exposed_cut_copper", ++ "minecraft:waxed_exposed_cut_copper_stairs", "minecraft:waxed_oxidized_chiseled_copper", "minecraft:waxed_oxidized_copper", ++ "minecraft:waxed_oxidized_copper_bulb", "minecraft:waxed_oxidized_copper_grate", "minecraft:waxed_oxidized_cut_copper", ++ "minecraft:waxed_oxidized_cut_copper_stairs", "minecraft:waxed_weathered_chiseled_copper", ++ "minecraft:waxed_weathered_copper", "minecraft:waxed_weathered_copper_bulb", "minecraft:waxed_weathered_copper_grate", ++ "minecraft:waxed_weathered_cut_copper", "minecraft:waxed_weathered_cut_copper_stairs", ++ "minecraft:weathered_chiseled_copper", "minecraft:weathered_copper", "minecraft:weathered_copper_bulb", ++ "minecraft:weathered_copper_grate", "minecraft:weathered_cut_copper", "minecraft:weathered_cut_copper_stairs", ++ "minecraft:wet_sponge", "minecraft:white_concrete", "minecraft:white_concrete_powder", "minecraft:white_glazed_terracotta", ++ "minecraft:white_stained_glass", "minecraft:white_terracotta", "minecraft:white_wool", "minecraft:yellow_concrete", ++ "minecraft:yellow_concrete_powder", "minecraft:yellow_glazed_terracotta", "minecraft:yellow_poplar_leaves", ++ "minecraft:yellow_stained_glass", "minecraft:yellow_terracotta", "minecraft:yellow_wool" -> FULL; ++ case "minecraft:campfire", "minecraft:soul_campfire" -> CAMPFIRE; ++ case "minecraft:copper_golem_statue", "minecraft:exposed_copper_golem_statue", "minecraft:oxidized_copper_golem_statue", ++ "minecraft:waxed_copper_golem_statue", "minecraft:waxed_exposed_copper_golem_statue", ++ "minecraft:waxed_oxidized_copper_golem_statue", "minecraft:waxed_weathered_copper_golem_statue", ++ "minecraft:weathered_copper_golem_statue" -> GOLEM_STATUE; ++ case "minecraft:turtle_egg" -> TURTLE_EGG; ++ case "minecraft:end_portal", "minecraft:straw_bed" -> QUARTER; ++ case "minecraft:bed", "minecraft:stonecutter_block" -> BED; ++ case "minecraft:conduit", "minecraft:heavy_core" -> HEAVY_CORE; ++ case "minecraft:chest", "minecraft:copper_chest", "minecraft:ender_chest", "minecraft:exposed_copper_chest", ++ "minecraft:oxidized_copper_chest", "minecraft:trapped_chest", "minecraft:waxed_copper_chest", ++ "minecraft:waxed_exposed_copper_chest", "minecraft:waxed_oxidized_copper_chest", "minecraft:waxed_weathered_copper_chest", ++ "minecraft:weathered_copper_chest" -> CHEST; ++ case "minecraft:sniffer_egg" -> SNIFFER_EGG; ++ case "minecraft:lectern" -> LECTERN; ++ case "minecraft:enchanting_table" -> ENCHANTING_TABLE; ++ case "minecraft:decorated_pot" -> DECORATED_POT; ++ case "minecraft:powered_comparator", "minecraft:powered_repeater", "minecraft:unpowered_comparator", "minecraft:unpowered_repeater" -> REDSTONE_DEVICE; ++ case "minecraft:end_portal_frame" -> END_PORTAL_FRAME; ++ case "minecraft:calibrated_sculk_sensor", "minecraft:sculk_sensor", "minecraft:sculk_shrieker" -> SCULK; ++ case "minecraft:daylight_detector", "minecraft:daylight_detector_inverted" -> DAYLIGHT_DETECTOR; ++ case "minecraft:dried_ghast" -> DRIED_GHAST; ++ case "minecraft:flower_pot" -> FLOWER_POT; ++ default -> NativeSlabGeometry.forState(state); ++ }; ++ } ++ ++ private static NativeBlockGeometry geometry(final float minX, final float minY, final float minZ, ++ final float maxX, final float maxY, final float maxZ) { ++ return new NativeBlockGeometry(List.of(new CustomBlockVisual.Box(minX, minY, minZ, maxX, maxY, maxZ)), CustomBlockOffset.NONE); ++ } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/NativeSlabGeometry.java b/src/main/java/net/raphimc/viabedrock/api/model/NativeSlabGeometry.java +new file mode 100644 +index 00000000..07c85994 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/model/NativeSlabGeometry.java +@@ -0,0 +1,82 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.model; ++ ++import java.util.List; ++ ++/** Primary bounds for native hardcoded slabs and derived classes, independent of Java block shapes. */ ++public final class NativeSlabGeometry { ++ ++ private static final NativeBlockGeometry LOWER = geometry(0, 0.5F); ++ private static final NativeBlockGeometry UPPER = geometry(0.5F, 1); ++ private static final NativeBlockGeometry DOUBLE = geometry(0, 1); ++ ++ private NativeSlabGeometry() { ++ } ++ ++ /** Null retains the existing provider for unregistered classes or unresolved property values. */ ++ public static NativeBlockGeometry forState(final BlockState state) { ++ // Exact Windows 1.26.60.5 registrations, including inherited copper slab providers. ++ return switch (state.namespacedIdentifier()) { ++ case "minecraft:acacia_slab", "minecraft:andesite_slab", "minecraft:bamboo_mosaic_slab", "minecraft:bamboo_slab", ++ "minecraft:birch_slab", "minecraft:blackstone_slab", "minecraft:brick_slab", "minecraft:cherry_slab", ++ "minecraft:cinnabar_brick_slab", "minecraft:cinnabar_slab", "minecraft:cobbled_deepslate_slab", ++ "minecraft:cobblestone_slab", "minecraft:crimson_slab", "minecraft:cut_copper_slab", "minecraft:cut_red_sandstone_slab", ++ "minecraft:cut_sandstone_slab", "minecraft:dark_oak_slab", "minecraft:dark_prismarine_slab", ++ "minecraft:deepslate_brick_slab", "minecraft:deepslate_tile_slab", "minecraft:diorite_slab", ++ "minecraft:end_stone_brick_slab", "minecraft:exposed_cut_copper_slab", "minecraft:granite_slab", "minecraft:jungle_slab", ++ "minecraft:mangrove_slab", "minecraft:mossy_cobblestone_slab", "minecraft:mossy_stone_brick_slab", ++ "minecraft:mud_brick_slab", "minecraft:nether_brick_slab", "minecraft:normal_stone_slab", "minecraft:oak_slab", ++ "minecraft:oxidized_cut_copper_slab", "minecraft:pale_oak_slab", "minecraft:petrified_oak_slab", ++ "minecraft:polished_andesite_slab", "minecraft:polished_blackstone_brick_slab", "minecraft:polished_blackstone_slab", ++ "minecraft:polished_cinnabar_slab", "minecraft:polished_deepslate_slab", "minecraft:polished_diorite_slab", ++ "minecraft:polished_granite_slab", "minecraft:polished_sulfur_slab", "minecraft:polished_tuff_slab", ++ "minecraft:poplar_slab", "minecraft:prismarine_brick_slab", "minecraft:prismarine_slab", "minecraft:purpur_slab", ++ "minecraft:quartz_slab", "minecraft:red_nether_brick_slab", "minecraft:red_sandstone_slab", "minecraft:resin_brick_slab", ++ "minecraft:sandstone_slab", "minecraft:smooth_quartz_slab", "minecraft:smooth_red_sandstone_slab", ++ "minecraft:smooth_sandstone_slab", "minecraft:smooth_stone_slab", "minecraft:spruce_slab", "minecraft:stone_brick_slab", ++ "minecraft:sulfur_brick_slab", "minecraft:sulfur_slab", "minecraft:tuff_brick_slab", "minecraft:tuff_slab", ++ "minecraft:warped_slab", "minecraft:waxed_cut_copper_slab", "minecraft:waxed_exposed_cut_copper_slab", ++ "minecraft:waxed_oxidized_cut_copper_slab", "minecraft:waxed_weathered_cut_copper_slab", ++ "minecraft:weathered_cut_copper_slab" -> switch (state.properties().getOrDefault("minecraft:vertical_half", "")) { ++ case "bottom" -> LOWER; ++ case "top" -> UPPER; ++ default -> null; ++ }; ++ case "minecraft:acacia_double_slab", "minecraft:andesite_double_slab", "minecraft:bamboo_double_slab", ++ "minecraft:bamboo_mosaic_double_slab", "minecraft:birch_double_slab", "minecraft:blackstone_double_slab", ++ "minecraft:brick_double_slab", "minecraft:cherry_double_slab", "minecraft:cinnabar_brick_double_slab", ++ "minecraft:cinnabar_double_slab", "minecraft:cobbled_deepslate_double_slab", "minecraft:cobblestone_double_slab", ++ "minecraft:crimson_double_slab", "minecraft:cut_red_sandstone_double_slab", "minecraft:cut_sandstone_double_slab", ++ "minecraft:dark_oak_double_slab", "minecraft:dark_prismarine_double_slab", "minecraft:deepslate_brick_double_slab", ++ "minecraft:deepslate_tile_double_slab", "minecraft:diorite_double_slab", "minecraft:double_cut_copper_slab", ++ "minecraft:end_stone_brick_double_slab", "minecraft:exposed_double_cut_copper_slab", "minecraft:granite_double_slab", ++ "minecraft:jungle_double_slab", "minecraft:mangrove_double_slab", "minecraft:mossy_cobblestone_double_slab", ++ "minecraft:mossy_stone_brick_double_slab", "minecraft:mud_brick_double_slab", "minecraft:nether_brick_double_slab", ++ "minecraft:normal_stone_double_slab", "minecraft:oak_double_slab", "minecraft:oxidized_double_cut_copper_slab", ++ "minecraft:pale_oak_double_slab", "minecraft:petrified_oak_double_slab", "minecraft:polished_andesite_double_slab", ++ "minecraft:polished_blackstone_brick_double_slab", "minecraft:polished_blackstone_double_slab", ++ "minecraft:polished_cinnabar_double_slab", "minecraft:polished_deepslate_double_slab", ++ "minecraft:polished_diorite_double_slab", "minecraft:polished_granite_double_slab", "minecraft:polished_sulfur_double_slab", ++ "minecraft:polished_tuff_double_slab", "minecraft:poplar_double_slab", "minecraft:prismarine_brick_double_slab", ++ "minecraft:prismarine_double_slab", "minecraft:purpur_double_slab", "minecraft:quartz_double_slab", ++ "minecraft:red_nether_brick_double_slab", "minecraft:red_sandstone_double_slab", "minecraft:resin_brick_double_slab", ++ "minecraft:sandstone_double_slab", "minecraft:smooth_quartz_double_slab", "minecraft:smooth_red_sandstone_double_slab", ++ "minecraft:smooth_sandstone_double_slab", "minecraft:smooth_stone_double_slab", "minecraft:spruce_double_slab", ++ "minecraft:stone_brick_double_slab", "minecraft:sulfur_brick_double_slab", "minecraft:sulfur_double_slab", ++ "minecraft:tuff_brick_double_slab", "minecraft:tuff_double_slab", "minecraft:warped_double_slab", ++ "minecraft:waxed_double_cut_copper_slab", "minecraft:waxed_exposed_double_cut_copper_slab", ++ "minecraft:waxed_oxidized_double_cut_copper_slab", "minecraft:waxed_weathered_double_cut_copper_slab", ++ "minecraft:weathered_double_cut_copper_slab" -> DOUBLE; ++ default -> null; ++ }; ++ } ++ ++ private static NativeBlockGeometry geometry(final float minY, final float maxY) { ++ return new NativeBlockGeometry(List.of(new CustomBlockVisual.Box(0, minY, 0, 1, maxY, 1)), CustomBlockOffset.NONE); ++ } ++ ++} diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/definition/BlockCullingDefinitions.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/definition/BlockCullingDefinitions.java new file mode 100644 index 00000000..039f1d46 @@ -2822,16 +3403,17 @@ index 00000000..9c9d6637 + +} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/BlockStateRewriter.java b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/BlockStateRewriter.java -index a4ce7054..dbb7b320 100644 +index a4ce7054..d596e6ff 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/BlockStateRewriter.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/BlockStateRewriter.java -@@ -17,23 +17,32 @@ +@@ -17,23 +17,35 @@ */ package net.raphimc.viabedrock.protocol.rewriter; -import com.google.common.collect.BiMap; -import com.google.common.collect.HashBiMap; +import net.raphimc.viabedrock.api.model.CustomBlockOffset; ++import net.raphimc.viabedrock.api.model.NativeBlockGeometry; import com.viaversion.nbt.tag.*; import com.viaversion.viaversion.api.connection.StorableObject; import com.viaversion.viaversion.libs.fastutil.ints.*; @@ -2844,6 +3426,7 @@ index a4ce7054..dbb7b320 100644 +import net.raphimc.viabedrock.api.chunk.blockstate.BlockItemDescriptors; +import net.raphimc.viabedrock.api.chunk.blockstate.BlockItemStates; import net.raphimc.viabedrock.api.model.BedrockBlockState; ++import net.raphimc.viabedrock.api.model.NativeBlockPrimaryGeometry; import net.raphimc.viabedrock.api.model.BlockState; +import net.raphimc.viabedrock.api.chunk.blockstate.BlockRegistryNames; +import net.raphimc.viabedrock.api.chunk.blockstate.RuntimeBlockPalette; @@ -2858,11 +3441,12 @@ index a4ce7054..dbb7b320 100644 +import net.raphimc.viabedrock.protocol.model.CustomBlockStates; +import net.raphimc.viabedrock.protocol.rewriter.resourcepack.CustomBlockResourceRewriter; +import net.raphimc.viabedrock.protocol.rewriter.resourcepack.CustomBlockModel; ++import net.raphimc.viabedrock.protocol.rewriter.resourcepack.NativeBlockCollision; +import net.raphimc.viabedrock.protocol.storage.ResourcePackStorage; import java.util.*; import java.util.logging.Level; -@@ -43,23 +52,41 @@ public class BlockStateRewriter implements StorableObject { +@@ -43,23 +55,45 @@ public class BlockStateRewriter implements StorableObject { private final Int2IntMap blockStateIdMappings = new Int2IntOpenHashMap(); // Bedrock -> Java private final Int2IntMap legacyBlockStateIdMappings = new Int2IntOpenHashMap(); // Bedrock -> Bedrock @@ -2870,6 +3454,8 @@ index a4ce7054..dbb7b320 100644 + private final RuntimeBlockPalette palette; + private final BlockItemDescriptors blockItemDescriptors; + private final BlockItemStates blockItemStates; ++ private final Set genericCustomStates = Collections.newSetFromMap(new IdentityHashMap<>()); ++ private final Map customBlockGeometry = new IdentityHashMap<>(); + private final Map customBlockComponents = new IdentityHashMap<>(); + private final Map customBlockItemOwners = new IdentityHashMap<>(); + private final Map registeredBlockItemIds = new HashMap<>(); @@ -2889,6 +3475,8 @@ index a4ce7054..dbb7b320 100644 final Map bedrockToJavaBlockStates = BedrockProtocol.MAPPINGS.getBedrockToJavaBlockStates(); final Map blockTags = BedrockProtocol.MAPPINGS.getBedrockCustomBlockTags(); final Set bedrockBlockIdentifiers = bedrockBlockStates.stream().map(BedrockBlockState::namespacedIdentifier).collect(Collectors.toSet()); ++ final Set nativeBlockIdentifiers = BedrockProtocol.MAPPINGS.getBedrockBlockStates().stream() ++ .map(BedrockBlockState::namespacedIdentifier).collect(Collectors.toSet()); final List customBlockStates = new ArrayList<>(); + final Map customDescriptors = new IdentityHashMap<>(); + final Map customJavaStates = new HashMap<>(); @@ -2908,7 +3496,7 @@ index a4ce7054..dbb7b320 100644 for (BlockProperties blockProperty : blockProperties) { final String identifier = blockProperty.name().toLowerCase(Locale.ROOT); if (bedrockBlockIdentifiers.contains(identifier)) { -@@ -67,102 +94,106 @@ public class BlockStateRewriter implements StorableObject { +@@ -67,102 +101,114 @@ public class BlockStateRewriter implements StorableObject { } if (!effectiveBlockProperties.containsKey(identifier)) { @@ -2952,6 +3540,9 @@ index a4ce7054..dbb7b320 100644 + + final BedrockBlockState blockState = BedrockBlockState.fromNbt(blockStateTag); + customBlockStates.add(blockState); ++ if (!nativeBlockIdentifiers.contains(blockProperty.getKey()) && NativeBlockCollision.usesBaseFactory(blockProperty.getValue().properties())) { ++ this.genericCustomStates.add(blockState); ++ } + this.customBlockItemOwners.put(blockState, BlockItemDescriptor.numericItemId(blockProperty.getValue().legacyId())); + if (generic) { + customDescriptors.put(blockState, new BlockItemDescriptor(blockProperty.getValue().legacyId(), state.metadata())); @@ -2986,6 +3577,9 @@ index a4ce7054..dbb7b320 100644 - } - } - } +- } else { +- throw new RuntimeException("Missing enabled_states tag for trait " + name); +- } + bedrockBlockStates.addAll(customBlockStates); + this.blockItemDescriptors = new BlockItemDescriptors(bedrockBlockStates, customDescriptors); + // Gameplay and visual consumers share one resolved snapshot, independent of Java pack loading. @@ -3001,11 +3595,22 @@ index a4ce7054..dbb7b320 100644 + final CustomBlockDefinition definition = compiledDefinitions.get(state.namespacedIdentifier()); + if (definition != null) { + try { -+ this.customBlockComponents.put(state, definition.resolve(state.blockStateTag().getCompoundTag("states"))); ++ final CompoundTag components = definition.resolve(state.blockStateTag().getCompoundTag("states")); ++ this.customBlockComponents.put(state, components); ++ if (this.genericCustomStates.contains(state)) { ++ this.customBlockGeometry.put(state, new NativeBlockGeometry(NativeBlockCollision.decode(components), ++ CustomBlockOffset.fromComponent(components.getCompoundTag("minecraft:random_offset")))); + } + } catch (final Exception e) { + ViaBedrock.getPlatform().getLogger().log(Level.WARNING, "Failed to resolve custom block " + state.toBlockStateString(), e); -+ } -+ } + } + } +- +- final List combinations = CombinationUtil.generateCombinations(propertiesMap).stream() +- .map(stringTagMap -> { +- final CompoundTag combination = new CompoundTag(); +- for (Map.Entry entry : stringTagMap.entrySet()) { +- combination.put(entry.getKey(), entry.getValue().copy()); + } + if (resourcePackStorage.isLoadedOnJavaClient()) { + final List carriers = CustomBlockResourceRewriter.availableStates(); @@ -3019,7 +3624,11 @@ index a4ce7054..dbb7b320 100644 + final CompoundTag components = this.customBlockComponents.get(customState); + if (components == null) { + continue; -+ } + } +- return combination; +- }).collect(Collectors.toList()); +- if (combinations.isEmpty()) { +- combinations.add(new CompoundTag()); + // Continue checking shared visuals after capacity is reached. + final int carrier = carriers.get(Math.min(visuals.size(), carriers.size() - 1)); + final CustomBlockVisual visual = CustomBlockModel.compile(resourcePackStorage, customState.toBlockStateString(), carrier, components); @@ -3034,28 +3643,15 @@ index a4ce7054..dbb7b320 100644 + visuals.put(carrier, visual); + sharedVisuals.put(key, carrier); + customJavaStates.put(customState, carrier); - } else { -- throw new RuntimeException("Missing enabled_states tag for trait " + name); ++ } else { + exhaustedStates++; - } - } ++ } ++ } + } catch (final Exception e) { + ViaBedrock.getPlatform().getLogger().log(Level.WARNING, "Failed to resolve custom block " + customState.toBlockStateString(), e); - } ++ } } - -- final List combinations = CombinationUtil.generateCombinations(propertiesMap).stream() -- .map(stringTagMap -> { -- final CompoundTag combination = new CompoundTag(); -- for (Map.Entry entry : stringTagMap.entrySet()) { -- combination.put(entry.getKey(), entry.getValue().copy()); -- } -- return combination; -- }).collect(Collectors.toList()); -- if (combinations.isEmpty()) { -- combinations.add(new CompoundTag()); -- } -- - for (CompoundTag combination : combinations) { - final CompoundTag blockStateTag = new CompoundTag(); - blockStateTag.putString("name", blockProperty.getKey()); @@ -3091,7 +3687,7 @@ index a4ce7054..dbb7b320 100644 this.validBlockStates.computeIfAbsent(bedrockBlockState.namespacedIdentifier(), k -> new IntLinkedOpenHashSet()).add(bedrockId); if (blockTags.containsKey(bedrockBlockState.namespacedIdentifier())) { -@@ -172,6 +203,8 @@ public class BlockStateRewriter implements StorableObject { +@@ -172,6 +218,8 @@ public class BlockStateRewriter implements StorableObject { if (bedrockToJavaBlockStates.containsKey(bedrockBlockState)) { final int javaId = javaBlockStates.get(bedrockToJavaBlockStates.get(bedrockBlockState)); this.blockStateIdMappings.put(bedrockId, javaId); @@ -3100,7 +3696,7 @@ index a4ce7054..dbb7b320 100644 } else { ViaBedrock.getPlatform().getLogger().log(Level.WARNING, "Missing bedrock -> java block state mapping: " + bedrockBlockState.toBlockStateString()); final int javaId = javaBlockStates.get(bedrockToJavaBlockStates.get(BedrockBlockState.INFO_UPDATE)); -@@ -186,26 +219,37 @@ public class BlockStateRewriter implements StorableObject { +@@ -186,26 +234,49 @@ public class BlockStateRewriter implements StorableObject { continue; // Dirty hack Mojang did in 1.12. Can be ignored safely as those values can't be used in chunk packets. } @@ -3125,15 +3721,25 @@ index a4ce7054..dbb7b320 100644 - for (final BedrockBlockState state : BedrockProtocol.MAPPINGS.getBedrockBlockStates()) { - if (!BedrockProtocol.MAPPINGS.getBedrockDataDrivenBlocks().contains(state.namespacedIdentifier()) || registered.contains(state.namespacedIdentifier())) { - states.add(state); -- } -- } -- return states; + /** Return an isolated snapshot for this registered runtime-state owner. */ + public CompoundTag blockComponents(final int runtimeId) { + final CompoundTag components = this.customBlockComponents.get(this.blockState(runtimeId)); + return components != null ? components.copy() : null; + } + ++ /** Null preserves unresolved native classes; an empty geometry is a verified non-colliding state. */ ++ public NativeBlockGeometry blockGeometry(final int runtimeId) { ++ final BlockState state = this.blockState(runtimeId); ++ if (state != null) { ++ final NativeBlockGeometry primary = NativeBlockPrimaryGeometry.forState(state); ++ if (primary != null) { ++ return primary; + } + } +- return states; ++ return this.customBlockGeometry.get(state); ++ } ++ + public boolean isRestrictionGroup(final String identifier) { + return this.registryNames.isRestrictionGroup(identifier); + } @@ -3153,7 +3759,7 @@ index a4ce7054..dbb7b320 100644 } public int bedrockId(final CompoundTag bedrockBlockStateTag) { -@@ -222,11 +266,37 @@ public class BlockStateRewriter implements StorableObject { +@@ -222,11 +293,37 @@ public class BlockStateRewriter implements StorableObject { } public int bedrockId(final BlockState bedrockBlockState) { @@ -3193,7 +3799,7 @@ index a4ce7054..dbb7b320 100644 } public int bedrockId(final int legacyBlockStateId) { -@@ -249,6 +319,15 @@ public class BlockStateRewriter implements StorableObject { +@@ -249,6 +346,15 @@ public class BlockStateRewriter implements StorableObject { }; } @@ -3209,7 +3815,7 @@ index a4ce7054..dbb7b320 100644 public int waterlog(final int javaBlockStateId) { if (BedrockProtocol.MAPPINGS.getJavaPreWaterloggedBlockStates().contains(javaBlockStateId)) { return javaBlockStateId; -@@ -266,4 +345,8 @@ public class BlockStateRewriter implements StorableObject { +@@ -266,4 +372,8 @@ public class BlockStateRewriter implements StorableObject { return this.blockStateTags.get(bedrockBlockStateId); } @@ -3408,10 +4014,10 @@ index 00000000..5a7981fc +} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/resourcepack/CustomBlockModel.java b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/resourcepack/CustomBlockModel.java new file mode 100644 -index 00000000..f0a19728 +index 00000000..8ad84b0a --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/resourcepack/CustomBlockModel.java -@@ -0,0 +1,587 @@ +@@ -0,0 +1,556 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -3431,7 +4037,6 @@ index 00000000..f0a19728 +import org.cube.converter.model.impl.bedrock.BedrockGeometryModel; +import org.cube.converter.model.impl.java.JavaItemModel; + -+import java.util.ArrayList; +import java.util.List; +import java.util.Comparator; +import java.util.Map; @@ -3841,47 +4446,14 @@ index 00000000..f0a19728 + } + + private static List boxes(final CompoundTag component, final boolean selection, final Transform transform) { -+ if (component != null && component.get("enabled") instanceof NumberTag enabled && enabled.asInt() == 0) { -+ return List.of(); ++ if (component == null) { ++ return List.of(new CustomBlockVisual.Box(0, 0, 0, 1, 1, 1)); + } -+ final List bounds = new ArrayList<>(); -+ final ListTag boxes = component == null ? null : component.getListTag("boxes", CompoundTag.class); -+ if (boxes != null) { -+ for (CompoundTag box : boxes) { -+ bounds.add(new double[]{number(box, "minX", 0), number(box, "minY", 0), number(box, "minZ", 0), -+ number(box, "maxX", 16), number(box, "maxY", 16), number(box, "maxZ", 16)}); -+ } -+ } else if (component != null && component.getListTag("origin") != null && component.getListTag("size") != null) { -+ final ListTag origin = component.getListTag("origin"); -+ final ListTag size = component.getListTag("size"); -+ final double x = ((NumberTag) origin.get(0)).asDouble() + 8; -+ final double y = ((NumberTag) origin.get(1)).asDouble(); -+ final double z = ((NumberTag) origin.get(2)).asDouble() + 8; -+ bounds.add(new double[]{x, y, z, x + ((NumberTag) size.get(0)).asDouble(), y + ((NumberTag) size.get(1)).asDouble(), z + ((NumberTag) size.get(2)).asDouble()}); -+ } else { -+ bounds.add(new double[]{0, 0, 0, 16, 16, 16}); ++ if (selection) { ++ final CustomBlockVisual.Box selected = NativeBlockSelection.decode(component); ++ return selected == null ? List.of() : List.of(transform.physical.box(selected)); + } -+ final List result = new ArrayList<>(); -+ for (double[] box : bounds) { -+ // An empty native shape stays empty even if rotation expands its enclosing bounds. -+ if (box[0] == box[3] || box[1] == box[4] || box[2] == box[5]) { -+ continue; -+ } -+ final double[] min = new double[]{Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY}; -+ final double[] max = new double[]{Double.NEGATIVE_INFINITY, Double.NEGATIVE_INFINITY, Double.NEGATIVE_INFINITY}; -+ for (int corner = 0; corner < 8; corner++) { -+ final double[] point = transform.point(new double[]{box[(corner & 1) == 0 ? 0 : 3], box[(corner & 2) == 0 ? 1 : 4], box[(corner & 4) == 0 ? 2 : 5]}); -+ for (int i = 0; i < 3; i++) { -+ min[i] = Math.min(min[i], point[i]); -+ max[i] = Math.max(max[i], point[i]); -+ } -+ } -+ if (min[0] == max[0] || min[1] == max[1] || min[2] == max[2]) { -+ continue; -+ } -+ result.add(new CustomBlockVisual.Box(min[0] / 16, min[1] / 16, min[2] / 16, max[0] / 16, max[1] / 16, max[2] / 16)); -+ } -+ return List.copyOf(result); ++ return NativeBlockCollision.decode(component, transform.physical); + } + + private static double number(final CompoundTag tag, final String key, final double fallback) { @@ -3941,6 +4513,7 @@ index 00000000..f0a19728 + } + + static final class Transform { ++ private final NativeBlockTransform physical; + private final double[] scale; + private final double[] translation; + private final double[] rotationPivot; @@ -3948,6 +4521,7 @@ index 00000000..f0a19728 + private final double[][] rotation; + + Transform(final CompoundTag tag) { ++ this.physical = new NativeBlockTransform(tag); + this.scale = new double[]{number(tag, "SX", 1), number(tag, "SY", 1), number(tag, "SZ", 1)}; + this.translation = new double[]{number(tag, "TX", 0) * 16, number(tag, "TY", 0) * 16, number(tag, "TZ", 0) * 16}; + this.rotationPivot = new double[]{8 + number(tag, "RXP", 0) * 16, 8 + number(tag, "RYP", 0) * 16, 8 + number(tag, "RZP", 0) * 16}; @@ -3956,6 +4530,7 @@ index 00000000..f0a19728 + } + + Transform(final Parent bone) { ++ this.physical = new NativeBlockTransform(null); + this.scale = new double[]{1, 1, 1}; + this.translation = new double[]{0, 0, 0}; + this.rotationPivot = new double[]{8 - bone.getPivot().getX(), bone.getPivot().getY(), 8 + bone.getPivot().getZ()}; @@ -4473,6 +5048,349 @@ index 00000000..a063da13 + } + +} +diff --git a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/resourcepack/NativeBlockCollision.java b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/resourcepack/NativeBlockCollision.java +new file mode 100644 +index 00000000..cb349154 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/resourcepack/NativeBlockCollision.java +@@ -0,0 +1,82 @@ ++/* ++ * 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.protocol.rewriter.resourcepack; ++ ++import com.viaversion.nbt.tag.ByteTag; ++import com.viaversion.nbt.tag.CompoundTag; ++import com.viaversion.nbt.tag.FloatTag; ++import com.viaversion.nbt.tag.ListTag; ++import com.viaversion.nbt.tag.StringTag; ++import com.viaversion.nbt.tag.Tag; ++import net.raphimc.viabedrock.api.model.CustomBlockVisual; ++ ++import java.util.ArrayList; ++import java.util.List; ++import java.util.Set; ++ ++/** Physical collision components admitted by the protocol-2225 base block factory. */ ++public final class NativeBlockCollision { ++ ++ // These exact, case-sensitive names select other native factories in Windows 1.26.60.5. ++ private static final Set ARCHETYPES = Set.of("double_sapling_block", "shelf_block", "bush_block", "wall_foliage_block", ++ "hanging_sign_block", "farm_block", "slab_block", "leaves_block", "door_block", "log_block", "button_block", ++ "carpet_block", "stripped_log_block", "mushroom_block", "crop_block", "dirt_block", "path_block", "fence_block", ++ "fence_gate_block", "flower_bed_block", "planks_block", "pressure_plate_block", "pumpkin_block", "sapling_block", ++ "sign_block", "stair_block", "trapdoor_block", "wall_sign_block"); ++ ++ private NativeBlockCollision() { ++ } ++ ++ public static boolean usesBaseFactory(final CompoundTag properties) { ++ if (properties.get("vanilla_block_data") instanceof CompoundTag vanilla ++ && vanilla.get("block_archetype") instanceof CompoundTag archetype ++ && archetype.get("type") instanceof StringTag type) { ++ return !ARCHETYPES.contains(type.getValue()); ++ } ++ return true; ++ } ++ ++ public static List decode(final CompoundTag components) { ++ return decode(component(components, "minecraft:collision_box"), ++ new NativeBlockTransform(component(components, "minecraft:transformation"))); ++ } ++ ++ static List decode(final CompoundTag component, final NativeBlockTransform transform) { ++ if (component == null) { ++ return List.of(new CustomBlockVisual.Box(0, 0, 0, 1, 1, 1)); ++ } ++ if (!(component.get("enabled") instanceof ByteTag enabled) || enabled.asByte() == 0) { ++ return List.of(); ++ } ++ final List bounds = new ArrayList<>(); ++ if (component.get("boxes") instanceof ListTag supplied) { ++ for (final Tag tag : supplied) { ++ if (tag instanceof CompoundTag box) { ++ bounds.add(new double[]{coordinate(box, "minX", 16F), coordinate(box, "minY", 24F), coordinate(box, "minZ", 16F), ++ coordinate(box, "maxX", 16F), coordinate(box, "maxY", 24F), coordinate(box, "maxZ", 16F)}); ++ } ++ } ++ } ++ if (bounds.isEmpty()) { ++ bounds.add(new double[]{0, 0, 0, 16, 16, 16}); ++ } ++ final List result = new ArrayList<>(); ++ for (final double[] box : bounds) { ++ result.add(transform.box(box)); ++ } ++ return List.copyOf(result); ++ } ++ ++ private static CompoundTag component(final CompoundTag components, final String key) { ++ return components.get(key) instanceof CompoundTag tag ? tag : null; ++ } ++ ++ private static float coordinate(final CompoundTag box, final String key, final float maximum) { ++ final float value = box.get(key) instanceof FloatTag number ? number.asFloat() : 0F; ++ return value > maximum ? maximum : value > 0F ? value : 0F; ++ } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/resourcepack/NativeBlockSelection.java b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/resourcepack/NativeBlockSelection.java +new file mode 100644 +index 00000000..3f5ae8ad +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/resourcepack/NativeBlockSelection.java +@@ -0,0 +1,54 @@ ++/* ++ * 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.protocol.rewriter.resourcepack; ++ ++import com.viaversion.nbt.tag.ByteTag; ++import com.viaversion.nbt.tag.CompoundTag; ++import com.viaversion.nbt.tag.FloatTag; ++import com.viaversion.nbt.tag.ListTag; ++import net.raphimc.viabedrock.api.model.CustomBlockVisual; ++ ++/** ++ * Native selection components use a reflected, clamped origin/size box rather than collision endpoints. ++ */ ++final class NativeBlockSelection { ++ ++ private NativeBlockSelection() { ++ } ++ ++ static CustomBlockVisual.Box decode(final CompoundTag component) { ++ if (!(component.get("enabled") instanceof ByteTag enabled) || enabled.asByte() == 0) { ++ return null; ++ } ++ if (!(component.get("origin") instanceof ListTag origin) || !(component.get("size") instanceof ListTag size)) { ++ // The wire description supplies both lists. Do not turn malformed descriptions into full blocks. ++ return null; ++ } ++ final float[] minimum = new float[3]; ++ final float[] maximum = new float[3]; ++ for (int axis = 0; axis < 3; axis++) { ++ final float offset = coordinate(origin, axis) + (axis == 1 ? 0F : 8F); ++ final float first = clamp(offset * 0.0625F); ++ final float second = clamp((coordinate(size, axis) + offset) * 0.0625F); ++ final float low = Math.min(first, second); ++ final float high = Math.max(first, second); ++ final float half = (high - low) * 0.5F; ++ final float center = low + half; ++ minimum[axis] = (axis == 0 ? 1F - center : center) - half; ++ maximum[axis] = (axis == 0 ? 1F - center : center) + half; ++ } ++ return new CustomBlockVisual.Box(minimum[0], minimum[1], minimum[2], maximum[0], maximum[1], maximum[2]); ++ } ++ ++ private static float coordinate(final ListTag list, final int index) { ++ return index < list.size() && list.get(index) instanceof FloatTag number ? number.asFloat() : 0F; ++ } ++ ++ private static float clamp(final float value) { ++ return value > 1F ? 1F : value > 0F ? value : 0F; ++ } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/resourcepack/NativeBlockTransform.java b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/resourcepack/NativeBlockTransform.java +new file mode 100644 +index 00000000..09e03da3 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/resourcepack/NativeBlockTransform.java +@@ -0,0 +1,189 @@ ++/* ++ * 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.protocol.rewriter.resourcepack; ++ ++import com.viaversion.nbt.tag.CompoundTag; ++import com.viaversion.nbt.tag.FloatTag; ++import com.viaversion.nbt.tag.IntTag; ++import net.raphimc.viabedrock.api.model.CustomBlockVisual; ++ ++/** ++ * Physical block transforms selected by the protocol-2225 component decoder. ++ * Keep matrix products and paired AABB sums in native float order. Model vertices use their separate coordinate transform. ++ */ ++final class NativeBlockTransform { ++ ++ private final float[] matrix; ++ ++ NativeBlockTransform(final CompoundTag tag) { ++ this.matrix = tag == null ? null : physicalMatrix(tag); ++ } ++ ++ CustomBlockVisual.Box box(final double[] pixels) { ++ final float[] minimum = new float[3]; ++ final float[] maximum = new float[3]; ++ for (int axis = 0; axis < 3; axis++) { ++ minimum[axis] = Math.min((float) pixels[axis], (float) pixels[axis + 3]) / 16F; ++ maximum[axis] = Math.max((float) pixels[axis], (float) pixels[axis + 3]) / 16F; ++ } ++ return this.project(minimum, maximum); ++ } ++ ++ CustomBlockVisual.Box box(final CustomBlockVisual.Box normalized) { ++ return this.project(new float[]{(float) normalized.minX(), (float) normalized.minY(), (float) normalized.minZ()}, ++ new float[]{(float) normalized.maxX(), (float) normalized.maxY(), (float) normalized.maxZ()}); ++ } ++ ++ private CustomBlockVisual.Box project(final float[] minimum, final float[] maximum) { ++ if (this.matrix == null) { ++ return bounds(minimum, maximum); ++ } ++ final float[] center = new float[3]; ++ final float[] half = new float[3]; ++ for (int axis = 0; axis < 3; axis++) { ++ half[axis] = (maximum[axis] - minimum[axis]) * 0.5F; ++ center[axis] = minimum[axis] + half[axis]; ++ } ++ for (int axis = 0; axis < 3; axis++) { ++ final float transformed = (this.matrix[axis + 4] * center[1] + this.matrix[axis] * center[0]) ++ + (this.matrix[axis + 12] + this.matrix[axis + 8] * center[2]); ++ final float extent = (Math.abs(this.matrix[axis + 4]) * half[1] + Math.abs(this.matrix[axis]) * half[0]) ++ + (Math.abs(this.matrix[axis + 12]) * 0F + Math.abs(this.matrix[axis + 8]) * half[2]); ++ minimum[axis] = Math.min(transformed - extent, transformed + extent); ++ maximum[axis] = Math.max(transformed - extent, transformed + extent); ++ } ++ return bounds(minimum, maximum); ++ } ++ ++ private static CustomBlockVisual.Box bounds(final float[] min, final float[] max) { ++ return new CustomBlockVisual.Box(min[0], min[1], min[2], max[0], max[1], max[2]); ++ } ++ ++ private static float number(final CompoundTag tag, final String key) { ++ return tag.get(key) instanceof FloatTag value ? value.asFloat() : 0F; ++ } ++ ++ private static int turns(final CompoundTag tag, final String key) { ++ return tag.get(key) instanceof IntTag value ? Math.max(value.asInt(), 0) % 4 : 0; ++ } ++ ++ static float[] matrix(final CompoundTag tag) { ++ float[] rotation = identity(); ++ final float[][] axes = { ++ {1, 0, 0, 0, 0, 0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1}, ++ {0, 0, -1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1}, ++ {0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1} ++ }; ++ final int[] turns = {turns(tag, "RX"), turns(tag, "RY"), turns(tag, "RZ")}; ++ for (int axis = 2; axis >= 0; axis--) { ++ for (int turn = 0; turn < turns[axis]; turn++) { ++ rotation = multiply(rotation, axes[axis]); ++ } ++ } ++ // Native quarter-turn matrices pass through quaternion extraction before scale and pivot application. ++ rotation = quaternionMatrix(rotation); ++ final float[] translation = {number(tag, "TX"), number(tag, "TY"), number(tag, "TZ")}; ++ final float[] rotationPivot = {number(tag, "RXP"), number(tag, "RYP"), number(tag, "RZP")}; ++ final float[] scalePivot = {number(tag, "SXP"), number(tag, "SYP"), number(tag, "SZP")}; ++ final float[] scale = {number(tag, "SX"), number(tag, "SY"), number(tag, "SZ")}; ++ float[] result = identity(); ++ for (int axis = 0; axis < 3; axis++) { ++ result[axis + 12] = translation[axis] + rotationPivot[axis]; ++ } ++ result = multiply(result, rotation); ++ for (int row = 0; row < 4; row++) { ++ final float shifted = (-rotationPivot[0] * result[row] - rotationPivot[1] * result[row + 4]) ++ - rotationPivot[2] * result[row + 8] + result[row + 12]; ++ final float unscaled = ((result[row] * scalePivot[0] + result[row + 4] * scalePivot[1]) ++ + result[row + 8] * scalePivot[2]) + shifted; ++ result[row] *= scale[0]; ++ result[row + 4] *= scale[1]; ++ result[row + 8] *= scale[2]; ++ result[row + 12] = (-scalePivot[0] * result[row] - scalePivot[1] * result[row + 4]) ++ - scalePivot[2] * result[row + 8] + unscaled; ++ } ++ return result; ++ } ++ ++ private static float[] physicalMatrix(final CompoundTag tag) { ++ final float[] before = identity(); ++ final float[] after = identity(); ++ for (int axis = 0; axis < 3; axis++) { ++ before[axis + 12] = 0.5F; ++ after[axis + 12] = -0.5F; ++ } ++ return multiply(multiply(before, matrix(tag)), after); ++ } ++ ++ private static float[] quaternionMatrix(final float[] m) { ++ final float[] choices = {m[0] + m[5] + m[10], (m[0] - m[5]) - m[10], (m[5] - m[0]) - m[10], (m[10] - m[0]) - m[5]}; ++ int biggest = 0; ++ for (int i = 1; i < choices.length; i++) { ++ if (choices[i] > choices[biggest]) { ++ biggest = i; ++ } ++ } ++ final float value = (float) Math.sqrt(choices[biggest] + 1F) * 0.5F; ++ final float multiplier = 0.25F / value; ++ final float x; ++ final float y; ++ final float z; ++ final float w; ++ switch (biggest) { ++ case 0 -> { ++ w = value; ++ x = (m[6] - m[9]) * multiplier; ++ y = (m[8] - m[2]) * multiplier; ++ z = (m[1] - m[4]) * multiplier; ++ } ++ case 1 -> { ++ x = value; ++ w = (m[6] - m[9]) * multiplier; ++ y = (m[4] + m[1]) * multiplier; ++ z = (m[8] + m[2]) * multiplier; ++ } ++ case 2 -> { ++ y = value; ++ w = (m[8] - m[2]) * multiplier; ++ x = (m[4] + m[1]) * multiplier; ++ z = (m[9] + m[6]) * multiplier; ++ } ++ default -> { ++ z = value; ++ w = (m[1] - m[4]) * multiplier; ++ x = (m[8] + m[2]) * multiplier; ++ y = (m[9] + m[6]) * multiplier; ++ } ++ } ++ final float xx = x * (x + x); ++ final float yy = y * (y + y); ++ final float zz = z * (z + z); ++ final float xy = x * (y + y); ++ final float xz = x * (z + z); ++ final float yz = y * (z + z); ++ final float wx = w * (x + x); ++ final float wy = w * (y + y); ++ final float wz = w * (z + z); ++ return new float[]{1F - yy - zz, xy + wz, xz - wy, 0, xy - wz, 1F - xx - zz, yz + wx, 0, ++ xz + wy, yz - wx, 1F - xx - yy, 0, 0, 0, 0, 1}; ++ } ++ ++ private static float[] identity() { ++ return new float[]{1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1}; ++ } ++ ++ private static float[] multiply(final float[] left, final float[] right) { ++ final float[] result = new float[16]; ++ for (int column = 0; column < 4; column++) { ++ for (int row = 0; row < 4; row++) { ++ result[column * 4 + row] = ((left[row] * right[column * 4] + left[row + 4] * right[column * 4 + 1]) ++ + left[row + 8] * right[column * 4 + 2]) + left[row + 12] * right[column * 4 + 3]; ++ } ++ } ++ return result; ++ } ++ ++} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java index 2483b18c..b0b0a4e9 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java @@ -8788,10 +9706,10 @@ index 00000000..2615420e +} diff --git a/src/test/java/net/raphimc/viabedrock/api/model/CustomBlockPropertiesTest.java b/src/test/java/net/raphimc/viabedrock/api/model/CustomBlockPropertiesTest.java new file mode 100644 -index 00000000..0e855c20 +index 00000000..872ce5e2 --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/api/model/CustomBlockPropertiesTest.java -@@ -0,0 +1,69 @@ +@@ -0,0 +1,92 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -8823,7 +9741,7 @@ index 00000000..0e855c20 + } + + @Test -+ void canonicalizesEmptyPhysicalBoxesWithoutRejectingThePack() { ++ void retainsDegenerateMetadataAndOmitsEmptyPhysicalVolumes() { + final var volume = new CustomBlockVisual.Box(0, 0, 0, 1, 1, 1); + final List boxes = List.of(volume, + new CustomBlockVisual.Box(0, 0, 0, 0, 1, 1), @@ -8832,13 +9750,36 @@ index 00000000..0e855c20 + final var visual = new CustomBlockVisual("test:block", 42, "texture", "{}", "opaque", + boxes, boxes.subList(1, 4), false, 0, 15, Map.of(), Map.of(), CustomBlockOffset.NONE); + final var properties = CustomBlockProperties.of(visual); -+ assertEquals(List.of(volume), properties.collisionBoxes()); -+ assertTrue(properties.selectionBoxes().isEmpty()); ++ assertEquals(boxes, properties.collisionBoxes()); ++ assertEquals(boxes.subList(1, 4), properties.selectionBoxes()); ++ assertEquals(List.of(volume), properties.collisionBoxesAt(0, 0, 0)); ++ assertTrue(properties.selectionBoxesAt(0, 0, 0).isEmpty()); + assertEquals(properties, CustomBlockProperties.decode(CustomBlockProperties.encode(List.of(visual))).get(42)); + assertEquals(4, visual.collisionBoxes().size()); + } + + @Test ++ void retainsCameraEnvelopeWhenIndividualBoxesCollapseAtLargeWorldCoordinates() { ++ final var boxes = List.of(new CustomBlockVisual.Box(0, 0, 0, 0.0625, 1, 0.0625), ++ new CustomBlockVisual.Box(0.5, 0, 0.5, 0.5625, 1, 0.5625)); ++ final var properties = new CustomBlockProperties("test:split", 42, boxes, boxes, false, 0, 15, CustomBlockOffset.NONE); ++ assertEquals(List.of(new CustomBlockVisual.Box(1048576, 64, 1048576, 1048576.5, 65, 1048576.5)), ++ properties.cameraBoxesAt(1048576, 64, 1048576)); ++ assertTrue(properties.selectionBoxesAt(1048576, 64, 1048576).isEmpty()); ++ assertTrue(properties.collisionBoxesAt(1048576, 64, 1048576).isEmpty()); ++ } ++ ++ @Test ++ void retainsFlatBoxesInCameraBoundsWithoutCreatingMovementVolume() { ++ final var plane = new CustomBlockVisual.Box(0, 0, 0, 0, 1, 1); ++ final var volume = new CustomBlockVisual.Box(0.25, 0, 0.25, 0.5, 1, 0.5); ++ final var properties = new CustomBlockProperties("test:mixed", 42, List.of(plane, volume), List.of(), ++ false, 0, 15, CustomBlockOffset.NONE); ++ assertEquals(List.of(new CustomBlockVisual.Box(0, 64, 0, 0.5, 65, 1)), properties.cameraBoxesAt(0, 64, 0)); ++ assertEquals(List.of(new CustomBlockVisual.Box(0.25, 64, 0.25, 0.5, 65, 0.5)), properties.collisionBoxesAt(0, 64, 0)); ++ } ++ ++ @Test + void rejectsAmbiguousVersionsDuplicateStatesAndInvalidBounds() { + final CustomBlockVisual visual = new CustomBlockVisual("test:block", 42, "texture", "{}", "opaque", + List.of(), List.of(), true, 0, 15, Map.of(), Map.of(), CustomBlockOffset.NONE); @@ -8861,6 +9802,149 @@ index 00000000..0e855c20 + } + +} +diff --git a/src/test/java/net/raphimc/viabedrock/api/model/NativeBlockGeometryTest.java b/src/test/java/net/raphimc/viabedrock/api/model/NativeBlockGeometryTest.java +new file mode 100644 +index 00000000..b8e45975 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/api/model/NativeBlockGeometryTest.java +@@ -0,0 +1,31 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.model; ++ ++import org.junit.jupiter.api.Test; ++import java.util.List; ++import static org.junit.jupiter.api.Assertions.*; ++ ++class NativeBlockGeometryTest { ++ ++ @Test ++ void envelopesPlanesBeforeWorldFloatRoundingAndRetainsTheCollapsedPrimary() { ++ final var geometry = new NativeBlockGeometry(List.of(new CustomBlockVisual.Box(0, 0, 0, 0, 1, 1), ++ new CustomBlockVisual.Box(1, 0, 0, 1, 1, 1)), CustomBlockOffset.NONE); ++ assertEquals(new CustomBlockVisual.Box(0, 64, 0, 1, 65, 1), geometry.primaryBoundsAt(0, 64, 0)); ++ assertEquals(new CustomBlockVisual.Box(16777216, 64, 16777216, 16777216, 65, 16777216), ++ geometry.primaryBoundsAt(16777216, 64, 16777216)); ++ assertTrue(geometry.cameraBoxesAt(16777216, 64, 16777216).isEmpty()); ++ } ++ ++ @Test ++ void disabledCollisionDoesNotTranslateTheZeroBoundsOrApplyOffsets() { ++ final var offset = new CustomBlockOffset(new CustomBlockOffset.Axis(1, 1, 0), CustomBlockOffset.ZERO, CustomBlockOffset.ZERO); ++ assertEquals(new CustomBlockVisual.Box(0, 0, 0, 0, 0, 0), ++ new NativeBlockGeometry(List.of(), offset).primaryBoundsAt(-17, -64, 93)); ++ } ++ ++} +diff --git a/src/test/java/net/raphimc/viabedrock/api/model/NativeBlockPrimaryGeometryTest.java b/src/test/java/net/raphimc/viabedrock/api/model/NativeBlockPrimaryGeometryTest.java +new file mode 100644 +index 00000000..db26803d +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/api/model/NativeBlockPrimaryGeometryTest.java +@@ -0,0 +1,46 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.model; ++ ++import org.junit.jupiter.api.Test; ++ ++import java.util.Map; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++class NativeBlockPrimaryGeometryTest { ++ ++ @Test ++ void preservesRoundedChestAndEggBoundsThroughWorldTranslation() { ++ final var chest = NativeBlockPrimaryGeometry.forState(new BlockState("chest", Map.of())); ++ assertNotNull(chest); ++ assertEquals(new CustomBlockVisual.Box(-16.975000381469727, -64, 93.0250015258789, ++ -16.024999618530273, -63.04999923706055, 93.9749984741211), chest.primaryBoundsAt(-17, -64, 93)); ++ final var egg = NativeBlockPrimaryGeometry.forState(new BlockState("turtle_egg", Map.of())); ++ assertNotNull(egg); ++ assertEquals(new CustomBlockVisual.Box(-16.799999237060547, -64, 93.19999694824219, ++ -16.200000762939453, -63.54999923706055, 93.80000305175781), egg.primaryBoundsAt(-17, -64, 93)); ++ } ++ ++ @Test ++ void usesTheLecternCollisionComponentAndEmptyFlowerPrimaryProvider() { ++ final var lectern = NativeBlockPrimaryGeometry.forState(new BlockState("lectern", Map.of())); ++ assertNotNull(lectern); ++ assertEquals(new CustomBlockVisual.Box(0, 0, 0, 1, 0.9F, 1), lectern.primaryBoundsAt(0, 0, 0)); ++ final var flower = NativeBlockPrimaryGeometry.forState(new BlockState("poppy", Map.of())); ++ assertNotNull(flower); ++ assertTrue(flower.collisionBoxes().isEmpty()); ++ assertEquals(new CustomBlockVisual.Box(0, 0, 0, 0, 0, 0), flower.primaryBoundsAt(-17, -64, 93)); ++ } ++ ++ @Test ++ void keepsUnresolvedProvidersAndCustomNamespacesWithTheirExistingFallback() { ++ assertNull(NativeBlockPrimaryGeometry.forState(new BlockState("oak_fence", Map.of()))); ++ assertNull(NativeBlockPrimaryGeometry.forState(new BlockState("probe", "stone", Map.of()))); ++ assertNull(NativeBlockPrimaryGeometry.forState(new BlockState("future_block", Map.of()))); ++ } ++ ++} +diff --git a/src/test/java/net/raphimc/viabedrock/api/model/NativeSlabGeometryTest.java b/src/test/java/net/raphimc/viabedrock/api/model/NativeSlabGeometryTest.java +new file mode 100644 +index 00000000..6ea74aac +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/api/model/NativeSlabGeometryTest.java +@@ -0,0 +1,48 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.model; ++ ++import org.junit.jupiter.api.Test; ++ ++import java.util.Map; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++class NativeSlabGeometryTest { ++ ++ @Test ++ void retainsHalfBoundsUntilWorldFloatRoundingCollapsesTheCameraVolume() { ++ final var upper = NativeSlabGeometry.forState(new BlockState("oak_slab", Map.of("minecraft:vertical_half", "top"))); ++ assertNotNull(upper); ++ assertEquals(new CustomBlockVisual.Box(-17, -63.5, 93, -16, -63, 94), upper.primaryBoundsAt(-17, -64, 93)); ++ assertTrue(upper.cameraBoxesAt(16777216, 64, 16777216).isEmpty()); ++ final var doubled = NativeSlabGeometry.forState(new BlockState("oak_double_slab", Map.of("minecraft:vertical_half", "top"))); ++ assertNotNull(doubled); ++ assertEquals(new CustomBlockVisual.Box(-17, -64, 93, -16, -63, 94), doubled.primaryBoundsAt(-17, -64, 93)); ++ } ++ ++ @Test ++ void retainsInheritedCopperSlabBoundsAcrossWaxingOxidationAndHalfChanges() { ++ final var lower = NativeSlabGeometry.forState(new BlockState("cut_copper_slab", Map.of("minecraft:vertical_half", "bottom"))); ++ final var upper = NativeSlabGeometry.forState(new BlockState("waxed_oxidized_cut_copper_slab", Map.of("minecraft:vertical_half", "top"))); ++ final var doubled = NativeSlabGeometry.forState(new BlockState("waxed_weathered_double_cut_copper_slab", Map.of("minecraft:vertical_half", "bottom"))); ++ assertNotNull(lower); ++ assertNotNull(upper); ++ assertNotNull(doubled); ++ assertEquals(new CustomBlockVisual.Box(-17, -64, 93, -16, -63.5, 94), lower.primaryBoundsAt(-17, -64, 93)); ++ assertEquals(new CustomBlockVisual.Box(-17, -63.5, 93, -16, -63, 94), upper.primaryBoundsAt(-17, -64, 93)); ++ assertEquals(new CustomBlockVisual.Box(-17, -64, 93, -16, -63, 94), doubled.primaryBoundsAt(-17, -64, 93)); ++ } ++ ++ @Test ++ void leavesOtherClassesAndInvalidHalfValuesToTheirExistingProvider() { ++ assertNull(NativeSlabGeometry.forState(new BlockState("probe", "oak_slab", Map.of("minecraft:vertical_half", "top")))); ++ assertNull(NativeSlabGeometry.forState(new BlockState("future_slab", Map.of("minecraft:vertical_half", "top")))); ++ assertNull(NativeSlabGeometry.forState(new BlockState("oak_slab", Map.of("minecraft:vertical_half", "Top")))); ++ assertNull(NativeSlabGeometry.forState(new BlockState("oak_slab", Map.of()))); ++ } ++ ++} diff --git a/src/test/java/net/raphimc/viabedrock/api/resourcepack/definition/TextureDefinitionsTest.java b/src/test/java/net/raphimc/viabedrock/api/resourcepack/definition/TextureDefinitionsTest.java new file mode 100644 index 00000000..9b0790e1 @@ -8910,33 +9994,38 @@ index 00000000..9b0790e1 + +} diff --git a/src/test/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCacheTest.java b/src/test/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCacheTest.java -index 9e5137e3..d9c13386 100644 +index 9e5137e3..b3a1a330 100644 --- a/src/test/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCacheTest.java +++ b/src/test/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCacheTest.java -@@ -17,7 +17,9 @@ +@@ -17,7 +17,10 @@ */ package net.raphimc.viabedrock.api.resourcepack.http; +import net.raphimc.viabedrock.api.model.CustomBlockOffset; import net.raphimc.viabedrock.api.resourcepack.ResourcePack; +import net.raphimc.viabedrock.api.model.CustomBlockVisual; ++import net.raphimc.viabedrock.api.model.CustomBlockProperties; import net.raphimc.viabedrock.api.resourcepack.content.InMemoryContent; import net.raphimc.viabedrock.api.resourcepack.content.Content; import net.raphimc.viabedrock.api.resourcepack.content.ZipContent; -@@ -147,6 +149,33 @@ class ConvertedResourcePackCacheTest { +@@ -147,6 +150,37 @@ class ConvertedResourcePackCacheTest { } } + @Test -+ void emptyBoxesUseTheSameCacheIdentityAsDisabledShapes() { ++ void flatBoundsThatChangeTheCameraEnvelopeInvalidateTheConvertedPack() { + final ResourcePackStorage storage = new ResourcePackStorage(List.of()); -+ final var empty = new CustomBlockVisual("test:block", 42, "texture", "{}", "opaque", -+ List.of(new CustomBlockVisual.Box(0, 0, 0, 0, 1, 1)), List.of(), false, 0, 15, Map.of(), Map.of(), CustomBlockOffset.NONE); -+ storage.setCustomBlockVisuals(Map.of(42, empty)); ++ final var plane = new CustomBlockVisual.Box(0, 0, 0, 0, 1, 1); ++ final var volume = new CustomBlockVisual.Box(0.25, 0, 0.25, 0.5, 1, 0.5); ++ final var mixed = new CustomBlockVisual("test:block", 42, "texture", "{}", "opaque", ++ List.of(plane, volume), List.of(), false, 0, 15, Map.of(), Map.of(), CustomBlockOffset.NONE); ++ final var solid = new CustomBlockVisual("test:block", 42, "texture", "{}", "opaque", ++ List.of(volume), List.of(), false, 0, 15, Map.of(), Map.of(), CustomBlockOffset.NONE); ++ storage.setCustomBlockVisuals(Map.of(42, mixed)); + final String fingerprint = ConvertedResourcePackCache.fingerprint(storage); -+ storage.setCustomBlockVisuals(Map.of(42, new CustomBlockVisual("test:block", 42, "texture", "{}", "opaque", -+ List.of(), List.of(), false, 0, 15, Map.of(), Map.of(), CustomBlockOffset.NONE))); -+ assertEquals(fingerprint, ConvertedResourcePackCache.fingerprint(storage)); ++ storage.setCustomBlockVisuals(Map.of(42, solid)); ++ assertNotEquals(CustomBlockProperties.of(mixed).cameraBoxesAt(0, 64, 0), CustomBlockProperties.of(solid).cameraBoxesAt(0, 64, 0)); ++ assertNotEquals(fingerprint, ConvertedResourcePackCache.fingerprint(storage)); + } + + @Test @@ -9711,10 +10800,10 @@ index 00000000..61c3a60c +} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/rewriter/CustomBlockModelSelectionTest.java b/src/test/java/net/raphimc/viabedrock/protocol/rewriter/CustomBlockModelSelectionTest.java new file mode 100644 -index 00000000..a12d1f9d +index 00000000..638bed76 --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/protocol/rewriter/CustomBlockModelSelectionTest.java -@@ -0,0 +1,512 @@ +@@ -0,0 +1,723 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -9725,6 +10814,7 @@ index 00000000..a12d1f9d +import com.viaversion.nbt.tag.*; +import io.netty.buffer.Unpooled; +import net.raphimc.viabedrock.api.model.CustomBlockVisual; ++import net.raphimc.viabedrock.api.model.CustomBlockProperties; +import net.raphimc.viabedrock.api.resourcepack.ResourcePack; +import net.raphimc.viabedrock.api.resourcepack.content.InMemoryContent; +import net.raphimc.viabedrock.api.resourcepack.definition.CustomBlockDefinition; @@ -9769,30 +10859,239 @@ index 00000000..a12d1f9d + } + + @Test -+ void emptyBoxesRemainEmptyBeforeAndAfterTransformation() { ++ void decodesCollisionFlagsAndEnabledEmptyBoxesFromTheNativeWireForm() throws Exception { ++ final ResourcePackStorage storage = new ResourcePackStorage(List.of(pack(new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB)))); ++ final CompoundTag components = components("minecraft:geometry.full_block", "ruby", "opaque"); ++ final CompoundTag collision = new CompoundTag(); ++ collision.putByte("enabled", (byte) 1); ++ collision.put("boxes", new ListTag<>(CompoundTag.class)); ++ components.put("minecraft:collision_box", collision); ++ final var buffer = Unpooled.buffer(); ++ try { ++ BedrockTypes.NETWORK_TAG.write(buffer, components); ++ final CompoundTag decoded = (CompoundTag) BedrockTypes.NETWORK_TAG.read(buffer); ++ assertEquals(List.of(new CustomBlockVisual.Box(0, 0, 0, 1, 1, 1)), ++ CustomBlockModel.compile(storage, "test:block", 42, decoded).collisionBoxes()); ++ decoded.getCompoundTag("minecraft:collision_box").remove("enabled"); ++ assertTrue(CustomBlockModel.compile(storage, "test:block", 42, decoded).collisionBoxes().isEmpty()); ++ decoded.getCompoundTag("minecraft:collision_box").putInt("enabled", 1); ++ assertTrue(CustomBlockModel.compile(storage, "test:block", 42, decoded).collisionBoxes().isEmpty()); ++ } finally { ++ buffer.release(); ++ } ++ } ++ ++ @Test ++ void clampsCollisionEndpointsBeforeConvertingToBlockUnits() { ++ final ResourcePackStorage storage = new ResourcePackStorage(List.of(pack(new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB)))); ++ final CompoundTag components = components("minecraft:geometry.full_block", "ruby", "opaque"); ++ final CompoundTag collision = new CompoundTag(); ++ collision.putByte("enabled", (byte) 1); ++ collision.put("boxes", new ListTag<>(List.of(collisionBox(-8, -2, 3, 24, 32, 12)))); ++ components.put("minecraft:collision_box", collision); ++ assertEquals(List.of(new CustomBlockVisual.Box(0, 0, 0.1875, 1, 1.5, 0.75)), ++ CustomBlockModel.compile(storage, "test:block", 42, components).collisionBoxes()); ++ } ++ ++ @Test ++ void defaultsMissingAndNonFloatCollisionCoordinatesToZero() { ++ final ResourcePackStorage storage = new ResourcePackStorage(List.of(pack(new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB)))); ++ final CompoundTag components = components("minecraft:geometry.full_block", "ruby", "opaque"); ++ final CompoundTag box = collisionBox(0, 0, 0, 16, 16, 16); ++ final CompoundTag collision = new CompoundTag(); ++ collision.putByte("enabled", (byte) 1); ++ collision.put("boxes", new ListTag<>(List.of(box))); ++ components.put("minecraft:collision_box", collision); ++ box.remove("maxX"); ++ assertTrue(CustomBlockProperties.of(CustomBlockModel.compile(storage, "test:block", 42, components)).collisionBoxesAt(0, 0, 0).isEmpty()); ++ box.putInt("maxX", 16); ++ assertTrue(CustomBlockProperties.of(CustomBlockModel.compile(storage, "test:block", 42, components)).collisionBoxesAt(0, 0, 0).isEmpty()); ++ } ++ ++ private static CompoundTag collisionBox(final float minX, final float minY, final float minZ, ++ final float maxX, final float maxY, final float maxZ) { ++ final CompoundTag box = new CompoundTag(); ++ box.putFloat("minX", minX); ++ box.putFloat("minY", minY); ++ box.putFloat("minZ", minZ); ++ box.putFloat("maxX", maxX); ++ box.putFloat("maxY", maxY); ++ box.putFloat("maxZ", maxZ); ++ return box; ++ } ++ ++ @Test ++ void retainsFlatCollisionBoundsUntilNativeVolumeQueries() { + final ResourcePackStorage storage = new ResourcePackStorage(List.of(pack(new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB)))); + for (int axis = 0; axis < 3; axis++) { -+ for (boolean rotated : new boolean[]{false, true}) { -+ final CompoundTag components = components("minecraft:geometry.full_block", "ruby", "opaque"); -+ final CompoundTag shape = new CompoundTag(); -+ shape.put("origin", new ListTag<>(List.of(new FloatTag(-8), new FloatTag(0), new FloatTag(-8)))); -+ shape.put("size", new ListTag<>(List.of(new FloatTag(axis == 0 ? 0 : 16), new FloatTag(axis == 1 ? 0 : 16), new FloatTag(axis == 2 ? 0 : 16)))); -+ components.put("minecraft:collision_box", shape); -+ components.put("minecraft:selection_box", shape.copy()); -+ if (rotated) { -+ final CompoundTag transform = new CompoundTag(); -+ transform.put("rotation", new ListTag<>(List.of(new FloatTag(45), new FloatTag(45), new FloatTag(0)))); -+ components.put("minecraft:transformation", transform); -+ } -+ final var visual = CustomBlockModel.compile(storage, "test:block", 42, components); -+ assertTrue(visual.collisionBoxes().isEmpty()); -+ assertTrue(visual.selectionBoxes().isEmpty()); -+ assertFalse(JsonParser.parseString(visual.modelJson()).getAsJsonObject().getAsJsonArray("elements").isEmpty()); -+ } ++ final CompoundTag components = components("geometry.test", "ruby", "opaque"); ++ final CompoundTag shape = new CompoundTag(); ++ shape.putByte("enabled", (byte) 1); ++ shape.put("origin", new ListTag<>(List.of(new FloatTag(-8), new FloatTag(0), new FloatTag(-8)))); ++ shape.put("size", new ListTag<>(List.of(new FloatTag(axis == 0 ? 0 : 16), new FloatTag(axis == 1 ? 0 : 16), new FloatTag(axis == 2 ? 0 : 16)))); ++ final CompoundTag collision = new CompoundTag(); ++ collision.putByte("enabled", (byte) 1); ++ collision.put("boxes", new ListTag<>(List.of(collisionBox(0, 0, 0, ++ axis == 0 ? 0 : 16, axis == 1 ? 0 : 16, axis == 2 ? 0 : 16)))); ++ components.put("minecraft:collision_box", collision); ++ components.put("minecraft:selection_box", shape); ++ final var visual = CustomBlockModel.compile(storage, "test:block", 42, components); ++ assertFalse(visual.collisionBoxes().isEmpty()); ++ assertTrue(CustomBlockProperties.of(visual).collisionBoxesAt(0, 0, 0).isEmpty()); ++ assertFalse(visual.selectionBoxes().isEmpty()); ++ assertTrue(CustomBlockProperties.of(visual).selectionBoxesAt(0, 0, 0).isEmpty()); ++ assertFalse(JsonParser.parseString(visual.modelJson()).getAsJsonObject().getAsJsonArray("elements").isEmpty()); + } + } + + @Test ++ void reflectsAndTransformsNativeSelectionCoordinates() { ++ final CompoundTag selection = nativeSelection(); ++ assertEquals(List.of(new CustomBlockVisual.Box(0.125, 0.0625, 0.25, 0.375, 0.5625, 0.875)), ++ selectionVisual(selection, null).selectionBoxes()); ++ final CompoundTag transform = unitScale(); ++ transform.putInt("RY", 1); ++ assertEquals(List.of(new CustomBlockVisual.Box(0.24999997F, 0.0625F, 0.625F, 0.8749999F, 0.5625F, 0.875F)), ++ selectionVisual(selection, transform).selectionBoxes()); ++ } ++ ++ @Test ++ void clampsSelectionHeightToOneBlockAndPreservesFlatBounds() { ++ final CompoundTag selection = nativeSelection(); ++ selection.put("origin", new ListTag<>(List.of(new FloatTag(-8), new FloatTag(16), new FloatTag(-8)))); ++ selection.put("size", new ListTag<>(List.of(new FloatTag(16), new FloatTag(16), new FloatTag(16)))); ++ final var visual = selectionVisual(selection, null); ++ assertEquals(List.of(new CustomBlockVisual.Box(0, 1, 0, 1, 1, 1)), visual.selectionBoxes()); ++ assertTrue(CustomBlockProperties.of(visual).selectionBoxesAt(0, 64, 0).isEmpty()); ++ } ++ ++ @Test ++ void defaultsShortAndNonFloatSelectionVectorsToZero() { ++ final CompoundTag selection = nativeSelection(); ++ selection.put("origin", new ListTag<>(List.of(new FloatTag(2)))); ++ selection.put("size", new ListTag<>(List.of(new FloatTag(4)))); ++ assertEquals(List.of(new CustomBlockVisual.Box(0.125, 0, 0.5, 0.375, 0, 0.5)), ++ selectionVisual(selection, null).selectionBoxes()); ++ selection.put("origin", new ListTag<>(List.of(new IntTag(2), new IntTag(1), new IntTag(-4)))); ++ selection.put("size", new ListTag<>(List.of(new IntTag(4), new IntTag(8), new IntTag(10)))); ++ assertEquals(List.of(new CustomBlockVisual.Box(0.5, 0, 0.5, 0.5, 0, 0.5)), ++ selectionVisual(selection, null).selectionBoxes()); ++ } ++ ++ @Test ++ void requiresByteEnabledAndBothSelectionVectors() { ++ final CompoundTag selection = nativeSelection(); ++ selection.putByte("enabled", (byte) 0); ++ assertTrue(selectionVisual(selection, null).selectionBoxes().isEmpty()); ++ selection.putInt("enabled", 1); ++ assertTrue(selectionVisual(selection, null).selectionBoxes().isEmpty()); ++ selection.remove("enabled"); ++ assertTrue(selectionVisual(selection, null).selectionBoxes().isEmpty()); ++ selection.putByte("enabled", (byte) 1); ++ selection.remove("origin"); ++ assertTrue(selectionVisual(selection, null).selectionBoxes().isEmpty()); ++ } ++ ++ private static CompoundTag nativeSelection() { ++ final CompoundTag selection = new CompoundTag(); ++ selection.putByte("enabled", (byte) 1); ++ selection.put("origin", new ListTag<>(List.of(new FloatTag(2), new FloatTag(1), new FloatTag(-4)))); ++ selection.put("size", new ListTag<>(List.of(new FloatTag(4), new FloatTag(8), new FloatTag(10)))); ++ return selection; ++ } ++ ++ private static CustomBlockVisual selectionVisual(final CompoundTag selection, final CompoundTag transform) { ++ final ResourcePackStorage storage = new ResourcePackStorage(List.of(pack(new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB)))); ++ final CompoundTag components = components("geometry.test", "ruby", "opaque"); ++ components.put("minecraft:selection_box", selection); ++ if (transform != null) { ++ components.put("minecraft:transformation", transform); ++ } ++ return CustomBlockModel.compile(storage, "test:block", 42, components); ++ } ++ ++ @Test ++ void preservesLegacyBoundsWithoutPhysicalComponent() { ++ final ResourcePackStorage storage = new ResourcePackStorage(List.of(pack(new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB)))); ++ final CompoundTag components = components("geometry.test", "ruby", "opaque"); ++ components.put("minecraft:transformation", new CompoundTag()); ++ final var visual = CustomBlockModel.compile(storage, "test:block", 42, components); ++ final var unit = List.of(new CustomBlockVisual.Box(0, 0, 0, 1, 1, 1)); ++ assertEquals(unit, visual.collisionBoxes()); ++ assertEquals(unit, visual.selectionBoxes()); ++ } ++ ++ @Test ++ void requiresIntegerQuarterTurnsForPhysicalBlockRotation() { ++ final CompoundTag transform = unitScale(); ++ transform.putInt("RX", 1); ++ assertEquals(new CustomBlockVisual.Box(0.125F, 0.12500003F, 0.0625F, 0.375F, 0.75F, 0.5625F), ++ collisionVisual(transform).collisionBoxes().getFirst()); ++ transform.putFloat("RX", 0.5F); ++ final var unchanged = new CustomBlockVisual.Box(0.125F, 0.0625F, 0.25F, 0.375F, 0.5625F, 0.875F); ++ assertEquals(unchanged, collisionVisual(transform).collisionBoxes().getFirst()); ++ transform.putInt("RX", -1); ++ assertEquals(unchanged, collisionVisual(transform).collisionBoxes().getFirst()); ++ } ++ ++ @Test ++ void usesZeroForMissingAndNonFloatPhysicalScale() { ++ final CompoundTag transform = unitScale(); ++ transform.remove("SX"); ++ assertTrue(CustomBlockProperties.of(collisionVisual(transform)).collisionBoxesAt(0, 64, 0).isEmpty()); ++ transform.putInt("SX", 1); ++ assertTrue(CustomBlockProperties.of(collisionVisual(transform)).collisionBoxesAt(0, 64, 0).isEmpty()); ++ } ++ ++ @Test ++ void preservesQuaternionAndPairedProjectionRoundingInPhysicalBounds() { ++ final CompoundTag transform = unitScale(); ++ transform.putInt("RY", 1); ++ assertEquals(new CustomBlockVisual.Box(0.24999997F, 0.0625F, 0.625F, 0.8749999F, 0.5625F, 0.875F), ++ collisionVisual(transform).collisionBoxes().getFirst()); ++ transform.putInt("RX", 1); ++ transform.putInt("RY", 2); ++ assertEquals(new CustomBlockVisual.Box(0.625F, 0.12500003F, 0.4375F, 0.875F, 0.75F, 0.9375F), ++ collisionVisual(transform).collisionBoxes().getFirst()); ++ } ++ ++ @Test ++ void appliesScalePivotsAndTranslationBeforeWorldFloatRounding() { ++ final CompoundTag transform = unitScale(); ++ transform.putFloat("TX", 0.1F); ++ transform.putFloat("TY", 0.2F); ++ transform.putFloat("TZ", 0.3F); ++ transform.putFloat("SX", 0.5F); ++ transform.putFloat("SY", 2F); ++ transform.putFloat("SZ", -1F); ++ transform.putFloat("SXP", 0.3F); ++ transform.putFloat("SYP", 0.25F); ++ transform.putFloat("SZP", -0.5F); ++ final var properties = CustomBlockProperties.of(collisionVisual(transform)); ++ assertEquals(List.of(new CustomBlockVisual.Box(0.5625F, 63.575F, -0.575F, 0.6875F, 64.575F, 0.050000012F)), ++ properties.cameraBoxesAt(0, 64, 0)); ++ } ++ ++ private static CompoundTag unitScale() { ++ final CompoundTag transform = new CompoundTag(); ++ transform.putFloat("SX", 1F); ++ transform.putFloat("SY", 1F); ++ transform.putFloat("SZ", 1F); ++ return transform; ++ } ++ ++ private static CustomBlockVisual collisionVisual(final CompoundTag transform) { ++ final ResourcePackStorage storage = new ResourcePackStorage(List.of(pack(new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB)))); ++ final CompoundTag components = components("geometry.test", "ruby", "opaque"); ++ final CompoundTag collision = new CompoundTag(); ++ collision.putByte("enabled", (byte) 1); ++ collision.put("boxes", new ListTag<>(List.of(collisionBox(2, 1, 4, 6, 9, 14)))); ++ components.put("minecraft:collision_box", collision); ++ components.put("minecraft:transformation", transform); ++ return CustomBlockModel.compile(storage, "test:block", 42, components); ++ } ++ ++ @Test + void preservesLegacyFullCubeBottomOrientationAndPhysicalProperties() { + final ResourcePackStorage storage = new ResourcePackStorage(List.of(pack(new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB)))); + final CustomBlockVisual modern = CustomBlockModel.compile(storage, "test:block", 1, @@ -9992,9 +11291,10 @@ index 00000000..a12d1f9d + box.putFloat("maxZ", 16); + final ListTag boxes = new ListTag<>(CompoundTag.class); + boxes.add(box); ++ collision.putByte("enabled", (byte) 1); + collision.put("boxes", boxes); + components.put("minecraft:collision_box", collision); -+ final CompoundTag transform = new CompoundTag(); ++ final CompoundTag transform = unitScale(); + transform.putInt("RY", 1); + components.put("minecraft:transformation", transform); + final CustomBlockVisual visual = CustomBlockModel.compile(storage, "test:bench[facing=east]", 123, components); @@ -10006,8 +11306,8 @@ index 00000000..a12d1f9d + final var bounds = visual.collisionBoxes().get(0); + assertEquals(0, bounds.minX(), 1E-8); + assertEquals(1, bounds.maxX(), 1E-8); -+ assertEquals(0, bounds.minZ(), 1E-8); -+ assertEquals(0.5, bounds.maxZ(), 1E-8); ++ assertEquals(0.5, bounds.minZ(), 1E-8); ++ assertEquals(1, bounds.maxZ(), 1E-8); + assertEquals(0.5, bounds.maxY(), 1E-8); + assertFalse(visual.occluding()); + assertParticleTexture(model); @@ -10403,6 +11703,157 @@ index 00000000..dfcb667a + } + +} +diff --git a/src/test/java/net/raphimc/viabedrock/protocol/rewriter/NativeBlockGeometryRegistrationTest.java b/src/test/java/net/raphimc/viabedrock/protocol/rewriter/NativeBlockGeometryRegistrationTest.java +new file mode 100644 +index 00000000..d763dcee +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/protocol/rewriter/NativeBlockGeometryRegistrationTest.java +@@ -0,0 +1,145 @@ ++/* ++ * 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.protocol.rewriter; ++ ++import com.google.common.collect.HashBiMap; ++import com.viaversion.nbt.tag.CompoundTag; ++import com.viaversion.nbt.tag.ListTag; ++import com.viaversion.viaversion.libs.fastutil.ints.Int2ObjectOpenHashMap; ++import net.raphimc.viabedrock.ViaBedrock; ++import net.raphimc.viabedrock.api.model.BedrockBlockState; ++import net.raphimc.viabedrock.api.util.BlockStateHasher; ++import net.raphimc.viabedrock.api.model.BlockState; ++import net.raphimc.viabedrock.api.model.CustomBlockVisual; ++import net.raphimc.viabedrock.api.resourcepack.definition.BlockDefinitions; ++import net.raphimc.viabedrock.platform.ViaBedrockPlatform; ++import net.raphimc.viabedrock.protocol.BedrockProtocol; ++import net.raphimc.viabedrock.protocol.model.BlockProperties; ++import net.raphimc.viabedrock.protocol.storage.ResourcePackStorage; ++import org.junit.jupiter.api.Test; ++ ++import java.lang.reflect.Field; ++import java.util.LinkedHashMap; ++import java.util.Map; ++import java.util.Set; ++import java.util.logging.Logger; ++import static org.junit.jupiter.api.Assertions.*; ++import static org.mockito.Mockito.*; ++ ++class NativeBlockGeometryRegistrationTest { ++ ++ @Test ++ void retainsBoundsWhenVisualsAreUnavailableAndLeavesRecognizedClassesUnknown() throws Exception { ++ final var saved = new LinkedHashMap(); ++ final var air = BlockState.fromString("minecraft:air"); ++ final var update = BlockState.fromString("minecraft:info_update"); ++ final var stone = nativeState("minecraft:stone"); ++ final var lectern = nativeState("minecraft:lectern"); ++ final var poppy = nativeState("minecraft:poppy"); ++ final var fence = nativeState("minecraft:oak_fence"); ++ final var lower = nativeState("minecraft:cut_copper_slab", "bottom"); ++ final var upper = nativeState("minecraft:waxed_oxidized_cut_copper_slab", "top"); ++ final var doubled = nativeState("minecraft:waxed_weathered_double_cut_copper_slab", "top"); ++ final var javaStates = HashBiMap.create(); ++ javaStates.put(air, 0); ++ javaStates.put(update, 1); ++ javaStates.put(BlockState.fromString("minecraft:note_block[instrument=harp,note=1,powered=false]"), 2); ++ final Map values = Map.of("bedrockBlockStates", Set.of(nativeState("minecraft:air"), nativeState("minecraft:info_update"), lower, upper, doubled, stone, lectern, poppy, fence), ++ "dataDrivenBedrockBlocks", Set.of(), "javaBlockStates", javaStates, ++ "bedrockToJavaBlockStates", Map.of(BedrockBlockState.AIR, air, BedrockBlockState.INFO_UPDATE, update, lower, update, upper, update, doubled, update, stone, update, lectern, update, poppy, update, fence, update), ++ "bedrockCustomBlockTags", Map.of(), "bedrockLegacyBlocks", HashBiMap.create(), ++ "bedrockLegacyBlockStates", new Int2ObjectOpenHashMap()); ++ try (var via = mockStatic(ViaBedrock.class)) { ++ for (final var entry : values.entrySet()) { ++ final Field field = BedrockProtocol.MAPPINGS.getClass().getDeclaredField(entry.getKey()); ++ field.setAccessible(true); ++ saved.put(field, field.get(BedrockProtocol.MAPPINGS)); ++ field.set(BedrockProtocol.MAPPINGS, entry.getValue()); ++ } ++ final var platform = mock(ViaBedrockPlatform.class); ++ when(platform.getLogger()).thenReturn(Logger.getAnonymousLogger()); ++ via.when(ViaBedrock::getPlatform).thenReturn(platform); ++ for (final boolean accepted : new boolean[]{false, true}) { ++ final var storage = mock(ResourcePackStorage.class); ++ when(storage.isLoadedOnJavaClient()).thenReturn(accepted); ++ when(storage.getBlocks()).thenReturn(mock(BlockDefinitions.class)); ++ final var rewriter = new BlockStateRewriter(new BlockProperties[]{definition("probe:shape", "probe:no_match"), ++ definition("probe:slab", "slab_block")}, true, storage); ++ final var physical = rewriter.blockGeometry(rewriter.bedrockId(BlockState.fromString("probe:shape"))); ++ assertNotNull(physical); ++ assertEquals(new CustomBlockVisual.Box(-16.875, -63.9375, 93.25, -16.625, -63.4375, 93.875), ++ physical.primaryBoundsAt(-17, -64, 93)); ++ assertNull(rewriter.blockGeometry(rewriter.bedrockId(BlockState.fromString("probe:slab")))); ++ assertTrue(rewriter.blockGeometry(rewriter.bedrockId(BedrockBlockState.AIR)).collisionBoxes().isEmpty()); ++ assertNull(rewriter.blockGeometry(Integer.MAX_VALUE)); ++ assertNotNull(rewriter.blockGeometry(rewriter.bedrockId(stone))); ++ assertEquals(new CustomBlockVisual.Box(-17, -64, 93, -16, -63, 94), ++ rewriter.blockGeometry(rewriter.bedrockId(stone)).primaryBoundsAt(-17, -64, 93)); ++ assertEquals(0.9F, rewriter.blockGeometry(rewriter.bedrockId(lectern)).primaryBoundsAt(0, 0, 0).maxY()); ++ assertTrue(rewriter.blockGeometry(rewriter.bedrockId(poppy)).collisionBoxes().isEmpty()); ++ assertNull(rewriter.blockGeometry(rewriter.bedrockId(fence))); ++ assertEquals(new CustomBlockVisual.Box(-17, -64, 93, -16, -63.5, 94), ++ rewriter.blockGeometry(rewriter.bedrockId(lower)).primaryBoundsAt(-17, -64, 93)); ++ assertEquals(new CustomBlockVisual.Box(-17, -63.5, 93, -16, -63, 94), ++ rewriter.blockGeometry(rewriter.bedrockId(upper)).primaryBoundsAt(-17, -64, 93)); ++ assertEquals(new CustomBlockVisual.Box(-17, -64, 93, -16, -63, 94), ++ rewriter.blockGeometry(rewriter.bedrockId(doubled)).primaryBoundsAt(-17, -64, 93)); ++ if (accepted) { ++ verify(storage).setCustomBlockVisuals(Map.of()); ++ } else { ++ verify(storage, never()).setCustomBlockVisuals(anyMap()); ++ } ++ } ++ } finally { ++ for (final var entry : saved.entrySet()) { ++ entry.getKey().set(BedrockProtocol.MAPPINGS, entry.getValue()); ++ } ++ } ++ } ++ ++ private static BedrockBlockState nativeState(final String name) { ++ return nativeState(name, null); ++ } ++ ++ private static BedrockBlockState nativeState(final String name, final String half) { ++ final var tag = new CompoundTag(); ++ tag.putString("name", name); ++ final var states = new CompoundTag(); ++ if (half != null) { ++ states.putString("minecraft:vertical_half", half); ++ } ++ tag.put("states", states); ++ tag.putInt("network_id", BlockStateHasher.hash(tag)); ++ return BedrockBlockState.fromNbt(tag); ++ } ++ ++ private static BlockProperties definition(final String name, final String archetype) { ++ final var vanilla = new CompoundTag(); ++ vanilla.putInt("block_id", 60000); ++ final var type = new CompoundTag(); ++ type.putString("type", archetype); ++ vanilla.put("block_archetype", type); ++ final var box = new CompoundTag(); ++ final String[] keys = {"minX", "minY", "minZ", "maxX", "maxY", "maxZ"}; ++ final float[] values = {2, 1, 4, 6, 9, 14}; ++ for (int index = 0; index < keys.length; index++) { ++ box.putFloat(keys[index], values[index]); ++ } ++ final var boxes = new ListTag(CompoundTag.class); ++ boxes.add(box); ++ final var collision = new CompoundTag(); ++ collision.putByte("enabled", (byte) 1); ++ collision.put("boxes", boxes); ++ final var components = new CompoundTag(); ++ components.put("minecraft:collision_box", collision); ++ final var properties = new CompoundTag(); ++ properties.put("vanilla_block_data", vanilla); ++ properties.put("menu_category", new CompoundTag()); ++ properties.put("components", components); ++ return new BlockProperties(name, properties); ++ } ++ ++} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/rewriter/resourcepack/CustomBlockCullingCompilerTest.java b/src/test/java/net/raphimc/viabedrock/protocol/rewriter/resourcepack/CustomBlockCullingCompilerTest.java new file mode 100644 index 00000000..0cc309b7 diff --git a/patches/viabedrock/upstreamable/0054-render-native-block-state-geometry-and-materials.pr.md b/patches/viabedrock/upstreamable/0054-render-native-block-state-geometry-and-materials.pr.md index bf7a1794..3a9c63f9 100644 --- a/patches/viabedrock/upstreamable/0054-render-native-block-state-geometry-and-materials.pr.md +++ b/patches/viabedrock/upstreamable/0054-render-native-block-state-geometry-and-materials.pr.md @@ -8,6 +8,8 @@ Resolve custom block properties and ordered permutations after StartGame, includ Resolve ordered permutations and preserve original native block identities. Assign deterministic Java carriers only for representable states. Keep physical properties, culling, lighting, and supplemental-pack admission with their authoritative owner. +Resolve physical collision separately from visual compilation and Java pack acceptance. Native base-factory registrations retain collision planes, transforms, random offsets, and default friction. Known native classes keep their own providers. Missing physical data remains distinct from an empty shape. + ## Validation and remaining limits Regression coverage includes `BlockItemDescriptorsTest`, `BlockItemStatesTest`, `BlockRegistryNamesTest`, `RuntimeBlockPaletteTest`, and `VanillaBlockStatesTest`. @@ -33,4 +35,33 @@ Core, add-on, and proxy packages retain the original NBT bytes and its license. Shape, state, culling, and conversion tests cover supported definitions. Carrier capacity, unsupported materials, and complete native appearance remain limits. +Camera bounds combine local component boxes before translating them with native float arithmetic. Retain flat bounds through metadata and cache identity; camera queries discard volumes that collapse after world translation. + +Collision components require the native Byte enabled flag and Float coordinates. Missing or differently typed values decode to false and zero. Endpoints clamp to 16 pixels horizontally and 24 vertically; an enabled empty list supplies a full block. + +Selection components require a Byte enabled flag and Float origin/size lists. Clamp all axes to one block, reflect X using the native rounded center and half extent, and retain flat bounds before transformation. Missing list entries decode to zero; malformed descriptions without both lists do not gain a full-block selection shape. + +Physical transformations require integer quarter turns and Float scale, translation, and pivot fields. Preserve the native missing-field defaults, quaternion reconstruction, matrix products, and paired AABB sums. Keep this path separate from model vertices. A missing physical component retains its Legacy fallback bounds. + +Compiled converter and metadata controls match original 1.26.60.5 decoding, typed component application, Block finalization, and primary bounds. They include all quarter-turn combinations and mixed scales, pivots, and translations. Component storage, tag IDs, empty property maps, unrelated cache values, and libc boundaries remain supplied inputs. Installed software-rendered BDS controls cover the transformed collision and selection metadata on direct and ViaProxy routes, including resource reload and dimension return. Full native world publication, other shape providers, native picking and movement trajectories, and visual transformation parity remain open. + +The independent physical decoder and version-7 dictionary match nine original native description chains and all 36 primary-bound queries. +Registration regressions retain geometry through pack refusal and unavailable visuals while leaving recognized nonbase classes unresolved. +Installed direct and ViaProxy controls preserve the same bounds without visual definitions across state changes, resource reload, and dimension return. + +Hardcoded primary geometry covers 1,051 registered types: 203 empty providers, 710 fixed providers, and 138 slab providers. +Fixed providers use their finalized collision component before the Legacy fallback. This preserves the lectern's 0.9-block height and the rounded chest and egg bounds. +Empty providers ignore collision and offset components. Slabs retain their decoded half behavior. +Other classes retain their existing fallback. Physical metadata remains independent of visual models and pack acceptance. + +Original static initialization, main registration, and state enumeration produce all 1,382 built-in types and 22,543 states. +All ordinary state hashes match the pinned palette. The `unknown` entry still differs from the pinned fallback ID and remains outside ID-parity claims. +Corrected shared constants, finalized caches, and original primary/friction queries establish the fixed and empty profiles. +The observed feature bit affects two unsupported classes and leaves these providers unchanged. + +The compiled provider matches 34,530 original bounds queries across 8,495 states after metadata encoding and decoding. +Installed direct and ViaProxy controls cover all 18 fixed profiles, representative empty states, an inherited slab, and an unresolved fence. +These controls retain the bounds through swaps, resource reload, and dimension return. Full core, add-on, and proxy builds pass. +Other shape providers, loaded native world publication, full camera trajectories, and mounted movement parity remain unverified. + The [owning patch](0054-render-native-block-state-geometry-and-materials.patch) retains implementation rationale and versioned evidence. diff --git a/patches/viabedrock/upstreamable/0057-forward-java-book-edits-to-bedrock.patch b/patches/viabedrock/upstreamable/0057-forward-java-book-edits-to-bedrock.patch index 75d59305..7a8bd195 100644 --- a/patches/viabedrock/upstreamable/0057-forward-java-book-edits-to-bedrock.patch +++ b/patches/viabedrock/upstreamable/0057-forward-java-book-edits-to-bedrock.patch @@ -1,4 +1,4 @@ -From 0cd404c924d2b22da64fa70d4795a2aadf0ecaf5 Mon Sep 17 00:00:00 2001 +From b7e2cc4324ef178f8770a59ddecd7086cb8aabce 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] fix(books): forward Java book edits to Bedrock @@ -12,9 +12,9 @@ book succeeds but right-clicking never opens the reader. Packet tests cover selected hotbar slots and verify that reading does not mutate items. --- .../protocol/packet/InteractionPackets.java | 9 ++ - .../protocol/packet/InventoryPackets.java | 85 ++++++++++++++ - .../packet/WrittenBookUsePacketTest.java | 110 ++++++++++++++++++ - 3 files changed, 204 insertions(+) + .../protocol/packet/InventoryPackets.java | 85 +++++++++++++ + .../packet/WrittenBookUsePacketTest.java | 112 ++++++++++++++++++ + 3 files changed, 206 insertions(+) create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/packet/WrittenBookUsePacketTest.java diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/InteractionPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/InteractionPackets.java @@ -156,10 +156,10 @@ index 9be115b5..1136f9f4 100644 for (String line : BedrockTextUtils.split(text, "\n")) { diff --git a/src/test/java/net/raphimc/viabedrock/protocol/packet/WrittenBookUsePacketTest.java b/src/test/java/net/raphimc/viabedrock/protocol/packet/WrittenBookUsePacketTest.java new file mode 100644 -index 00000000..e9f3965d +index 00000000..cd84f43d --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/protocol/packet/WrittenBookUsePacketTest.java -@@ -0,0 +1,110 @@ +@@ -0,0 +1,112 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -183,6 +183,7 @@ index 00000000..e9f3965d +import net.raphimc.viabedrock.protocol.rewriter.ItemRewriter; +import net.raphimc.viabedrock.protocol.storage.EntityTracker; +import net.raphimc.viabedrock.protocol.storage.InventoryTracker; ++import net.raphimc.viabedrock.protocol.storage.PlayerInputStorage; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + @@ -209,6 +210,7 @@ index 00000000..e9f3965d + when(container.getSelectedHotbarSlot()).thenReturn((byte) slot); + when(container.getSelectedHotbarItem()).thenReturn(held); + stores.put(InventoryTracker.class, inventory); ++ stores.put(PlayerInputStorage.class, new PlayerInputStorage(user)); + stores.put(EntityTracker.class, mock(EntityTracker.class)); + stores.put(InventoryTransactionRewriter.class, mock(InventoryTransactionRewriter.class)); + final ItemRewriter items = mock(ItemRewriter.class); diff --git a/patches/viabedrock/upstreamable/0064-evaluate-named-actor-properties.patch b/patches/viabedrock/upstreamable/0064-evaluate-named-actor-properties.patch index cf157b94..7b5e8306 100644 --- a/patches/viabedrock/upstreamable/0064-evaluate-named-actor-properties.patch +++ b/patches/viabedrock/upstreamable/0064-evaluate-named-actor-properties.patch @@ -1,4 +1,4 @@ -From 65270a670c7eb36fbbe343fe3ce5b8a3c265fda6 Mon Sep 17 00:00:00 2001 +From fb6df91983f54f5050998acec0f56ffb90515d5c 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(entities): evaluate named actor properties and @@ -357,6 +357,12 @@ changes, including pure white at index zero. The matching color-mask shader uses texture alpha to select tint strength, unlike ordinary cutout shading. Transport supplies raw inputs, not frontend dye colors; shader and visible rendering acceptance remain separate work. + +Original 1.26.60.5 float metadata construction and admission retain +finite negative Scale (38). The complete native size updater clamps the +scaled product before its upper-bound check, including negative overflow. +Keep these physical dimensions available to collision, camera and +portrait consumers. Preserve unavailable base-dimension handling. --- .../api/model/NativeActorBoundsMessage.java | 75 ++++ .../api/model/NativeActorIntegerQueries.java | 36 ++ @@ -372,7 +378,7 @@ shader and visible rendering acceptance remain separate work. .../protocol/packet/WorldPackets.java | 1 + .../protocol/storage/EntityTracker.java | 25 ++ .../model/NativeActorBoundsMessageTest.java | 82 ++++ - .../api/model/NativeActorEntityTest.java | 118 ++++++ + .../api/model/NativeActorEntityTest.java | 141 +++++++ .../model/NativeActorIntegerQueriesTest.java | 30 ++ .../api/model/NativeActorMessageTest.java | 371 ++++++++++++++++++ .../api/model/NativeActorStatesTest.java | 162 ++++++++ @@ -383,7 +389,7 @@ shader and visible rendering acceptance remain separate work. .../CustomEntityModelSelectionTest.java | 15 +- .../api/model/entity/EntityColorsTest.java | 36 ++ .../api/model/entity/EntityScaleTest.java | 178 +++++++++ - 25 files changed, 2560 insertions(+), 15 deletions(-) + 25 files changed, 2583 insertions(+), 15 deletions(-) create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeActorBoundsMessage.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeActorIntegerQueries.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeActorMessage.java @@ -405,7 +411,7 @@ shader and visible rendering acceptance remain separate work. diff --git a/src/main/java/net/raphimc/viabedrock/api/model/NativeActorBoundsMessage.java b/src/main/java/net/raphimc/viabedrock/api/model/NativeActorBoundsMessage.java new file mode 100644 -index 00000000..17de51b8 +index 00000000..1b12ea63 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/model/NativeActorBoundsMessage.java @@ -0,0 +1,75 @@ @@ -473,14 +479,14 @@ index 00000000..17de51b8 + + /** Retain raw metadata in transport; only physical bounds apply scale and the native collapsed-box clamp. */ + public static float scaledDimension(final Float base, final float scale) { -+ if (base == null || !Float.isFinite(base) || base < 0 || !Float.isFinite(scale) || scale < 0) { ++ if (base == null || !Float.isFinite(base) || base < 0 || !Float.isFinite(scale)) { + throw new IllegalArgumentException("Native actor dimension is unavailable"); + } -+ final float value = base * scale; ++ final float value = Math.max(.005F, base * scale); + if (!Float.isFinite(value) || value >= 1E9F) { + throw new IllegalArgumentException("Invalid native actor dimension"); + } -+ return Math.max(.005F, value); ++ return value; + } + +} @@ -1885,10 +1891,10 @@ index 00000000..46619f01 +} diff --git a/src/test/java/net/raphimc/viabedrock/api/model/NativeActorEntityTest.java b/src/test/java/net/raphimc/viabedrock/api/model/NativeActorEntityTest.java new file mode 100644 -index 00000000..4029172e +index 00000000..f9aa3812 --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/api/model/NativeActorEntityTest.java -@@ -0,0 +1,118 @@ +@@ -0,0 +1,141 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -1905,6 +1911,7 @@ index 00000000..4029172e +import org.junit.jupiter.api.Test; + +import java.util.Map; ++import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; @@ -1970,6 +1977,28 @@ index 00000000..4029172e + } + + @Test ++ void negativeScaleCollapsesBoundsWithoutDiscardingMetadata() { ++ final Entity actor = new Entity(null, 1, 2, "minecraft:boat", 3, UUID.randomUUID(), EntityTypes26_3.OAK_BOAT); ++ actor.entityData().put(ActorDataIds.RESERVED_053, new EntityData(53, EntityDataTypesBedrock.FLOAT, 1.4F)); ++ actor.entityData().put(ActorDataIds.RESERVED_054, new EntityData(54, EntityDataTypesBedrock.FLOAT, .455F)); ++ for (float scale : new float[]{-.5F, -2F, -Float.MAX_VALUE, -0F}) { ++ actor.entityData().put(ActorDataIds.RESERVED_038, new EntityData(38, EntityDataTypesBedrock.FLOAT, scale)); ++ final var snapshot = actor.nativeActorSnapshot(); ++ final var bounds = actor.nativeActorBounds(); ++ assertEquals(scale, snapshot.scale()); ++ assertEquals(1.4F, bounds.width()); ++ assertEquals(.455F, bounds.height()); ++ assertEquals(Optional.of(.005F), actor.boundingBoxDimensions().width()); ++ assertEquals(Optional.of(.005F), actor.boundingBoxDimensions().height()); ++ assertEquals(.005F, NativeActorBoundsMessage.scaledDimension(bounds.width(), snapshot.scale())); ++ assertEquals(.005F, NativeActorBoundsMessage.scaledDimension(bounds.height(), snapshot.scale())); ++ } ++ actor.entityData().put(ActorDataIds.RESERVED_038, new EntityData(38, EntityDataTypesBedrock.FLOAT, 2F)); ++ assertEquals(Optional.of(2.8F), actor.boundingBoxDimensions().width()); ++ assertEquals(Optional.of(.91F), actor.boundingBoxDimensions().height()); ++ } ++ ++ @Test + void snapshotsSparseRetainedInputsAndKeepsTheActorLifetimeStable() { + final UUID uuid = UUID.randomUUID(); + final Entity actor = new Entity(null, 1, 2, "minecraft:pig", 3, uuid, EntityTypes26_3.PIG); diff --git a/patches/viabedrock/upstreamable/0064-evaluate-named-actor-properties.pr.md b/patches/viabedrock/upstreamable/0064-evaluate-named-actor-properties.pr.md index 82affb3c..e4a9c31d 100644 --- a/patches/viabedrock/upstreamable/0064-evaluate-named-actor-properties.pr.md +++ b/patches/viabedrock/upstreamable/0064-evaluate-named-actor-properties.pr.md @@ -17,6 +17,14 @@ Prerequisites: Regression coverage includes `NativeActorBoundsMessageTest`, `NativeActorEntityTest`, `NativeActorIntegerQueriesTest`, `NativeActorMessageTest`. +Physical dimension projection retains finite negative scale and clamps the scaled result to 0.005 blocks before checking the upper bound. +Metadata projection and negotiated consumers share this calculation. Raw scale and base dimensions remain available in snapshots. + +Original 1.26.60.5 controls execute typed float metadata construction, Scale (38) admission, component storage, size updates and boat offset initialization. +They include negative multiplication overflow, zero scale, ordinary positive scale and the initialization guard. +The compiled bridge matches 24 active controls across 216 dimension, bounds and origin fields, with exact bounds-codec round trips. +The metadata-to-bounds regression fails before the fix. Complete native world construction, initialization dispatch and mounted trajectories remain unverified. + Targeted graph and transport tests cover known inputs. Unavailable queries, unsupported controller behavior, and complete native visuals remain explicit gaps. The [owning patch](0064-evaluate-named-actor-properties.patch) retains implementation rationale and versioned evidence. diff --git a/patches/viabedrock/upstreamable/0072-convert-native-sidebar-title-images-and-styled-scores.patch b/patches/viabedrock/upstreamable/0072-convert-native-sidebar-title-images-and-styled-scores.patch index 68631c1a..b55a2152 100644 --- a/patches/viabedrock/upstreamable/0072-convert-native-sidebar-title-images-and-styled-scores.patch +++ b/patches/viabedrock/upstreamable/0072-convert-native-sidebar-title-images-and-styled-scores.patch @@ -1,4 +1,4 @@ -From a177eea5706536c4bc0360875a52accbce7fe663 Mon Sep 17 00:00:00 2001 +From 84d1537af848b9d4b4e0b063e332d4cd890acd41 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(scoreboard): convert native sidebar title images and @@ -862,7 +862,7 @@ index 129790fd..8f4de419 100644 out.append(style.asString()); } diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java -index c717576d..910f6420 100644 +index ae020452..35ab3921 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java @@ -17,6 +17,7 @@ diff --git a/patches/viabedrock/upstreamable/0078-preserve-native-identifiers-in-java-item-data.patch b/patches/viabedrock/upstreamable/0078-preserve-native-identifiers-in-java-item-data.patch index 795c5d65..7d15d594 100644 --- a/patches/viabedrock/upstreamable/0078-preserve-native-identifiers-in-java-item-data.patch +++ b/patches/viabedrock/upstreamable/0078-preserve-native-identifiers-in-java-item-data.patch @@ -1,4 +1,4 @@ -From bbf6cfc33593cb4622a705fba82f343910b54332 Mon Sep 17 00:00:00 2001 +From 402d9b83e604184cd66670a78e0c739ef3afcd25 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(items): preserve native item context in Java data @@ -71,26 +71,51 @@ change later recipe or creative-catalog matching of the same Item. A private runtime probe confirms that the old converter changes all three controlled no-Block Items while displaying stripped oak wood, oak logs and stone. The probe uses only ephemeral objects in an owned test proxy. + +Carry native air-continuation predicates independently of Java carriers. +The 1.26.60.5 executable (SHA-256 +988f6a61403b4381aa41e6c66d64cce441b14e561a9b10f647c2758ef3f2672a) +checks active item use or a retained nonempty stack with Item virtual +predicates at a0/a8 after writing its 200 ms air deadline. The ordinary +air callback result alone does not select this mode. Thirty-two scoped +instruction controls and six original Ender Pearl vtable controls cover +that distinction. ComponentItem predicates14483cc60/14483cd40 check +minecraft:throwable and minecraft:legacy_trigger_on_use presence. +Legacy constructor bindings establish egg, pearl, eye, experience bottle, +trident, splash potion and lingering potion predicates. Preserve explicit +false metadata, and keep constructor replacement separate from component +installation. Conditional Education item predicates remain unverified. + +Snowball constructor1435856a0 installs projectile and throwable components +before packet declarations. Its native1.26.60.5 factory binds the Snowball +name at14474b0d6 and invokes that constructor at14474b0f1. A captured +protocol2225 registry supplies version None and no throwable component for +Snowball, unlike Wind Charge. Keep this built-in default separate from +generic data-driven replacement of the same name. --- .../api/model/NativeItemListTypes.java | 131 ++++++++++++ .../api/model/NativeItemStackData.java | 110 ++++++++++ .../protocol/model/BedrockItem.java | 19 +- + .../protocol/model/ItemAirUseProperties.java | 52 +++++ .../protocol/model/ItemAnimationData.java | 88 ++++++++ .../protocol/model/ItemUseDuration.java | 25 +++ - .../protocol/rewriter/ItemDataRewriter.java | 26 +++ - .../protocol/rewriter/ItemRewriter.java | 19 +- + .../protocol/rewriter/ItemDataRewriter.java | 33 +++ + .../protocol/rewriter/ItemRewriter.java | 23 +- .../protocol/types/item/BedrockItemType.java | 2 +- .../item/NetworkItemStackDescriptorType.java | 2 +- .../api/model/NativeItemStackDataTest.java | 197 ++++++++++++++++++ + .../model/ItemAirUsePropertiesTest.java | 34 +++ .../protocol/model/ItemAnimationDataTest.java | 90 ++++++++ .../protocol/model/ItemUseDurationTest.java | 82 ++++++++ - .../protocol/rewriter/ItemIdentityTest.java | 58 ++++++ - 13 files changed, 837 insertions(+), 12 deletions(-) + .../protocol/rewriter/ItemIdentityTest.java | 69 ++++++ + 15 files changed, 945 insertions(+), 12 deletions(-) create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeItemListTypes.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeItemStackData.java + create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/model/ItemAirUseProperties.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/model/ItemAnimationData.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/model/ItemUseDuration.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/NativeItemStackDataTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/model/ItemAirUsePropertiesTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/model/ItemAnimationDataTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/model/ItemUseDurationTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/rewriter/ItemIdentityTest.java @@ -411,6 +436,64 @@ index 77d33f59..64ceb8da 100644 } @Override +diff --git a/src/main/java/net/raphimc/viabedrock/protocol/model/ItemAirUseProperties.java b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemAirUseProperties.java +new file mode 100644 +index 00000000..45d70afc +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemAirUseProperties.java +@@ -0,0 +1,52 @@ ++/* ++ * 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.protocol.model; ++ ++import com.viaversion.nbt.tag.CompoundTag; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ItemVersion; ++ ++import java.util.Set; ++ ++/** Native item predicates that retain air-only input after an admitted use. */ ++public record ItemAirUseProperties(boolean throwableItem, boolean useableItem) { ++ ++ public static final ItemAirUseProperties NONE = new ItemAirUseProperties(false, false); ++ private static final Set LEGACY_THROWABLES = Set.of( ++ "minecraft:egg", "minecraft:ender_pearl", "minecraft:ender_eye", "minecraft:experience_bottle", ++ "minecraft:trident", "minecraft:splash_potion", "minecraft:lingering_potion"); ++ ++ public static ItemAirUseProperties legacyConstructor(final String identifier) { ++ return LEGACY_THROWABLES.contains(identifier) ? new ItemAirUseProperties(true, false) : NONE; ++ } ++ ++ /** Some built-in ComponentItem subclasses install components in their constructors. */ ++ public static ItemAirUseProperties builtinConstructor(final String identifier) { ++ return "minecraft:snowball".equals(identifier) ? new ItemAirUseProperties(true, false) : legacyConstructor(identifier); ++ } ++ ++ public static ItemAirUseProperties fromRegistration(final ItemEntry entry) { ++ final boolean component = entry.version() == ItemVersion.DataDriven ++ || entry.version() == ItemVersion.None && entry.componentBased(); ++ final CompoundTag data = entry.componentData(); ++ final CompoundTag components = data != null && data.get("components") instanceof CompoundTag value ? value : new CompoundTag(); ++ final ItemAirUseProperties initial = entry.version() == ItemVersion.None ? builtinConstructor(entry.identifier()) ++ : component ? NONE : legacyConstructor(entry.identifier()); ++ return initial.withComponentData(components, component); ++ } ++ ++ public ItemAirUseProperties withComponentData(final CompoundTag components, final boolean component) { ++ if (!component) { ++ return this; ++ } ++ return new ItemAirUseProperties(this.throwableItem || components.get("minecraft:throwable") instanceof CompoundTag, ++ this.useableItem || components.get("minecraft:legacy_trigger_on_use") instanceof CompoundTag); ++ } ++ ++ public boolean retainsAirUse() { ++ return this.throwableItem || this.useableItem; ++ } ++ ++} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/model/ItemAnimationData.java b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemAnimationData.java new file mode 100644 index 00000000..acdb84c5 @@ -537,7 +620,7 @@ index 00000000..060368e6 + +} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemDataRewriter.java b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemDataRewriter.java -index 4fbf0a35..4b76624a 100644 +index 4fbf0a35..c9c0893c 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemDataRewriter.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemDataRewriter.java @@ -47,6 +47,8 @@ import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.Enchant_Type @@ -549,7 +632,7 @@ index 4fbf0a35..4b76624a 100644 import java.util.ArrayList; import java.util.HashMap; -@@ -58,6 +60,11 @@ import java.util.logging.Level; +@@ -58,6 +60,12 @@ import java.util.logging.Level; public final class ItemDataRewriter { @@ -557,18 +640,25 @@ index 4fbf0a35..4b76624a 100644 + public static final String BEDROCK_IDENTIFIER_TAG = "viabedrock:item_identifier"; + public static final String BEDROCK_ANIMATION_TAG = "viabedrock:item_animation"; + public static final String BEDROCK_USE_DURATION_TAG = "viabedrock:item_use_duration"; ++ public static final String BEDROCK_AIR_USE_TAG = "viabedrock:item_air_use"; + private static final Set DYED_LEATHER_ARMOR_ITEMS = Set.of( "minecraft:leather_helmet", "minecraft:leather_chestplate", "minecraft:leather_leggings", "minecraft:leather_boots"); -@@ -332,6 +339,25 @@ public final class ItemDataRewriter { +@@ -332,6 +340,31 @@ public final class ItemDataRewriter { } } + /** Retain native identity and animation context across Java mappings and proxy connections. */ + static CompoundTag withBedrockItemData(final CompoundTag javaData, final String identifier, final ItemAnimationData animation, final NativeItemStackData nativeStack) { ++ return withBedrockItemData(javaData, identifier, animation, nativeStack, false); ++ } ++ ++ static CompoundTag withBedrockItemData(final CompoundTag javaData, final String identifier, final ItemAnimationData animation, ++ final NativeItemStackData nativeStack, final boolean retainsAirUse) { + final CompoundTag data = javaData == null ? new CompoundTag() : javaData.copy(); + data.putString(BEDROCK_IDENTIFIER_TAG, identifier); ++ data.putBoolean(BEDROCK_AIR_USE_TAG, retainsAirUse); + data.put(BEDROCK_STACK_TAG, nativeStack.toTag()); + if (animation.useDurationTicks() != null) { + data.putInt(BEDROCK_USE_DURATION_TAG, animation.useDurationTicks()); @@ -588,7 +678,7 @@ index 4fbf0a35..4b76624a 100644 } 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 9010fec4..52524c02 100644 +index 9010fec4..2e530ec9 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemRewriter.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemRewriter.java @@ -39,6 +39,7 @@ import com.viaversion.viaversion.libs.fastutil.ints.IntSortedSet; @@ -599,24 +689,31 @@ index 9010fec4..52524c02 100644 import net.raphimc.viabedrock.api.resourcepack.definition.ItemDefinitions; import net.raphimc.viabedrock.api.util.TextUtil; import net.raphimc.viabedrock.protocol.BedrockProtocol; -@@ -49,6 +50,7 @@ import net.raphimc.viabedrock.protocol.data.generated.bedrock.CustomItemTags; +@@ -49,6 +50,8 @@ import net.raphimc.viabedrock.protocol.data.generated.bedrock.CustomItemTags; import net.raphimc.viabedrock.protocol.model.BedrockItem; import net.raphimc.viabedrock.protocol.model.ItemEntry; import net.raphimc.viabedrock.protocol.model.ItemCooldown; +import net.raphimc.viabedrock.protocol.model.ItemAnimationData; ++import net.raphimc.viabedrock.protocol.model.ItemAirUseProperties; import net.raphimc.viabedrock.protocol.rewriter.item.BundleItemRewriter; import net.raphimc.viabedrock.protocol.rewriter.resourcepack.CustomAttachableResourceRewriter; import net.raphimc.viabedrock.protocol.rewriter.resourcepack.CustomItemTextureResourceRewriter; -@@ -67,6 +69,8 @@ import java.util.logging.Level; +@@ -67,6 +70,9 @@ import java.util.logging.Level; public class ItemRewriter extends StoredObject { + private final Map animationData = new HashMap<>(); ++ private final Map airUseProperties = new HashMap<>(); + private final Map cooldowns = new HashMap<>(Map.of( "minecraft:ender_pearl", new ItemCooldown("ender_pearl", 1F), "minecraft:goat_horn", new ItemCooldown("goat_horn", 7F), -@@ -99,6 +103,7 @@ public class ItemRewriter extends StoredObject { +@@ -95,10 +101,12 @@ public class ItemRewriter extends StoredObject { + this.items = HashBiMap.create(itemEntries.length); + this.componentItems = new HashSet<>(); + for (ItemEntry itemEntry : itemEntries) { ++ this.airUseProperties.put(itemEntry.identifier(), ItemAirUseProperties.fromRegistration(itemEntry)); + this.items.inverse().remove(itemEntry.id()); this.items.put(itemEntry.identifier(), itemEntry.id()); if (itemEntry.componentData() != null && itemEntry.componentData().get("components") instanceof CompoundTag components) { @@ -624,7 +721,7 @@ index 9010fec4..52524c02 100644 final ItemCooldown cooldown = ItemCooldown.fromNetworkComponents(components); if (cooldown != null) { this.cooldowns.put(itemEntry.identifier(), cooldown); -@@ -147,14 +152,19 @@ public class ItemRewriter extends StoredObject { +@@ -147,14 +155,19 @@ public class ItemRewriter extends StoredObject { return StructuredItem.empty(); } @@ -647,13 +744,14 @@ index 9010fec4..52524c02 100644 } else { final int meta = bedrockItem.data() & 0xFFFF; final String newIdentifier = BedrockProtocol.MAPPINGS.getBedrockItemUpgrader().upgradeMetaItem(identifier, meta); -@@ -254,6 +264,9 @@ public class ItemRewriter extends StoredObject { +@@ -254,6 +267,10 @@ public class ItemRewriter extends StoredObject { } CustomAttachableResourceRewriter.applyItemModel(this.user().get(ResourcePackStorage.class), identifier, javaItem); + javaItem.dataContainer().set(StructuredDataKey.CUSTOM_DATA, + ItemDataRewriter.withBedrockItemData(javaItem.dataContainer().get(StructuredDataKey.CUSTOM_DATA), -+ identifier, this.animationData.getOrDefault(identifier, ItemAnimationData.EMPTY), nativeStack)); ++ identifier, this.animationData.getOrDefault(identifier, ItemAnimationData.EMPTY), nativeStack, ++ this.airUseProperties.getOrDefault(identifier, ItemAirUseProperties.NONE).retainsAirUse())); return javaItem; } @@ -886,6 +984,46 @@ index 00000000..48d0e424 + } + +} +diff --git a/src/test/java/net/raphimc/viabedrock/protocol/model/ItemAirUsePropertiesTest.java b/src/test/java/net/raphimc/viabedrock/protocol/model/ItemAirUsePropertiesTest.java +new file mode 100644 +index 00000000..fccf55b2 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/protocol/model/ItemAirUsePropertiesTest.java +@@ -0,0 +1,34 @@ ++package net.raphimc.viabedrock.protocol.model; ++ ++import com.viaversion.nbt.tag.CompoundTag; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ItemVersion; ++import org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++final class ItemAirUsePropertiesTest { ++ ++ @Test ++ void dataDrivenReplacementDoesNotInheritTheLegacyPearlPredicate() { ++ final var legacy = new ItemEntry("minecraft:ender_pearl", 1, false, ItemVersion.Legacy, null); ++ final var component = new ItemEntry("minecraft:ender_pearl", 1, true, ItemVersion.DataDriven, null); ++ assertTrue(ItemAirUseProperties.fromRegistration(legacy).retainsAirUse()); ++ assertFalse(ItemAirUseProperties.fromRegistration(component).retainsAirUse()); ++ } ++ ++ @Test ++ void installedAirComponentsSurviveOmittedUpdatesAndIgnoreProjectileRenderingAlone() { ++ final CompoundTag projectile = new CompoundTag(); ++ projectile.put("minecraft:projectile", new CompoundTag()); ++ assertFalse(ItemAirUseProperties.NONE.withComponentData(projectile, true).retainsAirUse()); ++ final CompoundTag components = new CompoundTag(); ++ components.put("minecraft:throwable", new CompoundTag()); ++ final var throwable = ItemAirUseProperties.NONE.withComponentData(components, true); ++ assertTrue(throwable.withComponentData(new CompoundTag(), true).retainsAirUse()); ++ assertFalse(ItemAirUseProperties.NONE.withComponentData(components, false).retainsAirUse()); ++ final CompoundTag trigger = new CompoundTag(); ++ trigger.put("minecraft:legacy_trigger_on_use", new CompoundTag()); ++ assertTrue(ItemAirUseProperties.NONE.withComponentData(trigger, true).retainsAirUse()); ++ } ++ ++} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/model/ItemAnimationDataTest.java b/src/test/java/net/raphimc/viabedrock/protocol/model/ItemAnimationDataTest.java new file mode 100644 index 00000000..0e65ce98 @@ -1072,10 +1210,10 @@ index 00000000..8390e3fe +} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/rewriter/ItemIdentityTest.java b/src/test/java/net/raphimc/viabedrock/protocol/rewriter/ItemIdentityTest.java new file mode 100644 -index 00000000..8717fa89 +index 00000000..adbffe8e --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/protocol/rewriter/ItemIdentityTest.java -@@ -0,0 +1,58 @@ +@@ -0,0 +1,69 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -1123,6 +1261,17 @@ index 00000000..8717fa89 + } + + @Test ++ void replacesAirContinuationMetadataWithoutMutatingThePreviousStack() { ++ final CompoundTag projectile = ItemDataRewriter.withBedrockItemData(null, "probe:shared_carrier", ++ ItemAnimationData.EMPTY, STACK, true); ++ final CompoundTag ordinary = ItemDataRewriter.withBedrockItemData(projectile, "probe:shared_carrier", ++ ItemAnimationData.EMPTY, STACK, false); ++ assertTrue(projectile.getBoolean(ItemDataRewriter.BEDROCK_AIR_USE_TAG)); ++ assertFalse(ordinary.getBoolean(ItemDataRewriter.BEDROCK_AIR_USE_TAG)); ++ assertTrue(ordinary.contains(ItemDataRewriter.BEDROCK_AIR_USE_TAG)); ++ } ++ ++ @Test + void keepsDifferentNativeAliasesDistinctDespiteTheirSharedJavaMapping() { + final String first = "minecraft:apple"; + final String second = "probe:apple"; diff --git a/patches/viabedrock/upstreamable/0078-preserve-native-identifiers-in-java-item-data.pr.md b/patches/viabedrock/upstreamable/0078-preserve-native-identifiers-in-java-item-data.pr.md index 5082f2c3..041d0234 100644 --- a/patches/viabedrock/upstreamable/0078-preserve-native-identifiers-in-java-item-data.pr.md +++ b/patches/viabedrock/upstreamable/0078-preserve-native-identifiers-in-java-item-data.pr.md @@ -8,9 +8,11 @@ Retain the original Bedrock identifier after Java item translation so client int Keep original native identifiers, descriptors, and component ownership alongside Java mappings. Preserve unrelated custom data and distinguish aliases that share a Java item. +Carry explicit native air-continuation metadata alongside the item identity. Component presence and built-in subclass defaults differ from Java carrier types. Preserve an explicit false value when replacing an earlier stack's context. + ## Validation and remaining limits -Regression coverage includes `NativeItemStackDataTest`, `ItemAnimationDataTest`, `ItemUseDurationTest`, `ItemIdentityTest`. +Regression coverage includes `NativeItemStackDataTest`, `ItemAnimationDataTest`, `ItemUseDurationTest`, `ItemIdentityTest`, and `ItemAirUsePropertiesTest`. Construction, merge, naming, and descriptor regressions cover the implemented boundaries. Arbitrary custom definitions and complete native recipe admission remain open. diff --git a/patches/viabedrock/upstreamable/0083-synchronize-native-armor-attributes.patch b/patches/viabedrock/upstreamable/0083-synchronize-native-armor-attributes.patch index 83979f91..99e5d2e3 100644 --- a/patches/viabedrock/upstreamable/0083-synchronize-native-armor-attributes.patch +++ b/patches/viabedrock/upstreamable/0083-synchronize-native-armor-attributes.patch @@ -1,4 +1,4 @@ -From f665b24c69bad4fbe35f435daf40f979de7fbe1a Mon Sep 17 00:00:00 2001 +From 246a82dcf76721f314c1dfb25f85704c308c16d3 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(equipment): synchronize native armor attributes @@ -244,26 +244,26 @@ index ef4ab158..150be112 100644 final PacketWrapper initializeBorder = PacketWrapper.create(ClientboundPackets26_3.INITIALIZE_BORDER, wrapper.user()); 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 52524c02..3bb3fff4 100644 +index 2e530ec9..006250a2 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemRewriter.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemRewriter.java -@@ -51,6 +51,7 @@ import net.raphimc.viabedrock.protocol.model.BedrockItem; - import net.raphimc.viabedrock.protocol.model.ItemEntry; +@@ -52,6 +52,7 @@ import net.raphimc.viabedrock.protocol.model.ItemEntry; import net.raphimc.viabedrock.protocol.model.ItemCooldown; import net.raphimc.viabedrock.protocol.model.ItemAnimationData; + import net.raphimc.viabedrock.protocol.model.ItemAirUseProperties; +import net.raphimc.viabedrock.protocol.model.ItemArmorData; import net.raphimc.viabedrock.protocol.rewriter.item.BundleItemRewriter; import net.raphimc.viabedrock.protocol.rewriter.resourcepack.CustomAttachableResourceRewriter; import net.raphimc.viabedrock.protocol.rewriter.resourcepack.CustomItemTextureResourceRewriter; -@@ -70,6 +71,7 @@ import java.util.logging.Level; - public class ItemRewriter extends StoredObject { +@@ -72,6 +73,7 @@ public class ItemRewriter extends StoredObject { private final Map animationData = new HashMap<>(); + private final Map airUseProperties = new HashMap<>(); + private final Map armorData = new HashMap<>(); private final Map cooldowns = new HashMap<>(Map.of( "minecraft:ender_pearl", new ItemCooldown("ender_pearl", 1F), -@@ -93,6 +95,14 @@ public class ItemRewriter extends StoredObject { +@@ -95,6 +97,14 @@ public class ItemRewriter extends StoredObject { ITEM_NBT_REWRITERS.put(CustomItemTags.BUNDLE, new BundleItemRewriter()); } @@ -278,7 +278,7 @@ index 52524c02..3bb3fff4 100644 public ItemRewriter(final UserConnection user, final ItemEntry[] itemEntries) { super(user); -@@ -104,6 +114,7 @@ public class ItemRewriter extends StoredObject { +@@ -107,6 +117,7 @@ public class ItemRewriter extends StoredObject { if (itemEntry.componentData() != null && itemEntry.componentData().get("components") instanceof CompoundTag components) { this.animationData.put(itemEntry.identifier(), ItemAnimationData.fromNetworkComponents(components)); diff --git a/patches/viabedrock/upstreamable/0086-transport-native-actor-animation-commands.patch b/patches/viabedrock/upstreamable/0086-transport-native-actor-animation-commands.patch index 8c81fb9d..99e11b4d 100644 --- a/patches/viabedrock/upstreamable/0086-transport-native-actor-animation-commands.patch +++ b/patches/viabedrock/upstreamable/0086-transport-native-actor-animation-commands.patch @@ -1,4 +1,4 @@ -From 07d384e0f4afec9484626980ec1007eeb1d616ec Mon Sep 17 00:00:00 2001 +From 0c393241641102c8bf9579501cf0eba4bf96b213 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(animations): decode and transport native actor commands @@ -353,7 +353,7 @@ index 00000000..e47da430 + +} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java -index dcd49bf6..c6ea78df 100644 +index 4103bd32..a88292f9 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java @@ -18,6 +18,7 @@ @@ -373,7 +373,7 @@ index dcd49bf6..c6ea78df 100644 import net.raphimc.viabedrock.api.model.entity.ClientPlayerEntity; import net.raphimc.viabedrock.api.model.entity.CustomEntity; import net.raphimc.viabedrock.api.model.entity.Entity; -@@ -58,6 +61,7 @@ import net.raphimc.viabedrock.protocol.data.generated.java.RegistryKeys; +@@ -57,6 +60,7 @@ import net.raphimc.viabedrock.protocol.data.generated.java.RegistryKeys; import net.raphimc.viabedrock.protocol.model.*; import net.raphimc.viabedrock.protocol.rewriter.ItemRewriter; import net.raphimc.viabedrock.protocol.storage.EntityPropertyTracker; @@ -381,7 +381,7 @@ index dcd49bf6..c6ea78df 100644 import net.raphimc.viabedrock.protocol.storage.EntityTracker; import net.raphimc.viabedrock.protocol.storage.GameSessionStorage; import net.raphimc.viabedrock.protocol.storage.ResourcePackStorage; -@@ -78,7 +82,43 @@ public final class EntityPackets { +@@ -77,7 +81,43 @@ public final class EntityPackets { return new Position3f(hasX ? x : current.x(), hasY ? y : current.y(), hasZ ? z : current.z()); } diff --git a/patches/viabedrock/upstreamable/0091-retain-native-held-block-use-sessions.patch b/patches/viabedrock/upstreamable/0091-retain-native-held-block-use-sessions.patch index b71fa9d0..b635cba9 100644 --- a/patches/viabedrock/upstreamable/0091-retain-native-held-block-use-sessions.patch +++ b/patches/viabedrock/upstreamable/0091-retain-native-held-block-use-sessions.patch @@ -1,4 +1,4 @@ -From eb7c9aec7d7bef63fa4a757a3e94c5ce23de41f9 Mon Sep 17 00:00:00 2001 +From 45d52a142a7d88df8b6c4be151ecae003e584c2a Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:34:25 +0100 Subject: [PATCH] feat(interaction): retain native held block-use sessions @@ -55,27 +55,42 @@ its existing channel for older clients, with its prior air default. Withdraw either prediction capability through the existing held owner. Tests cover the legacy wire fixture, the suppressed-air codec, sequence ownership and build success independently from the air decision. + +Pair ordinary item-use consumption prediction with its concluding Java +sequence. Keep one optional report and retire it after that packet or +capability withdrawal. A skipped sequence must not leak its prediction +into a later use. Sequence numbering can restart with a new client level. + +Native Bedrock1.26.60.5 records an inventory action only when local use +changes the stack. Cooldown-refused pearl callbacks dispatch without +a debit or legacy slot declaration. Publish that distinction before the +ordinary Java packet so inventory translation can retain native behavior +without using Java carrier classes or changing air-route ownership. --- .../api/model/BlockUsePrediction.java | 90 +++++++++++++ + .../api/model/ItemUsePrediction.java | 42 ++++++ .../viabedrock/api/model/PlayerBlockUse.java | 35 +++++ .../container/player/InventoryContainer.java | 2 +- .../viabedrock/protocol/BedrockProtocol.java | 1 + .../protocol/packet/ClientPlayerPackets.java | 2 +- .../protocol/packet/InteractionPackets.java | 120 +++++++++++------- - .../protocol/packet/MultiStatePackets.java | 35 +++++ + .../protocol/packet/MultiStatePackets.java | 50 ++++++++ .../protocol/storage/BlockUseStorage.java | 83 ++++++++++++ .../protocol/storage/InventoryTracker.java | 6 + - .../protocol/storage/PlayerInputStorage.java | 4 +- + .../protocol/storage/PlayerInputStorage.java | 20 ++- .../api/model/BlockUsePredictionTest.java | 70 ++++++++++ .../packet/BlockItemUseTransactionTest.java | 96 ++++++++++++++ .../protocol/storage/BlockUseStorageTest.java | 120 ++++++++++++++++++ - 13 files changed, 618 insertions(+), 46 deletions(-) + .../storage/PlayerInputStorageTest.java | 38 ++++++ + 15 files changed, 729 insertions(+), 46 deletions(-) create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/BlockUsePrediction.java + create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/ItemUsePrediction.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/PlayerBlockUse.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/storage/BlockUseStorage.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/BlockUsePredictionTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/packet/BlockItemUseTransactionTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/storage/BlockUseStorageTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/storage/PlayerInputStorageTest.java diff --git a/src/main/java/net/raphimc/viabedrock/api/model/BlockUsePrediction.java b/src/main/java/net/raphimc/viabedrock/api/model/BlockUsePrediction.java new file mode 100644 @@ -173,6 +188,54 @@ index 00000000..8183b1e0 + } + +} +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/ItemUsePrediction.java b/src/main/java/net/raphimc/viabedrock/api/model/ItemUsePrediction.java +new file mode 100644 +index 00000000..e476713c +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/model/ItemUsePrediction.java +@@ -0,0 +1,42 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.model; ++ ++import com.viaversion.viaversion.api.type.Type; ++import com.viaversion.viaversion.api.type.Types; ++import io.netty.buffer.ByteBuf; ++import net.raphimc.viabedrock.protocol.data.ProtocolConstants; ++ ++/** Local stack consumption paired with the concluding ordinary Java item-use packet. */ ++public record ItemUsePrediction(int sequence, boolean consumed) { ++ ++ public static final String CHANNEL = "viabedrock:item_use_prediction"; ++ public static final Type TYPE = new Type<>(ItemUsePrediction.class) { ++ @Override ++ public ItemUsePrediction read(final ByteBuf buffer) { ++ if (Types.VAR_INT.readPrimitive(buffer) != 1 ++ || Types.VAR_INT.readPrimitive(buffer) != ProtocolConstants.BEDROCK_PROTOCOL_VERSION) { ++ throw new IllegalArgumentException("Unsupported item-use prediction"); ++ } ++ return new ItemUsePrediction(Types.VAR_INT.readPrimitive(buffer), buffer.readBoolean()); ++ } ++ ++ @Override ++ public void write(final ByteBuf buffer, final ItemUsePrediction value) { ++ Types.VAR_INT.writePrimitive(buffer, 1); ++ Types.VAR_INT.writePrimitive(buffer, ProtocolConstants.BEDROCK_PROTOCOL_VERSION); ++ Types.VAR_INT.writePrimitive(buffer, value.sequence()); ++ buffer.writeBoolean(value.consumed()); ++ } ++ }; ++ ++ public ItemUsePrediction { ++ if (sequence < 0) { ++ throw new IllegalArgumentException("Negative item-use prediction sequence"); ++ } ++ } ++ ++} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/PlayerBlockUse.java b/src/main/java/net/raphimc/viabedrock/api/model/PlayerBlockUse.java new file mode 100644 index 00000000..fb688e29 @@ -449,20 +512,21 @@ index 0acee785..223db3d9 100644 + } diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java -index 29541609..8210c4cc 100644 +index 29541609..d3ecbb9c 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java -@@ -53,6 +53,9 @@ import net.raphimc.viabedrock.api.model.NativeActorPickingMessage; +@@ -53,6 +53,10 @@ import net.raphimc.viabedrock.api.model.NativeActorPickingMessage; import net.raphimc.viabedrock.api.model.NativeActorAttack; import net.raphimc.viabedrock.api.modinterface.ViaBedrockUtilityInterface; import net.raphimc.viabedrock.api.model.PlayerInput; +import net.raphimc.viabedrock.api.model.PlayerBlockUse; +import net.raphimc.viabedrock.api.model.BlockUsePrediction; ++import net.raphimc.viabedrock.api.model.ItemUsePrediction; +import net.raphimc.viabedrock.protocol.storage.BlockUseStorage; import net.raphimc.viabedrock.api.model.PlayerPrediction; import net.raphimc.viabedrock.api.model.VehiclePrediction; import net.raphimc.viabedrock.api.model.PlayerInputPermissions; -@@ -250,6 +253,11 @@ public final class MultiStatePackets { +@@ -250,6 +254,14 @@ public final class MultiStatePackets { if (channel.equals("minecraft:unregister")) { final List channels = Arrays.asList(new String(wrapper.read(Types.SERVERBOUND_CUSTOM_PAYLOAD_DATA), StandardCharsets.UTF_8).split("\0")); wrapper.user().get(ChannelStorage.class).removeChannels(channels); @@ -470,14 +534,28 @@ index 29541609..8210c4cc 100644 + || channels.contains(BlockUsePrediction.AIR_CHANNEL) + || channels.contains(PlayerInputPermissions.CHANNEL)) { + InteractionPackets.stopBlockUse(wrapper.user()); ++ } ++ if (channels.contains(ItemUsePrediction.CHANNEL) || channels.contains(PlayerInputPermissions.CHANNEL)) { ++ wrapper.user().get(PlayerInputStorage.class).clearItemUsePrediction(); + } if (channels.contains(NativeSkinFragment.CHANNEL) || channels.contains(NativeSkinChange.Result.CHANNEL)) { SkinChangeStorage.refreshCapability(wrapper.user()); } -@@ -300,6 +308,33 @@ public final class MultiStatePackets { +@@ -300,6 +312,44 @@ public final class MultiStatePackets { } return; } ++ if (channel.equals(ItemUsePrediction.CHANNEL)) { ++ if (wrapper.user().getProtocolInfo().getClientState() == State.PLAY ++ && wrapper.user().get(ChannelStorage.class).hasChannel(PlayerInputPermissions.CHANNEL)) { ++ final ItemUsePrediction prediction = wrapper.read(ItemUsePrediction.TYPE); ++ if (wrapper.read(Types.REMAINING_BYTES).length != 0) { ++ throw new IllegalArgumentException("Unexpected item-use prediction fields"); ++ } ++ wrapper.user().get(PlayerInputStorage.class).recordItemUsePrediction(prediction); ++ } ++ return; ++ } + if (channel.equals(BlockUsePrediction.CHANNEL) || channel.equals(BlockUsePrediction.AIR_CHANNEL)) { + if (wrapper.user().getProtocolInfo().getClientState() == State.PLAY + && wrapper.user().get(ChannelStorage.class).hasChannel(PlayerInputPermissions.CHANNEL)) { @@ -622,24 +700,52 @@ index 84f976c9..7472880e 100644 if (containerId == this.inventoryContainer.containerId()) { return this.inventoryContainer; diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/PlayerInputStorage.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/PlayerInputStorage.java -index f8bf20f8..91374668 100644 +index f8bf20f8..4f2019a7 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/PlayerInputStorage.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/PlayerInputStorage.java -@@ -11,6 +11,8 @@ import com.viaversion.viaversion.api.protocol.packet.PacketWrapper; +@@ -11,6 +11,9 @@ import com.viaversion.viaversion.api.protocol.packet.PacketWrapper; import com.viaversion.viaversion.api.type.Types; import com.viaversion.viaversion.protocols.v26_2to26_3.packet.ClientboundPackets26_3; import net.raphimc.viabedrock.api.model.PlayerInput; ++import net.raphimc.viabedrock.api.model.ItemUsePrediction; +import net.raphimc.viabedrock.api.model.PlayerBlockUse; +import net.raphimc.viabedrock.api.model.BlockUsePrediction; import net.raphimc.viabedrock.api.model.PlayerPrediction; import net.raphimc.viabedrock.api.model.VehiclePrediction; import net.raphimc.viabedrock.api.model.VehiclePredictionContext; -@@ -112,7 +114,7 @@ public final class PlayerInputStorage extends StoredObject { +@@ -31,11 +34,26 @@ public final class PlayerInputStorage extends StoredObject { + private PlayerInput rawInput = PlayerInput.NONE; + private int pressed; + private int released; ++ private ItemUsePrediction itemUsePrediction; + + public PlayerInputStorage(final UserConnection user) { + super(user); + } + ++ public void recordItemUsePrediction(final ItemUsePrediction prediction) { ++ this.itemUsePrediction = prediction; ++ } ++ ++ public ItemUsePrediction takeItemUsePrediction(final int sequence) { ++ final ItemUsePrediction prediction = this.itemUsePrediction; ++ this.itemUsePrediction = null; ++ return prediction != null && prediction.sequence() == sequence ? prediction : null; ++ } ++ ++ public void clearItemUsePrediction() { ++ this.itemUsePrediction = null; ++ } ++ + public PlayerInputPermissions permissions() { + return this.permissions; + } +@@ -112,7 +130,7 @@ public final class PlayerInputStorage extends StoredObject { register.write(Types.STRING, "minecraft:register"); final String vehicleChannel = this.user().get(ChannelStorage.class).hasChannel(VehiclePredictionContext.CHANNEL) ? "\0" + VehiclePrediction.CHANNEL : ""; - register.write(Types.REMAINING_BYTES, (PlayerInput.CHANNEL + "\0" + PlayerPrediction.CHANNEL + vehicleChannel).getBytes(StandardCharsets.UTF_8)); -+ register.write(Types.REMAINING_BYTES, (PlayerInput.CHANNEL + "\0" + PlayerPrediction.CHANNEL + "\0" + PlayerBlockUse.CHANNEL + "\0" + BlockUsePrediction.CHANNEL + "\0" + BlockUsePrediction.AIR_CHANNEL + vehicleChannel).getBytes(StandardCharsets.UTF_8)); ++ register.write(Types.REMAINING_BYTES, (PlayerInput.CHANNEL + "\0" + PlayerPrediction.CHANNEL + "\0" + PlayerBlockUse.CHANNEL + "\0" + BlockUsePrediction.CHANNEL + "\0" + BlockUsePrediction.AIR_CHANNEL + "\0" + ItemUsePrediction.CHANNEL + vehicleChannel).getBytes(StandardCharsets.UTF_8)); register.send(BedrockProtocol.class); final PacketWrapper packet = PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, this.user()); packet.write(Types.STRING, PlayerInputPermissions.CHANNEL); @@ -947,5 +1053,49 @@ index 00000000..25cfe501 + } + +} +diff --git a/src/test/java/net/raphimc/viabedrock/protocol/storage/PlayerInputStorageTest.java b/src/test/java/net/raphimc/viabedrock/protocol/storage/PlayerInputStorageTest.java +new file mode 100644 +index 00000000..04fc7a4a +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/protocol/storage/PlayerInputStorageTest.java +@@ -0,0 +1,38 @@ ++package net.raphimc.viabedrock.protocol.storage; ++ ++import com.viaversion.viaversion.api.connection.UserConnection; ++import io.netty.buffer.ByteBuf; ++import io.netty.buffer.Unpooled; ++import net.raphimc.viabedrock.api.model.ItemUsePrediction; ++import org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.assertFalse; ++import static org.junit.jupiter.api.Assertions.assertNull; ++import static org.junit.jupiter.api.Assertions.assertTrue; ++import static org.mockito.Mockito.mock; ++ ++final class PlayerInputStorageTest { ++ ++ @Test ++ void itemPredictionIsConsumedOnceAndCannotLeakBetweenSequences() { ++ final PlayerInputStorage input = new PlayerInputStorage(mock(UserConnection.class)); ++ final ByteBuf payload = Unpooled.buffer(); ++ try { ++ ItemUsePrediction.TYPE.write(payload, new ItemUsePrediction(7, false)); ++ input.recordItemUsePrediction(ItemUsePrediction.TYPE.read(payload)); ++ assertFalse(input.takeItemUsePrediction(7).consumed()); ++ assertNull(input.takeItemUsePrediction(7)); ++ } finally { ++ payload.release(); ++ } ++ input.recordItemUsePrediction(new ItemUsePrediction(8, true)); ++ assertNull(input.takeItemUsePrediction(9)); ++ assertNull(input.takeItemUsePrediction(8)); ++ input.recordItemUsePrediction(new ItemUsePrediction(0, true)); ++ assertTrue(input.takeItemUsePrediction(0).consumed()); ++ input.recordItemUsePrediction(new ItemUsePrediction(1, true)); ++ input.clearItemUsePrediction(); ++ assertNull(input.takeItemUsePrediction(1)); ++ } ++ ++} -- 2.56.0 diff --git a/patches/viabedrock/upstreamable/0091-retain-native-held-block-use-sessions.pr.md b/patches/viabedrock/upstreamable/0091-retain-native-held-block-use-sessions.pr.md index 04b6d974..3c5e4e4f 100644 --- a/patches/viabedrock/upstreamable/0091-retain-native-held-block-use-sessions.pr.md +++ b/patches/viabedrock/upstreamable/0091-retain-native-held-block-use-sessions.pr.md @@ -16,6 +16,8 @@ Start the native session when prediction succeeds. Release, cancellation, and ca A failed local prediction does not start a session or submit a placement delta. Clients without the channels keep independent block uses. +Ordinary item use has a separate optional consumption report, paired with its concluding Java sequence. Core consumes each report once and clears it on a skipped sequence or capability withdrawal. Sequence numbering may restart in a new level. The [air-use prediction owner](../deferred/0016-synchronize-glide-rockets-with-auth-input.pr.md) records native cooldown-refusal evidence, inventory behavior, and installed validation. + Native Survival predicts one consumed block for successful local placement. The first placement sends a use-in-air phase with the remaining stack only when the client admits that phase. A fresh block call can run before the strict 200 ms air deadline expires. diff --git a/patches/viabedrock/upstreamable/0092-retain-completed-client-prediction-frames.patch b/patches/viabedrock/upstreamable/0092-retain-completed-client-prediction-frames.patch index ca9e14da..36615452 100644 --- a/patches/viabedrock/upstreamable/0092-retain-completed-client-prediction-frames.patch +++ b/patches/viabedrock/upstreamable/0092-retain-completed-client-prediction-frames.patch @@ -1,4 +1,4 @@ -From 307da33ed56db6ef7c55d5cd6952fcceaf3ace5f Mon Sep 17 00:00:00 2001 +From fd6f9121f3952eff2f43fe761056e3441a9b7a88 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(movement): retain completed client prediction frames @@ -690,22 +690,86 @@ kernel 0x1484f88e0 tests the swimming flag and MovementEffects slot 1 kind against -1, then writes 2F or 1F. Six bounded original-instruction cases verify the swimming gate, absent entry and zero-duration marker. Keep the existing duration-active query for callers that need duration activity. + +Carry explicit native block friction with the authoritative state dictionary, +independently of Java carrier models or pack acceptance. The version-7 channel +retains unknown defaults separately from present float coefficients. Resolve +registered per-state component overlays before transport. Legacy NBT decoding +accepts only a Float named value, defaults a missing or differently typed field +to zero, and stores 1.0F minus that value without another cache conversion. +Original protocol-2225 description, cache and component-name controls establish +these rules. Verified base-factory registrations use the native coefficient of 0.6 when no +explicit component applies. Other unresolved classes retain an unknown default. +Normal boat tick integration and installed trajectories remain unfinished. + +Bind hardcoded vanilla defaults only for types admitted from the pinned palette. +Exclude server-defined vanilla types and custom registrations from this fallback. +A read-only snapshot of the 1.26.60.5 original live registration map establishes +all 1,480 named Legacy objects and 23,960 finalized Block caches. Names match the +protocol-2225 palette exactly. Each state's cached friction agrees with its +Legacy default: air and unknown 0.9F; blue ice 0.989F; ice, packed ice and frosted +ice 0.98F; slime and honey 0.8F; all other types 0.6F. Every observed byte agrees +in a second pass. Complete live registration, map insertion, constructors, cache +and boat friction functions match the verified reference instructions. The +snapshot establishes coefficients, not primary collision shapes or trajectories. +Do not infer the same default for unknown minecraft-namespace registrations. + +Carry the exact StartGame base-game version text in the native world reset. +Keep it with dimension and block state so replacement worlds and disconnects +cannot retain the previous physics gate. Preserve wildcard, suffix and invalid +text instead of substituting resource selection or client protocol versions. +The version-7 contract rejects old resets that cannot supply this context. + +Retain the exact signed StartGame generator ID alongside its base-game +version text in the version-7 world reset. Keep unknown enum values rather +than replacing them with Undefined. Expose the immutable reset so consumers +read version and generator from one world snapshot. + +Current 1.26.60.5 original StartGame/settings-copy, factory and world-setup +fragments establish the version and generator source fields. LevelData uses +the settings version as its first candidate. Generator zero selects a +1.17.40 second candidate, while other generators select wildcard. The +published typed buoyancy component receives the same effective selection. +All 378 bounded native controls preserve the accepted fields through these +fragments. Unrelated member construction, allocation and dispatch remain +hosting boundaries. Complete wire decoding and world lifecycle are outside +those controls; normal boat ticks remain separate work. + +Publish optional native block geometry with the accepted world dictionary +independently of Java resource-pack acceptance. Version the block channel +with its bounds and offset payload. Preserve unresolved versus verified-empty +states and retain the base-factory friction default when no explicit component +replaces it. + +Share the native post-collision axis cutoff between ordinary movement and +flight. Bedrock 1.26.60 protocol 2225 original friction instructions select +zero at abs(velocity) <= 2^-23 before multiplying friction, preserving +signed zero and leaving ordinary vertical motion to its separate stage. +The packaged native client retains wind-charge drift below Java's 0.003 +threshold. Full original callback controls and a successive small-impulse +sequence establish the boundary; focused regressions retain that coasting +and the existing flight arithmetic. This does not establish liquid, +glide, mount or full trajectory parity. --- build.gradle | 29 + .../api/model/ActorMotionCommand.java | 72 ++ .../api/model/ActorSpatialCommand.java | 80 ++ .../api/model/CollisionContact.java | 80 ++ - .../viabedrock/api/model/FlightMovement.java | 83 ++ + .../viabedrock/api/model/FlightMovement.java | 79 ++ .../api/model/FlightMovementContext.java | 50 + .../viabedrock/api/model/FluidCurrent.java | 57 + .../viabedrock/api/model/GeyserMovement.java | 87 ++ .../viabedrock/api/model/GlideMovement.java | 64 + + .../viabedrock/api/model/MotionFriction.java | 23 + .../api/model/MovementCollisionQuery.java | 181 +++ .../api/model/MovementCollisionWorld.java | 74 ++ .../api/model/MovementCorrectionTiming.java | 56 + .../viabedrock/api/model/MovementEffect.java | 100 ++ .../api/model/MovementEffectTimeline.java | 438 +++++++ .../api/model/NativeActorStates.java | 30 + + .../api/model/NativeBlockFriction.java | 36 + + .../api/model/NativeBlockIdentifiers.java | 86 +- + .../viabedrock/api/model/NativeBlockView.java | 14 +- .../api/model/NativeMovementSources.java | 137 ++ .../api/model/PlayerCorrection.java | 138 ++ .../api/model/PlayerCorrectionTracker.java | 36 + @@ -730,14 +794,17 @@ existing duration-active query for callers that need duration activity. .../protocol/model/ItemUseModifiers.java | 50 + .../protocol/packet/ClientPlayerPackets.java | 147 ++- .../protocol/packet/EntityPackets.java | 63 +- - .../protocol/packet/JoinPackets.java | 2 + + .../protocol/packet/JoinPackets.java | 7 +- .../packet/MovementEffectPackets.java | 54 + .../protocol/packet/MultiStatePackets.java | 53 +- .../protocol/packet/OtherPlayerPackets.java | 6 +- .../protocol/packet/WorldPackets.java | 1 + + .../protocol/rewriter/BlockStateRewriter.java | 18 + .../protocol/rewriter/ItemDataRewriter.java | 7 +- .../protocol/rewriter/ItemRewriter.java | 19 + + .../protocol/storage/ChunkTracker.java | 11 +- .../protocol/storage/EntityTracker.java | 43 + + .../protocol/storage/GameSessionStorage.java | 19 + .../protocol/storage/PlayerInputStorage.java | 11 +- .../protocol/storage/PlayerModeStorage.java | 63 + .../storage/PlayerPredictionStorage.java | 133 ++ @@ -750,6 +817,7 @@ existing duration-active query for callers that need duration activity. .../model/GeyserMovementReferenceTest.java | 50 + .../api/model/GeyserMovementTest.java | 131 ++ .../api/model/GlideMovementTest.java | 115 ++ + .../api/model/MotionFrictionTest.java | 38 + .../api/model/MovementCollisionQueryTest.java | 53 + .../api/model/MovementCollisionWorldTest.java | 54 + .../model/MovementControllerOrderTest.java | 52 + @@ -758,6 +826,9 @@ existing duration-active query for callers that need duration activity. .../api/model/MovementEffectReplayTest.java | 181 +++ .../api/model/MovementEffectTest.java | 127 ++ .../api/model/MovementEffectTimelineTest.java | 273 ++++ + .../api/model/NativeBlockFrictionTest.java | 244 ++++ + .../NativeBlockGeometryTransportTest.java | 84 ++ + .../api/model/NativeBlockViewTest.java | 55 +- .../api/model/NativeMovementSourcesTest.java | 126 ++ .../api/model/PlayerCorrectionTest.java | 101 ++ .../api/model/PlayerModeMessageTest.java | 113 ++ @@ -785,8 +856,9 @@ existing duration-active query for callers that need duration activity. .../packet/PlayerPredictionPacketTest.java | 87 ++ .../packet/PlayerSpatialResetTest.java | 147 +++ .../packet/TickedMotionPacketTest.java | 156 +++ + .../NativeBlockGeometryRegistrationTest.java | 1 + .../protocol/storage/EntityTrackerTest.java | 4 +- - 95 files changed, 9236 insertions(+), 77 deletions(-) + 107 files changed, 9847 insertions(+), 96 deletions(-) create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/ActorMotionCommand.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/ActorSpatialCommand.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/CollisionContact.java @@ -795,11 +867,13 @@ existing duration-active query for callers that need duration activity. create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/FluidCurrent.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/GeyserMovement.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/GlideMovement.java + create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/MotionFriction.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/MovementCollisionQuery.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/MovementCollisionWorld.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/MovementCorrectionTiming.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/MovementEffect.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/MovementEffectTimeline.java + create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeBlockFriction.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeMovementSources.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/PlayerCorrection.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/PlayerCorrectionTracker.java @@ -826,6 +900,7 @@ existing duration-active query for callers that need duration activity. create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/GeyserMovementReferenceTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/GeyserMovementTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/GlideMovementTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/MotionFrictionTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/MovementCollisionQueryTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/MovementCollisionWorldTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/MovementControllerOrderTest.java @@ -834,6 +909,8 @@ existing duration-active query for callers that need duration activity. create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/MovementEffectReplayTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/MovementEffectTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/MovementEffectTimelineTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/NativeBlockFrictionTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/NativeBlockGeometryTransportTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/NativeMovementSourcesTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/PlayerCorrectionTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/PlayerModeMessageTest.java @@ -1155,10 +1232,10 @@ index 00000000..925b0654 +} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/FlightMovement.java b/src/main/java/net/raphimc/viabedrock/api/model/FlightMovement.java new file mode 100644 -index 00000000..1537f689 +index 00000000..46d96c04 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/model/FlightMovement.java -@@ -0,0 +1,83 @@ +@@ -0,0 +1,79 @@ +/* + * This file is part of ViaBedrock - https://github.com/ViaVersionAddons/ViaBedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -1230,17 +1307,13 @@ index 00000000..1537f689 + horizontal = context.legacyFriction() ? (friction * context.frictionModifier()) * 0.91F + : retainedFriction(friction, context.dragModifier()) * retainedFriction(0.91F, context.dragModifier()); + } -+ return new Position3f(cutoff(motion.x(), horizontal), verticalDrag(motion.y(), context.dragModifier()), cutoff(motion.z(), horizontal)); ++ return new Position3f(MotionFriction.apply(motion.x(), horizontal), verticalDrag(motion.y(), context.dragModifier()), MotionFriction.apply(motion.z(), horizontal)); + } + + private static float retainedFriction(final float base, final float modifier) { + return Math.max(0F, 1F - Math.max(0F, (1F - base) * modifier)); + } + -+ private static float cutoff(final float velocity, final float friction) { -+ return (Math.abs(velocity) <= 1.1920929E-7F ? 0F : friction) * velocity; -+ } -+ +} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/FlightMovementContext.java b/src/main/java/net/raphimc/viabedrock/api/model/FlightMovementContext.java new file mode 100644 @@ -1524,6 +1597,35 @@ index 00000000..e3df1f47 + } + +} +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/MotionFriction.java b/src/main/java/net/raphimc/viabedrock/api/model/MotionFriction.java +new file mode 100644 +index 00000000..801b152a +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/model/MotionFriction.java +@@ -0,0 +1,23 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/ViaVersionAddons/ViaBedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.model; ++ ++/** Native post-collision motion cutoff, before the retained friction is applied. */ ++public final class MotionFriction { ++ ++ private MotionFriction() { ++ } ++ ++ public static boolean belowCutoff(final float velocity) { ++ return Math.abs(velocity) <= 0x1.0p-23F; ++ } ++ ++ public static float apply(final float velocity, final float friction) { ++ // Select the multiplier first, retaining the native signed zero and NaN behavior. ++ return (belowCutoff(velocity) ? 0F : friction) * velocity; ++ } ++ ++} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/MovementCollisionQuery.java b/src/main/java/net/raphimc/viabedrock/api/model/MovementCollisionQuery.java new file mode 100644 index 00000000..2f0075e6 @@ -2479,6 +2581,265 @@ index 82d9ed3a..34711a6a 100644 this.tick = 0; this.animations.clear(); } +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/NativeBlockFriction.java b/src/main/java/net/raphimc/viabedrock/api/model/NativeBlockFriction.java +new file mode 100644 +index 00000000..69882704 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/model/NativeBlockFriction.java +@@ -0,0 +1,36 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.model; ++ ++import com.viaversion.nbt.tag.CompoundTag; ++import com.viaversion.nbt.tag.FloatTag; ++ ++/** Retained-motion coefficients read from the native block cache. */ ++public final class NativeBlockFriction { ++ ++ private NativeBlockFriction() { ++ } ++ ++ /** Only for hardcoded types from the pinned vanilla palette, excluding server-defined blocks. */ ++ public static float vanilla(final String identifier) { ++ return switch (identifier) { ++ case "minecraft:air", "minecraft:unknown" -> 0.9F; ++ case "minecraft:blue_ice" -> 0.989F; ++ case "minecraft:ice", "minecraft:packed_ice", "minecraft:frosted_ice" -> 0.98F; ++ case "minecraft:slime", "minecraft:honey_block" -> 0.8F; ++ default -> 0.6F; ++ }; ++ } ++ ++ /** Null leaves unresolved defaults and component forms unknown to the consumer. */ ++ public static Float explicit(final CompoundTag components) { ++ if (components == null || !(components.get("minecraft:friction") instanceof CompoundTag friction)) { ++ return null; ++ } ++ return 1F - (friction.get("value") instanceof FloatTag value ? value.asFloat() : 0F); ++ } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/NativeBlockIdentifiers.java b/src/main/java/net/raphimc/viabedrock/api/model/NativeBlockIdentifiers.java +index 7cc87f22..5ac95e6f 100644 +--- a/src/main/java/net/raphimc/viabedrock/api/model/NativeBlockIdentifiers.java ++++ b/src/main/java/net/raphimc/viabedrock/api/model/NativeBlockIdentifiers.java +@@ -10,30 +10,32 @@ import com.viaversion.viaversion.api.type.Type; + import com.viaversion.viaversion.api.type.Types; + import io.netty.buffer.ByteBuf; + import net.raphimc.viabedrock.protocol.data.ProtocolConstants; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.GeneratorType; + + import java.nio.charset.StandardCharsets; + import java.util.ArrayList; + import java.util.HashMap; + import java.util.List; + import java.util.Map; ++import java.util.Objects; + + /** Ordered authoritative block identity snapshots and changes for native client effects. */ + public sealed interface NativeBlockIdentifiers permits NativeBlockIdentifiers.Reset, NativeBlockIdentifiers.Section, + NativeBlockIdentifiers.Update, NativeBlockIdentifiers.Dictionary, NativeBlockIdentifiers.Forget { + +- String CHANNEL = "viabedrock:block_identifiers_v3"; ++ String CHANNEL = "viabedrock:block_identifiers_v7"; + int MAX_DICTIONARY_BYTES = 256 * 1024; + Type TYPE = new Type<>(NativeBlockIdentifiers.class) { + @Override + public NativeBlockIdentifiers read(final ByteBuf buffer) { +- if (Types.VAR_INT.readPrimitive(buffer) != 3 ++ if (Types.VAR_INT.readPrimitive(buffer) != 7 + || Types.VAR_INT.readPrimitive(buffer) != ProtocolConstants.BEDROCK_PROTOCOL_VERSION) { + throw new IllegalArgumentException("Unsupported native block identity message"); + } + final int kind = buffer.readUnsignedByte(); + final int dimension = Types.VAR_INT.readPrimitive(buffer); + return switch (kind) { +- case 0 -> new Reset(dimension, buffer.readInt(), buffer.readInt(), buffer.readInt()); ++ case 0 -> new Reset(dimension, buffer.readInt(), buffer.readInt(), buffer.readInt(), Types.STRING.read(buffer), buffer.readInt()); + case 1 -> { + final int x = buffer.readInt(); + final int y = buffer.readInt(); +@@ -82,7 +84,23 @@ public sealed interface NativeBlockIdentifiers permits NativeBlockIdentifiers.Re + throw new IllegalArgumentException("Duplicate native block property"); + } + } +- names.add(new Name(state, identifier, properties, fluidJavaId)); ++ final Float friction = buffer.readBoolean() ? buffer.readFloat() : null; ++ final NativeBlockGeometry geometry; ++ if (buffer.readBoolean()) { ++ final int boxes = Types.VAR_INT.readPrimitive(buffer); ++ if (boxes < 0 || boxes > NativeBlockGeometry.MAX_BOXES) { ++ throw new IllegalArgumentException("Invalid native block box count"); ++ } ++ final List bounds = new ArrayList<>(boxes); ++ for (int box = 0; box < boxes; box++) { ++ bounds.add(new CustomBlockVisual.Box(buffer.readFloat(), buffer.readFloat(), buffer.readFloat(), ++ buffer.readFloat(), buffer.readFloat(), buffer.readFloat())); ++ } ++ geometry = new NativeBlockGeometry(bounds, new CustomBlockOffset(readAxis(buffer), readAxis(buffer), readAxis(buffer))); ++ } else { ++ geometry = null; ++ } ++ names.add(new Name(state, identifier, properties, fluidJavaId, friction, geometry)); + if (buffer.readerIndex() - start > MAX_DICTIONARY_BYTES) { + throw new IllegalArgumentException("Native block dictionary exceeds its byte limit"); + } +@@ -95,12 +113,14 @@ public sealed interface NativeBlockIdentifiers permits NativeBlockIdentifiers.Re + + @Override + public void write(final ByteBuf buffer, final NativeBlockIdentifiers value) { +- Types.VAR_INT.writePrimitive(buffer, 3); ++ Types.VAR_INT.writePrimitive(buffer, 7); + Types.VAR_INT.writePrimitive(buffer, ProtocolConstants.BEDROCK_PROTOCOL_VERSION); + buffer.writeByte(value instanceof Reset ? 0 : value instanceof Section ? 1 : value instanceof Update ? 2 : value instanceof Forget ? 3 : 4); + Types.VAR_INT.writePrimitive(buffer, value.dimension()); + if (value instanceof Reset reset) { + buffer.writeInt(reset.minY()).writeInt(reset.height()).writeInt(reset.air()); ++ Types.STRING.write(buffer, reset.baseGameVersion()); ++ buffer.writeInt(reset.generator()); + } else if (value instanceof Section section) { + buffer.writeInt(section.chunkX()).writeInt(section.sectionY()).writeInt(section.chunkZ()); + buffer.writeByte(section.layer()); +@@ -131,6 +151,21 @@ public sealed interface NativeBlockIdentifiers permits NativeBlockIdentifiers.Re + Types.STRING.write(buffer, property.getKey()); + Types.STRING.write(buffer, property.getValue()); + } ++ buffer.writeBoolean(name.friction() != null); ++ if (name.friction() != null) { ++ buffer.writeFloat(name.friction()); ++ } ++ buffer.writeBoolean(name.geometry() != null); ++ if (name.geometry() != null) { ++ Types.VAR_INT.writePrimitive(buffer, name.geometry().collisionBoxes().size()); ++ for (final CustomBlockVisual.Box box : name.geometry().collisionBoxes()) { ++ buffer.writeFloat((float) box.minX()).writeFloat((float) box.minY()).writeFloat((float) box.minZ()); ++ buffer.writeFloat((float) box.maxX()).writeFloat((float) box.maxY()).writeFloat((float) box.maxZ()); ++ } ++ writeAxis(buffer, name.geometry().randomOffset().x()); ++ writeAxis(buffer, name.geometry().randomOffset().y()); ++ writeAxis(buffer, name.geometry().randomOffset().z()); ++ } + } + } + } +@@ -145,6 +180,14 @@ public sealed interface NativeBlockIdentifiers permits NativeBlockIdentifiers.Re + return layer; + } + ++ private static CustomBlockOffset.Axis readAxis(final ByteBuf buffer) { ++ return new CustomBlockOffset.Axis(buffer.readFloat(), buffer.readFloat(), Integer.toUnsignedLong(buffer.readInt())); ++ } ++ ++ private static void writeAxis(final ByteBuf buffer, final CustomBlockOffset.Axis axis) { ++ buffer.writeFloat(axis.min()).writeFloat(axis.max()).writeInt((int) axis.steps()); ++ } ++ + private static String text(final ByteBuf buffer) { + final int bytes = Types.VAR_INT.readPrimitive(buffer); + if (bytes < 0 || bytes > 1024 || bytes > buffer.readableBytes()) { +@@ -170,8 +213,20 @@ public sealed interface NativeBlockIdentifiers permits NativeBlockIdentifiers.Re + return value < 128 ? 1 : 2; + } + +- record Reset(int dimension, int minY, int height, int air) implements NativeBlockIdentifiers { ++ record Reset(int dimension, int minY, int height, int air, String baseGameVersion, int generator) implements NativeBlockIdentifiers { ++ public Reset(final int dimension, final int minY, final int height, final int air) { ++ this(dimension, minY, height, air, "", GeneratorType.Undefined.getValue()); ++ } ++ ++ public Reset(final int dimension, final int minY, final int height, final int air, final String baseGameVersion) { ++ this(dimension, minY, height, air, baseGameVersion, GeneratorType.Undefined.getValue()); ++ } ++ + public Reset { ++ Objects.requireNonNull(baseGameVersion); ++ if (baseGameVersion.length() > 32767) { ++ throw new IllegalArgumentException("Native world version exceeds limits"); ++ } + if ((minY & 15) != 0 || height <= 0 || height > 4096 || (height & 15) != 0 + || (long) minY + height > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Invalid native block world height"); +@@ -182,18 +237,26 @@ public sealed interface NativeBlockIdentifiers permits NativeBlockIdentifiers.Re + record Section(int dimension, int chunkX, int sectionY, int chunkZ, int layer, NativeBlockSection blocks) implements NativeBlockIdentifiers { + public Section { + NativeBlockIdentifiers.layer(layer); +- java.util.Objects.requireNonNull(blocks); ++ Objects.requireNonNull(blocks); + } + } + + record Update(int dimension, BlockPosition position, int layer, int state) implements NativeBlockIdentifiers { + public Update { + NativeBlockIdentifiers.layer(layer); +- java.util.Objects.requireNonNull(position); ++ Objects.requireNonNull(position); + } + } + +- record Name(int state, String identifier, Map properties, int fluidJavaId) { ++ record Name(int state, String identifier, Map properties, int fluidJavaId, Float friction, NativeBlockGeometry geometry) { ++ public Name(final int state, final String identifier, final Map properties, final int fluidJavaId) { ++ this(state, identifier, properties, fluidJavaId, null, null); ++ } ++ ++ public Name(final int state, final String identifier, final Map properties, final int fluidJavaId, final Float friction) { ++ this(state, identifier, properties, fluidJavaId, friction, null); ++ } ++ + public Name { + checkText(identifier); + if (fluidJavaId < -1) { +@@ -210,10 +273,13 @@ public sealed interface NativeBlockIdentifiers permits NativeBlockIdentifiers.Re + } + + public int encodedSize() { +- int size = 8 + textSize(this.identifier) + varIntSize(this.properties.size()); ++ int size = 10 + (this.friction == null ? 0 : 4) + textSize(this.identifier) + varIntSize(this.properties.size()); + for (final var property : this.properties.entrySet()) { + size += textSize(property.getKey()) + textSize(property.getValue()); + } ++ if (this.geometry != null) { ++ size += varIntSize(this.geometry.collisionBoxes().size()) + 24 * this.geometry.collisionBoxes().size() + 36; ++ } + return size; + } + } +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/NativeBlockView.java b/src/main/java/net/raphimc/viabedrock/api/model/NativeBlockView.java +index 621fab75..59cfb7db 100644 +--- a/src/main/java/net/raphimc/viabedrock/api/model/NativeBlockView.java ++++ b/src/main/java/net/raphimc/viabedrock/api/model/NativeBlockView.java +@@ -22,6 +22,18 @@ public final class NativeBlockView { + return this.snapshot != null; + } + ++ /** Immutable world settings from one reset, or null until this world's reset arrives. */ ++ public NativeBlockIdentifiers.Reset world() { ++ final Snapshot current = this.snapshot; ++ return current == null ? null : current.world; ++ } ++ ++ /** Exact StartGame level version, or null until this world's reset arrives. */ ++ public String baseGameVersion() { ++ final var world = this.world(); ++ return world == null ? null : world.baseGameVersion(); ++ } ++ + public synchronized void accept(final NativeBlockIdentifiers message) { + if (message instanceof NativeBlockIdentifiers.Reset reset) { + this.snapshot = new Snapshot(reset); +@@ -139,7 +151,7 @@ public final class NativeBlockView { + + private Snapshot(final NativeBlockIdentifiers.Reset world) { + this.world = world; +- this.air = new NativeBlockIdentifiers.Name(world.air(), "minecraft:air", Map.of(), -1); ++ this.air = new NativeBlockIdentifiers.Name(world.air(), "minecraft:air", Map.of(), -1, 0.9F, NativeBlockGeometry.EMPTY); + } + } + diff --git a/src/main/java/net/raphimc/viabedrock/api/model/NativeMovementSources.java b/src/main/java/net/raphimc/viabedrock/api/model/NativeMovementSources.java new file mode 100644 index 00000000..76bf5d59 @@ -4690,7 +5051,7 @@ index 21d20d88..ceabfddc 100644 + } diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java -index c6ea78df..423c0c09 100644 +index a88292f9..1d7c6c07 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java @@ -37,6 +37,8 @@ import com.viaversion.viaversion.protocols.v26_2to26_3.packet.ClientboundPackets @@ -4702,7 +5063,7 @@ index c6ea78df..423c0c09 100644 import net.raphimc.viabedrock.api.model.NativeAnimationMessage; import net.raphimc.viabedrock.api.model.entity.ClientPlayerEntity; import net.raphimc.viabedrock.api.model.entity.CustomEntity; -@@ -63,6 +65,8 @@ import net.raphimc.viabedrock.protocol.rewriter.ItemRewriter; +@@ -62,6 +64,8 @@ import net.raphimc.viabedrock.protocol.rewriter.ItemRewriter; import net.raphimc.viabedrock.protocol.storage.EntityPropertyTracker; import net.raphimc.viabedrock.protocol.storage.ChannelStorage; import net.raphimc.viabedrock.protocol.storage.EntityTracker; @@ -4711,7 +5072,7 @@ index c6ea78df..423c0c09 100644 import net.raphimc.viabedrock.protocol.storage.GameSessionStorage; import net.raphimc.viabedrock.protocol.storage.ResourcePackStorage; import net.raphimc.viabedrock.protocol.types.BedrockTypes; -@@ -251,11 +255,13 @@ public final class EntityPackets { +@@ -250,11 +254,13 @@ public final class EntityPackets { entity.retainSpatialUpdate(position, new Position3f(pitch, yaw, headYaw), flags, 0); @@ -4729,7 +5090,7 @@ index c6ea78df..423c0c09 100644 entity.setPosition(position); if (teleported) { -@@ -276,6 +282,7 @@ public final class EntityPackets { +@@ -275,6 +281,7 @@ public final class EntityPackets { return; } @@ -4737,7 +5098,7 @@ index c6ea78df..423c0c09 100644 entity.setPosition(position); entity.setRotation(new Position3f(pitch, yaw, headYaw)); entity.setOnGround(onGround); -@@ -332,11 +339,13 @@ public final class EntityPackets { +@@ -331,11 +338,13 @@ public final class EntityPackets { | (forceMoveLocalEntity ? 4 : 0) | (forceCompletion ? 8 : 0)); entity.retainSpatialUpdate(position, rotation, flags, interpolationTicks); @@ -4755,7 +5116,7 @@ index c6ea78df..423c0c09 100644 entity.setPosition(position); if (teleported) { wrapper.setPacketType(ClientboundPackets26_3.PLAYER_POSITION); -@@ -356,6 +365,7 @@ public final class EntityPackets { +@@ -355,6 +364,7 @@ public final class EntityPackets { return; } @@ -4763,7 +5124,7 @@ index c6ea78df..423c0c09 100644 entity.setPosition(position); entity.setRotation(rotation); if (hasHeadYaw) { -@@ -377,8 +387,8 @@ public final class EntityPackets { +@@ -376,8 +386,8 @@ public final class EntityPackets { protocol.registerClientbound(ClientboundBedrockPackets.SET_ENTITY_MOTION, ClientboundPackets26_3.SET_ENTITY_MOTION, wrapper -> { final long entityRuntimeId = wrapper.read(BedrockTypes.UNSIGNED_VAR_LONG); // entity runtime id final Position3f motion = wrapper.read(BedrockTypes.POSITION_3F); // motion @@ -4774,7 +5135,7 @@ index c6ea78df..423c0c09 100644 }); protocol.registerClientbound(ClientboundBedrockPackets.MOTION_PREDICTION_HINTS, ClientboundPackets26_3.SET_ENTITY_MOTION, wrapper -> { final long entityRuntimeId = wrapper.read(BedrockTypes.UNSIGNED_VAR_LONG); // entity runtime id -@@ -748,6 +758,39 @@ public final class EntityPackets { +@@ -744,6 +754,39 @@ public final class EntityPackets { }); } @@ -4815,18 +5176,32 @@ index c6ea78df..423c0c09 100644 final Entity entity = wrapper.user().get(EntityTracker.class).getEntityByRid(runtimeId); if (entity == null) { diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/JoinPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/JoinPackets.java -index 85a0d136..2210c336 100644 +index 85a0d136..904df450 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/JoinPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/JoinPackets.java -@@ -338,6 +338,7 @@ public final class JoinPackets { +@@ -221,7 +221,8 @@ public final class JoinPackets { + wrapper.read(BedrockTypes.SHORT_LE); // spawn biome type + wrapper.read(BedrockTypes.STRING); // custom biome name + final Dimension dimension = Dimension.values()[wrapper.read(BedrockTypes.VAR_INT)]; // dimension +- final GeneratorType generatorType = GeneratorType.getByValue(wrapper.read(BedrockTypes.VAR_INT), GeneratorType.Undefined); // generator id ++ final int generator = wrapper.read(BedrockTypes.VAR_INT); // generator id ++ final GeneratorType generatorType = GeneratorType.getByValue(generator, GeneratorType.Undefined); + final GameType levelGameType = GameType.getByValue(wrapper.read(BedrockTypes.VAR_INT), GameType.Undefined); // level game type + final boolean hardcore = wrapper.read(Types.BOOLEAN); // hardcore + final Difficulty difficulty = Difficulty.getByValue(wrapper.read(BedrockTypes.VAR_INT), Difficulty.Unknown); // difficulty +@@ -336,8 +337,11 @@ public final class JoinPackets { + } + gameSession.setBedrockVanillaVersion(version); ++ gameSession.setBedrockBaseGameVersionText(vanillaVersion); gameSession.setFlatGenerator(generatorType == GeneratorType.Flat); ++ gameSession.setBedrockGenerator(generator); gameSession.setMovementRewindHistorySize(rewindHistorySize); + wrapper.user().get(PlayerPredictionStorage.class).configureHistory(rewindHistorySize); gameSession.setLevelGameType(levelGameType); gameSession.setLevelTime(levelTime); gameSession.setHardcoreMode(hardcore); -@@ -566,6 +567,7 @@ public final class JoinPackets { +@@ -566,6 +570,7 @@ public final class JoinPackets { clientPlayer.createTeam(); clientPlayer.sendInitialEntityData(); @@ -5039,11 +5414,61 @@ index 23328682..afcc1205 100644 clientPlayer.sendPlayerPositionPacketToClient(Relative.NONE); clientPlayer.sendAttribute("minecraft:health"); // Java client always resets health on respawn, but Bedrock client keeps health when switching dimensions clientPlayer.sendEffects(); // Java client always resets effects on respawn. Resend them +diff --git a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/BlockStateRewriter.java b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/BlockStateRewriter.java +index d596e6ff..5516de94 100644 +--- a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/BlockStateRewriter.java ++++ b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/BlockStateRewriter.java +@@ -19,6 +19,7 @@ package net.raphimc.viabedrock.protocol.rewriter; + + import net.raphimc.viabedrock.api.model.CustomBlockOffset; + import net.raphimc.viabedrock.api.model.NativeBlockGeometry; ++import net.raphimc.viabedrock.api.model.NativeBlockFriction; + import com.viaversion.nbt.tag.*; + import com.viaversion.viaversion.api.connection.StorableObject; + import com.viaversion.viaversion.libs.fastutil.ints.*; +@@ -60,6 +61,7 @@ public class BlockStateRewriter implements StorableObject { + private final BlockItemStates blockItemStates; + private final Set genericCustomStates = Collections.newSetFromMap(new IdentityHashMap<>()); + private final Map customBlockGeometry = new IdentityHashMap<>(); ++ private final Set nativeVanillaBlocks; + private final Map customBlockComponents = new IdentityHashMap<>(); + private final Map customBlockItemOwners = new IdentityHashMap<>(); + private final Map registeredBlockItemIds = new HashMap<>(); +@@ -79,6 +81,9 @@ public class BlockStateRewriter implements StorableObject { + final Set bedrockBlockIdentifiers = bedrockBlockStates.stream().map(BedrockBlockState::namespacedIdentifier).collect(Collectors.toSet()); + final Set nativeBlockIdentifiers = BedrockProtocol.MAPPINGS.getBedrockBlockStates().stream() + .map(BedrockBlockState::namespacedIdentifier).collect(Collectors.toSet()); ++ this.nativeVanillaBlocks = bedrockBlockIdentifiers.stream() ++ .filter(identifier -> !BedrockProtocol.MAPPINGS.getBedrockDataDrivenBlocks().contains(identifier)) ++ .collect(Collectors.toUnmodifiableSet()); + final List customBlockStates = new ArrayList<>(); + final Map customDescriptors = new IdentityHashMap<>(); + final Map customJavaStates = new HashMap<>(); +@@ -243,6 +248,19 @@ public class BlockStateRewriter implements StorableObject { + BedrockProtocol.MAPPINGS.getBedrockLegacyBlocks(), BedrockProtocol.MAPPINGS.getBedrockLegacyBlockStates()); + } + ++ /** Native coefficients are independent of visual compilation and Java carrier assignment. */ ++ public Float blockFriction(final int runtimeId) { ++ final BlockState state = this.blockState(runtimeId); ++ if (state == null) { ++ return null; ++ } ++ if (this.nativeVanillaBlocks.contains(state.namespacedIdentifier())) { ++ return NativeBlockFriction.vanilla(state.namespacedIdentifier()); ++ } ++ final Float explicit = NativeBlockFriction.explicit(this.customBlockComponents.get(state)); ++ return explicit != null ? explicit : this.genericCustomStates.contains(state) ? 0.6F : null; ++ } ++ + /** Return an isolated snapshot for this registered runtime-state owner. */ + public CompoundTag blockComponents(final int runtimeId) { + final CompoundTag components = this.customBlockComponents.get(this.blockState(runtimeId)); diff --git a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemDataRewriter.java b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemDataRewriter.java -index 4b76624a..f9836514 100644 +index c9c0893c..bd4d5810 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemDataRewriter.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemDataRewriter.java -@@ -306,14 +306,15 @@ public final class ItemDataRewriter { +@@ -307,14 +307,15 @@ public final class ItemDataRewriter { if (enchantment instanceof CompoundTag compoundTag) { //id and lvl must be a short. Else bedrock defaults to protection (id 0) and lvl 0 (TODO: implement the fallback) if (compoundTag.get("id") instanceof ShortTag idTag && compoundTag.get("lvl") instanceof ShortTag levelTag) { @@ -5063,26 +5488,26 @@ index 4b76624a..f9836514 100644 //Update the java item with the enchantment 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 3bb3fff4..d0af6440 100644 +index 006250a2..abfd6bd4 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemRewriter.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemRewriter.java -@@ -52,6 +52,7 @@ import net.raphimc.viabedrock.protocol.model.ItemEntry; - import net.raphimc.viabedrock.protocol.model.ItemCooldown; +@@ -53,6 +53,7 @@ import net.raphimc.viabedrock.protocol.model.ItemCooldown; import net.raphimc.viabedrock.protocol.model.ItemAnimationData; + import net.raphimc.viabedrock.protocol.model.ItemAirUseProperties; import net.raphimc.viabedrock.protocol.model.ItemArmorData; +import net.raphimc.viabedrock.protocol.model.ItemUseModifiers; import net.raphimc.viabedrock.protocol.rewriter.item.BundleItemRewriter; import net.raphimc.viabedrock.protocol.rewriter.resourcepack.CustomAttachableResourceRewriter; import net.raphimc.viabedrock.protocol.rewriter.resourcepack.CustomItemTextureResourceRewriter; -@@ -72,6 +73,7 @@ public class ItemRewriter extends StoredObject { - +@@ -74,6 +75,7 @@ public class ItemRewriter extends StoredObject { private final Map animationData = new HashMap<>(); + private final Map airUseProperties = new HashMap<>(); private final Map armorData = new HashMap<>(); + private final Map useModifiers = new HashMap<>(); private final Map cooldowns = new HashMap<>(Map.of( "minecraft:ender_pearl", new ItemCooldown("ender_pearl", 1F), -@@ -103,6 +105,20 @@ public class ItemRewriter extends StoredObject { +@@ -105,6 +107,20 @@ public class ItemRewriter extends StoredObject { return this.armorData.getOrDefault(identifier, ItemArmorData.vanilla(identifier)); } @@ -5103,7 +5528,7 @@ index 3bb3fff4..d0af6440 100644 public ItemRewriter(final UserConnection user, final ItemEntry[] itemEntries) { super(user); -@@ -115,6 +131,7 @@ public class ItemRewriter extends StoredObject { +@@ -118,6 +134,7 @@ public class ItemRewriter extends StoredObject { && itemEntry.componentData().get("components") instanceof CompoundTag components) { this.animationData.put(itemEntry.identifier(), ItemAnimationData.fromNetworkComponents(components)); this.armorData.put(itemEntry.identifier(), ItemArmorData.fromNetworkComponents(components)); @@ -5111,7 +5536,7 @@ index 3bb3fff4..d0af6440 100644 final ItemCooldown cooldown = ItemCooldown.fromNetworkComponents(components); if (cooldown != null) { this.cooldowns.put(itemEntry.identifier(), cooldown); -@@ -274,6 +291,8 @@ public class ItemRewriter extends StoredObject { +@@ -277,6 +294,8 @@ public class ItemRewriter extends StoredObject { ITEM_NBT_REWRITERS.get(tag).toJava(this.user(), bedrockItem, javaItem); } @@ -5120,8 +5545,44 @@ index 3bb3fff4..d0af6440 100644 CustomAttachableResourceRewriter.applyItemModel(this.user().get(ResourcePackStorage.class), identifier, javaItem); javaItem.dataContainer().set(StructuredDataKey.CUSTOM_DATA, ItemDataRewriter.withBedrockItemData(javaItem.dataContainer().get(StructuredDataKey.CUSTOM_DATA), +diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java +index e2f2db7f..6ab245b4 100644 +--- a/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java ++++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java +@@ -712,7 +712,9 @@ public class ChunkTracker extends StoredObject { + return; + } + this.nativeBlockWorldSent = false; +- this.sendNativeBlocks(new NativeBlockIdentifiers.Reset(this.dimension.ordinal(), this.minY, this.worldHeight, this.bedrockAirId())); ++ this.sendNativeBlocks(new NativeBlockIdentifiers.Reset(this.dimension.ordinal(), this.minY, this.worldHeight, this.bedrockAirId(), ++ this.user().get(GameSessionStorage.class).getBedrockBaseGameVersionText(), ++ this.user().get(GameSessionStorage.class).getBedrockGenerator())); + for (final BedrockChunk chunk : this.chunks.values()) { + if (isChunkFullyLoaded(chunk)) { + this.sendNativeChunk(chunk); +@@ -733,7 +735,9 @@ public class ChunkTracker extends StoredObject { + this.nativeBlockWorldSent = true; + this.nativeBlockNames.clear(); + } else if (!this.nativeBlockWorldSent) { +- this.sendNativeBlocks(new NativeBlockIdentifiers.Reset(this.dimension.ordinal(), this.minY, this.worldHeight, this.bedrockAirId())); ++ this.sendNativeBlocks(new NativeBlockIdentifiers.Reset(this.dimension.ordinal(), this.minY, this.worldHeight, this.bedrockAirId(), ++ this.user().get(GameSessionStorage.class).getBedrockBaseGameVersionText(), ++ this.user().get(GameSessionStorage.class).getBedrockGenerator())); + } + final int[] states = message instanceof NativeBlockIdentifiers.Section section ? section.blocks().palette() + : message instanceof NativeBlockIdentifiers.Update update ? new int[]{update.state()} : new int[0]; +@@ -745,7 +749,8 @@ public class ChunkTracker extends StoredObject { + } + final var block = this.user().get(BlockStateRewriter.class).blockState(state); + final var name = new NativeBlockIdentifiers.Name(state, block == null ? "" : block.namespacedIdentifier(), +- block == null ? Map.of() : block.properties(), this.user().get(BlockStateRewriter.class).fluidJavaId(state)); ++ block == null ? Map.of() : block.properties(), this.user().get(BlockStateRewriter.class).fluidJavaId(state), ++ this.user().get(BlockStateRewriter.class).blockFriction(state), this.user().get(BlockStateRewriter.class).blockGeometry(state)); + final int size = name.encodedSize(); + if (!names.isEmpty() && (names.size() == 256 || bytes + size > NativeBlockIdentifiers.MAX_DICTIONARY_BYTES)) { + this.sendNativeBlocks(new NativeBlockIdentifiers.Dictionary(this.dimension.ordinal(), names)); diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/EntityTracker.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/EntityTracker.java -index 136722c7..d50c2799 100644 +index 0ca02d6e..86238ca5 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/EntityTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/EntityTracker.java @@ -43,6 +43,7 @@ import net.raphimc.viabedrock.api.chunk.BedrockBlockEntity; @@ -5132,7 +5593,7 @@ index 136722c7..d50c2799 100644 import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ActorLinkType; import net.raphimc.viabedrock.protocol.data.generated.java.EntityDataFields; import net.raphimc.viabedrock.protocol.model.EntityLink; -@@ -331,6 +332,15 @@ public class EntityTracker extends StoredObject { +@@ -332,6 +333,15 @@ public class EntityTracker extends StoredObject { public void markNativeActorsJoined() { this.nativeActorsJoined = true; this.sendNativeActorSnapshots(); @@ -5148,7 +5609,7 @@ index 136722c7..d50c2799 100644 } public List getEntities() { -@@ -341,6 +351,22 @@ public class EntityTracker extends StoredObject { +@@ -342,6 +352,22 @@ public class EntityTracker extends StoredObject { this.entities.values().forEach(entity -> entity.sendNativeActorSnapshot(true)); } @@ -5171,7 +5632,7 @@ index 136722c7..d50c2799 100644 public void sendNativeCustomEntitySnapshot() { for (Entity entity : this.entities.values()) { if (entity instanceof CustomEntity customEntity) { -@@ -384,6 +410,23 @@ public class EntityTracker extends StoredObject { +@@ -385,6 +411,23 @@ public class EntityTracker extends StoredObject { return this.itemFramesByJavaId.get(javaId); } @@ -5195,6 +5656,56 @@ index 136722c7..d50c2799 100644 public ClientPlayerEntity getClientPlayer() { return this.clientPlayerEntity; } +diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/GameSessionStorage.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/GameSessionStorage.java +index fb054492..0c998db0 100644 +--- a/src/main/java/net/raphimc/viabedrock/protocol/storage/GameSessionStorage.java ++++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/GameSessionStorage.java +@@ -25,6 +25,7 @@ import com.viaversion.viaversion.libs.mcstructs.text.TextComponent; + import net.raphimc.viabedrock.protocol.BedrockProtocol; + import net.raphimc.viabedrock.protocol.model.DimensionDefinition; + import net.raphimc.viabedrock.protocol.data.JavaRegistries; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.GeneratorType; + import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ChatRestrictionLevel; + import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.GameType; + import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.Difficulty; +@@ -36,12 +37,14 @@ import java.util.Set; + + public class GameSessionStorage extends StoredObject { + ++ private String bedrockBaseGameVersionText = ""; + private CompoundTag javaRegistries; + private CompoundTag bedrockBiomeDefinitions = BedrockProtocol.MAPPINGS.getBedrockBiomeDefinitions(); + private final Map bedrockDimensionDefinitions = new HashMap<>(); + private final Set availableEntityIdentifiers = new HashSet<>(BedrockProtocol.MAPPINGS.getBedrockEntities().keySet()); + private Semver bedrockVanillaVersion; + private boolean flatGenerator; ++ private int bedrockGenerator = GeneratorType.Undefined.getValue(); + private int movementRewindHistorySize; + private GameType levelGameType; + private Difficulty difficulty = Difficulty.Unknown; +@@ -111,6 +114,22 @@ public class GameSessionStorage extends StoredObject { + this.bedrockVanillaVersion = bedrockVanillaVersion; + } + ++ public String getBedrockBaseGameVersionText() { ++ return this.bedrockBaseGameVersionText; ++ } ++ ++ public void setBedrockBaseGameVersionText(final String version) { ++ this.bedrockBaseGameVersionText = version; ++ } ++ ++ public int getBedrockGenerator() { ++ return this.bedrockGenerator; ++ } ++ ++ public void setBedrockGenerator(final int generator) { ++ this.bedrockGenerator = generator; ++ } ++ + public boolean isFlatGenerator() { + return this.flatGenerator; + } diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/PlayerInputStorage.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/PlayerInputStorage.java index 95391851..f8bf20f8 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/PlayerInputStorage.java @@ -6311,6 +6822,50 @@ index 00000000..be74f06d + } + +} +diff --git a/src/test/java/net/raphimc/viabedrock/api/model/MotionFrictionTest.java b/src/test/java/net/raphimc/viabedrock/api/model/MotionFrictionTest.java +new file mode 100644 +index 00000000..ce7ef0ca +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/api/model/MotionFrictionTest.java +@@ -0,0 +1,38 @@ ++package net.raphimc.viabedrock.api.model; ++ ++import org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++final class MotionFrictionTest { ++ ++ @Test ++ void smallKnockbackCoastsUntilTheNativeCutoff() { ++ final float friction = 0.6F * 0.91F; ++ float positive = 0.002F; ++ float negative = -0.002F; ++ for (int tick = 0; tick < 17; tick++) { ++ positive = MotionFriction.apply(positive, friction); ++ negative = MotionFriction.apply(negative, friction); ++ assertTrue(positive > 0F); ++ assertTrue(negative < 0F); ++ } ++ // Original 1.26.60 friction callback after seventeen retained steps. ++ assertEquals(865225741, Float.floatToRawIntBits(positive)); ++ assertEquals(3012709389L, Integer.toUnsignedLong(Float.floatToRawIntBits(negative))); ++ assertEquals(0, Float.floatToRawIntBits(MotionFriction.apply(positive, friction))); ++ assertEquals(0x80000000, Float.floatToRawIntBits(MotionFriction.apply(negative, friction))); ++ } ++ ++ @Test ++ void appliesTheInclusiveAxisCutoffBeforeFriction() { ++ final float epsilon = 0x1.0p-23F; ++ assertEquals(0, Float.floatToRawIntBits(MotionFriction.apply(epsilon, 0.91F))); ++ assertEquals(0x80000000, Float.floatToRawIntBits(MotionFriction.apply(-epsilon, 0.91F))); ++ assertTrue(MotionFriction.apply(Math.nextUp(epsilon), 0.91F) > 0F); ++ assertTrue(MotionFriction.apply(-Math.nextUp(epsilon), 0.91F) < 0F); ++ assertEquals(0F, MotionFriction.apply(Math.nextDown(epsilon), Float.NaN)); ++ assertTrue(Float.isNaN(MotionFriction.apply(Float.NaN, 0.91F))); ++ } ++ ++} diff --git a/src/test/java/net/raphimc/viabedrock/api/model/MovementCollisionQueryTest.java b/src/test/java/net/raphimc/viabedrock/api/model/MovementCollisionQueryTest.java new file mode 100644 index 00000000..88f1b3b2 @@ -7223,6 +7778,433 @@ index 00000000..4ff00ed1 + } + +} +diff --git a/src/test/java/net/raphimc/viabedrock/api/model/NativeBlockFrictionTest.java b/src/test/java/net/raphimc/viabedrock/api/model/NativeBlockFrictionTest.java +new file mode 100644 +index 00000000..ce3b2e66 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/api/model/NativeBlockFrictionTest.java +@@ -0,0 +1,244 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.model; ++ ++import com.viaversion.nbt.tag.CompoundTag; ++import com.viaversion.nbt.tag.DoubleTag; ++import com.viaversion.nbt.tag.ListTag; ++import com.viaversion.viaversion.api.minecraft.BlockPosition; ++import com.viaversion.viaversion.api.type.Types; ++import io.netty.buffer.Unpooled; ++import com.google.common.collect.HashBiMap; ++import com.viaversion.nbt.tag.IntTag; ++import com.viaversion.viaversion.libs.fastutil.ints.Int2ObjectOpenHashMap; ++import net.raphimc.viabedrock.ViaBedrock; ++import net.raphimc.viabedrock.platform.ViaBedrockPlatform; ++import net.raphimc.viabedrock.protocol.BedrockProtocol; ++import net.raphimc.viabedrock.protocol.model.BlockProperties; ++import net.raphimc.viabedrock.protocol.rewriter.BlockStateRewriter; ++import net.raphimc.viabedrock.protocol.storage.ResourcePackStorage; ++import net.raphimc.viabedrock.protocol.data.ProtocolConstants; ++import net.raphimc.viabedrock.protocol.types.BedrockTypes; ++import org.junit.jupiter.api.Test; ++ ++import java.util.ArrayList; ++import java.util.HashSet; ++import java.util.List; ++import java.util.Set; ++import java.util.HashMap; ++import java.util.logging.Logger; ++import java.util.Map; ++ ++import static org.junit.jupiter.api.Assertions.*; ++import static org.mockito.Mockito.*; ++ ++class NativeBlockFrictionTest { ++ ++ @Test ++ void transportsPerStateFrictionIntoBothNativeLayersWithoutVisualResources() throws Exception { ++ final CompoundTag definition = new CompoundTag(); ++ final CompoundTag base = components(0.4F); ++ base.putString("minecraft:geometry", "test:missing_geometry"); ++ definition.put("components", base); ++ definition.put("menu_category", new CompoundTag()); ++ final CompoundTag vanilla = new CompoundTag(); ++ vanilla.putInt("block_id", 1024); ++ definition.put("vanilla_block_data", vanilla); ++ final CompoundTag property = new CompoundTag(); ++ property.putString("name", "test:variant"); ++ property.put("enum", new ListTag<>(List.of(new IntTag(0), new IntTag(1)))); ++ definition.put("properties", new ListTag<>(List.of(property))); ++ final CompoundTag permutation = new CompoundTag(); ++ permutation.putString("condition", "q.block_state('test:variant') == 1"); ++ permutation.put("components", components(0.8F)); ++ definition.put("permutations", new ListTag<>(List.of(permutation))); ++ final var buffer = Unpooled.buffer(); ++ try { ++ BedrockTypes.NETWORK_TAG.write(buffer, definition); ++ final CompoundTag decodedDefinition = (CompoundTag) BedrockTypes.NETWORK_TAG.read(buffer); ++ final BlockStateRewriter blocks = register(decodedDefinition, false); ++ final BlockStateRewriter missingVisuals = register(decodedDefinition, true); ++ final int primaryId = blocks.bedrockId(BlockState.fromString("test:block[test:variant=0]")); ++ final int secondaryId = blocks.bedrockId(BlockState.fromString("test:block[test:variant=1]")); ++ final Float primary = blocks.blockFriction(primaryId); ++ final Float secondary = blocks.blockFriction(secondaryId); ++ assertEquals(0x3F19999A, Float.floatToRawIntBits(primary)); ++ assertEquals(0x3E4CCCCC, Float.floatToRawIntBits(secondary)); ++ assertEquals(primary, missingVisuals.blockFriction(primaryId)); ++ assertEquals(secondary, missingVisuals.blockFriction(secondaryId)); ++ assertEquals(blocks.javaId(primaryId), blocks.javaId(secondaryId)); ++ assertNull(blocks.blockFriction(Integer.MIN_VALUE)); ++ final var dictionary = new NativeBlockIdentifiers.Dictionary(0, List.of( ++ new NativeBlockIdentifiers.Name(-17, "test:block", Map.of("test:variant", "0"), -1, primary), ++ new NativeBlockIdentifiers.Name(43, "test:block", Map.of("test:variant", "1"), -1, secondary))); ++ buffer.clear(); ++ NativeBlockIdentifiers.TYPE.write(buffer, dictionary); ++ final var decoded = NativeBlockIdentifiers.TYPE.read(buffer); ++ assertEquals(dictionary, decoded); ++ assertFalse(buffer.isReadable()); ++ final var view = new NativeBlockView(); ++ view.accept(new NativeBlockIdentifiers.Reset(0, -64, 384, 0)); ++ view.accept(decoded); ++ view.accept(new NativeBlockIdentifiers.Section(0, -2, -4, -3, 0, NativeBlockSection.create(index -> -17))); ++ final var position = new BlockPosition(-31, -63, -47); ++ view.accept(new NativeBlockIdentifiers.Update(0, position, 1, 43)); ++ assertEquals(primary, view.state(-31, -63, -47, 0).friction()); ++ assertEquals(secondary, view.state(-31, -63, -47, 1).friction()); ++ view.accept(new NativeBlockIdentifiers.Reset(1, -64, 384, 0)); ++ view.accept(new NativeBlockIdentifiers.Section(1, -2, -4, -3, 0, NativeBlockSection.create(index -> -17))); ++ assertNull(view.state(-31, -63, -47, 0)); ++ } finally { ++ buffer.release(); ++ } ++ } ++ ++ @Test ++ void transportsVanillaAndVerifiedBaseCoefficientsWithoutGuessingOtherClasses() throws Exception { ++ final CompoundTag definition = new CompoundTag(); ++ definition.put("components", new CompoundTag()); ++ definition.put("menu_category", new CompoundTag()); ++ final CompoundTag vanilla = new CompoundTag(); ++ vanilla.putInt("block_id", 1024); ++ definition.put("vanilla_block_data", vanilla); ++ final BlockStateRewriter blocks = register(definition, false, "minecraft:future_stone"); ++ final var expected = Map.of("air", 0x3F666666, "unknown", 0x3F666666, "stone", 0x3F19999A, ++ "ice", 0x3F7AE148, "packed_ice", 0x3F7AE148, "frosted_ice", 0x3F7AE148, ++ "blue_ice", 0x3F7D2F1B, "slime", 0x3F4CCCCD, "honey_block", 0x3F4CCCCD); ++ final var names = new ArrayList(); ++ for (final var entry : expected.entrySet()) { ++ final String identifier = "minecraft:" + entry.getKey(); ++ final int runtimeId = blocks.bedrockId(BlockState.fromString(identifier)); ++ final Float coefficient = blocks.blockFriction(runtimeId); ++ assertEquals(entry.getValue().intValue(), Float.floatToRawIntBits(coefficient), identifier); ++ names.add(new NativeBlockIdentifiers.Name(runtimeId, identifier, Map.of(), -1, coefficient)); ++ } ++ assertEquals(blocks.javaId(blocks.bedrockId(BlockState.fromString("minecraft:stone"))), ++ blocks.javaId(blocks.bedrockId(BlockState.fromString("minecraft:ice")))); ++ assertEquals(0.6F, blocks.blockFriction(blocks.bedrockId(BlockState.fromString("minecraft:future_stone")))); ++ assertNull(blocks.blockFriction(blocks.bedrockId(BlockState.fromString("minecraft:red_shrub")))); ++ assertNull(blocks.blockFriction(Integer.MIN_VALUE)); ++ final var view = new NativeBlockView(); ++ final var buffer = Unpooled.buffer(); ++ try { ++ view.accept(new NativeBlockIdentifiers.Reset(0, -64, 384, 0)); ++ NativeBlockIdentifiers.TYPE.write(buffer, new NativeBlockIdentifiers.Dictionary(0, names)); ++ view.accept(NativeBlockIdentifiers.TYPE.read(buffer)); ++ final int ice = blocks.bedrockId(BlockState.fromString("minecraft:ice")); ++ final int stone = blocks.bedrockId(BlockState.fromString("minecraft:stone")); ++ view.accept(new NativeBlockIdentifiers.Section(0, 0, 0, 0, 0, NativeBlockSection.create(index -> ice))); ++ view.accept(new NativeBlockIdentifiers.Update(0, new BlockPosition(0, 0, 0), 1, stone)); ++ assertEquals(0x3F7AE148, Float.floatToRawIntBits(view.state(0, 0, 0, 0).friction())); ++ assertEquals(0x3F19999A, Float.floatToRawIntBits(view.state(0, 0, 0, 1).friction())); ++ assertFalse(buffer.isReadable()); ++ } finally { ++ buffer.release(); ++ } ++ } ++ ++ @Test ++ void preservesUnknownDefaultsAndStrictNativeFloatDecoding() throws Exception { ++ final CompoundTag components = new CompoundTag(); ++ assertNull(NativeBlockFriction.explicit(components)); ++ final CompoundTag friction = new CompoundTag(); ++ components.put("minecraft:friction", friction); ++ assertEquals(1F, NativeBlockFriction.explicit(components)); ++ friction.put("value", new DoubleTag(0.4)); ++ assertEquals(1F, NativeBlockFriction.explicit(components)); ++ friction.putFloat("value", 1.25F); ++ assertEquals(-0.25F, NativeBlockFriction.explicit(components)); ++ final var buffer = Unpooled.buffer(); ++ try { ++ final var dictionary = new NativeBlockIdentifiers.Dictionary(0, List.of( ++ new NativeBlockIdentifiers.Name(1, "test:unknown", Map.of(), -1), ++ new NativeBlockIdentifiers.Name(2, "test:explicit", Map.of(), -1, -0.25F))); ++ NativeBlockIdentifiers.TYPE.write(buffer, dictionary); ++ assertEquals(dictionary, NativeBlockIdentifiers.TYPE.read(buffer)); ++ } finally { ++ buffer.release(); ++ } ++ } ++ ++ @Test ++ void rejectsOldIdentityMessagesBeforeReadingTheirDictionaryBody() { ++ final var buffer = Unpooled.buffer(); ++ try { ++ Types.VAR_INT.writePrimitive(buffer, 4); ++ Types.VAR_INT.writePrimitive(buffer, ProtocolConstants.BEDROCK_PROTOCOL_VERSION); ++ final var failure = assertThrows(IllegalArgumentException.class, () -> NativeBlockIdentifiers.TYPE.read(buffer)); ++ assertEquals("Unsupported native block identity message", failure.getMessage()); ++ } finally { ++ buffer.release(); ++ } ++ } ++ ++ private static BedrockBlockState nativeState(final String identifier) { ++ final CompoundTag tag = new CompoundTag(); ++ tag.putString("name", identifier); ++ tag.put("states", new CompoundTag()); ++ return BedrockBlockState.fromNbt(tag); ++ } ++ ++ private static BlockStateRewriter register(final CompoundTag definition, final boolean loaded) throws Exception { ++ return register(definition, loaded, "test:block"); ++ } ++ ++ private static BlockStateRewriter register(final CompoundTag definition, final boolean loaded, final String identifier) throws Exception { ++ final BlockState air = BlockState.fromString("minecraft:air"); ++ final BlockState stone = BlockState.fromString("minecraft:stone"); ++ final Set nativeStates = new HashSet<>(); ++ final Map mappings = new HashMap<>(); ++ for (final String name : List.of("air", "info_update", "unknown", "stone", "ice", "packed_ice", "frosted_ice", ++ "blue_ice", "slime", "honey_block", "red_shrub")) { ++ final BedrockBlockState state = nativeState("minecraft:" + name); ++ nativeStates.add(state); ++ mappings.put(state, name.equals("air") ? air : stone); ++ } ++ final var replacements = Map.of( ++ "javaBlockStates", HashBiMap.create(Map.of(air, 0, stone, 1, BlockState.fromString("minecraft:note_block"), 2)), ++ "bedrockBlockStates", nativeStates, ++ "bedrockToJavaBlockStates", mappings, ++ "bedrockCustomBlockTags", Map.of(), "bedrockLegacyBlocks", HashBiMap.create(), ++ "bedrockLegacyBlockStates", new Int2ObjectOpenHashMap<>(), "dataDrivenBedrockBlocks", Set.of("minecraft:red_shrub")); ++ final Map previous = new HashMap<>(); ++ final var platform = ViaBedrock.class.getDeclaredField("platform"); ++ platform.setAccessible(true); ++ final Object oldPlatform = platform.get(null); ++ try { ++ final var supplied = mock(ViaBedrockPlatform.class); ++ when(supplied.getLogger()).thenReturn(Logger.getAnonymousLogger()); ++ platform.set(null, supplied); ++ for (final var entry : replacements.entrySet()) { ++ final var field = BedrockProtocol.MAPPINGS.getClass().getDeclaredField(entry.getKey()); ++ field.setAccessible(true); ++ previous.put(entry.getKey(), field.get(BedrockProtocol.MAPPINGS)); ++ field.set(BedrockProtocol.MAPPINGS, entry.getValue()); ++ } ++ final var resources = new ResourcePackStorage(List.of()); ++ if (loaded) { ++ resources.setLoadedOnJavaClient(); ++ } ++ final var blocks = new BlockStateRewriter(new BlockProperties[]{new BlockProperties(identifier, definition), new BlockProperties("minecraft:red_shrub", definition)}, false, resources); ++ assertTrue(resources.getCustomBlockVisuals().isEmpty()); ++ return blocks; ++ } finally { ++ for (final var entry : previous.entrySet()) { ++ final var field = BedrockProtocol.MAPPINGS.getClass().getDeclaredField(entry.getKey()); ++ field.setAccessible(true); ++ field.set(BedrockProtocol.MAPPINGS, entry.getValue()); ++ } ++ platform.set(null, oldPlatform); ++ } ++ } ++ ++ private static CompoundTag components(final float value) { ++ final CompoundTag result = new CompoundTag(); ++ final CompoundTag friction = new CompoundTag(); ++ friction.putFloat("value", value); ++ result.put("minecraft:friction", friction); ++ return result; ++ } ++ ++} +diff --git a/src/test/java/net/raphimc/viabedrock/api/model/NativeBlockGeometryTransportTest.java b/src/test/java/net/raphimc/viabedrock/api/model/NativeBlockGeometryTransportTest.java +new file mode 100644 +index 00000000..f34c560b +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/api/model/NativeBlockGeometryTransportTest.java +@@ -0,0 +1,84 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.model; ++ ++import com.viaversion.viaversion.api.type.Types; ++import com.viaversion.viaversion.api.minecraft.BlockPosition; ++import io.netty.buffer.Unpooled; ++import net.raphimc.viabedrock.protocol.data.ProtocolConstants; ++import org.junit.jupiter.api.Test; ++import java.util.List; ++import java.util.Map; ++import static org.junit.jupiter.api.Assertions.*; ++ ++class NativeBlockGeometryTransportTest { ++ ++ @Test ++ void retainsUnknownEmptyAndPlanarBoundsAcrossDictionaryUpdatesAndWorldReplacement() { ++ final var offset = new CustomBlockOffset(new CustomBlockOffset.Axis(0, 0, 0xFFFFFFFFL), CustomBlockOffset.ZERO, CustomBlockOffset.ZERO); ++ final var planes = new NativeBlockGeometry(List.of(new CustomBlockVisual.Box(0, 0, 0, 0, 1, 1), ++ new CustomBlockVisual.Box(1, 0, 0, 1, 1, 1)), offset); ++ final var names = List.of(new NativeBlockIdentifiers.Name(1, "probe:unknown", Map.of(), -1, null, null), ++ new NativeBlockIdentifiers.Name(2, "probe:disabled", Map.of(), -1, 0.6F, NativeBlockGeometry.EMPTY), ++ new NativeBlockIdentifiers.Name(3, "probe:planes", Map.of(), -1, 0.6F, planes)); ++ final var buffer = Unpooled.buffer(); ++ try { ++ NativeBlockIdentifiers.TYPE.write(buffer, new NativeBlockIdentifiers.Dictionary(0, names)); ++ final var decoded = (NativeBlockIdentifiers.Dictionary) NativeBlockIdentifiers.TYPE.read(buffer); ++ assertEquals(names, decoded.names()); ++ assertFalse(buffer.isReadable()); ++ final var view = new NativeBlockView(); ++ view.accept(new NativeBlockIdentifiers.Reset(0, -64, 384, 0)); ++ view.accept(decoded); ++ view.accept(new NativeBlockIdentifiers.Section(0, 0, 0, 0, 0, new NativeBlockSection(new int[]{1}, new long[0]))); ++ assertNull(view.state(0, 0, 0, 0).geometry()); ++ view.accept(new NativeBlockIdentifiers.Update(0, new BlockPosition(0, 0, 0), 0, 2)); ++ assertTrue(view.state(0, 0, 0, 0).geometry().cameraBoxesAt(0, 0, 0).isEmpty()); ++ view.accept(new NativeBlockIdentifiers.Update(0, new BlockPosition(0, 0, 0), 0, 3)); ++ assertEquals(new CustomBlockVisual.Box(0, 0, 0, 1, 1, 1), view.state(0, 0, 0, 0).geometry().primaryBoundsAt(0, 0, 0)); ++ view.accept(new NativeBlockIdentifiers.Forget(0, 0, 0)); ++ assertTrue(view.state(0, 0, 0, 0).geometry().collisionBoxes().isEmpty()); ++ view.accept(new NativeBlockIdentifiers.Reset(1, -64, 384, 0)); ++ view.accept(decoded); ++ view.accept(new NativeBlockIdentifiers.Section(1, 0, 0, 0, 0, new NativeBlockSection(new int[]{3}, new long[0]))); ++ assertNull(view.state(0, 0, 0, 0)); ++ } finally { ++ buffer.release(); ++ } ++ } ++ ++ @Test ++ void accountsForGeometryBytesAndRejectsOversizedWireBoxLists() { ++ final var geometry = new NativeBlockGeometry(List.of(new CustomBlockVisual.Box(0, 0, 0, 1, 1, 1)), CustomBlockOffset.NONE); ++ final var name = new NativeBlockIdentifiers.Name(1, "probe:block", Map.of(), -1, 0.6F, geometry); ++ final var buffer = Unpooled.buffer(); ++ try { ++ NativeBlockIdentifiers.TYPE.write(buffer, new NativeBlockIdentifiers.Dictionary(0, List.of(name))); ++ Types.VAR_INT.readPrimitive(buffer); ++ Types.VAR_INT.readPrimitive(buffer); ++ buffer.readByte(); ++ Types.VAR_INT.readPrimitive(buffer); ++ Types.VAR_INT.readPrimitive(buffer); ++ assertEquals(name.encodedSize(), buffer.readableBytes()); ++ buffer.clear(); ++ Types.VAR_INT.writePrimitive(buffer, 7); ++ Types.VAR_INT.writePrimitive(buffer, ProtocolConstants.BEDROCK_PROTOCOL_VERSION); ++ buffer.writeByte(4); ++ Types.VAR_INT.writePrimitive(buffer, 0); ++ Types.VAR_INT.writePrimitive(buffer, 1); ++ buffer.writeInt(1); ++ Types.STRING.write(buffer, "probe:block"); ++ buffer.writeInt(-1); ++ Types.VAR_INT.writePrimitive(buffer, 0); ++ buffer.writeBoolean(false).writeBoolean(true); ++ Types.VAR_INT.writePrimitive(buffer, NativeBlockGeometry.MAX_BOXES + 1); ++ assertThrows(IllegalArgumentException.class, () -> NativeBlockIdentifiers.TYPE.read(buffer)); ++ } finally { ++ buffer.release(); ++ } ++ } ++ ++} +diff --git a/src/test/java/net/raphimc/viabedrock/api/model/NativeBlockViewTest.java b/src/test/java/net/raphimc/viabedrock/api/model/NativeBlockViewTest.java +index 9140b86b..8c33b0eb 100644 +--- a/src/test/java/net/raphimc/viabedrock/api/model/NativeBlockViewTest.java ++++ b/src/test/java/net/raphimc/viabedrock/api/model/NativeBlockViewTest.java +@@ -80,11 +80,58 @@ class NativeBlockViewTest { + assertFalse(view.ready()); + } + ++ @Test ++ void worldSettingsTravelTogetherAndCannotSurviveWorldReplacementOrDisconnect() { ++ final var view = new NativeBlockView(); ++ assertNull(view.world()); ++ assertNull(view.baseGameVersion()); ++ final var buffer = Unpooled.buffer(); ++ try { ++ final var previous = new NativeBlockIdentifiers.Reset(0, -64, 384, 0, "*", 0); ++ for (final var reset : List.of(previous, ++ new NativeBlockIdentifiers.Reset(0, -64, 384, 0, "1.21.89", 1), ++ new NativeBlockIdentifiers.Reset(1, 0, 256, 0, "invalid-version", 2), ++ new NativeBlockIdentifiers.Reset(0, -64, 384, 0, "1.21.90-pre+world", Integer.MAX_VALUE))) { ++ NativeBlockIdentifiers.TYPE.write(buffer, reset); ++ view.accept(NativeBlockIdentifiers.TYPE.read(buffer)); ++ assertEquals(reset, view.world()); ++ assertEquals(reset.baseGameVersion(), view.baseGameVersion()); ++ view.accept(new NativeBlockIdentifiers.Forget(reset.dimension(), 2, 3)); ++ assertEquals(reset, view.world()); ++ assertFalse(buffer.isReadable()); ++ buffer.clear(); ++ } ++ assertEquals(0, previous.generator()); ++ assertEquals("*", previous.baseGameVersion()); ++ view.clear(); ++ assertNull(view.world()); ++ assertNull(view.baseGameVersion()); ++ } finally { ++ buffer.release(); ++ } ++ } ++ ++ @Test ++ void rejectsOlderResetWithoutGenerator() { ++ final var buffer = Unpooled.buffer(); ++ try { ++ Types.VAR_INT.writePrimitive(buffer, 5); ++ Types.VAR_INT.writePrimitive(buffer, ProtocolConstants.BEDROCK_PROTOCOL_VERSION); ++ buffer.writeByte(0); ++ Types.VAR_INT.writePrimitive(buffer, 0); ++ buffer.writeInt(-64).writeInt(384).writeInt(0); ++ Types.STRING.write(buffer, "*"); ++ assertThrows(IllegalArgumentException.class, () -> NativeBlockIdentifiers.TYPE.read(buffer)); ++ } finally { ++ buffer.release(); ++ } ++ } ++ + @Test + void roundtripsAllMessagesWithoutConsumingFollowingFields() { + final var section = NativeBlockSection.create(index -> index * 17 - 4096); + final List messages = List.of( +- new NativeBlockIdentifiers.Reset(0, -64, 384, 0), ++ new NativeBlockIdentifiers.Reset(0, -64, 384, 0, "1.21.90-pre+world"), + new NativeBlockIdentifiers.Dictionary(0, List.of(new NativeBlockIdentifiers.Name(Integer.MIN_VALUE, "test:custom", Map.of("test:mode", "mixed_α"), 1234))), + new NativeBlockIdentifiers.Section(0, -5, -4, 7, 1, section), + new NativeBlockIdentifiers.Update(0, new BlockPosition(-79, -62, 115), 1, Integer.MAX_VALUE), +@@ -202,7 +249,7 @@ class NativeBlockViewTest { + assertTrue(name.encodedSize() * 3 > NativeBlockIdentifiers.MAX_DICTIONARY_BYTES); + assertThrows(IllegalArgumentException.class, () -> new NativeBlockIdentifiers.Dictionary(0, List.of(name, name, name))); + buffer.clear(); +- Types.VAR_INT.writePrimitive(buffer, 3); ++ Types.VAR_INT.writePrimitive(buffer, 7); + Types.VAR_INT.writePrimitive(buffer, ProtocolConstants.BEDROCK_PROTOCOL_VERSION); + buffer.writeByte(4); + Types.VAR_INT.writePrimitive(buffer, 0); +@@ -231,11 +278,11 @@ class NativeBlockViewTest { + assertThrows(IllegalArgumentException.class, () -> new NativeBlockIdentifiers.Update(0, new BlockPosition(0, 0, 0), -1, 0)); + final var buffer = Unpooled.buffer(); + try { +- Types.VAR_INT.writePrimitive(buffer, 3); ++ Types.VAR_INT.writePrimitive(buffer, 7); + Types.VAR_INT.writePrimitive(buffer, ProtocolConstants.BEDROCK_PROTOCOL_VERSION + 1); + assertThrows(IllegalArgumentException.class, () -> NativeBlockIdentifiers.TYPE.read(buffer)); + buffer.clear(); +- Types.VAR_INT.writePrimitive(buffer, 3); ++ Types.VAR_INT.writePrimitive(buffer, 7); + Types.VAR_INT.writePrimitive(buffer, ProtocolConstants.BEDROCK_PROTOCOL_VERSION); + buffer.writeByte(1); + Types.VAR_INT.writePrimitive(buffer, 0); diff --git a/src/test/java/net/raphimc/viabedrock/api/model/NativeMovementSourcesTest.java b/src/test/java/net/raphimc/viabedrock/api/model/NativeMovementSourcesTest.java new file mode 100644 index 00000000..bdb0afe8 @@ -11590,8 +12572,20 @@ index 00000000..caaa6306 + } + +} +diff --git a/src/test/java/net/raphimc/viabedrock/protocol/rewriter/NativeBlockGeometryRegistrationTest.java b/src/test/java/net/raphimc/viabedrock/protocol/rewriter/NativeBlockGeometryRegistrationTest.java +index d763dcee..80f59b25 100644 +--- a/src/test/java/net/raphimc/viabedrock/protocol/rewriter/NativeBlockGeometryRegistrationTest.java ++++ b/src/test/java/net/raphimc/viabedrock/protocol/rewriter/NativeBlockGeometryRegistrationTest.java +@@ -70,6 +70,7 @@ class NativeBlockGeometryRegistrationTest { + definition("probe:slab", "slab_block")}, true, storage); + final var physical = rewriter.blockGeometry(rewriter.bedrockId(BlockState.fromString("probe:shape"))); + assertNotNull(physical); ++ assertEquals(0.6F, rewriter.blockFriction(rewriter.bedrockId(BlockState.fromString("probe:shape")))); + assertEquals(new CustomBlockVisual.Box(-16.875, -63.9375, 93.25, -16.625, -63.4375, 93.875), + physical.primaryBoundsAt(-17, -64, 93)); + assertNull(rewriter.blockGeometry(rewriter.bedrockId(BlockState.fromString("probe:slab")))); diff --git a/src/test/java/net/raphimc/viabedrock/protocol/storage/EntityTrackerTest.java b/src/test/java/net/raphimc/viabedrock/protocol/storage/EntityTrackerTest.java -index 77b9559d..1eb2c103 100644 +index 85498d35..3f21a65b 100644 --- a/src/test/java/net/raphimc/viabedrock/protocol/storage/EntityTrackerTest.java +++ b/src/test/java/net/raphimc/viabedrock/protocol/storage/EntityTrackerTest.java @@ -85,14 +85,14 @@ class EntityTrackerTest { diff --git a/patches/viabedrock/upstreamable/0092-retain-completed-client-prediction-frames.pr.md b/patches/viabedrock/upstreamable/0092-retain-completed-client-prediction-frames.pr.md index f655ed65..b572e6cb 100644 --- a/patches/viabedrock/upstreamable/0092-retain-completed-client-prediction-frames.pr.md +++ b/patches/viabedrock/upstreamable/0092-retain-completed-client-prediction-frames.pr.md @@ -8,16 +8,41 @@ Native 1.26.51.1 protocol 2193 packets distinguish completed fluid motion from a Pair input, completed motion, item state, and authoritative frame identity. Retain correction history and ordered effects without allowing stale actor or world state into replay. -Prerequisites: +The version-7 native block dictionary carries physical geometry, random offsets, and friction separately from visual models. +Verified base-factory registrations use the native default coefficient when no explicit friction component applies. +Hardcoded types retain their pinned coefficients. Other server-defined classes retain unknown physical data. +An empty geometry value remains distinct from unknown geometry through section updates, unloading, and world replacement. -- viabedrock/0018-preserve-sparse-actor-spatial-updates.patch: Retain complete received spatial state before local-control admission. The registered vehicle comparisons require this cache independently of the live actor pose. +World resets carry the exact base-game version text and signed generator ID from `StartGame` with dimension and terrain bounds. +Consumers read both fields from one immutable reset. Unknown generator IDs retain their original values. +The mirror replaces these settings with each reset and clears them on disconnect. Resource cutoffs and client protocol versions remain separate inputs. + +The prerequisite entries in `series.json` supply actor snapshots, block dictionaries, and resolved custom-block components. ## Validation and remaining limits -Regression coverage includes `ActorMotionCommandTest`, `ActorSpatialCommandTest`, `CollisionContactTest`, `FlightMovementTest`. +Regression coverage includes `ActorMotionCommandTest`, `ActorSpatialCommandTest`, `CollisionContactTest`, `FlightMovementTest`, and `MotionFrictionTest`. + +The shared motion-friction policy follows the full original 1.26.60.5 callback: cut each horizontal axis at `abs(velocity) <= 2^-23` before friction, retaining signed zero. A small-impulse sequence and boundary regressions cover continued drift below Java's threshold and its eventual stop. Flight uses the same policy. Ordinary vertical motion, liquid, glide and mounted stages remain separate. History, clock, correction, and ordering regressions cover retained-frame behavior. Native world history, mixed mechanics, network conditions, and broader replay parity remain open. +Friction regressions follow registered state overlays through pack refusal, failed visual compilation, dictionary encoding, both native block layers, and world reset. +Installed direct and proxy controls retain explicit coefficients through native block-state swaps and restoration. Verified base-factory blocks without the component now use the native coefficient of 0.6. Other custom classes retain an unknown coefficient. +These controls use actual 1.26.60.6 server definitions, with no resource-pack models for the custom blocks. +Both routes preserve the server's `*` version and generator `2` through Overworld, Nether, and return transitions. +Both settings and the dictionary become unavailable during level replacement. Each replacement receives a fresh mirror, and the original block coefficients return with the Overworld. +Regressions cover wire encoding, reset replacement, disconnect, unknown generator IDs, and exact wildcard, suffix, and malformed version text. +The [boat provider controls](../../viafabricplus-bedrock/upstreamable/0025-send-completed-local-physics-to-viabedrock.pr.md) establish the bounded native version-selection path. +A stable read-only snapshot of the original 1.26.60.5 registry covers all 1,480 palette names and 23,960 finalized state caches. +Every state agrees with its type's default. The compiled coefficients match native bits for all 1,382 hardcoded types. +A regression carries vanilla coefficients through dictionary encoding and both block layers while preserving unresolved class defaults. +Geometry regressions distinguish unknown bounds, empty collision, planes, and random offsets through the version-7 dictionary and mirror lifecycle. +Nine original native component chains match all 36 primary-bound queries after encoding and decoding. +Complete installed direct and ViaProxy recordings retain geometry without visual definitions across swaps, reload, and dimension return. +The full core, add-on, and proxy builds and fresh replays pass. +Installed vanilla-coefficient checks, different-version server transfers, whole native world and actor lifecycles, nonbase physical providers, boat ticks, trajectories, and visual parity remain unverified. + Original Bedrock 1.26.60.5 Dolphin Boost kernel controls establish that the swim multiplier uses effect-marker presence, including the final zero-duration frame. The shared timeline exposes that multiplier from retained values. A regression checks actor and effect-kind isolation, the swimming gate, expiry, and restoration after clearing state. Full world scheduling at the swim-expiry boundary remains unverified. The [owning patch](0092-retain-completed-client-prediction-frames.patch) retains implementation rationale and versioned evidence. diff --git a/patches/viabedrock/upstreamable/0094-synchronize-authoritative-entity-references.patch b/patches/viabedrock/upstreamable/0094-synchronize-authoritative-entity-references.patch index 43447dae..7d43774e 100644 --- a/patches/viabedrock/upstreamable/0094-synchronize-authoritative-entity-references.patch +++ b/patches/viabedrock/upstreamable/0094-synchronize-authoritative-entity-references.patch @@ -1,4 +1,4 @@ -From 5b974bce408e1ffd3218ffdbd4a509e955dc9017 Mon Sep 17 00:00:00 2001 +From 3f20da1644d7c3f8e486ef15c00ecf8afbd917b2 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] fix(entities): synchronize authoritative entity references @@ -304,10 +304,10 @@ index 4ebe5ed5..28991e6a 100644 public final void updateEntityData(final EntityData[] entityData) { diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java -index 423c0c09..b8ba20f5 100644 +index 1d7c6c07..efe4871a 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java -@@ -184,9 +184,10 @@ public final class EntityPackets { +@@ -183,9 +183,10 @@ public final class EntityPackets { wrapper.write(Types.BYTE, MathUtil.float2Byte(entity.javaRotation().x())); // pitch wrapper.write(Types.BYTE, MathUtil.float2Byte(entity.javaRotation().y())); // yaw wrapper.write(Types.BYTE, MathUtil.float2Byte(entity.javaRotation().z())); // head yaw @@ -319,7 +319,7 @@ index 423c0c09..b8ba20f5 100644 if (entity instanceof LivingEntity livingEntity) { livingEntity.updateAttributes(attributes); -@@ -225,6 +226,7 @@ public final class EntityPackets { +@@ -224,6 +225,7 @@ public final class EntityPackets { wrapper.write(Types.VAR_INT, 0); // data wrapper.send(BedrockProtocol.class); wrapper.cancel(); @@ -327,7 +327,7 @@ index 423c0c09..b8ba20f5 100644 final List javaEntityData = new ArrayList<>(); entity.updateEntityData(entityData, javaEntityData); -@@ -465,6 +467,7 @@ public final class EntityPackets { +@@ -464,6 +466,7 @@ public final class EntityPackets { wrapper.write(Types.VAR_INT, direction.verticalId()); // data wrapper.send(BedrockProtocol.class); wrapper.cancel(); diff --git a/patches/viabedrock/upstreamable/0095-integrate-authored-native-ui-presentation.patch b/patches/viabedrock/upstreamable/0095-integrate-authored-native-ui-presentation.patch index 303b2c73..db30c6e7 100644 --- a/patches/viabedrock/upstreamable/0095-integrate-authored-native-ui-presentation.patch +++ b/patches/viabedrock/upstreamable/0095-integrate-authored-native-ui-presentation.patch @@ -1,4 +1,4 @@ -From 89dbbf8d733db412c0f6ef85a14ccb7870fc75a8 Mon Sep 17 00:00:00 2001 +From 5864142f0dbc9e9aaba2f767e030c29175aa6a2e 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): resolve authored Bedrock server UI scenes @@ -791,6 +791,40 @@ physical selection, Take identity, partial merging, rejection, empty containers and owned before/after contents. The native controls use explicit model/item, identity and publication boundaries; they do not establish controller delivery, wire grouping or installed parity. + +Retain native air-use predicates on the constructed Item receiver. Data- +driven replacement does not inherit a legacy projectile class by name. +Compiled throwable and legacy-trigger-on-use components stay installed +across reads that omit them. Numeric lookup selects its own receiver; +constructed stacks can retain another receiver after ID collisions. +The Java item-context bridge reads this owner rather than a raw packet +entry or the Java carrier's class. Target1.26.60.5 ComponentItem predicates +14483cc60 and14483cd40 establish the component-presence checks. + +Expose the retained persistent bundle hover flag independently of active +bundle contents. Preserve it through detail updates and supply touch and +gamepad bindings from the same input mode without changing screen layout. +Bedrock 1.26.60.5 original getter and frame controls establish the flag and +its inclusive six-second expiry; original arrow callbacks set it in touch +mode. The input owner supplies its lifetime. Transported-scene tests check +visibility, input mode and classic layout independently. These controls do +not establish physical touch input or complete screen parity. + +Keep retained selected-source images separate from the real cursor and +interactive inventory slots. The authored inventory_selected_item binding +now accepts a display snapshot tied to an existing slot and a bounded +quantity. Selected count labels retain their own rendering provenance. +Reject quantities above the current source count, and preserve cursor-only +behavior when no source image is supplied. No item request or menu mutation +belongs to this presentation contract. + +Bedrock 1.26.60.5 ui_common.json supplies the selected icon, count label and +cursor-following parent. Original target-build selected-context/count +controls retain a source and partial quantity while the cursor stays empty. +Regression coverage resolves the actual exported cooking controls, checks +selected image/count ownership, retires the image, and rejects an oversized +quantity without changing the source. These CPU controls do not establish +native pixels or physical touchscreen delivery. --- Data Asset Sources.md | 33 + build.gradle | 1 + @@ -812,9 +846,9 @@ wire grouping or installed parity. .../container/dynamic/BundleContainer.java | 2 +- .../definition/ItemDefinitions.java | 5 - .../resourcepack/ui/BundleTooltipLayout.java | 62 + - .../api/resourcepack/ui/BundleTooltipUi.java | 123 + - .../api/resourcepack/ui/ContainerUi.java | 34 +- - .../api/resourcepack/ui/CookingUi.java | 8 +- + .../api/resourcepack/ui/BundleTooltipUi.java | 137 + + .../api/resourcepack/ui/ContainerUi.java | 87 +- + .../api/resourcepack/ui/CookingUi.java | 20 +- .../viabedrock/api/util/NativeStringMap.java | 75 + .../viabedrock/protocol/BedrockProtocol.java | 3 + .../protocol/data/BedrockMappingData.java | 15 +- @@ -828,11 +862,11 @@ wire grouping or installed parity. .../protocol/model/BuiltinPotionNames.java | 62 + .../ComponentItemStackingProperties.java | 40 + .../protocol/model/ItemBlockPlacer.java | 56 + - .../model/ItemDefinitionProperties.java | 136 + + .../model/ItemDefinitionProperties.java | 145 + .../model/ItemDescriptionContext.java | 30 + .../viabedrock/protocol/model/ItemEntry.java | 10 + .../protocol/model/ItemNameContext.java | 23 + - .../protocol/model/ItemRegistry.java | 187 + + .../protocol/model/ItemRegistry.java | 195 + .../model/ItemStackingProperties.java | 36 + .../protocol/model/ItemStoragePolicy.java | 61 + .../protocol/model/ItemStorageProperties.java | 45 + @@ -847,7 +881,7 @@ wire grouping or installed parity. .../protocol/packet/NpcDialoguePackets.java | 417 +- .../protocol/rewriter/BlockStateRewriter.java | 7 + .../protocol/rewriter/ItemDataRewriter.java | 1 - - .../protocol/rewriter/ItemRewriter.java | 204 +- + .../protocol/rewriter/ItemRewriter.java | 209 +- .../protocol/storage/EntityTracker.java | 4 +- .../protocol/storage/GameSessionStorage.java | 19 + .../protocol/storage/InventoryTracker.java | 27 +- @@ -870,10 +904,12 @@ wire grouping or installed parity. .../NativeContainerTransferTest.java | 115 + .../container/NativeItemStackMatcherTest.java | 191 + .../ui/BundleTooltipLayoutTest.java | 38 + - .../resourcepack/ui/BundleTooltipUiTest.java | 189 + + .../resourcepack/ui/BundleTooltipUiTest.java | 215 + + .../api/resourcepack/ui/CookingUiTest.java | 47 +- .../resourcepack/ui/HudActionbarUiTest.java | 108 + .../api/resourcepack/ui/HudTitleUiTest.java | 176 + .../protocol/model/BuiltinItemNameTest.java | 159 + + .../model/ItemRegistryAirUseTest.java | 44 + .../model/ItemRegistryAliasesTest.java | 36 + .../model/ItemRegistryComponentNamesTest.java | 84 + .../model/ItemRegistryOrdinaryNamesTest.java | 72 + @@ -892,7 +928,7 @@ wire grouping or installed parity. .../native-bundle-tooltip-layout.json | 266 + .../inventory/native-bundle-transfers.json | 1622 ++ .../inventory/native-item-storage.json | 568 + - 100 files changed, 40441 insertions(+), 364 deletions(-) + 102 files changed, 40647 insertions(+), 376 deletions(-) create mode 100644 src/main/java/net/raphimc/viabedrock/api/chunk/blockstate/BuiltinBlockNames.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeBundleTooltipData.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeBundleTooltipItems.java @@ -950,6 +986,7 @@ wire grouping or installed parity. create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/HudActionbarUiTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/HudTitleUiTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/model/BuiltinItemNameTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/model/ItemRegistryAirUseTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/model/ItemRegistryAliasesTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/model/ItemRegistryComponentNamesTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/model/ItemRegistryOrdinaryNamesTest.java @@ -2526,10 +2563,10 @@ index 00000000..966a11ab +} diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/BundleTooltipUi.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/BundleTooltipUi.java new file mode 100644 -index 00000000..06289dc4 +index 00000000..45a2ff6d --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/BundleTooltipUi.java -@@ -0,0 +1,123 @@ +@@ -0,0 +1,137 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -2566,6 +2603,9 @@ index 00000000..06289dc4 + dimensions.add(layout.rows()); + values.put("#bundle_tooltip_grid_dimensions", dimensions); + values.put("#using_touch", new JsonPrimitive(view.inputMode == 2)); ++ values.put("#is_using_touch", new JsonPrimitive(view.inputMode == 2)); ++ values.put("#is_using_gamepad", new JsonPrimitive(view.inputMode == 3)); ++ values.put("#show_persistent_bundle_hover_text", new JsonPrimitive(view.persistentHoverText)); + values.put("#show_bundle_tooltip_hover", new JsonPrimitive(view.mode == 2 && view.inputMode != 2)); + values.put("#show_bundle_tooltip_pocket", new JsonPrimitive(view.mode == 1 && view.inputMode == 2 && !view.hidden)); + values.put("#bundle_name", new JsonPrimitive(active ? view.contents.primaryName() + (view.inputMode == 2 ? " " : "") : "Bundle")); @@ -2619,10 +2659,16 @@ index 00000000..06289dc4 + /** Modes 1 and 2 activate the retained tooltip source. Selection is a grid cell. */ + public record View(int mode, int inputMode, boolean hidden, Integer selectedCell, + NativeBundleTooltipData contents, NativeItemStorageData storage, List items, -+ List details) { ++ List details, boolean persistentHoverText) { + public View(final int mode, final int inputMode, final boolean hidden, final Integer selectedCell, + final NativeBundleTooltipData contents, final NativeItemStorageData storage, final List items) { -+ this(mode, inputMode, hidden, selectedCell, contents, storage, items, List.of()); ++ this(mode, inputMode, hidden, selectedCell, contents, storage, items, List.of(), false); ++ } ++ ++ public View(final int mode, final int inputMode, final boolean hidden, final Integer selectedCell, ++ final NativeBundleTooltipData contents, final NativeItemStorageData storage, final List items, ++ final List details) { ++ this(mode, inputMode, hidden, selectedCell, contents, storage, items, details, false); + } + + public View { @@ -2639,7 +2685,12 @@ index 00000000..06289dc4 + } + + public View withDetails(final List instances) { -+ return new View(this.mode, this.inputMode, this.hidden, this.selectedCell, this.contents, this.storage, this.items, instances); ++ return new View(this.mode, this.inputMode, this.hidden, this.selectedCell, this.contents, this.storage, this.items, instances, this.persistentHoverText); ++ } ++ ++ public View withPersistentHoverText(final boolean persistentHoverText) { ++ return new View(this.mode, this.inputMode, this.hidden, this.selectedCell, this.contents, this.storage, this.items, ++ this.details, persistentHoverText); + } + + boolean active() { @@ -2654,7 +2705,7 @@ index 00000000..06289dc4 + +} diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/ContainerUi.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/ContainerUi.java -index 76e62199..e9c09c7f 100644 +index 76e62199..cf02315e 100644 --- a/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/ContainerUi.java +++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/ContainerUi.java @@ -61,6 +61,7 @@ public final class ContainerUi { @@ -2691,15 +2742,33 @@ index 76e62199..e9c09c7f 100644 final String host = menu.kind.host; final var resolved = this.program.resolve(host, controller); if (resolved.root() == null) { -@@ -295,6 +305,7 @@ public final class ContainerUi { +@@ -259,6 +269,16 @@ public final class ContainerUi { + } + } + ++ /** A retained source image uses selected-item bindings without becoming the real cursor. */ ++ public record SelectedItem(int sourceSlot, int count, Map presentation) { ++ public SelectedItem { ++ if (sourceSlot < 0 || sourceSlot > Short.MAX_VALUE || count < 1 || count > 255) { ++ throw new IllegalArgumentException("Invalid selected inventory item"); ++ } ++ presentation = copy(presentation); ++ } ++ } ++ + public record MenuView(int menuId, Kind kind, String title, List slots, Map cursor, + List data) { + +@@ -295,6 +315,8 @@ public final class ContainerUi { private final boolean nativeActions; protected final Map collections = new HashMap<>(); protected final Map globals = new HashMap<>(); + private List itemDetails = List.of(); ++ private SelectedItem selectedItem; private final Set singletonCollections = new HashSet<>(); private final Map slots = new HashMap<>(); -@@ -323,6 +334,7 @@ public final class ContainerUi { +@@ -323,6 +345,7 @@ public final class ContainerUi { this.globals.put("$pocket_screen", new JsonPrimitive(false)); this.globals.put("#is_using_gamepad", new JsonPrimitive(false)); this.globals.put("#is_using_touch", new JsonPrimitive(false)); @@ -2707,7 +2776,7 @@ index 76e62199..e9c09c7f 100644 this.globals.put("#close_button_visible", new JsonPrimitive(true)); this.globals.putAll(cursor); final JsonObject cursorReference = new JsonObject(); -@@ -330,6 +342,13 @@ public final class ContainerUi { +@@ -330,6 +353,31 @@ public final class ContainerUi { this.globals.put("#inventory_selected_item", cursorReference); } @@ -2717,11 +2786,29 @@ index 76e62199..e9c09c7f 100644 + this.collections.put("bundle_items", BundleTooltipUi.collection(tooltip)); + return this; + } ++ ++ final Controller selectedItem(final SelectedItem selected) { ++ if (selected == null) { ++ return this; ++ } ++ final Slot source = this.slots.get(selected.sourceSlot); ++ if (source == null || !source.presentation.containsKey("#inventory_stack_count") ++ || selected.count > source.presentation.get("#inventory_stack_count").getAsInt()) { ++ throw new IllegalArgumentException("Selected inventory item differs from its source slot"); ++ } ++ this.selectedItem = selected; ++ this.globals.putAll(selected.presentation); ++ final JsonObject reference = new JsonObject(); ++ reference.addProperty("selected_slot", selected.sourceSlot); ++ this.globals.put("#inventory_selected_item", reference); ++ this.globals.put("#inventory_selected_item_stack_count", new JsonPrimitive(selected.count)); ++ return this; ++ } + protected final void registerSingleton(final String name, final int slot, final String emptyBinding) { this.collections.put(name, singleton(this.slots, slot, emptyBinding)); this.singletonCollections.add(name); -@@ -401,6 +420,9 @@ public final class ContainerUi { +@@ -401,6 +449,9 @@ public final class ContainerUi { public Map bindings(final Scope scope) { final Map values = new HashMap<>(this.globals); values.putAll(this.variables(scope)); @@ -2731,7 +2818,7 @@ index 76e62199..e9c09c7f 100644 if (scope != null && Set.of("brewing_result_items", "brewing_fuel_item").contains(scope.collection())) { final JsonElement count = values.get("#inventory_stack_count"); if (count == null || !count.isJsonPrimitive() || !count.getAsJsonPrimitive().isNumber()) { -@@ -417,6 +439,11 @@ public final class ContainerUi { +@@ -417,6 +468,11 @@ public final class ContainerUi { return ""; } @@ -2743,7 +2830,7 @@ index 76e62199..e9c09c7f 100644 @Override public void decorate(final JsonObject node, final JsonObject control, final Map resolved, final Scope scope) { if (this.nativeActions && "grid".equals(JsonUiProgram.string(node, "type")) -@@ -428,6 +455,9 @@ public final class ContainerUi { +@@ -428,6 +484,9 @@ public final class ContainerUi { node.addProperty("collection_index", scope.index()); if (this.variables(scope).get("#item_renderer_data") instanceof JsonObject reference && reference.has("java_slot")) { node.addProperty("inventory_slot", reference.get("java_slot").getAsInt()); @@ -2753,17 +2840,47 @@ index 76e62199..e9c09c7f 100644 } } else if (this.singletonCollections.contains(UiExpression.text(resolved.get("$item_collection_name")))) { final String collection = UiExpression.text(resolved.get("$item_collection_name")); -@@ -443,6 +473,8 @@ public final class ContainerUi { +@@ -435,16 +494,34 @@ public final class ContainerUi { + node.addProperty("inventory_slot", reference.get("java_slot").getAsInt()); + node.addProperty("collection", collection); + node.addProperty("collection_index", 0); +- } else if ("inventory_item_renderer".equals(JsonUiProgram.string(node, "renderer")) +- && resolved.get("#item_id_aux") instanceof JsonObject reference && reference.has("cursor")) { +- node.addProperty("inventory_cursor", true); ++ } ++ if ("inventory_item_renderer".equals(JsonUiProgram.string(node, "renderer")) ++ && resolved.get("#item_id_aux") instanceof JsonObject reference ++ && (reference.has("selected_slot") || reference.has("cursor"))) { ++ // Explicit image bindings take precedence over an enclosing collection cell. ++ node.remove("inventory_slot"); ++ node.remove("bundle_slot"); ++ node.remove("bundle_identity"); ++ if (reference.has("selected_slot")) { ++ node.addProperty("inventory_selected_slot", reference.get("selected_slot").getAsInt()); ++ } else { ++ node.addProperty("inventory_cursor", true); ++ } + } + if ("label".equals(JsonUiProgram.string(node, "type"))) { final String countSource = countSource(control, resolved); if (node.has("inventory_slot") && "#inventory_stack_count".equals(countSource)) { node.addProperty("inventory_count", "slot"); + } else if (node.has("bundle_slot") && "#inventory_stack_count".equals(countSource)) { + node.addProperty("inventory_count", "bundle"); } else if ("#inventory_selected_item_stack_count".equals(countSource)) { - node.addProperty("inventory_count", "cursor"); +- node.addProperty("inventory_count", "cursor"); ++ node.remove("inventory_slot"); ++ node.remove("bundle_slot"); ++ node.remove("bundle_identity"); ++ node.addProperty("inventory_count", this.selectedItem == null ? "cursor" : "selected"); ++ if (this.selectedItem != null) { ++ node.addProperty("inventory_selected_slot", this.selectedItem.sourceSlot); ++ } } + } + if (!node.has("button_mappings") || !node.get("button_mappings").isJsonArray()) { diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingUi.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingUi.java -index 8bd1da2a..8d51d187 100644 +index 45753c6d..750eca03 100644 --- a/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingUi.java +++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingUi.java @@ -47,6 +47,7 @@ public final class CookingUi { @@ -2774,24 +2891,45 @@ index 8bd1da2a..8d51d187 100644 exported.addProperty("cooking_family", family.name()); exported.getAsJsonObject("globals").addProperty("$container_title", title.raw()); exported.getAsJsonObject("globals").addProperty("$localize_title", title.localize()); -@@ -76,11 +77,16 @@ public final class CookingUi { +@@ -81,15 +82,25 @@ public final class CookingUi { } public JsonObject resolve(final MenuView menu) { +- return this.resolve(menu, QuantitySelection.inactive()); + return this.resolve(menu, BundleTooltipUi.View.inactive()); + } + +- public JsonObject resolve(final MenuView menu, final QuantitySelection quantity) { ++ public JsonObject resolve(final MenuView menu, final BundleTooltipUi.View tooltip) { ++ return this.resolve(menu, tooltip, QuantitySelection.inactive()); + } + -+ public JsonObject resolve(final MenuView menu, final BundleTooltipUi.View tooltip) { ++ public JsonObject resolve(final MenuView menu, final BundleTooltipUi.View tooltip, final QuantitySelection quantity) { ++ return this.resolve(menu, tooltip, quantity, null); ++ } ++ ++ public JsonObject resolve(final MenuView menu, final BundleTooltipUi.View tooltip, final QuantitySelection quantity, ++ final ContainerUi.SelectedItem selected) { if (this.family != null && menu.family != this.family) { throw new IllegalArgumentException("Cooking program belongs to another family"); } final var resolved = this.program.resolve(menu.family.host, -- new Controller(menu, this.title == null ? menu.title : this.title)); -+ new Controller(menu, this.title == null ? menu.title : this.title) -+ .bundle(tooltip, this.program.translate(BundleTooltipUi.EMPTY_DESCRIPTION))); +- new Controller(menu, this.title == null ? menu.title : this.title, quantity)); ++ new Controller(menu, this.title == null ? menu.title : this.title, quantity) ++ .bundle(tooltip, this.program.translate(BundleTooltipUi.EMPTY_DESCRIPTION)).selectedItem(selected)); if (resolved.root() == null || !"screen".equals(UiExpression.text(resolved.root().get("type"))) || !resolved.requiredDefinitions().contains(menu.family.host) || !resolved.missingTemplates().isEmpty()) { throw new IllegalArgumentException("Incomplete native cooking program"); +@@ -437,7 +448,8 @@ public final class CookingUi { + @Override + public void decorate(final JsonObject node, final JsonObject control, final Map resolved, final Scope scope) { + super.decorate(node, control, resolved, scope); +- if (scope == null || !"recipe_book".equals(scope.collection())) { ++ if (scope == null || !"recipe_book".equals(scope.collection()) || node.has("inventory_selected_slot") ++ || new JsonPrimitive(true).equals(node.get("inventory_cursor"))) { + return; + } + node.addProperty("recipe_id", this.variables(scope).get("#item_renderer_data").getAsJsonObject().get("recipe_id").getAsInt()); diff --git a/src/main/java/net/raphimc/viabedrock/api/util/NativeStringMap.java b/src/main/java/net/raphimc/viabedrock/api/util/NativeStringMap.java new file mode 100644 index 00000000..dd4f7744 @@ -4121,10 +4259,10 @@ index 00000000..455c29f9 +} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/model/ItemDefinitionProperties.java b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemDefinitionProperties.java new file mode 100644 -index 00000000..4d1d9d28 +index 00000000..5547a49e --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemDefinitionProperties.java -@@ -0,0 +1,136 @@ +@@ -0,0 +1,145 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -4156,6 +4294,7 @@ index 00000000..4d1d9d28 + private final List updates = new ArrayList<>(); + private ItemStackingProperties stacking; + private ItemStorageProperties storage = ItemStorageProperties.NONE; ++ private ItemAirUseProperties airUse = ItemAirUseProperties.NONE; + private ItemBlockPlacer blockPlacer; + private int category; + private String description; @@ -4166,6 +4305,9 @@ index 00000000..4d1d9d28 + this.kind = kind; + this.builtin = builtin; + this.block = block; ++ if (kind == Kind.BUILTIN) { ++ this.airUse = ItemAirUseProperties.builtinConstructor(identifier); ++ } + this.stacking = builtin != null ? builtin.stacking() + : kind == Kind.COMPONENT ? ComponentItemStackingProperties.DEFAULT : LegacyItemStackingProperties.DEFAULT; + this.blockPlacer = replacementBlockItem @@ -4177,6 +4319,7 @@ index 00000000..4d1d9d28 + + void read(final CompoundTag components, final Function resolveBlock) { + this.updates.add(components.copy()); ++ this.airUse = this.airUse.withComponentData(components, this.component()); + this.stacking = this.stacking.withComponentData(components); + this.storage = this.storage.withComponentData(components); + if (this.component()) { @@ -4205,6 +4348,10 @@ index 00000000..4d1d9d28 + return this.block; + } + ++ ItemAirUseProperties airUse() { ++ return this.airUse; ++ } ++ + ItemBlockPlacer blockPlacer() { + return this.blockPlacer; + } @@ -4347,10 +4494,10 @@ index 00000000..20d57645 +} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/model/ItemRegistry.java b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemRegistry.java new file mode 100644 -index 00000000..1c5d6065 +index 00000000..9612f9e3 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemRegistry.java -@@ -0,0 +1,187 @@ +@@ -0,0 +1,195 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -4453,6 +4600,14 @@ index 00000000..1c5d6065 + return definition != null ? ((ItemDefinitionProperties) definition.receiver()).stacking() : null; + } + ++ public ItemAirUseProperties airUseProperties(final NativeItemDefinitions.Definition definition) { ++ return definition != null ? ((ItemDefinitionProperties) definition.receiver()).airUse() : ItemAirUseProperties.NONE; ++ } ++ ++ public ItemAirUseProperties airUseProperties(final BedrockItem item) { ++ return this.airUseProperties(this.definition(item)); ++ } ++ + public ItemStorageProperties storageProperties(final BedrockItem item) { + final var definition = this.definition(item); + return definition != null ? ((ItemDefinitionProperties) definition.receiver()).storage() : ItemStorageProperties.NONE; @@ -5158,7 +5313,7 @@ index 00000000..5ceed231 + +} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java -index 910f6420..ea1d8d7e 100644 +index 35ab3921..4cb81aa6 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java @@ -26,14 +26,22 @@ import com.viaversion.viaversion.api.protocol.remapper.PacketHandlers; @@ -6027,10 +6182,10 @@ index 39b8a7e4..7b3138e1 100644 + } diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/JoinPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/JoinPackets.java -index 29722156..cb61ffc5 100644 +index b194d114..579f61fd 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/JoinPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/JoinPackets.java -@@ -268,6 +268,7 @@ public final class JoinPackets { +@@ -269,6 +269,7 @@ public final class JoinPackets { wrapper.read(Types.BOOLEAN); // disable custom skins final boolean emoteChatMuted = wrapper.read(Types.BOOLEAN); // mute emote chat final String vanillaVersion = wrapper.read(BedrockTypes.STRING); // vanilla version @@ -6038,7 +6193,7 @@ index 29722156..cb61ffc5 100644 wrapper.read(BedrockTypes.INT_LE); // limited world width wrapper.read(BedrockTypes.INT_LE); // limited world height wrapper.read(Types.BOOLEAN); // nether type -@@ -337,7 +338,7 @@ public final class JoinPackets { +@@ -338,7 +339,7 @@ public final class JoinPackets { } if (!inventoryServerAuthoritative) { @@ -6047,7 +6202,7 @@ index 29722156..cb61ffc5 100644 } gameSession.setBedrockVanillaVersion(version); -@@ -427,7 +428,8 @@ public final class JoinPackets { +@@ -430,7 +431,8 @@ public final class JoinPackets { ); protocol.registerClientbound(ClientboundBedrockPackets.ITEM_REGISTRY, null, wrapper -> { wrapper.cancel(); @@ -6057,7 +6212,7 @@ index 29722156..cb61ffc5 100644 ViaBedrock.getPlatform().getLogger().log(Level.WARNING, "Received ITEM_REGISTRY after item rewriter was already initialized"); return; } -@@ -435,11 +437,14 @@ public final class JoinPackets { +@@ -438,11 +440,14 @@ public final class JoinPackets { final ItemEntry[] itemEntries = wrapper.read(BedrockTypes.ITEM_ENTRY_ARRAY); // items final ItemRewriter itemRewriter = new ItemRewriter(wrapper.user(), itemEntries); wrapper.user().put(itemRewriter); @@ -6074,7 +6229,7 @@ index 29722156..cb61ffc5 100644 } for (ItemEntry itemEntry : itemEntries) { if (itemEntry.componentData() != null && itemEntry.version() == ItemVersion.DataDriven && itemRewriter.getComponentItems().contains(itemEntry.identifier())) { -@@ -562,6 +567,7 @@ public final class JoinPackets { +@@ -565,6 +570,7 @@ public final class JoinPackets { user.get(EntityTracker.class).sendNativeCustomEntitySnapshot(); user.get(PlayerInputStorage.class).markJoined(); user.get(PlayerInputStorage.class).sendSnapshot(); @@ -6650,18 +6805,18 @@ index 521161d5..a9fcc0c7 100644 writeRequest(wrapper, request); wrapper.sendToServer(BedrockProtocol.class); diff --git a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/BlockStateRewriter.java b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/BlockStateRewriter.java -index dbb7b320..1514a849 100644 +index 5516de94..5fa5bab0 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/BlockStateRewriter.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/BlockStateRewriter.java -@@ -28,6 +28,7 @@ import net.raphimc.viabedrock.api.chunk.blockstate.BlockStateSanitizer; +@@ -30,6 +30,7 @@ import net.raphimc.viabedrock.api.chunk.blockstate.BlockStateSanitizer; import net.raphimc.viabedrock.api.chunk.blockstate.BlockItemDescriptor; import net.raphimc.viabedrock.api.chunk.blockstate.BlockItemDescriptors; import net.raphimc.viabedrock.api.chunk.blockstate.BlockItemStates; +import net.raphimc.viabedrock.api.chunk.blockstate.BuiltinBlockNames; import net.raphimc.viabedrock.api.model.BedrockBlockState; + import net.raphimc.viabedrock.api.model.NativeBlockPrimaryGeometry; import net.raphimc.viabedrock.api.model.BlockState; - import net.raphimc.viabedrock.api.chunk.blockstate.BlockRegistryNames; -@@ -291,6 +292,12 @@ public class BlockStateRewriter implements StorableObject { +@@ -336,6 +337,12 @@ public class BlockStateRewriter implements StorableObject { return this.blockItemStates.resolve(identifier, metadata); } @@ -6675,10 +6830,10 @@ index dbb7b320..1514a849 100644 return this.blockItemDescriptors.resolve(this.blockItemStates.resolve(identifier, metadata)); } diff --git a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemDataRewriter.java b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemDataRewriter.java -index f9836514..1baab221 100644 +index bd4d5810..19799a9c 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemDataRewriter.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemDataRewriter.java -@@ -266,7 +266,6 @@ public final class ItemDataRewriter { +@@ -267,7 +267,6 @@ public final class ItemDataRewriter { } } @@ -6687,7 +6842,7 @@ index f9836514..1baab221 100644 && bedrockTag.get("customColor") instanceof IntTag color) { javaItem.dataContainer().set(StructuredDataKey.DYED_COLOR1_21_5, 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 d0af6440..36b2cf55 100644 +index abfd6bd4..c261dc25 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemRewriter.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemRewriter.java @@ -17,8 +17,6 @@ @@ -6699,7 +6854,7 @@ index d0af6440..36b2cf55 100644 import com.viaversion.nbt.tag.CompoundTag; import com.viaversion.nbt.tag.IntTag; import com.viaversion.nbt.tag.NumberTag; -@@ -39,16 +37,29 @@ import com.viaversion.viaversion.libs.fastutil.ints.IntSortedSet; +@@ -39,24 +37,38 @@ import com.viaversion.viaversion.libs.fastutil.ints.IntSortedSet; import com.viaversion.viaversion.util.Key; import net.raphimc.viabedrock.ViaBedrock; import net.raphimc.viabedrock.api.model.BlockState; @@ -6729,8 +6884,9 @@ index d0af6440..36b2cf55 100644 +import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ContainerEnumName; import net.raphimc.viabedrock.protocol.model.ItemCooldown; import net.raphimc.viabedrock.protocol.model.ItemAnimationData; +-import net.raphimc.viabedrock.protocol.model.ItemAirUseProperties; import net.raphimc.viabedrock.protocol.model.ItemArmorData; -@@ -56,6 +67,8 @@ import net.raphimc.viabedrock.protocol.model.ItemUseModifiers; + import net.raphimc.viabedrock.protocol.model.ItemUseModifiers; import net.raphimc.viabedrock.protocol.rewriter.item.BundleItemRewriter; import net.raphimc.viabedrock.protocol.rewriter.resourcepack.CustomAttachableResourceRewriter; import net.raphimc.viabedrock.protocol.rewriter.resourcepack.CustomItemTextureResourceRewriter; @@ -6739,7 +6895,7 @@ index d0af6440..36b2cf55 100644 import net.raphimc.viabedrock.protocol.storage.MapTracker; import net.raphimc.viabedrock.protocol.storage.ResourcePackStorage; import net.raphimc.viabedrock.protocol.types.BedrockTypes; -@@ -71,6 +84,9 @@ import java.util.logging.Level; +@@ -72,8 +84,10 @@ import java.util.logging.Level; public class ItemRewriter extends StoredObject { @@ -6747,9 +6903,11 @@ index d0af6440..36b2cf55 100644 + private int javaItemDepth; + private final Map animationData = new HashMap<>(); +- private final Map airUseProperties = new HashMap<>(); private final Map armorData = new HashMap<>(); private final Map useModifiers = new HashMap<>(); -@@ -82,7 +98,7 @@ public class ItemRewriter extends StoredObject { + +@@ -84,7 +98,7 @@ public class ItemRewriter extends StoredObject { private static final Map ITEM_NBT_REWRITERS = new HashMap<>(); @@ -6758,7 +6916,7 @@ index d0af6440..36b2cf55 100644 private final Set componentItems; private final Int2ObjectMap blockItemValidBlockStates; private final Type itemType; -@@ -101,12 +117,12 @@ public class ItemRewriter extends StoredObject { +@@ -103,12 +117,12 @@ public class ItemRewriter extends StoredObject { if (item.isEmpty()) { return ItemArmorData.NONE; } @@ -6773,7 +6931,7 @@ index d0af6440..36b2cf55 100644 if ("minecraft:shield".equals(identifier)) { // Bedrock shields use sneaking rather than ItemInUse slowdown. return ItemUseModifiers.NONE; -@@ -122,11 +138,11 @@ public class ItemRewriter extends StoredObject { +@@ -124,12 +138,11 @@ public class ItemRewriter extends StoredObject { public ItemRewriter(final UserConnection user, final ItemEntry[] itemEntries) { super(user); @@ -6784,12 +6942,13 @@ index d0af6440..36b2cf55 100644 + this.items = this.registry.ids(); + this.componentItems = this.registry.componentItems(); for (ItemEntry itemEntry : itemEntries) { +- this.airUseProperties.put(itemEntry.identifier(), ItemAirUseProperties.fromRegistration(itemEntry)); - this.items.inverse().remove(itemEntry.id()); - this.items.put(itemEntry.identifier(), itemEntry.id()); if (itemEntry.componentData() != null && itemEntry.componentData().get("components") instanceof CompoundTag components) { this.animationData.put(itemEntry.identifier(), ItemAnimationData.fromNetworkComponents(components)); -@@ -137,15 +153,11 @@ public class ItemRewriter extends StoredObject { +@@ -140,15 +153,11 @@ public class ItemRewriter extends StoredObject { this.cooldowns.put(itemEntry.identifier(), cooldown); } } @@ -6805,7 +6964,7 @@ index d0af6440..36b2cf55 100644 for (String identifier : blockItems) { IntSortedSet validBlockStates = blockStateRewriter.validBlockStates(identifier); if (validBlockStates == null) { -@@ -170,11 +182,20 @@ public class ItemRewriter extends StoredObject { +@@ -173,11 +182,20 @@ public class ItemRewriter extends StoredObject { } public Item javaItem(final BedrockItem bedrockItem) { @@ -6827,7 +6986,7 @@ index d0af6440..36b2cf55 100644 if (identifier == null) { ViaBedrock.getPlatform().getLogger().log(Level.WARNING, "Missing item identifier for id: " + bedrockItem.identifier()); return StructuredItem.empty(); -@@ -200,7 +221,8 @@ public class ItemRewriter extends StoredObject { +@@ -203,7 +221,8 @@ public class ItemRewriter extends StoredObject { final Map newBlockItemMappings = BedrockProtocol.MAPPINGS.getBedrockToJavaBlockItems().get(newIdentifier); if (newBlockItemMappings != null) { final IntSortedSet validBlockStates = this.blockItemValidBlockStates.get(bedrockItem.identifier()); @@ -6837,7 +6996,7 @@ index d0af6440..36b2cf55 100644 } else { javaItemMapping = null; } -@@ -222,13 +244,15 @@ public class ItemRewriter extends StoredObject { +@@ -225,13 +244,15 @@ public class ItemRewriter extends StoredObject { } } @@ -6854,7 +7013,7 @@ index d0af6440..36b2cf55 100644 final ResourcePackStorage resourcePackStorage = this.user().get(ResourcePackStorage.class); data.set(StructuredDataKey.ITEM_NAME, TextUtil.stringToNbt(resourcePackStorage.getTexts().get(javaItemMapping.name()))); data.set(StructuredDataKey.LORE, new Tag[]{TextUtil.stringToNbt("§7[ViaBedrock] Mapped item: " + identifier)}); -@@ -240,12 +264,14 @@ public class ItemRewriter extends StoredObject { +@@ -243,12 +264,14 @@ public class ItemRewriter extends StoredObject { final StructuredDataContainer data = ProtocolConstants.createStructuredDataContainer(); if (itemDefinition != null) { @@ -6875,7 +7034,7 @@ index d0af6440..36b2cf55 100644 } if (itemDefinition.iconComponent() != null && resourcePackStorage.isLoadedOnJavaClient()) { -@@ -254,13 +280,21 @@ public class ItemRewriter extends StoredObject { +@@ -257,13 +280,21 @@ public class ItemRewriter extends StoredObject { } else { data.set(StructuredDataKey.LORE, new Tag[]{TextUtil.stringToNbt("§7[ViaBedrock] Custom item: " + identifier)}); } @@ -6898,7 +7057,7 @@ index d0af6440..36b2cf55 100644 ItemCooldown cooldown = this.cooldowns.get(identifier); if (cooldown == null) { final ItemDefinitions.ItemDefinition definition = this.user().get(ResourcePackStorage.class).getItems().get(identifier); -@@ -277,10 +311,9 @@ public class ItemRewriter extends StoredObject { +@@ -280,10 +311,9 @@ public class ItemRewriter extends StoredObject { if (identifier.equals("minecraft:filled_map") && bedrockTag.get("map_uuid") instanceof NumberTag mapUuid) { javaItem.dataContainer().set(StructuredDataKey.MAP_ID, this.user().get(MapTracker.class).itemJavaId(mapUuid.asLong())); } @@ -6912,10 +7071,12 @@ index d0af6440..36b2cf55 100644 } } -@@ -297,6 +330,56 @@ public class ItemRewriter extends StoredObject { +@@ -300,7 +330,57 @@ public class ItemRewriter extends StoredObject { javaItem.dataContainer().set(StructuredDataKey.CUSTOM_DATA, ItemDataRewriter.withBedrockItemData(javaItem.dataContainer().get(StructuredDataKey.CUSTOM_DATA), - identifier, this.animationData.getOrDefault(identifier, ItemAnimationData.EMPTY), nativeStack)); + identifier, this.animationData.getOrDefault(identifier, ItemAnimationData.EMPTY), nativeStack, +- this.airUseProperties.getOrDefault(identifier, ItemAirUseProperties.NONE).retainsAirUse())); ++ this.registry.airUseProperties(bedrockItem).retainsAirUse())); + if (this.registry.storageProperties(bedrockItem).storage()) { + final var storageSource = new NativeItemStorageData.Source() { + @Override @@ -6969,7 +7130,7 @@ index d0af6440..36b2cf55 100644 return javaItem; } -@@ -344,6 +427,18 @@ public class ItemRewriter extends StoredObject { +@@ -348,6 +428,18 @@ public class ItemRewriter extends StoredObject { return this.javaItem(item); } @@ -6988,7 +7149,7 @@ index d0af6440..36b2cf55 100644 public Item[] javaItems(final BedrockItem[] bedrockItems) { final Item[] javaItems = new Item[bedrockItems.length]; for (int i = 0; i < bedrockItems.length; i++) { -@@ -364,14 +459,65 @@ public class ItemRewriter extends StoredObject { +@@ -368,14 +460,65 @@ public class ItemRewriter extends StoredObject { return bedrockItems; } @@ -7079,7 +7240,7 @@ index 3d7f067e..81763394 100644 this.runtimeIdToUniqueId.remove(entity.runtimeId()); this.javaIdToUniqueId.remove(entity.javaId()); diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/GameSessionStorage.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/GameSessionStorage.java -index fb054492..13672f9b 100644 +index 0c998db0..814adc90 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/GameSessionStorage.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/GameSessionStorage.java @@ -24,6 +24,7 @@ import com.viaversion.viaversion.api.connection.UserConnection; @@ -7088,17 +7249,17 @@ index fb054492..13672f9b 100644 import net.raphimc.viabedrock.protocol.model.DimensionDefinition; +import net.raphimc.viabedrock.protocol.model.BaseGameVersion; import net.raphimc.viabedrock.protocol.data.JavaRegistries; + import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.GeneratorType; import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ChatRestrictionLevel; - import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.GameType; -@@ -41,6 +42,7 @@ public class GameSessionStorage extends StoredObject { +@@ -43,6 +44,7 @@ public class GameSessionStorage extends StoredObject { private final Map bedrockDimensionDefinitions = new HashMap<>(); private final Set availableEntityIdentifiers = new HashSet<>(BedrockProtocol.MAPPINGS.getBedrockEntities().keySet()); private Semver bedrockVanillaVersion; + private BaseGameVersion bedrockBaseGameVersion = BaseGameVersion.INVALID; private boolean flatGenerator; + private int bedrockGenerator = GeneratorType.Undefined.getValue(); private int movementRewindHistorySize; - private GameType levelGameType; -@@ -51,6 +53,7 @@ public class GameSessionStorage extends StoredObject { +@@ -54,6 +56,7 @@ public class GameSessionStorage extends StoredObject { private boolean emoteChatMuted; private boolean commandsEnabled; private boolean inventoryServerAuthoritative; @@ -7106,7 +7267,7 @@ index fb054492..13672f9b 100644 private boolean blockBreakingServerAuthoritative; private boolean immutableWorld; -@@ -103,6 +106,14 @@ public class GameSessionStorage extends StoredObject { +@@ -106,6 +109,14 @@ public class GameSessionStorage extends StoredObject { this.availableEntityIdentifiers.add(entityIdentifier); } @@ -7121,7 +7282,7 @@ index fb054492..13672f9b 100644 public Semver getBedrockVanillaVersion() { return this.bedrockVanillaVersion; } -@@ -175,6 +186,14 @@ public class GameSessionStorage extends StoredObject { +@@ -194,6 +205,14 @@ public class GameSessionStorage extends StoredObject { this.commandsEnabled = commandsEnabled; } @@ -37559,10 +37720,10 @@ index 00000000..da791492 +} diff --git a/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/BundleTooltipUiTest.java b/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/BundleTooltipUiTest.java new file mode 100644 -index 00000000..342bca00 +index 00000000..b904acc4 --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/BundleTooltipUiTest.java -@@ -0,0 +1,189 @@ +@@ -0,0 +1,215 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -37737,6 +37898,32 @@ index 00000000..342bca00 + "selected_item_details_factory").getAsJsonArray("children").isEmpty()); + } + ++ @Test ++ void transportedSceneUsesInputModeAndPersistentHoverIndependentlyOfClassicLayoutAndBundleContents() { ++ final String ui = """ ++ {"namespace":"chest","small_chest_screen":{"type":"screen","controls":[ ++ {"persistent":{"type":"label","text":"Selected bundle item","bindings":[ ++ {"binding_name":"#show_persistent_bundle_hover_text","binding_name_override":"#visible"}]}}, ++ {"touch":{"type":"panel","bindings":[{"binding_name":"#is_using_touch","binding_name_override":"#visible"}]}}, ++ {"gamepad":{"type":"panel","bindings":[{"binding_name":"#is_using_gamepad","binding_name_override":"#visible"}]}}, ++ {"classic":{"type":"panel","visible":"$desktop_screen"}}]}} ++ """; ++ final var source = new ContainerUi(new JsonUiProgram(List.of(Map.of("ui/chest.json", ui.getBytes(StandardCharsets.UTF_8))))); ++ final var imported = ContainerUi.fromProgram(source.exportProgram(ContainerUi.Kind.CHEST_3_ROWS, new ContainerUi.Title("Chest", false))); ++ final var slots = IntStream.range(0, 63).mapToObj(slot -> new ContainerUi.Slot(slot, Map.of())).toList(); ++ final var menu = new ContainerUi.MenuView(7, ContainerUi.Kind.CHEST_3_ROWS, "Chest", slots, Map.of(), List.of()); ++ for (final int mode : new int[]{1, 2, 3}) { ++ final var frame = new BundleTooltipUi.View(0, mode, false, null, null, null, List.of()); ++ final var shown = imported.resolve(menu, frame.withPersistentHoverText(true).withDetails(List.of())); ++ assertTrue(node(shown, "persistent").get("visible").getAsBoolean()); ++ assertEquals(mode == 2, node(shown, "touch").get("visible").getAsBoolean()); ++ assertEquals(mode == 3, node(shown, "gamepad").get("visible").getAsBoolean()); ++ assertTrue(node(shown, "classic").get("visible").getAsBoolean()); ++ final var expired = imported.resolve(menu, frame.withDetails(List.of()).withPersistentHoverText(false)); ++ assertFalse(node(expired, "persistent").get("visible").getAsBoolean()); ++ } ++ } ++ + private static JsonObject node(final JsonObject root, final String name) { + return nodes(root.has("root") ? root.getAsJsonObject("root") : root).stream() + .filter(node -> name.equals(JsonUiProgram.string(node, "name"))).findFirst().orElseThrow(); @@ -37752,6 +37939,78 @@ index 00000000..342bca00 + } + +} +diff --git a/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingUiTest.java b/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingUiTest.java +index 0addbb43..6fbf1e09 100644 +--- a/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingUiTest.java ++++ b/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingUiTest.java +@@ -6,6 +6,7 @@ + package net.raphimc.viabedrock.api.resourcepack.ui; + + import com.viaversion.viaversion.libs.gson.JsonObject; ++import com.viaversion.viaversion.libs.gson.JsonParser; + import com.viaversion.viaversion.libs.gson.JsonArray; + import com.viaversion.viaversion.libs.gson.JsonNull; + import com.viaversion.viaversion.libs.gson.JsonElement; +@@ -29,6 +30,50 @@ import static org.junit.jupiter.api.Assertions.*; + + class CookingUiTest { + ++ @Test ++ void retainedSelectedItemUsesAuthoredIconAndCountWithoutCreatingAnotherMenuSlot() { ++ final var ui = new ResourcePackStorage(List.of()).getCookingUi(); ++ final var initial = menu(CookingUi.Family.FURNACE); ++ final var slots = initial.slots().stream().map(slot -> slot.javaSlot() == 30 ++ ? new ContainerUi.Slot(30, Map.of("#inventory_stack_count", new JsonPrimitive(9))) : slot).toList(); ++ final var view = new CookingUi.MenuView(initial.menuId(), initial.family(), initial.title(), slots, ++ Map.of("#inventory_selected_item_stack_count", new JsonPrimitive(0)), initial.progress(), initial.book()); ++ final var restored = CookingUi.fromProgram(ui.exportProgram(view)); ++ final var selected = new ContainerUi.SelectedItem(30, 3, Map.of("#hover_text", new JsonPrimitive("Selected source"))); ++ final var scene = restored.resolve(view, BundleTooltipUi.View.inactive(), CookingUi.QuantitySelection.inactive(), selected); ++ final var controls = nodes(scene.getAsJsonObject("root")); ++ final var images = controls.stream().filter(node -> node.has("inventory_selected_slot") ++ && new JsonPrimitive("inventory_item_renderer").equals(node.get("renderer"))).toList(); ++ assertFalse(images.isEmpty(), "The actual exported native selected-item controls must receive the source image"); ++ assertTrue(images.stream().allMatch(node -> node.get("inventory_selected_slot").getAsInt() == 30 ++ && !node.has("inventory_slot") && !node.has("inventory_actions") && !node.has("inventory_cursor"))); ++ final var counts = controls.stream().filter(node -> new JsonPrimitive("selected").equals(node.get("inventory_count"))).toList(); ++ assertFalse(counts.isEmpty()); ++ assertTrue(counts.stream().allMatch(node -> node.get("text").getAsString().equals("3") ++ && node.get("inventory_selected_slot").getAsInt() == 30)); ++ assertEquals(9, view.slots().get(30).presentation().get("#inventory_stack_count").getAsInt()); ++ final var cleared = nodes(restored.resolve(view).getAsJsonObject("root")); ++ assertTrue(cleared.stream().noneMatch(node -> node.has("inventory_selected_slot"))); ++ assertTrue(cleared.stream().anyMatch(node -> new JsonPrimitive(true).equals(node.get("inventory_cursor")))); ++ assertThrows(IllegalArgumentException.class, () -> restored.resolve(view, BundleTooltipUi.View.inactive(), ++ CookingUi.QuantitySelection.inactive(), new ContainerUi.SelectedItem(30, 10, Map.of()))); ++ for (final String collection : List.of("hotbar_items", "recipe_book")) { ++ final var scoped = ui.exportProgram(view); ++ scoped.getAsJsonObject("definitions").getAsJsonObject("furnace.furnace_panel").add("value", ++ JsonParser.parseString(""" ++ {"type":"panel","controls":[{"selected_grid":{"type":"grid","collection_name":"%s", ++ "grid_dimensions":[9,1],"grid_item_template":"common.inventory_selected_icon_button"}}]} ++ """.formatted(collection))); ++ final var nested = nodes(CookingUi.fromProgram(scoped).resolve(view, BundleTooltipUi.View.inactive(), ++ CookingUi.QuantitySelection.inactive(), selected).getAsJsonObject("root")); ++ assertTrue(nested.stream().anyMatch(node -> node.has("inventory_selected_slot") ++ && new JsonPrimitive("inventory_item_renderer").equals(node.get("renderer")))); ++ assertTrue(nested.stream().filter(node -> node.has("inventory_selected_slot")) ++ .noneMatch(node -> node.has("inventory_slot") || node.has("bundle_slot") || node.has("recipe_id")), ++ "An enclosing collection must not turn a selected image or count into an ordinary slot"); ++ } ++ } ++ + @Test + void cookingHelpRetainsTheFurnaceTopicAndTranslatedBodyAcrossProgramTransport() { + final var content = new InMemoryContent(); +@@ -61,7 +106,7 @@ class CookingUiTest { + final var ui = new ResourcePackStorage(List.of()).getCookingUi(); + final var view = menu(CookingUi.Family.FURNACE); + final var restored = CookingUi.fromProgram(ui.exportProgram(view)); +- final var selected = restored.resolve(view, new CookingUi.QuantitySelection(9, 3)); ++ final var selected = restored.resolve(view, BundleTooltipUi.View.inactive(), new CookingUi.QuantitySelection(9, 3)); + final var bars = nodes(selected.getAsJsonObject("root")).stream() + .filter(node -> new JsonPrimitive("progress_bar_renderer").equals(node.get("renderer"))) + .filter(node -> new JsonPrimitive(true).equals(node.get("progress_bar_visible"))).toList(); diff --git a/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/HudActionbarUiTest.java b/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/HudActionbarUiTest.java new file mode 100644 index 00000000..2aee20b8 @@ -38213,6 +38472,56 @@ index 00000000..1024026f + } + +} +diff --git a/src/test/java/net/raphimc/viabedrock/protocol/model/ItemRegistryAirUseTest.java b/src/test/java/net/raphimc/viabedrock/protocol/model/ItemRegistryAirUseTest.java +new file mode 100644 +index 00000000..42124685 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/protocol/model/ItemRegistryAirUseTest.java +@@ -0,0 +1,44 @@ ++package net.raphimc.viabedrock.protocol.model; ++ ++import com.viaversion.nbt.tag.CompoundTag; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ItemVersion; ++import org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++final class ItemRegistryAirUseTest { ++ ++ @Test ++ void nativeConstructorReplacementChangesAirContinuationWithoutFollowingTheName() { ++ final var legacy = new ItemRegistry(new ItemEntry[] { ++ new ItemEntry("minecraft:ender_pearl", 30010, false, ItemVersion.Legacy, null)}); ++ final var replaced = new ItemRegistry(new ItemEntry[] { ++ new ItemEntry("minecraft:ender_pearl", 30010, true, ItemVersion.DataDriven, null)}); ++ assertTrue(legacy.airUseProperties(new BedrockItem(30010)).retainsAirUse()); ++ assertFalse(replaced.airUseProperties(new BedrockItem(30010)).retainsAirUse()); ++ } ++ ++ @Test ++ void builtinThrowableComponentSurvivesEmptyDeclarationsButNotClassReplacement() { ++ final var builtin = new ItemRegistry(new ItemEntry[0]); ++ assertTrue(builtin.airUseProperties(builtin.definition("minecraft:snowball")).retainsAirUse()); ++ final var replaced = new ItemRegistry(new ItemEntry[] { ++ new ItemEntry("minecraft:snowball", 30012, true, ItemVersion.DataDriven, null)}); ++ assertFalse(replaced.airUseProperties(new BedrockItem(30012)).retainsAirUse()); ++ } ++ ++ @Test ++ void numericLookupUsesItsOwnDefinitionWhenNativeItemsShareAnId() { ++ final CompoundTag components = new CompoundTag(); ++ components.put("minecraft:throwable", new CompoundTag()); ++ final CompoundTag data = new CompoundTag(); ++ data.put("components", components); ++ final var registry = new ItemRegistry(new ItemEntry[] { ++ new ItemEntry("test:custom_first", 30011, true, ItemVersion.DataDriven, data), ++ new ItemEntry("test:custom_second", 30011, true, ItemVersion.DataDriven, null)}); ++ assertTrue(registry.airUseProperties(registry.definition("test:custom_first")).retainsAirUse()); ++ assertEquals("test:custom_second", registry.identifier(30011)); ++ assertFalse(registry.airUseProperties(new BedrockItem(30011)).retainsAirUse()); ++ } ++ ++} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/model/ItemRegistryAliasesTest.java b/src/test/java/net/raphimc/viabedrock/protocol/model/ItemRegistryAliasesTest.java new file mode 100644 index 00000000..a2cef843 @@ -40299,10 +40608,10 @@ index b130e7e9..f25cc793 100644 } diff --git a/src/test/java/net/raphimc/viabedrock/protocol/packet/WrittenBookUsePacketTest.java b/src/test/java/net/raphimc/viabedrock/protocol/packet/WrittenBookUsePacketTest.java -index e9f3965d..cb7447d1 100644 +index cd84f43d..74bb7e08 100644 --- a/src/test/java/net/raphimc/viabedrock/protocol/packet/WrittenBookUsePacketTest.java +++ b/src/test/java/net/raphimc/viabedrock/protocol/packet/WrittenBookUsePacketTest.java -@@ -53,6 +53,7 @@ class WrittenBookUsePacketTest { +@@ -55,6 +55,7 @@ class WrittenBookUsePacketTest { final var identifiers = HashBiMap.create(); identifiers.put("minecraft:written_book", held.identifier()); when(items.getItems()).thenReturn(identifiers); diff --git a/patches/viabedrock/upstreamable/0095-integrate-authored-native-ui-presentation.pr.md b/patches/viabedrock/upstreamable/0095-integrate-authored-native-ui-presentation.pr.md index b6a8d29f..b41e6909 100644 --- a/patches/viabedrock/upstreamable/0095-integrate-authored-native-ui-presentation.pr.md +++ b/patches/viabedrock/upstreamable/0095-integrate-authored-native-ui-presentation.pr.md @@ -14,6 +14,10 @@ Prerequisites: - viabedrock/0054-render-native-block-state-geometry-and-materials.patch: Land canonical block auxiliary descriptors and restriction lookup before native subtype-aware merge checks. +- viabedrock/0078-preserve-native-identifiers-in-java-item-data.patch: Provide immutable native item predicates and their Java item-context transport. + +Native item predicates belong to the constructed receiver. Built-in Snowballs retain their constructor-installed throwable component when declarations omit it. Data-driven replacement of that name starts from its new class. Numeric lookup and retained definitions remain separate during item-ID collisions. `ItemRegistryAirUseTest` covers these boundaries. + ## Validation and remaining limits Regression coverage includes `NativeItemNamesTest`, `NativeItemStackingTest`, `NativeContainerLegacyActionsTest`, `NativeContainerTransferTest`. @@ -35,6 +39,11 @@ The full stack supplies descriptor binding and request execution in [deferred pa Packet, menu-lifetime, and action tests cover supported adapters. Complete installed player/cooking controllers and live native parity remain unfinished. +Selected-item presentation retains an immutable source/count snapshot independently of the real cursor. +Explicit selected-item bindings take precedence over enclosing slot and recipe collections. +Scene regressions check the original selected icon and count controls, source retirement, and oversized quantities. +Installed selected-source validation and its limits are recorded in the [cooking reference](../../../docs/native-cooking-actions.md#validation). + Action-form dialog labels use the accepted pack translator, as the title and body already do. `ModalFormTransitionTest` covers translated and literal buttons without authored JSON UI. Native Bedrock 1.26.60.5 and BDS 1.26.60.6 establish the expected text with the same English language pack. 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 409bd836..ed901aee 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 61029b133c68d4b916990d2b58cdd74489be35d8 Mon Sep 17 00:00:00 2001 +From c4bd95540e33fd3726973877ff4e6d8ea32327bb 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 @@ -567,6 +567,73 @@ Pinned Bedrock 1.26.60.5/protocol2225 original factory prefix141917070 and compl Initialize collection_details event properties even without binding_name. Keep named collection lookup, zero fallback, prefixed source fields and unnamed collection maps in the owning control bag. Export final bound values rather than the original property_bag declarations so subsequent view/global updates reach input consumers. Do not inherit a parent control event bag merely through expression scope. Bedrock 1.26.60.5/protocol 2225 executable SHA-256 988f6a61403b4381aa41e6c66d64cce441b14e561a9b10f647c2758ef3f2672a supplies the original binding-type parser1400f7750, collection-details initialization14191e310, ancestor index lookup141c42d50, enumeration141c6b230 and map callback141932700. Fifteen native controls execute those paths and original JSON setters; eight parser controls establish collection_details=3. Named misses resolve to zero; unnamed maps retain outer values for repeated names, and no scopes produce null. Source getter14500e920 reads the typed bag value. Authored parsing and installed controller input remain separate validation gates. + +Expose optional cooking query and acknowledgment contracts beside the +shared cooking snapshots. Retain raw text and bind every query to its +menu opening, published revisions, and request sequence. Bound text and +encoded lengths and reject trailing fields or non-boolean result flags. +Backend state, filtering, and request execution remain separate. + +Retain binding-looking strings as literal property-bag data. Resolve UI +variables and expressions separately from runtime binding updates, and +preserve the string identity of text_box_name. Otherwise a native search +editor's property_field loses its item_name key or substitutes label +text before the input callback reads it. Binding updates still replace +authored values through the explicit bindings list. + +The original Bedrock 1.26.60.5 property-bag reader calls its property-value +resolver for each direct member. Six original-instruction controls keep +hash-prefixed strings intact, including a dollar-variable result. The +variable provider and CRT/allocator services are declared boundaries. +Real exported furnace, smoker, and blast-furnace editor regressions keep +the property key, box identity and current search text through transport. +This does not establish complete native cooking input or screen parity. + +The clear button follows raw text, including whitespace; ready cooking +frames keep the editor enabled. + +Current Bedrock 1.26.60.5 layout callbacks retain Inventory tab 5 when +leaving Inventory with retention enabled, independently of the selected +recipe category. Preserve that distinction in the shared transition API. +Expose the layout value separately so client profile changes need no +synthetic controller-local saved-tab state. + +Execute both original callbacks across 384 forced-layout, retention, tab, +layout and inline/heap hover controls. The downstream settings refresh is +an observed boundary. Current registration tables connect the callbacks +to their authored layout action hashes. This does not establish complete +cooking-controller or installed screen parity. + +Transport native held-quantity source ownership separately from recipe input. +START retains its exact opening and displayed frame. COMPLETE carries an +explicit quantity for the same source, and RETIRE ends transport ownership +without another item operation. Admission results retain their original +action identity even when preceding inventory replies delay the phase. +Versioned channels keep older clients on their existing input contract. +Codec regressions preserve the source binding and reject invalid quantities, +slots and trailing data. Inventory mutation remains in its request owner. + +Expose desktop held-quantity progress with the retained source total and +selected item count, independently of inventory mutation. Inactive selection +hides the authored progress control without replacing touch visibility policy. +Original Bedrock 1.26.60.5 getters establish the count formula and source +presence across forty controlled cases; item lookup and input services are +supplied boundaries. A real exported program regression retains these values +through authored binding resolution. Complete screen parity remains separate. + +Share authored Help export and topic resolution between player and cooking +controllers. Official Bedrock 1.26.60.5 registers one cooking Help callback +for furnace, smoker, and blast furnace; it opens encyclopedia topic 31 +(Furnace). Physical Back and Escape controls return to the retained menu. +Keep Help resources within the existing cooking payload bound. + +Bind selected-source cooking commands to all six fields of the displayed +snapshot and the shared request sequence. Carry the ordinary source slot, +first destination and quantity budget independently of the HUD cursor. +Expose bounded action/result codecs for tap admission, drawing admission, +and terminal retirement of the original drawing action. A retirement may +survive newer item frames but cannot choose another source or budget. +The transfer and input owners remain separate from these shared contracts. --- build.gradle | 11 + .../api/model/NativeContainerAction.java | 93 + @@ -576,8 +643,14 @@ Bedrock 1.26.60.5/protocol 2225 executable SHA-256 988f6a61403b4381aa41e6c66d64c .../api/model/NativeCookingAction.java | 96 + .../api/model/NativeCookingActionResult.java | 43 + .../api/model/NativeCookingControl.java | 73 + + .../api/model/NativeCookingHoldAction.java | 79 + + .../api/model/NativeCookingHoldResult.java | 41 + .../api/model/NativeCookingMessage.java | 304 +++ .../api/model/NativeCookingProgress.java | 55 + + .../api/model/NativeCookingQuery.java | 62 + + .../api/model/NativeCookingQueryResult.java | 41 + + .../api/model/NativeCookingSourceAction.java | 84 + + .../api/model/NativeCookingSourceResult.java | 43 + .../api/model/NativeCookingStatus.java | 41 + .../api/model/NativeFormMessage.java | 56 + .../api/model/NativeHudMessage.java | 60 + @@ -589,18 +662,19 @@ Bedrock 1.26.60.5/protocol 2225 executable SHA-256 988f6a61403b4381aa41e6c66d64c .../http/ConvertedResourcePackCache.java | 6 +- .../api/resourcepack/ui/BrewingStandUi.java | 44 + .../api/resourcepack/ui/ContainerUi.java | 508 +++++ - .../resourcepack/ui/CookingNavigation.java | 83 + + .../resourcepack/ui/CookingNavigation.java | 91 + .../resourcepack/ui/CookingRecipeOrder.java | 62 + .../resourcepack/ui/CookingSearchText.java | 45 + - .../api/resourcepack/ui/CookingUi.java | 433 ++++ + .../api/resourcepack/ui/CookingUi.java | 460 +++++ .../api/resourcepack/ui/FormBindings.java | 290 +++ + .../api/resourcepack/ui/HelpUi.java | 174 ++ .../api/resourcepack/ui/HudActionbarUi.java | 20 + .../api/resourcepack/ui/HudFactoryUi.java | 132 ++ .../api/resourcepack/ui/HudRootUi.java | 35 + .../api/resourcepack/ui/HudTitleUi.java | 28 + .../resourcepack/ui/InventoryItemFilter.java | 43 + .../resourcepack/ui/InventoryStackCount.java | 21 + - .../api/resourcepack/ui/JsonUiProgram.java | 1827 +++++++++++++++++ + .../api/resourcepack/ui/JsonUiProgram.java | 1832 +++++++++++++++++ .../resourcepack/ui/NativeRecipeSearch.java | 124 ++ .../ui/NativeSearchNormalization.java | 189 ++ .../api/resourcepack/ui/NpcDialogueUi.java | 316 +++ @@ -631,8 +705,11 @@ Bedrock 1.26.60.5/protocol 2225 executable SHA-256 988f6a61403b4381aa41e6c66d64c .../model/NativeCookingActionResultTest.java | 63 + .../api/model/NativeCookingActionTest.java | 170 ++ .../api/model/NativeCookingControlTest.java | 99 + + .../model/NativeCookingHoldActionTest.java | 42 + .../api/model/NativeCookingMessageTest.java | 424 ++++ .../api/model/NativeCookingProgressTest.java | 38 + + .../api/model/NativeCookingQueryTest.java | 52 + + .../model/NativeCookingSourceActionTest.java | 72 + .../api/model/NativeFormMessageTest.java | 51 + .../api/model/NativeHudMessageTest.java | 38 + .../api/model/NativeItemHoverTextTest.java | 56 + @@ -641,15 +718,15 @@ Bedrock 1.26.60.5/protocol 2225 executable SHA-256 988f6a61403b4381aa41e6c66d64c .../api/model/NativeUiHurtStateTest.java | 81 + .../resourcepack/ui/BrewingStandUiTest.java | 49 + .../api/resourcepack/ui/ContainerUiTest.java | 429 ++++ - .../ui/CookingNavigationTest.java | 87 + + .../ui/CookingNavigationTest.java | 115 ++ .../resourcepack/ui/CookingProgressTest.java | 47 + .../ui/CookingRecipeOrderTest.java | 82 + .../ui/CookingSearchTextTest.java | 90 + - .../api/resourcepack/ui/CookingUiTest.java | 646 ++++++ + .../api/resourcepack/ui/CookingUiTest.java | 731 +++++++ .../ui/FormCollectionActivationTest.java | 97 + .../api/resourcepack/ui/HudRootUiTest.java | 90 + .../ui/InventoryItemFilterTest.java | 33 + - .../resourcepack/ui/JsonUiProgramTest.java | 803 ++++++++ + .../resourcepack/ui/JsonUiProgramTest.java | 835 ++++++++ .../ui/NativeRecipeSearchTest.java | 110 + .../ui/NativeSearchNormalizationTest.java | 46 + .../resourcepack/ui/NpcDialogueUiTest.java | 108 + @@ -668,7 +745,7 @@ Bedrock 1.26.60.5/protocol 2225 executable SHA-256 988f6a61403b4381aa41e6c66d64c .../api/resourcepack/ui/UiTexturesTest.java | 231 +++ .../ui/native-inventory-item-filter.json | 311 +++ .../generator/SearchUnicodeDataGenerator.java | 126 ++ - 100 files changed, 15821 insertions(+), 60 deletions(-) + 110 files changed, 16696 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 @@ -676,8 +753,14 @@ Bedrock 1.26.60.5/protocol 2225 executable SHA-256 988f6a61403b4381aa41e6c66d64c create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeCookingAction.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeCookingActionResult.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeCookingControl.java + create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeCookingHoldAction.java + create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeCookingHoldResult.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeCookingMessage.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeCookingProgress.java + create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeCookingQuery.java + create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeCookingQueryResult.java + create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeCookingSourceAction.java + create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeCookingSourceResult.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeCookingStatus.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeFormMessage.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeHudMessage.java @@ -693,6 +776,7 @@ Bedrock 1.26.60.5/protocol 2225 executable SHA-256 988f6a61403b4381aa41e6c66d64c create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingSearchText.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingUi.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/FormBindings.java + create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/HelpUi.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/HudActionbarUi.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/HudFactoryUi.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/HudRootUi.java @@ -726,8 +810,11 @@ Bedrock 1.26.60.5/protocol 2225 executable SHA-256 988f6a61403b4381aa41e6c66d64c create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/NativeCookingActionResultTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/NativeCookingActionTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/NativeCookingControlTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/NativeCookingHoldActionTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/NativeCookingMessageTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/NativeCookingProgressTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/NativeCookingQueryTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/NativeCookingSourceActionTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/NativeFormMessageTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/NativeHudMessageTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/NativeItemHoverTextTest.java @@ -1372,6 +1459,138 @@ index 00000000..0ab33dd5 + } + +} +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/NativeCookingHoldAction.java b/src/main/java/net/raphimc/viabedrock/api/model/NativeCookingHoldAction.java +new file mode 100644 +index 00000000..bbbb7962 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/model/NativeCookingHoldAction.java +@@ -0,0 +1,79 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.model; ++ ++import com.viaversion.viaversion.api.type.Type; ++import com.viaversion.viaversion.api.type.Types; ++import io.netty.buffer.ByteBuf; ++import net.raphimc.viabedrock.protocol.data.ProtocolConstants; ++ ++/** Retain the starting frame while a held source returns its cursor and completes a partial pickup. */ ++public record NativeCookingHoldAction(NativeCookingControl source, int slot, Phase phase, ++ int quantity, long requestSequence) { ++ ++ public static final String CHANNEL = "viabedrock:native_cooking_hold_v1"; ++ public static final int MAX_ENCODED_BYTES = 96; ++ public static final Type TYPE = new Type<>(NativeCookingHoldAction.class) { ++ @Override ++ public NativeCookingHoldAction read(final ByteBuf buffer) { ++ if (buffer.readableBytes() > MAX_ENCODED_BYTES || buffer.readUnsignedByte() != 1 ++ || Types.VAR_INT.readPrimitive(buffer) != ProtocolConstants.BEDROCK_PROTOCOL_VERSION) { ++ throw new IllegalArgumentException("Unsupported native cooking hold"); ++ } ++ final var source = new NativeCookingControl(Types.VAR_INT.readPrimitive(buffer), ++ Types.VAR_LONG.readPrimitive(buffer), Types.VAR_LONG.readPrimitive(buffer), ++ Types.VAR_LONG.readPrimitive(buffer), Types.VAR_LONG.readPrimitive(buffer), ++ Types.VAR_LONG.readPrimitive(buffer), NativeCookingControl.Mode.ACTIVATE); ++ final int slot = Types.VAR_INT.readPrimitive(buffer); ++ final int phase = buffer.readUnsignedByte(); ++ if (phase >= Phase.values().length) { ++ throw new IllegalArgumentException("Unknown native cooking hold phase"); ++ } ++ final var action = new NativeCookingHoldAction(source, slot, Phase.values()[phase], ++ Types.VAR_INT.readPrimitive(buffer), Types.VAR_LONG.readPrimitive(buffer)); ++ if (buffer.isReadable()) { ++ throw new IllegalArgumentException("Trailing native cooking hold fields"); ++ } ++ return action; ++ } ++ ++ @Override ++ public void write(final ByteBuf buffer, final NativeCookingHoldAction action) { ++ buffer.writeByte(1); ++ Types.VAR_INT.writePrimitive(buffer, ProtocolConstants.BEDROCK_PROTOCOL_VERSION); ++ final var source = action.source; ++ Types.VAR_INT.writePrimitive(buffer, source.menuId()); ++ Types.VAR_LONG.writePrimitive(buffer, source.sequence()); ++ Types.VAR_LONG.writePrimitive(buffer, source.updateSequence()); ++ Types.VAR_LONG.writePrimitive(buffer, source.menuRevision()); ++ Types.VAR_LONG.writePrimitive(buffer, source.inventoryRevision()); ++ Types.VAR_LONG.writePrimitive(buffer, source.cursorRevision()); ++ Types.VAR_INT.writePrimitive(buffer, action.slot); ++ buffer.writeByte(action.phase.ordinal()); ++ Types.VAR_INT.writePrimitive(buffer, action.quantity); ++ Types.VAR_LONG.writePrimitive(buffer, action.requestSequence); ++ } ++ }; ++ ++ public NativeCookingHoldAction { ++ if (source == null || source.mode() != NativeCookingControl.Mode.ACTIVATE || slot < 0 || slot >= 39 ++ || phase == null || requestSequence <= 0 ++ || (phase != Phase.COMPLETE ? quantity != 0 : quantity < 1 || quantity > 255)) { ++ throw new IllegalArgumentException("Invalid native cooking hold"); ++ } ++ } ++ ++ public boolean continues(final NativeCookingHoldAction start) { ++ return start != null && start.phase == Phase.START && this.phase != Phase.START ++ && this.source.equals(start.source) && this.slot == start.slot ++ && this.requestSequence > start.requestSequence; ++ } ++ ++ public enum Phase { ++ START, COMPLETE, RETIRE ++ } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/NativeCookingHoldResult.java b/src/main/java/net/raphimc/viabedrock/api/model/NativeCookingHoldResult.java +new file mode 100644 +index 00000000..00bb8b1c +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/model/NativeCookingHoldResult.java +@@ -0,0 +1,41 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.model; ++ ++import com.viaversion.viaversion.api.type.Type; ++import io.netty.buffer.ByteBuf; ++import java.util.Objects; ++ ++/** An admission result for one hold phase, including phases delayed behind inventory replies. */ ++public record NativeCookingHoldResult(NativeCookingHoldAction action, boolean accepted) { ++ ++ public static final String CHANNEL = "viabedrock:native_cooking_hold_result_v1"; ++ public static final Type TYPE = new Type<>(NativeCookingHoldResult.class) { ++ @Override ++ public NativeCookingHoldResult read(final ByteBuf buffer) { ++ if (buffer.readableBytes() < 2 || buffer.readableBytes() > NativeCookingHoldAction.MAX_ENCODED_BYTES + 1) { ++ throw new IllegalArgumentException("Invalid native cooking hold result length"); ++ } ++ final var action = NativeCookingHoldAction.TYPE.read(buffer.readSlice(buffer.readableBytes() - 1)); ++ final int accepted = buffer.readUnsignedByte(); ++ if (accepted > 1) { ++ throw new IllegalArgumentException("Invalid native cooking hold result flag"); ++ } ++ return new NativeCookingHoldResult(action, accepted == 1); ++ } ++ ++ @Override ++ public void write(final ByteBuf buffer, final NativeCookingHoldResult result) { ++ NativeCookingHoldAction.TYPE.write(buffer, result.action); ++ buffer.writeBoolean(result.accepted); ++ } ++ }; ++ ++ public NativeCookingHoldResult { ++ Objects.requireNonNull(action); ++ } ++ ++} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/NativeCookingMessage.java b/src/main/java/net/raphimc/viabedrock/api/model/NativeCookingMessage.java new file mode 100644 index 00000000..b40a8a90 @@ -1743,6 +1962,260 @@ index 00000000..027dfed4 + } + +} +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/NativeCookingQuery.java b/src/main/java/net/raphimc/viabedrock/api/model/NativeCookingQuery.java +new file mode 100644 +index 00000000..4baf4e05 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/model/NativeCookingQuery.java +@@ -0,0 +1,62 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.model; ++ ++import com.viaversion.viaversion.api.type.Type; ++import com.viaversion.viaversion.api.type.Types; ++import com.viaversion.viaversion.api.type.types.StringType; ++import io.netty.buffer.ByteBuf; ++import java.util.Objects; ++ ++/** Raw cooking search input belongs to one displayed opening and snapshot. */ ++public record NativeCookingQuery(NativeCookingControl source, long requestSequence, String text) { ++ ++ public static final String CHANNEL = "viabedrock:native_cooking_query_v1"; ++ public static final int MAX_TEXT_LENGTH = 8192; ++ static final int MAX_ENCODED_BYTES = MAX_TEXT_LENGTH * 3 + 80; ++ private static final StringType TEXT = new StringType(MAX_TEXT_LENGTH); ++ public static final Type TYPE = new Type<>(NativeCookingQuery.class) { ++ @Override ++ public NativeCookingQuery read(final ByteBuf buffer) { ++ if (buffer.readableBytes() > MAX_ENCODED_BYTES) { ++ throw new IllegalArgumentException("Invalid native cooking query length"); ++ } ++ final int sourceLength = buffer.readUnsignedByte(); ++ if (sourceLength == 0 || sourceLength > 64 || sourceLength > buffer.readableBytes()) { ++ throw new IllegalArgumentException("Invalid native cooking query source"); ++ } ++ final var query = new NativeCookingQuery(NativeCookingControl.TYPE.read(buffer.readSlice(sourceLength)), ++ Types.VAR_LONG.readPrimitive(buffer), TEXT.read(buffer)); ++ if (buffer.isReadable()) { ++ throw new IllegalArgumentException("Trailing native cooking query fields"); ++ } ++ return query; ++ } ++ ++ @Override ++ public void write(final ByteBuf buffer, final NativeCookingQuery query) { ++ final int lengthIndex = buffer.writerIndex(); ++ buffer.writeByte(0); ++ NativeCookingControl.TYPE.write(buffer, query.source); ++ buffer.setByte(lengthIndex, buffer.writerIndex() - lengthIndex - 1); ++ Types.VAR_LONG.writePrimitive(buffer, query.requestSequence); ++ TEXT.write(buffer, query.text); ++ } ++ }; ++ ++ public NativeCookingQuery { ++ Objects.requireNonNull(source); ++ Objects.requireNonNull(text); ++ if (source.mode() != NativeCookingControl.Mode.ACTIVATE || requestSequence <= 0 || text.length() > MAX_TEXT_LENGTH) { ++ throw new IllegalArgumentException("Invalid native cooking query"); ++ } ++ } ++ ++ public static NativeCookingQuery forMessage(final NativeCookingMessage message, final long requestSequence, final String text) { ++ return new NativeCookingQuery(NativeCookingControl.forMessage(message, NativeCookingControl.Mode.ACTIVATE), requestSequence, text); ++ } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/NativeCookingQueryResult.java b/src/main/java/net/raphimc/viabedrock/api/model/NativeCookingQueryResult.java +new file mode 100644 +index 00000000..4bd2b6e5 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/model/NativeCookingQueryResult.java +@@ -0,0 +1,41 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.model; ++ ++import com.viaversion.viaversion.api.type.Type; ++import io.netty.buffer.ByteBuf; ++import java.util.Objects; ++ ++/** An acknowledgment retires only the exact edit, independently of later text and snapshots. */ ++public record NativeCookingQueryResult(NativeCookingQuery query, boolean accepted) { ++ ++ public static final String CHANNEL = "viabedrock:native_cooking_query_result_v1"; ++ public static final Type TYPE = new Type<>(NativeCookingQueryResult.class) { ++ @Override ++ public NativeCookingQueryResult read(final ByteBuf buffer) { ++ if (buffer.readableBytes() < 2 || buffer.readableBytes() > NativeCookingQuery.MAX_ENCODED_BYTES + 1) { ++ throw new IllegalArgumentException("Invalid native cooking query result length"); ++ } ++ final var query = NativeCookingQuery.TYPE.read(buffer.readSlice(buffer.readableBytes() - 1)); ++ final int accepted = buffer.readUnsignedByte(); ++ if (accepted > 1) { ++ throw new IllegalArgumentException("Invalid native cooking query result flag"); ++ } ++ return new NativeCookingQueryResult(query, accepted == 1); ++ } ++ ++ @Override ++ public void write(final ByteBuf buffer, final NativeCookingQueryResult result) { ++ NativeCookingQuery.TYPE.write(buffer, result.query); ++ buffer.writeBoolean(result.accepted); ++ } ++ }; ++ ++ public NativeCookingQueryResult { ++ Objects.requireNonNull(query); ++ } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/NativeCookingSourceAction.java b/src/main/java/net/raphimc/viabedrock/api/model/NativeCookingSourceAction.java +new file mode 100644 +index 00000000..e8bac555 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/model/NativeCookingSourceAction.java +@@ -0,0 +1,84 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.model; ++ ++import com.viaversion.viaversion.api.type.Type; ++import com.viaversion.viaversion.api.type.Types; ++import io.netty.buffer.ByteBuf; ++import net.raphimc.viabedrock.protocol.data.ProtocolConstants; ++ ++/** Start a selected-source transfer from the exact cooking frame displayed by the client. */ ++public record NativeCookingSourceAction(NativeCookingControl source, int sourceSlot, int destinationSlot, ++ int quantity, Kind kind, long requestSequence) { ++ ++ public static final String CHANNEL = "viabedrock:native_cooking_source_v1"; ++ public static final int MAX_ENCODED_BYTES = 104; ++ public static final Type TYPE = new Type<>(NativeCookingSourceAction.class) { ++ @Override ++ public NativeCookingSourceAction read(final ByteBuf buffer) { ++ if (buffer.readableBytes() > MAX_ENCODED_BYTES || buffer.readUnsignedByte() != 1 ++ || Types.VAR_INT.readPrimitive(buffer) != ProtocolConstants.BEDROCK_PROTOCOL_VERSION) { ++ throw new IllegalArgumentException("Unsupported native cooking source action"); ++ } ++ final var source = new NativeCookingControl(Types.VAR_INT.readPrimitive(buffer), ++ Types.VAR_LONG.readPrimitive(buffer), Types.VAR_LONG.readPrimitive(buffer), ++ Types.VAR_LONG.readPrimitive(buffer), Types.VAR_LONG.readPrimitive(buffer), ++ Types.VAR_LONG.readPrimitive(buffer), NativeCookingControl.Mode.ACTIVATE); ++ final int sourceSlot = Types.VAR_INT.readPrimitive(buffer); ++ final int destinationSlot = Types.VAR_INT.readPrimitive(buffer); ++ final int quantity = Types.VAR_INT.readPrimitive(buffer); ++ final int kind = buffer.readUnsignedByte(); ++ if (kind >= Kind.values().length) { ++ throw new IllegalArgumentException("Unknown native cooking source action"); ++ } ++ final var action = new NativeCookingSourceAction(source, sourceSlot, destinationSlot, quantity, ++ Kind.values()[kind], Types.VAR_LONG.readPrimitive(buffer)); ++ if (buffer.isReadable()) { ++ throw new IllegalArgumentException("Trailing native cooking source action fields"); ++ } ++ return action; ++ } ++ ++ @Override ++ public void write(final ByteBuf buffer, final NativeCookingSourceAction action) { ++ buffer.writeByte(1); ++ Types.VAR_INT.writePrimitive(buffer, ProtocolConstants.BEDROCK_PROTOCOL_VERSION); ++ final var source = action.source; ++ Types.VAR_INT.writePrimitive(buffer, source.menuId()); ++ Types.VAR_LONG.writePrimitive(buffer, source.sequence()); ++ Types.VAR_LONG.writePrimitive(buffer, source.updateSequence()); ++ Types.VAR_LONG.writePrimitive(buffer, source.menuRevision()); ++ Types.VAR_LONG.writePrimitive(buffer, source.inventoryRevision()); ++ Types.VAR_LONG.writePrimitive(buffer, source.cursorRevision()); ++ Types.VAR_INT.writePrimitive(buffer, action.sourceSlot); ++ Types.VAR_INT.writePrimitive(buffer, action.destinationSlot); ++ Types.VAR_INT.writePrimitive(buffer, action.quantity); ++ buffer.writeByte(action.kind.ordinal()); ++ Types.VAR_LONG.writePrimitive(buffer, action.requestSequence); ++ } ++ }; ++ ++ public NativeCookingSourceAction { ++ if (source == null || source.mode() != NativeCookingControl.Mode.ACTIVATE ++ || sourceSlot < 0 || sourceSlot >= 39 || destinationSlot < 0 || destinationSlot >= 39 ++ || sourceSlot == destinationSlot || quantity < 1 || quantity > 255 || kind == null || requestSequence <= 0) { ++ throw new IllegalArgumentException("Invalid native cooking source action"); ++ } ++ } ++ ++ public boolean continues(final NativeCookingSourceAction start) { ++ return start != null && start.kind == Kind.DRAW && this.kind == Kind.RETIRE ++ && this.source.equals(start.source) && this.sourceSlot == start.sourceSlot ++ && this.destinationSlot == start.destinationSlot && this.quantity == start.quantity ++ && this.requestSequence > start.requestSequence; ++ } ++ ++ /** DRAW admits the first destination; RETIRE safely ends its original queued operation. */ ++ public enum Kind { ++ DRAW, TAP, RETIRE ++ } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/NativeCookingSourceResult.java b/src/main/java/net/raphimc/viabedrock/api/model/NativeCookingSourceResult.java +new file mode 100644 +index 00000000..d7fdd2c8 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/model/NativeCookingSourceResult.java +@@ -0,0 +1,43 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.model; ++ ++import com.viaversion.viaversion.api.type.Type; ++import io.netty.buffer.ByteBuf; ++import java.util.Objects; ++ ++/** A source action's admission and whether its original item authority remains current. */ ++public record NativeCookingSourceResult(NativeCookingSourceAction action, boolean accepted, boolean current) { ++ ++ public static final String CHANNEL = "viabedrock:native_cooking_source_result_v1"; ++ public static final Type TYPE = new Type<>(NativeCookingSourceResult.class) { ++ @Override ++ public NativeCookingSourceResult read(final ByteBuf buffer) { ++ if (buffer.readableBytes() < 3 || buffer.readableBytes() > NativeCookingSourceAction.MAX_ENCODED_BYTES + 2) { ++ throw new IllegalArgumentException("Invalid native cooking source result length"); ++ } ++ final var action = NativeCookingSourceAction.TYPE.read(buffer.readSlice(buffer.readableBytes() - 2)); ++ final int accepted = buffer.readUnsignedByte(); ++ final int current = buffer.readUnsignedByte(); ++ if (accepted > 1 || current > 1) { ++ throw new IllegalArgumentException("Invalid native cooking source result flags"); ++ } ++ return new NativeCookingSourceResult(action, accepted == 1, current == 1); ++ } ++ ++ @Override ++ public void write(final ByteBuf buffer, final NativeCookingSourceResult result) { ++ NativeCookingSourceAction.TYPE.write(buffer, result.action); ++ buffer.writeBoolean(result.accepted); ++ buffer.writeBoolean(result.current); ++ } ++ }; ++ ++ public NativeCookingSourceResult { ++ Objects.requireNonNull(action); ++ } ++ ++} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/NativeCookingStatus.java b/src/main/java/net/raphimc/viabedrock/api/model/NativeCookingStatus.java new file mode 100644 index 00000000..3c751742 @@ -2884,10 +3357,10 @@ index 00000000..76e62199 +} diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingNavigation.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingNavigation.java new file mode 100644 -index 00000000..3a96be76 +index 00000000..f02f5c20 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingNavigation.java -@@ -0,0 +1,83 @@ +@@ -0,0 +1,91 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -2956,10 +3429,18 @@ index 00000000..3a96be76 + if (forcedRecipeBook) { + return new LayoutTransition(current, savedTab, false, false); + } ++ // Native retains Inventory (tab 5), independently of the selected recipe category. ++ return new LayoutTransition(layout(false, current, direction), current == 1 && rememberInventory ? 5 : savedTab, true, true); ++ } ++ ++ public static int layout(final boolean forcedRecipeBook, final int current, final Direction direction) { ++ Objects.requireNonNull(direction); ++ if (forcedRecipeBook) { ++ return current; ++ } + final boolean advance = direction == Direction.NEXT ? current >= 0 : current >= 2; + final int candidate = current + (direction == Direction.NEXT ? 1 : -1); -+ final int layout = advance ? Integer.compareUnsigned(candidate, 3) < 0 ? candidate : 1 : 2; -+ return new LayoutTransition(layout, current == 1 && rememberInventory ? tab : savedTab, true, true); ++ return advance ? Integer.compareUnsigned(candidate, 3) < 0 ? candidate : 1 : 2; + } + + public enum Direction { @@ -3092,10 +3573,10 @@ index 00000000..00df0b13 +} diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingUi.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingUi.java new file mode 100644 -index 00000000..8bd1da2a +index 00000000..45753c6d --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingUi.java -@@ -0,0 +1,433 @@ +@@ -0,0 +1,460 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -3141,7 +3622,7 @@ index 00000000..8bd1da2a + final JsonObject controller = new JsonObject(); + controller.addProperty("$container_title", title.raw()); + controller.addProperty("#tab_label_text", menu.book.tabLabel); -+ final JsonObject exported = this.program.export(Set.of(family.host, "furnace.furnace_panel"), controller); ++ final JsonObject exported = HelpUi.exportProgram(this.program, Set.of(family.host, "furnace.furnace_panel"), controller); + if (!exported.getAsJsonObject("definitions").has(family.host)) { + throw new IllegalArgumentException("Missing native cooking host"); + } @@ -3169,16 +3650,25 @@ index 00000000..8bd1da2a + return new CookingUi(ui, selected, new ContainerUi.Title(title.getAsString(), localize.getAsBoolean())); + } + ++ /** All three native cooking controllers open the Furnace encyclopedia topic. */ ++ public JsonObject help(final int selected) { ++ return HelpUi.resolve(this.program, true, selected); ++ } ++ + private static boolean isString(final JsonElement value) { + return value != null && value.isJsonPrimitive() && value.getAsJsonPrimitive().isString(); + } + + public JsonObject resolve(final MenuView menu) { ++ return this.resolve(menu, QuantitySelection.inactive()); ++ } ++ ++ public JsonObject resolve(final MenuView menu, final QuantitySelection quantity) { + if (this.family != null && menu.family != this.family) { + throw new IllegalArgumentException("Cooking program belongs to another family"); + } + final var resolved = this.program.resolve(menu.family.host, -+ new Controller(menu, this.title == null ? menu.title : this.title)); ++ new Controller(menu, this.title == null ? menu.title : this.title, quantity)); + if (resolved.root() == null || !"screen".equals(UiExpression.text(resolved.root().get("type"))) + || !resolved.requiredDefinitions().contains(menu.family.host) || !resolved.missingTemplates().isEmpty()) { + throw new IllegalArgumentException("Incomplete native cooking program"); @@ -3300,6 +3790,19 @@ index 00000000..8bd1da2a + return value.getAsBoolean(); + } + ++ /** Desktop held-quantity bindings retain the original source count until completion. */ ++ public record QuantitySelection(int total, int current) { ++ public QuantitySelection { ++ if (total < 0 || total > 255 || current < 0 || current > total || total > 0 && current == 0) { ++ throw new IllegalArgumentException("Invalid native quantity selection"); ++ } ++ } ++ ++ public static QuantitySelection inactive() { ++ return new QuantitySelection(0, 0); ++ } ++ } ++ + public record RecipeActionScope(Map> actions) { + + public RecipeActionScope { @@ -3423,7 +3926,7 @@ index 00000000..8bd1da2a + private static final Set RECIPE_ACTIONS = Set.of("button.recipe_select", "button.recipe_secondary", "button.recipe_tertiary"); + private final Selection selection; + -+ private Controller(final MenuView menu, final ContainerUi.Title title) { ++ private Controller(final MenuView menu, final ContainerUi.Title title, final QuantitySelection quantity) { + super(3, menu.slots, menu.cursor, title, true); + this.selection = menu.book.selection; + this.globals.put("$food_index", new JsonPrimitive(1)); @@ -3453,6 +3956,8 @@ index 00000000..8bd1da2a + this.globals.put("#recipe_book_total_items", new JsonPrimitive(recipes.size())); + this.globals.put("#tab_label_text", new JsonPrimitive(menu.book.tabLabel)); + this.globals.put("#text_box_item_name", new JsonPrimitive(menu.book.searchText)); ++ this.globals.put("#show_clear_text_button", new JsonPrimitive(!menu.book.searchText.isEmpty())); ++ this.globals.put("#text_box_enabled", new JsonPrimitive(true)); + this.globals.put("#filtering_enabled", new JsonPrimitive(menu.book.filtering)); + this.globals.put("#is_survival_layout", new JsonPrimitive(menu.book.effectiveLayout() == 1 && !menu.book.forcedRecipeBookLayout)); + this.globals.put("#is_recipe_book_layout", new JsonPrimitive(menu.book.effectiveLayout() == 2 || menu.book.forcedRecipeBookLayout)); @@ -3474,6 +3979,9 @@ index 00000000..8bd1da2a + this.globals.put("#food_tab_offset", offset(0, verticalTabs)); + this.globals.put("#items_tab_offset", offset(food ? 0 : -tabSpacing, verticalTabs)); + this.globals.put("#blocks_tab_offset", offset(((food ? -1 : -2) + (items ? 1 : 0)) * tabSpacing, verticalTabs)); ++ this.globals.put("#progress_bar_visible", new JsonPrimitive(quantity.total > 0)); ++ this.globals.put("#progress_bar_total_amount", new JsonPrimitive(quantity.total)); ++ this.globals.put("#progress_bar_current_amount", new JsonPrimitive(quantity.current)); + this.globals.put("#furnace_arrow_ratio", new JsonPrimitive(menu.progress.arrowRatio(menu.family))); + this.globals.put("#furnace_flame_ratio", new JsonPrimitive(menu.progress.flameRatio(menu.family))); + } @@ -3825,6 +4333,186 @@ index 00000000..e889f9d1 + } + +} +diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/HelpUi.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/HelpUi.java +new file mode 100644 +index 00000000..8107dc02 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/HelpUi.java +@@ -0,0 +1,174 @@ ++/* ++ * 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 com.viaversion.viaversion.libs.gson.JsonArray; ++import com.viaversion.viaversion.libs.gson.JsonElement; ++import com.viaversion.viaversion.libs.gson.JsonObject; ++import com.viaversion.viaversion.libs.gson.JsonPrimitive; ++ ++import java.util.HashSet; ++import java.util.List; ++import java.util.Map; ++import java.util.Set; ++ ++/** Authored Help resources are shared by inventory controllers. */ ++final class HelpUi { ++ ++ private HelpUi() { ++ } ++ ++ static JsonObject exportProgram(final JsonUiProgram program, final Set hosts, final JsonObject globals) { ++ final Set roots = new HashSet<>(hosts); ++ roots.add("how_to_play.how_to_play_screen"); ++ roots.add("encyclopedia.encyclopedia_screen"); ++ final JsonObject result = program.export(roots, globals); ++ exportTranslations(program, result.get("definitions"), result.getAsJsonObject("translations")); ++ result.getAsJsonObject("translations").addProperty("howtoplay.encyclopedia.title", program.translate("howtoplay.encyclopedia.title")); ++ return result; ++ } ++ ++ private static void exportTranslations(final JsonUiProgram program, final JsonElement value, final JsonObject translations) { ++ if (value.isJsonArray()) { ++ for (final var child : value.getAsJsonArray()) { ++ exportTranslations(program, child, translations); ++ } ++ } else if (value.isJsonObject()) { ++ for (final var entry : value.getAsJsonObject().entrySet()) { ++ final var child = entry.getValue(); ++ if (Set.of("$section_topic", "$category").contains(entry.getKey()) && child.isJsonPrimitive() && child.getAsJsonPrimitive().isString()) { ++ final String name = child.getAsString(); ++ if (!name.isEmpty() && !name.startsWith("$") && !name.startsWith("(")) { ++ final String key = (entry.getKey().equals("$section_topic") ? "howtoplay." : "howtoplay.category.") + name; ++ translations.addProperty(key, program.translate(key)); ++ if (entry.getKey().equals("$section_topic")) { ++ translations.addProperty(key + ".title", program.translate(key + ".title")); ++ } ++ } ++ } ++ exportTranslations(program, child, translations); ++ } ++ } ++ } ++ ++ /** Resolve the actual guide topics without replacing the inventory's owner or source collections. */ ++ static JsonObject resolve(final JsonUiProgram program, final boolean encyclopedia, final int selected) { ++ if (selected < 0) { ++ throw new IllegalArgumentException("Invalid native Help topic"); ++ } ++ final String host = encyclopedia ? "encyclopedia.encyclopedia_screen" : "how_to_play.how_to_play_screen"; ++ final var bindings = new HelpBindings(program, encyclopedia, selected, ""); ++ program.resolve(host, bindings); ++ if (bindings.title == null) { ++ throw new IllegalArgumentException("Missing authored Help topic"); ++ } ++ final var resolved = program.resolve(host, new HelpBindings(program, encyclopedia, selected, bindings.title)); ++ if (resolved.root() == null || !resolved.missingTemplates().isEmpty()) { ++ throw new IllegalArgumentException("Incomplete authored Help scene"); ++ } ++ final JsonObject scene = new JsonObject(); ++ scene.addProperty("format", 1); ++ scene.add("root", resolved.root()); ++ scene.add("missing_templates", new JsonArray()); ++ return scene; ++ } ++ ++ private static final class HelpBindings implements UiBindings { ++ ++ private final JsonUiProgram program; ++ private final int selected; ++ private final boolean encyclopedia; ++ private final Map globals; ++ private String title; ++ ++ private HelpBindings(final JsonUiProgram program, final boolean encyclopedia, final int selected, final String title) { ++ this.program = program; ++ this.encyclopedia = encyclopedia; ++ this.selected = selected; ++ this.globals = Map.of( ++ "#keyboard_being_used", new JsonPrimitive(true), "#gamepad_being_used", new JsonPrimitive(false), ++ "#touch_being_used", new JsonPrimitive(false), "#help_selected_index", new JsonPrimitive(selected), ++ "$toggle_binding_type", new JsonPrimitive("global"), ++ "$toggle_state_binding_name", new JsonPrimitive("($toggle_group_forced_index = #help_selected_index)"), ++ "#section_title", new JsonPrimitive(title)); ++ } ++ ++ @Override ++ public Map globals() { ++ return this.globals; ++ } ++ ++ @Override ++ public String defaultCollection() { ++ return ""; ++ } ++ ++ @Override ++ public boolean ownsCollection(final String name) { ++ return false; ++ } ++ ++ @Override ++ public Collection collection(final String name, final Scope parent) { ++ throw new IllegalArgumentException("Unsupported Help collection " + name); ++ } ++ ++ @Override ++ public Map variables(final Scope scope) { ++ return this.globals; ++ } ++ ++ @Override ++ public Map bindings(final Scope scope) { ++ return this.globals; ++ } ++ ++ @Override ++ public String factoryKey() { ++ return ""; ++ } ++ ++ @Override ++ public void decorate(final JsonObject node, final JsonObject control, final Map resolved, final Scope scope) { ++ if (JsonUiProgram.string(node, "type").equals("toggle") && node.has("toggle_group_forced_index")) { ++ final int index = node.get("toggle_group_forced_index").getAsInt(); ++ node.addProperty("help_topic", index); ++ if (index == this.selected && resolved.containsKey("$section_topic")) { ++ final String key = "howtoplay." + resolved.get("$section_topic").getAsString(); ++ this.title = this.encyclopedia ++ ? this.program.translate("howtoplay.encyclopedia.title") + ": " + this.program.translate(key) ++ : this.program.translate(key + ".title"); ++ } ++ } ++ final String text = JsonUiProgram.string(node, "text"); ++ if (text.startsWith("#pre_process_")) { ++ final String translated = this.program.translate(text.substring("#pre_process_".length())); ++ node.addProperty("text", translated); ++ node.add("text_component", this.program.textComponent(translated)); ++ node.addProperty("localize", false); ++ } ++ if (node.has("button_mappings")) { ++ for (final var value : node.getAsJsonArray("button_mappings")) { ++ final var mapping = value.getAsJsonObject(); ++ for (final String field : List.of("from_button_id", "to_button_id", "mapping_type")) { ++ if (mapping.has(field)) { ++ mapping.addProperty(field, UiExpression.text(JsonUiProgram.resolveValue(mapping.get(field), resolved, 0))); ++ } ++ } ++ if (mapping.has("ignored")) { ++ mapping.addProperty("ignored", UiExpression.truth(JsonUiProgram.resolveValue(mapping.get("ignored"), resolved, 0))); ++ } ++ if (JsonUiProgram.string(node, "type").equals("button") && JsonUiProgram.string(mapping, "to_button_id").equals("button.menu_exit")) { ++ node.addProperty("help_close", true); ++ node.addProperty("action", 0); ++ } ++ } ++ } ++ } ++ ++ } ++ ++} diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/HudActionbarUi.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/HudActionbarUi.java new file mode 100644 index 00000000..79ec855f @@ -4142,10 +4830,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..36db9013 +index 00000000..cfe91f93 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/JsonUiProgram.java -@@ -0,0 +1,1827 @@ +@@ -0,0 +1,1832 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -4860,13 +5548,18 @@ index 00000000..36db9013 + } + + static JsonElement resolveValue(final JsonElement value, final Map values, final int depth) { ++ return resolveValue(value, values, depth, false); ++ } ++ ++ /** Property data expands variables and expressions but retains binding names and aggregate values. */ ++ private static JsonElement resolveValue(final JsonElement value, final Map values, final int depth, final boolean propertyData) { + if (depth > 32) { + throw new IllegalArgumentException("Recursive UI variable"); + } + if (value == null || value.isJsonNull()) { + return JsonNull.INSTANCE; + } -+ if (value.isJsonArray()) { ++ if (value.isJsonArray() && !propertyData) { + final JsonArray result = new JsonArray(); + for (final JsonElement entry : value.getAsJsonArray()) { + result.add(resolveValue(entry, values, depth + 1)); @@ -4877,7 +5570,7 @@ index 00000000..36db9013 + return value.deepCopy(); + } + final String text = value.getAsString(); -+ if (text.startsWith("$") || text.startsWith("#")) { ++ if (text.startsWith("$") || !propertyData && text.startsWith("#")) { + final JsonElement source = values.containsKey(text) ? values.get(text) : values.get(text + "|default"); + if (source == null) { + return JsonNull.INSTANCE; @@ -4888,7 +5581,7 @@ index 00000000..36db9013 + if (source == value) { + throw new IllegalArgumentException("Recursive UI variable " + text); + } -+ return resolveValue(source, values, depth + 1); ++ return resolveValue(source, values, depth + 1, propertyData); + } + if (text.startsWith("(")) { + return UiExpression.evaluate(text, key -> resolveValue(new JsonPrimitive(key), values, depth + 1)); @@ -5236,7 +5929,7 @@ index 00000000..36db9013 + if (entry.getKey().startsWith("$") || Set.of("bindings", "variables", "controls", "factory", "property_bag", "modifications").contains(entry.getKey())) { + continue; + } -+ node.add(entry.getKey(), resolveValue(entry.getValue(), values, 0)); ++ node.add(entry.getKey(), resolveValue(entry.getValue(), values, 0, entry.getKey().equals("text_box_name"))); + } + for (final String property : List.of("text", "hover_text", "texture", "texture_file_system", "visible", "enabled", "alpha", "clip_ratio", "collection_length", "maximum_grid_items", "toggle_state", "slider_value", "slider_steps", "slider_text", "text_edit_box_text", "item_id_aux", "touch_progress_bar_visible", "progress_bar_visible", "progress_bar_total_amount", "progress_bar_current_amount", "disabled_filter_visible", "filtered_light_multiplier")) { + if (values.containsKey("#" + property) && (bound.contains("#" + property) @@ -5853,10 +6546,10 @@ index 00000000..36db9013 + + private Set bind(final JsonObject control, final Map values, final UiBindings.Scope scope) { + final Set bound = new HashSet<>(); -+ final JsonElement propertyBag = resolveValue(control.get("property_bag"), values, 0); ++ final JsonElement propertyBag = resolveValue(control.get("property_bag"), values, 0, true); + if (propertyBag.isJsonObject()) { + for (final Map.Entry entry : propertyBag.getAsJsonObject().entrySet()) { -+ values.put(entry.getKey(), resolveValue(entry.getValue(), values, 0)); ++ values.put(entry.getKey(), resolveValue(entry.getValue(), values, 0, true)); + bound.add(entry.getKey()); + } + } @@ -9673,7 +10366,7 @@ index 00000000..20278d84 + +} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java -index b8ba20f5..9c4b8cec 100644 +index efe4871a..8a8c6f74 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java @@ -36,6 +36,7 @@ import com.viaversion.viaversion.api.type.types.version.VersionedTypes; @@ -9684,7 +10377,7 @@ index b8ba20f5..9c4b8cec 100644 import net.raphimc.viabedrock.api.model.NativeActorMessage; import net.raphimc.viabedrock.api.model.ActorMotionCommand; import net.raphimc.viabedrock.api.model.MovementCorrectionTiming; -@@ -494,6 +495,14 @@ public final class EntityPackets { +@@ -493,6 +494,14 @@ public final class EntityPackets { wrapper.cancel(); return; } @@ -11391,6 +12084,54 @@ index 00000000..83cedd24 + } + +} +diff --git a/src/test/java/net/raphimc/viabedrock/api/model/NativeCookingHoldActionTest.java b/src/test/java/net/raphimc/viabedrock/api/model/NativeCookingHoldActionTest.java +new file mode 100644 +index 00000000..9c7e2d23 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/api/model/NativeCookingHoldActionTest.java +@@ -0,0 +1,42 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.model; ++ ++import io.netty.buffer.Unpooled; ++import org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++class NativeCookingHoldActionTest { ++ ++ @Test ++ void completionRetainsItsStartingFrameAndRejectsTrailingOrInvalidWireData() { ++ final var source = new NativeCookingControl(5, 7, 8, 9, 10, 11, NativeCookingControl.Mode.ACTIVATE); ++ final var start = new NativeCookingHoldAction(source, 30, NativeCookingHoldAction.Phase.START, 0, 12); ++ final var complete = new NativeCookingHoldAction(source, 30, NativeCookingHoldAction.Phase.COMPLETE, 3, 13); ++ final var buffer = Unpooled.buffer(); ++ try { ++ NativeCookingHoldAction.TYPE.write(buffer, complete); ++ assertEquals(complete, NativeCookingHoldAction.TYPE.read(buffer)); ++ assertTrue(complete.continues(start)); ++ buffer.clear(); ++ NativeCookingHoldResult.TYPE.write(buffer, new NativeCookingHoldResult(complete, true)); ++ assertEquals(new NativeCookingHoldResult(complete, true), NativeCookingHoldResult.TYPE.read(buffer)); ++ buffer.clear(); ++ NativeCookingHoldAction.TYPE.write(buffer, complete); ++ buffer.writeByte(0); ++ assertThrows(IllegalArgumentException.class, () -> NativeCookingHoldAction.TYPE.read(buffer)); ++ assertThrows(IllegalArgumentException.class, () -> new NativeCookingHoldAction(source, 39, ++ NativeCookingHoldAction.Phase.START, 0, 1)); ++ assertThrows(IllegalArgumentException.class, () -> new NativeCookingHoldAction(source, 0, ++ NativeCookingHoldAction.Phase.COMPLETE, 0, 1)); ++ assertFalse(new NativeCookingHoldAction(source, 29, NativeCookingHoldAction.Phase.COMPLETE, 3, 13).continues(start)); ++ } finally { ++ buffer.release(); ++ } ++ } ++ ++} diff --git a/src/test/java/net/raphimc/viabedrock/api/model/NativeCookingMessageTest.java b/src/test/java/net/raphimc/viabedrock/api/model/NativeCookingMessageTest.java new file mode 100644 index 00000000..f2ee7e84 @@ -11865,6 +12606,142 @@ index 00000000..f289608e + } + +} +diff --git a/src/test/java/net/raphimc/viabedrock/api/model/NativeCookingQueryTest.java b/src/test/java/net/raphimc/viabedrock/api/model/NativeCookingQueryTest.java +new file mode 100644 +index 00000000..ca9f4609 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/api/model/NativeCookingQueryTest.java +@@ -0,0 +1,52 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.model; ++ ++import io.netty.buffer.Unpooled; ++import org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++class NativeCookingQueryTest { ++ ++ @Test ++ void roundTripPreservesOpeningRevisionsAndRawSearchText() { ++ final var source = new NativeCookingControl(255, 11, 12, 13, 14, 15, NativeCookingControl.Mode.ACTIVATE); ++ for (final String text : new String[]{"", "STONE ", "é\0猫", "猫".repeat(NativeCookingQuery.MAX_TEXT_LENGTH)}) { ++ final var result = new NativeCookingQueryResult(new NativeCookingQuery(source, 27, text), true); ++ final var buffer = Unpooled.buffer(); ++ try { ++ NativeCookingQueryResult.TYPE.write(buffer, result); ++ assertEquals(result, NativeCookingQueryResult.TYPE.read(buffer)); ++ assertFalse(buffer.isReadable()); ++ } finally { ++ buffer.release(); ++ } ++ } ++ } ++ ++ @Test ++ void malformedSourceAndTrailingFieldsCannotBecomeAnEdit() { ++ final var source = new NativeCookingControl(5, 1, 2, 3, 4, 5, NativeCookingControl.Mode.ACTIVATE); ++ assertThrows(IllegalArgumentException.class, () -> new NativeCookingQuery(source, 0, "")); ++ assertThrows(IllegalArgumentException.class, () -> new NativeCookingQuery(source, 1, "x".repeat(NativeCookingQuery.MAX_TEXT_LENGTH + 1))); ++ final var buffer = Unpooled.buffer(); ++ try { ++ NativeCookingQuery.TYPE.write(buffer, new NativeCookingQuery(source, 1, "stone")); ++ buffer.writeByte(0); ++ assertThrows(IllegalArgumentException.class, () -> NativeCookingQuery.TYPE.read(buffer)); ++ buffer.clear().writeByte(65).writeZero(65); ++ assertThrows(IllegalArgumentException.class, () -> NativeCookingQuery.TYPE.read(buffer)); ++ buffer.clear(); ++ NativeCookingQueryResult.TYPE.write(buffer, new NativeCookingQueryResult(new NativeCookingQuery(source, 1, ""), true)); ++ buffer.setByte(buffer.writerIndex() - 1, 2); ++ assertThrows(IllegalArgumentException.class, () -> NativeCookingQueryResult.TYPE.read(buffer)); ++ } finally { ++ buffer.release(); ++ } ++ } ++ ++} +diff --git a/src/test/java/net/raphimc/viabedrock/api/model/NativeCookingSourceActionTest.java b/src/test/java/net/raphimc/viabedrock/api/model/NativeCookingSourceActionTest.java +new file mode 100644 +index 00000000..b6c42585 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/api/model/NativeCookingSourceActionTest.java +@@ -0,0 +1,72 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.model; ++ ++import io.netty.buffer.Unpooled; ++import org.junit.jupiter.api.Test; ++import org.junit.jupiter.params.ParameterizedTest; ++import org.junit.jupiter.params.provider.EnumSource; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++class NativeCookingSourceActionTest { ++ ++ private static final NativeCookingControl SOURCE = new NativeCookingControl(7, 11, 12, 13, 14, 15, ++ NativeCookingControl.Mode.ACTIVATE); ++ ++ @ParameterizedTest ++ @EnumSource(NativeCookingSourceAction.Kind.class) ++ void sourceActionAndAdmissionRetainEverySnapshotFieldAndTransferBudget(final NativeCookingSourceAction.Kind kind) { ++ final var action = new NativeCookingSourceAction(SOURCE, 0, 38, 4, kind, 16); ++ final var bytes = Unpooled.buffer(); ++ try { ++ NativeCookingSourceAction.TYPE.write(bytes, action); ++ assertEquals(action, NativeCookingSourceAction.TYPE.read(bytes)); ++ assertFalse(bytes.isReadable()); ++ bytes.clear(); ++ final var result = new NativeCookingSourceResult(action, true, false); ++ NativeCookingSourceResult.TYPE.write(bytes, result); ++ assertEquals(result, NativeCookingSourceResult.TYPE.read(bytes)); ++ assertFalse(bytes.isReadable()); ++ } finally { ++ bytes.release(); ++ } ++ } ++ ++ @Test ++ void rejectsTrailingCommandsAndNonBooleanAdmissionFlags() { ++ final var action = new NativeCookingSourceAction(SOURCE, 0, 3, 9, NativeCookingSourceAction.Kind.DRAW, 1); ++ final var bytes = Unpooled.buffer(); ++ try { ++ NativeCookingSourceAction.TYPE.write(bytes, action); ++ bytes.writeByte(0); ++ assertThrows(IllegalArgumentException.class, () -> NativeCookingSourceAction.TYPE.read(bytes)); ++ bytes.clear(); ++ NativeCookingSourceResult.TYPE.write(bytes, new NativeCookingSourceResult(action, true, true)); ++ bytes.setByte(bytes.writerIndex() - 1, 2); ++ assertThrows(IllegalArgumentException.class, () -> NativeCookingSourceResult.TYPE.read(bytes)); ++ } finally { ++ bytes.release(); ++ } ++ } ++ ++ @Test ++ void rejectsMissingBudgetAndSlotsOutsideThePublishedMenu() { ++ assertThrows(IllegalArgumentException.class, () -> new NativeCookingSourceAction(SOURCE, 0, 3, 0, NativeCookingSourceAction.Kind.DRAW, 1)); ++ assertThrows(IllegalArgumentException.class, () -> new NativeCookingSourceAction(SOURCE, 0, 39, 9, NativeCookingSourceAction.Kind.DRAW, 1)); ++ assertThrows(IllegalArgumentException.class, () -> new NativeCookingSourceAction(SOURCE, 0, 0, 9, NativeCookingSourceAction.Kind.TAP, 1)); ++ } ++ ++ @Test ++ void retirementCannotChooseAnotherSourceFrameOrBudget() { ++ final var start = new NativeCookingSourceAction(SOURCE, 0, 4, 9, NativeCookingSourceAction.Kind.DRAW, 1); ++ assertTrue(new NativeCookingSourceAction(SOURCE, 0, 4, 9, NativeCookingSourceAction.Kind.RETIRE, 2).continues(start)); ++ final var newer = new NativeCookingControl(7, 11, 13, 13, 14, 15, NativeCookingControl.Mode.ACTIVATE); ++ assertFalse(new NativeCookingSourceAction(newer, 0, 4, 9, NativeCookingSourceAction.Kind.RETIRE, 2).continues(start)); ++ assertFalse(new NativeCookingSourceAction(SOURCE, 0, 4, 4, NativeCookingSourceAction.Kind.RETIRE, 2).continues(start)); ++ } ++ ++} diff --git a/src/test/java/net/raphimc/viabedrock/api/model/NativeFormMessageTest.java b/src/test/java/net/raphimc/viabedrock/api/model/NativeFormMessageTest.java new file mode 100644 index 00000000..29e1c757 @@ -12801,10 +13678,10 @@ index 00000000..24333fa6 +} diff --git a/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingNavigationTest.java b/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingNavigationTest.java new file mode 100644 -index 00000000..00b24f79 +index 00000000..457aadba --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingNavigationTest.java -@@ -0,0 +1,87 @@ +@@ -0,0 +1,115 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -12845,6 +13722,34 @@ index 00000000..00b24f79 + } + + @Test ++ void leavingInventoryRemembersItsTabIndependentlyOfTheSelectedCategory() { ++ for (final var direction : CookingNavigation.Direction.values()) { ++ assertEquals(5, CookingNavigation.moveLayout(false, 1, 2, 3, true, direction).savedTab()); ++ assertEquals(3, CookingNavigation.moveLayout(false, 1, 2, 3, false, direction).savedTab()); ++ } ++ } ++ ++ @Test ++ void layoutTransitionsMatchCurrentNativeCallbacks() throws Exception { ++ final String fixture = System.getenv("STACKANVIL_NATIVE_COOKING_LAYOUT_2225"); ++ assumeTrue(fixture != null && Files.isRegularFile(Path.of(fixture))); ++ final var reference = JsonParser.parseString(Files.readString(Path.of(fixture))).getAsJsonObject(); ++ assertEquals("1.26.60.5", reference.get("version").getAsString()); ++ assertEquals(2225, reference.get("protocol").getAsInt()); ++ assertEquals(384, reference.getAsJsonArray("rows").size()); ++ for (final var element : reference.getAsJsonArray("rows")) { ++ final var row = element.getAsJsonObject(); ++ final var actual = CookingNavigation.moveLayout(row.get("forced").getAsBoolean(), row.get("layout").getAsInt(), ++ row.get("tab").getAsInt(), 3, row.get("rememberInventory").getAsBoolean(), row.get("delta").getAsInt() > 0 ++ ? CookingNavigation.Direction.NEXT : CookingNavigation.Direction.PREVIOUS); ++ assertEquals(row.get("nextLayout").getAsInt(), actual.layout(), row.toString()); ++ assertEquals(row.get("savedTab").getAsInt(), actual.savedTab(), row.toString()); ++ assertEquals(row.get("hoverLength").getAsInt() == 0, actual.clearHover(), row.toString()); ++ assertEquals(row.get("refreshes").getAsInt() != 0, actual.refresh(), row.toString()); ++ } ++ } ++ ++ @Test + void categoryAvailabilityAndForcedLayoutRetainNativeNavigationOrder() { + assertEquals(List.of(2, 4), CookingNavigation.tabs(false, 0, 2, 0)); + assertEquals(List.of(4, 2, 5), CookingNavigation.tabs(true, 0, 2, 0)); @@ -13131,10 +14036,10 @@ index 00000000..b6077600 +} diff --git a/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingUiTest.java b/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingUiTest.java new file mode 100644 -index 00000000..8af320fa +index 00000000..0addbb43 --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingUiTest.java -@@ -0,0 +1,646 @@ +@@ -0,0 +1,731 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -13167,6 +14072,91 @@ index 00000000..8af320fa +class CookingUiTest { + + @Test ++ void cookingHelpRetainsTheFurnaceTopicAndTranslatedBodyAcrossProgramTransport() { ++ final var content = new InMemoryContent(); ++ content.putString("manifest.json", "{\"format_version\":2,\"header\":{\"uuid\":\"" + UUID.randomUUID() ++ + "\",\"version\":[1,0,0],\"name\":\"Help test\"}}"); ++ content.putString("texts/en_US.lang", "howtoplay.encyclopedia.title=Encyclopedia guide\nhowtoplay.furnace=Furnace guide\nhowtoplay.furnace.text.1=Cook with retained ingredients.\n"); ++ final var ui = new ResourcePackStorage(List.of(new ResourcePack(content))).getCookingUi(); ++ for (final var family : CookingUi.Family.values()) { ++ final var exported = ui.exportProgram(menu(family)); ++ assertTrue(exported.toString().getBytes(StandardCharsets.UTF_8).length <= JsonUiProgram.MAX_PAYLOAD_BYTES); ++ final var restored = CookingUi.fromProgram(exported); ++ final var scene = restored.help(31); ++ assertEquals(ui.help(31), scene); ++ final var controls = nodes(scene.getAsJsonObject("root")); ++ final var selected = controls.stream().filter(node -> node.has("help_topic") && node.get("toggle_state").getAsBoolean()).toList(); ++ assertEquals(1, selected.size()); ++ assertEquals(31, selected.getFirst().get("help_topic").getAsInt()); ++ assertTrue(controls.stream().anyMatch(node -> node.has("help_close"))); ++ assertTrue(controls.stream().anyMatch(node -> node.has("text") ++ && node.get("text").getAsString().equals("Encyclopedia guide: Furnace guide"))); ++ assertTrue(controls.stream().anyMatch(node -> node.has("text") ++ && node.get("text").getAsString().equals("Cook with retained ingredients."))); ++ assertThrows(IllegalArgumentException.class, () -> restored.help(-1)); ++ assertThrows(IllegalArgumentException.class, () -> restored.help(10000)); ++ } ++ } ++ ++ @Test ++ void quantityProgressRetainsSourceCountAndClearsWhenTheGestureEnds() { ++ final var ui = new ResourcePackStorage(List.of()).getCookingUi(); ++ final var view = menu(CookingUi.Family.FURNACE); ++ final var restored = CookingUi.fromProgram(ui.exportProgram(view)); ++ final var selected = restored.resolve(view, new CookingUi.QuantitySelection(9, 3)); ++ final var bars = nodes(selected.getAsJsonObject("root")).stream() ++ .filter(node -> new JsonPrimitive("progress_bar_renderer").equals(node.get("renderer"))) ++ .filter(node -> new JsonPrimitive(true).equals(node.get("progress_bar_visible"))).toList(); ++ assertFalse(bars.isEmpty()); ++ assertTrue(bars.stream().anyMatch(node -> new JsonPrimitive(9).equals(node.get("progress_bar_total_amount")) ++ && new JsonPrimitive(3).equals(node.get("progress_bar_current_amount")))); ++ final var inactive = restored.resolve(view); ++ assertTrue(nodes(inactive.getAsJsonObject("root")).stream() ++ .filter(node -> new JsonPrimitive("progress_bar_renderer").equals(node.get("renderer"))) ++ .noneMatch(node -> new JsonPrimitive(true).equals(node.get("progress_bar_visible")))); ++ } ++ ++ @Test ++ void cookingSearchClearButtonFollowsRawTextIncludingWhitespace() { ++ final var ui = new ResourcePackStorage(List.of()).getCookingUi(); ++ for (final String query : List.of("", "iron", " ")) { ++ final var initial = menu(CookingUi.Family.FURNACE); ++ final var source = initial.book(); ++ final var book = new CookingUi.Book(source.layout(), source.forcedRecipeBookLayout(), 4, source.filtering(), ++ source.foodCount(), source.itemCount(), source.blockCount(), source.tabLabel(), query, ++ source.selection(), source.catalog(), source.visibleRecipes()); ++ final var view = new CookingUi.MenuView(initial.menuId(), initial.family(), initial.title(), initial.slots(), initial.cursor(), initial.progress(), book); ++ final var scene = CookingUi.fromProgram(ui.exportProgram(view)).resolve(view); ++ final var controls = nodes(scene.getAsJsonObject("root")); ++ final var clear = controls.stream().filter(node -> "clear_text_button".equals(node.get("name").getAsString())).findFirst().orElseThrow(); ++ assertEquals(!query.isEmpty(), clear.get("visible").getAsBoolean(), "Clear visibility for raw query: '" + query + "'"); ++ final var editor = controls.stream().filter(node -> "edit_box".equals(node.get("type").getAsString())).findFirst().orElseThrow(); ++ assertEquals(new JsonPrimitive(true), editor.get("enabled")); ++ } ++ } ++ ++ @Test ++ void exportedCookingEditorsRetainTheirPropertyKeyAndSearchText() { ++ final var ui = new ResourcePackStorage(List.of()).getCookingUi(); ++ for (final var family : CookingUi.Family.values()) { ++ final var initial = menu(family); ++ final var source = initial.book(); ++ final var book = new CookingUi.Book(source.layout(), source.forcedRecipeBookLayout(), 4, source.filtering(), ++ source.foodCount(), source.itemCount(), source.blockCount(), source.tabLabel(), "iron", ++ source.selection(), source.catalog(), source.visibleRecipes()); ++ final var view = new CookingUi.MenuView(initial.menuId(), family, initial.title(), initial.slots(), initial.cursor(), initial.progress(), book); ++ final var direct = ui.resolve(view); ++ final var replay = CookingUi.fromProgram(ui.exportProgram(view)).resolve(view); ++ assertEquals(direct, replay); ++ final var all = nodes(replay.getAsJsonObject("root")); ++ final var editor = all.stream().filter(node -> "edit_box".equals(node.get("type").getAsString())).findFirst().orElseThrow(); ++ assertEquals(new JsonPrimitive("#item_name"), editor.getAsJsonObject("property_bag").get("#property_field")); ++ assertEquals("#text_box_name", editor.get("text_box_name").getAsString()); ++ assertTrue(all.stream().anyMatch(node -> node.has("text") && new JsonPrimitive("iron").equals(node.get("text")))); ++ } ++ } ++ ++ @Test + void exportsTranslationsFromTheCurrentControllerAlongsideStaticDeclarations() { + final var content = new InMemoryContent(); + content.putString("manifest.json", "{\"format_version\":2,\"header\":{\"uuid\":\"" + UUID.randomUUID() @@ -14021,10 +15011,10 @@ index 00000000..bc575caa +} diff --git a/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/JsonUiProgramTest.java b/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/JsonUiProgramTest.java new file mode 100644 -index 00000000..de572c3a +index 00000000..bacf77b3 --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/JsonUiProgramTest.java -@@ -0,0 +1,803 @@ +@@ -0,0 +1,835 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -14071,6 +15061,38 @@ index 00000000..de572c3a + } + + @Test ++ void propertyBagFieldsKeepNativePropertyNamesWhileBindingsStillUpdateValues() { ++ final var authored = JsonParser.parseString(""" ++ {"#property_field":"#item_name","#indirect":"$name_key","#quantity":"($count + 1)","#caption":"#initial"} ++ """).getAsJsonObject(); ++ for (final String bag : List.of(authored.toString(), "\"$bag\"")) { ++ final var ui = program(""" ++ {"namespace":"example","root":{"type":"edit_box","text_box_name":"$name_key","property_bag":%s, ++ "bindings":[{"binding_type":"view","source_property_name":"#desired_caption","target_property_name":"#caption"}]}} ++ """.formatted(bag)); ++ final var bindings = new Bindings(); ++ bindings.globals.put("$bag", authored); ++ bindings.globals.put("$name_key", new JsonPrimitive("#item_name")); ++ bindings.globals.put("$count", new JsonPrimitive(2)); ++ bindings.globals.put("#item_name", new JsonPrimitive("Inventory label")); ++ bindings.globals.put("#desired_caption", new JsonPrimitive("First caption")); ++ final var scene = ui.resolve("example.root", bindings); ++ final var properties = scene.root().getAsJsonObject("property_bag"); ++ assertEquals(new JsonPrimitive("#item_name"), properties.get("#property_field")); ++ assertEquals(new JsonPrimitive("#item_name"), properties.get("#indirect")); ++ assertEquals(3, properties.get("#quantity").getAsInt()); ++ assertEquals("First caption", properties.get("#caption").getAsString()); ++ assertEquals("#item_name", scene.root().get("text_box_name").getAsString()); ++ final var replay = JsonUiProgram.fromProgram(ui.export(scene.requiredDefinitions(), new JsonObject())); ++ assertEquals(scene.root(), replay.resolve("example.root", bindings).root()); ++ bindings.globals.put("#desired_caption", new JsonPrimitive("Updated caption")); ++ final var updated = replay.resolve("example.root", bindings).root().getAsJsonObject("property_bag"); ++ assertEquals("#item_name", updated.get("#property_field").getAsString()); ++ assertEquals("Updated caption", updated.get("#caption").getAsString()); ++ } ++ } ++ ++ @Test + void collectionDetailsPopulateLocalEventBagsWithoutBindingNames() { + final var ui = program(""" + {"namespace":"example","root":{"type":"panel","bindings":[ 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 b35edeb8..576c25f6 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 @@ -14,6 +14,13 @@ Prerequisites: ## Validation and remaining limits +Official Bedrock 1.26.60.5 desktop controls open Encyclopedia topic 31, Furnace, from all three cooking families. +Furnace and blast-furnace controls retain carried items after returning from Help. +Language data distinguishes encyclopedia topic labels from Survival guide title keys. +Regressions retain the selected topic, localized heading, body text, and cooking payload bound across program transport. +Player inventory uses the same Help resolver with its existing guide choice. +Native multiplayer Help traffic and complete visual parity remain unverified. + Storage programs export and resolve the complete host graph, including backdrop, close controls, and animation tracks. Synthetic screen fixtures declare their content controls explicitly. The bundled-family regression retains authoritative slots and the host hurt policy across program transport. @@ -141,3 +148,25 @@ The local diagnostic control disables authored-form capability without changing The original reported dialog remains unmatched. Collection-details bindings now populate each control event bag without requiring binding_name. Named lookups, zero fallback, prefixes and unnamed scope maps match fifteen original Bedrock 1.26.60.5 execution controls; eight parser controls establish the binding type. Local regressions cover evaluated property replacement, control-local bags and export/import. Full controller input and installed parity remain separate gates. + +Optional cooking query contracts retain raw text and bind edits to the displayed opening, snapshot, and request sequence. +Codec regressions retain Unicode and embedded NUL text and reject malformed source lengths, trailing fields, and invalid result flags. +The backend owns filtering and execution. Complete default cooking screen admission remains unfinished. + +Optional held-quantity messages retain the starting frame, source slot, phase, and ordered request identity. +Results report local phase admission; inventory responses retain final authority. +Codec regressions cover round trips, source identity, bounds, and trailing data. + +Native 1.26.60.5 property-value controls preserve hash-prefixed names in property data and expand dollar variables without converting the result into a binding lookup. +Regressions cover direct and variable-supplied property bags, explicit binding updates, and exported cooking editors for all three station families. +Cooking clear-button visibility follows raw search text, including whitespace. +These controls do not establish complete loader or cooking-screen parity. + +Current 1.26.60.5 original-instruction controls cover both layout callbacks across 384 supplied controller states, including forced layouts and inline or heap hover text. +Leaving Inventory with retention enabled saves Inventory tab 5 independently of the selected recipe category. +The downstream settings refresh is an observed boundary. These controls do not establish physical input or full cooking-screen parity. + +Desktop held-quantity progress retains the original source total and the selected item count. +Forty original Bedrock 1.26.60.5 getter controls establish these values and visibility under supplied item and input services. +An exported cooking-program regression resolves active progress and clears it after selection ends. +Touch visibility policy and complete native pixels remain unverified. diff --git a/patches/viabedrock/upstreamable/0104-transport-resolved-native-fog-stacks.patch b/patches/viabedrock/upstreamable/0104-transport-resolved-native-fog-stacks.patch index 216b82ba..27f74040 100644 --- a/patches/viabedrock/upstreamable/0104-transport-resolved-native-fog-stacks.patch +++ b/patches/viabedrock/upstreamable/0104-transport-resolved-native-fog-stacks.patch @@ -1,4 +1,4 @@ -From d548c45c70243e92242daee198be6f000f39ed3e Mon Sep 17 00:00:00 2001 +From ac4b348d8dc39fd8112bb4a907f8278bd01a973b Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:05:15 +0100 Subject: [PATCH] feat(fog): transport resolved native fog settings @@ -386,10 +386,10 @@ index 4f775c01..5af0eca2 100644 user.get(HudVisibilityStorage.class).sendSnapshot(); user.get(CameraEffectsStorage.class).markJoined(); diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java -index 8210c4cc..67129d78 100644 +index d3ecbb9c..3b5821f5 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java -@@ -88,6 +88,7 @@ import net.raphimc.viabedrock.protocol.storage.PlayerInputStorage; +@@ -89,6 +89,7 @@ import net.raphimc.viabedrock.protocol.storage.PlayerInputStorage; import net.raphimc.viabedrock.protocol.storage.PlayerPredictionStorage; import net.raphimc.viabedrock.protocol.storage.VehiclePredictionStorage; import net.raphimc.viabedrock.protocol.storage.HudVisibilityStorage; @@ -397,7 +397,7 @@ index 8210c4cc..67129d78 100644 import net.raphimc.viabedrock.protocol.storage.CameraEffectsStorage; import net.raphimc.viabedrock.protocol.types.BedrockTypes; -@@ -392,6 +393,9 @@ public final class MultiStatePackets { +@@ -407,6 +408,9 @@ public final class MultiStatePackets { HudPackets.sendNativeRoot(wrapper.user()); wrapper.user().get(PlayerInputStorage.class).sendSnapshot(); wrapper.user().get(HudVisibilityStorage.class).sendSnapshot(); diff --git a/patches/viabedrock/upstreamable/0105-ignore-removals-for-absent-unmapped-effects.patch b/patches/viabedrock/upstreamable/0105-ignore-removals-for-absent-unmapped-effects.patch index 2f81aaeb..ffca5d34 100644 --- a/patches/viabedrock/upstreamable/0105-ignore-removals-for-absent-unmapped-effects.patch +++ b/patches/viabedrock/upstreamable/0105-ignore-removals-for-absent-unmapped-effects.patch @@ -1,4 +1,4 @@ -From 86b7b04308a33aade7cb9884538cea97e197c9b4 Mon Sep 17 00:00:00 2001 +From 6de9511cb4301fe6974fc5be7687284e2d863405 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:25:36 +0100 Subject: [PATCH] fix(effects): ignore removals for absent unmapped effects @@ -32,10 +32,10 @@ probes can crash and do not establish behavior for arbitrary integers. create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/packet/MobEffectPacketTest.java diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java -index 1c23b759..4578a73f 100644 +index d6ea7d51..f66c7f90 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java -@@ -618,7 +618,12 @@ public final class EntityPackets { +@@ -614,7 +614,12 @@ public final class EntityPackets { } final String bedrockIdentifier = BedrockProtocol.MAPPINGS.getBedrockEffects().inverse().get(effectId); diff --git a/patches/viabedrock/upstreamable/0107-transport-remote-player-movement-modes.patch b/patches/viabedrock/upstreamable/0107-transport-remote-player-movement-modes.patch index 006f4343..0e077690 100644 --- a/patches/viabedrock/upstreamable/0107-transport-remote-player-movement-modes.patch +++ b/patches/viabedrock/upstreamable/0107-transport-remote-player-movement-modes.patch @@ -1,4 +1,4 @@ -From da3c1093042008fab5ed5df2366aed3c39f9d23b Mon Sep 17 00:00:00 2001 +From eaf597e82caa97a2dd73d1b75cd83ef5ef690e4a Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:43:18 +0100 Subject: [PATCH] feat(players): transport remote movement modes and targets @@ -237,7 +237,7 @@ index b47f5471..a4b3ec09 100644 final var tracker = this.user == null ? null : this.user.get(EntityTracker.class); // The horse writer sets VehicleInputIntent.mIsClientPredicted only for a saddled, tamed local controller. diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java -index 4578a73f..6f65acbc 100644 +index f66c7f90..dde3d4b1 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java @@ -38,6 +38,7 @@ import net.raphimc.viabedrock.ViaBedrock; @@ -248,7 +248,7 @@ index 4578a73f..6f65acbc 100644 import net.raphimc.viabedrock.api.model.MovementCorrectionTiming; import net.raphimc.viabedrock.api.model.NativeAnimationMessage; import net.raphimc.viabedrock.api.model.entity.ClientPlayerEntity; -@@ -294,6 +295,7 @@ public final class EntityPackets { +@@ -293,6 +294,7 @@ public final class EntityPackets { entity.setPosition(position); entity.setRotation(new Position3f(pitch, yaw, headYaw)); entity.setOnGround(onGround); @@ -256,7 +256,7 @@ index 4578a73f..6f65acbc 100644 wrapper.write(Types.VAR_INT, entity.javaId()); // entity id wrapper.write(Types.VAR_INT, 1); // Stepped -@@ -380,6 +382,7 @@ public final class EntityPackets { +@@ -379,6 +381,7 @@ public final class EntityPackets { PacketFactory.sendJavaRotateHead(wrapper.user(), entity); } entity.setOnGround(onGround); diff --git a/patches/viabedrock/upstreamable/0111-honor-death-message-visibility.patch b/patches/viabedrock/upstreamable/0111-honor-death-message-visibility.patch new file mode 100644 index 00000000..97ce6327 --- /dev/null +++ b/patches/viabedrock/upstreamable/0111-honor-death-message-visibility.patch @@ -0,0 +1,334 @@ +From 57a093dc5f31941bcec7ad0dfedffccc738cd578 Mon Sep 17 00:00:00 2001 +From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> +Date: Thu, 5 Nov 2026 20:20:15 +0100 +Subject: [PATCH] fix(hud): honor death message visibility + +Keep the translated death reason in session storage and apply +showDeathMessages when sending the death screen. Share this policy between +DeathInfo and actor-death handling so either packet order hides only the +reason. Refresh a dead player when the rule changes, including restoring +the retained reason when it is enabled again. + +Bedrock 1.26.60.5, protocol 2225, evaluates this rule in its active OreUI +PlayerDeathInfoQuery as well as its classic death controller. Eight +original-executable query controls select an empty string unless the world, +player and enabled rule are all present. Executable SHA-256: +988f6a61403b4381aa41e6c66d64cce441b14e561a9b10f647c2758ef3f2672a. + +Packet regressions cover both arrival orders, alive-player admission, +retained text, live rule changes and repeated unchanged rules. This fixes +reason projection; the OreUI screen layout and lifecycle remain separate +work. +--- + .../viabedrock/api/util/PacketFactory.java | 16 ++ + .../protocol/packet/EntityPackets.java | 8 +- + .../protocol/packet/HudPackets.java | 8 +- + .../protocol/storage/GameRulesStorage.java | 2 + + .../packet/DeathMessagePacketsTest.java | 201 ++++++++++++++++++ + 5 files changed, 222 insertions(+), 13 deletions(-) + create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/packet/DeathMessagePacketsTest.java + +diff --git a/src/main/java/net/raphimc/viabedrock/api/util/PacketFactory.java b/src/main/java/net/raphimc/viabedrock/api/util/PacketFactory.java +index f67b7f7d..68fa4fac 100644 +--- a/src/main/java/net/raphimc/viabedrock/api/util/PacketFactory.java ++++ b/src/main/java/net/raphimc/viabedrock/api/util/PacketFactory.java +@@ -38,10 +38,26 @@ import net.raphimc.viabedrock.protocol.data.enums.java.GameEventType; + import net.raphimc.viabedrock.protocol.data.enums.java.generated.CustomChatCompletionsAction; + import net.raphimc.viabedrock.protocol.model.Position3f; + import net.raphimc.viabedrock.protocol.storage.InventoryTracker; ++import net.raphimc.viabedrock.protocol.storage.EntityTracker; ++import net.raphimc.viabedrock.protocol.storage.GameRulesStorage; ++import net.raphimc.viabedrock.protocol.storage.GameSessionStorage; + import net.raphimc.viabedrock.protocol.types.BedrockTypes; + + public final class PacketFactory { + ++ public static void sendJavaDeathMessage(final UserConnection user) { ++ final var player = user.get(EntityTracker.class).getClientPlayer(); ++ final var message = user.get(GameSessionStorage.class).getDeathMessage(); ++ if (!player.isDead() || message == null) { ++ return; ++ } ++ final boolean showReason = Boolean.TRUE.equals(user.get(GameRulesStorage.class).getGameRule("showDeathMessages")); ++ final PacketWrapper death = PacketWrapper.create(ClientboundPackets26_3.PLAYER_COMBAT_KILL, user); ++ death.write(Types.VAR_INT, player.javaId()); // entity id ++ death.write(Types.TAG, showReason ? TextUtil.textComponentToNbt(message) : TextUtil.stringToNbt("")); // message ++ death.send(BedrockProtocol.class); ++ } ++ + public static void sendJavaBlockDestroyProgress(final UserConnection user, final int id, final BlockPosition position, final int stage) { + final PacketWrapper blockDestruction = PacketWrapper.create(ClientboundPackets26_3.BLOCK_DESTRUCTION, user); + blockDestruction.write(Types.VAR_INT, id); // id +diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java +index 39c8bf5f..63b06dce 100644 +--- a/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java ++++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java +@@ -45,7 +45,6 @@ import net.raphimc.viabedrock.api.resourcepack.definition.EntityDefinitions; + import net.raphimc.viabedrock.api.util.MathUtil; + import net.raphimc.viabedrock.api.util.PacketFactory; + import net.raphimc.viabedrock.api.util.RegistryUtil; +-import net.raphimc.viabedrock.api.util.TextUtil; + import net.raphimc.viabedrock.protocol.BedrockProtocol; + import net.raphimc.viabedrock.protocol.ClientboundBedrockPackets; + import net.raphimc.viabedrock.protocol.data.enums.Direction; +@@ -452,11 +451,8 @@ public final class EntityPackets { + livingEntity.setHealth(0F); + livingEntity.sendAttribute("minecraft:health"); + } +- if (entity == entityTracker.getClientPlayer() && entityTracker.getClientPlayer().isDead() && gameSession.getDeathMessage() != null) { +- final PacketWrapper playerCombatKill = PacketWrapper.create(ClientboundPackets26_3.PLAYER_COMBAT_KILL, wrapper.user()); +- playerCombatKill.write(Types.VAR_INT, entityTracker.getClientPlayer().javaId()); // entity id +- playerCombatKill.write(Types.TAG, TextUtil.textComponentToNbt(gameSession.getDeathMessage())); // message +- playerCombatKill.send(BedrockProtocol.class); ++ if (entity == entityTracker.getClientPlayer()) { ++ PacketFactory.sendJavaDeathMessage(wrapper.user()); + } + if (entity != entityTracker.getClientPlayer()) { + entity.playSound("death"); +diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java +index 1e094252..4b40f209 100644 +--- a/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java ++++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java +@@ -438,18 +438,12 @@ public final class HudPackets { + protocol.registerClientbound(ClientboundBedrockPackets.DEATH_INFO, null, wrapper -> { + wrapper.cancel(); + final GameSessionStorage gameSession = wrapper.user().get(GameSessionStorage.class); +- final EntityTracker entityTracker = wrapper.user().get(EntityTracker.class); + final String message = wrapper.read(BedrockTypes.STRING); // death cause message + final String[] parameters = wrapper.read(BedrockTypes.STRING_ARRAY); // parameters + + final Function translator = wrapper.user().get(ResourcePackStorage.class).getTexts().lookup(); + gameSession.setDeathMessage(TextUtil.stringToTextComponent(BedrockTranslator.translate(message, translator, parameters))); +- if (entityTracker.getClientPlayer().isDead()) { +- final PacketWrapper playerCombatKill = PacketWrapper.create(ClientboundPackets26_3.PLAYER_COMBAT_KILL, wrapper.user()); +- playerCombatKill.write(Types.VAR_INT, entityTracker.getClientPlayer().javaId()); // entity id +- playerCombatKill.write(Types.TAG, TextUtil.textComponentToNbt(gameSession.getDeathMessage())); // message +- playerCombatKill.send(BedrockProtocol.class); +- } ++ PacketFactory.sendJavaDeathMessage(wrapper.user()); + }); + protocol.registerClientbound(ClientboundBedrockPackets.GUI_DATA_PICK_ITEM, ClientboundPackets26_3.SYSTEM_CHAT, wrapper -> { + final String itemName = wrapper.read(BedrockTypes.STRING); // item name +diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/GameRulesStorage.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/GameRulesStorage.java +index 9dcfed5b..9d4df851 100644 +--- a/src/main/java/net/raphimc/viabedrock/protocol/storage/GameRulesStorage.java ++++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/GameRulesStorage.java +@@ -61,6 +61,8 @@ public class GameRulesStorage extends StoredObject { + PacketFactory.sendJavaEntityEvent(this.user(), clientPlayer, (boolean) gameRule.value() ? EntityEvent.FULL_DEBUG_INFO : EntityEvent.REDUCED_DEBUG_INFO); + } else if (gameRule.name().equalsIgnoreCase("doImmediateRespawn")) { + PacketFactory.sendJavaGameEvent(this.user(), GameEventType.IMMEDIATE_RESPAWN, (boolean) gameRule.value() ? 1F : 0F); ++ } else if (gameRule.name().equalsIgnoreCase("showDeathMessages")) { ++ PacketFactory.sendJavaDeathMessage(this.user()); + } else if (gameRule.name().equalsIgnoreCase("doLimitedCrafting")) { + PacketFactory.sendJavaGameEvent(this.user(), GameEventType.LIMITED_CRAFTING, (boolean) gameRule.value() ? 1F : 0F); + } +diff --git a/src/test/java/net/raphimc/viabedrock/protocol/packet/DeathMessagePacketsTest.java b/src/test/java/net/raphimc/viabedrock/protocol/packet/DeathMessagePacketsTest.java +new file mode 100644 +index 00000000..20c41ece +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/protocol/packet/DeathMessagePacketsTest.java +@@ -0,0 +1,201 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/ViaVersionAddons/ViaBedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.protocol.packet; ++ ++import com.viaversion.nbt.tag.Tag; ++import com.viaversion.viaversion.api.connection.ProtocolInfo; ++import com.viaversion.viaversion.api.connection.UserConnection; ++import com.viaversion.viaversion.api.protocol.packet.PacketWrapper; ++import com.viaversion.viaversion.api.protocol.packet.State; ++import com.viaversion.viaversion.api.protocol.packet.mapping.PacketMapping; ++import com.viaversion.viaversion.api.type.Types; ++import com.viaversion.viaversion.protocol.packet.PacketWrapperImpl; ++import com.viaversion.viaversion.protocols.v26_2to26_3.packet.ClientboundPackets26_3; ++import io.netty.buffer.ByteBuf; ++import io.netty.buffer.Unpooled; ++import java.lang.reflect.Field; ++import java.util.ArrayList; ++import java.util.List; ++import java.util.Map; ++import java.util.concurrent.atomic.AtomicBoolean; ++import java.util.function.Consumer; ++import java.util.function.Function; ++import net.raphimc.viabedrock.api.model.entity.ClientPlayerEntity; ++import net.raphimc.viabedrock.api.resourcepack.definition.TextDefinitions; ++import net.raphimc.viabedrock.protocol.BedrockProtocol; ++import net.raphimc.viabedrock.protocol.ClientboundBedrockPackets; ++import net.raphimc.viabedrock.protocol.data.ProtocolConstants; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ActorEvent; ++import net.raphimc.viabedrock.protocol.model.GameRule; ++import net.raphimc.viabedrock.protocol.storage.ChannelStorage; ++import net.raphimc.viabedrock.protocol.storage.EntityTracker; ++import net.raphimc.viabedrock.protocol.storage.GameRulesStorage; ++import net.raphimc.viabedrock.protocol.storage.GameSessionStorage; ++import net.raphimc.viabedrock.protocol.storage.ResourcePackStorage; ++import net.raphimc.viabedrock.protocol.types.BedrockTypes; ++import org.junit.jupiter.api.Test; ++import org.mockito.MockedStatic; ++ ++import static org.junit.jupiter.api.Assertions.*; ++import static org.mockito.Mockito.*; ++ ++final class DeathMessagePacketsTest { ++ ++ @Test ++ void hidesTheReasonWithoutSuppressingTheDeathScreenInEitherPacketOrder() throws Exception { ++ for (final boolean infoFirst : List.of(false, true)) { ++ try (var fixture = new Fixture()) { ++ fixture.showReason(false); ++ if (infoFirst) { ++ fixture.info(); ++ } ++ fixture.death(); ++ if (!infoFirst) { ++ fixture.info(); ++ } ++ assertEquals(List.of(""), fixture.messages); ++ assertEquals("Controlled death reason", fixture.session.getDeathMessage().asUnformattedString()); ++ } ++ } ++ } ++ ++ @Test ++ void usesTheCurrentRuleWhenCachedOrLateDeathInformationIsPresented() throws Exception { ++ try (var fixture = new Fixture()) { ++ fixture.info(); ++ assertTrue(fixture.messages.isEmpty(), "An alive player must not receive a death screen"); ++ fixture.showReason(false); ++ fixture.death(); ++ assertEquals(List.of(""), fixture.messages); ++ fixture.showReason(true); ++ assertEquals(List.of("", "Controlled death reason"), fixture.messages); ++ fixture.showReason(false); ++ assertEquals(List.of("", "Controlled death reason", ""), fixture.messages); ++ fixture.showReason(false); ++ assertEquals(3, fixture.messages.size(), "An unchanged rule must not replace the screen"); ++ } ++ } ++ ++ private static GameRulesStorage rules(final UserConnection user) throws Exception { ++ final Field defaults = BedrockProtocol.MAPPINGS.getClass().getDeclaredField("bedrockGameRules"); ++ defaults.setAccessible(true); ++ final Object previous = defaults.get(BedrockProtocol.MAPPINGS); ++ try { ++ defaults.set(BedrockProtocol.MAPPINGS, Map.of("showdeathmessages", true)); ++ return new GameRulesStorage(user, new GameRule[0]); ++ } finally { ++ defaults.set(BedrockProtocol.MAPPINGS, previous); ++ } ++ } ++ ++ private static final class Fixture implements AutoCloseable { ++ private final UserConnection user = mock(UserConnection.class); ++ private final ClientPlayerEntity player = mock(ClientPlayerEntity.class); ++ private final GameSessionStorage session = mock(GameSessionStorage.class, CALLS_REAL_METHODS); ++ private final AtomicBoolean dead = new AtomicBoolean(); ++ private GameRulesStorage rules; ++ private final List messages = new ArrayList<>(); ++ private final MockedStatic packets = mockStatic(PacketWrapper.class); ++ private final TestProtocol protocol = new TestProtocol(); ++ ++ private Fixture() throws Exception { ++ final var entities = mock(EntityTracker.class); ++ final var info = mock(ProtocolInfo.class); ++ when(this.user.getProtocolInfo()).thenReturn(info); ++ when(info.getServerState()).thenReturn(State.PLAY); ++ this.rules = rules(this.user); ++ final var resources = mock(ResourcePackStorage.class); ++ final var texts = mock(TextDefinitions.class); ++ final Map, Object> storage = Map.of(EntityTracker.class, entities, ++ GameSessionStorage.class, this.session, GameRulesStorage.class, this.rules, ++ ResourcePackStorage.class, resources, ChannelStorage.class, new ChannelStorage()); ++ when(this.user.get(any())).thenAnswer(call -> storage.get(call.getArgument(0))); ++ when(entities.getClientPlayer()).thenReturn(this.player); ++ when(entities.getEntityByRid(42L)).thenReturn(this.player); ++ when(this.player.javaId()).thenReturn(7); ++ when(this.player.isDead()).thenAnswer(call -> this.dead.get()); ++ doAnswer(call -> { ++ this.dead.set((float) call.getArgument(0) <= 0); ++ return null; ++ }) ++ .when(this.player).setHealth(anyFloat()); ++ when(resources.getTexts()).thenReturn(texts); ++ when(texts.lookup()).thenReturn(Function.identity()); ++ this.packets.when(() -> PacketWrapper.create(ClientboundPackets26_3.PLAYER_COMBAT_KILL, this.user)) ++ .thenAnswer(call -> { ++ final var output = spy(new PacketWrapperImpl(ClientboundPackets26_3.PLAYER_COMBAT_KILL, null, this.user)); ++ doAnswer(send -> { ++ final var buffer = Unpooled.buffer(); ++ try { ++ output.writeToBuffer(buffer); ++ assertEquals(ClientboundPackets26_3.PLAYER_COMBAT_KILL.getId(), Types.VAR_INT.readPrimitive(buffer)); ++ assertEquals(7, Types.VAR_INT.readPrimitive(buffer)); ++ final Tag reason = Types.TAG.read(buffer); ++ this.messages.add(ProtocolConstants.JAVA_TEXT_COMPONENT_SERIALIZER ++ .deserializeNbtTree(reason).asUnformattedString()); ++ assertFalse(buffer.isReadable()); ++ } finally { ++ buffer.release(); ++ } ++ return null; ++ }).when(output).send(BedrockProtocol.class); ++ return output; ++ }); ++ } ++ ++ private void showReason(final boolean show) { ++ this.rules.updateGameRules(new GameRule[]{new GameRule("showDeathMessages", false, show)}); ++ } ++ ++ private void info() throws Exception { ++ this.receive(ClientboundBedrockPackets.DEATH_INFO, buffer -> { ++ BedrockTypes.STRING.write(buffer, "Controlled death reason"); ++ BedrockTypes.STRING_ARRAY.write(buffer, new String[0]); ++ }); ++ } ++ ++ private void death() throws Exception { ++ this.receive(ClientboundBedrockPackets.ENTITY_EVENT, buffer -> { ++ BedrockTypes.UNSIGNED_VAR_LONG.write(buffer, 42L); ++ Types.UNSIGNED_BYTE.write(buffer, (short) ActorEvent.DEATH.getValue()); ++ BedrockTypes.VAR_INT.write(buffer, 0); ++ BedrockTypes.OPTIONAL_POSITION_3F.write(buffer, null); ++ }); ++ } ++ ++ private void receive(final ClientboundBedrockPackets type, final Consumer encode) throws Exception { ++ final var input = Unpooled.buffer(); ++ try { ++ encode.accept(input); ++ final var packet = new PacketWrapperImpl(type, input, this.user); ++ final var mapping = this.protocol.mapping(packet); ++ mapping.applyType(packet); ++ mapping.handler().handle(packet); ++ assertTrue(packet.isCancelled()); ++ assertFalse(input.isReadable()); ++ } finally { ++ input.release(); ++ } ++ } ++ ++ @Override ++ public void close() { ++ this.packets.close(); ++ } ++ } ++ ++ private static final class TestProtocol extends BedrockProtocol { ++ private TestProtocol() { ++ HudPackets.register(this); ++ EntityPackets.register(this); ++ } ++ ++ private PacketMapping mapping(final PacketWrapper packet) { ++ return this.clientboundMappings.mappedPacket(State.PLAY, packet.getId()); ++ } ++ } ++ ++} +-- +2.56.0 + diff --git a/patches/viabedrock/upstreamable/0111-honor-death-message-visibility.pr.md b/patches/viabedrock/upstreamable/0111-honor-death-message-visibility.pr.md new file mode 100644 index 00000000..43e41cf8 --- /dev/null +++ b/patches/viabedrock/upstreamable/0111-honor-death-message-visibility.pr.md @@ -0,0 +1,9 @@ +# fix(hud): honor death message visibility + +Hide the death reason when `showDeathMessages` is disabled while preserving the death screen and cached text. Apply the rule to either DeathInfo/actor-death arrival order and refresh a dead player when the rule changes. + +Bedrock 1.26.60.5 on protocol 2225 applies this rule in its active OreUI `PlayerDeathInfoQuery`. Eight original-executable controls select an empty reason unless the world, player, and enabled rule are present. The executable digest and rationale are in the patch body. + +`DeathMessagePacketsTest` covers both packet orders, alive-player admission, retained text, live visibility changes, and unchanged rules. Both tests fail on the previous projection and pass with this change. Main and test Checkstyle pass. The standalone production patch applies and builds on pinned upstream without setup or local tests. The full four-project build passes 3,524 tests with 328 skipped and no failures. + +This changes reason projection. The [add-on](../../viafabricplus-bedrock/upstreamable/0042-retain-bedrock-death-screen-lifetimes.pr.md) owns on-screen update admission and lifetime. The native OreUI layout, animation stages, menu routing, and respawn lifecycle remain unfinished. diff --git a/patches/viabedrock/upstreamable/0112-retain-native-death-text.patch b/patches/viabedrock/upstreamable/0112-retain-native-death-text.patch new file mode 100644 index 00000000..6fc9d058 --- /dev/null +++ b/patches/viabedrock/upstreamable/0112-retain-native-death-text.patch @@ -0,0 +1,327 @@ +From 4f61f7500b4e85ef004846aa91bf72578b1c5155 Mon Sep 17 00:00:00 2001 +From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> +Date: Fri, 6 Nov 2026 12:09:43 +0100 +Subject: [PATCH] feat(ui): retain native death text source + +Retain the translated DeathInfo source before conversion to Java text +components. The acquired gameplay formatter needs original code spelling +and boundaries: it preserves internal boundary spaces, ignores bold and +italic codes, masks UTF-16 units for obfuscation and assigns opaque +palette-colored shadows. Java style projection cannot retain this input. + +Send a bounded, versioned raw-text payload before the matching ordinary +Java death message when the client registers both the new channel and +native actor state. Bind it to the local actor lifetime and include the +plain Java projection for association. Existing clients and oversized +sources keep the ordinary Java path. The visibility rule hides both +native fields while retaining the cached source for a later rule change. + +The original 1.26.60.5 gameplay component selects FormattingCodes for the +death-info node. Executions of its unchanged formatter establish the +listed transformations, including literal uppercase/unknown codes. +Script SHA-256: +9e7efe423b6cc8a1c1fa92756bd181675cffa51a4728bce386b0081cad1bb979. +This is a negotiated translation bridge, not a new Bedrock wire packet. + +Packet regressions retain exact codes and whitespace, decode the payload +and Java projection in order, cover visibility restoration and channel +removal, and preserve the Java projection without actor negotiation or +for an oversized source. Complete native client policy, layout, input +and visual parity remain separate work. +--- + .../viabedrock/api/model/NativeDeathText.java | 57 +++++++++++++ + .../viabedrock/api/util/PacketFactory.java | 13 +++ + .../protocol/packet/HudPackets.java | 4 +- + .../protocol/storage/GameSessionStorage.java | 9 +++ + .../packet/DeathMessagePacketsTest.java | 81 ++++++++++++++++++- + 5 files changed, 161 insertions(+), 3 deletions(-) + create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeDeathText.java + +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/NativeDeathText.java b/src/main/java/net/raphimc/viabedrock/api/model/NativeDeathText.java +new file mode 100644 +index 00000000..70a5b0fc +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/model/NativeDeathText.java +@@ -0,0 +1,57 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/ViaVersionAddons/ViaBedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.model; ++ ++import com.viaversion.viaversion.api.type.Type; ++import com.viaversion.viaversion.api.type.Types; ++import com.viaversion.viaversion.api.type.types.StringType; ++import io.netty.buffer.ByteBuf; ++import java.util.Objects; ++import java.util.UUID; ++import net.raphimc.viabedrock.protocol.data.ProtocolConstants; ++ ++/** Translated native source before Java formatting conversion, paired with its ordinary projection. */ ++public record NativeDeathText(UUID lifetime, String source, String projected) { ++ ++ public static final String CHANNEL = "viabedrock:native_death_text_v1"; ++ public static final int MAX_TEXT_LENGTH = 32768; ++ private static final StringType TEXT = new StringType(MAX_TEXT_LENGTH); ++ public static final Type TYPE = new Type<>(NativeDeathText.class) { ++ @Override ++ public NativeDeathText read(final ByteBuf buffer) { ++ if (buffer.readableBytes() > MAX_TEXT_LENGTH * 8 + 32 || Types.VAR_INT.readPrimitive(buffer) != 1 ++ || Types.VAR_INT.readPrimitive(buffer) != ProtocolConstants.BEDROCK_PROTOCOL_VERSION) { ++ throw new IllegalArgumentException("Unsupported native death text"); ++ } ++ final var text = new NativeDeathText(Types.UUID.read(buffer), TEXT.read(buffer), TEXT.read(buffer)); ++ if (buffer.isReadable()) { ++ throw new IllegalArgumentException("Trailing native death text data"); ++ } ++ return text; ++ } ++ ++ @Override ++ public void write(final ByteBuf buffer, final NativeDeathText text) { ++ Types.VAR_INT.writePrimitive(buffer, 1); ++ Types.VAR_INT.writePrimitive(buffer, ProtocolConstants.BEDROCK_PROTOCOL_VERSION); ++ Types.UUID.write(buffer, text.lifetime); ++ TEXT.write(buffer, text.source); ++ TEXT.write(buffer, text.projected); ++ } ++ }; ++ ++ public NativeDeathText { ++ Objects.requireNonNull(lifetime); ++ if (!canEncode(source, projected)) { ++ throw new IllegalArgumentException("Native death text exceeds limits"); ++ } ++ } ++ ++ public static boolean canEncode(final String source, final String projected) { ++ return source != null && projected != null && source.length() <= MAX_TEXT_LENGTH && projected.length() <= MAX_TEXT_LENGTH; ++ } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/api/util/PacketFactory.java b/src/main/java/net/raphimc/viabedrock/api/util/PacketFactory.java +index f9fec82b..bdea523c 100644 +--- a/src/main/java/net/raphimc/viabedrock/api/util/PacketFactory.java ++++ b/src/main/java/net/raphimc/viabedrock/api/util/PacketFactory.java +@@ -28,6 +28,8 @@ import com.viaversion.viaversion.api.type.Types; + import com.viaversion.viaversion.api.type.types.version.VersionedTypes; + import com.viaversion.viaversion.libs.gson.JsonNull; + import com.viaversion.viaversion.protocols.v26_2to26_3.packet.ClientboundPackets26_3; ++import net.raphimc.viabedrock.api.model.NativeActorMessage; ++import net.raphimc.viabedrock.api.model.NativeDeathText; + import net.raphimc.viabedrock.api.model.container.Container; + import net.raphimc.viabedrock.api.model.entity.Entity; + import net.raphimc.viabedrock.protocol.BedrockProtocol; +@@ -41,6 +43,7 @@ import net.raphimc.viabedrock.protocol.data.enums.java.generated.CustomChatCompl + import net.raphimc.viabedrock.protocol.model.Position3f; + import net.raphimc.viabedrock.protocol.storage.InventoryTracker; + import net.raphimc.viabedrock.protocol.storage.EntityTracker; ++import net.raphimc.viabedrock.protocol.storage.ChannelStorage; + import net.raphimc.viabedrock.protocol.storage.GameRulesStorage; + import net.raphimc.viabedrock.protocol.storage.GameSessionStorage; + import net.raphimc.viabedrock.protocol.types.BedrockTypes; +@@ -54,6 +57,16 @@ public final class PacketFactory { + return; + } + final boolean showReason = Boolean.TRUE.equals(user.get(GameRulesStorage.class).getGameRule("showDeathMessages")); ++ final var channels = user.get(ChannelStorage.class); ++ final String source = user.get(GameSessionStorage.class).getDeathMessageSource(); ++ final String projected = showReason ? message.asUnformattedString() : ""; ++ if (channels != null && channels.hasChannel(NativeDeathText.CHANNEL) && NativeActorMessage.hasStateChannel(channels::hasChannel) ++ && NativeDeathText.canEncode(source, projected)) { ++ final PacketWrapper nativeText = PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, user); ++ nativeText.write(Types.STRING, NativeDeathText.CHANNEL); ++ nativeText.write(NativeDeathText.TYPE, new NativeDeathText(player.nativeActorSnapshot().lifetime(), showReason ? source : "", projected)); ++ nativeText.send(BedrockProtocol.class); ++ } + final PacketWrapper death = PacketWrapper.create(ClientboundPackets26_3.PLAYER_COMBAT_KILL, user); + death.write(Types.VAR_INT, player.javaId()); // entity id + death.write(Types.TAG, showReason ? TextUtil.textComponentToNbt(message) : TextUtil.stringToNbt("")); // message +diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java +index 4cb81aa6..5339fe66 100644 +--- a/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java ++++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java +@@ -501,7 +501,9 @@ public final class HudPackets { + final String[] parameters = wrapper.read(BedrockTypes.STRING_ARRAY); // parameters + + final Function translator = wrapper.user().get(ResourcePackStorage.class).getTexts().lookup(); +- gameSession.setDeathMessage(TextUtil.stringToTextComponent(BedrockTranslator.translate(message, translator, parameters))); ++ final String translated = BedrockTranslator.translate(message, translator, parameters); ++ gameSession.setDeathMessageSource(translated); ++ gameSession.setDeathMessage(TextUtil.stringToTextComponent(translated)); + PacketFactory.sendJavaDeathMessage(wrapper.user()); + }); + } +diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/GameSessionStorage.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/GameSessionStorage.java +index 814adc90..4fdf1508 100644 +--- a/src/main/java/net/raphimc/viabedrock/protocol/storage/GameSessionStorage.java ++++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/GameSessionStorage.java +@@ -61,6 +61,7 @@ public class GameSessionStorage extends StoredObject { + + private boolean immutableWorld; + private TextComponent deathMessage; ++ private String deathMessageSource; + + public GameSessionStorage(final UserConnection user) { + super(user); +@@ -237,6 +238,14 @@ public class GameSessionStorage extends StoredObject { + this.immutableWorld = immutableWorld; + } + ++ public String getDeathMessageSource() { ++ return this.deathMessageSource; ++ } ++ ++ public void setDeathMessageSource(final String deathMessageSource) { ++ this.deathMessageSource = deathMessageSource; ++ } ++ + public TextComponent getDeathMessage() { + return this.deathMessage; + } +diff --git a/src/test/java/net/raphimc/viabedrock/protocol/packet/DeathMessagePacketsTest.java b/src/test/java/net/raphimc/viabedrock/protocol/packet/DeathMessagePacketsTest.java +index 20c41ece..b678584f 100644 +--- a/src/test/java/net/raphimc/viabedrock/protocol/packet/DeathMessagePacketsTest.java ++++ b/src/test/java/net/raphimc/viabedrock/protocol/packet/DeathMessagePacketsTest.java +@@ -20,9 +20,12 @@ import java.lang.reflect.Field; + import java.util.ArrayList; + import java.util.List; + import java.util.Map; ++import java.util.UUID; + import java.util.concurrent.atomic.AtomicBoolean; + import java.util.function.Consumer; + import java.util.function.Function; ++import net.raphimc.viabedrock.api.model.NativeActorMessage; ++import net.raphimc.viabedrock.api.model.NativeDeathText; + import net.raphimc.viabedrock.api.model.entity.ClientPlayerEntity; + import net.raphimc.viabedrock.api.resourcepack.definition.TextDefinitions; + import net.raphimc.viabedrock.protocol.BedrockProtocol; +@@ -79,6 +82,42 @@ final class DeathMessagePacketsTest { + } + } + ++ @Test ++ void retainsRawFormattingBeforeTheMatchingJavaProjectionAndVisibilityChanges() throws Exception { ++ try (var fixture = new Fixture()) { ++ fixture.nativeChannel(true); ++ final String source = "§c Native §lreason§r "; ++ fixture.info(source); ++ assertTrue(fixture.nativeTexts.isEmpty()); ++ fixture.death(); ++ assertEquals(new NativeDeathText(fixture.lifetime, source, " Native reason "), fixture.nativeTexts.getFirst()); ++ fixture.showReason(false); ++ assertEquals(new NativeDeathText(fixture.lifetime, "", ""), fixture.nativeTexts.getLast()); ++ assertEquals(source, fixture.session.getDeathMessageSource()); ++ fixture.showReason(true); ++ assertEquals(new NativeDeathText(fixture.lifetime, source, " Native reason "), fixture.nativeTexts.getLast()); ++ assertEquals(List.of("native", "java", "native", "java", "native", "java"), fixture.emitted); ++ assertEquals(List.of(" Native reason ", "", " Native reason "), fixture.messages); ++ fixture.channels.removeChannels(List.of(NativeDeathText.CHANNEL)); ++ fixture.showReason(false); ++ assertEquals(3, fixture.nativeTexts.size()); ++ assertEquals("", fixture.messages.getLast()); ++ } ++ } ++ ++ @Test ++ void retainsJavaDeathMessagesWithoutNativeActorNegotiationOrForOversizedSource() throws Exception { ++ for (final boolean oversized : List.of(false, true)) { ++ try (var fixture = new Fixture()) { ++ fixture.nativeChannel(oversized); ++ fixture.info(oversized ? "§l".repeat(20000) + "a" : "a"); ++ fixture.death(); ++ assertEquals(List.of("a"), fixture.messages); ++ assertTrue(fixture.nativeTexts.isEmpty()); ++ } ++ } ++ } ++ + private static GameRulesStorage rules(final UserConnection user) throws Exception { + final Field defaults = BedrockProtocol.MAPPINGS.getClass().getDeclaredField("bedrockGameRules"); + defaults.setAccessible(true); +@@ -97,6 +136,10 @@ final class DeathMessagePacketsTest { + private final GameSessionStorage session = mock(GameSessionStorage.class, CALLS_REAL_METHODS); + private final AtomicBoolean dead = new AtomicBoolean(); + private GameRulesStorage rules; ++ private final ChannelStorage channels = new ChannelStorage(); ++ private final UUID lifetime = UUID.fromString("42424242-1111-2222-3333-121212121212"); ++ private final List nativeTexts = new ArrayList<>(); ++ private final List emitted = new ArrayList<>(); + private final List messages = new ArrayList<>(); + private final MockedStatic packets = mockStatic(PacketWrapper.class); + private final TestProtocol protocol = new TestProtocol(); +@@ -111,7 +154,7 @@ final class DeathMessagePacketsTest { + final var texts = mock(TextDefinitions.class); + final Map, Object> storage = Map.of(EntityTracker.class, entities, + GameSessionStorage.class, this.session, GameRulesStorage.class, this.rules, +- ResourcePackStorage.class, resources, ChannelStorage.class, new ChannelStorage()); ++ ResourcePackStorage.class, resources, ChannelStorage.class, this.channels); + when(this.user.get(any())).thenAnswer(call -> storage.get(call.getArgument(0))); + when(entities.getClientPlayer()).thenReturn(this.player); + when(entities.getEntityByRid(42L)).thenReturn(this.player); +@@ -124,6 +167,25 @@ final class DeathMessagePacketsTest { + .when(this.player).setHealth(anyFloat()); + when(resources.getTexts()).thenReturn(texts); + when(texts.lookup()).thenReturn(Function.identity()); ++ this.packets.when(() -> PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, this.user)) ++ .thenAnswer(call -> { ++ final var output = spy(new PacketWrapperImpl(ClientboundPackets26_3.CUSTOM_PAYLOAD, null, this.user)); ++ doAnswer(send -> { ++ final var buffer = Unpooled.buffer(); ++ try { ++ output.writeToBuffer(buffer); ++ assertEquals(ClientboundPackets26_3.CUSTOM_PAYLOAD.getId(), Types.VAR_INT.readPrimitive(buffer)); ++ assertEquals(NativeDeathText.CHANNEL, Types.STRING.read(buffer)); ++ this.nativeTexts.add(NativeDeathText.TYPE.read(buffer)); ++ assertFalse(buffer.isReadable()); ++ this.emitted.add("native"); ++ } finally { ++ buffer.release(); ++ } ++ return null; ++ }).when(output).send(BedrockProtocol.class); ++ return output; ++ }); + this.packets.when(() -> PacketWrapper.create(ClientboundPackets26_3.PLAYER_COMBAT_KILL, this.user)) + .thenAnswer(call -> { + final var output = spy(new PacketWrapperImpl(ClientboundPackets26_3.PLAYER_COMBAT_KILL, null, this.user)); +@@ -134,6 +196,7 @@ final class DeathMessagePacketsTest { + assertEquals(ClientboundPackets26_3.PLAYER_COMBAT_KILL.getId(), Types.VAR_INT.readPrimitive(buffer)); + assertEquals(7, Types.VAR_INT.readPrimitive(buffer)); + final Tag reason = Types.TAG.read(buffer); ++ this.emitted.add("java"); + this.messages.add(ProtocolConstants.JAVA_TEXT_COMPONENT_SERIALIZER + .deserializeNbtTree(reason).asUnformattedString()); + assertFalse(buffer.isReadable()); +@@ -146,13 +209,27 @@ final class DeathMessagePacketsTest { + }); + } + ++ private void nativeChannel(final boolean actorState) { ++ this.channels.addChannels(List.of(NativeDeathText.CHANNEL)); ++ if (actorState) { ++ this.channels.addChannels(List.of(NativeActorMessage.CHANNEL)); ++ final var snapshot = mock(NativeActorMessage.Snapshot.class); ++ when(snapshot.lifetime()).thenReturn(this.lifetime); ++ when(this.player.nativeActorSnapshot()).thenReturn(snapshot); ++ } ++ } ++ + private void showReason(final boolean show) { + this.rules.updateGameRules(new GameRule[]{new GameRule("showDeathMessages", false, show)}); + } + + private void info() throws Exception { ++ this.info("Controlled death reason"); ++ } ++ ++ private void info(final String source) throws Exception { + this.receive(ClientboundBedrockPackets.DEATH_INFO, buffer -> { +- BedrockTypes.STRING.write(buffer, "Controlled death reason"); ++ BedrockTypes.STRING.write(buffer, source); + BedrockTypes.STRING_ARRAY.write(buffer, new String[0]); + }); + } +-- +2.56.0 diff --git a/patches/viabedrock/upstreamable/0112-retain-native-death-text.pr.md b/patches/viabedrock/upstreamable/0112-retain-native-death-text.pr.md new file mode 100644 index 00000000..88537cf2 --- /dev/null +++ b/patches/viabedrock/upstreamable/0112-retain-native-death-text.pr.md @@ -0,0 +1,16 @@ +# Retain native death text + +Preserve translated DeathInfo text before Java formatting conversion and send it to negotiated native-screen clients before the Java death packet. +The payload carries the actor lifetime and the plain Java projection. Visibility changes hide or restore both representations. +Clients without both channels, and source text outside the bounded transport, keep the Java path. + +Original 1.26.60.5 formatter controls establish boundary spaces, palette-colored shadows and case-sensitive codes. +Packet tests cover source retention, ordering, visibility, capability gating and oversized-source fallback. +The full 109-patch core build and Checkstyle pass: 2,148 tests pass and 176 skip. + +Installed software controls use an isolated screen candidate on direct and freshly rebuilt ViaProxy routes. +Both routes pass 25 stages covering packet order, visibility, capability changes, source updates with identical Java components, and usable fallback for oversized sources. +Declared searching/ready respawn responses at the loaded spawn verify retry cancellation and source retirement across Java player replacement, followed by another death. +Complete journals retain the original bootstrap and all 26 declared Bedrock packets in order. + +Screen presentation remains a separate patch. BDS respawn readiness, native activation timing, complete styled layout and native final pixels remain unverified. diff --git a/patches/viafabricplus-bedrock/deferred/0001-allow-confirmed-blacklisted-server-joins.patch b/patches/viafabricplus-bedrock/deferred/0001-allow-confirmed-blacklisted-server-joins.patch index a95c9ad0..0d1d276e 100644 --- a/patches/viafabricplus-bedrock/deferred/0001-allow-confirmed-blacklisted-server-joins.patch +++ b/patches/viafabricplus-bedrock/deferred/0001-allow-confirmed-blacklisted-server-joins.patch @@ -1,4 +1,4 @@ -From 0a76d82100ed213fb903e3776f371b3e141d3216 Mon Sep 17 00:00:00 2001 +From bbf881d4d748618201c1fb38f412a50a70459ce1 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:06:07 +0100 Subject: [PATCH] feat(connection): allow confirmed blacklisted server joins @@ -247,10 +247,10 @@ index f345d7c..40e7ae9 100644 "command_block.viafabricplus.first_tick": "Execute on first tick", "base.viafabricplus.accept": "Accept", diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json -index ddd097f..0f32d42 100644 +index 0081123..da4d3e1 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -49,6 +49,8 @@ +@@ -50,6 +50,8 @@ "core.access.MixinEventLoopGroupHolder", "core.access.MixinServerAddress", "core.connection.MixinConnectScreen_1", diff --git a/patches/viafabricplus-bedrock/series.json b/patches/viafabricplus-bedrock/series.json index 5aca044b..2ccffd19 100644 --- a/patches/viafabricplus-bedrock/series.json +++ b/patches/viafabricplus-bedrock/series.json @@ -58,6 +58,16 @@ "project": "viabedrock", "patch": "0085-resolve-server-effects-relative-to-their-actor.patch", "reason": "Publish native animation-controller command snapshots consumed by the shared graph runtime." + }, + { + "project": "viabedrock", + "patch": "0078-preserve-native-identifiers-in-java-item-data.patch", + "reason": "Provide the optional native air-continuation item field consumed by the shared metadata cache." + }, + { + "project": "viabedrock", + "patch": "0092-retain-completed-client-prediction-frames.patch", + "reason": "Publish optional physical geometry in the accepted native world dictionary before owner-bound client queries." } ] }, @@ -113,7 +123,19 @@ }, { "file": "0017-apply-custom-block-shapes.patch", - "title": "feat(blocks): apply native custom block shapes and lighting" + "title": "feat(blocks): apply native custom block shapes and lighting", + "prerequisites": [ + { + "project": "viabedrock", + "patch": "0054-render-native-block-state-geometry-and-materials.patch", + "reason": "Provide shared native physical bounds and offsets independently of compiled visuals." + }, + { + "project": "viafabricplus-bedrock", + "patch": "0008-share-native-actor-and-resource-runtime.patch", + "reason": "Provide the accepted level-owned native block mirror before camera geometry queries." + } + ] }, { "file": "0013-resolve-licensed-native-vanilla-images.patch", @@ -316,6 +338,11 @@ "project": "viafabricplus-bedrock", "patch": "0020-apply-server-movement-and-camera-permissions.patch", "reason": "Land direct/proxy connection capability tracking before enabling native block-use lifetimes." + }, + { + "project": "viafabricplus-bedrock", + "patch": "0008-share-native-actor-and-resource-runtime.patch", + "reason": "Decode native item predicates independently of their Java carriers before selecting held input routes." } ] }, @@ -402,6 +429,17 @@ "reason": "Use the existing physical replay scope to prevent live remote interpolation during historical simulation." } ] + }, + { + "file": "0042-retain-bedrock-death-screen-lifetimes.patch", + "title": "fix(ui): retain Bedrock death screen lifetimes", + "prerequisites": [ + { + "project": "viafabricplus-bedrock", + "patch": "0020-apply-server-movement-and-camera-permissions.patch", + "reason": "Recognize Bedrock sessions across direct and ViaProxy connections." + } + ] } ], "deferred": [ diff --git a/patches/viafabricplus-bedrock/setup/0001-stackanvil-branding.patch b/patches/viafabricplus-bedrock/setup/0001-stackanvil-branding.patch index 4eb479c8..3a95b461 100644 --- a/patches/viafabricplus-bedrock/setup/0001-stackanvil-branding.patch +++ b/patches/viafabricplus-bedrock/setup/0001-stackanvil-branding.patch @@ -1,4 +1,4 @@ -From cc6281f481b81d4a67f7a57e37e779fe5b1dd681 Mon Sep 17 00:00:00 2001 +From fbab068879ed7bf612d5c3ea0c1dfbf884a10bd9 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:55:34 +0200 Subject: [PATCH] chore(build): configure StackAnvil identity and compatibility @@ -7,17 +7,17 @@ Keep StackAnvil artifact identity and verified local dependency routing in the setup layer. Follow the current upstream package structure and omit the removed debug HUD integration. Exclude setup from upstream PRs. --- - build.gradle.kts | 20 +++++++++++++++++-- + build.gradle.kts | 29 +++++++++++++++++-- gradle.properties | 2 +- - settings.gradle.kts | 13 ++++++++++++ - .../settings/impl/BedrockSettings.java | 6 ------ - 4 files changed, 32 insertions(+), 9 deletions(-) + settings.gradle.kts | 13 +++++++++ + .../settings/impl/BedrockSettings.java | 6 ---- + 4 files changed, 41 insertions(+), 9 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts -index 200aa63..a38c2db 100644 +index 200aa63..832bcee 100644 --- a/build.gradle.kts +++ b/build.gradle.kts -@@ -5,10 +5,26 @@ plugins { +@@ -5,10 +5,35 @@ plugins { id("via.maven_publish") } @@ -36,6 +36,15 @@ index 200aa63..a38c2db 100644 +tasks.withType().configureEach { + manifest.attributes["Implementation-Vendor"] = "StackAnvil" +} ++ ++val bedrockOreUiShaper = providers.gradleProperty("bedrockOreUiShaper") ++if (bedrockOreUiShaper.isPresent) { ++ sourceSets.main { resources.srcDir(bedrockOreUiShaper) } ++ sourceSets.test { resources.srcDir(bedrockOreUiShaper) } ++ tasks.withType().configureEach { ++ jvmArgs("--enable-native-access=ALL-UNNAMED") ++ } ++} + dependencies { - implementation(libs.viafabricplus) diff --git a/patches/viafabricplus-bedrock/upstreamable/0001-social-party-realms.patch b/patches/viafabricplus-bedrock/upstreamable/0001-social-party-realms.patch index 69d42589..dcdc1f9c 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0001-social-party-realms.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0001-social-party-realms.patch @@ -1,4 +1,4 @@ -From c93e143d3a76ec75449ec230ef18b79b7b5a02a4 Mon Sep 17 00:00:00 2001 +From 0f58ee2db51b340af87883e73fbcd7a94729731a Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:55:34 +0200 Subject: [PATCH] feat(bedrock): add social, party, and Realm Hub support @@ -252,10 +252,10 @@ which must compile before the downstream entity rendering patch applies. create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/party/BedrockPartyDestinationTest.java diff --git a/build.gradle.kts b/build.gradle.kts -index a38c2db..6936fa8 100644 +index 832bcee..7ba0932 100644 --- a/build.gradle.kts +++ b/build.gradle.kts -@@ -38,4 +38,18 @@ dependencies { +@@ -47,4 +47,18 @@ dependencies { exclude(group = "dev.opencollab", module = "libdatachannel-java") } jarInJar(libs.libdatachannel.java.arch.detect) diff --git a/patches/viafabricplus-bedrock/upstreamable/0002-native-entities-and-skins.patch b/patches/viafabricplus-bedrock/upstreamable/0002-native-entities-and-skins.patch index 6f0c9a52..3f7de4ab 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0002-native-entities-and-skins.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0002-native-entities-and-skins.patch @@ -1,4 +1,4 @@ -From 143796b877eeaf9ee0b15f4cc7aec8503afe3cae Mon Sep 17 00:00:00 2001 +From 762a10a9b56dbbfb40fcd37719e839c6ccfa75cb Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:55:34 +0200 Subject: [PATCH] feat(bedrock): render custom entities and player skins @@ -793,128 +793,146 @@ the movement controller and current profile entered Spectator while the entity stayed Creative. Refresh the cached owner only on a direct Bedrock connection or after accepted native rendering metadata on a proxy route. Ordinary Java connections keep their existing cache behavior. + +Support native pattern and pattern_glint actor materials with immutable +per-draw colors and atlas UV transforms. The licensed 1.26.60.5 executable +and version26 ActorPattern/ActorPatternGlint materials establish a base +layer followed by at most six patterns, sampler0 for the authored shield +image and sampler2 for the pattern atlas. Preserve opacity independently +of source texture alpha. Pattern glint adds squared radiance before fog; +the existing ENTITY glint family keeps its distinct order. + +Read-only native draw observations verify the red base and white stripe +inputs. Current original SM60 fragment bytecode passes 512 independent +host arithmetic controls. Retain GPU comparisons as optional private +fixture tests. Shader-pack sampling composes the same ordered layers +before pack shading, with per-draw inputs cleared for ordinary Java draws. --- build.gradle.kts | 6 + settings.gradle.kts | 3 + - .../bedrock/ViaFabricPlusBedrock.java | 23 ++ + .../bedrock/ViaFabricPlusBedrock.java | 23 + .../bedrock/injection/BedrockMixinPlugin.java | 32 ++ - .../access/IBedrockActorDynamicData.java | 16 + - .../IBedrockActorPreparedRenderType.java | 15 + - .../access/IBedrockActorRenderType.java | 20 + + .../access/IBedrockActorDynamicData.java | 19 + + .../IBedrockActorPreparedRenderType.java | 18 + + .../access/IBedrockActorRenderType.java | 23 + .../access/IBedrockDynamicTexture.java | 10 + .../access/IBedrockShaderPipeline.java | 10 + - .../mixin/compat/emf/MixinEmfModelRoots.java | 24 ++ - .../iris/IrisProgramSourceConstructor.java | 24 ++ + .../mixin/compat/emf/MixinEmfModelRoots.java | 24 + + .../iris/IrisProgramSourceConstructor.java | 24 + .../compat/iris/MixinIrisAlphaSampler.java | 33 ++ .../compat/iris/MixinIrisAlphaTransform.java | 30 ++ - .../compat/iris/MixinIrisAlphaUniforms.java | 26 ++ + .../compat/iris/MixinIrisAlphaUniforms.java | 26 + .../compat/iris/MixinIrisEmissionLookup.java | 36 ++ - .../iris/MixinIrisEmissionPrograms.java | 76 ++++ + .../iris/MixinIrisEmissionPrograms.java | 76 +++ .../compat/iris/MixinIrisFallbackAlpha.java | 56 +++ .../iris/MixinIrisFallbackTransform.java | 40 ++ .../compat/iris/MixinIrisGlintLookup.java | 33 ++ - .../compat/iris/MixinIrisGlintPrograms.java | 77 ++++ - .../compat/iris/MixinIrisGlintTransform.java | 27 ++ + .../compat/iris/MixinIrisGlintPrograms.java | 77 +++ + .../compat/iris/MixinIrisGlintTransform.java | 27 + .../compat/iris/MixinIrisJavaAlphaTest.java | 29 ++ .../compat/iris/MixinIrisMaterialAlpha.java | 40 ++ .../iris/MixinIrisNativeMaterialBinding.java | 44 ++ .../MixinIrisNativeSamplerReservation.java | 30 ++ - .../mixin/compat/iris/MixinIrisPipelines.java | 23 ++ + .../mixin/compat/iris/MixinIrisPipelines.java | 23 + .../compat/iris/MixinNativeShaderBinding.java | 38 ++ .../iris/MixinNativeShaderCompilation.java | 30 ++ .../render/MixinAbstractClientPlayer.java | 55 +++ - .../render/MixinBedrockActorDynamicData.java | 64 +++ + .../render/MixinBedrockActorDynamicData.java | 82 ++++ .../render/MixinBedrockActorGlintFrame.java | 33 ++ - .../render/MixinBedrockActorPipelines.java | 27 ++ - .../MixinBedrockActorPreparedRenderType.java | 58 +++ - .../render/MixinBedrockActorRenderType.java | 81 ++++ + .../render/MixinBedrockActorPipelines.java | 27 + + .../MixinBedrockActorPreparedRenderType.java | 74 +++ + .../render/MixinBedrockActorRenderType.java | 92 ++++ .../render/MixinBedrockActorSubmission.java | 33 ++ .../render/MixinBedrockDynamicTexture.java | 29 ++ .../render/MixinBedrockNameTagMetadata.java | 33 ++ .../render/MixinBedrockNameTagRenderer.java | 56 +++ - .../features/render/MixinBedrockNameTags.java | 72 ++++ + .../features/render/MixinBedrockNameTags.java | 72 +++ .../render/MixinBedrockRidingMetadata.java | 38 ++ - .../render/MixinBedrockRidingPosition.java | 27 ++ - .../render/MixinEntityRenderDispatcher.java | 93 +++++ + .../render/MixinBedrockRidingPosition.java | 27 + + .../render/MixinEntityRenderDispatcher.java | 93 ++++ .../render/MixinMinecraftSkinLifecycle.java | 41 ++ - .../features/render/MixinPlayerInfoSkin.java | 24 ++ + .../features/render/MixinPlayerInfoSkin.java | 24 + .../render/MixinPlayerSkinRenderCache.java | 55 +++ .../render/MixinPlayerSkinWidget.java | 28 ++ .../render/MixinPlayerTabOverlay.java | 22 + - .../render/BedrockAcceptedResources.java | 53 +++ - .../bedrock/render/BedrockActorDepth.java | 52 +++ + .../render/BedrockAcceptedResources.java | 53 ++ + .../bedrock/render/BedrockActorDepth.java | 52 ++ .../bedrock/render/BedrockActorGlint.java | 42 ++ .../render/BedrockActorGlintFrames.java | 37 ++ - .../BedrockActorMaterialDefinitions.java | 211 ++++++++++ + .../BedrockActorMaterialDefinitions.java | 233 +++++++++ .../bedrock/render/BedrockActorMaterials.java | 56 +++ - .../render/BedrockActorRenderTypes.java | 215 ++++++++++ + .../bedrock/render/BedrockActorPattern.java | 41 ++ + .../render/BedrockActorRenderTypes.java | 248 ++++++++++ .../bedrock/render/BedrockActorResources.java | 43 ++ .../bedrock/render/BedrockActorTint.java | 34 ++ .../render/BedrockActorUvTransform.java | 18 + - .../bedrock/render/BedrockEmissiveMask.java | 27 ++ - .../bedrock/render/BedrockEntityRenderer.java | 195 +++++++++ - .../bedrock/render/BedrockGeometry.java | 227 ++++++++++ + .../bedrock/render/BedrockEmissiveMask.java | 27 + + .../bedrock/render/BedrockEntityRenderer.java | 195 ++++++++ + .../bedrock/render/BedrockGeometry.java | 227 +++++++++ .../bedrock/render/BedrockModelCube.java | 35 ++ - .../bedrock/render/BedrockNameTagAnchors.java | 75 ++++ - .../bedrock/render/BedrockNameTags.java | 76 ++++ + .../bedrock/render/BedrockNameTagAnchors.java | 75 +++ + .../bedrock/render/BedrockNameTags.java | 76 +++ .../render/BedrockPlayerLayerModels.java | 57 +++ - .../bedrock/render/BedrockPlayerRenderer.java | 54 +++ - .../bedrock/render/BedrockPlayerSkins.java | 289 +++++++++++++ - .../render/BedrockRenderSnapshotCache.java | 48 +++ - .../bedrock/render/BedrockRidingAnchors.java | 73 ++++ - .../bedrock/render/BedrockShaderAlpha.java | 52 +++ - .../bedrock/render/BedrockShaderEmission.java | 208 ++++++++++ + .../bedrock/render/BedrockPlayerRenderer.java | 54 ++ + .../bedrock/render/BedrockPlayerSkins.java | 289 +++++++++++ + .../render/BedrockRenderSnapshotCache.java | 48 ++ + .../bedrock/render/BedrockRidingAnchors.java | 73 +++ + .../bedrock/render/BedrockShaderAlpha.java | 52 ++ + .../bedrock/render/BedrockShaderEmission.java | 208 ++++++++ + .../bedrock/render/BedrockShaderPattern.java | 26 + .../render/BedrockShaderPipelines.java | 55 +++ - .../bedrock/render/BedrockShaderTint.java | 26 ++ + .../bedrock/render/BedrockShaderTint.java | 26 + .../bedrock/render/BedrockSkinImages.java | 113 +++++ - .../render/CustomEntityRenderStore.java | 71 ++++ - .../render/NativeCustomEntityPayload.java | 24 ++ - .../NativeCustomEntitySamplerPayload.java | 24 ++ + .../render/CustomEntityRenderStore.java | 71 +++ + .../render/NativeCustomEntityPayload.java | 24 + + .../NativeCustomEntitySamplerPayload.java | 24 + .../bedrock/render/NativeSkinPayload.java | 22 + - .../render/compat/iris/BedrockIrisAlpha.java | 106 +++++ - .../compat/iris/BedrockIrisAlphaUniforms.java | 51 +++ + .../render/compat/iris/BedrockIrisAlpha.java | 106 ++++ + .../compat/iris/BedrockIrisAlphaUniforms.java | 76 +++ .../compat/iris/BedrockIrisEmission.java | 16 + - .../render/compat/iris/BedrockIrisGlint.java | 143 +++++++ + .../render/compat/iris/BedrockIrisGlint.java | 143 ++++++ .../compat/iris/BedrockIrisGlintPrograms.java | 16 + - .../compat/iris/BedrockIrisMaskedTexture.java | 153 +++++++ - .../compat/iris/BedrockIrisSamplers.java | 18 + - .../shaders/core/bedrock_actor.fsh | 100 +++++ - .../shaders/core/bedrock_actor.vsh | 77 ++++ + .../compat/iris/BedrockIrisMaskedTexture.java | 170 +++++++ + .../compat/iris/BedrockIrisSamplers.java | 19 + + .../shaders/core/bedrock_actor.fsh | 123 +++++ + .../shaders/core/bedrock_actor.vsh | 77 +++ .../shaders/include/bedrock_glint.glsl | 14 + + .../shaders/include/bedrock_pattern.glsl | 14 + .../shaders/include/bedrock_player_fade.glsl | 15 + .../viafabricplus-bedrock.accesswidener | 6 + .../viafabricplus-bedrock.mixins.json | 39 ++ - .../render/BedrockAcceptedResourcesTest.java | 118 ++++++ - .../render/BedrockActorDepthGpuTest.java | 123 ++++++ + .../render/BedrockAcceptedResourcesTest.java | 118 +++++ + .../render/BedrockActorDepthGpuTest.java | 123 +++++ .../render/BedrockActorGlintFramesTest.java | 41 ++ - .../render/BedrockActorGlintGpuTest.java | 327 +++++++++++++++ + .../render/BedrockActorGlintGpuTest.java | 327 ++++++++++++ .../bedrock/render/BedrockActorGlintTest.java | 42 ++ - .../BedrockActorMaterialDefinitionsTest.java | 307 ++++++++++++++ - .../render/BedrockActorMaterialsGpuTest.java | 211 ++++++++++ - .../render/BedrockActorMaterialsTest.java | 48 +++ + .../BedrockActorMaterialDefinitionsTest.java | 327 ++++++++++++ + .../render/BedrockActorMaterialsGpuTest.java | 211 ++++++++ + .../render/BedrockActorMaterialsTest.java | 48 ++ + .../render/BedrockActorPatternGpuTest.java | 166 +++++++ .../bedrock/render/BedrockActorTintTest.java | 32 ++ - .../render/BedrockActorUvTransformTest.java | 27 ++ - .../bedrock/render/BedrockCubeFacesTest.java | 182 ++++++++ + .../render/BedrockActorUvTransformTest.java | 27 + + .../bedrock/render/BedrockCubeFacesTest.java | 182 +++++++ .../render/BedrockEmissiveMaskTest.java | 36 ++ - .../render/BedrockEntityRendererTest.java | 50 +++ - .../bedrock/render/BedrockFaceUvsTest.java | 100 +++++ - .../bedrock/render/BedrockModelCubeTest.java | 47 +++ - .../render/BedrockNameTagAnchorsTest.java | 120 ++++++ - .../bedrock/render/BedrockNameTagsTest.java | 127 ++++++ - .../render/BedrockPlayerFadeGpuTest.java | 245 +++++++++++ - .../render/BedrockPlayerLayerModelsTest.java | 103 +++++ + .../render/BedrockEntityRendererTest.java | 50 ++ + .../bedrock/render/BedrockFaceUvsTest.java | 100 ++++ + .../bedrock/render/BedrockModelCubeTest.java | 47 ++ + .../render/BedrockNameTagAnchorsTest.java | 120 +++++ + .../bedrock/render/BedrockNameTagsTest.java | 127 +++++ + .../render/BedrockPlayerFadeGpuTest.java | 245 +++++++++ + .../render/BedrockPlayerLayerModelsTest.java | 103 ++++ .../render/BedrockPlayerMaterialsTest.java | 42 ++ - .../BedrockRenderSnapshotCacheTest.java | 123 ++++++ - .../render/BedrockRidingAnchorsTest.java | 149 +++++++ + .../BedrockRenderSnapshotCacheTest.java | 123 +++++ + .../render/BedrockRidingAnchorsTest.java | 149 ++++++ .../render/BedrockShaderAlphaTest.java | 43 ++ - .../render/BedrockShaderPipelinesTest.java | 119 ++++++ + .../render/BedrockShaderPipelinesTest.java | 119 +++++ .../bedrock/render/BedrockShaderTintTest.java | 31 ++ .../bedrock/render/BedrockSkinImagesTest.java | 82 ++++ .../render/CustomEntityRenderStoreTest.java | 84 ++++ - .../bedrock/render/EglTestContext.java | 52 +++ - .../compat/iris/BedrockIrisAlphaGpuTest.java | 391 ++++++++++++++++++ - .../compat/iris/BedrockIrisGlintGpuTest.java | 253 ++++++++++++ - 120 files changed, 8641 insertions(+) + .../bedrock/render/EglTestContext.java | 52 ++ + .../compat/iris/BedrockIrisAlphaGpuTest.java | 464 ++++++++++++++++++ + .../compat/iris/BedrockIrisGlintGpuTest.java | 253 ++++++++++ + 124 files changed, 9156 insertions(+) create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/BedrockMixinPlugin.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockActorDynamicData.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockActorPreparedRenderType.java @@ -965,6 +983,7 @@ Ordinary Java connections keep their existing cache behavior. create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorGlintFrames.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorMaterialDefinitions.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorMaterials.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorPattern.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorRenderTypes.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorResources.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorTint.java @@ -982,6 +1001,7 @@ Ordinary Java connections keep their existing cache behavior. create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockRidingAnchors.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderAlpha.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderEmission.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderPattern.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderPipelines.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderTint.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockSkinImages.java @@ -999,6 +1019,7 @@ Ordinary Java connections keep their existing cache behavior. create mode 100644 src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_actor.fsh create mode 100644 src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_actor.vsh create mode 100644 src/main/resources/assets/viafabricplus-bedrock/shaders/include/bedrock_glint.glsl + create mode 100644 src/main/resources/assets/viafabricplus-bedrock/shaders/include/bedrock_pattern.glsl create mode 100644 src/main/resources/assets/viafabricplus-bedrock/shaders/include/bedrock_player_fade.glsl create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockAcceptedResourcesTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorDepthGpuTest.java @@ -1008,6 +1029,7 @@ Ordinary Java connections keep their existing cache behavior. create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorMaterialDefinitionsTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorMaterialsGpuTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorMaterialsTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorPatternGpuTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorTintTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorUvTransformTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockCubeFacesTest.java @@ -1032,10 +1054,10 @@ Ordinary Java connections keep their existing cache behavior. create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/compat/iris/BedrockIrisGlintGpuTest.java diff --git a/build.gradle.kts b/build.gradle.kts -index 6936fa8..80d366b 100644 +index 7ba0932..d5d27f4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts -@@ -23,6 +23,12 @@ tasks.withType().configureEach { +@@ -32,6 +32,12 @@ if (bedrockOreUiShaper.isPresent) { dependencies { implementation(viaFabricPlusDependency) @@ -1143,10 +1165,10 @@ index 0000000..3155d70 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockActorDynamicData.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockActorDynamicData.java new file mode 100644 -index 0000000..4e17e3b +index 0000000..bd6d1e0 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockActorDynamicData.java -@@ -0,0 +1,16 @@ +@@ -0,0 +1,19 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -1157,18 +1179,21 @@ index 0000000..4e17e3b +import com.mojang.renderpearl.api.buffers.GpuBufferSlice; +import com.viaversion.viafabricplus.bedrock.render.BedrockActorGlint; +import com.viaversion.viafabricplus.bedrock.render.BedrockActorTint; ++import com.viaversion.viafabricplus.bedrock.render.BedrockActorPattern; + +public interface IBedrockActorDynamicData { + GpuBufferSlice bedrock$writeGlint(BedrockActorGlint glint); + + GpuBufferSlice bedrock$writeTint(BedrockActorTint tint); ++ ++ GpuBufferSlice bedrock$writePattern(BedrockActorPattern pattern); +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockActorPreparedRenderType.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockActorPreparedRenderType.java new file mode 100644 -index 0000000..d259c3a +index 0000000..ee50fcd --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockActorPreparedRenderType.java -@@ -0,0 +1,15 @@ +@@ -0,0 +1,18 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -1178,18 +1203,21 @@ index 0000000..d259c3a + +import com.mojang.renderpearl.api.buffers.GpuBufferSlice; +import com.viaversion.viafabricplus.bedrock.render.BedrockActorTint; ++import com.viaversion.viafabricplus.bedrock.render.BedrockActorPattern; + +public interface IBedrockActorPreparedRenderType { + void bedrock$glint(GpuBufferSlice slice); + + void bedrock$tint(GpuBufferSlice slice, BedrockActorTint tint); ++ ++ void bedrock$pattern(GpuBufferSlice slice, BedrockActorPattern pattern); +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockActorRenderType.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockActorRenderType.java new file mode 100644 -index 0000000..a55f1b3 +index 0000000..3405f5c --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockActorRenderType.java -@@ -0,0 +1,20 @@ +@@ -0,0 +1,23 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -1199,6 +1227,7 @@ index 0000000..a55f1b3 + +import com.viaversion.viafabricplus.bedrock.render.BedrockActorGlint; +import com.viaversion.viafabricplus.bedrock.render.BedrockActorTint; ++import com.viaversion.viafabricplus.bedrock.render.BedrockActorPattern; +import com.viaversion.viafabricplus.bedrock.render.BedrockActorUvTransform; + +public interface IBedrockActorRenderType { @@ -1209,6 +1238,8 @@ index 0000000..a55f1b3 + void bedrock$glint(BedrockActorGlint glint); + + void bedrock$tint(BedrockActorTint tint); ++ ++ void bedrock$pattern(BedrockActorPattern pattern); +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockDynamicTexture.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockDynamicTexture.java new file mode 100644 @@ -2135,10 +2166,10 @@ index 0000000..eab8d23 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinBedrockActorDynamicData.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinBedrockActorDynamicData.java new file mode 100644 -index 0000000..8c98d3d +index 0000000..ffe2d23 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinBedrockActorDynamicData.java -@@ -0,0 +1,64 @@ +@@ -0,0 +1,82 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -2151,6 +2182,7 @@ index 0000000..8c98d3d +import com.viaversion.viafabricplus.bedrock.injection.access.IBedrockActorDynamicData; +import com.viaversion.viafabricplus.bedrock.render.BedrockActorGlint; +import com.viaversion.viafabricplus.bedrock.render.BedrockActorTint; ++import com.viaversion.viafabricplus.bedrock.render.BedrockActorPattern; +import net.minecraft.client.renderer.DynamicGpuData; +import net.minecraft.client.renderer.DynamicGpuDataStorageMapped; +import org.spongepowered.asm.mixin.Mixin; @@ -2167,6 +2199,18 @@ index 0000000..8c98d3d + @Unique + private DynamicGpuDataStorageMapped bedrock$tint; + ++ @Unique ++ private DynamicGpuDataStorageMapped bedrock$pattern; ++ ++ @Override ++ public GpuBufferSlice bedrock$writePattern(final BedrockActorPattern pattern) { ++ if (this.bedrock$pattern == null) { ++ this.bedrock$pattern = new DynamicGpuDataStorageMapped<>("Bedrock actor pattern", BedrockActorPattern.UNIFORM_SIZE, ++ GpuBuffer.USAGE_UNIFORM, 2); ++ } ++ return this.bedrock$pattern.writeData(pattern); ++ } ++ + @Override + public GpuBufferSlice bedrock$writeTint(final BedrockActorTint tint) { + if (this.bedrock$tint == null) { @@ -2189,10 +2233,15 @@ index 0000000..8c98d3d + private void bedrock$endFrame(final CallbackInfo ci) { + if (this.bedrock$glint != null) this.bedrock$glint.endFrame(); + if (this.bedrock$tint != null) this.bedrock$tint.endFrame(); ++ if (this.bedrock$pattern != null) this.bedrock$pattern.endFrame(); + } + + @Inject(method = "close", at = @At("RETURN")) + private void bedrock$close(final CallbackInfo ci) { ++ if (this.bedrock$pattern != null) { ++ this.bedrock$pattern.close(); ++ this.bedrock$pattern = null; ++ } + if (this.bedrock$tint != null) { + this.bedrock$tint.close(); + this.bedrock$tint = null; @@ -2277,10 +2326,10 @@ index 0000000..63ca5f9 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinBedrockActorPreparedRenderType.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinBedrockActorPreparedRenderType.java new file mode 100644 -index 0000000..0515889 +index 0000000..18d6f3d --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinBedrockActorPreparedRenderType.java -@@ -0,0 +1,58 @@ +@@ -0,0 +1,74 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -2295,7 +2344,9 @@ index 0000000..0515889 +import com.mojang.renderpearl.api.pipeline.RenderPipeline; +import com.viaversion.viafabricplus.bedrock.injection.access.IBedrockActorPreparedRenderType; +import com.viaversion.viafabricplus.bedrock.render.BedrockActorTint; ++import com.viaversion.viafabricplus.bedrock.render.BedrockActorPattern; +import com.viaversion.viafabricplus.bedrock.render.BedrockShaderTint; ++import com.viaversion.viafabricplus.bedrock.render.BedrockShaderPattern; +import net.minecraft.client.renderer.StagedVertexBuffer; +import net.minecraft.client.renderer.rendertype.PreparedRenderType; +import org.spongepowered.asm.mixin.Mixin; @@ -2315,6 +2366,18 @@ index 0000000..0515889 + @Unique + private BedrockActorTint bedrock$sampledTint; + ++ @Unique ++ private GpuBufferSlice bedrock$pattern; ++ ++ @Unique ++ private BedrockActorPattern bedrock$sampledPattern; ++ ++ @Override ++ public void bedrock$pattern(final GpuBufferSlice slice, final BedrockActorPattern pattern) { ++ this.bedrock$pattern = slice; ++ this.bedrock$sampledPattern = pattern; ++ } ++ + @Override + public void bedrock$tint(final GpuBufferSlice slice, final BedrockActorTint tint) { + this.bedrock$tint = slice; @@ -2324,7 +2387,8 @@ index 0000000..0515889 + @WrapMethod(method = "draw") + private void bedrock$scopeTint(final StagedVertexBuffer.ExecuteInfo info, final RenderPass pass, + final RenderPipeline pipeline, final Operation original) { -+ BedrockShaderTint.bind(this.bedrock$sampledTint, () -> original.call(info, pass, pipeline)); ++ BedrockShaderPattern.bind(this.bedrock$sampledPattern, ++ () -> BedrockShaderTint.bind(this.bedrock$sampledTint, () -> original.call(info, pass, pipeline))); + } + + @Override @@ -2337,14 +2401,15 @@ index 0000000..0515889 + final RenderPipeline pipeline, final CallbackInfo ci) { + if (this.bedrock$glint != null) pass.setUniform("BedrockGlintInfo", this.bedrock$glint); + if (this.bedrock$tint != null) pass.setUniform("BedrockTintInfo", this.bedrock$tint); ++ if (this.bedrock$pattern != null) pass.setUniform("BedrockPatternInfo", this.bedrock$pattern); + } +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinBedrockActorRenderType.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinBedrockActorRenderType.java new file mode 100644 -index 0000000..c021af8 +index 0000000..b73ff25 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinBedrockActorRenderType.java -@@ -0,0 +1,81 @@ +@@ -0,0 +1,92 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -2361,6 +2426,7 @@ index 0000000..c021af8 +import com.viaversion.viafabricplus.bedrock.injection.access.IBedrockActorRenderType; +import com.viaversion.viafabricplus.bedrock.render.BedrockActorGlint; +import com.viaversion.viafabricplus.bedrock.render.BedrockActorTint; ++import com.viaversion.viafabricplus.bedrock.render.BedrockActorPattern; +import com.viaversion.viafabricplus.bedrock.render.BedrockActorUvTransform; +import net.minecraft.client.renderer.DynamicGpuData; +import net.minecraft.client.renderer.rendertype.PreparedRenderType; @@ -2388,6 +2454,12 @@ index 0000000..c021af8 + @Unique + private BedrockActorTint bedrock$tint; + ++ @Unique ++ private BedrockActorPattern bedrock$pattern; ++ ++ @Override ++ public void bedrock$pattern(final BedrockActorPattern pattern) { this.bedrock$pattern = pattern; } ++ + @Override + public void bedrock$tint(final BedrockActorTint tint) { this.bedrock$tint = tint; } + @@ -2398,6 +2470,10 @@ index 0000000..c021af8 + + @Inject(method = "prepare", at = @At("RETURN")) + private void bedrock$prepareMaterialUniforms(final CallbackInfoReturnable cir) { ++ if (this.bedrock$pattern != null) { ++ final var slice = ((IBedrockActorDynamicData) RenderSystem.getDynamicUniforms()).bedrock$writePattern(this.bedrock$pattern); ++ ((IBedrockActorPreparedRenderType) (Object) cir.getReturnValue()).bedrock$pattern(slice, this.bedrock$pattern); ++ } + if (this.bedrock$tint != null) { + final var slice = ((IBedrockActorDynamicData) RenderSystem.getDynamicUniforms()).bedrock$writeTint(this.bedrock$tint); + ((IBedrockActorPreparedRenderType) (Object) cir.getReturnValue()).bedrock$tint(slice, this.bedrock$tint); @@ -3265,10 +3341,10 @@ index 0000000..f8df41f +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorMaterialDefinitions.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorMaterialDefinitions.java new file mode 100644 -index 0000000..e851321 +index 0000000..bf972c1 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorMaterialDefinitions.java -@@ -0,0 +1,211 @@ +@@ -0,0 +1,233 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -3299,6 +3375,9 @@ index 0000000..e851321 + // Authored metadata for the target's verified ENTITY material families. + this.add(JsonParser.parseString(""" + {"materials":{ ++ "pattern":{"vertexShader":"shaders/pattern.vertex","fragmentShader":"shaders/pattern.fragment", ++ "defines":["ENABLE_FOG","ENABLE_LIGHT"]}, ++ "pattern_glint:pattern":{"+defines":["GLINT"]}, + "entity":{"vertexShader":"shaders/entity.vertex","fragmentShader":"shaders/entity.fragment", + "defines":["USE_OVERLAY"]}, + "entity_nocull:entity":{"+states":["DisableCulling"]}, @@ -3356,7 +3435,7 @@ index 0000000..e851321 + + /** Color-mask families require a native per-draw tint instead of an assumed white value. */ + public Optional material(final String name, final BedrockActorTint tint) { -+ return this.classify(name).filter(material -> !material.glint() && (!material.colorMask() || tint != null)); ++ return this.classify(name).filter(material -> !material.pattern() && !material.glint() && (!material.colorMask() || tint != null)); + } + + /** Held-item callers provide their extraction frame and native item UV scale. */ @@ -3372,6 +3451,17 @@ index 0000000..e851321 + shaderDefines.remove("USE_UV_ANIM"); + // The target native legacy define map does not assign either name a shader flag. + shaderDefines.removeAll(Set.of("USE_MASK", "Blending")); ++ if (effective != null && effective.verifiedFields ++ && "shaders/pattern.vertex".equals(effective.vertexShader) ++ && "shaders/pattern.fragment".equals(effective.fragmentShader) ++ && shaderDefines.containsAll(Set.of("ENABLE_FOG", "ENABLE_LIGHT")) ++ && Set.of("ENABLE_FOG", "ENABLE_LIGHT", "GLINT").containsAll(shaderDefines) ++ && Set.of("DisableCulling", "DisableDepthTest", "DisableDepthWrite", "DisableAlphaWrite").containsAll(effective.states)) { ++ return BedrockActorDepth.read(effective.depthFunction, effective.states).map(depth -> ++ new Material(false, !effective.states.contains("DisableCulling"), false, false, false, ++ effective.defines.contains("GLINT"), depth, false, ++ !effective.states.contains("DisableAlphaWrite"), true)); ++ } + if (effective == null || !effective.verifiedFields + || !"shaders/entity.vertex".equals(effective.vertexShader) + || !"shaders/entity.fragment".equals(effective.fragmentShader) @@ -3452,7 +3542,7 @@ index 0000000..e851321 + return result; + } + -+ public record Material(boolean alphaTest, boolean cull, boolean emissive, boolean uvAnimation, boolean masked, boolean glint, BedrockActorDepth depth, boolean colorMask, boolean alphaWrite) { ++ public record Material(boolean alphaTest, boolean cull, boolean emissive, boolean uvAnimation, boolean masked, boolean glint, BedrockActorDepth depth, boolean colorMask, boolean alphaWrite, boolean pattern) { + public static final Material CUTOUT = new Material(true, false, false, false, false, false, BedrockActorDepth.DEFAULT); + + public Material(final boolean alphaTest, final boolean cull, final boolean emissive, final boolean uvAnimation, @@ -3465,16 +3555,24 @@ index 0000000..e851321 + this(alphaTest, cull, emissive, uvAnimation, masked, glint, depth, colorMask, true); + } + ++ public Material(final boolean alphaTest, final boolean cull, final boolean emissive, final boolean uvAnimation, ++ final boolean masked, final boolean glint, final BedrockActorDepth depth, ++ final boolean colorMask, final boolean alphaWrite) { ++ this(alphaTest, cull, emissive, uvAnimation, masked, glint, depth, colorMask, alphaWrite, false); ++ } ++ + public Material { + Objects.requireNonNull(depth); ++ if (pattern && (alphaTest || emissive || masked || colorMask || uvAnimation)) ++ throw new IllegalArgumentException("Unsupported native pattern material family"); + if (colorMask && (emissive || masked || glint)) throw new IllegalArgumentException("Unsupported native tint material family"); + if (emissive && !depth.repeatable()) throw new IllegalArgumentException("Unsupported split-emission depth predicate"); + if (glint && (emissive || masked)) throw new IllegalArgumentException("Unsupported native glint material family"); + if (masked && (!alphaTest || emissive)) throw new IllegalArgumentException("Unsupported masked material family"); + } + -+ public int textureCount() { return this.masked || this.glint ? 2 : 1; } -+ public Material withoutEmission() { return new Material(this.alphaTest, this.cull, false, this.uvAnimation, this.masked, this.glint, this.depth, this.colorMask, this.alphaWrite); } ++ public int textureCount() { return this.pattern ? 3 : this.masked || this.glint ? 2 : 1; } ++ public Material withoutEmission() { return new Material(this.alphaTest, this.cull, false, this.uvAnimation, this.masked, this.glint, this.depth, this.colorMask, this.alphaWrite, this.pattern); } + } + private record Definition(String parent, JsonObject fields) {} + private record Effective(String vertexShader, String fragmentShader, String depthFunction, Set defines, @@ -3542,12 +3640,59 @@ index 0000000..8e46c00 + return patternIndex == pattern.length(); + } +} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorPattern.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorPattern.java +new file mode 100644 +index 0000000..4494c38 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorPattern.java +@@ -0,0 +1,41 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.render; ++ ++import com.mojang.blaze3d.buffers.Std140Builder; ++import java.nio.ByteBuffer; ++import java.util.List; ++import net.minecraft.client.renderer.DynamicGpuDataStorage; ++ ++/** Ordered native pattern inputs retained by one prepared draw. */ ++public record BedrockActorPattern(List layers) implements DynamicGpuDataStorage.DynamicGpuData { ++ public static final int MAX_LAYERS = 7; ++ public static final int UNIFORM_SIZE = 16 + MAX_LAYERS * 32; ++ ++ public BedrockActorPattern { ++ layers = List.copyOf(layers); ++ if (layers.isEmpty() || layers.size() > MAX_LAYERS) ++ throw new IllegalArgumentException("Native pattern draws require one base and at most six patterns"); ++ } ++ ++ @Override ++ public void write(final ByteBuffer buffer) { ++ final var builder = Std140Builder.intoBuffer(buffer).putVec4(this.layers.size(), 0F, 0F, 0F); ++ for (int i = 0; i < MAX_LAYERS; i++) { ++ final var layer = i < this.layers.size() ? this.layers.get(i) : Layer.EMPTY; ++ builder.putVec4(layer.red, layer.green, layer.blue, layer.alpha); ++ } ++ for (int i = 0; i < MAX_LAYERS; i++) { ++ final var layer = i < this.layers.size() ? this.layers.get(i) : Layer.EMPTY; ++ builder.putVec4(layer.offsetU, layer.offsetV, layer.scaleU, layer.scaleV); ++ } ++ } ++ ++ public record Layer(float red, float green, float blue, float alpha, ++ float offsetU, float offsetV, float scaleU, float scaleV) { ++ private static final Layer EMPTY = new Layer(0F, 0F, 0F, 0F, 0F, 0F, 0F, 0F); ++ } ++} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorRenderTypes.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorRenderTypes.java new file mode 100644 -index 0000000..516fdbf +index 0000000..42e2ce0 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorRenderTypes.java -@@ -0,0 +1,215 @@ +@@ -0,0 +1,248 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -3601,7 +3746,7 @@ index 0000000..516fdbf + + private static RenderPipeline pipeline(final PipelineKey key) { + final var builder = RenderPipeline.builder(RenderPipelines.ENTITY_SNIPPET) -+ .withLocation(Identifier.fromNamespaceAndPath("viafabricplus-bedrock", "pipeline/actor_" + key.alphaTest + "_" + key.cull + "_" + key.unlit + "_" + key.emissive + "_" + key.nether + (key.playerFade ? "_player_fade" : "") + (key.masked ? "_masked" : "") + (key.colorMask ? "_tint" : "") + (key.alphaWrite ? "" : "_no_alpha") + (key.glint == GlintLighting.NONE ? "" : key.glint == GlintLighting.LIGHTMAP ? "_glint_lightmap" : "_glint") + (key.depth.equals(BedrockActorDepth.DEFAULT) ? "" : "_depth_" + key.depth.suffix()))) ++ .withLocation(Identifier.fromNamespaceAndPath("viafabricplus-bedrock", "pipeline/actor_" + key.alphaTest + "_" + key.cull + "_" + key.unlit + "_" + key.emissive + "_" + key.nether + (key.playerFade ? "_player_fade" : "") + (key.pattern ? "_pattern" : "") + (key.masked ? "_masked" : "") + (key.colorMask ? "_tint" : "") + (key.alphaWrite ? "" : "_no_alpha") + (key.glint == GlintLighting.NONE ? "" : key.glint == GlintLighting.LIGHTMAP ? "_glint_lightmap" : "_glint") + (key.depth.equals(BedrockActorDepth.DEFAULT) ? "" : "_depth_" + key.depth.suffix()))) + .withVertexShader(Identifier.fromNamespaceAndPath("viafabricplus-bedrock", "core/bedrock_actor")) + .withFragmentShader(Identifier.fromNamespaceAndPath("viafabricplus-bedrock", "core/bedrock_actor")) + .withBindGroupLayout(BindGroupLayouts.SAMPLER1) @@ -3609,6 +3754,10 @@ index 0000000..516fdbf + ColorTargetState.DEFAULT.format(), key.alphaWrite ? ColorTargetState.WRITE_ALL : ColorTargetState.WRITE_COLOR)) + .withCull(key.cull) + .withDepthStencilState(key.depth.state()); ++ if (key.pattern) builder.withShaderDefine("PATTERN") ++ .withBindGroupLayout(BindGroupLayout.builder() ++ .withUniform("BedrockPatternInfo", UniformType.UNIFORM_BUFFER) ++ .withUniform("BedrockPatternTexture", UniformType.COMBINED_IMAGE_SAMPLER).build()); + if (key.colorMask) builder.withShaderDefine("COLOR_MASK") + .withBindGroupLayout(BindGroupLayout.builder().withUniform("BedrockTintInfo", UniformType.UNIFORM_BUFFER).build()); + if (key.masked) builder.withShaderDefine("MASKED_MULTITEXTURE") @@ -3670,6 +3819,17 @@ index 0000000..516fdbf + java.util.Objects.requireNonNull(frame).sample(netherLighting, scaleU, scaleV), GlintLighting.LIGHTMAP, null); + } + ++ /** Native patterns keep their ordered atlas inputs and the held item's extraction clock. */ ++ public static RenderType createPattern(final List textures, final BedrockActorMaterialDefinitions.Material material, ++ final boolean ignoreLighting, final float multiplier, final BedrockActorUvTransform uv, ++ final boolean netherLighting, final BedrockActorPattern pattern, final BedrockActorGlintFrames.Frame frame) { ++ if (!material.pattern()) throw new IllegalArgumentException("Expected a native pattern material"); ++ final var glint = material.glint() ? java.util.Objects.requireNonNull(frame).sample(netherLighting, .5F, .5F) : null; ++ return create(textures, material, ignoreLighting, multiplier, uv, netherLighting, glint, ++ glint == null ? GlintLighting.NONE : GlintLighting.LIGHTMAP, null, false, ++ java.util.Objects.requireNonNull(pattern)); ++ } ++ + private static RenderType create(final List textures, final BedrockActorMaterialDefinitions.Material material, + final boolean ignoreLighting, final float multiplier, final BedrockActorUvTransform uv, + final boolean netherLighting, final BedrockActorGlint glint, final GlintLighting lighting, final BedrockActorTint tint) { @@ -3680,6 +3840,14 @@ index 0000000..516fdbf + final boolean ignoreLighting, final float multiplier, final BedrockActorUvTransform uv, + final boolean netherLighting, final BedrockActorGlint glint, final GlintLighting lighting, + final BedrockActorTint tint, final boolean playerFade) { ++ return create(textures, material, ignoreLighting, multiplier, uv, netherLighting, glint, lighting, tint, playerFade, null); ++ } ++ ++ private static RenderType create(final List textures, final BedrockActorMaterialDefinitions.Material material, ++ final boolean ignoreLighting, final float multiplier, final BedrockActorUvTransform uv, ++ final boolean netherLighting, final BedrockActorGlint glint, final GlintLighting lighting, ++ final BedrockActorTint tint, final boolean playerFade, final BedrockActorPattern pattern) { ++ if (material.pattern() != (pattern != null)) throw new IllegalArgumentException("Native pattern input is unavailable"); + if (material.colorMask() && tint == null) throw new IllegalArgumentException("Native tint input is unavailable"); + final var pipeline = glint == null ? selectPipeline(material, ignoreLighting, netherLighting, playerFade) + : selectGlintPipeline(material, ignoreLighting, netherLighting, lighting, playerFade); @@ -3692,12 +3860,13 @@ index 0000000..516fdbf + ((IBedrockActorRenderType) type).bedrock$lightColorMultiplier(multiplier); + ((IBedrockActorRenderType) type).bedrock$uvTransform(uv); + ((IBedrockActorRenderType) type).bedrock$glint(glint); ++ ((IBedrockActorRenderType) type).bedrock$pattern(pattern); + ((IBedrockActorRenderType) type).bedrock$tint(material.colorMask() ? tint : null); + if (material.emissive()) BedrockShaderEmission.register(type, textures.getFirst(), material, ignoreLighting, multiplier, uv, netherLighting, playerFade); + if (!playerFade) { + final var bindings = List.copyOf(textures); + PLAYER_TYPES.put(type, Suppliers.memoize(() -> create(bindings, material, ignoreLighting, -+ multiplier, uv, netherLighting, glint, lighting, tint, true))); ++ multiplier, uv, netherLighting, glint, lighting, tint, true, pattern))); + } + return type; + } @@ -3717,7 +3886,7 @@ index 0000000..516fdbf + final boolean ignoreLighting, final boolean netherLighting, final boolean playerFade) { + if (material.glint()) throw new IllegalArgumentException("Native glint requires caller-owned frame inputs"); + return MATERIAL_PIPELINES.computeIfAbsent(new PipelineKey(material.alphaTest(), material.cull(), ignoreLighting, -+ material.emissive(), netherLighting && !ignoreLighting, material.masked(), material.colorMask(), GlintLighting.NONE, material.depth(), material.alphaWrite(), playerFade), BedrockActorRenderTypes::pipeline); ++ material.emissive(), netherLighting && !ignoreLighting, material.masked(), material.colorMask(), GlintLighting.NONE, material.depth(), material.alphaWrite(), playerFade, material.pattern()), BedrockActorRenderTypes::pipeline); + } + + static RenderPipeline selectGlintPipeline(final BedrockActorMaterialDefinitions.Material material, @@ -3730,7 +3899,7 @@ index 0000000..516fdbf + if (lighting == GlintLighting.NONE) throw new IllegalArgumentException("Missing native glint lighting source"); + if (material.emissive() || material.masked() || material.colorMask()) throw new IllegalArgumentException("Unsupported native glint material combination"); + return MATERIAL_PIPELINES.computeIfAbsent(new PipelineKey(material.alphaTest(), material.cull(), ignoreLighting, -+ false, netherLighting && !ignoreLighting, false, false, lighting, material.depth(), material.alphaWrite(), playerFade), BedrockActorRenderTypes::pipeline); ++ false, netherLighting && !ignoreLighting, false, false, lighting, material.depth(), material.alphaWrite(), playerFade, material.pattern()), BedrockActorRenderTypes::pipeline); + } + + private static com.mojang.renderpearl.api.textures.GpuSampler repeatedSampler(final Identifier texture) { @@ -3744,7 +3913,10 @@ index 0000000..516fdbf + private static RenderSetup.RenderSetupBuilder textures(final RenderSetup.RenderSetupBuilder setup, + final List textures, final BedrockActorMaterialDefinitions.Material material) { + if (textures.size() < material.textureCount()) throw new IllegalArgumentException("Missing material sampler binding"); -+ if (material.masked()) { ++ if (material.pattern()) { ++ setup.withTexture("Sampler0", textures.getFirst(), () -> clampedSampler(textures.getFirst())); ++ setup.withTexture("BedrockPatternTexture", textures.get(2), () -> clampedSampler(textures.get(2))); ++ } else if (material.masked()) { + setup.withTexture("Sampler0", textures.getFirst(), () -> clampedSampler(textures.getFirst())); + setup.withTexture("BedrockMaskTexture", textures.get(1), () -> clampedSampler(textures.get(1))); + } else setup.withTexture("Sampler0", textures.getFirst()); @@ -3761,7 +3933,13 @@ index 0000000..516fdbf + + enum GlintLighting { NONE, EXPLICIT, LIGHTMAP } + -+ private record PipelineKey(boolean alphaTest, boolean cull, boolean unlit, boolean emissive, boolean nether, boolean masked, boolean colorMask, GlintLighting glint, BedrockActorDepth depth, boolean alphaWrite, boolean playerFade) {} ++ private record PipelineKey(boolean alphaTest, boolean cull, boolean unlit, boolean emissive, boolean nether, boolean masked, boolean colorMask, GlintLighting glint, BedrockActorDepth depth, boolean alphaWrite, boolean playerFade, boolean pattern) { ++ private PipelineKey(final boolean alphaTest, final boolean cull, final boolean unlit, final boolean emissive, ++ final boolean nether, final boolean masked, final boolean colorMask, final GlintLighting glint, ++ final BedrockActorDepth depth, final boolean alphaWrite, final boolean playerFade) { ++ this(alphaTest, cull, unlit, emissive, nether, masked, colorMask, glint, depth, alphaWrite, playerFade, false); ++ } ++ } +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorResources.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorResources.java new file mode 100644 @@ -5370,6 +5548,38 @@ index 0000000..7551b96 + } + } +} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderPattern.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderPattern.java +new file mode 100644 +index 0000000..a060b52 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderPattern.java +@@ -0,0 +1,26 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.render; ++ ++/** Supplies the prepared draw's pattern inputs to shared shader-pack programs. */ ++public final class BedrockShaderPattern { ++ private static final ThreadLocal CURRENT = new ThreadLocal<>(); ++ ++ private BedrockShaderPattern() {} ++ ++ public static BedrockActorPattern current() { return CURRENT.get(); } ++ ++ public static void bind(final BedrockActorPattern pattern, final Runnable draw) { ++ final var previous = CURRENT.get(); ++ if (pattern == null) CURRENT.remove(); ++ else CURRENT.set(pattern); ++ try { draw.run(); } ++ finally { ++ if (previous == null) CURRENT.remove(); ++ else CURRENT.set(previous); ++ } ++ } ++} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderPipelines.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderPipelines.java new file mode 100644 index 0000000..122cb02 @@ -5861,10 +6071,10 @@ index 0000000..e19a59f +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/compat/iris/BedrockIrisAlphaUniforms.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/compat/iris/BedrockIrisAlphaUniforms.java new file mode 100644 -index 0000000..3a71efd +index 0000000..96ac1a1 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/compat/iris/BedrockIrisAlphaUniforms.java -@@ -0,0 +1,51 @@ +@@ -0,0 +1,76 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -5874,6 +6084,7 @@ index 0000000..3a71efd + +import com.viaversion.viafabricplus.bedrock.render.BedrockShaderAlpha; +import com.viaversion.viafabricplus.bedrock.render.BedrockShaderTint; ++import com.viaversion.viafabricplus.bedrock.render.BedrockShaderPattern; +import org.lwjgl.opengl.GL46C; + +/** Locations belong to one program; values belong to each pipeline bind. */ @@ -5887,6 +6098,10 @@ index 0000000..3a71efd + private final int maskSampler; + private final int tintEnabled; + private final int tint; ++ private final int patternCount; ++ private final int patternColors; ++ private final int patternUv; ++ private final int patternSampler; + + public BedrockIrisAlphaUniforms(final int program) { + this.enabled = GL46C.glGetUniformLocation(program, BedrockIrisAlpha.ENABLED); @@ -5898,6 +6113,10 @@ index 0000000..3a71efd + this.maskSampler = GL46C.glGetUniformLocation(program, BedrockIrisMaskedTexture.SAMPLER); + this.tintEnabled = GL46C.glGetUniformLocation(program, BedrockIrisMaskedTexture.TINT_ENABLED); + this.tint = GL46C.glGetUniformLocation(program, BedrockIrisMaskedTexture.TINT); ++ this.patternCount = GL46C.glGetUniformLocation(program, BedrockIrisMaskedTexture.PATTERN_COUNT); ++ this.patternColors = GL46C.glGetUniformLocation(program, BedrockIrisMaskedTexture.PATTERN_COLORS + "[0]"); ++ this.patternUv = GL46C.glGetUniformLocation(program, BedrockIrisMaskedTexture.PATTERN_UV + "[0]"); ++ this.patternSampler = GL46C.glGetUniformLocation(program, BedrockIrisMaskedTexture.PATTERN_SAMPLER); + } + + /** Call after Iris updates uniforms and samplers, while this program is current. */ @@ -5911,6 +6130,22 @@ index 0000000..3a71efd + GL46C.glUniform1i(this.sampler, 0); + GL46C.glUniform1i(this.masked, BedrockShaderAlpha.masked() ? 1 : 0); + GL46C.glUniform1i(this.maskSampler, BedrockIrisMaskedTexture.UNIT); ++ GL46C.glUniform1i(this.patternSampler, BedrockIrisMaskedTexture.PATTERN_UNIT); ++ final var pattern = BedrockShaderPattern.current(); ++ GL46C.glUniform1i(this.patternCount, pattern == null ? 0 : pattern.layers().size()); ++ if (pattern != null) { ++ final float[] colors = new float[pattern.layers().size() * 4]; ++ final float[] uv = new float[colors.length]; ++ for (int i = 0; i < pattern.layers().size(); i++) { ++ final var layer = pattern.layers().get(i); ++ colors[i * 4] = layer.red(); colors[i * 4 + 1] = layer.green(); ++ colors[i * 4 + 2] = layer.blue(); colors[i * 4 + 3] = layer.alpha(); ++ uv[i * 4] = layer.offsetU(); uv[i * 4 + 1] = layer.offsetV(); ++ uv[i * 4 + 2] = layer.scaleU(); uv[i * 4 + 3] = layer.scaleV(); ++ } ++ GL46C.glUniform4fv(this.patternColors, colors); ++ GL46C.glUniform4fv(this.patternUv, uv); ++ } + final var tint = BedrockShaderTint.current(); + GL46C.glUniform1i(this.tintEnabled, tint == null ? 0 : 1); + if (tint != null) GL46C.glUniform4f(this.tint, tint.red(), tint.green(), tint.blue(), tint.alpha()); @@ -6111,10 +6346,10 @@ index 0000000..2835207 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/compat/iris/BedrockIrisMaskedTexture.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/compat/iris/BedrockIrisMaskedTexture.java new file mode 100644 -index 0000000..fc0042a +index 0000000..058b059 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/compat/iris/BedrockIrisMaskedTexture.java -@@ -0,0 +1,153 @@ +@@ -0,0 +1,170 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -6154,6 +6389,11 @@ index 0000000..fc0042a + public static final String SAMPLER = "BedrockMaskTexture"; + // Iris reserves 0/1/2 for the primary image, overlay and lightmap. Reserve this separately. + public static final int UNIT = 3; ++ public static final String PATTERN_COUNT = "iris_bedrock_PatternCount"; ++ public static final String PATTERN_COLORS = "iris_bedrock_PatternColors"; ++ public static final String PATTERN_UV = "iris_bedrock_PatternUv"; ++ public static final String PATTERN_SAMPLER = "BedrockPatternTexture"; ++ public static final int PATTERN_UNIT = 5; + private static final Set PRIMARY = Set.of("tex", "texture", "gtexture", "u_MainSampler", "Sampler0"); + + private BedrockIrisMaskedTexture() {} @@ -6205,6 +6445,10 @@ index 0000000..fc0042a + declarations.add("uniform int " + TINT_ENABLED + ";"); + declarations.add("uniform vec4 " + TINT + ";"); + declarations.add("uniform sampler2D " + SAMPLER + ";"); ++ declarations.add("uniform int " + PATTERN_COUNT + ";"); ++ declarations.add("uniform vec4 " + PATTERN_COLORS + "[7];"); ++ declarations.add("uniform vec4 " + PATTERN_UV + "[7];"); ++ declarations.add("uniform sampler2D " + PATTERN_SAMPLER + ";"); + declarations.add(""" + vec4 iris_bedrock_Compose(vec4 base, vec4 mask) { + if (iris_bedrock_Masked == 0) return base; @@ -6249,6 +6493,9 @@ index 0000000..fc0042a + default -> null; + }; + } ++ private String patternArguments() { ++ return this.function.equals("textureGrad") ? ", dx * transform.zw, dy * transform.zw" : this.arguments; ++ } + private String wrapper() { return "iris_bedrock_" + this.function + this.count; } + private String source() { + return "vec4 " + wrapper() + "(sampler2D image, vec2 uv" + this.parameters + ", bool actor) {\n" @@ -6256,9 +6503,14 @@ index 0000000..fc0042a + + "if (actor && " + ENABLED + " != 0) color = iris_bedrock_Compose(" + + this.function + "(" + BedrockIrisAlpha.SAMPLER + ", uv" + this.arguments + "), " + + this.function + "(" + SAMPLER + ", uv" + this.arguments + "));\n" -+ + "else if (actor && " + TINT_ENABLED + " != 0) color = " + this.function ++ + "else if (actor && (" + TINT_ENABLED + " != 0 || " + PATTERN_COUNT + " != 0)) color = " + this.function + + "(" + BedrockIrisAlpha.SAMPLER + ", uv" + this.arguments + ");\n" + + "else color = " + this.function + "(image, uv" + this.arguments + ");\n" ++ + "if (actor) for (int i = 0; i < " + PATTERN_COUNT + "; i++) {\n" ++ + "vec4 transform = " + PATTERN_UV + "[i];\n" ++ + "vec4 layer = " + this.function + "(" + PATTERN_SAMPLER ++ + ", uv * transform.zw + transform.xy" + patternArguments() + ") * " + PATTERN_COLORS + "[i];\n" ++ + "color.rgb = mix(color.rgb, layer.rgb, layer.a);\n}\n" + + "if (actor && " + TINT_ENABLED + " != 0) {\n" + + "color.rgb = mix(color.rgb, color.rgb * " + TINT + ".rgb, color.a);\n" + + "color.a *= " + TINT + ".a;\n}\n" @@ -6270,10 +6522,10 @@ index 0000000..fc0042a +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/compat/iris/BedrockIrisSamplers.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/compat/iris/BedrockIrisSamplers.java new file mode 100644 -index 0000000..c6a7061 +index 0000000..aa7d9a6 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/compat/iris/BedrockIrisSamplers.java -@@ -0,0 +1,18 @@ +@@ -0,0 +1,19 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -6288,16 +6540,17 @@ index 0000000..c6a7061 + public static final Map EXTERNAL = Map.of( + BedrockIrisMaskedTexture.SAMPLER, BedrockIrisMaskedTexture.UNIT, + BedrockIrisGlint.SAMPLER, BedrockIrisGlint.UNIT, ++ BedrockIrisMaskedTexture.PATTERN_SAMPLER, BedrockIrisMaskedTexture.PATTERN_UNIT, + BedrockIrisGlint.LIGHTMAP, BedrockIrisGlint.LIGHTMAP_UNIT); + + private BedrockIrisSamplers() {} +} diff --git a/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_actor.fsh b/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_actor.fsh new file mode 100644 -index 0000000..a744fb8 +index 0000000..3eef652 --- /dev/null +++ b/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_actor.fsh -@@ -0,0 +1,100 @@ +@@ -0,0 +1,123 @@ +#version 330 +#extension GL_ARB_separate_shader_objects : require + @@ -6324,6 +6577,10 @@ index 0000000..a744fb8 +#endif + +uniform sampler2D Sampler0; ++#ifdef PATTERN ++#include ++uniform sampler2D BedrockPatternTexture; ++#endif +#ifdef MASKED_MULTITEXTURE +uniform sampler2D BedrockMaskTexture; +#endif @@ -6342,6 +6599,10 @@ index 0000000..a744fb8 + if (!bedrock_player_visible(bedrockPlayerViewDepth, gl_FragCoord.xy)) discard; +#endif + vec4 color = texture(Sampler0, texCoord0); ++#ifdef PATTERN ++ color.rgb = bedrock_pattern_color(BedrockPatternTexture, texCoord0, color.rgb); ++ color.a = vertexColor.a * ColorModulator.a; ++#endif +#ifdef COLOR_MASK +#ifdef ALPHA_TEST + if (color.a <= 0.0) { @@ -6364,7 +6625,9 @@ index 0000000..a744fb8 + discard; + } +#endif ++#ifndef PATTERN + color.rgb = mix(overlayColor.rgb, color.rgb, overlayColor.a); ++#endif + vec3 lighting = vertexColor.rgb * ColorModulator.rgb; +#ifndef IGNORE_LIGHTING + lighting *= lightMapColor.rgb; @@ -6377,15 +6640,19 @@ index 0000000..a744fb8 + discard; + } +#endif ++#ifndef PATTERN + color.rgb = mix(overlayColor.rgb, color.rgb, overlayColor.a); ++#endif + color.rgb *= vertexColor.rgb * ColorModulator.rgb; +#ifndef IGNORE_LIGHTING + color.rgb *= lightMapColor.rgb; +#endif +#endif ++#ifndef PATTERN + fragColor = apply_fog(color, sphericalVertexDistance, cylindricalVertexDistance, + FogEnvironmentalStart, FogEnvironmentalEnd, FogRenderDistanceStart, + FogRenderDistanceEnd, FogColor); ++#endif +#ifdef GLINT + vec3 firstGlint = texture(BedrockGlintTexture, fract(glintTexCoords.xy)).rgb; + vec3 secondGlint = texture(BedrockGlintTexture, fract(glintTexCoords.zw)).rgb; @@ -6394,9 +6661,18 @@ index 0000000..a744fb8 + tileLight *= glintTileLight; +#endif + vec3 glint = (firstGlint + secondGlint) * BedrockGlintColor.rgb * tileLight; ++#ifdef PATTERN ++ color.rgb += glint * glint; ++#else + fragColor.rgb += glint * glint; + fragColor.a = abs((firstGlint.r + secondGlint.r) * BedrockGlintColor.a * BedrockGlintTileLight.a); +#endif ++#endif ++#ifdef PATTERN ++ fragColor = apply_fog(color, sphericalVertexDistance, cylindricalVertexDistance, ++ FogEnvironmentalStart, FogEnvironmentalEnd, FogRenderDistanceStart, ++ FogRenderDistanceEnd, FogColor); ++#endif +} diff --git a/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_actor.vsh b/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_actor.vsh new file mode 100644 @@ -6501,6 +6777,26 @@ index 0000000..32cda1c + return (vec2(c * centered.x + s * centered.y + offset, + c * centered.y - s * centered.x) + 0.5) * BedrockGlintScale.xy; +} +diff --git a/src/main/resources/assets/viafabricplus-bedrock/shaders/include/bedrock_pattern.glsl b/src/main/resources/assets/viafabricplus-bedrock/shaders/include/bedrock_pattern.glsl +new file mode 100644 +index 0000000..159d88b +--- /dev/null ++++ b/src/main/resources/assets/viafabricplus-bedrock/shaders/include/bedrock_pattern.glsl +@@ -0,0 +1,14 @@ ++layout(std140) uniform BedrockPatternInfo { ++ vec4 BedrockPatternCount; ++ vec4 BedrockPatternColors[7]; ++ vec4 BedrockPatternUv[7]; ++}; ++ ++vec3 bedrock_pattern_color(sampler2D atlas, vec2 uv, vec3 base) { ++ for (int i = 0; i < int(BedrockPatternCount.x); i++) { ++ vec4 layer = texture(atlas, uv * BedrockPatternUv[i].zw + BedrockPatternUv[i].xy) ++ * BedrockPatternColors[i]; ++ base = mix(base, layer.rgb, layer.a); ++ } ++ return base; ++} diff --git a/src/main/resources/assets/viafabricplus-bedrock/shaders/include/bedrock_player_fade.glsl b/src/main/resources/assets/viafabricplus-bedrock/shaders/include/bedrock_player_fade.glsl new file mode 100644 index 0000000..915b6a5 @@ -7280,10 +7576,10 @@ index 0000000..ca3478d +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorMaterialDefinitionsTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorMaterialDefinitionsTest.java new file mode 100644 -index 0000000..8d3a452 +index 0000000..c5e44ee --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorMaterialDefinitionsTest.java -@@ -0,0 +1,307 @@ +@@ -0,0 +1,327 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -7294,12 +7590,32 @@ index 0000000..8d3a452 +import com.viaversion.viaversion.libs.gson.JsonObject; +import com.viaversion.viaversion.libs.gson.JsonParser; +import java.util.List; ++import java.util.Set; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +final class BedrockActorMaterialDefinitionsTest { + @Test ++ void requiresPatternInputsForHeldAtlasMaterialsAndPreservesTheirGlintFamily() { ++ final var materials = read(""" ++ {"custom:pattern":{"+states":["DisableDepthWrite"]}, ++ "unsupported:pattern":{"+defines":["USE_EMISSIVE"]}} ++ """); ++ assertTrue(materials.material("pattern").isEmpty()); ++ assertTrue(materials.heldItemMaterial("unsupported").isEmpty()); ++ final var pattern = materials.heldItemMaterial("custom").orElseThrow(); ++ assertTrue(pattern.pattern()); assertTrue(pattern.cull()); assertFalse(pattern.alphaTest()); ++ assertFalse(pattern.depth().write()); assertEquals(3, pattern.textureCount()); ++ assertTrue(BedrockActorRenderTypes.selectPipeline(pattern, false, false).getShaderDefines().flags().contains("PATTERN")); ++ final var glint = materials.heldItemMaterial("pattern_glint").orElseThrow(); ++ assertTrue(glint.pattern()); assertTrue(glint.glint()); assertEquals(3, glint.textureCount()); ++ final var flags = BedrockActorRenderTypes.selectGlintPipeline(glint, false, false, ++ BedrockActorRenderTypes.GlintLighting.LIGHTMAP).getShaderDefines().flags(); ++ assertTrue(flags.containsAll(Set.of("PATTERN", "GLINT", "GLINT_LIGHTMAP"))); ++ } ++ ++ @Test + void admitsColorMasksOnlyWithNativeTintAndWithoutUnsupportedMaterialCombinations() { + final var materials = read(""" + {"tinted:entity_alphatest":{"+defines":["USE_COLOR_MASK"],"+states":["DisableAlphaWrite"]}, @@ -7862,6 +8178,178 @@ index 0000000..865c89a + assertEquals((float) Math.PI / 6F, body.zRot, 0.000001F); + } +} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorPatternGpuTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorPatternGpuTest.java +new file mode 100644 +index 0000000..1acb74f +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorPatternGpuTest.java +@@ -0,0 +1,166 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.render; ++ ++import com.google.gson.JsonObject; ++import com.google.gson.JsonParser; ++import java.io.IOException; ++import java.nio.charset.StandardCharsets; ++import java.nio.FloatBuffer; ++import java.nio.file.Files; ++import java.nio.file.Path; ++import java.util.ArrayList; ++import org.junit.jupiter.api.Test; ++import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; ++import org.lwjgl.system.MemoryStack; ++ ++import static org.junit.jupiter.api.Assertions.*; ++import static org.lwjgl.opengl.GL33C.*; ++ ++/** Real fragment pixels against the target's original pattern and pattern-glint bytecode. */ ++@EnabledIfEnvironmentVariable(named = "STACKANVIL_IRIS_ALPHA_GPU_TESTS", matches = "1") ++@EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_PATTERN_MATERIALS", matches = ".+") ++final class BedrockActorPatternGpuTest { ++ @Test ++ void matchesOrderedPatternLayersOpacityAndGlintBeforeFog() throws IOException { ++ final var controls = JsonParser.parseString(Files.readString( ++ Path.of(System.getenv("STACKANVIL_NATIVE_PATTERN_MATERIALS")))).getAsJsonObject().getAsJsonArray("cases"); ++ assertFalse(controls.isEmpty()); ++ EglTestContext.run(() -> { ++ final int vao = glGenVertexArrays(), framebuffer = glGenFramebuffers(), target = glGenTextures(); ++ final int source = glGenTextures(), atlas = glGenTextures(), glintTexture = glGenTextures(); ++ final int fog = glGenBuffers(), dynamic = glGenBuffers(), patterns = glGenBuffers(), glintInfo = glGenBuffers(); ++ final int[] programs = {program(false), program(true)}; ++ try { ++ glBindVertexArray(vao); glViewport(0, 0, 1, 1); glDisable(GL_BLEND); glDisable(GL_DEPTH_TEST); ++ texture(0, target, 1, new float[] {0F, 0F, 0F, 0F}); ++ glBindFramebuffer(GL_FRAMEBUFFER, framebuffer); ++ glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, target, 0); ++ assertEquals(GL_FRAMEBUFFER_COMPLETE, glCheckFramebufferStatus(GL_FRAMEBUFFER)); ++ for (final var value : controls) { ++ final var control = value.getAsJsonObject(); ++ final boolean enchanted = control.get("family").getAsString().equals("ActorPatternGlint"); ++ final int count = control.get("count").getAsInt(), program = programs[enchanted ? 1 : 0]; ++ glUseProgram(program); ++ final float[] light = floats(control, "light"); ++ glUniform4f(glGetUniformLocation(program, "inputVertex"), light[0], light[1], light[2], control.get("opacity").getAsFloat()); ++ final float[] fogValue = floats(control, "fog"); ++ glUniform1f(glGetUniformLocation(program, "inputDistance"), fogValue[3]); ++ texture(0, source, 1, floats(control, "base")); ++ glUniform1i(glGetUniformLocation(program, "Sampler0"), 0); ++ final var layers = new ArrayList(); ++ final float[] texels = new float[Math.max(1, count) * 4]; ++ for (int i = 0; i < count; i++) { ++ final var tint = control.getAsJsonArray("tints").get(i).getAsJsonArray(); ++ layers.add(new BedrockActorPattern.Layer(tint.get(0).getAsFloat(), tint.get(1).getAsFloat(), ++ tint.get(2).getAsFloat(), tint.get(3).getAsFloat(), (i + .5F) / count, .5F, 0F, 0F)); ++ final var mask = control.getAsJsonArray("masks").get(i).getAsJsonArray(); ++ for (int channel = 0; channel < 4; channel++) texels[i * 4 + channel] = mask.get(channel).getAsFloat(); ++ } ++ texture(2, atlas, Math.max(1, count), texels); ++ glUniform1i(glGetUniformLocation(program, "BedrockPatternTexture"), 2); ++ try (var stack = MemoryStack.stackPush()) { ++ final var fogBytes = stack.callocFloat(12); ++ for (int i = 0; i < 3; i++) fogBytes.put(i, fogValue[i]); ++ fogBytes.put(3, 1F).put(5, 1F).put(7, 1F); ++ buffer(program, "Fog", fog, 0, fogBytes); ++ final var dynamicBytes = stack.callocFloat(40); ++ for (int i = 0; i < 4; i++) dynamicBytes.put(32 + i, 1F); ++ buffer(program, "DynamicTransforms", dynamic, 1, dynamicBytes); ++ final var patternBytes = stack.calloc(BedrockActorPattern.UNIFORM_SIZE); ++ if (count != 0) new BedrockActorPattern(layers).write(patternBytes); ++ buffer(program, "BedrockPatternInfo", patterns, 2, patternBytes.rewind().asFloatBuffer()); ++ if (enchanted) { ++ final float[] samples = new float[8]; ++ for (int i = 0; i < 2; i++) for (int channel = 0; channel < 4; channel++) ++ samples[i * 4 + channel] = control.getAsJsonArray("glintSamples").get(i).getAsJsonArray().get(channel).getAsFloat(); ++ texture(1, glintTexture, 2, samples); ++ glUniform1i(glGetUniformLocation(program, "BedrockGlintTexture"), 1); ++ final float[] color = floats(control, "glintColor"), tile = floats(control, "tile"); ++ final var glint = new BedrockActorGlint(0F, 0F, 0F, 0F, 1F, 1F, ++ color[0], color[1], color[2], color[3], tile[0], tile[1], tile[2], tile[3]); ++ final var bytes = stack.calloc(BedrockActorGlint.UNIFORM_SIZE); glint.write(bytes); ++ buffer(program, "BedrockGlintInfo", glintInfo, 3, bytes.rewind().asFloatBuffer()); ++ } ++ glDrawArrays(GL_TRIANGLES, 0, 3); ++ final var pixel = stack.mallocFloat(4); ++ glReadPixels(0, 0, 1, 1, GL_RGBA, GL_FLOAT, pixel); ++ final float[] expected = floats(control, "expected"); ++ for (int channel = 0; channel < 4; channel++) ++ assertEquals(expected[channel], pixel.get(channel), .000002F, control::toString); ++ assertEquals(GL_NO_ERROR, glGetError()); ++ } ++ } ++ } finally { ++ glUseProgram(0); for (int program : programs) glDeleteProgram(program); ++ glBindFramebuffer(GL_FRAMEBUFFER, 0); glDeleteFramebuffers(framebuffer); ++ glDeleteTextures(target); glDeleteTextures(source); glDeleteTextures(atlas); glDeleteTextures(glintTexture); ++ glDeleteBuffers(fog); glDeleteBuffers(dynamic); glDeleteBuffers(patterns); glDeleteBuffers(glintInfo); glDeleteVertexArrays(vao); ++ } ++ }); ++ } ++ ++ private static int program(final boolean glint) { ++ final String vertex = """ ++ #version 330 ++ #extension GL_ARB_separate_shader_objects : require ++ uniform vec4 inputVertex; ++ uniform float inputDistance; ++ layout(location=0) out float sphericalVertexDistance; ++ layout(location=1) out float cylindricalVertexDistance; ++ layout(location=2) out vec4 vertexColor; ++ layout(location=5) out vec4 overlayColor; ++ layout(location=6) out vec2 texCoord0; ++ layout(location=7) out vec4 glintTexCoords; ++ void main() { ++ vec2 p=vec2((gl_VertexID<<1)&2,gl_VertexID&2); ++ gl_Position=vec4(p*2.0-1.0,0.0,1.0); ++ sphericalVertexDistance=inputDistance; cylindricalVertexDistance=inputDistance; ++ vertexColor=inputVertex; overlayColor=vec4(1,0,1,0); texCoord0=vec2(.5); ++ glintTexCoords=vec4(.25,.5,.75,.5); ++ } ++ """; ++ String fragment = resource("/assets/viafabricplus-bedrock/shaders/core/bedrock_actor.fsh") ++ .replace("#version 330", "#version 330\n#define PATTERN\n#define IGNORE_LIGHTING\n" + (glint ? "#define GLINT\n" : "")); ++ for (final String path : new String[] {"minecraft:fog", "minecraft:dynamictransforms", "viafabricplus-bedrock:bedrock_glint", "viafabricplus-bedrock:bedrock_pattern", "viafabricplus-bedrock:bedrock_player_fade"}) { ++ final String[] parts = path.split(":"); ++ fragment = fragment.replace("#include <" + path + ".glsl>", resource("/assets/" + parts[0] + "/shaders/include/" + parts[1] + ".glsl")); ++ } ++ final int vs = shader(GL_VERTEX_SHADER, vertex), fs = shader(GL_FRAGMENT_SHADER, fragment), program = glCreateProgram(); ++ try { ++ glAttachShader(program, vs); glAttachShader(program, fs); glLinkProgram(program); ++ assertEquals(GL_TRUE, glGetProgrami(program, GL_LINK_STATUS), glGetProgramInfoLog(program)); ++ return program; ++ } finally { glDeleteShader(vs); glDeleteShader(fs); } ++ } ++ ++ private static int shader(final int type, final String source) { ++ final int shader = glCreateShader(type); glShaderSource(shader, source); glCompileShader(shader); ++ assertEquals(GL_TRUE, glGetShaderi(shader, GL_COMPILE_STATUS), glGetShaderInfoLog(shader)); return shader; ++ } ++ ++ private static void texture(final int unit, final int texture, final int width, final float[] values) { ++ glActiveTexture(GL_TEXTURE0 + unit); glBindTexture(GL_TEXTURE_2D, texture); ++ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); ++ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA32F, width, 1, 0, GL_RGBA, GL_FLOAT, values); ++ } ++ ++ private static void buffer(final int program, final String name, final int buffer, final int binding, final FloatBuffer values) { ++ glBindBuffer(GL_UNIFORM_BUFFER, buffer); glBufferData(GL_UNIFORM_BUFFER, values, GL_STATIC_DRAW); ++ glBindBufferBase(GL_UNIFORM_BUFFER, binding, buffer); glUniformBlockBinding(program, glGetUniformBlockIndex(program, name), binding); ++ } ++ ++ private static float[] floats(final JsonObject object, final String name) { ++ final var array = object.getAsJsonArray(name); final float[] result = new float[array.size()]; ++ for (int i = 0; i < result.length; i++) result[i] = array.get(i).getAsFloat(); return result; ++ } ++ ++ private static String resource(final String path) { ++ try (final var stream = BedrockActorPatternGpuTest.class.getResourceAsStream(path)) { ++ assertNotNull(stream, path); return new String(stream.readAllBytes(), StandardCharsets.UTF_8); ++ } catch (IOException failure) { throw new IllegalStateException(failure); } ++ } ++} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorTintTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorTintTest.java new file mode 100644 index 0000000..0724253 @@ -9778,13 +10266,17 @@ index 0000000..fbb2ce7 +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/compat/iris/BedrockIrisAlphaGpuTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/compat/iris/BedrockIrisAlphaGpuTest.java new file mode 100644 -index 0000000..2c066e8 +index 0000000..d48dc11 --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/compat/iris/BedrockIrisAlphaGpuTest.java -@@ -0,0 +1,391 @@ +@@ -0,0 +1,464 @@ +package com.viaversion.viafabricplus.bedrock.render.compat.iris; + +import com.viaversion.viafabricplus.bedrock.render.BedrockShaderAlpha; ++import com.viaversion.viafabricplus.bedrock.render.BedrockActorPattern; ++import com.viaversion.viafabricplus.bedrock.render.BedrockActorPattern.Layer; ++import com.viaversion.viafabricplus.bedrock.render.BedrockShaderPattern; ++import java.util.List; +import io.github.douira.glsl_transformer.ast.transform.JobParameters; +import io.github.douira.glsl_transformer.ast.transform.SingleASTTransformer; +import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; @@ -9887,6 +10379,75 @@ index 0000000..2c066e8 + }); + } + ++ @Test ++ void composesOrderedNativePatternsThroughPackHelpersAndClearsTheirInputsForJavaDraws() { ++ com.viaversion.viafabricplus.bedrock.render.EglTestContext.run(() -> { ++ final int vao = glGenVertexArrays(), source = glGenTextures(), override = glGenTextures(), atlas = glGenTextures(); ++ try { ++ glBindVertexArray(vao); glVertexAttrib2f(0, .25F, .5F); ++ glViewport(0, 0, 1, 1); glDisable(GL_BLEND); glDisable(GL_DEPTH_TEST); ++ pixel(0, source, new float[] {.2F, .4F, .6F, 0F}); ++ pixel(1, override, new float[] {1F, 0F, 0F, 1F}); ++ glActiveTexture(GL_TEXTURE0 + BedrockIrisMaskedTexture.PATTERN_UNIT); ++ glBindTexture(GL_TEXTURE_2D, atlas); ++ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); ++ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); ++ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA32F, 2, 1, 0, GL_RGBA, GL_FLOAT, ++ new float[] {1F, .5F, .25F, 1F, .5F, 1F, .25F, .5F}); ++ final var pattern = new BedrockActorPattern(List.of( ++ new Layer(.2F, .4F, .6F, 1F, .25F, .5F, 0F, 0F), ++ new Layer(.8F, .3F, .4F, 1F, .75F, .5F, 0F, 0F))); ++ for (boolean fallback : new boolean[] {false, true}) { ++ final String vertex = "#version 330 core\nlayout(location=0) in vec2 " + (fallback ? "UV0" : "iris_UV0") ++ + "; void main(){vec2 p=vec2((gl_VertexID<<1)&2,gl_VertexID&2);gl_Position=vec4(p*2-1,0,1);}"; ++ final String fragment = """ ++ #version 330 core ++ uniform sampler2D gtexture, otherImage; ++ uniform int method; ++ out vec4 fragColor; ++ vec4 readImage(sampler2D image, vec2 uv) { ++ if(method==1) return textureLod(image,uv,0.0); ++ if(method==2) return textureGrad(image,uv,vec2(0),vec2(0)); ++ return texture(image,uv); ++ } ++ vec4 forwardImage(sampler2D image, vec2 uv) { return readImage(image,uv); } ++ void main() { ++ fragColor=forwardImage(gtexture,vec2(.25,.5)); ++ fragColor.rgb+=forwardImage(otherImage,vec2(.25,.5)).rgb*.1; ++ } ++ """; ++ final int vs = shader(GL_VERTEX_SHADER, transformed(vertex, PatchShaderType.VERTEX, fallback)); ++ final int fs = shader(GL_FRAGMENT_SHADER, transformed(fragment, PatchShaderType.FRAGMENT, fallback)); ++ final int program = glCreateProgram(); ++ try { ++ glAttachShader(program, vs); glAttachShader(program, fs); glLinkProgram(program); ++ assertEquals(GL_TRUE, glGetProgrami(program, GL_LINK_STATUS), glGetProgramInfoLog(program)); ++ glUseProgram(program); ++ glUniform1i(glGetUniformLocation(program, "gtexture"), 1); ++ glUniform1i(glGetUniformLocation(program, "otherImage"), 1); ++ final var uniforms = new BedrockIrisAlphaUniforms(program); ++ for (int method = 0; method < 3; method++) { ++ glUniform1i(glGetUniformLocation(program, "method"), method); ++ for (boolean nativeDraw : new boolean[] {true, false, true, false}) { ++ BedrockShaderPattern.bind(nativeDraw ? pattern : null, ++ () -> BedrockShaderAlpha.bind(nativeDraw ? BedrockShaderAlpha.OPAQUE_REFERENCE : Float.NaN, uniforms::bind)); ++ glDrawArrays(GL_TRIANGLES, 0, 3); ++ try (var stack = MemoryStack.stackPush()) { ++ final var actual = stack.malloc(4); ++ glReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, actual); ++ final float[] expected = nativeDraw ? new float[] {.4F, .25F, .125F, 1F} : new float[] {1F, 0F, 0F, 1F}; ++ for (int channel = 0; channel < 4; channel++) ++ assertEquals(Math.round(expected[channel] * 255), Byte.toUnsignedInt(actual.get(channel)), 1); ++ assertEquals(GL_NO_ERROR, glGetError()); ++ } ++ } ++ } ++ } finally { glUseProgram(0); glDeleteProgram(program); glDeleteShader(vs); glDeleteShader(fs); } ++ } ++ } finally { glDeleteTextures(source); glDeleteTextures(override); glDeleteTextures(atlas); glDeleteVertexArrays(vao); } ++ }); ++ } ++ + private static void pixel(final int unit, final int texture, final float[] value) { + glActiveTexture(GL_TEXTURE0+unit); glBindTexture(GL_TEXTURE_2D,texture); + glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,GL_NEAREST); diff --git a/patches/viafabricplus-bedrock/upstreamable/0002-native-entities-and-skins.pr.md b/patches/viafabricplus-bedrock/upstreamable/0002-native-entities-and-skins.pr.md index 1f4cd2d9..da18165b 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0002-native-entities-and-skins.pr.md +++ b/patches/viafabricplus-bedrock/upstreamable/0002-native-entities-and-skins.pr.md @@ -13,6 +13,10 @@ Native player entities resolve the current connection's profile after list repla World player surfaces use separate cached materials for native near-camera fading. Shared hands, unrelated actors, and GUI portraits retain their own materials. The native and Iris programs use the same ordered coverage function. Surviving pixels retain the material depth policy. +Pattern and pattern-glint materials retain ordered colors and atlas transforms for each prepared draw. +Their native shader adds glint before fog. Ordinary entity glint keeps its separate order. +Iris composes pattern layers before shader-pack shading and clears their inputs for ordinary Java draws. + Prerequisites: - viabedrock/0064-evaluate-named-actor-properties.patch: Publish the versioned custom-actor sampler snapshot and immutable evaluated texture bindings. @@ -22,6 +26,10 @@ Prerequisites: Regression coverage includes `BedrockAcceptedResourcesTest`, `BedrockActorDepthGpuTest`, `BedrockActorGlintFramesTest`, `BedrockShaderAlphaTest`, and `BedrockPlayerFadeGpuTest`. +`BedrockActorPatternGpuTest` compares 512 actual fragment outputs with current original 1.26.60.5 shader controls. +Iris GPU tests cover ordered atlas layers through sampler helpers, texture overrides, and return to ordinary Java draws. +[Installed shield controls](0017-render-equipped-native-pack-models.pr.md) exercise the shared material on both connection routes. + A controlled protocol-2225 replay uses captured official 1.26.60.5 skin/profile bytes with declared Creative, Spectator, Survival, and return transitions plus a repeated profile list. The previous client keeps an old Creative profile after the current profile and controller enter Spectator. Read-only installed-client observations verify agreement after the fix on direct and proxy routes. The [core mode patch](../../viabedrock/upstreamable/0007-forward-local-game-mode-updates.pr.md) separately preserves the local mode when recreating the profile. These controls verify state ownership, without establishing complete native trajectories or visual parity. Official Bedrock 1.26.60.5 (protocol 2225) GPU draws establish the player fade across zero to one block of signed view depth. diff --git a/patches/viafabricplus-bedrock/upstreamable/0006-render-character-creator-geometry-and-face-animations.patch b/patches/viafabricplus-bedrock/upstreamable/0006-render-character-creator-geometry-and-face-animations.patch index fd37c5a8..bacdc809 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0006-render-character-creator-geometry-and-face-animations.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0006-render-character-creator-geometry-and-face-animations.patch @@ -1,4 +1,4 @@ -From f93f231d83e7592f8e6a4d7c4a393a91dc326a49 Mon Sep 17 00:00:00 2001 +From fffbb19cafc60e87d5246973392774bce300c7f4 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:55:34 +0200 Subject: [PATCH] feat(bedrock): render Character Creator geometry and face @@ -87,10 +87,10 @@ one-element binding list and unchanged ordinary material flags. create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/light/BedrockRenderingMetadataTest.java diff --git a/build.gradle.kts b/build.gradle.kts -index 80d366b..6fdbc91 100644 +index d5d27f4..4183834 100644 --- a/build.gradle.kts +++ b/build.gradle.kts -@@ -22,6 +22,7 @@ tasks.withType().configureEach { +@@ -31,6 +31,7 @@ if (bedrockOreUiShaper.isPresent) { } dependencies { diff --git a/patches/viafabricplus-bedrock/upstreamable/0007-join-offline-lan-worlds.patch b/patches/viafabricplus-bedrock/upstreamable/0007-join-offline-lan-worlds.patch index e043d5f6..a8f9a31a 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0007-join-offline-lan-worlds.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0007-join-offline-lan-worlds.patch @@ -1,4 +1,4 @@ -From c3d04e883b1191b3bba190257eb6f84f24ec315c Mon Sep 17 00:00:00 2001 +From 719d33674b350d365e518998254a602ecfb8ae97 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:55:34 +0200 Subject: [PATCH] feat(bedrock): join offline LAN worlds @@ -87,10 +87,10 @@ index 1e71dc0..0e04f81 100644 | `nethernet-xbox://network-id` | Xbox WebSocket, needs account | | `nethernet-xbox-json-rpc://network-id` | Xbox JSON-RPC, needs account | diff --git a/build.gradle.kts b/build.gradle.kts -index 6fdbc91..580bdf9 100644 +index 4183834..417e7dd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts -@@ -39,6 +39,9 @@ dependencies { +@@ -48,6 +48,9 @@ dependencies { jarInJar(libs.minecraftauth) { exclude(group = "com.google.code.gson", module = "gson") } diff --git a/patches/viafabricplus-bedrock/upstreamable/0008-share-native-actor-and-resource-runtime.patch b/patches/viafabricplus-bedrock/upstreamable/0008-share-native-actor-and-resource-runtime.patch index 7e98fe69..336a13f1 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0008-share-native-actor-and-resource-runtime.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0008-share-native-actor-and-resource-runtime.patch @@ -1,4 +1,4 @@ -From c87cda02d6851386dde9c64a00b6a0236ce2e860 Mon Sep 17 00:00:00 2001 +From 2ec6ae95502bf17c345ba747c415e67b92b838f4 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(runtime): share native actor animation and resource @@ -475,6 +475,23 @@ Java's extra offset. A real-renderer regression reproduces that drop at non-unit scale and verifies native animated/visibility-only trees and fallback ownership. This establishes root anchoring, not complete body rotation, movement timing, or the original custom-skin report. + +Cache native air-continuation metadata with the existing immutable item +context. Keep an absent field distinct from false so older proxy cores +retain their prior input fallback. Replaced custom data selects a new +snapshot without changing a previously cached predicate. + +Clear the inherited head-pitch query in the first-person hand frame. +Bedrock 1.26.60.5 clears both actor angle samples at 0x14532bcb5 before +hand-graph evaluation. Its query.head_x_rotation(0) callback at 0x142cb53a0 +reads those fields. Fifteen original instruction controls return zero in +that scope while retaining distinct world pitch before the clear. +Preserve the camera-pitch variable and the world frame. The authored arm +regression observes 37 degrees before this correction instead of zero. +These controls do not establish the cause of the reported shield or skin +pose, complete hand pixels, or other rotation-query behavior. + +Expose verified native physical geometry through the accepted level mirror. Keep unresolved classes distinct from known-empty collision, and reject queries from other level owners or before the current native world reset. --- .gitignore | 4 +- build.gradle.kts | 10 + @@ -493,7 +510,7 @@ rotation, movement timing, or the original custom-skin report. .../appearance/BedrockEmitterEvents.java | 74 ++ .../appearance/BedrockEmoteMessages.java | 124 ++ .../appearance/BedrockItemAnimation.java | 193 +++ - .../appearance/BedrockItemMetadata.java | 44 + + .../appearance/BedrockItemMetadata.java | 45 + .../appearance/BedrockLevelSounds.java | 164 +++ .../bedrock/appearance/BedrockLimbSide.java | 67 + .../appearance/BedrockMarketplaceAssets.java | 330 +++++ @@ -501,7 +518,7 @@ rotation, movement timing, or the original custom-skin report. .../appearance/BedrockMolangColor.java | 40 + .../appearance/BedrockMolangVariables.java | 108 ++ .../appearance/BedrockNativeBlockUpdates.java | 72 ++ - .../appearance/BedrockNativeBlocks.java | 87 ++ + .../appearance/BedrockNativeBlocks.java | 97 ++ .../appearance/BedrockParticleAttachment.java | 167 +++ .../appearance/BedrockParticleBlocks.java | 67 + .../appearance/BedrockParticleChildren.java | 93 ++ @@ -579,7 +596,7 @@ rotation, movement timing, or the original custom-skin report. .../bedrock/render/BedrockBoneTransform.java | 31 + .../render/BedrockFirstPersonActor.java | 38 + .../render/BedrockFirstPersonCamera.java | 61 + - .../render/BedrockFirstPersonInput.java | 41 + + .../render/BedrockFirstPersonInput.java | 42 + .../BedrockFirstPersonItemRendering.java | 27 + .../render/BedrockFirstPersonProjection.java | 28 + .../bedrock/render/BedrockFluidRendering.java | 34 + @@ -624,13 +641,14 @@ rotation, movement timing, or the original custom-skin report. .../appearance/BedrockEmitterEventsTest.java | 181 +++ .../appearance/BedrockEmoteMessagesTest.java | 94 ++ .../appearance/BedrockItemAnimationTest.java | 448 +++++++ - .../appearance/BedrockItemMetadataTest.java | 102 ++ + .../appearance/BedrockItemMetadataTest.java | 107 ++ .../appearance/BedrockLimbSideTest.java | 89 ++ .../appearance/BedrockMolangColorTest.java | 110 ++ .../bedrock/appearance/BedrockMolangTest.java | 231 ++++ .../BedrockMolangVariablesTest.java | 72 ++ .../BedrockNativeBlockUpdatesTest.java | 70 ++ .../appearance/BedrockNativeFluidTest.java | 34 + + .../appearance/BedrockNativeGeometryTest.java | 54 + .../appearance/BedrockParticleAgingTest.java | 118 ++ .../appearance/BedrockParticleBlocksTest.java | 149 +++ .../BedrockParticleChildrenTest.java | 300 +++++ @@ -674,7 +692,7 @@ rotation, movement timing, or the original custom-skin report. .../render/BedrockBoneTransformTest.java | 69 ++ .../render/BedrockFirstPersonActorTest.java | 117 ++ .../render/BedrockFirstPersonCameraTest.java | 86 ++ - .../render/BedrockFirstPersonInputTest.java | 135 ++ + .../render/BedrockFirstPersonInputTest.java | 151 +++ .../BedrockFirstPersonItemRenderingTest.java | 53 + .../BedrockFirstPersonProjectionTest.java | 88 ++ .../render/BedrockFluidRenderingTest.java | 39 + @@ -689,7 +707,7 @@ rotation, movement timing, or the original custom-skin report. .../render/BedrockViewBobbingTest.java | 93 ++ .../render/BedrockWalkingAnimationTest.java | 65 + .../render/NativeActorColorPayloadTest.java | 50 + - 213 files changed, 25267 insertions(+), 16 deletions(-) + 214 files changed, 25354 insertions(+), 16 deletions(-) create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockActorAnimation.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockActorQueries.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockActorScale.java @@ -834,6 +852,7 @@ rotation, movement timing, or the original custom-skin report. create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockMolangVariablesTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockNativeBlockUpdatesTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockNativeFluidTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockNativeGeometryTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleAgingTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleBlocksTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleChildrenTest.java @@ -907,10 +926,10 @@ index d845caa..e1f9fb5 100644 +# Bundled package helper build output +/native/persona-assets/target/ diff --git a/build.gradle.kts b/build.gradle.kts -index 580bdf9..909ac3c 100644 +index 417e7dd..ad7d3c5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts -@@ -31,6 +31,10 @@ dependencies { +@@ -40,6 +40,10 @@ dependencies { testImplementation("io.github.douira:glsl-transformer:3.0.0-pre3") testImplementation("org.lwjgl:lwjgl-egl:3.4.3") @@ -921,7 +940,7 @@ index 580bdf9..909ac3c 100644 jarInJar(viaBedrockDependency) { exclude(group = "com.mojang", module = "brigadier") exclude(group = "at.yawk.lz4", module = "lz4-java") -@@ -62,4 +66,10 @@ dependencies { +@@ -71,4 +75,10 @@ dependencies { tasks.test { useJUnitPlatform() @@ -3858,10 +3877,10 @@ index 0000000..a6c7571 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockItemMetadata.java b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockItemMetadata.java new file mode 100644 -index 0000000..906372b +index 0000000..35c9f1b --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockItemMetadata.java -@@ -0,0 +1,44 @@ +@@ -0,0 +1,45 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -3876,8 +3895,8 @@ index 0000000..906372b +import net.raphimc.viabedrock.protocol.rewriter.ItemDataRewriter; + +/** Decodes immutable native item components once, independently of live item use state. */ -+public record BedrockItemMetadata(String identifier, ItemAnimationData animation) { -+ private static final BedrockItemMetadata EMPTY = new BedrockItemMetadata(null, ItemAnimationData.EMPTY); ++public record BedrockItemMetadata(String identifier, ItemAnimationData animation, Boolean retainsAirUse) { ++ private static final BedrockItemMetadata EMPTY = new BedrockItemMetadata(null, ItemAnimationData.EMPTY, null); + // Weak keys use identity comparison and avoid hashing the full tag on every render query. + // Values contain only immutable strings/numbers, never the component or its NBT tree. + private static final Map CACHE = new MapMaker().weakKeys().makeMap(); @@ -3899,7 +3918,8 @@ index 0000000..906372b + new ItemAnimationData.KineticWeapon(value.getIntOr("delay", 0), + value.getIntOr("damage_duration", 0), value.getIntOr("knockback_duration", 0), + value.getIntOr("dismount_duration", 0))).orElse(null); -+ return new BedrockItemMetadata(identifier, new ItemAnimationData(useDuration, tags, swingTicks, kinetic)); ++ return new BedrockItemMetadata(identifier, new ItemAnimationData(useDuration, tags, swingTicks, kinetic), ++ tag.getBoolean(ItemDataRewriter.BEDROCK_AIR_USE_TAG).orElse(null)); + } + + public static void clear() { @@ -5073,10 +5093,10 @@ index 0000000..105cc63 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockNativeBlocks.java b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockNativeBlocks.java new file mode 100644 -index 0000000..3515be1 +index 0000000..6e47d33 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockNativeBlocks.java -@@ -0,0 +1,87 @@ +@@ -0,0 +1,97 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -5088,10 +5108,13 @@ index 0000000..3515be1 +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.network.Connection; +import net.minecraft.world.level.Level; ++import net.minecraft.world.level.BlockGetter; ++import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.material.FluidState; +import net.minecraft.world.level.material.Fluids; +import net.raphimc.viabedrock.api.model.NativeBlockIdentifiers; ++import net.raphimc.viabedrock.api.model.NativeBlockGeometry; +import net.raphimc.viabedrock.api.model.NativeBlockView; + +/** Client lifecycle adapter for the core's authoritative block mirror. */ @@ -5136,6 +5159,13 @@ index 0000000..3515be1 + return VIEW.state(x, y, z, layer); + } + ++ /** Known physical data for this accepted level; null leaves unresolved classes with the existing renderer. */ ++ public static NativeBlockGeometry geometry(final BlockGetter owner, final BlockPos pos) { ++ if (owner == null || owner != level) return null; ++ final var state = VIEW.state(pos.getX(), pos.getY(), pos.getZ(), 0); ++ return state == null ? null : state.geometry(); ++ } ++ + /** Worker-safe fluid query. Null retains vanilla behavior until this world's native mirror is ready. */ + public static FluidState fluidState(final Level owner, final int x, final int y, final int z) { + if (owner == null || owner != level) return null; @@ -13438,10 +13468,10 @@ index 0000000..32d6cd8 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockFirstPersonInput.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockFirstPersonInput.java new file mode 100644 -index 0000000..fb3bd20 +index 0000000..27d46f4 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockFirstPersonInput.java -@@ -0,0 +1,41 @@ +@@ -0,0 +1,42 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -13473,6 +13503,7 @@ index 0000000..fb3bd20 + // Native clears actor, head, and body rotations while rendering hands. Camera pitch remains a variable. + queries.put("target_x_rotation", 0F); + queries.put("target_y_rotation", 0F); ++ queries.put("head_x_rotation", 0F); + variables.put("player_arm_height", oldMainHeight + (mainHeight - oldMainHeight) * partialTick); + context.put("player_offhand_arm_height", oldOffHeight + (offHeight - oldOffHeight) * partialTick); + final var query = actor.queries().get("get_default_bone_pivot"); @@ -18447,10 +18478,10 @@ index 0000000..a2d8350 +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockItemMetadataTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockItemMetadataTest.java new file mode 100644 -index 0000000..89c8429 +index 0000000..518132c --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockItemMetadataTest.java -@@ -0,0 +1,102 @@ +@@ -0,0 +1,107 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -18506,9 +18537,12 @@ index 0000000..89c8429 + final var replacement = data.update(tag -> { + tag.putString(ItemDataRewriter.BEDROCK_IDENTIFIER_TAG, "fixture:replacement"); + tag.putInt(ItemDataRewriter.BEDROCK_USE_DURATION_TAG, 34); ++ tag.putBoolean(ItemDataRewriter.BEDROCK_AIR_USE_TAG, true); + }); + assertEquals("fixture:replacement", BedrockItemMetadata.get(replacement).identifier()); + assertEquals(34, BedrockItemMetadata.get(replacement).animation().useDurationTicks()); ++ assertEquals(Boolean.TRUE, BedrockItemMetadata.get(replacement).retainsAirUse()); ++ assertEquals(Boolean.FALSE, first.retainsAirUse()); + assertSame(first, BedrockItemMetadata.get(data)); + assertEquals(72, first.animation().useDurationTicks()); + BedrockItemMetadata.clear(); @@ -18518,6 +18552,7 @@ index 0000000..89c8429 + @Test + void missingAndInvalidNativeMetadataKeepJavaFallbacks() { + assertNull(BedrockItemMetadata.get(null).identifier()); ++ assertNull(BedrockItemMetadata.get(null).retainsAirUse()); + assertEquals(ItemAnimationData.EMPTY, BedrockItemMetadata.get(null).animation()); + final var malformed = new CompoundTag(); + malformed.putInt(ItemDataRewriter.BEDROCK_IDENTIFIER_TAG, 7); @@ -18531,6 +18566,7 @@ index 0000000..89c8429 + final var tag = new CompoundTag(); + tag.putString(ItemDataRewriter.BEDROCK_IDENTIFIER_TAG, "fixture:spear"); + tag.putInt(ItemDataRewriter.BEDROCK_USE_DURATION_TAG, 72); ++ tag.putBoolean(ItemDataRewriter.BEDROCK_AIR_USE_TAG, false); + final var animation = new CompoundTag(); + final var tags = new ListTag(); + tags.add(StringTag.valueOf("minecraft:is_spear")); @@ -19195,6 +19231,66 @@ index 0000000..61d128e + assertNull(BedrockNativeBlocks.fluidState(null, 0, 0, 0)); + } +} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockNativeGeometryTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockNativeGeometryTest.java +new file mode 100644 +index 0000000..1ae0c63 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockNativeGeometryTest.java +@@ -0,0 +1,54 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.appearance; ++ ++import net.minecraft.SharedConstants; ++import net.minecraft.server.Bootstrap; ++import net.minecraft.client.multiplayer.ClientLevel; ++import net.minecraft.core.BlockPos; ++import net.raphimc.viabedrock.api.model.CustomBlockOffset; ++import net.raphimc.viabedrock.api.model.CustomBlockVisual; ++import net.raphimc.viabedrock.api.model.NativeBlockGeometry; ++import net.raphimc.viabedrock.api.model.NativeBlockIdentifiers; ++import net.raphimc.viabedrock.api.model.NativeBlockSection; ++import net.raphimc.viabedrock.api.model.NativeBlockView; ++import org.junit.jupiter.api.BeforeAll; ++import org.junit.jupiter.api.Test; ++import java.util.List; ++import java.util.Map; ++import static org.junit.jupiter.api.Assertions.*; ++import static org.mockito.Mockito.*; ++ ++class BedrockNativeGeometryTest { ++ @BeforeAll ++ static void bootstrap() { ++ SharedConstants.tryDetectVersion(); Bootstrap.bootStrap(); ++ } ++ ++ @Test ++ void requiresTheAcceptedLevelAndClearsPhysicalDataWithTheWorldMirror() throws Exception { ++ final var field = BedrockNativeBlocks.class.getDeclaredField("level"); field.setAccessible(true); ++ final var viewField = BedrockNativeBlocks.class.getDeclaredField("VIEW"); viewField.setAccessible(true); ++ final var view = (NativeBlockView) viewField.get(null); ++ final var previous = field.get(null); ++ final var owner = mock(ClientLevel.class); ++ final var other = mock(ClientLevel.class); ++ final var position = new BlockPos(-17, -64, 93); ++ try { ++ field.set(null, owner); view.clear(); ++ assertNull(BedrockNativeBlocks.geometry(owner, position)); ++ view.accept(new NativeBlockIdentifiers.Reset(0, -64, 384, 0)); ++ final var geometry = new NativeBlockGeometry(List.of(new CustomBlockVisual.Box(.125, .0625, .25, .375, .5625, .875)), CustomBlockOffset.NONE); ++ view.accept(new NativeBlockIdentifiers.Dictionary(0, List.of(new NativeBlockIdentifiers.Name(1, "probe:shape", Map.of(), -1, .6F, geometry)))); ++ view.accept(new NativeBlockIdentifiers.Section(0, -2, -4, 5, 0, NativeBlockSection.create(index -> 1))); ++ assertEquals(geometry, BedrockNativeBlocks.geometry(owner, position)); ++ assertNull(BedrockNativeBlocks.geometry(other, position)); ++ view.clear(); assertNull(BedrockNativeBlocks.geometry(owner, position)); ++ } finally { ++ view.clear(); field.set(null, previous); ++ } ++ } ++} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleAgingTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleAgingTest.java new file mode 100644 index 0000000..70d7c4b @@ -26237,10 +26333,10 @@ index 0000000..a820ceb +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockFirstPersonInputTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockFirstPersonInputTest.java new file mode 100644 -index 0000000..2919aa9 +index 0000000..a594483 --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockFirstPersonInputTest.java -@@ -0,0 +1,135 @@ +@@ -0,0 +1,151 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -26295,6 +26391,22 @@ index 0000000..2919aa9 + } + + @Test ++ void handAnimationsUseClearedHeadPitchWhileRetainingCameraPitchAndTheWorldPose() throws Exception { ++ final var actor = new BedrockActorAnimation.Frame( ++ Map.of("head_x_rotation", 37F), Map.of("player_x_rotation", 37F)); ++ final var animation = BedrockBoneAnimation.parse(json(""" ++ {"loop":true,"bones":{"rightarm":{"rotation":["q.head_x_rotation(0)",0,0]}}} ++ """)).playback(); ++ final var hands = BedrockFirstPersonInput.bind(actor, EMPTY_ITEMS, 1, 1, 1, 1, .5F); ++ final var handPose = animation.sample(0, hands.queries()); ++ // Bedrock 1.26.60.5 clears both actor pitch samples before evaluating its hand graph. ++ assertEquals(0F, handPose.get("rightarm").rotation().x()); ++ assertEquals(37F, hands.variables().get("player_x_rotation")); ++ final var worldPose = animation.sample(.05F, actor.queries()); ++ assertEquals(37F, worldPose.get("rightarm").rotation().x()); ++ } ++ ++ @Test + void distinguishesUnavailableGeometryFromADataModelWithoutArms() throws Exception { + final var absent = BedrockFirstPersonInput.bind(new BedrockActorAnimation.Frame(Map.of(), Map.of()), EMPTY_ITEMS, 0, 1, 0, 1, .5F); + assertFalse(absent.variables().containsKey("short_arm_offset_right")); diff --git a/patches/viafabricplus-bedrock/upstreamable/0008-share-native-actor-and-resource-runtime.pr.md b/patches/viafabricplus-bedrock/upstreamable/0008-share-native-actor-and-resource-runtime.pr.md index 64791d20..e1e59ada 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0008-share-native-actor-and-resource-runtime.pr.md +++ b/patches/viafabricplus-bedrock/upstreamable/0008-share-native-actor-and-resource-runtime.pr.md @@ -21,6 +21,10 @@ Prerequisites: - viabedrock/0085-resolve-server-effects-relative-to-their-actor.patch: Publish native animation-controller command snapshots consumed by the shared graph runtime. +- viabedrock/0078-preserve-native-identifiers-in-java-item-data.patch: Provide the optional native air-continuation item field consumed by the shared metadata cache. + +The immutable item cache retains the optional native air-use predicate. Missing metadata selects the older-core fallback; explicit false selects the current native policy. Replacing custom data changes the predicate without mutating cached snapshots. `BedrockItemMetadataTest` covers this boundary. + ## Validation and remaining limits Regression coverage includes `BedrockActorAnimationTest`, `BedrockActorQueriesTest`, `BedrockActorRenderSelectionTest`, `BedrockActorScaleTest`, and `BedrockActorPickingStateTest`. @@ -65,6 +69,14 @@ Releasing the controls restores the resting shield transform. These controls do not reproduce the reported extreme shield pose. Exact native movement and hit-animation timing, patterned shields, and third-party mod combinations remain open. +Native Bedrock 1.26.60.5 clears current and previous actor angles before evaluating the hand graph. +Fifteen original instruction controls establish that `query.head_x_rotation(0)` returns zero in that scope. +The hand frame now clears inherited head pitch while preserving the camera-pitch variable and the world frame. +The authored-arm regression fails before the correction with 37 degrees instead of zero. +Installed direct and ViaProxy controls each retain the corrected query and camera variable across five view angles from -64 to 80 degrees. +The full add-on build passes with 1,416 tests and 154 optional skips. A fresh saved-stack replay reproduces the tested tree. +These controls cover the head-pitch binding. Other rotation queries, complete hand pixels, and the original shield/custom-skin reports remain open. + Animation timelines retain the remaining animation when an individual Molang string has a syntax error. The failed script keeps a zero-valued entry at its authored time. Structural validation and resource limits still reject invalid input. A native Bedrock 1.26.60.5 local-world control places MegaSMP's captured invalid string between valid assignments. @@ -86,4 +98,12 @@ These controls cover one headwear item. Observer FOV effects and idle-pose diffe Full skin geometry, lighting, and account parity remain open. The full add-on suite passes: 1,464 tests, 153 optional skips, and no failures. A fresh full-stack replay reproduces the tested source tree. +Physical geometry queries use the accepted level owner and the authoritative native block mirror. +They do not require a Java carrier or compiled visual. Unknown geometry keeps the existing camera fallback. +A regression rejects another level owner and clears geometry with the world mirror. + +Complete installed direct and ViaProxy controls receive physical geometry while both visual-definition maps remain empty. +They preserve the accepted level lifetime through resource reload and an Overworld, Nether, and return sequence. +The full add-on build and fresh stack replay pass. Broader native world and actor lifecycles remain open. + The [owning patch](0008-share-native-actor-and-resource-runtime.patch) retains implementation rationale and versioned evidence. diff --git a/patches/viafabricplus-bedrock/upstreamable/0017-apply-custom-block-shapes.patch b/patches/viafabricplus-bedrock/upstreamable/0017-apply-custom-block-shapes.patch index 20fd2ea5..a08390a4 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0017-apply-custom-block-shapes.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0017-apply-custom-block-shapes.patch @@ -1,4 +1,4 @@ -From c86979dd73dfcfcf373c0ef88beb2dc7876304ac Mon Sep 17 00:00:00 2001 +From 7866070fe49d6a391c1a66f912354f7c14dae85e 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(blocks): apply native custom block shapes and lighting @@ -180,16 +180,29 @@ pool and geometry lookup repairs remain separate responsibilities. Targeted tests cover exact preserved coordinates, unmarked models, invalid array sizes, nulls, infinities and oversized vertices. Runtime verification must also exercise the injected Java model loader. + +Apply native world-float rounding before creating position-dependent collision +and selection shapes even when random offsets are disabled. Reusing the +local cached shape otherwise keeps thin collision boxes at large coordinates +where native world bounds collapse. Camera queries still combine local +bounds before world translation, retaining their distinct envelope. + +A private installed 1.26.60 BDS array-box fixture demonstrates the bypass +through the real BlockState hook at a supplied far query position. Keep +the boundary regression for direct definitions and encoded proxy metadata; +this method control does not establish loaded far-world movement parity. + +Read verified physical geometry from the accepted native world mirror for camera queries, including when no Java pack or visual carrier is available. Preserve known-empty collision and native world-float translation before converting to local voxel coordinates; unresolved classes retain the existing visual path. Windows 1.26.60.5 original component import/application/finalization controls establish the generic bounds contract. --- .../bedrock/block/BedrockBlockFace.java | 22 +++ .../block/BedrockBlockModelBounds.java | 38 ++++ .../block/BedrockCustomBlockResources.java | 81 +++++++++ - .../bedrock/block/BedrockCustomBlocks.java | 137 +++++++++++++++ + .../bedrock/block/BedrockCustomBlocks.java | 145 +++++++++++++++ .../bedrock/block/BedrockCustomCulling.java | 50 ++++++ .../bedrock/block/BedrockSodiumLightData.java | 13 ++ .../bedrock/block/BedrockSodiumQuad.java | 18 ++ .../bedrock/block/BedrockTerrainLighting.java | 74 ++++++++ - .../sodium/MixinSodiumBlockLighting.java | 49 ++++++ + .../sodium/MixinSodiumBlockLighting.java | 49 +++++ .../compat/sodium/MixinSodiumLightData.java | 30 ++++ .../compat/sodium/MixinSodiumMutableQuad.java | 37 ++++ .../mixin/compat/sodium/MixinSodiumQuad.java | 37 ++++ @@ -198,22 +211,23 @@ verification must also exercise the injected Java model loader. .../features/block/MixinBlockFaceBakery.java | 41 +++++ .../block/MixinBlockFaceDeserializer.java | 35 ++++ .../MixinBlockModelElementDeserializer.java | 26 +++ - .../block/MixinCustomBlockCollisions.java | 39 +++++ + .../block/MixinCustomBlockCollisions.java | 39 ++++ .../block/MixinCustomBlockConnection.java | 24 +++ .../block/MixinCustomBlockCulling.java | 34 ++++ .../block/MixinCustomBlockLighting.java | 64 +++++++ .../block/MixinCustomBlockResources.java | 24 +++ .../block/MixinCustomBlockSession.java | 24 +++ - .../features/block/MixinCustomBlockState.java | 162 ++++++++++++++++++ - .../block/MixinRedstoneWireBlock.java | 29 ++++ + .../features/block/MixinCustomBlockState.java | 168 ++++++++++++++++++ + .../block/MixinRedstoneWireBlock.java | 29 +++ .../viafabricplus-bedrock.mixins.json | 17 ++ .../block/BedrockBlockModelBoundsTest.java | 32 ++++ .../block/BedrockCustomBlockLightingTest.java | 54 ++++++ .../BedrockCustomBlockResourcesTest.java | 91 ++++++++++ - .../block/BedrockCustomBlocksTest.java | 88 ++++++++++ + .../block/BedrockCustomBlocksTest.java | 107 +++++++++++ .../block/BedrockCustomCullingTest.java | 51 ++++++ + .../block/BedrockNativeCameraShapeTest.java | 41 +++++ .../block/BedrockTerrainLightingTest.java | 142 +++++++++++++++ - 32 files changed, 1641 insertions(+) + 33 files changed, 1715 insertions(+) create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/block/BedrockBlockFace.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/block/BedrockBlockModelBounds.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/block/BedrockCustomBlockResources.java @@ -244,6 +258,7 @@ verification must also exercise the injected Java model loader. create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/block/BedrockCustomBlockResourcesTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/block/BedrockCustomBlocksTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/block/BedrockCustomCullingTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/block/BedrockNativeCameraShapeTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/block/BedrockTerrainLightingTest.java diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/block/BedrockBlockFace.java b/src/main/java/com/viaversion/viafabricplus/bedrock/block/BedrockBlockFace.java @@ -407,10 +422,10 @@ index 0000000..0681cfb +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/block/BedrockCustomBlocks.java b/src/main/java/com/viaversion/viafabricplus/bedrock/block/BedrockCustomBlocks.java new file mode 100644 -index 0000000..d14c83e +index 0000000..8300e93 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/block/BedrockCustomBlocks.java -@@ -0,0 +1,137 @@ +@@ -0,0 +1,145 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/florianreuth/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -427,9 +442,9 @@ index 0000000..d14c83e +import net.minecraft.world.phys.shapes.Shapes; +import net.minecraft.world.phys.shapes.VoxelShape; +import net.minecraft.world.phys.Vec3; -+import net.raphimc.viabedrock.api.model.CustomBlockOffset; +import net.raphimc.viabedrock.api.model.CustomBlockVisual; +import net.raphimc.viabedrock.api.model.CustomBlockProperties; ++import net.raphimc.viabedrock.api.model.NativeBlockGeometry; +import net.raphimc.viabedrock.protocol.storage.ResourcePackStorage; +import org.jetbrains.annotations.Nullable; + @@ -512,6 +527,24 @@ index 0000000..d14c83e + return shape.optimize(); + } + ++ public static VoxelShape cameraShape(final NativeBlockGeometry geometry, final BlockPos pos) { ++ return cameraShape(geometry.cameraBoxesAt(pos.getX(), pos.getY(), pos.getZ()), pos); ++ } ++ ++ private static VoxelShape cameraShape(final List boxes, final BlockPos pos) { ++ final var shape = localShape(boxes, pos); ++ return shape.isEmpty() ? shape : Shapes.create(shape.bounds()); ++ } ++ ++ private static VoxelShape localShape(final List boxes, final BlockPos pos) { ++ VoxelShape shape = Shapes.empty(); ++ for (final var box : boxes) { ++ shape = Shapes.or(shape, Shapes.box(box.minX() - pos.getX(), box.minY() - pos.getY(), box.minZ() - pos.getZ(), ++ box.maxX() - pos.getX(), box.maxY() - pos.getY(), box.maxZ() - pos.getZ())); ++ } ++ return shape.optimize(); ++ } ++ + public record Visual(CustomBlockProperties definition, VoxelShape collision, VoxelShape selection, VoxelShape occlusion, + boolean fullCollision, boolean largeCollision) { + @@ -521,28 +554,18 @@ index 0000000..d14c83e + } + + public VoxelShape collision(final BlockPos pos) { -+ if (this.definition.randomOffset().equals(CustomBlockOffset.NONE)) return this.collision; + return localShape(this.definition.collisionBoxesAt(pos.getX(), pos.getY(), pos.getZ()), pos); + } + + public VoxelShape selection(final BlockPos pos) { -+ if (this.definition.randomOffset().equals(CustomBlockOffset.NONE)) return this.selection; + return localShape(this.definition.selectionBoxesAt(pos.getX(), pos.getY(), pos.getZ()), pos); + } + + public VoxelShape camera(final BlockPos pos) { -+ final var shape = localShape(this.definition.cameraBoxesAt(pos.getX(), pos.getY(), pos.getZ()), pos); -+ return shape.isEmpty() ? shape : Shapes.create(shape.bounds()); ++ return cameraShape(this.definition.cameraBoxesAt(pos.getX(), pos.getY(), pos.getZ()), pos); + } + -+ private static VoxelShape localShape(final List boxes, final BlockPos pos) { -+ VoxelShape shape = Shapes.empty(); -+ for (final var box : boxes) { -+ shape = Shapes.or(shape, Shapes.box(box.minX() - pos.getX(), box.minY() - pos.getY(), box.minZ() - pos.getZ(), -+ box.maxX() - pos.getX(), box.maxY() - pos.getY(), box.maxZ() - pos.getZ())); -+ } -+ return shape.optimize(); -+ } ++ + } + + private record Snapshot(Object connection, Map direct, Map remote, Map visuals, boolean extendedCollision) { @@ -1361,10 +1384,10 @@ index 0000000..8b7cd8b +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/block/MixinCustomBlockState.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/block/MixinCustomBlockState.java new file mode 100644 -index 0000000..35eec68 +index 0000000..db46e6f --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/block/MixinCustomBlockState.java -@@ -0,0 +1,162 @@ +@@ -0,0 +1,168 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/florianreuth/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -1373,6 +1396,7 @@ index 0000000..35eec68 +package com.viaversion.viafabricplus.bedrock.injection.mixin.features.block; + +import com.viaversion.viafabricplus.bedrock.block.BedrockCustomBlocks; ++import com.viaversion.viafabricplus.bedrock.appearance.BedrockNativeBlocks; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.world.level.BlockGetter; @@ -1399,6 +1423,11 @@ index 0000000..35eec68 + + @Inject(method = "getVisualShape", at = @At("HEAD"), cancellable = true) + private void customCamera(final BlockGetter level, final BlockPos pos, final CollisionContext context, final CallbackInfoReturnable cir) { ++ final var geometry = BedrockNativeBlocks.geometry(level, pos); ++ if (geometry != null) { ++ cir.setReturnValue(BedrockCustomBlocks.cameraShape(geometry, pos)); ++ return; ++ } + final var visual = BedrockCustomBlocks.current((BlockState) (Object) this); + if (visual != null) cir.setReturnValue(visual.camera(pos)); + } @@ -1805,10 +1834,10 @@ index 0000000..acea52c +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/block/BedrockCustomBlocksTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/block/BedrockCustomBlocksTest.java new file mode 100644 -index 0000000..8a05166 +index 0000000..5d54f6e --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/block/BedrockCustomBlocksTest.java -@@ -0,0 +1,88 @@ +@@ -0,0 +1,107 @@ +package com.viaversion.viafabricplus.bedrock.block; + +import net.raphimc.viabedrock.api.model.CustomBlockOffset; @@ -1889,6 +1918,25 @@ index 0000000..8a05166 + assertEquals(visual.offset(pos), BedrockCustomBlocks.visual(remote).offset(pos)); + } + ++ @Test ++ void roundsUnshiftedCollisionAndSelectionAtWorldFloatBoundaries() { ++ final var boxes = List.of(box(0.9375, 0, 0, 1, 1, 0.0625), box(0.4375, 0, 0.5, 0.5, 1, 0.5625)); ++ final var visual = BedrockCustomBlocks.visual(definition(boxes, boxes, false)); ++ final BlockPos near = new BlockPos(2, 64, 4); ++ final BlockPos far = new BlockPos(1048576, 64, 1048576); ++ ++ assertFalse(visual.collision(near).isEmpty()); ++ assertFalse(visual.selection(near).isEmpty()); ++ assertTrue(visual.collision(far).isEmpty()); ++ assertTrue(visual.selection(far).isEmpty()); ++ assertTrue(Shapes.equal(Shapes.box(0.5, 0, 0, 1, 1, 0.5), visual.camera(far))); ++ ++ final var remote = CustomBlockProperties.decode(CustomBlockProperties.encode(List.of(new CustomBlockVisual("test:block", 1, ++ "texture", "{}", "opaque", boxes, boxes, false, 0, 0, java.util.Map.of(), java.util.Map.of(), CustomBlockOffset.NONE)))).get(1); ++ assertTrue(BedrockCustomBlocks.visual(remote).collision(far).isEmpty()); ++ assertTrue(Shapes.equal(visual.camera(far), BedrockCustomBlocks.visual(remote).camera(far))); ++ } ++ + private static CustomBlockProperties definition(final List collision, final List selection, final boolean occluding) { + return new CustomBlockProperties("test:block", 1, collision, selection, occluding, 0, 0, CustomBlockOffset.NONE); + } @@ -1954,6 +2002,53 @@ index 0000000..640a616 + return new Rule(Direction.NORTH, condition, opaque); + } +} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/block/BedrockNativeCameraShapeTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/block/BedrockNativeCameraShapeTest.java +new file mode 100644 +index 0000000..10934cb +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/block/BedrockNativeCameraShapeTest.java +@@ -0,0 +1,41 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.block; ++ ++import net.minecraft.SharedConstants; ++import net.minecraft.server.Bootstrap; ++import net.minecraft.core.BlockPos; ++import net.minecraft.world.phys.AABB; ++import net.raphimc.viabedrock.api.model.CustomBlockOffset; ++import net.raphimc.viabedrock.api.model.CustomBlockVisual; ++import net.raphimc.viabedrock.api.model.NativeBlockGeometry; ++import org.junit.jupiter.api.BeforeAll; ++import org.junit.jupiter.api.Test; ++import java.util.List; ++import static org.junit.jupiter.api.Assertions.*; ++ ++class BedrockNativeCameraShapeTest { ++ @BeforeAll ++ static void bootstrap() { ++ SharedConstants.tryDetectVersion(); Bootstrap.bootStrap(); ++ } ++ ++ @Test ++ void convertsNativeWorldBoundsToLocalCameraShapeWithoutACarrier() { ++ final var geometry = new NativeBlockGeometry(List.of(new CustomBlockVisual.Box(.125, .0625, .25, .375, .5625, .875)), CustomBlockOffset.NONE); ++ assertEquals(new AABB(.125, .0625, .25, .375, .5625, .875), ++ BedrockCustomBlocks.cameraShape(geometry, new BlockPos(-17, -64, 93)).bounds()); ++ assertTrue(BedrockCustomBlocks.cameraShape(geometry, new BlockPos(16777216, 64, 16777216)).isEmpty()); ++ assertTrue(BedrockCustomBlocks.cameraShape(NativeBlockGeometry.EMPTY, new BlockPos(-17, -64, 93)).isEmpty()); ++ } ++ ++ @Test ++ void combinesDisjointPlanesBeforeTheCameraShapeDropsEmptyVolumes() { ++ final var geometry = new NativeBlockGeometry(List.of(new CustomBlockVisual.Box(0, 0, 0, 0, 1, 1), ++ new CustomBlockVisual.Box(1, 0, 0, 1, 1, 1)), CustomBlockOffset.NONE); ++ assertEquals(new AABB(0, 0, 0, 1, 1, 1), BedrockCustomBlocks.cameraShape(geometry, new BlockPos(8, 64, -9)).bounds()); ++ } ++} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/block/BedrockTerrainLightingTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/block/BedrockTerrainLightingTest.java new file mode 100644 index 0000000..b897254 diff --git a/patches/viafabricplus-bedrock/upstreamable/0017-apply-custom-block-shapes.pr.md b/patches/viafabricplus-bedrock/upstreamable/0017-apply-custom-block-shapes.pr.md index 4e3f182b..c5e2b7ca 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0017-apply-custom-block-shapes.pr.md +++ b/patches/viafabricplus-bedrock/upstreamable/0017-apply-custom-block-shapes.pr.md @@ -2,16 +2,20 @@ ## Purpose -Read per-connection custom block metadata from ViaBedrock for reserved carrier states. Apply separate collision and selection shapes, occlusion, support, suffocation, brightness and light values on the client. Preserve ordinary block behavior when no custom definition owns a state. +Read physical camera geometry from native world snapshots and visual properties from per-connection metadata. Apply separate collision and selection shapes, occlusion, support, suffocation, brightness and light values on the client. Use the accepted native geometry for camera queries even when no custom visual owns the Java state. Preserve ordinary block behavior when physical data is unknown. ## Design constraints -Apply authoritative custom collision, selection, lighting, and culling to owned carrier states. Preserve ordinary Java behavior outside those states and respect native coordinate and float boundaries. +Apply authoritative custom collision, selection, lighting, and culling to owned carrier states. Preserve ordinary Java behavior outside those states. Collision and selection queries use native world-float bounds even when random offsets are disabled. Camera queries combine local bounds before world translation. The level-owned physical mirror supplies this geometry independently of visual carriers. It also accepts the [hardcoded primary providers](../../viabedrock/upstreamable/0054-render-native-block-state-geometry-and-materials.pr.md). Known empty collision suppresses the camera shape. Unknown classes retain their existing fallback. ## Validation and remaining limits Regression coverage includes `BedrockBlockModelBoundsTest`, `BedrockCustomBlockLightingTest`, `BedrockCustomBlockResourcesTest`, `BedrockCustomBlocksTest`. -Semantic geometry tests and selected live model controls cover supported cases. Additional materials, carrier limits, and native lighting/visual parity remain open. +Semantic geometry tests and selected live model controls cover supported cases. Installed software-rendered 1.26.60 BDS controls cover a required custom resource pack, flat collision planes, enabled empty box lists, state changes, resource reload, and dimension return on direct and ViaProxy routes. Far-position shape-hook controls verify float rounding and the distinct camera envelope; they do not establish loaded far-world gameplay parity. Physical camera regressions cover missing carriers, negative coordinates, disjoint planes, and float collapse. Additional materials, carrier limits, and native lighting/visual parity remain open. + +Installed direct and ViaProxy recordings match 17,883 camera-hook samples without visual definitions. +The captured definitions match the original native component controls. State swaps, resource reload, and dimension return retain the geometry. +Both recorders exit successfully, and the full builds and fresh replays pass. Movement collision, selection without carriers, and full camera trajectories remain open. The [owning patch](0017-apply-custom-block-shapes.patch) retains implementation rationale and versioned evidence. diff --git a/patches/viafabricplus-bedrock/upstreamable/0017-render-equipped-native-pack-models.patch b/patches/viafabricplus-bedrock/upstreamable/0017-render-equipped-native-pack-models.patch index ad789ce1..9ff52980 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0017-render-equipped-native-pack-models.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0017-render-equipped-native-pack-models.patch @@ -1,4 +1,4 @@ -From e8cc7bd00ecfdb403e9a5360434363f92308ae32 Mon Sep 17 00:00:00 2001 +From 63c9a463e187de88afbcd9ed515d1b8983768211 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(attachables): render equipped native pack models @@ -330,28 +330,66 @@ the world context to latest after shared dependency selection. Extend the owner-binding regression with an older world cutoff while retaining server replacement conditions. This verifies context propagation, not complete native armor masking or graphical equipment parity. + +Render native shield Base and Patterns through the authored pattern +controller, geometry and animation. Read the preserved version3 native +stack snapshot so Bedrock's diagonal texture aliases, palette, unknown +pattern fallback and six-source-entry limit survive Java component mapping. +Build one bounded connection-owned atlas from the resolved pack images, +including server overrides, and release it with the existing texture +registry. Appearance replacement changes immutable draw inputs without +resetting the equipped item's animation playback. + +The 1.26.60.5 licensed executable's native pattern registry and read-only +pattern draw inputs establish the texture names, sixteen dye colors, +ordered base/stripe inputs and sampler2 UV transforms. Native red with a +white bottom stripe is visibly patterned while blocking; the old Java +path skipped atlas items. Keep the original oversized-shield and custom +skin reports open because this fixture establishes a distinct failure. + +Preserve the complete native arm matrix when Java fallback held items +attach to an animated player. Flattened humanoid fields discard reflected +scale and inherited shear, and collapsed scales make their rotation +undefined. Read only the selected arm and its ancestors, convert its +translation to block units, and compose it with the existing render pose. +Retain Java26.3's root-space half-pixel slim anchor. Missing arms and a +retired native tree keep the ordinary Java attachment path. + +The target protocol2225 player runtime already draws the authored native +hierarchy and exports its complete matrices to native attachables. This +change makes Java fallback item placement use that same hierarchy. +A mandatory regression exercises actual ArmedModel dispatch with authored +parent rotation, reflected/nonuniform scale and collapsed positions, both +arms, slim/wide models and a nonidentity incoming render pose. The old +adapter moves a sampled point by 0.44624 blocks on X. Separate controls +retain missing-arm fallback and restore Java placement after retirement. +These controls establish adapter consistency, not native lighting or +singular normal parity. The original oversized shield and custom-skin +screenshots remain unmatched. --- .../appearance/BedrockActorAnimation.java | 66 ++- .../appearance/BedrockAttachableBindings.java | 66 +++ .../BedrockAttachableSelection.java | 51 ++ - .../appearance/BedrockEquipmentResources.java | 123 +++++ + .../appearance/BedrockEquipmentResources.java | 124 +++++ .../appearance/BedrockPlayerControllers.java | 5 + .../render/MixinAvatarAnimationRenderer.java | 18 +- .../render/MixinAvatarAnimationState.java | 11 +- .../MixinBedrockFirstPersonVisibility.java | 8 + .../bedrock/render/BedrockActorResources.java | 3 + - .../bedrock/render/BedrockAnimatedModel.java | 96 +++- + .../bedrock/render/BedrockAnimatedModel.java | 101 +++- .../bedrock/render/BedrockEquipmentCache.java | 41 ++ .../BedrockEquippedAttachableLayer.java | 36 ++ .../render/BedrockHeldAttachableBindings.java | 59 +++ - .../render/BedrockPlayerAttachables.java | 479 ++++++++++++++++++ - .../bedrock/render/BedrockPlayerModel.java | 8 + + .../render/BedrockPlayerAttachables.java | 501 ++++++++++++++++++ + .../bedrock/render/BedrockPlayerModel.java | 28 + .../bedrock/render/BedrockPlayerSkins.java | 37 +- + .../bedrock/render/BedrockShieldAtlas.java | 50 ++ + .../bedrock/render/BedrockShieldPatterns.java | 121 +++++ .../bedrock/render/BedrockTextureMeshes.java | 109 ++++ .../BedrockAttachableBindingsTest.java | 105 ++++ .../BedrockAttachableScopeTest.java | 154 ++++++ .../BedrockAttachableSelectionTest.java | 48 ++ - .../BedrockEquipmentResourcesTest.java | 88 ++++ + .../BedrockEquipmentResourcesTest.java | 119 +++++ .../BedrockEquippedAnimationTest.java | 46 ++ .../BedrockPlayerControllersTest.java | 23 +- .../BedrockServerEntityAnimationTest.java | 94 ++++ @@ -364,9 +402,10 @@ complete native armor masking or graphical equipment parity. .../render/BedrockExplicitBindingsTest.java | 155 ++++++ .../BedrockHeldAttachableBindingsTest.java | 128 +++++ .../render/BedrockHeldGlintMaterialsTest.java | 39 ++ - .../render/BedrockPlayerModelTest.java | 59 +++ - .../render/BedrockTextureMeshesTest.java | 87 ++++ - 35 files changed, 2690 insertions(+), 26 deletions(-) + .../render/BedrockPlayerModelTest.java | 140 +++++ + .../render/BedrockShieldPatternsTest.java | 91 ++++ + .../render/BedrockTextureMeshesTest.java | 87 +++ + 38 files changed, 3112 insertions(+), 26 deletions(-) create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockAttachableBindings.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockAttachableSelection.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockEquipmentResources.java @@ -374,6 +413,8 @@ complete native armor masking or graphical equipment parity. create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockEquippedAttachableLayer.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockHeldAttachableBindings.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerAttachables.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockShieldAtlas.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockShieldPatterns.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockTextureMeshes.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockAttachableBindingsTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockAttachableScopeTest.java @@ -388,6 +429,7 @@ complete native armor masking or graphical equipment parity. create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockExplicitBindingsTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockHeldAttachableBindingsTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockHeldGlintMaterialsTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockShieldPatternsTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockTextureMeshesTest.java diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockActorAnimation.java b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockActorAnimation.java @@ -629,10 +671,10 @@ index 0000000..a66cd4c +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockEquipmentResources.java b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockEquipmentResources.java new file mode 100644 -index 0000000..ce41f31 +index 0000000..1bb23b4 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockEquipmentResources.java -@@ -0,0 +1,123 @@ +@@ -0,0 +1,124 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -714,7 +756,8 @@ index 0000000..ce41f31 + if (path.equals("manifest.json") || path.startsWith("attachables/") || path.startsWith("animations/") + || path.startsWith("animation_controllers/") || path.startsWith("render_controllers/") + || path.startsWith("materials/") || path.startsWith("textures/") && path.length() > 4 -+ && equippedTextures.contains(path.substring(0, path.length() - 4))) resources.put(path, bytes); ++ && (equippedTextures.contains(path.substring(0, path.length() - 4)) ++ || equippedTextures.contains("atlas.shield") && path.startsWith("textures/entity/shield_patterns/"))) resources.put(path, bytes); + }); + for (final var entry : layer.resources().entrySet()) { + if (!entry.getKey().startsWith("models/") || !entry.getKey().endsWith(".json")) continue; @@ -894,7 +937,7 @@ index eb6c3eb..6b10dbd 100644 BedrockAcceptedResources.clear(); loaded = false; diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockAnimatedModel.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockAnimatedModel.java -index bbeebe6..3e8e5c4 100644 +index bbeebe6..0697b72 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockAnimatedModel.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockAnimatedModel.java @@ -5,10 +5,9 @@ @@ -952,7 +995,7 @@ index bbeebe6..3e8e5c4 100644 } Map drawingMatrices() { -@@ -171,9 +175,93 @@ public final class BedrockAnimatedModel { +@@ -171,9 +175,98 @@ public final class BedrockAnimatedModel { target.jacket.loadPose(target.body.storePose()); } @@ -985,6 +1028,11 @@ index bbeebe6..3e8e5c4 100644 + }); + } + ++ Matrix4f boneMatrix(final String name) { ++ final var part = this.parts.get(this.boneName(name)); ++ return part == null ? null : new Matrix4f(this.global(part, new HashMap<>())); ++ } ++ + Map boneMatrices() { + final Map current = new HashMap<>(); + final Map result = new HashMap<>(); @@ -1202,10 +1250,10 @@ index 0000000..1ea81dc +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerAttachables.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerAttachables.java new file mode 100644 -index 0000000..9c823f1 +index 0000000..384a400 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerAttachables.java -@@ -0,0 +1,479 @@ +@@ -0,0 +1,501 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -1224,6 +1272,7 @@ index 0000000..9c823f1 +import com.viaversion.viafabricplus.bedrock.appearance.BedrockPlayerControllers; + +import java.io.IOException; ++import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; @@ -1267,6 +1316,7 @@ index 0000000..9c823f1 + private static ResourcePackStorage currentPacks; + private static Object currentLevel; + private static BedrockActorMaterialDefinitions materials; ++ private static Optional shieldAtlas; + + private BedrockPlayerAttachables() { + } @@ -1358,14 +1408,15 @@ index 0000000..9c823f1 + for (final EquipmentSlot slot : firstPerson ? List.of(EquipmentSlot.MAINHAND, EquipmentSlot.OFFHAND) : SLOTS) { + final ItemStack stack = firstPerson ? slot == EquipmentSlot.MAINHAND ? main : off : entity.getItemBySlot(slot); + final boolean hand = slot == EquipmentSlot.MAINHAND || slot == EquipmentSlot.OFFHAND; -+ if (hand && (requiresBannerAtlas(stack) || !BedrockPlayerSkins.options(state.skin).rendering().getOrDefault("enable_attachables", true))) continue; ++ final var appearance = BedrockShieldPatterns.get(stack); ++ if (hand && (requiresBannerAtlas(stack) && !appearance.patterned() ++ || !BedrockPlayerSkins.options(state.skin).rendering().getOrDefault("enable_attachables", true))) continue; + final Map context = Map.of("is_first_person", firstPerson ? 1F : 0F, "item_slot", slotName(slot)); + final Map itemQueries = new HashMap<>(queries); + itemQueries.put("get_animation_frame", BedrockFirstPersonItemRendering.animationFrame(stack)); + final Map variables = Map.of("is_first_person", firstPerson ? 1F : 0F, + "is_enchanted", stack.hasFoil() ? 1F : 0F, -+ // Patterned hand items retain their atlas fallback, but plain native controllers still read this flag. -+ "is_patterned", 0F); ++ "is_patterned", appearance.patterned() ? 1F : 0F); + ItemIdentity identity; + try { + identity = identify(stack, packs, SELECTORS.computeIfAbsent(entity, _ -> new BedrockAttachableSelection()), properties, @@ -1426,6 +1477,9 @@ index 0000000..9c823f1 + for (final var group : BedrockGeometry.partitionMaterials(geometry, Map.of(), selected.materials()).entrySet()) { + final var material = (hand ? materials.heldItemMaterial(group.getKey()) : materials.material(group.getKey())).orElse(null); + if (material == null || material.glint() && glintFrame == null || selected.textures().size() < material.textureCount()) { supported = false; break; } ++ final var atlas = material.pattern() ? shieldAtlas(packs) : null; ++ final var pattern = atlas == null ? null : appearance.draw(atlas); ++ if (material.pattern() && pattern == null) { supported = false; break; } + final List textures = new ArrayList<>(); + for (int index = 0; index < material.textureCount(); index++) { + final Identifier texture = texture(selected.textures().get(index), packs); @@ -1440,7 +1494,7 @@ index 0000000..9c823f1 + model = model(geometry, group.getValue()); + equipped.resource.models.put(key, model); + } -+ slotSurfaces.add(new Surface(slot, model, selected, pose, bindings, renderType(List.copyOf(textures), selected, material, glintFrame))); ++ slotSurfaces.add(new Surface(slot, model, selected, pose, bindings, renderType(List.copyOf(textures), selected, material, glintFrame, pattern))); + } + if (!supported) break; + } @@ -1580,7 +1634,7 @@ index 0000000..9c823f1 + }; + } + -+ private static java.awt.image.BufferedImage texturePixels(final String path, final ResourcePackStorage packs) { ++ private static BufferedImage texturePixels(final String path, final ResourcePackStorage packs) { + for (final var pack : packs.getPackStackTopToBottom()) { + final var image = pack.content().getShortnameImage(path); + if (image != null) return image.getImage(); @@ -1588,39 +1642,53 @@ index 0000000..9c823f1 + return null; + } + ++ private static BedrockShieldAtlas shieldAtlas(final ResourcePackStorage packs) { ++ if (shieldAtlas == null) shieldAtlas = Optional.ofNullable(BedrockShieldAtlas.build(path -> texturePixels(path, packs))); ++ return shieldAtlas.orElse(null); ++ } ++ + private static Identifier texture(final String path, final ResourcePackStorage packs) { + final Identifier cached = TEXTURES.get(path); + if (cached != null) return cached; -+ for (final var pack : packs.getPackStackTopToBottom()) { -+ final var image = pack.content().getShortnameImage(path); -+ if (image == null) continue; -+ final var pixels = image.getImage(); -+ if (pixels == null || pixels.getWidth() < 1 || pixels.getHeight() < 1 || pixels.getWidth() > 4096 || pixels.getHeight() > 4096) return null; -+ final NativeImage nativeImage = new NativeImage(pixels.getWidth(), pixels.getHeight(), false); -+ for (int y = 0; y < pixels.getHeight(); y++) for (int x = 0; x < pixels.getWidth(); x++) { -+ final int argb = pixels.getRGB(x, y); -+ nativeImage.setPixelABGR(x, y, (argb & 0xFF00FF00) | ((argb & 0xFF) << 16) | ((argb >>> 16) & 0xFF)); -+ } -+ final Identifier id = Identifier.fromNamespaceAndPath("viafabricplus-bedrock", "attachables/" + TEXTURES.size()); -+ Minecraft.getInstance().getTextureManager().register(id, new DynamicTexture(id::toString, nativeImage)); -+ TEXTURES.put(path, id); -+ return id; ++ if (path.equals("atlas.shield")) { ++ final var atlas = shieldAtlas(packs); ++ return atlas == null ? null : uploadTexture(path, atlas.image()); + } -+ return null; ++ final var pixels = texturePixels(path, packs); ++ return pixels == null ? null : uploadTexture(path, pixels); ++ } ++ ++ private static Identifier uploadTexture(final String path, final BufferedImage pixels) { ++ if (pixels.getWidth() < 1 || pixels.getHeight() < 1 || pixels.getWidth() > 4096 || pixels.getHeight() > 4096) return null; ++ final NativeImage nativeImage = new NativeImage(pixels.getWidth(), pixels.getHeight(), false); ++ for (int y = 0; y < pixels.getHeight(); y++) for (int x = 0; x < pixels.getWidth(); x++) { ++ final int argb = pixels.getRGB(x, y); ++ nativeImage.setPixelABGR(x, y, (argb & 0xFF00FF00) | ((argb & 0xFF) << 16) | ((argb >>> 16) & 0xFF)); ++ } ++ final Identifier id = Identifier.fromNamespaceAndPath("viafabricplus-bedrock", "attachables/" + TEXTURES.size()); ++ Minecraft.getInstance().getTextureManager().register(id, new DynamicTexture(id::toString, nativeImage)); ++ TEXTURES.put(path, id); ++ return id; + } + + private static RenderType renderType(final List textures, final BedrockPlayerControllers.Surface selection, -+ final BedrockActorMaterialDefinitions.Material material, final BedrockActorGlintFrames.Frame frame) { ++ final BedrockActorMaterialDefinitions.Material material, final BedrockActorGlintFrames.Frame frame, ++ final BedrockActorPattern pattern) { + final boolean nether = BedrockActorRenderTypes.usesNetherLighting(); + if (material.glint()) { ++ if (pattern != null) return BedrockActorRenderTypes.createPattern(textures, material, selection.ignoreLighting(), ++ selection.lightMultiplier(), BedrockActorUvTransform.IDENTITY, nether, pattern, frame); + // Target item renderers supply UVScale(.5,.5); retain this extraction frame for the queued draw. + return BedrockActorRenderTypes.createFrameGlint(textures, material, selection.ignoreLighting(), + selection.lightMultiplier(), frame, .5F, .5F, nether); + } + final MaterialKey key = new MaterialKey(textures, material, selection.ignoreLighting(), selection.lightMultiplier(), -+ selection.uv(), nether); ++ selection.uv(), nether, pattern); + return RENDER_TYPES.computeIfAbsent(key, value -> { + final var uv = value.uv(); ++ if (value.pattern() != null) return BedrockActorRenderTypes.createPattern(value.textures(), value.material(), ++ value.ignoreLighting(), value.multiplier(), ++ new BedrockActorUvTransform(uv.offsetX(), uv.offsetY(), uv.scaleX(), uv.scaleY()), value.nether(), value.pattern(), null); + return BedrockActorRenderTypes.create(value.textures(), value.material(), value.ignoreLighting(), + value.multiplier(), new BedrockActorUvTransform(uv.offsetX(), uv.offsetY(), uv.scaleX(), uv.scaleY()), value.nether()); + }); @@ -1640,6 +1708,8 @@ index 0000000..9c823f1 + currentPacks = null; + currentLevel = null; + materials = null; ++ shieldAtlas = null; ++ BedrockShieldPatterns.clear(); + } + + public interface State { @@ -1654,7 +1724,7 @@ index 0000000..9c823f1 + Map pose) {} + + private record MaterialKey(List textures, BedrockActorMaterialDefinitions.Material material, -+ boolean ignoreLighting, float multiplier, BedrockPlayerControllers.Uv uv, boolean nether) {} ++ boolean ignoreLighting, float multiplier, BedrockPlayerControllers.Uv uv, boolean nether, BedrockActorPattern pattern) {} + + private record GeometryModelKey(String source, Set bones) {} + @@ -1686,21 +1756,60 @@ index 0000000..9c823f1 + } +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerModel.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerModel.java -index 4bedd7f..acf0f1b 100644 +index 4bedd7f..c69a29c 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerModel.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerModel.java -@@ -13,6 +13,7 @@ import net.minecraft.client.model.player.PlayerModel; +@@ -7,18 +7,22 @@ package com.viaversion.viafabricplus.bedrock.render; + + import com.viaversion.viafabricplus.bedrock.appearance.BedrockActorAnimation; + import com.viaversion.viafabricplus.bedrock.appearance.BedrockBoneAnimation; ++import com.mojang.blaze3d.vertex.PoseStack; + + import net.minecraft.client.model.geom.ModelPart; + import net.minecraft.client.model.player.PlayerModel; import net.minecraft.client.renderer.entity.state.AvatarRenderState; ++import net.minecraft.world.entity.HumanoidArm; import java.util.Map; +import org.joml.Matrix4f; /** Applies native actor and emote poses to the authored bone hierarchy. */ final class BedrockPlayerModel extends PlayerModel { -@@ -50,4 +51,11 @@ final class BedrockPlayerModel extends PlayerModel { + private final Map children; + private final Map ordinary; + private final BedrockAnimatedModel animated; ++ private final float handOffset; + + BedrockPlayerModel( + final ModelPart root, +@@ -29,6 +33,7 @@ final class BedrockPlayerModel extends PlayerModel { + this.children = children; + this.ordinary = Map.copyOf(children); + this.animated = animated; ++ this.handOffset = slim ? .5F / 16F : 0F; + } + + @Override +@@ -50,4 +55,27 @@ final class BedrockPlayerModel extends PlayerModel { this.children.clear(); this.children.put("animated", this.animated.model().root()); } ++ ++ @Override ++ public void translateToHand(final AvatarRenderState state, final HumanoidArm arm, final PoseStack poses) { ++ final var matrix = this.children.get("animated") == this.animated.model().root() ++ ? this.animated.boneMatrix(arm == HumanoidArm.RIGHT ? "rightarm" : "leftarm") : null; ++ if (matrix == null) { ++ super.translateToHand(state, arm, poses); ++ return; ++ } ++ this.root().translateAndRotate(poses); ++ // SRT decomposition loses inherited shear and reflection, and is undefined for collapsed bones. ++ matrix.m30(matrix.m30() / 16F + (arm == HumanoidArm.RIGHT ? this.handOffset : -this.handOffset)) ++ .m31(matrix.m31() / 16F).m32(matrix.m32() / 16F); ++ poses.mulPose(matrix); ++ } ++ + Map boneMatrices() { return this.animated.boneMatrices(); } + + /** Uses global bind/current transforms only while the native hierarchy is the rendered tree. */ @@ -1814,6 +1923,189 @@ index 90c2541..7b3e97c 100644 APPEARANCES.values().forEach(BedrockPlayerSkins::release); APPEARANCES.clear(); PLAYER_BY_TEXTURE.clear(); +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockShieldAtlas.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockShieldAtlas.java +new file mode 100644 +index 0000000..25acb63 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockShieldAtlas.java +@@ -0,0 +1,50 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.render; ++ ++import java.awt.image.BufferedImage; ++import java.util.LinkedHashMap; ++import java.util.Map; ++import java.util.function.Function; ++ ++/** One pack-owned atlas; item appearances change uniforms without allocating more textures. */ ++record BedrockShieldAtlas(BufferedImage image, Map sprites) { ++ static BedrockShieldAtlas build(final Function images) { ++ final var sources = new LinkedHashMap(); ++ int maxWidth = 0, maxHeight = 0; ++ long area = 0; ++ for (final String name : BedrockShieldPatterns.TEXTURES.values().stream().distinct().sorted().toList()) { ++ final var image = images.apply("textures/entity/shield_patterns/" + name); ++ if (image == null) continue; ++ if (image.getWidth() < 1 || image.getHeight() < 1 || image.getWidth() > 4096 || image.getHeight() > 4096 ++ || (area += (long) image.getWidth() * image.getHeight()) > 16_777_216L) return null; ++ sources.put(name, image); ++ maxWidth = Math.max(maxWidth, image.getWidth()); maxHeight = Math.max(maxHeight, image.getHeight()); ++ } ++ if (!sources.containsKey("base")) return null; ++ final int columns = (int) Math.ceil(Math.sqrt(sources.size())), rows = (sources.size() + columns - 1) / columns; ++ final int width = powerOfTwo(columns * (maxWidth + 2)), height = powerOfTwo(rows * (maxHeight + 2)); ++ if (width > 4096 || height > 4096 || (long) width * height > 16_777_216L) return null; ++ final var atlas = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); ++ final Map sprites = new LinkedHashMap<>(); ++ int index = 0; ++ for (final var entry : sources.entrySet()) { ++ final var image = entry.getValue(); ++ final int x = index % columns * (maxWidth + 2) + 1, y = index / columns * (maxHeight + 2) + 1; ++ // Extrude one texel so linear sampling at an authored edge cannot read another pattern. ++ for (int dy = -1; dy <= image.getHeight(); dy++) for (int dx = -1; dx <= image.getWidth(); dx++) ++ atlas.setRGB(x + dx, y + dy, image.getRGB(Math.clamp(dx, 0, image.getWidth() - 1), Math.clamp(dy, 0, image.getHeight() - 1))); ++ sprites.put(entry.getKey(), new Uv((float) x / width, (float) y / height, ++ (float) image.getWidth() / width, (float) image.getHeight() / height)); ++ index++; ++ } ++ return new BedrockShieldAtlas(atlas, Map.copyOf(sprites)); ++ } ++ ++ private static int powerOfTwo(final int value) { return Integer.highestOneBit(value - 1) << 1; } ++ ++ record Uv(float offsetU, float offsetV, float scaleU, float scaleV) {} ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockShieldPatterns.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockShieldPatterns.java +new file mode 100644 +index 0000000..8cc6969 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockShieldPatterns.java +@@ -0,0 +1,121 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.render; ++ ++import com.google.common.collect.MapMaker; ++import com.viaversion.viafabricplus.bedrock.appearance.BedrockItemMetadata; ++import java.util.ArrayList; ++import java.util.List; ++import java.util.Map; ++import net.minecraft.core.component.DataComponents; ++import net.minecraft.nbt.CompoundTag; ++import net.minecraft.world.item.ItemStack; ++import net.minecraft.world.item.component.CustomData; ++import net.raphimc.viabedrock.protocol.rewriter.ItemDataRewriter; ++ ++/** The target's native shield NBT rules, before Java banner aliases change texture names. */ ++final class BedrockShieldPatterns { ++ private static final Map CACHE = new MapMaker().weakKeys().makeMap(); ++ private static final Appearance PLAIN = new Appearance(List.of()); ++ // Bedrock 1.26.60.5 native pattern registry. In particular, ld/lud are different from Java's texture names. ++ static final Map TEXTURES = Map.ofEntries( ++ Map.entry("", "base"), ++ Map.entry("bo", "border"), ++ Map.entry("bri", "bricks"), ++ Map.entry("mc", "circle"), ++ Map.entry("cre", "creeper"), ++ Map.entry("sku", "skull"), ++ Map.entry("cr", "cross"), ++ Map.entry("cbo", "curly_border"), ++ Map.entry("ld", "diagonal_up_left"), ++ Map.entry("rud", "diagonal_up_right"), ++ Map.entry("lud", "diagonal_left"), ++ Map.entry("rd", "diagonal_right"), ++ Map.entry("flo", "flower"), ++ Map.entry("gra", "gradient"), ++ Map.entry("gru", "gradient_up"), ++ Map.entry("hh", "half_horizontal"), ++ Map.entry("hhb", "half_horizontal_bottom"), ++ Map.entry("vh", "half_vertical"), ++ Map.entry("vhr", "half_vertical_right"), ++ Map.entry("ill", "illager"), ++ Map.entry("moj", "mojang"), ++ Map.entry("mr", "rhombus"), ++ Map.entry("ss", "small_stripes"), ++ Map.entry("bl", "square_bottom_left"), ++ Map.entry("br", "square_bottom_right"), ++ Map.entry("tl", "square_top_left"), ++ Map.entry("tr", "square_top_right"), ++ Map.entry("sc", "straight_cross"), ++ Map.entry("bs", "stripe_bottom"), ++ Map.entry("cs", "stripe_center"), ++ Map.entry("dls", "stripe_downleft"), ++ Map.entry("drs", "stripe_downright"), ++ Map.entry("ls", "stripe_left"), ++ Map.entry("ms", "stripe_middle"), ++ Map.entry("rs", "stripe_right"), ++ Map.entry("ts", "stripe_top"), ++ Map.entry("bt", "triangle_bottom"), ++ Map.entry("tt", "triangle_top"), ++ Map.entry("bts", "triangles_bottom"), ++ Map.entry("tts", "triangles_top"), ++ Map.entry("pig", "piglin"), ++ Map.entry("glb", "globe"), ++ Map.entry("flw", "flow"), ++ Map.entry("gus", "guster")); ++ private static final int[] COLORS = {0x1D1D21, 0xB02E26, 0x5E7C16, 0x835432, 0x3C44AA, 0x8932B8, 0x169C9C, 0x9D9D97, 0x474F52, 0xF38BAA, 0x80C71F, 0xFED83D, 0x3AB3DA, 0xC74EBD, 0xF9801D, 0xF0F0F0}; ++ ++ private BedrockShieldPatterns() {} ++ ++ static Appearance get(final ItemStack stack) { ++ final var data = stack.get(DataComponents.CUSTOM_DATA); ++ if (!"minecraft:shield".equals(BedrockItemMetadata.get(data).identifier())) return PLAIN; ++ return CACHE.computeIfAbsent(data, value -> { ++ final var snapshot = value.copyTag().getCompoundOrEmpty(ItemDataRewriter.BEDROCK_STACK_TAG); ++ return snapshot.getIntOr("version", -1) == 3 ? read(snapshot.getCompoundOrEmpty("user_data")) : PLAIN; ++ }); ++ } ++ ++ static Appearance read(final CompoundTag tag) { ++ final var patterns = tag.getListOrEmpty("Patterns"); ++ if (!tag.contains("Base") && patterns.isEmpty()) return PLAIN; ++ final var layers = new ArrayList(); ++ final int base = tag.getIntOr("Base", 0); ++ layers.add(layer("base", base == -1 ? 15 : base)); ++ // Native limits source entries before resolving each pattern, not the number of accepted layers. ++ for (int i = 0; i < Math.min(6, patterns.size()); i++) { ++ if (!(patterns.get(i) instanceof CompoundTag pattern)) continue; ++ layers.add(layer(TEXTURES.getOrDefault(pattern.getStringOr("Pattern", ""), "base"), ++ pattern.getIntOr("Color", 0))); ++ } ++ return new Appearance(layers); ++ } ++ ++ private static Layer layer(final String texture, final int value) { ++ final int color = value & 255; ++ return new Layer(texture, COLORS[color < COLORS.length ? color : 5]); ++ } ++ ++ static void clear() { CACHE.clear(); } ++ ++ record Layer(String texture, int rgb) {} ++ record Appearance(List layers) { ++ Appearance { layers = List.copyOf(layers); } ++ boolean patterned() { return !this.layers.isEmpty(); } ++ ++ BedrockActorPattern draw(final BedrockShieldAtlas atlas) { ++ final var result = new ArrayList(); ++ for (final var layer : this.layers) { ++ final var uv = atlas.sprites().get(layer.texture); ++ if (uv == null) return null; ++ result.add(new BedrockActorPattern.Layer((layer.rgb >>> 16 & 255) / 255F, ++ (layer.rgb >>> 8 & 255) / 255F, (layer.rgb & 255) / 255F, 1F, ++ uv.offsetU(), uv.offsetV(), uv.scaleU(), uv.scaleV())); ++ } ++ return result.isEmpty() ? null : new BedrockActorPattern(result); ++ } ++ } ++} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockTextureMeshes.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockTextureMeshes.java new file mode 100644 index 0000000..af2632d @@ -2256,10 +2548,10 @@ index 0000000..bea20e3 +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockEquipmentResourcesTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockEquipmentResourcesTest.java new file mode 100644 -index 0000000..07900ae +index 0000000..721a77d --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockEquipmentResourcesTest.java -@@ -0,0 +1,88 @@ +@@ -0,0 +1,119 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -2268,6 +2560,9 @@ index 0000000..07900ae +package com.viaversion.viafabricplus.bedrock.appearance; + +import java.nio.charset.StandardCharsets; ++import java.awt.image.BufferedImage; ++import java.io.ByteArrayOutputStream; ++import javax.imageio.ImageIO; +import java.util.List; +import java.util.Map; +import net.raphimc.viabedrock.api.resourcepack.NativeActorLibrary; @@ -2324,6 +2619,34 @@ index 0000000..07900ae + assertEquals(128, overridden.getModels().entityModels().get("geometry.armor.chest").getTextureSize().getX()); + } + ++ @Test ++ void retainsShieldAtlasImagesAndAppliesServerReplacementsInPackOrder() throws Exception { ++ final byte[] shield = """ ++ {"format_version":"1.10.0","minecraft:attachable":{"description":{ ++ "identifier":"minecraft:shield","textures":{"pattern":"atlas.shield"}}}} ++ """.getBytes(StandardCharsets.UTF_8); ++ final var builtin = BedrockResourceLibrary.read(Map.of( ++ "library/vanilla/attachables/shield.json", shield, ++ "library/vanilla/textures/entity/shield_patterns/base.png", pixel(0xFF123456), ++ "library/vanilla/textures/entity/shield_patterns/stripe_bottom.png", pixel(0xFF445566), ++ "library/vanilla/textures/entity/unreferenced.png", pixel(0xFF778899))); ++ final var resolved = BedrockEquipmentResources.merge(builtin, List.of(new NativeActorLibrary.Layer(true, Map.of( ++ "manifest.json", manifest(), "textures/entity/shield_patterns/stripe_bottom.png", pixel(0xFFABCDEF))))); ++ final var packs = resolved.getPackStackTopToBottom(); ++ final var stripe = packs.stream().map(pack -> pack.content().getShortnameImage("textures/entity/shield_patterns/stripe_bottom")) ++ .filter(image -> image != null).findFirst().orElseThrow().getImage(); ++ assertEquals(0xFFABCDEF, stripe.getRGB(0, 0)); ++ final var base = packs.stream().map(pack -> pack.content().getShortnameImage("textures/entity/shield_patterns/base")) ++ .filter(image -> image != null).findFirst().orElseThrow().getImage(); ++ assertEquals(0xFF123456, base.getRGB(0, 0)); ++ assertTrue(packs.stream().allMatch(pack -> pack.content().getShortnameImage("textures/entity/unreferenced") == null)); ++ } ++ ++ private static byte[] pixel(final int argb) throws Exception { ++ final var image = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB); image.setRGB(0, 0, argb); ++ final var output = new ByteArrayOutputStream(); ImageIO.write(image, "png", output); return output.toByteArray(); ++ } ++ + private static byte[] manifest() { + return ("{\"format_version\":3,\"header\":{\"uuid\":\"" + java.util.UUID.randomUUID() + + "\",\"version\":\"1.0.0\",\"name\":\"server\"}}").getBytes(StandardCharsets.UTF_8); @@ -3408,10 +3731,100 @@ index 0000000..f7b23fc + } +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerModelTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerModelTest.java -index 166bca7..484967f 100644 +index 166bca7..0807022 100644 --- a/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerModelTest.java +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerModelTest.java -@@ -125,6 +125,65 @@ final class BedrockPlayerModelTest { +@@ -14,8 +14,89 @@ import org.junit.jupiter.api.Test; + + import java.util.Map; + import java.util.List; ++import com.mojang.blaze3d.vertex.PoseStack; ++import net.minecraft.world.entity.HumanoidArm; ++import net.minecraft.client.model.ArmedModel; ++import org.joml.Matrix4f; ++import org.joml.Vector3f; + + final class BedrockPlayerModelTest { ++ @Test ++ void heldItemPointsRetainMirroredShearedAndCollapsedParentTransforms() throws Exception { ++ final var geometry = BedrockGeometryModel.fromJson(""" ++ {"minecraft:geometry":[{"description":{"identifier":"geometry.hand_parent","texture_width":64,"texture_height":64}, ++ "bones":[{"name":"root","pivot":[0,0,0]}, ++ {"name":"rightarm","parent":"root","pivot":[-6,22,0]}, ++ {"name":"leftarm","parent":"root","pivot":[6,22,0]}]}]} ++ """).getFirst(); ++ for (final boolean slim : List.of(false, true)) { ++ final var model = BedrockGeometry.playerModel(geometry, slim); ++ for (final float scale : new float[]{-2F, 0F}) { ++ final var state = new State(); ++ state.pose = BedrockBoneAnimation.parse(JsonParser.parseString(""" ++ {"bones":{"root":{"rotation":[0,0,30],"scale":[%s,%s,%s]}, ++ "rightarm":{"rotation":[0,45,0]},"leftarm":{"rotation":[0,-45,0]}}} ++ """.formatted(scale, scale == 0 ? 0 : 1, scale == 0 ? 0 : .5F)) ++ .getAsJsonObject()).playback().sample(0, Map.of()); ++ model.setupAnim(state); ++ for (final var hand : HumanoidArm.values()) { ++ final boolean right = hand == HumanoidArm.RIGHT; ++ final var expected = new Matrix4f().translation(0, 1.5F, 0).rotateZ((float) Math.toRadians(30)) ++ .scale(scale, scale == 0 ? 0 : 1, scale == 0 ? 0 : .5F) ++ .translate((right ? -6F : 6F) / 16F, -22F / 16F, 0) ++ .rotateY((float) Math.toRadians(right ? 45 : -45)); ++ // Preserve Java's root-space half-pixel anchor for slim fallback items. ++ if (slim) expected.m30(expected.m30() + (right ? .5F : -.5F) / 16F); ++ final var incoming = new Matrix4f().translation(2, -.5F, 3).rotateY(.4F); ++ expected.set(new Matrix4f(incoming).mul(expected)); ++ final var actual = new PoseStack(); ++ actual.mulPose(incoming); ++ final ArmedModel heldItemOwner = model; ++ heldItemOwner.translateToHand(state, hand, actual); ++ for (final var point : List.of(new Vector3f(), new Vector3f(.125F, .25F, -.5F))) { ++ final var wanted = expected.transformPosition(new Vector3f(point)); ++ final var submitted = actual.last().pose().transformPosition(new Vector3f(point)); ++ assertTrue(submitted.isFinite(), "Collapsed bones must not produce invalid item positions"); ++ assertEquals(wanted.x, submitted.x, .000005F, hand + " x, slim=" + slim + ", scale=" + scale); ++ assertEquals(wanted.y, submitted.y, .000005F, hand + " y, slim=" + slim + ", scale=" + scale); ++ assertEquals(wanted.z, submitted.z, .000005F, hand + " z, slim=" + slim + ", scale=" + scale); ++ } ++ } ++ } ++ } ++ } ++ ++ @Test ++ void restoresJavaHandPlacementWhenTheNativeTreeRetiresOrTheArmIsMissing() throws Exception { ++ for (final boolean slim : List.of(false, true)) for (final boolean hasArms : List.of(false, true)) { ++ final var geometry = BedrockGeometryModel.fromJson(""" ++ {"minecraft:geometry":[{"description":{"identifier":"geometry.missing_arm","texture_width":64,"texture_height":64}, ++ "bones":[{"name":"root","pivot":[0,0,0]},{"name":"head","parent":"root","pivot":[0,24,0]}%s]}]} ++ """.formatted(hasArms ? """ ++ ,{"name":"rightarm","parent":"root","pivot":[-6,22,0]}, ++ {"name":"leftarm","parent":"root","pivot":[6,22,0]} ++ """ : "")).getFirst(); ++ final var model = BedrockGeometry.playerModel(geometry, slim); ++ final var state = new State(); ++ model.setupAnim(state); ++ final var ordinary = new PoseStack(); ++ model.translateToHand(state, HumanoidArm.RIGHT, ordinary); ++ state.pose = BedrockBoneAnimation.parse(JsonParser.parseString(""" ++ {"bones":{"root":{"rotation":[0,30,0]}}} ++ """).getAsJsonObject()).playback().sample(0, Map.of()); ++ model.setupAnim(state); ++ final var missing = new PoseStack(); ++ model.translateToHand(state, HumanoidArm.RIGHT, missing); ++ if (!hasArms) assertEquals(ordinary.last().pose(), missing.last().pose()); ++ else assertNotEquals(ordinary.last().pose(), missing.last().pose()); ++ state.pose = Map.of(); ++ model.setupAnim(state); ++ final var restored = new PoseStack(); ++ model.translateToHand(state, HumanoidArm.RIGHT, restored); ++ assertEquals(ordinary.last().pose(), restored.last().pose()); ++ } ++ } ++ + @Test + void appliesControllerVisibilityWithoutBoneTransformsAndRestoresTheJavaTree() { + final var geometry = BedrockGeometryModel.fromJson(""" +@@ -125,6 +206,65 @@ final class BedrockPlayerModelTest { } } @@ -3477,6 +3890,103 @@ index 166bca7..484967f 100644 private static final class State extends AvatarRenderState implements BedrockPlayerAnimationState { private Map pose = Map.of(); private BedrockActorAnimation.Visibility visibility = +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockShieldPatternsTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockShieldPatternsTest.java +new file mode 100644 +index 0000000..cc66ca1 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockShieldPatternsTest.java +@@ -0,0 +1,91 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.render; ++ ++import java.awt.image.BufferedImage; ++import java.util.List; ++import net.minecraft.SharedConstants; ++import net.minecraft.core.component.DataComponentMap; ++import net.minecraft.core.component.DataComponents; ++import net.minecraft.nbt.CompoundTag; ++import net.minecraft.nbt.ListTag; ++import net.minecraft.server.Bootstrap; ++import net.minecraft.world.item.ItemStack; ++import net.minecraft.world.item.Items; ++import net.minecraft.world.item.component.CustomData; ++import net.raphimc.viabedrock.protocol.rewriter.ItemDataRewriter; ++import org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++final class BedrockShieldPatternsTest { ++ @Test ++ void keepsNativeColorsTextureAliasesAndFirstSixSourcePatternsInDrawOrder() { ++ final var tag = new CompoundTag(); tag.putInt("Base", 1); ++ final var patterns = new ListTag(); ++ for (final String code : List.of("bs", "ld", "lud", "rd", "rud", "unknown", "flw")) { ++ final var layer = new CompoundTag(); layer.putString("Pattern", code); layer.putInt("Color", 15); patterns.add(layer); ++ } ++ tag.put("Patterns", patterns); ++ final var original = tag.copy(); ++ final var appearance = BedrockShieldPatterns.read(tag); ++ assertEquals(7, appearance.layers().size()); ++ assertEquals(List.of("base", "stripe_bottom", "diagonal_up_left", "diagonal_left", ++ "diagonal_right", "diagonal_up_right", "base"), appearance.layers().stream().map(BedrockShieldPatterns.Layer::texture).toList()); ++ final var atlas = BedrockShieldAtlas.build(_ -> new BufferedImage(2, 3, BufferedImage.TYPE_INT_ARGB)); ++ final var draw = appearance.draw(atlas); ++ assertEquals(7, draw.layers().size()); ++ assertEquals(176 / 255F, draw.layers().getFirst().red()); ++ assertEquals(46 / 255F, draw.layers().getFirst().green()); ++ assertEquals(38 / 255F, draw.layers().getFirst().blue()); ++ assertEquals(240 / 255F, draw.layers().get(1).red()); ++ final var stripe = atlas.sprites().get("stripe_bottom"); ++ assertEquals(stripe.offsetU(), draw.layers().get(1).offsetU()); ++ assertEquals(stripe.scaleV(), draw.layers().get(1).scaleV()); ++ assertEquals(original, tag); ++ } ++ ++ @Test ++ void clearsAppearanceWhenTheSameShieldReceivesAPlainSnapshot() { ++ SharedConstants.tryDetectVersion(); Bootstrap.bootStrap(); ++ if (!Items.SHIELD.builtInRegistryHolder().areComponentsBound()) ++ Items.SHIELD.builtInRegistryHolder().bindComponents(DataComponentMap.EMPTY); ++ final var stack = new ItemStack(Items.SHIELD); ++ final var source = new CompoundTag(); source.putInt("Base", 1); ++ final var snapshot = new CompoundTag(); snapshot.putInt("version", 3); snapshot.put("user_data", source); ++ final var data = new CompoundTag(); data.putString(ItemDataRewriter.BEDROCK_IDENTIFIER_TAG, "minecraft:shield"); ++ data.put(ItemDataRewriter.BEDROCK_STACK_TAG, snapshot); ++ stack.set(DataComponents.CUSTOM_DATA, CustomData.of(data)); ++ assertTrue(BedrockShieldPatterns.get(stack).patterned()); ++ source.remove("Base"); ++ stack.set(DataComponents.CUSTOM_DATA, CustomData.of(data)); ++ assertFalse(BedrockShieldPatterns.get(stack).patterned()); ++ source.putInt("Base", -1); ++ stack.set(DataComponents.CUSTOM_DATA, CustomData.of(data)); ++ assertEquals(0xF0F0F0, BedrockShieldPatterns.get(stack).layers().getFirst().rgb()); ++ data.putString(ItemDataRewriter.BEDROCK_IDENTIFIER_TAG, "minecraft:stone"); ++ stack.set(DataComponents.CUSTOM_DATA, CustomData.of(data)); ++ assertFalse(BedrockShieldPatterns.get(stack).patterned()); ++ BedrockShieldPatterns.clear(); ++ } ++ ++ @Test ++ void preservesSpriteDimensionsAndExtrudesEdgesWithoutAllocatingAnAtlasForMissingBase() { ++ final var base = new BufferedImage(2, 1, BufferedImage.TYPE_INT_ARGB); ++ base.setRGB(0, 0, 0xFF123456); base.setRGB(1, 0, 0xFFABCDEF); ++ final var stripe = new BufferedImage(3, 2, BufferedImage.TYPE_INT_ARGB); ++ stripe.setRGB(2, 1, 0x80335577); ++ final var atlas = BedrockShieldAtlas.build(path -> path.endsWith("/base") ? base : path.endsWith("/stripe_bottom") ? stripe : null); ++ assertNotNull(atlas); ++ final var uv = atlas.sprites().get("base"); ++ final int x = Math.round(uv.offsetU() * atlas.image().getWidth()), y = Math.round(uv.offsetV() * atlas.image().getHeight()); ++ assertEquals(base.getWidth(), Math.round(uv.scaleU() * atlas.image().getWidth())); ++ assertEquals(base.getHeight(), Math.round(uv.scaleV() * atlas.image().getHeight())); ++ assertEquals(0xFF123456, atlas.image().getRGB(x - 1, y - 1)); ++ assertEquals(0xFFABCDEF, atlas.image().getRGB(x + 2, y + 1)); ++ assertNull(BedrockShieldAtlas.build(path -> path.endsWith("/stripe_bottom") ? stripe : null)); ++ } ++} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockTextureMeshesTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockTextureMeshesTest.java new file mode 100644 index 0000000..fe8a623 diff --git a/patches/viafabricplus-bedrock/upstreamable/0017-render-equipped-native-pack-models.pr.md b/patches/viafabricplus-bedrock/upstreamable/0017-render-equipped-native-pack-models.pr.md index 11e2ac99..18b8a2bd 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0017-render-equipped-native-pack-models.pr.md +++ b/patches/viafabricplus-bedrock/upstreamable/0017-render-equipped-native-pack-models.pr.md @@ -20,6 +20,25 @@ Glint changes over time on idle and blocking shields. Returning to a plain shiel Separate plain-shield controls vary world FOV through 60, 30, 110, and 60, with repeated idle and blocking samples. Empty-offhand references isolate the visible shield through the bottom of the screen. Across three image-difference thresholds, normalized bounds from median images differ from native by less than 1% of either viewport axis. These controls do not reproduce the reported oversized shield. -The native and Java appearances were not matched. Outer bounds do not establish pixel parity. Exact lighting, glint phase, and the reported oversized shield and distorted custom-skin pose remain unverified. No renderer change follows from these controls. +A declared protocol-2225 inventory fixture delivers a red shield with a white bottom stripe. +Official Bedrock 1.26.60.5 renders the patterned front while blocking and restores the wooden appearance when the tags disappear. +Core now projects the base color and ordered patterns. The add-on uses a pack-owned atlas with the authored geometry and animation. + +Installed direct and ViaProxy controls preserve the plain, patterned, and plain sequence with the same tested add-on and core classes. +Both routes also prepare the native pattern inputs with Iris and Complementary Reimagined 5.9.3 active. +Read-only observations establish atlas upload, draw preparation, and the native red and white tints. +A separate mainhand control retains the pattern after a natural BDS Unbreaking I enchantment with Iris active. +Shader-pack lighting is separate from native lighting parity. + +`BedrockShieldPatternsTest` covers native texture aliases, colors, source limits, snapshot replacement, and atlas edges. +`BedrockEquipmentResourcesTest` keeps atlas dependencies and server image overrides in pack order. +The shared material patch compares actual fragment pixels with 512 current native shader controls, including glint before fog. + +These controls use different appearances and viewports. Exact pixels, lighting, glint phase, and poses remain unverified. +The reported oversized shield and distorted custom-skin pose remain unresolved. + +Java fallback held items retain the native arm's complete transform instead of its flattened humanoid fields. +`BedrockPlayerModelTest` covers reflected and sheared parents, collapsed positions, slim/wide anchors, the incoming render transform, and ordinary fallback after retirement. +The regression fails on the old adapter. These controls establish attachment consistency, not native pixels or singular surface normals. The [owning patch](0017-render-equipped-native-pack-models.patch) retains implementation rationale and versioned evidence. diff --git a/patches/viafabricplus-bedrock/upstreamable/0022-render-native-locator-groups.patch b/patches/viafabricplus-bedrock/upstreamable/0022-render-native-locator-groups.patch index 2246eedd..ed3c8ab2 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0022-render-native-locator-groups.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0022-render-native-locator-groups.patch @@ -1,4 +1,4 @@ -From a5c679e122ba3f2308ec41c9ff9988a2751efb0a Mon Sep 17 00:00:00 2001 +From eada3477864737df04d9debba1f2ab5851bdbbb3 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(hud): render native locator groups @@ -20,20 +20,34 @@ private; production resolves textures through accepted resource packs. This implements the default native locator renderer. Full resource-pack HUD definition overrides, legacy player-location presentation, and the remaining native comparison matrix are still incomplete. + +Use native float normalization and the camera forward vector for horizontal +bearing. Keep the native atan2(z,x) zero-vector result and signed wrapping. +Deriving bearing from yaw and unnormalized doubles changed visibility for +vertical and near-coincident targets and at a rounded 60-degree boundary. + +Bedrock 1.26.60.5 draw 0x145745690, instructions 0x145746a56..0x145746b64, +establish normalization, the 0.0001 cutoff and the visibility branch. +Fifty-five original-instruction controls with supplied cardinal camera +vectors and named atan2f/fmodf import boundaries expose nine old visibility +differences and agree after the change. Three behavior regressions fail +before and pass after. These controls do not establish complete camera, +texture, vertical-arrow or installed HUD parity. The reported missing +locator bar on its original server remains unmatched. --- .../bedrock/ViaFabricPlusBedrock.java | 6 + - .../bedrock/hud/BedrockLocatorBar.java | 121 ++++++++++++++++++ + .../bedrock/hud/BedrockLocatorBar.java | 122 ++++++++++++++++++ .../bedrock/hud/LocatorBarImages.java | 96 ++++++++++++++ .../bedrock/hud/LocatorBarMotion.java | 44 +++++++ .../bedrock/hud/LocatorBarPayload.java | 24 ++++ - .../bedrock/hud/LocatorBarProjection.java | 35 +++++ + .../bedrock/hud/LocatorBarProjection.java | 52 ++++++++ .../bedrock/hud/LocatorBarSession.java | 33 +++++ .../render/MixinBedrockHudVisibility.java | 17 +++ .../render/MixinMinecraftHudVisibility.java | 2 + .../bedrock/hud/LocatorBarMotionTest.java | 42 ++++++ - .../bedrock/hud/LocatorBarProjectionTest.java | 50 ++++++++ + .../bedrock/hud/LocatorBarProjectionTest.java | 69 ++++++++++ .../bedrock/hud/LocatorBarSessionTest.java | 24 ++++ - 12 files changed, 494 insertions(+) + 12 files changed, 531 insertions(+) create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/hud/BedrockLocatorBar.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/hud/LocatorBarImages.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/hud/LocatorBarMotion.java @@ -70,10 +84,10 @@ index 734c4bb..0b69037 100644 this.appearances = new BedrockAppearanceStore(ViaFabricPlus.api().path().resolve("bedrock-appearances")); diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/hud/BedrockLocatorBar.java b/src/main/java/com/viaversion/viafabricplus/bedrock/hud/BedrockLocatorBar.java new file mode 100644 -index 0000000..4925f87 +index 0000000..c03d8c5 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/hud/BedrockLocatorBar.java -@@ -0,0 +1,121 @@ +@@ -0,0 +1,122 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -154,8 +168,9 @@ index 0000000..4925f87 + final float width = LocatorBarProjection.iconExtent(waypoint.size().width()); + final float height = LocatorBarProjection.iconExtent(waypoint.size().height()); + if (!Float.isFinite(width) || !Float.isFinite(height)) continue; ++ final var forward = camera.forwardVector(); + final var projection = LocatorBarProjection.project(target.x - observer.x, target.y - observer.y, -+ target.z - observer.z, camera.yRot(), camera.xRot(), WIDTH, HEIGHT, width, camera.getFov()); ++ target.z - observer.z, forward.x(), forward.z(), camera.xRot(), WIDTH, HEIGHT, width, camera.getFov()); + if (projection == null) continue; + final float x = graphics.guiWidth() / 2F + projection.offset(); + final float y = graphics.guiHeight() - 27.5F; @@ -379,10 +394,10 @@ index 0000000..c2dc2b5 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/hud/LocatorBarProjection.java b/src/main/java/com/viaversion/viafabricplus/bedrock/hud/LocatorBarProjection.java new file mode 100644 -index 0000000..f676404 +index 0000000..9fb29fe --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/hud/LocatorBarProjection.java -@@ -0,0 +1,35 @@ +@@ -0,0 +1,52 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -395,25 +410,42 @@ index 0000000..f676404 + private LocatorBarProjection() {} + + static float iconExtent(final float size) { -+ // Native 1.26.51.1 sizes the quad independently of the source texture. ++ // Native 1.26.60.5 sizes the quad independently of the source texture. + return size * 5 + 2; + } + + static Placement project(final double dx, final double dy, final double dz, -+ final float yaw, final float pitch, final float width, ++ final float cameraX, final float cameraZ, final float pitch, final float width, + final float barHeight, final float iconWidth, final float verticalFov) { + if (!Double.isFinite(dx) || !Double.isFinite(dy) || !Double.isFinite(dz) -+ || !Float.isFinite(yaw) || !Float.isFinite(pitch) || !Float.isFinite(width) ++ || !Float.isFinite(cameraX) || !Float.isFinite(cameraZ) || !Float.isFinite(pitch) || !Float.isFinite(width) + || !Float.isFinite(barHeight) || !Float.isFinite(iconWidth) + || !Float.isFinite(verticalFov) || verticalFov <= 0 || verticalFov >= 180) return null; + final float limit = (float) Math.toRadians(60); -+ final float bearing = (float) Math.atan2(-dx, dz) - (float) Math.toRadians(yaw); -+ final float angle = (float) Math.IEEEremainder(bearing, Math.PI * 2); ++ // The native draw normalizes the complete direction before taking atan2(z, x). ++ // A vertical or near-coincident target therefore retains the native zero-vector bearing. ++ float x = (float) dx; ++ final float y = (float) dy; ++ float z = (float) dz; ++ final float length = (float) Math.sqrt(y * y + x * x + z * z); ++ if (length < 0.0001F) { ++ x = 0; ++ z = 0; ++ } else { ++ x /= length; ++ z /= length; ++ } ++ final float bearing = (float) Math.atan2(z, x) - (float) Math.atan2(cameraZ, cameraX); ++ final float pi = (float) Math.PI; ++ final float turn = pi * 2; ++ float angle = bearing % turn; ++ if (angle <= -pi) angle += turn; ++ else if (angle > pi) angle -= turn; + if (Math.abs(angle) >= limit) return null; + final double elevation = Math.atan2(dy, Math.hypot(dx, dz)) + Math.toRadians(pitch); + final double threshold = Math.toRadians(verticalFov) * 0.5 * 0.85; + final int arrow = elevation >= threshold ? 1 : elevation <= -threshold ? -1 : 0; -+ return new Placement(angle / limit * (width - barHeight - iconWidth - 2) / 2, arrow); ++ return new Placement(angle / limit * 0.5F * (width - (barHeight + iconWidth + 2)), arrow); + } + + record Placement(float offset, int arrow) {} @@ -580,10 +612,10 @@ index 0000000..48152ab +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/hud/LocatorBarProjectionTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/hud/LocatorBarProjectionTest.java new file mode 100644 -index 0000000..d0425f9 +index 0000000..5ba6c36 --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/hud/LocatorBarProjectionTest.java -@@ -0,0 +1,50 @@ +@@ -0,0 +1,69 @@ +package com.viaversion.viafabricplus.bedrock.hud; + +import org.junit.jupiter.api.Test; @@ -606,33 +638,52 @@ index 0000000..d0425f9 + void headingWrapAndRangeFollowTheNativeBar() { + final double sine = Math.sin(Math.toRadians(30)) * 40; + final double cosine = Math.cos(Math.toRadians(30)) * 40; -+ assertEquals(42F, LocatorBarProjection.project(-sine, 0, cosine, 0, 0, 182, 5, 7, 70).offset(), 0.0001F); -+ assertEquals(-42F, LocatorBarProjection.project(sine, 0, cosine, 360, 0, 182, 5, 7, 70).offset(), 0.0001F); -+ assertEquals(0F, LocatorBarProjection.project(0, 0, -40, 180, 0, 182, 5, 7, 70).offset(), 0.0001F); -+ assertNull(LocatorBarProjection.project(-40, 0, 0, 0, 0, 182, 5, 7, 70)); -+ assertNull(LocatorBarProjection.project(0, 0, -40, 0, 0, 182, 5, 7, 70)); -+ assertNull(LocatorBarProjection.project(Double.NaN, 0, 40, 0, 0, 182, 5, 7, 70)); ++ assertEquals(42F, LocatorBarProjection.project(-sine, 0, cosine, 0, 1, 0, 182, 5, 7, 70).offset(), 0.0001F); ++ assertEquals(-42F, LocatorBarProjection.project(sine, 0, cosine, 0, 1, 0, 182, 5, 7, 70).offset(), 0.0001F); ++ assertEquals(0F, LocatorBarProjection.project(0, 0, -40, 0, -1, 0, 182, 5, 7, 70).offset(), 0.0001F); ++ assertNull(LocatorBarProjection.project(-40, 0, 0, 0, 1, 0, 182, 5, 7, 70)); ++ assertNull(LocatorBarProjection.project(0, 0, -40, 0, 1, 0, 182, 5, 7, 70)); ++ assertNull(LocatorBarProjection.project(Double.NaN, 0, 40, 0, 1, 0, 182, 5, 7, 70)); + } + + @Test + void iconWidthAndCameraFovChangeTheNativeProjection() { + final double sine = Math.sin(Math.toRadians(30)) * 40; + final double cosine = Math.cos(Math.toRadians(30)) * 40; -+ assertEquals(40.75F, LocatorBarProjection.project(-sine, 0, cosine, 0, 0, 182, 5, 12, 70).offset(), 0.0001F); -+ assertEquals(1, LocatorBarProjection.project(0, 22, 40, 0, 0, 182, 5, 7, 60).arrow()); -+ assertEquals(0, LocatorBarProjection.project(0, 22, 40, 0, 0, 182, 5, 7, 90).arrow()); -+ assertNull(LocatorBarProjection.project(0, 0, 40, 60, 0, 182, 5, 7, 70)); ++ assertEquals(40.75F, LocatorBarProjection.project(-sine, 0, cosine, 0, 1, 0, 182, 5, 12, 70).offset(), 0.0001F); ++ assertEquals(1, LocatorBarProjection.project(0, 22, 40, 0, 1, 0, 182, 5, 7, 60).arrow()); ++ assertEquals(0, LocatorBarProjection.project(0, 22, 40, 0, 1, 0, 182, 5, 7, 90).arrow()); ++ assertNull(LocatorBarProjection.project(0, 0, 40, -0.9063078F, 0.42261827F, 0, 182, 5, 7, 70)); + } + + @Test + void heightDirectionAccountsForCameraPitchAndHorizontalDistance() { -+ assertEquals(0, LocatorBarProjection.project(0, 10, 40, 0, 0, 182, 5, 7, 70).arrow()); -+ assertEquals(1, LocatorBarProjection.project(0, 40, 40, 0, 0, 182, 5, 7, 70).arrow()); -+ assertEquals(-1, LocatorBarProjection.project(0, -40, 40, 0, 0, 182, 5, 7, 70).arrow()); -+ assertEquals(0, LocatorBarProjection.project(0, 40, 40, 0, -45, 182, 5, 7, 70).arrow()); -+ assertEquals(1, LocatorBarProjection.project(0, 0, 40, 0, 45, 182, 5, 7, 70).arrow()); -+ assertEquals(0, LocatorBarProjection.project(0, 10, 80, 0, 0, 182, 5, 7, 70).arrow()); ++ assertEquals(0, LocatorBarProjection.project(0, 10, 40, 0, 1, 0, 182, 5, 7, 70).arrow()); ++ assertEquals(1, LocatorBarProjection.project(0, 40, 40, 0, 1, 0, 182, 5, 7, 70).arrow()); ++ assertEquals(-1, LocatorBarProjection.project(0, -40, 40, 0, 1, 0, 182, 5, 7, 70).arrow()); ++ assertEquals(0, LocatorBarProjection.project(0, 40, 40, 0, 1, -45, 182, 5, 7, 70).arrow()); ++ assertEquals(1, LocatorBarProjection.project(0, 0, 40, 0, 1, 45, 182, 5, 7, 70).arrow()); ++ assertEquals(0, LocatorBarProjection.project(0, 10, 80, 0, 1, 0, 182, 5, 7, 70).arrow()); + } ++ @Test ++ void verticallyAlignedMarkersUseTheNativeFallbackBearing() { ++ assertNull(LocatorBarProjection.project(0, 40, 0, 0, 1, 0, 182, 5, 7, 70)); ++ assertEquals(0F, LocatorBarProjection.project(0, 40, 0, 1, 0, 0, 182, 5, 7, 70).offset()); ++ assertNull(LocatorBarProjection.project(0, 0, 0, 0, 1, 0, 182, 5, 7, 70)); ++ assertEquals(0F, LocatorBarProjection.project(0, 0, 0, 1, 0, 0, 182, 5, 7, 70).offset()); ++ } ++ ++ @Test ++ void nearCoincidentMarkersApplyTheNativeDirectionCutoff() { ++ assertNull(LocatorBarProjection.project(0, 0, .00001, 0, 1, 0, 182, 5, 7, 70)); ++ assertEquals(0F, LocatorBarProjection.project(0, 0, .0001, 0, 1, 0, 182, 5, 7, 70).offset()); ++ } ++ ++ @Test ++ void roundedBearingRetainsTheNativeBoundaryMarker() { ++ assertNotNull(LocatorBarProjection.project(-34.641016F, 0, 20F, 0, 1, 0, 182, 5, 7, 70)); ++ } ++ +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/hud/LocatorBarSessionTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/hud/LocatorBarSessionTest.java new file mode 100644 diff --git a/patches/viafabricplus-bedrock/upstreamable/0022-render-native-locator-groups.pr.md b/patches/viafabricplus-bedrock/upstreamable/0022-render-native-locator-groups.pr.md index 6b6f5198..cfb495ae 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0022-render-native-locator-groups.pr.md +++ b/patches/viafabricplus-bedrock/upstreamable/0022-render-native-locator-groups.pr.md @@ -5,9 +5,40 @@ Native 1.26.51.1 (build 51061372, protocol 2193) captures and draw routine `0x14 The icon extent is `size * 5 + 2`, including zero and negative sizes. Height arrows use the camera FOV and fixed geometry. Nearby groups draw over distant groups. Tests cover bearing, size, FOV, smoothing, and stale-session rejection. -The full add-on build passes with 798 tests, including 120 skipped tests. An isolated direct join verifies visible output, accepted textures, and positive, zero, and negative icon sizes. -Full HUD definition overrides, legacy player-location presentation, and the remaining native comparison matrix are incomplete. + +## Current bearing controls + +Bedrock 1.26.60.5 normalizes the complete marker direction before it computes horizontal bearing. +The add-on now uses this float calculation and the camera forward vector. +This preserves native visibility for vertical and near-coincident markers and at a rounded 60-degree boundary. + +Three regression tests fail on the previous implementation and pass after the change. +Fifty-five controls execute the original draw instructions with supplied camera vectors and named math-library boundaries. +Installed direct and ViaProxy controls complete eight scripted waypoint phases each. +Packet updates, actual draw submissions, and sixteen foreground measurements agree on marker presence, hiding, boundary placement, and removal. +All 1,079 recorded client projections match the original native bearing controls for their supplied inputs. + +These controls establish horizontal visibility for these inputs. They do not establish complete native camera, image, or HUD parity. + +## Current player updates + +Official Bedrock 1.26.60.5 and BDS 1.26.60.6 provide protocol-2225 controls with a real remote player. +The server sends both `PLAYER_LOCATION` and native locator-group updates. +The group switches from its supplied position to the actor's client position after spawn. +The native observer stays connected while direct and ViaProxy peers join in turn. + +Both Java routes display the remote-player marker when facing the observer. +Disabling `playerwaypoints` removes the marker on native and Java clients. Enabling it restores the marker. +Pixel measurements distinguish marker presence, removal, and restoration. They do not establish complete layout or image parity. +The timed Java recordings finish normally, and installed client and proxy hashes match the built artifacts. + +The native client also displays a scripted location waypoint beside the player marker. +Removing that waypoint preserves the player marker. +After a dimension change, BDS explicitly hides a scripted waypoint from the other dimension. + +These controls do not reproduce the reported missing locator bar. +Legacy-only player updates, full HUD definition overrides, custom textures, and the original reported server remain unverified. Requires the [HUD visibility hooks](0021-apply-server-element-visibility-restrictions.pr.md). diff --git a/patches/viafabricplus-bedrock/upstreamable/0023-render-core-resolved-camera-movement.patch b/patches/viafabricplus-bedrock/upstreamable/0023-render-core-resolved-camera-movement.patch index 6eb767b5..3dd8539f 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0023-render-core-resolved-camera-movement.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0023-render-core-resolved-camera-movement.patch @@ -1,4 +1,4 @@ -From 4aa8acab6512334b2387eb3eef8f2f8136f2430c Mon Sep 17 00:00:00 2001 +From 626aa644277b87a80fe8c03671aa4a089eb88c50 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(camera): render core-resolved server camera movement @@ -157,45 +157,73 @@ cases cover modifier math and six DolphinBoostSystem kernel cases confirm that swim boost uses marker presence, including zero duration. Consume the existing retained movement-effect timeline rather than a second timer. Keep Java's fractional FOV accessibility setting between the native boolean -endpoints. Fluid/death composition, portrait scaling and complete camera +endpoints. Other fluid effects, portrait scaling and complete camera scheduling still need native world comparisons. + +Apply the default Bedrock 1.26.60.5 death camera while the local view owns +camera activation. Its native profile uses an eight-block orbit with a +fixed 60-degree polar angle. Use the existing orbit and avoidance kernels, +render the detached player view, and omit Java's additional death roll. +Explicit server camera poses retain ownership, and clear resolves blends +through the current local death pose. + +Original world-FOV function 0x145309b40 replaces the slider-derived death +projection with a sine-table ease from 60 to 84 degrees over 120 ticks. +The initialized native lookup and original instructions establish its +float order. Disabled gameplay FOV retains the ordinary modifier history. +Keep Java's fractional accessibility setting between those endpoints. + +Advance shared camera timelines only from the main view. Auxiliary camera +instances must not consume transitions or advance the death counter. + +Native DeathTickingComponent updates a signed short. Seed the counter at +activation and continue on camera ticks after Java stops ticking its dead +local actor. Respawn, actor replacement and disconnect retire that state. +Two native death recordings at sliders 60 and 30 show the same settled +view. The profile-derived pose predicts both marker rectangles within one +pixel in nine native stills without fitting camera coordinates. Broader +activation timing, collision and non-default death contexts remain open. --- - .../bedrock/ViaFabricPlusBedrock.java | 44 +++++ - .../camera/BedrockCameraAimAssist.java | 70 +++++++ + .../bedrock/ViaFabricPlusBedrock.java | 44 ++++ + .../camera/BedrockCameraAimAssist.java | 70 ++++++ .../bedrock/camera/BedrockCameraFade.java | 40 ++++ - .../bedrock/camera/BedrockCameraFov.java | 68 +++++++ - .../bedrock/camera/BedrockCameraPosition.java | 183 ++++++++++++++++++ - .../bedrock/camera/BedrockCameraShake.java | 49 +++++ - .../bedrock/camera/BedrockCameraWorld.java | 95 +++++++++ - .../bedrock/camera/BedrockPlayerFov.java | 34 ++++ - .../camera/CameraAimAssistControlPayload.java | 22 +++ - .../camera/CameraAimAssistPayload.java | 22 +++ - .../bedrock/camera/CameraFadePayload.java | 22 +++ - .../bedrock/camera/CameraFollowPayload.java | 22 +++ - .../bedrock/camera/CameraFovPayload.java | 22 +++ - .../bedrock/camera/CameraPositionPayload.java | 22 +++ - .../bedrock/camera/CameraShakePayload.java | 26 +++ - .../bedrock/camera/CameraSplinePayload.java | 22 +++ - .../bedrock/camera/CameraTrackingPayload.java | 22 +++ - .../render/MixinBedrockCameraFade.java | 23 +++ - .../render/MixinBedrockCameraFov.java | 64 ++++++ + .../bedrock/camera/BedrockCameraFov.java | 68 ++++++ + .../bedrock/camera/BedrockCameraPosition.java | 224 ++++++++++++++++++ + .../bedrock/camera/BedrockCameraShake.java | 49 ++++ + .../bedrock/camera/BedrockCameraWorld.java | 95 ++++++++ + .../bedrock/camera/BedrockDeathCamera.java | 79 ++++++ + .../bedrock/camera/BedrockPlayerFov.java | 34 +++ + .../camera/CameraAimAssistControlPayload.java | 22 ++ + .../camera/CameraAimAssistPayload.java | 22 ++ + .../bedrock/camera/CameraFadePayload.java | 22 ++ + .../bedrock/camera/CameraFollowPayload.java | 22 ++ + .../bedrock/camera/CameraFovPayload.java | 22 ++ + .../bedrock/camera/CameraPositionPayload.java | 22 ++ + .../bedrock/camera/CameraShakePayload.java | 26 ++ + .../bedrock/camera/CameraSplinePayload.java | 22 ++ + .../bedrock/camera/CameraTrackingPayload.java | 22 ++ + .../render/MixinBedrockCameraFade.java | 23 ++ + .../render/MixinBedrockCameraFov.java | 85 +++++++ .../render/MixinBedrockCameraPerspective.java | 21 ++ - .../render/MixinBedrockCameraShake.java | 73 +++++++ - .../render/MixinBedrockFollowInput.java | 23 +++ + .../render/MixinBedrockCameraShake.java | 76 ++++++ + .../MixinBedrockDeathCameraEffects.java | 23 ++ + .../render/MixinBedrockFollowInput.java | 23 ++ .../render/MixinMinecraftHudVisibility.java | 6 + .../sound/MixinBedrockCameraListener.java | 30 +++ .../settings/impl/BedrockSettings.java | 4 + .../viafabricplus-bedrock/lang/en_us.json | 1 + - .../viafabricplus-bedrock.mixins.json | 8 +- - .../camera/BedrockCameraPositionTest.java | 68 +++++++ - .../bedrock/camera/BedrockPlayerFovTest.java | 85 ++++++++ - 29 files changed, 1190 insertions(+), 1 deletion(-) + .../viafabricplus-bedrock.mixins.json | 9 +- + .../camera/BedrockCameraPositionTest.java | 68 ++++++ + .../camera/BedrockDeathCameraTest.java | 113 +++++++++ + .../bedrock/camera/BedrockPlayerFovTest.java | 85 +++++++ + 32 files changed, 1471 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/camera/BedrockCameraAimAssist.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/camera/BedrockCameraFade.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/camera/BedrockCameraFov.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/camera/BedrockCameraPosition.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/camera/BedrockCameraShake.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/camera/BedrockCameraWorld.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/camera/BedrockDeathCamera.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/camera/BedrockPlayerFov.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/camera/CameraAimAssistControlPayload.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/camera/CameraAimAssistPayload.java @@ -210,9 +238,11 @@ scheduling still need native world comparisons. create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinBedrockCameraFov.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinBedrockCameraPerspective.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinBedrockCameraShake.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinBedrockDeathCameraEffects.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinBedrockFollowInput.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/sound/MixinBedrockCameraListener.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/camera/BedrockCameraPositionTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/camera/BedrockDeathCameraTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/camera/BedrockPlayerFovTest.java diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java b/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java @@ -475,10 +505,10 @@ index 0000000..49fab8a +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/camera/BedrockCameraPosition.java b/src/main/java/com/viaversion/viafabricplus/bedrock/camera/BedrockCameraPosition.java new file mode 100644 -index 0000000..c76154a +index 0000000..cec3cf2 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/camera/BedrockCameraPosition.java -@@ -0,0 +1,183 @@ +@@ -0,0 +1,224 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -487,6 +517,8 @@ index 0000000..c76154a +package com.viaversion.viafabricplus.bedrock.camera; + +import net.minecraft.client.CameraType; ++import net.minecraft.client.player.LocalPlayer; ++import com.viaversion.viafabricplus.bedrock.input.BedrockInputPermissions; +import com.viaversion.viafabricplus.bedrock.render.BedrockActorState; +import net.raphimc.viabedrock.api.model.CameraTrackingState; +import net.raphimc.viabedrock.api.model.CameraSplineState; @@ -510,6 +542,8 @@ index 0000000..c76154a + private static final CameraFollowRuntime FOLLOW = new CameraFollowRuntime(); + private static final CameraTrackingRuntime TRACKING = new CameraTrackingRuntime(); + private static final CameraPoseTimeline TIMELINE = new CameraPoseTimeline(); ++ private static final BedrockDeathCamera DEATH = new BedrockDeathCamera(); ++ private static LocalPlayer deathSubject; + private static final Deque PENDING = new ArrayDeque<>(); + private static CameraPositionState state = CameraPositionState.EMPTY; + private static CameraPose rendered; @@ -547,6 +581,8 @@ index 0000000..c76154a + } + + public static void clear() { ++ DEATH.clear(); ++ deathSubject = null; + FOLLOW.clear(); + TRACKING.clear(); + PENDING.clear(); @@ -563,6 +599,31 @@ index 0000000..c76154a + preparingTarget = true; + } + ++ public static void tickDeath() { ++ final var client = Minecraft.getInstance(); ++ final var player = client.player; ++ if (!BedrockInputPermissions.isBedrockConnection() || player == null || client.getCameraEntity() != player) { ++ DEATH.clear(); ++ deathSubject = null; ++ return; ++ } ++ if (deathSubject != player) { ++ DEATH.clear(); ++ deathSubject = player; ++ } ++ // Java stops ticking its dead local actor. The native death counter continues on camera ticks. ++ DEATH.tick(player.getHealth() <= 0, player.deathTime); ++ } ++ ++ public static boolean defaultDeathActive() { ++ final var requested = PENDING.isEmpty() ? state : PENDING.getLast(); ++ return DEATH.active() && requested.mode() == Mode.LOCAL; ++ } ++ ++ public static float deathFieldOfView(final float partialTick, final float normalFov, final float effectScale) { ++ return DEATH.projection(normalFov, effectScale, partialTick); ++ } ++ + public static CameraAudioListener playerListener() { + final var player = Minecraft.getInstance().player; + if (state.mode() == Mode.LOCAL || !state.playerAudio() || player == null) return null; @@ -572,6 +633,7 @@ index 0000000..c76154a + } + + public static CameraType perspective(final CameraType local) { ++ if (!preparingTarget && defaultDeathActive()) return CameraType.THIRD_PERSON_BACK; + final Mode mode; + if (preparingTarget) { + final CameraPositionState requested = PENDING.isEmpty() ? state : PENDING.getLast(); @@ -593,18 +655,27 @@ index 0000000..c76154a + final long now = System.nanoTime(); + final float delta = previousFrame == 0 ? 0 : Math.max(0, (now - previousFrame) / 1_000_000_000F); + previousFrame = now; ++ final var player = Minecraft.getInstance().player; ++ final CameraPose local; ++ if (DEATH.active() && player != null) { ++ final var eye = player.getEyePosition(partialTick); ++ local = DEATH.pose(new CameraPose.Vector((float) eye.x, (float) eye.y, (float) eye.z), ++ player.getYRot(partialTick), normal.fov(), delta, new BedrockCameraWorld(Minecraft.getInstance().level)); ++ } else { ++ local = normal; ++ } + boolean changed = false; + while (!PENDING.isEmpty()) { + final CameraPositionState snapshot = PENDING.removeFirst(); + state = snapshot; + if (snapshot.mode() == Mode.LOCAL) { + // Clear resets the next blend even when both commands arrive before this frame. -+ rendered = normal; ++ rendered = local; + } else { + // A snapshot lacks the old client's interrupted frame. Restore its settled target, + // rather than replaying an expired or newly negotiated cutscene from a guessed pose. + final boolean continuous = snapshot.live() && snapshot.sequence() == appliedSequence + 1; -+ TIMELINE.begin(rendered != null ? rendered : normal, continuous ? snapshot.duration() : 0, snapshot.ease()); ++ TIMELINE.begin(rendered != null ? rendered : local, continuous ? snapshot.duration() : 0, snapshot.ease()); + } + appliedSequence = snapshot.sequence(); + changed = true; @@ -612,8 +683,8 @@ index 0000000..c76154a + TRACKING.advance(delta, BedrockCameraPosition::targetActor); + FOLLOW.advance(delta, localFollowActor(), BedrockCameraPosition::followActor, new BedrockCameraWorld(Minecraft.getInstance().level)); + if (state.mode() == Mode.LOCAL) { -+ rendered = normal; -+ return null; ++ rendered = local; ++ return local != normal ? local : null; + } + final CameraPose tracked = TRACKING.active(state.sequence(), normal.fov(), Mode.FREE.ordinal()); + final CameraPose follow = FOLLOW.active(state.sequence(), normal.fov(), Mode.BOOM.ordinal()); @@ -818,6 +889,91 @@ index 0000000..3bbb458 + return new CameraRaycast.Box(minX, minY, minZ, maxX, maxY, maxZ); + } +} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/camera/BedrockDeathCamera.java b/src/main/java/com/viaversion/viafabricplus/bedrock/camera/BedrockDeathCamera.java +new file mode 100644 +index 0000000..63d6f68 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/camera/BedrockDeathCamera.java +@@ -0,0 +1,79 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.camera; ++ ++import net.raphimc.viabedrock.api.model.CameraAvoidance; ++import net.raphimc.viabedrock.api.model.CameraFollowRuntime; ++import net.raphimc.viabedrock.api.model.CameraOrbit; ++import net.raphimc.viabedrock.api.model.CameraPose; ++import net.raphimc.viabedrock.api.model.CameraPositionState; ++import net.raphimc.viabedrock.api.util.BedrockTrigonometry; ++ ++/** The default death profile and counter in Bedrock 1.26.60.5. */ ++final class BedrockDeathCamera { ++ private static final float RADIUS = 8F; ++ private static final float POLAR = 60F * CameraPose.DEGREES; ++ private boolean active; ++ private short ticks; ++ private CameraOrbit orbit; ++ private CameraAvoidance avoidance; ++ ++ void tick(final boolean dead, final int initialDeathTicks) { ++ if (!dead) { ++ this.clear(); ++ } else if (this.active) { ++ this.ticks++; ++ } else { ++ this.active = true; ++ this.ticks = (short) initialDeathTicks; ++ } ++ } ++ ++ boolean active() { ++ return this.active; ++ } ++ ++ float fieldOfView(final float partialTick) { ++ return fieldOfView(this.ticks, partialTick); ++ } ++ ++ float projection(final float normalFov, final float effectScale, final float partialTick) { ++ final float nativeFov = this.fieldOfView(partialTick); ++ // Disabled gameplay effects preserve the existing modifier history before it converges to one. ++ return effectScale == 1F ? nativeFov : normalFov + effectScale * (nativeFov - normalFov); ++ } ++ ++ static float fieldOfView(final int deathTicks, final float partialTick) { ++ final float progress = Math.clamp(((float) (short) deathTicks + partialTick) / 120F, 0F, 1F); ++ // The original renderer uses the native sine lookup, then replaces the slider-derived FOV. ++ final float eased = .4F * BedrockTrigonometry.sine(progress * (CameraPose.PI * .5F)); ++ return (eased + 1F) * 60F; ++ } ++ ++ CameraPose pose(final CameraPose.Vector eye, final float yaw, final float fov, final float delta, ++ final CameraFollowRuntime.World world) { ++ final var target = new CameraOrbit.Target(RADIUS, (yaw - 90F) * CameraPose.DEGREES, POLAR); ++ if (this.orbit == null) { ++ this.orbit = new CameraOrbit(new CameraOrbit.Settings(0, 0, 0, POLAR, POLAR, ++ -CameraPose.PI, CameraPose.PI, false, false), ++ new CameraOrbit.State(RADIUS, target.yaw(), POLAR, 0, 0, 0), target); ++ this.avoidance = new CameraAvoidance(CameraAvoidance.Settings.boom(), new CameraAvoidance.State(RADIUS, 0)); ++ } ++ this.orbit.target(target); ++ this.orbit.input(0, 0); ++ final var requested = this.orbit.advance(delta, eye, eye, Float.MAX_VALUE); ++ final var position = this.avoidance.advance(delta, eye, requested, RADIUS, .025F, ++ world.insideSolidBlock(eye), null, world); ++ return CameraPose.fromDegrees(position, 30F, yaw, fov, CameraPositionState.Mode.THIRD_PERSON.ordinal()); ++ } ++ ++ void clear() { ++ this.active = false; ++ this.ticks = 0; ++ this.orbit = null; ++ this.avoidance = null; ++ } ++} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/camera/BedrockPlayerFov.java b/src/main/java/com/viaversion/viafabricplus/bedrock/camera/BedrockPlayerFov.java new file mode 100644 index 0000000..92640be @@ -1145,10 +1301,10 @@ index 0000000..d122b86 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinBedrockCameraFov.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinBedrockCameraFov.java new file mode 100644 -index 0000000..742ac02 +index 0000000..e09bea0 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinBedrockCameraFov.java -@@ -0,0 +1,64 @@ +@@ -0,0 +1,85 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -1161,10 +1317,12 @@ index 0000000..742ac02 +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import com.viaversion.viafabricplus.bedrock.appearance.BedrockItemMetadata; +import com.viaversion.viafabricplus.bedrock.camera.BedrockCameraFov; ++import com.viaversion.viafabricplus.bedrock.camera.BedrockCameraPosition; +import com.viaversion.viafabricplus.bedrock.camera.BedrockPlayerFov; +import com.viaversion.viafabricplus.bedrock.input.BedrockInputPermissions; +import com.viaversion.viafabricplus.bedrock.input.BedrockMovementEffects; +import net.minecraft.client.Camera; ++import net.minecraft.client.Minecraft; +import net.minecraft.client.player.AbstractClientPlayer; +import net.minecraft.core.component.DataComponents; +import net.minecraft.world.effect.MobEffects; @@ -1173,12 +1331,30 @@ index 0000000..742ac02 +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; ++import org.spongepowered.asm.mixin.injection.Inject; ++import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(Camera.class) +public abstract class MixinBedrockCameraFov { + @Shadow private float fov; + @Shadow private boolean isPanoramicMode; + ++ @Inject(method = "tick", at = @At("HEAD")) ++ private void advanceNativeDeathCounter(final CallbackInfo ci) { ++ if ((Object) this == Minecraft.getInstance().gameRenderer.mainCamera()) BedrockCameraPosition.tickDeath(); ++ } ++ ++ @WrapOperation(method = "calculateFov", at = @At(value = "INVOKE", ++ target = "Lnet/minecraft/client/Camera;modifyFovBasedOnDeathOrFluid(FF)F")) ++ private float nativeDeathProjection(final Camera camera, final float partialTick, final float fov, ++ final Operation original) { ++ if (camera != Minecraft.getInstance().gameRenderer.mainCamera() || !BedrockCameraPosition.defaultDeathActive()) ++ return original.call(camera, partialTick, fov); ++ final var options = Minecraft.getInstance().options; ++ return BedrockCameraPosition.deathFieldOfView(partialTick, fov, ++ options.fovEffectScale().get().floatValue()); ++ } ++ + @WrapOperation(method = "tickFov", at = @At(value = "INVOKE", + target = "Lnet/minecraft/client/player/AbstractClientPlayer;getFieldOfViewModifier(ZF)F")) + private float nativePlayerModifier(final AbstractClientPlayer player, final boolean firstPerson, @@ -1208,6 +1384,7 @@ index 0000000..742ac02 + + @ModifyReturnValue(method = "calculateFov", at = @At("RETURN")) + private float serverProjection(final float normal) { ++ if ((Object) this != Minecraft.getInstance().gameRenderer.mainCamera()) return normal; + final float local = BedrockInputPermissions.isBedrockConnection() && !this.isPanoramicMode + ? Math.clamp(normal, 5F, 130F) : normal; + return BedrockCameraFov.project(local, this.fov); @@ -1242,10 +1419,10 @@ index 0000000..c36c380 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinBedrockCameraShake.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinBedrockCameraShake.java new file mode 100644 -index 0000000..bce161d +index 0000000..7ba1ce3 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinBedrockCameraShake.java -@@ -0,0 +1,73 @@ +@@ -0,0 +1,76 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -1259,6 +1436,7 @@ index 0000000..bce161d +import net.raphimc.viabedrock.api.model.CameraPositionState; + +import net.minecraft.client.Camera; ++import net.minecraft.client.Minecraft; +import net.minecraft.world.phys.Vec3; + +import org.spongepowered.asm.mixin.Mixin; @@ -1283,11 +1461,13 @@ index 0000000..bce161d + + @Inject(method = "alignWithEntity", at = @At("HEAD")) + private void prepareServerPose(final float partialTick, final CallbackInfo ci) { ++ if ((Object) this != Minecraft.getInstance().gameRenderer.mainCamera()) return; + BedrockCameraPosition.beginFrame(partialTick); + } + + @Inject(method = "alignWithEntity", at = @At("TAIL")) + private void applyServerShake(final float partialTick, final CallbackInfo ci) { ++ if ((Object) this != Minecraft.getInstance().gameRenderer.mainCamera()) return; + final CameraPose normal = CameraPose.fromDegrees(new CameraPose.Vector((float) this.position.x, + (float) this.position.y, (float) this.position.z), this.xRot, this.yRot, + this.fov, net.minecraft.client.Minecraft.getInstance().options.getCameraType() == net.minecraft.client.CameraType.FIRST_PERSON @@ -1319,6 +1499,35 @@ index 0000000..bce161d + } + } +} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinBedrockDeathCameraEffects.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinBedrockDeathCameraEffects.java +new file mode 100644 +index 0000000..7cef2ef +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinBedrockDeathCameraEffects.java +@@ -0,0 +1,23 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.injection.mixin.features.render; ++ ++import com.mojang.blaze3d.vertex.PoseStack; ++import com.viaversion.viafabricplus.bedrock.camera.BedrockCameraPosition; ++import net.minecraft.client.renderer.GameRenderer; ++import net.minecraft.client.renderer.state.level.CameraRenderState; ++import org.spongepowered.asm.mixin.Mixin; ++import org.spongepowered.asm.mixin.injection.At; ++import org.spongepowered.asm.mixin.injection.Inject; ++import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; ++ ++@Mixin(GameRenderer.class) ++public abstract class MixinBedrockDeathCameraEffects { ++ @Inject(method = "bobHurt", at = @At("HEAD"), cancellable = true) ++ private void retainNativeDeathOrientation(final CameraRenderState camera, final PoseStack poses, final CallbackInfo ci) { ++ if (BedrockCameraPosition.defaultDeathActive()) ci.cancel(); ++ } ++} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinBedrockFollowInput.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinBedrockFollowInput.java new file mode 100644 index 0000000..371ff8d @@ -1450,21 +1659,22 @@ index eadb404..b83cf42 100644 "bedrock_settings.viafabricplus.hide_own_captions": "Hide captions for your sounds", "bedrock_settings.viafabricplus.hide_ambient_captions": "Hide ambient and weather captions", diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json -index 549dc2b..605b96f 100644 +index 549dc2b..0bf601f 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -141,6 +141,10 @@ +@@ -141,6 +141,11 @@ "features.render.MixinBedrockFirstPersonVisibility", "features.render.MixinBedrockFirstPersonBobbing", "features.render.MixinBedrockFirstPersonProjection", + "features.render.MixinBedrockCameraFov", ++ "features.render.MixinBedrockDeathCameraEffects", + "features.render.MixinBedrockCameraShake", + "features.render.MixinBedrockCameraPerspective", + "features.render.MixinBedrockFollowInput", "features.render.MixinBedrockHandItemLifecycle", "features.render.MixinBedrockModelPartTransforms", "features.render.MixinPlayerSkinWidget", -@@ -165,11 +169,13 @@ +@@ -165,11 +170,13 @@ "features.sound.MixinBedrockSoundManager", "features.sound.MixinBedrockSoundCaptions", "features.sound.MixinBedrockSoundEngine", @@ -1553,6 +1763,125 @@ index 0000000..0836de0 + return new CameraPositionState(sequence, 0, true, mode, position, 0, 0, duration, CameraEase.LINEAR, true, false); + } +} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/camera/BedrockDeathCameraTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/camera/BedrockDeathCameraTest.java +new file mode 100644 +index 0000000..7751a91 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/camera/BedrockDeathCameraTest.java +@@ -0,0 +1,113 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.camera; ++ ++import com.google.gson.JsonObject; ++import com.google.gson.JsonParser; ++import net.raphimc.viabedrock.api.model.CameraFollowRuntime; ++import net.raphimc.viabedrock.api.model.CameraPose; ++import org.junit.jupiter.api.Test; ++import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; ++ ++import java.nio.file.Files; ++import java.nio.file.Path; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++final class BedrockDeathCameraTest { ++ @Test ++ void continuesAfterTheHostDeathCounterStopsAndRestartsAfterRespawn() { ++ final var camera = new BedrockDeathCamera(); ++ camera.tick(true, 20); ++ final float early = camera.fieldOfView(.5F); ++ for (int i = 0; i < 100; i++) camera.tick(true, 20); ++ assertEquals(84F, camera.fieldOfView(.5F)); ++ assertTrue(early < camera.fieldOfView(.5F)); ++ camera.tick(false, 20); ++ assertFalse(camera.active()); ++ camera.tick(true, 0); ++ assertEquals(60F, camera.fieldOfView(0)); ++ } ++ ++ @Test ++ void retainsTheSignedShortCounterWhenTheDeathScreenStaysOpen() { ++ final var camera = new BedrockDeathCamera(); ++ camera.tick(true, Short.MAX_VALUE); ++ assertEquals(84F, camera.fieldOfView(0)); ++ camera.tick(true, 20); ++ assertEquals(60F, camera.fieldOfView(0)); ++ } ++ ++ @Test ++ @EnabledIfEnvironmentVariable(named = "STACKANVIL_DEATH_CAMERA_REFERENCE", matches = ".+") ++ void matchesTheExecutedDefaultDeathProjectionAcrossSliderAndFluidInputs() throws Exception { ++ final var fixture = reference("native-world-default-death-cases.json"); ++ int checked = 0; ++ for (final var value : fixture.getAsJsonArray("cases")) { ++ final var row = value.getAsJsonObject(); ++ if (row.get("alive").getAsBoolean()) continue; ++ final var camera = new BedrockDeathCamera(); ++ camera.tick(true, row.get("ticks").getAsInt()); ++ final float normal = row.get("fov").getAsFloat() * row.get("modifier").getAsFloat(); ++ assertEquals(row.get("bits").getAsInt(), Float.floatToRawIntBits(camera.projection(normal, ++ row.get("effects").getAsBoolean() ? 1F : 0F, row.get("alpha").getAsFloat())), row::toString); ++ checked++; ++ } ++ assertTrue(checked > 0, "The native fixture contains no default death projections"); ++ } ++ ++ @Test ++ @EnabledIfEnvironmentVariable(named = "STACKANVIL_DEATH_CAMERA_REFERENCE", matches = ".+") ++ void projectsTheSettledDeathViewOntoBothNativeMarkerCaptures() throws Exception { ++ final var fixture = reference("native-death-geometry-review.json"); ++ final var eye = fixture.getAsJsonArray("eye"); ++ final var camera = new BedrockDeathCamera(); ++ camera.tick(true, 120); ++ final var pose = camera.pose(new CameraPose.Vector(eye.get(0).getAsFloat(), eye.get(1).getAsFloat(), ++ eye.get(2).getAsFloat()), 0, camera.fieldOfView(0), 0, new CameraFollowRuntime.World() { ++ @Override ++ public boolean insideSolidBlock(final CameraPose.Vector origin) { return false; } ++ @Override ++ public CameraPose.Vector cast(final CameraPose.Vector start, final CameraPose.Vector end) { return null; } ++ }); ++ final var angles = pose.orientation().angles(); ++ final double pitch = -angles.x(), yaw = CameraPose.PI - angles.y(); ++ final double sx = Math.sin(yaw), cx = Math.cos(yaw), sp = Math.sin(pitch), cp = Math.cos(pitch); ++ final double focal = 360 / Math.tan(pose.fov() * Math.PI / 360); ++ final double[][] bounds = new double[2][4]; ++ for (int marker = 0; marker < 2; marker++) { ++ bounds[marker] = new double[]{Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY, ++ Double.NEGATIVE_INFINITY, Double.NEGATIVE_INFINITY}; ++ for (final int x : new int[]{-marker * 2, 1 - marker * 2}) { ++ for (final int y : new int[]{102, 103}) { ++ final double dx = x - pose.position().x(), dy = y - pose.position().y(), dz = 8 - pose.position().z(); ++ final double depth = -sx * cp * dx - sp * dy + cx * cp * dz; ++ final double px = 640 + (-cx * dx - sx * dz) * focal / depth; ++ final double py = 360 - (-sx * sp * dx + cp * dy + cx * sp * dz) * focal / depth; ++ bounds[marker][0] = Math.min(bounds[marker][0], px); ++ bounds[marker][1] = Math.min(bounds[marker][1], py); ++ bounds[marker][2] = Math.max(bounds[marker][2], px); ++ bounds[marker][3] = Math.max(bounds[marker][3], py); ++ } ++ } ++ } ++ for (final var value : fixture.getAsJsonArray("rows")) { ++ final var row = value.getAsJsonObject(); ++ final var boxes = row.getAsJsonArray("boxes"); ++ for (int marker = 0; marker < 2; marker++) { ++ for (int edge = 0; edge < 4; edge++) { ++ assertEquals(boxes.get(marker).getAsJsonArray().get(edge).getAsDouble(), bounds[marker][edge], ++ 1.1, row::toString); ++ } ++ } ++ } ++ } ++ ++ private static JsonObject reference(final String name) throws Exception { ++ return JsonParser.parseString(Files.readString(Path.of(System.getenv("STACKANVIL_DEATH_CAMERA_REFERENCE"), name))) ++ .getAsJsonObject(); ++ } ++} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/camera/BedrockPlayerFovTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/camera/BedrockPlayerFovTest.java new file mode 100644 index 0000000..4871b06 diff --git a/patches/viafabricplus-bedrock/upstreamable/0023-render-core-resolved-camera-movement.pr.md b/patches/viafabricplus-bedrock/upstreamable/0023-render-core-resolved-camera-movement.pr.md index 50e95a99..277a49b3 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0023-render-core-resolved-camera-movement.pr.md +++ b/patches/viafabricplus-bedrock/upstreamable/0023-render-core-resolved-camera-movement.pr.md @@ -26,10 +26,22 @@ Rebuilt direct and ViaProxy clients each pass clear-only, ordinary blend, and tw Original Bedrock 1.26.60.5 instructions supply 25 player FOV cases covering movement speed, flight, Slowness, swim boost, bow charge, scoping, disabled gameplay effects, and zero walking speed. Finite outputs match native float bits; the zero-over-zero result remains NaN. Six native Dolphin Boost kernel controls establish the swimming gate and marker-presence rule. The effect timeline regression covers the final marker frame and restored values. -Matching stationary world markers on official 1.26.60.5 and rebuilt direct/ViaProxy clients cover slider values 60, 30, 110, and return to 60. The corrected projection matches native within pixel-spacing quantization. An add-on control with gameplay FOV disabled matches the native disabled control. A direct-route server command retains its specified 40-degree projection, and clear restores the corrected local projection. Complete protocol-2225 journals, installed JAR hashes, stationary position, eye height, and facing are verified. The extra proxy server-FOV command check did not run before its timed recorder ended. +Matching stationary world markers on official 1.26.60.5 and rebuilt direct/ViaProxy clients cover slider values 60, 30, 110, and return to 60. The corrected projection matches native within pixel-spacing quantization. An add-on control with gameplay FOV disabled matches the native disabled control. A direct-route server command retains its specified 40-degree projection, and clear restores the corrected local projection. Complete protocol-2225 journals, installed JAR hashes, stationary position, eye height, and facing are verified. The final death-camera controls also exercise server FOV set and clear on both routes. -The four-project build passes 3,515 tests with 329 skipped and no failures or errors, including the executable FOV comparison. Bundle and patch-health checks pass. +Native high-Speed controls reach the 130-degree projection limit and return to baseline after expiry. Direct and ViaProxy observations retain the 2.42 modifier before the final projection clamp. The native expiry video records intermediate projections above the former Java modifier limit. -Timeline and selected native/replay controls cover supported commands. Dynamic swim expiry, fluid/death composition, portrait scaling, complete transition timing, aim assistance, collision, and visual composition remain partial. Static marker controls do not establish complete camera or pixel parity. +A stationary, submerged fixture with a low ceiling compares native, direct, and ViaProxy swimming without movement input. All three reach the swimming pose and matching underwater projection. Dolphins widen that projection, which persists briefly after their removal and returns to baseline. Unobstructed marker pairs match within pixel-spacing quantization. Frames with obscured markers are excluded. + +Both translated journals contain six Dolphin Boost refreshes with 60-tick durations. Client-thread observations retain the multiplier through the final packet tick plus 60, then clear it on the next tick. The first direct water trial died during fixture preparation and remained on the death screen. It does not establish a swimming defect. The repeated direct trial completes alive with the expected pose, eye height, boost, and expiry. + +The four-project build passes 3,519 tests with 329 skipped and no failures or errors, including the executable FOV and death-camera comparisons. Bundle and patch-health checks pass. + +The default death camera uses the native eight-block orbit and 60-to-84-degree projection. Original 1.26.60.5 instructions supply 54 death-projection cases, including disabled gameplay effects with retained modifier history. Nine native stills at slider values 60 and 30 match the profile-derived marker bounds within 1.1 pixels. Rebuilt direct and ViaProxy clients pass settled pose, FOV disable/restore, explicit server FOV, server-camera ownership, clear, and respawn controls. Auxiliary camera ticks leave the shared death counter unchanged. Complete protocol-2225 journals and installed artifact hashes identify both final builds. + +The native viewport FOV factor uses viewport-to-context size ratios. Thirty-one controls execute the original 1.26.60.5 resize path, viewport getters, aspect update, FOV calculation, rendering cache setter, and projection writer together. Resize rounds viewport dimensions to a multiple of the previous native UI scale. At 1277 × 720 and scale two, it stores 1278 × 720 and changes FOV 66 to approximately 65.94836. Both the cached viewport and scissor rectangle retain those padded dimensions. The current Java camera uses raw dimensions and does not retain this padding. + +The produced native projection matrices match JOML in 62 comparisons across both Java depth ranges within 0.000002. Current Java parameters differ in 18 of 40 comparisons with supported UI scales. These controls supply the new UI-scale result, FOV delivery in radians, and `tanf` through libm. Final world render-target sizing, clipping or rescaling, official UCRT bits, installed rendering, and visible portrait parity remain unverified. The controls do not establish a complete correction. + +Exact death activation timing, varied angles, collision and postures, non-default death contexts, and native death-screen UI remain open. Other fluid effects, portrait scaling, input-frame expiry, complete transition timing, aim assistance, and visual composition remain partial. These controls do not establish complete camera or pixel parity. The [owning patch](0023-render-core-resolved-camera-movement.patch) retains implementation rationale and versioned evidence. diff --git a/patches/viafabricplus-bedrock/upstreamable/0025-send-completed-local-physics-to-viabedrock.patch b/patches/viafabricplus-bedrock/upstreamable/0025-send-completed-local-physics-to-viabedrock.patch index eec10cc0..f4ac51e2 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0025-send-completed-local-physics-to-viabedrock.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0025-send-completed-local-physics-to-viabedrock.patch @@ -1,4 +1,4 @@ -From e518b0163fff16a67057f77e809bf45af7b9849a Mon Sep 17 00:00:00 2001 +From c9f6da69928c09c5afcb03f12d3c2553c7aa42cf 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(movement): send completed local physics to ViaBedrock @@ -184,31 +184,41 @@ revision 6, including ViaProxy. Clear it before each frame, and bypass item-use slowdown for passengers. Consuming or replacing the selected item later cannot reconstruct a different factor for that frame. -Bound local Bedrock item-use duration at zero. Matching native duration -callback 0x14c6109b0 decrements positive ticks only and clamps at zero. -Java 26.3 updateUsingItem otherwise continues below zero while awaiting -server completion. Keep authoritative inventory updates and Java's server -acknowledgment path, and limit this change to the local Bedrock player. +Complete local automatic use when its bounded countdown reaches zero. +Native 1.26.60.5 Actor::tick at 0x1402c09b0 calls completeUsingItem at +0x1402c33d0, whose client branch stops use through 0x1402c3f70 without +inventory transactions. ItemUseTickDurationSystem decrements +positive ticks only. Keep the server authoritative for replacement items and count changes, +including instant food. Native 1.26.60.5 HUD captures during a four-second +authority pause show local hunger rising from ten to fifteen and twenty +while the stack remains at four. Retain the FoodProperties listener for +that local prediction, but suppress replacement listeners and count debit. +Later server attributes reconcile the predicted hunger. Preserve existing +completion feedback. -Native Level+0x238 supplies completion's virtual+0x9e8 side gate. -Client factory 0x14086eef0 sets LevelArguments+0x120 to one; base Level -constructor 0x14114e0c0 copies that flag, and ClientLevel 0x1412efe30 -retains it. Native local completion therefore skips the server inventory -transaction and CompletedUsingItem sender. Its ItemCoordinator event and -the separate ActorUseItem acknowledgment still have untraced consumers. -A local completion prototype exposed delayed updates interrupting the -next use cycle in strict BDS. Retain only the established bounded counter -while completion, event consumers and correction history remain open. +Native ActorUseItem from packet 142 notifies downstream listeners rather +than completing the current local cycle. Ignore its translated Java +completion event for the local Bedrock player; an absent acknowledgment +must not retain zero-counter use, and a late one must not end a newer +same-item cycle. Ordinary Java clients and remote players keep their +existing acknowledgment behavior. -Keep local item-use transitions independent of translated actor flags. In -Bedrock 1.26.51.1, Actor::startUsingItem and stopUsingItem maintain the local -active-use field separately from ActorDataFlagComponent. Flag assignment -only updates kinetic-damage state through the inspected using-item callback. -Java 26.3 LocalPlayer::onSyncedDataUpdated otherwise starts or stops that -local cycle from metadata, which lets delayed updates interrupt repeated use. -Guard those two metadata transitions for local Bedrock players. Preserve -metadata itself, remote players, ordinary Java sessions, selected-item -identity checks, explicit release, and inventory/completion handling. +Reconcile translated using-item metadata at the next local entity tick, +not during packet processing. Native synchronization kernel 0x14ce487e0 +starts or stops active use from ActorDataFlagComponent. Local start and +stop also update that flag, so metadata and the local active stack have +separate storage but are not independent. Preserve hand selection and +retain the existing selected native-definition identity checks. Physical +replay observes recorded use state without repeating countdown, flag +reconciliation or consumption side effects. + +Current native captures consume two bread with packet 142 suppressed. +With all clientbound gameplay authority delayed four seconds after the +first completion, native continues local use attempts before the flush +while retaining the old stack count. A passive owned-process trace +observes countdown zero, local completion and stop, then both local-input +and deferred metadata starts. These declared controls establish lifecycle +behavior; they do not establish the cause of the unmatched live report. Use core GlideMovement for completed local glide travel on direct and ViaProxy Bedrock sessions. Native callback 0x146667b10 in build 1.26.51.1 @@ -585,24 +595,13 @@ remote-player interpolation clears; the client consumes them through its existing lifetime-checked source channel and historical captures remain immutable. -End the movement modifier of an automatic consumable when its local use -counter expires, while leaving the use cycle, completion event and -inventory authority intact. Keep non-consumables and release-based items -on their existing path. Historical physics already retains the counter -and item components, so replay makes the same movement decision without -repeating consumption or changing the live inventory. - -A strict Bedrock 1.26.51.1 control with a declared 300 ms inbound Java -delay retains active food use for seven zero-counter ticks per completion. -The previous client keeps applying slowdown during that gap, causing eight -corrections during repeated moving eating and two on the final item. -The final-artifact controls remove that repeated burst: ViaProxy has no -corrections in either delayed food phase, while direct retains one -isolated correction per phase. Both routes complete four delayed foods -and the final item, retain absorption, and end with an empty hand and -inactive use. Undelayed controls remain correction-free. The remaining -direct transitions, exact native timing and intermittent original eating -report still need investigation. +Observe the retained automatic-use counter when applying movement slowdown. +Historical physics retains use state and item components, so replay makes +the same movement decision without repeating local completion or changing +live inventory. Local completion now ends the active cycle at expiry. +An earlier strict 1.26.51.1 delayed-authority control established that +zero-counter use must not retain the movement modifier. Broader native +movement timing and the original intermittent food report remain open. Apply local native mode events before their following retained physical input. Keep native layer declarations in snapshots and replay mode writes @@ -701,21 +700,166 @@ camera FOV. Bedrock 1.26.60.5 DolphinBoostSystem kernel 0x1484f88e0 checks the swimming flag and presence of MovementEffects slot 1, including its zero-duration marker. Query the existing retained timeline in the current physical replay scope; do not add an independent countdown. + +Retain native item identity when completing local item use. +Java 26.3 completeUsingItem compares stack references, while active use +already compares the native definition. A matching inventory refresh in +the same frame can replace the held stack before completion arrives. +In an installed protocol-2225 control this releases the active cycle +without its consumption effects. Use the existing native definition +comparison at completion too. Preserve Java sessions, remote entities, +different native definitions, and server-owned inventory reconciliation. + +A declared matching inventory refresh immediately before completion on +Bedrock 1.26.60.5/BDS 1.26.60.6 retains native consumption. The Java +before/after control observes the actual completion and consumable calls, +including distinct stack objects with equal counts. This establishes a +completion-effects boundary, not the original intermittent no-feeding +report or late completion across a new use cycle. + +Keep live hunger outside reversible physical snapshots. Current protocol +2225 moving-food controls expose both installed Java routes losing five +hunger during a delayed correction replay, while counts and local use +counters stay unchanged. The FOOD snapshot restores a value from before +local consumption, whose prediction must run only once. + +Record FoodData.hasEnoughFood at the same live phase as item-use input. +Read that observation for historical exhaustive-movement eligibility, +while retaining the separately replayed ability bypass. Remove the hunger +setter from physical capture so correction replay cannot discard later +meals. The focused regression keeps two later hunger values intact across +repeated historical sprint-eligibility queries. + +Retain native item use through the movement frame before ticking its +countdown and local completion. Official 1.26.60.5/protocol 2225 passive +traces show the movement countdown decrement on the first use frame, +then completion at zero after that frame's slowed motion. Java ticks +item use before LocalPlayer.aiStep and otherwise removes slowdown one +frame early. Keep the early held-definition check and stack refresh, +then tick the retained stack once after LivingEntity.tick movement. + +Do not equate packet 142 with server inventory Use transactions. Matching +native omission controls retain queued completion when 142 is omitted, +and remove it when the inventory transaction is omitted. Send that callback through a separate optional stop notification. + +Stop native use on a negotiated server inventory Use notification without +consumption effects. Official 1.26.60.5/protocol 2225 executes local food +feedback 1402c30c0 at zero before Actor::completeUsingItem. The trusted +server transaction calls completion directly and skips that feedback, +including when a new same-item cycle has 31 ticks remaining. With hunger +attribute updates omitted, one local completion plus that callback leave +hunger at five and the authoritative bread count at seven. Do not invoke +Java's consumable a second time for this notification. + +Register the receiver on its owning connection and retain the existing +Bedrock guard. Calling stopUsingItem clears local use and its native flags +without changing hunger or inventory. Packet 142 remains independent. + +Compare later prediction corrections with the most recent state before +physics, after previously replayed authoritative commands. Keep that state +separate from the immutable rewind baseline. Otherwise a second correction +that matches the original prediction can be discarded after an earlier +correction changed the same retained frame, leaving the older result live. + +Retain the new comparison snapshot before executing physical steps, and +continue restoring the original baseline when replaying commands again. +Regression controls reproduce the ignored correction before this change +and verify return to the original position/motion afterward. A separate +case prevents repeated replay from accumulating an authoritative delta. +Historical mount checks bind participants to their frame, independently of +the number of snapshot refreshes. This fixes the retained-state comparison; +it does not establish the cause of the reported pearl/wind rollback loop +or complete native correction timing and trajectory parity. + +Expose the accepted native level version through the world block mirror. +The version-5 reset owns its lifetime together with terrain, so movement +consumers cannot retain a previous level version across replacement or logout. +Keep the original StartGame text separate from resource-pack cutoffs and client +protocol versions; native parsing and feature selection belong to the consumer. + +Expose the immutable native world reset after synchronizing ClientLevel. +Consumers can read the accepted base-game version and signed generator ID +from one snapshot. Keep the raw version accessor for existing callers and +clear both inputs with the level-bound authoritative block mirror. + +Protocol-2225 native StartGame/settings and LevelData controls establish +that generator zero limits the effective version to 1.17.40. Other generator +values use the ordinary settings version and wildcard fallback. This API +supplies those inputs without enabling the unfinished native boat tick. + +Gather the controlling boat passenger's filtered input before vehicle physics. +Java 26.3 refreshes it later in the passenger tick, so boat control otherwise +uses the previous frame. Original 1.26.60.5 scheduler controls place the +passenger input phase before vehicle paddles, friction and control. + +Reuse that poll during the passenger tick. Keep its recorded value in the +existing physical step so replay does not read live keys or consume a second +poll. Admit only the locally controlled, predicted boat in the same level. +Clear reuse state at each step boundary, including an absent passenger tick. +This changes input timing only; Java boat motion remains in place while the +native environment, random stream and full tick integration remain unfinished. + +Follow the ordinary correction command admission and history mask from +Bedrock 1.26.60.5. On clean history, discard unchanged historical and +current-clock corrections instead of retaining an inert command or forcing +a replay. Preserve future commands without marking the history dirty. + +A command already inserted into history sets a native mask that bypasses +later comparisons. Track that mask per actor lifetime while those commands +await replay. Current-clock commands acquire the native mask when captured +into history. A successful rewind clears the mask without removing retained +commands; normal frame completion must not restore it from commands that +have already run. Keep the comparison snapshot and immutable rewind baseline +separate. + +Original receiver, comparator and queue controls cover typed and cached +state descriptors, unsigned tick placement, ground changes, float tolerance +and mixed historical/current/future sequences. Complete original capture and replay controls establish pending +command transfer, dirty frames and mask clearing. Scheduler regressions check +replay requests, later command application, replay completion and history reset. +These controls do not establish native world trajectories or the cause of +the reported pearl/wind rollback loop. + +Retain ordinary native ground and air drift below Java's 0.003 motion +threshold. Defer the four Java cutoff comparisons for the controlled local +Bedrock actor, then apply the shared per-axis 2^-23 cutoff after collision +and before ordinary friction. Preserve vertical motion, the surviving +precision, native flight's separate finish stage and the existing camera, +connection and passenger boundaries. Liquids and gliding retain their +separate paths. + +Bedrock 1.26.60 protocol 2225 native wind-charge captures retain this small +drift on the same flat arena where both installed Java routes stop early. +The complete original native friction callback establishes the inclusive +cutoff and signed zero. Share that policy with flight in the core owner; +keep the composable four-expression hook that avoids pinned VFP's velocity +constant hook. Bounded collision controls do not close the original +persistent pearl/wind rollback report or establish all movement parity. + +Retain the collision-resolved displacement as well as velocity. Java's +separate squared-length gate can discard the final small horizontal steps +while ground collision removes vertical motion. Preserve the original +magnitude and allow nonzero resolved movement for the same ordinary local +Bedrock actor. Native wind-charge traces continue through those steps; +the intermediate installed build retained velocity but still lost their +displacement. Keep the ordinary Java comparison and all collision work. --- build.gradle.kts | 2 +- - .../bedrock/ViaFabricPlusBedrock.java | 14 + + .../bedrock/ViaFabricPlusBedrock.java | 23 + + .../appearance/BedrockNativeBlocks.java | 12 + .../access/IBedrockFlightMotion.java | 14 + .../access/IBedrockPlayerPosture.java | 14 + .../access/IBedrockTeleportTravel.java | 12 + .../movement/MixinAgePhysicsState.java | 37 ++ .../mixin/features/movement/MixinAvatar.java | 27 + - .../movement/MixinBoatPhysicsState.java | 79 +++ + .../movement/MixinBoatPhysicsState.java | 88 +++ .../MixinClientInputPhysicsState.java | 26 + .../MixinClientLevelPhysicsReplay.java | 36 ++ ...ixinClientPlayerGameModePhysicsReplay.java | 21 + .../MixinConnectionPhysicsReplay.java | 24 + + .../features/movement/MixinConsumable.java | 41 ++ .../movement/MixinElytraPhysicsState.java | 36 ++ - .../mixin/features/movement/MixinEntity.java | 25 +- + .../mixin/features/movement/MixinEntity.java | 35 +- .../movement/MixinEntityCollisionReplay.java | 54 ++ .../movement/MixinEntityFluidInteraction.java | 16 + ...tyFluidInteraction_CurrentAccumulator.java | 73 ++- @@ -724,15 +868,15 @@ physical replay scope; do not add an independent countdown. .../movement/MixinEntityPhysicsState.java | 202 +++++++ .../movement/MixinFireworkRocketEntity.java | 28 + .../movement/MixinGameModePhysicsReplay.java | 29 + - .../movement/MixinHorsePhysicsState.java | 63 +++ + .../movement/MixinHorsePhysicsState.java | 63 ++ .../movement/MixinLevelPhysicsReplay.java | 25 + - .../features/movement/MixinLivingEntity.java | 150 +++++ + .../features/movement/MixinLivingEntity.java | 200 +++++++ .../movement/MixinLivingPhysicsState.java | 145 +++++ - .../features/movement/MixinLocalPlayer.java | 384 ++++++++++++- + .../features/movement/MixinLocalPlayer.java | 413 +++++++++++++- .../movement/MixinMobEffectPhysicsState.java | 18 + .../movement/MixinParticlePhysicsReplay.java | 24 + .../movement/MixinPassengerPhysicsReplay.java | 39 ++ - .../mixin/features/movement/MixinPlayer.java | 199 ++++++- + .../mixin/features/movement/MixinPlayer.java | 215 ++++++- .../MixinPlayerCorrectionPacketListener.java | 79 +++ .../MixinPotentSulfurBlockEntity.java | 44 ++ .../movement/MixinSoundPhysicsReplay.java | 23 + @@ -740,14 +884,16 @@ physical replay scope; do not add an independent countdown. .../features/movement/MixinVoxelShape.java | 28 + .../movement/MixinWalkPhysicsState.java | 32 ++ .../bedrock/input/ActorMotionPayload.java | 22 + + .../bedrock/input/BedrockBoatInput.java | 30 + .../input/BedrockInputPermissions.java | 4 +- .../bedrock/input/BedrockMovementEffects.java | 162 ++++++ - .../bedrock/input/BedrockPhysicalReplay.java | 535 ++++++++++++++++++ + .../bedrock/input/BedrockPhysicalReplay.java | 540 ++++++++++++++++++ .../input/BedrockPlayerCorrections.java | 64 +++ .../bedrock/input/BedrockPlayerModes.java | 176 ++++++ .../bedrock/input/BedrockUsedItem.java | 23 + .../input/BedrockVehiclePredictions.java | 48 ++ .../bedrock/input/FlightMovementPayload.java | 24 + + .../bedrock/input/ItemUseStopPayload.java | 22 + .../bedrock/input/MovementEffectPayload.java | 24 + .../input/PlayerCorrectionPayload.java | 24 + .../bedrock/input/PlayerModePayload.java | 22 + @@ -756,12 +902,13 @@ physical replay scope; do not add an independent countdown. .../VehiclePredictionContextPayload.java | 24 + .../input/VehiclePredictionPayload.java | 24 + .../input/VehiclePredictionSession.java | 91 +++ + .../bedrock/movement/BedrockAirMovement.java | 31 + .../movement/BedrockClimbingPhysics.java | 43 ++ .../movement/BedrockCollisionContacts.java | 102 ++++ .../movement/BedrockFlightPhysics.java | 61 ++ .../bedrock/movement/BedrockFluidPhysics.java | 86 +++ .../movement/BedrockGeyserPhysics.java | 70 +++ - .../movement/BedrockPlayerPosture.java | 63 +++ + .../movement/BedrockPlayerPosture.java | 63 ++ .../movement/BedrockTeleportTravel.java | 35 ++ .../movement/replay/ActorSpatialPayload.java | 22 + .../replay/AttributePhysicsState.java | 84 +++ @@ -775,40 +922,44 @@ physical replay scope; do not add an independent countdown. .../movement/replay/PhysicalInput.java | 20 + .../movement/replay/PhysicalItemUse.java | 21 + .../replay/PhysicalMovementCorrection.java | 35 ++ - .../replay/PhysicalReplayContext.java | 255 +++++++++ - .../replay/PhysicalReplayHistory.java | 190 +++++++ + .../replay/PhysicalReplayContext.java | 293 ++++++++++ + .../replay/PhysicalReplayHistory.java | 213 +++++++ .../movement/replay/PhysicalState.java | 140 +++++ .../movement/replay/PhysicalStateAccess.java | 43 ++ .../bedrock/render/BedrockActorState.java | 39 +- .../viafabricplus-bedrock.accesswidener | 5 + - .../viafabricplus-bedrock.mixins.json | 26 + - .../BedrockPhysicalMotionLifecycleTest.java | 190 +++++++ + .../viafabricplus-bedrock.mixins.json | 27 + + .../bedrock/input/BedrockBoatInputTest.java | 115 ++++ + .../BedrockPhysicalMotionLifecycleTest.java | 190 ++++++ .../bedrock/input/BedrockPlayerModesTest.java | 194 +++++++ .../bedrock/input/BedrockUsedItemTest.java | 66 +++ .../input/BedrockVehiclePredictionsTest.java | 155 +++++ + .../PhysicalCorrectionSchedulingTest.java | 190 ++++++ .../input/VehiclePredictionPayloadTest.java | 75 +++ - .../input/VehiclePredictionSessionTest.java | 284 ++++++++++ + .../input/VehiclePredictionSessionTest.java | 284 +++++++++ + .../movement/BedrockAirMovementTest.java | 16 + .../movement/BedrockClimbingPhysicsTest.java | 53 ++ - .../BedrockCollisionContactsTest.java | 95 ++++ + .../BedrockCollisionContactsTest.java | 95 +++ .../movement/BedrockFlightPhysicsTest.java | 130 +++++ .../movement/BedrockFluidPhysicsTest.java | 121 ++++ .../movement/BedrockGeyserPhysicsTest.java | 101 ++++ .../movement/BedrockPlayerPostureTest.java | 45 ++ .../movement/BedrockTeleportTravelTest.java | 77 +++ .../replay/AttributePhysicsStateTest.java | 101 ++++ + .../movement/replay/BoatInputPhaseTest.java | 105 ++++ .../replay/PhysicalBoundingBoxTest.java | 21 + .../replay/PhysicalCollisionCacheTest.java | 204 +++++++ .../PhysicalCorrectionIdentityTest.java | 157 +++++ .../movement/replay/PhysicalItemUseTest.java | 41 ++ .../replay/PhysicalMotionReplayTest.java | 102 ++++ .../PhysicalMovementCorrectionTest.java | 80 +++ - .../replay/PhysicalReplayContextTest.java | 90 +++ - .../replay/PhysicalReplayHistoryTest.java | 272 +++++++++ + .../replay/PhysicalReplayContextTest.java | 114 ++++ + .../replay/PhysicalReplayHistoryTest.java | 315 ++++++++++ .../movement/replay/PhysicalStateTest.java | 124 ++++ .../replay/PhysicalWorldCollisionTest.java | 111 ++++ .../render/PhysicalColliderSourcesTest.java | 151 +++++ .../render/PhysicalReplayLifetimeTest.java | 151 +++++ - 106 files changed, 8435 insertions(+), 74 deletions(-) + 115 files changed, 9256 insertions(+), 72 deletions(-) create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockFlightMotion.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockPlayerPosture.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockTeleportTravel.java @@ -819,6 +970,7 @@ physical replay scope; do not add an independent countdown. create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinClientLevelPhysicsReplay.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinClientPlayerGameModePhysicsReplay.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinConnectionPhysicsReplay.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinConsumable.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinElytraPhysicsState.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinEntityCollisionReplay.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinEntityFluidPhysicsState.java @@ -839,6 +991,7 @@ physical replay scope; do not add an independent countdown. create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinVoxelShape.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinWalkPhysicsState.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/input/ActorMotionPayload.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/input/BedrockBoatInput.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/input/BedrockMovementEffects.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/input/BedrockPhysicalReplay.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/input/BedrockPlayerCorrections.java @@ -846,6 +999,7 @@ physical replay scope; do not add an independent countdown. create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/input/BedrockUsedItem.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/input/BedrockVehiclePredictions.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/input/FlightMovementPayload.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/input/ItemUseStopPayload.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/input/MovementEffectPayload.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/input/PlayerCorrectionPayload.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/input/PlayerModePayload.java @@ -854,6 +1008,7 @@ physical replay scope; do not add an independent countdown. create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/input/VehiclePredictionContextPayload.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/input/VehiclePredictionPayload.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/input/VehiclePredictionSession.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/movement/BedrockAirMovement.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/movement/BedrockClimbingPhysics.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/movement/BedrockCollisionContacts.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/movement/BedrockFlightPhysics.java @@ -877,12 +1032,15 @@ physical replay scope; do not add an independent countdown. create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalReplayHistory.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalState.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalStateAccess.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/input/BedrockBoatInputTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/input/BedrockPhysicalMotionLifecycleTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/input/BedrockPlayerModesTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/input/BedrockUsedItemTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/input/BedrockVehiclePredictionsTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/input/PhysicalCorrectionSchedulingTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/input/VehiclePredictionPayloadTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/input/VehiclePredictionSessionTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockAirMovementTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockClimbingPhysicsTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockCollisionContactsTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockFlightPhysicsTest.java @@ -891,6 +1049,7 @@ physical replay scope; do not add an independent countdown. create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockPlayerPostureTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockTeleportTravelTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/AttributePhysicsStateTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/BoatInputPhaseTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalBoundingBoxTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalCollisionCacheTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalCorrectionIdentityTest.java @@ -905,10 +1064,10 @@ physical replay scope; do not add an independent countdown. create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/PhysicalReplayLifetimeTest.java diff --git a/build.gradle.kts b/build.gradle.kts -index 909ac3c..2e80f04 100644 +index ad7d3c5..a2642cd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts -@@ -22,7 +22,6 @@ tasks.withType().configureEach { +@@ -31,7 +31,6 @@ if (bedrockOreUiShaper.isPresent) { } dependencies { @@ -916,7 +1075,7 @@ index 909ac3c..2e80f04 100644 implementation(viaFabricPlusDependency) // Optional integration only. Iris supplies these classes when installed. compileOnly("maven.modrinth:iris:vTN4NRGW") -@@ -30,6 +29,7 @@ dependencies { +@@ -39,6 +38,7 @@ dependencies { testImplementation("maven.modrinth:iris:vTN4NRGW") testImplementation("io.github.douira:glsl-transformer:3.0.0-pre3") testImplementation("org.lwjgl:lwjgl-egl:3.4.3") @@ -925,7 +1084,7 @@ index 909ac3c..2e80f04 100644 jarInJar("org.redlance.mochafloats:runtime:6.1.0") { exclude(group = "io.netty") diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java b/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java -index 6d7f012..5765f62 100644 +index 6d7f012..5b2869e 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java @@ -27,6 +27,7 @@ import com.viaversion.viafabricplus.bedrock.appearance.BedrockSkinChange; @@ -936,7 +1095,15 @@ index 6d7f012..5765f62 100644 import com.viaversion.viafabricplus.bedrock.render.BedrockPlayerSkins; import com.viaversion.viafabricplus.bedrock.render.NativeSkinPayload; import com.viaversion.viafabricplus.bedrock.render.NativeCustomEntityPayload; -@@ -81,6 +82,10 @@ import com.viaversion.viafabricplus.bedrock.hud.BedrockHudVisibility; +@@ -63,6 +64,7 @@ import com.viaversion.viafabricplus.bedrock.inventory.BedrockCartographyContext; + import com.viaversion.viafabricplus.bedrock.inventory.CartographyPayload; + import com.viaversion.viafabricplus.bedrock.input.BedrockInputPermissions; + import com.viaversion.viafabricplus.bedrock.input.InputPermissionsPayload; ++import com.viaversion.viafabricplus.bedrock.input.ItemUseStopPayload; + import com.viaversion.viafabricplus.bedrock.hud.HudVisibilityPayload; + import com.viaversion.viafabricplus.bedrock.camera.CameraFadePayload; + import com.viaversion.viafabricplus.bedrock.camera.BedrockCameraFade; +@@ -81,6 +83,10 @@ import com.viaversion.viafabricplus.bedrock.hud.BedrockHudVisibility; import com.viaversion.viafabricplus.bedrock.hud.BedrockLocatorBar; import com.viaversion.viafabricplus.bedrock.hud.LocatorBarPayload; import com.viaversion.viafabricplus.bedrock.input.RawInputPayload; @@ -947,7 +1114,7 @@ index 6d7f012..5765f62 100644 import com.viaversion.viafabricplus.bedrock.appearance.BedrockAppearanceStore; import com.viaversion.viafabricplus.bedrock.appearance.ViaFabricPlusBuiltinResourcePackProvider; import com.viaversion.viaversion.api.Via; -@@ -156,6 +161,11 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { +@@ -156,6 +162,11 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { ClientPlayConnectionEvents.INIT.register((listener, client) -> ClientPlayNetworking.registerReceiver(NativeActorBoundsPayload.TYPE, (payload, context) -> BedrockActorState.accept(listener.getConnection(), payload.message()))); @@ -959,7 +1126,7 @@ index 6d7f012..5765f62 100644 PayloadTypeRegistry.clientboundPlay().register(NativeAnimationPayload.TYPE, NativeAnimationPayload.CODEC); ClientPlayConnectionEvents.INIT.register((listener, client) -> ClientPlayNetworking.registerReceiver(NativeAnimationPayload.TYPE, -@@ -172,6 +182,10 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { +@@ -172,6 +183,18 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { ClientPlayNetworking.registerGlobalReceiver(NativeParticlePlaybackPayload.TYPE, (payload, context) -> BedrockServerParticles.accept(payload.message())); PayloadTypeRegistry.serverboundPlay().register(RawInputPayload.TYPE, RawInputPayload.CODEC); @@ -967,9 +1134,40 @@ index 6d7f012..5765f62 100644 + BedrockPlayerCorrections.register(); + BedrockMovementEffects.register(); + BedrockVehiclePredictions.register(); ++ PayloadTypeRegistry.clientboundPlay().register(ItemUseStopPayload.TYPE, ItemUseStopPayload.CODEC); ++ ClientPlayConnectionEvents.INIT.register((listener, client) -> ++ ClientPlayNetworking.registerReceiver(ItemUseStopPayload.TYPE, (payload, context) -> { ++ if (context.client().getConnection() == listener && BedrockInputPermissions.isBedrockConnection()) { ++ // Server completion stops the current cycle. Local countdown owns food prediction and feedback. ++ context.player().stopUsingItem(); ++ } ++ })); PayloadTypeRegistry.clientboundPlay().register(InputPermissionsPayload.TYPE, InputPermissionsPayload.CODEC); ClientPlayNetworking.registerGlobalReceiver(InputPermissionsPayload.TYPE, (payload, context) -> BedrockInputPermissions.accept(payload.permissions())); +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockNativeBlocks.java b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockNativeBlocks.java +index 6e47d33..f16b4a1 100644 +--- a/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockNativeBlocks.java ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockNativeBlocks.java +@@ -54,6 +54,18 @@ public final class BedrockNativeBlocks { + }); + } + ++ /** Immutable accepted world settings for this level, or null before its native reset. */ ++ public static NativeBlockIdentifiers.Reset worldSettings() { ++ synchronizeLevel(Minecraft.getInstance()); ++ return VIEW.world(); ++ } ++ ++ /** Exact accepted StartGame version for this level, or null before its native reset. */ ++ public static String baseGameVersion() { ++ final var world = worldSettings(); ++ return world == null ? null : world.baseGameVersion(); ++ } ++ + /** Exact native runtime identity and properties for either block layer on the client thread. */ + public static NativeBlockIdentifiers.Name state(final int x, final int y, final int z, final int layer) { + synchronizeLevel(Minecraft.getInstance()); diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockFlightMotion.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockFlightMotion.java new file mode 100644 index 0000000..c4cda67 @@ -1106,10 +1304,10 @@ index 0000000..e2f91b4 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinBoatPhysicsState.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinBoatPhysicsState.java new file mode 100644 -index 0000000..9f45829 +index 0000000..cd77f94 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinBoatPhysicsState.java -@@ -0,0 +1,79 @@ +@@ -0,0 +1,88 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -1117,6 +1315,7 @@ index 0000000..9f45829 + */ +package com.viaversion.viafabricplus.bedrock.injection.mixin.features.movement; + ++import com.viaversion.viafabricplus.bedrock.input.BedrockBoatInput; +import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalState; +import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalStateAccess; +import net.minecraft.network.syncher.EntityDataAccessor; @@ -1124,6 +1323,9 @@ index 0000000..9f45829 +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; ++import org.spongepowered.asm.mixin.injection.At; ++import org.spongepowered.asm.mixin.injection.Inject; ++import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(AbstractBoat.class) +public abstract class MixinBoatPhysicsState implements PhysicalStateAccess.Boat { @@ -1148,6 +1350,11 @@ index 0000000..9f45829 + @Shadow @Final private static EntityDataAccessor DATA_ID_PADDLE_RIGHT; + @Shadow @Final private static EntityDataAccessor DATA_ID_BUBBLE_TIME; + ++ @Inject(method = "tick", at = @At("HEAD")) ++ private void gatherPassengerInputBeforeVehicleMovement(final CallbackInfo ci) { ++ BedrockBoatInput.beforeTick((AbstractBoat) (Object) this); ++ } ++ + @Override + public void captureBoatPhysics(final PhysicalState.Builder state) { + final var data = ((AbstractBoat) (Object) this).getEntityData(); @@ -1320,6 +1527,53 @@ index 0000000..683d816 + if (PhysicalReplayContext.suppressesNetwork((Connection) (Object) this)) ci.cancel(); + } +} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinConsumable.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinConsumable.java +new file mode 100644 +index 0000000..297002c +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinConsumable.java +@@ -0,0 +1,41 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.injection.mixin.features.movement; ++ ++import com.llamalad7.mixinextras.injector.v2.WrapWithCondition; ++import com.llamalad7.mixinextras.injector.wrapoperation.Operation; ++import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; ++import com.llamalad7.mixinextras.sugar.Local; ++import com.viaversion.viafabricplus.bedrock.input.BedrockInputPermissions; ++import net.minecraft.client.player.LocalPlayer; ++import net.minecraft.world.entity.LivingEntity; ++import net.minecraft.world.food.FoodProperties; ++import net.minecraft.world.item.ItemStack; ++import net.minecraft.world.item.component.Consumable; ++import org.spongepowered.asm.mixin.Mixin; ++import org.spongepowered.asm.mixin.injection.At; ++ ++import java.util.stream.Stream; ++ ++@Mixin(Consumable.class) ++public abstract class MixinConsumable { ++ ++ @WrapOperation(method = "onConsume", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/item/ItemStack;getAllOfType(Ljava/lang/Class;)Ljava/util/stream/Stream;")) ++ private Stream predictNativeHungerWithoutReplacementItems(final ItemStack item, final Class type, ++ final Operation> original, @Local(argsOnly = true) final LivingEntity entity) { ++ final Stream listeners = original.call(item, type); ++ if (entity instanceof LocalPlayer && BedrockInputPermissions.isBedrockConnection()) { ++ // Native predicts hunger while retaining the old stack until server authority arrives. ++ return listeners.filter(listener -> listener instanceof FoodProperties); ++ } ++ return listeners; ++ } ++ ++ @WrapWithCondition(method = "onConsume", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/item/ItemStack;consume(ILnet/minecraft/world/entity/LivingEntity;)V")) ++ private boolean retainServerItemCount(final ItemStack item, final int amount, final LivingEntity entity) { ++ return !(entity instanceof LocalPlayer) || !BedrockInputPermissions.isBedrockConnection(); ++ } ++} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinElytraPhysicsState.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinElytraPhysicsState.java new file mode 100644 index 0000000..71edbae @@ -1363,15 +1617,24 @@ index 0000000..71edbae + } +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinEntity.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinEntity.java -index 8c9bbaa..fa13133 100644 +index 8c9bbaa..181f7af 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinEntity.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinEntity.java -@@ -25,21 +25,17 @@ import com.llamalad7.mixinextras.injector.ModifyExpressionValue; +@@ -21,25 +21,26 @@ + + package com.viaversion.viafabricplus.bedrock.injection.mixin.features.movement; + ++import com.llamalad7.mixinextras.expression.Definition; ++import com.llamalad7.mixinextras.expression.Expression; + import com.llamalad7.mixinextras.injector.ModifyExpressionValue; ++import com.llamalad7.mixinextras.sugar.Local; import com.llamalad7.mixinextras.injector.ModifyReturnValue; import com.viaversion.viafabricplus.ViaFabricPlus; import com.viaversion.viafabricplus.bedrock.input.BedrockInputPermissions; -import com.viaversion.viaversion.api.connection.UserConnection; +import com.viaversion.viafabricplus.bedrock.movement.BedrockFluidPhysics; ++import com.viaversion.viafabricplus.bedrock.movement.BedrockAirMovement; ++import net.minecraft.client.player.LocalPlayer; import net.minecraft.world.entity.Entity; import net.minecraft.world.phys.AABB; import net.minecraft.world.phys.Vec3; @@ -1389,7 +1652,7 @@ index 8c9bbaa..fa13133 100644 @Mixin(Entity.class) public abstract class MixinEntity { -@@ -47,21 +43,6 @@ public abstract class MixinEntity { +@@ -47,19 +48,13 @@ public abstract class MixinEntity { @Shadow protected Vec3 stuckSpeedMultiplier; @@ -1406,12 +1669,17 @@ index 8c9bbaa..fa13133 100644 - if (connection != null && swimming != this.isSwimming()) { - connection.get(EntityTracker.class).getClientPlayer().addAuthInputData(swimming ? PlayerAuthInputData.StartSwimming : PlayerAuthInputData.StopSwimming); - } -- } -- ++ @Definition(id = "movementLength", local = @Local(type = double.class, ordinal = 0)) ++ @Expression("movementLength > ?") ++ @ModifyExpressionValue(method = "move", at = @At("MIXINEXTRAS:EXPRESSION"), require = 1, allow = 1) ++ private boolean retainNativeSmallDisplacement(final boolean aboveCutoff, @Local(ordinal = 0) final double movementLength) { ++ // Collision has already resolved the request. Java's length gate must not erase native drift. ++ return aboveCutoff || (Object) this instanceof LocalPlayer player ++ && BedrockAirMovement.applies(player) && movementLength > 0D; + } + @Redirect(method = "makeStuckInBlock", at = @At(value = "FIELD", target = "Lnet/minecraft/world/entity/Entity;stuckSpeedMultiplier:Lnet/minecraft/world/phys/Vec3;", opcode = Opcodes.PUTFIELD)) - private void prioritySlowestMovementMultiplier(final Entity instance, final Vec3 value) { - if (ViaFabricPlus.api().targetVersion().equals(BedrockProtocolVersion.BEDROCK_LATEST) && this.stuckSpeedMultiplier != Vec3.ZERO) { -@@ -77,11 +58,11 @@ public abstract class MixinEntity { +@@ -77,11 +72,11 @@ public abstract class MixinEntity { return BedrockInputPermissions.isBedrockConnection() ? null : vehicle; } @@ -2089,10 +2357,10 @@ index 0000000..b6ee81c + } +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinLivingEntity.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinLivingEntity.java -index cb89991..1e49806 100644 +index cb89991..5081b26 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinLivingEntity.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinLivingEntity.java -@@ -21,27 +21,177 @@ +@@ -21,27 +21,227 @@ package com.viaversion.viafabricplus.bedrock.injection.mixin.features.movement; @@ -2106,6 +2374,7 @@ index cb89991..1e49806 100644 +import com.viaversion.viafabricplus.bedrock.input.BedrockMovementEffects; +import com.viaversion.viafabricplus.bedrock.input.BedrockUsedItem; +import com.viaversion.viafabricplus.bedrock.movement.BedrockGeyserPhysics; ++import com.viaversion.viafabricplus.bedrock.movement.BedrockAirMovement; +import com.viaversion.viafabricplus.bedrock.movement.BedrockClimbingPhysics; +import com.viaversion.viafabricplus.bedrock.movement.BedrockFlightPhysics; +import com.viaversion.viafabricplus.bedrock.injection.access.IBedrockFlightMotion; @@ -2129,6 +2398,7 @@ index cb89991..1e49806 100644 +import org.objectweb.asm.Opcodes; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Shadow; ++import org.spongepowered.asm.mixin.Unique; import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Constant; import org.spongepowered.asm.mixin.injection.Inject; @@ -2140,6 +2410,15 @@ index cb89991..1e49806 100644 @Mixin(LivingEntity.class) public abstract class MixinLivingEntity { ++ @Shadow ++ protected abstract void completeUsingItem(); ++ ++ @Shadow ++ protected abstract void updateUsingItem(ItemStack item); ++ ++ @Unique ++ private ItemStack viaFabricPlusBedrock$pendingUseTick; ++ + @ModifyReturnValue(method = "handleOnClimbable", at = @At("RETURN")) + private Vec3 applyNativeClimbingBeforeMovement(final Vec3 javaMotion, final Vec3 requestedMotion) { + if ((Object) this instanceof LocalPlayer player && BedrockClimbingPhysics.applies(player)) { @@ -2175,8 +2454,9 @@ index cb89991..1e49806 100644 + @Definition(id = "horizontalDistanceSqr", method = "Lnet/minecraft/world/phys/Vec3;horizontalDistanceSqr()D") + @Expression({"abs(?) < ?", "?.horizontalDistanceSqr() < ?"}) + @ModifyExpressionValue(method = "aiStep", at = @At("MIXINEXTRAS:EXPRESSION"), require = 4, allow = 4) -+ private boolean deferNativeFlightMotionCutoff(final boolean belowCutoff) { -+ return belowCutoff && !((Object) this instanceof LocalPlayer player && BedrockFlightPhysics.applies(player)); ++ private boolean deferNativeMotionCutoff(final boolean belowCutoff) { ++ return belowCutoff && !((Object) this instanceof LocalPlayer player ++ && (BedrockFlightPhysics.applies(player) || BedrockAirMovement.applies(player))); + } + + @ModifyExpressionValue(method = "aiStep", at = @At(value = "FIELD", target = "Lnet/minecraft/world/entity/LivingEntity;jumping:Z", opcode = Opcodes.GETFIELD), require = 1, allow = 1) @@ -2191,13 +2471,14 @@ index cb89991..1e49806 100644 + } + + @WrapOperation(method = "travelInAir", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/entity/LivingEntity;handleRelativeFrictionAndCalculateMovement(Lnet/minecraft/world/phys/Vec3;F)Lnet/minecraft/world/phys/Vec3;")) -+ private Vec3 captureNativeFlightCollisionMotion(final LivingEntity entity, final Vec3 input, final float friction, final Operation original) { ++ private Vec3 captureNativeCollisionMotion(final LivingEntity entity, final Vec3 input, final float friction, final Operation original) { + final Vec3 motion = original.call(entity, input, friction); + if (entity instanceof LocalPlayer player && BedrockFlightPhysics.applies(player)) { + // Flying players bypass liquid travel, so this is the motion after move and before Java gravity/friction. + ((IBedrockFlightMotion) player).viaFabricPlusBedrock$captureFlightMotion(motion, friction); + } -+ return motion; ++ return entity instanceof LocalPlayer player && BedrockAirMovement.applies(player) ++ ? BedrockAirMovement.afterCollision(motion) : motion; + } + @Shadow @@ -2255,6 +2536,34 @@ index cb89991..1e49806 100644 + ? BedrockUsedItem.matches(held, active) : original.call(held, active); + } + ++ @WrapOperation(method = "completeUsingItem", at = @At(value = "INVOKE", target = "Ljava/lang/Object;equals(Ljava/lang/Object;)Z")) ++ private boolean retainNativeCompletionIdentity(final Object active, final Object held, final Operation original) { ++ // An authoritative refresh can replace the stack before its completion packet in the same frame. ++ return (Object) this instanceof LocalPlayer && BedrockInputPermissions.isBedrockConnection() ++ && active instanceof ItemStack activeItem && held instanceof ItemStack heldItem ++ ? BedrockUsedItem.matches(heldItem, activeItem) : original.call(active, held); ++ } ++ ++ @WrapOperation(method = "updatingUsingItem", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/entity/LivingEntity;updateUsingItem(Lnet/minecraft/world/item/ItemStack;)V")) ++ private void retainItemUseThroughMovement(final LivingEntity entity, final ItemStack item, final Operation original) { ++ if (entity instanceof LocalPlayer && BedrockInputPermissions.isBedrockConnection()) { ++ // Identity checks still run now. Native movement uses the active state before countdown completion. ++ this.viaFabricPlusBedrock$pendingUseTick = item; ++ } else { ++ original.call(entity, item); ++ } ++ } ++ ++ @Inject(method = "tick", at = @At("RETURN")) ++ private void tickNativeUseAfterMovement(final CallbackInfo ci) { ++ final ItemStack item = this.viaFabricPlusBedrock$pendingUseTick; ++ this.viaFabricPlusBedrock$pendingUseTick = null; ++ if (item != null && (Object) this instanceof LocalPlayer player ++ && player.isUsingItem() && player.getUseItem() == item) { ++ this.updateUsingItem(item); ++ } ++ } ++ + @WrapOperation(method = "updateUsingItem", at = @At(value = "FIELD", target = "Lnet/minecraft/world/entity/LivingEntity;useItemRemaining:I", opcode = Opcodes.PUTFIELD)) + private void boundNativeItemUseDuration(final LivingEntity entity, final int remaining, final Operation original) { + // Native decrements positive duration only; delayed completion never makes it negative. @@ -2262,6 +2571,15 @@ index cb89991..1e49806 100644 + ? Math.max(0, remaining) : remaining); + } + ++ @Inject(method = "updateUsingItem", at = @At("RETURN")) ++ private void completeNativeLocalUse(final ItemStack item, final CallbackInfo ci) { ++ if ((Object) this instanceof LocalPlayer player && BedrockInputPermissions.isBedrockConnection() ++ && player.getUseItemRemainingTicks() == 0 && !item.useOnRelease()) { ++ // Native Actor::tick completes at zero, before any server inventory or packet 142 arrives. ++ this.completeUsingItem(); ++ } ++ } ++ + @ModifyConstant(method = "updateSwimAmount", constant = @Constant(floatValue = 0.09F), require = 2) + private float useNativeSwimmingBlendStep(final float step) { + return BedrockInputPermissions.isBedrockConnection() ? 0.1F : step; @@ -2422,10 +2740,10 @@ index 0000000..3cad3f3 + } +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinLocalPlayer.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinLocalPlayer.java -index 0e466e5..59c993f 100644 +index 0e466e5..64b243a 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinLocalPlayer.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinLocalPlayer.java -@@ -21,22 +21,402 @@ +@@ -21,22 +21,431 @@ package com.viaversion.viafabricplus.bedrock.injection.mixin.features.movement; @@ -2498,10 +2816,36 @@ index 0e466e5..59c993f 100644 + + @WrapWithCondition(method = "onSyncedDataUpdated", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/player/LocalPlayer;stopUsingItem()V")) + private boolean retainNativeLocalUseStop(final LocalPlayer player) { -+ // Native active use belongs to the local cycle, separately from ActorDataFlagComponent. ++ // Native reconciles ActorDataFlagComponent in its tick system, after packet processing. + return !BedrockInputPermissions.isBedrockConnection(); + } + ++ @Inject(method = "tick", at = @At("HEAD")) ++ private void reconcileNativeUseFlag(final CallbackInfo ci) { ++ if (!BedrockInputPermissions.isBedrockConnection() || PhysicalReplayContext.replaying()) return; ++ final byte flags = this.entityData.get(DATA_LIVING_ENTITY_FLAGS); ++ if ((flags & 1) != 0 && !this.startedUsingItem) { ++ this.startUsingItem((flags & 2) != 0 ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND); ++ } else if ((flags & 1) == 0 && this.startedUsingItem) { ++ this.stopUsingItem(); ++ } ++ } ++ ++ @Inject(method = "startUsingItem", at = @At("RETURN")) ++ private void publishNativeLocalUseStart(final InteractionHand hand, final CallbackInfo ci) { ++ if (BedrockInputPermissions.isBedrockConnection() && !PhysicalReplayContext.replaying() && this.startedUsingItem) { ++ this.setLivingEntityFlag(1, true); ++ this.setLivingEntityFlag(2, this.usingItemHand == InteractionHand.OFF_HAND); ++ } ++ } ++ ++ @Inject(method = "stopUsingItem", at = @At("RETURN")) ++ private void publishNativeLocalUseStop(final CallbackInfo ci) { ++ if (BedrockInputPermissions.isBedrockConnection() && !PhysicalReplayContext.replaying()) { ++ this.setLivingEntityFlag(1, false); ++ } ++ } ++ + @Shadow + public ClientInput input; + @@ -2727,9 +3071,12 @@ index 0e466e5..59c993f 100644 + @WrapMethod(method = "aiStep") + private void useRecordedItemMovementState(final Operation original) { + if (!PhysicalReplayContext.replaying()) { -+ if (PhysicalReplayContext.recording()) PhysicalReplayContext.recordItemUse((LocalPlayer) (Object) this, -+ new PhysicalItemUse(this.startedUsingItem, this.usingItemHand, this.useItemRemaining, -+ (byte) (this.entityData.get(DATA_LIVING_ENTITY_FLAGS) & 3), this.useItem)); ++ if (PhysicalReplayContext.recording()) { ++ PhysicalReplayContext.recordItemUse((LocalPlayer) (Object) this, ++ new PhysicalItemUse(this.startedUsingItem, this.usingItemHand, this.useItemRemaining, ++ (byte) (this.entityData.get(DATA_LIVING_ENTITY_FLAGS) & 3), this.useItem)); ++ PhysicalReplayContext.recordFoodEligibility(this.getFoodData()); ++ } + original.call(); + return; + } @@ -2930,10 +3277,10 @@ index 0000000..3dda60f + } +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinPlayer.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinPlayer.java -index 8d10241..59d6646 100644 +index 8d10241..9772b7c 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinPlayer.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinPlayer.java -@@ -21,37 +21,114 @@ +@@ -21,37 +21,131 @@ package com.viaversion.viafabricplus.bedrock.injection.mixin.features.movement; @@ -2943,10 +3290,14 @@ index 8d10241..59d6646 100644 import com.llamalad7.mixinextras.injector.v2.WrapWithCondition; -import com.llamalad7.mixinextras.sugar.Local; -import com.viaversion.viafabricplus.ViaFabricPlus; ++import com.llamalad7.mixinextras.injector.wrapoperation.Operation; ++import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import com.viaversion.viafabricplus.bedrock.input.BedrockMovementEffects; +import com.viaversion.viafabricplus.bedrock.injection.access.IBedrockFlightMotion; +import com.viaversion.viafabricplus.bedrock.movement.BedrockCollisionContacts; +import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalState; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalReplayContext; ++import net.minecraft.world.food.FoodData; +import net.minecraft.world.entity.ai.attributes.Attributes; +import net.minecraft.util.Mth; +import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalStateAccess; @@ -2983,14 +3334,27 @@ index 8d10241..59d6646 100644 -public abstract class MixinPlayer extends Avatar { +public abstract class MixinPlayer extends Avatar implements IBedrockFlightMotion, PhysicalStateAccess.Player { + -+ @Unique -+ private Vec3 viaFabricPlusBedrock$flightMotion; ++ @WrapOperation(method = "hasEnoughFoodToDoExhaustiveManoeuvres", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/food/FoodData;hasEnoughFood()Z")) ++ private boolean retainHistoricalFoodEligibility(final FoodData food, final Operation original) { ++ final boolean live = original.call(food); ++ return (Object) this instanceof LocalPlayer && BedrockInputPermissions.isBedrockConnection() ++ ? PhysicalReplayContext.foodEligibility(food, live) : live; ++ } + -+ @Unique -+ private float viaFabricPlusBedrock$flightFriction; ++ @WrapWithCondition(method = "handleEntityEvent", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/entity/player/Player;completeUsingItem()V")) ++ private boolean retainLocalUseAcrossServerAcknowledgment(final Player player) { ++ // Native ActorUseItem is a notification. A delayed acknowledgment must not complete a newer cycle. ++ return !(player instanceof LocalPlayer) || !BedrockInputPermissions.isBedrockConnection(); ++ } @Unique - private int viaFabricPlusBedrock$ticksSinceSwimming; ++ private Vec3 viaFabricPlusBedrock$flightMotion; ++ ++ @Unique ++ private float viaFabricPlusBedrock$flightFriction; ++ ++ @Unique + private float viaFabricPlusBedrock$flightInputX; + + @Unique @@ -3055,7 +3419,7 @@ index 8d10241..59d6646 100644 @Redirect(method = "travel", at = @At(value = "INVOKE", target = "Lnet/minecraft/core/BlockPos;containing(DDD)Lnet/minecraft/core/BlockPos;")) private BlockPos modifyWaterAbovePosition(final double x, final double y, final double z) { if (BedrockInputPermissions.isBedrockConnection()) { -@@ -63,21 +140,16 @@ public abstract class MixinPlayer extends Avatar { +@@ -63,21 +157,16 @@ public abstract class MixinPlayer extends Avatar { @WrapWithCondition(method = "travel", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/entity/player/Player;setDeltaMovement(Lnet/minecraft/world/phys/Vec3;)V", ordinal = 0)) private boolean preventSwimmingMotionWhenJumping(final Player instance, final Vec3 movement) { @@ -3082,7 +3446,7 @@ index 8d10241..59d6646 100644 this.setDeltaMovement(this.getDeltaMovement().x(), 0, this.getDeltaMovement().z()); } } -@@ -88,9 +160,20 @@ public abstract class MixinPlayer extends Avatar { +@@ -88,9 +177,20 @@ public abstract class MixinPlayer extends Avatar { return instance.isSwimming(); } @@ -3106,7 +3470,7 @@ index 8d10241..59d6646 100644 instance.setDeltaMovement(instance.getDeltaMovement().x(), 0, instance.getDeltaMovement().z()); return false; } -@@ -99,12 +182,82 @@ public abstract class MixinPlayer extends Avatar { +@@ -99,12 +199,81 @@ public abstract class MixinPlayer extends Avatar { } @Redirect(method = "travel", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/entity/player/Player;setDeltaMovement(Lnet/minecraft/world/phys/Vec3;)V", ordinal = 1)) @@ -3178,7 +3542,6 @@ index 8d10241..59d6646 100644 + BedrockPlayerModes.captureAbility(player, state, PhysicsField.WALKING_SPEED, abilities.getWalkingSpeed(), abilities::setWalkingSpeed); + state.capture(PhysicsField.JUMP_TRIGGER, this.jumpTriggerTime, value -> this.jumpTriggerTime = value); + state.capture(PhysicsField.WAS_UNDERWATER, this.wasUnderwater, value -> this.wasUnderwater = value); -+ state.capture(PhysicsField.FOOD, player.getFoodData().getFoodLevel(), player.getFoodData()::setFoodLevel); + state.capture(PhysicsField.FLIGHT_MOTION, this.viaFabricPlusBedrock$flightMotion, value -> this.viaFabricPlusBedrock$flightMotion = value); + state.capture(PhysicsField.FLIGHT_FRICTION, this.viaFabricPlusBedrock$flightFriction, value -> this.viaFabricPlusBedrock$flightFriction = value); + state.capture(PhysicsField.FLIGHT_INPUT_X, this.viaFabricPlusBedrock$flightInputX, value -> this.viaFabricPlusBedrock$flightInputX = value); @@ -3188,7 +3551,7 @@ index 8d10241..59d6646 100644 + + private enum PhysicsField { + FLYING, MAY_FLY, INVULNERABLE, INSTANT_BUILD, MAY_BUILD, FLYING_SPEED, WALKING_SPEED, -+ JUMP_TRIGGER, WAS_UNDERWATER, FOOD, FLIGHT_MOTION, FLIGHT_FRICTION, FLIGHT_INPUT_X, FLIGHT_INPUT_Z, FLIGHT_PREPARED ++ JUMP_TRIGGER, WAS_UNDERWATER, FLIGHT_MOTION, FLIGHT_FRICTION, FLIGHT_INPUT_X, FLIGHT_INPUT_Z, FLIGHT_PREPARED } } @@ -3494,6 +3857,42 @@ index 0000000..d84e2a6 + @Override + public Type type() { return TYPE; } +} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/input/BedrockBoatInput.java b/src/main/java/com/viaversion/viafabricplus/bedrock/input/BedrockBoatInput.java +new file mode 100644 +index 0000000..dceb8cc +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/input/BedrockBoatInput.java +@@ -0,0 +1,30 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.input; ++ ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalReplayContext; ++import net.minecraft.client.Minecraft; ++import net.minecraft.client.player.LocalPlayer; ++import net.minecraft.world.entity.vehicle.boat.AbstractBoat; ++ ++/** Gather the controlling passenger's input before the vehicle's movement phase. */ ++public final class BedrockBoatInput { ++ private BedrockBoatInput() {} ++ ++ public static void beforeTick(final AbstractBoat boat) { ++ prepare(boat, Minecraft.getInstance().player, BedrockInputPermissions.isBedrockConnection()); ++ } ++ ++ static boolean prepare(final AbstractBoat boat, final LocalPlayer player, final boolean bedrock) { ++ if (!bedrock || player == null || !player.isAlive() || !boat.isAlive() ++ || player.level() != boat.level() || player.getVehicle() != boat || player.getRootVehicle() != boat ++ || boat.getControllingPassenger() != player || !boat.isLocalInstanceAuthoritative() ++ || !PhysicalReplayContext.pollBeforeVehicle(boat, player.input, player.input::tick)) return false; ++ final var keys = player.input.keyPresses; ++ boat.setInput(keys.left(), keys.right(), keys.forward(), keys.backward()); ++ return true; ++ } ++} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/input/BedrockInputPermissions.java b/src/main/java/com/viaversion/viafabricplus/bedrock/input/BedrockInputPermissions.java index 7b0f846..1578a6e 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/input/BedrockInputPermissions.java @@ -3686,10 +4085,10 @@ index 0000000..3f1420b +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/input/BedrockPhysicalReplay.java b/src/main/java/com/viaversion/viafabricplus/bedrock/input/BedrockPhysicalReplay.java new file mode 100644 -index 0000000..e895251 +index 0000000..57d239b --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/input/BedrockPhysicalReplay.java -@@ -0,0 +1,535 @@ +@@ -0,0 +1,540 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -3712,6 +4111,7 @@ index 0000000..e895251 +import net.minecraft.network.protocol.game.ServerboundMovePlayerPacket; +import net.minecraft.network.protocol.game.ServerboundMoveVehiclePacket; +import java.util.Objects; ++import java.util.UUID; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.player.LocalPlayer; @@ -3933,6 +4333,7 @@ index 0000000..e895251 + + private sealed interface Command permits Correction, Motion, Mode {} + private record Correction(PlayerCorrection value) implements Command {} ++ private record CorrectionGroup(UUID actor, UUID lifetime) {} + private record Motion(ActorMotionCommand value) implements Command {} + private record Mode(PlayerModeMessage value) implements Command {} + @@ -4076,13 +4477,16 @@ index 0000000..e895251 + final PhysicalGraphState frame = this.history.before(placement.frame()); + final PhysicalActorState target = frame == null ? null : frame.correctionTarget(actor, this.player, correction, this.controlLease); + if (target == null) return; ++ final var group = new CorrectionGroup(correction.uuid(), correction.lifetime()); ++ // A queued history command can change the comparison state during rewind. ++ if (placement.dirty() && !this.history.hasHistoricalCommands(group) && !target.correction().differs(correction)) return; + final long lease = this.controlLease; + this.history.schedule(placement.frame(), state -> this.controlLease == lease + && state.correctionTarget(actor, this.player, correction, this.controlLease) != null, + () -> { + PhysicalMovementCorrection.apply(actor, correction); + this.positionSync = true; -+ }, placement.pending() || placement.dirty() && target.correction().differs(correction)); ++ }, placement.dirty(), group); + } + + private void scheduleMode(final PlayerModeMessage message) { @@ -4590,6 +4994,34 @@ index 0000000..b56fdfb + return TYPE; + } +} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/input/ItemUseStopPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/input/ItemUseStopPayload.java +new file mode 100644 +index 0000000..6b67830 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/input/ItemUseStopPayload.java +@@ -0,0 +1,22 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.input; ++ ++import net.minecraft.network.RegistryFriendlyByteBuf; ++import net.minecraft.network.codec.StreamCodec; ++import net.minecraft.network.protocol.common.custom.CustomPacketPayload; ++import net.minecraft.resources.Identifier; ++ ++/** Server inventory Use completion, without the local countdown's consumption effects. */ ++public record ItemUseStopPayload() implements CustomPacketPayload { ++ public static final Type TYPE = new Type<>(Identifier.parse("viabedrock:item_use_stop")); ++ public static final StreamCodec CODEC = StreamCodec.unit(new ItemUseStopPayload()); ++ ++ @Override ++ public Type type() { ++ return TYPE; ++ } ++} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/input/MovementEffectPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/input/MovementEffectPayload.java new file mode 100644 index 0000000..7e1745e @@ -4895,6 +5327,43 @@ index 0000000..949adc6 + return Float.isFinite(fx) && Float.isFinite(fy) && Float.isFinite(fz) ? new Position3f(fx, fy, fz) : null; + } +} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/movement/BedrockAirMovement.java b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/BedrockAirMovement.java +new file mode 100644 +index 0000000..64bd1a6 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/BedrockAirMovement.java +@@ -0,0 +1,31 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.movement; ++ ++import com.viaversion.viafabricplus.bedrock.input.BedrockInputPermissions; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalReplayContext; ++import net.minecraft.client.Minecraft; ++import net.minecraft.client.player.LocalPlayer; ++import net.minecraft.world.phys.Vec3; ++import net.raphimc.viabedrock.api.model.MotionFriction; ++ ++/** Ordinary ground and air motion; liquid, glide, flight and mounted paths have separate stages. */ ++public final class BedrockAirMovement { ++ private BedrockAirMovement() { ++ } ++ ++ public static boolean applies(final LocalPlayer player) { ++ return BedrockInputPermissions.isBedrockConnection() && !player.getAbilities().flying ++ && !player.isPassenger() && !player.isFallFlying() && !player.isInWater() && !player.isInLava() ++ && PhysicalReplayContext.camera(Minecraft.getInstance().getCameraEntity()) == player; ++ } ++ ++ public static Vec3 afterCollision(final Vec3 motion) { ++ // Gravity handles Y separately. Preserve the surviving doubles until the existing friction stage. ++ return new Vec3(MotionFriction.belowCutoff((float) motion.x) ? Math.copySign(0D, motion.x) : motion.x, ++ motion.y, MotionFriction.belowCutoff((float) motion.z) ? Math.copySign(0D, motion.z) : motion.z); ++ } ++} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/movement/BedrockClimbingPhysics.java b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/BedrockClimbingPhysics.java new file mode 100644 index 0000000..14ff9c2 @@ -6178,10 +6647,10 @@ index 0000000..79ccffe +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalReplayContext.java b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalReplayContext.java new file mode 100644 -index 0000000..cd6b4f9 +index 0000000..1cf2d38 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalReplayContext.java -@@ -0,0 +1,255 @@ +@@ -0,0 +1,293 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -6190,8 +6659,10 @@ index 0000000..cd6b4f9 +package com.viaversion.viafabricplus.bedrock.movement.replay; + +import net.minecraft.world.entity.Entity; ++import net.minecraft.client.player.ClientInput; +import net.minecraft.world.entity.EquipmentSlot; +import net.minecraft.world.entity.LivingEntity; ++import net.minecraft.world.food.FoodData; +import net.minecraft.world.item.ItemStack; +import net.minecraft.network.Connection; +import net.minecraft.client.Minecraft; @@ -6199,6 +6670,7 @@ index 0000000..cd6b4f9 +import net.minecraft.world.level.GameType; +import net.raphimc.viabedrock.api.model.PlayerInputPermissions; +import net.raphimc.viabedrock.api.model.PlayerModeMessage; ++import java.util.Collections; +import java.util.EnumMap; +import java.util.IdentityHashMap; +import net.minecraft.world.level.Level; @@ -6293,6 +6765,19 @@ index 0000000..cd6b4f9 + return state.step.itemUse.get(actor); + } + ++ public static void recordFoodEligibility(final FoodData food) { ++ final PhysicalStep recording = RECORDING.get(); ++ if (recording != null) recording.foodEligibility.put(food, food.hasEnoughFood()); ++ } ++ ++ public static boolean foodEligibility(final FoodData food, final boolean live) { ++ final State state = CURRENT.get(); ++ if (state == null || state.step == null) return live; ++ final Boolean eligible = state.step.foodEligibility.get(food); ++ if (eligible == null) throw new IllegalStateException("Historical physics requested unrecorded food eligibility"); ++ return eligible; ++ } ++ + public static ItemStack equipment(final LivingEntity actor, final EquipmentSlot slot) { + final State state = CURRENT.get(); + if (state == null || state.step == null) return null; @@ -6335,7 +6820,25 @@ index 0000000..cd6b4f9 + return recording.collisions.computeIfAbsent(actor, entity -> new PhysicalCollisionCache(PhysicalCollisions.position(entity.position()), null)); + } + -+ public static void poll(final net.minecraft.client.player.ClientInput input, final Runnable livePoll) { ++ /** Gather this step's input before its controlled vehicle, then reuse it in the passenger tick. */ ++ public static boolean pollBeforeVehicle(final Entity vehicle, final ClientInput input, final Runnable livePoll) { ++ final PhysicalStep step = inputStep(); ++ if (step == null || !step.predictedActors.contains(vehicle)) return false; ++ if (step.earlyInputs.add(input)) pollValue(input, livePoll); ++ return true; ++ } ++ ++ public static void poll(final ClientInput input, final Runnable livePoll) { ++ final PhysicalStep step = inputStep(); ++ if (step == null || !step.earlyInputs.remove(input)) pollValue(input, livePoll); ++ } ++ ++ private static PhysicalStep inputStep() { ++ final State state = CURRENT.get(); ++ return state != null ? state.step : RECORDING.get(); ++ } ++ ++ private static void pollValue(final ClientInput input, final Runnable livePoll) { + final State state = CURRENT.get(); + if (state != null) { + if (state.step == null || state.poll >= state.step.inputs.size()) { @@ -6356,8 +6859,10 @@ index 0000000..cd6b4f9 + private final Set predictedActors; + private final Entity camera; + private final Map itemUse = new IdentityHashMap<>(); ++ private final Map foodEligibility = new IdentityHashMap<>(); + private final Map> equipment = new IdentityHashMap<>(); + private final java.util.List inputs = new java.util.ArrayList<>(); ++ private final Set earlyInputs = Collections.newSetFromMap(new IdentityHashMap<>()); + private final Map collisionInputs = new IdentityHashMap<>(); + private final Map collisions = new IdentityHashMap<>(); + @@ -6371,7 +6876,7 @@ index 0000000..cd6b4f9 + this.gameTime = gameTime; + this.run = run; + this.historical = historical; -+ final Set owned = java.util.Collections.newSetFromMap(new IdentityHashMap<>()); ++ final Set owned = Collections.newSetFromMap(new IdentityHashMap<>()); + owned.addAll(predictedActors); + this.predictedActors = java.util.Collections.unmodifiableSet(owned); + this.camera = camera; @@ -6389,6 +6894,7 @@ index 0000000..cd6b4f9 + try { + this.run.run(); + } finally { ++ this.earlyInputs.clear(); + this.collisions.forEach((actor, cache) -> this.collisionInputs.put(actor, cache.snapshot())); + this.collisions.clear(); + if (previous == null) RECORDING.remove(); @@ -6405,6 +6911,7 @@ index 0000000..cd6b4f9 + this.historical.run(); + if (state.poll != this.inputs.size()) throw new IllegalStateException("Historical physics skipped a recorded input poll"); + } finally { ++ this.earlyInputs.clear(); + state.step = null; + state.poll = 0; + state.collisions.clear(); @@ -6439,10 +6946,10 @@ index 0000000..cd6b4f9 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalReplayHistory.java b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalReplayHistory.java new file mode 100644 -index 0000000..68a5ee5 +index 0000000..339d033 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalReplayHistory.java -@@ -0,0 +1,190 @@ +@@ -0,0 +1,213 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -6453,9 +6960,11 @@ index 0000000..68a5ee5 +import net.raphimc.viabedrock.api.model.MovementCorrectionTiming; + +import java.util.ArrayList; ++import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.SequencedMap; ++import java.util.Set; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import java.util.function.Predicate; @@ -6475,6 +6984,7 @@ index 0000000..68a5ee5 + private final LongConsumer applyFrameCommands; + private final SequencedMap> frames = new LinkedHashMap<>(); + private final List> pending = new ArrayList<>(); ++ private final Set historicalCommandGroups = new HashSet<>(); + private Frame open; + private long completed = -1; + @@ -6534,15 +7044,30 @@ index 0000000..68a5ee5 + return this.completed; + } + ++ /** Compare new commands with the latest pre-physics state, while retaining the separate rewind baseline. */ + public S before(final long frame) { + final Frame value = this.frames.get(frame); -+ return value == null ? null : value.before; ++ return value == null ? null : value.beforePhysics; ++ } ++ ++ /** Native history command masks bypass comparisons until a replay or history reset. */ ++ public boolean hasHistoricalCommands(final Object group) { ++ return this.historicalCommandGroups.contains(group); ++ } ++ ++ private void retainGroup(final Object group) { ++ if (group != null) this.historicalCommandGroups.add(group); + } + + public boolean schedule(final long frame, final Predicate applicable, final Runnable command, final boolean dirty) { ++ return this.schedule(frame, applicable, command, dirty, null); ++ } ++ ++ public boolean schedule(final long frame, final Predicate applicable, final Runnable command, final boolean dirty, final Object group) { + final Frame target = this.frames.get(frame); + if (target != null) { + target.commands.add(new Command<>(applicable, command, dirty)); ++ if (target.after != null) this.retainGroup(group); + return true; + } + if (!this.frames.isEmpty() && frame == this.completed + 1 && this.open == null) { @@ -6588,18 +7113,21 @@ index 0000000..68a5ee5 + if (command.applicable.test(frame.before)) command.action.run(); + command.dirty = false; + }); ++ frame.beforePhysics = this.recapture.apply(frame.before); + if (frame.after != null) { + frame.steps.forEach(Runnable::run); + frame.after = this.recapture.apply(frame.after); + } + frame.dirty = false; + } ++ this.historicalCommandGroups.clear(); + return true; + } + + public void clear() { + this.frames.clear(); + this.pending.clear(); ++ this.historicalCommandGroups.clear(); + this.open = null; + this.completed = -1; + } @@ -6619,6 +7147,7 @@ index 0000000..68a5ee5 + private static final class Frame { + private final long id; + private S before; ++ private S beforePhysics; + private S after; + private final Runnable external; + private final List externalCommands = new ArrayList<>(); @@ -6629,6 +7158,7 @@ index 0000000..68a5ee5 + private Frame(final long id, final S before, final Runnable external) { + this.id = id; + this.before = before; ++ this.beforePhysics = before; + this.external = external; + } + } @@ -6918,14 +7448,15 @@ index f42de8f..8e8672c 100644 accessible field net/minecraft/client/renderer/RenderPipelines ENTITY_SNIPPET Lcom/mojang/renderpearl/api/pipeline/RenderPipeline$Snippet; diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json -index 605b96f..fab28a7 100644 +index 0bf601f..46ad9a2 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -155,12 +155,38 @@ +@@ -156,12 +156,39 @@ "features.input.MixinMouseHandler", "features.input.MixinInputPermissionConnection", "features.input.MixinMinecraftInputPermissions", + "features.movement.MixinAvatar", ++ "features.movement.MixinConsumable", "features.movement.MixinEntity", + "features.movement.MixinEntityPhysicsState", + "features.movement.MixinLivingPhysicsState", @@ -6960,6 +7491,127 @@ index 605b96f..fab28a7 100644 "features.movement.MixinPlayer", "features.sound.MixinBedrockChannelAccess", "features.sound.MixinBedrockChannel", +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/input/BedrockBoatInputTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/input/BedrockBoatInputTest.java +new file mode 100644 +index 0000000..e67ba0e +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/input/BedrockBoatInputTest.java +@@ -0,0 +1,115 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.input; ++ ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalInput; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalReplayContext; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalStateAccess; ++import net.minecraft.SharedConstants; ++import net.minecraft.client.multiplayer.ClientLevel; ++import net.minecraft.client.player.ClientInput; ++import net.minecraft.client.player.LocalPlayer; ++import net.minecraft.server.Bootstrap; ++import net.minecraft.world.entity.player.Input; ++import net.minecraft.world.entity.vehicle.boat.AbstractBoat; ++import net.minecraft.world.phys.Vec2; ++import org.junit.jupiter.api.BeforeAll; ++import org.junit.jupiter.api.Test; ++ ++import java.util.List; ++import java.util.Set; ++ ++import static org.junit.jupiter.api.Assertions.*; ++import static org.mockito.Mockito.*; ++ ++final class BedrockBoatInputTest { ++ @BeforeAll ++ static void initializeGame() { ++ SharedConstants.tryDetectVersion(); ++ Bootstrap.bootStrap(); ++ } ++ ++ @Test ++ void boatReceivesFreshInputBeforeMovementAndReplaysItAfterLiveKeysChange() { ++ final var fixture = new Fixture(); ++ final var step = fixture.step(() -> { ++ assertTrue(BedrockBoatInput.prepare(fixture.boat, fixture.player, true)); ++ verify(fixture.boat).setInput(true, false, true, false); ++ PhysicalReplayContext.poll(fixture.input, fixture.input::tick); ++ }); ++ step.record(); ++ assertEquals(1, fixture.input.polls); ++ fixture.input.next = Input.EMPTY; ++ clearInvocations(fixture.boat); ++ try (final var ignored = PhysicalReplayContext.enter(fixture.level, null, Set.of(fixture.boat))) { ++ step.replay(); ++ } ++ assertEquals(1, fixture.input.polls); ++ verify(fixture.boat).setInput(true, false, true, false); ++ } ++ ++ @Test ++ void unrelatedOrInactiveVehicleLeavesThePassengerPollUntouched() { ++ final var fixture = new Fixture(); ++ assertFalse(BedrockBoatInput.prepare(fixture.boat, fixture.player, true)); ++ fixture.step(() -> { ++ assertFalse(BedrockBoatInput.prepare(fixture.boat, fixture.player, false)); ++ when(fixture.boat.getControllingPassenger()).thenReturn(null); ++ assertFalse(BedrockBoatInput.prepare(fixture.boat, fixture.player, true)); ++ when(fixture.boat.getControllingPassenger()).thenReturn(fixture.player); ++ when(fixture.boat.isLocalInstanceAuthoritative()).thenReturn(false); ++ assertFalse(BedrockBoatInput.prepare(fixture.boat, fixture.player, true)); ++ when(fixture.boat.isLocalInstanceAuthoritative()).thenReturn(true); ++ when(fixture.boat.level()).thenReturn(mock(ClientLevel.class)); ++ assertFalse(BedrockBoatInput.prepare(fixture.boat, fixture.player, true)); ++ assertEquals(0, fixture.input.polls); ++ PhysicalReplayContext.poll(fixture.input, fixture.input::tick); ++ }).record(); ++ assertEquals(1, fixture.input.polls); ++ verify(fixture.boat, never()).setInput(anyBoolean(), anyBoolean(), anyBoolean(), anyBoolean()); ++ } ++ ++ private static final class Fixture { ++ private final ClientLevel level = mock(ClientLevel.class); ++ private final LocalPlayer player = mock(LocalPlayer.class); ++ private final AbstractBoat boat = mock(AbstractBoat.class); ++ private final Inputs input = new Inputs(); ++ ++ private Fixture() { ++ this.player.input = this.input; ++ when(this.player.isAlive()).thenReturn(true); ++ when(this.player.level()).thenReturn(this.level); ++ when(this.player.getVehicle()).thenReturn(this.boat); ++ when(this.player.getRootVehicle()).thenReturn(this.boat); ++ when(this.boat.isAlive()).thenReturn(true); ++ when(this.boat.level()).thenReturn(this.level); ++ when(this.boat.getControllingPassenger()).thenReturn(this.player); ++ when(this.boat.isLocalInstanceAuthoritative()).thenReturn(true); ++ } ++ ++ private PhysicalReplayContext.PhysicalStep step(final Runnable run) { ++ return new PhysicalReplayContext.PhysicalStep(3, Set.of(this.boat), null, List.of(), run); ++ } ++ } ++ ++ private static final class Inputs extends ClientInput implements PhysicalStateAccess.Input { ++ private Input next = new Input(true, false, true, false, false, false, false); ++ private int polls; ++ ++ @Override ++ public void tick() { ++ this.polls++; ++ this.keyPresses = this.next; ++ this.moveVector = this.next == Input.EMPTY ? Vec2.ZERO : new Vec2(1F, 1F); ++ } ++ ++ @Override ++ public void restorePhysicalInput(final PhysicalInput value) { ++ this.keyPresses = value.keys(); ++ this.moveVector = new Vec2(value.strafe(), value.forward()); ++ } ++ } ++} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/input/BedrockPhysicalMotionLifecycleTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/input/BedrockPhysicalMotionLifecycleTest.java new file mode 100644 index 0000000..586a485 @@ -7589,6 +8241,202 @@ index 0000000..4f2bef0 + } + } +} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/input/PhysicalCorrectionSchedulingTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/input/PhysicalCorrectionSchedulingTest.java +new file mode 100644 +index 0000000..374d1a1 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/input/PhysicalCorrectionSchedulingTest.java +@@ -0,0 +1,190 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.input; ++ ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalActorState; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalGraphState; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalReplayHistory; ++import com.viaversion.viafabricplus.bedrock.render.BedrockActorState; ++import net.minecraft.SharedConstants; ++import net.minecraft.client.multiplayer.ClientLevel; ++import net.minecraft.client.player.LocalPlayer; ++import net.minecraft.server.Bootstrap; ++import net.raphimc.viabedrock.api.model.MovementCorrectionTiming; ++import net.raphimc.viabedrock.api.model.PlayerCorrection; ++import net.raphimc.viabedrock.protocol.model.Position2f; ++import net.raphimc.viabedrock.protocol.model.Position3f; ++import org.junit.jupiter.api.BeforeAll; ++import org.junit.jupiter.api.Test; ++import org.junit.jupiter.params.ParameterizedTest; ++import org.junit.jupiter.params.provider.EnumSource; ++import org.junit.jupiter.params.provider.ValueSource; ++import org.mockito.MockedStatic; ++ ++import java.lang.reflect.Method; ++ ++import java.util.UUID; ++ ++import static org.junit.jupiter.api.Assertions.assertEquals; ++import static org.junit.jupiter.api.Assertions.assertTrue; ++import static org.junit.jupiter.api.Assertions.assertFalse; ++import static org.mockito.Mockito.*; ++ ++final class PhysicalCorrectionSchedulingTest { ++ @BeforeAll ++ static void initializeGame() { ++ SharedConstants.tryDetectVersion(); ++ Bootstrap.bootStrap(); ++ } ++ ++ @ParameterizedTest ++ @EnumSource(CorrectionCase.class) ++ void discardsMatchingCorrectionsOnCleanHistoryWhileRetainingFutureCommands(final CorrectionCase example) throws ReflectiveOperationException { ++ try (var fixture = new Fixture()) { ++ fixture.schedule(example.tick, example.position, example.ground); ++ assertEquals(example.replay, fixture.history.needsReplay()); ++ // A discarded command must also stay absent from a later rewind. ++ fixture.history.markDirty(15); ++ assertTrue(fixture.history.replay()); ++ if (example.replay || example.tick > 105) { ++ verify(fixture.player).setPos((double) example.position, 1D, 2D); ++ } else { ++ verify(fixture.player, never()).setPos(anyDouble(), anyDouble(), anyDouble()); ++ } ++ } ++ } ++ ++ @ParameterizedTest ++ @ValueSource(longs = {100, 105, 106}) ++ void aHistoryCommandMaskRetainsMatchingCorrectionsButAnOpenFrameCommandDoesNot(final long priorTick) throws ReflectiveOperationException { ++ try (var fixture = new Fixture()) { ++ fixture.schedule(priorTick, 200.25F, false); ++ fixture.schedule(105, 200F, false); ++ assertTrue(fixture.history.replay()); ++ if (priorTick == 100) { ++ final var order = inOrder(fixture.player); ++ order.verify(fixture.player).setPos(200.25D, 1D, 2D); ++ order.verify(fixture.player).setPos(200D, 1D, 2D); ++ } else { ++ // A future command waits in history; only the current pending command runs here. ++ verify(fixture.player).setPos(priorTick == 105 ? 200.25D : 200D, 1D, 2D); ++ } ++ verify(fixture.player, times(priorTick == 100 ? 2 : 1)).setPos(anyDouble(), anyDouble(), anyDouble()); ++ } ++ } ++ ++ @Test ++ void aCompletedPendingCorrectionDoesNotRestoreTheMaskClearedByReplay() throws ReflectiveOperationException { ++ try (var fixture = new Fixture()) { ++ fixture.schedule(105, 200.25F, false); ++ assertTrue(fixture.history.replay()); ++ fixture.history.complete(21); ++ fixture.history.begin(22); ++ fixture.schedule(106, 200F, false); ++ assertFalse(fixture.history.needsReplay()); ++ fixture.history.clear(); ++ fixture.seedHistory(); ++ fixture.schedule(105, 200F, false); ++ assertFalse(fixture.history.needsReplay()); ++ } ++ } ++ ++ @ParameterizedTest ++ @ValueSource(booleans = {false, true}) ++ void historicalReplayClearsComparisonMasksIncludingCommandsFromTheOpenFrame(final boolean currentCommand) throws ReflectiveOperationException { ++ try (var fixture = new Fixture()) { ++ fixture.schedule(100, 200.25F, false); ++ if (currentCommand) fixture.schedule(105, 200F, false); ++ assertTrue(fixture.history.replay()); ++ fixture.history.complete(21); ++ fixture.history.begin(22); ++ clearInvocations(fixture.player); ++ fixture.schedule(106, 200F, false); ++ assertFalse(fixture.history.needsReplay()); ++ fixture.history.markDirty(22); ++ assertTrue(fixture.history.replay()); ++ verify(fixture.player, never()).setPos(anyDouble(), anyDouble(), anyDouble()); ++ } ++ } ++ ++ private static final class Fixture implements AutoCloseable { ++ private final LocalPlayer player = mock(LocalPlayer.class); ++ private final ClientLevel level = mock(ClientLevel.class); ++ private final UUID uuid = UUID.randomUUID(), lifetime = UUID.randomUUID(); ++ private final MockedStatic nativeState = mockStatic(BedrockActorState.class); ++ private final PhysicalReplayHistory history; ++ private final Object session; ++ private final Method schedule; ++ ++ private Fixture() throws ReflectiveOperationException { ++ when(this.player.level()).thenReturn(this.level); ++ when(this.player.getId()).thenReturn(1); ++ when(this.player.getUUID()).thenReturn(this.uuid); ++ this.nativeState.when(() -> BedrockActorState.liveLifetime(this.uuid)).thenReturn(this.lifetime); ++ final var values = new PhysicalActorState.CorrectionValues(new Position3f(200F, 1F, 2F), ++ new Position3f(.25F, .5F, .75F), false, new Position2f(20F, 70F), null, false, false, null); ++ final var target = mock(PhysicalActorState.class); ++ when(target.correction()).thenReturn(values); ++ final var graph = mock(PhysicalGraphState.class); ++ when(graph.correctionTarget(eq(this.player), eq(this.player), any(), anyLong())).thenReturn(target); ++ this.history = new PhysicalReplayHistory<>(8, () -> graph, state -> state, ++ state -> {}, (previous, next) -> () -> {}, state -> true, frame -> {}, frame -> {}); ++ this.seedHistory(); ++ // Keep the production scheduler and journal together; supply only the captured actor graph. ++ final var sessionType = Class.forName(BedrockPhysicalReplay.class.getName() + "$Session"); ++ final var constructor = sessionType.getDeclaredConstructor(LocalPlayer.class); ++ constructor.setAccessible(true); ++ this.session = constructor.newInstance(this.player); ++ final var historyField = sessionType.getDeclaredField("history"); ++ historyField.setAccessible(true); ++ historyField.set(this.session, this.history); ++ this.schedule = sessionType.getDeclaredMethod("scheduleCorrection", PlayerCorrection.class); ++ this.schedule.setAccessible(true); ++ } ++ ++ private void seedHistory() { ++ for (long frame = 15; frame <= 20; frame++) { ++ this.history.begin(frame); ++ this.history.complete(frame); ++ } ++ this.history.begin(21); ++ } ++ ++ private void schedule(final long tick, final float position, final boolean ground) throws ReflectiveOperationException { ++ this.schedule.invoke(this.session, new PlayerCorrection(1, this.uuid, this.lifetime, 1, -1, ++ new MovementCorrectionTiming(tick, 105, 20, 100, -1), ++ new Position3f(position, 1F, 2F), new Position3f(.25F, .5F, .75F), ++ new Position2f(20F, 70F), null, 0F, 0F, ground)); ++ } ++ ++ @Override ++ public void close() { ++ this.nativeState.close(); ++ } ++ } ++ ++ private enum CorrectionCase { ++ CURRENT_MATCH(105, 200F, false, false), ++ CURRENT_POSITION(105, 200.25F, false, true), ++ CURRENT_GROUND(105, 200F, true, true), ++ HISTORICAL_MATCH(100, 200F, false, false), ++ HISTORICAL_POSITION(100, 200.25F, false, true), ++ FUTURE_POSITION(106, 200.25F, false, false), ++ FUTURE_MATCH(106, 200F, false, false); ++ ++ private final long tick; ++ private final float position; ++ private final boolean ground; ++ private final boolean replay; ++ ++ CorrectionCase(final long tick, final float position, final boolean ground, final boolean replay) { ++ this.tick = tick; ++ this.position = position; ++ this.ground = ground; ++ this.replay = replay; ++ } ++ } ++} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/input/VehiclePredictionPayloadTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/input/VehiclePredictionPayloadTest.java new file mode 100644 index 0000000..b76bbfc @@ -7960,6 +8808,28 @@ index 0000000..eac5b9e + } + } +} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockAirMovementTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockAirMovementTest.java +new file mode 100644 +index 0000000..ba6f5ef +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockAirMovementTest.java +@@ -0,0 +1,16 @@ ++package com.viaversion.viafabricplus.bedrock.movement; ++ ++import net.minecraft.world.phys.Vec3; ++import org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++final class BedrockAirMovementTest { ++ @Test ++ void cutsEachCollisionResolvedAxisWithoutErasingSmallKnockbackOrVerticalMotion() { ++ final Vec3 motion = BedrockAirMovement.afterCollision(new Vec3(-0x1.0p-23F, 0.002D, 0.002D)); ++ assertEquals(Double.doubleToRawLongBits(-0D), Double.doubleToRawLongBits(motion.x)); ++ assertEquals(0.002D, motion.y); ++ assertEquals(0.002D, motion.z); ++ } ++} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockClimbingPhysicsTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockClimbingPhysicsTest.java new file mode 100644 index 0000000..b1d54e6 @@ -8731,6 +9601,117 @@ index 0000000..8a4b930 + return new AttributeModifier(Identifier.withDefaultNamespace(Integer.toString(id)), amount, AttributeModifier.Operation.ADD_VALUE); + } +} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/BoatInputPhaseTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/BoatInputPhaseTest.java +new file mode 100644 +index 0000000..8e5ce75 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/BoatInputPhaseTest.java +@@ -0,0 +1,105 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.movement.replay; ++ ++import net.minecraft.SharedConstants; ++import net.minecraft.server.Bootstrap; ++import net.minecraft.client.player.ClientInput; ++import net.minecraft.world.entity.player.Input; ++import net.minecraft.world.entity.Entity; ++import net.minecraft.world.phys.Vec2; ++import org.junit.jupiter.api.BeforeAll; ++import org.junit.jupiter.api.Test; ++ ++import java.util.List; ++import java.util.Set; ++ ++import static org.junit.jupiter.api.Assertions.*; ++import static org.mockito.Mockito.mock; ++ ++class BoatInputPhaseTest { ++ @BeforeAll ++ static void initializeGame() { ++ SharedConstants.tryDetectVersion(); ++ Bootstrap.bootStrap(); ++ } ++ ++ @Test ++ void vehicleAndPassengerShareOnePollAndReplayUsesTheRecordedInput() { ++ final var input = new Inputs(); ++ final var boat = mock(Entity.class); ++ input.next = new Vec2(0F, 1F); ++ final var step = new PhysicalReplayContext.PhysicalStep(3, Set.of(boat), null, List.of(), () -> { ++ assertTrue(PhysicalReplayContext.pollBeforeVehicle(boat, input, input::tick)); ++ assertTrue(PhysicalReplayContext.pollBeforeVehicle(boat, input, input::tick)); ++ assertEquals(new Vec2(0F, 1F), input.getMoveVector()); ++ PhysicalReplayContext.poll(input, input::tick); ++ assertEquals(new Vec2(0F, 1F), input.getMoveVector()); ++ }); ++ step.record(); ++ assertEquals(1, input.polls); ++ input.next = new Vec2(-1F, 0F); ++ try (var ignored = PhysicalReplayContext.enter(null, null, Set.of(boat))) { ++ step.replay(); ++ step.replay(); ++ } ++ assertEquals(1, input.polls); ++ PhysicalReplayContext.poll(input, input::tick); ++ assertEquals(2, input.polls); ++ assertEquals(new Vec2(-1F, 0F), input.getMoveVector()); ++ } ++ ++ @Test ++ void earlyPollDoesNotSuppressTheNextStepWhenThePassengerTickIsAbsent() { ++ final var input = new Inputs(); ++ final var boat = mock(Entity.class); ++ input.next = new Vec2(0F, 1F); ++ final var vehicleOnly = new PhysicalReplayContext.PhysicalStep(3, Set.of(boat), null, List.of(), ++ () -> assertTrue(PhysicalReplayContext.pollBeforeVehicle(boat, input, input::tick))); ++ vehicleOnly.record(); ++ input.next = new Vec2(1F, 0F); ++ final var next = new PhysicalReplayContext.PhysicalStep(4, Set.of(boat), null, List.of(), ++ () -> PhysicalReplayContext.poll(input, input::tick)); ++ next.record(); ++ assertEquals(2, input.polls); ++ try (var ignored = PhysicalReplayContext.enter(null, null, Set.of(boat))) { ++ vehicleOnly.replay(); ++ assertEquals(new Vec2(0F, 1F), input.getMoveVector()); ++ next.replay(); ++ assertEquals(new Vec2(1F, 0F), input.getMoveVector()); ++ } ++ assertEquals(2, input.polls); ++ } ++ ++ @Test ++ void unrecordedVehicleInputLeavesTheNormalPollAvailable() { ++ final var input = new Inputs(); ++ final var boat = mock(Entity.class); ++ assertFalse(PhysicalReplayContext.pollBeforeVehicle(boat, input, input::tick)); ++ assertEquals(0, input.polls); ++ PhysicalReplayContext.poll(input, input::tick); ++ assertEquals(1, input.polls); ++ } ++ ++ private static final class Inputs extends ClientInput implements PhysicalStateAccess.Input { ++ private Vec2 next = Vec2.ZERO; ++ private int polls; ++ ++ @Override ++ public void tick() { ++ this.polls++; ++ this.moveVector = this.next; ++ this.keyPresses = new Input(this.next.y > 0, this.next.y < 0, ++ this.next.x > 0, this.next.x < 0, false, false, false); ++ } ++ ++ @Override ++ public void restorePhysicalInput(final PhysicalInput value) { ++ this.keyPresses = value.keys(); ++ this.moveVector = new Vec2(value.strafe(), value.forward()); ++ } ++ } ++} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalBoundingBoxTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalBoundingBoxTest.java new file mode 100644 index 0000000..090e854 @@ -9374,10 +10355,10 @@ index 0000000..a4ac827 +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalReplayContextTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalReplayContextTest.java new file mode 100644 -index 0000000..bcdd69f +index 0000000..f83bc99 --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalReplayContextTest.java -@@ -0,0 +1,90 @@ +@@ -0,0 +1,114 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -9389,6 +10370,7 @@ index 0000000..bcdd69f +import net.minecraft.network.Connection; +import net.minecraft.network.protocol.PacketFlow; +import net.minecraft.world.entity.player.Input; ++import net.minecraft.world.food.FoodData; +import net.minecraft.world.phys.Vec2; +import org.junit.jupiter.api.Test; + @@ -9402,6 +10384,29 @@ index 0000000..bcdd69f + +final class PhysicalReplayContextTest { + @Test ++ void retainsHistoricalSprintEligibilityWithoutRewindingLaterMeals() { ++ final FoodData food = new FoodData(); ++ food.setFoodLevel(5); ++ final var step = new PhysicalReplayContext.PhysicalStep(44, Set.of(), null, List.of(), () -> { ++ if (PhysicalReplayContext.replaying()) { ++ assertFalse(PhysicalReplayContext.foodEligibility(food, food.hasEnoughFood())); ++ } else { ++ PhysicalReplayContext.recordFoodEligibility(food); ++ } ++ }); ++ step.record(); ++ food.setFoodLevel(10); ++ try (var ignored = PhysicalReplayContext.enter(null, null, Set.of())) { ++ step.replay(); ++ assertEquals(10, food.getFoodLevel()); ++ food.setFoodLevel(20); ++ step.replay(); ++ assertEquals(20, food.getFoodLevel()); ++ } ++ assertTrue(PhysicalReplayContext.foodEligibility(food, food.hasEnoughFood())); ++ } ++ ++ @Test + void confinesNetworkAndWorldOverridesToTheReplayThreadAndClosesOnFailure() throws Exception { + assertFalse(PhysicalReplayContext.replaying()); + final Connection owned = new Connection(PacketFlow.CLIENTBOUND); @@ -9470,10 +10475,10 @@ index 0000000..bcdd69f +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalReplayHistoryTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalReplayHistoryTest.java new file mode 100644 -index 0000000..bed506d +index 0000000..03e0df0 --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalReplayHistoryTest.java -@@ -0,0 +1,272 @@ +@@ -0,0 +1,315 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -9485,6 +10490,7 @@ index 0000000..bed506d + +import java.util.ArrayList; +import java.util.List; ++import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.function.Supplier; @@ -9494,6 +10500,44 @@ index 0000000..bed506d + +final class PhysicalReplayHistoryTest { + @Test ++ void replaysACorrectionThatRestoresThePredictionBeforeAnEarlierCorrection() { ++ final Fixture actor = new Fixture(20); ++ actor.tick(0); ++ actor.tick(1); ++ final State original = actor.capture(); ++ assertTrue(actor.history.schedule(0, ignored -> true, () -> { ++ actor.position = 100; ++ actor.motion = 10; ++ }, true)); ++ assertTrue(actor.history.replay()); ++ assertEquals(new State(119, 8), actor.capture()); ++ ++ // The correction receiver compares the incoming command with the retained pre-physics state. ++ final State incoming = new State(0, 1); ++ final boolean differs = !incoming.equals(actor.history.before(0)); ++ assertTrue(actor.history.schedule(0, ignored -> true, () -> { ++ actor.position = incoming.position(); ++ actor.motion = incoming.motion(); ++ }, differs)); ++ assertTrue(actor.history.replay()); ++ assertEquals(original, actor.capture()); ++ } ++ ++ @Test ++ void doesNotAccumulateAnAuthoritativeDeltaWhenReplayingTheSameInputAgain() { ++ final Fixture actor = new Fixture(20); ++ actor.tick(0); ++ actor.tick(1); ++ assertTrue(actor.history.schedule(0, ignored -> true, () -> actor.motion += 2, true)); ++ assertTrue(actor.history.replay()); ++ final State first = actor.capture(); ++ assertEquals(new State(5, 1), first); ++ actor.history.markDirty(0); ++ assertTrue(actor.history.replay()); ++ assertEquals(first, actor.capture()); ++ } ++ ++ @Test + void discardsCommandsAfterAuthorityReplacementWithoutRestoringOrReexecutingPhysics() { + final Fixture actor = new Fixture(20); + final boolean[] controlled = {true}; @@ -9647,8 +10691,8 @@ index 0000000..bed506d + assertTrue(graph.history.schedule(0, ignored -> true, () -> player.position = 10, true)); + assertTrue(graph.history.replay()); + assertEquals(13, player.position); -+ assertEquals(List.of(Set.of(player, oldMount), Set.of(player, nextMount), -+ Set.of(player, nextMount), Set.of(player, currentMount)), graph.refreshedLayouts); ++ assertEquals(Map.of(0L, Set.of(player, oldMount), 1L, Set.of(player, nextMount), ++ 2L, Set.of(player, currentMount)), graph.refreshedLayouts); + assertTrue(graph.history.schedule(1, ignored -> true, () -> player.position = 40, true)); + assertTrue(graph.history.replay()); + assertEquals(42, player.position); @@ -9721,18 +10765,22 @@ index 0000000..bed506d + + private static final class GraphFixture { + private final List active = new ArrayList<>(); -+ private final List> refreshedLayouts = new ArrayList<>(); ++ private final Map> refreshedLayouts = new LinkedHashMap<>(); ++ private long replayFrame; + private int restores; + private final PhysicalReplayHistory> history = new PhysicalReplayHistory<>(20, + () -> this.capture(this.active), previous -> { -+ this.refreshedLayouts.add(previous.keySet()); ++ this.refreshedLayouts.merge(this.replayFrame, previous.keySet(), (before, next) -> { ++ assertEquals(before, next, "Every capture of one historical frame must retain its own participants"); ++ return next; ++ }); + return this.capture(previous.keySet()); + }, state -> { + this.restores++; + state.forEach((actor, position) -> actor.position = position); + }, (previous, next) -> () -> next.forEach((actor, position) -> { + if (!position.equals(previous.get(actor))) actor.position = position; -+ }), state -> state.keySet().stream().allMatch(actor -> actor.alive), frame -> {}, frame -> {}); ++ }), state -> state.keySet().stream().allMatch(actor -> actor.alive), frame -> this.replayFrame = frame, frame -> {}); + + private Map capture(final java.util.Collection actors) { + return actors.stream().collect(Collectors.toUnmodifiableMap(actor -> actor, actor -> actor.position)); diff --git a/patches/viafabricplus-bedrock/upstreamable/0025-send-completed-local-physics-to-viabedrock.pr.md b/patches/viafabricplus-bedrock/upstreamable/0025-send-completed-local-physics-to-viabedrock.pr.md index 1ded7f6b..53877e8d 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0025-send-completed-local-physics-to-viabedrock.pr.md +++ b/patches/viafabricplus-bedrock/upstreamable/0025-send-completed-local-physics-to-viabedrock.pr.md @@ -18,7 +18,9 @@ Prerequisites: ## Validation and remaining limits -Regression coverage includes `BedrockPhysicalMotionLifecycleTest`, `BedrockPlayerModesTest`, `BedrockVehiclePredictionsTest`, `VehiclePredictionPayloadTest`. +Regression coverage includes `BedrockAirMovementTest`, `BedrockPhysicalMotionLifecycleTest`, `BedrockPlayerModesTest`, `BedrockVehiclePredictionsTest`, `VehiclePredictionPayloadTest`. + +Ordinary local ground and air movement defers Java's early motion cutoff, then applies the shared native per-axis cutoff after collision and before friction. Small horizontal impulses survive below Java's 0.003 threshold, and nonzero collision-resolved displacement survives Java's separate squared-length gate. Vertical motion stays separate. Original 1.26.60.5 friction instructions and captured wind-charge drift establish the boundary. Camera, connection and passenger gates remain explicit, and flight retains its separate finish stage. Liquids and gliding remain outside this ordinary-air binding. Installed direct and ViaProxy controls retain the small motion and actual horizontal steps through the cutoff, then settle with matching server positions and inventory counts. These controls do not establish sub-cutoff displacement or complete collision and trajectory parity. Water physics and player FOV share the native swim multiplier from the retained movement-effect timeline. Original 1.26.60.5 Dolphin Boost kernel controls include a zero-duration marker, which a duration-only query omits. Existing replay scope selects historical values without a second timer. Dynamic world comparisons at boost expiry remain open. @@ -26,17 +28,111 @@ Historical mode queries use the local player's recorded mode on both the movemen A controlled protocol-2225 replay uses the existing official 1.26.60.5-derived bootstrap with declared mode transitions. A bounded installed probe queries all four historical Java modes across five live phases on direct and proxy routes. The previous client disagrees in 15 of 20 cases. The corrected client keeps local mode, Creative/Spectator predicates, and pickability aligned with the historical mode, while remote predicates and live queries after each scope remain unchanged. `BedrockPlayerModesTest` checks local ownership, remote fallback, and scope lifetime separately. These controls establish query ownership, without proving native trajectories or correction timing. +Clean-history corrections compare with the latest retained state before physics, after earlier authoritative commands. +The original rewind baseline remains separate, so repeating a replay does not accumulate command effects. +The regression fails before this change when a later correction matches the original prediction and verifies restoration afterward. +Installed-client controls send two declared protocol-2225 correction packets for the same input tick through core translation, Java payload/position pairing, and physical replay. +On both direct and ViaProxy routes, the previous artifact leaves a four-block error; the fixed artifact restores the original position exactly. +A read-only client observer checks the shared target frame and the second correction's match with the original prediction. +Both routes also queue opposite corrections before one physics step, then receive a delayed correction for the preceding frame. +Retained states show that the earlier correction applies and the later pair restores the original position. The final forty completed frames stay settled. +The wire controls use synthetic server corrections; native BDS admission and native trajectories remain unverified. +This retained-state fix does not establish the cause of the reported pearl/wind rollback loop. + +Original 1.26.60.5 receiver and queue controls establish ordinary correction admission for both typed and cached state. Clean history discards matching historical and current-clock corrections. Historical command masks bypass later comparisons, while future commands remain queued without requesting replay. Pending current-clock commands acquire their mask when captured into history. Original frame capture and replay controls establish that a successful rewind clears the mask. Retained commands remain available for later rewinds, and normal frame completion does not set the cleared mask again. Scheduler regressions check these distinctions, later command application, and history reset. An installed direct control repeats a correction that matches retained state after a rewind. The corrected client keeps the intended position without replaying physics again. The control uses two declared protocol-2225 packets over an unchanged official-derived bootstrap; it does not establish native trajectories or the reported projectile loop. These bounded controls supply the history and controller boundary; they do not execute live controller construction or world physics. + +Owned BDS 1.26.60.6 water-boat controls cover forward movement, a server impulse, an authoritative vehicle teleport, dismount, and remount on both routes. +SDK water probes admit the fixture before mounted input. +Real vehicle corrections replace historical positions before physics while separate rewind baselines remain available. +Completed vehicle frame IDs advance, and the same vehicle returns under a fresh context epoch after dismount. +Horizontal client and server endpoints settle within 0.0001 blocks in the recorded quiet windows. +These controls use server mounting. Trajectories from the native client, horses, other seats, and adverse networks remain unverified. + +Original 1.26.60.5 constructors zero paddle strength and timing state. The input callbacks retain strength and release decay. +The [versioned boat definition](https://github.com/Mojang/bedrock-samples/blob/v1.26.60.5/behavior_pack/entities/boat.json) uses buoyancy 1.0 and wave movement. +Selected native callbacks differ from Java 26.3's boat control method under supplied inputs. + +Original scheduler controls place passenger input before vehicle paddle updates, then boat friction and control before buoyancy. +Rewind-kernel controls resolve the Level getter and dispatch vehicle paddle updates, boat friction, control and movement, then buoyancy gravity and float through the native replay phase. +That phase omits passenger input gathering and buoyancy timer/request construction. +The controls supply the Level's root instance and stop at system iterators. Live provider construction, entity filtering and trajectories remain unverified. + +The controlled boat receives this frame's filtered input before its movement tick. +Java 26.3 normally refreshes that input during the later passenger tick. +The physical step retains one poll and lets the passenger reuse it. Replay restores the recorded value without reading live keys. +Admission requires the local controlling passenger, the same level, local vehicle authority and ownership in the current physical step. +Regression tests cover fresh boat control, passenger reuse, repeated replay and cleanup when the passenger tick is absent. +Installed BDS water-boat controls show the old client using stale input on a press and release. +The updated direct and ViaProxy clients poll before control and match the fresh filtered input in every observed complete step. +Direct correction replay restores recorded input without another keyboard poll. Both recordings finish normally on a software GPU. +These first-person controls do not verify local-avatar rendering or native boat trajectories. +Native water requests use the actor's cell and the cell above. The concrete block reader selects the secondary layer first and falls back to primary only for air. +Coupled request and layer-reader controls cover secondary water over solid blocks and secondary non-water blocks masking primary water. +The existing surface-height helper matches the original calculation for supplied depth states. + +Dry-friction material reads use the merged block view, while shape and coefficient reads use primary blocks. +The on-ground component admits block friction. If the primary collision shape has a zero or reversed extent on any axis, the probe selects the primary block below. +The original block cache selects a per-state friction component, then the shared block component, then the legacy default. +Legacy component application stores `1.0F - configured friction`. The cache copies that value without another conversion. +Original NBT-to-cache controls accept a Float tag named `value`. A missing key or another tag type decodes to zero. +These controls supply component storage and unrelated properties. Live block-definition dispatch, property transport and world binding remain unverified. + +The inspected client registration path places generic movement before buoyancy. +Original collision-free movement controls apply the saved movement request to the bounds even after the actor's velocity changes. +The request and the later velocity must remain separate. + +Native flag setters and metadata producers preserve the three flag words in metadata order. +`LEASHED` (30) selects saved dry friction and the positive-motion branch when its version predicate permits it. The original initializer and comparison enable that branch from `1.21.90`. +`OUT_OF_CONTROL` (96) prevents the surface counter from resetting. `GLIDING` (32) does neither. + +The current native world-version parser retains wildcard and `beta`, normalizes accepted suffixes, and clears invalid versions before comparison. +Under supplied registry storage, a typed world-version component takes precedence over the default. Missing, null and wrongly typed entries use the default. +Parser controls supply regex matches, allocation and memory copying. Complete native regex and wire-decoder execution remain unverified. + +Two original boat initialization paths write saved leash friction of `0.91F` from `1.21.90`, otherwise `1.0F`, after native version selection. +Reinitialization replaces this value. The dry callback uses it while leashed, and water overrides it without changing the saved value. +Initialization selects between two level versions before applying its initialized wildcard fallback. +Missing buoyancy context uses an initialized invalid version instead. The private port keeps these defaults and the explicit sentinel flag separate. +Original StartGame and settings-copy fragments preserve the packet's version and generator through the native settings factory. +Constructor fragments copy the settings version into the first slot. Generator zero sets the second slot to `1.17.40`; other generators use wildcard. +The world-setup fragment publishes the selected version through the concrete Level getter into the typed buoyancy component. +The compiled private port matches these fragments and composed native motion across 378 controls. Focused tests retain the generator limit and preserve the coefficient across rewind. +Whole native world and actor lifecycles and normal tick integration remain outside these controls. + +The version-6 native world reset carries the original `StartGame` version text and signed generator ID through the level-bound block mirror. +Movement consumers can read both fields from `worldSettings()` without using resource cutoffs or client protocol versions. +The [core transport controls](../../viabedrock/upstreamable/0092-retain-completed-client-prediction-frames.pr.md) cover installed version-6 metadata and dictionary replacement across dimensions. + +Native frames own a concrete snapshot. Buoyancy capture retains configuration, the double-valued phase and independent copies of liquid selectors. Restore also recovers recorded float-request flags. A separate visitor saves and restores the vehicle input component. +General component history separately retains boat movement, paddle and buoyancy state. Original frame-producer and callback-list controls exercise this capture path with supplied ECS views. +Refreshing that history clears saved presence bits before recapture. Restore preserves live components absent from the saved state. +Under supplied ECS storage, the native lending kernel moves the player's replay ownership to a connected vehicle when the rider reference resolves in the same registry generation, clearing the old history. An unresolved reference creates fresh vehicle history. +Rewind controls start at the first marked frame, run it and later retained frames, then clear replay markers. Active rider selection, populated-history lifetime and correction-packet admission remain unverified. + +Original Level construction publishes one random generator through a root component. Actor reference components resolve that shared generator within the registry generation. +Coupled controls execute the original publication, reference producers, component storage, timer initialization, view membership, reference resolution and random draws. +Two actors and an intervening consumer advance one continuous stream in both provider configurations across five supplied seeds. +Ninety timer calls cover shared consumption, bobbing, mismatched generations, missing source components and missing movement markers. +These controls supply registry allocation, context fields, motion inputs and the thread-local seed draw. Full world construction and live seed initialization remain unverified. + +Eight original position-request controls construct actor bounds with float arithmetic after subtracting the vertical offset from the position. +Reset and state/motion requests produce equal bounds while retaining their separate motion behavior. +Original boat initialization writes a 0.375 vertical offset and rebuilds bounds after scaled size updates. +Coupled metadata and size controls support the [shared physical dimension calculation](../../viabedrock/upstreamable/0064-evaluate-named-actor-properties.pr.md#validation-and-remaining-limits). +Full Level and actor construction, initialization dispatch, request production and tick integration remain unverified. + +The add-on retains Java boat motion. Native motion phases, complete block-friction inputs, live random consumers, full scheduling and matching trajectories remain open. + Native and live controls establish selected movement, correction, and flight behavior. Uncommon mounts, network conditions, and mixed mechanics remain open. Fresh Bedrock 1.26.60.5 and BDS 1.26.60.6 controls cover walking and falling through Survival, Spectator, and return transitions. Native keeps larger downward motion on Spectator entry and applies flight drag; the small grounded residue clears. Java 26.3's local player-list callback instead zeros vertical motion, producing a pause on both Java routes before correction. Suppress that callback's motion write for Bedrock and keep the native boundary in `BedrockPlayerModes`. Installed direct and proxy controls check retained fall motion, the grounded boundary, and eventual landing. Both native and Java receive corrections in these controls. Packet delivery, input, camera angle, and tick phases differ, so exact trajectories and transition timing remain unverified. -Bedrock 1.26.60.5 and BDS 1.26.60.6 controls cover pearl-only, wind-only, and two pearl/wind angles. -Both current Java routes produce the expected projectiles and settle after landing. -Selected native, direct, and ViaProxy combinations also settle with 300 ms of incoming delivery delay, including combinations with held movement input. -Native packets contain movement corrections after wind impulses, so correction counts alone do not identify the reported rollback loop. -The selected moving controls place four server ticks between native uses, two on direct Java, and three through ViaProxy. -Short native input attempts do not consistently produce both throws. These controls do not establish equal trajectories or timing for individual presses. -The original persistent rollback report remains open. +Bedrock 1.26.60.5 and BDS 1.26.60.6 controls cover separate throws and pearl/wind combinations, including held movement and delayed delivery. Current native, direct and ViaProxy controls identify pearl-to-wind collisions by the server's projectile and target IDs and settle afterward. Actor creation packets resolve IDs when a spawn callback runs after movement or removal. Original packet bytes and per-direction order survive the delayed controls. + +Native and Java movement-input modes and teleport acknowledgments agree in the inspected controls. Actual air-use spacing, server admission ticks, random projectile spread and contact geometry differ, so these results do not establish equal trajectories. Native packets can also contain movement corrections after wind impulses; correction counts alone do not identify the reported loop. + +The [local projectile input correction](0038-predict-held-trapdoor-placement.pr.md#evidence-and-validation) retains native admission and continuation. The [cooldown inventory owner](../../viabedrock/deferred/0016-synchronize-glide-rockets-with-auth-input.pr.md) records its consumption and reconciliation controls. The original persistent rollback report remains unmatched. + The permanent `pearl-wind-charge-moving` case requires walking before either projectile moves the player. It also requires both uses and impacts, consumption, a wind launch, and a quiet landing. Its pearl landing must match the impact position, so walking away from the initial position cannot substitute for the teleport result. @@ -49,3 +145,38 @@ Native 1.26.60.5 cancels a timed custom food cycle when the selected definition Both definitions use the same Java paper carrier. Local use now checks the native definition before retaining or releasing the active stack. Count and unrelated component updates preserve the use cycle for the same definition. `BedrockUsedItemTest` covers these identity boundaries and Java fallback. + +Local automatic use now completes when its countdown reaches zero. +Bedrock 1.26.60.5 traces show local completion before server authority, plus a separate tick system that reconciles the using-item flag. +The client applies both paths and keeps inventory counts and replacement items server-owned. +Native predicts hunger during the local cycle. Server attributes later reconcile that prediction. +Translated completion acknowledgments do not end the local cycle, so a late acknowledgment cannot finish a newer same-item use. +Matching inventory refreshes retain the existing native definition check. + +Installed direct and ViaProxy controls consume two breads with an empty inventory refresh or suppressed completion acknowledgments. +Both routes also consume two breads when acknowledgments arrive one second late, during the next use cycle. +Early release consumes nothing. The final bread leaves an empty hand, and zero-duration custom food retains server-owned counts. +A four-second pause of all server updates also preserves local hunger prediction and repeated use attempts before the inventory update arrives. +Both routes settle at the server count and hunger after the flush. +The observer records unchanged item counts across local completion. Completed physics samples contain no active zero-counter use. +These controls use declared relay mutations and an observer that adds logging without gameplay writes. + +Correction replay reads recorded food eligibility for movement and preserves the live hunger value, including meals predicted after a recorded frame. +The focused regression repeats historical sprint checks after two later meals. +Matched native, direct, and ViaProxy controls cover walking, moving consumption, the final item, and an ordered 300 ms delay of server updates. +Native also receives corrections at use transitions, so correction counts alone do not establish a client defect. +The installed Java controls preserve hunger, saturation, use state, countdown, and item counts across actual resimulation without repeating consumption callbacks. +Correction counts and endpoint distances still differ between routes. These controls establish hunger ownership, not complete trajectory parity. + +Native 1.26.60.5 traces place the countdown decrement and completion after the frame's slowed movement. +Retain the early item-identity check and tick the active stack after movement. +The recorded moving-food controls check the final slowed frame, local completion, and correction replay. +Native also completes active use through a server inventory `Use` transaction. +Selective omissions distinguish that callback from packet 142. +A separate negotiated notification now stops the current use cycle without local consumption effects. +Native predicts hunger through feedback before local completion. The server callback skips that feedback. +An attribute-omission control leaves five hunger and seven bread after local completion and a server callback during the next cycle. + +Release packaging remains unverified because the other five native shaping libraries are unavailable locally. +The broader animation, movement timing, consumable effects, and original intermittent no-feeding report remain open. +See the [food admission evidence](../../viabedrock/deferred/0008-signal-sustained-bedrock-item-use.pr.md) for core behavior and its limits. diff --git a/patches/viafabricplus-bedrock/upstreamable/0026-apply-native-fence-geometry-in-bedrock-sessions.patch b/patches/viafabricplus-bedrock/upstreamable/0026-apply-native-fence-geometry-in-bedrock-sessions.patch index e0c4069f..16178c91 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0026-apply-native-fence-geometry-in-bedrock-sessions.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0026-apply-native-fence-geometry-in-bedrock-sessions.patch @@ -1,4 +1,4 @@ -From b8755e58a98e02e146df551afef70b2f269773d4 Mon Sep 17 00:00:00 2001 +From 598812dc16b047f77fbb7182befb7acff220d53d 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(blocks): apply native fence geometry in Bedrock sessions @@ -151,7 +151,7 @@ index 0000000..4e14218 + } +} diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json -index fab28a7..ad52238 100644 +index 46ad9a2..e33a60a 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json @@ -79,6 +79,7 @@ diff --git a/patches/viafabricplus-bedrock/upstreamable/0027-apply-authoritative-projectile-owner-changes.patch b/patches/viafabricplus-bedrock/upstreamable/0027-apply-authoritative-projectile-owner-changes.patch index 43f3c1d8..9522e6b7 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0027-apply-authoritative-projectile-owner-changes.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0027-apply-authoritative-projectile-owner-changes.patch @@ -1,4 +1,4 @@ -From c4e8d77aa6aca46a5524850584854a5cd2a91a24 Mon Sep 17 00:00:00 2001 +From 01b7c63bc71bfddc632ec7e599023e685fc38676 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(entities): apply authoritative projectile owner changes @@ -37,7 +37,7 @@ comparisons; this change does not claim full projectile action parity. create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/NativeProjectileOwnerPayload.java diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java b/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java -index 5765f62..1267a0a 100644 +index 5b2869e..b0b48fd 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java @@ -41,6 +41,8 @@ import com.viaversion.viafabricplus.bedrock.render.NativeActorColorPayload; @@ -49,7 +49,7 @@ index 5765f62..1267a0a 100644 import com.viaversion.viafabricplus.bedrock.render.NativeAnimationPayload; import com.viaversion.viafabricplus.bedrock.render.BedrockActorState; import com.viaversion.viafabricplus.bedrock.render.CustomEntityRenderStore; -@@ -131,6 +133,11 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { +@@ -132,6 +134,11 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { ClientPlayConnectionEvents.DISCONNECT.register((listener, client) -> CustomEntityRenderStore.clear()); ClientPlayConnectionEvents.DISCONNECT.register((listener, client) -> com.viaversion.viafabricplus.bedrock.render.BedrockActorGlintFrames.clear()); PayloadTypeRegistry.serverboundPlay().register(NativeActorAttackPayload.TYPE, NativeActorAttackPayload.CODEC); @@ -255,10 +255,10 @@ index 0000000..0a3e377 + } +} diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json -index ad52238..91cd497 100644 +index e33a60a..fc4fb65 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -202,7 +202,10 @@ +@@ -204,7 +204,10 @@ "features.particle.MixinBedrockParticleRenderType", "features.render.MixinBedrockHudVisibility", "features.render.MixinMinecraftHudVisibility", diff --git a/patches/viafabricplus-bedrock/upstreamable/0028-apply-native-fishing-bite-impulses.patch b/patches/viafabricplus-bedrock/upstreamable/0028-apply-native-fishing-bite-impulses.patch index 466bbc5c..f9366613 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0028-apply-native-fishing-bite-impulses.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0028-apply-native-fishing-bite-impulses.patch @@ -1,4 +1,4 @@ -From 9b024cec4804082ced28ab1cf06033a8f7249a3b Mon Sep 17 00:00:00 2001 +From 3dad5399e4076667add5e2df063362eff06c78cd 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(entities): apply native fishing feedback @@ -71,7 +71,7 @@ simulation in the parity ledger until separately verified. create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/settings/base/ParticleViewDistanceSettingTest.java diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java b/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java -index 1267a0a..e0cc7f9 100644 +index b0b48fd..b129aaa 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java @@ -42,6 +42,8 @@ import com.viaversion.viafabricplus.bedrock.render.NativeActorPickingPayload; @@ -83,7 +83,7 @@ index 1267a0a..e0cc7f9 100644 import com.viaversion.viafabricplus.bedrock.render.BedrockProjectileOwners; import com.viaversion.viafabricplus.bedrock.render.NativeAnimationPayload; import com.viaversion.viafabricplus.bedrock.render.BedrockActorState; -@@ -134,6 +136,10 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { +@@ -135,6 +137,10 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { ClientPlayConnectionEvents.DISCONNECT.register((listener, client) -> com.viaversion.viafabricplus.bedrock.render.BedrockActorGlintFrames.clear()); PayloadTypeRegistry.serverboundPlay().register(NativeActorAttackPayload.TYPE, NativeActorAttackPayload.CODEC); PayloadTypeRegistry.clientboundPlay().register(NativeProjectileOwnerPayload.TYPE, NativeProjectileOwnerPayload.CODEC); 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 4b93ca87..4549b8df 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 07ee1a99037901644f562dd0103087f5fbc2b407 Mon Sep 17 00:00:00 2001 +From 8af61c17684f5e1e5289e03bf2ad521dabf0ca7f 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 @@ -112,6 +112,18 @@ separate from inventory slots, and retain raw progress without changing item authority. Tests resolve all 39 inventory slots through real layout and cover menu reuse, reconnects, invalid conversion and progress updates. +Distinguish unregistered native controller mappings from unfinished actions. +Official 1.26.60.5 furnace, smoker and blast-furnace registries omit the six +shared clear/destroy/pane controller targets admitted here. Original mapping +and dispatcher controls emit their events without invoking a callback. +Crafting-controller handlers with those names do not belong to cooking. + +Preserve the authored component's consumption when its controller target is +unregistered, so an ignored native action cannot fall through to Java item +input. Restrict this admission to the audited consuming mappings. View and +component-owned targets keep their own contracts. Unknown, registered but +unfinished, and nonconsuming custom mappings retain ordinary controls. + Keep ordinary cooking controls for scenes with required input that is not yet implemented. Complete cursor semantics, search/navigation and the remaining authored handlers before replacing the default cooking screen. @@ -910,6 +922,409 @@ reproduce the former manufactured pointer leave and require source restoration without sending another item command. Unchanged hover does not rebuild scenes. Storage admission and intervening native controller/model work remain explicit probe boundaries; these controls do not establish complete controller parity. + +Guard resize and GUI-scale rebuilds with the same ordinary-menu fallback +used for live presentation updates. A retired presentation can be observed +before its next tick cleanup; rebuilding it must not escape through +Screen.resize. Retain the connection and exact menu guard so fallback +preserves the cursor and cannot restore a menu that has already changed. +A full-screen resize regression covers both ownership outcomes. + +Initialize the installed container hover with separate physical X and Y +input conversions from the shared viewport. Java rounds each GUI dimension +independently; drawing scale is not its input inverse on odd frame sizes. +Keep the initial hover aligned with subsequent pointer and extraction events. + +Route desktop drop keys through the authored input router, including their +matching releases. Native Bedrock 1.26.60.5 bindings admit recipe/catalog +and actual slot controls; panels and outside areas do not handle these +sources. Drop-one prefers the carried stack. Drop-all requires an empty +cursor before targeting a real slot. Retain the source chosen on press +until release to preserve modifier order and one action per hold. Keep +search, close, ownership and pending-command admission intact. + +Bind supported cooking search editors to an optional snapshot-owned core +query channel. Coalesce raw edits while recipe input or a prior query +remains pending, share monotonic request ordering, and retain newer text +when older snapshots and acknowledgments arrive. Keep one editor across +resize and retire the opening on ambiguous sends, timeout, or capability +withdrawal. Unknown controls still retain the ordinary Java screen. + +Official Bedrock 1.26.60.5 integrated-world desktop controls retain +furnace search through category switches and reopening, independently +of smoker search. Client regressions cover queued edits, gesture release, +request ordering, stale replies, timeout, typing, resize, and fallback. +These controls do not establish complete default cooking admission, +installed direct/proxy delivery, other devices, or complete pixels. + +Retain the newest local cooking edit or explicit clear in the connection +until disconnect. A menu that closes before a reply or query flush +restores this intent under the next opening's own identity and sequence. +Late results cannot release that replacement opening. Keep the query +response deadline active until both its acknowledgment and newer source +snapshot arrive, so a missing publication cannot leave inert controls. + +Retain the original ordinary menu screen across native presentation replacements. +A rejected replacement retires the currently displayed native presentation, +and a later render failure restores that same ordinary host rather than a +closed native renderer. Keep the real menu, carried stack, slots and packet +ownership intact during recovery. + +Bind the native cooking editor through its retained property name and +selection mappings. Only its own embedded clear control can clear the +field; it blocks covered inventory slots. Keep the local query visible +while an older edit waits for a reply without replacing recipe authority. + +Bedrock 1.26.60.5 desktop controls show that embedded X clears during an +active input lifetime and preserves typing, including after the first +Escape. A reopened inactive field keeps its retained text when X is +clicked. Whitespace still shows X. Screen regressions use the exported +native editor and cover these paths while a query is pending. Complete +default cooking host bindings and installed-route parity remain open. + +Keep the parsed player inventory program for repeated snapshots whose complete +authored bytes are unchanged. Snapshot revision and source data can change +without replacing resources; reparsing the entire scene graph on each update +adds avoidable client-thread allocation and decoding. Changed program bytes +still replace the resolver, and the cache belongs only to one presentation. +Retain search, collection and preview updates independently of this resource +lifetime. Regression coverage keeps authored label replacement and malformed +update rejection independent of retained query state. +Decode the incoming snapshot once per accepted update and compare against the +already retained state. Pass that state through replacement so family checks, +selection retirement and publication cannot independently repeat JSON decoding. + +Bind the current native layout increment/decrement callbacks through the +same authored event router as tab navigation. Publish the current tab, +filter and changed layout independently of controller-local retained-tab +state. Forced recipe-book layouts consume the input without a change. + +Current 1.26.60.5 registration tables identify both action hashes and +original callback execution establishes their transition rules. Screen +regressions preserve a carried stack, suppress held duplicates, and wait +for the matching profile snapshot across screen, panel and button hosts. +Other default cooking host actions and installed route parity remain open. + +Dispatch authored cursor-drop actions through the shared component event +path for player and cooking screens. Pointer input must reach the same +ordinary inventory request owner as keyboard input, exactly once per press. +Keep empty cursors, pending commands and Help transitions silent. Partial +container scopes retain ordinary screens when they require cursor drops. + +Official 1.26.60.5 player and furnace desktop controls retain nine ingots +through a blank-panel click, drop one on secondary input, and drop the +remainder on primary input. An 800-millisecond secondary hold acts once. +Screen regressions cover both buttons, keyboard delivery without a duplicate +request, empty/pending cursors and the transition into Help. Other common +source-reset handlers and full default cooking admission remain unfinished. + +Retain temporary native search-hover text admission separately from an +active editing session. Hover alone does not select the editor. A first +character starts editing, which survives pointer departure and the first +Escape. Leaving before typing retires only the temporary text admission. +Reassert SDL text ownership after widget focus changes and release it when +the editor or screen retires. + +Route authored menu and inventory exits through the native component owner. +The inventory exit callback excludes the hovered search source. An inactive +editor's Escape deselection alone does not replace the ordinary screen exit. +Official 1.26.60.5 physical furnace controls establish hovered E text entry, +hover-leave E closure, unchanged inactive Backspace, and Escape lifetimes. +Regressions retain cursor items and distinguish temporary hover from editing. +Complete held-source reset and default cooking admission remain unfinished. + +Keep cooking hover independent of the selected-source snapshot. The current +native recipe-selected binding compares the normalized row with the primary +source, not the hover source. Pointer entry and exit must not select a recipe +or erase the selection published by core. Remove the local hover-driven +selection setters and their obsolete regression expectations. + +Layout and tab callbacks clear the shared hover context instead. This stops +a stale source from restoring a bundle tooltip after the layout changes, +without replacing the selected source. Original 1.26.60.5 selected-binding +controls establish primary versus hover ownership. Screen regressions cover +empty and selected snapshots, pointer entry and exit, and unchanged model +refreshes. Full held-source reset and default cooking admission remain open. + +Route desktop primary cooking pickup through retained held-quantity ownership. +A short press keeps the ordinary pickup; an admitted hold retains its source +and starts quantity timing independently of the shared View countdown. Release +or pointer exit submits the cached displayed count. Preview source and cursor +without mutating the real menu, and wait for authoritative transfer replies. + +Retain authored button phases while quantity selection owns input. A secondary +press preserves the selection; a delivered slot release completes it even while +the initiating primary button remains held. Unmapped buttons cannot complete +another button's selection, and later releases cannot submit it twice. +Official Bedrock 1.26.60.5 physical controls retain state15 with both buttons +down, then return to state1 after secondary release while primary stays down. +Read-only state and pointer observations establish this ordering. Keep the +original source and cached count in the authoritative completion request. + +Keep local input ownership while the initial pickup frame is delayed. START +uses a newer frame and COMPLETE retains that exact authority. Share request +sequences with queries and recipes, suppress overlapping gestures, and retire +pending ownership once on timeout, transport failure, or opening replacement. + +Official Bedrock 1.26.60.5 desktop controls establish partial and full pickup +from nine-item hotbar and furnace ingredient sources and pointer-exit completion. +Original View dispatch and progress getter controls establish timer ordering, +strict displacement admission, and the source-count progress formula. Those +instruction probes supply external services and do not establish full pixels. +Physical installed controls use an authored furnace fixture; default cooking +host admission and other input modes remain separate unfinished work. + +Include the initially pressed cell in desktop drag distribution before +pointer motion. Java starts its drag set empty and otherwise omits that +cell when the first movement reaches another slot. Reuse its ordinary +drag eligibility, limits, preview arithmetic and inventory request owner. + +Official Bedrock 1.26.60.5 integrated-world input distributes nine items +across all three visited cells. Read-only live controller controls traverse +states1/5/7/1 and match the original loaded entry/exit bytes. A blank-panel +release can instead retain state7 and its operation source, so this change +does not treat every release as a general native controller reset. + +The installed Java baseline places zero/four/four with one carried item. +A real-menu regression reproduces that omission and expects three items +in every cell with an empty cursor. Broader reset dispatch, default cooking +host admission and complete native timing remain separate unfinished work. + +Negotiate incremental ordinary-stack drag input separately from legacy Java quick craft. Emit start and unique destination visits from physical press and pointer motion, retain the original budget after authoritative placements empty the cursor, and emit the end phase once on a delivered release, close or fallback. Preserve bundle extraction and unnegotiated clients. Core retains authoritative quantities, network identities, delayed replies and recovery. The official Bedrock 1.26.60.5 integrated-world control distributes nine into 9, then 4/4, then 3/3/3 before release and retains those items through Escape while held. Parameterized client regressions cover both buttons, an emptied cursor, close and a late release. This reference does not establish native multiplayer request grouping or delayed/rejected timing. + +Send negotiated drag phases as ordinary Java click packets without running the Java menu's quick-craft prediction. Core already predicts each visited destination. A physical right-drag control exposed a second local distribution at release despite only four accepted server requests: Java showed two items per destination and three carried instead of one per destination and six carried. A real-menu regression reproduces this divergence and preserves the authoritative counts after the fix. Keep normal Java prediction for unnegotiated input. + +Retain negotiated desktop drags until a native slot or reset callback receives release. The official 1.26.60.5 client keeps state 7 after release over an empty recipe panel. With no mouse button down, entering another empty slot redistributes ten items from 3/3/3 plus one carried to 2/2/2/2 plus two carried. Release over the furnace root or a slot retires the allocation. Resolve the boundary from delivered authored mappings, not panel coordinates or physical mouse-up alone. Continue ordinary pointer visits while that operation owns input, including while transfer replies wait. A secondary press preserves a retained primary allocation. Its slot-delivered release retires that operation even though another button started it. Keep the original drag mode for the end packet and consume both the delivered release and the initiating button release. Closing and fallback still finish the gesture. A parameterized real-menu regression covers unhandled release, unheld entry, both delivered release buttons, duplicate release and close without another distribution. Default cooking admission guards and native multiplayer grouping limits remain unchanged. + +Share the Help view lifetime and input handling across player and cooking +presentations. Official Bedrock 1.26.60.5 furnace, smoker, and blast-furnace +Help opens encyclopedia topic 31 (Furnace), independently of the player +inventory guide choice. Native Back and Escape controls retain the cooking +opening and carried items. Keep live snapshots and pending search with the +inventory owner while Help owns the view, then restore its input lifecycle. +Do not replay a Help event batch into inventory actions after Back. + +Keep remembered navigation focus separate from its active keyboard visual. +Official Bedrock 1.26.60.5 Help opens with its selected topic focused. +Physical pointer movement clears that highlight while retaining the +navigation anchor. Hovering another topic moves the anchor without +selecting its chapter; arrows move focus, and confirmation selects it. + +Use that focus in cached painter traversal and restore it on Help entry. +Keep component hover separate from the View focus request. Pointer focus +on a visible target must not run layout or editor focus callbacks again. + +Carry the physical pointer into the replacement Help input view so the +release of its opening click does not clear the initial focus highlight. +Draw the active navigation focus or pointer hover as one focus source. + +Route cooking drop keys through the shared authored inventory input owner. +Official Bedrock 1.26.60.5 Furnace controls leave an empty recipe cursor +unchanged, drop one held item with Q, and retain the held stack with Ctrl+Q. +The recipe preview remains distinct from real inventory slots. Retain the +original drop binding until release so repeats and modifier release cannot +produce another action. + +All three live native cooking controllers register these drop callbacks. +Admit their recipe mappings and the already shared background cursor-drop +handler without relaxing guards for other unfinished host actions. +Regression tests cover real default geometry and isolated input ownership. +Complete default screen admission and full cooking parity remain open. + +Retain native bundle cell navigation independently of ordinary focus and +pixel scrolling. Original Bedrock 1.26.60.5 controller instructions distinguish +row-bound directions from sequential wrapping over padded item cells. +Sequential callbacks reject gamepad mode and a nonempty selected source. + +Player and workbench controllers carry the selected display cell through a +separately negotiated extraction channel. Core resolves physical slots from +the published snapshot, while existing publication and request lifetimes +serialize pending actions. Preserve the original codec for default extraction. +Do not infer mouse-wheel or arrow bindings from logical callback names. +Native physical input delivery and remaining default cooking host callbacks remain unfinished. + +Cooking bundle commands retain the complete cooking snapshot source and +share request ordering with recipe, search and held-item actions. Retain +pending selection through newer snapshots, exact replies, retirement and +timeouts. A reply that preserves item authority permits another command +without waiting for a nonexistent publication. + +Route admitted cooking slot gestures through the shared bundle controller +and suppress ordinary Java input while its command waits. Preserve newer +search text, reject unrelated replies and play only the matching operation +sound. Default cooking reset, arrow-key and scrollbar integration and native +physical bundle binding delivery remain open. + +Keep physical hover admitted while cooking item commands are pending, +including captured-button release. Pending commands still block item +activation. Disabling hover on release sends a false slot-leave callback +and loses bundle selection until the pointer moves again. Retain the source +through reply-driven updates so stationary input can continue selecting. + +Route desktop Return through the authored Return edge and ordinary cooking +slot callbacks. Original1.26.60.5 callbacks145053fe0/145053ff0 retain the +Return flag, and update145007e60 consumes it as held input. Fresh physical +controls enter state15 and retire it on Return release. Space over the same +slot leaves items unchanged. Keep Help and player input bindings separate. + +Reuse the ordinary inventory request owner and the existing publication-bound +quantity session for Return pickup. Retain the routed slot geometry as the +hold origin and finish only the matching Return lifetime. Key repeats cannot +start another ordinary pickup; releases still reach the busy controller. +Default source reset, arrow/touch predicates and scrollbar consumers retain +their admission guards. Complete native timing and default screens remain +unverified. + +Compose the registered 1.26.60.5 cooking reset callbacks through raw input +phases. Every phase-one event updates the empty source, including repeats. +Only phase zero whose previous phase differs from one finishes a gesture. +Original input/dispatcher controls establish these separate edges and the +state-specific source, finalizer and quantity-operation boundaries. + +Keep component input consumption while the inventory controller waits for +replies. Restore declined model-bound toggle changes without losing raw +button history. Preserve editor input during pending search. A qualifying +reset finishes recipe, quantity and incremental drag through their existing +request owners and original source identities, without retiring the menu. +Preserve empty-primary bundle hover, clear admitted selected-source context, +and retain the local selection through unchanged snapshot publications. +Keep arrow and scrollbar controller admission guarded pending their owners. + +Honor the original held-input edge filters independently of activation. +Return press requires raw phase one after another phase. Return release +requires raw phase zero after phase one. Unmatched and intermediate +releases preserve the held flag, timer and source until a matching edge. +Original 1.26.60.5 producer/dispatcher controls cover all three modes, +phases and previous phases. Real-menu regressions keep quantity selection +active after unmatched releases, then complete once through its owner. + +Bedrock 1.26.60.5 runs a separate exact-source hover-exit callback after bundle hover cleanup. Retire ordinary and touch hover even without an active desktop tooltip, so later item updates cannot reopen a tooltip for a departed slot. Preserve the earlier desktop callback's collection-alias cleanup and the independent primary and touch bundle sources. + +Retain selected-source cooking commands in the same client input sequence +as recipes, bundles, search and held quantities. Bind pending replies to +their complete original action, preserve drawing ownership across newer +item frames, and wait for replacement tap authority when core retires the +old frame. Reentrant replies cannot dispatch another command mid-send. +Keep a typed terminal retirement after ambiguous drawing transport; it +retains the original source frame and cannot release a later gesture. +Register source capability and result transport independently of the +selected input mode. + +Retain the persistent bundle hover flag from raw arrow pressed edges in +touch mode, publish it to the authored scene and expire it at six seconds. +Repeated pressed phases and arrow release do not restart or cancel it. +A real input-mode change clears it. Valid source pre-press resets the flag +before item admission; absent contexts preserve it. + +Original Bedrock 1.26.60.5 callback, getter and frame controls establish the +timer boundary. Eighteen original pre-press controls establish the reset +with item lookup supplied and execution stopped before item validity. +Regressions check visible state, repeated input and unchanged-frame revision. +Keep default cooking admission guarded until its remaining callbacks have +real consumers. This does not establish physical touch or complete screen parity. + +Connect selected-source cooking input to its snapshot-bound command owner. +Selection retains its ordinary slot and keeps the cursor empty. The first +drawing event retains a destination; the second begins source distribution +with that destination before its current cell. Later visits and completion +use the ordered incremental request path without cursor-drag Start input or +Java menu prediction. Authority owns destination eligibility and revisits. + +An explicit cooking control style keeps Mouse and keyboard as the default +and permits Touch-style contact through held mouse input. Hover without +contact cannot distribute items. Keep contact alive through the final slot +release before retiring hover. This is a declared Java input policy, not +evidence of physical touchscreen hardware support. + +Preserve native arrow entry blocking and the shared scrollbar-touch and +cursor-drop-all flag. A valid ordinary pre-press resets the shared blocker. +Source selection uses the released cell, including an empty cell. Native +release activation also completes drawing after component mapping history +was invalidated. Rejected admission clears local drawing ownership before +new input can use it. + +Bedrock 1.26.60.5 component, controller and original state-machine controls +establish ordinary selection, tap routing, retained destinations, arrow +blocking and release behavior for hotbar, inventory and furnace sources. +Six release-target controls retain the released cell without moving items; +six drawing controls complete with previous component phases one and two. +Source lookup, permissions, item services, synchronous atomic mutation, +clock and runtime services remain supplied boundaries. These controls do +not establish native multiplayer grouping or physical touch delivery. + +Regressions use the real client action owner for pending and rejected +admission, shared flags and tap quantities. A screen fixture drives mouse entry points and +checks contact, real authored collection details, source-payload identity, +ordered drawing clicks and release without Java pickup or prediction. +Touch bundle branches remain unfinished. Keep default cooking host +admission guarded. + +Keep initial secondary input on the release phase. Six additional native +controls distinguish ordinary half pickup without shape admission from +source selection after shape admission. Retain that distinction through +the real mouse event path: a shaped secondary contact selects without an +ordinary Java pickup, while an unshaped secondary release delegates one +ordinary pickup to the existing inventory owner. The native legacy router +and source post-update execute; atomic count, capacity and mutation remain +supplied model services. These controls do not establish multiplayer +request grouping or complete touch input parity. + +Original Bedrock 1.26.60.5 controller instructions establish Touch quantity +selection without cursor pickup. Option600 initializes its trigger to +525 milliseconds. Admission is strictly after that threshold, excludes +single-item sources and retains the existing secondary-source identity. +Pointer movement uses the native float sine curve and truncated count. +Release freezes that fraction and retains the source for a later tap or +drawing budget. Neither selection nor release submits an item request. +The later transfer uses the existing snapshot-bound authority owner. + +Native frame, state-machine, getter and tap controls cover threshold, +movement cancellation, GUI scalar, release retention and a three-of-nine +transfer. Clock, pointer, option lookup, GUI scalar, CRT sine and model +services remain declared boundaries. These controls do not establish +physical touchscreen input, presentation pixels or multiplayer grouping. +Client regressions exercise physical screen callbacks, partial drawing, +ordered completion and budget retirement without Java item prediction. +The client currently uses the native default trigger. Configurable trigger +time and Touch bundle branches remain unfinished. + +Render retained Touch source images through the authored selected-item +controls instead of treating every selected image as the real cursor. +Publish an immutable source/count display snapshot to the shared controller. +Use actual menu components with the selected quantity, and leave both the +source slot and real cursor unchanged. Reuse unchanged presentation data +rather than rebuilding native item metadata on every pointer frame. + +Selected-image parents use the shared native cursor-following layout. +Their icons and count labels keep authored bounds, visibility, layer and +opacity. Ordinary carried items keep their existing extraction owner. +Give the renderer a copy of the selected image so item extraction cannot +mutate the retained display snapshot. Retire the image when source +selection ends, without an inventory action. + +Bedrock 1.26.60.5 ui_common.json supplies the selected icon, count and parent +controls. Original target-build source/count and pointer-curve controls +establish the retained quantity with an empty cursor. Real screen tests +resolve those authored templates, exercise a seven-of-nine image and count, +retain physical slot hit regions, and check extraction opacity and snapshot +isolation through release and drawing completion. Native pixels, details +popup, Touch bundles and physical touchscreen delivery remain unfinished. + +Admit the registered arrow and scrollbar state callbacks through their +existing source owners. Keep scroll-view admission limited to the authored +scrollbar callback, and preserve unsupported and nonconsuming host guards. +Deliver scrollbar press bookkeeping before pointer capture, so a held +scrollbar cannot start source hover quantity selection over an item. + +Forward raw arrow aliases alongside ordinary keyboard focus input and +match their releases, including input bookkeeping while replies wait. +The target-build default panels name these aliases. Original 1.26.60.5 +producer, dispatcher and callback controls establish the held flags, and a +read-only native Right-arrow control observes their press/release lifetime. +Screen regressions exercise the authored persistent-text binding through +expiry and the scrollbar-to-item hold boundary without item movement. +Exact native scheduling and graphical parity remain unverified. Default +program admission still stops at the unsupported coalesce_stack action. --- .../bedrock/ViaFabricPlusBedrock.java | 4 + .../appearance/BedrockInventorySounds.java | 35 + @@ -924,83 +1339,99 @@ probe boundaries; these controls do not establish complete controller parity. .../render/MixinNativeGuiItemState.java | 20 + .../render/MixinNativeGuiOversizedItem.java | 31 + .../container/BedrockFurnacePreferences.java | 60 + - .../BedrockNativeContainerScreen.java | 1561 +++++++++++++++++ - .../container/BedrockNativeContainers.java | 283 +++ - .../container/BedrockNativeCooking.java | 147 ++ - .../BedrockNativePlayerInventory.java | 191 ++ - .../container/BedrockPlayerInventory.java | 144 ++ - .../container/NativeBundleActionPayload.java | 22 + + .../BedrockNativeContainerScreen.java | 2217 +++++++++++++ + .../container/BedrockNativeContainers.java | 300 ++ + .../container/BedrockNativeCooking.java | 219 ++ + .../BedrockNativePlayerInventory.java | 202 ++ + .../container/BedrockPlayerInventory.java | 144 + + .../container/NativeBundleActionPayload.java | 26 + .../container/NativeBundleResultPayload.java | 22 + .../NativeContainerActionPayload.java | 30 + .../NativeContainerCurrentPayload.java | 30 + .../container/NativeContainerDataPayload.java | 30 + - .../container/NativeContainerDraws.java | 36 + + .../container/NativeContainerDraws.java | 41 + .../NativeContainerFamiliesPayload.java | 30 + - .../container/NativeContainerGeometry.java | 274 +++ - .../container/NativeContainerInput.java | 521 ++++++ + .../container/NativeContainerGeometry.java | 335 ++ + .../container/NativeContainerInput.java | 609 ++++ .../container/NativeContainerPayload.java | 22 + .../container/NativeContainerPending.java | 65 + .../NativeContainerPresentation.java | 45 + ...NativeContainerPresentationController.java | 95 + - .../container/NativeContainerScroll.java | 143 ++ + .../container/NativeContainerScroll.java | 143 + .../container/NativeContainerSession.java | 37 + .../NativeContainerStatusPayload.java | 29 + .../NativeContainerWorkstationsPayload.java | 30 + .../container/NativeCookingActionPayload.java | 22 + .../NativeCookingActionResultPayload.java | 22 + - .../container/NativeCookingActions.java | 158 ++ - .../container/NativeCookingNavigation.java | 52 + + .../container/NativeCookingActions.java | 389 +++ + .../container/NativeCookingBundlePayload.java | 22 + + .../NativeCookingBundleResultPayload.java | 22 + + .../screen/container/NativeCookingHold.java | 166 + + .../container/NativeCookingHoldPayload.java | 22 + + .../NativeCookingHoldResultPayload.java | 22 + + .../container/NativeCookingNavigation.java | 76 + .../container/NativeCookingPayload.java | 23 + .../NativeCookingPreferenceControl.java | 63 + - .../container/NativeCookingPresentation.java | 136 ++ - .../NativeCookingPresentationController.java | 119 ++ + .../container/NativeCookingPresentation.java | 264 ++ + .../NativeCookingPresentationController.java | 141 + .../NativeCookingProgressPayload.java | 23 + + .../container/NativeCookingQueryPayload.java | 22 + + .../NativeCookingQueryResultPayload.java | 22 + + .../screen/container/NativeCookingReset.java | 25 + + .../container/NativeCookingSourceInput.java | 250 ++ + .../container/NativeCookingSourcePayload.java | 22 + + .../NativeCookingSourceResultPayload.java | 22 + .../NativeFurnaceOptionsPayload.java | 27 + .../NativeFurnacePreferenceState.java | 44 + + .../container/NativeInventoryDragPayload.java | 25 + .../container/NativeInventoryFlights.java | 65 + - .../NativeInventoryItemPresentation.java | 241 +++ + .../screen/container/NativeInventoryHelp.java | 47 + + .../container/NativeInventoryHelpText.java | 64 + + .../NativeInventoryItemPresentation.java | 241 ++ .../container/NativeInventoryItemStyle.java | 55 + .../NativeInventoryPresentation.java | 52 + .../container/NativeInventoryProgress.java | 70 + - .../container/NativeInventorySourceState.java | 210 +++ - .../container/NativePlayerHelpText.java | 64 + + .../container/NativeInventorySourceState.java | 306 ++ .../NativePlayerInventoryActionPayload.java | 23 + .../NativePlayerInventoryPayload.java | 22 + .../NativePlayerInventoryResultPayload.java | 22 + - .../container/NativePlayerPresentation.java | 245 +++ + .../container/NativePlayerPresentation.java | 248 ++ .../PlayerInventoryScreenClosePayload.java | 22 + - .../PlayerInventoryScreenLifecycle.java | 98 ++ + .../PlayerInventoryScreenLifecycle.java | 98 + .../PlayerInventoryScreenReadyPayload.java | 22 + .../PlayerInventoryScreenRequestPayload.java | 28 + - .../settings/impl/BedrockSettings.java | 4 + - .../viafabricplus-bedrock/lang/en_us.json | 1 + + .../base/NativeCookingControlStyle.java | 28 + + .../settings/impl/BedrockSettings.java | 9 + + .../viafabricplus-bedrock/lang/en_us.json | 4 + .../viafabricplus-bedrock.mixins.json | 9 +- .../BedrockInventorySoundsTest.java | 72 + - .../BedrockNativeContainerScreenTest.java | 760 ++++++++ - .../container/NativeContainerButtonTest.java | 280 +++ - .../container/NativeContainerDrawsTest.java | 70 + - .../NativeContainerGeometryTest.java | 407 +++++ - .../container/NativeContainerInputTest.java | 610 +++++++ + .../BedrockNativeContainerScreenTest.java | 941 ++++++ + .../container/NativeContainerButtonTest.java | 280 ++ + .../container/NativeContainerDrawsTest.java | 113 + + .../NativeContainerGeometryTest.java | 435 +++ + .../container/NativeContainerInputTest.java | 610 ++++ ...veContainerPresentationControllerTest.java | 91 + .../container/NativeContainerScrollTest.java | 51 + .../container/NativeContainerSessionTest.java | 40 + - .../container/NativeCookingActionsTest.java | 346 ++++ - ...tiveCookingPresentationControllerTest.java | 216 +++ - .../NativeCookingPresentationTest.java | 144 ++ - .../container/NativeCookingScreenTest.java | 1410 +++++++++++++++ + .../container/NativeCookingActionsTest.java | 544 ++++ + .../container/NativeCookingHoldTest.java | 158 + + ...tiveCookingPresentationControllerTest.java | 260 ++ + .../NativeCookingPresentationTest.java | 301 ++ + .../container/NativeCookingScreenTest.java | 2828 +++++++++++++++++ + .../NativeCookingSourceInputTest.java | 262 ++ .../NativeFurnacePreferenceStateTest.java | 45 + .../container/NativeInventoryFlightsTest.java | 70 + - .../NativeInventoryItemPresentationTest.java | 282 +++ + .../NativeInventoryHelpTextTest.java | 34 + + .../NativeInventoryItemPresentationTest.java | 282 ++ .../NativeInventoryItemStyleTest.java | 84 + .../NativeInventoryProgressTest.java | 83 + - .../NativeInventorySourceStateTest.java | 222 +++ - .../container/NativePlayerHelpTextTest.java | 34 + - .../NativePlayerInventoryLifetimeTest.java | 142 ++ - .../NativePlayerPresentationTest.java | 267 +++ - .../container/NativePlayerScreenTest.java | 947 ++++++++++ - .../PlayerInventoryAdmissionTest.java | 147 ++ - .../PlayerInventoryScreenLifecycleTest.java | 139 ++ - 89 files changed, 13044 insertions(+), 62 deletions(-) + .../NativeInventorySourceStateTest.java | 373 +++ + .../NativePlayerInventoryLifetimeTest.java | 142 + + .../NativePlayerPresentationTest.java | 302 ++ + .../container/NativePlayerScreenTest.java | 1145 +++++++ + .../PlayerInventoryAdmissionTest.java | 147 + + .../PlayerInventoryScreenLifecycleTest.java | 139 + + 105 files changed, 18060 insertions(+), 62 deletions(-) create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockInventorySounds.java 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 @@ -1038,21 +1469,34 @@ probe boundaries; these controls do not establish complete controller parity. create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingActionPayload.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingActionResultPayload.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingActions.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingBundlePayload.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingBundleResultPayload.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingHold.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingHoldPayload.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingHoldResultPayload.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingNavigation.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingPayload.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingPreferenceControl.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingPresentation.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingPresentationController.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingProgressPayload.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingQueryPayload.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingQueryResultPayload.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingReset.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingSourceInput.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingSourcePayload.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingSourceResultPayload.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeFurnaceOptionsPayload.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeFurnacePreferenceState.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryDragPayload.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryFlights.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryHelp.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryHelpText.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryItemPresentation.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryItemStyle.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryPresentation.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryProgress.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventorySourceState.java - create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativePlayerHelpText.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativePlayerInventoryActionPayload.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativePlayerInventoryPayload.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativePlayerInventoryResultPayload.java @@ -1061,6 +1505,7 @@ probe boundaries; these controls do not establish complete controller parity. create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/PlayerInventoryScreenLifecycle.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/PlayerInventoryScreenReadyPayload.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/PlayerInventoryScreenRequestPayload.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/settings/base/NativeCookingControlStyle.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockInventorySoundsTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockNativeContainerScreenTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerButtonTest.java @@ -1071,16 +1516,18 @@ probe boundaries; these controls do not establish complete controller parity. create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerScrollTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerSessionTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingActionsTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingHoldTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingPresentationControllerTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingPresentationTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingScreenTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingSourceInputTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeFurnacePreferenceStateTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryFlightsTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryHelpTextTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryItemPresentationTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryItemStyleTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryProgressTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventorySourceStateTest.java - create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativePlayerHelpTextTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativePlayerInventoryLifetimeTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativePlayerPresentationTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativePlayerScreenTest.java @@ -1088,10 +1535,10 @@ probe boundaries; these controls do not establish complete controller parity. create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/PlayerInventoryScreenLifecycleTest.java diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java b/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java -index 9cb7400..2abeb28 100644 +index a504ae7..7192ff9 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java -@@ -104,6 +104,8 @@ import com.viaversion.viafabricplus.bedrock.screen.ui.BedrockHudOverlays; +@@ -105,6 +105,8 @@ import com.viaversion.viafabricplus.bedrock.screen.ui.BedrockHudOverlays; import com.viaversion.viafabricplus.bedrock.screen.ui.BedrockNativeUiHurt; import com.viaversion.viafabricplus.bedrock.screen.form.BedrockNativeForms; import com.viaversion.viafabricplus.bedrock.screen.npc.BedrockNativeNpcs; @@ -1100,7 +1547,7 @@ index 9cb7400..2abeb28 100644 public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { -@@ -127,6 +129,8 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { +@@ -128,6 +130,8 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { BedrockHudOverlays.register(); BedrockNativeUiHurt.register(); BedrockNativeNpcs.register(); @@ -1597,10 +2044,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..ef59a9f +index 0000000..8ecbc10 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockNativeContainerScreen.java -@@ -0,0 +1,1561 @@ +@@ -0,0 +1,2217 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -1655,11 +2102,20 @@ index 0000000..ef59a9f +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.ContainerInput; ++import net.minecraft.network.HashedStack; ++import net.minecraft.network.protocol.game.ServerboundContainerClickPacket; ++import it.unimi.dsi.fastutil.ints.Int2ObjectMaps; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; ++import net.minecraft.core.component.DataComponents; +import net.raphimc.viabedrock.api.model.NativeContainerAction; ++import net.raphimc.viabedrock.api.model.NativeBundleAction; +import net.raphimc.viabedrock.api.model.NativeCookingAction; ++import net.raphimc.viabedrock.api.model.NativeCookingHoldAction; ++import net.raphimc.viabedrock.api.model.NativeCookingSourceAction; +import net.raphimc.viabedrock.api.resourcepack.ui.ContainerUi; ++import net.raphimc.viabedrock.api.resourcepack.ui.CookingUi; ++import net.raphimc.viabedrock.api.resourcepack.ui.BundleTooltipLayout; +import net.raphimc.viabedrock.protocol.storage.GameRulesStorage; + +/** @@ -1691,6 +2147,10 @@ index 0000000..ef59a9f + private final boolean nativeActions; + private final long sequence; + private final LongSupplier clock; ++ private final NativeCookingHold cookingHold; ++ private final NativeCookingSourceInput sourceInput; ++ private final int inventoryInputMode; ++ private DragInput dragInput; + private final BooleanSupplier currentOwner; + private ContainerUi.ActionScope actionScope = + new ContainerUi.ActionScope(false, Set.of(), Set.of()); @@ -1716,6 +2176,7 @@ index 0000000..ef59a9f + private Map craftingPreview = Map.of(); + private Map> recipeActions = Map.of(); + private final Map pointerSources = new HashMap<>(); ++ private final Map dropSources = new HashMap<>(); + private long cookingUpdate = -1; + private double pointerX = -1, pointerY = -1; + private double layoutPointerX = -1, layoutPointerY = -1; @@ -1751,9 +2212,12 @@ index 0000000..ef59a9f + private final Map anchors = new HashMap<>(); + private NativeUiScene.Node flightOwner; + private final Set vanillaCursor = Collections.newSetFromMap(new IdentityHashMap<>()); ++ private final Set selectedCursorRoots = Collections.newSetFromMap(new IdentityHashMap<>()); ++ private SelectedPreview selectedPreview; + private Slot drawingSlot; + private EditBox search; + private boolean searchInput; ++ private boolean searchTextInput; + private NativeUiLayout.Box searchBox; + private boolean synchronizingSearch; + private long imageRevision; @@ -1788,13 +2252,38 @@ index 0000000..ef59a9f + final Component title, final NativeInventoryPresentation presentation, + final boolean nativeActions, final long sequence, final LongSupplier clock, final BooleanSupplier currentOwner) { + super(ordinary.getMenu(), inventory, title); -+ this.ordinary = ordinary; ++ // Replacements share the original menu host, never a retired native renderer. ++ this.ordinary = ordinary instanceof BedrockNativeContainerScreen nativeScreen ++ ? nativeScreen.original() : ordinary; + this.owner = owner; + this.presentation = presentation; + this.nativeActions = nativeActions; + this.sequence = sequence; + this.clock = clock; ++ this.cookingHold = new NativeCookingHold(clock.getAsLong()); + this.currentOwner = currentOwner; ++ final var controls = presentation instanceof NativeCookingPresentation ++ ? ViaFabricPlusBedrock.impl().settings().nativeCookingControls() : null; ++ this.inventoryInputMode = controls == null || controls.value() == null ? 1 : controls.value().inputMode(); ++ this.tooltipSources.inputMode(this.inventoryInputMode); ++ this.sourceInput = presentation instanceof NativeCookingPresentation cooking ++ ? new NativeCookingSourceInput(this.tooltipSources, this::sourceCell, new NativeCookingSourceInput.Transport() { ++ @Override public boolean available() { return cooking.idle() && cooking.sourcesAvailable(); } ++ @Override public boolean drawing() { return cooking.sourceDrawing() && !cooking.expired(); } ++ @Override public boolean start(final int source, final int destination, final int quantity, ++ final NativeCookingSourceAction.Kind kind) { ++ return cooking.source(source, destination, quantity, kind, ++ action -> ClientPlayNetworking.send(new NativeCookingSourcePayload(action))); ++ } ++ @Override public void visit(final int slot) { sendDragInput(slot, 1); } ++ @Override public void pickup(final int slot, final int button) { ++ slotClicked(menu.getSlot(slot), slot, button, ContainerInput.PICKUP); ++ } ++ @Override public void finish() { ++ if (current()) sendDragInput(-999, 2); ++ cooking.finishSourceDrawing(); ++ } ++ }) : null; + } + + boolean updateData(final ClientPacketListener owner, final net.raphimc.viabedrock.api.model.NativeContainerData update) { @@ -1828,15 +2317,19 @@ index 0000000..ef59a9f + @Override + protected void setInitialFocus() { + // Opening the inventory must not activate its search editor. -+ if (!(this.presentation instanceof NativePlayerPresentation)) super.setInitialFocus(); ++ if (!this.authoredController()) super.setInitialFocus(); + } + + @Override + protected void repositionElements() { + final boolean focused = this.search != null && this.getFocused() == this.search; -+ super.repositionElements(); -+ if (focused && this.searchInput && this.search.visible && this.search.active && this.current() && !this.closing()) -+ this.focusSearch(); ++ try { ++ super.repositionElements(); ++ if (focused && this.searchInput && this.search.visible && this.search.active && this.current() && !this.closing()) ++ this.focusSearch(); ++ } catch (final RuntimeException unsupported) { ++ this.fallback(unsupported); ++ } + } + + private void focusSearch() { @@ -1844,14 +2337,43 @@ index 0000000..ef59a9f + this.setFocused(this.search); + // Native text admission outlives the highlight after Escape or a blank-area click. + // Own SDL input separately so clearing widget focus does not stop character events. -+ this.minecraft.onTextInputFocusChange(this, true); ++ this.synchronizeSearchInput(); ++ } ++ ++ private boolean searchHovered() { ++ return this.search != null && this.search.visible && this.search.active && this.searchBox != null ++ && this.nativeInput.hovered(this.searchBox.node(), NativeUiInput.symbol("button.search_bar_hovered")); ++ } ++ ++ private void synchronizeSearchInput() { ++ final boolean active = this.current() && !this.closing() && this.search != null && this.search.visible && this.search.active ++ && (this.searchInput || this.searchHovered()); ++ // Widget focus callbacks can release SDL input while native text admission remains active. ++ if (!active && !this.searchTextInput) return; ++ this.searchTextInput = active; ++ this.minecraft.onTextInputFocusChange(this, active); + } + + private void releaseSearch() { -+ if (!this.searchInput) return; + this.searchInput = false; + if (this.getFocused() == this.search) this.setFocused(null); -+ this.minecraft.onTextInputFocusChange(this, false); ++ if (this.searchTextInput) { ++ this.searchTextInput = false; ++ this.minecraft.onTextInputFocusChange(this, false); ++ } ++ } ++ ++ private NativeInventoryHelp help() { ++ if (this.presentation instanceof NativePlayerPresentation player) return player.help(); ++ if (this.presentation instanceof NativeCookingPresentation cooking) return cooking.help(); ++ return null; ++ } ++ ++ private boolean helping() { return this.help() != null && this.help().active(); } ++ ++ private void openHelp() { ++ if (this.presentation instanceof NativePlayerPresentation player) player.openHelp(); ++ else if (this.presentation instanceof NativeCookingPresentation cooking) cooking.openHelp(); + } + + private boolean authoredController() { @@ -1867,10 +2389,11 @@ index 0000000..ef59a9f + throw new IllegalStateException("Native inventory no longer owns the current menu"); + this.renderer.reset(); + this.showTags = this.showTags(); -+ if (this.presentation instanceof NativePlayerPresentation) this.tooltipData(NativeInventorySourceState.CURSOR); ++ if (this.authoredController()) this.tooltipData(NativeInventorySourceState.CURSOR); + else NativeInventoryItemPresentation.requireTooltip(this.menu.getCarried()); + final List slots = new ArrayList<>(); -+ for (final Slot slot : this.menu.slots) slots.add(new ContainerUi.Slot(slot.index, NativeInventoryItemPresentation.values(slot.getItem(), false, this.showTags))); ++ for (final Slot slot : this.menu.slots) slots.add(new ContainerUi.Slot(slot.index, ++ NativeInventoryItemPresentation.values(this.cookingHold.preview(slot.index, slot.getItem()), false, this.showTags))); + for (int pass = 0; ; pass++) { + this.refreshScene(slots); + if (!this.updateWindows()) break; @@ -1878,10 +2401,11 @@ index 0000000..ef59a9f + } + for (final var draw : this.draws) { + final var node = draw.box().node(); -+ if (this.presentation instanceof NativeCookingPresentation -+ && (Set.of("edit_box", "slider", "slider_box").contains(node.type()) ++ if (this.presentation instanceof NativeCookingPresentation && !this.helping() ++ && (Set.of("slider", "slider_box").contains(node.type()) ++ || node.type().equals("edit_box") && !this.cookingSearchEditor(node) + || !Set.of("button", "toggle").contains(node.type()) && node.properties().has("button_mappings") -+ && !NativeCookingNavigation.host(node))) ++ && !NativeCookingNavigation.host(node) && !this.cookingSearchEditor(node))) + throw new IllegalArgumentException("Unsupported native cooking control"); + if (this.authoredController() && draw.enabled()) { + for (final var mapping : NativeUiInput.parse(node.properties()).mappings()) { @@ -1889,6 +2413,9 @@ index 0000000..ef59a9f + throw new IllegalArgumentException("Unsupported native inventory keyboard action on " + node.name() + ": " + mapping.target()); + } + } ++ if (this.presentation instanceof NativeCookingPresentation cooking && !cooking.searchAvailable() ++ && NativeUiInput.parse(node.properties()).mappings().stream().anyMatch(mapping -> mapping.target() == NativeUiInput.symbol("button.search_bar_clear"))) ++ throw new IllegalArgumentException("Native cooking search is unavailable"); + final String custom = node.string("renderer", ""); + if (custom.equals("progress_bar_renderer")) + NativeInventoryProgress.quads(draw.box(), draw.alpha(), 1); @@ -1899,7 +2426,7 @@ index 0000000..ef59a9f + this.previousCarried = this.menu.getCarried().copy(); + this.previousPresentationRevision = this.presentation.revision(); + this.imageRevision = this.images.revision(); -+ if (this.activated && !this.actionScope.subset()) ++ if (this.activated && !this.helping() && !this.actionScope.subset()) + throw new IllegalArgumentException("Native inventory action scope changed"); + if (this.authoredController()) this.nativeInput.initialize(); + } @@ -1914,11 +2441,13 @@ index 0000000..ef59a9f + } + + private void refreshScene(final List slots) { ++ this.retainSelectedSource(); + this.tooltipSources.refresh(source -> this.tooltipData(source) != null); ++ this.publishSelectedSource(); + final var source = this.tooltipSources.bundle(); + final var bundle = this.tooltipData(source); + final var frame = this.presentation.resolve(this.menu.containerId, this.title.getString(), slots, -+ NativeInventoryItemPresentation.values(this.menu.getCarried(), true, this.showTags), this.showTags, ++ NativeInventoryItemPresentation.values(this.cookingHold.cursor(this.menu.getCarried()), true, this.showTags), this.showTags, + this.tooltipSources.view(_ -> bundle, this.showTags)); + this.renderedBundle = bundle; + final var resolved = frame.scene(); @@ -1937,8 +2466,9 @@ index 0000000..ef59a9f + } + } + } -+ final boolean help = this.presentation instanceof NativePlayerPresentation player && player.helping(); -+ if (help) NativePlayerHelpText.resolve(resolved, this::nativeHelpKey); ++ final boolean help = this.helping(); ++ final boolean enteringHelp = help && !this.helpView; ++ if (help) NativeInventoryHelpText.resolve(resolved, this::nativeHelpKey); + this.scene = NativeUiScene.parse(resolved.toString(), RENDERERS).root(); + final Map sourceSlots = new HashMap<>(); + this.tooltipSlots(this.scene, sourceSlots); @@ -1950,6 +2480,7 @@ index 0000000..ef59a9f + this.inventoryAnimationStarted = this.animationStarted; + this.lifecycle = null; + this.nativeInput = new NativeContainerInput(this::revealFocus); ++ this.nativeInput.copyPointerFrom(this.inventoryInput); + } else { + this.lifecycle = this.inventoryLifecycle; + this.animationStarted = this.inventoryAnimationStarted; @@ -1965,6 +2496,11 @@ index 0000000..ef59a9f + this.lifecycle.animations().event(this.scene, "screen.entrance_push", 0); + } else this.lifecycle.replaceRoot(this.scene, this.animationTime()); + this.layout(this.animationTime(), -1, -1); ++ if (enteringHelp) { ++ this.draws.stream().map(draw -> draw.box().node()) ++ .filter(node -> node.properties().has("help_topic") && node.flag("toggle_state", false)) ++ .findFirst().ifPresent(this.nativeInput::focus); ++ } + } + + private long animationTime() { return this.clock.getAsLong() - this.animationStarted; } @@ -1983,22 +2519,33 @@ index 0000000..ef59a9f + for (final var child : node.children()) this.tooltipSlots(child, slots); + } + -+ private ItemStack sourceItem(final Source source) { -+ ItemStack item = null; -+ if (source.equals(NativeInventorySourceState.CURSOR)) item = this.menu.getCarried(); -+ else { -+ Integer slot = this.tooltipSlots.get(source); -+ if (slot == null) { -+ for (final var entry : this.tooltipSlots.entrySet()) if (source.equivalent(entry.getKey())) { -+ if (slot != null && !slot.equals(entry.getValue())) throw new IllegalArgumentException("Native tooltip aliases have conflicting slots"); -+ slot = entry.getValue(); -+ } -+ } -+ if (slot != null) item = this.menu.getSlot(slot).getItem(); ++ private Integer sourceSlot(final Source source) { ++ Integer slot = this.tooltipSlots.get(source); ++ if (slot == null) for (final var entry : this.tooltipSlots.entrySet()) if (source.equivalent(entry.getKey())) { ++ if (slot != null && !slot.equals(entry.getValue())) throw new IllegalArgumentException("Native tooltip aliases have conflicting slots"); ++ slot = entry.getValue(); + } -+ return item; ++ return slot; ++ } ++ ++ private ItemStack sourceItem(final Source source) { ++ if (source.equals(NativeInventorySourceState.CURSOR)) return this.menu.getCarried(); ++ final Integer slot = this.sourceSlot(source); ++ return slot == null ? null : this.menu.getSlot(slot).getItem(); ++ } ++ ++ private NativeCookingSourceInput.Cell sourceCell(final Source source) { ++ final Integer index = source.present() ? this.sourceSlot(source) : null; ++ if (index == null) return null; ++ final var slot = this.menu.getSlot(index); ++ final var item = slot.getItem(); ++ return new NativeCookingSourceInput.Cell(index, item.getCount(), index != 2 && slot.isActive() ++ && !item.isEmpty() && item.getCount() <= 255 && slot.mayPickup(this.minecraft.player) ++ && this.tooltipData(item) == null); + } + ++ private int inputMode() { return this.helping() ? 1 : this.inventoryInputMode; } ++ + private NativeInventoryItemPresentation.BundleTooltip tooltipData(final Source source) { + return this.tooltipData(this.sourceItem(source)); + } @@ -2028,8 +2575,14 @@ index 0000000..ef59a9f + if (this.authoredController()) this.nativeInput.prepare(this.scene); + this.renderer.prepare(this.scene); + this.cursorPlacement = false; ++ this.selectedCursorRoots.clear(); ++ this.selectedCursorNodes(this.scene); + final var cursor = new NativeUiCursorPlacement((float) this.pointerX, (float) this.pointerY, true); + this.root = new NativeUiLayout(this.renderer, animations).layout(this.scene, this.width, this.height, this.scroll.offsets(this.scene), node -> { ++ if (this.selectedCursorRoots.contains(node)) { ++ this.cursorPlacement = true; ++ return cursor; ++ } + if (!node.string("renderer", "").equals("bundle_tooltip_renderer") || node.children().stream() + .noneMatch(child -> child.flag("visible", true) && !child.flag("ignored", false))) return null; + this.cursorPlacement = true; @@ -2037,12 +2590,13 @@ index 0000000..ef59a9f + }); + this.layoutPointerX = this.pointerX; + this.layoutPointerY = this.pointerY; -+ this.draws = NativeUiPresentation.draws(this.root, box -> box.node() == this.nativeInput.focused() || box.visibleBounds().contains(pointerX, pointerY), node -> animations.opacityAt(node, now), this.nativeInput::hasButtonState); ++ final var focused = this.nativeInput.focusedForDrawing(); ++ this.draws = NativeUiPresentation.draws(this.root, box -> focused != null ? box.node() == focused : box.visibleBounds().contains(pointerX, pointerY), node -> animations.opacityAt(node, now), this.nativeInput::hasButtonState); + if (this.authoredController()) this.nativeInput.layout(this.root, this.draws, this.width, this.height); + this.updateGeometry(); + this.reconcileSearch(); + this.vanillaCursor.clear(); -+ this.cursor(this.root, false); ++ this.cursor(this.root, false, false); + this.anchors.clear(); + this.flightOwner = null; + this.anchors(this.root); @@ -2079,11 +2633,13 @@ index 0000000..ef59a9f + @Override + public void onClose() { + if (!this.current() || this.closing() || this.exitFinished) return; -+ if (this.presentation instanceof NativePlayerPresentation player && player.closeHelp()) { ++ if (this.help() != null && this.help().close()) { + this.refreshSafely(); + return; + } + try { ++ if (this.sourceInput != null && this.inputMode() == 2) this.sourceInput.finish(); ++ this.finishDragInput(); + if (this.lifecycle != null && this.lifecycle.requestExit(this.animationTime())) { + this.setDragging(false); + this.scroll.release(); @@ -2095,6 +2651,7 @@ index 0000000..ef59a9f + this.capturedButtons.clear(); + this.nativeButtons.clear(); + this.pointerSources.clear(); ++ this.dropSources.clear(); + this.keyboardPressed = false; + this.deactivate(); + return; @@ -2109,7 +2666,14 @@ index 0000000..ef59a9f + + @Override + public void extractCarriedItem(final GuiGraphicsExtractor graphics, final int mouseX, final int mouseY) { -+ if (!this.helpView) super.extractCarriedItem(graphics, mouseX, mouseY); ++ if (this.helpView) return; ++ if (this.cookingHold.previewsCursor()) { ++ final var item = this.cookingHold.cursor(this.menu.getCarried()); ++ if (!item.isEmpty()) { ++ graphics.item(item, mouseX - 8, mouseY - 8); ++ graphics.itemDecorations(this.font, item, mouseX - 8, mouseY - 8, null); ++ } ++ } else super.extractCarriedItem(graphics, mouseX, mouseY); + } + + private static NativePlayerInventoryAction.Kind playerMode(final NativeCookingAction.Mode mode) { @@ -2120,8 +2684,35 @@ index 0000000..ef59a9f + }; + } + ++ private boolean cookingSearchEditor(final NativeUiScene.Node node) { ++ if (!(this.presentation instanceof NativeCookingPresentation cooking) || !cooking.searchAvailable() || !node.type().equals("edit_box")) return false; ++ return NativeContainerGeometry.cookingSearchEditor(node); ++ } ++ ++ private boolean clearSearch() { ++ if (!this.searchQuery("")) return false; ++ if (this.search != null) { ++ this.synchronizingSearch = true; ++ try { this.search.setValue(""); } ++ finally { this.synchronizingSearch = false; } ++ } ++ return true; ++ } ++ ++ private String searchQuery() { ++ return this.presentation instanceof NativePlayerPresentation player ? player.query() : ((NativeCookingPresentation) this.presentation).query(); ++ } ++ ++ private boolean searchQuery(final String value) { ++ if (this.presentation instanceof NativePlayerPresentation) { ++ BedrockNativePlayerInventory.query(this.owner, this.menu, value); ++ return true; ++ } ++ return BedrockNativeCooking.query(this.owner, this.menu, value); ++ } ++ + private void reconcileSearch() { -+ if (!(this.presentation instanceof NativePlayerPresentation player)) return; ++ if (!this.authoredController()) return; + this.searchBox = null; + for (final var draw : this.draws) { + if (!draw.box().node().type().equals("edit_box")) continue; @@ -2131,7 +2722,7 @@ index 0000000..ef59a9f + this.search = new EditBox(this.font, 0, 0, 1, 1, Component.empty()); + this.search.setBordered(false); + this.search.setResponder(value -> { -+ if (!this.synchronizingSearch) BedrockNativePlayerInventory.query(this.owner, this.menu, value); ++ if (!this.synchronizingSearch) this.searchQuery(value); + }); + } + if (!this.children().contains(this.search)) this.addWidget(this.search); @@ -2145,24 +2736,22 @@ index 0000000..ef59a9f + this.search.active = draw.enabled(); + this.search.setEditable(draw.enabled()); + this.search.setAlpha(draw.alpha()); -+ if (!this.search.getValue().equals(player.query())) { ++ if (!this.search.getValue().equals(this.searchQuery())) { + this.synchronizingSearch = true; -+ try { this.search.setValue(player.query()); } ++ try { this.search.setValue(this.searchQuery()); } + finally { this.synchronizingSearch = false; } + } + } + if (this.search != null && this.searchBox == null) this.search.visible = false; -+ if (this.searchInput) { -+ if (!this.search.visible || !this.search.active) this.releaseSearch(); -+ else this.minecraft.onTextInputFocusChange(this, true); -+ } ++ if (this.searchInput && (!this.search.visible || !this.search.active)) this.releaseSearch(); ++ this.synchronizeSearchInput(); + } + + @Override + public boolean charTyped(final CharacterEvent event) { + if (this.closing()) return true; + if (!this.current()) return false; -+ if (this.searchInput) { ++ if (this.searchInput || this.searchHovered()) { + this.focusSearch(); + return this.search.charTyped(event); + } @@ -2177,16 +2766,6 @@ index 0000000..ef59a9f + boolean handled = false; + for (final var event : result.events()) { + if (event.button() != null && event.button().phase() != 0 && event.button().phase() != 1) continue; -+ if (player.helping()) { -+ if (event.change() != null && event.change().checked() && event.node().properties().has("help_topic")) { -+ player.helpTopic(event.node().properties().get("help_topic").getAsInt()); -+ handled = true; -+ } else if (event.button() != null && event.button().transition() && event.node().flag("help_close", false)) { -+ player.closeHelp(); -+ handled = true; -+ } -+ continue; -+ } + if (event.change() != null) { + final var change = event.change(); + final String group = event.node().string("toggle_name", ""); @@ -2203,9 +2782,6 @@ index 0000000..ef59a9f + } + } + handled = true; -+ } else if (event.button() != null && event.button().transition() && event.button().target() == NativeUiInput.symbol("button.help")) { -+ player.openHelp(); -+ handled = true; + } else if (event.button() != null && event.button().transition() && event.button().target() == NativeUiInput.symbol("button.hide_bundle_tooltip")) { + handled |= this.tooltipSources.hide(); + } else if (event.button() != null && event.button().pressed() && event.button().previous() != 1 @@ -2238,12 +2814,12 @@ index 0000000..ef59a9f + private void updateGeometry() { + if (this.geometry != null && this.geometry.reusableFor(this.draws)) return; + this.geometry = new NativeContainerGeometry(this.draws, this.menu.slots.size(), this.recipeActions, -+ this.presentation instanceof NativeCookingPresentation, this.presentation instanceof NativePlayerPresentation); ++ this.presentation instanceof NativeCookingPresentation && !this.helping(), this.presentation instanceof NativePlayerPresentation || this.helping()); + this.requireCoverage(); + } + + private void requireCoverage() { -+ if (this.presentation instanceof NativePlayerPresentation) return; ++ if (this.presentation instanceof NativePlayerPresentation || this.helping()) return; + if (this.presentation instanceof NativeCookingPresentation cooking + && this.geometry.hasLayoutPreferences() && cooking.forcedRecipeBookLayout()) + throw new IllegalArgumentException("Forced native recipe-book layout controls are unsupported"); @@ -2257,6 +2833,9 @@ index 0000000..ef59a9f + + private void requireSubsetMappings() { + for (final var draw : this.draws) { ++ if (NativeUiInput.parse(draw.box().node().properties()).mappings().stream() ++ .anyMatch(mapping -> NativeContainerInput.cursorDropButton(mapping.target()) >= 0)) ++ throw new IllegalArgumentException("Native subset cursor drops are unavailable"); + if (!draw.box().node().properties().has("inventory_actions")) continue; + for (final var value : + draw.box().node().properties().getAsJsonArray("inventory_actions")) { @@ -2391,6 +2970,9 @@ index 0000000..ef59a9f + this.pointerX = mouseX; + this.pointerY = mouseY; + try { ++ this.advanceCookingInput(); ++ this.advanceSourceInput(mouseX, mouseY); ++ this.tooltipSources.frame(this.clock.getAsLong()); + if (this.tooltipSources.revision() != this.tooltipRevision) this.refresh(); + final long now = this.animationTime(); + this.lifecycle.advance(now); @@ -2413,14 +2995,15 @@ index 0000000..ef59a9f + } + this.hoveredSlot = this.closing() ? null : this.nativeHoveredSlot(mouseX, mouseY); + if (this.hoveredSlot != null) { -+ if (this.presentation instanceof NativePlayerPresentation) this.tooltipData(this.hoveredSlot.getItem()); ++ if (this.authoredController()) this.tooltipData(this.hoveredSlot.getItem()); + else NativeInventoryItemPresentation.requireTooltip(this.hoveredSlot.getItem()); + } + this.previews.clear(); + float layer = Float.NaN; + for (final var draw : this.draws) { + final var box = draw.box(); -+ if (this.vanillaCursor.contains(box.node())) continue; ++ if (this.vanillaCursor.contains(box.node()) && !(this.cookingHold.selecting() ++ && box.node().string("renderer", "").equals("progress_bar_renderer"))) continue; + if (draw.alpha() <= 0 || box.visibleBounds().width() <= 0 || box.visibleBounds().height() <= 0) continue; + // Authored layers already establish painter order. Bound Java's overlap search to each layer. + if (layer != box.layer()) { @@ -2432,7 +3015,7 @@ index 0000000..ef59a9f + try { + final String custom = box.node().string("renderer", ""); + final String countSource = box.node().string("inventory_count", ""); -+ if (countSource.equals("slot") || countSource.equals("bundle")) ++ if (countSource.equals("slot") || countSource.equals("bundle") || countSource.equals("selected")) + this.count(graphics, box, draw.alpha()); + else if (countSource.equals("cursor")) { + /* Vanilla carried decorations remain authoritative. */ @@ -2456,6 +3039,17 @@ index 0000000..ef59a9f + } + + private void item(final GuiGraphicsExtractor graphics, final NativeUiLayout.Box box, final int mouseX, final int mouseY, final float alpha) { ++ if (box.node().properties().has("inventory_selected_slot")) { ++ final var stack = this.selectedItem(box.node()); ++ 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(stack, 0, 0)); ++ } finally { graphics.pose().popMatrix(); } ++ return; ++ } + if (box.node().properties().has("bundle_slot")) { + final var stack = this.bundleItem(box.node()); + graphics.pose().pushMatrix(); @@ -2485,6 +3079,18 @@ index 0000000..ef59a9f + 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 heldPreview = this.cookingHold.preview(slot.index, slot.getItem()); ++ if (heldPreview != slot.getItem()) { ++ 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(heldPreview, 0, 0)); ++ this.previews.put(slot.index, new Preview(heldPreview, null)); ++ } finally { graphics.pose().popMatrix(); } ++ return; ++ } + final var ghost = this.craftingGhost(slot); + if (!ghost.isEmpty()) { + if (box.visibleBounds().contains(mouseX, mouseY)) NativeInventoryItemPresentation.requireTooltip(ghost); @@ -2534,12 +3140,13 @@ index 0000000..ef59a9f + 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); ++ return new Preview(ghost.isEmpty() ? this.cookingHold.preview(index, slot.getItem()) : ghost, null); + } + + private void count(final GuiGraphicsExtractor graphics, final NativeUiLayout.Box box, final float alpha) { + final var preview = box.node().string("inventory_count", "").equals("bundle") + ? new Preview(this.bundleItem(box.node()), null) ++ : box.node().string("inventory_count", "").equals("selected") ? new Preview(this.selectedItem(box.node()), null) + : this.displayPreview(box.node().properties().get("inventory_slot").getAsInt()); + final String count = NativeInventoryItemPresentation.countText(preview.stack.getCount(), preview.count); + if (count.isEmpty()) return; @@ -2551,6 +3158,12 @@ index 0000000..ef59a9f + return this.renderedBundle.item(node); + } + ++ private ItemStack selectedItem(final NativeUiScene.Node node) { ++ if (this.selectedPreview == null || node.properties().get("inventory_selected_slot").getAsInt() != this.selectedPreview.slot) ++ throw new IllegalArgumentException("Selected item image has no current source snapshot"); ++ return this.selectedPreview.stack.copy(); ++ } ++ + private void progress(final GuiGraphicsExtractor graphics, final NativeUiLayout.Box box, final float alpha) { + var progress = box; + final var bag = box.node().properties().getAsJsonObject("property_bag"); @@ -2591,10 +3204,18 @@ index 0000000..ef59a9f + } + } + -+ private void cursor(final NativeUiLayout.Box box, final boolean inherited) { ++ private boolean selectedCursorNodes(final NativeUiScene.Node node) { ++ boolean selected = node.properties().has("inventory_selected_slot"); ++ for (final var child : node.children()) selected |= this.selectedCursorNodes(child); ++ if (selected && node.flag("follows_cursor", false)) this.selectedCursorRoots.add(node); ++ return selected; ++ } ++ ++ private void cursor(final NativeUiLayout.Box box, final boolean inherited, final boolean selectedParent) { + final boolean cursor = inherited || box.node().flag("follows_cursor", false); -+ if (cursor) this.vanillaCursor.add(box.node()); -+ for (final var child : box.children()) this.cursor(child, cursor); ++ final boolean selected = selectedParent || this.selectedCursorRoots.contains(box.node()); ++ if (cursor && !selected) this.vanillaCursor.add(box.node()); ++ for (final var child : box.children()) this.cursor(child, cursor, selected); + } + + private void anchors(final NativeUiLayout.Box box) { @@ -2631,6 +3252,7 @@ index 0000000..ef59a9f + } + + private record Preview(ItemStack stack, String count) {} ++ private record SelectedPreview(int slot, ItemStack stack, Boolean showTags, ContainerUi.SelectedItem bindings) {} + + @Override + public void extractBackground(final GuiGraphicsExtractor graphics, final int mouseX, final int mouseY, final float delta) { @@ -2658,12 +3280,25 @@ index 0000000..ef59a9f + if (this.closing()) return true; + if (!this.current()) return false; + this.consumedReleases.remove(event.button()); -+ if (this.search != null && this.search.visible && this.searchBox != null ++ if (this.dragInput != null || this.cookingHold.busy()) { ++ this.mouseMoved(event.x(), event.y()); ++ final String heldSource = source(event.button()); ++ this.pointerSources.put(event.button(), heldSource); ++ // Retain authored button phases without starting another item operation. ++ final var result = this.nativeInput.button(heldSource, true, this.inputMode(), this.clock.getAsLong(), this.viewInputEnabled()); ++ if (!this.waitingForPreference()) this.bookkeepingEvents(result); ++ this.nativeButtons.add(event.button()); ++ return true; ++ } ++ final boolean clearSearch = this.presentation instanceof NativeCookingPresentation ++ && this.geometry.clearsSearch(event.x(), event.y(), source(event.button())); ++ final boolean editorInput = clearSearch && !this.waitingForPreference() && this.capturedButtons.isEmpty(); ++ if (!clearSearch && this.search != null && this.search.visible && this.searchBox != null + && this.searchBox.visibleBounds().contains(event.x(), event.y()) && this.search.mouseClicked(event, doubleClick)) { + this.focusSearch(); + return true; + } -+ if (this.searchInput && NativeUiFocus.targets(this.root, _ -> true).stream() ++ if (!clearSearch && this.searchInput && NativeUiFocus.targets(this.root, _ -> true).stream() + .anyMatch(target -> target.box().visibleBounds().contains(event.x(), event.y()))) this.releaseSearch(); + this.setFocused(null); + final String componentSource = this.presentation instanceof NativePlayerPresentation && event.hasShiftDown() @@ -2673,19 +3308,24 @@ index 0000000..ef59a9f + this.pointerSources.put(event.button(), componentSource); + NativeContainerInput.Result componentEvent = null; + if (this.authoredController()) { -+ final var hover = this.nativeInput.pointer(event.x(), event.y(), 1, this.clock.getAsLong(), this.pointerEnabled()); -+ componentEvent = this.nativeInput.button(componentSource, true, 1, this.clock.getAsLong(), this.componentEnabled()); ++ final var hover = this.nativeInput.pointer(event.x(), event.y(), this.inputMode(), this.clock.getAsLong(), editorInput || this.pointerEnabled()); ++ componentEvent = this.nativeInput.button(componentSource, true, this.inputMode(), this.clock.getAsLong(), editorInput || this.viewInputEnabled()); + if (!this.waitingForPreference()) this.componentEvents(hover); + } -+ if (this.waitingForPreference() || this.presentation instanceof NativePlayerPresentation player && !player.idle()) { ++ if (this.waitingForPreference() || this.presentation instanceof NativePlayerPresentation player && !player.idle() ++ || this.presentation instanceof NativeCookingPresentation cooking && cooking.bundlePending()) { ++ if (componentEvent != null && !this.waitingForPreference()) this.bookkeepingEvents(componentEvent); + this.capturedButtons.add(event.button()); + return true; + } + if (!this.capturedButtons.isEmpty()) { ++ if (componentEvent != null) this.bookkeepingEvents(componentEvent); + this.capturedButtons.add(event.button()); + return true; + } + if (event.button() == InputConstants.MOUSE_BUTTON_LEFT && this.scroll.press(this.root, event.x(), event.y())) { ++ // Scroll capture does not replace the authored controller's held-state callback. ++ if (componentEvent != null) this.bookkeepingEvents(componentEvent); + this.scrollAnimation(false); + this.refreshSafely(); + return true; @@ -2707,8 +3347,8 @@ index 0000000..ef59a9f + } + } + } -+ if (this.presentation instanceof NativePlayerPresentation player && this.geometry.help(event.x(), event.y(), input)) { -+ player.openHelp(); ++ if (this.help() != null && this.geometry.help(event.x(), event.y(), input)) { ++ this.openHelp(); + this.nativeButtons.add(event.button()); + return true; + } @@ -2740,7 +3380,79 @@ index 0000000..ef59a9f + } + return true; + } -+ return super.mouseClicked(event, doubleClick); ++ if (this.presentation instanceof NativeCookingPresentation cooking && cooking.holdsAvailable() && cooking.idle() ++ && event.button() == InputConstants.MOUSE_BUTTON_LEFT && !event.hasShiftDown() && !doubleClick ++ && this.menu.getCarried().isEmpty() && componentEvent != null) { ++ for (final var control : componentEvent.events()) { ++ if (control.button() == null || !control.button().transition() ++ || control.button().target() != NativeUiInput.symbol("button.container_take_all_place_all") ++ || !control.node().properties().has("inventory_slot")) continue; ++ final int index = control.node().properties().get("inventory_slot").getAsInt(); ++ final var slot = this.menu.getSlot(index); ++ if (!slot.isActive() || !slot.hasItem() || !slot.mayPickup(this.minecraft.player)) break; ++ this.cookingHold.pickup(event.button(), index, slot.getItem(), cooking.message().updateSequence(), ++ this.clock.getAsLong(), event.x(), event.y()); ++ cooking.holdInput(true); ++ this.nativeButtons.add(event.button()); ++ this.slotClicked(slot, index, 0, ContainerInput.PICKUP); ++ return true; ++ } ++ } ++ final boolean wasQuickCrafting = this.isQuickCrafting; ++ final boolean handled = super.mouseClicked(event, doubleClick); ++ if (!wasQuickCrafting && this.isQuickCrafting && !doubleClick ++ && (event.button() == InputConstants.MOUSE_BUTTON_LEFT || event.button() == InputConstants.MOUSE_BUTTON_RIGHT) ++ && this.geometry.hovered(event.x(), event.y()) >= 0) { ++ if (ClientPlayNetworking.canSend(NativeInventoryDragPayload.TYPE) ++ && !this.menu.getCarried().has(DataComponents.BUNDLE_CONTENTS)) { ++ this.dragInput = new DragInput(event.button(), this.menu.getCarried().copy()); ++ this.isQuickCrafting = false; ++ this.quickCraftSlots.clear(); ++ this.sendDragInput(-999, this.dragInput.mode() << 2); ++ this.visitDragSlot(event.x(), event.y()); ++ return handled; ++ } ++ // Native drag distribution includes the pressed cell before pointer motion. ++ // Reuse vanilla eligibility so blocked slots and carried-stack limits still apply. ++ super.mouseDragged(event, 0, 0); ++ } ++ return handled; ++ } ++ ++ private void visitDragSlot(final double x, final double y) { ++ final var drag = this.dragInput; ++ if (drag == null || !this.current()) return; ++ final int index = this.geometry.hovered(x, y); ++ if (index < 0 || index >= this.menu.slots.size() || drag.slots.size() >= drag.item.getCount()) return; ++ final var slot = this.menu.getSlot(index); ++ if (drag.slots.contains(index) || !slot.isActive() || !slot.mayPlace(drag.item) ++ || !this.menu.canDragTo(slot) || !AbstractContainerMenu.canItemQuickReplace(slot, drag.item, true)) return; ++ drag.slots.add(index); ++ this.sendDragInput(index, drag.mode() << 2 | 1); ++ } ++ ++ private void sendDragInput(final int slot, final int button) { ++ // Core predicts every visit. Vanilla menu quick craft would redistribute again on release. ++ this.owner.send(new ServerboundContainerClickPacket(this.menu.containerId, this.menu.getStateId(), ++ (short) slot, (byte) button, ContainerInput.QUICK_CRAFT, Int2ObjectMaps.emptyMap(), ++ HashedStack.create(this.menu.getCarried(), this.owner.decoratedHashOpsGenenerator()))); ++ } ++ ++ private void finishDragInput() { ++ final var drag = this.dragInput; ++ if (drag == null) return; ++ this.dragInput = null; ++ this.consumedReleases.add(drag.button); ++ if (this.current()) this.sendDragInput(-999, drag.mode() << 2 | 2); ++ } ++ ++ private static final class DragInput { ++ private final int button; ++ private final ItemStack item; ++ private final Set slots = new HashSet<>(); ++ ++ private DragInput(final int button, final ItemStack item) { this.button = button; this.item = item; } ++ private int mode() { return this.button == InputConstants.MOUSE_BUTTON_RIGHT ? 1 : 0; } + } + + private static String source(final int button) { @@ -2756,28 +3468,217 @@ index 0000000..ef59a9f + if (this.closing()) return; + this.pointerX = x; + this.pointerY = y; ++ if (this.current() && this.cookingHold.busy() && this.presentation instanceof NativeCookingPresentation cooking) { ++ this.cookingHold.pointer(x, y, this.geometry.contains(this.cookingHold.slot(), x, y)); ++ this.cookingHold.flush(cooking, this.menu.getCarried(), this::sendCookingHold); ++ cooking.quantity(this.cookingHold.quantity()); ++ } + if (this.current() && this.authoredController()) { -+ final var result = this.nativeInput.pointer(x, y, 1, this.clock.getAsLong(), this.pointerEnabled()); ++ final var result = this.nativeInput.pointer(x, y, this.inputMode(), this.clock.getAsLong(), this.pointerEnabled()); ++ if (this.helping()) this.nativeInput.focusHovered(); + if (!this.waitingForPreference()) this.componentEvents(result); ++ this.synchronizeSearchInput(); + } ++ this.visitDragSlot(x, y); + super.mouseMoved(x, y); + } + + private boolean componentEnabled() { -+ return (this.presentation instanceof NativeCookingPresentation cooking && cooking.idle() ++ return (this.helping() || this.presentation instanceof NativeCookingPresentation cooking && cooking.idle() && !this.cookingHold.busy() + || this.presentation instanceof NativePlayerPresentation player && (player.helping() || player.idle())) + && !this.waitingForPreference() && this.capturedButtons.isEmpty(); + } + ++ private boolean viewInputEnabled() { ++ // Native components still consume input while their controller waits for inventory authority. ++ return this.presentation instanceof NativeCookingPresentation && !this.waitingForPreference() || this.componentEnabled(); ++ } ++ + private boolean pointerEnabled() { ++ // The optional Touch style maps a held mouse button to contact, not hover-only motion. ++ if (this.inputMode() == 2 && !this.pointerSources.containsKey(InputConstants.MOUSE_BUTTON_LEFT) ++ && !this.pointerSources.containsKey(InputConstants.MOUSE_BUTTON_RIGHT)) return false; + // Pending item commands block buttons, not physical hover or its retained source. -+ return this.presentation instanceof NativePlayerPresentation && !this.waitingForPreference() -+ || this.componentEnabled(); ++ if (this.dragInput != null) return !this.waitingForPreference(); ++ return (this.presentation instanceof NativePlayerPresentation || this.presentation instanceof NativeCookingPresentation) ++ && !this.waitingForPreference() || this.componentEnabled(); + } + -+ private boolean componentEvents(final NativeContainerInput.Result result) { ++ private boolean cookingHoldInside() { ++ return this.cookingHold.inside(this.geometry); ++ } ++ ++ void advanceCookingInput() { ++ if (!this.current() || this.closing() || !(this.presentation instanceof NativeCookingPresentation cooking)) return; ++ final boolean changed = this.cookingHold.frame(this.clock.getAsLong(), this.cookingHoldInside(), this.menu.getCarried(), ++ cooking, this::sendCookingHold); ++ cooking.holdInput(this.cookingHold.busy()); ++ if (changed) { ++ cooking.quantity(this.cookingHold.quantity()); ++ this.refresh(); ++ } ++ } ++ ++ private void sendCookingHold(final NativeCookingHoldAction action) { ++ ClientPlayNetworking.send(new NativeCookingHoldPayload(action)); ++ } ++ ++ private void retainSelectedSource() { ++ if (this.presentation instanceof NativeCookingPresentation cooking) { ++ final var selected = cooking.selectedSource(); ++ this.tooltipSources.primary(new Source(selected.collection(), selected.index())); ++ } ++ } ++ ++ private void publishSelectedSource() { ++ if (this.presentation instanceof NativeCookingPresentation cooking) { ++ final var selected = this.tooltipSources.primary(); ++ cooking.selectedSource(new CookingUi.Selection(selected.collection(), selected.index())); ++ final var cell = this.sourceInput != null && this.inputMode() == 2 && this.menu.getCarried().isEmpty() ++ ? this.sourceInput.selection() : null; ++ if (cell == null) this.selectedPreview = null; ++ else { ++ final var item = this.menu.getSlot(cell.slot()).getItem(); ++ if (this.selectedPreview == null || this.selectedPreview.slot != cell.slot() ++ || this.selectedPreview.stack.getCount() != cell.count() ++ || !Objects.equals(this.selectedPreview.showTags, this.showTags) ++ || !ItemStack.isSameItemSameComponents(this.selectedPreview.stack, item)) { ++ final var copy = item.copyWithCount(cell.count()); ++ this.selectedPreview = new SelectedPreview(cell.slot(), copy, this.showTags, ++ new ContainerUi.SelectedItem(cell.slot(), cell.count(), NativeInventoryItemPresentation.values(copy, true, this.showTags))); ++ } ++ } ++ cooking.selectedItem(this.selectedPreview == null ? null : this.selectedPreview.bindings); ++ } ++ } ++ ++ void advanceSourceInput(final double x, final double y) { ++ if (this.sourceInput == null || !this.current() || this.closing()) return; ++ if (this.inputMode() == 2 && !this.helping() && !this.helpView && !this.closing()) { ++ final float scale = (float) Math.max(1, this.minecraft.getWindow().getGuiScale()); ++ this.sourceInput.frame(this.clock.getAsLong(), x * scale, y * scale, scale, ++ this.pointerSources.containsKey(InputConstants.MOUSE_BUTTON_LEFT) ++ || this.pointerSources.containsKey(InputConstants.MOUSE_BUTTON_RIGHT)); ++ } else this.sourceInput.frame(); ++ this.publishSelectedSource(); ++ if (this.presentation.revision() != this.previousPresentationRevision) this.refresh(); ++ } ++ ++ private boolean resetCooking(final NativeContainerInput.Event event) { ++ if (!(this.presentation instanceof NativeCookingPresentation cooking) || this.helping() || this.helpView ++ || !this.current() || this.closing() || !this.nativeInput.enabled(event.node())) return false; ++ switch (NativeCookingReset.edge(event.button())) { ++ case EMPTY_SOURCE -> { ++ this.retainSelectedSource(); ++ this.tooltipSources.emptySource(); ++ this.publishSelectedSource(); ++ } ++ case COMPLETE -> { ++ final boolean source = this.sourceInput != null && this.inputMode() == 2 && this.sourceInput.finish(); ++ final boolean dragging = this.dragInput != null; ++ this.finishDragInput(); ++ this.cookingHold.reset(); ++ this.cookingHold.flush(cooking, this.menu.getCarried(), this::sendCookingHold); ++ cooking.holdInput(this.cookingHold.busy()); ++ cooking.quantity(this.cookingHold.quantity()); ++ final boolean recipe = cooking.resetRecipe(action -> ClientPlayNetworking.send(new NativeCookingActionPayload(action))); ++ if (source || dragging || recipe) { ++ this.retainSelectedSource(); ++ this.tooltipSources.finishOperation(this.tooltipData(this.tooltipSources.primary()) != null); ++ this.publishSelectedSource(); ++ } ++ if (recipe) { ++ final var press = this.recipePress; ++ this.recipePress = null; ++ if (press != null && press.button >= 0) { ++ this.capturedButtons.remove(press.button); ++ this.nativeButtons.remove(press.button); ++ this.consumedReleases.add(press.button); ++ } ++ } ++ } ++ case NONE -> { } ++ } ++ return true; ++ } ++ ++ private boolean controllerEvents(final NativeContainerInput.Result result) { ++ final boolean available = !this.helpView && !this.helping() && this.dragInput == null; ++ boolean handled = false; ++ for (final var event : result.events()) { ++ final var input = event.button(); ++ if (input == null || input.phase() != 0 && input.phase() != 1) continue; ++ if (this.sourceInput != null) this.sourceInput.control(input); ++ if (this.authoredController() && input.target() == NativeUiInput.symbol("button.arrow_key")) ++ this.tooltipSources.arrowInput(input, this.clock.getAsLong()); ++ if (this.sourceInput != null && this.inputMode() == 2 && !this.helping() && !this.helpView ++ && this.current() && !this.closing() && this.nativeInput.enabled(event.node()) ++ && this.sourceInput.event(input, new Source(event.collection(), event.collectionIndex()), this.menu.getCarried().isEmpty())) { ++ handled = true; ++ continue; ++ } ++ if (NativeCookingReset.target(input.target()) && this.presentation instanceof NativeCookingPresentation) { ++ handled |= this.resetCooking(event) && result.consumed(); ++ continue; ++ } ++ if (this.authoredController() && NativeInventorySourceState.navigation(input.target())) { ++ if (input.transition() && input.pressed() && available && this.current() && !this.closing() ++ && this.componentEnabled() && this.nativeInput.enabled(event.node())) { ++ try { ++ final var selected = this.sourceItem(this.tooltipSources.selected(this.menu.getCarried().isEmpty())); ++ final var contents = this.tooltipData(this.tooltipSources.bundle()); ++ if (this.tooltipSources.mode() == 2 && contents != null ++ && !this.bundleInputAvailable(true)) ++ throw new IllegalArgumentException("Native bundle selection transport is unavailable"); ++ handled |= this.tooltipSources.navigate(input.target(), selected != null && selected.isEmpty(), ++ contents == null ? null : BundleTooltipLayout.resolve(contents.contents())); ++ } catch (final RuntimeException unsupported) { this.fallback(unsupported); handled = true; } ++ } ++ continue; ++ } ++ if (this.presentation instanceof NativeCookingPresentation cooking && NativeCookingHold.returnTarget(input.target())) { ++ if (this.current() && !this.closing() && this.nativeInput.enabled(event.node())) { ++ this.cookingHold.returnInput(input); ++ this.cookingHold.flush(cooking, this.menu.getCarried(), this::sendCookingHold); ++ cooking.holdInput(this.cookingHold.busy()); ++ cooking.quantity(this.cookingHold.quantity()); ++ } ++ handled |= result.consumed(); ++ continue; ++ } ++ if (this.presentation instanceof NativeCookingPresentation && NativeCookingNavigation.unregistered(input.target())) { ++ // A missing native controller callback does not undo the component's input consumption. ++ handled |= result.consumed(); ++ continue; ++ } ++ if (NativeContainerInput.exitTarget(input.target())) { ++ if (input.transition() && this.current() && !this.closing() && this.nativeInput.enabled(event.node()) ++ && (input.target() != NativeUiInput.symbol("button.menu_inventory_exit") || !this.searchHovered())) ++ this.onClose(); ++ handled = true; ++ continue; ++ } ++ final int button = NativeContainerInput.cursorDropButton(input.target()); ++ if (button < 0) continue; ++ if (input.transition() && available && this.current() && !this.closing() && this.componentEnabled() && this.nativeInput.enabled(event.node()) ++ && !this.actionScope.subset() && !this.menu.getCarried().isEmpty()) ++ this.slotClicked(null, -999, button, ContainerInput.PICKUP); ++ handled = true; ++ } ++ return handled; ++ } ++ ++ private boolean bookkeepingEvents(final NativeContainerInput.Result result) { ++ if (this.presentation instanceof NativeCookingPresentation && !this.componentEnabled() ++ && result.events().stream().anyMatch(event -> event.change() != null)) this.nativeInput.restoreToggleBindings(); ++ this.retainSelectedSource(); + try { + for (final var event : result.events()) { ++ final var input = event.button(); ++ if (input != null && input.phase() == 1 && input.previous() != 1 ++ && (input.target() == NativeUiInput.symbol("button.container_take_all_place_all") ++ || input.target() == NativeUiInput.symbol("button.container_take_half_place_one"))) ++ this.tooltipSources.prePress(new Source(event.collection(), event.collectionIndex())); + if (event.button() == null || event.button().target() != NativeUiInput.symbol("button.container_slot_hovered") + || event.button().phase() != 0 && event.button().phase() != 1) continue; + final Source source = new Source(event.collection(), event.collectionIndex()); @@ -2788,36 +3689,71 @@ index 0000000..ef59a9f + this.fallback(error); + return true; + } -+ if (this.presentation instanceof NativePlayerPresentation player) return this.playerEvents(player, result); -+ if (!(this.presentation instanceof NativeCookingPresentation cooking)) return false; ++ final boolean handled = this.controllerEvents(result); ++ this.publishSelectedSource(); ++ return handled; ++ } ++ ++ private boolean componentEvents(final NativeContainerInput.Result result) { ++ if (this.helping()) { ++ for (final var event : result.events()) { ++ if (event.button() != null && event.button().phase() != 0 && event.button().phase() != 1) continue; ++ if (event.change() != null && event.change().checked() && event.node().properties().has("help_topic")) { ++ this.help().select(event.node().properties().get("help_topic").getAsInt()); ++ } else if (event.button() != null && event.button().transition() ++ && (event.node().flag("help_close", false) || NativeContainerInput.exitTarget(event.button().target()))) { ++ this.help().close(); ++ break; ++ } ++ } ++ return true; ++ } ++ if (this.help() != null && this.componentEnabled()) for (final var event : result.events()) { ++ if (event.button() != null && (event.button().phase() == 0 || event.button().phase() == 1) ++ && event.button().transition() && event.button().target() == NativeUiInput.symbol("button.help")) { ++ this.openHelp(); ++ return true; ++ } ++ } ++ final boolean controllerHandled = this.bookkeepingEvents(result); ++ if (this.presentation instanceof NativePlayerPresentation player) return this.playerEvents(player, result) || controllerHandled; ++ if (!(this.presentation instanceof NativeCookingPresentation cooking)) return controllerHandled; + final var message = cooking.message(); + final var initial = NativeCookingPreferenceControl.snapshot(message.view().family(), message.view().book()); + FurnaceOptions desired = initial; -+ boolean handled = false; ++ boolean handled = controllerHandled; + for (final var event : result.events()) { + if (event.button() != null && event.button().phase() != 0 && event.button().phase() != 1) continue; + if (event.change() != null) { ++ if (!this.componentEnabled()) continue; + final var preference = NativeCookingPreferenceControl.parse(event.node()); + if (preference != null) { + desired = this.changePreference(preference, event.change(), desired); + handled = true; + } -+ } else if (NativeCookingNavigation.supports(event.button().target())) { ++ } else if (event.button() != null && event.button().target() == NativeUiInput.symbol("button.search_bar_clear") && event.button().transition()) { ++ handled |= this.clearSearch(); ++ } else if (event.button() != null && event.button().target() == NativeUiInput.symbol("button.text_edit_box_clear") ++ && event.button().transition() && this.geometry.clearsSearch(event.node())) { ++ if (this.searchInput) this.clearSearch(); ++ handled = true; ++ } else if (event.button() != null && event.node().type().equals("edit_box") ++ && event.button().target() == NativeUiInput.symbol("button.search_bar_selected") && event.button().pressed() && this.search != null) { ++ if (event.button().handleSelect()) this.focusSearch(); ++ else if (event.button().handleDeselect()) this.setFocused(null); ++ handled = true; ++ } else if (event.button() != null && event.node().type().equals("edit_box") && event.button().pressed() ++ && event.button().target() == NativeUiInput.symbol("button.search_bar_deselected")) { ++ if (event.button().handleDeselect()) this.setFocused(null); ++ handled = true; ++ } else if (event.button() != null && NativeCookingNavigation.supports(event.button().target())) { + if (cooking.idle() && this.nativeInput.enabled(event.node()) + && message.updateSequence() == this.cookingUpdate && Minecraft.getInstance().gui.screen() == this) { + final var transition = NativeCookingNavigation.transition(desired, message.view().book(), event.button()); + desired = transition.options(); -+ if (transition.refresh()) cooking.tabChanged(); ++ if (transition.refresh()) this.tooltipSources.clearHover(); + } + handled = true; -+ } else { -+ if (event.button().target() == NativeUiInput.symbol("button.filter_toggle_hovered") && event.button().pressed()) -+ cooking.select("filter_toggle", 0); -+ else if (event.button().target() == NativeUiInput.symbol("button.container_slot_hovered")) { -+ final String collection = event.collection(); -+ if (event.button().pressed()) cooking.select(collection, event.collectionIndex()); -+ else cooking.deselect(collection, event.collectionIndex()); -+ } + } + } + if (!desired.equals(initial)) { @@ -2836,8 +3772,15 @@ index 0000000..ef59a9f + return handled; + } + ++ private boolean bundleInputAvailable(final boolean selection) { ++ if (this.presentation instanceof NativePlayerPresentation) ++ return selection ? BedrockNativePlayerInventory.bundleSelectionAvailable(this.owner, this.menu) ++ : BedrockNativePlayerInventory.bundlesAvailable(this.owner, this.menu); ++ return this.presentation instanceof NativeCookingPresentation cooking && cooking.bundlesAvailable(); ++ } ++ + private boolean bundleEvent(final NativeContainerInput.Event event) { -+ if (!(this.presentation instanceof NativePlayerPresentation player) || player.helping() || event.button() == null ++ if (!this.authoredController() || this.helping() || this.helpView || event.button() == null + || !event.button().transition() || event.button().target() != NativeUiInput.symbol("button.container_take_all_place_all") + && event.button().target() != NativeUiInput.symbol("button.container_take_half_place_one")) return false; + try { @@ -2846,8 +3789,23 @@ index 0000000..ef59a9f + if (clicked == null) return false; + final var kind = this.tooltipSources.desktopBundleInput(event, this.tooltipData(NativeInventorySourceState.CURSOR) != null, + this.menu.getCarried().isEmpty(), this.tooltipData(source) != null, clicked.isEmpty()); -+ if (kind == null || !BedrockNativePlayerInventory.bundlesAvailable(this.owner, this.menu)) return false; -+ if (player.idle()) BedrockNativePlayerInventory.bundle(this.owner, this.menu, source, kind, this.tooltipSources.mode()); ++ if (kind == null) return false; ++ if (!this.bundleInputAvailable(false)) { ++ if (this.presentation instanceof NativeCookingPresentation) ++ throw new IllegalArgumentException("Native cooking bundle transport is unavailable"); ++ return false; ++ } ++ final var root = this.menu.getCarried().isEmpty() ? source : NativeInventorySourceState.CURSOR; ++ final int selectedCell = kind == NativeBundleAction.Kind.SECONDARY ? this.tooltipSources.selectedCell(root) : -1; ++ if (selectedCell >= 0 && !this.bundleInputAvailable(true)) ++ throw new IllegalArgumentException("Native bundle selection transport is unavailable"); ++ if (this.presentation instanceof NativePlayerPresentation player && player.idle()) { ++ if (selectedCell >= 0) BedrockNativePlayerInventory.bundle(this.owner, this.menu, source, kind, this.tooltipSources.mode(), selectedCell); ++ else BedrockNativePlayerInventory.bundle(this.owner, this.menu, source, kind, this.tooltipSources.mode()); ++ } else if (this.presentation instanceof NativeCookingPresentation cooking) { ++ cooking.bundle(source.collection(), source.index(), kind, this.tooltipSources.mode(), selectedCell, ++ action -> ClientPlayNetworking.send(new NativeCookingBundlePayload(action))); ++ } + return true; + } catch (final RuntimeException unsupported) { this.fallback(unsupported); return true; } + } @@ -2881,6 +3839,10 @@ index 0000000..ef59a9f + public boolean mouseDragged(final MouseButtonEvent event, final double dx, final double dy) { + if (this.closing()) return true; + if (this.authoredController()) this.mouseMoved(event.x(), event.y()); ++ if (this.dragInput != null) { ++ if (this.dragInput.button == event.button()) this.visitDragSlot(event.x(), event.y()); ++ return true; ++ } + if (this.capturedButtons.contains(event.button()) || this.nativeButtons.contains(event.button())) return true; + if (!this.current()) return false; + if (event.button() == InputConstants.MOUSE_BUTTON_LEFT && this.scroll.drag(this.root, event.x(), event.y())) { @@ -2894,13 +3856,46 @@ index 0000000..ef59a9f + @Override + public boolean mouseReleased(final MouseButtonEvent event) { + if (this.closing()) return true; -+ final String pressedSource = this.pointerSources.remove(event.button()); ++ boolean gestureReleaseDelivered = !this.authoredController(); ++ final boolean touch = this.inputMode() == 2; ++ // Keep contact through the final slot release, then retire its pointer hover. ++ final String pressedSource = touch ? this.pointerSources.get(event.button()) : this.pointerSources.remove(event.button()); + final String componentSource = pressedSource == null ? source(event.button()) : pressedSource; + if (this.current() && this.authoredController()) { + if (this.helpView) this.inventoryInput.button(componentSource, false, 1, this.clock.getAsLong(), false); -+ final var hover = this.nativeInput.pointer(event.x(), event.y(), 1, this.clock.getAsLong(), this.pointerEnabled()); -+ final var released = this.nativeInput.button(componentSource, false, 1, this.clock.getAsLong(), this.componentEnabled()); ++ final var hover = this.nativeInput.pointer(event.x(), event.y(), this.inputMode(), this.clock.getAsLong(), this.pointerEnabled()); ++ final var released = this.nativeInput.button(componentSource, false, this.inputMode(), this.clock.getAsLong(), ++ this.viewInputEnabled() || this.dragInput != null || this.cookingHold.busy()); + if (!this.waitingForPreference()) { this.componentEvents(hover); this.componentEvents(released); } ++ gestureReleaseDelivered = released.events().stream().anyMatch(control -> control.button() != null ++ && control.button().phase() == 0 ++ && (this.presentation instanceof NativeCookingPresentation && NativeCookingReset.edge(control.button()) == NativeCookingReset.Edge.COMPLETE ++ || control.node().properties().has("inventory_slot") ++ && (control.button().target() == NativeUiInput.symbol("button.container_take_all_place_all") ++ || control.button().target() == NativeUiInput.symbol("button.container_take_half_place_one")))); ++ } ++ if (touch) { ++ this.pointerSources.remove(event.button()); ++ if (this.current() && this.inputMode() == 2 && !this.waitingForPreference()) ++ this.componentEvents(this.nativeInput.pointer(event.x(), event.y(), this.inputMode(), this.clock.getAsLong(), this.pointerEnabled())); ++ } ++ if (this.current() && this.presentation instanceof NativeCookingPresentation cooking ++ && this.cookingHold.release(event.button(), gestureReleaseDelivered)) { ++ this.nativeButtons.remove(event.button()); ++ this.consumedReleases.add(event.button()); ++ this.cookingHold.flush(cooking, this.menu.getCarried(), this::sendCookingHold); ++ cooking.holdInput(this.cookingHold.busy()); ++ cooking.quantity(this.cookingHold.quantity()); ++ this.refreshSafely(); ++ return true; ++ } ++ if (this.dragInput != null && (this.authoredController() || this.dragInput.button == event.button())) { ++ // Native keeps redistributing on pointer entry after an unhandled physical release. ++ if (gestureReleaseDelivered) { ++ this.finishDragInput(); ++ this.consumedReleases.add(event.button()); ++ } ++ return true; + } + if (this.nativeButtons.remove(event.button())) { + this.consumedReleases.add(event.button()); @@ -2928,6 +3923,7 @@ index 0000000..ef59a9f + public boolean keyPressed(final KeyEvent event) { + if (this.closing()) return true; + if (!this.current()) return false; ++ if (this.cookingHold.busy() && event.key() == InputConstants.KEY_ESCAPE) { this.onClose(); return true; } + if (this.searchInput) { + if (event.key() != InputConstants.KEY_ESCAPE) { + if (this.search.isFocused()) this.search.keyPressed(event); @@ -2938,25 +3934,46 @@ index 0000000..ef59a9f + return true; + } + } -+ if (this.presentation instanceof NativePlayerPresentation && !this.helpView && this.geometry != null -+ && this.geometry.recipe(this.pointerX, this.pointerY) != null && Minecraft.getInstance().options.keyDrop.matches(event)) { -+ this.fallback(new IllegalArgumentException("Native recipe drop input is not supported")); -+ return true; -+ } -+ if (this.waitingForPreference() || this.presentation instanceof NativePlayerPresentation player && !player.idle()) { ++ if (this.waitingForPreference() || this.presentation instanceof NativePlayerPresentation player && !player.idle() ++ || this.presentation instanceof NativeCookingPresentation cooking && cooking.bundlePending()) { + if (event.key() == InputConstants.KEY_ESCAPE) this.onClose(); + else { + final String source = keyboardSource(event.key()); -+ if (source != null) this.nativeInput.button(source, true, 1, this.clock.getAsLong(), false); ++ if (source != null) { ++ final String alias = this.keyboardAlias(event.key()); ++ if (alias != null) { ++ final var state = this.nativeInput.button(alias, true, this.inputMode(), this.clock.getAsLong(), this.viewInputEnabled()); ++ if (!this.waitingForPreference()) this.bookkeepingEvents(state); ++ } ++ final var result = this.nativeInput.button(source, true, this.inputMode(), this.clock.getAsLong(), this.viewInputEnabled()); ++ if (!this.waitingForPreference()) this.bookkeepingEvents(result); ++ } + if (event.key() == InputConstants.KEY_RETURN || event.key() == InputConstants.KEY_SPACE) this.keyboardPressed = true; + } + return true; + } + if (this.authoredController()) { -+ final String source = keyboardSource(event.key()); -+ if (source != null) { -+ if (event.key() == InputConstants.KEY_RETURN || event.key() == InputConstants.KEY_SPACE) this.keyboardPressed = true; -+ return this.keyboardInput(source, true); ++ final String exit = this.exitSource(event); ++ if (exit != null) { ++ if (exit.equals("button.menu_inventory_cancel") && this.searchHovered()) return true; ++ this.keyboardExit(exit); ++ return true; ++ } ++ } ++ if (this.authoredController() && !this.helpView ++ && Minecraft.getInstance().options.keyDrop.matches(event)) { ++ final String source = this.dropSources.computeIfAbsent(event.key(), _ -> event.hasControlDown() ++ ? "button.menu_inventory_drop_all" : "button.menu_inventory_drop"); ++ this.keyboardInput(source, true); ++ return true; ++ } ++ if (this.authoredController()) { ++ final String source = keyboardSource(event.key()); ++ if (source != null) { ++ final String alias = this.keyboardAlias(event.key()); ++ if (alias != null) this.keyboardInput(alias, true); ++ if (event.key() == InputConstants.KEY_RETURN || event.key() == InputConstants.KEY_SPACE) this.keyboardPressed = true; ++ return this.keyboardInput(source, true); + } + } + if (this.helpView) { @@ -2972,26 +3989,39 @@ index 0000000..ef59a9f + private boolean supportsKeyboardInput(final NativeUiScene.Node node, final NativeUiInput.Mapping mapping) { + if (mapping.scope() == NativeUiInput.Scope.VIEW) return NativeContainerInput.supportsViewTarget(mapping.target()); + final int target = mapping.target(); ++ if (NativeContainerInput.exitTarget(target)) return true; ++ if (this.presentation instanceof NativeCookingPresentation && NativeCookingNavigation.unregistered(target)) return true; ++ if (this.presentation instanceof NativeCookingPresentation && NativeCookingSourceInput.controlTarget(target)) return true; ++ if (this.presentation instanceof NativeCookingPresentation && (NativeCookingHold.returnTarget(target) || NativeCookingReset.target(target))) return true; + if (node.type().equals("toggle") || target == NativeUiInput.symbol("button.container_slot_hovered") + || NativeCookingNavigation.supports(target)) return true; + if (node.properties().has("recipe_id")) { + final int recipe = node.properties().get("recipe_id").getAsInt(); -+ return this.recipeActions.getOrDefault(recipe, Set.of()).stream().anyMatch(action -> NativeUiInput.symbol(action) == target); ++ return this.authoredController() && isDropTarget(target) ++ || this.recipeActions.getOrDefault(recipe, Set.of()).stream().anyMatch(action -> NativeUiInput.symbol(action) == target); + } ++ if (this.authoredController() && NativeInventorySourceState.navigation(target)) return true; + if (this.presentation instanceof NativePlayerPresentation && playerUnsupportedHostAction(target)) return true; + if (node.properties().has("inventory_slot")) + return this.presentation instanceof NativePlayerPresentation && playerSlotInput(target) != null ++ || this.presentation instanceof NativeCookingPresentation && (isDropTarget(target) || cookingSlotInput(target) != null) + || this.nativeActions && this.actionScope.subset() && target == NativeUiInput.symbol("button.container_auto_place_one"); -+ if (this.presentation instanceof NativePlayerPresentation && target == NativeUiInput.symbol("button.help")) return true; ++ if (this.presentation instanceof NativeCookingPresentation cooking && cooking.searchAvailable() ++ && target == NativeUiInput.symbol("button.search_bar_clear")) return true; ++ if (this.help() != null && target == NativeUiInput.symbol("button.help")) return true; + if (this.presentation instanceof NativePlayerPresentation && target == NativeUiInput.symbol("button.hide_bundle_tooltip")) return true; + if (this.presentation instanceof NativePlayerPresentation && target == NativeUiInput.symbol("button.tapped_bundle_item_icon")) return true; -+ if (this.presentation instanceof NativePlayerPresentation && cursorDropButton(target) >= 0) return true; -+ if (this.presentation instanceof NativePlayerPresentation && node.type().equals("edit_box") ++ if (this.authoredController() && NativeContainerInput.cursorDropButton(target) >= 0) return true; ++ if (this.authoredController() && node.type().equals("edit_box") + && target == NativeUiInput.symbol("button.search_bar_selected")) return true; + return (node.string("inventory_command", "").equals("close") || node.flag("help_close", false)) + && (target == NativeUiInput.symbol("button.menu_exit") || target == NativeUiInput.symbol("button.try_menu_exit")); + } + ++ private static boolean isDropTarget(final int target) { ++ return target == NativeUiInput.symbol("button.drop_one") || target == NativeUiInput.symbol("button.drop_all"); ++ } ++ + private static SlotInput playerSlotInput(final int target) { + for (final var entry : PLAYER_SLOT_INPUTS.entrySet()) + if (NativeUiInput.symbol(entry.getKey()) == target) return entry.getValue(); @@ -3005,25 +4035,56 @@ index 0000000..ef59a9f + return true; + } + ++ private static SlotInput cookingSlotInput(final int target) { ++ final var input = playerSlotInput(target); ++ return input != null && input.input() == ContainerInput.PICKUP ? input : null; ++ } ++ ++ private boolean cookingKeyboardSlot(final NativeContainerInput.Event event) { ++ final var input = cookingSlotInput(event.button().target()); ++ if (input == null || !event.node().properties().has("inventory_slot")) return false; ++ if (!(this.presentation instanceof NativeCookingPresentation cooking) || !cooking.idle() ++ || this.cookingHold.busy() || this.actionScope.subset()) return true; ++ final int index = event.node().properties().get("inventory_slot").getAsInt(); ++ final var slot = this.menu.getSlot(index); ++ if (!slot.isActive() || this.menu.getCarried().isEmpty() && slot.hasItem() && !slot.mayPickup(this.minecraft.player)) return true; ++ if (input.button() == 0 && this.menu.getCarried().isEmpty() && slot.hasItem() && cooking.holdsAvailable()) { ++ final var bounds = this.nativeInput.bounds(event.node()); ++ if (bounds != null && this.cookingHold.pickupReturn(index, slot.getItem(), cooking.message().updateSequence(), ++ this.clock.getAsLong(), bounds.x() + bounds.width() / 2, bounds.y() + bounds.height() / 2)) ++ cooking.holdInput(true); ++ } ++ this.slotClicked(slot, index, input.button(), input.input()); ++ return true; ++ } ++ + private record SlotInput(ContainerInput input, int button) {} + + private static boolean playerUnsupportedHostAction(final int target) { + return NativeContainerGeometry.PLAYER_FALLBACK_ACTIONS.stream().anyMatch(action -> NativeUiInput.symbol(action) == target); + } + -+ private static int cursorDropButton(final int target) { -+ if (target == NativeUiInput.symbol("button.cursor_drop_all")) return 0; -+ if (target == NativeUiInput.symbol("button.cursor_drop_one")) return 1; -+ return -1; -+ } -+ + private boolean keyboardEvents(final NativeContainerInput.Result result) { ++ if (this.presentation instanceof NativeCookingPresentation && (!this.componentEnabled() || this.dragInput != null)) ++ return this.bookkeepingEvents(result) || result.consumed(); + boolean handled = this.componentEvents(result); + for (final var event : result.events()) { + final var button = event.button(); + if (button == null || button.phase() != 0 && button.phase() != 1) continue; + final var node = event.node(); -+ if (node.properties().has("recipe_id")) { ++ if (this.authoredController() && !this.helpView && isDropTarget(button.target())) { ++ if (button.transition()) { ++ // Native slot bindings prefer a held stack for drop_one. A held stack blocks ++ // drop_all, including when the pointed slot contains another item. ++ if (!this.menu.getCarried().isEmpty()) { ++ if (button.target() == NativeUiInput.symbol("button.drop_one")) ++ this.slotClicked(null, -999, 1, ContainerInput.PICKUP); ++ } else if (node.properties().has("inventory_slot")) { ++ this.playerSlotAction(node.properties().get("inventory_slot").getAsInt(), button.target()); ++ } ++ } ++ handled = true; ++ } else if (node.properties().has("recipe_id")) { + final int recipe = node.properties().get("recipe_id").getAsInt(); + final var mode = java.util.Arrays.stream(NativeCookingAction.Mode.values()) + .filter(candidate -> NativeUiInput.symbol(candidate.button()) == button.target()).findFirst().orElse(null); @@ -3040,8 +4101,7 @@ index 0000000..ef59a9f + } + } else if (this.bundleEvent(event)) { + handled = true; -+ } else if (button.transition() && this.presentation instanceof NativePlayerPresentation && !this.helpView && cursorDropButton(button.target()) >= 0) { -+ this.slotClicked(null, -999, cursorDropButton(button.target()), ContainerInput.PICKUP); ++ } else if (button.transition() && this.presentation instanceof NativeCookingPresentation && this.cookingKeyboardSlot(event)) { + handled = true; + } else if (button.transition() && this.presentation instanceof NativePlayerPresentation && node.properties().has("inventory_slot") + && this.playerSlotAction(node.properties().get("inventory_slot").getAsInt(), button.target())) { @@ -3053,7 +4113,7 @@ index 0000000..ef59a9f + this.send(NativeContainerAction.Mode.AUTO_PLACE_ONE, slot); + handled = true; + } else if (button.transition() && node.string("inventory_command", "").equals("close") -+ && (button.target() == NativeUiInput.symbol("button.menu_exit") || button.target() == NativeUiInput.symbol("button.try_menu_exit"))) { ++ && button.target() == NativeUiInput.symbol("button.try_menu_exit")) { + this.onClose(); + handled = true; + } @@ -3061,20 +4121,50 @@ index 0000000..ef59a9f + return handled || result.consumed(); + } + -+ private static String keyboardSource(final int key) { ++ private String exitSource(final KeyEvent event) { ++ if (event.key() == InputConstants.KEY_ESCAPE) return "button.menu_cancel"; ++ return this.minecraft.options.keyInventory != null && this.minecraft.options.keyInventory.matches(event) ++ ? "button.menu_inventory_cancel" : null; ++ } ++ ++ private String keyboardAlias(final int key) { ++ if (!(this.presentation instanceof NativeCookingPresentation) || this.helpView) return null; ++ return switch (key) { ++ case InputConstants.KEY_RETURN -> "button.return"; ++ case InputConstants.KEY_UP -> "button.arrow_up"; ++ case InputConstants.KEY_DOWN -> "button.arrow_down"; ++ case InputConstants.KEY_LEFT -> "button.arrow_left"; ++ case InputConstants.KEY_RIGHT -> "button.arrow_right"; ++ default -> null; ++ }; ++ } ++ ++ private String keyboardSource(final int key) { + return switch (key) { + case InputConstants.KEY_UP -> "button.menu_up"; + case InputConstants.KEY_DOWN -> "button.menu_down"; + case InputConstants.KEY_LEFT -> "button.menu_left"; + case InputConstants.KEY_RIGHT -> "button.menu_right"; -+ case InputConstants.KEY_RETURN, InputConstants.KEY_SPACE -> "button.menu_ok"; ++ case InputConstants.KEY_RETURN -> "button.menu_ok"; ++ case InputConstants.KEY_SPACE -> this.presentation instanceof NativeCookingPresentation && !this.helpView ? null : "button.menu_ok"; + default -> null; + }; + } + ++ private void keyboardExit(final String source) { ++ final var result = this.nativeInput.button(source, true, this.inputMode(), this.clock.getAsLong(), this.componentEnabled()); ++ this.keyboardEvents(result); ++ // An inactive editor can consume menu_cancel to report deselection. That callback ++ // does not replace the screen exit after active text editing handled Escape above. ++ if (result.events().stream().allMatch(event -> event.button() != null ++ && event.button().target() == NativeUiInput.symbol("button.search_bar_deselected"))) this.onClose(); ++ if (this.updateWindows()) this.refreshSafely(); ++ } ++ + private boolean keyboardInput(final String source, final boolean pressed) { + final var previous = this.nativeInput.focused(); -+ final var result = this.nativeInput.button(source, pressed, 1, this.clock.getAsLong(), this.componentEnabled()); ++ final var result = this.nativeInput.button(source, pressed, this.inputMode(), this.clock.getAsLong(), ++ this.viewInputEnabled() || !pressed && this.cookingHold.busy()); + final boolean handled = !this.waitingForPreference() && this.keyboardEvents(result); + if (this.updateWindows()) this.refreshSafely(); + return handled || previous != this.nativeInput.focused() || result.consumed(); @@ -3088,9 +4178,17 @@ index 0000000..ef59a9f + @Override + public boolean keyReleased(final KeyEvent event) { + if (this.closing()) return true; -+ final String source = keyboardSource(event.key()); ++ final String dropSource = this.dropSources.remove(event.key()); ++ if (dropSource != null) { ++ if (this.current()) this.keyboardInput(dropSource, false); ++ return true; ++ } ++ final String exit = this.exitSource(event); ++ final String source = exit != null ? exit : keyboardSource(event.key()); + if (this.current() && this.authoredController() && source != null) { + if (this.helpView) this.inventoryInput.button(source, false, 1, this.clock.getAsLong(), false); ++ final String alias = this.keyboardAlias(event.key()); ++ if (alias != null) this.keyboardInput(alias, false); + final boolean handled = this.keyboardInput(source, false); + if (event.key() != InputConstants.KEY_RETURN && event.key() != InputConstants.KEY_SPACE) return handled; + final var press = this.recipePress; @@ -3109,6 +4207,8 @@ index 0000000..ef59a9f + if (this.fallingBack) return; + this.fallingBack = true; + try { ++ if (this.sourceInput != null && this.inputMode() == 2) this.sourceInput.finish(); ++ this.finishDragInput(); + this.deactivate(); + this.retireCooking(); + } catch (final RuntimeException transportError) { @@ -3129,9 +4229,12 @@ index 0000000..ef59a9f + } + + private void retireCooking() { ++ this.dragInput = null; ++ this.cookingHold.clear(); + this.capturedButtons.clear(); + this.nativeButtons.clear(); + this.pointerSources.clear(); ++ this.dropSources.clear(); + this.keyboardPressed = false; + this.consumedReleases.clear(); + this.recipePress = null; @@ -3164,10 +4267,10 @@ index 0000000..ef59a9f +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockNativeContainers.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockNativeContainers.java new file mode 100644 -index 0000000..8776ae3 +index 0000000..f6979cf --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockNativeContainers.java -@@ -0,0 +1,283 @@ +@@ -0,0 +1,300 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -3217,6 +4320,10 @@ index 0000000..8776ae3 + } + + public static void register() { ++ PayloadTypeRegistry.clientboundPlay().register(NativeInventoryDragPayload.TYPE, NativeInventoryDragPayload.CODEC); ++ PayloadTypeRegistry.serverboundPlay().register(NativeInventoryDragPayload.TYPE, NativeInventoryDragPayload.CODEC); ++ ClientPlayConnectionEvents.INIT.register((listener, client) -> ++ ClientPlayNetworking.registerReceiver(NativeInventoryDragPayload.TYPE, (payload, context) -> {})); + BedrockNativeCooking.register(); + BedrockPlayerInventory.register(); + BedrockNativePlayerInventory.register(); @@ -3339,6 +4446,10 @@ index 0000000..8776ae3 + presentation); + if (ready != null) present(ready); + } catch (final RuntimeException error) { ++ if (ordinary instanceof BedrockNativeContainerScreen previous) { ++ failedPresentation(previous, error); ++ return; ++ } + ViaFabricPlusBedrock.impl() + .logger() + .warn( @@ -3382,7 +4493,16 @@ index 0000000..8776ae3 + replace(screen); + screen.activate(); + } catch (final RuntimeException error) { -+ failedPresentation(screen, error); ++ if (client.gui.screen() == ordinary ++ && ordinary instanceof BedrockNativeContainerScreen previous) { ++ try { ++ screen.discard(); ++ } finally { ++ previous.fallback(error); ++ } ++ } else { ++ failedPresentation(screen, error); ++ } + } + } + @@ -3444,8 +4564,8 @@ index 0000000..8776ae3 + if (client.gui.screen() != screen) return; + final var window = client.getWindow(); + final var viewport = nativeScreen.nativeViewport(); -+ nativeScreen.mouseMoved(viewport.pointer(client.mouseHandler.getScaledXPos(window)), -+ viewport.pointer(client.mouseHandler.getScaledYPos(window))); ++ nativeScreen.mouseMoved(viewport.pointerX(client.mouseHandler.getScaledXPos(window)), ++ viewport.pointerY(client.mouseHandler.getScaledYPos(window))); + } + + /** Suppress menu teardown only during a presentation swap of the same live menu object. */ @@ -3453,10 +4573,10 @@ index 0000000..8776ae3 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockNativeCooking.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockNativeCooking.java new file mode 100644 -index 0000000..3e78d4d +index 0000000..776b959 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockNativeCooking.java -@@ -0,0 +1,147 @@ +@@ -0,0 +1,219 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -3465,6 +4585,7 @@ index 0000000..3e78d4d +package com.viaversion.viafabricplus.bedrock.screen.container; + +import com.viaversion.viafabricplus.bedrock.ViaFabricPlusBedrock; ++import com.viaversion.viafabricplus.bedrock.appearance.BedrockInventorySounds; +import com.viaversion.viafabricplus.bedrock.creative.BedrockCreativeCatalog; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; @@ -3494,8 +4615,34 @@ index 0000000..3e78d4d + PayloadTypeRegistry.clientboundPlay().register(NativeCookingProgressPayload.TYPE, NativeCookingProgressPayload.CODEC); + PayloadTypeRegistry.clientboundPlay().register(NativeCookingActionResultPayload.TYPE, NativeCookingActionResultPayload.CODEC); + PayloadTypeRegistry.serverboundPlay().register(NativeCookingActionPayload.TYPE, NativeCookingActionPayload.CODEC); ++ PayloadTypeRegistry.serverboundPlay().register(NativeCookingQueryPayload.TYPE, NativeCookingQueryPayload.CODEC); ++ PayloadTypeRegistry.clientboundPlay().register(NativeCookingQueryResultPayload.TYPE, NativeCookingQueryResultPayload.CODEC); ++ PayloadTypeRegistry.serverboundPlay().register(NativeCookingHoldPayload.TYPE, NativeCookingHoldPayload.CODEC); ++ PayloadTypeRegistry.serverboundPlay().register(NativeCookingBundlePayload.TYPE, NativeCookingBundlePayload.CODEC); ++ PayloadTypeRegistry.clientboundPlay().register(NativeCookingBundleResultPayload.TYPE, NativeCookingBundleResultPayload.CODEC); ++ PayloadTypeRegistry.clientboundPlay().register(NativeCookingHoldResultPayload.TYPE, NativeCookingHoldResultPayload.CODEC); ++ PayloadTypeRegistry.serverboundPlay().register(NativeCookingSourcePayload.TYPE, NativeCookingSourcePayload.CODEC); ++ PayloadTypeRegistry.clientboundPlay().register(NativeCookingSourceResultPayload.TYPE, NativeCookingSourceResultPayload.CODEC); + ClientPlayConnectionEvents.INIT.register((owner, client) -> { + PRESENTATIONS.begin(owner); ++ ClientPlayNetworking.registerReceiver(NativeCookingSourceResultPayload.TYPE, (payload, context) -> client.execute(() -> { ++ if (!connected(owner) || client.player == null) return; ++ final var menu = client.player.containerMenu; ++ final var presentation = PRESENTATIONS.current(owner, menu, menu.containerId); ++ if (presentation != null) presentation.result(payload.result()); ++ })); ++ ClientPlayNetworking.registerReceiver(NativeCookingBundleResultPayload.TYPE, (payload, context) -> client.execute(() -> { ++ if (!connected(owner) || client.player == null) return; ++ final var menu = client.player.containerMenu; ++ final var presentation = PRESENTATIONS.current(owner, menu, menu.containerId); ++ if (presentation != null) presentation.result(payload.result(), BedrockInventorySounds::play); ++ })); ++ ClientPlayNetworking.registerReceiver(NativeCookingHoldResultPayload.TYPE, (payload, context) -> client.execute(() -> { ++ if (!connected(owner) || client.player == null) return; ++ final var menu = client.player.containerMenu; ++ final var presentation = PRESENTATIONS.current(owner, menu, menu.containerId); ++ if (presentation != null) presentation.result(payload.result()); ++ })); + ClientPlayNetworking.registerReceiver(NativeCookingPayload.TYPE, + (payload, context) -> client.execute(() -> accept(owner, payload.message()))); + ClientPlayNetworking.registerReceiver(NativeCookingProgressPayload.TYPE, (payload, context) -> client.execute(() -> { @@ -3503,6 +4650,12 @@ index 0000000..3e78d4d + final var menu = client.player.containerMenu; + PRESENTATIONS.progress(owner, menu, menu.containerId, payload.progress()); + })); ++ ClientPlayNetworking.registerReceiver(NativeCookingQueryResultPayload.TYPE, (payload, context) -> client.execute(() -> { ++ if (!connected(owner) || client.player == null) return; ++ final var menu = client.player.containerMenu; ++ final var presentation = PRESENTATIONS.current(owner, menu, menu.containerId); ++ if (presentation != null) presentation.result(payload.result()); ++ })); + ClientPlayNetworking.registerReceiver(NativeCookingActionResultPayload.TYPE, (payload, context) -> client.execute(() -> { + if (!connected(owner) || client.player == null) return; + final var menu = client.player.containerMenu; @@ -3513,7 +4666,26 @@ index 0000000..3e78d4d + ClientTickEvents.END_CLIENT_TICK.register(client -> { + final var menu = client.player == null ? null : client.player.containerMenu; + PRESENTATIONS.capability(client.getConnection(), ClientPlayNetworking.canSend(NativeCookingActionPayload.TYPE)); ++ if (menu != null) { ++ final var previous = PRESENTATIONS.current(client.getConnection(), menu, menu.containerId); ++ if (previous != null && previous.expired()) retire(client.getConnection(), menu, previous); ++ } + PRESENTATIONS.tick(client.getConnection(), menu, menu == null ? -1 : menu.containerId); ++ if (menu != null) { ++ final var presentation = PRESENTATIONS.current(client.getConnection(), menu, menu.containerId); ++ if (presentation != null) { ++ presentation.searchCapability(ClientPlayNetworking.canSend(NativeCookingQueryPayload.TYPE)); ++ presentation.holdCapability(ClientPlayNetworking.canSend(NativeCookingHoldPayload.TYPE)); ++ presentation.sourceCapability(ClientPlayNetworking.canSend(NativeCookingSourcePayload.TYPE)); ++ presentation.bundleCapability(ClientPlayNetworking.canSend(NativeCookingBundlePayload.TYPE)); ++ try { ++ presentation.flushQuery(query -> ClientPlayNetworking.send(new NativeCookingQueryPayload(query))); ++ } catch (final RuntimeException failedTransport) { ++ PRESENTATIONS.retire(client.getConnection(), menu, menu.containerId); ++ ViaFabricPlusBedrock.impl().logger().warn("Unable to send native cooking search", failedTransport); ++ } ++ } ++ } + present(client); + }); + } @@ -3539,13 +4711,26 @@ index 0000000..3e78d4d + } + } + ++ static boolean query(final ClientPacketListener owner, final AbstractContainerMenu menu, final String text) { ++ final var client = Minecraft.getInstance(); ++ if (!connected(owner) || client.player == null || client.player.containerMenu != menu ++ || !(client.gui.screen() instanceof BedrockNativeContainerScreen screen) || screen.getMenu() != menu) return false; ++ final var presentation = PRESENTATIONS.current(owner, menu, menu.containerId); ++ return presentation != null && screen.presents(presentation) && ClientPlayNetworking.canSend(NativeCookingQueryPayload.TYPE) ++ && PRESENTATIONS.query(owner, menu, menu.containerId, text); ++ } ++ + static void retire(final ClientPacketListener owner, final AbstractContainerMenu menu, final NativeCookingPresentation presentation) { + try { + final var client = Minecraft.getInstance(); + final var current = PRESENTATIONS.current(owner, menu, menu.containerId); + if (connected(owner) && client.player != null && client.player.containerMenu == menu + && (current == null || current == presentation) && ClientPlayNetworking.canSend(NativeCookingActionPayload.TYPE)) -+ presentation.cancel(action -> ClientPlayNetworking.send(new NativeCookingActionPayload(action))); ++ presentation.cancel(action -> ClientPlayNetworking.send(new NativeCookingActionPayload(action)), ++ action -> { if (ClientPlayNetworking.canSend(NativeCookingHoldPayload.TYPE)) ++ ClientPlayNetworking.send(new NativeCookingHoldPayload(action)); }, ++ action -> { if (ClientPlayNetworking.canSend(NativeCookingSourcePayload.TYPE)) ++ ClientPlayNetworking.send(new NativeCookingSourcePayload(action)); }); + else presentation.retire(); + } catch (final RuntimeException failedRelease) { + ViaFabricPlusBedrock.impl().logger().warn("Unable to release retired native cooking input", failedRelease); @@ -3586,6 +4771,13 @@ index 0000000..3e78d4d + try { + PRESENTATIONS.accept(owner, menu, menu.containerId, message, + item -> BedrockCreativeCatalog.toNative(item, client.player.registryAccess())); ++ final var presentation = PRESENTATIONS.current(owner, menu, menu.containerId); ++ if (presentation != null) { ++ presentation.searchCapability(ClientPlayNetworking.canSend(NativeCookingQueryPayload.TYPE)); ++ presentation.holdCapability(ClientPlayNetworking.canSend(NativeCookingHoldPayload.TYPE)); ++ presentation.sourceCapability(ClientPlayNetworking.canSend(NativeCookingSourcePayload.TYPE)); ++ presentation.bundleCapability(ClientPlayNetworking.canSend(NativeCookingBundlePayload.TYPE)); ++ } + } catch (final RuntimeException unsupported) { + ViaFabricPlusBedrock.impl().logger().warn("Unsupported native cooking presentation; retaining ordinary controls", unsupported); + } @@ -3606,10 +4798,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..baffaa5 +index 0000000..0a6e9ff --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockNativePlayerInventory.java -@@ -0,0 +1,191 @@ +@@ -0,0 +1,202 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -3658,6 +4850,7 @@ index 0000000..baffaa5 + PayloadTypeRegistry.serverboundPlay().register(NativePlayerInventoryActionPayload.TYPE, NativePlayerInventoryActionPayload.CODEC); + PayloadTypeRegistry.serverboundPlay().register(NativePlayerInventoryActionPayload.AUTO_DESTROY_TYPE, NativePlayerInventoryActionPayload.CODEC); + PayloadTypeRegistry.serverboundPlay().register(NativeBundleActionPayload.TYPE, NativeBundleActionPayload.CODEC); ++ PayloadTypeRegistry.serverboundPlay().register(NativeBundleActionPayload.SELECTION_TYPE, NativeBundleActionPayload.SELECTION_CODEC); + ClientPlayConnectionEvents.INIT.register((listener, client) -> { + clear(); + owner = listener; @@ -3770,10 +4963,20 @@ index 0000000..baffaa5 + return current(listener, expected) && presentation != null && ClientPlayNetworking.canSend(NativeBundleActionPayload.TYPE); + } + ++ static boolean bundleSelectionAvailable(final ClientPacketListener listener, final AbstractContainerMenu expected) { ++ return current(listener, expected) && presentation != null && ClientPlayNetworking.canSend(NativeBundleActionPayload.SELECTION_TYPE); ++ } ++ + static boolean bundle(final ClientPacketListener listener, final AbstractContainerMenu expected, final NativeInventorySourceState.Source source, + final NativeBundleAction.Kind kind, final int tooltipMode) { -+ return bundlesAvailable(listener, expected) && presentation.bundle(source.collection(), source.index(), kind, tooltipMode, -+ action -> ClientPlayNetworking.send(new NativeBundleActionPayload(action))); ++ return bundle(listener, expected, source, kind, tooltipMode, -1); ++ } ++ ++ static boolean bundle(final ClientPacketListener listener, final AbstractContainerMenu expected, final NativeInventorySourceState.Source source, ++ final NativeBundleAction.Kind kind, final int tooltipMode, final int selectedCell) { ++ return (selectedCell >= 0 ? bundleSelectionAvailable(listener, expected) : bundlesAvailable(listener, expected)) ++ && presentation.bundle(source.collection(), source.index(), kind, tooltipMode, selectedCell, ++ action -> ClientPlayNetworking.send(new NativeBundleActionPayload(action))); + } + + static boolean preferences(final ClientPacketListener listener, final AbstractContainerMenu expected, final InventoryOptions options) { @@ -3953,10 +5156,10 @@ index 0000000..6f7e980 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeBundleActionPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeBundleActionPayload.java new file mode 100644 -index 0000000..b4372a9 +index 0000000..3d3ed7e --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeBundleActionPayload.java -@@ -0,0 +1,22 @@ +@@ -0,0 +1,26 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -3972,12 +5175,16 @@ index 0000000..b4372a9 + +public record NativeBundleActionPayload(NativeBundleAction action) implements CustomPacketPayload { + public static final Type TYPE = new Type<>(Identifier.parse(NativeBundleAction.CHANNEL)); ++ public static final Type SELECTION_TYPE = new Type<>(Identifier.parse(NativeBundleAction.SELECTION_CHANNEL)); ++ public static final StreamCodec SELECTION_CODEC = CustomPacketPayload.codec( ++ (payload, buffer) -> NativeBundleAction.SELECTION_TYPE.write(buffer, payload.action), ++ buffer -> new NativeBundleActionPayload(NativeBundleAction.SELECTION_TYPE.read(buffer))); + public static final StreamCodec CODEC = CustomPacketPayload.codec( + (payload, buffer) -> NativeBundleAction.TYPE.write(buffer, payload.action), + buffer -> new NativeBundleActionPayload(NativeBundleAction.TYPE.read(buffer))); + + @Override -+ public Type type() { return TYPE; } ++ public Type type() { return this.action.channel().equals(NativeBundleAction.SELECTION_CHANNEL) ? SELECTION_TYPE : TYPE; } +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeBundleResultPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeBundleResultPayload.java new file mode 100644 @@ -4117,10 +5324,10 @@ index 0000000..7773e13 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerDraws.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerDraws.java new file mode 100644 -index 0000000..0c36c70 +index 0000000..8d8d034 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerDraws.java -@@ -0,0 +1,36 @@ +@@ -0,0 +1,41 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -4131,26 +5338,31 @@ index 0000000..0c36c70 +import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiAnimations; +import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiLayout; +import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiPresentation; ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiScene; +import java.util.List; + -+/** Retains painter traversal until layout, component state, pointer or opacity changes. */ ++/** Retains painter traversal until layout, focus, component state, pointer or opacity changes. */ +final class NativeContainerDraws { + private NativeUiLayout.Box root; + private double pointerX, pointerY; + private long inputRevision, opacityRevision; ++ private NativeUiScene.Node focused; + private List draws = List.of(); + + List update(final NativeUiLayout.Box root, final double pointerX, + final double pointerY, final NativeUiAnimations animations, final NativeContainerInput input, final long now) { + final long inputRevision = input.visualRevision(); ++ final var focused = input.focusedForDrawing(); + if (this.root != root || this.pointerX != pointerX || this.pointerY != pointerY -+ || this.inputRevision != inputRevision || this.opacityRevision != animations.opacityRevision()) { -+ this.draws = NativeUiPresentation.draws(root, box -> box.visibleBounds().contains(pointerX, pointerY), ++ || this.inputRevision != inputRevision || this.focused != focused ++ || this.opacityRevision != animations.opacityRevision()) { ++ this.draws = NativeUiPresentation.draws(root, box -> focused != null ? box.node() == focused : box.visibleBounds().contains(pointerX, pointerY), + node -> animations.opacityAt(node, now), input::hasButtonState); + this.root = root; + this.pointerX = pointerX; + this.pointerY = pointerY; + this.inputRevision = inputRevision; ++ this.focused = focused; + // First-render tracks can start while collecting this frame. + this.opacityRevision = animations.opacityRevision(); + } @@ -4195,10 +5407,10 @@ index 0000000..3387649 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerGeometry.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerGeometry.java new file mode 100644 -index 0000000..408be17 +index 0000000..20f5b5a --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerGeometry.java -@@ -0,0 +1,274 @@ +@@ -0,0 +1,335 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -4207,12 +5419,15 @@ index 0000000..408be17 +package com.viaversion.viafabricplus.bedrock.screen.container; + +import com.google.gson.JsonObject; ++import com.google.gson.JsonPrimitive; +import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiInput; +import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiLayout; +import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiPresentation; +import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiScene; +import java.util.ArrayList; +import java.util.Comparator; ++import java.util.Collections; ++import java.util.IdentityHashMap; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; @@ -4225,6 +5440,7 @@ index 0000000..408be17 + static final Set PLAYER_FALLBACK_ACTIONS = Set.of("button.container_reset_held", "button.destroy_selection"); + static final Set RECIPE_AUXILIARY_ACTIONS = Set.of("button.drop_one", "button.drop_all", "button.shape_drawing"); + private static final Set RECIPE_MAPPING_FIELDS = Set.of("from_button_id", "to_button_id", "mapping_type", "ignored", "consume_event"); ++ private final Set searchClear = Collections.newSetFromMap(new IdentityHashMap<>()); + private final List controls; + private final List draws; + private final Set registered = new HashSet<>(); @@ -4247,6 +5463,9 @@ index 0000000..408be17 + NativeContainerGeometry(final List draws, final int slotCount, + final Map> recipeActions, final boolean cooking, final boolean player) { + this.draws = List.copyOf(draws); ++ if (cooking) for (final var draw : draws) { ++ if (cookingSearchEditor(draw.box().node())) this.collectSearchClear(draw.box().node()); ++ } + final Set reinjected = new HashSet<>(); + if (cooking) for (final var draw : draws) { + final var input = NativeUiInput.parse(draw.box().node().properties()); @@ -4260,15 +5479,16 @@ index 0000000..408be17 + final var recipe = recipe(node, draw.enabled(), recipeActions); + final var preference = cooking ? NativeCookingPreferenceControl.parse(node) : null; + final boolean navigation = cooking && NativeCookingNavigation.mapped(node); -+ if (draw.enabled()) validateActions(node, recipe, cooking, reinjected, player); ++ if (draw.enabled()) validateActions(node, recipe, cooking, reinjected, player, this.searchClear.contains(node)); + final boolean mappedSlot = node.properties().has("inventory_actions") && node.properties().has("inventory_slot"); -+ final Set help = player ? helpInputs(node) : Set.of(); ++ final Set help = player || cooking ? helpInputs(node) : Set.of(); + final boolean command = !help.isEmpty() || node.type().equals("button") && (node.string("inventory_command", "").equals("close") || node.flag("help_close", false)); + if (mappedSlot && recipe != null) throw new IllegalArgumentException("Recipe control cannot also own an inventory slot"); + if (navigation && (mappedSlot || recipe != null)) + throw new IllegalArgumentException("Native navigation cannot also own an inventory or recipe action"); + this.navigation |= navigation; -+ if (!mappedSlot && !command && recipe == null && preference == null && !navigation) continue; ++ final Set clearInputs = this.searchClear.contains(node) ? searchClearInputs(node) : Set.of(); ++ if (!mappedSlot && !command && recipe == null && preference == null && !navigation && clearInputs.isEmpty()) continue; + final int slot = mappedSlot ? node.properties().get("inventory_slot").getAsInt() : -1; + if (mappedSlot && (slot < 0 || slot >= slotCount)) + throw new IllegalArgumentException("Invalid native inventory slot"); @@ -4276,7 +5496,7 @@ index 0000000..408be17 + // Pointer regions below still require visible, non-empty geometry. + if (mappedSlot) this.registered.add(slot); + if (draw.enabled() && draw.alpha() > 0 && box.visibleBounds().width() > 0 && box.visibleBounds().height() > 0) { -+ regions.add(new ControlRegion(slot, closeInputs(node), help, recipe, preference, box.bounds(), box.clip(), box.layer())); ++ regions.add(new ControlRegion(slot, closeInputs(node), help, clearInputs, recipe, preference, box.bounds(), box.clip(), box.layer())); + } + } + // Later draws cover earlier controls when their authored layers are equal. @@ -4297,6 +5517,50 @@ index 0000000..408be17 + return true; + } + ++ static boolean cookingSearchEditor(final NativeUiScene.Node node) { ++ if (!node.type().equals("edit_box")) return false; ++ final var input = NativeUiInput.parse(node.properties()); ++ if (input.mappings().isEmpty() || input.options().wildcard()) return false; ++ if (node.string("text_box_name", "").equals("item_name")) ++ return input.mappings().stream().allMatch(mapping -> mapping.target() == NativeUiInput.symbol("button.search_bar_selected")); ++ final var bag = node.properties().get("property_bag"); ++ if (!node.string("text_box_name", "").equals("#text_box_name") || bag == null || !bag.isJsonObject() ++ || !bag.getAsJsonObject().has("#property_field") ++ || !bag.getAsJsonObject().get("#property_field").equals(new JsonPrimitive("#item_name"))) return false; ++ final Set targets = Set.of(NativeUiInput.symbol("button.search_bar_selected"), ++ NativeUiInput.symbol("button.search_bar_deselected"), NativeUiInput.symbol("button.search_bar_clear")); ++ return input.mappings().stream().allMatch(mapping -> targets.contains(mapping.target())) ++ && input.mappings().stream().anyMatch(mapping -> mapping.target() == NativeUiInput.symbol("button.search_bar_selected") && mapping.handleSelect()) ++ && input.hoverBindings().stream().allMatch(mapping -> mapping.target() == NativeUiInput.symbol("button.search_bar_hovered")); ++ } ++ ++ private void collectSearchClear(final NativeUiScene.Node node) { ++ for (final var child : node.children()) { ++ if (child.type().equals("button") && NativeUiInput.parse(child.properties()).mappings().stream() ++ .anyMatch(mapping -> mapping.target() == NativeUiInput.symbol("button.text_edit_box_clear"))) this.searchClear.add(child); ++ this.collectSearchClear(child); ++ } ++ } ++ ++ private static Set searchClearInputs(final NativeUiScene.Node node) { ++ final Set inputs = new HashSet<>(); ++ for (final var value : node.properties().getAsJsonArray("button_mappings")) { ++ final var mapping = value.getAsJsonObject(); ++ if (!NativeUiScene.string(mapping, "to_button_id", "").equals("button.text_edit_box_clear") ++ || !NativeUiScene.string(mapping, "mapping_type", "").equals("pressed") || mapping.has("ignored") && mapping.get("ignored").getAsBoolean()) continue; ++ inputs.add(NativeUiScene.string(mapping, "from_button_id", "")); ++ } ++ inputs.remove(""); ++ return Set.copyOf(inputs); ++ } ++ ++ boolean clearsSearch(final NativeUiScene.Node node) { return this.searchClear.contains(node); } ++ ++ boolean clearsSearch(final double x, final double y, final String input) { ++ for (final var region : this.controls) if (region.contains(x, y)) return region.searchClearInputs.contains(input); ++ return false; ++ } ++ + private static Set closeInputs(final NativeUiScene.Node node) { + if (!(node.string("inventory_command", "").equals("close") || node.flag("help_close", false)) || !node.properties().has("button_mappings")) return Set.of(); + final Set inputs = new HashSet<>(); @@ -4365,14 +5629,21 @@ index 0000000..408be17 + return mapping.has(key) && mapping.get(key).isJsonPrimitive() && mapping.get(key).getAsJsonPrimitive().isString(); + } + -+ private static void validateActions(final NativeUiScene.Node node, final RecipeControl recipe, final boolean cooking, final Set reinjected, final boolean player) { ++ private static void validateActions(final NativeUiScene.Node node, final RecipeControl recipe, final boolean cooking, final Set reinjected, final boolean player, final boolean searchClear) { + if (!node.type().equals("button") || !node.properties().has("button_mappings")) return; + final Set supported = new HashSet<>(); + if (cooking && NativeCookingNavigation.mapped(node)) supported.addAll(NativeCookingNavigation.TARGETS); ++ if (cooking) supported.addAll(Set.of("button.search_bar_clear", "button.help", NativeCookingReset.TARGET)); ++ if (cooking) supported.addAll(NativeCookingNavigation.UNREGISTERED_TARGETS); ++ if (cooking) supported.addAll(NativeCookingSourceInput.CONTROL_TARGETS); ++ if (searchClear) supported.add("button.text_edit_box_clear"); + if (recipe != null) recipe.inputs.values().forEach(mode -> supported.add(mode.button())); -+ if (recipe != null) supported.add("button.container_slot_hovered"); ++ if (recipe != null || (player || cooking) && node.properties().has("inventory_slot")) ++ supported.add("button.container_slot_hovered"); + if (recipe != null && player) supported.addAll(RECIPE_AUXILIARY_ACTIONS); ++ if (recipe != null && cooking) supported.addAll(Set.of("button.drop_one", "button.drop_all")); + if (player) supported.addAll(PLAYER_FALLBACK_ACTIONS); ++ if (player || cooking) supported.addAll(NativeInventorySourceState.NAVIGATION_TARGETS); + if (player && node.properties().has("inventory_slot")) supported.add("button.container_auto_destroy"); + if (player) supported.addAll(Set.of("button.help", "button.search_bar_clear", "button.left_panel_tab_increment", "button.left_panel_tab_decrement", + "button.hide_bundle_tooltip", "button.tapped_bundle_item_icon")); @@ -4389,7 +5660,9 @@ index 0000000..408be17 + // must not bypass that controller's action ownership or press/release protocol. + final Set pointerTargets = new HashSet<>(); + for (final String target : supported) { -+ if (!NativeCookingNavigation.TARGETS.contains(target) && !target.equals("button.container_slot_hovered")) ++ if (!NativeCookingNavigation.TARGETS.contains(target) && !NativeCookingNavigation.UNREGISTERED_TARGETS.contains(target) ++ && !NativeCookingSourceInput.CONTROL_TARGETS.contains(target) ++ && !target.equals(NativeCookingReset.TARGET) && !NativeInventorySourceState.NAVIGATION_TARGETS.contains(target) && !target.equals("button.container_slot_hovered") && !target.equals("button.help")) + pointerTargets.add(NativeUiInput.symbol(target)); + } + final var input = NativeUiInput.parse(node.properties()); @@ -4468,17 +5741,17 @@ index 0000000..408be17 + } + } + -+ private record ControlRegion(int slot, Set closeInputs, Set helpInputs, RecipeControl recipe, NativeCookingPreferenceControl preference, ++ private record ControlRegion(int slot, Set closeInputs, Set helpInputs, Set searchClearInputs, RecipeControl recipe, NativeCookingPreferenceControl preference, + NativeUiLayout.Rect bounds, NativeUiLayout.Rect clip, float layer) { + boolean contains(final double x, final double y) { return this.bounds.intersect(this.clip).contains(x, y); } + } +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerInput.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerInput.java new file mode 100644 -index 0000000..d221870 +index 0000000..39c84a4 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerInput.java -@@ -0,0 +1,521 @@ +@@ -0,0 +1,609 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -4520,6 +5793,7 @@ index 0000000..d221870 + private NativeUiLayout.Box root; + private NativeUiLayout.Box geometryRoot; + private NativeUiScene.Node focused; ++ private boolean keyboardFocus; + private float pointerX = -1, pointerY = -1; + private float inputX = -1, inputY = -1; + private float viewportWidth, viewportHeight; @@ -4530,6 +5804,16 @@ index 0000000..d221870 + this.revealFocus = java.util.Objects.requireNonNull(revealFocus); + } + ++ static int cursorDropButton(final int target) { ++ if (target == NativeUiInput.symbol("button.cursor_drop_all")) return 0; ++ if (target == NativeUiInput.symbol("button.cursor_drop_one")) return 1; ++ return -1; ++ } ++ ++ static boolean exitTarget(final int target) { ++ return target == NativeUiInput.symbol("button.menu_exit") || target == NativeUiInput.symbol("button.menu_inventory_exit"); ++ } ++ + /** The installed native View retires its mappings before the first controller input. */ + void initialize() { + if (this.initialized) return; @@ -4683,6 +5967,16 @@ index 0000000..d221870 + return revision; + } + ++ /** Restore model-bound toggles after the controller declines their queued changes. */ ++ void restoreToggleBindings() { ++ for (final var entry : this.entries.values()) { ++ if (entry.toggle == null || entry.binding == null || entry.toggle.checked() == entry.binding) continue; ++ entry.toggle.bindChecked(entry.binding); ++ entry.node.properties().addProperty("toggle_state", entry.binding); ++ this.toggleRevision++; ++ } ++ } ++ + private static Identity identity(final NativeUiScene.Node node, final int occurrence) { + return new Identity(node.name(), node.type(), String.valueOf(node.properties().get("inventory_slot")), + String.valueOf(node.properties().get("collection")), String.valueOf(node.properties().get("collection_index")), @@ -4693,12 +5987,33 @@ index 0000000..d221870 + return new NativeUiInput.Bounds(rect.x(), rect.y(), rect.width(), rect.height()); + } + ++ void copyPointerFrom(final NativeContainerInput previous) { ++ this.inputX = previous.inputX; ++ this.inputY = previous.inputY; ++ this.pointerX = previous.pointerX; ++ this.pointerY = previous.pointerY; ++ } ++ + Result pointer(final double x, final double y, final int mode, final long nanos, final boolean enabled) { ++ final boolean moved = this.inputX != (float) x || this.inputY != (float) y; + this.inputX = (float) x; + this.inputY = (float) y; ++ if (moved) this.keyboardFocus(false); + return this.hover(x, y, mode, nanos, enabled); + } + ++ // Component hover and View focus have separate owners. ++ void focusHovered() { ++ for (final var frame : this.frames) { ++ if (!frame.enabled || !frame.entry.hovered || !frame.entry.node.flag("focus_enabled", false)) continue; ++ if (frame.entry.node != this.focused) { ++ final var target = this.targets().stream().filter(candidate -> candidate.box().node() == frame.entry.node).findFirst().orElse(null); ++ if (target != null) this.focus(target, false); ++ } ++ break; ++ } ++ } ++ + private Result hover(final double x, final double y, final int mode, final long nanos, final boolean enabled) { + this.pointerX = (float) x; + this.pointerY = (float) y; @@ -4715,7 +6030,7 @@ index 0000000..d221870 + final int mask = entry.buttonDefinition == null ? 0 : entry.buttonDefinition.handlerMask(active); + final var result = entry.input.pointer(entry.hovered, inside, consumed || !active, mask); + entry.hovered = result.hover().hovered(); -+ if (entry.button != null && result.hover().changed()) entry.button.hover(entry.hovered, active); ++ if (entry.button != null && result.hover().changed()) entry.button.hover(this.visualHovered(entry), active); + if (entry.toggle != null) this.change(entry, entry.toggle.hover(entry.hovered), events); + for (final var button : result.hover().events()) events.add(new Event(entry.node, button, null)); + consumed |= active && result.result() == 1; @@ -4725,6 +6040,10 @@ index 0000000..d221870 + + Result button(final String source, final boolean pressed, final int mode, final long nanos, final boolean active) { + if (source.isEmpty()) return new Result(false, List.of()); ++ if (active && pressed) { ++ if (source.equals("button.menu_ok")) this.keyboardFocus(true); ++ else if (source.equals("button.menu_select") || source.equals("button.menu_secondary_select")) this.keyboardFocus(false); ++ } + return this.button(NativeUiInput.symbol(source), pressed ? 1 : 0, mode, nanos, active); + } + @@ -4762,7 +6081,7 @@ index 0000000..d221870 + + private void button(final Entry entry, final NativeUiInput.ButtonEvent button, final boolean enabled, final List events) { + // Native component callbacks enqueue changes before ScreenView applies the button's scope. -+ if (entry.button != null) entry.button.button(button, enabled, entry.hovered); ++ if (entry.button != null) entry.button.button(button, enabled, this.visualHovered(entry)); + if (entry.toggle != null) this.change(entry, entry.toggle.button(button, enabled), events); + events.add(new Event(entry.node, button, null)); + } @@ -4803,21 +6122,53 @@ index 0000000..d221870 + + private List navigate(final NativeUiFocus.Direction direction, final int mode, final long nanos) { + final var target = NativeUiFocus.next(this.targets(), this.focused, direction, this.viewportWidth, this.viewportHeight, this.history); -+ if (target == null || target.box().node() == this.focused) return List.of(); -+ final var revealed = this.focus(target); ++ if (target == null) return List.of(); ++ this.keyboardFocus(true); ++ if (target.box().node() == this.focused) return List.of(); ++ final var revealed = this.focus(target, true); + if (revealed == null) return List.of(); + final var bounds = revealed.box().bounds(); + return this.hover(bounds.x() + bounds.width() * .5F, bounds.y() + bounds.height() * .5F, mode, nanos, true).events(); + } + -+ private NativeUiFocus.Target focus(final NativeUiFocus.Target target) { ++ boolean focus(final NativeUiScene.Node node) { ++ final var target = this.targets().stream().filter(candidate -> candidate.box().node() == node).findFirst().orElse(null); ++ if (target == null) return false; ++ return this.focus(target, true) != null; ++ } ++ ++ private NativeUiFocus.Target focus(final NativeUiFocus.Target target, final boolean keyboard) { ++ if (target.box().node() == this.focused) { ++ this.keyboardFocus(keyboard); ++ return target; ++ } ++ final var previous = this.indexed.get(this.focused); + this.focused = target.box().node(); ++ this.keyboardFocus(keyboard); ++ if (previous != null && previous.button != null) previous.button.hover(previous.hovered, previous.enabled); ++ final var selected = this.indexed.get(this.focused); ++ if (selected != null && selected.button != null) selected.button.hover(this.visualHovered(selected), selected.enabled); + this.history.select(target); ++ // A visible pointer target does not need a scroll or another layout callback. ++ if (!keyboard) return target; + this.revealFocus.accept(this.focused); + // Revealing a descendant can relayout the tree and replace its geometry. + return this.targets().stream().filter(candidate -> candidate.box().node() == this.focused).findFirst().orElse(null); + } + ++ private boolean visualHovered(final Entry entry) { ++ return entry.hovered || this.keyboardFocus && entry.node == this.focused; ++ } ++ ++ private void keyboardFocus(final boolean active) { ++ if (this.keyboardFocus == active) return; ++ this.keyboardFocus = active; ++ for (final var entry : this.entries.values()) if (entry.button != null) ++ entry.button.hover(this.visualHovered(entry), entry.enabled); ++ } ++ ++ NativeUiScene.Node focusedForDrawing() { return this.keyboardFocus ? this.focused : null; } ++ + static boolean supportsViewTarget(final int target) { + return target == NativeUiInput.symbol("button.menu_select") || direction(target) != null || tabDirection(target) != null; + } @@ -4879,7 +6230,7 @@ index 0000000..d221870 + final var selectedTarget = target; + final var focus = this.targets().stream().filter(candidate -> candidate.box().node() == selectedTarget.node).findFirst().orElse(null); + if (focus != null) { -+ final var revealed = this.focus(focus); ++ final var revealed = this.focus(focus, true); + if (revealed != null) { + final var bounds = revealed.box().bounds(); + // A forced tab focus refresh delivers the whole hover queue before its group change, @@ -4921,6 +6272,16 @@ index 0000000..d221870 + return this.root == null ? List.of() : NativeUiFocus.targets(this.root, _ -> true); + } + ++ boolean hovered(final NativeUiScene.Node node, final int target) { ++ final var entry = this.indexed.get(node); ++ return entry != null && entry.hovered && entry.definition.hoverBindings().stream().anyMatch(binding -> binding.target() == target); ++ } ++ ++ NativeUiInput.Bounds bounds(final NativeUiScene.Node node) { ++ final var entry = this.indexed.get(node); ++ return entry == null ? null : entry.geometry.bounds(); ++ } ++ + NativeUiScene.Node focused() { return this.focused; } + boolean enabled(final NativeUiScene.Node node) { return this.frames.stream().anyMatch(frame -> frame.entry.node == node && frame.enabled); } + boolean captures(final String source) { return this.router.captures(NativeUiInput.symbol(source)); } @@ -5572,10 +6933,10 @@ index 0000000..00bb0e9 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingActions.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingActions.java new file mode 100644 -index 0000000..62b9e4d +index 0000000..b8d4739 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingActions.java -@@ -0,0 +1,158 @@ +@@ -0,0 +1,389 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -5591,8 +6952,18 @@ index 0000000..62b9e4d +import java.util.function.Consumer; +import java.util.function.LongSupplier; +import net.raphimc.viabedrock.api.model.NativeCookingAction; ++import net.raphimc.viabedrock.api.model.NativeCookingBundleAction; ++import net.raphimc.viabedrock.api.model.NativeCookingBundleResult; ++import net.raphimc.viabedrock.api.model.NativeBundleAction; +import net.raphimc.viabedrock.api.model.NativeCookingActionResult; +import net.raphimc.viabedrock.api.model.NativeCookingMessage; ++import net.raphimc.viabedrock.api.model.NativeCookingControl; ++import net.raphimc.viabedrock.api.model.NativeCookingHoldAction; ++import net.raphimc.viabedrock.api.model.NativeCookingHoldResult; ++import net.raphimc.viabedrock.api.model.NativeCookingSourceAction; ++import net.raphimc.viabedrock.api.model.NativeCookingSourceResult; ++import net.raphimc.viabedrock.api.model.NativeCookingQuery; ++import net.raphimc.viabedrock.api.model.NativeCookingQueryResult; +import net.raphimc.viabedrock.api.resourcepack.ui.CookingUi; + +/** One menu's input phases retain their original authority while predicted frames advance. */ @@ -5600,14 +6971,29 @@ index 0000000..62b9e4d + private static final long RESPONSE_TIMEOUT_NANOS = 10_000_000_000L; + private final LongSupplier clock; + private final Map pending = new HashMap<>(); ++ private final Map heldPending = new HashMap<>(); + private final Set emitted = new HashSet<>(); + private NativeCookingMessage message; + private Map> commands = Map.of(); + private NativeCookingAction gesture; + private NativeCookingAction releaseSource; ++ private NativeCookingHoldAction held; ++ private NativeCookingHoldAction heldReleaseSource; ++ private boolean heldRejected; + private Set gestureCommands = Set.of(); + private long requestSequence; ++ private NativeCookingQuery query; ++ private NativeCookingBundleAction bundle; ++ private long bundleSentAt; ++ private long bundleAwaitingUpdate; ++ private long querySentAt; ++ private long queryAwaitingUpdate; + private long invalidatedUpdate; ++ private NativeCookingSourceAction source; ++ private NativeCookingSourceAction sourceRelease; ++ private long sourceSentAt; ++ private long sourceAwaitingUpdate; ++ private boolean sourceDrawing; + private boolean retired; + private boolean dispatching; + @@ -5621,6 +7007,9 @@ index 0000000..62b9e4d + || message.updateSequence() <= this.message.updateSequence()) + throw new IllegalArgumentException("Cooking action authority belongs to another frame"); + this.message = message; ++ if (message.updateSequence() > this.queryAwaitingUpdate) this.queryAwaitingUpdate = 0; ++ if (message.updateSequence() > this.bundleAwaitingUpdate) this.bundleAwaitingUpdate = 0; ++ if (message.updateSequence() > this.sourceAwaitingUpdate) this.sourceAwaitingUpdate = 0; + this.commands = Map.of(); + } + @@ -5630,8 +7019,13 @@ index 0000000..62b9e4d + this.commands = scope.actions(); + } + ++ boolean reset(final Consumer sender) { ++ final var original = this.gesture; ++ return original != null && this.send(original.recipeId(), original.mode(), original.controllerInputMode(), 2, sender); ++ } ++ + boolean send(final int recipe, final NativeCookingAction.Mode mode, final int controllerMode, final int phase, final Consumer sender) { -+ if (this.retired || this.dispatching || mode == null || controllerMode < 0 || controllerMode > 3 || phase < 0 || phase > 2 || this.expired()) return false; ++ if (this.retired || this.dispatching || this.sourcePending() || this.sourceDrawing || this.bundlePending() || this.query != null || this.held != null || !this.heldPending.isEmpty() || mode == null || controllerMode < 0 || controllerMode > 3 || phase < 0 || phase > 2 || this.expired()) return false; + final NativeCookingAction original; + final boolean startsFocus = phase == 1 && mode == NativeCookingAction.Mode.PRIMARY && controllerMode == 2 && this.gesture == null; + final boolean focused = this.gesture != null && this.gesture.eventPhase() == 1; @@ -5698,15 +7092,203 @@ index 0000000..62b9e4d + return true; + } + ++ boolean bundle(final String collection, final int index, final NativeBundleAction.Kind kind, final int tooltipMode, ++ final int selectedCell, final Consumer sender) { ++ if (!this.idle() || this.dispatching || this.message.updateSequence() <= this.invalidatedUpdate) return false; ++ if (this.requestSequence == Long.MAX_VALUE) { this.retire(); return false; } ++ final var action = new NativeCookingBundleAction(NativeCookingControl.forMessage(this.message, NativeCookingControl.Mode.ACTIVATE), ++ collection, index, kind, tooltipMode, selectedCell, ++this.requestSequence); ++ this.bundle = action; ++ this.bundleSentAt = this.clock.getAsLong(); ++ this.dispatching = true; ++ try { ++ sender.accept(action); ++ return true; ++ } catch (final RuntimeException failedTransport) { ++ this.retire(); ++ throw failedTransport; ++ } finally { this.dispatching = false; } ++ } ++ ++ boolean result(final NativeCookingBundleResult result) { ++ if (this.expired()) { this.retire(); return false; } ++ if (!result.action().equals(this.bundle)) return false; ++ this.bundle = null; ++ this.bundleAwaitingUpdate = !result.current() && this.message.updateSequence() <= result.action().source().updateSequence() ++ ? result.action().source().updateSequence() : 0; ++ return true; ++ } ++ ++ boolean bundlePending() { return this.bundle != null || this.bundleAwaitingUpdate != 0; } ++ ++ boolean source(final int sourceSlot, final int destinationSlot, final int quantity, ++ final NativeCookingSourceAction.Kind kind, final Consumer sender) { ++ if (kind == NativeCookingSourceAction.Kind.RETIRE || !this.idle() || this.dispatching ++ || this.message.updateSequence() <= this.invalidatedUpdate) return false; ++ if (this.requestSequence == Long.MAX_VALUE) { this.retire(); return false; } ++ final var action = new NativeCookingSourceAction(NativeCookingControl.forMessage(this.message, NativeCookingControl.Mode.ACTIVATE), ++ sourceSlot, destinationSlot, quantity, kind, ++this.requestSequence); ++ this.source = action; ++ this.sourceSentAt = this.clock.getAsLong(); ++ this.sourceDrawing = kind == NativeCookingSourceAction.Kind.DRAW; ++ if (this.sourceDrawing) this.sourceRelease = action; ++ this.dispatching = true; ++ try { ++ sender.accept(action); ++ return true; ++ } catch (final RuntimeException failedTransport) { ++ this.retire(); ++ throw failedTransport; ++ } finally { this.dispatching = false; } ++ } ++ ++ boolean result(final NativeCookingSourceResult result) { ++ if (this.expired()) { this.retire(); return false; } ++ if (!result.action().equals(this.source)) return false; ++ this.source = null; ++ if (!result.accepted()) { ++ this.sourceDrawing = false; ++ this.sourceRelease = null; ++ } ++ // An admitted drawing operation continues against its retained source after item frames advance. ++ this.sourceAwaitingUpdate = !this.sourceDrawing && !result.current() ++ && this.message.updateSequence() <= result.action().source().updateSequence() ++ ? result.action().source().updateSequence() : 0; ++ return true; ++ } ++ ++ boolean sourcePending() { return this.source != null || this.sourceAwaitingUpdate != 0; } ++ boolean sourceDrawing() { return this.sourceDrawing; } ++ boolean sourceAdmitted() { return this.sourceDrawing && this.source == null && !this.expired(); } ++ ++ /** The input owner sends its ordered terminal quick-craft phase before retiring this allocation. */ ++ void finishSourceDrawing() { ++ this.sourceDrawing = false; ++ this.sourceRelease = null; ++ } ++ ++ boolean query(final String text, final Consumer sender) { ++ if (!this.idle() || this.dispatching || this.message.updateSequence() <= this.invalidatedUpdate) return false; ++ if (this.requestSequence == Long.MAX_VALUE) { ++ this.retire(); ++ return false; ++ } ++ final var query = NativeCookingQuery.forMessage(this.message, ++this.requestSequence, text); ++ this.query = query; ++ this.querySentAt = this.clock.getAsLong(); ++ this.dispatching = true; ++ try { ++ sender.accept(query); ++ return true; ++ } catch (final RuntimeException failedTransport) { ++ this.retire(); ++ throw failedTransport; ++ } finally { ++ this.dispatching = false; ++ } ++ } ++ ++ boolean result(final NativeCookingQueryResult result) { ++ if (this.expired()) { ++ this.retire(); ++ return false; ++ } ++ if (!result.query().equals(this.query)) return false; ++ this.query = null; ++ this.queryAwaitingUpdate = this.message.updateSequence() <= result.query().source().updateSequence() ? result.query().source().updateSequence() : 0; ++ this.invalidatedUpdate = Math.max(this.invalidatedUpdate, result.query().source().updateSequence()); ++ return true; ++ } ++ ++ boolean queryPending() { return this.query != null || this.queryAwaitingUpdate != 0; } ++ ++ boolean hold(final int slot, final NativeCookingHoldAction.Phase phase, final int quantity, ++ final Consumer sender) { ++ if (this.expired() || this.dispatching || this.sourcePending() || this.sourceDrawing || this.bundlePending() || this.queryPending() || phase == null) return false; ++ if (phase == NativeCookingHoldAction.Phase.START ? !this.idle() || this.message.updateSequence() <= this.invalidatedUpdate ++ : this.held == null || slot != this.held.slot() ++ || this.heldPending.values().stream().anyMatch(p -> p.action.phase() == phase)) return false; ++ if (this.requestSequence == Long.MAX_VALUE) { this.retire(); return false; } ++ final var action = new NativeCookingHoldAction(phase == NativeCookingHoldAction.Phase.START ++ ? NativeCookingControl.forMessage(this.message, NativeCookingControl.Mode.ACTIVATE) : this.held.source(), ++ slot, phase, quantity, ++this.requestSequence); ++ this.heldPending.put(action.requestSequence(), new HeldPending(action, this.clock.getAsLong())); ++ if (phase == NativeCookingHoldAction.Phase.START) { ++ this.held = action; ++ this.heldReleaseSource = action; ++ this.heldRejected = false; ++ } ++ this.dispatching = true; ++ try { ++ sender.accept(action); ++ return true; ++ } catch (final RuntimeException failedTransport) { ++ this.retire(); ++ throw failedTransport; ++ } finally { this.dispatching = false; } ++ } ++ ++ boolean result(final NativeCookingHoldResult result) { ++ if (this.expired()) { this.retire(); return false; } ++ final var action = result.action(); ++ final var pending = this.heldPending.get(action.requestSequence()); ++ if (pending == null || !pending.action.equals(action)) return false; ++ this.heldPending.remove(action.requestSequence()); ++ if (!result.accepted()) this.heldRejected = true; ++ if (!result.accepted() || action.phase() != NativeCookingHoldAction.Phase.START) { ++ this.held = null; ++ this.heldReleaseSource = null; ++ this.invalidatedUpdate = Math.max(this.invalidatedUpdate, action.source().updateSequence()); ++ } ++ return true; ++ } ++ ++ boolean holding() { return this.held != null; } ++ boolean holdPending() { return !this.heldPending.isEmpty(); } ++ boolean holdRejected() { return this.heldRejected; } ++ + boolean expired() { + final long now = this.clock.getAsLong(); -+ return this.retired || this.pending.values().stream().anyMatch(pending -> now - pending.sentAt >= RESPONSE_TIMEOUT_NANOS); ++ return this.retired || this.sourcePending() && now - this.sourceSentAt >= RESPONSE_TIMEOUT_NANOS ++ || this.bundlePending() && now - this.bundleSentAt >= RESPONSE_TIMEOUT_NANOS ++ || this.queryPending() && now - this.querySentAt >= RESPONSE_TIMEOUT_NANOS ++ || this.pending.values().stream().anyMatch(pending -> now - pending.sentAt >= RESPONSE_TIMEOUT_NANOS) ++ || this.heldPending.values().stream().anyMatch(pending -> now - pending.sentAt >= RESPONSE_TIMEOUT_NANOS); + } + -+ boolean idle() { return !this.expired() && this.gesture == null && this.pending.isEmpty(); } ++ boolean idle() { return !this.expired() && !this.sourcePending() && !this.sourceDrawing && !this.bundlePending() && this.gesture == null && this.pending.isEmpty() && !this.queryPending() ++ && this.held == null && this.heldPending.isEmpty(); } + + /** Terminal release is safe after authority retires: phase two never repeats a transfer. */ + boolean cancel(final Consumer sender) { ++ return this.cancel(sender, null); ++ } ++ ++ boolean cancel(final Consumer sender, final Consumer heldSender) { ++ return this.cancel(sender, heldSender, null); ++ } ++ ++ boolean cancel(final Consumer sender, final Consumer heldSender, ++ final Consumer sourceSender) { ++ final var source = this.sourceRelease; ++ if (source != null && sourceSender != null && this.requestSequence != Long.MAX_VALUE) { ++ this.sourceRelease = null; ++ final var action = new NativeCookingSourceAction(source.source(), source.sourceSlot(), source.destinationSlot(), ++ source.quantity(), NativeCookingSourceAction.Kind.RETIRE, ++this.requestSequence); ++ this.retire(); ++ sourceSender.accept(action); ++ return true; ++ } ++ final var held = this.heldReleaseSource; ++ if (held != null && heldSender != null && this.requestSequence != Long.MAX_VALUE) { ++ final var action = new NativeCookingHoldAction(held.source(), held.slot(), NativeCookingHoldAction.Phase.RETIRE, ++ 0, ++this.requestSequence); ++ this.held = null; ++ this.heldReleaseSource = null; ++ this.retire(); ++ heldSender.accept(action); ++ return true; ++ } + this.retire(); + final var original = this.releaseSource; + this.releaseSource = null; @@ -5721,6 +7303,15 @@ index 0000000..62b9e4d + void retire() { + this.retired = true; + this.pending.clear(); ++ this.heldPending.clear(); ++ this.held = null; ++ this.query = null; ++ this.queryAwaitingUpdate = 0; ++ this.bundle = null; ++ this.bundleAwaitingUpdate = 0; ++ this.source = null; ++ this.sourceAwaitingUpdate = 0; ++ this.sourceDrawing = false; + this.commands = Map.of(); + this.clearGesture(); + } @@ -5733,13 +7324,14 @@ index 0000000..62b9e4d + + private record Event(NativeCookingAction.Mode mode, int phase) { } + private record Pending(NativeCookingAction action, long sentAt) { } ++ private record HeldPending(NativeCookingHoldAction action, long sentAt) { } +} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingNavigation.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingNavigation.java +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingBundlePayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingBundlePayload.java new file mode 100644 -index 0000000..027b258 +index 0000000..0c576dd --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingNavigation.java -@@ -0,0 +1,52 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingBundlePayload.java +@@ -0,0 +1,22 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -5747,42 +7339,350 @@ index 0000000..027b258 + */ +package com.viaversion.viafabricplus.bedrock.screen.container; + -+import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiInput; -+import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiScene; -+import java.util.Set; -+import net.raphimc.viabedrock.api.resourcepack.ui.CookingNavigation; -+import net.raphimc.viabedrock.api.resourcepack.ui.CookingUi; -+import net.raphimc.viabedrock.protocol.model.FurnaceOptions; ++import net.minecraft.network.RegistryFriendlyByteBuf; ++import net.minecraft.network.codec.StreamCodec; ++import net.minecraft.network.protocol.common.custom.CustomPacketPayload; ++import net.minecraft.resources.Identifier; ++import net.raphimc.viabedrock.api.model.NativeCookingBundleAction; + -+/** Native tab callbacks consume activation events and the current category counts. */ -+final class NativeCookingNavigation { -+ static final Set TARGETS = Set.of("button.left_panel_tab_decrement", "button.left_panel_tab_increment"); ++public record NativeCookingBundlePayload(NativeCookingBundleAction action) implements CustomPacketPayload { ++ public static final Type TYPE = new Type<>(Identifier.parse(NativeCookingBundleAction.CHANNEL)); ++ public static final StreamCodec CODEC = CustomPacketPayload.codec( ++ (payload, buffer) -> NativeCookingBundleAction.TYPE.write(buffer, payload.action), ++ buffer -> new NativeCookingBundlePayload(NativeCookingBundleAction.TYPE.read(buffer))); + -+ private NativeCookingNavigation() { } ++ @Override ++ public Type type() { return TYPE; } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingBundleResultPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingBundleResultPayload.java +new file mode 100644 +index 0000000..9478179 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingBundleResultPayload.java +@@ -0,0 +1,22 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.container; + -+ static boolean supports(final int target) { -+ return target == NativeUiInput.symbol("button.left_panel_tab_decrement") -+ || target == NativeUiInput.symbol("button.left_panel_tab_increment"); -+ } ++import net.minecraft.network.RegistryFriendlyByteBuf; ++import net.minecraft.network.codec.StreamCodec; ++import net.minecraft.network.protocol.common.custom.CustomPacketPayload; ++import net.minecraft.resources.Identifier; ++import net.raphimc.viabedrock.api.model.NativeCookingBundleResult; + -+ static boolean mapped(final NativeUiScene.Node node) { -+ final var mappings = NativeUiInput.parse(node.properties()).mappings(); -+ if (mappings.stream().anyMatch(mapping -> supports(mapping.target()) && !mapping.consume())) -+ throw new IllegalArgumentException("Unsupported mixed native navigation and inventory dispatch"); -+ return mappings.stream().anyMatch(mapping -> supports(mapping.target())); -+ } ++public record NativeCookingBundleResultPayload(NativeCookingBundleResult result) implements CustomPacketPayload { ++ public static final Type TYPE = new Type<>(Identifier.parse(NativeCookingBundleResult.CHANNEL)); ++ public static final StreamCodec CODEC = CustomPacketPayload.codec( ++ (payload, buffer) -> NativeCookingBundleResult.TYPE.write(buffer, payload.result), ++ buffer -> new NativeCookingBundleResultPayload(NativeCookingBundleResult.TYPE.read(buffer))); ++ ++ @Override ++ public Type type() { return TYPE; } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingHold.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingHold.java +new file mode 100644 +index 0000000..8a18950 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingHold.java +@@ -0,0 +1,166 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.container; ++ ++import com.mojang.blaze3d.platform.InputConstants; ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiHold; ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiInput; ++import java.util.function.Consumer; ++import net.minecraft.world.item.ItemStack; ++import net.raphimc.viabedrock.api.model.NativeCookingHoldAction; ++import net.raphimc.viabedrock.api.resourcepack.ui.CookingUi; ++ ++/** One ordinary pickup retains its source while desktop quantity selection and replies advance. */ ++final class NativeCookingHold { ++ private static final long TIMEOUT = 10_000_000_000L; ++ private final NativeUiHold view; ++ private int button = -1, slot = -1; ++ private ItemStack item = ItemStack.EMPTY; ++ private long pickupUpdate, pressedAt; ++ private int enteredMillis, elapsedMillis; ++ private float x, y; ++ private boolean selecting, finished, started, completed; ++ private boolean returnHeld; ++ private int completion; ++ private long completionUpdate; ++ ++ NativeCookingHold(final long now) { this.view = new NativeUiHold(now); } ++ ++ boolean busy() { return this.slot >= 0; } ++ int slot() { return this.slot; } ++ boolean selecting() { return this.busy() && this.selecting && !this.finished; } ++ boolean owns(final int button) { return this.busy() && this.button == button; } ++ boolean inside(final NativeContainerGeometry geometry) { return this.busy() && geometry.contains(this.slot, this.x, this.y); } ++ ++ void pickup(final int button, final int slot, final ItemStack item, final long update, ++ final long now, final double x, final double y) { ++ if (this.busy() || slot < 0 || item.isEmpty() || item.getCount() > 255) ++ throw new IllegalArgumentException("Native hold requires one admitted pickup"); ++ this.button = button; ++ this.slot = slot; ++ this.item = item.copy(); ++ this.pickupUpdate = update; ++ this.pressedAt = now; ++ this.pointer(x, y, true); ++ this.view.result(2, this.x, this.y); ++ } ++ ++ static boolean returnTarget(final int target) { return target == NativeUiInput.symbol("button.return"); } ++ ++ /** The native Return edge also supplies the keyboard branch of held quantity input. */ ++ void returnInput(final NativeUiInput.ButtonEvent input) { ++ // Native edge filters are independent of the mode-specific activation flag. ++ if (input.phase() == 1 && input.previous() != 1) this.returnHeld = true; ++ else if (input.phase() == 0 && input.previous() == 1) { ++ this.returnHeld = false; ++ if (this.owns(InputConstants.KEY_RETURN)) this.release(InputConstants.KEY_RETURN, true); ++ } ++ } ++ ++ boolean pickupReturn(final int slot, final ItemStack item, final long update, final long now, ++ final double x, final double y) { ++ if (!this.returnHeld) return false; ++ this.pickup(InputConstants.KEY_RETURN, slot, item, update, now, x, y); ++ return true; ++ } ++ ++ void pointer(final double x, final double y, final boolean inside) { ++ this.x = (float) x; ++ this.y = (float) y; ++ if (this.selecting() && !inside) this.finish(); ++ } ++ ++ boolean release(final int button, final boolean completionDelivered) { ++ if (!this.owns(button) && !(this.selecting() && completionDelivered)) return false; ++ if (this.selecting) this.finish(); ++ else this.clear(); ++ return true; ++ } ++ ++ void reset() { ++ if (this.selecting) this.finish(); ++ else this.clear(); ++ } ++ ++ boolean frame(final long now, final boolean inside, final ItemStack cursor, ++ final NativeCookingPresentation presentation, final Consumer sender) { ++ final var before = this.quantity(); ++ final var event = this.view.frame(now, this.x, this.y); ++ if (!this.busy()) return false; ++ if (!presentation.holdsAvailable()) { this.clear(); return true; } ++ if (now - this.pressedAt >= TIMEOUT && !this.started) { this.clear(); return true; } ++ if (this.started && !presentation.holding()) { ++ // Phase admission is independent of the subsequent authoritative inventory reply. ++ if (!presentation.holdPending() && (presentation.holdRejected() || !this.completed ++ || presentation.message().updateSequence() > this.completionUpdate)) { this.clear(); return true; } ++ return !before.equals(this.quantity()); ++ } ++ if (!this.started && presentation.message().updateSequence() > this.pickupUpdate && !ItemStack.matches(cursor, this.item)) { ++ this.clear(); ++ return true; ++ } ++ if (!this.selecting && event != null && !event.moved() && inside && ItemStack.matches(cursor, this.item)) { ++ this.selecting = true; ++ this.enteredMillis = (int) (now / 1_000_000L); ++ this.elapsedMillis = 0; ++ } ++ if (this.selecting() && !inside) this.finish(); ++ if (this.selecting()) ++ this.elapsedMillis = (int) (now / 1_000_000L) - this.enteredMillis; ++ this.flush(presentation, cursor, sender); ++ return !before.equals(this.quantity()); ++ } ++ ++ void flush(final NativeCookingPresentation presentation, final ItemStack cursor, ++ final Consumer sender) { ++ if (!this.busy() || !this.selecting) return; ++ if (!this.started && presentation.message().updateSequence() > this.pickupUpdate ++ && ItemStack.matches(cursor, this.item)) { ++ this.started = presentation.hold(this.slot, NativeCookingHoldAction.Phase.START, 0, sender); ++ } ++ if (this.started && this.finished && !this.completed) { ++ this.completionUpdate = presentation.message().updateSequence(); ++ this.completed = presentation.hold(this.slot, NativeCookingHoldAction.Phase.COMPLETE, this.completion, sender); ++ } ++ } ++ ++ private void finish() { ++ if (this.finished) return; ++ this.completion = this.selectedCount(); ++ this.finished = true; ++ this.view.result(4, this.x, this.y); ++ } ++ ++ private int selectedCount() { ++ return (int) ((this.item.getCount() - 1) * Math.min(this.elapsedMillis / 1000F, 1F)) + 1; ++ } ++ ++ CookingUi.QuantitySelection quantity() { ++ return this.selecting() ? new CookingUi.QuantitySelection(this.item.getCount(), this.selectedCount()) ++ : CookingUi.QuantitySelection.inactive(); ++ } ++ ++ ItemStack preview(final int slot, final ItemStack actual) { ++ if (!this.busy() || !this.selecting || this.slot != slot) return actual; ++ return this.item.copyWithCount(this.finished ? this.item.getCount() - this.completion : this.item.getCount()); ++ } ++ ++ ItemStack cursor(final ItemStack actual) { ++ if (!this.busy() || !this.selecting) return actual; ++ return this.finished ? this.item.copyWithCount(this.completion) : ItemStack.EMPTY; ++ } ++ ++ boolean previewsCursor() { return this.busy() && this.selecting; } ++ ++ void clear() { ++ this.view.result(4, this.x, this.y); ++ this.button = -1; ++ this.slot = -1; ++ this.item = ItemStack.EMPTY; ++ this.selecting = this.finished = this.started = this.completed = false; ++ this.elapsedMillis = 0; ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingHoldPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingHoldPayload.java +new file mode 100644 +index 0000000..834a599 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingHoldPayload.java +@@ -0,0 +1,22 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.container; ++ ++import net.minecraft.network.RegistryFriendlyByteBuf; ++import net.minecraft.network.codec.StreamCodec; ++import net.minecraft.network.protocol.common.custom.CustomPacketPayload; ++import net.minecraft.resources.Identifier; ++import net.raphimc.viabedrock.api.model.NativeCookingHoldAction; ++ ++public record NativeCookingHoldPayload(NativeCookingHoldAction action) implements CustomPacketPayload { ++ public static final Type TYPE = new Type<>(Identifier.parse(NativeCookingHoldAction.CHANNEL)); ++ public static final StreamCodec CODEC = CustomPacketPayload.codec( ++ (payload, buffer) -> NativeCookingHoldAction.TYPE.write(buffer, payload.action), ++ buffer -> new NativeCookingHoldPayload(NativeCookingHoldAction.TYPE.read(buffer))); ++ ++ @Override ++ public Type type() { return TYPE; } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingHoldResultPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingHoldResultPayload.java +new file mode 100644 +index 0000000..dff2e08 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingHoldResultPayload.java +@@ -0,0 +1,22 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.container; ++ ++import net.minecraft.network.RegistryFriendlyByteBuf; ++import net.minecraft.network.codec.StreamCodec; ++import net.minecraft.network.protocol.common.custom.CustomPacketPayload; ++import net.minecraft.resources.Identifier; ++import net.raphimc.viabedrock.api.model.NativeCookingHoldResult; ++ ++public record NativeCookingHoldResultPayload(NativeCookingHoldResult result) implements CustomPacketPayload { ++ public static final Type TYPE = new Type<>(Identifier.parse(NativeCookingHoldResult.CHANNEL)); ++ public static final StreamCodec CODEC = CustomPacketPayload.codec( ++ (payload, buffer) -> NativeCookingHoldResult.TYPE.write(buffer, payload.result), ++ buffer -> new NativeCookingHoldResultPayload(NativeCookingHoldResult.TYPE.read(buffer))); ++ ++ @Override ++ public Type type() { return TYPE; } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingNavigation.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingNavigation.java +new file mode 100644 +index 0000000..7cadb59 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingNavigation.java +@@ -0,0 +1,76 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.container; ++ ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiInput; ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiScene; ++import java.util.Set; ++import net.raphimc.viabedrock.api.resourcepack.ui.CookingNavigation; ++import net.raphimc.viabedrock.api.resourcepack.ui.CookingUi; ++import net.raphimc.viabedrock.protocol.model.FurnaceOptions; ++ ++/** Admits cooking hosts and resolves native tab and layout callbacks. */ ++final class NativeCookingNavigation { ++ static final Set TARGETS = Set.of("button.left_panel_tab_decrement", "button.left_panel_tab_increment", ++ "button.layout_decrement", "button.layout_increment"); ++ // Bedrock 1.26.60.5 maps these names in cooking scenes without registering controller callbacks. ++ static final Set UNREGISTERED_TARGETS = Set.of("button.clear_hotbar_or_drop", "button.clear_hotbar_or_remove_one", ++ "button.clear_selected_recipe", "button.destroy_selection", "button.menu_clear", "button.shift_pane_focus"); ++ private static final Set UNREGISTERED_SYMBOLS = Set.copyOf(UNREGISTERED_TARGETS.stream().map(NativeUiInput::symbol).toList()); ++ ++ private NativeCookingNavigation() { } ++ ++ static boolean unregistered(final int target) { ++ return UNREGISTERED_SYMBOLS.contains(target); ++ } ++ ++ static boolean supports(final int target) { ++ return target == NativeUiInput.symbol("button.left_panel_tab_decrement") ++ || target == NativeUiInput.symbol("button.left_panel_tab_increment") ++ || target == NativeUiInput.symbol("button.layout_decrement") ++ || target == NativeUiInput.symbol("button.layout_increment"); ++ } ++ ++ static boolean mapped(final NativeUiScene.Node node) { ++ final var mappings = NativeUiInput.parse(node.properties()).mappings(); ++ if (mappings.stream().anyMatch(mapping -> supports(mapping.target()) && !mapping.consume())) ++ throw new IllegalArgumentException("Unsupported mixed native navigation and inventory dispatch"); ++ if (mappings.stream().anyMatch(mapping -> (unregistered(mapping.target()) || NativeCookingReset.target(mapping.target()) || NativeCookingSourceInput.controlTarget(mapping.target())) && !mapping.consume())) ++ throw new IllegalArgumentException("Unsupported nonconsuming native cooking controller mapping"); ++ return mappings.stream().anyMatch(mapping -> supports(mapping.target())); ++ } + + static boolean host(final NativeUiScene.Node node) { -+ if (!Set.of("input_panel", "screen").contains(node.type())) return false; ++ if (!node.scroll() && !Set.of("input_panel", "screen").contains(node.type())) return false; + final var input = NativeUiInput.parse(node.properties()); + return !input.options().wildcard() && input.hoverBindings().isEmpty() && !input.mappings().isEmpty() -+ && input.mappings().stream().allMatch(mapping -> mapping.scope() == NativeUiInput.Scope.VIEW -+ ? NativeContainerInput.supportsViewTarget(mapping.target()) : supports(mapping.target())); ++ && input.mappings().stream().allMatch(mapping -> node.scroll() ++ ? mapping.scope() == NativeUiInput.Scope.DEFAULT && mapping.consume() ++ && mapping.target() == NativeUiInput.symbol("button.scrollbar_touch") ++ : mapping.scope() == NativeUiInput.Scope.VIEW ++ ? NativeContainerInput.supportsViewTarget(mapping.target()) ++ : supports(mapping.target()) || NativeCookingSourceInput.controlTarget(mapping.target()) || NativeCookingReset.target(mapping.target()) || NativeCookingHold.returnTarget(mapping.target()) || unregistered(mapping.target()) || NativeInventorySourceState.navigation(mapping.target()) || mapping.target() == NativeUiInput.symbol("button.help") || NativeContainerInput.cursorDropButton(mapping.target()) >= 0 ++ || NativeContainerInput.exitTarget(mapping.target())); + } + + static Transition transition(final FurnaceOptions current, final CookingUi.Book book, final NativeUiInput.ButtonEvent event) { + if (!supports(event.target())) throw new IllegalArgumentException("Unknown native cooking navigation action"); + if (!event.transition()) return new Transition(current, false); ++ if (event.target() == NativeUiInput.symbol("button.layout_increment") || event.target() == NativeUiInput.symbol("button.layout_decrement")) { ++ final var direction = event.target() == NativeUiInput.symbol("button.layout_increment") ++ ? CookingNavigation.Direction.NEXT : CookingNavigation.Direction.PREVIOUS; ++ final int layout = CookingNavigation.layout(book.forcedRecipeBookLayout(), current.layout(), direction); ++ return new Transition(new FurnaceOptions(current.furnaceType(), current.leftTab(), current.filtering(), layout), !book.forcedRecipeBookLayout()); ++ } + final int delta = event.target() == NativeUiInput.symbol("button.left_panel_tab_decrement") ? -1 : 1; + final var next = CookingNavigation.moveTab(book.forcedRecipeBookLayout(), current.layout(), book.foodCount(), + book.itemCount(), book.blockCount(), current.leftTab(), delta); @@ -5892,10 +7792,10 @@ index 0000000..636f3bd +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingPresentation.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingPresentation.java new file mode 100644 -index 0000000..e5b8fc6 +index 0000000..0a5ab18 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingPresentation.java -@@ -0,0 +1,136 @@ +@@ -0,0 +1,264 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -5908,13 +7808,24 @@ index 0000000..e5b8fc6 +import java.util.HashMap; +import java.util.List; +import java.util.Map; ++import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.LongSupplier; +import net.minecraft.world.item.ItemStack; +import net.raphimc.viabedrock.api.model.NativeCookingAction; ++import net.raphimc.viabedrock.api.model.NativeCookingBundleAction; ++import net.raphimc.viabedrock.api.model.NativeCookingBundleResult; ++import net.raphimc.viabedrock.api.model.NativeBundleAction; ++import net.raphimc.viabedrock.api.model.NativeBundleResult; +import net.raphimc.viabedrock.api.model.NativeCookingActionResult; +import net.raphimc.viabedrock.api.model.NativeCookingMessage; ++import net.raphimc.viabedrock.api.model.NativeCookingHoldAction; ++import net.raphimc.viabedrock.api.model.NativeCookingHoldResult; ++import net.raphimc.viabedrock.api.model.NativeCookingSourceAction; ++import net.raphimc.viabedrock.api.model.NativeCookingSourceResult; +import net.raphimc.viabedrock.api.model.NativeCookingProgress; ++import net.raphimc.viabedrock.api.model.NativeCookingQuery; ++import net.raphimc.viabedrock.api.model.NativeCookingQueryResult; +import net.raphimc.viabedrock.api.resourcepack.ui.ContainerUi; +import net.raphimc.viabedrock.api.resourcepack.ui.BundleTooltipUi; +import net.raphimc.viabedrock.api.resourcepack.ui.CookingUi; @@ -5922,6 +7833,7 @@ index 0000000..e5b8fc6 +/** Authority and progress stay server-owned; genuine client stacks supply rendering and hover data. */ +final class NativeCookingPresentation implements NativeInventoryPresentation { + private final NativeCookingActions actions; ++ private final NativeInventoryHelp help = new NativeInventoryHelp(topic -> this.program.help(topic)); + private NativeCookingMessage message; + private CookingUi program; + private CookingUi.MenuView view; @@ -5930,6 +7842,15 @@ index 0000000..e5b8fc6 + private CookingUi.Selection selection; + private long dataRevision = -1; + private long revision; ++ private String query; ++ private boolean queryDirty; ++ private boolean searchAvailable; ++ private boolean holdsAvailable; ++ private boolean sourcesAvailable; ++ private boolean bundlesAvailable; ++ private boolean holdInput; ++ private CookingUi.QuantitySelection quantity = CookingUi.QuantitySelection.inactive(); ++ private ContainerUi.SelectedItem selectedItem; + + NativeCookingPresentation(final NativeCookingMessage message, final Map recipes) { + this(message, recipes, System::nanoTime); @@ -5958,6 +7879,8 @@ index 0000000..e5b8fc6 + recipes.forEach((id, item) -> copies.put(id, item.copy())); + if (this.view == null || !this.view.book().selection().equals(view.book().selection())) + this.selection = view.book().selection(); ++ if (this.query == null || !this.queryDirty && (this.actions == null || !this.actions.queryPending())) ++ this.query = view.book().searchText(); + this.message = message; + this.program = program; + this.view = view; @@ -5976,32 +7899,129 @@ index 0000000..e5b8fc6 + return true; + } + ++ NativeInventoryHelp help() { return this.help; } ++ ++ void openHelp() { ++ if (!this.expired()) this.help.open(31); ++ } ++ + NativeCookingMessage message() { return this.message; } + -+ boolean select(final String collection, final int index) { -+ final var selected = new CookingUi.Selection(collection, index); -+ if (selected.equals(this.selection)) return false; -+ this.selection = selected; -+ this.revision++; ++ CookingUi.Selection selectedSource() { return this.selection; } ++ ++ void selectedSource(final CookingUi.Selection source) { ++ if (!this.selection.equals(source)) { this.selection = source; this.revision++; } ++ } ++ ++ void selectedItem(final ContainerUi.SelectedItem selected) { ++ if (!Objects.equals(this.selectedItem, selected)) { this.selectedItem = selected; this.revision++; } ++ } ++ ++ boolean resetRecipe(final Consumer sender) { return this.actions.reset(sender); } ++ ++ boolean idle() { return !this.holdInput && !this.queryDirty && this.actions.idle(); } ++ ++ boolean searchAvailable() { return this.searchAvailable; } ++ ++ boolean bundlesAvailable() { return this.bundlesAvailable; } ++ boolean bundlePending() { return this.actions.bundlePending(); } ++ ++ void bundleCapability(final boolean available) { ++ if (this.bundlesAvailable && !available && this.actions.bundlePending()) this.retire(); ++ this.bundlesAvailable = available; ++ } ++ ++ boolean bundle(final String collection, final int index, final NativeBundleAction.Kind kind, final int tooltipMode, ++ final int selectedCell, final Consumer sender) { ++ return this.bundlesAvailable && !this.holdInput && !this.queryDirty ++ && this.actions.bundle(collection, index, kind, tooltipMode, selectedCell, sender); ++ } ++ ++ boolean result(final NativeCookingBundleResult result, final Consumer sounds) { ++ if (!this.actions.result(result)) return false; ++ if (result.sound() != NativeBundleResult.Sound.NONE) sounds.accept(result.sound()); + return true; + } + -+ boolean deselect(final String collection, final int index) { -+ if (!this.selection.equals(new CookingUi.Selection(collection, index))) return false; -+ return this.select("", -1); ++ boolean holdsAvailable() { return this.holdsAvailable; } ++ boolean sourcesAvailable() { return this.sourcesAvailable; } ++ void sourceCapability(final boolean available) { ++ if (this.sourcesAvailable && !available && (this.actions.sourcePending() || this.actions.sourceDrawing())) this.retire(); ++ this.sourcesAvailable = available; ++ } ++ boolean source(final int sourceSlot, final int destinationSlot, final int quantity, ++ final NativeCookingSourceAction.Kind kind, final Consumer sender) { ++ return this.sourcesAvailable && !this.holdInput && !this.queryDirty ++ && this.actions.source(sourceSlot, destinationSlot, quantity, kind, sender); ++ } ++ boolean sourcePending() { return this.actions.sourcePending(); } ++ boolean sourceDrawing() { return this.actions.sourceDrawing(); } ++ boolean sourceAdmitted() { return this.actions.sourceAdmitted(); } ++ void finishSourceDrawing() { this.actions.finishSourceDrawing(); } ++ boolean result(final NativeCookingSourceResult result) { return this.actions.result(result); } ++ void holdInput(final boolean active) { this.holdInput = active; } ++ void holdCapability(final boolean available) { ++ if (this.holdsAvailable && !available && (this.actions.holding() || this.actions.holdPending())) this.retire(); ++ this.holdsAvailable = available; ++ } ++ boolean holding() { return this.actions.holding(); } ++ boolean holdPending() { return this.actions.holdPending(); } ++ boolean holdRejected() { return this.actions.holdRejected(); } ++ boolean hold(final int slot, final NativeCookingHoldAction.Phase phase, final int quantity, ++ final Consumer sender) { ++ return this.holdsAvailable && !this.queryDirty && this.actions.hold(slot, phase, quantity, sender); ++ } ++ boolean result(final NativeCookingHoldResult result) { return this.actions.result(result); } ++ void quantity(final CookingUi.QuantitySelection quantity) { ++ if (!this.quantity.equals(quantity)) { this.quantity = quantity; this.revision++; } ++ } ++ ++ void searchCapability(final boolean available) { ++ if (this.searchAvailable == available) return; ++ if (!available && (this.queryDirty || this.actions.queryPending())) this.retire(); ++ this.searchAvailable = available; ++ this.revision++; ++ } ++ ++ String query() { return this.query; } ++ ++ void restoreQuery(final String text) { ++ if (this.query.equals(text)) return; ++ this.query = text; ++ this.queryDirty = !text.equals(this.message.view().book().searchText()); ++ this.revision++; + } + -+ void tabChanged() { -+ this.selection = new CookingUi.Selection("", -1); ++ boolean query(final String text) { ++ if (!this.searchAvailable || this.holdInput || this.expired() || text.length() > NativeCookingQuery.MAX_TEXT_LENGTH) return false; ++ this.query = text; ++ this.queryDirty = true; + this.revision++; ++ return true; ++ } ++ ++ boolean flushQuery(final Consumer sender) { ++ if (!this.searchAvailable || this.holdInput || !this.queryDirty) return false; ++ this.queryDirty = false; ++ if (this.actions.query(this.query, sender)) return true; ++ this.queryDirty = true; ++ return false; + } + -+ boolean idle() { return this.actions.idle(); } ++ boolean result(final NativeCookingQueryResult result) { ++ if (!this.actions.result(result)) return false; ++ if (!result.accepted()) this.queryDirty = true; ++ else { ++ this.selection = new CookingUi.Selection("", -1); ++ this.revision++; ++ } ++ return true; ++ } + + boolean forcedRecipeBookLayout() { return this.view.book().forcedRecipeBookLayout(); } + + boolean action(final int recipe, final NativeCookingAction.Mode mode, final int controllerMode, final int phase, final Consumer sender) { -+ return this.actions.send(recipe, mode, controllerMode, phase, sender); ++ return !this.holdInput && (!this.queryDirty || phase == 2) && this.actions.send(recipe, mode, controllerMode, phase, sender); + } + + boolean result(final NativeCookingActionResult result) { return this.actions.result(result); } @@ -6011,13 +8031,21 @@ index 0000000..e5b8fc6 + void retire() { this.actions.retire(); } + + boolean cancel(final Consumer sender) { return this.actions.cancel(sender); } ++ boolean cancel(final Consumer sender, final Consumer heldSender) { ++ return this.actions.cancel(sender, heldSender); ++ } ++ boolean cancel(final Consumer sender, final Consumer heldSender, ++ final Consumer sourceSender) { ++ return this.actions.cancel(sender, heldSender, sourceSender); ++ } + -+ @Override public long revision() { return this.revision; } ++ @Override public long revision() { return this.revision + this.help.revision(); } + + @Override + public Frame resolve(final int menuId, final String title, final List slots, + final Map cursor, final Boolean showTags, final BundleTooltipUi.View tooltip) { + if (menuId != this.message.menuId()) throw new IllegalArgumentException("Cooking view belongs to another menu"); ++ if (this.help.active()) return new Frame(this.help.resolve(), 3, Map.of(), Map.of()); + final var source = this.view.book(); + final var catalog = source.catalog().stream().map(recipe -> { + final Map values = new HashMap<>(NativeInventoryItemPresentation.values(this.recipes.get(recipe.id()), false, showTags)); @@ -6025,8 +8053,8 @@ index 0000000..e5b8fc6 + return new CookingUi.Recipe(recipe.id(), recipe.uniqueId(), recipe.priority(), recipe.unlocked(), recipe.outputName(), recipe.evaluation(), values); + }).toList(); + final var book = new CookingUi.Book(source.layout(), source.forcedRecipeBookLayout(), source.tab(), source.filtering(), -+ source.foodCount(), source.itemCount(), source.blockCount(), source.tabLabel(), source.searchText(), this.selection, catalog, source.visibleRecipes()); -+ final var scene = this.program.resolve(new CookingUi.MenuView(menuId, this.view.family(), this.view.title(), slots, cursor, this.progress, book), tooltip); ++ source.foodCount(), source.itemCount(), source.blockCount(), source.tabLabel(), this.query, this.selection, catalog, source.visibleRecipes()); ++ final var scene = this.program.resolve(new CookingUi.MenuView(menuId, this.view.family(), this.view.title(), slots, cursor, this.progress, book), tooltip, this.quantity, this.selectedItem); + final var scope = CookingUi.actionScope(scene, book); + this.actions.presented(this.message, scope); + return new Frame(scene, 3, this.recipes, scope.actions()); @@ -6034,10 +8062,10 @@ index 0000000..e5b8fc6 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingPresentationController.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingPresentationController.java new file mode 100644 -index 0000000..cff5136 +index 0000000..d57202b --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingPresentationController.java -@@ -0,0 +1,119 @@ +@@ -0,0 +1,141 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -6047,6 +8075,8 @@ index 0000000..cff5136 + +import com.viaversion.viaversion.api.minecraft.item.Item; +import java.util.HashMap; ++import java.util.EnumMap; ++import net.raphimc.viabedrock.api.resourcepack.ui.CookingUi; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Function; @@ -6064,6 +8094,7 @@ index 0000000..cff5136 + private C owner; + private NativeCookingPresentation presentation; + private boolean capabilityAvailable; ++ private final Map queries = new EnumMap<>(CookingUi.Family.class); + + NativeCookingPresentationController() { this(System::nanoTime); } + @@ -6075,6 +8106,7 @@ index 0000000..cff5136 + this.retirePresentation(); + this.session.clear(); + this.owner = owner; ++ this.queries.clear(); + this.capabilityAvailable = false; + } + @@ -6091,8 +8123,13 @@ index 0000000..cff5136 + try { + final Map items = new HashMap<>(); + message.recipeItems().forEach((id, item) -> items.put(id, decode.apply(item.copy()))); -+ if (update) return this.presentation.update(message, items); ++ if (update) { ++ if (!this.presentation.update(message, items)) return false; ++ this.restoreQuery(this.presentation); ++ return true; ++ } + final var next = new NativeCookingPresentation(message, items, this.clock); ++ this.restoreQuery(next); + if (!this.session.accept(owner, menu, currentMenuId, message.menuId(), message.sequence())) return false; + if (this.presentation != null) this.presentation.retire(); + this.presentation = next; @@ -6106,6 +8143,18 @@ index 0000000..cff5136 + } + } + ++ private void restoreQuery(final NativeCookingPresentation presentation) { ++ final String retained = this.queries.get(presentation.message().view().family()); ++ if (retained != null) presentation.restoreQuery(retained); ++ } ++ ++ boolean query(final C owner, final M menu, final int menuId, final String text) { ++ final var current = this.current(owner, menu, menuId); ++ if (current == null || !current.query(text)) return false; ++ this.queries.put(current.message().view().family(), text); ++ return true; ++ } ++ + boolean progress(final C owner, final M menu, final int menuId, final NativeCookingProgress progress) { + final var current = this.current(owner, menu, menuId); + return current != null && current.update(progress); @@ -6147,6 +8196,7 @@ index 0000000..cff5136 + this.retirePresentation(); + this.session.clear(); + this.owner = null; ++ this.queries.clear(); + this.capabilityAvailable = false; + } + @@ -6186,12 +8236,12 @@ index 0000000..4a8745e + public Type type() { return TYPE; } +} + -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeFurnaceOptionsPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeFurnaceOptionsPayload.java +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingQueryPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingQueryPayload.java new file mode 100644 -index 0000000..8b1ea67 +index 0000000..7c9ecc2 --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeFurnaceOptionsPayload.java -@@ -0,0 +1,27 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingQueryPayload.java +@@ -0,0 +1,22 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -6203,28 +8253,23 @@ index 0000000..8b1ea67 +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; -+import net.raphimc.viabedrock.api.model.NativeFurnaceOptions; -+import net.raphimc.viabedrock.protocol.model.FurnaceOptions; ++import net.raphimc.viabedrock.api.model.NativeCookingQuery; + -+public record NativeFurnaceOptionsPayload(FurnaceOptions options) implements CustomPacketPayload { -+ public static final Type TYPE = -+ new Type<>(Identifier.parse(NativeFurnaceOptions.CHANNEL)); -+ public static final StreamCodec CODEC = -+ CustomPacketPayload.codec( -+ (payload, buffer) -> NativeFurnaceOptions.TYPE.write(buffer, payload.options), -+ buffer -> new NativeFurnaceOptionsPayload(NativeFurnaceOptions.TYPE.read(buffer))); ++public record NativeCookingQueryPayload(NativeCookingQuery query) implements CustomPacketPayload { ++ public static final Type TYPE = new Type<>(Identifier.parse(NativeCookingQuery.CHANNEL)); ++ public static final StreamCodec CODEC = CustomPacketPayload.codec( ++ (payload, buffer) -> NativeCookingQuery.TYPE.write(buffer, payload.query), ++ buffer -> new NativeCookingQueryPayload(NativeCookingQuery.TYPE.read(buffer))); + + @Override -+ public Type type() { -+ return TYPE; -+ } ++ public Type type() { return TYPE; } +} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeFurnacePreferenceState.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeFurnacePreferenceState.java +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingQueryResultPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingQueryResultPayload.java new file mode 100644 -index 0000000..2de05c4 +index 0000000..a5bf2ea --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeFurnacePreferenceState.java -@@ -0,0 +1,44 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingQueryResultPayload.java +@@ -0,0 +1,22 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -6232,49 +8277,58 @@ index 0000000..2de05c4 + */ +package com.viaversion.viafabricplus.bedrock.screen.container; + -+import java.util.HashMap; -+import java.util.Map; -+import java.util.Objects; -+import net.raphimc.viabedrock.protocol.model.FurnaceOptions; ++import net.minecraft.network.RegistryFriendlyByteBuf; ++import net.minecraft.network.codec.StreamCodec; ++import net.minecraft.network.protocol.common.custom.CustomPacketPayload; ++import net.minecraft.resources.Identifier; ++import net.raphimc.viabedrock.api.model.NativeCookingQueryResult; + -+/** Native preferences outlive individual screens, but never their connection. */ -+final class NativeFurnacePreferenceState { -+ private C owner; -+ private final Map values = new HashMap<>(); -+ private long revision; ++public record NativeCookingQueryResultPayload(NativeCookingQueryResult result) implements CustomPacketPayload { ++ public static final Type TYPE = new Type<>(Identifier.parse(NativeCookingQueryResult.CHANNEL)); ++ public static final StreamCodec CODEC = CustomPacketPayload.codec( ++ (payload, buffer) -> NativeCookingQueryResult.TYPE.write(buffer, payload.result), ++ buffer -> new NativeCookingQueryResultPayload(NativeCookingQueryResult.TYPE.read(buffer))); + -+ void begin(final C connection) { -+ this.owner = Objects.requireNonNull(connection); -+ this.values.clear(); -+ this.revision++; -+ } ++ @Override ++ public Type type() { return TYPE; } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingReset.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingReset.java +new file mode 100644 +index 0000000..9f1e754 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingReset.java +@@ -0,0 +1,25 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.container; + -+ void accept(final C connection, final FurnaceOptions options) { -+ if (connection == null || this.owner != connection) return; -+ if (!Objects.equals(this.values.put(options.furnaceType(), options), options)) this.revision++; -+ } ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiInput; + -+ FurnaceOptions options(final C connection, final int family) { -+ return connection != null && this.owner == connection ? this.values.get(family) : null; -+ } ++/** Bedrock 1.26.60.5 reset callbacks use raw phases, independently of activation. */ ++final class NativeCookingReset { ++ static final String TARGET = "button.container_reset_held"; + -+ long revision(final C connection) { -+ return connection != null && this.owner == connection ? this.revision : -1; -+ } ++ private NativeCookingReset() { } + -+ void clear(final C connection) { -+ if (connection == null || this.owner != connection) return; -+ this.owner = null; -+ this.values.clear(); -+ this.revision++; ++ static boolean target(final int target) { return target == NativeUiInput.symbol(TARGET); } ++ ++ static Edge edge(final NativeUiInput.ButtonEvent event) { ++ if (!target(event.target())) return Edge.NONE; ++ if (event.phase() == 1) return Edge.EMPTY_SOURCE; ++ return event.phase() == 0 && event.previous() != 1 ? Edge.COMPLETE : Edge.NONE; + } ++ ++ enum Edge { NONE, EMPTY_SOURCE, COMPLETE } +} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryFlights.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryFlights.java +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingSourceInput.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingSourceInput.java new file mode 100644 -index 0000000..0ea5137 +index 0000000..59fe373 --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryFlights.java -@@ -0,0 +1,65 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingSourceInput.java +@@ -0,0 +1,250 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -6282,15 +8336,441 @@ index 0000000..0ea5137 + */ +package com.viaversion.viafabricplus.bedrock.screen.container; + -+import java.util.ArrayList; -+import java.util.List; -+import java.util.Map; -+import net.minecraft.world.item.ItemStack; -+ -+/** Matched real slot loss/gain, native quintic motion and expiration. Cursor collection membership is unbound. */ -+final class NativeInventoryFlights { -+ private final List flights = new ArrayList<>(); -+ ++import com.viaversion.viafabricplus.bedrock.screen.container.NativeInventorySourceState.Source; ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiInput; ++import java.util.Set; ++import java.util.function.Function; ++import net.raphimc.viabedrock.api.model.NativeCookingSourceAction; ++ ++/** Touch source selection and drawing retain their original slot instead of picking up the cursor. */ ++final class NativeCookingSourceInput { ++ static final Set CONTROL_TARGETS = Set.of("button.arrow_key", "button.scrollbar_touch"); ++ ++ static boolean controlTarget(final int target) { ++ return CONTROL_TARGETS.stream().anyMatch(name -> NativeUiInput.symbol(name) == target); ++ } ++ ++ // Bedrock 1.26.60.5 option600 (ctrl_stacksplittingtriggertime), in milliseconds. ++ private static final int DEFAULT_SPLIT_TRIGGER = 525; ++ private final NativeInventorySourceState sources; ++ private final Function cells; ++ private final Transport transport; ++ private Source previous = NativeInventorySourceState.NONE; ++ private boolean selecting; ++ private boolean secondary; ++ private boolean drawing; ++ private boolean arrowHeld; ++ private boolean shapeBlocked; ++ private Source holdSource = NativeInventorySourceState.NONE; ++ private Source quantitySource = NativeInventorySourceState.NONE; ++ private long holdStarted = Long.MIN_VALUE; ++ private short holdX; ++ private short holdY; ++ private short quantityOriginX; ++ private float quantityFraction; ++ private boolean quantityActive; ++ ++ NativeCookingSourceInput(final NativeInventorySourceState sources, final Function cells, ++ final Transport transport) { ++ this.sources = sources; ++ this.cells = cells; ++ this.transport = transport; ++ } ++ ++ /** Raw edge callbacks share a single native flag for scrollbar touch and cursor-drop-all. */ ++ void control(final NativeUiInput.ButtonEvent input) { ++ final boolean down = input.phase() == 1 && input.previous() != 1; ++ final boolean up = input.phase() == 0 && input.previous() == 1; ++ if (input.target() == NativeUiInput.symbol("button.arrow_key")) { ++ if (down) this.arrowHeld = true; ++ else if (up) this.arrowHeld = false; ++ } else if (input.target() == NativeUiInput.symbol("button.scrollbar_touch") ++ || input.target() == NativeUiInput.symbol("button.cursor_drop_all")) { ++ if (down) this.shapeBlocked = true; ++ else if (up) this.shapeBlocked = false; ++ } ++ } ++ ++ boolean event(final NativeUiInput.ButtonEvent input, final Source source, final boolean cursorEmpty) { ++ this.frame(); ++ final boolean all = input.target() == NativeUiInput.symbol("button.container_take_all_place_all"); ++ final boolean one = input.target() == NativeUiInput.symbol("button.container_take_half_place_one"); ++ final boolean shape = input.target() == NativeUiInput.symbol("button.shape_drawing"); ++ if (!all && !one && !shape || !cursorEmpty && !this.drawing) return false; ++ if (input.phase() != 0 && input.phase() != 1) return false; ++ if (!this.drawing && !this.transport.available()) return true; ++ final Cell cell = source.present() ? this.cells.apply(source) : null; ++ if (shape) { ++ if (input.phase() != 1) return false; ++ if (this.quantityActive) return true; ++ this.shape(source, cell); ++ return true; ++ } ++ if (input.phase() == 1 && input.previous() != 1) { ++ if (cell != null && cell.selectable()) this.shapeBlocked = false; ++ final Cell selected = this.cells.apply(this.sources.primary()); ++ if (!this.drawing && cell != null && cell.selectable() && !this.holdSource.present()) this.holdSource = source; ++ if (this.drawing || this.selecting || selected != null && selected.selectable()) return true; ++ if (one && cell != null && cell.selectable()) { ++ this.secondary = true; ++ return true; ++ } ++ if (!all || cell == null || !cell.selectable()) return false; ++ this.selecting = true; ++ return true; ++ } ++ // Native activation can finish after a mapping was invalidated (previous phase two). ++ if (input.phase() != 0 || input.previous() == 0) return this.selecting || this.drawing || this.sources.primary().present(); ++ this.cancelHold(); ++ if (this.quantityActive) { ++ // Native state18 exit clears the active contact, not the retained source or quantity. ++ this.quantityActive = false; ++ this.previous = NativeInventorySourceState.NONE; ++ return true; ++ } ++ if (this.drawing) { ++ this.finish(); ++ return true; ++ } ++ if (this.selecting) { ++ this.selecting = this.secondary = false; ++ this.previous = NativeInventorySourceState.NONE; ++ this.sources.primary(cell != null ? source : NativeInventorySourceState.NONE); ++ return true; ++ } ++ if (this.secondary) { ++ this.secondary = false; ++ this.previous = NativeInventorySourceState.NONE; ++ if (cell != null) this.transport.pickup(cell.slot(), 1); ++ return true; ++ } ++ final Cell selected = this.cells.apply(this.sources.primary()); ++ if (selected == null || !selected.selectable() || cell == null) return false; ++ if (selected.slot() != cell.slot() ++ && !this.transport.start(selected.slot(), cell.slot(), one ? 1 : this.quantity(selected), NativeCookingSourceAction.Kind.TAP)) ++ return true; ++ this.clear(); ++ return true; ++ } ++ ++ private void shape(final Source source, final Cell cell) { ++ if (this.drawing) { ++ this.visit(cell); ++ } else { ++ final Cell selected = this.cells.apply(this.sources.primary()); ++ if (selected == null || !selected.selectable()) { ++ if (!this.shapeBlocked && cell != null && cell.selectable()) { ++ this.selecting = true; ++ if (!this.holdSource.present()) this.holdSource = source; ++ } ++ } else if (!this.arrowHeld && selected.selectable() && cell != null && cell.slot() != selected.slot() ++ && this.previous.present()) { ++ final Cell previous = this.cells.apply(this.previous); ++ final Cell first = previous != null && previous.slot() != selected.slot() ? previous : cell; ++ if (this.transport.start(selected.slot(), first.slot(), this.quantity(selected), NativeCookingSourceAction.Kind.DRAW)) { ++ this.drawing = true; ++ this.selecting = false; ++ this.cancelHold(); ++ if (cell.slot() != first.slot()) this.visit(cell); ++ } ++ } ++ } ++ // Native retains the latest destination even while an arrow blocks distribution entry. ++ this.previous = source; ++ } ++ ++ private void visit(final Cell cell) { ++ // Authority owns destination eligibility and duplicate allocation. A rejected or locked ++ // destination may become eligible before this pointer returns to it. ++ if (cell != null) this.transport.visit(cell.slot()); ++ } ++ ++ boolean finish() { ++ final boolean active = this.drawing || this.selecting || this.secondary || this.sources.primary().present(); ++ if (this.drawing) this.transport.finish(); ++ this.clear(); ++ return active; ++ } ++ ++ /** Rejected or retired admission cannot leave a local drawing owner behind. */ ++ void frame() { ++ if (this.drawing && !this.transport.drawing()) this.clear(); ++ } ++ ++ /** Native pointer coordinates are physical pixels; scale converts the authored GUI units. */ ++ void frame(final long nanos, final double x, final double y, final float scale, final boolean contact) { ++ this.frame(); ++ if (!contact || this.drawing || !this.transport.available()) { ++ this.cancelHold(); ++ this.quantityActive = false; ++ return; ++ } ++ final short pointerX = (short) x; ++ final short pointerY = (short) y; ++ if (this.holdSource.present() && !this.quantityActive) { ++ if (this.holdStarted == Long.MIN_VALUE) { ++ this.holdStarted = nanos / 1_000_000; ++ this.holdX = pointerX; ++ this.holdY = pointerY; ++ } ++ final short dx = (short) (pointerX - this.holdX); ++ final short dy = (short) (pointerY - this.holdY); ++ if ((short) (dx * dx + dy * dy) >= 900) { ++ this.cancelHold(); ++ this.selecting = this.secondary = false; ++ } else if (nanos / 1_000_000 - this.holdStarted > DEFAULT_SPLIT_TRIGGER ++ && !this.holdSource.collection().equals("recipe_search") ++ && (!this.quantitySource.present() || this.quantitySource.equivalent(this.holdSource))) { ++ final Cell held = this.cells.apply(this.holdSource); ++ if (held != null && held.selectable() && held.count() > 1) { ++ this.sources.primary(this.holdSource); ++ if (!this.quantitySource.present()) { ++ this.quantitySource = this.holdSource; ++ this.quantityOriginX = pointerX; ++ this.quantityFraction = 0; ++ } ++ this.quantityActive = true; ++ this.selecting = this.secondary = false; ++ this.cancelHold(); ++ } ++ } ++ } ++ if (this.quantityActive) { ++ final float normalized = Math.clamp((pointerX - this.quantityOriginX) / (scale * 32F), -1F, 1F); ++ final float angle = (float) (normalized * Math.PI * 0.5D); ++ this.quantityFraction = ((float) Math.sin(angle) + 1F) * 0.5F; ++ } ++ } ++ ++ private int quantity(final Cell selected) { ++ return this.quantitySource.present() && this.quantitySource.equivalent(this.sources.primary()) ++ ? (int) ((selected.count() - 1) * this.quantityFraction) + 1 : selected.count(); ++ } ++ ++ Cell selection() { ++ final Cell selected = this.cells.apply(this.sources.primary()); ++ return selected != null && selected.selectable() ++ ? new Cell(selected.slot(), this.quantity(selected), true) : null; ++ } ++ ++ private void cancelHold() { ++ this.holdSource = NativeInventorySourceState.NONE; ++ this.holdStarted = Long.MIN_VALUE; ++ } ++ ++ private void clear() { ++ this.drawing = this.selecting = this.secondary = false; ++ this.quantityActive = false; ++ this.quantitySource = NativeInventorySourceState.NONE; ++ this.quantityFraction = 0; ++ this.cancelHold(); ++ this.previous = NativeInventorySourceState.NONE; ++ this.sources.primary(NativeInventorySourceState.NONE); ++ } ++ ++ record Cell(int slot, int count, boolean selectable) { } ++ ++ interface Transport { ++ boolean available(); ++ boolean drawing(); ++ boolean start(int source, int destination, int quantity, NativeCookingSourceAction.Kind kind); ++ void visit(int slot); ++ void pickup(int slot, int button); ++ void finish(); ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingSourcePayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingSourcePayload.java +new file mode 100644 +index 0000000..65ce217 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingSourcePayload.java +@@ -0,0 +1,22 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.container; ++ ++import net.minecraft.network.RegistryFriendlyByteBuf; ++import net.minecraft.network.codec.StreamCodec; ++import net.minecraft.network.protocol.common.custom.CustomPacketPayload; ++import net.minecraft.resources.Identifier; ++import net.raphimc.viabedrock.api.model.NativeCookingSourceAction; ++ ++public record NativeCookingSourcePayload(NativeCookingSourceAction action) implements CustomPacketPayload { ++ public static final Type TYPE = new Type<>(Identifier.parse(NativeCookingSourceAction.CHANNEL)); ++ public static final StreamCodec CODEC = CustomPacketPayload.codec( ++ (payload, buffer) -> NativeCookingSourceAction.TYPE.write(buffer, payload.action), ++ buffer -> new NativeCookingSourcePayload(NativeCookingSourceAction.TYPE.read(buffer))); ++ ++ @Override ++ public Type type() { return TYPE; } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingSourceResultPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingSourceResultPayload.java +new file mode 100644 +index 0000000..cf60d26 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingSourceResultPayload.java +@@ -0,0 +1,22 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.container; ++ ++import net.minecraft.network.RegistryFriendlyByteBuf; ++import net.minecraft.network.codec.StreamCodec; ++import net.minecraft.network.protocol.common.custom.CustomPacketPayload; ++import net.minecraft.resources.Identifier; ++import net.raphimc.viabedrock.api.model.NativeCookingSourceResult; ++ ++public record NativeCookingSourceResultPayload(NativeCookingSourceResult result) implements CustomPacketPayload { ++ public static final Type TYPE = new Type<>(Identifier.parse(NativeCookingSourceResult.CHANNEL)); ++ public static final StreamCodec CODEC = CustomPacketPayload.codec( ++ (payload, buffer) -> NativeCookingSourceResult.TYPE.write(buffer, payload.result), ++ buffer -> new NativeCookingSourceResultPayload(NativeCookingSourceResult.TYPE.read(buffer))); ++ ++ @Override ++ public Type type() { return TYPE; } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeFurnaceOptionsPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeFurnaceOptionsPayload.java +new file mode 100644 +index 0000000..8b1ea67 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeFurnaceOptionsPayload.java +@@ -0,0 +1,27 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.container; ++ ++import net.minecraft.network.RegistryFriendlyByteBuf; ++import net.minecraft.network.codec.StreamCodec; ++import net.minecraft.network.protocol.common.custom.CustomPacketPayload; ++import net.minecraft.resources.Identifier; ++import net.raphimc.viabedrock.api.model.NativeFurnaceOptions; ++import net.raphimc.viabedrock.protocol.model.FurnaceOptions; ++ ++public record NativeFurnaceOptionsPayload(FurnaceOptions options) implements CustomPacketPayload { ++ public static final Type TYPE = ++ new Type<>(Identifier.parse(NativeFurnaceOptions.CHANNEL)); ++ public static final StreamCodec CODEC = ++ CustomPacketPayload.codec( ++ (payload, buffer) -> NativeFurnaceOptions.TYPE.write(buffer, payload.options), ++ buffer -> new NativeFurnaceOptionsPayload(NativeFurnaceOptions.TYPE.read(buffer))); ++ ++ @Override ++ public Type type() { ++ return TYPE; ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeFurnacePreferenceState.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeFurnacePreferenceState.java +new file mode 100644 +index 0000000..2de05c4 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeFurnacePreferenceState.java +@@ -0,0 +1,44 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.container; ++ ++import java.util.HashMap; ++import java.util.Map; ++import java.util.Objects; ++import net.raphimc.viabedrock.protocol.model.FurnaceOptions; ++ ++/** Native preferences outlive individual screens, but never their connection. */ ++final class NativeFurnacePreferenceState { ++ private C owner; ++ private final Map values = new HashMap<>(); ++ private long revision; ++ ++ void begin(final C connection) { ++ this.owner = Objects.requireNonNull(connection); ++ this.values.clear(); ++ this.revision++; ++ } ++ ++ void accept(final C connection, final FurnaceOptions options) { ++ if (connection == null || this.owner != connection) return; ++ if (!Objects.equals(this.values.put(options.furnaceType(), options), options)) this.revision++; ++ } ++ ++ FurnaceOptions options(final C connection, final int family) { ++ return connection != null && this.owner == connection ? this.values.get(family) : null; ++ } ++ ++ long revision(final C connection) { ++ return connection != null && this.owner == connection ? this.revision : -1; ++ } ++ ++ void clear(final C connection) { ++ if (connection == null || this.owner != connection) return; ++ this.owner = null; ++ this.values.clear(); ++ this.revision++; ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryDragPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryDragPayload.java +new file mode 100644 +index 0000000..7e402fc +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryDragPayload.java +@@ -0,0 +1,25 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.container; ++ ++import net.minecraft.network.RegistryFriendlyByteBuf; ++import net.minecraft.network.codec.StreamCodec; ++import net.minecraft.network.protocol.common.custom.CustomPacketPayload; ++import net.minecraft.resources.Identifier; ++import net.raphimc.viabedrock.api.model.container.Container; ++ ++/** Capability marker. Input uses the ordered Java quick-craft phases. */ ++public record NativeInventoryDragPayload() implements CustomPacketPayload { ++ public static final Type TYPE = new Type<>(Identifier.parse(Container.INCREMENTAL_DRAG_CHANNEL)); ++ public static final StreamCodec CODEC = CustomPacketPayload.codec( ++ (payload, buffer) -> {}, buffer -> { ++ if (buffer.isReadable()) throw new IllegalArgumentException("Unexpected incremental drag fields"); ++ return new NativeInventoryDragPayload(); ++ }); ++ ++ @Override ++ public Type type() { return TYPE; } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryFlights.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryFlights.java +new file mode 100644 +index 0000000..0ea5137 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryFlights.java +@@ -0,0 +1,65 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.container; ++ ++import java.util.ArrayList; ++import java.util.List; ++import java.util.Map; ++import net.minecraft.world.item.ItemStack; ++ ++/** Matched real slot loss/gain, native quintic motion and expiration. Cursor collection membership is unbound. */ ++final class NativeInventoryFlights { ++ private final List flights = new ArrayList<>(); ++ + void update(final List before, final List after, final Map anchors, final long now) { + if (before.size() != after.size()) throw new IllegalArgumentException("Inventory flight snapshot sizes differ"); + final int[] lost = new int[before.size()], gained = new int[after.size()]; @@ -6340,12 +8820,12 @@ index 0000000..0ea5137 + record Frame(ItemStack stack, float x, float y, float scale) {} + private record Flight(ItemStack stack, Anchor origin, Anchor destination, long start, float duration) {} +} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryItemPresentation.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryItemPresentation.java +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryHelp.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryHelp.java new file mode 100644 -index 0000000..2012b06 +index 0000000..c3a229a --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryItemPresentation.java -@@ -0,0 +1,241 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryHelp.java +@@ -0,0 +1,47 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -6353,40 +8833,163 @@ index 0000000..2012b06 + */ +package com.viaversion.viafabricplus.bedrock.screen.container; + -+import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiScene; -+import com.viaversion.nbt.io.NBTIO; -+import com.viaversion.nbt.limiter.TagLimiter; -+import com.viaversion.viaversion.libs.gson.JsonElement; -+import com.viaversion.viaversion.libs.gson.JsonPrimitive; ++import com.viaversion.viaversion.libs.gson.JsonObject; ++import java.util.function.IntFunction; + -+import net.minecraft.core.component.DataComponents; -+import net.minecraft.nbt.ByteTag; -+import net.minecraft.nbt.ByteArrayTag; -+import net.minecraft.nbt.IntArrayTag; -+import net.minecraft.nbt.StringTag; -+import net.minecraft.nbt.CompoundTag; -+import net.minecraft.nbt.IntTag; -+import net.minecraft.nbt.NbtIo; -+import net.minecraft.world.item.ItemStack; -+import net.raphimc.viabedrock.api.model.NativeItemHoverText; -+import net.raphimc.viabedrock.api.model.NativeBundleTooltipData; -+import net.raphimc.viabedrock.api.model.NativeBundleTooltipItems; -+import net.raphimc.viabedrock.api.resourcepack.ui.BundleTooltipUi; -+import com.viaversion.viaversion.api.minecraft.item.Item; -+import net.raphimc.viabedrock.api.model.NativeItemStackData; -+import net.raphimc.viabedrock.api.model.NativeItemStorageData; -+import net.raphimc.viabedrock.api.resourcepack.ui.InventoryStackCount; -+import net.raphimc.viabedrock.protocol.rewriter.ItemDataRewriter; ++/** A Help view retains its inventory owner while navigating authored topics. */ ++final class NativeInventoryHelp { ++ private final IntFunction resolver; ++ private int topic = -1; ++ private long revision; + -+import java.io.ByteArrayInputStream; -+import java.io.ByteArrayOutputStream; -+import java.io.DataInputStream; -+import java.io.DataOutputStream; -+import java.io.IOException; -+import java.util.HashMap; -+import java.util.ArrayList; -+import java.util.Arrays; -+import java.util.Collections; ++ NativeInventoryHelp(final IntFunction resolver) { this.resolver = resolver; } ++ ++ boolean active() { return this.topic >= 0; } ++ long revision() { return this.revision; } ++ ++ void open(final int topic) { ++ if (this.active()) return; ++ this.resolver.apply(topic); ++ this.topic = topic; ++ this.revision++; ++ } ++ ++ boolean close() { ++ if (!this.active()) return false; ++ this.topic = -1; ++ this.revision++; ++ return true; ++ } ++ ++ void select(final int topic) { ++ if (!this.active() || this.topic == topic) return; ++ this.resolver.apply(topic); ++ this.topic = topic; ++ this.revision++; ++ } ++ ++ JsonObject resolve() { ++ if (!this.active()) throw new IllegalStateException("Native Help is closed"); ++ return this.resolver.apply(this.topic); ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryHelpText.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryHelpText.java +new file mode 100644 +index 0000000..ee1ffaa +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryHelpText.java +@@ -0,0 +1,64 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.container; ++ ++import com.viaversion.viaversion.libs.gson.JsonObject; ++import com.viaversion.viaversion.libs.gson.JsonElement; ++import com.viaversion.viaversion.libs.gson.JsonArray; ++import com.viaversion.viaversion.libs.gson.JsonPrimitive; ++import java.util.function.Function; ++import java.util.regex.Pattern; ++import java.util.regex.Matcher; ++import net.raphimc.viabedrock.api.resourcepack.ui.UiExpression; ++ ++/** Input instructions use this client's actual bindings; unsupported inline glyphs cannot leak as token names. */ ++final class NativeInventoryHelpText { ++ private static final Pattern TOKEN = Pattern.compile(":([A-Za-z0-9_.]+):"); ++ ++ private NativeInventoryHelpText() {} ++ ++ static void resolve(final JsonObject scene, final Function input) { ++ resolveNode(scene.getAsJsonObject("root"), true, input); ++ } ++ ++ private static void resolveNode(final JsonObject node, final boolean parentVisible, final Function input) { ++ final boolean visible = parentVisible && (!node.has("visible") || UiExpression.truth(node.get("visible"))) ++ && (!UiExpression.truth(node.get("ignored"))); ++ if (visible && node.has("type") && node.get("type").getAsString().equals("label") && node.has("text")) { ++ node.addProperty("text", expand(node.get("text").getAsString(), input)); ++ if (node.has("text_component")) node.add("text_component", component(node.get("text_component"), input)); ++ } ++ if (node.has("children")) for (final var child : node.getAsJsonArray("children")) resolveNode(child.getAsJsonObject(), visible, input); ++ } ++ private static String expand(final String value, final Function input) { ++ final var matcher = TOKEN.matcher(value); ++ final var text = new StringBuilder(); ++ while (matcher.find()) { ++ final String replacement = input.apply(matcher.group(1)); ++ if (replacement == null) throw new IllegalArgumentException("Unsupported native Help glyph " + matcher.group(1)); ++ matcher.appendReplacement(text, Matcher.quoteReplacement(replacement)); ++ } ++ matcher.appendTail(text); ++ return text.toString(); ++ } ++ ++ private static JsonElement component(final JsonElement source, final Function input) { ++ if (source.isJsonPrimitive() && source.getAsJsonPrimitive().isString()) return new JsonPrimitive(expand(source.getAsString(), input)); ++ if (source.isJsonArray()) { ++ final var values = new JsonArray(); ++ for (final var value : source.getAsJsonArray()) values.add(component(value, input)); ++ return values; ++ } ++ final JsonElement result = source.deepCopy(); ++ if (result.isJsonObject()) { ++ final var object = result.getAsJsonObject(); ++ if (object.has("text")) object.addProperty("text", expand(object.get("text").getAsString(), input)); ++ for (final String key : new String[]{"extra", "with"}) if (object.has(key)) object.add(key, component(object.get(key), input)); ++ } ++ return result; ++ } ++ ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryItemPresentation.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryItemPresentation.java +new file mode 100644 +index 0000000..2012b06 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryItemPresentation.java +@@ -0,0 +1,241 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.container; ++ ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiScene; ++import com.viaversion.nbt.io.NBTIO; ++import com.viaversion.nbt.limiter.TagLimiter; ++import com.viaversion.viaversion.libs.gson.JsonElement; ++import com.viaversion.viaversion.libs.gson.JsonPrimitive; ++ ++import net.minecraft.core.component.DataComponents; ++import net.minecraft.nbt.ByteTag; ++import net.minecraft.nbt.ByteArrayTag; ++import net.minecraft.nbt.IntArrayTag; ++import net.minecraft.nbt.StringTag; ++import net.minecraft.nbt.CompoundTag; ++import net.minecraft.nbt.IntTag; ++import net.minecraft.nbt.NbtIo; ++import net.minecraft.world.item.ItemStack; ++import net.raphimc.viabedrock.api.model.NativeItemHoverText; ++import net.raphimc.viabedrock.api.model.NativeBundleTooltipData; ++import net.raphimc.viabedrock.api.model.NativeBundleTooltipItems; ++import net.raphimc.viabedrock.api.resourcepack.ui.BundleTooltipUi; ++import com.viaversion.viaversion.api.minecraft.item.Item; ++import net.raphimc.viabedrock.api.model.NativeItemStackData; ++import net.raphimc.viabedrock.api.model.NativeItemStorageData; ++import net.raphimc.viabedrock.api.resourcepack.ui.InventoryStackCount; ++import net.raphimc.viabedrock.protocol.rewriter.ItemDataRewriter; ++ ++import java.io.ByteArrayInputStream; ++import java.io.ByteArrayOutputStream; ++import java.io.DataInputStream; ++import java.io.DataOutputStream; ++import java.io.IOException; ++import java.util.HashMap; ++import java.util.ArrayList; ++import java.util.Arrays; ++import java.util.Collections; +import java.util.List; +import java.util.function.Function; +import java.util.stream.IntStream; @@ -6784,10 +9387,10 @@ index 0000000..c655dc7 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventorySourceState.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventorySourceState.java new file mode 100644 -index 0000000..08baf58 +index 0000000..0e3aa8c --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventorySourceState.java -@@ -0,0 +1,210 @@ +@@ -0,0 +1,306 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -6798,6 +9401,7 @@ index 0000000..08baf58 +import com.viaversion.viaversion.libs.gson.JsonObject; +import java.util.List; +import java.util.Objects; ++import java.util.Set; +import java.util.function.Function; +import java.util.function.IntFunction; +import java.util.function.Predicate; @@ -6820,15 +9424,62 @@ index 0000000..08baf58 + private int mode; + private int inputMode = 1; + private boolean hidden; ++ private boolean persistentHoverText; ++ private long persistentHoverStarted; + private Integer selectedCell; + private long revision; + private final UiFactoryInstances details = new UiFactoryInstances(); + + long revision() { return this.revision; } ++ Source primary() { return this.primary; } + Source bundle() { return this.bundle; } + Source hover() { return this.hover; } + int mode() { return this.mode; } + ++ static final Set NAVIGATION_TARGETS = Set.of("button.scroll_up", "button.scroll_down", ++ "button.scroll_left", "button.scroll_right", "button.inventory_left", "button.inventory_right"); ++ ++ static boolean navigation(final int target) { ++ return NAVIGATION_TARGETS.stream().anyMatch(name -> NativeUiInput.symbol(name) == target); ++ } ++ ++ /** Native directional callbacks stop at row edges; sequential callbacks wrap over real cells. */ ++ boolean navigate(final int target, final boolean selectedSourceEmpty, final BundleTooltipLayout layout) { ++ if (!navigation(target)) return false; ++ final boolean previous = target == NativeUiInput.symbol("button.inventory_left"); ++ final boolean next = target == NativeUiInput.symbol("button.inventory_right"); ++ final boolean sequential = previous || next; ++ if (sequential && (this.inputMode == 3 || !selectedSourceEmpty)) return false; ++ if (this.mode != 2) return sequential; ++ if (layout == null) return false; ++ final int columns = layout.columns(); ++ final int first = (columns - 1) * (layout.visibleCells() + layout.hiddenEntries()) % columns; ++ final int last = first + layout.visibleCells() - 1; ++ final int cell; ++ if (this.selectedCell == null) { ++ cell = next || target == NativeUiInput.symbol("button.scroll_right") ? first : last; ++ } else if (sequential) { ++ final int candidate = this.selectedCell + (previous ? -1 : 1); ++ cell = candidate < first || candidate > last ? previous ? last : first : candidate; ++ } else { ++ final int current = this.selectedCell; ++ if (target == NativeUiInput.symbol("button.scroll_up")) cell = current - columns >= first ? current - columns : current; ++ else if (target == NativeUiInput.symbol("button.scroll_down")) cell = current + columns <= last ? current + columns : current; ++ else if (target == NativeUiInput.symbol("button.scroll_left")) ++ cell = current > first && Math.floorDiv(current - 1, columns) == Math.floorDiv(current, columns) ? current - 1 : current; ++ else cell = current < last && Math.floorDiv(current + 1, columns) == Math.floorDiv(current, columns) ? current + 1 : current; ++ } ++ if (!Objects.equals(this.selectedCell, cell)) { ++ this.selectedCell = cell; ++ this.revision++; ++ } ++ return true; ++ } ++ ++ int selectedCell(final Source source) { ++ return this.mode == 2 && this.bundle.equivalent(source) && this.selectedCell != null ? this.selectedCell : -1; ++ } ++ + /** Desktop state1 activation with no retained primary or secondary source. */ + NativeBundleAction.Kind desktopBundleInput(final NativeContainerInput.Event event, final boolean heldStorage, + final boolean heldEmpty, final boolean clickedStorage, final boolean clickedEmpty) { @@ -6846,13 +9497,51 @@ index 0000000..08baf58 + void inputMode(final int mode) { + if (this.inputMode != mode) { + this.inputMode = mode; ++ this.persistentHoverText = false; ++ this.revision++; ++ } ++ } ++ ++ /** The registered arrow callback uses raw pressed edges, independently of activation. */ ++ void arrowInput(final NativeUiInput.ButtonEvent input, final long nanos) { ++ if (input.target() == NativeUiInput.symbol("button.arrow_key") && input.phase() == 1 && input.previous() != 1 ++ && this.inputMode == 2) { ++ this.persistentHoverText = true; ++ this.persistentHoverStarted = nanos; ++ this.revision++; ++ } ++ } ++ ++ void frame(final long nanos) { ++ if (this.inputMode == 2 && this.persistentHoverText && nanos - this.persistentHoverStarted >= 6_000_000_000L) { ++ this.persistentHoverText = false; ++ this.revision++; ++ } ++ } ++ ++ /** Native pre-press resets the text before checking item validity or permissions. */ ++ void prePress(final Source source) { ++ if (source.present() && this.inputMode == 2 && this.persistentHoverText) { ++ this.persistentHoverText = false; + this.revision++; + } + } + + /** Called after controller-specific primary-source admission. */ + void primary(final Source source) { -+ this.primary = Objects.requireNonNull(source); ++ if (!this.primary.equals(Objects.requireNonNull(source))) { ++ this.primary = source; ++ this.revision++; ++ } ++ } ++ ++ void emptySource() { this.hover(NONE, false); } ++ ++ /** Native gesture finalization preserves hover and only clears admitted storage details. */ ++ void finishOperation(final boolean storage) { ++ if (this.primary.present() && storage) this.clearBundle(); ++ this.primary = this.secondary = NONE; ++ this.secondarySelected = false; + this.revision++; + } + @@ -6890,11 +9579,21 @@ index 0000000..08baf58 + return admitted; + } + ++ boolean clearHover() { ++ if (this.hover.equals(NONE)) return false; ++ this.hover = NONE; ++ this.revision++; ++ return true; ++ } ++ + void leave(final Source source) { + if (this.mode == 2 && this.hover.equivalent(source)) { + this.hover = NONE; + this.clear(); + this.revision++; ++ } else if (this.hover.equals(source)) { ++ // Native also retires exact hover outside the desktop bundle-tooltip callback. ++ this.hover = NONE; + } + } + @@ -6963,10 +9662,10 @@ index 0000000..08baf58 + + BundleTooltipUi.View view(final Function contents, + final Boolean showTags) { -+ if (this.mode == 0) return new BundleTooltipUi.View(0, this.inputMode, this.hidden, this.selectedCell, null, null, List.of(), this.details.instances()); ++ if (this.mode == 0) return new BundleTooltipUi.View(0, this.inputMode, this.hidden, this.selectedCell, null, null, List.of(), this.details.instances()).withPersistentHoverText(this.persistentHoverText); + final var snapshot = contents.apply(this.bundle); + if (snapshot == null) throw new IllegalArgumentException("Retained native bundle source is unavailable"); -+ return snapshot.view(this.mode, this.inputMode, this.hidden, this.selectedCell, showTags).withDetails(this.details.instances()); ++ return snapshot.view(this.mode, this.inputMode, this.hidden, this.selectedCell, showTags).withDetails(this.details.instances()).withPersistentHoverText(this.persistentHoverText); + } + + private void clear() { @@ -6998,76 +9697,6 @@ index 0000000..08baf58 + } + } +} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativePlayerHelpText.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativePlayerHelpText.java -new file mode 100644 -index 0000000..d1209c8 ---- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativePlayerHelpText.java -@@ -0,0 +1,64 @@ -+/* -+ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock -+ * Copyright (C) 2026 ViaVersion and contributors -+ * SPDX-License-Identifier: GPL-3.0-or-later -+ */ -+package com.viaversion.viafabricplus.bedrock.screen.container; -+ -+import com.viaversion.viaversion.libs.gson.JsonObject; -+import com.viaversion.viaversion.libs.gson.JsonElement; -+import com.viaversion.viaversion.libs.gson.JsonArray; -+import com.viaversion.viaversion.libs.gson.JsonPrimitive; -+import java.util.function.Function; -+import java.util.regex.Pattern; -+import java.util.regex.Matcher; -+import net.raphimc.viabedrock.api.resourcepack.ui.UiExpression; -+ -+/** Input instructions use this client's actual bindings; unsupported inline glyphs cannot leak as token names. */ -+final class NativePlayerHelpText { -+ private static final Pattern TOKEN = Pattern.compile(":([A-Za-z0-9_.]+):"); -+ -+ private NativePlayerHelpText() {} -+ -+ static void resolve(final JsonObject scene, final Function input) { -+ resolveNode(scene.getAsJsonObject("root"), true, input); -+ } -+ -+ private static void resolveNode(final JsonObject node, final boolean parentVisible, final Function input) { -+ final boolean visible = parentVisible && (!node.has("visible") || UiExpression.truth(node.get("visible"))) -+ && (!UiExpression.truth(node.get("ignored"))); -+ if (visible && node.has("type") && node.get("type").getAsString().equals("label") && node.has("text")) { -+ node.addProperty("text", expand(node.get("text").getAsString(), input)); -+ if (node.has("text_component")) node.add("text_component", component(node.get("text_component"), input)); -+ } -+ if (node.has("children")) for (final var child : node.getAsJsonArray("children")) resolveNode(child.getAsJsonObject(), visible, input); -+ } -+ private static String expand(final String value, final Function input) { -+ final var matcher = TOKEN.matcher(value); -+ final var text = new StringBuilder(); -+ while (matcher.find()) { -+ final String replacement = input.apply(matcher.group(1)); -+ if (replacement == null) throw new IllegalArgumentException("Unsupported native Help glyph " + matcher.group(1)); -+ matcher.appendReplacement(text, Matcher.quoteReplacement(replacement)); -+ } -+ matcher.appendTail(text); -+ return text.toString(); -+ } -+ -+ private static JsonElement component(final JsonElement source, final Function input) { -+ if (source.isJsonPrimitive() && source.getAsJsonPrimitive().isString()) return new JsonPrimitive(expand(source.getAsString(), input)); -+ if (source.isJsonArray()) { -+ final var values = new JsonArray(); -+ for (final var value : source.getAsJsonArray()) values.add(component(value, input)); -+ return values; -+ } -+ final JsonElement result = source.deepCopy(); -+ if (result.isJsonObject()) { -+ final var object = result.getAsJsonObject(); -+ if (object.has("text")) object.addProperty("text", expand(object.get("text").getAsString(), input)); -+ for (final String key : new String[]{"extra", "with"}) if (object.has(key)) object.add(key, component(object.get(key), input)); -+ } -+ return result; -+ } -+ -+} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativePlayerInventoryActionPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativePlayerInventoryActionPayload.java new file mode 100644 index 0000000..b1eb6f2 @@ -7155,10 +9784,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..e7c0417 +index 0000000..94f53bd --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativePlayerPresentation.java -@@ -0,0 +1,245 @@ +@@ -0,0 +1,248 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -7207,16 +9836,17 @@ index 0000000..e7c0417 + private boolean retired; + private String selectedCollection = ""; + private int selectedIndex = -1; -+ private int helpTopic = -1; ++ private final NativeInventoryHelp help = new NativeInventoryHelp(topic -> this.program.help(this.helpCreative, topic)); + private boolean helpCreative; + + NativePlayerPresentation(final NativePlayerInventoryMessage message, final Map previews, + final Map craftingPreview, final LongSupplier clock) { + this.clock = clock; -+ this.replace(message, previews, craftingPreview); ++ this.replace(message, message.state(), previews, craftingPreview); + } + -+ private void replace(final NativePlayerInventoryMessage message, final Map previews, ++ private void replace(final NativePlayerInventoryMessage message, final PlayerInventoryUi.State state, ++ 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"); @@ -7226,10 +9856,12 @@ index 0000000..e7c0417 + 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); ++ // Snapshot revisions can retain the same immutable authored resources. ++ if (this.message == null || !this.message.program().equals(message.program())) ++ this.program = PlayerInventoryUi.fromProgram(JsonParser.parseString(message.program()).getAsJsonObject()); ++ if (this.message != null && !this.state.cells().equals(state.cells())) this.select("", -1); + this.message = message; -+ this.state = message.state(); ++ this.state = state; + this.previews = Map.copyOf(owned); + this.craftingPreview = Map.copyOf(ghosts); + this.revision++; @@ -7238,9 +9870,11 @@ index 0000000..e7c0417 + 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() ++ || message.revision() <= this.message.revision() + || this.message.generation() != 0 && message.generation() != this.message.generation()) return false; -+ this.replace(message, previews, craftingPreview); ++ final var state = message.state(); ++ if (state.workbench() != this.state.workbench()) return false; ++ this.replace(message, state, previews, craftingPreview); + return true; + } + @@ -7259,27 +9893,20 @@ index 0000000..e7c0417 + return true; + } + -+ boolean helping() { return this.helpTopic >= 0; } ++ boolean helping() { return this.help.active(); } ++ ++ NativeInventoryHelp help() { return this.help; } + + void openHelp() { + if (this.retired || this.helping()) return; + this.helpCreative = this.state.creative(); -+ this.helpTopic = this.helpCreative ? 47 : 5; -+ this.revision++; ++ this.help.open(this.helpCreative ? 47 : 5); + } + -+ boolean closeHelp() { -+ if (!this.helping()) return false; -+ this.helpTopic = -1; -+ this.revision++; -+ return true; -+ } ++ boolean closeHelp() { return this.help.close(); } + + void helpTopic(final int index) { -+ if (!this.helping() || this.retired || index == this.helpTopic) return; -+ this.program.help(this.helpCreative, index); -+ this.helpTopic = index; -+ this.revision++; ++ if (!this.retired) this.help.select(index); + } + + boolean select(final String collection, final int index) { @@ -7345,10 +9972,15 @@ index 0000000..e7c0417 + + boolean bundle(final String collection, final int index, final NativeBundleAction.Kind kind, final int tooltipMode, + final Consumer sender) { ++ return this.bundle(collection, index, kind, tooltipMode, -1, sender); ++ } ++ ++ boolean bundle(final String collection, final int index, final NativeBundleAction.Kind kind, final int tooltipMode, ++ final int selectedCell, final Consumer sender) { + if (!this.idle() || this.message.generation() <= 0) return false; + final var message = this.message; + final var action = new NativeBundleAction(message.menuId(), message.opening(), message.generation(), message.revision(), -+ Math.incrementExact(this.sequence), collection, index, kind, tooltipMode); ++ Math.incrementExact(this.sequence), collection, index, kind, tooltipMode, selectedCell); + this.sequence = action.request(); + this.pendingBundle = action; + this.sentAt = this.clock.getAsLong(); @@ -7356,13 +9988,13 @@ index 0000000..e7c0417 + return true; + } + -+ @Override public long revision() { return this.revision; } ++ @Override public long revision() { return this.revision + this.help.revision(); } + + @Override + public Frame resolve(final int menuId, final String title, final List slots, + final Map cursor, final Boolean showTags, final BundleTooltipUi.View tooltip) { + if (menuId != this.message.menuId() || this.retired) throw new IllegalArgumentException("Retired player menu"); -+ if (this.helping()) return new Frame(this.program.help(this.helpCreative, this.helpTopic), 46, Map.of(), Map.of()); ++ if (this.helping()) return new Frame(this.help.resolve(), 46, Map.of(), Map.of()); + final var source = this.state; + final var cells = source.cells().stream().map(cell -> { + final Map values = new HashMap<>(NativeInventoryItemPresentation.values(this.previews.get(cell.id()), false, showTags)); @@ -7598,52 +10230,101 @@ index 0000000..5d3f834 + @Override + public Type type() { return this.deferred ? DEFERRED_TYPE : TYPE; } +} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/settings/base/NativeCookingControlStyle.java b/src/main/java/com/viaversion/viafabricplus/bedrock/settings/base/NativeCookingControlStyle.java +new file mode 100644 +index 0000000..420ad85 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/settings/base/NativeCookingControlStyle.java +@@ -0,0 +1,28 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.settings.base; ++ ++import com.viaversion.viafabricplus.api.settings.base.EnumSetting; ++import net.minecraft.network.chat.Component; ++ ++/** Explicit interaction preference; Java mouse events do not identify a touchscreen. */ ++public enum NativeCookingControlStyle implements EnumSetting.EnumValue { ++ MOUSE_AND_KEYBOARD(1, "mouse"), TOUCH(2, "touch"); ++ ++ private final int inputMode; ++ private final String key; ++ ++ NativeCookingControlStyle(final int inputMode, final String key) { ++ this.inputMode = inputMode; ++ this.key = key; ++ } ++ ++ public int inputMode() { return this.inputMode; } ++ ++ @Override public Component component() { ++ return Component.translatable("bedrock_settings.viafabricplus.native_cooking_controls." + this.key); ++ } ++} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/settings/impl/BedrockSettings.java b/src/main/java/com/viaversion/viafabricplus/bedrock/settings/impl/BedrockSettings.java -index a2c1af8..a0b21b8 100644 +index a2c1af8..8f87d64 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/settings/impl/BedrockSettings.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/settings/impl/BedrockSettings.java -@@ -50,6 +50,7 @@ public final class BedrockSettings { +@@ -27,6 +27,7 @@ import com.viaversion.viafabricplus.bedrock.settings.base.NativeUiScale; + import com.viaversion.viafabricplus.bedrock.settings.base.CaptionDuration; + import com.viaversion.viafabricplus.bedrock.settings.base.CaptionPosition; + import com.viaversion.viafabricplus.bedrock.settings.base.ParticleViewDistanceSetting; ++import com.viaversion.viafabricplus.bedrock.settings.base.NativeCookingControlStyle; + + + import com.viaversion.viafabricplus.ViaFabricPlus; +@@ -50,6 +51,8 @@ public final class BedrockSettings { private final BooleanSetting alwaysUseJavaHand; private final BooleanSetting allowCameraShake; private final EnumSetting nativeUiScale; + private final BooleanSetting nativeInventoryScreens; ++ private final EnumSetting nativeCookingControls; private final ParticleViewDistanceSetting particleViewDistance; private final BooleanSetting captions; private final BooleanSetting hideOwnCaptions; -@@ -68,6 +69,7 @@ public final class BedrockSettings { +@@ -68,6 +71,8 @@ public final class BedrockSettings { this.alwaysUseJavaHand = group.registerBoolean("always_use_java_hand", false); this.allowCameraShake = group.registerBoolean("allow_camera_shake", true); this.nativeUiScale = group.registerEnum("native_ui_scale", NativeUiScale.DEFAULT); + this.nativeInventoryScreens = group.registerBoolean("native_inventory_screens", true); ++ this.nativeCookingControls = group.registerEnum("native_cooking_controls", NativeCookingControlStyle.MOUSE_AND_KEYBOARD); this.particleViewDistance = new ParticleViewDistanceSetting(); group.register("particle_view_distance", this.particleViewDistance); this.captions = group.registerBoolean("captions", false); -@@ -98,6 +100,8 @@ public final class BedrockSettings { +@@ -98,6 +103,10 @@ public final class BedrockSettings { public EnumSetting nativeUiScale() { return this.nativeUiScale; } + public BooleanSetting nativeInventoryScreens() { return this.nativeInventoryScreens; } ++ ++ public EnumSetting nativeCookingControls() { return this.nativeCookingControls; } + public ParticleViewDistanceSetting particleViewDistance() { return this.particleViewDistance; } public BooleanSetting captions() { return this.captions; } diff --git a/src/main/resources/assets/viafabricplus-bedrock/lang/en_us.json b/src/main/resources/assets/viafabricplus-bedrock/lang/en_us.json -index d0fac3c..da0aa13 100644 +index d0fac3c..fdf6c1e 100644 --- a/src/main/resources/assets/viafabricplus-bedrock/lang/en_us.json +++ b/src/main/resources/assets/viafabricplus-bedrock/lang/en_us.json -@@ -389,6 +389,7 @@ +@@ -389,6 +389,10 @@ "bedrock_marketplace.viafabricplus.wallet_refresh": "Refresh balance", "bedrock_marketplace.viafabricplus.wallet_sign_in_short": "Sign in", "bedrock_settings.viafabricplus.native_ui_scale": "Bedrock UI scale offset", + "bedrock_settings.viafabricplus.native_inventory_screens": "Bedrock inventory screens", ++ "bedrock_settings.viafabricplus.native_cooking_controls": "Cooking control style", ++ "bedrock_settings.viafabricplus.native_cooking_controls.mouse": "Mouse and keyboard", ++ "bedrock_settings.viafabricplus.native_cooking_controls.touch": "Touch", "bedrock_settings.viafabricplus.native_ui_scale.default": "Default (0)", "bedrock_settings.viafabricplus.allow_camera_shake": "Allow camera shake", "bedrock_settings.viafabricplus.particle_view_distance": "Particle render distance", diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json -index 77e339e..e1f51e2 100644 +index f357e72..ff2a762 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -14,6 +14,12 @@ +@@ -15,6 +15,12 @@ "features.render.MixinNativeChunkFluid", "features.render.MixinNativeRegionFluid", "compat.sodium.MixinSodiumNativeFluid", @@ -7656,7 +10337,7 @@ index 77e339e..e1f51e2 100644 "compat.iris.MixinIrisPipelines", "compat.iris.IrisProgramSourceConstructor", "compat.iris.MixinIrisEmissionPrograms", -@@ -89,13 +95,14 @@ +@@ -90,13 +96,14 @@ "features.creative.MixinCreativeModeInventoryScreen", "features.creative.MixinCreativeModeTab_ItemDisplayBuilder", "features.creative.MixinCreativeModeTabs", @@ -7752,10 +10433,10 @@ index 0000000..80bc87a +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockNativeContainerScreenTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockNativeContainerScreenTest.java new file mode 100644 -index 0000000..be5c741 +index 0000000..fe87eb8 --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockNativeContainerScreenTest.java -@@ -0,0 +1,760 @@ +@@ -0,0 +1,941 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -7770,8 +10451,16 @@ index 0000000..be5c741 +import com.google.gson.JsonObject; +import com.viaversion.viaversion.libs.gson.JsonParser; +import com.mojang.blaze3d.platform.InputConstants; -+import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiLayout; -+import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiPresentation; ++import com.mojang.blaze3d.platform.Window; ++import com.viaversion.viafabricplus.api.settings.base.BooleanSetting; ++import com.viaversion.viafabricplus.api.settings.base.EnumSetting; ++import com.viaversion.viafabricplus.bedrock.settings.impl.BedrockSettings; ++import com.viaversion.viafabricplus.bedrock.settings.base.NativeUiScale; ++import net.raphimc.viabedrock.api.model.NativeContainerMessage; ++ ++import com.viaversion.viafabricplus.bedrock.ViaFabricPlusBedrock; ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiLayout; ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiPresentation; +import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiScene; +import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiRenderer; +import com.viaversion.viafabricplus.bedrock.injection.access.IBedrockGuiItemStyle; @@ -7781,6 +10470,8 @@ index 0000000..be5c741 +import java.util.concurrent.atomic.AtomicReference; +import net.minecraft.SharedConstants; +import net.minecraft.client.Minecraft; ++import net.minecraft.client.InputType; ++import net.minecraft.client.GameNarrator; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.Gui; +import net.minecraft.client.gui.GuiGraphicsExtractor; @@ -7816,11 +10507,120 @@ index 0000000..be5c741 +import net.raphimc.viabedrock.api.resourcepack.ui.ContainerUi; +import org.joml.Matrix3x2f; +import org.joml.Matrix3x2fStack; ++import org.apache.logging.log4j.Logger; +import org.junit.jupiter.api.Test; + +final class BedrockNativeContainerScreenTest { + private enum ExitOwner { CURRENT, REPLACED_SCREEN, RETIRED_MENU } + ++ private enum ReplacementFailure { DECODE, PREPARE, INSTALLED } ++ ++ @Test ++ void invalidReplacementRestoresOrdinaryStorageWithoutChangingMenuOrCursor() throws Exception { ++ replacementFailure(ReplacementFailure.DECODE); ++ } ++ ++ @Test ++ void unsupportedReplacementRestoresOrdinaryStorageWithoutChangingMenuOrCursor() throws Exception { ++ replacementFailure(ReplacementFailure.PREPARE); ++ } ++ ++ @Test ++ void replacementFailureRestoresTheOriginalOrdinaryScreenInsteadOfAnOlderNativeScreen() throws Exception { ++ replacementFailure(ReplacementFailure.INSTALLED); ++ } ++ ++ @SuppressWarnings("unchecked") ++ private static void replacementFailure(final ReplacementFailure failure) throws Exception { ++ SharedConstants.tryDetectVersion(); ++ Bootstrap.bootStrap(); ++ if (!Items.IRON_INGOT.builtInRegistryHolder().areComponentsBound()) ++ Items.IRON_INGOT.builtInRegistryHolder().bindComponents(DataComponentMap.EMPTY); ++ final var player = mock(LocalPlayer.class); ++ final var inventory = new Inventory(player, new EntityEquipment()); ++ final var menu = ChestMenu.threeRows(7, inventory); ++ final var carried = new ItemStack(Items.IRON_INGOT, 3); ++ menu.setCarried(carried); ++ menu.getSlot(0).set(new ItemStack(Items.IRON_INGOT, 1)); ++ player.containerMenu = menu; ++ when(player.getInventory()).thenReturn(inventory); ++ final var displayed = new AtomicReference(); ++ final var owner = mock(ClientPacketListener.class); ++ final var connection = mock(Connection.class); ++ when(owner.getConnection()).thenReturn(connection); ++ when(connection.isConnected()).thenReturn(true); ++ final var game = mock(Minecraft.class); ++ game.player = player; ++ game.gameMode = mock(MultiPlayerGameMode.class); ++ when(game.getConnection()).thenReturn(owner); ++ final var gui = mock(Gui.class); ++ when(gui.screen()).thenAnswer(_ -> displayed.get()); ++ final var guiField = Minecraft.class.getField("gui"); ++ guiField.setAccessible(true); ++ guiField.set(game, gui); ++ final var fontField = Minecraft.class.getField("font"); ++ fontField.setAccessible(true); ++ fontField.set(game, mock(Font.class)); ++ final var window = mock(Window.class); ++ when(game.getWindow()).thenReturn(window); ++ when(window.getWidth()).thenReturn(640); ++ when(window.getHeight()).thenReturn(320); ++ when(window.getGuiScale()).thenReturn(1); ++ when(window.getGuiScaledWidth()).thenReturn(640); ++ when(window.getGuiScaledHeight()).thenReturn(320); ++ doAnswer(call -> { ((Runnable) call.getArgument(0)).run(); return null; }) ++ .when(game).execute(any(Runnable.class)); ++ doAnswer(call -> { displayed.set(call.getArgument(0)); return null; }) ++ .when(game).setScreenAndShow(any()); ++ final var implementation = mock(ViaFabricPlusBedrock.class); ++ final var settings = mock(BedrockSettings.class); ++ final var enabled = mock(BooleanSetting.class); ++ final EnumSetting scale = mock(EnumSetting.class); ++ when(implementation.settings()).thenReturn(settings); ++ when(implementation.logger()).thenReturn(mock(Logger.class)); ++ when(settings.nativeInventoryScreens()).thenReturn(enabled); ++ when(enabled.isActive()).thenReturn(true); ++ when(settings.nativeUiScale()).thenReturn(scale); ++ when(scale.value()).thenReturn(NativeUiScale.DEFAULT); ++ final var field = BedrockNativeContainers.class.getDeclaredField("PRESENTATIONS"); ++ field.setAccessible(true); ++ final var controller = (NativeContainerPresentationController) field.get(null); ++ try (final var singleton = mockStatic(Minecraft.class); ++ final var entrypoint = mockStatic(ViaFabricPlusBedrock.class)) { ++ singleton.when(Minecraft::getInstance).thenReturn(game); ++ entrypoint.when(ViaFabricPlusBedrock::impl).thenReturn(implementation); ++ final var ordinary = new ContainerScreen(menu, inventory, Component.empty()); ++ displayed.set(ordinary); ++ controller.begin(owner); ++ final var title = new ContainerUi.Title("", false); ++ final String valid = program().exportProgram(ContainerUi.Kind.CHEST_3_ROWS, title).toString(); ++ BedrockNativeContainers.accept(owner, new NativeContainerMessage(7, 1, ContainerUi.Kind.CHEST_3_ROWS, "", valid, List.of()), 1); ++ final var first = assertInstanceOf(BedrockNativeContainerScreen.class, displayed.get()); ++ final String replacement = switch (failure) { ++ case DECODE -> "{"; ++ case PREPARE -> program(false).exportProgram(ContainerUi.Kind.CHEST_3_ROWS, title).toString(); ++ case INSTALLED -> valid; ++ }; ++ BedrockNativeContainers.accept(owner, new NativeContainerMessage(7, 2, ContainerUi.Kind.CHEST_3_ROWS, "", replacement, List.of()), 1); ++ if (failure == ReplacementFailure.INSTALLED) { ++ final var second = assertInstanceOf(BedrockNativeContainerScreen.class, displayed.get()); ++ assertNotSame(first, second); ++ second.fallback(new IllegalArgumentException("Unsupported replacement image")); ++ second.discard(); ++ } ++ assertSame(ordinary, displayed.get()); ++ assertSame(menu, player.containerMenu); ++ assertSame(carried, menu.getCarried()); ++ assertEquals(3, menu.getCarried().getCount()); ++ assertEquals(1, menu.getSlot(0).getItem().getCount()); ++ verify(player, never()).closeContainer(); ++ verifyNoInteractions(game.gameMode); ++ first.discard(); ++ } finally { ++ controller.clear(); ++ } ++ } ++ + @Test + void bundleDrawingExtractsThePhysicalItemAndCountWithoutUsingJavaMenuOrQuickcraftState() throws Exception { + SharedConstants.tryDetectVersion(); @@ -8395,6 +11195,68 @@ index 0000000..be5c741 + } + + @Test ++ void resizingARetiredPresentationRestoresOnlyItsStillOpenMenuAndCursor() throws Exception { ++ SharedConstants.tryDetectVersion(); ++ Bootstrap.bootStrap(); ++ if (!Items.IRON_INGOT.builtInRegistryHolder().areComponentsBound()) Items.IRON_INGOT.builtInRegistryHolder().bindComponents(DataComponentMap.EMPTY); ++ for (final boolean sameMenu : List.of(true, false)) { ++ final var player = mock(LocalPlayer.class); ++ final var inventory = new Inventory(player, new EntityEquipment()); ++ final var menu = ChestMenu.threeRows(7, inventory); ++ final var carried = new ItemStack(Items.IRON_INGOT, 3); ++ menu.setCarried(carried); ++ player.containerMenu = menu; ++ final var ordinary = mock(AbstractContainerScreen.class); ++ when(ordinary.getMenu()).thenReturn(menu); ++ final var game = mock(Minecraft.class); ++ when(game.getLastInputType()).thenReturn(InputType.MOUSE); ++ when(game.getNarrator()).thenReturn(mock(GameNarrator.class)); ++ game.player = player; ++ game.gameMode = mock(MultiPlayerGameMode.class); ++ final var gui = mock(Gui.class); ++ final var guiField = Minecraft.class.getField("gui"); ++ guiField.setAccessible(true); ++ guiField.set(game, gui); ++ final var owner = mock(ClientPacketListener.class); ++ final var connection = mock(Connection.class); ++ when(owner.getConnection()).thenReturn(connection); ++ when(connection.isConnected()).thenReturn(true); ++ when(game.getConnection()).thenReturn(owner); ++ final var current = new AtomicBoolean(true); ++ final var implementation = mock(ViaFabricPlusBedrock.class); ++ when(implementation.logger()).thenReturn(mock(Logger.class)); ++ try (final var singleton = mockStatic(Minecraft.class); ++ final var entrypoint = mockStatic(ViaFabricPlusBedrock.class)) { ++ singleton.when(Minecraft::getInstance).thenReturn(game); ++ entrypoint.when(ViaFabricPlusBedrock::impl).thenReturn(implementation); ++ final var presentation = new NativeContainerPresentation(program(), ContainerUi.Kind.CHEST_3_ROWS, List.of()); ++ final var screen = new BedrockNativeContainerScreen(owner, ordinary, inventory, Component.empty(), ++ presentation, false, 1, () -> 0, current::get); ++ final var minecraft = Screen.class.getDeclaredField("minecraft"); ++ minecraft.setAccessible(true); ++ minecraft.set(screen, game); ++ final var font = Screen.class.getDeclaredField("font"); ++ font.setAccessible(true); ++ font.set(screen, mock(Font.class)); ++ when(gui.screen()).thenReturn(screen); ++ try { ++ screen.init(640, 320); ++ current.set(false); ++ if (!sameMenu) player.containerMenu = ChestMenu.threeRows(8, inventory); ++ assertDoesNotThrow(() -> screen.resize(512, 300)); ++ if (sameMenu) verify(game).setScreenAndShow(ordinary); ++ else verify(game, never()).setScreenAndShow(any()); ++ assertSame(carried, menu.getCarried()); ++ assertEquals(3, menu.getCarried().getCount()); ++ verify(player, never()).closeContainer(); ++ } finally { ++ screen.discard(); ++ } ++ } ++ } ++ } ++ ++ @Test + void visibleRequiredRendererGateRejectsUnknownButKeepsHiddenIdentity() { + final var properties = new JsonObject(); + properties.addProperty("renderer", "unsupported_renderer"); @@ -8804,10 +11666,10 @@ index 0000000..5a97c2d +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerDrawsTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerDrawsTest.java new file mode 100644 -index 0000000..f0c9c26 +index 0000000..17ef45d --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerDrawsTest.java -@@ -0,0 +1,70 @@ +@@ -0,0 +1,113 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -8826,6 +11688,49 @@ index 0000000..f0c9c26 + +final class NativeContainerDrawsTest { + @Test ++ void keyboardFocusKeepsItsVisualStateWhenThePointerIsOutsideTheControls() { ++ final var scene = NativeUiScene.parse(""" ++ {"format":1,"root":{"name":"host","type":"panel","size":[100,100], ++ "button_mappings":[{"from_button_id":"button.menu_right","to_button_id":"button.menu_right", ++ "mapping_type":"global","scope":"view"}],"children":[ ++ {"name":"first","type":"toggle","toggle_state":true,"focus_enabled":true,"anchor_from":"top_left","anchor_to":"top_left", ++ "size":[40,40],"checked_control":"first_default","checked_hover_control":"first_focused", ++ "children":[{"name":"first_default","type":"image"},{"name":"first_focused","type":"image"}]}, ++ {"name":"second","type":"toggle","toggle_state":true,"focus_enabled":true,"anchor_from":"top_left","anchor_to":"top_left", ++ "offset":[50,0],"size":[40,40],"checked_control":"second_default","checked_hover_control":"second_focused", ++ "children":[{"name":"second_default","type":"image"},{"name":"second_focused","type":"image"}]}]}} ++ """, Set.of()).root(); ++ final var root = layout(scene); ++ final var lifecycle = new NativeUiAnimationLifecycle(scene); ++ final var input = new NativeContainerInput(_ -> { }); ++ input.prepare(scene); ++ final var draws = new NativeContainerDraws(); ++ final var initial = draws.update(root, 60, 20, lifecycle.animations(), input, 0); ++ input.layout(root, initial, 100, 100); ++ input.button("button.menu_right", true, 1, 0, true); ++ assertSame(scene.children().getFirst(), input.focused()); ++ final var focused = draws.update(root, 60, 20, lifecycle.animations(), input, 0); ++ assertTrue(focused.stream().anyMatch(draw -> draw.box().node().name().equals("first_focused"))); ++ assertFalse(focused.stream().anyMatch(draw -> draw.box().node().name().equals("first_default"))); ++ assertFalse(focused.stream().anyMatch(draw -> draw.box().node().name().equals("second_focused"))); ++ final var moved = draws.update(root, 91, 91, lifecycle.animations(), input, 1); ++ assertTrue(moved.stream().anyMatch(draw -> draw.box().node().name().equals("first_focused"))); ++ input.layout(root, moved, 100, 100); ++ input.pointer(91, 91, 1, 2, true); ++ assertSame(scene.children().getFirst(), input.focused()); ++ final var pointer = draws.update(root, 91, 91, lifecycle.animations(), input, 2); ++ assertTrue(pointer.stream().anyMatch(draw -> draw.box().node().name().equals("first_default"))); ++ assertFalse(pointer.stream().anyMatch(draw -> draw.box().node().name().equals("first_focused"))); ++ input.button("button.menu_right", false, 1, 2, true); ++ input.button("button.menu_right", true, 1, 3, true); ++ assertSame(scene.children().getLast(), input.focused()); ++ final var next = draws.update(root, 91, 91, lifecycle.animations(), input, 3); ++ assertTrue(next.stream().anyMatch(draw -> draw.box().node().name().equals("second_focused"))); ++ assertTrue(next.stream().anyMatch(draw -> draw.box().node().name().equals("first_default"))); ++ assertFalse(next.stream().anyMatch(draw -> draw.box().node().name().equals("first_focused"))); ++ } ++ ++ @Test + void aStationaryPointerDoesNotFreezeFadesOrEventResets() { + final var scene = NativeUiScene.parse(""" + {"format":1,"root":{"name":"backdrop","type":"image","alpha":0.4,"size":[100,100], @@ -8880,10 +11785,10 @@ index 0000000..f0c9c26 +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerGeometryTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerGeometryTest.java new file mode 100644 -index 0000000..71d454b +index 0000000..4554d9c --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerGeometryTest.java -@@ -0,0 +1,407 @@ +@@ -0,0 +1,435 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -8911,6 +11816,34 @@ index 0000000..71d454b + +final class NativeContainerGeometryTest { + @Test ++ void embeddedSearchClearRequiresAnEditorOwnerAndBlocksCoveredSlots() { ++ final var clearProperties = JsonParser.parseString(""" ++ {"button_mappings":[{"from_button_id":"button.menu_select", ++ "to_button_id":"button.text_edit_box_clear","mapping_type":"pressed"}]} ++ """).getAsJsonObject(); ++ final var clear = new NativeUiScene.Node("clear_text_button", "button", clearProperties, List.of(), Integer.MIN_VALUE); ++ final var editorProperties = JsonParser.parseString(""" ++ {"text_box_name":"#text_box_name","property_bag":{"#property_field":"#item_name"}, ++ "button_mappings":[{"from_button_id":"button.menu_select","to_button_id":"button.search_bar_selected", ++ "mapping_type":"pressed","handle_select":true}]} ++ """).getAsJsonObject(); ++ final var editor = new NativeUiScene.Node("editor", "edit_box", editorProperties, List.of(clear), Integer.MIN_VALUE); ++ final var bounds = new NativeUiLayout.Rect(0, 0, 18, 18); ++ final var size = new NativeUiLayout.Size(18, 18); ++ final var clearDraw = new NativeUiPresentation.Draw(new NativeUiLayout.Box(clear, bounds, bounds, size, bounds, 2, List.of()), 1, 1, true); ++ final var editorDraw = new NativeUiPresentation.Draw(new NativeUiLayout.Box(editor, bounds, bounds, size, bounds, 1, List.of()), 1, 1, true); ++ final var slot = draw(0, true, 1, 0, 0, 18, bounds, 0); ++ assertThrows(IllegalArgumentException.class, () -> new NativeContainerGeometry(List.of(slot, clearDraw), 1, Map.of(), true)); ++ final var geometry = new NativeContainerGeometry(List.of(slot, editorDraw, clearDraw), 1, Map.of(), true); ++ assertTrue(geometry.clearsSearch(clear)); ++ assertTrue(geometry.clearsSearch(1, 1, "button.menu_select")); ++ assertFalse(geometry.clearsSearch(1, 1, "button.menu_secondary_select")); ++ assertEquals(-1, geometry.hovered(1, 1)); ++ assertFalse(new NativeContainerGeometry(List.of(editorDraw, clearDraw, draw(0, true, 1, 0, 0, 18, bounds, 3)), ++ 1, Map.of(), true).clearsSearch(1, 1, "button.menu_select")); ++ } ++ ++ @Test + void retainedHitRegionsFollowVisibilityEnablementAndLayoutChanges() { + final var slot = draw(0, true, 1, 0, 0, 18, new NativeUiLayout.Rect(0, 0, 100, 100), 1); + var geometry = new NativeContainerGeometry(List.of(slot), 1); @@ -10109,10 +13042,10 @@ index 0000000..adee31b +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingActionsTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingActionsTest.java new file mode 100644 -index 0000000..5380dd7 +index 0000000..b29689d --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingActionsTest.java -@@ -0,0 +1,346 @@ +@@ -0,0 +1,544 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -10128,8 +13061,18 @@ index 0000000..5380dd7 +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; +import net.raphimc.viabedrock.api.model.NativeCookingAction; ++import net.raphimc.viabedrock.api.model.NativeCookingBundleAction; ++import net.raphimc.viabedrock.api.model.NativeCookingBundleResult; ++import net.raphimc.viabedrock.api.model.NativeCookingControl; ++import net.raphimc.viabedrock.api.model.NativeCookingQuery; ++import net.raphimc.viabedrock.api.model.NativeCookingQueryResult; ++import net.raphimc.viabedrock.api.model.NativeBundleAction; +import net.raphimc.viabedrock.api.model.NativeCookingActionResult; +import net.raphimc.viabedrock.api.model.NativeCookingMessage; ++import net.raphimc.viabedrock.api.model.NativeCookingHoldAction; ++import net.raphimc.viabedrock.api.model.NativeCookingHoldResult; ++import net.raphimc.viabedrock.api.model.NativeCookingSourceAction; ++import net.raphimc.viabedrock.api.model.NativeCookingSourceResult; +import net.raphimc.viabedrock.api.resourcepack.ui.CookingUi; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; @@ -10139,6 +13082,99 @@ index 0000000..5380dd7 + private static final CookingUi.RecipeActionScope ALL_COMMANDS = new CookingUi.RecipeActionScope(Map.of(7, + Set.of(NativeCookingAction.Mode.PRIMARY.button(), NativeCookingAction.Mode.SECONDARY.button(), NativeCookingAction.Mode.TERTIARY.button()))); + ++ @Test ++ void sourceDrawingRetainsItsOriginalFrameAndBlocksOtherCommandsThroughCompletion() { ++ final var actions = new NativeCookingActions(first, () -> 0); ++ final var sent = new ArrayList(); ++ assertTrue(actions.source(0, 4, 4, NativeCookingSourceAction.Kind.DRAW, sent::add)); ++ final var source = sent.getFirst(); ++ assertEquals(NativeCookingControl.forMessage(first, NativeCookingControl.Mode.ACTIVATE), source.source()); ++ assertEquals(4, source.quantity()); ++ assertTrue(actions.sourcePending()); ++ assertFalse(actions.sourceAdmitted()); ++ assertFalse(actions.query("food", _ -> fail("The source command owns input"))); ++ assertFalse(actions.hold(0, NativeCookingHoldAction.Phase.START, 0, _ -> fail("The source command owns input"))); ++ actions.update(update(2)); ++ assertTrue(actions.result(new NativeCookingSourceResult(source, true, false))); ++ assertTrue(actions.sourceAdmitted()); ++ assertFalse(actions.query("food", _ -> fail("Drawing retains its input owner"))); ++ assertFalse(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 0, _ -> fail("Drawing retains its input owner"))); ++ actions.finishSourceDrawing(); ++ final var queries = new ArrayList(); ++ assertTrue(actions.query("food", queries::add)); ++ assertEquals(2, queries.getFirst().requestSequence()); ++ assertEquals(2, queries.getFirst().source().updateSequence()); ++ assertFalse(actions.result(new NativeCookingSourceResult(source, false, false))); ++ assertTrue(actions.queryPending()); ++ } ++ ++ @Test ++ void aTapWaitsForReplacementAuthorityUnlessItArrivedBeforeTheReply() { ++ for (final boolean earlyFrame : new boolean[]{false, true}) { ++ final var actions = new NativeCookingActions(first, () -> 0); ++ final var sent = new ArrayList(); ++ assertTrue(actions.source(0, 4, 1, NativeCookingSourceAction.Kind.TAP, sent::add)); ++ if (earlyFrame) actions.update(update(2)); ++ assertTrue(actions.result(new NativeCookingSourceResult(sent.getFirst(), true, false))); ++ assertFalse(actions.sourceDrawing()); ++ if (!earlyFrame) { ++ assertFalse(actions.idle()); ++ actions.update(update(2)); ++ } ++ assertTrue(actions.idle()); ++ } ++ } ++ ++ @Test ++ void reentrantSourceRepliesCannotSendAnotherCommandBeforeDispatchReturns() { ++ final var actions = new NativeCookingActions(first, () -> 0); ++ assertTrue(actions.source(0, 4, 9, NativeCookingSourceAction.Kind.DRAW, action -> { ++ assertTrue(actions.result(new NativeCookingSourceResult(action, true, false))); ++ assertFalse(actions.source(0, 5, 9, NativeCookingSourceAction.Kind.DRAW, _ -> fail("No nested send"))); ++ })); ++ assertTrue(actions.sourceAdmitted()); ++ } ++ ++ @Test ++ void aReplyWithAnotherSnapshotCannotReleaseThePendingSourceCommand() { ++ final var actions = new NativeCookingActions(first, () -> 0); ++ final var sent = new ArrayList(); ++ assertTrue(actions.source(0, 4, 9, NativeCookingSourceAction.Kind.DRAW, sent::add)); ++ final var forged = new NativeCookingSourceAction(NativeCookingControl.forMessage(update(2), NativeCookingControl.Mode.ACTIVATE), ++ 0, 4, 9, NativeCookingSourceAction.Kind.DRAW, sent.getFirst().requestSequence()); ++ assertFalse(actions.result(new NativeCookingSourceResult(forged, true, false))); ++ assertTrue(actions.sourcePending()); ++ assertFalse(actions.sourceAdmitted()); ++ } ++ ++ @Test ++ void anAmbiguousSourceSendRetainsExactlyOneTerminalDrawingRelease() { ++ final var actions = new NativeCookingActions(first, () -> 0); ++ final var sent = new ArrayList(); ++ assertThrows(IllegalStateException.class, () -> actions.source(0, 4, 9, NativeCookingSourceAction.Kind.DRAW, action -> { ++ sent.add(action); ++ throw new IllegalStateException("Transport failed after dispatch"); ++ })); ++ assertTrue(actions.cancel(_ -> fail("No recipe release"), _ -> fail("No held release"), sent::add)); ++ assertFalse(actions.cancel(_ -> fail("No recipe release"), _ -> fail("No held release"), sent::add)); ++ assertEquals(2, sent.size()); ++ assertTrue(sent.getLast().continues(sent.getFirst())); ++ assertFalse(actions.idle()); ++ } ++ ++ @Test ++ void aTimedOutSourceReplyCannotReviveTheRetiredPresentation() { ++ final var now = new AtomicLong(); ++ final var actions = new NativeCookingActions(first, now::get); ++ final var sent = new ArrayList(); ++ assertTrue(actions.source(0, 4, 4, NativeCookingSourceAction.Kind.TAP, sent::add)); ++ now.set(10_000_000_000L); ++ assertTrue(actions.expired()); ++ assertFalse(actions.result(new NativeCookingSourceResult(sent.getFirst(), true, true))); ++ assertThrows(IllegalArgumentException.class, () -> actions.update(update(2))); ++ assertFalse(actions.idle()); ++ } ++ + @BeforeAll + static void bootstrap() throws Exception { + NativeCookingPresentationTest.bootstrap(); @@ -10146,6 +13182,67 @@ index 0000000..5380dd7 + } + + @Test ++ void bundleRepliesWaitForFreshAuthorityAndShareSearchAndHoldRequestOrder() { ++ for (final boolean snapshotFirst : new boolean[]{false, true}) { ++ final var actions = new NativeCookingActions(first, () -> 0); ++ final var bundles = new ArrayList(); ++ final var queries = new ArrayList(); ++ final var holds = new ArrayList(); ++ actions.presented(first, ALL_COMMANDS); ++ assertTrue(actions.bundle("inventory_items", 0, NativeBundleAction.Kind.SECONDARY, 2, 3, bundles::add)); ++ final var action = bundles.getFirst(); ++ assertFalse(actions.query("ore", queries::add)); ++ assertFalse(actions.hold(3, NativeCookingHoldAction.Phase.START, 0, holds::add)); ++ assertFalse(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 0, ignored -> fail("A bundle owns the input"))); ++ final var source = action.source(); ++ final var wrong = new NativeCookingBundleAction(new NativeCookingControl(source.menuId(), source.sequence(), ++ source.updateSequence(), source.menuRevision(), source.inventoryRevision(), source.cursorRevision() + 1, ++ NativeCookingControl.Mode.ACTIVATE), action.collection(), action.index(), action.kind(), ++ action.tooltipMode(), action.selectedCell(), action.requestSequence()); ++ assertFalse(actions.result(NativeCookingBundleResult.reply(wrong, true, false))); ++ if (snapshotFirst) actions.update(update(2)); ++ assertTrue(actions.result(NativeCookingBundleResult.reply(action, true, false))); ++ if (!snapshotFirst) { ++ assertFalse(actions.idle(), "An acknowledgment alone cannot renew item authority"); ++ actions.update(update(2)); ++ } ++ assertTrue(actions.idle()); ++ assertFalse(actions.result(NativeCookingBundleResult.reply(action, true, false))); ++ assertTrue(actions.query("ore", queries::add)); ++ assertEquals(2, queries.getFirst().requestSequence()); ++ assertTrue(actions.result(new NativeCookingQueryResult(queries.getFirst(), true))); ++ actions.update(update(3)); ++ assertTrue(actions.hold(3, NativeCookingHoldAction.Phase.START, 0, holds::add)); ++ assertEquals(3, holds.getFirst().requestSequence()); ++ } ++ } ++ ++ @Test ++ void nonmutatingBundleResultsKeepTheCurrentFrameUsable() { ++ final var actions = new NativeCookingActions(first, () -> 0); ++ final var sent = new ArrayList(); ++ assertTrue(actions.bundle("hotbar_items", 0, NativeBundleAction.Kind.SECONDARY, 2, -1, sent::add)); ++ assertTrue(actions.result(NativeCookingBundleResult.reply(sent.getFirst(), true, true))); ++ assertTrue(actions.idle()); ++ assertTrue(actions.query("ore", ignored -> {})); ++ } ++ ++ @Test ++ void missingBundlePublicationsTimeOutAndRetirementRejectsLateResults() { ++ final var clock = new AtomicLong(); ++ final var actions = new NativeCookingActions(first, clock::get); ++ final var sent = new ArrayList(); ++ assertTrue(actions.bundle("hotbar_items", 0, NativeBundleAction.Kind.PRIMARY, 0, -1, sent::add)); ++ assertTrue(actions.result(NativeCookingBundleResult.reply(sent.getFirst(), true, false))); ++ clock.set(10_000_000_000L); ++ assertTrue(actions.expired()); ++ assertFalse(actions.bundle("hotbar_items", 0, NativeBundleAction.Kind.PRIMARY, 0, -1, sent::add)); ++ actions.retire(); ++ assertFalse(actions.result(NativeCookingBundleResult.reply(sent.getFirst(), true, false))); ++ assertEquals(1, sent.size()); ++ } ++ ++ @Test + void followUpPhasesRetainOriginalAuthorityAcrossNewFramesAndOutOfOrderReplies() { + final var actions = new NativeCookingActions(first, () -> 0); + final var sent = new ArrayList(); @@ -10454,17 +13551,215 @@ index 0000000..5380dd7 + assertFalse(actions.cancel(sent::add)); + } + ++ @Test ++ void heldTransfersShareRequestOrderingAndRetireWithTheirOriginalOpening() { ++ final var actions = new NativeCookingActions(first, () -> 0); ++ final var sent = new ArrayList(); ++ assertTrue(actions.hold(30, NativeCookingHoldAction.Phase.START, 0, sent::add)); ++ final var start = sent.getFirst(); ++ actions.update(update(2)); ++ assertFalse(actions.query("iron", _ -> fail("A held source owns input"))); ++ assertTrue(actions.hold(30, NativeCookingHoldAction.Phase.COMPLETE, 3, sent::add)); ++ assertEquals(start.source(), sent.getLast().source()); ++ assertFalse(actions.hold(30, NativeCookingHoldAction.Phase.COMPLETE, 3, sent::add)); ++ assertTrue(actions.cancel(_ -> fail("No recipe owns release"), sent::add)); ++ assertEquals(NativeCookingHoldAction.Phase.RETIRE, sent.getLast().phase()); ++ assertEquals(start.source(), sent.getLast().source()); ++ assertEquals(List.of(1L, 2L, 3L), sent.stream().map(NativeCookingHoldAction::requestSequence).toList()); ++ assertFalse(actions.result(new NativeCookingHoldResult(start, true))); ++ assertFalse(actions.idle()); ++ } ++ ++ @Test ++ void anAmbiguousHeldSendRetainsOneTerminalRetirementWithoutRetryingTheTransfer() { ++ final var actions = new NativeCookingActions(first, () -> 0); ++ final var sent = new ArrayList(); ++ assertThrows(IllegalStateException.class, () -> actions.hold(30, NativeCookingHoldAction.Phase.START, 0, action -> { ++ sent.add(action); ++ throw new IllegalStateException("Transport failed after dispatch"); ++ })); ++ assertTrue(actions.cancel(_ -> fail("No recipe owns release"), sent::add)); ++ assertFalse(actions.cancel(_ -> fail("No recipe owns release"), sent::add)); ++ assertEquals(List.of(NativeCookingHoldAction.Phase.START, NativeCookingHoldAction.Phase.RETIRE), ++ sent.stream().map(NativeCookingHoldAction::phase).toList()); ++ assertEquals(sent.getFirst().source(), sent.getLast().source()); ++ } ++ + private static NativeCookingMessage update(final long update) { + return new NativeCookingMessage(first.menuId(), first.sequence(), update, update + 10, update + 20, update + 30, + first.program(), first.snapshot(), first.recipeItems()); + } +} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingHoldTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingHoldTest.java +new file mode 100644 +index 0000000..d0a74ab +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingHoldTest.java +@@ -0,0 +1,158 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.container; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++import java.util.ArrayList; ++import java.util.Map; ++import java.util.Set; ++import java.util.concurrent.atomic.AtomicLong; ++import java.util.stream.Collectors; ++import net.minecraft.world.item.ItemStack; ++import net.minecraft.core.component.DataComponentMap; ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiHold; ++import net.minecraft.world.item.Items; ++import net.minecraft.world.item.Item; ++import net.raphimc.viabedrock.api.model.NativeCookingHoldAction; ++import net.raphimc.viabedrock.api.model.NativeCookingHoldResult; ++import net.raphimc.viabedrock.api.resourcepack.ui.CookingUi; ++import org.junit.jupiter.api.BeforeAll; ++import org.junit.jupiter.api.Test; ++import org.junit.jupiter.params.ParameterizedTest; ++import org.junit.jupiter.params.provider.ValueSource; ++ ++class NativeCookingHoldTest { ++ @BeforeAll ++ static void bootstrap() throws Exception { ++ NativeCookingPresentationTest.bootstrap(); ++ for (final var item : new Item[]{Items.COBBLESTONE, Items.DIAMOND}) ++ if (!item.builtInRegistryHolder().areComponentsBound()) item.builtInRegistryHolder().bindComponents(DataComponentMap.EMPTY); ++ } ++ ++ @ParameterizedTest ++ @ValueSource(booleans = {false, true}) ++ void completionPreservesTheLastDisplayedQuantityAndItsSourceAcrossReplies(final boolean reset) { ++ final var f = new Fixture(); ++ f.pickup(); ++ f.update(2); ++ f.frame(300_000_000L, f.item); ++ assertTrue(f.sent.isEmpty()); ++ f.frame(301_000_000L, f.item); ++ assertEquals(new CookingUi.QuantitySelection(9, 1), f.hold.quantity()); ++ final var start = f.sent.getFirst(); ++ assertEquals(NativeCookingHoldAction.Phase.START, start.phase()); ++ assertTrue(f.presentation.result(new NativeCookingHoldResult(start, true))); ++ f.update(3); ++ f.frame(601_000_000L, ItemStack.EMPTY); ++ assertEquals(new CookingUi.QuantitySelection(9, 3), f.hold.quantity()); ++ if (reset) f.hold.reset(); ++ else f.hold.pointer(100, 100, false); ++ f.hold.flush(f.presentation, ItemStack.EMPTY, f.sent::add); ++ assertEquals(2, f.sent.size()); ++ final var complete = f.sent.getLast(); ++ assertEquals(NativeCookingHoldAction.Phase.COMPLETE, complete.phase()); ++ assertEquals(3, complete.quantity()); ++ assertEquals(start.source(), complete.source()); ++ assertEquals(6, f.hold.preview(30, ItemStack.EMPTY).getCount()); ++ assertEquals(3, f.hold.cursor(ItemStack.EMPTY).getCount()); ++ assertTrue(f.hold.release(0, true)); ++ f.hold.flush(f.presentation, ItemStack.EMPTY, f.sent::add); ++ assertEquals(2, f.sent.size()); ++ assertTrue(f.presentation.result(new NativeCookingHoldResult(complete, true))); ++ f.update(4); ++ f.frame(901_000_000L, f.item.copyWithCount(3)); ++ assertFalse(f.hold.busy()); ++ assertFalse(f.hold.previewsCursor()); ++ } ++ ++ @Test ++ void aLatePickupFrameQueuesBothHoldPhasesWithoutRestartingTheQuantityClock() { ++ final var f = new Fixture(); ++ f.pickup(); ++ f.frame(301_000_000L, f.item); ++ f.frame(601_000_000L, f.item); ++ assertTrue(f.sent.isEmpty()); ++ f.clock.set(901_000_000L); ++ assertTrue(f.hold.release(0, true)); ++ f.update(2); ++ f.hold.flush(f.presentation, f.item, f.sent::add); ++ assertEquals(2, f.sent.size()); ++ assertEquals(3, f.sent.getLast().quantity()); ++ assertEquals(f.sent.getFirst().source(), f.sent.getLast().source()); ++ assertTrue(f.presentation.result(new NativeCookingHoldResult(f.sent.getFirst(), false))); ++ assertTrue(f.presentation.result(new NativeCookingHoldResult(f.sent.getLast(), false))); ++ f.frame(902_000_000L, f.item); ++ assertFalse(f.hold.busy()); ++ assertSame(f.item, f.hold.cursor(f.item)); ++ } ++ ++ @Test ++ void shortPickupAndWithdrawnCapabilityNeverStartAReturnRequest() { ++ final var f = new Fixture(); ++ f.pickup(); ++ f.frame(100_000_000L, f.item); ++ assertTrue(f.hold.release(0, true)); ++ assertFalse(f.hold.busy()); ++ assertTrue(f.sent.isEmpty()); ++ f.pickup(); ++ f.presentation.holdCapability(false); ++ f.frame(500_000_000L, f.item); ++ assertFalse(f.hold.busy()); ++ assertTrue(f.sent.isEmpty()); ++ } ++ ++ @Test ++ void aCorrectedInitialPickupCannotHideTheAuthoritativeCursorBehindAPreview() { ++ final var f = new Fixture(); ++ f.pickup(); ++ f.frame(301_000_000L, f.item); ++ f.update(2); ++ final var corrected = new ItemStack(Items.DIAMOND, 2); ++ f.frame(302_000_000L, corrected); ++ assertFalse(f.hold.busy()); ++ assertSame(corrected, f.hold.cursor(corrected)); ++ assertTrue(f.sent.isEmpty()); ++ } ++ ++ @Test ++ void nativeCountdownUsesFrameTimeStrictExpiryAndAxisDisplacement() { ++ final var view = new NativeUiHold(0); ++ view.result(2, 10, 20); ++ assertNull(view.frame(300_000_000L, 10, 20)); ++ assertFalse(view.frame(301_000_000L, 15, 15).moved()); ++ assertTrue(view.frame(302_000_000L, 15.00001F, 20).moved()); ++ view.result(2 | 4, 10, 20); ++ assertNull(view.frame(700_000_000L, 10, 20)); ++ view.result(2 | 8, 10, 20); ++ assertNull(view.frame(1_100_000_000L, 10, 20)); ++ } ++ ++ private static final class Fixture { ++ final AtomicLong clock = new AtomicLong(); ++ final ItemStack item = new ItemStack(Items.COBBLESTONE, 9); ++ final ArrayList sent = new ArrayList<>(); ++ final NativeCookingHold hold = new NativeCookingHold(0); ++ final NativeCookingPresentation presentation; ++ Fixture() { ++ final var first = NativeCookingPresentationTest.message(CookingUi.Family.FURNACE, 1); ++ this.presentation = new NativeCookingPresentation(first, recipes(first.recipeItems().keySet()), this.clock::get); ++ this.presentation.holdCapability(true); ++ } ++ private static Map recipes(final Set ids) { ++ return ids.stream().collect(Collectors.toMap(id -> id, _ -> new ItemStack(Items.IRON_INGOT))); ++ } ++ void pickup() { this.hold.pickup(0, 30, this.item, this.presentation.message().updateSequence(), this.clock.get(), 10, 20); } ++ void frame(final long now, final ItemStack cursor) { ++ this.clock.set(now); ++ this.hold.frame(now, true, cursor, this.presentation, this.sent::add); ++ } ++ void update(final long update) { ++ final var next = NativeCookingPresentationTest.message(CookingUi.Family.FURNACE, update); ++ assertTrue(this.presentation.update(next, recipes(next.recipeItems().keySet()))); ++ } ++ } ++} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingPresentationControllerTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingPresentationControllerTest.java new file mode 100644 -index 0000000..906aebf +index 0000000..ea3c7d8 --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingPresentationControllerTest.java -@@ -0,0 +1,216 @@ +@@ -0,0 +1,260 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -10480,6 +13775,9 @@ index 0000000..906aebf +import net.minecraft.world.item.Items; +import net.raphimc.viabedrock.api.model.NativeCookingMessage; +import net.raphimc.viabedrock.api.model.NativeCookingProgress; ++import net.raphimc.viabedrock.api.model.NativeCookingQuery; ++import net.raphimc.viabedrock.api.model.NativeCookingQueryResult; ++import com.viaversion.viaversion.libs.gson.JsonParser; +import net.raphimc.viabedrock.api.model.NativeCookingAction; +import net.raphimc.viabedrock.api.model.NativeCookingActionResult; +import net.raphimc.viabedrock.api.resourcepack.ui.CookingUi; @@ -10494,6 +13792,47 @@ index 0000000..906aebf + static void bootstrap() throws Exception { NativeCookingPresentationTest.bootstrap(); } + + @Test ++ void closingBeforeReplyRetainsTheLatestEditOrClearForTheNextOpeningOnly() { ++ for (final String latest : new String[]{"stone", ""}) { ++ final var controller = new NativeCookingPresentationController(); ++ final var owner = new Object(); ++ final var menu = new Object(); ++ final var base = message(CookingUi.Family.FURNACE, 1); ++ final var snapshot = JsonParser.parseString(base.snapshot()).getAsJsonObject(); ++ snapshot.getAsJsonObject("book").addProperty("searchText", "iron"); ++ final var first = new NativeCookingMessage(base.menuId(), base.sequence(), base.updateSequence(), base.menuRevision(), ++ base.inventoryRevision(), base.cursorRevision(), base.program(), snapshot.toString(), base.recipeItems()); ++ controller.begin(owner); ++ assertTrue(controller.accept(owner, menu, 5, first, _ -> new ItemStack(Items.IRON_INGOT))); ++ final var old = controller.current(owner, menu, 5); ++ old.searchCapability(true); ++ assertTrue(controller.query(owner, menu, 5, "iron")); ++ final var sent = new ArrayList(); ++ assertTrue(old.flushQuery(sent::add)); ++ assertTrue(controller.query(owner, menu, 5, latest)); ++ controller.tick(owner, new Object(), 5); ++ assertTrue(old.expired()); ++ final var nextMenu = new Object(); ++ assertTrue(controller.accept(owner, nextMenu, 5, sequence(first, 4), _ -> new ItemStack(Items.IRON_INGOT))); ++ final var reopened = controller.current(owner, nextMenu, 5); ++ reopened.searchCapability(true); ++ assertEquals(latest, reopened.query()); ++ assertTrue(reopened.flushQuery(sent::add)); ++ assertEquals(latest, sent.getLast().text()); ++ assertEquals(4, sent.getLast().source().sequence()); ++ assertEquals(1, sent.getLast().requestSequence()); ++ assertFalse(reopened.result(new NativeCookingQueryResult(sent.getFirst(), true))); ++ assertEquals(latest, reopened.query()); ++ controller.clear(owner); ++ final var newOwner = new Object(); ++ controller.begin(newOwner); ++ final var newMenu = new Object(); ++ assertTrue(controller.accept(newOwner, newMenu, 5, base, _ -> new ItemStack(Items.IRON_INGOT))); ++ assertEquals("", controller.current(newOwner, newMenu, 5).query()); ++ } ++ } ++ ++ @Test + void actionsAndRepliesStayWithThePresentedFrameAndCapabilityWithdrawalRetiresItsGesture() { + final var controller = new NativeCookingPresentationController(); + final var owner = new Object(); @@ -10683,10 +14022,10 @@ index 0000000..906aebf +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingPresentationTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingPresentationTest.java new file mode 100644 -index 0000000..0d86d83 +index 0000000..0f526b6 --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingPresentationTest.java -@@ -0,0 +1,144 @@ +@@ -0,0 +1,301 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -10717,8 +14056,17 @@ index 0000000..0d86d83 +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.raphimc.viabedrock.api.model.NativeCookingControl; ++import net.raphimc.viabedrock.api.model.NativeCookingBundleAction; ++import net.raphimc.viabedrock.api.model.NativeCookingBundleResult; ++import net.raphimc.viabedrock.api.model.NativeBundleAction; ++import net.raphimc.viabedrock.api.model.NativeBundleResult; +import net.raphimc.viabedrock.api.model.NativeCookingMessage; +import net.raphimc.viabedrock.api.model.NativeCookingProgress; ++import net.raphimc.viabedrock.api.model.NativeCookingQuery; ++import net.raphimc.viabedrock.api.model.NativeCookingQueryResult; ++import net.raphimc.viabedrock.api.model.NativeCookingAction; ++import net.raphimc.viabedrock.api.model.NativeCookingActionResult; ++import java.util.concurrent.atomic.AtomicLong; +import net.raphimc.viabedrock.api.resourcepack.ui.ContainerUi; +import net.raphimc.viabedrock.api.resourcepack.ui.CookingUi; +import net.raphimc.viabedrock.protocol.storage.ResourcePackStorage; @@ -10740,29 +14088,155 @@ index 0000000..0d86d83 + } + + @Test -+ void anOlderCellExitCannotEraseNewSelectionOrAdvanceItsRevision() { ++ void bundleCompletionPreservesBufferedSearchAndPlaysOnlyTheMatchingOperationOnce() { ++ final var p = new NativeCookingPresentation(message(CookingUi.Family.FURNACE, 1), Map.of(7, new ItemStack(Items.IRON_INGOT))); ++ final var sent = new ArrayList(); ++ final var queries = new ArrayList(); ++ final var sounds = new ArrayList(); ++ p.bundleCapability(true); ++ p.searchCapability(true); ++ assertTrue(p.bundle("inventory_items", 0, NativeBundleAction.Kind.PRIMARY, 2, -1, sent::add)); ++ assertTrue(p.query("iron")); ++ assertFalse(p.flushQuery(queries::add)); ++ assertTrue(p.update(message(CookingUi.Family.FURNACE, 2), Map.of(7, new ItemStack(Items.IRON_INGOT)))); ++ assertTrue(p.result(NativeCookingBundleResult.reply(sent.getFirst(), true, false), sounds::add)); ++ assertFalse(p.result(NativeCookingBundleResult.reply(sent.getFirst(), true, false), sounds::add)); ++ assertEquals(List.of(NativeBundleResult.Sound.INSERT), sounds); ++ assertEquals("iron", p.query()); ++ assertTrue(p.flushQuery(queries::add)); ++ assertEquals("iron", queries.getFirst().text()); ++ assertEquals(2, queries.getFirst().requestSequence()); ++ p.retire(); ++ assertFalse(p.result(NativeCookingBundleResult.reply(sent.getFirst(), true, false), sounds::add)); ++ } ++ ++ @Test ++ void helpRetainsPendingSearchAndTheOpeningWhileSourceSnapshotsAdvance() { + for (final var family : CookingUi.Family.values()) { -+ final var presentation = new NativeCookingPresentation(message(family, 1), Map.of(7, new ItemStack(Items.IRON_INGOT))); -+ assertTrue(presentation.select("recipe_book", 0)); -+ assertTrue(presentation.select("inventory_items", 3)); -+ final long revision = presentation.revision(); -+ assertFalse(presentation.deselect("recipe_book", 0)); -+ assertFalse(presentation.deselect("inventory_items", 2)); -+ assertEquals(revision, presentation.revision()); -+ assertTrue(presentation.deselect("inventory_items", 3)); -+ assertEquals(revision + 1, presentation.revision()); -+ assertFalse(presentation.deselect("", -1)); -+ assertTrue(presentation.select("recipe_book", 0)); -+ assertTrue(presentation.update(message(family, 2), Map.of(7, new ItemStack(Items.IRON_INGOT)))); -+ assertTrue(presentation.deselect("recipe_book", 0)); ++ final var p = new NativeCookingPresentation(message(family, 1), Map.of(7, new ItemStack(Items.IRON_INGOT))); ++ final var sent = new ArrayList(); ++ p.searchCapability(true); ++ assertTrue(p.query("iron")); ++ assertTrue(p.flushQuery(sent::add)); ++ p.openHelp(); ++ assertTrue(p.help().active()); ++ final var scene = p.resolve(5, "", slots(), Map.of(), false).scene(); ++ assertTrue(nodes(scene.getAsJsonObject("root")).stream().anyMatch(node -> node.has("help_topic") ++ && node.get("help_topic").getAsInt() == 31 && node.get("toggle_state").getAsBoolean())); ++ assertTrue(p.update(message(family, 2), Map.of(7, new ItemStack(Items.IRON_INGOT)))); ++ assertTrue(p.result(new NativeCookingQueryResult(sent.getFirst(), true))); ++ assertTrue(p.help().active()); ++ assertEquals("iron", p.query()); ++ assertTrue(p.help().close()); ++ assertFalse(p.help().active()); ++ assertEquals(3, p.message().sequence()); ++ assertEquals(2, p.message().updateSequence()); ++ assertFalse(p.resolve(5, "", slots(), Map.of(), false).recipes().isEmpty()); ++ assertEquals(1, sent.size()); + } + } + + @Test -+ void realRecipesAndProgressResolveWithoutChangingItemAuthority() { -+ for (final var family : CookingUi.Family.values()) { -+ final var message = message(family, 1); -+ final var stack = new ItemStack(Items.IRON_INGOT, 3); ++ void queuedSearchCoalescesEditsAndAnOlderReplyCannotEraseLaterTyping() { ++ final var p = new NativeCookingPresentation(message(CookingUi.Family.FURNACE, 1), Map.of(7, new ItemStack(Items.IRON_INGOT))); ++ final var sent = new ArrayList(); ++ assertFalse(p.query("unavailable")); ++ p.searchCapability(true); ++ assertTrue(p.query("i")); ++ assertTrue(p.query("ir")); ++ assertTrue(p.flushQuery(sent::add)); ++ assertEquals(List.of("ir"), sent.stream().map(NativeCookingQuery::text).toList()); ++ assertTrue(p.query("iron")); ++ assertFalse(p.flushQuery(sent::add)); ++ assertTrue(p.update(message(CookingUi.Family.FURNACE, 2), Map.of(7, new ItemStack(Items.IRON_INGOT)))); ++ assertEquals("iron", p.query()); ++ assertTrue(p.result(new NativeCookingQueryResult(sent.getFirst(), true))); ++ assertEquals("iron", p.query()); ++ assertTrue(p.flushQuery(sent::add)); ++ assertEquals(List.of("ir", "iron"), sent.stream().map(NativeCookingQuery::text).toList()); ++ assertEquals(2, sent.getLast().requestSequence()); ++ assertFalse(p.result(new NativeCookingQueryResult(sent.getFirst(), true))); ++ assertTrue(p.update(message(CookingUi.Family.FURNACE, 3), Map.of(7, new ItemStack(Items.IRON_INGOT)))); ++ assertTrue(p.result(new NativeCookingQueryResult(sent.getLast(), true))); ++ assertTrue(p.idle()); ++ assertEquals("iron", p.query()); ++ } ++ ++ @Test ++ void searchWaitsForRecipeReleaseAndSharesItsRequestSequence() { ++ final var first = message(CookingUi.Family.FURNACE, 1); ++ final var actions = new NativeCookingActions(first, () -> 0); ++ actions.presented(first, new CookingUi.RecipeActionScope(Map.of(7, Set.of(NativeCookingAction.Mode.PRIMARY.button())))); ++ final var commands = new ArrayList(); ++ final var edits = new ArrayList(); ++ assertTrue(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 0, commands::add)); ++ assertFalse(actions.query("iron", edits::add)); ++ assertTrue(actions.result(new NativeCookingActionResult(commands.getFirst(), true, true))); ++ assertFalse(actions.query("iron", edits::add)); ++ assertTrue(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 2, commands::add)); ++ assertTrue(actions.result(new NativeCookingActionResult(commands.getLast(), true, true))); ++ assertTrue(actions.query("iron", edits::add)); ++ assertEquals(3, edits.getFirst().requestSequence()); ++ assertFalse(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 0, commands::add)); ++ assertTrue(actions.result(new NativeCookingQueryResult(edits.getFirst(), true))); ++ assertFalse(actions.query("another", edits::add)); ++ final var next = message(CookingUi.Family.FURNACE, 2); ++ actions.update(next); ++ assertTrue(actions.query("another", edits::add)); ++ assertEquals(4, edits.getLast().requestSequence()); ++ } ++ ++ @Test ++ void anAcknowledgmentWithoutItsReplacementSnapshotStillTimesOut() { ++ final var clock = new AtomicLong(); ++ final var p = new NativeCookingPresentation(message(CookingUi.Family.FURNACE, 1), Map.of(7, new ItemStack(Items.IRON_INGOT)), clock::get); ++ final var sent = new ArrayList(); ++ p.searchCapability(true); ++ p.query("iron"); ++ assertTrue(p.flushQuery(sent::add)); ++ assertTrue(p.result(new NativeCookingQueryResult(sent.getFirst(), true))); ++ assertFalse(p.idle()); ++ clock.set(10_000_000_000L); ++ assertTrue(p.expired()); ++ final var current = new NativeCookingPresentation(message(CookingUi.Family.FURNACE, 1), Map.of(7, new ItemStack(Items.IRON_INGOT)), clock::get); ++ current.searchCapability(true); ++ current.query("stone"); ++ assertTrue(current.flushQuery(sent::add)); ++ assertTrue(current.result(new NativeCookingQueryResult(sent.getLast(), true))); ++ assertTrue(current.update(message(CookingUi.Family.FURNACE, 2), Map.of(7, new ItemStack(Items.IRON_INGOT)))); ++ clock.set(20_000_000_000L); ++ assertFalse(current.expired()); ++ assertTrue(current.idle()); ++ } ++ ++ @Test ++ void ambiguousSearchSendTimeoutAndCapabilityWithdrawalRetireTheOpening() { ++ final var clock = new AtomicLong(); ++ final var p = new NativeCookingPresentation(message(CookingUi.Family.FURNACE, 1), Map.of(7, new ItemStack(Items.IRON_INGOT)), clock::get); ++ p.searchCapability(true); ++ assertTrue(p.query("iron")); ++ assertTrue(p.flushQuery(_ -> {})); ++ clock.set(10_000_000_000L); ++ assertTrue(p.expired()); ++ assertFalse(p.query("later")); ++ final var other = new NativeCookingPresentation(message(CookingUi.Family.FURNACE, 1), Map.of(7, new ItemStack(Items.IRON_INGOT))); ++ other.searchCapability(true); ++ assertTrue(other.query("iron")); ++ other.searchCapability(false); ++ assertTrue(other.expired()); ++ assertFalse(other.flushQuery(_ -> fail("Withdrawn search must not send"))); ++ final var failed = new NativeCookingPresentation(message(CookingUi.Family.FURNACE, 1), Map.of(7, new ItemStack(Items.IRON_INGOT))); ++ failed.searchCapability(true); ++ failed.query("iron"); ++ assertThrows(IllegalStateException.class, () -> failed.flushQuery(_ -> { throw new IllegalStateException(); })); ++ assertTrue(failed.expired()); ++ } ++ ++ @Test ++ void realRecipesAndProgressResolveWithoutChangingItemAuthority() { ++ for (final var family : CookingUi.Family.values()) { ++ final var message = message(family, 1); ++ final var stack = new ItemStack(Items.IRON_INGOT, 3); + final var presentation = new NativeCookingPresentation(message, Map.of(7, stack)); + stack.setCount(64); + final var before = presentation.resolve(5, "", slots(), Map.of(), false); @@ -10804,6 +14278,28 @@ index 0000000..0d86d83 + } finally { renderer.close(); images.close(); } + } + ++ @Test ++ void defaultCookingRecipeDropsPreserveDistinctInventoryAndRecipeGeometry() { ++ for (final var family : CookingUi.Family.values()) { ++ final var message = message(family, 1); ++ final var presentation = new NativeCookingPresentation(message, Map.of(7, new ItemStack(Items.IRON_INGOT))); ++ final var frame = presentation.resolve(5, "", slots(), Map.of(), false); ++ final var scene = NativeUiScene.parse(frame.scene().toString(), BedrockNativeContainerScreen.RENDERERS); ++ final var images = new NativeUiImages(); ++ final var renderer = new NativeUiRenderer(mock(Font.class), images); ++ try { ++ final var root = new NativeUiLayout(renderer).layout(scene.root(), 640, 360, Map.of()); ++ final var draws = NativeUiPresentation.draws(root, _ -> false); ++ final var geometry = new NativeContainerGeometry(draws, 39, frame.recipeActions(), true); ++ geometry.requireComplete(39); ++ final var cell = draws.stream().filter(draw -> draw.box().node().properties().has("recipe_actions")) ++ .findFirst().orElseThrow().box().visibleBounds(); ++ assertEquals(7, geometry.recipe(cell.x() + cell.width() / 2, cell.y() + cell.height() / 2).id()); ++ assertEquals(-1, geometry.hovered(cell.x() + cell.width() / 2, cell.y() + cell.height() / 2)); ++ } finally { renderer.close(); images.close(); } ++ } ++ } ++ + private static float arrow(final JsonObject scene) { + return nodes(scene.getAsJsonObject("root")).stream().filter(n -> n.has("clip_ratio") + && n.has("clip_direction") && n.get("clip_direction").getAsString().equals("left")) @@ -10833,10 +14329,10 @@ index 0000000..0d86d83 +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingScreenTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingScreenTest.java new file mode 100644 -index 0000000..684afed +index 0000000..376f628 --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingScreenTest.java -@@ -0,0 +1,1410 @@ +@@ -0,0 +1,2828 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -10853,45 +14349,83 @@ index 0000000..684afed + +import com.viaversion.viafabricplus.bedrock.ViaFabricPlusBedrock; +import com.viaversion.viafabricplus.api.settings.base.BooleanSetting; ++import com.viaversion.viafabricplus.api.settings.base.EnumSetting; ++import com.viaversion.viafabricplus.bedrock.settings.base.NativeCookingControlStyle; ++import net.raphimc.viabedrock.api.model.NativeCookingSourceAction; ++import net.raphimc.viabedrock.api.model.NativeCookingSourceResult; +import com.viaversion.viafabricplus.bedrock.screen.container.NativeCookingPreferenceControl.Kind; ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiPresentation; ++import com.viaversion.viaversion.libs.gson.JsonArray; +import com.viaversion.viaversion.libs.gson.JsonObject; +import com.viaversion.viaversion.libs.gson.JsonParser; + +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.minecraft.client.Minecraft; ++import net.minecraft.client.gui.GuiGraphicsExtractor; ++import net.minecraft.client.KeyMapping; ++import net.minecraft.client.Options; +import net.minecraft.client.MouseHandler; +import net.minecraft.client.gui.Font; ++import net.minecraft.client.gui.components.EditBox; ++import net.minecraft.client.input.CharacterEvent; ++import net.raphimc.viabedrock.api.model.NativeCookingQuery; ++import net.raphimc.viabedrock.api.model.NativeCookingBundleAction; ++import net.raphimc.viabedrock.api.model.NativeCookingBundleResult; ++import net.raphimc.viabedrock.api.model.NativeCookingControl; ++import net.raphimc.viabedrock.api.model.NativeBundleAction; ++import com.viaversion.viafabricplus.bedrock.creative.BedrockCreativeCatalog; ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiInput; ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiScissor; ++import com.viaversion.viaversion.api.minecraft.item.Item; +import net.minecraft.client.gui.Gui; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.client.input.KeyEvent; +import com.mojang.blaze3d.platform.InputConstants; ++import com.viaversion.viafabricplus.bedrock.injection.access.IBedrockGuiItemStyle; ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiRenderer; ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiLayout; ++import org.joml.Matrix3x2fStack; +import com.mojang.blaze3d.platform.Window; +import net.minecraft.client.input.MouseButtonInfo; +import net.minecraft.client.multiplayer.ClientPacketListener; ++import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.multiplayer.MultiPlayerGameMode; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.network.Connection; ++import net.minecraft.network.HashedPatchMap; ++import net.minecraft.network.protocol.game.ServerboundContainerClickPacket; +import net.minecraft.network.chat.Component; ++import net.minecraft.network.chat.FormattedText; +import net.minecraft.world.entity.EntityEquipment; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.inventory.FurnaceMenu; ++import net.minecraft.world.inventory.Slot; ++import net.minecraft.core.component.DataComponents; ++import net.minecraft.world.inventory.ContainerInput; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.raphimc.viabedrock.api.model.NativeCookingAction; +import net.raphimc.viabedrock.api.model.NativeCookingActionResult; +import net.raphimc.viabedrock.api.model.NativeCookingMessage; ++import net.raphimc.viabedrock.api.model.NativeCookingHoldAction; ++import net.raphimc.viabedrock.api.model.NativeCookingHoldResult; +import net.raphimc.viabedrock.api.resourcepack.ui.CookingUi; +import net.raphimc.viabedrock.protocol.model.FurnaceOptions; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; ++import org.junit.jupiter.params.ParameterizedTest; ++import org.junit.jupiter.params.provider.Arguments; ++import org.junit.jupiter.params.provider.MethodSource; ++import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedStatic; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; ++import java.util.stream.Stream; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + @@ -10901,23 +14435,959 @@ index 0000000..684afed + NativeCookingPresentationTest.bootstrap(); + } + ++ @ParameterizedTest ++ @ValueSource(booleans = {false, true}) ++ void resetReleaseUsesTheComponentsPreviousPhaseWhileARecipeReplyWaits(final boolean resetSawPress) throws Exception { ++ try (final var f = new Fixture()) { ++ assertTrue(f.controller.accept(f.owner, f.menu, 5, resetMessage(2, true), _ -> new ItemStack(Items.IRON_INGOT))); ++ f.screen.containerTick(); ++ assertSame(f.screen, f.displayed.get()); ++ assertTrue(f.screen.mouseClicked(f.recipePointer(MOUSE_BUTTON_LEFT), false)); ++ final var original = f.sent.getFirst(); ++ // A recipe component consumes this down even while its controller waits for the first reply. ++ final var secondary = resetSawPress ? pointer(MOUSE_BUTTON_RIGHT, 500, 300) : f.recipePointer(MOUSE_BUTTON_RIGHT); ++ assertTrue(f.screen.mouseClicked(secondary, false)); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_RIGHT, 500, 300))); ++ assertEquals(resetSawPress ? 1 : 2, f.sent.size()); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_LEFT, 500, 300))); ++ assertEquals(2, f.sent.size()); ++ f.screen.containerTick(); ++ assertFalse(f.recipeSelected(), "Completion clears the selected source before its reply"); ++ final var terminal = f.sent.getLast(); ++ assertEquals(new NativeCookingAction(original.menuId(), original.sequence(), original.updateSequence(), ++ original.menuRevision(), original.inventoryRevision(), original.cursorRevision(), original.recipeId(), ++ original.mode(), original.controllerInputMode(), 2, 2), terminal); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_RIGHT, 500, 300))); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_LEFT, 500, 300))); ++ assertEquals(2, f.sent.size()); ++ assertTrue(f.presentation.result(new NativeCookingActionResult(original, true, true))); ++ assertTrue(f.presentation.result(new NativeCookingActionResult(terminal, true, true))); ++ assertTrue(f.controller.accept(f.owner, f.menu, 5, resetMessage(3, false), _ -> new ItemStack(Items.IRON_INGOT))); ++ f.screen.containerTick(); ++ assertTrue(f.screen.mouseClicked(f.recipePointer(MOUSE_BUTTON_LEFT), false)); ++ assertEquals(3, f.sent.size(), "Reset finishes the gesture without retiring the opening"); ++ assertSame(f.screen, f.displayed.get()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ ++ @ParameterizedTest ++ @ValueSource(booleans = {false, true}) ++ void resetPressPreservesEmptyPrimaryHoverButClearsSelectedBundleContext(final boolean selected) throws Exception { ++ NativePlayerPresentationTest.bootstrap(); ++ try (final var f = new Fixture(); final var previews = mockStatic(BedrockCreativeCatalog.class)) { ++ f.game.level = mock(ClientLevel.class); ++ previews.when(() -> BedrockCreativeCatalog.toNative(any(Item.class), any())) ++ .thenAnswer(call -> new ItemStack(Items.IRON_INGOT, ((Item) call.getArgument(0)).amount())); ++ assertTrue(f.controller.accept(f.owner, f.menu, 5, resetMessage(2, selected), _ -> new ItemStack(Items.IRON_INGOT))); ++ f.menu.getSlot(30).set(NativePlayerScreenTest.bundle(5)); ++ f.screen.containerTick(); ++ assertSame(f.screen, f.displayed.get()); ++ f.screen.mouseMoved(109, 219); ++ f.screen.containerTick(); ++ final var sourcesField = BedrockNativeContainerScreen.class.getDeclaredField("tooltipSources"); ++ sourcesField.setAccessible(true); ++ final var sources = (NativeInventorySourceState) sourcesField.get(f.screen); ++ assertEquals(2, sources.mode()); ++ assertEquals(selected, f.recipeSelected()); ++ // This fixture's global mapping keeps the pointer stationary while delivering a reset press. ++ final var right = new KeyEvent(InputConstants.KEY_RIGHT, 0, 0); ++ assertTrue(f.screen.keyPressed(right)); ++ f.screen.containerTick(); ++ assertEquals(selected ? 0 : 2, sources.mode()); ++ assertEquals(selected ? NativeInventorySourceState.NONE : new NativeInventorySourceState.Source("hotbar_items", 0), sources.hover()); ++ assertFalse(f.recipeSelected()); ++ assertTrue(f.screen.keyPressed(right)); ++ assertTrue(f.screen.keyReleased(right)); ++ assertTrue(f.controller.accept(f.owner, f.menu, 5, resetMessage(3, selected), _ -> new ItemStack(Items.IRON_INGOT))); ++ f.screen.containerTick(); ++ assertFalse(f.recipeSelected(), "An unchanged server selection cannot restore the locally reset source"); ++ assertEquals(selected ? 0 : 2, sources.mode()); ++ assertTrue(f.sent.isEmpty()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ ++ @Test ++ void resetFinishesRetainedDragOnlyWhenItsDownWasConsumedByAnotherComponent() throws Exception { ++ try (final var f = new Fixture()) { ++ assertTrue(f.controller.accept(f.owner, f.menu, 5, resetMessage(2, false), _ -> new ItemStack(Items.IRON_INGOT))); ++ f.screen.containerTick(); ++ f.networking.when(() -> ClientPlayNetworking.canSend(NativeInventoryDragPayload.TYPE)).thenReturn(true); ++ final var x = Slot.class.getDeclaredField("x"); ++ final var y = Slot.class.getDeclaredField("y"); ++ x.setAccessible(true); ++ y.setAccessible(true); ++ x.setInt(f.menu.getSlot(30), 100); ++ y.setInt(f.menu.getSlot(30), 210); ++ final var carried = new ItemStack(Items.IRON_INGOT, 10); ++ f.menu.setCarried(carried); ++ final var clicks = new ArrayList>(); ++ doAnswer(call -> { ++ final var packet = (ServerboundContainerClickPacket) call.getArgument(0); ++ clicks.add(List.of((int) packet.slotNum(), (int) packet.buttonNum())); ++ return null; ++ }).when(f.owner).send(any(ServerboundContainerClickPacket.class)); ++ assertTrue(f.screen.mouseClicked(pointer(MOUSE_BUTTON_LEFT, 109, 219), false)); ++ f.menu.getSlot(30).set(carried); ++ f.menu.setCarried(ItemStack.EMPTY); ++ assertTrue(f.screen.mouseReleased(f.recipePointer(MOUSE_BUTTON_LEFT))); ++ assertEquals(2, clicks.size()); ++ assertTrue(f.screen.mouseClicked(pointer(MOUSE_BUTTON_RIGHT, 500, 300), false)); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_RIGHT, 500, 300))); ++ assertEquals(2, clicks.size(), "Reset saw its own down and cannot finish the retained drag"); ++ assertTrue(f.screen.mouseClicked(f.recipePointer(MOUSE_BUTTON_RIGHT), false)); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_RIGHT, 500, 300))); ++ assertEquals(List.of(-999, 2), clicks.getLast()); ++ assertEquals(3, clicks.size()); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_RIGHT, 500, 300))); ++ f.screen.onClose(); ++ assertEquals(3, clicks.size()); ++ assertTrue(f.sent.isEmpty()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ ++ @ParameterizedTest ++ @ValueSource(booleans = {false, true}) ++ void cookingBundleClicksUseTheirTypedSourceAndBlockDuplicateJavaActions(final boolean secondary) throws Exception { ++ NativePlayerPresentationTest.bootstrap(); ++ try (final var f = new Fixture(); final var previews = mockStatic(BedrockCreativeCatalog.class)) { ++ f.game.level = mock(ClientLevel.class); ++ previews.when(() -> BedrockCreativeCatalog.toNative(any(Item.class), any())) ++ .thenAnswer(call -> new ItemStack(Items.IRON_INGOT, ((Item) call.getArgument(0)).amount())); ++ final var next = message(2, true); ++ final var exported = JsonParser.parseString(next.program()).getAsJsonObject(); ++ final var definitions = exported.getAsJsonObject("definitions"); ++ final var slot = definitions.getAsJsonObject("furnace.slot").deepCopy(); ++ final var value = slot.getAsJsonObject("value"); ++ final var bag = new JsonObject(); ++ bag.addProperty("#collection_name", "furnace_ingredient_items"); ++ bag.addProperty("#collection_index", 0); ++ value.add("property_bag", bag); ++ value.getAsJsonArray("button_mappings").add(JsonParser.parseString( ++ "{\"to_button_id\":\"button.container_slot_hovered\",\"mapping_type\":\"pressed\"}")); ++ definitions.add("furnace.bundle_slot", slot); ++ final var panel = definitions.getAsJsonObject("furnace.furnace_panel").getAsJsonObject("value"); ++ panel.getAsJsonArray("controls").asList().stream().map(element -> element.getAsJsonObject()) ++ .filter(control -> control.has("furnace_ingredient_items")).findFirst().orElseThrow() ++ .getAsJsonObject("furnace_ingredient_items").addProperty("grid_item_template", "furnace.bundle_slot"); ++ final var custom = new NativeCookingMessage(next.menuId(), next.sequence(), next.updateSequence(), next.menuRevision(), ++ next.inventoryRevision(), next.cursorRevision(), exported.toString(), next.snapshot(), next.recipeItems()); ++ assertTrue(f.controller.accept(f.owner, f.menu, 5, custom, _ -> new ItemStack(Items.IRON_INGOT))); ++ f.presentation.bundleCapability(true); ++ final var sent = new ArrayList(); ++ f.networking.when(() -> ClientPlayNetworking.canSend(NativeCookingBundlePayload.TYPE)).thenReturn(true); ++ f.networking.when(() -> ClientPlayNetworking.send(any(NativeCookingBundlePayload.class))).thenAnswer(call -> { ++ sent.add(((NativeCookingBundlePayload) call.getArgument(0)).action()); ++ return null; ++ }); ++ f.menu.getSlot(0).set(NativePlayerScreenTest.bundle(5)); ++ f.menu.setCarried(secondary ? ItemStack.EMPTY : new ItemStack(Items.IRON_INGOT, 5)); ++ f.clock.set(1_000_000_000L); ++ f.screen.prepare(mock(Font.class), 640, 320); ++ final var field = BedrockNativeContainerScreen.class.getDeclaredField("draws"); ++ field.setAccessible(true); ++ @SuppressWarnings("unchecked") ++ final var draws = (List) field.get(f.screen); ++ final var inputField = BedrockNativeContainerScreen.class.getDeclaredField("nativeInput"); ++ inputField.setAccessible(true); ++ final var input = (NativeContainerInput) inputField.get(f.screen); ++ final var draw = draws.stream().filter(candidate -> input.enabled(candidate.box().node()) ++ && candidate.box().node().properties().has("inventory_slot") ++ && candidate.box().node().properties().get("inventory_slot").getAsInt() == 0).findFirst().orElseThrow(); ++ final var bounds = draw.box().visibleBounds(); ++ final var mouse = pointer(secondary ? MOUSE_BUTTON_RIGHT : MOUSE_BUTTON_LEFT, ++ bounds.x() + bounds.width() / 2, bounds.y() + bounds.height() / 2); ++ f.screen.mouseMoved(mouse.x(), mouse.y()); ++ if (secondary) { ++ final var callbacks = BedrockNativeContainerScreen.class.getDeclaredMethod("componentEvents", NativeContainerInput.Result.class); ++ callbacks.setAccessible(true); ++ final var right = new NativeUiInput.ButtonEvent(NativeUiInput.symbol("button.inventory_right"), ++ NativeUiInput.symbol("button.inventory_right"), 0, 1, true, 1, NativeUiInput.Scope.DEFAULT, false, false); ++ assertEquals(true, callbacks.invoke(f.screen, new NativeContainerInput.Result(true, ++ List.of(new NativeContainerInput.Event(draw.box().node(), right, null))))); ++ } ++ assertTrue(f.screen.mouseClicked(mouse, false)); ++ assertEquals(1, sent.size()); ++ assertEquals(new NativeCookingBundleAction(NativeCookingControl.forMessage(custom, NativeCookingControl.Mode.ACTIVATE), ++ "furnace_ingredient_items", 0, secondary ? NativeBundleAction.Kind.SECONDARY : NativeBundleAction.Kind.PRIMARY, ++ 2, secondary ? 3 : -1, 1), sent.getFirst()); ++ final var sourcesField = BedrockNativeContainerScreen.class.getDeclaredField("tooltipSources"); ++ sourcesField.setAccessible(true); ++ final var sources = (NativeInventorySourceState) sourcesField.get(f.screen); ++ assertTrue(f.presentation.bundlePending()); ++ assertTrue(f.screen.mouseClicked(mouse, false)); ++ assertTrue(f.screen.mouseReleased(mouse)); ++ assertEquals(new NativeInventorySourceState.Source("furnace_ingredient_items", 0), sources.hover()); ++ assertEquals(2, sources.mode()); ++ assertEquals(secondary ? 3 : -1, sources.selectedCell(sources.bundle())); ++ assertEquals(1, sent.size()); ++ verifyNoInteractions(f.game.gameMode); ++ assertSame(f.screen, f.displayed.get()); ++ f.presentation.retire(); ++ assertFalse(f.presentation.result(NativeCookingBundleResult.reply(sent.getFirst(), true, false), ++ ignored -> fail("A retired screen cannot play a bundle sound"))); ++ } ++ } ++ ++ @Test ++ void cookingRecipeDropKeysRetainTheOpeningAndRespectTheCarriedStack() throws Exception { ++ try (final var f = new Fixture()) { ++ f.enablePreferences(); ++ final var previous = message(2, true); ++ final var program = JsonParser.parseString(previous.program()).getAsJsonObject(); ++ final var definitions = program.getAsJsonObject("definitions"); ++ final var mappings = definitions.getAsJsonObject("furnace.recipe").getAsJsonObject("value").getAsJsonArray("button_mappings"); ++ mappings.add(JsonParser.parseString("{\"from_button_id\":\"button.menu_inventory_drop\",\"to_button_id\":\"button.drop_one\",\"mapping_type\":\"pressed\"}")); ++ mappings.add(JsonParser.parseString("{\"from_button_id\":\"button.menu_inventory_drop_all\",\"to_button_id\":\"button.drop_all\",\"mapping_type\":\"pressed\"}")); ++ final var source = new NativeCookingMessage(5, previous.sequence(), previous.updateSequence(), previous.menuRevision(), ++ previous.inventoryRevision(), previous.cursorRevision(), program.toString(), previous.snapshot(), previous.recipeItems()); ++ assertTrue(f.controller.accept(f.owner, f.menu, 5, source, _ -> new ItemStack(Items.IRON_INGOT))); ++ f.presentation.searchCapability(true); ++ f.screen.prepare(mock(Font.class), 640, 360); ++ f.clock.set(1_000_000_000); ++ f.screen.prepare(mock(Font.class), 640, 360); ++ final var field = BedrockNativeContainerScreen.class.getDeclaredField("draws"); ++ field.setAccessible(true); ++ @SuppressWarnings("unchecked") ++ final var draws = (List) field.get(f.screen); ++ final var recipe = draws.stream().filter(draw -> draw.box().node().properties().has("recipe_actions")) ++ .findFirst().orElseThrow().box().bounds(); ++ f.screen.mouseMoved(recipe.x() + recipe.width() / 2, recipe.y() + recipe.height() / 2); ++ final var drop = new KeyEvent(InputConstants.KEY_Q, 0, 0); ++ final var dropAll = new KeyEvent(InputConstants.KEY_Q, 0, InputConstants.MOD_CONTROL); ++ assertTrue(f.screen.keyPressed(drop)); ++ assertTrue(f.screen.keyReleased(drop)); ++ assertTrue(f.screen.keyPressed(dropAll)); ++ assertTrue(f.screen.keyReleased(drop)); ++ verifyNoInteractions(f.game.gameMode); ++ assertTrue(f.presentation.idle()); ++ f.menu.setCarried(new ItemStack(Items.IRON_INGOT, 9)); ++ assertTrue(f.screen.keyPressed(dropAll)); ++ assertTrue(f.screen.keyReleased(drop)); ++ verifyNoInteractions(f.game.gameMode); ++ assertTrue(f.screen.keyPressed(drop)); ++ assertTrue(f.screen.keyPressed(drop)); ++ assertTrue(f.screen.keyReleased(drop)); ++ verify(f.game.gameMode, times(1)).handleContainerInput(eq(5), eq(-999), eq(1), eq(ContainerInput.PICKUP), eq(f.player)); ++ verifyNoMoreInteractions(f.game.gameMode); ++ assertTrue(f.presentation.idle()); ++ assertEquals(9, f.menu.getCarried().getCount()); // The mocked game mode does not predict a count. ++ assertTrue(f.sent.isEmpty()); ++ assertTrue(f.displayed.get() == null || f.displayed.get() == f.screen); ++ } ++ } ++ ++ @Test ++ void cookingHelpRetainsTheOpeningAndCarriedItemsAcrossUpdatesBackAndEscape() throws Exception { ++ try (final var f = new Fixture()) { ++ final var next = message(2, true); ++ final var exported = JsonParser.parseString(NativeCookingPresentationTest.message(CookingUi.Family.FURNACE, 2).program()).getAsJsonObject(); ++ final var definitions = exported.getAsJsonObject("definitions"); ++ JsonParser.parseString(next.program()).getAsJsonObject().getAsJsonObject("definitions").entrySet() ++ .forEach(entry -> definitions.add(entry.getKey(), entry.getValue().deepCopy())); ++ definitions.getAsJsonObject("furnace.furnace_panel").getAsJsonObject("value").getAsJsonArray("controls").add(JsonParser.parseString(""" ++ {"help":{"type":"button","anchor_from":"top_left","anchor_to":"top_left", ++ "offset":[580,20],"size":[40,20],"button_mappings":[ ++ {"from_button_id":"button.menu_select","to_button_id":"button.help","mapping_type":"pressed"}, ++ {"from_button_id":"button.menu_ok","to_button_id":"button.help","mapping_type":"focused"}]}} ++ """)); ++ final var bound = new NativeCookingMessage(5, next.sequence(), next.updateSequence(), next.menuRevision(), ++ next.inventoryRevision(), next.cursorRevision(), exported.toString(), next.snapshot(), next.recipeItems()); ++ assertTrue(f.controller.accept(f.owner, f.menu, 5, bound, _ -> new ItemStack(Items.IRON_INGOT))); ++ f.menu.setCarried(new ItemStack(Items.IRON_INGOT, 9)); ++ f.screen.prepare(mock(Font.class), 640, 320); ++ final var field = BedrockNativeContainerScreen.class.getDeclaredField("draws"); ++ field.setAccessible(true); ++ @SuppressWarnings("unchecked") ++ final var inventoryDraws = (List) field.get(f.screen); ++ final var helpBox = inventoryDraws.stream().filter(draw -> draw.box().node().name().equals("help")).findFirst().orElseThrow().box().bounds(); ++ final var help = pointer(MOUSE_BUTTON_LEFT, helpBox.x() + helpBox.width() / 2, helpBox.y() + helpBox.height() / 2); ++ assertTrue(f.screen.mouseClicked(help, false)); ++ f.screen.containerTick(); ++ assertTrue(f.presentation.help().active()); ++ assertTrue(f.screen.mouseReleased(help)); ++ assertEquals(9, f.menu.getCarried().getCount()); ++ final var update = new NativeCookingMessage(5, next.sequence(), 3, next.menuRevision(), next.inventoryRevision(), ++ next.cursorRevision(), exported.toString(), next.snapshot(), next.recipeItems()); ++ assertTrue(f.controller.accept(f.owner, f.menu, 5, update, _ -> new ItemStack(Items.IRON_INGOT))); ++ f.clock.addAndGet(1_000_000_000); ++ f.screen.prepare(mock(Font.class), 640, 320); ++ @SuppressWarnings("unchecked") ++ final var helpDraws = (List) field.get(f.screen); ++ final var selected = helpDraws.stream().filter(draw -> draw.box().node().properties().has("help_topic") ++ && draw.box().node().flag("toggle_state", false)).findFirst().orElseThrow(); ++ assertEquals(31, selected.box().node().properties().get("help_topic").getAsInt()); ++ final var inputField = BedrockNativeContainerScreen.class.getDeclaredField("nativeInput"); ++ inputField.setAccessible(true); ++ assertSame(selected.box().node(), ((NativeContainerInput) inputField.get(f.screen)).focused()); ++ assertSame(selected.box().node(), ((NativeContainerInput) inputField.get(f.screen)).focusedForDrawing()); ++ final var input = (NativeContainerInput) inputField.get(f.screen); ++ final var other = helpDraws.stream().filter(draw -> draw.box().node().properties().has("help_topic") ++ && draw.box().node().properties().get("help_topic").getAsInt() == 30).findFirst().orElseThrow().box().visibleBounds(); ++ f.screen.mouseMoved(other.x() + other.width() / 2, other.y() + other.height() / 2); ++ assertEquals(30, input.focused().properties().get("help_topic").getAsInt()); ++ f.screen.mouseMoved(600, 250); ++ assertNull(input.focusedForDrawing()); ++ assertEquals(30, input.focused().properties().get("help_topic").getAsInt()); ++ final var down = new KeyEvent(InputConstants.KEY_DOWN, 0, 0); ++ assertTrue(f.screen.keyPressed(down)); ++ assertTrue(f.screen.keyReleased(down)); ++ assertEquals(31, input.focusedForDrawing().properties().get("help_topic").getAsInt()); ++ assertEquals(selected.box().bounds().height(), selected.box().visibleBounds().height(), 0.01F); ++ final var backBox = helpDraws.stream().filter(draw -> draw.box().node().flag("help_close", false)).findFirst().orElseThrow().box().bounds(); ++ final var back = pointer(MOUSE_BUTTON_LEFT, backBox.x() + backBox.width() / 2, backBox.y() + backBox.height() / 2); ++ f.screen.mouseMoved(back.x(), back.y()); ++ final var confirm = new KeyEvent(InputConstants.KEY_RETURN, 0, 0); ++ assertTrue(f.screen.keyPressed(confirm)); ++ assertTrue(f.screen.keyReleased(confirm)); ++ f.screen.containerTick(); ++ assertFalse(f.presentation.help().active()); ++ assertSame(f.menu, f.screen.getMenu()); ++ assertSame(f.presentation, f.controller.current(f.owner, f.menu, 5)); ++ assertEquals(9, f.menu.getCarried().getCount()); ++ assertTrue(f.screen.mouseClicked(help, false)); ++ f.screen.containerTick(); ++ assertTrue(f.presentation.help().active()); ++ assertTrue(f.screen.mouseReleased(help)); ++ assertTrue(f.screen.keyPressed(new KeyEvent(InputConstants.KEY_ESCAPE, 0, 0))); ++ assertFalse(f.presentation.help().active()); ++ assertEquals(9, f.menu.getCarried().getCount()); ++ verify(f.player, never()).closeContainer(); ++ verifyNoInteractions(f.game.gameMode); ++ assertTrue(f.sent.isEmpty()); ++ assertTrue(f.preferences.isEmpty()); ++ assertSame(f.screen, f.displayed.get()); ++ } ++ } ++ ++ @Test ++ void initialInputAdmissionRetiresMappingsOnceAndPreservesHeldInputOnResize() throws Exception { ++ try (final var f = new Fixture()) { ++ final var field = BedrockNativeContainerScreen.class.getDeclaredField("nativeInput"); ++ field.setAccessible(true); ++ final var input = (NativeContainerInput) field.get(f.screen); ++ f.screen.mouseMoved(f.recipeX, f.recipeY); ++ final var first = input.button("button.menu_select", true, 1, f.clock.get(), true); ++ assertFalse(first.events().isEmpty()); ++ assertEquals(2, first.events().getFirst().button().previous()); ++ ++ f.screen.prepare(mock(Font.class), 640, 320); ++ f.screen.mouseMoved(f.recipeX, f.recipeY); ++ final var repeated = input.button("button.menu_select", true, 1, f.clock.get(), true); ++ assertFalse(repeated.events().isEmpty()); ++ assertEquals(1, repeated.events().getFirst().button().previous()); ++ assertFalse(repeated.events().getFirst().button().transition()); ++ } ++ } ++ ++ @Test ++ void dragDistributionIncludesThePressedSlotWithoutAnInitialMotionEvent() throws Exception { ++ try (final var f = new Fixture()) { ++ when(f.player.getInventory()).thenReturn(f.inventory); ++ doAnswer(call -> { ++ f.menu.clicked(call.getArgument(1), call.getArgument(2), call.getArgument(3), f.player); ++ return null; ++ }).when(f.game.gameMode).handleContainerInput(anyInt(), anyInt(), anyInt(), any(), eq(f.player)); ++ final var carried = new ItemStack(Items.IRON_INGOT, 9); ++ carried.set(DataComponents.MAX_STACK_SIZE, 64); ++ f.menu.setCarried(carried); ++ // The unit JVM does not apply the native hover Mixin. Match these slot positions ++ // to the authored cells before exercising vanilla eligibility and real menu input. ++ for (int index = 31; index <= 33; index++) { ++ final var x = Slot.class.getDeclaredField("x"); ++ final var y = Slot.class.getDeclaredField("y"); ++ x.setAccessible(true); ++ y.setAccessible(true); ++ x.setInt(f.menu.getSlot(index), 118 + (index - 31) * 18); ++ y.setInt(f.menu.getSlot(index), 210); ++ } ++ final var first = pointer(MOUSE_BUTTON_LEFT, 127, 219); ++ final var second = pointer(MOUSE_BUTTON_LEFT, 145, 219); ++ final var third = pointer(MOUSE_BUTTON_LEFT, 163, 219); ++ assertEquals(31, f.screen.nativeHoveredSlot(first.x(), first.y()).index); ++ assertTrue(f.screen.mouseClicked(first, false)); ++ assertTrue(f.screen.mouseDragged(second, 18, 0)); ++ assertTrue(f.screen.mouseDragged(third, 18, 0)); ++ f.screen.mouseReleased(third); ++ assertEquals(List.of(3, 3, 3), List.of(f.menu.getSlot(31).getItem().getCount(), ++ f.menu.getSlot(32).getItem().getCount(), f.menu.getSlot(33).getItem().getCount())); ++ assertTrue(f.menu.getCarried().isEmpty()); ++ } ++ } ++ ++ @Test ++ void releasingNegotiatedSecondaryDragKeepsTheAuthoritativeDistribution() throws Exception { ++ try (final var f = new Fixture()) { ++ f.networking.when(() -> ClientPlayNetworking.canSend(NativeInventoryDragPayload.TYPE)).thenReturn(true); ++ when(f.player.getInventory()).thenReturn(f.inventory); ++ doAnswer(call -> { ++ f.menu.clicked(call.getArgument(1), call.getArgument(2), call.getArgument(3), f.player); ++ return null; ++ }).when(f.game.gameMode).handleContainerInput(anyInt(), anyInt(), anyInt(), any(), eq(f.player)); ++ final var carried = new ItemStack(Items.IRON_INGOT, 9); ++ carried.set(DataComponents.MAX_STACK_SIZE, 64); ++ f.menu.setCarried(carried); ++ for (int index = 31; index <= 33; index++) { ++ final var x = Slot.class.getDeclaredField("x"); ++ final var y = Slot.class.getDeclaredField("y"); ++ x.setAccessible(true); ++ y.setAccessible(true); ++ x.setInt(f.menu.getSlot(index), 118 + (index - 31) * 18); ++ y.setInt(f.menu.getSlot(index), 210); ++ } ++ final var first = pointer(MOUSE_BUTTON_RIGHT, 127, 219); ++ final var second = pointer(MOUSE_BUTTON_RIGHT, 145, 219); ++ final var third = pointer(MOUSE_BUTTON_RIGHT, 163, 219); ++ assertTrue(f.screen.mouseClicked(first, false)); ++ assertTrue(f.screen.mouseDragged(second, 18, 0)); ++ assertTrue(f.screen.mouseDragged(third, 18, 0)); ++ // Core has already reconciled each incremental placement before release. ++ for (int index = 31; index <= 33; index++) f.menu.getSlot(index).set(carried.copyWithCount(1)); ++ f.menu.setCarried(carried.copyWithCount(6)); ++ assertTrue(f.screen.mouseReleased(third)); ++ assertEquals(List.of(1, 1, 1), List.of(f.menu.getSlot(31).getItem().getCount(), ++ f.menu.getSlot(32).getItem().getCount(), f.menu.getSlot(33).getItem().getCount())); ++ assertEquals(6, f.menu.getCarried().getCount()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ ++ @ParameterizedTest ++ @ValueSource(ints = {MOUSE_BUTTON_LEFT, MOUSE_BUTTON_RIGHT}) ++ void negotiatedDragVisitsDestinationsBeforeReleaseEvenAfterTheCursorEmpties(final int button) throws Exception { ++ try (final var f = new Fixture()) { ++ f.networking.when(() -> ClientPlayNetworking.canSend(NativeInventoryDragPayload.TYPE)).thenReturn(true); ++ when(f.player.getInventory()).thenReturn(f.inventory); ++ final var carried = new ItemStack(Items.IRON_INGOT, 9); ++ carried.set(DataComponents.MAX_STACK_SIZE, 64); ++ f.menu.setCarried(carried); ++ for (int index = 31; index <= 33; index++) { ++ final var x = Slot.class.getDeclaredField("x"); ++ final var y = Slot.class.getDeclaredField("y"); ++ x.setAccessible(true); ++ y.setAccessible(true); ++ x.setInt(f.menu.getSlot(index), 118 + (index - 31) * 18); ++ y.setInt(f.menu.getSlot(index), 210); ++ } ++ final var clicks = new ArrayList>(); ++ doAnswer(call -> { ++ final var packet = (ServerboundContainerClickPacket) call.getArgument(0); ++ assertEquals(ContainerInput.QUICK_CRAFT, packet.containerInput()); ++ assertTrue(packet.changedSlots().isEmpty()); ++ clicks.add(List.of((int) packet.slotNum(), (int) packet.buttonNum())); ++ return null; ++ }).when(f.owner).send(any(ServerboundContainerClickPacket.class)); ++ final int mode = button == MOUSE_BUTTON_RIGHT ? 4 : 0; ++ final var first = pointer(button, 127, 219); ++ final var second = pointer(button, 145, 219); ++ final var third = pointer(button, 163, 219); ++ assertTrue(f.screen.mouseClicked(first, false)); ++ assertEquals(List.of(List.of(-999, mode), List.of(31, mode | 1)), clicks); ++ // A real core update can empty the cursor after the first destination. ++ // The original drag budget must still admit subsequent pointer visits. ++ f.menu.setCarried(ItemStack.EMPTY); ++ f.menu.getSlot(31).set(carried.copy()); ++ assertTrue(f.screen.mouseDragged(second, 18, 0)); ++ assertTrue(f.screen.mouseDragged(third, 18, 0)); ++ assertTrue(f.screen.mouseDragged(second, -18, 0)); ++ assertEquals(List.of(List.of(-999, mode), List.of(31, mode | 1), List.of(32, mode | 1), List.of(33, mode | 1)), clicks); ++ f.screen.onClose(); ++ assertEquals(List.of(-999, mode | 2), clicks.getLast()); ++ assertEquals(5, clicks.size()); ++ verify(f.player, times(1)).closeContainer(); ++ f.screen.mouseReleased(third); ++ assertEquals(5, clicks.size()); ++ } ++ } ++ ++ @ParameterizedTest ++ @ValueSource(ints = {MOUSE_BUTTON_LEFT, MOUSE_BUTTON_RIGHT}) ++ void admittedSlotReleaseEndsRetainedDragRegardlessOfItsStartingButton(final int releaseButton) throws Exception { ++ try (final var f = new Fixture()) { ++ f.networking.when(() -> ClientPlayNetworking.canSend(NativeInventoryDragPayload.TYPE)).thenReturn(true); ++ final var carried = new ItemStack(Items.IRON_INGOT, 10); ++ f.menu.setCarried(carried); ++ for (int index = 31; index <= 34; index++) { ++ final var x = Slot.class.getDeclaredField("x"); ++ final var y = Slot.class.getDeclaredField("y"); ++ x.setAccessible(true); ++ y.setAccessible(true); ++ x.setInt(f.menu.getSlot(index), 118 + (index - 31) * 18); ++ y.setInt(f.menu.getSlot(index), 210); ++ } ++ final var clicks = new ArrayList>(); ++ doAnswer(call -> { ++ final var packet = (ServerboundContainerClickPacket) call.getArgument(0); ++ clicks.add(List.of((int) packet.slotNum(), (int) packet.buttonNum())); ++ return null; ++ }).when(f.owner).send(any(ServerboundContainerClickPacket.class)); ++ assertTrue(f.screen.mouseClicked(pointer(MOUSE_BUTTON_LEFT, 127, 219), false)); ++ f.menu.getSlot(31).set(carried.copyWithCount(10)); ++ f.menu.setCarried(ItemStack.EMPTY); ++ assertTrue(f.screen.mouseDragged(pointer(MOUSE_BUTTON_LEFT, 145, 219), 18, 0)); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_LEFT, 400, 100))); ++ // A physical release without a controller callback leaves native drag ownership intact. ++ f.screen.mouseMoved(163, 219); ++ assertEquals(List.of(List.of(-999, 0), List.of(31, 1), List.of(32, 1), List.of(33, 1)), clicks); ++ for (int index = 31; index <= 33; index++) f.menu.getSlot(index).set(carried.copyWithCount(3)); ++ f.menu.setCarried(carried.copyWithCount(1)); ++ assertTrue(f.screen.mouseClicked(pointer(releaseButton, 163, 219), false)); ++ assertEquals(4, clicks.size(), "A new press retains the existing allocation until release"); ++ assertTrue(f.screen.mouseReleased(pointer(releaseButton, 163, 219))); ++ assertEquals(List.of(-999, 2), clicks.getLast()); ++ f.screen.mouseMoved(181, 219); ++ assertEquals(5, clicks.size()); ++ assertEquals(1, f.menu.getCarried().getCount()); ++ assertTrue(f.screen.mouseReleased(pointer(releaseButton, 163, 219))); ++ assertEquals(5, clicks.size()); ++ f.screen.onClose(); ++ assertEquals(5, clicks.size()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ ++ @ParameterizedTest ++ @ValueSource(ints = {0, 2}) ++ void returnHoldIgnoresAnUnmatchedReleaseAndCompletesOnceAfterItsReply(final int previous) throws Exception { ++ try (final var f = new Fixture()) { ++ final var message = returnSlotMessage(2); ++ assertTrue(f.controller.accept(f.owner, f.menu, 5, message, _ -> new ItemStack(Items.IRON_INGOT))); ++ f.presentation.holdCapability(true); ++ final var held = new ArrayList(); ++ f.networking.when(() -> ClientPlayNetworking.send(any())).thenAnswer(call -> { ++ held.add(((NativeCookingHoldPayload) call.getArgument(0)).action()); ++ return null; ++ }); ++ final var slot = f.menu.getSlot(30); ++ slot.set(new ItemStack(Items.IRON_INGOT, 9)); ++ f.screen.containerTick(); ++ assertSame(f.screen, f.displayed.get()); ++ doAnswer(_ -> { f.menu.setCarried(slot.getItem().copy()); slot.set(ItemStack.EMPTY); return null; }) ++ .when(f.game.gameMode).handleContainerInput(f.menu.containerId, 30, 0, ContainerInput.PICKUP, f.player); ++ f.screen.mouseMoved(109, 219); ++ assertFalse(f.screen.keyPressed(new KeyEvent(InputConstants.KEY_SPACE, 0, 0))); ++ verifyNoInteractions(f.game.gameMode); ++ final var enter = new KeyEvent(InputConstants.KEY_RETURN, 0, 0); ++ assertTrue(f.screen.keyPressed(enter)); ++ assertEquals(9, f.menu.getCarried().getCount()); ++ assertTrue(f.controller.accept(f.owner, f.menu, 5, returnSlotMessage(3), _ -> new ItemStack(Items.IRON_INGOT))); ++ f.clock.set(301_000_000L); ++ f.screen.advanceCookingInput(); ++ assertEquals(1, held.size()); ++ assertEquals(NativeCookingHoldAction.Phase.START, held.getFirst().phase()); ++ assertTrue(f.screen.keyPressed(enter)); ++ assertEquals(1, held.size(), "A repeated Return press cannot start another pickup"); ++ assertTrue(f.presentation.result(new NativeCookingHoldResult(held.getFirst(), true))); ++ slot.set(new ItemStack(Items.IRON_INGOT, 9)); ++ f.menu.setCarried(ItemStack.EMPTY); ++ f.clock.set(601_000_000L); ++ f.screen.advanceCookingInput(); ++ final var drawsField = BedrockNativeContainerScreen.class.getDeclaredField("draws"); ++ drawsField.setAccessible(true); ++ @SuppressWarnings("unchecked") ++ final var draws = (List) drawsField.get(f.screen); ++ final var returnNode = draws.stream().map(draw -> draw.box().node()) ++ .filter(node -> node.name().equals("return_input")).findFirst().orElseThrow(); ++ final var callbacks = BedrockNativeContainerScreen.class.getDeclaredMethod("componentEvents", NativeContainerInput.Result.class); ++ callbacks.setAccessible(true); ++ final int target = NativeUiInput.symbol("button.return"); ++ final var unmatched = new NativeUiInput.ButtonEvent(target, target, previous, 0, false, 1, ++ NativeUiInput.Scope.DEFAULT, false, false); ++ callbacks.invoke(f.screen, new NativeContainerInput.Result(true, ++ List.of(new NativeContainerInput.Event(returnNode, unmatched, null)))); ++ assertEquals(1, held.size(), "A release without this component's press cannot finish Return selection"); ++ f.clock.set(801_000_000L); ++ f.screen.advanceCookingInput(); ++ assertTrue(f.screen.keyReleased(enter)); ++ assertEquals(2, held.size()); ++ assertEquals(NativeCookingHoldAction.Phase.COMPLETE, held.getLast().phase()); ++ assertEquals(5, held.getLast().quantity()); ++ assertEquals(held.getFirst().source(), held.getLast().source()); ++ f.screen.keyReleased(enter); ++ assertEquals(2, held.size()); ++ assertEquals(9, slot.getItem().getCount()); ++ assertTrue(f.menu.getCarried().isEmpty()); ++ verify(f.game.gameMode).handleContainerInput(f.menu.containerId, 30, 0, ContainerInput.PICKUP, f.player); ++ verifyNoMoreInteractions(f.game.gameMode); ++ } ++ } ++ ++ private static NativeCookingMessage returnSlotMessage(final long update) { ++ final var source = message(update, true); ++ final var program = JsonParser.parseString(source.program()).getAsJsonObject(); ++ final var definitions = program.getAsJsonObject("definitions"); ++ definitions.getAsJsonObject("furnace.slot").getAsJsonObject("value").getAsJsonArray("button_mappings") ++ .add(JsonParser.parseString(""" ++ {"from_button_id":"button.menu_ok","to_button_id":"button.container_take_all_place_all","mapping_type":"focused"} ++ """)); ++ definitions.getAsJsonObject("furnace.furnace_panel").getAsJsonObject("value").getAsJsonArray("controls") ++ .add(JsonParser.parseString(""" ++ {"return_input":{"type":"input_panel","size":[0,0],"button_mappings":[ ++ {"from_button_id":"button.return","to_button_id":"button.return","mapping_type":"global"}]}} ++ """)); ++ return new NativeCookingMessage(source.menuId(), source.sequence(), source.updateSequence(), source.menuRevision(), ++ source.inventoryRevision(), source.cursorRevision(), program.toString(), source.snapshot(), source.recipeItems()); ++ } ++ ++ @Test ++ void heldPickupRoutesOneOrdinaryClickAndCompletesOnPointerExitWithoutMutatingMenuPreviews() throws Exception { ++ try (final var f = new Fixture()) { ++ f.presentation.holdCapability(true); ++ f.presentation.searchCapability(true); ++ final var held = new ArrayList(); ++ f.networking.when(() -> ClientPlayNetworking.send(any())).thenAnswer(call -> { ++ held.add(((NativeCookingHoldPayload) call.getArgument(0)).action()); ++ return null; ++ }); ++ final var slot = f.menu.getSlot(30); ++ slot.set(new ItemStack(Items.IRON_INGOT, 9)); ++ f.screen.containerTick(); ++ doAnswer(_ -> { f.menu.setCarried(slot.getItem().copy()); slot.set(ItemStack.EMPTY); return null; }) ++ .when(f.game.gameMode).handleContainerInput(f.menu.containerId, 30, 0, ContainerInput.PICKUP, f.player); ++ final var click = pointer(MOUSE_BUTTON_LEFT, 109, 219); ++ assertEquals(30, f.screen.nativeHoveredSlot(click.x(), click.y()).index); ++ f.screen.mouseMoved(click.x(), click.y()); ++ assertTrue(f.screen.mouseClicked(click, false)); ++ assertFalse(f.presentation.query("blocked")); ++ assertTrue(f.controller.accept(f.owner, f.menu, 5, message(2, true), _ -> new ItemStack(Items.IRON_INGOT))); ++ f.clock.set(301_000_000L); ++ f.screen.advanceCookingInput(); ++ assertEquals(1, held.size()); ++ assertEquals(NativeCookingHoldAction.Phase.START, held.getFirst().phase()); ++ final var hiddenCursor = mock(GuiGraphicsExtractor.class); ++ f.screen.extractCarriedItem(hiddenCursor, 109, 219); ++ verifyNoInteractions(hiddenCursor); ++ assertEquals(9, f.menu.getCarried().getCount()); ++ assertFalse(slot.hasItem()); ++ assertTrue(f.presentation.result(new NativeCookingHoldResult(held.getFirst(), true))); ++ slot.set(new ItemStack(Items.IRON_INGOT, 9)); ++ f.menu.setCarried(ItemStack.EMPTY); ++ f.clock.set(601_000_000L); ++ f.screen.advanceCookingInput(); ++ f.screen.mouseMoved(630, 300); ++ assertEquals(2, held.size()); ++ assertEquals(3, held.getLast().quantity()); ++ assertEquals(held.getFirst().source(), held.getLast().source()); ++ assertEquals(9, slot.getItem().getCount()); ++ assertTrue(f.menu.getCarried().isEmpty()); ++ final var preview = mock(GuiGraphicsExtractor.class); ++ f.screen.extractCarriedItem(preview, 630, 300); ++ verify(preview).item(argThat(item -> item.getCount() == 3), eq(622), eq(292)); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_LEFT, 630, 300))); ++ assertEquals(2, held.size()); ++ verify(f.game.gameMode).handleContainerInput(f.menu.containerId, 30, 0, ContainerInput.PICKUP, f.player); ++ verifyNoMoreInteractions(f.game.gameMode); ++ } ++ } ++ ++ @ParameterizedTest ++ @ValueSource(ints = {MOUSE_BUTTON_RIGHT, MOUSE_BUTTON_MIDDLE}) ++ void quantitySelectionOnlyCompletesOnAnAdmittedSecondaryRelease(final int releaseButton) throws Exception { ++ try (final var f = new Fixture()) { ++ f.presentation.holdCapability(true); ++ final var held = new ArrayList(); ++ f.networking.when(() -> ClientPlayNetworking.send(any())).thenAnswer(call -> { ++ held.add(((NativeCookingHoldPayload) call.getArgument(0)).action()); ++ return null; ++ }); ++ final var slot = f.menu.getSlot(30); ++ slot.set(new ItemStack(Items.IRON_INGOT, 9)); ++ f.screen.containerTick(); ++ doAnswer(_ -> { f.menu.setCarried(slot.getItem().copy()); slot.set(ItemStack.EMPTY); return null; }) ++ .when(f.game.gameMode).handleContainerInput(f.menu.containerId, 30, 0, ContainerInput.PICKUP, f.player); ++ final var primary = pointer(MOUSE_BUTTON_LEFT, 109, 219); ++ final var secondary = pointer(releaseButton, 109, 219); ++ f.screen.mouseMoved(primary.x(), primary.y()); ++ assertTrue(f.screen.mouseClicked(primary, false)); ++ assertTrue(f.controller.accept(f.owner, f.menu, 5, message(2, true), _ -> new ItemStack(Items.IRON_INGOT))); ++ f.clock.set(301_000_000L); ++ f.screen.advanceCookingInput(); ++ assertEquals(1, held.size()); ++ assertTrue(f.presentation.result(new NativeCookingHoldResult(held.getFirst(), true))); ++ slot.set(new ItemStack(Items.IRON_INGOT, 9)); ++ f.menu.setCarried(ItemStack.EMPTY); ++ f.clock.set(601_000_000L); ++ f.screen.advanceCookingInput(); ++ assertTrue(f.screen.mouseClicked(secondary, false)); ++ assertEquals(1, held.size(), "A second press preserves quantity selection"); ++ assertTrue(f.screen.mouseReleased(secondary)); ++ assertEquals(releaseButton == MOUSE_BUTTON_RIGHT ? 2 : 1, held.size(), ++ "Only a delivered slot release completes selection before the primary release"); ++ assertTrue(f.screen.mouseReleased(primary)); ++ assertEquals(2, held.size()); ++ assertEquals(NativeCookingHoldAction.Phase.COMPLETE, held.getLast().phase()); ++ assertEquals(3, held.getLast().quantity()); ++ assertEquals(held.getFirst().source(), held.getLast().source()); ++ assertTrue(f.screen.mouseReleased(secondary)); ++ assertEquals(2, held.size(), "Later releases cannot complete the same quantity twice"); ++ assertEquals(9, slot.getItem().getCount()); ++ assertTrue(f.menu.getCarried().isEmpty()); ++ verify(f.game.gameMode).handleContainerInput(f.menu.containerId, 30, 0, ContainerInput.PICKUP, f.player); ++ verifyNoMoreInteractions(f.game.gameMode); ++ } ++ } ++ ++ @ParameterizedTest ++ @ValueSource(strings = {"button.clear_hotbar_or_drop", "button.clear_hotbar_or_remove_one", ++ "button.clear_selected_recipe", "button.destroy_selection", "button.menu_clear", "button.shift_pane_focus"}) ++ void unregisteredCookingControllerMappingsRetainTheScreenWithoutChangingItems(final String target) throws Exception { ++ try (final var f = new Fixture()) { ++ final var carried = new ItemStack(Items.IRON_INGOT, 9); ++ f.menu.setCarried(carried); ++ final var next = messageWithHostAction(target, true); ++ assertTrue(f.controller.accept(f.owner, f.menu, 5, next, _ -> new ItemStack(Items.IRON_INGOT))); ++ f.screen.containerTick(); ++ assertSame(f.screen, f.displayed.get()); ++ final var click = pointer(MOUSE_BUTTON_LEFT, 630, 300); ++ f.screen.mouseMoved(click.x(), click.y()); ++ assertTrue(f.screen.mouseClicked(click, false)); ++ assertTrue(f.screen.mouseReleased(click)); ++ assertTrue(f.screen.keyPressed(new KeyEvent(InputConstants.KEY_RETURN, 0, 0))); ++ f.screen.keyReleased(new KeyEvent(InputConstants.KEY_RETURN, 0, 0)); ++ assertSame(f.screen, f.displayed.get()); ++ assertSame(carried, f.menu.getCarried()); ++ assertEquals(9, f.menu.getCarried().getCount()); ++ assertTrue(f.sent.isEmpty()); ++ assertTrue(f.preferences.isEmpty()); ++ verifyNoInteractions(f.game.gameMode); ++ verify(f.owner, never()).send(any(ServerboundContainerClickPacket.class)); ++ } ++ } ++ ++ @Test ++ void arrowHostPublishesTouchHoverTextThroughReleaseAndExpiresItsAuthoredControl() throws Exception { ++ try (final var f = new Fixture(NativeCookingControlStyle.TOUCH)) { ++ when(f.window.getWidth()).thenReturn(640); ++ when(f.window.getHeight()).thenReturn(320); ++ when(f.window.getGuiScale()).thenReturn(1); ++ when(f.window.getGuiScaledWidth()).thenReturn(640); ++ when(f.window.getGuiScaledHeight()).thenReturn(320); ++ f.menu.setCarried(new ItemStack(Items.IRON_INGOT, 9)); ++ final var next = messageWithHostAction("button.arrow_key", true); ++ final var program = JsonParser.parseString(next.program()).getAsJsonObject(); ++ program.getAsJsonObject("definitions").getAsJsonObject("furnace.furnace_screen").getAsJsonObject("value") ++ .add("button_mappings", JsonParser.parseString(""" ++ [{"from_button_id":"button.arrow_right","to_button_id":"button.arrow_key","mapping_type":"global"}] ++ """)); ++ program.getAsJsonObject("definitions").getAsJsonObject("furnace.furnace_panel").getAsJsonObject("value") ++ .getAsJsonArray("controls").add(JsonParser.parseString(""" ++ {"persistent_text":{"type":"label","text":"Selected","size":[40,10], ++ "bindings":[{"binding_name":"#show_persistent_bundle_hover_text","binding_name_override":"#visible"}]}} ++ """)); ++ final var authored = new NativeCookingMessage(next.menuId(), next.sequence(), next.updateSequence(), next.menuRevision(), ++ next.inventoryRevision(), next.cursorRevision(), program.toString(), next.snapshot(), next.recipeItems()); ++ assertTrue(f.controller.accept(f.owner, f.menu, 5, authored, _ -> new ItemStack(Items.IRON_INGOT))); ++ f.screen.containerTick(); ++ assertSame(f.screen, f.displayed.get()); ++ assertFalse(persistentTextVisibleAfterFrame(f.screen)); ++ final var key = new KeyEvent(InputConstants.KEY_RIGHT, 0, 0); ++ f.screen.keyPressed(key); ++ f.screen.containerTick(); ++ assertTrue(persistentTextVisibleAfterFrame(f.screen)); ++ f.screen.keyReleased(key); ++ f.clock.set(5_999_999_999L); ++ f.screen.containerTick(); ++ assertTrue(persistentTextVisibleAfterFrame(f.screen)); ++ f.clock.set(6_000_000_000L); ++ f.screen.containerTick(); ++ assertFalse(persistentTextVisibleAfterFrame(f.screen)); ++ assertSame(f.screen, f.displayed.get()); ++ assertEquals(9, f.menu.getCarried().getCount()); ++ assertTrue(f.sent.isEmpty()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ ++ @Test ++ void scrollbarCaptureBlocksTouchSourceHoldUntilItsReleaseWithoutMovingItems() throws Exception { ++ try (final var f = new Fixture(NativeCookingControlStyle.TOUCH)) { ++ f.presentation.sourceCapability(true); ++ final var next = message(2, true); ++ final var program = JsonParser.parseString(next.program()).getAsJsonObject(); ++ final var definitions = program.getAsJsonObject("definitions"); ++ final var slot = definitions.getAsJsonObject("furnace.slot").getAsJsonObject("value"); ++ slot.add("bindings", JsonParser.parseString(""" ++ [{"binding_type":"collection_details","binding_collection_name":"$item_collection_name"}] ++ """)); ++ slot.getAsJsonArray("button_mappings").add(JsonParser.parseString(""" ++ {"to_button_id":"button.shape_drawing","mapping_type":"pressed"} ++ """)); ++ final var controls = definitions.getAsJsonObject("furnace.furnace_panel").getAsJsonObject("value").getAsJsonArray("controls"); ++ for (final var row : controls) for (final var entry : row.getAsJsonObject().entrySet()) { ++ final var control = entry.getValue().getAsJsonObject(); ++ if (control.has("collection_name")) control.addProperty("$item_collection_name", control.get("collection_name").getAsString()); ++ } ++ controls.add(JsonParser.parseString(""" ++ {"view":{"type":"scroll_view","size":[120,100],"offset":[500,0],"anchor_from":"top_left","anchor_to":"top_left", ++ "scroll_content":"items","scroll_view_port":"viewport","scrollbar_track":"track","scrollbar_box":"thumb", ++ "button_mappings":[{"from_button_id":"button.menu_select","to_button_id":"button.scrollbar_touch", ++ "mapping_type":"pressed","button_up_right_of_first_refusal":true}],"controls":[ ++ {"viewport":{"type":"panel","size":[110,"100%"],"anchor_from":"top_left","anchor_to":"top_left","controls":[ ++ {"items":{"type":"panel","size":[110,300],"anchor_from":"top_left","anchor_to":"top_left"}}]}}, ++ {"track":{"type":"scroll_track","size":[10,"100%"],"offset":[110,0],"anchor_from":"top_left","anchor_to":"top_left","controls":[ ++ {"thumb":{"type":"scrollbar_box","size":[10,"100%"],"min_size":[0,12],"draggable":"vertical", ++ "anchor_from":"top_left","anchor_to":"top_left"}}]}}]}} ++ """)); ++ final var authored = new NativeCookingMessage(next.menuId(), next.sequence(), next.updateSequence(), next.menuRevision(), ++ next.inventoryRevision(), next.cursorRevision(), program.toString(), next.snapshot(), next.recipeItems()); ++ assertTrue(f.controller.accept(f.owner, f.menu, 5, authored, _ -> new ItemStack(Items.IRON_INGOT))); ++ f.menu.getSlot(31).set(new ItemStack(Items.IRON_INGOT, 9)); ++ f.screen.containerTick(); ++ assertSame(f.screen, f.displayed.get()); ++ assertTrue(f.screen.mouseClicked(pointer(MOUSE_BUTTON_LEFT, 615, 90), false)); ++ f.screen.mouseMoved(127, 219); ++ f.screen.advanceSourceInput(127, 219); ++ f.clock.set(526_000_000L); ++ f.screen.advanceSourceInput(127, 219); ++ assertEquals(new CookingUi.Selection("", -1), f.presentation.selectedSource()); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_LEFT, 127, 219))); ++ assertTrue(f.screen.mouseClicked(pointer(MOUSE_BUTTON_LEFT, 127, 219), false)); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_LEFT, 127, 219))); ++ assertEquals(new CookingUi.Selection("hotbar_items", 1), f.presentation.selectedSource()); ++ assertEquals(9, f.menu.getSlot(31).getItem().getCount()); ++ assertTrue(f.menu.getCarried().isEmpty()); ++ assertTrue(f.sent.isEmpty()); ++ verifyNoInteractions(f.game.gameMode); ++ verify(f.owner, never()).send(any(ServerboundContainerClickPacket.class)); ++ } ++ } ++ ++ @SuppressWarnings("unchecked") ++ private static boolean persistentTextVisibleAfterFrame(final BedrockNativeContainerScreen screen) throws Exception { ++ final var graphics = mock(GuiGraphicsExtractor.class, withSettings().extraInterfaces(IBedrockGuiItemStyle.class)); ++ when(graphics.pose()).thenReturn(new Matrix3x2fStack(8)); ++ when(((IBedrockGuiItemStyle) graphics).stackanvil$itemStyle()).thenReturn(NativeInventoryItemStyle.NORMAL); ++ final var logger = ViaFabricPlusBedrock.impl().logger(); ++ doAnswer(call -> { throw new AssertionError("Unexpected frame fallback", (Throwable) call.getArgument(1)); }) ++ .when(logger).warn(anyString(), any(Throwable.class)); ++ // The native scissor extension is installed by client mixins, outside this unit fixture. ++ try (final var scissor = mockStatic(NativeUiScissor.class)) { ++ screen.extractContents(graphics, 0, 0, 0); ++ } ++ final var field = BedrockNativeContainerScreen.class.getDeclaredField("draws"); ++ field.setAccessible(true); ++ final var draws = (List) field.get(screen); ++ return draws.stream().anyMatch(draw -> draw.box().node().name().equals("persistent_text") && draw.alpha() > 0 ++ && draw.box().node().flag("visible", true) && draw.box().visibleBounds().width() > 0); ++ } ++ ++ @Test ++ void unknownCookingHostActionsStillRestoreOrdinaryControls() throws Exception { ++ final String target = "button.future_cooking_action"; ++ try (final var f = new Fixture()) { ++ final var carried = new ItemStack(Items.IRON_INGOT, 9); ++ f.menu.setCarried(carried); ++ assertTrue(f.controller.accept(f.owner, f.menu, 5, messageWithHostAction(target, true), _ -> new ItemStack(Items.IRON_INGOT))); ++ f.screen.containerTick(); ++ assertSame(f.ordinary, f.displayed.get()); ++ assertSame(carried, f.menu.getCarried()); ++ assertTrue(f.sent.isEmpty()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ ++ @ParameterizedTest ++ @ValueSource(strings = {"button.clear_selected_recipe", "button.container_reset_held"}) ++ void nonconsumingCookingHostMappingsRetainOrdinaryControls(final String target) throws Exception { ++ try (final var f = new Fixture()) { ++ assertTrue(f.controller.accept(f.owner, f.menu, 5, ++ messageWithHostAction(target, false), _ -> new ItemStack(Items.IRON_INGOT))); ++ f.screen.containerTick(); ++ assertSame(f.ordinary, f.displayed.get()); ++ assertTrue(f.sent.isEmpty()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ ++ @Test ++ void cookingCursorHostRoutesBothButtonsWithoutRepeatingHeldInput() throws Exception { ++ for (final int button : List.of(MOUSE_BUTTON_LEFT, MOUSE_BUTTON_RIGHT)) { ++ try (final var f = new Fixture()) { ++ final var next = message(2, true); ++ final var exported = JsonParser.parseString(next.program()).getAsJsonObject(); ++ exported.getAsJsonObject("definitions").getAsJsonObject("furnace.furnace_screen").getAsJsonObject("value") ++ .add("button_mappings", JsonParser.parseString(""" ++ [{"from_button_id":"button.menu_select","to_button_id":"button.cursor_drop_all","mapping_type":"pressed"}, ++ {"from_button_id":"button.menu_secondary_select","to_button_id":"button.cursor_drop_one","mapping_type":"pressed"}] ++ """)); ++ final var bound = new NativeCookingMessage(5, next.sequence(), next.updateSequence(), next.menuRevision(), ++ next.inventoryRevision(), next.cursorRevision(), exported.toString(), next.snapshot(), next.recipeItems()); ++ assertTrue(f.controller.accept(f.owner, f.menu, 5, bound, _ -> new ItemStack(Items.IRON_INGOT))); ++ f.screen.containerTick(); ++ assertSame(f.screen, f.displayed.get()); ++ final var held = new ItemStack(Items.IRON_INGOT, 9); ++ f.menu.setCarried(held); ++ final var click = pointer(button, 630, 300); ++ f.screen.mouseMoved(click.x(), click.y()); ++ assertTrue(f.screen.mouseClicked(click, false)); ++ assertTrue(f.screen.mouseClicked(click, false)); ++ assertTrue(f.screen.mouseReleased(click)); ++ verify(f.game.gameMode).handleContainerInput(f.menu.containerId, -999, button == MOUSE_BUTTON_LEFT ? 0 : 1, ContainerInput.PICKUP, f.player); ++ verifyNoMoreInteractions(f.game.gameMode); ++ assertSame(held, f.menu.getCarried()); ++ assertTrue(f.sent.isEmpty()); ++ assertTrue(f.preferences.isEmpty()); ++ } ++ } ++ } ++ ++ @Test ++ void layoutHostActionsPreserveTheCursorAndWaitForTheMatchingModel() throws Exception { ++ for (final var host : List.of(NavigationHost.SCREEN, NavigationHost.PANEL, NavigationHost.GLOBAL_PANEL, NavigationHost.BUTTON)) { ++ try (final var f = new Fixture()) { ++ f.enablePreferences(); ++ f.navigation(2, 1, 4, 2, false, host, "button.layout_increment"); ++ assertSame(f.screen, f.displayed.get(), host.toString()); ++ f.menu.setCarried(new ItemStack(Items.IRON_INGOT, 3)); ++ final var key = new KeyEvent(InputConstants.KEY_RETURN, 0, 0); ++ if (host == NavigationHost.BUTTON) { ++ assertTrue(f.screen.mouseClicked(pointer(MOUSE_BUTTON_LEFT, 310, 10), false)); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_LEFT, 310, 10))); ++ } else { ++ assertTrue(f.screen.keyPressed(key)); ++ assertTrue(f.screen.keyPressed(key)); ++ assertTrue(f.screen.keyReleased(key)); ++ } ++ assertEquals(List.of(new FurnaceOptions(1, 4, true, 1)), f.preferences); ++ f.navigation(3, -1, 4, 2, false, host, "button.layout_decrement"); ++ if (host == NavigationHost.BUTTON) { ++ assertTrue(f.screen.mouseClicked(pointer(MOUSE_BUTTON_LEFT, 310, 10), false)); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_LEFT, 310, 10))); ++ } else { ++ assertTrue(f.screen.keyPressed(key)); ++ assertTrue(f.screen.keyReleased(key)); ++ } ++ assertEquals(1, f.preferences.size(), "An unrelated model cannot acknowledge the pending layout"); ++ f.navigation(4, -1, 4, 1, false, host, "button.layout_decrement"); ++ if (host == NavigationHost.BUTTON) { ++ assertTrue(f.screen.mouseClicked(pointer(MOUSE_BUTTON_LEFT, 310, 10), false)); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_LEFT, 310, 10))); ++ } else { ++ assertTrue(f.screen.keyPressed(key)); ++ assertTrue(f.screen.keyReleased(key)); ++ } ++ assertEquals(List.of(new FurnaceOptions(1, 4, true, 1), new FurnaceOptions(1, 4, true, 2)), f.preferences); ++ assertEquals(3, f.menu.getCarried().getCount()); ++ assertTrue(f.sent.isEmpty()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ } ++ + @Test -+ void initialInputAdmissionRetiresMappingsOnceAndPreservesHeldInputOnResize() throws Exception { ++ void forcedRecipeBookConsumesLayoutNavigationWithoutChangingPreferences() throws Exception { + try (final var f = new Fixture()) { -+ final var field = BedrockNativeContainerScreen.class.getDeclaredField("nativeInput"); -+ field.setAccessible(true); -+ final var input = (NativeContainerInput) field.get(f.screen); -+ f.screen.mouseMoved(f.recipeX, f.recipeY); -+ final var first = input.button("button.menu_select", true, 1, f.clock.get(), true); -+ assertFalse(first.events().isEmpty()); -+ assertEquals(2, first.events().getFirst().button().previous()); -+ -+ f.screen.prepare(mock(Font.class), 640, 320); -+ f.screen.mouseMoved(f.recipeX, f.recipeY); -+ final var repeated = input.button("button.menu_select", true, 1, f.clock.get(), true); -+ assertFalse(repeated.events().isEmpty()); -+ assertEquals(1, repeated.events().getFirst().button().previous()); -+ assertFalse(repeated.events().getFirst().button().transition()); ++ f.enablePreferences(); ++ f.navigation(2, -1, 4, 2, true, NavigationHost.SCREEN, "button.layout_decrement"); ++ assertSame(f.screen, f.displayed.get()); ++ assertTrue(f.screen.keyPressed(new KeyEvent(InputConstants.KEY_RETURN, 0, 0))); ++ assertTrue(f.screen.keyReleased(new KeyEvent(InputConstants.KEY_RETURN, 0, 0))); ++ assertTrue(f.preferences.isEmpty()); ++ assertTrue(f.sent.isEmpty()); ++ assertSame(f.screen, f.displayed.get()); ++ verifyNoInteractions(f.game.gameMode); + } + } + @@ -11094,13 +15564,9 @@ index 0000000..684afed + f.enablePreferences(); + final var key = new KeyEvent(InputConstants.KEY_RETURN, 0, 0); + f.navigation(2, 1, 4, 1, false, NavigationHost.PANEL); -+ f.screen.mouseMoved(f.recipeX, f.recipeY); -+ f.screen.containerTick(); -+ assertTrue(f.recipeSelected()); + assertTrue(f.screen.keyPressed(key)); + assertTrue(f.screen.keyReleased(key)); + assertTrue(f.preferences.isEmpty()); -+ assertTrue(f.recipeSelected()); + f.navigation(3, 1, 4, 2, false, NavigationHost.PANEL); + assertTrue(f.screen.keyPressed(key)); + assertTrue(f.screen.keyPressed(key)); @@ -11118,7 +15584,7 @@ index 0000000..684afed + } + + @Test -+ void recipeHoverSelectionClearsOnExitAndSurvivesUnchangedModelRefreshes() throws Exception { ++ void recipeHoverCannotReplaceOrEraseTheAuthoritativeSelectedSource() throws Exception { + try (final var f = new Fixture()) { + final var before = f.presentation.message(); + final var exported = JsonParser.parseString(before.program()).getAsJsonObject(); @@ -11130,43 +15596,31 @@ index 0000000..684afed + [{"selected_recipe":{"type":"panel","bindings":[ + {"binding_name":"#is_recipe_selected_slot","binding_name_override":"#visible"}]}}] + """)); -+ final var message = NativeCookingMessage.create(CookingUi.fromProgram(exported), before.view(), before.sequence(), 2, -+ before.menuRevision(), before.inventoryRevision(), before.cursorRevision(), before.recipeItems()); -+ assertTrue(f.controller.accept(f.owner, f.menu, 5, message, _ -> new ItemStack(Items.IRON_INGOT))); -+ f.screen.containerTick(); -+ assertSame(f.screen, f.displayed.get()); -+ assertFalse(f.recipeSelected()); -+ when(f.window.getWidth()).thenReturn(1280); -+ when(f.window.getHeight()).thenReturn(640); -+ when(f.mouse.getScaledXPos(f.window)).thenReturn(630D); -+ when(f.mouse.getScaledYPos(f.window)).thenReturn(300D); -+ doAnswer(call -> { -+ f.displayed.set(call.getArgument(0)); -+ when(f.mouse.getScaledXPos(f.window)).thenReturn(f.recipeX * 2); -+ when(f.mouse.getScaledYPos(f.window)).thenReturn(f.recipeY * 2); -+ BedrockNativeContainers.screenInstalled(call.getArgument(0)); -+ return null; -+ }).when(f.game).setScreenAndShow(f.screen); -+ f.displayed.set(f.ordinary); -+ BedrockNativeContainers.replace(f.screen); -+ f.screen.containerTick(); -+ assertTrue(f.recipeSelected()); -+ final var next = NativeCookingMessage.create(CookingUi.fromProgram(exported), before.view(), before.sequence(), 3, -+ before.menuRevision(), before.inventoryRevision(), before.cursorRevision(), before.recipeItems()); -+ assertTrue(f.controller.accept(f.owner, f.menu, 5, next, _ -> new ItemStack(Items.IRON_INGOT))); -+ f.screen.containerTick(); -+ assertTrue(f.recipeSelected()); -+ f.screen.mouseMoved(630, 300); -+ f.screen.containerTick(); -+ assertFalse(f.recipeSelected()); -+ f.displayed.set(f.ordinary); -+ BedrockNativeContainers.screenInstalled(f.screen); -+ f.displayed.set(f.screen); -+ f.screen.containerTick(); -+ assertFalse(f.recipeSelected()); -+ f.screen.mouseMoved(f.recipeX, f.recipeY); -+ f.screen.containerTick(); -+ assertTrue(f.recipeSelected()); ++ long update = 1; ++ for (final boolean selected : List.of(false, true, false)) { ++ final var snapshot = JsonParser.parseString(before.snapshot()).getAsJsonObject(); ++ final var source = new JsonObject(); ++ source.addProperty("collection", selected ? "recipe_book" : ""); ++ source.addProperty("index", selected ? 0 : -1); ++ snapshot.getAsJsonObject("book").add("selection", source); ++ final var message = new NativeCookingMessage(5, before.sequence(), ++update, before.menuRevision(), ++ before.inventoryRevision(), before.cursorRevision(), exported.toString(), snapshot.toString(), before.recipeItems()); ++ assertTrue(f.controller.accept(f.owner, f.menu, 5, message, _ -> new ItemStack(Items.IRON_INGOT))); ++ f.screen.containerTick(); ++ assertSame(f.screen, f.displayed.get()); ++ assertEquals(selected, f.recipeSelected()); ++ f.screen.mouseMoved(f.recipeX, f.recipeY); ++ f.screen.containerTick(); ++ assertEquals(selected, f.recipeSelected(), "Hover cannot select a recipe or replace the primary source"); ++ final var refreshed = new NativeCookingMessage(5, before.sequence(), ++update, before.menuRevision(), ++ before.inventoryRevision(), before.cursorRevision(), exported.toString(), snapshot.toString(), before.recipeItems()); ++ assertTrue(f.controller.accept(f.owner, f.menu, 5, refreshed, _ -> new ItemStack(Items.IRON_INGOT))); ++ f.screen.containerTick(); ++ assertEquals(selected, f.recipeSelected()); ++ f.screen.mouseMoved(630, 300); ++ f.screen.containerTick(); ++ assertEquals(selected, f.recipeSelected(), "A hover exit cannot erase the selected primary source"); ++ } + assertTrue(f.sent.isEmpty()); + assertTrue(f.preferences.isEmpty()); + verifyNoInteractions(f.game.gameMode); @@ -11174,13 +15628,14 @@ index 0000000..684afed + } + + @Test -+ void unsupportedFocusedSlotActionsRestoreTheOrdinaryMenuBeforeInput() throws Exception { ++ void unsupportedFocusedQuickMoveRestoresTheOrdinaryMenuBeforeInput() throws Exception { + try (final var f = new Fixture()) { + final var before = f.presentation.message(); + final var exported = JsonParser.parseString(before.program()).getAsJsonObject(); + final var slot = exported.getAsJsonObject("definitions").getAsJsonObject("furnace.slot").getAsJsonObject("value"); + final var mapping = slot.getAsJsonArray("button_mappings").get(0).deepCopy().getAsJsonObject(); + mapping.addProperty("from_button_id", "button.menu_ok"); ++ mapping.addProperty("to_button_id", "button.container_auto_place"); + mapping.addProperty("mapping_type", "focused"); + slot.addProperty("focus_enabled", true); + slot.getAsJsonArray("button_mappings").add(mapping); @@ -11631,6 +16086,182 @@ index 0000000..684afed + } + + @Test ++ void hoveringNativeSearchAdmitsTextWithoutASelectionAndRetainsEditingAfterLeaving() throws Exception { ++ try (final var f = new Fixture()) { ++ final var editor = f.nativeEditor(""); ++ final var held = new ItemStack(Items.IRON_INGOT, 9); ++ f.menu.setCarried(held); ++ clearInvocations(f.game); ++ f.screen.mouseMoved(editor.getX() + 2, editor.getY() + 2); ++ assertNull(f.screen.getFocused(), "Hover alone does not select the editor"); ++ verify(f.game).onTextInputFocusChange(f.screen, true); ++ final var inventory = new KeyEvent(InputConstants.KEY_E, 0, 0); ++ assertTrue(f.screen.keyPressed(inventory)); ++ assertFalse(f.screen.closing()); ++ assertTrue(f.screen.charTyped(new CharacterEvent('e'))); ++ assertEquals("e", f.presentation.query()); ++ assertSame(editor, f.screen.getFocused()); ++ f.screen.mouseMoved(630, 300); ++ assertTrue(f.screen.keyPressed(new KeyEvent(InputConstants.KEY_ESCAPE, 0, 0))); ++ assertNull(f.screen.getFocused()); ++ assertFalse(f.screen.closing()); ++ assertTrue(f.screen.keyPressed(inventory)); ++ assertFalse(f.screen.closing()); ++ assertTrue(f.screen.charTyped(new CharacterEvent('e'))); ++ assertEquals("ee", f.presentation.query()); ++ assertSame(held, f.menu.getCarried()); ++ assertTrue(f.sent.isEmpty()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ ++ @Test ++ void leavingAnUnselectedSearchHoverRetiresTextInputAndAllowsInventoryExit() throws Exception { ++ try (final var f = new Fixture()) { ++ final var editor = f.nativeEditor(""); ++ f.screen.mouseMoved(editor.getX() + 2, editor.getY() + 2); ++ clearInvocations(f.game); ++ f.screen.mouseMoved(630, 300); ++ verify(f.game).onTextInputFocusChange(f.screen, false); ++ assertNull(f.screen.getFocused()); ++ assertFalse(f.screen.charTyped(new CharacterEvent('e'))); ++ assertEquals("", f.presentation.query()); ++ assertTrue(f.screen.keyPressed(new KeyEvent(InputConstants.KEY_E, 0, 0))); ++ verify(f.player).closeContainer(); ++ assertTrue(f.sent.isEmpty()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ ++ @Test ++ void cookingExitHostsCloseOutsideSearchAndEscapeClosesAnUnselectedSearchHover() throws Exception { ++ for (final int key : List.of(InputConstants.KEY_E, InputConstants.KEY_ESCAPE)) { ++ try (final var f = new Fixture()) { ++ final var editor = f.nativeEditor("", true); ++ assertSame(f.screen, f.displayed.get()); ++ if (key == InputConstants.KEY_ESCAPE) f.screen.mouseMoved(editor.getX() + 2, editor.getY() + 2); ++ else f.screen.mouseMoved(630, 300); ++ final var held = new ItemStack(Items.IRON_INGOT, 9); ++ f.menu.setCarried(held); ++ assertTrue(f.screen.keyPressed(new KeyEvent(key, 0, 0))); ++ verify(f.player).closeContainer(); ++ assertSame(held, f.menu.getCarried()); ++ assertTrue(f.sent.isEmpty()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ } ++ ++ @Test ++ void nativeEmbeddedClearRetainsTypingWhileAnOlderQueryIsPending() throws Exception { ++ try (final var f = new Fixture()) { ++ final var editor = f.nativeEditor(""); ++ assertTrue(f.clearDraws().isEmpty()); ++ assertTrue(f.screen.mouseClicked(pointer(MOUSE_BUTTON_LEFT, editor.getX() + 2, editor.getY() + 2), false)); ++ for (final char value : "iron".toCharArray()) assertTrue(f.screen.charTyped(new CharacterEvent(value))); ++ final var queries = new ArrayList(); ++ assertTrue(f.presentation.flushQuery(queries::add)); ++ f.screen.containerTick(); ++ final var clear = f.clearDraws().getFirst().box().visibleBounds(); ++ assertTrue(f.screen.mouseClicked(pointer(MOUSE_BUTTON_LEFT, clear.x() + 2, clear.y() + 2), false)); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_LEFT, clear.x() + 2, clear.y() + 2))); ++ assertEquals("", editor.getValue()); ++ assertEquals("", f.presentation.query()); ++ assertNull(f.screen.getFocused()); ++ assertTrue(f.screen.charTyped(new CharacterEvent('s'))); ++ assertEquals("s", editor.getValue()); ++ assertFalse(f.presentation.flushQuery(queries::add)); ++ assertEquals(List.of("iron"), queries.stream().map(NativeCookingQuery::text).toList()); ++ f.screen.containerTick(); ++ assertTrue(f.screen.keyPressed(new KeyEvent(InputConstants.KEY_ESCAPE, 0, 0))); ++ assertNull(f.screen.getFocused()); ++ final var afterEscape = f.clearDraws().getFirst().box().visibleBounds(); ++ assertTrue(f.screen.mouseClicked(pointer(MOUSE_BUTTON_LEFT, afterEscape.x() + 2, afterEscape.y() + 2), false)); ++ assertEquals("", editor.getValue()); ++ assertTrue(f.screen.charTyped(new CharacterEvent(' '))); ++ f.screen.containerTick(); ++ assertFalse(f.clearDraws().isEmpty(), "Raw whitespace still shows the native clear button"); ++ assertEquals(1, f.screen.children().stream().filter(child -> child == editor).count()); ++ assertSame(f.screen, f.displayed.get()); ++ assertTrue(f.sent.isEmpty()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ ++ @Test ++ void nativeEmbeddedClearLeavesAnInactiveRetainedQueryUnchanged() throws Exception { ++ try (final var f = new Fixture()) { ++ final var editor = f.nativeEditor("stone"); ++ f.screen.resize(512, 360); ++ assertNull(f.screen.getFocused()); ++ final var clear = f.clearDraws().getFirst().box().visibleBounds(); ++ assertTrue(f.screen.mouseClicked(pointer(MOUSE_BUTTON_LEFT, clear.x() + 2, clear.y() + 2), false)); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_LEFT, clear.x() + 2, clear.y() + 2))); ++ assertEquals("stone", editor.getValue()); ++ assertEquals("stone", f.presentation.query()); ++ assertNull(f.screen.getFocused()); ++ assertFalse(f.screen.charTyped(new CharacterEvent('i'))); ++ assertEquals("stone", f.presentation.query()); ++ assertSame(f.screen, f.displayed.get()); ++ assertTrue(f.sent.isEmpty()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ ++ @Test ++ void boundCookingSearchRetainsItsEditorAndLatestTextAcrossAResize() throws Exception { ++ try (final var f = new Fixture()) { ++ final var next = message(2, true); ++ final var exported = JsonParser.parseString(next.program()).getAsJsonObject(); ++ final var panel = exported.getAsJsonObject("definitions").getAsJsonObject("furnace.furnace_panel").getAsJsonObject("value"); ++ final var control = JsonParser.parseString(""" ++ {"search":{"type":"edit_box","text_box_name":"item_name","max_length":128, ++ "anchor_from":"top_left","anchor_to":"top_left","offset":[20,20],"size":[100,16], ++ "button_mappings":[{"from_button_id":"button.menu_select","to_button_id":"button.search_bar_selected", ++ "mapping_type":"pressed","handle_select":true}]}} ++ """); ++ panel.getAsJsonArray("controls").add(control); ++ final var bound = new NativeCookingMessage(5, next.sequence(), next.updateSequence(), next.menuRevision(), ++ next.inventoryRevision(), next.cursorRevision(), exported.toString(), next.snapshot(), next.recipeItems()); ++ assertTrue(f.controller.accept(f.owner, f.menu, 5, bound, _ -> new ItemStack(Items.IRON_INGOT))); ++ f.presentation.searchCapability(true); ++ f.networking.when(() -> ClientPlayNetworking.canSend(NativeCookingQueryPayload.TYPE)).thenReturn(true); ++ final var font = mock(Font.class); ++ when(font.plainSubstrByWidth(anyString(), anyInt())).thenAnswer(call -> call.getArgument(0)); ++ when(font.plainSubstrByWidth(anyString(), anyInt(), anyBoolean())).thenAnswer(call -> call.getArgument(0)); ++ when(font.width(anyString())).thenAnswer(call -> ((String) call.getArgument(0)).length() * 6); ++ final var fontField = Screen.class.getDeclaredField("font"); ++ fontField.setAccessible(true); ++ fontField.set(f.screen, font); ++ f.screen.prepare(font, 640, 320); ++ final var editor = f.screen.children().stream().filter(EditBox.class::isInstance).map(EditBox.class::cast).findFirst().orElseThrow(); ++ assertNull(f.screen.getFocused()); ++ final var click = pointer(MOUSE_BUTTON_LEFT, editor.getX() + 2, editor.getY() + 2); ++ assertTrue(f.screen.mouseClicked(click, false)); ++ assertSame(editor, f.screen.getFocused(), "Editor bounds " + editor.getX() + "," + editor.getY() + " " + editor.getWidth() + "x" + editor.getHeight() + "; visible=" + editor.visible + "; active=" + editor.active); ++ assertTrue(f.screen.charTyped(new CharacterEvent('i'))); ++ assertTrue(f.screen.charTyped(new CharacterEvent('r'))); ++ final var queries = new ArrayList(); ++ assertTrue(f.presentation.flushQuery(queries::add)); ++ assertTrue(f.screen.charTyped(new CharacterEvent('o'))); ++ assertTrue(f.screen.charTyped(new CharacterEvent('n'))); ++ f.screen.resize(512, 360); ++ assertSame(editor, f.screen.getFocused()); ++ assertEquals("iron", editor.getValue()); ++ assertEquals("iron", f.presentation.query()); ++ assertEquals(1, f.screen.children().stream().filter(child -> child == editor).count()); ++ assertEquals(List.of("ir"), queries.stream().map(NativeCookingQuery::text).toList()); ++ assertTrue(f.screen.keyPressed(new KeyEvent(InputConstants.KEY_ESCAPE, 0, 0))); ++ assertNull(f.screen.getFocused()); ++ assertFalse(f.screen.closing()); ++ f.presentation.searchCapability(false); ++ f.screen.containerTick(); ++ assertSame(f.ordinary, f.displayed.get()); ++ assertTrue(f.sent.isEmpty()); ++ } ++ } ++ ++ @Test + void unsupportedVisibleControlsCannotInstallAnInertCookingScreen() throws Exception { + try (final var f = new Fixture()) { + final var next = message(2, true); @@ -11722,6 +16353,111 @@ index 0000000..684afed + } + } + ++ private static Stream touchControls() { ++ return Stream.of(Arguments.of(MOUSE_BUTTON_LEFT, false), Arguments.of(MOUSE_BUTTON_RIGHT, false), ++ Arguments.of(MOUSE_BUTTON_LEFT, true)); ++ } ++ ++ @ParameterizedTest ++ @MethodSource("touchControls") ++ void explicitTouchStyleSelectsThePhysicalSourceAndQueuesDrawingWithoutJavaPickupOrPrediction(final int initialButton, final boolean quantity) throws Exception { ++ try (final var f = new Fixture(NativeCookingControlStyle.TOUCH)) { ++ f.presentation.sourceCapability(true); ++ final var commands = new ArrayList(); ++ final var clicks = new ArrayList>(); ++ f.networking.when(() -> ClientPlayNetworking.send(any())).thenAnswer(call -> { ++ final var payload = call.getArgument(0); ++ if (payload instanceof NativeCookingSourcePayload source) commands.add(source.action()); ++ else fail("Unexpected source-input payload: " + payload); ++ return null; ++ }); ++ doAnswer(call -> { ++ final ServerboundContainerClickPacket packet = call.getArgument(0); ++ assertEquals(ContainerInput.QUICK_CRAFT, packet.containerInput()); ++ clicks.add(List.of((int) packet.slotNum(), (int) packet.buttonNum())); ++ return null; ++ }).when(f.owner).send(any(ServerboundContainerClickPacket.class)); ++ final var next = message(2, true); ++ final var program = JsonParser.parseString(next.program()).getAsJsonObject(); ++ program.getAsJsonObject("definitions").getAsJsonObject("furnace.slot").getAsJsonObject("value") ++ .getAsJsonArray("button_mappings").add(JsonParser.parseString(""" ++ {"to_button_id":"button.shape_drawing","mapping_type":"pressed"} ++ """)); ++ final var definitions = program.getAsJsonObject("definitions"); ++ definitions.getAsJsonObject("furnace.slot").getAsJsonObject("value").add("bindings", JsonParser.parseString(""" ++ [{"binding_type":"collection_details","binding_collection_name":"$item_collection_name"}] ++ """)); ++ for (final var row : definitions.getAsJsonObject("furnace.furnace_panel").getAsJsonObject("value").getAsJsonArray("controls")) { ++ for (final var entry : row.getAsJsonObject().entrySet()) { ++ final var control = entry.getValue().getAsJsonObject(); ++ if (control.has("collection_name")) control.addProperty("$item_collection_name", control.get("collection_name").getAsString()); ++ } ++ } ++ if (quantity) { ++ final var builtin = JsonParser.parseString(NativeCookingPresentationTest.message(CookingUi.Family.FURNACE, 2).program()).getAsJsonObject(); ++ builtin.getAsJsonObject("definitions").entrySet().forEach(entry -> { ++ if (!definitions.has(entry.getKey())) definitions.add(entry.getKey(), entry.getValue().deepCopy()); ++ }); ++ builtin.getAsJsonObject("globals").entrySet().forEach(entry -> { ++ if (!program.getAsJsonObject("globals").has(entry.getKey())) ++ program.getAsJsonObject("globals").add(entry.getKey(), entry.getValue().deepCopy()); ++ }); ++ definitions.getAsJsonObject("furnace.furnace_panel").getAsJsonObject("value").getAsJsonArray("controls") ++ .add(JsonParser.parseString("{\"selected@common.inventory_selected_icon_button\":{}}")); ++ } ++ final var authored = new NativeCookingMessage(next.menuId(), next.sequence(), next.updateSequence(), next.menuRevision(), ++ next.inventoryRevision(), next.cursorRevision(), program.toString(), next.snapshot(), next.recipeItems()); ++ assertTrue(f.controller.accept(f.owner, f.menu, 5, authored, _ -> new ItemStack(Items.IRON_INGOT))); ++ f.menu.getSlot(31).set(new ItemStack(Items.IRON_INGOT, 9)); ++ final var font = mock(Font.class); ++ when(font.width(any(FormattedText.class))).thenAnswer(call -> ((FormattedText) call.getArgument(0)).getString().length() * 6); ++ f.screen.prepare(font, 640, 320); ++ final var source = pointer(initialButton, 127, 219); ++ assertEquals(31, f.screen.nativeHoveredSlot(source.x(), source.y()).index); ++ assertTrue(f.screen.mouseClicked(source, false)); ++ if (quantity) { ++ f.screen.advanceSourceInput(source.x(), source.y()); ++ f.clock.set(526_000_000L); ++ f.screen.advanceSourceInput(source.x(), source.y()); ++ f.screen.mouseMoved(143, 219); ++ f.clock.set(600_000_000L); ++ f.screen.advanceSourceInput(143, 219); ++ } ++ assertTrue(f.screen.mouseReleased(quantity ? pointer(initialButton, 143, 219) : source)); ++ assertEquals(new CookingUi.Selection("hotbar_items", 1), f.presentation.selectedSource()); ++ assertTrue(commands.isEmpty()); ++ assertTrue(clicks.isEmpty()); ++ assertEquals(9, f.menu.getSlot(31).getItem().getCount()); ++ assertTrue(f.menu.getCarried().isEmpty()); ++ if (quantity) f.selectedFeedback(7, 143, 219); ++ f.screen.mouseMoved(145, 219); ++ f.screen.mouseMoved(163, 219); ++ assertTrue(commands.isEmpty(), "Hover without contact must not distribute the selected source"); ++ assertTrue(f.screen.mouseClicked(pointer(MOUSE_BUTTON_LEFT, 145, 219), false)); ++ assertTrue(commands.isEmpty(), "The new contact retains its first destination"); ++ assertTrue(f.screen.mouseDragged(pointer(MOUSE_BUTTON_LEFT, 163, 219), 18, 0)); ++ assertEquals(1, commands.size()); ++ final var drawing = commands.getFirst(); ++ assertEquals(NativeCookingSourceAction.Kind.DRAW, drawing.kind()); ++ assertEquals(31, drawing.sourceSlot()); ++ assertEquals(32, drawing.destinationSlot()); ++ assertEquals(quantity ? 7 : 9, drawing.quantity()); ++ assertTrue(f.screen.mouseDragged(pointer(MOUSE_BUTTON_LEFT, 181, 219), 18, 0)); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_LEFT, 181, 219))); ++ assertEquals(List.of(List.of(33, 1), List.of(34, 1), List.of(-999, 2)), clicks); ++ assertEquals(new CookingUi.Selection("", -1), f.presentation.selectedSource()); ++ if (quantity) { ++ f.screen.advanceSourceInput(181, 219); ++ assertTrue(f.selectedDraws().isEmpty(), "Completed source selection must retire its authored image and count"); ++ } ++ assertEquals(9, f.menu.getSlot(31).getItem().getCount()); ++ assertTrue(f.menu.getCarried().isEmpty()); ++ verify(f.game.gameMode, never()).handleContainerInput(anyInt(), anyInt(), anyInt(), any(), any()); ++ assertTrue(f.presentation.result(new NativeCookingSourceResult(drawing, true, true))); ++ assertTrue(f.presentation.idle()); ++ } ++ } ++ + private static MouseButtonEvent pointer(final int button, final double x, final double y) { + return new MouseButtonEvent(x, y, new MouseButtonInfo(button, 0)); + } @@ -11755,10 +16491,110 @@ index 0000000..684afed + return pointer(button, this.recipeX, this.recipeY); + } + ++ List clearDraws() throws Exception { ++ final var field = BedrockNativeContainerScreen.class.getDeclaredField("draws"); ++ field.setAccessible(true); ++ @SuppressWarnings("unchecked") ++ final var draws = (List) field.get(this.screen); ++ return draws.stream().filter(draw -> draw.box().node().name().equals("clear_text_button")).toList(); ++ } ++ ++ List selectedDraws() throws Exception { ++ final var field = BedrockNativeContainerScreen.class.getDeclaredField("draws"); ++ field.setAccessible(true); ++ final var draws = (List) field.get(this.screen); ++ return draws.stream().filter(draw -> draw.box().node().properties().has("inventory_selected_slot")).toList(); ++ } ++ ++ void selectedFeedback(final int count, final int x, final int y) throws Exception { ++ final var selected = this.selectedDraws(); ++ final var image = selected.stream().filter(draw -> draw.box().node().string("renderer", "").equals("inventory_item_renderer")).findFirst().orElseThrow(); ++ final var label = selected.stream().filter(draw -> draw.box().node().string("inventory_count", "").equals("selected")).findFirst().orElseThrow(); ++ assertTrue(label.box().visibleBounds().width() > 0 && label.box().visibleBounds().height() > 0); ++ assertEquals(21, image.box().bounds().width()); ++ assertEquals(x - 1, image.box().bounds().x() + image.box().bounds().width() * .5F); ++ assertEquals(y - 2, image.box().bounds().y() + image.box().bounds().height() * .5F); ++ assertEquals(31, this.screen.nativeHoveredSlot(127, 219).index); ++ assertEquals(32, this.screen.nativeHoveredSlot(145, 219).index, "Selected images cannot replace the physical hit regions"); ++ assertFalse(image.box().node().properties().has("inventory_slot")); ++ final var graphics = mock(GuiGraphicsExtractor.class, withSettings().extraInterfaces(IBedrockGuiItemStyle.class)); ++ final var pose = new Matrix3x2fStack(8); ++ when(graphics.pose()).thenReturn(pose); ++ final var style = (IBedrockGuiItemStyle) graphics; ++ final var styles = new AtomicReference<>(NativeInventoryItemStyle.NORMAL); ++ when(style.stackanvil$itemStyle()).thenAnswer(_ -> styles.get()); ++ doAnswer(call -> { styles.set(call.getArgument(0)); return null; }).when(style).stackanvil$itemStyle(any(NativeInventoryItemStyle.class)); ++ doAnswer(call -> { ++ final ItemStack stack = call.getArgument(0); ++ assertEquals(count, stack.getCount()); ++ assertTrue(ItemStack.isSameItemSameComponents(this.menu.getSlot(31).getItem(), stack)); ++ assertEquals(image.box().bounds().x(), pose.m20()); ++ assertEquals(image.box().bounds().y(), pose.m21()); ++ assertEquals(.5F, styles.get().opacity()); ++ stack.setCount(1); ++ return null; ++ }).when(graphics).item(any(ItemStack.class), anyInt(), anyInt()); ++ final var extract = BedrockNativeContainerScreen.class.getDeclaredMethod("item", GuiGraphicsExtractor.class, NativeUiLayout.Box.class, int.class, int.class, float.class); ++ extract.setAccessible(true); ++ extract.invoke(this.screen, graphics, image.box(), x, y, .5F); ++ verify(graphics).item(any(ItemStack.class), eq(0), eq(0)); ++ final var renderer = mock(NativeUiRenderer.class); ++ when(renderer.styled(any(), any(Component.class))).thenAnswer(call -> call.getArgument(1)); ++ final var rendererField = BedrockNativeContainerScreen.class.getDeclaredField("renderer"); ++ rendererField.setAccessible(true); ++ final var original = rendererField.get(this.screen); ++ try { ++ rendererField.set(this.screen, renderer); ++ final var decorate = BedrockNativeContainerScreen.class.getDeclaredMethod("count", GuiGraphicsExtractor.class, NativeUiLayout.Box.class, float.class); ++ decorate.setAccessible(true); ++ decorate.invoke(this.screen, graphics, label.box(), .5F); ++ verify(renderer).label(eq(graphics), eq(label.box()), eq(.5F), argThat(text -> text.getString().equals(Integer.toString(count)))); ++ } finally { rendererField.set(this.screen, original); } ++ assertEquals(9, this.menu.getSlot(31).getItem().getCount()); ++ assertTrue(this.menu.getCarried().isEmpty()); ++ } ++ ++ EditBox nativeEditor(final String retained) throws Exception { ++ return this.nativeEditor(retained, false); ++ } ++ ++ EditBox nativeEditor(final String retained, final boolean exitHost) throws Exception { ++ final var next = message(2, true); ++ final var exported = JsonParser.parseString(NativeCookingPresentationTest.message(CookingUi.Family.FURNACE, 2).program()).getAsJsonObject(); ++ final var definitions = exported.getAsJsonObject("definitions"); ++ JsonParser.parseString(next.program()).getAsJsonObject().getAsJsonObject("definitions").entrySet() ++ .forEach(entry -> definitions.add(entry.getKey(), entry.getValue().deepCopy())); ++ if (exitHost) definitions.getAsJsonObject("furnace.furnace_screen").getAsJsonObject("value") ++ .add("button_mappings", JsonParser.parseString(""" ++ [{"from_button_id":"button.menu_cancel","to_button_id":"button.menu_exit","mapping_type":"global"}, ++ {"from_button_id":"button.menu_inventory_cancel","to_button_id":"button.menu_inventory_exit","mapping_type":"global"}] ++ """)); ++ final var panel = definitions.getAsJsonObject("furnace.furnace_panel").getAsJsonObject("value"); ++ panel.getAsJsonArray("controls").add(JsonParser.parseString(""" ++ {"editor@common.text_edit_control":{"anchor_from":"top_left","anchor_to":"top_left", ++ "offset":[20,20],"size":[120,20]}} ++ """)); ++ final var bound = new NativeCookingMessage(5, next.sequence(), next.updateSequence(), next.menuRevision(), ++ next.inventoryRevision(), next.cursorRevision(), exported.toString(), next.snapshot(), next.recipeItems()); ++ assertTrue(this.controller.accept(this.owner, this.menu, 5, bound, _ -> new ItemStack(Items.IRON_INGOT))); ++ this.presentation.searchCapability(true); ++ this.presentation.restoreQuery(retained); ++ this.networking.when(() -> ClientPlayNetworking.canSend(NativeCookingQueryPayload.TYPE)).thenReturn(true); ++ final var font = mock(Font.class); ++ when(font.plainSubstrByWidth(anyString(), anyInt())).thenAnswer(call -> call.getArgument(0)); ++ when(font.plainSubstrByWidth(anyString(), anyInt(), anyBoolean())).thenAnswer(call -> call.getArgument(0)); ++ when(font.width(anyString())).thenAnswer(call -> ((String) call.getArgument(0)).length() * 6); ++ final var fontField = Screen.class.getDeclaredField("font"); ++ fontField.setAccessible(true); ++ fontField.set(this.screen, font); ++ this.screen.prepare(font, 640, 320); ++ return this.screen.children().stream().filter(EditBox.class::isInstance).map(EditBox.class::cast).findFirst().orElseThrow(); ++ } ++ + boolean recipeSelected() throws Exception { + final var field = BedrockNativeContainerScreen.class.getDeclaredField("draws"); + field.setAccessible(true); -+ final var draws = (List) field.get(this.screen); ++ final var draws = (List) field.get(this.screen); + return draws.stream().anyMatch(draw -> draw.box().node().name().equals("selected_recipe")); + } + @@ -11771,7 +16607,9 @@ index 0000000..684afed + return player; + } + -+ Fixture() throws Exception { ++ Fixture() throws Exception { this(NativeCookingControlStyle.MOUSE_AND_KEYBOARD); } ++ ++ Fixture(final NativeCookingControlStyle controlStyle) throws Exception { + this.singleton = mockStatic(Minecraft.class); + this.networking = mockStatic(ClientPlayNetworking.class); + this.entrypoint = mockStatic(ViaFabricPlusBedrock.class); @@ -11786,6 +16624,7 @@ index 0000000..684afed + when(this.window.getGuiScale()).thenReturn(1); + this.game.player = this.player; + this.game.gameMode = mock(MultiPlayerGameMode.class); ++ when(this.owner.decoratedHashOpsGenenerator()).thenReturn(mock(HashedPatchMap.HashGenerator.class)); + final var options = mock(net.minecraft.client.Options.class); + final var optionsField = Minecraft.class.getDeclaredField("options"); + optionsField.setAccessible(true); @@ -11793,6 +16632,16 @@ index 0000000..684afed + final var pick = net.minecraft.client.Options.class.getDeclaredField("keyPickItem"); + pick.setAccessible(true); + pick.set(options, mock(net.minecraft.client.KeyMapping.class)); ++ final var dropKey = mock(KeyMapping.class); ++ when(dropKey.matches(any(KeyEvent.class))).thenAnswer(call -> ((KeyEvent) call.getArgument(0)).key() == InputConstants.KEY_Q); ++ final var dropField = Options.class.getDeclaredField("keyDrop"); ++ dropField.setAccessible(true); ++ dropField.set(options, dropKey); ++ final var inventoryKey = mock(KeyMapping.class); ++ when(inventoryKey.matches(any(KeyEvent.class))).thenAnswer(call -> ((KeyEvent) call.getArgument(0)).key() == InputConstants.KEY_E); ++ final var inventoryField = Options.class.getDeclaredField("keyInventory"); ++ inventoryField.setAccessible(true); ++ inventoryField.set(options, inventoryKey); + this.player.containerMenu = this.menu; + final var connection = mock(Connection.class); + when(connection.isConnected()).thenReturn(true); @@ -11821,6 +16670,10 @@ index 0000000..684afed + mock(com.viaversion.viafabricplus.api.settings.base.EnumSetting.class); + when(implementation.settings()).thenReturn(settings); + when(settings.nativeInventoryScreens()).thenReturn(this.nativeInventoryScreens); ++ @SuppressWarnings("unchecked") ++ final EnumSetting controls = mock(EnumSetting.class); ++ when(settings.nativeCookingControls()).thenReturn(controls); ++ when(controls.value()).thenReturn(controlStyle); + when(this.nativeInventoryScreens.isActive()).thenReturn(true); + when(settings.nativeUiScale()).thenReturn(uiScale); + when(uiScale.value()).thenReturn(com.viaversion.viafabricplus.bedrock.settings.base.NativeUiScale.DEFAULT); @@ -12045,6 +16898,12 @@ index 0000000..684afed + } + + void navigation(final long update, final int delta, final int tab, final int layout, final boolean forced, final NavigationHost host) { ++ this.navigation(update, delta, tab, layout, forced, host, ++ delta < 0 ? "button.left_panel_tab_decrement" : "button.left_panel_tab_increment"); ++ } ++ ++ void navigation(final long update, final int delta, final int tab, final int layout, final boolean forced, ++ final NavigationHost host, final String action) { + final var source = message(update, true); + final var exported = JsonParser.parseString(source.program()).getAsJsonObject(); + final var recipe = exported.getAsJsonObject("definitions").getAsJsonObject("furnace.recipe").getAsJsonObject("value"); @@ -12065,7 +16924,7 @@ index 0000000..684afed + {"from_button_id":"button.menu_ok","mapping_type":"focused"}]} + """).getAsJsonObject(); + for (final var value : control.getAsJsonArray("button_mappings")) -+ value.getAsJsonObject().addProperty("to_button_id", delta < 0 ? "button.left_panel_tab_decrement" : "button.left_panel_tab_increment"); ++ value.getAsJsonObject().addProperty("to_button_id", action); + if (host == NavigationHost.VIEW_TABS || host == NavigationHost.FOCUSED_VIEW_TABS) { + for (final var value : control.getAsJsonArray("button_mappings")) { + value.getAsJsonObject().addProperty("to_button_id", delta < 0 ? "button.menu_tab_left" : "button.menu_tab_right"); @@ -12115,6 +16974,60 @@ index 0000000..684afed + } + } + ++ private static NativeCookingMessage messageWithHostAction(final String target, final boolean consume) { ++ final var next = message(2, true); ++ final var program = JsonParser.parseString(next.program()).getAsJsonObject(); ++ final var mappings = new JsonArray(); ++ for (final String source : List.of("button.menu_select", "button.menu_ok")) { ++ final var mapping = new JsonObject(); ++ mapping.addProperty("from_button_id", source); ++ mapping.addProperty("to_button_id", target); ++ mapping.addProperty("mapping_type", "pressed"); ++ mapping.addProperty("consume_event", consume); ++ mappings.add(mapping); ++ } ++ program.getAsJsonObject("definitions").getAsJsonObject("furnace.furnace_screen").getAsJsonObject("value") ++ .add("button_mappings", mappings); ++ return new NativeCookingMessage(next.menuId(), next.sequence(), next.updateSequence(), next.menuRevision(), ++ next.inventoryRevision(), next.cursorRevision(), program.toString(), next.snapshot(), next.recipeItems()); ++ } ++ ++ private static NativeCookingMessage resetMessage(final long update, final boolean selected) { ++ final var source = message(update, true); ++ final var exported = JsonParser.parseString(source.program()).getAsJsonObject(); ++ final var definitions = exported.getAsJsonObject("definitions"); ++ definitions.getAsJsonObject("furnace.recipe").getAsJsonObject("value").add("controls", JsonParser.parseString(""" ++ [{"selected_recipe":{"type":"panel","bindings":[ ++ {"binding_name":"#is_recipe_selected_slot","binding_name_override":"#visible"}]}}] ++ """)); ++ final var slot = definitions.getAsJsonObject("furnace.slot").deepCopy(); ++ slot.getAsJsonObject("value").add("property_bag", JsonParser.parseString(""" ++ {"#collection_name":"hotbar_items","#collection_index":0} ++ """)); ++ slot.getAsJsonObject("value").getAsJsonArray("button_mappings").add(JsonParser.parseString(""" ++ {"to_button_id":"button.container_slot_hovered","mapping_type":"pressed"} ++ """)); ++ definitions.add("furnace.bundle_slot", slot); ++ final var controls = definitions.getAsJsonObject("furnace.furnace_panel").getAsJsonObject("value").getAsJsonArray("controls"); ++ controls.asList().stream().map(element -> element.getAsJsonObject()).filter(control -> control.has("hotbar_items")) ++ .findFirst().orElseThrow().getAsJsonObject("hotbar_items").addProperty("grid_item_template", "furnace.bundle_slot"); ++ controls.add(JsonParser.parseString(""" ++ {"reset":{"type":"input_panel","layer":-1,"size":[640,320],"button_mappings":[ ++ {"from_button_id":"button.menu_select","to_button_id":"button.container_reset_held","mapping_type":"pressed"}, ++ {"from_button_id":"button.menu_secondary_select","to_button_id":"button.container_reset_held","mapping_type":"pressed"}, ++ {"from_button_id":"button.menu_ok","to_button_id":"button.container_reset_held","mapping_type":"pressed"}, ++ {"from_button_id":"button.menu_right","to_button_id":"button.container_reset_held","mapping_type":"global"}]}} ++ """)); ++ final var view = source.view(); ++ final var book = view.book(); ++ final var changedBook = new CookingUi.Book(book.layout(), book.forcedRecipeBookLayout(), book.tab(), book.filtering(), ++ book.foodCount(), book.itemCount(), book.blockCount(), book.tabLabel(), book.searchText(), ++ new CookingUi.Selection(selected ? "recipe_book" : "", selected ? 0 : -1), book.catalog(), book.visibleRecipes()); ++ final var changedView = new CookingUi.MenuView(view.menuId(), view.family(), view.title(), view.slots(), view.cursor(), view.progress(), changedBook); ++ return NativeCookingMessage.create(CookingUi.fromProgram(exported), changedView, source.sequence(), update, ++ source.menuRevision(), source.inventoryRevision(), source.cursorRevision(), source.recipeItems()); ++ } ++ + private static NativeCookingMessage message(final long update, final boolean recipeVisible) { + final var source = NativeCookingPresentationTest.message(CookingUi.Family.FURNACE, update); + final var previous = source.view(); @@ -12166,7 +17079,8 @@ index 0000000..684afed + JsonParser.parseString( + """ + {"type":"button","size":[18,18],"button_mappings":[ -+ {"from_button_id":"button.menu_select","to_button_id":"button.container_take_all_place_all","mapping_type":"pressed"}]} ++ {"from_button_id":"button.menu_select","to_button_id":"button.container_take_all_place_all","mapping_type":"pressed"}, ++ {"from_button_id":"button.menu_secondary_select","to_button_id":"button.container_take_half_place_one","mapping_type":"pressed"}]} + """) + .getAsJsonObject())); + definitions.add( @@ -12240,11 +17154,279 @@ index 0000000..684afed + return CookingUi.fromProgram(exported); + } + -+ private static JsonObject definition(final JsonObject value) { -+ final var definition = new JsonObject(); -+ definition.addProperty("parent", ""); -+ definition.add("value", value); -+ return definition; ++ private static JsonObject definition(final JsonObject value) { ++ final var definition = new JsonObject(); ++ definition.addProperty("parent", ""); ++ definition.add("value", value); ++ return definition; ++ } ++} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingSourceInputTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingSourceInputTest.java +new file mode 100644 +index 0000000..58e93f7 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingSourceInputTest.java +@@ -0,0 +1,262 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.container; ++ ++import com.viaversion.viafabricplus.bedrock.screen.container.NativeInventorySourceState.Source; ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiInput; ++import java.util.ArrayList; ++import java.util.List; ++import java.util.Map; ++import net.raphimc.viabedrock.api.model.NativeCookingSourceAction; ++import net.raphimc.viabedrock.api.model.NativeCookingSourceResult; ++import net.raphimc.viabedrock.api.resourcepack.ui.CookingUi; ++import net.minecraft.world.item.ItemStack; ++import net.minecraft.world.item.Items; ++import org.junit.jupiter.api.BeforeAll; ++import org.junit.jupiter.api.Test; ++import org.junit.jupiter.params.ParameterizedTest; ++import org.junit.jupiter.params.provider.ValueSource; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++final class NativeCookingSourceInputTest { ++ private static final Source SOURCE = new Source("furnace_ingredient_items", 0); ++ private static final Source FIRST = new Source("hotbar_items", 6); ++ private static final Source SECOND = new Source("hotbar_items", 7); ++ private static final Source THIRD = new Source("hotbar_items", 8); ++ ++ @BeforeAll ++ static void bootstrap() throws Exception { NativeCookingPresentationTest.bootstrap(); } ++ ++ @ParameterizedTest ++ @ValueSource(ints = {1, 2}) ++ void sourceSelectionLeavesItemsInPlaceAndDrawingStartsWithTheTwoRetainedDestinations(final int previous) throws Exception { ++ final var fixture = new Fixture(); ++ fixture.select(); ++ assertEquals(SOURCE, fixture.sources.primary()); ++ assertTrue(fixture.commands.isEmpty()); ++ fixture.shape(FIRST); ++ assertTrue(fixture.commands.isEmpty(), "The first destination only establishes drawing history"); ++ fixture.shape(SECOND); ++ fixture.shape(THIRD); ++ fixture.shape(SECOND); ++ final var start = fixture.commands.getFirst(); ++ assertEquals(NativeCookingSourceAction.Kind.DRAW, start.kind()); ++ assertEquals(0, start.sourceSlot()); ++ assertEquals(4, start.destinationSlot()); ++ assertEquals(9, start.quantity()); ++ assertEquals(List.of(new Click(5, 1), new Click(6, 1), new Click(5, 1)), fixture.clicks); ++ assertTrue(fixture.presentation.sourcePending()); ++ assertFalse(fixture.presentation.source(0, 6, 1, NativeCookingSourceAction.Kind.TAP, ++ _ -> fail("Drawing must keep its action owner while admission waits"))); ++ assertTrue(fixture.input.event(button("container_take_half_place_one", previous, 0), THIRD, true)); ++ assertEquals(List.of(new Click(5, 1), new Click(6, 1), new Click(5, 1), new Click(-999, 2)), fixture.clicks); ++ assertEquals(NativeInventorySourceState.NONE, fixture.sources.primary()); ++ assertTrue(fixture.presentation.sourcePending(), "Physical completion must still wait for its original command reply"); ++ assertTrue(fixture.presentation.result(new NativeCookingSourceResult(start, true, true))); ++ assertTrue(fixture.presentation.idle()); ++ assertFalse(fixture.input.finish(), "A second release cannot send another completion"); ++ } ++ ++ @Test ++ void arrowHoldPreservesTheLatestDestinationAndOnlyItsMatchingReleaseUnblocksEntry() throws Exception { ++ final var fixture = new Fixture(); ++ fixture.select(); ++ fixture.input.control(button("arrow_key", 2, 1)); ++ fixture.shape(FIRST); ++ fixture.input.control(button("arrow_key", 2, 0)); ++ fixture.shape(SECOND); ++ assertTrue(fixture.commands.isEmpty()); ++ fixture.input.control(button("arrow_key", 1, 0)); ++ fixture.shape(THIRD); ++ assertEquals(5, fixture.commands.getFirst().destinationSlot()); ++ assertEquals(List.of(new Click(6, 1)), fixture.clicks); ++ fixture.input.control(button("arrow_key", 0, 1)); ++ fixture.shape(FIRST); ++ assertEquals(List.of(new Click(6, 1), new Click(4, 1)), fixture.clicks, ++ "Arrow input blocks entry, not visits in an already admitted drawing operation"); ++ } ++ ++ @Test ++ void scrollbarAndCursorDropShareOneShapeBlockerAndAnOrdinaryPrePressClearsIt() throws Exception { ++ final var fixture = new Fixture(); ++ fixture.input.control(button("scrollbar_touch", 0, 1)); ++ fixture.shape(SOURCE); ++ fixture.input.event(button("container_take_all_place_all", 1, 0), SOURCE, true); ++ assertEquals(NativeInventorySourceState.NONE, fixture.sources.primary()); ++ fixture.input.control(button("cursor_drop_all", 0, 1)); ++ fixture.input.control(button("scrollbar_touch", 1, 0)); ++ fixture.shape(SOURCE); ++ fixture.input.event(button("container_take_all_place_all", 1, 0), SOURCE, true); ++ assertEquals(SOURCE, fixture.sources.primary(), "Either registered up callback clears the shared native flag"); ++ fixture.input.finish(); ++ fixture.input.control(button("scrollbar_touch", 0, 1)); ++ fixture.select(); ++ assertEquals(SOURCE, fixture.sources.primary(), "A valid ordinary press resets the blocker before source selection"); ++ } ++ ++ @Test ++ void selectedSourceTapsUseTheSameSnapshotOwnerAndDoNotMoveAnythingOnPress() throws Exception { ++ for (final boolean one : new boolean[]{false, true}) { ++ final var fixture = new Fixture(); ++ fixture.select(); ++ final String target = one ? "container_take_half_place_one" : "container_take_all_place_all"; ++ assertTrue(fixture.input.event(button(target, 2, 1), FIRST, true)); ++ assertTrue(fixture.commands.isEmpty()); ++ assertTrue(fixture.input.event(button(target, 1, 0), FIRST, true)); ++ final var tap = fixture.commands.getFirst(); ++ assertEquals(NativeCookingSourceAction.Kind.TAP, tap.kind()); ++ assertEquals(one ? 1 : 9, tap.quantity()); ++ assertEquals(NativeInventorySourceState.NONE, fixture.sources.primary()); ++ assertTrue(fixture.clicks.isEmpty(), "A tap uses the typed transfer owner without cursor quick-craft input"); ++ fixture.shape(SECOND); ++ assertEquals(1, fixture.commands.size(), "A pending tap must not admit another source operation"); ++ } ++ } ++ ++ @Test ++ void tappingTheOriginalSourceClearsSelectionWithoutAnInventoryCommand() throws Exception { ++ final var fixture = new Fixture(); ++ fixture.select(); ++ fixture.input.event(button("container_take_all_place_all", 0, 1), SOURCE, true); ++ fixture.input.event(button("container_take_all_place_all", 1, 0), SOURCE, true); ++ assertEquals(NativeInventorySourceState.NONE, fixture.sources.primary()); ++ assertTrue(fixture.commands.isEmpty()); ++ assertTrue(fixture.clicks.isEmpty()); ++ } ++ ++ @Test ++ void rejectedDrawingCannotConsumeNewVisitsOrReviveItsSelectedSource() throws Exception { ++ final var fixture = new Fixture(); ++ fixture.select(); ++ fixture.shape(FIRST); ++ fixture.shape(SECOND); ++ assertTrue(fixture.presentation.result(new NativeCookingSourceResult(fixture.commands.getFirst(), false, true))); ++ fixture.input.frame(); ++ assertEquals(NativeInventorySourceState.NONE, fixture.sources.primary()); ++ fixture.shape(THIRD); ++ assertEquals(1, fixture.commands.size()); ++ assertEquals(List.of(new Click(5, 1)), fixture.clicks); ++ } ++ ++ @Test ++ void sourceSelectionUsesTheReleasedCellEvenWhenItIsEmptyAndCanThenSelectAnotherSource() throws Exception { ++ final var fixture = new Fixture(); ++ assertTrue(fixture.input.event(button("container_take_all_place_all", 2, 1), SOURCE, true)); ++ assertTrue(fixture.input.event(button("container_take_all_place_all", 1, 0), FIRST, true)); ++ assertEquals(FIRST, fixture.sources.primary()); ++ assertTrue(fixture.commands.isEmpty()); ++ fixture.select(); ++ assertEquals(SOURCE, fixture.sources.primary()); ++ } ++ ++ @Test ++ void initialSecondaryInputPicksUpHalfOnlyOnReleaseUnlessShapeAlreadyAdmittedSelection() throws Exception { ++ for (final boolean shape : new boolean[]{false, true}) { ++ final var fixture = new Fixture(); ++ if (shape) fixture.shape(SOURCE); ++ assertTrue(fixture.input.event(button("container_take_half_place_one", 2, 1), SOURCE, true)); ++ assertTrue(fixture.pickups.isEmpty()); ++ assertTrue(fixture.commands.isEmpty()); ++ assertTrue(fixture.input.event(button("container_take_half_place_one", 1, 0), SOURCE, true)); ++ assertEquals(shape ? List.of() : List.of(new Click(0, 1)), fixture.pickups); ++ assertEquals(shape ? SOURCE : NativeInventorySourceState.NONE, fixture.sources.primary()); ++ assertTrue(fixture.commands.isEmpty()); ++ } ++ } ++ ++ @ParameterizedTest ++ @ValueSource(booleans = {false, true}) ++ void touchQuantityReleaseRetainsThePointerSelectedBudgetForTheNextTransfer(final boolean drawing) throws Exception { ++ final var fixture = new Fixture(); ++ fixture.shape(SOURCE); ++ fixture.input.event(button("container_take_all_place_all", 2, 1), SOURCE, true); ++ fixture.input.frame(0, 100, 100, 1, true); ++ fixture.input.frame(525_000_000L, 100, 100, 1, true); ++ assertEquals(NativeInventorySourceState.NONE, fixture.sources.primary(), "Entry is strictly after the native option threshold"); ++ fixture.input.frame(526_000_000L, 100, 100, 1, true); ++ fixture.input.frame(600_000_000L, 84, 100, 1, true); ++ assertTrue(fixture.input.event(button("container_take_all_place_all", 1, 0), SOURCE, true)); ++ fixture.input.frame(700_000_000L, 132, 100, 1, false); ++ assertEquals(SOURCE, fixture.sources.primary()); ++ assertTrue(fixture.commands.isEmpty(), "Quantity selection and release leave the real items in their source"); ++ assertTrue(fixture.pickups.isEmpty()); ++ if (drawing) { ++ fixture.shape(FIRST); ++ fixture.shape(SECOND); ++ } else { ++ fixture.input.event(button("container_take_all_place_all", 2, 1), FIRST, true); ++ fixture.input.event(button("container_take_all_place_all", 1, 0), FIRST, true); ++ } ++ final var action = fixture.commands.getFirst(); ++ assertEquals(drawing ? NativeCookingSourceAction.Kind.DRAW : NativeCookingSourceAction.Kind.TAP, action.kind()); ++ assertEquals(2, action.quantity(), "Native single-precision sine and truncation select two of nine at minus sixteen pixels"); ++ assertEquals(0, action.sourceSlot()); ++ assertEquals(4, action.destinationSlot()); ++ assertTrue(fixture.presentation.sourcePending()); ++ } ++ ++ @Test ++ void closingRetiresTouchQuantityWithoutMovingItemsOrReusingItsBudget() throws Exception { ++ final var fixture = new Fixture(); ++ fixture.select(); ++ fixture.input.event(button("container_take_all_place_all", 2, 1), SOURCE, true); ++ fixture.input.frame(0, 200, 200, 2, true); ++ fixture.input.frame(526_000_000L, 200, 200, 2, true); ++ fixture.input.frame(600_000_000L, 232, 200, 2, true); ++ fixture.input.event(button("container_take_all_place_all", 1, 0), SOURCE, true); ++ assertTrue(fixture.input.finish()); ++ assertTrue(fixture.commands.isEmpty()); ++ assertTrue(fixture.pickups.isEmpty()); ++ fixture.select(); ++ fixture.input.event(button("container_take_all_place_all", 2, 1), FIRST, true); ++ fixture.input.event(button("container_take_all_place_all", 1, 0), FIRST, true); ++ assertEquals(9, fixture.commands.getFirst().quantity(), "A later opening or completed operation cannot inherit a partial budget"); ++ } ++ ++ private record Click(int slot, int button) { } ++ ++ private static NativeUiInput.ButtonEvent button(final String target, final int previous, final int phase) { ++ return new NativeUiInput.ButtonEvent(0, NativeUiInput.symbol("button." + target), previous, phase, false, 2, ++ NativeUiInput.Scope.VIEW, false, false); ++ } ++ ++ private static final class Fixture { ++ final NativeInventorySourceState sources = new NativeInventorySourceState(); ++ final NativeCookingPresentation presentation; ++ final List commands = new ArrayList<>(); ++ final List clicks = new ArrayList<>(); ++ final List pickups = new ArrayList<>(); ++ final Map cells = Map.of(SOURCE, new NativeCookingSourceInput.Cell(0, 9, true), ++ FIRST, new NativeCookingSourceInput.Cell(4, 0, false), SECOND, new NativeCookingSourceInput.Cell(5, 0, false), ++ THIRD, new NativeCookingSourceInput.Cell(6, 0, false)); ++ final NativeCookingSourceInput input; ++ ++ Fixture() throws Exception { ++ final var message = NativeCookingPresentationTest.message(CookingUi.Family.FURNACE, 1); ++ this.presentation = new NativeCookingPresentation(message, Map.of(7, new ItemStack(Items.IRON_INGOT)), () -> 0); ++ this.sources.inputMode(2); ++ this.presentation.sourceCapability(true); ++ this.input = new NativeCookingSourceInput(this.sources, this.cells::get, new NativeCookingSourceInput.Transport() { ++ @Override public boolean available() { return presentation.idle() && presentation.sourcesAvailable(); } ++ @Override public boolean drawing() { return presentation.sourceDrawing(); } ++ @Override public boolean start(final int source, final int destination, final int quantity, final NativeCookingSourceAction.Kind kind) { ++ return presentation.source(source, destination, quantity, kind, commands::add); ++ } ++ @Override public void visit(final int slot) { clicks.add(new Click(slot, 1)); } ++ @Override public void pickup(final int slot, final int button) { pickups.add(new Click(slot, button)); } ++ @Override public void finish() { clicks.add(new Click(-999, 2)); presentation.finishSourceDrawing(); } ++ }); ++ } ++ ++ void select() { ++ assertTrue(this.input.event(button("container_take_all_place_all", 2, 1), SOURCE, true)); ++ assertTrue(this.input.event(button("container_take_all_place_all", 1, 0), SOURCE, true)); ++ } ++ ++ void shape(final Source source) { assertTrue(this.input.event(button("shape_drawing", 2, 1), source, true)); } + } +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeFurnacePreferenceStateTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeFurnacePreferenceStateTest.java @@ -12374,6 +17556,46 @@ index 0000000..9646a61 + } catch (final ReflectiveOperationException error) { throw new AssertionError(error); } + } +} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryHelpTextTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryHelpTextTest.java +new file mode 100644 +index 0000000..200b9d3 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryHelpTextTest.java +@@ -0,0 +1,34 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.container; ++ ++import com.viaversion.viaversion.libs.gson.JsonParser; ++import org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++class NativeInventoryHelpTextTest { ++ @Test ++ void inputNamesReplaceVisibleComponentTextWithoutChangingFormattingOrHiddenContent() { ++ final var scene = JsonParser.parseString(""" ++ {"root":{"type":"panel","children":[ ++ {"type":"label","text":"Press :_input_key.inventory:", ++ "text_component":{"text":"Press ","extra":[{"text":":_input_key.inventory:","color":"#ded605"}]}}, ++ {"type":"label","visible":false,"text":":unsupported:","text_component":":unsupported:"} ++ ]}} ++ """).getAsJsonObject(); ++ NativeInventoryHelpText.resolve(scene, token -> token.equals("_input_key.inventory") ? "$E" : null); ++ final var label = scene.getAsJsonObject("root").getAsJsonArray("children").get(0).getAsJsonObject(); ++ assertEquals("Press $E", label.get("text").getAsString()); ++ final var styled = label.getAsJsonObject("text_component").getAsJsonArray("extra").get(0).getAsJsonObject(); ++ assertEquals("$E", styled.get("text").getAsString()); ++ assertEquals("#ded605", styled.get("color").getAsString()); ++ final var hidden = scene.getAsJsonObject("root").getAsJsonArray("children").get(1).getAsJsonObject(); ++ assertEquals(":unsupported:", hidden.get("text").getAsString()); ++ hidden.addProperty("visible", true); ++ assertThrows(IllegalArgumentException.class, () -> NativeInventoryHelpText.resolve(scene, token -> null)); ++ } ++} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryItemPresentationTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryItemPresentationTest.java new file mode 100644 index 0000000..4242f7e @@ -12843,14 +18065,15 @@ index 0000000..2d543d3 +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventorySourceStateTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventorySourceStateTest.java new file mode 100644 -index 0000000..125a729 +index 0000000..2d48ac5 --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventorySourceStateTest.java -@@ -0,0 +1,222 @@ +@@ -0,0 +1,373 @@ +package com.viaversion.viafabricplus.bedrock.screen.container; + +import com.viaversion.viafabricplus.bedrock.screen.container.NativeInventorySourceState.Source; +import java.util.List; ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiInput; +import java.util.stream.IntStream; +import net.minecraft.SharedConstants; +import net.minecraft.core.component.DataComponentMap; @@ -12863,6 +18086,8 @@ index 0000000..125a729 +import net.raphimc.viabedrock.api.resourcepack.ui.BundleTooltipUi; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; ++import org.junit.jupiter.params.ParameterizedTest; ++import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.*; + @@ -12962,6 +18187,35 @@ index 0000000..125a729 + } + + @Test ++ void layoutChangePreventsAStaleHoverFromRestoringTheBundleAndKeepsTheSelectedSource() { ++ final var state = new NativeInventorySourceState(); ++ state.primary(HOTBAR); ++ state.hover(INVENTORY, true); ++ assertEquals("Inventory bundle", view(state).contents().primaryName()); ++ assertTrue(state.refresh(ignored -> false)); ++ assertEquals(0, view(state).mode()); ++ assertTrue(state.clearHover()); ++ assertFalse(state.refresh(INVENTORY::equals)); ++ assertEquals(0, view(state).mode(), "A layout reset must prevent stationary hover restoration"); ++ assertEquals(HOTBAR, state.selected(true)); ++ assertFalse(state.clearHover()); ++ } ++ ++ @ParameterizedTest ++ @ValueSource(booleans = {false, true}) ++ void ordinaryHoverExitControlsWhetherALaterItemUpdateCanOpenItsBundle(final boolean matchingExit) { ++ final var state = new NativeInventorySourceState(); ++ state.primary(HOTBAR); ++ state.hover(INVENTORY, false); ++ state.leave(matchingExit ? INVENTORY : new Source("inventory_items", 5)); ++ ++ assertEquals(!matchingExit, state.refresh(INVENTORY::equals)); ++ assertEquals(matchingExit ? 0 : 2, view(state).mode(), ++ "An item update cannot restore a tooltip after its ordinary slot hover ended"); ++ assertEquals(HOTBAR, state.selected(true)); ++ } ++ ++ @Test + void touchUpdatesClearMissingRetainedBundlesWithoutOpeningOneFromTheHoverSource() { + final var state = new NativeInventorySourceState(); + state.inputMode(2); @@ -12990,6 +18244,8 @@ index 0000000..125a729 + assertEquals("Inventory bundle", view(state).contents().primaryName()); + state.leave(new Source("container_items", 7)); + assertEquals(1, view(state).mode()); ++ assertEquals(NativeInventorySourceState.NONE, state.hover()); ++ assertEquals(HOTBAR, state.primary()); + assertTrue(state.hide()); + assertTrue(view(state).hidden()); + state.inputMode(1); @@ -12998,6 +18254,57 @@ index 0000000..125a729 + } + + @Test ++ void directionalBundleInputStopsAtRowEdgesWhileSequentialInputWrapsAcrossThem() { ++ final var state = new NativeInventorySourceState(); ++ final var contents = snapshot("Inventory bundle", 10); ++ final var layout = BundleTooltipLayout.resolve(contents.contents()); ++ state.hover(INVENTORY, true); ++ for (final var step : List.of(new Object[]{"scroll_right", 2}, new Object[]{"scroll_left", 2}, ++ new Object[]{"scroll_down", 6}, new Object[]{"scroll_left", 5}, new Object[]{"scroll_left", 4}, ++ new Object[]{"scroll_left", 4}, new Object[]{"inventory_left", 3}, new Object[]{"scroll_right", 3}, ++ new Object[]{"inventory_left", 2}, new Object[]{"inventory_left", 11}, new Object[]{"scroll_down", 11}, ++ new Object[]{"scroll_up", 7}, new Object[]{"inventory_right", 8}, new Object[]{"scroll_right", 9})) { ++ assertTrue(state.navigate(NativeUiInput.symbol("button." + step[0]), true, layout)); ++ assertEquals(step[1], state.view(_ -> contents, false).selectedCell(), step[0].toString()); ++ } ++ assertEquals(9, state.selectedCell(INVENTORY)); ++ assertEquals(9, state.selectedCell(new Source("combined_hotbar_and_inventory_items", 13))); ++ assertEquals(-1, state.selectedCell(HOTBAR), "Another source cannot use this bundle's selected cell"); ++ state.leave(INVENTORY); ++ assertEquals(-1, state.selectedCell(INVENTORY)); ++ } ++ ++ @Test ++ void sequentialInputRequiresAnEmptySelectedSourceAndExcludesTheHiddenItemsLabel() { ++ final var state = new NativeInventorySourceState(); ++ final var contents = snapshot("Inventory bundle", 13); ++ final var layout = BundleTooltipLayout.resolve(contents.contents()); ++ assertTrue(state.navigate(NativeUiInput.symbol("button.inventory_right"), true, null), ++ "An inactive desktop bundle callback still consumes sequential input"); ++ assertFalse(state.navigate(NativeUiInput.symbol("button.scroll_right"), true, null)); ++ state.hover(INVENTORY, true); ++ assertFalse(state.navigate(NativeUiInput.symbol("button.inventory_left"), false, layout)); ++ assertNull(state.view(_ -> contents, false).selectedCell()); ++ assertTrue(state.navigate(NativeUiInput.symbol("button.inventory_right"), true, layout)); ++ assertEquals(3, state.selectedCell(INVENTORY)); ++ assertEquals(12, layout.sourceSlots().get(state.selectedCell(INVENTORY))); ++ assertTrue(state.navigate(NativeUiInput.symbol("button.inventory_left"), true, layout)); ++ assertEquals(10, state.selectedCell(INVENTORY)); ++ assertEquals(5, layout.sourceSlots().get(state.selectedCell(INVENTORY))); ++ state.inputMode(3); ++ assertFalse(state.navigate(NativeUiInput.symbol("button.inventory_right"), true, layout)); ++ assertEquals(10, state.selectedCell(INVENTORY)); ++ assertTrue(state.navigate(NativeUiInput.symbol("button.scroll_up"), false, layout)); ++ assertEquals(6, state.selectedCell(INVENTORY), "Directional callbacks have a separate input-mode gate"); ++ state.inputMode(1); ++ assertTrue(state.navigate(NativeUiInput.symbol("button.inventory_left"), true, layout)); ++ assertEquals(5, state.selectedCell(INVENTORY)); ++ state.hover(HOTBAR, true); ++ assertEquals(-1, state.selectedCell(INVENTORY)); ++ assertEquals(-1, state.selectedCell(HOTBAR)); ++ } ++ ++ @Test + void selectingAPhysicalCellHidesTheTooltipAndRepeatedSelectionClearsItWithoutMovingItems() { + final var state = new NativeInventorySourceState(); + state.bundleOpened(INVENTORY); @@ -13057,6 +18364,72 @@ index 0000000..125a729 + assertTrue(view(state).details().isEmpty()); + } + ++ @Test ++ void touchArrowHoverExpiresAtSixSecondsAndRepeatedPressedPhasesDoNotRestartIt() { ++ final var state = new NativeInventorySourceState(); ++ state.inputMode(2); ++ state.arrowInput(arrow(2, 1, false), 10_000_000_000L); ++ assertTrue(view(state).persistentHoverText(), "Raw down reaches the callback even without activation"); ++ final long shown = state.revision(); ++ state.arrowInput(arrow(1, 1, true), 15_000_000_000L); ++ state.arrowInput(arrow(1, 0, true), 15_100_000_000L); ++ state.frame(15_999_999_900L); ++ assertTrue(view(state).persistentHoverText()); ++ assertEquals(shown, state.revision(), "An unchanged frame must not rebuild the scene"); ++ state.frame(16_000_000_000L); ++ assertFalse(view(state).persistentHoverText()); ++ final long expired = state.revision(); ++ state.frame(16_000_000_100L); ++ assertEquals(expired, state.revision()); ++ state.arrowInput(arrow(0, 1, true), 17_000_000_000L); ++ state.frame(22_999_999_900L); ++ assertTrue(view(state).persistentHoverText(), "A new down starts its own lifetime"); ++ state.frame(23_000_000_000L); ++ assertFalse(view(state).persistentHoverText()); ++ } ++ ++ @Test ++ void inputModeChangesClearPersistentHoverAndDesktopArrowInputCannotRestoreIt() { ++ final var state = new NativeInventorySourceState(); ++ for (final int mode : new int[]{1, 3}) { ++ state.inputMode(2); ++ state.arrowInput(arrow(0, 2, true), 0); ++ state.arrowInput(arrow(1, 0, true), 0); ++ assertFalse(view(state).persistentHoverText()); ++ state.arrowInput(arrow(0, 1, true), 1); ++ state.inputMode(2); ++ assertTrue(view(state).persistentHoverText(), "An unchanged mode must preserve the native flag"); ++ state.inputMode(mode); ++ assertFalse(view(state).persistentHoverText()); ++ state.arrowInput(arrow(0, 1, true), 2); ++ state.frame(3); ++ assertFalse(view(state).persistentHoverText()); ++ } ++ } ++ ++ @Test ++ void sourcePrePressRetiresPersistentTextButAbsentContextsPreserveItsLifetime() { ++ final var state = new NativeInventorySourceState(); ++ state.inputMode(2); ++ state.arrowInput(arrow(0, 1, true), 10); ++ final long shown = state.revision(); ++ state.prePress(new Source("", 4)); ++ state.prePress(new Source("inventory_items", -1)); ++ assertTrue(view(state).persistentHoverText()); ++ assertEquals(shown, state.revision()); ++ state.prePress(INVENTORY); ++ assertFalse(view(state).persistentHoverText()); ++ final long reset = state.revision(); ++ state.prePress(INVENTORY); ++ state.frame(6_000_000_010L); ++ assertEquals(reset, state.revision()); ++ } ++ ++ private static NativeUiInput.ButtonEvent arrow(final int previous, final int phase, final boolean transition) { ++ return new NativeUiInput.ButtonEvent(0, NativeUiInput.symbol("button.arrow_key"), previous, phase, transition, 0, ++ NativeUiInput.Scope.VIEW, false, false); ++ } ++ + private static BundleTooltipUi.View view(final NativeInventorySourceState state) { + return state.view(source -> snapshot(source.equals(INVENTORY) ? "Inventory bundle" : "Hotbar bundle", 5), false); + } @@ -13069,46 +18442,6 @@ index 0000000..125a729 + IntStream.range(0, size).mapToObj(_ -> new ItemStack(Items.IRON_INGOT)).toList()); + } +} -diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativePlayerHelpTextTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativePlayerHelpTextTest.java -new file mode 100644 -index 0000000..b9a8f60 ---- /dev/null -+++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativePlayerHelpTextTest.java -@@ -0,0 +1,34 @@ -+/* -+ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock -+ * Copyright (C) 2026 ViaVersion and contributors -+ * SPDX-License-Identifier: GPL-3.0-or-later -+ */ -+package com.viaversion.viafabricplus.bedrock.screen.container; -+ -+import com.viaversion.viaversion.libs.gson.JsonParser; -+import org.junit.jupiter.api.Test; -+ -+import static org.junit.jupiter.api.Assertions.*; -+ -+class NativePlayerHelpTextTest { -+ @Test -+ void inputNamesReplaceVisibleComponentTextWithoutChangingFormattingOrHiddenContent() { -+ final var scene = JsonParser.parseString(""" -+ {"root":{"type":"panel","children":[ -+ {"type":"label","text":"Press :_input_key.inventory:", -+ "text_component":{"text":"Press ","extra":[{"text":":_input_key.inventory:","color":"#ded605"}]}}, -+ {"type":"label","visible":false,"text":":unsupported:","text_component":":unsupported:"} -+ ]}} -+ """).getAsJsonObject(); -+ NativePlayerHelpText.resolve(scene, token -> token.equals("_input_key.inventory") ? "$E" : null); -+ final var label = scene.getAsJsonObject("root").getAsJsonArray("children").get(0).getAsJsonObject(); -+ assertEquals("Press $E", label.get("text").getAsString()); -+ final var styled = label.getAsJsonObject("text_component").getAsJsonArray("extra").get(0).getAsJsonObject(); -+ assertEquals("$E", styled.get("text").getAsString()); -+ assertEquals("#ded605", styled.get("color").getAsString()); -+ final var hidden = scene.getAsJsonObject("root").getAsJsonArray("children").get(1).getAsJsonObject(); -+ assertEquals(":unsupported:", hidden.get("text").getAsString()); -+ hidden.addProperty("visible", true); -+ assertThrows(IllegalArgumentException.class, () -> NativePlayerHelpText.resolve(scene, token -> null)); -+ } -+} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativePlayerInventoryLifetimeTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativePlayerInventoryLifetimeTest.java new file mode 100644 index 0000000..3875605 @@ -13259,10 +18592,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..950b348 +index 0000000..848df47 --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativePlayerPresentationTest.java -@@ -0,0 +1,267 @@ +@@ -0,0 +1,302 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -13274,6 +18607,8 @@ index 0000000..950b348 +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.JsonArray; ++import com.viaversion.viaversion.libs.gson.JsonParser; +import com.viaversion.viaversion.libs.gson.JsonPrimitive; +import com.viaversion.viaversion.util.GsonUtil; +import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiImages; @@ -13318,6 +18653,39 @@ index 0000000..950b348 + } + + @Test ++ void snapshotUpdatesRetainSearchWhileChangedResourceBytesReplaceTheAuthoredLabel() { ++ final var source = message(false, 1, 8, 1, "", false); ++ final var authored = JsonParser.parseString(source.program()).getAsJsonObject(); ++ final var host = authored.getAsJsonObject("definitions").getAsJsonObject("crafting.inventory_screen").getAsJsonObject("value"); ++ if (!host.has("controls")) host.add("controls", new JsonArray()); ++ final var extra = JsonParser.parseString(""" ++ {"resource_label":{"type":"label","text":"First resource title","size":[120,20]}} ++ """).getAsJsonObject(); ++ host.getAsJsonArray("controls").add(extra); ++ final String firstProgram = authored.toString(); ++ final var first = new NativePlayerInventoryMessage(0, 1, 8, 1, firstProgram, source.snapshot(), Map.of()); ++ final var presentation = new NativePlayerPresentation(first, Map.of(), Map.of(), () -> 0); ++ assertTrue(presentation.resolve(0, "", slots(), Map.of(), false).scene().toString().contains("First resource title")); ++ final var searched = message(false, 1, 8, 2, "oak", false); ++ assertTrue(presentation.update(new NativePlayerInventoryMessage(0, 1, 8, 2, ++ new String(firstProgram), searched.snapshot(), Map.of()), Map.of(), Map.of())); ++ assertEquals("oak", presentation.query()); ++ assertTrue(presentation.resolve(0, "", slots(), Map.of(), false).scene().toString().contains("First resource title")); ++ extra.getAsJsonObject("resource_label").addProperty("text", "Replacement resource title"); ++ assertTrue(presentation.update(new NativePlayerInventoryMessage(0, 1, 8, 3, ++ authored.toString(), searched.snapshot(), Map.of()), Map.of(), Map.of())); ++ final String replaced = presentation.resolve(0, "", slots(), Map.of(), false).scene().toString(); ++ assertTrue(replaced.contains("Replacement resource title")); ++ assertFalse(replaced.contains("First resource title")); ++ assertEquals("oak", presentation.query()); ++ assertThrows(RuntimeException.class, () -> presentation.update(new NativePlayerInventoryMessage( ++ 0, 1, 8, 4, "{", searched.snapshot(), Map.of()), Map.of(), Map.of())); ++ assertEquals(3, presentation.message().revision()); ++ assertEquals("oak", presentation.query()); ++ assertTrue(presentation.resolve(0, "", slots(), Map.of(), false).scene().toString().contains("Replacement resource title")); ++ } ++ ++ @Test + void craftingPreviewsStaySeparateFromRealSlotsAndSurviveSearchWithoutCrossingOpenings() { + final var source = message(false, 1, 8, 1, "plank", false); + final var wireGhosts = Map.of( @@ -13532,10 +18900,10 @@ index 0000000..950b348 +} 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..b337faa +index 0000000..f1efb9c --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativePlayerScreenTest.java -@@ -0,0 +1,947 @@ +@@ -0,0 +1,1145 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -13630,6 +18998,78 @@ index 0000000..b337faa + NativePlayerPresentationTest.bootstrap(); + } + ++ @ParameterizedTest ++ @MethodSource("catalogButtons") ++ void authoredCursorDropsUseOneOrdinaryRequestAndKeepPendingOrEmptyCursorsSilent(final int button) 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 owner = mock(ClientPacketListener.class); ++ final var ordinary = mock(AbstractContainerScreen.class); ++ final var options = mock(Options.class); ++ final var font = mock(Font.class); ++ final var clock = new AtomicLong(); ++ set(Minecraft.class, game, "gameMode", mock(MultiPlayerGameMode.class)); ++ when(ordinary.getMenu()).thenReturn(menu); ++ when(owner.getConnection()).thenReturn(mock(Connection.class)); ++ for (final String name : List.of("keyPickItem", "keyDrop", "keyInventory")) ++ set(Options.class, options, name, mock(KeyMapping.class)); ++ set(Minecraft.class, game, "options", options); ++ set(Minecraft.class, game, "gui", mock(Gui.class)); ++ try (final var singleton = mockStatic(Minecraft.class); ++ final var network = mockStatic(BedrockNativePlayerInventory.class)) { ++ singleton.when(Minecraft::getInstance).thenReturn(game); ++ final var ui = new PlayerInventoryUi(new ResourcePackStorage(List.of()).getJsonUiProgram()); ++ final var state = new PlayerInventoryUi.State(false, false, 2, 2, 4, 2, false, "", "All", "Crafting", ++ List.of(0, 0, 0, 0), List.of(), "", -1); ++ final var source = new NativePlayerInventoryMessage(0, 1, 8, 1, ui.exportProgram(false).toString(), ++ GsonUtil.getGson().toJson(state), Map.of()); ++ final var presentation = new NativePlayerPresentation(source, Map.of(), 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); ++ set(Screen.class, screen, "font", font); ++ try { ++ screen.prepare(font, 640, 360); ++ clock.set(1_000_000_000); ++ screen.prepare(font, 640, 360); ++ final var held = new ItemStack(Items.IRON_INGOT, 9); ++ menu.setCarried(held); ++ final var click = new MouseButtonEvent(630, 350, new MouseButtonInfo(button, 0)); ++ screen.mouseMoved(click.x(), click.y()); ++ assertTrue(screen.mouseClicked(click, false)); ++ assertTrue(screen.mouseClicked(click, false)); ++ assertTrue(screen.mouseReleased(click)); ++ final int clickButton = button == InputConstants.MOUSE_BUTTON_LEFT ? 0 : 1; ++ verify(game.gameMode).handleContainerInput(menu.containerId, -999, clickButton, ContainerInput.PICKUP, null); ++ assertSame(held, menu.getCarried(), "The authoritative request owner predicts cursor quantities"); ++ final var enter = new KeyEvent(InputConstants.KEY_RETURN, 0, 0); ++ assertTrue(screen.keyPressed(enter)); ++ assertTrue(screen.keyReleased(enter)); ++ verify(game.gameMode, times(clickButton == 0 ? 2 : 1)).handleContainerInput(menu.containerId, -999, 0, ContainerInput.PICKUP, null); ++ if (clickButton == 1) verify(game.gameMode).handleContainerInput(menu.containerId, -999, 1, ContainerInput.PICKUP, null); ++ clearInvocations(game.gameMode); ++ menu.setCarried(ItemStack.EMPTY); ++ assertTrue(screen.mouseClicked(click, false)); ++ assertTrue(screen.mouseReleased(click)); ++ menu.setCarried(held); ++ presentation.openHelp(); ++ assertTrue(screen.mouseClicked(click, false)); ++ assertTrue(screen.mouseReleased(click)); ++ verifyNoInteractions(game.gameMode); ++ assertTrue(presentation.closeHelp()); ++ presentation.query("iron", _ -> {}); ++ assertFalse(presentation.idle()); ++ assertTrue(screen.mouseClicked(click, false)); ++ assertTrue(screen.mouseReleased(click)); ++ verifyNoInteractions(game.gameMode); ++ assertSame(held, menu.getCarried()); ++ network.verifyNoInteractions(); ++ } finally { screen.discard(); } ++ } ++ } ++ + @Test + @SuppressWarnings("unchecked") + void countControlsReadGhostsBeforeTheItemDrawWhileRealItemsAndDraggingKeepPrecedence() throws Exception { @@ -14142,9 +19582,10 @@ index 0000000..b337faa + } + + @ParameterizedTest -+ @ValueSource(ints = {0, 1, 2, 3}) ++ @ValueSource(ints = {0, 1, 2, 3, 4}) + void bundleClicksUseAuthoredSourcesWithoutSendingASecondJavaInventoryAction(final int scenario) throws Exception { -+ final boolean cursorRoot = scenario % 2 == 0; ++ final boolean selectedBundle = scenario == 4; ++ final boolean cursorRoot = !selectedBundle && scenario % 2 == 0; + final boolean secondary = scenario >= 2; + final var game = mock(Minecraft.class); + final var player = mock(LocalPlayer.class); @@ -14189,6 +19630,7 @@ index 0000000..b337faa + openings.when(() -> BedrockPlayerInventory.opening(owner, menu)).thenReturn(1L); + network.when(() -> ClientPlayNetworking.canSend(NativePlayerInventoryActionPayload.TYPE)).thenReturn(true); + network.when(() -> ClientPlayNetworking.canSend(NativeBundleActionPayload.TYPE)).thenReturn(true); ++ network.when(() -> ClientPlayNetworking.canSend(NativeBundleActionPayload.SELECTION_TYPE)).thenReturn(true); + network.when(() -> ClientPlayNetworking.send(any(NativeBundleActionPayload.class))).thenAnswer(call -> { + sent.add(((NativeBundleActionPayload) call.getArgument(0)).action()); + return null; @@ -14211,6 +19653,24 @@ index 0000000..b337faa + secondary ? InputConstants.MOUSE_BUTTON_RIGHT : InputConstants.MOUSE_BUTTON_LEFT, 0)); + screen.mouseMoved(target.x(), target.y()); + screen.prepare(font, 640, 360); ++ if (selectedBundle) { ++ final var callbacks = BedrockNativeContainerScreen.class.getDeclaredMethod("componentEvents", NativeContainerInput.Result.class); ++ callbacks.setAccessible(true); ++ final var drawsField = BedrockNativeContainerScreen.class.getDeclaredField("draws"); ++ drawsField.setAccessible(true); ++ @SuppressWarnings("unchecked") ++ final var draws = (List) drawsField.get(screen); ++ final var inputField = BedrockNativeContainerScreen.class.getDeclaredField("nativeInput"); ++ inputField.setAccessible(true); ++ final var nativeInput = (NativeContainerInput) inputField.get(screen); ++ final var node = draws.stream().filter(draw -> nativeInput.enabled(draw.box().node()) ++ && draw.box().node().properties().has("inventory_slot") ++ && draw.box().node().properties().get("inventory_slot").getAsInt() == 9).findFirst().orElseThrow().box().node(); ++ final var right = new NativeUiInput.ButtonEvent(NativeUiInput.symbol("button.inventory_right"), ++ NativeUiInput.symbol("button.inventory_right"), 0, 1, true, 1, NativeUiInput.Scope.DEFAULT, false, false); ++ assertEquals(true, callbacks.invoke(screen, new NativeContainerInput.Result(true, ++ List.of(new NativeContainerInput.Event(node, right, null))))); ++ } + assertTrue(screen.mouseClicked(mouse, false)); + assertEquals(1, sent.size()); + assertTrue(screen.mouseReleased(mouse)); @@ -14222,7 +19682,8 @@ index 0000000..b337faa + "Waiting for an item reply must not manufacture a pointer leave"); + } + assertEquals(new NativeBundleAction(0, 1, 8, 1, 1, "inventory_items", 0, -+ secondary ? NativeBundleAction.Kind.SECONDARY : NativeBundleAction.Kind.PRIMARY, cursorRoot ? 0 : 2), sent.getFirst()); ++ secondary ? NativeBundleAction.Kind.SECONDARY : NativeBundleAction.Kind.PRIMARY, cursorRoot ? 0 : 2, ++ selectedBundle ? 3 : -1), sent.getFirst()); + assertFalse(presentation.idle()); + clock.set(2_000_000_000); + assertTrue(screen.mouseClicked(mouse, false)); @@ -14283,7 +19744,7 @@ index 0000000..b337faa + return bundle(0); + } + -+ private static ItemStack bundle(final int amount) { ++ static ItemStack bundle(final int amount) { + final var data = new CompoundTag(); + final var storage = new CompoundTag(); + storage.putInt("version", NativeItemStorageData.VERSION); @@ -14410,9 +19871,7 @@ index 0000000..b337faa + final var input = (NativeContainerInput) get(screen, "nativeInput"); + final var initialRoot = (NativeUiLayout.Box) get(screen, "root"); + final var target = NativeUiFocus.targets(initialRoot, node -> node.properties().has("recipe_actions")).getFirst(); -+ final var focus = NativeContainerInput.class.getDeclaredMethod("focus", NativeUiFocus.Target.class); -+ focus.setAccessible(true); -+ focus.invoke(input, target); ++ assertTrue(input.focus(target.box().node())); + final int selected = input.focused().properties().get("recipe_id").getAsInt(); + assertTrue(screen.mouseScrolled(first.x(), first.y(), 0, -180)); + final var geometry = (NativeContainerGeometry) get(screen, "geometry"); @@ -14459,6 +19918,113 @@ index 0000000..b337faa + } + } + ++ @ParameterizedTest ++ @ValueSource(booleans = {false, true}) ++ void dropKeysRetainTheScreenAndRespectCursorOwnership(final boolean creative) 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 owner = mock(ClientPacketListener.class); ++ final var ordinary = mock(AbstractContainerScreen.class); ++ final var options = mock(Options.class); ++ final var font = mock(Font.class); ++ final var clock = new AtomicLong(); ++ final var impl = mock(ViaFabricPlusBedrock.class); ++ when(impl.logger()).thenReturn(mock(Logger.class)); ++ game.player = player; ++ game.gameMode = mock(MultiPlayerGameMode.class); ++ player.containerMenu = menu; ++ when(ordinary.getMenu()).thenReturn(menu); ++ when(owner.getConnection()).thenReturn(mock(Connection.class)); ++ for (final String name : List.of("keyPickItem", "keyDrop", "keyInventory")) ++ set(Options.class, options, name, mock(KeyMapping.class)); ++ set(Minecraft.class, game, "options", options); ++ set(Minecraft.class, game, "gui", mock(Gui.class)); ++ when(options.keyDrop.matches(any(KeyEvent.class))).thenAnswer(call -> ((KeyEvent) call.getArgument(0)).key() == InputConstants.KEY_Q); ++ try (final var singleton = mockStatic(Minecraft.class); ++ final var entrypoint = mockStatic(ViaFabricPlusBedrock.class); ++ final var network = mockStatic(BedrockNativePlayerInventory.class)) { ++ singleton.when(Minecraft::getInstance).thenReturn(game); ++ entrypoint.when(ViaFabricPlusBedrock::impl).thenReturn(impl); ++ network.when(() -> BedrockNativePlayerInventory.retire(any())).thenCallRealMethod(); ++ final var ui = new PlayerInventoryUi(new ResourcePackStorage(List.of()).getJsonUiProgram()); ++ final var cell = new PlayerInventoryUi.Cell(0, 10, -1, 0, 0, 1, false, false, "", Map.of()); ++ final var state = new PlayerInventoryUi.State(false, creative, creative ? 3 : 2, 2, 5, 2, false, "", "All", "Crafting", ++ 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)), 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); ++ set(Screen.class, screen, "font", font); ++ try { ++ screen.prepare(font, 640, 360); ++ clock.set(1_000_000_000); ++ screen.prepare(font, 640, 360); ++ final var recipe = pointer(screen, draw -> draw.box().node().properties().has("recipe_actions")); ++ screen.mouseMoved(recipe.x(), recipe.y()); ++ final var drop = new KeyEvent(InputConstants.KEY_Q, 0, 0); ++ final var dropAll = new KeyEvent(InputConstants.KEY_Q, 0, InputConstants.MOD_CONTROL); ++ assertTrue(screen.keyPressed(drop)); ++ screen.keyReleased(drop); ++ assertTrue(presentation.idle(), "An empty-cursor recipe drop must retain its presentation"); ++ verifyNoInteractions(game.gameMode); ++ final var held = new ItemStack(Items.IRON_INGOT, 4); ++ menu.setCarried(held); ++ assertTrue(screen.keyPressed(dropAll)); ++ screen.keyReleased(drop); // The modifier can be released before its mapped key. ++ assertTrue(presentation.idle()); ++ verifyNoInteractions(game.gameMode); ++ assertTrue(screen.keyPressed(drop)); ++ assertTrue(screen.keyPressed(drop)); // A held key retains its original mapping until release. ++ screen.keyReleased(drop); ++ final var shifted = new KeyEvent(InputConstants.KEY_Q, 0, InputConstants.MOD_SHIFT); ++ assertTrue(screen.keyPressed(shifted)); ++ screen.keyReleased(shifted); ++ verify(game.gameMode, times(2)).handleContainerInput(0, -999, 1, ContainerInput.PICKUP, player); ++ verifyNoMoreInteractions(game.gameMode); ++ assertSame(held, menu.getCarried()); ++ assertTrue(presentation.idle()); ++ menu.getSlot(36).set(new ItemStack(Items.IRON_INGOT, 32)); ++ screen.prepare(font, 640, 360); ++ final var slot = pointer(screen, draw -> draw.box().node().type().equals("button") ++ && draw.box().node().properties().has("inventory_slot") ++ && draw.box().node().properties().get("inventory_slot").getAsInt() == 36); ++ screen.mouseMoved(slot.x(), slot.y()); ++ assertTrue(screen.keyPressed(drop)); ++ screen.keyReleased(drop); ++ assertTrue(screen.keyPressed(dropAll)); ++ screen.keyReleased(drop); // The modifier can be released before its mapped key. ++ verify(game.gameMode, times(3)).handleContainerInput(0, -999, 1, ContainerInput.PICKUP, player); ++ verifyNoMoreInteractions(game.gameMode); ++ assertEquals(32, menu.getSlot(36).getItem().getCount()); ++ screen.mouseMoved(0, 0); ++ assertTrue(screen.keyPressed(drop)); ++ screen.keyReleased(drop); ++ verifyNoMoreInteractions(game.gameMode); ++ menu.setCarried(ItemStack.EMPTY); ++ screen.mouseMoved(slot.x(), slot.y()); ++ assertTrue(screen.keyPressed(drop)); ++ screen.keyReleased(drop); ++ assertTrue(screen.keyPressed(dropAll)); ++ screen.keyReleased(drop); // The modifier can be released before its mapped key. ++ verify(game.gameMode).handleContainerInput(0, 36, 0, ContainerInput.THROW, player); ++ verify(game.gameMode).handleContainerInput(0, 36, 1, ContainerInput.THROW, player); ++ verifyNoMoreInteractions(game.gameMode); ++ menu.setCarried(held); ++ screen.mouseMoved(recipe.x(), recipe.y()); ++ presentation.query("oak", _ -> {}); ++ assertFalse(presentation.idle()); ++ assertTrue(screen.keyPressed(drop)); ++ screen.keyReleased(drop); ++ verifyNoMoreInteractions(game.gameMode); ++ assertEquals(4, held.getCount()); ++ } finally { screen.discard(); } ++ } ++ } ++ + private static Object get(final Object target, final String name) throws Exception { + final var field = target.getClass().getDeclaredField(name); + field.setAccessible(true); 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 c3ad2084..e9433807 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 @@ -21,6 +21,12 @@ Prerequisites: Regression coverage includes `BedrockNativeContainerScreenTest`, `NativeContainerButtonTest`, `NativeContainerGeometryTest`, `NativeContainerInputTest`, and `NativeContainerDrawsTest`. +Three receiver regressions cover malformed replacements, unsupported preparation, and failure after replacement. +Recovery restores the original ordinary screen with the same menu, carried stack, and slot contents. +Installed direct and ViaProxy controls inject a supported legacy replacement and a render failure into a real chest. +Both retain nine carried ingots and complete a server-accepted pickup and ordinary return. +These controls do not reproduce the reported WildFFA background or menu overlap failures. + An installed BDS control with Bedrock inventory screens disabled retains Java inventory, chest, furnace, and crafting screens. Pickup, return, and reopening preserve all 64 logs. BDS accepts both observed inventory requests without a synchronization timeout. @@ -43,6 +49,10 @@ Official Bedrock 1.26.60.5 local-world controls retain active Creative and Survi A Creative Ctrl+A selection survives a GUI-scale change. An inactive editor stays inactive through resizing. The first Escape removes the highlight; the next closes the inventory. Full Screen initialization and resize regressions preserve the same editor, registration, selection replacement, and inactive state. +Resize and GUI-scale rebuild failures use the ordinary-menu fallback, including presentation retirement before tick cleanup. +The resize regression retains the cursor stack and restores only the same still-open menu; it cannot revive a replacement menu. +An installed direct control retires a real player presentation during resize and retains its menu and three-item cursor through fallback. +Physical pickup and placement receive two successful BDS replies, and reopening retains all three items. This injected retirement does not reproduce the original inventory-background report. Installed direct and ViaProxy controls use ordinary typing and retain a Ctrl+A selection through an actual window resize. Both routes preserve inactive search, resume typing after Escape and blank clicks, and stop typing after recipe and slot selection. A read-only observer confirms one registered editor and SDL input ownership throughout these transitions. @@ -71,6 +81,36 @@ Default player and workbench graphs retain all 46 menu slots and real recipe row Help uses the native How to Play or Encyclopedia graph and the client key bindings. Screen regressions cover slot confirmation, keyboard Back, Escape, repeated Help openings, and a retained carried stack. Model regressions retain the opening and pending search across Help and source updates. +Cooking recipe drop keys share the player inventory drop owner. +Official 1.26.60.5 Furnace controls drop one carried item with Q and retain that stack with Ctrl+Q. +Both keys leave an empty recipe cursor alone. +Regressions distinguish recipe previews from real slots across the three default cooking geometries. +An admitted scene also covers held-key repeats and modifier release without extra requests. +Other default host actions retain their admission guards. +Registered directional callbacks navigate the retained bundle grid, with separate row and wrap rules. +The selected cell must resolve to the physical bundle slot before extraction and reply reconciliation. +Installed direct and ViaProxy controls each accept a nine-item pickup, two one-item drops, and a seven-item return. +Their cursor responses are 9, 8, 7, and 0. Reopening accounts for all nine items across inventory and dropped items. +A separate direct control retains seven carried items and two inventory items after the server rejects the return destination. +The installed controls use an admitted authored furnace fixture with normal guards. They do not establish complete default-screen support. + +Cooking uses that same Help lifetime with the native Furnace encyclopedia topic. +Regressions retain pending search, source updates, carried items, and the inventory owner across Back and Escape. +The selected topic starts visible and focused, and encyclopedia headings use their localized prefix and topic label. +Official 1.26.60.5 controls distinguish the selected chapter from keyboard and pointer focus. +Pointer movement clears the keyboard highlight and retains the navigation anchor. +Hovering another topic moves that anchor without selecting its chapter. +Arrow keys resume from the anchor, and confirmation selects the chapter. +Cached drawing retains the active keyboard highlight and avoids highlighting the parked pointer at the same time. +The release of the opening click preserves Help focus because the replacement view retains the physical pointer position. +Installed direct and ViaProxy controls match the scoped native green and gray focus pixels at 1280 × 720. +Both routes retain nine carried items through pointer handoff, resumed keyboard navigation, Back, Escape, and reopening. +Settled backdrop samples match the native opaque color. Selector layout, inline input glyphs, Java overlays, and complete screen parity remain open. +Installed direct and ViaProxy controls each open Furnace Help and return with Back and Escape while carrying nine items. +Each complete journal contains only the two accepted pickup and return transfers, followed by ordinary container closes. +Fresh openings, authoritative responses, and server inventory agree on all nine items. +These controls use an authored furnace fixture with normal admission guards and software rendering. +Other input modes, native multiplayer Help traffic, and complete visual parity remain unverified. Live direct and ViaProxy checks retain the real Survival recipe layout and its inactive row after reopening. Both routes open translated Help and return on Escape. Server responses confirm placement of the retained eight-log cursor stack. @@ -81,7 +121,17 @@ Physical bundle preview tables decode into owned Minecraft stacks and explicit t The default-screen regression retains an empty bundle alongside an inactive recipe. Installed direct and ViaProxy controls retain a server-issued empty bundle in Survival after reopening. Unknown snapshots restore ordinary controls. Player and workbench screens accept complete carried-bundle snapshots and desktop hover views. Touch selection and the full controller lifetime remain unfinished. -Unsupported visible glyphs, recipe auxiliary actions, and held-selection commands also restore ordinary controls. +Unsupported visible glyphs, `button.shape_drawing` recipe actions, and held-selection commands also restore ordinary controls. +Desktop drop keys follow the [native cursor behavior](../../../docs/native-player-inventory.md#actions-and-presentation) through the authored input router and keep the screen. +Screen regressions cover Survival and Creative slots, cursor ownership, held keys, modifier release order, outside controls, and pending commands. +Installed direct and ViaProxy controls each complete 14 server-accepted requests across Survival and Creative. +Six drops per route remove one carried item each. Four further drops use real slots with an empty cursor. +An 800-millisecond hold acts once. Outside controls and Ctrl+Q with a held stack add no requests. +Both routes retain the same screen, menu, and presentation, then reopen with the exact remaining items. +These checks include one immediate move from a real slot to a recipe before Q. +Full native pointer/key timing, workbench controls, other devices, and complete pixels remain unverified. +The installed drop artifact predates the pointer-coordinate change. A combined full-stack build also passes after reconciliation. +These builds include the private death-screen consumer. They do not establish a released artifact or complete death-screen parity. Rejected presentations remain retired until the next opening, so subsequent updates do not repeat the failed installation. Fixed recipe grids expand visible cells and retained keyboard neighbors over the complete catalog. Local window changes preserve the menu opening and authoritative revision. @@ -115,6 +165,13 @@ Both installed add-ons and the running proxy match the rebuilt artifact hashes. Bulk Survival recipe actions remain unresolved. Complete cooking integration, live transfers, and filled-bundle pixels also remain unresolved. +Player snapshot revisions retain the parsed UI while its program bytes are unchanged. +The presentation consumer decodes each accepted snapshot once. +Regression coverage retains search through changed resources and rejects malformed replacement bytes. +Installed E-opened Creative controls complete 24 physical queries on each route with program reuse. +JFR profiles no longer sample repeated program decoding in these controls. +Overall frame-rate gains and the reported modpack regression remain unverified. + Stable frames reuse the draw list, parsed hit regions, and input ancestor geometry. Changed pointer coordinates, component state, or animated opacity refresh the draw list. Toggle changes include keyboard tab selection. @@ -169,12 +226,26 @@ With one log available, an ordinary recipe click places the real input and shows Bedrock 1.26.60.5 provides the native selection and lifetime reference. Complete native pixels, fallback variant order, variant cycling, and broader recipe families remain unverified. -Container layout supplies the desktop UI pointer to shared cursor placement and invalidates active placement when the pointer moves. Input validation rejects enabled unsupported controls and repeats validation when a disabled control becomes enabled. +Container layout supplies the desktop UI pointer to shared cursor placement and invalidates active placement when the pointer moves. Initial hover after installation uses the same per-axis [shared pointer conversion](0029-share-native-ui-scene-rendering.pr.md) as later input. Input validation rejects enabled unsupported controls and repeats validation when a disabled control becomes enabled. Tooltip sources retain primary, pending, secondary and hover contexts independently of recipe selection. Desktop hover resolves the retained bundle against actual menu slots and native collection aliases. Regressions cover restoration, carried-item priority, source replacement, touch retention, alias leave events and physical-cell selection. Thirteen original source controls and 22 cell-input controls establish the ported state boundaries. Selected physical children request exclusive detail creations with their supplied native hover text. Popup snapshots retain independent lifetime and ignore late retirement of a replaced creation. Client retirement runs after animation traversal, and input reconciliation discards captures from replaced creations. Regressions cover these boundaries. The full action graph, transfer quantity, native detail visibility, child-description formatting, and installed renderer comparison remain open. Enabled unsupported controls still restore ordinary controls. Inventory hover events read the evaluated control property bag. A routed slot-event regression distinguishes the event source from render-slot annotations, observes retained updates and rejects missing or wrongly typed fields. Native 1.26.60.5 controls establish the retained bag and typed source getter. These typed sources also feed desktop bundle input; installed input parity remains unverified. +Retained bundle navigation separates row-bound directional input from wrapping previous/next input. +Original Bedrock 1.26.60.5 controls establish the padded cell range, input-mode gates, and the empty selected-stack requirement. +Player and workbench callbacks retain that cell through the optional selected-extraction transport. +Screen and backend regressions cover source ownership, pending input, sparse physical slots, and rejected transfers. +Cooking slot gestures use their own publication-bound bundle channel and retain pending selection across snapshots. +A matching reply either releases the current frame or waits for renewed authority. Buffered search, timeout, capability withdrawal, and late-reply sound ownership share that lifetime. +Screen regressions cover insertion and selected extraction without duplicate Java actions. Backend regressions cover rejection recovery and replacement openings. +Physical input bindings and the remaining default cooking host callbacks remain unfinished. +Pending cooking commands retain physical hover through captured-button release. Regressions preserve the source and selected cell while duplicate item input stays blocked. +Installed direct and ViaProxy controls insert two distinct stacks, select the older bundle entry, extract both stacks, and retain all items after reopening. +Stationary hover and cell selection survive a delayed server reply. An overlapping transfer sends no extra item request. +Each complete journal contains nine successful requests. These controls use an admitted authored furnace fixture with explicit navigation mappings. +They do not establish native physical bindings, complete default-screen support, or native pixels. + Desktop player/workbench bundle inputs use the optional core transport and share the presentation's request sequence, reply handling, timeout, and retirement with search and crafting. Real exported-program tests exercise insertion and default extraction with either cursor or clicked roots, including filled hover views and a bundle moved to the cursor. They check exact payload scope, buffered search, stale replies, desktop cell/hide callbacks, and suppression of duplicate Java actions while a request is pending. A frame-extraction regression covers physical bundle hover. The optional bundle-result channel plays a sound only for the matching pending bundle command. Duplicate, unrelated, retired, and wrong-kind replies cannot play it or release a different command. Playback uses current accepted Bedrock level-event resources, bank definitions, and captions. Original Bedrock 1.26.60.5 sound helper 144fe5060 and position helper 140ba2af0 select the actor AABB center with float arithmetic; 24 combined insertion-handler controls verify success and failure dispatch. Client regressions cover reply lifetime, resource selection, missing actors, and body-center coordinates. Installed direct and ViaProxy controls each observe seven level-event requests followed by decoded native PCM channel starts with the matching caption keys, including a full-bundle insertion failure. Ordinary pickups, placements, close, and reopen add no bundle sounds. Silent lab audio does not establish audible comparison or exact timing. @@ -182,6 +253,9 @@ The optional bundle-result channel plays a sound only for the matching pending b Installed direct and ViaProxy controls each complete ten server-accepted requests, including five native bundle commands. Screen audits observe the shared sequence through both insertion/extraction directions and its reset under a new opening after reopening. The screens stay native throughout. Installed add-on hashes match the rebuilt artifact, and the proxy transport classes match the current core. Source regressions restore bundle hover after temporary source removal and retain independent selection. +Matching hover exits prevent later item updates from opening a tooltip for a departed ordinary slot. +Original Bedrock 1.26.60.5 exit callbacks clear exact hover in every tooltip mode after desktop bundle-alias cleanup. +Regressions preserve unrelated hover, the selected source, and an open touch bundle. Real screen regressions preserve hover while pending commands suppress duplicate actions. Original Bedrock 1.26.60.5 source getter and update blocks cover 72 availability updates across desktop, touch, and gamepad profiles. Storage admission and intervening controller work remain supplied probe boundaries. @@ -190,3 +264,115 @@ The pointer stays still through predicted disappearance and tooltip restoration. These controls do not establish the official client's complete rejection path. Installed native-command rejection controls and their remaining limits are recorded in the [core inventory notes](../../viabedrock/deferred/0003-translate-server-authoritative-bedrock-requests.pr.md). Native wire grouping, larger rejected bundle plans, touch and retained detail gestures, full controller composition, and complete pixels remain unfinished. + +Cooking search uses the optional core query transport and shares ordering, acknowledgments, timeouts, and retirement with recipe commands. +The client queues the latest raw edit and preserves newer typing when an earlier reply arrives. +Closing retains an unsent edit or explicit clear for the next opening. Disconnect clears this client state. +A reply without the replacement snapshot still reaches the response timeout. +Bound cooking editors use the shared search widget and retain ordinary controls when a required binding or capability is unavailable. +Official Bedrock 1.26.60.5 desktop controls retain furnace search after reopening and keep smoker text independent. +Screen regressions use the exported 1.26.60.5 editor and its embedded X. +They cover active clearing during a pending query, continued typing, the first Escape, whitespace, and inactive retained text after resize. +Geometry regressions reject an unowned clear control and preserve front-to-back slot protection. +The matching native desktop controls establish these editor input lifetimes. +Current native registration tables and callback controls also establish layout increment/decrement transitions. +Screen regressions cover screen, panel, global-panel, and pointer-button dispatch, retained carried items, held input, matching snapshots, and forced-layout suppression. +Shared component events route cursor-drop input to the ordinary inventory request owner. +Screen regressions cover player pointer and keyboard delivery, held presses, empty and pending cursors, Help transitions, and admitted cooking pointer hosts. +Official 1.26.60.5 player and furnace desktop controls retain nine carried ingots through a blank-panel click, drop one on secondary input, and drop the remainder on primary input. An 800-millisecond secondary hold acts once. +Installed direct and ViaProxy [player controls](../../../docs/native-player-inventory.md#actions-and-presentation) each receive three successful matched replies and retain the screen, menu, presentation, and total item count. Empty-cursor input adds no requests. +Official 1.26.60.5 furnace controls also establish temporary search-hover text admission and inventory-key exclusion over that field. +Leaving before typing permits inventory-key closure. A first character starts editing that survives pointer departure and the first Escape. +Screen regressions distinguish temporary hover from editing and preserve cursor items through authored exit actions. +Installed direct and ViaProxy player controls admit physical text on hover, retain editing after Escape, and preserve the query after reopening. +Both routes close through E outside inactive search and Escape over inactive search. Inventory and cursor counts stay unchanged without item requests. +Cooking selected-source snapshots remain separate from ordinary recipe hover. Original 1.26.60.5 getter controls distinguish the primary and hover contexts. +The shared source controller also handles native bundle transitions that clear the primary context. +Screen regressions preserve ordinary recipe selection and retain local reset through unchanged model refreshes. +Layout changes clear the shared hover context and prevent a stale bundle tooltip from returning after source restoration. +Installed direct and ViaProxy controls use an admitted authored furnace scene with the normal admission guards. +Hover leaves the recipe unselected. A held recipe action retains selection after pointer exit, and release clears it. +Both routes retain the same screen, menu, and presentation. Server replies confirm all nine cobblestone in the furnace input. +Official 1.26.60.5 desktop holds establish a partial pickup from both hotbar and furnace ingredient slots. +Live controller observations retain a separate source and timer for quantity selection. Pointer exit can complete the pickup before physical release. +Original 1.26.60.5 View countdown and event-construction controls establish the [hold timing contract](../../../docs/native-cooking-actions.md#recipe-input). +The produced event passes unchanged through the original controller dispatcher and secondary-transition predicate in separate composition controls. +Item lookup and final state entry remain declared boundaries in those controls. +Core now retains the return and partial-pickup transaction phases across delayed responses and renewed stack IDs. +Desktop primary pickup now retains its source through the shared View countdown and a separate quantity timer. +Official 1.26.60.5 desktop Return controls enter quantity state 15 and end it on key release. Space over the same ordinary slot leaves items unchanged. +Return uses authored slot confirmation and a separate held-input callback. It shares the existing quantity request owner and retains the routed slot as its origin. +Original 1.26.60.5 producer and dispatcher controls establish Return's held-edge filters in all three input modes. +The production handler matches all 54 combinations of mode, phase, previous phase, and initial held state. +A release without the same component's prior press cannot clear Return or finish its quantity owner. +Real-menu regressions preserve the source and timer after unmatched releases, then complete once on the matching release. +Installed controls reveal a second Return mapping during a physical hold. Its first release has no prior press in that component. +The previous build completes at two items while Return remains down. Current direct and ViaProxy controls retain selection until the real release. +Each fixed route completes four accepted transfers and retains all nine items after reopening, with the same screen and menu throughout the gesture. +Screen regressions cover pickup, key repeats, completion while the hold owns input, duplicate release, and unchanged menu previews. Unsupported keyboard quick-move still restores ordinary controls. +Installed direct and ViaProxy Return controls each complete ten accepted requests across short pickup, partial and full holds, return, and reopening. +Both routes retain six items after the partial release and nine after the full release. Held input uses the loaded client's Return key constant. +These controls do not establish equal native timing, complete default-screen support, or native pixels. +Quantity previews leave the real menu unchanged. Delayed pickup snapshots cannot admit an overlapping query or recipe action. +Official 1.26.60.5 physical controls retain quantity selection while both buttons are held and complete it after secondary release before primary release. +Screen regressions cover that delivered release, an unmapped button, and duplicate completion. +Installed direct and ViaProxy controls retain selection during the second press and complete it before primary release. +Each route receives four accepted transfer replies and retains nine items after reopening. Exact native frame timing remains unverified. +Regressions cover cached completion, delayed frames, correction, capability loss, transport retirement, and one physical screen pickup. +Installed direct and ViaProxy controls verify short, partial, and full hotbar holds, partial ingredient pickup, and completion before physical release on pointer exit. +Each journal contains 18 successful transfers. Both routes retain all nine items with the same screen, menu, and presentation. +Official 1.26.60.5 desktop registries agree across furnace, smoker, and blast-furnace controllers. +Six shared controller targets have no registered callback in those cooking scenes. +Their consuming mappings now retain authored presentation without invoking crafting-screen handlers or Java item input. +Original producer and dispatcher controls cover 35 input cases. Screen regressions preserve carried items and pointer and keyboard input ownership. +Installed direct and ViaProxy controls retain the same screen, menu, and cursor through all six actions. +Each route sends only the pickup and return requests and retains nine items after reopening. +Unknown, registered but unfinished, and nonconsuming custom mappings still restore ordinary controls. +Default cooking host admission, other input modes, exact frame scheduling, and native graphical parity remain unfinished. + +Negotiated desktop drags submit the pressed cell and later destinations before release, including visits after the real cursor empties. +Client regressions cover both buttons, close, and a late release without another action. +A real-menu regression prevents Java from distributing the same items again on secondary release. +Installed direct and ViaProxy controls match the official 1.26.60.5 integrated-world distribution: nine in the first cell, four in each of two cells, then three in each of three cells. +Escape while held retains that distribution after reopening, including a control that holds incoming replies for about three seconds. +Each complete journal contains 18 successful requests across ordinary drag, delayed close, and secondary drag. +Secondary release retains one item per destination and six carried items. +The [cooking reference](../../../docs/native-cooking-actions.md#recipe-input) describes ownership and the remaining limits. +These controls use an admitted furnace fixture and do not establish default cooking-screen support. + +Original 1.26.60.5 component and controller controls execute source selection, drawing, and release for hotbar, inventory, and furnace ingredient sources. +Twelve controls cover ordinary drawing, earlier source drawing, and arrow-held admission through the native state machine and synchronous legacy inventory model. +Source-selection release clears an earlier drawing destination while retaining the selected item source. +The first drawing event retains its destination. The second distributes across the retained and current destinations. +Arrow-held events retain the latest destination without starting distribution. +These controls keep the cursor empty and emit no cursor-drag start signal through the native sender. +Control properties, source admission, item services, and atomic transfers remain supplied probe boundaries. +The negotiated Java input owner now sends snapshot-bound source taps, drawing, and terminal retirement through core's existing transfer queue. +The optional Touch style retains source selection and the native quantity budget without filling the real cursor. +Original authored selected-item controls receive a cached display snapshot and use the shared cursor layout. +Extraction copies the display item, so rendering cannot mutate the source or retained preview. +Regressions cover command ordering, stale replies, quantity input, selected image/count extraction, and retirement. +Installed direct and ViaProxy controls cover partial taps, drawing, delayed close, real server rejection, and accepted retry. +The [cooking reference](../../../docs/native-cooking-actions.md#validation) states the verified quantities and remaining limits. +Physical touch delivery, the request-backed native model, selected detail popups, Touch bundle input, and configurable trigger time remain unfinished. + +An unhandled release over an empty recipe panel retains drag ownership, including subsequent pointer entry without a held button. +A delivered slot release or qualifying reset callback ends the drag. Closing and fallback still retire it. +The delivered slot release can come from another button. Pressing that button preserves the retained allocation. +The real-menu regression covers both release buttons, duplicate release, unheld entry, and close. +Installed direct and ViaProxy controls match the selected native allocation through delayed replies and preserve every item after reopening. +Original 1.26.60.5 producer and dispatcher controls establish the raw reset phases. The client decoder matches all 216 recorded controls. +Every pressed phase updates empty-source context. A released phase completes an operation only when its previous phase differs from pressed. +Component consumption continues while inventory replies wait. A child can consume the press before a parent receives the release. +Completion uses the existing recipe, quantity, and drag owners without retiring the opening or duplicating a later release. +Screen regressions cover delayed recipe replies, source refresh, retained drag, and declined preference toggles. Held-quantity regressions preserve the original source through completion. +Installed direct and ViaProxy reset controls each complete seven accepted transfers with one drag start/end pair. +A release after the reset component receives its own press preserves the allocation. A later qualifying release ends it once. +Server inventory and reopened menus retain three stacks of three, without dropped items or a transfer after completion. +Registered arrow and scrollbar mappings now reach their existing source-state consumers. +Raw arrow aliases accompany ordinary focus input. Scrollbar press bookkeeping runs before capture, so dragging over an item cannot enter quantity selection. +Screen regressions check authored persistent-text visibility through release and expiry, and source selection after scrollbar release without moving items. +The original 1.26.60.5 producer, dispatcher, and flag callbacks pass 162 controls. Their callback tables match all three cooking registries. +Full default-screen preparation advances to the separate `coalesce_stack` guard in both control styles. +Broader source selection, other input modes, installed default-host workflows, and exact native frame timing remain unfinished. +Native multiplayer request grouping and delayed or rejected native drag behavior remain unverified. diff --git a/patches/viafabricplus-bedrock/upstreamable/0029-render-authored-forms-and-npc-dialogs.patch b/patches/viafabricplus-bedrock/upstreamable/0029-render-authored-forms-and-npc-dialogs.patch index 7558a051..75125ab9 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0029-render-authored-forms-and-npc-dialogs.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0029-render-authored-forms-and-npc-dialogs.patch @@ -1,4 +1,4 @@ -From b81cd4ed7cd4904976a727742d246ac2b6f5633a Mon Sep 17 00:00:00 2001 +From 78d638e3c8147b1310288adba1554fdeb06b7b31 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): render authored Bedrock forms and NPC dialogs @@ -212,10 +212,10 @@ Filtering rows must never renumber a response or send a second reply. create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/npc/BedrockNpcConversationTest.java diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java b/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java -index f64ad7c..9cb7400 100644 +index c05c123..a504ae7 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java -@@ -102,6 +102,8 @@ import org.apache.logging.log4j.LogManager; +@@ -103,6 +103,8 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import com.viaversion.viafabricplus.bedrock.screen.ui.BedrockHudOverlays; import com.viaversion.viafabricplus.bedrock.screen.ui.BedrockNativeUiHurt; @@ -224,7 +224,7 @@ index f64ad7c..9cb7400 100644 public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { -@@ -121,8 +123,10 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { +@@ -122,8 +124,10 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { public void onPreSettingsLoading() { com.viaversion.viafabricplus.bedrock.appearance.BedrockSkinChange.register(); com.viaversion.viafabricplus.bedrock.appearance.BedrockEmotePlayback.register(); 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 d6bf354f..c43ed5a1 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 00f166135e78a6a7debd8aa462e4091147242d2d Mon Sep 17 00:00:00 2001 +From 865868d2ae5b6d8992cdfc4f42ea67db0de160c6 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 @@ -946,6 +946,282 @@ pixel parity remain separate requirements. Retain factory creation identity in animation reconciliation so same-named replacements start fresh clocks throughout their child trees. Queue completed factory roots for hosts to retire after animation traversal. Keep destruction separate from opacity. Pinned Bedrock 1.26.60.5/protocol2225 execution of original141c64b70 with real virtual completion141c5e040 verifies exact current-control/ancestor name lookup, expired-parent termination and incomplete-animation gating in ten controls. Remove the broader descendant and qualified-suffix searches. A selected_item_details target does not match popup_tip_text after the separately verified factory rename; retain such unmatched faded creations until exclusive replacement. Hierarchy removal and native pixels remain outside these instruction controls. + +Keep OreUI typography separate from classic JSON UI's 1.5 display grid. +Load an independently owned, checksum-verified HarfBuzz engine and supply +FreeType callbacks from the game's existing library. Each engine owns its +font and callback arenas; closing one engine cannot invalidate another. +Bound font faces, shaped runs, CPU images and GPU atlas allocations. + +Shape at the requested size independently of glyph-cache generation. +Bedrock 1.26.60.5/protocol 2225's original FreeType-backed shaping and CPU +raster/distance functions establish selected glyph IDs, positioning and +cache bytes. An authored mark fixture at 13 pixels has advance 499 and +mark offset (-500,-1), which differ from scaling the 52-pixel run. Keep +that rounding before applying the display transform. Ordinary original +run coverage functions bind display size divided by generation size; +outlined/GPU paths retain their separate native parameter calculation. + +Submit float premultiplied tint, coverage and atlas coordinates through a +dedicated GUI pipeline. Its bitmap/CPU-distance fragment arithmetic is +checked against execution of the original Gameface shader IR with supplied +samples and inputs. Append-only atlas slots keep queued glyphs stable +through extraction. Skip uploads for fully clipped glyphs and retire all +pages on the render thread. Source bundles remain separate cache owners. + +The original native glyph-placement loop verifies the supplied-position +bearing and scale arithmetic. Headless controls exercise prepared images, +independent owners, actual Minecraft vertex-buffer emission, atlas reuse +and cleanup; software EGL covers selected fragment results. These checks +do not establish native CSS layout, fallback, bidi, atlas filtering, input, +complete screens, GPU frame timing, Iris behavior or platform game parity. + +Retain normalized vertical face metrics with each prepared OreUI run. +Resolve line boxes through the target's ceilf font span, explicit height +ceiling and roundf baseline/advance operations. Preserve negative leading +and half ties away from zero. Add the cached offset to the supplied +fractional content origin before glyph bearings, without snapping it again. + +Bedrock 1.26.60.5 cohtml.WindowsDesktop.dll SHA-256 +9ad9a26235c016e55fe68800cb3d00843a83840f9683cc478a2a6079d59b4669 +and RenoirCore.WindowsDesktop.dll SHA-256 +97954352dcbcab17cd503149c2832b49df3bf55b828edb8ac78b1bc41eff68a0 +supply the line-metric rules. Original Renoir 180068a70 feeds the Cohtml +18003ad00-18003ad9f slice in 72 controls. Eight original style-height +resolution controls and 14 rectangle/fragment-placement controls retain +explicit CRT math, supplied storage and graphics endpoints. DOM layout, +font fallback, complete screens and native pixel parity remain separate. + +Resolve OreUI content boxes with ordered border and padding subtraction +before node-selected snapping. The native rectangle path rounds float +coordinates and signed fractional extents separately; it is not CRT roundf +or independent absolute-edge rounding. Preserve unsnapped content origins. + +Bedrock 1.26.60.5 Cohtml SHA-256 +9ad9a26235c016e55fe68800cb3d00843a83840f9683cc478a2a6079d59b4669 +supplies the complete 1800643c0 policy and 18000a288 content-box functions. +Six original policy controls and twenty original content-box controls +execute with supplied node/style/layout storage and no callbacks. Java +content bounds match all twenty controls bit for bit. Text-paint wrapper +1800d4f40 snaps the content box before adding cached run offsets. These +controls do not establish CSS cascade, DOM layout, active game flags or +complete native screen pixels. + +Prepare ordinary outline-font images from the target Windows host defaults. +Use bitmap images below ten display pixels or when the face opts out of +distance fields. Otherwise retain the 52-pixel CPU distance cache and +shape independently at the requested display size. Keep explicit image +preparation for callers that resolve different run settings. + +Sixteen complete original Cohtml/Renoir controls verify face mode conversion +and algorithm selection at the target threshold. The source digests are +9ad9a26235c016e55fe68800cb3d00843a83840f9683cc478a2a6079d59b4669 +and 97954352dcbcab17cd503149c2832b49df3bf55b828edb8ac78b1bc41eff68a0. +Original Minecraft initialization supplies --disableSDFonGPU. Eight +executions of the shipped GUI-scale component establish a five-pixel +root size per GUI-scale unit, so body text crosses this boundary from +eight to sixteen pixels at scales one and two. Original glyph-kind +production, live view overrides and final draw bindings remain unverified. + +Keep ordinary OreUI measurement separate from full drawing substitutions. +Select nominal glyph advances for single characters or faces without +positioning, legacy pair kerning with native space exclusion where present, +and the native thirteen-feature GPOS measurement profile otherwise. Keep +Unicode-scalar output slots and consecutive-cluster float accumulation. +Full draw runs retain their existing shaping features and image caches. + +Bedrock 1.26.60.5/protocol 2225 RenoirCore.WindowsDesktop.dll SHA-256 +97954352dcbcab17cd503149c2832b49df3bf55b828edb8ac78b1bc41eff68a0 +supplies the ordinary dispatch, feature records and advance rules. Sixteen +original dispatch controls and the original feature-construction slice +establish the selection. Original FreeType and HarfBuzz execute on three +authored fonts, including ligatures, marks and legacy space pairs. Native +Minecraft Ten at twenty pixels measures fi as two characters while drawing +the ligature. Respawn is unchanged between the paths, so this distinction +does not explain its remaining layout difference. The complete measurement +wrapper/cache, inline layout, fallback and native screen pixels remain +unverified. Regression tests exercise the measured outputs and preserve +drawing substitutions across intervening measurement calls. + +Acquire the original OreUI animation images alongside fonts and text. +Register their exact paths in the checked bundle descriptor and return +copies to consumers. Decode bounded GIF frames with their original +durations, transparency, restore-to-background and restore-to-previous +behavior; reject unsupported frames rather than inventing an animation. + +The target death route references animation-074ed0ba8c16bb30e36c.gif. +Its original SHA-256 is +27d3ad3174cbe640acbed6346646ba1f8d69891dd2e8aaed63ccd8c6536270b3. +It contains ten 28x28 frames delivered at 100ms each. Independent ImageIO +and Pillow decodes agree on all ten composited ARGB frames. This +establishes software decoding, not native display sampling or timing. + +Construct ordinary single-font OreUI lines from preprocessed Unicode scalars +and independently measured advances. Add tracking only between scalars, +retain consumed whitespace ranges, explicit empty rows and soft-wrap +boundaries, and resolve alignment without rounding fragment positions. +Keep long-word accumulation in source float order without rescanning its +prefixes. Font fallback, mixed-font fragments, bidi, ellipsis, preprocessing +and parent CSS/flex sizing remain separate layout responsibilities. + +Bedrock 1.26.60.5/protocol 2225 cohtml.WindowsDesktop.dll SHA-256 +9ad9a26235c016e55fe68800cb3d00843a83840f9683cc478a2a6079d59b4669 +supplies the complete ordinary line builder and alignment functions. +Twenty-two retained controls establish wrapping, preserved/trailing spaces, +indentation, explicit endings, empty fragments, overflow and fractional +fit boundaries. One hundred additional short-sequence controls match all +compared Java ranges, widths, baselines, extents and alignment exactly. +Original layout helpers execute with supplied font maps/metrics, scalar +advances and style flags; only bounded allocation/free and CRT memory/math +endpoints are supplied. No complete CSS cascade or screen parity is claimed. + +Retain original source offsets during OreUI text preprocessing. The native +1.26.60.5 cohtml module (SHA-256 +9ad9a26235c016e55fe68800cb3d00843a83840f9683cc478a2a6079d59b4669) +normalizes CR/LF according to a caller-supplied policy before CSS whitespace +collapse. Supplementary characters retain their original UTF-16 offsets. +Normal and nowrap collapse only the native ASCII whitespace set and keep +the first source offset of a collapsed run. Pre and pre-wrap retain it. +Keep the trailing-whitespace reserve separate from the displayed scalar +stream: native fitting uses a quarter of the font size plus letter +spacing even when collapse removes the visible trailing space. + +Execute the original normalization and collapse routines at 0x18003a08c +and 0x18013c830 with well-formed Unicode, preallocated output vectors, +an initialized empty per-thread temporary-allocation context, and bounded +allocation/free and CRT memory endpoints. All 195 native scalar, source +mapping and float reserve outcomes match Java. Selected boundary fixtures +cover newline policies, supplementary characters, embedded controls, +nonbreaking Unicode spaces, and trailing-space fitting. A combined +preprocessing/line regression maps a wrapped line to its original source. +The Java boundary rejects unpaired UTF-16 surrogates instead of silently +replacing them during UTF-8 encoding. Native invalid-UTF8 behavior, +CSS selection, bidi, font fallback, mixed runs and complete screens remain +outside these controls. + +Assign ordinary OreUI fonts per unresolved scalar range instead of +replacing an entire string with one fallback face. Each ordered font +fills only missing nominal glyphs, but measures that entire unresolved +range before later fonts fill its remaining holes. This preserves +measurement context at fallback boundaries. Retain the last available +font's missing-glyph result when no face covers a character. Unavailable +font entries keep their original positions in the supplied list. + +Interior ASCII whitespace uses its neighbors' shared font when that +font covers the whitespace. Keep its advance while suppressing its draw +glyph, including at paragraph boundaries. Nominal coverage stays separate +from draw shaping. The shared text owner resolves font lifetimes again +at each provider call so its bounded face cache remains the only owner. + +Original 1.26.60.5 cohtml constructor 0x18003a9ec (module SHA-256 +9ad9a26235c016e55fe68800cb3d00843a83840f9683cc478a2a6079d59b4669) +executes preprocessing, ordered assignment, metrics and whitespace +reassignment through entry to the original line builder. Eighteen +controls supply explicit font coverage/advance/metric interfaces and +bounded allocation/CRT endpoints. They cover mixed ownership, contextual +advances, missing characters, whitespace, null slots, and selected +Greek/CJK/supplementary scalars. No assignment algorithm is replaced. + +A separate original Windows 1.26.60.5 font-parser control admits the +authored disjoint-coverage fallback fixture and establishes B glyph 1 with +advance 749/64 at 13 pixels. The native FreeType module SHA-256 is +97954352dcbcab17cd503149c2832b49df3bf55b828edb8ac78b1bc41eff68a0. +The Java engine integration retains primary a glyphs around that B. +CSS family selection, complex-script/bidi dispatch, mixed-font line +metrics and complete rendered screen parity remain outside these checks. + +Construct ordinary mixed-font lines with the selected native font identity +and per-face metrics. Font changes flush fragments and can wrap a word +at that boundary. Preserve each fragment's baseline and use the greatest +fragment advance for its row, including zero-width whitespace fragments. +Equal font identities coalesce across alias slots. Explicit terminal +newlines return to the primary slot for the final empty row. +Keep the existing single-font entry point and line constructor. + +Original 1.26.60.5 cohtml line construction at 0x18003951c, alignment at +0x18003c438 and extent query at 0x18003bee4 execute with supplied advances, +font maps, identities, metrics and style flags. Bounded allocation/free +and CRT memory endpoints do not replace the layout helpers. Module +SHA-256: 9ad9a26235c016e55fe68800cb3d00843a83840f9683cc478a2a6079d59b4669. +All 37 targeted mixed-font controls, 100 varied mixed-font sequences and +100 retained single-font sequences match compared Java fragment records, +row geometry and extents bit for bit. Selected independent native +fixtures retain regressions for font boundaries, aliases, unavailable +slots, whitespace heights and empty final rows. + +The packaged-engine fallback regression connects authored disjoint +coverage and per-face metrics to actual line construction. Original +Renoir scaling at 0x180068a70 and cohtml metric conversion establish +14-pixel line advance for that 13-pixel fixture, including native float +rounding before the ceiling. CSS policy selection, complex-script/bidi +layout, fitting and complete mixed-font screen rendering remain open. + +Resolve regular gameplay font families in the original locale-dependent +order before ordinary per-scalar assignment. Keep the caller's primary +family first and preserve unavailable slots. The ordered CJK and Arabic +families and the Mongolian/Syriac/Tamil tail are not interchangeable. +The API accepts native-case locale identifiers and resolves only the +registered 400-weight normal faces used by this gameplay path. +It does not infer a locale from a text's characters. + +Execute the unchanged locale-selection function and family constants in +shipped 1.26.60.5 gameplay-6de8e340eddced55490b.js, SHA-256 +9e7efe423b6cc8a1c1fa92756bd181675cffa51a4728bce386b0081cad1bb979. +Its CSS variable supplies the fallback list after the selected primary +family. Japanese and Korean use prefix checks, Chinese variants use exact +native identifiers, and Arabic requires the native ar_ prefix. + +Original target FreeType face parsing and character maps establish glyph +indices for a/CJK/b in Minecraft Seven and six Noto families. The ordinary +cohtml constructor then executes assignment with those coverage results +and explicitly supplied advance/metric interfaces. Thirteen locale and +unavailable-primary controls match the packaged engine's real family +order, glyph indices and selected slots, while preserving primary Latin. +The unused tail interfaces do not establish their font coverage. Complete +CSS cascade, weight/style substitution, complex-script/bidi dispatch, +consumer policy selection and rendered locale parity remain open. + +Apply the collapsed trailing-whitespace reserve to row widths before +alignment, rather than adding it to a visible glyph or fragment advance. +Each row admits the reserve only up to its available width, with the +first row's indent considered separately. Existing overflow is retained. +Keep negative reserves and the native 0.001 tolerance. The old layout +entry points retain their zero-reserve behavior. + +Original target cohtml 0x18003c4cc executes between original construction, +alignment and extent queries in 26 native controls. They establish full, +partial and rejected reserves, indents, negative widths, the exact +floating tolerance, empty input and mixed-font fragment preservation. +The shared regression compares row and fragment geometry with that +independent output. Full measurement-cache and fitting policy selection +remain outside this port. + +Convert host pointer input independently from native drawing scale. +Java26.3 GuiRenderer projects framebuffer dimensions divided by the +integer GUI scale, but MouseHandler uses ceiling-sized GUI width and +height. Retain each actual input axis factor in NativeUiViewport and +apply it consistently to clicks, release, movement, drag deltas and +scroll positions. Read the current unrounded pointer before native +render extraction. Native GuiData layout and physical drawing remain +unchanged. Explicit four-argument viewports retain uniform input units. + +A real authored-screen action regression routes MouseHandler coordinates +through the production adapter. The old conversion moves an inside point +past a button edge on each rounded axis, including a HiDPI host. Remove +the old test that treated input as the inverse of rendering; that equality +is false at ceiling-sized host dimensions. Existing versioned native +viewport-scale controls retain their separate layout contract. This fixes +host input ownership, not complete native screen/device parity. + +BREAKING CHANGE: Replace NativeUiViewport.pointer with pointerX or +pointerY. Host input conversion is axis-specific. + +Retain the native View hold countdown separately from controller gesture time. +Controller result bits start a binary32 0.3-second frame countdown, stop it, +or suppress both updates. Expiry is strictly negative; event data retain the +starting pointer and a strict five-unit displacement gate on each axis. +Original Bedrock 1.26.60.5 countdown and result-composition controls establish +these boundaries. Feature controllers own admission and completion. --- .../bedrock/ViaFabricPlusBedrock.java | 4 + .../appearance/BedrockBoneAnimation.java | 3 + @@ -965,14 +1241,15 @@ Pinned Bedrock 1.26.60.5/protocol2225 execution of original141c64b70 with real v .../render/MixinEntityRenderDispatcher.java | 21 +- .../render/MixinGuiEntityRenderer.java | 53 + .../render/MixinNativeHudOverlays.java | 34 + - .../render/MixinNativeUiExtraction.java | 40 + + .../render/MixinNativeUiExtraction.java | 43 + .../render/MixinNativeUiPictures.java | 72 ++ .../render/MixinNativeUiScissorArea.java | 20 + .../render/MixinNativeUiScissorRenderer.java | 42 + .../render/MixinNativeUiViewport.java | 25 + .../render/MixinPictureInPictureRenderer.java | 41 + + .../screen/GuiGraphicsExtractorAccess.java | 17 + .../BedrockActorMaterialDefinitions.java | 4 +- - .../render/BedrockActorRenderTypes.java | 65 +- + .../render/BedrockActorRenderTypes.java | 71 +- .../render/BedrockAnimatedEntityState.java | 16 + .../bedrock/render/BedrockEntityRenderer.java | 6 +- .../bedrock/render/BedrockGeometry.java | 4 +- @@ -985,6 +1262,18 @@ Pinned Bedrock 1.26.60.5/protocol2225 execution of original141c64b70 with real v .../render/font/BedrockBitmapPipeline.java | 49 + .../render/font/BedrockTrueTypeFont.java | 213 ++++ .../render/font/BedrockTrueTypeRaster.java | 375 ++++++ + .../bedrock/render/oreui/OreUiAnimation.java | 102 ++ + .../bedrock/render/oreui/OreUiAssets.java | 162 +++ + .../bedrock/render/oreui/OreUiBox.java | 42 + + .../bedrock/render/oreui/OreUiCharacters.java | 71 ++ + .../render/oreui/OreUiDistanceField.java | 157 +++ + .../bedrock/render/oreui/OreUiFont.java | 337 ++++++ + .../bedrock/render/oreui/OreUiFonts.java | 89 ++ + .../bedrock/render/oreui/OreUiGlyphs.java | 205 ++++ + .../bedrock/render/oreui/OreUiLines.java | 235 ++++ + .../bedrock/render/oreui/OreUiPipeline.java | 52 + + .../bedrock/render/oreui/OreUiShaper.java | 180 +++ + .../bedrock/render/oreui/OreUiText.java | 144 +++ .../bedrock/screen/ui/BedrockHudOverlays.java | 128 +++ .../screen/ui/BedrockNativeUiHurt.java | 67 ++ .../screen/ui/NativeHudOverlayState.java | 66 ++ @@ -1005,6 +1294,7 @@ Pinned Bedrock 1.26.60.5/protocol2225 execution of original141c64b70 with real v .../bedrock/screen/ui/NativeUiFocus.java | 329 ++++++ .../bedrock/screen/ui/NativeUiFont.java | 80 ++ .../bedrock/screen/ui/NativeUiGraphics.java | 56 + + .../bedrock/screen/ui/NativeUiHold.java | 42 + .../screen/ui/NativeUiHurtPayload.java | 22 + .../bedrock/screen/ui/NativeUiImageCache.java | 229 ++++ .../screen/ui/NativeUiImageRegions.java | 132 +++ @@ -1025,16 +1315,18 @@ Pinned Bedrock 1.26.60.5/protocol2225 execution of original141c64b70 with real v .../bedrock/screen/ui/NativeUiSlider.java | 123 ++ .../bedrock/screen/ui/NativeUiText.java | 114 ++ .../bedrock/screen/ui/NativeUiToggle.java | 94 ++ - .../bedrock/screen/ui/NativeUiViewport.java | 32 + + .../bedrock/screen/ui/NativeUiViewport.java | 47 + .../bedrock/settings/base/NativeUiScale.java | 27 + .../settings/impl/BedrockSettings.java | 7 + .../viafabricplus-bedrock/lang/en_us.json | 2 + .../shaders/core/bedrock_actor.fsh | 8 + .../shaders/core/bedrock_bitmap_font.fsh | 26 + .../shaders/core/bedrock_bitmap_font.vsh | 29 + + .../shaders/core/bedrock_oreui_font.fsh | 27 + + .../shaders/core/bedrock_oreui_font.vsh | 22 + .../shaders/core/bedrock_truetype_font.fsh | 10 + .../viafabricplus-bedrock.accesswidener | 4 + - .../viafabricplus-bedrock.mixins.json | 15 +- + .../viafabricplus-bedrock.mixins.json | 16 +- .../BedrockBitmapFontResourcesTest.java | 67 ++ .../BedrockGuiActorAppearanceTest.java | 92 ++ .../BedrockGuiActorContextTest.java | 140 +++ @@ -1046,6 +1338,17 @@ Pinned Bedrock 1.26.60.5/protocol2225 execution of original141c64b70 with real v .../render/font/BedrockBitmapGlyphTest.java | 107 ++ .../font/BedrockBitmapShaderGpuTest.java | 291 +++++ .../render/font/BedrockTrueTypeFontTest.java | 329 ++++++ + .../render/oreui/OreUiAnimationTest.java | 74 ++ + .../bedrock/render/oreui/OreUiAssetsTest.java | 60 + + .../bedrock/render/oreui/OreUiBoxTest.java | 39 + + .../render/oreui/OreUiCharactersTest.java | 60 + + .../bedrock/render/oreui/OreUiFontTest.java | 226 ++++ + .../bedrock/render/oreui/OreUiFontsTest.java | 59 + + .../bedrock/render/oreui/OreUiGlyphsTest.java | 231 ++++ + .../render/oreui/OreUiLineMetricsTest.java | 41 + + .../bedrock/render/oreui/OreUiLinesTest.java | 41 + + .../render/oreui/OreUiMixedLinesTest.java | 73 ++ + .../render/oreui/OreUiShaderGpuTest.java | 99 ++ .../bedrock/screen/ui/AnimationPrograms.java | 23 + .../screen/ui/BedrockNativeUiHurtTest.java | 71 ++ .../screen/ui/NativeHudOverlayStateTest.java | 153 +++ @@ -1071,7 +1374,7 @@ Pinned Bedrock 1.26.60.5/protocol2225 execution of original141c64b70 with real v .../screen/ui/NativeUiPlayerCameraTest.java | 132 +++ .../screen/ui/NativeUiPresentationTest.java | 258 +++++ .../screen/ui/NativeUiRendererTest.java | 201 ++++ - .../NativeUiSceneScreenInteractionTest.java | 359 ++++++ + .../NativeUiSceneScreenInteractionTest.java | 407 +++++++ .../bedrock/screen/ui/NativeUiSceneTest.java | 40 + .../screen/ui/NativeUiScissorTest.java | 69 ++ .../ui/NativeUiScrollbarLayoutTest.java | 107 ++ @@ -1079,13 +1382,26 @@ Pinned Bedrock 1.26.60.5/protocol2225 execution of original141c64b70 with real v .../bedrock/screen/ui/NativeUiTextTest.java | 100 ++ .../ui/NativeUiTextureAnimationTest.java | 108 ++ .../bedrock/screen/ui/NativeUiToggleTest.java | 86 ++ - .../screen/ui/NativeUiViewportTest.java | 95 ++ + .../screen/ui/NativeUiViewportTest.java | 84 ++ .../resources/font/native-outline-fixture.ttf | Bin 0 -> 480 bytes .../resources/font/native-outline-fixture.txt | 4 + .../native-scrollbar-visibility.json | 162 +++ .../native-ui/cursor-placement-2225.json | 27 + .../resources/native-ui/scissor-2225.json | 622 ++++++++++ - 138 files changed, 16340 insertions(+), 37 deletions(-) + .../resources/oreui/fallback-coverage.ttf | Bin 0 -> 452 bytes + src/test/resources/oreui/kerning-advances.ttf | Bin 0 -> 600 bytes + src/test/resources/oreui/make-fixture.py | 87 ++ + src/test/resources/oreui/minimal-a.sdf | Bin 0 -> 1620 bytes + src/test/resources/oreui/minimal.ttf | Bin 0 -> 572 bytes + .../oreui/native-characters-2225.json | 1 + + .../oreui/native-font-assignment-2225.json | 1 + + .../resources/oreui/native-lines-2225.json | 22 + + .../oreui/native-locale-fonts-2225.json | 1 + + .../oreui/native-mixed-lines-2225.json | 1 + + .../oreui/native-trailing-reserve-2225.json | 1 + + src/test/resources/oreui/nominal-advances.ttf | Bin 0 -> 548 bytes + .../resources/oreui/ordinary-advances.ttf | Bin 0 -> 668 bytes + 178 files changed, 19401 insertions(+), 39 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 @@ -1102,6 +1418,7 @@ Pinned Bedrock 1.26.60.5/protocol2225 execution of original141c64b70 with real v create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeUiScissorRenderer.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeUiViewport.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinPictureInPictureRenderer.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/screen/GuiGraphicsExtractorAccess.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockAnimatedEntityState.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockGuiActorContext.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockGuiActorRenderer.java @@ -1110,6 +1427,18 @@ Pinned Bedrock 1.26.60.5/protocol2225 execution of original141c64b70 with real v create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/font/BedrockBitmapPipeline.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/font/BedrockTrueTypeFont.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/font/BedrockTrueTypeRaster.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiAnimation.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiAssets.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiBox.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiCharacters.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiDistanceField.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiFont.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiFonts.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiGlyphs.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiLines.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiPipeline.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiShaper.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiText.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/BedrockHudOverlays.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/BedrockNativeUiHurt.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeHudOverlayState.java @@ -1130,6 +1459,7 @@ Pinned Bedrock 1.26.60.5/protocol2225 execution of original141c64b70 with real v create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiFocus.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiFont.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiGraphics.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiHold.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiHurtPayload.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiImageCache.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiImageRegions.java @@ -1154,6 +1484,8 @@ Pinned Bedrock 1.26.60.5/protocol2225 execution of original141c64b70 with real v create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/settings/base/NativeUiScale.java create mode 100644 src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_bitmap_font.fsh create mode 100644 src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_bitmap_font.vsh + create mode 100644 src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_oreui_font.fsh + create mode 100644 src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_oreui_font.vsh create mode 100644 src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_truetype_font.fsh create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockBitmapFontResourcesTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockGuiActorAppearanceTest.java @@ -1162,6 +1494,17 @@ Pinned Bedrock 1.26.60.5/protocol2225 execution of original141c64b70 with real v create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/font/BedrockBitmapGlyphTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/font/BedrockBitmapShaderGpuTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/font/BedrockTrueTypeFontTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiAnimationTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiAssetsTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiBoxTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiCharactersTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiFontTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiFontsTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiGlyphsTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiLineMetricsTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiLinesTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiMixedLinesTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiShaderGpuTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/AnimationPrograms.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/BedrockNativeUiHurtTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeHudOverlayStateTest.java @@ -1201,12 +1544,25 @@ Pinned Bedrock 1.26.60.5/protocol2225 execution of original141c64b70 with real v create mode 100644 src/test/resources/native-scrollbar-visibility.json create mode 100644 src/test/resources/native-ui/cursor-placement-2225.json create mode 100644 src/test/resources/native-ui/scissor-2225.json + create mode 100644 src/test/resources/oreui/fallback-coverage.ttf + create mode 100644 src/test/resources/oreui/kerning-advances.ttf + create mode 100644 src/test/resources/oreui/make-fixture.py + create mode 100644 src/test/resources/oreui/minimal-a.sdf + create mode 100644 src/test/resources/oreui/minimal.ttf + create mode 100644 src/test/resources/oreui/native-characters-2225.json + create mode 100644 src/test/resources/oreui/native-font-assignment-2225.json + create mode 100644 src/test/resources/oreui/native-lines-2225.json + create mode 100644 src/test/resources/oreui/native-locale-fonts-2225.json + create mode 100644 src/test/resources/oreui/native-mixed-lines-2225.json + create mode 100644 src/test/resources/oreui/native-trailing-reserve-2225.json + create mode 100644 src/test/resources/oreui/nominal-advances.ttf + create mode 100644 src/test/resources/oreui/ordinary-advances.ttf diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java b/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java -index e0cc7f9..f64ad7c 100644 +index b129aaa..c05c123 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java -@@ -100,6 +100,8 @@ import net.raphimc.viabedrock.protocol.provider.EmoteProvider; +@@ -101,6 +101,8 @@ import net.raphimc.viabedrock.protocol.provider.EmoteProvider; import com.viaversion.viafabricplus.bedrock.appearance.ViaFabricPlusEmoteProvider; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -1215,7 +1571,7 @@ index e0cc7f9..f64ad7c 100644 public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { -@@ -119,6 +121,8 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { +@@ -120,6 +122,8 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { public void onPreSettingsLoading() { com.viaversion.viafabricplus.bedrock.appearance.BedrockSkinChange.register(); com.viaversion.viafabricplus.bedrock.appearance.BedrockEmotePlayback.register(); @@ -1478,7 +1834,7 @@ index 0000000..2ddf17c +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/input/MixinNativeUiInput.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/input/MixinNativeUiInput.java new file mode 100644 -index 0000000..58fa5ef +index 0000000..c11b01a --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/input/MixinNativeUiInput.java @@ -0,0 +1,69 @@ @@ -1503,7 +1859,7 @@ index 0000000..58fa5ef + private static MouseButtonEvent nativeEvent(final Screen screen, final MouseButtonEvent event) { + if (!(screen instanceof NativeUiScreen nativeScreen)) return event; + final var viewport = nativeScreen.nativeViewport(); -+ return new MouseButtonEvent(viewport.pointer(event.x()), viewport.pointer(event.y()), event.buttonInfo()); ++ return new MouseButtonEvent(viewport.pointerX(event.x()), viewport.pointerY(event.y()), event.buttonInfo()); + } + + @WrapOperation(method = "onButton", at = @At(value = "INVOKE", target = @@ -1525,7 +1881,7 @@ index 0000000..58fa5ef + private void mouseMoved(final Screen screen, final double x, final double y, final Operation original) { + if (screen instanceof NativeUiScreen nativeScreen) { + final var viewport = nativeScreen.nativeViewport(); -+ original.call(screen, viewport.pointer(x), viewport.pointer(y)); ++ original.call(screen, viewport.pointerX(x), viewport.pointerY(y)); + } else original.call(screen, x, y); + } + @@ -1535,7 +1891,7 @@ index 0000000..58fa5ef + final Operation original) { + if (screen instanceof NativeUiScreen nativeScreen) { + final var viewport = nativeScreen.nativeViewport(); -+ return original.call(screen, nativeEvent(screen, event), viewport.pointer(x), viewport.pointer(y)); ++ return original.call(screen, nativeEvent(screen, event), viewport.pointerX(x), viewport.pointerY(y)); + } + return original.call(screen, event, x, y); + } @@ -1546,7 +1902,7 @@ index 0000000..58fa5ef + final double vertical, final Operation original) { + if (screen instanceof NativeUiScreen nativeScreen) { + final var viewport = nativeScreen.nativeViewport(); -+ return original.call(screen, viewport.pointer(x), viewport.pointer(y), horizontal, vertical); ++ return original.call(screen, viewport.pointerX(x), viewport.pointerY(y), horizontal, vertical); + } + return original.call(screen, x, y, horizontal, vertical); + } @@ -1919,10 +2275,10 @@ index 0000000..e05bfae +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeUiExtraction.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeUiExtraction.java new file mode 100644 -index 0000000..8a6d214 +index 0000000..aa39057 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeUiExtraction.java -@@ -0,0 +1,40 @@ +@@ -0,0 +1,43 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -1958,7 +2314,10 @@ index 0000000..8a6d214 + return; + } + final var viewport = nativeScreen.nativeViewport(); -+ final int nativeX = (int) viewport.pointer(x), nativeY = (int) viewport.pointer(y); ++ final var window = this.minecraft.getWindow(); ++ // Preserve host pointer precision until conversion to native units. ++ final int nativeX = (int) viewport.pointerX(this.minecraft.mouseHandler.getScaledXPos(window)); ++ final int nativeY = (int) viewport.pointerY(this.minecraft.mouseHandler.getScaledYPos(window)); + original.call(screen, new NativeUiGraphics(this.minecraft, this.guiRenderState, graphics, viewport, nativeX, nativeY), + nativeX, nativeY, delta); + } @@ -2193,11 +2552,34 @@ index 0000000..cfae369 + }); + } +} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/screen/GuiGraphicsExtractorAccess.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/screen/GuiGraphicsExtractorAccess.java +new file mode 100644 +index 0000000..f353d55 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/screen/GuiGraphicsExtractorAccess.java +@@ -0,0 +1,17 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.injection.mixin.features.screen; ++ ++import net.minecraft.client.gui.GuiGraphicsExtractor; ++import net.minecraft.client.renderer.state.gui.GuiRenderState; ++import org.spongepowered.asm.mixin.Mixin; ++import org.spongepowered.asm.mixin.gen.Accessor; ++ ++@Mixin(GuiGraphicsExtractor.class) ++public interface GuiGraphicsExtractorAccess { ++ @Accessor("guiRenderState") ++ GuiRenderState bedrockGuiState(); ++} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorMaterialDefinitions.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorMaterialDefinitions.java -index e851321..74bf1c7 100644 +index bf972c1..ba4bad3 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorMaterialDefinitions.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorMaterialDefinitions.java -@@ -60,12 +60,12 @@ public final class BedrockActorMaterialDefinitions { +@@ -63,12 +63,12 @@ public final class BedrockActorMaterialDefinitions { return read(documents); } @@ -2213,7 +2595,7 @@ index e851321..74bf1c7 100644 final JsonElement materials = compiled.get("materials"); if (materials == null || !materials.isJsonObject()) return compiled; diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorRenderTypes.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorRenderTypes.java -index 516fdbf..6129085 100644 +index 42e2ce0..b3ed2ea 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorRenderTypes.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorRenderTypes.java @@ -7,6 +7,8 @@ package com.viaversion.viafabricplus.bedrock.render; @@ -2255,8 +2637,8 @@ index 516fdbf..6129085 100644 private static RenderPipeline pipeline(final PipelineKey key) { final var builder = RenderPipeline.builder(RenderPipelines.ENTITY_SNIPPET) -- .withLocation(Identifier.fromNamespaceAndPath("viafabricplus-bedrock", "pipeline/actor_" + key.alphaTest + "_" + key.cull + "_" + key.unlit + "_" + key.emissive + "_" + key.nether + (key.playerFade ? "_player_fade" : "") + (key.masked ? "_masked" : "") + (key.colorMask ? "_tint" : "") + (key.alphaWrite ? "" : "_no_alpha") + (key.glint == GlintLighting.NONE ? "" : key.glint == GlintLighting.LIGHTMAP ? "_glint_lightmap" : "_glint") + (key.depth.equals(BedrockActorDepth.DEFAULT) ? "" : "_depth_" + key.depth.suffix()))) -+ .withLocation(Identifier.fromNamespaceAndPath("viafabricplus-bedrock", "pipeline/actor_" + key.alphaTest + "_" + key.cull + "_" + key.unlit + "_" + key.emissive + "_" + key.nether + (key.gui ? "_gui" : "") + (key.playerFade ? "_player_fade" : "") + (key.masked ? "_masked" : "") + (key.colorMask ? "_tint" : "") + (key.alphaWrite ? "" : "_no_alpha") + (key.glint == GlintLighting.NONE ? "" : key.glint == GlintLighting.LIGHTMAP ? "_glint_lightmap" : "_glint") + (key.depth.equals(BedrockActorDepth.DEFAULT) ? "" : "_depth_" + key.depth.suffix()))) +- .withLocation(Identifier.fromNamespaceAndPath("viafabricplus-bedrock", "pipeline/actor_" + key.alphaTest + "_" + key.cull + "_" + key.unlit + "_" + key.emissive + "_" + key.nether + (key.playerFade ? "_player_fade" : "") + (key.pattern ? "_pattern" : "") + (key.masked ? "_masked" : "") + (key.colorMask ? "_tint" : "") + (key.alphaWrite ? "" : "_no_alpha") + (key.glint == GlintLighting.NONE ? "" : key.glint == GlintLighting.LIGHTMAP ? "_glint_lightmap" : "_glint") + (key.depth.equals(BedrockActorDepth.DEFAULT) ? "" : "_depth_" + key.depth.suffix()))) ++ .withLocation(Identifier.fromNamespaceAndPath("viafabricplus-bedrock", "pipeline/actor_" + key.alphaTest + "_" + key.cull + "_" + key.unlit + "_" + key.emissive + "_" + key.nether + (key.gui ? "_gui" : "") + (key.playerFade ? "_player_fade" : "") + (key.pattern ? "_pattern" : "") + (key.masked ? "_masked" : "") + (key.colorMask ? "_tint" : "") + (key.alphaWrite ? "" : "_no_alpha") + (key.glint == GlintLighting.NONE ? "" : key.glint == GlintLighting.LIGHTMAP ? "_glint_lightmap" : "_glint") + (key.depth.equals(BedrockActorDepth.DEFAULT) ? "" : "_depth_" + key.depth.suffix()))) .withVertexShader(Identifier.fromNamespaceAndPath("viafabricplus-bedrock", "core/bedrock_actor")) .withFragmentShader(Identifier.fromNamespaceAndPath("viafabricplus-bedrock", "core/bedrock_actor")) .withBindGroupLayout(BindGroupLayouts.SAMPLER1) @@ -2267,10 +2649,10 @@ index 516fdbf..6129085 100644 + if (key.gui) builder.withShaderDefine("GUI_PORTRAIT").withColorTargetState(new ColorTargetState( + new BlendFunction(BlendFactor.SRC_ALPHA, BlendFactor.ONE_MINUS_SRC_ALPHA, + BlendFactor.ONE, BlendFactor.ONE_MINUS_SRC_ALPHA))); - if (key.colorMask) builder.withShaderDefine("COLOR_MASK") - .withBindGroupLayout(BindGroupLayout.builder().withUniform("BedrockTintInfo", UniformType.UNIFORM_BUFFER).build()); - if (key.masked) builder.withShaderDefine("MASKED_MULTITEXTURE") -@@ -73,7 +80,8 @@ public final class BedrockActorRenderTypes { + if (key.pattern) builder.withShaderDefine("PATTERN") + .withBindGroupLayout(BindGroupLayout.builder() + .withUniform("BedrockPatternInfo", UniformType.UNIFORM_BUFFER) +@@ -77,7 +84,8 @@ public final class BedrockActorRenderTypes { if (key.unlit) builder.withShaderDefine("IGNORE_LIGHTING"); if (key.nether) builder.withShaderDefine("NETHER_LIGHTING"); if (key.emissive) builder.withShaderDefine("USE_EMISSIVE"); @@ -2280,18 +2662,18 @@ index 516fdbf..6129085 100644 : !key.alphaTest ? RenderPipelines.ENTITY_SOLID : key.unlit ? RenderPipelines.ENTITY_CUTOUT_Z_OFFSET : key.cull ? RenderPipelines.ENTITY_CUTOUT_CULL : RenderPipelines.ENTITY_CUTOUT; -@@ -144,8 +152,9 @@ public final class BedrockActorRenderTypes { - ((IBedrockActorRenderType) type).bedrock$glint(glint); +@@ -168,8 +176,9 @@ public final class BedrockActorRenderTypes { + ((IBedrockActorRenderType) type).bedrock$pattern(pattern); ((IBedrockActorRenderType) type).bedrock$tint(material.colorMask() ? tint : null); if (material.emissive()) BedrockShaderEmission.register(type, textures.getFirst(), material, ignoreLighting, multiplier, uv, netherLighting, playerFade); + final var bindings = List.copyOf(textures); -+ GUI_TYPES.put(type, com.google.common.base.Suppliers.memoize(() -> createGui(bindings, material, ignoreLighting, multiplier, uv, glint, tint))); ++ GUI_TYPES.put(type, com.google.common.base.Suppliers.memoize(() -> createGui(bindings, material, ignoreLighting, multiplier, uv, glint, tint, pattern))); if (!playerFade) { - final var bindings = List.copyOf(textures); PLAYER_TYPES.put(type, Suppliers.memoize(() -> create(bindings, material, ignoreLighting, - multiplier, uv, netherLighting, glint, lighting, tint, true))); + multiplier, uv, netherLighting, glint, lighting, tint, true, pattern))); } -@@ -158,6 +167,38 @@ public final class BedrockActorRenderTypes { +@@ -182,6 +191,40 @@ public final class BedrockActorRenderTypes { return player == null ? type : player.get(); } @@ -2303,19 +2685,21 @@ index 516fdbf..6129085 100644 + + public static RenderType createGui(final List textures, final BedrockActorMaterialDefinitions.Material material, + final boolean ignoreLighting, final float multiplier, final BedrockActorUvTransform uv) { -+ return createGui(textures, material, ignoreLighting, multiplier, uv, null, null); ++ return createGui(textures, material, ignoreLighting, multiplier, uv, null, null, null); + } + + private static RenderType createGui(final List textures, final BedrockActorMaterialDefinitions.Material material, -+ final boolean ignoreLighting, final float multiplier, final BedrockActorUvTransform uv, final BedrockActorGlint glint, final BedrockActorTint tint) { ++ final boolean ignoreLighting, final float multiplier, final BedrockActorUvTransform uv, final BedrockActorGlint glint, final BedrockActorTint tint, final BedrockActorPattern pattern) { ++ if (material.pattern() != (pattern != null)) throw new IllegalArgumentException("Native pattern input is unavailable"); + if (material.colorMask() && tint == null) throw new IllegalArgumentException("Native tint input is unavailable"); + final var pipeline = glint == null ? selectGuiPipeline(material, ignoreLighting) + : MATERIAL_PIPELINES.computeIfAbsent(new PipelineKey(material.alphaTest(), material.cull(), ignoreLighting, -+ false, false, true, false, false, GlintLighting.EXPLICIT, material.depth(), true, false), BedrockActorRenderTypes::pipeline); ++ false, false, true, false, false, GlintLighting.EXPLICIT, material.depth(), true, false, material.pattern()), BedrockActorRenderTypes::pipeline); + final var setup = textures(RenderSetup.builder(pipeline), textures, material); + if (glint != null) setup.withTexture("BedrockGlintTexture", textures.get(1), () -> repeatedSampler(textures.get(1))); + final var type = RenderType.create("bedrock_gui_actor", setup.useLightmap().useOverlay().createRenderSetup()); + ((IBedrockActorRenderType) type).bedrock$glint(glint); ++ ((IBedrockActorRenderType) type).bedrock$pattern(pattern); + ((IBedrockActorRenderType) type).bedrock$tint(material.colorMask() ? tint : null); + ((IBedrockActorRenderType) type).bedrock$lightColorMultiplier(multiplier); + ((IBedrockActorRenderType) type).bedrock$uvTransform(uv); @@ -2324,36 +2708,44 @@ index 516fdbf..6129085 100644 + + static RenderPipeline selectGuiPipeline(final BedrockActorMaterialDefinitions.Material material, final boolean ignoreLighting) { + if (material.glint()) throw new IllegalArgumentException("Native glint requires caller-owned frame inputs"); -+ return MATERIAL_PIPELINES.computeIfAbsent(new PipelineKey(material.alphaTest(), material.cull(), ignoreLighting, material.emissive(), false, true, material.masked(), material.colorMask(), GlintLighting.NONE, material.depth(), true, false), BedrockActorRenderTypes::pipeline); ++ return MATERIAL_PIPELINES.computeIfAbsent(new PipelineKey(material.alphaTest(), material.cull(), ignoreLighting, material.emissive(), false, true, material.masked(), material.colorMask(), GlintLighting.NONE, material.depth(), true, false, material.pattern()), BedrockActorRenderTypes::pipeline); + } + static RenderPipeline selectPipeline(final BedrockActorMaterialDefinitions.Material material, final boolean ignoreLighting, final boolean netherLighting) { return selectPipeline(material, ignoreLighting, netherLighting, false); -@@ -167,7 +208,7 @@ public final class BedrockActorRenderTypes { +@@ -191,7 +234,7 @@ public final class BedrockActorRenderTypes { final boolean ignoreLighting, final boolean netherLighting, final boolean playerFade) { if (material.glint()) throw new IllegalArgumentException("Native glint requires caller-owned frame inputs"); return MATERIAL_PIPELINES.computeIfAbsent(new PipelineKey(material.alphaTest(), material.cull(), ignoreLighting, -- material.emissive(), netherLighting && !ignoreLighting, material.masked(), material.colorMask(), GlintLighting.NONE, material.depth(), material.alphaWrite(), playerFade), BedrockActorRenderTypes::pipeline); -+ material.emissive(), netherLighting && !ignoreLighting, false, material.masked(), material.colorMask(), GlintLighting.NONE, material.depth(), material.alphaWrite(), playerFade), BedrockActorRenderTypes::pipeline); +- material.emissive(), netherLighting && !ignoreLighting, material.masked(), material.colorMask(), GlintLighting.NONE, material.depth(), material.alphaWrite(), playerFade, material.pattern()), BedrockActorRenderTypes::pipeline); ++ material.emissive(), netherLighting && !ignoreLighting, false, material.masked(), material.colorMask(), GlintLighting.NONE, material.depth(), material.alphaWrite(), playerFade, material.pattern()), BedrockActorRenderTypes::pipeline); } static RenderPipeline selectGlintPipeline(final BedrockActorMaterialDefinitions.Material material, -@@ -180,7 +221,7 @@ public final class BedrockActorRenderTypes { +@@ -204,7 +247,7 @@ public final class BedrockActorRenderTypes { if (lighting == GlintLighting.NONE) throw new IllegalArgumentException("Missing native glint lighting source"); if (material.emissive() || material.masked() || material.colorMask()) throw new IllegalArgumentException("Unsupported native glint material combination"); return MATERIAL_PIPELINES.computeIfAbsent(new PipelineKey(material.alphaTest(), material.cull(), ignoreLighting, -- false, netherLighting && !ignoreLighting, false, false, lighting, material.depth(), material.alphaWrite(), playerFade), BedrockActorRenderTypes::pipeline); -+ false, netherLighting && !ignoreLighting, false, false, false, lighting, material.depth(), material.alphaWrite(), playerFade), BedrockActorRenderTypes::pipeline); +- false, netherLighting && !ignoreLighting, false, false, lighting, material.depth(), material.alphaWrite(), playerFade, material.pattern()), BedrockActorRenderTypes::pipeline); ++ false, netherLighting && !ignoreLighting, false, false, false, lighting, material.depth(), material.alphaWrite(), playerFade, material.pattern()), BedrockActorRenderTypes::pipeline); } private static com.mojang.renderpearl.api.textures.GpuSampler repeatedSampler(final Identifier texture) { -@@ -211,5 +252,5 @@ public final class BedrockActorRenderTypes { +@@ -238,11 +281,11 @@ public final class BedrockActorRenderTypes { enum GlintLighting { NONE, EXPLICIT, LIGHTMAP } -- private record PipelineKey(boolean alphaTest, boolean cull, boolean unlit, boolean emissive, boolean nether, boolean masked, boolean colorMask, GlintLighting glint, BedrockActorDepth depth, boolean alphaWrite, boolean playerFade) {} -+ private record PipelineKey(boolean alphaTest, boolean cull, boolean unlit, boolean emissive, boolean nether, boolean gui, boolean masked, boolean colorMask, GlintLighting glint, BedrockActorDepth depth, boolean alphaWrite, boolean playerFade) {} +- private record PipelineKey(boolean alphaTest, boolean cull, boolean unlit, boolean emissive, boolean nether, boolean masked, boolean colorMask, GlintLighting glint, BedrockActorDepth depth, boolean alphaWrite, boolean playerFade, boolean pattern) { ++ private record PipelineKey(boolean alphaTest, boolean cull, boolean unlit, boolean emissive, boolean nether, boolean gui, boolean masked, boolean colorMask, GlintLighting glint, BedrockActorDepth depth, boolean alphaWrite, boolean playerFade, boolean pattern) { + private PipelineKey(final boolean alphaTest, final boolean cull, final boolean unlit, final boolean emissive, +- final boolean nether, final boolean masked, final boolean colorMask, final GlintLighting glint, ++ final boolean nether, final boolean gui, final boolean masked, final boolean colorMask, final GlintLighting glint, + final BedrockActorDepth depth, final boolean alphaWrite, final boolean playerFade) { +- this(alphaTest, cull, unlit, emissive, nether, masked, colorMask, glint, depth, alphaWrite, playerFade, false); ++ this(alphaTest, cull, unlit, emissive, nether, gui, masked, colorMask, glint, depth, alphaWrite, playerFade, false); + } + } } diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockAnimatedEntityState.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockAnimatedEntityState.java new file mode 100644 @@ -2751,10 +3143,10 @@ index 0000000..2ed5c05 + private record Draw(Model model, RenderType type) {} +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerAttachables.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerAttachables.java -index 9c823f1..4accc01 100644 +index 384a400..342f4bc 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerAttachables.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerAttachables.java -@@ -99,6 +99,9 @@ public final class BedrockPlayerAttachables { +@@ -101,6 +101,9 @@ public final class BedrockPlayerAttachables { final BedrockActorAnimation.Frame input, final boolean firstPerson, final ItemStack main, final ItemStack off) { final var packs = resources(entity); if (packs == null) return List.of(); @@ -2764,7 +3156,7 @@ index 9c823f1..4accc01 100644 final List result = new ArrayList<>(); final Map queries = new HashMap<>(input.queries()); queries.putAll(BedrockAttachableBindings.queries()); -@@ -110,7 +113,7 @@ public final class BedrockPlayerAttachables { +@@ -112,7 +115,7 @@ public final class BedrockPlayerAttachables { final var stack = firstPerson ? slot == EquipmentSlot.MAINHAND ? main : off : entity.getItemBySlot(slot); final var context = Map.of("is_first_person", firstPerson ? 1F : 0F, "item_slot", slotName(slot)); try { @@ -2773,7 +3165,7 @@ index 9c823f1..4accc01 100644 properties, new BedrockActorAnimation.Frame(queries, input.variables(), context)); if (identity == null || !identity.variant().equals("default")) continue; final var resource = RESOURCES.computeIfAbsent(identity.identifier(), _ -> load(identity.identifier(), packs)).orElse(null); -@@ -137,12 +140,18 @@ public final class BedrockPlayerAttachables { +@@ -139,12 +142,18 @@ public final class BedrockPlayerAttachables { final ResourcePackStorage packs = resources(entity); if (packs == null) return List.of(); final var glintFrame = BedrockActorGlintFrames.current(); @@ -2793,8 +3185,8 @@ index 9c823f1..4accc01 100644 queries.put("owner_identifier", "minecraft:player"); queries.put("is_owner_identifier_any", (BedrockActorAnimation.Query) names -> names.contains("minecraft:player")); final var actor = BedrockActorState.state(entity.getUUID()); -@@ -160,7 +169,7 @@ public final class BedrockPlayerAttachables { - "is_patterned", 0F); +@@ -163,7 +172,7 @@ public final class BedrockPlayerAttachables { + "is_patterned", appearance.patterned() ? 1F : 0F); ItemIdentity identity; try { - identity = identify(stack, packs, SELECTORS.computeIfAbsent(entity, _ -> new BedrockAttachableSelection()), properties, @@ -2802,15 +3194,15 @@ index 9c823f1..4accc01 100644 new BedrockActorAnimation.Frame(itemQueries, variables, context)); } catch (IOException | RuntimeException invalid) { if (FAILED_SELECTIONS.add(stack.getItem().toString())) -@@ -216,7 +225,7 @@ public final class BedrockPlayerAttachables { +@@ -219,7 +228,7 @@ public final class BedrockPlayerAttachables { if (!heldBindings.hasBindingPath(bindings.declared())) { supported = false; break; } } for (final var group : BedrockGeometry.partitionMaterials(geometry, Map.of(), selected.materials()).entrySet()) { - final var material = (hand ? materials.heldItemMaterial(group.getKey()) : materials.material(group.getKey())).orElse(null); + final var material = (hand && gui == null ? materials.heldItemMaterial(group.getKey()) : materials.material(group.getKey())).orElse(null); if (material == null || material.glint() && glintFrame == null || selected.textures().size() < material.textureCount()) { supported = false; break; } - final List textures = new ArrayList<>(); - for (int index = 0; index < material.textureCount(); index++) { + final var atlas = material.pattern() ? shieldAtlas(packs) : null; + final var pattern = atlas == null ? null : appearance.draw(atlas); diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerSkins.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerSkins.java index 7b3e97c..81950c2 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerSkins.java @@ -3964,858 +4356,1860 @@ index 0000000..7a6265e + } + } +} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/BedrockHudOverlays.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/BedrockHudOverlays.java +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiAnimation.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiAnimation.java new file mode 100644 -index 0000000..df6dfa1 +index 0000000..402d5c8 --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/BedrockHudOverlays.java -@@ -0,0 +1,128 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiAnimation.java +@@ -0,0 +1,102 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ -+package com.viaversion.viafabricplus.bedrock.screen.ui; ++package com.viaversion.viafabricplus.bedrock.render.oreui; + -+import com.viaversion.viafabricplus.bedrock.ViaFabricPlusBedrock; ++import java.io.ByteArrayInputStream; ++import java.io.IOException; ++import java.util.ArrayList; ++import java.util.Arrays; ++import java.util.List; ++import javax.imageio.ImageIO; ++import javax.imageio.stream.MemoryCacheImageInputStream; ++import org.w3c.dom.Node; ++ ++/** Bounded original GIF frames retain their palette, transparency, disposal and delivery durations. */ ++public final class OreUiAnimation { ++ private static final long MAX_DECODED_BYTES = 16 * 1024 * 1024; ++ private final int width, height; ++ private final List frames; ++ private final long duration; ++ ++ private OreUiAnimation(final int width, final int height, final List frames, final long duration) { ++ this.width = width; this.height = height; this.frames = List.copyOf(frames); this.duration = duration; ++ } ++ ++ public static OreUiAnimation decode(final byte[] bytes) throws IOException { ++ if (bytes.length < 13 || bytes.length > 16 * 1024 * 1024) throw new IOException("Invalid OreUI animation size"); ++ final var readers = ImageIO.getImageReadersByFormatName("gif"); ++ if (!readers.hasNext()) throw new IOException("GIF decoder is unavailable"); ++ final var reader = readers.next(); ++ try (var input = new MemoryCacheImageInputStream(new ByteArrayInputStream(bytes))) { ++ reader.setInput(input, false, false); ++ final Node screen = child(reader.getStreamMetadata().getAsTree("javax_imageio_gif_stream_1.0"), "LogicalScreenDescriptor"); ++ final int width = integer(screen, "logicalScreenWidth"), height = integer(screen, "logicalScreenHeight"); ++ final int count = reader.getNumImages(true); ++ if (width <= 0 || height <= 0 || width > 2048 || height > 2048 || count < 1 || count > 256 ++ || (long) width * height * 4 * count > MAX_DECODED_BYTES) throw new IOException("OreUI animation exceeds decoded limits"); ++ int[] canvas = new int[width * height]; ++ final List frames = new ArrayList<>(); ++ long duration = 0; ++ for (int index = 0; index < count; index++) { ++ final Node metadata = reader.getImageMetadata(index).getAsTree("javax_imageio_gif_image_1.0"); ++ final Node image = child(metadata, "ImageDescriptor"), control = child(metadata, "GraphicControlExtension"); ++ final int left = integer(image, "imageLeftPosition"), top = integer(image, "imageTopPosition"); ++ final int frameWidth = integer(image, "imageWidth"), frameHeight = integer(image, "imageHeight"); ++ final int delay = integer(control, "delayTime") * 10; ++ if (left < 0 || top < 0 || frameWidth <= 0 || frameHeight <= 0 || left + frameWidth > width ++ || top + frameHeight > height || delay <= 0) throw new IOException("Unsupported OreUI animation frame"); ++ final String disposal = attribute(control, "disposalMethod"); ++ final int[] previous = disposal.equals("restoreToPrevious") ? canvas.clone() : null; ++ final var decoded = reader.read(index); ++ if (decoded.getWidth() != frameWidth || decoded.getHeight() != frameHeight) throw new IOException("Invalid OreUI GIF frame dimensions"); ++ for (int y = 0; y < frameHeight; y++) for (int x = 0; x < frameWidth; x++) { ++ final int argb = decoded.getRGB(x, y); ++ if ((argb >>> 24) != 0) canvas[(top + y) * width + left + x] = argb; ++ } ++ frames.add(new Frame(canvas.clone(), delay)); duration += delay; ++ switch (disposal) { ++ case "restoreToBackgroundColor" -> { ++ // Acquired OreUI GIFs use a transparent background; opaque backgrounds are unsupported. ++ if (!attribute(control, "transparentColorFlag").equals("TRUE")) throw new IOException("Opaque GIF background disposal is unsupported"); ++ for (int y = top; y < top + frameHeight; y++) Arrays.fill(canvas, y * width + left, y * width + left + frameWidth, 0); ++ } ++ case "restoreToPrevious" -> canvas = previous; ++ case "none", "doNotDispose" -> { } ++ default -> throw new IOException("Unsupported OreUI GIF disposal"); ++ } ++ } ++ return new OreUiAnimation(width, height, frames, duration); ++ } catch (final RuntimeException failure) { throw new IOException("Invalid OreUI animation", failure); } ++ finally { reader.dispose(); } ++ } ++ ++ public int width() { return this.width; } ++ public int height() { return this.height; } ++ public List frames() { return this.frames; } ++ public long durationMillis() { return this.duration; } ++ public Frame frame(final long elapsedMillis) { ++ long time = Math.floorMod(elapsedMillis, this.duration); ++ for (final var frame : this.frames) { if (time < frame.durationMillis) return frame; time -= frame.durationMillis; } ++ throw new AssertionError("Animation duration does not cover its frames"); ++ } ++ public static final class Frame { ++ private final int[] argb; ++ private final int durationMillis; ++ private Frame(final int[] argb, final int durationMillis) { this.argb = argb; this.durationMillis = durationMillis; } ++ public int[] argb() { return this.argb.clone(); } ++ public int durationMillis() { return this.durationMillis; } ++ } ++ private static Node child(final Node parent, final String name) throws IOException { ++ for (Node node = parent.getFirstChild(); node != null; node = node.getNextSibling()) if (node.getNodeName().equals(name)) return node; ++ throw new IOException("Missing GIF metadata: " + name); ++ } ++ private static String attribute(final Node node, final String name) throws IOException { ++ final Node attribute = node.getAttributes().getNamedItem(name); ++ if (attribute == null) throw new IOException("Missing GIF attribute: " + name); ++ return attribute.getNodeValue(); ++ } ++ private static int integer(final Node node, final String name) throws IOException { return Integer.parseInt(attribute(node, name)); } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiAssets.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiAssets.java +new file mode 100644 +index 0000000..1353fe3 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiAssets.java +@@ -0,0 +1,162 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.render.oreui; ++ ++import com.google.gson.JsonObject; ++import com.google.gson.JsonParser; ++import java.io.ByteArrayInputStream; ++import java.io.IOException; ++import java.io.InputStream; ++import java.nio.charset.StandardCharsets; ++import java.security.MessageDigest; ++import java.security.NoSuchAlgorithmException; ++import java.util.ArrayList; ++import java.util.HashSet; ++import java.util.HexFormat; ++import java.util.LinkedHashMap; ++import java.util.List; +import java.util.Map; -+import java.util.EnumMap; -+import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; -+import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; -+import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry; -+import net.minecraft.client.Minecraft; -+import net.minecraft.client.DeltaTracker; -+import net.minecraft.client.gui.GuiGraphicsExtractor; -+import net.minecraft.network.Connection; -+import net.minecraft.network.chat.Component; -+import com.viaversion.viafabricplus.bedrock.screen.ui.NativeHudPayload.Kind; ++import java.util.Set; ++import java.util.zip.ZipInputStream; + -+/** Independent native HUD factories share layout, image and font rendering with server forms. */ -+public final class BedrockHudOverlays { -+ private static final Map states = new EnumMap<>(Kind.class); -+ static { for (final var kind : Kind.values()) states.put(kind, new NativeHudOverlayState()); } -+ private static NativeUiImages images; -+ private static NativeUiRenderer renderer; -+ private static Object resourceManager; -+ private static long generation; -+ private BedrockHudOverlays() {} ++/** The versioned OreUI stylesheet and font faces remain separate from classic JSON UI assets. */ ++public final class OreUiAssets { ++ private static final int MAX_BYTES = 64 * 1024 * 1024; ++ private static final String ROOT = "/assets/viafabricplus-bedrock/builtin_oreui.zip"; ++ private static OreUiAssets bundled; ++ private final Map files; ++ private final List faces; ++ private final String stylesheet; ++ private final List images; + -+ public static void register() { -+ for (final var kind : Kind.values()) { -+ PayloadTypeRegistry.clientboundPlay().register(kind.type(), kind.codec()); -+ ClientPlayConnectionEvents.INIT.register((listener, client) -> -+ ClientPlayNetworking.registerReceiver(kind.type(), -+ (payload, context) -> accept(listener.getConnection(), payload))); ++ private OreUiAssets(final Map files, final List faces, final String stylesheet, final List images) { ++ this.files = Map.copyOf(files); this.faces = List.copyOf(faces); this.stylesheet = stylesheet; this.images = List.copyOf(images); ++ } ++ ++ public static synchronized OreUiAssets bundled() throws IOException { ++ if (bundled == null) { ++ final byte[] archive = resource(ROOT, MAX_BYTES); ++ final JsonObject manifest; ++ try { manifest = json(resource(ROOT + ".manifest.json", 8192)); } ++ catch (final RuntimeException failure) { throw new IOException("Invalid OreUI bundle identity", failure); } ++ bundled = read(archive, manifest); + } -+ ClientPlayConnectionEvents.DISCONNECT.register((listener, client) -> { -+ generation++; -+ clear(); -+ }); ++ return bundled; + } + -+ private static void accept(final Connection owner, final NativeHudPayload payload) { -+ final long received = generation; -+ final var client = Minecraft.getInstance(); -+ client.execute(() -> { -+ if (received != generation || client.getConnection() == null -+ || client.getConnection().getConnection() != owner || !owner.isConnected()) return; -+ final var state = states.get(payload.kind()); -+ try { -+ if (!state.accept(payload.message())) return; -+ retainTextCaches(); -+ switch (payload.kind()) { -+ case ROOT -> {} -+ case TITLE -> client.gui.hud.clearTitles(); -+ case ACTIONBAR -> client.gui.hud.setOverlayMessage(Component.empty(), false); ++ static OreUiAssets read(final byte[] archive, final JsonObject manifest) throws IOException { ++ try { ++ if (archive.length > MAX_BYTES || manifest.get("format").getAsInt() != 1 ++ || !manifest.get("version").getAsString().equals("1.26.60.5") ++ || manifest.get("size").getAsInt() != archive.length ++ || !manifest.get("sha256").getAsString().equals(hash(archive))) throw new IOException("OreUI bundle identity mismatch"); ++ final Map files = new LinkedHashMap<>(); ++ long total = 0; ++ try (var zip = new ZipInputStream(new ByteArrayInputStream(archive))) { ++ for (var entry = zip.getNextEntry(); entry != null; entry = zip.getNextEntry()) { ++ final String name = entry.getName(); ++ if (entry.isDirectory() || files.size() >= 256 || name.indexOf('\\') >= 0 || name.indexOf('\0') >= 0 ++ || name.contains("..") || !name.matches("(?:oreui\\.json|NOTICE\\.txt|hbui/gameplay-[0-9a-f]+\\.css|hbui/fonts/[^/]+\\.(?:otf|ttf)|hbui/assets/animation-[0-9a-f]+\\.gif|texts/[a-z]{2}_[A-Z]{2}\\.lang)")) ++ throw new IOException("Invalid OreUI bundle path"); ++ final byte[] bytes = zip.readNBytes(16 * 1024 * 1024 + 1); ++ total += bytes.length; ++ if (bytes.length == 0 || bytes.length > 16 * 1024 * 1024 || total > MAX_BYTES || files.putIfAbsent(name, bytes) != null) ++ throw new IOException("Invalid OreUI bundle contents"); + } -+ } catch (RuntimeException invalid) { -+ state.clear(); -+ retainTextCaches(); -+ ViaFabricPlusBedrock.impl().logger().warn("Could not display native HUD " + payload.kind(), invalid); + } -+ }); -+ } ++ final var descriptor = json(required(files, "oreui.json")); ++ final String stylesheet = descriptor.get("stylesheet").getAsString(); ++ if (descriptor.get("format").getAsInt() != 1 || !descriptor.get("version").getAsString().equals("1.26.60.5") ++ || !stylesheet.matches("hbui/gameplay-[0-9a-f]+\\.css")) throw new IOException("Invalid OreUI descriptor"); ++ required(files, stylesheet); required(files, "NOTICE.txt"); ++ final var definitions = descriptor.getAsJsonArray("fonts"); ++ if (definitions.isEmpty() || definitions.size() > 32) throw new IOException("Invalid OreUI font registry"); ++ final List faces = new ArrayList<>(); ++ final Set keys = new HashSet<>(); ++ for (final var entry : definitions) { ++ final var definition = entry.getAsJsonObject(); ++ final var face = new Face(definition.get("family").getAsString(), definition.get("file").getAsString(), ++ definition.get("weight").getAsInt(), definition.get("style").getAsString(), definition.get("sdf").getAsString().equals("off")); ++ if (face.family.isEmpty() || face.family.length() > 128 || !face.file.matches("hbui/fonts/[^/]+\\.(?:otf|ttf)") ++ || face.weight != 400 && face.weight != 700 || !Set.of("normal", "italic").contains(face.style) ++ || !Set.of("default", "off").contains(definition.get("sdf").getAsString()) ++ || !keys.add(face.family + "\0" + face.weight + "\0" + face.style)) throw new IOException("Invalid OreUI face registration"); ++ required(files, face.file); faces.add(face); ++ } ++ final List images = new ArrayList<>(); ++ if (descriptor.has("images")) for (final var image : descriptor.getAsJsonArray("images")) { ++ final String path = image.getAsString(); ++ if (!path.matches("hbui/assets/animation-[0-9a-f]+\\.gif") || images.contains(path)) ++ throw new IOException("Invalid OreUI image registration"); ++ final byte[] bytes = required(files, path); ++ if (bytes.length < 13 || !(new String(bytes, 0, 6, StandardCharsets.US_ASCII).equals("GIF89a") ++ || new String(bytes, 0, 6, StandardCharsets.US_ASCII).equals("GIF87a"))) ++ throw new IOException("Invalid OreUI GIF image"); ++ final int width = (bytes[6] & 255) | (bytes[7] & 255) << 8, height = (bytes[8] & 255) | (bytes[9] & 255) << 8; ++ if (width == 0 || height == 0 || width > 2048 || height > 2048) throw new IOException("Invalid OreUI image dimensions"); ++ images.add(path); ++ } ++ if (!images.equals(files.keySet().stream().filter(path -> path.startsWith("hbui/assets/")).sorted().toList())) ++ throw new IOException("OreUI images do not match the descriptor"); ++ return new OreUiAssets(files, faces, stylesheet, images); ++ } catch (final RuntimeException failure) { throw new IOException("Invalid OreUI bundle", failure); } ++ } ++ ++ public List faces() { return this.faces; } ++ ++ /** Target gameplay's regular face order. The caller supplies its primary family and native-case locale. */ ++ public List regularFaces(final String primaryFamily, final String locale) { ++ final List families = new ArrayList<>(); ++ families.add(primaryFamily); ++ if (locale.startsWith("ja")) ++ families.addAll(List.of("Noto Sans JP", "Noto Sans TC", "Noto Sans SC", "Noto Sans KR", "Noto Sans")); ++ else if (locale.equals("zh_TW")) ++ families.addAll(List.of("Noto Sans TC", "Noto Sans SC", "Noto Sans JP", "Noto Sans KR", "Noto Sans")); ++ else if (locale.equals("zh_CN")) ++ families.addAll(List.of("Noto Sans SC", "Noto Sans TC", "Noto Sans JP", "Noto Sans KR", "Noto Sans")); ++ else if (locale.startsWith("ko")) ++ families.addAll(List.of("Noto Sans KR", "Noto Sans TC", "Noto Sans SC", "Noto Sans JP", "Noto Sans")); ++ else if (locale.startsWith("ar_")) ++ families.addAll(List.of("Noto Sans AR", "Noto Sans", "Noto Sans KR", "Noto Sans TC", "Noto Sans SC", "Noto Sans JP")); ++ else ++ families.addAll(List.of("Noto Sans", "Noto Sans SC", "Noto Sans TC", "Noto Sans JP", "Noto Sans KR", "Noto Sans AR")); ++ families.addAll(List.of("Noto Sans Mongolian", "Noto Sans Syriac", "Noto Sans TamilSupplement")); ++ // Preserve unavailable slots and aliases for the ordinary assignment and line-construction contracts. ++ return families.stream().map(name -> this.faces.stream().filter(face -> face.family.equals(name) ++ && face.weight == 400 && face.style.equals("normal")).findFirst().orElse(null)).toList(); ++ } ++ ++ public List images() { return this.images; } ++ public byte[] image(final String name) throws IOException { ++ if (!this.images.contains(name)) throw new IOException("OreUI image does not belong to this bundle"); ++ return required(this.files, name).clone(); ++ } ++ public String stylesheet() { return new String(this.files.get(this.stylesheet), StandardCharsets.UTF_8); } ++ byte[] font(final Face face) throws IOException { ++ if (!this.faces.contains(face)) throw new IOException("OreUI face does not belong to this bundle"); ++ return required(this.files, face.file); ++ } ++ ++ private static byte[] required(final Map files, final String name) throws IOException { ++ final byte[] bytes = files.get(name); ++ if (bytes == null) throw new IOException("Missing OreUI bundle file: " + name); ++ return bytes; ++ } ++ private static JsonObject json(final byte[] bytes) { return JsonParser.parseString(new String(bytes, StandardCharsets.UTF_8)).getAsJsonObject(); } ++ private static String hash(final byte[] bytes) { ++ try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); } ++ catch (final NoSuchAlgorithmException impossible) { throw new AssertionError(impossible); } ++ } ++ private static byte[] resource(final String name, final int limit) throws IOException { ++ try (InputStream input = OreUiAssets.class.getResourceAsStream(name)) { ++ if (input == null) throw new IOException("Missing bundled OreUI assets"); ++ final byte[] bytes = input.readNBytes(limit + 1); ++ if (bytes.length == 0 || bytes.length > limit) throw new IOException("Invalid bundled OreUI asset size"); ++ return bytes; ++ } ++ } ++ public record Face(String family, String file, int weight, String style, boolean sdfOff) { } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiBox.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiBox.java +new file mode 100644 +index 0000000..cdcc588 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiBox.java +@@ -0,0 +1,42 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.render.oreui; + -+ public static void tick() { -+ boolean retired = false; -+ for (final var state : states.values()) { -+ final boolean active = state.active(); -+ state.tick(); -+ retired |= active && !state.active(); -+ } -+ if (retired) retainTextCaches(); ++/** Physical OreUI bounds before transforms. Snapping applies to a resolved box, before cached text offsets. */ ++public record OreUiBox(float x, float y, float width, float height) { ++ public OreUiBox { ++ if (!Float.isFinite(x) || !Float.isFinite(y) || !Float.isFinite(width) || !Float.isFinite(height)) ++ throw new IllegalArgumentException("Invalid OreUI box"); + } + -+ private static void retainTextCaches() { -+ if (renderer != null) renderer.retain(states.values().stream().filter(NativeHudOverlayState::active) -+ .map(state -> state.scene().root()).toList()); ++ /** The caller resolves the node's native rendering option. Insets stay fractional until the content box is complete. */ ++ public OreUiBox content(final Insets border, final Insets padding, final boolean snap) { ++ final var content = new OreUiBox((border.left + this.x) + padding.left, ++ (border.top + this.y) + padding.top, ++ this.width - (((border.left + border.right) + padding.left) + padding.right), ++ this.height - (((border.top + border.bottom) + padding.top) + padding.bottom)); ++ return snap ? content.snapped() : content; + } + -+ public static boolean draw(final Kind kind, final GuiGraphicsExtractor graphics, final DeltaTracker delta) { -+ final var state = states.get(kind); -+ if (!state.active()) return false; -+ final var client = Minecraft.getInstance(); -+ try { -+ if (renderer == null || resourceManager != client.getResourceManager()) { -+ closeResources(); -+ resourceManager = client.getResourceManager(); -+ images = new NativeUiImages(); -+ renderer = new NativeUiRenderer(client.font, images); -+ } -+ final var viewport = NativeUiViewport.current(); -+ final long now = state.time(delta.getGameTimeDeltaPartialTick(false)); -+ state.animations().advance(state.scene().root(), now); -+ final var layout = state.layout(renderer, viewport.width(), viewport.height(), images.revision()); -+ final var draws = NativeUiPresentation.draws(layout, _ -> false, node -> state.animations().opacityAt(node, now)); -+ graphics.pose().pushMatrix(); -+ try { -+ graphics.pose().scale(viewport.renderScale(), viewport.renderScale()); -+ for (final var draw : draws) { -+ if (draw.alpha() <= 0 || draw.box().visibleBounds().width() <= 0 || draw.box().visibleBounds().height() <= 0) continue; -+ final var clip = draw.box().clip(); -+ NativeUiScissor.enable(graphics, clip); -+ try { renderer.draw(graphics, draw, state.animations()); } -+ finally { graphics.disableScissor(); } -+ } -+ } finally { graphics.pose().popMatrix(); } -+ return true; -+ } catch (RuntimeException invalid) { -+ state.clear(); -+ retainTextCaches(); -+ ViaFabricPlusBedrock.impl().logger().warn("Could not render native HUD " + kind, invalid); -+ return false; -+ } ++ public OreUiBox snapped() { ++ final float fractionX = this.x - truncate(this.x), fractionY = this.y - truncate(this.y); ++ return new OreUiBox(round(this.x), round(this.y), ++ round(fractionX + this.width) - round(fractionX), ++ round(fractionY + this.height) - round(fractionY)); + } + -+ private static void closeResources() { -+ if (renderer != null) renderer.close(); -+ if (images != null) images.close(); -+ renderer = null; images = null; resourceManager = null; ++ // Native rectangle snapping adds a signed half in float precision before cvttss2si. ++ // This differs from CRT roundf and from rounding each absolute edge independently. ++ private static float round(final float value) { return truncate(value + Math.copySign(.5F, value)); } ++ ++ private static int truncate(final float value) { ++ return value >= 0x1p31F || value < -0x1p31F ? Integer.MIN_VALUE : (int) value; + } -+ public static void resourcesReloaded() { closeResources(); } -+ public static void clear() { -+ for (final var state : states.values()) state.clear(); -+ closeResources(); ++ ++ public record Insets(float left, float right, float top, float bottom) { ++ public static final Insets NONE = new Insets(0, 0, 0, 0); + } +} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/BedrockNativeUiHurt.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/BedrockNativeUiHurt.java +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiCharacters.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiCharacters.java new file mode 100644 -index 0000000..dcc7c1e +index 0000000..71cca75 --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/BedrockNativeUiHurt.java -@@ -0,0 +1,67 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiCharacters.java +@@ -0,0 +1,71 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ -+package com.viaversion.viafabricplus.bedrock.screen.ui; ++package com.viaversion.viafabricplus.bedrock.render.oreui; + -+import com.viaversion.viafabricplus.bedrock.render.BedrockActorState; ++import java.util.ArrayList; ++import java.util.List; +import java.util.Objects; -+import java.util.UUID; -+import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; -+import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; -+import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; -+import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry; -+import net.minecraft.client.Minecraft; -+import net.minecraft.network.Connection; -+import net.raphimc.viabedrock.api.model.NativeUiHurtEvent; -+import net.raphimc.viabedrock.api.model.NativeUiHurtState; + -+/** The native pending check belongs to the current player, including while no screen can close. */ -+public final class BedrockNativeUiHurt { -+ private static final NativeUiHurtState STATE = new NativeUiHurtState(); -+ private static Connection owner; -+ private static UUID lifetime; ++/** Native newline and whitespace preprocessing retains offsets into the original UTF-16 source. */ ++public record OreUiCharacters(List characters, int sourceLength, float trailingAdvance) { ++ public OreUiCharacters { characters = List.copyOf(characters); } ++ ++ /** The native caller supplies this policy separately from the CSS white-space property. */ ++ public enum LineEndings { NORMALIZE, REMOVE, IGNORE_CR } ++ public record Scalar(int value, int sourceOffset) { } ++ ++ public static OreUiCharacters prepare(final String source, final LineEndings endings, ++ final OreUiLines.WhiteSpace whitespace, final float fontPixels, final float letterSpacing) { ++ Objects.requireNonNull(source); Objects.requireNonNull(endings); Objects.requireNonNull(whitespace); ++ if (source.length() > 32768 || !Float.isFinite(fontPixels) || fontPixels <= 0 || fontPixels > 1024 ++ || !Float.isFinite(letterSpacing)) throw new IllegalArgumentException("Invalid OreUI text bounds"); ++ final List decoded = new ArrayList<>(); ++ boolean carriageReturn = false; ++ int count = 0; ++ for (int offset = 0; offset < source.length();) { ++ final char first = source.charAt(offset); ++ if (Character.isSurrogate(first) && (!Character.isHighSurrogate(first) || offset + 1 == source.length() ++ || !Character.isLowSurrogate(source.charAt(offset + 1)))) ++ throw new IllegalArgumentException("Invalid OreUI Unicode text"); ++ if (++count > 16384) throw new IllegalArgumentException("OreUI text exceeds character limits"); ++ final int value = source.codePointAt(offset), start = offset; ++ offset += Character.charCount(value); ++ if (value == '\r') { ++ if (endings == LineEndings.NORMALIZE) decoded.add(new Scalar('\n', start)); ++ carriageReturn = true; ++ } else { ++ if (value != '\n' || endings != LineEndings.REMOVE ++ && (endings != LineEndings.NORMALIZE || !carriageReturn)) decoded.add(new Scalar(value, start)); ++ carriageReturn = false; ++ } ++ } ++ if (whitespace == OreUiLines.WhiteSpace.PRE || whitespace == OreUiLines.WhiteSpace.PRE_WRAP) ++ return new OreUiCharacters(decoded, source.length(), 0F); ++ float trailingAdvance = 0F; ++ if (!decoded.isEmpty() && horizontalSpace(decoded.getLast().value)) trailingAdvance = fontPixels * .25F + letterSpacing; ++ if (!Float.isFinite(trailingAdvance)) throw new IllegalArgumentException("Invalid OreUI trailing advance"); ++ final List collapsed = new ArrayList<>(); ++ for (final var scalar : decoded) { ++ if (horizontalSpace(scalar.value) || scalar.value == '\r' || scalar.value == '\n') { ++ if (!collapsed.isEmpty() && collapsed.getLast().value != ' ') collapsed.add(new Scalar(' ', scalar.sourceOffset)); ++ } else collapsed.add(scalar); ++ } ++ if (!collapsed.isEmpty() && collapsed.getLast().value == ' ') collapsed.removeLast(); ++ return new OreUiCharacters(collapsed, source.length(), trailingAdvance); ++ } + -+ private BedrockNativeUiHurt() {} ++ public int[] scalars() { return this.characters.stream().mapToInt(Scalar::value).toArray(); } ++ public String text() { return new String(this.scalars(), 0, this.characters.size()); } + -+ public static void register() { -+ PayloadTypeRegistry.clientboundPlay().register(NativeUiHurtPayload.TYPE, NativeUiHurtPayload.CODEC); -+ ClientPlayConnectionEvents.INIT.register((listener, client) -> -+ ClientPlayNetworking.registerReceiver(NativeUiHurtPayload.TYPE, (payload, context) -> -+ client.execute(() -> apply(listener.getConnection(), payload.event(), client)))); -+ ClientPlayConnectionEvents.DISCONNECT.register((listener, client) -> { -+ if (listener.getConnection() == owner) { -+ STATE.clear(); -+ owner = null; -+ lifetime = null; -+ } -+ }); -+ ClientTickEvents.START_CLIENT_TICK.register(BedrockNativeUiHurt::tick); ++ /** The end of a processed range can still include removed characters at the end of its source. */ ++ public int sourceOffset(final int scalarIndex) { ++ return scalarIndex == this.characters.size() ? this.sourceLength : this.characters.get(scalarIndex).sourceOffset; + } + -+ private static void synchronize(final Minecraft client) { -+ final var listener = client.getConnection(); -+ final Connection current = listener == null || client.level == null || client.player == null -+ || !listener.getConnection().isConnected() ? null : listener.getConnection(); -+ final UUID currentLifetime = current == null ? null : BedrockActorState.lifetime(client.player.getUUID()); -+ if (current != owner || !Objects.equals(currentLifetime, lifetime)) { -+ STATE.clear(); -+ owner = current; -+ lifetime = currentLifetime; -+ } -+ } -+ -+ static void apply(final Connection expected, final NativeUiHurtEvent event, final Minecraft client) { -+ synchronize(client); -+ if (expected == owner && event.lifetime().equals(lifetime)) STATE.accept(event); -+ } -+ -+ static void tick(final Minecraft client) { -+ synchronize(client); -+ if (owner == null || lifetime == null) return; -+ final var screen = client.gui.screen(); -+ final boolean policy = screen instanceof NativeUiScreen nativeScreen && nativeScreen.closeOnPlayerHurt(); -+ if (STATE.tick(policy) && client.gui.screen() == screen) screen.onClose(); ++ private static boolean horizontalSpace(final int scalar) { ++ return scalar == 0 || scalar == '\t' || scalar == 0xb || scalar == 0xc || scalar == ' '; + } +} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeHudOverlayState.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeHudOverlayState.java +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiDistanceField.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiDistanceField.java new file mode 100644 -index 0000000..6db85a8 +index 0000000..0bcafe9 --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeHudOverlayState.java -@@ -0,0 +1,66 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiDistanceField.java +@@ -0,0 +1,157 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ -+package com.viaversion.viafabricplus.bedrock.screen.ui; -+ -+import java.util.Set; -+import java.util.Map; -+import net.raphimc.viabedrock.api.model.NativeHudMessage; ++package com.viaversion.viafabricplus.bedrock.render.oreui; ++ ++/** Native CPU distance transform for the 52-pixel OreUI glyph cache. */ ++public final class OreUiDistanceField { ++ private OreUiDistanceField() { } ++ private static final float INV_255 = Float.intBitsToFloat(0x3b808080); ++ private static final float SQRT_2 = Float.intBitsToFloat(0x3fb504f3); ++ private static final float EPSILON = 0.001F; ++ ++ public record Field(int width, int height, int stride, byte[] pixels) {} ++ ++ public static Field generate(byte[] source, int width, int height, int pitch) { ++ if (width <= 0 || height <= 0 || width > 2048 || height > 2048 || pitch != width ++ || source.length != pitch * height) { ++ throw new IllegalArgumentException("Invalid OreUI distance-field source"); ++ } ++ int sw = width + 2, sh = height + 2; ++ byte[] padded = new byte[sw * sh]; ++ for (int y = 0; y < height; y++) { ++ System.arraycopy(source, y * pitch, padded, (y + 1) * sw + 1, width); ++ } ++ int w = width + 10, h = height + 10; ++ float[] alpha = new float[w * h], squared = new float[w * h]; ++ float[] dx = new float[w * h], dy = new float[w * h]; ++ boolean[] seed = new boolean[w * h]; ++ int[] nx = {-1, 1, -1, 0, 1, -1, 0, 1}; ++ int[] ny = {0, 0, -1, -1, -1, 1, 1, 1}; ++ for (int y = 0; y < sh; y++) { ++ for (int x = 0; x < sw; x++) { ++ int a = Byte.toUnsignedInt(padded[y * sw + x]); ++ int i = (y + 4) * w + x + 4; ++ alpha[i] = a == 255 ? 1F : a * INV_255; ++ for (int n = 0; n < 8; n++) { ++ int xx = x + nx[n], yy = y + ny[n]; ++ int b = xx < 0 || xx >= sw || yy < 0 || yy >= sh ? 0 ++ : Byte.toUnsignedInt(padded[yy * sw + xx]); ++ if ((a >= 128) != (b >= 128) || (a < 128 && b < 128 && a != 0 && b != 0)) { ++ seed[i] = true; ++ break; ++ } ++ } ++ } ++ } ++ for (int i = 0; i < alpha.length; i++) { ++ squared[i] = 2_000_000F; ++ dx[i] = dy[i] = 1000F; ++ if (!seed[i]) continue; ++ float gx = ((((alpha[i - w + 1] - alpha[i - w - 1]) + alpha[i + 1] * SQRT_2) ++ - alpha[i - 1] * SQRT_2) + alpha[i + w + 1]) - alpha[i + w - 1]; ++ float gy = ((((alpha[i + w - 1] - alpha[i - w - 1]) + alpha[i + w] * SQRT_2) ++ - alpha[i - w] * SQRT_2) + alpha[i + w + 1]) - alpha[i - w + 1]; ++ if (Math.abs(gx) > EPSILON || Math.abs(gy) > EPSILON) { ++ float inverse = 1F / sqrt(gy * gy + gx * gx); ++ gx *= inverse; ++ gy *= inverse; ++ } ++ float distance = edgeDistance(gx, gy, alpha[i]); ++ dx[i] = distance * gx; ++ dy[i] = distance * gy; ++ squared[i] = distance * distance; ++ } ++ for (int y = 1; y < h - 1; y++) { ++ for (int x = 1; x < w - 1; x++) { ++ int i = y * w + x; ++ if (seed[i]) continue; ++ int s = i - w - 1; ++ float t = (dy[s] + dx[s]) - 1F; ++ update(i, s, squared[s] - (t + t), -1F, -1F, squared, dx, dy); ++ s = i - w; ++ update(i, s, (squared[s] - (dy[s] + dy[s])) + 1F, 0F, -1F, squared, dx, dy); ++ s = i - w + 1; ++ t = (dx[s] - dy[s]) + 1F; ++ update(i, s, t + t + squared[s], 1F, -1F, squared, dx, dy); ++ left(i, squared, dx, dy); ++ } ++ for (int x = w - 2; x >= 1; x--) { ++ int i = y * w + x; ++ if (!seed[i]) right(i, squared, dx, dy); ++ } ++ } ++ // Preserve the original pointer traversal, including its wrapped border cells. ++ int start = (h - 2) * w - 1; ++ for (int row = 0; row < h - 2; row++, start -= w) { ++ int i = start; ++ for (int x = 0; x < w - 2; x++, i++) { ++ if (!seed[i]) left(i, squared, dx, dy); ++ } ++ i--; ++ for (int x = 0; x < w - 2; x++, i--) { ++ if (seed[i]) continue; ++ right(i, squared, dx, dy); ++ int s = i + w - 1; ++ float t = (dx[s] - dy[s]) - 1F; ++ update(i, s, squared[s] - (t + t), -1F, 1F, squared, dx, dy); ++ s = i + w; ++ update(i, s, dy[s] + dy[s] + squared[s] + 1F, 0F, 1F, squared, dx, dy); ++ s = i + w + 1; ++ t = dy[s] + dx[s] + 1F; ++ update(i, s, t + t + squared[s], 1F, 1F, squared, dx, dy); ++ } ++ } ++ int outWidth = w - 2, outHeight = h - 2, stride = (outWidth + 3) & ~3; ++ byte[] pixels = new byte[stride * outHeight]; ++ for (int y = 0; y < outHeight; y++) { ++ for (int x = 0; x < outWidth; x++) { ++ int i = (y + 1) * w + x + 1; ++ float distance = sqrt(squared[i]); ++ if (alpha[i] > 0.5F) distance = -distance; ++ int value = distance <= -4F ? 255 : distance > 4F ? 0 ++ : (int) (((4F - distance) * 128F) / 4F); ++ pixels[y * stride + x] = (byte) value; ++ } ++ } ++ return new Field(outWidth, outHeight, stride, pixels); ++ } + -+/** One factory instance and its clock; scene replacement does not revive an expired title. */ -+final class NativeHudOverlayState { -+ private NativeUiScene scene; -+ private NativeUiAnimations animations; -+ private NativeUiAnimationLifecycle lifecycle; -+ private long elapsed; -+ private int lifetime; -+ private NativeUiLayout.Box layout; -+ private NativeUiLayout.Metrics metrics; -+ private int width, height; -+ private long layoutRevision, imageRevision; ++ private static void left(int i, float[] squared, float[] dx, float[] dy) { ++ int s = i - 1; ++ update(i, s, (squared[s] - (dx[s] + dx[s])) + 1F, -1F, 0F, squared, dx, dy); ++ } + -+ boolean accept(final NativeHudMessage message) { -+ if (message.scene().isEmpty()) { clear(); return true; } -+ if (!message.restart() && !active()) return false; -+ final var next = NativeUiScene.parse(message.scene(), Set.of()); -+ if (message.restart()) { -+ this.elapsed = 0; -+ this.lifecycle = new NativeUiAnimationLifecycle(next.root()); -+ } else this.lifecycle.replaceRoot(next.root(), time(0)); -+ this.scene = next; -+ this.lifetime = message.ticks(); -+ this.animations = this.lifecycle.animations(); -+ this.lifecycle.advance(time(0)); -+ this.layout = null; -+ if (!active()) clear(); -+ return true; ++ private static void right(int i, float[] squared, float[] dx, float[] dy) { ++ int s = i + 1; ++ update(i, s, dx[s] + dx[s] + squared[s] + 1F, 1F, 0F, squared, dx, dy); + } + -+ boolean active() { return this.scene != null && (this.lifetime == NativeHudMessage.ANIMATION_LIFETIME ? this.lifecycle.active() : this.elapsed < this.lifetime); } -+ NativeUiScene scene() { return this.scene; } -+ NativeUiAnimations animations() { return this.animations; } -+ long time(final float partialTick) { return (long) ((this.elapsed + partialTick) * 50_000_000D); } ++ private static void update(int i, int s, float candidate, float x, float y, ++ float[] squared, float[] dx, float[] dy) { ++ if (candidate < squared[i]) { ++ squared[i] = candidate; ++ dx[i] = dx[s] + x; ++ dy[i] = dy[s] + y; ++ } ++ } + -+ NativeUiLayout.Box layout(final NativeUiLayout.Metrics metrics, final int width, final int height, final long imageRevision) { -+ if (this.layout == null || this.metrics != metrics || this.width != width || this.height != height -+ || this.layoutRevision != this.animations.layoutRevision() || this.imageRevision != imageRevision) { -+ this.layout = new NativeUiLayout(metrics, this.animations).layout(this.scene.root(), width, height, Map.of()); -+ this.metrics = metrics; -+ this.width = width; -+ this.height = height; -+ this.layoutRevision = this.animations.layoutRevision(); -+ this.imageRevision = imageRevision; ++ private static float edgeDistance(float gx, float gy, float alpha) { ++ if (Math.abs(gx) <= EPSILON || Math.abs(gy) <= EPSILON) return 0.5F - alpha; ++ float max = Math.max(Math.abs(gx), Math.abs(gy)), min = Math.min(Math.abs(gx), Math.abs(gy)); ++ if (min * 0.5F <= max * alpha) { ++ if (max - min * 0.5F <= max * alpha) { ++ return sqrt((max + max) * min * (1F - alpha)) - (min + max) * 0.5F; ++ } ++ return (0.5F - alpha) * max; + } -+ return this.layout; ++ return (max + min) * 0.5F - sqrt((max + max) * min * alpha); + } + -+ void tick() { -+ if (this.scene == null) return; -+ this.elapsed++; -+ this.lifecycle.advance(time(0)); -+ if (!active()) clear(); ++ private static float sqrt(float value) { ++ return (float) Math.sqrt(value); + } -+ void clear() { this.scene = null; this.animations = null; this.lifecycle = null; this.layout = null; this.metrics = null; this.elapsed = 0; this.lifetime = 0; } ++ +} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeHudPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeHudPayload.java +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiFont.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiFont.java new file mode 100644 -index 0000000..a93257e +index 0000000..a92f73b --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeHudPayload.java -@@ -0,0 +1,36 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiFont.java +@@ -0,0 +1,337 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ -+package com.viaversion.viafabricplus.bedrock.screen.ui; ++package com.viaversion.viafabricplus.bedrock.render.oreui; + -+import net.minecraft.network.RegistryFriendlyByteBuf; -+import net.minecraft.network.codec.StreamCodec; -+import net.minecraft.network.protocol.common.custom.CustomPacketPayload; -+import net.minecraft.resources.Identifier; -+import net.raphimc.viabedrock.api.model.NativeHudMessage; ++import java.io.IOException; ++import java.lang.foreign.Arena; ++import java.lang.foreign.FunctionDescriptor; ++import java.lang.foreign.Linker; ++import java.lang.foreign.MemoryLayout; ++import java.lang.foreign.MemorySegment; ++import java.lang.invoke.MethodHandles; ++import java.nio.ByteBuffer; ++import java.nio.ByteOrder; ++import java.util.ArrayList; ++import java.util.List; ++import org.lwjgl.system.MemoryStack; ++import org.lwjgl.util.freetype.FT_Face; ++import org.lwjgl.util.freetype.FT_Vector; ++import org.lwjgl.util.freetype.FreeType; ++import static com.viaversion.viafabricplus.bedrock.render.oreui.OreUiShaper.I; ++import static com.viaversion.viafabricplus.bedrock.render.oreui.OreUiShaper.NULL; ++import static com.viaversion.viafabricplus.bedrock.render.oreui.OreUiShaper.P; ++import static java.lang.foreign.ValueLayout.ADDRESS; ++import static java.lang.foreign.ValueLayout.JAVA_INT; ++ ++/** A shaped glyph face and its FreeType resources share one engine lifetime and serialization lock. */ ++public final class OreUiFont implements AutoCloseable { ++ private static final int MAX_FONT_BYTES = 16 * 1024 * 1024; ++ private static final int MAX_GLYPHS = 16384; ++ // Ordinary GPOS measurement retains kerning but disables these substitutions and positions. ++ private static final String[] ADVANCE_FEATURES = {"kern", "abvm", "blwm", "ccmp", "locl", "mark", "mkmk", ++ "rlig", "calt", "curs", "dist", "liga", "rclt"}; ++ private final OreUiShaper engine; ++ private final Arena data = Arena.ofShared(); ++ private final Arena callbacks = Arena.ofShared(); ++ private long library; ++ private FT_Face face; ++ private final VerticalMetrics verticalMetrics; ++ private MemorySegment font = NULL, functions = NULL; ++ private Throwable failure; ++ private boolean closed; ++ private boolean kern; ++ private boolean gpos; + -+/** Separate capabilities retain the existing title wire format for older add-ons. */ -+public record NativeHudPayload(Kind kind, NativeHudMessage message) implements CustomPacketPayload { -+ public enum Kind { -+ ROOT(NativeHudMessage.ROOT_CHANNEL), -+ TITLE(NativeHudMessage.CHANNEL), -+ ACTIONBAR(NativeHudMessage.ACTIONBAR_CHANNEL); ++ OreUiFont(final OreUiShaper engine, final byte[] bytes, final int pixels) throws IOException { ++ this.engine = engine; ++ try { ++ if (bytes.length < 12 || bytes.length > MAX_FONT_BYTES || pixels < 1 || pixels > 2048) ++ throw new IOException("Invalid OreUI font bounds"); ++ final var source = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN); ++ if (source.getInt(0) != 0x4f54544f && source.getInt(0) != 0x00010000) throw new IOException("Unsupported OreUI font format"); ++ final MemorySegment memory = this.data.allocate(bytes.length); ++ memory.copyFrom(MemorySegment.ofArray(bytes)); ++ try (var stack = MemoryStack.stackPush()) { ++ final var pointer = stack.mallocPointer(1); ++ if (FreeType.FT_Init_FreeType(pointer) != 0) throw new IOException("Cannot initialize OreUI font reader"); ++ this.library = pointer.get(0); ++ if (FreeType.FT_New_Memory_Face(this.library, memory.asByteBuffer(), 0, pointer) != 0) ++ throw new IOException("Invalid OreUI font face"); ++ this.face = FT_Face.create(pointer.get(0)); ++ if (FreeType.FT_Set_Pixel_Sizes(this.face, 0, pixels) != 0) throw new IOException("Invalid OreUI font size"); ++ } ++ final int units = this.face.units_per_EM(); ++ if (units == 0) throw new IOException("Invalid OreUI font units"); ++ this.verticalMetrics = new VerticalMetrics(-(int) this.face.ascender() / (float) units, ++ -(int) this.face.descender() / (float) units, ++ ((int) this.face.height() - this.face.ascender() + this.face.descender()) / (float) units); ++ final var blob = this.engine.pointer("hb_blob_create", new MemoryLayout[]{P,I,I,P,P}, memory, bytes.length, 1, NULL, NULL); ++ final MemorySegment hbFace; ++ try { hbFace = this.engine.pointer("hb_face_create", new MemoryLayout[]{P,I}, blob, 0); } ++ finally { this.engine.run("hb_blob_destroy", new MemoryLayout[]{P}, blob); } ++ try { ++ this.font = this.engine.pointer("hb_font_create", new MemoryLayout[]{P}, hbFace); ++ if (this.font.address() == 0) throw new IOException("Cannot create OreUI shaping font"); ++ final var gpos = this.engine.pointer("hb_face_reference_table", new MemoryLayout[]{P,I}, hbFace, 0x47504f53); ++ try { ++ this.gpos = this.engine.integer("hb_blob_get_length", new MemoryLayout[]{P}, gpos) != 0; ++ this.kern = this.gpos || (this.face.face_flags() & FreeType.FT_FACE_FLAG_KERNING) != 0; ++ } ++ finally { this.engine.run("hb_blob_destroy", new MemoryLayout[]{P}, gpos); } ++ } finally { this.engine.run("hb_face_destroy", new MemoryLayout[]{P}, hbFace); } ++ final var metrics = this.face.size().metrics(); ++ this.engine.run("hb_font_set_scale", new MemoryLayout[]{P,I,I}, this.font, ++ (int) ((metrics.x_scale() * this.face.units_per_EM() + 0x8000) >> 16), ++ (int) ((metrics.y_scale() * this.face.units_per_EM() + 0x8000) >> 16)); ++ this.functions = this.engine.pointer("hb_font_funcs_create", new MemoryLayout[0]); ++ this.register("nominal", "nominal_glyph", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_INT, ADDRESS, ADDRESS)); ++ this.register("advance", "glyph_h_advance", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_INT, ADDRESS)); ++ this.register("extents", "glyph_extents", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_INT, ADDRESS, ADDRESS)); ++ this.register("contour", "glyph_contour_point", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_INT, JAVA_INT, ADDRESS, ADDRESS, ADDRESS)); ++ this.engine.run("hb_font_funcs_make_immutable", new MemoryLayout[]{P}, this.functions); ++ this.engine.run("hb_font_set_funcs", new MemoryLayout[]{P,P,P,P}, this.font, this.functions, NULL, NULL); ++ } catch (final IOException | RuntimeException | Error failure) { ++ try { this.close(); } catch (final RuntimeException | Error cleanup) { failure.addSuppressed(cleanup); } ++ throw failure; ++ } catch (final ReflectiveOperationException failure) { ++ this.close(); ++ throw new IOException("Cannot initialize OreUI font callbacks", failure); ++ } ++ } + -+ private final Type type; -+ private final StreamCodec codec; ++ private void register(final String method, final String suffix, final FunctionDescriptor descriptor) throws ReflectiveOperationException { ++ final var handle = MethodHandles.lookup().findVirtual(OreUiFont.class, method, descriptor.toMethodType()).bindTo(this); ++ final var stub = Linker.nativeLinker().upcallStub(handle, descriptor, this.callbacks); ++ this.engine.run("hb_font_funcs_set_" + suffix + "_func", new MemoryLayout[]{P,P,P,P}, this.functions, stub, NULL, NULL); ++ } + -+ Kind(final String channel) { -+ this.type = new Type<>(Identifier.parse(channel)); -+ this.codec = CustomPacketPayload.codec( -+ (payload, buffer) -> NativeHudMessage.TYPE.write(buffer, payload.message), -+ buffer -> new NativeHudPayload(this, NativeHudMessage.TYPE.read(buffer))); ++ private int nominal(final MemorySegment font, final MemorySegment data, final int codepoint, final MemorySegment output, final MemorySegment user) { ++ try { ++ final int glyph = FreeType.FT_Get_Char_Index(this.face, codepoint); ++ output.reinterpret(4).set(JAVA_INT, 0, glyph); ++ return glyph == 0 ? 0 : 1; ++ } catch (final Throwable failure) { this.failure = failure; return 0; } ++ } ++ ++ private int advance(final MemorySegment font, final MemorySegment data, final int glyph, final MemorySegment user) { ++ try (var stack = MemoryStack.stackPush()) { ++ final var value = stack.mallocCLong(1); ++ if (FreeType.FT_Get_Advance(this.face, glyph, 2, value) != 0) throw new IllegalStateException("Invalid OreUI glyph advance"); ++ return (int) ((value.get(0) + 0x200) >> 10); ++ } catch (final Throwable failure) { this.failure = failure; return 0; } ++ } ++ ++ private int extents(final MemorySegment font, final MemorySegment data, final int glyph, final MemorySegment output, final MemorySegment user) { ++ try { ++ if (FreeType.FT_Load_Glyph(this.face, glyph, 2) != 0) throw new IllegalStateException("Invalid OreUI glyph extents"); ++ final var metrics = this.face.glyph().metrics(); ++ final var value = output.reinterpret(16); ++ value.set(JAVA_INT, 0, (int) metrics.horiBearingX()); value.set(JAVA_INT, 4, (int) metrics.horiBearingY()); ++ value.set(JAVA_INT, 8, (int) metrics.width()); value.set(JAVA_INT, 12, (int) -metrics.height()); ++ return 1; ++ } catch (final Throwable failure) { this.failure = failure; return 0; } ++ } ++ ++ private int contour(final MemorySegment font, final MemorySegment data, final int glyph, final int index, ++ final MemorySegment x, final MemorySegment y, final MemorySegment user) { ++ try { ++ if (FreeType.FT_Load_Glyph(this.face, glyph, 2) != 0) throw new IllegalStateException("Invalid OreUI glyph contour"); ++ final var slot = this.face.glyph(); ++ if (slot.format() != FreeType.FT_GLYPH_FORMAT_OUTLINE || index < 0 || index >= slot.outline().n_points()) return 0; ++ final var point = slot.outline().points().get(index); ++ x.reinterpret(4).set(JAVA_INT, 0, (int) point.x()); y.reinterpret(4).set(JAVA_INT, 0, (int) point.y()); ++ return 1; ++ } catch (final Throwable failure) { this.failure = failure; return 0; } ++ } ++ ++ /** Clusters index Unicode codepoints in the supplied text, not UTF-16 code units. */ ++ public Run shape(final String text, final String language) throws IOException { ++ return this.shape(text, language, false); ++ } ++ ++ /** Nominal coverage remains separate from shaping substitutions and ordinary measurement. */ ++ public int[] glyphs(final int[] scalars) throws IOException { ++ synchronized (this.engine) { ++ this.requireOpen(); ++ if (scalars.length > MAX_GLYPHS) throw new IOException("OreUI text exceeds shaping limits"); ++ final int[] glyphs = new int[scalars.length]; ++ for (int index = 0; index < scalars.length; index++) { ++ final int scalar = scalars[index]; ++ if (!Character.isValidCodePoint(scalar) || scalar >= 0xd800 && scalar <= 0xdfff) ++ throw new IOException("Invalid OreUI Unicode scalar"); ++ glyphs[index] = FreeType.FT_Get_Char_Index(this.face, scalar); ++ } ++ return glyphs; ++ } ++ } ++ ++ /** Per-Unicode-scalar advances for the ordinary Cohtml measurement path, separate from a shaped draw run. */ ++ public float[] ordinaryAdvances(final String text, final String language) throws IOException { ++ synchronized (this.engine) { ++ this.requireOpen(); ++ final int[] codepoints = codepoints(text, language); ++ final float[] advances = new float[codepoints.length]; ++ if (this.gpos && codepoints.length >= 2) { ++ final var run = this.shape(text, language, true); ++ int previous = -1; ++ for (final var glyph : run.glyphs()) { ++ final int cluster = glyph.cluster(); ++ if (cluster < 0 || cluster >= advances.length) throw new IOException("Invalid OreUI measurement cluster"); ++ final float advance = glyph.advanceX() / 64F; ++ advances[cluster] = cluster == previous ? advance + advances[cluster] : advance; ++ previous = cluster; ++ } ++ } else { ++ final int[] glyphs = this.glyphs(codepoints); ++ for (int index = 0; index < codepoints.length; index++) { ++ if (FreeType.FT_Load_Glyph(this.face, glyphs[index], 10) != 0) ++ throw new IOException("Invalid OreUI measurement glyph"); ++ advances[index] = this.face.glyph().advance().x() / 64F; ++ } ++ if (this.kern && !this.gpos && glyphs.length >= 2) { ++ final int space = FreeType.FT_Get_Char_Index(this.face, ' '); ++ try (var stack = MemoryStack.stackPush()) { ++ final var vector = FT_Vector.calloc(stack); ++ for (int index = 0; index < glyphs.length - 1; index++) { ++ if (glyphs[index] == space || glyphs[index + 1] == space) continue; ++ if (FreeType.FT_Get_Kerning(this.face, glyphs[index], glyphs[index + 1], 0, vector) != 0) ++ throw new IOException("Invalid OreUI measurement kerning"); ++ advances[index] += vector.x() / 64F; ++ } ++ } ++ } ++ } ++ return advances; ++ } ++ } ++ ++ private static int[] codepoints(final String text, final String language) throws IOException { ++ if (text.length() > MAX_GLYPHS * 2 || language.length() > 128) throw new IOException("OreUI text exceeds shaping limits"); ++ final int[] codepoints = text.codePoints().toArray(); ++ if (codepoints.length > MAX_GLYPHS) throw new IOException("OreUI text exceeds shaping limits"); ++ return codepoints; ++ } ++ ++ private Run shape(final String text, final String language, final boolean ordinary) throws IOException { ++ synchronized (this.engine) { ++ this.requireOpen(); ++ final int[] codepoints = codepoints(text, language); ++ final var buffer = this.engine.pointer("hb_buffer_create", new MemoryLayout[0]); ++ try (var arena = Arena.ofConfined()) { ++ this.engine.run("hb_buffer_add_utf32", new MemoryLayout[]{P,P,I,I,I}, buffer, ++ arena.allocateFrom(JAVA_INT, codepoints), codepoints.length, 0, -1); ++ final var locale = this.engine.pointer("hb_language_from_string", new MemoryLayout[]{P,I}, arena.allocateFrom(language), -1); ++ this.engine.run("hb_buffer_set_language", new MemoryLayout[]{P,P}, buffer, locale); ++ this.engine.run("hb_buffer_guess_segment_properties", new MemoryLayout[]{P}, buffer); ++ final int featureCount = ordinary ? ADVANCE_FEATURES.length : 1; ++ final var features = arena.allocate(16L * featureCount, 4); ++ for (int index = 0; index < featureCount; index++) { ++ int tag = 0; ++ for (int character = 0; character < 4; character++) tag = (tag << 8) | ADVANCE_FEATURES[index].charAt(character); ++ final long offset = index * 16L; ++ features.set(JAVA_INT, offset, tag); ++ features.set(JAVA_INT, offset + 4, index == 0 && this.kern ? 1 : 0); ++ features.set(JAVA_INT, offset + 8, 0); features.set(JAVA_INT, offset + 12, -1); ++ } ++ this.engine.run("hb_shape", new MemoryLayout[]{P,P,P,I}, this.font, buffer, features, featureCount); ++ this.check(); ++ final var count = arena.allocate(JAVA_INT); ++ final var infos = this.engine.pointer("hb_buffer_get_glyph_infos", new MemoryLayout[]{P,P}, buffer, count); ++ final int length = count.get(JAVA_INT, 0); ++ final var positions = this.engine.pointer("hb_buffer_get_glyph_positions", new MemoryLayout[]{P,P}, buffer, count); ++ if (length < 0 || length > MAX_GLYPHS || length != count.get(JAVA_INT, 0)) throw new IOException("Invalid OreUI shaping output"); ++ final var glyphInfo = infos.reinterpret(length * 20L); ++ final var glyphPositions = positions.reinterpret(length * 20L); ++ final List glyphs = new ArrayList<>(length); ++ long advanceX = 0, advanceY = 0; ++ for (int index = 0; index < length; index++) { ++ final long offset = index * 20L; ++ final var glyph = new Glyph(glyphInfo.get(JAVA_INT, offset), glyphInfo.get(JAVA_INT, offset + 8), ++ glyphPositions.get(JAVA_INT, offset), glyphPositions.get(JAVA_INT, offset + 4), ++ glyphPositions.get(JAVA_INT, offset + 8), glyphPositions.get(JAVA_INT, offset + 12)); ++ glyphs.add(glyph); advanceX += glyph.advanceX; advanceY += glyph.advanceY; ++ } ++ return new Run(this.engine.integer("hb_buffer_get_direction", new MemoryLayout[]{P}, buffer), ++ this.engine.integer("hb_buffer_get_script", new MemoryLayout[]{P}, buffer), List.copyOf(glyphs), advanceX, advanceY); ++ } finally { this.engine.run("hb_buffer_destroy", new MemoryLayout[]{P}, buffer); } + } ++ } + -+ public Type type() { return this.type; } -+ public StreamCodec codec() { return this.codec; } ++ public Raster raster(final int glyph) throws IOException { ++ synchronized (this.engine) { ++ this.requireOpen(); ++ if (glyph < 0 || glyph >= this.face.num_glyphs() || FreeType.FT_Load_Glyph(this.face, glyph, 10) != 0) ++ throw new IOException("Invalid OreUI raster glyph"); ++ final var slot = this.face.glyph(); ++ final int advance = (int) slot.advance().x(); ++ if (FreeType.FT_Render_Glyph(slot, FreeType.FT_RENDER_MODE_NORMAL) != 0) throw new IOException("Cannot render OreUI glyph"); ++ final var bitmap = slot.bitmap(); ++ final int width = bitmap.width(), height = bitmap.rows(), pitch = bitmap.pitch(); ++ if (width == 0 || height == 0) return new Raster(0, 0, 0, slot.bitmap_left(), slot.bitmap_top(), advance, new byte[0]); ++ if (width < 0 || height < 0 || width > 2048 || height > 2048 || pitch < width || pitch > 8192 ++ || bitmap.pixel_mode() != FreeType.FT_PIXEL_MODE_GRAY) throw new IOException("Unsupported OreUI glyph bitmap"); ++ final byte[] pixels = new byte[width * height]; ++ final var source = bitmap.buffer(pitch * height); ++ for (int y = 0; y < height; y++) source.get(y * pitch, pixels, y * width, width); ++ return new Raster(width, height, width, slot.bitmap_left(), slot.bitmap_top(), advance, pixels); ++ } + } + -+ @Override public Type type() { return this.kind.type(); } ++ private void requireOpen() { ++ this.engine.requireOpen(); ++ if (this.closed) throw new IllegalStateException("OreUI font is closed"); ++ } ++ ++ private void check() throws IOException { ++ if (this.failure != null) throw new IOException("OreUI font callback failed", this.failure); ++ } ++ ++ @Override ++ public void close() { ++ synchronized (this.engine) { ++ if (this.closed) return; ++ this.closed = true; ++ try { ++ if (this.font.address() != 0) this.engine.run("hb_font_destroy", new MemoryLayout[]{P}, this.font); ++ } finally { ++ try { if (this.functions.address() != 0) this.engine.run("hb_font_funcs_destroy", new MemoryLayout[]{P}, this.functions); } ++ finally { ++ this.callbacks.close(); ++ if (this.face != null) FreeType.FT_Done_Face(this.face); ++ if (this.library != 0) FreeType.FT_Done_FreeType(this.library); ++ this.data.close(); ++ this.engine.retired(this); ++ } ++ } ++ } ++ } ++ ++ public VerticalMetrics verticalMetrics() { return this.verticalMetrics; } ++ ++ /** Normalized native face metrics, independent of the glyph cache generation height. */ ++ public record VerticalMetrics(float ascenderY, float descenderY, float lineGap) { ++ /** The caller resolves CSS units and font fitting. A null height retains normal font spacing. */ ++ public LineMetrics line(final float pixels, final Float lineHeight) throws IOException { ++ if (!Float.isFinite(pixels) || pixels <= 0 || lineHeight != null && (!Float.isFinite(lineHeight) || lineHeight < 0)) ++ throw new IOException("Invalid OreUI line size"); ++ final float ascent = this.ascenderY * pixels, descent = this.descenderY * pixels, gap = this.lineGap * pixels; ++ final float span = (float) Math.ceil((descent - ascent) + gap); ++ final float leading = lineHeight == null ? 0F : (float) Math.ceil(lineHeight) - span; ++ final float baseline = round((leading + gap) * .5F - ascent); ++ final float advance = round(leading + span); ++ if (!Float.isFinite(baseline) || !Float.isFinite(advance)) throw new IOException("Invalid OreUI line metrics"); ++ return new LineMetrics(baseline, advance); ++ } ++ ++ private static float round(final float value) { ++ return (float) Math.copySign(Math.floor(Math.abs((double) value) + .5), value); ++ } ++ } ++ public record LineMetrics(float baselineOffset, float advance) { } ++ public record Glyph(int index, int cluster, int advanceX, int advanceY, int offsetX, int offsetY) { } ++ public record Run(int direction, int script, List glyphs, long advanceX, long advanceY) { } ++ public record Raster(int width, int height, int stride, int left, int top, int advance, byte[] pixels) { } +} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeHudResources.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeHudResources.java +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiFonts.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiFonts.java new file mode 100644 -index 0000000..8643e56 +index 0000000..7eb9c4b --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeHudResources.java -@@ -0,0 +1,21 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiFonts.java +@@ -0,0 +1,89 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ -+package com.viaversion.viafabricplus.bedrock.screen.ui; ++package com.viaversion.viafabricplus.bedrock.render.oreui; + -+import net.minecraft.server.packs.resources.ResourceManager; -+import net.minecraft.server.packs.resources.SimplePreparableReloadListener; -+import net.minecraft.util.profiling.ProfilerFiller; ++import java.io.IOException; ++import java.util.ArrayList; ++import java.util.Arrays; ++import java.util.List; + -+/** Retires HUD font and image caches on the resource reload's client apply executor. */ -+public final class NativeHudResources extends SimplePreparableReloadListener { -+ @Override -+ protected Boolean prepare(final ResourceManager resources, final ProfilerFiller profiler) { return true; } ++/** Ordered font assignment for the ordinary native path, before line construction and draw shaping. */ ++public record OreUiFonts(List glyphs) { ++ public OreUiFonts { glyphs = List.copyOf(glyphs); } ++ public record Glyph(int scalar, int index, int font, float advance) { } ++ ++ public interface Font { ++ int[] glyphs(int[] scalars) throws IOException; ++ float[] advances(int[] scalars) throws IOException; ++ } ++ ++ /** Null entries retain the positions of unavailable native font families. */ ++ public static OreUiFonts assign(final OreUiCharacters text, final List supplied) throws IOException { ++ final List fonts = new ArrayList<>(supplied); ++ final int[] scalars = text.scalars(); ++ if (scalars.length > 16384 || fonts.size() > 32) throw new IOException("OreUI font assignment exceeds limits"); ++ final int[] glyphs = new int[scalars.length], selected = new int[scalars.length]; ++ final float[] advances = new float[scalars.length]; ++ for (int index = 0; index < fonts.size(); index++) { ++ final var font = fonts.get(index); ++ if (font == null) continue; ++ for (int start = 0; start < scalars.length;) { ++ while (start < scalars.length && glyphs[start] != 0) start++; ++ int end = start; ++ while (end < scalars.length && glyphs[end] == 0) end++; ++ if (start == end) break; ++ final int[] span = Arrays.copyOfRange(scalars, start, end); ++ final int[] mapped = font.glyphs(span); ++ requireGlyphs(mapped, span.length); ++ // Measure the whole unresolved span before the next font fills any remaining holes. ++ final float[] measured = font.advances(span); ++ requireAdvances(measured, span.length); ++ System.arraycopy(mapped, 0, glyphs, start, span.length); ++ System.arraycopy(measured, 0, advances, start, span.length); ++ Arrays.fill(selected, start, end, index); ++ start = end; ++ } ++ } ++ for (int index = 0; index < scalars.length; index++) { ++ if (!whitespace(scalars[index])) continue; ++ if (index > 0 && index + 1 < scalars.length && selected[index - 1] == selected[index + 1] ++ && selected[index] != selected[index + 1]) { ++ final int candidate = selected[index + 1]; ++ final var font = fonts.get(candidate); ++ if (font != null) { ++ final int[] scalar = {scalars[index]}; ++ final int[] mapped = font.glyphs(scalar); ++ requireGlyphs(mapped, 1); ++ if (mapped[0] != 0) { ++ final float[] measured = font.advances(scalar); ++ requireAdvances(measured, 1); ++ selected[index] = candidate; ++ advances[index] = measured[0]; ++ } ++ } ++ } ++ glyphs[index] = -1; ++ } ++ final List result = new ArrayList<>(scalars.length); ++ for (int index = 0; index < scalars.length; index++) ++ result.add(new Glyph(scalars[index], glyphs[index], selected[index], advances[index])); ++ return new OreUiFonts(result); ++ } + -+ @Override -+ protected void apply(final Boolean prepared, final ResourceManager resources, final ProfilerFiller profiler) { -+ BedrockHudOverlays.resourcesReloaded(); ++ private static boolean whitespace(final int scalar) { ++ return scalar == 0 || scalar == '\t' || scalar == '\n' || scalar == 0xb || scalar == 0xc || scalar == ' '; ++ } ++ ++ private static void requireGlyphs(final int[] values, final int count) throws IOException { ++ if (values == null || values.length != count) throw new IOException("Invalid OreUI glyph coverage"); ++ for (final int value : values) if (value < 0) throw new IOException("Invalid OreUI glyph index"); ++ } ++ ++ private static void requireAdvances(final float[] values, final int count) throws IOException { ++ if (values == null || values.length != count) throw new IOException("Invalid OreUI font advances"); ++ for (final float value : values) if (!Float.isFinite(value)) throw new IOException("Invalid OreUI font advance"); + } +} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiActorCamera.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiActorCamera.java +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiGlyphs.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiGlyphs.java new file mode 100644 -index 0000000..774496f +index 0000000..86fb4a1 --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiActorCamera.java -@@ -0,0 +1,60 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiGlyphs.java +@@ -0,0 +1,205 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ -+package com.viaversion.viafabricplus.bedrock.screen.ui; ++package com.viaversion.viafabricplus.bedrock.render.oreui; + -+import com.google.gson.JsonObject; -+import org.joml.Matrix4f; -+import org.joml.Vector3f; ++import com.mojang.blaze3d.platform.NativeImage; ++import com.mojang.blaze3d.systems.RenderSystem; ++import com.mojang.blaze3d.vertex.VertexConsumer; ++import com.mojang.renderpearl.api.pipeline.RenderPipeline; ++import com.mojang.renderpearl.api.textures.GpuSampler; ++import com.viaversion.viafabricplus.bedrock.injection.mixin.features.screen.GuiGraphicsExtractorAccess; ++import java.io.IOException; ++import java.util.ArrayList; ++import java.util.HashMap; ++import java.util.List; ++import java.util.Map; ++import java.util.UUID; ++import net.minecraft.client.Minecraft; ++import net.minecraft.client.gui.GuiGraphicsExtractor; ++import net.minecraft.client.gui.navigation.ScreenRectangle; ++import net.minecraft.client.gui.render.TextureSetup; ++import net.minecraft.client.renderer.state.gui.GuiElementRenderState; ++import net.minecraft.client.renderer.texture.DynamicTexture; ++import net.minecraft.resources.Identifier; ++import org.joml.Matrix3x2f; ++import org.joml.Matrix3x2fc; ++ ++/** Append-only atlas slots remain valid while GUI extraction queues later glyphs in the same frame. */ ++public final class OreUiGlyphs implements AutoCloseable { ++ private static final int MAX_BYTES = 16 * 1024 * 1024; ++ private final Map entries = new HashMap<>(); ++ private final List pages = new ArrayList<>(); ++ private int allocatedBytes; ++ private boolean closed; + -+/** Actor portrait transform, distinct from the native live player renderer. */ -+public record NativeUiActorCamera(float scale, Vector3f translation, Matrix4f modelTransform) { -+ public static NativeUiActorCamera resolve(final NativeUiLayout.Rect control, final NativeUiLayout.Rect viewport, -+ final float layer, final float width, final float height, final float depth, final int guiScale, -+ final JsonObject properties, final JsonObject offsets, final float automaticRotation) { -+ if (guiScale < 1 || !(width > 0) || !(height > 0) || !(depth > 0) -+ || !Float.isFinite(width + height + depth + layer + automaticRotation)) -+ throw new IllegalArgumentException("Invalid native portrait dimensions"); -+ // Pinned 1.26.51.1 actor_portrait_renderer: FUN_145543ce0, verified with live arguments. -+ final float fit = Math.min(1, 2F / Math.max(width, Math.max(height, depth))); -+ final float base = Math.min(control.width() / 20F, control.height() / 39F) * 10F; -+ final float scale = base * fit; -+ if (!(scale > 0) || !Float.isFinite(scale)) throw new IllegalArgumentException("Invalid native portrait control size"); -+ final Vector3f scaling = vector(properties, offsets, "scale", new Vector3f(1)); -+ final Vector3f rotation = vector(properties, offsets, "rotate", new Vector3f(0, 30, 0)); -+ final Vector3f offset = vector(properties, offsets, "translate", new Vector3f()); -+ final Vector3f translation = new Vector3f( -+ (control.x() + control.width() / 2F + offset.x - viewport.x() - viewport.width() / 2F) / scale, -+ (viewport.y() + viewport.height() / 2F - control.y() - control.height() * .8F + base / (10F * guiScale) - offset.y) / scale, -+ 500F - layer - offset.z); -+ // Java's converted geometry has a downward Y axis and a 24-pixel root. -+ // PIP supplies (scale, -scale, 1). Reverse native camera depth for Java's -+ // orthographic projection as well as converting the downward model Y axis. -+ final Matrix4f model = new Matrix4f().scaling(scaling.x, -scaling.y, scale * scaling.z) -+ .rotateZ((float) Math.toRadians(rotation.z + 180F)) -+ .rotateY((float) Math.toRadians(rotation.y - 180F + automaticRotation)) -+ .rotateX((float) Math.toRadians(rotation.x)) -+ .scale(1, -1, 1).translate(0, -1.5F, 0); -+ return new NativeUiActorCamera(scale, translation, model); ++ /** Resolves the native baseline within a line box before applying glyph bearings and cache scale. */ ++ public OreUiFont.LineMetrics drawLine(final GuiGraphicsExtractor graphics, final OreUiText.Prepared text, ++ final float x, final float top, final float displayPixels, final Float lineHeight, ++ final Tint tint, final GpuSampler sampler, final boolean linearTint) throws IOException { ++ if (!Float.isFinite(top)) throw new IOException("Invalid OreUI line origin"); ++ final var metrics = text.lineMetrics(displayPixels, lineHeight); ++ this.drawRun(graphics, text, x, top + metrics.baselineOffset(), displayPixels, tint, sampler, linearTint); ++ return metrics; ++ } ++ ++ /** Draws one shaped line at an already resolved baseline; wrapping and fallback belong to layout. */ ++ public void drawRun(final GuiGraphicsExtractor graphics, final OreUiText.Prepared text, ++ final float x, final float baseline, final float displayPixels, final Tint tint, ++ final GpuSampler sampler, final boolean linearTint) throws IOException { ++ if (this.closed) throw new IllegalStateException("OreUI glyph atlas is closed"); ++ if (!Float.isFinite(displayPixels) || displayPixels <= 0 || text.pixels() <= 0 || text.shapingPixels() <= 0) ++ throw new IOException("Invalid OreUI text scale"); ++ final float imageScale = displayPixels / text.pixels(); ++ final float shapeScale = displayPixels / text.shapingPixels(); ++ long penX = 0, penY = 0; ++ for (final var glyph : text.run().glyphs()) { ++ final var image = text.images().get(glyph.index()); ++ if (image == null) throw new IOException("Prepared OreUI run has no image for this glyph"); ++ final var raster = image.raster(); ++ if (raster.width() != 0 && raster.height() != 0) { ++ final var field = image.distance(); ++ final int width = field == null ? raster.width() : field.width(); ++ final int height = field == null ? raster.height() : field.height(); ++ final int paddingX = (width - raster.width()) / 2, paddingY = (height - raster.height()) / 2; ++ // Keep the native operation order: add the shaped origin, then the scaled cache bearing. ++ final float left = (penX + glyph.offsetX()) / 64F * shapeScale + x + (raster.left() - paddingX) * imageScale; ++ final float top = (baseline - (penY + glyph.offsetY()) / 64F * shapeScale) - (raster.top() + paddingY) * imageScale; ++ this.draw(graphics, text, glyph.index(), new Quad(left, top, left + width * imageScale, ++ top + height * imageScale, imageScale, tint), sampler, linearTint); ++ } ++ penX += glyph.advanceX(); penY += glyph.advanceY(); ++ } + } + -+ private static Vector3f vector(final JsonObject properties, final JsonObject offsets, final String name, -+ final Vector3f fallback) { -+ final Vector3f result = new Vector3f(fallback); -+ if (offsets != null && offsets.has(name)) { -+ final var values = offsets.getAsJsonArray(name); -+ if (values.size() != 3) throw new IllegalArgumentException("Invalid native portrait transform"); -+ result.set(values.get(0).getAsFloat(), values.get(1).getAsFloat(), values.get(2).getAsFloat()); ++ /** Placement, coverage and sampling come from the caller's resolved native text run. */ ++ public void draw(final GuiGraphicsExtractor graphics, final OreUiText.Prepared text, final int glyph, ++ final Quad quad, final GpuSampler sampler, final boolean linearTint) throws IOException { ++ if (this.closed) throw new IllegalStateException("OreUI glyph atlas is closed"); ++ RenderSystem.assertOnRenderThread(); ++ quad.validate(); ++ final var image = text.images().get(glyph); ++ if (image == null) throw new IOException("Prepared OreUI run has no image for this glyph"); ++ if (image.raster().width() == 0 || image.raster().height() == 0) return; ++ final var pose = new Matrix3x2f(graphics.pose()); ++ final var scissor = graphics.scissorStack.peek(); ++ final int left = (int) Math.floor(quad.left), top = (int) Math.floor(quad.top); ++ var bounds = new ScreenRectangle(left, top, (int) Math.ceil(quad.right) - left, (int) Math.ceil(quad.bottom) - top).transformMaxBounds(pose); ++ if (scissor != null) bounds = bounds.intersection(scissor); ++ if (bounds == null || bounds.width() == 0 || bounds.height() == 0) return; ++ final var key = new Key(text.source(), text.face().file(), text.pixels(), text.sdf(), glyph); ++ var entry = this.entries.get(key); ++ if (entry == null) { ++ entry = this.upload(image); ++ this.entries.put(key, entry); ++ } ++ final var setup = TextureSetup.singleTexture(entry.page.texture.getTextureView(), sampler); ++ ((GuiGraphicsExtractorAccess) (Object) graphics).bedrockGuiState().addGlyphToCurrentLayer(new Draw( ++ OreUiPipeline.get(text.sdf(), linearTint), setup, pose, scissor, bounds, entry, quad)); ++ } ++ ++ private Entry upload(final OreUiText.Image image) throws IOException { ++ final var raster = image.raster(); ++ final var field = image.distance(); ++ final int width = field == null ? raster.width() : field.width(); ++ final int height = field == null ? raster.height() : field.height(); ++ final int stride = field == null ? raster.stride() : field.stride(); ++ final byte[] source = field == null ? raster.pixels() : field.pixels(); ++ Page page = null; ++ for (final var candidate : this.pages) if (candidate.admit(width, height)) { page = candidate; break; } ++ if (page == null) { ++ int side = 512; ++ while (side < width + 2 || side < height + 2) side *= 2; ++ if (side > 2048 || this.allocatedBytes + side * side * 4 > MAX_BYTES) throw new IOException("OreUI glyph atlas exceeds the texture budget"); ++ page = new Page(side); ++ this.pages.add(page); this.allocatedBytes += side * side * 4; ++ if (!page.admit(width, height)) throw new IOException("OreUI glyph does not fit its atlas page"); ++ } ++ final int x = page.x, y = page.y; ++ try (var upload = new NativeImage(width, height, true)) { ++ final var pixels = upload.getPixelBytes(); ++ final var retained = page.texture.getPixels().getPixelBytes(); ++ for (int row = 0; row < height; row++) { ++ for (int column = 0; column < width; column++) { ++ final int value = Byte.toUnsignedInt(source[row * stride + column]); ++ final int offset = (row * width + column) * 4; ++ pixels.put(offset, (byte) value); pixels.put(offset + 1, (byte) 0); pixels.put(offset + 2, (byte) 0); pixels.put(offset + 3, (byte) 255); ++ final int target = ((y + row) * page.side + x + column) * 4; ++ retained.put(target, (byte) value); retained.put(target + 1, (byte) 0); retained.put(target + 2, (byte) 0); retained.put(target + 3, (byte) 255); ++ } ++ } ++ RenderSystem.getDevice().createCommandEncoder().writeToTexture(page.texture.getTexture(), upload, 0, 0, x, y); + } -+ final String[] axes = {"x", "y", "z"}; -+ for (int axis = 0; axis < axes.length; axis++) { -+ final String key = name + "_" + axes[axis]; -+ if (properties.has(key)) result.setComponent(axis, properties.get(key).getAsFloat()); ++ page.x += width + 2; page.rowHeight = Math.max(page.rowHeight, height + 2); ++ return new Entry(page, x, y, width, height); ++ } ++ ++ @Override ++ public void close() { ++ if (this.closed) return; ++ this.closed = true; ++ final var retiring = List.copyOf(this.pages); ++ this.entries.clear(); this.pages.clear(); this.allocatedBytes = 0; ++ final Runnable dispose = () -> { ++ RuntimeException failure = null; ++ for (final var page : retiring) { ++ try { Minecraft.getInstance().getTextureManager().release(page.id); } ++ catch (final RuntimeException cleanup) { ++ if (failure == null) failure = cleanup; ++ else failure.addSuppressed(cleanup); ++ } ++ } ++ if (failure != null) throw failure; ++ }; ++ if (RenderSystem.isOnRenderThread()) dispose.run(); ++ else Minecraft.getInstance().execute(dispose); ++ } ++ ++ private static final class Page { ++ private final Identifier id = Identifier.fromNamespaceAndPath("viafabricplus-bedrock", "oreui/atlas/" + UUID.randomUUID()); ++ private final int side; ++ private final DynamicTexture texture; ++ private int x = 1, y = 1, rowHeight; ++ private Page(final int side) { ++ this.side = side; ++ this.texture = new DynamicTexture(this.id::toString, new NativeImage(side, side, true)); ++ try { Minecraft.getInstance().getTextureManager().register(this.id, this.texture); } ++ catch (final RuntimeException | Error failure) { this.texture.close(); throw failure; } ++ } ++ private boolean admit(final int width, final int height) { ++ if (width + 2 > this.side || height + 2 > this.side) return false; ++ if (this.x + width + 1 <= this.side) return this.y + height + 1 <= this.side; ++ if (this.y + this.rowHeight + height + 1 > this.side) return false; ++ this.x = 1; this.y += this.rowHeight; this.rowHeight = 0; ++ return true; ++ } ++ } ++ ++ private record Key(OreUiAssets source, String file, int pixels, boolean sdf, int glyph) { } ++ private record Entry(Page page, int x, int y, int width, int height) { } ++ public record Tint(float red, float green, float blue, float alpha) { } ++ public record Quad(float left, float top, float right, float bottom, float coverage, Tint tint) { ++ private void validate() throws IOException { ++ if (!Float.isFinite(this.left) || !Float.isFinite(this.top) || !Float.isFinite(this.right) || !Float.isFinite(this.bottom) ++ || this.right <= this.left || this.bottom <= this.top || !Float.isFinite(this.coverage) || this.coverage <= 0 || this.tint == null) ++ throw new IOException("Invalid OreUI glyph geometry"); ++ for (final float channel : new float[]{this.tint.red, this.tint.green, this.tint.blue, this.tint.alpha}) ++ if (!Float.isFinite(channel) || channel < 0 || channel > 1) throw new IOException("Invalid OreUI glyph tint"); ++ } ++ } ++ private record Draw(RenderPipeline pipeline, TextureSetup textureSetup, Matrix3x2fc pose, ScreenRectangle scissorArea, ++ ScreenRectangle bounds, Entry entry, Quad quad) implements GuiElementRenderState { ++ @Override public void buildVertices(final VertexConsumer vertices) { ++ this.vertex(vertices, this.quad.left, this.quad.top, this.entry.x, this.entry.y); ++ this.vertex(vertices, this.quad.left, this.quad.bottom, this.entry.x, this.entry.y + this.entry.height); ++ this.vertex(vertices, this.quad.right, this.quad.bottom, this.entry.x + this.entry.width, this.entry.y + this.entry.height); ++ this.vertex(vertices, this.quad.right, this.quad.top, this.entry.x + this.entry.width, this.entry.y); ++ } ++ private void vertex(final VertexConsumer target, final float x, final float y, final int u, final int v) { ++ target.addVertexWith2DPose(this.pose, x, y).setUv(this.quad.tint.blue, this.quad.tint.alpha) ++ .setUv1(u, v).setUv3(this.quad.tint.red, this.quad.tint.green).setLineWidth(this.quad.coverage); + } -+ if (!Float.isFinite(result.x + result.y + result.z)) -+ throw new IllegalArgumentException("Invalid native portrait transform"); -+ return result; + } +} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiActorPortraits.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiActorPortraits.java +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiLines.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiLines.java new file mode 100644 -index 0000000..286546d +index 0000000..c6a9563 --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiActorPortraits.java -@@ -0,0 +1,258 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiLines.java +@@ -0,0 +1,235 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ -+package com.viaversion.viafabricplus.bedrock.screen.ui; ++package com.viaversion.viafabricplus.bedrock.render.oreui; + -+import com.google.gson.JsonElement; -+import com.google.gson.JsonObject; -+import com.viaversion.viafabricplus.bedrock.appearance.BedrockGuiActorAppearance; -+import com.viaversion.viafabricplus.bedrock.injection.access.IBedrockGuiActorRenderer; -+import com.viaversion.viafabricplus.bedrock.render.BedrockActorState; -+import com.viaversion.viafabricplus.bedrock.render.BedrockGuiActorContext; ++import java.util.ArrayList; ++import java.util.List; ++ ++/** Ordinary line construction after text preprocessing and font selection. */ ++public record OreUiLines(List lines, float width, float height) { ++ public OreUiLines { lines = List.copyOf(lines); } ++ ++ /** Ranges index supplied Unicode scalars, including consumed spaces and explicit line endings. */ ++ public record Fragment(int start, int end, int font, float x, float baseline, float width, float height) { } ++ /** A mixed-font line retains each fragment's own baseline. The summary uses its first fragment. */ ++ public record Line(int start, int end, float x, float baseline, float width, float height, boolean hardBreak, ++ List fragments) { ++ public Line { fragments = List.copyOf(fragments); } ++ public Line(final int start, final int end, final float x, final float baseline, final float width, ++ final float height, final boolean hardBreak) { ++ this(start, end, x, baseline, width, height, hardBreak, start == end && !hardBreak ? List.of() ++ : List.of(new Fragment(start, end, 1, x, baseline, width, height))); ++ } ++ } ++ /** Equal identities share one native face. Zero represents an unavailable face. */ ++ public record Font(int identity, OreUiFont.LineMetrics metrics) { } ++ public enum WhiteSpace { NORMAL, NO_WRAP, PRE, PRE_WRAP } ++ public enum OverflowWrap { NORMAL, BREAK_WORD, ANYWHERE } ++ public enum Alignment { LEFT, RIGHT, CENTER } ++ ++ public static OreUiLines layout(final int[] scalars, final float[] advances, final OreUiFont.LineMetrics metrics, ++ final float availableWidth, final float indent, final float letterSpacing, ++ final WhiteSpace whitespace, final OverflowWrap wrap, ++ final Alignment alignment, final boolean fillAvailable) { ++ return layout(scalars, advances, new int[scalars.length], List.of(new Font(1, metrics)), availableWidth, ++ indent, letterSpacing, whitespace, wrap, alignment, fillAvailable); ++ } ++ ++ public static OreUiLines layout(final int[] scalars, final float[] advances, final int[] fontMap, final List supplied, ++ final float availableWidth, final float indent, final float letterSpacing, ++ final WhiteSpace whitespace, final OverflowWrap wrap, ++ final Alignment alignment, final boolean fillAvailable) { ++ return layout(scalars, advances, fontMap, supplied, availableWidth, indent, letterSpacing, whitespace, wrap, ++ alignment, fillAvailable, 0F); ++ } ++ ++ /** The preprocessing reserve adjusts row bounds before alignment without changing fragment advances. */ ++ public static OreUiLines layout(final int[] scalars, final float[] advances, final int[] fontMap, final List supplied, ++ final float availableWidth, final float indent, final float letterSpacing, ++ final WhiteSpace whitespace, final OverflowWrap wrap, ++ final Alignment alignment, final boolean fillAvailable, final float trailingAdvance) { ++ final List fonts = List.copyOf(supplied); ++ if (scalars.length > 16384 || advances.length != scalars.length || fontMap.length != scalars.length ++ || fonts.isEmpty() || fonts.size() > 32 || !Float.isFinite(availableWidth) ++ || availableWidth < 0 || !Float.isFinite(indent) || !Float.isFinite(letterSpacing) || !Float.isFinite(trailingAdvance)) ++ throw new IllegalArgumentException("Invalid OreUI line bounds"); ++ for (final var font : fonts) { ++ if (!Float.isFinite(font.metrics.baselineOffset()) || !Float.isFinite(font.metrics.advance()) || font.metrics.advance() < 0) ++ throw new IllegalArgumentException("Invalid OreUI line metrics"); ++ } ++ final float[] tracked = new float[advances.length + 1]; ++ for (int index = 0; index < advances.length; index++) { ++ if (!Float.isFinite(advances[index]) || fontMap[index] < 0 || fontMap[index] >= fonts.size()) ++ throw new IllegalArgumentException("Invalid OreUI character advance or font"); ++ tracked[index] = advances[index] + (index < advances.length - 1 ? letterSpacing : 0F); ++ if (!Float.isFinite(tracked[index])) throw new IllegalArgumentException("Invalid OreUI tracked advance"); ++ } ++ final var state = new Builder(scalars, fontMap, fonts, availableWidth, indent, whitespace); ++ for (; state.index <= scalars.length; state.index++) { ++ final int scalar = state.index == scalars.length ? '\n' : scalars[state.index]; ++ final boolean space = space(scalar), hardBreak = scalar == '\n'; ++ final boolean word = !space && !hardBreak && scalar != '\r'; ++ final boolean fontChange = (word || space) && state.fontAt(state.index).identity != state.font; ++ if (word && state.wordStart < 0) state.wordStart = state.index; ++ final float wordWidth = state.wordWidth(tracked); ++ final boolean breakWord = wrap == OverflowWrap.BREAK_WORD && wordWidth > availableWidth; ++ if (space || hardBreak || breakWord || wrap == OverflowWrap.ANYWHERE || fontChange) { ++ if (state.wordStart >= 0) { ++ final float combined = state.currentWidth + state.pendingSpace + wordWidth; ++ if (state.remaining < state.x + combined && (whitespace == WhiteSpace.NORMAL || whitespace == WhiteSpace.PRE_WRAP)) { ++ state.flushLine(false); ++ state.start = state.wordStart; ++ state.end = state.index; ++ state.currentWidth = wordWidth; ++ state.refreshFont(); ++ } else { ++ state.currentWidth = combined; ++ state.end = state.index; ++ state.pendingSpace = 0; ++ } ++ if (breakWord && state.end - state.start > 1) { ++ state.end--; ++ state.currentWidth -= tracked[state.index - 1]; ++ state.flushLine(false); ++ state.index--; ++ final int previous = scalars[state.index]; ++ if (!space(previous) && previous != '\r' && previous != '\n') state.wordStart = state.index; ++ continue; ++ } ++ } ++ if (word) state.wordStart = state.index; ++ else { ++ state.wordStart = -1; ++ if (space) state.pendingSpace += tracked[state.index]; ++ } ++ if (fontChange) state.flushFragment(false); ++ if (hardBreak) state.flushLine(true); ++ } ++ } ++ if (Math.abs(trailingAdvance) > .001F) { ++ for (int index = 0; index < state.lines.size(); index++) { ++ final var line = state.lines.get(index); ++ final float available = index == 0 ? availableWidth - indent : availableWidth; ++ final float limit = available <= line.width ? line.width : available; ++ final float reserved = line.width + trailingAdvance; ++ final float adjusted = reserved <= limit ? reserved : limit; ++ state.lines.set(index, new Line(line.start, line.end, line.x, line.baseline, adjusted, line.height, ++ line.hardBreak, line.fragments)); ++ } ++ } ++ float width = 0F; ++ boolean softWrap = false; ++ for (int index = 0; index < state.lines.size(); index++) { ++ final var line = state.lines.get(index); ++ if (index == 0 || line.width() > width) width = line.width(); ++ if (index < state.lines.size() - 1 && !line.hardBreak()) softWrap = true; ++ } ++ final float overflow = Math.max(0F, width - availableWidth); ++ final float alignmentWidth = width < availableWidth && fillAvailable && softWrap ? availableWidth : width; ++ final List aligned = new ArrayList<>(state.lines.size()); ++ for (final var line : state.lines) { ++ float offset = 0F; ++ if (!line.fragments.isEmpty() && alignment != Alignment.LEFT && Math.abs(line.width - alignmentWidth) > .001F) { ++ offset = Math.max(0F, (alignmentWidth - overflow) - line.width); ++ if (alignment == Alignment.CENTER) offset *= .5F; ++ } ++ if (offset == 0F) { aligned.add(line); continue; } ++ final List fragments = new ArrayList<>(line.fragments.size()); ++ for (final var fragment : line.fragments) fragments.add(new Fragment(fragment.start, fragment.end, fragment.font, ++ offset + fragment.x, fragment.baseline, fragment.width, fragment.height)); ++ aligned.add(new Line(line.start, line.end, offset + line.x, line.baseline, line.width, line.height, line.hardBreak, fragments)); ++ } ++ return new OreUiLines(aligned, width, state.height); ++ } ++ ++ private static boolean space(final int scalar) { ++ return scalar == 0 || scalar == '\t' || scalar == 0xb || scalar == 0xc || scalar == ' '; ++ } ++ ++ private static final class Builder { ++ private final List lines = new ArrayList<>(); ++ private final List fragments = new ArrayList<>(); ++ private final int[] scalars, fontMap; ++ private final List fonts; ++ private final float width, indent; ++ private final WhiteSpace whitespace; ++ private int wordStart = -1, index, start, end, font; ++ private int measuredStart = -1, measuredEnd; ++ private float measuredWidth; ++ private float remaining, currentWidth, pendingSpace, height, x, baseline, fragmentHeight, rowWidth, rowHeight; ++ ++ private Builder(final int[] scalars, final int[] fontMap, final List fonts, ++ final float width, final float indent, final WhiteSpace whitespace) { ++ this.scalars = scalars; this.fontMap = fontMap; this.fonts = fonts; ++ this.width = width; this.indent = indent; this.whitespace = whitespace; ++ this.remaining = width - indent; ++ if (!this.refreshFont() && scalars.length == 0) { ++ final var fallback = fonts.stream().filter(value -> value.identity != 0).findFirst() ++ .orElseThrow(() -> new IllegalArgumentException("No OreUI font for an empty line")); ++ this.font = fallback.identity; ++ this.baseline = fallback.metrics.baselineOffset(); ++ this.fragmentHeight = fallback.metrics.advance(); ++ // The original empty-input path retains this seed before forcing the final fragment. ++ this.fragments.add(new Fragment(0, 0, this.font, 0F, this.baseline, 0F, this.fragmentHeight)); ++ } ++ } ++ ++ private Font fontAt(final int index) { return this.fonts.get(index < this.fontMap.length ? this.fontMap[index] : 0); } ++ ++ private boolean refreshFont() { ++ final var selected = this.fontAt(this.start); ++ if (selected.identity == this.font) return false; ++ this.font = selected.identity; ++ this.baseline = this.height + selected.metrics.baselineOffset(); ++ this.fragmentHeight = selected.metrics.advance(); ++ return true; ++ } ++ ++ private float wordWidth(final float[] advances) { ++ if (this.wordStart < 0) return advances[this.index]; ++ if (this.measuredStart != this.wordStart || this.measuredEnd > this.index) { ++ this.measuredStart = this.measuredEnd = this.wordStart; ++ this.measuredWidth = 0F; ++ } ++ while (this.measuredEnd < this.index) this.measuredWidth += advances[this.measuredEnd++]; ++ return this.measuredWidth; ++ } ++ ++ private void flushFragment(final boolean force) { ++ final int requested = this.wordStart < 0 ? this.index : this.wordStart; ++ final boolean advanced = this.end < requested; ++ if (advanced) this.end = requested; ++ if (this.start == this.end && !force) return; ++ if (advanced && (this.whitespace == WhiteSpace.PRE || this.whitespace == WhiteSpace.PRE_WRAP)) { ++ this.currentWidth += Math.min(Math.max(0F, this.remaining - this.currentWidth), Math.max(0F, this.pendingSpace)); ++ } ++ if (force && this.index != this.scalars.length) this.end++; ++ this.fragments.add(new Fragment(this.start, this.end, this.font, this.x, this.baseline, this.currentWidth, this.fragmentHeight)); ++ this.rowHeight = Math.max(this.rowHeight, this.fragmentHeight); ++ this.rowWidth = this.currentWidth + this.rowWidth; ++ this.start = this.end; ++ this.x = this.currentWidth + this.x; ++ this.currentWidth = 0F; ++ this.refreshFont(); ++ } ++ ++ private void flushLine(final boolean hardBreak) { ++ this.flushFragment(hardBreak); ++ if (this.indent == 0F && this.fragments.isEmpty()) return; ++ if (this.fragments.isEmpty()) this.lines.add(new Line(0, 0, 0F, 0F, this.rowWidth, this.rowHeight, hardBreak, List.of())); ++ else { ++ final var first = this.fragments.getFirst(); final var last = this.fragments.getLast(); ++ this.lines.add(new Line(first.start, last.end, first.x, first.baseline, this.rowWidth, this.rowHeight, hardBreak, this.fragments)); ++ } ++ this.height = this.rowHeight + this.height; ++ this.fragments.clear(); this.rowWidth = this.rowHeight = 0F; ++ this.x = 0F; ++ this.baseline = this.height + this.fontAt(this.start).metrics.baselineOffset(); ++ this.pendingSpace = 0F; ++ this.remaining = this.width; ++ } ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiPipeline.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiPipeline.java +new file mode 100644 +index 0000000..38140fe +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiPipeline.java +@@ -0,0 +1,52 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.render.oreui; ++ ++import com.mojang.blaze3d.vertex.DefaultVertexFormat; ++import com.mojang.renderpearl.api.GpuFormat; ++import com.mojang.renderpearl.api.pipeline.BlendFunction; ++import com.mojang.renderpearl.api.pipeline.ColorTargetState; ++import com.mojang.renderpearl.api.pipeline.RenderPipeline; ++import com.mojang.renderpearl.api.vertex.VertexFormat; ++import java.util.Optional; ++import net.minecraft.client.renderer.RenderPipelines; ++import net.minecraft.resources.Identifier; ++ ++/** Dedicated GUI vertex payload: float tint and coverage, pixel atlas coordinates, and native text programs. */ ++public final class OreUiPipeline { ++ private static final RenderPipeline[][] PIPELINES = {{create(false, false), create(false, true)}, ++ {create(true, false), create(true, true)}}; ++ private OreUiPipeline() { } ++ ++ public static RenderPipeline get(final boolean sdf, final boolean linearTint) { ++ return PIPELINES[sdf ? 1 : 0][linearTint ? 1 : 0]; ++ } ++ ++ private static RenderPipeline create(final boolean sdf, final boolean linearTint) { ++ final var original = RenderPipelines.GUI_TEXTURED; ++ final var format = VertexFormat.builder(0) ++ .addAttribute(DefaultVertexFormat.POSITION_SEMANTIC_NAME, GpuFormat.RGB32_FLOAT) ++ .addAttribute(DefaultVertexFormat.UV0_SEMANTIC_NAME, GpuFormat.RG32_FLOAT) ++ .addAttribute(DefaultVertexFormat.UV1_SEMANTIC_NAME, GpuFormat.RG16_SINT) ++ .addAttribute(DefaultVertexFormat.UV3_SEMANTIC_NAME, GpuFormat.RG32_FLOAT) ++ .addAttribute(DefaultVertexFormat.LINE_WIDTH_SEMANTIC_NAME, GpuFormat.R32_FLOAT).build(); ++ final var colors = original.getColorTargetStates().stream().map(color -> new ColorTargetState( ++ Optional.of(BlendFunction.TRANSLUCENT_PREMULTIPLIED_ALPHA), color.format(), color.writeMask())).toArray(ColorTargetState[]::new); ++ final var snippet = new RenderPipeline.Snippet(original.getShaders(), Optional.of(original.getShaderDefines()), ++ Optional.of(original.getBindGroupLayouts()), colors, colors.length, Optional.ofNullable(original.getDepthStencilState()), ++ Optional.of(original.getPolygonMode()), Optional.of(original.isCull()), ++ original.getVertexFormatBindings().toArray(VertexFormat[]::new), Optional.of(original.getPrimitiveTopology()), original.pushConstantSize()); ++ final var builder = RenderPipeline.builder(snippet) ++ .withLocation(Identifier.fromNamespaceAndPath("viafabricplus-bedrock", "pipeline/oreui_font/" ++ + (sdf ? "sdf" : "bitmap") + (linearTint ? "/linear" : "/encoded"))) ++ .withVertexShader(Identifier.fromNamespaceAndPath("viafabricplus-bedrock", "core/bedrock_oreui_font")) ++ .withFragmentShader(Identifier.fromNamespaceAndPath("viafabricplus-bedrock", "core/bedrock_oreui_font")) ++ .withVertexBinding(0, format); ++ if (sdf) builder.withShaderDefine("OREUI_SDF"); ++ if (linearTint) builder.withShaderDefine("OREUI_LINEAR_TINT"); ++ return builder.build(); ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiShaper.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiShaper.java +new file mode 100644 +index 0000000..1968888 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiShaper.java +@@ -0,0 +1,180 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.render.oreui; ++ ++import com.google.gson.JsonParser; ++import java.io.IOException; ++import java.io.InputStream; ++import java.lang.foreign.Arena; ++import java.lang.foreign.FunctionDescriptor; ++import java.lang.foreign.Linker; ++import java.lang.foreign.MemoryLayout; ++import java.lang.foreign.MemorySegment; ++import java.lang.foreign.SymbolLookup; ++import java.lang.invoke.MethodHandle; ++import java.nio.charset.StandardCharsets; ++import java.nio.file.Files; ++import java.nio.file.Path; ++import java.security.MessageDigest; ++import java.security.NoSuchAlgorithmException; +import java.util.HashMap; ++import java.util.HashSet; ++import java.util.HexFormat; ++import java.util.Locale; +import java.util.Map; -+import net.minecraft.client.Minecraft; -+import net.minecraft.client.gui.GuiGraphicsExtractor; -+import net.minecraft.client.renderer.entity.state.AvatarRenderState; -+import net.minecraft.world.entity.Avatar; -+import net.raphimc.viabedrock.api.model.NativeActorMessage; -+import net.raphimc.viabedrock.protocol.data.enums.bedrock.ActorFlags; -+import org.joml.Quaternionf; -+import org.joml.Quaternionfc; -+import org.joml.Vector3f; ++import java.util.Set; ++import static java.lang.foreign.ValueLayout.ADDRESS; ++import static java.lang.foreign.ValueLayout.JAVA_INT; ++ ++/** Owns a compatible shaping engine without changing LWJGL's process-global HarfBuzz selection. */ ++public final class OreUiShaper implements AutoCloseable { ++ static final int MAX_FONTS = 32; ++ static final MemoryLayout P = ADDRESS, I = JAVA_INT; ++ static final MemorySegment NULL = MemorySegment.NULL; ++ private static final int MAX_LIBRARY_BYTES = 16 * 1024 * 1024; ++ private static final String RESOURCE = "/assets/viafabricplus-bedrock/oreui-shaper/"; ++ private final Arena arena = Arena.ofShared(); ++ private final SymbolLookup symbols; ++ private final Map calls = new HashMap<>(); ++ private final Set fonts = new HashSet<>(); ++ private final Path temporary; ++ private boolean closed; + -+/** Native UID selection and independent cameras for player and actor portraits. */ -+public final class NativeUiActorPortraits { -+ public static final String RENDERER = "live_horse_renderer"; -+ public static final String ACTOR_RENDERER = "actor_portrait_renderer"; -+ public static final String PLAYER_RENDERER = "live_player_renderer"; -+ private final Map contexts = new HashMap<>(); -+ private final Map paths = new java.util.IdentityHashMap<>(); -+ private Object level; ++ /** The path entry point is also used by native-boundary integration tests. */ ++ OreUiShaper(final Path library) throws IOException { this(library, null); } + -+ public void clear() { -+ this.contexts.values().forEach(BedrockGuiActorContext::close); -+ this.contexts.clear(); -+ this.paths.clear(); -+ this.level = null; ++ private OreUiShaper(final Path library, final Path temporary) throws IOException { ++ this.temporary = temporary; ++ try { ++ this.symbols = SymbolLookup.libraryLookup(library.toAbsolutePath(), this.arena); ++ final String version = this.pointer("hb_version_string", new MemoryLayout[0]).reinterpret(64).getString(0); ++ if (!version.equals("14.3.0")) throw new IOException("Incompatible OreUI shaping engine: " + version); ++ } catch (final IOException | RuntimeException | Error failure) { ++ this.arena.close(); ++ throw failure; ++ } + } + -+ public void prepare(final NativeUiScene.Node root) { -+ this.paths.clear(); -+ this.path(root, ""); -+ final var retained = new java.util.HashSet<>(this.paths.values()); -+ this.contexts.entrySet().removeIf(entry -> { -+ if (retained.contains(entry.getKey())) return false; -+ entry.getValue().close(); -+ return true; -+ }); ++ public static OreUiShaper bundled() throws IOException { ++ final String platform = platform(); ++ final String root = RESOURCE + platform + "/"; ++ final String library = platform.startsWith("windows-") ? "stackanvil_oreui_harfbuzz.dll" ++ : platform.startsWith("macos-") ? "libstackanvil_oreui_harfbuzz.dylib" : "libstackanvil_oreui_harfbuzz.so"; ++ final byte[] bytes; ++ try { ++ final var manifest = JsonParser.parseString(new String(resource(root + "manifest.json", 8192), StandardCharsets.UTF_8)).getAsJsonObject(); ++ if (manifest.get("format").getAsInt() != 1 || !manifest.get("engine").getAsString().equals("HarfBuzz") ++ || !manifest.get("version").getAsString().equals("14.3.0") ++ || !manifest.get("target").getAsString().equals("Bedrock 1.26.60.5 OreUI") ++ || !(manifest.get("platform").getAsString() + "-" + manifest.get("architecture").getAsString()).equals(platform) ++ || !manifest.get("library").getAsString().equals(library)) throw new IOException("Invalid OreUI engine identity"); ++ bytes = resource(root + library, MAX_LIBRARY_BYTES); ++ if (!digest(bytes).equals(manifest.get("sha256").getAsString())) throw new IOException("OreUI engine checksum mismatch"); ++ } catch (final RuntimeException invalid) { throw new IOException("Invalid OreUI engine manifest", invalid); } ++ final Path directory = Files.createTempDirectory("viafabricplus-oreui-"); ++ final Path extracted = directory.resolve(library); ++ try { ++ Files.write(extracted, bytes); ++ return new OreUiShaper(extracted, directory); ++ } catch (final IOException | RuntimeException | Error failure) { ++ try { Files.deleteIfExists(extracted); Files.deleteIfExists(directory); } ++ catch (final IOException cleanup) { failure.addSuppressed(cleanup); } ++ throw failure; ++ } + } + -+ private void path(final NativeUiScene.Node node, final String parent) { -+ final String path = parent + "/" + node.name(); -+ this.paths.put(node, path); -+ final Map occurrences = new HashMap<>(); -+ for (final var child : node.children()) { -+ final int occurrence = occurrences.merge(child.name(), 1, Integer::sum); -+ this.path(child, path + "/" + occurrence); -+ } ++ static String platform() throws IOException { ++ final String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT); ++ final String system = os.startsWith("windows") ? "windows" : os.startsWith("mac") ? "macos" : os.startsWith("linux") ? "linux" : ""; ++ final String architecture = switch (System.getProperty("os.arch", "").toLowerCase(Locale.ROOT)) { ++ case "amd64", "x86_64" -> "x64"; ++ case "aarch64", "arm64" -> "arm64"; ++ default -> ""; ++ }; ++ if (system.isEmpty() || architecture.isEmpty()) throw new IOException("Unsupported OreUI engine platform"); ++ return system + "-" + architecture; + } + -+ public static long uniqueId(final NativeUiScene.Node node) { -+ if (node.string("renderer", "").equals(ACTOR_RENDERER)) { -+ final var portrait = node.properties().getAsJsonObject("actor_portrait"); -+ if (portrait == null || !portrait.has("unique_id")) -+ throw new IllegalArgumentException("Native actor portrait has no identity"); -+ return portrait.get("unique_id").getAsBigDecimal().longValueExact(); ++ private static byte[] resource(final String path, final int limit) throws IOException { ++ try (InputStream input = OreUiShaper.class.getResourceAsStream(path)) { ++ if (input == null) throw new IOException("Missing bundled OreUI resource: " + path); ++ final byte[] bytes = input.readNBytes(limit + 1); ++ if (bytes.length == 0 || bytes.length > limit) throw new IOException("Invalid bundled OreUI resource size"); ++ return bytes; + } -+ final var bag = node.properties().getAsJsonObject("property_bag"); -+ final JsonElement value = bag == null ? null : bag.get("#entity_id"); -+ if (value == null) return -1; -+ if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) -+ throw new IllegalArgumentException("Native portrait actor ID must be a string"); -+ final String text = value.getAsString(); -+ int start = 0; -+ while (start < text.length() && " \t\n\r\f\u000b".indexOf(text.charAt(start)) >= 0) start++; -+ int end = start; -+ if (end < text.length() && (text.charAt(end) == '+' || text.charAt(end) == '-')) end++; -+ final int digits = end; -+ while (end < text.length() && text.charAt(end) >= '0' && text.charAt(end) <= '9') end++; -+ if (end == digits) throw new IllegalArgumentException("Invalid native portrait actor ID"); -+ return Long.parseLong(text.substring(start, end)); + } + -+ public static Camera camera(final NativeUiLayout.Rect rect, final int mode, final float mouseX, final float mouseY) { -+ final float dx = mode == 1 ? (mouseX - rect.x() - rect.width() / 2F) / 40F : 3F; -+ final float dy = mode == 1 ? (mouseY - rect.y() - rect.height() / 2F) / 40F : 1F; -+ return new Camera((float) Math.atan(dy) * 20F, (float) -Math.atan(dx) * 20F, rect.width()); ++ private static String digest(final byte[] bytes) { ++ try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); } ++ catch (final NoSuchAlgorithmException impossible) { throw new AssertionError(impossible); } + } + -+ /** Cancels Java's model origin for the native GUI player renderer. */ -+ public static Vector3f playerTranslation(final Quaternionfc rotation, final float playerScale) { -+ if (playerScale < 0 || !Float.isFinite(playerScale)) -+ throw new IllegalArgumentException("Invalid native portrait player scale"); -+ // Native GUI actor draws skip both the eye offset and world model-root translation. -+ // Cancel Java's Living/Avatar origin shift; converted bones already use the GUI origin. -+ return rotation.transform(new Vector3f(0, -1.501F * .9375F * playerScale, 0)).add(0, 0, -5); ++ public synchronized OreUiFont font(final byte[] bytes, final int pixels) throws IOException { ++ this.requireOpen(); ++ if (this.fonts.size() >= MAX_FONTS) throw new IOException("Too many active OreUI font faces"); ++ final var font = new OreUiFont(this, bytes, pixels); ++ this.fonts.add(font); ++ return font; + } + -+ /** The native renderer uses the inherited scissor, not the custom control as a viewport. */ -+ public static NativeUiLayout.Rect viewport(final NativeUiLayout.Rect clip, final int width, final int height) { -+ final var visible = clip.intersect(new NativeUiLayout.Rect(0, 0, width, height)); -+ final int left = Math.round(visible.x()), top = Math.round(visible.y()); -+ return new NativeUiLayout.Rect(left, top, Math.round(visible.x() + visible.width()) - left, -+ Math.round(visible.y() + visible.height()) - top); ++ void retired(final OreUiFont font) { this.fonts.remove(font); } ++ ++ void requireOpen() { ++ if (this.closed) throw new IllegalStateException("OreUI shaping engine is closed"); + } + -+ /** Keeps the native control center when Java's intermediate texture uses a larger viewport. */ -+ public static Vector3f viewportTranslation(final NativeUiLayout.Rect control, final NativeUiLayout.Rect viewport, -+ final Quaternionfc rotation, final float playerScale) { -+ if (!(control.width() > 0) || !Float.isFinite(control.width())) -+ throw new IllegalArgumentException("Invalid native portrait control width"); -+ return playerTranslation(rotation, playerScale).add( -+ (control.x() + control.width() / 2F - viewport.x() - viewport.width() / 2F) / control.width(), -+ (viewport.y() + viewport.height() / 2F - control.y() - control.height() / 2F) / control.width(), 0); ++ private Object call(final String name, final MemoryLayout result, final MemoryLayout[] arguments, final Object[] values) { ++ this.requireOpen(); ++ final var handle = this.calls.computeIfAbsent(name, key -> Linker.nativeLinker().downcallHandle(this.symbols.findOrThrow(key), ++ result == null ? FunctionDescriptor.ofVoid(arguments) : FunctionDescriptor.of(result, arguments))); ++ try { return handle.invokeWithArguments(values); } ++ catch (final RuntimeException | Error failure) { throw failure; } ++ catch (final Throwable failure) { throw new IllegalStateException("OreUI native call failed: " + name, failure); } + } + -+ public void draw(final GuiGraphicsExtractor graphics, final NativeUiLayout.Box box, -+ final float mouseX, final float mouseY, final float partialTick) { -+ this.draw(graphics, box, mouseX, mouseY, partialTick, 1); ++ MemorySegment pointer(final String name, final MemoryLayout[] arguments, final Object... values) { ++ return (MemorySegment) this.call(name, P, arguments, values); + } + -+ public void draw(final GuiGraphicsExtractor graphics, final NativeUiLayout.Box box, -+ final float mouseX, final float mouseY, final float partialTick, final float opacity) { -+ final Minecraft client = Minecraft.getInstance(); -+ if (client.level != this.level) { -+ this.contexts.values().forEach(BedrockGuiActorContext::close); -+ this.contexts.clear(); -+ this.level = client.level; ++ int integer(final String name, final MemoryLayout[] arguments, final Object... values) { ++ return (int) this.call(name, I, arguments, values); ++ } ++ ++ void run(final String name, final MemoryLayout[] arguments, final Object... values) { ++ this.call(name, null, arguments, values); ++ } ++ ++ @Override ++ public synchronized void close() throws IOException { ++ if (this.closed) return; ++ Throwable failure = null; ++ for (final var font : Set.copyOf(this.fonts)) { ++ try { font.close(); } ++ catch (final RuntimeException | Error error) { ++ if (failure == null) failure = error; else failure.addSuppressed(error); ++ } + } -+ final String key = this.paths.get(box.node()); -+ if (key == null) throw new IllegalStateException("Native portrait scene was not prepared"); -+ final boolean localPlayer = box.node().string("renderer", "").equals(PLAYER_RENDERER); -+ if (!localPlayer && !BedrockActorState.supportsUniqueIds()) -+ throw new IllegalArgumentException("Native portrait identity capability is unavailable"); -+ final var uuid = localPlayer -+ ? client.player == null ? null : client.player.getUUID() -+ : BedrockActorState.uuidByUniqueId(uniqueId(box.node())); -+ if (uuid == null || client.level == null) { -+ this.retire(key); -+ return; -+ } -+ final var entity = localPlayer ? client.player : client.level.getEntity(uuid); -+ if (entity == null || entity.isRemoved()) { -+ this.retire(key); -+ return; -+ } -+ final var snapshot = BedrockActorState.state(uuid); -+ if (snapshot == null || localPlayer && !snapshot.localPlayer() || !localPlayer && snapshot.uniqueId() == null) -+ throw new IllegalArgumentException("Native portrait identity is unavailable"); -+ var context = this.contexts.get(key); -+ if (context == null || !context.owns(uuid, snapshot.lifetime())) { -+ if (context != null) context.close(); -+ context = new BedrockGuiActorContext(uuid, snapshot.lifetime()); -+ this.contexts.put(key, context); -+ } -+ context.opacity(box.node().string("renderer", "").equals(ACTOR_RENDERER) ? 1 : opacity); -+ if (box.node().string("renderer", "").equals(ACTOR_RENDERER)) { -+ this.actor(graphics, box, entity, snapshot, context, opacity); -+ return; -+ } -+ if (!(entity instanceof Avatar player)) throw new IllegalArgumentException("Native portrait actor class is not verified"); -+ if (localPlayer) { -+ this.player(graphics, box, player, snapshot, context, mouseX, mouseY, partialTick); -+ return; ++ this.closed = true; ++ this.calls.clear(); ++ try { ++ this.arena.close(); ++ } catch (final RuntimeException | Error error) { ++ if (failure == null) failure = error; else failure.addSuppressed(error); + } -+ final var camera = camera(box.bounds(), 1, mouseX, mouseY); -+ if (camera.scale() <= 0) return; -+ context.camera(camera.pitch(), camera.yaw()); -+ final var renderState = context.scoped(() -> client.getEntityRenderDispatcher().extractEntity(player, partialTick)); -+ context.requireSupportedEffects(); -+ if (!(renderState instanceof AvatarRenderState state)) throw new IllegalArgumentException("Invalid native portrait player state"); -+ final var rotation = new Quaternionf().rotateX((float) Math.toRadians(camera.pitch())); -+ final var viewport = viewport(box.clip(), graphics.guiWidth(), graphics.guiHeight()); -+ if (viewport.width() <= 0 || viewport.height() <= 0) return; -+ graphics.entity(state, camera.scale(), viewportTranslation(box.bounds(), viewport, rotation, state.scale), -+ rotation, null, (int) viewport.x(), (int) viewport.y(), -+ (int) (viewport.x() + viewport.width()), (int) (viewport.y() + viewport.height())); -+ } -+ -+ private void player(final GuiGraphicsExtractor graphics, final NativeUiLayout.Box box, final Avatar player, -+ final NativeActorMessage.Snapshot snapshot, -+ final BedrockGuiActorContext context, final float mouseX, final float mouseY, final float partialTick) { -+ if (box.bounds().width() <= 0 || box.bounds().height() <= 0) return; -+ final var viewport = viewport(box.clip(), graphics.guiWidth(), graphics.guiHeight()); -+ if (viewport.width() <= 0 || viewport.height() <= 0) return; -+ final var flags = snapshot.flags(); -+ final var camera = NativeUiPlayerCamera.resolve(box.bounds(), viewport, box.layer(), box.node().properties(), -+ new NativeUiPlayerCamera.Cursor(mouseX, mouseY), -+ new NativeUiPlayerCamera.Rotation(player.getXRot(), player.getYRot(), player.yBodyRot, player.yBodyRotO, -+ player.yHeadRot, player.yHeadRotO), -+ flags.contains(ActorFlags.GLIDING), -+ flags.contains(ActorFlags.SWIMMING), -+ flags.contains(ActorFlags.CRAWLING)); -+ context.pose(camera.pitch(), camera.yaw(), camera.bodyYaw(), camera.headYaw(), camera.modelTransform()); -+ final var renderState = context.scoped(() -> Minecraft.getInstance().getEntityRenderDispatcher().extractEntity(player, partialTick)); -+ context.requireSupportedEffects(); -+ if (!(renderState instanceof AvatarRenderState state)) -+ throw new IllegalArgumentException("Invalid native player preview state"); -+ graphics.entity(state, camera.scale(), camera.translation(state.scale), new Quaternionf(), null, -+ (int) viewport.x(), (int) viewport.y(), (int) (viewport.x() + viewport.width()), -+ (int) (viewport.y() + viewport.height())); -+ } -+ -+ private void actor(final GuiGraphicsExtractor graphics, final NativeUiLayout.Box box, -+ final net.minecraft.world.entity.Entity entity, -+ final NativeActorMessage.Snapshot snapshot, -+ final BedrockGuiActorContext context, final float opacity) { -+ final var viewport = viewport(box.clip(), graphics.guiWidth(), graphics.guiHeight()); -+ if (viewport.width() <= 0 || viewport.height() <= 0) return; -+ final var portrait = box.node().properties().getAsJsonObject("actor_portrait"); -+ final var data = portrait.getAsJsonObject("actor_data"); -+ final var properties = box.node().properties().deepCopy(); -+ final var bag = properties.getAsJsonObject("property_bag"); -+ if (bag != null) bag.entrySet().forEach(entry -> properties.add(entry.getKey(), entry.getValue())); -+ final String offsetType = portrait.has("offsets") ? portrait.get("offsets").getAsString() : "portrait_offsets"; -+ if (!offsetType.equals("portrait_offsets") && !offsetType.equals("picker_offsets")) -+ throw new IllegalArgumentException("Invalid native portrait offset type"); -+ final JsonObject offsets = data.getAsJsonObject(offsetType); -+ final var nativeBounds = BedrockActorState.bounds(entity.getUUID()); -+ if (nativeBounds == null) throw new IllegalArgumentException("Native portrait bounding box is unavailable"); -+ final var client = Minecraft.getInstance(); -+ final float automaticRotation = properties.has("rotate_mode") && properties.get("rotate_mode").getAsString().equals("auto") -+ ? context.seconds() * 30F : 0; -+ final var camera = NativeUiActorCamera.resolve(box.bounds(), viewport, box.layer(), -+ net.raphimc.viabedrock.api.model.NativeActorBoundsMessage.scaledDimension(nativeBounds.width(), snapshot.scale()), -+ net.raphimc.viabedrock.api.model.NativeActorBoundsMessage.scaledDimension(nativeBounds.height(), snapshot.scale()), -+ net.raphimc.viabedrock.api.model.NativeActorBoundsMessage.scaledDimension(nativeBounds.width(), snapshot.scale()), -+ NativeUiViewport.current().scale(), properties, offsets, automaticRotation); -+ context.camera(0, 0); + try { -+ final var resources = BedrockGuiActorAppearance.resources(); -+ final var appearance = context.resource(resources, snapshot.identifier(), () -> { -+ try { return BedrockGuiActorAppearance.load(resources, snapshot.identifier()); } -+ catch (final java.io.IOException error) { throw new java.io.UncheckedIOException(error); } -+ }); -+ final int variant = portrait.get("variant").getAsBigDecimal().intValueExact(); -+ final var state = context.scoped(() -> { -+ try { -+ final var frame = appearance.sample(context.seconds(), snapshot, variant, 0, 0); -+ return ((IBedrockGuiActorRenderer) client.getEntityRenderDispatcher()).stackanvil$guiActorRenderer() -+ .extract(appearance, frame, camera.modelTransform(), opacity); -+ } catch (final java.io.IOException error) { throw new java.io.UncheckedIOException(error); } -+ }); -+ graphics.entity(state, camera.scale(), camera.translation(), new Quaternionf(), null, -+ (int) viewport.x(), (int) viewport.y(), (int) (viewport.x() + viewport.width()), -+ (int) (viewport.y() + viewport.height())); -+ } catch (final java.io.IOException error) { throw new java.io.UncheckedIOException(error); } -+ } -+ -+ private void retire(final String key) { -+ final var context = this.contexts.remove(key); -+ if (context != null) context.close(); ++ if (this.temporary != null) { ++ try (var entries = Files.list(this.temporary)) { ++ for (final Path entry : entries.toList()) Files.deleteIfExists(entry); ++ } finally { Files.deleteIfExists(this.temporary); } ++ } ++ } catch (final IOException | RuntimeException | Error error) { ++ if (failure == null) failure = error; else failure.addSuppressed(error); ++ } ++ if (failure instanceof IOException error) throw error; ++ if (failure instanceof RuntimeException error) throw error; ++ if (failure instanceof Error error) throw error; + } -+ -+ public record Camera(float pitch, float yaw, float scale) {} +} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiAnimationLifecycle.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiAnimationLifecycle.java +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiText.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiText.java new file mode 100644 -index 0000000..c0b255c +index 0000000..654ac41 --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiAnimationLifecycle.java -@@ -0,0 +1,162 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiText.java +@@ -0,0 +1,144 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ -+package com.viaversion.viafabricplus.bedrock.screen.ui; ++package com.viaversion.viafabricplus.bedrock.render.oreui; + ++import java.io.IOException; +import java.util.ArrayList; -+import java.util.Collections; -+import java.util.HashMap; -+import java.util.IdentityHashMap; +import java.util.List; ++import java.util.LinkedHashMap; +import java.util.Map; -+import java.util.Objects; -+import java.util.Set; + -+/** Applies authored animation destruction to the resolved control tree. */ -+public final class NativeUiAnimationLifecycle { -+ private NativeUiScene.Node root; -+ private final NativeUiAnimations animations; -+ private boolean exiting; -+ private boolean exitComplete; -+ private Map> pendingExit = new IdentityHashMap<>(); -+ private final Map> paths = new IdentityHashMap<>(); -+ private final Set destroyed = Collections.newSetFromMap(new IdentityHashMap<>()); -+ private final Set retiredFactories = Collections.newSetFromMap(new IdentityHashMap<>()); ++/** Prepares shaped glyph IDs and cache images together, before the screen submits their geometry. */ ++public final class OreUiText implements AutoCloseable { ++ private static final int MAX_IMAGE_BYTES = 16 * 1024 * 1024; ++ private final OreUiAssets assets; ++ private final OreUiShaper engine; ++ private final LinkedHashMap fonts = new LinkedHashMap<>(32, .75F, true); ++ private final LinkedHashMap images = new LinkedHashMap<>(512, .75F, true); ++ private int imageBytes; ++ private boolean closed; + -+ public NativeUiAnimationLifecycle(final NativeUiScene.Node root) { -+ this.root = root; -+ collect(root, List.of()); -+ this.animations = new NativeUiAnimations((node, name) -> { -+ final var control = target(this.paths.get(node), name); -+ if (control == null) return false; -+ control.properties().addProperty("ignored", true); -+ if (this.destroyed.add(control)) this.retireFactories(control); -+ return true; -+ }, completion -> { -+ if (!this.exiting || !completion.event().equals("screen.exit_end")) return; -+ final var tracks = this.pendingExit.get(completion.node()); -+ if (tracks == null) return; -+ tracks.remove(completion.track()); -+ if (tracks.isEmpty()) this.pendingExit.remove(completion.node()); -+ this.exitComplete = this.pendingExit.isEmpty(); -+ }); -+ } ++ OreUiText(final OreUiAssets assets, final OreUiShaper engine) { this.assets = assets; this.engine = engine; } + -+ public void advance(final long elapsedNanos) { -+ this.animations.advance(this.root, elapsedNanos); ++ public static OreUiText bundled() throws IOException { ++ final var assets = OreUiAssets.bundled(); ++ return new OreUiText(assets, OreUiShaper.bundled()); + } + -+ public NativeUiAnimations animations() { return this.animations; } ++ public OreUiAssets assets() { return this.assets; } + -+ /** Hosts retire only these completed creations, after animation traversal has finished. */ -+ public List drainRetiredFactories() { -+ final var retired = List.copyOf(this.retiredFactories); -+ this.retiredFactories.clear(); -+ return retired; ++ /** Shaping keeps its own integer rounding, independently of the retained cache generation height. */ ++ /** Uses the target Windows host's ordinary outline-font defaults, without per-run render overrides. */ ++ public Prepared prepareDefault(final OreUiAssets.Face face, final int displayPixels, ++ final String text, final String language) throws IOException { ++ final boolean sdf = face != null && !face.sdfOff() && displayPixels >= 10; ++ return this.prepare(face, sdf ? 52 : displayPixels, displayPixels, sdf, text, language); + } + -+ private void retireFactories(final NativeUiScene.Node node) { -+ if (node.properties().has("factory_instance_id")) this.retiredFactories.add(node); -+ for (final var child : node.children()) this.retireFactories(child); ++ /** The ordinary layout path measures per-scalar advances before line construction and letter spacing. */ ++ public synchronized float[] ordinaryAdvances(final OreUiAssets.Face face, final int pixels, ++ final String text, final String language) throws IOException { ++ if (this.closed || pixels < 1 || pixels > 1024 || !this.assets.faces().contains(face)) ++ throw new IOException("Invalid OreUI measurement font"); ++ return this.font(face, pixels).ordinaryAdvances(text, language); + } + -+ /** Returns whether an authored pop exit must finish before its host removes the scene. */ -+ public boolean requestExit(final long elapsedNanos) { -+ if (this.exiting) return !this.exitComplete; -+ this.advance(elapsedNanos); -+ this.exiting = true; -+ this.pendingExit = this.animations.completions(this.root, "screen.exit_pop", "screen.exit_end"); -+ this.exitComplete = this.pendingExit.isEmpty(); -+ this.animations.event(this.root, "screen.exit_pop", elapsedNanos); -+ return !this.exitComplete; ++ /** Resolve each face's display line metrics without preparing draw glyphs or cache images. */ ++ public synchronized OreUiFont.LineMetrics lineMetrics(final OreUiAssets.Face face, final int pixels, ++ final Float lineHeight) throws IOException { ++ if (this.closed || pixels < 1 || pixels > 1024 || !this.assets.faces().contains(face)) ++ throw new IOException("Invalid OreUI metrics font"); ++ return this.font(face, pixels).verticalMetrics().line(pixels, lineHeight); + } + -+ public boolean exiting() { return this.exiting; } -+ public boolean exitComplete() { return this.exitComplete; } -+ -+ /** Binding refreshes retain control clocks; new controls start at the refresh time. */ -+ public void replaceRoot(final NativeUiScene.Node root, final long elapsedNanos) { -+ this.advance(elapsedNanos); -+ final Map, NativeUiScene.Node> previous = new HashMap<>(), current = new HashMap<>(); -+ index(this.root, List.of(), 0, previous); -+ index(root, List.of(), 0, current); -+ final Map replacements = new IdentityHashMap<>(); -+ final Set retainedDestroyed = Collections.newSetFromMap(new IdentityHashMap<>()); -+ for (final var control : current.entrySet()) { -+ final var old = previous.get(control.getKey()); -+ final var node = control.getValue(); -+ if (old != null && this.destroyed.contains(old) -+ && Objects.equals(old.properties().get("animation_program"), node.properties().get("animation_program"))) { -+ node.properties().addProperty("ignored", true); -+ retainedDestroyed.add(node); -+ } -+ // New controls have no old identity. Each replacement still needs its own clock entry. -+ replacements.put(old == null ? node : old, node); -+ } -+ this.animations.reconcile(replacements, elapsedNanos); -+ if (this.exiting) { -+ final Map> retainedExit = new IdentityHashMap<>(); -+ replacements.forEach((previousNode, currentNode) -> { -+ final var pending = this.pendingExit.get(previousNode); -+ if (pending != null && Objects.equals(previousNode.properties().get("animation_program"), currentNode.properties().get("animation_program"))) -+ retainedExit.put(currentNode, pending); ++ /** The caller resolves the native face list and chooses the ordinary, non-bidi layout path. */ ++ public synchronized OreUiFonts ordinaryFonts(final List faces, final int pixels, ++ final OreUiCharacters text, final String language) throws IOException { ++ if (this.closed || pixels < 1 || pixels > 1024 || faces.size() > 32 || language.length() > 128) ++ throw new IOException("Invalid OreUI font assignment"); ++ final List fonts = new ArrayList<>(faces.size()); ++ for (final var face : faces) { ++ if (face == null) { fonts.add(null); continue; } ++ this.assets.font(face); ++ fonts.add(new OreUiFonts.Font() { ++ @Override public int[] glyphs(final int[] scalars) throws IOException { ++ return OreUiText.this.font(face, pixels).glyphs(scalars); ++ } ++ @Override public float[] advances(final int[] scalars) throws IOException { ++ return OreUiText.this.font(face, pixels).ordinaryAdvances(new String(scalars, 0, scalars.length), language); ++ } + }); -+ this.pendingExit = retainedExit; -+ this.exitComplete = retainedExit.isEmpty(); + } -+ this.destroyed.clear(); -+ this.destroyed.addAll(retainedDestroyed); -+ this.root = root; -+ this.paths.clear(); -+ collect(root, List.of()); ++ return OreUiFonts.assign(text, fonts); ++ } ++ ++ public synchronized Prepared prepare(final OreUiAssets.Face face, final int pixels, final int shapingPixels, final boolean sdf, ++ final String text, final String language) throws IOException { ++ if (this.closed) throw new IllegalStateException("OreUI text resources are closed"); ++ if (pixels < 1 || pixels > 2048 || shapingPixels < 1 || shapingPixels > 2048 ++ || sdf && (face.sdfOff() || pixels != 52)) throw new IOException("Invalid OreUI cache mode"); ++ // An alias must belong to the selected immutable source bundle even on a cache hit. ++ this.assets.font(face); ++ final OreUiFont.Run run = this.font(face, shapingPixels).shape(text, language); ++ final var key = new FontKey(face.file(), pixels); ++ final var font = this.font(face, pixels); ++ final Map selected = new LinkedHashMap<>(); ++ long selectedBytes = 0; ++ for (final var glyph : run.glyphs()) { ++ if (selected.containsKey(glyph.index())) continue; ++ final var imageKey = new ImageKey(key, glyph.index(), sdf); ++ var image = this.images.get(imageKey); ++ if (image == null) { ++ final var raster = font.raster(glyph.index()); ++ final var distance = sdf && raster.width() > 0 && raster.height() > 0 ++ ? OreUiDistanceField.generate(raster.pixels(), raster.width(), raster.height(), raster.stride()) : null; ++ image = new Image(raster, distance); ++ final int bytes = image.bytes(); ++ if (bytes > MAX_IMAGE_BYTES) throw new IOException("OreUI glyph exceeds the image budget"); ++ while (!this.images.isEmpty() && (this.images.size() >= 512 || this.imageBytes + bytes > MAX_IMAGE_BYTES)) { ++ this.imageBytes -= this.images.remove(this.images.firstEntry().getKey()).bytes(); ++ } ++ this.images.put(imageKey, image); this.imageBytes += bytes; ++ } ++ selectedBytes += image.bytes(); ++ if (selectedBytes > MAX_IMAGE_BYTES) throw new IOException("OreUI text exceeds the image budget"); ++ selected.put(glyph.index(), image); ++ } ++ return new Prepared(this.assets, face, pixels, shapingPixels, sdf, run, Map.copyOf(selected), font.verticalMetrics()); + } + -+ private static void index(final NativeUiScene.Node node, final List ancestors, final int occurrence, -+ final Map, NativeUiScene.Node> controls) { -+ final var path = new ArrayList<>(ancestors); -+ path.add(identity(node, occurrence)); -+ controls.put(List.copyOf(path), node); -+ final Map occurrences = new HashMap<>(); -+ for (final var child : node.children()) { -+ final int index = occurrences.merge(identity(child, 0), 1, Integer::sum) - 1; -+ index(child, path, index, controls); ++ private OreUiFont font(final OreUiAssets.Face face, final int pixels) throws IOException { ++ final var key = new FontKey(face.file(), pixels); ++ var font = this.fonts.get(key); ++ if (font == null) { ++ final byte[] bytes = this.assets.font(face); ++ if (this.fonts.size() == OreUiShaper.MAX_FONTS) this.fonts.remove(this.fonts.firstEntry().getKey()).close(); ++ font = this.engine.font(bytes, pixels); ++ this.fonts.put(key, font); + } ++ return font; + } + -+ private static Identity identity(final NativeUiScene.Node node, final int occurrence) { -+ return new Identity(node.name(), node.type(), String.valueOf(node.properties().get("inventory_slot")), -+ String.valueOf(node.properties().get("collection")), String.valueOf(node.properties().get("collection_index")), -+ String.valueOf(node.properties().get("factory_instance_id")), occurrence); ++ @Override ++ public synchronized void close() throws IOException { ++ if (this.closed) return; ++ this.closed = true; ++ try { this.engine.close(); } ++ finally { this.fonts.clear(); this.images.clear(); this.imageBytes = 0; } + } + -+ private record Identity(String name, String type, String slot, String collection, String index, String factoryInstance, int occurrence) {} -+ -+ boolean active() { return active(this.root); } -+ -+ private static boolean active(final NativeUiScene.Node node) { -+ if (node.flag("ignored", false) || !node.flag("visible", true)) return false; -+ if (!node.string("factory_name", "").isEmpty()) return node.children().stream().anyMatch(NativeUiAnimationLifecycle::visible); -+ return node.children().isEmpty() || node.children().stream().anyMatch(NativeUiAnimationLifecycle::active); ++ private record FontKey(String file, int pixels) { } ++ private record ImageKey(FontKey font, int glyph, boolean sdf) { } ++ public record Prepared(OreUiAssets source, OreUiAssets.Face face, int pixels, int shapingPixels, boolean sdf, ++ OreUiFont.Run run, Map images, OreUiFont.VerticalMetrics verticalMetrics) { ++ public OreUiFont.LineMetrics lineMetrics(final float pixels, final Float lineHeight) throws IOException { ++ return this.verticalMetrics.line(pixels, lineHeight); ++ } + } -+ -+ private static boolean visible(final NativeUiScene.Node node) { -+ return !node.flag("ignored", false) && node.flag("visible", true); -+ } -+ -+ private void collect(final NativeUiScene.Node node, final List ancestors) { -+ final var path = new ArrayList<>(ancestors); -+ path.add(node); -+ this.paths.put(node, List.copyOf(path)); -+ for (final var child : node.children()) collect(child, path); -+ } -+ -+ private static NativeUiScene.Node target(final List path, final String name) { -+ for (int i = path.size() - 1; i >= 0; i--) { -+ final var node = path.get(i); -+ if (node.name().equals(name)) return node; -+ } -+ return null; ++ public record Image(OreUiFont.Raster raster, OreUiDistanceField.Field distance) { ++ int bytes() { return this.raster.pixels().length + (this.distance == null ? 0 : this.distance.pixels().length); } + } +} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiAnimations.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiAnimations.java +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/BedrockHudOverlays.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/BedrockHudOverlays.java new file mode 100644 -index 0000000..b7aadf0 +index 0000000..df6dfa1 --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiAnimations.java -@@ -0,0 +1,455 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/BedrockHudOverlays.java +@@ -0,0 +1,128 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -4823,460 +6217,320 @@ index 0000000..b7aadf0 + */ +package com.viaversion.viafabricplus.bedrock.screen.ui; + -+import com.google.gson.JsonElement; -+import com.google.gson.JsonObject; -+import java.util.ArrayDeque; -+import java.util.ArrayList; -+import java.util.HashSet; -+import java.util.IdentityHashMap; -+import java.util.List; ++import com.viaversion.viafabricplus.bedrock.ViaFabricPlusBedrock; +import java.util.Map; -+import java.util.Set; -+import java.util.function.Consumer; -+import java.util.function.BiPredicate; -+import net.raphimc.viabedrock.api.model.CameraEase; -+ -+/** Per-control animation managers with native next-track and play/reset event semantics. */ -+public final class NativeUiAnimations { -+ private final Map entries = new IdentityHashMap<>(); -+ private final BiPredicate destroy; -+ private final Consumer completed; -+ private NativeUiScene.Node indexedRoot; -+ private List managers = List.of(); -+ private long layoutRevision; -+ private long opacityRevision; ++import java.util.EnumMap; ++import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; ++import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; ++import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry; ++import net.minecraft.client.Minecraft; ++import net.minecraft.client.DeltaTracker; ++import net.minecraft.client.gui.GuiGraphicsExtractor; ++import net.minecraft.network.Connection; ++import net.minecraft.network.chat.Component; ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeHudPayload.Kind; + -+ public long layoutRevision() { return this.layoutRevision; } -+ public long opacityRevision() { return this.opacityRevision; } ++/** Independent native HUD factories share layout, image and font rendering with server forms. */ ++public final class BedrockHudOverlays { ++ private static final Map states = new EnumMap<>(Kind.class); ++ static { for (final var kind : Kind.values()) states.put(kind, new NativeHudOverlayState()); } ++ private static NativeUiImages images; ++ private static NativeUiRenderer renderer; ++ private static Object resourceManager; ++ private static long generation; ++ private BedrockHudOverlays() {} + -+ void reconcile(final Map replacements, final long now) { -+ final Map retained = new IdentityHashMap<>(); -+ for (final var replacement : replacements.entrySet()) { -+ final var node = replacement.getValue(); -+ final Entry previous = this.entries.get(replacement.getKey()); -+ final Entry entry; -+ if (previous != null && java.util.Objects.equals(previous.node.properties().get("animation_program"), node.properties().get("animation_program"))) { -+ previous.advance(now); -+ previous.node = node; -+ previous.bindAseprite(); -+ if (previous.definitions.stream().noneMatch(definition -> definition.alpha)) -+ previous.current = previous.potential = Math.clamp(node.number("alpha", 1), 0, 1); -+ entry = previous; -+ } else entry = new Entry(node, now); -+ retained.put(node, entry); ++ public static void register() { ++ for (final var kind : Kind.values()) { ++ PayloadTypeRegistry.clientboundPlay().register(kind.type(), kind.codec()); ++ ClientPlayConnectionEvents.INIT.register((listener, client) -> ++ ClientPlayNetworking.registerReceiver(kind.type(), ++ (payload, context) -> accept(listener.getConnection(), payload))); + } -+ this.entries.clear(); -+ this.entries.putAll(retained); -+ this.indexedRoot = null; -+ this.managers = List.of(); -+ this.layoutRevision++; -+ } -+ -+ NativeUiDimension dimension(final NativeUiScene.Node node, final String property, final int axis) { -+ final Entry entry = this.entries.get(node); -+ if (entry == null) return null; -+ final Vector vector = switch (property) { -+ case "size" -> entry.size; -+ case "offset" -> entry.offset; -+ default -> null; -+ }; -+ return vector == null ? null : axis == 0 ? vector.x : vector.y; -+ } -+ -+ NativeUiColor color(final NativeUiScene.Node node) { -+ final Entry entry = this.entries.get(node); -+ return entry == null ? null : entry.color; ++ ClientPlayConnectionEvents.DISCONNECT.register((listener, client) -> { ++ generation++; ++ clear(); ++ }); + } + -+ NativeUiImageRegions.Source source(final NativeUiScene.Node node, final int width, final int height) { -+ final Entry entry = this.entries.get(node); -+ final float cropWidth = NativeUiLayout.vector(node, "uv_size", 0, width); -+ final float cropHeight = NativeUiLayout.vector(node, "uv_size", 1, height); -+ final var atlas = entry == null ? null : entry.aseprite; -+ return new NativeUiImageRegions.Source( -+ entry != null && entry.u != null ? entry.u.resolve(width) : NativeUiLayout.vector(node, "uv", 0, 0), -+ entry != null && entry.v != null ? entry.v.resolve(height) : NativeUiLayout.vector(node, "uv", 1, 0), -+ entry != null && entry.width != null ? entry.width.resolve(width) : atlas == null || atlas.width() == 0 ? cropWidth : width / atlas.width() * cropWidth, -+ entry != null && entry.height != null ? entry.height.resolve(height) : atlas == null || atlas.height() == 0 ? cropHeight : height / atlas.height() * cropHeight); ++ private static void accept(final Connection owner, final NativeHudPayload payload) { ++ final long received = generation; ++ final var client = Minecraft.getInstance(); ++ client.execute(() -> { ++ if (received != generation || client.getConnection() == null ++ || client.getConnection().getConnection() != owner || !owner.isConnected()) return; ++ final var state = states.get(payload.kind()); ++ try { ++ if (!state.accept(payload.message())) return; ++ retainTextCaches(); ++ switch (payload.kind()) { ++ case ROOT -> {} ++ case TITLE -> client.gui.hud.clearTitles(); ++ case ACTIONBAR -> client.gui.hud.setOverlayMessage(Component.empty(), false); ++ } ++ } catch (RuntimeException invalid) { ++ state.clear(); ++ retainTextCaches(); ++ ViaFabricPlusBedrock.impl().logger().warn("Could not display native HUD " + payload.kind(), invalid); ++ } ++ }); + } + -+ public NativeUiAnimations() { this((node, name) -> false); } -+ -+ NativeUiAnimations(final BiPredicate destroy) { this(destroy, event -> { }); } -+ -+ NativeUiAnimations(final BiPredicate destroy, -+ final Consumer completed) { -+ this.destroy = destroy; -+ this.completed = completed; ++ public static void tick() { ++ boolean retired = false; ++ for (final var state : states.values()) { ++ final boolean active = state.active(); ++ state.tick(); ++ retired |= active && !state.active(); ++ } ++ if (retired) retainTextCaches(); + } + -+ record Completion(NativeUiScene.Node node, int track, String event) {} -+ -+ Map> completions(final NativeUiScene.Node node, final String play, final String completion) { -+ final Map> result = new IdentityHashMap<>(); -+ this.collectCompletions(node, play, completion, result); -+ return result; ++ private static void retainTextCaches() { ++ if (renderer != null) renderer.retain(states.values().stream().filter(NativeHudOverlayState::active) ++ .map(state -> state.scene().root()).toList()); + } + -+ private void collectCompletions(final NativeUiScene.Node node, final String play, final String completion, -+ final Map> result) { -+ final var entry = this.entries.get(node); -+ if (entry != null) { -+ final var tracks = entry.completions(play, completion); -+ if (!tracks.isEmpty()) result.put(node, tracks); ++ public static boolean draw(final Kind kind, final GuiGraphicsExtractor graphics, final DeltaTracker delta) { ++ final var state = states.get(kind); ++ if (!state.active()) return false; ++ final var client = Minecraft.getInstance(); ++ try { ++ if (renderer == null || resourceManager != client.getResourceManager()) { ++ closeResources(); ++ resourceManager = client.getResourceManager(); ++ images = new NativeUiImages(); ++ renderer = new NativeUiRenderer(client.font, images); ++ } ++ final var viewport = NativeUiViewport.current(); ++ final long now = state.time(delta.getGameTimeDeltaPartialTick(false)); ++ state.animations().advance(state.scene().root(), now); ++ final var layout = state.layout(renderer, viewport.width(), viewport.height(), images.revision()); ++ final var draws = NativeUiPresentation.draws(layout, _ -> false, node -> state.animations().opacityAt(node, now)); ++ graphics.pose().pushMatrix(); ++ try { ++ graphics.pose().scale(viewport.renderScale(), viewport.renderScale()); ++ for (final var draw : draws) { ++ if (draw.alpha() <= 0 || draw.box().visibleBounds().width() <= 0 || draw.box().visibleBounds().height() <= 0) continue; ++ final var clip = draw.box().clip(); ++ NativeUiScissor.enable(graphics, clip); ++ try { renderer.draw(graphics, draw, state.animations()); } ++ finally { graphics.disableScissor(); } ++ } ++ } finally { graphics.pose().popMatrix(); } ++ return true; ++ } catch (RuntimeException invalid) { ++ state.clear(); ++ retainTextCaches(); ++ ViaFabricPlusBedrock.impl().logger().warn("Could not render native HUD " + kind, invalid); ++ return false; + } -+ for (final var child : node.children()) this.collectCompletions(child, play, completion, result); + } + -+ public NativeUiPresentation.Opacity opacity(final NativeUiScene.Node node, final long now) { -+ final Entry entry = this.entries.computeIfAbsent(node, value -> new Entry(value, now)); -+ entry.rendered(); -+ entry.advance(now); -+ return entry.opacity(); ++ private static void closeResources() { ++ if (renderer != null) renderer.close(); ++ if (images != null) images.close(); ++ renderer = null; images = null; resourceManager = null; + } -+ -+ /** HUD replacements retain their elapsed title clock rather than using the scene's creation time. */ -+ public NativeUiPresentation.Opacity opacityAt(final NativeUiScene.Node node, final long elapsed) { -+ final Entry entry = this.entries.computeIfAbsent(node, value -> new Entry(value, 0)); -+ entry.rendered(); -+ entry.advance(elapsed); -+ return entry.opacity(); ++ public static void resourcesReloaded() { closeResources(); } ++ public static void clear() { ++ for (final var state : states.values()) state.clear(); ++ closeResources(); + } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/BedrockNativeUiHurt.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/BedrockNativeUiHurt.java +new file mode 100644 +index 0000000..dcc7c1e +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/BedrockNativeUiHurt.java +@@ -0,0 +1,67 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.ui; + -+ void advance(final NativeUiScene.Node node, final long elapsed) { -+ if (this.indexedRoot != node) { -+ final List managers = new ArrayList<>(); -+ this.collectManagers(node, managers); -+ this.managers = List.copyOf(managers); -+ this.indexedRoot = node; -+ } -+ for (final var manager : this.managers) manager.advance(elapsed); -+ } ++import com.viaversion.viafabricplus.bedrock.render.BedrockActorState; ++import java.util.Objects; ++import java.util.UUID; ++import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; ++import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; ++import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; ++import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry; ++import net.minecraft.client.Minecraft; ++import net.minecraft.network.Connection; ++import net.raphimc.viabedrock.api.model.NativeUiHurtEvent; ++import net.raphimc.viabedrock.api.model.NativeUiHurtState; + -+ private void collectManagers(final NativeUiScene.Node node, final List managers) { -+ final Entry entry = this.entries.computeIfAbsent(node, value -> new Entry(value, 0)); -+ if (!entry.definitions.isEmpty()) managers.add(entry); -+ for (final var child : node.children()) this.collectManagers(child, managers); ++/** The native pending check belongs to the current player, including while no screen can close. */ ++public final class BedrockNativeUiHurt { ++ private static final NativeUiHurtState STATE = new NativeUiHurtState(); ++ private static Connection owner; ++ private static UUID lifetime; ++ ++ private BedrockNativeUiHurt() {} ++ ++ public static void register() { ++ PayloadTypeRegistry.clientboundPlay().register(NativeUiHurtPayload.TYPE, NativeUiHurtPayload.CODEC); ++ ClientPlayConnectionEvents.INIT.register((listener, client) -> ++ ClientPlayNetworking.registerReceiver(NativeUiHurtPayload.TYPE, (payload, context) -> ++ client.execute(() -> apply(listener.getConnection(), payload.event(), client)))); ++ ClientPlayConnectionEvents.DISCONNECT.register((listener, client) -> { ++ if (listener.getConnection() == owner) { ++ STATE.clear(); ++ owner = null; ++ lifetime = null; ++ } ++ }); ++ ClientTickEvents.START_CLIENT_TICK.register(BedrockNativeUiHurt::tick); + } + -+ /** Events affect the receiving control and its descendants, not unrelated scrolling panels. */ -+ public void event(final NativeUiScene.Node node, final String event, final long now) { -+ final Entry entry = this.entries.computeIfAbsent(node, value -> new Entry(value, now)); -+ entry.advance(now); -+ entry.event(event); -+ for (final var child : node.children()) event(child, event, now); ++ private static void synchronize(final Minecraft client) { ++ final var listener = client.getConnection(); ++ final Connection current = listener == null || client.level == null || client.player == null ++ || !listener.getConnection().isConnected() ? null : listener.getConnection(); ++ final UUID currentLifetime = current == null ? null : BedrockActorState.lifetime(client.player.getUUID()); ++ if (current != owner || !Objects.equals(currentLifetime, lifetime)) { ++ STATE.clear(); ++ owner = current; ++ lifetime = currentLifetime; ++ } + } + -+ private final class Entry { -+ NativeUiScene.Node node; -+ final List definitions = new ArrayList<>(); -+ final List active = new ArrayList<>(); -+ final float[] times; -+ final State[] states; -+ final NativeUiFlipbook[] flipbooks; -+ final NativeUiAseprite[] aseprites; -+ NativeUiAseprite.Metadata aseprite; -+ Vector size, offset; -+ NativeUiColor color; -+ TextureCoordinate u, v, width, height; -+ float current; -+ float potential; -+ boolean rendered, advanced; -+ long last; ++ static void apply(final Connection expected, final NativeUiHurtEvent event, final Minecraft client) { ++ synchronize(client); ++ if (expected == owner && event.lifetime().equals(lifetime)) STATE.accept(event); ++ } + -+ Entry(final NativeUiScene.Node node, final long now) { -+ this.node = node; -+ this.last = now; -+ this.current = this.potential = Math.clamp(node.number("alpha", 1), 0, 1); -+ final JsonObject program = node.properties().getAsJsonObject("animation_program"); -+ if (program != null) { -+ for (final var value : program.getAsJsonArray("tracks")) { -+ final Definition definition = new Definition(value.getAsJsonObject(), node.number("alpha", 1)); -+ this.definitions.add(definition); -+ if (definition.alpha) this.potential = Math.max(this.potential, Math.max(definition.track.from, definition.track.to) * definition.alphaScale); -+ } -+ for (final var root : program.getAsJsonArray("roots")) this.active.add(root.getAsInt()); -+ } -+ this.times = new float[this.definitions.size()]; -+ this.states = new State[this.definitions.size()]; -+ this.flipbooks = new NativeUiFlipbook[this.definitions.size()]; -+ this.aseprites = new NativeUiAseprite[this.definitions.size()]; -+ bindAseprite(); -+ for (int i = 0; i < this.definitions.size(); i++) { -+ final var definition = this.definitions.get(i); -+ if (definition.next < -1 || definition.next >= this.definitions.size()) throw new IllegalArgumentException("Invalid UI animation successor"); -+ if (definition.flipbook) this.flipbooks[i] = new NativeUiFlipbook(definition.fps, definition.frames, -+ definition.looping, definition.reversible, definition.vertical); -+ if (definition.aseprite) this.aseprites[i] = new NativeUiAseprite(); -+ this.states[i] = initial(definition); -+ } -+ for (final int root : this.active) { -+ if (root < 0 || root >= this.definitions.size()) throw new IllegalArgumentException("Invalid UI animation root"); -+ final var definition = this.definitions.get(root); -+ sample(root, definition, 0, true); -+ } -+ } ++ static void tick(final Minecraft client) { ++ synchronize(client); ++ if (owner == null || lifetime == null) return; ++ final var screen = client.gui.screen(); ++ final boolean policy = screen instanceof NativeUiScreen nativeScreen && nativeScreen.closeOnPlayerHurt(); ++ if (STATE.tick(policy) && client.gui.screen() == screen) screen.onClose(); ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeHudOverlayState.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeHudOverlayState.java +new file mode 100644 +index 0000000..6db85a8 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeHudOverlayState.java +@@ -0,0 +1,66 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.ui; + -+ void bindAseprite() { -+ this.aseprite = this.node.type().equals("image") && this.definitions.stream().anyMatch(definition -> definition.aseprite) -+ ? NativeUiAseprite.Metadata.parse(this.node.properties().get("aseprite")) : null; -+ } ++import java.util.Set; ++import java.util.Map; ++import net.raphimc.viabedrock.api.model.NativeHudMessage; + -+ State initial(final Definition definition) { -+ return !definition.play.isEmpty() ? State.EVENT : definition.waitForRender && !this.rendered ? State.RENDER : State.PLAYING; -+ } ++/** One factory instance and its clock; scene replacement does not revive an expired title. */ ++final class NativeHudOverlayState { ++ private NativeUiScene scene; ++ private NativeUiAnimations animations; ++ private NativeUiAnimationLifecycle lifecycle; ++ private long elapsed; ++ private int lifetime; ++ private NativeUiLayout.Box layout; ++ private NativeUiLayout.Metrics metrics; ++ private int width, height; ++ private long layoutRevision, imageRevision; + -+ void rendered() { -+ this.rendered = true; -+ for (int i = 0; i < this.states.length; i++) if (this.states[i] == State.RENDER) this.states[i] = State.PLAYING; -+ } ++ boolean accept(final NativeHudMessage message) { ++ if (message.scene().isEmpty()) { clear(); return true; } ++ if (!message.restart() && !active()) return false; ++ final var next = NativeUiScene.parse(message.scene(), Set.of()); ++ if (message.restart()) { ++ this.elapsed = 0; ++ this.lifecycle = new NativeUiAnimationLifecycle(next.root()); ++ } else this.lifecycle.replaceRoot(next.root(), time(0)); ++ this.scene = next; ++ this.lifetime = message.ticks(); ++ this.animations = this.lifecycle.animations(); ++ this.lifecycle.advance(time(0)); ++ this.layout = null; ++ if (!active()) clear(); ++ return true; ++ } + -+ void event(final String event) { -+ for (final int index : List.copyOf(this.active)) { -+ final var definition = this.definitions.get(index); -+ if (!definition.play.isEmpty() && definition.play.equals(event)) { -+ this.states[index] = definition.waitForRender && !this.rendered ? State.RENDER : State.PLAYING; -+ } else if (!definition.reset.isEmpty() && definition.reset.equals(event)) { -+ this.times[index] = 0; -+ this.states[index] = initial(definition); -+ sample(index, definition, 0, true); -+ } -+ } -+ } ++ boolean active() { return this.scene != null && (this.lifetime == NativeHudMessage.ANIMATION_LIFETIME ? this.lifecycle.active() : this.elapsed < this.lifetime); } ++ NativeUiScene scene() { return this.scene; } ++ NativeUiAnimations animations() { return this.animations; } ++ long time(final float partialTick) { return (long) ((this.elapsed + partialTick) * 50_000_000D); } + -+ Set completions(final String play, final String completion) { -+ final Set result = new HashSet<>(); -+ for (final int root : this.active) { -+ if (!this.definitions.get(root).play.equals(play)) continue; -+ int index = root; -+ for (int visited = 0; visited < this.definitions.size(); visited++) { -+ final var definition = this.definitions.get(index); -+ if (index != root && !definition.play.isEmpty()) break; -+ if (definition.next < 0) { -+ if (definition.end.equals(completion)) result.add(index); -+ break; -+ } -+ index = definition.next; -+ } -+ } -+ return result; ++ NativeUiLayout.Box layout(final NativeUiLayout.Metrics metrics, final int width, final int height, final long imageRevision) { ++ if (this.layout == null || this.metrics != metrics || this.width != width || this.height != height ++ || this.layoutRevision != this.animations.layoutRevision() || this.imageRevision != imageRevision) { ++ this.layout = new NativeUiLayout(metrics, this.animations).layout(this.scene.root(), width, height, Map.of()); ++ this.metrics = metrics; ++ this.width = width; ++ this.height = height; ++ this.layoutRevision = this.animations.layoutRevision(); ++ this.imageRevision = imageRevision; + } ++ return this.layout; ++ } + -+ void advance(final long now) { -+ // Java partial ticks can retreat during a pause; native frame deltas never rewind. -+ if (now < this.last || this.advanced && now == this.last) return; -+ this.advanced = true; -+ final float delta = (float) ((now - this.last) / 1_000_000_000D); -+ this.last = now; -+ if (this.active.isEmpty()) return; -+ final var next = new ArrayDeque(); -+ final var events = new ArrayList(); -+ // Native managers update all current tracks before processing their queued successors. -+ for (final int index : List.copyOf(this.active)) update(index, delta, next, events); -+ int transitions = 0; -+ while (!next.isEmpty()) { -+ if (++transitions > 65536) throw new IllegalArgumentException("UI animation chain does not settle in this frame"); -+ final var step = next.removeFirst(); -+ this.active.add(step.index); -+ update(step.index, step.delta, next, events); -+ } -+ for (final var event : events) { -+ event(event.event()); -+ NativeUiAnimations.this.completed.accept(event); -+ } -+ } ++ void tick() { ++ if (this.scene == null) return; ++ this.elapsed++; ++ this.lifecycle.advance(time(0)); ++ if (!active()) clear(); ++ } ++ void clear() { this.scene = null; this.animations = null; this.lifecycle = null; this.layout = null; this.metrics = null; this.elapsed = 0; this.lifetime = 0; } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeHudPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeHudPayload.java +new file mode 100644 +index 0000000..a93257e +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeHudPayload.java +@@ -0,0 +1,36 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.ui; + -+ void update(final int index, final float delta, final ArrayDeque next, final List events) { -+ if (this.states[index] != State.PLAYING) return; -+ final var definition = this.definitions.get(index); -+ // Image tracks finish without consuming time when the control has no image component. -+ final boolean applicable = !definition.image || this.node.type().equals("image"); -+ if (applicable && definition.aseprite) { -+ final var frame = this.aseprites[index].advance(this.aseprite, delta); -+ if (frame != null) { -+ this.u = new TextureCoordinate(0, 1, frame.x()); -+ this.v = new TextureCoordinate(0, 1, frame.y()); -+ } -+ return; -+ } else if (applicable && definition.flipbook) { -+ final var flipbook = this.flipbooks[index]; -+ if (flipbook.advance(delta)) { -+ final float positionFrame = flipbook.frames() > 0 ? flipbook.position() : 0; -+ final float cropFrame = flipbook.frames() > 0 ? 1 : 0; -+ final int frames = Math.max(1, flipbook.frames()); -+ final var position = new TextureCoordinate(positionFrame, frames, 1F / 256); -+ final var crop = new TextureCoordinate(cropFrame, frames, -1F / 128); -+ this.u = flipbook.vertical() ? new TextureCoordinate(0, 1, 0) : position; -+ this.v = flipbook.vertical() ? position : new TextureCoordinate(0, 1, 0); -+ if (flipbook.vertical()) this.height = crop; -+ else this.width = crop; -+ return; -+ } -+ if (definition.resettable) { this.states[index] = State.EVENT; return; } -+ } else if (applicable) { -+ this.times[index] += delta; -+ sample(index, definition, this.times[index], false); -+ if (this.times[index] < definition.track.delay + Math.max(.01F, definition.track.duration)) return; -+ } -+ final float elapsed = this.times[index]; -+ this.active.remove(Integer.valueOf(index)); -+ if (!definition.destroy.isEmpty() && NativeUiAnimations.this.destroy.test(this.node, definition.destroy)) { -+ NativeUiAnimations.this.layoutRevision++; -+ if (!definition.end.isEmpty()) events.add(new Completion(this.node, index, definition.end)); -+ return; -+ } -+ if (definition.next >= 0) { -+ this.times[definition.next] = 0; -+ next.addLast(new Step(definition.next, Math.max(0, elapsed - definition.track.delay - definition.track.duration))); -+ } else if (!definition.end.isEmpty()) events.add(new Completion(this.node, index, definition.end)); -+ } -+ -+ void sample(final int index, final Definition definition, final float elapsed, final boolean reset) { -+ if (definition.image && !this.node.type().equals("image")) return; -+ if (definition.aseprite && reset) this.u = this.v = new TextureCoordinate(0, 1, 0); -+ if (definition.flipbook && reset) { -+ this.flipbooks[index].reset(); -+ this.u = this.v = new TextureCoordinate(0, 1, 0); -+ } -+ if (definition.uvFrom != null) { -+ final float factor = reset ? 0 : definition.track.factor(elapsed); -+ this.u = new TextureCoordinate(0, 1, definition.uvFrom.x + (definition.uvTo.x - definition.uvFrom.x) * factor); -+ this.v = new TextureCoordinate(0, 1, definition.uvFrom.y + (definition.uvTo.y - definition.uvFrom.y) * factor); -+ } -+ if (definition.alpha) { -+ final float alpha = (reset ? definition.track.from : definition.track.value(elapsed)) * definition.alphaScale; -+ if (this.current != alpha) { -+ this.current = alpha; -+ NativeUiAnimations.this.opacityRevision++; -+ } -+ } -+ if (definition.colorFrom != null && this.node.type().equals("image")) -+ this.color = reset ? definition.colorFrom : definition.colorFrom.interpolate(definition.colorTo, definition.track.factor(elapsed)); -+ if (definition.from == null) return; -+ final Vector value = reset ? definition.from : definition.from.interpolate(definition.to, definition.track.factor(elapsed)); -+ final Vector previous = definition.type.equals("size") ? this.size : this.offset; -+ if (!value.equals(previous)) { -+ if (definition.type.equals("size")) this.size = value; -+ else this.offset = value; -+ NativeUiAnimations.this.layoutRevision++; -+ } -+ } -+ -+ NativeUiPresentation.Opacity opacity() { -+ return new NativeUiPresentation.Opacity(Math.clamp(this.current, 0, 1), Math.clamp(this.potential, 0, 1)); -+ } -+ } -+ -+ private enum State { PLAYING, EVENT, RENDER } -+ private record Step(int index, float delta) {} -+ -+ private static final class Definition { -+ final Track track; -+ final String type; -+ final Vector from, to; -+ final NativeUiColor colorFrom, colorTo; -+ final boolean alpha, image, flipbook, aseprite, looping, reversible, vertical, resettable; -+ final float fps, alphaScale; -+ final int frames; -+ final Uv uvFrom, uvTo; -+ final boolean waitForRender; -+ final int next; -+ final String play, reset, end, destroy; -+ -+ Definition(final JsonObject value, final float startingAlpha) { -+ this.type = NativeUiScene.string(value, "anim_type", ""); -+ this.alpha = this.type.equals("alpha"); -+ this.alphaScale = this.alpha && flag(value, "scale_from_starting_alpha", false) ? startingAlpha : 1; -+ this.flipbook = this.type.equals("flip_book"); -+ this.aseprite = this.type.equals("aseprite_flip_book"); -+ this.image = this.aseprite || this.flipbook || this.type.equals("uv") || this.type.equals("color"); -+ this.uvFrom = this.type.equals("uv") ? Uv.parse(value.get("from")) : null; -+ this.uvTo = this.type.equals("uv") ? Uv.parse(value.get("to")) : null; -+ this.fps = this.flipbook ? number(value, "fps", 0) : 0; -+ this.frames = this.flipbook ? integer(value, "frame_count", 1) : 0; -+ this.looping = flag(value, "looping", true); -+ this.resettable = flag(value, "resettable", true); -+ this.reversible = flag(value, "reversible", false); -+ this.vertical = List.of("vertical", "none").contains(NativeUiScene.string(value, "orientation", "horizontal")); -+ this.colorFrom = this.type.equals("color") ? NativeUiColor.parse(value.get("from")) : null; -+ this.colorTo = this.type.equals("color") ? NativeUiColor.parse(value.get("to")) : null; -+ final boolean vector = this.type.equals("size") || this.type.equals("offset"); -+ this.from = vector ? Vector.parse(value.get("from")) : null; -+ this.to = vector ? Vector.parse(value.get("to")) : null; -+ this.track = new Track(this.alpha ? number(value, "from", 1) : 1, this.alpha ? number(value, "to", 1) : 1, number(value, "duration", 0), -+ number(value, "delay", number(value, "start_delay", 0)), NativeUiScene.string(value, "easing", "linear")); -+ this.next = value.has("next_index") ? value.get("next_index").getAsInt() : -1; -+ this.play = NativeUiScene.string(value, "play_event", ""); -+ this.reset = NativeUiScene.string(value, "reset_event", ""); -+ this.end = NativeUiScene.string(value, "end_event", ""); -+ this.destroy = NativeUiScene.string(value, "destroy_at_end", ""); -+ this.waitForRender = value.has("wait_until_rendered_to_play") && !value.get("wait_until_rendered_to_play").isJsonNull() && value.get("wait_until_rendered_to_play").getAsBoolean(); -+ } -+ } -+ -+ private static boolean flag(final JsonObject value, final String key, final boolean fallback) { -+ return !value.has(key) || value.get(key).isJsonNull() ? fallback : value.get(key).getAsBoolean(); -+ } -+ -+ private static int integer(final JsonObject value, final String key, final int fallback) { -+ final float number = number(value, key, fallback); -+ if (number < Integer.MIN_VALUE || number > Integer.MAX_VALUE || number != Math.rint(number)) -+ throw new IllegalArgumentException("Invalid native UI animation integer"); -+ return (int) number; -+ } -+ -+ private record TextureCoordinate(float frame, int frames, float offset) { -+ float resolve(final int extent) { return (float) extent / this.frames * this.frame + this.offset; } -+ } ++import net.minecraft.network.RegistryFriendlyByteBuf; ++import net.minecraft.network.codec.StreamCodec; ++import net.minecraft.network.protocol.common.custom.CustomPacketPayload; ++import net.minecraft.resources.Identifier; ++import net.raphimc.viabedrock.api.model.NativeHudMessage; + -+ private record Uv(float x, float y) { -+ static Uv parse(final JsonElement value) { -+ if (value == null || value.isJsonNull()) return new Uv(0, 0); -+ if (!value.isJsonArray() || value.getAsJsonArray().size() != 2) -+ throw new IllegalArgumentException("Invalid UI texture animation endpoint"); -+ final float x = value.getAsJsonArray().get(0).getAsFloat(), y = value.getAsJsonArray().get(1).getAsFloat(); -+ if (!Float.isFinite(x) || !Float.isFinite(y)) throw new IllegalArgumentException("Non-finite UI texture coordinate"); -+ return new Uv(x, y); -+ } -+ } ++/** Separate capabilities retain the existing title wire format for older add-ons. */ ++public record NativeHudPayload(Kind kind, NativeHudMessage message) implements CustomPacketPayload { ++ public enum Kind { ++ ROOT(NativeHudMessage.ROOT_CHANNEL), ++ TITLE(NativeHudMessage.CHANNEL), ++ ACTIONBAR(NativeHudMessage.ACTIONBAR_CHANNEL); + -+ private static float number(final JsonObject value, final String key, final float fallback) { -+ if (!value.has(key) || value.get(key).isJsonNull()) return fallback; -+ final float number = value.get(key).getAsFloat(); -+ if (!Float.isFinite(number)) throw new IllegalArgumentException("Non-finite native UI animation"); -+ return number; -+ } ++ private final Type type; ++ private final StreamCodec codec; + -+ public record Track(float from, float to, float duration, float delay, String easing) { -+ public float value(final double elapsed) { -+ // The target updater divides by max(duration, 0.01), including zero-duration tracks. -+ return this.from + (this.to - this.from) * factor(elapsed); ++ Kind(final String channel) { ++ this.type = new Type<>(Identifier.parse(channel)); ++ this.codec = CustomPacketPayload.codec( ++ (payload, buffer) -> NativeHudMessage.TYPE.write(buffer, payload.message), ++ buffer -> new NativeHudPayload(this, NativeHudMessage.TYPE.read(buffer))); + } + -+ float factor(final double elapsed) { -+ final float progress = (float) Math.clamp((elapsed - delay) / Math.max(.01F, duration), 0, 1); -+ return CameraEase.fromName(this.easing).factor(progress); -+ } ++ public Type type() { return this.type; } ++ public StreamCodec codec() { return this.codec; } + } + -+ private record Vector(NativeUiDimension x, NativeUiDimension y) { -+ static Vector parse(final JsonElement value) { -+ if (value == null || value.isJsonNull()) return new Vector(new NativeUiDimension(List.of()), new NativeUiDimension(List.of())); -+ if (!value.isJsonArray() || value.getAsJsonArray().size() != 2) throw new IllegalArgumentException("Invalid UI vector animation endpoint"); -+ return new Vector(NativeUiDimension.parse(value.getAsJsonArray().get(0)), NativeUiDimension.parse(value.getAsJsonArray().get(1))); -+ } -+ -+ Vector interpolate(final Vector to, final float factor) { -+ return new Vector(this.x.interpolate(to.x, factor), this.y.interpolate(to.y, factor)); -+ } -+ } ++ @Override public Type type() { return this.kind.type(); } +} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiAseprite.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiAseprite.java +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeHudResources.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeHudResources.java new file mode 100644 -index 0000000..b02e0d5 +index 0000000..8643e56 --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiAseprite.java -@@ -0,0 +1,54 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeHudResources.java +@@ -0,0 +1,21 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -5284,59 +6538,26 @@ index 0000000..b02e0d5 + */ +package com.viaversion.viafabricplus.bedrock.screen.ui; + -+import com.google.gson.JsonElement; -+import java.util.ArrayList; -+import java.util.List; -+ -+/** Aseprite tracks retain phase across resets and select frames by elapsed milliseconds. */ -+final class NativeUiAseprite { -+ private float elapsed; -+ -+ Frame advance(final Metadata metadata, final float delta) { -+ if (metadata == null) return null; -+ this.elapsed += delta; -+ if (metadata.duration == 0) return new Frame(0, 0, 0); -+ final float milliseconds = this.elapsed * 1000; -+ // CVTTSS2SI returns the minimum integer for a float outside the signed range. -+ final int time = milliseconds >= 2147483648F || milliseconds < -2147483648F ? Integer.MIN_VALUE : (int) milliseconds; -+ int position = time % metadata.duration; -+ for (final Frame frame : metadata.frames) { -+ position -= frame.duration; -+ if (position < 0) return frame; -+ } -+ return new Frame(0, 0, 0); -+ } ++import net.minecraft.server.packs.resources.ResourceManager; ++import net.minecraft.server.packs.resources.SimplePreparableReloadListener; ++import net.minecraft.util.profiling.ProfilerFiller; + -+ record Frame(int x, int y, int duration) {} ++/** Retires HUD font and image caches on the resource reload's client apply executor. */ ++public final class NativeHudResources extends SimplePreparableReloadListener { ++ @Override ++ protected Boolean prepare(final ResourceManager resources, final ProfilerFiller profiler) { return true; } + -+ record Metadata(float width, float height, List frames, int duration) { -+ static Metadata parse(final JsonElement value) { -+ if (value == null || value.isJsonNull()) return null; -+ final var object = value.getAsJsonObject(); -+ final var size = object.getAsJsonArray("size"); -+ if (size.size() != 2) throw new IllegalArgumentException("Invalid native atlas size"); -+ final float width = size.get(0).getAsFloat(), height = size.get(1).getAsFloat(); -+ if (!Float.isFinite(width) || !Float.isFinite(height)) throw new IllegalArgumentException("Non-finite native atlas size"); -+ final var frames = new ArrayList(); -+ int duration = 0; -+ for (final var item : object.getAsJsonArray("frames")) { -+ final var fields = item.getAsJsonArray(); -+ if (fields.size() != 3) throw new IllegalArgumentException("Invalid native atlas frame"); -+ final var frame = new Frame(fields.get(0).getAsInt(), fields.get(1).getAsInt(), fields.get(2).getAsInt()); -+ if (frame.x < 0 || frame.y < 0 || frame.duration <= 0) throw new IllegalArgumentException("Invalid native atlas frame"); -+ frames.add(frame); -+ duration += frame.duration; -+ } -+ return new Metadata(width, height, List.copyOf(frames), duration); -+ } ++ @Override ++ protected void apply(final Boolean prepared, final ResourceManager resources, final ProfilerFiller profiler) { ++ BedrockHudOverlays.resourcesReloaded(); + } +} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiButton.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiButton.java +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiActorCamera.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiActorCamera.java new file mode 100644 -index 0000000..bc593e4 +index 0000000..774496f --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiButton.java -@@ -0,0 +1,167 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiActorCamera.java +@@ -0,0 +1,60 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -5345,171 +6566,64 @@ index 0000000..bc593e4 +package com.viaversion.viafabricplus.bedrock.screen.ui; + +import com.google.gson.JsonObject; -+import java.util.ArrayDeque; -+import java.util.ArrayList; -+import java.util.List; -+import java.util.Objects; -+import java.util.function.BiConsumer; -+ -+/** Button appearance is independent of edit-box selection and mapping activation. */ -+public record NativeUiButton(boolean consumeEvent, List controls) { -+ public NativeUiButton { -+ controls = List.copyOf(controls); -+ if (controls.size() != 4) throw new IllegalArgumentException("Expected four native Button states"); -+ } ++import org.joml.Matrix4f; ++import org.joml.Vector3f; + -+ public static NativeUiButton parse(final JsonObject properties) { -+ final List controls = new ArrayList<>(); -+ for (final String name : List.of("default_control", "hover_control", "pressed_control", "locked_control")) { -+ final var value = properties.get(name); -+ controls.add(value != null && value.isJsonPrimitive() && value.getAsJsonPrimitive().isString() ? value.getAsString() : ""); -+ } -+ return new NativeUiButton(NativeUiScene.flag(properties, "consume_event", true), controls); ++/** Actor portrait transform, distinct from the native live player renderer. */ ++public record NativeUiActorCamera(float scale, Vector3f translation, Matrix4f modelTransform) { ++ public static NativeUiActorCamera resolve(final NativeUiLayout.Rect control, final NativeUiLayout.Rect viewport, ++ final float layer, final float width, final float height, final float depth, final int guiScale, ++ final JsonObject properties, final JsonObject offsets, final float automaticRotation) { ++ if (guiScale < 1 || !(width > 0) || !(height > 0) || !(depth > 0) ++ || !Float.isFinite(width + height + depth + layer + automaticRotation)) ++ throw new IllegalArgumentException("Invalid native portrait dimensions"); ++ // Pinned 1.26.51.1 actor_portrait_renderer: FUN_145543ce0, verified with live arguments. ++ final float fit = Math.min(1, 2F / Math.max(width, Math.max(height, depth))); ++ final float base = Math.min(control.width() / 20F, control.height() / 39F) * 10F; ++ final float scale = base * fit; ++ if (!(scale > 0) || !Float.isFinite(scale)) throw new IllegalArgumentException("Invalid native portrait control size"); ++ final Vector3f scaling = vector(properties, offsets, "scale", new Vector3f(1)); ++ final Vector3f rotation = vector(properties, offsets, "rotate", new Vector3f(0, 30, 0)); ++ final Vector3f offset = vector(properties, offsets, "translate", new Vector3f()); ++ final Vector3f translation = new Vector3f( ++ (control.x() + control.width() / 2F + offset.x - viewport.x() - viewport.width() / 2F) / scale, ++ (viewport.y() + viewport.height() / 2F - control.y() - control.height() * .8F + base / (10F * guiScale) - offset.y) / scale, ++ 500F - layer - offset.z); ++ // Java's converted geometry has a downward Y axis and a 24-pixel root. ++ // PIP supplies (scale, -scale, 1). Reverse native camera depth for Java's ++ // orthographic projection as well as converting the downward model Y axis. ++ final Matrix4f model = new Matrix4f().scaling(scaling.x, -scaling.y, scale * scaling.z) ++ .rotateZ((float) Math.toRadians(rotation.z + 180F)) ++ .rotateY((float) Math.toRadians(rotation.y - 180F + automaticRotation)) ++ .rotateX((float) Math.toRadians(rotation.x)) ++ .scale(1, -1, 1).translate(0, -1.5F, 0); ++ return new NativeUiActorCamera(scale, translation, model); + } + -+ public int handlerMask(final boolean enabled) { return enabled && !this.consumeEvent ? 1 : 0; } -+ -+ public static final class State { -+ private final NativeUiButton definition; -+ private final BiConsumer visibility; -+ private final Reference[] controls = new Reference[4]; -+ private NativeUiScene.Node owner; -+ private boolean pressed; -+ private boolean visualPressed; -+ private boolean visualHovered; -+ private boolean visualEnabled; -+ private long revision; -+ -+ public State(final NativeUiButton definition, final NativeUiScene.Node owner, final boolean enabled, final boolean hovered) { -+ this(definition, State::writeVisibility); -+ this.attach(owner, enabled, hovered); -+ } -+ -+ /** Native components exist before deferred references bind their named controls. */ -+ public State(final NativeUiButton definition, final BiConsumer visibility) { -+ this.definition = Objects.requireNonNull(definition); -+ this.visibility = Objects.requireNonNull(visibility); -+ } -+ -+ public boolean pressed() { return this.pressed; } -+ public long revision() { return this.revision; } -+ -+ /** Replacing a Java scene node does not produce another native input or property event. */ -+ public void attach(final NativeUiScene.Node owner, final boolean enabled, final boolean hovered) { -+ if (this.owner == owner) return; -+ final boolean initial = this.owner == null; -+ this.owner = owner; -+ if (this.resolve(owner) || initial) this.update(enabled, hovered); -+ else this.apply(State::writeVisibility); -+ } -+ -+ public void button(final NativeUiInput.ButtonEvent event, final boolean enabled, final boolean hovered) { -+ if (!enabled || event.phase() != 0 && event.phase() != 1) return; -+ final boolean pressed = event.phase() == 1; -+ if (this.pressed == pressed) return; -+ this.pressed = pressed; -+ if (pressed ? event.handleSelect() : event.handleDeselect()) this.update(enabled, hovered); -+ } -+ -+ public void hover(final boolean hovered, final boolean enabled) { -+ if (!enabled) return; -+ if (!hovered) this.pressed = false; -+ this.update(enabled, hovered); -+ } -+ -+ public void reset(final boolean enabled, final boolean hovered) { -+ if (!enabled) return; -+ this.pressed = false; -+ this.update(enabled, hovered); -+ } -+ -+ public void visibility(final boolean visible, final boolean enabled, final boolean hovered) { -+ if (visible) return; -+ this.pressed = false; -+ this.update(enabled, hovered); -+ } -+ -+ public void enabled(final boolean enabled) { -+ if (!enabled) this.pressed = false; -+ } -+ -+ public void general(final int kind, final boolean enabled, final boolean hovered) { -+ if (kind == 17) this.update(enabled, hovered); -+ } -+ -+ private void update(final boolean enabled, final boolean hovered) { -+ this.visualEnabled = enabled; -+ this.visualHovered = hovered; -+ this.visualPressed = this.pressed; -+ this.apply(); -+ } -+ -+ private void apply() { -+ this.apply(this.visibility); -+ } -+ -+ private void apply(final BiConsumer visibility) { -+ // Setter order matters when authored state names resolve to the same control. -+ if (this.visualEnabled) { -+ this.visible(3, false, visibility); -+ this.visible(2, this.visualPressed, visibility); -+ this.visible(1, !this.visualPressed && this.visualHovered, visibility); -+ this.visible(0, !this.visualPressed && !this.visualHovered, visibility); -+ } else { -+ this.visible(3, true, visibility); -+ this.visible(0, this.controls[3] == null, visibility); -+ this.visible(2, false, visibility); -+ this.visible(1, false, visibility); -+ } -+ } -+ -+ private static void writeVisibility(final NativeUiScene.Node node, final boolean visible) { -+ node.properties().addProperty("visible", visible); -+ } -+ -+ private void visible(final int index, final boolean visible, final BiConsumer visibility) { -+ final var control = this.controls[index]; -+ if (control != null && control.node.flag("visible", true) != visible) { -+ visibility.accept(control.node, visible); -+ this.revision++; -+ } ++ private static Vector3f vector(final JsonObject properties, final JsonObject offsets, final String name, ++ final Vector3f fallback) { ++ final Vector3f result = new Vector3f(fallback); ++ if (offsets != null && offsets.has(name)) { ++ final var values = offsets.getAsJsonArray(name); ++ if (values.size() != 3) throw new IllegalArgumentException("Invalid native portrait transform"); ++ result.set(values.get(0).getAsFloat(), values.get(1).getAsFloat(), values.get(2).getAsFloat()); + } -+ -+ private boolean resolve(final NativeUiScene.Node owner) { -+ final Reference[] resolved = new Reference[4]; -+ final ArrayDeque pending = new ArrayDeque<>(); -+ pending.add(new Reference(owner, List.of())); -+ while (!pending.isEmpty()) { -+ final var candidate = pending.removeFirst(); -+ for (int state = 0; state < resolved.length; state++) { -+ final String name = this.definition.controls.get(state); -+ if (resolved[state] == null && !name.isEmpty() && name.equals(candidate.node.name())) resolved[state] = candidate; -+ } -+ for (int index = 0; index < candidate.node.children().size(); index++) { -+ final List path = new ArrayList<>(candidate.path); -+ path.add(index); -+ pending.addLast(new Reference(candidate.node.children().get(index), List.copyOf(path))); -+ } -+ } -+ boolean changed = false; -+ for (int state = 0; state < resolved.length; state++) { -+ final var old = this.controls[state]; -+ final var next = resolved[state]; -+ changed |= !Objects.equals(old == null ? null : old.path, next == null ? null : next.path); -+ this.controls[state] = next; -+ } -+ return changed; ++ final String[] axes = {"x", "y", "z"}; ++ for (int axis = 0; axis < axes.length; axis++) { ++ final String key = name + "_" + axes[axis]; ++ if (properties.has(key)) result.setComponent(axis, properties.get(key).getAsFloat()); + } -+ -+ private record Reference(NativeUiScene.Node node, List path) {} ++ if (!Float.isFinite(result.x + result.y + result.z)) ++ throw new IllegalArgumentException("Invalid native portrait transform"); ++ return result; + } +} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiCollectionWindow.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiCollectionWindow.java +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiActorPortraits.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiActorPortraits.java new file mode 100644 -index 0000000..67d2f90 +index 0000000..286546d --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiCollectionWindow.java -@@ -0,0 +1,92 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiActorPortraits.java +@@ -0,0 +1,258 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -5518,236 +6632,262 @@ index 0000000..67d2f90 +package com.viaversion.viafabricplus.bedrock.screen.ui; + +import com.google.gson.JsonElement; ++import com.google.gson.JsonObject; ++import com.viaversion.viafabricplus.bedrock.appearance.BedrockGuiActorAppearance; ++import com.viaversion.viafabricplus.bedrock.injection.access.IBedrockGuiActorRenderer; ++import com.viaversion.viafabricplus.bedrock.render.BedrockActorState; ++import com.viaversion.viafabricplus.bedrock.render.BedrockGuiActorContext; +import java.util.HashMap; +import java.util.Map; -+import net.raphimc.viabedrock.api.resourcepack.ui.JsonUiProgram; -+import net.raphimc.viabedrock.api.resourcepack.ui.UiBindings; ++import net.minecraft.client.Minecraft; ++import net.minecraft.client.gui.GuiGraphicsExtractor; ++import net.minecraft.client.renderer.entity.state.AvatarRenderState; ++import net.minecraft.world.entity.Avatar; ++import net.raphimc.viabedrock.api.model.NativeActorMessage; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.ActorFlags; ++import org.joml.Quaternionf; ++import org.joml.Quaternionfc; ++import org.joml.Vector3f; + -+/** A bounded set of cell views retains the complete source's indices and scroll extent. */ -+public record NativeUiCollectionWindow(String key, int size, UiBindings.Window range, float cellWidth, float cellHeight) { -+ public static NativeUiCollectionWindow from(final NativeUiScene.Node node) { -+ if (!node.properties().has("collection_window_key")) return null; -+ final String key = node.string("collection_window_key", ""); -+ final int size = integer(node.properties().get("collection_size")); -+ final int start = integer(node.properties().get("collection_start")); -+ final int count = integer(node.properties().get("collection_count")); -+ final int retainedStart = node.properties().has("collection_retained_start") ? integer(node.properties().get("collection_retained_start")) : 0; -+ final int retainedCount = node.properties().has("collection_retained_count") ? integer(node.properties().get("collection_retained_count")) : 0; -+ final float width = NativeUiLayout.vector(node, "collection_cell_size", 0, 0); -+ final float height = NativeUiLayout.vector(node, "collection_cell_size", 1, 0); -+ if (!node.type().equals("grid") || !node.string("grid_rescaling_type", "").equals("horizontal") -+ || node.string("grid_fill_direction", "horizontal").equals("vertical") || node.properties().has("grid_dimensions") -+ || key.isEmpty() || key.length() > 16384 || size < 1 || size > JsonUiProgram.MAX_NODES -+ || start < 0 || start >= size || count < 1 || count > size - start || width <= 0 || height <= 0 -+ || retainedStart < 0 || retainedStart >= size || retainedCount < 0 || retainedCount > size - retainedStart) -+ throw new IllegalArgumentException("Invalid native collection window"); -+ final var range = new UiBindings.Window(start, count, retainedStart, retainedCount); -+ final var indices = range.indices(size); -+ if (indices.size() != node.children().size()) throw new IllegalArgumentException("Native collection window lost its cell views"); -+ for (int index = 0; index < node.children().size(); index++) -+ if (integer(node.children().get(index).properties().get("collection_index")) != indices.get(index)) -+ throw new IllegalArgumentException("Native collection window lost its source index"); -+ return new NativeUiCollectionWindow(key, size, range, width, height); ++/** Native UID selection and independent cameras for player and actor portraits. */ ++public final class NativeUiActorPortraits { ++ public static final String RENDERER = "live_horse_renderer"; ++ public static final String ACTOR_RENDERER = "actor_portrait_renderer"; ++ public static final String PLAYER_RENDERER = "live_player_renderer"; ++ private final Map contexts = new HashMap<>(); ++ private final Map paths = new java.util.IdentityHashMap<>(); ++ private Object level; ++ ++ public void clear() { ++ this.contexts.values().forEach(BedrockGuiActorContext::close); ++ this.contexts.clear(); ++ this.paths.clear(); ++ this.level = null; + } + -+ public static Map visible(final NativeUiLayout.Box root) { -+ return visible(root, null); ++ public void prepare(final NativeUiScene.Node root) { ++ this.paths.clear(); ++ this.path(root, ""); ++ final var retained = new java.util.HashSet<>(this.paths.values()); ++ this.contexts.entrySet().removeIf(entry -> { ++ if (retained.contains(entry.getKey())) return false; ++ entry.getValue().close(); ++ return true; ++ }); + } + -+ public static Map visible(final NativeUiLayout.Box root, final NativeUiScene.Node focused) { -+ final Map result = new HashMap<>(); -+ collect(root, focused, result); -+ return Map.copyOf(result); ++ private void path(final NativeUiScene.Node node, final String parent) { ++ final String path = parent + "/" + node.name(); ++ this.paths.put(node, path); ++ final Map occurrences = new HashMap<>(); ++ for (final var child : node.children()) { ++ final int occurrence = occurrences.merge(child.name(), 1, Integer::sum); ++ this.path(child, path + "/" + occurrence); ++ } + } + -+ private static void collect(final NativeUiLayout.Box box, final NativeUiScene.Node focused, final Map result) { -+ final var window = from(box.node()); -+ if (window != null) { -+ final var clip = box.visibleBounds(); -+ final int columns = Math.clamp((int) Math.floor(box.bounds().width() / window.cellWidth), 1, 128); -+ final int rows = Math.ceilDiv(window.size, columns); -+ final int firstRow = (int) Math.clamp(Math.floor(((double) clip.y() - box.bounds().y()) / window.cellHeight) - 1, 0, rows - 1); -+ final int endRow = (int) Math.clamp(Math.ceil(((double) clip.y() + clip.height() - box.bounds().y()) / window.cellHeight) + 1, firstRow + 1, rows); -+ final int start = clip.width() <= 0 || clip.height() <= 0 ? 0 : Math.min(window.size - 1, firstRow * columns); -+ final int end = clip.width() <= 0 || clip.height() <= 0 ? 1 : Math.min(window.size, endRow * columns); -+ int retainedStart = 0, retainedCount = 0; -+ if (focused != null && focused.properties().has("collection_index") && contains(box.node(), focused)) { -+ final int selected = integer(focused.properties().get("collection_index")); -+ if (selected >= 0 && selected < window.size) { -+ final int focusRow = selected / columns; -+ final int first = Math.max(0, focusRow - 1) * columns; -+ final int last = Math.min(window.size, (focusRow + 2) * columns); -+ if (first < start || last > end) { -+ retainedStart = first; -+ retainedCount = last - first; -+ } -+ } -+ } -+ if (result.put(window.key, new UiBindings.Window(start, Math.max(1, end - start), retainedStart, retainedCount)) != null) -+ throw new IllegalArgumentException("Duplicate native collection window identity"); ++ public static long uniqueId(final NativeUiScene.Node node) { ++ if (node.string("renderer", "").equals(ACTOR_RENDERER)) { ++ final var portrait = node.properties().getAsJsonObject("actor_portrait"); ++ if (portrait == null || !portrait.has("unique_id")) ++ throw new IllegalArgumentException("Native actor portrait has no identity"); ++ return portrait.get("unique_id").getAsBigDecimal().longValueExact(); + } -+ for (final var child : box.children()) collect(child, focused, result); ++ final var bag = node.properties().getAsJsonObject("property_bag"); ++ final JsonElement value = bag == null ? null : bag.get("#entity_id"); ++ if (value == null) return -1; ++ if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) ++ throw new IllegalArgumentException("Native portrait actor ID must be a string"); ++ final String text = value.getAsString(); ++ int start = 0; ++ while (start < text.length() && " \t\n\r\f\u000b".indexOf(text.charAt(start)) >= 0) start++; ++ int end = start; ++ if (end < text.length() && (text.charAt(end) == '+' || text.charAt(end) == '-')) end++; ++ final int digits = end; ++ while (end < text.length() && text.charAt(end) >= '0' && text.charAt(end) <= '9') end++; ++ if (end == digits) throw new IllegalArgumentException("Invalid native portrait actor ID"); ++ return Long.parseLong(text.substring(start, end)); + } + -+ private static boolean contains(final NativeUiScene.Node node, final NativeUiScene.Node selected) { -+ if (node == selected) return true; -+ for (final var child : node.children()) if (contains(child, selected)) return true; -+ return false; ++ public static Camera camera(final NativeUiLayout.Rect rect, final int mode, final float mouseX, final float mouseY) { ++ final float dx = mode == 1 ? (mouseX - rect.x() - rect.width() / 2F) / 40F : 3F; ++ final float dy = mode == 1 ? (mouseY - rect.y() - rect.height() / 2F) / 40F : 1F; ++ return new Camera((float) Math.atan(dy) * 20F, (float) -Math.atan(dx) * 20F, rect.width()); + } + -+ private static int integer(final JsonElement value) { -+ if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber()) -+ throw new IllegalArgumentException("Invalid native collection window integer"); -+ try { return value.getAsBigDecimal().intValueExact(); } -+ catch (final ArithmeticException invalid) { throw new IllegalArgumentException("Invalid native collection window integer", invalid); } ++ /** Cancels Java's model origin for the native GUI player renderer. */ ++ public static Vector3f playerTranslation(final Quaternionfc rotation, final float playerScale) { ++ if (playerScale < 0 || !Float.isFinite(playerScale)) ++ throw new IllegalArgumentException("Invalid native portrait player scale"); ++ // Native GUI actor draws skip both the eye offset and world model-root translation. ++ // Cancel Java's Living/Avatar origin shift; converted bones already use the GUI origin. ++ return rotation.transform(new Vector3f(0, -1.501F * .9375F * playerScale, 0)).add(0, 0, -5); + } -+} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiColor.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiColor.java -new file mode 100644 -index 0000000..83c9295 ---- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiColor.java -@@ -0,0 +1,55 @@ -+/* -+ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock -+ * Copyright (C) 2026 ViaVersion and contributors -+ * SPDX-License-Identifier: GPL-3.0-or-later -+ */ -+package com.viaversion.viafabricplus.bedrock.screen.ui; -+ -+import com.google.gson.JsonElement; -+ -+/** Image-component RGBA, kept unclamped during native animation interpolation. */ -+record NativeUiColor(float red, float green, float blue, float alpha) { -+ static final NativeUiColor WHITE = new NativeUiColor(1, 1, 1, 1); + -+ static NativeUiColor parse(final JsonElement value) { -+ if (value == null || value.isJsonNull()) return WHITE; -+ if (value.isJsonPrimitive() && value.getAsJsonPrimitive().isString()) { -+ return switch (value.getAsString()) { -+ case "red" -> new NativeUiColor(1, 0, 0, 1); -+ case "green" -> new NativeUiColor(0, 1, 0, 1); -+ case "blue" -> new NativeUiColor(0, 0, 1, 1); -+ case "purple" -> new NativeUiColor(1, 0, 1, 1); -+ case "cyan" -> new NativeUiColor(0, 1, 1, 1); -+ case "nil" -> new NativeUiColor(0, 0, 0, 0); -+ case "black" -> new NativeUiColor(0, 0, 0, 1); -+ case "yellow" -> new NativeUiColor(1, 1, 0, 1); -+ case "orange" -> new NativeUiColor(.85F, .5F, .2F, 1); -+ case "grey", "gray" -> new NativeUiColor(.5F, .5F, .5F, 1); -+ default -> WHITE; -+ }; -+ } -+ if (!value.isJsonArray()) return WHITE; -+ final var channels = value.getAsJsonArray(); -+ return new NativeUiColor(channel(channels, 0), channel(channels, 1), channel(channels, 2), channel(channels, 3)); ++ /** The native renderer uses the inherited scissor, not the custom control as a viewport. */ ++ public static NativeUiLayout.Rect viewport(final NativeUiLayout.Rect clip, final int width, final int height) { ++ final var visible = clip.intersect(new NativeUiLayout.Rect(0, 0, width, height)); ++ final int left = Math.round(visible.x()), top = Math.round(visible.y()); ++ return new NativeUiLayout.Rect(left, top, Math.round(visible.x() + visible.width()) - left, ++ Math.round(visible.y() + visible.height()) - top); + } + -+ private static float channel(final com.google.gson.JsonArray values, final int index) { -+ if (index >= values.size() || values.get(index).isJsonNull()) return 1; -+ final float channel = values.get(index).getAsFloat(); -+ if (!Float.isFinite(channel)) throw new IllegalArgumentException("Non-finite native UI color"); -+ return channel; ++ /** Keeps the native control center when Java's intermediate texture uses a larger viewport. */ ++ public static Vector3f viewportTranslation(final NativeUiLayout.Rect control, final NativeUiLayout.Rect viewport, ++ final Quaternionfc rotation, final float playerScale) { ++ if (!(control.width() > 0) || !Float.isFinite(control.width())) ++ throw new IllegalArgumentException("Invalid native portrait control width"); ++ return playerTranslation(rotation, playerScale).add( ++ (control.x() + control.width() / 2F - viewport.x() - viewport.width() / 2F) / control.width(), ++ (viewport.y() + viewport.height() / 2F - control.y() - control.height() / 2F) / control.width(), 0); + } + -+ NativeUiColor interpolate(final NativeUiColor to, final float factor) { -+ return new NativeUiColor(this.red + (to.red - this.red) * factor, -+ this.green + (to.green - this.green) * factor, this.blue + (to.blue - this.blue) * factor, -+ this.alpha + (to.alpha - this.alpha) * factor); ++ public void draw(final GuiGraphicsExtractor graphics, final NativeUiLayout.Box box, ++ final float mouseX, final float mouseY, final float partialTick) { ++ this.draw(graphics, box, mouseX, mouseY, partialTick, 1); + } + -+ int argb(final float opacity) { -+ return byteChannel(this.alpha * opacity) << 24 | byteChannel(this.red) << 16 -+ | byteChannel(this.green) << 8 | byteChannel(this.blue); ++ public void draw(final GuiGraphicsExtractor graphics, final NativeUiLayout.Box box, ++ final float mouseX, final float mouseY, final float partialTick, final float opacity) { ++ final Minecraft client = Minecraft.getInstance(); ++ if (client.level != this.level) { ++ this.contexts.values().forEach(BedrockGuiActorContext::close); ++ this.contexts.clear(); ++ this.level = client.level; ++ } ++ final String key = this.paths.get(box.node()); ++ if (key == null) throw new IllegalStateException("Native portrait scene was not prepared"); ++ final boolean localPlayer = box.node().string("renderer", "").equals(PLAYER_RENDERER); ++ if (!localPlayer && !BedrockActorState.supportsUniqueIds()) ++ throw new IllegalArgumentException("Native portrait identity capability is unavailable"); ++ final var uuid = localPlayer ++ ? client.player == null ? null : client.player.getUUID() ++ : BedrockActorState.uuidByUniqueId(uniqueId(box.node())); ++ if (uuid == null || client.level == null) { ++ this.retire(key); ++ return; ++ } ++ final var entity = localPlayer ? client.player : client.level.getEntity(uuid); ++ if (entity == null || entity.isRemoved()) { ++ this.retire(key); ++ return; ++ } ++ final var snapshot = BedrockActorState.state(uuid); ++ if (snapshot == null || localPlayer && !snapshot.localPlayer() || !localPlayer && snapshot.uniqueId() == null) ++ throw new IllegalArgumentException("Native portrait identity is unavailable"); ++ var context = this.contexts.get(key); ++ if (context == null || !context.owns(uuid, snapshot.lifetime())) { ++ if (context != null) context.close(); ++ context = new BedrockGuiActorContext(uuid, snapshot.lifetime()); ++ this.contexts.put(key, context); ++ } ++ context.opacity(box.node().string("renderer", "").equals(ACTOR_RENDERER) ? 1 : opacity); ++ if (box.node().string("renderer", "").equals(ACTOR_RENDERER)) { ++ this.actor(graphics, box, entity, snapshot, context, opacity); ++ return; ++ } ++ if (!(entity instanceof Avatar player)) throw new IllegalArgumentException("Native portrait actor class is not verified"); ++ if (localPlayer) { ++ this.player(graphics, box, player, snapshot, context, mouseX, mouseY, partialTick); ++ return; ++ } ++ final var camera = camera(box.bounds(), 1, mouseX, mouseY); ++ if (camera.scale() <= 0) return; ++ context.camera(camera.pitch(), camera.yaw()); ++ final var renderState = context.scoped(() -> client.getEntityRenderDispatcher().extractEntity(player, partialTick)); ++ context.requireSupportedEffects(); ++ if (!(renderState instanceof AvatarRenderState state)) throw new IllegalArgumentException("Invalid native portrait player state"); ++ final var rotation = new Quaternionf().rotateX((float) Math.toRadians(camera.pitch())); ++ final var viewport = viewport(box.clip(), graphics.guiWidth(), graphics.guiHeight()); ++ if (viewport.width() <= 0 || viewport.height() <= 0) return; ++ graphics.entity(state, camera.scale(), viewportTranslation(box.bounds(), viewport, rotation, state.scale), ++ rotation, null, (int) viewport.x(), (int) viewport.y(), ++ (int) (viewport.x() + viewport.width()), (int) (viewport.y() + viewport.height())); + } + -+ private static int byteChannel(final float value) { return Math.round(Math.clamp(value, 0, 1) * 255); } -+} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiController.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiController.java -new file mode 100644 -index 0000000..1a59843 ---- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiController.java -@@ -0,0 +1,37 @@ -+/* -+ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock -+ * Copyright (C) 2026 ViaVersion and contributors -+ * SPDX-License-Identifier: GPL-3.0-or-later -+ */ -+package com.viaversion.viafabricplus.bedrock.screen.ui; -+ -+import java.util.List; ++ private void player(final GuiGraphicsExtractor graphics, final NativeUiLayout.Box box, final Avatar player, ++ final NativeActorMessage.Snapshot snapshot, ++ final BedrockGuiActorContext context, final float mouseX, final float mouseY, final float partialTick) { ++ if (box.bounds().width() <= 0 || box.bounds().height() <= 0) return; ++ final var viewport = viewport(box.clip(), graphics.guiWidth(), graphics.guiHeight()); ++ if (viewport.width() <= 0 || viewport.height() <= 0) return; ++ final var flags = snapshot.flags(); ++ final var camera = NativeUiPlayerCamera.resolve(box.bounds(), viewport, box.layer(), box.node().properties(), ++ new NativeUiPlayerCamera.Cursor(mouseX, mouseY), ++ new NativeUiPlayerCamera.Rotation(player.getXRot(), player.getYRot(), player.yBodyRot, player.yBodyRotO, ++ player.yHeadRot, player.yHeadRotO), ++ flags.contains(ActorFlags.GLIDING), ++ flags.contains(ActorFlags.SWIMMING), ++ flags.contains(ActorFlags.CRAWLING)); ++ context.pose(camera.pitch(), camera.yaw(), camera.bodyYaw(), camera.headYaw(), camera.modelTransform()); ++ final var renderState = context.scoped(() -> Minecraft.getInstance().getEntityRenderDispatcher().extractEntity(player, partialTick)); ++ context.requireSupportedEffects(); ++ if (!(renderState instanceof AvatarRenderState state)) ++ throw new IllegalArgumentException("Invalid native player preview state"); ++ graphics.entity(state, camera.scale(), camera.translation(state.scale), new Quaternionf(), null, ++ (int) viewport.x(), (int) viewport.y(), (int) (viewport.x() + viewport.width()), ++ (int) (viewport.y() + viewport.height())); ++ } + -+/** Domain state and responses for an authored screen; geometry and input belong to the shared screen. */ -+public interface NativeUiController { -+ String title(); -+ NativeUiScene.Node root(); -+ void validateVisibleControls(); -+ void validateResponseControls(List controls); -+ boolean current(); -+ Activation activate(NativeUiScene.Node node); -+ void cancel(); -+ void failed(RuntimeException error); -+ -+ default Activation dismiss() { this.cancel(); return Activation.CLOSE; } -+ default Inputs inputs() { return null; } -+ default void refresh() {} -+ -+ enum Activation { STAY, CLOSE, REPLACED } -+ -+ /** Editable values addressed by the controller's original input indices. */ -+ interface Inputs { -+ int maxLength(int index); -+ String text(int index); -+ String placeholder(int index); -+ boolean setText(int index, String value); -+ boolean isSlider(int index); -+ void slide(int index, double fraction); -+ void adjust(int index, int direction); ++ private void actor(final GuiGraphicsExtractor graphics, final NativeUiLayout.Box box, ++ final net.minecraft.world.entity.Entity entity, ++ final NativeActorMessage.Snapshot snapshot, ++ final BedrockGuiActorContext context, final float opacity) { ++ final var viewport = viewport(box.clip(), graphics.guiWidth(), graphics.guiHeight()); ++ if (viewport.width() <= 0 || viewport.height() <= 0) return; ++ final var portrait = box.node().properties().getAsJsonObject("actor_portrait"); ++ final var data = portrait.getAsJsonObject("actor_data"); ++ final var properties = box.node().properties().deepCopy(); ++ final var bag = properties.getAsJsonObject("property_bag"); ++ if (bag != null) bag.entrySet().forEach(entry -> properties.add(entry.getKey(), entry.getValue())); ++ final String offsetType = portrait.has("offsets") ? portrait.get("offsets").getAsString() : "portrait_offsets"; ++ if (!offsetType.equals("portrait_offsets") && !offsetType.equals("picker_offsets")) ++ throw new IllegalArgumentException("Invalid native portrait offset type"); ++ final JsonObject offsets = data.getAsJsonObject(offsetType); ++ final var nativeBounds = BedrockActorState.bounds(entity.getUUID()); ++ if (nativeBounds == null) throw new IllegalArgumentException("Native portrait bounding box is unavailable"); ++ final var client = Minecraft.getInstance(); ++ final float automaticRotation = properties.has("rotate_mode") && properties.get("rotate_mode").getAsString().equals("auto") ++ ? context.seconds() * 30F : 0; ++ final var camera = NativeUiActorCamera.resolve(box.bounds(), viewport, box.layer(), ++ net.raphimc.viabedrock.api.model.NativeActorBoundsMessage.scaledDimension(nativeBounds.width(), snapshot.scale()), ++ net.raphimc.viabedrock.api.model.NativeActorBoundsMessage.scaledDimension(nativeBounds.height(), snapshot.scale()), ++ net.raphimc.viabedrock.api.model.NativeActorBoundsMessage.scaledDimension(nativeBounds.width(), snapshot.scale()), ++ NativeUiViewport.current().scale(), properties, offsets, automaticRotation); ++ context.camera(0, 0); ++ try { ++ final var resources = BedrockGuiActorAppearance.resources(); ++ final var appearance = context.resource(resources, snapshot.identifier(), () -> { ++ try { return BedrockGuiActorAppearance.load(resources, snapshot.identifier()); } ++ catch (final java.io.IOException error) { throw new java.io.UncheckedIOException(error); } ++ }); ++ final int variant = portrait.get("variant").getAsBigDecimal().intValueExact(); ++ final var state = context.scoped(() -> { ++ try { ++ final var frame = appearance.sample(context.seconds(), snapshot, variant, 0, 0); ++ return ((IBedrockGuiActorRenderer) client.getEntityRenderDispatcher()).stackanvil$guiActorRenderer() ++ .extract(appearance, frame, camera.modelTransform(), opacity); ++ } catch (final java.io.IOException error) { throw new java.io.UncheckedIOException(error); } ++ }); ++ graphics.entity(state, camera.scale(), camera.translation(), new Quaternionf(), null, ++ (int) viewport.x(), (int) viewport.y(), (int) (viewport.x() + viewport.width()), ++ (int) (viewport.y() + viewport.height())); ++ } catch (final java.io.IOException error) { throw new java.io.UncheckedIOException(error); } + } -+} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiCursorPlacement.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiCursorPlacement.java -new file mode 100644 -index 0000000..165c9f3 ---- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiCursorPlacement.java -@@ -0,0 +1,30 @@ -+/* -+ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock -+ * Copyright (C) 2026 ViaVersion and contributors -+ * SPDX-License-Identifier: GPL-3.0-or-later -+ */ -+package com.viaversion.viafabricplus.bedrock.screen.ui; + -+/** A resolved native type-3 anchor, including a renderer's inside-parent override. */ -+public record NativeUiCursorPlacement(float x, float y, boolean insideParent) { -+ public NativeUiCursorPlacement { -+ if (!Float.isFinite(x) || !Float.isFinite(y)) throw new IllegalArgumentException("Non-finite native UI anchor"); ++ private void retire(final String key) { ++ final var context = this.contexts.remove(key); ++ if (context != null) context.close(); + } + -+ NativeUiLayout.Size offset(final NativeUiScene.Node node, final NativeUiLayout.Rect bounds, -+ final NativeUiLayout.Rect parent, final float offsetX, final float offsetY) { -+ // Bedrock 1.26.60.5 RenderComponent type 3: these are local offsets, -+ // not global positions. Centering takes precedence over parent fitting. -+ if (node.flag("follows_cursor", false)) { -+ return new NativeUiLayout.Size((this.x - bounds.width() * .5F) - (bounds.x() - offsetX), -+ (this.y - bounds.height() * .5F) - (bounds.y() - offsetY)); -+ } -+ if (!this.insideParent && !node.flag("follows_cursor_inside_parent", false)) return null; -+ if (bounds.width() == 0 || bounds.height() == 0) return new NativeUiLayout.Size(30000, 30000); -+ final float right = parent.width() < 0 ? parent.x() : parent.x() + parent.width(); -+ final float bottom = parent.height() < 0 ? parent.y() : parent.y() + parent.height(); -+ final float x = this.x + bounds.width() + 10 <= right ? this.x + 10 : (this.x - bounds.width()) - 10; -+ final float y = this.y + bounds.height() + 10 <= bottom ? this.y + 10 : (this.y - bounds.height()) - 10; -+ return new NativeUiLayout.Size((offsetX - bounds.x()) + x, (offsetY - bounds.y()) + y); -+ } ++ public record Camera(float pitch, float yaw, float scale) {} +} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiDimension.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiDimension.java +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiAnimationLifecycle.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiAnimationLifecycle.java new file mode 100644 -index 0000000..8affc07 +index 0000000..c0b255c --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiDimension.java -@@ -0,0 +1,93 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiAnimationLifecycle.java +@@ -0,0 +1,162 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -5755,151 +6895,167 @@ index 0000000..8affc07 + */ +package com.viaversion.viafabricplus.bedrock.screen.ui; + -+import com.google.gson.JsonElement; +import java.util.ArrayList; -+import java.util.EnumMap; ++import java.util.Collections; ++import java.util.HashMap; ++import java.util.IdentityHashMap; +import java.util.List; -+import java.util.regex.Pattern; -+ -+/** Authored dimension coefficients, resolved against the current layout after animation sampling. */ -+record NativeUiDimension(List terms) { -+ private static final Pattern EXPRESSION = Pattern.compile("([+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+))(%sm|%cm|%c|%x|%y|%|px)?"); ++import java.util.Map; ++import java.util.Objects; ++import java.util.Set; + -+ NativeUiDimension { terms = List.copyOf(terms); } ++/** Applies authored animation destruction to the resolved control tree. */ ++public final class NativeUiAnimationLifecycle { ++ private NativeUiScene.Node root; ++ private final NativeUiAnimations animations; ++ private boolean exiting; ++ private boolean exitComplete; ++ private Map> pendingExit = new IdentityHashMap<>(); ++ private final Map> paths = new IdentityHashMap<>(); ++ private final Set destroyed = Collections.newSetFromMap(new IdentityHashMap<>()); ++ private final Set retiredFactories = Collections.newSetFromMap(new IdentityHashMap<>()); + -+ static NativeUiDimension parse(final JsonElement value) { -+ if (!value.isJsonPrimitive()) throw new IllegalArgumentException("Unsupported native UI dimension"); -+ if (value.getAsJsonPrimitive().isNumber()) return new NativeUiDimension(List.of(new Term(Unit.PIXELS, finite(value.getAsFloat())))); -+ final String expression = value.getAsString().replace(" ", ""); -+ if (expression.equals("default")) return new NativeUiDimension(List.of(new Term(Unit.DEFAULT, 1))); -+ if (expression.equals("fill")) return new NativeUiDimension(List.of(new Term(Unit.FILL, 1))); -+ final var matcher = EXPRESSION.matcher(expression); -+ final var terms = new ArrayList(); -+ int end = 0; -+ while (matcher.find()) { -+ if (matcher.start() != end || end != 0 && expression.charAt(end) != '+' && expression.charAt(end) != '-') -+ throw new IllegalArgumentException("Unsupported native UI dimension expression"); -+ final String unit = matcher.group(2); -+ terms.add(new Term(switch (unit == null ? "px" : unit) { -+ case "%" -> Unit.PARENT; -+ case "%c" -> Unit.CHILDREN; -+ case "%cm" -> Unit.MAX_CHILD; -+ case "%x" -> Unit.WIDTH; -+ case "%y" -> Unit.HEIGHT; -+ case "%sm" -> Unit.SIBLING; -+ default -> Unit.PIXELS; -+ }, finite(Float.parseFloat(matcher.group(1))))); -+ end = matcher.end(); -+ } -+ if (end == 0 || end != expression.length()) throw new IllegalArgumentException("Unsupported native UI dimension expression"); -+ return new NativeUiDimension(List.copyOf(terms)); ++ public NativeUiAnimationLifecycle(final NativeUiScene.Node root) { ++ this.root = root; ++ collect(root, List.of()); ++ this.animations = new NativeUiAnimations((node, name) -> { ++ final var control = target(this.paths.get(node), name); ++ if (control == null) return false; ++ control.properties().addProperty("ignored", true); ++ if (this.destroyed.add(control)) this.retireFactories(control); ++ return true; ++ }, completion -> { ++ if (!this.exiting || !completion.event().equals("screen.exit_end")) return; ++ final var tracks = this.pendingExit.get(completion.node()); ++ if (tracks == null) return; ++ tracks.remove(completion.track()); ++ if (tracks.isEmpty()) this.pendingExit.remove(completion.node()); ++ this.exitComplete = this.pendingExit.isEmpty(); ++ }); + } + -+ boolean uses(final Unit unit) { return this.terms.stream().anyMatch(term -> term.unit == unit); } -+ -+ float resolve(final float parent, final float content, final float maximum, final float width, -+ final float height, final float sibling, final float fallback) { -+ float result = 0; -+ for (final var term : this.terms) result += term.amount * switch (term.unit) { -+ case PIXELS -> 1; -+ case PARENT -> parent / 100; -+ case CHILDREN -> content / 100; -+ case MAX_CHILD -> maximum / 100; -+ case WIDTH -> width / 100; -+ case HEIGHT -> height / 100; -+ case SIBLING -> sibling / 100; -+ case DEFAULT -> Math.max(0, fallback); -+ case FILL -> Math.max(0, parent); -+ }; -+ return finite(result); ++ public void advance(final long elapsedNanos) { ++ this.animations.advance(this.root, elapsedNanos); + } + -+ NativeUiDimension interpolate(final NativeUiDimension to, final float factor) { -+ final var fromTerms = coefficients(this); -+ final var toTerms = coefficients(to); -+ final var result = new ArrayList(); -+ // Native constructors merge the endpoint bases, inserting zero coefficients for missing units. -+ for (final var unit : Unit.values()) if (fromTerms.containsKey(unit) || toTerms.containsKey(unit)) { -+ final float from = fromTerms.getOrDefault(unit, 0F); -+ final float end = toTerms.getOrDefault(unit, 0F); -+ result.add(new Term(unit, finite(from + (end - from) * factor))); -+ } -+ return new NativeUiDimension(List.copyOf(result)); ++ public NativeUiAnimations animations() { return this.animations; } ++ ++ /** Hosts retire only these completed creations, after animation traversal has finished. */ ++ public List drainRetiredFactories() { ++ final var retired = List.copyOf(this.retiredFactories); ++ this.retiredFactories.clear(); ++ return retired; + } + -+ private static EnumMap coefficients(final NativeUiDimension dimension) { -+ final var result = new EnumMap(Unit.class); -+ for (final var term : dimension.terms) result.merge(term.unit, term.amount, Float::sum); -+ return result; ++ private void retireFactories(final NativeUiScene.Node node) { ++ if (node.properties().has("factory_instance_id")) this.retiredFactories.add(node); ++ for (final var child : node.children()) this.retireFactories(child); + } + -+ private static float finite(final float value) { -+ if (!Float.isFinite(value)) throw new IllegalArgumentException("Non-finite native UI dimension"); -+ return value; ++ /** Returns whether an authored pop exit must finish before its host removes the scene. */ ++ public boolean requestExit(final long elapsedNanos) { ++ if (this.exiting) return !this.exitComplete; ++ this.advance(elapsedNanos); ++ this.exiting = true; ++ this.pendingExit = this.animations.completions(this.root, "screen.exit_pop", "screen.exit_end"); ++ this.exitComplete = this.pendingExit.isEmpty(); ++ this.animations.event(this.root, "screen.exit_pop", elapsedNanos); ++ return !this.exitComplete; + } + -+ enum Unit { PIXELS, PARENT, CHILDREN, MAX_CHILD, WIDTH, HEIGHT, SIBLING, DEFAULT, FILL } -+ record Term(Unit unit, float amount) {} -+} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiFlipbook.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiFlipbook.java -new file mode 100644 -index 0000000..399e5e8 ---- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiFlipbook.java -@@ -0,0 +1,47 @@ -+/* -+ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock -+ * Copyright (C) 2026 ViaVersion and contributors -+ * SPDX-License-Identifier: GPL-3.0-or-later -+ */ -+package com.viaversion.viafabricplus.bedrock.screen.ui; ++ public boolean exiting() { return this.exiting; } ++ public boolean exitComplete() { return this.exitComplete; } + -+/** Native image flipbooks consume at most one frame interval per update. */ -+final class NativeUiFlipbook { -+ private final float interval; -+ private final int count; -+ private final boolean looping, reversible, vertical; -+ private float elapsed, frame; -+ private boolean reversed; -+ -+ NativeUiFlipbook(final float fps, final int count, final boolean looping, final boolean reversible, final boolean vertical) { -+ this.interval = 1 / Math.max(.1F, fps); -+ this.count = count; -+ this.looping = looping; -+ this.reversible = reversible; -+ this.vertical = vertical; ++ /** Binding refreshes retain control clocks; new controls start at the refresh time. */ ++ public void replaceRoot(final NativeUiScene.Node root, final long elapsedNanos) { ++ this.advance(elapsedNanos); ++ final Map, NativeUiScene.Node> previous = new HashMap<>(), current = new HashMap<>(); ++ index(this.root, List.of(), 0, previous); ++ index(root, List.of(), 0, current); ++ final Map replacements = new IdentityHashMap<>(); ++ final Set retainedDestroyed = Collections.newSetFromMap(new IdentityHashMap<>()); ++ for (final var control : current.entrySet()) { ++ final var old = previous.get(control.getKey()); ++ final var node = control.getValue(); ++ if (old != null && this.destroyed.contains(old) ++ && Objects.equals(old.properties().get("animation_program"), node.properties().get("animation_program"))) { ++ node.properties().addProperty("ignored", true); ++ retainedDestroyed.add(node); ++ } ++ // New controls have no old identity. Each replacement still needs its own clock entry. ++ replacements.put(old == null ? node : old, node); ++ } ++ this.animations.reconcile(replacements, elapsedNanos); ++ if (this.exiting) { ++ final Map> retainedExit = new IdentityHashMap<>(); ++ replacements.forEach((previousNode, currentNode) -> { ++ final var pending = this.pendingExit.get(previousNode); ++ if (pending != null && Objects.equals(previousNode.properties().get("animation_program"), currentNode.properties().get("animation_program"))) ++ retainedExit.put(currentNode, pending); ++ }); ++ this.pendingExit = retainedExit; ++ this.exitComplete = retainedExit.isEmpty(); ++ } ++ this.destroyed.clear(); ++ this.destroyed.addAll(retainedDestroyed); ++ this.root = root; ++ this.paths.clear(); ++ collect(root, List.of()); + } + -+ boolean advance(final float delta) { -+ final float next = this.frame + 1; -+ if (next >= this.count && !this.looping) return false; -+ this.elapsed += delta; -+ if (this.elapsed >= this.interval) { -+ this.elapsed -= this.interval; -+ this.frame = next; -+ if (next >= this.count) { -+ this.frame = 0; -+ if (this.reversible) this.reversed = !this.reversed; -+ } ++ private static void index(final NativeUiScene.Node node, final List ancestors, final int occurrence, ++ final Map, NativeUiScene.Node> controls) { ++ final var path = new ArrayList<>(ancestors); ++ path.add(identity(node, occurrence)); ++ controls.put(List.copyOf(path), node); ++ final Map occurrences = new HashMap<>(); ++ for (final var child : node.children()) { ++ final int index = occurrences.merge(identity(child, 0), 1, Integer::sum) - 1; ++ index(child, path, index, controls); + } -+ return true; + } + -+ void reset() { -+ // The target reset clears the frame clock, but retains the current direction. -+ this.elapsed = this.frame = 0; ++ private static Identity identity(final NativeUiScene.Node node, final int occurrence) { ++ return new Identity(node.name(), node.type(), String.valueOf(node.properties().get("inventory_slot")), ++ String.valueOf(node.properties().get("collection")), String.valueOf(node.properties().get("collection_index")), ++ String.valueOf(node.properties().get("factory_instance_id")), occurrence); + } + -+ float position() { return this.reversed ? this.count - this.frame - 1 : this.frame; } -+ int frames() { return this.count; } -+ boolean vertical() { return this.vertical; } ++ private record Identity(String name, String type, String slot, String collection, String index, String factoryInstance, int occurrence) {} ++ ++ boolean active() { return active(this.root); } ++ ++ private static boolean active(final NativeUiScene.Node node) { ++ if (node.flag("ignored", false) || !node.flag("visible", true)) return false; ++ if (!node.string("factory_name", "").isEmpty()) return node.children().stream().anyMatch(NativeUiAnimationLifecycle::visible); ++ return node.children().isEmpty() || node.children().stream().anyMatch(NativeUiAnimationLifecycle::active); ++ } ++ ++ private static boolean visible(final NativeUiScene.Node node) { ++ return !node.flag("ignored", false) && node.flag("visible", true); ++ } ++ ++ private void collect(final NativeUiScene.Node node, final List ancestors) { ++ final var path = new ArrayList<>(ancestors); ++ path.add(node); ++ this.paths.put(node, List.copyOf(path)); ++ for (final var child : node.children()) collect(child, path); ++ } ++ ++ private static NativeUiScene.Node target(final List path, final String name) { ++ for (int i = path.size() - 1; i >= 0; i--) { ++ final var node = path.get(i); ++ if (node.name().equals(name)) return node; ++ } ++ return null; ++ } +} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiFocus.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiFocus.java +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiAnimations.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiAnimations.java new file mode 100644 -index 0000000..0d64363 +index 0000000..b7aadf0 --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiFocus.java -@@ -0,0 +1,329 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiAnimations.java +@@ -0,0 +1,455 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -5909,332 +7065,458 @@ index 0000000..0d64363 + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; -+import java.lang.ref.WeakReference; -+import java.math.BigInteger; +import java.util.ArrayDeque; +import java.util.ArrayList; ++import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.List; -+import java.util.Locale; +import java.util.Map; -+import java.util.function.Predicate; ++import java.util.Set; ++import java.util.function.Consumer; ++import java.util.function.BiPredicate; ++import net.raphimc.viabedrock.api.model.CameraEase; + -+/** Native directional selection, scroll contexts and focus-container policies for admitted controls. */ -+public final class NativeUiFocus { -+ private NativeUiFocus() {} ++/** Per-control animation managers with native next-track and play/reset event semantics. */ ++public final class NativeUiAnimations { ++ private final Map entries = new IdentityHashMap<>(); ++ private final BiPredicate destroy; ++ private final Consumer completed; ++ private NativeUiScene.Node indexedRoot; ++ private List managers = List.of(); ++ private long layoutRevision; ++ private long opacityRevision; + -+ public static List targets(final NativeUiLayout.Box root, final Predicate admitted) { -+ final List result = new ArrayList<>(); -+ collect(root, null, List.of(), true, true, admitted, result); -+ return List.copyOf(result); -+ } ++ public long layoutRevision() { return this.layoutRevision; } ++ public long opacityRevision() { return this.opacityRevision; } + -+ private static void collect(final NativeUiLayout.Box box, final Clip inheritedClip, final List ancestors, final boolean parentVisible, -+ final boolean parentEnabled, final Predicate admitted, final List targets) { -+ final var node = box.node(); -+ final boolean visible = parentVisible && node.flag("visible", true) && !node.flag("ignored", false); -+ final boolean enabled = parentEnabled && node.flag("enabled", true); -+ if (!visible) return; -+ if (enabled && node.flag("focus_enabled", false) && admitted.test(node)) targets.add(new Target(box, inheritedClip, ancestors)); -+ final Clip clip = node.scroll() || node.flag("clips_children", false) -+ ? new Clip(node, node.scroll() ? box.scrollViewport() : box.bounds()) : inheritedClip; -+ final List nested = new ArrayList<>(ancestors); -+ nested.add(box); -+ for (final var child : box.children()) collect(child, clip, List.copyOf(nested), visible, enabled, admitted, targets); ++ void reconcile(final Map replacements, final long now) { ++ final Map retained = new IdentityHashMap<>(); ++ for (final var replacement : replacements.entrySet()) { ++ final var node = replacement.getValue(); ++ final Entry previous = this.entries.get(replacement.getKey()); ++ final Entry entry; ++ if (previous != null && java.util.Objects.equals(previous.node.properties().get("animation_program"), node.properties().get("animation_program"))) { ++ previous.advance(now); ++ previous.node = node; ++ previous.bindAseprite(); ++ if (previous.definitions.stream().noneMatch(definition -> definition.alpha)) ++ previous.current = previous.potential = Math.clamp(node.number("alpha", 1), 0, 1); ++ entry = previous; ++ } else entry = new Entry(node, now); ++ retained.put(node, entry); ++ } ++ this.entries.clear(); ++ this.entries.putAll(retained); ++ this.indexedRoot = null; ++ this.managers = List.of(); ++ this.layoutRevision++; + } + -+ public static Target next(final List targets, final NativeUiScene.Node selected, -+ final Direction direction, final float viewportWidth, final float viewportHeight, -+ final History history) { -+ if (targets.isEmpty()) return null; -+ final Target current = targets.stream().filter(target -> target.box.node() == selected).findFirst().orElse(null); -+ if (current == null) return initial(targets, viewportWidth); -+ final String override = string(current.box.node(), direction.property); -+ if (override.equals("FOCUS_OVERRIDE_STOP")) return current; -+ Target target = null; -+ if (!override.isEmpty()) { -+ for (final Target candidate : targets) if (string(candidate.box.node(), "focus_identifier").equals(override)) return candidate; -+ } -+ boolean wrapping = current.box.node().flag("focus_wrap_enabled", true); -+ if (target == null) { -+ final Map indexed = new IdentityHashMap<>(); -+ for (final var candidate : targets) indexed.put(candidate.box.node(), candidate); -+ for (int index = current.ancestors.size() - 1; index >= 0; index--) { -+ final var ancestor = current.ancestors.get(index); -+ if (!ancestor.node().scroll()) continue; -+ final var viewport = NativeUiScroll.descendant(ancestor, string(ancestor.node(), "scroll_view_port")); -+ if (viewport == null) continue; -+ wrapping = false; -+ final List candidates = new ArrayList<>(); -+ subtree(viewport, indexed, candidates); -+ target = geometry(candidates, current, direction, viewportWidth, viewportHeight, false, false, false); -+ if (target != null) break; -+ } -+ } -+ if (target == null) target = geometry(targets, current, direction, viewportWidth, viewportHeight, wrapping, true, false); -+ if (target == null) return current; -+ final var sourceGroup = group(current); -+ final var targetGroup = group(target); -+ if (sourceGroup == targetGroup && sourceGroup != null && !sourceGroup.flag("focus_wrap_enabled", true) -+ && backwards(current, target, direction)) return current; -+ if (sourceGroup == targetGroup || sourceGroup == null) return target; -+ final String mode = string(sourceGroup, "focus_navigation_mode_" + direction.name().toLowerCase(Locale.ROOT)); -+ if (mode.equals("stop")) return current; -+ if (mode.equals("custom")) { -+ final var entries = sourceGroup.properties().get("focus_container_custom_" + direction.name().toLowerCase(Locale.ROOT)); -+ if (entries == null || !entries.isJsonArray() || entries.getAsJsonArray().asList().stream().noneMatch(JsonElement::isJsonObject)) return target; -+ final Target custom = custom(targets, current, sourceGroup, direction, history); -+ if (custom != null) return custom; -+ } -+ if (targetGroup == null) return target; -+ if (mode.equals("contained") && !within(target, sourceGroup)) return current; -+ if (targetGroup.flag("use_last_focus", false) && !within(current, targetGroup)) { -+ final Target remembered = history.remembered(targets, targetGroup); -+ if (remembered != null) return remembered; -+ } -+ return target; ++ NativeUiDimension dimension(final NativeUiScene.Node node, final String property, final int axis) { ++ final Entry entry = this.entries.get(node); ++ if (entry == null) return null; ++ final Vector vector = switch (property) { ++ case "size" -> entry.size; ++ case "offset" -> entry.offset; ++ default -> null; ++ }; ++ return vector == null ? null : axis == 0 ? vector.x : vector.y; + } + -+ private static Target initial(final List targets, final float viewportWidth) { -+ int maximum = 0; -+ for (final var target : targets) maximum = Math.max(maximum, precedence(target.box.node())); -+ Target selected = null; -+ float nearest = Float.MAX_VALUE; -+ for (final var target : targets) { -+ if (precedence(target.box.node()) != maximum) continue; -+ final var bounds = target.box.bounds(); -+ final float score = bounds.y() * viewportWidth + bounds.x(); -+ if (score < nearest) { -+ selected = target; -+ nearest = score; -+ } -+ } -+ return selected; ++ NativeUiColor color(final NativeUiScene.Node node) { ++ final Entry entry = this.entries.get(node); ++ return entry == null ? null : entry.color; + } + -+ private static int precedence(final NativeUiScene.Node node) { -+ final var value = node.properties().get("default_focus_precedence"); -+ if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber()) return 0; -+ try { -+ // The native signed-int64 reader narrows to int32; unsigned-only and real values use zero. -+ final var integer = new BigInteger(value.getAsString()); -+ return integer.bitLength() <= 63 ? integer.intValue() : 0; -+ } catch (final NumberFormatException invalid) { -+ return 0; -+ } ++ NativeUiImageRegions.Source source(final NativeUiScene.Node node, final int width, final int height) { ++ final Entry entry = this.entries.get(node); ++ final float cropWidth = NativeUiLayout.vector(node, "uv_size", 0, width); ++ final float cropHeight = NativeUiLayout.vector(node, "uv_size", 1, height); ++ final var atlas = entry == null ? null : entry.aseprite; ++ return new NativeUiImageRegions.Source( ++ entry != null && entry.u != null ? entry.u.resolve(width) : NativeUiLayout.vector(node, "uv", 0, 0), ++ entry != null && entry.v != null ? entry.v.resolve(height) : NativeUiLayout.vector(node, "uv", 1, 0), ++ entry != null && entry.width != null ? entry.width.resolve(width) : atlas == null || atlas.width() == 0 ? cropWidth : width / atlas.width() * cropWidth, ++ entry != null && entry.height != null ? entry.height.resolve(height) : atlas == null || atlas.height() == 0 ? cropHeight : height / atlas.height() * cropHeight); + } + -+ private static void subtree(final NativeUiLayout.Box box, final Map targets, final List result) { -+ final var target = targets.get(box.node()); -+ if (target != null) result.add(target); -+ for (final var child : box.children()) subtree(child, targets, result); ++ public NativeUiAnimations() { this((node, name) -> false); } ++ ++ NativeUiAnimations(final BiPredicate destroy) { this(destroy, event -> { }); } ++ ++ NativeUiAnimations(final BiPredicate destroy, ++ final Consumer completed) { ++ this.destroy = destroy; ++ this.completed = completed; + } + -+ private static boolean backwards(final Target current, final Target target, final Direction direction) { -+ final var source = current.box.bounds(); -+ final var result = target.box.bounds(); -+ return switch (direction) { -+ case UP -> source.y() < result.y(); -+ case DOWN -> result.y() < source.y(); -+ case LEFT -> source.x() < result.x(); -+ case RIGHT -> result.x() < source.x(); -+ }; ++ record Completion(NativeUiScene.Node node, int track, String event) {} ++ ++ Map> completions(final NativeUiScene.Node node, final String play, final String completion) { ++ final Map> result = new IdentityHashMap<>(); ++ this.collectCompletions(node, play, completion, result); ++ return result; + } + -+ private static NativeUiScene.Node group(final Target target) { -+ if (target.box.node().flag("focus_container", false)) return target.box.node(); -+ for (int i = target.ancestors.size() - 1; i >= 0; i--) { -+ final var node = target.ancestors.get(i).node(); -+ if (node.flag("focus_container", false)) return node; ++ private void collectCompletions(final NativeUiScene.Node node, final String play, final String completion, ++ final Map> result) { ++ final var entry = this.entries.get(node); ++ if (entry != null) { ++ final var tracks = entry.completions(play, completion); ++ if (!tracks.isEmpty()) result.put(node, tracks); + } -+ return null; ++ for (final var child : node.children()) this.collectCompletions(child, play, completion, result); + } + -+ private static boolean within(final Target target, final NativeUiScene.Node ancestor) { -+ return target.ancestors.stream().anyMatch(parent -> parent.node() == ancestor); ++ public NativeUiPresentation.Opacity opacity(final NativeUiScene.Node node, final long now) { ++ final Entry entry = this.entries.computeIfAbsent(node, value -> new Entry(value, now)); ++ entry.rendered(); ++ entry.advance(now); ++ return entry.opacity(); + } + -+ private static Target custom(final List targets, final Target current, final NativeUiScene.Node sourceGroup, -+ final Direction direction, final History history) { -+ final var value = sourceGroup.properties().get("focus_container_custom_" + direction.name().toLowerCase(Locale.ROOT)); -+ if (value == null || !value.isJsonArray()) return null; -+ for (final var entry : value.getAsJsonArray()) { -+ if (!entry.isJsonObject()) continue; -+ final String other = string(entry.getAsJsonObject(), "other_focus_container_name"); -+ final String name = string(entry.getAsJsonObject(), "focus_id_inside"); -+ final List candidates = targets.stream().filter(candidate -> other.isEmpty() -+ || group(candidate) != null && group(candidate).name().equals(other)).toList(); -+ if (!name.isEmpty()) { -+ for (final var candidate : candidates) if (candidate.box.node().name().equals(name)) return candidate; -+ } else if (!other.isEmpty() && !candidates.isEmpty()) { -+ final var container = group(candidates.getFirst()); -+ if (container.flag("use_last_focus", false) && !within(current, container)) { -+ final var remembered = history.remembered(targets, container); -+ if (remembered != null) return remembered; -+ } -+ Target closest = null; -+ float distance = Float.MAX_VALUE; -+ final var origin = edges(current.box.bounds()); -+ for (final var candidate : candidates) { -+ final var center = edges(candidate.box.bounds()); -+ final float dx = center.centerX - origin.centerX, dy = center.centerY - origin.centerY; -+ final float squared = dx * dx + dy * dy; -+ if (squared < distance) { closest = candidate; distance = squared; } -+ } -+ if (closest != null) return closest; -+ } ++ /** HUD replacements retain their elapsed title clock rather than using the scene's creation time. */ ++ public NativeUiPresentation.Opacity opacityAt(final NativeUiScene.Node node, final long elapsed) { ++ final Entry entry = this.entries.computeIfAbsent(node, value -> new Entry(value, 0)); ++ entry.rendered(); ++ entry.advance(elapsed); ++ return entry.opacity(); ++ } ++ ++ void advance(final NativeUiScene.Node node, final long elapsed) { ++ if (this.indexedRoot != node) { ++ final List managers = new ArrayList<>(); ++ this.collectManagers(node, managers); ++ this.managers = List.copyOf(managers); ++ this.indexedRoot = node; + } -+ return null; ++ for (final var manager : this.managers) manager.advance(elapsed); + } + -+ public static final class History { -+ private final Map>> recent = new IdentityHashMap<>(); -+ private final Map> remembered = new IdentityHashMap<>(); -+ private Target selected; ++ private void collectManagers(final NativeUiScene.Node node, final List managers) { ++ final Entry entry = this.entries.computeIfAbsent(node, value -> new Entry(value, 0)); ++ if (!entry.definitions.isEmpty()) managers.add(entry); ++ for (final var child : node.children()) this.collectManagers(child, managers); ++ } + -+ public void select(final Target target) { -+ if (this.selected != null && (target == null || this.selected.box.node() != target.box.node())) { -+ final List groups = new ArrayList<>(); -+ if (this.selected.box.node().flag("focus_container", false)) groups.add(this.selected.box.node()); -+ for (final var box : this.selected.ancestors) if (box.node().flag("focus_container", false)) groups.add(box.node()); -+ for (final var group : groups) { -+ final var entries = this.recent.computeIfAbsent(group, _ -> new ArrayDeque<>()); -+ entries.addFirst(new WeakReference<>(this.selected.box.node())); -+ if (entries.size() > 50) entries.removeLast(); -+ if (target == null || group(this.selected) != group(target)) this.remembered.put(group, new WeakReference<>(this.selected.box.node())); ++ /** Events affect the receiving control and its descendants, not unrelated scrolling panels. */ ++ public void event(final NativeUiScene.Node node, final String event, final long now) { ++ final Entry entry = this.entries.computeIfAbsent(node, value -> new Entry(value, now)); ++ entry.advance(now); ++ entry.event(event); ++ for (final var child : node.children()) event(child, event, now); ++ } ++ ++ private final class Entry { ++ NativeUiScene.Node node; ++ final List definitions = new ArrayList<>(); ++ final List active = new ArrayList<>(); ++ final float[] times; ++ final State[] states; ++ final NativeUiFlipbook[] flipbooks; ++ final NativeUiAseprite[] aseprites; ++ NativeUiAseprite.Metadata aseprite; ++ Vector size, offset; ++ NativeUiColor color; ++ TextureCoordinate u, v, width, height; ++ float current; ++ float potential; ++ boolean rendered, advanced; ++ long last; ++ ++ Entry(final NativeUiScene.Node node, final long now) { ++ this.node = node; ++ this.last = now; ++ this.current = this.potential = Math.clamp(node.number("alpha", 1), 0, 1); ++ final JsonObject program = node.properties().getAsJsonObject("animation_program"); ++ if (program != null) { ++ for (final var value : program.getAsJsonArray("tracks")) { ++ final Definition definition = new Definition(value.getAsJsonObject(), node.number("alpha", 1)); ++ this.definitions.add(definition); ++ if (definition.alpha) this.potential = Math.max(this.potential, Math.max(definition.track.from, definition.track.to) * definition.alphaScale); + } ++ for (final var root : program.getAsJsonArray("roots")) this.active.add(root.getAsInt()); ++ } ++ this.times = new float[this.definitions.size()]; ++ this.states = new State[this.definitions.size()]; ++ this.flipbooks = new NativeUiFlipbook[this.definitions.size()]; ++ this.aseprites = new NativeUiAseprite[this.definitions.size()]; ++ bindAseprite(); ++ for (int i = 0; i < this.definitions.size(); i++) { ++ final var definition = this.definitions.get(i); ++ if (definition.next < -1 || definition.next >= this.definitions.size()) throw new IllegalArgumentException("Invalid UI animation successor"); ++ if (definition.flipbook) this.flipbooks[i] = new NativeUiFlipbook(definition.fps, definition.frames, ++ definition.looping, definition.reversible, definition.vertical); ++ if (definition.aseprite) this.aseprites[i] = new NativeUiAseprite(); ++ this.states[i] = initial(definition); ++ } ++ for (final int root : this.active) { ++ if (root < 0 || root >= this.definitions.size()) throw new IllegalArgumentException("Invalid UI animation root"); ++ final var definition = this.definitions.get(root); ++ sample(root, definition, 0, true); + } -+ this.selected = target; + } + -+ public void remap(final Map replacements, final List targets) { -+ final Map>> updated = new IdentityHashMap<>(); -+ for (final var entry : this.recent.entrySet()) { -+ final var group = replacements.get(entry.getKey()); -+ if (group == null || !group.flag("focus_container", false)) continue; -+ final ArrayDeque> values = new ArrayDeque<>(); -+ for (final var node : entry.getValue()) values.addLast(new WeakReference<>(replacements.get(node.get()))); -+ updated.put(group, values); -+ } -+ this.recent.clear(); -+ this.recent.putAll(updated); -+ final Map> remembered = new IdentityHashMap<>(); -+ for (final var entry : this.remembered.entrySet()) { -+ final var group = replacements.get(entry.getKey()); -+ if (group != null && group.flag("focus_container", false)) remembered.put(group, new WeakReference<>(replacements.get(entry.getValue().get()))); ++ void bindAseprite() { ++ this.aseprite = this.node.type().equals("image") && this.definitions.stream().anyMatch(definition -> definition.aseprite) ++ ? NativeUiAseprite.Metadata.parse(this.node.properties().get("aseprite")) : null; ++ } ++ ++ State initial(final Definition definition) { ++ return !definition.play.isEmpty() ? State.EVENT : definition.waitForRender && !this.rendered ? State.RENDER : State.PLAYING; ++ } ++ ++ void rendered() { ++ this.rendered = true; ++ for (int i = 0; i < this.states.length; i++) if (this.states[i] == State.RENDER) this.states[i] = State.PLAYING; ++ } ++ ++ void event(final String event) { ++ for (final int index : List.copyOf(this.active)) { ++ final var definition = this.definitions.get(index); ++ if (!definition.play.isEmpty() && definition.play.equals(event)) { ++ this.states[index] = definition.waitForRender && !this.rendered ? State.RENDER : State.PLAYING; ++ } else if (!definition.reset.isEmpty() && definition.reset.equals(event)) { ++ this.times[index] = 0; ++ this.states[index] = initial(definition); ++ sample(index, definition, 0, true); ++ } + } -+ this.remembered.clear(); -+ this.remembered.putAll(remembered); -+ final var selected = this.selected == null ? null : replacements.get(this.selected.box.node()); -+ this.selected = targets.stream().filter(target -> target.box.node() == selected).findFirst().orElse(null); + } + -+ private Target remembered(final List targets, final NativeUiScene.Node group) { -+ final var reference = this.remembered.get(group); -+ final var preferred = reference == null ? null : reference.get(); -+ for (final var target : targets) if (target.box.node() == preferred) return target; -+ final var entries = this.recent.get(group); -+ if (entries != null) for (final var entry : entries) { -+ final var node = entry.get(); -+ for (final var target : targets) if (target.box.node() == node) return target; ++ Set completions(final String play, final String completion) { ++ final Set result = new HashSet<>(); ++ for (final int root : this.active) { ++ if (!this.definitions.get(root).play.equals(play)) continue; ++ int index = root; ++ for (int visited = 0; visited < this.definitions.size(); visited++) { ++ final var definition = this.definitions.get(index); ++ if (index != root && !definition.play.isEmpty()) break; ++ if (definition.next < 0) { ++ if (definition.end.equals(completion)) result.add(index); ++ break; ++ } ++ index = definition.next; ++ } + } -+ return null; ++ return result; + } -+ } + -+ private static Target geometry(final List targets, final Target current, final Direction direction, -+ final float width, final float height, final boolean wrapping, final boolean clipCheck, final boolean wrapped) { -+ final Edges own = edges(current.box.bounds()); -+ final boolean vertical = direction == Direction.UP || direction == Direction.DOWN; -+ final boolean negative = direction == Direction.UP || direction == Direction.LEFT; -+ final float originX = !wrapped || vertical ? own.centerX : negative ? width : 0; -+ final float originY = !wrapped || !vertical ? own.centerY : negative ? height : 0; -+ final float probeX = wrapped || vertical ? originX -+ : negative ? originX - (own.halfWidth - 2) : originX + (own.halfWidth - 2); -+ final float probeY = wrapped || !vertical ? originY -+ : negative ? originY - (own.halfHeight - 2) : originY + (own.halfHeight - 2); -+ final float boundaryX = (negative ? own.left : own.right) - originX; -+ final float boundaryY = (negative ? own.top : own.bottom) - originY; -+ final float boundaryLength = length(boundaryX, boundaryY); -+ final float axis = vertical ? boundaryY : boundaryX; -+ final float threshold = Math.min(.02F, (negative ? -1 : 1) -+ * (boundaryLength > .0001F ? axis / boundaryLength : axis)); -+ Target closest = null, aligned = null; -+ float nearestDistance = 1_000_000, nearestAngle = 1_000_000; -+ for (final Target candidate : targets) { -+ if (candidate.box.node() == current.box.node() || wrapped && current.clip != null && candidate.clip != null -+ && current.clip.owner == candidate.clip.owner) continue; -+ final Edges bounds = edges(candidate.box.bounds()); -+ if (clipCheck && candidate.clip != null && !visibleCorner(candidate.clip.bounds, bounds)) continue; -+ final float x = vertical ? Math.clamp(probeX, bounds.left, bounds.right) -+ : negative ? bounds.right : bounds.left; -+ final float y = !vertical ? Math.clamp(probeY, bounds.top, bounds.bottom) -+ : negative ? bounds.bottom : bounds.top; -+ final float dx = x - probeX, dy = y - probeY; -+ final float distance = length(dx, dy); -+ final float cosine = distance > Math.ulp(1F) ? (negative ? -1 : 1) * ((vertical ? dy : dx) / distance) : 1; -+ if (!(threshold <= cosine)) continue; -+ final float angle = 1 - cosine; -+ if (angle < nearestAngle) { -+ aligned = candidate; -+ nearestAngle = angle; ++ void advance(final long now) { ++ // Java partial ticks can retreat during a pause; native frame deltas never rewind. ++ if (now < this.last || this.advanced && now == this.last) return; ++ this.advanced = true; ++ final float delta = (float) ((now - this.last) / 1_000_000_000D); ++ this.last = now; ++ if (this.active.isEmpty()) return; ++ final var next = new ArrayDeque(); ++ final var events = new ArrayList(); ++ // Native managers update all current tracks before processing their queued successors. ++ for (final int index : List.copyOf(this.active)) update(index, delta, next, events); ++ int transitions = 0; ++ while (!next.isEmpty()) { ++ if (++transitions > 65536) throw new IllegalArgumentException("UI animation chain does not settle in this frame"); ++ final var step = next.removeFirst(); ++ this.active.add(step.index); ++ update(step.index, step.delta, next, events); + } -+ if (distance < nearestDistance) { -+ closest = candidate; -+ nearestDistance = distance; ++ for (final var event : events) { ++ event(event.event()); ++ NativeUiAnimations.this.completed.accept(event); + } + } -+ if (closest != null) return closest; -+ if (aligned != null) return aligned; -+ return !wrapped && wrapping -+ ? geometry(targets, current, direction, width, height, wrapping, clipCheck, true) : null; ++ ++ void update(final int index, final float delta, final ArrayDeque next, final List events) { ++ if (this.states[index] != State.PLAYING) return; ++ final var definition = this.definitions.get(index); ++ // Image tracks finish without consuming time when the control has no image component. ++ final boolean applicable = !definition.image || this.node.type().equals("image"); ++ if (applicable && definition.aseprite) { ++ final var frame = this.aseprites[index].advance(this.aseprite, delta); ++ if (frame != null) { ++ this.u = new TextureCoordinate(0, 1, frame.x()); ++ this.v = new TextureCoordinate(0, 1, frame.y()); ++ } ++ return; ++ } else if (applicable && definition.flipbook) { ++ final var flipbook = this.flipbooks[index]; ++ if (flipbook.advance(delta)) { ++ final float positionFrame = flipbook.frames() > 0 ? flipbook.position() : 0; ++ final float cropFrame = flipbook.frames() > 0 ? 1 : 0; ++ final int frames = Math.max(1, flipbook.frames()); ++ final var position = new TextureCoordinate(positionFrame, frames, 1F / 256); ++ final var crop = new TextureCoordinate(cropFrame, frames, -1F / 128); ++ this.u = flipbook.vertical() ? new TextureCoordinate(0, 1, 0) : position; ++ this.v = flipbook.vertical() ? position : new TextureCoordinate(0, 1, 0); ++ if (flipbook.vertical()) this.height = crop; ++ else this.width = crop; ++ return; ++ } ++ if (definition.resettable) { this.states[index] = State.EVENT; return; } ++ } else if (applicable) { ++ this.times[index] += delta; ++ sample(index, definition, this.times[index], false); ++ if (this.times[index] < definition.track.delay + Math.max(.01F, definition.track.duration)) return; ++ } ++ final float elapsed = this.times[index]; ++ this.active.remove(Integer.valueOf(index)); ++ if (!definition.destroy.isEmpty() && NativeUiAnimations.this.destroy.test(this.node, definition.destroy)) { ++ NativeUiAnimations.this.layoutRevision++; ++ if (!definition.end.isEmpty()) events.add(new Completion(this.node, index, definition.end)); ++ return; ++ } ++ if (definition.next >= 0) { ++ this.times[definition.next] = 0; ++ next.addLast(new Step(definition.next, Math.max(0, elapsed - definition.track.delay - definition.track.duration))); ++ } else if (!definition.end.isEmpty()) events.add(new Completion(this.node, index, definition.end)); ++ } ++ ++ void sample(final int index, final Definition definition, final float elapsed, final boolean reset) { ++ if (definition.image && !this.node.type().equals("image")) return; ++ if (definition.aseprite && reset) this.u = this.v = new TextureCoordinate(0, 1, 0); ++ if (definition.flipbook && reset) { ++ this.flipbooks[index].reset(); ++ this.u = this.v = new TextureCoordinate(0, 1, 0); ++ } ++ if (definition.uvFrom != null) { ++ final float factor = reset ? 0 : definition.track.factor(elapsed); ++ this.u = new TextureCoordinate(0, 1, definition.uvFrom.x + (definition.uvTo.x - definition.uvFrom.x) * factor); ++ this.v = new TextureCoordinate(0, 1, definition.uvFrom.y + (definition.uvTo.y - definition.uvFrom.y) * factor); ++ } ++ if (definition.alpha) { ++ final float alpha = (reset ? definition.track.from : definition.track.value(elapsed)) * definition.alphaScale; ++ if (this.current != alpha) { ++ this.current = alpha; ++ NativeUiAnimations.this.opacityRevision++; ++ } ++ } ++ if (definition.colorFrom != null && this.node.type().equals("image")) ++ this.color = reset ? definition.colorFrom : definition.colorFrom.interpolate(definition.colorTo, definition.track.factor(elapsed)); ++ if (definition.from == null) return; ++ final Vector value = reset ? definition.from : definition.from.interpolate(definition.to, definition.track.factor(elapsed)); ++ final Vector previous = definition.type.equals("size") ? this.size : this.offset; ++ if (!value.equals(previous)) { ++ if (definition.type.equals("size")) this.size = value; ++ else this.offset = value; ++ NativeUiAnimations.this.layoutRevision++; ++ } ++ } ++ ++ NativeUiPresentation.Opacity opacity() { ++ return new NativeUiPresentation.Opacity(Math.clamp(this.current, 0, 1), Math.clamp(this.potential, 0, 1)); ++ } + } + -+ private static String string(final NativeUiScene.Node node, final String key) { -+ return string(node.properties(), key); ++ private enum State { PLAYING, EVENT, RENDER } ++ private record Step(int index, float delta) {} ++ ++ private static final class Definition { ++ final Track track; ++ final String type; ++ final Vector from, to; ++ final NativeUiColor colorFrom, colorTo; ++ final boolean alpha, image, flipbook, aseprite, looping, reversible, vertical, resettable; ++ final float fps, alphaScale; ++ final int frames; ++ final Uv uvFrom, uvTo; ++ final boolean waitForRender; ++ final int next; ++ final String play, reset, end, destroy; ++ ++ Definition(final JsonObject value, final float startingAlpha) { ++ this.type = NativeUiScene.string(value, "anim_type", ""); ++ this.alpha = this.type.equals("alpha"); ++ this.alphaScale = this.alpha && flag(value, "scale_from_starting_alpha", false) ? startingAlpha : 1; ++ this.flipbook = this.type.equals("flip_book"); ++ this.aseprite = this.type.equals("aseprite_flip_book"); ++ this.image = this.aseprite || this.flipbook || this.type.equals("uv") || this.type.equals("color"); ++ this.uvFrom = this.type.equals("uv") ? Uv.parse(value.get("from")) : null; ++ this.uvTo = this.type.equals("uv") ? Uv.parse(value.get("to")) : null; ++ this.fps = this.flipbook ? number(value, "fps", 0) : 0; ++ this.frames = this.flipbook ? integer(value, "frame_count", 1) : 0; ++ this.looping = flag(value, "looping", true); ++ this.resettable = flag(value, "resettable", true); ++ this.reversible = flag(value, "reversible", false); ++ this.vertical = List.of("vertical", "none").contains(NativeUiScene.string(value, "orientation", "horizontal")); ++ this.colorFrom = this.type.equals("color") ? NativeUiColor.parse(value.get("from")) : null; ++ this.colorTo = this.type.equals("color") ? NativeUiColor.parse(value.get("to")) : null; ++ final boolean vector = this.type.equals("size") || this.type.equals("offset"); ++ this.from = vector ? Vector.parse(value.get("from")) : null; ++ this.to = vector ? Vector.parse(value.get("to")) : null; ++ this.track = new Track(this.alpha ? number(value, "from", 1) : 1, this.alpha ? number(value, "to", 1) : 1, number(value, "duration", 0), ++ number(value, "delay", number(value, "start_delay", 0)), NativeUiScene.string(value, "easing", "linear")); ++ this.next = value.has("next_index") ? value.get("next_index").getAsInt() : -1; ++ this.play = NativeUiScene.string(value, "play_event", ""); ++ this.reset = NativeUiScene.string(value, "reset_event", ""); ++ this.end = NativeUiScene.string(value, "end_event", ""); ++ this.destroy = NativeUiScene.string(value, "destroy_at_end", ""); ++ this.waitForRender = value.has("wait_until_rendered_to_play") && !value.get("wait_until_rendered_to_play").isJsonNull() && value.get("wait_until_rendered_to_play").getAsBoolean(); ++ } + } + -+ private static String string(final JsonObject properties, final String key) { -+ final var value = properties.get(key); -+ return value != null && value.isJsonPrimitive() && value.getAsJsonPrimitive().isString() ? value.getAsString() : ""; ++ private static boolean flag(final JsonObject value, final String key, final boolean fallback) { ++ return !value.has(key) || value.get(key).isJsonNull() ? fallback : value.get(key).getAsBoolean(); + } + -+ private static float length(final float x, final float y) { -+ return (float) Math.sqrt(x * x + y * y); ++ private static int integer(final JsonObject value, final String key, final int fallback) { ++ final float number = number(value, key, fallback); ++ if (number < Integer.MIN_VALUE || number > Integer.MAX_VALUE || number != Math.rint(number)) ++ throw new IllegalArgumentException("Invalid native UI animation integer"); ++ return (int) number; + } + -+ private static Edges edges(final NativeUiLayout.Rect rect) { -+ final float halfWidth = rect.width() * .5F, halfHeight = rect.height() * .5F; -+ final float x = rect.x() + halfWidth, y = rect.y() + halfHeight; -+ return new Edges(x, y, halfWidth, halfHeight, x - halfWidth, x + halfWidth, y - halfHeight, y + halfHeight); ++ private record TextureCoordinate(float frame, int frames, float offset) { ++ float resolve(final int extent) { return (float) extent / this.frames * this.frame + this.offset; } + } + -+ private static boolean visibleCorner(final NativeUiLayout.Rect clip, final Edges bounds) { -+ return contains(clip, bounds.left, bounds.top) || contains(clip, bounds.right, bounds.top) -+ || contains(clip, bounds.left, bounds.bottom) || contains(clip, bounds.right, bounds.bottom); ++ private record Uv(float x, float y) { ++ static Uv parse(final JsonElement value) { ++ if (value == null || value.isJsonNull()) return new Uv(0, 0); ++ if (!value.isJsonArray() || value.getAsJsonArray().size() != 2) ++ throw new IllegalArgumentException("Invalid UI texture animation endpoint"); ++ final float x = value.getAsJsonArray().get(0).getAsFloat(), y = value.getAsJsonArray().get(1).getAsFloat(); ++ if (!Float.isFinite(x) || !Float.isFinite(y)) throw new IllegalArgumentException("Non-finite UI texture coordinate"); ++ return new Uv(x, y); ++ } + } + -+ private static boolean contains(final NativeUiLayout.Rect rect, final float x, final float y) { -+ return rect.x() <= x && x <= rect.x() + rect.width() && rect.y() <= y && y <= rect.y() + rect.height(); ++ private static float number(final JsonObject value, final String key, final float fallback) { ++ if (!value.has(key) || value.get(key).isJsonNull()) return fallback; ++ final float number = value.get(key).getAsFloat(); ++ if (!Float.isFinite(number)) throw new IllegalArgumentException("Non-finite native UI animation"); ++ return number; + } + -+ public record Clip(NativeUiScene.Node owner, NativeUiLayout.Rect bounds) {} -+ public record Target(NativeUiLayout.Box box, Clip clip, List ancestors) {} -+ private record Edges(float centerX, float centerY, float halfWidth, float halfHeight, -+ float left, float right, float top, float bottom) {} ++ public record Track(float from, float to, float duration, float delay, String easing) { ++ public float value(final double elapsed) { ++ // The target updater divides by max(duration, 0.01), including zero-duration tracks. ++ return this.from + (this.to - this.from) * factor(elapsed); ++ } + -+ public enum Direction { -+ UP("focus_change_up"), DOWN("focus_change_down"), LEFT("focus_change_left"), RIGHT("focus_change_right"); -+ private final String property; -+ Direction(final String property) { this.property = property; } ++ float factor(final double elapsed) { ++ final float progress = (float) Math.clamp((elapsed - delay) / Math.max(.01F, duration), 0, 1); ++ return CameraEase.fromName(this.easing).factor(progress); ++ } ++ } ++ ++ private record Vector(NativeUiDimension x, NativeUiDimension y) { ++ static Vector parse(final JsonElement value) { ++ if (value == null || value.isJsonNull()) return new Vector(new NativeUiDimension(List.of()), new NativeUiDimension(List.of())); ++ if (!value.isJsonArray() || value.getAsJsonArray().size() != 2) throw new IllegalArgumentException("Invalid UI vector animation endpoint"); ++ return new Vector(NativeUiDimension.parse(value.getAsJsonArray().get(0)), NativeUiDimension.parse(value.getAsJsonArray().get(1))); ++ } ++ ++ Vector interpolate(final Vector to, final float factor) { ++ return new Vector(this.x.interpolate(to.x, factor), this.y.interpolate(to.y, factor)); ++ } + } +} -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 +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiAseprite.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiAseprite.java new file mode 100644 -index 0000000..6e767d7 +index 0000000..b02e0d5 --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiFont.java -@@ -0,0 +1,80 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiAseprite.java +@@ -0,0 +1,54 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -6242,85 +7524,59 @@ index 0000000..6e767d7 + */ +package com.viaversion.viafabricplus.bedrock.screen.ui; + -+/** Applies the native label size multiplier to the selected font's metrics and drawing. */ -+public final class NativeUiFont { -+ private NativeUiFont() {} -+ -+ public static float scale(final NativeUiScene.Node node) { -+ final float multiplier = switch (node.string("font_size", "normal")) { -+ case "small" -> .5F; -+ case "large" -> 2F; -+ case "extra_large" -> 4F; -+ default -> 1F; -+ }; -+ final float scale = multiplier * node.number("font_scale_factor", 1); -+ if (!Float.isFinite(scale) || scale < 0) throw new IllegalArgumentException("Unsupported native UI font scale"); -+ return scale; -+ } ++import com.google.gson.JsonElement; ++import java.util.ArrayList; ++import java.util.List; + -+ /** Snap the label origin in physical pixels before applying its font-size transform. */ -+ public static Origin origin(final float x, final float y, final float fontScale, final int uiScale) { -+ return new Origin(snap(x, fontScale, uiScale), snap(y + 1, fontScale, uiScale)); -+ } ++/** Aseprite tracks retain phase across resets and select frames by elapsed milliseconds. */ ++final class NativeUiAseprite { ++ private float elapsed; + -+ public static Origin origin(final NativeUiScene.Node node, final float x, final float y, -+ final float fontScale, final int uiScale) { -+ final float displayScale = node.number("font_origin_scale", 1); -+ if (!Float.isFinite(displayScale) || displayScale <= 0) { -+ throw new IllegalArgumentException("Unsupported native font origin scale"); -+ } -+ final float grid = fontScale * displayScale; -+ if (!Float.isFinite(grid) || grid <= 0) { -+ throw new IllegalArgumentException("Unsupported native font origin grid"); ++ Frame advance(final Metadata metadata, final float delta) { ++ if (metadata == null) return null; ++ this.elapsed += delta; ++ if (metadata.duration == 0) return new Frame(0, 0, 0); ++ final float milliseconds = this.elapsed * 1000; ++ // CVTTSS2SI returns the minimum integer for a float outside the signed range. ++ final int time = milliseconds >= 2147483648F || milliseconds < -2147483648F ? Integer.MIN_VALUE : (int) milliseconds; ++ int position = time % metadata.duration; ++ for (final Frame frame : metadata.frames) { ++ position -= frame.duration; ++ if (position < 0) return frame; + } -+ return origin(x, y, grid, uiScale); -+ } -+ -+ private static float snap(final float coordinate, final float fontScale, final int uiScale) { -+ // Native converts scaled float coordinates to integers before multiplying by the inverse scale. -+ return (int) (coordinate * (uiScale / fontScale)) * (fontScale / uiScale); ++ return new Frame(0, 0, 0); + } + -+ public record Origin(float x, float y) { } -+ -+ public static int wrapWidth(final NativeUiScene.Node node, final float width) { -+ final float scale = scale(node); -+ return scale == 0 ? Integer.MAX_VALUE : Math.max(1, (int) (width / scale)); -+ } ++ record Frame(int x, int y, int duration) {} + -+ public static float lineHeight(final NativeUiScene.Node node) { -+ final float height = node.number("font_line_height", 10); -+ if (!Float.isFinite(height) || height <= 0 || height > 4096) { -+ throw new IllegalArgumentException("Unsupported native UI font line height"); ++ record Metadata(float width, float height, List frames, int duration) { ++ static Metadata parse(final JsonElement value) { ++ if (value == null || value.isJsonNull()) return null; ++ final var object = value.getAsJsonObject(); ++ final var size = object.getAsJsonArray("size"); ++ if (size.size() != 2) throw new IllegalArgumentException("Invalid native atlas size"); ++ final float width = size.get(0).getAsFloat(), height = size.get(1).getAsFloat(); ++ if (!Float.isFinite(width) || !Float.isFinite(height)) throw new IllegalArgumentException("Non-finite native atlas size"); ++ final var frames = new ArrayList(); ++ int duration = 0; ++ for (final var item : object.getAsJsonArray("frames")) { ++ final var fields = item.getAsJsonArray(); ++ if (fields.size() != 3) throw new IllegalArgumentException("Invalid native atlas frame"); ++ final var frame = new Frame(fields.get(0).getAsInt(), fields.get(1).getAsInt(), fields.get(2).getAsInt()); ++ if (frame.x < 0 || frame.y < 0 || frame.duration <= 0) throw new IllegalArgumentException("Invalid native atlas frame"); ++ frames.add(frame); ++ duration += frame.duration; ++ } ++ return new Metadata(width, height, List.copyOf(frames), duration); + } -+ 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); -+ return new NativeUiLayout.Size(width * scale, lines * lineHeight(node) * scale); -+ } -+ -+ public static NativeUiLayout.Size constrained(final NativeUiScene.Node node, final float width, -+ final int lines) { -+ return new NativeUiLayout.Size(width, lines * lineHeight(node) * scale(node)); + } +} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiGraphics.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiGraphics.java +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiButton.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiButton.java new file mode 100644 -index 0000000..24ce149 +index 0000000..bc593e4 --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiGraphics.java -@@ -0,0 +1,56 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiButton.java +@@ -0,0 +1,167 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -6328,324 +7584,270 @@ index 0000000..24ce149 + */ +package com.viaversion.viafabricplus.bedrock.screen.ui; + -+import com.mojang.blaze3d.platform.cursor.CursorType; -+import net.minecraft.client.Minecraft; -+import net.minecraft.client.gui.GuiGraphicsExtractor; -+import net.minecraft.client.renderer.entity.state.EntityRenderState; -+import net.minecraft.client.renderer.state.gui.GuiRenderState; -+import org.joml.Quaternionfc; -+import org.joml.Vector3fc; ++import com.google.gson.JsonObject; ++import java.util.ArrayDeque; ++import java.util.ArrayList; ++import java.util.List; ++import java.util.Objects; ++import java.util.function.BiConsumer; + -+/** Extracts native units into the shared Java GUI render state, including deferred tooltips. */ -+public final class NativeUiGraphics extends GuiGraphicsExtractor { -+ private final NativeUiViewport viewport; -+ private final GuiGraphicsExtractor parent; ++/** Button appearance is independent of edit-box selection and mapping activation. */ ++public record NativeUiButton(boolean consumeEvent, List controls) { ++ public NativeUiButton { ++ controls = List.copyOf(controls); ++ if (controls.size() != 4) throw new IllegalArgumentException("Expected four native Button states"); ++ } + -+ public NativeUiGraphics(final Minecraft client, final GuiRenderState state, final GuiGraphicsExtractor parent, -+ final NativeUiViewport viewport, final int mouseX, final int mouseY) { -+ super(client, state, mouseX, mouseY); -+ this.viewport = viewport; -+ this.parent = parent; -+ this.pose().scale(viewport.renderScale(), viewport.renderScale()); ++ public static NativeUiButton parse(final JsonObject properties) { ++ final List controls = new ArrayList<>(); ++ for (final String name : List.of("default_control", "hover_control", "pressed_control", "locked_control")) { ++ final var value = properties.get(name); ++ controls.add(value != null && value.isJsonPrimitive() && value.getAsJsonPrimitive().isString() ? value.getAsString() : ""); ++ } ++ return new NativeUiButton(NativeUiScene.flag(properties, "consume_event", true), controls); + } + -+ // Super's constructor creates the initial scissor in the render state's Java coordinates. -+ @Override public int guiWidth() { return this.viewport == null ? super.guiWidth() : this.viewport.width(); } -+ @Override public int guiHeight() { return this.viewport == null ? super.guiHeight() : this.viewport.height(); } -+ @Override public void requestCursor(final CursorType cursor) { this.parent.requestCursor(cursor); } ++ public int handlerMask(final boolean enabled) { return enabled && !this.consumeEvent ? 1 : 0; } + -+ NativeUiViewport viewport() { return this.viewport; } ++ public static final class State { ++ private final NativeUiButton definition; ++ private final BiConsumer visibility; ++ private final Reference[] controls = new Reference[4]; ++ private NativeUiScene.Node owner; ++ private boolean pressed; ++ private boolean visualPressed; ++ private boolean visualHovered; ++ private boolean visualEnabled; ++ private long revision; + -+ @Override -+ public void enableScissor(final int left, final int top, final int right, final int bottom) { -+ NativeUiScissor.enable(this, new NativeUiLayout.Rect(left, top, right - left, bottom - top)); -+ } ++ public State(final NativeUiButton definition, final NativeUiScene.Node owner, final boolean enabled, final boolean hovered) { ++ this(definition, State::writeVisibility); ++ this.attach(owner, enabled, hovered); ++ } + -+ @Override -+ public boolean containsPointInScissor(final int x, final int y) { -+ final var clip = NativeUiScissor.from(this.scissorStack.peek()); -+ if (clip != null) return clip.pixels().containsPoint(x * this.viewport.scale(), y * this.viewport.scale()); -+ return super.containsPointInScissor(this.viewport.pixel(x), this.viewport.pixel(y)); -+ } ++ /** Native components exist before deferred references bind their named controls. */ ++ public State(final NativeUiButton definition, final BiConsumer visibility) { ++ this.definition = Objects.requireNonNull(definition); ++ this.visibility = Objects.requireNonNull(visibility); ++ } + -+ @Override -+ public void entity(final EntityRenderState state, final float scale, final Vector3fc translation, -+ final Quaternionfc rotation, final Quaternionfc cameraRotation, -+ final int left, final int top, final int right, final int bottom) { -+ // Picture-in-picture extraction does not apply the 2D pose, unlike images, items and text. -+ super.entity(state, scale * this.viewport.renderScale(), translation, rotation, cameraRotation, -+ this.viewport.pixel(left), this.viewport.pixel(top), this.viewport.pixel(right), this.viewport.pixel(bottom)); -+ } -+} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiHurtPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiHurtPayload.java -new file mode 100644 -index 0000000..5016ff0 ---- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiHurtPayload.java -@@ -0,0 +1,22 @@ -+/* -+ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock -+ * Copyright (C) 2026 ViaVersion and contributors -+ * SPDX-License-Identifier: GPL-3.0-or-later -+ */ -+package com.viaversion.viafabricplus.bedrock.screen.ui; ++ public boolean pressed() { return this.pressed; } ++ public long revision() { return this.revision; } + -+import net.minecraft.network.RegistryFriendlyByteBuf; -+import net.minecraft.network.codec.StreamCodec; -+import net.minecraft.network.protocol.common.custom.CustomPacketPayload; -+import net.minecraft.resources.Identifier; -+import net.raphimc.viabedrock.api.model.NativeUiHurtEvent; ++ /** Replacing a Java scene node does not produce another native input or property event. */ ++ public void attach(final NativeUiScene.Node owner, final boolean enabled, final boolean hovered) { ++ if (this.owner == owner) return; ++ final boolean initial = this.owner == null; ++ this.owner = owner; ++ if (this.resolve(owner) || initial) this.update(enabled, hovered); ++ else this.apply(State::writeVisibility); ++ } + -+public record NativeUiHurtPayload(NativeUiHurtEvent event) implements CustomPacketPayload { -+ public static final Type TYPE = new Type<>(Identifier.parse(NativeUiHurtEvent.CHANNEL)); -+ public static final StreamCodec CODEC = CustomPacketPayload.codec( -+ (payload, buffer) -> NativeUiHurtEvent.TYPE.write(buffer, payload.event), -+ buffer -> new NativeUiHurtPayload(NativeUiHurtEvent.TYPE.read(buffer))); ++ public void button(final NativeUiInput.ButtonEvent event, final boolean enabled, final boolean hovered) { ++ if (!enabled || event.phase() != 0 && event.phase() != 1) return; ++ final boolean pressed = event.phase() == 1; ++ if (this.pressed == pressed) return; ++ this.pressed = pressed; ++ if (pressed ? event.handleSelect() : event.handleDeselect()) this.update(enabled, hovered); ++ } + -+ @Override -+ public Type type() { return TYPE; } -+} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiImageCache.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiImageCache.java -new file mode 100644 -index 0000000..1ffe21b ---- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiImageCache.java -@@ -0,0 +1,229 @@ -+/* -+ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock -+ * Copyright (C) 2026 ViaVersion and contributors -+ * SPDX-License-Identifier: GPL-3.0-or-later -+ */ -+package com.viaversion.viafabricplus.bedrock.screen.ui; ++ public void hover(final boolean hovered, final boolean enabled) { ++ if (!enabled) return; ++ if (!hovered) this.pressed = false; ++ this.update(enabled, hovered); ++ } + -+import java.awt.image.BufferedImage; -+import java.io.ByteArrayInputStream; -+import java.io.ByteArrayOutputStream; -+import java.io.IOException; -+import java.net.URI; -+import java.net.http.HttpClient; -+import java.net.http.HttpRequest; -+import java.net.http.HttpResponse; -+import java.nio.ByteBuffer; -+import java.nio.file.Files; -+import java.nio.file.Path; -+import java.nio.file.StandardCopyOption; -+import java.security.MessageDigest; -+import java.time.Duration; -+import java.util.HexFormat; -+import java.util.LinkedHashMap; -+import java.util.List; -+import java.util.Map; -+import java.util.concurrent.CompletableFuture; -+import java.util.concurrent.CompletionStage; -+import java.util.concurrent.Executor; -+import java.util.concurrent.Flow; -+import java.util.concurrent.TimeUnit; -+import java.util.function.Predicate; -+import javax.imageio.ImageIO; -+ -+/** Bounded asynchronous public images, cached by target version and complete URL without account credentials. */ -+public final class NativeUiImageCache { -+ public static final int MAX_BYTES = 4 * 1024 * 1024; -+ public static final int MAX_PIXELS = 4 * 1024 * 1024; -+ private static final int MAX_ENTRIES = 128; -+ private static final long MAX_CACHE_BYTES = 64L * 1024 * 1024; -+ private static final Duration AGE = Duration.ofDays(7); -+ private final Path directory; -+ private final HttpClient http; -+ private final Executor executor; -+ private final Predicate allowed; -+ private final Map> pending = new LinkedHashMap<>(16, .75F, true); -+ -+ public NativeUiImageCache(final Path directory, final String version, final HttpClient http, final Executor executor) { -+ this(directory, version, http, executor, NativeUiImageCache::https); -+ } -+ -+ // Tests use a separately owned loopback server; production always uses the HTTPS policy above. -+ public NativeUiImageCache(final Path directory, final String version, final HttpClient http, -+ final Executor executor, final Predicate allowed) { -+ if (!version.matches("[A-Za-z0-9._-]{1,64}")) throw new IllegalArgumentException("Invalid image cache version"); -+ this.directory = directory.resolve(version); -+ this.http = http; -+ this.executor = executor; -+ this.allowed = allowed; -+ } ++ public void reset(final boolean enabled, final boolean hovered) { ++ if (!enabled) return; ++ this.pressed = false; ++ this.update(enabled, hovered); ++ } + -+ synchronized CompletableFuture load(final String url) { -+ final URI uri; -+ try { -+ uri = URI.create(url); -+ if (url.length() > 4096 || !this.allowed.test(uri)) throw new IllegalArgumentException("Unsupported image URL"); -+ } catch (final RuntimeException error) { -+ return CompletableFuture.failedFuture(error); ++ public void visibility(final boolean visible, final boolean enabled, final boolean hovered) { ++ if (visible) return; ++ this.pressed = false; ++ this.update(enabled, hovered); + } -+ final var existing = this.pending.get(uri); -+ if (existing != null && !existing.isCompletedExceptionally()) return existing; -+ if (existing != null) this.pending.remove(uri, existing); -+ this.pruneMemory(); -+ if (this.pending.size() >= MAX_ENTRIES) return CompletableFuture.failedFuture(new IOException("Too many menu images")); -+ final CompletableFuture result; -+ try { -+ result = CompletableFuture.supplyAsync(() -> { -+ try { -+ final Path file = this.directory.resolve(hash(uri.toString()) + ".png"); -+ if (Files.isRegularFile(file)) { -+ try { -+ if (Files.size(file) <= MAX_BYTES && Files.getLastModifiedTime(file).toInstant().plus(AGE).isAfter(java.time.Instant.now())) -+ try (final var input = Files.newInputStream(file)) { return decode(input.readNBytes(MAX_BYTES + 1)); } -+ } catch (final IOException | RuntimeException invalid) { -+ // Replace an incomplete or corrupt entry with a fresh bounded download. -+ } -+ } -+ final Data data = decode(this.download(uri, 0, System.nanoTime() + TimeUnit.SECONDS.toNanos(20))); -+ this.store(file, data.bytes); -+ return data; -+ } catch (final Exception error) { -+ throw new java.util.concurrent.CompletionException(error); -+ } -+ }, this.executor); -+ } catch (final java.util.concurrent.RejectedExecutionException busy) { -+ return CompletableFuture.failedFuture(new IOException("Menu image queue is full", busy)); ++ ++ public void enabled(final boolean enabled) { ++ if (!enabled) this.pressed = false; + } -+ this.pending.put(uri, result); -+ result.whenComplete((data, failure) -> { -+ synchronized (this) { -+ if (failure != null) this.pending.remove(uri, result); -+ this.pruneMemory(); -+ } -+ }); -+ return result; -+ } + -+ private byte[] download(final URI uri, final int redirects, final long deadline) throws Exception { -+ if (!this.allowed.test(uri)) throw new IOException("Unsupported image redirect"); -+ final long remaining = deadline - System.nanoTime(); -+ if (remaining <= 0) throw new java.util.concurrent.TimeoutException("Menu image request timed out"); -+ final var request = HttpRequest.newBuilder(uri).timeout(Duration.ofNanos(Math.min(remaining, TimeUnit.SECONDS.toNanos(15)))) -+ .header("Accept", "image/png,image/jpeg").GET().build(); -+ final var responseFuture = this.http.sendAsync(request, info -> new LimitedBody()); -+ final HttpResponse response; -+ try { response = responseFuture.get(remaining, TimeUnit.NANOSECONDS); } -+ finally { if (!responseFuture.isDone()) responseFuture.cancel(true); } -+ if (response.statusCode() >= 300 && response.statusCode() <= 399) { -+ if (redirects >= 3) throw new IOException("Too many image redirects"); -+ final String location = response.headers().firstValue("Location").orElseThrow(() -> new IOException("Missing image redirect")); -+ return this.download(uri.resolve(location), redirects + 1, deadline); ++ public void general(final int kind, final boolean enabled, final boolean hovered) { ++ if (kind == 17) this.update(enabled, hovered); + } -+ if (response.statusCode() != 200) throw new IOException("Menu image request failed"); -+ return response.body(); -+ } + -+ public static boolean https(final URI uri) { -+ return "https".equalsIgnoreCase(uri.getScheme()) && uri.getHost() != null -+ && uri.getUserInfo() == null && uri.getFragment() == null; -+ } ++ private void update(final boolean enabled, final boolean hovered) { ++ this.visualEnabled = enabled; ++ this.visualHovered = hovered; ++ this.visualPressed = this.pressed; ++ this.apply(); ++ } + -+ public static Data decode(final byte[] bytes) throws IOException { -+ if (bytes.length == 0 || bytes.length > MAX_BYTES) throw new IOException("Menu image exceeds byte limits"); -+ try (final var input = ImageIO.createImageInputStream(new ByteArrayInputStream(bytes))) { -+ final var readers = ImageIO.getImageReaders(input); -+ if (!readers.hasNext()) throw new IOException("Unsupported menu image format"); -+ final var reader = readers.next(); -+ try { -+ if (!reader.getFormatName().equalsIgnoreCase("png") && !reader.getFormatName().equalsIgnoreCase("jpeg")) -+ throw new IOException("Unsupported menu image format"); -+ reader.setInput(input, true, true); -+ final int width = reader.getWidth(0), height = reader.getHeight(0); -+ if (width <= 0 || height <= 0 || width > 2048 || height > 2048 || (long) width * height > MAX_PIXELS) -+ throw new IOException("Menu image exceeds pixel limits"); -+ final BufferedImage pixels = reader.read(0); -+ final var output = new ByteArrayOutputStream(); -+ if (!ImageIO.write(pixels, "png", output) || output.size() > MAX_BYTES) -+ throw new IOException("Decoded menu image exceeds byte limits"); -+ return new Data(output.toByteArray(), width, height); -+ } finally { reader.dispose(); } ++ private void apply() { ++ this.apply(this.visibility); + } -+ } + -+ private void store(final Path file, final byte[] bytes) { -+ Path temporary = null; -+ try { -+ Files.createDirectories(this.directory); -+ temporary = Files.createTempFile(this.directory, "image-", ".tmp"); -+ Files.write(temporary, bytes); -+ Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); -+ try (final var paths = Files.list(this.directory)) { -+ final var entries = paths.filter(path -> path.getFileName().toString().matches("[0-9a-f]{64}\\.png")) -+ .sorted(java.util.Comparator.comparingLong(NativeUiImageCache::modified).reversed()).toList(); -+ long total = 0; -+ for (int i = 0; i < entries.size(); i++) { -+ total += Files.size(entries.get(i)); -+ if (i >= MAX_ENTRIES || total > MAX_CACHE_BYTES) Files.deleteIfExists(entries.get(i)); -+ } ++ private void apply(final BiConsumer visibility) { ++ // Setter order matters when authored state names resolve to the same control. ++ if (this.visualEnabled) { ++ this.visible(3, false, visibility); ++ this.visible(2, this.visualPressed, visibility); ++ this.visible(1, !this.visualPressed && this.visualHovered, visibility); ++ this.visible(0, !this.visualPressed && !this.visualHovered, visibility); ++ } else { ++ this.visible(3, true, visibility); ++ this.visible(0, this.controls[3] == null, visibility); ++ this.visible(2, false, visibility); ++ this.visible(1, false, visibility); + } -+ } catch (final IOException ignored) { -+ // Read-only or unavailable cache storage must not stop the current menu. -+ } finally { -+ if (temporary != null) try { Files.deleteIfExists(temporary); } catch (final IOException ignored) {} + } -+ } + -+ private synchronized void pruneMemory() { -+ long total = 0; -+ for (final var future : this.pending.values()) if (future.isDone() && !future.isCompletedExceptionally()) { -+ final Data data = future.getNow(null); -+ if (data != null) total += data.bytes.length; ++ private static void writeVisibility(final NativeUiScene.Node node, final boolean visible) { ++ node.properties().addProperty("visible", visible); + } -+ final var values = this.pending.entrySet().iterator(); -+ while (values.hasNext() && (total > MAX_CACHE_BYTES || this.pending.size() >= MAX_ENTRIES)) { -+ final var future = values.next().getValue(); -+ if (!future.isDone()) continue; -+ final Data data = future.isCompletedExceptionally() ? null : future.getNow(null); -+ if (data != null) total -= data.bytes.length; -+ values.remove(); ++ ++ private void visible(final int index, final boolean visible, final BiConsumer visibility) { ++ final var control = this.controls[index]; ++ if (control != null && control.node.flag("visible", true) != visible) { ++ visibility.accept(control.node, visible); ++ this.revision++; ++ } + } -+ } + -+ private static long modified(final Path path) { -+ try { return Files.getLastModifiedTime(path).toMillis(); } catch (final IOException missing) { return 0; } -+ } ++ private boolean resolve(final NativeUiScene.Node owner) { ++ final Reference[] resolved = new Reference[4]; ++ final ArrayDeque pending = new ArrayDeque<>(); ++ pending.add(new Reference(owner, List.of())); ++ while (!pending.isEmpty()) { ++ final var candidate = pending.removeFirst(); ++ for (int state = 0; state < resolved.length; state++) { ++ final String name = this.definition.controls.get(state); ++ if (resolved[state] == null && !name.isEmpty() && name.equals(candidate.node.name())) resolved[state] = candidate; ++ } ++ for (int index = 0; index < candidate.node.children().size(); index++) { ++ final List path = new ArrayList<>(candidate.path); ++ path.add(index); ++ pending.addLast(new Reference(candidate.node.children().get(index), List.copyOf(path))); ++ } ++ } ++ boolean changed = false; ++ for (int state = 0; state < resolved.length; state++) { ++ final var old = this.controls[state]; ++ final var next = resolved[state]; ++ changed |= !Objects.equals(old == null ? null : old.path, next == null ? null : next.path); ++ this.controls[state] = next; ++ } ++ return changed; ++ } + -+ private static String hash(final String value) { -+ try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8))); } -+ catch (final java.security.NoSuchAlgorithmException impossible) { throw new AssertionError(impossible); } ++ private record Reference(NativeUiScene.Node node, List path) {} + } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiCollectionWindow.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiCollectionWindow.java +new file mode 100644 +index 0000000..67d2f90 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiCollectionWindow.java +@@ -0,0 +1,92 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.ui; + -+ public record Data(byte[] bytes, int width, int height) {} ++import com.google.gson.JsonElement; ++import java.util.HashMap; ++import java.util.Map; ++import net.raphimc.viabedrock.api.resourcepack.ui.JsonUiProgram; ++import net.raphimc.viabedrock.api.resourcepack.ui.UiBindings; + -+ private static final class LimitedBody implements HttpResponse.BodySubscriber { -+ private final CompletableFuture result = new CompletableFuture<>(); -+ private final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); -+ private Flow.Subscription subscription; ++/** A bounded set of cell views retains the complete source's indices and scroll extent. */ ++public record NativeUiCollectionWindow(String key, int size, UiBindings.Window range, float cellWidth, float cellHeight) { ++ public static NativeUiCollectionWindow from(final NativeUiScene.Node node) { ++ if (!node.properties().has("collection_window_key")) return null; ++ final String key = node.string("collection_window_key", ""); ++ final int size = integer(node.properties().get("collection_size")); ++ final int start = integer(node.properties().get("collection_start")); ++ final int count = integer(node.properties().get("collection_count")); ++ final int retainedStart = node.properties().has("collection_retained_start") ? integer(node.properties().get("collection_retained_start")) : 0; ++ final int retainedCount = node.properties().has("collection_retained_count") ? integer(node.properties().get("collection_retained_count")) : 0; ++ final float width = NativeUiLayout.vector(node, "collection_cell_size", 0, 0); ++ final float height = NativeUiLayout.vector(node, "collection_cell_size", 1, 0); ++ if (!node.type().equals("grid") || !node.string("grid_rescaling_type", "").equals("horizontal") ++ || node.string("grid_fill_direction", "horizontal").equals("vertical") || node.properties().has("grid_dimensions") ++ || key.isEmpty() || key.length() > 16384 || size < 1 || size > JsonUiProgram.MAX_NODES ++ || start < 0 || start >= size || count < 1 || count > size - start || width <= 0 || height <= 0 ++ || retainedStart < 0 || retainedStart >= size || retainedCount < 0 || retainedCount > size - retainedStart) ++ throw new IllegalArgumentException("Invalid native collection window"); ++ final var range = new UiBindings.Window(start, count, retainedStart, retainedCount); ++ final var indices = range.indices(size); ++ if (indices.size() != node.children().size()) throw new IllegalArgumentException("Native collection window lost its cell views"); ++ for (int index = 0; index < node.children().size(); index++) ++ if (integer(node.children().get(index).properties().get("collection_index")) != indices.get(index)) ++ throw new IllegalArgumentException("Native collection window lost its source index"); ++ return new NativeUiCollectionWindow(key, size, range, width, height); ++ } + -+ @Override public CompletionStage getBody() { return this.result; } -+ @Override public void onSubscribe(final Flow.Subscription subscription) { -+ this.subscription = subscription; -+ subscription.request(1); -+ } -+ @Override public void onNext(final List buffers) { -+ for (final var buffer : buffers) { -+ if ((long) this.bytes.size() + buffer.remaining() > MAX_BYTES) { -+ this.subscription.cancel(); -+ this.result.completeExceptionally(new IOException("Menu image exceeds byte limits")); -+ return; ++ public static Map visible(final NativeUiLayout.Box root) { ++ return visible(root, null); ++ } ++ ++ public static Map visible(final NativeUiLayout.Box root, final NativeUiScene.Node focused) { ++ final Map result = new HashMap<>(); ++ collect(root, focused, result); ++ return Map.copyOf(result); ++ } ++ ++ private static void collect(final NativeUiLayout.Box box, final NativeUiScene.Node focused, final Map result) { ++ final var window = from(box.node()); ++ if (window != null) { ++ final var clip = box.visibleBounds(); ++ final int columns = Math.clamp((int) Math.floor(box.bounds().width() / window.cellWidth), 1, 128); ++ final int rows = Math.ceilDiv(window.size, columns); ++ final int firstRow = (int) Math.clamp(Math.floor(((double) clip.y() - box.bounds().y()) / window.cellHeight) - 1, 0, rows - 1); ++ final int endRow = (int) Math.clamp(Math.ceil(((double) clip.y() + clip.height() - box.bounds().y()) / window.cellHeight) + 1, firstRow + 1, rows); ++ final int start = clip.width() <= 0 || clip.height() <= 0 ? 0 : Math.min(window.size - 1, firstRow * columns); ++ final int end = clip.width() <= 0 || clip.height() <= 0 ? 1 : Math.min(window.size, endRow * columns); ++ int retainedStart = 0, retainedCount = 0; ++ if (focused != null && focused.properties().has("collection_index") && contains(box.node(), focused)) { ++ final int selected = integer(focused.properties().get("collection_index")); ++ if (selected >= 0 && selected < window.size) { ++ final int focusRow = selected / columns; ++ final int first = Math.max(0, focusRow - 1) * columns; ++ final int last = Math.min(window.size, (focusRow + 2) * columns); ++ if (first < start || last > end) { ++ retainedStart = first; ++ retainedCount = last - first; ++ } + } -+ final byte[] chunk = new byte[buffer.remaining()]; -+ buffer.get(chunk); -+ this.bytes.writeBytes(chunk); + } -+ this.subscription.request(1); ++ if (result.put(window.key, new UiBindings.Window(start, Math.max(1, end - start), retainedStart, retainedCount)) != null) ++ throw new IllegalArgumentException("Duplicate native collection window identity"); + } -+ @Override public void onError(final Throwable error) { this.result.completeExceptionally(error); } -+ @Override public void onComplete() { this.result.complete(this.bytes.toByteArray()); } ++ for (final var child : box.children()) collect(child, focused, result); ++ } ++ ++ private static boolean contains(final NativeUiScene.Node node, final NativeUiScene.Node selected) { ++ if (node == selected) return true; ++ for (final var child : node.children()) if (contains(child, selected)) return true; ++ return false; ++ } ++ ++ private static int integer(final JsonElement value) { ++ if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber()) ++ throw new IllegalArgumentException("Invalid native collection window integer"); ++ try { return value.getAsBigDecimal().intValueExact(); } ++ catch (final ArithmeticException invalid) { throw new IllegalArgumentException("Invalid native collection window integer", invalid); } + } +} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiImageRegions.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiImageRegions.java +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiColor.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiColor.java new file mode 100644 -index 0000000..3f3e908 +index 0000000..83c9295 --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiImageRegions.java -@@ -0,0 +1,132 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiColor.java +@@ -0,0 +1,55 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -6654,136 +7856,59 @@ index 0000000..3f3e908 +package com.viaversion.viafabricplus.bedrock.screen.ui; + +import com.google.gson.JsonElement; -+import java.util.ArrayList; -+import java.util.List; -+ -+/** Produces the native image component's ordered, signed destination and source regions. */ -+public final class NativeUiImageRegions { -+ private static final int[][] ORDER = {{0, 0}, {2, 0}, {0, 2}, {2, 2}, {0, 1}, {1, 0}, {2, 1}, {1, 2}, {1, 1}}; -+ -+ private NativeUiImageRegions() {} + -+ public static List regions(final NativeUiScene.Node node, final NativeUiLayout.Rect bounds, -+ final int textureWidth, final int textureHeight) { -+ return regions(node, bounds, new Source(NativeUiLayout.vector(node, "uv", 0, 0), NativeUiLayout.vector(node, "uv", 1, 0), -+ NativeUiLayout.vector(node, "uv_size", 0, textureWidth), NativeUiLayout.vector(node, "uv_size", 1, textureHeight))); -+ } ++/** Image-component RGBA, kept unclamped during native animation interpolation. */ ++record NativeUiColor(float red, float green, float blue, float alpha) { ++ static final NativeUiColor WHITE = new NativeUiColor(1, 1, 1, 1); + -+ static List regions(final NativeUiScene.Node node, final NativeUiLayout.Rect bounds, final Source source) { -+ final float u = source.u, v = source.v, sourceWidth = source.width, sourceHeight = source.height; -+ if (sourceWidth <= 0 || sourceHeight <= 0) return List.of(); -+ final int direction = switch (node.string("clip_direction", "none")) { -+ case "left" -> 1; -+ case "right" -> 2; -+ case "up" -> 3; -+ case "down" -> 4; -+ case "center" -> 5; -+ default -> 0; -+ }; -+ final float ratio = node.number("clip_ratio", 0); -+ if (direction != 0 && ratio == 1) return List.of(); -+ final boolean pixelPerfect = node.flag("clip_pixelperfect", true); -+ final JsonElement slice = node.properties().get(node.properties().has("nine_slice") ? "nine_slice" : "nineslice_size"); -+ float width = bounds.width(), height = bounds.height(); -+ if (slice == null && !(direction != 0 && ratio > 0) && node.flag("keep_ratio", true)) { -+ final float scale = Math.min(width / sourceWidth, height / sourceHeight); -+ width = sourceWidth * scale; -+ height = sourceHeight * scale; -+ } -+ final float x = bounds.x() + (bounds.width() - width) / 2; -+ final float y = bounds.y() + (bounds.height() - height) / 2; -+ if (slice == null) { -+ final Region region = new Region(x, y, x + width, y + height, u, v, u + sourceWidth, v + sourceHeight); -+ final Region clipped = direction != 0 && ratio > 0 ? clip(region, direction, ratio, pixelPerfect) : region; -+ return clipped.finite() ? List.of(clipped) : List.of(); -+ } -+ final float[] sides = new float[4]; -+ if (slice.isJsonArray() && slice.getAsJsonArray().size() == 4) { -+ for (int i = 0; i < 4; i++) sides[i] = slice.getAsJsonArray().get(i).getAsFloat(); -+ } else if (slice.isJsonPrimitive() && slice.getAsJsonPrimitive().isNumber()) java.util.Arrays.fill(sides, slice.getAsFloat()); -+ for (final float side : sides) if (!Float.isFinite(side) || side < 0 || side > 16384) -+ throw new IllegalArgumentException("Invalid native UI nine-slice"); -+ if (width == 0) { sides[0] = 0; sides[2] = 0; } -+ if (height == 0) { sides[1] = 0; sides[3] = 0; } -+ float baseWidth = NativeUiLayout.vector(node, "base_size", 0, 0); -+ float baseHeight = NativeUiLayout.vector(node, "base_size", 1, 0); -+ if (baseWidth == 0 || baseHeight == 0) { baseWidth = sourceWidth; baseHeight = sourceHeight; } -+ final float[] dx = {x, x + sides[0], x + width - sides[2], x + width}; -+ final float[] dy = {y, y + sides[1], y + height - sides[3], y + height}; -+ final float[] sx = {u, u + sides[0] * sourceWidth / baseWidth, u + sourceWidth - sides[2] * sourceWidth / baseWidth, u + sourceWidth}; -+ final float[] sy = {v, v + sides[1] * sourceHeight / baseHeight, v + sourceHeight - sides[3] * sourceHeight / baseHeight, v + sourceHeight}; -+ final List result = new ArrayList<>(9); -+ // Native nine-slice clipping keeps the facing border and trims individual edges and the center. -+ for (final int[] cell : ORDER) { -+ final Region region = new Region(dx[cell[0]], dy[cell[1]], dx[cell[0] + 1], dy[cell[1] + 1], -+ sx[cell[0]], sy[cell[1]], sx[cell[0] + 1], sy[cell[1] + 1]); -+ if (direction == 0 || ratio == 0) result.add(region); -+ else if (cell[0] != 1 && cell[1] != 1) { -+ if (ratio > 0 && retainedBorder(cell, direction)) result.add(region); -+ } else if (ratio > 0 && retainedBorder(cell, direction)) result.add(region); -+ else if (cell[0] == 1 && cell[1] == 1 || ratio > 0 && !discardedBorder(cell, direction)) -+ result.add(clip(region, direction, ratio, pixelPerfect)); ++ static NativeUiColor parse(final JsonElement value) { ++ if (value == null || value.isJsonNull()) return WHITE; ++ if (value.isJsonPrimitive() && value.getAsJsonPrimitive().isString()) { ++ return switch (value.getAsString()) { ++ case "red" -> new NativeUiColor(1, 0, 0, 1); ++ case "green" -> new NativeUiColor(0, 1, 0, 1); ++ case "blue" -> new NativeUiColor(0, 0, 1, 1); ++ case "purple" -> new NativeUiColor(1, 0, 1, 1); ++ case "cyan" -> new NativeUiColor(0, 1, 1, 1); ++ case "nil" -> new NativeUiColor(0, 0, 0, 0); ++ case "black" -> new NativeUiColor(0, 0, 0, 1); ++ case "yellow" -> new NativeUiColor(1, 1, 0, 1); ++ case "orange" -> new NativeUiColor(.85F, .5F, .2F, 1); ++ case "grey", "gray" -> new NativeUiColor(.5F, .5F, .5F, 1); ++ default -> WHITE; ++ }; + } -+ // Native pixel clipping divides by each source extent, including zero-width slice interiors. -+ // Keep its finite geometry but never submit the resulting NaN coordinates to the Java renderer. -+ result.removeIf(region -> !region.finite()); -+ return List.copyOf(result); -+ } -+ -+ private static boolean retainedBorder(final int[] cell, final int direction) { -+ return switch (direction) { -+ case 1 -> cell[0] == 0; -+ case 2 -> cell[0] == 2; -+ case 3 -> cell[1] == 0; -+ case 4 -> cell[1] == 2; -+ default -> false; -+ }; ++ if (!value.isJsonArray()) return WHITE; ++ final var channels = value.getAsJsonArray(); ++ return new NativeUiColor(channel(channels, 0), channel(channels, 1), channel(channels, 2), channel(channels, 3)); + } + -+ private static boolean discardedBorder(final int[] cell, final int direction) { -+ return switch (direction) { -+ case 1 -> cell[0] == 2; -+ case 2 -> cell[0] == 0; -+ case 3 -> cell[1] == 2; -+ case 4 -> cell[1] == 0; -+ default -> false; -+ }; ++ private static float channel(final com.google.gson.JsonArray values, final int index) { ++ if (index >= values.size() || values.get(index).isJsonNull()) return 1; ++ final float channel = values.get(index).getAsFloat(); ++ if (!Float.isFinite(channel)) throw new IllegalArgumentException("Non-finite native UI color"); ++ return channel; + } + -+ private static Region clip(final Region region, final int direction, final float ratio, final boolean pixelPerfect) { -+ final float sourceWidth = region.u1 - region.u0, sourceHeight = region.v1 - region.v0; -+ final float horizontal = direction == 1 || direction == 2 || direction == 5 -+ ? clipFraction(ratio, sourceWidth, pixelPerfect) : 0; -+ final float vertical = direction == 3 || direction == 4 || direction == 5 -+ ? clipFraction(ratio, sourceHeight, pixelPerfect) : 0; -+ final float shiftX = direction == 2 ? horizontal : direction == 5 ? horizontal / 2 : 0; -+ final float shiftY = direction == 4 ? vertical : direction == 5 ? vertical / 2 : 0; -+ final float width = region.x1 - region.x0, height = region.y1 - region.y0; -+ final float x = region.x0 + width * shiftX, y = region.y0 + height * shiftY; -+ final float u = region.u0 + sourceWidth * shiftX, v = region.v0 + sourceHeight * shiftY; -+ return new Region(x, y, x + (width - width * horizontal), y + (height - height * vertical), -+ u, v, u + (sourceWidth - sourceWidth * horizontal), v + (sourceHeight - sourceHeight * vertical)); ++ NativeUiColor interpolate(final NativeUiColor to, final float factor) { ++ return new NativeUiColor(this.red + (to.red - this.red) * factor, ++ this.green + (to.green - this.green) * factor, this.blue + (to.blue - this.blue) * factor, ++ this.alpha + (to.alpha - this.alpha) * factor); + } + -+ private static float clipFraction(final float ratio, final float sourceExtent, final boolean pixelPerfect) { -+ return pixelPerfect ? (float) Math.floor(ratio * sourceExtent) / sourceExtent : ratio; ++ int argb(final float opacity) { ++ return byteChannel(this.alpha * opacity) << 24 | byteChannel(this.red) << 16 ++ | byteChannel(this.green) << 8 | byteChannel(this.blue); + } + -+ record Source(float u, float v, float width, float height) {} -+ -+ public record Region(float x0, float y0, float x1, float y1, float u0, float v0, float u1, float v1) { -+ boolean finite() { -+ return Float.isFinite(x0) && Float.isFinite(y0) && Float.isFinite(x1) && Float.isFinite(y1) -+ && Float.isFinite(u0) && Float.isFinite(v0) && Float.isFinite(u1) && Float.isFinite(v1); -+ } -+ } ++ private static int byteChannel(final float value) { return Math.round(Math.clamp(value, 0, 1) * 255); } +} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiImages.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiImages.java +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiController.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiController.java new file mode 100644 -index 0000000..b106fad +index 0000000..1a59843 --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiImages.java -@@ -0,0 +1,193 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiController.java +@@ -0,0 +1,37 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -6791,198 +7916,78 @@ index 0000000..b106fad + */ +package com.viaversion.viafabricplus.bedrock.screen.ui; + -+import com.mojang.blaze3d.platform.NativeImage; -+import java.io.IOException; -+import java.net.http.HttpClient; -+import java.time.Duration; -+import java.util.HashMap; -+import java.util.LinkedHashMap; -+import java.util.Map; -+import java.util.UUID; -+import java.util.concurrent.ArrayBlockingQueue; -+import java.util.concurrent.CompletionException; -+import java.util.concurrent.ExecutorService; -+import java.util.concurrent.ThreadPoolExecutor; -+import java.util.concurrent.TimeUnit; -+import java.util.concurrent.atomic.AtomicLong; -+import net.minecraft.client.Minecraft; -+import net.minecraft.client.gui.GuiGraphicsExtractor; -+import net.minecraft.client.renderer.RenderPipelines; -+import net.minecraft.client.renderer.texture.DynamicTexture; -+import net.minecraft.resources.Identifier; -+import net.raphimc.viabedrock.api.resourcepack.ui.UiTextureLimits; -+import net.raphimc.viabedrock.protocol.data.ProtocolConstants; -+ -+/** Draws accepted pack textures and bounded public HTTPS images. */ -+public final class NativeUiImages implements AutoCloseable { -+ private static final int MAX_PACK_IMAGES = 256; -+ private static final ExecutorService DOWNLOADS = new ThreadPoolExecutor(4, 4, 0, TimeUnit.MILLISECONDS, -+ new ArrayBlockingQueue<>(256), Thread.ofPlatform().daemon().name("Bedrock UI images-", 0).factory(), -+ new ThreadPoolExecutor.AbortPolicy()); -+ private static final HttpClient HTTP = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)) -+ .followRedirects(HttpClient.Redirect.NEVER).build(); -+ private static NativeUiImageCache remoteCache; -+ private final Map remote = new HashMap<>(); -+ private final LinkedHashMap images = new LinkedHashMap<>(MAX_PACK_IMAGES, .75F, true); -+ private final LinkedHashMap failedImages = new LinkedHashMap<>(MAX_PACK_IMAGES, .75F, true); -+ private volatile boolean closed; -+ private final AtomicLong remotePixels = new AtomicLong(); -+ private boolean failed; -+ private long revision; -+ -+ private static synchronized NativeUiImageCache remoteCache() { -+ if (remoteCache == null) remoteCache = new NativeUiImageCache( -+ Minecraft.getInstance().gameDirectory.toPath().resolve(".cache/viabedrock/form-images"), -+ ProtocolConstants.BEDROCK_VERSION_NAME, HTTP, DOWNLOADS); -+ return remoteCache; -+ } -+ -+ public Image image(final NativeUiScene.Node node) { -+ return node.string("texture_file_system", "path").equals("url") -+ ? this.remote(node.string("texture", "")) : this.image(node.string("texture", ""), !node.flag("allow_debug_missing_texture", true)); -+ } -+ -+ private Image remote(final String url) { -+ final Remote existing = this.remote.get(url); -+ if (existing != null) return existing.image; -+ if (this.closed || this.remote.size() >= 128) { this.failed = true; return null; } -+ final Remote entry = new Remote(); -+ this.remote.put(url, entry); -+ final var future = remoteCache().load(url).thenApplyAsync(data -> { -+ if (this.closed) return null; -+ final long count = (long) data.width() * data.height(); -+ long previous; -+ do { -+ previous = this.remotePixels.get(); -+ if (previous + count > 16L * 1024 * 1024) -+ throw new CompletionException(new IOException("Menu images exceed pixel limits")); -+ } while (!this.remotePixels.compareAndSet(previous, previous + count)); -+ try { return NativeImage.read(data.bytes()); } -+ catch (final IOException | RuntimeException error) { -+ this.remotePixels.addAndGet(-count); -+ throw new CompletionException(error); -+ } -+ }, DOWNLOADS); -+ future.whenComplete((pixels, error) -> Minecraft.getInstance().execute(() -> { -+ if (this.closed) { -+ if (pixels != null) { this.remotePixels.addAndGet(-(long) pixels.getWidth() * pixels.getHeight()); pixels.close(); } -+ return; -+ } -+ if (error != null) { entry.failed = true; this.failed = true; this.revision++; return; } -+ final Identifier id = Identifier.fromNamespaceAndPath("viafabricplus-bedrock", "form/" + UUID.randomUUID()); -+ try { -+ Minecraft.getInstance().getTextureManager().register(id, new DynamicTexture(id::toString, pixels)); -+ entry.image = new Image(id, pixels.getWidth(), pixels.getHeight(), null); -+ } catch (final RuntimeException failure) { -+ this.remotePixels.addAndGet(-(long) pixels.getWidth() * pixels.getHeight()); -+ pixels.close(); -+ entry.failed = true; -+ this.failed = true; -+ } -+ this.revision++; -+ })); -+ return null; -+ } ++import java.util.List; + -+ public long revision() { return this.revision; } -+ public boolean failed() { return this.failed; } ++/** Domain state and responses for an authored screen; geometry and input belong to the shared screen. */ ++public interface NativeUiController { ++ String title(); ++ NativeUiScene.Node root(); ++ void validateVisibleControls(); ++ void validateResponseControls(List controls); ++ boolean current(); ++ Activation activate(NativeUiScene.Node node); ++ void cancel(); ++ void failed(RuntimeException error); + -+ @Override -+ public void close() { -+ this.closed = true; -+ for (final Remote entry : this.remote.values()) if (entry.image != null) { -+ Minecraft.getInstance().getTextureManager().release(entry.image.id); -+ this.remotePixels.addAndGet(-(long) entry.image.width * entry.image.height); -+ } -+ this.remote.clear(); -+ this.images.clear(); -+ this.failedImages.clear(); -+ } ++ default Activation dismiss() { this.cancel(); return Activation.CLOSE; } ++ default Inputs inputs() { return null; } ++ default void refresh() {} + -+ private static final class Remote { -+ Image image; -+ boolean failed; -+ } ++ enum Activation { STAY, CLOSE, REPLACED } + -+ public Image image(final String texture) { -+ return this.image(texture, false); ++ /** Editable values addressed by the controller's original input indices. */ ++ interface Inputs { ++ int maxLength(int index); ++ String text(int index); ++ String placeholder(int index); ++ boolean setText(int index, String value); ++ boolean isSlider(int index); ++ void slide(int index, double fraction); ++ void adjust(int index, int direction); + } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiCursorPlacement.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiCursorPlacement.java +new file mode 100644 +index 0000000..165c9f3 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiCursorPlacement.java +@@ -0,0 +1,30 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.ui; + -+ private Image image(final String texture, final boolean allowMissing) { -+ final Identifier id = Identifier.tryParse(texture); -+ if (id == null || !id.getNamespace().equals("viabedrock") || !id.getPath().startsWith("textures/")) -+ throw new IllegalArgumentException("Invalid native UI texture"); -+ final var resource = Minecraft.getInstance().getResourceManager().getResource(id).orElse(null); -+ if (resource == null) { -+ if (allowMissing) return null; -+ this.failed = true; -+ throw new IllegalArgumentException("Missing native UI texture: " + id); -+ } -+ if (this.failedImages.get(id) == resource.source()) -+ throw new IllegalArgumentException("Invalid native UI texture: " + id); -+ final Image cached = this.images.get(id); -+ if (cached != null && cached.source == resource.source()) return cached; -+ try (final var input = resource.open()) { -+ final Image image; -+ if (id.getPath().endsWith(".jpg")) { -+ final var decoded = NativeUiJpegTexture.decode(input); -+ final var textureResource = new NativeUiJpegTexture(id, decoded); -+ try { -+ Minecraft.getInstance().getTextureManager().registerAndLoad(id, textureResource); -+ } catch (final RuntimeException | Error failure) { -+ textureResource.close(); -+ throw failure; -+ } -+ image = new Image(id, decoded.getWidth(), decoded.getHeight(), resource.source()); -+ } else { -+ try (final NativeImage pixels = NativeImage.read(input)) { -+ if (!UiTextureLimits.supportsDimensions(pixels.getWidth(), pixels.getHeight())) -+ throw new IOException("Native UI texture exceeds size limits"); -+ image = new Image(id, pixels.getWidth(), pixels.getHeight(), resource.source()); -+ } -+ } -+ this.images.put(id, image); -+ if (this.images.size() > MAX_PACK_IMAGES) this.images.pollFirstEntry(); -+ this.failedImages.remove(id); -+ return image; -+ } catch (final IOException | RuntimeException error) { -+ this.failedImages.put(id, resource.source()); -+ if (this.failedImages.size() > MAX_PACK_IMAGES) this.failedImages.pollFirstEntry(); -+ this.failed = true; -+ throw new IllegalArgumentException("Invalid native UI texture: " + id, error); -+ } ++/** A resolved native type-3 anchor, including a renderer's inside-parent override. */ ++public record NativeUiCursorPlacement(float x, float y, boolean insideParent) { ++ public NativeUiCursorPlacement { ++ if (!Float.isFinite(x) || !Float.isFinite(y)) throw new IllegalArgumentException("Non-finite native UI anchor"); + } + -+ public void draw(final GuiGraphicsExtractor graphics, final NativeUiLayout.Box box, final int color, final NativeUiAnimations animations) { -+ final NativeUiScene.Node node = box.node(); -+ final Image image = this.image(node); -+ if (image == null) { -+ final Remote entry = this.remote.get(node.string("texture", "")); -+ if (entry != null && !entry.failed && !this.closed) graphics.fill(Math.round(box.bounds().x()), Math.round(box.bounds().y()), -+ Math.round(box.bounds().x() + box.bounds().width()), Math.round(box.bounds().y() + box.bounds().height()), -+ color & 0xFF000000 | 0x303030); -+ return; -+ } -+ for (final var region : NativeUiImageRegions.regions(node, box.bounds(), animations.source(node, image.width, image.height))) { -+ final int x0 = Math.round(Math.min(region.x0(), region.x1())), x1 = Math.round(Math.max(region.x0(), region.x1())); -+ final int y0 = Math.round(Math.min(region.y0(), region.y1())), y1 = Math.round(Math.max(region.y0(), region.y1())); -+ // Native solid nine-slices can stretch a single texture point across a nonempty region. -+ if (x0 == x1 || y0 == y1) continue; -+ final boolean reverseX = region.x0() > region.x1(), reverseY = region.y0() > region.y1(); -+ graphics.innerBlit(RenderPipelines.GUI_TEXTURED, image.id, x0, x1, y0, y1, -+ (reverseX ? region.u1() : region.u0()) / image.width, (reverseX ? region.u0() : region.u1()) / image.width, -+ (reverseY ? region.v1() : region.v0()) / image.height, (reverseY ? region.v0() : region.v1()) / image.height, color); ++ NativeUiLayout.Size offset(final NativeUiScene.Node node, final NativeUiLayout.Rect bounds, ++ final NativeUiLayout.Rect parent, final float offsetX, final float offsetY) { ++ // Bedrock 1.26.60.5 RenderComponent type 3: these are local offsets, ++ // not global positions. Centering takes precedence over parent fitting. ++ if (node.flag("follows_cursor", false)) { ++ return new NativeUiLayout.Size((this.x - bounds.width() * .5F) - (bounds.x() - offsetX), ++ (this.y - bounds.height() * .5F) - (bounds.y() - offsetY)); + } ++ if (!this.insideParent && !node.flag("follows_cursor_inside_parent", false)) return null; ++ if (bounds.width() == 0 || bounds.height() == 0) return new NativeUiLayout.Size(30000, 30000); ++ final float right = parent.width() < 0 ? parent.x() : parent.x() + parent.width(); ++ final float bottom = parent.height() < 0 ? parent.y() : parent.y() + parent.height(); ++ final float x = this.x + bounds.width() + 10 <= right ? this.x + 10 : (this.x - bounds.width()) - 10; ++ final float y = this.y + bounds.height() + 10 <= bottom ? this.y + 10 : (this.y - bounds.height()) - 10; ++ return new NativeUiLayout.Size((offsetX - bounds.x()) + x, (offsetY - bounds.y()) + y); + } -+ -+ public record Image(Identifier id, int width, int height, Object source) {} +} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiInput.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiInput.java +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiDimension.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiDimension.java new file mode 100644 -index 0000000..2b63181 +index 0000000..8affc07 --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiInput.java -@@ -0,0 +1,248 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiDimension.java +@@ -0,0 +1,93 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -6990,420 +7995,486 @@ index 0000000..2b63181 + */ +package com.viaversion.viafabricplus.bedrock.screen.ui; + -+import com.google.gson.JsonObject; -+import java.nio.charset.StandardCharsets; ++import com.google.gson.JsonElement; +import java.util.ArrayList; ++import java.util.EnumMap; +import java.util.List; -+import java.util.function.Consumer; ++import java.util.regex.Pattern; + -+/** Native mapping factory fields, event phases, pointer scopes and double-press state. */ -+public record NativeUiInput(Options options, List mappings, List hoverBindings) { -+ public static NativeUiInput parse(final JsonObject properties) { -+ final List mappings = new ArrayList<>(); -+ final List hover = new ArrayList<>(); -+ boolean wildcard = false; -+ Scope wildcardScope = Scope.DEFAULT; -+ final var entries = properties.get("button_mappings"); -+ if (entries != null && entries.isJsonArray()) for (final var value : entries.getAsJsonArray()) { -+ if (!value.isJsonObject()) continue; -+ final var mapping = value.getAsJsonObject(); -+ if (flag(mapping, "ignored", false)) continue; -+ final String source = string(mapping, "from_button_id"); -+ final String target = string(mapping, "to_button_id"); -+ final Kind kind = switch (string(mapping, "mapping_type")) { -+ case "global" -> Kind.GLOBAL; -+ case "double_pressed" -> Kind.DOUBLE_PRESSED; -+ case "focused" -> Kind.FOCUSED; -+ default -> Kind.PRESSED; -+ }; -+ final Scope scope = switch (string(mapping, "scope")) { -+ case "view" -> Scope.VIEW; -+ case "global" -> Scope.GLOBAL; -+ default -> Scope.DEFAULT; -+ }; -+ if (source.equals("any")) { -+ wildcard = true; -+ wildcardScope = scope; -+ } else if (source.isEmpty() || target.isEmpty()) { -+ if (source.isEmpty() && !target.isEmpty() && kind == Kind.PRESSED) hover.add(new HoverBinding(symbol(target), scope)); -+ } else { -+ final Condition condition = switch (string(mapping, "input_mode_condition")) { -+ case "not_gamepad" -> Condition.NOT_GAMEPAD; -+ case "gamepad" -> Condition.GAMEPAD; -+ default -> Condition.ANY; -+ }; -+ mappings.add(new Mapping(symbol(source), symbol(target), kind, scope, -+ flag(mapping, "button_up_right_of_first_refusal", false), flag(mapping, "handle_select", true), -+ flag(mapping, "handle_deselect", true), flag(mapping, "alternate_input_scope", false), -+ flag(mapping, "consume_event", true), condition, flag(mapping, "ignore_input_scope", false))); -+ } ++/** Authored dimension coefficients, resolved against the current layout after animation sampling. */ ++record NativeUiDimension(List terms) { ++ private static final Pattern EXPRESSION = Pattern.compile("([+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+))(%sm|%cm|%c|%x|%y|%|px)?"); ++ ++ NativeUiDimension { terms = List.copyOf(terms); } ++ ++ static NativeUiDimension parse(final JsonElement value) { ++ if (!value.isJsonPrimitive()) throw new IllegalArgumentException("Unsupported native UI dimension"); ++ if (value.getAsJsonPrimitive().isNumber()) return new NativeUiDimension(List.of(new Term(Unit.PIXELS, finite(value.getAsFloat())))); ++ final String expression = value.getAsString().replace(" ", ""); ++ if (expression.equals("default")) return new NativeUiDimension(List.of(new Term(Unit.DEFAULT, 1))); ++ if (expression.equals("fill")) return new NativeUiDimension(List.of(new Term(Unit.FILL, 1))); ++ final var matcher = EXPRESSION.matcher(expression); ++ final var terms = new ArrayList(); ++ int end = 0; ++ while (matcher.find()) { ++ if (matcher.start() != end || end != 0 && expression.charAt(end) != '+' && expression.charAt(end) != '-') ++ throw new IllegalArgumentException("Unsupported native UI dimension expression"); ++ final String unit = matcher.group(2); ++ terms.add(new Term(switch (unit == null ? "px" : unit) { ++ case "%" -> Unit.PARENT; ++ case "%c" -> Unit.CHILDREN; ++ case "%cm" -> Unit.MAX_CHILD; ++ case "%x" -> Unit.WIDTH; ++ case "%y" -> Unit.HEIGHT; ++ case "%sm" -> Unit.SIBLING; ++ default -> Unit.PIXELS; ++ }, finite(Float.parseFloat(matcher.group(1))))); ++ end = matcher.end(); + } -+ final var options = new Options(wildcardScope, flag(properties, "always_handle_pointer", false), -+ flag(properties, "always_handle_controller_direction", false), wildcard, -+ flag(properties, "always_listen_to_input", false), flag(properties, "modal", false), -+ flag(properties, "inline_modal", false), flag(properties, "hover_enabled", true), -+ flag(properties, "consume_hover_events", true), flag(properties, "prevent_touch_input", false), -+ flag(properties, "gamepad_deflection_mode", false)); -+ return new NativeUiInput(options, List.copyOf(mappings), List.copyOf(hover)); ++ if (end == 0 || end != expression.length()) throw new IllegalArgumentException("Unsupported native UI dimension expression"); ++ return new NativeUiDimension(List.copyOf(terms)); + } + -+ private static String string(final JsonObject properties, final String key) { -+ final var value = properties.get(key); -+ return value != null && value.isJsonPrimitive() && value.getAsJsonPrimitive().isString() ? value.getAsString() : ""; ++ boolean uses(final Unit unit) { return this.terms.stream().anyMatch(term -> term.unit == unit); } ++ ++ float resolve(final float parent, final float content, final float maximum, final float width, ++ final float height, final float sibling, final float fallback) { ++ float result = 0; ++ for (final var term : this.terms) result += term.amount * switch (term.unit) { ++ case PIXELS -> 1; ++ case PARENT -> parent / 100; ++ case CHILDREN -> content / 100; ++ case MAX_CHILD -> maximum / 100; ++ case WIDTH -> width / 100; ++ case HEIGHT -> height / 100; ++ case SIBLING -> sibling / 100; ++ case DEFAULT -> Math.max(0, fallback); ++ case FILL -> Math.max(0, parent); ++ }; ++ return finite(result); + } + -+ private static boolean flag(final JsonObject properties, final String key, final boolean fallback) { -+ return NativeUiScene.flag(properties, key, fallback); ++ NativeUiDimension interpolate(final NativeUiDimension to, final float factor) { ++ final var fromTerms = coefficients(this); ++ final var toTerms = coefficients(to); ++ final var result = new ArrayList(); ++ // Native constructors merge the endpoint bases, inserting zero coefficients for missing units. ++ for (final var unit : Unit.values()) if (fromTerms.containsKey(unit) || toTerms.containsKey(unit)) { ++ final float from = fromTerms.getOrDefault(unit, 0F); ++ final float end = toTerms.getOrDefault(unit, 0F); ++ result.add(new Term(unit, finite(from + (end - from) * factor))); ++ } ++ return new NativeUiDimension(List.copyOf(result)); + } + -+ public static int symbol(final String value) { -+ int result = 0x811c9dc5; -+ for (final byte octet : value.getBytes(StandardCharsets.UTF_8)) result = (result ^ Byte.toUnsignedInt(octet)) * 0x01000193; ++ private static EnumMap coefficients(final NativeUiDimension dimension) { ++ final var result = new EnumMap(Unit.class); ++ for (final var term : dimension.terms) result.merge(term.unit, term.amount, Float::sum); + return result; + } + -+ public static final class State { -+ private final NativeUiInput definition; -+ private final int[] phases; -+ private final java.util.Map presses = new java.util.HashMap<>(); -+ -+ public State(final NativeUiInput definition) { -+ this.definition = definition; -+ this.phases = new int[definition.mappings.size()]; -+ } ++ private static float finite(final float value) { ++ if (!Float.isFinite(value)) throw new IllegalArgumentException("Non-finite native UI dimension"); ++ return value; ++ } + -+ public int phase(final int index) { return this.phases[index]; } ++ enum Unit { PIXELS, PARENT, CHILDREN, MAX_CHILD, WIDTH, HEIGHT, SIBLING, DEFAULT, FILL } ++ record Term(Unit unit, float amount) {} ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiFlipbook.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiFlipbook.java +new file mode 100644 +index 0000000..399e5e8 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiFlipbook.java +@@ -0,0 +1,47 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.ui; + -+ /** Visibility callbacks and input-order removal preserve double-press history. */ -+ public void invalidateMappings() { java.util.Arrays.fill(this.phases, 2); } ++/** Native image flipbooks consume at most one frame interval per update. */ ++final class NativeUiFlipbook { ++ private final float interval; ++ private final int count; ++ private final boolean looping, reversible, vertical; ++ private float elapsed, frame; ++ private boolean reversed; + -+ /** Dispatches an Input component without sibling event handlers. */ -+ public Dispatch dispatch(final int source, final int phase, final Context context) { -+ return this.dispatch(source, phase, context, _ -> { }); -+ } ++ NativeUiFlipbook(final float fps, final int count, final boolean looping, final boolean reversible, final boolean vertical) { ++ this.interval = 1 / Math.max(.1F, fps); ++ this.count = count; ++ this.looping = looping; ++ this.reversible = reversible; ++ this.vertical = vertical; ++ } + -+ /** Sibling components run before the sender commits its accepted mapping phase. */ -+ public Dispatch dispatch(final int source, final int phase, final Context context, final Consumer handler) { -+ final List events = new ArrayList<>(); -+ int result = 0; -+ if (this.definition.options.wildcard) { -+ final var event = event(source, source, 2, phase, context.mode, this.definition.options.wildcardScope, false, false); -+ handler.accept(event); -+ events.add(event); -+ result = 1; -+ } -+ final boolean doubled = this.doublePress(source, phase, context); -+ for (int index = 0; index < this.definition.mappings.size(); index++) { -+ final var mapping = this.definition.mappings.get(index); -+ if (mapping.source != source || doubled != (mapping.kind == Kind.DOUBLE_PRESSED || context.touchFallback)) continue; -+ final boolean condition = mapping.condition != Condition.NOT_GAMEPAD || context.mode != 3; -+ final boolean gamepad = mapping.condition != Condition.GAMEPAD || context.mode == 3; -+ final boolean ordinary = !doubled || context.touchFallback; -+ final boolean admitted = condition && gamepad && switch (mapping.kind) { -+ case GLOBAL -> true; -+ case FOCUSED -> (context.flags & 0x82) != 0; -+ case PRESSED -> ordinary && this.pointer(mapping, phase, this.phases[index], context); -+ case DOUBLE_PRESSED -> !ordinary && this.pointer(mapping, phase, this.phases[index], context); -+ }; -+ if (!admitted) { this.phases[index] = 2; result = 0; continue; } -+ final int previous = this.phases[index]; -+ final var event = event(mapping.source, mapping.target, previous, phase, context.mode, -+ mapping.scope, mapping.handleSelect, mapping.handleDeselect); -+ handler.accept(event); -+ events.add(event); -+ this.phases[index] = phase; -+ if ((context.handlerMask & 1) == 0) result = mapping.consume ? 1 : 2; -+ return new Dispatch(result, List.copyOf(events), phase == 1 && mapping.firstRefusal ? List.of(source) : List.of()); ++ boolean advance(final float delta) { ++ final float next = this.frame + 1; ++ if (next >= this.count && !this.looping) return false; ++ this.elapsed += delta; ++ if (this.elapsed >= this.interval) { ++ this.elapsed -= this.interval; ++ this.frame = next; ++ if (next >= this.count) { ++ this.frame = 0; ++ if (this.reversible) this.reversed = !this.reversed; + } -+ return new Dispatch(result, List.copyOf(events), List.of()); + } ++ return true; ++ } + -+ /** The caller supplies geometry/control admission; hover_enabled is checked by the native pointer entry, not this handler. */ -+ public Hover hover(final boolean previous, final boolean inside, final boolean forcedOut, final int handlerMask) { -+ final boolean hovered = !forcedOut && inside; -+ final boolean changed = previous != hovered; -+ final List events = new ArrayList<>(); -+ if (changed) for (final var binding : this.definition.hoverBindings) { -+ events.add(new ButtonEvent(0, binding.target, 0, hovered ? 1 : 0, false, 0, binding.scope, false, false)); -+ } -+ final boolean handled = hovered && (!changed || (handlerMask & 1) == 0); -+ return new Hover(forcedOut || handled && this.definition.options.consumeHoverEvents ? 1 : 0, -+ hovered, changed, List.copyOf(events)); -+ } ++ void reset() { ++ // The target reset clears the frame clock, but retains the current direction. ++ this.elapsed = this.frame = 0; ++ } + -+ public Pointer pointer(final boolean previous, final boolean inside, final boolean forcedOut, final int handlerMask) { -+ if (!this.definition.options.hoverEnabled) return new Pointer(0, new Hover(0, previous, false, List.of()), false); -+ final Hover hover = this.hover(previous, inside, forcedOut, handlerMask); -+ final boolean deliverPointer = !forcedOut || this.definition.options.alwaysHandlePointer; -+ return new Pointer(deliverPointer && (handlerMask & 1) != 0 ? 0 : hover.result, hover, deliverPointer); -+ } ++ float position() { return this.reversed ? this.count - this.frame - 1 : this.frame; } ++ int frames() { return this.count; } ++ boolean vertical() { return this.vertical; } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiFocus.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiFocus.java +new file mode 100644 +index 0000000..0d64363 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiFocus.java +@@ -0,0 +1,329 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.ui; + -+ public void suppress(final int source, final int phase, final Context context) { -+ final boolean doubled = this.doublePress(source, phase, context); -+ for (int index = 0; index < this.definition.mappings.size(); index++) { -+ final var mapping = this.definition.mappings.get(index); -+ if (mapping.source == source && doubled == (mapping.kind == Kind.DOUBLE_PRESSED || context.touchFallback)) this.phases[index] = 2; -+ } -+ } ++import com.google.gson.JsonElement; ++import com.google.gson.JsonObject; ++import java.lang.ref.WeakReference; ++import java.math.BigInteger; ++import java.util.ArrayDeque; ++import java.util.ArrayList; ++import java.util.IdentityHashMap; ++import java.util.List; ++import java.util.Locale; ++import java.util.Map; ++import java.util.function.Predicate; + -+ private boolean pointer(final Mapping mapping, final int phase, final int previous, final Context context) { -+ if (mapping.ignoreScope && (context.flags & 0x20) != 0) return true; -+ final boolean admitted = mapping.alternateScope ? context.insideAlternate -+ : (context.flags & 2) != 0 || this.definition.options.alwaysListenToInput && context.inside; -+ return admitted && (context.mode == 3 || context.insideClip) || phase == 0 && mapping.firstRefusal && previous == 1; -+ } ++/** Native directional selection, scroll contexts and focus-container policies for admitted controls. */ ++public final class NativeUiFocus { ++ private NativeUiFocus() {} + -+ private boolean doublePress(final int source, final int phase, final Context context) { -+ if (!context.touchFallback && this.definition.mappings.stream().noneMatch(mapping -> mapping.source == source && mapping.kind == Kind.DOUBLE_PRESSED)) return false; -+ final var history = this.presses.computeIfAbsent(source, key -> new PressHistory()); -+ if (phase != 1) { -+ if (history.phase != 2) return false; -+ if (phase == 0) history.phase = 0; -+ return true; ++ public static List targets(final NativeUiLayout.Box root, final Predicate admitted) { ++ final List result = new ArrayList<>(); ++ collect(root, null, List.of(), true, true, admitted, result); ++ return List.copyOf(result); ++ } ++ ++ private static void collect(final NativeUiLayout.Box box, final Clip inheritedClip, final List ancestors, final boolean parentVisible, ++ final boolean parentEnabled, final Predicate admitted, final List targets) { ++ final var node = box.node(); ++ final boolean visible = parentVisible && node.flag("visible", true) && !node.flag("ignored", false); ++ final boolean enabled = parentEnabled && node.flag("enabled", true); ++ if (!visible) return; ++ if (enabled && node.flag("focus_enabled", false) && admitted.test(node)) targets.add(new Target(box, inheritedClip, ancestors)); ++ final Clip clip = node.scroll() || node.flag("clips_children", false) ++ ? new Clip(node, node.scroll() ? box.scrollViewport() : box.bounds()) : inheritedClip; ++ final List nested = new ArrayList<>(ancestors); ++ nested.add(box); ++ for (final var child : box.children()) collect(child, clip, List.copyOf(nested), visible, enabled, admitted, targets); ++ } ++ ++ public static Target next(final List targets, final NativeUiScene.Node selected, ++ final Direction direction, final float viewportWidth, final float viewportHeight, ++ final History history) { ++ if (targets.isEmpty()) return null; ++ final Target current = targets.stream().filter(target -> target.box.node() == selected).findFirst().orElse(null); ++ if (current == null) return initial(targets, viewportWidth); ++ final String override = string(current.box.node(), direction.property); ++ if (override.equals("FOCUS_OVERRIDE_STOP")) return current; ++ Target target = null; ++ if (!override.isEmpty()) { ++ for (final Target candidate : targets) if (string(candidate.box.node(), "focus_identifier").equals(override)) return candidate; ++ } ++ boolean wrapping = current.box.node().flag("focus_wrap_enabled", true); ++ if (target == null) { ++ final Map indexed = new IdentityHashMap<>(); ++ for (final var candidate : targets) indexed.put(candidate.box.node(), candidate); ++ for (int index = current.ancestors.size() - 1; index >= 0; index--) { ++ final var ancestor = current.ancestors.get(index); ++ if (!ancestor.node().scroll()) continue; ++ final var viewport = NativeUiScroll.descendant(ancestor, string(ancestor.node(), "scroll_view_port")); ++ if (viewport == null) continue; ++ wrapping = false; ++ final List candidates = new ArrayList<>(); ++ subtree(viewport, indexed, candidates); ++ target = geometry(candidates, current, direction, viewportWidth, viewportHeight, false, false, false); ++ if (target != null) break; + } -+ final float dx = context.x - history.x, dy = context.y - history.y; -+ final float distance = (float) Math.sqrt(dx * dx + dy * dy); -+ final boolean doubled = history.phase != 0 && context.nanos - history.nanos <= 499_999_999L && distance < 10F; -+ history.phase = doubled ? 2 : 1; -+ history.nanos = context.nanos; -+ history.x = context.x; -+ history.y = context.y; -+ return doubled; + } -+ -+ private static ButtonEvent event(final int source, final int target, final int previous, final int phase, -+ final int mode, final Scope scope, final boolean select, final boolean deselect) { -+ final int activation = mode == 2 ? 0 : 1; -+ return new ButtonEvent(source, target, previous, phase, phase == activation && previous != activation, -+ mode, scope, select, deselect); ++ if (target == null) target = geometry(targets, current, direction, viewportWidth, viewportHeight, wrapping, true, false); ++ if (target == null) return current; ++ final var sourceGroup = group(current); ++ final var targetGroup = group(target); ++ if (sourceGroup == targetGroup && sourceGroup != null && !sourceGroup.flag("focus_wrap_enabled", true) ++ && backwards(current, target, direction)) return current; ++ if (sourceGroup == targetGroup || sourceGroup == null) return target; ++ final String mode = string(sourceGroup, "focus_navigation_mode_" + direction.name().toLowerCase(Locale.ROOT)); ++ if (mode.equals("stop")) return current; ++ if (mode.equals("custom")) { ++ final var entries = sourceGroup.properties().get("focus_container_custom_" + direction.name().toLowerCase(Locale.ROOT)); ++ if (entries == null || !entries.isJsonArray() || entries.getAsJsonArray().asList().stream().noneMatch(JsonElement::isJsonObject)) return target; ++ final Target custom = custom(targets, current, sourceGroup, direction, history); ++ if (custom != null) return custom; + } -+ -+ private static final class PressHistory { -+ private int phase; -+ private long nanos; -+ private float x; -+ private float y; ++ if (targetGroup == null) return target; ++ if (mode.equals("contained") && !within(target, sourceGroup)) return current; ++ if (targetGroup.flag("use_last_focus", false) && !within(current, targetGroup)) { ++ final Target remembered = history.remembered(targets, targetGroup); ++ if (remembered != null) return remembered; + } ++ return target; + } + -+ public record Bounds(float x, float y, float width, float height) { -+ public boolean contains(final float pointX, final float pointY) { -+ return pointX >= this.x && pointX <= this.x + this.width && pointY >= this.y && pointY <= this.y + this.height; ++ private static Target initial(final List targets, final float viewportWidth) { ++ int maximum = 0; ++ for (final var target : targets) maximum = Math.max(maximum, precedence(target.box.node())); ++ Target selected = null; ++ float nearest = Float.MAX_VALUE; ++ for (final var target : targets) { ++ if (precedence(target.box.node()) != maximum) continue; ++ final var bounds = target.box.bounds(); ++ final float score = bounds.y() * viewportWidth + bounds.x(); ++ if (score < nearest) { ++ selected = target; ++ nearest = score; ++ } + } ++ return selected; + } + -+ public record Ancestor(Bounds bounds, boolean clip, boolean scroll, Bounds alternateTrack) {} -+ -+ /** Ancestors are supplied nearest first; native input tests only the nearest clipping ancestor. */ -+ public record Geometry(Bounds bounds, List ancestors) { -+ public boolean insideClip(final float x, final float y) { -+ for (final var parent : this.ancestors) if (parent.clip) return parent.bounds.contains(x, y); -+ return true; -+ } -+ -+ public boolean insideAlternate(final int flags, final float x, final float y) { -+ if (this.ancestors.isEmpty()) return false; -+ final var parent = this.ancestors.getFirst(); -+ if (parent.scroll && (parent.alternateTrack == null || !parent.alternateTrack.contains(x, y))) return false; -+ return (flags & 0xc) != 0xc || !this.bounds.contains(x, y); ++ private static int precedence(final NativeUiScene.Node node) { ++ final var value = node.properties().get("default_focus_precedence"); ++ if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber()) return 0; ++ try { ++ // The native signed-int64 reader narrows to int32; unsigned-only and real values use zero. ++ final var integer = new BigInteger(value.getAsString()); ++ return integer.bitLength() <= 63 ? integer.intValue() : 0; ++ } catch (final NumberFormatException invalid) { ++ return 0; + } + } + -+ public record Context(int flags, int mode, float x, float y, boolean inside, boolean insideClip, -+ boolean insideAlternate, boolean touchFallback, long nanos, int handlerMask) {} -+ -+ /** Retains the native raw phase; only phase one is a pressed controller event. */ -+ public record ButtonEvent(int source, int target, int previous, int phase, boolean transition, int mode, -+ Scope scope, boolean handleSelect, boolean handleDeselect) { -+ public boolean pressed() { return this.phase == 1; } ++ private static void subtree(final NativeUiLayout.Box box, final Map targets, final List result) { ++ final var target = targets.get(box.node()); ++ if (target != null) result.add(target); ++ for (final var child : box.children()) subtree(child, targets, result); + } -+ public record Dispatch(int result, List events, List captureRequests) {} -+ public record Hover(int result, boolean hovered, boolean changed, List events) {} -+ public record Pointer(int result, Hover hover, boolean pointerEvent) {} -+ -+ public record Options(Scope wildcardScope, boolean alwaysHandlePointer, boolean alwaysHandleControllerDirection, -+ boolean wildcard, boolean alwaysListenToInput, boolean modal, boolean inlineModal, -+ boolean hoverEnabled, boolean consumeHoverEvents, boolean preventTouchInput, boolean gamepadDeflectionMode) {} -+ public record Mapping(int source, int target, Kind kind, Scope scope, boolean firstRefusal, boolean handleSelect, -+ boolean handleDeselect, boolean alternateScope, boolean consume, Condition condition, boolean ignoreScope) {} -+ public record HoverBinding(int target, Scope scope) {} -+ public enum Kind { GLOBAL, DOUBLE_PRESSED, PRESSED, FOCUSED } -+ public enum Scope { DEFAULT, VIEW, GLOBAL } -+ public enum Condition { ANY, NOT_GAMEPAD, GAMEPAD } -+} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiInputRouter.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiInputRouter.java -new file mode 100644 -index 0000000..8c0c6f6 ---- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiInputRouter.java -@@ -0,0 +1,72 @@ -+/* -+ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock -+ * Copyright (C) 2026 ViaVersion and contributors -+ * SPDX-License-Identifier: GPL-3.0-or-later -+ */ -+package com.viaversion.viafabricplus.bedrock.screen.ui; -+ -+import java.util.ArrayList; -+import java.util.HashMap; -+import java.util.LinkedHashMap; -+import java.util.List; -+import java.util.Map; -+import java.util.Set; -+import java.util.function.Consumer; -+import java.util.function.Supplier; + -+/** Routes captured releases before the current control order and retires skipped mapping phases. */ -+public final class NativeUiInputRouter { -+ private final Map> captures = new HashMap<>(); ++ private static boolean backwards(final Target current, final Target target, final Direction direction) { ++ final var source = current.box.bounds(); ++ final var result = target.box.bounds(); ++ return switch (direction) { ++ case UP -> source.y() < result.y(); ++ case DOWN -> result.y() < source.y(); ++ case LEFT -> source.x() < result.x(); ++ case RIGHT -> result.x() < source.x(); ++ }; ++ } + -+ public Result dispatch(final int source, final int phase, final List> controls) { -+ final Map> indexed = new LinkedHashMap<>(); -+ for (final var control : controls) indexed.put(control.key, control); -+ final List> events = new ArrayList<>(); -+ boolean consumed = false; -+ final var captured = phase != 0 ? this.captures.get(source) : this.captures.remove(source); -+ if (phase == 0 && captured != null) { -+ for (final K key : captured) { -+ final var control = indexed.get(key); -+ if (control == null) continue; -+ final var result = control.state.dispatch(source, 0, control.context.get(), control.handler); -+ for (final var event : result.events()) { -+ events.add(new Event<>(key, event)); -+ } -+ consumed = result.result() == 1; -+ if (consumed) break; -+ } -+ } -+ final List requested = new ArrayList<>(phase != 0 && captured != null ? captured : List.of()); -+ for (final var control : controls) { -+ if (!control.registered) continue; -+ if (consumed) { -+ control.state.suppress(source, phase, control.context.get()); -+ continue; -+ } -+ final var result = control.state.dispatch(source, phase, control.context.get(), control.handler); -+ for (final var event : result.events()) { -+ events.add(new Event<>(control.key, event)); -+ } -+ for (final int capture : result.captureRequests()) { -+ if (capture != source) throw new IllegalStateException("Native capture changed its source input"); -+ requested.add(control.key); -+ } -+ consumed = result.result() == 1; ++ private static NativeUiScene.Node group(final Target target) { ++ if (target.box.node().flag("focus_container", false)) return target.box.node(); ++ for (int i = target.ancestors.size() - 1; i >= 0; i--) { ++ final var node = target.ancestors.get(i).node(); ++ if (node.flag("focus_container", false)) return node; + } -+ if (!requested.isEmpty()) this.captures.put(source, List.copyOf(requested)); -+ return new Result<>(consumed, List.copyOf(events)); ++ return null; + } + -+ public void retain(final Set controls) { -+ this.captures.replaceAll((source, captured) -> captured.stream().filter(controls::contains).toList()); -+ this.captures.values().removeIf(List::isEmpty); ++ private static boolean within(final Target target, final NativeUiScene.Node ancestor) { ++ return target.ancestors.stream().anyMatch(parent -> parent.node() == ancestor); + } + -+ public boolean captures(final int source) { return this.captures.containsKey(source); } -+ -+ /** Captured controls remain addressable after they leave the normal input order. */ -+ public record Control(K key, NativeUiInput.State state, Supplier context, -+ Consumer handler, boolean registered) {} -+ public record Event(K key, NativeUiInput.ButtonEvent button) {} -+ public record Result(boolean consumed, List> events) {} -+} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiJpegTexture.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiJpegTexture.java -new file mode 100644 -index 0000000..a0dfa44 ---- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiJpegTexture.java -@@ -0,0 +1,83 @@ -+/* -+ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock -+ * Copyright (C) 2026 ViaVersion and contributors -+ * SPDX-License-Identifier: GPL-3.0-or-later -+ */ -+package com.viaversion.viafabricplus.bedrock.screen.ui; ++ private static Target custom(final List targets, final Target current, final NativeUiScene.Node sourceGroup, ++ final Direction direction, final History history) { ++ final var value = sourceGroup.properties().get("focus_container_custom_" + direction.name().toLowerCase(Locale.ROOT)); ++ if (value == null || !value.isJsonArray()) return null; ++ for (final var entry : value.getAsJsonArray()) { ++ if (!entry.isJsonObject()) continue; ++ final String other = string(entry.getAsJsonObject(), "other_focus_container_name"); ++ final String name = string(entry.getAsJsonObject(), "focus_id_inside"); ++ final List candidates = targets.stream().filter(candidate -> other.isEmpty() ++ || group(candidate) != null && group(candidate).name().equals(other)).toList(); ++ if (!name.isEmpty()) { ++ for (final var candidate : candidates) if (candidate.box.node().name().equals(name)) return candidate; ++ } else if (!other.isEmpty() && !candidates.isEmpty()) { ++ final var container = group(candidates.getFirst()); ++ if (container.flag("use_last_focus", false) && !within(current, container)) { ++ final var remembered = history.remembered(targets, container); ++ if (remembered != null) return remembered; ++ } ++ Target closest = null; ++ float distance = Float.MAX_VALUE; ++ final var origin = edges(current.box.bounds()); ++ for (final var candidate : candidates) { ++ final var center = edges(candidate.box.bounds()); ++ final float dx = center.centerX - origin.centerX, dy = center.centerY - origin.centerY; ++ final float squared = dx * dx + dy * dy; ++ if (squared < distance) { closest = candidate; distance = squared; } ++ } ++ if (closest != null) return closest; ++ } ++ } ++ return null; ++ } + -+import com.mojang.blaze3d.platform.NativeImage; -+import java.awt.image.BufferedImage; -+import java.io.ByteArrayInputStream; -+import java.io.IOException; -+import java.io.InputStream; -+import javax.imageio.ImageIO; -+import javax.imageio.stream.MemoryCacheImageInputStream; -+import net.minecraft.client.renderer.texture.ReloadableTexture; -+import net.minecraft.client.renderer.texture.TextureContents; -+import net.minecraft.resources.Identifier; -+import net.minecraft.server.packs.resources.ResourceManager; -+import net.raphimc.viabedrock.api.resourcepack.ui.UiTextureLimits; ++ public static final class History { ++ private final Map>> recent = new IdentityHashMap<>(); ++ private final Map> remembered = new IdentityHashMap<>(); ++ private Target selected; + -+/** Native JPEG resources keep their encoding in the pack and decode only when displayed. */ -+final class NativeUiJpegTexture extends ReloadableTexture { -+ private BufferedImage prepared; ++ public void select(final Target target) { ++ if (this.selected != null && (target == null || this.selected.box.node() != target.box.node())) { ++ final List groups = new ArrayList<>(); ++ if (this.selected.box.node().flag("focus_container", false)) groups.add(this.selected.box.node()); ++ for (final var box : this.selected.ancestors) if (box.node().flag("focus_container", false)) groups.add(box.node()); ++ for (final var group : groups) { ++ final var entries = this.recent.computeIfAbsent(group, _ -> new ArrayDeque<>()); ++ entries.addFirst(new WeakReference<>(this.selected.box.node())); ++ if (entries.size() > 50) entries.removeLast(); ++ if (target == null || group(this.selected) != group(target)) this.remembered.put(group, new WeakReference<>(this.selected.box.node())); ++ } ++ } ++ this.selected = target; ++ } + -+ NativeUiJpegTexture(final Identifier id, final BufferedImage prepared) { -+ super(id); -+ this.prepared = prepared; ++ public void remap(final Map replacements, final List targets) { ++ final Map>> updated = new IdentityHashMap<>(); ++ for (final var entry : this.recent.entrySet()) { ++ final var group = replacements.get(entry.getKey()); ++ if (group == null || !group.flag("focus_container", false)) continue; ++ final ArrayDeque> values = new ArrayDeque<>(); ++ for (final var node : entry.getValue()) values.addLast(new WeakReference<>(replacements.get(node.get()))); ++ updated.put(group, values); ++ } ++ this.recent.clear(); ++ this.recent.putAll(updated); ++ final Map> remembered = new IdentityHashMap<>(); ++ for (final var entry : this.remembered.entrySet()) { ++ final var group = replacements.get(entry.getKey()); ++ if (group != null && group.flag("focus_container", false)) remembered.put(group, new WeakReference<>(replacements.get(entry.getValue().get()))); ++ } ++ this.remembered.clear(); ++ this.remembered.putAll(remembered); ++ final var selected = this.selected == null ? null : replacements.get(this.selected.box.node()); ++ this.selected = targets.stream().filter(target -> target.box.node() == selected).findFirst().orElse(null); ++ } ++ ++ private Target remembered(final List targets, final NativeUiScene.Node group) { ++ final var reference = this.remembered.get(group); ++ final var preferred = reference == null ? null : reference.get(); ++ for (final var target : targets) if (target.box.node() == preferred) return target; ++ final var entries = this.recent.get(group); ++ if (entries != null) for (final var entry : entries) { ++ final var node = entry.get(); ++ for (final var target : targets) if (target.box.node() == node) return target; ++ } ++ return null; ++ } + } + -+ @Override -+ public TextureContents loadContents(final ResourceManager resources) throws IOException { -+ BufferedImage image = this.prepared; -+ this.prepared = null; -+ if (image == null) { -+ try (final var input = resources.getResourceOrThrow(this.resourceId()).open()) { -+ image = decode(input); ++ private static Target geometry(final List targets, final Target current, final Direction direction, ++ final float width, final float height, final boolean wrapping, final boolean clipCheck, final boolean wrapped) { ++ final Edges own = edges(current.box.bounds()); ++ final boolean vertical = direction == Direction.UP || direction == Direction.DOWN; ++ final boolean negative = direction == Direction.UP || direction == Direction.LEFT; ++ final float originX = !wrapped || vertical ? own.centerX : negative ? width : 0; ++ final float originY = !wrapped || !vertical ? own.centerY : negative ? height : 0; ++ final float probeX = wrapped || vertical ? originX ++ : negative ? originX - (own.halfWidth - 2) : originX + (own.halfWidth - 2); ++ final float probeY = wrapped || !vertical ? originY ++ : negative ? originY - (own.halfHeight - 2) : originY + (own.halfHeight - 2); ++ final float boundaryX = (negative ? own.left : own.right) - originX; ++ final float boundaryY = (negative ? own.top : own.bottom) - originY; ++ final float boundaryLength = length(boundaryX, boundaryY); ++ final float axis = vertical ? boundaryY : boundaryX; ++ final float threshold = Math.min(.02F, (negative ? -1 : 1) ++ * (boundaryLength > .0001F ? axis / boundaryLength : axis)); ++ Target closest = null, aligned = null; ++ float nearestDistance = 1_000_000, nearestAngle = 1_000_000; ++ for (final Target candidate : targets) { ++ if (candidate.box.node() == current.box.node() || wrapped && current.clip != null && candidate.clip != null ++ && current.clip.owner == candidate.clip.owner) continue; ++ final Edges bounds = edges(candidate.box.bounds()); ++ if (clipCheck && candidate.clip != null && !visibleCorner(candidate.clip.bounds, bounds)) continue; ++ final float x = vertical ? Math.clamp(probeX, bounds.left, bounds.right) ++ : negative ? bounds.right : bounds.left; ++ final float y = !vertical ? Math.clamp(probeY, bounds.top, bounds.bottom) ++ : negative ? bounds.bottom : bounds.top; ++ final float dx = x - probeX, dy = y - probeY; ++ final float distance = length(dx, dy); ++ final float cosine = distance > Math.ulp(1F) ? (negative ? -1 : 1) * ((vertical ? dy : dx) / distance) : 1; ++ if (!(threshold <= cosine)) continue; ++ final float angle = 1 - cosine; ++ if (angle < nearestAngle) { ++ aligned = candidate; ++ nearestAngle = angle; ++ } ++ if (distance < nearestDistance) { ++ closest = candidate; ++ nearestDistance = distance; + } + } -+ return new TextureContents(pixels(image), null); ++ if (closest != null) return closest; ++ if (aligned != null) return aligned; ++ return !wrapped && wrapping ++ ? geometry(targets, current, direction, width, height, wrapping, clipCheck, true) : null; + } + -+ @Override -+ public void close() { -+ this.prepared = null; -+ super.close(); ++ private static String string(final NativeUiScene.Node node, final String key) { ++ return string(node.properties(), key); + } + -+ static BufferedImage decode(final InputStream source) throws IOException { -+ final byte[] bytes = source.readNBytes(UiTextureLimits.MAX_ENCODED_BYTES + 1); -+ if (bytes.length > UiTextureLimits.MAX_ENCODED_BYTES) throw new IOException("Native UI texture exceeds byte limits"); -+ try (final var input = new MemoryCacheImageInputStream(new ByteArrayInputStream(bytes))) { -+ final var readers = ImageIO.getImageReaders(input); -+ if (!readers.hasNext()) throw new IOException("Unsupported native UI image"); -+ final var reader = readers.next(); -+ try { -+ reader.setInput(input, true, true); -+ if (!UiTextureLimits.supportsDimensions(reader.getWidth(0), reader.getHeight(0))) -+ throw new IOException("Native UI texture exceeds dimension limits"); -+ final BufferedImage image = reader.read(0); -+ if (image == null || !UiTextureLimits.supportsDimensions(image.getWidth(), image.getHeight())) -+ throw new IOException("Invalid native UI image"); -+ return image; -+ } finally { -+ reader.dispose(); -+ } -+ } ++ private static String string(final JsonObject properties, final String key) { ++ final var value = properties.get(key); ++ return value != null && value.isJsonPrimitive() && value.getAsJsonPrimitive().isString() ? value.getAsString() : ""; + } + -+ static NativeImage pixels(final BufferedImage image) { -+ final NativeImage pixels = new NativeImage(image.getWidth(), image.getHeight(), false); -+ try { -+ final int[] row = new int[image.getWidth()]; -+ for (int y = 0; y < image.getHeight(); y++) { -+ image.getRGB(0, y, image.getWidth(), 1, row, 0, image.getWidth()); -+ for (int x = 0; x < image.getWidth(); x++) pixels.setPixel(x, y, row[x]); -+ } -+ return pixels; -+ } catch (final RuntimeException | Error failure) { -+ pixels.close(); -+ throw failure; -+ } ++ private static float length(final float x, final float y) { ++ return (float) Math.sqrt(x * x + y * y); ++ } ++ ++ private static Edges edges(final NativeUiLayout.Rect rect) { ++ final float halfWidth = rect.width() * .5F, halfHeight = rect.height() * .5F; ++ final float x = rect.x() + halfWidth, y = rect.y() + halfHeight; ++ return new Edges(x, y, halfWidth, halfHeight, x - halfWidth, x + halfWidth, y - halfHeight, y + halfHeight); ++ } ++ ++ private static boolean visibleCorner(final NativeUiLayout.Rect clip, final Edges bounds) { ++ return contains(clip, bounds.left, bounds.top) || contains(clip, bounds.right, bounds.top) ++ || contains(clip, bounds.left, bounds.bottom) || contains(clip, bounds.right, bounds.bottom); ++ } ++ ++ private static boolean contains(final NativeUiLayout.Rect rect, final float x, final float y) { ++ return rect.x() <= x && x <= rect.x() + rect.width() && rect.y() <= y && y <= rect.y() + rect.height(); ++ } ++ ++ public record Clip(NativeUiScene.Node owner, NativeUiLayout.Rect bounds) {} ++ public record Target(NativeUiLayout.Box box, Clip clip, List ancestors) {} ++ private record Edges(float centerX, float centerY, float halfWidth, float halfHeight, ++ float left, float right, float top, float bottom) {} ++ ++ public enum Direction { ++ UP("focus_change_up"), DOWN("focus_change_down"), LEFT("focus_change_left"), RIGHT("focus_change_right"); ++ private final String property; ++ Direction(final String property) { this.property = property; } + } +} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiLayout.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiLayout.java +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..64b45fa +index 0000000..6e767d7 --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiLayout.java -@@ -0,0 +1,633 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiFont.java +@@ -0,0 +1,80 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -7411,638 +8482,596 @@ index 0000000..64b45fa + */ +package com.viaversion.viafabricplus.bedrock.screen.ui; + -+import com.google.gson.JsonElement; -+import java.util.ArrayList; -+import java.util.Collections; -+import java.util.HashMap; -+import java.util.IdentityHashMap; -+import java.util.List; -+import java.util.Map; -+import java.util.Set; -+import java.util.function.Function; ++/** Applies the native label size multiplier to the selected font's metrics and drawing. */ ++public final class NativeUiFont { ++ private NativeUiFont() {} + -+/** Resolves authored anchors and content dimensions independently of the rendering backend. */ -+public final class NativeUiLayout { -+ private static final float LIMIT = 16384; -+ private static final Size EMPTY = new Size(0, 0); -+ private final Metrics metrics; -+ private final NativeUiAnimations animations; -+ private final Map> measured = new IdentityHashMap<>(); -+ private final Map visibleNodes = new IdentityHashMap<>(); -+ private final Map windows = new IdentityHashMap<>(); -+ private Function placements; -+ private final Map cursorOffsets = new IdentityHashMap<>(); -+ private float verticalLimit; -+ private int work; ++ public static float scale(final NativeUiScene.Node node) { ++ final float multiplier = switch (node.string("font_size", "normal")) { ++ case "small" -> .5F; ++ case "large" -> 2F; ++ case "extra_large" -> 4F; ++ default -> 1F; ++ }; ++ final float scale = multiplier * node.number("font_scale_factor", 1); ++ if (!Float.isFinite(scale) || scale < 0) throw new IllegalArgumentException("Unsupported native UI font scale"); ++ return scale; ++ } + -+ public NativeUiLayout(final Metrics metrics) { -+ this(metrics, null); ++ /** Snap the label origin in physical pixels before applying its font-size transform. */ ++ public static Origin origin(final float x, final float y, final float fontScale, final int uiScale) { ++ return new Origin(snap(x, fontScale, uiScale), snap(y + 1, fontScale, uiScale)); + } + -+ public NativeUiLayout(final Metrics metrics, final NativeUiAnimations animations) { -+ this.metrics = metrics; -+ this.animations = animations; ++ public static Origin origin(final NativeUiScene.Node node, final float x, final float y, ++ final float fontScale, final int uiScale) { ++ final float displayScale = node.number("font_origin_scale", 1); ++ if (!Float.isFinite(displayScale) || displayScale <= 0) { ++ throw new IllegalArgumentException("Unsupported native font origin scale"); ++ } ++ final float grid = fontScale * displayScale; ++ if (!Float.isFinite(grid) || grid <= 0) { ++ throw new IllegalArgumentException("Unsupported native font origin grid"); ++ } ++ return origin(x, y, grid, uiScale); + } + -+ private NativeUiDimension dimensionValue(final NativeUiScene.Node node, final String key, final int axis) { -+ final NativeUiDimension animated = this.animations == null ? null : this.animations.dimension(node, key, axis); -+ if (animated != null) return animated; -+ final JsonElement pair = node.properties().get(key); -+ if (pair == null || !pair.isJsonArray() || pair.getAsJsonArray().size() != 2) return null; -+ return NativeUiDimension.parse(pair.getAsJsonArray().get(axis)); ++ private static float snap(final float coordinate, final float fontScale, final int uiScale) { ++ // Native converts scaled float coordinates to integers before multiplying by the inverse scale. ++ return (int) (coordinate * (uiScale / fontScale)) * (fontScale / uiScale); + } + -+ public Box layout(final NativeUiScene.Node node, final int width, final int height, -+ final Map scroll) { -+ return this.layout(node, width, height, scroll, _ -> null); ++ public record Origin(float x, float y) { } ++ ++ public static int wrapWidth(final NativeUiScene.Node node, final float width) { ++ final float scale = scale(node); ++ return scale == 0 ? Integer.MAX_VALUE : Math.max(1, (int) (width / scale)); + } + -+ public Box layout(final NativeUiScene.Node node, final int width, final int height, -+ final Map scroll, -+ final Function placements) { -+ this.placements = placements; -+ this.work = 0; -+ final Rect viewport = new Rect(0, 0, width, height); -+ Set hidden = Collections.newSetFromMap(new IdentityHashMap<>()); -+ final List> states = new ArrayList<>(); -+ while (true) { -+ this.measured.clear(); -+ this.visibleNodes.clear(); -+ this.windows.clear(); -+ this.cursorOffsets.clear(); -+ this.verticalLimit = LIMIT; -+ this.visibility(node, true, hidden); -+ final Size size = this.measure(node, width, height); -+ final Box result = this.place(node, viewport, size, 0, viewport, scroll, null); -+ // Native scrollbar callbacks change computed visibility and schedule layout again. -+ // Keep the declared dimensions intact so resize and content refresh can restore them. -+ final Set next = Collections.newSetFromMap(new IdentityHashMap<>()); -+ this.scrollVisibility(result, next); -+ if (sameNodes(hidden, next)) return this.cursorOffsets.isEmpty() ? result -+ : this.positionCursors(result, viewport, viewport, new IdentityHashMap<>()); -+ if (states.stream().anyMatch(state -> sameNodes(state, next))) { -+ throw new IllegalArgumentException("Native UI scrollbar layout does not settle"); -+ } -+ states.add(hidden); -+ hidden = next; ++ public static float lineHeight(final NativeUiScene.Node node) { ++ final float height = node.number("font_line_height", 10); ++ if (!Float.isFinite(height) || height <= 0 || height > 4096) { ++ throw new IllegalArgumentException("Unsupported native UI font line height"); + } ++ return height; + } + -+ private static boolean sameNodes(final Set first, final Set second) { -+ return first.size() == second.size() && first.stream().allMatch(second::contains); ++ /** 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)))); + } + -+ private void scrollVisibility(final Box box, final Set hidden) { -+ if (!this.visibleNodes.getOrDefault(box.node, true)) return; -+ if (box.node.scroll() && !box.node.flag("scrollbar_always_visible", false) && NativeUiScroll.maximum(box) == 0) { -+ final Box panel = NativeUiScroll.descendant(box, box.node.string("scroll_box_and_track_panel", "")); -+ if (panel != null && List.of("scroll_content", "scroll_view_port", "scrollbar_track", "scrollbar_box").stream() -+ .allMatch(property -> NativeUiScroll.descendant(box, box.node.string(property, "")) != null)) { -+ hidden.add(panel.node); -+ } -+ } -+ for (final var child : box.children) this.scrollVisibility(child, hidden); ++ public static NativeUiLayout.Size intrinsic(final NativeUiScene.Node node, final float width, ++ final int lines) { ++ final float scale = scale(node); ++ return new NativeUiLayout.Size(width * scale, lines * lineHeight(node) * scale); + } + -+ private void visibility(final NativeUiScene.Node node, final boolean inherited, final Set hidden) { -+ final boolean visible = inherited && node.flag("visible", true) && !node.flag("ignored", false) && !hidden.contains(node); -+ this.visibleNodes.put(node, visible); -+ final var window = NativeUiCollectionWindow.from(node); -+ if (window != null) { -+ this.windows.put(node, window); -+ this.verticalLimit += window.size() * window.cellHeight(); -+ } -+ for (final var child : node.children()) this.visibility(child, visible, hidden); ++ public static NativeUiLayout.Size constrained(final NativeUiScene.Node node, final float width, ++ final int lines) { ++ return new NativeUiLayout.Size(width, lines * lineHeight(node) * scale(node)); + } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiGraphics.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiGraphics.java +new file mode 100644 +index 0000000..24ce149 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiGraphics.java +@@ -0,0 +1,56 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.ui; + -+ private Size measure(final NativeUiScene.Node node, final float parentWidth, final float parentHeight) { -+ return this.measure(node, new Measurement(parentWidth, parentHeight, 0, 0, parentWidth, parentHeight)); ++import com.mojang.blaze3d.platform.cursor.CursorType; ++import net.minecraft.client.Minecraft; ++import net.minecraft.client.gui.GuiGraphicsExtractor; ++import net.minecraft.client.renderer.entity.state.EntityRenderState; ++import net.minecraft.client.renderer.state.gui.GuiRenderState; ++import org.joml.Quaternionfc; ++import org.joml.Vector3fc; ++ ++/** Extracts native units into the shared Java GUI render state, including deferred tooltips. */ ++public final class NativeUiGraphics extends GuiGraphicsExtractor { ++ private final NativeUiViewport viewport; ++ private final GuiGraphicsExtractor parent; ++ ++ public NativeUiGraphics(final Minecraft client, final GuiRenderState state, final GuiGraphicsExtractor parent, ++ final NativeUiViewport viewport, final int mouseX, final int mouseY) { ++ super(client, state, mouseX, mouseY); ++ this.viewport = viewport; ++ this.parent = parent; ++ this.pose().scale(viewport.renderScale(), viewport.renderScale()); + } + -+ private Size measure(final NativeUiScene.Node node, final Measurement environment) { -+ // The native visibility setter resets computed size metrics without changing authored dimensions. -+ if (!this.visibleNodes.getOrDefault(node, true)) return EMPTY; -+ final Map cache = this.measured.computeIfAbsent(node, _ -> new HashMap<>()); -+ final Size previous = cache.get(environment); -+ if (previous != null) return previous; -+ if (++this.work > 65536) throw new IllegalArgumentException("Native UI layout exceeds work limits"); -+ final boolean container = switch (node.type()) { -+ case "screen", "panel", "input_panel", "factory", "collection_panel", "scroll_view", "scroll_track", "scrollbar_box", "button", "toggle" -> true; -+ default -> false; -+ }; -+ final boolean inventoryButton = node.type().equals("button") && node.properties().has("inventory_actions"); -+ final boolean image = node.type().equals("image"); -+ final boolean ratioSize = image && node.flag("default_size_scales_to_ratio", false); -+ final Size intrinsic = container || inventoryButton || image && !ratioSize -+ ? new Size(environment.defaultWidth, environment.defaultHeight) : this.metrics.intrinsic(node); -+ final boolean defaultWidthAxis = defaultSize(node, 0); -+ final boolean defaultHeightAxis = defaultSize(node, 1); -+ float width = intrinsic.width, height = intrinsic.height; -+ for (int pass = 0; pass < 3; pass++) { -+ final float previousWidth = width, previousHeight = height; -+ // Resolve dimensions independent of children before descending. Measuring at the -+ // inherited extent first multiplies speculative passes through nested content panels. -+ if (!defaultWidthAxis && !childDimension(node, 0)) { -+ width = dimension(node, "size", 0, environment, width, height, 0, 0, intrinsic.width); -+ width = constrain(node, 0, width, environment, width, height, 0, 0); -+ } -+ if (!defaultHeightAxis && !childDimension(node, 1)) { -+ height = dimension(node, "size", 1, environment, width, height, 0, 0, intrinsic.height); -+ height = constrain(node, 1, height, environment, width, height, 0, 0); -+ } -+ final List children = this.children(node, width, height); -+ final Size content = this.content(node, children, width, height); -+ final float maximumWidth = children.stream().map(Size::width).max(Float::compare).orElse(0F); -+ final float maximumHeight = children.stream().map(Size::height).max(Float::compare).orElse(0F); -+ final boolean stack = node.type().equals("stack_panel"); -+ final boolean vertical = !node.string("orientation", "vertical").equals("horizontal"); -+ final float defaultWidth = ratioSize && defaultWidthAxis && !defaultHeightAxis && intrinsic.height > 0 -+ ? height * intrinsic.width / intrinsic.height -+ : stack ? vertical ? environment.width : content.width : intrinsic.width; -+ width = dimension(node, "size", 0, environment, width, height, content.width, maximumWidth, defaultWidth); -+ width = constrain(node, 0, width, environment, width, height, content.width, maximumWidth); -+ final float intrinsicHeight = container || inventoryButton || stack || image ? intrinsic.height : this.metrics.constrained(node, width).height; -+ final float defaultHeight = ratioSize && defaultHeightAxis && !defaultWidthAxis && intrinsic.width > 0 -+ ? width * intrinsic.height / intrinsic.width -+ : stack ? vertical ? content.height : environment.height -+ : node.type().equals("grid") && !node.string("grid_item_template", "").isEmpty() ? content.height : intrinsicHeight; -+ height = dimension(node, "size", 1, environment, width, height, content.height, maximumHeight, defaultHeight); -+ height = constrain(node, 1, height, environment, width, height, content.height, maximumHeight); -+ if (width == previousWidth && height == previousHeight) break; -+ } -+ final Size result = new Size(width, height); -+ cache.put(environment, result); -+ return result; ++ // Super's constructor creates the initial scissor in the render state's Java coordinates. ++ @Override public int guiWidth() { return this.viewport == null ? super.guiWidth() : this.viewport.width(); } ++ @Override public int guiHeight() { return this.viewport == null ? super.guiHeight() : this.viewport.height(); } ++ @Override public void requestCursor(final CursorType cursor) { this.parent.requestCursor(cursor); } ++ ++ NativeUiViewport viewport() { return this.viewport; } ++ ++ @Override ++ public void enableScissor(final int left, final int top, final int right, final int bottom) { ++ NativeUiScissor.enable(this, new NativeUiLayout.Rect(left, top, right - left, bottom - top)); + } + -+ private boolean childDimension(final NativeUiScene.Node node, final int axis) { -+ for (final String key : List.of("size", "min_size", "max_size")) { -+ final var value = this.dimensionValue(node, key, axis); -+ if (value != null && (value.uses(NativeUiDimension.Unit.CHILDREN) || value.uses(NativeUiDimension.Unit.MAX_CHILD))) return true; -+ } -+ return false; ++ @Override ++ public boolean containsPointInScissor(final int x, final int y) { ++ final var clip = NativeUiScissor.from(this.scissorStack.peek()); ++ if (clip != null) return clip.pixels().containsPoint(x * this.viewport.scale(), y * this.viewport.scale()); ++ return super.containsPointInScissor(this.viewport.pixel(x), this.viewport.pixel(y)); + } + -+ private boolean defaultSize(final NativeUiScene.Node node, final int axis) { -+ final var size = this.dimensionValue(node, "size", axis); -+ return size == null || size.uses(NativeUiDimension.Unit.DEFAULT); ++ @Override ++ public void entity(final EntityRenderState state, final float scale, final Vector3fc translation, ++ final Quaternionfc rotation, final Quaternionfc cameraRotation, ++ final int left, final int top, final int right, final int bottom) { ++ // Picture-in-picture extraction does not apply the 2D pose, unlike images, items and text. ++ super.entity(state, scale * this.viewport.renderScale(), translation, rotation, cameraRotation, ++ this.viewport.pixel(left), this.viewport.pixel(top), this.viewport.pixel(right), this.viewport.pixel(bottom)); + } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiHold.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiHold.java +new file mode 100644 +index 0000000..6e6af30 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiHold.java +@@ -0,0 +1,42 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.ui; + -+ private List children(final NativeUiScene.Node node, final float width, final float height) { -+ // Collection percentages use the complete grid, while default dimensions -+ // inherit a single cell. Nested controls then inherit that measured cell. -+ final float availableWidth = width; -+ final float availableHeight = height; -+ final List result = new ArrayList<>(); -+ final Grid collection = node.type().equals("grid") && !node.string("grid_item_template", "").isEmpty() -+ ? this.grid(node, width, height) : null; -+ final float defaultWidth = collection == null ? width : width / collection.columns; -+ final float defaultHeight = collection == null ? height : height / collection.rows; -+ for (final var child : node.children()) -+ result.add(this.measure(child, new Measurement(availableWidth, availableHeight, 0, 0, defaultWidth, defaultHeight))); -+ for (int i = 0; i < result.size(); i++) { -+ final var child = node.children().get(i); -+ if (!siblingDimension(child)) continue; -+ float siblingWidth = 0, siblingHeight = 0; -+ for (int other = 0; other < result.size(); other++) if (other != i) { -+ siblingWidth = Math.max(siblingWidth, result.get(other).width); -+ siblingHeight = Math.max(siblingHeight, result.get(other).height); -+ } -+ result.set(i, this.measure(child, new Measurement(availableWidth, availableHeight, siblingWidth, siblingHeight, defaultWidth, defaultHeight))); ++/** ScreenView's frame countdown is independent of the inventory quantity-selection clock. */ ++public final class NativeUiHold { ++ private long frame; ++ private float remaining; ++ private float repeat; ++ private float startX, startY; ++ private boolean active; ++ ++ public NativeUiHold(final long now) { this.frame = now; } ++ ++ public void result(final int mask, final float x, final float y) { ++ if ((mask & 8) != 0) return; ++ if ((mask & 2) != 0) { ++ this.active = true; ++ this.startX = x; ++ this.startY = y; ++ this.remaining = .3F; ++ this.repeat = 0; + } -+ if (node.type().equals("stack_panel")) { -+ final boolean vertical = !node.string("orientation", "vertical").equals("horizontal"); -+ final int axis = vertical ? 1 : 0; -+ float used = 0; -+ int fills = 0; -+ for (int i = 0; i < result.size(); i++) { -+ if (!this.visibleNodes.getOrDefault(node.children().get(i), true)) continue; -+ if (fill(node.children().get(i), axis)) fills++; -+ else used += vertical ? result.get(i).height : result.get(i).width; -+ } -+ if (fills > 0) { -+ final float remainder = Math.max(0, (vertical ? height : width) - used) / fills; -+ for (int i = 0; i < result.size(); i++) { -+ final var child = node.children().get(i); -+ if (!this.visibleNodes.getOrDefault(child, true) || !fill(child, axis)) continue; -+ float siblingWidth = 0, siblingHeight = 0; -+ if (siblingDimension(child)) for (int other = 0; other < result.size(); other++) if (other != i) { -+ siblingWidth = Math.max(siblingWidth, result.get(other).width); -+ siblingHeight = Math.max(siblingHeight, result.get(other).height); ++ if ((mask & 4) != 0) this.active = false; ++ } ++ ++ public Event frame(final long now, final float x, final float y) { ++ final float delta = (float) (now - this.frame) / 1_000_000_000F; ++ this.frame = now; ++ if (!this.active) return null; ++ this.remaining -= delta; ++ if (!(this.remaining < 0)) return null; ++ final float dx = this.startX - x, dy = this.startY - y; ++ this.remaining = this.repeat; ++ return new Event(this.startX, this.startY, dx, dy, Math.abs(dx) > 5F || Math.abs(dy) > 5F); ++ } ++ ++ public record Event(float startX, float startY, float dx, float dy, boolean moved) { } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiHurtPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiHurtPayload.java +new file mode 100644 +index 0000000..5016ff0 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiHurtPayload.java +@@ -0,0 +1,22 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.ui; ++ ++import net.minecraft.network.RegistryFriendlyByteBuf; ++import net.minecraft.network.codec.StreamCodec; ++import net.minecraft.network.protocol.common.custom.CustomPacketPayload; ++import net.minecraft.resources.Identifier; ++import net.raphimc.viabedrock.api.model.NativeUiHurtEvent; ++ ++public record NativeUiHurtPayload(NativeUiHurtEvent event) implements CustomPacketPayload { ++ public static final Type TYPE = new Type<>(Identifier.parse(NativeUiHurtEvent.CHANNEL)); ++ public static final StreamCodec CODEC = CustomPacketPayload.codec( ++ (payload, buffer) -> NativeUiHurtEvent.TYPE.write(buffer, payload.event), ++ buffer -> new NativeUiHurtPayload(NativeUiHurtEvent.TYPE.read(buffer))); ++ ++ @Override ++ public Type type() { return TYPE; } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiImageCache.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiImageCache.java +new file mode 100644 +index 0000000..1ffe21b +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiImageCache.java +@@ -0,0 +1,229 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.ui; ++ ++import java.awt.image.BufferedImage; ++import java.io.ByteArrayInputStream; ++import java.io.ByteArrayOutputStream; ++import java.io.IOException; ++import java.net.URI; ++import java.net.http.HttpClient; ++import java.net.http.HttpRequest; ++import java.net.http.HttpResponse; ++import java.nio.ByteBuffer; ++import java.nio.file.Files; ++import java.nio.file.Path; ++import java.nio.file.StandardCopyOption; ++import java.security.MessageDigest; ++import java.time.Duration; ++import java.util.HexFormat; ++import java.util.LinkedHashMap; ++import java.util.List; ++import java.util.Map; ++import java.util.concurrent.CompletableFuture; ++import java.util.concurrent.CompletionStage; ++import java.util.concurrent.Executor; ++import java.util.concurrent.Flow; ++import java.util.concurrent.TimeUnit; ++import java.util.function.Predicate; ++import javax.imageio.ImageIO; ++ ++/** Bounded asynchronous public images, cached by target version and complete URL without account credentials. */ ++public final class NativeUiImageCache { ++ public static final int MAX_BYTES = 4 * 1024 * 1024; ++ public static final int MAX_PIXELS = 4 * 1024 * 1024; ++ private static final int MAX_ENTRIES = 128; ++ private static final long MAX_CACHE_BYTES = 64L * 1024 * 1024; ++ private static final Duration AGE = Duration.ofDays(7); ++ private final Path directory; ++ private final HttpClient http; ++ private final Executor executor; ++ private final Predicate allowed; ++ private final Map> pending = new LinkedHashMap<>(16, .75F, true); ++ ++ public NativeUiImageCache(final Path directory, final String version, final HttpClient http, final Executor executor) { ++ this(directory, version, http, executor, NativeUiImageCache::https); ++ } ++ ++ // Tests use a separately owned loopback server; production always uses the HTTPS policy above. ++ public NativeUiImageCache(final Path directory, final String version, final HttpClient http, ++ final Executor executor, final Predicate allowed) { ++ if (!version.matches("[A-Za-z0-9._-]{1,64}")) throw new IllegalArgumentException("Invalid image cache version"); ++ this.directory = directory.resolve(version); ++ this.http = http; ++ this.executor = executor; ++ this.allowed = allowed; ++ } ++ ++ synchronized CompletableFuture load(final String url) { ++ final URI uri; ++ try { ++ uri = URI.create(url); ++ if (url.length() > 4096 || !this.allowed.test(uri)) throw new IllegalArgumentException("Unsupported image URL"); ++ } catch (final RuntimeException error) { ++ return CompletableFuture.failedFuture(error); ++ } ++ final var existing = this.pending.get(uri); ++ if (existing != null && !existing.isCompletedExceptionally()) return existing; ++ if (existing != null) this.pending.remove(uri, existing); ++ this.pruneMemory(); ++ if (this.pending.size() >= MAX_ENTRIES) return CompletableFuture.failedFuture(new IOException("Too many menu images")); ++ final CompletableFuture result; ++ try { ++ result = CompletableFuture.supplyAsync(() -> { ++ try { ++ final Path file = this.directory.resolve(hash(uri.toString()) + ".png"); ++ if (Files.isRegularFile(file)) { ++ try { ++ if (Files.size(file) <= MAX_BYTES && Files.getLastModifiedTime(file).toInstant().plus(AGE).isAfter(java.time.Instant.now())) ++ try (final var input = Files.newInputStream(file)) { return decode(input.readNBytes(MAX_BYTES + 1)); } ++ } catch (final IOException | RuntimeException invalid) { ++ // Replace an incomplete or corrupt entry with a fresh bounded download. ++ } + } -+ final float fillWidth = vertical ? availableWidth : remainder; -+ final float fillHeight = vertical ? remainder : availableHeight; -+ result.set(i, this.measure(child, new Measurement(fillWidth, fillHeight, -+ siblingWidth, siblingHeight, fillWidth, fillHeight))); ++ final Data data = decode(this.download(uri, 0, System.nanoTime() + TimeUnit.SECONDS.toNanos(20))); ++ this.store(file, data.bytes); ++ return data; ++ } catch (final Exception error) { ++ throw new java.util.concurrent.CompletionException(error); + } -+ } ++ }, this.executor); ++ } catch (final java.util.concurrent.RejectedExecutionException busy) { ++ return CompletableFuture.failedFuture(new IOException("Menu image queue is full", busy)); + } ++ this.pending.put(uri, result); ++ result.whenComplete((data, failure) -> { ++ synchronized (this) { ++ if (failure != null) this.pending.remove(uri, result); ++ this.pruneMemory(); ++ } ++ }); + return result; + } + -+ private boolean fill(final NativeUiScene.Node node, final int axis) { -+ final var size = this.dimensionValue(node, "size", axis); -+ return size != null && size.uses(NativeUiDimension.Unit.FILL); -+ } -+ -+ private boolean siblingDimension(final NativeUiScene.Node node) { -+ for (final String key : List.of("size", "min_size", "max_size")) for (int axis = 0; axis < 2; axis++) { -+ final var value = this.dimensionValue(node, key, axis); -+ if (value != null && value.uses(NativeUiDimension.Unit.SIBLING)) return true; ++ private byte[] download(final URI uri, final int redirects, final long deadline) throws Exception { ++ if (!this.allowed.test(uri)) throw new IOException("Unsupported image redirect"); ++ final long remaining = deadline - System.nanoTime(); ++ if (remaining <= 0) throw new java.util.concurrent.TimeoutException("Menu image request timed out"); ++ final var request = HttpRequest.newBuilder(uri).timeout(Duration.ofNanos(Math.min(remaining, TimeUnit.SECONDS.toNanos(15)))) ++ .header("Accept", "image/png,image/jpeg").GET().build(); ++ final var responseFuture = this.http.sendAsync(request, info -> new LimitedBody()); ++ final HttpResponse response; ++ try { response = responseFuture.get(remaining, TimeUnit.NANOSECONDS); } ++ finally { if (!responseFuture.isDone()) responseFuture.cancel(true); } ++ if (response.statusCode() >= 300 && response.statusCode() <= 399) { ++ if (redirects >= 3) throw new IOException("Too many image redirects"); ++ final String location = response.headers().firstValue("Location").orElseThrow(() -> new IOException("Missing image redirect")); ++ return this.download(uri.resolve(location), redirects + 1, deadline); + } -+ return false; ++ if (response.statusCode() != 200) throw new IOException("Menu image request failed"); ++ return response.body(); + } + -+ private Size content(final NativeUiScene.Node node, final List children, final float parentWidth, final float parentHeight) { -+ float width = 0, height = 0; -+ final boolean vertical = !node.string("orientation", "vertical").equals("horizontal"); -+ if (node.type().equals("grid")) { -+ final Grid grid = this.grid(node, parentWidth, parentHeight); -+ final var window = this.windows.get(node); -+ if (window != null) { -+ for (final var child : children) -+ if (child.width != window.cellWidth() || child.height != window.cellHeight()) -+ throw new IllegalArgumentException("Native collection cell geometry changed"); -+ return new Size(grid.columns * window.cellWidth(), grid.rows * window.cellHeight()); -+ } -+ final Map widths = new HashMap<>(); -+ final Map heights = new HashMap<>(); -+ for (int i = 0; i < children.size(); i++) { -+ final int[] position = grid.position(node.children().get(i), i); -+ final int column = position[0], row = position[1]; -+ widths.merge(column, children.get(i).width, Math::max); -+ heights.merge(row, children.get(i).height, Math::max); -+ } -+ for (final float value : widths.values()) width += value; -+ for (final float value : heights.values()) height += value; -+ } else for (final Size child : children) { -+ if (node.type().equals("stack_panel") && !vertical) width += child.width; -+ else width = Math.max(width, child.width); -+ if (node.type().equals("stack_panel") && vertical) height += child.height; -+ else height = Math.max(height, child.height); -+ } -+ return new Size(width, height); ++ public static boolean https(final URI uri) { ++ return "https".equalsIgnoreCase(uri.getScheme()) && uri.getHost() != null ++ && uri.getUserInfo() == null && uri.getFragment() == null; + } + -+ private Box place(final NativeUiScene.Node node, final Rect parent, final Size size, final float parentLayer, -+ final Rect parentClip, final Map scroll, final NativeUiScene.Node container) { -+ if (!this.visibleNodes.getOrDefault(node, true)) { -+ final Rect bounds = new Rect(parent.x, parent.y, 0, 0); -+ return new Box(node, bounds, parentClip, EMPTY, bounds, parentLayer, -+ node.children().stream().map(child -> this.place(child, bounds, EMPTY, parentLayer, parentClip, scroll, node)).toList()); -+ } -+ final String defaultAnchor = container != null && container.type().equals("grid") -+ && container.string("grid_item_template", "").isEmpty() ? "top_left" : "center"; -+ final float[] from = anchor(node.string("anchor_from", defaultAnchor)); -+ final float[] to = anchor(node.string("anchor_to", defaultAnchor)); -+ // Stack panels own child positions and only apply anchors when explicitly enabled. -+ final boolean stacked = container != null && container.type().equals("stack_panel"); -+ final boolean anchored = !stacked || container.flag("use_child_anchors", false); -+ final float offsetX = stacked ? 0 : offset(node, 0, parent.width, size); -+ final float offsetY = stacked ? 0 : offset(node, 1, parent.height, size); -+ final float x = parent.x + (anchored ? parent.width * from[0] - size.width * to[0] : 0) + offsetX; -+ final float y = parent.y + (anchored ? parent.height * from[1] - size.height * to[1] : 0) + offsetY; -+ final NativeUiCursorPlacement placement = this.placements.apply(node); -+ if (placement != null) this.cursorOffsets.put(node, new CursorOffset(placement, offsetX, offsetY)); -+ final Rect bounds = new Rect(x, y, size.width, size.height); -+ final Rect clip = node.scroll() || node.flag("clips_children", false) ? parentClip.intersect(bounds) : parentClip; -+ final List sizes = this.children(node, size.width, size.height); -+ Size content = this.content(node, sizes, size.width, size.height); -+ final String contentName = node.string("scroll_content", ""); -+ final boolean horizontalScroll = node.string("scroll_direction", "vertical").equals("horizontal"); -+ final float scrollAmount = node.scroll() && contentName.isEmpty() -+ ? Math.clamp(scroll.getOrDefault(node, 0F), 0, Math.max(0, -+ horizontalScroll ? content.width - size.width : content.height - size.height)) : 0; -+ final float scrollX = horizontalScroll ? scrollAmount : 0, scrollY = horizontalScroll ? 0 : scrollAmount; -+ final float layer = parentLayer + node.number("layer", 0); -+ final List children = new ArrayList<>(); -+ final boolean vertical = !node.string("orientation", "vertical").equals("horizontal"); -+ final Grid grid = this.grid(node, size.width, size.height); -+ final float cellWidth = size.width / grid.columns; -+ final float cellHeight = contentDimension(node, 1) ? content.height / grid.rows : size.height / grid.rows; -+ float cursor = 0; -+ for (int i = 0; i < node.children().size(); i++) { -+ final NativeUiScene.Node child = node.children().get(i); -+ final Size childSize = sizes.get(i); -+ final Rect childParent; -+ if (node.type().equals("stack_panel")) { -+ childParent = vertical ? new Rect(x - scrollX, y + cursor - scrollY, size.width, childSize.height) -+ : new Rect(x + cursor - scrollX, y - scrollY, childSize.width, size.height); -+ if (this.visibleNodes.getOrDefault(child, true)) cursor += vertical ? childSize.height : childSize.width; -+ } else if (node.type().equals("grid")) { -+ final int[] position = grid.position(child, i); -+ childParent = new Rect(x + position[0] * cellWidth - scrollX, y + position[1] * cellHeight - scrollY, -+ grid.manual ? size.width : cellWidth, grid.manual ? size.height : cellHeight); -+ } else childParent = new Rect(x - scrollX, y - scrollY, size.width, size.height); -+ children.add(this.place(child, childParent, childSize, layer, clip, scroll, node)); ++ public static Data decode(final byte[] bytes) throws IOException { ++ if (bytes.length == 0 || bytes.length > MAX_BYTES) throw new IOException("Menu image exceeds byte limits"); ++ try (final var input = ImageIO.createImageInputStream(new ByteArrayInputStream(bytes))) { ++ final var readers = ImageIO.getImageReaders(input); ++ if (!readers.hasNext()) throw new IOException("Unsupported menu image format"); ++ final var reader = readers.next(); ++ try { ++ if (!reader.getFormatName().equalsIgnoreCase("png") && !reader.getFormatName().equalsIgnoreCase("jpeg")) ++ throw new IOException("Unsupported menu image format"); ++ reader.setInput(input, true, true); ++ final int width = reader.getWidth(0), height = reader.getHeight(0); ++ if (width <= 0 || height <= 0 || width > 2048 || height > 2048 || (long) width * height > MAX_PIXELS) ++ throw new IOException("Menu image exceeds pixel limits"); ++ final BufferedImage pixels = reader.read(0); ++ final var output = new ByteArrayOutputStream(); ++ if (!ImageIO.write(pixels, "png", output) || output.size() > MAX_BYTES) ++ throw new IOException("Decoded menu image exceeds byte limits"); ++ return new Data(output.toByteArray(), width, height); ++ } finally { reader.dispose(); } + } -+ Rect viewport = bounds; -+ if (node.scroll() && !contentName.isEmpty()) { -+ final Box contentBox = descendant(children, contentName); -+ final Box viewportBox = descendant(children, node.string("scroll_view_port", "")); -+ if (contentBox != null) { -+ viewport = viewportBox == null ? bounds : viewportBox.bounds; -+ content = new Size(Math.max(contentBox.bounds.width, contentBox.content.width), -+ Math.max(contentBox.bounds.height, contentBox.content.height)); -+ final Box thumb = descendant(children, node.string("scrollbar_box", "")); -+ final boolean horizontal = thumb != null && thumb.node.string("draggable", "vertical").equals("horizontal"); -+ final float amount = Math.clamp(scroll.getOrDefault(node, 0F), 0, Math.max(0, -+ horizontal ? content.width - viewport.width : content.height - viewport.height)); -+ final Rect contentClip = clip.intersect(viewport); -+ for (int i = 0; i < children.size(); i++) -+ children.set(i, scrollContent(children.get(i), contentBox.node, horizontal ? -amount : 0, horizontal ? 0 : -amount, contentClip)); ++ } ++ ++ private void store(final Path file, final byte[] bytes) { ++ Path temporary = null; ++ try { ++ Files.createDirectories(this.directory); ++ temporary = Files.createTempFile(this.directory, "image-", ".tmp"); ++ Files.write(temporary, bytes); ++ Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); ++ try (final var paths = Files.list(this.directory)) { ++ final var entries = paths.filter(path -> path.getFileName().toString().matches("[0-9a-f]{64}\\.png")) ++ .sorted(java.util.Comparator.comparingLong(NativeUiImageCache::modified).reversed()).toList(); ++ long total = 0; ++ for (int i = 0; i < entries.size(); i++) { ++ total += Files.size(entries.get(i)); ++ if (i >= MAX_ENTRIES || total > MAX_CACHE_BYTES) Files.deleteIfExists(entries.get(i)); ++ } + } ++ } catch (final IOException ignored) { ++ // Read-only or unavailable cache storage must not stop the current menu. ++ } finally { ++ if (temporary != null) try { Files.deleteIfExists(temporary); } catch (final IOException ignored) {} + } -+ Box result = new Box(node, bounds, clip, content, viewport, layer, List.copyOf(children)); -+ if (node.scroll()) { -+ final var model = NativeUiScroll.model(result, scroll.getOrDefault(node, 0F)); -+ if (model != null) result = replace(result, model.originalThumb().node, -+ this.resize(model.originalThumb(), model.thumb(), scroll)); -+ } -+ return result; + } + -+ private Box positionCursors(Box box, final Rect parent, Rect inheritedClip, -+ final Map contentClips) { -+ final Rect contentClip = contentClips.get(box.node); -+ if (contentClip != null) inheritedClip = inheritedClip.intersect(contentClip); -+ final CursorOffset cursor = this.cursorOffsets.get(box.node); -+ if (cursor != null) { -+ final Size offset = cursor.placement.offset(box.node, box.bounds, parent, cursor.x, cursor.y); -+ if (offset != null) box = translated(box, offset.width - cursor.x, offset.height - cursor.y, inheritedClip); ++ private synchronized void pruneMemory() { ++ long total = 0; ++ for (final var future : this.pending.values()) if (future.isDone() && !future.isCompletedExceptionally()) { ++ final Data data = future.getNow(null); ++ if (data != null) total += data.bytes.length; + } -+ final Rect bounds = box.bounds; -+ final Rect clip = box.node.scroll() || box.node.flag("clips_children", false) ? inheritedClip.intersect(bounds) : inheritedClip; -+ if (box.node.scroll()) { -+ final Box content = descendant(box.children, box.node.string("scroll_content", "")); -+ if (content != null) contentClips.put(content.node, clip.intersect(box.scrollViewport)); ++ final var values = this.pending.entrySet().iterator(); ++ while (values.hasNext() && (total > MAX_CACHE_BYTES || this.pending.size() >= MAX_ENTRIES)) { ++ final var future = values.next().getValue(); ++ if (!future.isDone()) continue; ++ final Data data = future.isCompletedExceptionally() ? null : future.getNow(null); ++ if (data != null) total -= data.bytes.length; ++ values.remove(); + } -+ // Native requests use the actual parent control after layout, not a grid's -+ // virtual cell or the unscrolled child origin. -+ final List children = new ArrayList<>(); -+ for (final Box child : box.children) children.add(this.positionCursors(child, bounds, clip, contentClips)); -+ return new Box(box.node, bounds, clip, box.content, box.scrollViewport, box.layer, List.copyOf(children)); + } + -+ private record CursorOffset(NativeUiCursorPlacement placement, float x, float y) {} -+ -+ private Box resize(final Box box, final Rect bounds, final Map scroll) { -+ final List sizes = this.children(box.node, bounds.width, bounds.height); -+ final List children = new ArrayList<>(); -+ for (int i = 0; i < sizes.size(); i++) -+ children.add(this.place(box.node.children().get(i), bounds, sizes.get(i), box.layer, box.clip, scroll, box.node)); -+ return new Box(box.node, bounds, box.clip, this.content(box.node, sizes, bounds.width, bounds.height), -+ bounds, box.layer, List.copyOf(children)); -+ } -+ -+ private static Box replace(final Box box, final NativeUiScene.Node target, final Box replacement) { -+ if (box.node == target) return replacement; -+ return new Box(box.node, box.bounds, box.clip, box.content, box.scrollViewport, box.layer, -+ box.children.stream().map(child -> replace(child, target, replacement)).toList()); -+ } -+ -+ private static Box descendant(final List children, final String name) { -+ if (name.isEmpty()) return null; -+ for (final Box child : children) { -+ if (child.node.name().equals(name)) return child; -+ final Box nested = descendant(child.children, name); -+ if (nested != null) return nested; -+ } -+ return null; ++ private static long modified(final Path path) { ++ try { return Files.getLastModifiedTime(path).toMillis(); } catch (final IOException missing) { return 0; } + } + -+ private static Box scrollContent(final Box box, final NativeUiScene.Node content, final float x, final float y, final Rect clip) { -+ if (box.node == content) return translated(box, x, y, clip); -+ return new Box(box.node, box.bounds, box.clip, box.content, box.scrollViewport, box.layer, -+ box.children.stream().map(child -> scrollContent(child, content, x, y, clip)).toList()); ++ private static String hash(final String value) { ++ try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8))); } ++ catch (final java.security.NoSuchAlgorithmException impossible) { throw new AssertionError(impossible); } + } + -+ private static Box translated(final Box box, final float x, final float y, final Rect parentClip) { -+ final Rect bounds = new Rect(box.bounds.x + x, box.bounds.y + y, box.bounds.width, box.bounds.height); -+ final Rect clip = box.node.scroll() || box.node.flag("clips_children", false) ? parentClip.intersect(bounds) : parentClip; -+ final Rect viewport = new Rect(box.scrollViewport.x + x, box.scrollViewport.y + y, box.scrollViewport.width, box.scrollViewport.height); -+ return new Box(box.node, bounds, clip, box.content, viewport, box.layer, -+ box.children.stream().map(child -> translated(child, x, y, clip)).toList()); -+ } ++ public record Data(byte[] bytes, int width, int height) {} + -+ private float constrain(final NativeUiScene.Node node, final int axis, final float size, -+ final Measurement environment, final float width, final float height, -+ final float content, final float maximum) { -+ final float minimum = dimension(node, "min_size", axis, environment, width, height, content, maximum, 0); -+ final float limit = axis == 1 ? this.verticalLimit : LIMIT; -+ final float declaredUpper = dimension(node, "max_size", axis, environment, width, height, content, maximum, limit); -+ final float upper = declaredUpper == 0 ? limit : declaredUpper; -+ return Math.clamp(size, Math.min(minimum, upper), upper); -+ } ++ private static final class LimitedBody implements HttpResponse.BodySubscriber { ++ private final CompletableFuture result = new CompletableFuture<>(); ++ private final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); ++ private Flow.Subscription subscription; + -+ private float dimension(final NativeUiScene.Node node, final String key, final int axis, -+ final Measurement environment, final float width, final float height, -+ final float content, final float maximum, final float fallback) { -+ final NativeUiDimension dimension = this.dimensionValue(node, key, axis); -+ final float limit = axis == 1 ? this.verticalLimit : LIMIT; -+ if (dimension == null) { -+ if (!Float.isFinite(fallback) || Math.abs(fallback) > limit) -+ throw new IllegalArgumentException("Native UI dimension exceeds layout limits"); -+ return Math.max(0, fallback); ++ @Override public CompletionStage getBody() { return this.result; } ++ @Override public void onSubscribe(final Flow.Subscription subscription) { ++ this.subscription = subscription; ++ subscription.request(1); + } -+ final float parent = axis == 0 ? environment.width : environment.height; -+ final float sibling = axis == 0 ? environment.siblingWidth : environment.siblingHeight; -+ try { -+ final float value = dimension.resolve(parent, content, maximum, width, height, sibling, fallback); -+ // A loose maximum does not request geometry beyond our supported layout range. -+ final float bounded = key.equals("max_size") ? Math.min(value, limit) : value; -+ if (!Float.isFinite(bounded) || Math.abs(bounded) > limit) -+ throw new IllegalArgumentException("Native UI dimension exceeds layout limits"); -+ return Math.max(0, bounded); -+ } catch (final IllegalArgumentException error) { -+ throw new IllegalArgumentException("Invalid native UI dimension: " + node.name() + "." + key + "[" + axis + "]", error); ++ @Override public void onNext(final List buffers) { ++ for (final var buffer : buffers) { ++ if ((long) this.bytes.size() + buffer.remaining() > MAX_BYTES) { ++ this.subscription.cancel(); ++ this.result.completeExceptionally(new IOException("Menu image exceeds byte limits")); ++ return; ++ } ++ final byte[] chunk = new byte[buffer.remaining()]; ++ buffer.get(chunk); ++ this.bytes.writeBytes(chunk); ++ } ++ this.subscription.request(1); + } ++ @Override public void onError(final Throwable error) { this.result.completeExceptionally(error); } ++ @Override public void onComplete() { this.result.complete(this.bytes.toByteArray()); } + } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiImageRegions.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiImageRegions.java +new file mode 100644 +index 0000000..3f3e908 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiImageRegions.java +@@ -0,0 +1,132 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.ui; + -+ public static float dimension(final JsonElement value, final float parent, final float content, final float maximum) { -+ return dimension(value, parent, content, maximum, parent, parent, maximum, 0); -+ } ++import com.google.gson.JsonElement; ++import java.util.ArrayList; ++import java.util.List; + -+ private static float dimension(final JsonElement value, final float parent, final float content, final float maximum, -+ final float width, final float height, final float sibling, final float fallback) { -+ return bounded(evaluatedDimension(value, parent, content, maximum, width, height, sibling, fallback)); -+ } ++/** Produces the native image component's ordered, signed destination and source regions. */ ++public final class NativeUiImageRegions { ++ private static final int[][] ORDER = {{0, 0}, {2, 0}, {0, 2}, {2, 2}, {0, 1}, {1, 0}, {2, 1}, {1, 2}, {1, 1}}; + -+ private static float evaluatedDimension(final JsonElement value, final float parent, final float content, final float maximum, -+ final float width, final float height, final float sibling, final float fallback) { -+ return NativeUiDimension.parse(value).resolve(parent, content, maximum, width, height, sibling, fallback); ++ private NativeUiImageRegions() {} ++ ++ public static List regions(final NativeUiScene.Node node, final NativeUiLayout.Rect bounds, ++ final int textureWidth, final int textureHeight) { ++ return regions(node, bounds, new Source(NativeUiLayout.vector(node, "uv", 0, 0), NativeUiLayout.vector(node, "uv", 1, 0), ++ NativeUiLayout.vector(node, "uv_size", 0, textureWidth), NativeUiLayout.vector(node, "uv_size", 1, textureHeight))); + } + -+ private float offset(final NativeUiScene.Node node, final int axis, final float parent, final Size size) { -+ final var dimension = this.dimensionValue(node, "offset", axis); -+ return dimension == null ? 0 : signed(dimension.resolve(parent, 0, 0, size.width, size.height, 0, 0)); -+ } -+ -+ private boolean contentDimension(final NativeUiScene.Node node, final int axis) { -+ final var size = this.dimensionValue(node, "size", axis); -+ return size != null && (size.uses(NativeUiDimension.Unit.CHILDREN) || size.uses(NativeUiDimension.Unit.MAX_CHILD)); -+ } -+ -+ public static float vector(final NativeUiScene.Node node, final String key, final int axis, final float fallback) { -+ final JsonElement value = node.properties().get(key); -+ if (value == null || !value.isJsonArray() || value.getAsJsonArray().size() <= axis) return fallback; -+ final float result = value.getAsJsonArray().get(axis).getAsFloat(); -+ if (!Float.isFinite(result) || Math.abs(result) > LIMIT) throw new IllegalArgumentException("Invalid native UI vector"); -+ return result; -+ } -+ -+ private static float bounded(final float value) { -+ return Math.max(0, signed(value)); -+ } -+ -+ private static float signed(final float value) { -+ if (!Float.isFinite(value) || Math.abs(value) > LIMIT) throw new IllegalArgumentException("Native UI dimension exceeds layout limits"); -+ return value; -+ } -+ -+ private Grid grid(final NativeUiScene.Node node, final float width, final float height) { -+ if (!node.type().equals("grid")) return new Grid(1, 1, true, false, false, false); -+ final boolean horizontal = !node.string("grid_fill_direction", "horizontal").equals("vertical"); -+ final var window = this.windows.get(node); -+ final int count = window == null ? node.children().size() : window.size(); -+ if (count > 8192) throw new IllegalArgumentException("Native grid exceeds child limits"); -+ final boolean explicit = node.properties().has("grid_dimensions"); -+ final String rescaling = node.string("grid_rescaling_type", "none"); -+ final boolean manual = node.string("grid_item_template", "").isEmpty(); -+ int columns = gridCapacity(node, 0, manual); -+ int rows = gridCapacity(node, 1, manual); -+ if (manual && (columns == 0 || rows == 0)) -+ throw new IllegalArgumentException("Unsupported zero-capacity manual native grid"); -+ final boolean rescaled = !explicit && !manual && (rescaling.equals("horizontal") || rescaling.equals("vertical")); -+ if (manual) { -+ for (final var child : node.children()) { -+ final int[] position = gridPosition(child); -+ try { -+ Math.addExact(Math.multiplyExact(columns, position[1]), position[0]); -+ } catch (final ArithmeticException error) { -+ throw new IllegalArgumentException("Native grid index exceeds its range", error); -+ } -+ } -+ } -+ if (rescaled) { -+ float cellWidth = 0, cellHeight = 0; -+ for (final var child : node.children()) { -+ final Size size = this.measure(child, width, height); -+ cellWidth = Math.max(cellWidth, size.width); -+ cellHeight = Math.max(cellHeight, size.height); -+ } -+ // Percentage sizing can round an exact capacity down by one float ULP. -+ if (rescaling.equals("horizontal")) { -+ columns = cellWidth > 0 ? Math.clamp((int) Math.floor(Math.nextUp(width / cellWidth)), 1, 128) : 1; -+ rows = Math.max(1, (int) Math.ceilDiv((long) count, columns)); -+ } else { -+ rows = cellHeight > 0 ? Math.clamp((int) Math.floor(Math.nextUp(height / cellHeight)), 1, 4096) : 1; -+ columns = Math.max(1, (int) Math.ceilDiv((long) count, rows)); -+ } -+ } -+ if (!manual) { -+ if (horizontal) rows = Math.max(rows, (int) Math.ceilDiv((long) count, columns)); -+ else columns = Math.max(columns, (int) Math.ceilDiv((long) count, rows)); ++ static List regions(final NativeUiScene.Node node, final NativeUiLayout.Rect bounds, final Source source) { ++ final float u = source.u, v = source.v, sourceWidth = source.width, sourceHeight = source.height; ++ if (sourceWidth <= 0 || sourceHeight <= 0) return List.of(); ++ final int direction = switch (node.string("clip_direction", "none")) { ++ case "left" -> 1; ++ case "right" -> 2; ++ case "up" -> 3; ++ case "down" -> 4; ++ case "center" -> 5; ++ default -> 0; ++ }; ++ final float ratio = node.number("clip_ratio", 0); ++ if (direction != 0 && ratio == 1) return List.of(); ++ final boolean pixelPerfect = node.flag("clip_pixelperfect", true); ++ final JsonElement slice = node.properties().get(node.properties().has("nine_slice") ? "nine_slice" : "nineslice_size"); ++ float width = bounds.width(), height = bounds.height(); ++ if (slice == null && !(direction != 0 && ratio > 0) && node.flag("keep_ratio", true)) { ++ final float scale = Math.min(width / sourceWidth, height / sourceHeight); ++ width = sourceWidth * scale; ++ height = sourceHeight * scale; + } -+ return new Grid(columns, rows, horizontal, rescaled, manual, window != null); -+ } -+ -+ private static int gridCapacity(final NativeUiScene.Node node, final int axis, final boolean manual) { -+ final JsonElement value = node.properties().get("grid_dimensions"); -+ if (value == null) return manual ? 0 : 1; -+ if (!value.isJsonArray() || value.getAsJsonArray().size() != 2) -+ throw new IllegalArgumentException("Invalid native grid capacity"); -+ final JsonElement count = value.getAsJsonArray().get(axis); -+ if (!count.isJsonPrimitive() || !count.getAsJsonPrimitive().isNumber()) -+ throw new IllegalArgumentException("Invalid native grid capacity"); -+ try { -+ final int capacity = count.getAsBigDecimal().intValueExact(); -+ if (capacity < 0) throw new IllegalArgumentException("Invalid native grid capacity"); -+ return manual ? capacity : Math.max(1, capacity); -+ } catch (final ArithmeticException error) { -+ throw new IllegalArgumentException("Invalid native grid capacity", error); ++ final float x = bounds.x() + (bounds.width() - width) / 2; ++ final float y = bounds.y() + (bounds.height() - height) / 2; ++ if (slice == null) { ++ final Region region = new Region(x, y, x + width, y + height, u, v, u + sourceWidth, v + sourceHeight); ++ final Region clipped = direction != 0 && ratio > 0 ? clip(region, direction, ratio, pixelPerfect) : region; ++ return clipped.finite() ? List.of(clipped) : List.of(); + } -+ } -+ -+ private static int[] gridPosition(final NativeUiScene.Node node) { -+ final JsonElement value = node.properties().get("grid_position"); -+ if (value == null) return new int[]{0, 0}; -+ if (!value.isJsonArray() || value.getAsJsonArray().size() != 2) -+ throw new IllegalArgumentException("Invalid native grid position"); -+ final int[] position = new int[2]; -+ for (int axis = 0; axis < position.length; axis++) { -+ final JsonElement coordinate = value.getAsJsonArray().get(axis); -+ if (!coordinate.isJsonPrimitive() || !coordinate.getAsJsonPrimitive().isNumber()) -+ throw new IllegalArgumentException("Invalid native grid position"); -+ try { -+ position[axis] = coordinate.getAsBigDecimal().intValueExact(); -+ } catch (final ArithmeticException error) { -+ throw new IllegalArgumentException("Invalid native grid position", error); -+ } -+ if (position[axis] < 0) throw new IllegalArgumentException("Invalid native grid position"); ++ final float[] sides = new float[4]; ++ if (slice.isJsonArray() && slice.getAsJsonArray().size() == 4) { ++ for (int i = 0; i < 4; i++) sides[i] = slice.getAsJsonArray().get(i).getAsFloat(); ++ } else if (slice.isJsonPrimitive() && slice.getAsJsonPrimitive().isNumber()) java.util.Arrays.fill(sides, slice.getAsFloat()); ++ for (final float side : sides) if (!Float.isFinite(side) || side < 0 || side > 16384) ++ throw new IllegalArgumentException("Invalid native UI nine-slice"); ++ if (width == 0) { sides[0] = 0; sides[2] = 0; } ++ if (height == 0) { sides[1] = 0; sides[3] = 0; } ++ float baseWidth = NativeUiLayout.vector(node, "base_size", 0, 0); ++ float baseHeight = NativeUiLayout.vector(node, "base_size", 1, 0); ++ if (baseWidth == 0 || baseHeight == 0) { baseWidth = sourceWidth; baseHeight = sourceHeight; } ++ final float[] dx = {x, x + sides[0], x + width - sides[2], x + width}; ++ final float[] dy = {y, y + sides[1], y + height - sides[3], y + height}; ++ final float[] sx = {u, u + sides[0] * sourceWidth / baseWidth, u + sourceWidth - sides[2] * sourceWidth / baseWidth, u + sourceWidth}; ++ final float[] sy = {v, v + sides[1] * sourceHeight / baseHeight, v + sourceHeight - sides[3] * sourceHeight / baseHeight, v + sourceHeight}; ++ final List result = new ArrayList<>(9); ++ // Native nine-slice clipping keeps the facing border and trims individual edges and the center. ++ for (final int[] cell : ORDER) { ++ final Region region = new Region(dx[cell[0]], dy[cell[1]], dx[cell[0] + 1], dy[cell[1] + 1], ++ sx[cell[0]], sy[cell[1]], sx[cell[0] + 1], sy[cell[1] + 1]); ++ if (direction == 0 || ratio == 0) result.add(region); ++ else if (cell[0] != 1 && cell[1] != 1) { ++ if (ratio > 0 && retainedBorder(cell, direction)) result.add(region); ++ } else if (ratio > 0 && retainedBorder(cell, direction)) result.add(region); ++ else if (cell[0] == 1 && cell[1] == 1 || ratio > 0 && !discardedBorder(cell, direction)) ++ result.add(clip(region, direction, ratio, pixelPerfect)); + } -+ return position; ++ // Native pixel clipping divides by each source extent, including zero-width slice interiors. ++ // Keep its finite geometry but never submit the resulting NaN coordinates to the Java renderer. ++ result.removeIf(region -> !region.finite()); ++ return List.copyOf(result); + } + -+ private record Grid(int columns, int rows, boolean horizontal, boolean rescaled, boolean manual, boolean windowed) { -+ int[] position(final NativeUiScene.Node node, final int index) { -+ if (this.manual) return gridPosition(node); -+ final int original = this.windowed ? node.properties().get("collection_index").getAsInt() : index; -+ return this.horizontal ? new int[]{original % this.columns, original / this.columns} -+ : new int[]{original / this.rows, original % this.rows}; -+ } ++ private static boolean retainedBorder(final int[] cell, final int direction) { ++ return switch (direction) { ++ case 1 -> cell[0] == 0; ++ case 2 -> cell[0] == 2; ++ case 3 -> cell[1] == 0; ++ case 4 -> cell[1] == 2; ++ default -> false; ++ }; + } + -+ private static float[] anchor(final String name) { -+ return switch (name) { -+ case "top_left" -> new float[]{0, 0}; -+ case "top_middle" -> new float[]{.5F, 0}; -+ case "top_right" -> new float[]{1, 0}; -+ case "left_middle" -> new float[]{0, .5F}; -+ case "center" -> new float[]{.5F, .5F}; -+ case "right_middle" -> new float[]{1, .5F}; -+ case "bottom_left" -> new float[]{0, 1}; -+ case "bottom_middle" -> new float[]{.5F, 1}; -+ case "bottom_right" -> new float[]{1, 1}; -+ default -> throw new IllegalArgumentException("Unsupported native UI anchor"); ++ private static boolean discardedBorder(final int[] cell, final int direction) { ++ return switch (direction) { ++ case 1 -> cell[0] == 2; ++ case 2 -> cell[0] == 0; ++ case 3 -> cell[1] == 2; ++ case 4 -> cell[1] == 0; ++ default -> false; + }; + } + -+ public interface Metrics { -+ Size intrinsic(NativeUiScene.Node node); -+ -+ default Size constrained(final NativeUiScene.Node node, final float width) { -+ return this.intrinsic(node); -+ } ++ private static Region clip(final Region region, final int direction, final float ratio, final boolean pixelPerfect) { ++ final float sourceWidth = region.u1 - region.u0, sourceHeight = region.v1 - region.v0; ++ final float horizontal = direction == 1 || direction == 2 || direction == 5 ++ ? clipFraction(ratio, sourceWidth, pixelPerfect) : 0; ++ final float vertical = direction == 3 || direction == 4 || direction == 5 ++ ? clipFraction(ratio, sourceHeight, pixelPerfect) : 0; ++ final float shiftX = direction == 2 ? horizontal : direction == 5 ? horizontal / 2 : 0; ++ final float shiftY = direction == 4 ? vertical : direction == 5 ? vertical / 2 : 0; ++ final float width = region.x1 - region.x0, height = region.y1 - region.y0; ++ final float x = region.x0 + width * shiftX, y = region.y0 + height * shiftY; ++ final float u = region.u0 + sourceWidth * shiftX, v = region.v0 + sourceHeight * shiftY; ++ return new Region(x, y, x + (width - width * horizontal), y + (height - height * vertical), ++ u, v, u + (sourceWidth - sourceWidth * horizontal), v + (sourceHeight - sourceHeight * vertical)); + } + -+ private record Measurement(float width, float height, float siblingWidth, float siblingHeight, float defaultWidth, float defaultHeight) {} -+ -+ public record Size(float width, float height) {} -+ public record Box(NativeUiScene.Node node, Rect bounds, Rect clip, Size content, Rect scrollViewport, float layer, List children) { -+ public Rect visibleBounds() { -+ return this.bounds.intersect(this.clip); -+ } ++ private static float clipFraction(final float ratio, final float sourceExtent, final boolean pixelPerfect) { ++ return pixelPerfect ? (float) Math.floor(ratio * sourceExtent) / sourceExtent : ratio; + } + -+ public record Rect(float x, float y, float width, float height) { -+ public Rect intersect(final Rect other) { -+ final float left = Math.max(this.x, other.x), top = Math.max(this.y, other.y); -+ return new Rect(left, top, Math.max(0, Math.min(this.x + this.width, other.x + other.width) - left), -+ Math.max(0, Math.min(this.y + this.height, other.y + other.height) - top)); -+ } ++ record Source(float u, float v, float width, float height) {} + -+ public boolean contains(final double x, final double y) { -+ return x >= this.x && y >= this.y && x < this.x + this.width && y < this.y + this.height; ++ public record Region(float x0, float y0, float x1, float y1, float u0, float v0, float u1, float v1) { ++ boolean finite() { ++ return Float.isFinite(x0) && Float.isFinite(y0) && Float.isFinite(x1) && Float.isFinite(y1) ++ && Float.isFinite(u0) && Float.isFinite(v0) && Float.isFinite(u1) && Float.isFinite(v1); + } + } +} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiMultilineInput.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiMultilineInput.java +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiImages.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiImages.java new file mode 100644 -index 0000000..92a87a7 +index 0000000..b106fad --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiMultilineInput.java -@@ -0,0 +1,267 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiImages.java +@@ -0,0 +1,193 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -8050,271 +9079,451 @@ index 0000000..92a87a7 + */ +package com.viaversion.viafabricplus.bedrock.screen.ui; + -+import com.mojang.blaze3d.platform.cursor.CursorTypes; -+import java.util.ArrayList; -+import java.util.List; -+import java.util.function.Consumer; -+import net.minecraft.client.Minecraft; -+import net.minecraft.client.gui.Font; -+import net.minecraft.client.gui.GuiGraphicsExtractor; -+import net.minecraft.client.gui.components.AbstractWidget; -+import net.minecraft.client.gui.components.IMEPreeditOverlay; -+import net.minecraft.client.gui.components.MultilineTextField; -+import net.minecraft.client.gui.components.TextCursorUtils; -+import net.minecraft.client.gui.components.Whence; -+import net.minecraft.client.gui.narration.NarratedElementType; -+import net.minecraft.client.gui.narration.NarrationElementOutput; -+import net.minecraft.client.input.CharacterEvent; -+import net.minecraft.client.input.KeyEvent; -+import net.minecraft.client.input.MouseButtonEvent; -+import net.minecraft.client.input.PreeditEvent; -+import net.minecraft.client.sounds.SoundManager; -+import net.minecraft.network.chat.Component; -+import net.minecraft.util.Util; ++import com.mojang.blaze3d.platform.NativeImage; ++import java.io.IOException; ++import java.net.http.HttpClient; ++import java.time.Duration; ++import java.util.HashMap; ++import java.util.LinkedHashMap; ++import java.util.Map; ++import java.util.UUID; ++import java.util.concurrent.ArrayBlockingQueue; ++import java.util.concurrent.CompletionException; ++import java.util.concurrent.ExecutorService; ++import java.util.concurrent.ThreadPoolExecutor; ++import java.util.concurrent.TimeUnit; ++import java.util.concurrent.atomic.AtomicLong; ++import net.minecraft.client.Minecraft; ++import net.minecraft.client.gui.GuiGraphicsExtractor; ++import net.minecraft.client.renderer.RenderPipelines; ++import net.minecraft.client.renderer.texture.DynamicTexture; ++import net.minecraft.resources.Identifier; ++import net.raphimc.viabedrock.api.resourcepack.ui.UiTextureLimits; ++import net.raphimc.viabedrock.protocol.data.ProtocolConstants; + -+/** Text editing and selection inside authored geometry; the scene draws its own decorations. */ -+class NativeUiMultilineInput extends AbstractWidget { -+ // MultilineTextField uses the target client's nine-pixel line spacing for pointer navigation. -+ private static final int LINE_HEIGHT = 9; -+ private final Font font; -+ private final String placeholder; -+ private final int maximumLength; -+ private final Consumer responder; -+ private TextField text; -+ private int layoutWidth; -+ private String lastValue; -+ private int textColor = 0xFFFFFFFF; -+ private boolean shadow; -+ private boolean editable = true; -+ private double scroll; -+ private long focusedTime; -+ private IMEPreeditOverlay preedit; ++/** Draws accepted pack textures and bounded public HTTPS images. */ ++public final class NativeUiImages implements AutoCloseable { ++ private static final int MAX_PACK_IMAGES = 256; ++ private static final ExecutorService DOWNLOADS = new ThreadPoolExecutor(4, 4, 0, TimeUnit.MILLISECONDS, ++ new ArrayBlockingQueue<>(256), Thread.ofPlatform().daemon().name("Bedrock UI images-", 0).factory(), ++ new ThreadPoolExecutor.AbortPolicy()); ++ private static final HttpClient HTTP = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)) ++ .followRedirects(HttpClient.Redirect.NEVER).build(); ++ private static NativeUiImageCache remoteCache; ++ private final Map remote = new HashMap<>(); ++ private final LinkedHashMap images = new LinkedHashMap<>(MAX_PACK_IMAGES, .75F, true); ++ private final LinkedHashMap failedImages = new LinkedHashMap<>(MAX_PACK_IMAGES, .75F, true); ++ private volatile boolean closed; ++ private final AtomicLong remotePixels = new AtomicLong(); ++ private boolean failed; ++ private long revision; + -+ NativeUiMultilineInput(final Font font, final Component caption, final String value, final String placeholder, -+ final int maximumLength, final Consumer responder) { -+ super(0, 0, 1, 1, caption); -+ this.font = font; -+ this.placeholder = placeholder; -+ this.maximumLength = maximumLength; -+ this.responder = responder; -+ this.text = this.field(1, value); -+ this.layoutWidth = 1; -+ this.listen(); ++ private static synchronized NativeUiImageCache remoteCache() { ++ if (remoteCache == null) remoteCache = new NativeUiImageCache( ++ Minecraft.getInstance().gameDirectory.toPath().resolve(".cache/viabedrock/form-images"), ++ ProtocolConstants.BEDROCK_VERSION_NAME, HTTP, DOWNLOADS); ++ return remoteCache; + } + -+ private TextField field(final int width, final String value) { -+ final var result = new TextField(this.font, width); -+ result.setCharacterLimit(this.maximumLength); -+ result.setValue(value); -+ return result; ++ public Image image(final NativeUiScene.Node node) { ++ return node.string("texture_file_system", "path").equals("url") ++ ? this.remote(node.string("texture", "")) : this.image(node.string("texture", ""), !node.flag("allow_debug_missing_texture", true)); + } + -+ private void listen() { -+ this.lastValue = this.text.value(); -+ this.text.setValueListener(value -> { -+ if (!value.equals(this.lastValue)) { -+ this.lastValue = value; -+ this.responder.accept(value); ++ private Image remote(final String url) { ++ final Remote existing = this.remote.get(url); ++ if (existing != null) return existing.image; ++ if (this.closed || this.remote.size() >= 128) { this.failed = true; return null; } ++ final Remote entry = new Remote(); ++ this.remote.put(url, entry); ++ final var future = remoteCache().load(url).thenApplyAsync(data -> { ++ if (this.closed) return null; ++ final long count = (long) data.width() * data.height(); ++ long previous; ++ do { ++ previous = this.remotePixels.get(); ++ if (previous + count > 16L * 1024 * 1024) ++ throw new CompletionException(new IOException("Menu images exceed pixel limits")); ++ } while (!this.remotePixels.compareAndSet(previous, previous + count)); ++ try { return NativeImage.read(data.bytes()); } ++ catch (final IOException | RuntimeException error) { ++ this.remotePixels.addAndGet(-count); ++ throw new CompletionException(error); + } -+ }); -+ this.text.setCursorListener(this::revealCursor); ++ }, DOWNLOADS); ++ future.whenComplete((pixels, error) -> Minecraft.getInstance().execute(() -> { ++ if (this.closed) { ++ if (pixels != null) { this.remotePixels.addAndGet(-(long) pixels.getWidth() * pixels.getHeight()); pixels.close(); } ++ return; ++ } ++ if (error != null) { entry.failed = true; this.failed = true; this.revision++; return; } ++ final Identifier id = Identifier.fromNamespaceAndPath("viafabricplus-bedrock", "form/" + UUID.randomUUID()); ++ try { ++ Minecraft.getInstance().getTextureManager().register(id, new DynamicTexture(id::toString, pixels)); ++ entry.image = new Image(id, pixels.getWidth(), pixels.getHeight(), null); ++ } catch (final RuntimeException failure) { ++ this.remotePixels.addAndGet(-(long) pixels.getWidth() * pixels.getHeight()); ++ pixels.close(); ++ entry.failed = true; ++ this.failed = true; ++ } ++ this.revision++; ++ })); ++ return null; + } + -+ final void configure(final NativeUiLayout.Rect bounds, final int color, final boolean shadow) { -+ final int width = Math.max(1, Math.round(bounds.width())); -+ this.setRectangle(width, Math.max(1, Math.round(bounds.height())), Math.round(bounds.x()), Math.round(bounds.y())); -+ this.textColor = color; -+ this.shadow = shadow; -+ if (width != this.layoutWidth) { -+ final int cursor = this.text.cursor(); -+ final var selection = this.text.selection(); -+ final int anchor = cursor == selection.start() ? selection.end() : selection.start(); -+ this.text = this.field(width, this.text.value()); -+ this.text.seekCursor(Whence.ABSOLUTE, anchor); -+ this.text.setSelecting(true); -+ this.text.seekCursor(Whence.ABSOLUTE, cursor); -+ this.text.setSelecting(false); -+ this.layoutWidth = width; -+ this.listen(); ++ public long revision() { return this.revision; } ++ public boolean failed() { return this.failed; } ++ ++ @Override ++ public void close() { ++ this.closed = true; ++ for (final Remote entry : this.remote.values()) if (entry.image != null) { ++ Minecraft.getInstance().getTextureManager().release(entry.image.id); ++ this.remotePixels.addAndGet(-(long) entry.image.width * entry.image.height); + } -+ this.scroll = Math.clamp(this.scroll, 0, this.maximumScroll()); -+ if (this.isFocused()) this.revealCursor(); ++ this.remote.clear(); ++ this.images.clear(); ++ this.failedImages.clear(); + } + -+ public final void setEditable(final boolean editable) { -+ final boolean changed = this.editable != editable; -+ this.editable = editable; -+ if (!editable) this.preedit = null; -+ if (changed && this.isFocused()) Minecraft.getInstance().onTextInputFocusChange(this, this.capturesInput()); ++ private static final class Remote { ++ Image image; ++ boolean failed; + } + -+ final String value() { return this.text.value(); } -+ final int cursor() { return this.text.cursor(); } -+ final String selectedText() { return this.text.getSelectedText(); } -+ -+ private double maximumScroll() { -+ return Math.max(0, this.text.getLineCount() * LINE_HEIGHT - this.height); ++ public Image image(final String texture) { ++ return this.image(texture, false); + } + -+ private void revealCursor() { -+ final double top = this.text.getLineAtCursor() * LINE_HEIGHT; -+ if (top < this.scroll) this.scroll = top; -+ else if (top + LINE_HEIGHT > this.scroll + this.height) this.scroll = top + LINE_HEIGHT - this.height; -+ this.scroll = Math.clamp(this.scroll, 0, this.maximumScroll()); -+ this.focusedTime = Util.getMillis(); ++ private Image image(final String texture, final boolean allowMissing) { ++ final Identifier id = Identifier.tryParse(texture); ++ if (id == null || !id.getNamespace().equals("viabedrock") || !id.getPath().startsWith("textures/")) ++ throw new IllegalArgumentException("Invalid native UI texture"); ++ final var resource = Minecraft.getInstance().getResourceManager().getResource(id).orElse(null); ++ if (resource == null) { ++ if (allowMissing) return null; ++ this.failed = true; ++ throw new IllegalArgumentException("Missing native UI texture: " + id); ++ } ++ if (this.failedImages.get(id) == resource.source()) ++ throw new IllegalArgumentException("Invalid native UI texture: " + id); ++ final Image cached = this.images.get(id); ++ if (cached != null && cached.source == resource.source()) return cached; ++ try (final var input = resource.open()) { ++ final Image image; ++ if (id.getPath().endsWith(".jpg")) { ++ final var decoded = NativeUiJpegTexture.decode(input); ++ final var textureResource = new NativeUiJpegTexture(id, decoded); ++ try { ++ Minecraft.getInstance().getTextureManager().registerAndLoad(id, textureResource); ++ } catch (final RuntimeException | Error failure) { ++ textureResource.close(); ++ throw failure; ++ } ++ image = new Image(id, decoded.getWidth(), decoded.getHeight(), resource.source()); ++ } else { ++ try (final NativeImage pixels = NativeImage.read(input)) { ++ if (!UiTextureLimits.supportsDimensions(pixels.getWidth(), pixels.getHeight())) ++ throw new IOException("Native UI texture exceeds size limits"); ++ image = new Image(id, pixels.getWidth(), pixels.getHeight(), resource.source()); ++ } ++ } ++ this.images.put(id, image); ++ if (this.images.size() > MAX_PACK_IMAGES) this.images.pollFirstEntry(); ++ this.failedImages.remove(id); ++ return image; ++ } catch (final IOException | RuntimeException error) { ++ this.failedImages.put(id, resource.source()); ++ if (this.failedImages.size() > MAX_PACK_IMAGES) this.failedImages.pollFirstEntry(); ++ this.failed = true; ++ throw new IllegalArgumentException("Invalid native UI texture: " + id, error); ++ } + } + -+ @Override -+ public void setFocused(final boolean focused) { -+ final boolean changed = this.isFocused() != focused; -+ super.setFocused(focused); -+ if (changed) { -+ this.preedit = null; -+ if (focused) this.revealCursor(); -+ Minecraft.getInstance().onTextInputFocusChange(this, focused); ++ public void draw(final GuiGraphicsExtractor graphics, final NativeUiLayout.Box box, final int color, final NativeUiAnimations animations) { ++ final NativeUiScene.Node node = box.node(); ++ final Image image = this.image(node); ++ if (image == null) { ++ final Remote entry = this.remote.get(node.string("texture", "")); ++ if (entry != null && !entry.failed && !this.closed) graphics.fill(Math.round(box.bounds().x()), Math.round(box.bounds().y()), ++ Math.round(box.bounds().x() + box.bounds().width()), Math.round(box.bounds().y() + box.bounds().height()), ++ color & 0xFF000000 | 0x303030); ++ return; ++ } ++ for (final var region : NativeUiImageRegions.regions(node, box.bounds(), animations.source(node, image.width, image.height))) { ++ final int x0 = Math.round(Math.min(region.x0(), region.x1())), x1 = Math.round(Math.max(region.x0(), region.x1())); ++ final int y0 = Math.round(Math.min(region.y0(), region.y1())), y1 = Math.round(Math.max(region.y0(), region.y1())); ++ // Native solid nine-slices can stretch a single texture point across a nonempty region. ++ if (x0 == x1 || y0 == y1) continue; ++ final boolean reverseX = region.x0() > region.x1(), reverseY = region.y0() > region.y1(); ++ graphics.innerBlit(RenderPipelines.GUI_TEXTURED, image.id, x0, x1, y0, y1, ++ (reverseX ? region.u1() : region.u0()) / image.width, (reverseX ? region.u0() : region.u1()) / image.width, ++ (reverseY ? region.v1() : region.v0()) / image.height, (reverseY ? region.v0() : region.v1()) / image.height, color); + } + } + -+ @Override -+ public boolean capturesInput() { -+ return this.visible && this.active && this.editable && this.isFocused(); -+ } ++ public record Image(Identifier id, int width, int height, Object source) {} ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiInput.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiInput.java +new file mode 100644 +index 0000000..2b63181 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiInput.java +@@ -0,0 +1,248 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.ui; + -+ @Override -+ public boolean keyPressed(final KeyEvent event) { -+ return this.capturesInput() && this.text.keyPressed(event); -+ } ++import com.google.gson.JsonObject; ++import java.nio.charset.StandardCharsets; ++import java.util.ArrayList; ++import java.util.List; ++import java.util.function.Consumer; + -+ @Override -+ public boolean charTyped(final CharacterEvent event) { -+ if (!this.capturesInput() || !event.isAllowedChatCharacter()) return false; -+ this.text.insertText(event.codepointAsString()); -+ return true; ++/** Native mapping factory fields, event phases, pointer scopes and double-press state. */ ++public record NativeUiInput(Options options, List mappings, List hoverBindings) { ++ public static NativeUiInput parse(final JsonObject properties) { ++ final List mappings = new ArrayList<>(); ++ final List hover = new ArrayList<>(); ++ boolean wildcard = false; ++ Scope wildcardScope = Scope.DEFAULT; ++ final var entries = properties.get("button_mappings"); ++ if (entries != null && entries.isJsonArray()) for (final var value : entries.getAsJsonArray()) { ++ if (!value.isJsonObject()) continue; ++ final var mapping = value.getAsJsonObject(); ++ if (flag(mapping, "ignored", false)) continue; ++ final String source = string(mapping, "from_button_id"); ++ final String target = string(mapping, "to_button_id"); ++ final Kind kind = switch (string(mapping, "mapping_type")) { ++ case "global" -> Kind.GLOBAL; ++ case "double_pressed" -> Kind.DOUBLE_PRESSED; ++ case "focused" -> Kind.FOCUSED; ++ default -> Kind.PRESSED; ++ }; ++ final Scope scope = switch (string(mapping, "scope")) { ++ case "view" -> Scope.VIEW; ++ case "global" -> Scope.GLOBAL; ++ default -> Scope.DEFAULT; ++ }; ++ if (source.equals("any")) { ++ wildcard = true; ++ wildcardScope = scope; ++ } else if (source.isEmpty() || target.isEmpty()) { ++ if (source.isEmpty() && !target.isEmpty() && kind == Kind.PRESSED) hover.add(new HoverBinding(symbol(target), scope)); ++ } else { ++ final Condition condition = switch (string(mapping, "input_mode_condition")) { ++ case "not_gamepad" -> Condition.NOT_GAMEPAD; ++ case "gamepad" -> Condition.GAMEPAD; ++ default -> Condition.ANY; ++ }; ++ mappings.add(new Mapping(symbol(source), symbol(target), kind, scope, ++ flag(mapping, "button_up_right_of_first_refusal", false), flag(mapping, "handle_select", true), ++ flag(mapping, "handle_deselect", true), flag(mapping, "alternate_input_scope", false), ++ flag(mapping, "consume_event", true), condition, flag(mapping, "ignore_input_scope", false))); ++ } ++ } ++ final var options = new Options(wildcardScope, flag(properties, "always_handle_pointer", false), ++ flag(properties, "always_handle_controller_direction", false), wildcard, ++ flag(properties, "always_listen_to_input", false), flag(properties, "modal", false), ++ flag(properties, "inline_modal", false), flag(properties, "hover_enabled", true), ++ flag(properties, "consume_hover_events", true), flag(properties, "prevent_touch_input", false), ++ flag(properties, "gamepad_deflection_mode", false)); ++ return new NativeUiInput(options, List.copyOf(mappings), List.copyOf(hover)); + } + -+ @Override -+ public boolean preeditUpdated(final PreeditEvent event) { -+ if (!this.capturesInput()) return false; -+ this.preedit = event == null ? null : new IMEPreeditOverlay(event, this.font, LINE_HEIGHT + 1); -+ return true; ++ private static String string(final JsonObject properties, final String key) { ++ final var value = properties.get(key); ++ return value != null && value.isJsonPrimitive() && value.getAsJsonPrimitive().isString() ? value.getAsString() : ""; + } + -+ @Override -+ public void onClick(final MouseButtonEvent event, final boolean doubleClick) { -+ if (!this.editable) return; -+ if (doubleClick) this.text.selectWordAtCursor(); -+ else { -+ this.text.setSelecting(event.hasShiftDown()); -+ this.seek(event); -+ } ++ private static boolean flag(final JsonObject properties, final String key, final boolean fallback) { ++ return NativeUiScene.flag(properties, key, fallback); + } + -+ @Override -+ protected void onDrag(final MouseButtonEvent event, final double deltaX, final double deltaY) { -+ if (!this.capturesInput()) return; -+ this.text.setSelecting(true); -+ this.seek(event); -+ this.text.setSelecting(event.hasShiftDown()); ++ public static int symbol(final String value) { ++ int result = 0x811c9dc5; ++ for (final byte octet : value.getBytes(StandardCharsets.UTF_8)) result = (result ^ Byte.toUnsignedInt(octet)) * 0x01000193; ++ return result; + } + -+ private void seek(final MouseButtonEvent event) { -+ this.text.seekCursorToPoint(event.x() - this.getX(), event.y() - this.getY() + this.scroll); -+ } ++ public static final class State { ++ private final NativeUiInput definition; ++ private final int[] phases; ++ private final java.util.Map presses = new java.util.HashMap<>(); + -+ @Override -+ public boolean mouseScrolled(final double x, final double y, final double horizontal, final double vertical) { -+ if (!this.active || !this.visible || !this.isMouseOver(x, y) || vertical == 0) return false; -+ final double previous = this.scroll; -+ this.scroll = Math.clamp(previous - vertical * LINE_HEIGHT, 0, this.maximumScroll()); -+ return this.scroll != previous; -+ } ++ public State(final NativeUiInput definition) { ++ this.definition = definition; ++ this.phases = new int[definition.mappings.size()]; ++ } + -+ @Override -+ public void playDownSound(final SoundManager sounds) { -+ // Editing text does not play a button activation sound. -+ } ++ public int phase(final int index) { return this.phases[index]; } + -+ @Override -+ protected void extractWidgetRenderState(final GuiGraphicsExtractor graphics, final int mouseX, final int mouseY, -+ final float delta) { -+ graphics.enableScissor(this.getX(), this.getY(), this.getRight(), this.getBottom()); -+ try { -+ final int color = Math.round(this.alpha * (this.textColor >>> 24)) << 24 | this.textColor & 0xFFFFFF; -+ final String value = this.text.value(); -+ final int cursorLine = this.text.getLineAtCursor(); -+ final var cursorView = this.text.line(cursorLine); -+ final int cursorX = this.getX() + this.font.width(value.substring(cursorView.start(), this.text.cursor())); -+ final int cursorY = this.getY() + cursorLine * LINE_HEIGHT - (int) this.scroll; -+ if (value.isEmpty() && !this.isFocused()) { -+ graphics.textWithWordWrap(this.font, NativeUiText.component(this.placeholder), this.getX(), this.getY(), -+ this.width, color); -+ } else { -+ final var selected = this.text.selection(); -+ int y = this.getY() - (int) this.scroll; -+ for (final var line : this.text.lines()) { -+ if (y + LINE_HEIGHT > this.getY() && y < this.getBottom()) { -+ graphics.text(this.font, value.substring(line.start(), line.end()), this.getX(), y, color, this.shadow); -+ final int start = Math.max(line.start(), selected.start()); -+ final int end = Math.min(line.end(), selected.end()); -+ if (start < end || selected.start() <= line.end() && selected.end() > line.end()) { -+ final int left = this.getX() + this.font.width(value.substring(line.start(), start)); -+ final int right = selected.end() > line.end() ? this.getRight() -+ : this.getX() + this.font.width(value.substring(line.start(), end)); -+ graphics.textHighlight(left, y, right, y + LINE_HEIGHT, true); -+ } -+ } -+ y += LINE_HEIGHT; -+ } ++ /** Visibility callbacks and input-order removal preserve double-press history. */ ++ public void invalidateMappings() { java.util.Arrays.fill(this.phases, 2); } ++ ++ /** Dispatches an Input component without sibling event handlers. */ ++ public Dispatch dispatch(final int source, final int phase, final Context context) { ++ return this.dispatch(source, phase, context, _ -> { }); ++ } ++ ++ /** Sibling components run before the sender commits its accepted mapping phase. */ ++ public Dispatch dispatch(final int source, final int phase, final Context context, final Consumer handler) { ++ final List events = new ArrayList<>(); ++ int result = 0; ++ if (this.definition.options.wildcard) { ++ final var event = event(source, source, 2, phase, context.mode, this.definition.options.wildcardScope, false, false); ++ handler.accept(event); ++ events.add(event); ++ result = 1; + } -+ if (this.capturesInput()) { -+ if (TextCursorUtils.isCursorVisible(Util.getMillis() - this.focusedTime)) -+ TextCursorUtils.extractInsertCursor(graphics, cursorX, cursorY, color, LINE_HEIGHT); -+ if (this.preedit == null) -+ Minecraft.getInstance().textInputManager().setTextInputArea(cursorX, cursorY, cursorX + 1, cursorY + LINE_HEIGHT + 1); -+ else { -+ this.preedit.updateInputPosition(cursorX, cursorY); -+ graphics.setPreeditOverlay(this.preedit); -+ } ++ final boolean doubled = this.doublePress(source, phase, context); ++ for (int index = 0; index < this.definition.mappings.size(); index++) { ++ final var mapping = this.definition.mappings.get(index); ++ if (mapping.source != source || doubled != (mapping.kind == Kind.DOUBLE_PRESSED || context.touchFallback)) continue; ++ final boolean condition = mapping.condition != Condition.NOT_GAMEPAD || context.mode != 3; ++ final boolean gamepad = mapping.condition != Condition.GAMEPAD || context.mode == 3; ++ final boolean ordinary = !doubled || context.touchFallback; ++ final boolean admitted = condition && gamepad && switch (mapping.kind) { ++ case GLOBAL -> true; ++ case FOCUSED -> (context.flags & 0x82) != 0; ++ case PRESSED -> ordinary && this.pointer(mapping, phase, this.phases[index], context); ++ case DOUBLE_PRESSED -> !ordinary && this.pointer(mapping, phase, this.phases[index], context); ++ }; ++ if (!admitted) { this.phases[index] = 2; result = 0; continue; } ++ final int previous = this.phases[index]; ++ final var event = event(mapping.source, mapping.target, previous, phase, context.mode, ++ mapping.scope, mapping.handleSelect, mapping.handleDeselect); ++ handler.accept(event); ++ events.add(event); ++ this.phases[index] = phase; ++ if ((context.handlerMask & 1) == 0) result = mapping.consume ? 1 : 2; ++ return new Dispatch(result, List.copyOf(events), phase == 1 && mapping.firstRefusal ? List.of(source) : List.of()); + } -+ if (this.isHovered() && this.active && this.editable) graphics.requestCursor(CursorTypes.IBEAM); -+ } finally { -+ graphics.disableScissor(); ++ return new Dispatch(result, List.copyOf(events), List.of()); + } -+ } + -+ private record Range(int start, int end) {} -+ -+ /** Exposes the protected line spans to the authored renderer without changing editing behavior. */ -+ private static final class TextField extends MultilineTextField { -+ TextField(final Font font, final int width) { super(font, width); } -+ -+ Range selection() { -+ final var selected = this.getSelected(); -+ return new Range(selected.beginIndex(), selected.endIndex()); ++ /** The caller supplies geometry/control admission; hover_enabled is checked by the native pointer entry, not this handler. */ ++ public Hover hover(final boolean previous, final boolean inside, final boolean forcedOut, final int handlerMask) { ++ final boolean hovered = !forcedOut && inside; ++ final boolean changed = previous != hovered; ++ final List events = new ArrayList<>(); ++ if (changed) for (final var binding : this.definition.hoverBindings) { ++ events.add(new ButtonEvent(0, binding.target, 0, hovered ? 1 : 0, false, 0, binding.scope, false, false)); ++ } ++ final boolean handled = hovered && (!changed || (handlerMask & 1) == 0); ++ return new Hover(forcedOut || handled && this.definition.options.consumeHoverEvents ? 1 : 0, ++ hovered, changed, List.copyOf(events)); + } + -+ Range line(final int index) { -+ final var line = this.getLineView(index); -+ return new Range(line.beginIndex(), line.endIndex()); ++ public Pointer pointer(final boolean previous, final boolean inside, final boolean forcedOut, final int handlerMask) { ++ if (!this.definition.options.hoverEnabled) return new Pointer(0, new Hover(0, previous, false, List.of()), false); ++ final Hover hover = this.hover(previous, inside, forcedOut, handlerMask); ++ final boolean deliverPointer = !forcedOut || this.definition.options.alwaysHandlePointer; ++ return new Pointer(deliverPointer && (handlerMask & 1) != 0 ? 0 : hover.result, hover, deliverPointer); + } + -+ List lines() { -+ final List lines = new ArrayList<>(); -+ for (final var line : this.iterateLines()) lines.add(new Range(line.beginIndex(), line.endIndex())); -+ return lines; ++ public void suppress(final int source, final int phase, final Context context) { ++ final boolean doubled = this.doublePress(source, phase, context); ++ for (int index = 0; index < this.definition.mappings.size(); index++) { ++ final var mapping = this.definition.mappings.get(index); ++ if (mapping.source == source && doubled == (mapping.kind == Kind.DOUBLE_PRESSED || context.touchFallback)) this.phases[index] = 2; ++ } + } -+ } + -+ @Override -+ protected void updateWidgetNarration(final NarrationElementOutput narration) { -+ narration.add(NarratedElementType.TITLE, Component.translatable("gui.narrate.editBox", this.getMessage(), this.value())); ++ private boolean pointer(final Mapping mapping, final int phase, final int previous, final Context context) { ++ if (mapping.ignoreScope && (context.flags & 0x20) != 0) return true; ++ final boolean admitted = mapping.alternateScope ? context.insideAlternate ++ : (context.flags & 2) != 0 || this.definition.options.alwaysListenToInput && context.inside; ++ return admitted && (context.mode == 3 || context.insideClip) || phase == 0 && mapping.firstRefusal && previous == 1; ++ } ++ ++ private boolean doublePress(final int source, final int phase, final Context context) { ++ if (!context.touchFallback && this.definition.mappings.stream().noneMatch(mapping -> mapping.source == source && mapping.kind == Kind.DOUBLE_PRESSED)) return false; ++ final var history = this.presses.computeIfAbsent(source, key -> new PressHistory()); ++ if (phase != 1) { ++ if (history.phase != 2) return false; ++ if (phase == 0) history.phase = 0; ++ return true; ++ } ++ final float dx = context.x - history.x, dy = context.y - history.y; ++ final float distance = (float) Math.sqrt(dx * dx + dy * dy); ++ final boolean doubled = history.phase != 0 && context.nanos - history.nanos <= 499_999_999L && distance < 10F; ++ history.phase = doubled ? 2 : 1; ++ history.nanos = context.nanos; ++ history.x = context.x; ++ history.y = context.y; ++ return doubled; ++ } ++ ++ private static ButtonEvent event(final int source, final int target, final int previous, final int phase, ++ final int mode, final Scope scope, final boolean select, final boolean deselect) { ++ final int activation = mode == 2 ? 0 : 1; ++ return new ButtonEvent(source, target, previous, phase, phase == activation && previous != activation, ++ mode, scope, select, deselect); ++ } ++ ++ private static final class PressHistory { ++ private int phase; ++ private long nanos; ++ private float x; ++ private float y; ++ } ++ } ++ ++ public record Bounds(float x, float y, float width, float height) { ++ public boolean contains(final float pointX, final float pointY) { ++ return pointX >= this.x && pointX <= this.x + this.width && pointY >= this.y && pointY <= this.y + this.height; ++ } ++ } ++ ++ public record Ancestor(Bounds bounds, boolean clip, boolean scroll, Bounds alternateTrack) {} ++ ++ /** Ancestors are supplied nearest first; native input tests only the nearest clipping ancestor. */ ++ public record Geometry(Bounds bounds, List ancestors) { ++ public boolean insideClip(final float x, final float y) { ++ for (final var parent : this.ancestors) if (parent.clip) return parent.bounds.contains(x, y); ++ return true; ++ } ++ ++ public boolean insideAlternate(final int flags, final float x, final float y) { ++ if (this.ancestors.isEmpty()) return false; ++ final var parent = this.ancestors.getFirst(); ++ if (parent.scroll && (parent.alternateTrack == null || !parent.alternateTrack.contains(x, y))) return false; ++ return (flags & 0xc) != 0xc || !this.bounds.contains(x, y); ++ } ++ } ++ ++ public record Context(int flags, int mode, float x, float y, boolean inside, boolean insideClip, ++ boolean insideAlternate, boolean touchFallback, long nanos, int handlerMask) {} ++ ++ /** Retains the native raw phase; only phase one is a pressed controller event. */ ++ public record ButtonEvent(int source, int target, int previous, int phase, boolean transition, int mode, ++ Scope scope, boolean handleSelect, boolean handleDeselect) { ++ public boolean pressed() { return this.phase == 1; } + } ++ public record Dispatch(int result, List events, List captureRequests) {} ++ public record Hover(int result, boolean hovered, boolean changed, List events) {} ++ public record Pointer(int result, Hover hover, boolean pointerEvent) {} ++ ++ public record Options(Scope wildcardScope, boolean alwaysHandlePointer, boolean alwaysHandleControllerDirection, ++ boolean wildcard, boolean alwaysListenToInput, boolean modal, boolean inlineModal, ++ boolean hoverEnabled, boolean consumeHoverEvents, boolean preventTouchInput, boolean gamepadDeflectionMode) {} ++ public record Mapping(int source, int target, Kind kind, Scope scope, boolean firstRefusal, boolean handleSelect, ++ boolean handleDeselect, boolean alternateScope, boolean consume, Condition condition, boolean ignoreScope) {} ++ public record HoverBinding(int target, Scope scope) {} ++ public enum Kind { GLOBAL, DOUBLE_PRESSED, PRESSED, FOCUSED } ++ public enum Scope { DEFAULT, VIEW, GLOBAL } ++ public enum Condition { ANY, NOT_GAMEPAD, GAMEPAD } +} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiPlayerCamera.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiPlayerCamera.java +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiInputRouter.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiInputRouter.java new file mode 100644 -index 0000000..81c52c3 +index 0000000..8c0c6f6 --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiPlayerCamera.java ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiInputRouter.java @@ -0,0 +1,72 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock @@ -8323,77 +9532,77 @@ index 0000000..81c52c3 + */ +package com.viaversion.viafabricplus.bedrock.screen.ui; + -+import com.google.gson.JsonObject; -+import org.joml.Matrix4f; -+import org.joml.Vector3f; ++import java.util.ArrayList; ++import java.util.HashMap; ++import java.util.LinkedHashMap; ++import java.util.List; ++import java.util.Map; ++import java.util.Set; ++import java.util.function.Consumer; ++import java.util.function.Supplier; + -+/** Local player framing and independent actor, body and head rotations from the native draw. */ -+public record NativeUiPlayerCamera(float scale, Vector3f translation, Matrix4f modelTransform, -+ float pitch, float yaw, float bodyYaw, float headYaw) { -+ public static NativeUiPlayerCamera resolve(final NativeUiLayout.Rect control, final NativeUiLayout.Rect viewport, -+ final float layer, final JsonObject properties, final Cursor cursor, final Rotation rotation, -+ final boolean gliding, final boolean swimming, final boolean crawling) { -+ if (!(control.width() > 0) || !(control.height() > 0) -+ || !Float.isFinite(control.x() + control.y() + control.width() + control.height() + layer)) -+ throw new IllegalArgumentException("Invalid native player preview geometry"); -+ final float centerX = control.x() + control.width() * .5F; -+ final float centerY = control.y() + control.height() * .5F; -+ float scale = Math.min(control.width(), control.height()); -+ final float x = looksAtCursor(properties) && cursor != null ? centerX - cursor.x : 0; -+ final float y = looksAtCursor(properties) && cursor != null ? centerY - cursor.y : 0; -+ final float xAngle = (float) Math.atan(x / 40F); -+ final float yAngle = (float) Math.atan(y / 40F); -+ final float pitch = gliding ? rotation.pitch : yAngle * -20F; -+ final float yaw = gliding ? rotation.yaw : xAngle * 40F; -+ final float bodyYaw = gliding ? 0 : xAngle * 20F; -+ final float headYaw = gliding -+ ? ((rotation.headYaw + rotation.previousHeadYaw) - rotation.previousHeadYaw) -+ - ((rotation.bodyYaw + rotation.previousBodyYaw) - rotation.previousBodyYaw) -+ : yaw; -+ final Matrix4f model; -+ final float yOffset; -+ if (gliding) { -+ scale *= .6F; -+ yOffset = 15; -+ model = new Matrix4f().rotationX(.17453292F).scale(-scale, scale, 1).rotateZ((float) Math.PI); -+ } else { -+ yOffset = swimming || crawling ? -20 : 0; -+ model = new Matrix4f().scaling(-scale, scale, 1).rotateZ((float) Math.PI) -+ .rotateX(pitch * .017453292F); -+ } -+ // PIP supplies (scale, -scale, 1). Keep the native transform order after its translation. -+ final Matrix4f correction = new Matrix4f().scaling(1F / scale, -1F / scale, 1).mul(model); -+ final Vector3f translation = new Vector3f((centerX - viewport.x() - viewport.width() * .5F) / scale, -+ (viewport.y() + viewport.height() * .5F - centerY - yOffset) / scale, -+ (layer + 1F) * 10F - 1000F); -+ return new NativeUiPlayerCamera(scale, translation, correction, pitch, yaw, bodyYaw, headYaw); -+ } ++/** Routes captured releases before the current control order and retires skipped mapping phases. */ ++public final class NativeUiInputRouter { ++ private final Map> captures = new HashMap<>(); + -+ public Vector3f translation(final float playerScale) { -+ if (playerScale < 0 || !Float.isFinite(playerScale)) -+ throw new IllegalArgumentException("Invalid native player preview scale"); -+ return this.modelTransform.transformDirection(new Vector3f(0, -1.501F * .9375F * playerScale, 0)) -+ .add(this.translation); ++ public Result dispatch(final int source, final int phase, final List> controls) { ++ final Map> indexed = new LinkedHashMap<>(); ++ for (final var control : controls) indexed.put(control.key, control); ++ final List> events = new ArrayList<>(); ++ boolean consumed = false; ++ final var captured = phase != 0 ? this.captures.get(source) : this.captures.remove(source); ++ if (phase == 0 && captured != null) { ++ for (final K key : captured) { ++ final var control = indexed.get(key); ++ if (control == null) continue; ++ final var result = control.state.dispatch(source, 0, control.context.get(), control.handler); ++ for (final var event : result.events()) { ++ events.add(new Event<>(key, event)); ++ } ++ consumed = result.result() == 1; ++ if (consumed) break; ++ } ++ } ++ final List requested = new ArrayList<>(phase != 0 && captured != null ? captured : List.of()); ++ for (final var control : controls) { ++ if (!control.registered) continue; ++ if (consumed) { ++ control.state.suppress(source, phase, control.context.get()); ++ continue; ++ } ++ final var result = control.state.dispatch(source, phase, control.context.get(), control.handler); ++ for (final var event : result.events()) { ++ events.add(new Event<>(control.key, event)); ++ } ++ for (final int capture : result.captureRequests()) { ++ if (capture != source) throw new IllegalStateException("Native capture changed its source input"); ++ requested.add(control.key); ++ } ++ consumed = result.result() == 1; ++ } ++ if (!requested.isEmpty()) this.captures.put(source, List.copyOf(requested)); ++ return new Result<>(consumed, List.copyOf(events)); + } + -+ static boolean looksAtCursor(final JsonObject properties) { -+ final var bag = properties.getAsJsonObject("property_bag"); -+ final var value = bag == null ? null : bag.get("#look_at_cursor"); -+ return value != null && value.isJsonPrimitive() && value.getAsJsonPrimitive().isBoolean() && value.getAsBoolean(); ++ public void retain(final Set controls) { ++ this.captures.replaceAll((source, captured) -> captured.stream().filter(controls::contains).toList()); ++ this.captures.values().removeIf(List::isEmpty); + } + -+ /** Logical cursor position, after the current input source supplied its admission and coordinates. */ -+ public record Cursor(float x, float y) {} ++ public boolean captures(final int source) { return this.captures.containsKey(source); } + -+ public record Rotation(float pitch, float yaw, float bodyYaw, float previousBodyYaw, -+ float headYaw, float previousHeadYaw) {} ++ /** Captured controls remain addressable after they leave the normal input order. */ ++ public record Control(K key, NativeUiInput.State state, Supplier context, ++ Consumer handler, boolean registered) {} ++ public record Event(K key, NativeUiInput.ButtonEvent button) {} ++ public record Result(boolean consumed, List> events) {} +} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiPresentation.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiPresentation.java +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiJpegTexture.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiJpegTexture.java new file mode 100644 -index 0000000..99d79fd +index 0000000..a0dfa44 --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiPresentation.java -@@ -0,0 +1,162 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiJpegTexture.java +@@ -0,0 +1,83 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -8401,167 +9610,88 @@ index 0000000..99d79fd + */ +package com.viaversion.viafabricplus.bedrock.screen.ui; + -+import java.util.ArrayList; -+import java.util.Collections; -+import java.util.Comparator; -+import java.util.HashSet; -+import java.util.IdentityHashMap; -+import java.util.List; -+import java.util.Set; -+import java.util.function.Function; -+import java.util.function.Predicate; -+ -+/** Selects native visual states before layout while preserving control identities. */ -+public final class NativeUiPresentation { -+ private static final List SLIDER_STATES = List.of("default_control", "hover_control"); -+ private static final List THUMB_STATES = List.of("default_control", "hover_control", "locked_control", "indent_control"); ++import com.mojang.blaze3d.platform.NativeImage; ++import java.awt.image.BufferedImage; ++import java.io.ByteArrayInputStream; ++import java.io.IOException; ++import java.io.InputStream; ++import javax.imageio.ImageIO; ++import javax.imageio.stream.MemoryCacheImageInputStream; ++import net.minecraft.client.renderer.texture.ReloadableTexture; ++import net.minecraft.client.renderer.texture.TextureContents; ++import net.minecraft.resources.Identifier; ++import net.minecraft.server.packs.resources.ResourceManager; ++import net.raphimc.viabedrock.api.resourcepack.ui.UiTextureLimits; + -+ private NativeUiPresentation() {} ++/** Native JPEG resources keep their encoding in the pack and decode only when displayed. */ ++final class NativeUiJpegTexture extends ReloadableTexture { ++ private BufferedImage prepared; + -+ public static boolean updateSliderStates(final NativeUiScene.Node root, -+ final Predicate hovered, -+ final Predicate selected) { -+ return updateSliderStates(root, true, true, false, null, hovered, selected); ++ NativeUiJpegTexture(final Identifier id, final BufferedImage prepared) { ++ super(id); ++ this.prepared = prepared; + } + -+ private static boolean updateSliderStates(final NativeUiScene.Node node, final boolean inheritedVisible, -+ final boolean inheritedEnabled, final boolean stateBranch, -+ final Boolean activeState, final Predicate hovered, -+ final Predicate selected) { -+ // Native named-state setters replace the branch root's authored visibility, not its descendants' bindings. -+ final boolean ownVisible = activeState != null ? activeState -+ : stateBranch ? node.flag("visible_when_active", node.flag("visible", true)) : node.flag("visible", true); -+ final boolean visible = inheritedVisible && ownVisible && !node.flag("ignored", false); -+ boolean changed = false; -+ if (stateBranch && node.flag("visible", true) != visible) { -+ node.properties().addProperty("visible", visible); -+ changed = true; -+ } -+ final boolean enabled = inheritedEnabled && node.flag("enabled", true); -+ final List states = switch (node.type()) { -+ case "slider" -> SLIDER_STATES; -+ case "slider_box" -> THUMB_STATES; -+ default -> List.of(); -+ }; -+ final String selectedName; -+ if (states.isEmpty()) { -+ selectedName = ""; -+ } else { -+ final String selectedProperty = node.type().equals("slider_box") -+ ? !enabled ? "locked_control" : selected.test(node) ? "indent_control" : hovered.test(node) ? "hover_control" : "default_control" -+ : hovered.test(node) ? "hover_control" : "default_control"; -+ selectedName = node.string(selectedProperty, ""); -+ } -+ for (final var child : node.children()) { -+ Boolean active = null; -+ for (final String state : states) { -+ final String name = node.string(state, ""); -+ if (!name.isEmpty() && name.equals(child.name())) { -+ active = name.equals(selectedName); -+ break; -+ } ++ @Override ++ public TextureContents loadContents(final ResourceManager resources) throws IOException { ++ BufferedImage image = this.prepared; ++ this.prepared = null; ++ if (image == null) { ++ try (final var input = resources.getResourceOrThrow(this.resourceId()).open()) { ++ image = decode(input); + } -+ changed |= updateSliderStates(child, visible, enabled, stateBranch || active != null, active, hovered, selected); + } -+ return changed; -+ } -+ -+ public static List draws(final NativeUiLayout.Box root, final Predicate hovered) { -+ return draws(root, hovered, node -> new Opacity(node.number("alpha", 1), node.number("alpha", 1))); -+ } -+ -+ public static List draws(final NativeUiLayout.Box root, final Predicate hovered, -+ final Function opacity) { -+ return draws(root, hovered, opacity, _ -> false); ++ return new TextureContents(pixels(image), null); + } + -+ /** Retained Button components apply their named setters before layout. */ -+ public static List draws(final NativeUiLayout.Box root, final Predicate hovered, -+ final Function opacity, -+ final Predicate retainedButtons) { -+ final List result = new ArrayList<>(); -+ collect(root, new Opacity(1, 1), true, hovered, opacity, retainedButtons, -+ Collections.newSetFromMap(new IdentityHashMap<>()), result); -+ // Native traversal retains authored child order. Sort the complete draw list -+ // so equal cumulative layers keep that order across different subtrees. -+ result.sort(Comparator.comparingDouble(draw -> draw.box().layer())); -+ return result; ++ @Override ++ public void close() { ++ this.prepared = null; ++ super.close(); + } + -+ private static void collect(final NativeUiLayout.Box box, final Opacity inheritedAlpha, -+ final boolean inheritedEnabled, final Predicate hovered, -+ final Function opacity, -+ final Predicate retainedButtons, -+ final Set hidden, final List result) { -+ final var node = box.node(); -+ if (hidden.contains(node) || !node.flag("visible", true) || node.flag("ignored", false)) return; -+ if (node.scroll() && !node.flag("scrollbar_always_visible", false) && NativeUiScroll.maximum(box) == 0) { -+ final var panel = NativeUiScroll.descendant(box, node.string("scroll_box_and_track_panel", "")); -+ if (panel != null) hidden.add(panel.node()); -+ else { -+ for (final String property : List.of("scrollbar_track", "scrollbar_box")) { -+ final var control = NativeUiScroll.descendant(box, node.string(property, "")); -+ if (control != null) hidden.add(control.node()); -+ } ++ static BufferedImage decode(final InputStream source) throws IOException { ++ final byte[] bytes = source.readNBytes(UiTextureLimits.MAX_ENCODED_BYTES + 1); ++ if (bytes.length > UiTextureLimits.MAX_ENCODED_BYTES) throw new IOException("Native UI texture exceeds byte limits"); ++ try (final var input = new MemoryCacheImageInputStream(new ByteArrayInputStream(bytes))) { ++ final var readers = ImageIO.getImageReaders(input); ++ if (!readers.hasNext()) throw new IOException("Unsupported native UI image"); ++ final var reader = readers.next(); ++ try { ++ reader.setInput(input, true, true); ++ if (!UiTextureLimits.supportsDimensions(reader.getWidth(0), reader.getHeight(0))) ++ throw new IOException("Native UI texture exceeds dimension limits"); ++ final BufferedImage image = reader.read(0); ++ if (image == null || !UiTextureLimits.supportsDimensions(image.getWidth(), image.getHeight())) ++ throw new IOException("Invalid native UI image"); ++ return image; ++ } finally { ++ reader.dispose(); + } + } -+ final Opacity local = opacity.apply(node); -+ final Opacity alpha = new Opacity(inheritedAlpha.current * Math.clamp(local.current, 0, 1), -+ inheritedAlpha.potential * Math.clamp(local.potential, 0, 1)); -+ final boolean enabled = inheritedEnabled && node.flag("enabled", true); -+ if (alpha.potential > 0) result.add(new Draw(box, alpha.current, alpha.potential, enabled)); -+ // Native image opacity is local unless the owner explicitly enables propagation. -+ // Dialogs use transparent image owners whose labels and buttons remain opaque. -+ final Opacity childAlpha = node.flag("propagate_alpha", false) ? alpha : inheritedAlpha; -+ if (childAlpha.potential == 0 || box.children().isEmpty()) return; -+ final boolean isHovered = node.interactive() && hovered.test(box); -+ final Set excluded = new HashSet<>(); -+ if (!node.type().equals("slider") && !node.type().equals("slider_box") && !retainedButtons.test(node)) { -+ select(node, excluded, !enabled ? "locked_control" : isHovered ? "hover_control" : "default_control", "default_control", -+ "default_control", "hover_control", "pressed_control", "locked_control"); -+ } -+ if (node.type().equals("toggle")) { -+ final String state = node.flag("toggle_state", false) ? "checked" : "unchecked"; -+ select(node, excluded, state + (!enabled ? "_locked" : "") + (isHovered ? "_hover" : "") + "_control", state + "_control", -+ "checked_control", "unchecked_control", "checked_hover_control", "unchecked_hover_control", -+ "checked_locked_control", "unchecked_locked_control", "checked_locked_hover_control", "unchecked_locked_hover_control"); -+ } -+ if (node.type().equals("slider")) { -+ select(node, excluded, isHovered ? "background_hover_control" : "background_control", "background_control", -+ "background_control", "background_hover_control"); -+ select(node, excluded, isHovered ? "progress_hover_control" : "progress_control", "progress_control", -+ "progress_control", "progress_hover_control"); -+ } -+ if (node.type().equals("edit_box") && node.properties().has("input")) { -+ excluded.add(node.string("text_control", "")); -+ excluded.add(node.string("place_holder_control", "")); -+ } -+ for (final var child : box.children()) { -+ if (excluded.contains(child.node().name())) continue; -+ collect(child, childAlpha, enabled, hovered, opacity, retainedButtons, hidden, result); -+ } + } + -+ private static void select(final NativeUiScene.Node node, final Set excluded, -+ final String selectedProperty, final String fallbackProperty, final String... states) { -+ String selected = node.string(selectedProperty, ""); -+ if (selected.isEmpty()) selected = node.string(fallbackProperty, ""); -+ for (final String state : states) { -+ final String name = node.string(state, ""); -+ if (!name.isEmpty() && !name.equals(selected)) excluded.add(name); ++ static NativeImage pixels(final BufferedImage image) { ++ final NativeImage pixels = new NativeImage(image.getWidth(), image.getHeight(), false); ++ try { ++ final int[] row = new int[image.getWidth()]; ++ for (int y = 0; y < image.getHeight(); y++) { ++ image.getRGB(0, y, image.getWidth(), 1, row, 0, image.getWidth()); ++ for (int x = 0; x < image.getWidth(); x++) pixels.setPixel(x, y, row[x]); ++ } ++ return pixels; ++ } catch (final RuntimeException | Error failure) { ++ pixels.close(); ++ throw failure; + } + } -+ -+ public record Opacity(float current, float potential) {} -+ -+ public record Draw(NativeUiLayout.Box box, float alpha, float potential, boolean enabled) {} +} -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 +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiLayout.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiLayout.java new file mode 100644 -index 0000000..f64ab0d +index 0000000..64b45fa --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiRenderer.java -@@ -0,0 +1,182 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiLayout.java +@@ -0,0 +1,633 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -8569,300 +9699,638 @@ index 0000000..f64ab0d + */ +package com.viaversion.viafabricplus.bedrock.screen.ui; + -+import com.mojang.serialization.JsonOps; -+import java.util.IdentityHashMap; ++import com.google.gson.JsonElement; +import java.util.ArrayList; +import java.util.Collections; ++import java.util.HashMap; ++import java.util.IdentityHashMap; +import java.util.List; -+import java.util.Set; +import java.util.Map; -+import net.minecraft.client.gui.Font; -+import net.minecraft.client.gui.GuiGraphicsExtractor; -+import net.minecraft.network.chat.Component; -+import net.minecraft.network.chat.ComponentSerialization; -+import net.minecraft.network.chat.FontDescription; -+import net.minecraft.resources.Identifier; -+import net.minecraft.util.FormattedCharSequence; -+ -+/** Shared accepted-pack text, image and intrinsic metrics, independent of screen authority. */ -+public final class NativeUiRenderer implements NativeUiLayout.Metrics { -+ private final NativeUiActorPortraits portraits = new NativeUiActorPortraits(); -+ private final Font font; -+ private final NativeUiImages images; -+ private final Map texts = new IdentityHashMap<>(); -+ private final Map wrapped = new IdentityHashMap<>(); -+ -+ public NativeUiRenderer(final Font font, final NativeUiImages images) { -+ this.font = font; -+ this.images = images; -+ } ++import java.util.Set; ++import java.util.function.Function; + -+ public void reset() { this.texts.clear(); this.wrapped.clear(); } -+ public void retain(final List roots) { -+ final Set live = Collections.newSetFromMap(new IdentityHashMap<>()); -+ for (final var root : roots) collect(root, live); -+ this.texts.keySet().retainAll(live); -+ this.wrapped.keySet().retainAll(live); -+ } ++/** Resolves authored anchors and content dimensions independently of the rendering backend. */ ++public final class NativeUiLayout { ++ private static final float LIMIT = 16384; ++ private static final Size EMPTY = new Size(0, 0); ++ private final Metrics metrics; ++ private final NativeUiAnimations animations; ++ private final Map> measured = new IdentityHashMap<>(); ++ private final Map visibleNodes = new IdentityHashMap<>(); ++ private final Map windows = new IdentityHashMap<>(); ++ private Function placements; ++ private final Map cursorOffsets = new IdentityHashMap<>(); ++ private float verticalLimit; ++ private int work; + -+ private static void collect(final NativeUiScene.Node node, final Set live) { -+ live.add(node); -+ for (final var child : node.children()) collect(child, live); ++ public NativeUiLayout(final Metrics metrics) { ++ this(metrics, null); + } -+ public void close() { this.reset(); this.portraits.clear(); } -+ public void prepare(final NativeUiScene.Node root) { this.portraits.prepare(root); } + -+ public void portrait(final GuiGraphicsExtractor graphics, final NativeUiLayout.Box box, -+ final float mouseX, final float mouseY, final float partialTick, final float alpha) { -+ this.portraits.draw(graphics, box, mouseX, mouseY, partialTick, alpha); ++ public NativeUiLayout(final Metrics metrics, final NativeUiAnimations animations) { ++ this.metrics = metrics; ++ this.animations = animations; + } + -+ @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)).lines.size(); -+ return NativeUiFont.constrained(node, width, lines); ++ private NativeUiDimension dimensionValue(final NativeUiScene.Node node, final String key, final int axis) { ++ final NativeUiDimension animated = this.animations == null ? null : this.animations.dimension(node, key, axis); ++ if (animated != null) return animated; ++ final JsonElement pair = node.properties().get(key); ++ if (pair == null || !pair.isJsonArray() || pair.getAsJsonArray().size() != 2) return null; ++ return NativeUiDimension.parse(pair.getAsJsonArray().get(axis)); + } + -+ public void draw(final GuiGraphicsExtractor graphics, final NativeUiPresentation.Draw draw, final NativeUiAnimations animations) { -+ final var node = draw.box().node(); -+ if (node.type().equals("image") && !node.string("texture", "").isEmpty()) -+ this.images.draw(graphics, draw.box(), imageColor(node, draw.alpha(), animations), animations); -+ else if (node.type().equals("label")) this.label(graphics, draw.box(), draw.alpha()); ++ public Box layout(final NativeUiScene.Node node, final int width, final int height, ++ final Map scroll) { ++ return this.layout(node, width, height, scroll, _ -> null); + } + -+ public NativeUiLayout.Size intrinsic(final NativeUiScene.Node node) { -+ return switch (node.type()) { -+ case "label" -> { -+ 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()); -+ } -+ case "image" -> { -+ if (node.string("texture", "").isEmpty()) yield new NativeUiLayout.Size(0, 0); -+ final var image = this.images.image(node); -+ yield image == null ? new NativeUiLayout.Size(16, 16) : new NativeUiLayout.Size(image.width(), image.height()); -+ } -+ case "button", "toggle" -> new NativeUiLayout.Size(200, 20); -+ default -> new NativeUiLayout.Size(0, 0); -+ }; ++ public Box layout(final NativeUiScene.Node node, final int width, final int height, ++ final Map scroll, ++ final Function placements) { ++ this.placements = placements; ++ this.work = 0; ++ final Rect viewport = new Rect(0, 0, width, height); ++ Set hidden = Collections.newSetFromMap(new IdentityHashMap<>()); ++ final List> states = new ArrayList<>(); ++ while (true) { ++ this.measured.clear(); ++ this.visibleNodes.clear(); ++ this.windows.clear(); ++ this.cursorOffsets.clear(); ++ this.verticalLimit = LIMIT; ++ this.visibility(node, true, hidden); ++ final Size size = this.measure(node, width, height); ++ final Box result = this.place(node, viewport, size, 0, viewport, scroll, null); ++ // Native scrollbar callbacks change computed visibility and schedule layout again. ++ // Keep the declared dimensions intact so resize and content refresh can restore them. ++ final Set next = Collections.newSetFromMap(new IdentityHashMap<>()); ++ this.scrollVisibility(result, next); ++ if (sameNodes(hidden, next)) return this.cursorOffsets.isEmpty() ? result ++ : this.positionCursors(result, viewport, viewport, new IdentityHashMap<>()); ++ if (states.stream().anyMatch(state -> sameNodes(state, next))) { ++ throw new IllegalArgumentException("Native UI scrollbar layout does not settle"); ++ } ++ states.add(hidden); ++ hidden = next; ++ } + } + -+ public void label(final GuiGraphicsExtractor graphics, final NativeUiLayout.Box box, final float alpha) { -+ this.label(graphics, box, alpha, this.text(box.node())); ++ private static boolean sameNodes(final Set first, final Set second) { ++ return first.size() == second.size() && first.stream().allMatch(second::contains); + } + -+ public void label(final GuiGraphicsExtractor graphics, final NativeUiLayout.Box box, final float alpha, final Component text) { -+ final var node = box.node(); -+ 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).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(); -+ for (final var line : lines) { -+ final int lineWidth = this.font.width(line); -+ final int x = alignment.equals("center") ? (width - lineWidth) / 2 : alignment.equals("right") ? width - lineWidth : 0; -+ final var origin = NativeUiFont.origin(node, box.bounds().x() + x * scale, y, scale, uiScale); -+ graphics.pose().pushMatrix(); -+ try { -+ graphics.pose().translate(origin.x(), origin.y()); -+ graphics.pose().scale(scale, scale); -+ graphics.text(this.font, line, 0, 0, color(node, alpha), node.flag("shadow", false)); -+ } finally { -+ graphics.pose().popMatrix(); ++ private void scrollVisibility(final Box box, final Set hidden) { ++ if (!this.visibleNodes.getOrDefault(box.node, true)) return; ++ if (box.node.scroll() && !box.node.flag("scrollbar_always_visible", false) && NativeUiScroll.maximum(box) == 0) { ++ final Box panel = NativeUiScroll.descendant(box, box.node.string("scroll_box_and_track_panel", "")); ++ if (panel != null && List.of("scroll_content", "scroll_view_port", "scrollbar_track", "scrollbar_box").stream() ++ .allMatch(property -> NativeUiScroll.descendant(box, box.node.string(property, "")) != null)) { ++ hidden.add(panel.node); + } -+ y += NativeUiFont.lineHeight(node) * scale; + } ++ for (final var child : box.children) this.scrollVisibility(child, hidden); + } + -+ 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; -+ final var value = new WrappedText(text, width, NativeUiText.labelLines(this.font, text, width)); -+ this.wrapped.put(node, value); -+ return value; ++ private void visibility(final NativeUiScene.Node node, final boolean inherited, final Set hidden) { ++ final boolean visible = inherited && node.flag("visible", true) && !node.flag("ignored", false) && !hidden.contains(node); ++ this.visibleNodes.put(node, visible); ++ final var window = NativeUiCollectionWindow.from(node); ++ if (window != null) { ++ this.windows.put(node, window); ++ this.verticalLimit += window.size() * window.cellHeight(); ++ } ++ for (final var child : node.children()) this.visibility(child, visible, hidden); + } + -+ 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; -+ } ++ private Size measure(final NativeUiScene.Node node, final float parentWidth, final float parentHeight) { ++ return this.measure(node, new Measurement(parentWidth, parentHeight, 0, 0, parentWidth, parentHeight)); ++ } + -+ 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()); ++ private Size measure(final NativeUiScene.Node node, final Measurement environment) { ++ // The native visibility setter resets computed size metrics without changing authored dimensions. ++ if (!this.visibleNodes.getOrDefault(node, true)) return EMPTY; ++ final Map cache = this.measured.computeIfAbsent(node, _ -> new HashMap<>()); ++ final Size previous = cache.get(environment); ++ if (previous != null) return previous; ++ if (++this.work > 65536) throw new IllegalArgumentException("Native UI layout exceeds work limits"); ++ final boolean container = switch (node.type()) { ++ case "screen", "panel", "input_panel", "factory", "collection_panel", "scroll_view", "scroll_track", "scrollbar_box", "button", "toggle" -> true; ++ default -> false; ++ }; ++ final boolean inventoryButton = node.type().equals("button") && node.properties().has("inventory_actions"); ++ final boolean image = node.type().equals("image"); ++ final boolean ratioSize = image && node.flag("default_size_scales_to_ratio", false); ++ final Size intrinsic = container || inventoryButton || image && !ratioSize ++ ? new Size(environment.defaultWidth, environment.defaultHeight) : this.metrics.intrinsic(node); ++ final boolean defaultWidthAxis = defaultSize(node, 0); ++ final boolean defaultHeightAxis = defaultSize(node, 1); ++ float width = intrinsic.width, height = intrinsic.height; ++ for (int pass = 0; pass < 3; pass++) { ++ final float previousWidth = width, previousHeight = height; ++ // Resolve dimensions independent of children before descending. Measuring at the ++ // inherited extent first multiplies speculative passes through nested content panels. ++ if (!defaultWidthAxis && !childDimension(node, 0)) { ++ width = dimension(node, "size", 0, environment, width, height, 0, 0, intrinsic.width); ++ width = constrain(node, 0, width, environment, width, height, 0, 0); + } -+ this.visible = count; -+ return this.display = List.copyOf(result); ++ if (!defaultHeightAxis && !childDimension(node, 1)) { ++ height = dimension(node, "size", 1, environment, width, height, 0, 0, intrinsic.height); ++ height = constrain(node, 1, height, environment, width, height, 0, 0); ++ } ++ final List children = this.children(node, width, height); ++ final Size content = this.content(node, children, width, height); ++ final float maximumWidth = children.stream().map(Size::width).max(Float::compare).orElse(0F); ++ final float maximumHeight = children.stream().map(Size::height).max(Float::compare).orElse(0F); ++ final boolean stack = node.type().equals("stack_panel"); ++ final boolean vertical = !node.string("orientation", "vertical").equals("horizontal"); ++ final float defaultWidth = ratioSize && defaultWidthAxis && !defaultHeightAxis && intrinsic.height > 0 ++ ? height * intrinsic.width / intrinsic.height ++ : stack ? vertical ? environment.width : content.width : intrinsic.width; ++ width = dimension(node, "size", 0, environment, width, height, content.width, maximumWidth, defaultWidth); ++ width = constrain(node, 0, width, environment, width, height, content.width, maximumWidth); ++ final float intrinsicHeight = container || inventoryButton || stack || image ? intrinsic.height : this.metrics.constrained(node, width).height; ++ final float defaultHeight = ratioSize && defaultHeightAxis && !defaultWidthAxis && intrinsic.width > 0 ++ ? width * intrinsic.height / intrinsic.width ++ : stack ? vertical ? content.height : environment.height ++ : node.type().equals("grid") && !node.string("grid_item_template", "").isEmpty() ? content.height : intrinsicHeight; ++ height = dimension(node, "size", 1, environment, width, height, content.height, maximumHeight, defaultHeight); ++ height = constrain(node, 1, height, environment, width, height, content.height, maximumHeight); ++ if (width == previousWidth && height == previousHeight) break; + } ++ final Size result = new Size(width, height); ++ cache.put(environment, result); ++ return result; + } + -+ public Component text(final NativeUiScene.Node node) { -+ return this.texts.computeIfAbsent(node, value -> { -+ final Component text = value.properties().has("text_component") -+ ? ComponentSerialization.CODEC.parse(JsonOps.INSTANCE, -+ value.properties().get("text_component")).getOrThrow() -+ : value.flag("localize", false) ? Component.translatable(value.string("text", "")) -+ : NativeUiText.component(value.string("text", "")); -+ return this.styled(value, text); -+ }); ++ private boolean childDimension(final NativeUiScene.Node node, final int axis) { ++ for (final String key : List.of("size", "min_size", "max_size")) { ++ final var value = this.dimensionValue(node, key, axis); ++ if (value != null && (value.uses(NativeUiDimension.Unit.CHILDREN) || value.uses(NativeUiDimension.Unit.MAX_CHILD))) return true; ++ } ++ return false; + } + -+ public Component styled(final NativeUiScene.Node node, final Component text) { -+ final String font = node.string("font", ""); -+ return font.isEmpty() ? text : text.copy().withStyle(style -> style.withFont(new FontDescription.Resource(Identifier.parse(font)))); ++ private boolean defaultSize(final NativeUiScene.Node node, final int axis) { ++ final var size = this.dimensionValue(node, "size", axis); ++ return size == null || size.uses(NativeUiDimension.Unit.DEFAULT); + } + -+ static int imageColor(final NativeUiScene.Node node, final float alpha, final NativeUiAnimations animations) { -+ final NativeUiColor animated = animations.color(node); -+ return (animated == null ? NativeUiColor.parse(node.properties().get("color")) : animated).argb(alpha); ++ private List children(final NativeUiScene.Node node, final float width, final float height) { ++ // Collection percentages use the complete grid, while default dimensions ++ // inherit a single cell. Nested controls then inherit that measured cell. ++ final float availableWidth = width; ++ final float availableHeight = height; ++ final List result = new ArrayList<>(); ++ final Grid collection = node.type().equals("grid") && !node.string("grid_item_template", "").isEmpty() ++ ? this.grid(node, width, height) : null; ++ final float defaultWidth = collection == null ? width : width / collection.columns; ++ final float defaultHeight = collection == null ? height : height / collection.rows; ++ for (final var child : node.children()) ++ result.add(this.measure(child, new Measurement(availableWidth, availableHeight, 0, 0, defaultWidth, defaultHeight))); ++ for (int i = 0; i < result.size(); i++) { ++ final var child = node.children().get(i); ++ if (!siblingDimension(child)) continue; ++ float siblingWidth = 0, siblingHeight = 0; ++ for (int other = 0; other < result.size(); other++) if (other != i) { ++ siblingWidth = Math.max(siblingWidth, result.get(other).width); ++ siblingHeight = Math.max(siblingHeight, result.get(other).height); ++ } ++ result.set(i, this.measure(child, new Measurement(availableWidth, availableHeight, siblingWidth, siblingHeight, defaultWidth, defaultHeight))); ++ } ++ if (node.type().equals("stack_panel")) { ++ final boolean vertical = !node.string("orientation", "vertical").equals("horizontal"); ++ final int axis = vertical ? 1 : 0; ++ float used = 0; ++ int fills = 0; ++ for (int i = 0; i < result.size(); i++) { ++ if (!this.visibleNodes.getOrDefault(node.children().get(i), true)) continue; ++ if (fill(node.children().get(i), axis)) fills++; ++ else used += vertical ? result.get(i).height : result.get(i).width; ++ } ++ if (fills > 0) { ++ final float remainder = Math.max(0, (vertical ? height : width) - used) / fills; ++ for (int i = 0; i < result.size(); i++) { ++ final var child = node.children().get(i); ++ if (!this.visibleNodes.getOrDefault(child, true) || !fill(child, axis)) continue; ++ float siblingWidth = 0, siblingHeight = 0; ++ if (siblingDimension(child)) for (int other = 0; other < result.size(); other++) if (other != i) { ++ siblingWidth = Math.max(siblingWidth, result.get(other).width); ++ siblingHeight = Math.max(siblingHeight, result.get(other).height); ++ } ++ final float fillWidth = vertical ? availableWidth : remainder; ++ final float fillHeight = vertical ? remainder : availableHeight; ++ result.set(i, this.measure(child, new Measurement(fillWidth, fillHeight, ++ siblingWidth, siblingHeight, fillWidth, fillHeight))); ++ } ++ } ++ } ++ return result; + } + -+ public static int color(final NativeUiScene.Node node, final float alpha) { -+ final int red = textChannel(NativeUiLayout.vector(node, "color", 0, 1)); -+ final int green = textChannel(NativeUiLayout.vector(node, "color", 1, 1)); -+ final int blue = textChannel(NativeUiLayout.vector(node, "color", 2, 1)); -+ return textChannel(alpha) << 24 | red << 16 | green << 8 | blue; ++ private boolean fill(final NativeUiScene.Node node, final int axis) { ++ final var size = this.dimensionValue(node, "size", axis); ++ return size != null && size.uses(NativeUiDimension.Unit.FILL); + } + -+ private static int textChannel(final float value) { -+ return Math.clamp((int) (value * 255), 0, 255); ++ private boolean siblingDimension(final NativeUiScene.Node node) { ++ for (final String key : List.of("size", "min_size", "max_size")) for (int axis = 0; axis < 2; axis++) { ++ final var value = this.dimensionValue(node, key, axis); ++ if (value != null && value.uses(NativeUiDimension.Unit.SIBLING)) return true; ++ } ++ return false; + } + -+} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiScene.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiScene.java -new file mode 100644 -index 0000000..e0ed675 ---- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiScene.java -@@ -0,0 +1,107 @@ -+/* -+ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock -+ * Copyright (C) 2026 ViaVersion and contributors -+ * SPDX-License-Identifier: GPL-3.0-or-later -+ */ -+package com.viaversion.viafabricplus.bedrock.screen.ui; ++ private Size content(final NativeUiScene.Node node, final List children, final float parentWidth, final float parentHeight) { ++ float width = 0, height = 0; ++ final boolean vertical = !node.string("orientation", "vertical").equals("horizontal"); ++ if (node.type().equals("grid")) { ++ final Grid grid = this.grid(node, parentWidth, parentHeight); ++ final var window = this.windows.get(node); ++ if (window != null) { ++ for (final var child : children) ++ if (child.width != window.cellWidth() || child.height != window.cellHeight()) ++ throw new IllegalArgumentException("Native collection cell geometry changed"); ++ return new Size(grid.columns * window.cellWidth(), grid.rows * window.cellHeight()); ++ } ++ final Map widths = new HashMap<>(); ++ final Map heights = new HashMap<>(); ++ for (int i = 0; i < children.size(); i++) { ++ final int[] position = grid.position(node.children().get(i), i); ++ final int column = position[0], row = position[1]; ++ widths.merge(column, children.get(i).width, Math::max); ++ heights.merge(row, children.get(i).height, Math::max); ++ } ++ for (final float value : widths.values()) width += value; ++ for (final float value : heights.values()) height += value; ++ } else for (final Size child : children) { ++ if (node.type().equals("stack_panel") && !vertical) width += child.width; ++ else width = Math.max(width, child.width); ++ if (node.type().equals("stack_panel") && vertical) height += child.height; ++ else height = Math.max(height, child.height); ++ } ++ return new Size(width, height); ++ } + -+import com.google.gson.JsonElement; -+import com.google.gson.JsonObject; -+import com.google.gson.JsonParser; -+import java.math.BigInteger; -+import java.util.ArrayList; -+import java.util.List; -+import java.util.Set; ++ private Box place(final NativeUiScene.Node node, final Rect parent, final Size size, final float parentLayer, ++ final Rect parentClip, final Map scroll, final NativeUiScene.Node container) { ++ if (!this.visibleNodes.getOrDefault(node, true)) { ++ final Rect bounds = new Rect(parent.x, parent.y, 0, 0); ++ return new Box(node, bounds, parentClip, EMPTY, bounds, parentLayer, ++ node.children().stream().map(child -> this.place(child, bounds, EMPTY, parentLayer, parentClip, scroll, node)).toList()); ++ } ++ final String defaultAnchor = container != null && container.type().equals("grid") ++ && container.string("grid_item_template", "").isEmpty() ? "top_left" : "center"; ++ final float[] from = anchor(node.string("anchor_from", defaultAnchor)); ++ final float[] to = anchor(node.string("anchor_to", defaultAnchor)); ++ // Stack panels own child positions and only apply anchors when explicitly enabled. ++ final boolean stacked = container != null && container.type().equals("stack_panel"); ++ final boolean anchored = !stacked || container.flag("use_child_anchors", false); ++ final float offsetX = stacked ? 0 : offset(node, 0, parent.width, size); ++ final float offsetY = stacked ? 0 : offset(node, 1, parent.height, size); ++ final float x = parent.x + (anchored ? parent.width * from[0] - size.width * to[0] : 0) + offsetX; ++ final float y = parent.y + (anchored ? parent.height * from[1] - size.height * to[1] : 0) + offsetY; ++ final NativeUiCursorPlacement placement = this.placements.apply(node); ++ if (placement != null) this.cursorOffsets.put(node, new CursorOffset(placement, offsetX, offsetY)); ++ final Rect bounds = new Rect(x, y, size.width, size.height); ++ final Rect clip = node.scroll() || node.flag("clips_children", false) ? parentClip.intersect(bounds) : parentClip; ++ final List sizes = this.children(node, size.width, size.height); ++ Size content = this.content(node, sizes, size.width, size.height); ++ final String contentName = node.string("scroll_content", ""); ++ final boolean horizontalScroll = node.string("scroll_direction", "vertical").equals("horizontal"); ++ final float scrollAmount = node.scroll() && contentName.isEmpty() ++ ? Math.clamp(scroll.getOrDefault(node, 0F), 0, Math.max(0, ++ horizontalScroll ? content.width - size.width : content.height - size.height)) : 0; ++ final float scrollX = horizontalScroll ? scrollAmount : 0, scrollY = horizontalScroll ? 0 : scrollAmount; ++ final float layer = parentLayer + node.number("layer", 0); ++ final List children = new ArrayList<>(); ++ final boolean vertical = !node.string("orientation", "vertical").equals("horizontal"); ++ final Grid grid = this.grid(node, size.width, size.height); ++ final float cellWidth = size.width / grid.columns; ++ final float cellHeight = contentDimension(node, 1) ? content.height / grid.rows : size.height / grid.rows; ++ float cursor = 0; ++ for (int i = 0; i < node.children().size(); i++) { ++ final NativeUiScene.Node child = node.children().get(i); ++ final Size childSize = sizes.get(i); ++ final Rect childParent; ++ if (node.type().equals("stack_panel")) { ++ childParent = vertical ? new Rect(x - scrollX, y + cursor - scrollY, size.width, childSize.height) ++ : new Rect(x + cursor - scrollX, y - scrollY, childSize.width, size.height); ++ if (this.visibleNodes.getOrDefault(child, true)) cursor += vertical ? childSize.height : childSize.width; ++ } else if (node.type().equals("grid")) { ++ final int[] position = grid.position(child, i); ++ childParent = new Rect(x + position[0] * cellWidth - scrollX, y + position[1] * cellHeight - scrollY, ++ grid.manual ? size.width : cellWidth, grid.manual ? size.height : cellHeight); ++ } else childParent = new Rect(x - scrollX, y - scrollY, size.width, size.height); ++ children.add(this.place(child, childParent, childSize, layer, clip, scroll, node)); ++ } ++ Rect viewport = bounds; ++ if (node.scroll() && !contentName.isEmpty()) { ++ final Box contentBox = descendant(children, contentName); ++ final Box viewportBox = descendant(children, node.string("scroll_view_port", "")); ++ if (contentBox != null) { ++ viewport = viewportBox == null ? bounds : viewportBox.bounds; ++ content = new Size(Math.max(contentBox.bounds.width, contentBox.content.width), ++ Math.max(contentBox.bounds.height, contentBox.content.height)); ++ final Box thumb = descendant(children, node.string("scrollbar_box", "")); ++ final boolean horizontal = thumb != null && thumb.node.string("draggable", "vertical").equals("horizontal"); ++ final float amount = Math.clamp(scroll.getOrDefault(node, 0F), 0, Math.max(0, ++ horizontal ? content.width - viewport.width : content.height - viewport.height)); ++ final Rect contentClip = clip.intersect(viewport); ++ for (int i = 0; i < children.size(); i++) ++ children.set(i, scrollContent(children.get(i), contentBox.node, horizontal ? -amount : 0, horizontal ? 0 : -amount, contentClip)); ++ } ++ } ++ Box result = new Box(node, bounds, clip, content, viewport, layer, List.copyOf(children)); ++ if (node.scroll()) { ++ final var model = NativeUiScroll.model(result, scroll.getOrDefault(node, 0F)); ++ if (model != null) result = replace(result, model.originalThumb().node, ++ this.resize(model.originalThumb(), model.thumb(), scroll)); ++ } ++ return result; ++ } + -+/** Bounded resolved UI geometry and opaque controller annotations shared by forms and containers. */ -+public record NativeUiScene(Node root) { -+ private static final Set TYPES = Set.of("screen", "panel", "label", "image", "button", "toggle", -+ "stack_panel", "grid", "scroll_view", "scrollbar_box", "scroll_track", "collection_panel", "factory", "custom", -+ "slider", "slider_box", "edit_box", "input_panel"); ++ private Box positionCursors(Box box, final Rect parent, Rect inheritedClip, ++ final Map contentClips) { ++ final Rect contentClip = contentClips.get(box.node); ++ if (contentClip != null) inheritedClip = inheritedClip.intersect(contentClip); ++ final CursorOffset cursor = this.cursorOffsets.get(box.node); ++ if (cursor != null) { ++ final Size offset = cursor.placement.offset(box.node, box.bounds, parent, cursor.x, cursor.y); ++ if (offset != null) box = translated(box, offset.width - cursor.x, offset.height - cursor.y, inheritedClip); ++ } ++ final Rect bounds = box.bounds; ++ final Rect clip = box.node.scroll() || box.node.flag("clips_children", false) ? inheritedClip.intersect(bounds) : inheritedClip; ++ if (box.node.scroll()) { ++ final Box content = descendant(box.children, box.node.string("scroll_content", "")); ++ if (content != null) contentClips.put(content.node, clip.intersect(box.scrollViewport)); ++ } ++ // Native requests use the actual parent control after layout, not a grid's ++ // virtual cell or the unscrolled child origin. ++ final List children = new ArrayList<>(); ++ for (final Box child : box.children) children.add(this.positionCursors(child, bounds, clip, contentClips)); ++ return new Box(box.node, bounds, clip, box.content, box.scrollViewport, box.layer, List.copyOf(children)); ++ } + -+ public static NativeUiScene parse(final String json, final Set renderers) { -+ final JsonObject scene = JsonParser.parseString(json).getAsJsonObject(); -+ final int format = scene.get("format").getAsBigDecimal().intValueExact(); -+ if (format != 1 && format != 2) throw new IllegalArgumentException("Unsupported native UI format"); -+ return new NativeUiScene(node(scene.getAsJsonObject("root"), 0, new int[1], true, renderers)); ++ private record CursorOffset(NativeUiCursorPlacement placement, float x, float y) {} ++ ++ private Box resize(final Box box, final Rect bounds, final Map scroll) { ++ final List sizes = this.children(box.node, bounds.width, bounds.height); ++ final List children = new ArrayList<>(); ++ for (int i = 0; i < sizes.size(); i++) ++ children.add(this.place(box.node.children().get(i), bounds, sizes.get(i), box.layer, box.clip, scroll, box.node)); ++ return new Box(box.node, bounds, box.clip, this.content(box.node, sizes, bounds.width, bounds.height), ++ bounds, box.layer, List.copyOf(children)); + } + -+ public static void validateVisibleControls(final Node node, final Set renderers) { -+ validateVisibleControls(node, true, renderers); ++ private static Box replace(final Box box, final NativeUiScene.Node target, final Box replacement) { ++ if (box.node == target) return replacement; ++ return new Box(box.node, box.bounds, box.clip, box.content, box.scrollViewport, box.layer, ++ box.children.stream().map(child -> replace(child, target, replacement)).toList()); + } + -+ private static void validateVisibleControls(final Node node, final boolean inheritedVisible, final Set renderers) { -+ final boolean visible = inheritedVisible && node.flag("visible", true) && !node.flag("ignored", false); -+ if (visible && (!TYPES.contains(node.type()) || node.type().equals("custom") && !renderers.contains(node.string("renderer", "")))) -+ throw new IllegalArgumentException("Unsupported native UI control: " + node.type()); -+ for (final var child : node.children()) validateVisibleControls(child, visible, renderers); ++ private static Box descendant(final List children, final String name) { ++ if (name.isEmpty()) return null; ++ for (final Box child : children) { ++ if (child.node.name().equals(name)) return child; ++ final Box nested = descendant(child.children, name); ++ if (nested != null) return nested; ++ } ++ return null; + } + -+ private static Node node(final JsonObject value, final int depth, final int[] count, final boolean inheritedVisible, -+ final Set renderers) { -+ if (depth > 64 || ++count[0] > 8192) throw new IllegalArgumentException("Native UI scene exceeds layout limits"); -+ final String type = string(value, "type", "panel"); -+ final boolean visible = inheritedVisible && flag(value, "visible", true) && !flag(value, "ignored", false); -+ if (visible && (!TYPES.contains(type) || type.equals("custom") && !renderers.contains(string(value, "renderer", "")))) -+ throw new IllegalArgumentException("Unsupported native UI control: " + type); -+ final List children = new ArrayList<>(); -+ if (value.has("children")) for (final JsonElement child : value.getAsJsonArray("children")) -+ children.add(node(child.getAsJsonObject(), depth + 1, count, visible, renderers)); -+ final JsonObject properties = new JsonObject(); -+ value.entrySet().stream().filter(entry -> !entry.getKey().equals("children")) -+ .forEach(entry -> properties.add(entry.getKey(), entry.getValue().deepCopy())); -+ final int action = value.has("action") ? value.get("action").getAsBigDecimal().intValueExact() : Integer.MIN_VALUE; -+ return new Node(string(value, "name", ""), type, properties, List.copyOf(children), action); ++ private static Box scrollContent(final Box box, final NativeUiScene.Node content, final float x, final float y, final Rect clip) { ++ if (box.node == content) return translated(box, x, y, clip); ++ return new Box(box.node, box.bounds, box.clip, box.content, box.scrollViewport, box.layer, ++ box.children.stream().map(child -> scrollContent(child, content, x, y, clip)).toList()); + } + -+ public static String string(final JsonObject properties, final String key, final String fallback) { -+ final JsonElement value = properties.get(key); -+ return value == null || !value.isJsonPrimitive() ? fallback : value.getAsString(); ++ private static Box translated(final Box box, final float x, final float y, final Rect parentClip) { ++ final Rect bounds = new Rect(box.bounds.x + x, box.bounds.y + y, box.bounds.width, box.bounds.height); ++ final Rect clip = box.node.scroll() || box.node.flag("clips_children", false) ? parentClip.intersect(bounds) : parentClip; ++ final Rect viewport = new Rect(box.scrollViewport.x + x, box.scrollViewport.y + y, box.scrollViewport.width, box.scrollViewport.height); ++ return new Box(box.node, bounds, clip, box.content, viewport, box.layer, ++ box.children.stream().map(child -> translated(child, x, y, clip)).toList()); + } + -+ static boolean flag(final JsonObject properties, final String key, final boolean fallback) { -+ final JsonElement value = properties.get(key); -+ if (value == null || !value.isJsonPrimitive()) return fallback; -+ final var primitive = value.getAsJsonPrimitive(); -+ if (primitive.isBoolean()) return primitive.getAsBoolean(); -+ if (!primitive.isNumber()) return fallback; -+ // Native component readers accept JsonCpp signed/unsigned integers, but not real values. -+ final String encoded = primitive.getAsString(); -+ if (encoded.indexOf('.') >= 0 || encoded.indexOf('e') >= 0 || encoded.indexOf('E') >= 0) return fallback; ++ private float constrain(final NativeUiScene.Node node, final int axis, final float size, ++ final Measurement environment, final float width, final float height, ++ final float content, final float maximum) { ++ final float minimum = dimension(node, "min_size", axis, environment, width, height, content, maximum, 0); ++ final float limit = axis == 1 ? this.verticalLimit : LIMIT; ++ final float declaredUpper = dimension(node, "max_size", axis, environment, width, height, content, maximum, limit); ++ final float upper = declaredUpper == 0 ? limit : declaredUpper; ++ return Math.clamp(size, Math.min(minimum, upper), upper); ++ } ++ ++ private float dimension(final NativeUiScene.Node node, final String key, final int axis, ++ final Measurement environment, final float width, final float height, ++ final float content, final float maximum, final float fallback) { ++ final NativeUiDimension dimension = this.dimensionValue(node, key, axis); ++ final float limit = axis == 1 ? this.verticalLimit : LIMIT; ++ if (dimension == null) { ++ if (!Float.isFinite(fallback) || Math.abs(fallback) > limit) ++ throw new IllegalArgumentException("Native UI dimension exceeds layout limits"); ++ return Math.max(0, fallback); ++ } ++ final float parent = axis == 0 ? environment.width : environment.height; ++ final float sibling = axis == 0 ? environment.siblingWidth : environment.siblingHeight; + try { -+ final BigInteger integer = new BigInteger(encoded); -+ if (integer.bitLength() > (integer.signum() < 0 ? 63 : 64)) return fallback; -+ return integer.signum() != 0; -+ } catch (final NumberFormatException invalid) { -+ return fallback; ++ final float value = dimension.resolve(parent, content, maximum, width, height, sibling, fallback); ++ // A loose maximum does not request geometry beyond our supported layout range. ++ final float bounded = key.equals("max_size") ? Math.min(value, limit) : value; ++ if (!Float.isFinite(bounded) || Math.abs(bounded) > limit) ++ throw new IllegalArgumentException("Native UI dimension exceeds layout limits"); ++ return Math.max(0, bounded); ++ } catch (final IllegalArgumentException error) { ++ throw new IllegalArgumentException("Invalid native UI dimension: " + node.name() + "." + key + "[" + axis + "]", error); + } + } + -+ public record Node(String name, String type, JsonObject properties, List children, int action) { -+ public String string(final String key, final String fallback) { -+ return NativeUiScene.string(this.properties, key, fallback); -+ } ++ public static float dimension(final JsonElement value, final float parent, final float content, final float maximum) { ++ return dimension(value, parent, content, maximum, parent, parent, maximum, 0); ++ } + -+ public float number(final String key, final float fallback) { -+ final JsonElement value = this.properties.get(key); -+ if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber()) return fallback; -+ final float result = value.getAsFloat(); -+ if (!Float.isFinite(result)) throw new IllegalArgumentException("Non-finite native UI property"); -+ return result; ++ private static float dimension(final JsonElement value, final float parent, final float content, final float maximum, ++ final float width, final float height, final float sibling, final float fallback) { ++ return bounded(evaluatedDimension(value, parent, content, maximum, width, height, sibling, fallback)); ++ } ++ ++ private static float evaluatedDimension(final JsonElement value, final float parent, final float content, final float maximum, ++ final float width, final float height, final float sibling, final float fallback) { ++ return NativeUiDimension.parse(value).resolve(parent, content, maximum, width, height, sibling, fallback); ++ } ++ ++ private float offset(final NativeUiScene.Node node, final int axis, final float parent, final Size size) { ++ final var dimension = this.dimensionValue(node, "offset", axis); ++ return dimension == null ? 0 : signed(dimension.resolve(parent, 0, 0, size.width, size.height, 0, 0)); ++ } ++ ++ private boolean contentDimension(final NativeUiScene.Node node, final int axis) { ++ final var size = this.dimensionValue(node, "size", axis); ++ return size != null && (size.uses(NativeUiDimension.Unit.CHILDREN) || size.uses(NativeUiDimension.Unit.MAX_CHILD)); ++ } ++ ++ public static float vector(final NativeUiScene.Node node, final String key, final int axis, final float fallback) { ++ final JsonElement value = node.properties().get(key); ++ if (value == null || !value.isJsonArray() || value.getAsJsonArray().size() <= axis) return fallback; ++ final float result = value.getAsJsonArray().get(axis).getAsFloat(); ++ if (!Float.isFinite(result) || Math.abs(result) > LIMIT) throw new IllegalArgumentException("Invalid native UI vector"); ++ return result; ++ } ++ ++ private static float bounded(final float value) { ++ return Math.max(0, signed(value)); ++ } ++ ++ private static float signed(final float value) { ++ if (!Float.isFinite(value) || Math.abs(value) > LIMIT) throw new IllegalArgumentException("Native UI dimension exceeds layout limits"); ++ return value; ++ } ++ ++ private Grid grid(final NativeUiScene.Node node, final float width, final float height) { ++ if (!node.type().equals("grid")) return new Grid(1, 1, true, false, false, false); ++ final boolean horizontal = !node.string("grid_fill_direction", "horizontal").equals("vertical"); ++ final var window = this.windows.get(node); ++ final int count = window == null ? node.children().size() : window.size(); ++ if (count > 8192) throw new IllegalArgumentException("Native grid exceeds child limits"); ++ final boolean explicit = node.properties().has("grid_dimensions"); ++ final String rescaling = node.string("grid_rescaling_type", "none"); ++ final boolean manual = node.string("grid_item_template", "").isEmpty(); ++ int columns = gridCapacity(node, 0, manual); ++ int rows = gridCapacity(node, 1, manual); ++ if (manual && (columns == 0 || rows == 0)) ++ throw new IllegalArgumentException("Unsupported zero-capacity manual native grid"); ++ final boolean rescaled = !explicit && !manual && (rescaling.equals("horizontal") || rescaling.equals("vertical")); ++ if (manual) { ++ for (final var child : node.children()) { ++ final int[] position = gridPosition(child); ++ try { ++ Math.addExact(Math.multiplyExact(columns, position[1]), position[0]); ++ } catch (final ArithmeticException error) { ++ throw new IllegalArgumentException("Native grid index exceeds its range", error); ++ } ++ } ++ } ++ if (rescaled) { ++ float cellWidth = 0, cellHeight = 0; ++ for (final var child : node.children()) { ++ final Size size = this.measure(child, width, height); ++ cellWidth = Math.max(cellWidth, size.width); ++ cellHeight = Math.max(cellHeight, size.height); ++ } ++ // Percentage sizing can round an exact capacity down by one float ULP. ++ if (rescaling.equals("horizontal")) { ++ columns = cellWidth > 0 ? Math.clamp((int) Math.floor(Math.nextUp(width / cellWidth)), 1, 128) : 1; ++ rows = Math.max(1, (int) Math.ceilDiv((long) count, columns)); ++ } else { ++ rows = cellHeight > 0 ? Math.clamp((int) Math.floor(Math.nextUp(height / cellHeight)), 1, 4096) : 1; ++ columns = Math.max(1, (int) Math.ceilDiv((long) count, rows)); ++ } ++ } ++ if (!manual) { ++ if (horizontal) rows = Math.max(rows, (int) Math.ceilDiv((long) count, columns)); ++ else columns = Math.max(columns, (int) Math.ceilDiv((long) count, rows)); + } ++ return new Grid(columns, rows, horizontal, rescaled, manual, window != null); ++ } + -+ public boolean flag(final String key, final boolean fallback) { -+ return NativeUiScene.flag(this.properties, key, fallback); ++ private static int gridCapacity(final NativeUiScene.Node node, final int axis, final boolean manual) { ++ final JsonElement value = node.properties().get("grid_dimensions"); ++ if (value == null) return manual ? 0 : 1; ++ if (!value.isJsonArray() || value.getAsJsonArray().size() != 2) ++ throw new IllegalArgumentException("Invalid native grid capacity"); ++ final JsonElement count = value.getAsJsonArray().get(axis); ++ if (!count.isJsonPrimitive() || !count.getAsJsonPrimitive().isNumber()) ++ throw new IllegalArgumentException("Invalid native grid capacity"); ++ try { ++ final int capacity = count.getAsBigDecimal().intValueExact(); ++ if (capacity < 0) throw new IllegalArgumentException("Invalid native grid capacity"); ++ return manual ? capacity : Math.max(1, capacity); ++ } catch (final ArithmeticException error) { ++ throw new IllegalArgumentException("Invalid native grid capacity", error); + } ++ } + -+ public boolean interactive() { -+ return this.type.equals("toggle") || this.action != Integer.MIN_VALUE || this.properties.has("input") || this.properties.has("command") -+ || this.properties.has("recipe_actions") || this.properties.has("inventory_actions") || this.properties.has("inventory_command") || this.properties.has("url"); ++ private static int[] gridPosition(final NativeUiScene.Node node) { ++ final JsonElement value = node.properties().get("grid_position"); ++ if (value == null) return new int[]{0, 0}; ++ if (!value.isJsonArray() || value.getAsJsonArray().size() != 2) ++ throw new IllegalArgumentException("Invalid native grid position"); ++ final int[] position = new int[2]; ++ for (int axis = 0; axis < position.length; axis++) { ++ final JsonElement coordinate = value.getAsJsonArray().get(axis); ++ if (!coordinate.isJsonPrimitive() || !coordinate.getAsJsonPrimitive().isNumber()) ++ throw new IllegalArgumentException("Invalid native grid position"); ++ try { ++ position[axis] = coordinate.getAsBigDecimal().intValueExact(); ++ } catch (final ArithmeticException error) { ++ throw new IllegalArgumentException("Invalid native grid position", error); ++ } ++ if (position[axis] < 0) throw new IllegalArgumentException("Invalid native grid position"); + } ++ return position; ++ } + -+ public boolean scroll() { -+ return this.type.equals("scroll_view"); ++ private record Grid(int columns, int rows, boolean horizontal, boolean rescaled, boolean manual, boolean windowed) { ++ int[] position(final NativeUiScene.Node node, final int index) { ++ if (this.manual) return gridPosition(node); ++ final int original = this.windowed ? node.properties().get("collection_index").getAsInt() : index; ++ return this.horizontal ? new int[]{original % this.columns, original / this.columns} ++ : new int[]{original / this.rows, original % this.rows}; ++ } ++ } ++ ++ private static float[] anchor(final String name) { ++ return switch (name) { ++ case "top_left" -> new float[]{0, 0}; ++ case "top_middle" -> new float[]{.5F, 0}; ++ case "top_right" -> new float[]{1, 0}; ++ case "left_middle" -> new float[]{0, .5F}; ++ case "center" -> new float[]{.5F, .5F}; ++ case "right_middle" -> new float[]{1, .5F}; ++ case "bottom_left" -> new float[]{0, 1}; ++ case "bottom_middle" -> new float[]{.5F, 1}; ++ case "bottom_right" -> new float[]{1, 1}; ++ default -> throw new IllegalArgumentException("Unsupported native UI anchor"); ++ }; ++ } ++ ++ public interface Metrics { ++ Size intrinsic(NativeUiScene.Node node); ++ ++ default Size constrained(final NativeUiScene.Node node, final float width) { ++ return this.intrinsic(node); ++ } ++ } ++ ++ private record Measurement(float width, float height, float siblingWidth, float siblingHeight, float defaultWidth, float defaultHeight) {} ++ ++ public record Size(float width, float height) {} ++ public record Box(NativeUiScene.Node node, Rect bounds, Rect clip, Size content, Rect scrollViewport, float layer, List children) { ++ public Rect visibleBounds() { ++ return this.bounds.intersect(this.clip); + } + } + ++ public record Rect(float x, float y, float width, float height) { ++ public Rect intersect(final Rect other) { ++ final float left = Math.max(this.x, other.x), top = Math.max(this.y, other.y); ++ return new Rect(left, top, Math.max(0, Math.min(this.x + this.width, other.x + other.width) - left), ++ Math.max(0, Math.min(this.y + this.height, other.y + other.height) - top)); ++ } ++ ++ public boolean contains(final double x, final double y) { ++ return x >= this.x && y >= this.y && x < this.x + this.width && y < this.y + this.height; ++ } ++ } +} -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 +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiMultilineInput.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiMultilineInput.java new file mode 100644 -index 0000000..05f9080 +index 0000000..92a87a7 --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiSceneScreen.java -@@ -0,0 +1,822 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiMultilineInput.java +@@ -0,0 +1,267 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -8870,827 +10338,819 @@ index 0000000..05f9080 + */ +package com.viaversion.viafabricplus.bedrock.screen.ui; + -+import com.mojang.blaze3d.platform.InputConstants; -+import com.mojang.serialization.JsonOps; ++import com.mojang.blaze3d.platform.cursor.CursorTypes; +import java.util.ArrayList; -+import java.util.Collections; -+import java.util.Comparator; -+import java.util.HashMap; -+import java.util.HashSet; -+import java.util.IdentityHashMap; +import java.util.List; -+import java.util.Map; -+import java.util.Set; -+import net.minecraft.client.gui.ComponentPath; ++import java.util.function.Consumer; ++import net.minecraft.client.Minecraft; ++import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphicsExtractor; -+import net.minecraft.client.gui.components.AbstractButton; +import net.minecraft.client.gui.components.AbstractWidget; -+import net.minecraft.client.gui.components.EditBox; -+import net.minecraft.client.gui.components.Tooltip; -+import net.minecraft.client.gui.components.events.GuiEventListener; ++import net.minecraft.client.gui.components.IMEPreeditOverlay; ++import net.minecraft.client.gui.components.MultilineTextField; ++import net.minecraft.client.gui.components.TextCursorUtils; ++import net.minecraft.client.gui.components.Whence; ++import net.minecraft.client.gui.narration.NarratedElementType; +import net.minecraft.client.gui.narration.NarrationElementOutput; -+import net.minecraft.client.gui.navigation.FocusNavigationEvent; -+import net.minecraft.client.gui.navigation.ScreenDirection; -+import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.input.CharacterEvent; -+import net.minecraft.client.input.InputWithModifiers; +import net.minecraft.client.input.KeyEvent; +import net.minecraft.client.input.MouseButtonEvent; ++import net.minecraft.client.input.PreeditEvent; ++import net.minecraft.client.sounds.SoundManager; +import net.minecraft.network.chat.Component; -+import net.minecraft.network.chat.ComponentSerialization; ++import net.minecraft.util.Util; + -+/** Draws authored scenes with native directional focus geometry and Java input, narration and domain responses. */ -+public final class NativeUiSceneScreen extends Screen implements NativeUiScreen { -+ private final NativeUiController controller; -+ private NativeUiScene.Node scene; -+ private final NativeUiImages images = new NativeUiImages(); -+ private NativeUiAnimationLifecycle lifecycle; -+ private NativeUiAnimations animations; -+ private long animationStarted; -+ private final Map scroll = new IdentityHashMap<>(); -+ private final NativeUiFocus.History focusHistory = new NativeUiFocus.History(); -+ private final Map actions = new IdentityHashMap<>(); -+ private final Map inputs = new IdentityHashMap<>(); -+ private final Set eligibleInputs = Collections.newSetFromMap(new IdentityHashMap<>()); -+ private final Map geometry = new IdentityHashMap<>(); -+ private final Map actionWidgets = new HashMap<>(); -+ private final Map inputWidgets = new HashMap<>(); -+ private boolean refreshPending; -+ private NativeUiRenderer renderer; -+ private NativeUiLayout.Box root; -+ private Drag drag; -+ private InputDrag inputDrag; -+ private long imageRevision; -+ private double pointerX = -1; -+ private double pointerY = -1; ++/** Text editing and selection inside authored geometry; the scene draws its own decorations. */ ++class NativeUiMultilineInput extends AbstractWidget { ++ // MultilineTextField uses the target client's nine-pixel line spacing for pointer navigation. ++ private static final int LINE_HEIGHT = 9; ++ private final Font font; ++ private final String placeholder; ++ private final int maximumLength; ++ private final Consumer responder; ++ private TextField text; ++ private int layoutWidth; ++ private String lastValue; ++ private int textColor = 0xFFFFFFFF; ++ private boolean shadow; ++ private boolean editable = true; ++ private double scroll; ++ private long focusedTime; ++ private IMEPreeditOverlay preedit; + -+ public NativeUiSceneScreen(final NativeUiController controller) { -+ super(NativeUiText.component(controller.title())); -+ this.controller = controller; -+ this.scene = controller.root(); -+ this.resetAnimations(); ++ NativeUiMultilineInput(final Font font, final Component caption, final String value, final String placeholder, ++ final int maximumLength, final Consumer responder) { ++ super(0, 0, 1, 1, caption); ++ this.font = font; ++ this.placeholder = placeholder; ++ this.maximumLength = maximumLength; ++ this.responder = responder; ++ this.text = this.field(1, value); ++ this.layoutWidth = 1; ++ this.listen(); + } + -+ private void resetAnimations() { -+ this.lifecycle = new NativeUiAnimationLifecycle(this.scene); -+ this.animations = this.lifecycle.animations(); -+ this.animationStarted = System.nanoTime(); ++ private TextField field(final int width, final String value) { ++ final var result = new TextField(this.font, width); ++ result.setCharacterLimit(this.maximumLength); ++ result.setValue(value); ++ return result; + } + -+ private long animationTime() { return System.nanoTime() - this.animationStarted; } ++ private void listen() { ++ this.lastValue = this.text.value(); ++ this.text.setValueListener(value -> { ++ if (!value.equals(this.lastValue)) { ++ this.lastValue = value; ++ this.responder.accept(value); ++ } ++ }); ++ this.text.setCursorListener(this::revealCursor); ++ } + -+ private void scrollAnimation(final NativeUiScene.Node target, final boolean released) { -+ final long now = this.animationTime(); -+ this.animations.event(target, "scrollbar.active", now); -+ if (released) this.animations.event(target, "scrollbar.released", now); ++ final void configure(final NativeUiLayout.Rect bounds, final int color, final boolean shadow) { ++ final int width = Math.max(1, Math.round(bounds.width())); ++ this.setRectangle(width, Math.max(1, Math.round(bounds.height())), Math.round(bounds.x()), Math.round(bounds.y())); ++ this.textColor = color; ++ this.shadow = shadow; ++ if (width != this.layoutWidth) { ++ final int cursor = this.text.cursor(); ++ final var selection = this.text.selection(); ++ final int anchor = cursor == selection.start() ? selection.end() : selection.start(); ++ this.text = this.field(width, this.text.value()); ++ this.text.seekCursor(Whence.ABSOLUTE, anchor); ++ this.text.setSelecting(true); ++ this.text.seekCursor(Whence.ABSOLUTE, cursor); ++ this.text.setSelecting(false); ++ this.layoutWidth = width; ++ this.listen(); ++ } ++ this.scroll = Math.clamp(this.scroll, 0, this.maximumScroll()); ++ if (this.isFocused()) this.revealCursor(); + } + -+ public boolean controlledBy(final NativeUiController controller) { -+ return this.controller == controller; ++ public final void setEditable(final boolean editable) { ++ final boolean changed = this.editable != editable; ++ this.editable = editable; ++ if (!editable) this.preedit = null; ++ if (changed && this.isFocused()) Minecraft.getInstance().onTextInputFocusChange(this, this.capturesInput()); + } + -+ @Override -+ public void extractBackground(final GuiGraphicsExtractor graphics, final int mouseX, final int mouseY, final float delta) { -+ // The resource-pack scene owns its background, including any world overlay. ++ final String value() { return this.text.value(); } ++ final int cursor() { return this.text.cursor(); } ++ final String selectedText() { return this.text.getSelectedText(); } ++ ++ private double maximumScroll() { ++ return Math.max(0, this.text.getLineCount() * LINE_HEIGHT - this.height); + } + -+ @Override -+ protected void init() { -+ this.drag = null; -+ this.inputDrag = null; -+ this.setDragging(false); -+ this.layout(); -+ this.reconcileWidgets(); ++ private void revealCursor() { ++ final double top = this.text.getLineAtCursor() * LINE_HEIGHT; ++ if (top < this.scroll) this.scroll = top; ++ else if (top + LINE_HEIGHT > this.scroll + this.height) this.scroll = top + LINE_HEIGHT - this.height; ++ this.scroll = Math.clamp(this.scroll, 0, this.maximumScroll()); ++ this.focusedTime = Util.getMillis(); + } + -+ private void reconcileWidgets() { -+ this.actions.clear(); -+ this.inputs.clear(); -+ final List boxes = new ArrayList<>(); -+ controls(this.root, "", 0, boxes); -+ Collections.reverse(boxes); -+ boxes.sort(Comparator.comparingDouble((ControlBox control) -> control.box.layer()).reversed()); -+ final Set retainedActions = new HashSet<>(), retainedInputs = new HashSet<>(); -+ for (final var control : boxes) { -+ final var box = control.box; -+ if (!box.node().interactive()) continue; -+ if (this.controller.inputs() != null && box.node().type().equals("edit_box") && box.node().properties().has("input")) { -+ final NativeInput input = this.inputWidgets.computeIfAbsent(control.key, -+ key -> box.node().flag("enabled_newline", false) ? new MultilineInput(box) : new SingleLineInput(box)); -+ input.place(box); -+ if (!this.children().contains(input.widget())) this.addWidget(input.widget()); -+ this.inputs.put(box.node(), input); -+ retainedInputs.add(control.key); -+ } else { -+ final Action action = this.actionWidgets.computeIfAbsent(control.key, key -> new Action(box)); -+ action.place(box); -+ if (!this.children().contains(action)) this.addWidget(action); -+ this.actions.put(box.node(), action); -+ retainedActions.add(control.key); -+ } ++ @Override ++ public void setFocused(final boolean focused) { ++ final boolean changed = this.isFocused() != focused; ++ super.setFocused(focused); ++ if (changed) { ++ this.preedit = null; ++ if (focused) this.revealCursor(); ++ Minecraft.getInstance().onTextInputFocusChange(this, focused); + } -+ this.inputWidgets.entrySet().removeIf(entry -> { -+ if (retainedInputs.contains(entry.getKey())) return false; -+ this.removeWidget(entry.getValue().widget()); -+ return true; -+ }); -+ this.actionWidgets.entrySet().removeIf(entry -> { -+ if (retainedActions.contains(entry.getKey())) return false; -+ this.removeWidget(entry.getValue()); -+ return true; -+ }); -+ this.synchronize(this.draws(-1, -1)); -+ this.controller.validateResponseControls(this.actions.values().stream() -+ .filter(action -> action.visible && action.eligible).map(action -> action.node).toList()); + } + -+ private static void controls(final NativeUiLayout.Box box, final String parent, final int occurrence, -+ final List result) { -+ // Collection templates repeat authored names. Retain each instance separately across scene refreshes. -+ final String name = box.node().name(); -+ final String path = parent + "/" + name.length() + ":" + name + ":" + occurrence; -+ result.add(new ControlBox(box, path + ":" + box.node().type() + ":" + box.node().action() -+ + ":" + box.node().properties().get("input") + ":" + box.node().flag("enabled_newline", false))); -+ final Map occurrences = new HashMap<>(); -+ for (final var child : box.children()) { -+ final int childOccurrence = occurrences.merge(child.node().name(), 1, Integer::sum) - 1; -+ controls(child, path, childOccurrence, result); -+ } ++ @Override ++ public boolean capturesInput() { ++ return this.visible && this.active && this.editable && this.isFocused(); + } + -+ private record ControlBox(NativeUiLayout.Box box, String key) {} ++ @Override ++ public boolean keyPressed(final KeyEvent event) { ++ return this.capturesInput() && this.text.keyPressed(event); ++ } + -+ private long layoutRevision = -1; ++ @Override ++ public boolean charTyped(final CharacterEvent event) { ++ if (!this.capturesInput() || !event.isAllowedChatCharacter()) return false; ++ this.text.insertText(event.codepointAsString()); ++ return true; ++ } + -+ private void layout() { -+ NativeUiPresentation.updateSliderStates(this.scene, this::sliderHovered, this::sliderSelected); -+ this.controller.validateVisibleControls(); -+ this.renderer().prepare(this.scene); -+ this.root = new NativeUiLayout(this.renderer(), this.animations).layout(this.scene, this.width, this.height, this.scroll); -+ this.root = NativeUiSlider.apply(this.root); -+ this.geometry.clear(); -+ this.indexGeometry(this.root); -+ this.positionActions(this.root); -+ this.layoutRevision = this.animations.layoutRevision(); -+ // Reflow can reveal an editable control before the next render pass. -+ this.synchronize(this.currentDraws((int) this.pointerX, (int) this.pointerY, this.animationTime())); ++ @Override ++ public boolean preeditUpdated(final PreeditEvent event) { ++ if (!this.capturesInput()) return false; ++ this.preedit = event == null ? null : new IMEPreeditOverlay(event, this.font, LINE_HEIGHT + 1); ++ return true; + } + -+ private void indexGeometry(final NativeUiLayout.Box box) { -+ this.geometry.put(box.node(), box); -+ for (final var child : box.children()) this.indexGeometry(child); ++ @Override ++ public void onClick(final MouseButtonEvent event, final boolean doubleClick) { ++ if (!this.editable) return; ++ if (doubleClick) this.text.selectWordAtCursor(); ++ else { ++ this.text.setSelecting(event.hasShiftDown()); ++ this.seek(event); ++ } + } + -+ private boolean sliderHovered(final NativeUiScene.Node node) { -+ final var box = this.geometry.get(node); -+ return box != null && box.visibleBounds().contains(this.pointerX, this.pointerY); ++ @Override ++ protected void onDrag(final MouseButtonEvent event, final double deltaX, final double deltaY) { ++ if (!this.capturesInput()) return; ++ this.text.setSelecting(true); ++ this.seek(event); ++ this.text.setSelecting(event.hasShiftDown()); + } + -+ private boolean sliderSelected(final NativeUiScene.Node node) { -+ return this.inputDrag != null && node.properties().has("input") -+ && inputIndex(node) == this.inputDrag.index; ++ private void seek(final MouseButtonEvent event) { ++ this.text.seekCursorToPoint(event.x() - this.getX(), event.y() - this.getY() + this.scroll); + } + -+ private NativeUiRenderer renderer() { -+ if (this.renderer == null) this.renderer = new NativeUiRenderer(this.font, this.images); -+ return this.renderer; ++ @Override ++ public boolean mouseScrolled(final double x, final double y, final double horizontal, final double vertical) { ++ if (!this.active || !this.visible || !this.isMouseOver(x, y) || vertical == 0) return false; ++ final double previous = this.scroll; ++ this.scroll = Math.clamp(previous - vertical * LINE_HEIGHT, 0, this.maximumScroll()); ++ return this.scroll != previous; + } + -+ private void positionActions(final NativeUiLayout.Box box) { -+ final Action action = this.actions.get(box.node()); -+ if (action != null) action.place(box); -+ final NativeInput input = this.inputs.get(box.node()); -+ if (input != null) input.place(box); -+ for (final var child : box.children()) this.positionActions(child); ++ @Override ++ public void playDownSound(final SoundManager sounds) { ++ // Editing text does not play a button activation sound. + } + + @Override -+ public void extractRenderState(final GuiGraphicsExtractor graphics, final int mouseX, final int mouseY, final float delta) { ++ protected void extractWidgetRenderState(final GuiGraphicsExtractor graphics, final int mouseX, final int mouseY, ++ final float delta) { ++ graphics.enableScissor(this.getX(), this.getY(), this.getRight(), this.getBottom()); + try { -+ this.refreshPendingScene(); -+ this.pointerX = mouseX; -+ this.pointerY = mouseY; -+ if (NativeUiPresentation.updateSliderStates(this.scene, this::sliderHovered, this::sliderSelected)) -+ this.layout(); -+ final List draws = this.draws(mouseX, mouseY); -+ this.synchronize(draws); -+ NativeUiScene.Node tooltip = null; -+ for (final NativeUiPresentation.Draw draw : draws) { -+ final var box = draw.box(); -+ 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(); -+ if (draw.alpha() <= 0 || box.visibleBounds().width() <= 0 || box.visibleBounds().height() <= 0) continue; -+ NativeUiScissor.enable(graphics, clip); -+ try { -+ final var node = box.node(); -+ this.renderer().draw(graphics, draw, this.animations); -+ if (node.type().equals("custom") && (node.string("renderer", "").equals(NativeUiActorPortraits.RENDERER) -+ || node.string("renderer", "").equals(NativeUiActorPortraits.ACTOR_RENDERER) -+ || node.string("renderer", "").equals(NativeUiActorPortraits.PLAYER_RENDERER))) -+ this.renderer().portrait(graphics, box, mouseX, mouseY, delta, draw.alpha()); -+ final Action action = this.actions.get(node); -+ if (action != null) action.extractRenderState(graphics, mouseX, mouseY, delta); -+ final NativeInput input = this.inputs.get(node); -+ if (input != null) input.widget().extractRenderState(graphics, mouseX, mouseY, delta); -+ } finally { -+ graphics.disableScissor(); -+ } -+ } -+ if (tooltip != null) { -+ final Component text = tooltip.properties().has("hover_text_component") -+ ? ComponentSerialization.CODEC.parse(JsonOps.INSTANCE, tooltip.properties().get("hover_text_component")).getOrThrow() -+ : NativeUiText.component(tooltip.string("hover_text", "")); -+ final int maximum = Math.round(tooltip.number("hover_text_max_width", 0)); -+ final int wrapWidth = maximum > 0 ? Math.max(1, Math.min(maximum, this.width - 16)) : Integer.MAX_VALUE; -+ graphics.setTooltipForNextFrame(this.font, NativeUiText.tooltipLines(this.font, text, wrapWidth), mouseX, mouseY); ++ final int color = Math.round(this.alpha * (this.textColor >>> 24)) << 24 | this.textColor & 0xFFFFFF; ++ final String value = this.text.value(); ++ final int cursorLine = this.text.getLineAtCursor(); ++ final var cursorView = this.text.line(cursorLine); ++ final int cursorX = this.getX() + this.font.width(value.substring(cursorView.start(), this.text.cursor())); ++ final int cursorY = this.getY() + cursorLine * LINE_HEIGHT - (int) this.scroll; ++ if (value.isEmpty() && !this.isFocused()) { ++ graphics.textWithWordWrap(this.font, NativeUiText.component(this.placeholder), this.getX(), this.getY(), ++ this.width, color); ++ } else { ++ final var selected = this.text.selection(); ++ int y = this.getY() - (int) this.scroll; ++ for (final var line : this.text.lines()) { ++ if (y + LINE_HEIGHT > this.getY() && y < this.getBottom()) { ++ graphics.text(this.font, value.substring(line.start(), line.end()), this.getX(), y, color, this.shadow); ++ final int start = Math.max(line.start(), selected.start()); ++ final int end = Math.min(line.end(), selected.end()); ++ if (start < end || selected.start() <= line.end() && selected.end() > line.end()) { ++ final int left = this.getX() + this.font.width(value.substring(line.start(), start)); ++ final int right = selected.end() > line.end() ? this.getRight() ++ : this.getX() + this.font.width(value.substring(line.start(), end)); ++ graphics.textHighlight(left, y, right, y + LINE_HEIGHT, true); ++ } ++ } ++ y += LINE_HEIGHT; ++ } + } -+ if (this.images.failed()) graphics.text(this.font, -+ Component.literal("Some menu images could not load."), 8, this.height - this.font.lineHeight - 8, -+ 0xFFFFCCCC, true); -+ } catch (final RuntimeException error) { -+ this.controller.failed(error); ++ if (this.capturesInput()) { ++ if (TextCursorUtils.isCursorVisible(Util.getMillis() - this.focusedTime)) ++ TextCursorUtils.extractInsertCursor(graphics, cursorX, cursorY, color, LINE_HEIGHT); ++ if (this.preedit == null) ++ Minecraft.getInstance().textInputManager().setTextInputArea(cursorX, cursorY, cursorX + 1, cursorY + LINE_HEIGHT + 1); ++ else { ++ this.preedit.updateInputPosition(cursorX, cursorY); ++ graphics.setPreeditOverlay(this.preedit); ++ } ++ } ++ if (this.isHovered() && this.active && this.editable) graphics.requestCursor(CursorTypes.IBEAM); ++ } finally { ++ graphics.disableScissor(); + } + } + -+ private List draws(final int mouseX, final int mouseY) { -+ final long now = this.animationTime(); -+ this.lifecycle.advance(now); -+ if (this.animations.layoutRevision() != this.layoutRevision) this.layout(); -+ return this.currentDraws(mouseX, mouseY, now); -+ } ++ private record Range(int start, int end) {} + -+ private List currentDraws(final int mouseX, final int mouseY, final long now) { -+ return NativeUiPresentation.draws(this.root, box -> { -+ final Action action = this.actions.get(box.node()); -+ return box.node().type().equals("slider") || box.node().type().equals("slider_box") -+ ? box.visibleBounds().contains(mouseX, mouseY) -+ : action != null && (action.isFocused() || box.visibleBounds().contains(mouseX, mouseY)); -+ }, node -> this.animations.opacityAt(node, now)); -+ } ++ /** Exposes the protected line spans to the authored renderer without changing editing behavior. */ ++ private static final class TextField extends MultilineTextField { ++ TextField(final Font font, final int width) { super(font, width); } + -+ private void synchronize(final List draws) { -+ this.eligibleInputs.clear(); -+ for (final Action action : this.actions.values()) action.visible = false; -+ for (final NativeInput input : this.inputs.values()) input.widget().visible = false; -+ for (final NativeUiPresentation.Draw draw : draws) { -+ final Action action = this.actions.get(draw.box().node()); -+ if (action != null) { -+ action.visible = true; -+ action.eligible = draw.enabled() && draw.box().bounds().width() > 0 && draw.box().bounds().height() > 0; -+ action.active = action.eligible && draw.alpha() > 0; -+ action.setAlpha(draw.alpha()); -+ } -+ final NativeInput input = this.inputs.get(draw.box().node()); -+ if (input != null) { -+ if (draw.enabled() && draw.box().bounds().width() > 0 && draw.box().bounds().height() > 0) -+ this.eligibleInputs.add(draw.box().node()); -+ input.widget().visible = draw.box().visibleBounds().width() > 0 && draw.box().visibleBounds().height() > 0; -+ input.widget().active = draw.enabled() && draw.alpha() > 0; -+ input.setEditable(input.widget().active); -+ input.widget().setAlpha(draw.alpha()); -+ } ++ Range selection() { ++ final var selected = this.getSelected(); ++ return new Range(selected.beginIndex(), selected.endIndex()); + } -+ } + -+ private Map interactive(final double x, final double y) { -+ final Map result = new IdentityHashMap<>(); -+ for (final var draw : this.draws((int) x, (int) y)) if (draw.enabled() && draw.alpha() > 0) result.put(draw.box().node(), true); -+ return result; -+ } ++ Range line(final int index) { ++ final var line = this.getLineView(index); ++ return new Range(line.beginIndex(), line.endIndex()); ++ } + -+ @Override -+ public boolean mouseScrolled(final double x, final double y, final double horizontal, final double vertical) { -+ for (final NativeInput input : this.inputs.values()) { -+ if (input instanceof MultilineInput && input.widget().mouseScrolled(x, y, horizontal, vertical)) return true; ++ List lines() { ++ final List lines = new ArrayList<>(); ++ for (final var line : this.iterateLines()) lines.add(new Range(line.beginIndex(), line.endIndex())); ++ return lines; + } -+ final var target = scrolling(this.root, x, y, this.interactive(x, y)); -+ if (target == null) return super.mouseScrolled(x, y, horizontal, vertical); -+ final double delta = NativeUiScroll.horizontal(target) && horizontal != 0 ? horizontal : vertical; -+ this.scroll.put(target.node(), Math.clamp(this.scroll.getOrDefault(target.node(), 0F) -+ - (float) delta * target.node().number("scroll_speed", 18), 0, NativeUiScroll.maximum(target))); -+ this.scrollAnimation(target.node(), true); -+ this.layout(); -+ return true; + } + + @Override -+ public boolean mouseClicked(final MouseButtonEvent event, final boolean doubleClick) { -+ if (this.controller.inputs() != null && event.button() == InputConstants.MOUSE_BUTTON_LEFT) { -+ for (final var draw : this.draws((int) event.x(), (int) event.y()).reversed()) { -+ final var box = draw.box(); -+ if (!draw.enabled() || draw.alpha() <= 0 || !box.visibleBounds().contains(event.x(), event.y()) -+ || !box.node().properties().has("input")) continue; -+ final int index = inputIndex(box.node()); -+ if (!this.controller.inputs().isSlider(index)) continue; -+ final var owner = sliderOwner(this.root, box.node(), null); -+ if (owner == null) continue; -+ this.inputDrag = new InputDrag(index, owner); -+ this.slide(this.inputDrag, event.x(), event.y()); -+ this.setDragging(true); -+ return true; -+ } -+ } -+ if (event.button() == InputConstants.MOUSE_BUTTON_LEFT) { -+ final var target = scrollbar(this.root, event.x(), event.y(), this.interactive(event.x(), event.y())); -+ if (target != null) { -+ final var model = NativeUiScroll.model(target, this.scroll.getOrDefault(target.node(), 0F)); -+ final float pointer = model.pointer(event.x(), event.y()); -+ final float grab = model.containsThumb(event.x(), event.y()) ? pointer - model.start() : model.extent() / 2; -+ this.scrollAnimation(target.node(), false); -+ this.drag = new Drag(target.node(), grab); -+ this.setDragging(true); -+ this.scroll.put(target.node(), model.offsetAt(pointer, grab)); -+ this.layout(); -+ return true; -+ } -+ } -+ return super.mouseClicked(event, doubleClick); ++ protected void updateWidgetNarration(final NarrationElementOutput narration) { ++ narration.add(NarratedElementType.TITLE, Component.translatable("gui.narrate.editBox", this.getMessage(), this.value())); + } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiPlayerCamera.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiPlayerCamera.java +new file mode 100644 +index 0000000..81c52c3 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiPlayerCamera.java +@@ -0,0 +1,72 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.ui; + -+ @Override -+ public boolean mouseDragged(final MouseButtonEvent event, final double deltaX, final double deltaY) { -+ if (this.inputDrag != null && event.button() == InputConstants.MOUSE_BUTTON_LEFT) { -+ final InputDrag current = this.inputDrag; -+ this.slide(current, event.x(), event.y()); -+ this.inputDrag = current; -+ this.setDragging(true); -+ return true; -+ } -+ if (this.drag != null && event.button() == InputConstants.MOUSE_BUTTON_LEFT) { -+ final var target = find(this.root, this.drag.node); -+ final var model = target == null ? null : NativeUiScroll.model(target, this.scroll.getOrDefault(this.drag.node, 0F)); -+ if (model != null) { -+ this.scrollAnimation(this.drag.node, false); -+ this.scroll.put(this.drag.node, model.offsetAt(model.pointer(event.x(), event.y()), -+ Math.min(this.drag.grab, model.extent()))); -+ this.layout(); -+ } -+ return true; ++import com.google.gson.JsonObject; ++import org.joml.Matrix4f; ++import org.joml.Vector3f; ++ ++/** Local player framing and independent actor, body and head rotations from the native draw. */ ++public record NativeUiPlayerCamera(float scale, Vector3f translation, Matrix4f modelTransform, ++ float pitch, float yaw, float bodyYaw, float headYaw) { ++ public static NativeUiPlayerCamera resolve(final NativeUiLayout.Rect control, final NativeUiLayout.Rect viewport, ++ final float layer, final JsonObject properties, final Cursor cursor, final Rotation rotation, ++ final boolean gliding, final boolean swimming, final boolean crawling) { ++ if (!(control.width() > 0) || !(control.height() > 0) ++ || !Float.isFinite(control.x() + control.y() + control.width() + control.height() + layer)) ++ throw new IllegalArgumentException("Invalid native player preview geometry"); ++ final float centerX = control.x() + control.width() * .5F; ++ final float centerY = control.y() + control.height() * .5F; ++ float scale = Math.min(control.width(), control.height()); ++ final float x = looksAtCursor(properties) && cursor != null ? centerX - cursor.x : 0; ++ final float y = looksAtCursor(properties) && cursor != null ? centerY - cursor.y : 0; ++ final float xAngle = (float) Math.atan(x / 40F); ++ final float yAngle = (float) Math.atan(y / 40F); ++ final float pitch = gliding ? rotation.pitch : yAngle * -20F; ++ final float yaw = gliding ? rotation.yaw : xAngle * 40F; ++ final float bodyYaw = gliding ? 0 : xAngle * 20F; ++ final float headYaw = gliding ++ ? ((rotation.headYaw + rotation.previousHeadYaw) - rotation.previousHeadYaw) ++ - ((rotation.bodyYaw + rotation.previousBodyYaw) - rotation.previousBodyYaw) ++ : yaw; ++ final Matrix4f model; ++ final float yOffset; ++ if (gliding) { ++ scale *= .6F; ++ yOffset = 15; ++ model = new Matrix4f().rotationX(.17453292F).scale(-scale, scale, 1).rotateZ((float) Math.PI); ++ } else { ++ yOffset = swimming || crawling ? -20 : 0; ++ model = new Matrix4f().scaling(-scale, scale, 1).rotateZ((float) Math.PI) ++ .rotateX(pitch * .017453292F); + } -+ return super.mouseDragged(event, deltaX, deltaY); ++ // PIP supplies (scale, -scale, 1). Keep the native transform order after its translation. ++ final Matrix4f correction = new Matrix4f().scaling(1F / scale, -1F / scale, 1).mul(model); ++ final Vector3f translation = new Vector3f((centerX - viewport.x() - viewport.width() * .5F) / scale, ++ (viewport.y() + viewport.height() * .5F - centerY - yOffset) / scale, ++ (layer + 1F) * 10F - 1000F); ++ return new NativeUiPlayerCamera(scale, translation, correction, pitch, yaw, bodyYaw, headYaw); + } + -+ @Override -+ public boolean mouseReleased(final MouseButtonEvent event) { -+ if (this.inputDrag != null && event.button() == InputConstants.MOUSE_BUTTON_LEFT) { -+ this.inputDrag = null; -+ this.setDragging(false); -+ return true; -+ } -+ if (this.drag != null && event.button() == InputConstants.MOUSE_BUTTON_LEFT) { -+ this.animations.event(this.drag.node, "scrollbar.released", this.animationTime()); -+ this.drag = null; -+ this.setDragging(false); -+ return true; -+ } -+ return super.mouseReleased(event); ++ public Vector3f translation(final float playerScale) { ++ if (playerScale < 0 || !Float.isFinite(playerScale)) ++ throw new IllegalArgumentException("Invalid native player preview scale"); ++ return this.modelTransform.transformDirection(new Vector3f(0, -1.501F * .9375F * playerScale, 0)) ++ .add(this.translation); + } + -+ private static NativeUiLayout.Box find(final NativeUiLayout.Box box, final NativeUiScene.Node node) { -+ if (box.node() == node) return box; -+ for (final var child : box.children()) { -+ final var found = find(child, node); -+ if (found != null) return found; -+ } -+ return null; ++ static boolean looksAtCursor(final JsonObject properties) { ++ final var bag = properties.getAsJsonObject("property_bag"); ++ final var value = bag == null ? null : bag.get("#look_at_cursor"); ++ return value != null && value.isJsonPrimitive() && value.getAsJsonPrimitive().isBoolean() && value.getAsBoolean(); + } + -+ private NativeUiLayout.Box scrollbar(final NativeUiLayout.Box box, final double x, final double y, -+ final Map interactive) { -+ if (!interactive.containsKey(box.node()) || !box.clip().contains(x, y)) return null; -+ for (int i = box.children().size() - 1; i >= 0; i--) { -+ final var target = this.scrollbar(box.children().get(i), x, y, interactive); -+ if (target != null) return target; -+ } -+ if (box.node().scroll()) { -+ final var model = NativeUiScroll.model(box, this.scroll.getOrDefault(box.node(), 0F)); -+ if (model != null && model.maximum() > 0 && interactive.containsKey(model.originalThumb().node()) -+ && model.containsTrack(x, y)) return box; -+ } -+ return null; -+ } ++ /** Logical cursor position, after the current input source supplied its admission and coordinates. */ ++ public record Cursor(float x, float y) {} + -+ private static NativeUiLayout.Box scrolling(final NativeUiLayout.Box box, final double x, final double y, -+ final Map interactive) { -+ if (!interactive.containsKey(box.node()) || !box.clip().contains(x, y)) return null; -+ for (int i = box.children().size() - 1; i >= 0; i--) { -+ final var target = scrolling(box.children().get(i), x, y, interactive); -+ if (target != null) return target; -+ } -+ return box.node().scroll() && box.bounds().contains(x, y) && NativeUiScroll.maximum(box) > 0 ? box : null; -+ } ++ public record Rotation(float pitch, float yaw, float bodyYaw, float previousBodyYaw, ++ float headYaw, float previousHeadYaw) {} ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiPresentation.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiPresentation.java +new file mode 100644 +index 0000000..99d79fd +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiPresentation.java +@@ -0,0 +1,162 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.ui; + -+ @Override -+ public ComponentPath nextFocusPath(final FocusNavigationEvent event) { -+ if (!(event instanceof FocusNavigationEvent.ArrowNavigation arrow)) return super.nextFocusPath(event); -+ final NativeUiFocus.Direction direction = switch (arrow.direction()) { -+ case UP -> NativeUiFocus.Direction.UP; -+ case DOWN -> NativeUiFocus.Direction.DOWN; -+ case LEFT -> NativeUiFocus.Direction.LEFT; -+ case RIGHT -> NativeUiFocus.Direction.RIGHT; -+ }; -+ final NativeUiScene.Node selected = this.getFocused() instanceof Action action ? action.node -+ : this.getFocused() instanceof NativeInput input ? input.node() : null; -+ final var targets = this.focusTargets(); -+ final var target = NativeUiFocus.next(targets, selected, direction, this.width, this.height, this.focusHistory); -+ if (target == null) return null; -+ final var node = target.box().node(); -+ final GuiEventListener widget = this.actions.containsKey(node) ? this.actions.get(node) : this.inputs.get(node).widget(); -+ return ComponentPath.path(this, ComponentPath.leaf(widget)); -+ } ++import java.util.ArrayList; ++import java.util.Collections; ++import java.util.Comparator; ++import java.util.HashSet; ++import java.util.IdentityHashMap; ++import java.util.List; ++import java.util.Set; ++import java.util.function.Function; ++import java.util.function.Predicate; + -+ private List focusTargets() { -+ return NativeUiFocus.targets(this.root, node -> { -+ final Action action = this.actions.get(node); -+ if (action != null) return action.visible && action.eligible; -+ final NativeInput input = this.inputs.get(node); -+ return input != null && this.eligibleInputs.contains(node); -+ }); -+ } ++/** Selects native visual states before layout while preserving control identities. */ ++public final class NativeUiPresentation { ++ private static final List SLIDER_STATES = List.of("default_control", "hover_control"); ++ private static final List THUMB_STATES = List.of("default_control", "hover_control", "locked_control", "indent_control"); + -+ @Override -+ public void setFocused(final GuiEventListener listener) { -+ super.setFocused(listener); -+ if (this.root == null) return; -+ final NativeUiScene.Node selected = listener instanceof Action action ? action.node -+ : listener instanceof NativeInput input ? input.node() : null; -+ this.focusHistory.select(this.focusTargets().stream().filter(target -> target.box().node() == selected).findFirst().orElse(null)); ++ private NativeUiPresentation() {} ++ ++ public static boolean updateSliderStates(final NativeUiScene.Node root, ++ final Predicate hovered, ++ final Predicate selected) { ++ return updateSliderStates(root, true, true, false, null, hovered, selected); + } + -+ @Override -+ public boolean keyPressed(final KeyEvent event) { -+ if (this.controller.inputs() != null && this.getFocused() instanceof Action action && action.node.properties().has("input") -+ && (event.key() == InputConstants.KEY_LEFT || event.key() == InputConstants.KEY_RIGHT)) { -+ this.controller.inputs().adjust(inputIndex(action.node), event.key() == InputConstants.KEY_LEFT ? -1 : 1); -+ this.refreshScene(); -+ return true; -+ } -+ if (!(this.getFocused() instanceof NativeInput) && (event.key() == InputConstants.KEY_PAGEUP || event.key() == InputConstants.KEY_PAGEDOWN -+ || event.key() == InputConstants.KEY_HOME || event.key() == InputConstants.KEY_END)) { -+ final var focused = this.getFocused() instanceof Action action ? action.node -+ : this.getFocused() instanceof NativeInput input ? input.node() : null; -+ NativeUiLayout.Box target = this.keyboardScroll(this.root, focused); -+ if (target == null) target = this.keyboardScroll(this.root, null); -+ if (target != null) { -+ final float page = NativeUiScroll.horizontal(target) ? target.scrollViewport().width() : target.scrollViewport().height(); -+ final float maximum = NativeUiScroll.maximum(target); -+ final float previous = this.scroll.getOrDefault(target.node(), 0F); -+ final float value = switch (event.key()) { -+ case InputConstants.KEY_HOME -> 0; -+ case InputConstants.KEY_END -> maximum; -+ case InputConstants.KEY_PAGEUP -> previous - page; -+ default -> previous + page; -+ }; -+ this.scrollAnimation(target.node(), true); -+ this.scroll.put(target.node(), Math.clamp(value, 0, maximum)); -+ this.layout(); -+ return true; -+ } ++ private static boolean updateSliderStates(final NativeUiScene.Node node, final boolean inheritedVisible, ++ final boolean inheritedEnabled, final boolean stateBranch, ++ final Boolean activeState, final Predicate hovered, ++ final Predicate selected) { ++ // Native named-state setters replace the branch root's authored visibility, not its descendants' bindings. ++ final boolean ownVisible = activeState != null ? activeState ++ : stateBranch ? node.flag("visible_when_active", node.flag("visible", true)) : node.flag("visible", true); ++ final boolean visible = inheritedVisible && ownVisible && !node.flag("ignored", false); ++ boolean changed = false; ++ if (stateBranch && node.flag("visible", true) != visible) { ++ node.properties().addProperty("visible", visible); ++ changed = true; + } -+ final var direction = switch (event.key()) { -+ case InputConstants.KEY_UP -> ScreenDirection.UP; -+ case InputConstants.KEY_DOWN -> ScreenDirection.DOWN; -+ case InputConstants.KEY_LEFT -> ScreenDirection.LEFT; -+ case InputConstants.KEY_RIGHT -> ScreenDirection.RIGHT; -+ default -> null; ++ final boolean enabled = inheritedEnabled && node.flag("enabled", true); ++ final List states = switch (node.type()) { ++ case "slider" -> SLIDER_STATES; ++ case "slider_box" -> THUMB_STATES; ++ default -> List.of(); + }; -+ if (direction != null) { -+ // Screen invokes its superclass focus path directly. Route arrows here after editable controls get their input. -+ if (this.getFocused() != null && this.getFocused().keyPressed(event)) { -+ this.refreshFocusedControl(); -+ return true; -+ } -+ final var path = this.nextFocusPath(new FocusNavigationEvent.ArrowNavigation(direction)); -+ if (path == null) return false; -+ if (path.leafComponent() != this.getFocused()) { -+ this.changeFocus(path); -+ this.refreshFocusedControl(); ++ final String selectedName; ++ if (states.isEmpty()) { ++ selectedName = ""; ++ } else { ++ final String selectedProperty = node.type().equals("slider_box") ++ ? !enabled ? "locked_control" : selected.test(node) ? "indent_control" : hovered.test(node) ? "hover_control" : "default_control" ++ : hovered.test(node) ? "hover_control" : "default_control"; ++ selectedName = node.string(selectedProperty, ""); ++ } ++ for (final var child : node.children()) { ++ Boolean active = null; ++ for (final String state : states) { ++ final String name = node.string(state, ""); ++ if (!name.isEmpty() && name.equals(child.name())) { ++ active = name.equals(selectedName); ++ break; ++ } + } -+ return true; ++ changed |= updateSliderStates(child, visible, enabled, stateBranch || active != null, active, hovered, selected); + } -+ final boolean result = super.keyPressed(event); -+ this.refreshFocusedControl(); -+ return result; ++ return changed; + } + -+ private void refreshFocusedControl() { -+ this.refreshPendingScene(); -+ if (this.getFocused() instanceof Action action) this.reveal(this.root, action.node); -+ else if (this.getFocused() instanceof NativeInput input) this.reveal(this.root, input.node()); ++ public static List draws(final NativeUiLayout.Box root, final Predicate hovered) { ++ return draws(root, hovered, node -> new Opacity(node.number("alpha", 1), node.number("alpha", 1))); + } + -+ @Override -+ public boolean charTyped(final CharacterEvent event) { -+ final boolean result = super.charTyped(event); -+ this.refreshFocusedControl(); -+ return result; ++ public static List draws(final NativeUiLayout.Box root, final Predicate hovered, ++ final Function opacity) { ++ return draws(root, hovered, opacity, _ -> false); + } + -+ private NativeUiLayout.Box keyboardScroll(final NativeUiLayout.Box box, final NativeUiScene.Node focused) { -+ for (int i = box.children().size() - 1; i >= 0; i--) { -+ final var nested = this.keyboardScroll(box.children().get(i), focused); -+ if (nested != null) return nested; -+ } -+ if (!box.node().scroll() || NativeUiScroll.maximum(box) == 0) return null; -+ if (focused != null) return find(box, focused) != null ? box : null; -+ return this.actions.values().stream().anyMatch(action -> action.visible && action.active && find(box, action.node) != null) ? box : null; ++ /** Retained Button components apply their named setters before layout. */ ++ public static List draws(final NativeUiLayout.Box root, final Predicate hovered, ++ final Function opacity, ++ final Predicate retainedButtons) { ++ final List result = new ArrayList<>(); ++ collect(root, new Opacity(1, 1), true, hovered, opacity, retainedButtons, ++ Collections.newSetFromMap(new IdentityHashMap<>()), result); ++ // Native traversal retains authored child order. Sort the complete draw list ++ // so equal cumulative layers keep that order across different subtrees. ++ result.sort(Comparator.comparingDouble(draw -> draw.box().layer())); ++ return result; + } + -+ private boolean reveal(final NativeUiLayout.Box box, final NativeUiScene.Node focused) { -+ if (box.node() == focused) return true; -+ for (final var child : box.children()) if (this.reveal(child, focused)) { -+ if (box.node().scroll()) { -+ final var control = find(box, focused); -+ if (control == null) return true; -+ final var bounds = control.bounds(); -+ final var viewport = box.scrollViewport().intersect(box.clip()); -+ final boolean horizontal = NativeUiScroll.horizontal(box); -+ final float start = horizontal ? bounds.x() : bounds.y(), extent = horizontal ? bounds.width() : bounds.height(); -+ final float viewportStart = horizontal ? viewport.x() : viewport.y(), viewportExtent = horizontal ? viewport.width() : viewport.height(); -+ final float delta = start < viewportStart ? start - viewportStart -+ : Math.max(0, start + extent - viewportStart - viewportExtent); -+ if (delta != 0) { -+ this.scrollAnimation(box.node(), true); -+ this.scroll.put(box.node(), Math.clamp(this.scroll.getOrDefault(box.node(), 0F) + delta, -+ 0, NativeUiScroll.maximum(box))); -+ this.layout(); ++ private static void collect(final NativeUiLayout.Box box, final Opacity inheritedAlpha, ++ final boolean inheritedEnabled, final Predicate hovered, ++ final Function opacity, ++ final Predicate retainedButtons, ++ final Set hidden, final List result) { ++ final var node = box.node(); ++ if (hidden.contains(node) || !node.flag("visible", true) || node.flag("ignored", false)) return; ++ if (node.scroll() && !node.flag("scrollbar_always_visible", false) && NativeUiScroll.maximum(box) == 0) { ++ final var panel = NativeUiScroll.descendant(box, node.string("scroll_box_and_track_panel", "")); ++ if (panel != null) hidden.add(panel.node()); ++ else { ++ for (final String property : List.of("scrollbar_track", "scrollbar_box")) { ++ final var control = NativeUiScroll.descendant(box, node.string(property, "")); ++ if (control != null) hidden.add(control.node()); + } + } -+ return true; -+ } -+ return false; -+ } -+ -+ @Override -+ public void onClose() { -+ if (!this.controller.current()) return; -+ switch (this.controller.dismiss()) { -+ case CLOSE -> this.minecraft.setScreenAndShow(null); -+ case STAY -> this.refreshScene(); -+ case REPLACED -> { /* The controller transferred focus to another native screen. */ } ++ } ++ final Opacity local = opacity.apply(node); ++ final Opacity alpha = new Opacity(inheritedAlpha.current * Math.clamp(local.current, 0, 1), ++ inheritedAlpha.potential * Math.clamp(local.potential, 0, 1)); ++ final boolean enabled = inheritedEnabled && node.flag("enabled", true); ++ if (alpha.potential > 0) result.add(new Draw(box, alpha.current, alpha.potential, enabled)); ++ // Native image opacity is local unless the owner explicitly enables propagation. ++ // Dialogs use transparent image owners whose labels and buttons remain opaque. ++ final Opacity childAlpha = node.flag("propagate_alpha", false) ? alpha : inheritedAlpha; ++ if (childAlpha.potential == 0 || box.children().isEmpty()) return; ++ final boolean isHovered = node.interactive() && hovered.test(box); ++ final Set excluded = new HashSet<>(); ++ if (!node.type().equals("slider") && !node.type().equals("slider_box") && !retainedButtons.test(node)) { ++ select(node, excluded, !enabled ? "locked_control" : isHovered ? "hover_control" : "default_control", "default_control", ++ "default_control", "hover_control", "pressed_control", "locked_control"); ++ } ++ if (node.type().equals("toggle")) { ++ final String state = node.flag("toggle_state", false) ? "checked" : "unchecked"; ++ select(node, excluded, state + (!enabled ? "_locked" : "") + (isHovered ? "_hover" : "") + "_control", state + "_control", ++ "checked_control", "unchecked_control", "checked_hover_control", "unchecked_hover_control", ++ "checked_locked_control", "unchecked_locked_control", "checked_locked_hover_control", "unchecked_locked_hover_control"); ++ } ++ if (node.type().equals("slider")) { ++ select(node, excluded, isHovered ? "background_hover_control" : "background_control", "background_control", ++ "background_control", "background_hover_control"); ++ select(node, excluded, isHovered ? "progress_hover_control" : "progress_control", "progress_control", ++ "progress_control", "progress_hover_control"); ++ } ++ if (node.type().equals("edit_box") && node.properties().has("input")) { ++ excluded.add(node.string("text_control", "")); ++ excluded.add(node.string("place_holder_control", "")); ++ } ++ for (final var child : box.children()) { ++ if (excluded.contains(child.node().name())) continue; ++ collect(child, childAlpha, enabled, hovered, opacity, retainedButtons, hidden, result); + } + } + -+ @Override -+ public boolean isPauseScreen() { -+ return false; -+ } -+ -+ @Override -+ public void tick() { -+ if (!this.controller.current()) this.minecraft.setScreenAndShow(null); -+ else if (this.imageRevision != this.images.revision()) { -+ this.imageRevision = this.images.revision(); -+ try { this.layout(); } -+ catch (final RuntimeException error) { -+ this.controller.failed(error); -+ } ++ private static void select(final NativeUiScene.Node node, final Set excluded, ++ final String selectedProperty, final String fallbackProperty, final String... states) { ++ String selected = node.string(selectedProperty, ""); ++ if (selected.isEmpty()) selected = node.string(fallbackProperty, ""); ++ for (final String state : states) { ++ final String name = node.string(state, ""); ++ if (!name.isEmpty() && !name.equals(selected)) excluded.add(name); + } + } + -+ @Override -+ public void removed() { -+ if (this.renderer != null) this.renderer.close(); -+ this.images.close(); ++ public record Opacity(float current, float potential) {} ++ ++ public record Draw(NativeUiLayout.Box box, float alpha, float potential, boolean enabled) {} ++} +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..f64ab0d +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiRenderer.java +@@ -0,0 +1,182 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.ui; ++ ++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; ++import java.util.Map; ++import net.minecraft.client.gui.Font; ++import net.minecraft.client.gui.GuiGraphicsExtractor; ++import net.minecraft.network.chat.Component; ++import net.minecraft.network.chat.ComponentSerialization; ++import net.minecraft.network.chat.FontDescription; ++import net.minecraft.resources.Identifier; ++import net.minecraft.util.FormattedCharSequence; ++ ++/** Shared accepted-pack text, image and intrinsic metrics, independent of screen authority. */ ++public final class NativeUiRenderer implements NativeUiLayout.Metrics { ++ private final NativeUiActorPortraits portraits = new NativeUiActorPortraits(); ++ private final Font font; ++ private final NativeUiImages images; ++ private final Map texts = new IdentityHashMap<>(); ++ private final Map wrapped = new IdentityHashMap<>(); ++ ++ public NativeUiRenderer(final Font font, final NativeUiImages images) { ++ this.font = font; ++ this.images = images; + } + -+ private static int inputIndex(final NativeUiScene.Node node) { -+ return node.properties().getAsJsonObject("input").get("index").getAsBigDecimal().intValueExact(); ++ public void reset() { this.texts.clear(); this.wrapped.clear(); } ++ public void retain(final List roots) { ++ final Set live = Collections.newSetFromMap(new IdentityHashMap<>()); ++ for (final var root : roots) collect(root, live); ++ this.texts.keySet().retainAll(live); ++ this.wrapped.keySet().retainAll(live); + } + -+ private void activate(final NativeUiScene.Node node) { -+ switch (this.controller.activate(node)) { -+ case CLOSE -> this.minecraft.setScreenAndShow(null); -+ case STAY -> this.refreshScene(); -+ case REPLACED -> { /* The controller transferred focus to another native screen. */ } -+ } ++ private static void collect(final NativeUiScene.Node node, final Set live) { ++ live.add(node); ++ for (final var child : node.children()) collect(child, live); + } ++ public void close() { this.reset(); this.portraits.clear(); } ++ public void prepare(final NativeUiScene.Node root) { this.portraits.prepare(root); } + -+ private static NativeUiLayout.Box sliderOwner(final NativeUiLayout.Box box, final NativeUiScene.Node target, -+ final NativeUiLayout.Box owner) { -+ final var current = box.node().type().equals("slider") ? box : owner; -+ if (box.node() == target) return current; -+ for (final var child : box.children()) { -+ final var found = sliderOwner(child, target, current); -+ if (found != null) return found; -+ } -+ return null; ++ public void portrait(final GuiGraphicsExtractor graphics, final NativeUiLayout.Box box, ++ final float mouseX, final float mouseY, final float partialTick, final float alpha) { ++ this.portraits.draw(graphics, box, mouseX, mouseY, partialTick, alpha); + } + -+ private void slide(final InputDrag drag, final double x, final double y) { -+ final boolean vertical = drag.owner.node().string("slider_direction", "horizontal").equals("vertical"); -+ final float extent = vertical ? drag.owner.bounds().height() : drag.owner.bounds().width(); -+ if (extent <= 0) return; -+ this.controller.inputs().slide(drag.index, NativeUiSlider.pointerFraction(drag.owner, x, y)); -+ this.refreshScene(); -+ this.inputDrag = drag; ++ @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)).lines.size(); ++ return NativeUiFont.constrained(node, width, lines); + } + -+ private void refreshPendingScene() { -+ if (this.refreshPending) this.refreshScene(); ++ public void draw(final GuiGraphicsExtractor graphics, final NativeUiPresentation.Draw draw, final NativeUiAnimations animations) { ++ final var node = draw.box().node(); ++ if (node.type().equals("image") && !node.string("texture", "").isEmpty()) ++ this.images.draw(graphics, draw.box(), imageColor(node, draw.alpha(), animations), animations); ++ else if (node.type().equals("label")) this.label(graphics, draw.box(), draw.alpha()); + } + -+ private void refreshScene() { -+ this.refreshPending = false; -+ try { -+ final List previousControls = new ArrayList<>(); -+ controls(this.root, "", 0, previousControls); -+ final Map previousNodes = new HashMap<>(); -+ for (final var control : previousControls) previousNodes.put(control.key, control.box.node()); -+ final Map offsets = new HashMap<>(); -+ this.scrollOffsets(this.scene, "", offsets, false); -+ this.controller.refresh(); -+ this.scene = this.controller.root(); -+ this.resetAnimations(); -+ if (this.renderer != null) this.renderer.reset(); -+ this.scroll.clear(); -+ this.scrollOffsets(this.scene, "", offsets, true); -+ this.layout(); -+ this.reconcileWidgets(); -+ final List currentControls = new ArrayList<>(); -+ controls(this.root, "", 0, currentControls); -+ final Map replacements = new IdentityHashMap<>(); -+ for (final var control : currentControls) { -+ final var previous = previousNodes.get(control.key); -+ if (previous != null) replacements.put(previous, control.box.node()); ++ public NativeUiLayout.Size intrinsic(final NativeUiScene.Node node) { ++ return switch (node.type()) { ++ case "label" -> { ++ 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()); + } -+ this.focusHistory.remap(replacements, this.focusTargets()); -+ } catch (final RuntimeException error) { -+ this.controller.failed(error); -+ } ++ case "image" -> { ++ if (node.string("texture", "").isEmpty()) yield new NativeUiLayout.Size(0, 0); ++ final var image = this.images.image(node); ++ yield image == null ? new NativeUiLayout.Size(16, 16) : new NativeUiLayout.Size(image.width(), image.height()); ++ } ++ case "button", "toggle" -> new NativeUiLayout.Size(200, 20); ++ default -> new NativeUiLayout.Size(0, 0); ++ }; + } + -+ private void scrollOffsets(final NativeUiScene.Node node, final String parent, final Map offsets, -+ final boolean restore) { -+ final String key = parent + "/" + node.name(); -+ if (node.scroll()) { -+ if (restore) this.scroll.put(node, offsets.getOrDefault(key, 0F)); -+ else offsets.put(key, this.scroll.getOrDefault(node, 0F)); -+ } -+ for (final var child : node.children()) this.scrollOffsets(child, key, offsets, restore); ++ public void label(final GuiGraphicsExtractor graphics, final NativeUiLayout.Box box, final float alpha) { ++ this.label(graphics, box, alpha, this.text(box.node())); + } + -+ private record InputDrag(int index, NativeUiLayout.Box owner) {} -+ -+ private interface NativeInput { -+ NativeUiScene.Node node(); -+ AbstractWidget widget(); -+ void place(NativeUiLayout.Box box); -+ void setEditable(boolean editable); ++ public void label(final GuiGraphicsExtractor graphics, final NativeUiLayout.Box box, final float alpha, final Component text) { ++ final var node = box.node(); ++ 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).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(); ++ for (final var line : lines) { ++ final int lineWidth = this.font.width(line); ++ final int x = alignment.equals("center") ? (width - lineWidth) / 2 : alignment.equals("right") ? width - lineWidth : 0; ++ final var origin = NativeUiFont.origin(node, box.bounds().x() + x * scale, y, scale, uiScale); ++ graphics.pose().pushMatrix(); ++ try { ++ graphics.pose().translate(origin.x(), origin.y()); ++ graphics.pose().scale(scale, scale); ++ graphics.text(this.font, line, 0, 0, color(node, alpha), node.flag("shadow", false)); ++ } finally { ++ graphics.pose().popMatrix(); ++ } ++ y += NativeUiFont.lineHeight(node) * scale; ++ } + } + -+ private static NativeUiScene.Node inputTextStyle(final NativeUiLayout.Box owner) { -+ final String name = owner.node().string("text_control", ""); -+ final var control = name.isEmpty() ? null : NativeUiScroll.descendant(owner, name); -+ return control == null ? owner.node() : control.node(); ++ 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; ++ final var value = new WrappedText(text, width, NativeUiText.labelLines(this.font, text, width)); ++ this.wrapped.put(node, value); ++ return value; + } + -+ private final class SingleLineInput extends EditBox implements NativeInput { -+ private NativeUiScene.Node node; -+ private NativeUiLayout.Box box; ++ private static final class WrappedText { ++ final Component text; ++ final int width; ++ final List lines; ++ private int visible = -1; ++ private List display = List.of(); + -+ SingleLineInput(final NativeUiLayout.Box box) { -+ super(NativeUiSceneScreen.this.font, 0, 0, 1, 1, NativeUiText.component(Action.caption(box.node()))); -+ this.node = box.node(); -+ final int index = inputIndex(this.node); -+ this.setMaxLength(NativeUiSceneScreen.this.controller.inputs().maxLength(index)); -+ this.setBordered(false); -+ this.place(box); -+ this.setValue(NativeUiSceneScreen.this.controller.inputs().text(index)); -+ this.setHint(NativeUiText.component(NativeUiSceneScreen.this.controller.inputs().placeholder(index))); -+ this.setResponder(value -> { -+ NativeUiSceneScreen.this.refreshPending |= NativeUiSceneScreen.this.controller.inputs().setText(index, value); -+ }); ++ WrappedText(final Component text, final int width, final List lines) { ++ this.text = text; ++ this.width = width; ++ this.lines = lines; + } + -+ @Override -+ public void place(final NativeUiLayout.Box box) { -+ this.node = box.node(); -+ this.box = box; -+ final var bounds = box.bounds(); -+ this.setRectangle(Math.max(1, Math.round(bounds.width())), Math.max(1, Math.round(bounds.height())), -+ Math.round(bounds.x()), Math.round(bounds.y())); -+ final var style = inputTextStyle(box); -+ this.setTextColor(NativeUiRenderer.color(style, 1)); -+ this.setTextColorUneditable(NativeUiRenderer.color(style, 1)); -+ // EditBox calculates horizontal scrolling when its cursor moves, not -+ // when its rectangle changes. Reflow without changing the selection. -+ this.setCursorPosition(this.getCursorPosition()); ++ 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); + } ++ } + -+ @Override -+ public NativeUiScene.Node node() { return this.node; } ++ public Component text(final NativeUiScene.Node node) { ++ return this.texts.computeIfAbsent(node, value -> { ++ final Component text = value.properties().has("text_component") ++ ? ComponentSerialization.CODEC.parse(JsonOps.INSTANCE, ++ value.properties().get("text_component")).getOrThrow() ++ : value.flag("localize", false) ? Component.translatable(value.string("text", "")) ++ : NativeUiText.component(value.string("text", "")); ++ return this.styled(value, text); ++ }); ++ } + -+ @Override -+ public AbstractWidget widget() { return this; } ++ public Component styled(final NativeUiScene.Node node, final Component text) { ++ final String font = node.string("font", ""); ++ return font.isEmpty() ? text : text.copy().withStyle(style -> style.withFont(new FontDescription.Resource(Identifier.parse(font)))); ++ } + -+ @Override -+ public boolean isMouseOver(final double x, final double y) { -+ return this.box.visibleBounds().contains(x, y) && super.isMouseOver(x, y); -+ } ++ static int imageColor(final NativeUiScene.Node node, final float alpha, final NativeUiAnimations animations) { ++ final NativeUiColor animated = animations.color(node); ++ return (animated == null ? NativeUiColor.parse(node.properties().get("color")) : animated).argb(alpha); + } + -+ private final class MultilineInput extends NativeUiMultilineInput implements NativeInput { -+ private NativeUiLayout.Box box; ++ public static int color(final NativeUiScene.Node node, final float alpha) { ++ final int red = textChannel(NativeUiLayout.vector(node, "color", 0, 1)); ++ final int green = textChannel(NativeUiLayout.vector(node, "color", 1, 1)); ++ final int blue = textChannel(NativeUiLayout.vector(node, "color", 2, 1)); ++ return textChannel(alpha) << 24 | red << 16 | green << 8 | blue; ++ } + -+ MultilineInput(final NativeUiLayout.Box box) { -+ super(NativeUiSceneScreen.this.font, NativeUiText.component(Action.caption(box.node())), -+ NativeUiSceneScreen.this.controller.inputs().text(inputIndex(box.node())), -+ NativeUiSceneScreen.this.controller.inputs().placeholder(inputIndex(box.node())), -+ NativeUiSceneScreen.this.controller.inputs().maxLength(inputIndex(box.node())), -+ value -> NativeUiSceneScreen.this.refreshPending |= -+ NativeUiSceneScreen.this.controller.inputs().setText(inputIndex(box.node()), value)); -+ this.place(box); -+ } ++ private static int textChannel(final float value) { ++ return Math.clamp((int) (value * 255), 0, 255); ++ } + -+ @Override -+ public void place(final NativeUiLayout.Box box) { -+ this.box = box; -+ final var style = inputTextStyle(box); -+ this.configure(box.bounds(), NativeUiRenderer.color(style, 1), style.flag("shadow", false)); -+ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiScene.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiScene.java +new file mode 100644 +index 0000000..e0ed675 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiScene.java +@@ -0,0 +1,107 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.ui; + -+ @Override -+ public NativeUiScene.Node node() { return this.box.node(); } ++import com.google.gson.JsonElement; ++import com.google.gson.JsonObject; ++import com.google.gson.JsonParser; ++import java.math.BigInteger; ++import java.util.ArrayList; ++import java.util.List; ++import java.util.Set; + -+ @Override -+ public AbstractWidget widget() { return this; } ++/** Bounded resolved UI geometry and opaque controller annotations shared by forms and containers. */ ++public record NativeUiScene(Node root) { ++ private static final Set TYPES = Set.of("screen", "panel", "label", "image", "button", "toggle", ++ "stack_panel", "grid", "scroll_view", "scrollbar_box", "scroll_track", "collection_panel", "factory", "custom", ++ "slider", "slider_box", "edit_box", "input_panel"); + -+ @Override -+ public boolean isMouseOver(final double x, final double y) { -+ return this.box.visibleBounds().contains(x, y) && super.isMouseOver(x, y); -+ } ++ public static NativeUiScene parse(final String json, final Set renderers) { ++ final JsonObject scene = JsonParser.parseString(json).getAsJsonObject(); ++ final int format = scene.get("format").getAsBigDecimal().intValueExact(); ++ if (format != 1 && format != 2) throw new IllegalArgumentException("Unsupported native UI format"); ++ return new NativeUiScene(node(scene.getAsJsonObject("root"), 0, new int[1], true, renderers)); + } + -+ private record Drag(NativeUiScene.Node node, float grab) {} ++ public static void validateVisibleControls(final Node node, final Set renderers) { ++ validateVisibleControls(node, true, renderers); ++ } + -+ private final class Action extends AbstractButton { -+ private NativeUiScene.Node node; -+ private NativeUiLayout.Box box; -+ private boolean eligible; ++ private static void validateVisibleControls(final Node node, final boolean inheritedVisible, final Set renderers) { ++ final boolean visible = inheritedVisible && node.flag("visible", true) && !node.flag("ignored", false); ++ if (visible && (!TYPES.contains(node.type()) || node.type().equals("custom") && !renderers.contains(node.string("renderer", "")))) ++ throw new IllegalArgumentException("Unsupported native UI control: " + node.type()); ++ for (final var child : node.children()) validateVisibleControls(child, visible, renderers); ++ } + -+ Action(final NativeUiLayout.Box box) { -+ super(0, 0, 1, 1, NativeUiText.component(caption(box.node()))); -+ this.node = box.node(); -+ this.place(box); -+ } ++ private static Node node(final JsonObject value, final int depth, final int[] count, final boolean inheritedVisible, ++ final Set renderers) { ++ if (depth > 64 || ++count[0] > 8192) throw new IllegalArgumentException("Native UI scene exceeds layout limits"); ++ final String type = string(value, "type", "panel"); ++ final boolean visible = inheritedVisible && flag(value, "visible", true) && !flag(value, "ignored", false); ++ if (visible && (!TYPES.contains(type) || type.equals("custom") && !renderers.contains(string(value, "renderer", "")))) ++ throw new IllegalArgumentException("Unsupported native UI control: " + type); ++ final List children = new ArrayList<>(); ++ if (value.has("children")) for (final JsonElement child : value.getAsJsonArray("children")) ++ children.add(node(child.getAsJsonObject(), depth + 1, count, visible, renderers)); ++ final JsonObject properties = new JsonObject(); ++ value.entrySet().stream().filter(entry -> !entry.getKey().equals("children")) ++ .forEach(entry -> properties.add(entry.getKey(), entry.getValue().deepCopy())); ++ final int action = value.has("action") ? value.get("action").getAsBigDecimal().intValueExact() : Integer.MIN_VALUE; ++ return new Node(string(value, "name", ""), type, properties, List.copyOf(children), action); ++ } + -+ void place(final NativeUiLayout.Box box) { -+ this.node = box.node(); -+ this.setMessage(NativeUiText.component(caption(this.node))); -+ final String tooltip = this.node.string("tooltip", ""); -+ this.setTooltip(tooltip.isEmpty() ? null : Tooltip.create(NativeUiText.component(tooltip))); -+ this.box = box; -+ final var bounds = box.bounds(); -+ this.setRectangle(Math.max(1, Math.round(bounds.width())), Math.max(1, Math.round(bounds.height())), -+ Math.round(bounds.x()), Math.round(bounds.y())); -+ } ++ public static String string(final JsonObject properties, final String key, final String fallback) { ++ final JsonElement value = properties.get(key); ++ return value == null || !value.isJsonPrimitive() ? fallback : value.getAsString(); ++ } + -+ @Override -+ public void onPress(final InputWithModifiers input) { -+ NativeUiSceneScreen.this.activate(this.node); ++ static boolean flag(final JsonObject properties, final String key, final boolean fallback) { ++ final JsonElement value = properties.get(key); ++ if (value == null || !value.isJsonPrimitive()) return fallback; ++ final var primitive = value.getAsJsonPrimitive(); ++ if (primitive.isBoolean()) return primitive.getAsBoolean(); ++ if (!primitive.isNumber()) return fallback; ++ // Native component readers accept JsonCpp signed/unsigned integers, but not real values. ++ final String encoded = primitive.getAsString(); ++ if (encoded.indexOf('.') >= 0 || encoded.indexOf('e') >= 0 || encoded.indexOf('E') >= 0) return fallback; ++ try { ++ final BigInteger integer = new BigInteger(encoded); ++ if (integer.bitLength() > (integer.signum() < 0 ? 63 : 64)) return fallback; ++ return integer.signum() != 0; ++ } catch (final NumberFormatException invalid) { ++ return fallback; + } ++ } + -+ @Override -+ protected void extractContents(final GuiGraphicsExtractor graphics, final int x, final int y, final float delta) { -+ // Native button controls are hit targets. Their authored state children -+ // provide visuals; a control without visual children stays transparent. ++ public record Node(String name, String type, JsonObject properties, List children, int action) { ++ public String string(final String key, final String fallback) { ++ return NativeUiScene.string(this.properties, key, fallback); + } + -+ @Override -+ public ComponentPath nextFocusPath(final FocusNavigationEvent event) { -+ return this.node.flag("focus_enabled", false) ? super.nextFocusPath(event) : null; ++ public float number(final String key, final float fallback) { ++ final JsonElement value = this.properties.get(key); ++ if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber()) return fallback; ++ final float result = value.getAsFloat(); ++ if (!Float.isFinite(result)) throw new IllegalArgumentException("Non-finite native UI property"); ++ return result; + } + -+ @Override -+ public boolean isMouseOver(final double x, final double y) { -+ return this.box.visibleBounds().contains(x, y) && super.isMouseOver(x, y); ++ public boolean flag(final String key, final boolean fallback) { ++ return NativeUiScene.flag(this.properties, key, fallback); + } + -+ @Override -+ protected void updateWidgetNarration(final NarrationElementOutput output) { -+ this.defaultButtonNarrationText(output); ++ public boolean interactive() { ++ return this.type.equals("toggle") || this.action != Integer.MIN_VALUE || this.properties.has("input") || this.properties.has("command") ++ || this.properties.has("recipe_actions") || this.properties.has("inventory_actions") || this.properties.has("inventory_command") || this.properties.has("url"); + } + -+ private static String caption(final NativeUiScene.Node node) { -+ final String text = node.string("text", ""); -+ if (!text.isEmpty()) return text; -+ for (final var child : node.children()) { -+ final String value = caption(child); -+ if (!value.isEmpty()) return value; -+ } -+ return node.name(); ++ public boolean scroll() { ++ return this.type.equals("scroll_view"); + } + } ++ +} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiScissor.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiScissor.java +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..99a2446 +index 0000000..05f9080 --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiScissor.java -@@ -0,0 +1,73 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiSceneScreen.java +@@ -0,0 +1,822 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -9698,1226 +11158,2139 @@ index 0000000..99a2446 + */ +package com.viaversion.viafabricplus.bedrock.screen.ui; + -+import com.viaversion.viafabricplus.bedrock.injection.access.IBedrockGuiScissor; -+import net.minecraft.client.Minecraft; ++import com.mojang.blaze3d.platform.InputConstants; ++import com.mojang.serialization.JsonOps; ++import java.util.ArrayList; ++import java.util.Collections; ++import java.util.Comparator; ++import java.util.HashMap; ++import java.util.HashSet; ++import java.util.IdentityHashMap; ++import java.util.List; ++import java.util.Map; ++import java.util.Set; ++import net.minecraft.client.gui.ComponentPath; +import net.minecraft.client.gui.GuiGraphicsExtractor; -+import net.minecraft.client.gui.navigation.ScreenRectangle; -+ -+/** Retains native clip precision independently of Java's integer GUI coordinate grid. */ -+public final class NativeUiScissor { -+ private final NativeUiLayout.Rect bounds; -+ private final ScreenRectangle pixels; ++import net.minecraft.client.gui.components.AbstractButton; ++import net.minecraft.client.gui.components.AbstractWidget; ++import net.minecraft.client.gui.components.EditBox; ++import net.minecraft.client.gui.components.Tooltip; ++import net.minecraft.client.gui.components.events.GuiEventListener; ++import net.minecraft.client.gui.narration.NarrationElementOutput; ++import net.minecraft.client.gui.navigation.FocusNavigationEvent; ++import net.minecraft.client.gui.navigation.ScreenDirection; ++import net.minecraft.client.gui.screens.Screen; ++import net.minecraft.client.input.CharacterEvent; ++import net.minecraft.client.input.InputWithModifiers; ++import net.minecraft.client.input.KeyEvent; ++import net.minecraft.client.input.MouseButtonEvent; ++import net.minecraft.network.chat.Component; ++import net.minecraft.network.chat.ComponentSerialization; + -+ public NativeUiScissor(final NativeUiLayout.Rect bounds) { -+ this.bounds = bounds; -+ // Native intersects floating rectangles before truncating the origin and extent. -+ this.pixels = new ScreenRectangle((int) bounds.x(), (int) bounds.y(), -+ Math.max(0, (int) bounds.width()), Math.max(0, (int) bounds.height())); ++/** Draws authored scenes with native directional focus geometry and Java input, narration and domain responses. */ ++public final class NativeUiSceneScreen extends Screen implements NativeUiScreen { ++ private final NativeUiController controller; ++ private NativeUiScene.Node scene; ++ private final NativeUiImages images = new NativeUiImages(); ++ private NativeUiAnimationLifecycle lifecycle; ++ private NativeUiAnimations animations; ++ private long animationStarted; ++ private final Map scroll = new IdentityHashMap<>(); ++ private final NativeUiFocus.History focusHistory = new NativeUiFocus.History(); ++ private final Map actions = new IdentityHashMap<>(); ++ private final Map inputs = new IdentityHashMap<>(); ++ private final Set eligibleInputs = Collections.newSetFromMap(new IdentityHashMap<>()); ++ private final Map geometry = new IdentityHashMap<>(); ++ private final Map actionWidgets = new HashMap<>(); ++ private final Map inputWidgets = new HashMap<>(); ++ private boolean refreshPending; ++ private NativeUiRenderer renderer; ++ private NativeUiLayout.Box root; ++ private Drag drag; ++ private InputDrag inputDrag; ++ private long imageRevision; ++ private double pointerX = -1; ++ private double pointerY = -1; ++ ++ public NativeUiSceneScreen(final NativeUiController controller) { ++ super(NativeUiText.component(controller.title())); ++ this.controller = controller; ++ this.scene = controller.root(); ++ this.resetAnimations(); + } + -+ public static NativeUiScissor scaled(final NativeUiLayout.Rect bounds, final int scale) { -+ return new NativeUiScissor(new NativeUiLayout.Rect(bounds.x() * scale, bounds.y() * scale, -+ bounds.width() * scale, bounds.height() * scale)); ++ private void resetAnimations() { ++ this.lifecycle = new NativeUiAnimationLifecycle(this.scene); ++ this.animations = this.lifecycle.animations(); ++ this.animationStarted = System.nanoTime(); + } + -+ public NativeUiScissor intersect(final NativeUiScissor other) { -+ return new NativeUiScissor(this.bounds.intersect(other.bounds)); ++ private long animationTime() { return System.nanoTime() - this.animationStarted; } ++ ++ private void scrollAnimation(final NativeUiScene.Node target, final boolean released) { ++ final long now = this.animationTime(); ++ this.animations.event(target, "scrollbar.active", now); ++ if (released) this.animations.event(target, "scrollbar.released", now); + } + -+ public ScreenRectangle pixels() { return this.pixels; } ++ public boolean controlledBy(final NativeUiController controller) { ++ return this.controller == controller; ++ } + -+ /** Conservative bounds for Java's overlap search; the render pass uses the exact pixels. */ -+ public ScreenRectangle guiBounds(final int javaScale) { -+ final int left = Math.floorDiv(this.pixels.left(), javaScale); -+ final int top = Math.floorDiv(this.pixels.top(), javaScale); -+ return new ScreenRectangle(left, top, Math.ceilDiv(this.pixels.right(), javaScale) - left, -+ Math.ceilDiv(this.pixels.bottom(), javaScale) - top); ++ @Override ++ public void extractBackground(final GuiGraphicsExtractor graphics, final int mouseX, final int mouseY, final float delta) { ++ // The resource-pack scene owns its background, including any world overlay. + } + -+ public static boolean samePixels(final NativeUiScissor first, final NativeUiScissor second) { -+ return first == second || first != null && second != null && first.pixels.equals(second.pixels); ++ @Override ++ protected void init() { ++ this.drag = null; ++ this.inputDrag = null; ++ this.setDragging(false); ++ this.layout(); ++ this.reconcileWidgets(); + } + -+ public static NativeUiScissor from(final ScreenRectangle rectangle) { -+ return rectangle == null ? null : ((IBedrockGuiScissor) (Object) rectangle).stackanvil$scissor(); ++ private void reconcileWidgets() { ++ this.actions.clear(); ++ this.inputs.clear(); ++ final List boxes = new ArrayList<>(); ++ controls(this.root, "", 0, boxes); ++ Collections.reverse(boxes); ++ boxes.sort(Comparator.comparingDouble((ControlBox control) -> control.box.layer()).reversed()); ++ final Set retainedActions = new HashSet<>(), retainedInputs = new HashSet<>(); ++ for (final var control : boxes) { ++ final var box = control.box; ++ if (!box.node().interactive()) continue; ++ if (this.controller.inputs() != null && box.node().type().equals("edit_box") && box.node().properties().has("input")) { ++ final NativeInput input = this.inputWidgets.computeIfAbsent(control.key, ++ key -> box.node().flag("enabled_newline", false) ? new MultilineInput(box) : new SingleLineInput(box)); ++ input.place(box); ++ if (!this.children().contains(input.widget())) this.addWidget(input.widget()); ++ this.inputs.put(box.node(), input); ++ retainedInputs.add(control.key); ++ } else { ++ final Action action = this.actionWidgets.computeIfAbsent(control.key, key -> new Action(box)); ++ action.place(box); ++ if (!this.children().contains(action)) this.addWidget(action); ++ this.actions.put(box.node(), action); ++ retainedActions.add(control.key); ++ } ++ } ++ this.inputWidgets.entrySet().removeIf(entry -> { ++ if (retainedInputs.contains(entry.getKey())) return false; ++ this.removeWidget(entry.getValue().widget()); ++ return true; ++ }); ++ this.actionWidgets.entrySet().removeIf(entry -> { ++ if (retainedActions.contains(entry.getKey())) return false; ++ this.removeWidget(entry.getValue()); ++ return true; ++ }); ++ this.synchronize(this.draws(-1, -1)); ++ this.controller.validateResponseControls(this.actions.values().stream() ++ .filter(action -> action.visible && action.eligible).map(action -> action.node).toList()); + } + -+ /** The supplied rectangle is in absolute native layout units, before the Java pose scale. */ -+ public static void enable(final GuiGraphicsExtractor graphics, final NativeUiLayout.Rect bounds) { -+ final var window = Minecraft.getInstance().getWindow(); -+ final int javaScale = window.getGuiScale(); -+ final var viewport = graphics instanceof NativeUiGraphics nativeGraphics -+ ? nativeGraphics.viewport() : NativeUiViewport.current(); -+ NativeUiScissor clip = scaled(bounds, viewport.scale()); -+ final var stack = graphics.scissorStack; -+ final var parent = stack.peek(); -+ if (parent != null) { -+ final var nativeParent = from(parent); -+ clip = clip.intersect(nativeParent != null ? nativeParent : scaled(new NativeUiLayout.Rect( -+ parent.left(), parent.top(), parent.width(), parent.height()), javaScale)); ++ private static void controls(final NativeUiLayout.Box box, final String parent, final int occurrence, ++ final List result) { ++ // Collection templates repeat authored names. Retain each instance separately across scene refreshes. ++ final String name = box.node().name(); ++ final String path = parent + "/" + name.length() + ":" + name + ":" + occurrence; ++ result.add(new ControlBox(box, path + ":" + box.node().type() + ":" + box.node().action() ++ + ":" + box.node().properties().get("input") + ":" + box.node().flag("enabled_newline", false))); ++ final Map occurrences = new HashMap<>(); ++ for (final var child : box.children()) { ++ final int childOccurrence = occurrences.merge(child.node().name(), 1, Integer::sum) - 1; ++ controls(child, path, childOccurrence, result); + } -+ clip = clip.intersect(new NativeUiScissor(new NativeUiLayout.Rect(0, 0, window.getWidth(), window.getHeight()))); -+ final var rectangle = clip.guiBounds(javaScale); -+ ((IBedrockGuiScissor) (Object) rectangle).stackanvil$scissor(clip); -+ // push() intersects integer GUI bounds. That can discard pixels before the native clip -+ // reaches the render pass, and its shared empty rectangle cannot carry per-draw state. -+ stack.stack.addLast(rectangle); + } -+} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiScreen.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiScreen.java -new file mode 100644 -index 0000000..bb9713e ---- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiScreen.java -@@ -0,0 +1,13 @@ -+/* -+ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock -+ * Copyright (C) 2026 ViaVersion and contributors -+ * SPDX-License-Identifier: GPL-3.0-or-later -+ */ -+package com.viaversion.viafabricplus.bedrock.screen.ui; + -+/** Screens whose layout and input use the target Bedrock GuiData viewport. */ -+public interface NativeUiScreen { -+ default boolean closeOnPlayerHurt() { return false; } ++ private record ControlBox(NativeUiLayout.Box box, String key) {} + -+ default NativeUiViewport nativeViewport() { return NativeUiViewport.current(); } -+} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiScroll.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiScroll.java -new file mode 100644 -index 0000000..ed28afb ---- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiScroll.java -@@ -0,0 +1,69 @@ -+/* -+ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock -+ * Copyright (C) 2026 ViaVersion and contributors -+ * SPDX-License-Identifier: GPL-3.0-or-later -+ */ -+package com.viaversion.viafabricplus.bedrock.screen.ui; ++ private long layoutRevision = -1; + -+/** Pointer geometry for authored scrollbars. Native layout math remains a separate parity boundary. */ -+public final class NativeUiScroll { -+ private NativeUiScroll() {} ++ private void layout() { ++ NativeUiPresentation.updateSliderStates(this.scene, this::sliderHovered, this::sliderSelected); ++ this.controller.validateVisibleControls(); ++ this.renderer().prepare(this.scene); ++ this.root = new NativeUiLayout(this.renderer(), this.animations).layout(this.scene, this.width, this.height, this.scroll); ++ this.root = NativeUiSlider.apply(this.root); ++ this.geometry.clear(); ++ this.indexGeometry(this.root); ++ this.positionActions(this.root); ++ this.layoutRevision = this.animations.layoutRevision(); ++ // Reflow can reveal an editable control before the next render pass. ++ this.synchronize(this.currentDraws((int) this.pointerX, (int) this.pointerY, this.animationTime())); ++ } + -+ public static boolean horizontal(final NativeUiLayout.Box view) { -+ final var thumb = descendant(view, view.node().string("scrollbar_box", "")); -+ return thumb != null ? thumb.node().string("draggable", "vertical").equals("horizontal") -+ : view.node().string("scroll_direction", "vertical").equals("horizontal"); ++ private void indexGeometry(final NativeUiLayout.Box box) { ++ this.geometry.put(box.node(), box); ++ for (final var child : box.children()) this.indexGeometry(child); + } + -+ public static float maximum(final NativeUiLayout.Box view) { -+ return Math.max(0, horizontal(view) ? view.content().width() - view.scrollViewport().width() -+ : view.content().height() - view.scrollViewport().height()); ++ private boolean sliderHovered(final NativeUiScene.Node node) { ++ final var box = this.geometry.get(node); ++ return box != null && box.visibleBounds().contains(this.pointerX, this.pointerY); + } + -+ public static Model model(final NativeUiLayout.Box view, final float offset) { -+ final var track = descendant(view, view.node().string("scrollbar_track", "")); -+ final var thumb = descendant(view, view.node().string("scrollbar_box", "")); -+ if (track == null || thumb == null) return null; -+ final boolean horizontal = horizontal(view); -+ final float maximum = maximum(view); -+ final float content = horizontal ? view.content().width() : view.content().height(); -+ final float viewport = horizontal ? view.scrollViewport().width() : view.scrollViewport().height(); -+ final float length = horizontal ? track.bounds().width() : track.bounds().height(); -+ final int axis = horizontal ? 0 : 1; -+ final var minimum = thumb.node().properties().getAsJsonArray("min_size"); -+ final float minimumLength = minimum != null && minimum.size() == 2 -+ ? NativeUiLayout.dimension(minimum.get(axis), length, 0, 0) : 0; -+ final float extent = Math.clamp(content > 0 ? length * Math.min(1, viewport / content) : length, -+ Math.clamp(minimumLength, 0, length), length); -+ final float position = maximum > 0 ? (length - extent) * Math.clamp(offset, 0, maximum) / maximum : 0; -+ final var bounds = horizontal -+ ? new NativeUiLayout.Rect(track.bounds().x() + position, thumb.bounds().y(), extent, thumb.bounds().height()) -+ : new NativeUiLayout.Rect(thumb.bounds().x(), track.bounds().y() + position, thumb.bounds().width(), extent); -+ return new Model(thumb, track.bounds(), bounds, track.clip().intersect(thumb.clip()), maximum, -+ Math.clamp(offset, 0, maximum), horizontal); ++ private boolean sliderSelected(final NativeUiScene.Node node) { ++ return this.inputDrag != null && node.properties().has("input") ++ && inputIndex(node) == this.inputDrag.index; + } + -+ public static NativeUiLayout.Box descendant(final NativeUiLayout.Box box, final String name) { -+ if (name.isEmpty()) return null; -+ for (final var child : box.children()) { -+ if (child.node().name().equals(name)) return child; -+ final var found = descendant(child, name); -+ if (found != null) return found; -+ } -+ return null; ++ private NativeUiRenderer renderer() { ++ if (this.renderer == null) this.renderer = new NativeUiRenderer(this.font, this.images); ++ return this.renderer; + } + -+ public record Model(NativeUiLayout.Box originalThumb, NativeUiLayout.Rect track, NativeUiLayout.Rect thumb, -+ NativeUiLayout.Rect clip, float maximum, float offset, boolean horizontal) { -+ public boolean containsTrack(final double x, final double y) { return this.track.intersect(this.clip).contains(x, y); } -+ public boolean containsThumb(final double x, final double y) { return this.thumb.intersect(this.clip).contains(x, y); } -+ public float pointer(final double x, final double y) { return (float) (this.horizontal ? x : y); } -+ public float start() { return this.horizontal ? this.thumb.x() : this.thumb.y(); } -+ public float extent() { return this.horizontal ? this.thumb.width() : this.thumb.height(); } -+ public float offsetAt(final float pointer, final float grab) { -+ final float travel = this.horizontal ? this.track.width() - this.thumb.width() : this.track.height() - this.thumb.height(); -+ final float start = this.horizontal ? this.track.x() : this.track.y(); -+ return travel > 0 ? Math.clamp((pointer - start - grab) / travel, 0, 1) * this.maximum : 0; -+ } ++ private void positionActions(final NativeUiLayout.Box box) { ++ final Action action = this.actions.get(box.node()); ++ if (action != null) action.place(box); ++ final NativeInput input = this.inputs.get(box.node()); ++ if (input != null) input.place(box); ++ for (final var child : box.children()) this.positionActions(child); + } -+} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiSlider.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiSlider.java -new file mode 100644 -index 0000000..9726a32 ---- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiSlider.java -@@ -0,0 +1,123 @@ -+/* -+ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock -+ * Copyright (C) 2026 ViaVersion and contributors -+ * SPDX-License-Identifier: GPL-3.0-or-later -+ */ -+package com.viaversion.viafabricplus.bedrock.screen.ui; -+ -+/** Applies the native slider component's completed-layout thumb and image updates. */ -+public final class NativeUiSlider { -+ private NativeUiSlider() {} + -+ public static NativeUiLayout.Box apply(final NativeUiLayout.Box box) { -+ final var children = box.children().stream().map(NativeUiSlider::apply).toList(); -+ final var result = withChildren(box, children); -+ if (!box.node().type().equals("slider")) return result; -+ final float value = fraction(box.node()); -+ return withChildren(result, result.children().stream() -+ .map(child -> update(child, result, result, value)).toList()); ++ @Override ++ public void extractRenderState(final GuiGraphicsExtractor graphics, final int mouseX, final int mouseY, final float delta) { ++ try { ++ this.refreshPendingScene(); ++ this.pointerX = mouseX; ++ this.pointerY = mouseY; ++ if (NativeUiPresentation.updateSliderStates(this.scene, this::sliderHovered, this::sliderSelected)) ++ this.layout(); ++ final List draws = this.draws(mouseX, mouseY); ++ this.synchronize(draws); ++ NativeUiScene.Node tooltip = null; ++ for (final NativeUiPresentation.Draw draw : draws) { ++ final var box = draw.box(); ++ 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(); ++ if (draw.alpha() <= 0 || box.visibleBounds().width() <= 0 || box.visibleBounds().height() <= 0) continue; ++ NativeUiScissor.enable(graphics, clip); ++ try { ++ final var node = box.node(); ++ this.renderer().draw(graphics, draw, this.animations); ++ if (node.type().equals("custom") && (node.string("renderer", "").equals(NativeUiActorPortraits.RENDERER) ++ || node.string("renderer", "").equals(NativeUiActorPortraits.ACTOR_RENDERER) ++ || node.string("renderer", "").equals(NativeUiActorPortraits.PLAYER_RENDERER))) ++ this.renderer().portrait(graphics, box, mouseX, mouseY, delta, draw.alpha()); ++ final Action action = this.actions.get(node); ++ if (action != null) action.extractRenderState(graphics, mouseX, mouseY, delta); ++ final NativeInput input = this.inputs.get(node); ++ if (input != null) input.widget().extractRenderState(graphics, mouseX, mouseY, delta); ++ } finally { ++ graphics.disableScissor(); ++ } ++ } ++ if (tooltip != null) { ++ final Component text = tooltip.properties().has("hover_text_component") ++ ? ComponentSerialization.CODEC.parse(JsonOps.INSTANCE, tooltip.properties().get("hover_text_component")).getOrThrow() ++ : NativeUiText.component(tooltip.string("hover_text", "")); ++ final int maximum = Math.round(tooltip.number("hover_text_max_width", 0)); ++ final int wrapWidth = maximum > 0 ? Math.max(1, Math.min(maximum, this.width - 16)) : Integer.MAX_VALUE; ++ graphics.setTooltipForNextFrame(this.font, NativeUiText.tooltipLines(this.font, text, wrapWidth), mouseX, mouseY); ++ } ++ if (this.images.failed()) graphics.text(this.font, ++ Component.literal("Some menu images could not load."), 8, this.height - this.font.lineHeight - 8, ++ 0xFFFFCCCC, true); ++ } catch (final RuntimeException error) { ++ this.controller.failed(error); ++ } + } + -+ /** Continuous values are normalized; stepped values are native option ordinals. */ -+ public static float fraction(final NativeUiScene.Node node) { -+ final int count = steps(node); -+ final float value = node.number("slider_value", 0); -+ return count >= 2 ? value / (count - 1) : value; ++ private List draws(final int mouseX, final int mouseY) { ++ final long now = this.animationTime(); ++ this.lifecycle.advance(now); ++ if (this.animations.layoutRevision() != this.layoutRevision) this.layout(); ++ return this.currentDraws(mouseX, mouseY, now); + } + -+ public static float pointerFraction(final NativeUiLayout.Box owner, final double x, final double y) { -+ if (!Double.isFinite(x) || !Double.isFinite(y)) throw new IllegalArgumentException("Non-finite slider pointer"); -+ final boolean vertical = vertical(owner.node()); -+ final var bounds = owner.bounds(); -+ final float start = vertical ? bounds.y() : bounds.x(); -+ final float extent = vertical ? bounds.height() : bounds.width(); -+ final float coordinate = (float) (vertical ? y : x); -+ float value = extent > 0 ? (Math.clamp(coordinate, start, start + extent) - start) / extent : 0; -+ if (owner.node().flag("slider_inverted", false)) value = 1 - value; -+ final int count = steps(owner.node()); -+ return count >= 2 ? (float) stepIndex(value, count) / (count - 1) : value; ++ private List currentDraws(final int mouseX, final int mouseY, final long now) { ++ return NativeUiPresentation.draws(this.root, box -> { ++ final Action action = this.actions.get(box.node()); ++ return box.node().type().equals("slider") || box.node().type().equals("slider_box") ++ ? box.visibleBounds().contains(mouseX, mouseY) ++ : action != null && (action.isFocused() || box.visibleBounds().contains(mouseX, mouseY)); ++ }, node -> this.animations.opacityAt(node, now)); + } + -+ public static int stepIndex(final float fraction, final int count) { -+ if (!Float.isFinite(fraction) || count < 1) throw new IllegalArgumentException("Invalid stepped slider input"); -+ if (count == 1) return 0; -+ // The native multiply and half-step addition both round as float before floor. -+ return (int) Math.floor(Math.clamp(fraction, 0, 1) * (count - 1) + .5F); ++ private void synchronize(final List draws) { ++ this.eligibleInputs.clear(); ++ for (final Action action : this.actions.values()) action.visible = false; ++ for (final NativeInput input : this.inputs.values()) input.widget().visible = false; ++ for (final NativeUiPresentation.Draw draw : draws) { ++ final Action action = this.actions.get(draw.box().node()); ++ if (action != null) { ++ action.visible = true; ++ action.eligible = draw.enabled() && draw.box().bounds().width() > 0 && draw.box().bounds().height() > 0; ++ action.active = action.eligible && draw.alpha() > 0; ++ action.setAlpha(draw.alpha()); ++ } ++ final NativeInput input = this.inputs.get(draw.box().node()); ++ if (input != null) { ++ if (draw.enabled() && draw.box().bounds().width() > 0 && draw.box().bounds().height() > 0) ++ this.eligibleInputs.add(draw.box().node()); ++ input.widget().visible = draw.box().visibleBounds().width() > 0 && draw.box().visibleBounds().height() > 0; ++ input.widget().active = draw.enabled() && draw.alpha() > 0; ++ input.setEditable(input.widget().active); ++ input.widget().setAlpha(draw.alpha()); ++ } ++ } + } + -+ private static int steps(final NativeUiScene.Node node) { -+ return (int) node.number("slider_steps", 0); ++ private Map interactive(final double x, final double y) { ++ final Map result = new IdentityHashMap<>(); ++ for (final var draw : this.draws((int) x, (int) y)) if (draw.enabled() && draw.alpha() > 0) result.put(draw.box().node(), true); ++ return result; + } + -+ private static boolean vertical(final NativeUiScene.Node node) { -+ return node.string("slider_direction", "horizontal").equals("vertical"); ++ @Override ++ public boolean mouseScrolled(final double x, final double y, final double horizontal, final double vertical) { ++ for (final NativeInput input : this.inputs.values()) { ++ if (input instanceof MultilineInput && input.widget().mouseScrolled(x, y, horizontal, vertical)) return true; ++ } ++ final var target = scrolling(this.root, x, y, this.interactive(x, y)); ++ if (target == null) return super.mouseScrolled(x, y, horizontal, vertical); ++ final double delta = NativeUiScroll.horizontal(target) && horizontal != 0 ? horizontal : vertical; ++ this.scroll.put(target.node(), Math.clamp(this.scroll.getOrDefault(target.node(), 0F) ++ - (float) delta * target.node().number("scroll_speed", 18), 0, NativeUiScroll.maximum(target))); ++ this.scrollAnimation(target.node(), true); ++ this.layout(); ++ return true; + } + -+ private static NativeUiLayout.Box update(final NativeUiLayout.Box box, final NativeUiLayout.Box parent, -+ final NativeUiLayout.Box owner, final float fraction) { -+ if (box.node().type().equals("slider")) return box; -+ var result = box; -+ if (named(box, owner, "slider_box_control")) { -+ final float[] from = anchor(box.node().string("anchor_from", "center")); -+ final float[] to = anchor(box.node().string("anchor_to", "center")); -+ final boolean vertical = vertical(owner.node()); -+ final float value = owner.node().flag("slider_inverted", false) ? 1 - fraction : fraction; -+ final var bounds = box.bounds(); -+ final var container = parent.bounds(); -+ // Native replaces both offset axes; the selected axis retains a parent-relative term. -+ final float x = container.x() + container.width() * from[0] - bounds.width() * to[0] -+ + (vertical ? 0 : value * container.width() - owner.bounds().width() * .5F); -+ final float y = container.y() + container.height() * from[1] - bounds.height() * to[1] -+ + (vertical ? value * container.height() - owner.bounds().height() * .5F : 0); -+ result = translate(box, x - bounds.x(), y - bounds.y(), parent.clip()); ++ @Override ++ public boolean mouseClicked(final MouseButtonEvent event, final boolean doubleClick) { ++ if (this.controller.inputs() != null && event.button() == InputConstants.MOUSE_BUTTON_LEFT) { ++ for (final var draw : this.draws((int) event.x(), (int) event.y()).reversed()) { ++ final var box = draw.box(); ++ if (!draw.enabled() || draw.alpha() <= 0 || !box.visibleBounds().contains(event.x(), event.y()) ++ || !box.node().properties().has("input")) continue; ++ final int index = inputIndex(box.node()); ++ if (!this.controller.inputs().isSlider(index)) continue; ++ final var owner = sliderOwner(this.root, box.node(), null); ++ if (owner == null) continue; ++ this.inputDrag = new InputDrag(index, owner); ++ this.slide(this.inputDrag, event.x(), event.y()); ++ this.setDragging(true); ++ return true; ++ } + } -+ if (box.node().type().equals("image") -+ && (named(box, owner, "progress_control") || named(box, owner, "progress_hover_control"))) { -+ // Runtime image state belongs to the same node used by animation and input caches. -+ result.node().properties().addProperty("clip_ratio", 1 - fraction); ++ if (event.button() == InputConstants.MOUSE_BUTTON_LEFT) { ++ final var target = scrollbar(this.root, event.x(), event.y(), this.interactive(event.x(), event.y())); ++ if (target != null) { ++ final var model = NativeUiScroll.model(target, this.scroll.getOrDefault(target.node(), 0F)); ++ final float pointer = model.pointer(event.x(), event.y()); ++ final float grab = model.containsThumb(event.x(), event.y()) ? pointer - model.start() : model.extent() / 2; ++ this.scrollAnimation(target.node(), false); ++ this.drag = new Drag(target.node(), grab); ++ this.setDragging(true); ++ this.scroll.put(target.node(), model.offsetAt(pointer, grab)); ++ this.layout(); ++ return true; ++ } + } -+ final var updated = result; -+ return withChildren(updated, updated.children().stream() -+ .map(child -> update(child, updated, owner, fraction)).toList()); -+ } -+ -+ private static boolean named(final NativeUiLayout.Box box, final NativeUiLayout.Box owner, -+ final String property) { -+ final String name = owner.node().string(property, ""); -+ return !name.isEmpty() && box.node().name().equals(name); ++ return super.mouseClicked(event, doubleClick); + } + -+ private static NativeUiLayout.Box translate(final NativeUiLayout.Box box, final float x, final float y, -+ final NativeUiLayout.Rect parentClip) { -+ final var bounds = shifted(box.bounds(), x, y); -+ final var clip = box.node().scroll() || box.node().flag("clips_children", false) -+ ? parentClip.intersect(bounds) : parentClip; -+ return new NativeUiLayout.Box(box.node(), bounds, clip, box.content(), shifted(box.scrollViewport(), x, y), -+ box.layer(), box.children().stream().map(child -> translate(child, x, y, clip)).toList()); ++ @Override ++ public boolean mouseDragged(final MouseButtonEvent event, final double deltaX, final double deltaY) { ++ if (this.inputDrag != null && event.button() == InputConstants.MOUSE_BUTTON_LEFT) { ++ final InputDrag current = this.inputDrag; ++ this.slide(current, event.x(), event.y()); ++ this.inputDrag = current; ++ this.setDragging(true); ++ return true; ++ } ++ if (this.drag != null && event.button() == InputConstants.MOUSE_BUTTON_LEFT) { ++ final var target = find(this.root, this.drag.node); ++ final var model = target == null ? null : NativeUiScroll.model(target, this.scroll.getOrDefault(this.drag.node, 0F)); ++ if (model != null) { ++ this.scrollAnimation(this.drag.node, false); ++ this.scroll.put(this.drag.node, model.offsetAt(model.pointer(event.x(), event.y()), ++ Math.min(this.drag.grab, model.extent()))); ++ this.layout(); ++ } ++ return true; ++ } ++ return super.mouseDragged(event, deltaX, deltaY); + } + -+ private static NativeUiLayout.Rect shifted(final NativeUiLayout.Rect bounds, final float x, final float y) { -+ return new NativeUiLayout.Rect(bounds.x() + x, bounds.y() + y, bounds.width(), bounds.height()); -+ } -+ -+ private static NativeUiLayout.Box withChildren(final NativeUiLayout.Box box, -+ final java.util.List children) { -+ return new NativeUiLayout.Box(box.node(), box.bounds(), box.clip(), box.content(), box.scrollViewport(), -+ box.layer(), children); ++ @Override ++ public boolean mouseReleased(final MouseButtonEvent event) { ++ if (this.inputDrag != null && event.button() == InputConstants.MOUSE_BUTTON_LEFT) { ++ this.inputDrag = null; ++ this.setDragging(false); ++ return true; ++ } ++ if (this.drag != null && event.button() == InputConstants.MOUSE_BUTTON_LEFT) { ++ this.animations.event(this.drag.node, "scrollbar.released", this.animationTime()); ++ this.drag = null; ++ this.setDragging(false); ++ return true; ++ } ++ return super.mouseReleased(event); + } + -+ private static float[] anchor(final String name) { -+ return switch (name) { -+ case "top_left" -> new float[] {0, 0}; -+ case "top_middle" -> new float[] {.5F, 0}; -+ case "top_right" -> new float[] {1, 0}; -+ case "left_middle" -> new float[] {0, .5F}; -+ case "center" -> new float[] {.5F, .5F}; -+ case "right_middle" -> new float[] {1, .5F}; -+ case "bottom_left" -> new float[] {0, 1}; -+ case "bottom_middle" -> new float[] {.5F, 1}; -+ case "bottom_right" -> new float[] {1, 1}; -+ default -> throw new IllegalArgumentException("Unsupported native UI anchor"); -+ }; ++ private static NativeUiLayout.Box find(final NativeUiLayout.Box box, final NativeUiScene.Node node) { ++ if (box.node() == node) return box; ++ for (final var child : box.children()) { ++ final var found = find(child, node); ++ if (found != null) return found; ++ } ++ return null; + } -+} -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..c21e081 ---- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiText.java -@@ -0,0 +1,114 @@ -+/* -+ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock -+ * Copyright (C) 2026 ViaVersion and contributors -+ * SPDX-License-Identifier: GPL-3.0-or-later -+ */ -+package com.viaversion.viafabricplus.bedrock.screen.ui; -+ -+import com.google.gson.JsonParser; -+import com.mojang.serialization.JsonOps; -+import java.util.ArrayList; -+import java.util.List; -+import java.util.Optional; -+import net.minecraft.client.gui.Font; -+import net.minecraft.network.chat.Component; -+import net.minecraft.network.chat.MutableComponent; -+import net.minecraft.network.chat.Style; -+import net.minecraft.util.FormattedCharSequence; -+import net.minecraft.network.chat.ComponentSerialization; -+import net.raphimc.viabedrock.api.util.TextUtil; -+ -+public final class NativeUiText { -+ private NativeUiText() {} + -+ public static List tooltipLines(final Font font, final Component text, final int width) { -+ final List result = new ArrayList<>(); -+ for (final var paragraph : paragraphs(text)) { -+ final var wrapped = font.split(paragraph, width); -+ if (wrapped.isEmpty()) result.add(FormattedCharSequence.EMPTY); -+ else result.addAll(wrapped); ++ private NativeUiLayout.Box scrollbar(final NativeUiLayout.Box box, final double x, final double y, ++ final Map interactive) { ++ if (!interactive.containsKey(box.node()) || !box.clip().contains(x, y)) return null; ++ for (int i = box.children().size() - 1; i >= 0; i--) { ++ final var target = this.scrollbar(box.children().get(i), x, y, interactive); ++ if (target != null) return target; + } -+ return List.copyOf(result); ++ if (box.node().scroll()) { ++ final var model = NativeUiScroll.model(box, this.scroll.getOrDefault(box.node(), 0F)); ++ if (model != null && model.maximum() > 0 && interactive.containsKey(model.originalThumb().node()) ++ && model.containsTrack(x, y)) return box; ++ } ++ return null; + } + -+ 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])); -+ } ++ private static NativeUiLayout.Box scrolling(final NativeUiLayout.Box box, final double x, final double y, ++ final Map interactive) { ++ if (!interactive.containsKey(box.node()) || !box.clip().contains(x, y)) return null; ++ for (int i = box.children().size() - 1; i >= 0; i--) { ++ final var target = scrolling(box.children().get(i), x, y, interactive); ++ if (target != null) return target; + } -+ return List.copyOf(result); ++ return box.node().scroll() && box.bounds().contains(x, y) && NativeUiScroll.maximum(box) > 0 ? box : null; + } + -+ 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); -+ } ++ @Override ++ public ComponentPath nextFocusPath(final FocusNavigationEvent event) { ++ if (!(event instanceof FocusNavigationEvent.ArrowNavigation arrow)) return super.nextFocusPath(event); ++ final NativeUiFocus.Direction direction = switch (arrow.direction()) { ++ case UP -> NativeUiFocus.Direction.UP; ++ case DOWN -> NativeUiFocus.Direction.DOWN; ++ case LEFT -> NativeUiFocus.Direction.LEFT; ++ case RIGHT -> NativeUiFocus.Direction.RIGHT; ++ }; ++ final NativeUiScene.Node selected = this.getFocused() instanceof Action action ? action.node ++ : this.getFocused() instanceof NativeInput input ? input.node() : null; ++ final var targets = this.focusTargets(); ++ final var target = NativeUiFocus.next(targets, selected, direction, this.width, this.height, this.focusHistory); ++ if (target == null) return null; ++ final var node = target.box().node(); ++ final GuiEventListener widget = this.actions.containsKey(node) ? this.actions.get(node) : this.inputs.get(node).widget(); ++ return ComponentPath.path(this, ComponentPath.leaf(widget)); + } + -+ static List labelParagraphs(final Component text) { -+ return paragraphs(text, false); ++ private List focusTargets() { ++ return NativeUiFocus.targets(this.root, node -> { ++ final Action action = this.actions.get(node); ++ if (action != null) return action.visible && action.eligible; ++ final NativeInput input = this.inputs.get(node); ++ return input != null && this.eligibleInputs.contains(node); ++ }); + } + -+ static List paragraphs(final Component text) { -+ return paragraphs(text, true); ++ @Override ++ public void setFocused(final GuiEventListener listener) { ++ super.setFocused(listener); ++ if (this.root == null) return; ++ final NativeUiScene.Node selected = listener instanceof Action action ? action.node ++ : listener instanceof NativeInput input ? input.node() : null; ++ this.focusHistory.select(this.focusTargets().stream().filter(target -> target.box().node() == selected).findFirst().orElse(null)); + } + -+ private static List paragraphs(final Component text, final boolean normalizeCarriageReturns) { -+ final List lines = new ArrayList<>(); -+ final MutableComponent[] current = {Component.empty()}; -+ final boolean[] carriageReturn = {false}; -+ text.visit((style, value) -> { -+ final StringBuilder run = new StringBuilder(); -+ for (int i = 0; i < value.length(); i++) { -+ final char character = value.charAt(i); -+ 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] = 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]); -+ current[0] = Component.empty(); -+ } else run.append(character); ++ @Override ++ public boolean keyPressed(final KeyEvent event) { ++ if (this.controller.inputs() != null && this.getFocused() instanceof Action action && action.node.properties().has("input") ++ && (event.key() == InputConstants.KEY_LEFT || event.key() == InputConstants.KEY_RIGHT)) { ++ this.controller.inputs().adjust(inputIndex(action.node), event.key() == InputConstants.KEY_LEFT ? -1 : 1); ++ this.refreshScene(); ++ return true; ++ } ++ if (!(this.getFocused() instanceof NativeInput) && (event.key() == InputConstants.KEY_PAGEUP || event.key() == InputConstants.KEY_PAGEDOWN ++ || event.key() == InputConstants.KEY_HOME || event.key() == InputConstants.KEY_END)) { ++ final var focused = this.getFocused() instanceof Action action ? action.node ++ : this.getFocused() instanceof NativeInput input ? input.node() : null; ++ NativeUiLayout.Box target = this.keyboardScroll(this.root, focused); ++ if (target == null) target = this.keyboardScroll(this.root, null); ++ if (target != null) { ++ final float page = NativeUiScroll.horizontal(target) ? target.scrollViewport().width() : target.scrollViewport().height(); ++ final float maximum = NativeUiScroll.maximum(target); ++ final float previous = this.scroll.getOrDefault(target.node(), 0F); ++ final float value = switch (event.key()) { ++ case InputConstants.KEY_HOME -> 0; ++ case InputConstants.KEY_END -> maximum; ++ case InputConstants.KEY_PAGEUP -> previous - page; ++ default -> previous + page; ++ }; ++ this.scrollAnimation(target.node(), true); ++ this.scroll.put(target.node(), Math.clamp(value, 0, maximum)); ++ this.layout(); ++ return true; + } -+ current[0].append(Component.literal(run.toString()).withStyle(style)); -+ return Optional.empty(); -+ }, Style.EMPTY); -+ lines.add(current[0]); -+ return List.copyOf(lines); ++ } ++ final var direction = switch (event.key()) { ++ case InputConstants.KEY_UP -> ScreenDirection.UP; ++ case InputConstants.KEY_DOWN -> ScreenDirection.DOWN; ++ case InputConstants.KEY_LEFT -> ScreenDirection.LEFT; ++ case InputConstants.KEY_RIGHT -> ScreenDirection.RIGHT; ++ default -> null; ++ }; ++ if (direction != null) { ++ // Screen invokes its superclass focus path directly. Route arrows here after editable controls get their input. ++ if (this.getFocused() != null && this.getFocused().keyPressed(event)) { ++ this.refreshFocusedControl(); ++ return true; ++ } ++ final var path = this.nextFocusPath(new FocusNavigationEvent.ArrowNavigation(direction)); ++ if (path == null) return false; ++ if (path.leafComponent() != this.getFocused()) { ++ this.changeFocus(path); ++ this.refreshFocusedControl(); ++ } ++ return true; ++ } ++ final boolean result = super.keyPressed(event); ++ this.refreshFocusedControl(); ++ return result; + } + -+ public static Component component(final String text) { -+ return ComponentSerialization.CODEC.parse(JsonOps.INSTANCE, JsonParser.parseString(TextUtil.stringToJson(text))).getOrThrow(); ++ private void refreshFocusedControl() { ++ this.refreshPendingScene(); ++ if (this.getFocused() instanceof Action action) this.reveal(this.root, action.node); ++ else if (this.getFocused() instanceof NativeInput input) this.reveal(this.root, input.node()); + } -+} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiToggle.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiToggle.java -new file mode 100644 -index 0000000..e63749c ---- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiToggle.java -@@ -0,0 +1,94 @@ -+/* -+ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock -+ * Copyright (C) 2026 ViaVersion and contributors -+ * SPDX-License-Identifier: GPL-3.0-or-later -+ */ -+package com.viaversion.viafabricplus.bedrock.screen.ui; -+ -+import com.google.gson.JsonObject; -+import java.math.BigInteger; + -+/** Native toggle initialization and component events; the screen owns collection indices and radio traversal. */ -+public record NativeUiToggle(boolean radio, boolean defaultChecked, boolean directional, boolean toggleOnHover, -+ int group, int onButton, int offButton, int forcedIndex, int defaultSelected) { -+ public static NativeUiToggle parse(final JsonObject properties) { -+ return new NativeUiToggle(NativeUiScene.flag(properties, "radio_toggle_group", false), -+ NativeUiScene.flag(properties, "toggle_default_state", false), -+ NativeUiScene.flag(properties, "enable_directional_toggling", false), -+ NativeUiScene.flag(properties, "toggle_on_hover", false), -+ NativeUiInput.symbol(string(properties, "toggle_name")), NativeUiInput.symbol(string(properties, "toggle_on_button")), -+ NativeUiInput.symbol(string(properties, "toggle_off_button")), integer(properties, "toggle_group_forced_index", -1), -+ integer(properties, "toggle_group_default_selected", 0)); ++ @Override ++ public boolean charTyped(final CharacterEvent event) { ++ final boolean result = super.charTyped(event); ++ this.refreshFocusedControl(); ++ return result; + } + -+ private static String string(final JsonObject properties, final String key) { -+ final var value = properties.get(key); -+ return value != null && value.isJsonPrimitive() && value.getAsJsonPrimitive().isString() ? value.getAsString() : ""; ++ private NativeUiLayout.Box keyboardScroll(final NativeUiLayout.Box box, final NativeUiScene.Node focused) { ++ for (int i = box.children().size() - 1; i >= 0; i--) { ++ final var nested = this.keyboardScroll(box.children().get(i), focused); ++ if (nested != null) return nested; ++ } ++ if (!box.node().scroll() || NativeUiScroll.maximum(box) == 0) return null; ++ if (focused != null) return find(box, focused) != null ? box : null; ++ return this.actions.values().stream().anyMatch(action -> action.visible && action.active && find(box, action.node) != null) ? box : null; + } + -+ private static int integer(final JsonObject properties, final String key, final int fallback) { -+ final var value = properties.get(key); -+ if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber()) return fallback; -+ final String encoded = value.getAsString(); -+ if (encoded.indexOf('.') >= 0 || encoded.indexOf('e') >= 0 || encoded.indexOf('E') >= 0) return fallback; -+ try { -+ final var integer = new BigInteger(encoded); -+ if (integer.bitLength() > 63) return fallback; -+ return integer.intValue(); -+ } catch (final NumberFormatException invalid) { -+ return fallback; ++ private boolean reveal(final NativeUiLayout.Box box, final NativeUiScene.Node focused) { ++ if (box.node() == focused) return true; ++ for (final var child : box.children()) if (this.reveal(child, focused)) { ++ if (box.node().scroll()) { ++ final var control = find(box, focused); ++ if (control == null) return true; ++ final var bounds = control.bounds(); ++ final var viewport = box.scrollViewport().intersect(box.clip()); ++ final boolean horizontal = NativeUiScroll.horizontal(box); ++ final float start = horizontal ? bounds.x() : bounds.y(), extent = horizontal ? bounds.width() : bounds.height(); ++ final float viewportStart = horizontal ? viewport.x() : viewport.y(), viewportExtent = horizontal ? viewport.width() : viewport.height(); ++ final float delta = start < viewportStart ? start - viewportStart ++ : Math.max(0, start + extent - viewportStart - viewportExtent); ++ if (delta != 0) { ++ this.scrollAnimation(box.node(), true); ++ this.scroll.put(box.node(), Math.clamp(this.scroll.getOrDefault(box.node(), 0F) + delta, ++ 0, NativeUiScroll.maximum(box))); ++ this.layout(); ++ } ++ } ++ return true; + } ++ return false; + } + -+ public int index(final int collectionIndex) { return this.forcedIndex >= 0 ? this.forcedIndex : collectionIndex; } -+ -+ public boolean initialChecked(final int collectionIndex) { -+ return this.radio ? this.index(collectionIndex) == this.defaultSelected : this.defaultChecked; ++ @Override ++ public void onClose() { ++ if (!this.controller.current()) return; ++ switch (this.controller.dismiss()) { ++ case CLOSE -> this.minecraft.setScreenAndShow(null); ++ case STAY -> this.refreshScene(); ++ case REPLACED -> { /* The controller transferred focus to another native screen. */ } ++ } + } + -+ public static final class State { -+ private final NativeUiToggle definition; -+ private final int index; -+ private boolean checked; -+ private boolean hovered; ++ @Override ++ public boolean isPauseScreen() { ++ return false; ++ } + -+ public State(final NativeUiToggle definition, final int collectionIndex) { -+ this.definition = definition; -+ this.index = definition.index(collectionIndex); -+ this.checked = definition.initialChecked(collectionIndex); ++ @Override ++ public void tick() { ++ if (!this.controller.current()) this.minecraft.setScreenAndShow(null); ++ else if (this.imageRevision != this.images.revision()) { ++ this.imageRevision = this.images.revision(); ++ try { this.layout(); } ++ catch (final RuntimeException error) { ++ this.controller.failed(error); ++ } + } ++ } + -+ public boolean checked() { return this.checked; } -+ public void bindChecked(final boolean checked) { this.checked = checked; } -+ -+ public Change button(final NativeUiInput.ButtonEvent event, final boolean enabled) { -+ if (!enabled || !event.transition()) return null; -+ if (event.target() == this.definition.onButton) { -+ if (!this.definition.directional || this.checked) return null; -+ this.checked = true; -+ } else if (event.target() == this.definition.offButton) { -+ if (!this.definition.directional || !this.checked) return null; -+ this.checked = this.definition.radio; -+ } else this.checked = this.definition.radio || !this.checked; -+ return this.change(true); -+ } ++ @Override ++ public void removed() { ++ if (this.renderer != null) this.renderer.close(); ++ this.images.close(); ++ } + -+ public Change hover(final boolean hovered) { -+ if (this.hovered == hovered) return null; -+ this.hovered = hovered; -+ if (!hovered || !this.definition.toggleOnHover) return null; -+ this.checked = this.definition.radio || !this.checked; -+ return this.change(false); -+ } ++ private static int inputIndex(final NativeUiScene.Node node) { ++ return node.properties().getAsJsonObject("input").get("index").getAsBigDecimal().intValueExact(); ++ } + -+ private Change change(final boolean notify) { -+ return new Change(this.definition.group, this.index, this.checked, notify, this.definition.radio); ++ private void activate(final NativeUiScene.Node node) { ++ switch (this.controller.activate(node)) { ++ case CLOSE -> this.minecraft.setScreenAndShow(null); ++ case STAY -> this.refreshScene(); ++ case REPLACED -> { /* The controller transferred focus to another native screen. */ } + } ++ } + -+ public void sibling(final Change change) { -+ if (change.radio && change.group == this.definition.group && change.index != this.index) this.checked = false; ++ private static NativeUiLayout.Box sliderOwner(final NativeUiLayout.Box box, final NativeUiScene.Node target, ++ final NativeUiLayout.Box owner) { ++ final var current = box.node().type().equals("slider") ? box : owner; ++ if (box.node() == target) return current; ++ for (final var child : box.children()) { ++ final var found = sliderOwner(child, target, current); ++ if (found != null) return found; + } ++ return null; + } + -+ public record Change(int group, int index, boolean checked, boolean notifyController, boolean radio) {} -+} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiViewport.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiViewport.java -new file mode 100644 -index 0000000..b1f6a30 ---- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiViewport.java -@@ -0,0 +1,32 @@ -+/* -+ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock -+ * Copyright (C) 2026 ViaVersion and contributors -+ * SPDX-License-Identifier: GPL-3.0-or-later -+ */ -+package com.viaversion.viafabricplus.bedrock.screen.ui; ++ private void slide(final InputDrag drag, final double x, final double y) { ++ final boolean vertical = drag.owner.node().string("slider_direction", "horizontal").equals("vertical"); ++ final float extent = vertical ? drag.owner.bounds().height() : drag.owner.bounds().width(); ++ if (extent <= 0) return; ++ this.controller.inputs().slide(drag.index, NativeUiSlider.pointerFraction(drag.owner, x, y)); ++ this.refreshScene(); ++ this.inputDrag = drag; ++ } + -+import com.viaversion.viafabricplus.bedrock.ViaFabricPlusBedrock; -+import net.minecraft.client.Minecraft; ++ private void refreshPendingScene() { ++ if (this.refreshPending) this.refreshScene(); ++ } + -+/** Desktop GuiData units, independent of Java's HUD scale. */ -+public record NativeUiViewport(int width, int height, int scale, float renderScale) { -+ public static NativeUiViewport desktop(final int width, final int height, final int offset, final int javaScale) { -+ if (width < 1 || height < 1 || javaScale < 1) -+ throw new IllegalArgumentException("Invalid native UI viewport"); -+ // Target AppPlatform desktop type 0: minimum viewport 376 x 250, scale table 1..8. -+ final int maximum = Math.clamp(Math.min(width / 376, height / 250), 1, 8); -+ final int minimum = (maximum + 1) / 2; -+ final int scale = maximum + Math.clamp(offset, minimum - maximum, 0); -+ return new NativeUiViewport(Math.ceilDiv(width, scale), Math.ceilDiv(height, scale), scale, -+ (float) scale / javaScale); ++ private void refreshScene() { ++ this.refreshPending = false; ++ try { ++ final List previousControls = new ArrayList<>(); ++ controls(this.root, "", 0, previousControls); ++ final Map previousNodes = new HashMap<>(); ++ for (final var control : previousControls) previousNodes.put(control.key, control.box.node()); ++ final Map offsets = new HashMap<>(); ++ this.scrollOffsets(this.scene, "", offsets, false); ++ this.controller.refresh(); ++ this.scene = this.controller.root(); ++ this.resetAnimations(); ++ if (this.renderer != null) this.renderer.reset(); ++ this.scroll.clear(); ++ this.scrollOffsets(this.scene, "", offsets, true); ++ this.layout(); ++ this.reconcileWidgets(); ++ final List currentControls = new ArrayList<>(); ++ controls(this.root, "", 0, currentControls); ++ final Map replacements = new IdentityHashMap<>(); ++ for (final var control : currentControls) { ++ final var previous = previousNodes.get(control.key); ++ if (previous != null) replacements.put(previous, control.box.node()); ++ } ++ this.focusHistory.remap(replacements, this.focusTargets()); ++ } catch (final RuntimeException error) { ++ this.controller.failed(error); ++ } + } + -+ public static NativeUiViewport current() { -+ final var window = Minecraft.getInstance().getWindow(); -+ return desktop(window.getWidth(), window.getHeight(), -+ ViaFabricPlusBedrock.impl().settings().nativeUiScale().value().offset(), window.getGuiScale()); ++ private void scrollOffsets(final NativeUiScene.Node node, final String parent, final Map offsets, ++ final boolean restore) { ++ final String key = parent + "/" + node.name(); ++ if (node.scroll()) { ++ if (restore) this.scroll.put(node, offsets.getOrDefault(key, 0F)); ++ else offsets.put(key, this.scroll.getOrDefault(node, 0F)); ++ } ++ for (final var child : node.children()) this.scrollOffsets(child, key, offsets, restore); + } + -+ public double pointer(final double javaCoordinate) { return javaCoordinate / this.renderScale; } -+ public int pixel(final float nativeCoordinate) { return Math.round(nativeCoordinate * this.renderScale); } -+} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/settings/base/NativeUiScale.java b/src/main/java/com/viaversion/viafabricplus/bedrock/settings/base/NativeUiScale.java -new file mode 100644 -index 0000000..b3f8333 ---- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/settings/base/NativeUiScale.java -@@ -0,0 +1,27 @@ -+/* -+ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock -+ * Copyright (C) 2026 ViaVersion and contributors -+ * SPDX-License-Identifier: GPL-3.0-or-later -+ */ -+package com.viaversion.viafabricplus.bedrock.settings.base; ++ private record InputDrag(int index, NativeUiLayout.Box owner) {} + -+import com.viaversion.viafabricplus.api.settings.base.EnumSetting; -+import net.minecraft.network.chat.Component; ++ private interface NativeInput { ++ NativeUiScene.Node node(); ++ AbstractWidget widget(); ++ void place(NativeUiLayout.Box box); ++ void setEditable(boolean editable); ++ } + -+/** Desktop GuiData offsets; each viewport clamps the preference to its supported range. */ -+public enum NativeUiScale implements EnumSetting.EnumValue { -+ DEFAULT(0), SMALLER(-1), SMALLER_TWO(-2), SMALLER_THREE(-3), SMALLEST(-4); ++ private static NativeUiScene.Node inputTextStyle(final NativeUiLayout.Box owner) { ++ final String name = owner.node().string("text_control", ""); ++ final var control = name.isEmpty() ? null : NativeUiScroll.descendant(owner, name); ++ return control == null ? owner.node() : control.node(); ++ } + -+ private final int offset; ++ private final class SingleLineInput extends EditBox implements NativeInput { ++ private NativeUiScene.Node node; ++ private NativeUiLayout.Box box; + -+ NativeUiScale(final int offset) { this.offset = offset; } ++ SingleLineInput(final NativeUiLayout.Box box) { ++ super(NativeUiSceneScreen.this.font, 0, 0, 1, 1, NativeUiText.component(Action.caption(box.node()))); ++ this.node = box.node(); ++ final int index = inputIndex(this.node); ++ this.setMaxLength(NativeUiSceneScreen.this.controller.inputs().maxLength(index)); ++ this.setBordered(false); ++ this.place(box); ++ this.setValue(NativeUiSceneScreen.this.controller.inputs().text(index)); ++ this.setHint(NativeUiText.component(NativeUiSceneScreen.this.controller.inputs().placeholder(index))); ++ this.setResponder(value -> { ++ NativeUiSceneScreen.this.refreshPending |= NativeUiSceneScreen.this.controller.inputs().setText(index, value); ++ }); ++ } + -+ public int offset() { return this.offset; } ++ @Override ++ public void place(final NativeUiLayout.Box box) { ++ this.node = box.node(); ++ this.box = box; ++ final var bounds = box.bounds(); ++ this.setRectangle(Math.max(1, Math.round(bounds.width())), Math.max(1, Math.round(bounds.height())), ++ Math.round(bounds.x()), Math.round(bounds.y())); ++ final var style = inputTextStyle(box); ++ this.setTextColor(NativeUiRenderer.color(style, 1)); ++ this.setTextColorUneditable(NativeUiRenderer.color(style, 1)); ++ // EditBox calculates horizontal scrolling when its cursor moves, not ++ // when its rectangle changes. Reflow without changing the selection. ++ this.setCursorPosition(this.getCursorPosition()); ++ } + -+ @Override -+ public Component component() { -+ return this == DEFAULT -+ ? Component.translatable("bedrock_settings.viafabricplus.native_ui_scale.default") -+ : Component.literal(Integer.toString(this.offset)); ++ @Override ++ public NativeUiScene.Node node() { return this.node; } ++ ++ @Override ++ public AbstractWidget widget() { return this; } ++ ++ @Override ++ public boolean isMouseOver(final double x, final double y) { ++ return this.box.visibleBounds().contains(x, y) && super.isMouseOver(x, y); ++ } + } -+} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/settings/impl/BedrockSettings.java b/src/main/java/com/viaversion/viafabricplus/bedrock/settings/impl/BedrockSettings.java -index a0f9d2f..a2c1af8 100644 ---- a/src/main/java/com/viaversion/viafabricplus/bedrock/settings/impl/BedrockSettings.java -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/settings/impl/BedrockSettings.java -@@ -21,6 +21,9 @@ - - package com.viaversion.viafabricplus.bedrock.settings.impl; - -+import com.viaversion.viafabricplus.bedrock.settings.base.NativeUiScale; + ++ private final class MultilineInput extends NativeUiMultilineInput implements NativeInput { ++ private NativeUiLayout.Box box; + - import com.viaversion.viafabricplus.bedrock.settings.base.CaptionDuration; - import com.viaversion.viafabricplus.bedrock.settings.base.CaptionPosition; - import com.viaversion.viafabricplus.bedrock.settings.base.ParticleViewDistanceSetting; -@@ -46,6 +49,7 @@ public final class BedrockSettings { - private final BooleanSetting muteEmoteChat; - private final BooleanSetting alwaysUseJavaHand; - private final BooleanSetting allowCameraShake; -+ private final EnumSetting nativeUiScale; - private final ParticleViewDistanceSetting particleViewDistance; - private final BooleanSetting captions; - private final BooleanSetting hideOwnCaptions; -@@ -63,6 +67,7 @@ public final class BedrockSettings { - this.muteEmoteChat = group.registerBoolean("mute_emote_chat", false); - this.alwaysUseJavaHand = group.registerBoolean("always_use_java_hand", false); - this.allowCameraShake = group.registerBoolean("allow_camera_shake", true); -+ this.nativeUiScale = group.registerEnum("native_ui_scale", NativeUiScale.DEFAULT); - this.particleViewDistance = new ParticleViewDistanceSetting(); - group.register("particle_view_distance", this.particleViewDistance); - this.captions = group.registerBoolean("captions", false); -@@ -91,6 +96,8 @@ public final class BedrockSettings { - - public BooleanSetting allowCameraShake() { return this.allowCameraShake; } - -+ public EnumSetting nativeUiScale() { return this.nativeUiScale; } ++ MultilineInput(final NativeUiLayout.Box box) { ++ super(NativeUiSceneScreen.this.font, NativeUiText.component(Action.caption(box.node())), ++ NativeUiSceneScreen.this.controller.inputs().text(inputIndex(box.node())), ++ NativeUiSceneScreen.this.controller.inputs().placeholder(inputIndex(box.node())), ++ NativeUiSceneScreen.this.controller.inputs().maxLength(inputIndex(box.node())), ++ value -> NativeUiSceneScreen.this.refreshPending |= ++ NativeUiSceneScreen.this.controller.inputs().setText(inputIndex(box.node()), value)); ++ this.place(box); ++ } + - public ParticleViewDistanceSetting particleViewDistance() { return this.particleViewDistance; } - - public BooleanSetting captions() { return this.captions; } -diff --git a/src/main/resources/assets/viafabricplus-bedrock/lang/en_us.json b/src/main/resources/assets/viafabricplus-bedrock/lang/en_us.json -index 545727e..d0fac3c 100644 ---- a/src/main/resources/assets/viafabricplus-bedrock/lang/en_us.json -+++ b/src/main/resources/assets/viafabricplus-bedrock/lang/en_us.json -@@ -388,6 +388,8 @@ - "bedrock_marketplace.viafabricplus.wallet_sign_in": "Minecoins: sign in", - "bedrock_marketplace.viafabricplus.wallet_refresh": "Refresh balance", - "bedrock_marketplace.viafabricplus.wallet_sign_in_short": "Sign in", -+ "bedrock_settings.viafabricplus.native_ui_scale": "Bedrock UI scale offset", -+ "bedrock_settings.viafabricplus.native_ui_scale.default": "Default (0)", - "bedrock_settings.viafabricplus.allow_camera_shake": "Allow camera shake", - "bedrock_settings.viafabricplus.particle_view_distance": "Particle render distance", - "bedrock_settings.viafabricplus.captions": "Bedrock closed captions", -diff --git a/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_actor.fsh b/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_actor.fsh -index a744fb8..df8abcd 100644 ---- a/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_actor.fsh -+++ b/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_actor.fsh -@@ -82,6 +82,11 @@ void main() { - #ifndef IGNORE_LIGHTING - color.rgb *= lightMapColor.rgb; - #endif -+#endif -+#ifdef GUI_PORTRAIT -+ // Source alpha controls cutout and emission above. Surviving GUI fragments -+ // carry material coverage, with control opacity independent of that sample. -+ color.a = vertexColor.a * ColorModulator.a; - #endif - fragColor = apply_fog(color, sphericalVertexDistance, cylindricalVertexDistance, - FogEnvironmentalStart, FogEnvironmentalEnd, FogRenderDistanceStart, -@@ -96,5 +101,8 @@ void main() { - vec3 glint = (firstGlint + secondGlint) * BedrockGlintColor.rgb * tileLight; - fragColor.rgb += glint * glint; - fragColor.a = abs((firstGlint.r + secondGlint.r) * BedrockGlintColor.a * BedrockGlintTileLight.a); -+#ifdef GUI_PORTRAIT -+ fragColor.a = vertexColor.a * ColorModulator.a; -+#endif - #endif - } -diff --git a/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_bitmap_font.fsh b/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_bitmap_font.fsh -new file mode 100644 -index 0000000..caf1dcd ---- /dev/null -+++ b/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_bitmap_font.fsh -@@ -0,0 +1,26 @@ -+#version 330 -+#extension GL_ARB_separate_shader_objects : require -+#include ++ @Override ++ public void place(final NativeUiLayout.Box box) { ++ this.box = box; ++ final var style = inputTextStyle(box); ++ this.configure(box.bounds(), NativeUiRenderer.color(style, 1), style.flag("shadow", false)); ++ } + -+uniform sampler2D Sampler0; -+layout(location = 0) in vec4 vertexColor; -+layout(location = 1) in vec2 glyphUv; -+layout(location = 2) flat in vec4 glyphBounds; -+layout(location = 3) noperspective in float glyphGuiY; -+layout(location = 0) out vec4 fragColor; ++ @Override ++ public NativeUiScene.Node node() { return this.box.node(); } + -+void main() { -+ // Preserve the unshifted sample position while selecting native top-edge coverage. -+ vec2 sourceUv = glyphUv - dFdy(glyphUv) * sign(dFdy(glyphGuiY)) / 256.0; -+ vec2 uv = clamp(sourceUv, glyphBounds.xy, glyphBounds.zw); -+#ifdef BITMAP_SMOOTH -+ vec4 color = texture(Sampler0, uv); -+ color = clamp((color - 0.3) * 5.000000476837158, 0.0, 1.0); -+ color = color * color * (3.0 - 2.0 * color); -+#else -+ // Preserve native point addresses when float interpolation lands just below a texel boundary. -+ vec2 texels = roundEven(uv * vec2(textureSize(Sampler0, 0)) * 256.0) / 256.0; -+ vec4 color = texelFetch(Sampler0, ivec2(floor(texels)), 0); -+#endif -+ fragColor = color * vertexColor * ColorModulator; ++ @Override ++ public AbstractWidget widget() { return this; } ++ ++ @Override ++ public boolean isMouseOver(final double x, final double y) { ++ return this.box.visibleBounds().contains(x, y) && super.isMouseOver(x, y); ++ } ++ } ++ ++ private record Drag(NativeUiScene.Node node, float grab) {} ++ ++ private final class Action extends AbstractButton { ++ private NativeUiScene.Node node; ++ private NativeUiLayout.Box box; ++ private boolean eligible; ++ ++ Action(final NativeUiLayout.Box box) { ++ super(0, 0, 1, 1, NativeUiText.component(caption(box.node()))); ++ this.node = box.node(); ++ this.place(box); ++ } ++ ++ void place(final NativeUiLayout.Box box) { ++ this.node = box.node(); ++ this.setMessage(NativeUiText.component(caption(this.node))); ++ final String tooltip = this.node.string("tooltip", ""); ++ this.setTooltip(tooltip.isEmpty() ? null : Tooltip.create(NativeUiText.component(tooltip))); ++ this.box = box; ++ final var bounds = box.bounds(); ++ this.setRectangle(Math.max(1, Math.round(bounds.width())), Math.max(1, Math.round(bounds.height())), ++ Math.round(bounds.x()), Math.round(bounds.y())); ++ } ++ ++ @Override ++ public void onPress(final InputWithModifiers input) { ++ NativeUiSceneScreen.this.activate(this.node); ++ } ++ ++ @Override ++ protected void extractContents(final GuiGraphicsExtractor graphics, final int x, final int y, final float delta) { ++ // Native button controls are hit targets. Their authored state children ++ // provide visuals; a control without visual children stays transparent. ++ } ++ ++ @Override ++ public ComponentPath nextFocusPath(final FocusNavigationEvent event) { ++ return this.node.flag("focus_enabled", false) ? super.nextFocusPath(event) : null; ++ } ++ ++ @Override ++ public boolean isMouseOver(final double x, final double y) { ++ return this.box.visibleBounds().contains(x, y) && super.isMouseOver(x, y); ++ } ++ ++ @Override ++ protected void updateWidgetNarration(final NarrationElementOutput output) { ++ this.defaultButtonNarrationText(output); ++ } ++ ++ private static String caption(final NativeUiScene.Node node) { ++ final String text = node.string("text", ""); ++ if (!text.isEmpty()) return text; ++ for (final var child : node.children()) { ++ final String value = caption(child); ++ if (!value.isEmpty()) return value; ++ } ++ return node.name(); ++ } ++ } +} -diff --git a/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_bitmap_font.vsh b/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_bitmap_font.vsh +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiScissor.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiScissor.java new file mode 100644 -index 0000000..1564b4b +index 0000000..99a2446 --- /dev/null -+++ b/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_bitmap_font.vsh -@@ -0,0 +1,29 @@ -+#version 330 -+#extension GL_ARB_separate_shader_objects : require -+#include -+#include ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiScissor.java +@@ -0,0 +1,73 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.ui; + -+uniform sampler2D Sampler0; -+layout(location = 0) in vec3 Position; -+layout(location = 1) in vec4 Color; -+layout(location = 2) in vec2 UV0; -+layout(location = 3) in ivec2 UV1; -+layout(location = 4) in vec2 UV3; -+layout(location = 0) out vec4 vertexColor; -+layout(location = 1) out vec2 glyphUv; -+layout(location = 2) flat out vec4 glyphBounds; -+layout(location = 3) noperspective out float glyphGuiY; ++import com.viaversion.viafabricplus.bedrock.injection.access.IBedrockGuiScissor; ++import net.minecraft.client.Minecraft; ++import net.minecraft.client.gui.GuiGraphicsExtractor; ++import net.minecraft.client.gui.navigation.ScreenRectangle; + -+void main() { -+ gl_Position = ProjMat * ModelViewMat * vec4(Position, 1.0); -+ vec2 page = vec2(textureSize(Sampler0, 0)); -+ vec2 sourceOrigin = vec2(UV1) / page; -+ // Native packs 16.16 origins into an unsigned-normalized 16-bit attribute. -+ vec2 origin = clamp(floor(sourceOrigin * 65536.0), 0.0, 65535.0) / 65535.0; -+ vec2 extent = UV3 / page; -+ vec2 corner = vec2(greaterThan(UV0, sourceOrigin + extent * 0.5)); -+ glyphUv = origin + corner * extent; -+ glyphBounds = vec4(origin + 0.5 / page, origin + extent - 0.5 / page); -+ vertexColor = Color; -+ glyphGuiY = Position.y; -+} -diff --git a/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_truetype_font.fsh b/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_truetype_font.fsh -new file mode 100644 -index 0000000..ecc427e ---- /dev/null -+++ b/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_truetype_font.fsh -@@ -0,0 +1,10 @@ -+#version 330 -+#extension GL_ARB_separate_shader_objects : require -+#include -+uniform sampler2D Sampler0; -+layout(location = 2) in vec4 vertexColor; -+layout(location = 3) in vec2 texCoord0; -+layout(location = 0) out vec4 fragColor; -+void main() { -+ fragColor = texture(Sampler0, texCoord0).rrrr * vertexColor * ColorModulator; ++/** Retains native clip precision independently of Java's integer GUI coordinate grid. */ ++public final class NativeUiScissor { ++ private final NativeUiLayout.Rect bounds; ++ private final ScreenRectangle pixels; ++ ++ public NativeUiScissor(final NativeUiLayout.Rect bounds) { ++ this.bounds = bounds; ++ // Native intersects floating rectangles before truncating the origin and extent. ++ this.pixels = new ScreenRectangle((int) bounds.x(), (int) bounds.y(), ++ Math.max(0, (int) bounds.width()), Math.max(0, (int) bounds.height())); ++ } ++ ++ public static NativeUiScissor scaled(final NativeUiLayout.Rect bounds, final int scale) { ++ return new NativeUiScissor(new NativeUiLayout.Rect(bounds.x() * scale, bounds.y() * scale, ++ bounds.width() * scale, bounds.height() * scale)); ++ } ++ ++ public NativeUiScissor intersect(final NativeUiScissor other) { ++ return new NativeUiScissor(this.bounds.intersect(other.bounds)); ++ } ++ ++ public ScreenRectangle pixels() { return this.pixels; } ++ ++ /** Conservative bounds for Java's overlap search; the render pass uses the exact pixels. */ ++ public ScreenRectangle guiBounds(final int javaScale) { ++ final int left = Math.floorDiv(this.pixels.left(), javaScale); ++ final int top = Math.floorDiv(this.pixels.top(), javaScale); ++ return new ScreenRectangle(left, top, Math.ceilDiv(this.pixels.right(), javaScale) - left, ++ Math.ceilDiv(this.pixels.bottom(), javaScale) - top); ++ } ++ ++ public static boolean samePixels(final NativeUiScissor first, final NativeUiScissor second) { ++ return first == second || first != null && second != null && first.pixels.equals(second.pixels); ++ } ++ ++ public static NativeUiScissor from(final ScreenRectangle rectangle) { ++ return rectangle == null ? null : ((IBedrockGuiScissor) (Object) rectangle).stackanvil$scissor(); ++ } ++ ++ /** The supplied rectangle is in absolute native layout units, before the Java pose scale. */ ++ public static void enable(final GuiGraphicsExtractor graphics, final NativeUiLayout.Rect bounds) { ++ final var window = Minecraft.getInstance().getWindow(); ++ final int javaScale = window.getGuiScale(); ++ final var viewport = graphics instanceof NativeUiGraphics nativeGraphics ++ ? nativeGraphics.viewport() : NativeUiViewport.current(); ++ NativeUiScissor clip = scaled(bounds, viewport.scale()); ++ final var stack = graphics.scissorStack; ++ final var parent = stack.peek(); ++ if (parent != null) { ++ final var nativeParent = from(parent); ++ clip = clip.intersect(nativeParent != null ? nativeParent : scaled(new NativeUiLayout.Rect( ++ parent.left(), parent.top(), parent.width(), parent.height()), javaScale)); ++ } ++ clip = clip.intersect(new NativeUiScissor(new NativeUiLayout.Rect(0, 0, window.getWidth(), window.getHeight()))); ++ final var rectangle = clip.guiBounds(javaScale); ++ ((IBedrockGuiScissor) (Object) rectangle).stackanvil$scissor(clip); ++ // push() intersects integer GUI bounds. That can discard pixels before the native clip ++ // reaches the render pass, and its shared empty rectangle cannot carry per-draw state. ++ stack.stack.addLast(rectangle); ++ } +} -diff --git a/src/main/resources/viafabricplus-bedrock.accesswidener b/src/main/resources/viafabricplus-bedrock.accesswidener -index 8e8672c..288cc7a 100644 ---- a/src/main/resources/viafabricplus-bedrock.accesswidener -+++ b/src/main/resources/viafabricplus-bedrock.accesswidener -@@ -12,3 +12,7 @@ accessible method net/minecraft/client/renderer/rendertype/RenderType create (Lj - - accessible field net/minecraft/client/renderer/RenderPipelines ENTITY_NO_LIGHTMAP_SNIPPET Lcom/mojang/renderpearl/api/pipeline/RenderPipeline$Snippet; - accessible field net/minecraft/client/renderer/RenderPipelines EYES_SNIPPET Lcom/mojang/renderpearl/api/pipeline/RenderPipeline$Snippet; -+accessible method net/minecraft/client/gui/GuiGraphicsExtractor innerBlit (Lcom/mojang/renderpearl/api/pipeline/RenderPipeline;Lnet/minecraft/resources/Identifier;IIIIFFFFI)V -+accessible class net/minecraft/client/gui/GuiGraphicsExtractor$ScissorStack -+accessible field net/minecraft/client/gui/GuiGraphicsExtractor scissorStack Lnet/minecraft/client/gui/GuiGraphicsExtractor$ScissorStack; -+accessible field net/minecraft/client/gui/GuiGraphicsExtractor$ScissorStack stack Ljava/util/Deque; -diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json -index 91cd497..77e339e 100644 ---- a/src/main/resources/viafabricplus-bedrock.mixins.json -+++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -150,6 +150,13 @@ - "features.render.MixinBedrockModelPartTransforms", - "features.render.MixinPlayerSkinWidget", - "features.render.MixinGuiSkinRenderer", -+ "features.render.MixinGuiEntityRenderer", -+ "features.render.MixinPictureInPictureRenderer", -+ "features.render.MixinNativeUiPictures", -+ "features.render.MixinBedrockBitmapFont", -+ "features.render.MixinBedrockTrueTypeAtlas", -+ "features.render.MixinBedrockTrueTypeFont", -+ "features.render.MixinBedrockBitmapGlyphRenderState", - "features.input.MixinKeyboardInput", - "features.input.MixinLocalPlayerInput", - "features.input.MixinClientInput", -@@ -205,7 +212,13 @@ - "features.render.MixinBedrockCameraFade", - "features.entities.MixinClientEntityTracking", - "features.entities.MixinMinecraftProjectileOwners", -- "features.entities.MixinFishingHookOwner" -+ "features.entities.MixinFishingHookOwner", -+ "features.input.MixinNativeUiInput", -+ "features.render.MixinNativeUiExtraction", -+ "features.render.MixinNativeUiScissorArea", -+ "features.render.MixinNativeUiScissorRenderer", -+ "features.render.MixinNativeHudOverlays", -+ "features.render.MixinNativeUiViewport" - ], - "injectors": { - "defaultRequire": 1 -diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockBitmapFontResourcesTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockBitmapFontResourcesTest.java +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiScreen.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiScreen.java new file mode 100644 -index 0000000..19aa549 +index 0000000..bb9713e --- /dev/null -+++ b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockBitmapFontResourcesTest.java -@@ -0,0 +1,67 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiScreen.java +@@ -0,0 +1,13 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ -+package com.viaversion.viafabricplus.bedrock.appearance; -+ -+import java.awt.image.BufferedImage; -+import java.io.ByteArrayOutputStream; -+import java.nio.charset.StandardCharsets; -+import java.util.ArrayList; -+import java.util.List; -+import java.util.Map; -+import java.util.UUID; -+import javax.imageio.ImageIO; -+import net.raphimc.viabedrock.api.resourcepack.ResourcePack; -+import net.raphimc.viabedrock.api.resourcepack.content.Content; -+import net.raphimc.viabedrock.api.resourcepack.content.InMemoryContent; -+import net.raphimc.viabedrock.protocol.rewriter.resourcepack.GlyphSheetResourceRewriter; -+import net.raphimc.viabedrock.protocol.storage.ResourcePackStorage; -+import org.junit.jupiter.api.Test; -+import static org.junit.jupiter.api.Assertions.*; -+ -+final class BedrockBitmapFontResourcesTest { -+ @Test -+ void carriesLicensedAtlasLayoutIntoConversionAndKeepsLegacyServerTextureOwnership() throws Exception { -+ final var source = new BufferedImage(128, 128, BufferedImage.TYPE_INT_ARGB); -+ source.setRGB(0, 0, 0xFF123456); -+ final var encoded = new ByteArrayOutputStream(); -+ ImageIO.write(source, "png", encoded); -+ final byte[] atlas = encoded.toByteArray(); -+ final byte[] metadata = "{\"ascii_extended\":true}".getBytes(StandardCharsets.UTF_8); -+ final var library = BedrockResourceLibrary.read(Map.of( -+ "library/vanilla/font/default8.png", atlas, -+ "library/vanilla/font/default8.json", metadata)); -+ final var nativePacks = library.resourcePacks(); -+ assertSame(atlas, nativePacks.getFirst().content().get("font/default8.png")); -+ assertSame(metadata, nativePacks.getFirst().content().get("font/default8.json")); -+ final var extended = convert(new ResourcePackStorage(List.of(), nativePacks)); -+ assertEquals(12, firstProvider(extended).get("height").getAsInt()); -+ assertEquals(120, extended.getImage("assets/viabedrock/textures/font/bedrock_bitmap/default8_0.png").getImage().getHeight()); -+ -+ final var legacyContent = new InMemoryContent(); -+ legacyContent.putString("manifest.json", "{\"format_version\":2,\"header\":{\"name\":\"legacy atlas\",\"uuid\":\"" -+ + UUID.randomUUID() + "\",\"version\":[1,0,0]}}"); -+ source.setRGB(0, 0, 0xFF654321); -+ legacyContent.putPngImage("font/default8.png", source); -+ final var legacy = convert(new ResourcePackStorage(List.of(new ResourcePack(legacyContent)), nativePacks)); -+ assertEquals(8, firstProvider(legacy).get("height").getAsInt()); -+ final var image = legacy.getImage("assets/viabedrock/textures/font/bedrock_bitmap/default8_0.png").getImage(); -+ assertEquals(128, image.getHeight()); -+ assertEquals(0xFF654321, image.getRGB(0, 0)); -+ // Converting the upper pack must not mutate the cached licensed view. -+ assertEquals(12, firstProvider(convert(new ResourcePackStorage(List.of(), nativePacks))).get("height").getAsInt()); -+ } ++package com.viaversion.viafabricplus.bedrock.screen.ui; + -+ private static Content convert(final ResourcePackStorage storage) { -+ final List result = new ArrayList<>(); -+ new GlyphSheetResourceRewriter().submitTasks(storage, supplier -> result.add(supplier.get())); -+ assertEquals(1, result.size()); -+ return result.getFirst(); -+ } ++/** Screens whose layout and input use the target Bedrock GuiData viewport. */ ++public interface NativeUiScreen { ++ default boolean closeOnPlayerHurt() { return false; } + -+ private static com.viaversion.viaversion.libs.gson.JsonObject firstProvider(final Content content) { -+ return content.getJson("assets/minecraft/font/default.json").getAsJsonArray("providers").get(0).getAsJsonObject(); -+ } ++ default NativeUiViewport nativeViewport() { return NativeUiViewport.current(); } +} -diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockGuiActorAppearanceTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockGuiActorAppearanceTest.java +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiScroll.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiScroll.java new file mode 100644 -index 0000000..eb50b32 +index 0000000..ed28afb --- /dev/null -+++ b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockGuiActorAppearanceTest.java -@@ -0,0 +1,92 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiScroll.java +@@ -0,0 +1,69 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ -+package com.viaversion.viafabricplus.bedrock.appearance; ++package com.viaversion.viafabricplus.bedrock.screen.ui; + -+import com.viaversion.viafabricplus.bedrock.render.BedrockGuiActorContext; -+import java.io.IOException; -+import java.nio.charset.StandardCharsets; -+import java.util.Map; -+import java.util.UUID; -+import org.junit.jupiter.api.Test; -+import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; ++/** Pointer geometry for authored scrollbars. Native layout math remains a separate parity boundary. */ ++public final class NativeUiScroll { ++ private NativeUiScroll() {} + -+import static org.junit.jupiter.api.Assertions.*; ++ public static boolean horizontal(final NativeUiLayout.Box view) { ++ final var thumb = descendant(view, view.node().string("scrollbar_box", "")); ++ return thumb != null ? thumb.node().string("draggable", "vertical").equals("horizontal") ++ : view.node().string("scroll_direction", "vertical").equals("horizontal"); ++ } + -+final class BedrockGuiActorAppearanceTest { -+ @Test -+ void samplesVariantSelectionAndPoseInSeparatePersistentPerspectives() throws Exception { -+ final var library = BedrockResourceLibrary.read(Map.of( -+ "library/vanilla/entity/probe.json", bytes(""" -+ {"minecraft:client_entity":{"description":{"identifier":"test:probe", -+ "materials":{"default":"npc"},"geometry":{"default":"geometry.probe"}, -+ "textures":{"first":"textures/first","second":"textures/second"}, -+ "animations":{"pose":"animation.probe"},"scripts":{"initialize":["v.counter=0;"], -+ "pre_animation":["v.counter=v.counter+1;"],"animate":["pose"]}, -+ "render_controllers":["controller.render.probe"]}}} -+ """), -+ "library/vanilla/animations/probe.json", bytes(""" -+ {"animations":{"animation.probe":{"loop":true,"bones":{"head":{ -+ "position":["v.counter",0,0],"rotation":["q.target_x_rotation","q.target_y_rotation",0]}}}}} -+ """), -+ "library/vanilla/render_controllers/probe.json", bytes(""" -+ {"render_controllers":{"controller.render.probe":{ -+ "arrays":{"textures":{"Array.skins":["Texture.first","Texture.second"]}}, -+ "geometry":"Geometry.default","materials":[{"*":"Material.default"}], -+ "textures":["Array.skins[q.variant]"],"part_visibility":[{"*":"q.is_in_ui"}]}}} -+ """), -+ "library/vanilla/materials/probe.material", bytes(""" -+ {"materials":{"npc:entity_alphatest":{}}} -+ """))); -+ final var first = BedrockGuiActorAppearance.load(library, "test:probe"); -+ final var second = BedrockGuiActorAppearance.load(library, "test:probe"); -+ try (final var context = new BedrockGuiActorContext(UUID.randomUUID(), UUID.randomUUID())) { -+ final var a = sample(context, first, 0, 0); -+ final var b = sample(context, first, 1, 1); -+ final var c = sample(context, second, 0, 1); -+ assertEquals(1, a.pose().get("head").position().x()); -+ assertEquals(2, b.pose().get("head").position().x()); -+ assertEquals(1, c.pose().get("head").position().x()); -+ assertEquals(12, b.pose().get("head").rotation().x()); -+ assertEquals(-8, b.pose().get("head").rotation().y()); -+ assertNotEquals(a.draws().surfaces().getFirst().textures().getFirst(), b.draws().surfaces().getFirst().textures().getFirst()); -+ assertEquals(b.draws(), c.draws()); -+ assertTrue(b.draws().surfaces().getFirst().visibility().getFirst().visible()); -+ assertTrue(b.draws().surfaces().getFirst().materials().values().stream().allMatch(name -> first.materials().material(name).isPresent())); -+ } -+ assertThrows(IllegalStateException.class, () -> first.sample(2, null, 0, 0, 0)); ++ public static float maximum(final NativeUiLayout.Box view) { ++ return Math.max(0, horizontal(view) ? view.content().width() - view.scrollViewport().width() ++ : view.content().height() - view.scrollViewport().height()); + } + -+ @Test -+ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_NPC_ASSETS", matches = ".+") -+ void resolvesEveryLicensedNpcVariantWithItsNativeGeometryTextureAndMaterial() throws Exception { -+ final var library = BedrockBuiltinAssets.load().library(); -+ final var appearance = BedrockGuiActorAppearance.load(library, "minecraft:npc"); -+ final var selected = new java.util.HashSet(); -+ try (final var context = new BedrockGuiActorContext(UUID.randomUUID(), UUID.randomUUID())) { -+ for (int variant = 0; variant < 60; variant++) { -+ final var frame = sample(context, appearance, variant / 60F, variant); -+ assertEquals(1, frame.draws().surfaces().size()); -+ final var draw = frame.draws().surfaces().getFirst(); -+ assertTrue(selected.add(draw.textures().getFirst())); -+ assertFalse(appearance.model(draw.geometry()).getParents().isEmpty()); -+ assertNotNull(appearance.image(draw.textures().getFirst())); -+ assertTrue(draw.materials().values().stream().allMatch(name -> appearance.materials().material(name).isPresent())); -+ assertFalse(frame.pose().isEmpty()); -+ } -+ } ++ public static Model model(final NativeUiLayout.Box view, final float offset) { ++ final var track = descendant(view, view.node().string("scrollbar_track", "")); ++ final var thumb = descendant(view, view.node().string("scrollbar_box", "")); ++ if (track == null || thumb == null) return null; ++ final boolean horizontal = horizontal(view); ++ final float maximum = maximum(view); ++ final float content = horizontal ? view.content().width() : view.content().height(); ++ final float viewport = horizontal ? view.scrollViewport().width() : view.scrollViewport().height(); ++ final float length = horizontal ? track.bounds().width() : track.bounds().height(); ++ final int axis = horizontal ? 0 : 1; ++ final var minimum = thumb.node().properties().getAsJsonArray("min_size"); ++ final float minimumLength = minimum != null && minimum.size() == 2 ++ ? NativeUiLayout.dimension(minimum.get(axis), length, 0, 0) : 0; ++ final float extent = Math.clamp(content > 0 ? length * Math.min(1, viewport / content) : length, ++ Math.clamp(minimumLength, 0, length), length); ++ final float position = maximum > 0 ? (length - extent) * Math.clamp(offset, 0, maximum) / maximum : 0; ++ final var bounds = horizontal ++ ? new NativeUiLayout.Rect(track.bounds().x() + position, thumb.bounds().y(), extent, thumb.bounds().height()) ++ : new NativeUiLayout.Rect(thumb.bounds().x(), track.bounds().y() + position, thumb.bounds().width(), extent); ++ return new Model(thumb, track.bounds(), bounds, track.clip().intersect(thumb.clip()), maximum, ++ Math.clamp(offset, 0, maximum), horizontal); + } + -+ private static BedrockGuiActorAppearance.Frame sample(final BedrockGuiActorContext context, -+ final BedrockGuiActorAppearance appearance, final float seconds, final int variant) { -+ context.camera(12, -8); -+ return context.scoped(() -> { -+ try { return appearance.sample(seconds, null, variant, 12, -8); } -+ catch (final IOException error) { throw new java.io.UncheckedIOException(error); } -+ }); ++ public static NativeUiLayout.Box descendant(final NativeUiLayout.Box box, final String name) { ++ if (name.isEmpty()) return null; ++ for (final var child : box.children()) { ++ if (child.node().name().equals(name)) return child; ++ final var found = descendant(child, name); ++ if (found != null) return found; ++ } ++ return null; + } + -+ private static byte[] bytes(final String json) { return json.getBytes(StandardCharsets.UTF_8); } ++ public record Model(NativeUiLayout.Box originalThumb, NativeUiLayout.Rect track, NativeUiLayout.Rect thumb, ++ NativeUiLayout.Rect clip, float maximum, float offset, boolean horizontal) { ++ public boolean containsTrack(final double x, final double y) { return this.track.intersect(this.clip).contains(x, y); } ++ public boolean containsThumb(final double x, final double y) { return this.thumb.intersect(this.clip).contains(x, y); } ++ public float pointer(final double x, final double y) { return (float) (this.horizontal ? x : y); } ++ public float start() { return this.horizontal ? this.thumb.x() : this.thumb.y(); } ++ public float extent() { return this.horizontal ? this.thumb.width() : this.thumb.height(); } ++ public float offsetAt(final float pointer, final float grab) { ++ final float travel = this.horizontal ? this.track.width() - this.thumb.width() : this.track.height() - this.thumb.height(); ++ final float start = this.horizontal ? this.track.x() : this.track.y(); ++ return travel > 0 ? Math.clamp((pointer - start - grab) / travel, 0, 1) * this.maximum : 0; ++ } ++ } +} -diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockGuiActorContextTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockGuiActorContextTest.java +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiSlider.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiSlider.java new file mode 100644 -index 0000000..11027a2 +index 0000000..9726a32 --- /dev/null -+++ b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockGuiActorContextTest.java -@@ -0,0 +1,140 @@ -+package com.viaversion.viafabricplus.bedrock.appearance; -+ -+import com.viaversion.viafabricplus.bedrock.render.BedrockGuiActorContext; -+ -+import com.google.gson.JsonObject; -+import com.google.gson.JsonParser; -+import com.viaversion.viafabricplus.bedrock.appearance.BedrockActorAnimation; -+import java.util.Map; -+import java.util.UUID; -+import org.junit.jupiter.api.Test; ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiSlider.java +@@ -0,0 +1,123 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.ui; + -+import static org.junit.jupiter.api.Assertions.*; ++/** Applies the native slider component's completed-layout thumb and image updates. */ ++public final class NativeUiSlider { ++ private NativeUiSlider() {} + -+final class BedrockGuiActorContextTest { -+ @Test -+ void portraitFadeModulatesTheFinishedPremultipliedTextureWithoutChangingAnotherPerspective() { -+ try (final var fading = new BedrockGuiActorContext(UUID.randomUUID(), UUID.randomUUID()); -+ final var ordinary = new BedrockGuiActorContext(UUID.randomUUID(), UUID.randomUUID())) { -+ fading.opacity(.5F); -+ assertEquals(0x80808080, fading.compositeColor(0xFFFFFFFF)); -+ assertEquals(0x40201008, fading.compositeColor(0x80402010)); -+ assertEquals(0x80402010, ordinary.compositeColor(0x80402010)); -+ fading.opacity(0); -+ assertEquals(0, fading.compositeColor(0xFFFFFFFF)); -+ assertThrows(IllegalArgumentException.class, () -> fading.opacity(Float.NaN)); -+ } ++ public static NativeUiLayout.Box apply(final NativeUiLayout.Box box) { ++ final var children = box.children().stream().map(NativeUiSlider::apply).toList(); ++ final var result = withChildren(box, children); ++ if (!box.node().type().equals("slider")) return result; ++ final float value = fraction(box.node()); ++ return withChildren(result, result.children().stream() ++ .map(child -> update(child, result, result, value)).toList()); + } + -+ @Test -+ void restoresPictureSubmissionAfterNestedFailureWithoutCreatingAnActorPerspective() { -+ assertFalse(BedrockGuiActorContext.inPicture()); -+ assertNull(BedrockGuiActorContext.current()); -+ assertThrows(IllegalArgumentException.class, () -> BedrockGuiActorContext.picture(() -> { -+ assertTrue(BedrockGuiActorContext.inPicture()); -+ assertThrows(IllegalStateException.class, () -> BedrockGuiActorContext.picture(() -> { -+ assertTrue(BedrockGuiActorContext.inPicture()); -+ throw new IllegalStateException(); -+ })); -+ assertTrue(BedrockGuiActorContext.inPicture()); -+ assertNull(BedrockGuiActorContext.current()); -+ throw new IllegalArgumentException(); -+ })); -+ assertFalse(BedrockGuiActorContext.inPicture()); -+ assertNull(BedrockGuiActorContext.current()); ++ /** Continuous values are normalized; stepped values are native option ordinals. */ ++ public static float fraction(final NativeUiScene.Node node) { ++ final int count = steps(node); ++ final float value = node.number("slider_value", 0); ++ return count >= 2 ? value / (count - 1) : value; + } + -+ @Test -+ void keepsGuiClockAndScriptVariablesSeparateFromWorldAndOtherPortraits() throws Exception { -+ final var graph = BedrockActorAnimation.read(JsonParser.parseString(""" -+ {"animations":{"idle":"animation.idle"},"scripts":{ -+ "initialize":["v.counter=0;"],"pre_animation":["v.counter=v.counter+1;"],"animate":["idle"]}} -+ """).getAsJsonObject(), Map.of(), JsonParser.parseString(""" -+ {"animation.idle":{"loop":true,"bones":{"head":{"position":["v.counter",0,0]}}}} -+ """).getAsJsonObject(), new JsonObject(), "1.10.0"); -+ final var world = graph.playback(); -+ final var frame = new BedrockActorAnimation.Frame(Map.of("is_in_ui", true), Map.of()); -+ final var gui = new BedrockGuiActorContext(UUID.randomUUID(), UUID.randomUUID()); -+ final var other = new BedrockGuiActorContext(UUID.randomUUID(), UUID.randomUUID()); -+ final var guiActor = gui.playback(world); -+ assertNotSame(world, guiActor); -+ assertSame(guiActor, gui.playback(world)); -+ assertEquals(1, guiActor.sample(0, frame).get("head").position().x()); -+ assertEquals(2, guiActor.sample(1, frame).get("head").position().x()); -+ assertEquals(1, world.sample(0, frame).get("head").position().x()); -+ assertEquals(1, other.playback(world).sample(0, frame).get("head").position().x()); -+ assertNotSame(guiActor, gui.playback(graph.playback())); ++ public static float pointerFraction(final NativeUiLayout.Box owner, final double x, final double y) { ++ if (!Double.isFinite(x) || !Double.isFinite(y)) throw new IllegalArgumentException("Non-finite slider pointer"); ++ final boolean vertical = vertical(owner.node()); ++ final var bounds = owner.bounds(); ++ final float start = vertical ? bounds.y() : bounds.x(); ++ final float extent = vertical ? bounds.height() : bounds.width(); ++ final float coordinate = (float) (vertical ? y : x); ++ float value = extent > 0 ? (Math.clamp(coordinate, start, start + extent) - start) / extent : 0; ++ if (owner.node().flag("slider_inverted", false)) value = 1 - value; ++ final int count = steps(owner.node()); ++ return count >= 2 ? (float) stepIndex(value, count) / (count - 1) : value; + } + -+ @Test -+ void correctsPipUniformDepthScaleWithoutChangingWorldOrOrdinaryGuiTransforms() { -+ for (final int guiScale : new int[] {1, 2, 3}) { -+ final var pose = new com.mojang.blaze3d.vertex.PoseStack(); -+ pose.translate(40 * guiScale, 65 * guiScale, 0); -+ pose.scale(60 * guiScale, 60 * guiScale, -60 * guiScale); -+ pose.pushPose(); -+ BedrockGuiActorContext.correctPose(pose, 60, guiScale); -+ pose.translate(0, 0, -5); -+ final var matrix = pose.last().pose(); -+ assertEquals(60 * guiScale, matrix.m00(), .00001F); -+ assertEquals(-60 * guiScale, matrix.m11(), .00001F); -+ assertEquals(1, matrix.m22(), .00001F); -+ assertEquals(-5, matrix.m32(), .00001F); -+ assertEquals(40 * guiScale, matrix.m30(), .00001F); -+ pose.popPose(); -+ assertEquals(-60 * guiScale, pose.last().pose().m22(), .00001F); -+ } ++ public static int stepIndex(final float fraction, final int count) { ++ if (!Float.isFinite(fraction) || count < 1) throw new IllegalArgumentException("Invalid stepped slider input"); ++ if (count == 1) return 0; ++ // The native multiply and half-step addition both round as float before floor. ++ return (int) Math.floor(Math.clamp(fraction, 0, 1) * (count - 1) + .5F); + } + -+ @Test -+ void retainsGuiEffectsAndRejectsUnimplementedSubmissionInsteadOfPlayingIntoWorld() throws Exception { -+ final var graph = BedrockActorAnimation.read(JsonParser.parseString("{\"sound_effects\":{\"click\":\"gui.click\"}}").getAsJsonObject(), -+ Map.of(), new JsonObject(), new JsonObject(), "1.10.0").playback(); -+ final var gui = new BedrockGuiActorContext(UUID.randomUUID(), UUID.randomUUID()); -+ gui.requireSupportedEffects(); -+ final var sound = graph.soundCue(new BedrockAnimationSound(0, "click", "head")); -+ gui.sound(sound); -+ assertEquals(java.util.List.of(sound), gui.sounds()); -+ assertThrows(IllegalArgumentException.class, gui::requireSupportedEffects); -+ gui.camera(0, 0); -+ gui.requireSupportedEffects(); ++ private static int steps(final NativeUiScene.Node node) { ++ return (int) node.number("slider_steps", 0); + } + -+ @Test -+ void retiresExtractedFramesAndEquipmentResourcesWithoutTouchingAnotherPerspective() { -+ final var context = new BedrockGuiActorContext(UUID.randomUUID(), UUID.randomUUID()); -+ final var other = new BedrockGuiActorContext(UUID.randomUUID(), UUID.randomUUID()); -+ final var frame = new net.minecraft.client.renderer.entity.state.AvatarRenderState(); -+ final var otherFrame = new net.minecraft.client.renderer.entity.state.AvatarRenderState(); -+ context.scoped(() -> { BedrockGuiActorContext.extracted(frame); return null; }); -+ other.scoped(() -> { BedrockGuiActorContext.extracted(otherFrame); return null; }); -+ final Object key = new Object(); -+ final Object equipment = context.resource(key, "appearance", Object::new); -+ assertSame(equipment, context.resource(key, "appearance", Object::new)); -+ assertNotSame(equipment, context.resource(key, "replacement", Object::new)); -+ final Object replacement = context.resource(new Object(), "appearance", Object::new); -+ assertNotSame(equipment, replacement); -+ context.close(); -+ assertNull(BedrockGuiActorContext.frame(frame)); -+ assertSame(other, BedrockGuiActorContext.frame(otherFrame)); -+ assertNotSame(replacement, context.resource(key, "appearance", Object::new)); -+ other.close(); ++ private static boolean vertical(final NativeUiScene.Node node) { ++ return node.string("slider_direction", "horizontal").equals("vertical"); + } + -+ @Test -+ void restoresNestedGuiContextAfterSubmissionFailure() { -+ final var outer = new BedrockGuiActorContext(UUID.randomUUID(), UUID.randomUUID()); -+ final var inner = new BedrockGuiActorContext(UUID.randomUUID(), UUID.randomUUID()); -+ assertNull(BedrockGuiActorContext.current()); -+ outer.scoped(() -> { -+ assertSame(outer, BedrockGuiActorContext.current()); -+ assertThrows(IllegalStateException.class, () -> inner.scoped(() -> { -+ assertSame(inner, BedrockGuiActorContext.current()); -+ throw new IllegalStateException("submission failed"); -+ })); -+ assertSame(outer, BedrockGuiActorContext.current()); -+ return null; -+ }); -+ assertNull(BedrockGuiActorContext.current()); ++ private static NativeUiLayout.Box update(final NativeUiLayout.Box box, final NativeUiLayout.Box parent, ++ final NativeUiLayout.Box owner, final float fraction) { ++ if (box.node().type().equals("slider")) return box; ++ var result = box; ++ if (named(box, owner, "slider_box_control")) { ++ final float[] from = anchor(box.node().string("anchor_from", "center")); ++ final float[] to = anchor(box.node().string("anchor_to", "center")); ++ final boolean vertical = vertical(owner.node()); ++ final float value = owner.node().flag("slider_inverted", false) ? 1 - fraction : fraction; ++ final var bounds = box.bounds(); ++ final var container = parent.bounds(); ++ // Native replaces both offset axes; the selected axis retains a parent-relative term. ++ final float x = container.x() + container.width() * from[0] - bounds.width() * to[0] ++ + (vertical ? 0 : value * container.width() - owner.bounds().width() * .5F); ++ final float y = container.y() + container.height() * from[1] - bounds.height() * to[1] ++ + (vertical ? value * container.height() - owner.bounds().height() * .5F : 0); ++ result = translate(box, x - bounds.x(), y - bounds.y(), parent.clip()); ++ } ++ if (box.node().type().equals("image") ++ && (named(box, owner, "progress_control") || named(box, owner, "progress_hover_control"))) { ++ // Runtime image state belongs to the same node used by animation and input caches. ++ result.node().properties().addProperty("clip_ratio", 1 - fraction); ++ } ++ final var updated = result; ++ return withChildren(updated, updated.children().stream() ++ .map(child -> update(child, updated, owner, fraction)).toList()); ++ } ++ ++ private static boolean named(final NativeUiLayout.Box box, final NativeUiLayout.Box owner, ++ final String property) { ++ final String name = owner.node().string(property, ""); ++ return !name.isEmpty() && box.node().name().equals(name); ++ } ++ ++ private static NativeUiLayout.Box translate(final NativeUiLayout.Box box, final float x, final float y, ++ final NativeUiLayout.Rect parentClip) { ++ final var bounds = shifted(box.bounds(), x, y); ++ final var clip = box.node().scroll() || box.node().flag("clips_children", false) ++ ? parentClip.intersect(bounds) : parentClip; ++ return new NativeUiLayout.Box(box.node(), bounds, clip, box.content(), shifted(box.scrollViewport(), x, y), ++ box.layer(), box.children().stream().map(child -> translate(child, x, y, clip)).toList()); ++ } ++ ++ private static NativeUiLayout.Rect shifted(final NativeUiLayout.Rect bounds, final float x, final float y) { ++ return new NativeUiLayout.Rect(bounds.x() + x, bounds.y() + y, bounds.width(), bounds.height()); ++ } ++ ++ private static NativeUiLayout.Box withChildren(final NativeUiLayout.Box box, ++ final java.util.List children) { ++ return new NativeUiLayout.Box(box.node(), box.bounds(), box.clip(), box.content(), box.scrollViewport(), ++ box.layer(), children); ++ } ++ ++ private static float[] anchor(final String name) { ++ return switch (name) { ++ case "top_left" -> new float[] {0, 0}; ++ case "top_middle" -> new float[] {.5F, 0}; ++ case "top_right" -> new float[] {1, 0}; ++ case "left_middle" -> new float[] {0, .5F}; ++ case "center" -> new float[] {.5F, .5F}; ++ case "right_middle" -> new float[] {1, .5F}; ++ case "bottom_left" -> new float[] {0, 1}; ++ case "bottom_middle" -> new float[] {.5F, 1}; ++ case "bottom_right" -> new float[] {1, 1}; ++ default -> throw new IllegalArgumentException("Unsupported native UI anchor"); ++ }; + } +} -diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockResourceLibraryTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockResourceLibraryTest.java -index f07a19a..bf983a3 100644 ---- a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockResourceLibraryTest.java -+++ b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockResourceLibraryTest.java -@@ -176,6 +176,31 @@ final class BedrockResourceLibraryTest { - assertEquals("server", server.translate("fallback", "de_de")); - } - -+ @Test -+ void selectsTheHighestTexturePackBeforeSelectingAnImageFormat() throws Exception { -+ final var png = new java.awt.image.BufferedImage(1, 1, java.awt.image.BufferedImage.TYPE_INT_ARGB); -+ png.setRGB(0, 0, 0xFFFF0000); -+ final var bytes = new java.io.ByteArrayOutputStream(); -+ javax.imageio.ImageIO.write(png, "png", bytes); -+ final var files = Map.of("library/vanilla/textures/entity/probe.png", bytes.toByteArray()); -+ final var lower = BedrockResourceLibrary.read(files); -+ assertEquals(0xFFFF0000, lower.image("textures/entity/probe").getRGB(0, 0)); +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..c21e081 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiText.java +@@ -0,0 +1,114 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.ui; + -+ final var tga = java.nio.ByteBuffer.allocate(22).order(java.nio.ByteOrder.LITTLE_ENDIAN); -+ tga.put(2, (byte) 2); -+ tga.putShort(12, (short) 1).putShort(14, (short) 1); -+ tga.put(16, (byte) 32).put(17, (byte) 0x28); -+ tga.putInt(18, 0xFF00FF00); -+ final var higher = BedrockResourceLibrary.withServerPacks(lower, -+ List.of(Map.of("textures/entity/probe.tga", tga.array()))); -+ assertEquals(0xFF00FF00, higher.image("textures/entity/probe").getRGB(0, 0)); -+ assertEquals(0xFFFF0000, higher.image("textures/entity/probe.png").getRGB(0, 0)); -+ final var invalid = BedrockResourceLibrary.withServerPacks(lower, -+ List.of(Map.of("textures/entity/probe.png", new byte[] {0}))); -+ assertThrows(IOException.class, () -> invalid.image("textures/entity/probe")); -+ assertThrows(IOException.class, () -> higher.image("../probe")); ++import com.google.gson.JsonParser; ++import com.mojang.serialization.JsonOps; ++import java.util.ArrayList; ++import java.util.List; ++import java.util.Optional; ++import net.minecraft.client.gui.Font; ++import net.minecraft.network.chat.Component; ++import net.minecraft.network.chat.MutableComponent; ++import net.minecraft.network.chat.Style; ++import net.minecraft.util.FormattedCharSequence; ++import net.minecraft.network.chat.ComponentSerialization; ++import net.raphimc.viabedrock.api.util.TextUtil; ++ ++public final class NativeUiText { ++ private NativeUiText() {} ++ ++ public static List tooltipLines(final Font font, final Component text, final int width) { ++ final List result = new ArrayList<>(); ++ for (final var paragraph : paragraphs(text)) { ++ final var wrapped = font.split(paragraph, width); ++ if (wrapped.isEmpty()) result.add(FormattedCharSequence.EMPTY); ++ else result.addAll(wrapped); ++ } ++ return List.copyOf(result); + } + - @Test - void resolvesServerParticlesTexturesAndLevelSoundsAcrossPackOverrides() throws Exception { - final java.util.List packs = new java.util.ArrayList<>(); -diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/ViaFabricPlusBuiltinResourcePackProviderTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/ViaFabricPlusBuiltinResourcePackProviderTest.java -index 85d180b..45825b5 100644 ---- a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/ViaFabricPlusBuiltinResourcePackProviderTest.java -+++ b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/ViaFabricPlusBuiltinResourcePackProviderTest.java -@@ -59,12 +59,16 @@ final class ViaFabricPlusBuiltinResourcePackProviderTest { - files.put("library/vanilla/textures/item_texture.json", new byte[] {'{', '}'}); - files.put("library/vanilla/textures/terrain_texture.json", new byte[] {'{', '}'}); - files.put("library/vanilla/textures/unrelated.json", new byte[] {'{', '}'}); -+ files.put("library/vanilla/ui/ui_common.json", new byte[] {3}); -+ files.put("library/vanilla/textures/items/shared.json", new byte[] {4}); - files.put("library/vanilla/entity/player.entity.json", new byte[] {'{', '}'}); - files.put("library/vanilla_1.11/manifest.json", new byte[] {'{', '}'}); - final var library = BedrockResourceLibrary.read(files); - final var packs = library.resourcePacks(); - assertEquals(4, packs.size()); - assertArrayEquals(new byte[] {1}, packs.get(0).content().get("textures/items/shared.png")); -+ assertArrayEquals(new byte[] {3}, packs.get(0).content().get("ui/ui_common.json")); -+ assertArrayEquals(new byte[] {4}, packs.get(0).content().get("textures/items/shared.json")); - assertArrayEquals(new byte[] {2}, packs.get(1).content().get("textures/items/CaseSensitive.jpg")); - assertNull(packs.get(1).content().get("textures/items/casesensitive.jpg")); - assertArrayEquals(new byte[] {9}, packs.get(2).content().get("textures/items/shared.png")); -diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorMaterialsGpuTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorMaterialsGpuTest.java -index 3f42496..74c05a3 100644 ---- a/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorMaterialsGpuTest.java -+++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorMaterialsGpuTest.java -@@ -37,6 +37,22 @@ final class BedrockActorMaterialsGpuTest { - EglTestContext.run(() -> verify(cases)); - } - -+ @Test -+ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_MASKED_MATERIALS", matches = ".+") -+ void retainsGuiCoverageWithoutReusingTextureAlphaAsOpacity() throws IOException { -+ final var cases = new com.google.gson.JsonArray(); -+ for (final String variable : new String[] {"STACKANVIL_NATIVE_ACTOR_MATERIALS", "STACKANVIL_NATIVE_MASKED_MATERIALS"}) { -+ final var controls = JsonParser.parseString(Files.readString(Path.of(System.getenv(variable)))) -+ .getAsJsonObject().getAsJsonArray("cases"); -+ for (final var element : controls) for (final float opacity : new float[] {0F, .25F, 1F}) { -+ final var control = element.getAsJsonObject().deepCopy(); -+ control.getAsJsonArray("vertex").set(3, new com.google.gson.JsonPrimitive(opacity)); -+ cases.add(control); ++ 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])); + } + } -+ EglTestContext.run(() -> verify(cases, true)); ++ return List.copyOf(result); + } + - @Test - @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_TINT_MATERIALS", matches = ".+") - void matchesNativeColorMaskAlphaTintLightingFogAndDepth() throws IOException { -@@ -55,6 +71,10 @@ final class BedrockActorMaterialsGpuTest { - } - - private static void verify(final com.google.gson.JsonArray cases) { -+ verify(cases, false); ++ 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); ++ } + } + -+ private static void verify(final com.google.gson.JsonArray cases, final boolean gui) { - final int vao = glGenVertexArrays(), framebuffer = glGenFramebuffers(); - final int color = glGenTextures(), depth = glGenTextures(), source = glGenTextures(), secondary = glGenTextures(); - final int fog = glGenBuffers(), dynamic = glGenBuffers(), tint = glGenBuffers(); -@@ -70,7 +90,10 @@ final class BedrockActorMaterialsGpuTest { - glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, depth, 0); - assertEquals(GL_FRAMEBUFFER_COMPLETE, glCheckFramebufferStatus(GL_FRAMEBUFFER)); - glViewport(0, 0, 1, 1); -- glDisable(GL_BLEND); -+ if (gui) { -+ glEnable(GL_BLEND); -+ glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA); -+ } else glDisable(GL_BLEND); - glEnable(GL_DEPTH_TEST); - glDepthFunc(GL_LESS); - glDepthMask(true); -@@ -80,7 +103,8 @@ final class BedrockActorMaterialsGpuTest { - final var material = new BedrockActorMaterialDefinitions.Material(masked || control.get("alphaTest").getAsBoolean(), - false, !masked && control.get("emissive").getAsBoolean(), false, masked, false, BedrockActorDepth.DEFAULT, control.has("tint"), - !control.has("alphaWrite") || control.get("alphaWrite").getAsBoolean()); -- final var pipeline = BedrockActorRenderTypes.selectPipeline(material, -+ final var pipeline = gui ? BedrockActorRenderTypes.selectGuiPipeline(material, -+ !masked && control.get("ignoreLighting").getAsBoolean()) : BedrockActorRenderTypes.selectPipeline(material, - !masked && control.get("ignoreLighting").getAsBoolean(), false); - final int program = programs.computeIfAbsent(pipeline, BedrockActorMaterialsGpuTest::program); - glUseProgram(program); -@@ -117,7 +141,8 @@ final class BedrockActorMaterialsGpuTest { ++ 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}; ++ text.visit((style, value) -> { ++ final StringBuilder run = new StringBuilder(); ++ for (int i = 0; i < value.length(); i++) { ++ final char character = value.charAt(i); ++ 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] = 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]); ++ current[0] = Component.empty(); ++ } else run.append(character); ++ } ++ current[0].append(Component.literal(run.toString()).withStyle(style)); ++ return Optional.empty(); ++ }, Style.EMPTY); ++ lines.add(current[0]); ++ return List.copyOf(lines); ++ } ++ ++ public static Component component(final String text) { ++ return ComponentSerialization.CODEC.parse(JsonOps.INSTANCE, JsonParser.parseString(TextUtil.stringToJson(text))).getOrThrow(); ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiToggle.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiToggle.java +new file mode 100644 +index 0000000..e63749c +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiToggle.java +@@ -0,0 +1,94 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.ui; ++ ++import com.google.gson.JsonObject; ++import java.math.BigInteger; ++ ++/** Native toggle initialization and component events; the screen owns collection indices and radio traversal. */ ++public record NativeUiToggle(boolean radio, boolean defaultChecked, boolean directional, boolean toggleOnHover, ++ int group, int onButton, int offButton, int forcedIndex, int defaultSelected) { ++ public static NativeUiToggle parse(final JsonObject properties) { ++ return new NativeUiToggle(NativeUiScene.flag(properties, "radio_toggle_group", false), ++ NativeUiScene.flag(properties, "toggle_default_state", false), ++ NativeUiScene.flag(properties, "enable_directional_toggling", false), ++ NativeUiScene.flag(properties, "toggle_on_hover", false), ++ NativeUiInput.symbol(string(properties, "toggle_name")), NativeUiInput.symbol(string(properties, "toggle_on_button")), ++ NativeUiInput.symbol(string(properties, "toggle_off_button")), integer(properties, "toggle_group_forced_index", -1), ++ integer(properties, "toggle_group_default_selected", 0)); ++ } ++ ++ private static String string(final JsonObject properties, final String key) { ++ final var value = properties.get(key); ++ return value != null && value.isJsonPrimitive() && value.getAsJsonPrimitive().isString() ? value.getAsString() : ""; ++ } ++ ++ private static int integer(final JsonObject properties, final String key, final int fallback) { ++ final var value = properties.get(key); ++ if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber()) return fallback; ++ final String encoded = value.getAsString(); ++ if (encoded.indexOf('.') >= 0 || encoded.indexOf('e') >= 0 || encoded.indexOf('E') >= 0) return fallback; ++ try { ++ final var integer = new BigInteger(encoded); ++ if (integer.bitLength() > 63) return fallback; ++ return integer.intValue(); ++ } catch (final NumberFormatException invalid) { ++ return fallback; ++ } ++ } ++ ++ public int index(final int collectionIndex) { return this.forcedIndex >= 0 ? this.forcedIndex : collectionIndex; } ++ ++ public boolean initialChecked(final int collectionIndex) { ++ return this.radio ? this.index(collectionIndex) == this.defaultSelected : this.defaultChecked; ++ } ++ ++ public static final class State { ++ private final NativeUiToggle definition; ++ private final int index; ++ private boolean checked; ++ private boolean hovered; ++ ++ public State(final NativeUiToggle definition, final int collectionIndex) { ++ this.definition = definition; ++ this.index = definition.index(collectionIndex); ++ this.checked = definition.initialChecked(collectionIndex); ++ } ++ ++ public boolean checked() { return this.checked; } ++ public void bindChecked(final boolean checked) { this.checked = checked; } ++ ++ public Change button(final NativeUiInput.ButtonEvent event, final boolean enabled) { ++ if (!enabled || !event.transition()) return null; ++ if (event.target() == this.definition.onButton) { ++ if (!this.definition.directional || this.checked) return null; ++ this.checked = true; ++ } else if (event.target() == this.definition.offButton) { ++ if (!this.definition.directional || !this.checked) return null; ++ this.checked = this.definition.radio; ++ } else this.checked = this.definition.radio || !this.checked; ++ return this.change(true); ++ } ++ ++ public Change hover(final boolean hovered) { ++ if (this.hovered == hovered) return null; ++ this.hovered = hovered; ++ if (!hovered || !this.definition.toggleOnHover) return null; ++ this.checked = this.definition.radio || !this.checked; ++ return this.change(false); ++ } ++ ++ private Change change(final boolean notify) { ++ return new Change(this.definition.group, this.index, this.checked, notify, this.definition.radio); ++ } ++ ++ public void sibling(final Change change) { ++ if (change.radio && change.group == this.definition.group && change.index != this.index) this.checked = false; ++ } ++ } ++ ++ public record Change(int group, int index, boolean checked, boolean notifyController, boolean radio) {} ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiViewport.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiViewport.java +new file mode 100644 +index 0000000..5567c1e +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiViewport.java +@@ -0,0 +1,47 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.screen.ui; ++ ++import com.viaversion.viafabricplus.bedrock.ViaFabricPlusBedrock; ++import net.minecraft.client.Minecraft; ++ ++/** Desktop GuiData units, independent of Java's HUD scale. */ ++public record NativeUiViewport(int width, int height, int scale, float renderScale, ++ double inputScaleX, double inputScaleY) { ++ /** Explicit drawing-only viewports have uniform input units without host-window rounding. */ ++ public NativeUiViewport(final int width, final int height, final int scale, final float renderScale) { ++ this(width, height, scale, renderScale, 1D / renderScale, 1D / renderScale); ++ } ++ ++ public static NativeUiViewport desktop(final int width, final int height, final int offset, final int javaScale) { ++ if (width < 1 || height < 1 || javaScale < 1) ++ throw new IllegalArgumentException("Invalid native UI viewport"); ++ return desktop(width, height, offset, javaScale, Math.ceilDiv(width, javaScale), Math.ceilDiv(height, javaScale)); ++ } ++ ++ private static NativeUiViewport desktop(final int width, final int height, final int offset, final int javaScale, ++ final int inputWidth, final int inputHeight) { ++ if (width < 1 || height < 1 || javaScale < 1 || inputWidth < 1 || inputHeight < 1) ++ throw new IllegalArgumentException("Invalid native UI viewport"); ++ // Target AppPlatform desktop type 0: minimum viewport 376 x 250, scale table 1..8. ++ final int maximum = Math.clamp(Math.min(width / 376, height / 250), 1, 8); ++ final int minimum = (maximum + 1) / 2; ++ final int scale = maximum + Math.clamp(offset, minimum - maximum, 0); ++ return new NativeUiViewport(Math.ceilDiv(width, scale), Math.ceilDiv(height, scale), scale, ++ (float) scale / javaScale, (double) width / inputWidth / scale, (double) height / inputHeight / scale); ++ } ++ ++ public static NativeUiViewport current() { ++ final var window = Minecraft.getInstance().getWindow(); ++ return desktop(window.getWidth(), window.getHeight(), ++ ViaFabricPlusBedrock.impl().settings().nativeUiScale().value().offset(), window.getGuiScale(), ++ window.getGuiScaledWidth(), window.getGuiScaledHeight()); ++ } ++ ++ public double pointerX(final double javaCoordinate) { return javaCoordinate * this.inputScaleX; } ++ public double pointerY(final double javaCoordinate) { return javaCoordinate * this.inputScaleY; } ++ public int pixel(final float nativeCoordinate) { return Math.round(nativeCoordinate * this.renderScale); } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/settings/base/NativeUiScale.java b/src/main/java/com/viaversion/viafabricplus/bedrock/settings/base/NativeUiScale.java +new file mode 100644 +index 0000000..b3f8333 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/settings/base/NativeUiScale.java +@@ -0,0 +1,27 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.settings.base; ++ ++import com.viaversion.viafabricplus.api.settings.base.EnumSetting; ++import net.minecraft.network.chat.Component; ++ ++/** Desktop GuiData offsets; each viewport clamps the preference to its supported range. */ ++public enum NativeUiScale implements EnumSetting.EnumValue { ++ DEFAULT(0), SMALLER(-1), SMALLER_TWO(-2), SMALLER_THREE(-3), SMALLEST(-4); ++ ++ private final int offset; ++ ++ NativeUiScale(final int offset) { this.offset = offset; } ++ ++ public int offset() { return this.offset; } ++ ++ @Override ++ public Component component() { ++ return this == DEFAULT ++ ? Component.translatable("bedrock_settings.viafabricplus.native_ui_scale.default") ++ : Component.literal(Integer.toString(this.offset)); ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/settings/impl/BedrockSettings.java b/src/main/java/com/viaversion/viafabricplus/bedrock/settings/impl/BedrockSettings.java +index a0f9d2f..a2c1af8 100644 +--- a/src/main/java/com/viaversion/viafabricplus/bedrock/settings/impl/BedrockSettings.java ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/settings/impl/BedrockSettings.java +@@ -21,6 +21,9 @@ + + package com.viaversion.viafabricplus.bedrock.settings.impl; + ++import com.viaversion.viafabricplus.bedrock.settings.base.NativeUiScale; ++ ++ + import com.viaversion.viafabricplus.bedrock.settings.base.CaptionDuration; + import com.viaversion.viafabricplus.bedrock.settings.base.CaptionPosition; + import com.viaversion.viafabricplus.bedrock.settings.base.ParticleViewDistanceSetting; +@@ -46,6 +49,7 @@ public final class BedrockSettings { + private final BooleanSetting muteEmoteChat; + private final BooleanSetting alwaysUseJavaHand; + private final BooleanSetting allowCameraShake; ++ private final EnumSetting nativeUiScale; + private final ParticleViewDistanceSetting particleViewDistance; + private final BooleanSetting captions; + private final BooleanSetting hideOwnCaptions; +@@ -63,6 +67,7 @@ public final class BedrockSettings { + this.muteEmoteChat = group.registerBoolean("mute_emote_chat", false); + this.alwaysUseJavaHand = group.registerBoolean("always_use_java_hand", false); + this.allowCameraShake = group.registerBoolean("allow_camera_shake", true); ++ this.nativeUiScale = group.registerEnum("native_ui_scale", NativeUiScale.DEFAULT); + this.particleViewDistance = new ParticleViewDistanceSetting(); + group.register("particle_view_distance", this.particleViewDistance); + this.captions = group.registerBoolean("captions", false); +@@ -91,6 +96,8 @@ public final class BedrockSettings { + + public BooleanSetting allowCameraShake() { return this.allowCameraShake; } + ++ public EnumSetting nativeUiScale() { return this.nativeUiScale; } ++ + public ParticleViewDistanceSetting particleViewDistance() { return this.particleViewDistance; } + + public BooleanSetting captions() { return this.captions; } +diff --git a/src/main/resources/assets/viafabricplus-bedrock/lang/en_us.json b/src/main/resources/assets/viafabricplus-bedrock/lang/en_us.json +index 545727e..d0fac3c 100644 +--- a/src/main/resources/assets/viafabricplus-bedrock/lang/en_us.json ++++ b/src/main/resources/assets/viafabricplus-bedrock/lang/en_us.json +@@ -388,6 +388,8 @@ + "bedrock_marketplace.viafabricplus.wallet_sign_in": "Minecoins: sign in", + "bedrock_marketplace.viafabricplus.wallet_refresh": "Refresh balance", + "bedrock_marketplace.viafabricplus.wallet_sign_in_short": "Sign in", ++ "bedrock_settings.viafabricplus.native_ui_scale": "Bedrock UI scale offset", ++ "bedrock_settings.viafabricplus.native_ui_scale.default": "Default (0)", + "bedrock_settings.viafabricplus.allow_camera_shake": "Allow camera shake", + "bedrock_settings.viafabricplus.particle_view_distance": "Particle render distance", + "bedrock_settings.viafabricplus.captions": "Bedrock closed captions", +diff --git a/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_actor.fsh b/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_actor.fsh +index 3eef652..7f0a0e8 100644 +--- a/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_actor.fsh ++++ b/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_actor.fsh +@@ -95,6 +95,11 @@ void main() { + color.rgb *= lightMapColor.rgb; + #endif + #endif ++#ifdef GUI_PORTRAIT ++ // Source alpha controls cutout and emission above. Surviving GUI fragments ++ // carry material coverage, with control opacity independent of that sample. ++ color.a = vertexColor.a * ColorModulator.a; ++#endif + #ifndef PATTERN + fragColor = apply_fog(color, sphericalVertexDistance, cylindricalVertexDistance, + FogEnvironmentalStart, FogEnvironmentalEnd, FogRenderDistanceStart, +@@ -113,6 +118,9 @@ void main() { + #else + fragColor.rgb += glint * glint; + fragColor.a = abs((firstGlint.r + secondGlint.r) * BedrockGlintColor.a * BedrockGlintTileLight.a); ++#ifdef GUI_PORTRAIT ++ fragColor.a = vertexColor.a * ColorModulator.a; ++#endif + #endif + #endif + #ifdef PATTERN +diff --git a/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_bitmap_font.fsh b/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_bitmap_font.fsh +new file mode 100644 +index 0000000..caf1dcd +--- /dev/null ++++ b/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_bitmap_font.fsh +@@ -0,0 +1,26 @@ ++#version 330 ++#extension GL_ARB_separate_shader_objects : require ++#include ++ ++uniform sampler2D Sampler0; ++layout(location = 0) in vec4 vertexColor; ++layout(location = 1) in vec2 glyphUv; ++layout(location = 2) flat in vec4 glyphBounds; ++layout(location = 3) noperspective in float glyphGuiY; ++layout(location = 0) out vec4 fragColor; ++ ++void main() { ++ // Preserve the unshifted sample position while selecting native top-edge coverage. ++ vec2 sourceUv = glyphUv - dFdy(glyphUv) * sign(dFdy(glyphGuiY)) / 256.0; ++ vec2 uv = clamp(sourceUv, glyphBounds.xy, glyphBounds.zw); ++#ifdef BITMAP_SMOOTH ++ vec4 color = texture(Sampler0, uv); ++ color = clamp((color - 0.3) * 5.000000476837158, 0.0, 1.0); ++ color = color * color * (3.0 - 2.0 * color); ++#else ++ // Preserve native point addresses when float interpolation lands just below a texel boundary. ++ vec2 texels = roundEven(uv * vec2(textureSize(Sampler0, 0)) * 256.0) / 256.0; ++ vec4 color = texelFetch(Sampler0, ivec2(floor(texels)), 0); ++#endif ++ fragColor = color * vertexColor * ColorModulator; ++} +diff --git a/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_bitmap_font.vsh b/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_bitmap_font.vsh +new file mode 100644 +index 0000000..1564b4b +--- /dev/null ++++ b/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_bitmap_font.vsh +@@ -0,0 +1,29 @@ ++#version 330 ++#extension GL_ARB_separate_shader_objects : require ++#include ++#include ++ ++uniform sampler2D Sampler0; ++layout(location = 0) in vec3 Position; ++layout(location = 1) in vec4 Color; ++layout(location = 2) in vec2 UV0; ++layout(location = 3) in ivec2 UV1; ++layout(location = 4) in vec2 UV3; ++layout(location = 0) out vec4 vertexColor; ++layout(location = 1) out vec2 glyphUv; ++layout(location = 2) flat out vec4 glyphBounds; ++layout(location = 3) noperspective out float glyphGuiY; ++ ++void main() { ++ gl_Position = ProjMat * ModelViewMat * vec4(Position, 1.0); ++ vec2 page = vec2(textureSize(Sampler0, 0)); ++ vec2 sourceOrigin = vec2(UV1) / page; ++ // Native packs 16.16 origins into an unsigned-normalized 16-bit attribute. ++ vec2 origin = clamp(floor(sourceOrigin * 65536.0), 0.0, 65535.0) / 65535.0; ++ vec2 extent = UV3 / page; ++ vec2 corner = vec2(greaterThan(UV0, sourceOrigin + extent * 0.5)); ++ glyphUv = origin + corner * extent; ++ glyphBounds = vec4(origin + 0.5 / page, origin + extent - 0.5 / page); ++ vertexColor = Color; ++ glyphGuiY = Position.y; ++} +diff --git a/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_oreui_font.fsh b/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_oreui_font.fsh +new file mode 100644 +index 0000000..5fafd95 +--- /dev/null ++++ b/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_oreui_font.fsh +@@ -0,0 +1,27 @@ ++#version 330 ++#extension GL_ARB_separate_shader_objects : require ++ ++uniform sampler2D Sampler0; ++layout(location = 0) in vec4 nativeTint; ++layout(location = 1) in vec2 glyphUv; ++layout(location = 2) in float coverageRatio; ++layout(location = 0) out vec4 fragColor; ++ ++void main() { ++ float coverage = texture(Sampler0, glyphUv).r; ++#ifdef OREUI_SDF ++ float distance = (0.501960813999176 / coverageRatio - 3.984375) + coverage * 7.96875; ++ coverage = clamp((coverageRatio * 0.9960936903953552) * distance, 0.0, 1.0); ++ coverage = (coverage * coverage) * (3.0 - coverage * 2.0); ++#endif ++ float luminance = (nativeTint.r * 0.2125999927520752 + nativeTint.g * 0.7152000069618225) ++ + nativeTint.b * 0.0722000002861023; ++#ifdef OREUI_LINEAR_TINT ++ luminance = luminance > 0.0031308000907301903 ++ ? exp2(log2(luminance) * 0.4166666567325592) * 1.0549999475479126 - 0.054999999701976776 ++ : luminance * 12.920000076293945; ++#endif ++ float exponent = max(abs(1.4500000476837158 - luminance), 0.00009999999747378752); ++ coverage = coverage == 0.0 ? 0.0 : exp2(log2(abs(coverage)) * exponent); ++ fragColor = nativeTint * coverage; ++} +diff --git a/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_oreui_font.vsh b/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_oreui_font.vsh +new file mode 100644 +index 0000000..e51e21b +--- /dev/null ++++ b/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_oreui_font.vsh +@@ -0,0 +1,22 @@ ++#version 330 ++#extension GL_ARB_separate_shader_objects : require ++#include ++#include ++ ++uniform sampler2D Sampler0; ++layout(location = 0) in vec3 Position; ++layout(location = 1) in vec2 UV0; ++layout(location = 2) in ivec2 UV1; ++layout(location = 3) in vec2 UV3; ++layout(location = 4) in float LineWidth; ++layout(location = 0) out vec4 nativeTint; ++layout(location = 1) out vec2 glyphUv; ++layout(location = 2) out float coverageRatio; ++ ++void main() { ++ gl_Position = ProjMat * ModelViewMat * vec4(Position, 1.0); ++ // Float payloads retain premultiplied native tint without an eight-bit color conversion. ++ nativeTint = vec4(UV3, UV0); ++ glyphUv = vec2(UV1) / vec2(textureSize(Sampler0, 0)); ++ coverageRatio = LineWidth; ++} +diff --git a/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_truetype_font.fsh b/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_truetype_font.fsh +new file mode 100644 +index 0000000..ecc427e +--- /dev/null ++++ b/src/main/resources/assets/viafabricplus-bedrock/shaders/core/bedrock_truetype_font.fsh +@@ -0,0 +1,10 @@ ++#version 330 ++#extension GL_ARB_separate_shader_objects : require ++#include ++uniform sampler2D Sampler0; ++layout(location = 2) in vec4 vertexColor; ++layout(location = 3) in vec2 texCoord0; ++layout(location = 0) out vec4 fragColor; ++void main() { ++ fragColor = texture(Sampler0, texCoord0).rrrr * vertexColor * ColorModulator; ++} +diff --git a/src/main/resources/viafabricplus-bedrock.accesswidener b/src/main/resources/viafabricplus-bedrock.accesswidener +index 8e8672c..288cc7a 100644 +--- a/src/main/resources/viafabricplus-bedrock.accesswidener ++++ b/src/main/resources/viafabricplus-bedrock.accesswidener +@@ -12,3 +12,7 @@ accessible method net/minecraft/client/renderer/rendertype/RenderType create (Lj + + accessible field net/minecraft/client/renderer/RenderPipelines ENTITY_NO_LIGHTMAP_SNIPPET Lcom/mojang/renderpearl/api/pipeline/RenderPipeline$Snippet; + accessible field net/minecraft/client/renderer/RenderPipelines EYES_SNIPPET Lcom/mojang/renderpearl/api/pipeline/RenderPipeline$Snippet; ++accessible method net/minecraft/client/gui/GuiGraphicsExtractor innerBlit (Lcom/mojang/renderpearl/api/pipeline/RenderPipeline;Lnet/minecraft/resources/Identifier;IIIIFFFFI)V ++accessible class net/minecraft/client/gui/GuiGraphicsExtractor$ScissorStack ++accessible field net/minecraft/client/gui/GuiGraphicsExtractor scissorStack Lnet/minecraft/client/gui/GuiGraphicsExtractor$ScissorStack; ++accessible field net/minecraft/client/gui/GuiGraphicsExtractor$ScissorStack stack Ljava/util/Deque; +diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json +index fc4fb65..f357e72 100644 +--- a/src/main/resources/viafabricplus-bedrock.mixins.json ++++ b/src/main/resources/viafabricplus-bedrock.mixins.json +@@ -6,6 +6,7 @@ + "compatibilityLevel": "JAVA_25", + "priority": 1001, + "client": [ ++ "features.screen.GuiGraphicsExtractorAccess", + "compat.emf.MixinEmfModelRoots", + "features.render.MixinNativeFluidMesh", + "features.render.MixinNativeFluidNeighbors", +@@ -151,6 +152,13 @@ + "features.render.MixinBedrockModelPartTransforms", + "features.render.MixinPlayerSkinWidget", + "features.render.MixinGuiSkinRenderer", ++ "features.render.MixinGuiEntityRenderer", ++ "features.render.MixinPictureInPictureRenderer", ++ "features.render.MixinNativeUiPictures", ++ "features.render.MixinBedrockBitmapFont", ++ "features.render.MixinBedrockTrueTypeAtlas", ++ "features.render.MixinBedrockTrueTypeFont", ++ "features.render.MixinBedrockBitmapGlyphRenderState", + "features.input.MixinKeyboardInput", + "features.input.MixinLocalPlayerInput", + "features.input.MixinClientInput", +@@ -207,7 +215,13 @@ + "features.render.MixinBedrockCameraFade", + "features.entities.MixinClientEntityTracking", + "features.entities.MixinMinecraftProjectileOwners", +- "features.entities.MixinFishingHookOwner" ++ "features.entities.MixinFishingHookOwner", ++ "features.input.MixinNativeUiInput", ++ "features.render.MixinNativeUiExtraction", ++ "features.render.MixinNativeUiScissorArea", ++ "features.render.MixinNativeUiScissorRenderer", ++ "features.render.MixinNativeHudOverlays", ++ "features.render.MixinNativeUiViewport" + ], + "injectors": { + "defaultRequire": 1 +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockBitmapFontResourcesTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockBitmapFontResourcesTest.java +new file mode 100644 +index 0000000..19aa549 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockBitmapFontResourcesTest.java +@@ -0,0 +1,67 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.appearance; ++ ++import java.awt.image.BufferedImage; ++import java.io.ByteArrayOutputStream; ++import java.nio.charset.StandardCharsets; ++import java.util.ArrayList; ++import java.util.List; ++import java.util.Map; ++import java.util.UUID; ++import javax.imageio.ImageIO; ++import net.raphimc.viabedrock.api.resourcepack.ResourcePack; ++import net.raphimc.viabedrock.api.resourcepack.content.Content; ++import net.raphimc.viabedrock.api.resourcepack.content.InMemoryContent; ++import net.raphimc.viabedrock.protocol.rewriter.resourcepack.GlyphSheetResourceRewriter; ++import net.raphimc.viabedrock.protocol.storage.ResourcePackStorage; ++import org.junit.jupiter.api.Test; ++import static org.junit.jupiter.api.Assertions.*; ++ ++final class BedrockBitmapFontResourcesTest { ++ @Test ++ void carriesLicensedAtlasLayoutIntoConversionAndKeepsLegacyServerTextureOwnership() throws Exception { ++ final var source = new BufferedImage(128, 128, BufferedImage.TYPE_INT_ARGB); ++ source.setRGB(0, 0, 0xFF123456); ++ final var encoded = new ByteArrayOutputStream(); ++ ImageIO.write(source, "png", encoded); ++ final byte[] atlas = encoded.toByteArray(); ++ final byte[] metadata = "{\"ascii_extended\":true}".getBytes(StandardCharsets.UTF_8); ++ final var library = BedrockResourceLibrary.read(Map.of( ++ "library/vanilla/font/default8.png", atlas, ++ "library/vanilla/font/default8.json", metadata)); ++ final var nativePacks = library.resourcePacks(); ++ assertSame(atlas, nativePacks.getFirst().content().get("font/default8.png")); ++ assertSame(metadata, nativePacks.getFirst().content().get("font/default8.json")); ++ final var extended = convert(new ResourcePackStorage(List.of(), nativePacks)); ++ assertEquals(12, firstProvider(extended).get("height").getAsInt()); ++ assertEquals(120, extended.getImage("assets/viabedrock/textures/font/bedrock_bitmap/default8_0.png").getImage().getHeight()); ++ ++ final var legacyContent = new InMemoryContent(); ++ legacyContent.putString("manifest.json", "{\"format_version\":2,\"header\":{\"name\":\"legacy atlas\",\"uuid\":\"" ++ + UUID.randomUUID() + "\",\"version\":[1,0,0]}}"); ++ source.setRGB(0, 0, 0xFF654321); ++ legacyContent.putPngImage("font/default8.png", source); ++ final var legacy = convert(new ResourcePackStorage(List.of(new ResourcePack(legacyContent)), nativePacks)); ++ assertEquals(8, firstProvider(legacy).get("height").getAsInt()); ++ final var image = legacy.getImage("assets/viabedrock/textures/font/bedrock_bitmap/default8_0.png").getImage(); ++ assertEquals(128, image.getHeight()); ++ assertEquals(0xFF654321, image.getRGB(0, 0)); ++ // Converting the upper pack must not mutate the cached licensed view. ++ assertEquals(12, firstProvider(convert(new ResourcePackStorage(List.of(), nativePacks))).get("height").getAsInt()); ++ } ++ ++ private static Content convert(final ResourcePackStorage storage) { ++ final List result = new ArrayList<>(); ++ new GlyphSheetResourceRewriter().submitTasks(storage, supplier -> result.add(supplier.get())); ++ assertEquals(1, result.size()); ++ return result.getFirst(); ++ } ++ ++ private static com.viaversion.viaversion.libs.gson.JsonObject firstProvider(final Content content) { ++ return content.getJson("assets/minecraft/font/default.json").getAsJsonArray("providers").get(0).getAsJsonObject(); ++ } ++} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockGuiActorAppearanceTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockGuiActorAppearanceTest.java +new file mode 100644 +index 0000000..eb50b32 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockGuiActorAppearanceTest.java +@@ -0,0 +1,92 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.appearance; ++ ++import com.viaversion.viafabricplus.bedrock.render.BedrockGuiActorContext; ++import java.io.IOException; ++import java.nio.charset.StandardCharsets; ++import java.util.Map; ++import java.util.UUID; ++import org.junit.jupiter.api.Test; ++import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++final class BedrockGuiActorAppearanceTest { ++ @Test ++ void samplesVariantSelectionAndPoseInSeparatePersistentPerspectives() throws Exception { ++ final var library = BedrockResourceLibrary.read(Map.of( ++ "library/vanilla/entity/probe.json", bytes(""" ++ {"minecraft:client_entity":{"description":{"identifier":"test:probe", ++ "materials":{"default":"npc"},"geometry":{"default":"geometry.probe"}, ++ "textures":{"first":"textures/first","second":"textures/second"}, ++ "animations":{"pose":"animation.probe"},"scripts":{"initialize":["v.counter=0;"], ++ "pre_animation":["v.counter=v.counter+1;"],"animate":["pose"]}, ++ "render_controllers":["controller.render.probe"]}}} ++ """), ++ "library/vanilla/animations/probe.json", bytes(""" ++ {"animations":{"animation.probe":{"loop":true,"bones":{"head":{ ++ "position":["v.counter",0,0],"rotation":["q.target_x_rotation","q.target_y_rotation",0]}}}}} ++ """), ++ "library/vanilla/render_controllers/probe.json", bytes(""" ++ {"render_controllers":{"controller.render.probe":{ ++ "arrays":{"textures":{"Array.skins":["Texture.first","Texture.second"]}}, ++ "geometry":"Geometry.default","materials":[{"*":"Material.default"}], ++ "textures":["Array.skins[q.variant]"],"part_visibility":[{"*":"q.is_in_ui"}]}}} ++ """), ++ "library/vanilla/materials/probe.material", bytes(""" ++ {"materials":{"npc:entity_alphatest":{}}} ++ """))); ++ final var first = BedrockGuiActorAppearance.load(library, "test:probe"); ++ final var second = BedrockGuiActorAppearance.load(library, "test:probe"); ++ try (final var context = new BedrockGuiActorContext(UUID.randomUUID(), UUID.randomUUID())) { ++ final var a = sample(context, first, 0, 0); ++ final var b = sample(context, first, 1, 1); ++ final var c = sample(context, second, 0, 1); ++ assertEquals(1, a.pose().get("head").position().x()); ++ assertEquals(2, b.pose().get("head").position().x()); ++ assertEquals(1, c.pose().get("head").position().x()); ++ assertEquals(12, b.pose().get("head").rotation().x()); ++ assertEquals(-8, b.pose().get("head").rotation().y()); ++ assertNotEquals(a.draws().surfaces().getFirst().textures().getFirst(), b.draws().surfaces().getFirst().textures().getFirst()); ++ assertEquals(b.draws(), c.draws()); ++ assertTrue(b.draws().surfaces().getFirst().visibility().getFirst().visible()); ++ assertTrue(b.draws().surfaces().getFirst().materials().values().stream().allMatch(name -> first.materials().material(name).isPresent())); ++ } ++ assertThrows(IllegalStateException.class, () -> first.sample(2, null, 0, 0, 0)); ++ } ++ ++ @Test ++ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_NPC_ASSETS", matches = ".+") ++ void resolvesEveryLicensedNpcVariantWithItsNativeGeometryTextureAndMaterial() throws Exception { ++ final var library = BedrockBuiltinAssets.load().library(); ++ final var appearance = BedrockGuiActorAppearance.load(library, "minecraft:npc"); ++ final var selected = new java.util.HashSet(); ++ try (final var context = new BedrockGuiActorContext(UUID.randomUUID(), UUID.randomUUID())) { ++ for (int variant = 0; variant < 60; variant++) { ++ final var frame = sample(context, appearance, variant / 60F, variant); ++ assertEquals(1, frame.draws().surfaces().size()); ++ final var draw = frame.draws().surfaces().getFirst(); ++ assertTrue(selected.add(draw.textures().getFirst())); ++ assertFalse(appearance.model(draw.geometry()).getParents().isEmpty()); ++ assertNotNull(appearance.image(draw.textures().getFirst())); ++ assertTrue(draw.materials().values().stream().allMatch(name -> appearance.materials().material(name).isPresent())); ++ assertFalse(frame.pose().isEmpty()); ++ } ++ } ++ } ++ ++ private static BedrockGuiActorAppearance.Frame sample(final BedrockGuiActorContext context, ++ final BedrockGuiActorAppearance appearance, final float seconds, final int variant) { ++ context.camera(12, -8); ++ return context.scoped(() -> { ++ try { return appearance.sample(seconds, null, variant, 12, -8); } ++ catch (final IOException error) { throw new java.io.UncheckedIOException(error); } ++ }); ++ } ++ ++ private static byte[] bytes(final String json) { return json.getBytes(StandardCharsets.UTF_8); } ++} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockGuiActorContextTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockGuiActorContextTest.java +new file mode 100644 +index 0000000..11027a2 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockGuiActorContextTest.java +@@ -0,0 +1,140 @@ ++package com.viaversion.viafabricplus.bedrock.appearance; ++ ++import com.viaversion.viafabricplus.bedrock.render.BedrockGuiActorContext; ++ ++import com.google.gson.JsonObject; ++import com.google.gson.JsonParser; ++import com.viaversion.viafabricplus.bedrock.appearance.BedrockActorAnimation; ++import java.util.Map; ++import java.util.UUID; ++import org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++final class BedrockGuiActorContextTest { ++ @Test ++ void portraitFadeModulatesTheFinishedPremultipliedTextureWithoutChangingAnotherPerspective() { ++ try (final var fading = new BedrockGuiActorContext(UUID.randomUUID(), UUID.randomUUID()); ++ final var ordinary = new BedrockGuiActorContext(UUID.randomUUID(), UUID.randomUUID())) { ++ fading.opacity(.5F); ++ assertEquals(0x80808080, fading.compositeColor(0xFFFFFFFF)); ++ assertEquals(0x40201008, fading.compositeColor(0x80402010)); ++ assertEquals(0x80402010, ordinary.compositeColor(0x80402010)); ++ fading.opacity(0); ++ assertEquals(0, fading.compositeColor(0xFFFFFFFF)); ++ assertThrows(IllegalArgumentException.class, () -> fading.opacity(Float.NaN)); ++ } ++ } ++ ++ @Test ++ void restoresPictureSubmissionAfterNestedFailureWithoutCreatingAnActorPerspective() { ++ assertFalse(BedrockGuiActorContext.inPicture()); ++ assertNull(BedrockGuiActorContext.current()); ++ assertThrows(IllegalArgumentException.class, () -> BedrockGuiActorContext.picture(() -> { ++ assertTrue(BedrockGuiActorContext.inPicture()); ++ assertThrows(IllegalStateException.class, () -> BedrockGuiActorContext.picture(() -> { ++ assertTrue(BedrockGuiActorContext.inPicture()); ++ throw new IllegalStateException(); ++ })); ++ assertTrue(BedrockGuiActorContext.inPicture()); ++ assertNull(BedrockGuiActorContext.current()); ++ throw new IllegalArgumentException(); ++ })); ++ assertFalse(BedrockGuiActorContext.inPicture()); ++ assertNull(BedrockGuiActorContext.current()); ++ } ++ ++ @Test ++ void keepsGuiClockAndScriptVariablesSeparateFromWorldAndOtherPortraits() throws Exception { ++ final var graph = BedrockActorAnimation.read(JsonParser.parseString(""" ++ {"animations":{"idle":"animation.idle"},"scripts":{ ++ "initialize":["v.counter=0;"],"pre_animation":["v.counter=v.counter+1;"],"animate":["idle"]}} ++ """).getAsJsonObject(), Map.of(), JsonParser.parseString(""" ++ {"animation.idle":{"loop":true,"bones":{"head":{"position":["v.counter",0,0]}}}} ++ """).getAsJsonObject(), new JsonObject(), "1.10.0"); ++ final var world = graph.playback(); ++ final var frame = new BedrockActorAnimation.Frame(Map.of("is_in_ui", true), Map.of()); ++ final var gui = new BedrockGuiActorContext(UUID.randomUUID(), UUID.randomUUID()); ++ final var other = new BedrockGuiActorContext(UUID.randomUUID(), UUID.randomUUID()); ++ final var guiActor = gui.playback(world); ++ assertNotSame(world, guiActor); ++ assertSame(guiActor, gui.playback(world)); ++ assertEquals(1, guiActor.sample(0, frame).get("head").position().x()); ++ assertEquals(2, guiActor.sample(1, frame).get("head").position().x()); ++ assertEquals(1, world.sample(0, frame).get("head").position().x()); ++ assertEquals(1, other.playback(world).sample(0, frame).get("head").position().x()); ++ assertNotSame(guiActor, gui.playback(graph.playback())); ++ } ++ ++ @Test ++ void correctsPipUniformDepthScaleWithoutChangingWorldOrOrdinaryGuiTransforms() { ++ for (final int guiScale : new int[] {1, 2, 3}) { ++ final var pose = new com.mojang.blaze3d.vertex.PoseStack(); ++ pose.translate(40 * guiScale, 65 * guiScale, 0); ++ pose.scale(60 * guiScale, 60 * guiScale, -60 * guiScale); ++ pose.pushPose(); ++ BedrockGuiActorContext.correctPose(pose, 60, guiScale); ++ pose.translate(0, 0, -5); ++ final var matrix = pose.last().pose(); ++ assertEquals(60 * guiScale, matrix.m00(), .00001F); ++ assertEquals(-60 * guiScale, matrix.m11(), .00001F); ++ assertEquals(1, matrix.m22(), .00001F); ++ assertEquals(-5, matrix.m32(), .00001F); ++ assertEquals(40 * guiScale, matrix.m30(), .00001F); ++ pose.popPose(); ++ assertEquals(-60 * guiScale, pose.last().pose().m22(), .00001F); ++ } ++ } ++ ++ @Test ++ void retainsGuiEffectsAndRejectsUnimplementedSubmissionInsteadOfPlayingIntoWorld() throws Exception { ++ final var graph = BedrockActorAnimation.read(JsonParser.parseString("{\"sound_effects\":{\"click\":\"gui.click\"}}").getAsJsonObject(), ++ Map.of(), new JsonObject(), new JsonObject(), "1.10.0").playback(); ++ final var gui = new BedrockGuiActorContext(UUID.randomUUID(), UUID.randomUUID()); ++ gui.requireSupportedEffects(); ++ final var sound = graph.soundCue(new BedrockAnimationSound(0, "click", "head")); ++ gui.sound(sound); ++ assertEquals(java.util.List.of(sound), gui.sounds()); ++ assertThrows(IllegalArgumentException.class, gui::requireSupportedEffects); ++ gui.camera(0, 0); ++ gui.requireSupportedEffects(); ++ } ++ ++ @Test ++ void retiresExtractedFramesAndEquipmentResourcesWithoutTouchingAnotherPerspective() { ++ final var context = new BedrockGuiActorContext(UUID.randomUUID(), UUID.randomUUID()); ++ final var other = new BedrockGuiActorContext(UUID.randomUUID(), UUID.randomUUID()); ++ final var frame = new net.minecraft.client.renderer.entity.state.AvatarRenderState(); ++ final var otherFrame = new net.minecraft.client.renderer.entity.state.AvatarRenderState(); ++ context.scoped(() -> { BedrockGuiActorContext.extracted(frame); return null; }); ++ other.scoped(() -> { BedrockGuiActorContext.extracted(otherFrame); return null; }); ++ final Object key = new Object(); ++ final Object equipment = context.resource(key, "appearance", Object::new); ++ assertSame(equipment, context.resource(key, "appearance", Object::new)); ++ assertNotSame(equipment, context.resource(key, "replacement", Object::new)); ++ final Object replacement = context.resource(new Object(), "appearance", Object::new); ++ assertNotSame(equipment, replacement); ++ context.close(); ++ assertNull(BedrockGuiActorContext.frame(frame)); ++ assertSame(other, BedrockGuiActorContext.frame(otherFrame)); ++ assertNotSame(replacement, context.resource(key, "appearance", Object::new)); ++ other.close(); ++ } ++ ++ @Test ++ void restoresNestedGuiContextAfterSubmissionFailure() { ++ final var outer = new BedrockGuiActorContext(UUID.randomUUID(), UUID.randomUUID()); ++ final var inner = new BedrockGuiActorContext(UUID.randomUUID(), UUID.randomUUID()); ++ assertNull(BedrockGuiActorContext.current()); ++ outer.scoped(() -> { ++ assertSame(outer, BedrockGuiActorContext.current()); ++ assertThrows(IllegalStateException.class, () -> inner.scoped(() -> { ++ assertSame(inner, BedrockGuiActorContext.current()); ++ throw new IllegalStateException("submission failed"); ++ })); ++ assertSame(outer, BedrockGuiActorContext.current()); ++ return null; ++ }); ++ assertNull(BedrockGuiActorContext.current()); ++ } ++} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockResourceLibraryTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockResourceLibraryTest.java +index f07a19a..bf983a3 100644 +--- a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockResourceLibraryTest.java ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockResourceLibraryTest.java +@@ -176,6 +176,31 @@ final class BedrockResourceLibraryTest { + assertEquals("server", server.translate("fallback", "de_de")); + } + ++ @Test ++ void selectsTheHighestTexturePackBeforeSelectingAnImageFormat() throws Exception { ++ final var png = new java.awt.image.BufferedImage(1, 1, java.awt.image.BufferedImage.TYPE_INT_ARGB); ++ png.setRGB(0, 0, 0xFFFF0000); ++ final var bytes = new java.io.ByteArrayOutputStream(); ++ javax.imageio.ImageIO.write(png, "png", bytes); ++ final var files = Map.of("library/vanilla/textures/entity/probe.png", bytes.toByteArray()); ++ final var lower = BedrockResourceLibrary.read(files); ++ assertEquals(0xFFFF0000, lower.image("textures/entity/probe").getRGB(0, 0)); ++ ++ final var tga = java.nio.ByteBuffer.allocate(22).order(java.nio.ByteOrder.LITTLE_ENDIAN); ++ tga.put(2, (byte) 2); ++ tga.putShort(12, (short) 1).putShort(14, (short) 1); ++ tga.put(16, (byte) 32).put(17, (byte) 0x28); ++ tga.putInt(18, 0xFF00FF00); ++ final var higher = BedrockResourceLibrary.withServerPacks(lower, ++ List.of(Map.of("textures/entity/probe.tga", tga.array()))); ++ assertEquals(0xFF00FF00, higher.image("textures/entity/probe").getRGB(0, 0)); ++ assertEquals(0xFFFF0000, higher.image("textures/entity/probe.png").getRGB(0, 0)); ++ final var invalid = BedrockResourceLibrary.withServerPacks(lower, ++ List.of(Map.of("textures/entity/probe.png", new byte[] {0}))); ++ assertThrows(IOException.class, () -> invalid.image("textures/entity/probe")); ++ assertThrows(IOException.class, () -> higher.image("../probe")); ++ } ++ + @Test + void resolvesServerParticlesTexturesAndLevelSoundsAcrossPackOverrides() throws Exception { + final java.util.List packs = new java.util.ArrayList<>(); +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/ViaFabricPlusBuiltinResourcePackProviderTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/ViaFabricPlusBuiltinResourcePackProviderTest.java +index 85d180b..45825b5 100644 +--- a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/ViaFabricPlusBuiltinResourcePackProviderTest.java ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/ViaFabricPlusBuiltinResourcePackProviderTest.java +@@ -59,12 +59,16 @@ final class ViaFabricPlusBuiltinResourcePackProviderTest { + files.put("library/vanilla/textures/item_texture.json", new byte[] {'{', '}'}); + files.put("library/vanilla/textures/terrain_texture.json", new byte[] {'{', '}'}); + files.put("library/vanilla/textures/unrelated.json", new byte[] {'{', '}'}); ++ files.put("library/vanilla/ui/ui_common.json", new byte[] {3}); ++ files.put("library/vanilla/textures/items/shared.json", new byte[] {4}); + files.put("library/vanilla/entity/player.entity.json", new byte[] {'{', '}'}); + files.put("library/vanilla_1.11/manifest.json", new byte[] {'{', '}'}); + final var library = BedrockResourceLibrary.read(files); + final var packs = library.resourcePacks(); + assertEquals(4, packs.size()); + assertArrayEquals(new byte[] {1}, packs.get(0).content().get("textures/items/shared.png")); ++ assertArrayEquals(new byte[] {3}, packs.get(0).content().get("ui/ui_common.json")); ++ assertArrayEquals(new byte[] {4}, packs.get(0).content().get("textures/items/shared.json")); + assertArrayEquals(new byte[] {2}, packs.get(1).content().get("textures/items/CaseSensitive.jpg")); + assertNull(packs.get(1).content().get("textures/items/casesensitive.jpg")); + assertArrayEquals(new byte[] {9}, packs.get(2).content().get("textures/items/shared.png")); +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorMaterialsGpuTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorMaterialsGpuTest.java +index 3f42496..74c05a3 100644 +--- a/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorMaterialsGpuTest.java ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorMaterialsGpuTest.java +@@ -37,6 +37,22 @@ final class BedrockActorMaterialsGpuTest { + EglTestContext.run(() -> verify(cases)); + } + ++ @Test ++ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_MASKED_MATERIALS", matches = ".+") ++ void retainsGuiCoverageWithoutReusingTextureAlphaAsOpacity() throws IOException { ++ final var cases = new com.google.gson.JsonArray(); ++ for (final String variable : new String[] {"STACKANVIL_NATIVE_ACTOR_MATERIALS", "STACKANVIL_NATIVE_MASKED_MATERIALS"}) { ++ final var controls = JsonParser.parseString(Files.readString(Path.of(System.getenv(variable)))) ++ .getAsJsonObject().getAsJsonArray("cases"); ++ for (final var element : controls) for (final float opacity : new float[] {0F, .25F, 1F}) { ++ final var control = element.getAsJsonObject().deepCopy(); ++ control.getAsJsonArray("vertex").set(3, new com.google.gson.JsonPrimitive(opacity)); ++ cases.add(control); ++ } ++ } ++ EglTestContext.run(() -> verify(cases, true)); ++ } ++ + @Test + @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_TINT_MATERIALS", matches = ".+") + void matchesNativeColorMaskAlphaTintLightingFogAndDepth() throws IOException { +@@ -55,6 +71,10 @@ final class BedrockActorMaterialsGpuTest { + } + + private static void verify(final com.google.gson.JsonArray cases) { ++ verify(cases, false); ++ } ++ ++ private static void verify(final com.google.gson.JsonArray cases, final boolean gui) { + final int vao = glGenVertexArrays(), framebuffer = glGenFramebuffers(); + final int color = glGenTextures(), depth = glGenTextures(), source = glGenTextures(), secondary = glGenTextures(); + final int fog = glGenBuffers(), dynamic = glGenBuffers(), tint = glGenBuffers(); +@@ -70,7 +90,10 @@ final class BedrockActorMaterialsGpuTest { + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, depth, 0); + assertEquals(GL_FRAMEBUFFER_COMPLETE, glCheckFramebufferStatus(GL_FRAMEBUFFER)); + glViewport(0, 0, 1, 1); +- glDisable(GL_BLEND); ++ if (gui) { ++ glEnable(GL_BLEND); ++ glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA); ++ } else glDisable(GL_BLEND); + glEnable(GL_DEPTH_TEST); + glDepthFunc(GL_LESS); + glDepthMask(true); +@@ -80,7 +103,8 @@ final class BedrockActorMaterialsGpuTest { + final var material = new BedrockActorMaterialDefinitions.Material(masked || control.get("alphaTest").getAsBoolean(), + false, !masked && control.get("emissive").getAsBoolean(), false, masked, false, BedrockActorDepth.DEFAULT, control.has("tint"), + !control.has("alphaWrite") || control.get("alphaWrite").getAsBoolean()); +- final var pipeline = BedrockActorRenderTypes.selectPipeline(material, ++ final var pipeline = gui ? BedrockActorRenderTypes.selectGuiPipeline(material, ++ !masked && control.get("ignoreLighting").getAsBoolean()) : BedrockActorRenderTypes.selectPipeline(material, + !masked && control.get("ignoreLighting").getAsBoolean(), false); + final int program = programs.computeIfAbsent(pipeline, BedrockActorMaterialsGpuTest::program); + glUseProgram(program); +@@ -117,7 +141,8 @@ final class BedrockActorMaterialsGpuTest { glUniform1i(glGetUniformLocation(program, "BedrockMaskTexture"), 1); } glColorMask(true, true, true, true); @@ -10938,1039 +13311,2108 @@ index 3f42496..74c05a3 100644 + for (int i = 0; i < 3; i++) expected[i] *= opacity; + expected[3] = opacity; + } - if (!writeMask.writeAlpha()) expected[3] = 1F; - for (int i = 0; i < 4; i++) assertEquals(expected[i], pixel.get(i), .000001F, control::toString); - assertEquals(discarded ? 1F : .5F, depthPixel.get(0), .000001F, control::toString); -diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderPipelinesTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderPipelinesTest.java -index 934021b..96f2342 100644 ---- a/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderPipelinesTest.java -+++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderPipelinesTest.java -@@ -13,6 +13,7 @@ final class BedrockShaderPipelinesTest { - final var definitions = BedrockActorMaterialDefinitions.read(java.util.List.of()); - final var material = definitions.heldItemMaterial("entity_alphatest_glint").orElseThrow(); - assertThrows(IllegalArgumentException.class, () -> BedrockActorRenderTypes.selectPipeline(material, false, false)); -+ assertThrows(IllegalArgumentException.class, () -> BedrockActorRenderTypes.selectGuiPipeline(material, false)); - assertDoesNotThrow(() -> BedrockActorRenderTypes.selectGlintPipeline(material, false, false, - BedrockActorRenderTypes.GlintLighting.LIGHTMAP)); - } -@@ -63,6 +64,29 @@ final class BedrockShaderPipelinesTest { - assertNull(BedrockShaderEmission.emissionPipeline(body).getDepthStencilState()); - } - + if (!writeMask.writeAlpha()) expected[3] = 1F; + for (int i = 0; i < 4; i++) assertEquals(expected[i], pixel.get(i), .000001F, control::toString); + assertEquals(discarded ? 1F : .5F, depthPixel.get(0), .000001F, control::toString); +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderPipelinesTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderPipelinesTest.java +index 934021b..96f2342 100644 +--- a/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderPipelinesTest.java ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderPipelinesTest.java +@@ -13,6 +13,7 @@ final class BedrockShaderPipelinesTest { + final var definitions = BedrockActorMaterialDefinitions.read(java.util.List.of()); + final var material = definitions.heldItemMaterial("entity_alphatest_glint").orElseThrow(); + assertThrows(IllegalArgumentException.class, () -> BedrockActorRenderTypes.selectPipeline(material, false, false)); ++ assertThrows(IllegalArgumentException.class, () -> BedrockActorRenderTypes.selectGuiPipeline(material, false)); + assertDoesNotThrow(() -> BedrockActorRenderTypes.selectGlintPipeline(material, false, false, + BedrockActorRenderTypes.GlintLighting.LIGHTMAP)); + } +@@ -63,6 +64,29 @@ final class BedrockShaderPipelinesTest { + assertNull(BedrockShaderEmission.emissionPipeline(body).getDepthStencilState()); + } + ++ @Test ++ void guiPortraitsBlendWithoutChangingWorldMaterialPipelines() { ++ for (final boolean alphaTest : new boolean[] {false, true}) ++ for (final boolean cull : new boolean[] {false, true}) ++ for (final boolean unlit : new boolean[] {false, true}) ++ for (final boolean emissive : new boolean[] {false, true}) ++ for (final var depth : java.util.List.of(BedrockActorDepth.DEFAULT, BedrockActorDepth.DISABLED, ++ BedrockActorDepth.read("Equal", java.util.Set.of("DisableDepthWrite")).orElseThrow())) { ++ final var material = new BedrockActorMaterialDefinitions.Material(alphaTest, cull, emissive, false, false, false, depth); ++ final var gui = BedrockActorRenderTypes.selectGuiPipeline(material, unlit); ++ final var world = BedrockActorRenderTypes.selectPipeline(material, unlit, false); ++ assertSame(alphaTest ? RenderPipelines.ENTITY_TRANSLUCENT : RenderPipelines.ENTITY_SOLID, ++ BedrockShaderPipelines.equivalent(gui)); ++ assertEquals(cull, gui.isCull()); ++ assertEquals(depth.state().orElse(null), gui.getDepthStencilState()); ++ assertEquals(world.getDepthStencilState(), gui.getDepthStencilState()); ++ assertEquals(world.getVertexFormatBindings(), gui.getVertexFormatBindings()); ++ assertNotEquals(world.getColorTargetStates(), gui.getColorTargetStates()); ++ assertEquals(depth.equals(BedrockActorDepth.DEFAULT), BedrockActorRenderTypes.PIPELINES.contains(gui)); ++ assertNotSame(gui, world); ++ } ++ } ++ + @Test + void bypassesWorldLightWithoutChangingOrdinaryActorLight() { + for (final int light : new int[] {0, 0x80, 0x900050, net.minecraft.util.LightCoordsUtil.FULL_BRIGHT}) { +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/font/BedrockBitmapFontTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/font/BedrockBitmapFontTest.java +new file mode 100644 +index 0000000..64784a5 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/font/BedrockBitmapFontTest.java +@@ -0,0 +1,239 @@ ++package com.viaversion.viafabricplus.bedrock.render.font; ++ ++import com.mojang.blaze3d.font.UnbakedGlyph; ++import com.mojang.blaze3d.font.GlyphInfo; ++import com.mojang.blaze3d.font.GlyphProvider; ++import com.mojang.blaze3d.platform.NativeImage; ++import com.mojang.blaze3d.systems.RenderSystem; ++import java.io.ByteArrayInputStream; ++import java.io.InputStream; ++import java.io.IOException; ++import java.nio.charset.StandardCharsets; ++import java.util.function.IntBinaryOperator; ++import net.minecraft.client.Minecraft; ++import net.minecraft.client.gui.font.GlyphRenderTypes; ++import net.minecraft.client.renderer.texture.DynamicTexture; ++import net.minecraft.client.renderer.texture.TextureManager; ++import net.minecraft.server.packs.PackResources; ++import net.minecraft.server.packs.PackType; ++import net.minecraft.server.packs.resources.Resource; ++import net.minecraft.server.packs.resources.ResourceManager; ++import net.minecraft.server.packs.resources.IoSupplier; ++import net.minecraft.resources.Identifier; ++import org.junit.jupiter.api.Test; ++import org.mockito.ArgumentCaptor; ++import static org.junit.jupiter.api.Assertions.*; ++import static org.mockito.ArgumentMatchers.*; ++import static org.mockito.Mockito.*; ++ ++final class BedrockBitmapFontTest { ++ @Test ++ void retainsFractionalAdvanceAndAlignsNarrowGlyphsWithoutChangingTheirPixels() { ++ assertEquals(new BedrockBitmapFont.Metrics(4, 0), glyph(0x41, 16, 8, 1, 7)); ++ assertEquals(new BedrockBitmapFont.Metrics(4.5F, 0), glyph(0x436, 16, 8, 1, 8)); ++ assertEquals(new BedrockBitmapFont.Metrics(4.5F, .5F), glyph(0x5DD, 16, 8, 0, 7)); ++ assertEquals(new BedrockBitmapFont.Metrics(4, -.5F), glyph(0x41, 16, 8, 2, 8)); ++ assertEquals(new BedrockBitmapFont.Metrics(4, -.25F), glyph(0x41, 32, 8, 2, 14)); ++ assertEquals(new BedrockBitmapFont.Metrics(4.5F, -.25F), glyph(0x436, 32, 8, 2, 16)); ++ } ++ ++ @Test ++ void keepsNativeWideRangesAndHangulAdvanceIndependentOfPaintedWidth() { ++ final int[][] ranges = {{0x1100, 0x11FF}, {0x3000, 0x30FF}, {0x3130, 0x318F}, {0x3200, 0x9FFF}, ++ {0xA960, 0xA97F}, {0xD7B0, 0xD7FF}, {0xF900, 0xFAFF}, {0xFF01, 0xFF5E}}; ++ for (final int[] range : ranges) { ++ for (int codepoint = range[0]; codepoint <= range[1]; codepoint++) { ++ assertEquals(new BedrockBitmapFont.Metrics(9, 0), glyph(codepoint, 16, 8, 2, 6)); ++ } ++ } ++ for (int codepoint = 0xAC00; codepoint < 0xD7B0; codepoint++) { ++ assertEquals(new BedrockBitmapFont.Metrics(8, 0), glyph(codepoint, 16, 8, 1, 7)); ++ } ++ for (final int codepoint : new int[] {0x10FF, 0x1200, 0x2FFF, 0x3100, 0x312F, 0x3190, ++ 0x31FF, 0xA000, 0xA95F, 0xA980, 0xD800, 0xF8FF, 0xFB00, 0xFF00, 0xFF5F}) { ++ assertEquals(new BedrockBitmapFont.Metrics(3, -.5F), glyph(codepoint, 16, 8, 2, 6)); ++ } ++ } ++ ++ @Test ++ void retainsEmptyGlyphAndPrivateUseMetrics() { ++ final IntBinaryOperator empty = (x, y) -> 0; ++ assertEquals(new BedrockBitmapFont.Metrics(4, 0), BedrockBitmapFont.measure(0x4E00, 16, 16, 8, empty)); ++ assertEquals(new BedrockBitmapFont.Metrics(9, 0), BedrockBitmapFont.measure(0x3000, 16, 16, 8, empty)); ++ assertEquals(new BedrockBitmapFont.Metrics(15, -8), glyph(0xE000, 32, 32, 9, 23)); ++ assertEquals(new BedrockBitmapFont.Metrics(10, 1), glyph(0xE100, 16, 16, 0, 9)); ++ } ++ ++ @Test ++ void admitsOnlyTheConvertedUnicodeResourceNamespace() { ++ assertTrue(BedrockBitmapFont.ownsUnicode(Identifier.fromNamespaceAndPath("viabedrock", "font/glyph_00.png"))); ++ assertFalse(BedrockBitmapFont.ownsUnicode(Identifier.fromNamespaceAndPath("server", "font/glyph_00.png"))); ++ assertFalse(BedrockBitmapFont.ownsUnicode(Identifier.fromNamespaceAndPath("viabedrock", "font/bedrock_bitmap/default8_0.png"))); ++ } ++ ++ @Test ++ void retainsNativeFloatOperationOrderAtGlyphCellEdges() { ++ // Original 1.26.60.5 glyph writer at 0x141590f60, with bitmap height 12 and offset -3. ++ final var bitmap = BedrockBitmapFont.Cell.bitmap(1, 4, 8, 12, 128, 128, -3); ++ assertEquals(.0625F, bitmap.u0()); ++ assertEquals(.375F, bitmap.v0()); ++ assertEquals(.12499219179153442F, bitmap.u1()); ++ assertEquals(.4687421917915344F, bitmap.v1()); ++ final var unicode = new BedrockBitmapFont.Cell(16, 64, 16, 16, 256, 256, .5F, 0, 0, .01F); ++ assertEquals(.0625F, unicode.u0()); ++ assertEquals(.25F, unicode.v0()); ++ assertEquals(.12496093660593033F, unicode.u1()); ++ assertEquals(.31246092915534973F, unicode.v1()); ++ } ++ ++ @Test ++ void retainsPageCoordinatesAndFullCellBoundsAtDifferentResolutions() { ++ for (final int resolution : new int[] {256, 512, 1024}) { ++ final int size = resolution / 16; ++ final var cell = new BedrockBitmapFont.Cell(size * 15, size * 7, size, size, ++ resolution, resolution, 8F / size, -.5F, -4, .01F); ++ assertEquals(15F / 16, cell.u0()); ++ assertEquals(7F / 16, cell.v0()); ++ assertEquals(7.5F, cell.right()); ++ assertEquals(4, cell.bottom()); ++ } ++ } ++ ++ @Test ++ void releasesUnusedAtlasOnceAndClosesInvalidAtlasWithoutUploading() throws Exception { ++ final var resources = mock(ResourceManager.class); ++ final var pixels = mock(NativeImage.class); ++ when(resources.open(any())).thenAnswer(_ -> new ByteArrayInputStream(new byte[0])); ++ when(pixels.getWidth()).thenReturn(32); ++ when(pixels.getHeight()).thenReturn(32); ++ try (final var decoder = mockStatic(NativeImage.class)) { ++ decoder.when(() -> NativeImage.read(any(InputStream.class))).thenReturn(pixels); ++ final var font = BedrockBitmapFont.loadUnicode(resources, ++ Identifier.fromNamespaceAndPath("viabedrock", "font/glyph_00.png"), new int[][] {{65, 66}, {0, 67}}, 8, 7); ++ final var glyph = font.getGlyph(65); ++ assertNotNull(glyph); ++ assertEquals(3, font.getSupportedGlyphs().size()); ++ font.close(); ++ font.close(); ++ verify(pixels).close(); ++ assertThrows(IllegalStateException.class, () -> glyph.bake(null)); ++ ++ assertThrows(IOException.class, () -> BedrockBitmapFont.loadUnicode(resources, ++ Identifier.fromNamespaceAndPath("viabedrock", "font/glyph_00.png"), new int[][] {{65, 66, 67}}, 8, 7)); ++ verify(pixels, times(2)).close(); ++ } ++ } ++ ++ @Test ++ void sharesOneTextureAcrossGlyphsAndRetiresItWithTheProvider() throws Exception { ++ final var resources = mock(ResourceManager.class); ++ final var pixels = mock(NativeImage.class); ++ final var game = mock(Minecraft.class); ++ final var manager = mock(TextureManager.class); ++ when(resources.open(any())).thenAnswer(_ -> new ByteArrayInputStream(new byte[0])); ++ when(pixels.getWidth()).thenReturn(32); ++ when(pixels.getHeight()).thenReturn(32); ++ when(game.getTextureManager()).thenReturn(manager); ++ try (final var decoder = mockStatic(NativeImage.class); ++ final var singleton = mockStatic(Minecraft.class); ++ final var renderSystem = mockStatic(RenderSystem.class); ++ final var renderTypes = mockStatic(GlyphRenderTypes.class); ++ final var textures = mockConstruction(DynamicTexture.class)) { ++ decoder.when(() -> NativeImage.read(any(InputStream.class))).thenReturn(pixels); ++ singleton.when(Minecraft::getInstance).thenReturn(game); ++ renderSystem.when(RenderSystem::isOnRenderThread).thenReturn(true); ++ renderTypes.when(() -> GlyphRenderTypes.createForColorTexture(any())).thenReturn(mock(GlyphRenderTypes.class)); ++ final var font = BedrockBitmapFont.loadUnicode(resources, ++ Identifier.fromNamespaceAndPath("viabedrock", "font/glyph_00.png"), new int[][] {{65, 66}, {0, 67}}, 8, 7); ++ final var stitcher = mock(UnbakedGlyph.Stitcher.class); ++ assertNotNull(font.getGlyph(65).bake(stitcher)); ++ assertNotNull(font.getGlyph(67).bake(stitcher)); ++ verifyNoInteractions(stitcher); ++ assertEquals(1, textures.constructed().size()); ++ verify(manager).register(any(), same(textures.constructed().getFirst())); ++ renderSystem.when(RenderSystem::isOnRenderThread).thenReturn(false); ++ font.close(); ++ font.close(); ++ final var disposal = ArgumentCaptor.forClass(Runnable.class); ++ verify(game).execute(disposal.capture()); ++ verify(manager, never()).release(any()); ++ disposal.getValue().run(); ++ verify(manager).release(any()); ++ verify(pixels, never()).close(); ++ assertThrows(IllegalStateException.class, () -> font.getGlyph(65).bake(stitcher)); ++ } ++ } ++ ++ @Test ++ void loadsBitmapPixelsFromTheDescriptorsPackAndRetainsSourceAdvances() throws Exception { ++ final var file = Identifier.fromNamespaceAndPath("viabedrock", "font/bedrock_bitmap/default8_0.png"); ++ final var nativeFile = Identifier.fromNamespaceAndPath("viabedrock", "textures/font/bedrock_bitmap/default8_0_native.png"); ++ final var resources = mock(ResourceManager.class); ++ final var resource = mock(Resource.class); ++ final var pack = mock(PackResources.class); ++ final var source = mock(GlyphProvider.class); ++ final var original = mock(UnbakedGlyph.class); ++ final var pixels = mock(NativeImage.class); ++ when(resources.getResourceOrThrow(file.withPrefix("textures/"))).thenReturn(resource); ++ when(resource.source()).thenReturn(pack); ++ when(pack.getResource(PackType.CLIENT_RESOURCES, file.withPrefix("textures/").withSuffix(".mcmeta"))) ++ .thenAnswer(_ -> (IoSupplier) () -> new ByteArrayInputStream( ++ ("{\"viabedrock:font_atlas\":{\"texture\":\"viabedrock:font/bedrock_bitmap/default8_0_native.png\",\"height\":12,\"y_offset\":-8}}") ++ .getBytes(StandardCharsets.UTF_8))); ++ when(pack.getResource(PackType.CLIENT_RESOURCES, nativeFile)) ++ .thenAnswer(_ -> (IoSupplier) () -> new ByteArrayInputStream(new byte[0])); ++ when(pixels.getWidth()).thenReturn(128); ++ when(pixels.getHeight()).thenReturn(128); ++ when(source.getGlyph(65)).thenReturn(original); ++ when(original.info()).thenReturn(GlyphInfo.simple(5.5F)); ++ final int[][] grid = new int[10][16]; ++ grid[3][1] = 65; ++ try (final var decoder = mockStatic(NativeImage.class)) { ++ decoder.when(() -> NativeImage.read(any(InputStream.class))).thenReturn(pixels); ++ final var font = BedrockBitmapFont.loadBitmap(source, resources, file, grid); ++ assertEquals(5.5F, font.getGlyph(65).info().getAdvance()); ++ verify(source).close(); ++ verify(pack).getResource(PackType.CLIENT_RESOURCES, nativeFile); ++ verify(resources, never()).open(any()); ++ font.close(); ++ font.close(); ++ verify(pixels).close(); ++ when(pack.getResource(PackType.CLIENT_RESOURCES, nativeFile)).thenReturn(null); ++ assertThrows(IOException.class, () -> BedrockBitmapFont.loadBitmap(source, resources, file, grid)); ++ verify(source, times(2)).close(); ++ verify(resources, never()).getResourceOrThrow(nativeFile); ++ } ++ final var cell = BedrockBitmapFont.Cell.bitmap(1, 3, 8, 12, 128, 128, -8); ++ assertEquals(8F / 128, cell.u0()); ++ assertEquals(.12499219179153442F, cell.u1()); ++ assertEquals(36F / 128, cell.v0()); ++ assertEquals((48 - .001F) / 128, cell.v1()); ++ assertEquals(4, cell.bottom()); ++ final var higherResolution = BedrockBitmapFont.Cell.bitmap(1, 3, 16, 24, 256, 256, -8); ++ assertEquals(cell.u0(), higherResolution.u0()); ++ assertEquals(cell.u1(), higherResolution.u1()); ++ assertEquals(cell.v0(), higherResolution.v0()); ++ assertEquals(cell.v1(), higherResolution.v1()); ++ assertEquals(cell.bottom(), higherResolution.bottom()); ++ } ++ ++ @Test ++ void keepsJavaBitmapProviderWhenItsWinningPackHasNoNativeDescriptor() throws Exception { ++ final var file = Identifier.fromNamespaceAndPath("viabedrock", "font/bedrock_bitmap/default8_0.png"); ++ final var resources = mock(ResourceManager.class); ++ final var resource = mock(Resource.class); ++ final var pack = mock(PackResources.class); ++ final var source = mock(GlyphProvider.class); ++ when(resources.getResourceOrThrow(file.withPrefix("textures/"))).thenReturn(resource); ++ when(resource.source()).thenReturn(pack); ++ assertSame(source, BedrockBitmapFont.loadBitmap(source, resources, file, new int[16][16])); ++ verifyNoInteractions(source); ++ verify(resources, never()).open(any()); ++ } ++ ++ private static BedrockBitmapFont.Metrics glyph(final int codepoint, final int cell, final int renderHeight, ++ final int left, final int right) { ++ return BedrockBitmapFont.measure(codepoint, cell, cell, renderHeight, ++ (x, y) -> x >= left && x < right && y == cell - 2 ? 1 : 0); ++ } ++} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/font/BedrockBitmapGlyphTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/font/BedrockBitmapGlyphTest.java +new file mode 100644 +index 0000000..8724755 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/font/BedrockBitmapGlyphTest.java +@@ -0,0 +1,107 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.render.font; ++ ++import com.mojang.blaze3d.font.GlyphInfo; ++import com.mojang.blaze3d.platform.Window; ++import com.mojang.blaze3d.vertex.VertexConsumer; ++import com.mojang.renderpearl.api.textures.GpuTextureView; ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiViewport; ++import net.minecraft.client.Minecraft; ++import net.minecraft.client.gui.font.GlyphRenderTypes; ++import net.minecraft.client.gui.font.TextRenderable; ++import net.minecraft.client.gui.font.glyphs.BakedSheetGlyph; ++import net.minecraft.network.chat.Style; ++import org.joml.Matrix4f; ++import org.junit.jupiter.api.Test; ++import org.mockito.ArgumentCaptor; ++import java.util.List; ++ ++import static org.junit.jupiter.api.Assertions.*; ++import static org.mockito.ArgumentMatchers.*; ++import static org.mockito.Mockito.*; ++ ++final class BedrockBitmapGlyphTest { ++ @Test ++ void packsGuiLocalVerticesBeforeThePoseAndPreservesWorldTextAndLight() { ++ final var cell = new BedrockBitmapFont.Cell(16, 64, 16, 16, 256, 256, .5F, 0, 0, .01F); ++ final var source = new BakedSheetGlyph(GlyphInfo.simple(4), mock(GlyphRenderTypes.class), mock(GpuTextureView.class), ++ cell.u0(), cell.u1(), cell.v0(), cell.v1(), cell.left(), cell.right(), cell.top(), cell.bottom()); ++ final var bitmap = new BedrockBitmapGlyph(source, cell, false); ++ final var unicode = new BedrockBitmapGlyph(source, cell, true); ++ final TextRenderable.Styled glyph; ++ try (final var viewport = mockStatic(NativeUiViewport.class)) { ++ viewport.when(NativeUiViewport::current).thenReturn(new NativeUiViewport(1280, 720, 1, 1)); ++ glyph = unicode.createGlyph(109.01F, 0, -1, 0, Style.EMPTY, 0, 0); ++ assertTrue(BedrockBitmapPipeline.isSmoothed(glyph.guiPipeline())); ++ assertFalse(BedrockBitmapPipeline.isSmoothed(bitmap.createGlyph(0, 0, -1, 0, Style.EMPTY, 0, 0).guiPipeline())); ++ viewport.when(NativeUiViewport::current).thenReturn(new NativeUiViewport(640, 360, 2, 2)); ++ assertFalse(BedrockBitmapPipeline.isSmoothed(unicode.createGlyph(0, 0, -1, 0, Style.EMPTY, 0, 0).guiPipeline())); ++ } ++ final var vertices = mock(VertexConsumer.class, RETURNS_SELF); ++ final var pose = new Matrix4f().translate(20, 30, 0).scale(2); ++ final var client = mock(Minecraft.class); ++ final var window = mock(Window.class); ++ when(client.getWindow()).thenReturn(window); ++ when(window.getGuiScale()).thenReturn(2); ++ try (final var minecraft = mockStatic(Minecraft.class)) { ++ minecraft.when(Minecraft::getInstance).thenReturn(client); ++ glyph.render(pose, vertices, 0x00A00050, true); ++ } ++ verify(vertices).addVertex(238F, 30F - 1F / 512, 0F); ++ verify(vertices).addVertex(238F, 46F - 1F / 512, 0F); ++ verify(vertices, times(2)).addVertex(eq(254F), anyFloat(), eq(0F)); ++ verify(vertices, times(4)).setUv1(16, 64); ++ verify(vertices, times(4)).setUv3(16, 16); ++ verify(vertices).setUv(cell.u0(), cell.v0()); ++ verify(vertices).setUv(cell.u1(), cell.v1()); ++ verify(vertices, times(4)).setUv2(80, 160); ++ assertEquals(109.01F, glyph.activeLeft()); ++ assertEquals(113.01F, glyph.activeRight()); ++ assertTrue(BedrockBitmapPipeline.isSmoothed(glyph.guiPipeline())); ++ ++ final var world = mock(VertexConsumer.class, RETURNS_SELF); ++ glyph.render(pose, world, 0x00A00050, false); ++ verify(world, times(2)).addVertex(same(pose), eq(109.01F), anyFloat(), anyFloat()); ++ verify(world, never()).setUv1(anyInt(), anyInt()); ++ verify(world).setUv(cell.u0(), cell.v0()); ++ verify(world, times(4)).setLight(0x00A00050); ++ } ++ @Test ++ void usesFixedItalicEdgesForTallBitmapCellsAndPreservesWorldVertices() { ++ final var cell = BedrockBitmapFont.Cell.bitmap(1, 4, 8, 12, 128, 128, -3); ++ final var sheet = new BakedSheetGlyph(GlyphInfo.simple(4), mock(GlyphRenderTypes.class), mock(GpuTextureView.class), ++ cell.u0(), cell.u1(), cell.v0(), cell.v1(), cell.left(), cell.right(), cell.top(), cell.bottom()); ++ final var style = Style.EMPTY.withItalic(true); ++ final var glyph = new BedrockBitmapGlyph(sheet, cell, false).createGlyph(0, 0, -1, 0, style, 0, 0); ++ final var pose = new Matrix4f(); ++ final var client = mock(Minecraft.class); ++ final var window = mock(Window.class); ++ when(client.getWindow()).thenReturn(window); ++ when(window.getGuiScale()).thenReturn(1); ++ final var gui = mock(VertexConsumer.class, RETURNS_SELF); ++ try (final var minecraft = mockStatic(Minecraft.class)) { ++ minecraft.when(Minecraft::getInstance).thenReturn(client); ++ glyph.render(pose, gui, 0, true); ++ } ++ final var guiX = ArgumentCaptor.forClass(Float.class); ++ verify(gui, times(4)).addVertex(guiX.capture(), anyFloat(), anyFloat()); ++ // Original 1.26.60.5 bitmap writer and quad expansion for height 12 and offset -3. ++ assertEquals(List.of(1F, -1F, 7F, 9F), guiX.getAllValues()); ++ assertTrue(glyph.left() <= -1); ++ assertTrue(glyph.right() >= 9); ++ assertTrue(glyph.activeLeft() <= -1); ++ assertTrue(glyph.activeRight() >= 5); ++ ++ final var world = mock(VertexConsumer.class, RETURNS_SELF); ++ glyph.render(pose, world, 0, false); ++ final var worldX = ArgumentCaptor.forClass(Float.class); ++ verify(world, times(4)).addVertex(same(pose), worldX.capture(), anyFloat(), anyFloat()); ++ assertEquals(List.of(1.75F, -1.25F, 6.75F, 9.75F), worldX.getAllValues()); ++ assertNotEquals(guiX.getAllValues(), worldX.getAllValues()); ++ } ++ ++} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/font/BedrockBitmapShaderGpuTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/font/BedrockBitmapShaderGpuTest.java +new file mode 100644 +index 0000000..0e8ffc2 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/font/BedrockBitmapShaderGpuTest.java +@@ -0,0 +1,291 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.render.font; ++ ++import com.viaversion.viafabricplus.bedrock.render.EglTestContext; ++import java.io.IOException; ++import java.nio.charset.StandardCharsets; ++import java.util.List; ++import org.junit.jupiter.api.Test; ++import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; ++import org.lwjgl.system.MemoryStack; ++import org.lwjgl.system.MemoryUtil; ++ ++import static org.junit.jupiter.api.Assertions.*; ++import static org.lwjgl.opengl.GL33C.*; ++ ++@EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_FONT_GPU_TESTS", matches = "1") ++final class BedrockBitmapShaderGpuTest { ++ @Test ++ void reproducesPackedOriginsAndClampBoundsFromTheNativeGpuDraw() { ++ EglTestContext.run(() -> { ++ final int program = program(false, true), vao = glGenVertexArrays(); ++ final int texture = glGenTextures(), output = glGenBuffers(); ++ try { ++ glBindVertexArray(vao); ++ glBindTexture(GL_TEXTURE_2D, texture); ++ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 128, 128, 0, GL_RGBA, GL_UNSIGNED_BYTE, 0L); ++ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); ++ glUseProgram(program); ++ glUniform1i(glGetUniformLocation(program, "Sampler0"), 0); ++ glVertexAttrib3f(0, 12, 9, 0); ++ glVertexAttrib4f(1, 1, 0, 0, 1); ++ glVertexAttrib2f(2, .5625F, .5624921917915344F); ++ glVertexAttribI2i(3, 72, 60); ++ glVertexAttrib2f(4, 8, 12); ++ glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, output); ++ glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, 6L * Float.BYTES, GL_STREAM_READ); ++ glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, output); ++ glEnable(GL_RASTERIZER_DISCARD); ++ glBeginTransformFeedback(GL_POINTS); ++ glDrawArrays(GL_POINTS, 0, 1); ++ glEndTransformFeedback(); ++ glDisable(GL_RASTERIZER_DISCARD); ++ final float[] actual = new float[6]; ++ glGetBufferSubData(GL_TRANSFORM_FEEDBACK_BUFFER, 0, actual); ++ // Original 1.26.60.5 UI/Text vertex outputs, including a source origin above 0.5. ++ assertArrayEquals(new float[] {.5625085830688477F, .562507152557373F, ++ .5664148330688477F, .47266340255737305F, .6211023330688477F, .558600902557373F}, actual, 1E-7F); ++ } finally { ++ glDisable(GL_RASTERIZER_DISCARD); ++ glUseProgram(0); ++ glDeleteBuffers(output); ++ glDeleteTextures(texture); ++ glDeleteVertexArrays(vao); ++ glDeleteProgram(program); ++ } ++ }); ++ } ++ ++ @Test ++ void appliesNativeUnicodeSmoothingToEachSampledColorChannel() { ++ EglTestContext.run(() -> { ++ final int program = program(true, false), vao = glGenVertexArrays(); ++ final int buffer = glGenBuffers(), texture = glGenTextures(); ++ try (var stack = MemoryStack.stackPush()) { ++ glBindVertexArray(vao); ++ glBindBuffer(GL_ARRAY_BUFFER, buffer); ++ glBufferData(GL_ARRAY_BUFFER, stack.floats(-1, -1, 0, 0, 1, 1, -1, 0, 1, 1, ++ -1, 1, 0, 0, 0, 1, 1, 0, 1, 0), GL_STATIC_DRAW); ++ glEnableVertexAttribArray(0); ++ glVertexAttribPointer(0, 3, GL_FLOAT, false, 20, 0L); ++ glEnableVertexAttribArray(2); ++ glVertexAttribPointer(2, 2, GL_FLOAT, false, 20, 12L); ++ glVertexAttrib4f(1, 1, 1, 1, 1); ++ glVertexAttribI2i(3, 0, 0); ++ glVertexAttrib2f(4, 1, 1); ++ glBindTexture(GL_TEXTURE_2D, texture); ++ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, ++ stack.bytes((byte) 102, (byte) 102, (byte) 102, (byte) 102)); ++ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); ++ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); ++ glUseProgram(program); ++ final var identity = stack.floats(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); ++ glUniformMatrix4fv(glGetUniformLocation(program, "ModelViewMat"), false, identity); ++ glUniformMatrix4fv(glGetUniformLocation(program, "ProjMat"), false, identity); ++ glUniform4f(glGetUniformLocation(program, "ColorModulator"), 1, 1, 1, 1); ++ glUniform1i(glGetUniformLocation(program, "Sampler0"), 0); ++ glViewport(0, 0, 1, 1); ++ glDisable(GL_BLEND); ++ glDisable(GL_DEPTH_TEST); ++ glDisable(GL_CULL_FACE); ++ glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); ++ final var pixel = stack.malloc(4); ++ glReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); ++ for (int channel = 0; channel < 4; channel++) assertEquals(128, Byte.toUnsignedInt(pixel.get(channel)), 1); ++ } finally { ++ glUseProgram(0); ++ glDeleteTextures(texture); ++ glDeleteBuffers(buffer); ++ glDeleteVertexArrays(vao); ++ glDeleteProgram(program); ++ } ++ }); ++ } ++ ++ @Test ++ void retainsNativeTopEdgeAndUnshiftedSamplesInAnOffscreenTarget() { ++ EglTestContext.run(() -> { ++ final int program = program(false, false), vao = glGenVertexArrays(), buffer = glGenBuffers(); ++ final int atlas = glGenTextures(), target = glGenTextures(), framebuffer = glGenFramebuffers(); ++ try (var stack = MemoryStack.stackPush()) { ++ glBindTexture(GL_TEXTURE_2D, target); ++ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 16, 16, 0, GL_RGBA, GL_UNSIGNED_BYTE, 0L); ++ glBindFramebuffer(GL_FRAMEBUFFER, framebuffer); ++ glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, target, 0); ++ assertEquals(GL_FRAMEBUFFER_COMPLETE, glCheckFramebufferStatus(GL_FRAMEBUFFER)); ++ glBindTexture(GL_TEXTURE_2D, atlas); ++ final var texels = stack.malloc(8 * 8 * 4); ++ for (int y = 0; y < 8; y++) { ++ for (int x = 0; x < 8; x++) { ++ texels.put((byte) (y * 32)).put((byte) (x * 32)).put((byte) 0).put((byte) 255); ++ } ++ } ++ texels.flip(); ++ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 8, 8, 0, GL_RGBA, GL_UNSIGNED_BYTE, texels); ++ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); ++ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); ++ glBindVertexArray(vao); ++ glBindBuffer(GL_ARRAY_BUFFER, buffer); ++ glEnableVertexAttribArray(0); ++ glVertexAttribPointer(0, 3, GL_FLOAT, false, 20, 0L); ++ glEnableVertexAttribArray(2); ++ glVertexAttribPointer(2, 2, GL_FLOAT, false, 20, 12L); ++ glVertexAttrib4f(1, 1, 1, 1, 1); ++ glVertexAttribI2i(3, 0, 0); ++ glVertexAttrib2f(4, 8, 8); ++ glUseProgram(program); ++ glUniformMatrix4fv(glGetUniformLocation(program, "ModelViewMat"), false, ++ stack.floats(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)); ++ glUniform4f(glGetUniformLocation(program, "ColorModulator"), 1, 1, 1, 1); ++ glUniform1i(glGetUniformLocation(program, "Sampler0"), 0); ++ glViewport(0, 0, 16, 16); ++ glDisable(GL_BLEND); ++ glDisable(GL_DEPTH_TEST); ++ glDisable(GL_CULL_FACE); ++ for (int guiScale = 1; guiScale <= 2; guiScale++) { ++ final float unit = 1F / guiScale; ++ glUniformMatrix4fv(glGetUniformLocation(program, "ProjMat"), false, ++ stack.floats(guiScale / 8F, 0, 0, 0, 0, -guiScale / 8F, 0, 0, 0, 0, 1, 0, -1, 1, 0, 1)); ++ for (final float phase : new float[] {0, .25F, .5F, .75F, .5F - 1F / 1024, .5F + 1F / 1024, ++ .5F - 3F / 1024, .5F + 3F / 1024}) { ++ final float left = 3 * unit; ++ final float top = (3 + phase) * unit; ++ final float shifted = BedrockBitmapGlyph.rasterSnap(top, guiScale) - unit / 256; ++ glBufferData(GL_ARRAY_BUFFER, stack.floats( ++ left, shifted, 0, 0, 0, left, shifted + 8 * unit, 0, 0, 1, ++ left + 8 * unit, shifted, 0, 1, 0, left + 8 * unit, shifted + 8 * unit, 0, 1, 1), GL_STREAM_DRAW); ++ glClearColor(0, 0, 0, 0); ++ glClear(GL_COLOR_BUFFER_BIT); ++ glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); ++ final var pixels = stack.malloc(16 * 16 * 4); ++ glReadPixels(0, 0, 16, 16, GL_RGBA, GL_UNSIGNED_BYTE, pixels); ++ for (int y = 0; y < 16; y++) { ++ final float snappedTop = (float) Math.rint((3 + phase) * 256) / 256F; ++ final boolean covered = y + .5F >= snappedTop && y + .5F < snappedTop + 8; ++ final int index = ((15 - y) * 16 + 5) * 4; ++ assertEquals(covered ? 255 : 0, Byte.toUnsignedInt(pixels.get(index + 3)), ++ "GUI scale " + guiScale + ", phase " + phase + ", row " + y); ++ if (covered) assertEquals((int) (y + .5F - snappedTop) * 32, Byte.toUnsignedInt(pixels.get(index)), ++ "GUI scale " + guiScale + ", phase " + phase + ", sample row " + y); ++ } ++ } ++ } ++ } finally { ++ glBindFramebuffer(GL_FRAMEBUFFER, 0); ++ glUseProgram(0); ++ glDeleteFramebuffers(framebuffer); ++ glDeleteTextures(atlas); ++ glDeleteTextures(target); ++ glDeleteBuffers(buffer); ++ glDeleteVertexArrays(vao); ++ glDeleteProgram(program); ++ } ++ }); ++ } ++ + @Test -+ void guiPortraitsBlendWithoutChangingWorldMaterialPipelines() { -+ for (final boolean alphaTest : new boolean[] {false, true}) -+ for (final boolean cull : new boolean[] {false, true}) -+ for (final boolean unlit : new boolean[] {false, true}) -+ for (final boolean emissive : new boolean[] {false, true}) -+ for (final var depth : java.util.List.of(BedrockActorDepth.DEFAULT, BedrockActorDepth.DISABLED, -+ BedrockActorDepth.read("Equal", java.util.Set.of("DisableDepthWrite")).orElseThrow())) { -+ final var material = new BedrockActorMaterialDefinitions.Material(alphaTest, cull, emissive, false, false, false, depth); -+ final var gui = BedrockActorRenderTypes.selectGuiPipeline(material, unlit); -+ final var world = BedrockActorRenderTypes.selectPipeline(material, unlit, false); -+ assertSame(alphaTest ? RenderPipelines.ENTITY_TRANSLUCENT : RenderPipelines.ENTITY_SOLID, -+ BedrockShaderPipelines.equivalent(gui)); -+ assertEquals(cull, gui.isCull()); -+ assertEquals(depth.state().orElse(null), gui.getDepthStencilState()); -+ assertEquals(world.getDepthStencilState(), gui.getDepthStencilState()); -+ assertEquals(world.getVertexFormatBindings(), gui.getVertexFormatBindings()); -+ assertNotEquals(world.getColorTargetStates(), gui.getColorTargetStates()); -+ assertEquals(depth.equals(BedrockActorDepth.DEFAULT), BedrockActorRenderTypes.PIPELINES.contains(gui)); -+ assertNotSame(gui, world); ++ void retainsTheNativeAccentPixelAtAPointSampleBoundary() { ++ EglTestContext.run(() -> { ++ final String vertex = """ ++ #version 330 ++ #extension GL_ARB_separate_shader_objects : require ++ uniform vec2 SampleUv; ++ layout(location = 0) out vec4 vertexColor; ++ layout(location = 1) out vec2 glyphUv; ++ layout(location = 2) flat out vec4 glyphBounds; ++ layout(location = 3) noperspective out float glyphGuiY; ++ void main() { ++ vec2 corner = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2); ++ gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0); ++ vertexColor = vec4(1.0); ++ glyphUv = SampleUv; ++ glyphBounds = vec4(0.0, 0.0, 1.0, 1.0); ++ glyphGuiY = 0.0; ++ } ++ """; ++ final int program = program(false, false, vertex), vao = glGenVertexArrays(), atlas = glGenTextures(); ++ final var texels = MemoryUtil.memCalloc(384 * 384 * 4); ++ try (var stack = MemoryStack.stackPush()) { ++ glBindTexture(GL_TEXTURE_2D, atlas); ++ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); ++ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); ++ glBindVertexArray(vao); ++ glUseProgram(program); ++ glUniform1i(glGetUniformLocation(program, "Sampler0"), 0); ++ glUniform4f(glGetUniformLocation(program, "ColorModulator"), 1, 1, 1, 1); ++ glViewport(0, 0, 1, 1); ++ glDisable(GL_BLEND); ++ glDisable(GL_DEPTH_TEST); ++ glDisable(GL_CULL_FACE); ++ record Sample(float v, int channel) { } ++ final var pixel = stack.malloc(4); ++ for (final int size : new int[] {128, 384}) { ++ texels.clear(); ++ texels.limit(size * size * 4); ++ texels.putInt((3 * size + 2) * 4, -1); ++ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, size, size, 0, GL_RGBA, GL_UNSIGNED_BYTE, texels); ++ // Captured Java coordinate at the missing À pixel, its boundary, and a distinct texel. ++ final var samples = List.of(new Sample(0.0234374962747097F * 128F / size, 255), ++ new Sample(3F / size, 255), new Sample((3F - 1F / 256F) / size, 0)); ++ for (final Sample sample : samples) { ++ glUniform2f(glGetUniformLocation(program, "SampleUv"), 2F / size, sample.v()); ++ glDrawArrays(GL_TRIANGLES, 0, 3); ++ glReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); ++ for (int channel = 0; channel < 4; channel++) { ++ assertEquals(sample.channel(), Byte.toUnsignedInt(pixel.get(channel)), ++ "Atlas size " + size + ", texture V=" + sample.v()); ++ } + } ++ texels.putInt((3 * size + 2) * 4, 0); ++ } ++ } finally { ++ MemoryUtil.memFree(texels); ++ glUseProgram(0); ++ glDeleteTextures(atlas); ++ glDeleteVertexArrays(vao); ++ glDeleteProgram(program); ++ } ++ }); + } + - @Test - void bypassesWorldLightWithoutChangingOrdinaryActorLight() { - for (final int light : new int[] {0, 0x80, 0x900050, net.minecraft.util.LightCoordsUtil.FULL_BRIGHT}) { -diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/font/BedrockBitmapFontTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/font/BedrockBitmapFontTest.java ++ private static int program(final boolean smooth, final boolean feedback) { ++ return program(smooth, feedback, resource("vsh", smooth)); ++ } ++ ++ private static int program(final boolean smooth, final boolean feedback, final String vertexSource) { ++ final int program = glCreateProgram(); ++ final int vertex = shader(GL_VERTEX_SHADER, vertexSource); ++ final int fragment = shader(GL_FRAGMENT_SHADER, resource("fsh", smooth)); ++ try { ++ glAttachShader(program, vertex); ++ glAttachShader(program, fragment); ++ if (feedback) glTransformFeedbackVaryings(program, new String[] {"glyphUv", "glyphBounds"}, GL_INTERLEAVED_ATTRIBS); ++ glLinkProgram(program); ++ assertEquals(GL_TRUE, glGetProgrami(program, GL_LINK_STATUS), glGetProgramInfoLog(program)); ++ return program; ++ } finally { glDeleteShader(vertex); glDeleteShader(fragment); } ++ } ++ ++ private static int shader(final int type, final String source) { ++ final int shader = glCreateShader(type); ++ glShaderSource(shader, source); ++ glCompileShader(shader); ++ assertEquals(GL_TRUE, glGetShaderi(shader, GL_COMPILE_STATUS), glGetShaderInfoLog(shader)); ++ return shader; ++ } ++ ++ private static String resource(final String extension, final boolean smooth) { ++ final String path = "/assets/viafabricplus-bedrock/shaders/core/bedrock_bitmap_font." + extension; ++ try (var input = BedrockBitmapShaderGpuTest.class.getResourceAsStream(path)) { ++ assertNotNull(input); ++ return new String(input.readAllBytes(), StandardCharsets.UTF_8) ++ .replace("#version 330", "#version 330\n" + (smooth ? "#define BITMAP_SMOOTH\n" : "")) ++ .replace("#include ", "uniform mat4 ModelViewMat; uniform vec4 ColorModulator;") ++ .replace("#include ", "uniform mat4 ProjMat;"); ++ } catch (final IOException error) { throw new IllegalStateException(error); } ++ } ++} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/font/BedrockTrueTypeFontTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/font/BedrockTrueTypeFontTest.java new file mode 100644 -index 0000000..64784a5 +index 0000000..29743c7 --- /dev/null -+++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/font/BedrockBitmapFontTest.java -@@ -0,0 +1,239 @@ ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/font/BedrockTrueTypeFontTest.java +@@ -0,0 +1,329 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ +package com.viaversion.viafabricplus.bedrock.render.font; + -+import com.mojang.blaze3d.font.UnbakedGlyph; ++import com.mojang.blaze3d.font.GlyphBitmap; +import com.mojang.blaze3d.font.GlyphInfo; -+import com.mojang.blaze3d.font.GlyphProvider; ++import com.mojang.blaze3d.font.UnbakedGlyph; ++import com.mojang.blaze3d.vertex.VertexConsumer; +import com.mojang.blaze3d.platform.NativeImage; +import com.mojang.blaze3d.systems.RenderSystem; ++import com.mojang.renderpearl.api.commands.CommandEncoder; ++import com.mojang.renderpearl.api.device.GpuDevice; ++import com.mojang.renderpearl.api.textures.GpuTexture; ++import com.mojang.renderpearl.api.textures.GpuTextureView; +import java.io.ByteArrayInputStream; -+import java.io.InputStream; +import java.io.IOException; ++import java.nio.ByteBuffer; ++import java.nio.ByteOrder; ++import java.util.HexFormat; ++import java.util.Arrays; +import java.nio.charset.StandardCharsets; -+import java.util.function.IntBinaryOperator; -+import net.minecraft.client.Minecraft; -+import net.minecraft.client.gui.font.GlyphRenderTypes; -+import net.minecraft.client.renderer.texture.DynamicTexture; -+import net.minecraft.client.renderer.texture.TextureManager; ++import net.minecraft.client.gui.font.glyphs.BakedGlyph; ++import net.minecraft.client.gui.font.glyphs.BakedSheetGlyph; ++import net.minecraft.network.chat.Style; ++import org.joml.Matrix4f; ++import org.mockito.Answers; ++import java.util.List; ++import java.util.ArrayList; ++import net.minecraft.resources.Identifier; +import net.minecraft.server.packs.PackResources; +import net.minecraft.server.packs.PackType; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; -+import net.minecraft.server.packs.resources.IoSupplier; -+import net.minecraft.resources.Identifier; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; ++ +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + -+final class BedrockBitmapFontTest { ++class BedrockTrueTypeFontTest { ++ // Original Bedrock 1.26.60.5 raster controls for this authored, six-glyph test font. ++ private static final String RECTANGLE = "193333333333230a59b2b2b2b2b27c237fffffffffffb2337fffffffffffb233" ++ + "7fffffffffffb2337fffffffffffb2337fffffffffffb2333f7f7f7f7f7f5919"; ++ private static final String TRANSLATED = "19597f7f7f7f7f3f33b2ffffffffff7f33b2ffffffffff7f33b2ffffffffff7f" ++ + "33b2ffffffffff7f33b2ffffffffff7f237cb2b2b2b2b2590a23333333333319"; ++ ++ private static byte[] fixture() throws IOException { ++ try (var stream = BedrockTrueTypeFontTest.class.getResourceAsStream("/font/native-outline-fixture.ttf")) { ++ assertNotNull(stream); ++ return stream.readAllBytes(); ++ } ++ } ++ ++ private static GpuTextureView texture() { ++ final var texture = mock(GpuTextureView.class); ++ when(texture.getWidth(0)).thenReturn(256); ++ when(texture.getHeight(0)).thenReturn(256); ++ return texture; ++ } ++ + @Test -+ void retainsFractionalAdvanceAndAlignsNarrowGlyphsWithoutChangingTheirPixels() { -+ assertEquals(new BedrockBitmapFont.Metrics(4, 0), glyph(0x41, 16, 8, 1, 7)); -+ assertEquals(new BedrockBitmapFont.Metrics(4.5F, 0), glyph(0x436, 16, 8, 1, 8)); -+ assertEquals(new BedrockBitmapFont.Metrics(4.5F, .5F), glyph(0x5DD, 16, 8, 0, 7)); -+ assertEquals(new BedrockBitmapFont.Metrics(4, -.5F), glyph(0x41, 16, 8, 2, 8)); -+ assertEquals(new BedrockBitmapFont.Metrics(4, -.25F), glyph(0x41, 32, 8, 2, 14)); -+ assertEquals(new BedrockBitmapFont.Metrics(4.5F, -.25F), glyph(0x436, 32, 8, 2, 16)); ++ void matchesNativeRasterForRectanglesCurvesAndUnshiftedCompositeCoordinates() throws Exception { ++ try (var raster = new BedrockTrueTypeRaster(fixture(), 32)) { ++ final var plain = raster.raster(65); ++ final var translated = raster.raster(67); ++ assertArrayEquals(HexFormat.of().parseHex(RECTANGLE), plain.pixels()); ++ assertArrayEquals(HexFormat.of().parseHex(TRANSLATED), translated.pixels()); ++ assertEquals(8, plain.width()); ++ assertEquals(8, translated.height()); ++ assertEquals(3, plain.advance()); ++ assertEquals(-.09375F, plain.left()); ++ assertEquals(.09375F, translated.left()); ++ final var scaled = raster.raster(68); ++ assertArrayEquals(HexFormat.of().parseHex("0000000000253b16000064a13c00007fcc4c00007fcc4c00003f66260000"), scaled.pixels()); ++ assertEquals(5, scaled.width()); ++ assertEquals(6, scaled.height()); ++ } ++ try (var raster = new BedrockTrueTypeRaster(fixture(), 8)) { ++ final var curve = raster.raster(66); ++ assertArrayEquals(HexFormat.of().parseHex("0000000000000000000004222608002e8c9b3f022a6a753702"), curve.pixels()); ++ assertEquals(5, curve.width()); ++ assertEquals(5, curve.height()); ++ } + } + + @Test -+ void keepsNativeWideRangesAndHangulAdvanceIndependentOfPaintedWidth() { -+ final int[][] ranges = {{0x1100, 0x11FF}, {0x3000, 0x30FF}, {0x3130, 0x318F}, {0x3200, 0x9FFF}, -+ {0xA960, 0xA97F}, {0xD7B0, 0xD7FF}, {0xF900, 0xFAFF}, {0xFF01, 0xFF5E}}; -+ for (final int[] range : ranges) { -+ for (int codepoint = range[0]; codepoint <= range[1]; codepoint++) { -+ assertEquals(new BedrockBitmapFont.Metrics(9, 0), glyph(codepoint, 16, 8, 2, 6)); -+ } ++ void clearsReservedGlyphGuttersAndKeepsUploadsInsideTheAtlas() throws Exception { ++ try (var font = new BedrockTrueTypeFont(new BedrockTrueTypeRaster(fixture(), 32), ""); ++ var system = mockStatic(RenderSystem.class)) { ++ final var texture = mock(GpuTexture.class); ++ when(texture.getWidth(0)).thenReturn(20); ++ when(texture.getHeight(0)).thenReturn(20); ++ final var device = mock(GpuDevice.class); ++ final var encoder = mock(CommandEncoder.class); ++ system.when(RenderSystem::getDevice).thenReturn(device); ++ when(device.createCommandEncoder()).thenReturn(encoder); ++ final var uploads = new ArrayList(); ++ final var dimensions = new ArrayList>(); ++ doAnswer(call -> { ++ final NativeImage image = call.getArgument(1); ++ final byte[] pixels = new byte[image.getWidth() * image.getHeight()]; ++ image.getPixelBytes().get(pixels); ++ uploads.add(pixels); ++ dimensions.add(List.of(image.getWidth(), image.getHeight())); ++ return null; ++ }).when(encoder).writeToTexture(same(texture), any(NativeImage.class), eq(0), eq(0), anyInt(), anyInt()); ++ final var stitcher = mock(UnbakedGlyph.Stitcher.class); ++ when(stitcher.stitch(any(), any())).thenAnswer(call -> { ++ final GlyphBitmap bitmap = call.getArgument(1); ++ bitmap.upload(5, 7, texture); ++ bitmap.upload(12, 12, texture); ++ return mock(BakedGlyph.class); ++ }); ++ font.getGlyph(65).bake(stitcher); ++ assertEquals(List.of(List.of(9, 9), List.of(8, 8)), dimensions); ++ assertArrayEquals(HexFormat.of().parseHex("193333333333230a00" ++ + "59b2b2b2b2b27c2300" + "7fffffffffffb23300" + "7fffffffffffb23300" ++ + "7fffffffffffb23300" + "7fffffffffffb23300" + "7fffffffffffb23300" ++ + "3f7f7f7f7f7f591900" + "000000000000000000"), uploads.getFirst()); ++ assertArrayEquals(HexFormat.of().parseHex(RECTANGLE), uploads.getLast()); + } -+ for (int codepoint = 0xAC00; codepoint < 0xD7B0; codepoint++) { -+ assertEquals(new BedrockBitmapFont.Metrics(8, 0), glyph(codepoint, 16, 8, 1, 7)); ++ } ++ ++ @Test ++ void preservesNativeAtlasSamplingWhenJavaRelocatesGlyphs() throws Exception { ++ try (var font = new BedrockTrueTypeFont(new BedrockTrueTypeRaster(fixture(), 32), "")) { ++ final var stitcher = mock(UnbakedGlyph.Stitcher.class); ++ final var texture = texture(); ++ when(stitcher.stitch(any(), any())).thenAnswer(call -> { ++ final GlyphInfo info = call.getArgument(0); ++ final GlyphBitmap bitmap = call.getArgument(1); ++ return new BakedSheetGlyph(info, null, texture, 1 / 256F, 9 / 256F, 17 / 256F, 25 / 256F, ++ bitmap.getLeft(), bitmap.getRight(), bitmap.getTop(), bitmap.getBottom()); ++ }); ++ final var glyph = font.getGlyph(65).bake(stitcher).createGlyph(0, 0, -1, 0, Style.EMPTY, 0, 0); ++ final var gui = mock(VertexConsumer.class, Answers.RETURNS_SELF); ++ glyph.render(new Matrix4f(), gui, 0, true); ++ final var u = ArgumentCaptor.forClass(Float.class); ++ final var v = ArgumentCaptor.forClass(Float.class); ++ verify(gui, times(4)).setUv(u.capture(), v.capture()); ++ // Original 1.26.60.5 shelf places A at [5,1..13,9] on a 256 page. ++ // Retain its captured fixed-point sample phase at Java's [1,17..9,25]. ++ assertEquals(List.of(.003906548F, .003906548F, .035157025F, .035157025F), u.getAllValues()); ++ assertEquals(List.of(.06640631F, .09765679F, .09765679F, .06640631F), v.getAllValues()); ++ final var world = mock(VertexConsumer.class, Answers.RETURNS_SELF); ++ glyph.render(new Matrix4f(), world, 0, false); ++ final var worldU = ArgumentCaptor.forClass(Float.class); ++ verify(world, times(4)).setUv(worldU.capture(), anyFloat()); ++ assertEquals(List.of(1 / 256F, 1 / 256F, 9 / 256F, 9 / 256F), worldU.getAllValues()); + } -+ for (final int codepoint : new int[] {0x10FF, 0x1200, 0x2FFF, 0x3100, 0x312F, 0x3190, -+ 0x31FF, 0xA000, 0xA95F, 0xA980, 0xD800, 0xF8FF, 0xFB00, 0xFF00, 0xFF5F}) { -+ assertEquals(new BedrockBitmapFont.Metrics(3, -.5F), glyph(codepoint, 16, 8, 2, 6)); ++ } ++ ++ @Test ++ void submitsCachePrecisionBeforeTheDisplayTransformAndPreservesWorldVertices() throws Exception { ++ try (var font = new BedrockTrueTypeFont(new BedrockTrueTypeRaster(fixture(), 32), "")) { ++ final var stitcher = mock(UnbakedGlyph.Stitcher.class); ++ when(stitcher.stitch(any(), any())).thenAnswer(call -> { ++ final GlyphInfo info = call.getArgument(0); ++ final GlyphBitmap bitmap = call.getArgument(1); ++ return new BakedSheetGlyph(info, null, texture(), 0, 1, 0, 1, ++ bitmap.getLeft(), bitmap.getRight(), bitmap.getTop(), bitmap.getBottom()); ++ }); ++ final var glyph = font.getGlyph(65).bake(stitcher).createGlyph(28.325806F, 0, -1, 0, Style.EMPTY, 0, 0); ++ final var pose = new Matrix4f().scaling(2); ++ final var nativeVertices = mock(VertexConsumer.class, Answers.RETURNS_SELF); ++ glyph.render(pose, nativeVertices, 0, true); ++ final var nativeX = ArgumentCaptor.forClass(Float.class); ++ verify(nativeVertices, times(4)).addVertex(same(pose), nativeX.capture(), anyFloat(), anyFloat()); ++ assertEquals(List.of(28.2421875F, 28.2421875F, 29.7421875F, 29.7421875F), nativeX.getAllValues()); ++ final var worldVertices = mock(VertexConsumer.class, Answers.RETURNS_SELF); ++ glyph.render(pose, worldVertices, 0, false); ++ final var worldX = ArgumentCaptor.forClass(Float.class); ++ verify(worldVertices, times(4)).addVertex(same(pose), worldX.capture(), anyFloat(), anyFloat()); ++ assertEquals(28.232056F, worldX.getAllValues().getFirst()); ++ assertNotEquals(nativeX.getAllValues(), worldX.getAllValues()); + } + } + + @Test -+ void retainsEmptyGlyphAndPrivateUseMetrics() { -+ final IntBinaryOperator empty = (x, y) -> 0; -+ assertEquals(new BedrockBitmapFont.Metrics(4, 0), BedrockBitmapFont.measure(0x4E00, 16, 16, 8, empty)); -+ assertEquals(new BedrockBitmapFont.Metrics(9, 0), BedrockBitmapFont.measure(0x3000, 16, 16, 8, empty)); -+ assertEquals(new BedrockBitmapFont.Metrics(15, -8), glyph(0xE000, 32, 32, 9, 23)); -+ assertEquals(new BedrockBitmapFont.Metrics(10, 1), glyph(0xE100, 16, 16, 0, 9)); ++ void usesFixedNativeItalicEdgesInGuiAndPreservesWorldGeometry() throws Exception { ++ try (var font = new BedrockTrueTypeFont(new BedrockTrueTypeRaster(fixture(), 32), "")) { ++ final var stitcher = mock(UnbakedGlyph.Stitcher.class); ++ final BakedSheetGlyph[] sheet = new BakedSheetGlyph[1]; ++ when(stitcher.stitch(any(), any())).thenAnswer(call -> { ++ final GlyphInfo info = call.getArgument(0); ++ final GlyphBitmap bitmap = call.getArgument(1); ++ sheet[0] = new BakedSheetGlyph(info, null, texture(), 0, 1, 0, 1, ++ bitmap.getLeft(), bitmap.getRight(), bitmap.getTop(), bitmap.getBottom()); ++ return sheet[0]; ++ }); ++ final var style = Style.EMPTY.withItalic(true); ++ final var glyph = font.getGlyph(65).bake(stitcher).createGlyph(0, 0, -1, 0, style, 0, 0); ++ final var pose = new Matrix4f(); ++ final var gui = mock(VertexConsumer.class, Answers.RETURNS_SELF); ++ glyph.render(pose, gui, 0, true); ++ final var guiX = ArgumentCaptor.forClass(Float.class); ++ verify(gui, times(4)).addVertex(same(pose), guiX.capture(), anyFloat(), anyFloat()); ++ // Native 1.26.60.5 quad expansion: top +1, bottom -1 before the 1.5 display transform. ++ assertEquals(List.of(1.40625F, -1.59375F, -.09375F, 2.90625F), guiX.getAllValues()); ++ assertTrue(glyph.left() <= -1.59375F); ++ assertTrue(glyph.right() >= 2.90625F); ++ assertEquals(style, glyph.style()); ++ final var fractional = font.getGlyph(65).bake(stitcher).createGlyph(.114990324F, 0, -1, 0, style, 0, 0); ++ final var fractionalVertices = mock(VertexConsumer.class, Answers.RETURNS_SELF); ++ fractional.render(pose, fractionalVertices, 0, true); ++ final var fractionalX = ArgumentCaptor.forClass(Float.class); ++ verify(fractionalVertices, times(4)).addVertex(same(pose), fractionalX.capture(), anyFloat(), anyFloat()); ++ // Applying display-space shear before normalization rounds onto the other half-float. ++ assertEquals(1.5205078125F, fractionalX.getAllValues().getFirst()); ++ ++ final var world = mock(VertexConsumer.class, Answers.RETURNS_SELF); ++ final var originalWorld = mock(VertexConsumer.class, Answers.RETURNS_SELF); ++ glyph.render(pose, world, 0, false); ++ sheet[0].createGlyph(0, 0, -1, 0, style, 0, 0).render(pose, originalWorld, 0, false); ++ final var worldX = ArgumentCaptor.forClass(Float.class); ++ final var originalX = ArgumentCaptor.forClass(Float.class); ++ verify(world, times(4)).addVertex(same(pose), worldX.capture(), anyFloat(), anyFloat()); ++ verify(originalWorld, times(4)).addVertex(same(pose), originalX.capture(), anyFloat(), anyFloat()); ++ assertEquals(originalX.getAllValues(), worldX.getAllValues()); ++ assertNotEquals(guiX.getAllValues(), worldX.getAllValues()); ++ } + } + + @Test -+ void admitsOnlyTheConvertedUnicodeResourceNamespace() { -+ assertTrue(BedrockBitmapFont.ownsUnicode(Identifier.fromNamespaceAndPath("viabedrock", "font/glyph_00.png"))); -+ assertFalse(BedrockBitmapFont.ownsUnicode(Identifier.fromNamespaceAndPath("server", "font/glyph_00.png"))); -+ assertFalse(BedrockBitmapFont.ownsUnicode(Identifier.fromNamespaceAndPath("viabedrock", "font/bedrock_bitmap/default8_0.png"))); ++ void ownsItsFontBytesAndRejectsUseAfterCloseAcrossReloads() throws Exception { ++ final byte[] bytes = fixture(); ++ final var raster = new BedrockTrueTypeRaster(bytes, 32); ++ Arrays.fill(bytes, (byte) 0); ++ assertArrayEquals(HexFormat.of().parseHex(RECTANGLE), raster.raster(65).pixels()); ++ raster.close(); ++ raster.close(); ++ assertThrows(IllegalStateException.class, () -> raster.raster(65)); ++ try (var replacement = new BedrockTrueTypeRaster(fixture(), 32)) { ++ assertArrayEquals(HexFormat.of().parseHex(RECTANGLE), replacement.raster(65).pixels()); ++ } + } + + @Test -+ void retainsNativeFloatOperationOrderAtGlyphCellEdges() { -+ // Original 1.26.60.5 glyph writer at 0x141590f60, with bitmap height 12 and offset -3. -+ final var bitmap = BedrockBitmapFont.Cell.bitmap(1, 4, 8, 12, 128, 128, -3); -+ assertEquals(.0625F, bitmap.u0()); -+ assertEquals(.375F, bitmap.v0()); -+ assertEquals(.12499219179153442F, bitmap.u1()); -+ assertEquals(.4687421917915344F, bitmap.v1()); -+ final var unicode = new BedrockBitmapFont.Cell(16, 64, 16, 16, 256, 256, .5F, 0, 0, .01F); -+ assertEquals(.0625F, unicode.u0()); -+ assertEquals(.25F, unicode.v0()); -+ assertEquals(.12496093660593033F, unicode.u1()); -+ assertEquals(.31246092915534973F, unicode.v1()); ++ void rejectsUnsignedTableSpansAndBoundsGlyphWorkBeforeRasterization() throws Exception { ++ final byte[] truncated = fixture(); ++ ByteBuffer.wrap(truncated).order(ByteOrder.BIG_ENDIAN).putInt(20, -1); ++ assertThrows(IOException.class, () -> new BedrockTrueTypeRaster(truncated, 32)); ++ final byte[] oversized = fixture(); ++ final var data = ByteBuffer.wrap(oversized).order(ByteOrder.BIG_ENDIAN); ++ final int glyphs = table(data, 0x676C7966); ++ data.putShort(glyphs + 6, Short.MAX_VALUE); ++ try (var raster = new BedrockTrueTypeRaster(oversized, 255)) { ++ assertThrows(IOException.class, () -> raster.raster(65)); ++ } ++ final byte[] inconsistent = fixture(); ++ final var bounds = ByteBuffer.wrap(inconsistent).order(ByteOrder.BIG_ENDIAN); ++ bounds.putShort(table(bounds, 0x676C7966) + 6, (short) 2); ++ try (var raster = new BedrockTrueTypeRaster(inconsistent, 32)) { ++ assertThrows(IOException.class, () -> raster.raster(65)); ++ } + } + + @Test -+ void retainsPageCoordinatesAndFullCellBoundsAtDifferentResolutions() { -+ for (final int resolution : new int[] {256, 512, 1024}) { -+ final int size = resolution / 16; -+ final var cell = new BedrockBitmapFont.Cell(size * 15, size * 7, size, size, -+ resolution, resolution, 8F / size, -.5F, -4, .01F); -+ assertEquals(15F / 16, cell.u0()); -+ assertEquals(7F / 16, cell.v0()); -+ assertEquals(7.5F, cell.right()); -+ assertEquals(4, cell.bottom()); ++ void rejectsCyclicComponentsAndUsesTheStitchersMissingGlyphForMalformedOutlines() throws Exception { ++ final byte[] bytes = fixture(); ++ final var data = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN); ++ final int composite = table(data, 0x676C7966) + data.getInt(table(data, 0x6C6F6361) + 4 * 4); ++ data.putShort(composite + 12, (short) 4); ++ try (var raster = new BedrockTrueTypeRaster(bytes, 32)) { ++ assertThrows(IOException.class, () -> raster.raster(67)); ++ } ++ try (var font = new BedrockTrueTypeFont(new BedrockTrueTypeRaster(bytes, 32), "")) { ++ final var stitcher = mock(UnbakedGlyph.Stitcher.class); ++ final var missing = mock(BakedGlyph.class); ++ when(stitcher.getMissing()).thenReturn(missing); ++ assertSame(missing, font.getGlyph(67).bake(stitcher)); ++ verify(stitcher).getMissing(); ++ verifyNoMoreInteractions(stitcher); + } + } + + @Test -+ void releasesUnusedAtlasOnceAndClosesInvalidAtlasWithoutUploading() throws Exception { -+ final var resources = mock(ResourceManager.class); -+ final var pixels = mock(NativeImage.class); -+ when(resources.open(any())).thenAnswer(_ -> new ByteArrayInputStream(new byte[0])); -+ when(pixels.getWidth()).thenReturn(32); -+ when(pixels.getHeight()).thenReturn(32); -+ try (final var decoder = mockStatic(NativeImage.class)) { -+ decoder.when(() -> NativeImage.read(any(InputStream.class))).thenReturn(pixels); -+ final var font = BedrockBitmapFont.loadUnicode(resources, -+ Identifier.fromNamespaceAndPath("viabedrock", "font/glyph_00.png"), new int[][] {{65, 66}, {0, 67}}, 8, 7); ++ void bakesNativeMetricsAndRetiresPreviouslyReturnedGlyphs() throws Exception { ++ final var font = new BedrockTrueTypeFont(new BedrockTrueTypeRaster(fixture(), 32), "B"); ++ try { ++ assertNull(font.getGlyph(66)); + final var glyph = font.getGlyph(65); + assertNotNull(glyph); -+ assertEquals(3, font.getSupportedGlyphs().size()); -+ font.close(); ++ assertEquals(3, glyph.info().getAdvance()); ++ final var stitcher = mock(UnbakedGlyph.Stitcher.class); ++ when(stitcher.stitch(any(), any())).thenReturn(mock(BakedGlyph.class)); ++ assertNotNull(glyph.bake(stitcher)); ++ final var bitmap = ArgumentCaptor.forClass(GlyphBitmap.class); ++ verify(stitcher).stitch(any(GlyphInfo.class), bitmap.capture()); ++ assertEquals(8, bitmap.getValue().getPixelWidth()); ++ assertEquals(32F / 6, bitmap.getValue().getOversample()); ++ assertEquals(-.09375F, bitmap.getValue().getBearingLeft()); + font.close(); -+ verify(pixels).close(); -+ assertThrows(IllegalStateException.class, () -> glyph.bake(null)); -+ -+ assertThrows(IOException.class, () -> BedrockBitmapFont.loadUnicode(resources, -+ Identifier.fromNamespaceAndPath("viabedrock", "font/glyph_00.png"), new int[][] {{65, 66, 67}}, 8, 7)); -+ verify(pixels, times(2)).close(); -+ } ++ assertNull(font.getGlyph(65)); ++ assertThrows(IllegalStateException.class, () -> glyph.bake(stitcher)); ++ } finally { font.close(); } + } + + @Test -+ void sharesOneTextureAcrossGlyphsAndRetiresItWithTheProvider() throws Exception { ++ void usesTheSelectedFontsOwnDescriptorAndKeepsOlderExportsOnVanillaLoading() throws Exception { ++ final var id = Identifier.parse("viabedrock:server_ui/0123456789abcdef0123456789abcdef.ttf"); ++ final var file = id.withPrefix("font/"); ++ final var pack = mock(PackResources.class); + final var resources = mock(ResourceManager.class); -+ final var pixels = mock(NativeImage.class); -+ final var game = mock(Minecraft.class); -+ final var manager = mock(TextureManager.class); -+ when(resources.open(any())).thenAnswer(_ -> new ByteArrayInputStream(new byte[0])); -+ when(pixels.getWidth()).thenReturn(32); -+ when(pixels.getHeight()).thenReturn(32); -+ when(game.getTextureManager()).thenReturn(manager); -+ try (final var decoder = mockStatic(NativeImage.class); -+ final var singleton = mockStatic(Minecraft.class); -+ final var renderSystem = mockStatic(RenderSystem.class); -+ final var renderTypes = mockStatic(GlyphRenderTypes.class); -+ final var textures = mockConstruction(DynamicTexture.class)) { -+ decoder.when(() -> NativeImage.read(any(InputStream.class))).thenReturn(pixels); -+ singleton.when(Minecraft::getInstance).thenReturn(game); -+ renderSystem.when(RenderSystem::isOnRenderThread).thenReturn(true); -+ renderTypes.when(() -> GlyphRenderTypes.createForColorTexture(any())).thenReturn(mock(GlyphRenderTypes.class)); -+ final var font = BedrockBitmapFont.loadUnicode(resources, -+ Identifier.fromNamespaceAndPath("viabedrock", "font/glyph_00.png"), new int[][] {{65, 66}, {0, 67}}, 8, 7); -+ final var stitcher = mock(UnbakedGlyph.Stitcher.class); -+ assertNotNull(font.getGlyph(65).bake(stitcher)); -+ assertNotNull(font.getGlyph(67).bake(stitcher)); -+ verifyNoInteractions(stitcher); -+ assertEquals(1, textures.constructed().size()); -+ verify(manager).register(any(), same(textures.constructed().getFirst())); -+ renderSystem.when(RenderSystem::isOnRenderThread).thenReturn(false); -+ font.close(); -+ font.close(); -+ final var disposal = ArgumentCaptor.forClass(Runnable.class); -+ verify(game).execute(disposal.capture()); -+ verify(manager, never()).release(any()); -+ disposal.getValue().run(); -+ verify(manager).release(any()); -+ verify(pixels, never()).close(); -+ assertThrows(IllegalStateException.class, () -> font.getGlyph(65).bake(stitcher)); ++ final byte[] bytes = fixture(); ++ when(resources.getResourceOrThrow(file)).thenReturn(new Resource(pack, () -> new ByteArrayInputStream(bytes))); ++ assertNull(BedrockTrueTypeFont.load(resources, id, "")); ++ when(pack.getResource(PackType.CLIENT_RESOURCES, file.withSuffix(".mcmeta"))).thenReturn(() -> ++ new ByteArrayInputStream("{\"viabedrock:truetype\":{\"render_height\":32}}".getBytes(StandardCharsets.UTF_8))); ++ try (var font = BedrockTrueTypeFont.load(resources, id, "A")) { ++ assertInstanceOf(BedrockTrueTypeFont.class, font); ++ assertNull(font.getGlyph(65)); ++ assertNotNull(font.getGlyph(66)); ++ } ++ verify(resources, times(2)).getResourceOrThrow(file); ++ verifyNoMoreInteractions(resources); ++ } ++ ++ private static int table(final ByteBuffer data, final int tag) { ++ for (int record = 12; record < 12 + data.getShort(4) * 16; record += 16) { ++ if (data.getInt(record) == tag) return data.getInt(record + 8); + } ++ throw new AssertionError("Missing test-font table"); ++ } ++} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiAnimationTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiAnimationTest.java +new file mode 100644 +index 0000000..847d083 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiAnimationTest.java +@@ -0,0 +1,74 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.render.oreui; ++ ++import java.awt.image.BufferedImage; ++import java.awt.image.IndexColorModel; ++import java.io.ByteArrayOutputStream; ++import java.io.IOException; ++import java.util.Arrays; ++import javax.imageio.IIOImage; ++import javax.imageio.ImageIO; ++import javax.imageio.metadata.IIOMetadataNode; ++import javax.imageio.stream.MemoryCacheImageOutputStream; ++import org.junit.jupiter.api.Test; ++import static org.junit.jupiter.api.Assertions.*; ++ ++class OreUiAnimationTest { ++ static byte[] animation() throws IOException { ++ final var bytes = new ByteArrayOutputStream(); ++ final var writer = ImageIO.getImageWritersByFormatName("gif").next(); ++ try (var output = new MemoryCacheImageOutputStream(bytes)) { ++ writer.setOutput(output); writer.prepareWriteSequence(null); ++ final var colors = new IndexColorModel(2, 4, new byte[]{0, -1, 0, 0}, new byte[]{0, 0, 0, -1}, new byte[]{0, 0, -1, 0}, 0); ++ final String[] disposal = {"doNotDispose", "restoreToPrevious", "restoreToBackgroundColor", "doNotDispose"}; ++ final int[][] pixels = {{1, 0}, {0, 2}, {3, 0}, {0, 2}}; ++ for (int i = 0; i < pixels.length; i++) { ++ final var image = new BufferedImage(2, 1, BufferedImage.TYPE_BYTE_INDEXED, colors); ++ image.getRaster().setSamples(0, 0, 2, 1, 0, pixels[i]); ++ final var metadata = writer.getDefaultImageMetadata(javax.imageio.ImageTypeSpecifier.createFromRenderedImage(image), null); ++ final var tree = (IIOMetadataNode) metadata.getAsTree("javax_imageio_gif_image_1.0"); ++ final var control = (IIOMetadataNode) tree.getElementsByTagName("GraphicControlExtension").item(0); ++ control.setAttribute("disposalMethod", disposal[i]); control.setAttribute("delayTime", Integer.toString((i + 1) * 10)); ++ control.setAttribute("transparentColorFlag", "TRUE"); control.setAttribute("transparentColorIndex", "0"); ++ metadata.setFromTree("javax_imageio_gif_image_1.0", tree); ++ writer.writeToSequence(new IIOImage(image, null, metadata), null); ++ } ++ writer.endWriteSequence(); output.flush(); ++ } finally { writer.dispose(); } ++ return bytes.toByteArray(); ++ } ++ ++ @Test void restoresTransparentBackgroundAndPreviousFramesAtOriginalDeliveryBoundaries() throws Exception { ++ final var animation = OreUiAnimation.decode(animation()); ++ assertArrayEquals(new int[]{0xffff0000, 0}, animation.frame(0).argb()); ++ assertSame(animation.frame(0), animation.frame(99)); ++ assertArrayEquals(new int[]{0xffff0000, 0xff0000ff}, animation.frame(100).argb()); ++ assertArrayEquals(new int[]{0xff00ff00, 0}, animation.frame(300).argb()); ++ assertArrayEquals(new int[]{0, 0xff0000ff}, animation.frame(600).argb()); ++ assertSame(animation.frame(0), animation.frame(1000)); ++ final int[] external = animation.frame(0).argb(); external[0] = 0; ++ assertEquals(0xffff0000, animation.frame(0).argb()[0]); ++ } ++ ++ @Test void rejectsAnOversizedCanvasBeforeAllocatingDecodedFrames() throws Exception { ++ final byte[] bytes = animation(); ++ bytes[6] = 0; bytes[7] = 8; bytes[8] = 0; bytes[9] = 8; ++ assertThrows(IOException.class, () -> OreUiAnimation.decode(bytes)); ++ assertThrows(IOException.class, () -> OreUiAnimation.decode(Arrays.copyOf(bytes, 13))); ++ } ++ ++ @Test void decodesTheAcquiredNativeSpinnerWithoutReplacingItsFramesOrDurations() throws Exception { ++ final var assets = OreUiAssets.bundled(); ++ final String path = "hbui/assets/animation-074ed0ba8c16bb30e36c.gif"; ++ org.junit.jupiter.api.Assumptions.assumeTrue(assets.images().contains(path), "Regenerated native OreUI images are required"); ++ final var animation = OreUiAnimation.decode(assets.image(path)); ++ assertEquals(28, animation.width()); assertEquals(28, animation.height()); ++ assertEquals(10, animation.frames().size()); assertEquals(1000, animation.durationMillis()); ++ for (final var frame : animation.frames()) assertEquals(100, frame.durationMillis()); ++ assertFalse(Arrays.equals(animation.frame(0).argb(), animation.frame(100).argb())); ++ } ++} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiAssetsTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiAssetsTest.java +new file mode 100644 +index 0000000..cdc6741 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiAssetsTest.java +@@ -0,0 +1,60 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.render.oreui; ++ ++import com.google.gson.JsonObject; ++import com.google.gson.JsonParser; ++import java.io.ByteArrayOutputStream; ++import java.io.IOException; ++import java.nio.charset.StandardCharsets; ++import java.security.MessageDigest; ++import java.util.HexFormat; ++import java.util.LinkedHashMap; ++import java.util.Map; ++import java.util.zip.ZipEntry; ++import java.util.zip.ZipOutputStream; ++import org.junit.jupiter.api.Test; ++import static org.junit.jupiter.api.Assertions.*; ++ ++class OreUiAssetsTest { ++ private static final String IMAGE = "hbui/assets/animation-ab.gif"; ++ private static Map files() throws Exception { ++ final Map files = new LinkedHashMap<>(); ++ files.put("oreui.json", ("{\"format\":1,\"version\":\"1.26.60.5\",\"stylesheet\":\"hbui/gameplay-ab.css\"," ++ + "\"fonts\":[{\"family\":\"Fixture\",\"file\":\"hbui/fonts/minimal.ttf\",\"weight\":400,\"style\":\"normal\",\"sdf\":\"default\"}]," ++ + "\"images\":[\"" + IMAGE + "\"]}").getBytes(StandardCharsets.UTF_8)); ++ files.put("NOTICE.txt", new byte[]{1}); files.put("hbui/gameplay-ab.css", new byte[]{1}); ++ files.put("hbui/fonts/minimal.ttf", new byte[]{1}); files.put(IMAGE, OreUiAnimationTest.animation()); ++ return files; ++ } ++ private static OreUiAssets read(final Map files) throws Exception { ++ final var output = new ByteArrayOutputStream(); ++ try (var zip = new ZipOutputStream(output)) { ++ for (final var file : files.entrySet()) { zip.putNextEntry(new ZipEntry(file.getKey())); zip.write(file.getValue()); zip.closeEntry(); } ++ } ++ final byte[] bytes = output.toByteArray(); ++ final var manifest = new JsonObject(); manifest.addProperty("format", 1); manifest.addProperty("version", "1.26.60.5"); ++ manifest.addProperty("size", bytes.length); manifest.addProperty("sha256", HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes))); ++ return OreUiAssets.read(bytes, manifest); ++ } ++ @Test void exposesOnlyRegisteredImagesWithoutAllowingCallersToMutateTheBundle() throws Exception { ++ final var files = files(); final var assets = read(files); ++ final byte[] copy = assets.image(IMAGE); copy[0] = 0; ++ assertArrayEquals(files.get(IMAGE), assets.image(IMAGE)); ++ assertThrows(IOException.class, () -> assets.image("hbui/fonts/minimal.ttf")); ++ assertThrows(UnsupportedOperationException.class, () -> assets.images().clear()); ++ } ++ @Test void refusesUnregisteredMissingAndMalformedImagesDespiteAValidOuterChecksum() throws Exception { ++ final var files = files(); final byte[] image = files.remove(IMAGE); ++ assertThrows(IOException.class, () -> read(files)); ++ files.put(IMAGE, image); ++ final var descriptor = JsonParser.parseString(new String(files.get("oreui.json"), StandardCharsets.UTF_8)).getAsJsonObject(); ++ descriptor.remove("images"); files.put("oreui.json", descriptor.toString().getBytes(StandardCharsets.UTF_8)); ++ assertThrows(IOException.class, () -> read(files)); ++ files.put("oreui.json", files().get("oreui.json")); files.put(IMAGE, new byte[]{1, 2, 3}); ++ assertThrows(IOException.class, () -> read(files)); + } ++} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiBoxTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiBoxTest.java +new file mode 100644 +index 0000000..f96d7cb +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiBoxTest.java +@@ -0,0 +1,39 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.render.oreui; ++ ++import org.junit.jupiter.api.Test; ++import static org.junit.jupiter.api.Assertions.*; + ++class OreUiBoxTest { + @Test -+ void loadsBitmapPixelsFromTheDescriptorsPackAndRetainsSourceAdvances() throws Exception { -+ final var file = Identifier.fromNamespaceAndPath("viabedrock", "font/bedrock_bitmap/default8_0.png"); -+ final var nativeFile = Identifier.fromNamespaceAndPath("viabedrock", "textures/font/bedrock_bitmap/default8_0_native.png"); -+ final var resources = mock(ResourceManager.class); -+ final var resource = mock(Resource.class); -+ final var pack = mock(PackResources.class); -+ final var source = mock(GlyphProvider.class); -+ final var original = mock(UnbakedGlyph.class); -+ final var pixels = mock(NativeImage.class); -+ when(resources.getResourceOrThrow(file.withPrefix("textures/"))).thenReturn(resource); -+ when(resource.source()).thenReturn(pack); -+ when(pack.getResource(PackType.CLIENT_RESOURCES, file.withPrefix("textures/").withSuffix(".mcmeta"))) -+ .thenAnswer(_ -> (IoSupplier) () -> new ByteArrayInputStream( -+ ("{\"viabedrock:font_atlas\":{\"texture\":\"viabedrock:font/bedrock_bitmap/default8_0_native.png\",\"height\":12,\"y_offset\":-8}}") -+ .getBytes(StandardCharsets.UTF_8))); -+ when(pack.getResource(PackType.CLIENT_RESOURCES, nativeFile)) -+ .thenAnswer(_ -> (IoSupplier) () -> new ByteArrayInputStream(new byte[0])); -+ when(pixels.getWidth()).thenReturn(128); -+ when(pixels.getHeight()).thenReturn(128); -+ when(source.getGlyph(65)).thenReturn(original); -+ when(original.info()).thenReturn(GlyphInfo.simple(5.5F)); -+ final int[][] grid = new int[10][16]; -+ grid[3][1] = 65; -+ try (final var decoder = mockStatic(NativeImage.class)) { -+ decoder.when(() -> NativeImage.read(any(InputStream.class))).thenReturn(pixels); -+ final var font = BedrockBitmapFont.loadBitmap(source, resources, file, grid); -+ assertEquals(5.5F, font.getGlyph(65).info().getAdvance()); -+ verify(source).close(); -+ verify(pack).getResource(PackType.CLIENT_RESOURCES, nativeFile); -+ verify(resources, never()).open(any()); -+ font.close(); -+ font.close(); -+ verify(pixels).close(); -+ when(pack.getResource(PackType.CLIENT_RESOURCES, nativeFile)).thenReturn(null); -+ assertThrows(IOException.class, () -> BedrockBitmapFont.loadBitmap(source, resources, file, grid)); -+ verify(source, times(2)).close(); -+ verify(resources, never()).getResourceOrThrow(nativeFile); -+ } -+ final var cell = BedrockBitmapFont.Cell.bitmap(1, 3, 8, 12, 128, 128, -8); -+ assertEquals(8F / 128, cell.u0()); -+ assertEquals(.12499219179153442F, cell.u1()); -+ assertEquals(36F / 128, cell.v0()); -+ assertEquals((48 - .001F) / 128, cell.v1()); -+ assertEquals(4, cell.bottom()); -+ final var higherResolution = BedrockBitmapFont.Cell.bitmap(1, 3, 16, 24, 256, 256, -8); -+ assertEquals(cell.u0(), higherResolution.u0()); -+ assertEquals(cell.u1(), higherResolution.u1()); -+ assertEquals(cell.v0(), higherResolution.v0()); -+ assertEquals(cell.v1(), higherResolution.v1()); -+ assertEquals(cell.bottom(), higherResolution.bottom()); ++ void appliesBordersAndPaddingBeforeSnappingThePaintedContent() { ++ final var bounds = new OreUiBox(.4F, .4F, 10.4F, 20.4F); ++ final var insets = new OreUiBox.Insets(.4F, .4F, .4F, .4F); ++ assertEquals(new OreUiBox(1F, 1F, 9F, 19F), bounds.content(insets, insets, true)); ++ final var raw = bounds.content(insets, insets, false); ++ assertEquals(1.2F, raw.x()); assertEquals(1.2F, raw.y()); ++ assertEquals(8.799999F, raw.width()); assertEquals(18.8F, raw.height()); + } + + @Test -+ void keepsJavaBitmapProviderWhenItsWinningPackHasNoNativeDescriptor() throws Exception { -+ final var file = Identifier.fromNamespaceAndPath("viabedrock", "font/bedrock_bitmap/default8_0.png"); -+ final var resources = mock(ResourceManager.class); -+ final var resource = mock(Resource.class); -+ final var pack = mock(PackResources.class); -+ final var source = mock(GlyphProvider.class); -+ when(resources.getResourceOrThrow(file.withPrefix("textures/"))).thenReturn(resource); -+ when(resource.source()).thenReturn(pack); -+ assertSame(source, BedrockBitmapFont.loadBitmap(source, resources, file, new int[16][16])); -+ verifyNoInteractions(source); -+ verify(resources, never()).open(any()); ++ void retainsTheSignedFractionWhenRoundingDimensions() { ++ // Original 1.26.60.5 content-box controls. Rounding absolute edges gives a different width. ++ assertEquals(new OreUiBox(-2F, -1F, 2F, 2F), new OreUiBox(-1.5F, -.5F, 1F, 1F).snapped()); + } + -+ private static BedrockBitmapFont.Metrics glyph(final int codepoint, final int cell, final int renderHeight, -+ final int left, final int right) { -+ return BedrockBitmapFont.measure(codepoint, cell, cell, renderHeight, -+ (x, y) -> x >= left && x < right && y == cell - 2 ? 1 : 0); ++ @Test ++ void snapsTheContentOriginBeforeAddingTheCachedTextBaseline() throws Exception { ++ final var bounds = new OreUiBox(480F, 498.4F, 320F, 48F); ++ final var border = new OreUiBox.Insets(2F, 2F, 2F, 2F); ++ final var padding = new OreUiBox.Insets(20F, 20F, 8F, 8F); ++ final var content = bounds.content(border, padding, true); ++ final var metrics = new OreUiFont.VerticalMetrics(-.99F, .25F, 0F).line(20F, 24F); ++ assertEquals(new OreUiBox(502F, 508F, 276F, 28F), content); ++ assertEquals(527F, content.y() + metrics.baselineOffset()); ++ assertEquals(527.4F, bounds.content(border, padding, false).y() + metrics.baselineOffset()); + } +} -diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/font/BedrockBitmapGlyphTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/font/BedrockBitmapGlyphTest.java +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiCharactersTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiCharactersTest.java new file mode 100644 -index 0000000..8724755 +index 0000000..199dcb5 --- /dev/null -+++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/font/BedrockBitmapGlyphTest.java -@@ -0,0 +1,107 @@ ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiCharactersTest.java +@@ -0,0 +1,60 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ -+package com.viaversion.viafabricplus.bedrock.render.font; ++package com.viaversion.viafabricplus.bedrock.render.oreui; + -+import com.mojang.blaze3d.font.GlyphInfo; -+import com.mojang.blaze3d.platform.Window; -+import com.mojang.blaze3d.vertex.VertexConsumer; -+import com.mojang.renderpearl.api.textures.GpuTextureView; -+import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiViewport; -+import net.minecraft.client.Minecraft; -+import net.minecraft.client.gui.font.GlyphRenderTypes; -+import net.minecraft.client.gui.font.TextRenderable; -+import net.minecraft.client.gui.font.glyphs.BakedSheetGlyph; -+import net.minecraft.network.chat.Style; -+import org.joml.Matrix4f; ++import com.google.gson.JsonArray; ++import com.google.gson.JsonParser; ++import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; -+import org.mockito.ArgumentCaptor; -+import java.util.List; -+ +import static org.junit.jupiter.api.Assertions.*; -+import static org.mockito.ArgumentMatchers.*; -+import static org.mockito.Mockito.*; + -+final class BedrockBitmapGlyphTest { ++class OreUiCharactersTest { + @Test -+ void packsGuiLocalVerticesBeforeThePoseAndPreservesWorldTextAndLight() { -+ final var cell = new BedrockBitmapFont.Cell(16, 64, 16, 16, 256, 256, .5F, 0, 0, .01F); -+ final var source = new BakedSheetGlyph(GlyphInfo.simple(4), mock(GlyphRenderTypes.class), mock(GpuTextureView.class), -+ cell.u0(), cell.u1(), cell.v0(), cell.v1(), cell.left(), cell.right(), cell.top(), cell.bottom()); -+ final var bitmap = new BedrockBitmapGlyph(source, cell, false); -+ final var unicode = new BedrockBitmapGlyph(source, cell, true); -+ final TextRenderable.Styled glyph; -+ try (final var viewport = mockStatic(NativeUiViewport.class)) { -+ viewport.when(NativeUiViewport::current).thenReturn(new NativeUiViewport(1280, 720, 1, 1)); -+ glyph = unicode.createGlyph(109.01F, 0, -1, 0, Style.EMPTY, 0, 0); -+ assertTrue(BedrockBitmapPipeline.isSmoothed(glyph.guiPipeline())); -+ assertFalse(BedrockBitmapPipeline.isSmoothed(bitmap.createGlyph(0, 0, -1, 0, Style.EMPTY, 0, 0).guiPipeline())); -+ viewport.when(NativeUiViewport::current).thenReturn(new NativeUiViewport(640, 360, 2, 2)); -+ assertFalse(BedrockBitmapPipeline.isSmoothed(unicode.createGlyph(0, 0, -1, 0, Style.EMPTY, 0, 0).guiPipeline())); -+ } -+ final var vertices = mock(VertexConsumer.class, RETURNS_SELF); -+ final var pose = new Matrix4f().translate(20, 30, 0).scale(2); -+ final var client = mock(Minecraft.class); -+ final var window = mock(Window.class); -+ when(client.getWindow()).thenReturn(window); -+ when(window.getGuiScale()).thenReturn(2); -+ try (final var minecraft = mockStatic(Minecraft.class)) { -+ minecraft.when(Minecraft::getInstance).thenReturn(client); -+ glyph.render(pose, vertices, 0x00A00050, true); ++ void preservesNativeSourceOffsetsAcrossNewlinesSupplementaryCharactersAndCollapsedWhitespace() throws Exception { ++ try (var stream = OreUiCharactersTest.class.getResourceAsStream("/oreui/native-characters-2225.json")) { ++ final var fixture = JsonParser.parseString(new String(stream.readAllBytes(), StandardCharsets.UTF_8)).getAsJsonObject(); ++ for (final var entry : fixture.getAsJsonArray("cases")) { ++ final var sample = entry.getAsJsonObject(); ++ final String source = sample.get("source").getAsString(); ++ final var result = OreUiCharacters.prepare(source, OreUiCharacters.LineEndings.values()[sample.get("mode").getAsInt()], ++ OreUiLines.WhiteSpace.values()[sample.get("whitespace").getAsInt()], 20F, .4F); ++ assertArrayEquals(integers(sample.getAsJsonArray("scalars")), result.scalars(), sample.toString()); ++ assertArrayEquals(integers(sample.getAsJsonArray("utf16Offsets")), ++ result.characters().stream().mapToInt(OreUiCharacters.Scalar::sourceOffset).toArray(), sample.toString()); ++ assertEquals(sample.get("trailingAdvance").getAsFloat(), result.trailingAdvance(), sample.toString()); ++ assertEquals(source.length(), result.sourceOffset(result.characters().size()), sample.toString()); ++ } + } -+ verify(vertices).addVertex(238F, 30F - 1F / 512, 0F); -+ verify(vertices).addVertex(238F, 46F - 1F / 512, 0F); -+ verify(vertices, times(2)).addVertex(eq(254F), anyFloat(), eq(0F)); -+ verify(vertices, times(4)).setUv1(16, 64); -+ verify(vertices, times(4)).setUv3(16, 16); -+ verify(vertices).setUv(cell.u0(), cell.v0()); -+ verify(vertices).setUv(cell.u1(), cell.v1()); -+ verify(vertices, times(4)).setUv2(80, 160); -+ assertEquals(109.01F, glyph.activeLeft()); -+ assertEquals(113.01F, glyph.activeRight()); -+ assertTrue(BedrockBitmapPipeline.isSmoothed(glyph.guiPipeline())); ++ } + -+ final var world = mock(VertexConsumer.class, RETURNS_SELF); -+ glyph.render(pose, world, 0x00A00050, false); -+ verify(world, times(2)).addVertex(same(pose), eq(109.01F), anyFloat(), anyFloat()); -+ verify(world, never()).setUv1(anyInt(), anyInt()); -+ verify(world).setUv(cell.u0(), cell.v0()); -+ verify(world, times(4)).setLight(0x00A00050); ++ @Test ++ void wrapsTheCollapsedTextAndMapsItsSecondLineBackToTheOriginalSource() { ++ final var source = OreUiCharacters.prepare(" \ud83d\ude00\t\r\nB ", OreUiCharacters.LineEndings.NORMALIZE, ++ OreUiLines.WhiteSpace.NORMAL, 20F, .4F); ++ assertEquals("\ud83d\ude00 B", source.text()); ++ final var layout = OreUiLines.layout(source.scalars(), new float[]{10F, 4F, 10F}, ++ new OreUiFont.LineMetrics(20F, 24F), 15F, 0F, 0F, OreUiLines.WhiteSpace.NORMAL, ++ OreUiLines.OverflowWrap.NORMAL, OreUiLines.Alignment.LEFT, false); ++ assertEquals(2, layout.lines().size()); ++ final var second = layout.lines().get(1); ++ assertEquals("B ", " \ud83d\ude00\t\r\nB ".substring(source.sourceOffset(second.start()), source.sourceOffset(second.end()))); ++ assertEquals(48F, layout.height()); + } ++ + @Test -+ void usesFixedItalicEdgesForTallBitmapCellsAndPreservesWorldVertices() { -+ final var cell = BedrockBitmapFont.Cell.bitmap(1, 4, 8, 12, 128, 128, -3); -+ final var sheet = new BakedSheetGlyph(GlyphInfo.simple(4), mock(GlyphRenderTypes.class), mock(GpuTextureView.class), -+ cell.u0(), cell.u1(), cell.v0(), cell.v1(), cell.left(), cell.right(), cell.top(), cell.bottom()); -+ final var style = Style.EMPTY.withItalic(true); -+ final var glyph = new BedrockBitmapGlyph(sheet, cell, false).createGlyph(0, 0, -1, 0, style, 0, 0); -+ final var pose = new Matrix4f(); -+ final var client = mock(Minecraft.class); -+ final var window = mock(Window.class); -+ when(client.getWindow()).thenReturn(window); -+ when(window.getGuiScale()).thenReturn(1); -+ final var gui = mock(VertexConsumer.class, RETURNS_SELF); -+ try (final var minecraft = mockStatic(Minecraft.class)) { -+ minecraft.when(Minecraft::getInstance).thenReturn(client); -+ glyph.render(pose, gui, 0, true); ++ void rejectsUnpairedSurrogatesInsteadOfReplacingTheSourceCharacter() { ++ for (final String source : new String[]{"A\ud800", "\udc00B", "\ud800B"}) { ++ assertThrows(IllegalArgumentException.class, () -> OreUiCharacters.prepare(source, ++ OreUiCharacters.LineEndings.NORMALIZE, OreUiLines.WhiteSpace.NORMAL, 20F, 0F)); + } -+ final var guiX = ArgumentCaptor.forClass(Float.class); -+ verify(gui, times(4)).addVertex(guiX.capture(), anyFloat(), anyFloat()); -+ // Original 1.26.60.5 bitmap writer and quad expansion for height 12 and offset -3. -+ assertEquals(List.of(1F, -1F, 7F, 9F), guiX.getAllValues()); -+ assertTrue(glyph.left() <= -1); -+ assertTrue(glyph.right() >= 9); -+ assertTrue(glyph.activeLeft() <= -1); -+ assertTrue(glyph.activeRight() >= 5); -+ -+ final var world = mock(VertexConsumer.class, RETURNS_SELF); -+ glyph.render(pose, world, 0, false); -+ final var worldX = ArgumentCaptor.forClass(Float.class); -+ verify(world, times(4)).addVertex(same(pose), worldX.capture(), anyFloat(), anyFloat()); -+ assertEquals(List.of(1.75F, -1.25F, 6.75F, 9.75F), worldX.getAllValues()); -+ assertNotEquals(guiX.getAllValues(), worldX.getAllValues()); + } + ++ private static int[] integers(final JsonArray values) { ++ final int[] result = new int[values.size()]; ++ for (int index = 0; index < result.length; index++) result[index] = values.get(index).getAsInt(); ++ return result; ++ } +} -diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/font/BedrockBitmapShaderGpuTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/font/BedrockBitmapShaderGpuTest.java +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiFontTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiFontTest.java new file mode 100644 -index 0000000..0e8ffc2 +index 0000000..1a859f5 --- /dev/null -+++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/font/BedrockBitmapShaderGpuTest.java -@@ -0,0 +1,291 @@ ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiFontTest.java +@@ -0,0 +1,226 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ -+package com.viaversion.viafabricplus.bedrock.render.font; ++package com.viaversion.viafabricplus.bedrock.render.oreui; + -+import com.viaversion.viafabricplus.bedrock.render.EglTestContext; +import java.io.IOException; -+import java.nio.charset.StandardCharsets; -+import java.util.List; -+import org.junit.jupiter.api.Test; -+import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; -+import org.lwjgl.system.MemoryStack; -+import org.lwjgl.system.MemoryUtil; -+ -+import static org.junit.jupiter.api.Assertions.*; -+import static org.lwjgl.opengl.GL33C.*; -+ -+@EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_FONT_GPU_TESTS", matches = "1") -+final class BedrockBitmapShaderGpuTest { -+ @Test -+ void reproducesPackedOriginsAndClampBoundsFromTheNativeGpuDraw() { -+ EglTestContext.run(() -> { -+ final int program = program(false, true), vao = glGenVertexArrays(); -+ final int texture = glGenTextures(), output = glGenBuffers(); -+ try { -+ glBindVertexArray(vao); -+ glBindTexture(GL_TEXTURE_2D, texture); -+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 128, 128, 0, GL_RGBA, GL_UNSIGNED_BYTE, 0L); -+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); -+ glUseProgram(program); -+ glUniform1i(glGetUniformLocation(program, "Sampler0"), 0); -+ glVertexAttrib3f(0, 12, 9, 0); -+ glVertexAttrib4f(1, 1, 0, 0, 1); -+ glVertexAttrib2f(2, .5625F, .5624921917915344F); -+ glVertexAttribI2i(3, 72, 60); -+ glVertexAttrib2f(4, 8, 12); -+ glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, output); -+ glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, 6L * Float.BYTES, GL_STREAM_READ); -+ glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, output); -+ glEnable(GL_RASTERIZER_DISCARD); -+ glBeginTransformFeedback(GL_POINTS); -+ glDrawArrays(GL_POINTS, 0, 1); -+ glEndTransformFeedback(); -+ glDisable(GL_RASTERIZER_DISCARD); -+ final float[] actual = new float[6]; -+ glGetBufferSubData(GL_TRANSFORM_FEEDBACK_BUFFER, 0, actual); -+ // Original 1.26.60.5 UI/Text vertex outputs, including a source origin above 0.5. -+ assertArrayEquals(new float[] {.5625085830688477F, .562507152557373F, -+ .5664148330688477F, .47266340255737305F, .6211023330688477F, .558600902557373F}, actual, 1E-7F); -+ } finally { -+ glDisable(GL_RASTERIZER_DISCARD); -+ glUseProgram(0); -+ glDeleteBuffers(output); -+ glDeleteTextures(texture); -+ glDeleteVertexArrays(vao); -+ glDeleteProgram(program); ++import java.io.ByteArrayOutputStream; ++import java.nio.charset.StandardCharsets; ++import java.security.MessageDigest; ++import java.util.Arrays; ++import java.util.HexFormat; ++import java.util.LinkedHashMap; ++import java.util.List; ++import java.util.zip.ZipEntry; ++import java.util.zip.ZipOutputStream; ++import com.google.gson.JsonParser; ++import org.junit.jupiter.api.Test; ++import static org.junit.jupiter.api.Assertions.*; ++import static org.junit.jupiter.api.Assumptions.assumeTrue; ++ ++class OreUiFontTest { ++ private static byte[] resource(final String name) throws IOException { ++ try (var input = OreUiFontTest.class.getResourceAsStream("/oreui/" + name)) { ++ assertNotNull(input); ++ return input.readAllBytes(); ++ } ++ } ++ ++ private static OreUiShaper engine() throws IOException { ++ assumeTrue(OreUiShaper.class.getResource("/assets/viafabricplus-bedrock/oreui-shaper/" + OreUiShaper.platform() + "/manifest.json") != null, ++ "Bundled native OreUI engine is required"); ++ return OreUiShaper.bundled(); ++ } ++ ++ private static OreUiAssets assets() throws Exception { return assets("minimal.ttf"); } ++ ++ private static OreUiAssets assets(final String fixture) throws Exception { return assets(fixture, null); } ++ ++ private static OreUiAssets assets(final String fixture, final String fallback) throws Exception { ++ final var files = new LinkedHashMap(); ++ files.put("oreui.json", ("{\"format\":1,\"version\":\"1.26.60.5\",\"stylesheet\":\"hbui/gameplay-0000.css\",\"fonts\":[" ++ + "{\"family\":\"Fixture\",\"file\":\"hbui/fonts/minimal.ttf\",\"weight\":400,\"style\":\"normal\",\"sdf\":\"default\"}," ++ + "{\"family\":\"Fixture\",\"file\":\"hbui/fonts/minimal.ttf\",\"weight\":700,\"style\":\"normal\",\"sdf\":\"off\"}" ++ + (fallback == null ? "" : ",{\"family\":\"Fallback\",\"file\":\"hbui/fonts/fallback.ttf\",\"weight\":400,\"style\":\"normal\",\"sdf\":\"default\"}") + "]}" ++ ).getBytes(StandardCharsets.UTF_8)); ++ files.put("hbui/gameplay-0000.css", "fixture".getBytes(StandardCharsets.UTF_8)); ++ files.put("NOTICE.txt", "Authored test font".getBytes(StandardCharsets.UTF_8)); ++ files.put("hbui/fonts/minimal.ttf", resource(fixture)); ++ if (fallback != null) files.put("hbui/fonts/fallback.ttf", resource(fallback)); ++ final var output = new ByteArrayOutputStream(); ++ try (var zip = new ZipOutputStream(output)) { ++ for (final var entry : files.entrySet()) { ++ zip.putNextEntry(new ZipEntry(entry.getKey())); zip.write(entry.getValue()); zip.closeEntry(); + } -+ }); ++ } ++ final byte[] bytes = output.toByteArray(); ++ final String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); ++ return OreUiAssets.read(bytes, JsonParser.parseString("{\"format\":1,\"version\":\"1.26.60.5\",\"size\":" + bytes.length ++ + ",\"sha256\":\"" + hash + "\"}").getAsJsonObject()); + } + + @Test -+ void appliesNativeUnicodeSmoothingToEachSampledColorChannel() { -+ EglTestContext.run(() -> { -+ final int program = program(true, false), vao = glGenVertexArrays(); -+ final int buffer = glGenBuffers(), texture = glGenTextures(); -+ try (var stack = MemoryStack.stackPush()) { -+ glBindVertexArray(vao); -+ glBindBuffer(GL_ARRAY_BUFFER, buffer); -+ glBufferData(GL_ARRAY_BUFFER, stack.floats(-1, -1, 0, 0, 1, 1, -1, 0, 1, 1, -+ -1, 1, 0, 0, 0, 1, 1, 0, 1, 0), GL_STATIC_DRAW); -+ glEnableVertexAttribArray(0); -+ glVertexAttribPointer(0, 3, GL_FLOAT, false, 20, 0L); -+ glEnableVertexAttribArray(2); -+ glVertexAttribPointer(2, 2, GL_FLOAT, false, 20, 12L); -+ glVertexAttrib4f(1, 1, 1, 1, 1); -+ glVertexAttribI2i(3, 0, 0); -+ glVertexAttrib2f(4, 1, 1); -+ glBindTexture(GL_TEXTURE_2D, texture); -+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, -+ stack.bytes((byte) 102, (byte) 102, (byte) 102, (byte) 102)); -+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); -+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); -+ glUseProgram(program); -+ final var identity = stack.floats(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); -+ glUniformMatrix4fv(glGetUniformLocation(program, "ModelViewMat"), false, identity); -+ glUniformMatrix4fv(glGetUniformLocation(program, "ProjMat"), false, identity); -+ glUniform4f(glGetUniformLocation(program, "ColorModulator"), 1, 1, 1, 1); -+ glUniform1i(glGetUniformLocation(program, "Sampler0"), 0); -+ glViewport(0, 0, 1, 1); -+ glDisable(GL_BLEND); -+ glDisable(GL_DEPTH_TEST); -+ glDisable(GL_CULL_FACE); -+ glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); -+ final var pixel = stack.malloc(4); -+ glReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); -+ for (int channel = 0; channel < 4; channel++) assertEquals(128, Byte.toUnsignedInt(pixel.get(channel)), 1); -+ } finally { -+ glUseProgram(0); -+ glDeleteTextures(texture); -+ glDeleteBuffers(buffer); -+ glDeleteVertexArrays(vao); -+ glDeleteProgram(program); ++ void measuresDisjointFontCoverageWithoutReplacingThePrimaryCharacters() throws Exception { ++ final var assets = assets("minimal.ttf", "fallback-coverage.ttf"); ++ final var characters = OreUiCharacters.prepare("aBa", OreUiCharacters.LineEndings.NORMALIZE, ++ OreUiLines.WhiteSpace.NORMAL, 13F, 0F); ++ try (var text = new OreUiText(assets, engine())) { ++ final var result = text.ordinaryFonts(List.of(assets.faces().getFirst(), assets.faces().get(2)), 13, characters, "en"); ++ assertEquals(List.of(new OreUiFonts.Glyph('a', 1, 0, 499F / 64F), ++ new OreUiFonts.Glyph('B', 1, 1, 749F / 64F), new OreUiFonts.Glyph('a', 1, 0, 499F / 64F)), result.glyphs()); ++ final float[] advances = new float[result.glyphs().size()]; ++ final int[] fontMap = new int[advances.length]; ++ for (int index = 0; index < advances.length; index++) { ++ advances[index] = result.glyphs().get(index).advance(); ++ fontMap[index] = result.glyphs().get(index).font(); + } -+ }); ++ final var layout = OreUiLines.layout(characters.scalars(), advances, fontMap, ++ List.of(new OreUiLines.Font(1, text.lineMetrics(assets.faces().getFirst(), 13, null)), ++ new OreUiLines.Font(2, text.lineMetrics(assets.faces().get(2), 13, null))), ++ 18F, 0F, 0F, OreUiLines.WhiteSpace.NORMAL, OreUiLines.OverflowWrap.NORMAL, OreUiLines.Alignment.LEFT, false); ++ assertEquals(List.of(1, 2, 1), layout.lines().stream().map(line -> line.fragments().getFirst().font()).toList()); ++ assertEquals(749F / 64F, layout.width()); ++ assertEquals(42F, layout.height()); ++ } + } + + @Test -+ void retainsNativeTopEdgeAndUnshiftedSamplesInAnOffscreenTarget() { -+ EglTestContext.run(() -> { -+ final int program = program(false, false), vao = glGenVertexArrays(), buffer = glGenBuffers(); -+ final int atlas = glGenTextures(), target = glGenTextures(), framebuffer = glGenFramebuffers(); -+ try (var stack = MemoryStack.stackPush()) { -+ glBindTexture(GL_TEXTURE_2D, target); -+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 16, 16, 0, GL_RGBA, GL_UNSIGNED_BYTE, 0L); -+ glBindFramebuffer(GL_FRAMEBUFFER, framebuffer); -+ glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, target, 0); -+ assertEquals(GL_FRAMEBUFFER_COMPLETE, glCheckFramebufferStatus(GL_FRAMEBUFFER)); -+ glBindTexture(GL_TEXTURE_2D, atlas); -+ final var texels = stack.malloc(8 * 8 * 4); -+ for (int y = 0; y < 8; y++) { -+ for (int x = 0; x < 8; x++) { -+ texels.put((byte) (y * 32)).put((byte) (x * 32)).put((byte) 0).put((byte) 255); -+ } -+ } -+ texels.flip(); -+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 8, 8, 0, GL_RGBA, GL_UNSIGNED_BYTE, texels); -+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); -+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); -+ glBindVertexArray(vao); -+ glBindBuffer(GL_ARRAY_BUFFER, buffer); -+ glEnableVertexAttribArray(0); -+ glVertexAttribPointer(0, 3, GL_FLOAT, false, 20, 0L); -+ glEnableVertexAttribArray(2); -+ glVertexAttribPointer(2, 2, GL_FLOAT, false, 20, 12L); -+ glVertexAttrib4f(1, 1, 1, 1, 1); -+ glVertexAttribI2i(3, 0, 0); -+ glVertexAttrib2f(4, 8, 8); -+ glUseProgram(program); -+ glUniformMatrix4fv(glGetUniformLocation(program, "ModelViewMat"), false, -+ stack.floats(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)); -+ glUniform4f(glGetUniformLocation(program, "ColorModulator"), 1, 1, 1, 1); -+ glUniform1i(glGetUniformLocation(program, "Sampler0"), 0); -+ glViewport(0, 0, 16, 16); -+ glDisable(GL_BLEND); -+ glDisable(GL_DEPTH_TEST); -+ glDisable(GL_CULL_FACE); -+ for (int guiScale = 1; guiScale <= 2; guiScale++) { -+ final float unit = 1F / guiScale; -+ glUniformMatrix4fv(glGetUniformLocation(program, "ProjMat"), false, -+ stack.floats(guiScale / 8F, 0, 0, 0, 0, -guiScale / 8F, 0, 0, 0, 0, 1, 0, -1, 1, 0, 1)); -+ for (final float phase : new float[] {0, .25F, .5F, .75F, .5F - 1F / 1024, .5F + 1F / 1024, -+ .5F - 3F / 1024, .5F + 3F / 1024}) { -+ final float left = 3 * unit; -+ final float top = (3 + phase) * unit; -+ final float shifted = BedrockBitmapGlyph.rasterSnap(top, guiScale) - unit / 256; -+ glBufferData(GL_ARRAY_BUFFER, stack.floats( -+ left, shifted, 0, 0, 0, left, shifted + 8 * unit, 0, 0, 1, -+ left + 8 * unit, shifted, 0, 1, 0, left + 8 * unit, shifted + 8 * unit, 0, 1, 1), GL_STREAM_DRAW); -+ glClearColor(0, 0, 0, 0); -+ glClear(GL_COLOR_BUFFER_BIT); -+ glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); -+ final var pixels = stack.malloc(16 * 16 * 4); -+ glReadPixels(0, 0, 16, 16, GL_RGBA, GL_UNSIGNED_BYTE, pixels); -+ for (int y = 0; y < 16; y++) { -+ final float snappedTop = (float) Math.rint((3 + phase) * 256) / 256F; -+ final boolean covered = y + .5F >= snappedTop && y + .5F < snappedTop + 8; -+ final int index = ((15 - y) * 16 + 5) * 4; -+ assertEquals(covered ? 255 : 0, Byte.toUnsignedInt(pixels.get(index + 3)), -+ "GUI scale " + guiScale + ", phase " + phase + ", row " + y); -+ if (covered) assertEquals((int) (y + .5F - snappedTop) * 32, Byte.toUnsignedInt(pixels.get(index)), -+ "GUI scale " + guiScale + ", phase " + phase + ", sample row " + y); -+ } -+ } ++ void keepsPrimaryGlyphsWhileSelectingNativeLocaleFallbackFaces() throws Exception { ++ assumeTrue(OreUiAssets.class.getResource("/assets/viafabricplus-bedrock/builtin_oreui.zip") != null, ++ "Acquired native OreUI assets are required"); ++ final var assets = OreUiAssets.bundled(); ++ try (var stream = OreUiFontTest.class.getResourceAsStream("/oreui/native-locale-fonts-2225.json"); ++ var text = new OreUiText(assets, engine())) { ++ final var fixture = JsonParser.parseString(new String(stream.readAllBytes(), StandardCharsets.UTF_8)).getAsJsonObject(); ++ for (final var entry : fixture.getAsJsonArray("cases")) { ++ final var sample = entry.getAsJsonObject(); ++ final var faces = assets.regularFaces(sample.get("primaryFamily").getAsString(), sample.get("locale").getAsString()); ++ final var families = sample.getAsJsonArray("families"); ++ assertEquals(families.size(), faces.size(), sample.toString()); ++ for (int index = 0; index < faces.size(); index++) { ++ final String family = families.get(index).getAsString(); ++ if (family.equals("Unavailable")) assertNull(faces.get(index), sample.toString()); ++ else assertEquals(family, faces.get(index).family(), sample.toString()); + } -+ } finally { -+ glBindFramebuffer(GL_FRAMEBUFFER, 0); -+ glUseProgram(0); -+ glDeleteFramebuffers(framebuffer); -+ glDeleteTextures(atlas); -+ glDeleteTextures(target); -+ glDeleteBuffers(buffer); -+ glDeleteVertexArrays(vao); -+ glDeleteProgram(program); ++ final var characters = OreUiCharacters.prepare(sample.get("text").getAsString(), ++ OreUiCharacters.LineEndings.NORMALIZE, OreUiLines.WhiteSpace.NORMAL, 16F, 0F); ++ final var assigned = text.ordinaryFonts(faces, 16, characters, "en"); ++ assertArrayEquals(sample.getAsJsonArray("glyphs").asList().stream().mapToInt(value -> value.getAsInt()).toArray(), ++ assigned.glyphs().stream().mapToInt(OreUiFonts.Glyph::index).toArray(), sample.toString()); ++ assertArrayEquals(sample.getAsJsonArray("fonts").asList().stream().mapToInt(value -> value.getAsInt()).toArray(), ++ assigned.glyphs().stream().mapToInt(OreUiFonts.Glyph::font).toArray(), sample.toString()); + } -+ }); ++ } + } + + @Test -+ void retainsTheNativeAccentPixelAtAPointSampleBoundary() { -+ EglTestContext.run(() -> { -+ final String vertex = """ -+ #version 330 -+ #extension GL_ARB_separate_shader_objects : require -+ uniform vec2 SampleUv; -+ layout(location = 0) out vec4 vertexColor; -+ layout(location = 1) out vec2 glyphUv; -+ layout(location = 2) flat out vec4 glyphBounds; -+ layout(location = 3) noperspective out float glyphGuiY; -+ void main() { -+ vec2 corner = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2); -+ gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0); -+ vertexColor = vec4(1.0); -+ glyphUv = SampleUv; -+ glyphBounds = vec4(0.0, 0.0, 1.0, 1.0); -+ glyphGuiY = 0.0; -+ } -+ """; -+ final int program = program(false, false, vertex), vao = glGenVertexArrays(), atlas = glGenTextures(); -+ final var texels = MemoryUtil.memCalloc(384 * 384 * 4); -+ try (var stack = MemoryStack.stackPush()) { -+ glBindTexture(GL_TEXTURE_2D, atlas); -+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); -+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); -+ glBindVertexArray(vao); -+ glUseProgram(program); -+ glUniform1i(glGetUniformLocation(program, "Sampler0"), 0); -+ glUniform4f(glGetUniformLocation(program, "ColorModulator"), 1, 1, 1, 1); -+ glViewport(0, 0, 1, 1); -+ glDisable(GL_BLEND); -+ glDisable(GL_DEPTH_TEST); -+ glDisable(GL_CULL_FACE); -+ record Sample(float v, int channel) { } -+ final var pixel = stack.malloc(4); -+ for (final int size : new int[] {128, 384}) { -+ texels.clear(); -+ texels.limit(size * size * 4); -+ texels.putInt((3 * size + 2) * 4, -1); -+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, size, size, 0, GL_RGBA, GL_UNSIGNED_BYTE, texels); -+ // Captured Java coordinate at the missing À pixel, its boundary, and a distinct texel. -+ final var samples = List.of(new Sample(0.0234374962747097F * 128F / size, 255), -+ new Sample(3F / size, 255), new Sample((3F - 1F / 256F) / size, 0)); -+ for (final Sample sample : samples) { -+ glUniform2f(glGetUniformLocation(program, "SampleUv"), 2F / size, sample.v()); -+ glDrawArrays(GL_TRIANGLES, 0, 3); -+ glReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); -+ for (int channel = 0; channel < 4; channel++) { -+ assertEquals(sample.channel(), Byte.toUnsignedInt(pixel.get(channel)), -+ "Atlas size " + size + ", texture V=" + sample.v()); -+ } -+ } -+ texels.putInt((3 * size + 2) * 4, 0); -+ } -+ } finally { -+ MemoryUtil.memFree(texels); -+ glUseProgram(0); -+ glDeleteTextures(atlas); -+ glDeleteVertexArrays(vao); -+ glDeleteProgram(program); ++ void measuresOrdinaryCharactersWithoutChangingLigaturesInDrawRuns() throws Exception { ++ final var assets = assets("ordinary-advances.ttf"); ++ try (var text = new OreUiText(assets, engine())) { ++ final var face = assets.faces().getFirst(); ++ assertEquals(1, text.prepareDefault(face, 13, "aa", "en").run().glyphs().size()); ++ assertArrayEquals(new float[]{499F / 64F, 499F / 64F}, text.ordinaryAdvances(face, 13, "aa", "en")); ++ assertArrayEquals(new float[]{499F / 64F, 0F, 499F / 64F}, text.ordinaryAdvances(face, 13, "a\u0301a", "en")); ++ assertArrayEquals(new float[0], text.ordinaryAdvances(face, 13, "", "en")); ++ assertEquals(1, text.prepareDefault(face, 13, "aa", "en").run().glyphs().size()); ++ } ++ } ++ ++ @Test ++ void measuresNominalGlyphsAndExcludesSpacesFromLegacyKerning() throws Exception { ++ for (final String fixture : List.of("nominal-advances.ttf", "kerning-advances.ttf")) { ++ final var assets = assets(fixture); ++ try (var text = new OreUiText(assets, engine())) { ++ final var face = assets.faces().getFirst(); ++ final float a = 499F / 64F; ++ assertArrayEquals(new float[]{fixture.startsWith("kerning") ? a - 1F : a, a}, ++ text.ordinaryAdvances(face, 13, "aa", "en")); ++ assertArrayEquals(new float[]{a, 250F / 64F, a}, text.ordinaryAdvances(face, 13, "a a", "en")); ++ assertArrayEquals(new float[]{a, a, a}, text.ordinaryAdvances(face, 13, "a\uD83D\uDE00a", "en")); ++ assertArrayEquals(new float[]{a}, text.ordinaryAdvances(face, 13, "a", "en")); + } -+ }); ++ } + } + -+ private static int program(final boolean smooth, final boolean feedback) { -+ return program(smooth, feedback, resource("vsh", smooth)); ++ @Test ++ void selectsDefaultImagesAtTheNativeSizeBoundaryAndHonorsFaceOptOut() throws Exception { ++ final var assets = assets(); ++ try (var text = new OreUiText(assets, engine())) { ++ final var face = assets.faces().getFirst(); ++ final var small = text.prepareDefault(face, 9, "a", "en"); ++ assertFalse(small.sdf()); assertEquals(9, small.pixels()); ++ assertNull(small.images().get(1).distance()); ++ final var boundary = text.prepareDefault(face, 10, "a", "en"); ++ assertTrue(boundary.sdf()); assertEquals(52, boundary.pixels()); ++ assertEquals(10, boundary.shapingPixels()); ++ assertNotNull(boundary.images().get(1).distance()); ++ final var larger = text.prepareDefault(face, 32, "a", "en"); ++ assertSame(boundary.images().get(1), larger.images().get(1)); ++ final var optedOut = text.prepareDefault(assets.faces().get(1), 32, "a", "en"); ++ assertFalse(optedOut.sdf()); assertEquals(32, optedOut.pixels()); ++ assertNull(optedOut.images().get(1).distance()); ++ } + } + -+ private static int program(final boolean smooth, final boolean feedback, final String vertexSource) { -+ final int program = glCreateProgram(); -+ final int vertex = shader(GL_VERTEX_SHADER, vertexSource); -+ final int fragment = shader(GL_FRAGMENT_SHADER, resource("fsh", smooth)); -+ try { -+ glAttachShader(program, vertex); -+ glAttachShader(program, fragment); -+ if (feedback) glTransformFeedbackVaryings(program, new String[] {"glyphUv", "glyphBounds"}, GL_INTERLEAVED_ATTRIBS); -+ glLinkProgram(program); -+ assertEquals(GL_TRUE, glGetProgrami(program, GL_LINK_STATUS), glGetProgramInfoLog(program)); -+ return program; -+ } finally { glDeleteShader(vertex); glDeleteShader(fragment); } ++ @Test ++ void preparesDescriptorSelectedGlyphIdsAndNativeCacheImages() throws Exception { ++ // Original 1.26.60.5 parser, shaper and rasterizer execute on this authored font. ++ final var assets = assets(); ++ try (var text = new OreUiText(assets, engine())) { ++ final var prepared = text.prepare(assets.faces().getFirst(), 52, 52, true, "a\u0301", "en-US"); ++ assertEquals(new OreUiFont.LineMetrics(48F, 65F), prepared.lineMetrics(52F, 65F)); ++ final var run = prepared.run(); ++ assertEquals(List.of(new OreUiFont.Glyph(1, 0, 1997, 0, 0, 0), ++ new OreUiFont.Glyph(2, 0, 0, 0, -2001, -4)), run.glyphs()); ++ assertEquals(1997, run.advanceX()); ++ final var image = prepared.images().get(run.glyphs().getFirst().index()); ++ final var raster = image.raster(); ++ assertEquals(26, raster.width()); assertEquals(37, raster.height()); ++ assertEquals(1997, raster.advance()); ++ final byte[] expected = new byte[26 * 37]; ++ Arrays.fill(expected, 0, 26, (byte) 103); ++ Arrays.fill(expected, 26, expected.length, (byte) 255); ++ assertArrayEquals(expected, raster.pixels()); ++ final var field = image.distance(); ++ assertEquals(34, field.width()); assertEquals(45, field.height()); assertEquals(36, field.stride()); ++ assertArrayEquals(resource("minimal-a.sdf"), field.pixels()); ++ final var small = text.prepare(assets.faces().getFirst(), 52, 13, true, "a\u0301", "en-US"); ++ assertEquals(new OreUiFont.LineMetrics(12F, 17F), small.lineMetrics(13F, 16.25F)); ++ assertEquals(List.of(new OreUiFont.Glyph(1, 0, 499, 0, 0, 0), ++ new OreUiFont.Glyph(2, 0, 0, 0, -500, -1)), small.run().glyphs()); ++ assertSame(image, small.images().get(1), "Changing shaping size must reuse the independent glyph image"); ++ final var plain = text.prepare(assets.faces().getLast(), 52, 52, false, "a", "en-US"); ++ assertNull(plain.images().get(1).distance()); ++ assertArrayEquals(expected, plain.images().get(1).raster().pixels()); ++ assertThrows(IOException.class, () -> text.prepare(assets.faces().getLast(), 52, 52, true, "a", "en-US")); ++ } + } + -+ private static int shader(final int type, final String source) { -+ final int shader = glCreateShader(type); -+ glShaderSource(shader, source); -+ glCompileShader(shader); -+ assertEquals(GL_TRUE, glGetShaderi(shader, GL_COMPILE_STATUS), glGetShaderInfoLog(shader)); -+ return shader; ++ @Test ++ void retiringAnEngineClosesItsFontsAndPreservesAnotherEngine() throws Exception { ++ final byte[] bytes = resource("minimal.ttf"); ++ try (var first = engine(); var second = engine(); var independent = second.font(bytes, 52)) { ++ final var retained = first.font(bytes, 52); ++ first.close(); ++ assertThrows(IllegalStateException.class, () -> retained.shape("a", "en-US")); ++ assertThrows(IllegalStateException.class, () -> retained.raster(1)); ++ assertThrows(IllegalStateException.class, () -> first.font(bytes, 52)); ++ assertEquals(1997, independent.shape("a", "en-US").advanceX()); ++ retained.close(); ++ } ++ } ++ ++ @Test ++ void failedFontConstructionRetainsNeitherSlotsNorInvalidatesAnExistingFace() throws Exception { ++ try (var engine = engine(); var live = engine.font(resource("minimal.ttf"), 52)) { ++ final byte[] invalid = new byte[12]; invalid[1] = 1; ++ for (int index = 0; index < 40; index++) assertThrows(IOException.class, () -> engine.font(invalid, 52)); ++ try (var next = engine.font(resource("minimal.ttf"), 52)) { ++ assertEquals(1997, live.shape("a", "en-US").advanceX()); ++ assertEquals(live.shape("a\u0301", "en-US"), next.shape("a\u0301", "en-US")); ++ } ++ } ++ } ++} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiFontsTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiFontsTest.java +new file mode 100644 +index 0000000..3e09978 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiFontsTest.java +@@ -0,0 +1,59 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.render.oreui; ++ ++import com.google.gson.JsonObject; ++import com.google.gson.JsonParser; ++import java.nio.charset.StandardCharsets; ++import java.util.ArrayList; ++import java.util.List; ++import org.junit.jupiter.api.Test; ++import static org.junit.jupiter.api.Assertions.*; ++ ++class OreUiFontsTest { ++ @Test ++ void preservesNativeFallbackOwnershipMeasurementContextAndWhitespaceAdvances() throws Exception { ++ try (var stream = OreUiFontsTest.class.getResourceAsStream("/oreui/native-font-assignment-2225.json")) { ++ final var fixture = JsonParser.parseString(new String(stream.readAllBytes(), StandardCharsets.UTF_8)).getAsJsonObject(); ++ for (final var entry : fixture.getAsJsonArray("cases")) { ++ final var sample = entry.getAsJsonObject(); ++ final List fonts = new ArrayList<>(); ++ final var definitions = sample.getAsJsonArray("definitions"); ++ for (int index = 0; index < definitions.size(); index++) fonts.add(definitions.get(index).isJsonNull() ? null ++ : new FixtureFont(index, definitions.get(index).getAsJsonObject(), sample.get("contextual").getAsBoolean())); ++ final var text = OreUiCharacters.prepare(sample.get("text").getAsString(), OreUiCharacters.LineEndings.NORMALIZE, ++ OreUiLines.WhiteSpace.PRE, 20F, 0F); ++ final var result = OreUiFonts.assign(text, fonts); ++ assertEquals(sample.getAsJsonArray("scalars").size(), result.glyphs().size(), sample.toString()); ++ for (int index = 0; index < result.glyphs().size(); index++) { ++ assertEquals(new OreUiFonts.Glyph(sample.getAsJsonArray("scalars").get(index).getAsInt(), ++ sample.getAsJsonArray("glyphs").get(index).getAsInt(), sample.getAsJsonArray("fonts").get(index).getAsInt(), ++ sample.getAsJsonArray("advances").get(index).getAsFloat()), result.glyphs().get(index), sample.toString()); ++ } ++ } ++ } + } + -+ private static String resource(final String extension, final boolean smooth) { -+ final String path = "/assets/viafabricplus-bedrock/shaders/core/bedrock_bitmap_font." + extension; -+ try (var input = BedrockBitmapShaderGpuTest.class.getResourceAsStream(path)) { -+ assertNotNull(input); -+ return new String(input.readAllBytes(), StandardCharsets.UTF_8) -+ .replace("#version 330", "#version 330\n" + (smooth ? "#define BITMAP_SMOOTH\n" : "")) -+ .replace("#include ", "uniform mat4 ModelViewMat; uniform vec4 ColorModulator;") -+ .replace("#include ", "uniform mat4 ProjMat;"); -+ } catch (final IOException error) { throw new IllegalStateException(error); } ++ private record FixtureFont(int index, JsonObject coverage, boolean contextual) implements OreUiFonts.Font { ++ @Override public int[] glyphs(final int[] scalars) { ++ final int[] result = new int[scalars.length]; ++ for (int i = 0; i < scalars.length; i++) { ++ final String character = new String(Character.toChars(scalars[i])); ++ result[i] = this.coverage.has(character) ? this.coverage.get(character).getAsInt() : 0; ++ } ++ return result; ++ } ++ @Override public float[] advances(final int[] scalars) { ++ final int[] glyphs = this.glyphs(scalars); ++ final float[] result = new float[scalars.length]; ++ for (int i = 0; i < glyphs.length; i++) { ++ result[i] = (this.index + 1) * 10 + glyphs[i] / 8F; ++ if (this.contextual && i + 1 < glyphs.length) result[i] += (glyphs[i + 1] + 3) / 16F; ++ } ++ return result; ++ } + } +} -diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/font/BedrockTrueTypeFontTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/font/BedrockTrueTypeFontTest.java +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiGlyphsTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiGlyphsTest.java new file mode 100644 -index 0000000..29743c7 +index 0000000..7eb413c --- /dev/null -+++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/font/BedrockTrueTypeFontTest.java -@@ -0,0 +1,329 @@ ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiGlyphsTest.java +@@ -0,0 +1,231 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ -+package com.viaversion.viafabricplus.bedrock.render.font; ++package com.viaversion.viafabricplus.bedrock.render.oreui; + -+import com.mojang.blaze3d.font.GlyphBitmap; -+import com.mojang.blaze3d.font.GlyphInfo; -+import com.mojang.blaze3d.font.UnbakedGlyph; -+import com.mojang.blaze3d.vertex.VertexConsumer; +import com.mojang.blaze3d.platform.NativeImage; +import com.mojang.blaze3d.systems.RenderSystem; ++import com.mojang.blaze3d.vertex.BufferBuilder; ++import com.mojang.blaze3d.vertex.ByteBufferBuilder; ++import com.mojang.blaze3d.vertex.DefaultVertexFormat; ++import com.mojang.blaze3d.vertex.VertexConsumer; +import com.mojang.renderpearl.api.commands.CommandEncoder; +import com.mojang.renderpearl.api.device.GpuDevice; ++import com.mojang.renderpearl.api.textures.GpuSampler; +import com.mojang.renderpearl.api.textures.GpuTexture; +import com.mojang.renderpearl.api.textures.GpuTextureView; -+import java.io.ByteArrayInputStream; -+import java.io.IOException; -+import java.nio.ByteBuffer; ++import com.viaversion.viafabricplus.bedrock.injection.mixin.features.screen.GuiGraphicsExtractorAccess; +import java.nio.ByteOrder; -+import java.util.HexFormat; -+import java.util.Arrays; -+import java.nio.charset.StandardCharsets; -+import net.minecraft.client.gui.font.glyphs.BakedGlyph; -+import net.minecraft.client.gui.font.glyphs.BakedSheetGlyph; -+import net.minecraft.network.chat.Style; -+import org.joml.Matrix4f; -+import org.mockito.Answers; -+import java.util.List; +import java.util.ArrayList; -+import net.minecraft.resources.Identifier; -+import net.minecraft.server.packs.PackResources; -+import net.minecraft.server.packs.PackType; -+import net.minecraft.server.packs.resources.Resource; -+import net.minecraft.server.packs.resources.ResourceManager; ++import java.util.List; ++import java.util.Map; ++import net.minecraft.client.Minecraft; ++import net.minecraft.client.gui.GuiGraphicsExtractor; ++import net.minecraft.client.gui.navigation.ScreenRectangle; ++import net.minecraft.client.renderer.state.gui.GuiElementRenderState; ++import net.minecraft.client.renderer.state.gui.GuiRenderState; ++import net.minecraft.client.renderer.texture.DynamicTexture; ++import net.minecraft.client.renderer.texture.TextureManager; ++import org.joml.Matrix3x2fStack; ++import org.joml.Matrix3x2fc; +import org.junit.jupiter.api.Test; ++import org.mockito.Answers; +import org.mockito.ArgumentCaptor; -+ ++import org.mockito.MockedConstruction; ++import org.mockito.MockedStatic; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + -+class BedrockTrueTypeFontTest { -+ // Original Bedrock 1.26.60.5 raster controls for this authored, six-glyph test font. -+ private static final String RECTANGLE = "193333333333230a59b2b2b2b2b27c237fffffffffffb2337fffffffffffb233" -+ + "7fffffffffffb2337fffffffffffb2337fffffffffffb2333f7f7f7f7f7f5919"; -+ private static final String TRANSLATED = "19597f7f7f7f7f3f33b2ffffffffff7f33b2ffffffffff7f33b2ffffffffff7f" -+ + "33b2ffffffffff7f33b2ffffffffff7f237cb2b2b2b2b2590a23333333333319"; -+ -+ private static byte[] fixture() throws IOException { -+ try (var stream = BedrockTrueTypeFontTest.class.getResourceAsStream("/font/native-outline-fixture.ttf")) { -+ assertNotNull(stream); -+ return stream.readAllBytes(); -+ } -+ } -+ -+ private static GpuTextureView texture() { -+ final var texture = mock(GpuTextureView.class); -+ when(texture.getWidth(0)).thenReturn(256); -+ when(texture.getHeight(0)).thenReturn(256); -+ return texture; -+ } -+ -+ @Test -+ void matchesNativeRasterForRectanglesCurvesAndUnshiftedCompositeCoordinates() throws Exception { -+ try (var raster = new BedrockTrueTypeRaster(fixture(), 32)) { -+ final var plain = raster.raster(65); -+ final var translated = raster.raster(67); -+ assertArrayEquals(HexFormat.of().parseHex(RECTANGLE), plain.pixels()); -+ assertArrayEquals(HexFormat.of().parseHex(TRANSLATED), translated.pixels()); -+ assertEquals(8, plain.width()); -+ assertEquals(8, translated.height()); -+ assertEquals(3, plain.advance()); -+ assertEquals(-.09375F, plain.left()); -+ assertEquals(.09375F, translated.left()); -+ final var scaled = raster.raster(68); -+ assertArrayEquals(HexFormat.of().parseHex("0000000000253b16000064a13c00007fcc4c00007fcc4c00003f66260000"), scaled.pixels()); -+ assertEquals(5, scaled.width()); -+ assertEquals(6, scaled.height()); -+ } -+ try (var raster = new BedrockTrueTypeRaster(fixture(), 8)) { -+ final var curve = raster.raster(66); -+ assertArrayEquals(HexFormat.of().parseHex("0000000000000000000004222608002e8c9b3f022a6a753702"), curve.pixels()); -+ assertEquals(5, curve.width()); -+ assertEquals(5, curve.height()); -+ } -+ } -+ -+ @Test -+ void clearsReservedGlyphGuttersAndKeepsUploadsInsideTheAtlas() throws Exception { -+ try (var font = new BedrockTrueTypeFont(new BedrockTrueTypeRaster(fixture(), 32), ""); -+ var system = mockStatic(RenderSystem.class)) { -+ final var texture = mock(GpuTexture.class); -+ when(texture.getWidth(0)).thenReturn(20); -+ when(texture.getHeight(0)).thenReturn(20); -+ final var device = mock(GpuDevice.class); -+ final var encoder = mock(CommandEncoder.class); -+ system.when(RenderSystem::getDevice).thenReturn(device); -+ when(device.createCommandEncoder()).thenReturn(encoder); -+ final var uploads = new ArrayList(); -+ final var dimensions = new ArrayList>(); -+ doAnswer(call -> { -+ final NativeImage image = call.getArgument(1); -+ final byte[] pixels = new byte[image.getWidth() * image.getHeight()]; -+ image.getPixelBytes().get(pixels); -+ uploads.add(pixels); -+ dimensions.add(List.of(image.getWidth(), image.getHeight())); -+ return null; -+ }).when(encoder).writeToTexture(same(texture), any(NativeImage.class), eq(0), eq(0), anyInt(), anyInt()); -+ final var stitcher = mock(UnbakedGlyph.Stitcher.class); -+ when(stitcher.stitch(any(), any())).thenAnswer(call -> { -+ final GlyphBitmap bitmap = call.getArgument(1); -+ bitmap.upload(5, 7, texture); -+ bitmap.upload(12, 12, texture); -+ return mock(BakedGlyph.class); -+ }); -+ font.getGlyph(65).bake(stitcher); -+ assertEquals(List.of(List.of(9, 9), List.of(8, 8)), dimensions); -+ assertArrayEquals(HexFormat.of().parseHex("193333333333230a00" -+ + "59b2b2b2b2b27c2300" + "7fffffffffffb23300" + "7fffffffffffb23300" -+ + "7fffffffffffb23300" + "7fffffffffffb23300" + "7fffffffffffb23300" -+ + "3f7f7f7f7f7f591900" + "000000000000000000"), uploads.getFirst()); -+ assertArrayEquals(HexFormat.of().parseHex(RECTANGLE), uploads.getLast()); -+ } -+ } -+ -+ @Test -+ void preservesNativeAtlasSamplingWhenJavaRelocatesGlyphs() throws Exception { -+ try (var font = new BedrockTrueTypeFont(new BedrockTrueTypeRaster(fixture(), 32), "")) { -+ final var stitcher = mock(UnbakedGlyph.Stitcher.class); -+ final var texture = texture(); -+ when(stitcher.stitch(any(), any())).thenAnswer(call -> { -+ final GlyphInfo info = call.getArgument(0); -+ final GlyphBitmap bitmap = call.getArgument(1); -+ return new BakedSheetGlyph(info, null, texture, 1 / 256F, 9 / 256F, 17 / 256F, 25 / 256F, -+ bitmap.getLeft(), bitmap.getRight(), bitmap.getTop(), bitmap.getBottom()); -+ }); -+ final var glyph = font.getGlyph(65).bake(stitcher).createGlyph(0, 0, -1, 0, Style.EMPTY, 0, 0); -+ final var gui = mock(VertexConsumer.class, Answers.RETURNS_SELF); -+ glyph.render(new Matrix4f(), gui, 0, true); -+ final var u = ArgumentCaptor.forClass(Float.class); -+ final var v = ArgumentCaptor.forClass(Float.class); -+ verify(gui, times(4)).setUv(u.capture(), v.capture()); -+ // Original 1.26.60.5 shelf places A at [5,1..13,9] on a 256 page. -+ // Retain its captured fixed-point sample phase at Java's [1,17..9,25]. -+ assertEquals(List.of(.003906548F, .003906548F, .035157025F, .035157025F), u.getAllValues()); -+ assertEquals(List.of(.06640631F, .09765679F, .09765679F, .06640631F), v.getAllValues()); -+ final var world = mock(VertexConsumer.class, Answers.RETURNS_SELF); -+ glyph.render(new Matrix4f(), world, 0, false); -+ final var worldU = ArgumentCaptor.forClass(Float.class); -+ verify(world, times(4)).setUv(worldU.capture(), anyFloat()); -+ assertEquals(List.of(1 / 256F, 1 / 256F, 9 / 256F, 9 / 256F), worldU.getAllValues()); -+ } -+ } -+ -+ @Test -+ void submitsCachePrecisionBeforeTheDisplayTransformAndPreservesWorldVertices() throws Exception { -+ try (var font = new BedrockTrueTypeFont(new BedrockTrueTypeRaster(fixture(), 32), "")) { -+ final var stitcher = mock(UnbakedGlyph.Stitcher.class); -+ when(stitcher.stitch(any(), any())).thenAnswer(call -> { -+ final GlyphInfo info = call.getArgument(0); -+ final GlyphBitmap bitmap = call.getArgument(1); -+ return new BakedSheetGlyph(info, null, texture(), 0, 1, 0, 1, -+ bitmap.getLeft(), bitmap.getRight(), bitmap.getTop(), bitmap.getBottom()); -+ }); -+ final var glyph = font.getGlyph(65).bake(stitcher).createGlyph(28.325806F, 0, -1, 0, Style.EMPTY, 0, 0); -+ final var pose = new Matrix4f().scaling(2); -+ final var nativeVertices = mock(VertexConsumer.class, Answers.RETURNS_SELF); -+ glyph.render(pose, nativeVertices, 0, true); -+ final var nativeX = ArgumentCaptor.forClass(Float.class); -+ verify(nativeVertices, times(4)).addVertex(same(pose), nativeX.capture(), anyFloat(), anyFloat()); -+ assertEquals(List.of(28.2421875F, 28.2421875F, 29.7421875F, 29.7421875F), nativeX.getAllValues()); -+ final var worldVertices = mock(VertexConsumer.class, Answers.RETURNS_SELF); -+ glyph.render(pose, worldVertices, 0, false); -+ final var worldX = ArgumentCaptor.forClass(Float.class); -+ verify(worldVertices, times(4)).addVertex(same(pose), worldX.capture(), anyFloat(), anyFloat()); -+ assertEquals(28.232056F, worldX.getAllValues().getFirst()); -+ assertNotEquals(nativeX.getAllValues(), worldX.getAllValues()); -+ } -+ } ++class OreUiGlyphsTest { ++ private static final OreUiGlyphs.Quad QUAD = new OreUiGlyphs.Quad(.25F, .5F, 2.25F, 2.5F, .5F, ++ new OreUiGlyphs.Tint(.15F, .3F, .45F, .6F)); + + @Test -+ void usesFixedNativeItalicEdgesInGuiAndPreservesWorldGeometry() throws Exception { -+ try (var font = new BedrockTrueTypeFont(new BedrockTrueTypeRaster(fixture(), 32), "")) { -+ final var stitcher = mock(UnbakedGlyph.Stitcher.class); -+ final BakedSheetGlyph[] sheet = new BakedSheetGlyph[1]; -+ when(stitcher.stitch(any(), any())).thenAnswer(call -> { -+ final GlyphInfo info = call.getArgument(0); -+ final GlyphBitmap bitmap = call.getArgument(1); -+ sheet[0] = new BakedSheetGlyph(info, null, texture(), 0, 1, 0, 1, -+ bitmap.getLeft(), bitmap.getRight(), bitmap.getTop(), bitmap.getBottom()); -+ return sheet[0]; -+ }); -+ final var style = Style.EMPTY.withItalic(true); -+ final var glyph = font.getGlyph(65).bake(stitcher).createGlyph(0, 0, -1, 0, style, 0, 0); -+ final var pose = new Matrix4f(); -+ final var gui = mock(VertexConsumer.class, Answers.RETURNS_SELF); -+ glyph.render(pose, gui, 0, true); -+ final var guiX = ArgumentCaptor.forClass(Float.class); -+ verify(gui, times(4)).addVertex(same(pose), guiX.capture(), anyFloat(), anyFloat()); -+ // Native 1.26.60.5 quad expansion: top +1, bottom -1 before the 1.5 display transform. -+ assertEquals(List.of(1.40625F, -1.59375F, -.09375F, 2.90625F), guiX.getAllValues()); -+ assertTrue(glyph.left() <= -1.59375F); -+ assertTrue(glyph.right() >= 2.90625F); -+ assertEquals(style, glyph.style()); -+ final var fractional = font.getGlyph(65).bake(stitcher).createGlyph(.114990324F, 0, -1, 0, style, 0, 0); -+ final var fractionalVertices = mock(VertexConsumer.class, Answers.RETURNS_SELF); -+ fractional.render(pose, fractionalVertices, 0, true); -+ final var fractionalX = ArgumentCaptor.forClass(Float.class); -+ verify(fractionalVertices, times(4)).addVertex(same(pose), fractionalX.capture(), anyFloat(), anyFloat()); -+ // Applying display-space shear before normalization rounds onto the other half-float. -+ assertEquals(1.5205078125F, fractionalX.getAllValues().getFirst()); -+ -+ final var world = mock(VertexConsumer.class, Answers.RETURNS_SELF); -+ final var originalWorld = mock(VertexConsumer.class, Answers.RETURNS_SELF); -+ glyph.render(pose, world, 0, false); -+ sheet[0].createGlyph(0, 0, -1, 0, style, 0, 0).render(pose, originalWorld, 0, false); -+ final var worldX = ArgumentCaptor.forClass(Float.class); -+ final var originalX = ArgumentCaptor.forClass(Float.class); -+ verify(world, times(4)).addVertex(same(pose), worldX.capture(), anyFloat(), anyFloat()); -+ verify(originalWorld, times(4)).addVertex(same(pose), originalX.capture(), anyFloat(), anyFloat()); -+ assertEquals(originalX.getAllValues(), worldX.getAllValues()); -+ assertNotEquals(guiX.getAllValues(), worldX.getAllValues()); ++ void placesShapedMarksWithIndependentCacheBearingsAndRunScale() throws Exception { ++ try (var fixture = new Fixture(); var atlas = new OreUiGlyphs()) { ++ // Original 1.26.60.5 shaping of the authored font at 13 pixels. ++ final var run = new OreUiFont.Run(4, 0, List.of(new OreUiFont.Glyph(1, 0, 499, 0, 0, 0), ++ new OreUiFont.Glyph(2, 0, 0, 0, -500, -1)), 499, 0); ++ final var base = new OreUiText.Image(new OreUiFont.Raster(26, 37, 26, 0, 37, 1997, new byte[26 * 37]), ++ new OreUiDistanceField.Field(34, 45, 36, new byte[36 * 45])); ++ final var mark = new OreUiText.Image(new OreUiFont.Raster(6, 5, 6, 0, 41, 0, new byte[6 * 5]), ++ new OreUiDistanceField.Field(14, 13, 16, new byte[16 * 13])); ++ final var text = new OreUiText.Prepared(mock(OreUiAssets.class), ++ new OreUiAssets.Face("Fixture", "fixture.ttf", 400, "normal", false), 52, 13, true, run, Map.of(1, base, 2, mark), new OreUiFont.VerticalMetrics(-.8F, .2F, 0F)); ++ atlas.drawRun(fixture.graphics, text, 100.25F, 200.5F, 13, QUAD.tint(), fixture.sampler, false); ++ final var draws = ArgumentCaptor.forClass(GuiElementRenderState.class); ++ verify(fixture.state, times(2)).addGlyphToCurrentLayer(draws.capture()); ++ final float[][] expectedX = {{99.25F, 99.25F, 107.75F, 107.75F}, ++ {99.234375F, 99.234375F, 102.734375F, 102.734375F}}; ++ final float[][] expectedY = {{190.25F, 201.5F, 201.5F, 190.25F}, ++ {189.265625F, 192.515625F, 192.515625F, 189.265625F}}; ++ for (int index = 0; index < 2; index++) { ++ final var draw = draws.getAllValues().get(index); ++ final var format = draw.pipeline().getVertexFormatBindings().getFirst(); ++ try (var buffer = new ByteBufferBuilder(256)) { ++ final var vertices = new BufferBuilder(buffer, draw.pipeline().getPrimitiveTopology(), format); ++ draw.buildVertices(vertices); ++ try (var mesh = vertices.buildOrThrow()) { ++ assertEquals(4, mesh.drawState().vertexCount()); ++ final var bytes = mesh.vertexBuffer().order(ByteOrder.nativeOrder()); ++ for (int corner = 0; corner < 4; corner++) { ++ final int offset = corner * format.getVertexSize(); ++ final int position = offset + format.getElement(DefaultVertexFormat.POSITION_SEMANTIC_NAME).offset(); ++ assertEquals(expectedX[index][corner], bytes.getFloat(position)); ++ assertEquals(expectedY[index][corner], bytes.getFloat(position + 4)); ++ final int tintRG = offset + format.getElement(DefaultVertexFormat.UV3_SEMANTIC_NAME).offset(); ++ final int tintBA = offset + format.getElement(DefaultVertexFormat.UV0_SEMANTIC_NAME).offset(); ++ assertEquals(.15F, bytes.getFloat(tintRG)); assertEquals(.3F, bytes.getFloat(tintRG + 4)); ++ assertEquals(.45F, bytes.getFloat(tintBA)); assertEquals(.6F, bytes.getFloat(tintBA + 4)); ++ assertEquals(.25F, bytes.getFloat(offset + format.getElement(DefaultVertexFormat.LINE_WIDTH_SEMANTIC_NAME).offset())); ++ } ++ } ++ } ++ } + } + } + + @Test -+ void ownsItsFontBytesAndRejectsUseAfterCloseAcrossReloads() throws Exception { -+ final byte[] bytes = fixture(); -+ final var raster = new BedrockTrueTypeRaster(bytes, 32); -+ Arrays.fill(bytes, (byte) 0); -+ assertArrayEquals(HexFormat.of().parseHex(RECTANGLE), raster.raster(65).pixels()); -+ raster.close(); -+ raster.close(); -+ assertThrows(IllegalStateException.class, () -> raster.raster(65)); -+ try (var replacement = new BedrockTrueTypeRaster(fixture(), 32)) { -+ assertArrayEquals(HexFormat.of().parseHex(RECTANGLE), replacement.raster(65).pixels()); ++ void resolvesTheLineBaselineBeforeAddingGlyphBearingsWithoutRoundingTheOrigin() throws Exception { ++ try (var fixture = new Fixture(); var atlas = new OreUiGlyphs()) { ++ final var prepared = new OreUiText.Prepared(mock(OreUiAssets.class), ++ new OreUiAssets.Face("Fixture", "fixture.ttf", 400, "normal", false), 16, 16, false, ++ new OreUiFont.Run(4, 0, List.of(new OreUiFont.Glyph(1, 0, 64, 0, 0, 0)), 64, 0), ++ Map.of(1, new OreUiText.Image(new OreUiFont.Raster(2, 3, 2, 1, 8, 64, new byte[6]), null)), ++ new OreUiFont.VerticalMetrics(-.8F, .1F, 0F)); ++ assertEquals(new OreUiFont.LineMetrics(15F, 20F), atlas.drawLine(fixture.graphics, prepared, ++ 10.25F, 20.4F, 16F, 20F, QUAD.tint(), fixture.sampler, false)); ++ final var draws = ArgumentCaptor.forClass(GuiElementRenderState.class); ++ verify(fixture.state).addGlyphToCurrentLayer(draws.capture()); ++ final var vertices = mock(VertexConsumer.class, Answers.RETURNS_SELF); ++ draws.getValue().buildVertices(vertices); ++ // Original Cohtml fragment submission followed by the native glyph-bearing arithmetic. ++ verify(vertices).addVertexWith2DPose(any(Matrix3x2fc.class), eq(11.25F), eq(27.400002F)); ++ verify(vertices).addVertexWith2DPose(any(Matrix3x2fc.class), eq(13.25F), eq(30.400002F)); + } + } + + @Test -+ void rejectsUnsignedTableSpansAndBoundsGlyphWorkBeforeRasterization() throws Exception { -+ final byte[] truncated = fixture(); -+ ByteBuffer.wrap(truncated).order(ByteOrder.BIG_ENDIAN).putInt(20, -1); -+ assertThrows(IOException.class, () -> new BedrockTrueTypeRaster(truncated, 32)); -+ final byte[] oversized = fixture(); -+ final var data = ByteBuffer.wrap(oversized).order(ByteOrder.BIG_ENDIAN); -+ final int glyphs = table(data, 0x676C7966); -+ data.putShort(glyphs + 6, Short.MAX_VALUE); -+ try (var raster = new BedrockTrueTypeRaster(oversized, 255)) { -+ assertThrows(IOException.class, () -> raster.raster(65)); -+ } -+ final byte[] inconsistent = fixture(); -+ final var bounds = ByteBuffer.wrap(inconsistent).order(ByteOrder.BIG_ENDIAN); -+ bounds.putShort(table(bounds, 0x676C7966) + 6, (short) 2); -+ try (var raster = new BedrockTrueTypeRaster(inconsistent, 32)) { -+ assertThrows(IOException.class, () -> raster.raster(65)); ++ void queuesStableGlyphSlotsWithoutUploadingClippedTextOrAliasingSourceBundles() throws Exception { ++ try (var fixture = new Fixture(); var atlas = new OreUiGlyphs()) { ++ final var first = prepared(mock(OreUiAssets.class)); ++ when(fixture.scissor.peek()).thenReturn(new ScreenRectangle(100, 100, 2, 2)); ++ atlas.draw(fixture.graphics, first, 1, QUAD, fixture.sampler, false); ++ assertTrue(fixture.textures.constructed().isEmpty()); ++ verifyNoInteractions(fixture.state); ++ ++ when(fixture.scissor.peek()).thenReturn(null); ++ fixture.pose.translate(10, 20); ++ atlas.draw(fixture.graphics, first, 1, QUAD, fixture.sampler, false); ++ atlas.draw(fixture.graphics, first, 1, QUAD, fixture.sampler, false); ++ atlas.draw(fixture.graphics, prepared(mock(OreUiAssets.class)), 1, QUAD, fixture.sampler, false); ++ assertEquals(1, fixture.textures.constructed().size()); ++ assertEquals(List.of(List.of(1, 1), List.of(5, 1)), fixture.destinations); ++ final byte[] expected = {64, 0, 0, -1, -128, 0, 0, -1, -64, 0, 0, -1, -1, 0, 0, -1}; ++ for (final var upload : fixture.uploads) assertArrayEquals(expected, upload); ++ final var retained = fixture.textures.constructed().getFirst().getPixels().getPixelBytes(); ++ assertEquals(64, Byte.toUnsignedInt(retained.get((512 + 1) * 4))); ++ assertEquals(64, Byte.toUnsignedInt(retained.get((512 + 5) * 4))); ++ ++ final var draws = ArgumentCaptor.forClass(GuiElementRenderState.class); ++ verify(fixture.state, times(3)).addGlyphToCurrentLayer(draws.capture()); ++ fixture.pose.translate(100, 100); ++ assertEquals(new ScreenRectangle(10, 20, 3, 3), draws.getAllValues().getFirst().bounds()); ++ final var vertices = mock(VertexConsumer.class, Answers.RETURNS_SELF); ++ draws.getAllValues().getFirst().buildVertices(vertices); ++ final var u = ArgumentCaptor.forClass(Integer.class); ++ final var v = ArgumentCaptor.forClass(Integer.class); ++ verify(vertices, times(4)).setUv1(u.capture(), v.capture()); ++ assertEquals(List.of(1, 1, 3, 3), u.getAllValues()); ++ assertEquals(List.of(1, 3, 3, 1), v.getAllValues()); ++ verify(vertices, times(4)).setUv(.45F, .6F); ++ verify(vertices, times(4)).setUv3(.15F, .3F); ++ verify(vertices, times(4)).setLineWidth(.5F); ++ ++ fixture.system.when(RenderSystem::isOnRenderThread).thenReturn(false); ++ atlas.close(); atlas.close(); ++ verify(fixture.manager, never()).release(any()); ++ final var disposal = ArgumentCaptor.forClass(Runnable.class); ++ verify(fixture.game).execute(disposal.capture()); ++ assertThrows(IllegalStateException.class, () -> atlas.draw(fixture.graphics, first, 1, QUAD, fixture.sampler, false)); ++ disposal.getValue().run(); ++ verify(fixture.manager).release(any()); + } + } + + @Test -+ void rejectsCyclicComponentsAndUsesTheStitchersMissingGlyphForMalformedOutlines() throws Exception { -+ final byte[] bytes = fixture(); -+ final var data = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN); -+ final int composite = table(data, 0x676C7966) + data.getInt(table(data, 0x6C6F6361) + 4 * 4); -+ data.putShort(composite + 12, (short) 4); -+ try (var raster = new BedrockTrueTypeRaster(bytes, 32)) { -+ assertThrows(IOException.class, () -> raster.raster(67)); ++ void releasesRemainingPagesAfterOneTextureRetirementFails() throws Exception { ++ try (var fixture = new Fixture(); var atlas = new OreUiGlyphs()) { ++ final var image = new OreUiText.Image(new OreUiFont.Raster(510, 510, 510, 0, 0, 1, new byte[510 * 510]), null); ++ final var text = prepared(mock(OreUiAssets.class)); ++ final var large = new OreUiText.Prepared(text.source(), text.face(), 52, 52, false, text.run(), Map.of(1, image, 2, image), text.verticalMetrics()); ++ atlas.draw(fixture.graphics, large, 1, QUAD, fixture.sampler, false); ++ atlas.draw(fixture.graphics, large, 2, QUAD, fixture.sampler, false); ++ assertEquals(2, fixture.textures.constructed().size()); ++ doThrow(new IllegalStateException("Retirement failed")).doNothing().when(fixture.manager).release(any()); ++ final var failure = assertThrows(IllegalStateException.class, atlas::close); ++ assertEquals("Retirement failed", failure.getMessage()); ++ verify(fixture.manager, times(2)).release(any()); ++ atlas.close(); ++ } ++ } ++ ++ private static OreUiText.Prepared prepared(final OreUiAssets source) { ++ return new OreUiText.Prepared(source, new OreUiAssets.Face("Fixture", "fixture.ttf", 400, "normal", false), 52, 52, false, ++ new OreUiFont.Run(4, 0, List.of(new OreUiFont.Glyph(1, 0, 64, 0, 0, 0)), 64, 0), ++ Map.of(1, new OreUiText.Image(new OreUiFont.Raster(2, 2, 2, 0, 0, 64, new byte[]{64, -128, -64, -1}), null)), new OreUiFont.VerticalMetrics(-.8F, .2F, 0F)); ++ } ++ ++ private static final class Fixture implements AutoCloseable { ++ private final Minecraft game = mock(Minecraft.class); ++ private final TextureManager manager = mock(TextureManager.class); ++ private final GuiRenderState state = mock(GuiRenderState.class); ++ private final GpuSampler sampler = mock(GpuSampler.class); ++ private final GuiGraphicsExtractor graphics = mock(GuiGraphicsExtractor.class, withSettings().extraInterfaces(GuiGraphicsExtractorAccess.class)); ++ private final GuiGraphicsExtractor.ScissorStack scissor = mock(GuiGraphicsExtractor.ScissorStack.class); ++ private final Matrix3x2fStack pose = new Matrix3x2fStack(16); ++ private final List uploads = new ArrayList<>(); ++ private final List> destinations = new ArrayList<>(); ++ private final List retained = new ArrayList<>(); ++ private final MockedStatic system = mockStatic(RenderSystem.class); ++ private final MockedStatic singleton = mockStatic(Minecraft.class); ++ private final MockedConstruction textures; ++ ++ private Fixture() throws ReflectiveOperationException { ++ this.singleton.when(Minecraft::getInstance).thenReturn(this.game); ++ when(this.game.getTextureManager()).thenReturn(this.manager); ++ this.system.when(RenderSystem::isOnRenderThread).thenReturn(true); ++ final var device = mock(GpuDevice.class); ++ final var encoder = mock(CommandEncoder.class); ++ this.system.when(RenderSystem::getDevice).thenReturn(device); ++ when(device.createCommandEncoder()).thenReturn(encoder); ++ doAnswer(call -> { ++ final NativeImage image = call.getArgument(1); ++ final var bytes = new byte[image.getWidth() * image.getHeight() * 4]; ++ image.getPixelBytes().get(0, bytes); ++ this.uploads.add(bytes); ++ this.destinations.add(List.of(call.getArgument(4), call.getArgument(5))); ++ return null; ++ }).when(encoder).writeToTexture(any(GpuTexture.class), any(NativeImage.class), eq(0), eq(0), anyInt(), anyInt()); ++ this.textures = mockConstruction(DynamicTexture.class, (texture, context) -> { ++ final NativeImage pixels = (NativeImage) context.arguments().get(1); ++ this.retained.add(pixels); ++ when(texture.getPixels()).thenReturn(pixels); ++ when(texture.getTexture()).thenReturn(mock(GpuTexture.class)); ++ when(texture.getTextureView()).thenReturn(mock(GpuTextureView.class)); ++ }); ++ when(this.graphics.pose()).thenReturn(this.pose); ++ when(((GuiGraphicsExtractorAccess) this.graphics).bedrockGuiState()).thenReturn(this.state); ++ final var field = GuiGraphicsExtractor.class.getField("scissorStack"); ++ field.setAccessible(true); field.set(this.graphics, this.scissor); + } -+ try (var font = new BedrockTrueTypeFont(new BedrockTrueTypeRaster(bytes, 32), "")) { -+ final var stitcher = mock(UnbakedGlyph.Stitcher.class); -+ final var missing = mock(BakedGlyph.class); -+ when(stitcher.getMissing()).thenReturn(missing); -+ assertSame(missing, font.getGlyph(67).bake(stitcher)); -+ verify(stitcher).getMissing(); -+ verifyNoMoreInteractions(stitcher); ++ ++ @Override public void close() { ++ this.textures.close(); this.singleton.close(); this.system.close(); ++ for (final var image : this.retained) image.close(); + } + } ++} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiLineMetricsTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiLineMetricsTest.java +new file mode 100644 +index 0000000..bce9482 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiLineMetricsTest.java +@@ -0,0 +1,41 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.render.oreui; ++ ++import java.io.IOException; ++import org.junit.jupiter.api.Test; ++import static org.junit.jupiter.api.Assertions.*; + ++class OreUiLineMetricsTest { + @Test -+ void bakesNativeMetricsAndRetiresPreviouslyReturnedGlyphs() throws Exception { -+ final var font = new BedrockTrueTypeFont(new BedrockTrueTypeRaster(fixture(), 32), "B"); -+ try { -+ assertNull(font.getGlyph(66)); -+ final var glyph = font.getGlyph(65); -+ assertNotNull(glyph); -+ assertEquals(3, glyph.info().getAdvance()); -+ final var stitcher = mock(UnbakedGlyph.Stitcher.class); -+ when(stitcher.stitch(any(), any())).thenReturn(mock(BakedGlyph.class)); -+ assertNotNull(glyph.bake(stitcher)); -+ final var bitmap = ArgumentCaptor.forClass(GlyphBitmap.class); -+ verify(stitcher).stitch(any(GlyphInfo.class), bitmap.capture()); -+ assertEquals(8, bitmap.getValue().getPixelWidth()); -+ assertEquals(32F / 6, bitmap.getValue().getOversample()); -+ assertEquals(-.09375F, bitmap.getValue().getBearingLeft()); -+ font.close(); -+ assertNull(font.getGlyph(65)); -+ assertThrows(IllegalStateException.class, () -> glyph.bake(stitcher)); -+ } finally { font.close(); } ++ void usesTheRoundedNativeFontSpanForDeathScreenLineBoxes() throws Exception { ++ // Original 1.26.60.5 Renoir metrics scaling and Cohtml line-builder controls. ++ final var ten = new OreUiFont.VerticalMetrics(-.99F, .25F, 0F); ++ final var seven = new OreUiFont.VerticalMetrics(-.8F, .1F, 0F); ++ assertEquals(new OreUiFont.LineMetrics(32F, 40F), ten.line(32F, 40F)); ++ assertEquals(new OreUiFont.LineMetrics(15F, 20F), seven.line(16F, 20F)); ++ assertEquals(new OreUiFont.LineMetrics(19F, 24F), ten.line(20F, 24F)); ++ assertEquals(new OreUiFont.LineMetrics(13F, 15F), seven.line(16F, null)); ++ assertEquals(new OreUiFont.LineMetrics(5F, 0F), seven.line(16F, 0F)); + } + + @Test -+ void usesTheSelectedFontsOwnDescriptorAndKeepsOlderExportsOnVanillaLoading() throws Exception { -+ final var id = Identifier.parse("viabedrock:server_ui/0123456789abcdef0123456789abcdef.ttf"); -+ final var file = id.withPrefix("font/"); -+ final var pack = mock(PackResources.class); -+ final var resources = mock(ResourceManager.class); -+ final byte[] bytes = fixture(); -+ when(resources.getResourceOrThrow(file)).thenReturn(new Resource(pack, () -> new ByteArrayInputStream(bytes))); -+ assertNull(BedrockTrueTypeFont.load(resources, id, "")); -+ when(pack.getResource(PackType.CLIENT_RESOURCES, file.withSuffix(".mcmeta"))).thenReturn(() -> -+ new ByteArrayInputStream("{\"viabedrock:truetype\":{\"render_height\":32}}".getBytes(StandardCharsets.UTF_8))); -+ try (var font = BedrockTrueTypeFont.load(resources, id, "A")) { -+ assertInstanceOf(BedrockTrueTypeFont.class, font); -+ assertNull(font.getGlyph(65)); -+ assertNotNull(font.getGlyph(66)); ++ void preservesLineGapAndRoundsNegativeHalfBaselinesAwayFromZero() throws Exception { ++ final var gap = new OreUiFont.VerticalMetrics(-.75F, .25F, .2F); ++ assertEquals(new OreUiFont.LineMetrics(13F, 20F), gap.line(10F, 20F)); ++ final var descending = new OreUiFont.VerticalMetrics(0F, 1F, 0F); ++ assertEquals(new OreUiFont.LineMetrics(-1F, 0F), descending.line(1F, 0F)); ++ } ++ ++ @Test ++ void rejectsInvalidLineSizesBeforeSubmittingGlyphs() { ++ final var metrics = new OreUiFont.VerticalMetrics(-.8F, .1F, 0F); ++ assertThrows(IOException.class, () -> metrics.line(0F, 20F)); ++ assertThrows(IOException.class, () -> metrics.line(Float.NaN, 20F)); ++ assertThrows(IOException.class, () -> metrics.line(16F, -1F)); ++ assertThrows(IOException.class, () -> metrics.line(16F, Float.POSITIVE_INFINITY)); ++ } ++} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiLinesTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiLinesTest.java +new file mode 100644 +index 0000000..8cd73f8 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiLinesTest.java +@@ -0,0 +1,41 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.render.oreui; ++ ++import com.google.gson.JsonParser; ++import java.nio.charset.StandardCharsets; ++import org.junit.jupiter.api.Test; ++import static org.junit.jupiter.api.Assertions.*; ++ ++class OreUiLinesTest { ++ @Test ++ void preservesNativeRangesWidthsAndAlignmentAcrossLineBoundaries() throws Exception { ++ try (var stream = OreUiLinesTest.class.getResourceAsStream("/oreui/native-lines-2225.json")) { ++ final var fixture = JsonParser.parseString(new String(stream.readAllBytes(), StandardCharsets.UTF_8)).getAsJsonObject(); ++ for (final var entry : fixture.getAsJsonArray("cases")) { ++ final var sample = entry.getAsJsonObject(); ++ final var source = sample.getAsJsonArray("advances"); ++ final float[] advances = new float[source.size()]; ++ for (int index = 0; index < advances.length; index++) advances[index] = source.get(index).getAsFloat(); ++ final var result = OreUiLines.layout(sample.get("text").getAsString().codePoints().toArray(), advances, ++ new OreUiFont.LineMetrics(20F, 24F), sample.get("width").getAsFloat(), sample.get("indent").getAsFloat(), ++ sample.get("letterSpacing").getAsFloat(), OreUiLines.WhiteSpace.values()[sample.get("whitespace").getAsInt()], ++ OreUiLines.OverflowWrap.values()[sample.get("wordbreak").getAsInt()], ++ OreUiLines.Alignment.values()[sample.get("alignment").getAsInt()], sample.get("fill").getAsBoolean()); ++ final var lines = sample.getAsJsonArray("lines"); ++ assertEquals(lines.size(), result.lines().size(), sample.toString()); ++ for (int index = 0; index < lines.size(); index++) { ++ final var line = lines.get(index).getAsJsonObject(); ++ assertEquals(new OreUiLines.Line(line.get("start").getAsInt(), line.get("end").getAsInt(), ++ line.get("x").getAsFloat(), line.get("baseline").getAsFloat(), line.get("width").getAsFloat(), ++ line.get("height").getAsFloat(), line.get("hardBreak").getAsBoolean()), result.lines().get(index), sample.toString()); ++ } ++ assertEquals(sample.getAsJsonArray("extent").get(0).getAsFloat(), result.width(), sample.toString()); ++ assertEquals(sample.getAsJsonArray("extent").get(1).getAsFloat(), result.height(), sample.toString()); ++ } + } -+ verify(resources, times(2)).getResourceOrThrow(file); -+ verifyNoMoreInteractions(resources); + } ++} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiMixedLinesTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiMixedLinesTest.java +new file mode 100644 +index 0000000..e4384f2 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiMixedLinesTest.java +@@ -0,0 +1,73 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.render.oreui; + -+ private static int table(final ByteBuffer data, final int tag) { -+ for (int record = 12; record < 12 + data.getShort(4) * 16; record += 16) { -+ if (data.getInt(record) == tag) return data.getInt(record + 8); ++import com.google.gson.JsonArray; ++import com.google.gson.JsonParser; ++import java.nio.charset.StandardCharsets; ++import java.util.ArrayList; ++import java.util.List; ++import org.junit.jupiter.api.Test; ++import static org.junit.jupiter.api.Assertions.*; ++ ++class OreUiMixedLinesTest { ++ @Test ++ void preservesNativeFragmentRangesBaselinesWrappingAndZeroWidthLineHeight() throws Exception { ++ assertNativeLines("/oreui/native-mixed-lines-2225.json"); ++ assertNativeLines("/oreui/native-trailing-reserve-2225.json"); ++ } ++ ++ private static void assertNativeLines(final String resource) throws Exception { ++ try (var stream = OreUiMixedLinesTest.class.getResourceAsStream(resource)) { ++ final var fixture = JsonParser.parseString(new String(stream.readAllBytes(), StandardCharsets.UTF_8)).getAsJsonObject(); ++ for (final var entry : fixture.getAsJsonArray("cases")) { ++ final var sample = entry.getAsJsonObject(); ++ final var definitions = sample.getAsJsonArray("fontMetrics"); ++ final List fonts = new ArrayList<>(); ++ for (int index = 0; index < definitions.size(); index++) { ++ final var metrics = definitions.get(index).getAsJsonArray(); ++ fonts.add(new OreUiLines.Font(sample.getAsJsonArray("fontIdentities").get(index).getAsInt(), ++ new OreUiFont.LineMetrics(metrics.get(0).getAsFloat(), metrics.get(1).getAsFloat()))); ++ } ++ final var map = sample.getAsJsonArray("fontMap"); ++ final int[] fontMap = new int[map.size()]; ++ for (int index = 0; index < map.size(); index++) fontMap[index] = map.get(index).getAsInt(); ++ final var result = OreUiLines.layout(sample.get("text").getAsString().codePoints().toArray(), ++ floats(sample.getAsJsonArray("advances")), fontMap, fonts, sample.get("width").getAsFloat(), ++ sample.get("indent").getAsFloat(), sample.get("letterSpacing").getAsFloat(), ++ OreUiLines.WhiteSpace.values()[sample.get("whitespace").getAsInt()], ++ OreUiLines.OverflowWrap.values()[sample.get("wordbreak").getAsInt()], ++ OreUiLines.Alignment.values()[sample.get("alignment").getAsInt()], sample.get("fill").getAsBoolean(), ++ sample.has("trailingAdvance") ? sample.get("trailingAdvance").getAsFloat() : 0F); ++ final var lines = sample.getAsJsonArray("lines"); ++ assertEquals(lines.size(), result.lines().size(), sample.toString()); ++ for (int index = 0; index < lines.size(); index++) { ++ final var line = lines.get(index).getAsJsonObject(); ++ final List fragments = new ArrayList<>(); ++ for (final var fragment : line.getAsJsonArray("fragments")) { ++ final var box = fragment.getAsJsonObject(); ++ fragments.add(new OreUiLines.Fragment(box.get("start").getAsInt(), box.get("end").getAsInt(), ++ box.get("font").getAsInt(), box.get("x").getAsFloat(), box.get("baseline").getAsFloat(), ++ box.get("width").getAsFloat(), box.get("height").getAsFloat())); ++ } ++ final var actual = result.lines().get(index); ++ assertEquals(fragments, actual.fragments(), sample.toString()); ++ assertEquals(line.get("width").getAsFloat(), actual.width(), sample.toString()); ++ assertEquals(line.get("height").getAsFloat(), actual.height(), sample.toString()); ++ assertEquals(line.get("hardBreak").getAsBoolean(), actual.hardBreak(), sample.toString()); ++ } ++ assertEquals(sample.getAsJsonArray("extent").get(0).getAsFloat(), result.width(), sample.toString()); ++ assertEquals(sample.getAsJsonArray("extent").get(1).getAsFloat(), result.height(), sample.toString()); ++ } + } -+ throw new AssertionError("Missing test-font table"); ++ } ++ ++ private static float[] floats(final JsonArray values) { ++ final float[] result = new float[values.size()]; ++ for (int index = 0; index < result.length; index++) result[index] = values.get(index).getAsFloat(); ++ return result; ++ } ++} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiShaderGpuTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiShaderGpuTest.java +new file mode 100644 +index 0000000..1823833 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/oreui/OreUiShaderGpuTest.java +@@ -0,0 +1,99 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.render.oreui; ++ ++import com.viaversion.viafabricplus.bedrock.render.EglTestContext; ++import java.io.IOException; ++import java.nio.charset.StandardCharsets; ++import org.junit.jupiter.api.Test; ++import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; ++import org.lwjgl.system.MemoryStack; ++import static org.junit.jupiter.api.Assertions.*; ++import static org.lwjgl.opengl.GL33C.*; ++ ++@EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_FONT_GPU_TESTS", matches = "1") ++class OreUiShaderGpuTest { ++ // Original 1.26.60.5 GamefaceStandard fragment IR with supplied input/sample/math endpoints. ++ // Selected shader arithmetic only: these controls do not establish native geometry or framebuffer parity. ++ private record Control(boolean sdf, boolean linear, float sample, float ratio, float[] tint, float[] expected) { } ++ private static final float[] WHITE = {1, 1, 1, 1}; ++ private static final float[] BLACK = {0, 0, 0, 1}; ++ private static final float[] TINT = {.15F, .3F, .45F, .6F}; ++ private static final Control[] CONTROLS = { ++ new Control(false, false, .25F, 1, WHITE, new float[]{.5358867F, .5358867F, .5358867F, .5358867F}), ++ new Control(false, false, .25F, 1, BLACK, new float[]{0, 0, 0, .13397168F}), ++ new Control(false, false, 128F / 255, 1, TINT, new float[]{.066920266F, .13384053F, .20076078F, .26768106F}), ++ new Control(false, true, 128F / 255, 1, TINT, new float[]{.081490815F, .16298163F, .24447243F, .32596326F}), ++ new Control(true, false, 0, .125F, WHITE, new float[]{.011117792F, .011117792F, .011117792F, .011117792F}), ++ new Control(true, false, 1, .125F, WHITE, new float[]{.99997956F, .99997956F, .99997956F, .99997956F}), ++ new Control(true, false, 127F / 255, 1, WHITE, new float[]{.7164642F, .7164642F, .7164642F, .7164642F}), ++ new Control(true, true, 128F / 255, .5F, TINT, new float[]{.08288493F, .16576986F, .24865477F, .33153972F}), ++ }; ++ ++ @Test ++ void matchesOriginalRasterAndCpuDistanceCoverageWithFloatTint() { ++ EglTestContext.run(() -> { ++ final int vao = glGenVertexArrays(), source = glGenTextures(), target = glGenTextures(), framebuffer = glGenFramebuffers(); ++ final int[] programs = {program(false, false), program(false, true), program(true, false), program(true, true)}; ++ try (var stack = MemoryStack.stackPush()) { ++ System.out.println("OreUI shader renderer: " + glGetString(GL_RENDERER)); ++ glBindVertexArray(vao); ++ glBindTexture(GL_TEXTURE_2D, target); ++ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA32F, 1, 1, 0, GL_RGBA, GL_FLOAT, 0L); ++ glBindFramebuffer(GL_FRAMEBUFFER, framebuffer); ++ glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, target, 0); ++ assertEquals(GL_FRAMEBUFFER_COMPLETE, glCheckFramebufferStatus(GL_FRAMEBUFFER)); ++ glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, source); ++ glTexImage2D(GL_TEXTURE_2D, 0, GL_R32F, 1, 1, 0, GL_RED, GL_FLOAT, 0L); ++ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); ++ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); ++ glViewport(0, 0, 1, 1); ++ glDisable(GL_BLEND); glDisable(GL_DEPTH_TEST); glDisable(GL_CULL_FACE); glDisable(GL_DITHER); glDisable(GL_FRAMEBUFFER_SRGB); ++ final var identity = stack.floats(1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1); ++ final var pixel = stack.mallocFloat(4); ++ for (final var control : CONTROLS) { ++ final int program = programs[(control.sdf ? 2 : 0) + (control.linear ? 1 : 0)]; glUseProgram(program); ++ glUniform1i(glGetUniformLocation(program, "Sampler0"), 0); ++ glUniformMatrix4fv(glGetUniformLocation(program, "ModelViewMat"), false, identity); ++ glUniformMatrix4fv(glGetUniformLocation(program, "ProjMat"), false, identity); ++ glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 1, 1, GL_RED, GL_FLOAT, stack.floats(control.sample)); ++ glVertexAttrib3f(0, 0, 0, 0); glVertexAttrib2f(1, control.tint[2], control.tint[3]); ++ glVertexAttribI2i(2, 0, 0); glVertexAttrib2f(3, control.tint[0], control.tint[1]); glVertexAttrib1f(4, control.ratio); ++ glDrawArrays(GL_POINTS, 0, 1); glReadPixels(0, 0, 1, 1, GL_RGBA, GL_FLOAT, pixel); ++ final float[] actual = new float[4]; pixel.get(0, actual); ++ assertArrayEquals(control.expected, actual, 2E-6F, control.toString()); ++ } ++ } finally { ++ glUseProgram(0); glBindFramebuffer(GL_FRAMEBUFFER, 0); ++ for (final int program : programs) glDeleteProgram(program); ++ glDeleteTextures(source); glDeleteTextures(target); glDeleteFramebuffers(framebuffer); glDeleteVertexArrays(vao); ++ } ++ }); ++ } ++ ++ private static int program(final boolean sdf, final boolean linear) { ++ final int vertex = shader(GL_VERTEX_SHADER, resource("vsh", sdf, linear)); ++ final int fragment = shader(GL_FRAGMENT_SHADER, resource("fsh", sdf, linear)); ++ final int program = glCreateProgram(); ++ try { ++ glAttachShader(program, vertex); glAttachShader(program, fragment); glLinkProgram(program); ++ assertEquals(GL_TRUE, glGetProgrami(program, GL_LINK_STATUS), glGetProgramInfoLog(program)); return program; ++ } finally { glDeleteShader(vertex); glDeleteShader(fragment); } ++ } ++ private static int shader(final int kind, final String source) { ++ final int shader = glCreateShader(kind); glShaderSource(shader, source); glCompileShader(shader); ++ assertEquals(GL_TRUE, glGetShaderi(shader, GL_COMPILE_STATUS), glGetShaderInfoLog(shader)); return shader; ++ } ++ private static String resource(final String extension, final boolean sdf, final boolean linear) { ++ try (var input = OreUiShaderGpuTest.class.getResourceAsStream("/assets/viafabricplus-bedrock/shaders/core/bedrock_oreui_font." + extension)) { ++ assertNotNull(input); ++ return new String(input.readAllBytes(), StandardCharsets.UTF_8) ++ .replace("#version 330", "#version 330\n" + (sdf ? "#define OREUI_SDF\n" : "") + (linear ? "#define OREUI_LINEAR_TINT\n" : "")) ++ .replace("#include ", "uniform mat4 ModelViewMat;") ++ .replace("#include ", "uniform mat4 ProjMat;"); ++ } catch (final IOException failure) { throw new IllegalStateException(failure); } + } +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/AnimationPrograms.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/AnimationPrograms.java @@ -16855,10 +20297,10 @@ index 0000000..36fc175 +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiSceneScreenInteractionTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiSceneScreenInteractionTest.java new file mode 100644 -index 0000000..4a0423c +index 0000000..668f63f --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiSceneScreenInteractionTest.java -@@ -0,0 +1,359 @@ +@@ -0,0 +1,407 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -16871,12 +20313,16 @@ index 0000000..4a0423c +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.mojang.blaze3d.platform.InputConstants; ++import com.mojang.blaze3d.platform.Window; ++import com.viaversion.viafabricplus.bedrock.injection.mixin.features.input.MixinNativeUiInput; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.minecraft.SharedConstants; +import net.minecraft.client.Minecraft; ++import net.minecraft.client.MouseHandler; ++import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.StringSplitter; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.navigation.FocusNavigationEvent; @@ -16902,6 +20348,50 @@ index 0000000..4a0423c + Bootstrap.bootStrap(); + } + ++ @Test ++ void hostPointerConversionKeepsAuthoredActionEdgesOnBothRoundedViewportAxes() throws Exception { ++ for (final int[] size : List.of(new int[]{1920, 1000, 960, 500}, new int[]{1000, 1920, 1000, 1920})) { ++ final var viewport = NativeUiViewport.desktop(size[0], size[1], 0, 3); ++ final boolean horizontal = size[0] < size[1]; ++ final var root = control("panel", viewport.width(), viewport.height(), 0); ++ final var button = control("button", 20, 20, horizontal ? 10 : 220); ++ button.getAsJsonArray("offset").set(0, new JsonPrimitive(horizontal ? 430 : 10)); ++ button.addProperty("action", 177); ++ final var children = new JsonArray(); ++ children.add(button); ++ root.add("children", children); ++ final var envelope = new JsonObject(); ++ envelope.addProperty("format", 1); ++ envelope.add("root", root); ++ final var controller = new Controller(NativeUiController.Activation.STAY, ++ NativeUiScene.parse(envelope.toString(), Set.of()).root()); ++ final var window = mock(Window.class); ++ when(window.getGuiScaledWidth()).thenReturn(Math.ceilDiv(size[0], 3)); ++ when(window.getGuiScaledHeight()).thenReturn(Math.ceilDiv(size[1], 3)); ++ when(window.getScreenWidth()).thenReturn(size[2]); ++ when(window.getScreenHeight()).thenReturn(size[3]); ++ // The last physical pixel inside the authored action, including a HiDPI host window. ++ final double physicalX = horizontal ? 899 : 42, physicalY = horizontal ? 22 : 959; ++ final var host = new MouseButtonEvent(MouseHandler.getScaledXPos(window, physicalX * size[2] / size[0]), ++ MouseHandler.getScaledYPos(window, physicalY * size[3] / size[1]), ++ new MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT, 0)); ++ try (final var current = mockStatic(NativeUiViewport.class)) { ++ current.when(NativeUiViewport::current).thenReturn(viewport); ++ withScreen(controller, (game, screen) -> { ++ screen.width = viewport.width(); ++ screen.height = viewport.height(); ++ screen.init(); ++ final var conversion = MixinNativeUiInput.class.getDeclaredMethod("nativeEvent", Screen.class, MouseButtonEvent.class); ++ conversion.setAccessible(true); ++ final var nativeEvent = (MouseButtonEvent) conversion.invoke(null, screen, host); ++ assertTrue(screen.mouseClicked(nativeEvent, false), ++ "A physical point inside the authored action must remain inside after host input conversion: " + nativeEvent); ++ assertEquals(List.of(177), controller.responses); ++ }); ++ } ++ } ++ } ++ + @ParameterizedTest + @EnumSource(NativeUiController.Activation.class) + void preservesDomainActionIndicesAndDoesNotCloseAReplacementScreen(final NativeUiController.Activation activation) throws Exception { @@ -17892,10 +21382,10 @@ index 0000000..de63cd3 +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiViewportTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiViewportTest.java new file mode 100644 -index 0000000..e394a6f +index 0000000..ceca6b2 --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiViewportTest.java -@@ -0,0 +1,95 @@ +@@ -0,0 +1,84 @@ +package com.viaversion.viafabricplus.bedrock.screen.ui; + +import com.mojang.blaze3d.platform.Window; @@ -17931,11 +21421,12 @@ index 0000000..e394a6f + when(window.getHeight()).thenReturn(dimensions[1]); + for (final int javaScale : new int[] {1, 2, 3, 4, 8}) { + when(window.getGuiScale()).thenReturn(javaScale); ++ when(window.getGuiScaledWidth()).thenReturn(Math.ceilDiv(dimensions[0], javaScale)); ++ when(window.getGuiScaledHeight()).thenReturn(Math.ceilDiv(dimensions[1], javaScale)); + for (final var value : NativeUiScale.values()) { + when(preference.value()).thenReturn(value); + final var current = NativeUiViewport.current(); + assertEquals(NativeUiViewport.desktop(dimensions[0], dimensions[1], value.offset(), javaScale), current); -+ assertEquals(37.25, current.pointer(37.25 * current.renderScale()), .0001); + assertEquals(javaScale, window.getGuiScale()); + } + } @@ -17978,18 +21469,6 @@ index 0000000..e394a6f + } + } + -+ @Test -+ void keepsNativeLayoutAndJavaRenderCoordinatesInvertibleAcrossGuiScales() { -+ for (final int javaScale : new int[] {1, 2, 3, 4, 5, 8}) { -+ final var viewport = NativeUiViewport.desktop(3840, 2134, 0, javaScale); -+ assertEquals(480, viewport.width()); -+ assertEquals(267, viewport.height()); -+ for (final float coordinate : new float[] {-1, 0, 5, 30, 129.5F, 267, 480}) { -+ assertEquals(coordinate, viewport.pointer(coordinate * viewport.renderScale()), .0001); -+ assertEquals(coordinate, viewport.pointer(viewport.pixel(coordinate)), .5 / viewport.renderScale() + .0001); -+ } -+ } -+ } +} diff --git a/src/test/resources/font/native-outline-fixture.ttf b/src/test/resources/font/native-outline-fixture.ttf new file mode 100644 @@ -18845,5 +22324,254 @@ index 0000000..0d80438 + } + ] +} +diff --git a/src/test/resources/oreui/fallback-coverage.ttf b/src/test/resources/oreui/fallback-coverage.ttf +new file mode 100644 +index 0000000000000000000000000000000000000000..7afc92378d8a6394a686d7c03730ac77999efeb3 +GIT binary patch +literal 452 +zcmZ{gu}T9$5Qe|Gz2puFsEvhH2$mLtAQr(w^A6g{l8YXaBL}89!NS5$TRTAnA0uF! +z_ptE+VyP=L7da99VcGe2{+aoA5Fq6S2gAab9J>qP9_XKsug+R`$tExd2KmTmJ~?^@ +z=>U6qKJu*%c>oyzTX`|9k`zF4U~N1ZdUol7RKQB%tCBWtkXxWzPA;c97vx2`1~}AF +zhnO96mJ(U+&`AhQ6yCz?r-p2c +z>1zK2+`Hxez^3Td)R{uSb;CYHCq@^)eAS3Gz@g>FIV%9TP9Be!4cW9sQ4Q +D&9XA1 + +literal 0 +HcmV?d00001 + +diff --git a/src/test/resources/oreui/kerning-advances.ttf b/src/test/resources/oreui/kerning-advances.ttf +new file mode 100644 +index 0000000000000000000000000000000000000000..688d6fc0a7dadc1e2e385236a397fe0b5d37594b +GIT binary patch +literal 600 +zcmZ{hKT88a6vTh;?s7RK_@@!EkPs|W2!f$+BNtJ8o3?3^$K!9VlLRXBLtjw#q{nYbg*p&>OfjSfs0U5%V-UMfmxPXXg +zQb7c!XIn%+KB@O>{ll5!JGh$5A6OOjrp^=sZq^jQIk4N|FJB{g%8Xc$y#1+;HA9yS +gg0ESap6*RgQ%HQxk<%Bk)tcbw#$)9P?7RGrZ{`$7lK=n! + +literal 0 +HcmV?d00001 + +diff --git a/src/test/resources/oreui/make-fixture.py b/src/test/resources/oreui/make-fixture.py +new file mode 100644 +index 0000000..29d6fff +--- /dev/null ++++ b/src/test/resources/oreui/make-fixture.py +@@ -0,0 +1,87 @@ ++"""Build an authored four-glyph TrueType fixture with mark-to-base positioning.""" ++from pathlib import Path ++import struct ++ ++ ++def u16(*values): ++ return struct.pack('>' + 'H' * len(values), *(x & 65535 for x in values)) ++ ++ ++def u32(*values): ++ return struct.pack('>' + 'I' * len(values), *values) ++ ++ ++def rectangle(width, height): ++ return u16(1, 0, 0, width, height, 3, 0) + bytes([1] * 4) + u16(0, width, 0, -width, 0, 0, height, 0) ++ ++ ++head = u32(0x10000, 0x10000, 0, 0x5f0f3cf5) + u16(0, 1000) + bytes(16) + u16(0, 0, 500, 700, 0, 8, 2, 0, 0) ++hhea = u32(0x10000) + u16(800, -200, 0, 600, 0, 0, 500, 1, 0, 0, 0, 0, 0, 0, 0, 4) ++maxp = u32(0x10000) + u16(4, 4, 1, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0) ++a, mark = rectangle(500, 700), rectangle(100, 100) ++glyf = a + mark ++loca = u16(0, 0, len(a) // 2, len(glyf) // 2, len(glyf) // 2) ++hmtx = u16(600, 0, 600, 0, 0, 0, 300, 0) ++characters = [32, 97, 0x301, 65535] ++cmapping = u16(4, 48, 0, 8, 8, 2, 0, *characters, 0, *characters, ++ 3-32, 1-97, 2-0x301, 1, 0, 0, 0, 0) ++cmap = u16(0, 1, 3, 1) + u32(12) + cmapping ++scripts = u16(1) + b'DFLT' + u16(8, 4, 0, 0, 65535, 1, 0) ++features = u16(1) + b'mark' + u16(8, 0, 1, 0) ++subtable = u16(1, 12, 18, 1, 24, 36, 1, 1, 2, 1, 1, 1, ++ 1, 0, 6, 1, 2, 2, 1, 4, 1, 1, 1) ++lookups = u16(1, 4, 4, 0, 1, 8) + subtable ++gpos = u32(0x10000) + u16(10, 10+len(scripts), 10+len(scripts)+len(features)) + scripts + features + lookups ++post = u32(0x30000) + bytes(28) ++tables = {'GPOS': gpos, 'cmap': cmap, 'glyf': glyf, 'head': head, 'hhea': hhea, ++ 'hmtx': hmtx, 'loca': loca, 'maxp': maxp, 'post': post} ++ ++ ++def checksum(data): ++ data += bytes((-len(data)) & 3) ++ return sum(struct.unpack('>' + 'I' * (len(data) // 4), data)) & 0xffffffff ++ ++ ++def write_font(tables, filename): ++ count = len(tables) ++ power = 1 << (count.bit_length() - 1) ++ font = bytearray(u32(0x10000) + u16(count, power * 16, power.bit_length()-1, count*16-power*16) + bytes(count*16)) ++ head_at = None ++ for index, (tag, contents) in enumerate(sorted(tables.items())): ++ font += bytes((-len(font)) & 3) ++ offset = len(font) ++ struct.pack_into('>4sIII', font, 12 + index * 16, tag.encode(), checksum(contents), offset, len(contents)) ++ font += contents ++ if tag == 'head': ++ head_at = offset ++ struct.pack_into('>I', font, head_at + 8, (0xb1b0afba - checksum(font)) & 0xffffffff) ++ assert checksum(font) == 0xb1b0afba ++ Path(__file__).with_name(filename).write_bytes(font) ++ ++ ++write_font(tables, 'minimal.ttf') ++# Reuse the a outline for an authored aa ligature. Retain the mark GPOS table. ++# Ordinary measurement must keep both advances while full drawing can substitute one glyph. ++ligature_features = u16(1) + b'liga' + u16(8, 0, 1, 0) ++ligature_subtable = u16(1, 8, 1, 14, 1, 1, 1, 1, 4, 1, 2, 1) ++ligature_lookups = u16(1, 4, 4, 0, 1, 8) + ligature_subtable ++ligature_tables = dict(tables) ++ligature_tables['GSUB'] = (u32(0x10000) + u16(10, 10+len(scripts), 10+len(scripts)+len(ligature_features)) ++ + scripts + ligature_features + ligature_lookups) ++write_font(ligature_tables, 'ordinary-advances.ttf') ++nominal_tables = {tag: contents for tag, contents in ligature_tables.items() if tag != 'GPOS'} ++write_font(nominal_tables, 'nominal-advances.ttf') ++# Include space pairs to catch the native ordinary path's explicit space exclusion. ++kerning_tables = dict(nominal_tables) ++pairs = u16(1, 1, -100, 1, 3, -100, 3, 1, -100) ++kerning_tables['kern'] = u16(0, 1, 0, 14 + len(pairs), 1, 3, 12, 1, 6) + pairs ++write_font(kerning_tables, 'kerning-advances.ttf') ++ ++# A disjoint Latin fallback face gives B a distinct advance while leaving a missing. ++fallback_tables = {tag: contents for tag, contents in tables.items() if tag != 'GPOS'} ++fallback_characters = [32, 66, 0x301, 65535] ++fallback_mapping = u16(4, 48, 0, 8, 8, 2, 0, *fallback_characters, 0, *fallback_characters, ++ 3-32, 1-66, 2-0x301, 1, 0, 0, 0, 0) ++fallback_tables['cmap'] = u16(0, 1, 3, 1) + u32(12) + fallback_mapping ++fallback_tables['hmtx'] = u16(600, 0, 900, 0, 0, 0, 300, 0) ++write_font(fallback_tables, 'fallback-coverage.ttf') +diff --git a/src/test/resources/oreui/minimal-a.sdf b/src/test/resources/oreui/minimal-a.sdf +new file mode 100644 +index 0000000000000000000000000000000000000000..31071dd0762beacdba247418ec600276762be807 +GIT binary patch +literal 1620 +zcmeIyF$#k~5Cza_Vj()Qw1~}O85bKnJ%J4D0pbA`5j;S=!!~-393@A|4H8xoh`Ix5 +z1HSq*#b@{czwqPa#7JU)zHm&7s$^6}YBL8>spi6HRAn>4xz@v#G3fdn0E8KJqhX94 +zWg$S6tKQrgrdN6N$=sb*nTNwB^W4}oUp?>rEbPDW@4w-&-UO%RwlLlsg_7EdnL0DI +oWcLsy>9%LInj`{vq9jrbqEdJOfI`ZgA*H~1=sm=UfoQM53nx&O-l! +zbwKRxY%K&EON-d4C$kqh5PY!9%)H0UV+H}nILD>9vAwl(H-BFRu7Oq~GF?vHLl6(B +z9kkC{nakV^$SP0|n%NFoh0DOEL>%q7Z$7vM5#y4L{rv%y)89D=XE+V2Cd>6Ft=pL+s +zfFM;6K&(5qkyQvhEj~W%kJmoHeY4yj*eU8wohby|QoI7ro=u0NSt&ea5*9=+tC*my +SSFmc`TH^4xYYKad|M3GGfj*i5 + +literal 0 +HcmV?d00001 + +diff --git a/src/test/resources/oreui/native-characters-2225.json b/src/test/resources/oreui/native-characters-2225.json +new file mode 100644 +index 0000000..5120719 +--- /dev/null ++++ b/src/test/resources/oreui/native-characters-2225.json +@@ -0,0 +1 @@ ++{"nativeBuild":"1.26.60.5","protocol":2225,"nativeSha256":"9ad9a26235c016e55fe68800cb3d00843a83840f9683cc478a2a6079d59b4669","scope":"Original newline/scalar/UTF16 mapping and normal/nowrap whitespace collapse. Complete target routines execute with supplied well-formed Unicode, preallocated outputs, initialized empty per-thread allocation context and bounded allocation/free/CRT memory endpoints. CSS selection, invalid UTF8, bidi, font fallback and final rendered layout are outside these controls.","cases":[{"source":"","mode":0,"scalars":[],"utf16Offsets":[],"whitespace":2,"trailingAdvance":0.0},{"source":"A\ud83d\ude00\r\nB","mode":0,"scalars":[65,128512,10,66],"utf16Offsets":[0,1,3,5],"whitespace":2,"trailingAdvance":0.0},{"source":"A\r\n\ud83d\ude00B","mode":1,"scalars":[65,128512,66],"utf16Offsets":[0,3,5],"whitespace":3,"trailingAdvance":0.0},{"source":"A\r\n\ud83d\ude00B","mode":2,"scalars":[65,10,128512,66],"utf16Offsets":[0,2,3,5],"whitespace":2,"trailingAdvance":0.0},{"source":"A\r\r\nB","mode":0,"scalars":[65,10,10,66],"utf16Offsets":[0,1,2,4],"whitespace":3,"trailingAdvance":0.0},{"source":"A\t \tB","mode":0,"scalars":[65,9,32,9,66],"utf16Offsets":[0,1,2,3,4],"whitespace":2,"trailingAdvance":0.0},{"source":"A\r\nB","mode":0,"scalars":[65,32,66],"utf16Offsets":[0,1,3],"whitespace":0,"trailingAdvance":0.0},{"source":"A\t \tB","mode":0,"scalars":[65,32,66],"utf16Offsets":[0,1,4],"whitespace":0,"trailingAdvance":0.0},{"source":"A\u0000B","mode":0,"scalars":[65,32,66],"utf16Offsets":[0,1,2],"whitespace":1,"trailingAdvance":0.0},{"source":"A\u000b\fB","mode":0,"scalars":[65,32,66],"utf16Offsets":[0,1,3],"whitespace":0,"trailingAdvance":0.0},{"source":"\u00a0\u2003\u2028\u2029","mode":0,"scalars":[160,8195,8232,8233],"utf16Offsets":[0,1,2,3],"whitespace":0,"trailingAdvance":0.0},{"source":"\r\ud83d\ude00\r\nZ","mode":0,"scalars":[128512,32,90],"utf16Offsets":[1,3,5],"whitespace":1,"trailingAdvance":0.0},{"source":" A ","mode":0,"scalars":[65],"utf16Offsets":[2],"whitespace":0,"trailingAdvance":5.400000095367432},{"source":" ","mode":0,"scalars":[],"utf16Offsets":[],"whitespace":0,"trailingAdvance":5.400000095367432},{"source":"A \n","mode":0,"scalars":[65],"utf16Offsets":[0],"whitespace":0,"trailingAdvance":0.0},{"source":"A\n ","mode":0,"scalars":[65],"utf16Offsets":[0],"whitespace":0,"trailingAdvance":5.400000095367432},{"source":"A\t","mode":1,"scalars":[65],"utf16Offsets":[0],"whitespace":0,"trailingAdvance":5.400000095367432},{"source":"A\nB","mode":1,"scalars":[65,66],"utf16Offsets":[0,2],"whitespace":0,"trailingAdvance":0.0},{"source":"A\rB","mode":2,"scalars":[65,66],"utf16Offsets":[0,2],"whitespace":1,"trailingAdvance":0.0}]} +diff --git a/src/test/resources/oreui/native-font-assignment-2225.json b/src/test/resources/oreui/native-font-assignment-2225.json +new file mode 100644 +index 0000000..248e72d +--- /dev/null ++++ b/src/test/resources/oreui/native-font-assignment-2225.json +@@ -0,0 +1 @@ ++{"nativeBuild":"1.26.60.5","protocol":2225,"nativeSha256":"9ad9a26235c016e55fe68800cb3d00843a83840f9683cc478a2a6079d59b4669","entry":"0x18003a9ec","scope":"Original ordinary constructor executes preprocessing, font assignment, metrics and interior-whitespace reassignment through entry to the original line builder. Supplied font-provider coverage/advance/metric callbacks and explicit allocation/CRT endpoints. Stops before line construction. Not actual font parsing, bidi/complex-script path, CSS font list selection or rendered parity.","cases":[{"text":"ABCD","definitions":[{"A":1," ":2},{"B":3,"C":4," ":5},{"D":6," ":7}],"contextual":false,"scalars":[65,66,67,68],"glyphs":[1,3,4,6],"advances":[10.125,20.375,20.5,30.75],"fonts":[0,1,1,2]},{"text":"B B","definitions":[{"A":1," ":2},{"B":3,"C":4," ":5},{"D":6," ":7}],"contextual":false,"scalars":[66,32,66],"glyphs":[3,4294967295,3],"advances":[20.375,20.625,20.375],"fonts":[1,1,1]},{"text":"A B","definitions":[{"A":1," ":2},{"B":3,"C":4," ":5},{"D":6," ":7}],"contextual":false,"scalars":[65,32,66],"glyphs":[1,4294967295,3],"advances":[10.125,10.25,20.375],"fonts":[0,0,1]},{"text":"B A B","definitions":[{"A":1," ":2},{"B":3,"C":4," ":5},{"D":6," ":7}],"contextual":false,"scalars":[66,32,65,32,66],"glyphs":[3,4294967295,1,4294967295,3],"advances":[20.375,10.25,10.125,10.25,20.375],"fonts":[1,0,0,0,1]},{"text":"B\tB","definitions":[{"A":1," ":2},{"B":3,"C":4," ":5},{"D":6," ":7}],"contextual":false,"scalars":[66,9,66],"glyphs":[3,4294967295,3],"advances":[20.375,30.0,20.375],"fonts":[1,2,1]},{"text":"B\nB","definitions":[{"A":1," ":2},{"B":3,"C":4," ":5},{"D":6," ":7}],"contextual":false,"scalars":[66,10,66],"glyphs":[3,4294967295,3],"advances":[20.375,30.0,20.375],"fonts":[1,2,1]},{"text":" B ","definitions":[{"A":1," ":2},{"B":3,"C":4," ":5},{"D":6," ":7}],"contextual":false,"scalars":[32,66,32],"glyphs":[4294967295,3,4294967295],"advances":[10.25,20.375,10.25],"fonts":[0,1,0]},{"text":"XBX","definitions":[{"A":1," ":2},{"B":3,"C":4," ":5},{"D":6," ":7}],"contextual":false,"scalars":[88,66,88],"glyphs":[0,3,0],"advances":[30.0,20.375,30.0],"fonts":[2,1,2]},{"text":"A\u03a9B","definitions":[{"A":1," ":2},{"B":3,"C":4," ":5},{"D":6," ":7}],"contextual":false,"scalars":[65,937,66],"glyphs":[1,0,3],"advances":[10.125,30.0,20.375],"fonts":[0,2,1]},{"text":"B B B","definitions":[{"A":1," ":2},{"B":3,"C":4," ":5},{"D":6," ":7}],"contextual":false,"scalars":[66,32,66,32,66],"glyphs":[3,4294967295,3,4294967295,3],"advances":[20.375,20.625,20.375,20.625,20.375],"fonts":[1,1,1,1,1]},{"text":"AB X BC","definitions":[{"A":1," ":2},{"B":3,"C":4," ":5},{"D":6," ":7}],"contextual":false,"scalars":[65,66,32,88,32,66,67],"glyphs":[1,3,4294967295,0,4294967295,3,4],"advances":[10.125,20.375,10.25,30.0,10.25,20.375,20.5],"fonts":[0,1,0,2,0,1,1]},{"text":"BC BC BC","definitions":[{"A":1," ":2},{"B":3,"C":4," ":5},{"D":6," ":7}],"contextual":false,"scalars":[66,67,32,66,67,32,66,67],"glyphs":[3,4,4294967295,3,4,4294967295,3,4],"advances":[20.375,20.5,20.625,20.375,20.5,20.625,20.375,20.5],"fonts":[1,1,1,1,1,1,1,1]},{"text":"ABCD","definitions":[{"A":1," ":2},{"B":3,"C":4," ":5},{"D":6," ":7}],"contextual":true,"scalars":[65,66,67,68],"glyphs":[1,3,4,6],"advances":[10.3125,20.8125,20.6875,30.75],"fonts":[0,1,1,2]},{"text":"ABBA","definitions":[null,{"A":8},{"B":9},null],"contextual":false,"scalars":[65,66,66,65],"glyphs":[8,9,9,8],"advances":[21.0,31.125,31.125,21.0],"fonts":[1,2,2,1]},{"text":"B BCC C","definitions":[null,null,null,null,null,null,{"B":1," ":2},{"C":3," ":4}],"contextual":false,"scalars":[66,32,66,67,67,32,67],"glyphs":[1,4294967295,1,3,3,4294967295,3],"advances":[70.125,70.25,70.125,80.375,80.375,80.5,80.375],"fonts":[6,6,6,7,7,7,7]},{"text":"A\u03a9\u4e2d\ud83d\ude00B","definitions":[{"A":1," ":2},{"\u03a9":3,"\u4e2d":4,"\ud83d\ude00":5},{"B":6}],"contextual":false,"scalars":[65,937,20013,128512,66],"glyphs":[1,3,4,5,6],"advances":[10.125,20.375,20.5,20.625,30.75],"fonts":[0,1,1,1,2]},{"text":"XYZ","definitions":[{"A":1," ":2},{"B":3,"C":4," ":5},{"D":6," ":7}],"contextual":false,"scalars":[88,89,90],"glyphs":[0,0,0],"advances":[30.0,30.0,30.0],"fonts":[2,2,2]},{"text":"A B","definitions":[null,null],"contextual":false,"scalars":[65,32,66],"glyphs":[0,4294967295,0],"advances":[0.0,0.0,0.0],"fonts":[0,0,0]}]} +diff --git a/src/test/resources/oreui/native-lines-2225.json b/src/test/resources/oreui/native-lines-2225.json +new file mode 100644 +index 0000000..98debf5 +--- /dev/null ++++ b/src/test/resources/oreui/native-lines-2225.json +@@ -0,0 +1,22 @@ ++{"nativeBuild":"1.26.60.5","protocol":2225,"nativeSha256":"9ad9a26235c016e55fe68800cb3d00843a83840f9683cc478a2a6079d59b4669","scope":"Complete original line builder and ordinary left/right/center alignment execute. Single supplied font, scalar advances, line metrics and style flags; original soft-wrap detection controls available-width alignment. Explicit bounded memory allocation/free and CRT memory/math endpoints. Preprocessing, font selection, CSS cascade, bidi, ellipsis and complete screen rendering remain outside.","cases":[{"extent":[10.0,48.0],"text":"a\na","advances":[10.0,0.0,10.0],"width":9.0,"indent":15.0,"letterSpacing":0.0,"whitespace":0,"wordbreak":0,"alignment":2,"fill":false,"lines":[{"start":0,"end":0,"x":0,"baseline":0,"width":0.0,"height":0.0,"hardBreak":false},{"start":0,"end":2,"x":0.0,"baseline":20.0,"width":10.0,"height":24.0,"hardBreak":true},{"start":0,"end":0,"x":0,"baseline":0,"width":0.0,"height":0.0,"hardBreak":false},{"start":2,"end":3,"x":0.0,"baseline":44.0,"width":10.0,"height":24.0,"hardBreak":true}]}, ++{"extent":[91.3218765258789,24.0],"text":"Respawn","advances":[11.90625,11.90625,11.90625,11.90625,11.90625,16.09375,13.296875],"width":320.0,"indent":0.0,"letterSpacing":0.4,"whitespace":1,"wordbreak":0,"alignment":2,"fill":true,"lines":[{"start":0,"end":7,"x":0.0,"baseline":20.0,"width":91.3218765258789,"height":24.0,"hardBreak":true}]}, ++{"extent":[10.399999618530273,48.0],"text":"a a","advances":[10.0,4.0,10.0],"width":15.0,"indent":0.0,"letterSpacing":0.4,"whitespace":0,"wordbreak":0,"alignment":2,"fill":true,"lines":[{"start":0,"end":2,"x":2.3000001907348633,"baseline":20.0,"width":10.399999618530273,"height":24.0,"hardBreak":false},{"start":2,"end":3,"x":2.5,"baseline":44.0,"width":10.0,"height":24.0,"hardBreak":true}]}, ++{"extent":[10.399999618530273,48.0],"text":"a a","advances":[10.0,4.0,10.0],"width":15.0,"indent":0.0,"letterSpacing":0.4,"whitespace":0,"wordbreak":0,"alignment":2,"fill":false,"lines":[{"start":0,"end":2,"x":0.0,"baseline":20.0,"width":10.399999618530273,"height":24.0,"hardBreak":false},{"start":2,"end":3,"x":0.19999980926513672,"baseline":44.0,"width":10.0,"height":24.0,"hardBreak":true}]}, ++{"extent":[41.19999694824219,24.0],"text":"aaaa","advances":[10.0,10.0,10.0,10.0],"width":15.0,"indent":0.0,"letterSpacing":0.4,"whitespace":0,"wordbreak":0,"alignment":2,"fill":true,"lines":[{"start":0,"end":4,"x":0.0,"baseline":20.0,"width":41.19999694824219,"height":24.0,"hardBreak":true}]}, ++{"extent":[10.399999618530273,96.0],"text":"aaaa","advances":[10.0,10.0,10.0,10.0],"width":15.0,"indent":0.0,"letterSpacing":0.4,"whitespace":0,"wordbreak":1,"alignment":1,"fill":true,"lines":[{"start":0,"end":1,"x":4.600000381469727,"baseline":20.0,"width":10.399999618530273,"height":24.0,"hardBreak":false},{"start":1,"end":2,"x":4.600000381469727,"baseline":44.0,"width":10.399999618530273,"height":24.0,"hardBreak":false},{"start":2,"end":3,"x":4.600000381469727,"baseline":68.0,"width":10.399999618530273,"height":24.0,"hardBreak":false},{"start":3,"end":4,"x":5.0,"baseline":92.0,"width":10.0,"height":24.0,"hardBreak":true}]}, ++{"extent":[25.19999885559082,48.0],"text":"aa aa","advances":[10.0,10.0,4.0,10.0,10.0],"width":30.0,"indent":0.0,"letterSpacing":0.4,"whitespace":0,"wordbreak":2,"alignment":0,"fill":false,"lines":[{"start":0,"end":3,"x":0.0,"baseline":20.0,"width":25.19999885559082,"height":24.0,"hardBreak":false},{"start":3,"end":5,"x":0.0,"baseline":44.0,"width":20.399999618530273,"height":24.0,"hardBreak":true}]}, ++{"extent":[20.799999237060547,72.0],"text":"a aaa","advances":[10.0,4.0,10.0,10.0,10.0],"width":25.0,"indent":0.0,"letterSpacing":0.4,"whitespace":0,"wordbreak":1,"alignment":0,"fill":false,"lines":[{"start":0,"end":2,"x":0.0,"baseline":20.0,"width":10.399999618530273,"height":24.0,"hardBreak":false},{"start":2,"end":4,"x":0.0,"baseline":44.0,"width":20.799999237060547,"height":24.0,"hardBreak":false},{"start":4,"end":5,"x":0.0,"baseline":68.0,"width":10.0,"height":24.0,"hardBreak":true}]}, ++{"extent":[18.799999237060547,24.0],"text":" a ","advances":[4.0,10.0,4.0],"width":320.0,"indent":0.0,"letterSpacing":0.4,"whitespace":2,"wordbreak":0,"alignment":0,"fill":false,"lines":[{"start":0,"end":3,"x":0.0,"baseline":20.0,"width":18.799999237060547,"height":24.0,"hardBreak":true}]}, ++{"extent":[10.399999618530273,48.0],"text":"a\n","advances":[10.0,0.0],"width":320.0,"indent":0.0,"letterSpacing":0.4,"whitespace":0,"wordbreak":0,"alignment":2,"fill":true,"lines":[{"start":0,"end":2,"x":0.0,"baseline":20.0,"width":10.399999618530273,"height":24.0,"hardBreak":true},{"start":2,"end":2,"x":5.199999809265137,"baseline":44.0,"width":0.0,"height":24.0,"hardBreak":true}]}, ++{"extent":[0.0,72.0],"text":"\n\n","advances":[0.0,0.0],"width":320.0,"indent":0.0,"letterSpacing":0.4,"whitespace":0,"wordbreak":0,"alignment":2,"fill":true,"lines":[{"start":0,"end":1,"x":0.0,"baseline":20.0,"width":0.0,"height":24.0,"hardBreak":true},{"start":1,"end":2,"x":0.0,"baseline":44.0,"width":0.0,"height":24.0,"hardBreak":true},{"start":2,"end":2,"x":0.0,"baseline":68.0,"width":0.0,"height":24.0,"hardBreak":true}]}, ++{"extent":[24.799999237060547,24.0],"text":"a\u00a0a","advances":[10.0,4.0,10.0],"width":15.0,"indent":0.0,"letterSpacing":0.4,"whitespace":0,"wordbreak":0,"alignment":0,"fill":false,"lines":[{"start":0,"end":3,"x":0.0,"baseline":20.0,"width":24.799999237060547,"height":24.0,"hardBreak":true}]}, ++{"extent":[10.399999618530273,48.0],"text":"a a","advances":[10.0,4.0,10.0],"width":20.0,"indent":30.0,"letterSpacing":0.4,"whitespace":0,"wordbreak":0,"alignment":0,"fill":false,"lines":[{"start":0,"end":0,"x":0,"baseline":0,"width":0.0,"height":0.0,"hardBreak":false},{"start":0,"end":2,"x":0.0,"baseline":20.0,"width":10.399999618530273,"height":24.0,"hardBreak":false},{"start":2,"end":3,"x":0.0,"baseline":44.0,"width":10.0,"height":24.0,"hardBreak":true}]}, ++{"extent":[24.799999237060547,24.0],"text":"a a","advances":[10.0,4.0,10.0],"width":20.0,"indent":-5.0,"letterSpacing":0.4,"whitespace":0,"wordbreak":0,"alignment":0,"fill":false,"lines":[{"start":0,"end":3,"x":0.0,"baseline":20.0,"width":24.799999237060547,"height":24.0,"hardBreak":true}]}, ++{"extent":[0.0,24.0],"text":"","advances":[],"width":320.0,"indent":0.0,"letterSpacing":0.4,"whitespace":0,"wordbreak":0,"alignment":2,"fill":true,"lines":[{"start":0,"end":0,"x":0.0,"baseline":20.0,"width":0.0,"height":24.0,"hardBreak":true}]}, ++{"extent":[19.399999618530273,24.0],"text":"aa","advances":[10.0,9.0],"width":19.4,"indent":0.0,"letterSpacing":0.4,"whitespace":0,"wordbreak":0,"alignment":0,"fill":false,"lines":[{"start":0,"end":2,"x":0.0,"baseline":20.0,"width":19.399999618530273,"height":24.0,"hardBreak":true}]}, ++{"extent":[10.399999618530273,48.0],"text":"aa","advances":[10.0,9.0],"width":19.39999,"indent":0.0,"letterSpacing":0.4,"whitespace":0,"wordbreak":1,"alignment":0,"fill":false,"lines":[{"start":0,"end":1,"x":0.0,"baseline":20.0,"width":10.399999618530273,"height":24.0,"hardBreak":false},{"start":1,"end":2,"x":0.0,"baseline":44.0,"width":9.0,"height":24.0,"hardBreak":true}]}, ++{"extent":[10.399999618530273,48.0],"text":"a a","advances":[10.0,4.0,4.0,10.0],"width":15.0,"indent":0.0,"letterSpacing":0.4,"whitespace":0,"wordbreak":0,"alignment":0,"fill":false,"lines":[{"start":0,"end":3,"x":0.0,"baseline":20.0,"width":10.399999618530273,"height":24.0,"hardBreak":false},{"start":3,"end":4,"x":0.0,"baseline":44.0,"width":10.0,"height":24.0,"hardBreak":true}]}, ++{"extent":[24.799999237060547,24.0],"text":"a\ta","advances":[10.0,4.0,10.0],"width":320.0,"indent":0.0,"letterSpacing":0.4,"whitespace":0,"wordbreak":0,"alignment":0,"fill":false,"lines":[{"start":0,"end":3,"x":0.0,"baseline":20.0,"width":24.799999237060547,"height":24.0,"hardBreak":true}]}, ++{"extent":[20.799999237060547,24.0],"text":"a\ra","advances":[10.0,0.0,10.0],"width":320.0,"indent":0.0,"letterSpacing":0.4,"whitespace":0,"wordbreak":0,"alignment":0,"fill":false,"lines":[{"start":0,"end":3,"x":0.0,"baseline":20.0,"width":20.799999237060547,"height":24.0,"hardBreak":true}]}, ++{"extent":[10.0,96.0],"text":"aaaa","advances":[10.0,10.0,10.0,10.0],"width":15.0,"indent":0.0,"letterSpacing":-2.0,"whitespace":0,"wordbreak":1,"alignment":0,"fill":false,"lines":[{"start":0,"end":1,"x":0.0,"baseline":20.0,"width":8.0,"height":24.0,"hardBreak":false},{"start":1,"end":2,"x":0.0,"baseline":44.0,"width":8.0,"height":24.0,"hardBreak":false},{"start":2,"end":3,"x":0.0,"baseline":68.0,"width":8.0,"height":24.0,"hardBreak":false},{"start":3,"end":4,"x":0.0,"baseline":92.0,"width":10.0,"height":24.0,"hardBreak":true}]}, ++{"extent":[14.799999237060547,48.0],"text":"a a","advances":[10.0,4.0,10.0],"width":15.0,"indent":0.0,"letterSpacing":0.4,"whitespace":3,"wordbreak":0,"alignment":0,"fill":false,"lines":[{"start":0,"end":2,"x":0.0,"baseline":20.0,"width":14.799999237060547,"height":24.0,"hardBreak":false},{"start":2,"end":3,"x":0.0,"baseline":44.0,"width":10.0,"height":24.0,"hardBreak":true}]}]} +diff --git a/src/test/resources/oreui/native-locale-fonts-2225.json b/src/test/resources/oreui/native-locale-fonts-2225.json +new file mode 100644 +index 0000000..b8a3d4f +--- /dev/null ++++ b/src/test/resources/oreui/native-locale-fonts-2225.json +@@ -0,0 +1 @@ ++{"nativeBuild":"1.26.60.5","protocol":2225,"scriptSha256":"9e7efe423b6cc8a1c1fa92756bd181675cffa51a4728bce386b0081cad1bb979","fontModuleSha256":"97954352dcbcab17cd503149c2832b49df3bf55b828edb8ac78b1bc41eff68a0","layoutModuleSha256":"9ad9a26235c016e55fe68800cb3d00843a83840f9683cc478a2a6079d59b4669","scope":"Original gameplay locale selector, independent original target FT character maps, and original ordinary constructor assignment through the line-builder boundary. Coverage callbacks supply the recorded real glyph indices; advances and metrics remain explicit synthetic interfaces. Tail font interfaces are unused in these inputs. Not combined font-provider registration, CSS cascade, rendering or live locale parity.","cases":[{"locale":"en_US","primaryFamily":"Minecraft Seven v2","families":["Minecraft Seven v2","Noto Sans","Noto Sans SC","Noto Sans TC","Noto Sans JP","Noto Sans KR","Noto Sans AR","Noto Sans Mongolian","Noto Sans Syriac","Noto Sans TamilSupplement"],"text":"a\u4e2db","glyphs":[31,8748,32],"fonts":[0,2,0]},{"locale":"de_DE","primaryFamily":"Minecraft Seven v2","families":["Minecraft Seven v2","Noto Sans","Noto Sans SC","Noto Sans TC","Noto Sans JP","Noto Sans KR","Noto Sans AR","Noto Sans Mongolian","Noto Sans Syriac","Noto Sans TamilSupplement"],"text":"a\u4e2db","glyphs":[31,8748,32],"fonts":[0,2,0]},{"locale":"ja_JP","primaryFamily":"Minecraft Seven v2","families":["Minecraft Seven v2","Noto Sans JP","Noto Sans TC","Noto Sans SC","Noto Sans KR","Noto Sans","Noto Sans Mongolian","Noto Sans Syriac","Noto Sans TamilSupplement"],"text":"a\u4e2db","glyphs":[31,2362,32],"fonts":[0,1,0]},{"locale":"ja","primaryFamily":"Minecraft Seven v2","families":["Minecraft Seven v2","Noto Sans JP","Noto Sans TC","Noto Sans SC","Noto Sans KR","Noto Sans","Noto Sans Mongolian","Noto Sans Syriac","Noto Sans TamilSupplement"],"text":"a\u4e2db","glyphs":[31,2362,32],"fonts":[0,1,0]},{"locale":"zh_TW","primaryFamily":"Minecraft Seven v2","families":["Minecraft Seven v2","Noto Sans TC","Noto Sans SC","Noto Sans JP","Noto Sans KR","Noto Sans","Noto Sans Mongolian","Noto Sans Syriac","Noto Sans TamilSupplement"],"text":"a\u4e2db","glyphs":[31,2728,32],"fonts":[0,1,0]},{"locale":"zh_CN","primaryFamily":"Minecraft Seven v2","families":["Minecraft Seven v2","Noto Sans SC","Noto Sans TC","Noto Sans JP","Noto Sans KR","Noto Sans","Noto Sans Mongolian","Noto Sans Syriac","Noto Sans TamilSupplement"],"text":"a\u4e2db","glyphs":[31,8748,32],"fonts":[0,1,0]},{"locale":"ko_KR","primaryFamily":"Minecraft Seven v2","families":["Minecraft Seven v2","Noto Sans KR","Noto Sans TC","Noto Sans SC","Noto Sans JP","Noto Sans","Noto Sans Mongolian","Noto Sans Syriac","Noto Sans TamilSupplement"],"text":"a\u4e2db","glyphs":[31,2450,32],"fonts":[0,1,0]},{"locale":"ko","primaryFamily":"Minecraft Seven v2","families":["Minecraft Seven v2","Noto Sans KR","Noto Sans TC","Noto Sans SC","Noto Sans JP","Noto Sans","Noto Sans Mongolian","Noto Sans Syriac","Noto Sans TamilSupplement"],"text":"a\u4e2db","glyphs":[31,2450,32],"fonts":[0,1,0]},{"locale":"ar_SA","primaryFamily":"Minecraft Seven v2","families":["Minecraft Seven v2","Noto Sans AR","Noto Sans","Noto Sans KR","Noto Sans TC","Noto Sans SC","Noto Sans JP","Noto Sans Mongolian","Noto Sans Syriac","Noto Sans TamilSupplement"],"text":"a\u4e2db","glyphs":[31,2450,32],"fonts":[0,3,0]},{"locale":"ar","primaryFamily":"Minecraft Seven v2","families":["Minecraft Seven v2","Noto Sans","Noto Sans SC","Noto Sans TC","Noto Sans JP","Noto Sans KR","Noto Sans AR","Noto Sans Mongolian","Noto Sans Syriac","Noto Sans TamilSupplement"],"text":"a\u4e2db","glyphs":[31,8748,32],"fonts":[0,2,0]},{"locale":"zh_HK","primaryFamily":"Minecraft Seven v2","families":["Minecraft Seven v2","Noto Sans","Noto Sans SC","Noto Sans TC","Noto Sans JP","Noto Sans KR","Noto Sans AR","Noto Sans Mongolian","Noto Sans Syriac","Noto Sans TamilSupplement"],"text":"a\u4e2db","glyphs":[31,8748,32],"fonts":[0,2,0]},{"locale":"zh_cn","primaryFamily":"Minecraft Seven v2","families":["Minecraft Seven v2","Noto Sans","Noto Sans SC","Noto Sans TC","Noto Sans JP","Noto Sans KR","Noto Sans AR","Noto Sans Mongolian","Noto Sans Syriac","Noto Sans TamilSupplement"],"text":"a\u4e2db","glyphs":[31,8748,32],"fonts":[0,2,0]},{"locale":"en_US","primaryFamily":"Unavailable","families":["Unavailable","Noto Sans","Noto Sans SC","Noto Sans TC","Noto Sans JP","Noto Sans KR","Noto Sans AR","Noto Sans Mongolian","Noto Sans Syriac","Noto Sans TamilSupplement"],"text":"a\u4e2db","glyphs":[68,8748,69],"fonts":[1,2,1]}]} +diff --git a/src/test/resources/oreui/native-mixed-lines-2225.json b/src/test/resources/oreui/native-mixed-lines-2225.json +new file mode 100644 +index 0000000..f589d77 +--- /dev/null ++++ b/src/test/resources/oreui/native-mixed-lines-2225.json +@@ -0,0 +1 @@ ++{"nativeBuild":"1.26.60.5","protocol":2225,"nativeSha256":"9ad9a26235c016e55fe68800cb3d00843a83840f9683cc478a2a6079d59b4669","scope":"Complete original line builder, alignment and extent query execute with supplied scalar advances, font identities, per-scalar font map and per-font baseline/advance pairs. Bounded native allocation/free and CRT endpoints. Not font selection, preprocessing, CSS cascade, bidi, shaping or complete screen rendering.","cases":[{"fontMap":[0,1],"fontMetrics":[[20,24],[8,12]],"fontIdentities":[1,2],"extent":[30.0,24.0],"text":"AB","advances":[10,20],"width":100,"indent":0.0,"letterSpacing":0,"whitespace":0,"wordbreak":0,"alignment":0,"fill":false,"lines":[{"width":30.0,"height":24.0,"hardBreak":true,"fragments":[{"start":0,"end":1,"x":0.0,"baseline":20.0,"width":10.0,"height":24.0,"font":1},{"start":1,"end":2,"x":10.0,"baseline":8.0,"width":20.0,"height":12.0,"font":2}]}]},{"fontMap":[0,1],"fontMetrics":[[20,24],[8,12]],"fontIdentities":[1,2],"extent":[20.0,36.0],"text":"AB","advances":[10,20],"width":25,"indent":0.0,"letterSpacing":0,"whitespace":0,"wordbreak":0,"alignment":0,"fill":false,"lines":[{"width":10.0,"height":24.0,"hardBreak":false,"fragments":[{"start":0,"end":1,"x":0.0,"baseline":20.0,"width":10.0,"height":24.0,"font":1}]},{"width":20.0,"height":12.0,"hardBreak":true,"fragments":[{"start":1,"end":2,"x":0.0,"baseline":32.0,"width":20.0,"height":12.0,"font":2}]}]},{"fontMap":[0,1],"fontMetrics":[[20,24],[8,12]],"fontIdentities":[1,2],"extent":[20.0,36.0],"text":"AB","advances":[10,20],"width":15,"indent":0.0,"letterSpacing":0,"whitespace":0,"wordbreak":0,"alignment":0,"fill":false,"lines":[{"width":10.0,"height":24.0,"hardBreak":false,"fragments":[{"start":0,"end":1,"x":0.0,"baseline":20.0,"width":10.0,"height":24.0,"font":1}]},{"width":20.0,"height":12.0,"hardBreak":true,"fragments":[{"start":1,"end":2,"x":0.0,"baseline":32.0,"width":20.0,"height":12.0,"font":2}]}]},{"fontMap":[0,1,0,1],"fontMetrics":[[20,24],[8,12]],"fontIdentities":[1,2],"extent":[60.0,24.0],"text":"ABAB","advances":[10,20,10,20],"width":100,"indent":0.0,"letterSpacing":0,"whitespace":0,"wordbreak":0,"alignment":0,"fill":false,"lines":[{"width":60.0,"height":24.0,"hardBreak":true,"fragments":[{"start":0,"end":1,"x":0.0,"baseline":20.0,"width":10.0,"height":24.0,"font":1},{"start":1,"end":2,"x":10.0,"baseline":8.0,"width":20.0,"height":12.0,"font":2},{"start":2,"end":3,"x":30.0,"baseline":20.0,"width":10.0,"height":24.0,"font":1},{"start":3,"end":4,"x":40.0,"baseline":8.0,"width":20.0,"height":12.0,"font":2}]}]},{"fontMap":[0,0,1],"fontMetrics":[[20,24],[8,12]],"fontIdentities":[1,2],"extent":[20.0,36.0],"text":"A B","advances":[10,4,20],"width":25,"indent":0.0,"letterSpacing":0,"whitespace":0,"wordbreak":0,"alignment":0,"fill":false,"lines":[{"width":14.0,"height":24.0,"hardBreak":false,"fragments":[{"start":0,"end":2,"x":0.0,"baseline":20.0,"width":14.0,"height":24.0,"font":1}]},{"width":20.0,"height":12.0,"hardBreak":true,"fragments":[{"start":2,"end":3,"x":0.0,"baseline":32.0,"width":20.0,"height":12.0,"font":2}]}]},{"fontMap":[1,1],"fontMetrics":[[20,24],[8,12]],"fontIdentities":[1,2],"extent":[20.0,36.0],"text":"B\n","advances":[20,0],"width":100,"indent":0.0,"letterSpacing":0,"whitespace":0,"wordbreak":0,"alignment":0,"fill":false,"lines":[{"width":20.0,"height":12.0,"hardBreak":true,"fragments":[{"start":0,"end":2,"x":0.0,"baseline":8.0,"width":20.0,"height":12.0,"font":2}]},{"width":0.0,"height":24.0,"hardBreak":true,"fragments":[{"start":2,"end":2,"x":0.0,"baseline":32.0,"width":0.0,"height":24.0,"font":1}]}]},{"fontMap":[0,1],"fontMetrics":[[20,24],[8,12]],"fontIdentities":[1,2],"extent":[20.0,36.0],"text":"\nB","advances":[0,20],"width":100,"indent":0.0,"letterSpacing":0,"whitespace":0,"wordbreak":0,"alignment":0,"fill":false,"lines":[{"width":0.0,"height":24.0,"hardBreak":true,"fragments":[{"start":0,"end":1,"x":0.0,"baseline":20.0,"width":0.0,"height":24.0,"font":1}]},{"width":20.0,"height":12.0,"hardBreak":true,"fragments":[{"start":1,"end":2,"x":0.0,"baseline":32.0,"width":20.0,"height":12.0,"font":2}]}]},{"fontMap":[0,0,1,0,0],"fontMetrics":[[20,24],[8,12]],"fontIdentities":[1,2],"extent":[48.0,24.0],"text":"A B A","advances":[10,4,20,4,10],"width":100,"indent":0.0,"letterSpacing":0,"whitespace":0,"wordbreak":0,"alignment":0,"fill":false,"lines":[{"width":48.0,"height":24.0,"hardBreak":true,"fragments":[{"start":0,"end":2,"x":0.0,"baseline":20.0,"width":14.0,"height":24.0,"font":1},{"start":2,"end":3,"x":14.0,"baseline":8.0,"width":20.0,"height":12.0,"font":2},{"start":3,"end":5,"x":34.0,"baseline":20.0,"width":14.0,"height":24.0,"font":1}]}]},{"fontMap":[0,0,1,0,0],"fontMetrics":[[20,24],[8,12]],"fontIdentities":[1,2],"extent":[20.0,72.0],"text":"A B A","advances":[10,4,20,4,10],"width":25,"indent":0.0,"letterSpacing":0,"whitespace":0,"wordbreak":0,"alignment":0,"fill":false,"lines":[{"width":14.0,"height":24.0,"hardBreak":false,"fragments":[{"start":0,"end":2,"x":0.0,"baseline":20.0,"width":14.0,"height":24.0,"font":1}]},{"width":20.0,"height":24.0,"hardBreak":false,"fragments":[{"start":2,"end":3,"x":0.0,"baseline":32.0,"width":20.0,"height":12.0,"font":2},{"start":3,"end":4,"x":20.0,"baseline":44.0,"width":0.0,"height":24.0,"font":1}]},{"width":10.0,"height":24.0,"hardBreak":true,"fragments":[{"start":4,"end":5,"x":0.0,"baseline":68.0,"width":10.0,"height":24.0,"font":1}]}]},{"fontMap":[0,0,1,0,0],"fontMetrics":[[20.5,24.25],[7.25,12.5]],"fontIdentities":[1,2],"extent":[35.19999694824219,48.5],"text":"A B A","advances":[10,4,20,4,10],"width":42.25,"indent":0.0,"letterSpacing":0.4,"whitespace":0,"wordbreak":0,"alignment":0,"fill":true,"lines":[{"width":35.19999694824219,"height":24.25,"hardBreak":false,"fragments":[{"start":0,"end":2,"x":0.0,"baseline":20.5,"width":14.799999237060547,"height":24.25,"font":1},{"start":2,"end":3,"x":14.799999237060547,"baseline":7.25,"width":20.399999618530273,"height":12.5,"font":2},{"start":3,"end":4,"x":35.19999694824219,"baseline":20.5,"width":0.0,"height":24.25,"font":1}]},{"width":10.0,"height":24.25,"hardBreak":true,"fragments":[{"start":4,"end":5,"x":0.0,"baseline":44.75,"width":10.0,"height":24.25,"font":1}]}]},{"fontMap":[0,0,1,0,0],"fontMetrics":[[20.5,24.25],[7.25,12.5]],"fontIdentities":[1,2],"extent":[35.19999694824219,48.5],"text":"A B A","advances":[10,4,20,4,10],"width":42.25,"indent":0.0,"letterSpacing":0.4,"whitespace":0,"wordbreak":0,"alignment":1,"fill":true,"lines":[{"width":35.19999694824219,"height":24.25,"hardBreak":false,"fragments":[{"start":0,"end":2,"x":7.0500030517578125,"baseline":20.5,"width":14.799999237060547,"height":24.25,"font":1},{"start":2,"end":3,"x":21.85000228881836,"baseline":7.25,"width":20.399999618530273,"height":12.5,"font":2},{"start":3,"end":4,"x":42.25,"baseline":20.5,"width":0.0,"height":24.25,"font":1}]},{"width":10.0,"height":24.25,"hardBreak":true,"fragments":[{"start":4,"end":5,"x":32.25,"baseline":44.75,"width":10.0,"height":24.25,"font":1}]}]},{"fontMap":[0,0,1,0,0],"fontMetrics":[[20.5,24.25],[7.25,12.5]],"fontIdentities":[1,2],"extent":[35.19999694824219,48.5],"text":"A B A","advances":[10,4,20,4,10],"width":42.25,"indent":0.0,"letterSpacing":0.4,"whitespace":0,"wordbreak":0,"alignment":2,"fill":true,"lines":[{"width":35.19999694824219,"height":24.25,"hardBreak":false,"fragments":[{"start":0,"end":2,"x":3.5250015258789062,"baseline":20.5,"width":14.799999237060547,"height":24.25,"font":1},{"start":2,"end":3,"x":18.325000762939453,"baseline":7.25,"width":20.399999618530273,"height":12.5,"font":2},{"start":3,"end":4,"x":38.724998474121094,"baseline":20.5,"width":0.0,"height":24.25,"font":1}]},{"width":10.0,"height":24.25,"hardBreak":true,"fragments":[{"start":4,"end":5,"x":16.125,"baseline":44.75,"width":10.0,"height":24.25,"font":1}]}]},{"fontMap":[0,0,1,1,0],"fontMetrics":[[20,24],[8,12]],"fontIdentities":[1,2],"extent":[20.399999618530273,48.0],"text":"A B ","advances":[10,4,4,20,4],"width":18.5,"indent":0.0,"letterSpacing":0.4,"whitespace":0,"wordbreak":0,"alignment":0,"fill":false,"lines":[{"width":10.399999618530273,"height":24.0,"hardBreak":false,"fragments":[{"start":0,"end":2,"x":0.0,"baseline":20.0,"width":10.399999618530273,"height":24.0,"font":1},{"start":2,"end":3,"x":10.399999618530273,"baseline":8.0,"width":0.0,"height":12.0,"font":2}]},{"width":20.399999618530273,"height":24.0,"hardBreak":true,"fragments":[{"start":3,"end":4,"x":0.0,"baseline":32.0,"width":20.399999618530273,"height":12.0,"font":2},{"start":4,"end":5,"x":20.399999618530273,"baseline":44.0,"width":0.0,"height":24.0,"font":1}]}]},{"fontMap":[0,0,1,1,0],"fontMetrics":[[20,24],[8,12]],"fontIdentities":[1,2],"extent":[39.599998474121094,24.0],"text":"A B ","advances":[10,4,4,20,4],"width":18.5,"indent":0.0,"letterSpacing":0.4,"whitespace":1,"wordbreak":0,"alignment":0,"fill":false,"lines":[{"width":39.599998474121094,"height":24.0,"hardBreak":true,"fragments":[{"start":0,"end":2,"x":0.0,"baseline":20.0,"width":10.399999618530273,"height":24.0,"font":1},{"start":2,"end":4,"x":10.399999618530273,"baseline":8.0,"width":29.200000762939453,"height":12.0,"font":2},{"start":4,"end":5,"x":39.599998474121094,"baseline":20.0,"width":0.0,"height":24.0,"font":1}]}]},{"fontMap":[0,0,1,1,0],"fontMetrics":[[20,24],[8,12]],"fontIdentities":[1,2],"extent":[51.70000076293945,24.0],"text":"A B ","advances":[10,4,4,20,4],"width":18.5,"indent":0.0,"letterSpacing":0.4,"whitespace":2,"wordbreak":0,"alignment":0,"fill":false,"lines":[{"width":51.70000076293945,"height":24.0,"hardBreak":true,"fragments":[{"start":0,"end":2,"x":0.0,"baseline":20.0,"width":18.5,"height":24.0,"font":1},{"start":2,"end":4,"x":18.5,"baseline":8.0,"width":29.200000762939453,"height":12.0,"font":2},{"start":4,"end":5,"x":47.70000076293945,"baseline":20.0,"width":4.0,"height":24.0,"font":1}]}]},{"fontMap":[0,0,1,1,0],"fontMetrics":[[20,24],[8,12]],"fontIdentities":[1,2],"extent":[27.299999237060547,48.0],"text":"A B ","advances":[10,4,4,20,4],"width":18.5,"indent":0.0,"letterSpacing":0.4,"whitespace":3,"wordbreak":0,"alignment":0,"fill":false,"lines":[{"width":27.299999237060547,"height":24.0,"hardBreak":false,"fragments":[{"start":0,"end":2,"x":0.0,"baseline":20.0,"width":18.5,"height":24.0,"font":1},{"start":2,"end":3,"x":18.5,"baseline":8.0,"width":8.800000190734863,"height":12.0,"font":2}]},{"width":24.399999618530273,"height":24.0,"hardBreak":true,"fragments":[{"start":3,"end":4,"x":0.0,"baseline":32.0,"width":20.399999618530273,"height":12.0,"font":2},{"start":4,"end":5,"x":20.399999618530273,"baseline":44.0,"width":4.0,"height":24.0,"font":1}]}]},{"fontMap":[0,0,0,0,1,1,1,1],"fontMetrics":[[20,24],[8,12]],"fontIdentities":[1,2],"extent":[41.599998474121094,36.0],"text":"AAAABBBB","advances":[10,10,10,10,10,10,10,10],"width":25.5,"indent":0.0,"letterSpacing":0.4,"whitespace":0,"wordbreak":0,"alignment":0,"fill":false,"lines":[{"width":41.599998474121094,"height":24.0,"hardBreak":false,"fragments":[{"start":0,"end":4,"x":0.0,"baseline":20.0,"width":41.599998474121094,"height":24.0,"font":1}]},{"width":41.19999694824219,"height":12.0,"hardBreak":true,"fragments":[{"start":4,"end":8,"x":0.0,"baseline":32.0,"width":41.19999694824219,"height":12.0,"font":2}]}]},{"fontMap":[0,0,0,0,1,1,1,1],"fontMetrics":[[20,24],[8,12]],"fontIdentities":[1,2],"extent":[20.799999237060547,72.0],"text":"AAAABBBB","advances":[10,10,10,10,10,10,10,10],"width":25.5,"indent":0.0,"letterSpacing":0.4,"whitespace":0,"wordbreak":1,"alignment":0,"fill":false,"lines":[{"width":20.799999237060547,"height":24.0,"hardBreak":false,"fragments":[{"start":0,"end":2,"x":0.0,"baseline":20.0,"width":20.799999237060547,"height":24.0,"font":1}]},{"width":20.799999237060547,"height":24.0,"hardBreak":false,"fragments":[{"start":2,"end":4,"x":0.0,"baseline":44.0,"width":20.799999237060547,"height":24.0,"font":1}]},{"width":20.799999237060547,"height":12.0,"hardBreak":false,"fragments":[{"start":4,"end":6,"x":0.0,"baseline":56.0,"width":20.799999237060547,"height":12.0,"font":2}]},{"width":20.399999618530273,"height":12.0,"hardBreak":true,"fragments":[{"start":6,"end":8,"x":0.0,"baseline":68.0,"width":20.399999618530273,"height":12.0,"font":2}]}]},{"fontMap":[0,0,0,0,1,1,1,1],"fontMetrics":[[20,24],[8,12]],"fontIdentities":[1,2],"extent":[20.799999237060547,72.0],"text":"AAAABBBB","advances":[10,10,10,10,10,10,10,10],"width":25.5,"indent":0.0,"letterSpacing":0.4,"whitespace":0,"wordbreak":2,"alignment":0,"fill":false,"lines":[{"width":20.799999237060547,"height":24.0,"hardBreak":false,"fragments":[{"start":0,"end":2,"x":0.0,"baseline":20.0,"width":20.799999237060547,"height":24.0,"font":1}]},{"width":20.799999237060547,"height":24.0,"hardBreak":false,"fragments":[{"start":2,"end":4,"x":0.0,"baseline":44.0,"width":20.799999237060547,"height":24.0,"font":1}]},{"width":20.799999237060547,"height":12.0,"hardBreak":false,"fragments":[{"start":4,"end":6,"x":0.0,"baseline":56.0,"width":20.799999237060547,"height":12.0,"font":2}]},{"width":20.399999618530273,"height":12.0,"hardBreak":true,"fragments":[{"start":6,"end":8,"x":0.0,"baseline":68.0,"width":20.399999618530273,"height":12.0,"font":2}]}]},{"fontMap":[0,1,1,0],"fontMetrics":[[20,24],[8,12]],"fontIdentities":[7,7],"extent":[61.19999694824219,24.0],"text":"ABBA","advances":[10,20,20,10],"width":320.0,"indent":0.0,"letterSpacing":0.4,"whitespace":1,"wordbreak":0,"alignment":0,"fill":false,"lines":[{"width":61.19999694824219,"height":24.0,"hardBreak":true,"fragments":[{"start":0,"end":4,"x":0.0,"baseline":20.0,"width":61.19999694824219,"height":24.0,"font":7}]}]},{"fontMap":[0,0,1],"fontMetrics":[[20,24],[8,12]],"fontIdentities":[1,2],"extent":[20.0,36.0],"text":"A\nB","advances":[10,0,20],"width":9,"indent":15,"letterSpacing":0.4,"whitespace":0,"wordbreak":0,"alignment":0,"fill":false,"lines":[{"width":0.0,"height":0.0,"hardBreak":false,"fragments":[]},{"width":10.399999618530273,"height":24.0,"hardBreak":true,"fragments":[{"start":0,"end":2,"x":0.0,"baseline":20.0,"width":10.399999618530273,"height":24.0,"font":1}]},{"width":0.0,"height":0.0,"hardBreak":false,"fragments":[]},{"width":20.0,"height":12.0,"hardBreak":true,"fragments":[{"start":2,"end":3,"x":0.0,"baseline":32.0,"width":20.0,"height":12.0,"font":2}]}]},{"fontMap":[],"fontMetrics":[[0,0],[8,12]],"fontIdentities":[0,2],"extent":[0.0,12.0],"text":"","advances":[],"width":320.0,"indent":0.0,"letterSpacing":0.4,"whitespace":1,"wordbreak":0,"alignment":0,"fill":false,"lines":[{"width":0.0,"height":12.0,"hardBreak":true,"fragments":[{"start":0,"end":0,"x":0.0,"baseline":8.0,"width":0.0,"height":12.0,"font":2},{"start":0,"end":0,"x":0.0,"baseline":8.0,"width":0.0,"height":12.0,"font":2}]}]},{"fontMap":[1,1],"fontMetrics":[[0,0],[8,12]],"fontIdentities":[0,2],"extent":[20.399999618530273,12.0],"text":"B\n","advances":[20,0],"width":320.0,"indent":0.0,"letterSpacing":0.4,"whitespace":1,"wordbreak":0,"alignment":0,"fill":false,"lines":[{"width":20.399999618530273,"height":12.0,"hardBreak":true,"fragments":[{"start":0,"end":2,"x":0.0,"baseline":8.0,"width":20.399999618530273,"height":12.0,"font":2}]},{"width":0.0,"height":0.0,"hardBreak":true,"fragments":[{"start":2,"end":2,"x":0.0,"baseline":12.0,"width":0.0,"height":0.0,"font":0}]}]},{"fontMap":[0,1],"fontMetrics":[[0,0],[8,12]],"fontIdentities":[0,2],"extent":[20.399999618530273,12.0],"text":"AB","advances":[0,20],"width":320.0,"indent":0.0,"letterSpacing":0.4,"whitespace":1,"wordbreak":0,"alignment":0,"fill":false,"lines":[{"width":20.399999618530273,"height":12.0,"hardBreak":true,"fragments":[{"start":0,"end":1,"x":0.0,"baseline":0.0,"width":0.4000000059604645,"height":0.0,"font":0},{"start":1,"end":2,"x":0.4000000059604645,"baseline":8.0,"width":20.0,"height":12.0,"font":2}]}]}]} +diff --git a/src/test/resources/oreui/native-trailing-reserve-2225.json b/src/test/resources/oreui/native-trailing-reserve-2225.json +new file mode 100644 +index 0000000..9d44458 +--- /dev/null ++++ b/src/test/resources/oreui/native-trailing-reserve-2225.json +@@ -0,0 +1 @@ ++{"nativeBuild":"1.26.60.5","protocol":2225,"nativeSha256":"9ad9a26235c016e55fe68800cb3d00843a83840f9683cc478a2a6079d59b4669","scope":"Original complete line builder, original trailing reserve width adjustment 0x18003c4cc, original alignment and extent. Supplied advances, metrics, font slots and style flags. No CSS/cache/fitting selection or screen rendering.","cases":[{"trailingAdvance":4,"fontMap":[0],"fontMetrics":[[20.0,24.0]],"fontIdentities":[1],"extent":[14.0,24.0],"text":"A","advances":[10],"width":20,"indent":0,"letterSpacing":0,"whitespace":0,"wordbreak":0,"alignment":0,"fill":true,"lines":[{"width":14.0,"height":24.0,"hardBreak":true,"fragments":[{"start":0,"end":1,"x":0.0,"baseline":20.0,"width":10.0,"height":24.0,"font":1}]}]},{"trailingAdvance":4,"fontMap":[0],"fontMetrics":[[20.0,24.0]],"fontIdentities":[1],"extent":[14.0,24.0],"text":"A","advances":[10],"width":20,"indent":0,"letterSpacing":0,"whitespace":0,"wordbreak":0,"alignment":2,"fill":true,"lines":[{"width":14.0,"height":24.0,"hardBreak":true,"fragments":[{"start":0,"end":1,"x":0.0,"baseline":20.0,"width":10.0,"height":24.0,"font":1}]}]},{"trailingAdvance":4,"fontMap":[0],"fontMetrics":[[20.0,24.0]],"fontIdentities":[1],"extent":[10.0,24.0],"text":"A","advances":[10],"width":10,"indent":0,"letterSpacing":0,"whitespace":0,"wordbreak":0,"alignment":0,"fill":true,"lines":[{"width":10.0,"height":24.0,"hardBreak":true,"fragments":[{"start":0,"end":1,"x":0.0,"baseline":20.0,"width":10.0,"height":24.0,"font":1}]}]},{"trailingAdvance":4,"fontMap":[0],"fontMetrics":[[20.0,24.0]],"fontIdentities":[1],"extent":[10.0,24.0],"text":"A","advances":[10],"width":10,"indent":0,"letterSpacing":0,"whitespace":0,"wordbreak":0,"alignment":2,"fill":true,"lines":[{"width":10.0,"height":24.0,"hardBreak":true,"fragments":[{"start":0,"end":1,"x":0.0,"baseline":20.0,"width":10.0,"height":24.0,"font":1}]}]},{"trailingAdvance":4,"fontMap":[0],"fontMetrics":[[20.0,24.0]],"fontIdentities":[1],"extent":[12.0,24.0],"text":"A","advances":[10],"width":12,"indent":0,"letterSpacing":0,"whitespace":0,"wordbreak":0,"alignment":0,"fill":true,"lines":[{"width":12.0,"height":24.0,"hardBreak":true,"fragments":[{"start":0,"end":1,"x":0.0,"baseline":20.0,"width":10.0,"height":24.0,"font":1}]}]},{"trailingAdvance":4,"fontMap":[0],"fontMetrics":[[20.0,24.0]],"fontIdentities":[1],"extent":[12.0,24.0],"text":"A","advances":[10],"width":12,"indent":0,"letterSpacing":0,"whitespace":0,"wordbreak":0,"alignment":2,"fill":true,"lines":[{"width":12.0,"height":24.0,"hardBreak":true,"fragments":[{"start":0,"end":1,"x":0.0,"baseline":20.0,"width":10.0,"height":24.0,"font":1}]}]},{"trailingAdvance":4,"fontMap":[0,0,0],"fontMetrics":[[20.0,24.0]],"fontIdentities":[1],"extent":[14.0,48.0],"text":"A A","advances":[10,4,10],"width":20,"indent":0,"letterSpacing":0,"whitespace":0,"wordbreak":0,"alignment":0,"fill":true,"lines":[{"width":14.0,"height":24.0,"hardBreak":false,"fragments":[{"start":0,"end":2,"x":0.0,"baseline":20.0,"width":10.0,"height":24.0,"font":1}]},{"width":14.0,"height":24.0,"hardBreak":true,"fragments":[{"start":2,"end":3,"x":0.0,"baseline":44.0,"width":10.0,"height":24.0,"font":1}]}]},{"trailingAdvance":4,"fontMap":[0,0,0],"fontMetrics":[[20.0,24.0]],"fontIdentities":[1],"extent":[14.0,48.0],"text":"A A","advances":[10,4,10],"width":20,"indent":0,"letterSpacing":0,"whitespace":0,"wordbreak":0,"alignment":2,"fill":true,"lines":[{"width":14.0,"height":24.0,"hardBreak":false,"fragments":[{"start":0,"end":2,"x":3.0,"baseline":20.0,"width":10.0,"height":24.0,"font":1}]},{"width":14.0,"height":24.0,"hardBreak":true,"fragments":[{"start":2,"end":3,"x":3.0,"baseline":44.0,"width":10.0,"height":24.0,"font":1}]}]},{"trailingAdvance":4,"fontMap":[0,0,0],"fontMetrics":[[20.0,24.0]],"fontIdentities":[1],"extent":[14.0,48.0],"text":"A A","advances":[10,4,10],"width":20,"indent":7,"letterSpacing":0,"whitespace":0,"wordbreak":0,"alignment":0,"fill":true,"lines":[{"width":13.0,"height":24.0,"hardBreak":false,"fragments":[{"start":0,"end":2,"x":0.0,"baseline":20.0,"width":10.0,"height":24.0,"font":1}]},{"width":14.0,"height":24.0,"hardBreak":true,"fragments":[{"start":2,"end":3,"x":0.0,"baseline":44.0,"width":10.0,"height":24.0,"font":1}]}]},{"trailingAdvance":4,"fontMap":[0,0,0],"fontMetrics":[[20.0,24.0]],"fontIdentities":[1],"extent":[14.0,48.0],"text":"A A","advances":[10,4,10],"width":20,"indent":7,"letterSpacing":0,"whitespace":0,"wordbreak":0,"alignment":2,"fill":true,"lines":[{"width":13.0,"height":24.0,"hardBreak":false,"fragments":[{"start":0,"end":2,"x":3.5,"baseline":20.0,"width":10.0,"height":24.0,"font":1}]},{"width":14.0,"height":24.0,"hardBreak":true,"fragments":[{"start":2,"end":3,"x":3.0,"baseline":44.0,"width":10.0,"height":24.0,"font":1}]}]},{"trailingAdvance":-4,"fontMap":[0],"fontMetrics":[[20.0,24.0]],"fontIdentities":[1],"extent":[6.0,24.0],"text":"A","advances":[10],"width":20,"indent":0,"letterSpacing":0,"whitespace":0,"wordbreak":0,"alignment":0,"fill":true,"lines":[{"width":6.0,"height":24.0,"hardBreak":true,"fragments":[{"start":0,"end":1,"x":0.0,"baseline":20.0,"width":10.0,"height":24.0,"font":1}]}]},{"trailingAdvance":-4,"fontMap":[0],"fontMetrics":[[20.0,24.0]],"fontIdentities":[1],"extent":[6.0,24.0],"text":"A","advances":[10],"width":20,"indent":0,"letterSpacing":0,"whitespace":0,"wordbreak":0,"alignment":2,"fill":true,"lines":[{"width":6.0,"height":24.0,"hardBreak":true,"fragments":[{"start":0,"end":1,"x":0.0,"baseline":20.0,"width":10.0,"height":24.0,"font":1}]}]},{"trailingAdvance":-4,"fontMap":[0],"fontMetrics":[[20.0,24.0]],"fontIdentities":[1],"extent":[-6.0,24.0],"text":"A","advances":[-2],"width":0,"indent":0,"letterSpacing":0,"whitespace":0,"wordbreak":0,"alignment":0,"fill":true,"lines":[{"width":-6.0,"height":24.0,"hardBreak":true,"fragments":[{"start":0,"end":1,"x":0.0,"baseline":20.0,"width":-2.0,"height":24.0,"font":1}]}]},{"trailingAdvance":-4,"fontMap":[0],"fontMetrics":[[20.0,24.0]],"fontIdentities":[1],"extent":[-6.0,24.0],"text":"A","advances":[-2],"width":0,"indent":0,"letterSpacing":0,"whitespace":0,"wordbreak":0,"alignment":2,"fill":true,"lines":[{"width":-6.0,"height":24.0,"hardBreak":true,"fragments":[{"start":0,"end":1,"x":0.0,"baseline":20.0,"width":-2.0,"height":24.0,"font":1}]}]},{"trailingAdvance":4,"fontMap":[],"fontMetrics":[[20.0,24.0]],"fontIdentities":[1],"extent":[4.0,24.0],"text":"","advances":[],"width":20,"indent":0,"letterSpacing":0,"whitespace":0,"wordbreak":0,"alignment":0,"fill":true,"lines":[{"width":4.0,"height":24.0,"hardBreak":true,"fragments":[{"start":0,"end":0,"x":0.0,"baseline":20.0,"width":0.0,"height":24.0,"font":1}]}]},{"trailingAdvance":4,"fontMap":[],"fontMetrics":[[20.0,24.0]],"fontIdentities":[1],"extent":[4.0,24.0],"text":"","advances":[],"width":20,"indent":0,"letterSpacing":0,"whitespace":0,"wordbreak":0,"alignment":2,"fill":true,"lines":[{"width":4.0,"height":24.0,"hardBreak":true,"fragments":[{"start":0,"end":0,"x":0.0,"baseline":20.0,"width":0.0,"height":24.0,"font":1}]}]},{"trailingAdvance":0.0001,"fontMap":[0],"fontMetrics":[[20.0,24.0]],"fontIdentities":[1],"extent":[10.0,24.0],"text":"A","advances":[10],"width":20,"indent":0,"letterSpacing":0,"whitespace":0,"wordbreak":0,"alignment":0,"fill":true,"lines":[{"width":10.0,"height":24.0,"hardBreak":true,"fragments":[{"start":0,"end":1,"x":0.0,"baseline":20.0,"width":10.0,"height":24.0,"font":1}]}]},{"trailingAdvance":0.0001,"fontMap":[0],"fontMetrics":[[20.0,24.0]],"fontIdentities":[1],"extent":[10.0,24.0],"text":"A","advances":[10],"width":20,"indent":0,"letterSpacing":0,"whitespace":0,"wordbreak":0,"alignment":2,"fill":true,"lines":[{"width":10.0,"height":24.0,"hardBreak":true,"fragments":[{"start":0,"end":1,"x":0.0,"baseline":20.0,"width":10.0,"height":24.0,"font":1}]}]},{"trailingAdvance":0.001,"fontMap":[0],"fontMetrics":[[20.0,24.0]],"fontIdentities":[1],"extent":[10.0,24.0],"text":"A","advances":[10],"width":20,"indent":0,"letterSpacing":0,"whitespace":0,"wordbreak":0,"alignment":0,"fill":true,"lines":[{"width":10.0,"height":24.0,"hardBreak":true,"fragments":[{"start":0,"end":1,"x":0.0,"baseline":20.0,"width":10.0,"height":24.0,"font":1}]}]},{"trailingAdvance":0.001,"fontMap":[0],"fontMetrics":[[20.0,24.0]],"fontIdentities":[1],"extent":[10.0,24.0],"text":"A","advances":[10],"width":20,"indent":0,"letterSpacing":0,"whitespace":0,"wordbreak":0,"alignment":2,"fill":true,"lines":[{"width":10.0,"height":24.0,"hardBreak":true,"fragments":[{"start":0,"end":1,"x":0.0,"baseline":20.0,"width":10.0,"height":24.0,"font":1}]}]},{"trailingAdvance":0.001001,"fontMap":[0],"fontMetrics":[[20.0,24.0]],"fontIdentities":[1],"extent":[10.001001358032227,24.0],"text":"A","advances":[10],"width":20,"indent":0,"letterSpacing":0,"whitespace":0,"wordbreak":0,"alignment":0,"fill":true,"lines":[{"width":10.001001358032227,"height":24.0,"hardBreak":true,"fragments":[{"start":0,"end":1,"x":0.0,"baseline":20.0,"width":10.0,"height":24.0,"font":1}]}]},{"trailingAdvance":0.001001,"fontMap":[0],"fontMetrics":[[20.0,24.0]],"fontIdentities":[1],"extent":[10.001001358032227,24.0],"text":"A","advances":[10],"width":20,"indent":0,"letterSpacing":0,"whitespace":0,"wordbreak":0,"alignment":2,"fill":true,"lines":[{"width":10.001001358032227,"height":24.0,"hardBreak":true,"fragments":[{"start":0,"end":1,"x":0.0,"baseline":20.0,"width":10.0,"height":24.0,"font":1}]}]},{"trailingAdvance":4,"fontMap":[0,0,0],"fontMetrics":[[20.0,24.0]],"fontIdentities":[1],"extent":[27.0,24.0],"text":"A A","advances":[10,4,10],"width":20,"indent":-7,"letterSpacing":0,"whitespace":0,"wordbreak":0,"alignment":0,"fill":true,"lines":[{"width":27.0,"height":24.0,"hardBreak":true,"fragments":[{"start":0,"end":3,"x":0.0,"baseline":20.0,"width":24.0,"height":24.0,"font":1}]}]},{"trailingAdvance":4,"fontMap":[0,0,0],"fontMetrics":[[20.0,24.0]],"fontIdentities":[1],"extent":[27.0,24.0],"text":"A A","advances":[10,4,10],"width":20,"indent":-7,"letterSpacing":0,"whitespace":0,"wordbreak":0,"alignment":2,"fill":true,"lines":[{"width":27.0,"height":24.0,"hardBreak":true,"fragments":[{"start":0,"end":3,"x":0.0,"baseline":20.0,"width":24.0,"height":24.0,"font":1}]}]},{"trailingAdvance":4,"fontMap":[0,1,0],"fontMetrics":[[20,24],[8,12]],"fontIdentities":[1,2],"extent":[28.799999237060547,24.0],"text":"A B","advances":[10,4,10],"width":30,"indent":0.0,"letterSpacing":0.4,"whitespace":0,"wordbreak":0,"alignment":0,"fill":true,"lines":[{"width":28.799999237060547,"height":24.0,"hardBreak":true,"fragments":[{"start":0,"end":1,"x":0.0,"baseline":20.0,"width":10.399999618530273,"height":24.0,"font":1},{"start":1,"end":2,"x":10.399999618530273,"baseline":8.0,"width":4.400000095367432,"height":12.0,"font":2},{"start":2,"end":3,"x":14.799999237060547,"baseline":20.0,"width":10.0,"height":24.0,"font":1}]}]},{"trailingAdvance":4,"fontMap":[0,1,0],"fontMetrics":[[20,24],[8,12]],"fontIdentities":[1,2],"extent":[28.799999237060547,24.0],"text":"A B","advances":[10,4,10],"width":30,"indent":0.0,"letterSpacing":0.4,"whitespace":0,"wordbreak":0,"alignment":2,"fill":true,"lines":[{"width":28.799999237060547,"height":24.0,"hardBreak":true,"fragments":[{"start":0,"end":1,"x":0.0,"baseline":20.0,"width":10.399999618530273,"height":24.0,"font":1},{"start":1,"end":2,"x":10.399999618530273,"baseline":8.0,"width":4.400000095367432,"height":12.0,"font":2},{"start":2,"end":3,"x":14.799999237060547,"baseline":20.0,"width":10.0,"height":24.0,"font":1}]}]}]} +diff --git a/src/test/resources/oreui/nominal-advances.ttf b/src/test/resources/oreui/nominal-advances.ttf +new file mode 100644 +index 0000000000000000000000000000000000000000..20714762c1fc78db3e3ef9bd7e63e55f755e6db2 +GIT binary patch +literal 548 +zcmZ{gu}T9$5Qe|GyCi1_8XF4>3Bkf5g&>GUDh=9L2rAggl8ZUzLIMVfU3zOf3k6?5 +zu*?Ja0)mCuXklR)OI?}Gg{1LchMk#zcIMwffEoJu6nFLyHy^T(05`y1y=6LF_-DWu +zP;DlcCz-3xMPTATL0^ +zWqKX5WI;ZFQm1|1#n*Z-&=}R0XNd|@+ul7OMDtkd7)ef>hEUr(3mhwism&tk2@t%r +z%6E5xfK?zQ#B;NZFV;>x`)+T4^!*sllc6_Ig(4y#L-;K?y@ON*L`0JcBCxtk=cNjG +z?!R$7SN#V6bKHI!Zi;GCWeNemWH-P&a^Y}F*9d_!BNn8RX6GsEwq3PuXY`wK*9qLt +G|HmK8a6Ov< + +literal 0 +HcmV?d00001 + +diff --git a/src/test/resources/oreui/ordinary-advances.ttf b/src/test/resources/oreui/ordinary-advances.ttf +new file mode 100644 +index 0000000000000000000000000000000000000000..eb50f009a95acc67ba918859aeceaac90bed480b +GIT binary patch +literal 668 +zcmZ{iKT88a5XFD9cZo*`{zF7WB#4b5CmYJx$7C|KBA+gZfk +zMzIX|75o5pTKEC95K! +zAOk>YyJqVgdwU=ukgrrv%c+a5VUQ+Jh<0pfyB4oOUVzys+OerYxdbx;j7PP`N$Lr} +z%mcmEgKf(UV_-_^qGnI(q)CI>1akF*qXwRnM{*VQG1d0AJZSc8t{Q;XZ-d +zb&u~@AwbB|k1)hIf?(WRSCDqGX8fOQvOm+gPj$CqJM__B-M{LpsLHD7xo;MqX&@lL +zxHDN#;<&wvpX26d;w*vgM&3+I|AdH$M(~x@ylMTog^;jaK?w1c6VOQ|4w!h%d~EgP +z-@$uL#Q(!}QE%!@A>ifWIWQY8d`sN?fv-%$g6Pdr^f9JWb=5ircZOfpHr#AG;~QE8 +BMU?;m + +literal 0 +HcmV?d00001 + -- 2.56.0 diff --git a/patches/viafabricplus-bedrock/upstreamable/0029-share-native-ui-scene-rendering.pr.md b/patches/viafabricplus-bedrock/upstreamable/0029-share-native-ui-scene-rendering.pr.md index 06b1919a..791c3a96 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0029-share-native-ui-scene-rendering.pr.md +++ b/patches/viafabricplus-bedrock/upstreamable/0029-share-native-ui-scene-rendering.pr.md @@ -10,6 +10,95 @@ GUI camera overrides retain their existing yaw values. World player queries use Share authored scene, layout, image, text, actor, and focus primitives. Preserve same-pack bitmap metadata, legacy layouts, native atlas sampling, and GUI raster coverage. +Host pointer conversion uses the actual rounded Java GUI width and height separately from drawing scale. Click, release, movement, drag, scroll, and extraction retain the same native coordinate space. A real authored-action regression covers edge clicks on both rounded axes and a HiDPI host. Complete native input-device and viewport parity remains open. + +Installed direct and ViaProxy controls using the rebuilt tracked stack target the last inside column of the authored inventory close button at 1276 × 720 and Java GUI scale 3. Both routes close the menu and send a Bedrock close request. The pre-fix direct control with the private consumer falls back to Java controls without closing. These runs use a declared server inventory-open acknowledgment and read-only GUI entry observations. Early-join admission, native server admission, and complete input parity remain unverified. + +OreUI text retains its own font engine, shaping size, cache images, distance fields, atlas, and float tint pipeline. +It remains separate from classic JSON UI typography. +Default preparation follows the target Windows host's ordinary outline-font settings: bitmap below 10 display pixels or for a face that opts out, otherwise the 52-pixel CPU distance cache. +Sixteen original mode/size controls establish this selection. Eight executions of the shipped GUI-scale component establish five root pixels per scale unit. +Body text therefore crosses the default threshold from 8 to 16 pixels at GUI scales 1 and 2. +The image regression covers the threshold, face opt-out, independent shaping size, and distance-cache reuse. +Installed automatic and explicitly resolved modes produce identical pixels in seven regions under both filters, including the 8- and 10-pixel probes. +Active Bedrock 1.26.60.5 death labels confirm automatic mode and the 52-pixel CPU distance cache at display sizes 16, 20, and 32. +Retained native records match Java bearings and image dimensions for all 76 compared glyph instances. +Seventy-four positions match bit for bit. Two reason-glyph instances differ by one float rounding step. +Thirteen distinct Ten/Seven button-glyph distance images match the original native parser, rasterizer, and distance converter byte for byte, including row padding. +For Respawn and Game menu, all 176 coordinate components of 88 native vertex-upload records match the current Java quad calculation at captured native origins. +Coverage values and native UV extents also match. These supplied origins do not establish complete consumer layout. +The acquired Noto Sans SC ideograph also matches original grayscale and 52-pixel CPU distance images byte for byte. +This comparison covers glyph images, not complete CJK screen pixels. +Three native redraws bind the glyph atlas to texture slot 2 of the Standard material. +The retained resource is a 1024 × 1024 R8 texture. Its captured sampler translates to host filter key `0x15`, consistent with [linear filtering](https://learn.microsoft.com/en-us/windows/win32/api/d3d12/ne-d3d12-d3d12_filter). +Actual atlas texels, addressing, and final GPU compositing remain unverified. +Original Bedrock 1.26.60.5 controls establish selected shaping, raster, distance, ordinary run coverage, and supplied-position arithmetic. +Prepared runs retain vertical face metrics independently of the glyph cache size. +Native line boxes preserve negative leading and fractional content origins. +Content boxes apply borders and padding before optional node-selected snapping. +The Java content bounds match all 20 original native controls bit for bit. Six policy controls establish the supplied style/global flag combinations. +The active native death view enables snapping for the title, reason, Respawn label, and Game menu label. +The live paint wrapper centers each intrinsic text box before snapping its origin, then adds the cached baseline offset. +At 1280 × 720, the button label boxes start 10 and 12 pixels below their respective button tops. +Complete CSS cascade and other active view flags remain unverified. +The Java calculation matches all 72 original native font-span and baseline controls bit for bit. +Eight style-height controls and 14 rectangle/fragment controls establish separate height resolution and placement boundaries. +Regressions cover explicit and normal line heights, negative half ties, real font metrics, atlas lifetimes, and Minecraft vertex emission. + +`OreUiCharacters` retains source UTF-16 offsets through native CR/LF policies and normal/nowrap whitespace collapse. +It keeps nonbreaking Unicode spaces and preserves whitespace in preformatted modes. +The trailing-space fitting reserve stays separate from displayed characters. +Target-version native controls match scalar arrays, offsets, and float reserves in 195 cases. +Regressions also map a wrapped line back to its original source. Java rejects unpaired UTF-16 surrogates before UTF-8 encoding. +These controls supply the newline and CSS whitespace policies. They do not establish policy selection, invalid native UTF-8 handling, or complete screen integration. + +Ordinary text measurement keeps a separate native feature profile from drawing. +It selects nominal advances, space-excluding legacy kerning, or GPOS from the face and character count. +Original 1.26.60.5 dispatch, feature records, and FreeType/HarfBuzz controls establish the tested paths. +Authored regressions preserve per-scalar advances and drawing substitutions across measurement calls. +Installed Ten advances match original controls for `fi`, `office`, and `Respawn`. +Ten selected draw regions retain identical pixels across measurement and mode return. +Ordinary font assignment fills unresolved scalar ranges in the caller's ordered face list and preserves their measurement context. +Eligible interior whitespace uses its neighbors' shared font while retaining its advance and omitting its draw glyph. +Eighteen original constructor controls cover ownership and advances through the line-builder boundary, with supplied font interfaces. +A separate target-native parser control and a packaged-engine regression cover the authored disjoint-coverage fallback face. +Regular gameplay faces follow the shipped locale-dependent fallback order after the caller's primary family. +Thirteen selector, original font-parser, and assignment controls match packaged-engine glyph ownership while preserving primary Latin glyphs. +The API accepts native-case locale identifiers and resolves registered 400-weight normal faces, including unavailable slots. +Complete CSS family matching, weight/style substitution, complex-script/bidi dispatch, and final multi-font drawing remain unverified. +Ordinary lines consume measured advances and preserve nonfinal tracking, whitespace ranges, explicit endings, soft wraps, and float alignment. +Font identity separates fragments and can cause a wrap within a word. Alias slots that share an identity keep one fragment. +Each fragment retains its own baseline. Row height uses the greatest fragment advance, including zero-width whitespace fragments. +Twenty-two single-font construction/alignment controls and 37 mixed-font controls establish the compared native geometry. +One hundred additional mixed-font sequences and 100 retained single-font sequences match Java fragment records, row geometry, and extents bit for bit. +The controls supply the font map, metrics, advances, and style flags; original layout helpers execute. +Regressions retain 24 mixed-font boundary controls and connect packaged-engine fallback coverage and per-face metrics to line construction. +Collapsed trailing whitespace can reserve row width before alignment without changing fragment advances. +Twenty-six original adjustment controls establish full, partial and rejected reserves, first-row indentation, negative values, and the native float tolerance. +These controls cover geometry. Measurement-cache ownership and fitting policy selection remain unverified. +Installed single-font paragraph ranges and placement match original native results at three supplied widths. Eleven selected draw regions remain unchanged across preparation modes and return. +The complete measurement wrapper/cache, CSS text policies, bidi, ellipsis, parent CSS sizing, and complete mixed-font screen rendering remain unverified. + +The full tracked add-on stack passes, including software font GPU checks. +An installed software-rendered Minecraft 26.3 diagnostic submits four labels and a `drawLine` probe through the production font engine, atlas, GUI extraction, and shaders. +Four executions of the shipped death and button components establish shadows on the title, reason, and enabled hero label. The disabled hero and secondary label omit them. +Five complete original zero-blur shadow controls establish reverse paint order, offset arithmetic, inherited color, and parent-color restoration. The shadow paint flag's downstream interpretation remains unverified. +The diagnostic applies the shipped black shadows through the unchanged font backend. Colored and blurred shadows remain outside this comparison. +Bitmap and distance-field modes each run with nearest and linear filters at 1280 × 720. +All five regions remain unchanged after returning to the original mode. Installed artifact hashes match the tested build. +The eight nearest-filtered label crops differ from the CPU raster reference by at most one color byte. +Linear-filtered crops differ from that idealized reference by up to 13 color bytes. Exact native sampling remains unverified. +The diagnostic uses production measurement and line construction. It supplies outer boxes, insets, spacing, colors, font modes, and node snap choices. +The diagnostic centers intrinsic text boxes before node snapping, matching the observed native order. +Fixed comparisons with the saved native frame give 98.5% to 100% foreground-mask overlap across four label regions at three thresholds each, without position fitting. +Final sampling, colors, complete button styling, and complete screen pixels remain unverified. Complete OreUI screen consumers, CSS layout and fallback, reloads, and input remain unfinished. + +The acquired OreUI bundle also registers the original loading GIF and retains all previous font, stylesheet, and translation bytes. +The decoder preserves frame durations, transparency, and disposal within a 16 MiB decoded-frame limit. +Five regressions cover image ownership, invalid registrations, decoded limits, frame disposal, and the acquired spinner. +The original spinner has ten 28 × 28 frames at 100ms each. Independent ImageIO and Pillow decodes agree on all ten frames. +These checks establish software decoding. Installed animation sampling and the complete death-screen consumer remain unfinished. + Prerequisites: - viabedrock/0095-share-native-ui-programs-and-resources.patch: Publish shared UI scene and program contracts before the client renderer. @@ -175,3 +264,8 @@ The full four-project build passes. These controls do not establish complete nat Cursor placement uses the resolved native anchor after grid layout and scrolling. Seventeen original Bedrock 1.26.60.5 controls establish local offsets, centering precedence, edge flips and zero-size hiding. Layout regressions cover actual grid-parent bounds, viewport clipping and unchanged authored offsets. Active tooltip sources, input actions and live pixels remain open. Factory creation IDs separate replacement clocks from binding refreshes. Completed named destruction queues the affected factory roots for host retirement. Ten original 1.26.60.5 instruction controls establish exact current-control and ancestor target lookup, expired-parent termination, and completion gating. Regressions cover replacement clocks, late retirement, and unrelated sibling preservation. These controls stop before native hierarchy removal and do not establish rendered parity. + +The shared View hold timer follows the native result flags, binary32 frame subtraction, and strict negative expiry. +Its event retains the initial pointer and the native per-axis displacement gate. +Cooking regressions cover the timer through [retained quantity selection](0029-present-authored-native-containers.pr.md). +Controller admission, quantity timing, and completion remain owned by that feature. diff --git a/patches/viafabricplus-bedrock/upstreamable/0030-use-supported-native-world-actor-definitions.patch b/patches/viafabricplus-bedrock/upstreamable/0030-use-supported-native-world-actor-definitions.patch index 406a2f19..d4031216 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0030-use-supported-native-world-actor-definitions.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0030-use-supported-native-world-actor-definitions.patch @@ -1,4 +1,4 @@ -From 318776b45c6ef11153a3f4775f00b4fb1df5c53f Mon Sep 17 00:00:00 2001 +From 2e0c3432f62de738e7ae9fca05391fed4ce666db 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(render): use supported native world actor definitions @@ -210,10 +210,10 @@ Reject absent or retired physical bounds instead of inventing dimensions. create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockWorldActorQueryTest.java diff --git a/build.gradle.kts b/build.gradle.kts -index 2e80f04..7c55446 100644 +index a2642cd..45f38a9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts -@@ -22,6 +22,8 @@ tasks.withType().configureEach { +@@ -31,6 +31,8 @@ if (bedrockOreUiShaper.isPresent) { } dependencies { @@ -239,10 +239,10 @@ index e1bd73b..ef45d56 100644 val stackAnvilMavenRepo = providers.gradleProperty("stackanvilMavenRepo") if (stackAnvilMavenRepo.isPresent) { diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java b/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java -index 2abeb28..b345de2 100644 +index 7192ff9..85d93cd 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java -@@ -123,6 +123,7 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { +@@ -124,6 +124,7 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { @Override public void onPreSettingsLoading() { @@ -636,7 +636,7 @@ index 6b10dbd..3d2927e 100644 com.viaversion.viafabricplus.bedrock.appearance.BedrockEntityResources.clear(); BedrockShaderEmission.clear(); diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockAnimatedModel.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockAnimatedModel.java -index 3e8e5c4..e8de8a4 100644 +index 0697b72..3899914 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockAnimatedModel.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockAnimatedModel.java @@ -81,12 +81,17 @@ public final class BedrockAnimatedModel { @@ -1031,10 +1031,10 @@ index 0000000..a7d9469 + } +} diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json -index e1f51e2..45c0917 100644 +index ff2a762..8749756 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -117,6 +117,7 @@ +@@ -118,6 +118,7 @@ "features.render.MixinBedrockActorDynamicData", "features.render.MixinBedrockActorPreparedRenderType", "features.render.MixinBedrockActorSubmission", diff --git a/patches/viafabricplus-bedrock/upstreamable/0031-edit-native-command-block-settings.patch b/patches/viafabricplus-bedrock/upstreamable/0031-edit-native-command-block-settings.patch index 8c0cccba..b38f637a 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0031-edit-native-command-block-settings.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0031-edit-native-command-block-settings.patch @@ -1,4 +1,4 @@ -From 3596380b12b4fcfcf0d70ee0084a3b01ea4a54bb Mon Sep 17 00:00:00 2001 +From 5d4bd205459f84f2c83bb8845b882cf1e1055704 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(command): edit native command block settings @@ -23,10 +23,10 @@ Open the Java command editor for an authoritative native COMMAND_BLOCK window. R create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/CommandBlockOpenPayload.java diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java b/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java -index b345de2..ff8ddd0 100644 +index 85d93cd..5e76d1e 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java -@@ -100,6 +100,8 @@ import net.raphimc.viabedrock.protocol.provider.EmoteProvider; +@@ -101,6 +101,8 @@ import net.raphimc.viabedrock.protocol.provider.EmoteProvider; import com.viaversion.viafabricplus.bedrock.appearance.ViaFabricPlusEmoteProvider; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -35,7 +35,7 @@ index b345de2..ff8ddd0 100644 import com.viaversion.viafabricplus.bedrock.screen.ui.BedrockHudOverlays; import com.viaversion.viafabricplus.bedrock.screen.ui.BedrockNativeUiHurt; import com.viaversion.viafabricplus.bedrock.screen.form.BedrockNativeForms; -@@ -208,6 +210,8 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { +@@ -209,6 +211,8 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { ClientPlayNetworking.registerGlobalReceiver(NativeParticlePlaybackPayload.TYPE, (payload, context) -> BedrockServerParticles.accept(payload.message())); PayloadTypeRegistry.serverboundPlay().register(RawInputPayload.TYPE, RawInputPayload.CODEC); @@ -341,10 +341,10 @@ index da0aa13..f345d7c 100644 "base.viafabricplus.code": "Code", "base.viafabricplus.copy_link": "Copy link", diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json -index 45c0917..19db653 100644 +index 8749756..9578c41 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -204,6 +204,8 @@ +@@ -207,6 +207,8 @@ "features.movement.MixinLocalPlayer", "features.movement.MixinPlayerCorrectionPacketListener", "features.movement.MixinPlayer", diff --git a/patches/viafabricplus-bedrock/upstreamable/0032-reconcile-native-block-break-audio.patch b/patches/viafabricplus-bedrock/upstreamable/0032-reconcile-native-block-break-audio.patch index 412ec7a0..5a7e52f8 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0032-reconcile-native-block-break-audio.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0032-reconcile-native-block-break-audio.patch @@ -1,4 +1,4 @@ -From 0c2b81a4c1d4a642015a97382178c395d88a7411 Mon Sep 17 00:00:00 2001 +From b2abd88a72584d0b50595c54698b6fb056642c52 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] fix(sound): reconcile native block break audio @@ -71,10 +71,10 @@ index 0000000..9f24afb + } +} diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json -index 19db653..df0bc1f 100644 +index 9578c41..36eb934 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -204,6 +204,7 @@ +@@ -207,6 +207,7 @@ "features.movement.MixinLocalPlayer", "features.movement.MixinPlayerCorrectionPacketListener", "features.movement.MixinPlayer", diff --git a/patches/viafabricplus-bedrock/upstreamable/0034-animate-native-moving-piston-blocks.patch b/patches/viafabricplus-bedrock/upstreamable/0034-animate-native-moving-piston-blocks.patch index 4974b82d..a57cfa60 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0034-animate-native-moving-piston-blocks.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0034-animate-native-moving-piston-blocks.patch @@ -1,4 +1,4 @@ -From c385de39f5f0b0fe3c16b14e2add9cc166cef73b Mon Sep 17 00:00:00 2001 +From 83d57d6d4ac7ee968dfd58c0d30c3af01a8eeca0 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] fix(render): animate native moving piston blocks @@ -155,10 +155,10 @@ index 0000000..f23da98 + } +} diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json -index df0bc1f..e837c43 100644 +index 36eb934..3c0e816 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -207,6 +207,8 @@ +@@ -210,6 +210,8 @@ "features.sound.MixinBedrockMiningAudio", "features.block.MixinBedrockCommandBlockSettings", "features.inventory.MixinBedrockCommandBlockScreen", diff --git a/patches/viafabricplus-bedrock/upstreamable/0035-forward-bedrock-fluid-pickup.patch b/patches/viafabricplus-bedrock/upstreamable/0035-forward-bedrock-fluid-pickup.patch index 5e4f0bf0..21c15d3e 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0035-forward-bedrock-fluid-pickup.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0035-forward-bedrock-fluid-pickup.patch @@ -1,4 +1,4 @@ -From 4ce9e70e4b298aab8085040b5d1de7ed3976bdff Mon Sep 17 00:00:00 2001 +From 4ed7512e9f4910899eb07b5da61e2c3a0eddd695 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] fix(interaction): forward Bedrock fluid pickup @@ -113,10 +113,10 @@ index 0000000..d3c7c2d + } +} diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json -index e837c43..2529ebe 100644 +index 3c0e816..15ba7dc 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -97,6 +97,7 @@ +@@ -98,6 +98,7 @@ "features.creative.MixinCreativeModeTabs", "features.inventory.MixinNativeContainerGeometry", "features.inventory.MixinNativeContainerScreenAdmission", diff --git a/patches/viafabricplus-bedrock/upstreamable/0038-predict-held-trapdoor-placement.patch b/patches/viafabricplus-bedrock/upstreamable/0038-predict-held-trapdoor-placement.patch index 89245f71..1e8e9b5c 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0038-predict-held-trapdoor-placement.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0038-predict-held-trapdoor-placement.patch @@ -1,4 +1,4 @@ -From b9b035c2314ede27c25a10333889c6466bb550cf Mon Sep 17 00:00:00 2001 +From 23be0d8232de522543aaa9e3b3ce3649a7708153 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:36:18 +0100 Subject: [PATCH] feat(interaction): preserve native held item use @@ -44,8 +44,8 @@ retains untimed block mode, rather than acquiring the air route. An owned air ho skips block/entity dispatch and offhand fallback; newly selected items share its air deadline. A placement hold cannot fall through to eating. Canonical consumption/completion remain server-owned. Initial block-item -air use retains untimed block mode. Other initial air-use families retain -their existing route pending matching controls. +air use retains untimed block mode. Native item predicates select the +other initial air-use routes as described below. The installed baseline places four Stone blocks after a food hold and consumes six foods after a trapdoor hold, unlike native. Input-state tests @@ -169,30 +169,81 @@ target before admission, allowing the ordinary air predicate to select the continuation. A target-boundary regression keeps block hits eligible without opening entity or air admission early. Held entity dispatch and broader actor handlers remain separate comparison work. + +For item context that supplies native predicates, record the strict +200 ms air deadline before every admitted main-hand air callback. Select +the initial continuation after that callback from active use or retained +stack count and the native item predicate. Keep an existing air route +across item changes and failed callbacks. A fresh clock rejection retains +block continuation and must not start air use later during the same hold. + +Original Bedrock1.26.60.5 instruction controls establish this separation: +32 outcome combinations and six original Ender Pearl vtable cases keep +the route independent of the ordinary air callback result. ComponentItem +predicates check throwable and legacy-trigger-on-use components; use core +metadata so Java carrier classes do not choose the mode. Retain the prior +food admission snapshot when Java completes zero-duration food inside +its callback. Older proxy cores without the field retain the previous +input fallback. Full physical timing, real projectile cooldowns, Education +predicates and matching installed direct/proxy controls remain open. + +Capture actual local stack consumption before ordinary item use and send +its result after prediction, before the concluding Java packet. Match +the prediction sequence in core. Cooldown refusal still sends air use +but must not invent a legacy inventory debit. Compare native definition +identity as well as quantity, and retain the existing core fallback when +the optional consumption channel is absent. + +Original Bedrock 1.26.60.5 client callbacks record one pearl debit for a +successful use and no inventory action for a cooldown-refused use. Java +also sends a packet when its local cooldown skips item use. Reporting +its actual stack change lets core reproduce the native action envelope +and receive authority for late server acceptance. Keep this separate +from result-independent air-route ownership and the strict 200 ms clock. + +Observe release on the configured use key and stop the current native +item-use cycle through the ordinary game-mode release. Java's tick key +handler can miss release and repress within one tick even when input +frames saw both edges. Handle the release during the key update so it +cannot affect a later cycle, and retain the released-state frame check +as a fallback. The active-use guard prevents duplicate tick releases. + +Bedrock 1.26.60.5 local-world short-gap controls stop the first bread +cycle with 23 ticks remaining and restart at 32. Installed Java before +the correction observes released and pressed frame states at the same +game tick while retaining 22 ticks, then consumes the old cycle. Keep +the existing held-session and strict air-deadline owners, ordinary Java +connections, and inventory authority. No second timer, delayed release +latch, forced food callback or quantity prediction is needed. The +original intermittent animation-without-feeding report remains open. --- .../bedrock/ViaFabricPlusBedrock.java | 2 + .../features/block/MixinTrapDoorBlock.java | 18 +- - .../features/input/MixinItemUseControl.java | 107 +++++++++ - .../inventory/MixinHeldBlockPlacement.java | 32 +++ - .../features/inventory/MixinHeldBlockUse.java | 61 +++++ - .../bedrock/input/BedrockItemUse.java | 221 ++++++++++++++++++ - .../bedrock/input/BlockPlacementPath.java | 83 +++++++ + .../features/input/MixinItemUseControl.java | 112 +++++++ + .../features/input/MixinUseKeyMapping.java | 25 ++ + .../inventory/MixinHeldBlockPlacement.java | 32 ++ + .../features/inventory/MixinHeldBlockUse.java | 65 ++++ + .../bedrock/input/BedrockItemUse.java | 285 +++++++++++++++++ + .../bedrock/input/BlockPlacementPath.java | 83 +++++ .../bedrock/input/BlockUsePayload.java | 24 ++ - .../input/BlockUsePredictionPayload.java | 33 +++ - .../bedrock/input/ItemUseState.java | 114 +++++++++ - .../bedrock/inventory/TrapdoorPlacement.java | 55 +++++ - .../viafabricplus-bedrock.mixins.json | 3 + - .../bedrock/input/BlockPlacementPathTest.java | 128 ++++++++++ - .../bedrock/input/ItemUseStateTest.java | 221 ++++++++++++++++++ - .../inventory/TrapdoorPlacementTest.java | 100 ++++++++ - 15 files changed, 1198 insertions(+), 4 deletions(-) + .../input/BlockUsePredictionPayload.java | 33 ++ + .../input/ItemUsePredictionPayload.java | 24 ++ + .../bedrock/input/ItemUseState.java | 139 +++++++++ + .../bedrock/inventory/TrapdoorPlacement.java | 55 ++++ + .../viafabricplus-bedrock.mixins.json | 4 + + .../bedrock/input/BlockPlacementPathTest.java | 128 ++++++++ + .../bedrock/input/ItemUseStateTest.java | 286 ++++++++++++++++++ + .../inventory/TrapdoorPlacementTest.java | 100 ++++++ + 17 files changed, 1411 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/input/MixinItemUseControl.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/input/MixinUseKeyMapping.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/inventory/MixinHeldBlockPlacement.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/inventory/MixinHeldBlockUse.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/input/BedrockItemUse.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/input/BlockPlacementPath.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/input/BlockUsePayload.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/input/BlockUsePredictionPayload.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/input/ItemUsePredictionPayload.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/input/ItemUseState.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/inventory/TrapdoorPlacement.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/input/BlockPlacementPathTest.java @@ -200,10 +251,10 @@ broader actor handlers remain separate comparison work. create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/inventory/TrapdoorPlacementTest.java diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java b/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java -index ff8ddd0..384a74d 100644 +index 5e76d1e..b26daac 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java -@@ -86,6 +86,7 @@ import com.viaversion.viafabricplus.bedrock.hud.BedrockHudVisibility; +@@ -87,6 +87,7 @@ import com.viaversion.viafabricplus.bedrock.hud.BedrockHudVisibility; import com.viaversion.viafabricplus.bedrock.hud.BedrockLocatorBar; import com.viaversion.viafabricplus.bedrock.hud.LocatorBarPayload; import com.viaversion.viafabricplus.bedrock.input.RawInputPayload; @@ -211,7 +262,7 @@ index ff8ddd0..384a74d 100644 import com.viaversion.viafabricplus.bedrock.input.PlayerPredictionPayload; import com.viaversion.viafabricplus.bedrock.input.BedrockPlayerCorrections; import com.viaversion.viafabricplus.bedrock.input.BedrockMovementEffects; -@@ -210,6 +211,7 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { +@@ -211,6 +212,7 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { ClientPlayNetworking.registerGlobalReceiver(NativeParticlePlaybackPayload.TYPE, (payload, context) -> BedrockServerParticles.accept(payload.message())); PayloadTypeRegistry.serverboundPlay().register(RawInputPayload.TYPE, RawInputPayload.CODEC); @@ -271,10 +322,10 @@ index 82db187..eda72b3 100644 return super.getOcclusionShape(state); diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/input/MixinItemUseControl.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/input/MixinItemUseControl.java new file mode 100644 -index 0000000..b6ef2d9 +index 0000000..17a29e8 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/input/MixinItemUseControl.java -@@ -0,0 +1,107 @@ +@@ -0,0 +1,112 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -346,8 +397,13 @@ index 0000000..b6ef2d9 + target = "Lnet/minecraft/client/multiplayer/MultiPlayerGameMode;useItem(Lnet/minecraft/world/entity/player/Player;Lnet/minecraft/world/InteractionHand;)Lnet/minecraft/world/InteractionResult;")) + private InteractionResult admitAirUse(final MultiPlayerGameMode gameMode, final Player player, + final InteractionHand hand, final Operation original) { -+ return BedrockItemUse.beginAirUse(hand, player.getItemInHand(hand)) -+ ? original.call(gameMode, player, hand) : InteractionResult.PASS; ++ if (!BedrockItemUse.beginAirUse(hand, player.getItemInHand(hand))) { ++ return InteractionResult.PASS; ++ } ++ BedrockItemUse.beginItemPrediction(hand, player.getItemInHand(hand)); ++ final InteractionResult result = original.call(gameMode, player, hand); ++ BedrockItemUse.airResult(hand); ++ return result; + } + + @Inject(method = "startUseItem", at = @At("RETURN")) @@ -382,6 +438,37 @@ index 0000000..b6ef2d9 + BedrockItemUse.release(); + } +} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/input/MixinUseKeyMapping.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/input/MixinUseKeyMapping.java +new file mode 100644 +index 0000000..d716321 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/input/MixinUseKeyMapping.java +@@ -0,0 +1,25 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.injection.mixin.features.input; ++ ++import com.viaversion.viafabricplus.bedrock.input.BedrockItemUse; ++import net.minecraft.client.KeyMapping; ++import net.minecraft.client.Minecraft; ++import org.spongepowered.asm.mixin.Mixin; ++import org.spongepowered.asm.mixin.injection.At; ++import org.spongepowered.asm.mixin.injection.Inject; ++import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; ++ ++@Mixin(KeyMapping.class) ++public abstract class MixinUseKeyMapping { ++ @Inject(method = "setDown", at = @At("TAIL")) ++ private void retainUseRelease(final boolean down, final CallbackInfo ci) { ++ final var client = Minecraft.getInstance(); ++ if (!down && client != null && client.options != null && (Object) this == client.options.keyUse) { ++ BedrockItemUse.releaseKey(); ++ } ++ } ++} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/inventory/MixinHeldBlockPlacement.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/inventory/MixinHeldBlockPlacement.java new file mode 100644 index 0000000..a54ba3a @@ -422,10 +509,10 @@ index 0000000..a54ba3a +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/inventory/MixinHeldBlockUse.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/inventory/MixinHeldBlockUse.java new file mode 100644 -index 0000000..69387b7 +index 0000000..82b3509 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/inventory/MixinHeldBlockUse.java -@@ -0,0 +1,61 @@ +@@ -0,0 +1,65 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -443,6 +530,7 @@ index 0000000..69387b7 +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.network.protocol.Packet; +import net.minecraft.network.protocol.game.ServerboundUseItemOnPacket; ++import net.minecraft.network.protocol.game.ServerboundUseItemPacket; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; +import net.minecraft.client.player.LocalPlayer; @@ -479,9 +567,12 @@ index 0000000..69387b7 + + @WrapOperation(method = "startPrediction", at = @At(value = "INVOKE", + target = "Lnet/minecraft/client/multiplayer/ClientPacketListener;send(Lnet/minecraft/network/protocol/Packet;)V")) -+ private void sendBlockUsePrediction(final ClientPacketListener connection, final Packet packet, final Operation original) { ++ private void sendLocalItemPrediction(final ClientPacketListener connection, final Packet packet, final Operation original) { + if (packet instanceof ServerboundUseItemOnPacket use) { + BedrockItemUse.sendPrediction(use.sequence()); ++ } else if (packet instanceof ServerboundUseItemPacket use) { ++ // Local item prediction has finished; send its result before the concluding use packet. ++ BedrockItemUse.sendItemPrediction(use.sequence(), use.hand()); + } + original.call(connection, packet); + } @@ -489,10 +580,10 @@ index 0000000..69387b7 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/input/BedrockItemUse.java b/src/main/java/com/viaversion/viafabricplus/bedrock/input/BedrockItemUse.java new file mode 100644 -index 0000000..45409b3 +index 0000000..6fb7aa2 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/input/BedrockItemUse.java -@@ -0,0 +1,221 @@ +@@ -0,0 +1,285 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -501,6 +592,7 @@ index 0000000..45409b3 +package com.viaversion.viafabricplus.bedrock.input; + +import com.viaversion.viafabricplus.bedrock.inventory.TrapdoorPlacement; ++import com.viaversion.viafabricplus.bedrock.appearance.BedrockItemMetadata; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; @@ -520,6 +612,7 @@ index 0000000..45409b3 +import net.minecraft.world.phys.Vec3; +import net.raphimc.viabedrock.protocol.model.Position3f; +import net.raphimc.viabedrock.api.model.PlayerBlockUse; ++import net.raphimc.viabedrock.api.model.ItemUsePrediction; + +public final class BedrockItemUse { + private static final TrapdoorPlacement PLACEMENT = new TrapdoorPlacement(); @@ -528,6 +621,10 @@ index 0000000..45409b3 + private static boolean placedBlock; + private static boolean successfulAttempt; + private static boolean airAllowed; ++ private static Boolean pendingAirPredicate; ++ private static boolean pendingAirItem; ++ private static boolean pendingFoodUse; ++ private static ItemStack predictedItem = ItemStack.EMPTY; + private static final ItemUseState INPUT = new ItemUseState(); + private static Position3f cursor; + private static BlockUsePrediction.Result predictedResult = BlockUsePrediction.Result.FAILED; @@ -536,6 +633,7 @@ index 0000000..45409b3 + + public static void register() { + PayloadTypeRegistry.serverboundPlay().register(BlockUsePayload.TYPE, BlockUsePayload.CODEC); ++ PayloadTypeRegistry.serverboundPlay().register(ItemUsePredictionPayload.TYPE, ItemUsePredictionPayload.CODEC); + PayloadTypeRegistry.serverboundPlay().register(BlockUsePredictionPayload.TYPE, BlockUsePredictionPayload.CODEC); + PayloadTypeRegistry.serverboundPlay().register(BlockUsePredictionPayload.AIR_TYPE, BlockUsePredictionPayload.AIR_CODEC); + ClientTickEvents.START_CLIENT_TICK.register(client -> refresh()); @@ -557,7 +655,44 @@ index 0000000..45409b3 + } + final var consumable = item.get(DataComponents.CONSUMABLE); + final boolean canConsume = consumable != null && consumable.canConsume(Minecraft.getInstance().player, item); -+ return INPUT.admitAirUse(System.nanoTime(), hand, item.has(DataComponents.FOOD), held && item.getItem() instanceof BlockItem, canConsume); ++ pendingAirPredicate = null; ++ final Boolean predicate = BedrockItemMetadata.get(item.get(DataComponents.CUSTOM_DATA)).retainsAirUse(); ++ if (predicate == null) { ++ return INPUT.admitLegacyAirUse(System.nanoTime(), hand, item.has(DataComponents.FOOD), held && item.getItem() instanceof BlockItem, canConsume); ++ } ++ final boolean admitted = INPUT.admitAirUse(System.nanoTime(), hand); ++ if (admitted && hand == InteractionHand.MAIN_HAND) { ++ pendingAirPredicate = predicate; ++ pendingAirItem = !item.isEmpty(); ++ // Java can finish zero-duration food inside useItem; retain its admitted native use route. ++ pendingFoodUse = item.has(DataComponents.FOOD) && canConsume; ++ } ++ return admitted; ++ } ++ ++ public static void beginItemPrediction(final InteractionHand hand, final ItemStack item) { ++ predictedItem = hand == InteractionHand.MAIN_HAND && bedrockConnection() ++ && ClientPlayNetworking.canSend(ItemUsePredictionPayload.TYPE) ? item.copy() : ItemStack.EMPTY; ++ } ++ ++ public static void sendItemPrediction(final int sequence, final InteractionHand hand) { ++ final var player = Minecraft.getInstance().player; ++ if (!predictedItem.isEmpty() && hand == InteractionHand.MAIN_HAND && player != null && bedrockConnection() ++ && ClientPlayNetworking.canSend(ItemUsePredictionPayload.TYPE)) { ++ final ItemStack after = player.getItemInHand(hand); ++ final boolean consumed = after.getCount() < predictedItem.getCount() ++ && (after.isEmpty() || BedrockUsedItem.matches(after, predictedItem)); ++ ClientPlayNetworking.send(new ItemUsePredictionPayload(new ItemUsePrediction(sequence, consumed))); ++ } ++ predictedItem = ItemStack.EMPTY; ++ } ++ ++ public static void airResult(final InteractionHand hand) { ++ if (hand == InteractionHand.MAIN_HAND && pendingAirPredicate != null && bedrockConnection()) { ++ INPUT.airResult(Minecraft.getInstance().player.isUsingItem() || pendingFoodUse, pendingAirItem, pendingAirPredicate); ++ } ++ pendingAirPredicate = null; ++ predictedItem = ItemStack.EMPTY; + } + + public static HitResult.Type hitType(final HitResult.Type target, final InteractionHand hand) { @@ -572,6 +707,18 @@ index 0000000..45409b3 + update(false); + } + ++ public static void releaseKey() { ++ release(); ++ stopItemUse(Minecraft.getInstance()); ++ } ++ ++ private static void stopItemUse(final Minecraft client) { ++ if (bedrockConnection() && client.player != null && client.gameMode != null ++ && client.player.isUsingItem()) { ++ client.gameMode.releaseUsingItem(client.player); ++ } ++ } ++ + private static boolean canPredict() { + return ClientPlayNetworking.canSend(BlockUsePredictionPayload.TYPE) + || ClientPlayNetworking.canSend(BlockUsePredictionPayload.AIR_TYPE); @@ -632,8 +779,12 @@ index 0000000..45409b3 + + public static void refresh() { + final var client = Minecraft.getInstance(); ++ final boolean down = client.options.keyUse.isDown(); ++ if (!down) { ++ stopItemUse(client); ++ } + update(client.player != null && client.gui.screen() == null && client.gui.overlay() == null -+ && !client.player.isSpectator() && client.options.keyUse.isDown()); ++ && !client.player.isSpectator() && down); + } + + public static boolean suppressInteraction() { @@ -712,6 +863,10 @@ index 0000000..45409b3 + private static void reset() { + resetBlockUse(); + INPUT.clear(); ++ pendingAirPredicate = null; ++ pendingAirItem = false; ++ pendingFoodUse = false; ++ predictedItem = ItemStack.EMPTY; + } +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/input/BlockPlacementPath.java b/src/main/java/com/viaversion/viafabricplus/bedrock/input/BlockPlacementPath.java @@ -872,12 +1027,42 @@ index 0000000..3ee51e7 + return this.withAirAdmission ? AIR_TYPE : TYPE; + } +} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/input/ItemUsePredictionPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/input/ItemUsePredictionPayload.java +new file mode 100644 +index 0000000..4b4e787 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/input/ItemUsePredictionPayload.java +@@ -0,0 +1,24 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.input; ++ ++import net.minecraft.network.RegistryFriendlyByteBuf; ++import net.minecraft.network.codec.StreamCodec; ++import net.minecraft.network.protocol.common.custom.CustomPacketPayload; ++import net.minecraft.resources.Identifier; ++import net.raphimc.viabedrock.api.model.ItemUsePrediction; ++ ++public record ItemUsePredictionPayload(ItemUsePrediction prediction) implements CustomPacketPayload { ++ public static final Type TYPE = new Type<>(Identifier.parse(ItemUsePrediction.CHANNEL)); ++ public static final StreamCodec CODEC = CustomPacketPayload.codec( ++ (payload, buffer) -> ItemUsePrediction.TYPE.write(buffer, payload.prediction), ++ buffer -> new ItemUsePredictionPayload(ItemUsePrediction.TYPE.read(buffer))); ++ ++ @Override ++ public Type type() { ++ return TYPE; ++ } ++} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/input/ItemUseState.java b/src/main/java/com/viaversion/viafabricplus/bedrock/input/ItemUseState.java new file mode 100644 -index 0000000..246e21b +index 0000000..3b10db1 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/input/ItemUseState.java -@@ -0,0 +1,114 @@ +@@ -0,0 +1,139 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -904,7 +1089,32 @@ index 0000000..246e21b + return !this.airAttempted || now - this.airStartedAt > AIR_DELAY_NANOS; + } + -+ public boolean admitAirUse(final long now, final InteractionHand hand, final boolean food, final boolean blockItem, final boolean canConsume) { ++ public boolean admitAirUse(final long now, final InteractionHand hand) { ++ if (hand != InteractionHand.MAIN_HAND) { ++ return !this.airActive && !this.blockActive; ++ } ++ if (this.blockActive) { ++ return false; ++ } ++ if (!this.canAttempt(now)) { ++ if (!this.airActive) { ++ this.blockActive = true; ++ } ++ return false; ++ } ++ this.recordAirUse(now); ++ return true; ++ } ++ ++ public void airResult(final boolean activeUse, final boolean retainedItem, final boolean retainsAirUse) { ++ if (!this.airActive) { ++ this.airActive = activeUse || retainedItem && retainsAirUse; ++ this.blockActive = !this.airActive; ++ } ++ } ++ ++ /** Older proxy cores do not provide native item predicates. Preserve their prior route selection. */ ++ public boolean admitLegacyAirUse(final long now, final InteractionHand hand, final boolean food, final boolean blockItem, final boolean canConsume) { + if (hand != InteractionHand.MAIN_HAND) { + return !this.airActive && !this.blockActive; + } @@ -1054,14 +1264,15 @@ index 0000000..7a2e793 + } +} diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json -index 2529ebe..192d194 100644 +index 15ba7dc..608ede6 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -92,6 +92,9 @@ +@@ -93,6 +93,10 @@ "features.block.MixinRedstoneWireBlock", "features.block.MixinSeaPickleBlock", "features.block.MixinTrapDoorBlock", + "features.input.MixinItemUseControl", ++ "features.input.MixinUseKeyMapping", + "features.inventory.MixinHeldBlockUse", + "features.inventory.MixinHeldBlockPlacement", "features.creative.MixinCreativeModeInventoryScreen", @@ -1203,10 +1414,10 @@ index 0000000..e4fe3f2 +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/input/ItemUseStateTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/input/ItemUseStateTest.java new file mode 100644 -index 0000000..29a02ab +index 0000000..00f79ed --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/input/ItemUseStateTest.java -@@ -0,0 +1,221 @@ +@@ -0,0 +1,286 @@ +package com.viaversion.viafabricplus.bedrock.input; + +import org.junit.jupiter.api.Test; @@ -1220,18 +1431,83 @@ index 0000000..29a02ab + +final class ItemUseStateTest { + @Test ++ void admittedProjectileOwnsTheDeadlineAndAirRouteEvenWithoutAnActiveUseCycle() { ++ final var input = new ItemUseState(); ++ assertTrue(input.admitAirUse(1_000_000_000L, InteractionHand.MAIN_HAND)); ++ input.airResult(false, true, true); ++ assertEquals(HitResult.Type.MISS, input.hitType(1_500_000_000L, HitResult.Type.BLOCK, InteractionHand.MAIN_HAND)); ++ assertFalse(input.frameReady(1_200_000_000L, false)); ++ assertTrue(input.frameReady(1_200_000_001L, false)); ++ input.release(); ++ assertFalse(input.admitAirUse(1_100_000_000L, InteractionHand.MAIN_HAND)); ++ } ++ ++ @Test ++ void rejectedFreshProjectileHoldDoesNotResumeAirUseAfterTheDeadline() { ++ final var input = new ItemUseState(); ++ assertTrue(input.admitAirUse(1_000_000_000L, InteractionHand.MAIN_HAND)); ++ input.airResult(false, true, true); ++ input.release(); ++ assertFalse(input.admitAirUse(1_100_000_000L, InteractionHand.MAIN_HAND)); ++ assertEquals(HitResult.Type.BLOCK, input.hitType(1_300_000_000L, HitResult.Type.BLOCK, InteractionHand.MAIN_HAND)); ++ assertTrue(input.frameReady(1_300_000_000L, false)); ++ assertFalse(input.admitAirUse(1_300_000_000L, InteractionHand.MAIN_HAND)); ++ input.release(); ++ assertTrue(input.admitAirUse(1_300_000_001L, InteractionHand.MAIN_HAND)); ++ input.airResult(false, true, true); ++ assertEquals(HitResult.Type.MISS, input.hitType(1_600_000_000L, HitResult.Type.BLOCK, InteractionHand.MAIN_HAND)); ++ } ++ ++ @Test ++ void projectileHoldKeepsAirRoutingAfterSwitchingToAnOrdinaryBlock() { ++ final var input = new ItemUseState(); ++ assertTrue(input.admitAirUse(1_000_000_000L, InteractionHand.MAIN_HAND)); ++ input.airResult(false, true, true); ++ assertTrue(input.admitAirUse(1_250_000_000L, InteractionHand.MAIN_HAND)); ++ input.airResult(false, true, false); ++ assertFalse(input.blockActive()); ++ assertFalse(input.admitAirUse(1_250_000_001L, InteractionHand.OFF_HAND)); ++ assertEquals(HitResult.Type.MISS, input.hitType(1_600_000_000L, HitResult.Type.BLOCK, InteractionHand.MAIN_HAND)); ++ } ++ ++ @Test ++ void activeUseCanRetainAirWithoutAThrowablePredicateButAnOrdinaryResultCannot() { ++ final var active = new ItemUseState(); ++ assertTrue(active.admitAirUse(1_000_000_000L, InteractionHand.MAIN_HAND)); ++ active.airResult(true, true, false); ++ assertEquals(HitResult.Type.MISS, active.hitType(1_500_000_000L, HitResult.Type.BLOCK, InteractionHand.MAIN_HAND)); ++ final var ordinary = new ItemUseState(); ++ assertTrue(ordinary.admitAirUse(1_000_000_000L, InteractionHand.MAIN_HAND)); ++ ordinary.airResult(false, true, false); ++ assertEquals(HitResult.Type.BLOCK, ordinary.hitType(1_500_000_000L, HitResult.Type.BLOCK, InteractionHand.MAIN_HAND)); ++ assertFalse(ordinary.admitAirUse(1_500_000_000L, InteractionHand.MAIN_HAND)); ++ } ++ ++ @Test ++ void aMissingRetainedStackDoesNotAcquireAirFromItsItemPredicate() { ++ final var input = new ItemUseState(); ++ assertTrue(input.admitAirUse(1_000_000_000L, InteractionHand.MAIN_HAND)); ++ input.airResult(false, false, true); ++ assertTrue(input.blockActive()); ++ input.release(); ++ assertFalse(input.admitAirUse(1_200_000_000L, InteractionHand.MAIN_HAND)); ++ input.clear(); ++ assertTrue(input.admitAirUse(1_200_000_000L, InteractionHand.MAIN_HAND)); ++ } ++ ++ @Test + void releasedFoodKeepsItsDeadlineForFreshClicksUntilConnectionReplacement() { + final var timing = new ItemUseState(); -+ assertTrue(timing.admitAirUse(1_000_000_000L, InteractionHand.MAIN_HAND, true, false, true)); ++ assertTrue(timing.admitLegacyAirUse(1_000_000_000L, InteractionHand.MAIN_HAND, true, false, true)); + timing.release(); -+ assertFalse(timing.admitAirUse(1_131_000_000L, InteractionHand.MAIN_HAND, true, false, true)); ++ assertFalse(timing.admitLegacyAirUse(1_131_000_000L, InteractionHand.MAIN_HAND, true, false, true)); + timing.release(); -+ assertFalse(timing.admitAirUse(1_200_000_000L, InteractionHand.MAIN_HAND, true, false, true)); ++ assertFalse(timing.admitLegacyAirUse(1_200_000_000L, InteractionHand.MAIN_HAND, true, false, true)); + timing.release(); -+ assertTrue(timing.admitAirUse(1_616_000_000L, InteractionHand.MAIN_HAND, true, false, true)); ++ assertTrue(timing.admitLegacyAirUse(1_616_000_000L, InteractionHand.MAIN_HAND, true, false, true)); + assertFalse(timing.canAttempt(1_700_000_000L)); + timing.clear(); -+ assertTrue(timing.admitAirUse(1_700_000_000L, InteractionHand.MAIN_HAND, true, false, true)); ++ assertTrue(timing.admitLegacyAirUse(1_700_000_000L, InteractionHand.MAIN_HAND, true, false, true)); + } + + @Test @@ -1239,11 +1515,11 @@ index 0000000..29a02ab + final var timing = new ItemUseState(); + final List usedAt = new ArrayList<>(); + assertFalse(timing.frameReady(1_000_000_000L, false)); -+ assertTrue(timing.admitAirUse(1_000_000_000L, InteractionHand.MAIN_HAND, true, false, true)); ++ assertTrue(timing.admitLegacyAirUse(1_000_000_000L, InteractionHand.MAIN_HAND, true, false, true)); + usedAt.add(0); + for (final int milliseconds : new int[] {47, 94, 141, 188, 236, 284, 332, 380, 428, 475, 542, 609, 676}) { + final long now = 1_000_000_000L + milliseconds * 1_000_000L; -+ if (timing.frameReady(now, false) && timing.admitAirUse(now, InteractionHand.MAIN_HAND, true, false, true)) { ++ if (timing.frameReady(now, false) && timing.admitLegacyAirUse(now, InteractionHand.MAIN_HAND, true, false, true)) { + usedAt.add(milliseconds); + } + } @@ -1253,14 +1529,14 @@ index 0000000..29a02ab + @Test + void foodHoldKeepsAirUseAcrossBlockAndEntityTargetsUntilRelease() { + final var input = new ItemUseState(); -+ assertTrue(input.admitAirUse(1_000_000_000L, InteractionHand.MAIN_HAND, true, false, true)); ++ assertTrue(input.admitLegacyAirUse(1_000_000_000L, InteractionHand.MAIN_HAND, true, false, true)); + assertEquals(HitResult.Type.MISS, input.hitType(1_500_000_000L, HitResult.Type.BLOCK, InteractionHand.MAIN_HAND)); + assertEquals(HitResult.Type.MISS, input.hitType(1_500_000_000L, HitResult.Type.ENTITY, InteractionHand.MAIN_HAND)); + input.attemptedBlock(1_100_000_000L, true); + assertFalse(input.frameReady(1_200_000_000L, false)); + assertTrue(input.frameReady(1_200_000_001L, false)); -+ assertTrue(input.admitAirUse(1_200_000_001L, InteractionHand.MAIN_HAND, false, false, true)); -+ assertFalse(input.admitAirUse(1_300_000_000L, InteractionHand.OFF_HAND, false, false, true)); ++ assertTrue(input.admitLegacyAirUse(1_200_000_001L, InteractionHand.MAIN_HAND, false, false, true)); ++ assertFalse(input.admitLegacyAirUse(1_300_000_000L, InteractionHand.OFF_HAND, false, false, true)); + input.cancelPlacement(); + assertEquals(HitResult.Type.MISS, input.hitType(1_500_000_000L, HitResult.Type.BLOCK, InteractionHand.MAIN_HAND)); + input.release(); @@ -1278,7 +1554,7 @@ index 0000000..29a02ab + assertTrue(input.frameReady(1_300_000_001L, false)); + assertEquals(HitResult.Type.BLOCK, input.hitType(1_500_000_000L, HitResult.Type.BLOCK, InteractionHand.MAIN_HAND)); + assertEquals(HitResult.Type.MISS, input.hitType(1_500_000_000L, HitResult.Type.BLOCK, InteractionHand.OFF_HAND)); -+ assertFalse(input.admitAirUse(1_300_000_000L, InteractionHand.MAIN_HAND, true, false, true)); ++ assertFalse(input.admitLegacyAirUse(1_300_000_000L, InteractionHand.MAIN_HAND, true, false, true)); + input.attemptedBlock(1_310_000_000L, false); + assertTrue(input.frameReady(1_311_000_000L, false)); + input.attemptedBlock(1_320_000_000L, true); @@ -1286,34 +1562,34 @@ index 0000000..29a02ab + assertFalse(input.frameReady(1_620_000_000L, false)); + assertTrue(input.frameReady(1_620_000_001L, false)); + input.release(); -+ assertTrue(input.admitAirUse(1_630_000_000L, InteractionHand.MAIN_HAND, true, false, true)); ++ assertTrue(input.admitLegacyAirUse(1_630_000_000L, InteractionHand.MAIN_HAND, true, false, true)); + assertFalse(input.canAttempt(1_800_000_000L)); + input.clear(); + assertEquals(HitResult.Type.ENTITY, input.hitType(1_500_000_000L, HitResult.Type.ENTITY, InteractionHand.MAIN_HAND)); -+ assertTrue(input.admitAirUse(1_800_000_000L, InteractionHand.MAIN_HAND, true, false, true)); ++ assertTrue(input.admitLegacyAirUse(1_800_000_000L, InteractionHand.MAIN_HAND, true, false, true)); + } + + @Test + void initiallyRefusedFoodKeepsUntimedBlockRepeatsWhileFreshCallsKeepTheAirDeadline() { + final var input = new ItemUseState(); -+ assertTrue(input.admitAirUse(1_000_000_000L, InteractionHand.MAIN_HAND, true, false, false)); ++ assertTrue(input.admitLegacyAirUse(1_000_000_000L, InteractionHand.MAIN_HAND, true, false, false)); + input.attemptedBlock(1_010_000_000L, false); + assertEquals(HitResult.Type.BLOCK, input.hitType(1_500_000_000L, HitResult.Type.BLOCK, InteractionHand.MAIN_HAND)); + assertTrue(input.frameReady(1_052_000_000L, false)); -+ assertFalse(input.admitAirUse(1_052_000_000L, InteractionHand.MAIN_HAND, true, false, true)); ++ assertFalse(input.admitLegacyAirUse(1_052_000_000L, InteractionHand.MAIN_HAND, true, false, true)); + input.attemptedBlock(1_105_000_000L, true); + assertFalse(input.frameReady(1_404_999_999L, false)); + assertFalse(input.frameReady(1_405_000_000L, false)); + assertTrue(input.frameReady(1_405_000_001L, false)); + input.release(); -+ assertTrue(input.admitAirUse(1_500_000_000L, InteractionHand.MAIN_HAND, true, false, true)); ++ assertTrue(input.admitLegacyAirUse(1_500_000_000L, InteractionHand.MAIN_HAND, true, false, true)); + } + + @Test + void foodThatBecomesRefusedDuringAnAirHoldRetainsItsAirRouteAndDeadline() { + final var input = new ItemUseState(); -+ assertTrue(input.admitAirUse(1_000_000_000L, InteractionHand.MAIN_HAND, true, false, true)); -+ assertTrue(input.admitAirUse(1_201_000_000L, InteractionHand.MAIN_HAND, true, false, false)); ++ assertTrue(input.admitLegacyAirUse(1_000_000_000L, InteractionHand.MAIN_HAND, true, false, true)); ++ assertTrue(input.admitLegacyAirUse(1_201_000_000L, InteractionHand.MAIN_HAND, true, false, false)); + assertEquals(HitResult.Type.MISS, input.hitType(1_500_000_000L, HitResult.Type.BLOCK, InteractionHand.MAIN_HAND)); + assertFalse(input.frameReady(1_401_000_000L, false)); + assertTrue(input.frameReady(1_401_000_001L, false)); @@ -1322,12 +1598,12 @@ index 0000000..29a02ab + @Test + void rejectedBlockStartsUntimedFrameContinuationAndOnlyOneAirUse() { + final var input = new ItemUseState(); -+ assertTrue(input.admitAirUse(1_000_000_000L, InteractionHand.MAIN_HAND, false, true, false)); ++ assertTrue(input.admitLegacyAirUse(1_000_000_000L, InteractionHand.MAIN_HAND, false, true, false)); + input.attemptedBlock(1_001_000_000L, false); + assertTrue(input.blockActive()); + assertTrue(input.frameReady(1_016_000_000L, false)); -+ assertFalse(input.admitAirUse(1_016_000_000L, InteractionHand.MAIN_HAND, false, true, false)); -+ assertFalse(input.admitAirUse(1_016_000_000L, InteractionHand.OFF_HAND, true, false, true)); ++ assertFalse(input.admitLegacyAirUse(1_016_000_000L, InteractionHand.MAIN_HAND, false, true, false)); ++ assertFalse(input.admitLegacyAirUse(1_016_000_000L, InteractionHand.OFF_HAND, true, false, true)); + input.attemptedBlock(1_080_000_000L, false); + assertTrue(input.frameReady(1_096_000_000L, false)); + input.attemptedBlock(1_100_000_000L, true); @@ -1343,7 +1619,7 @@ index 0000000..29a02ab + assertFalse(input.placedBlock(1_350_000_000L, true)); + input.attemptedBlock(1_351_000_000L, true); + input.release(); -+ assertTrue(input.admitAirUse(1_400_000_000L, InteractionHand.MAIN_HAND, true, false, true)); ++ assertTrue(input.admitLegacyAirUse(1_400_000_000L, InteractionHand.MAIN_HAND, true, false, true)); + } + + @Test @@ -1352,7 +1628,7 @@ index 0000000..29a02ab + assertFalse(input.placedBlock(1_000_000_000L, false)); + input.attemptedBlock(1_001_000_000L, true); + input.release(); -+ assertTrue(input.admitAirUse(1_050_000_000L, InteractionHand.MAIN_HAND, true, false, true)); ++ assertTrue(input.admitLegacyAirUse(1_050_000_000L, InteractionHand.MAIN_HAND, true, false, true)); + } + + @Test @@ -1365,21 +1641,21 @@ index 0000000..29a02ab + input.attemptedBlock(1_173_000_000L, true); + assertFalse(input.frameReady(1_200_000_000L, false)); + input.release(); -+ assertTrue(input.admitAirUse(1_201_000_000L, InteractionHand.MAIN_HAND, true, false, true)); ++ assertTrue(input.admitLegacyAirUse(1_201_000_000L, InteractionHand.MAIN_HAND, true, false, true)); + } + + @Test + void freshAirSuppressionRetainsUntimedBlockContinuationUntilRelease() { + final var input = new ItemUseState(); -+ assertTrue(input.admitAirUse(1_000_000_000L, InteractionHand.MAIN_HAND, true, false, true)); ++ assertTrue(input.admitLegacyAirUse(1_000_000_000L, InteractionHand.MAIN_HAND, true, false, true)); + input.release(); -+ assertFalse(input.admitAirUse(1_172_000_000L, InteractionHand.MAIN_HAND, true, false, true)); ++ assertFalse(input.admitLegacyAirUse(1_172_000_000L, InteractionHand.MAIN_HAND, true, false, true)); + assertTrue(input.frameReady(1_173_000_000L, false)); -+ assertFalse(input.admitAirUse(1_201_000_000L, InteractionHand.MAIN_HAND, true, false, true)); ++ assertFalse(input.admitLegacyAirUse(1_201_000_000L, InteractionHand.MAIN_HAND, true, false, true)); + input.attemptedBlock(1_202_000_000L, false); + assertTrue(input.frameReady(1_203_000_000L, false)); + input.release(); -+ assertTrue(input.admitAirUse(1_204_000_000L, InteractionHand.MAIN_HAND, true, false, true)); ++ assertTrue(input.admitLegacyAirUse(1_204_000_000L, InteractionHand.MAIN_HAND, true, false, true)); + } + + @Test @@ -1388,10 +1664,10 @@ index 0000000..29a02ab + input.retainBlockUse(); + input.attemptedBlock(1_000_000_000L, false); + assertTrue(input.frameReady(1_001_000_000L, false)); -+ assertFalse(input.admitAirUse(1_002_000_000L, InteractionHand.MAIN_HAND, true, false, true)); ++ assertFalse(input.admitLegacyAirUse(1_002_000_000L, InteractionHand.MAIN_HAND, true, false, true)); + input.release(); + assertFalse(input.frameReady(1_003_000_000L, false)); -+ assertTrue(input.admitAirUse(1_010_000_000L, InteractionHand.MAIN_HAND, true, false, true)); ++ assertTrue(input.admitLegacyAirUse(1_010_000_000L, InteractionHand.MAIN_HAND, true, false, true)); + input.retainBlockUse(); + assertEquals(HitResult.Type.MISS, input.hitType(1_500_000_000L, HitResult.Type.BLOCK, InteractionHand.MAIN_HAND)); + assertFalse(input.frameReady(1_210_000_000L, false)); @@ -1401,15 +1677,15 @@ index 0000000..29a02ab + @Test + void freshBlockDispatchDoesNotBypassTheExistingEntityAndAirDeadline() { + final var input = new ItemUseState(); -+ assertTrue(input.admitAirUse(1_000_000_000L, InteractionHand.MAIN_HAND, true, false, true)); ++ assertTrue(input.admitLegacyAirUse(1_000_000_000L, InteractionHand.MAIN_HAND, true, false, true)); + input.release(); + assertEquals(HitResult.Type.BLOCK, input.hitType(1_172_000_000L, HitResult.Type.BLOCK, InteractionHand.MAIN_HAND)); + assertEquals(HitResult.Type.MISS, input.hitType(1_172_000_000L, HitResult.Type.ENTITY, InteractionHand.MAIN_HAND)); -+ assertFalse(input.admitAirUse(1_172_000_000L, InteractionHand.MAIN_HAND, true, false, true)); ++ assertFalse(input.admitLegacyAirUse(1_172_000_000L, InteractionHand.MAIN_HAND, true, false, true)); + input.release(); + assertEquals(HitResult.Type.MISS, input.hitType(1_200_000_000L, HitResult.Type.ENTITY, InteractionHand.MAIN_HAND)); + assertEquals(HitResult.Type.ENTITY, input.hitType(1_200_000_001L, HitResult.Type.ENTITY, InteractionHand.MAIN_HAND)); -+ assertTrue(input.admitAirUse(1_200_000_001L, InteractionHand.MAIN_HAND, true, false, true)); ++ assertTrue(input.admitLegacyAirUse(1_200_000_001L, InteractionHand.MAIN_HAND, true, false, true)); + } + + @Test @@ -1418,7 +1694,7 @@ index 0000000..29a02ab + final long start = Long.MAX_VALUE - 150_000_000L; + timing.attemptedBlock(start, true); + timing.release(); -+ assertTrue(timing.admitAirUse(start, InteractionHand.MAIN_HAND, true, false, true)); ++ assertTrue(timing.admitLegacyAirUse(start, InteractionHand.MAIN_HAND, true, false, true)); + assertFalse(timing.frameReady(start + 200_000_000L, false)); + assertTrue(timing.frameReady(start + 200_000_001L, false)); + timing.release(); diff --git a/patches/viafabricplus-bedrock/upstreamable/0038-predict-held-trapdoor-placement.pr.md b/patches/viafabricplus-bedrock/upstreamable/0038-predict-held-trapdoor-placement.pr.md index d86a8bfa..59571bdb 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0038-predict-held-trapdoor-placement.pr.md +++ b/patches/viafabricplus-bedrock/upstreamable/0038-predict-held-trapdoor-placement.pr.md @@ -3,13 +3,13 @@ Coordinate food retries and block placement through one client input owner. Inventory consumption and completion remain server-owned. -Food and the first block-item air phase share a strict 200 ms monotonic deadline. +Every admitted main-hand air phase records a strict 200 ms monotonic deadline. Release retains the air deadline, but fresh block dispatch does not wait for it. Fresh entity admission still waits for that deadline. A consumed-click call resets the previous mode, path, and core session. Tick and frame continuation retain that ownership. Empty-hand rejection also continues from frames, including a fresh press after stack exhaustion. -A suppressed initial food/block air phase keeps untimed block continuation until release. +A suppressed initial air phase keeps untimed block continuation until release. Frame repeats run after camera input and require an already owned mode. The queued click establishes initial ownership, so a frame cannot preempt it. Select food from its transported component, including custom items carried as paper. @@ -21,6 +21,7 @@ Already admitted air use retains its route after hunger fills or the selected it Block continuation bypasses the air deadline and cannot fall through to eating. Offhand fallback cannot take over an owned hold. Release and screens retire the mode; the connection retains its air deadline until replacement. +Use-key releases also stop the current local cycle through the ordinary game mode. Handle the key update before a repress can erase the release between game ticks. The frame refresh also checks the released state; an already stopped cycle sends no duplicate release. Missing optional block-use channels do not clear the air-use hold or deadline. Placement has a separate strict delay: 300 ms before a line is established and 200 ms afterward. @@ -52,6 +53,13 @@ The client retains format 2 for older cores, which cannot honor the new admissio Use the active Bedrock connection for native outlines and occlusion, including ViaProxy sessions with a local Java protocol target. The visible raycast remains unchanged. +For current item metadata, every admitted main-hand air callback records the strict 200 ms deadline before dispatch. +The initial continuation uses active native use or retained stack count and native item predicates, independently of the callback result. +A fresh press rejected by the clock retains block continuation and cannot start air use later during that hold. +The older-core fallback retains its previous behavior when native predicate metadata is absent. + +When negotiated, ordinary use also reports its actual local stack consumption before the concluding Java packet. The snapshot compares native item identity and quantity, including an emptied final stack. A cooldown-refused callback reports no consumption. This report does not select the air route or refresh its clock. + ## Evidence and validation Official Bedrock 1.26.60.5 and BDS 1.26.60.6 establish the selected behavior on protocol 2225. @@ -67,7 +75,7 @@ The cursor keeps its original world location, yielding a negative relative Z coo Player-collision failures retain the next cell. Returning to the old aim stops those attempts; a fresh press after release can use the top face. -Fourteen input-state regressions cover ownership, hand/target routing, initial rejection, clocks, last-stack placement, release, connection reset, and rollover. +Nineteen input-state regressions cover ownership, hand/target routing, projectile continuation, initial rejection, clocks, last-stack placement, release, connection reset, and rollover. Eight path regressions cover native float points, rejection, advancement, all six axes, release, line delays, precision limits, and failed-attempt history. Five trapdoor regressions cover orientation, materials, independent properties, targets, frozen hits, and ordinary blocks. Unit cases for all axes do not establish live native parity in every direction. @@ -82,21 +90,46 @@ Activation leaves air admission available, and the last stack creates no air dea The direct boundary supplement uses the same installed JAR as its full session. Both routes also retain the six timed/refused-food controls, three earlier hold-mode controls, release/Pause, and eleven food-admission cases. The [food-use validation](../../viabedrock/deferred/0008-signal-sustained-bedrock-item-use.pr.md) records admission and use-cycle limits. -The full four-project build passes 3,308 tests, with 325 skipped and no failures or errors. +The earlier four-project build and installed controls predate the projectile admission correction. Installed JAR checksums match the build. Earlier trapdoor fixtures cover camera and material changes, cancellation, reconciliation, and specific rejection histories. Fresh native and proxy sessions match repeated rejection followed by rotation without consumption. After a preceding successful hold, both can instead place a neighbor and consume one item. +Seventy scoped original admitted-air outcomes match the compiled Java state. +The controls supply component lookup and callback boundaries for original Ender Pearl and ComponentItem predicates. +A complete protocol-2225 journal checks seven registry definitions, including Snowball's constructor default and Wind Charge's network component. +Native local-world controls distinguish cooldown retries from a failed initial sword use. +A pearl hold retains air use after selecting Stone. Release permits fresh Stone placement. +A failed sword hold suppresses a newly selected pearl but permits later Stone placement. + +Installed direct and ViaProxy controls suppress an early fresh wind hold, admit an uninterrupted pearl-to-wind hold, and repeat pearl attempts through cooldown. They retain 64 Stone during a pearl hold and throw no pearl after a failed sword hold. Fresh Stone placement, food-to-Stone, and block-to-food retain their independent routes. + +Consumption-report controls cover late acceptance under a declared incoming delay and final pearl/wind-charge removal. Held-item counts agree with BDS after settlement. The [air-use prediction owner](../../viabedrock/deferred/0016-synchronize-glide-rockets-with-auth-input.pr.md) records the native action boundary, exact installed artifact scope, older-client quantity fallback, build results, and remaining limits. + +A native bread control releases after half a second and represses after 20 ms. It cancels the first cycle and restarts at the full duration. Earlier Java frame observations saw both key states in one game tick, retained 22 use ticks, and consumed one bread. The configured-key release correction stops that cycle before a repress. + +Current direct and ViaProxy clients each complete six food controls in a 120-second recording. Short holds, split holds, rapid pulses, and repressing after a completion match the selected native bread counts and hunger endpoints. Local callback observations verify one ordinary release per active key-up and no consumption during that release. Long controls retain normal consumption. The full add-on build passes 1,416 tests, with 154 skipped, including five software font GPU checks. The complete 44-patch replay matches the build. + ## Remaining limits Live build-height limits, moving placement, and air/entity target dispatch need more native comparisons. -Other initial use families, mixed-use state, and complete input phases also remain open. + +Older clients without consumption reports retain the quantity-publication fallback and its temporary count changes on rejected retries. +Exact release timing, conditional Education predicates, and the original persistent rollback remain unverified. + +The native projectile controls use an owned local world with an embedded observer, separate from the existing BDS recordings. +Controls without a debugger confirm the rejected hold, an admitted uninterrupted hold, and a widely separated pair. +The shorter separate-press controls do not consistently admit both projectiles, including a press scheduled after the deadline. +Debugger traces establish the admitted and rejected callback paths, without proving complete physical input timing. +No current native network comparison is claimed from failed NetherNet joins. + +Other initial use families, mixed-use state, and complete input phases remain open. An unchanged stack does not prove repeat-timing parity. Frame cadence changes retry and instant-food counts, so these controls do not establish equal coordinates or complete scheduling parity. A native fresh callback dispatches rejected block use about 27 ms before the air deadline, with no second air transaction. Short native input spans still miss some physical edges and do not establish full edge parity. Dynamic capability changes and legacy fallback need broader installed controls. -Short food presses while Java still owns an active use cycle need separate native comparisons. +The native 80 ms control has no start/stop callback, so matching endpoints do not establish its initial admission. Exact edge timing and other active-use families still need comparisons. The original intermittent animation-without-feeding report remains open. diff --git a/patches/viafabricplus-bedrock/upstreamable/0039-allow-food-use-on-bedrock-peaceful-difficulty.patch b/patches/viafabricplus-bedrock/upstreamable/0039-allow-food-use-on-bedrock-peaceful-difficulty.patch index a4ca87cb..b30e41c2 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0039-allow-food-use-on-bedrock-peaceful-difficulty.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0039-allow-food-use-on-bedrock-peaceful-difficulty.patch @@ -1,4 +1,4 @@ -From 7ba54cb5f0ffdb9cc9bf2bee9a28114fd840ab0d Mon Sep 17 00:00:00 2001 +From b4936d757d821475438adb454b55f73baeaa25e8 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:54:04 +0100 Subject: [PATCH] fix(items): allow food use on Bedrock Peaceful difficulty @@ -57,10 +57,10 @@ index 0000000..e8a7d8c + +} diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json -index 9091a1f..401ea35 100644 +index 50b7eab..0e91745 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -64,6 +64,7 @@ +@@ -65,6 +65,7 @@ "features.block.MixinAbstractCauldronBlock", "features.block.MixinBambooStalkBlock", "features.block.MixinBedBlock", diff --git a/patches/viafabricplus-bedrock/upstreamable/0039-apply-native-command-stacks-to-dry-air.patch b/patches/viafabricplus-bedrock/upstreamable/0039-apply-native-command-stacks-to-dry-air.patch index 6095e4f6..6c05407c 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0039-apply-native-command-stacks-to-dry-air.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0039-apply-native-command-stacks-to-dry-air.patch @@ -1,4 +1,4 @@ -From 7e06b3414a57324f466a2472e2616806960ce7d1 Mon Sep 17 00:00:00 2001 +From 82354df6384e0a49277da8df9164cf82fcafd463 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:28:02 +0100 Subject: [PATCH] feat(fog): apply native biome and command settings @@ -69,18 +69,18 @@ BREAKING CHANGE: The fog receiver requires the matching core payload version 2. create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/fog/FogSessionTest.java diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java b/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java -index 384a74d..8ed6c7a 100644 +index b26daac..df5d7d6 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java -@@ -70,6 +70,7 @@ import com.viaversion.viafabricplus.bedrock.input.BedrockInputPermissions; - import com.viaversion.viafabricplus.bedrock.input.InputPermissionsPayload; +@@ -71,6 +71,7 @@ import com.viaversion.viafabricplus.bedrock.input.InputPermissionsPayload; + import com.viaversion.viafabricplus.bedrock.input.ItemUseStopPayload; import com.viaversion.viafabricplus.bedrock.hud.HudVisibilityPayload; import com.viaversion.viafabricplus.bedrock.camera.CameraFadePayload; +import com.viaversion.viafabricplus.bedrock.render.fog.BedrockFog; import com.viaversion.viafabricplus.bedrock.camera.BedrockCameraFade; import com.viaversion.viafabricplus.bedrock.camera.BedrockCameraFov; import com.viaversion.viafabricplus.bedrock.camera.CameraFovPayload; -@@ -231,6 +232,7 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { +@@ -240,6 +241,7 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { ClientPlayConnectionEvents.INIT.register((listener, client) -> ClientPlayNetworking.registerReceiver(LocatorBarPayload.TYPE, (payload, context) -> BedrockLocatorBar.accept(listener.getConnection(), payload.snapshot()))); @@ -591,10 +591,10 @@ index 0000000..afd7b44 + texCoord0 = UV0; +} diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json -index 192d194..9091a1f 100644 +index 24c7ccc..50b7eab 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -140,6 +140,9 @@ +@@ -141,6 +141,9 @@ "features.render.MixinBedrockLightmap", "features.render.MixinBedrockLightmapPipelines", "features.render.MixinBedrockVisionFog", diff --git a/patches/viafabricplus-bedrock/upstreamable/0040-preserve-native-occlusion-with-moreculling.patch b/patches/viafabricplus-bedrock/upstreamable/0040-preserve-native-occlusion-with-moreculling.patch index b7b22b62..42984156 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0040-preserve-native-occlusion-with-moreculling.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0040-preserve-native-occlusion-with-moreculling.patch @@ -1,4 +1,4 @@ -From 5ce93d858e3b90aa435997f1e213d19fac505f48 Mon Sep 17 00:00:00 2001 +From 5d8a8d7c65eef42145c966a37b92805c81791644 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Mon, 2 Nov 2026 06:35:10 +0100 Subject: [PATCH] fix(blocks): preserve native occlusion with MoreCulling @@ -103,13 +103,13 @@ index 0000000..d65280d + } +} diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json -index 401ea35..a105991 100644 +index 0e91745..a0547ea 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -6,6 +6,7 @@ - "compatibilityLevel": "JAVA_25", +@@ -7,6 +7,7 @@ "priority": 1001, "client": [ + "features.screen.GuiGraphicsExtractorAccess", + "compat.moreculling.MixinMoreCullingOcclusion", "compat.emf.MixinEmfModelRoots", "features.render.MixinNativeFluidMesh", diff --git a/patches/viafabricplus-bedrock/upstreamable/0041-interpolate-native-remote-player-movement.patch b/patches/viafabricplus-bedrock/upstreamable/0041-interpolate-native-remote-player-movement.patch index ab5e6c4e..6913d6ad 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0041-interpolate-native-remote-player-movement.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0041-interpolate-native-remote-player-movement.patch @@ -1,4 +1,4 @@ -From cdf89c1075278330f3fc1eb9a40579f93644976b Mon Sep 17 00:00:00 2001 +From 7e67ed4c2cc4e542520e7a20a8f7864eb2525ea1 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:51:01 +0100 Subject: [PATCH] fix(players): interpolate negotiated remote movement as @@ -112,10 +112,10 @@ Debugger pauses exclude wall-clock timing from this comparison. create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/movement/RemotePlayerInterpolationTest.java diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java b/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java -index 8ed6c7a..131e784 100644 +index df5d7d6..f860f6a 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java -@@ -90,6 +90,7 @@ import com.viaversion.viafabricplus.bedrock.input.RawInputPayload; +@@ -91,6 +91,7 @@ import com.viaversion.viafabricplus.bedrock.input.RawInputPayload; import com.viaversion.viafabricplus.bedrock.input.BedrockItemUse; import com.viaversion.viafabricplus.bedrock.input.PlayerPredictionPayload; import com.viaversion.viafabricplus.bedrock.input.BedrockPlayerCorrections; @@ -123,14 +123,14 @@ index 8ed6c7a..131e784 100644 import com.viaversion.viafabricplus.bedrock.input.BedrockMovementEffects; import com.viaversion.viafabricplus.bedrock.input.BedrockVehiclePredictions; import com.viaversion.viafabricplus.bedrock.appearance.BedrockAppearanceStore; -@@ -217,6 +218,7 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { +@@ -218,6 +219,7 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { BedrockCommandBlockScreen.register(); PayloadTypeRegistry.serverboundPlay().register(PlayerPredictionPayload.TYPE, PlayerPredictionPayload.CODEC); BedrockPlayerCorrections.register(); + BedrockRemotePlayerMovement.register(); BedrockMovementEffects.register(); BedrockVehiclePredictions.register(); - PayloadTypeRegistry.clientboundPlay().register(InputPermissionsPayload.TYPE, InputPermissionsPayload.CODEC); + PayloadTypeRegistry.clientboundPlay().register(ItemUseStopPayload.TYPE, ItemUseStopPayload.CODEC); diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinRemotePlayerInterpolation.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinRemotePlayerInterpolation.java new file mode 100644 index 0000000..5836e51 @@ -779,10 +779,10 @@ index 0000000..a958a6d + } +} diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json -index a105991..ddd097f 100644 +index a0547ea..18a2d88 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -212,6 +212,9 @@ +@@ -215,6 +215,9 @@ "features.movement.MixinFireworkRocketEntity", "features.movement.MixinLocalPlayer", "features.movement.MixinPlayerCorrectionPacketListener", diff --git a/patches/viafabricplus-bedrock/upstreamable/0042-retain-bedrock-death-screen-lifetimes.patch b/patches/viafabricplus-bedrock/upstreamable/0042-retain-bedrock-death-screen-lifetimes.patch new file mode 100644 index 00000000..b85482f5 --- /dev/null +++ b/patches/viafabricplus-bedrock/upstreamable/0042-retain-bedrock-death-screen-lifetimes.patch @@ -0,0 +1,704 @@ +From c022f88cf32180fc4541ae0f769df863c98881be Mon Sep 17 00:00:00 2001 +From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> +Date: Thu, 5 Nov 2026 20:48:28 +0100 +Subject: [PATCH] fix(ui): retain Bedrock death screen lifetimes + +Update a retained death reason instead of re-entering the screen for each +DeathInfo or visibility-rule update. Keep its existing button deadline and +controls, and do not replace a confirmation or reopen the screen while a +respawn is pending. Adopt the local death timer's already-open screen. +Retire the retained screen when the player becomes alive, its object +changes, or its connection ends. Ignore stale connection updates. + +The active OreUI death controller in Bedrock 1.26.60.5/protocol 2225 reads +deathInfo as an observable view property. Its stage effects do not depend +on that text. Executing the original shipped controller confirms that +reason changes retain the button stage and pending respawn timers. Source +gameplay-6de8e340eddced55490b.js SHA-256: +9e7efe423b6cc8a1c1fa92756bd181675cffa51a4728bce386b0081cad1bb979. + +Share pending respawn requests between manual screen actions, Java's +automatic null-screen path, and the combat-kill handler. Send once, wait 500 milliseconds, then enter +the loading stage and retry once per second. Retain a pending manual +screen through Java's automatic re-entry and disable both actions. +Leaving the active route, becoming alive, or replacing the connection or +player cancels its timer. Stale player actions cannot start a request. + +The combat-kill handler must also retain the immediate-respawn lifetime. +An installed client previously sent another request 50 milliseconds after +the first when a late reason packet entered that handler. Route the event +through the same pending attempt before Java searches the entity index. +The original OreUI controller retains its initial wait through a reason +update in immediate mode, without another command. + +Executing the same original controller establishes the initial wait, +retry spacing, route cancellation and reason retention. Late callbacks +schedule their next wait from delivery; do not burst obsolete requests +after a client stall. These controls supply observable hooks, query and +command endpoints, and virtual time. They do not execute native network +actions or the visual component. + +Headless regressions exercise real Java death timers and pending command +counts through late text, automatic re-entry, delayed callbacks, active +route changes, and connection/player replacement. The presentation remains +Java. Native OreUI typography, layout, animation, loading presentation, +menu routing and complete lifecycle parity remain unfinished. +--- + .../features/screen/DeathScreenAccess.java | 24 ++ + .../screen/MixinBedrockAutomaticRespawn.java | 37 +++ + .../MixinBedrockDeathScreenUpdates.java | 54 ++++ + .../screen/MixinBedrockRespawnScreen.java | 38 +++ + .../death/BedrockDeathScreenUpdates.java | 134 ++++++++ + .../viafabricplus-bedrock.mixins.json | 4 + + .../death/BedrockDeathScreenUpdatesTest.java | 303 ++++++++++++++++++ + 7 files changed, 594 insertions(+) + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/screen/DeathScreenAccess.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/screen/MixinBedrockAutomaticRespawn.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/screen/MixinBedrockDeathScreenUpdates.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/screen/MixinBedrockRespawnScreen.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/death/BedrockDeathScreenUpdates.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/death/BedrockDeathScreenUpdatesTest.java + +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/screen/DeathScreenAccess.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/screen/DeathScreenAccess.java +new file mode 100644 +index 0000000..a6c8723 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/screen/DeathScreenAccess.java +@@ -0,0 +1,24 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package com.viaversion.viafabricplus.bedrock.injection.mixin.features.screen; ++ ++import java.util.List; ++import net.minecraft.client.gui.components.Button; ++import net.minecraft.client.gui.screens.DeathScreen; ++import net.minecraft.client.player.LocalPlayer; ++import net.minecraft.network.chat.Component; ++import org.spongepowered.asm.mixin.Mixin; ++import org.spongepowered.asm.mixin.Mutable; ++import org.spongepowered.asm.mixin.gen.Accessor; ++ ++@Mixin(DeathScreen.class) ++public interface DeathScreenAccess { ++ @Accessor("player") LocalPlayer bedrockDeathPlayer(); ++ @Accessor("hardcore") boolean bedrockDeathHardcore(); ++ @Accessor("causeOfDeath") Component bedrockDeathReason(); ++ @Mutable @Accessor("causeOfDeath") void bedrockDeathReason(Component reason); ++ @Accessor("exitButtons") List