diff --git a/docs/bedrock-development-sources.md b/docs/bedrock-development-sources.md index ca91ddf3..0b81f8d4 100644 --- a/docs/bedrock-development-sources.md +++ b/docs/bedrock-development-sources.md @@ -68,6 +68,7 @@ Read the local generated checkout before changing a patch. The links to `main` h | [ViaFabricPlus Bedrock add-on](https://github.com/ViaVersionAddons/viafabricplus-bedrock) | Check client integration, network hooks, rendering, and account UI. | | [ViaVersion](https://github.com/ViaVersion/ViaVersion) | Check the translation API and Java protocol mappings. | | [StackAnvil patch workflow](patch-workflow.md) | Edit the generated checkout and rebuild the patch series. | +| [Native cooking action reference](native-cooking-actions.md) | Check verified cooking dispatch, source, quantity, and item-return behavior and its remaining limits. | For a specific feature, search the local `protocol/packet`, `protocol/types`, and `protocol/data` directories. For maps, skins, login, and resource packs, start with `MapPackets`, `SkinType`, `LoginPackets`, and `ResourcePackPackets`. Some of these files are StackAnvil patches and do not exist on upstream `main`. diff --git a/docs/lobby-survey.md b/docs/lobby-survey.md index 039b12c3..7c51e6d8 100644 --- a/docs/lobby-survey.md +++ b/docs/lobby-survey.md @@ -371,3 +371,38 @@ All 26 recorded process identities closed normally across these four runs. Screenshots, packet journals, ray observations, and accepted packs remain private. The attempted video capture did not produce a valid recording. These survey results add reproductions, without a claim that the rendering defects are fixed. + + +## Galaxite block conversion follow-up, 22 October + +The saved Galaxite palette contains 3,661 custom states. +The old 2,629-carrier limit leaves 734 states mapped to coarse dirt. +The expanded pool provides 3,500 carriers and assigns every recorded state to 3,340 visuals. +The compiler also checks reusable visuals after reaching capacity. + +The same recording exposes 88 states with orphaned geometry bones. +The compiler now preserves their remaining local transforms and retains cycle rejection. +One block references geometry stored under `models/entity`. +Block geometry lookup now covers the complete `models` directory. + +Eight fancy-table states have vertices outside Java's cuboid range. +Core marks those elements, and the add-on preserves their authored coordinates during model loading. +Ordinary Java models retain their existing validation. +Malformed, non-finite, and oversized coordinates remain invalid. + +An independent comparison against Java 26.3 assets verifies 4,246 block-state selectors across 27 block types. +It checks 701 custom assignments and preserves every unassigned vanilla selector. +The full build passes 2,354 tests, with 152 optional fixture skips and no failures or errors. + +A fresh default-OpenGL Galaxite connection reaches a playable lobby and completes its 30-second capture. +Its journal includes spawn, player initialization, and movement acknowledgments. +The client reports no custom-block mapping, geometry-conversion, or model-loading errors. +The screenshot is partly occluded by another player. +This verifies scene loading and model acceptance, but does not establish complete native appearance parity. +A forced software-Vulkan attempt and a later longer run disconnect before spawn. +Their failures remain separate from the successful recording. + +The broader audit includes 29 saved public-server connections, with their recorded pack order and bundled native images. +Mob Maze still exceeds the expanded carrier capacity with its much larger declared palette. +Other retained failures include missing pack assets, unsupported material methods, and unresolved legacy custom visuals. +These remain active work, alongside live interaction checks and the earlier entity, UI, and item findings. diff --git a/docs/native-cooking-actions.md b/docs/native-cooking-actions.md new file mode 100644 index 00000000..aba5c6d6 --- /dev/null +++ b/docs/native-cooking-actions.md @@ -0,0 +1,1376 @@ +# Native cooking action reference + +This reference records verified behavior for contributors who implement authored furnace, smoker, and blast-furnace controls. +The core executes snapshot-bound primary recipe actions through native transfer batches. +The Fabric client receives cooking snapshots and progress, and it can route recipe pointer input in admitted authored scenes. +The default cooking program still uses ordinary Java controls because its remaining required controls are unfinished. +Complete authored control bindings, remaining input branches, and native parity remain unfinished. + +## Version and evidence + +The reference executable is Bedrock 1.26.51.1, build 51061372, protocol 2193. +Private fixtures execute original instructions with controlled menu data and explicit external boundaries. +Licensed binaries, captured data, and fixture outputs remain outside this repository. +The [Bedrock source guide](bedrock-development-sources.md) explains source selection and version checks. + +| Check | Cases or registrations | Established behavior | +| --- | ---: | --- | +| Transfer quantity decoder | 1,638 cases | Descriptor domains, signed bounds, byte narrowing, and rejected values | +| Cooking selection predicate and ingredient matcher | 2,160 cases | Ingredient matching, fallback access, and recipe-collection rejection | +| Base and cooking state registrations | 94 plus 7 registrations | Callback identities and registration order | +| Native state storage | 101 registrations | Higher priorities run first. Equal priorities retain registration order. | +| Selected-recipe release | 90 cases | Exit conditions, actual base-state reset, and handled result | +| Initial-state recipe dispatch | 1,620 cases | Command, held-item, source-permission, and controlled input-mode conditions | +| Registered recipe input delivery | 48 cases | Raw event fields select phases through original registrations and callbacks | +| Typed recipe event payloads | 576 cases | Integer index decoding and retained selection, command, source, and position fields | +| Recipe input result propagation | 288 plus 96 controls | Delivered phases retain their order; callback results combine with bitwise OR | +| Native event source resolution | 240 controls | Active-tab recipe sources through the original event callbacks | +| Installed cooking selection and release | 6 sequence steps | Real selection-only effects and release through the original input graph | +| Installed first recipe placement | 1 controlled click | Two native Place actions, retained destination identity, and final ingredient count | +| Repeated recipe placement | 30 input steps and snapshots | Capacity, selection clearing, return to player inventory, and placement after return | +| Predicted request payload identities | 5 complete payloads | Native identity callbacks assign request references after slot mutation | +| Owned request finalization and flush | 1 cooking payload plus an empty flush | Native ownership release, queue removal, packet construction, serialization, and cleanup | +| Isolated primary phases | 3 controls | Phase-zero transfers, phase-one-only rejection, and distinct retained controller states | +| Owned repeated selection | 6 event steps | Enabled prediction, selection toggle, post-placement history, return, refill, and a native flush after each input | +| Cooking item-return planner | 1,500 cases | Original-slot deficits and the remaining count passed to auto-placement | +| Physical-slot snapshot producer | 45 cases plus 2 copy controls | Provider traversal, empty slots, append behavior, vector growth, and ordinary item-copy ownership | +| Registered cooking providers | 3 ingredient descriptors | Eleven registrations, native model import, skip callbacks, and 76-slot capture order | +| Installed-provider history replacement | 7 cases plus 7 repeated-return controls | Inventory deficits precede hotbar deficits; each return replaces stored history | +| Installed ordinary source policies | 480 cases | Typed item locks and source count limits for hotbar and inventory | +| Installed ordinary transfer executor | 30 cases | Native permissions, owned slot mutation, request allocation, and Place-action assembly | +| Cooking return destination maps | 6 controls | Original initializer selects the combined inventory provider for all three ingredient descriptors | +| Owned cooking return execution | 24 cases plus 24 repeated-return controls | Native transfers, fallback placement, coherent provider views, and Place actions with an unresolved world target | + +These checks cover different boundaries. Their case counts do not measure native gameplay or visual parity. + +## Core recipe action backend + +The core consumes version-`4` cooking actions only after the client registers the action and result channels. +The connection must also support cooking snapshots and progress and use server-authoritative inventory. +Each action requires the exact presentation, current items, current recipe catalog, and an authored command on the displayed recipe cell. + +Primary selection follows the native selected-output entry before the ingredient predicate. +Clicking the retained output returns ingredient and output items and clears that selection. +A matching ingredient otherwise selects the output without adding more items. +The placement branch chooses native input alternatives and collects from fuel, hotbar, and inventory providers. +Output return captures the post-placement player history. Selection-only admission leaves history unchanged. The constructor starts with an empty history. +The existing Java recipe-book increment and maximum-quantity modes remain separate. + +The backend submits one request per transfer and retains intermediate predictions. +Its two-source action matches the original auto-scoped native packet byte for byte. +Regressions cover all three station providers, fuel sources, output return, physical aliases, inventory deficits, rejected authority, and submission failure. + +Follow-up event phases identify their original gesture and do not repeat physical transfers. +A release can retire that gesture after prediction changes the presentation snapshot. +Request sequences reject duplicate input, and menu generations protect replacement menus. +Intermediate Java-content publication waits until the action finishes. Selection highlights follow the current visible recipe order. + +The Fabric client has typed action and result transport with revision-bound gesture handling. +The default cooking program retains ordinary controls. Each input batch waits for its responses before another batch can start. +Native device equivalence, remaining device bindings, nonempty-cursor branches, cross-input prediction, and authoritative model watchers remain unfinished. +Acceptance on unchanged server-authored cooking screens and broader recipes also remains unverified. +The original full controller, model owner, and independent frame scopes also remain outside the native fixtures. + +## Cooking preference controls + +The cooking controller supplies the seven constants from the pinned publisher at `0x146c44040`. +Food, items, blocks, search, and inventory use indices `1..5`. Survival and recipe-book layouts use `1` and `2`. +The resolved toggle groups retain these indices through program export and import in all three cooking families. + +Supported tab, filtering, and layout toggles use their authored group, index, and checked state. +The pointer adapter accepts consumed primary mappings. Unsupported focus and device handlers retain ordinary controls. +Layout toggles use indices `1` and `2` and preserve the current tab and filter. Selecting the current layout sends nothing. +Forced-profile layout controls retain ordinary screens before accepting input. +Preference input requires its installed menu, the displayed snapshot, and an idle recipe gesture. +Tab and filter changes permit a carried stack. Layout changes still require an empty cursor. +The client waits for a newer matching model before accepting another input. +Capability loss, ambiguous transport, or a ten-second wait restores the exact original screen. + +Original callbacks `0x146c99520` and `0x146c99b40` pass 74 private controls on the pinned executable. +Tab events read the index at event offset `4`; a current-tab event returns without a settings refresh. +Filtering events read the checked byte at offset `8`, mark all five tab collections dirty, and request a refresh. +These fixtures supply initialized controller/model state and valid weak ownership. +Downstream tab/filter setters, collection rebuild, and settings refresh remain observed external boundaries. +A separate fixture runs 480 controls through the unchanged direct layout callback at `0x146c99560`. +It supplies layout, tab, the inventory-memory flag, input mode, event fields, and held-source validity. +The callback refreshes only after a layout change. It also updates a saved tab and conditionally clears held selection. +The preference adapter does not yet implement those internal states or their producers. +These controls do not establish native physical input, complete controller construction, or visual parity. + +## Recipe command dispatch + +The authored commands are `button.recipe_select`, `button.recipe_secondary`, and `button.recipe_tertiary`. +Their names identify input commands. They do not specify transfer quantities. + +The native controller starts in internal state `1`. Recipe selection uses state `25`. +These state numbers belong to the UI controller, not the network protocol. + +For valid recipe indices and input-mode field values `0`, `1`, or `3`, the initial-state fixture establishes these rules: + +- Primary command at event phase `0` enters recipe selection when the held item is empty and the source permits selection. +- Secondary and tertiary commands do not enter recipe selection in these cases. +- A nonempty held item prevents that primary transition. +- In selected-recipe state `25`, primary or secondary at phase `2` returns to state `1`. +- Other tested phases and the tertiary command leave selected-recipe state unchanged. + +The release transition executes the real base-state reset and returns handled bit `4`. +The fixtures do not establish physical input-mode mappings or how Java mouse events produce native event phases. +The mode-`2` focus path has separate controls below. Physical event producers and the complete recipe action pipeline still need verification. + +Cooking action and result payloads use version `5` and preserve the controller input mode, event phase, command, and snapshot identity. +Only phases `0`, `1`, and `2` are accepted. +Earlier versions `3` and `4` reject instead of receiving a default controller mode or phase. +Only the four verified controller modes, `0` through `3`, are accepted. +Replies echo the complete action, including its phase. +These payloads do not establish physical input mapping or activate authored cooking controls. + +The Fabric action session authorizes initial input from its resolved recipe scope. +Follow-up phases retain the original cell, command permissions, and snapshot identity after newer frames arrive. +The sender records pending input before transport, so immediate bridge replies cannot precede that registration. +Replies must match the complete pending action. Rejected or consumed authority requires a newer resolved frame before another gesture starts. +Menu replacement, capability withdrawal, ambiguous transport errors, and a ten-second response timeout retire the session. +These client rules do not establish native physical input bindings or cross-input prediction. + +The renderer admits authored recipe cells only through the scope from the same resolved frame. +Slot controls, close commands, and recipe cells share clipped painter order. +Unsupported mapping attributes, select/deselect constraints, and string booleans reject before the scene becomes actionable. +The Java pointer adapter maps primary and secondary presses to phase `0`, and their releases to phase `2`. +This adapter does not establish the target native physical event mapping. +The sender requires the exact installed native screen as well as its owned menu. + +A recipe press captures its button, recipe, and command until release, even after a newer scene removes that cell. +Captured drags, additional buttons, and duplicate releases cannot dispatch vanilla inventory input. +A terminal release retains the original authority after timeout or an ambiguous send. It sends at most once and cannot repeat transfers. +Fallback restores the exact original screen on the same connected menu after presentation retirement. +It cannot replace a later screen or a reused menu. + +Unsupported required controls keep the ordinary screen. +This includes required help, focus, and carried-item branches. +The pinned desktop search tab inherits an inline text field from `common.text_edit_control`. +The field appears inside `search_and_filter_panel`, whose visibility follows `#is_left_tab_search`. +An earlier resolved scene from another tab did not establish that the field was absent. +Production text input, search filtering, and the field's enabled binding remain unfinished. +Layout selection history and its profile/device branches also remain unfinished. +Tab and filtering controls also retain ordinary screens when their required focus or device mappings remain unsupported. +The pointer and fallback regressions use constructed authored scenes and genuine Java menus. +They do not establish native physical input, GPU output, or complete native UI parity. + +### Search edit callbacks + +The pinned furnace constructor registers edit callback `0x146c991a0` through closure `0x14e9b8a68`. +The callback reads `#property_field`, then reads the property named by that value. +The default inherited field names `#item_name`. +The callback replaces pending text at controller offset `0x12f8` and sets its optional flag at `0x1318`. +It returns zero and does not apply the query to the recipe model immediately. + +Controller tick `0x146c41570` applies pending text after its readiness flag at `0x12e9` becomes true. +It passes the latest text to model setter `0x14698af40`, then clears the pending value. +Multiple edits before that tick replace the pending value. +A second tick without another edit does not apply the query again. + +Clear callback `0x146c998a0` has a separate guard. +If controller flag `0x12f5` equals one and the active input mode equals two, it returns zero without clearing text. +Otherwise, it applies an empty query, discards pending text, and refreshes the model and settings. +This callback returns one even when the query was already empty. + +Executable controls cover 22 edit/tick cases and 16 clear cases on Bedrock 1.26.51.1, build 51061372, protocol 2193. +The edit cases include empty text, repeated edits, UTF-8 text, embedded NUL, and strings beyond the inherited field's 30-character limit. +These long strings test callback behavior, not physical field admission. +The controls supply property lookup, model setters, settings refresh, base tick, and the active-mode interface. +The original callbacks, pending string replacement, weak ownership, and complete controller tick execute unchanged. +Physical editing, field admission, complete controller construction, and visible parity remain unverified. + +### Search predicate + +The constructor for model `0x146985800` installs closure `0x14e9ab6f0` for search collection `18`. +That closure invokes predicate `0x1469c96b0`. +The predicate uses the normalized query at model offset `0x220`. +Without filtering, a failed eligibility check returns evaluation `3` before any name lookup. +With filtering, the predicate continues to the name and availability checks. + +An empty query does not require a name lookup. +A nonempty query first matches a normalized primary Item name as a contiguous substring. +If that match fails, the predicate obtains the secondary Item label and removes its formatting codes. +It normalizes that label, then repeats the substring match. +The ordinary secondary label includes enchantment text. +The predicate neither trims spaces nor splits the query into words. +A failed match returns evaluation `3`, regardless of the supplied availability result. +A successful match retains the availability result `0..3`. + +The 23 controls for normalization helper `0x140323bb0` match compatibility decomposition, default full case folding, and canonical composition. +Its string wrapper truncates the result at an embedded NUL. +The retained 23 normalization controls distinguish this behavior from lowercase conversion and the ICU NFKC-casefold shortcut. +For example, `STRASSE` matches `Straße`, and composed `CAFÉ` matches its decomposed spelling. +Zero-width spaces remain significant. +Formatting removal applies to the secondary-label branch, not the query. +The shared normalizer now uses public Unicode 5.0 data that matches every target table entry. +The shared API verification below retains its scope and production integration limits. + +Executable predicate controls cover 19 text cases across both filtering flags, both eligibility results, and four availability results. +All 304 captured results match the expected composition. +The original predicate, normalization helper, formatting removal, and Item empty/air guard execute unchanged. +The controls supply the primary name, secondary label, eligibility, availability, and model filtering interface. +The earlier runtime and external service boundaries also apply. +They do not establish actual name or label production, language selection, physical text input, or visible parity. +Production search input and filtering remain unfinished. + +### Primary Item names + +Primary-name helper `0x142489b10` selects between a custom name and the ordinary description. +For an ordinary Item, a compound `display` with a present `Name` selects the custom branch. +An empty or wrongly typed `Name` still selects that branch and produces empty primary text. +`FilteredName` remains separate from the primary search name. + +Without a custom name, the helper obtains the translated ordinary description. +ComponentItem getter `0x143fd2be0` uses its display-name component only when that component is nonempty. +An empty component retains the ordinary description. Whitespace remains a nonempty override. +The production definition accessor now follows this rule for both pack and network definitions. +An empty upper-pack definition replaces a lower override, then ordinary naming applies. + +Fresh native controls cover 1,299 name-selection cases, including typed NBT, component names, and appended text. +The controls execute the original primary selector, typed NBT lookups, ordinary description, ComponentItem getter, and three translation-key families. +Translation, formatting, component lookup, initialized runtime storage, allocation, and external CRT services remain supplied boundaries. +Other specialized key families, enchantment labels, actual language selection, and visible parity remain unverified by these controls. +Two production regressions cover network replacement and pack precedence. Both fail against the previous getter. + +Separate native controls cover 4,276 ordinary secondary-label cases through `0x1424d0020`. +They execute enchantment parsing, flattened entries, descriptions, signed levels, category ordering, and line composition. +Typed tags, locale translation, registry identifier interning, allocation, and runtime services remain supplied boundaries. +These controls establish the enchantment-label branch, not other specialized labels, actual language selection, or visible parity. + +### Projected client fixtures + +A separate private client fixture projects supported controls from real cooking scenes. +It removes unfinished widgets, unsupported mappings, and unsupported renderer leaves. +Projection retains all 39 inventory addresses, toggle identities, recipe metadata, and retained original mapping entries. +A private color marker distinguishes the projection from the ordinary Java panel. +The ordinary test driver waits twelve seconds before its first panel check to avoid interference with the controlled gestures. + +Actual installed screen handlers send fourteen preference changes and receive newer cooking models with the requested state. +Furnace controls change tabs and toggle filtering. Smoker and blast-furnace controls toggle filtering. All three families switch to the inventory layout and back. +These model updates confirm the core round trip; they do not establish Bedrock settings acknowledgment. +The handlers then send primary recipe presses and releases in all three families. +All six production replies match and accept the actions. BDS starts cooking with one real ingredient and an empty cursor after each placement. +The fixture restores ordinary controls, and the three normal cooking cases and same-world reconnect pass. +This establishes bounded live preference and primary paths through a controlled client scene. +It does not establish an unchanged server-authored screen, OS mouse translation, broader recipes and quantities, carried-item branches, or native reference pixels. + +Negative-index cases are internal controls. They do not authorize invalid recipe identities. + +Inherited container rules precede the cooking rule at equal priority. +The implementation must preserve that ordering instead of treating every recipe command as an immediate transfer. + +## Input event delivery + +The input fixture runs the original service registry, thread configuration, scheduler, and queue constructors before the base input constructor. +Eight original worker entries initialize their own readiness fields and reach condition waits. +The scheduler publishes the input queue's lifetime handle through its normal construction path. +The fixture supplies ordinary OS and CRT services, separate worker stacks and TLS, and a single-processor environment. +It does not supply game readiness flags or queue lifetime fields. + +The original base input constructor is `0x144816390`. +The fixture separately constructs the router with `0x144634ae0` and registers all three recipe commands through `0x144dd48c0`. +Original callback cloning, callback maps, the dispatcher at `0x144819aa0`, and recipe phase callbacks execute. +Authored source lookup supplies `recipe_food`; the focus accessor supplies false. +The router records its arguments and supplies a result instead of executing state transitions or recipe effects. + +For raw event kind `1`, bit zero of the byte at offset `0x28` delivers phase `0` first. +The two bytes at offsets `0x10` and `0x11` determine an additional phase: + +| Byte at `0x10` | Byte at `0x11` | Additional phase | +| ---: | ---: | --- | +| `0` | `0` | None | +| `0` | `1` | `1` | +| `1` | `0` | `2` | +| `1` | `1` | None | + +All 48 combinations of commands, byte values, and masks `0` through `3` pass. +The 288 result controls preserve this phase order for supplied router results `0`, `1`, `2`, `4`, `8`, and `255`. +Another 96 controls vary the two callback results independently. +The dispatcher combines their result bits with bitwise OR. +These raw fields do not establish physical mouse, keyboard, or controller meanings. + +The typed payload fixture parses JSON through the original JsonCpp reader and parser. +It passes 576 cases across twelve payload shapes and the same command and event combinations. +The callbacks accept tested integer `#collection_index` values from `-1` through `2147483647`. +Missing, null, Boolean, string, array, object, fractional, and integral-real values yield index `-1`. +For example, integer `7` survives; real `7.0` yields `-1`. +Index decoding does not establish whether a recipe provider contains that index. + +Phase `1` retains the decoded index, command, collection source, and numeric screen coordinates. +Phase `2` clears the retained index to `-1` before router delivery. +These checks cover the supplied `recipe_food` source and screen coordinates `11` and `13`. +The source lookup, focus value, raw events, and router results remain explicit fixture boundaries. +The fixtures do not establish physical input mapping, complete cooking-controller installation, recipe effects, wire requests, or live acceptance. + +## Resolve recipe event sources + +The original source accessor at `0x146c45d70` reads `#collection_name` from the event payload. +Its native tab map comes from initializer `0x146c9c340`. +The accessor resolves `recipe_book` through the active tab, except for tab `5`: + +| Tab | Event source for `recipe_book` | Slot descriptor source for `recipe_book` | +| ---: | --- | --- | +| `1` | `recipe_food` | `recipe_food` | +| `2` | `recipe_items_furnace` | `recipe_items_furnace` | +| `3` | `recipe_blocks` | `recipe_blocks` | +| `4` | `recipe_search` | `recipe_search` | +| `5` | `recipe_book` | `inventory_items` | + +The separate slot resolver at `0x146c46140` uses the map for every listed tab. +Event source resolution and slot descriptor resolution therefore differ at tab `5`. +Other tested collection names pass through the event accessor unchanged, including long custom names. +All 240 event controls run the original source accessor inside the registered phase callbacks. +Their focus accessor and router results remain supplied boundaries. + +Recipe authority also requires the exact displayed cell identity. +The shared scope validator matches each recipe ID against its index in the sorted, visible recipe collection. +An ID from another visible cell cannot replace that identity. +Filtering and category selection compact the visible indices before this comparison. +Missing, non-integer, negative, out-of-range, and mismatched indices reject the control. +The validator does not authorize an inventory slot from a recipe preview. + +## Selection and ingredient placement + +The selection predicate checks the physical ingredient first. +When the native air check rejects that item, the predicate reads the controller fallback. +It compares the item with the clicked output's accepted ingredient alternatives using full native item matching. + +A matching ingredient takes the selection-only branch. +That branch updates the selected output without invoking ingredient auto-placement. +The other branch can return previous ingredients and collect ingredients for the new selection. + +Action sources and availability sources differ: + +| Use | Native collection IDs | +| --- | --- | +| Ingredient auto-placement sources | Fuel `24`, hotbar `28`, inventory `29` | +| Availability sources | Ingredient `25`, fuel `24`, hotbar `28`, inventory `29`, and optional cursor `59` | + +The action source vector does not include the cursor. +Held-item handling belongs to the controller dispatch path. +The existing Java recipe-book planner has separate quantity and placement behavior. +It cannot establish native action parity. + +Six further controls connect raw input to the original cooking state graph and selection-only effects. +The fixture constructs the original cooking manager at `0x14c53c710` over installed providers and owned physical storage. +It supplies one concrete recipe with a matching five-item ingredient and one output item. +The original ingredient matcher, source lookup, selected Item assignment, and selected name lookup execute. + +For the primary command, raw flags `0,1` with mask `1` deliver phases `0` and `1` and leave state `25`. +The result is `1`, and the manager retains the selected output. +Raw flags `1,0` with mask `0` deliver phase `2`, return result `4`, and restore state `1`. +The selected output remains in the cooking manager after this release. +The tested secondary and tertiary sequences leave state `1` and return zero. +All six steps preserve the physical items and append no inventory action. + +These controls run the real router and selection callback instead of recording their intended effects. +The recipe cache, registry tree, world/recipe interfaces, raw events, and focus accessor remain supplied. +Full cooking-controller and model-owner construction, first placement, repeated selection, transactions, and live acceptance remain unverified by these controls. + +## Controlled first placement + +A separate fresh fixture connects the installed input graph to the original placement and request code. +It starts with seven matching items in hotbar slot `0`, five in inventory slot `9`, and an empty furnace ingredient slot. +A primary recipe click returns `1` and leaves state `25`. +The native code appends these Place actions: + +| Count | Source collection and slot | Source stack ID | Destination collection and slot | Destination stack ID | +| ---: | --- | ---: | --- | ---: | +| `7` | Hotbar `28`, slot `0` | `1000` | Furnace ingredient `25`, slot `0` | `0` | +| `5` | Inventory `29`, slot `9` | `1009` | Furnace ingredient `25`, slot `0` | `1000` | + +The native snapshot then contains twelve items in the ingredient slot and empty player slots. +The selected output remains in the cooking manager. +The unpredicted fixture retains the positive stack ID from the first source. +The enabled prediction path instead uses the negative request ID for that second destination. +This observation concerns native action objects before complete request serialization. +The [payload checks](#request-payload-identities) separately verify those IDs through native serialization. + +The fixture supplies consistent recipe caches, station data, and world interfaces. +It uses the original FurnaceModel vtable over controlled fields, without the complete model-owner constructor. +The original chunk lookup, native backing wrapper, destination guards, ingredient watcher, result lookup, transfer executor, and action append code execute. +The world interface supplies a controlled block-entity component rather than a constructed native furnace entity. +Other quantities, repeated selection, complete controller ownership, wire serialization, server acceptance, and visual parity remain unverified. + +## Repeat placement and return + +A fresh process also passes five six-step sequences through the original dispatcher, cooking graph, and placement code. +Each sequence presses and releases the same recipe three times. +The fixture supplies one concrete recipe and an Item with a maximum stack size of `64`. + +| Initial hotbar slot `0` | Initial inventory slot `9` | Ingredient count after the first press | Player items left after placement | +| ---: | ---: | ---: | ---: | +| `1` | `0` | `1` | `0` | +| `0` | `1` | `1` | `0` | +| `7` | `5` | `12` | `0` | +| `32` | `32` | `64` | `0` | +| `64` | `64` | `64` | `64` | + +The first press prefers hotbar sources and fills the ingredient slot to the available count or its capacity. +The second press returns those ingredients through combined player provider `12`, slot `0`. +That slot belongs to the hotbar, even when the first source was inventory slot `9`. +The selected-output entry clears the retained output and its name. +The third press places the returned items again and restores the selected output. + +Every press returns `1` and leaves state `25`. +Every release returns `4` and restores state `1` without another transfer. +The controls verify all thirty physical snapshots and conserve the supplied items. +They verify the shared hotbar, inventory, and combined-provider views independently. +Combined provider `12` aliases player inventory. It is distinct from cursor provider `59`. +The cursor remains empty throughout these sequences. + +Each sequence uses fresh native backing wrappers, a cooking manager, and a request. +Its six events share that open request, without request submission or server acknowledgments. +These controls establish repeated selection under that boundary, not the complete native frame and request lifecycle. +The first-placement fixture's recipe, world, model-owner, and external interface limits still apply. +Other recipes, Item definitions, input modes, complete serialization, server acceptance, and visual parity remain unverified. +The Java recipe-book planner's one-increment mode does not implement this authored primary action. + +Three fresh controls isolate primary event delivery with owned prediction and no explicitly opened request. +Phase `0` alone moves twelve ingredients through two requests, then returns to state `1`. +The original transition observer records `1` to `25`, then `25` to `1`. +Phase `1` alone moves nothing and remains in state `1`. +The combined phase `0`, then phase `1` input moves twelve ingredients once and retains state `25`. +Native flush serializes each nonempty queue and preserves its request references. +These controls supply the raw flags from the input-delivery table. They do not establish physical device mappings. +A primary command alone therefore does not establish a retained selected state. + +## Mode-dependent recipe focus + +The initial-state cursor rule does not describe every native recipe action path. +A separate focus path exists when the controller input-mode field is `2`. +The following controls use numeric field values without assigning physical device names. + +Two hundred eighty-eight controls execute three events each through the complete registered native graph. +They separate the recipe preview Item from the carried Item. +The controls vary modes `0` through `3`, both Item counts, source permission, two cell indices, and the input-device held flag. +The tested counts are `0`, `1`, and `64`. +Zero preview counts are internal negative controls; published recipe previews must remain nonempty. + +For mode `2`, primary phase `1` enters state `45` when the preview is nonempty and its source permits selection. +The actual entry callback `0x144e4d550` returns handled bit `2`. +It sets the focus flag and copies the source collection name and cell index into the controller. +This transition does not require an empty carried Item. +A subsequent primary phase `0` enters recipe-selection state `25`, including carried counts `1` and `64`. +Primary phase `2` then executes the real base reset and returns handled bit `4`. + +Modes `0`, `1`, and `3` do not take that focus transition in these controls. +Their direct primary press still requires an empty carried Item. +The initial-state recipe predicate `0x146c9a5b0` and focused-state recipe predicate `0x146c9a6c0` therefore have different cursor rules. +The focused-state predicate does not repeat the carried-item emptiness check. + +The controls execute original dispatcher `0x144637370`, transition `0x144637530`, all visited predicates, focus entry, and base reset. +All 101 previously captured registrations populate the native graph through original registration and storage functions. +Typed Items, input fields, provider flags, permissions, collection names, and device accessors remain supplied inputs. +Recipe-selection entry remains a recording boundary; these controls do not execute its placement or return actions. +Complete controller construction, physical event production, request serialization, server acceptance, and pixels remain outside this reference. + +Seventy-two additional controls execute the actual client mode getter `0x145dce1c0` and model accessor `0x145dcfb00`. +The original lookup `0x144353910` reads the mode from a map keyed by the active device ID. +They cover six device IDs, four stored mode values, missing entries, and both positions in a colliding bucket. +A missing entry or device ID `-1` returns mode `0`. +The active-device ID accessor is a supplied boundary; device registration and physical device interpretation remain unverified. +The original base-controller constructor stores this getter result at offset `0xc04`. +That constructor observation comes from instruction inspection, not complete constructor execution. + +Version-five actions carry the controller mode explicitly. +The core and client action sessions now admit an initial mode-`2` primary focus before the primary selection. +The core retains this permission within the current menu and opening. +Its release requires the original snapshot and controller context, including after newer frames arrive. +Capability withdrawal and menu replacement retire the permission. +Direct presses without focus retain their empty-carried-item check. + +The Java mouse adapter explicitly supplies mode `1`. +It does not infer mode `2` from a mouse event. +Physical mode-`2` input production, complete active-device integration, and carried-item screen controls remain unfinished. + +## Focused carried-item transfers + +Five fresh controls execute the focused branch through the original recipe effects and request serialization. +The carried slot has its own real backing and provider binding. +The actual held-slot getter `0x144dfcf00` resolves that cursor provider independently of the recipe preview. + +For mode `2`, carried counts `0`, `1`, and `64` pass focus, press, and release. +The native graph enters states `45`, `25`, and `1`, with handled results `2`, `1`, and `4`. +The press places seven hotbar ingredients and five inventory ingredients through two separate Place requests. +The second request uses the first negative request ID for the already changed destination. +Each case retains the original carried stack throughout all three events. +The carried controls in modes `1` and `3` produce no selection or requests. + +Original focus entry, selection, placement, return, provider mutation, request allocation, automatic scopes, flush, and serialization execute. +Typed controller and model fields, recipe/result caches, physical starting contents, and abstract world interfaces remain supplied inputs. +The client mode getter and no-local-player getter are supplied interfaces; native weak checks and creative-mode predicate `0x144923190` execute. +The [player-inventory binding reference](native-player-inventory.md#controller-state-and-collection-reference) establishes this predicate's meaning separately from the controller input mode. +The earlier external startup, OS/CRT, diagnostic text, and transport-sink boundaries still apply. +Full controller construction, physical event producers, server acknowledgments, and native pixels remain outside these controls. + +The production planner matches all three mode-`2` serialized request references. +Its prediction snapshots preserve the carried stack for every request. +The planner rechecks the player-UI contents and server revision after item metadata evaluation. +A changed cursor rejects the stale plan without restoring over the newer cursor. +Mandatory action tests cover mode-dependent admission, stale context, focus cancellation, capability loss, and menu reuse. +Client controls retain the captured context across newer frames, immediate replies, ambiguous transport, and terminal cancellation. +A late focus rejection cannot erase a newer active selection or strand its release. +These action changes do not replace the unfinished default carried-item screen controls. + +## Request payload identities + +The enabled prediction fixture executes the native client manager and its container identity callbacks. +The manager uses constructor flags `1,1` and client vtable `0x14e827ec0`. +The original constructor runs before a Level attaches and takes its normal null-Level branch. +The client subclass fields follow the inline initializer at `0x141336ca9`. +The complete Player constructor and negotiated flag provenance remain unverified. + +The original `ClientScreenData` component constructor runs through typed storage at `0x1425bdf10`. +A controlled registry contains one live entity and that original component storage. +The predicted-container constructor `0x1425ac0b0` installs identity callback `0x147c9ac00` and observer `0x147c9aa20`. +The component retains those containers through the original vector append function. +Full screen registration and the corresponding world lookup remain unverified. + +Five complete payloads cover one cooking click and four ordinary transfer controls. +The ordinary controls move partial or complete stacks into empty or occupied destinations. +Every changed source and destination receives the negative request ID. +This rule includes sources that become empty after a complete move. +Later actions in the same request use that request ID for each previously changed slot. +The first action still uses the original positive stack IDs or zero for an initially empty slot. + +Cooking placement uses hotbar provider `28` and inventory provider `29`, with physical slot indices. +Returns use combined player provider `12`. +These player providers share the same physical slot identities. +The Java planner tracks changed physical slots and substitutes request references during request assembly. +Its codec matches the complete native cooking payload byte for byte. +The optional comparison now reads `STACKANVIL_NATIVE_FINALIZED_REQUEST_WIRE` from the owned finalization fixture below. +Regressions also cover multiple ingredient returns, refill of an emptied ingredient slot, and rejection recovery. +Java recipe-book quantities remain separate from native authored primary selection. + +The earlier base-manager fixture used constructor flags `1,0` and wrappers without the native identity callback. +Those wrappers preserve positive IDs and clear empty slots to zero. +That fixture verifies serialization of unpredicted transfers, but it does not establish the enabled prediction behavior. +It cannot justify positive IDs for previously changed slots in production requests. + +Original conversion `0x14273fc70` turns owned requests into packet payload values. +The original wrapper `0x14273aab0` constructs the serialization context and calls packet writer `0x14273a2d0`. +Original BinaryStream methods write every byte and grow the buffer. +The fixture supplies the stream fields from native inline construction and a packet view with sender mode `5`. +Requests remain explicitly open during this comparison. +The open-request fixture does not execute finalization, the enclosing sender, or containing stream construction. +The payload excludes packet headers and transport framing. +Recipe data, world interfaces, and model-owner limits remain unchanged from the first-placement fixture. +These checks do not establish network exchange, server acceptance, full frame ownership, or complete authored cooking controls. + +## Owned request finalization and flush + +A fresh fixture uses the EntityRegistry that the original request-manager constructor creates and owns. +Original function `0x1425c0460` creates its live screen entity. +Original function `0x1425c06e0` constructs its weak reference and retains the registry ownership. +The original typed map inserts the ClientScreenData storage, and the original component constructor initializes its members and pages. +Typed storage fields and client subclass fields still follow controlled native inline layouts. +Full Player registration, component signals, and mode negotiation remain unverified. + +The fixture delivers the same two-source cooking input through the native prediction callbacks. +Original finish `0x1425b0cf0` calls detach `0x1425b0e50` and queues the owned request. +Detach clears the active request and weak reference, then releases exactly one weak count. +The registry retains one strong owner and its remaining weak references. +The request actions retain their IDs through finalization. + +Original flush `0x1425b1f70` converts the queued request and constructs the actual native packet with mode `5`. +It clears the queue and destroys its owned requests before the transport call. +The external transport sink receives that packet and calls the original serialization wrapper `0x14273aab0`. +The native writer and BinaryStream methods emit the same complete cooking payload as the open-request fixture. +Native flush then destroys the converted payload values. +A second flush with an empty queue makes no transport call. +The Java planner and codec match these finalized native bytes. + +The transport sink and BinaryStream fields remain supplied interfaces and data. +Screen container bindings use the original vector helper but remain controlled records. +The fixture starts its request explicitly before input. +Complete controller/model construction, physical input mapping, and the enclosing input/frame scope remain unverified. +The fixture does not execute transport framing, encryption, a network exchange, or server acceptance. +Authored cooking controls remain unfinished. + +A second fresh control installs an owned screen lease through native EntityContext move `0x1425c0540` and deque append `0x147c88220`. +The inherited input wrapper `0x144819a20` delivers the cooking event without an explicitly opened request. +Each transfer starts and finishes its own native request through the retained screen context. +The resulting queue contains two requests, and the second destination references the first request ID. +Native flush serializes both requests in one packet and preserves the reference counts. +The input-child vector remains empty, and the complete screen registration method does not execute. +An enclosing frame scope or deferred recipe component can change this batching. +This omission control therefore does not establish full application request grouping. + +The native configuration registry defaults `enable_client_container_simulation` to false. +Registration `0x140911d59` associates configuration ID `35` with the ContainerScreenSimulation description. +A fresh fixture reads that original registry and executes gate `0x1433f91d0` without changing its flag. +The gate returns false, and cooking initializer `0x14c53ded0` leaves its simulation helper absent. +This excludes that helper from the fixture's default path. Independent frame scopes and full controller construction remain unverified. + +## Reconcile ordinary transfer batches + +The core request tracker can retain separately scoped ordinary transfers as one pending batch. +Each request captures its own intermediate prediction before the next transfer changes the containers. +The sender preserves those captures when it writes the batch. + +The tracker buffers responses until every request receives a result, then processes them in request order. +It restores the oldest snapshots and replays accepted deltas before applying each server response. +A later rejection therefore preserves earlier accepted counts and confirmed stack IDs. +Server revisions protect newer contents, and menu generations protect replacement menus. +Each buffered response retains the server revision from its arrival. +Later contents supersede earlier buffered acknowledgments before reconciliation. +Queued clicks wait for the complete batch. A rejection discards them. +If packet submission throws, the tracker restores the snapshots and releases pending ownership. + +Targeted tests cover reversed and duplicate responses, partial rejection, complete rejection, newer server contents, and replacement menus. +Craft-result requests retain their separate synchronization path. +The primary cooking backend constructs and submits these transfers. +The Fabric input bindings and complete workstation controls remain unfinished. + +## Transfer quantities + +The shared `NativeTransferQuantity` decoder receives an available count after the executor resolves source validity and any maximum-stack lookup. +It narrows that count to an unsigned byte before applying the descriptor. + +| Kind | Value | Selected count | +| ---: | ---: | --- | +| `0` | Signed explicit count | Available count limited by the explicit count, with a minimum of zero | +| `1` | `0` | All available items | +| `1` | `1` | Half, rounded upward | +| `1` | `2` | One | +| `2` | `0` | All available items | +| `2` | `1` | One | + +Unknown kinds and unsupported values select zero. +At this internal boundary, the one-item modes return one even for an available count of zero. +The executor's empty-source guard precedes the decoder. +Permissions, actual source count, destination capacity, mutation, and request encoding remain separate steps. + +## Return previous ingredients + +The return planner first examines stored source snapshots for hotbar and inventory slots. +It requires a matching stored item and either a compatible current destination or an empty destination. +It restores the deficit between the stored count and the current destination count, limited by the remaining ingredient count. +It then passes the remaining ingredient to generic auto-placement with an all-items descriptor. + +The return fixture supplies transfer effects at explicit capacities and records the generic auto-placement call. +It does not execute generic auto-placement. +The installed-provider fixture below separately establishes capture order and history replacement with supplied transfer effects. +The owned-backing fixture below executes cooking-source transfers and generic auto-placement with an unresolved world target. +The complete recipe-selection action pipeline remains unverified. + +## Capture physical slots + +The complete native snapshot producer is `0x1433f7c60`. +It traverses the manager's provider entries and locks each available provider reference. +A nonzero result from the provider's skip callback excludes that provider. +The callback's broader meaning remains unverified. + +For each included provider, capture every slot in ascending index order, including empty slots. +Each entry contains an independent item copy, collection key, slot index, comparison flag, and empty flag. +The producer initializes the comparison flag to false and derives the empty flag from the source count. +It appends to the supplied vector and preserves existing entries. +The return helper separately destroys and clears its previous entries before requesting the next capture. + +The fixture supplies provider traversal order, skip results, sizes, and item accessors. +It executes the original producer, item constructors, copies, vector growth, and entry destruction. +Real native shared ownership supplies reference locking and release. +All 45 cases pass and restore the checked ownership counts. +Cases include long collection keys, full 36-slot player captures, and aligned vector growth. +Actual provider registration order and complete controller construction remain outside this probe. + +Two additional controls copy ordinary non-block items with zero count, both with and without retained source identity. +Both copies clear the Item identity and auxiliary value. +These controls do not cover populated userdata or the separate associated-block wildcard constructor path. +Do not generalize them into an unconditional rule for every empty stack field. + +The selection callback at `0x146c46900` also captures separate before and after vectors. +It passes copied vectors to comparator `0x1450d98b0` through its controller callback. +The 45-case probe does not execute that callback or comparator. +It does not establish history filtering or the complete history lifecycle. +Keep snapshot capture, history replacement, and selection effects as distinct verification boundaries. + +## Registered providers and history replacement + +The registration slice at `0x146985800` through `0x1469867e1` creates eleven providers. +It registers cursor, hotbar, inventory, combined inventory, ingredient, fuel, and output providers first. +Food, block, furnace-item, and search recipe providers follow. +The generic manager constructor at `0x1433e3ac0` imports these providers through native model construction and map insertion. + +Three controls use ingredient descriptors `25`, `45`, and `46` for furnace, blast furnace, and smoker providers. +Their original skip callbacks exclude all four recipe providers from physical capture. +Original size and item accessors produce the same ordered 76-slot snapshot in each control: + +| Collection | Captured slots | +| --- | ---: | +| `inventory_items` | 27 | +| `cursor_items` | 1 | +| `hotbar_items` | 9 | +| `furnace_output_items` | 1 | +| `combined_hotbar_and_inventory_items` | 36 | +| `furnace_ingredient_items` | 1 | +| `furnace_fuel_items` | 1 | + +The combined view appears alongside the separate inventory and hotbar views. +These 76 entries do not represent 76 distinct physical storage slots. +Capture follows the manager's map traversal, which differs from provider registration order. +Preserve the captured order when examining stored return destinations. + +Seven base-furnace controls then execute the original capture and return helper with owned item caches. +The stored inventory slot starts with five items; the hotbar slot starts with seven. +Before return, their current counts are one and two. +At ingredient counts `0`, `1`, `4`, `5`, `9`, `10`, and `64`, return restores the inventory deficit before the hotbar deficit. +Generic auto-placement receives the remaining ingredient count and the all-items descriptor. + +The helper destroys the previous history entries and captures the current slots after each return. +Seven repeated-return controls observe this replacement history and request no further deficit restoration. +They retain the current ingredient remainder, including zero, for the recorded generic auto-placement call. +Copied-item ownership counts return to their baseline after snapshot and cache cleanup. + +These controls supply collection names from a separately verified native registry fixture. +They also supply a game inventory size of 36, an abstract Level, and a live empty recipe service. +These controls supply owned provider caches and replace the cache-reset function. +The original provider constructors, registration, generic manager import, accessors, capture, full item queries, and history replacement execute. + +Transfer effects remain an explicit count-mutation boundary with capacity 64. +Generic auto-placement records its inputs without moving items. +The complete cooking subclass, enclosing selection callbacks, physical input mapping, transfer permissions, transaction assembly, and live acceptance remain unverified. +The variant controls cover empty capture order; they do not execute blast-furnace or smoker returns. + +## Installed ordinary transfer execution + +The ordinary transfer fixture binds original hotbar and inventory providers to a constructed native backing container. +The backing constructor is `0x147c96d60`; provider binding uses `0x143434050`. +The standard policy installer at `0x1431afb60` registers their slot ranges and source filters. +Construction and binding alone leave the source-validity map empty, so the executor rejects transfers. + +The complete source check at `0x147c98300` then passes 480 controlled cases. +These cases vary source count, requested quantity, absent userdata, empty compounds, and ByteTag, ShortTag, and IntTag lock values. +Both generic and typed NBT lookups execute original instructions over supplied compound trees. + +For hotbar and inventory sources, a ByteTag `minecraft:item_lock` value of `1` prevents removal. +Values `0`, `2`, `127`, `128`, and `255` permit removal when the source count covers the requested quantity. +Missing tags and tested non-ByteTag values also permit removal. +Cooking ingredient selection uses a separate rule that rejects every nonzero ByteTag lock value. +Keep these permission rules separate. + +Thirty additional cases execute the original transfer executor at `0x1433e8380` from hotbar slot zero to inventory slot zero. +They cover empty and compatible occupied destinations, explicit quantities `0`, `1`, and `3`, and five source-lock states. +The lock states are absent userdata and ByteTag values `0`, `1`, `2`, and `255`. +Rejected or zero-quantity moves leave both slots unchanged and append no action. +Accepted moves update backing storage and provider caches and append the expected Place action. + +The native request manager constructor at `0x1425aa270` receives fixture flags `1` and `0`. +The original request-start function allocates negative request IDs and constructs each current request. +Original descriptor construction, permission checks, ordinary item merging, owned item copies, request validation, and action append execute together. +The fixture checks transfer counts, collection IDs, physical slot indices, and supplied stack-network IDs in the resulting actions. +A fresh process reproduces all 480 policy cases and 30 executor cases. + +These fixtures supply a generic Item definition, synchronized initial slot values, numeric stack-network IDs, and compound trees. +They also supply storage read/size interfaces, an abstract game context, and an empty notification subscriber list. +Three diagnostic string-formatting functions return empty strings. +The request-manager flag combination omits its separate world-subscription path. +Provider content synchronization remains a supplied boundary; the combined inventory view is outside these transfer cases. +No transfer, permission, merge, item-copy, request-allocation, request-validation, or action-append function is substituted. + +These results cover hotbar and inventory sources. +The server-menu helper starts from `container_items`, which has separate source-policy registration. +Cooking-source transfers, generic auto-placement, full controller input, wire serialization, server acceptance, and graphical behavior remain outside these ordinary-transfer fixtures. + +## Owned cooking return execution + +A separate fixture gives each backing container its own physical Item objects. +The original binding code fills the separate inventory, hotbar, combined inventory, ingredient, fuel, and output caches. +The cache-reset function at `0x143443d00` executes unchanged. +Transfers update the combined and separate views through original native callbacks. + +The complete cooking destination-map initializer at `0x146c44e80` supplies the return destination vector. +Six controls cover ingredient descriptors `25`, `45`, and `46` with raw controller field `0x12bc` values `0` and `1`. +Every ingredient return vector contains one `combined_hotbar_and_inventory_items` entry with its flag set to false. +These controls do not establish physical input-mode mappings or complete controller construction. + +Three fresh processes execute eight return cases each, one process per ingredient descriptor. +The first seven cases use ingredient counts `0`, `1`, `4`, `5`, `9`, `10`, and `64`. +Stored inventory and hotbar counts are five and seven; current counts are one and two. +The original history-return helper restores the inventory deficit before the hotbar deficit. +It then executes generic auto-placement into the combined inventory provider. +A count of 64 produces Place actions for four inventory items, five hotbar items, and 55 remaining items. + +The eighth case checks occupied-stack capacity and empty-slot placement. +Stored inventory and hotbar counts are 64 and 63; current counts are 63 and 60. +Returning 64 ingredients produces four Place actions: inventory one, hotbar three, combined hotbar one, and combined empty-slot 59. +The final inventory stack and first hotbar stack each contain 64 items. +The next hotbar slot contains 59 items. + +The fixtures check collection descriptors, physical slot indices, counts, and stack-network IDs in the native Place actions. +The ingredient source retains its supplied positive stack-network ID across these actions. +The new empty destination uses stack-network ID zero. +All separate and combined inventory views agree after each return. +Twenty-four repeated-return controls append no further actions and preserve the replacement history. + +Original provider registration, backing construction, binding, cache reset, policies, history capture, return, generic auto-placement, and transfer execution run together. +Request-manager construction, negative request-ID allocation, Place construction, validation, and append also execute original instructions. +The fixtures do not substitute these functions. + +Initial physical slots, incoming authoritative values, a generic Item definition, numeric stack-network IDs, and collection-name lookup remain supplied inputs. +Original binding refreshes caches after the supplied authoritative changes. +Abstract Dimension and BlockSource interfaces exercise the unresolved-world-target branch with no loaded chunk. +These checks do not cover a loaded furnace block entity or its world updates. +An empty notification subscriber list, shared world ownership, and three diagnostic string formatters are also supplied. +Full cooking-manager and controller construction, recipe selection, input delivery, wire serialization, server acceptance, and graphical behavior remain unverified. + +## Resolve controller captions + +Cooking snapshots retain native tab-caption keys. +The search tab uses the all-recipes key when filtering is disabled. +When filtering is enabled, it uses the furnace or smoker key. +The inventory tab uses the shared crafting survival key. + +Program export collects translations from the current tab label and authoritative title. +This preserves computed controller captions that do not appear as literals in the UI declarations. +Preference changes advance the model update identity while retaining the opening and item authority. + +Sixty original native getter controls bind the caption rules. +Complete scene and action-scope round trips verify translation export for all three station families. +A controlled client fixture verifies fourteen model caption keys and eleven visible caption draws. +All three cooking cases and same-world reconnect pass with the rebuilt artifacts. +The fixture removes unfinished controls and mappings. +Native fallback title acquisition, physical input, and complete controller initialization remain separate work. + +## Implementation boundaries + +The [shared UI patch](../patches/viabedrock/upstreamable/0095-share-native-ui-programs-and-resources.patch) owns the cooking messages, scene contracts, and presentation authority. +The [inventory request patch](../patches/viabedrock/deferred/0003-translate-server-authoritative-bedrock-requests.patch) owns request prediction, response reconciliation, and transfer quantities. + +Remaining production work includes complete controller initialization, remaining input branches, selection-history invalidation, loaded world targets, and authored default controls. +The native instruction fixtures cover individual boundaries. Full controller construction requires separate evidence. +Native visual comparisons remain separate from these instruction-level checks. + +## Carried stacks on supported cooking screens + +Supported cooking scenes retain their installed screen when the real menu cursor changes. +Ordinary slot input and Java carried-item extraction keep their existing owners. +The screen no longer falls back merely because the player carries a stack. +Direct mouse recipe presses still require an empty cursor and consume their captured release without ordinary inventory input. + +Complete native tab and filter callbacks `0x146c99520` and `0x146c99b40` contain no held-item admission check. +The adapter now permits those preferences while carrying, with the same idle-gesture and newer-model checks. +Downstream native model setters remain supplied boundaries in the callback fixtures. +Layout changes retain their carried-item guard because saved-tab and held-selection behavior remains unfinished. + +Three new screen regressions fail before the correction and pass after it. +They cover counts `1` and `64`, preparation and refresh, newer snapshots, recipe suppression and resumption, and preference acknowledgment. +Separate negative controls restore the old preference guard or remove the mouse recipe guard. Each breaks the relevant behavioral regression. +The focused client suite passes all 34 tests without skips. +The four-project build passes 2,626 tests, with 163 optional skips and no failures or errors. +All 104 core patches and 38 client patches replay. +The 1,413 core classes match both packaged copies. All 1,139 production client classes match the add-on. + + +A fresh BDS 1.26.51.1 route passes furnace, smoker, and blast-furnace controls with one supplied ingredient each. +Actual screen handlers pick up that real stack, retain the screen, suppress a held recipe gesture, and return the unchanged stack. +The cooking model advances through both cursor transitions. Ordinary request responses do not increment the server revision counter. +Normal recipe input then receives all six matching press/release acknowledgments, and BDS starts cooking in each family. +All three ordinary follow-up cases and same-world reconnect pass with the expected final item counts. + +The private scene projection retains all 39 real slots and recipe metadata while removing unfinished controls and mappings. +Both runtime inventories match 2,551 project class resources and four data resources. +Normal cleanup removes the observer, restores the temporary six-second driver delay, and leaves no managed-profile JVM. +Two earlier failed fixture attempts remain archived separately. They assumed a spare ingredient-independent stack and a response-driven server revision. +This route does not establish carried tab/filter input, stack counts beyond one, unchanged authored screens, native opacity, or native pixels. + +This change does not complete carried-item opacity, native device production, default search/help controls, layout history, or visual parity. + +## Shared cooking search APIs + +The shared UI patch now exports the query matcher, target normalizer, and raw edit-coalescing state. +These APIs do not register a channel or replace the default cooking screen. +The production menu still publishes an empty query until name producers, transport, and widget integration are complete. + +`CookingRecipeSearch` normalizes the query once and requests primary names lazily. +A primary match skips the secondary-label producer. An empty query skips both producers. +The secondary branch removes native formatting codes before normalization. +The matcher does not trim spaces, split words, or substitute Java preview names. + +`CookingSearchText` retains the latest pending raw string until a ready tick. +Equal edits still apply. An empty string remains a pending edit until that tick. +A guarded mode-two clear preserves applied and pending text. Other clear events discard pending text and apply an empty value. +The caller remains responsible for model-clear effects, settings refresh, physical field admission, and exact menu ownership. +Repeated native eligibility checks and full recipe-predicate state remain outside these APIs. + +The target uses utf8proc 1.1.5 tables for Unicode 5.0. +All 4,352 first-stage entries, 23,296 second-stage entries, 5,618 property records, and 22,500 composition entries match the immutable target data. +The data generator uses the checksum-pinned public source and retains its MIT and Unicode notices. +Its 45,618-byte compressed resource reproduces exactly. An altered public source fails before an existing output changes. + +Fresh comparisons cover 17,987 executions of the original native normalizer and 1,122,310 production Java inputs against the licensed public algorithm. +Both comparisons report zero differences. The Java cases include every code point, invalid encodings, NUL handling, and mixed composition sequences. +Eight focused tests pass without skips. Retained native controls cover 304 matching compositions, 22 edit/tick cases, and 16 clear cases. +Those fixtures supply names, labels, model interfaces, and caller effects. They do not establish production name acquisition or physical editing. +Four deliberate regressions fail: unnormalized queries, query formatting removal, early edits, and pending-text loss after a guarded clear. + +All four projects build with 2,634 passing tests, 163 optional skips, and no failures or errors. +The core and client stacks replay all 104 and 38 patches. +All eight search tests run without skips in this build. +The final packaged normalizer repeats the 1,122,310-input comparison with zero differences. +Its class and resource load from the verified JAR. All 1,417 core classes match both packaged copies. +The generated data and license notices also match across the standalone core, embedded core, and proxy. +All 1,139 production client classes match the add-on. + +## Preserve native naming input bytes + +Production NBT decoding previously replaced malformed UTF-8 before native name matching or item-data equality. +Distinct names could become equal, and distinct compound keys could collapse. +The native NBT scalar patch now retains those bytes in both binary and network-NBT strings. +Copies and Java NBT transit preserve the byte identity. Ordinary packet text keeps its existing decoder. + +Fresh native binary userdata controls preserve raw keys, nested names, and string lists in 340 inputs and 680 directional comparisons. +The controls execute the original constructors, loader, NBT parser, and comparator with declared registry and runtime boundaries. +They do not establish outer packet decoding, visible text, or live malformed-NBT acceptance. +All six focused string tests pass. Five deliberate stream or surrogate-pair regressions fail. +The [NBT scalar notes](../patches/viabedrock/upstreamable/0101-normalize-native-nbt-floating-values.pr.md) retain the source and fixture limits. + +The separate factory capture now completes with 1,949 unique constructor-owned definitions, 36 key families, and seven secondary-label families. +Its 2,627 static cleanup callbacks complete, with no remaining owned allocations or locks. +`written_book` overrides the custom-name presence predicate. A separate getter returns its custom name or title. +These pre-packet facts do not establish later component overrides, actual language selection, or complete production name dispatch. +Search names, menu-owned transport, the authored field, and full predicate integration remain unfinished. + +The full four-project build passes 2,640 tests, with 163 optional skips and no failures or errors. +The core and client stacks replay all 104 and 38 patches. +All 35 focused NBT scalar/string checks run without skips. +All 1,418 core classes match both packaged copies. All 1,139 production client classes match the add-on. +The packaged core passes the six string checks with verified class origins. +It rejects merging distinct raw names, accepts an identical raw copy, and retains malformed input for the search matcher. + +The unmodified ordinary BDS 1.26.51.1 route passes furnace, smoker, blast furnace, and same-world reconnect. +Final item counts match all three cases. Installed add-on and running proxy JARs match the current build. +Normal cleanup stops the private display and leaves no managed-profile JVM or private observer. +This route does not inject malformed NBT or establish native authored-screen or pixel parity. + + +## Unfiltered item-name selection + +The shared `NativeItemNames` API now selects unfiltered custom names or a definition-owned ordinary name. +A present `display.Name` selects custom text even when its type is wrong or its value is empty. +Native written books use `title` when that display name is empty. +A present, wrongly typed title selects empty text. Filtered fields do not replace the unfiltered primary name. + +The original executable controls run the complete primary selector, ordinary append path, and both custom-name implementations. +They retain 1,299 base, component, coal, and append cases, plus 1,000 custom-name and written-book cases. +The shared selector matches all 2,286 applicable rows. The 13 ordinary-key controls remain separate. +Three deliberate regressions fail by assertion: removing title fallback, requiring typed custom-name presence, and accepting a non-byte append flag. + +The ordinary branch applies appended text only for a nonzero `ByteTag` named `AppendCustomName`. +A nonempty `GroupName` takes precedence over `BodyID` and `CustomName`. +The body formatter receives both translated colors only when both source strings are nonempty. +Otherwise it receives one translated first-color argument, including an empty argument. +Custom names skip ordinary-name production and all append callbacks. Missing definitions skip every name producer. + +The item registry now owns custom-name dispatch in Java item conversion. +Its constructed factory class selects written-book behavior, independently of later network flags. +Component replacements retain base custom-name behavior. Removed runtime-ID owners cannot acquire a custom name. +The focused selector and registry run passes 19 tests without skips. +This fixes empty-display-name title fallback in production. It does not establish native tooltip layout or complete cooking search. + +A fresh factory capture records the actual name methods for all 1,949 definitions. +It includes description, key, ordinary-name, custom-name, presence, and secondary-label dispatch. +All definitions share the same ordinary-name method. Only `written_book` overrides custom-name presence and retrieval. +The observer also records 71 class-owned variants and 90 spawn-egg name triples within verified allocation bounds. +All 2,627 static cleanup callbacks complete, with no remaining owned allocations or locks. +These are pre-packet constructor facts. Later component updates, actual language ownership, all ordinary-key providers, and secondary labels remain unfinished. + + +The current four-project build passes 2,644 tests, with 163 optional skips and no failures or errors. +All 104 core patches and 38 client patches replay. All three selector tests run without skips. +All 1,419 core classes match the embedded add-on and proxy copies. All 1,139 client classes match the add-on. +Four focused packaged checks pass, including the registry's book-class dispatch. +The packaged selector matches all 2,286 applicable native rows. Production class origins are verified against the current standalone core JAR. +This checkpoint does not establish full cooking search or native screen and pixel parity. + + +## Ordinary-name component updates + +The native display-name reader retains its current value when an update omits the component or supplies the wrong type. +This also applies to missing or wrongly typed `value` fields. A valid empty string clears the override. +A valid nonempty string replaces it, including whitespace. The native component getter falls back to its stored description for an empty override. + +The earlier network-reader fix preserved that field across unrelated updates. +The registry-owned component description path now owns this behavior and the construction phases described below. +The superseded network-name reader and its three tests have been removed. All thirteen native results run through the registry checks. + +Fresh original-executable controls construct 29 ordered ItemData updates for 13 actual ComponentItem definitions. +They use the native populated vector, registry initializer, named ItemStack constructor, description getter, and key method. +The real getters match all 13 Java results. All 2,630 static cleanup callbacks complete without owned allocation or lock leaks. +The controls use authored inputs. They do not establish normal packet decoding, native pack/client precedence, or complete language ownership. + +A separate native block probe queries all 1,379 constructed default states through the actual block-name getter. +It records nine Legacy key families. In 140 cases, the result differs from the stored description plus `.name`. +Examples include skulls, fences, planks, seagrass, elements, and brewing stands. +All 2,628 static cleanup callbacks complete without owned allocation or lock leaks. +These are defaults under the declared bootstrap, version, and Experiments control. +Element names use an independent registry lookup that remains unresolved in this control. Its unknown results are not a universal name baseline. +State-dependent keys, all ordinary-name providers, secondary labels, language ownership, and complete cooking search remain unfinished. + + +The current four-project build passes 2,646 tests, with 163 optional skips and no failures or errors. +All 104 core and 38 client patches replay. All six item-definition tests run without skips. +The packaged reader passes the same six tests, with five production class origins verified against the current core JAR. +All 1,419 core classes match both packaged copies. All 1,139 production client classes match the add-on. +Tooling, offline tests, and patch metadata checks pass. + + +Builtin override controls also distinguish wire runtime IDs from native internal item IDs. +Passing an authored changed wire ID directly to the native stack constructor can select a different builtin item. +That result cannot prove the requested definition's class or name. Complete packet identity translation remains an open producer boundary. + + +## Native text for name and label producers + +Production resource text now uses the pinned native language parser, lookup, and formatter. +The parser folds ASCII keys and preserves value bytes, whitespace, escapes, and NUL boundaries. +An empty duplicate does not erase an earlier value. Parsed tables remain immutable. +Replacing a content file invalidates its parsed cache. Existing text snapshots retain their own tables and folded lookup caches. + +Lookup tries the exact key before the folded-key cache and ASCII fallback. +Formatting expands native percent tokens, translates arguments, and retains whole-operation fallback. +Consumed sequential arguments advance later numeric references. +The accepted English source policy remains in effect. Locale acquisition and selection still need their own production integration. +Early kick and disconnect text use the same lookup, including camel-case keys after parser folding. + +Fresh native controls cover 1,099 parser cases, 6,689 lookup cases, five cache transitions, 752 formatter cases, and 777 fallback cases. +Another 780 compositions use the real parser, lookup, and formatter on the same table without a supplied translation result. +The shared parser and formatter use declared allocator, CRT, and single-thread lock boundaries. +These controls do not establish full native locale, asset-provider, client ownership, or visible parity. +The old content parser and three deliberate regressions fail behavioral assertions. + +The combined four-project build includes ticked actor-motion replay. +It passes 2,666 tests, with 163 optional skips and no failures or errors. +All 105 core and 38 client patches replay. All ten new language tests run without skips. +Eleven packaged checks include the concurrent content-cache test. Eleven production class origins match the current standalone core JAR. +All 1,427 core classes match both packaged copies. All 1,143 production client classes match the add-on. +The selected build outputs also match their packaged classes after the standard version-token replacement step. +The [text patch notes](../patches/viabedrock/upstreamable/0102-match-native-language-parsing-and-formatting.pr.md) retain the evidence and limits. + +A fresh ordinary BDS route passes furnace, smoker, blast furnace, and same-world reconnect. +Each cooking case ends with one player output and no remaining player or furnace input or furnace output. +Both selected integration-instance copies use the current add-on. Normal cleanup stops the private display. +This route does not prove native authored-screen acceptance, locale switching, or pixel parity. + +Direct native factory observations also confirm the first explicit construction choice. +A data-driven written book uses base custom-name methods. A legacy-first apple retains its base key after a later data-driven entry. +The existing builtin warped-fungus item retains its native default component name in the recorded None-entry control. +Do not infer every builtin update rule from a newly constructed component item. + +The refreshed pre-packet catalog records 1,949 effective default descriptions, 103 class variants, and three native potion-mode getter results. +Only the warped-fungus default description differs from its stored description in this control. +All 2,627 static cleanup callbacks complete without owned allocation or lock leaks. +Packet identity translation, all ordinary-key providers, secondary labels, and complete cooking search remain unfinished. + +## Component description construction and conversion + +The item registry now owns component description keys across construction and ordered updates. +Explicit component construction precedes all readers. Valid name strings in None or Legacy entries can update that existing component. +Empty strings restore its ordinary description. Omitted or malformed values retain the previous name. +Unknown None entries receive their fallback class afterward and ignore earlier metadata. + +Builtin component-name dispatch stays separate from factory version flags. +The recorded default warped-fungus-on-a-stick component retains its complete key until explicit data-driven reconstruction. +Java conversion uses the registry result for both mapped vanilla items and custom items through the shared text engine. +Other item fields retain their existing loader paths. + +Original native controls supply 23 final descriptions. The newest control executes 17 authored entries, records 16 native reader calls, and queries ten final definitions. +Its 2,627 static cleanup callbacks complete without owned allocation or lock leaks. +The old packaged converter fails twelve of twenty applicable name cases. +Both focused checks pass, including all 23 native results. Five deliberate construction, version, empty-value, fallback, and builtin-default regressions fail. + +A fresh control also executes 1,946 keys through actual factory receivers with an absent descriptor and no userdata. +Three potion methods require a language owner and remain deferred. Full native cleanup passes. +This establishes the declared input boundary. It does not establish all descriptor, locale, packet, client-asset, or visible behavior. + +The [component naming notes](../patches/viabedrock/upstreamable/0095-integrate-authored-native-ui-presentation.pr.md#component-description-construction-and-conversion) retain the evidence and limits. +Other ordinary-key providers, appended primary text, secondary labels, cooking search transport, and unchanged authored-screen acceptance remain unfinished. + +The final combined build passes 2,665 tests, with 163 optional skips and no failures or errors. +Both new checks run without skips. The three superseded reader tests are removed, with their thirteen native results retained in the registry checks. +All 105 core and 38 client patches replay. All 1,427 compiled core classes and both embedded copies match after the normal token replacement step. +All 1,143 client classes match the add-on. The new factory-description resource matches each packaged copy. +The final packaged converter passes all twenty component cases with five verified production class origins. + +A fresh ordinary BDS 1.26.51.1 route passes all three cooking families and same-world reconnect. +Both selected instances use the final add-on. Normal cleanup stops the private display. +This route does not inject the authored name-update cases or establish native screens and pixels. + +## Constructor-owned ordinary item names + +The connection registry supplies 559 builtin translation keys whose native methods ignore stack descriptors and userdata. +Java conversion resolves these keys through the current native language engine. +Explicit component construction replaces builtin dispatch. Later network flags do not change the constructed class. +Removed runtime-ID owners cannot retain a name. + +Original native execution confirms these keys after element registry initialization, with complete tracked allocation, lock, and static cleanup. +That initialization also corrects 118 element keys from the earlier probe. +The installed block archive accounts for all 98 palette names absent from the compiled default registry. +Native loading and naming for those asset definitions remain separate verification work. + +The packaged converter passes 557 applicable names from the typed protocol-2193 registry control. +Two reconstructed component entries use their separate naming path. +The old package fails 550 cases, including 41 exceptions from missing upgraded block-state sets. +The converter now preserves ordinary naming through the existing missing-visual fallback in that case. + +These checks do not complete descriptor-dependent names, appended primary text, secondary labels, cooking query transport, or native screen acceptance. +See the [owning patch notes](../patches/viabedrock/upstreamable/0095-integrate-authored-native-ui-presentation.pr.md#constructor-owned-ordinary-names) for inputs and limits. + +## Descriptor-aware ordinary item names + +The registry also supplies name providers for 41 builtin definitions across thirteen descriptor-dependent native methods. +These include buckets, dyes, books, maps, beds, banners, shields, and education items. +The converter supplies actual stack metadata and unfiltered userdata. Providers retain constructor variants independently of stack metadata and later factory flags. +Typed reads preserve absent values, empty compounds, wrong tag types, field presence, and each method's integer masks. +Component reconstruction and current runtime ownership still determine which provider can run. + +The private native corpus contains 10,906 original outputs from two initialized registry contexts. +The second context initializes structure identities before map names and replaces all 266 matching map inputs. +Both native contexts complete their original cleanup assertions. Another 9,089 bounded controls cover typed fields and constructor behavior. +The final packaged converter passes 8,282 applicable descriptor cases, all 557 fixed-name cases, and twenty component-description cases. +These controls verify supplied registry, descriptor, userdata, and language inputs. They do not establish native packet decoding, asset precedence, or visible UI parity. + +Block, potion, arrow, compound, appended primary text, secondary labels, query transport, and unchanged authored-screen acceptance remain open. +See the [descriptor provider notes](../patches/viabedrock/upstreamable/0095-integrate-authored-native-ui-presentation.pr.md#descriptor-aware-ordinary-names) for composition, initialization, regression controls, and build results. + +## Ordinary name append composition + +Java conversion now applies the native append path to modeled ordinary name providers. +The lazy primary selector uses the same implementation. Java custom names keep their separate override of the ordinary item component. +Only nonzero byte flags enable append text. Group names precede body formatting and raw suffixes. +Group and color translation use the current language engine. Body formatting retains the native color argument count, including an empty first argument. + +A fresh native control composes the selector with the actual I18n virtual functions, manager, formatter, and dictionary lookup. +All 2,442 supplied-input results match. The control supplies selected English table state and retains explicit descriptor, tag, allocation, and ownership boundaries. +Another control records 39 native coal primary and ordinary name pairs. +The final package matches all 78 results after parsing the supplied raw language files. The previous package fails 33 of the 39 paired cases. +See the [append evidence](../patches/viabedrock/upstreamable/0095-integrate-authored-native-ui-presentation.pr.md#ordinary-name-append-composition) for mutation controls and scope. + +Unmodeled ordinary providers, secondary labels, cooking query transport, and unchanged authored-screen acceptance remain open. + +## Potion version ownership + +Potion naming must use the world version received through Start Game. +The original native handler copies that version from the packet's level settings and supplies it to the client module's configure call. +Thirty-six native parser, comparison, copy and dispatch controls establish this bounded path. +The parser control completes native local and static cleanup without tracked allocations or held locks. + +The existing loose session conversion cannot supply the potion provider unchanged: it disagrees with native comparisons on eighteen inputs. +The differences include prerelease suffixes, malformed versions and values beyond the native 16-bit component range. +A fixed client-build version also fails older world contexts. +Potion and arrow providers now use a separate native version value parsed from Start Game. + +These controls use supplied packet storage and declared hosting boundaries. They do not execute the wire decoder or complete client startup. +See the [native version evidence](../patches/viabedrock/upstreamable/0095-integrate-authored-native-ui-presentation.pr.md#world-version-ownership-for-potion-names) for the caller, offsets and limits. + + +## Native potion primary names + +Potion, splash and lingering names now use their native constructor mode, registry fields and current language formatting. +A separate native world-version value preserves partial versions, wildcard and `beta`, and removes accepted suffixes before feature comparisons. +Invalid or overflowing versions follow native zero-version behavior. +Component reconstruction and current runtime ownership still govern provider access. + +The packaged provider matches 5,688 original native key/I18n results across modern, legacy, malformed-version and prerelease contexts. +All 3,504 applicable packaged conversions pass; the previous package fails 3,456 of those inputs. +Four deliberately wrong version or fallback variants fail assertions. The block-name conversion retains all 1,337 passing sentinel cases. + +The combined build passes 2,703 tests with 163 optional skips. +Fresh final-artifact BDS cooking and reconnect checks also pass, with normal cleanup of owned processes and the private display. +These checks do not establish authored screens, complete native session ownership or visible potion text. +See the [potion provider evidence](../patches/viabedrock/upstreamable/0095-integrate-authored-native-ui-presentation.pr.md#native-potion-primary-names) for inputs, exclusions and remaining work. + + +## Native arrow primary names + +Ordinary arrows use their constructor description. Tipped arrows select the native potion entry at auxiliary minus one. +Absent or unavailable entries retain the empty-arrow name. Ordinary arrows skip the version source; arrow keys do not request block or language callbacks. +Component reconstruction and runtime-owner retirement retain their existing control over naming. + +The provider matches all 1,264 original native key results across four world-version contexts. +All 584 applicable packaged conversions pass; the previous package fails 576. +Four wrong auxiliary, version and source access variants fail assertions. +The 5,688 potion receiver cases and 1,337 packaged block sentinel cases still pass. +The combined build passes 2,703 tests with 163 optional skips. +Fresh final-artifact BDS cooking and reconnect checks also pass, with normal cleanup. + +Complete native ownership, asset/locale acquisition and visible arrow text remain separate requirements. +See the [arrow provider evidence](../patches/viabedrock/upstreamable/0095-integrate-authored-native-ui-presentation.pr.md#native-arrow-primary-names) for raw descriptions, exclusions and integration checks. + + +## Native compound primary names + +Compound names select the native 38-entry enum through the low auxiliary byte. +Absent descriptors select 255, and unknown values retain an empty name suffix. +The provider skips block, language and world-version callbacks. Registry replacement and component reconstruction still control its lifetime. + +A fresh initialized native registry control matches all 263 keys and completes every registered cleanup callback. +The actual recipe aliases are disjoint from all compound auxiliary values. +All 512 packaged conversions pass; the previous package fails 510. Four deliberately wrong variants fail behavioral assertions. +Every entry in the captured 1,949-constructor table now has primary naming dispatch or a component description. +Arrow, potion and block comparisons still pass. The complete build passes 2,703 tests with 163 optional skips. +Fresh BDS cooking and same-world reconnect checks pass through ordinary Java screens, with normal cleanup. + +Complete descriptor/session ownership, secondary labels, locale acquisition, query transport and visible compound text remain separate requirements. +See the [compound provider evidence](../patches/viabedrock/upstreamable/0095-integrate-authored-native-ui-presentation.pr.md#native-compound-primary-names) for native inputs, exclusions and integration checks. + +## Authored toggle input delivery + +The packaged preference parser still rejects the target's focused and implicit hover mappings. +Fresh resolution of the three default cooking fixtures produces 15 toggle controls, and the current package rejects all 15. + +Original native dispatch and toggle controls now establish the event phases required for integration. +Focused input uses native control flags independently of pointer bounds. +Mouse and gamepad activation occurs on a new press; touch activation occurs on a new release. +Repeated phases do not activate again. Checked radio controls still notify their group. +A private candidate matches 4,320 dispatch cases and 1,440 toggle steps; four wrong candidates fail behavioral assertions. +Production input delivery remains unchanged. + +Native focus production, hover events, radio sibling state, device translation and event propagation remain required before complete scene admission. +See the [toggle input contract](../patches/viafabricplus-bedrock/upstreamable/0029-present-authored-native-containers.pr.md#authored-toggle-input-contract) for original functions and supplied interfaces. + +## Native focus admission and hover delivery + +Original native focus admission now has 16,384 cases across all low-byte flags, focus settings, registration state and component presence. +Admission retains the control owner, publishes the manager to its focus component, and prevents duplicate registration. +The optional component that admits some disabled controls remains unidentified. Its type ID and presence are supplied inputs. + +Another 3,840 cases run original default selection after original focus construction and admission. +Default ranking starts with a precedence floor of zero. It then compares `y * weight + x` through separate binary32 operations. +Equal scores retain registration order. A score equal to `Float.MAX_VALUE` does not select a control. +The manager weight, geometry and precedence are supplied fields. + +The original directional manager executes 1,024 three-step sequences on a four-control grid. +These controls vary registration order, initial selection and wrapping. All 3,072 steps match a private Java grid model. +Every native focus component completes its original destructor, and its allocation ledger is empty after each case. +Supplied control owners and manager storage remain outside that allocation ledger. +The grid model does not establish arbitrary directional scoring, authored overrides, parent clipping or scrolling. + +Original hover delivery now feeds focused dispatch and the original toggle handler in 144 sequences. +These controls execute 720 hover calls and 720 dispatch calls. +Hover delivery produces control flag `0x02` from eligible geometry and honors the delegated-hover marker. +Focused dispatch then reads the resulting flags. It does not repeat the pointer-bounds test. +The controls retain new-press activation for mouse/gamepad and new-release activation for touch. +They check strong-owner balance but do not retire queued weak events or establish complete manager lifetime. + +A private Java candidate matches these admission, default, grid and hover/dispatch controls. +Six wrong variants fail assertions, including double-precision ranking, missing wrapping and overwritten delegated hover. +These earlier controls do not execute the selection listener or construct implicit hover bindings. +The factory and ScreenView controls below extend that evidence. +These controls do not establish unchanged authored screen acceptance or native pixels. + + +## Native component flags and factory-created input + +Shared scenes now read component flags as native boolean or signed/unsigned 64-bit integer values. +Zero is false. Other integers are true. +Real values, strings, arrays, objects and null retain each property's default. +Scene visibility admission uses the same reader as runtime properties. +Keyboard traversal now defaults `focus_enabled` to false, as the original FocusComponent factory does. +Pointer action delivery remains available without keyboard traversal focus. + +Original JsonCpp constructors and PropertyView getters feed the native InputComponent and FocusComponent factories in 666 cases. +The production scene reader matches 5,544 property reads from these cases. +Four deliberately wrong readers fail: boolean-only parsing, real-number coercion, signed-only integers and string coercion. +All 25,840 recorded native allocations complete their frees after factory and property cleanup. +These controls supply component IDs, bare control ownership and empty component vectors. +They observe diagnostic output, profiling and dirty-tree notifications at platform interfaces. + +Factory-created mappings start at phase `0`. +A fresh selected mapping does not activate on an initial touch release. +An absent or unknown mapping type uses `pressed`. +A source-less `pressed` mapping creates an implicit hover binding when its target is present. +Source-less `focused` and `global` mappings do not create that binding. +These mapping rules remain native evidence for the pending input port. + +Another 432 sequences combine those factories with original ScreenView selection, hover and button routes. +They cover three input modes, four initial selections, wrapping, implicit hover and gamepad conditions. +Original `144846d40` moves selection. Original `1448357d0` produces hover through `1445069a0` before focused dispatch. +Original `144843510` delivers physical button values through InputComponent and ToggleComponent. +A private Java candidate matches all 2,880 sequence steps. Three wrong phase, condition and focus variants fail. + +Three additional controls each perform 240 selections and 480 button calls through the native selection-history ring. +The combined controls execute 5,040 screen input calls and 193,283 native frees. +Original manager and component destruction restore every declared owner and leave no recorded native allocation after each case. +Controller queue delivery, binding publication, visuals, radio propagation and settings notification remain observed host interfaces. +Screen/controller/root fields, four rectangles, an empty observer map and input-mode values are supplied inputs. + +The factory controls establish component flags and keyboard traversal admission. +The directional controls below extend the production port. +Device-origin translation, controller event handling, radio siblings, complete control/screen construction and unchanged authored screen acceptance remain incomplete. + +## Native directional focus and scroll contexts + +Shared actions and edit controls now use native directional edge scoring and binary32 geometry. +Equal scores retain registration order. Direct overrides use focus identifiers and can stop selection or bypass container policies. +The selector uses the nearest clipping ancestor and preserves viewport wrapping. +Keyboard arrows first reach the focused editable control. + +Scroll views search their named viewport subtree before the global clipped search. +This first search includes offscreen controls, follows subtree order and disables wrapping. +An unsuccessful viewport search also disables wrapping for the subsequent global search. +The screen reveals the selected control through its existing scroll path. +Layout synchronizes widget visibility immediately, so a revealed edit field accepts input before another render pass. + +Focus containers apply separate `stop`, `contained` and `custom` policies for each direction. +Custom routes use control names through `other_focus_container_name` and `focus_id_inside`. +Remembered selections propagate through ancestor containers. +A 50-entry weak history supplies fallback when the preferred control becomes ineligible. +History follows retained controls across scene refreshes and removes references to deleted containers. + +Original native constructors, property parsing, ancestor registration, viewport binding, subtree iteration, selection and destruction execute for these comparisons. +The geometry corpus contains 186 scenes and 3,976 steps. +Another 124 scenes add 5,344 context steps, including nested groups, custom routes, overrides, remembered selection and nested scroll views. +The production selector matches all 9,320 steps. Twelve deliberately wrong selector variants fail behavioral assertions. +A separate native history control checks 36 results across enabled-state changes and the 50-entry boundary. +Its candidate also checks retained-node replacement against those results. + +These controls supply static rectangles, component IDs, bare controls, parent/child ownership and focus-container boolean fields. +Original custom-route parsing consumes JsonCpp and PropertyView objects. +Original viewport callbacks own their weak references. The fixture retires bare parent/child references and caller-owned history results. +All recorded native allocations complete their cleanup. + +Screen regressions check actual arrow delivery, offscreen reveal, group policies, direct overrides and remembered focus after retained scene refresh. +Complete control/screen construction, non-widget focus controls, magnet state, scroll physics, controller events and physical device translation remain separate requirements. +These checks do not establish complete authored-screen support, a fresh account-based game join or native pixels. + +## Native cooking controller event delivery + +Original callback registration and controller dispatch now pass 632 cases on Bedrock 1.26.51.1, build 51061372. +The base controller uses its supported mode without global registration. +The fixture supplies Furnace fields, model ownership and the original Furnace virtual table. +Original manager and base-controller destructors retire all native allocations. + +The tab callback uses the emitted index and ignores the checked value. +The filter callback uses the emitted checked value. +Both callbacks run with either value of the toggle notification flag. +Default and global scope forward toggle events to these callbacks. View scope discards those events. + +The implicit `button.filter_toggle_hovered` event passes collection name `filter_toggle` and cell index zero to the container selection handler. +The downstream handler stores that name and index. It does not set a hover binding or establish keyboard focus. +Its registered callback accepts pressed events regardless of their previous phase or transition flag. +Release events do not invoke that callback. + +Another 96 scenes compose original Input/Toggle factories, pointer entry, button routing, capture priority, event delivery and controller callbacks. +Their 384 button steps cover pointer, touch and gamepad modes, default/view scope, radio state and captured releases. +Toggle changes enter the controller queue before their originating button events. +A view-scoped button can therefore produce a default-scoped toggle change even though the controller never receives that button event. + +The actual main Java input, toggle, capture and container consumer classes match all 384 steps with a private controller observation model. +Four incorrect source variants fail behavioral assertions for touch activation, transition handling, checkbox state and repeated radio notification. +The two native runs complete 19,688 frees and leave no live native allocations. + +These fixtures observe downstream selection requests, model setters, collection refresh and settings notifications at their call boundaries. +Complete Furnace/ScreenView construction, complete downstream selection behavior, application integration, unchanged screen admission, devices and pixels remain unverified. +The source now connects the shared components to container pointer input and focused keyboard actions. +Retained controls preserve phases, captures, checked state and focus across scene refreshes. +Non-consuming events compose their preference changes before the client publishes the final options. +Blocked components retire input phases without changing checked state. +All 54 targeted tests pass, including native component state, container geometry and actual screen interaction. + +The source parser accepts all 15 unmodified default toggle definitions. +Complete default cooking screens still fall back because their search, slider and other handlers remain unfinished. +Screen-owned view/global routing, unresolved collection indices and unsupported modal/controller policies remain explicit admission limits. +The component consumer remains outside release 0.3.46. diff --git a/docs/native-player-inventory.md b/docs/native-player-inventory.md new file mode 100644 index 00000000..ff849322 --- /dev/null +++ b/docs/native-player-inventory.md @@ -0,0 +1,237 @@ +# Native player inventory + +This reference supports the remaining player-inventory implementation. It describes the target renderer and the current Java integration gaps. + +## Current integration + +Release 0.3.53 synchronizes player-inventory preferences and preserves independent inventory and workbench layouts. +The E-key path still opens Java's inventory screen. +The native inventory asset requires `live_player_renderer`, armor and crafting collections, recipe controls, tabs and search. +The shared Java renderer now supports `live_player_renderer` through the actual local player. +It preserves separate actor, body and head rotations and applies the native model transform after Java's portrait translation. +Form, NPC and container adapters admit the new renderer. Release 0.3.54 includes these portrait changes. + +## Installed-screen lifecycle + +Release 0.3.55 includes the installed-screen lifecycle bridge. +The client sends an opening request after `Gui.setScreen` installs the actual inventory screen. +Constructing an unused screen does not open the Bedrock inventory. +The bridge supports direct translation and ViaProxy through the same negotiated payload. +Java creative inventory also opens the native player inventory, while its item picker retains its existing presentation. + +Each installed opening receives a monotonic token for its connection. +An ordinary reopen receives a new token, even though Java retains the same player menu object. +A genuine native presentation swap retains the token. +Tokens remain monotonic across play-listener replacements and physical reconnects. +A late capability announcement does not replace an existing legacy opening or send a duplicate native request. + +The Bedrock open acknowledgment does not carry this token. +The core therefore serializes openings and retains retired requests until their acknowledgments arrive. +A new screen waits for the previous native close acknowledgment. +Old screen clicks cannot replay into that new opening. +Independent global inventory actions can use a retired acknowledgment before the temporary inventory closes. +Timeout closes the matching client screen and retains the unresolved native request. + +Server closes identify the owning client token through a separate payload. +A stale close cannot close a newer screen or another connection. +The bridge replaces the constructor trigger and preserves the legacy installed-screen fallback for older direct translators. +The full authored inventory layout, recipes, tabs, search and physical controller input remain unfinished. + +## Controller state and collection reference + +The target constructor is `0x144e0f520`. Its binding registrations use 32-bit FNV-1a hashes of the complete property names, including `#`. +The registered callback at `0x144e5b410` implements `#is_creative_mode` and calls `0x144923190`. +This shared helper checks creative mode. It does not identify the active input device. +The earlier cooking reference called it an input accessor without establishing its meaning. + +The helper reads actor component `0x88d3eddf` through the original sparse registry. +Raw game type `1` returns true. Raw game type `5` returns true only when the fallback getter returns `1`. +An absent player, absent component, missing page, stale entity generation or raw value `-1` returns false. +The reference executes 640 cases through both the original helper and the registered callback, for 1,280 calls. +Actor delivery, component backing and the fallback virtual getter remain supplied interfaces. + +The constructor also stores an independent flag at offset `0x1319`. +Its producer compares a virtual service result with `1`; the service comes from client vtable offset `0x578`, then service vtable offset `0x70`. +The service type remains unverified. The implementation must not substitute the effective creative predicate or the input mode for this flag. + +### Layout and tabs + +The original property getters select these numeric values: + +| State | Value | Property | +| --- | --- | --- | +| Layout | 1 | `#is_survival_layout` | +| Layout | 2 | `#is_recipe_book_layout` | +| Layout | 3 | `#is_creative_layout` | +| Left tab | 1 | `#is_left_tab_construct` | +| Left tab | 2 | `#is_left_tab_equipment` | +| Left tab | 3 | `#is_left_tab_items` | +| Left tab | 4 | `#is_left_tab_nature` | +| Left tab | 5 | `#is_left_tab_search` | +| Left tab | 6 | `#is_left_tab_inventory` | +| Right tab | 1 | `#is_right_tab_fullscreen` | +| Right tab | 2 | `#is_right_tab_crafting` | +| Right tab | 3 | `#is_right_tab_armor` | + +The constructor selects the active saved layout for inventory or workbench and retains the other layout separately. +Its default is `3` for creative inventory and `2` otherwise. Workbench always uses default `2`. +Saved `0` and positive values greater than `3` use that default. Negative values remain unchanged. +The independent constructor flag forces the default. Saved layout `3` becomes `2` outside creative inventory. +These rules do not change the raw saved preferences. + +The constructor normalizes tabs too. Without the independent flag, left tab `6` becomes search tab `5`. +With that flag, input mode `3` and noncreative state select inventory/armor tabs or search/crafting tabs, according to screen type. +Noncreative fullscreen right tab `1` becomes crafting tab `2`. +The raw filtering preference remains separate from the recipe model's effective filter, which is disabled in creative mode. + +The bounded constructor reference executes 3,472 combinations of layouts, tabs, filtering, both flags, screen type and controller input mode. +Each case then executes sixteen original property getters, for 55,552 getter calls. +The reference supplies model-filter updates and initial tab publication. It disables the separate restricted-tab policy. +Full construction, restricted-tab availability, device delivery and visible pixels remain unverified. + +### Logical item and recipe collections + +The original initializer at `0x1431bd020` constructs 67 collection names. +The executed slice uses supplied map nodes and sufficient string capacity. Native hash insertion and string allocation remain outside this reference. +The player model requires these distinct collections: + +| Native ID | Collection | +| --- | --- | +| 6 | `armor_items` | +| 12 | `combined_hotbar_and_inventory_items` | +| 13 | `crafting_input_items` | +| 14 | `crafting_output_items` | +| 15 | `recipe_construction` | +| 16 | `recipe_nature` | +| 17 | `recipe_items` | +| 18 | `recipe_search` | +| 20 | `recipe_equipment` | +| 28 | `hotbar_items` | +| 29 | `inventory_items` | +| 34 | `offhand_items` | +| 59 | `cursor_items` | +| 60 | `creative_output_items` | + +Model builder `0x1434360d0` creates the item collections and four category recipe collections through `0x143438210`. +It also creates a separate search collection. These recipe collections share constructor `0x143443e50`. +The category counts use the source category's group references; search uses the complete source list. +This model construction evidence comes from disassembly. Production recipe acquisition, category assignment, availability evaluation and mutation remain unfinished. +The player screen must retain the complete catalog and recipes that need a crafting table. +A fabricated empty recipe catalog cannot establish support for the default inventory asset. + +## Shared recipe collections + +The shared `RecipeCollection` model implements the target collection ordering and retained header expansion. +Cooking uses the same partitioner for its flat visible recipe prefix. +The player controller still needs a complete catalog producer before it can use grouped collections. + +Entries retain four stable evaluator buckets. +A contiguous catalog group moves as one run: state zero wins, then state one; otherwise the first member selects state two or three. +Unfiltered grouped refreshes skip availability evaluation. +Flat refreshes still evaluate entries and merge states one and two when filtering is disabled. +Invalid entries do not split a pending group or invoke the evaluator. +Grouped projection includes valid groups beyond the flat eligible prefix. + +Grouping uses catalog object identity. Expansion history instead matches the header icon and count. +Refresh retains expanded headers across ordering and filtering changes, including an intervening flat refresh. +An explicit reset clears that history. Header expansion reports its visible position and member count. +The caller must validate the selected header before invoking this operation. +Native action admission and arbitrary item activation remain separate controller work. + +The original routines are `0x143445150`, `0x143446b90`, `0x143447710` and `0x143447ab0`. +The shared Java implementation matches 4,128 original collection cases and sixteen retained-state steps. +The steps cover expansion history and groups with equal icons but different identities or counts. +Native vector allocation and cleanup execute. Item copy/destruction, icon equality, availability evaluation and external callbacks use supplied boundaries. +These checks do not establish recipe acquisition, unlocks, craftability, full controller ownership, physical input or visible screen parity. +Six focused regressions cover ordering, representative identity, filtering, invalid entries, expansion history and immutable snapshots. +The complete build passes 2,823 tests, with 163 optional skips and no failures or errors. +All 1,465 core classes match the embedded add-on and proxy copies; all 1,191 client classes match the tested add-on. +The packaged core repeats all 4,144 recipe comparisons with its class origin verified. + +## Target and original functions + +The reference uses Bedrock 1.26.51.1, build 51061372, protocol 2193. +The binary SHA-256 is `537c0aee2e79afbdc94b44b28e00f466ae62bc50e2733d953b430db9dbaa9ee7`. + +| Function or component | Address or identifier | Role | +| --- | --- | --- | +| Live player draw | `0x148ddd910` | Reads the local actor and constructs the preview | +| Cursor delta helper | `0x148de0020` | Reads `#look_at_cursor` and the current input source | +| Body-yaw getter | `0x14207f2b0` | Reads current and previous body yaw | +| Body-yaw setter | `0x14204d890` | Writes current and previous body yaw | +| Actor rotation | Actor offset `0x228` | Stores pitch and yaw separately from yaw history | +| Body-yaw history | Component hash `0xd7f64bba` | Stores two body-yaw values | +| Head-yaw history | Component hash `0xbabe7211` | Stores two head-yaw values | + +The renderer factory selects different draw functions for live player and horse controls. +The live player draw uses the local actor directly. It does not select an actor through the horse control's `#entity_id`. + +## Framing and pose + +The control center determines the preview position. The smaller control dimension determines its scale. +The actor rotation, body-yaw history and head-yaw history remain separate throughout the draw. + +| Native state | Preview behavior | +| --- | --- | +| Standing | Uses the control center and the smaller dimension | +| Swimming or crawling | Moves the preview center upward by 20 logical units | +| Gliding | Moves the center downward by 15 units, uses 60% scale and a fixed 10-degree tilt | + +Gliding takes precedence over swimming and crawling. Its actor rotation remains unchanged. +Its body yaw becomes zero, while its head yaw retains the difference between the original head and body values. + +For ordinary poses, cursor deltas use the control center minus the cursor position. +Their angles use `atan(delta / 40)`. +The pitch multiplier is `-20`, the actor/head-yaw multiplier is `40`, and the body-yaw multiplier is `20`. + +The cursor helper requires a true boolean `#look_at_cursor` property. +Raw input mode `1` reads the native mouse coordinates and GUI scale. +Other captured modes use a separate cursor object and two admission getters. +An absent or inactive cursor contributes zero deltas. +The admission getters' physical producers remain unverified. +The current Java adapter supplies mouse coordinates. Physical controller and touch delivery remain unfinished. + +The draw temporarily changes the admitted mount's body yaw too. +The normal return restores actor rotation, both yaw histories and the mount's body yaw. + +## Executed reference and limits + +The private reference executes 672 original draw calls through normal return with an absent renderer result. +It covers seven pose combinations, three control shapes, eight cursor configurations, mount presence and head-yaw component presence. +The original sparse component lookups, body-yaw getter/setter, matrix arithmetic and restoration instructions execute. +Every case restores the supplied player and mount values. + +The fixture supplies local actor delivery, flags, cursor admission values, matrix-stack bookkeeping and renderer-context boundaries. +Host float math supplies `atanf`, `sinf` and `cosf`. +The absent renderer result skips successful renderer submission, renderer variables and effects. +The reference does not establish GPU output, clipping, physical input delivery or visible native parity. + +## Java implementation checks + +The full stack passes 2,817 tests, with 163 optional skips. +Three packaged camera regressions pass and reject four deliberately incorrect implementations. +The native comparison covers framing, actor rotation and body yaw in all 672 cases. +Head-yaw comparisons cover the 336 cases with that component present. +The prior 35 screen regressions and 1,940 UI input reference cases also pass. + +Two private Java 26.3 clients verify actual entity submission at 854 by 480 and 1920 by 1013 pixels. +Their Java/native GUI scale pairs are 2/1 and 3/4. +The checks retain 36 submission samples across twelve supplied scene stages. +They cover cursor tracking, pose-specific framing and a malformed horse-style entity ID that the local-player renderer ignores. +Every observed preview draw preserves the world player's rotation fields. +Private screenshots show the local player at both viewport sizes. + +These client checks supply the scene and native pose flags. They do not reproduce genuine world gliding or the authored inventory controller. +Native visible comparisons, successful native renderer variables, converted skins, equipment, emotes, GUI effects and Iris comparisons remain incomplete. +Actual mounted preview queries and seat ownership also need verification. +An isolated Java 26.3 client completes four real E-key openings through ViaProxy and Bedrock 1.26.51.1. +Two openings use survival inventory, then two use the creative picker after a server mode change. +Each receives a matching native open and close acknowledgment without an inventory click. +All four retain the same persistent player-menu object and use distinct opening tokens. +Constructing an unused inventory screen sends no native request. +Private screenshots confirm that these remain Java's ordinary survival and creative screens. + +The installed-screen bridge has sixteen packaged lifecycle and codec regressions. +The full player-inventory controller and physical input delivery remain unfinished. +Direct joins, creative item actions, reconfiguration and recovery under packet delay still need live verification. +All five implementation areas and the four original bug reports remain open. diff --git a/patches/cubeconverter/series.json b/patches/cubeconverter/series.json index 6d090af7..29b3f4de 100644 --- a/patches/cubeconverter/series.json +++ b/patches/cubeconverter/series.json @@ -5,7 +5,7 @@ "upstreamable": [ { "file": "0001-preserve-invisible-bedrock-cubes.patch", - "title": "fix(models): preserve cubes without visible faces" + "title": "fix(models): preserve invisible geometry and native defaults" }, { "file": "0002-preserve-actor-lighting-controls.patch", diff --git a/patches/cubeconverter/upstreamable/0001-preserve-invisible-bedrock-cubes.patch b/patches/cubeconverter/upstreamable/0001-preserve-invisible-bedrock-cubes.patch index 52e1847d..902f5ebd 100644 --- a/patches/cubeconverter/upstreamable/0001-preserve-invisible-bedrock-cubes.patch +++ b/patches/cubeconverter/upstreamable/0001-preserve-invisible-bedrock-cubes.patch @@ -1,7 +1,7 @@ -From 994a0e40cc182fadcb289661c44f615585e62610 Mon Sep 17 00:00:00 2001 +From b00815c0844bbbb95a65064941de10c0e041878c Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:54:14 +0200 -Subject: [PATCH] fix(models): preserve cubes without visible faces +Subject: [PATCH] fix(models): preserve invisible geometry and native defaults Bedrock geometry can contain cubes with an empty UV face map. Omit these cubes from Java model elements instead of inventing an unresolved texture @@ -39,14 +39,34 @@ The versioned package supplies the cases; the official geometry schema documents this optional default: https://learn.microsoft.com/en-us/minecraft/creator/reference/content/schemasreference/schemas/minecraftschema_geometry_1.21.0?view=minecraft-bedrock-experimental Numeric tests cover all six asymmetric faces and an explicit negative size. + +Retain geometry declarations that omit texture dimensions or the bones +array. The native geometry compiler supplies 64x64 defaults, and missing +bones describe an empty model. Dropping those declarations made invisible +Galaxite markers appear as unresolved geometry in the downstream resolver. +Legacy geometry keys also need those defaults; identify them by geometry. +prefix rather than requiring a texturewidth field. + +Target Bedrock 1.26.51.1/protocol 2193 caller 141c11ba0 supplies width and +height 64 to compiler 141c13a30. A native instruction probe of its field +reader 141c13bfd..141c13c6b preserves caller defaults for absent/null fields +and overrides each dimension independently. The probe uses original JSON +lookup/integer helpers with exact host CRT and allocation boundaries; it +does not establish full model cache, schema dispatch or rendering parity. +Mojang's geometry 1.8.0 and 1.12.0 schemas mark dimensions and bones optional. + +Preserve explicit dimensions and source JSON. Tests export modern and +legacy empty models through all Java rotation modes with finite unit scale, +including independent dimension overrides and a null dimension default. --- - build.gradle | 7 ++ + build.gradle | 7 + .../converter/converter/FormatConverter.java | 6 +- - .../model/impl/java/JavaItemModel.java | 23 ++-- - .../cube/converter/util/element/UVMap.java | 33 ++++-- - .../converter/FormatConverterTest.java | 103 ++++++++++++++++ - .../model/impl/java/JavaItemModelTest.java | 112 ++++++++++++++++++ - 6 files changed, 255 insertions(+), 29 deletions(-) + .../model/impl/java/JavaItemModel.java | 23 +-- + .../geometry/BedrockGeometryParser.java | 23 ++- + .../cube/converter/util/element/UVMap.java | 33 ++-- + .../converter/FormatConverterTest.java | 143 ++++++++++++++++++ + .../model/impl/java/JavaItemModelTest.java | 112 ++++++++++++++ + 7 files changed, 305 insertions(+), 42 deletions(-) create mode 100644 src/test/java/org/cube/converter/converter/FormatConverterTest.java create mode 100644 src/test/java/org/cube/converter/model/impl/java/JavaItemModelTest.java @@ -173,6 +193,61 @@ index 2c45ce3..8dfb658 100644 -} \ No newline at end of file +} +diff --git a/src/main/java/org/cube/converter/parser/bedrock/geometry/BedrockGeometryParser.java b/src/main/java/org/cube/converter/parser/bedrock/geometry/BedrockGeometryParser.java +index b692f94..46ca26f 100644 +--- a/src/main/java/org/cube/converter/parser/bedrock/geometry/BedrockGeometryParser.java ++++ b/src/main/java/org/cube/converter/parser/bedrock/geometry/BedrockGeometryParser.java +@@ -16,6 +16,8 @@ import java.util.List; + + // I am NOT cleaning this up lmao... + public class BedrockGeometryParser { ++ private static final int DEFAULT_TEXTURE_SIZE = 64; ++ + public static List parse(String json) { + return parse(GsonUtil.getGson().fromJson(json.trim(), JsonObject.class)); + } +@@ -50,7 +52,7 @@ public class BedrockGeometryParser { + continue; + } + JsonObject object = element.getAsJsonObject(); +- if (!object.has("texturewidth")) { ++ if (!elementName.startsWith("geometry.")) { + continue; + } + final BedrockGeometryModel geometry = getGeometry(object, elementName, "texturewidth", "textureheight"); +@@ -67,19 +69,14 @@ public class BedrockGeometryParser { + JsonObject description = geometry.has("description") ? geometry.getAsJsonObject("description") : geometry; + String identifier = description.has("identifier") ? description.get("identifier").getAsString() : elementName; + +- if (!description.has(textureHeightName) || !description.has(textureWidthName)) { +- return null; +- } +- +- int textureWidth = description.get(textureWidthName).getAsInt(); +- int textureHeight = description.get(textureHeightName).getAsInt(); +- +- if (!geometry.has("bones")) { +- return null; +- } ++ final int textureWidth = description.has(textureWidthName) && !description.get(textureWidthName).isJsonNull() ++ ? description.get(textureWidthName).getAsInt() : DEFAULT_TEXTURE_SIZE; ++ final int textureHeight = description.has(textureHeightName) && !description.get(textureHeightName).isJsonNull() ++ ? description.get(textureHeightName).getAsInt() : DEFAULT_TEXTURE_SIZE; ++ final JsonArray boneElements = geometry.has("bones") ? geometry.getAsJsonArray("bones") : new JsonArray(); + + final List bones = new ArrayList<>(); +- for (JsonElement boneElement : geometry.getAsJsonArray("bones")) { ++ for (JsonElement boneElement : boneElements) { + final JsonObject boneObject = boneElement.getAsJsonObject(); + final String name = boneObject.get("name").getAsString(); + +@@ -144,4 +141,4 @@ public class BedrockGeometryParser { + geometryModel.getParents().addAll(bones); + return geometryModel; + } +-} +\ No newline at end of file ++} diff --git a/src/main/java/org/cube/converter/util/element/UVMap.java b/src/main/java/org/cube/converter/util/element/UVMap.java index bf3054a..9c1e76a 100644 --- a/src/main/java/org/cube/converter/util/element/UVMap.java @@ -233,12 +308,14 @@ index bf3054a..9c1e76a 100644 @Override diff --git a/src/test/java/org/cube/converter/converter/FormatConverterTest.java b/src/test/java/org/cube/converter/converter/FormatConverterTest.java new file mode 100644 -index 0000000..3257dca +index 0000000..a890ea9 --- /dev/null +++ b/src/test/java/org/cube/converter/converter/FormatConverterTest.java -@@ -0,0 +1,103 @@ +@@ -0,0 +1,143 @@ +package org.cube.converter.converter; + ++import com.viaversion.viaversion.libs.gson.JsonArray; ++import com.viaversion.viaversion.libs.gson.JsonNull; +import com.viaversion.viaversion.libs.gson.JsonObject; +import org.cube.converter.converter.enums.RotationType; +import org.cube.converter.model.impl.java.JavaItemModel; @@ -251,6 +328,44 @@ index 0000000..3257dca +class FormatConverterTest { + + @Test ++ void preservesOptionalGeometryFieldsAndNativeTextureDefaults() { ++ for (final boolean modern : new boolean[]{false, true}) { ++ for (final boolean bones : new boolean[]{false, true}) { ++ final JsonObject document = modern ? geometryJson("[]") ++ : GsonUtil.getGson().fromJson("{\"geometry.example\":{}}", JsonObject.class); ++ final JsonObject geometry = modern ? document.getAsJsonArray("minecraft:geometry").get(0).getAsJsonObject() ++ : document.getAsJsonObject("geometry.example"); ++ final JsonObject description = modern ? geometry.getAsJsonObject("description") : geometry; ++ final String width = modern ? "texture_width" : "texturewidth"; ++ final String height = modern ? "texture_height" : "textureheight"; ++ description.remove(width); ++ description.remove(height); ++ if (bones) geometry.add("bones", new JsonArray()); ++ else geometry.remove("bones"); ++ for (int overrides = 0; overrides < 4; overrides++) { ++ if ((overrides & 1) != 0) description.addProperty(width, 32); ++ else description.add(width, JsonNull.INSTANCE); ++ if ((overrides & 2) != 0) description.addProperty(height, 16); ++ else description.remove(height); ++ final JsonObject original = document.deepCopy(); ++ final var models = BedrockGeometryParser.parse(document); ++ assertEquals(1, models.size()); ++ final var parsed = models.get(0); ++ assertEquals((overrides & 1) != 0 ? 32 : 64, parsed.getTextureSize().getX()); ++ assertEquals((overrides & 2) != 0 ? 16 : 64, parsed.getTextureSize().getY()); ++ assertTrue(parsed.getParents().isEmpty()); ++ for (final RotationType rotation : RotationType.values()) { ++ final var model = parsed.toJavaItemModel("example:item/actor", rotation); ++ assertEquals(1, model.getScale()); ++ assertTrue(finiteExport(model).getAsJsonArray("elements").isEmpty()); ++ } ++ assertEquals(original, document); ++ } ++ } ++ } ++ } ++ ++ @Test + void exportsGeometryWithoutBonesAtUnitScaleForEveryRotationMode() { + final var geometry = BedrockGeometryParser.parse(geometryJson("[]")).get(0); + for (final RotationType rotation : RotationType.values()) { diff --git a/patches/cubeconverter/upstreamable/0001-preserve-invisible-bedrock-cubes.pr.md b/patches/cubeconverter/upstreamable/0001-preserve-invisible-bedrock-cubes.pr.md index f9d44d86..ccb2189a 100644 --- a/patches/cubeconverter/upstreamable/0001-preserve-invisible-bedrock-cubes.pr.md +++ b/patches/cubeconverter/upstreamable/0001-preserve-invisible-bedrock-cubes.pr.md @@ -18,3 +18,36 @@ The parser uses the declared cube face dimensions and preserves explicitly signe The matching 1.26.51.1 package supplies the affected models. The [official geometry schema](https://learn.microsoft.com/en-us/minecraft/creator/reference/content/schemasreference/schemas/minecraftschema_geometry_1.21.0?view=minecraft-bedrock-experimental) specifies this default. Numeric regressions cover all six asymmetric faces and a negative authored size. + + +## Geometry with optional fields + +Keep valid geometry declarations when texture dimensions or the bones array are omitted. +Dimensions default to 64 by 64, and absent bones produce an empty model. +Recognize legacy declarations by their geometry key, since texture dimensions are optional there too. +Preserve explicit dimensions and source JSON. + +The matching Bedrock 1.26.51.1/protocol 2193 compiler caller `141c11ba0` supplies width and height 64 to `141c13a30`. +A private instruction probe executes the compiler's texture-dimension reader at `141c13bfd..141c13c6b`. +It verifies 12 cases covering absent/null dimensions and independent numeric overrides with two caller defaults. +The probe uses the original native JSON lookup and integer conversion helpers. +Its host boundaries provide exact CRT byte/string operations, allocation, frees, and a diagnostic backend. +It excludes schema dispatch, cached/inherited model dimensions, bone loading, registration, and rendering. +The target executable SHA-256 is `537c0aee2e79afbdc94b44b28e00f466ae62bc50e2733d953b430db9dbaa9ee7`. +Mojang's [geometry schemas](https://github.com/Mojang/bedrock-samples/blob/main/documentation/Schemas.html) mark texture dimensions and bones optional in formats 1.8.0 and 1.12.0. + +Validation: + +- The north-star patch applies alone to the pinned upstream base without setup. +- The CubeConverter stack replays successfully. +- All four project builds pass: 2,358 tests passed, 152 optional skips, no failures or errors. +- Parser/export tests cover modern and legacy declarations, absent and empty bones, defaults, independent dimension overrides, source preservation, and finite export through every rotation mode. +- A 29-connection audit checks 16,043 model declarations across 82 content-hashed pack archives. +- Five captured declarations use these optional fields; all five now parse and export with finite values through every rotation mode. +- These include Galaxite's empty marker, GenWars text helpers, another empty helper, and two Dimension Clash models with bones. +- A complete 40-second Galaxite replay passes transport checks with no unresolved marker geometry, model-parser, particle-load, or mixin errors. + +The replay still reports the camera item texture and a missing waterlogged block state. +The matching bundled atlas references a camera image that is absent from the inspected packs; its native fallback behavior remains unverified. +Full model inheritance, cache behavior, and native image parity remain outside this validation. +All captured geometry, native exports, and recordings remain private. 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 ebe841d9..bc47371f 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 a3f7eae963aeb612276b19d7e707f5d7814f1766 Mon Sep 17 00:00:00 2001 +From b141f26022f59be7dd2779720d5b000f796190b2 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 @@ -301,10 +301,10 @@ index 000000000..36a2d78e7 + +} 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 b99ec84bd..bdfb425b9 100644 +index c80751bb9..1a0bcb7b4 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 -@@ -209,14 +209,18 @@ public class Entity { +@@ -223,14 +223,18 @@ public class Entity { } public void playSound(final String soundEvent) { @@ -327,18 +327,18 @@ index b99ec84bd..bdfb425b9 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 ee5087560..3f7a921cc 100644 +index 5e2219690..03d29d3a6 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,7 @@ import com.viaversion.viaversion.util.Key; - import net.raphimc.viabedrock.ViaBedrock; - import net.raphimc.viabedrock.api.model.NativeActorMessage; +@@ -39,6 +39,7 @@ import net.raphimc.viabedrock.api.model.NativeActorMessage; + import net.raphimc.viabedrock.api.model.ActorMotionCommand; + import net.raphimc.viabedrock.api.model.MovementCorrectionTiming; import net.raphimc.viabedrock.api.model.NativeAnimationMessage; +import net.raphimc.viabedrock.api.model.NativeFishingEvent; import net.raphimc.viabedrock.api.model.entity.ClientPlayerEntity; import net.raphimc.viabedrock.api.model.entity.CustomEntity; import net.raphimc.viabedrock.api.model.entity.Entity; -@@ -48,10 +49,12 @@ import net.raphimc.viabedrock.api.util.RegistryUtil; +@@ -50,10 +51,12 @@ 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; @@ -351,7 +351,7 @@ index ee5087560..3f7a921cc 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; -@@ -60,6 +63,9 @@ import net.raphimc.viabedrock.protocol.model.*; +@@ -62,6 +65,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; @@ -360,8 +360,8 @@ index ee5087560..3f7a921cc 100644 +import com.viaversion.viaversion.api.minecraft.BlockPosition; import net.raphimc.viabedrock.protocol.storage.ChannelStorage; import net.raphimc.viabedrock.protocol.storage.EntityTracker; - import net.raphimc.viabedrock.protocol.storage.GameSessionStorage; -@@ -68,6 +74,7 @@ import net.raphimc.viabedrock.protocol.types.BedrockTypes; + import net.raphimc.viabedrock.protocol.storage.PlayerPredictionStorage; +@@ -72,6 +78,7 @@ import net.raphimc.viabedrock.protocol.types.BedrockTypes; import java.util.ArrayList; import java.util.List; @@ -369,7 +369,7 @@ index ee5087560..3f7a921cc 100644 import java.util.logging.Level; public final class EntityPackets { -@@ -482,6 +489,43 @@ public final class EntityPackets { +@@ -499,6 +506,43 @@ public final class EntityPackets { return; } switch (event) { @@ -413,7 +413,7 @@ index ee5087560..3f7a921cc 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); -@@ -511,12 +555,17 @@ public final class EntityPackets { +@@ -528,12 +572,17 @@ public final class EntityPackets { } if (entity != entityTracker.getClientPlayer()) { entity.playSound("death"); @@ -434,7 +434,7 @@ index ee5087560..3f7a921cc 100644 } } }); -@@ -725,6 +774,34 @@ public final class EntityPackets { +@@ -775,6 +824,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 1e6a4dd8..05ee7cf1 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 b738c75be1f4b18613e0f98bd78ab372982af3b9 Mon Sep 17 00:00:00 2001 +From 128d795d7f92c8cf0f310feda48a4fe920a8870b 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 @@ -457,119 +457,170 @@ ID and skip unresolved occupied merge destinations. Do not unbox a missing ID or send a predicted request with an unresolved identity. Keep empty destinations at network ID zero and derive predicted counts from the actual emitted action. + +Pass the connection-owned native stack query matcher into authored +count-one placement. Use the destination as query, matching the ordinary +native transfer comparator rather than Java tag object equality. Keep +modern network-ID and placement-permission checks in this authority +adapter; the shared planner contains no raw-equality fallback. + +Retain separately scoped ordinary transfer requests as one pending input +batch. Capture each intermediate prediction, buffer responses until the +batch is complete, then restore the oldest snapshots and replay accepted +deltas in request order. This preserves confirmed stack IDs when a later +transfer rejects and keeps queued clicks behind the complete batch. +Server revisions and menu generations still protect newer contents and +replacement menus. Craft-result requests retain their separate full-item +synchronization path. + +The pinned native 1.26.51.1 client control emits two separately owned +requests for one two-source cooking input when no enclosing request is +open. Native flush sends both in one packet; the second destination uses +the first request ID. The configuration registry defaults +`enable_client_container_simulation` to false, and the original cooking +initializer leaves its simulation helper absent under that gate. Full +controller/model construction and independent frame scopes remain open. +This batch API supports those request boundaries without claiming that the +authored cooking backend or full native application grouping is finished. + +Record the server revision when each batch response arrives. Discard its +stale slot acknowledgments if newer authoritative contents arrive before +the final response. This preserves packet ordering across the batch's +buffering boundary instead of allowing delayed reconciliation to replace +newer server state. + +Submit prepared batches through the request tracker and restore their +oldest snapshots if packet submission throws. Release pending ownership +and queued input on that failure so an unsent batch cannot lock inventory. + +Ignore empty resource-pack display-name overrides when selecting the Java +fallback label. Retain upper-pack precedence across primitive and object +forms. Network component descriptions now belong to ItemRegistry, whose +construction and ordered-reader state also drives mapped Java items. Keep +this loader focused on its remaining icon, cooldown and use metadata. + +The registry-owned name checks cover all 13 prior typed update results and +the newer construction/admission controls. Remove the superseded network +name reader and its three tests so two independent state machines cannot +drift. Native asset precedence and full ordinary names remain open. + +Own player inventory openings by the actual installed client screen over a negotiated payload shared by direct and proxy routes. Java reuses menu zero, so retain retired native opens until acknowledgment and correlate server closes to a monotonic client opening. Serialize rapid reopenings, discard retired screen clicks, and preserve later global inventory interactions. Reject malformed or stale lifecycle messages without changing native ownership. --- - Data Asset Sources.md | 8 + build.gradle | 3 +- - .../api/model/container/ChestContainer.java | 65 +- - .../api/model/container/Container.java | 1107 ++++- + .../api/model/PlayerInventoryScreenClose.java | 46 + + .../model/PlayerInventoryScreenRequest.java | 52 + + .../api/model/container/ChestContainer.java | 66 +- + .../api/model/container/Container.java | 1107 +++++++++++- .../model/container/ContainerOpenContext.java | 19 + - .../api/model/container/HorseContainer.java | 239 ++ - .../NativeContainerActionBackend.java | 143 + - .../NativeContainerActionSession.java | 133 + - .../NativeContainerAutoPlacement.java | 155 + + .../api/model/container/HorseContainer.java | 239 +++ + .../NativeContainerActionBackend.java | 85 + + .../NativeContainerActionSession.java | 136 ++ + .../NativeContainerAutoPlacement.java | 91 + .../container/NativeTransferQuantity.java | 40 + - .../model/container/block/AnvilContainer.java | 178 + - .../container/block/BeaconContainer.java | 238 + + .../model/container/block/AnvilContainer.java | 178 ++ + .../container/block/BeaconContainer.java | 238 +++ .../block/BlastFurnaceContainer.java | 51 + - .../block/BrewingStandContainer.java | 98 + + .../block/BrewingStandContainer.java | 98 ++ .../container/block/CrafterContainer.java | 88 + - .../block/CraftingTableContainer.java | 219 + - .../container/block/EnchantmentContainer.java | 248 ++ + .../block/CraftingTableContainer.java | 219 +++ + .../container/block/EnchantmentContainer.java | 248 +++ .../container/block/FurnaceContainer.java | 77 + - .../container/block/Generic3x3Container.java | 54 + + .../container/block/Generic3x3Container.java | 7 + .../container/block/GrindstoneContainer.java | 79 + - .../container/block/HopperContainer.java | 54 + - .../container/block/LecternContainer.java | 165 + - .../container/block/SmithingContainer.java | 223 + + .../container/block/HopperContainer.java | 7 + + .../container/block/LecternContainer.java | 165 ++ + .../container/block/SmithingContainer.java | 223 +++ .../container/block/SmokerContainer.java | 45 + - .../container/block/StonecutterContainer.java | 236 + - .../container/dynamic/BundleContainer.java | 254 +- + .../container/block/StonecutterContainer.java | 236 +++ + .../container/dynamic/BundleContainer.java | 254 ++- .../container/player/ArmorContainer.java | 12 + .../model/container/player/HudContainer.java | 31 +- .../container/player/InventoryContainer.java | 87 +- .../player/InventorySubContainer.java | 6 + .../container/player/OffhandContainer.java | 50 +- - .../definition/ItemDefinitions.java | 54 +- - .../viabedrock/api/util/PacketFactory.java | 184 +- - .../viabedrock/protocol/BedrockProtocol.java | 122 +- - .../protocol/data/BedrockMappingData.java | 707 ++- + .../definition/ItemDefinitions.java | 57 +- + .../viabedrock/api/util/PacketFactory.java | 187 +- + .../viabedrock/protocol/BedrockProtocol.java | 123 +- + .../protocol/data/BedrockMappingData.java | 707 +++++--- .../generated/bedrock/CustomBlockTags.java | 5 +- - .../protocol/model/BuiltinItemFactories.java | 88 + - .../ComponentItemStackingProperties.java | 40 + + .../protocol/model/BuiltinItemFactories.java | 25 +- .../protocol/model/ItemBlockPlacer.java | 56 + .../protocol/model/ItemCookingProperties.java | 58 + - .../viabedrock/protocol/model/ItemEntry.java | 10 + - .../protocol/model/ItemRegistry.java | 224 + + .../protocol/model/ItemRegistry.java | 73 + .../protocol/model/ItemStackHeader.java | 59 + - .../model/ItemStackingProperties.java | 36 + - .../model/LegacyItemStackingProperties.java | 54 + .../protocol/model/recipe/ItemDescriptor.java | 12 +- .../protocol/model/recipe/ItemRecipeKey.java | 63 + .../protocol/packet/ClientPlayerPackets.java | 60 +- .../protocol/packet/InteractionPackets.java | 53 +- - .../protocol/packet/InventoryPackets.java | 1508 +++++-- + .../protocol/packet/InventoryPackets.java | 1511 +++++++++++++---- .../protocol/packet/JoinPackets.java | 2 +- - .../protocol/packet/MultiStatePackets.java | 32 + + .../protocol/packet/MultiStatePackets.java | 34 +- .../protocol/packet/WorldPackets.java | 12 + - .../protocol/rewriter/ItemRewriter.java | 306 +- + .../protocol/rewriter/ItemRewriter.java | 219 ++- .../rewriter/item/BundleItemRewriter.java | 58 +- - .../storage/CraftedItemSynchronization.java | 128 + - .../protocol/storage/CraftingDataTracker.java | 206 + + .../storage/CraftedItemSynchronization.java | 128 ++ + .../protocol/storage/CraftingDataTracker.java | 206 +++ .../storage/InventoryRequestRecovery.java | 80 + - .../storage/InventoryRequestStorage.java | 202 + - .../storage/InventoryRequestTracker.java | 564 +++ - .../protocol/storage/InventoryTracker.java | 243 +- + .../storage/InventoryRequestStorage.java | 232 +++ + .../storage/InventoryRequestTracker.java | 725 ++++++++ + .../protocol/storage/InventoryTracker.java | 267 ++- + .../storage/PlayerInventoryScreenTracker.java | 202 +++ .../protocol/types/item/BedrockItemType.java | 68 +- .../types/item/NetworkItemStackContext.java | 18 + .../item/NetworkItemStackDescriptorType.java | 79 +- - .../data/bedrock/item_factories.json | 3815 +++++++++++++++++ .../viabedrock/data/custom/block_tags.json | 16 +- - .../api/model/container/ContainerTest.java | 198 + - .../model/container/HorseContainerTest.java | 187 + - .../NativeContainerActionSessionTest.java | 128 + - .../NativeContainerAutoPlacementTest.java | 165 + + .../api/model/container/ContainerTest.java | 198 +++ + .../model/container/HorseContainerTest.java | 187 ++ + .../NativeContainerActionSessionTest.java | 132 ++ + .../NativeContainerAutoPlacementTest.java | 172 ++ + .../NativeContainerLegacyActionsTest.java | 5 +- .../container/NativeTransferQuantityTest.java | 60 + .../block/BrewingStandContainerTest.java | 29 + - .../block/EnchantmentContainerTest.java | 120 + + .../block/EnchantmentContainerTest.java | 120 ++ .../container/block/LecternContainerTest.java | 84 + .../player/OffhandRequestSlotsTest.java | 37 + - .../definition/ItemDefinitionsTest.java | 45 + - .../api/util/ContainerContentPacketTest.java | 95 + - .../ComponentItemStackingPropertiesTest.java | 173 + - .../model/ItemCookingPropertiesTest.java | 201 + - .../protocol/model/ItemRegistryTest.java | 618 +++ + .../definition/ItemDefinitionsTest.java | 86 + + .../api/util/ContainerContentPacketTest.java | 95 ++ + .../ComponentItemStackingPropertiesTest.java | 173 ++ + .../model/ItemCookingPropertiesTest.java | 201 +++ + .../protocol/model/ItemRegistryTest.java | 313 ++++ .../protocol/model/ItemStackHeaderTest.java | 45 + - .../LegacyItemStackingPropertiesTest.java | 104 + + .../LegacyItemStackingPropertiesTest.java | 104 ++ .../model/recipe/ItemRecipeKeyTest.java | 91 + - .../packet/BundleSelectionPacketTest.java | 225 + + .../packet/BundleSelectionPacketTest.java | 225 +++ .../packet/ClientPlayerPacketsTest.java | 30 + - .../packet/ContainerDataPacketTest.java | 119 + - .../packet/ContainerLifecyclePacketTest.java | 395 ++ + .../packet/ContainerDataPacketTest.java | 119 ++ + .../packet/ContainerLifecyclePacketTest.java | 395 +++++ .../protocol/packet/DropTransactionTest.java | 46 + - .../packet/HotbarSelectionPacketTest.java | 155 + - .../protocol/packet/InventoryPacketsTest.java | 254 ++ - .../packet/MountInventoryPacketTest.java | 163 + - .../NativeContainerOpeningPacketTest.java | 196 +- + .../packet/HotbarSelectionPacketTest.java | 155 ++ + .../protocol/packet/InventoryPacketsTest.java | 254 +++ + .../packet/MountInventoryPacketTest.java | 163 ++ + .../NativeContainerOpeningPacketTest.java | 198 ++- .../protocol/packet/SpearJabTest.java | 6 +- .../ItemRewriterBlockEntityItemTest.java | 68 + - .../rewriter/item/BundleItemRewriterTest.java | 279 ++ - .../storage/BundleHolderPacketTest.java | 450 ++ - .../storage/BundleInventoryActionTest.java | 548 +++ - .../CraftedItemSynchronizationTest.java | 287 ++ - .../InventoryContainerGenerationTest.java | 118 + + .../rewriter/item/BundleItemRewriterTest.java | 279 +++ + .../storage/BundleHolderPacketTest.java | 450 +++++ + .../storage/BundleInventoryActionTest.java | 548 ++++++ + .../CraftedItemSynchronizationTest.java | 287 ++++ + .../InventoryContainerGenerationTest.java | 118 ++ .../storage/InventoryRequestRecoveryTest.java | 68 + - .../InventoryRequestTrackerQueueTest.java | 136 + - .../storage/InventoryRequestTrackerTest.java | 385 ++ - .../storage/InventoryTrackerTest.java | 124 + - .../NativeAutoPlacementRecoveryTest.java | 120 + - .../NativeContainerActionBackendTest.java | 95 + - .../storage/NativeContainerResponseTest.java | 287 ++ + .../InventoryRequestTrackerQueueTest.java | 136 ++ + .../storage/InventoryRequestTrackerTest.java | 385 +++++ + .../storage/InventoryTrackerTest.java | 124 ++ + .../storage/InventoryTransferBatchTest.java | 258 +++ + .../NativeAutoPlacementRecoveryTest.java | 126 ++ + .../NativeContainerActionBackendTest.java | 95 ++ + .../storage/NativeContainerResponseTest.java | 287 ++++ + .../PlayerInventoryScreenCodecTest.java | 71 + + .../PlayerInventoryScreenTrackerTest.java | 271 +++ .../storage/StonecutterRecipePacketTest.java | 53 + .../types/inventory/ItemStackCodecTest.java | 12 +- - .../item/NetworkItemConstructionTest.java | 165 + + .../item/NetworkItemConstructionTest.java | 165 ++ .../types/item/NetworkItemCountTest.java | 35 +- .../types/item/NetworkItemStackFixtures.java | 69 + - 111 files changed, 19943 insertions(+), 952 deletions(-) + 112 files changed, 16332 insertions(+), 974 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/PlayerInventoryScreenRequest.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/ContainerOpenContext.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/HorseContainer.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/NativeContainerActionBackend.java @@ -584,29 +635,22 @@ from the actual emitted action. create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/block/CraftingTableContainer.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/block/EnchantmentContainer.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/block/FurnaceContainer.java - create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/block/Generic3x3Container.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/block/GrindstoneContainer.java - create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/block/HopperContainer.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/block/LecternContainer.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/block/SmithingContainer.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/block/SmokerContainer.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/block/StonecutterContainer.java - create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/model/BuiltinItemFactories.java - create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/model/ComponentItemStackingProperties.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/model/ItemBlockPlacer.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/model/ItemCookingProperties.java - create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/model/ItemRegistry.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/model/ItemStackHeader.java - create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/model/ItemStackingProperties.java - create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/model/LegacyItemStackingProperties.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/model/recipe/ItemRecipeKey.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/storage/CraftedItemSynchronization.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/storage/CraftingDataTracker.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryRequestRecovery.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryRequestStorage.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryRequestTracker.java + create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/storage/PlayerInventoryScreenTracker.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/types/item/NetworkItemStackContext.java - create mode 100644 src/main/resources/assets/viabedrock/data/bedrock/item_factories.json create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/container/ContainerTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/container/HorseContainerTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/container/NativeContainerActionSessionTest.java @@ -620,7 +664,6 @@ from the actual emitted action. create mode 100644 src/test/java/net/raphimc/viabedrock/api/util/ContainerContentPacketTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/model/ComponentItemStackingPropertiesTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/model/ItemCookingPropertiesTest.java - create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/model/ItemRegistryTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/model/ItemStackHeaderTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/model/LegacyItemStackingPropertiesTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/model/recipe/ItemRecipeKeyTest.java @@ -641,34 +684,21 @@ from the actual emitted action. create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/storage/InventoryRequestTrackerQueueTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/storage/InventoryRequestTrackerTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/storage/InventoryTrackerTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/storage/InventoryTransferBatchTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/storage/NativeAutoPlacementRecoveryTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/storage/NativeContainerActionBackendTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/storage/NativeContainerResponseTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/storage/PlayerInventoryScreenCodecTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/storage/PlayerInventoryScreenTrackerTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/storage/StonecutterRecipePacketTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/types/item/NetworkItemConstructionTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/types/item/NetworkItemStackFixtures.java -diff --git a/Data Asset Sources.md b/Data Asset Sources.md -index 399698635..df11b4386 100644 ---- a/Data Asset Sources.md -+++ b/Data Asset Sources.md -@@ -142,3 +142,11 @@ Curated native block aliases for Bedrock 1.26.51.1, build 51061372, protocol 219 - The matching executable and private registry lookup probes supply these identities. - Keep primary registrations ahead of alias lookup. - Namespace fallback and complete runtime registration still need native comparison. -+ -+### data/bedrock/item_factories.json -+ -+Curated constructor defaults and cooking traits for Bedrock 1.26.51.1, build 51061372, protocol 2193. -+The matching native executable has SHA-256 `537c0aee2e79afbdc94b44b28e00f466ae62bc50e2733d953b430db9dbaa9ee7`. -+Private constructor and typed component-loader probes supply the values. -+The table retains factory identity separately from advertised runtime IDs. -+It covers stacking state and cooking categories; it does not define icons, use actions or equipment behavior. diff --git a/build.gradle b/build.gradle -index e9814cbf0..c8a7fa531 100644 +index 7c771c896..91d656fa1 100644 --- a/build.gradle +++ b/build.gradle -@@ -151,7 +151,8 @@ dependencies { +@@ -150,7 +150,8 @@ dependencies { testImplementation "com.viaversion:viaversion-common:5.12.0" testImplementation "com.google.guava:guava:33.7.1-jre" @@ -676,13 +706,123 @@ index e9814cbf0..c8a7fa531 100644 + testImplementation "io.netty:netty-buffer:4.2.18.Final" + testImplementation "io.netty:netty-transport:4.2.18.Final" testImplementation "org.junit.jupiter:junit-jupiter:6.1.3" + testImplementation "org.mockito:mockito-core:5.24.0" testRuntimeOnly "org.junit.platform:junit-platform-launcher:6.1.3" - } +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/PlayerInventoryScreenClose.java b/src/main/java/net/raphimc/viabedrock/api/model/PlayerInventoryScreenClose.java +new file mode 100644 +index 000000000..652570b8d +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/model/PlayerInventoryScreenClose.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 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; ++ ++/** Identifies one installed player screen, independently of Java's persistent menu zero. */ ++public record PlayerInventoryScreenClose(long opening) { ++ ++ public static final String CHANNEL = "viabedrock:player_inventory_screen_close_v1"; ++ public static final Type TYPE = new Type<>(PlayerInventoryScreenClose.class) { ++ ++ @Override ++ public PlayerInventoryScreenClose read(final ByteBuf buffer) { ++ if (buffer.readableBytes() > 18 || Types.VAR_INT.readPrimitive(buffer) != 1 ++ || Types.VAR_INT.readPrimitive(buffer) != ProtocolConstants.BEDROCK_PROTOCOL_VERSION) { ++ throw new IllegalArgumentException("Unsupported inventory screen message"); ++ } ++ final var result = new PlayerInventoryScreenClose(Types.VAR_LONG.readPrimitive(buffer)); ++ if (buffer.isReadable()) { ++ throw new IllegalArgumentException("Trailing inventory screen fields"); ++ } ++ return result; ++ } ++ ++ @Override ++ public void write(final ByteBuf buffer, final PlayerInventoryScreenClose value) { ++ Types.VAR_INT.writePrimitive(buffer, 1); ++ Types.VAR_INT.writePrimitive(buffer, ProtocolConstants.BEDROCK_PROTOCOL_VERSION); ++ Types.VAR_LONG.writePrimitive(buffer, value.opening); ++ } ++ }; ++ ++ public PlayerInventoryScreenClose { ++ if (opening <= 0) { ++ throw new IllegalArgumentException("Invalid inventory screen opening"); ++ } ++ } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/PlayerInventoryScreenRequest.java b/src/main/java/net/raphimc/viabedrock/api/model/PlayerInventoryScreenRequest.java +new file mode 100644 +index 000000000..f52cd9a93 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/model/PlayerInventoryScreenRequest.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 com.viaversion.viaversion.api.type.Types; ++import io.netty.buffer.ByteBuf; ++import net.raphimc.viabedrock.protocol.data.ProtocolConstants; ++ ++/** Identifies one installed player screen, independently of Java's persistent menu zero. */ ++public record PlayerInventoryScreenRequest(long opening, boolean open) { ++ ++ public static final String CHANNEL = "viabedrock:player_inventory_screen_request_v1"; ++ public static final Type TYPE = new Type<>(PlayerInventoryScreenRequest.class) { ++ ++ @Override ++ public PlayerInventoryScreenRequest read(final ByteBuf buffer) { ++ if (buffer.readableBytes() > 18 || Types.VAR_INT.readPrimitive(buffer) != 1 ++ || Types.VAR_INT.readPrimitive(buffer) != ProtocolConstants.BEDROCK_PROTOCOL_VERSION) { ++ throw new IllegalArgumentException("Unsupported inventory screen message"); ++ } ++ final long opening = Types.VAR_LONG.readPrimitive(buffer); ++ final int flag = buffer.readUnsignedByte(); ++ if (flag > 1) { ++ throw new IllegalArgumentException("Invalid inventory screen flag"); ++ } ++ final var result = new PlayerInventoryScreenRequest(opening, flag == 1); ++ if (buffer.isReadable()) { ++ throw new IllegalArgumentException("Trailing inventory screen fields"); ++ } ++ return result; ++ } ++ ++ @Override ++ public void write(final ByteBuf buffer, final PlayerInventoryScreenRequest value) { ++ Types.VAR_INT.writePrimitive(buffer, 1); ++ Types.VAR_INT.writePrimitive(buffer, ProtocolConstants.BEDROCK_PROTOCOL_VERSION); ++ Types.VAR_LONG.writePrimitive(buffer, value.opening); ++ buffer.writeBoolean(value.open); ++ } ++ }; ++ ++ public PlayerInventoryScreenRequest { ++ if (opening <= 0) { ++ throw new IllegalArgumentException("Invalid inventory screen opening"); ++ } ++ } ++ ++} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/container/ChestContainer.java b/src/main/java/net/raphimc/viabedrock/api/model/container/ChestContainer.java -index a37f1b071..d535541bf 100644 +index 913255f8f..d535541bf 100644 --- a/src/main/java/net/raphimc/viabedrock/api/model/container/ChestContainer.java +++ b/src/main/java/net/raphimc/viabedrock/api/model/container/ChestContainer.java -@@ -20,13 +20,74 @@ package net.raphimc.viabedrock.api.model.container; +@@ -20,14 +20,74 @@ package net.raphimc.viabedrock.api.model.container; import com.viaversion.viaversion.api.connection.UserConnection; import com.viaversion.viaversion.api.minecraft.BlockPosition; import com.viaversion.viaversion.libs.mcstructs.text.TextComponent; @@ -697,7 +837,8 @@ index a37f1b071..d535541bf 100644 public class ChestContainer extends Container { - public ChestContainer(final UserConnection user, final byte containerId, final TextComponent title, final BlockPosition position, final int size) { -- super(user, containerId, ContainerType.CONTAINER, title, position, size, CustomBlockTags.CHEST, CustomBlockTags.TRAPPED_CHEST); +- super(user, containerId, ContainerType.CONTAINER, title, position, size, CustomBlockTags.CHEST, CustomBlockTags.TRAPPED_CHEST, +- CustomBlockTags.BARREL, CustomBlockTags.SHULKER_BOX, CustomBlockTags.ENDER_CHEST); + private ContainerEnumName requestName; + + public ChestContainer( @@ -2241,10 +2382,10 @@ index 000000000..0bc05269f +} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/container/NativeContainerActionBackend.java b/src/main/java/net/raphimc/viabedrock/api/model/container/NativeContainerActionBackend.java new file mode 100644 -index 000000000..1b93e64c1 +index 000000000..9470005ed --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/model/container/NativeContainerActionBackend.java -@@ -0,0 +1,143 @@ +@@ -0,0 +1,85 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -2253,17 +2394,10 @@ index 000000000..1b93e64c1 +package net.raphimc.viabedrock.api.model.container; + +import com.viaversion.viaversion.api.connection.UserConnection; -+import com.viaversion.viaversion.libs.gson.JsonElement; +import com.viaversion.viaversion.libs.gson.JsonParser; -+import com.viaversion.viaversion.libs.gson.JsonPrimitive; -+import java.util.ArrayList; -+import java.util.HashMap; -+import java.util.Map; +import java.util.Set; +import net.raphimc.viabedrock.api.model.NativeContainerMessage; -+import net.raphimc.viabedrock.api.model.NativeItemHoverText; +import net.raphimc.viabedrock.api.resourcepack.ui.ContainerUi; -+import net.raphimc.viabedrock.protocol.model.BedrockItem; +import net.raphimc.viabedrock.protocol.storage.GameSessionStorage; +import net.raphimc.viabedrock.protocol.storage.InventoryTracker; +import net.raphimc.viabedrock.protocol.storage.NativeContainerUiState; @@ -2293,7 +2427,7 @@ index 000000000..1b93e64c1 + || menu == null || tracker.currentContainerGeneration() <= 0 + || !NativeContainerAutoPlacement.supported(menu) + || Byte.toUnsignedInt(menu.javaContainerId()) != message.menuId() -+ || !matches(menu, message.kind()) ++ || !NativeStorageContainers.matches(menu, message.kind()) + || menu.size() != message.kind().containerSlots()) { + return null; + } @@ -2301,17 +2435,6 @@ index 000000000..1b93e64c1 + new NativeContainerAutoPlacement.Binding(menu, tracker.currentContainerGeneration(), message.sequence()), message); + } + -+ private static boolean matches(final Container menu, final ContainerUi.Kind kind) { -+ return switch (kind) { -+ case CHEST_3_ROWS, CHEST_6_ROWS, ENDER_CHEST, SHULKER_BOX, BARREL -> menu.getClass() == ChestContainer.class; -+ case HOPPER -> menu.type() == net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ContainerType.HOPPER -+ || menu.type() == net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ContainerType.MINECART_HOPPER; -+ case DISPENSER -> menu.type() == net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ContainerType.DISPENSER; -+ case BREWING_STAND -> false; -+ case DROPPER -> menu.type() == net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ContainerType.DROPPER; -+ }; -+ } -+ + @Override + public boolean current() { + final var tracker = this.user.get(InventoryTracker.class); @@ -2344,56 +2467,16 @@ index 000000000..1b93e64c1 + binding.presentationSequence())) { + return Set.of(); + } -+ final var slots = new ArrayList(); -+ final Container menu = binding.menu(); -+ for (int index = 0; index < menu.size(); index++) { -+ slots.add(new ContainerUi.Slot(index, presentation(menu.getItem(index)))); -+ } -+ final var inventory = tracker.getInventoryContainer(); -+ for (int index = 0; index < 36; index++) { -+ final int bedrockSlot = index < 27 ? index + 9 : index - 27; -+ slots.add( -+ new ContainerUi.Slot( -+ menu.size() + index, presentation(inventory.getItem(bedrockSlot)))); -+ } -+ final var scene = -+ program.resolve( -+ new ContainerUi.MenuView( -+ menuId, -+ kind, -+ title, -+ slots, -+ presentation(tracker.getHudContainer().getItem(0)), kind.initialData())); -+ if (scene == null || !scene.getAsJsonArray("missing_templates").isEmpty()) { -+ return Set.of(); -+ } -+ final var scope = ContainerUi.actionScope(scene, menu.size()); -+ for (final int slot : scope.referencedSlots()) { -+ final var item = menu.getItem(slot); -+ if (NativeItemHoverText.custom(item.isEmpty(), item.tag()) == null) { -+ return Set.of(); -+ } -+ } -+ return scope.subset() ? scope.slots() : Set.of(); -+ } -+ -+ private static Map presentation(final BedrockItem item) { -+ final Map values = new HashMap<>(); -+ values.put("#inventory_stack_count", new JsonPrimitive(item.isEmpty() ? 0 : item.amount())); -+ final String hover = NativeItemHoverText.custom(item.isEmpty(), item.tag()); -+ if (hover != null) { -+ values.put("#hover_text", new JsonPrimitive(hover)); -+ } -+ return values; ++ return NativeContainerActionSlots.resolve(user, binding.menu(), menuId, kind, title, program); + } + +} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/container/NativeContainerActionSession.java b/src/main/java/net/raphimc/viabedrock/api/model/container/NativeContainerActionSession.java new file mode 100644 -index 000000000..1cddc51b5 +index 000000000..07ab6baae --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/model/container/NativeContainerActionSession.java -@@ -0,0 +1,133 @@ +@@ -0,0 +1,136 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -2413,6 +2496,7 @@ index 000000000..1cddc51b5 + +import java.util.List; +import java.util.Set; ++import java.util.function.BiPredicate; +import java.util.function.Consumer; +import java.util.function.Supplier; +import java.util.function.ToIntFunction; @@ -2453,6 +2537,7 @@ index 000000000..1cddc51b5 + revision, + sourceSlot, + user.get(net.raphimc.viabedrock.protocol.rewriter.ItemRewriter.class)::maxStackSize, ++ user.get(net.raphimc.viabedrock.protocol.rewriter.ItemRewriter.class)::canMerge, + request -> + net.raphimc.viabedrock.api.util.PacketFactory.sendBedrockInventoryRequest( + user, new ItemStackRequestInfo[] {request})); @@ -2465,6 +2550,7 @@ index 000000000..1cddc51b5 + final int revision, + final int sourceSlot, + final ToIntFunction maximumStackSize, ++ final BiPredicate canMerge, + final Consumer sender) { + final InventoryTracker inventory = user.get(InventoryTracker.class); + final InventoryRequestTracker requests = user.get(InventoryRequestTracker.class); @@ -2490,7 +2576,7 @@ index 000000000..1cddc51b5 + sequence, + sourceSlot, + player, -+ maximumStackSize.applyAsInt(source.getItem(sourceSlot))); ++ maximumStackSize.applyAsInt(source.getItem(sourceSlot)), canMerge); + if (plan == null) { + return false; + } @@ -2505,8 +2591,8 @@ index 000000000..1cddc51b5 + TextProcessingEventOrigin.unknown); + requests.setPredictingClick(true); + try { -+ source.setItem(plan.sourceSlot(), plan.sourceAfter()); -+ player.setItem(plan.destinationSlot(), plan.destinationAfter()); ++ source.setItem(plan.sourceSlot(), plan.transfer().sourceAfter()); ++ player.setItem(plan.transfer().destinationSlot(), plan.transfer().destinationAfter()); + requests.addRequest( + new InventoryRequestStorage( + request, @@ -2529,10 +2615,10 @@ index 000000000..1cddc51b5 +} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/container/NativeContainerAutoPlacement.java b/src/main/java/net/raphimc/viabedrock/api/model/container/NativeContainerAutoPlacement.java new file mode 100644 -index 000000000..54fbeeaa1 +index 000000000..519b2c3e7 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/model/container/NativeContainerAutoPlacement.java -@@ -0,0 +1,155 @@ +@@ -0,0 +1,91 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -2540,11 +2626,8 @@ index 000000000..54fbeeaa1 + */ +package net.raphimc.viabedrock.api.model.container; + -+import com.viaversion.nbt.tag.ByteTag; -+ ++import java.util.function.BiPredicate; +import net.raphimc.viabedrock.api.model.container.player.InventoryContainer; -+import net.raphimc.viabedrock.api.model.container.block.Generic3x3Container; -+import net.raphimc.viabedrock.api.model.container.block.HopperContainer; +import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ContainerEnumName; +import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ContainerType; +import net.raphimc.viabedrock.protocol.model.BedrockItem; @@ -2562,7 +2645,8 @@ index 000000000..54fbeeaa1 + final long presentationSequence, + final int sourceSlot, + final InventoryContainer inventory, -+ final int maximumStackSize) { ++ final int maximumStackSize, ++ final BiPredicate canMerge) { + if (!binding.current(current, generation, presentationSequence)) { + throw new IllegalArgumentException("Native container action belongs to a stale menu"); + } @@ -2578,52 +2662,22 @@ index 000000000..54fbeeaa1 + if (source.isEmpty() || !hasServerIdentity(source)) { + return null; + } -+ requireUnlocked(source); -+ // Original combined provider is Bedrock hotbar 0..8, then inventory 9..35. -+ // Try compatible occupied slots before empty slots, in ascending provider ordinal. -+ for (final boolean merge : new boolean[] {true, false}) { -+ for (int destinationSlot = 0; destinationSlot < 36; destinationSlot++) { -+ final BedrockItem destination = inventory.getItem(destinationSlot); -+ if (!inventory.canPlaceItem(destinationSlot, source)) { -+ continue; -+ } -+ if (merge) { -+ if (destination.isEmpty() -+ || !hasServerIdentity(destination) -+ || destination.isDifferent(source) -+ || destination.amount() >= maximumStackSize) { -+ continue; -+ } -+ } else if (!destination.isEmpty()) { -+ continue; -+ } -+ requireUnlocked(destination); -+ final var action = -+ new ItemStackRequestAction.PlaceAction( -+ NativeTransferQuantity.ONE.count(source.amount()), -+ current.requestSlotInfo(sourceSlot, source.netId()), -+ inventory.requestSlotInfo( -+ destinationSlot, -+ destination.isEmpty() ? 0 : destination.netId())); -+ return new Plan( -+ sourceSlot, destinationSlot, source.copy(), destination.copy(), action); -+ } ++ final var transfer = NativeContainerTransfer.plan(source, inventory.getItems(), maximumStackSize, canMerge, ++ slot -> inventory.canPlaceItem(slot, source) ++ && (inventory.getItem(slot).isEmpty() || hasServerIdentity(inventory.getItem(slot)))); ++ if (transfer == null) { ++ return null; + } -+ return null; ++ final var action = new ItemStackRequestAction.PlaceAction( ++ NativeTransferQuantity.ONE.count(source.amount()), ++ current.requestSlotInfo(sourceSlot, source.netId()), ++ inventory.requestSlotInfo(transfer.destinationSlot(), transfer.destinationBefore().isEmpty() ++ ? 0 : transfer.destinationBefore().netId())); ++ return new Plan(sourceSlot, transfer, action); + } + + static boolean supported(final Container container) { -+ if (container == null) { -+ return false; -+ } -+ final boolean provider = switch (container.type()) { -+ case CONTAINER, MINECART_CHEST, CHEST_BOAT -> container.getClass() == ChestContainer.class -+ && (container.size() == 27 || container.size() == 54); -+ case HOPPER, MINECART_HOPPER -> container.getClass() == HopperContainer.class && container.size() == 5; -+ case DISPENSER, DROPPER -> container.getClass() == Generic3x3Container.class && container.size() == 9; -+ default -> false; -+ }; -+ if (!provider) { ++ if (!NativeStorageContainers.supported(container)) { + return false; + } + final ContainerEnumName requestName = container.getFullContainerName(0).name(); @@ -2632,15 +2686,6 @@ index 000000000..54fbeeaa1 + && (requestName == ContainerEnumName.BarrelContainer || requestName == ContainerEnumName.ShulkerBoxContainer); + } + -+ private static void requireUnlocked(final BedrockItem item) { -+ if (item.tag() != null -+ && item.tag().get("minecraft:item_lock") instanceof final ByteTag value -+ && value.asByte() != 0) { -+ throw new IllegalArgumentException( -+ "Native locked-item transfer permission is not supported"); -+ } -+ } -+ + private static boolean hasServerIdentity(final BedrockItem item) { + return item.netId() != null && item.netId() > 0; + } @@ -2661,31 +2706,8 @@ index 000000000..54fbeeaa1 + } + } + -+ public record Plan( -+ int sourceSlot, -+ int destinationSlot, -+ BedrockItem sourceBefore, -+ BedrockItem destinationBefore, -+ ItemStackRequestAction.PlaceAction action) { -+ -+ public BedrockItem sourceAfter() { -+ if (this.sourceBefore.amount() == this.action.count()) { -+ return BedrockItem.empty(); -+ } -+ final BedrockItem result = this.sourceBefore.copy(); -+ result.setAmount(result.amount() - this.action.count()); -+ return result; -+ } -+ -+ public BedrockItem destinationAfter() { -+ final BedrockItem result = -+ this.destinationBefore.isEmpty() -+ ? this.sourceBefore.copy() -+ : this.destinationBefore.copy(); -+ result.setAmount(this.destinationBefore.amount() + this.action.count()); -+ return result; -+ } -+ } ++ public record Plan(int sourceSlot, NativeContainerTransfer.Plan transfer, ++ ItemStackRequestAction.PlaceAction action) { } + +} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/container/NativeTransferQuantity.java b/src/main/java/net/raphimc/viabedrock/api/model/container/NativeTransferQuantity.java @@ -3980,65 +4002,30 @@ index 000000000..09fb14352 + +} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/container/block/Generic3x3Container.java b/src/main/java/net/raphimc/viabedrock/api/model/container/block/Generic3x3Container.java -new file mode 100644 -index 000000000..24f92acf4 ---- /dev/null +index 1d4e45f17..24f92acf4 100644 +--- a/src/main/java/net/raphimc/viabedrock/api/model/container/block/Generic3x3Container.java +++ b/src/main/java/net/raphimc/viabedrock/api/model/container/block/Generic3x3Container.java -@@ -0,0 +1,54 @@ -+/* -+ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock -+ * Copyright (C) 2023-2025 RK_01/RaphiMC and contributors -+ * -+ * This program is free software: you can redistribute it and/or modify -+ * it under the terms of the GNU General Public License as published by -+ * the Free Software Foundation, either version 3 of the License, or -+ * (at your option) any later version. -+ * -+ * This program is distributed in the hope that it will be useful, -+ * but WITHOUT ANY WARRANTY; without even the implied warranty of -+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -+ * GNU General Public License for more details. -+ * -+ * You should have received a copy of the GNU General Public License -+ * along with this program. If not, see . -+ */ -+package net.raphimc.viabedrock.api.model.container.block; -+ -+import com.viaversion.viaversion.api.connection.UserConnection; -+import com.viaversion.viaversion.api.minecraft.BlockPosition; -+import com.viaversion.viaversion.libs.mcstructs.text.TextComponent; -+ -+import net.raphimc.viabedrock.api.model.container.Container; +@@ -22,8 +22,10 @@ import com.viaversion.viaversion.api.minecraft.BlockPosition; + import com.viaversion.viaversion.libs.mcstructs.text.TextComponent; + + import net.raphimc.viabedrock.api.model.container.Container; +import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ContainerEnumName; -+import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ContainerType; -+import net.raphimc.viabedrock.protocol.data.generated.bedrock.CustomBlockTags; + import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ContainerType; + import net.raphimc.viabedrock.protocol.data.generated.bedrock.CustomBlockTags; +import net.raphimc.viabedrock.protocol.model.FullContainerName; -+ -+public class Generic3x3Container extends Container { -+ -+ public Generic3x3Container( -+ final UserConnection user, -+ final byte containerId, -+ final ContainerType type, -+ final TextComponent title, -+ final BlockPosition position) { -+ super( -+ user, -+ containerId, -+ type, -+ title, -+ position, -+ 9, -+ CustomBlockTags.DISPENSER, -+ CustomBlockTags.DROPPER); -+ } -+ + + public class Generic3x3Container extends Container { + +@@ -44,4 +46,9 @@ public class Generic3x3Container extends Container { + CustomBlockTags.DROPPER); + } + + @Override + public FullContainerName getFullContainerName(final int slot) { + return new FullContainerName(ContainerEnumName.LevelEntityContainer, null); + } + -+} + } diff --git a/src/main/java/net/raphimc/viabedrock/api/model/container/block/GrindstoneContainer.java b/src/main/java/net/raphimc/viabedrock/api/model/container/block/GrindstoneContainer.java new file mode 100644 index 000000000..fdf10b0a1 @@ -4125,65 +4112,30 @@ index 000000000..fdf10b0a1 + +} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/container/block/HopperContainer.java b/src/main/java/net/raphimc/viabedrock/api/model/container/block/HopperContainer.java -new file mode 100644 -index 000000000..dc9109dd6 ---- /dev/null +index f11d7be62..dc9109dd6 100644 +--- a/src/main/java/net/raphimc/viabedrock/api/model/container/block/HopperContainer.java +++ b/src/main/java/net/raphimc/viabedrock/api/model/container/block/HopperContainer.java -@@ -0,0 +1,54 @@ -+/* -+ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock -+ * Copyright (C) 2023-2026 RK_01/RaphiMC and contributors -+ * -+ * This program is free software: you can redistribute it and/or modify -+ * it under the terms of the GNU General Public License as published by -+ * the Free Software Foundation, either version 3 of the License, or -+ * (at your option) any later version. -+ * -+ * This program is distributed in the hope that it will be useful, -+ * but WITHOUT ANY WARRANTY; without even the implied warranty of -+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -+ * GNU General Public License for more details. -+ * -+ * You should have received a copy of the GNU General Public License -+ * along with this program. If not, see . -+ */ -+package net.raphimc.viabedrock.api.model.container.block; -+ -+import com.viaversion.viaversion.api.connection.UserConnection; -+import com.viaversion.viaversion.api.minecraft.BlockPosition; -+import com.viaversion.viaversion.libs.mcstructs.text.TextComponent; -+ -+import net.raphimc.viabedrock.api.model.container.Container; +@@ -22,8 +22,10 @@ import com.viaversion.viaversion.api.minecraft.BlockPosition; + import com.viaversion.viaversion.libs.mcstructs.text.TextComponent; + + import net.raphimc.viabedrock.api.model.container.Container; +import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ContainerEnumName; -+import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ContainerType; -+import net.raphimc.viabedrock.protocol.data.generated.bedrock.CustomBlockTags; + import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ContainerType; + import net.raphimc.viabedrock.protocol.data.generated.bedrock.CustomBlockTags; +import net.raphimc.viabedrock.protocol.model.FullContainerName; -+ -+public class HopperContainer extends Container { -+ -+ public HopperContainer( -+ final UserConnection user, -+ final byte containerId, -+ final TextComponent title, -+ final BlockPosition position) { -+ this(user, containerId, ContainerType.HOPPER, title, position); -+ } -+ -+ public HopperContainer( -+ final UserConnection user, -+ final byte containerId, -+ final ContainerType type, -+ final TextComponent title, -+ final BlockPosition position) { -+ super(user, containerId, type, title, position, 5, CustomBlockTags.HOPPER); -+ } -+ + + public class HopperContainer extends Container { + +@@ -44,4 +46,9 @@ public class HopperContainer extends Container { + super(user, containerId, type, title, position, 5, CustomBlockTags.HOPPER); + } + + @Override + public FullContainerName getFullContainerName(final int slot) { + return new FullContainerName(ContainerEnumName.LevelEntityContainer, null); + } + -+} + } diff --git a/src/main/java/net/raphimc/viabedrock/api/model/container/block/LecternContainer.java b/src/main/java/net/raphimc/viabedrock/api/model/container/block/LecternContainer.java new file mode 100644 index 000000000..db220be4c @@ -5541,7 +5493,7 @@ index 22ad60398..c10493db1 100644 mobEquipment.write(Types.BYTE, (byte) 1); // slot mobEquipment.write(Types.BYTE, (byte) 0); // selected slot diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/definition/ItemDefinitions.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/definition/ItemDefinitions.java -index 8a5a49326..ff686f4ac 100644 +index f8282f25a..c0b6e4525 100644 --- a/src/main/java/net/raphimc/viabedrock/api/resourcepack/definition/ItemDefinitions.java +++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/definition/ItemDefinitions.java @@ -19,6 +19,7 @@ package net.raphimc.viabedrock.api.resourcepack.definition; @@ -5630,8 +5582,18 @@ index 8a5a49326..ff686f4ac 100644 public void addFromNetworkTag(final String identifier, final CompoundTag tag) { final ItemDefinition itemDefinition = new ItemDefinition(identifier); if (tag.get("components") instanceof CompoundTag components) { +@@ -118,7 +162,8 @@ public class ItemDefinitions { + } + + public String displayNameComponent() { +- return this.displayNameComponent; ++ // ComponentItem's native description getter ignores an empty override. ++ return this.displayNameComponent == null || this.displayNameComponent.isEmpty() ? null : this.displayNameComponent; + } + + 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 c51ffd409..bcefe0aea 100644 +index c51ffd409..fba5d12ab 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; @@ -5680,7 +5642,7 @@ index c51ffd409..bcefe0aea 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,159 @@ public final class PacketFactory { +@@ -53,89 +68,162 @@ public final class PacketFactory { } public static void sendJavaSystemChat(final UserConnection user, final Tag message) { @@ -5756,22 +5718,25 @@ index c51ffd409..bcefe0aea 100644 + final PacketWrapper cursor = PacketWrapper.create(ClientboundPackets26_3.SET_CURSOR_ITEM, user); + cursor.write(VersionedTypes.V26_3.item, tracker.getHudContainer().getJavaItem(0)); + cursor.send(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 sendBedrockInventoryRequest( + final UserConnection user, final ItemStackRequestInfo[] requests) { + // Callers register requests before or during prediction; capture the final state before sending. + final InventoryRequestTracker tracker = user.get(InventoryRequestTracker.class); + for (final ItemStackRequestInfo request : requests) { -+ tracker.getRequest(request.requestId()).capturePrediction(user.get(InventoryTracker.class)); ++ final var storage = tracker.getRequest(request.requestId()); ++ if (!storage.hasPrediction()) { ++ storage.capturePrediction(user.get(InventoryTracker.class)); ++ } + } + 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 = @@ -5863,7 +5828,7 @@ index c51ffd409..bcefe0aea 100644 loadingScreen.write(BedrockTypes.VAR_INT, type.getValue()); // type loadingScreen.write(Types.BOOLEAN, loadingScreenId != null); // has loading screen id if (loadingScreenId != null) { -@@ -146,20 +231,53 @@ public final class PacketFactory { +@@ -146,20 +234,53 @@ public final class PacketFactory { public static void writeJavaDisconnect(final PacketWrapper wrapper, final String reason) { switch (wrapper.getPacketType().state()) { @@ -5926,7 +5891,7 @@ index c51ffd409..bcefe0aea 100644 wrapper.write(Types.BOOLEAN, false); // override limiter wrapper.write(Types.BOOLEAN, false); // always show diff --git a/src/main/java/net/raphimc/viabedrock/protocol/BedrockProtocol.java b/src/main/java/net/raphimc/viabedrock/protocol/BedrockProtocol.java -index 306d200e6..2f3f130a3 100644 +index 306d200e6..04e687fa4 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/BedrockProtocol.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/BedrockProtocol.java @@ -32,16 +32,18 @@ import com.viaversion.viaversion.protocols.base.ClientboundLoginPackets; @@ -6043,16 +6008,17 @@ index 306d200e6..2f3f130a3 100644 } @Override -@@ -184,6 +196,8 @@ public class BedrockProtocol +@@ -184,6 +196,9 @@ public class BedrockProtocol user.put(new ScoreboardTracker()); user.put(new InventoryTracker(user)); user.put(new NpcDialogueStorage()); + user.put(new InventoryRequestTracker(user)); ++ user.put(new PlayerInventoryScreenTracker(user)); + user.put(new CraftingDataTracker(user)); user.put(new BreakingTracker(user)); user.put(new InventoryTransactionRewriter(user)); -@@ -197,13 +211,23 @@ public class BedrockProtocol +@@ -197,13 +212,23 @@ public class BedrockProtocol } @Override @@ -6079,7 +6045,7 @@ index 306d200e6..2f3f130a3 100644 throw CancelException.generate(); } final WorldPacketBuffer worldPackets = wrapper.user().get(WorldPacketBuffer.class); -@@ -226,14 +250,22 @@ public class BedrockProtocol +@@ -226,14 +251,22 @@ public class BedrockProtocol && BEFORE_PLAY_STATE_WHITELIST.contains( packet)) { // Bedrock client can skip the login state ViaBedrock.getPlatform().getLogger().warning("Skipping LOGIN state"); @@ -6105,7 +6071,7 @@ index 306d200e6..2f3f130a3 100644 throw CancelException.generate(); } } -@@ -255,18 +287,26 @@ public class BedrockProtocol +@@ -255,18 +288,26 @@ public class BedrockProtocol kickForIllegalState(user, reason, null); } @@ -7589,84 +7555,55 @@ index a3ac81345..a3b933c2a 100644 + public static final String WORKBENCH = "workbench"; } diff --git a/src/main/java/net/raphimc/viabedrock/protocol/model/BuiltinItemFactories.java b/src/main/java/net/raphimc/viabedrock/protocol/model/BuiltinItemFactories.java -new file mode 100644 -index 000000000..4d697a1b7 ---- /dev/null +index 928f2175b..90a202d8a 100644 +--- a/src/main/java/net/raphimc/viabedrock/protocol/model/BuiltinItemFactories.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/model/BuiltinItemFactories.java -@@ -0,0 +1,88 @@ -+/* -+ * 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; -+ +@@ -5,6 +5,7 @@ + */ + package net.raphimc.viabedrock.protocol.model; + +import com.viaversion.viaversion.libs.gson.JsonArray; -+import com.viaversion.viaversion.libs.gson.JsonParser; -+import net.raphimc.viabedrock.protocol.data.ProtocolConstants; -+import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ItemVersion; -+ -+import java.io.IOException; -+import java.io.InputStreamReader; -+import java.nio.charset.StandardCharsets; -+import java.util.HashMap; + import com.viaversion.viaversion.libs.gson.JsonParser; + import net.raphimc.viabedrock.protocol.data.ProtocolConstants; + import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ItemVersion; +@@ -13,7 +14,9 @@ import java.io.IOException; + import java.io.InputStreamReader; + import java.nio.charset.StandardCharsets; + import java.util.HashMap; +import java.util.HashSet; -+import java.util.Map; + import java.util.Map; +import java.util.Set; -+ -+/** Constructor-owned native factories, independent of advertised runtime-ID assignments. */ -+final class BuiltinItemFactories { -+ -+ private static final Map FACTORIES = load(); -+ -+ static Definition get(final String identifier) { -+ return FACTORIES.get(identifier); -+ } -+ -+ private static Map load() { -+ final String path = "/assets/viabedrock/data/bedrock/item_factories.json"; -+ try (var stream = BuiltinItemFactories.class.getResourceAsStream(path)) { -+ if (stream == null) { -+ throw new IllegalStateException("Missing native item factories"); -+ } -+ final var data = JsonParser.parseReader(new InputStreamReader(stream, StandardCharsets.UTF_8)).getAsJsonObject(); -+ if (data.get("format").getAsInt() != 1 || data.get("protocol").getAsInt() != ProtocolConstants.BEDROCK_PROTOCOL_VERSION) { -+ throw new IllegalStateException("Native item factories do not match the target protocol"); -+ } + + /** Constructor-owned native factories, independent of advertised runtime-ID assignments. */ + final class BuiltinItemFactories { +@@ -55,6 +58,11 @@ final class BuiltinItemFactories { + names.put(entry.getKey(), new BuiltinItemName.Arrow(entry.getValue().getAsString()))); + data.getAsJsonArray("compoundNames").forEach(entry -> + names.put(entry.getAsString(), new BuiltinItemName.Compound())); + final var cooking = data.getAsJsonObject("cooking"); + final Set food = identifiers(cooking.getAsJsonArray("food")); + final Set blocks = identifiers(cooking.getAsJsonArray("blocks")); + final Set replacements = identifiers(cooking.getAsJsonArray("replacementBlockItems")); + final var blockAliases = cooking.getAsJsonObject("blockAliases"); -+ final Map factories = new HashMap<>(); -+ for (final var element : data.getAsJsonArray("factories")) { -+ final var factory = element.getAsJsonObject(); -+ final ItemVersion version = ItemVersion.getByValue(factory.get("version").getAsInt()); -+ final int maximum = factory.get("maximum").getAsInt(); -+ final boolean stackedByData = factory.get("stackedByData").getAsBoolean(); -+ final ItemStackingProperties stacking = switch (version) { -+ case DataDriven -> new ComponentItemStackingProperties(maximum, null, stackedByData, factory.get("damageable").getAsBoolean()); -+ case Legacy -> new LegacyItemStackingProperties(maximum, stackedByData, factory.get("maximumDamage").getAsShort(), -+ factory.has("fixedMaximum") ? factory.get("fixedMaximum").getAsInt() : null, -+ factory.has("forcedSubtype") && factory.get("forcedSubtype").getAsBoolean()); -+ default -> throw new IllegalStateException("Invalid native item factory version"); -+ }; -+ for (final var name : factory.getAsJsonArray("identifiers")) { -+ final String identifier = name.getAsString(); + final Map factories = new HashMap<>(); + for (final var element : data.getAsJsonArray("factories")) { + final var factory = element.getAsJsonObject(); +@@ -71,7 +79,10 @@ final class BuiltinItemFactories { + for (final var name : factory.getAsJsonArray("identifiers")) { + final String identifier = name.getAsString(); + final String description = componentDescriptions.has(identifier) ? componentDescriptions.get(identifier).getAsString() : null; +- if (factories.putIfAbsent(identifier, new Definition(version, stacking, description, names.get(identifier))) != null) { + final String block = blocks.contains(identifier) ? identifier + : blockAliases.has(identifier) ? blockAliases.get(identifier).getAsString() : null; + final var traits = new ItemCookingProperties(food.contains(identifier), block, replacements.contains(identifier)); -+ if (factories.putIfAbsent(identifier, new Definition(version, stacking, traits)) != null) { -+ throw new IllegalStateException("Duplicate native item factory: " + identifier); -+ } -+ } -+ } -+ return Map.copyOf(factories); -+ } catch (final IOException error) { -+ throw new IllegalStateException("Failed to load native item factories", error); -+ } -+ } -+ ++ if (factories.putIfAbsent(identifier, new Definition(version, stacking, description, names.get(identifier), traits)) != null) { + throw new IllegalStateException("Duplicate native item factory: " + identifier); + } + } +@@ -82,8 +93,18 @@ final class BuiltinItemFactories { + } + } + + private static Set identifiers(final JsonArray values) { + final Set identifiers = new HashSet<>(); + for (final var value : values) { @@ -7677,57 +7614,12 @@ index 000000000..4d697a1b7 + return Set.copyOf(identifiers); + } + -+ private BuiltinItemFactories() { } -+ -+ record Definition(ItemVersion version, ItemStackingProperties stacking, ItemCookingProperties cooking) { } -+ -+} -diff --git a/src/main/java/net/raphimc/viabedrock/protocol/model/ComponentItemStackingProperties.java b/src/main/java/net/raphimc/viabedrock/protocol/model/ComponentItemStackingProperties.java -new file mode 100644 -index 000000000..0c0c7b002 ---- /dev/null -+++ b/src/main/java/net/raphimc/viabedrock/protocol/model/ComponentItemStackingProperties.java -@@ -0,0 +1,40 @@ -+/* -+ * 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.ByteTag; -+import com.viaversion.nbt.tag.CompoundTag; -+import com.viaversion.nbt.tag.IntTag; -+ -+/** Compiled Item flags and persistent components have separate update lifetimes. */ -+public record ComponentItemStackingProperties(int compiledMaximum, Integer componentMaximum, -+ boolean stackedByData, boolean damageable) implements ItemStackingProperties { -+ -+ static final ComponentItemStackingProperties DEFAULT = new ComponentItemStackingProperties(64, null, false, false); -+ -+ @Override -+ public ComponentItemStackingProperties withComponentData(final CompoundTag components) { -+ int compiledMaximum = this.compiledMaximum; -+ boolean stackedByData = this.stackedByData; -+ if (components.get("item_properties") instanceof CompoundTag properties) { -+ compiledMaximum = properties.get("max_stack_size") instanceof IntTag size ? size.asInt() & 0xFF : 0; -+ stackedByData = properties.get("stacked_by_data") instanceof ByteTag flag && flag.asByte() != 0; -+ } -+ Integer componentMaximum = this.componentMaximum; -+ if (components.get("minecraft:max_stack_size") instanceof CompoundTag size) { -+ componentMaximum = size.get("value") instanceof ByteTag value ? Byte.toUnsignedInt(value.asByte()) : 1; -+ } -+ // Once installed, omitted or mistyped component updates do not remove durability. -+ final boolean damageable = this.damageable || components.get("minecraft:durability") instanceof CompoundTag; -+ return new ComponentItemStackingProperties(compiledMaximum, componentMaximum, stackedByData, damageable); -+ } -+ -+ @Override -+ public int maximum() { -+ return this.componentMaximum != null ? this.componentMaximum : this.compiledMaximum; -+ } -+ -+} + private BuiltinItemFactories() { } + +- record Definition(ItemVersion version, ItemStackingProperties stacking, String componentDescription, BuiltinItemName ordinaryName) { } ++ record Definition(ItemVersion version, ItemStackingProperties stacking, String componentDescription, BuiltinItemName ordinaryName, ItemCookingProperties cooking) { } + + } diff --git a/src/main/java/net/raphimc/viabedrock/protocol/model/ItemBlockPlacer.java b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemBlockPlacer.java new file mode 100644 index 000000000..455c29f95 @@ -7854,107 +7746,32 @@ index 000000000..19e0148c5 + } + +} -diff --git a/src/main/java/net/raphimc/viabedrock/protocol/model/ItemEntry.java b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemEntry.java -index 319d0e8e8..fb504c391 100644 ---- a/src/main/java/net/raphimc/viabedrock/protocol/model/ItemEntry.java -+++ b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemEntry.java -@@ -21,4 +21,14 @@ import com.viaversion.nbt.tag.CompoundTag; - import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ItemVersion; - - public record ItemEntry(String identifier, int id, boolean componentBased, ItemVersion version, CompoundTag componentData) { -+ -+ public ItemEntry { -+ componentData = componentData == null ? null : componentData.copy(); -+ } -+ -+ @Override -+ public CompoundTag componentData() { -+ return this.componentData == null ? null : this.componentData.copy(); -+ } -+ - } 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 000000000..b03817bf0 ---- /dev/null +index 8e1217d5a..88f258dbb 100644 +--- a/src/main/java/net/raphimc/viabedrock/protocol/model/ItemRegistry.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemRegistry.java -@@ -0,0 +1,224 @@ -+/* -+ * 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.google.common.collect.BiMap; -+import com.google.common.collect.HashBiMap; -+import com.google.common.collect.ImmutableBiMap; -+import com.viaversion.nbt.tag.CompoundTag; +@@ -12,6 +12,8 @@ import com.viaversion.nbt.tag.CompoundTag; + import com.viaversion.nbt.tag.StringTag; + import com.viaversion.viaversion.util.Key; + import net.raphimc.viabedrock.api.model.NativeItemNames; +import net.raphimc.viabedrock.api.chunk.blockstate.BlockItemDescriptor; +import net.raphimc.viabedrock.protocol.model.recipe.ItemRecipeKey; -+import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ItemVersion; -+ -+import java.util.HashMap; -+import java.util.LinkedHashMap; -+import java.util.List; -+import java.util.Map; -+import java.util.Set; + import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ItemVersion; + + import java.util.HashMap; +@@ -20,6 +22,8 @@ import java.util.LinkedHashMap; + import java.util.List; + import java.util.Map; + import java.util.Set; +import java.util.function.Function; +import java.util.function.Supplier; -+import java.util.stream.Collectors; -+ -+/** One connection's item IDs and the complete definitions that own those IDs. */ -+public final class ItemRegistry { -+ -+ private final BiMap ids; -+ private final Map definitions; -+ private final List entries; -+ private final List registrations; -+ -+ public ItemRegistry(final ItemEntry[] entries) { -+ final BiMap ids = HashBiMap.create(entries.length); -+ final Map definitions = new LinkedHashMap<>(); -+ for (final ItemEntry entry : entries) { -+ // The native runtime-ID pass skips the invalid-ID sentinel, including later references. -+ if (entry.id() == -1) { -+ continue; -+ } -+ final String previousIdentifier = ids.inverse().remove(entry.id()); -+ if (previousIdentifier != null) { -+ definitions.remove(previousIdentifier); -+ } -+ ids.put(entry.identifier(), entry.id()); -+ definitions.remove(entry.identifier()); -+ definitions.put(entry.identifier(), entry); -+ } -+ this.registrations = List.of(entries.clone()); -+ this.ids = ImmutableBiMap.copyOf(ids); -+ this.definitions = Map.copyOf(definitions); -+ this.entries = List.copyOf(definitions.values()); -+ } -+ -+ public BiMap ids() { -+ return this.ids; -+ } -+ -+ public List entries() { -+ return this.entries; -+ } -+ -+ /** Ordered network updates, including entries whose ID or identifier was subsequently replaced. */ -+ public List registrations() { -+ return this.registrations; -+ } -+ -+ public ItemEntry definition(final String identifier) { -+ return this.definitions.get(identifier); -+ } -+ -+ public ItemEntry definition(final int id) { -+ final String identifier = this.ids.inverse().get(id); -+ return identifier != null ? this.definitions.get(identifier) : null; -+ } -+ + import java.util.stream.Collectors; + + /** One connection's item IDs and the complete definitions that own those IDs. */ +@@ -78,6 +82,75 @@ public final class ItemRegistry { + return identifier != null ? this.definitions.get(identifier) : null; + } + + /** Named descriptors narrow to signed-short metadata before resolving concrete identity. */ + public ItemRecipeKey namedRecipeKey(final String identifier, final int metadata) { + final ItemEntry entry = this.definition(identifier); @@ -8024,85 +7841,9 @@ index 000000000..b03817bf0 + return Map.copyOf(result); + } + -+ public Set componentItems() { -+ final Map factories = this.factories(); -+ return this.ids.keySet().stream().filter(identifier -> factories.get(identifier) == ItemVersion.DataDriven) -+ .collect(Collectors.toUnmodifiableSet()); -+ } -+ -+ /** Constructor-owned defaults precede every ordered network update, including None entries. */ -+ public Map stackingProperties() { -+ final Map factories = this.factories(); -+ final Map properties = new HashMap<>(); -+ for (final ItemEntry entry : this.registrations) { -+ final BuiltinItemFactories.Definition builtin = BuiltinItemFactories.get(entry.identifier()); -+ if (builtin != null) { -+ properties.putIfAbsent(entry.identifier(), builtin.stacking()); -+ } -+ } -+ for (final Map.Entry construction : this.constructionVersions().entrySet()) { -+ if (construction.getValue() == ItemVersion.DataDriven) { -+ properties.put(construction.getKey(), ComponentItemStackingProperties.DEFAULT); -+ } else { -+ properties.putIfAbsent(construction.getKey(), LegacyItemStackingProperties.DEFAULT); -+ } -+ } -+ for (final ItemEntry entry : this.registrations) { -+ final ItemStackingProperties previous = properties.get(entry.identifier()); -+ final CompoundTag data = entry.componentData(); -+ if (previous != null && data != null && data.get("components") instanceof CompoundTag components) { -+ properties.put(entry.identifier(), previous.withComponentData(components)); -+ } -+ } -+ final Map result = new HashMap<>(); -+ for (final String identifier : this.ids.keySet()) { -+ final ItemStackingProperties state = properties.get(identifier); -+ if (state != null) { -+ result.put(identifier, state); -+ } else { -+ // Unknown None entries are constructed after updates. -+ result.put(identifier, factories.get(identifier) == ItemVersion.DataDriven -+ ? ComponentItemStackingProperties.DEFAULT : LegacyItemStackingProperties.DEFAULT); -+ } -+ } -+ return Map.copyOf(result); -+ } -+ -+ /** The first explicit registration selects the named factory; subsequent entries only update it. */ -+ private Map constructionVersions() { -+ final Map versions = new LinkedHashMap<>(); -+ for (final ItemEntry entry : this.registrations) { -+ if (entry.version() != ItemVersion.None) { -+ versions.putIfAbsent(entry.identifier(), entry.version()); -+ } -+ } -+ return versions; -+ } -+ -+ private Map factories() { -+ final Map factories = new HashMap<>(); -+ for (final ItemEntry entry : this.registrations) { -+ final BuiltinItemFactories.Definition builtin = BuiltinItemFactories.get(entry.identifier()); -+ if (builtin != null) { -+ factories.putIfAbsent(entry.identifier(), builtin.version()); -+ } -+ } -+ for (final Map.Entry construction : this.constructionVersions().entrySet()) { -+ if (construction.getValue() == ItemVersion.DataDriven) { -+ factories.put(construction.getKey(), ItemVersion.DataDriven); -+ } else { -+ factories.putIfAbsent(construction.getKey(), ItemVersion.Legacy); -+ } -+ } -+ for (final ItemEntry entry : this.registrations) { -+ if (entry.id() != -1) { -+ factories.putIfAbsent(entry.identifier(), entry.componentBased() ? ItemVersion.DataDriven : ItemVersion.Legacy); -+ } -+ } -+ return factories; -+ } -+ -+} + /** The constructed item class owns custom-name dispatch, independently of its final network flags. */ + public String customName(final String identifier, final CompoundTag data) { + if (!this.ids.containsKey(identifier)) { diff --git a/src/main/java/net/raphimc/viabedrock/protocol/model/ItemStackHeader.java b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemStackHeader.java new file mode 100644 index 000000000..8e09b8c24 @@ -8168,108 +7909,6 @@ index 000000000..8e09b8c24 + } + +} -diff --git a/src/main/java/net/raphimc/viabedrock/protocol/model/ItemStackingProperties.java b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemStackingProperties.java -new file mode 100644 -index 000000000..06361312a ---- /dev/null -+++ b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemStackingProperties.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.protocol.model; -+ -+import com.viaversion.nbt.tag.ByteTag; -+import com.viaversion.nbt.tag.CompoundTag; -+import com.viaversion.nbt.tag.IntTag; -+ -+/** Stacking state belongs to the constructed native Item factory. */ -+public sealed interface ItemStackingProperties permits ComponentItemStackingProperties, LegacyItemStackingProperties { -+ -+ ItemStackingProperties withComponentData(CompoundTag components); -+ -+ int maximum(); -+ -+ boolean stackedByData(); -+ -+ boolean damageable(); -+ -+ default boolean stackable(final BedrockItem item) { -+ if (this.maximum() < 2) { -+ return false; -+ } -+ if (!this.damageable() || item.tag() == null) { -+ return true; -+ } -+ if (item.tag().get("Unbreakable") instanceof ByteTag flag && flag.asByte() != 0) { -+ return true; -+ } -+ return !(item.tag().get("Damage") instanceof IntTag damage) || damage.asShort() <= 0; -+ } -+ -+} -diff --git a/src/main/java/net/raphimc/viabedrock/protocol/model/LegacyItemStackingProperties.java b/src/main/java/net/raphimc/viabedrock/protocol/model/LegacyItemStackingProperties.java -new file mode 100644 -index 000000000..2922971d7 ---- /dev/null -+++ b/src/main/java/net/raphimc/viabedrock/protocol/model/LegacyItemStackingProperties.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.model; -+ -+import com.viaversion.nbt.tag.ByteTag; -+import com.viaversion.nbt.tag.CompoundTag; -+import com.viaversion.nbt.tag.IntTag; -+ -+/** Ordinary Item definitions update flat namespaced scalars independently. */ -+public record LegacyItemStackingProperties(int compiledMaximum, boolean compiledStackedByData, short maximumDamage, -+ Integer fixedMaximum, boolean forcedSubtype) implements ItemStackingProperties { -+ -+ static final LegacyItemStackingProperties DEFAULT = new LegacyItemStackingProperties(64, false, (short) 0); -+ -+ public LegacyItemStackingProperties(final int maximum, final boolean stackedByData, final short maximumDamage) { -+ this(maximum, stackedByData, maximumDamage, null, false); -+ } -+ -+ @Override -+ public int maximum() { -+ return this.fixedMaximum != null ? this.fixedMaximum : this.compiledMaximum; -+ } -+ -+ @Override -+ public boolean stackedByData() { -+ return this.forcedSubtype || this.compiledStackedByData; -+ } -+ -+ @Override -+ public LegacyItemStackingProperties withComponentData(final CompoundTag components) { -+ int maximum = this.compiledMaximum; -+ if (components.contains("minecraft:max_stack_size")) { -+ maximum = components.get("minecraft:max_stack_size") instanceof IntTag size ? size.asInt() & 0xFF : 0; -+ } -+ boolean stackedByData = this.compiledStackedByData; -+ if (components.contains("minecraft:stacked_by_data")) { -+ stackedByData = components.get("minecraft:stacked_by_data") instanceof ByteTag flag && flag.asByte() != 0; -+ } -+ short maximumDamage = this.maximumDamage; -+ if (components.contains("minecraft:max_damage")) { -+ maximumDamage = components.get("minecraft:max_damage") instanceof IntTag damage ? damage.asShort() : 0; -+ } -+ return new LegacyItemStackingProperties(maximum, stackedByData, maximumDamage, this.fixedMaximum, this.forcedSubtype); -+ } -+ -+ @Override -+ public boolean damageable() { -+ return this.maximumDamage > 0; -+ } -+ -+} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/model/recipe/ItemDescriptor.java b/src/main/java/net/raphimc/viabedrock/protocol/model/recipe/ItemDescriptor.java index eb263ea74..d62d7b11f 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/model/recipe/ItemDescriptor.java @@ -8370,7 +8009,7 @@ index 000000000..740fef1f2 + +} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/ClientPlayerPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/ClientPlayerPackets.java -index a82837482..6f3ca3945 100644 +index abe574edb..e95834c6a 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/ClientPlayerPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/ClientPlayerPackets.java @@ -31,6 +31,7 @@ import com.viaversion.viaversion.protocols.v26_2to26_3.packet.ServerboundPackets @@ -8381,7 +8020,7 @@ index a82837482..6f3ca3945 100644 import net.raphimc.viabedrock.api.model.entity.ClientPlayerEntity; import net.raphimc.viabedrock.api.model.entity.Entity; import net.raphimc.viabedrock.api.util.BitSets; -@@ -60,12 +61,18 @@ import net.raphimc.viabedrock.protocol.model.ItemUseModifiers; +@@ -61,12 +62,18 @@ import net.raphimc.viabedrock.protocol.model.ItemUseModifiers; import net.raphimc.viabedrock.protocol.model.Position2f; import net.raphimc.viabedrock.protocol.model.Position3f; import net.raphimc.viabedrock.protocol.provider.EmoteProvider; @@ -8400,7 +8039,7 @@ index a82837482..6f3ca3945 100644 import java.util.Set; import java.util.UUID; import java.util.logging.Level; -@@ -132,6 +139,18 @@ public final class ClientPlayerPackets { +@@ -133,6 +140,18 @@ public final class ClientPlayerPackets { return vehicle; } @@ -8419,7 +8058,7 @@ index a82837482..6f3ca3945 100644 public static void register(final BedrockProtocol protocol) { protocol.registerClientbound(ClientboundBedrockPackets.RESPAWN, ClientboundPackets26_3.RESPAWN, wrapper -> { final Position3f position = wrapper.read(BedrockTypes.POSITION_3F); // position -@@ -379,6 +398,11 @@ public final class ClientPlayerPackets { +@@ -416,6 +435,11 @@ public final class ClientPlayerPackets { clientPlayer.setGliding(true); clientPlayer.addAuthInputData(PlayerAuthInputData.StartGliding); } @@ -8431,7 +8070,7 @@ index a82837482..6f3ca3945 100644 default -> throw new IllegalStateException("Unhandled PlayerCommandAction: " + action); } }); -@@ -441,8 +465,7 @@ public final class ClientPlayerPackets { +@@ -478,8 +502,7 @@ public final class ClientPlayerPackets { chunkTracker.handleBlockChange(position, 0, chunkTracker.bedrockAirId()); PacketFactory.sendJavaBlockUpdate(wrapper.user(), position, ProtocolConstants.JAVA_AIR_ID); } @@ -8441,8 +8080,8 @@ index a82837482..6f3ca3945 100644 case STAB -> { final InventoryContainer inventory = wrapper.user().get(InventoryTracker.class).getInventoryContainer(); final PacketWrapper jab = PacketWrapper.create(ServerboundBedrockPackets.INVENTORY_TRANSACTION, wrapper.user()); -@@ -837,6 +860,39 @@ public final class ClientPlayerPackets { - }); +@@ -885,6 +908,39 @@ public final class ClientPlayerPackets { + return new CompletedPredictions(player, mounted, vehicle == null ? player : mounted != null ? frame : null); } + private static void swapOffhand(final PacketWrapper wrapper) { @@ -8570,10 +8209,16 @@ index 9a22ebb11..023c7c9e6 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 040fe1681..7385b056e 100644 +index d22d76165..3bf84f65a 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/InventoryPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/InventoryPackets.java -@@ -21,20 +21,24 @@ import com.viaversion.viaversion.libs.gson.JsonParser; +@@ -17,26 +17,28 @@ + */ + package net.raphimc.viabedrock.protocol.packet; + +-import net.raphimc.viabedrock.protocol.storage.GameSessionStorage; +- + import com.viaversion.viaversion.libs.gson.JsonParser; import net.raphimc.viabedrock.api.resourcepack.ui.ServerFormUi; import net.raphimc.viabedrock.api.model.NativeFormMessage; import net.raphimc.viabedrock.api.model.NativeContainerMessage; @@ -8599,7 +8244,7 @@ index 040fe1681..7385b056e 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; -@@ -56,6 +60,9 @@ import com.viaversion.viaversion.libs.mcstructs.text.components.StringComponent; +@@ -58,6 +60,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; @@ -8609,7 +8254,7 @@ index 040fe1681..7385b056e 100644 import net.lenni0451.mcstructs_bedrock.forms.Form; import net.lenni0451.mcstructs_bedrock.forms.elements.*; import net.lenni0451.mcstructs_bedrock.forms.serializer.FormSerializer; -@@ -64,10 +71,15 @@ import net.lenni0451.mcstructs_bedrock.forms.types.CustomForm; +@@ -66,14 +71,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; @@ -8617,6 +8262,10 @@ index 040fe1681..7385b056e 100644 import net.raphimc.viabedrock.api.model.CommandBlockOpen; import net.raphimc.viabedrock.api.chunk.BedrockBlockEntity; import net.raphimc.viabedrock.api.model.container.ChestContainer; + import net.raphimc.viabedrock.api.model.container.NativeStorageContainers; +-import net.raphimc.viabedrock.api.model.container.block.HopperContainer; +-import net.raphimc.viabedrock.api.model.container.block.Generic3x3Container; +-import net.raphimc.viabedrock.protocol.data.generated.bedrock.CustomBlockTags; 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; @@ -8625,7 +8274,7 @@ index 040fe1681..7385b056e 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; -@@ -75,18 +87,28 @@ import net.raphimc.viabedrock.api.util.TextUtil; +@@ -81,18 +88,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; @@ -8655,7 +8304,7 @@ index 040fe1681..7385b056e 100644 import java.util.ArrayList; import java.util.List; -@@ -96,11 +118,52 @@ public final class InventoryPackets { +@@ -102,11 +119,52 @@ public final class InventoryPackets { private static final int DIALOG_BUTTON_WIDTH = 200; private static final int DIALOG_FAKE_BUTTON_WIDTH = 300; @@ -8711,7 +8360,7 @@ index 040fe1681..7385b056e 100644 final CommandBlockSession command = wrapper.user().get(CommandBlockSession.class); if (command != null && command.matches((byte) (int) wrapper.get(Types.VAR_INT, 0))) { -@@ -110,20 +173,29 @@ public final class InventoryPackets { +@@ -116,20 +174,34 @@ public final class InventoryPackets { return; } @@ -8739,13 +8388,18 @@ index 040fe1681..7385b056e 100644 wrapper.cancel(); return; } ++ final var screens = wrapper.user().get(PlayerInventoryScreenTracker.class); ++ final boolean correlatedClose = container instanceof InventoryContainer && screens != null && screens.ownsClose(); inventoryTracker.setCurrentContainerClosed(serverInitiated); - if (!serverInitiated && inventoryTracker.getCurrentForm() != null) { ++ if (correlatedClose) { ++ wrapper.cancel(); ++ } + if (!serverInitiated && (container instanceof InventoryContainer || inventoryTracker.getCurrentForm() != null)) { wrapper.cancel(); } } -@@ -201,329 +273,844 @@ public final class InventoryPackets { +@@ -207,342 +279,857 @@ public final class InventoryPackets { wrapper.write(Types.STRING, ItemCooldown.javaGroup(wrapper.read(BedrockTypes.STRING))); wrapper.write(Types.VAR_INT, Math.max(0, wrapper.read(BedrockTypes.VAR_INT))); }); @@ -8773,7 +8427,12 @@ index 040fe1681..7385b056e 100644 - } - final BedrockBlockEntity blockEntity = actorId == -1 ? chunkTracker.getBlockEntity(position) : null; - final ContainerTitle openingTitle = containerTitle(wrapper.user(), type, actor, blockEntity, -- new TranslationComponent("container." + blockStateRewriter.tag(chunkTracker.getBlockState(position)))); +- new TranslationComponent(switch (type) { +- case HOPPER, MINECART_HOPPER -> "container.hopper"; +- case DISPENSER -> "container.dispenser"; +- case DROPPER -> "container.dropper"; +- default -> "container." + blockStateRewriter.tag(chunkTracker.getBlockState(position)); +- })); - final TextComponent title = openingTitle.caption(); - - final Container container; @@ -8784,6 +8443,9 @@ index 040fe1681..7385b056e 100644 - return; - } - case CONTAINER -> container = new ChestContainer(wrapper.user(), containerId, title, position, 27); +- case HOPPER, MINECART_HOPPER -> container = new HopperContainer(wrapper.user(), containerId, type, title, +- actorId == -1 ? position : null); +- case DISPENSER, DROPPER -> container = new Generic3x3Container(wrapper.user(), containerId, type, title, position); - case COMMAND_BLOCK -> { - wrapper.cancel(); - if (!WorldPackets.openNativeCommandBlock(wrapper.user(), containerId, blockEntity)) { @@ -8809,7 +8471,7 @@ index 040fe1681..7385b056e 100644 - final int javaMenuId = BedrockProtocol.MAPPINGS.getBedrockToJavaContainers().get(type); - wrapper.write(Types.VAR_INT, javaMenuId); // type - wrapper.write(Types.TAG, TextUtil.textComponentToNbt(title)); // title -- sendNativeContainerPresentation(wrapper, javaMenuId, openingTitle.presentation()); +- sendNativeContainerPresentation(wrapper, nativeContainerKind(type, blockStateRewriter.tag(chunkTracker.getBlockState(position)), container.size()), openingTitle.presentation()); - }); - protocol.registerClientbound(ClientboundBedrockPackets.CONTAINER_CLOSE, ClientboundPackets26_3.CONTAINER_CLOSE, new PacketHandlers() { - @Override @@ -8944,8 +8606,11 @@ index 040fe1681..7385b056e 100644 + wrapper.user(), containerId, position, + inventoryTracker.getInventoryContainer()); + inventoryTracker.setCurrentContainer(playerInventory, openContext); -+ wrapper.user().get(InventoryRequestTracker.class) -+ .handleInventoryOpen(playerInventory); ++ final var screens = wrapper.user().get(PlayerInventoryScreenTracker.class); ++ if (screens != null) { ++ screens.opened(); ++ } ++ wrapper.user().get(InventoryRequestTracker.class).handleInventoryOpen(playerInventory); + wrapper.cancel(); + return; + } @@ -9436,6 +9101,11 @@ index 040fe1681..7385b056e 100644 - wrapper.cancel(); - return; - } +- final var nativeContainer = wrapper.user().get(NativeContainerUiState.class); +- if (nativeContainer != null && nativeContainer.blocksVanillaClicks()) { +- PacketFactory.sendJavaContainerSetContent(wrapper.user(), container); +- return; +- } - if (!container.handleClick(revision, slot, button, action)) { - if (container.type() != ContainerType.INVENTORY) { - PacketFactory.sendJavaContainerSetContent(wrapper.user(), inventoryTracker.getInventoryContainer()); @@ -9528,6 +9198,11 @@ index 040fe1681..7385b056e 100644 + final InventoryTracker inventoryTracker = + wrapper.user().get(InventoryTracker.class); + wrapper.user().get(InventoryRequestTracker.class).keepPlayerInventoryOpen(); ++ final var screens = wrapper.user().get(PlayerInventoryScreenTracker.class); ++ if (containerId == 0 && screens != null && screens.ownsClose() && !screens.acceptsClicks()) { ++ PacketFactory.sendJavaContainerSetContent(wrapper.user(), inventoryTracker.getInventoryContainer()); ++ return; ++ } + if (inventoryTracker.getPendingCloseContainer() != null) { + wrapper.cancel(); + return; @@ -9851,6 +9526,11 @@ index 040fe1681..7385b056e 100644 + final InventoryTracker inventoryTracker = + wrapper.user().get(InventoryTracker.class); + final byte containerId = wrapper.get(Types.BYTE, 0); ++ final var screens = wrapper.user().get(PlayerInventoryScreenTracker.class); ++ if (containerId == 0 && screens != null && screens.ownsClose()) { ++ wrapper.cancel(); ++ return; ++ } + final CommandBlockSession command = wrapper.user().get(CommandBlockSession.class); + if (command != null && command.matches(containerId)) { + command.beginClose(); @@ -9886,7 +9566,7 @@ index 040fe1681..7385b056e 100644 protocol.registerServerbound( ServerboundPackets26_3.EDIT_BOOK, null, -@@ -574,33 +1161,55 @@ public final class InventoryPackets { +@@ -593,33 +1180,55 @@ public final class InventoryPackets { updatedTag.put("pages", updatedPages); book.setTag(updatedTag); }); @@ -9964,7 +9644,7 @@ index 040fe1681..7385b056e 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; -@@ -612,7 +1221,7 @@ public final class InventoryPackets { +@@ -631,7 +1240,7 @@ public final class InventoryPackets { final String raw = customName.getValue(); return new ContainerTitle(TextUtil.stringToTextComponent(user.get(ResourcePackStorage.class).getTexts().translate(raw)), raw, false); } @@ -9973,63 +9653,49 @@ index 040fe1681..7385b056e 100644 } private static String actorTitle(final Entity actor, final ActorDataIds field) { -@@ -620,26 +1229,70 @@ public final class InventoryPackets { - return data != null && data.dataType() == EntityDataTypesBedrock.STRING && data.value() instanceof String value ? value : ""; +@@ -657,21 +1266,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; ++ case BREWING_STAND -> size == 5 ? ContainerUi.Kind.BREWING_STAND : null; + default -> null; + }; } -- static void sendNativeContainerPresentation(final PacketWrapper wrapper, final int javaMenuId, final ContainerUi.Title title) { -- if (!wrapper.user().get(ChannelStorage.class).hasChannel(NativeContainerMessage.CHANNEL)) { -+ static ContainerUi.Kind nativeContainerKind(final ContainerType type, final String blockTag, final int size) { -+ return switch (type) { -+ case CONTAINER -> { -+ if (size == 54) { -+ yield ContainerUi.Kind.CHEST_6_ROWS; -+ } -+ if (size != 27) { -+ yield null; -+ } -+ yield CustomBlockTags.BARREL.equals(blockTag) ? ContainerUi.Kind.BARREL -+ : CustomBlockTags.SHULKER_BOX.equals(blockTag) ? ContainerUi.Kind.SHULKER_BOX -+ : CustomBlockTags.ENDER_CHEST.equals(blockTag) ? ContainerUi.Kind.ENDER_CHEST -+ : ContainerUi.Kind.CHEST_3_ROWS; -+ } -+ case MINECART_CHEST, CHEST_BOAT -> size == 27 ? ContainerUi.Kind.CHEST_3_ROWS : null; -+ 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; -+ case BREWING_STAND -> size == 5 ? ContainerUi.Kind.BREWING_STAND : null; -+ default -> null; -+ }; -+ } -+ -+ static void sendNativeContainerPresentation(final PacketWrapper wrapper, final ContainerUi.Kind kind, final ContainerUi.Title title) { -+ final var channels = wrapper.user().get(ChannelStorage.class); + static void sendNativeContainerPresentation(final PacketWrapper wrapper, final ContainerUi.Kind kind, final ContainerUi.Title title) { + final var channels = wrapper.user().get(ChannelStorage.class); + final boolean workstations = kind == ContainerUi.Kind.BREWING_STAND; -+ final boolean families = channels.hasChannel(NativeContainerMessage.FAMILIES_CHANNEL); + final boolean families = channels.hasChannel(NativeContainerMessage.FAMILIES_CHANNEL); +- if (kind == null || kind == ContainerUi.Kind.BREWING_STAND || !kind.legacy() && !families) { + if (kind == null || workstations && (!channels.hasChannel(NativeContainerMessage.WORKSTATIONS_CHANNEL) + || !channels.hasChannel(NativeContainerData.CHANNEL)) || !workstations && !families && !kind.legacy()) { return; } -- final ContainerUi.Kind kind = javaMenuId == BedrockProtocol.MAPPINGS.getJavaMenuId("minecraft:generic_9x3") -- ? ContainerUi.Kind.CHEST_3_ROWS : javaMenuId == BedrockProtocol.MAPPINGS.getJavaMenuId("minecraft:generic_9x6") -- ? ContainerUi.Kind.CHEST_6_ROWS : null; -- final var message = wrapper.user().get(NativeContainerUiState.class).prepare( -- wrapper.get(Types.VAR_INT, 0), kind, title, wrapper.user().get(ResourcePackStorage.class).getContainerUi(), List.of()); +- final boolean nativeActions = channels.hasChannel(NativeContainerMessage.CURRENT_CHANNEL) + final boolean nativeActions = !workstations && channels.hasChannel(NativeContainerMessage.CURRENT_CHANNEL) -+ && channels.hasChannel(net.raphimc.viabedrock.api.model.NativeContainerStatus.CHANNEL) -+ && wrapper.user().get(GameSessionStorage.class).isInventoryServerAuthoritative(); + && channels.hasChannel(net.raphimc.viabedrock.api.model.NativeContainerStatus.CHANNEL) +- && !wrapper.user().get(GameSessionStorage.class).isInventoryServerAuthoritative() + && NativeStorageContainers.matches(wrapper.user().get(InventoryTracker.class).getCurrentContainer(), kind); +- if (!nativeActions && !families && !channels.hasChannel(NativeContainerMessage.CHANNEL)) { + if (!workstations && !families && !nativeActions && !channels.hasChannel(NativeContainerMessage.CHANNEL)) { -+ return; -+ } -+ final var state = wrapper.user().get(NativeContainerUiState.class); -+ final var ui = wrapper.user().get(ResourcePackStorage.class).getContainerUi(); + return; + } + final var resources = wrapper.user().get(ResourcePackStorage.class); +@@ -680,22 +1291,33 @@ public final class InventoryPackets { + } + final var state = wrapper.user().get(NativeContainerUiState.class); + final var ui = resources.getContainerUi(); + final Container container = workstations ? wrapper.user().get(InventoryTracker.class).getCurrentContainer() : null; + if (workstations && (!(container instanceof BrewingStandContainer) + || Byte.toUnsignedInt(container.javaContainerId()) != wrapper.get(Types.VAR_INT, 0))) { + return; + } -+ final var message = nativeActions ? state.prepareActions(wrapper.get(Types.VAR_INT, 0), kind, title, ui, -+ value -> net.raphimc.viabedrock.api.model.container.NativeContainerActionBackend.create(wrapper.user(), value)) + final var message = nativeActions ? state.prepareActions(wrapper.get(Types.VAR_INT, 0), kind, title, ui, +- value -> net.raphimc.viabedrock.api.model.container.NativeContainerLegacyActions.create(wrapper.user(), value)) +- : state.prepare(wrapper.get(Types.VAR_INT, 0), kind, title, ui, List.of()); ++ value -> wrapper.user().get(GameSessionStorage.class).isInventoryServerAuthoritative() ++ ? net.raphimc.viabedrock.api.model.container.NativeContainerActionBackend.create(wrapper.user(), value) ++ : net.raphimc.viabedrock.api.model.container.NativeContainerLegacyActions.create(wrapper.user(), value)) + : state.prepare(wrapper.get(Types.VAR_INT, 0), kind, title, ui, workstations ? nativeContainerData(container) : List.of()); if (message == null) { return; @@ -10037,12 +9703,12 @@ index 040fe1681..7385b056e 100644 wrapper.send(BedrockProtocol.class); wrapper.cancel(); final PacketWrapper presentation = PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, wrapper.user()); -- presentation.write(Types.STRING, NativeContainerMessage.CHANNEL); -- presentation.write(NativeContainerMessage.TYPE, message); +- presentation.write(Types.STRING, families ? NativeContainerMessage.FAMILIES_CHANNEL + presentation.write(Types.STRING, workstations ? NativeContainerMessage.WORKSTATIONS_CHANNEL : families ? NativeContainerMessage.FAMILIES_CHANNEL -+ : nativeActions ? NativeContainerMessage.CURRENT_CHANNEL : NativeContainerMessage.CHANNEL); + : nativeActions ? NativeContainerMessage.CURRENT_CHANNEL : NativeContainerMessage.CHANNEL); +- presentation.write(families ? NativeContainerMessage.FAMILIES_TYPE + presentation.write(workstations ? NativeContainerMessage.WORKSTATIONS_TYPE : families ? NativeContainerMessage.FAMILIES_TYPE -+ : nativeActions ? NativeContainerMessage.CURRENT_TYPE : NativeContainerMessage.TYPE, message); + : nativeActions ? NativeContainerMessage.CURRENT_TYPE : NativeContainerMessage.TYPE, message); presentation.send(BedrockProtocol.class); } @@ -10053,7 +9719,7 @@ index 040fe1681..7385b056e 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); -@@ -930,13 +1583,147 @@ public final class InventoryPackets { +@@ -986,13 +1608,147 @@ public final class InventoryPackets { } } @@ -10203,7 +9869,7 @@ index 040fe1681..7385b056e 100644 for (String line : BedrockTextUtils.split(text, "\n")) { final TextInput textInput = new TextInput(TextUtil.stringToTextComponent(line)); textInput.setMaxLength(Integer.MAX_VALUE); -@@ -944,14 +1731,53 @@ public final class InventoryPackets { +@@ -1000,14 +1756,53 @@ public final class InventoryPackets { dialog.getInputs().add(new Input("dummy", textInput)); } } else { // VB compatibility @@ -10260,10 +9926,10 @@ index 040fe1681..7385b056e 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 7a2863735..bf77f0bd5 100644 +index f57454026..1b78b15b3 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/JoinPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/JoinPackets.java -@@ -471,7 +471,7 @@ public final class JoinPackets { +@@ -474,7 +474,7 @@ public final class JoinPackets { for (String identifier : itemRewriter.getComponentItems()) { itemDefinitions.remove(identifier); } @@ -10273,63 +9939,73 @@ index 7a2863735..bf77f0bd5 100644 itemDefinitions.addFromNetworkTag(itemEntry.identifier(), itemEntry.componentData()); } 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 d30ed8f75..b405927ae 100644 +index 4e73e7bf0..0b8c600e9 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java -@@ -17,7 +17,12 @@ +@@ -17,17 +17,14 @@ */ package net.raphimc.viabedrock.protocol.packet; -+import net.raphimc.viabedrock.protocol.storage.NativeContainerUiState; -+import net.raphimc.viabedrock.api.model.NativeContainerAction; -+import net.raphimc.viabedrock.api.model.NativeContainerStatus; +-import com.viaversion.viaversion.api.protocol.packet.PacketWrapper; +- +-import net.raphimc.viabedrock.api.model.NativeContainerMessage; +- +-import net.raphimc.viabedrock.api.model.NativeContainerStatus; +- +-import net.raphimc.viabedrock.api.model.NativeContainerAction; +- ++import net.raphimc.viabedrock.api.model.PlayerInventoryScreenRequest; ++import net.raphimc.viabedrock.protocol.storage.PlayerInventoryScreenTracker; + import net.raphimc.viabedrock.protocol.storage.NativeContainerUiState; +- ++import net.raphimc.viabedrock.api.model.NativeContainerAction; ++import net.raphimc.viabedrock.api.model.NativeContainerStatus; +import net.raphimc.viabedrock.api.model.NativeContainerMessage; import com.viaversion.viaversion.api.protocol.packet.State; +import com.viaversion.viaversion.api.protocol.packet.PacketWrapper; import com.viaversion.viaversion.api.protocol.remapper.PacketHandler; import com.viaversion.viaversion.api.type.Types; import com.viaversion.viaversion.protocols.base.ClientboundLoginPackets; -@@ -178,6 +183,27 @@ public final class MultiStatePackets { - } - return; - } -+ if (channel.equals(NativeContainerMessage.CURRENT_CHANNEL)) { -+ if (wrapper.user().getProtocolInfo().getClientState() == State.PLAY -+ && wrapper.user().get(ChannelStorage.class).hasChannel(channel) -+ && wrapper.user().get(ChannelStorage.class).hasChannel(NativeContainerStatus.CHANNEL)) { +@@ -163,6 +160,17 @@ public final class MultiStatePackets { + public static final PacketHandler CUSTOM_PAYLOAD_HANDLER = wrapper -> { + wrapper.cancel(); + final String channel = Key.namespaced(wrapper.read(Types.STRING)); // channel ++ if (channel.equals(PlayerInventoryScreenRequest.CHANNEL)) { ++ final var screens = wrapper.user().get(PlayerInventoryScreenTracker.class); ++ if (screens != null && screens.enabled()) { + try { -+ final var action = wrapper.read(NativeContainerAction.TYPE); -+ final boolean accepted = wrapper.user().get(NativeContainerUiState.class) -+ .handleAction(action); -+ if (action.mode() == NativeContainerAction.Mode.ACTIVATE) { -+ final var status = PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, wrapper.user()); -+ status.write(Types.STRING, NativeContainerStatus.CHANNEL); -+ status.write(NativeContainerStatus.TYPE, -+ new NativeContainerStatus(action.menuId(), action.sequence(), accepted)); -+ status.send(BedrockProtocol.class); -+ } ++ screens.request(wrapper.read(PlayerInventoryScreenRequest.TYPE)); + } catch (final IllegalArgumentException | IndexOutOfBoundsException malformed) { -+ // Invalid input never reaches prediction or ordinary Java inventory handling. ++ // Invalid messages cannot own a native opening or close another screen. + } + } + return; + } + 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)); +@@ -211,6 +219,10 @@ 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); -@@ -187,6 +213,12 @@ public final class MultiStatePackets { - if (channels.contains(PlayerInputPermissions.CHANNEL) && wrapper.user().has(PlayerPredictionStorage.class)) { - wrapper.user().get(PlayerPredictionStorage.class).discardPending(); - } -+ if ((channels.contains(NativeContainerMessage.CURRENT_CHANNEL) -+ || channels.contains(NativeContainerMessage.FAMILIES_CHANNEL) -+ || channels.contains(NativeContainerStatus.CHANNEL)) -+ && wrapper.user().has(NativeContainerUiState.class)) { -+ wrapper.user().get(NativeContainerUiState.class).clearActions(); ++ final var screens = wrapper.user().get(PlayerInventoryScreenTracker.class); ++ if (screens != null) { ++ screens.refreshCapability(); + } - if (wrapper.user().has(VehiclePredictionStorage.class)) { - wrapper.user().get(VehiclePredictionStorage.class).refresh(); + if (channels.contains(PlayerInputPermissions.CHANNEL) && wrapper.user().has(PlayerInputStorage.class)) { + wrapper.user().get(PlayerInputStorage.class).withdrawRawInput(); } +@@ -269,6 +281,10 @@ public final class MultiStatePackets { + if (channel.equals("minecraft:register")) { + final List channels = Arrays.asList(new String(wrapper.read(Types.SERVERBOUND_CUSTOM_PAYLOAD_DATA), StandardCharsets.UTF_8).split("\0")); + wrapper.user().get(ChannelStorage.class).addChannels(channels); ++ final var screens = wrapper.user().get(PlayerInventoryScreenTracker.class); ++ if (screens != null) { ++ screens.refreshCapability(); ++ } + WorldPackets.sendNativeCommandCapability(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 85cdb3dee..a98ad8137 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/WorldPackets.java @@ -10361,42 +10037,33 @@ index 85cdb3dee..a98ad8137 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 353a67ede..e984d0f67 100644 +index 234767d2c..771a85f95 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemRewriter.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemRewriter.java -@@ -18,7 +18,6 @@ - package net.raphimc.viabedrock.protocol.rewriter; - - import com.google.common.collect.BiMap; --import com.google.common.collect.HashBiMap; - import com.viaversion.nbt.tag.CompoundTag; - import com.viaversion.nbt.tag.IntTag; - import com.viaversion.nbt.tag.NumberTag; -@@ -37,6 +36,7 @@ import com.viaversion.viaversion.libs.fastutil.ints.Int2ObjectMap; +@@ -36,6 +36,7 @@ import com.viaversion.viaversion.libs.fastutil.ints.Int2ObjectMap; import com.viaversion.viaversion.libs.fastutil.ints.Int2ObjectOpenHashMap; 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; - import net.raphimc.viabedrock.api.model.NativeItemStackData; -@@ -45,10 +45,14 @@ import net.raphimc.viabedrock.api.util.TextUtil; - import net.raphimc.viabedrock.protocol.BedrockProtocol; - import net.raphimc.viabedrock.protocol.data.BedrockMappingData; + import net.raphimc.viabedrock.api.model.NativeItemNames; +@@ -49,10 +50,13 @@ import net.raphimc.viabedrock.protocol.data.BedrockMappingData; import net.raphimc.viabedrock.protocol.data.ProtocolConstants; --import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ItemVersion; 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.ItemRegistry; ++import net.raphimc.viabedrock.protocol.model.ItemEntry; + import net.raphimc.viabedrock.protocol.model.ItemRegistry; + import net.raphimc.viabedrock.protocol.model.ItemNameContext; +import net.raphimc.viabedrock.protocol.model.ItemStackHeader; +import net.raphimc.viabedrock.protocol.model.recipe.ItemRecipeKey; -+import net.raphimc.viabedrock.protocol.model.ItemStackingProperties; + import net.raphimc.viabedrock.protocol.model.ItemStackingProperties; +-import net.raphimc.viabedrock.protocol.model.ItemEntry; +import net.raphimc.viabedrock.protocol.model.ItemCookingProperties; import net.raphimc.viabedrock.protocol.model.ItemCooldown; import net.raphimc.viabedrock.protocol.model.ItemAnimationData; import net.raphimc.viabedrock.protocol.model.ItemArmorData; -@@ -60,16 +64,19 @@ import net.raphimc.viabedrock.protocol.storage.MapTracker; +@@ -65,16 +69,19 @@ import net.raphimc.viabedrock.protocol.storage.MapTracker; import net.raphimc.viabedrock.protocol.storage.ResourcePackStorage; import net.raphimc.viabedrock.protocol.types.BedrockTypes; import net.raphimc.viabedrock.protocol.types.array.ArrayType; @@ -10415,19 +10082,20 @@ index 353a67ede..e984d0f67 100644 -public class ItemRewriter extends StoredObject { +public class ItemRewriter extends StoredObject implements NetworkItemStackContext { - private final Map animationData = new HashMap<>(); - private final Map armorData = new HashMap<>(); -@@ -82,6 +89,9 @@ public class ItemRewriter extends StoredObject { + private final ItemRegistry registry; + +@@ -89,8 +96,9 @@ public class ItemRewriter extends StoredObject { private static final Map ITEM_NBT_REWRITERS = new HashMap<>(); -+ private final ItemRegistry registry; -+ private final Map stackingProperties; +- private final BiMap items; + private final Map stackingProperties; + private final Map cookingProperties; - private final BiMap items; ++ private final BiMap items; private final Set componentItems; private final Int2ObjectMap blockItemValidBlockStates; -@@ -91,6 +101,8 @@ public class ItemRewriter extends StoredObject { + private final Type itemType; +@@ -99,6 +107,8 @@ public class ItemRewriter extends StoredObject { private final Type newItemType; private final Type optionalNewItemType; private final Type newItemArrayType; @@ -10436,32 +10104,21 @@ index 353a67ede..e984d0f67 100644 static { // TODO: Add missing item nbt rewriters -@@ -122,11 +134,13 @@ public class ItemRewriter extends StoredObject { - public ItemRewriter(final UserConnection user, final ItemEntry[] itemEntries) { - super(user); - -- this.items = HashBiMap.create(itemEntries.length); -- this.componentItems = new HashSet<>(); +@@ -134,7 +144,9 @@ public class ItemRewriter extends StoredObject { + this.items = this.registry.ids(); + this.componentItems = this.registry.componentItems(); + this.stackingProperties = this.registry.stackingProperties(); - for (ItemEntry itemEntry : itemEntries) { -- this.items.inverse().remove(itemEntry.id()); -- this.items.put(itemEntry.identifier(), itemEntry.id()); -+ this.registry = new ItemRegistry(itemEntries); -+ this.items = this.registry.ids(); -+ this.componentItems = this.registry.componentItems(); -+ this.stackingProperties = this.registry.stackingProperties(); + final BlockStateRewriter blockStateRewriter = this.user().get(BlockStateRewriter.class); + this.cookingProperties = this.registry.cookingProperties(blockStateRewriter::registeredBlockIdentifier); + for (ItemEntry itemEntry : this.registry.registrations()) { if (itemEntry.componentData() != null && itemEntry.componentData().get("components") instanceof CompoundTag components) { this.animationData.put(itemEntry.identifier(), ItemAnimationData.fromNetworkComponents(components)); -@@ -137,36 +151,49 @@ public class ItemRewriter extends StoredObject { +@@ -145,33 +157,49 @@ public class ItemRewriter extends StoredObject { this.cooldowns.put(itemEntry.identifier(), cooldown); } } -- if (itemEntry.version() == ItemVersion.DataDriven || (itemEntry.version() == ItemVersion.None && itemEntry.componentBased())) { -- this.componentItems.add(itemEntry.identifier()); -- } + } - final Set blockItems = new HashSet<>(BedrockProtocol.MAPPINGS.getBedrockBlockItems()); @@ -10515,7 +10172,7 @@ index 353a67ede..e984d0f67 100644 } public Item javaItem(final BedrockItem bedrockItem) { -@@ -176,13 +203,18 @@ public class ItemRewriter extends StoredObject { +@@ -181,13 +209,18 @@ public class ItemRewriter extends StoredObject { final String identifier = this.items.inverse().get(bedrockItem.identifier()); if (identifier == null) { @@ -10536,7 +10193,7 @@ index 353a67ede..e984d0f67 100644 if (blockItemMappings != null) { final BlockState displayState; if (bedrockItem.hasBlockState()) { -@@ -195,17 +227,27 @@ public class ItemRewriter extends StoredObject { +@@ -200,9 +233,13 @@ public class ItemRewriter extends StoredObject { javaItemMapping = displayState != null ? blockItemMappings.get(displayState) : null; } else { final int meta = bedrockItem.data() & 0xFFFF; @@ -10550,16 +10207,9 @@ index 353a67ede..e984d0f67 100644 + final Map newBlockItemMappings = + BedrockProtocol.MAPPINGS.getBedrockToJavaBlockItems().get(newIdentifier); if (newBlockItemMappings != null) { -- final IntSortedSet validBlockStates = this.blockItemValidBlockStates.get(bedrockItem.identifier()); -- javaItemMapping = newBlockItemMappings.get(this.user().get(BlockStateRewriter.class).blockState(validBlockStates.firstInt())); -+ final IntSortedSet validBlockStates = -+ this.blockItemValidBlockStates.get(bedrockItem.identifier()); -+ javaItemMapping = -+ newBlockItemMappings.get( -+ this.user() -+ .get(BlockStateRewriter.class) -+ .blockState(validBlockStates.firstInt())); - } else { + final IntSortedSet validBlockStates = this.blockItemValidBlockStates.get(bedrockItem.identifier()); + javaItemMapping = validBlockStates != null && !validBlockStates.isEmpty() +@@ -211,7 +248,8 @@ public class ItemRewriter extends StoredObject { javaItemMapping = null; } } else { @@ -10569,24 +10219,7 @@ index 353a67ede..e984d0f67 100644 if (metaItemMappings != null) { if (!metaItemMappings.containsKey(meta)) { if ((metaItemMappings.size() != 1 || meta != 0) -@@ -229,36 +271,82 @@ public class ItemRewriter extends StoredObject { - ItemDataRewriter.applyJavaOverride(javaItemMapping.overrideTag(), data, BedrockProtocol.MAPPINGS.getJavaRegistries()); - } - if (javaItemMapping.name() != null) { -- 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)}); -+ 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) -+ }); +@@ -245,8 +283,10 @@ public class ItemRewriter extends StoredObject { } javaItem = new StructuredItem(javaItemMapping.id(), bedrockItem.amount(), data); } else { @@ -10599,40 +10232,7 @@ index 353a67ede..e984d0f67 100644 final StructuredDataContainer data = ProtocolConstants.createStructuredDataContainer(); if (itemDefinition != null) { - if (itemDefinition.displayNameComponent() != null) { -- data.set(StructuredDataKey.ITEM_NAME, TextUtil.stringToNbt(resourcePackStorage.getTexts().translate(itemDefinition.displayNameComponent()))); -+ data.set( -+ StructuredDataKey.ITEM_NAME, -+ TextUtil.stringToNbt( -+ resourcePackStorage -+ .getTexts() -+ .translate(itemDefinition.displayNameComponent()))); - } else if (this.componentItems.contains(identifier)) { -- data.set(StructuredDataKey.ITEM_NAME, TextUtil.stringToNbt(resourcePackStorage.getTexts().get("item." + Key.stripMinecraftNamespace(identifier)))); -+ data.set( -+ StructuredDataKey.ITEM_NAME, -+ TextUtil.stringToNbt( -+ resourcePackStorage -+ .getTexts() -+ .get( -+ "item." -+ + Key.stripMinecraftNamespace( -+ identifier)))); - } else { -- data.set(StructuredDataKey.ITEM_NAME, TextUtil.stringToNbt(resourcePackStorage.getTexts().get("item." + Key.stripMinecraftNamespace(identifier) + ".name"))); -+ data.set( -+ StructuredDataKey.ITEM_NAME, -+ TextUtil.stringToNbt( -+ resourcePackStorage -+ .getTexts() -+ .get( -+ "item." -+ + Key.stripMinecraftNamespace( -+ identifier) -+ + ".name"))); - } - - if (itemDefinition.iconComponent() != null && resourcePackStorage.isLoadedOnJavaClient()) { +@@ -264,13 +304,21 @@ public class ItemRewriter extends StoredObject { data.set(StructuredDataKey.ITEM_MODEL, CustomItemTextureResourceRewriter.getItemModel(itemDefinition.iconComponent())); data.set(StructuredDataKey.CUSTOM_MODEL_DATA1_21_4, CustomItemTextureResourceRewriter.getCustomModelData("0")); } else { @@ -10643,17 +10243,9 @@ index 353a67ede..e984d0f67 100644 + TextUtil.stringToNbt("§7[ViaBedrock] Custom item: " + identifier) + }); } - } else { -- ViaBedrock.getPlatform().getLogger().log(Level.WARNING, "Missing bedrock -> java item mapping for " + identifier); -- data.set(StructuredDataKey.ITEM_NAME, TextUtil.stringToNbt("§cMissing item: " + identifier)); -+ ViaBedrock.getPlatform() -+ .getLogger() -+ .log( -+ Level.WARNING, -+ "Missing bedrock -> java item mapping for " + identifier); -+ data.set( -+ StructuredDataKey.ITEM_NAME, -+ TextUtil.stringToNbt("§cMissing item: " + identifier)); + } else if (ordinaryNameKey == null) { + ViaBedrock.getPlatform().getLogger().log(Level.WARNING, "Missing bedrock -> java item mapping for " + identifier); + data.set(StructuredDataKey.ITEM_NAME, TextUtil.stringToNbt("§cMissing item: " + identifier)); } - javaItem = new StructuredItem(BedrockProtocol.MAPPINGS.getJavaItems().get("minecraft:paper"), bedrockItem.amount(), data); + javaItem = @@ -10663,8 +10255,8 @@ index 353a67ede..e984d0f67 100644 + data); } - ItemCooldown cooldown = this.cooldowns.get(identifier); -@@ -274,12 +362,20 @@ public class ItemRewriter extends StoredObject { + if (ordinaryNameKey != null) { +@@ -294,8 +342,12 @@ public class ItemRewriter extends StoredObject { final CompoundTag bedrockTag = bedrockItem.tag(); if (bedrockTag != null) { @@ -10677,19 +10269,9 @@ index 353a67ede..e984d0f67 100644 + StructuredDataKey.MAP_ID, + this.user().get(MapTracker.class).itemJavaId(mapUuid.asLong())); } - if (bedrockTag.get("display") instanceof CompoundTag display) { -- if (display.contains("Name")) { // Bedrock client defaults to empty string if the type is wrong -- javaItem.dataContainer().set(StructuredDataKey.CUSTOM_NAME, TextUtil.stringToNbt(display.getString("Name", ""))); -+ if (display.contains( -+ "Name")) { // Bedrock client defaults to empty string if the type is wrong -+ javaItem.dataContainer() -+ .set( -+ StructuredDataKey.CUSTOM_NAME, -+ TextUtil.stringToNbt(display.getString("Name", ""))); - } - } - } -@@ -291,6 +387,8 @@ public class ItemRewriter extends StoredObject { + final String customName = this.registry.customName(identifier, bedrockTag); + if (customName != null) { +@@ -310,6 +362,8 @@ public class ItemRewriter extends StoredObject { ITEM_NBT_REWRITERS.get(tag).toJava(this.user(), bedrockItem, javaItem); } @@ -10698,7 +10280,7 @@ index 353a67ede..e984d0f67 100644 javaItem.dataContainer().set(StructuredDataKey.USE_EFFECTS, this.useModifiers(bedrockItem).javaComponent()); CustomAttachableResourceRewriter.applyItemModel(this.user().get(ResourcePackStorage.class), identifier, javaItem); -@@ -306,7 +404,8 @@ public class ItemRewriter extends StoredObject { +@@ -325,7 +379,8 @@ public class ItemRewriter extends StoredObject { return null; } final CompoundTag javaTag = new CompoundTag(); @@ -10708,7 +10290,7 @@ index 353a67ede..e984d0f67 100644 javaTag.put("id", new StringTag(javaId)); javaTag.put("count", new IntTag(javaItem.amount())); if (javaItem.tag() != null) { -@@ -320,28 +419,31 @@ public class ItemRewriter extends StoredObject { +@@ -339,28 +394,31 @@ public class ItemRewriter extends StoredObject { return null; } @@ -10751,7 +10333,7 @@ index 353a67ede..e984d0f67 100644 } public Item[] javaItems(final BedrockItem[] bedrockItems) { -@@ -353,7 +455,33 @@ public class ItemRewriter extends StoredObject { +@@ -372,7 +430,33 @@ public class ItemRewriter extends StoredObject { } public BedrockItem bedrockItem(final Item javaItem) { @@ -10786,10 +10368,12 @@ index 353a67ede..e984d0f67 100644 } public BedrockItem[] bedrockItems(final Item[] javaItems) { -@@ -368,6 +496,71 @@ public class ItemRewriter extends StoredObject { +@@ -387,8 +471,52 @@ public class ItemRewriter extends StoredObject { return this.items; } +- public Set getComponentItems() { +- return this.componentItems; + public boolean isBundle(final BedrockItem item) { + return item != null && !item.isEmpty() && CustomItemTags.BUNDLE.equals( + BedrockProtocol.MAPPINGS.getBedrockCustomItemTags().get(this.items.inverse().get(item.identifier()))); @@ -10836,17 +10420,13 @@ index 353a67ede..e984d0f67 100644 + + public ItemRegistry getItemRegistry() { + return this.registry; -+ } -+ -+ public int maxStackSize(final BedrockItem item) { -+ if (item == null || item.isEmpty()) { -+ return 64; -+ } -+ final String identifier = this.items.inverse().get(item.identifier()); -+ final var properties = identifier == null ? null : this.stackingProperties.get(identifier); -+ return properties == null ? 0 : properties.maximum(); -+ } -+ + } + + /** Match network-loaded stack queries while their connection registry remains current. */ +@@ -428,6 +556,18 @@ public class ItemRewriter extends StoredObject { + return properties == null ? 0 : properties.maximum(); + } + + public ItemCookingProperties cookingProperties(final BedrockItem item) { + if (item == null || item.isEmpty()) { + return ItemCookingProperties.NONE; @@ -10855,10 +10435,14 @@ index 353a67ede..e984d0f67 100644 + return identifier == null ? ItemCookingProperties.NONE : this.cookingProperties.getOrDefault(identifier, ItemCookingProperties.NONE); + } + - public Set getComponentItems() { - return this.componentItems; ++ public Set getComponentItems() { ++ return this.componentItems; ++ } ++ + public Type itemType() { + return this.itemType; } -@@ -396,10 +589,13 @@ public class ItemRewriter extends StoredObject { +@@ -452,10 +592,13 @@ public class ItemRewriter extends StoredObject { return this.newItemArrayType; } @@ -11404,10 +10988,10 @@ index 000000000..afdbf134f +} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryRequestStorage.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryRequestStorage.java new file mode 100644 -index 000000000..e4f92acd8 +index 000000000..5fddc92eb --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryRequestStorage.java -@@ -0,0 +1,202 @@ +@@ -0,0 +1,232 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2023-2025 RK_01/RaphiMC and contributors @@ -11482,6 +11066,10 @@ index 000000000..e4f92acd8 + return this.prevContainers; + } + ++ public boolean hasPrediction() { ++ return this.prediction != null; ++ } ++ + public void capturePrediction(final InventoryTracker tracker) { + if (this.prediction != null) { + throw new IllegalStateException("Inventory request prediction was already captured"); @@ -11503,6 +11091,32 @@ index 000000000..e4f92acd8 + List.copyOf(containers), tracker.getCurrentContainer(), tracker.latestContainerGeneration()); + } + ++ /** Replay only this request's delta after restoring the batch's initial snapshot. */ ++ Set applyPrediction(final InventoryTracker tracker) { ++ final Set changed = new HashSet<>(); ++ this.applyPrediction(tracker, this.prediction.cursor(), changed); ++ for (final ContainerPrediction snapshot : this.prediction.containers()) { ++ this.applyPrediction(tracker, snapshot, changed); ++ } ++ return changed; ++ } ++ ++ private void applyPrediction(final InventoryTracker tracker, final ContainerPrediction snapshot, ++ final Set changed) { ++ final Container owner = snapshot.owner(); ++ if (resolveSnapshotContainer(tracker, owner) != owner || !this.acceptsResponse(tracker, owner) ++ || owner.serverRevision() != snapshot.before().serverRevision()) { ++ return; ++ } ++ for (int slot = 0; slot < owner.size(); slot++) { ++ if (this.acceptsResponse(tracker, owner, slot) ++ && !snapshot.before().getItem(slot).equals(snapshot.after().getItem(slot))) { ++ owner.setItem(slot, snapshot.after().getItem(slot).copy()); ++ changed.add(owner); ++ } ++ } ++ } ++ + public BedrockItem predictedItem(final Container target, final int slot) { + if (this.prediction == null) { + throw new IllegalStateException("Inventory request prediction has not been captured"); @@ -11612,10 +11226,10 @@ index 000000000..e4f92acd8 +} 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 000000000..ce84b3856 +index 000000000..208c07858 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryRequestTracker.java -@@ -0,0 +1,564 @@ +@@ -0,0 +1,725 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2023-2025 RK_01/RaphiMC and contributors @@ -11651,8 +11265,11 @@ index 000000000..ce84b3856 +import net.raphimc.viabedrock.protocol.data.enums.bedrock.ComplexInventoryTransaction_Type; +import net.raphimc.viabedrock.protocol.model.inventory.BedrockInventoryTransaction; +import net.raphimc.viabedrock.protocol.model.inventory.InventoryTransactionData; ++import net.raphimc.viabedrock.protocol.model.inventory.ItemStackRequestAction; ++import net.raphimc.viabedrock.protocol.model.inventory.ItemStackRequestInfo; +import net.raphimc.viabedrock.protocol.rewriter.InventoryTransactionRewriter; +import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.InteractPacketPayload_Action; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ContainerEnumName; +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; @@ -11663,7 +11280,9 @@ index 000000000..ce84b3856 + +import java.util.ArrayDeque; +import java.util.HashSet; ++import java.util.HashMap; +import java.util.List; ++import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; @@ -11686,6 +11305,8 @@ index 000000000..ce84b3856 + private boolean closeTemporaryInventory; + private boolean predictingClick; + private CraftedItemSynchronization craftedItemSynchronization; ++ private TransferBatch transferBatch; ++ private Set batchChanges; + + public InventoryRequestTracker(final UserConnection user) { + super(user); @@ -11709,8 +11330,68 @@ index 000000000..ce84b3856 + this.recovery.reset(); + } + ++ /** Send one input's separately scoped transfers, each with its intermediate prediction captured. */ ++ public void sendTransferBatch(final List batch) { ++ this.addTransferBatch(batch); ++ try { ++ PacketFactory.sendBedrockInventoryRequest(this.user(), batch.stream() ++ .map(InventoryRequestStorage::requestInfo) ++ .toArray(ItemStackRequestInfo[]::new)); ++ } catch (final RuntimeException error) { ++ final boolean wasPredicting = this.predictingClick; ++ this.predictingClick = true; ++ try { ++ for (final var request : batch.reversed()) { ++ this.restoreRejectedRequest(request); ++ } ++ } finally { ++ this.predictingClick = wasPredicting; ++ this.requests.clear(); ++ this.transferBatch = null; ++ this.resetRecovery(); ++ this.clearQueuedInteractions(); ++ } ++ throw error; ++ } ++ } ++ ++ void addTransferBatch(final List batch) { ++ if (this.hasPending() || batch.isEmpty()) { ++ throw new IllegalStateException("An inventory transfer batch cannot start"); ++ } ++ final Set ids = new HashSet<>(); ++ for (final var request : batch) { ++ if (!request.hasPrediction() || !request.hasCurrentMenu(this.user().get(InventoryTracker.class)) ++ || !ids.add(request.requestInfo().requestId()) ++ || request.requestInfo().actions().isEmpty() ++ || !request.requestInfo().actions().stream().allMatch(InventoryRequestTracker::ordinaryTransfer)) { ++ throw new IllegalArgumentException("A transfer batch requires distinct, predicted ordinary requests"); ++ } ++ } ++ this.transferBatch = new TransferBatch(List.copyOf(batch), new Int2ObjectOpenHashMap<>()); ++ for (final var request : batch) { ++ this.requests.put(request.requestInfo().requestId(), request); ++ } ++ this.pendingSinceNanos = System.nanoTime(); ++ this.recovery.reset(); ++ } ++ ++ private static boolean ordinaryTransfer(final ItemStackRequestAction action) { ++ if (action instanceof ItemStackRequestAction.TakeAction take) { ++ return take.source().container().name() != ContainerEnumName.CreatedOutputContainer ++ && take.destination().container().name() != ContainerEnumName.CreatedOutputContainer; ++ } ++ if (action instanceof ItemStackRequestAction.PlaceAction place) { ++ return place.source().container().name() != ContainerEnumName.CreatedOutputContainer ++ && place.destination().container().name() != ContainerEnumName.CreatedOutputContainer; ++ } ++ return action instanceof ItemStackRequestAction.SwapAction swap ++ && swap.source().container().name() != ContainerEnumName.CreatedOutputContainer ++ && swap.destination().container().name() != ContainerEnumName.CreatedOutputContainer; ++ } ++ + public boolean hasPending() { -+ return !this.requests.isEmpty() || this.craftedItemSynchronization != null; ++ return !this.requests.isEmpty() || this.craftedItemSynchronization != null || this.batchChanges != null; + } + + public boolean isPredictingClick() { @@ -11727,11 +11408,18 @@ index 000000000..ce84b3856 + } + + public boolean isWaitingForInventoryOpen() { -+ return this.pendingInventoryOpenClicks.isWaiting(); ++ final var screens = this.user().get(PlayerInventoryScreenTracker.class); ++ return this.pendingInventoryOpenClicks.isWaiting() || screens != null && screens.hasPendingOpen(); + } + + public boolean beginInventoryOpen() { -+ return this.pendingInventoryOpenClicks.begin(System.nanoTime()); ++ final var screens = this.user().get(PlayerInventoryScreenTracker.class); ++ return (screens == null || !screens.hasPendingOpen()) ++ && this.pendingInventoryOpenClicks.begin(System.nanoTime()); ++ } ++ ++ public boolean isRecovering() { ++ return this.recovery.isActive(); + } + + public boolean queueInventoryOpenClick(final int revision, final short slot, @@ -11748,7 +11436,7 @@ index 000000000..ce84b3856 + this.closeTemporaryInventory = false; + } + -+ private void closeTemporaryInventoryIfIdle() { ++ void closeTemporaryInventoryIfIdle() { + if (!this.closeTemporaryInventory || this.hasPending() + || this.isWaitingForInventoryOpen() || !this.queuedInteractions.isEmpty()) { + return; @@ -11825,6 +11513,9 @@ index 000000000..ce84b3856 + } + + public void removeRequest(final int requestId) { ++ if (this.transferBatch != null || this.batchChanges != null) { ++ throw new IllegalStateException("A transfer batch must complete as a whole"); ++ } + this.requests.remove(requestId); + if (!this.hasPending()) { + this.resetRecovery(); @@ -11856,22 +11547,25 @@ index 000000000..ce84b3856 + ViaBedrock.getPlatform().getLogger().log(Level.WARNING, + "Inventory request timed out; reopening player inventory for authoritative state"); + final InventoryTracker tracker = this.user().get(InventoryTracker.class); -+ final InventoryRequestStorage request = this.requests.isEmpty() ? null -+ : this.requests.values().iterator().next(); -+ final boolean armorTouched = request == null || request.prevContainers().stream() ++ final boolean armorTouched = this.requests.isEmpty() || this.requests.values().stream() ++ .flatMap(request -> request.prevContainers().stream()) + .anyMatch(container -> container.containerId() == tracker.getArmorContainer().containerId()); -+ final boolean offhandTouched = request == null || request.prevContainers().stream() ++ final boolean offhandTouched = this.requests.isEmpty() || this.requests.values().stream() ++ .flatMap(request -> request.prevContainers().stream()) + .anyMatch(container -> container.containerId() == tracker.getOffhandContainer().containerId()); + this.recovery.begin(now, armorTouched, offhandTouched); + this.clearQueuedInteractions(); + tracker.closeScreenForInventoryRecovery(); -+ final PacketWrapper interact = PacketWrapper.create(ServerboundBedrockPackets.INTERACT, this.user()); -+ interact.write(Types.UNSIGNED_BYTE, -+ (short) InteractPacketPayload_Action.OpenInventory.getValue()); -+ interact.write(BedrockTypes.UNSIGNED_VAR_LONG, -+ this.user().get(EntityTracker.class).getClientPlayer().runtimeId()); -+ interact.write(BedrockTypes.OPTIONAL_POSITION_3F, null); -+ interact.sendToServer(BedrockProtocol.class); ++ final var screens = this.user().get(PlayerInventoryScreenTracker.class); ++ if (screens == null || !screens.hasPendingOpen()) { ++ final PacketWrapper interact = PacketWrapper.create(ServerboundBedrockPackets.INTERACT, this.user()); ++ interact.write(Types.UNSIGNED_BYTE, ++ (short) InteractPacketPayload_Action.OpenInventory.getValue()); ++ interact.write(BedrockTypes.UNSIGNED_VAR_LONG, ++ this.user().get(EntityTracker.class).getClientPlayer().runtimeId()); ++ interact.write(BedrockTypes.OPTIONAL_POSITION_3F, null); ++ interact.sendToServer(BedrockProtocol.class); ++ } + this.requestInventoryRefresh(); + } + @@ -11900,6 +11594,7 @@ index 000000000..ce84b3856 + final InventoryTracker tracker = this.user().get(InventoryTracker.class); + if (this.recovery.observe(container, tracker.getHudContainer(), fullContent, slot)) { + this.requests.clear(); ++ this.transferBatch = null; + this.craftedItemSynchronization = null; + this.resetRecovery(); + PacketFactory.sendJavaContainerSetContent(this.user(), tracker.getInventoryContainer()); @@ -11917,6 +11612,13 @@ index 000000000..ce84b3856 + // player inventory or cursor, and its slot IDs may now refer to another screen. + return; + } ++ if (this.transferBatch != null && this.requests.containsKey(response.requestId())) { ++ this.transferBatch.responses().putIfAbsent(response.requestId(), this.captureTransferResponse(response)); ++ if (this.transferBatch.responses().size() == this.transferBatch.requests().size()) { ++ this.completeTransferBatch(); ++ } ++ return; ++ } + final InventoryRequestStorage request = this.requests.remove(response.requestId()); + if (request == null) { + ViaBedrock.getPlatform() @@ -11938,7 +11640,9 @@ index 000000000..ce84b3856 + + response.requestId() + + " failed: " + + response.result()); -+ changed.addAll(this.restoreRejectedRequest(request)); ++ if (this.batchChanges == null) { ++ changed.addAll(this.restoreRejectedRequest(request)); ++ } + } else { + changed.addAll(this.applySuccessfulResponse(response, request)); + if (this.synchronizeCraftedItems(request, response)) { @@ -11946,6 +11650,10 @@ index 000000000..ce84b3856 + } + } + ++ if (this.batchChanges != null) { ++ this.batchChanges.addAll(changed); ++ return; ++ } + if (response.result() == ItemStackNetResult.Success) { + this.drainQueuedInteractions(); + } @@ -11957,6 +11665,55 @@ index 000000000..ce84b3856 + this.closeTemporaryInventoryIfIdle(); + } + ++ private void completeTransferBatch() { ++ final TransferBatch batch = this.transferBatch; ++ final InventoryTracker inventory = this.user().get(InventoryTracker.class); ++ final Set changed = new HashSet<>(); ++ this.transferBatch = null; ++ this.batchChanges = changed; ++ final boolean wasPredicting = this.predictingClick; ++ this.predictingClick = true; ++ try { ++ // Restore the oldest snapshot of each touched container, then replay accepted deltas. ++ // A rejected later request must not restore an intermediate, unconfirmed network ID. ++ for (final var request : batch.requests().reversed()) { ++ changed.addAll(this.restoreRejectedRequest(request)); ++ } ++ for (final var request : batch.requests()) { ++ final var response = batch.responses().get(request.requestInfo().requestId()).current(inventory); ++ if (response.result() == ItemStackNetResult.Success) { ++ changed.addAll(request.applyPrediction(inventory)); ++ } ++ this.handleResponse(response); ++ } ++ } finally { ++ this.predictingClick = wasPredicting; ++ this.batchChanges = null; ++ } ++ if (batch.responses().values().stream().allMatch(response -> response.response().result() == ItemStackNetResult.Success)) { ++ this.drainQueuedInteractions(); ++ } ++ for (final var container : changed) { ++ PacketFactory.sendJavaContainerSetContent(this.user(), container); ++ } ++ PacketFactory.sendJavaContainerSetContent(this.user(), inventory.getInventoryContainer()); ++ this.closeTemporaryInventoryIfIdle(); ++ } ++ ++ private BufferedTransferResponse captureTransferResponse(final ItemStackResponseInfo response) { ++ final InventoryTracker inventory = this.user().get(InventoryTracker.class); ++ final Map revisions = new HashMap<>(); ++ for (final var info : response.containers()) { ++ for (final var slot : info.slots()) { ++ final var owner = inventory.getContainerFromName(info.containerName(), Byte.toUnsignedInt(slot.slot())); ++ if (owner != null) { ++ revisions.put(owner, owner.serverRevision()); ++ } ++ } ++ } ++ return new BufferedTransferResponse(response, Map.copyOf(revisions)); ++ } ++ + private void requestInventoryRefresh() { + final PacketWrapper refresh = PacketWrapper.create( + ServerboundBedrockPackets.INVENTORY_TRANSACTION, this.user()); @@ -12147,6 +11904,24 @@ index 000000000..ce84b3856 + + record InventoryOpenClick(int revision, short slot, byte button, ContainerInput action) { } + ++ private record TransferBatch(List requests, ++ Int2ObjectOpenHashMap responses) { } ++ ++ private record BufferedTransferResponse(ItemStackResponseInfo response, Map revisions) { ++ ++ private ItemStackResponseInfo current(final InventoryTracker inventory) { ++ final List containers = this.response.containers().stream().map(info -> ++ new ItemStackResponseContainerInfo(info.containerName(), info.slots().stream().filter(slot -> { ++ final var owner = inventory.getContainerFromName(info.containerName(), Byte.toUnsignedInt(slot.slot())); ++ // Keep unknown slots for the normal response diagnostic. A new owner or ++ // a later contents update supersedes an earlier buffered acknowledgment. ++ return owner == null || Long.valueOf(owner.serverRevision()).equals(this.revisions.get(owner)); ++ }).toList())).filter(info -> !info.slots().isEmpty()).toList(); ++ return new ItemStackResponseInfo(this.response.result(), this.response.requestId(), containers); ++ } ++ ++ } ++ + interface QueuedInteraction { + + Container container(); @@ -12181,7 +11956,7 @@ index 000000000..ce84b3856 + +} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryTracker.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryTracker.java -index 3e0723358..a15bdeeb1 100644 +index cf0850a00..99d26d410 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryTracker.java @@ -22,9 +22,12 @@ import com.viaversion.viaversion.api.connection.UserConnection; @@ -12326,7 +12101,7 @@ index 3e0723358..a15bdeeb1 100644 public BundleContainer getDynamicContainer(final FullContainerName containerName) { return this.dynamicContainerRegistry.get(containerName); } -@@ -111,18 +187,79 @@ public class InventoryTracker extends StoredObject { +@@ -111,24 +187,95 @@ public class InventoryTracker extends StoredObject { if (this.pendingCloseContainer != null) { throw new IllegalStateException("There is already another container pending close"); } @@ -12340,6 +12115,7 @@ index 3e0723358..a15bdeeb1 100644 + this.currentContainerOpenContext = null; } this.pendingCloseContainer = container; +- this.clearNativeContainerActions(); } public void setCurrentContainerClosed(final boolean serverInitiated) { @@ -12360,9 +12136,17 @@ index 3e0723358..a15bdeeb1 100644 + if (requests != null) { + requests.drainQueuedInteractions(); + } ++ final var screens = this.user().get(PlayerInventoryScreenTracker.class); ++ if (screens != null) { ++ screens.closed(serverInitiated); ++ } + } + + public void closeScreenForInventoryRecovery() { ++ final var screens = this.user().get(PlayerInventoryScreenTracker.class); ++ if (screens != null) { ++ screens.recovering(); ++ } + this.clearQueuedInteractions(); + if (this.currentContainer != null) { + if (this.currentContainer.type() != ContainerType.INVENTORY) { @@ -12372,7 +12156,7 @@ index 3e0723358..a15bdeeb1 100644 + PacketFactory.sendBedrockContainerClose( + this.user(), this.currentContainer.containerId(), ContainerType.NONE); + } -+ this.clearNativeContainerActions(); + this.clearNativeContainerActions(); + this.currentContainer = null; + this.pendingCloseContainer = null; + this.currentContainerGeneration = 0; @@ -12407,7 +12191,15 @@ index 3e0723358..a15bdeeb1 100644 } /** A server form replaces the inventory screen before its close acknowledgement arrives. */ -@@ -136,36 +273,65 @@ public class InventoryTracker extends StoredObject { + public void closeContainerForForm() { ++ final var screens = this.user().get(PlayerInventoryScreenTracker.class); ++ if (screens != null) { ++ screens.retire(); ++ } + if (this.currentContainer != null) { + this.forceCloseCurrentContainer(); + } +@@ -138,36 +285,69 @@ public class InventoryTracker extends StoredObject { if (this.currentForm == null) { throw new IllegalStateException("There is no form currently open"); } @@ -12425,6 +12217,10 @@ index 3e0723358..a15bdeeb1 100644 } public void tick() { ++ final var screens = this.user().get(PlayerInventoryScreenTracker.class); ++ if (screens != null) { ++ screens.tick(System.nanoTime()); ++ } + this.user().get(InventoryRequestTracker.class).tick(); if (this.currentContainer != null && this.currentContainer.position() != null) { if (this.currentContainer.type() == ContainerType.INVENTORY) { @@ -12479,37 +12275,47 @@ index 3e0723358..a15bdeeb1 100644 this.forceCloseCurrentContainer(); } } -@@ -205,10 +371,55 @@ public class InventoryTracker extends StoredObject { +@@ -202,6 +382,27 @@ public class InventoryTracker extends StoredObject { + return this.hudContainer; } - public void setCurrentContainer(final Container container) { ++ public Container getCurrentContainer() { ++ return this.currentContainer; ++ } ++ ++ public void setCurrentContainer(final Container container) { + this.setCurrentContainer(container, null); + } + + public void setCurrentContainer(final Container container, final ContainerOpenContext openContext) { + Objects.requireNonNull(container, "container"); - if (this.isContainerOpen()) { - throw new IllegalStateException("There is already another container open"); - } ++ if (this.isContainerOpen()) { ++ throw new IllegalStateException("There is already another container open"); ++ } + final long generation = Math.incrementExact(this.lastContainerGeneration); + this.clearNativeContainerActions(); - this.currentContainer = container; ++ this.currentContainer = container; + this.lastContainerGeneration = generation; + this.currentContainerGeneration = generation; + this.currentContainerOpenContext = openContext; + } + -+ private void clearNativeContainerActions() { -+ final var state = this.user() == null ? null : this.user().get(NativeContainerUiState.class); -+ if (state != null) { -+ state.clearActions(); -+ } -+ } -+ + private void clearNativeContainerActions() { + final var state = this.user() == null ? null : this.user().get(NativeContainerUiState.class); + if (state != null) { +@@ -209,15 +410,32 @@ public class InventoryTracker extends StoredObject { + } + } + +- public Container getCurrentContainer() { +- return this.currentContainer; + public long currentContainerGeneration() { + return this.currentContainerGeneration; -+ } -+ + } + +- public void setCurrentContainer(final Container container) { +- if (this.isContainerOpen()) { +- throw new IllegalStateException("There is already another container open"); + long latestContainerGeneration() { + return this.lastContainerGeneration; + } @@ -12522,7 +12328,8 @@ index 3e0723358..a15bdeeb1 100644 + public void retainCurrentContainerOpenContext(final Container container, final ContainerOpenContext openContext) { + if (container != this.currentContainer || container == null) { + throw new IllegalStateException("Container opening no longer belongs to the current menu"); -+ } + } +- this.currentContainer = container; + this.currentContainerOpenContext = Objects.requireNonNull(openContext, "openContext"); + } + @@ -12535,19 +12342,230 @@ index 3e0723358..a15bdeeb1 100644 } public Container getPendingCloseContainer() { -@@ -225,8 +436,10 @@ public class InventoryTracker extends StoredObject { +@@ -234,8 +452,13 @@ public class InventoryTracker extends StoredObject { private void forceCloseCurrentContainer() { this.markPendingClose(this.currentContainer); - PacketFactory.sendJavaContainerClose(this.user(), this.pendingCloseContainer.javaContainerId()); - PacketFactory.sendBedrockContainerClose(this.user(), this.pendingCloseContainer.containerId(), ContainerType.NONE); -+ PacketFactory.sendJavaContainerClose( -+ this.user(), this.pendingCloseContainer.javaContainerId()); ++ final var screens = this.user().get(PlayerInventoryScreenTracker.class); ++ if (!(this.pendingCloseContainer instanceof InventoryContainer) || screens == null || !screens.ownsClose()) { ++ PacketFactory.sendJavaContainerClose( ++ this.user(), this.pendingCloseContainer.javaContainerId()); ++ } + PacketFactory.sendBedrockContainerClose( + this.user(), this.pendingCloseContainer.containerId(), ContainerType.NONE); } } +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 000000000..e7a9d17d9 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/PlayerInventoryScreenTracker.java +@@ -0,0 +1,202 @@ ++/* ++ * 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.StoredObject; ++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.protocols.v26_2to26_3.packet.ClientboundPackets26_3; ++import java.nio.charset.StandardCharsets; ++import java.util.concurrent.TimeUnit; ++import net.raphimc.viabedrock.api.model.PlayerInventoryScreenClose; ++import net.raphimc.viabedrock.api.model.PlayerInventoryScreenRequest; ++import net.raphimc.viabedrock.api.model.container.player.InventoryContainer; ++import net.raphimc.viabedrock.protocol.BedrockProtocol; ++import net.raphimc.viabedrock.protocol.ServerboundBedrockPackets; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.InteractPacketPayload_Action; ++import net.raphimc.viabedrock.protocol.types.BedrockTypes; ++ ++/** Serializes uncorrelated Bedrock opens and retains retired openings until their acknowledgments arrive. */ ++public final class PlayerInventoryScreenTracker extends StoredObject { ++ ++ private static final long OPEN_TIMEOUT_NANOS = TimeUnit.SECONDS.toNanos(5); ++ private long latestOpening; ++ private boolean requested; ++ private long inFlight; ++ private long requestedSince; ++ private long nativeOpening; ++ private boolean advertised; ++ ++ public PlayerInventoryScreenTracker(final UserConnection user) { ++ super(user); ++ } ++ ++ public boolean enabled() { ++ final var channels = this.user().get(ChannelStorage.class); ++ return channels != null && channels.hasChannel(PlayerInventoryScreenClose.CHANNEL) ++ && this.user().getProtocolInfo().getClientState() == State.PLAY; ++ } ++ ++ public void refreshCapability() { ++ if (this.user().getProtocolInfo().getClientState() != State.PLAY) { ++ this.advertised = false; ++ this.retire(); ++ return; ++ } ++ final boolean enabled = this.enabled(); ++ if (enabled == this.advertised) { ++ return; ++ } ++ this.advertised = enabled; ++ final var packet = PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, this.user()); ++ packet.write(Types.STRING, enabled ? "minecraft:register" : "minecraft:unregister"); ++ packet.write(Types.REMAINING_BYTES, PlayerInventoryScreenRequest.CHANNEL.getBytes(StandardCharsets.UTF_8)); ++ packet.send(BedrockProtocol.class); ++ if (!enabled) { ++ this.retire(); ++ } ++ } ++ ++ public void request(final PlayerInventoryScreenRequest request) { ++ if (!this.enabled()) { ++ return; ++ } ++ if (request.open()) { ++ if (request.opening() <= this.latestOpening) { ++ return; ++ } ++ this.requests().clearQueuedScreenInteractions(); ++ this.latestOpening = request.opening(); ++ this.requested = true; ++ this.requestedSince = System.nanoTime(); ++ } else { ++ if (request.opening() != this.latestOpening || !this.requested) { ++ return; ++ } ++ this.requested = false; ++ this.requests().clearQueuedScreenInteractions(); ++ } ++ this.service(); ++ } ++ ++ /** Ordinary close-zero packets cannot identify which of two player openings they belong to. */ ++ public boolean ownsClose() { ++ return this.requested || this.inFlight != 0 || this.nativeOpening != 0; ++ } ++ ++ public boolean hasPendingOpen() { ++ return this.inFlight != 0; ++ } ++ ++ public boolean acceptsClicks() { ++ return this.requested && (this.inFlight == this.latestOpening ++ || this.nativeOpening == this.latestOpening && this.inventory().getCurrentContainer() instanceof InventoryContainer); ++ } ++ ++ /** Called after the native container is installed, before any queued Java clicks are replayed. */ ++ public void opened() { ++ if (this.inFlight == 0) { ++ return; ++ } ++ final long opening = this.inFlight; ++ this.inFlight = 0; ++ this.nativeOpening = opening; ++ if (this.enabled() && this.requested && opening == this.latestOpening) { ++ this.requests().keepPlayerInventoryOpen(); ++ return; ++ } ++ // Old screen clicks were discarded when its request retired. Later global offhand ++ // actions can still use this acknowledgment, then close the temporary inventory. ++ this.requests().openPlayerInventoryTemporarily(); ++ } ++ ++ /** The next request starts only after the previous native close acknowledgment. */ ++ public void closed(final boolean serverInitiated) { ++ final long opening = this.nativeOpening; ++ this.nativeOpening = 0; ++ if (serverInitiated && opening != 0 && opening == this.latestOpening) { ++ this.retire(); ++ } ++ this.service(); ++ } ++ ++ public void retire() { ++ 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.requests().clearQueuedScreenInteractions(); ++ this.service(); ++ } ++ ++ public void recovering() { ++ this.retire(); ++ this.nativeOpening = 0; ++ } ++ ++ public void tick(final long now) { ++ if (this.requested && (this.nativeOpening != this.latestOpening || this.inventory().getCurrentContainer() == null) ++ && now - this.requestedSince >= OPEN_TIMEOUT_NANOS) { ++ // Keep inFlight: a timeout cannot attach an eventual old acknowledgment to a new screen. ++ this.retire(); ++ } ++ this.service(); ++ } ++ ++ private void service() { ++ final var inventory = this.inventory(); ++ if (this.nativeOpening != 0 && inventory.getCurrentContainer() instanceof InventoryContainer ++ && (!this.requested || this.nativeOpening != this.latestOpening)) { ++ this.requests().openPlayerInventoryTemporarily(); ++ this.requests().closeTemporaryInventoryIfIdle(); ++ return; ++ } ++ if (!this.requested || this.inFlight != 0 || inventory.getPendingCloseContainer() != null) { ++ return; ++ } ++ if (!this.enabled() || this.requests().isRecovering()) { ++ this.retire(); ++ return; ++ } ++ if (inventory.isNonInventoryScreenOpen() ++ || inventory.getCurrentContainer() != null && !(inventory.getCurrentContainer() instanceof InventoryContainer)) { ++ this.retire(); ++ return; ++ } ++ this.requests().keepPlayerInventoryOpen(); ++ if (inventory.getCurrentContainer() instanceof InventoryContainer) { ++ // A temporary offhand opening can become the actual installed player screen. ++ this.nativeOpening = this.latestOpening; ++ return; ++ } ++ final boolean send = this.requests().beginInventoryOpen(); ++ this.inFlight = this.latestOpening; ++ if (!send) { ++ return; ++ } ++ final var packet = PacketWrapper.create(ServerboundBedrockPackets.INTERACT, this.user()); ++ packet.write(Types.UNSIGNED_BYTE, (short) InteractPacketPayload_Action.OpenInventory.getValue()); ++ packet.write(BedrockTypes.UNSIGNED_VAR_LONG, this.user().get(EntityTracker.class).getClientPlayer().runtimeId()); ++ packet.write(BedrockTypes.OPTIONAL_POSITION_3F, null); ++ packet.sendToServer(BedrockProtocol.class); ++ } ++ ++ private InventoryTracker inventory() { ++ return this.user().get(InventoryTracker.class); ++ } ++ ++ private InventoryRequestTracker requests() { ++ return this.user().get(InventoryRequestTracker.class); ++ } ++ ++} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/types/item/BedrockItemType.java b/src/main/java/net/raphimc/viabedrock/protocol/types/item/BedrockItemType.java index 8c3cf5c9c..44df53169 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/types/item/BedrockItemType.java @@ -12821,3827 +12839,6 @@ index 676ea8b4a..345e4720a 100644 } BedrockTypes.UNSIGNED_VAR_INT.write(buffer, userData.readableBytes()); -diff --git a/src/main/resources/assets/viabedrock/data/bedrock/item_factories.json b/src/main/resources/assets/viabedrock/data/bedrock/item_factories.json -new file mode 100644 -index 000000000..0b51da499 ---- /dev/null -+++ b/src/main/resources/assets/viabedrock/data/bedrock/item_factories.json -@@ -0,0 +1,3815 @@ -+{ -+ "format": 1, -+ "gameVersion": "1.26.51.1", -+ "protocol": 2193, -+ "factories": [ -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 0, -+ "damageable": false, -+ "identifiers": [ -+ "minecraft:black_harness", -+ "minecraft:black_shulker_box", -+ "minecraft:blue_harness", -+ "minecraft:blue_shulker_box", -+ "minecraft:bordure_indented_banner_pattern", -+ "minecraft:brown_harness", -+ "minecraft:brown_shulker_box", -+ "minecraft:chest_minecart", -+ "minecraft:command_block_minecart", -+ "minecraft:copper_horse_armor", -+ "minecraft:copper_nautilus_armor", -+ "minecraft:creeper_banner_pattern", -+ "minecraft:cyan_harness", -+ "minecraft:cyan_shulker_box", -+ "minecraft:diamond_horse_armor", -+ "minecraft:diamond_nautilus_armor", -+ "minecraft:enchanted_book", -+ "minecraft:field_masoned_banner_pattern", -+ "minecraft:flow_banner_pattern", -+ "minecraft:flower_banner_pattern", -+ "minecraft:globe_banner_pattern", -+ "minecraft:goat_horn", -+ "minecraft:golden_horse_armor", -+ "minecraft:golden_nautilus_armor", -+ "minecraft:gray_harness", -+ "minecraft:gray_shulker_box", -+ "minecraft:green_harness", -+ "minecraft:green_shulker_box", -+ "minecraft:guster_banner_pattern", -+ "minecraft:hopper_minecart", -+ "minecraft:iron_horse_armor", -+ "minecraft:iron_nautilus_armor", -+ "minecraft:leather_horse_armor", -+ "minecraft:light_blue_harness", -+ "minecraft:light_blue_shulker_box", -+ "minecraft:light_gray_harness", -+ "minecraft:light_gray_shulker_box", -+ "minecraft:lime_harness", -+ "minecraft:lime_shulker_box", -+ "minecraft:magenta_harness", -+ "minecraft:magenta_shulker_box", -+ "minecraft:minecart", -+ "minecraft:mojang_banner_pattern", -+ "minecraft:netherite_horse_armor", -+ "minecraft:netherite_nautilus_armor", -+ "minecraft:orange_harness", -+ "minecraft:orange_shulker_box", -+ "minecraft:photo_item", -+ "minecraft:piglin_banner_pattern", -+ "minecraft:pink_harness", -+ "minecraft:pink_shulker_box", -+ "minecraft:portfolio", -+ "minecraft:purple_harness", -+ "minecraft:purple_shulker_box", -+ "minecraft:red_harness", -+ "minecraft:red_shulker_box", -+ "minecraft:saddle", -+ "minecraft:skull_banner_pattern", -+ "minecraft:spyglass", -+ "minecraft:suspicious_stew", -+ "minecraft:tnt_minecart", -+ "minecraft:totem_of_undying", -+ "minecraft:undyed_shulker_box", -+ "minecraft:white_harness", -+ "minecraft:white_shulker_box", -+ "minecraft:writable_book", -+ "minecraft:yellow_harness", -+ "minecraft:yellow_shulker_box" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 195, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:chainmail_boots", -+ "minecraft:iron_boots" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 240, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:chainmail_chestplate", -+ "minecraft:iron_chestplate" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 165, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:chainmail_helmet", -+ "minecraft:copper_leggings", -+ "minecraft:iron_helmet" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 225, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:chainmail_leggings", -+ "minecraft:iron_leggings" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 143, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:copper_boots" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 176, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:copper_chestplate" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 121, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:copper_helmet" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 429, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:diamond_boots" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 528, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:diamond_chestplate" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 363, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:diamond_helmet" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 495, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:diamond_leggings" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 432, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:elytra" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 91, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:golden_boots" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 112, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:golden_chestplate" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 77, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:golden_helmet" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 105, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:golden_leggings" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 65, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:leather_boots" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 80, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:leather_chestplate" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 55, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:leather_helmet" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 75, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:leather_leggings" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 481, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:netherite_boots" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 592, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:netherite_chestplate" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 407, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:netherite_helmet" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 555, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:netherite_leggings" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 275, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:turtle_helmet" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": true, -+ "maximumDamage": 0, -+ "damageable": false, -+ "forcedSubtype": true, -+ "identifiers": [ -+ "minecraft:acacia_boat", -+ "minecraft:acacia_chest_boat", -+ "minecraft:bamboo_chest_raft", -+ "minecraft:bamboo_raft", -+ "minecraft:birch_boat", -+ "minecraft:birch_chest_boat", -+ "minecraft:cherry_boat", -+ "minecraft:cherry_chest_boat", -+ "minecraft:dark_oak_boat", -+ "minecraft:dark_oak_chest_boat", -+ "minecraft:jungle_boat", -+ "minecraft:jungle_chest_boat", -+ "minecraft:mangrove_boat", -+ "minecraft:mangrove_chest_boat", -+ "minecraft:oak_boat", -+ "minecraft:oak_chest_boat", -+ "minecraft:pale_oak_boat", -+ "minecraft:pale_oak_chest_boat", -+ "minecraft:poplar_boat", -+ "minecraft:poplar_chest_boat", -+ "minecraft:spruce_boat", -+ "minecraft:spruce_chest_boat" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 26, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:carrot_on_a_stick" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 32, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:golden_axe", -+ "minecraft:golden_hoe", -+ "minecraft:golden_pickaxe", -+ "minecraft:golden_shovel", -+ "minecraft:golden_sword" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 59, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:wooden_axe", -+ "minecraft:wooden_hoe", -+ "minecraft:wooden_pickaxe", -+ "minecraft:wooden_shovel", -+ "minecraft:wooden_sword" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 64, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:brush", -+ "minecraft:flint_and_steel" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 131, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:stone_axe", -+ "minecraft:stone_hoe", -+ "minecraft:stone_pickaxe", -+ "minecraft:stone_shovel", -+ "minecraft:stone_sword" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 190, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:copper_axe", -+ "minecraft:copper_hoe", -+ "minecraft:copper_pickaxe", -+ "minecraft:copper_shovel", -+ "minecraft:copper_sword" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 238, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:shears" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 250, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:iron_axe", -+ "minecraft:iron_hoe", -+ "minecraft:iron_pickaxe", -+ "minecraft:iron_shovel", -+ "minecraft:iron_sword", -+ "minecraft:trident" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 336, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:shield" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 384, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:bow" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 464, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:crossbow" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 500, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:mace" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 1561, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:diamond_axe", -+ "minecraft:diamond_hoe", -+ "minecraft:diamond_pickaxe", -+ "minecraft:diamond_shovel", -+ "minecraft:diamond_sword" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 2031, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:netherite_axe", -+ "minecraft:netherite_hoe", -+ "minecraft:netherite_pickaxe", -+ "minecraft:netherite_shovel", -+ "minecraft:netherite_sword" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": true, -+ "maximumDamage": 0, -+ "damageable": false, -+ "identifiers": [ -+ "minecraft:bed", -+ "minecraft:lingering_potion", -+ "minecraft:medicine", -+ "minecraft:potion", -+ "minecraft:splash_potion" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": true, -+ "maximumDamage": 100, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:glow_stick", -+ "minecraft:sparkler" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 16, -+ "stackedByData": false, -+ "maximumDamage": 0, -+ "damageable": false, -+ "identifiers": [ -+ "minecraft:acacia_hanging_sign", -+ "minecraft:acacia_sign", -+ "minecraft:bamboo_hanging_sign", -+ "minecraft:bamboo_sign", -+ "minecraft:birch_hanging_sign", -+ "minecraft:birch_sign", -+ "minecraft:black_cushion", -+ "minecraft:blue_cushion", -+ "minecraft:blue_egg", -+ "minecraft:brown_cushion", -+ "minecraft:brown_egg", -+ "minecraft:cherry_hanging_sign", -+ "minecraft:cherry_sign", -+ "minecraft:crimson_hanging_sign", -+ "minecraft:crimson_sign", -+ "minecraft:cyan_cushion", -+ "minecraft:dark_oak_hanging_sign", -+ "minecraft:dark_oak_sign", -+ "minecraft:egg", -+ "minecraft:ender_pearl", -+ "minecraft:gray_cushion", -+ "minecraft:green_cushion", -+ "minecraft:ice_bomb", -+ "minecraft:jungle_hanging_sign", -+ "minecraft:jungle_sign", -+ "minecraft:light_blue_cushion", -+ "minecraft:light_gray_cushion", -+ "minecraft:lime_cushion", -+ "minecraft:magenta_cushion", -+ "minecraft:mangrove_hanging_sign", -+ "minecraft:mangrove_sign", -+ "minecraft:oak_hanging_sign", -+ "minecraft:oak_sign", -+ "minecraft:orange_cushion", -+ "minecraft:pale_oak_hanging_sign", -+ "minecraft:pale_oak_sign", -+ "minecraft:pink_cushion", -+ "minecraft:poplar_hanging_sign", -+ "minecraft:poplar_sign", -+ "minecraft:purple_cushion", -+ "minecraft:red_cushion", -+ "minecraft:spruce_hanging_sign", -+ "minecraft:spruce_sign", -+ "minecraft:straw_bed", -+ "minecraft:warped_hanging_sign", -+ "minecraft:warped_sign", -+ "minecraft:white_cushion", -+ "minecraft:written_book", -+ "minecraft:yellow_cushion" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 16, -+ "stackedByData": true, -+ "maximumDamage": 0, -+ "damageable": false, -+ "identifiers": [ -+ "minecraft:banner", -+ "minecraft:board" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 64, -+ "stackedByData": false, -+ "maximumDamage": 0, -+ "damageable": false, -+ "identifiers": [ -+ "minecraft:acacia_button", -+ "minecraft:acacia_door", -+ "minecraft:acacia_fence", -+ "minecraft:acacia_fence_gate", -+ "minecraft:acacia_leaves", -+ "minecraft:acacia_log", -+ "minecraft:acacia_planks", -+ "minecraft:acacia_pressure_plate", -+ "minecraft:acacia_shelf", -+ "minecraft:acacia_stairs", -+ "minecraft:acacia_standing_sign", -+ "minecraft:acacia_trapdoor", -+ "minecraft:acacia_wall_sign", -+ "minecraft:acacia_wood", -+ "minecraft:activator_rail", -+ "minecraft:air", -+ "minecraft:allium", -+ "minecraft:allow", -+ "minecraft:amethyst_block", -+ "minecraft:amethyst_cluster", -+ "minecraft:amethyst_shard", -+ "minecraft:ancient_debris", -+ "minecraft:andesite", -+ "minecraft:andesite_stairs", -+ "minecraft:andesite_wall", -+ "minecraft:angler_pottery_sherd", -+ "minecraft:anvil", -+ "minecraft:apple", -+ "minecraft:archer_pottery_sherd", -+ "minecraft:armadillo_scute", -+ "minecraft:armor_stand", -+ "minecraft:arms_up_pottery_sherd", -+ "minecraft:azalea", -+ "minecraft:azure_bluet", -+ "minecraft:baked_potato", -+ "minecraft:bamboo", -+ "minecraft:bamboo_block", -+ "minecraft:bamboo_button", -+ "minecraft:bamboo_door", -+ "minecraft:bamboo_fence", -+ "minecraft:bamboo_fence_gate", -+ "minecraft:bamboo_mosaic", -+ "minecraft:bamboo_mosaic_stairs", -+ "minecraft:bamboo_planks", -+ "minecraft:bamboo_pressure_plate", -+ "minecraft:bamboo_sapling", -+ "minecraft:bamboo_shelf", -+ "minecraft:bamboo_stairs", -+ "minecraft:bamboo_standing_sign", -+ "minecraft:bamboo_trapdoor", -+ "minecraft:bamboo_wall_sign", -+ "minecraft:banner_pattern", -+ "minecraft:barrel", -+ "minecraft:barrier", -+ "minecraft:basalt", -+ "minecraft:beacon", -+ "minecraft:bedrock", -+ "minecraft:bee_nest", -+ "minecraft:beef", -+ "minecraft:beehive", -+ "minecraft:beetroot", -+ "minecraft:beetroot_seeds", -+ "minecraft:beetroot_soup", -+ "minecraft:bell", -+ "minecraft:big_dripleaf", -+ "minecraft:birch_button", -+ "minecraft:birch_door", -+ "minecraft:birch_fence", -+ "minecraft:birch_fence_gate", -+ "minecraft:birch_leaves", -+ "minecraft:birch_log", -+ "minecraft:birch_planks", -+ "minecraft:birch_pressure_plate", -+ "minecraft:birch_shelf", -+ "minecraft:birch_stairs", -+ "minecraft:birch_standing_sign", -+ "minecraft:birch_trapdoor", -+ "minecraft:birch_wall_sign", -+ "minecraft:birch_wood", -+ "minecraft:black_candle", -+ "minecraft:black_candle_cake", -+ "minecraft:black_carpet", -+ "minecraft:black_concrete", -+ "minecraft:black_concrete_powder", -+ "minecraft:black_glazed_terracotta", -+ "minecraft:black_stained_glass", -+ "minecraft:black_stained_glass_pane", -+ "minecraft:black_terracotta", -+ "minecraft:black_wool", -+ "minecraft:blackstone", -+ "minecraft:blackstone_stairs", -+ "minecraft:blackstone_wall", -+ "minecraft:blade_pottery_sherd", -+ "minecraft:blast_furnace", -+ "minecraft:blaze_powder", -+ "minecraft:blaze_rod", -+ "minecraft:bleach", -+ "minecraft:blue_candle", -+ "minecraft:blue_candle_cake", -+ "minecraft:blue_carpet", -+ "minecraft:blue_concrete", -+ "minecraft:blue_concrete_powder", -+ "minecraft:blue_glazed_terracotta", -+ "minecraft:blue_ice", -+ "minecraft:blue_orchid", -+ "minecraft:blue_stained_glass", -+ "minecraft:blue_stained_glass_pane", -+ "minecraft:blue_terracotta", -+ "minecraft:blue_wool", -+ "minecraft:boat", -+ "minecraft:bolt_armor_trim_smithing_template", -+ "minecraft:bone", -+ "minecraft:bone_block", -+ "minecraft:book", -+ "minecraft:bookshelf", -+ "minecraft:border_block", -+ "minecraft:bowl", -+ "minecraft:brain_coral", -+ "minecraft:brain_coral_block", -+ "minecraft:brain_coral_wall_fan", -+ "minecraft:bread", -+ "minecraft:breeze_rod", -+ "minecraft:brewer_pottery_sherd", -+ "minecraft:brick", -+ "minecraft:brick_block", -+ "minecraft:brick_stairs", -+ "minecraft:brick_wall", -+ "minecraft:brown_candle", -+ "minecraft:brown_candle_cake", -+ "minecraft:brown_carpet", -+ "minecraft:brown_concrete", -+ "minecraft:brown_concrete_powder", -+ "minecraft:brown_glazed_terracotta", -+ "minecraft:brown_mushroom", -+ "minecraft:brown_stained_glass", -+ "minecraft:brown_stained_glass_pane", -+ "minecraft:brown_terracotta", -+ "minecraft:brown_wool", -+ "minecraft:bubble_column", -+ "minecraft:bubble_coral", -+ "minecraft:bubble_coral_block", -+ "minecraft:bubble_coral_wall_fan", -+ "minecraft:budding_amethyst", -+ "minecraft:burn_pottery_sherd", -+ "minecraft:bush", -+ "minecraft:cactus", -+ "minecraft:cactus_flower", -+ "minecraft:calcite", -+ "minecraft:calibrated_sculk_sensor", -+ "minecraft:camera", -+ "minecraft:candle", -+ "minecraft:candle_cake", -+ "minecraft:carpet", -+ "minecraft:carrot", -+ "minecraft:carrots", -+ "minecraft:cartography_table", -+ "minecraft:carved_pumpkin", -+ "minecraft:cave_vines", -+ "minecraft:cave_vines_body_with_berries", -+ "minecraft:cave_vines_head_with_berries", -+ "minecraft:chain_command_block", -+ "minecraft:chalkboard", -+ "minecraft:chemical_heat", -+ "minecraft:chemistry_table", -+ "minecraft:cherry_button", -+ "minecraft:cherry_door", -+ "minecraft:cherry_fence", -+ "minecraft:cherry_fence_gate", -+ "minecraft:cherry_leaves", -+ "minecraft:cherry_log", -+ "minecraft:cherry_planks", -+ "minecraft:cherry_pressure_plate", -+ "minecraft:cherry_sapling", -+ "minecraft:cherry_shelf", -+ "minecraft:cherry_stairs", -+ "minecraft:cherry_standing_sign", -+ "minecraft:cherry_trapdoor", -+ "minecraft:cherry_wall_sign", -+ "minecraft:cherry_wood", -+ "minecraft:chest", -+ "minecraft:chest_boat", -+ "minecraft:chicken", -+ "minecraft:chipped_anvil", -+ "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:chorus_fruit", -+ "minecraft:chorus_plant", -+ "minecraft:cinnabar", -+ "minecraft:cinnabar_brick_stairs", -+ "minecraft:cinnabar_brick_wall", -+ "minecraft:cinnabar_bricks", -+ "minecraft:cinnabar_stairs", -+ "minecraft:cinnabar_wall", -+ "minecraft:clay", -+ "minecraft:clay_ball", -+ "minecraft:client_request_placeholder_block", -+ "minecraft:clock", -+ "minecraft:closed_eyeblossom", -+ "minecraft:coal_block", -+ "minecraft:coal_ore", -+ "minecraft:coarse_dirt", -+ "minecraft:coast_armor_trim_smithing_template", -+ "minecraft:cobbled_deepslate", -+ "minecraft:cobbled_deepslate_stairs", -+ "minecraft:cobbled_deepslate_wall", -+ "minecraft:cobblestone", -+ "minecraft:cobblestone_wall", -+ "minecraft:cocoa", -+ "minecraft:cod", -+ "minecraft:colored_torch_blue", -+ "minecraft:colored_torch_bp", -+ "minecraft:colored_torch_green", -+ "minecraft:colored_torch_purple", -+ "minecraft:colored_torch_red", -+ "minecraft:colored_torch_rg", -+ "minecraft:command_block", -+ "minecraft:compass", -+ "minecraft:composter", -+ "minecraft:compound_creator", -+ "minecraft:concrete", -+ "minecraft:concrete_powder", -+ "minecraft:conduit", -+ "minecraft:cooked_beef", -+ "minecraft:cooked_chicken", -+ "minecraft:cooked_cod", -+ "minecraft:cooked_mutton", -+ "minecraft:cooked_porkchop", -+ "minecraft:cooked_rabbit", -+ "minecraft:cooked_salmon", -+ "minecraft:cookie", -+ "minecraft:copper_bars", -+ "minecraft:copper_bulb", -+ "minecraft:copper_chain", -+ "minecraft:copper_chest", -+ "minecraft:copper_door", -+ "minecraft:copper_golem_statue", -+ "minecraft:copper_grate", -+ "minecraft:copper_ingot", -+ "minecraft:copper_lantern", -+ "minecraft:copper_nugget", -+ "minecraft:copper_ore", -+ "minecraft:copper_torch", -+ "minecraft:copper_trapdoor", -+ "minecraft:coral", -+ "minecraft:coral_block", -+ "minecraft:coral_fan", -+ "minecraft:coral_fan_dead", -+ "minecraft:cornflower", -+ "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_button", -+ "minecraft:crimson_door", -+ "minecraft:crimson_fence", -+ "minecraft:crimson_fence_gate", -+ "minecraft:crimson_fungus", -+ "minecraft:crimson_hyphae", -+ "minecraft:crimson_nylium", -+ "minecraft:crimson_planks", -+ "minecraft:crimson_pressure_plate", -+ "minecraft:crimson_shelf", -+ "minecraft:crimson_stairs", -+ "minecraft:crimson_standing_sign", -+ "minecraft:crimson_stem", -+ "minecraft:crimson_trapdoor", -+ "minecraft:crimson_wall_sign", -+ "minecraft:crying_obsidian", -+ "minecraft:cut_red_sandstone", -+ "minecraft:cut_sandstone", -+ "minecraft:cyan_candle", -+ "minecraft:cyan_candle_cake", -+ "minecraft:cyan_carpet", -+ "minecraft:cyan_concrete", -+ "minecraft:cyan_concrete_powder", -+ "minecraft:cyan_glazed_terracotta", -+ "minecraft:cyan_stained_glass", -+ "minecraft:cyan_stained_glass_pane", -+ "minecraft:cyan_terracotta", -+ "minecraft:cyan_wool", -+ "minecraft:damaged_anvil", -+ "minecraft:danger_pottery_sherd", -+ "minecraft:dark_oak_button", -+ "minecraft:dark_oak_door", -+ "minecraft:dark_oak_fence", -+ "minecraft:dark_oak_fence_gate", -+ "minecraft:dark_oak_leaves", -+ "minecraft:dark_oak_log", -+ "minecraft:dark_oak_planks", -+ "minecraft:dark_oak_pressure_plate", -+ "minecraft:dark_oak_shelf", -+ "minecraft:dark_oak_stairs", -+ "minecraft:dark_oak_trapdoor", -+ "minecraft:dark_oak_wood", -+ "minecraft:dark_prismarine", -+ "minecraft:dark_prismarine_stairs", -+ "minecraft:darkoak_standing_sign", -+ "minecraft:darkoak_wall_sign", -+ "minecraft:daylight_detector", -+ "minecraft:daylight_detector_inverted", -+ "minecraft:dead_brain_coral", -+ "minecraft:dead_brain_coral_block", -+ "minecraft:dead_brain_coral_wall_fan", -+ "minecraft:dead_bubble_coral", -+ "minecraft:dead_bubble_coral_block", -+ "minecraft:dead_bubble_coral_wall_fan", -+ "minecraft:dead_fire_coral", -+ "minecraft:dead_fire_coral_block", -+ "minecraft:dead_fire_coral_wall_fan", -+ "minecraft:dead_horn_coral", -+ "minecraft:dead_horn_coral_block", -+ "minecraft:dead_horn_coral_wall_fan", -+ "minecraft:dead_tube_coral", -+ "minecraft:dead_tube_coral_block", -+ "minecraft:dead_tube_coral_wall_fan", -+ "minecraft:deadbush", -+ "minecraft:decorated_pot", -+ "minecraft:deepslate", -+ "minecraft:deepslate_brick_stairs", -+ "minecraft:deepslate_brick_wall", -+ "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_tile_wall", -+ "minecraft:deepslate_tiles", -+ "minecraft:deny", -+ "minecraft:deprecated_anvil", -+ "minecraft:deprecated_purpur_block_1", -+ "minecraft:deprecated_purpur_block_2", -+ "minecraft:detector_rail", -+ "minecraft:diamond", -+ "minecraft:diamond_block", -+ "minecraft:diamond_ore", -+ "minecraft:diorite", -+ "minecraft:diorite_stairs", -+ "minecraft:diorite_wall", -+ "minecraft:dirt", -+ "minecraft:dirt_with_roots", -+ "minecraft:disc_fragment_5", -+ "minecraft:dispenser", -+ "minecraft:double_plant", -+ "minecraft:double_stone_block_slab", -+ "minecraft:double_stone_block_slab2", -+ "minecraft:double_stone_block_slab3", -+ "minecraft:double_stone_block_slab4", -+ "minecraft:dragon_breath", -+ "minecraft:dragon_egg", -+ "minecraft:dried_ghast", -+ "minecraft:dried_kelp", -+ "minecraft:dried_kelp_block", -+ "minecraft:dripstone_block", -+ "minecraft:dropper", -+ "minecraft:dune_armor_trim_smithing_template", -+ "minecraft:dye", -+ "minecraft:echo_shard", -+ "minecraft:element_constructor", -+ "minecraft:emerald", -+ "minecraft:emerald_block", -+ "minecraft:emerald_ore", -+ "minecraft:enchanted_golden_apple", -+ "minecraft:enchanting_table", -+ "minecraft:end_brick_stairs", -+ "minecraft:end_bricks", -+ "minecraft:end_gateway", -+ "minecraft:end_portal", -+ "minecraft:end_rod", -+ "minecraft:end_stone", -+ "minecraft:end_stone_brick_wall", -+ "minecraft:ender_chest", -+ "minecraft:ender_eye", -+ "minecraft:experience_bottle", -+ "minecraft:explorer_pottery_sherd", -+ "minecraft:exposed_chiseled_copper", -+ "minecraft:exposed_copper", -+ "minecraft:exposed_copper_bars", -+ "minecraft:exposed_copper_bulb", -+ "minecraft:exposed_copper_chain", -+ "minecraft:exposed_copper_chest", -+ "minecraft:exposed_copper_door", -+ "minecraft:exposed_copper_golem_statue", -+ "minecraft:exposed_copper_grate", -+ "minecraft:exposed_copper_lantern", -+ "minecraft:exposed_copper_trapdoor", -+ "minecraft:exposed_cut_copper", -+ "minecraft:exposed_cut_copper_stairs", -+ "minecraft:exposed_lightning_rod", -+ "minecraft:eye_armor_trim_smithing_template", -+ "minecraft:feather", -+ "minecraft:fence", -+ "minecraft:fence_gate", -+ "minecraft:fermented_spider_eye", -+ "minecraft:fern", -+ "minecraft:fire", -+ "minecraft:fire_charge", -+ "minecraft:fire_coral", -+ "minecraft:fire_coral_block", -+ "minecraft:fire_coral_wall_fan", -+ "minecraft:firefly_bush", -+ "minecraft:firework_rocket", -+ "minecraft:firework_star", -+ "minecraft:fletching_table", -+ "minecraft:flint", -+ "minecraft:flow_armor_trim_smithing_template", -+ "minecraft:flow_pottery_sherd", -+ "minecraft:flowering_azalea", -+ "minecraft:flowing_lava", -+ "minecraft:flowing_water", -+ "minecraft:friend_pottery_sherd", -+ "minecraft:frog_spawn", -+ "minecraft:frosted_ice", -+ "minecraft:furnace", -+ "minecraft:ghast_tear", -+ "minecraft:gilded_blackstone", -+ "minecraft:glass", -+ "minecraft:glass_bottle", -+ "minecraft:glass_pane", -+ "minecraft:glistering_melon_slice", -+ "minecraft:glow_ink_sac", -+ "minecraft:glow_lichen", -+ "minecraft:glowingobsidian", -+ "minecraft:glowstone", -+ "minecraft:glowstone_dust", -+ "minecraft:gold_block", -+ "minecraft:gold_ingot", -+ "minecraft:gold_nugget", -+ "minecraft:gold_ore", -+ "minecraft:golden_apple", -+ "minecraft:golden_carrot", -+ "minecraft:golden_dandelion", -+ "minecraft:golden_rail", -+ "minecraft:granite", -+ "minecraft:granite_stairs", -+ "minecraft:granite_wall", -+ "minecraft:grass_block", -+ "minecraft:grass_path", -+ "minecraft:gravel", -+ "minecraft:gray_candle", -+ "minecraft:gray_candle_cake", -+ "minecraft:gray_carpet", -+ "minecraft:gray_concrete", -+ "minecraft:gray_concrete_powder", -+ "minecraft:gray_glazed_terracotta", -+ "minecraft:gray_stained_glass", -+ "minecraft:gray_stained_glass_pane", -+ "minecraft:gray_terracotta", -+ "minecraft:gray_wool", -+ "minecraft:green_candle", -+ "minecraft:green_candle_cake", -+ "minecraft:green_carpet", -+ "minecraft:green_concrete", -+ "minecraft:green_concrete_powder", -+ "minecraft:green_glazed_terracotta", -+ "minecraft:green_stained_glass", -+ "minecraft:green_stained_glass_pane", -+ "minecraft:green_terracotta", -+ "minecraft:green_wool", -+ "minecraft:grindstone", -+ "minecraft:gunpowder", -+ "minecraft:guster_pottery_sherd", -+ "minecraft:hanging_roots", -+ "minecraft:hard_black_stained_glass", -+ "minecraft:hard_black_stained_glass_pane", -+ "minecraft:hard_blue_stained_glass", -+ "minecraft:hard_blue_stained_glass_pane", -+ "minecraft:hard_brown_stained_glass", -+ "minecraft:hard_brown_stained_glass_pane", -+ "minecraft:hard_cyan_stained_glass", -+ "minecraft:hard_cyan_stained_glass_pane", -+ "minecraft:hard_glass", -+ "minecraft:hard_glass_pane", -+ "minecraft:hard_gray_stained_glass", -+ "minecraft:hard_gray_stained_glass_pane", -+ "minecraft:hard_green_stained_glass", -+ "minecraft:hard_green_stained_glass_pane", -+ "minecraft:hard_light_blue_stained_glass", -+ "minecraft:hard_light_blue_stained_glass_pane", -+ "minecraft:hard_light_gray_stained_glass", -+ "minecraft:hard_light_gray_stained_glass_pane", -+ "minecraft:hard_lime_stained_glass", -+ "minecraft:hard_lime_stained_glass_pane", -+ "minecraft:hard_magenta_stained_glass", -+ "minecraft:hard_magenta_stained_glass_pane", -+ "minecraft:hard_orange_stained_glass", -+ "minecraft:hard_orange_stained_glass_pane", -+ "minecraft:hard_pink_stained_glass", -+ "minecraft:hard_pink_stained_glass_pane", -+ "minecraft:hard_purple_stained_glass", -+ "minecraft:hard_purple_stained_glass_pane", -+ "minecraft:hard_red_stained_glass", -+ "minecraft:hard_red_stained_glass_pane", -+ "minecraft:hard_stained_glass", -+ "minecraft:hard_stained_glass_pane", -+ "minecraft:hard_white_stained_glass", -+ "minecraft:hard_white_stained_glass_pane", -+ "minecraft:hard_yellow_stained_glass", -+ "minecraft:hard_yellow_stained_glass_pane", -+ "minecraft:hardened_clay", -+ "minecraft:hay_block", -+ "minecraft:heart_of_the_sea", -+ "minecraft:heart_pottery_sherd", -+ "minecraft:heartbreak_pottery_sherd", -+ "minecraft:heavy_core", -+ "minecraft:heavy_weighted_pressure_plate", -+ "minecraft:honey_block", -+ "minecraft:honey_bottle", -+ "minecraft:honeycomb", -+ "minecraft:honeycomb_block", -+ "minecraft:horn_coral", -+ "minecraft:horn_coral_block", -+ "minecraft:horn_coral_wall_fan", -+ "minecraft:host_armor_trim_smithing_template", -+ "minecraft:howl_pottery_sherd", -+ "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_bars", -+ "minecraft:iron_block", -+ "minecraft:iron_chain", -+ "minecraft:iron_door", -+ "minecraft:iron_ingot", -+ "minecraft:iron_nugget", -+ "minecraft:iron_ore", -+ "minecraft:iron_trapdoor", -+ "minecraft:item.bed", -+ "minecraft:item.beetroot", -+ "minecraft:item.brewing_stand", -+ "minecraft:item.cake", -+ "minecraft:item.camera", -+ "minecraft:item.campfire", -+ "minecraft:item.cauldron", -+ "minecraft:item.flower_pot", -+ "minecraft:item.frame", -+ "minecraft:item.glow_frame", -+ "minecraft:item.hopper", -+ "minecraft:item.kelp", -+ "minecraft:item.nether_sprouts", -+ "minecraft:item.nether_wart", -+ "minecraft:item.reeds", -+ "minecraft:item.soul_campfire", -+ "minecraft:item.straw_bed", -+ "minecraft:item.wheat", -+ "minecraft:jigsaw", -+ "minecraft:jukebox", -+ "minecraft:jungle_button", -+ "minecraft:jungle_door", -+ "minecraft:jungle_fence", -+ "minecraft:jungle_fence_gate", -+ "minecraft:jungle_leaves", -+ "minecraft:jungle_log", -+ "minecraft:jungle_planks", -+ "minecraft:jungle_pressure_plate", -+ "minecraft:jungle_shelf", -+ "minecraft:jungle_stairs", -+ "minecraft:jungle_standing_sign", -+ "minecraft:jungle_trapdoor", -+ "minecraft:jungle_wall_sign", -+ "minecraft:jungle_wood", -+ "minecraft:lab_table", -+ "minecraft:ladder", -+ "minecraft:lantern", -+ "minecraft:lapis_block", -+ "minecraft:lapis_ore", -+ "minecraft:large_amethyst_bud", -+ "minecraft:large_fern", -+ "minecraft:lava", -+ "minecraft:lead", -+ "minecraft:leaf_litter", -+ "minecraft:leather", -+ "minecraft:leaves", -+ "minecraft:leaves2", -+ "minecraft:lectern", -+ "minecraft:lever", -+ "minecraft:light_block", -+ "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_blue_candle", -+ "minecraft:light_blue_candle_cake", -+ "minecraft:light_blue_carpet", -+ "minecraft:light_blue_concrete", -+ "minecraft:light_blue_concrete_powder", -+ "minecraft:light_blue_glazed_terracotta", -+ "minecraft:light_blue_stained_glass", -+ "minecraft:light_blue_stained_glass_pane", -+ "minecraft:light_blue_terracotta", -+ "minecraft:light_blue_wool", -+ "minecraft:light_gray_candle", -+ "minecraft:light_gray_candle_cake", -+ "minecraft:light_gray_carpet", -+ "minecraft:light_gray_concrete", -+ "minecraft:light_gray_concrete_powder", -+ "minecraft:light_gray_stained_glass", -+ "minecraft:light_gray_stained_glass_pane", -+ "minecraft:light_gray_terracotta", -+ "minecraft:light_gray_wool", -+ "minecraft:light_weighted_pressure_plate", -+ "minecraft:lightning_rod", -+ "minecraft:lilac", -+ "minecraft:lily_of_the_valley", -+ "minecraft:lime_candle", -+ "minecraft:lime_candle_cake", -+ "minecraft:lime_carpet", -+ "minecraft:lime_concrete", -+ "minecraft:lime_concrete_powder", -+ "minecraft:lime_glazed_terracotta", -+ "minecraft:lime_stained_glass", -+ "minecraft:lime_stained_glass_pane", -+ "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:lodestone_compass", -+ "minecraft:log", -+ "minecraft:log2", -+ "minecraft:loom", -+ "minecraft:magenta_candle", -+ "minecraft:magenta_candle_cake", -+ "minecraft:magenta_carpet", -+ "minecraft:magenta_concrete", -+ "minecraft:magenta_concrete_powder", -+ "minecraft:magenta_glazed_terracotta", -+ "minecraft:magenta_stained_glass", -+ "minecraft:magenta_stained_glass_pane", -+ "minecraft:magenta_terracotta", -+ "minecraft:magenta_wool", -+ "minecraft:magma", -+ "minecraft:magma_cream", -+ "minecraft:mangrove_button", -+ "minecraft:mangrove_door", -+ "minecraft:mangrove_fence", -+ "minecraft:mangrove_fence_gate", -+ "minecraft:mangrove_log", -+ "minecraft:mangrove_planks", -+ "minecraft:mangrove_pressure_plate", -+ "minecraft:mangrove_propagule", -+ "minecraft:mangrove_roots", -+ "minecraft:mangrove_shelf", -+ "minecraft:mangrove_stairs", -+ "minecraft:mangrove_standing_sign", -+ "minecraft:mangrove_trapdoor", -+ "minecraft:mangrove_wall_sign", -+ "minecraft:mangrove_wood", -+ "minecraft:material_reducer", -+ "minecraft:medium_amethyst_bud", -+ "minecraft:melon_block", -+ "minecraft:melon_seeds", -+ "minecraft:melon_slice", -+ "minecraft:melon_stem", -+ "minecraft:miner_pottery_sherd", -+ "minecraft:mob_spawner", -+ "minecraft:monster_egg", -+ "minecraft:moss_block", -+ "minecraft:moss_carpet", -+ "minecraft:mossy_cobblestone", -+ "minecraft:mossy_cobblestone_stairs", -+ "minecraft:mossy_cobblestone_wall", -+ "minecraft:mossy_stone_brick_stairs", -+ "minecraft:mossy_stone_brick_wall", -+ "minecraft:mossy_stone_bricks", -+ "minecraft:mourner_pottery_sherd", -+ "minecraft:moving_block", -+ "minecraft:mud", -+ "minecraft:mud_brick_stairs", -+ "minecraft:mud_brick_wall", -+ "minecraft:mud_bricks", -+ "minecraft:muddy_mangrove_roots", -+ "minecraft:mushroom_stem", -+ "minecraft:mushroom_stew", -+ "minecraft:mutton", -+ "minecraft:mycelium", -+ "minecraft:name_tag", -+ "minecraft:nautilus_shell", -+ "minecraft:nether_brick", -+ "minecraft:nether_brick_fence", -+ "minecraft:nether_brick_stairs", -+ "minecraft:nether_brick_wall", -+ "minecraft:nether_gold_ore", -+ "minecraft:nether_star", -+ "minecraft:nether_wart", -+ "minecraft:nether_wart_block", -+ "minecraft:netherbrick", -+ "minecraft:netherite_block", -+ "minecraft:netherite_ingot", -+ "minecraft:netherite_scrap", -+ "minecraft:netherite_upgrade_smithing_template", -+ "minecraft:netherrack", -+ "minecraft:netherreactor", -+ "minecraft:normal_stone_stairs", -+ "minecraft:noteblock", -+ "minecraft:oak_fence", -+ "minecraft:oak_leaves", -+ "minecraft:oak_log", -+ "minecraft:oak_planks", -+ "minecraft:oak_shelf", -+ "minecraft:oak_stairs", -+ "minecraft:oak_wood", -+ "minecraft:observer", -+ "minecraft:obsidian", -+ "minecraft:ochre_froglight", -+ "minecraft:open_eyeblossom", -+ "minecraft:orange_candle", -+ "minecraft:orange_candle_cake", -+ "minecraft:orange_carpet", -+ "minecraft:orange_concrete", -+ "minecraft:orange_concrete_powder", -+ "minecraft:orange_glazed_terracotta", -+ "minecraft:orange_poplar_leaves", -+ "minecraft:orange_stained_glass", -+ "minecraft:orange_stained_glass_pane", -+ "minecraft:orange_terracotta", -+ "minecraft:orange_tulip", -+ "minecraft:orange_wool", -+ "minecraft:oxeye_daisy", -+ "minecraft:oxidized_chiseled_copper", -+ "minecraft:oxidized_copper", -+ "minecraft:oxidized_copper_bars", -+ "minecraft:oxidized_copper_bulb", -+ "minecraft:oxidized_copper_chain", -+ "minecraft:oxidized_copper_chest", -+ "minecraft:oxidized_copper_door", -+ "minecraft:oxidized_copper_golem_statue", -+ "minecraft:oxidized_copper_grate", -+ "minecraft:oxidized_copper_lantern", -+ "minecraft:oxidized_copper_trapdoor", -+ "minecraft:oxidized_cut_copper", -+ "minecraft:oxidized_cut_copper_stairs", -+ "minecraft:oxidized_lightning_rod", -+ "minecraft:packed_ice", -+ "minecraft:packed_mud", -+ "minecraft:painting", -+ "minecraft:pale_hanging_moss", -+ "minecraft:pale_moss_block", -+ "minecraft:pale_moss_carpet", -+ "minecraft:pale_oak_button", -+ "minecraft:pale_oak_door", -+ "minecraft:pale_oak_fence", -+ "minecraft:pale_oak_fence_gate", -+ "minecraft:pale_oak_leaves", -+ "minecraft:pale_oak_log", -+ "minecraft:pale_oak_planks", -+ "minecraft:pale_oak_pressure_plate", -+ "minecraft:pale_oak_sapling", -+ "minecraft:pale_oak_shelf", -+ "minecraft:pale_oak_stairs", -+ "minecraft:pale_oak_standing_sign", -+ "minecraft:pale_oak_trapdoor", -+ "minecraft:pale_oak_wall_sign", -+ "minecraft:pale_oak_wood", -+ "minecraft:paper", -+ "minecraft:pearlescent_froglight", -+ "minecraft:peony", -+ "minecraft:phantom_membrane", -+ "minecraft:pink_candle", -+ "minecraft:pink_candle_cake", -+ "minecraft:pink_carpet", -+ "minecraft:pink_concrete", -+ "minecraft:pink_concrete_powder", -+ "minecraft:pink_glazed_terracotta", -+ "minecraft:pink_petals", -+ "minecraft:pink_stained_glass", -+ "minecraft:pink_stained_glass_pane", -+ "minecraft:pink_terracotta", -+ "minecraft:pink_tulip", -+ "minecraft:pink_wool", -+ "minecraft:piston", -+ "minecraft:piston_arm_collision", -+ "minecraft:pitcher_crop", -+ "minecraft:pitcher_plant", -+ "minecraft:pitcher_pod", -+ "minecraft:planks", -+ "minecraft:plenty_pottery_sherd", -+ "minecraft:podzol", -+ "minecraft:pointed_dripstone", -+ "minecraft:poisonous_potato", -+ "minecraft:polished_andesite", -+ "minecraft:polished_andesite_stairs", -+ "minecraft:polished_basalt", -+ "minecraft:polished_blackstone", -+ "minecraft:polished_blackstone_brick_stairs", -+ "minecraft:polished_blackstone_brick_wall", -+ "minecraft:polished_blackstone_bricks", -+ "minecraft:polished_blackstone_button", -+ "minecraft:polished_blackstone_pressure_plate", -+ "minecraft:polished_blackstone_stairs", -+ "minecraft:polished_blackstone_wall", -+ "minecraft:polished_cinnabar", -+ "minecraft:polished_cinnabar_stairs", -+ "minecraft:polished_cinnabar_wall", -+ "minecraft:polished_deepslate", -+ "minecraft:polished_deepslate_stairs", -+ "minecraft:polished_deepslate_wall", -+ "minecraft:polished_diorite", -+ "minecraft:polished_diorite_stairs", -+ "minecraft:polished_granite", -+ "minecraft:polished_granite_stairs", -+ "minecraft:polished_sulfur", -+ "minecraft:polished_sulfur_stairs", -+ "minecraft:polished_sulfur_wall", -+ "minecraft:polished_tuff", -+ "minecraft:polished_tuff_stairs", -+ "minecraft:polished_tuff_wall", -+ "minecraft:poplar_button", -+ "minecraft:poplar_door", -+ "minecraft:poplar_fence", -+ "minecraft:poplar_fence_gate", -+ "minecraft:poplar_log", -+ "minecraft:poplar_planks", -+ "minecraft:poplar_pressure_plate", -+ "minecraft:poplar_sapling", -+ "minecraft:poplar_shelf", -+ "minecraft:poplar_stairs", -+ "minecraft:poplar_standing_sign", -+ "minecraft:poplar_trapdoor", -+ "minecraft:poplar_wall_sign", -+ "minecraft:poplar_wood", -+ "minecraft:popped_chorus_fruit", -+ "minecraft:poppy", -+ "minecraft:porkchop", -+ "minecraft:portal", -+ "minecraft:potato", -+ "minecraft:potatoes", -+ "minecraft:potent_sulfur", -+ "minecraft:powder_snow", -+ "minecraft:powered_comparator", -+ "minecraft:powered_repeater", -+ "minecraft:prismarine", -+ "minecraft:prismarine_bricks", -+ "minecraft:prismarine_bricks_stairs", -+ "minecraft:prismarine_crystals", -+ "minecraft:prismarine_shard", -+ "minecraft:prismarine_stairs", -+ "minecraft:prismarine_wall", -+ "minecraft:prize_pottery_sherd", -+ "minecraft:pufferfish", -+ "minecraft:pumpkin", -+ "minecraft:pumpkin_pie", -+ "minecraft:pumpkin_seeds", -+ "minecraft:pumpkin_stem", -+ "minecraft:purple_candle", -+ "minecraft:purple_candle_cake", -+ "minecraft:purple_carpet", -+ "minecraft:purple_concrete", -+ "minecraft:purple_concrete_powder", -+ "minecraft:purple_glazed_terracotta", -+ "minecraft:purple_stained_glass", -+ "minecraft:purple_stained_glass_pane", -+ "minecraft:purple_terracotta", -+ "minecraft:purple_wool", -+ "minecraft:purpur_block", -+ "minecraft:purpur_pillar", -+ "minecraft:purpur_stairs", -+ "minecraft:quartz", -+ "minecraft:quartz_block", -+ "minecraft:quartz_bricks", -+ "minecraft:quartz_ore", -+ "minecraft:quartz_pillar", -+ "minecraft:quartz_stairs", -+ "minecraft:rabbit", -+ "minecraft:rabbit_foot", -+ "minecraft:rabbit_hide", -+ "minecraft:rabbit_stew", -+ "minecraft:rail", -+ "minecraft:raiser_armor_trim_smithing_template", -+ "minecraft:rapid_fertilizer", -+ "minecraft:raw_copper", -+ "minecraft:raw_copper_block", -+ "minecraft:raw_gold", -+ "minecraft:raw_gold_block", -+ "minecraft:raw_iron", -+ "minecraft:raw_iron_block", -+ "minecraft:recovery_compass", -+ "minecraft:red_candle", -+ "minecraft:red_candle_cake", -+ "minecraft:red_carpet", -+ "minecraft:red_concrete", -+ "minecraft:red_concrete_powder", -+ "minecraft:red_flower", -+ "minecraft:red_glazed_terracotta", -+ "minecraft:red_mushroom", -+ "minecraft:red_nether_brick", -+ "minecraft:red_nether_brick_stairs", -+ "minecraft:red_nether_brick_wall", -+ "minecraft:red_poplar_leaves", -+ "minecraft:red_sand", -+ "minecraft:red_sandstone", -+ "minecraft:red_sandstone_stairs", -+ "minecraft:red_sandstone_wall", -+ "minecraft:red_stained_glass", -+ "minecraft:red_stained_glass_pane", -+ "minecraft:red_terracotta", -+ "minecraft:red_tulip", -+ "minecraft:red_wool", -+ "minecraft:redstone", -+ "minecraft:redstone_block", -+ "minecraft:redstone_lamp", -+ "minecraft:redstone_ore", -+ "minecraft:redstone_torch", -+ "minecraft:redstone_wire", -+ "minecraft:reinforced_deepslate", -+ "minecraft:repeating_command_block", -+ "minecraft:reserved6", -+ "minecraft:resin_block", -+ "minecraft:resin_brick", -+ "minecraft:resin_brick_stairs", -+ "minecraft:resin_brick_wall", -+ "minecraft:resin_bricks", -+ "minecraft:resin_clump", -+ "minecraft:rib_armor_trim_smithing_template", -+ "minecraft:rose_bush", -+ "minecraft:rotten_flesh", -+ "minecraft:salmon", -+ "minecraft:sand", -+ "minecraft:sandstone", -+ "minecraft:sandstone_stairs", -+ "minecraft:sandstone_wall", -+ "minecraft:sapling", -+ "minecraft:scaffolding", -+ "minecraft:scrape_pottery_sherd", -+ "minecraft:sculk", -+ "minecraft:sculk_catalyst", -+ "minecraft:sculk_sensor", -+ "minecraft:sculk_shrieker", -+ "minecraft:sculk_vein", -+ "minecraft:sea_lantern", -+ "minecraft:sea_pickle", -+ "minecraft:sentry_armor_trim_smithing_template", -+ "minecraft:shaper_armor_trim_smithing_template", -+ "minecraft:sheaf_pottery_sherd", -+ "minecraft:shelter_pottery_sherd", -+ "minecraft:short_dry_grass", -+ "minecraft:short_grass", -+ "minecraft:shroomlight", -+ "minecraft:shulker_box", -+ "minecraft:shulker_shell", -+ "minecraft:silence_armor_trim_smithing_template", -+ "minecraft:silver_glazed_terracotta", -+ "minecraft:skull", -+ "minecraft:skull_pottery_sherd", -+ "minecraft:slime", -+ "minecraft:slime_ball", -+ "minecraft:small_amethyst_bud", -+ "minecraft:small_dripleaf_block", -+ "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:sniffer_egg", -+ "minecraft:snort_pottery_sherd", -+ "minecraft:snout_armor_trim_smithing_template", -+ "minecraft:snow", -+ "minecraft:soul_fire", -+ "minecraft:soul_lantern", -+ "minecraft:soul_sand", -+ "minecraft:soul_soil", -+ "minecraft:soul_torch", -+ "minecraft:spawn_egg", -+ "minecraft:spider_eye", -+ "minecraft:spire_armor_trim_smithing_template", -+ "minecraft:sponge", -+ "minecraft:spore_blossom", -+ "minecraft:spruce_button", -+ "minecraft:spruce_door", -+ "minecraft:spruce_fence", -+ "minecraft:spruce_fence_gate", -+ "minecraft:spruce_leaves", -+ "minecraft:spruce_log", -+ "minecraft:spruce_planks", -+ "minecraft:spruce_pressure_plate", -+ "minecraft:spruce_shelf", -+ "minecraft:spruce_stairs", -+ "minecraft:spruce_standing_sign", -+ "minecraft:spruce_trapdoor", -+ "minecraft:spruce_wall_sign", -+ "minecraft:spruce_wood", -+ "minecraft:stained_glass", -+ "minecraft:stained_glass_pane", -+ "minecraft:stained_hardened_clay", -+ "minecraft:standing_banner", -+ "minecraft:standing_sign", -+ "minecraft:stick", -+ "minecraft:sticky_piston", -+ "minecraft:sticky_piston_arm_collision", -+ "minecraft:stone", -+ "minecraft:stone_block_slab", -+ "minecraft:stone_block_slab2", -+ "minecraft:stone_block_slab3", -+ "minecraft:stone_block_slab4", -+ "minecraft:stone_brick_stairs", -+ "minecraft:stone_brick_wall", -+ "minecraft:stone_bricks", -+ "minecraft:stone_button", -+ "minecraft:stone_pressure_plate", -+ "minecraft:stone_stairs", -+ "minecraft:stonebrick", -+ "minecraft:stonecutter", -+ "minecraft:stonecutter_block", -+ "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:structure_void", -+ "minecraft:sugar", -+ "minecraft:sulfur", -+ "minecraft:sulfur_brick_stairs", -+ "minecraft:sulfur_brick_wall", -+ "minecraft:sulfur_bricks", -+ "minecraft:sulfur_spike", -+ "minecraft:sulfur_stairs", -+ "minecraft:sulfur_wall", -+ "minecraft:sunflower", -+ "minecraft:suspicious_gravel", -+ "minecraft:suspicious_sand", -+ "minecraft:sweet_berries", -+ "minecraft:sweet_berry_bush", -+ "minecraft:tall_dry_grass", -+ "minecraft:tall_grass", -+ "minecraft:tallgrass", -+ "minecraft:target", -+ "minecraft:tide_armor_trim_smithing_template", -+ "minecraft:tinted_glass", -+ "minecraft:tnt", -+ "minecraft:torch", -+ "minecraft:torchflower", -+ "minecraft:torchflower_crop", -+ "minecraft:torchflower_seeds", -+ "minecraft:trapdoor", -+ "minecraft:trapped_chest", -+ "minecraft:trial_spawner", -+ "minecraft:trip_wire", -+ "minecraft:tripwire_hook", -+ "minecraft:tropical_fish", -+ "minecraft:tube_coral", -+ "minecraft:tube_coral_block", -+ "minecraft:tube_coral_wall_fan", -+ "minecraft:tuff", -+ "minecraft:tuff_brick_stairs", -+ "minecraft:tuff_brick_wall", -+ "minecraft:tuff_bricks", -+ "minecraft:tuff_stairs", -+ "minecraft:tuff_wall", -+ "minecraft:turtle_egg", -+ "minecraft:turtle_scute", -+ "minecraft:twisting_vines", -+ "minecraft:underwater_tnt", -+ "minecraft:underwater_torch", -+ "minecraft:unknown", -+ "minecraft:unlit_redstone_torch", -+ "minecraft:unpowered_comparator", -+ "minecraft:unpowered_repeater", -+ "minecraft:vault", -+ "minecraft:verdant_froglight", -+ "minecraft:vex_armor_trim_smithing_template", -+ "minecraft:vine", -+ "minecraft:wall_banner", -+ "minecraft:wall_sign", -+ "minecraft:ward_armor_trim_smithing_template", -+ "minecraft:warped_button", -+ "minecraft:warped_door", -+ "minecraft:warped_fence", -+ "minecraft:warped_fence_gate", -+ "minecraft:warped_fungus", -+ "minecraft:warped_hyphae", -+ "minecraft:warped_nylium", -+ "minecraft:warped_planks", -+ "minecraft:warped_pressure_plate", -+ "minecraft:warped_shelf", -+ "minecraft:warped_stairs", -+ "minecraft:warped_standing_sign", -+ "minecraft:warped_stem", -+ "minecraft:warped_trapdoor", -+ "minecraft:warped_wall_sign", -+ "minecraft:warped_wart_block", -+ "minecraft:water", -+ "minecraft:waterlily", -+ "minecraft:waxed_chiseled_copper", -+ "minecraft:waxed_copper", -+ "minecraft:waxed_copper_bars", -+ "minecraft:waxed_copper_bulb", -+ "minecraft:waxed_copper_chain", -+ "minecraft:waxed_copper_chest", -+ "minecraft:waxed_copper_door", -+ "minecraft:waxed_copper_golem_statue", -+ "minecraft:waxed_copper_grate", -+ "minecraft:waxed_copper_lantern", -+ "minecraft:waxed_copper_trapdoor", -+ "minecraft:waxed_cut_copper", -+ "minecraft:waxed_cut_copper_stairs", -+ "minecraft:waxed_exposed_chiseled_copper", -+ "minecraft:waxed_exposed_copper", -+ "minecraft:waxed_exposed_copper_bars", -+ "minecraft:waxed_exposed_copper_bulb", -+ "minecraft:waxed_exposed_copper_chain", -+ "minecraft:waxed_exposed_copper_chest", -+ "minecraft:waxed_exposed_copper_door", -+ "minecraft:waxed_exposed_copper_golem_statue", -+ "minecraft:waxed_exposed_copper_grate", -+ "minecraft:waxed_exposed_copper_lantern", -+ "minecraft:waxed_exposed_copper_trapdoor", -+ "minecraft:waxed_exposed_cut_copper", -+ "minecraft:waxed_exposed_cut_copper_stairs", -+ "minecraft:waxed_exposed_lightning_rod", -+ "minecraft:waxed_lightning_rod", -+ "minecraft:waxed_oxidized_chiseled_copper", -+ "minecraft:waxed_oxidized_copper", -+ "minecraft:waxed_oxidized_copper_bars", -+ "minecraft:waxed_oxidized_copper_bulb", -+ "minecraft:waxed_oxidized_copper_chain", -+ "minecraft:waxed_oxidized_copper_chest", -+ "minecraft:waxed_oxidized_copper_door", -+ "minecraft:waxed_oxidized_copper_golem_statue", -+ "minecraft:waxed_oxidized_copper_grate", -+ "minecraft:waxed_oxidized_copper_lantern", -+ "minecraft:waxed_oxidized_copper_trapdoor", -+ "minecraft:waxed_oxidized_cut_copper", -+ "minecraft:waxed_oxidized_cut_copper_stairs", -+ "minecraft:waxed_oxidized_lightning_rod", -+ "minecraft:waxed_weathered_chiseled_copper", -+ "minecraft:waxed_weathered_copper", -+ "minecraft:waxed_weathered_copper_bars", -+ "minecraft:waxed_weathered_copper_bulb", -+ "minecraft:waxed_weathered_copper_chain", -+ "minecraft:waxed_weathered_copper_chest", -+ "minecraft:waxed_weathered_copper_door", -+ "minecraft:waxed_weathered_copper_golem_statue", -+ "minecraft:waxed_weathered_copper_grate", -+ "minecraft:waxed_weathered_copper_lantern", -+ "minecraft:waxed_weathered_copper_trapdoor", -+ "minecraft:waxed_weathered_cut_copper", -+ "minecraft:waxed_weathered_cut_copper_stairs", -+ "minecraft:waxed_weathered_lightning_rod", -+ "minecraft:wayfinder_armor_trim_smithing_template", -+ "minecraft:weathered_chiseled_copper", -+ "minecraft:weathered_copper", -+ "minecraft:weathered_copper_bars", -+ "minecraft:weathered_copper_bulb", -+ "minecraft:weathered_copper_chain", -+ "minecraft:weathered_copper_chest", -+ "minecraft:weathered_copper_door", -+ "minecraft:weathered_copper_golem_statue", -+ "minecraft:weathered_copper_grate", -+ "minecraft:weathered_copper_lantern", -+ "minecraft:weathered_copper_trapdoor", -+ "minecraft:weathered_cut_copper", -+ "minecraft:weathered_cut_copper_stairs", -+ "minecraft:weathered_lightning_rod", -+ "minecraft:web", -+ "minecraft:weeping_vines", -+ "minecraft:wet_sponge", -+ "minecraft:wheat", -+ "minecraft:wheat_seeds", -+ "minecraft:white_candle", -+ "minecraft:white_candle_cake", -+ "minecraft:white_carpet", -+ "minecraft:white_concrete", -+ "minecraft:white_concrete_powder", -+ "minecraft:white_glazed_terracotta", -+ "minecraft:white_stained_glass", -+ "minecraft:white_stained_glass_pane", -+ "minecraft:white_terracotta", -+ "minecraft:white_tulip", -+ "minecraft:white_wool", -+ "minecraft:wild_armor_trim_smithing_template", -+ "minecraft:wildflowers", -+ "minecraft:wither_rose", -+ "minecraft:wood", -+ "minecraft:wooden_button", -+ "minecraft:wooden_door", -+ "minecraft:wooden_pressure_plate", -+ "minecraft:wooden_slab", -+ "minecraft:wool", -+ "minecraft:yellow_candle", -+ "minecraft:yellow_candle_cake", -+ "minecraft:yellow_carpet", -+ "minecraft:yellow_concrete", -+ "minecraft:yellow_concrete_powder", -+ "minecraft:yellow_glazed_terracotta", -+ "minecraft:yellow_poplar_leaves", -+ "minecraft:yellow_stained_glass", -+ "minecraft:yellow_stained_glass_pane", -+ "minecraft:yellow_terracotta", -+ "minecraft:yellow_wool" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 64, -+ "stackedByData": true, -+ "maximumDamage": 0, -+ "damageable": false, -+ "identifiers": [ -+ "minecraft:acacia_double_slab", -+ "minecraft:acacia_sapling", -+ "minecraft:acacia_slab", -+ "minecraft:agent_spawn_egg", -+ "minecraft:allay_spawn_egg", -+ "minecraft:andesite_double_slab", -+ "minecraft:andesite_slab", -+ "minecraft:armadillo_spawn_egg", -+ "minecraft:axolotl_spawn_egg", -+ "minecraft:azalea_leaves", -+ "minecraft:azalea_leaves_flowered", -+ "minecraft:balloon", -+ "minecraft:bamboo_double_slab", -+ "minecraft:bamboo_mosaic_double_slab", -+ "minecraft:bamboo_mosaic_slab", -+ "minecraft:bamboo_slab", -+ "minecraft:bat_spawn_egg", -+ "minecraft:bee_spawn_egg", -+ "minecraft:birch_double_slab", -+ "minecraft:birch_sapling", -+ "minecraft:birch_slab", -+ "minecraft:black_dye", -+ "minecraft:blackstone_double_slab", -+ "minecraft:blackstone_slab", -+ "minecraft:blaze_spawn_egg", -+ "minecraft:blue_dye", -+ "minecraft:bogged_spawn_egg", -+ "minecraft:bone_meal", -+ "minecraft:brain_coral_fan", -+ "minecraft:breeze_spawn_egg", -+ "minecraft:brick_double_slab", -+ "minecraft:brick_slab", -+ "minecraft:brown_dye", -+ "minecraft:brown_mushroom_block", -+ "minecraft:bubble_coral_fan", -+ "minecraft:camel_husk_spawn_egg", -+ "minecraft:camel_spawn_egg", -+ "minecraft:cat_spawn_egg", -+ "minecraft:cave_spider_spawn_egg", -+ "minecraft:cherry_double_slab", -+ "minecraft:cherry_slab", -+ "minecraft:chicken_spawn_egg", -+ "minecraft:cinnabar_brick_double_slab", -+ "minecraft:cinnabar_brick_slab", -+ "minecraft:cinnabar_double_slab", -+ "minecraft:cinnabar_slab", -+ "minecraft:cobbled_deepslate_double_slab", -+ "minecraft:cobbled_deepslate_slab", -+ "minecraft:cobblestone_double_slab", -+ "minecraft:cobblestone_slab", -+ "minecraft:cocoa_beans", -+ "minecraft:cod_spawn_egg", -+ "minecraft:compound", -+ "minecraft:copper_block", -+ "minecraft:copper_golem_spawn_egg", -+ "minecraft:cow_spawn_egg", -+ "minecraft:creaking_spawn_egg", -+ "minecraft:creeper_head", -+ "minecraft:creeper_spawn_egg", -+ "minecraft:crimson_double_slab", -+ "minecraft:crimson_roots", -+ "minecraft:crimson_slab", -+ "minecraft:cut_copper", -+ "minecraft:cut_copper_slab", -+ "minecraft:cut_copper_stairs", -+ "minecraft:cut_red_sandstone_double_slab", -+ "minecraft:cut_red_sandstone_slab", -+ "minecraft:cut_sandstone_double_slab", -+ "minecraft:cut_sandstone_slab", -+ "minecraft:cyan_dye", -+ "minecraft:dandelion", -+ "minecraft:dark_oak_double_slab", -+ "minecraft:dark_oak_sapling", -+ "minecraft:dark_oak_slab", -+ "minecraft:dark_prismarine_double_slab", -+ "minecraft:dark_prismarine_slab", -+ "minecraft:dead_brain_coral_fan", -+ "minecraft:dead_bubble_coral_fan", -+ "minecraft:dead_fire_coral_fan", -+ "minecraft:dead_horn_coral_fan", -+ "minecraft:dead_tube_coral_fan", -+ "minecraft:deepslate_brick_double_slab", -+ "minecraft:deepslate_brick_slab", -+ "minecraft:deepslate_tile_double_slab", -+ "minecraft:deepslate_tile_slab", -+ "minecraft:diorite_double_slab", -+ "minecraft:diorite_slab", -+ "minecraft:dolphin_spawn_egg", -+ "minecraft:donkey_spawn_egg", -+ "minecraft:double_cut_copper_slab", -+ "minecraft:dragon_head", -+ "minecraft:drowned_spawn_egg", -+ "minecraft:elder_guardian_spawn_egg", -+ "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:empty_map", -+ "minecraft:end_portal_frame", -+ "minecraft:end_stone_brick_double_slab", -+ "minecraft:end_stone_brick_slab", -+ "minecraft:ender_dragon_spawn_egg", -+ "minecraft:enderman_spawn_egg", -+ "minecraft:endermite_spawn_egg", -+ "minecraft:evoker_spawn_egg", -+ "minecraft:exposed_cut_copper_slab", -+ "minecraft:exposed_double_cut_copper_slab", -+ "minecraft:farmland", -+ "minecraft:filled_map", -+ "minecraft:fire_coral_fan", -+ "minecraft:fox_spawn_egg", -+ "minecraft:frog_spawn_egg", -+ "minecraft:ghast_spawn_egg", -+ "minecraft:glow_squid_spawn_egg", -+ "minecraft:goat_spawn_egg", -+ "minecraft:granite_double_slab", -+ "minecraft:granite_slab", -+ "minecraft:gray_dye", -+ "minecraft:green_dye", -+ "minecraft:guardian_spawn_egg", -+ "minecraft:happy_ghast_spawn_egg", -+ "minecraft:hoglin_spawn_egg", -+ "minecraft:horn_coral_fan", -+ "minecraft:horse_spawn_egg", -+ "minecraft:husk_spawn_egg", -+ "minecraft:ink_sac", -+ "minecraft:iron_golem_spawn_egg", -+ "minecraft:jungle_double_slab", -+ "minecraft:jungle_sapling", -+ "minecraft:jungle_slab", -+ "minecraft:lapis_lazuli", -+ "minecraft:light_blue_dye", -+ "minecraft:light_gray_dye", -+ "minecraft:lime_dye", -+ "minecraft:llama_spawn_egg", -+ "minecraft:magenta_dye", -+ "minecraft:magma_cube_spawn_egg", -+ "minecraft:mangrove_double_slab", -+ "minecraft:mangrove_leaves", -+ "minecraft:mangrove_slab", -+ "minecraft:mooshroom_spawn_egg", -+ "minecraft:mossy_cobblestone_double_slab", -+ "minecraft:mossy_cobblestone_slab", -+ "minecraft:mossy_stone_brick_double_slab", -+ "minecraft:mossy_stone_brick_slab", -+ "minecraft:mud_brick_double_slab", -+ "minecraft:mud_brick_slab", -+ "minecraft:mule_spawn_egg", -+ "minecraft:nautilus_spawn_egg", -+ "minecraft:nether_brick_double_slab", -+ "minecraft:nether_brick_slab", -+ "minecraft:normal_stone_double_slab", -+ "minecraft:normal_stone_slab", -+ "minecraft:npc_spawn_egg", -+ "minecraft:oak_double_slab", -+ "minecraft:oak_sapling", -+ "minecraft:oak_slab", -+ "minecraft:ocelot_spawn_egg", -+ "minecraft:ominous_bottle", -+ "minecraft:orange_dye", -+ "minecraft:oxidized_cut_copper_slab", -+ "minecraft:oxidized_double_cut_copper_slab", -+ "minecraft:pale_oak_double_slab", -+ "minecraft:pale_oak_slab", -+ "minecraft:panda_spawn_egg", -+ "minecraft:parched_spawn_egg", -+ "minecraft:parrot_spawn_egg", -+ "minecraft:petrified_oak_double_slab", -+ "minecraft:petrified_oak_slab", -+ "minecraft:phantom_spawn_egg", -+ "minecraft:pig_spawn_egg", -+ "minecraft:piglin_brute_spawn_egg", -+ "minecraft:piglin_head", -+ "minecraft:piglin_spawn_egg", -+ "minecraft:pillager_spawn_egg", -+ "minecraft:pink_dye", -+ "minecraft:player_head", -+ "minecraft:polar_bear_spawn_egg", -+ "minecraft:polished_andesite_double_slab", -+ "minecraft:polished_andesite_slab", -+ "minecraft:polished_blackstone_brick_double_slab", -+ "minecraft:polished_blackstone_brick_slab", -+ "minecraft:polished_blackstone_double_slab", -+ "minecraft:polished_blackstone_slab", -+ "minecraft:polished_cinnabar_double_slab", -+ "minecraft:polished_cinnabar_slab", -+ "minecraft:polished_deepslate_double_slab", -+ "minecraft:polished_deepslate_slab", -+ "minecraft:polished_diorite_double_slab", -+ "minecraft:polished_diorite_slab", -+ "minecraft:polished_granite_double_slab", -+ "minecraft:polished_granite_slab", -+ "minecraft:polished_sulfur_double_slab", -+ "minecraft:polished_sulfur_slab", -+ "minecraft:polished_tuff_double_slab", -+ "minecraft:polished_tuff_slab", -+ "minecraft:poplar_double_slab", -+ "minecraft:poplar_slab", -+ "minecraft:prismarine_brick_double_slab", -+ "minecraft:prismarine_brick_slab", -+ "minecraft:prismarine_double_slab", -+ "minecraft:prismarine_slab", -+ "minecraft:pufferfish_spawn_egg", -+ "minecraft:purple_dye", -+ "minecraft:purpur_double_slab", -+ "minecraft:purpur_slab", -+ "minecraft:quartz_double_slab", -+ "minecraft:quartz_slab", -+ "minecraft:rabbit_spawn_egg", -+ "minecraft:ravager_spawn_egg", -+ "minecraft:red_dye", -+ "minecraft:red_mushroom_block", -+ "minecraft:red_nether_brick_double_slab", -+ "minecraft:red_nether_brick_slab", -+ "minecraft:red_sandstone_double_slab", -+ "minecraft:red_sandstone_slab", -+ "minecraft:resin_brick_double_slab", -+ "minecraft:resin_brick_slab", -+ "minecraft:respawn_anchor", -+ "minecraft:salmon_spawn_egg", -+ "minecraft:sandstone_double_slab", -+ "minecraft:sandstone_slab", -+ "minecraft:seagrass", -+ "minecraft:sheep_spawn_egg", -+ "minecraft:shulker_spawn_egg", -+ "minecraft:silverfish_spawn_egg", -+ "minecraft:skeleton_horse_spawn_egg", -+ "minecraft:skeleton_skull", -+ "minecraft:skeleton_spawn_egg", -+ "minecraft:slime_spawn_egg", -+ "minecraft:smooth_quartz_double_slab", -+ "minecraft:smooth_quartz_slab", -+ "minecraft:smooth_red_sandstone_double_slab", -+ "minecraft:smooth_red_sandstone_slab", -+ "minecraft:smooth_sandstone_double_slab", -+ "minecraft:smooth_sandstone_slab", -+ "minecraft:smooth_stone_double_slab", -+ "minecraft:smooth_stone_slab", -+ "minecraft:sniffer_spawn_egg", -+ "minecraft:snow_golem_spawn_egg", -+ "minecraft:snow_layer", -+ "minecraft:spider_spawn_egg", -+ "minecraft:spruce_double_slab", -+ "minecraft:spruce_sapling", -+ "minecraft:spruce_slab", -+ "minecraft:squid_spawn_egg", -+ "minecraft:stone_brick_double_slab", -+ "minecraft:stone_brick_slab", -+ "minecraft:stray_spawn_egg", -+ "minecraft:strider_spawn_egg", -+ "minecraft:sulfur_brick_double_slab", -+ "minecraft:sulfur_brick_slab", -+ "minecraft:sulfur_cube_spawn_egg", -+ "minecraft:sulfur_double_slab", -+ "minecraft:sulfur_slab", -+ "minecraft:tadpole_spawn_egg", -+ "minecraft:trader_llama_spawn_egg", -+ "minecraft:tropical_fish_spawn_egg", -+ "minecraft:tube_coral_fan", -+ "minecraft:tuff_brick_double_slab", -+ "minecraft:tuff_brick_slab", -+ "minecraft:tuff_double_slab", -+ "minecraft:tuff_slab", -+ "minecraft:turtle_spawn_egg", -+ "minecraft:vex_spawn_egg", -+ "minecraft:villager_spawn_egg", -+ "minecraft:vindicator_spawn_egg", -+ "minecraft:wandering_trader_spawn_egg", -+ "minecraft:warden_spawn_egg", -+ "minecraft:warped_double_slab", -+ "minecraft:warped_roots", -+ "minecraft:warped_slab", -+ "minecraft:waxed_cut_copper_slab", -+ "minecraft:waxed_double_cut_copper_slab", -+ "minecraft:waxed_exposed_cut_copper_slab", -+ "minecraft:waxed_exposed_double_cut_copper_slab", -+ "minecraft:waxed_oxidized_cut_copper_slab", -+ "minecraft:waxed_oxidized_double_cut_copper_slab", -+ "minecraft:waxed_weathered_cut_copper_slab", -+ "minecraft:waxed_weathered_double_cut_copper_slab", -+ "minecraft:weathered_cut_copper_slab", -+ "minecraft:weathered_double_cut_copper_slab", -+ "minecraft:white_dye", -+ "minecraft:witch_spawn_egg", -+ "minecraft:wither_skeleton_skull", -+ "minecraft:wither_skeleton_spawn_egg", -+ "minecraft:wither_spawn_egg", -+ "minecraft:wolf_spawn_egg", -+ "minecraft:yellow_dye", -+ "minecraft:zoglin_spawn_egg", -+ "minecraft:zombie_head", -+ "minecraft:zombie_horse_spawn_egg", -+ "minecraft:zombie_nautilus_spawn_egg", -+ "minecraft:zombie_pigman_spawn_egg", -+ "minecraft:zombie_spawn_egg", -+ "minecraft:zombie_villager_spawn_egg" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 1, -+ "stackedByData": true, -+ "maximumDamage": 0, -+ "damageable": false, -+ "fixedMaximum": 1, -+ "identifiers": [ -+ "minecraft:axolotl_bucket", -+ "minecraft:cod_bucket", -+ "minecraft:lava_bucket", -+ "minecraft:milk_bucket", -+ "minecraft:powder_snow_bucket", -+ "minecraft:pufferfish_bucket", -+ "minecraft:salmon_bucket", -+ "minecraft:sulfur_cube_bucket", -+ "minecraft:tadpole_bucket", -+ "minecraft:tropical_fish_bucket", -+ "minecraft:water_bucket" -+ ] -+ }, -+ { -+ "version": 0, -+ "maximum": 16, -+ "stackedByData": true, -+ "maximumDamage": 0, -+ "damageable": false, -+ "fixedMaximum": 16, -+ "identifiers": [ -+ "minecraft:bucket" -+ ] -+ }, -+ { -+ "version": 1, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 0, -+ "damageable": false, -+ "identifiers": [ -+ "minecraft:music_disc_11", -+ "minecraft:music_disc_13", -+ "minecraft:music_disc_5", -+ "minecraft:music_disc_blocks", -+ "minecraft:music_disc_bounce", -+ "minecraft:music_disc_cat", -+ "minecraft:music_disc_chirp", -+ "minecraft:music_disc_creator", -+ "minecraft:music_disc_creator_music_box", -+ "minecraft:music_disc_far", -+ "minecraft:music_disc_lava_chicken", -+ "minecraft:music_disc_mall", -+ "minecraft:music_disc_mellohi", -+ "minecraft:music_disc_otherside", -+ "minecraft:music_disc_pigstep", -+ "minecraft:music_disc_precipice", -+ "minecraft:music_disc_relic", -+ "minecraft:music_disc_stal", -+ "minecraft:music_disc_strad", -+ "minecraft:music_disc_tears", -+ "minecraft:music_disc_wait", -+ "minecraft:music_disc_ward" -+ ] -+ }, -+ { -+ "version": 1, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 384, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:fishing_rod" -+ ] -+ }, -+ { -+ "version": 1, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 100, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:warped_fungus_on_a_stick" -+ ] -+ }, -+ { -+ "version": 1, -+ "maximum": 1, -+ "stackedByData": false, -+ "maximumDamage": 64, -+ "damageable": true, -+ "identifiers": [ -+ "minecraft:wolf_armor" -+ ] -+ }, -+ { -+ "version": 1, -+ "maximum": 16, -+ "stackedByData": false, -+ "maximumDamage": 0, -+ "damageable": false, -+ "identifiers": [ -+ "minecraft:snowball" -+ ] -+ }, -+ { -+ "version": 1, -+ "maximum": 64, -+ "stackedByData": false, -+ "maximumDamage": 0, -+ "damageable": false, -+ "identifiers": [ -+ "minecraft:brewing_stand", -+ "minecraft:cake", -+ "minecraft:campfire", -+ "minecraft:comparator", -+ "minecraft:flower_pot", -+ "minecraft:frame", -+ "minecraft:glow_frame", -+ "minecraft:hopper", -+ "minecraft:kelp", -+ "minecraft:nether_sprouts", -+ "minecraft:repeater", -+ "minecraft:soul_campfire", -+ "minecraft:string", -+ "minecraft:sugar_cane" -+ ] -+ }, -+ { -+ "version": 1, -+ "maximum": 64, -+ "stackedByData": true, -+ "maximumDamage": 0, -+ "damageable": false, -+ "identifiers": [ -+ "minecraft:arrow", -+ "minecraft:cauldron", -+ "minecraft:charcoal", -+ "minecraft:coal" -+ ] -+ } -+ ], -+ "cooking": { -+ "food": [], -+ "blocks": [ -+ "minecraft:acacia_button", -+ "minecraft:acacia_double_slab", -+ "minecraft:acacia_fence", -+ "minecraft:acacia_fence_gate", -+ "minecraft:acacia_leaves", -+ "minecraft:acacia_log", -+ "minecraft:acacia_planks", -+ "minecraft:acacia_pressure_plate", -+ "minecraft:acacia_sapling", -+ "minecraft:acacia_shelf", -+ "minecraft:acacia_slab", -+ "minecraft:acacia_stairs", -+ "minecraft:acacia_standing_sign", -+ "minecraft:acacia_trapdoor", -+ "minecraft:acacia_wall_sign", -+ "minecraft:acacia_wood", -+ "minecraft:activator_rail", -+ "minecraft:air", -+ "minecraft:allium", -+ "minecraft:allow", -+ "minecraft:amethyst_block", -+ "minecraft:amethyst_cluster", -+ "minecraft:ancient_debris", -+ "minecraft:andesite", -+ "minecraft:andesite_double_slab", -+ "minecraft:andesite_slab", -+ "minecraft:andesite_stairs", -+ "minecraft:andesite_wall", -+ "minecraft:anvil", -+ "minecraft:azalea", -+ "minecraft:azalea_leaves", -+ "minecraft:azalea_leaves_flowered", -+ "minecraft:azure_bluet", -+ "minecraft:bamboo", -+ "minecraft:bamboo_block", -+ "minecraft:bamboo_button", -+ "minecraft:bamboo_double_slab", -+ "minecraft:bamboo_fence", -+ "minecraft:bamboo_fence_gate", -+ "minecraft:bamboo_mosaic", -+ "minecraft:bamboo_mosaic_double_slab", -+ "minecraft:bamboo_mosaic_slab", -+ "minecraft:bamboo_mosaic_stairs", -+ "minecraft:bamboo_planks", -+ "minecraft:bamboo_pressure_plate", -+ "minecraft:bamboo_sapling", -+ "minecraft:bamboo_shelf", -+ "minecraft:bamboo_slab", -+ "minecraft:bamboo_stairs", -+ "minecraft:bamboo_standing_sign", -+ "minecraft:bamboo_trapdoor", -+ "minecraft:bamboo_wall_sign", -+ "minecraft:barrel", -+ "minecraft:barrier", -+ "minecraft:basalt", -+ "minecraft:beacon", -+ "minecraft:bedrock", -+ "minecraft:bee_nest", -+ "minecraft:beehive", -+ "minecraft:bell", -+ "minecraft:big_dripleaf", -+ "minecraft:birch_button", -+ "minecraft:birch_double_slab", -+ "minecraft:birch_fence", -+ "minecraft:birch_fence_gate", -+ "minecraft:birch_leaves", -+ "minecraft:birch_log", -+ "minecraft:birch_planks", -+ "minecraft:birch_pressure_plate", -+ "minecraft:birch_sapling", -+ "minecraft:birch_shelf", -+ "minecraft:birch_slab", -+ "minecraft:birch_stairs", -+ "minecraft:birch_standing_sign", -+ "minecraft:birch_trapdoor", -+ "minecraft:birch_wall_sign", -+ "minecraft:birch_wood", -+ "minecraft:black_candle", -+ "minecraft:black_candle_cake", -+ "minecraft:black_carpet", -+ "minecraft:black_concrete", -+ "minecraft:black_concrete_powder", -+ "minecraft:black_glazed_terracotta", -+ "minecraft:black_shulker_box", -+ "minecraft:black_stained_glass", -+ "minecraft:black_stained_glass_pane", -+ "minecraft:black_terracotta", -+ "minecraft:black_wool", -+ "minecraft:blackstone", -+ "minecraft:blackstone_double_slab", -+ "minecraft:blackstone_slab", -+ "minecraft:blackstone_stairs", -+ "minecraft:blackstone_wall", -+ "minecraft:blast_furnace", -+ "minecraft:blue_candle", -+ "minecraft:blue_candle_cake", -+ "minecraft:blue_carpet", -+ "minecraft:blue_concrete", -+ "minecraft:blue_concrete_powder", -+ "minecraft:blue_glazed_terracotta", -+ "minecraft:blue_ice", -+ "minecraft:blue_orchid", -+ "minecraft:blue_shulker_box", -+ "minecraft:blue_stained_glass", -+ "minecraft:blue_stained_glass_pane", -+ "minecraft:blue_terracotta", -+ "minecraft:blue_wool", -+ "minecraft:bone_block", -+ "minecraft:bookshelf", -+ "minecraft:border_block", -+ "minecraft:brain_coral", -+ "minecraft:brain_coral_block", -+ "minecraft:brain_coral_fan", -+ "minecraft:brain_coral_wall_fan", -+ "minecraft:brick_block", -+ "minecraft:brick_double_slab", -+ "minecraft:brick_slab", -+ "minecraft:brick_stairs", -+ "minecraft:brick_wall", -+ "minecraft:brown_candle", -+ "minecraft:brown_candle_cake", -+ "minecraft:brown_carpet", -+ "minecraft:brown_concrete", -+ "minecraft:brown_concrete_powder", -+ "minecraft:brown_glazed_terracotta", -+ "minecraft:brown_mushroom", -+ "minecraft:brown_mushroom_block", -+ "minecraft:brown_shulker_box", -+ "minecraft:brown_stained_glass", -+ "minecraft:brown_stained_glass_pane", -+ "minecraft:brown_terracotta", -+ "minecraft:brown_wool", -+ "minecraft:bubble_column", -+ "minecraft:bubble_coral", -+ "minecraft:bubble_coral_block", -+ "minecraft:bubble_coral_fan", -+ "minecraft:bubble_coral_wall_fan", -+ "minecraft:budding_amethyst", -+ "minecraft:bush", -+ "minecraft:cactus", -+ "minecraft:cactus_flower", -+ "minecraft:calcite", -+ "minecraft:calibrated_sculk_sensor", -+ "minecraft:candle", -+ "minecraft:candle_cake", -+ "minecraft:carrots", -+ "minecraft:cartography_table", -+ "minecraft:carved_pumpkin", -+ "minecraft:cave_vines", -+ "minecraft:cave_vines_body_with_berries", -+ "minecraft:cave_vines_head_with_berries", -+ "minecraft:chain_command_block", -+ "minecraft:chalkboard", -+ "minecraft:chemical_heat", -+ "minecraft:cherry_button", -+ "minecraft:cherry_double_slab", -+ "minecraft:cherry_fence", -+ "minecraft:cherry_fence_gate", -+ "minecraft:cherry_leaves", -+ "minecraft:cherry_log", -+ "minecraft:cherry_planks", -+ "minecraft:cherry_pressure_plate", -+ "minecraft:cherry_sapling", -+ "minecraft:cherry_shelf", -+ "minecraft:cherry_slab", -+ "minecraft:cherry_stairs", -+ "minecraft:cherry_standing_sign", -+ "minecraft:cherry_trapdoor", -+ "minecraft:cherry_wall_sign", -+ "minecraft:cherry_wood", -+ "minecraft:chest", -+ "minecraft:chipped_anvil", -+ "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:chorus_plant", -+ "minecraft:cinnabar", -+ "minecraft:cinnabar_brick_double_slab", -+ "minecraft:cinnabar_brick_slab", -+ "minecraft:cinnabar_brick_stairs", -+ "minecraft:cinnabar_brick_wall", -+ "minecraft:cinnabar_bricks", -+ "minecraft:cinnabar_double_slab", -+ "minecraft:cinnabar_slab", -+ "minecraft:cinnabar_stairs", -+ "minecraft:cinnabar_wall", -+ "minecraft:clay", -+ "minecraft:client_request_placeholder_block", -+ "minecraft:closed_eyeblossom", -+ "minecraft:coal_block", -+ "minecraft:coal_ore", -+ "minecraft:coarse_dirt", -+ "minecraft:cobbled_deepslate", -+ "minecraft:cobbled_deepslate_double_slab", -+ "minecraft:cobbled_deepslate_slab", -+ "minecraft:cobbled_deepslate_stairs", -+ "minecraft:cobbled_deepslate_wall", -+ "minecraft:cobblestone", -+ "minecraft:cobblestone_double_slab", -+ "minecraft:cobblestone_slab", -+ "minecraft:cobblestone_wall", -+ "minecraft:cocoa", -+ "minecraft:colored_torch_blue", -+ "minecraft:colored_torch_green", -+ "minecraft:colored_torch_purple", -+ "minecraft:colored_torch_red", -+ "minecraft:command_block", -+ "minecraft:composter", -+ "minecraft:compound_creator", -+ "minecraft:conduit", -+ "minecraft:copper_bars", -+ "minecraft:copper_block", -+ "minecraft:copper_bulb", -+ "minecraft:copper_chain", -+ "minecraft:copper_chest", -+ "minecraft:copper_golem_statue", -+ "minecraft:copper_grate", -+ "minecraft:copper_lantern", -+ "minecraft:copper_ore", -+ "minecraft:copper_torch", -+ "minecraft:copper_trapdoor", -+ "minecraft:cornflower", -+ "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:creeper_head", -+ "minecraft:crimson_button", -+ "minecraft:crimson_double_slab", -+ "minecraft:crimson_fence", -+ "minecraft:crimson_fence_gate", -+ "minecraft:crimson_fungus", -+ "minecraft:crimson_hyphae", -+ "minecraft:crimson_nylium", -+ "minecraft:crimson_planks", -+ "minecraft:crimson_pressure_plate", -+ "minecraft:crimson_roots", -+ "minecraft:crimson_shelf", -+ "minecraft:crimson_slab", -+ "minecraft:crimson_stairs", -+ "minecraft:crimson_standing_sign", -+ "minecraft:crimson_stem", -+ "minecraft:crimson_trapdoor", -+ "minecraft:crimson_wall_sign", -+ "minecraft:crying_obsidian", -+ "minecraft:cut_copper", -+ "minecraft:cut_copper_slab", -+ "minecraft:cut_copper_stairs", -+ "minecraft:cut_red_sandstone", -+ "minecraft:cut_red_sandstone_double_slab", -+ "minecraft:cut_red_sandstone_slab", -+ "minecraft:cut_sandstone", -+ "minecraft:cut_sandstone_double_slab", -+ "minecraft:cut_sandstone_slab", -+ "minecraft:cyan_candle", -+ "minecraft:cyan_candle_cake", -+ "minecraft:cyan_carpet", -+ "minecraft:cyan_concrete", -+ "minecraft:cyan_concrete_powder", -+ "minecraft:cyan_glazed_terracotta", -+ "minecraft:cyan_shulker_box", -+ "minecraft:cyan_stained_glass", -+ "minecraft:cyan_stained_glass_pane", -+ "minecraft:cyan_terracotta", -+ "minecraft:cyan_wool", -+ "minecraft:damaged_anvil", -+ "minecraft:dandelion", -+ "minecraft:dark_oak_button", -+ "minecraft:dark_oak_double_slab", -+ "minecraft:dark_oak_fence", -+ "minecraft:dark_oak_fence_gate", -+ "minecraft:dark_oak_leaves", -+ "minecraft:dark_oak_log", -+ "minecraft:dark_oak_planks", -+ "minecraft:dark_oak_pressure_plate", -+ "minecraft:dark_oak_sapling", -+ "minecraft:dark_oak_shelf", -+ "minecraft:dark_oak_slab", -+ "minecraft:dark_oak_stairs", -+ "minecraft:dark_oak_trapdoor", -+ "minecraft:dark_oak_wood", -+ "minecraft:dark_prismarine", -+ "minecraft:dark_prismarine_double_slab", -+ "minecraft:dark_prismarine_slab", -+ "minecraft:dark_prismarine_stairs", -+ "minecraft:darkoak_standing_sign", -+ "minecraft:darkoak_wall_sign", -+ "minecraft:daylight_detector", -+ "minecraft:daylight_detector_inverted", -+ "minecraft:dead_brain_coral", -+ "minecraft:dead_brain_coral_block", -+ "minecraft:dead_brain_coral_fan", -+ "minecraft:dead_brain_coral_wall_fan", -+ "minecraft:dead_bubble_coral", -+ "minecraft:dead_bubble_coral_block", -+ "minecraft:dead_bubble_coral_fan", -+ "minecraft:dead_bubble_coral_wall_fan", -+ "minecraft:dead_fire_coral", -+ "minecraft:dead_fire_coral_block", -+ "minecraft:dead_fire_coral_fan", -+ "minecraft:dead_fire_coral_wall_fan", -+ "minecraft:dead_horn_coral", -+ "minecraft:dead_horn_coral_block", -+ "minecraft:dead_horn_coral_fan", -+ "minecraft:dead_horn_coral_wall_fan", -+ "minecraft:dead_tube_coral", -+ "minecraft:dead_tube_coral_block", -+ "minecraft:dead_tube_coral_fan", -+ "minecraft:dead_tube_coral_wall_fan", -+ "minecraft:deadbush", -+ "minecraft:decorated_pot", -+ "minecraft:deepslate", -+ "minecraft:deepslate_brick_double_slab", -+ "minecraft:deepslate_brick_slab", -+ "minecraft:deepslate_brick_stairs", -+ "minecraft:deepslate_brick_wall", -+ "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_double_slab", -+ "minecraft:deepslate_tile_slab", -+ "minecraft:deepslate_tile_stairs", -+ "minecraft:deepslate_tile_wall", -+ "minecraft:deepslate_tiles", -+ "minecraft:deny", -+ "minecraft:deprecated_anvil", -+ "minecraft:deprecated_purpur_block_1", -+ "minecraft:deprecated_purpur_block_2", -+ "minecraft:detector_rail", -+ "minecraft:diamond_block", -+ "minecraft:diamond_ore", -+ "minecraft:diorite", -+ "minecraft:diorite_double_slab", -+ "minecraft:diorite_slab", -+ "minecraft:diorite_stairs", -+ "minecraft:diorite_wall", -+ "minecraft:dirt", -+ "minecraft:dirt_with_roots", -+ "minecraft:dispenser", -+ "minecraft:double_cut_copper_slab", -+ "minecraft:dragon_egg", -+ "minecraft:dragon_head", -+ "minecraft:dried_ghast", -+ "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:enchanting_table", -+ "minecraft:end_brick_stairs", -+ "minecraft:end_bricks", -+ "minecraft:end_gateway", -+ "minecraft:end_portal", -+ "minecraft:end_portal_frame", -+ "minecraft:end_rod", -+ "minecraft:end_stone", -+ "minecraft:end_stone_brick_double_slab", -+ "minecraft:end_stone_brick_slab", -+ "minecraft:end_stone_brick_wall", -+ "minecraft:ender_chest", -+ "minecraft:exposed_chiseled_copper", -+ "minecraft:exposed_copper", -+ "minecraft:exposed_copper_bars", -+ "minecraft:exposed_copper_bulb", -+ "minecraft:exposed_copper_chain", -+ "minecraft:exposed_copper_chest", -+ "minecraft:exposed_copper_golem_statue", -+ "minecraft:exposed_copper_grate", -+ "minecraft:exposed_copper_lantern", -+ "minecraft:exposed_copper_trapdoor", -+ "minecraft:exposed_cut_copper", -+ "minecraft:exposed_cut_copper_slab", -+ "minecraft:exposed_cut_copper_stairs", -+ "minecraft:exposed_double_cut_copper_slab", -+ "minecraft:exposed_lightning_rod", -+ "minecraft:farmland", -+ "minecraft:fence_gate", -+ "minecraft:fern", -+ "minecraft:fire", -+ "minecraft:fire_coral", -+ "minecraft:fire_coral_block", -+ "minecraft:fire_coral_fan", -+ "minecraft:fire_coral_wall_fan", -+ "minecraft:firefly_bush", -+ "minecraft:fletching_table", -+ "minecraft:flowering_azalea", -+ "minecraft:flowing_lava", -+ "minecraft:flowing_water", -+ "minecraft:frog_spawn", -+ "minecraft:frosted_ice", -+ "minecraft:furnace", -+ "minecraft:gilded_blackstone", -+ "minecraft:glass", -+ "minecraft:glass_pane", -+ "minecraft:glow_lichen", -+ "minecraft:glowingobsidian", -+ "minecraft:glowstone", -+ "minecraft:gold_block", -+ "minecraft:gold_ore", -+ "minecraft:golden_dandelion", -+ "minecraft:golden_rail", -+ "minecraft:granite", -+ "minecraft:granite_double_slab", -+ "minecraft:granite_slab", -+ "minecraft:granite_stairs", -+ "minecraft:granite_wall", -+ "minecraft:grass_block", -+ "minecraft:grass_path", -+ "minecraft:gravel", -+ "minecraft:gray_candle", -+ "minecraft:gray_candle_cake", -+ "minecraft:gray_carpet", -+ "minecraft:gray_concrete", -+ "minecraft:gray_concrete_powder", -+ "minecraft:gray_glazed_terracotta", -+ "minecraft:gray_shulker_box", -+ "minecraft:gray_stained_glass", -+ "minecraft:gray_stained_glass_pane", -+ "minecraft:gray_terracotta", -+ "minecraft:gray_wool", -+ "minecraft:green_candle", -+ "minecraft:green_candle_cake", -+ "minecraft:green_carpet", -+ "minecraft:green_concrete", -+ "minecraft:green_concrete_powder", -+ "minecraft:green_glazed_terracotta", -+ "minecraft:green_shulker_box", -+ "minecraft:green_stained_glass", -+ "minecraft:green_stained_glass_pane", -+ "minecraft:green_terracotta", -+ "minecraft:green_wool", -+ "minecraft:grindstone", -+ "minecraft:hanging_roots", -+ "minecraft:hard_black_stained_glass", -+ "minecraft:hard_black_stained_glass_pane", -+ "minecraft:hard_blue_stained_glass", -+ "minecraft:hard_blue_stained_glass_pane", -+ "minecraft:hard_brown_stained_glass", -+ "minecraft:hard_brown_stained_glass_pane", -+ "minecraft:hard_cyan_stained_glass", -+ "minecraft:hard_cyan_stained_glass_pane", -+ "minecraft:hard_glass", -+ "minecraft:hard_glass_pane", -+ "minecraft:hard_gray_stained_glass", -+ "minecraft:hard_gray_stained_glass_pane", -+ "minecraft:hard_green_stained_glass", -+ "minecraft:hard_green_stained_glass_pane", -+ "minecraft:hard_light_blue_stained_glass", -+ "minecraft:hard_light_blue_stained_glass_pane", -+ "minecraft:hard_light_gray_stained_glass", -+ "minecraft:hard_light_gray_stained_glass_pane", -+ "minecraft:hard_lime_stained_glass", -+ "minecraft:hard_lime_stained_glass_pane", -+ "minecraft:hard_magenta_stained_glass", -+ "minecraft:hard_magenta_stained_glass_pane", -+ "minecraft:hard_orange_stained_glass", -+ "minecraft:hard_orange_stained_glass_pane", -+ "minecraft:hard_pink_stained_glass", -+ "minecraft:hard_pink_stained_glass_pane", -+ "minecraft:hard_purple_stained_glass", -+ "minecraft:hard_purple_stained_glass_pane", -+ "minecraft:hard_red_stained_glass", -+ "minecraft:hard_red_stained_glass_pane", -+ "minecraft:hard_white_stained_glass", -+ "minecraft:hard_white_stained_glass_pane", -+ "minecraft:hard_yellow_stained_glass", -+ "minecraft:hard_yellow_stained_glass_pane", -+ "minecraft:hardened_clay", -+ "minecraft:hay_block", -+ "minecraft:heavy_core", -+ "minecraft:heavy_weighted_pressure_plate", -+ "minecraft:honey_block", -+ "minecraft:honeycomb_block", -+ "minecraft:horn_coral", -+ "minecraft:horn_coral_block", -+ "minecraft:horn_coral_fan", -+ "minecraft:horn_coral_wall_fan", -+ "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_bars", -+ "minecraft:iron_block", -+ "minecraft:iron_chain", -+ "minecraft:iron_ore", -+ "minecraft:iron_trapdoor", -+ "minecraft:jigsaw", -+ "minecraft:jukebox", -+ "minecraft:jungle_button", -+ "minecraft:jungle_double_slab", -+ "minecraft:jungle_fence", -+ "minecraft:jungle_fence_gate", -+ "minecraft:jungle_leaves", -+ "minecraft:jungle_log", -+ "minecraft:jungle_planks", -+ "minecraft:jungle_pressure_plate", -+ "minecraft:jungle_sapling", -+ "minecraft:jungle_shelf", -+ "minecraft:jungle_slab", -+ "minecraft:jungle_stairs", -+ "minecraft:jungle_standing_sign", -+ "minecraft:jungle_trapdoor", -+ "minecraft:jungle_wall_sign", -+ "minecraft:jungle_wood", -+ "minecraft:lab_table", -+ "minecraft:ladder", -+ "minecraft:lantern", -+ "minecraft:lapis_block", -+ "minecraft:lapis_ore", -+ "minecraft:large_amethyst_bud", -+ "minecraft:large_fern", -+ "minecraft:lava", -+ "minecraft:leaf_litter", -+ "minecraft:lectern", -+ "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_blue_candle", -+ "minecraft:light_blue_candle_cake", -+ "minecraft:light_blue_carpet", -+ "minecraft:light_blue_concrete", -+ "minecraft:light_blue_concrete_powder", -+ "minecraft:light_blue_glazed_terracotta", -+ "minecraft:light_blue_shulker_box", -+ "minecraft:light_blue_stained_glass", -+ "minecraft:light_blue_stained_glass_pane", -+ "minecraft:light_blue_terracotta", -+ "minecraft:light_blue_wool", -+ "minecraft:light_gray_candle", -+ "minecraft:light_gray_candle_cake", -+ "minecraft:light_gray_carpet", -+ "minecraft:light_gray_concrete", -+ "minecraft:light_gray_concrete_powder", -+ "minecraft:light_gray_shulker_box", -+ "minecraft:light_gray_stained_glass", -+ "minecraft:light_gray_stained_glass_pane", -+ "minecraft:light_gray_terracotta", -+ "minecraft:light_gray_wool", -+ "minecraft:light_weighted_pressure_plate", -+ "minecraft:lightning_rod", -+ "minecraft:lilac", -+ "minecraft:lily_of_the_valley", -+ "minecraft:lime_candle", -+ "minecraft:lime_candle_cake", -+ "minecraft:lime_carpet", -+ "minecraft:lime_concrete", -+ "minecraft:lime_concrete_powder", -+ "minecraft:lime_glazed_terracotta", -+ "minecraft:lime_shulker_box", -+ "minecraft:lime_stained_glass", -+ "minecraft:lime_stained_glass_pane", -+ "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_candle", -+ "minecraft:magenta_candle_cake", -+ "minecraft:magenta_carpet", -+ "minecraft:magenta_concrete", -+ "minecraft:magenta_concrete_powder", -+ "minecraft:magenta_glazed_terracotta", -+ "minecraft:magenta_shulker_box", -+ "minecraft:magenta_stained_glass", -+ "minecraft:magenta_stained_glass_pane", -+ "minecraft:magenta_terracotta", -+ "minecraft:magenta_wool", -+ "minecraft:magma", -+ "minecraft:mangrove_button", -+ "minecraft:mangrove_double_slab", -+ "minecraft:mangrove_fence", -+ "minecraft:mangrove_fence_gate", -+ "minecraft:mangrove_leaves", -+ "minecraft:mangrove_log", -+ "minecraft:mangrove_planks", -+ "minecraft:mangrove_pressure_plate", -+ "minecraft:mangrove_propagule", -+ "minecraft:mangrove_roots", -+ "minecraft:mangrove_shelf", -+ "minecraft:mangrove_slab", -+ "minecraft:mangrove_stairs", -+ "minecraft:mangrove_standing_sign", -+ "minecraft:mangrove_trapdoor", -+ "minecraft:mangrove_wall_sign", -+ "minecraft:mangrove_wood", -+ "minecraft:material_reducer", -+ "minecraft:medium_amethyst_bud", -+ "minecraft:melon_block", -+ "minecraft:melon_stem", -+ "minecraft:mob_spawner", -+ "minecraft:moss_block", -+ "minecraft:moss_carpet", -+ "minecraft:mossy_cobblestone", -+ "minecraft:mossy_cobblestone_double_slab", -+ "minecraft:mossy_cobblestone_slab", -+ "minecraft:mossy_cobblestone_stairs", -+ "minecraft:mossy_cobblestone_wall", -+ "minecraft:mossy_stone_brick_double_slab", -+ "minecraft:mossy_stone_brick_slab", -+ "minecraft:mossy_stone_brick_stairs", -+ "minecraft:mossy_stone_brick_wall", -+ "minecraft:mossy_stone_bricks", -+ "minecraft:moving_block", -+ "minecraft:mud", -+ "minecraft:mud_brick_double_slab", -+ "minecraft:mud_brick_slab", -+ "minecraft:mud_brick_stairs", -+ "minecraft:mud_brick_wall", -+ "minecraft:mud_bricks", -+ "minecraft:muddy_mangrove_roots", -+ "minecraft:mushroom_stem", -+ "minecraft:mycelium", -+ "minecraft:nether_brick", -+ "minecraft:nether_brick_double_slab", -+ "minecraft:nether_brick_fence", -+ "minecraft:nether_brick_slab", -+ "minecraft:nether_brick_stairs", -+ "minecraft:nether_brick_wall", -+ "minecraft:nether_gold_ore", -+ "minecraft:nether_wart_block", -+ "minecraft:netherite_block", -+ "minecraft:netherrack", -+ "minecraft:netherreactor", -+ "minecraft:normal_stone_double_slab", -+ "minecraft:normal_stone_slab", -+ "minecraft:normal_stone_stairs", -+ "minecraft:noteblock", -+ "minecraft:oak_double_slab", -+ "minecraft:oak_fence", -+ "minecraft:oak_leaves", -+ "minecraft:oak_log", -+ "minecraft:oak_planks", -+ "minecraft:oak_sapling", -+ "minecraft:oak_shelf", -+ "minecraft:oak_slab", -+ "minecraft:oak_stairs", -+ "minecraft:oak_wood", -+ "minecraft:observer", -+ "minecraft:obsidian", -+ "minecraft:ochre_froglight", -+ "minecraft:open_eyeblossom", -+ "minecraft:orange_candle", -+ "minecraft:orange_candle_cake", -+ "minecraft:orange_carpet", -+ "minecraft:orange_concrete", -+ "minecraft:orange_concrete_powder", -+ "minecraft:orange_glazed_terracotta", -+ "minecraft:orange_poplar_leaves", -+ "minecraft:orange_shulker_box", -+ "minecraft:orange_stained_glass", -+ "minecraft:orange_stained_glass_pane", -+ "minecraft:orange_terracotta", -+ "minecraft:orange_tulip", -+ "minecraft:orange_wool", -+ "minecraft:oxeye_daisy", -+ "minecraft:oxidized_chiseled_copper", -+ "minecraft:oxidized_copper", -+ "minecraft:oxidized_copper_bars", -+ "minecraft:oxidized_copper_bulb", -+ "minecraft:oxidized_copper_chain", -+ "minecraft:oxidized_copper_chest", -+ "minecraft:oxidized_copper_golem_statue", -+ "minecraft:oxidized_copper_grate", -+ "minecraft:oxidized_copper_lantern", -+ "minecraft:oxidized_copper_trapdoor", -+ "minecraft:oxidized_cut_copper", -+ "minecraft:oxidized_cut_copper_slab", -+ "minecraft:oxidized_cut_copper_stairs", -+ "minecraft:oxidized_double_cut_copper_slab", -+ "minecraft:oxidized_lightning_rod", -+ "minecraft:packed_ice", -+ "minecraft:packed_mud", -+ "minecraft:pale_hanging_moss", -+ "minecraft:pale_moss_block", -+ "minecraft:pale_moss_carpet", -+ "minecraft:pale_oak_button", -+ "minecraft:pale_oak_double_slab", -+ "minecraft:pale_oak_fence", -+ "minecraft:pale_oak_fence_gate", -+ "minecraft:pale_oak_leaves", -+ "minecraft:pale_oak_log", -+ "minecraft:pale_oak_planks", -+ "minecraft:pale_oak_pressure_plate", -+ "minecraft:pale_oak_sapling", -+ "minecraft:pale_oak_shelf", -+ "minecraft:pale_oak_slab", -+ "minecraft:pale_oak_stairs", -+ "minecraft:pale_oak_standing_sign", -+ "minecraft:pale_oak_trapdoor", -+ "minecraft:pale_oak_wall_sign", -+ "minecraft:pale_oak_wood", -+ "minecraft:pearlescent_froglight", -+ "minecraft:peony", -+ "minecraft:petrified_oak_double_slab", -+ "minecraft:petrified_oak_slab", -+ "minecraft:piglin_head", -+ "minecraft:pink_candle", -+ "minecraft:pink_candle_cake", -+ "minecraft:pink_carpet", -+ "minecraft:pink_concrete", -+ "minecraft:pink_concrete_powder", -+ "minecraft:pink_glazed_terracotta", -+ "minecraft:pink_petals", -+ "minecraft:pink_shulker_box", -+ "minecraft:pink_stained_glass", -+ "minecraft:pink_stained_glass_pane", -+ "minecraft:pink_terracotta", -+ "minecraft:pink_tulip", -+ "minecraft:pink_wool", -+ "minecraft:piston", -+ "minecraft:piston_arm_collision", -+ "minecraft:pitcher_crop", -+ "minecraft:pitcher_plant", -+ "minecraft:player_head", -+ "minecraft:podzol", -+ "minecraft:pointed_dripstone", -+ "minecraft:polished_andesite", -+ "minecraft:polished_andesite_double_slab", -+ "minecraft:polished_andesite_slab", -+ "minecraft:polished_andesite_stairs", -+ "minecraft:polished_basalt", -+ "minecraft:polished_blackstone", -+ "minecraft:polished_blackstone_brick_double_slab", -+ "minecraft:polished_blackstone_brick_slab", -+ "minecraft:polished_blackstone_brick_stairs", -+ "minecraft:polished_blackstone_brick_wall", -+ "minecraft:polished_blackstone_bricks", -+ "minecraft:polished_blackstone_button", -+ "minecraft:polished_blackstone_double_slab", -+ "minecraft:polished_blackstone_pressure_plate", -+ "minecraft:polished_blackstone_slab", -+ "minecraft:polished_blackstone_stairs", -+ "minecraft:polished_blackstone_wall", -+ "minecraft:polished_cinnabar", -+ "minecraft:polished_cinnabar_double_slab", -+ "minecraft:polished_cinnabar_slab", -+ "minecraft:polished_cinnabar_stairs", -+ "minecraft:polished_cinnabar_wall", -+ "minecraft:polished_deepslate", -+ "minecraft:polished_deepslate_double_slab", -+ "minecraft:polished_deepslate_slab", -+ "minecraft:polished_deepslate_stairs", -+ "minecraft:polished_deepslate_wall", -+ "minecraft:polished_diorite", -+ "minecraft:polished_diorite_double_slab", -+ "minecraft:polished_diorite_slab", -+ "minecraft:polished_diorite_stairs", -+ "minecraft:polished_granite", -+ "minecraft:polished_granite_double_slab", -+ "minecraft:polished_granite_slab", -+ "minecraft:polished_granite_stairs", -+ "minecraft:polished_sulfur", -+ "minecraft:polished_sulfur_double_slab", -+ "minecraft:polished_sulfur_slab", -+ "minecraft:polished_sulfur_stairs", -+ "minecraft:polished_sulfur_wall", -+ "minecraft:polished_tuff", -+ "minecraft:polished_tuff_double_slab", -+ "minecraft:polished_tuff_slab", -+ "minecraft:polished_tuff_stairs", -+ "minecraft:polished_tuff_wall", -+ "minecraft:poplar_button", -+ "minecraft:poplar_double_slab", -+ "minecraft:poplar_fence", -+ "minecraft:poplar_fence_gate", -+ "minecraft:poplar_log", -+ "minecraft:poplar_planks", -+ "minecraft:poplar_pressure_plate", -+ "minecraft:poplar_sapling", -+ "minecraft:poplar_shelf", -+ "minecraft:poplar_slab", -+ "minecraft:poplar_stairs", -+ "minecraft:poplar_standing_sign", -+ "minecraft:poplar_trapdoor", -+ "minecraft:poplar_wall_sign", -+ "minecraft:poplar_wood", -+ "minecraft:poppy", -+ "minecraft:portal", -+ "minecraft:potatoes", -+ "minecraft:potent_sulfur", -+ "minecraft:powder_snow", -+ "minecraft:powered_comparator", -+ "minecraft:powered_repeater", -+ "minecraft:prismarine", -+ "minecraft:prismarine_brick_double_slab", -+ "minecraft:prismarine_brick_slab", -+ "minecraft:prismarine_bricks", -+ "minecraft:prismarine_bricks_stairs", -+ "minecraft:prismarine_double_slab", -+ "minecraft:prismarine_slab", -+ "minecraft:prismarine_stairs", -+ "minecraft:prismarine_wall", -+ "minecraft:pumpkin", -+ "minecraft:pumpkin_stem", -+ "minecraft:purple_candle", -+ "minecraft:purple_candle_cake", -+ "minecraft:purple_carpet", -+ "minecraft:purple_concrete", -+ "minecraft:purple_concrete_powder", -+ "minecraft:purple_glazed_terracotta", -+ "minecraft:purple_shulker_box", -+ "minecraft:purple_stained_glass", -+ "minecraft:purple_stained_glass_pane", -+ "minecraft:purple_terracotta", -+ "minecraft:purple_wool", -+ "minecraft:purpur_block", -+ "minecraft:purpur_double_slab", -+ "minecraft:purpur_pillar", -+ "minecraft:purpur_slab", -+ "minecraft:purpur_stairs", -+ "minecraft:quartz_block", -+ "minecraft:quartz_bricks", -+ "minecraft:quartz_double_slab", -+ "minecraft:quartz_ore", -+ "minecraft:quartz_pillar", -+ "minecraft:quartz_slab", -+ "minecraft:quartz_stairs", -+ "minecraft:rail", -+ "minecraft:raw_copper_block", -+ "minecraft:raw_gold_block", -+ "minecraft:raw_iron_block", -+ "minecraft:red_candle", -+ "minecraft:red_candle_cake", -+ "minecraft:red_carpet", -+ "minecraft:red_concrete", -+ "minecraft:red_concrete_powder", -+ "minecraft:red_glazed_terracotta", -+ "minecraft:red_mushroom", -+ "minecraft:red_mushroom_block", -+ "minecraft:red_nether_brick", -+ "minecraft:red_nether_brick_double_slab", -+ "minecraft:red_nether_brick_slab", -+ "minecraft:red_nether_brick_stairs", -+ "minecraft:red_nether_brick_wall", -+ "minecraft:red_poplar_leaves", -+ "minecraft:red_sand", -+ "minecraft:red_sandstone", -+ "minecraft:red_sandstone_double_slab", -+ "minecraft:red_sandstone_slab", -+ "minecraft:red_sandstone_stairs", -+ "minecraft:red_sandstone_wall", -+ "minecraft:red_shulker_box", -+ "minecraft:red_stained_glass", -+ "minecraft:red_stained_glass_pane", -+ "minecraft:red_terracotta", -+ "minecraft:red_tulip", -+ "minecraft:red_wool", -+ "minecraft:redstone_block", -+ "minecraft:redstone_lamp", -+ "minecraft:redstone_ore", -+ "minecraft:redstone_torch", -+ "minecraft:redstone_wire", -+ "minecraft:reinforced_deepslate", -+ "minecraft:repeating_command_block", -+ "minecraft:reserved6", -+ "minecraft:resin_block", -+ "minecraft:resin_brick_double_slab", -+ "minecraft:resin_brick_slab", -+ "minecraft:resin_brick_stairs", -+ "minecraft:resin_brick_wall", -+ "minecraft:resin_bricks", -+ "minecraft:resin_clump", -+ "minecraft:respawn_anchor", -+ "minecraft:rose_bush", -+ "minecraft:sand", -+ "minecraft:sandstone", -+ "minecraft:sandstone_double_slab", -+ "minecraft:sandstone_slab", -+ "minecraft:sandstone_stairs", -+ "minecraft:sandstone_wall", -+ "minecraft:scaffolding", -+ "minecraft:sculk", -+ "minecraft:sculk_catalyst", -+ "minecraft:sculk_sensor", -+ "minecraft:sculk_shrieker", -+ "minecraft:sculk_vein", -+ "minecraft:sea_lantern", -+ "minecraft:sea_pickle", -+ "minecraft:seagrass", -+ "minecraft:short_dry_grass", -+ "minecraft:short_grass", -+ "minecraft:shroomlight", -+ "minecraft:silver_glazed_terracotta", -+ "minecraft:skeleton_skull", -+ "minecraft:slime", -+ "minecraft:small_amethyst_bud", -+ "minecraft:small_dripleaf_block", -+ "minecraft:smithing_table", -+ "minecraft:smoker", -+ "minecraft:smooth_basalt", -+ "minecraft:smooth_quartz", -+ "minecraft:smooth_quartz_double_slab", -+ "minecraft:smooth_quartz_slab", -+ "minecraft:smooth_quartz_stairs", -+ "minecraft:smooth_red_sandstone", -+ "minecraft:smooth_red_sandstone_double_slab", -+ "minecraft:smooth_red_sandstone_slab", -+ "minecraft:smooth_red_sandstone_stairs", -+ "minecraft:smooth_sandstone", -+ "minecraft:smooth_sandstone_double_slab", -+ "minecraft:smooth_sandstone_slab", -+ "minecraft:smooth_sandstone_stairs", -+ "minecraft:smooth_stone", -+ "minecraft:smooth_stone_double_slab", -+ "minecraft:smooth_stone_slab", -+ "minecraft:sniffer_egg", -+ "minecraft:snow", -+ "minecraft:snow_layer", -+ "minecraft:soul_fire", -+ "minecraft:soul_lantern", -+ "minecraft:soul_sand", -+ "minecraft:soul_soil", -+ "minecraft:soul_torch", -+ "minecraft:sponge", -+ "minecraft:spore_blossom", -+ "minecraft:spruce_button", -+ "minecraft:spruce_double_slab", -+ "minecraft:spruce_fence", -+ "minecraft:spruce_fence_gate", -+ "minecraft:spruce_leaves", -+ "minecraft:spruce_log", -+ "minecraft:spruce_planks", -+ "minecraft:spruce_pressure_plate", -+ "minecraft:spruce_sapling", -+ "minecraft:spruce_shelf", -+ "minecraft:spruce_slab", -+ "minecraft:spruce_stairs", -+ "minecraft:spruce_standing_sign", -+ "minecraft:spruce_trapdoor", -+ "minecraft:spruce_wall_sign", -+ "minecraft:spruce_wood", -+ "minecraft:standing_banner", -+ "minecraft:standing_sign", -+ "minecraft:sticky_piston", -+ "minecraft:sticky_piston_arm_collision", -+ "minecraft:stone", -+ "minecraft:stone_brick_double_slab", -+ "minecraft:stone_brick_slab", -+ "minecraft:stone_brick_stairs", -+ "minecraft:stone_brick_wall", -+ "minecraft:stone_bricks", -+ "minecraft:stone_button", -+ "minecraft:stone_pressure_plate", -+ "minecraft:stone_stairs", -+ "minecraft:stonecutter", -+ "minecraft:stonecutter_block", -+ "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:structure_void", -+ "minecraft:sulfur", -+ "minecraft:sulfur_brick_double_slab", -+ "minecraft:sulfur_brick_slab", -+ "minecraft:sulfur_brick_stairs", -+ "minecraft:sulfur_brick_wall", -+ "minecraft:sulfur_bricks", -+ "minecraft:sulfur_double_slab", -+ "minecraft:sulfur_slab", -+ "minecraft:sulfur_spike", -+ "minecraft:sulfur_stairs", -+ "minecraft:sulfur_wall", -+ "minecraft:sunflower", -+ "minecraft:suspicious_gravel", -+ "minecraft:suspicious_sand", -+ "minecraft:sweet_berry_bush", -+ "minecraft:tall_dry_grass", -+ "minecraft:tall_grass", -+ "minecraft:target", -+ "minecraft:tinted_glass", -+ "minecraft:tnt", -+ "minecraft:torch", -+ "minecraft:torchflower", -+ "minecraft:torchflower_crop", -+ "minecraft:trapdoor", -+ "minecraft:trapped_chest", -+ "minecraft:trial_spawner", -+ "minecraft:trip_wire", -+ "minecraft:tripwire_hook", -+ "minecraft:tube_coral", -+ "minecraft:tube_coral_block", -+ "minecraft:tube_coral_fan", -+ "minecraft:tube_coral_wall_fan", -+ "minecraft:tuff", -+ "minecraft:tuff_brick_double_slab", -+ "minecraft:tuff_brick_slab", -+ "minecraft:tuff_brick_stairs", -+ "minecraft:tuff_brick_wall", -+ "minecraft:tuff_bricks", -+ "minecraft:tuff_double_slab", -+ "minecraft:tuff_slab", -+ "minecraft:tuff_stairs", -+ "minecraft:tuff_wall", -+ "minecraft:turtle_egg", -+ "minecraft:twisting_vines", -+ "minecraft:underwater_tnt", -+ "minecraft:underwater_torch", -+ "minecraft:undyed_shulker_box", -+ "minecraft:unknown", -+ "minecraft:unlit_redstone_torch", -+ "minecraft:unpowered_comparator", -+ "minecraft:unpowered_repeater", -+ "minecraft:vault", -+ "minecraft:verdant_froglight", -+ "minecraft:vine", -+ "minecraft:wall_banner", -+ "minecraft:wall_sign", -+ "minecraft:warped_button", -+ "minecraft:warped_double_slab", -+ "minecraft:warped_fence", -+ "minecraft:warped_fence_gate", -+ "minecraft:warped_fungus", -+ "minecraft:warped_hyphae", -+ "minecraft:warped_nylium", -+ "minecraft:warped_planks", -+ "minecraft:warped_pressure_plate", -+ "minecraft:warped_roots", -+ "minecraft:warped_shelf", -+ "minecraft:warped_slab", -+ "minecraft:warped_stairs", -+ "minecraft:warped_standing_sign", -+ "minecraft:warped_stem", -+ "minecraft:warped_trapdoor", -+ "minecraft:warped_wall_sign", -+ "minecraft:warped_wart_block", -+ "minecraft:water", -+ "minecraft:waterlily", -+ "minecraft:waxed_chiseled_copper", -+ "minecraft:waxed_copper", -+ "minecraft:waxed_copper_bars", -+ "minecraft:waxed_copper_bulb", -+ "minecraft:waxed_copper_chain", -+ "minecraft:waxed_copper_chest", -+ "minecraft:waxed_copper_golem_statue", -+ "minecraft:waxed_copper_grate", -+ "minecraft:waxed_copper_lantern", -+ "minecraft:waxed_copper_trapdoor", -+ "minecraft:waxed_cut_copper", -+ "minecraft:waxed_cut_copper_slab", -+ "minecraft:waxed_cut_copper_stairs", -+ "minecraft:waxed_double_cut_copper_slab", -+ "minecraft:waxed_exposed_chiseled_copper", -+ "minecraft:waxed_exposed_copper", -+ "minecraft:waxed_exposed_copper_bars", -+ "minecraft:waxed_exposed_copper_bulb", -+ "minecraft:waxed_exposed_copper_chain", -+ "minecraft:waxed_exposed_copper_chest", -+ "minecraft:waxed_exposed_copper_golem_statue", -+ "minecraft:waxed_exposed_copper_grate", -+ "minecraft:waxed_exposed_copper_lantern", -+ "minecraft:waxed_exposed_copper_trapdoor", -+ "minecraft:waxed_exposed_cut_copper", -+ "minecraft:waxed_exposed_cut_copper_slab", -+ "minecraft:waxed_exposed_cut_copper_stairs", -+ "minecraft:waxed_exposed_double_cut_copper_slab", -+ "minecraft:waxed_exposed_lightning_rod", -+ "minecraft:waxed_lightning_rod", -+ "minecraft:waxed_oxidized_chiseled_copper", -+ "minecraft:waxed_oxidized_copper", -+ "minecraft:waxed_oxidized_copper_bars", -+ "minecraft:waxed_oxidized_copper_bulb", -+ "minecraft:waxed_oxidized_copper_chain", -+ "minecraft:waxed_oxidized_copper_chest", -+ "minecraft:waxed_oxidized_copper_golem_statue", -+ "minecraft:waxed_oxidized_copper_grate", -+ "minecraft:waxed_oxidized_copper_lantern", -+ "minecraft:waxed_oxidized_copper_trapdoor", -+ "minecraft:waxed_oxidized_cut_copper", -+ "minecraft:waxed_oxidized_cut_copper_slab", -+ "minecraft:waxed_oxidized_cut_copper_stairs", -+ "minecraft:waxed_oxidized_double_cut_copper_slab", -+ "minecraft:waxed_oxidized_lightning_rod", -+ "minecraft:waxed_weathered_chiseled_copper", -+ "minecraft:waxed_weathered_copper", -+ "minecraft:waxed_weathered_copper_bars", -+ "minecraft:waxed_weathered_copper_bulb", -+ "minecraft:waxed_weathered_copper_chain", -+ "minecraft:waxed_weathered_copper_chest", -+ "minecraft:waxed_weathered_copper_golem_statue", -+ "minecraft:waxed_weathered_copper_grate", -+ "minecraft:waxed_weathered_copper_lantern", -+ "minecraft:waxed_weathered_copper_trapdoor", -+ "minecraft:waxed_weathered_cut_copper", -+ "minecraft:waxed_weathered_cut_copper_slab", -+ "minecraft:waxed_weathered_cut_copper_stairs", -+ "minecraft:waxed_weathered_double_cut_copper_slab", -+ "minecraft:waxed_weathered_lightning_rod", -+ "minecraft:weathered_chiseled_copper", -+ "minecraft:weathered_copper", -+ "minecraft:weathered_copper_bars", -+ "minecraft:weathered_copper_bulb", -+ "minecraft:weathered_copper_chain", -+ "minecraft:weathered_copper_chest", -+ "minecraft:weathered_copper_golem_statue", -+ "minecraft:weathered_copper_grate", -+ "minecraft:weathered_copper_lantern", -+ "minecraft:weathered_copper_trapdoor", -+ "minecraft:weathered_cut_copper", -+ "minecraft:weathered_cut_copper_slab", -+ "minecraft:weathered_cut_copper_stairs", -+ "minecraft:weathered_double_cut_copper_slab", -+ "minecraft:weathered_lightning_rod", -+ "minecraft:web", -+ "minecraft:weeping_vines", -+ "minecraft:wet_sponge", -+ "minecraft:white_candle", -+ "minecraft:white_candle_cake", -+ "minecraft:white_carpet", -+ "minecraft:white_concrete", -+ "minecraft:white_concrete_powder", -+ "minecraft:white_glazed_terracotta", -+ "minecraft:white_shulker_box", -+ "minecraft:white_stained_glass", -+ "minecraft:white_stained_glass_pane", -+ "minecraft:white_terracotta", -+ "minecraft:white_tulip", -+ "minecraft:white_wool", -+ "minecraft:wildflowers", -+ "minecraft:wither_rose", -+ "minecraft:wither_skeleton_skull", -+ "minecraft:wooden_button", -+ "minecraft:wooden_pressure_plate", -+ "minecraft:yellow_candle", -+ "minecraft:yellow_candle_cake", -+ "minecraft:yellow_carpet", -+ "minecraft:yellow_concrete", -+ "minecraft:yellow_concrete_powder", -+ "minecraft:yellow_glazed_terracotta", -+ "minecraft:yellow_poplar_leaves", -+ "minecraft:yellow_shulker_box", -+ "minecraft:yellow_stained_glass", -+ "minecraft:yellow_stained_glass_pane", -+ "minecraft:yellow_terracotta", -+ "minecraft:yellow_wool", -+ "minecraft:zombie_head" -+ ], -+ "blockAliases": { -+ "minecraft:item.bed": "minecraft:bed", -+ "minecraft:item.beetroot": "minecraft:beetroot", -+ "minecraft:item.brewing_stand": "minecraft:brewing_stand", -+ "minecraft:item.cake": "minecraft:cake", -+ "minecraft:item.camera": "minecraft:camera", -+ "minecraft:item.campfire": "minecraft:campfire", -+ "minecraft:item.cauldron": "minecraft:cauldron", -+ "minecraft:item.flower_pot": "minecraft:flower_pot", -+ "minecraft:item.frame": "minecraft:frame", -+ "minecraft:item.glow_frame": "minecraft:glow_frame", -+ "minecraft:item.hopper": "minecraft:hopper", -+ "minecraft:item.kelp": "minecraft:kelp", -+ "minecraft:item.nether_sprouts": "minecraft:nether_sprouts", -+ "minecraft:item.nether_wart": "minecraft:nether_wart", -+ "minecraft:item.reeds": "minecraft:reeds", -+ "minecraft:item.soul_campfire": "minecraft:soul_campfire", -+ "minecraft:item.straw_bed": "minecraft:straw_bed", -+ "minecraft:item.wheat": "minecraft:wheat" -+ }, -+ "replacementBlockItems": [] -+ } -+} diff --git a/src/main/resources/assets/viabedrock/data/custom/block_tags.json b/src/main/resources/assets/viabedrock/data/custom/block_tags.json index fbc16b4b2..a02f9294f 100644 --- a/src/main/resources/assets/viabedrock/data/custom/block_tags.json @@ -17086,10 +13283,10 @@ index 000000000..c093868a2 +} diff --git a/src/test/java/net/raphimc/viabedrock/api/model/container/NativeContainerActionSessionTest.java b/src/test/java/net/raphimc/viabedrock/api/model/container/NativeContainerActionSessionTest.java new file mode 100644 -index 000000000..3be14d14e +index 000000000..d445537ed --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/api/model/container/NativeContainerActionSessionTest.java -@@ -0,0 +1,128 @@ +@@ -0,0 +1,132 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -17154,11 +13351,11 @@ index 000000000..3be14d14e + sent.incrementAndGet(); + requests.getRequest(request.requestId()).capturePrediction(inventory); + }; -+ check(!session.autoPlaceOne(user, 7, 8, 0, 9, item -> 64, sender)); -+ check(!session.autoPlaceOne(user, 6, 9, 0, 9, item -> 64, sender)); -+ check(!session.autoPlaceOne(user, 6, 8, 0, 26, item -> 64, sender)); ++ check(!session.autoPlaceOne(user, 7, 8, 0, 9, item -> 64, NativeContainerActionSessionTest::matches, sender)); ++ check(!session.autoPlaceOne(user, 6, 9, 0, 9, item -> 64, NativeContainerActionSessionTest::matches, sender)); ++ check(!session.autoPlaceOne(user, 6, 8, 0, 26, item -> 64, NativeContainerActionSessionTest::matches, sender)); + org.mockito.Mockito.when(game.isInventoryServerAuthoritative()).thenReturn(false); -+ check(!session.autoPlaceOne(user, 6, 8, 0, 9, item -> 64, sender)); ++ check(!session.autoPlaceOne(user, 6, 8, 0, 9, item -> 64, NativeContainerActionSessionTest::matches, sender)); + org.mockito.Mockito.when(game.isInventoryServerAuthoritative()).thenReturn(true); + for (final Integer networkId : java.util.Arrays.asList(null, 0, -3)) { + final var unresolved = item(4, 1008); @@ -17166,20 +13363,20 @@ index 000000000..3be14d14e + requests.setPredictingClick(true); + menu.setItem(9, unresolved); + requests.setPredictingClick(false); -+ check(!session.autoPlaceOne(user, 6, 8, 0, 9, item -> 64, sender)); ++ check(!session.autoPlaceOne(user, 6, 8, 0, 9, item -> 64, NativeContainerActionSessionTest::matches, sender)); + check(sent.get() == 0 && !requests.hasPending() && !requests.isPredictingClick()); + check(menu.getItem(9).amount() == 4 && inventory.getInventoryContainer().getItem(0).isEmpty()); + } + requests.setPredictingClick(true); + menu.setItem(9, item(4, 1008)); + requests.setPredictingClick(false); -+ check(session.autoPlaceOne(user, 6, 8, 0, 9, item -> 64, sender)); ++ check(session.autoPlaceOne(user, 6, 8, 0, 9, item -> 64, NativeContainerActionSessionTest::matches, sender)); + check( + menu.getItem(9).amount() == 3 + && inventory.getInventoryContainer().getItem(0).amount() == 1 + && inventory.getHudContainer().getItem(0).amount() == 4 + && sent.get() == 1); -+ check(!session.autoPlaceOne(user, 6, 8, 0, 9, item -> 64, sender)); ++ check(!session.autoPlaceOne(user, 6, 8, 0, 9, item -> 64, NativeContainerActionSessionTest::matches, sender)); + requests.removeRequest(-3); + try { + session.autoPlaceOne( @@ -17188,7 +13385,7 @@ index 000000000..3be14d14e + 8, + 0, + 9, -+ item -> 64, ++ item -> 64, NativeContainerActionSessionTest::matches, + request -> { + throw new IllegalStateException("Transport failed"); + }); @@ -17202,10 +13399,14 @@ index 000000000..3be14d14e + } + inventory.setCurrentContainerClosed(false); + inventory.setCurrentContainer(new ChestContainer(user, (byte) 6, null, null, 27)); -+ check(!session.autoPlaceOne(user, 6, 8, 0, 9, item -> 64, sender)); ++ check(!session.autoPlaceOne(user, 6, 8, 0, 9, item -> 64, NativeContainerActionSessionTest::matches, sender)); + check(sent.get() == 1 && inventory.getHudContainer().getItem(0).netId() == 5001); + } + ++ private static boolean matches(final BedrockItem query, final BedrockItem candidate) { ++ return NativeItemStackMatcher.matches(query, candidate, ignored -> null, ignored -> Set.of()); ++ } ++ + private static BedrockItem item(final int count, final int netId) { + final var item = new BedrockItem(1); + item.setAmount(count); @@ -17220,10 +13421,10 @@ index 000000000..3be14d14e +} diff --git a/src/test/java/net/raphimc/viabedrock/api/model/container/NativeContainerAutoPlacementTest.java b/src/test/java/net/raphimc/viabedrock/api/model/container/NativeContainerAutoPlacementTest.java new file mode 100644 -index 000000000..35a42fdf6 +index 000000000..9334cab2e --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/api/model/container/NativeContainerAutoPlacementTest.java -@@ -0,0 +1,165 @@ +@@ -0,0 +1,172 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -17231,6 +13432,7 @@ index 000000000..35a42fdf6 + */ +package net.raphimc.viabedrock.api.model.container; + ++import java.util.Set; +import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ContainerType; +import net.raphimc.viabedrock.api.model.container.block.FurnaceContainer; +import net.raphimc.viabedrock.api.model.container.block.Generic3x3Container; @@ -17251,32 +13453,32 @@ index 000000000..35a42fdf6 + source.items[9] = item(4, 1008); + inventory.put(1, item(63, 2012)); + final var cursor = item(4, 5001); -+ final var plan = NativeContainerAutoPlacement.plan(binding, source, 7, 8, 9, inventory, 64); -+ check(plan.destinationSlot() == 1 && plan.action().count() == 1); ++ final var plan = NativeContainerAutoPlacement.plan(binding, source, 7, 8, 9, inventory, 64, NativeContainerAutoPlacementTest::matches); ++ check(plan.transfer().destinationSlot() == 1 && plan.action().count() == 1); + check( + plan.action().source().stackNetworkId() == 1008 + && plan.action().destination().stackNetworkId() == 2012); + check( + plan.action().destination().container().name() + == ContainerEnumName.CombinedHotbarAndInventoryContainer); -+ check(plan.sourceAfter().amount() == 3 && plan.destinationAfter().amount() == 64); ++ check(plan.transfer().sourceAfter().amount() == 3 && plan.transfer().destinationAfter().amount() == 64); + check( + source.getItem(9).amount() == 4 + && inventory.getItem(1).amount() == 63 + && cursor.amount() == 4); + inventory.put(1, item(64, 2012)); + final var emptyPlan = -+ NativeContainerAutoPlacement.plan(binding, source, 7, 8, 9, inventory, 64); ++ NativeContainerAutoPlacement.plan(binding, source, 7, 8, 9, inventory, 64, NativeContainerAutoPlacementTest::matches); + check( -+ emptyPlan.destinationSlot() == 0 ++ emptyPlan.transfer().destinationSlot() == 0 + && emptyPlan.action().destination().stackNetworkId() == 0); + inventory.forbid(0); + check( -+ NativeContainerAutoPlacement.plan(binding, source, 7, 8, 9, inventory, 64) -+ .destinationSlot() ++ NativeContainerAutoPlacement.plan(binding, source, 7, 8, 9, inventory, 64, NativeContainerAutoPlacementTest::matches) ++ .transfer().destinationSlot() + == 2); + Arrays.fill(inventory.items, item(64, 2012)); -+ check(NativeContainerAutoPlacement.plan(binding, source, 7, 8, 9, inventory, 64) == null); ++ check(NativeContainerAutoPlacement.plan(binding, source, 7, 8, 9, inventory, 64, NativeContainerAutoPlacementTest::matches) == null); + reject( + () -> + NativeContainerAutoPlacement.plan( @@ -17286,10 +13488,10 @@ index 000000000..35a42fdf6 + 8, + 9, + inventory, -+ 64)); -+ reject(() -> NativeContainerAutoPlacement.plan(binding, source, 8, 8, 9, inventory, 64)); -+ reject(() -> NativeContainerAutoPlacement.plan(binding, source, 7, 9, 9, inventory, 64)); -+ reject(() -> NativeContainerAutoPlacement.plan(binding, source, 7, 8, 27, inventory, 64)); ++ 64, NativeContainerAutoPlacementTest::matches)); ++ reject(() -> NativeContainerAutoPlacement.plan(binding, source, 8, 8, 9, inventory, 64, NativeContainerAutoPlacementTest::matches)); ++ reject(() -> NativeContainerAutoPlacement.plan(binding, source, 7, 9, 9, inventory, 64, NativeContainerAutoPlacementTest::matches)); ++ reject(() -> NativeContainerAutoPlacement.plan(binding, source, 7, 8, 27, inventory, 64, NativeContainerAutoPlacementTest::matches)); + } + + @org.junit.jupiter.api.Test @@ -17301,6 +13503,8 @@ index 000000000..35a42fdf6 + new ChestContainer(null, (byte) 6, + ContainerType.MINECART_CHEST, null, null, 27), + new HopperContainer(null, (byte) 6, null, null), ++ new HopperContainer(null, (byte) 6, ContainerType.MINECART_HOPPER, null, null), ++ new HopperContainer(null, (byte) 6, ContainerType.CONTAINER, null, null), + new Generic3x3Container(null, (byte) 6, + ContainerType.DISPENSER, null, null), + new Generic3x3Container(null, (byte) 6, @@ -17309,15 +13513,19 @@ index 000000000..35a42fdf6 + source.items[0] = item(4, 1008); + final var inventory = new Player(); + final var binding = new NativeContainerAutoPlacement.Binding(source, 7, 8); -+ final var plan = NativeContainerAutoPlacement.plan(binding, source, 7, 8, 0, inventory, 64); ++ final var plan = NativeContainerAutoPlacement.plan(binding, source, 7, 8, 0, inventory, 64, NativeContainerAutoPlacementTest::matches); + org.junit.jupiter.api.Assertions.assertEquals(source.requestSlotInfo(0, 1008), plan.action().source()); -+ check(plan.sourceAfter().amount() == 3 && plan.destinationAfter().amount() == 1); ++ check(plan.transfer().sourceAfter().amount() == 3 && plan.transfer().destinationAfter().amount() == 1); + check(source.getItem(0).amount() == 4 && inventory.getItem(0).isEmpty()); -+ reject(() -> NativeContainerAutoPlacement.plan(binding, source, 7, 8, source.size(), inventory, 64)); ++ reject(() -> NativeContainerAutoPlacement.plan(binding, source, 7, 8, source.size(), inventory, 64, NativeContainerAutoPlacementTest::matches)); + } + final var furnace = new FurnaceContainer(null, (byte) 6, null, null); + reject(() -> NativeContainerAutoPlacement.plan(new NativeContainerAutoPlacement.Binding(furnace, 7, 8), -+ furnace, 7, 8, 0, new Player(), 64)); ++ furnace, 7, 8, 0, new Player(), 64, NativeContainerAutoPlacementTest::matches)); ++ } ++ ++ private static boolean matches(final BedrockItem query, final BedrockItem candidate) { ++ return NativeItemStackMatcher.matches(query, candidate, ignored -> null, ignored -> Set.of()); + } + + private static BedrockItem item(final int count, final int networkId) { @@ -17337,19 +13545,19 @@ index 000000000..35a42fdf6 + unresolved.setNetId(networkId); + source.items[0] = unresolved; + org.junit.jupiter.api.Assertions.assertNull( -+ NativeContainerAutoPlacement.plan(binding, source, 7, 8, 0, inventory, 64)); ++ NativeContainerAutoPlacement.plan(binding, source, 7, 8, 0, inventory, 64, NativeContainerAutoPlacementTest::matches)); + check(source.getItem(0).amount() == 4 && inventory.getItem(0).isEmpty()); + source.items[0] = item(4, 1008); + inventory.put(0, unresolved); -+ final var plan = NativeContainerAutoPlacement.plan(binding, source, 7, 8, 0, inventory, 64); -+ check(plan.destinationSlot() == 1 && plan.action().destination().stackNetworkId() == 0); ++ final var plan = NativeContainerAutoPlacement.plan(binding, source, 7, 8, 0, inventory, 64, NativeContainerAutoPlacementTest::matches); ++ check(plan.transfer().destinationSlot() == 1 && plan.action().destination().stackNetworkId() == 0); + check(inventory.getItem(0).amount() == 4 && inventory.getItem(1).isEmpty()); + inventory.put(0, BedrockItem.empty()); + } + source.items[0] = item(255, Integer.MAX_VALUE); + inventory.put(0, item(254, Integer.MAX_VALUE - 1)); -+ final var plan = NativeContainerAutoPlacement.plan(binding, source, 7, 8, 0, inventory, 255); -+ check(plan.action().count() == 1 && plan.sourceAfter().amount() == 254 && plan.destinationAfter().amount() == 255); ++ final var plan = NativeContainerAutoPlacement.plan(binding, source, 7, 8, 0, inventory, 255, NativeContainerAutoPlacementTest::matches); ++ check(plan.action().count() == 1 && plan.transfer().sourceAfter().amount() == 254 && plan.transfer().destinationAfter().amount() == 255); + check(source.getItem(0).amount() == 255 && inventory.getItem(0).amount() == 254); + } + @@ -17389,6 +13597,29 @@ index 000000000..35a42fdf6 + } + +} +diff --git a/src/test/java/net/raphimc/viabedrock/api/model/container/NativeContainerLegacyActionsTest.java b/src/test/java/net/raphimc/viabedrock/api/model/container/NativeContainerLegacyActionsTest.java +index 88dd3e3f2..f55ea640f 100644 +--- a/src/test/java/net/raphimc/viabedrock/api/model/container/NativeContainerLegacyActionsTest.java ++++ b/src/test/java/net/raphimc/viabedrock/api/model/container/NativeContainerLegacyActionsTest.java +@@ -30,7 +30,7 @@ import net.raphimc.viabedrock.protocol.storage.NativeContainerUiState; + import net.raphimc.viabedrock.protocol.rewriter.ItemRewriter; + import net.raphimc.viabedrock.protocol.rewriter.InventoryTransactionRewriter; + import net.raphimc.viabedrock.protocol.types.item.NetworkItemStackDescriptorType; +-import com.viaversion.viaversion.libs.fastutil.ints.Int2ObjectOpenHashMap; ++import net.raphimc.viabedrock.protocol.types.item.NetworkItemStackFixtures; + import io.netty.buffer.Unpooled; + import org.junit.jupiter.api.Test; + import static org.junit.jupiter.api.Assertions.*; +@@ -77,8 +77,7 @@ final class NativeContainerLegacyActionsTest { + final var itemRewriter = mock(ItemRewriter.class); + when(fixture.user.get(ItemRewriter.class)).thenReturn(itemRewriter); + when(itemRewriter.newItemType()).thenReturn( +- new NetworkItemStackDescriptorType(0, +- new Int2ObjectOpenHashMap<>(), false)); ++ new NetworkItemStackDescriptorType(0, false, NetworkItemStackFixtures.CONTEXT)); + final var codec = new InventoryTransactionRewriter(fixture.user) + .getInventoryTransactionType(); + final var sent = new AtomicReference(); diff --git a/src/test/java/net/raphimc/viabedrock/api/model/container/NativeTransferQuantityTest.java b/src/test/java/net/raphimc/viabedrock/api/model/container/NativeTransferQuantityTest.java new file mode 100644 index 000000000..be76ce6df @@ -17751,14 +13982,17 @@ index 000000000..33741180e +} diff --git a/src/test/java/net/raphimc/viabedrock/api/resourcepack/definition/ItemDefinitionsTest.java b/src/test/java/net/raphimc/viabedrock/api/resourcepack/definition/ItemDefinitionsTest.java new file mode 100644 -index 000000000..5b6701866 +index 000000000..2230c942d --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/api/resourcepack/definition/ItemDefinitionsTest.java -@@ -0,0 +1,45 @@ +@@ -0,0 +1,86 @@ +package net.raphimc.viabedrock.api.resourcepack.definition; + +import com.viaversion.nbt.tag.CompoundTag; ++import com.viaversion.viaversion.libs.gson.JsonObject; +import com.viaversion.viaversion.libs.gson.JsonParser; ++import net.raphimc.viabedrock.api.resourcepack.ResourcePack; ++import net.raphimc.viabedrock.api.resourcepack.content.InMemoryContent; +import net.raphimc.viabedrock.protocol.storage.ResourcePackStorage; +import org.junit.jupiter.api.Test; + @@ -17799,6 +14033,44 @@ index 000000000..5b6701866 + assertEquals("minecart_furnace", definitions.get("geysermc:furnace_minecart").iconComponent()); + } + ++ @Test ++ void emptyPackDisplayNameOverridesLowerPackWithoutHidingOrdinaryName() { ++ final String identifier = "custom:probe"; ++ final ResourcePackStorage packs = mock(ResourcePackStorage.class); ++ for (final boolean objectForm : List.of(false, true)) { ++ for (final String value : List.of("", " ", identifier)) { ++ final ResourcePack lower = itemPack(identifier, identifier, false); ++ final ResourcePack upper = itemPack(identifier, value, objectForm); ++ when(packs.getPackStackBottomToTop()).thenReturn(List.of(lower, upper)); ++ final var definition = new ItemDefinitions(packs).get(identifier); ++ assertEquals(value.isEmpty() ? null : value, definition.displayNameComponent()); ++ } ++ } ++ } ++ ++ private static ResourcePack itemPack(final String identifier, final String name, final boolean objectForm) { ++ final var content = new InMemoryContent(); ++ final JsonObject description = new JsonObject(); ++ description.addProperty("identifier", identifier); ++ final JsonObject components = new JsonObject(); ++ if (objectForm) { ++ final JsonObject value = new JsonObject(); ++ value.addProperty("value", name); ++ components.add("minecraft:display_name", value); ++ } else { ++ components.addProperty("minecraft:display_name", name); ++ } ++ final JsonObject item = new JsonObject(); ++ item.add("description", description); ++ item.add("components", components); ++ final JsonObject file = new JsonObject(); ++ file.add("minecraft:item", item); ++ content.putJson("items/probe.json", file); ++ final ResourcePack pack = mock(ResourcePack.class); ++ when(pack.content()).thenReturn(content); ++ return pack; ++ } ++ +} diff --git a/src/test/java/net/raphimc/viabedrock/api/util/ContainerContentPacketTest.java b/src/test/java/net/raphimc/viabedrock/api/util/ContainerContentPacketTest.java new file mode 100644 @@ -18288,46 +14560,38 @@ index 000000000..80861941e + +} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/model/ItemRegistryTest.java b/src/test/java/net/raphimc/viabedrock/protocol/model/ItemRegistryTest.java -new file mode 100644 -index 000000000..39e32e088 ---- /dev/null +index eaf0eb96f..91dd1e997 100644 +--- a/src/test/java/net/raphimc/viabedrock/protocol/model/ItemRegistryTest.java +++ b/src/test/java/net/raphimc/viabedrock/protocol/model/ItemRegistryTest.java -@@ -0,0 +1,618 @@ -+/* -+ * 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.ByteTag; +@@ -6,13 +6,24 @@ + package net.raphimc.viabedrock.protocol.model; + + import com.viaversion.nbt.tag.ByteTag; +import net.raphimc.viabedrock.api.chunk.blockstate.BlockItemDescriptor; +import net.raphimc.viabedrock.protocol.model.recipe.ItemRecipeKey; -+import com.viaversion.nbt.tag.CompoundTag; -+import com.viaversion.nbt.tag.ListTag; -+import com.viaversion.nbt.tag.LongTag; + import com.viaversion.nbt.tag.CompoundTag; + import com.viaversion.nbt.tag.ListTag; + import com.viaversion.nbt.tag.LongTag; +import com.viaversion.viaversion.libs.gson.JsonParser; -+import io.netty.buffer.Unpooled; -+import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ItemVersion; -+import net.raphimc.viabedrock.protocol.types.BedrockTypes; + import io.netty.buffer.Unpooled; + import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ItemVersion; + import net.raphimc.viabedrock.protocol.types.BedrockTypes; +import net.raphimc.viabedrock.protocol.types.item.BedrockItemType; +import net.raphimc.viabedrock.protocol.types.item.NetworkItemStackDescriptorType; +import net.raphimc.viabedrock.protocol.types.item.NetworkItemStackContext; +import net.raphimc.viabedrock.protocol.types.item.NetworkItemStackFixtures; -+import org.junit.jupiter.api.Test; + import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import java.nio.file.Files; +import java.nio.file.Path; -+ -+import java.util.List; -+import java.util.Map; -+import java.util.Set; -+ -+import static org.junit.jupiter.api.Assertions.*; -+ -+class ItemRegistryTest { -+ + + import java.util.List; + import java.util.Map; +@@ -55,6 +66,198 @@ class ItemRegistryTest { + assertEquals("", new ItemRegistry(updates.getFirst()).customName(book, data)); + } + + @Test + @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_NAMED_MATCHING", matches = ".+") + void namedRecipeMatchingAgreesWithNativeResolutionAndBlockComparison() throws Exception { @@ -18520,89 +14784,13 @@ index 000000000..39e32e088 + assertEquals(new ItemRecipeKey(2, (short) 3), registry.recipeKey(new BedrockItem(2, (short) 3, (byte) 1), block)); + } + -+ @Test -+ void stackSizeConstructionFinishesBeforeOrderedUpdatesIncludingInvalidIds() { -+ final String identifier = "test:component"; -+ final var compiled = new CompoundTag(); -+ compiled.putInt("max_stack_size", 16); -+ final var first = new CompoundTag(); -+ first.put("item_properties", compiled); -+ final var named = new CompoundTag(); -+ named.putByte("value", (byte) 32); -+ final var second = new CompoundTag(); -+ second.put("minecraft:max_stack_size", named); -+ final var last = new CompoundTag(); -+ last.put("item_properties", new CompoundTag()); -+ final var registry = new ItemRegistry(new ItemEntry[]{ -+ componentEntry(identifier, 20, ItemVersion.None, first), -+ entry(identifier, 21, false, ItemVersion.DataDriven), -+ componentEntry(identifier, -1, ItemVersion.None, second), -+ componentEntry(identifier, 22, ItemVersion.DataDriven, last), -+ entry(identifier, 23, false, ItemVersion.None)}); -+ final var sizes = registry.stackingProperties(); -+ assertEquals(Map.of(identifier, new ComponentItemStackingProperties(0, 32, false, false)), sizes); -+ assertEquals(23, registry.ids().get(identifier)); -+ assertThrows(UnsupportedOperationException.class, sizes::clear); -+ } -+ -+ @Test -+ void runtimeFallbackDoesNotReceiveEarlierUpdatesAndConstructedFactoriesKeepTheSize() { -+ final var properties = new CompoundTag(); -+ properties.putInt("max_stack_size", 1); -+ final var components = new CompoundTag(); -+ components.put("item_properties", properties); -+ final var registry = new ItemRegistry(new ItemEntry[]{ -+ componentEntry("test:fallback", 20, ItemVersion.None, components), -+ entry("test:fallback", 21, false, ItemVersion.None), -+ componentEntry("test:legacy", 22, ItemVersion.DataDriven, components), -+ entry("test:legacy", 23, true, ItemVersion.Legacy), -+ componentEntry("test:removed", 24, ItemVersion.DataDriven, components), -+ entry("test:owner", 24, false, ItemVersion.DataDriven), -+ entry("test:invalid", -1, true, ItemVersion.DataDriven)}); -+ assertEquals(Map.of("test:fallback", ComponentItemStackingProperties.DEFAULT, "test:owner", ComponentItemStackingProperties.DEFAULT, "test:legacy", new ComponentItemStackingProperties(1, null, false, false)), registry.stackingProperties()); -+ } -+ -+ @Test -+ void firstComponentFactoryReceivesLaterLegacyUpdatesUsingItsOwnLayout() { -+ final var compiled = new CompoundTag(); -+ compiled.putInt("max_stack_size", 1); -+ final var componentUpdate = new CompoundTag(); -+ componentUpdate.put("item_properties", compiled); -+ componentUpdate.put("minecraft:durability", new CompoundTag()); -+ final var legacyUpdate = new CompoundTag(); -+ legacyUpdate.putInt("minecraft:max_stack_size", 16); -+ legacyUpdate.putByte("minecraft:stacked_by_data", (byte) 1); -+ legacyUpdate.putInt("minecraft:max_damage", 65537); -+ final var registry = new ItemRegistry(new ItemEntry[]{ -+ componentEntry("test:item", 20, ItemVersion.DataDriven, componentUpdate), -+ componentEntry("test:item", -1, ItemVersion.None, legacyUpdate), -+ entry("test:item", 21, true, ItemVersion.Legacy), -+ entry("test:item", 22, false, ItemVersion.None)}); -+ assertEquals(Map.of("test:item", new ComponentItemStackingProperties(1, null, false, true)), registry.stackingProperties()); -+ assertEquals(Set.of("test:item"), registry.componentItems()); -+ assertEquals(22, registry.ids().get("test:item")); -+ } -+ -+ @Test -+ void firstLegacyFactorySurvivesLaterComponentRegistrationAndKeepsItsLayout() { -+ final var legacy = new CompoundTag(); -+ legacy.putInt("minecraft:max_stack_size", 32); -+ legacy.putByte("minecraft:stacked_by_data", (byte) 1); -+ legacy.putInt("minecraft:max_damage", 20); -+ final var compiled = new CompoundTag(); -+ compiled.putInt("max_stack_size", 16); -+ final var component = new CompoundTag(); -+ component.put("item_properties", compiled); -+ component.put("minecraft:durability", new CompoundTag()); -+ final var registry = new ItemRegistry(new ItemEntry[]{ -+ componentEntry("test:item", 20, ItemVersion.Legacy, legacy), -+ componentEntry("test:item", 21, ItemVersion.DataDriven, component)}); -+ assertEquals(Map.of("test:item", new LegacyItemStackingProperties(32, true, (short) 20)), -+ registry.stackingProperties()); -+ assertTrue(registry.componentItems().isEmpty()); -+ assertEquals(21, registry.ids().get("test:item")); -+ } -+ + @Test + void stackSizeConstructionFinishesBeforeOrderedUpdatesIncludingInvalidIds() { + final String identifier = "test:component"; +@@ -138,6 +341,19 @@ class ItemRegistryTest { + assertEquals(21, registry.ids().get("test:item")); + } + + @Test + void legacyRegistrationRetainsTheBuiltinBlockAndComponentConstructionReplacesIt() { + final var registry = new ItemRegistry(new ItemEntry[]{ @@ -18616,171 +14804,13 @@ index 000000000..39e32e088 + assertEquals(new ItemCookingProperties(false, "minecraft:camera", false), cooking.get("minecraft:item.camera")); + } + -+ @Test -+ void lateOrdinaryFallbackConstructionDoesNotConsumeEarlierLegacyUpdates() { -+ final var update = new CompoundTag(); -+ update.putInt("minecraft:max_stack_size", 16); -+ update.putInt("minecraft:max_damage", 64); -+ final var data = new CompoundTag(); -+ data.put("components", update); -+ final var registry = new ItemRegistry(new ItemEntry[]{new ItemEntry("test:item", 20, false, ItemVersion.None, data)}); -+ assertEquals(Map.of("test:item", LegacyItemStackingProperties.DEFAULT), registry.stackingProperties()); -+ } -+ -+ @Test -+ void networkConstructionReplacesBuiltinStackSizeState() { -+ final var registry = new ItemRegistry(new ItemEntry[]{ -+ entry("minecraft:apple", 20, true, ItemVersion.None), -+ entry("minecraft:stone", 21, true, ItemVersion.None), -+ entry("minecraft:diamond_sword", 22, false, ItemVersion.DataDriven)}); -+ assertEquals(Map.of("minecraft:apple", LegacyItemStackingProperties.DEFAULT, -+ "minecraft:stone", LegacyItemStackingProperties.DEFAULT, -+ "minecraft:diamond_sword", ComponentItemStackingProperties.DEFAULT), registry.stackingProperties()); -+ } -+ -+ @Test -+ void legacyRegistrationKeepsBuiltinDurabilityAndSubtypeDefaults() { -+ final var registry = new ItemRegistry(new ItemEntry[]{ -+ entry("minecraft:netherite_sword", 20, false, ItemVersion.Legacy), -+ entry("minecraft:fishing_rod", 21, false, ItemVersion.Legacy), -+ entry("minecraft:arrow", 22, false, ItemVersion.Legacy)}); -+ final var properties = registry.stackingProperties(); -+ assertEquals(new LegacyItemStackingProperties(1, false, (short) 2031), properties.get("minecraft:netherite_sword")); -+ assertEquals(new ComponentItemStackingProperties(1, null, false, true), properties.get("minecraft:fishing_rod")); -+ assertEquals(new ComponentItemStackingProperties(64, null, true, false), properties.get("minecraft:arrow")); -+ assertEquals(Set.of("minecraft:fishing_rod", "minecraft:arrow"), registry.componentItems()); -+ } -+ -+ @Test -+ void builtinOverridesSurviveNoneUpdatesButExplicitComponentConstructionReplacesThem() { -+ final var update = new CompoundTag(); -+ update.putInt("minecraft:max_stack_size", 32); -+ update.putByte("minecraft:stacked_by_data", (byte) 0); -+ final var registry = new ItemRegistry(new ItemEntry[]{ -+ componentEntry("minecraft:birch_boat", 20, ItemVersion.None, update), -+ componentEntry("minecraft:bucket", 21, ItemVersion.Legacy, update), -+ componentEntry("minecraft:milk_bucket", 22, ItemVersion.None, update), -+ entry("minecraft:spruce_boat", 23, false, ItemVersion.DataDriven), -+ entry("minecraft:lava_bucket", 24, false, ItemVersion.DataDriven)}); -+ final var properties = registry.stackingProperties(); -+ assertEquals(32, properties.get("minecraft:birch_boat").maximum()); -+ assertTrue(properties.get("minecraft:birch_boat").stackedByData()); -+ assertEquals(16, properties.get("minecraft:bucket").maximum()); -+ assertFalse(properties.get("minecraft:bucket").stackedByData()); -+ assertEquals(1, properties.get("minecraft:milk_bucket").maximum()); -+ assertFalse(properties.get("minecraft:milk_bucket").stackedByData()); -+ assertEquals(ComponentItemStackingProperties.DEFAULT, properties.get("minecraft:spruce_boat")); -+ assertEquals(ComponentItemStackingProperties.DEFAULT, properties.get("minecraft:lava_bucket")); -+ } -+ -+ private static ItemEntry componentEntry(final String identifier, final int id, final ItemVersion version, final CompoundTag components) { -+ final var data = new CompoundTag(); -+ data.put("components", components); -+ return new ItemEntry(identifier, id, true, version, data); -+ } -+ -+ @Test -+ void idReplacementRemovesThePreviousDefinitionsAndComponentMembership() { -+ final var first = entry("test:first", -7, true, ItemVersion.DataDriven); -+ final var moved = entry(first.identifier(), 20, false, ItemVersion.None); -+ final var replacement = entry("test:replacement", 20, false, ItemVersion.Legacy); -+ final var reused = entry("test:reused", -7, true, ItemVersion.None); -+ final var registry = new ItemRegistry(new ItemEntry[]{first, moved, replacement, reused}); -+ -+ assertNull(registry.definition(first.identifier())); -+ assertSame(replacement, registry.definition(20)); -+ assertSame(reused, registry.definition(-7)); -+ assertEquals(List.of(replacement, reused), registry.entries()); -+ assertEquals(List.of(first, moved, replacement, reused), registry.registrations()); -+ assertEquals(Map.of(replacement.identifier(), 20, reused.identifier(), -7), registry.ids()); -+ assertEquals(Set.of(reused.identifier()), registry.componentItems()); -+ assertThrows(UnsupportedOperationException.class, () -> registry.ids().inverse().remove(20)); -+ assertThrows(UnsupportedOperationException.class, () -> registry.entries().clear()); -+ assertThrows(UnsupportedOperationException.class, () -> registry.componentItems().clear()); -+ } -+ -+ @Test -+ void repeatedNamesKeepOnlyTheLastOwnerAndItsActualVersion() { -+ final var component = entry("test:item", 50, false, ItemVersion.DataDriven); -+ final var legacy = entry(component.identifier(), 51, true, ItemVersion.Legacy); -+ final ItemEntry[] input = {component, legacy}; -+ final var registry = new ItemRegistry(input); -+ input[1] = component; -+ -+ assertNull(registry.definition(50)); -+ assertSame(legacy, registry.definition(51)); -+ assertSame(legacy, registry.definition(legacy.identifier())); -+ assertEquals(List.of(legacy), registry.entries()); -+ assertEquals(List.of(component, legacy), registry.registrations()); -+ assertThrows(UnsupportedOperationException.class, () -> registry.registrations().clear()); -+ assertEquals(Set.of("test:item"), registry.componentItems()); -+ } -+ -+ @Test -+ void runtimeIdFallbackConstructsUnknownReferences() { -+ final var builtin = entry("minecraft:hopper", 50, true, ItemVersion.None); -+ final var reference = entry("test:unknown", 51, true, ItemVersion.None); -+ final var legacy = entry("test:legacy", 52, true, ItemVersion.Legacy); -+ final var component = entry("test:component", 53, false, ItemVersion.DataDriven); -+ final var registry = new ItemRegistry(new ItemEntry[]{builtin, reference, legacy, component}); -+ -+ assertSame(builtin, registry.definition(50)); -+ assertSame(reference, registry.definition(51)); -+ assertSame(legacy, registry.definition(52)); -+ assertSame(component, registry.definition(53)); -+ assertNull(registry.definition(54)); -+ -+ final var laterReference = entry(legacy.identifier(), 54, false, ItemVersion.None); -+ final var replacedRegistry = new ItemRegistry(new ItemEntry[]{legacy, laterReference}); -+ assertNull(replacedRegistry.definition(52)); -+ assertSame(laterReference, replacedRegistry.definition(54)); -+ } -+ -+ @Test -+ void completeNetworkDefinitionsRemainStableAcrossInputAndConsumerMutation() { -+ final var flags = new CompoundTag(); -+ flags.put("replaceBlockItem", new ByteTag((byte) 255)); -+ final var opaqueValues = new ListTag<>(LongTag.class); -+ opaqueValues.add(new LongTag(Long.MIN_VALUE)); -+ flags.put("opaque", opaqueValues); -+ final var components = new CompoundTag(); -+ components.put("minecraft:block_placer", flags); -+ final var data = new CompoundTag(); -+ data.put("components", components); -+ final var definition = new ItemEntry("test:item", 19, true, ItemVersion.DataDriven, data); -+ final var registry = new ItemRegistry(new ItemEntry[]{definition}); -+ final var expected = data.copy(); -+ -+ flags.remove("replaceBlockItem"); -+ opaqueValues.getValue().clear(); -+ data.getValue().clear(); -+ definition.componentData().getValue().clear(); -+ registry.definition(19).componentData().getCompoundTag("components").getValue().clear(); -+ -+ assertEquals(expected, registry.definition(19).componentData()); -+ final var buffer = Unpooled.buffer(); -+ try { -+ BedrockTypes.ITEM_ENTRY.write(buffer, registry.definition(19)); -+ final var decoded = BedrockTypes.ITEM_ENTRY.read(buffer); -+ assertEquals(definition, decoded); -+ assertEquals(0, buffer.readableBytes()); -+ decoded.componentData().getValue().clear(); -+ assertEquals(expected, decoded.componentData()); -+ } finally { -+ buffer.release(); -+ } -+ } -+ -+ @Test -+ void missingDataAndUnknownIdsDoNotAcquireDefinitions() { -+ final var definition = entry("test:item", 32767, false, ItemVersion.None); -+ final var registry = new ItemRegistry(new ItemEntry[]{definition}); -+ assertNull(registry.definition(32767).componentData()); -+ assertNull(registry.definition(0)); -+ assertNull(registry.definition("test:missing")); -+ assertTrue(new ItemRegistry(new ItemEntry[0]).entries().isEmpty()); -+ } -+ + @Test + void lateOrdinaryFallbackConstructionDoesNotConsumeEarlierLegacyUpdates() { + final var update = new CompoundTag(); +@@ -303,6 +519,85 @@ class ItemRegistryTest { + assertTrue(new ItemRegistry(new ItemEntry[0]).entries().isEmpty()); + } + + @Test + void cookingTraitsReplayBothFactorySchemasAcrossIdChanges() { + final String componentItem = "minecraft:hopper"; @@ -18860,34 +14890,13 @@ index 000000000..39e32e088 + } + } + -+ @Test -+ void legacyReferencesPreserveExistingBuiltinFactoriesAndTheirComponentLayout() { -+ final String identifier = "minecraft:hopper"; -+ final var flat = new CompoundTag(); -+ flat.putInt("minecraft:max_stack_size", 32); -+ final var registry = new ItemRegistry(new ItemEntry[]{componentEntry(identifier, 20, ItemVersion.Legacy, flat)}); -+ assertEquals(Set.of(identifier), registry.componentItems()); -+ assertEquals(Map.of(identifier, ComponentItemStackingProperties.DEFAULT), registry.stackingProperties()); -+ } -+ -+ @Test -+ void advertisedItemsDoNotBecomeBuiltinFactoriesBeforeNetworkConstruction() { -+ for (final boolean flag : new boolean[]{false, true}) { -+ final var properties = new CompoundTag(); -+ properties.putInt("max_stack_size", 1); -+ final var components = new CompoundTag(); -+ components.put("item_properties", properties); -+ final var data = new CompoundTag(); -+ data.put("components", components); -+ final var registry = new ItemRegistry(new ItemEntry[]{new ItemEntry("minecraft:bundle", 20, flag, ItemVersion.None, data)}); -+ assertEquals(flag ? Set.of("minecraft:bundle") : Set.of(), registry.componentItems()); -+ assertEquals(flag ? ComponentItemStackingProperties.DEFAULT : LegacyItemStackingProperties.DEFAULT, -+ registry.stackingProperties().get("minecraft:bundle")); -+ } -+ final var apple = new ItemRegistry(new ItemEntry[]{entry("minecraft:apple", 20, true, ItemVersion.None)}); -+ assertTrue(apple.componentItems().isEmpty()); -+ } -+ + @Test + void legacyReferencesPreserveExistingBuiltinFactoriesAndTheirComponentLayout() { + final String identifier = "minecraft:hopper"; +@@ -331,6 +626,24 @@ class ItemRegistryTest { + assertTrue(apple.componentItems().isEmpty()); + } + + @Test + void invalidIdEntriesCanUpdateAnExistingItemWithoutReplacingItsRuntimeId() { + final String identifier = "test:registered"; @@ -18906,11 +14915,9 @@ index 000000000..39e32e088 + registry.cookingProperties(ignored -> null).get(identifier)); + } + -+ private static ItemEntry entry(final String identifier, final int id, final boolean componentBased, final ItemVersion version) { -+ return new ItemEntry(identifier, id, componentBased, version, null); -+ } -+ -+} + private static ItemEntry entry(final String identifier, final int id, final boolean componentBased, final ItemVersion version) { + return new ItemEntry(identifier, id, componentBased, version, null); + } diff --git a/src/test/java/net/raphimc/viabedrock/protocol/model/ItemStackHeaderTest.java b/src/test/java/net/raphimc/viabedrock/protocol/model/ItemStackHeaderTest.java new file mode 100644 index 000000000..86cecb92c @@ -20632,7 +16639,7 @@ index 000000000..30faf553b + +} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/packet/NativeContainerOpeningPacketTest.java b/src/test/java/net/raphimc/viabedrock/protocol/packet/NativeContainerOpeningPacketTest.java -index b582dc508..33d6320a9 100644 +index 3472fd3b4..81ef48e17 100644 --- a/src/test/java/net/raphimc/viabedrock/protocol/packet/NativeContainerOpeningPacketTest.java +++ b/src/test/java/net/raphimc/viabedrock/protocol/packet/NativeContainerOpeningPacketTest.java @@ -18,6 +18,7 @@ import net.raphimc.viabedrock.platform.ViaBedrockPlatform; @@ -20643,15 +16650,15 @@ index b582dc508..33d6320a9 100644 import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ContainerType; import net.raphimc.viabedrock.protocol.model.PlayerAbilities; import net.raphimc.viabedrock.protocol.model.Position3f; -@@ -46,6 +47,7 @@ import java.util.List; - import java.util.Map; - import java.util.UUID; - import net.raphimc.viabedrock.api.model.NativeContainerMessage; +@@ -51,6 +52,7 @@ import net.raphimc.viabedrock.api.model.container.Container; + import net.raphimc.viabedrock.api.model.container.block.HopperContainer; + import net.raphimc.viabedrock.api.model.container.block.Generic3x3Container; + import net.raphimc.viabedrock.protocol.storage.GameSessionStorage; +import net.raphimc.viabedrock.api.model.container.block.BrewingStandContainer; import net.raphimc.viabedrock.api.resourcepack.ResourcePack; import net.raphimc.viabedrock.api.resourcepack.content.InMemoryContent; import net.raphimc.viabedrock.api.resourcepack.ui.ContainerUi; -@@ -63,6 +65,7 @@ import static org.mockito.Mockito.*; +@@ -68,6 +70,7 @@ import static org.mockito.Mockito.*; class NativeContainerOpeningPacketTest { @@ -20659,7 +16666,7 @@ index b582dc508..33d6320a9 100644 private static Field menusField; private static Object previousMenus; private static Field containersField; -@@ -70,6 +73,7 @@ class NativeContainerOpeningPacketTest { +@@ -75,6 +78,7 @@ class NativeContainerOpeningPacketTest { @BeforeAll static void loadActualJavaMenuRegistry() throws Exception { @@ -20667,7 +16674,7 @@ index b582dc508..33d6320a9 100644 final var menus = new ArrayList(); try (var reader = new InputStreamReader(NativeContainerOpeningPacketTest.class.getResourceAsStream( "/assets/viabedrock/data/java/via_mappings.json"), StandardCharsets.UTF_8)) { -@@ -87,11 +91,12 @@ class NativeContainerOpeningPacketTest { +@@ -92,11 +96,12 @@ class NativeContainerOpeningPacketTest { @AfterAll static void restoreRegistry() throws Exception { @@ -20681,8 +16688,17 @@ index b582dc508..33d6320a9 100644 final var content = new InMemoryContent(); content.putString("manifest.json", "{\"format_version\":2,\"header\":{\"uuid\":\"" + UUID.randomUUID() + "\",\"version\":[1,0,0],\"name\":\"test\"}}"); -@@ -105,13 +110,148 @@ class NativeContainerOpeningPacketTest { - return new ResourcePackStorage(List.of(new ResourcePack(content))); +@@ -117,7 +122,7 @@ class NativeContainerOpeningPacketTest { + final var user = mock(UserConnection.class); + final var channels = new ChannelStorage(); + channels.addChannels(List.of(NativeContainerMessage.CHANNEL)); +- final var resources = spy(packs()); ++ final var resources = spy(packs); + when(resources.isLoadedOnJavaClient()).thenReturn(false); + final Map, Object> storage = Map.of(ChannelStorage.class, channels, + NativeContainerUiState.class, new NativeContainerUiState(), ResourcePackStorage.class, resources); +@@ -146,13 +151,152 @@ class NativeContainerOpeningPacketTest { + } } + @Test @@ -20708,8 +16724,10 @@ index b582dc508..33d6320a9 100644 + org.junit.jupiter.api.Assumptions.assumeTrue(ContainerUi.class.getResource("/assets/viabedrock/builtin_ui.zip") != null); + final var user = mock(UserConnection.class); + final var channels = new ChannelStorage(); ++ final var resources = new ResourcePackStorage(List.of()); ++ resources.setLoadedOnJavaClient(); + final Map, Object> storage = Map.of(ChannelStorage.class, channels, -+ NativeContainerUiState.class, new NativeContainerUiState(), ResourcePackStorage.class, new ResourcePackStorage(List.of())); ++ NativeContainerUiState.class, new NativeContainerUiState(), ResourcePackStorage.class, resources); + when(user.get(any())).thenAnswer(call -> storage.get(call.getArgument(0))); + final var sent = new ArrayList(); + try (var factory = mockStatic(PacketWrapper.class)) { @@ -20763,8 +16781,10 @@ index b582dc508..33d6320a9 100644 + brewing.setData(0, 400); + brewing.setData(1, 65536); + brewing.setData(2, Integer.MAX_VALUE); ++ final var resources = new ResourcePackStorage(List.of()); ++ resources.setLoadedOnJavaClient(); + final Map, Object> storage = Map.of(ChannelStorage.class, channels, InventoryTracker.class, tracker, -+ NativeContainerUiState.class, state, ResourcePackStorage.class, new ResourcePackStorage(List.of())); ++ NativeContainerUiState.class, state, ResourcePackStorage.class, resources); + when(user.get(any())).thenAnswer(call -> storage.get(call.getArgument(0))); + final var sent = new ArrayList(); + try (var factory = mockStatic(PacketWrapper.class)) { @@ -20831,16 +16851,7 @@ index b582dc508..33d6320a9 100644 when(user.get(any())).thenAnswer(call -> storage.get(call.getArgument(0))); final List sent = new ArrayList<>(); try (var factory = mockStatic(PacketWrapper.class)) { -@@ -123,7 +263,7 @@ class NativeContainerOpeningPacketTest { - open.write(Types.VAR_INT, 7); - open.write(Types.VAR_INT, menuType); - open.write(Types.TAG, new StringTag("Actual display title")); -- InventoryPackets.sendNativeContainerPresentation(open, menuType, new ContainerUi.Title("Raw authored title", false)); -+ InventoryPackets.sendNativeContainerPresentation(open, family.equals("minecraft:generic_9x3") ? ContainerUi.Kind.CHEST_3_ROWS : ContainerUi.Kind.CHEST_6_ROWS, new ContainerUi.Title("Raw authored title", false)); - assertTrue(open.isCancelled()); - } - } -@@ -139,8 +279,9 @@ class NativeContainerOpeningPacketTest { +@@ -180,8 +324,9 @@ class NativeContainerOpeningPacketTest { assertEquals("Actual display title", ((StringTag) Types.TAG.read(open)).getValue()); assertFalse(open.isReadable()); assertEquals(ClientboundPackets26_3.CUSTOM_PAYLOAD.getId(), Types.VAR_INT.readPrimitive(payload)); @@ -20852,7 +16863,7 @@ index b582dc508..33d6320a9 100644 assertEquals(7, message.menuId()); assertEquals(index + 1, message.sequence()); assertEquals(index == 0 ? ContainerUi.Kind.CHEST_3_ROWS : ContainerUi.Kind.CHEST_6_ROWS, message.kind()); -@@ -161,14 +302,14 @@ class NativeContainerOpeningPacketTest { +@@ -202,7 +347,7 @@ class NativeContainerOpeningPacketTest { final Map, Object> storage = new HashMap<>(); storage.put(ChannelStorage.class, channels); storage.put(NativeContainerUiState.class, new NativeContainerUiState()); @@ -20861,16 +16872,16 @@ index b582dc508..33d6320a9 100644 when(user.get(any())).thenAnswer(call -> storage.get(call.getArgument(0))); final var sent = new ArrayList(); final var open = outbound(ClientboundPackets26_3.OPEN_SCREEN, user, sent); -- InventoryPackets.sendNativeContainerPresentation(open, BedrockProtocol.MAPPINGS.getJavaMenuId("minecraft:generic_9x3"), new ContainerUi.Title("Actual", false)); -+ InventoryPackets.sendNativeContainerPresentation(open, ContainerUi.Kind.CHEST_3_ROWS, new ContainerUi.Title("Actual", false)); - channels.addChannels(List.of(NativeContainerMessage.CHANNEL)); - open.write(Types.VAR_INT, 7); -- InventoryPackets.sendNativeContainerPresentation(open, BedrockProtocol.MAPPINGS.getJavaMenuId("minecraft:furnace"), new ContainerUi.Title("Actual", false)); -+ InventoryPackets.sendNativeContainerPresentation(open, null, new ContainerUi.Title("Actual", false)); - assertFalse(open.isCancelled()); - assertTrue(sent.isEmpty()); - } -@@ -176,11 +317,11 @@ class NativeContainerOpeningPacketTest { +@@ -225,7 +370,7 @@ class NativeContainerOpeningPacketTest { + final Container menu = type == ContainerType.HOPPER ? new HopperContainer(user, (byte) 7, null, null) + : new Generic3x3Container(user, (byte) 7, type, null, null); + final Map, Object> storage = Map.of(ChannelStorage.class, channels, NativeContainerUiState.class, state, +- ResourcePackStorage.class, packs(), InventoryTracker.class, tracker, GameSessionStorage.class, mock(GameSessionStorage.class)); ++ ResourcePackStorage.class, packs, InventoryTracker.class, tracker, GameSessionStorage.class, mock(GameSessionStorage.class)); + when(user.get(any())).thenAnswer(call -> storage.get(call.getArgument(0))); + when(tracker.getCurrentContainer()).thenReturn(menu); + final var kind = InventoryPackets.nativeContainerKind(type, null, menu.size()); +@@ -266,11 +411,11 @@ class NativeContainerOpeningPacketTest { @Test void unsupportedProgramsDoNotConsumePresentationSequence() { final var state = new NativeContainerUiState(); @@ -20886,7 +16897,7 @@ index b582dc508..33d6320a9 100644 assertNotNull(first); assertNotNull(second); assertEquals(1, first.sequence()); -@@ -237,7 +378,29 @@ class NativeContainerOpeningPacketTest { +@@ -327,7 +472,29 @@ class NativeContainerOpeningPacketTest { } fixture.open(123); assertEquals("container.chest", fixture.inventory.getCurrentContainer().title().asUnformattedString()); @@ -20917,7 +16928,7 @@ index b582dc508..33d6320a9 100644 .getAsJsonObject("globals").get("$localize_title").getAsBoolean()); } } -@@ -290,7 +453,7 @@ class NativeContainerOpeningPacketTest { +@@ -380,7 +547,7 @@ class NativeContainerOpeningPacketTest { channels.addChannels(List.of(NativeContainerMessage.CHANNEL)); this.storage.put(ChannelStorage.class, channels); this.storage.put(NativeContainerUiState.class, new NativeContainerUiState()); @@ -20926,7 +16937,7 @@ index b582dc508..33d6320a9 100644 this.storage.put(InventoryTracker.class, this.inventory); this.storage.put(EntityTracker.class, this.entities); final var player = new ClientPlayerEntity(this.user, 1, new UUID(0, 1), new PlayerAbilities(1, (byte) 0, (byte) 0)); -@@ -363,8 +526,9 @@ class NativeContainerOpeningPacketTest { +@@ -453,8 +620,9 @@ class NativeContainerOpeningPacketTest { final var payload = Unpooled.wrappedBuffer(this.sent.get(1)); try { assertEquals(ClientboundPackets26_3.CUSTOM_PAYLOAD.getId(), Types.VAR_INT.readPrimitive(payload)); @@ -23495,12 +19506,276 @@ index 000000000..9574382a1 + } + +} +diff --git a/src/test/java/net/raphimc/viabedrock/protocol/storage/InventoryTransferBatchTest.java b/src/test/java/net/raphimc/viabedrock/protocol/storage/InventoryTransferBatchTest.java +new file mode 100644 +index 000000000..56734494d +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/protocol/storage/InventoryTransferBatchTest.java +@@ -0,0 +1,258 @@ ++/* ++ * 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.UserConnection; ++import net.raphimc.viabedrock.ViaBedrock; ++import net.raphimc.viabedrock.api.model.container.Container; ++import net.raphimc.viabedrock.api.model.container.block.FurnaceContainer; ++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.bedrock.generated.TextProcessingEventOrigin; ++import net.raphimc.viabedrock.protocol.model.BedrockItem; ++import net.raphimc.viabedrock.protocol.model.inventory.ItemStackRequestAction; ++import net.raphimc.viabedrock.protocol.model.inventory.ItemStackRequestInfo; ++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 org.junit.jupiter.api.Test; ++import org.mockito.MockedStatic; ++ ++import java.util.HashMap; ++import java.util.ArrayList; ++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 InventoryTransferBatchTest { ++ ++ @Test ++ void responsesWaitForTheWholeBatchAndReconcileInRequestOrder() { ++ try (var fixture = new Fixture()) { ++ final var batch = fixture.prepare(); ++ fixture.requests.sendTransferBatch(batch); ++ fixture.packets.verify(() -> PacketFactory.sendBedrockInventoryRequest(eq(fixture.user), any())); ++ fixture.packets.clearInvocations(); ++ final List observed = new ArrayList<>(); ++ assertTrue(fixture.requests.queueInteraction(new InventoryRequestTracker.QueuedInteraction() { ++ @Override ++ public Container container() { ++ return fixture.furnace; ++ } ++ ++ @Override ++ public boolean apply() { ++ observed.add(fixture.furnace.getItem(0).netId()); ++ return true; ++ } ++ })); ++ fixture.requests.handleResponse(fixture.response(batch.getLast(), 9, 12, 202)); ++ fixture.requests.handleResponse(fixture.response(batch.getLast(), 9, 12, 202)); ++ assertTrue(fixture.requests.hasPending()); ++ assertEquals(12, fixture.furnace.getItem(0).amount()); ++ assertEquals(-5, fixture.furnace.getItem(0).netId()); ++ fixture.packets.verifyNoInteractions(); ++ assertTrue(observed.isEmpty()); ++ ++ fixture.requests.handleResponse(fixture.response(batch.getFirst(), 0, 7, 201)); ++ assertFalse(fixture.requests.hasPending()); ++ assertEquals(item(12, 202), fixture.furnace.getItem(0)); ++ assertEquals(List.of(202), observed); ++ assertTrue(fixture.inventory.getInventoryContainer().getItem(0).isEmpty()); ++ assertTrue(fixture.inventory.getInventoryContainer().getItem(9).isEmpty()); ++ } ++ } ++ ++ @Test ++ void partialRejectionPreservesAcceptedCountsAndConfirmedNetworkIds() { ++ try (var fixture = new Fixture()) { ++ final var batch = fixture.prepare(); ++ fixture.requests.addTransferBatch(batch); ++ fixture.requests.handleResponse(fixture.response(batch.getFirst(), 0, 7, 201)); ++ fixture.requests.handleResponse(rejected(batch.getLast())); ++ assertFalse(fixture.requests.hasPending()); ++ assertEquals(item(7, 201), fixture.furnace.getItem(0)); ++ assertTrue(fixture.inventory.getInventoryContainer().getItem(0).isEmpty()); ++ assertEquals(item(5, 1009), fixture.inventory.getInventoryContainer().getItem(9)); ++ } ++ } ++ ++ @Test ++ void rejectedBatchRestoresItsOldestSnapshotRatherThanAnIntermediatePrediction() { ++ try (var fixture = new Fixture()) { ++ final var batch = fixture.prepare(); ++ fixture.requests.addTransferBatch(batch); ++ fixture.requests.handleResponse(rejected(batch.getLast())); ++ fixture.requests.handleResponse(rejected(batch.getFirst())); ++ assertFalse(fixture.requests.hasPending()); ++ assertTrue(fixture.furnace.getItem(0).isEmpty()); ++ assertEquals(item(7, 1000), fixture.inventory.getInventoryContainer().getItem(0)); ++ assertEquals(item(5, 1009), fixture.inventory.getInventoryContainer().getItem(9)); ++ } ++ } ++ ++ @Test ++ void rejectionDoesNotOverwriteNewerAuthoritativeContents() { ++ try (var fixture = new Fixture()) { ++ final var batch = fixture.prepare(); ++ fixture.requests.addTransferBatch(batch); ++ fixture.furnace.setItem(0, item(3, 301)); ++ fixture.furnace.markServerUpdate(); ++ fixture.inventory.getInventoryContainer().setItem(9, item(8, 309)); ++ fixture.inventory.getInventoryContainer().markServerUpdate(); ++ fixture.requests.handleResponse(rejected(batch.getFirst())); ++ fixture.requests.handleResponse(rejected(batch.getLast())); ++ assertEquals(item(3, 301), fixture.furnace.getItem(0)); ++ assertEquals(item(8, 309), fixture.inventory.getInventoryContainer().getItem(9)); ++ } ++ } ++ ++ @Test ++ void laterServerContentsSupersedeAnEarlierBufferedAcknowledgment() { ++ try (var fixture = new Fixture()) { ++ final var batch = fixture.prepare(); ++ fixture.requests.addTransferBatch(batch); ++ fixture.requests.handleResponse(fixture.response(batch.getFirst(), 0, 7, 201)); ++ fixture.furnace.setItem(0, item(3, 301)); ++ fixture.furnace.markServerUpdate(); ++ final var last = fixture.response(batch.getLast(), 9, 12, 202); ++ fixture.requests.handleResponse(new ItemStackResponseInfo(last.result(), last.requestId(), ++ List.of(last.containers().getLast()))); ++ assertFalse(fixture.requests.hasPending()); ++ assertEquals(item(3, 301), fixture.furnace.getItem(0)); ++ assertTrue(fixture.inventory.getInventoryContainer().getItem(0).isEmpty()); ++ assertTrue(fixture.inventory.getInventoryContainer().getItem(9).isEmpty()); ++ } ++ } ++ ++ @Test ++ void replacementMenuIgnoresResponsesFromThePreviousOwner() { ++ try (var fixture = new Fixture()) { ++ final var batch = fixture.prepare(); ++ fixture.requests.addTransferBatch(batch); ++ fixture.inventory.setCurrentContainerClosed(false); ++ final var replacement = new FurnaceContainer(fixture.user, (byte) 5, null, null); ++ fixture.inventory.setCurrentContainer(replacement); ++ replacement.setItem(0, item(2, 303)); ++ fixture.requests.handleResponse(fixture.response(batch.getFirst(), 0, 7, 201)); ++ fixture.requests.handleResponse(fixture.response(batch.getLast(), 9, 12, 202)); ++ assertFalse(fixture.requests.hasPending()); ++ assertEquals(item(2, 303), replacement.getItem(0)); ++ } ++ } ++ ++ @Test ++ void failedSubmissionRestoresTheWholeBatchAndReleasesPendingOwnership() { ++ try (var fixture = new Fixture()) { ++ final var batch = fixture.prepare(); ++ final var error = new IllegalStateException(); ++ fixture.packets.when(() -> PacketFactory.sendBedrockInventoryRequest(eq(fixture.user), any())) ++ .thenThrow(error); ++ assertSame(error, assertThrows(IllegalStateException.class, () -> fixture.requests.sendTransferBatch(batch))); ++ assertFalse(fixture.requests.hasPending()); ++ assertTrue(fixture.furnace.getItem(0).isEmpty()); ++ assertEquals(item(7, 1000), fixture.inventory.getInventoryContainer().getItem(0)); ++ assertEquals(item(5, 1009), fixture.inventory.getInventoryContainer().getItem(9)); ++ } ++ } ++ ++ @Test ++ void registrationRejectsDuplicateOrUncapturedRequestsWithoutLeavingPendingState() { ++ try (var fixture = new Fixture()) { ++ final var batch = fixture.prepare(); ++ assertThrows(IllegalArgumentException.class, ++ () -> fixture.requests.addTransferBatch(List.of(batch.getFirst(), batch.getFirst()))); ++ assertFalse(fixture.requests.hasPending()); ++ final var uncaptured = new InventoryRequestStorage(batch.getFirst().requestInfo(), 0, ++ fixture.inventory.getHudContainer().copy(), List.of(fixture.furnace.copy())); ++ assertThrows(IllegalArgumentException.class, ++ () -> fixture.requests.addTransferBatch(List.of(uncaptured))); ++ assertFalse(fixture.requests.hasPending()); ++ fixture.requests.addTransferBatch(batch); ++ assertThrows(IllegalStateException.class, ++ () -> fixture.requests.addRequest(batch.getFirst())); ++ assertThrows(IllegalStateException.class, ++ () -> fixture.requests.removeRequest(batch.getFirst().requestInfo().requestId())); ++ } ++ } ++ ++ private static ItemStackResponseInfo rejected(final InventoryRequestStorage request) { ++ return new ItemStackResponseInfo(ItemStackNetResult.Error, request.requestInfo().requestId(), List.of()); ++ } ++ ++ 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 viaBedrock = mockStatic(ViaBedrock.class); ++ private final InventoryTracker inventory; ++ private final InventoryRequestTracker requests; ++ private final FurnaceContainer furnace; ++ ++ private Fixture() { ++ 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()); ++ this.viaBedrock.when(ViaBedrock::getPlatform).thenReturn(platform); ++ this.inventory = 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.requests.setPredictingClick(true); ++ this.furnace = new FurnaceContainer(this.user, (byte) 5, null, null); ++ this.inventory.setCurrentContainer(this.furnace); ++ } ++ ++ private List prepare() { ++ final var player = this.inventory.getInventoryContainer(); ++ player.setItem(0, item(7, 1000)); ++ player.setItem(9, item(5, 1009)); ++ return List.of(this.transfer(0, 7), this.transfer(9, 5)); ++ } ++ ++ private InventoryRequestStorage transfer(final int source, final int amount) { ++ final var player = this.inventory.getInventoryContainer(); ++ final int id = this.requests.nextRequestId(); ++ final var request = new InventoryRequestStorage(new ItemStackRequestInfo(id, ++ List.of(new ItemStackRequestAction.PlaceAction(amount, ++ player.requestSlotInfo(source, player.getItem(source).netId()), ++ this.furnace.requestSlotInfo(0, this.furnace.getItem(0).isEmpty() ? 0 : this.furnace.getItem(0).netId()))), ++ List.of(), TextProcessingEventOrigin.unknown), 0, ++ this.inventory.getHudContainer().copy(), List.of(this.furnace.copy(), player.copy())); ++ this.furnace.setItem(0, item(this.furnace.getItem(0).amount() + amount, id)); ++ player.setItem(source, BedrockItem.empty()); ++ request.capturePrediction(this.inventory); ++ return request; ++ } ++ ++ private ItemStackResponseInfo response(final InventoryRequestStorage request, final int source, ++ final int amount, final int id) { ++ return new ItemStackResponseInfo(ItemStackNetResult.Success, request.requestInfo().requestId(), List.of( ++ new ItemStackResponseContainerInfo(this.furnace.getFullContainerName(0), ++ List.of(new ItemStackResponseSlotInfo((byte) 0, (byte) 0, (byte) amount, id, "", null, 0))), ++ new ItemStackResponseContainerInfo(this.inventory.getInventoryContainer().getFullContainerName(source), ++ List.of(new ItemStackResponseSlotInfo((byte) source, (byte) source, (byte) 0, 0, "", null, 0))))); ++ } ++ ++ @Override ++ public void close() { ++ this.viaBedrock.close(); ++ this.packets.close(); ++ } ++ } ++ ++} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/storage/NativeAutoPlacementRecoveryTest.java b/src/test/java/net/raphimc/viabedrock/protocol/storage/NativeAutoPlacementRecoveryTest.java new file mode 100644 -index 000000000..9c35e9131 +index 000000000..d43d9f79a --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/protocol/storage/NativeAutoPlacementRecoveryTest.java -@@ -0,0 +1,120 @@ +@@ -0,0 +1,126 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -23508,6 +19783,8 @@ index 000000000..9c35e9131 + */ +package net.raphimc.viabedrock.protocol.storage; + ++import java.util.Set; ++import net.raphimc.viabedrock.api.model.container.NativeItemStackMatcher; +import com.viaversion.viaversion.api.connection.UserConnection; + +import net.raphimc.viabedrock.api.model.container.ChestContainer; @@ -23557,7 +19834,7 @@ index 000000000..9c35e9131 + 1, + 9, + inventory.getInventoryContainer(), -+ 64); ++ 64, NativeAutoPlacementRecoveryTest::matches); + final var previous = + new InventoryRequestStorage( + new ItemStackRequestInfo( @@ -23568,8 +19845,8 @@ index 000000000..9c35e9131 + 7, + inventory.getHudContainer().copy(), + List.of(menu.copy(), inventory.getInventoryContainer().copy())); -+ menu.setItem(9, plan.sourceAfter()); -+ inventory.getInventoryContainer().setItem(plan.destinationSlot(), plan.destinationAfter()); ++ menu.setItem(9, plan.transfer().sourceAfter()); ++ inventory.getInventoryContainer().setItem(plan.transfer().destinationSlot(), plan.transfer().destinationAfter()); + previous.capturePrediction(inventory); + menu.setData(0, 13); + requests.restoreRejectedRequest(previous); @@ -23581,8 +19858,8 @@ index 000000000..9c35e9131 + check( + inventory.getHudContainer().getItem(0).amount() == 4 + && inventory.getHudContainer().getItem(0).netId() == 5001); -+ menu.setItem(9, plan.sourceAfter()); -+ inventory.getInventoryContainer().setItem(0, plan.destinationAfter()); ++ menu.setItem(9, plan.transfer().sourceAfter()); ++ inventory.getInventoryContainer().setItem(0, plan.transfer().destinationAfter()); + requests.applySuccessfulResponse( + new ItemStackResponseInfo( + ItemStackNetResult.Success, @@ -23609,6 +19886,10 @@ index 000000000..9c35e9131 + && inventory.getHudContainer().getItem(0).netId() == 5001); + } + ++ private static boolean matches(final BedrockItem query, final BedrockItem candidate) { ++ return NativeItemStackMatcher.matches(query, candidate, ignored -> null, ignored -> Set.of()); ++ } ++ + private static BedrockItem item(final int count, final int netId) { + final var result = new BedrockItem(1); + result.setAmount(count); @@ -24015,6 +20296,360 @@ index 000000000..1f766c4e6 + } + +} +diff --git a/src/test/java/net/raphimc/viabedrock/protocol/storage/PlayerInventoryScreenCodecTest.java b/src/test/java/net/raphimc/viabedrock/protocol/storage/PlayerInventoryScreenCodecTest.java +new file mode 100644 +index 000000000..66e78fc96 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/protocol/storage/PlayerInventoryScreenCodecTest.java +@@ -0,0 +1,71 @@ ++/* ++ * 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.type.Types; ++import io.netty.buffer.Unpooled; ++import net.raphimc.viabedrock.api.model.PlayerInventoryScreenClose; ++import net.raphimc.viabedrock.api.model.PlayerInventoryScreenRequest; ++import net.raphimc.viabedrock.protocol.data.ProtocolConstants; ++import org.junit.jupiter.api.Test; ++import static org.junit.jupiter.api.Assertions.*; ++ ++class PlayerInventoryScreenCodecTest { ++ ++ @Test ++ void roundTripsOpeningIdentityAndRejectsTruncatedOrTrailingRequests() { ++ for (final long opening : new long[]{1, 127, 128, Integer.MAX_VALUE, Long.MAX_VALUE}) { ++ for (final boolean open : new boolean[]{false, true}) { ++ final var request = new PlayerInventoryScreenRequest(opening, open); ++ final var buffer = Unpooled.buffer(); ++ try { ++ PlayerInventoryScreenRequest.TYPE.write(buffer, request); ++ final int size = buffer.readableBytes(); ++ for (int length = 0; length < size; length++) { ++ final var truncated = buffer.slice(0, length); ++ assertThrows(RuntimeException.class, () -> PlayerInventoryScreenRequest.TYPE.read(truncated)); ++ } ++ assertEquals(request, PlayerInventoryScreenRequest.TYPE.read(buffer)); ++ buffer.clear(); ++ PlayerInventoryScreenRequest.TYPE.write(buffer, request); ++ buffer.writeByte(0); ++ assertThrows(IllegalArgumentException.class, () -> PlayerInventoryScreenRequest.TYPE.read(buffer)); ++ buffer.clear(); ++ final var close = new PlayerInventoryScreenClose(opening); ++ PlayerInventoryScreenClose.TYPE.write(buffer, close); ++ assertEquals(close, PlayerInventoryScreenClose.TYPE.read(buffer)); ++ } finally { ++ buffer.release(); ++ } ++ } ++ } ++ } ++ ++ @Test ++ void rejectsUnsupportedVersionsInvalidIdentitiesAndNonBooleanFlags() { ++ final var buffer = Unpooled.buffer(); ++ try { ++ for (final int version : new int[]{0, 2}) { ++ Types.VAR_INT.writePrimitive(buffer, version); ++ Types.VAR_INT.writePrimitive(buffer, ProtocolConstants.BEDROCK_PROTOCOL_VERSION); ++ Types.VAR_LONG.writePrimitive(buffer, 1); ++ buffer.writeByte(1); ++ assertThrows(IllegalArgumentException.class, () -> PlayerInventoryScreenRequest.TYPE.read(buffer)); ++ buffer.clear(); ++ } ++ for (final long opening : new long[]{0, -1}) { ++ assertThrows(IllegalArgumentException.class, () -> new PlayerInventoryScreenRequest(opening, true)); ++ assertThrows(IllegalArgumentException.class, () -> new PlayerInventoryScreenClose(opening)); ++ } ++ PlayerInventoryScreenRequest.TYPE.write(buffer, new PlayerInventoryScreenRequest(1, true)); ++ buffer.setByte(buffer.writerIndex() - 1, 2); ++ assertThrows(IllegalArgumentException.class, () -> PlayerInventoryScreenRequest.TYPE.read(buffer)); ++ } finally { ++ buffer.release(); ++ } ++ } ++ ++} +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 000000000..7882c7128 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/protocol/storage/PlayerInventoryScreenTrackerTest.java +@@ -0,0 +1,271 @@ ++/* ++ * 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.UserConnection; ++import com.viaversion.viaversion.api.protocol.packet.PacketType; ++import com.viaversion.viaversion.api.protocol.packet.PacketWrapper; ++import com.viaversion.viaversion.api.protocol.packet.State; ++import com.viaversion.viaversion.protocol.packet.PacketWrapperImpl; ++import java.util.ArrayList; ++import java.util.HashMap; ++import java.util.List; ++import java.util.Map; ++import java.util.concurrent.TimeUnit; ++import net.raphimc.viabedrock.api.model.PlayerInventoryScreenClose; ++import net.raphimc.viabedrock.api.model.PlayerInventoryScreenRequest; ++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; ++import net.raphimc.viabedrock.protocol.BedrockProtocol; ++import net.raphimc.viabedrock.protocol.ServerboundBedrockPackets; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ContainerType; ++import org.junit.jupiter.api.Test; ++import org.mockito.MockedStatic; ++import static org.junit.jupiter.api.Assertions.*; ++import static org.mockito.Mockito.*; ++ ++class PlayerInventoryScreenTrackerTest { ++ ++ @Test ++ void retiresAnEarlyCloseBeforeOpeningTheNewScreen() throws Exception { ++ try (var f = new Fixture()) { ++ f.request(1, true); ++ f.request(1, false); ++ f.request(2, true); ++ assertEquals(1, f.opens()); ++ assertFalse(f.screens.acceptsClicks()); ++ f.acknowledge(); ++ assertNotNull(f.inventory.getPendingCloseContainer()); ++ assertEquals(1, f.opens()); ++ f.inventory.setCurrentContainerClosed(false); ++ assertEquals(2, f.opens()); ++ f.acknowledge(); ++ f.request(1, false); ++ assertTrue(f.screens.acceptsClicks()); ++ assertNull(f.inventory.getPendingCloseContainer()); ++ } ++ } ++ ++ @Test ++ void serializesReopeningTheSameJavaMenuAfterNativeClose() throws Exception { ++ try (var f = new Fixture()) { ++ f.request(1, true); ++ f.acknowledge(); ++ final long oldGeneration = f.inventory.currentContainerGeneration(); ++ f.request(1, true); ++ assertEquals(1, f.opens()); ++ f.request(1, false); ++ f.request(2, true); ++ assertEquals(1, f.opens()); ++ f.inventory.setCurrentContainerClosed(false); ++ assertEquals(2, f.opens()); ++ f.acknowledge(); ++ assertTrue(f.inventory.currentContainerGeneration() > oldGeneration); ++ } ++ } ++ ++ @Test ++ void timeoutRetainsTheUncorrelatedAcknowledgmentTombstone() throws Exception { ++ try (var f = new Fixture()) { ++ f.request(1, true); ++ f.screens.tick(System.nanoTime() + TimeUnit.SECONDS.toNanos(6)); ++ assertEquals(List.of(1L), f.closedScreens()); ++ f.request(2, true); ++ assertEquals(1, f.opens()); ++ f.acknowledge(); ++ f.inventory.setCurrentContainerClosed(false); ++ assertEquals(2, f.opens()); ++ f.acknowledge(); ++ } ++ } ++ ++ @Test ++ void adoptsAnExistingTemporaryInventoryWithoutSendingAnotherOpen() throws Exception { ++ try (var f = new Fixture()) { ++ f.inventory.setCurrentContainer(f.newInventory()); ++ f.requests.openPlayerInventoryTemporarily(); ++ f.request(1, true); ++ assertEquals(0, f.opens()); ++ assertTrue(f.screens.acceptsClicks()); ++ f.requests.handleInventoryOpen(f.inventory.getCurrentContainer()); ++ assertNotNull(f.inventory.getCurrentContainer()); ++ f.request(1, false); ++ assertNotNull(f.inventory.getPendingCloseContainer()); ++ } ++ } ++ ++ @Test ++ void serverCloseTargetsOnlyTheOwningClientOpening() throws Exception { ++ try (var f = new Fixture()) { ++ f.request(1, true); ++ f.acknowledge(); ++ f.inventory.setCurrentContainerClosed(true); ++ assertEquals(List.of(1L), f.closedScreens()); ++ assertFalse(f.screens.ownsClose()); ++ f.request(2, true); ++ f.acknowledge(); ++ f.request(1, false); ++ assertTrue(f.screens.acceptsClicks()); ++ } ++ } ++ ++ @Test ++ void anUnrelatedNativeMenuRejectsThePlayerScreenWithoutClosingTheMenu() throws Exception { ++ try (var f = new Fixture()) { ++ final var chest = mock(Container.class); ++ when(chest.type()).thenReturn(ContainerType.CONTAINER); ++ f.inventory.setCurrentContainer(chest); ++ f.request(1, true); ++ assertEquals(List.of(1L), f.closedScreens()); ++ assertSame(chest, f.inventory.getCurrentContainer()); ++ assertEquals(0, f.opens()); ++ } ++ } ++ ++ @Test ++ void retainsTheNativeWaitAcrossScreenRetirementAndRunsLaterGlobalActions() throws Exception { ++ try (var f = new Fixture()) { ++ f.request(1, true); ++ f.request(1, false); ++ assertTrue(f.requests.isWaitingForInventoryOpen()); ++ assertFalse(f.requests.beginInventoryOpen()); ++ final var applied = new ArrayList(); ++ assertTrue(f.requests.queueInteraction(new InventoryRequestTracker.QueuedInteraction() { ++ ++ @Override ++ public Container container() { ++ return f.inventory.getInventoryContainer(); ++ } ++ ++ @Override ++ public boolean isScreenInteraction() { ++ return false; ++ } ++ ++ @Override ++ public boolean isValid(final InventoryTracker tracker) { ++ return tracker.getCurrentContainer() instanceof InventoryContainer; ++ } ++ ++ @Override ++ public boolean apply() { ++ applied.add(1); ++ return true; ++ } ++ })); ++ f.acknowledge(); ++ assertEquals(List.of(1), applied); ++ assertEquals(1, f.opens()); ++ assertNotNull(f.inventory.getPendingCloseContainer()); ++ } ++ } ++ ++ @Test ++ void formReplacementUsesOnlyTheCorrelatedPlayerCloseWhileAnActionIsPending() throws Exception { ++ try (var f = new Fixture()) { ++ f.request(1, true); ++ f.acknowledge(); ++ doReturn(true).when(f.requests).hasPending(); ++ f.inventory.closeContainerForForm(); ++ assertEquals(List.of(1L), f.closedScreens()); ++ assertNotNull(f.inventory.getPendingCloseContainer()); ++ } ++ } ++ ++ @Test ++ void reconfigurationRetiresTheScreenWithoutSendingPlayPayloadsDuringConfiguration() throws Exception { ++ try (var f = new Fixture()) { ++ f.screens.refreshCapability(); ++ f.request(1, true); ++ assertEquals(2, f.sent.size()); ++ when(f.user.getProtocolInfo().getClientState()).thenReturn(State.CONFIGURATION); ++ f.screens.refreshCapability(); ++ assertEquals(2, f.sent.size()); ++ assertTrue(f.screens.hasPendingOpen()); ++ assertFalse(f.screens.acceptsClicks()); ++ when(f.user.getProtocolInfo().getClientState()).thenReturn(State.PLAY); ++ f.screens.refreshCapability(); ++ f.request(2, true); ++ assertEquals(1, f.opens()); ++ f.acknowledge(); ++ f.inventory.setCurrentContainerClosed(false); ++ assertEquals(2, f.opens()); ++ } ++ } ++ ++ private static final class Fixture implements AutoCloseable { ++ private final Map, Object> storage = new HashMap<>(); ++ private final UserConnection user = mock(UserConnection.class, RETURNS_DEEP_STUBS); ++ private final InventoryTracker inventory; ++ private final InventoryRequestTracker requests; ++ private final PlayerInventoryScreenTracker screens; ++ private final List sent = new ArrayList<>(); ++ private final MockedStatic packets = mockStatic(PacketWrapper.class); ++ ++ private Fixture() throws Exception { ++ when(this.user.get(any())).thenAnswer(call -> this.storage.get(call.getArgument(0))); ++ when(this.user.getProtocolInfo().getClientState()).thenReturn(State.PLAY); ++ final var channels = new ChannelStorage(); ++ channels.addChannels(List.of(PlayerInventoryScreenClose.CHANNEL)); ++ this.storage.put(ChannelStorage.class, channels); ++ this.inventory = new InventoryTracker(this.user); ++ this.requests = spy(new InventoryRequestTracker(this.user)); ++ this.screens = new PlayerInventoryScreenTracker(this.user); ++ this.storage.put(InventoryTracker.class, this.inventory); ++ this.storage.put(InventoryRequestTracker.class, this.requests); ++ this.storage.put(PlayerInventoryScreenTracker.class, this.screens); ++ final var entities = mock(EntityTracker.class); ++ final var player = mock(ClientPlayerEntity.class); ++ when(player.runtimeId()).thenReturn(101L); ++ when(entities.getClientPlayer()).thenReturn(player); ++ this.storage.put(EntityTracker.class, entities); ++ this.packets.when(() -> PacketWrapper.create(any(PacketType.class), eq(this.user))).thenAnswer(call -> { ++ final var packet = spy(new PacketWrapperImpl(call.getArgument(0, PacketType.class), null, this.user)); ++ doAnswer(send -> { ++ this.sent.add(packet); ++ return null; ++ }).when(packet).send(BedrockProtocol.class); ++ doAnswer(send -> { ++ this.sent.add(packet); ++ return null; ++ }).when(packet).sendToServer(BedrockProtocol.class); ++ return packet; ++ }); ++ } ++ ++ private void request(final long opening, final boolean open) { ++ this.screens.request(new PlayerInventoryScreenRequest(opening, open)); ++ } ++ ++ private InventoryContainer newInventory() { ++ return new InventoryContainer(this.user, (byte) 0, null, this.inventory.getInventoryContainer()); ++ } ++ ++ private void acknowledge() { ++ final var inventory = this.newInventory(); ++ this.inventory.setCurrentContainer(inventory); ++ this.screens.opened(); ++ this.requests.handleInventoryOpen(inventory); ++ } ++ ++ private long opens() { ++ return this.sent.stream().filter(p -> p.getPacketType() == ServerboundBedrockPackets.INTERACT).count(); ++ } ++ ++ private List closedScreens() { ++ return this.sent.stream().filter(p -> p.getPacketType() != ServerboundBedrockPackets.CONTAINER_CLOSE ++ && p.getPacketType() != ServerboundBedrockPackets.INTERACT) ++ .map(p -> p.get(PlayerInventoryScreenClose.TYPE, 0).opening()).toList(); ++ } ++ ++ @Override ++ public void close() { ++ this.packets.close(); ++ } ++ } ++ ++} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/storage/StonecutterRecipePacketTest.java b/src/test/java/net/raphimc/viabedrock/protocol/storage/StonecutterRecipePacketTest.java new file mode 100644 index 000000000..40a787148 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 2bd4b9d2..c42a6c86 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 @@ -3,6 +3,25 @@ This patch follows the inventory work owned by [ViaBedrock #276](https://github.com/ViaVersionAddons/ViaBedrock/pull/276). It remains deferred until that prerequisite lands. +## Empty display-name components + +An empty `minecraft:display_name` component now retains ordinary naming instead of installing a blank Java label. +The definition accessor applies this rule to resource-pack definitions. +Registry-owned component descriptions handle network construction and updates. +An empty upper-pack definition replaces a lower override. Whitespace and later nonempty values remain valid overrides. + +Fresh controls on Bedrock 1.26.51.1/build 51061372 execute 1,299 primary-name cases, including ComponentItem getter `0x143fd2be0`. +The native getter ignores an empty display-name component and retains its ordinary description. +Typed NBT lookups, custom-name selection, ordinary naming, and three translation-key families execute unchanged. +Language translation, component lookup, allocation, and runtime services remain supplied boundaries. +Full specialized-name, secondary-label, language, search-input, and visual parity remain unfinished. + +The initial regressions covered repeated network updates and upper-pack precedence across primitive and object component forms. +The network cases now run through the registry-owned naming checks. This patch retains the pack-precedence check. +The full 104-patch core stack replays, and all four projects build. +Verification passes 2,623 tests, with 163 optional skips and no failures or errors. +All 1,413 packaged core classes match the copies in the add-on and proxy. + ## Brewing stand addressing The target BDS 1.26.51.1 inventory exposes ingredient slot 0, bottle slots 1 through 3, and fuel slot 4. @@ -579,3 +598,51 @@ All 102 core patches and 38 client patches replay cleanly. Tooling checks, 264 r A fresh strict-BDS ViaProxy run passes both authored chest transfers, ordinary chest transfer, pickup-all, and all three cooking controls. Initial join and same-world reconnect also pass. The runner stops its private display normally. These controls verify the existing request paths with the rebuilt artifacts. They do not establish authored cooking control or native visual parity. + +## Ordinary transfer batches + +The request tracker can retain separately scoped ordinary transfers as one pending batch. +Each request preserves its intermediate prediction. Responses wait for the complete batch and reconcile in request order. +Reconciliation restores the oldest snapshots and replays accepted deltas before server acknowledgments. +A later rejection preserves earlier accepted counts and confirmed stack IDs. +Newer server revisions and replacement menu generations retain their existing protections. +Queued clicks remain blocked until the batch completes. A rejection discards them. + +Eight regressions cover reversed and duplicate responses, queued input, partial and complete rejection, newer contents, menu replacement, registration guards, and failed submission. +Each buffered response retains its arrival revision, so newer contents supersede an earlier acknowledgment before batch completion. +Failed packet submission restores the snapshots and releases pending ownership. +All 22 selected inventory tests pass, and both Checkstyle tasks pass before stack replay. + +A fresh pinned native control confirms the false default for `enable_client_container_simulation` and executes the original disabled cooking initializer. +The controlled input still emits two owned requests in one packet, with the second destination referencing the first request ID. +The complete native controller, model owner, and independent frame scopes remain unverified. +This batch API does not activate authored cooking controls. Their backend remains unfinished. + +The full four-project build passes 2,399 tests, with 213 optional skips and no failures or errors. +All 104 core patches replay. Offline validation passes. +All 1,392 core classes match the add-on's embedded core byte for byte. + + +## Retain native display-name updates + +The registry-owned component description path preserves the last display name across omitted or wrongly typed updates. +Only a valid `StringTag` value replaces it. A valid empty string restores the ordinary description. +The superseded network-name reader and its three tests have been removed from this patch. +The remaining item-definition loader handles icons, cooldowns, use metadata, and resource-pack names. + +Fresh native 1.26.51.1 controls construct 29 ordered updates for 13 actual component definitions. +They execute the populated registry initializer, named stack construction, description getter `143fd2be0`, and key method `143fd6ac0`. +All 13 results now form part of the registry's 23 native description checks. +All 2,630 static cleanup callbacks complete without owned allocation or lock leaks. +The registry checks cover the constructor, reader, and fallback phases. Five deliberate regressions fail by assertion. + +The controls supply authored ItemData through the real typed vector and registry lifecycle. +They do not establish normal packet decoding, full native pack/client precedence, language selection, or screen parity. +The [component naming notes](../upstreamable/0095-integrate-authored-native-ui-presentation.pr.md#component-description-construction-and-conversion) retain the current production path and limits. + + +The earlier reader build passed 2,646 tests, with 163 optional skips and no failures or errors. +All 104 core and 38 client patches replayed. Its six item-definition checks ran without skips. +The packaged reader passes the same six tests, with five production class origins verified against the current core JAR. +All 1,419 core classes match both packaged copies. All 1,139 production client classes match the add-on. +Tooling, offline tests, and patch metadata checks pass. 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 fb8d648a..94c171a8 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 031278cecf4739182e6026de9b9ec80c2bd412ac Mon Sep 17 00:00:00 2001 +From 23f47fe2115f7de0bf38ab3d59cd6ac2ce4948c0 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 @@ -40,8 +40,9 @@ correct ingredient provider. Cooking and fuel/output updates remain server owned. Combine compatible inventory stacks, preserve input variants, clear mismatched input into existing stacks where possible, and plan atomically before recording both inventories for acknowledgment or rejection recovery. -Use negative client request references when an action revisits a slot -changed earlier in the same request; never invent a server stack identity. +Use the negative request ID for a slot changed earlier in the same request, +including a source that becomes empty. Canonicalize hotbar, inventory and +combined-player references to the same physical slot for this tracking. Reject recipes belonging to a different station. Evidence: the pinned 1.26.51.1 protocol 2193 recipe catalog advertises cooking @@ -222,86 +223,270 @@ updates retain item authority and use a separate small revisioned message. This supplies the production model and transport. Client authored cooking screens, activation, search/navigation controls, and native quantity/cursor actions remain unfinished and are not inferred from Java screen controls. + +Return mismatched cooking input with the shared source-first occupied-slot +merge predicate. Raw stack equality rejected auxiliary variants of items +that do not stack by data, and accepted damaged occupied destinations that +native stacking rejects. Keep recipe-query matching and Java recipe-book +placement quantities separate from this ordinary return transfer. + +The pinned 1.26.51.1 transfer engine at 1433e8380 uses occupied-stack +predicate 142488fe0 with the moving source first. Reuse ItemRewriter's +constructed stacking properties and canonical auxiliary resolution. Two +regressions fail before this change: native auxiliary merging with a full +inventory, including request references and rejection recovery, and atomic +refusal when only a damaged occupied destination has capacity. + +Assign request references after every predicted slot mutation. The pinned +1.26.51.1 executable uses identity callback147c9ac00 and observer147c9aa20 +from the client manager's predicted containers. Those callbacks assign +the negative request ID to both endpoints, even an emptied source. Later +transfers in the same request use that reference instead of an earlier +positive stack ID or zero. Track physical player slots across provider +aliases; cooking sources use hotbar28/inventory29 and returns use combined12. + +The earlier base-manager serialization fixture omitted those callbacks. +Its preserved positive IDs describe unpredicted backing and cannot justify +production prediction semantics. Replace that comparison with five fresh +payload controls using native predicted-container construction1425ac0b0, +ClientScreenData construction1425bdf10, original callbacks and mutation, +payload conversion14273fc70, context construction14273ad30 and packet +writer14273aab0/14273a2d0. Java matches the complete cooking payload. + +The manager uses flags1,1 and the native client vtable. Base construction +runs before Level attachment and takes its ordinary null-Level branch. +Subclass fields, one-entity registry, screen bindings, packet view and +BinaryStream fields are controlled data from native layouts. Full Player +and screen construction, negotiated flag provenance, predicted request +finalization, the enclosing sender, transport and live acceptance remain +unverified by those open-request controls. This does not complete native +authored cooking actions. + +A stronger fresh fixture uses the request manager's original owned registry, +original entity creation1425c0460 and weak-reference construction1425c06e0. +Native finish1425b0cf0 and detach1425b0e50 release exactly one retained weak +reference and queue the unchanged Place actions. Enabled flush1425b1f70 +converts the request, constructs the actual packet, clears and destroys +the queue before transport, and cleans up the converted payload afterward. +An external transport sink calls the original serializer on that packet. +An empty follow-up flush emits nothing. The Java planner/codec comparison +now reads this finalized native cooking payload byte for byte through +STACKANVIL_NATIVE_FINALIZED_REQUEST_WIRE. + +Typed-storage and client-subclass initialization still follow controlled +inline layouts. Screen container bindings, transport and BinaryStream +fields remain supplied. Full Player/screen construction, component signals, +negotiated mode provenance, outer input/frame scope, real transport and +server acceptance remain unverified. Preserve those limits separately +from the now-executed finish, flush and ownership-release functions. + +Execute snapshot-bound native primary cooking actions through their own +selection planner and the ordinary transfer batch API. Compare the clicked +output with the retained selection before the ingredient-only branch. +Toggle an existing selection by returning ingredient and output items; +otherwise choose unlocked alternatives through native descriptor groups, +collect from fuel/hotbar/inventory, and return output after placement. +Each return replaces the player history projection. The final output +return therefore captures post-placement history, while selection-only +admission leaves the constructor's initially empty history unchanged. + +Emit one owned request per transfer with physical provider indices and +negative references for every previously changed slot, including empty +slots and combined-player aliases. Retain intermediate predictions for +responses and restore physical items and selection on failed submission. +Keep the Java recipe-book increment/max behavior independent. + +Consume version-4 cooking actions only on negotiated authoritative +connections. Require the exact presentation, current items, unlocked +recipe inputs and authored cell command. Deduplicate request sequences; +follow-up phases retire the retained gesture without repeating transfer +effects. Defer intermediate publication and bind highlights to the current +visible recipe ordering. Menu generations prevent a late release from +changing a replacement menu. + +Evidence: Bedrock 1.26.51.1 build 51061372/protocol 2193. Native entry +146c9a3c0 compares the selected output before 146c46900, and 14c54c950 +returns both physical collections before clearing it. Original manager +construction initializes its history vector empty. A fresh enabled native +prediction control executes six press/release steps, automatic scopes, +owned finalization, and actual flush/packet serialization between steps. +It verifies selection toggle, combined-provider return, post-placement +history and refill request references. The Java primary action matches +the original two-source native packet byte for byte. + +The Fabric cooking presentation remains passive. Physical device bindings, +nonempty-cursor branches, cross-input prediction, authoritative model +watchers, full controller/frame ownership and live route acceptance remain +unfinished. This is a production backend milestone, not complete native +cooking or five-area parity. + +Use recipe descriptor matching for source admission. Keep full Item +queries for selected-output comparison, ingredient selection, history, +and the transfer loop's stop at a different item variant. Capture history +through the empty-ingredient return before filling as the native code +does, so an output can restore an emptied source slot with its request +reference instead of choosing a new hotbar destination. + +Negotiate cooking actions in the correct payload direction. Client model, +progress and result receivers enable a separately advertised serverbound +action channel. Do not require a client receiver for its outgoing action. +Retain play-state and server-authoritative inventory guards, suppress +repeated advertisements, and retire held input when a receiver withdraws. +Re-registration cannot release a gesture from the withdrawn capability. +Loss of inventory authority also withdraws the capability and presentation. +Packet regressions exercise real registration and action handlers. The +original direction check rejects the valid request without transferring. +Fabric cooking controls and live action acceptance remain incomplete. + +Publish cooking captions through the shared native tab-label rules. The +search caption depends on both station family and the current filtering +flag; the inventory tab uses the native shared survival key. Preserve +item and opening authority while preference changes advance the model. + +The publisher regression covers all three station families, both search +and inventory tabs, and both filter states across complete program +round trips. The old publisher fails and the shared-rule publisher passes. +Native getter parity is bound by sixty unchanged machine-code controls +in the shared UI prerequisite, with explicit allocator and CRT seams. + +Retain the native mode-two recipe focus before a primary selection. That +focus permits the following authored selection with a carried Item while +direct presses in the other verified modes keep their empty-cursor guard. +Bind terminal focus release to the exact menu, presentation and input +context. Capability loss or menu replacement retires the permission. +Focus does not transfer or manufacture a new selection snapshot. + +Pass focused admission explicitly into the existing native placement and +return planner. Preserve the carried Item in every request prediction and +retain existing server-authority, source, network-ID and revision checks. +Five fresh 1.26.51.1 cases execute the original full graph, held SlotInfo +getter, focus entry, selection effects, provider transfers, owned request +scopes and native flush/serialization. Mode-two carried counts 0/1/64 use +two Place requests for seven hotbar and five inventory ingredients without +changing the cursor; carried controls in modes one and three do nothing. + +Native typed model/controller fields, recipe/result caches and world +interfaces remain supplied, along with abstract client mode/no-player +interfaces and prior startup/CRT/transport boundaries. No state predicate, +focus/selection effect, transfer or serialization function is replaced. +Complete controller construction, physical input mapping, server +acknowledgments and native pixels remain separate verification work. + +Recheck the complete player-UI snapshot and its server revision after +planning. Item metadata callbacks can otherwise replace the carried Item +between focus admission and committing its requests. Reject that stale +plan without mutating station/player items or restoring over the newer +cursor; a fresh retry retains the original selection history. + +Translate SetPlayerInventoryOptions in both directions and retain all five +native fields. Player inventory and workbench share one Java crafting book +but own independent native layouts. Reproject on actual menu ownership +changes; changing filtering alone must preserve the original layout, both +tab selections and the other screen's layout, including unknown int32s. +Keep the native None sentinel for unseen fields when Java supplies its +first open/filter choice. + +Generalize FurnaceOptionsStorage to InventoryOptionsStorage so all four +Java books retain one connection-local snapshot. Update every consumer +rather than keeping a compatibility class. Furnace updates retain the +player book, and inventory updates retain the three furnace books. + +The 1.26.50-beta.26/protocol-2192 preview schema describes four signed +VarInts and a boolean. The target 1.26.51.1/protocol-2193 original packet +GetId returns 307 and its complete descriptor emits the matching fields, +including negative and extreme int32 values. The typed output sink is +supplied; this reference does not execute native compressed wire writers. +https://mojang.github.io/bedrock-protocol-docs/1.26.50-preview.26/packets/set-player-inventory-options-packet/ + +The packet and lifecycle regressions cover both directions, raw bytes, +recipe-book-only layouts, unknown values, independent furnace settings, +workbench admission and pending-close restoration. This adds native option +state and Java projection, not the unfinished authored inventory screen, +player portrait, native tab/search controls or full physical UI parity. --- - .../api/model/NativeFurnaceOptions.java | 44 ++ + .../api/model/NativeFurnaceOptions.java | 44 + .../container/CookingRecipeAvailability.java | 30 + - .../model/container/CookingRecipeCatalog.java | 81 +++ + .../model/container/CookingRecipeCatalog.java | 81 ++ .../model/container/CookingRecipeInputs.java | 53 ++ - .../model/container/CookingRecipeOutputs.java | 84 +++ - .../model/container/CookingRecipeSources.java | 87 +++ - .../model/container/CraftingOutputPlan.java | 109 ++++ - .../model/container/CraftingPlacement.java | 229 +++++++ - .../container/FurnaceRecipePlacement.java | 205 +++++++ - .../block/CraftingTableContainer.java | 100 +-- - .../container/block/FurnaceContainer.java | 112 ++++ + .../model/container/CookingRecipeSources.java | 87 ++ + .../model/container/CraftingOutputPlan.java | 109 +++ + .../model/container/CraftingPlacement.java | 229 +++++ + .../container/FurnaceRecipePlacement.java | 219 +++++ + .../NativeCookingRecipeSelection.java | 206 +++++ + .../block/CraftingTableContainer.java | 100 ++- + .../container/block/FurnaceContainer.java | 209 +++++ .../model/container/player/HudContainer.java | 3 + - .../container/player/InventoryContainer.java | 190 +++++- + .../container/player/InventoryContainer.java | 190 ++++- .../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/recipe/ItemDescriptor.java | 13 +- .../protocol/model/recipe/MolangTagQuery.java | 64 ++ .../protocol/model/recipe/MultiRecipe.java | 46 +- .../protocol/model/recipe/ShapedRecipe.java | 65 +- .../model/recipe/ShapelessRecipe.java | 6 +- .../protocol/model/recipe/SmithingRecipe.java | 4 +- - .../protocol/packet/InventoryPackets.java | 127 +++- + .../protocol/packet/InventoryPackets.java | 146 +++- .../protocol/packet/JoinPackets.java | 1 + - .../protocol/packet/MultiStatePackets.java | 30 + - .../protocol/rewriter/ItemRewriter.java | 39 ++ + .../protocol/packet/MultiStatePackets.java | 55 ++ + .../protocol/rewriter/ItemRewriter.java | 30 +- .../protocol/storage/CraftingDataStorage.java | 3 +- - .../protocol/storage/CraftingDataTracker.java | 573 +++++++++++++++--- - .../storage/FurnaceOptionsStorage.java | 139 +++++ - .../storage/InventoryRequestTracker.java | 6 + - .../protocol/storage/InventoryTracker.java | 4 + - .../storage/NativeCookingUiState.java | 348 +++++++++++ - .../protocol/types/BedrockTypes.java | 1 + + .../protocol/storage/CraftingDataTracker.java | 573 +++++++++++-- + .../storage/InventoryOptionsStorage.java | 187 ++++ + .../storage/InventoryRequestTracker.java | 7 + + .../protocol/storage/InventoryTracker.java | 16 + + .../storage/NativeCookingUiState.java | 502 +++++++++++ + .../protocol/types/BedrockTypes.java | 2 + .../types/item/BedrockRecipeResultType.java | 23 + - .../types/model/FurnaceOptionsType.java | 34 ++ + .../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 | 180 ++++++ - .../container/CookingRecipeOutputsTest.java | 178 ++++++ - .../container/CookingRecipeSourcesTest.java | 146 +++++ - .../container/CraftingOutputPlanTest.java | 84 +++ - .../container/CraftingPlacementTest.java | 161 +++++ - .../container/FurnaceRecipePlacementTest.java | 314 ++++++++++ - .../player/InventoryContainerTest.java | 131 ++++ + .../container/CookingRecipeInputsTest.java | 180 ++++ + .../container/CookingRecipeSourcesTest.java | 146 ++++ + .../container/CraftingOutputPlanTest.java | 84 ++ + .../container/CraftingPlacementTest.java | 161 ++++ + .../container/FurnaceRecipePlacementTest.java | 418 +++++++++ + .../player/InventoryContainerTest.java | 131 +++ .../model/recipe/MolangTagQueryTest.java | 53 ++ .../model/recipe/RecipeOwnershipTest.java | 52 ++ - .../packet/FurnaceOptionsPacketTest.java | 156 +++++ - .../NativeFurnaceOptionsPacketTest.java | 214 +++++++ - .../storage/CookingRecipeBookTest.java | 154 +++++ - .../protocol/storage/CraftingCursorTest.java | 158 +++++ - .../storage/CraftingDataTrackerTest.java | 400 ++++++++++++ - .../storage/FurnaceRecipeRequestTest.java | 316 ++++++++++ - .../storage/NativeCookingUiStateTest.java | 286 +++++++++ - ...NativeRecipeRegistrationReferenceTest.java | 104 ++++ - .../item/BedrockRecipeResultTypeTest.java | 106 ++++ - .../types/model/FurnaceOptionsTypeTest.java | 75 +++ - .../types/recipe/CraftingDataReaderTest.java | 81 ++- - 59 files changed, 6281 insertions(+), 160 deletions(-) + .../packet/FurnaceOptionsPacketTest.java | 162 ++++ + .../packet/InventoryOptionsPacketTest.java | 152 ++++ + .../NativeFurnaceOptionsPacketTest.java | 214 +++++ + .../storage/CookingRecipeBookTest.java | 154 ++++ + .../protocol/storage/CraftingCursorTest.java | 158 ++++ + .../storage/CraftingDataTrackerTest.java | 400 +++++++++ + .../storage/FurnaceRecipeRequestTest.java | 797 ++++++++++++++++++ + .../storage/NativeCookingUiStateTest.java | 619 ++++++++++++++ + ...NativeRecipeRegistrationReferenceTest.java | 104 +++ + .../item/BedrockRecipeResultTypeTest.java | 106 +++ + .../types/model/FurnaceOptionsTypeTest.java | 75 ++ + .../types/model/InventoryOptionsTypeTest.java | 46 + + .../types/recipe/CraftingDataReaderTest.java | 81 +- + 62 files changed, 7753 insertions(+), 161 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 create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/CookingRecipeInputs.java - create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/CookingRecipeOutputs.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/CookingRecipeSources.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/CraftingOutputPlan.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/CraftingPlacement.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/FurnaceRecipePlacement.java + create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/NativeCookingRecipeSelection.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/model/FurnaceOptions.java + create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/model/InventoryOptions.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/model/recipe/MolangTagQuery.java - create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/storage/FurnaceOptionsStorage.java + create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryOptionsStorage.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/storage/NativeCookingUiState.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/types/item/BedrockRecipeResultType.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/types/model/FurnaceOptionsType.java + create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/types/model/InventoryOptionsType.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/NativeFurnaceOptionsTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/container/CookingRecipeAvailabilityTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/container/CookingRecipeInputsTest.java - create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/container/CookingRecipeOutputsTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/container/CookingRecipeSourcesTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/container/CraftingOutputPlanTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/container/CraftingPlacementTest.java @@ -310,6 +495,7 @@ actions remain unfinished and are not inferred from Java screen controls. create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/model/recipe/MolangTagQueryTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/model/recipe/RecipeOwnershipTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/packet/FurnaceOptionsPacketTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/packet/InventoryOptionsPacketTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/packet/NativeFurnaceOptionsPacketTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/storage/CookingRecipeBookTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/storage/CraftingCursorTest.java @@ -319,6 +505,7 @@ actions remain unfinished and are not inferred from Java screen controls. create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/storage/NativeRecipeRegistrationReferenceTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/types/item/BedrockRecipeResultTypeTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/types/model/FurnaceOptionsTypeTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/types/model/InventoryOptionsTypeTest.java diff --git a/src/main/java/net/raphimc/viabedrock/api/model/NativeFurnaceOptions.java b/src/main/java/net/raphimc/viabedrock/api/model/NativeFurnaceOptions.java new file mode 100644 @@ -408,7 +595,7 @@ index 000000000..92369aea3 +} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/container/CookingRecipeCatalog.java b/src/main/java/net/raphimc/viabedrock/api/model/container/CookingRecipeCatalog.java new file mode 100644 -index 000000000..116a67474 +index 000000000..ff7a170ec --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/model/container/CookingRecipeCatalog.java @@ -0,0 +1,81 @@ @@ -454,7 +641,7 @@ index 000000000..116a67474 + final List alternatives = new ArrayList<>(); + for (final var candidate : this.entries) { + final BedrockItem result = output(items, candidate.data()); -+ if (candidate.unlocked() && result != null && items.matchesCookingOutput(output, result)) { ++ if (candidate.unlocked() && result != null && items.matchesStackQuery(output, result)) { + alternatives.addAll(CookingRecipeInputs.expand(user, + ((ShapelessRecipe) candidate.data().recipe()).getIngredients().getFirst())); + } @@ -552,96 +739,6 @@ index 000000000..d35a44bb8 + } + +} -diff --git a/src/main/java/net/raphimc/viabedrock/api/model/container/CookingRecipeOutputs.java b/src/main/java/net/raphimc/viabedrock/api/model/container/CookingRecipeOutputs.java -new file mode 100644 -index 000000000..452918a42 ---- /dev/null -+++ b/src/main/java/net/raphimc/viabedrock/api/model/container/CookingRecipeOutputs.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.container; -+ -+import com.viaversion.nbt.tag.CompoundTag; -+import com.viaversion.nbt.tag.DoubleTag; -+import com.viaversion.nbt.tag.FloatTag; -+import com.viaversion.nbt.tag.ListTag; -+import com.viaversion.nbt.tag.Tag; -+import net.raphimc.viabedrock.protocol.model.BedrockItem; -+ -+import java.util.Objects; -+import java.util.Set; -+import java.util.function.Function; -+ -+/** Output queries compare network-loaded items in one stable connection registry. */ -+public final class CookingRecipeOutputs { -+ -+ public static boolean matches(final BedrockItem query, final BedrockItem candidate, -+ final Function blocks, final Function> restrictions) { -+ if (query == null || candidate == null || query.isEmpty() || candidate.isEmpty() -+ || query.identifier() != candidate.identifier() -+ || query.data() != candidate.data() && query.data() != Short.MAX_VALUE && candidate.data() != Short.MAX_VALUE) { -+ return false; -+ } -+ if (query.hasBlockState()) { -+ final Object block = blocks.apply(query.blockRuntimeId()); -+ if (!candidate.hasBlockState() || block == null || block != blocks.apply(candidate.blockRuntimeId())) { -+ return false; -+ } -+ } -+ // The full network userdata also determines chargedItem construction in the same registry. -+ // Do not use this comparison for native stacks whose child has been mutated independently. -+ return userDataEqual(query.tag(), candidate.tag()) -+ && query.blockingTicks() == candidate.blockingTicks() -+ && restrictions.apply(query.canPlace()).equals(restrictions.apply(candidate.canPlace())) -+ && restrictions.apply(query.canBreak()).equals(restrictions.apply(candidate.canBreak())); -+ } -+ -+ static boolean userDataEqual(final CompoundTag first, final CompoundTag second) { -+ if (first == null) { -+ return second == null || second.isEmpty(); -+ } -+ return second == null ? first.isEmpty() : tagsEqual(first, second); -+ } -+ -+ /** Native Tag equality uses numeric floating comparison and retains empty-list element types. */ -+ static boolean tagsEqual(final Tag first, final Tag second) { -+ if (first == null || second == null || first.getClass() != second.getClass()) { -+ return false; -+ } -+ return switch (first) { -+ case FloatTag value -> value.asFloat() == ((FloatTag) second).asFloat(); -+ case DoubleTag value -> value.asDouble() == ((DoubleTag) second).asDouble(); -+ case ListTag values -> { -+ final var other = (ListTag) second; -+ boolean equal = Objects.equals(values.getElementType(), other.getElementType()) && values.size() == other.size(); -+ for (int index = 0; equal && index < values.size(); index++) { -+ equal = tagsEqual(values.get(index), other.get(index)); -+ } -+ yield equal; -+ } -+ case CompoundTag values -> { -+ final var other = (CompoundTag) second; -+ boolean equal = values.keySet().equals(other.keySet()); -+ for (final String key : values.keySet()) { -+ if (!equal || !tagsEqual(values.get(key), other.get(key))) { -+ equal = false; -+ break; -+ } -+ } -+ yield equal; -+ } -+ default -> first.equals(second); -+ }; -+ } -+ -+ private CookingRecipeOutputs() { -+ } -+ -+} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/container/CookingRecipeSources.java b/src/main/java/net/raphimc/viabedrock/api/model/container/CookingRecipeSources.java new file mode 100644 index 000000000..b2409a86a @@ -1087,10 +1184,10 @@ index 000000000..a6c19807a +} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/container/FurnaceRecipePlacement.java b/src/main/java/net/raphimc/viabedrock/api/model/container/FurnaceRecipePlacement.java new file mode 100644 -index 000000000..fa88d22a4 +index 000000000..065440f2d --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/model/container/FurnaceRecipePlacement.java -@@ -0,0 +1,205 @@ +@@ -0,0 +1,219 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -1100,6 +1197,7 @@ index 000000000..fa88d22a4 + +import net.raphimc.viabedrock.api.model.container.block.FurnaceContainer; +import net.raphimc.viabedrock.api.model.container.player.InventoryContainer; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ContainerEnumName; +import net.raphimc.viabedrock.protocol.model.BedrockItem; +import net.raphimc.viabedrock.protocol.model.FullContainerName; +import net.raphimc.viabedrock.protocol.model.inventory.ItemStackRequestSlotInfo; @@ -1116,6 +1214,7 @@ index 000000000..fa88d22a4 +import java.util.List; +import java.util.Map; +import java.util.Set; ++import java.util.function.BiPredicate; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.ToIntFunction; @@ -1128,6 +1227,7 @@ index 000000000..fa88d22a4 + public static Plan plan(final CraftingDataStorage data, final FurnaceContainer furnace, + final InventoryContainer inventory, final boolean maxItems, + final ToIntFunction maximum, final Function identity, ++ final BiPredicate canMerge, + final Predicate matches) { + if (!furnace.acceptsRecipe(data) || inventory.size() != 36) { + return null; @@ -1154,7 +1254,7 @@ index 000000000..fa88d22a4 + for (int slot = 0; slot < available.length && !input.isEmpty(); slot++) { + final BedrockItem destination = available[slot]; + if (!inventory.canPlaceItem(slot, input) || (merge -+ ? destination.isEmpty() || !hasServerId(destination) || destination.isDifferent(input) ++ ? destination.isEmpty() || !hasServerId(destination) || !canMerge.test(input, destination) + : !destination.isEmpty())) { + continue; + } @@ -1224,7 +1324,9 @@ index 000000000..fa88d22a4 + } + final int count = Math.min(source.amount(), target - input.amount()); + actions.add(new ItemStackRequestAction.PlaceAction(count, -+ inventory.requestSlotInfo(slot, source.netId()), ++ new ItemStackRequestSlotInfo(new FullContainerName(slot < 9 ++ ? ContainerEnumName.HotbarContainer : ContainerEnumName.InventoryContainer, null), ++ (byte) slot, source.netId()), + furnace.requestSlotInfo(0, input.isEmpty() ? 0 : input.netId()))); + final BedrockItem moved = input.isEmpty() ? source.copy() : input.copy(); + moved.setAmount(input.amount() + count); @@ -1266,34 +1368,255 @@ index 000000000..fa88d22a4 + } + + public List actions(final int requestId) { -+ if (requestId >= 0 || (requestId & 1) == 0) { -+ throw new IllegalArgumentException("Expected a negative odd client request ID"); -+ } + final Set changed = new HashSet<>(); + final List result = new ArrayList<>(this.actions.size()); -+ for (final ItemStackRequestAction.PlaceAction action : this.actions) { -+ final SlotKey source = new SlotKey(action.source().container(), action.source().slot()); -+ final SlotKey destination = new SlotKey(action.destination().container(), action.destination().slot()); -+ result.add(new ItemStackRequestAction.PlaceAction(action.count(), -+ changed.contains(source) ? requestSlot(action.source(), requestId) : action.source(), -+ changed.contains(destination) ? requestSlot(action.destination(), requestId) : action.destination())); -+ changed.add(source); -+ changed.add(destination); ++ for (final ItemStackRequestAction.PlaceAction transfer : this.actions) { ++ result.add(new ItemStackRequestAction.PlaceAction(transfer.count(), ++ requestSlot(transfer.source(), changed, requestId), ++ requestSlot(transfer.destination(), changed, requestId))); ++ changed.add(SlotKey.of(transfer.source())); ++ changed.add(SlotKey.of(transfer.destination())); + } + return List.copyOf(result); + } + -+ private static ItemStackRequestSlotInfo requestSlot(final ItemStackRequestSlotInfo slot, final int requestId) { -+ return new ItemStackRequestSlotInfo(slot.container(), slot.slot(), requestId); ++ private static ItemStackRequestSlotInfo requestSlot(final ItemStackRequestSlotInfo slot, ++ final Set changed, final int requestId) { ++ // Native predicted-container callbacks assign the request ID even to emptied slots. ++ return changed.contains(SlotKey.of(slot)) ++ ? new ItemStackRequestSlotInfo(slot.container(), slot.slot(), requestId) : slot; + } + + public void apply(final FurnaceContainer furnace, final InventoryContainer inventory) { + furnace.setItem(0, this.input.copy()); + this.inventoryUpdates.forEach((slot, item) -> inventory.setItem(slot, item.copy())); + } ++ ++ private record SlotKey(FullContainerName container, byte slot) { ++ ++ private static SlotKey of(final ItemStackRequestSlotInfo slot) { ++ final FullContainerName container = switch (slot.container().name()) { ++ case HotbarContainer, InventoryContainer, CombinedHotbarAndInventoryContainer -> ++ new FullContainerName(ContainerEnumName.CombinedHotbarAndInventoryContainer, ++ slot.container().dynamicId()); ++ default -> slot.container(); ++ }; ++ return new SlotKey(container, slot.slot()); ++ } ++ } ++ } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/container/NativeCookingRecipeSelection.java b/src/main/java/net/raphimc/viabedrock/api/model/container/NativeCookingRecipeSelection.java +new file mode 100644 +index 000000000..50b7fdf1d +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/model/container/NativeCookingRecipeSelection.java +@@ -0,0 +1,206 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.model.container; ++ ++import com.viaversion.nbt.tag.ByteTag; ++import net.raphimc.viabedrock.api.model.container.block.FurnaceContainer; ++import net.raphimc.viabedrock.api.model.container.player.InventoryContainer; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ContainerEnumName; ++import net.raphimc.viabedrock.protocol.model.BedrockItem; ++import net.raphimc.viabedrock.protocol.model.recipe.ItemRecipeKey; ++ ++import java.util.ArrayList; ++import java.util.Arrays; ++import java.util.List; ++import java.util.function.BiPredicate; ++import java.util.function.Function; ++import java.util.function.ToIntFunction; ++ ++/** Native output selection, ingredient collection, and item return. This is not Java recipe placement. */ ++public final class NativeCookingRecipeSelection { ++ ++ private NativeCookingRecipeSelection() { } ++ ++ public static Plan plan(final FurnaceContainer station, final InventoryContainer inventory, ++ final List inputs, final BedrockItem output, final Selection previous, ++ final ToIntFunction maximum, final Function identity, ++ final BiPredicate recipeMatches, ++ final BiPredicate matches, ++ final BiPredicate canMerge) { ++ if (station.size() != 3 || inventory.size() != 36 || inputs == null || inputs.isEmpty() || output.isEmpty()) { ++ return null; ++ } ++ final var planner = new Planner(station, inventory, previous, maximum, matches, canMerge); ++ if (!previous.output.isEmpty() && matches.test(previous.output, output)) { ++ planner.returnSlot(0); ++ planner.returnSlot(2); ++ return planner.finish(BedrockItem.empty()); ++ } ++ final BedrockItem ingredient = planner.stationItems[0]; ++ if (!ingredient.isEmpty() && inputs.stream().anyMatch(input -> matches.test(input, ingredient))) { ++ return planner.finish(output); ++ } ++ final List providers = List.of( ++ new Provider(station, planner.stationItems, 1, 2, ContainerEnumName.FurnaceFuelContainer), ++ new Provider(inventory, planner.playerItems, 0, 9, ContainerEnumName.HotbarContainer), ++ new Provider(inventory, planner.playerItems, 9, 36, ContainerEnumName.InventoryContainer)); ++ CookingRecipeSources.Result best = null; ++ BedrockItem selected = inputs.getFirst(); ++ for (final var input : inputs) { ++ final var candidates = CookingRecipeSources.collect(providers, Provider::items, identity, ++ item -> recipeMatches.test(input, item)); ++ if (best == null || candidates.amount() > best.amount()) { ++ best = candidates; ++ selected = input; ++ } ++ } ++ // The native model compares Item IDs before deciding whether to return the ingredient. ++ if (best.amount() == 0 || ingredient.isEmpty() || ingredient.identifier() != selected.identifier()) { ++ planner.returnSlot(0); ++ } ++ for (final var reference : best.sources()) { ++ final var provider = reference.container(); ++ final int slot = provider.begin + reference.slot(); ++ final BedrockItem source = provider.contents[slot]; ++ final BedrockItem current = planner.stationItems[0]; ++ if (!current.isEmpty() && !matches.test(current, source)) { ++ break; ++ } ++ planner.move(new Slot(provider.owner, slot, provider.collection), ++ new Slot(station, 0, station.getFullContainerName(0).name()), source.amount()); ++ } ++ // Both return calls replace history. Output return captures the post-placement inventory. ++ planner.returnSlot(2); ++ return planner.finish(output); ++ } ++ ++ private static BedrockItem[] copies(final BedrockItem[] items) { ++ return Arrays.stream(items).map(BedrockItem::copy).toArray(BedrockItem[]::new); ++ } ++ ++ private static boolean unlocked(final BedrockItem item) { ++ return item.tag() == null || !(item.tag().get("minecraft:item_lock") instanceof ByteTag lock && lock.asByte() != 0); ++ } ++ ++ public static BedrockItem remainder(final BedrockItem source, final int count) { ++ if (source.amount() == count) { ++ return BedrockItem.empty(); ++ } ++ final var remaining = source.copy(); ++ remaining.setAmount(source.amount() - count); ++ return remaining; ++ } ++ ++ public static final class Selection { ++ private final BedrockItem output; ++ private final BedrockItem[] history; ++ ++ public Selection() { ++ this(BedrockItem.empty(), null); ++ } ++ ++ private Selection(final BedrockItem output, final BedrockItem[] history) { ++ this.output = output.copy(); ++ this.history = history == null ? null : copies(history); ++ } ++ ++ } ++ ++ public record Slot(Container owner, int index, ContainerEnumName collection) { } ++ ++ public record Transfer(Slot source, Slot destination, int count) { } ++ ++ public record Plan(List transfers, Selection selection) { ++ public Plan { ++ transfers = List.copyOf(transfers); ++ } ++ } ++ ++ private record Provider(Container owner, BedrockItem[] contents, int begin, int end, ContainerEnumName collection) { ++ private BedrockItem[] items() { ++ return Arrays.copyOfRange(this.contents, this.begin, this.end); ++ } + } + -+ private record SlotKey(FullContainerName container, byte slot) { } ++ private static final class Planner { ++ private final FurnaceContainer station; ++ private final InventoryContainer player; ++ private final BedrockItem[] stationItems; ++ private final BedrockItem[] playerItems; ++ private BedrockItem[] history; ++ private final ToIntFunction maximum; ++ private final BiPredicate matches; ++ private final BiPredicate canMerge; ++ private final List transfers = new ArrayList<>(); ++ ++ private Planner(final FurnaceContainer station, final InventoryContainer player, final Selection previous, ++ final ToIntFunction maximum, final BiPredicate matches, ++ final BiPredicate canMerge) { ++ this.station = station; ++ this.player = player; ++ this.stationItems = copies(station.getItems()); ++ this.playerItems = copies(player.getItems()); ++ this.history = previous.history == null ? BedrockItem.emptyArray(36) : copies(previous.history); ++ this.maximum = maximum; ++ this.matches = matches; ++ this.canMerge = canMerge; ++ } ++ ++ private Plan finish(final BedrockItem output) { ++ return new Plan(this.transfers, new Selection(output, this.history)); ++ } ++ ++ private void returnSlot(final int slot) { ++ final Slot source = new Slot(this.station, slot, this.station.getFullContainerName(slot).name()); ++ // Native capture order examines inventory deficits before hotbar deficits. ++ for (int index = 9; index < 45 && !this.stationItems[slot].isEmpty(); index++) { ++ final int destination = index % 36; ++ final BedrockItem stored = this.history[destination]; ++ final BedrockItem current = this.playerItems[destination]; ++ if (!stored.isEmpty() && this.matches.test(stored, this.stationItems[slot]) ++ && (current.isEmpty() || this.matches.test(stored, current)) && stored.amount() > current.amount()) { ++ this.move(source, new Slot(this.player, destination, destination < 9 ++ ? ContainerEnumName.HotbarContainer : ContainerEnumName.InventoryContainer), ++ stored.amount() - current.amount()); ++ } ++ } ++ for (final boolean merge : new boolean[]{true, false}) { ++ for (int destination = 0; destination < 36 && !this.stationItems[slot].isEmpty(); destination++) { ++ final BedrockItem target = this.playerItems[destination]; ++ if (merge ? target.isEmpty() : !target.isEmpty()) { ++ continue; ++ } ++ this.move(source, new Slot(this.player, destination, ContainerEnumName.CombinedHotbarAndInventoryContainer), ++ this.stationItems[slot].amount()); ++ } ++ } ++ this.history = copies(this.playerItems); ++ } ++ ++ private void move(final Slot source, final Slot destination, final int requested) { ++ final BedrockItem[] sourceItems = source.owner == this.station ? this.stationItems : this.playerItems; ++ final BedrockItem[] destinationItems = destination.owner == this.station ? this.stationItems : this.playerItems; ++ final BedrockItem from = sourceItems[source.index]; ++ final BedrockItem to = destinationItems[destination.index]; ++ if (from.isEmpty() || !unlocked(from) || !unlocked(to) ++ || !destination.owner.canPlaceItem(destination.index, from) ++ || !to.isEmpty() && !this.canMerge.test(from, to)) { ++ return; ++ } ++ final int capacity = this.maximum.applyAsInt(to.isEmpty() ? from : to); ++ final int count = Math.min(Math.min(requested, from.amount()), Math.min(255, capacity) - to.amount()); ++ if (count <= 0) { ++ return; ++ } ++ this.transfers.add(new Transfer(source, destination, count)); ++ final BedrockItem moved = to.isEmpty() ? from.copy() : to.copy(); ++ moved.setAmount(to.amount() + count); ++ destinationItems[destination.index] = moved; ++ sourceItems[source.index] = remainder(from, count); ++ } ++ } + +} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/container/block/CraftingTableContainer.java b/src/main/java/net/raphimc/viabedrock/api/model/container/block/CraftingTableContainer.java @@ -1473,10 +1796,10 @@ index fec81fe6c..178d1ef15 100644 - } diff --git a/src/main/java/net/raphimc/viabedrock/api/model/container/block/FurnaceContainer.java b/src/main/java/net/raphimc/viabedrock/api/model/container/block/FurnaceContainer.java -index 09fb14352..44a1dd8ec 100644 +index 09fb14352..abd1e953c 100644 --- a/src/main/java/net/raphimc/viabedrock/api/model/container/block/FurnaceContainer.java +++ b/src/main/java/net/raphimc/viabedrock/api/model/container/block/FurnaceContainer.java -@@ -22,10 +22,31 @@ import com.viaversion.viaversion.api.minecraft.BlockPosition; +@@ -22,13 +22,42 @@ import com.viaversion.viaversion.api.minecraft.BlockPosition; import com.viaversion.viaversion.libs.mcstructs.text.TextComponent; import net.raphimc.viabedrock.api.model.container.Container; @@ -1485,12 +1808,16 @@ index 09fb14352..44a1dd8ec 100644 +import net.raphimc.viabedrock.api.model.container.CookingRecipeSources; +import net.raphimc.viabedrock.api.model.container.CookingRecipeInputs; +import net.raphimc.viabedrock.api.model.container.FurnaceRecipePlacement; ++import net.raphimc.viabedrock.api.model.container.NativeCookingRecipeSelection; +import net.raphimc.viabedrock.api.model.container.player.InventoryContainer; +import net.raphimc.viabedrock.protocol.model.inventory.ItemStackRequestInfo; ++import net.raphimc.viabedrock.protocol.model.inventory.ItemStackRequestAction; ++import net.raphimc.viabedrock.protocol.model.inventory.ItemStackRequestSlotInfo; +import net.raphimc.viabedrock.protocol.model.recipe.ShapelessRecipe; +import net.raphimc.viabedrock.protocol.rewriter.ItemRewriter; +import net.raphimc.viabedrock.protocol.storage.CraftingDataStorage; +import net.raphimc.viabedrock.protocol.storage.CraftingDataTracker; ++import net.raphimc.viabedrock.protocol.storage.GameSessionStorage; +import net.raphimc.viabedrock.protocol.storage.InventoryRequestStorage; +import net.raphimc.viabedrock.protocol.storage.InventoryRequestTracker; +import net.raphimc.viabedrock.protocol.storage.InventoryTracker; @@ -1503,12 +1830,19 @@ index 09fb14352..44a1dd8ec 100644 +import net.raphimc.viabedrock.protocol.model.BedrockItem; + +import java.util.Arrays; ++import java.util.ArrayList; ++import java.util.HashMap; +import java.util.List; +import java.util.function.Function; public class FurnaceContainer extends Container { -@@ -51,6 +72,97 @@ public class FurnaceContainer extends Container { ++ private NativeCookingRecipeSelection.Selection nativeSelection = new NativeCookingRecipeSelection.Selection(); ++ + public FurnaceContainer( + final UserConnection user, final byte containerId, final TextComponent title, final BlockPosition position) { + super( +@@ -51,6 +80,184 @@ public class FurnaceContainer extends Container { super(user, containerId, type, title, position, 3, validBlockTags); } @@ -1541,7 +1875,7 @@ index 09fb14352..44a1dd8ec 100644 + final InventoryContainer player = inventory.getInventoryContainer(); + final var items = this.user.get(ItemRewriter.class); + final var plan = FurnaceRecipePlacement.plan(data, this, player, maxItems, -+ items::maxStackSize, items::recipeKey, ++ items::maxStackSize, items::recipeKey, items::canMerge, + candidate -> inputs.stream().anyMatch(input -> items.matchesRecipeStack(input, candidate))); + if (plan == null) { + return false; @@ -1565,6 +1899,93 @@ index 09fb14352..44a1dd8ec 100644 + return new CookingRecipeCatalog(this.user, this); + } + ++ /** Execute the authored primary selection independently of Java's recipe-book quantity mode. */ ++ public boolean handleNativeRecipeSelection(final CookingRecipeCatalog catalog, final CraftingDataStorage data, final boolean focused) { ++ final var inventory = this.user.get(InventoryTracker.class); ++ final var requests = this.user.get(InventoryRequestTracker.class); ++ final var session = this.user.get(GameSessionStorage.class); ++ if (session == null || !session.isInventoryServerAuthoritative() ++ || inventory.getCurrentContainer() != this || inventory.getPendingCloseContainer() != null ++ || !requests.canStartRecipePlacement() || requests.isPredictingClick() ++ || !focused && !inventory.getHudContainer().getItem(0).isEmpty() || !this.acceptsRecipe(data)) { ++ return false; ++ } ++ final var inputs = catalog.inputs(data); ++ final var items = this.user.get(ItemRewriter.class); ++ final var output = ((ShapelessRecipe) data.recipe()).getResults().getFirst(); ++ final var player = inventory.getInventoryContainer(); ++ final var stationBefore = Arrays.stream(this.getItems()).map(BedrockItem::copy).toArray(BedrockItem[]::new); ++ final var playerBefore = Arrays.stream(player.getItems()).map(BedrockItem::copy).toArray(BedrockItem[]::new); ++ final var cursor = inventory.getHudContainer(); ++ final long cursorRevision = cursor.serverRevision(); ++ final var cursorBefore = Arrays.stream(cursor.getItems()).map(BedrockItem::copy).toArray(BedrockItem[]::new); ++ final var plan = NativeCookingRecipeSelection.plan(this, player, inputs, output, this.nativeSelection, ++ items::maxStackSize, items::recipeKey, items::matchesRecipeStack, items::matchesStackQuery, items::canMerge); ++ if (plan == null || !catalog.current(this.user.get(CraftingDataTracker.class)) ++ || inventory != this.user.get(InventoryTracker.class) || inventory.getCurrentContainer() != this ++ || inventory.getPendingCloseContainer() != null || !requests.canStartRecipePlacement() ++ || !Arrays.equals(stationBefore, this.getItems()) || !Arrays.equals(playerBefore, player.getItems()) ++ || cursor.serverRevision() != cursorRevision || !Arrays.equals(cursorBefore, cursor.getItems())) { ++ return false; ++ } ++ for (final var transfer : plan.transfers()) { ++ for (final var slot : List.of(transfer.source(), transfer.destination())) { ++ final var item = slot.owner().getItem(slot.index()); ++ if (!item.isEmpty() && (item.netId() == null || item.netId() <= 0)) { ++ return false; ++ } ++ } ++ } ++ if (!plan.transfers().isEmpty()) { ++ final var batch = new ArrayList(); ++ final var changed = new HashMap(); ++ requests.setPredictingClick(true); ++ try { ++ for (final var transfer : plan.transfers()) { ++ final int id = requests.nextRequestId(); ++ final var source = transfer.source(); ++ final var destination = transfer.destination(); ++ final var from = source.owner().getItem(source.index()); ++ final var to = destination.owner().getItem(destination.index()); ++ final var action = new ItemStackRequestAction.PlaceAction(transfer.count(), ++ nativeSlot(source, changed.get(new PhysicalSlot(source.owner(), source.index()))), ++ nativeSlot(destination, changed.get(new PhysicalSlot(destination.owner(), destination.index())))); ++ final var request = new InventoryRequestStorage(new ItemStackRequestInfo(id, List.of(action), ++ List.of(), TextProcessingEventOrigin.unknown), 0, inventory.getHudContainer().copy(), ++ List.of(this.copy(), player.copy())); ++ final var moved = to.isEmpty() ? from.copy() : to.copy(); ++ moved.setAmount(to.amount() + transfer.count()); ++ moved.setNetId(id); ++ final var remaining = NativeCookingRecipeSelection.remainder(from, transfer.count()); ++ remaining.setNetId(id); ++ source.owner().setItem(source.index(), remaining); ++ destination.owner().setItem(destination.index(), moved); ++ changed.put(new PhysicalSlot(source.owner(), source.index()), id); ++ changed.put(new PhysicalSlot(destination.owner(), destination.index()), id); ++ request.capturePrediction(inventory); ++ batch.add(request); ++ } ++ requests.sendTransferBatch(batch); ++ } catch (final RuntimeException error) { ++ this.setItems(stationBefore); ++ player.setItems(playerBefore); ++ throw error; ++ } finally { ++ requests.setPredictingClick(false); ++ } ++ PacketFactory.sendJavaContainerSetContent(this.user, this); ++ PacketFactory.sendJavaContainerSetContent(this.user, player); ++ } ++ this.nativeSelection = plan.selection(); ++ return true; ++ } ++ ++ private static ItemStackRequestSlotInfo nativeSlot(final NativeCookingRecipeSelection.Slot slot, final Integer changedId) { ++ final var item = slot.owner().getItem(slot.index()); ++ return new ItemStackRequestSlotInfo(new FullContainerName(slot.collection(), null), (byte) slot.index(), ++ changedId != null ? changedId : item.isEmpty() ? 0 : item.netId()); ++ } ++ + /** Native output availability includes all matching unlocked alternatives at this station. */ + public int recipeAvailability(final CraftingDataStorage data, final boolean filtering, final boolean includeCursor) { + return this.recipeAvailability(this.recipeCatalog(), data, filtering, includeCursor); @@ -1606,6 +2027,13 @@ index 09fb14352..44a1dd8ec 100644 @Override public FullContainerName getFullContainerName(final int slot) { return switch (slot) { +@@ -74,4 +281,6 @@ public class FurnaceContainer extends Container { + }; + } + ++ private record PhysicalSlot(Container owner, int slot) { } ++ + } diff --git a/src/main/java/net/raphimc/viabedrock/api/model/container/player/HudContainer.java b/src/main/java/net/raphimc/viabedrock/api/model/container/player/HudContainer.java index 4e784b7df..638c832b4 100644 --- a/src/main/java/net/raphimc/viabedrock/api/model/container/player/HudContainer.java @@ -1866,7 +2294,7 @@ index b5ae97d51..957492cd9 100644 return this.selectedHotbarSlot; } 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 bcefe0aea..bcfea603b 100644 +index fba5d12ab..84a2d6bbe 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 @@ -1886,7 +2314,7 @@ index bcefe0aea..bcfea603b 100644 } diff --git a/src/main/java/net/raphimc/viabedrock/protocol/BedrockProtocol.java b/src/main/java/net/raphimc/viabedrock/protocol/BedrockProtocol.java -index 2f3f130a3..860aa0e7e 100644 +index 04e687fa4..817fe139b 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/BedrockProtocol.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/BedrockProtocol.java @@ -183,6 +183,7 @@ public class BedrockProtocol @@ -1897,11 +2325,11 @@ index 2f3f130a3..860aa0e7e 100644 user.put(new NativeHudTitleState()); user.put(new NativeHudActionbarState()); user.put(new NativeSkinStorage(user)); -@@ -198,6 +199,7 @@ public class BedrockProtocol - user.put(new NpcDialogueStorage()); +@@ -199,6 +200,7 @@ public class BedrockProtocol user.put(new InventoryRequestTracker(user)); + user.put(new PlayerInventoryScreenTracker(user)); user.put(new CraftingDataTracker(user)); -+ user.put(new FurnaceOptionsStorage()); ++ user.put(new InventoryOptionsStorage()); user.put(new BreakingTracker(user)); user.put(new InventoryTransactionRewriter(user)); @@ -1940,6 +2368,22 @@ index 000000000..605d03d49 + } + +} +diff --git a/src/main/java/net/raphimc/viabedrock/protocol/model/InventoryOptions.java b/src/main/java/net/raphimc/viabedrock/protocol/model/InventoryOptions.java +new file mode 100644 +index 000000000..a3d828beb +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/protocol/model/InventoryOptions.java +@@ -0,0 +1,10 @@ ++/* ++ * 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; ++ ++/** Native player-inventory preferences, including independent inventory and crafting layouts. */ ++public record InventoryOptions(int leftTab, int rightTab, boolean filtering, int inventoryLayout, int craftingLayout) { ++} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/model/recipe/ItemDescriptor.java b/src/main/java/net/raphimc/viabedrock/protocol/model/recipe/ItemDescriptor.java index d62d7b11f..69e695e4e 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/model/recipe/ItemDescriptor.java @@ -2246,10 +2690,10 @@ index 11c4d0024..9dc86539c 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 7385b056e..c166c0851 100644 +index 3bf84f65a..60243c409 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/InventoryPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/InventoryPackets.java -@@ -92,6 +92,7 @@ import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.*; +@@ -93,6 +93,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; @@ -2257,7 +2701,7 @@ index 7385b056e..c166c0851 100644 import net.raphimc.viabedrock.protocol.model.ItemCooldown; import net.raphimc.viabedrock.protocol.model.FullContainerName; import net.raphimc.viabedrock.protocol.model.inventory.BedrockInventoryTransaction; -@@ -151,11 +152,54 @@ public final class InventoryPackets { +@@ -152,11 +153,69 @@ public final class InventoryPackets { final short javaId = container.translateContainerData(id); if (javaId < 0) { wrapper.cancel(); @@ -2272,27 +2716,44 @@ index 7385b056e..c166c0851 100644 + + static void handleFurnaceOptions(final PacketWrapper wrapper) { + final var options = wrapper.read(BedrockTypes.FURNACE_OPTIONS); -+ final var preferences = wrapper.user().get(FurnaceOptionsStorage.class); ++ final var preferences = wrapper.user().get(InventoryOptionsStorage.class); + if (preferences.apply(options)) { + preferences.writeJavaSettings(wrapper); + } else { + wrapper.cancel(); + } -+ FurnaceOptionsStorage.sendNativeUpdate(wrapper.user(), options); ++ InventoryOptionsStorage.sendNativeUpdate(wrapper.user(), options); + NativeCookingUiState.after(wrapper); + } + ++ static void handleInventoryOptions(final PacketWrapper wrapper) { ++ final var preferences = wrapper.user().get(InventoryOptionsStorage.class); ++ preferences.applyInventory(wrapper.read(BedrockTypes.INVENTORY_OPTIONS), craftingInventory(wrapper.user())); ++ preferences.writeJavaSettings(wrapper); ++ } ++ ++ private static boolean craftingInventory(final UserConnection user) { ++ final var container = user.get(InventoryTracker.class).getCurrentContainer(); ++ return container != null && container.type() == ContainerType.WORKBENCH; ++ } ++ + static void handleJavaRecipeBookSettings(final PacketWrapper wrapper) { + final int bookType = wrapper.read(Types.VAR_INT); + final boolean open = wrapper.read(Types.BOOLEAN); + final boolean filtering = wrapper.read(Types.BOOLEAN); -+ final var options = wrapper.user().get(FurnaceOptionsStorage.class) -+ .changeJavaBook(bookType, open, filtering); ++ if (bookType == 0) { ++ wrapper.setPacketType(ServerboundBedrockPackets.SET_PLAYER_INVENTORY_OPTIONS); ++ wrapper.write(BedrockTypes.INVENTORY_OPTIONS, wrapper.user().get(InventoryOptionsStorage.class) ++ .changeJavaInventoryBook(craftingInventory(wrapper.user()), open, filtering)); ++ return; ++ } ++ final var options = wrapper.user().get(InventoryOptionsStorage.class) ++ .changeJavaFurnaceBook(bookType, open, filtering); + if (options == null) { + wrapper.cancel(); + } else { + wrapper.write(BedrockTypes.FURNACE_OPTIONS, options); -+ FurnaceOptionsStorage.sendNativeUpdate(wrapper.user(), options); ++ InventoryOptionsStorage.sendNativeUpdate(wrapper.user(), options); + NativeCookingUiState.sendUpdate(wrapper.user()); + } + } @@ -2301,18 +2762,16 @@ index 7385b056e..c166c0851 100644 + final var packet = PacketWrapper.create(ServerboundBedrockPackets.SET_PLAYER_FURNACE_OPTIONS, user); + packet.write(BedrockTypes.FURNACE_OPTIONS, options); + packet.sendToServer(BedrockProtocol.class); -+ final var preferences = user.get(FurnaceOptionsStorage.class); ++ final var preferences = user.get(InventoryOptionsStorage.class); + if (preferences.apply(options)) { -+ final var javaSettings = PacketWrapper.create(ClientboundPackets26_3.RECIPE_BOOK_SETTINGS, user); -+ preferences.writeJavaSettings(javaSettings); -+ javaSettings.send(BedrockProtocol.class); ++ preferences.sendJavaSettings(user); + } -+ FurnaceOptionsStorage.sendNativeUpdate(user, options); ++ InventoryOptionsStorage.sendNativeUpdate(user, options); + NativeCookingUiState.sendUpdate(user); } static void handleContainerClose(final PacketWrapper wrapper) { -@@ -503,7 +547,13 @@ public final class InventoryPackets { +@@ -512,7 +571,13 @@ public final class InventoryPackets { if (container instanceof LecternContainer lectern) { lectern.sendInitialState(); } @@ -2327,7 +2786,7 @@ index 7385b056e..c166c0851 100644 }); protocol.registerClientbound( ClientboundBedrockPackets.CONTAINER_CLOSE, -@@ -540,6 +590,9 @@ public final class InventoryPackets { +@@ -549,6 +614,9 @@ public final class InventoryPackets { container.markServerUpdate(); wrapper.user().get(InventoryRequestTracker.class) .handleAuthoritativeContent(container); @@ -2337,7 +2796,7 @@ index 7385b056e..c166c0851 100644 if (container instanceof StonecutterContainer stonecutter) { stonecutter.refreshInput(); wrapper.cancel(); -@@ -547,6 +600,7 @@ public final class InventoryPackets { +@@ -556,6 +624,7 @@ public final class InventoryPackets { } PacketFactory.writeJavaContainerSetContent(wrapper, container); PacketFactory.sendJavaCursorForHudUpdate(wrapper.user(), container); @@ -2345,7 +2804,7 @@ index 7385b056e..c166c0851 100644 } else { wrapper.cancel(); } -@@ -575,6 +629,12 @@ public final class InventoryPackets { +@@ -584,6 +653,12 @@ public final class InventoryPackets { container.markServerUpdate(); wrapper.user().get(InventoryRequestTracker.class) .handleAuthoritativeSlot(container, slot); @@ -2358,7 +2817,7 @@ index 7385b056e..c166c0851 100644 if (container instanceof StonecutterContainer stonecutter && slot == 3) { stonecutter.refreshInput(); wrapper.cancel(); -@@ -598,6 +658,7 @@ public final class InventoryPackets { +@@ -607,6 +682,7 @@ public final class InventoryPackets { } wrapper.write( VersionedTypes.V26_3.item, container.getJavaItem(slot)); // item @@ -2366,7 +2825,7 @@ index 7385b056e..c166c0851 100644 } else { wrapper.cancel(); } -@@ -659,6 +720,10 @@ public final class InventoryPackets { +@@ -668,6 +744,14 @@ public final class InventoryPackets { inventoryTracker.removeDynamicContainer(containerName); } }); @@ -2374,16 +2833,20 @@ index 7385b056e..c166c0851 100644 + ClientboundBedrockPackets.SET_PLAYER_FURNACE_OPTIONS, + ClientboundPackets26_3.RECIPE_BOOK_SETTINGS, + InventoryPackets::handleFurnaceOptions); ++ protocol.registerClientbound( ++ ClientboundBedrockPackets.SET_PLAYER_INVENTORY_OPTIONS, ++ ClientboundPackets26_3.RECIPE_BOOK_SETTINGS, ++ InventoryPackets::handleInventoryOptions); protocol.registerClientbound( ClientboundBedrockPackets.CRAFTING_DATA, null, -@@ -668,7 +733,27 @@ public final class InventoryPackets { +@@ -677,7 +761,27 @@ public final class InventoryPackets { wrapper.user().get(CraftingDataTracker.class); final CraftingDataReader.Data data = CraftingDataReader.read(wrapper); tracker.updateCraftingDataList(data.recipes(), data.clearRecipes()); + wrapper.user().get(InventoryTracker.class).getInventoryContainer() + .refreshCraftingOutput(); -+ tracker.sendJavaUpdateRecipes(wrapper.user()); + tracker.sendJavaUpdateRecipes(wrapper.user()); + NativeCookingUiState.sendUpdate(wrapper.user()); + }); + protocol.registerClientbound( @@ -2400,12 +2863,12 @@ index 7385b056e..c166c0851 100644 + final CraftingDataTracker tracker = + wrapper.user().get(CraftingDataTracker.class); + tracker.updateUnlockedRecipes(type, ids); - tracker.sendJavaUpdateRecipes(wrapper.user()); ++ tracker.sendJavaUpdateRecipes(wrapper.user()); + NativeCookingUiState.sendUpdate(wrapper.user()); }); protocol.registerClientbound( ClientboundBedrockPackets.CONTAINER_SET_DATA, -@@ -876,6 +961,46 @@ public final class InventoryPackets { +@@ -890,6 +994,46 @@ public final class InventoryPackets { PacketFactory.sendJavaContainerSetContent(wrapper.user(), container); } }); @@ -2453,35 +2916,61 @@ index 7385b056e..c166c0851 100644 ServerboundPackets26_3.CONTAINER_BUTTON_CLICK, 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 bf77f0bd5..c166b2b7d 100644 +index 1b78b15b3..f5ad56265 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/JoinPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/JoinPackets.java -@@ -592,6 +592,7 @@ public final class JoinPackets { +@@ -596,6 +596,7 @@ public final class JoinPackets { user.get(EntityTracker.class).sendNativeCustomEntitySnapshot(); user.get(PlayerInputStorage.class).markJoined(); user.get(PlayerInputStorage.class).sendSnapshot(); -+ user.get(net.raphimc.viabedrock.protocol.storage.FurnaceOptionsStorage.class).sendNativeSnapshot(user); ++ user.get(net.raphimc.viabedrock.protocol.storage.InventoryOptionsStorage.class).sendNativeSnapshot(user); HudPackets.sendNativeRoot(user); user.get(HudVisibilityStorage.class).markJoined(); user.get(HudVisibilityStorage.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 b405927ae..5ffd06945 100644 +index 0b8c600e9..4fef2c18d 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java -@@ -18,7 +18,12 @@ - package net.raphimc.viabedrock.protocol.packet; - +@@ -20,7 +20,14 @@ package net.raphimc.viabedrock.protocol.packet; + import net.raphimc.viabedrock.api.model.PlayerInventoryScreenRequest; + import net.raphimc.viabedrock.protocol.storage.PlayerInventoryScreenTracker; import net.raphimc.viabedrock.protocol.storage.NativeContainerUiState; +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.NativeCookingActionResult; +import net.raphimc.viabedrock.api.model.NativeFurnaceOptions; -+import net.raphimc.viabedrock.protocol.storage.FurnaceOptionsStorage; ++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; -@@ -183,6 +188,20 @@ public final class MultiStatePackets { +@@ -171,6 +178,23 @@ public final class MultiStatePackets { + } + return; + } ++ if (channel.equals(NativeCookingAction.CHANNEL)) { ++ if (NativeCookingUiState.actionsEnabled(wrapper.user())) { ++ try { ++ final var action = wrapper.read(NativeCookingAction.TYPE); ++ final var state = wrapper.user().get(NativeCookingUiState.class); ++ final boolean accepted = state.handleAction(action); ++ NativeCookingUiState.sendUpdate(wrapper.user()); ++ final var result = PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, wrapper.user()); ++ result.write(Types.STRING, NativeCookingActionResult.CHANNEL); ++ result.write(NativeCookingActionResult.TYPE, new NativeCookingActionResult(action, accepted, state.current(action))); ++ result.send(BedrockProtocol.class); ++ } catch (final IllegalArgumentException | IndexOutOfBoundsException malformed) { ++ // Malformed or unsupported controls cannot authorize an ordinary Java recipe click. ++ } ++ } ++ return; ++ } + 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)); +@@ -195,6 +219,20 @@ public final class MultiStatePackets { } return; } @@ -2502,17 +2991,20 @@ index b405927ae..5ffd06945 100644 if (channel.equals(NativeContainerMessage.CURRENT_CHANNEL)) { if (wrapper.user().getProtocolInfo().getClientState() == State.PLAY && wrapper.user().get(ChannelStorage.class).hasChannel(channel) -@@ -207,6 +226,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); +@@ -223,6 +261,12 @@ public final class MultiStatePackets { + if (screens != null) { + screens.refreshCapability(); + } ++ if (wrapper.user().has(NativeCookingUiState.class)) { ++ wrapper.user().get(NativeCookingUiState.class).refreshActionCapability(); ++ } + if (channels.contains(NativeFurnaceOptions.CHANNEL)) { -+ FurnaceOptionsStorage.sendNativeCapability(wrapper.user(), false); ++ InventoryOptionsStorage.sendNativeCapability(wrapper.user(), false); + } if (channels.contains(PlayerInputPermissions.CHANNEL) && wrapper.user().has(PlayerInputStorage.class)) { wrapper.user().get(PlayerInputStorage.class).withdrawRawInput(); } -@@ -219,6 +241,11 @@ public final class MultiStatePackets { +@@ -235,6 +279,11 @@ public final class MultiStatePackets { && wrapper.user().has(NativeContainerUiState.class)) { wrapper.user().get(NativeContainerUiState.class).clearActions(); } @@ -2524,29 +3016,35 @@ index b405927ae..5ffd06945 100644 if (wrapper.user().has(VehiclePredictionStorage.class)) { wrapper.user().get(VehiclePredictionStorage.class).refresh(); } -@@ -265,6 +292,9 @@ public final class MultiStatePackets { - if (channel.equals("minecraft:register")) { - final List channels = Arrays.asList(new String(wrapper.read(Types.SERVERBOUND_CUSTOM_PAYLOAD_DATA), StandardCharsets.UTF_8).split("\0")); - wrapper.user().get(ChannelStorage.class).addChannels(channels); -+ if (channels.contains(NativeFurnaceOptions.CHANNEL) && wrapper.user().has(FurnaceOptionsStorage.class)) { -+ wrapper.user().get(FurnaceOptionsStorage.class).sendNativeSnapshot(wrapper.user()); +@@ -285,6 +334,12 @@ public final class MultiStatePackets { + if (screens != null) { + screens.refreshCapability(); + } ++ if (wrapper.user().has(NativeCookingUiState.class)) { ++ wrapper.user().get(NativeCookingUiState.class).refreshActionCapability(); ++ } ++ if (channels.contains(NativeFurnaceOptions.CHANNEL) && wrapper.user().has(InventoryOptionsStorage.class)) { ++ wrapper.user().get(InventoryOptionsStorage.class).sendNativeSnapshot(wrapper.user()); + } WorldPackets.sendNativeCommandCapability(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 e984d0f67..87e674a07 100644 +index 771a85f95..9eb3fb9fe 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemRewriter.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemRewriter.java -@@ -40,6 +40,7 @@ import com.viaversion.viaversion.util.Key; +@@ -40,9 +40,9 @@ import com.viaversion.viaversion.util.Key; import net.raphimc.viabedrock.ViaBedrock; import net.raphimc.viabedrock.api.model.BlockState; + import net.raphimc.viabedrock.api.model.NativeItemNames; +-import net.raphimc.viabedrock.api.model.container.NativeItemStackMatcher; + import net.raphimc.viabedrock.api.model.NativeItemStacking; import net.raphimc.viabedrock.api.model.NativeItemStackData; -+import net.raphimc.viabedrock.api.model.container.CookingRecipeOutputs; ++import net.raphimc.viabedrock.api.model.container.NativeItemStackMatcher; import net.raphimc.viabedrock.api.resourcepack.definition.ItemDefinitions; import net.raphimc.viabedrock.api.util.TextUtil; import net.raphimc.viabedrock.protocol.BedrockProtocol; -@@ -66,6 +67,7 @@ import net.raphimc.viabedrock.protocol.types.BedrockTypes; +@@ -71,6 +71,7 @@ import net.raphimc.viabedrock.protocol.types.BedrockTypes; import net.raphimc.viabedrock.protocol.types.array.ArrayType; import net.raphimc.viabedrock.protocol.types.inventory.ItemStackRequestResultType; import net.raphimc.viabedrock.protocol.types.item.BedrockItemType; @@ -2554,7 +3052,7 @@ index e984d0f67..87e674a07 100644 import net.raphimc.viabedrock.protocol.types.item.NetworkItemStackDescriptorType; import net.raphimc.viabedrock.protocol.types.item.NetworkItemStackContext; -@@ -96,6 +98,8 @@ public class ItemRewriter extends StoredObject implements NetworkItemStackContex +@@ -102,6 +103,8 @@ public class ItemRewriter extends StoredObject implements NetworkItemStackContex private final Set componentItems; private final Int2ObjectMap blockItemValidBlockStates; private final Type itemType; @@ -2563,7 +3061,7 @@ index e984d0f67..87e674a07 100644 private final Type optionalItemType; private final Type itemArrayType; private final Type newItemType; -@@ -183,6 +187,8 @@ public class ItemRewriter extends StoredObject implements NetworkItemStackContex +@@ -189,6 +192,8 @@ public class ItemRewriter extends StoredObject implements NetworkItemStackContex this.items.getOrDefault("minecraft:shield", 0), false, this); @@ -2572,7 +3070,7 @@ index e984d0f67..87e674a07 100644 this.optionalItemType = new OptionalType<>(this.itemType); this.itemArrayType = new ArrayType<>(this.itemType, BedrockTypes.UNSIGNED_VAR_INT); this.newItemType = -@@ -540,10 +546,35 @@ public class ItemRewriter extends StoredObject implements NetworkItemStackContex +@@ -515,6 +520,21 @@ public class ItemRewriter extends StoredObject implements NetworkItemStackContex ? blocks.blockItemState(queryAssociation, (short) auxiliary) : null); } @@ -2594,21 +3092,7 @@ index e984d0f67..87e674a07 100644 public ItemRegistry getItemRegistry() { return this.registry; } - -+ /** Match freshly decoded cooking outputs while their registry ownership remains current. */ -+ public boolean matchesCookingOutput(final BedrockItem query, final BedrockItem candidate) { -+ if (query == null || candidate == null || this.registry.definition(query.identifier()) == null -+ || this.registry.definition(candidate.identifier()) == null) { -+ return false; -+ } -+ final var blocks = this.user().get(BlockStateRewriter.class); -+ return CookingRecipeOutputs.matches(query, candidate, blocks::blockState, blocks::restrictionBlocks); -+ } -+ - public int maxStackSize(final BedrockItem item) { - if (item == null || item.isEmpty()) { - return 64; -@@ -569,6 +600,14 @@ public class ItemRewriter extends StoredObject implements NetworkItemStackContex +@@ -572,6 +592,14 @@ public class ItemRewriter extends StoredObject implements NetworkItemStackContex return this.itemType; } @@ -3310,12 +3794,12 @@ index 73165b006..669157039 100644 + public record RecipeMatch(CraftingDataStorage data, int[] amounts) { } + } -diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/FurnaceOptionsStorage.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/FurnaceOptionsStorage.java +diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryOptionsStorage.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryOptionsStorage.java new file mode 100644 -index 000000000..c3e30f8ae +index 000000000..8a573aaac --- /dev/null -+++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/FurnaceOptionsStorage.java -@@ -0,0 +1,139 @@ ++++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryOptionsStorage.java +@@ -0,0 +1,187 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -3332,25 +3816,76 @@ index 000000000..c3e30f8ae +import net.raphimc.viabedrock.api.model.NativeFurnaceOptions; +import net.raphimc.viabedrock.protocol.BedrockProtocol; +import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.FurnaceLayout; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.InventoryLayout; +import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.FurnaceLeftTabIndex; +import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.SetPlayerFurnaceOptionsPacketPayload_FurnaceType; +import net.raphimc.viabedrock.protocol.model.FurnaceOptions; ++import net.raphimc.viabedrock.protocol.model.InventoryOptions; + +import java.nio.charset.StandardCharsets; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; ++import java.util.Objects; + +/** Connection-local native options and the separate Java recipe-book projection. */ -+public final class FurnaceOptionsStorage implements StorableObject { ++public final class InventoryOptionsStorage implements StorableObject { + + private final Map options = new HashMap<>(); ++ private InventoryOptions inventoryOptions; + private final BookSettings[] javaBooks = { + new BookSettings(false, false), new BookSettings(false, false), + new BookSettings(false, false), new BookSettings(false, false) + }; + ++ public InventoryOptions inventoryOptions() { ++ return this.inventoryOptions; ++ } ++ ++ public void applyInventory(final InventoryOptions value, final boolean crafting) { ++ this.inventoryOptions = Objects.requireNonNull(value); ++ this.projectInventory(crafting); ++ } ++ ++ /** Java uses one crafting book for two independent native screen layouts. */ ++ public boolean projectInventory(final boolean crafting) { ++ if (this.inventoryOptions == null) { ++ return false; ++ } ++ final int layout = crafting ? this.inventoryOptions.craftingLayout() : this.inventoryOptions.inventoryLayout(); ++ final boolean open = switch (InventoryLayout.getByValue(layout, InventoryLayout.None)) { ++ case Default, RecipeBookOnly -> true; ++ case InventoryOnly -> false; ++ case None -> this.javaBooks[0].open(); ++ }; ++ final var settings = new BookSettings(open, this.inventoryOptions.filtering()); ++ final boolean changed = !settings.equals(this.javaBooks[0]); ++ this.javaBooks[0] = settings; ++ return changed; ++ } ++ ++ public InventoryOptions changeJavaInventoryBook(final boolean crafting, final boolean open, final boolean filtering) { ++ final int requestedLayout = (open ? InventoryLayout.Default : InventoryLayout.InventoryOnly).getValue(); ++ final boolean unseen = this.inventoryOptions == null; ++ final var previous = unseen ? new InventoryOptions(0, 0, false, 0, 0) : this.inventoryOptions; ++ final int oldLayout = crafting ? previous.craftingLayout() : previous.inventoryLayout(); ++ final int layout = unseen || this.javaBooks[0].open() != open ? requestedLayout : oldLayout; ++ this.inventoryOptions = new InventoryOptions(previous.leftTab(), previous.rightTab(), filtering, ++ crafting ? previous.inventoryLayout() : layout, crafting ? layout : previous.craftingLayout()); ++ this.javaBooks[0] = new BookSettings(open, filtering); ++ return this.inventoryOptions; ++ } ++ ++ public void sendJavaSettings(final UserConnection user) { ++ if (user.getProtocolInfo().getClientState() != State.PLAY) { ++ return; ++ } ++ final var packet = PacketWrapper.create(ClientboundPackets26_3.RECIPE_BOOK_SETTINGS, user); ++ this.writeJavaSettings(packet); ++ packet.send(BedrockProtocol.class); ++ } ++ + public FurnaceOptions options(final int furnaceType) { + return this.options.get(furnaceType); + } @@ -3410,11 +3945,7 @@ index 000000000..c3e30f8ae + } + + /** Java can express layout and filtering, but not native tab selection. */ -+ public FurnaceOptions changeJavaBook(final int bookType, final boolean open, final boolean filtering) { -+ if (bookType < 0 || bookType >= this.javaBooks.length) { -+ return null; -+ } -+ this.javaBooks[bookType] = new BookSettings(open, filtering); ++ public FurnaceOptions changeJavaFurnaceBook(final int bookType, final boolean open, final boolean filtering) { + final var type = switch (bookType) { + case 1 -> SetPlayerFurnaceOptionsPacketPayload_FurnaceType.Furnace; + case 2 -> SetPlayerFurnaceOptionsPacketPayload_FurnaceType.BlastFurnace; @@ -3424,6 +3955,7 @@ index 000000000..c3e30f8ae + if (type == null) { + return null; + } ++ this.javaBooks[bookType] = new BookSettings(open, filtering); + final FurnaceOptions previous = this.options(type.getValue()); + final FurnaceOptions value = new FurnaceOptions(type.getValue(), + previous == null ? FurnaceLeftTabIndex.None.getValue() : previous.leftTab(), filtering, @@ -3456,11 +3988,11 @@ index 000000000..c3e30f8ae + +} 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 ce84b3856..2723e75ce 100644 +index 208c07858..0f3889de3 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryRequestTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryRequestTracker.java -@@ -95,6 +95,10 @@ public class InventoryRequestTracker extends StoredObject { - return !this.requests.isEmpty() || this.craftedItemSynchronization != null; +@@ -162,6 +162,10 @@ public class InventoryRequestTracker extends StoredObject { + return !this.requests.isEmpty() || this.craftedItemSynchronization != null || this.batchChanges != null; } + public boolean canStartRecipePlacement() { @@ -3470,28 +4002,76 @@ index ce84b3856..2723e75ce 100644 public boolean isPredictingClick() { return this.predictingClick; } -@@ -284,6 +288,7 @@ public class InventoryRequestTracker extends StoredObject { - this.requests.clear(); +@@ -365,6 +369,7 @@ public class InventoryRequestTracker extends StoredObject { + this.transferBatch = null; this.craftedItemSynchronization = null; this.resetRecovery(); + tracker.getInventoryContainer().updateCraftingOutput(); PacketFactory.sendJavaContainerSetContent(this.user(), tracker.getInventoryContainer()); } } -@@ -328,6 +333,7 @@ public class InventoryRequestTracker extends StoredObject { - } +@@ -422,6 +427,7 @@ public class InventoryRequestTracker extends StoredObject { + this.batchChanges.addAll(changed); + return; } - + tracker.getInventoryContainer().updateCraftingOutput(); if (response.result() == ItemStackNetResult.Success) { this.drainQueuedInteractions(); } +@@ -458,6 +464,7 @@ public class InventoryRequestTracker extends StoredObject { + this.predictingClick = wasPredicting; + this.batchChanges = null; + } ++ inventory.getInventoryContainer().updateCraftingOutput(); + if (batch.responses().values().stream().allMatch(response -> response.response().result() == ItemStackNetResult.Success)) { + this.drainQueuedInteractions(); + } diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryTracker.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryTracker.java -index a15bdeeb1..d439b6b5d 100644 +index 99d26d410..6d498eac1 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryTracker.java -@@ -392,6 +392,10 @@ public class InventoryTracker extends StoredObject { - if (state != null) { +@@ -197,6 +197,7 @@ public class InventoryTracker extends StoredObject { + this.currentContainerOpenContext = null; + } + this.pendingCloseContainer = container; ++ this.updateInventoryOptions(); + } + + public void setCurrentContainerClosed(final boolean serverInitiated) { +@@ -212,6 +213,7 @@ public class InventoryTracker extends StoredObject { + this.pendingCloseContainerGeneration = 0; + this.currentContainerOpenContext = null; + this.pendingCloseContainerOpenContext = null; ++ this.updateInventoryOptions(); + final InventoryRequestTracker requests = this.user().get(InventoryRequestTracker.class); + if (requests != null) { + requests.drainQueuedInteractions(); +@@ -243,6 +245,7 @@ public class InventoryTracker extends StoredObject { + this.pendingCloseContainerGeneration = 0; + this.currentContainerOpenContext = null; + this.pendingCloseContainerOpenContext = null; ++ this.updateInventoryOptions(); + } + + private void clearQueuedScreenInteractions() { +@@ -401,6 +404,15 @@ public class InventoryTracker extends StoredObject { + this.lastContainerGeneration = generation; + this.currentContainerGeneration = generation; + this.currentContainerOpenContext = openContext; ++ this.updateInventoryOptions(); ++ } ++ ++ private void updateInventoryOptions() { ++ final var preferences = this.user() == null ? null : this.user().get(InventoryOptionsStorage.class); ++ if (preferences != null && preferences.projectInventory( ++ this.currentContainer != null && this.currentContainer.type() == ContainerType.WORKBENCH)) { ++ preferences.sendJavaSettings(this.user()); ++ } + } + + private void clearNativeContainerActions() { +@@ -408,6 +420,10 @@ public class InventoryTracker extends StoredObject { + if (state != null) { state.clearActions(); } + final var cooking = this.user() == null ? null : this.user().get(NativeCookingUiState.class); @@ -3503,10 +4083,10 @@ index a15bdeeb1..d439b6b5d 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 000000000..2608b2d64 +index 000000000..b21e811f6 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/NativeCookingUiState.java -@@ -0,0 +1,348 @@ +@@ -0,0 +1,502 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -3524,6 +4104,8 @@ index 000000000..2608b2d64 +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.NativeCookingAction; ++import net.raphimc.viabedrock.api.model.NativeCookingActionResult; +import net.raphimc.viabedrock.api.model.NativeCookingMessage; +import net.raphimc.viabedrock.api.model.NativeCookingProgress; +import net.raphimc.viabedrock.api.model.container.CookingRecipeCatalog; @@ -3540,6 +4122,7 @@ index 000000000..2608b2d64 +import net.raphimc.viabedrock.protocol.BedrockProtocol; +import net.raphimc.viabedrock.ViaBedrock; + ++import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; @@ -3568,6 +4151,14 @@ index 000000000..2608b2d64 + private BedrockItem[] cursorItems; + private long dataRevision; + private boolean publishing; ++ private boolean handlingAction; ++ private NativeCookingAction gesture; ++ private NativeCookingAction focus; ++ private Set gestureCommands = Set.of(); ++ private long lastRequestSequence; ++ private CookingUi.Selection selection = new CookingUi.Selection("", -1); ++ private CookingUi.Selection publishedSelection; ++ private boolean actionsAdvertised; + + public NativeCookingUiState(final UserConnection user) { + super(user); @@ -3580,9 +4171,45 @@ index 000000000..2608b2d64 + && user.get(GameSessionStorage.class).isInventoryServerAuthoritative(); + } + ++ /** The client receives results; the core receives actions on a separate channel. */ ++ public static boolean actionsEnabled(final UserConnection user) { ++ return enabled(user) && user.get(ChannelStorage.class).hasChannel(NativeCookingActionResult.CHANNEL); ++ } ++ ++ public void refreshActionCapability() { ++ if (this.user().getProtocolInfo().getClientState() != State.PLAY) { ++ this.actionsAdvertised = false; ++ this.clearActions(); ++ return; ++ } ++ final boolean available = actionsEnabled(this.user()); ++ if (!available) { ++ this.clearActions(); ++ } ++ if (available == this.actionsAdvertised) { ++ 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, NativeCookingAction.CHANNEL.getBytes(StandardCharsets.UTF_8)); ++ packet.send(BedrockProtocol.class); ++ this.actionsAdvertised = available; ++ } ++ ++ private void clearActions() { ++ this.gesture = null; ++ this.focus = null; ++ this.gestureCommands = Set.of(); ++ this.selection = new CookingUi.Selection("", -1); ++ } ++ + public static void sendOpening(final UserConnection user, final ContainerUi.Title title) { + final var state = user.get(NativeCookingUiState.class); -+ if (state != null && enabled(user)) { ++ if (state == null) { ++ return; ++ } ++ state.refreshActionCapability(); ++ if (enabled(user)) { + state.publish(() -> state.begin(title)); + } + } @@ -3595,6 +4222,7 @@ index 000000000..2608b2d64 + if (enabled(user)) { + state.publish(state::update); + } else { ++ state.refreshActionCapability(); + state.clear(); + } + } @@ -3613,7 +4241,7 @@ index 000000000..2608b2d64 + } + + private void publish(final Supplier update) { -+ if (this.publishing) { ++ if (this.publishing || this.handlingAction) { + return; + } + this.publishing = true; @@ -3683,6 +4311,103 @@ index 000000000..2608b2d64 + && NativeCookingControl.forMessage(this.message, control.mode()).equals(control); + } + ++ public boolean current(final NativeCookingAction action) { ++ return this.current(new NativeCookingControl(action.menuId(), action.sequence(), action.updateSequence(), ++ action.menuRevision(), action.inventoryRevision(), action.cursorRevision(), NativeCookingControl.Mode.ACTIVATE)); ++ } ++ ++ /** 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) { ++ return false; ++ } ++ this.handlingAction = true; ++ try { ++ return this.applyAction(action); ++ } finally { ++ this.handlingAction = false; ++ } ++ } ++ ++ private boolean applyAction(final NativeCookingAction action) { ++ if (!this.user().get(GameSessionStorage.class).isInventoryServerAuthoritative() ++ || !this.ownsMenu() || action.requestSequence() <= this.lastRequestSequence) { ++ return false; ++ } ++ if (action.eventPhase() == 2 && this.focus != null && action.mode() == NativeCookingAction.Mode.PRIMARY) { ++ if (!sameGesture(this.focus, action)) { ++ return false; ++ } ++ this.focus = null; ++ this.lastRequestSequence = action.requestSequence(); ++ return true; ++ } ++ if (action.eventPhase() != 0 && this.gesture != null && sameGesture(this.gesture, action) ++ && this.gestureCommands.contains(action.mode().button())) { ++ this.lastRequestSequence = action.requestSequence(); ++ if (action.eventPhase() == 2 && action.mode() != NativeCookingAction.Mode.TERTIARY) { ++ this.gesture = null; ++ this.gestureCommands = Set.of(); ++ this.selection = new CookingUi.Selection("", -1); ++ } ++ return true; ++ } ++ if (!this.current(action)) { ++ return false; ++ } ++ final var view = this.message.view(); ++ final var scope = CookingUi.actionScope(this.ui.resolve(view), view.book()); ++ if (!scope.actions().getOrDefault(action.recipeId(), Set.of()).contains(action.mode().button())) { ++ return false; ++ } ++ final var requests = this.user().get(InventoryRequestTracker.class); ++ if (!requests.canStartRecipePlacement() || requests.isPredictingClick()) { ++ return false; ++ } ++ if (action.mode() != NativeCookingAction.Mode.PRIMARY || action.eventPhase() != 0) { ++ // The mode-two focus entry precedes selection and does not transfer the carried Item. ++ if (this.gesture == null && this.focus == null && action.mode() == NativeCookingAction.Mode.PRIMARY ++ && action.controllerInputMode() == 2 && action.eventPhase() == 1) { ++ this.focus = action; ++ } ++ this.lastRequestSequence = action.requestSequence(); ++ return true; ++ } ++ if (this.focus == null && !this.user().get(InventoryTracker.class).getHudContainer().getItem(0).isEmpty()) { ++ return false; ++ } ++ final var expectedContainer = this.container; ++ final long expectedGeneration = this.generation; ++ final long expectedSequence = this.sequence; ++ if (this.gesture != null || !this.container.handleNativeRecipeSelection(this.catalog, this.catalog.recipe(action.recipeId()), this.focus != null)) { ++ return false; ++ } ++ if (!this.ownsMenu() || this.container != expectedContainer || this.generation != expectedGeneration ++ || this.sequence != expectedSequence) { ++ return false; ++ } ++ final List visible = view.book().recipes().stream() ++ .filter(recipe -> view.book().visibleRecipes().contains(recipe.id())).toList(); ++ for (int index = 0; index < visible.size(); index++) { ++ if (visible.get(index).id() == action.recipeId()) { ++ this.selection = new CookingUi.Selection("recipe_book", index); ++ break; ++ } ++ } ++ this.focus = null; ++ this.gesture = action; ++ this.gestureCommands = scope.actions().get(action.recipeId()); ++ this.lastRequestSequence = action.requestSequence(); ++ return true; ++ } ++ ++ private static boolean sameGesture(final NativeCookingAction original, final NativeCookingAction action) { ++ return original.menuId() == action.menuId() && original.sequence() == action.sequence() ++ && original.updateSequence() == action.updateSequence() && original.menuRevision() == action.menuRevision() ++ && original.inventoryRevision() == action.inventoryRevision() && original.cursorRevision() == action.cursorRevision() ++ && original.recipeId() == action.recipeId() && original.controllerInputMode() == action.controllerInputMode(); ++ } ++ + private boolean ownsMenu() { + final var inventory = this.user().get(InventoryTracker.class); + return this.container != null && inventory == this.inventoryOwner && inventory.getCurrentContainer() == this.container @@ -3700,6 +4425,7 @@ index 000000000..2608b2d64 + && Arrays.equals(this.stationItems, this.container.getItems()) + && Arrays.equals(this.playerItems, inventory.getInventoryContainer().getItems()) + && Arrays.equals(this.cursorItems, inventory.getHudContainer().getItems()) ++ && Objects.equals(this.publishedSelection, this.selection) + && Objects.equals(this.options, this.preferences()) + && this.ui == this.user().get(ResourcePackStorage.class).getCookingUi(); + } @@ -3713,6 +4439,8 @@ index 000000000..2608b2d64 + final var catalog = this.container.recipeCatalog(); + final var ui = this.user().get(ResourcePackStorage.class).getCookingUi(); + final var options = this.preferences(); ++ final var selectionBefore = this.selection; ++ final var gesture = this.gesture; + final BedrockItem[] stationItems = copy(this.container.getItems()); + final BedrockItem[] playerItems = copy(inventory.getInventoryContainer().getItems()); + final BedrockItem[] cursorItems = copy(inventory.getHudContainer().getItems()); @@ -3750,8 +4478,19 @@ index 000000000..2608b2d64 + visible.add(recipe.id()); + } + } ++ final List displayed = CookingRecipeOrder.visibleOrder(ordered, CookingUi.Recipe::evaluation, filtering) ++ .stream().filter(recipe -> visible.contains(recipe.id())).toList(); ++ CookingUi.Selection selection = new CookingUi.Selection("", -1); ++ if (gesture != null) { ++ for (int index = 0; index < displayed.size(); index++) { ++ if (displayed.get(index).id() == gesture.recipeId()) { ++ selection = new CookingUi.Selection("recipe_book", index); ++ break; ++ } ++ } ++ } + final var book = new CookingUi.Book(options == null ? 2 : options.layout(), false, tab, filtering, -+ food, ordinary, blocks, tabLabel(tab, family), "", new CookingUi.Selection("", -1), recipes, visible); ++ food, ordinary, blocks, CookingNavigation.tabLabel(tab, family, filtering), "", 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))); @@ -3769,6 +4508,7 @@ index 000000000..2608b2d64 + || 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()) ++ || gesture != this.gesture || !selectionBefore.equals(this.selection) + || ui != this.user().get(ResourcePackStorage.class).getCookingUi()) { + throw new IllegalStateException("Cooking authority changed during item conversion"); + } @@ -3776,6 +4516,8 @@ index 000000000..2608b2d64 + this.catalog = catalog; + this.ui = ui; + this.options = options; ++ this.selection = selection; ++ this.publishedSelection = selection; + this.playerItems = playerItems; + this.cursorItems = cursorItems; + this.dataRevision = this.container.serverDataRevision(); @@ -3784,7 +4526,7 @@ index 000000000..2608b2d64 + } + + private FurnaceOptions preferences() { -+ return this.user().get(FurnaceOptionsStorage.class).options(switch (this.family()) { ++ return this.user().get(InventoryOptionsStorage.class).options(switch (this.family()) { + case FURNACE -> 1; + case BLAST_FURNACE -> 2; + case SMOKER -> 3; @@ -3821,17 +4563,6 @@ index 000000000..2608b2d64 + return Map.of(cursor ? "#inventory_selected_item_stack_count" : "#inventory_stack_count", new JsonPrimitive(item.amount())); + } + -+ private static String tabLabel(final int tab, final CookingUi.Family family) { -+ return switch (tab) { -+ case 1 -> "furnaceScreen.tab.food"; -+ case 2 -> "furnaceScreen.tab.items"; -+ case 3 -> "furnaceScreen.tab.blocks"; -+ case 4 -> family == CookingUi.Family.SMOKER ? "furnaceScreen.tab.search.filterSmoker" : "furnaceScreen.tab.search.filterFurnace"; -+ case 5 -> "furnaceScreen.tab.inventory"; -+ default -> ""; -+ }; -+ } -+ + public void clear() { + this.inventoryOwner = null; + this.container = null; @@ -3843,6 +4574,9 @@ index 000000000..2608b2d64 + this.stationItems = null; + this.playerItems = null; + this.cursorItems = null; ++ this.clearActions(); ++ this.lastRequestSequence = 0; ++ this.publishedSelection = null; + } + + public record Publication(NativeCookingMessage message, NativeCookingProgress progress) { @@ -3856,14 +4590,15 @@ index 000000000..2608b2d64 + +} 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 6345518df..03c56ea71 100644 +index 6345518df..d23351ca1 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/types/BedrockTypes.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/types/BedrockTypes.java -@@ -79,6 +79,7 @@ public final class BedrockTypes { +@@ -79,6 +79,8 @@ public final class BedrockTypes { public static final Type POSITION_2F = new Position2fType(); public static final Type GAME_RULE = new GameRuleType(); public static final Type GAME_RULE_ARRAY = new ArrayType<>(GAME_RULE, UNSIGNED_VAR_INT); + public static final Type FURNACE_OPTIONS = new FurnaceOptionsType(); ++ public static final Type INVENTORY_OPTIONS = new InventoryOptionsType(); public static final Type EXPERIMENT = new ExperimentType(); public static final Type EXPERIMENT_ARRAY = new ArrayType<>(EXPERIMENT, UNSIGNED_INT_LE); public static final Type EDUCATION_URI_RESOURCE = new EducationUriResourceType(); @@ -3936,6 +4671,47 @@ index 000000000..2ec773609 + } + +} +diff --git a/src/main/java/net/raphimc/viabedrock/protocol/types/model/InventoryOptionsType.java b/src/main/java/net/raphimc/viabedrock/protocol/types/model/InventoryOptionsType.java +new file mode 100644 +index 000000000..ec10eef74 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/protocol/types/model/InventoryOptionsType.java +@@ -0,0 +1,35 @@ ++/* ++ * 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.types.model; ++ ++import com.viaversion.viaversion.api.type.Type; ++import io.netty.buffer.ByteBuf; ++import net.raphimc.viabedrock.protocol.model.InventoryOptions; ++import net.raphimc.viabedrock.protocol.types.BedrockTypes; ++ ++/** Protocol 2193: signed VarInt tabs, boolean filtering, then signed VarInt inventory and crafting layouts. */ ++public final class InventoryOptionsType extends Type { ++ ++ public InventoryOptionsType() { ++ super(InventoryOptions.class); ++ } ++ ++ @Override ++ public InventoryOptions read(final ByteBuf buffer) { ++ return new InventoryOptions(BedrockTypes.VAR_INT.readPrimitive(buffer), BedrockTypes.VAR_INT.readPrimitive(buffer), ++ buffer.readBoolean(), BedrockTypes.VAR_INT.readPrimitive(buffer), BedrockTypes.VAR_INT.readPrimitive(buffer)); ++ } ++ ++ @Override ++ public void write(final ByteBuf buffer, final InventoryOptions value) { ++ BedrockTypes.VAR_INT.writePrimitive(buffer, value.leftTab()); ++ BedrockTypes.VAR_INT.writePrimitive(buffer, value.rightTab()); ++ buffer.writeBoolean(value.filtering()); ++ BedrockTypes.VAR_INT.writePrimitive(buffer, value.inventoryLayout()); ++ BedrockTypes.VAR_INT.writePrimitive(buffer, value.craftingLayout()); ++ } ++ ++} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/types/recipe/CraftingDataReader.java b/src/main/java/net/raphimc/viabedrock/protocol/types/recipe/CraftingDataReader.java index 535f52d1c..87212a2d5 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/types/recipe/CraftingDataReader.java @@ -4415,190 +5191,6 @@ index 000000000..967d0c346 + } + +} -diff --git a/src/test/java/net/raphimc/viabedrock/api/model/container/CookingRecipeOutputsTest.java b/src/test/java/net/raphimc/viabedrock/api/model/container/CookingRecipeOutputsTest.java -new file mode 100644 -index 000000000..e5bdf640a ---- /dev/null -+++ b/src/test/java/net/raphimc/viabedrock/api/model/container/CookingRecipeOutputsTest.java -@@ -0,0 +1,178 @@ -+/* -+ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock -+ * Copyright (C) 2026 RK_01/RaphiMC and contributors -+ * SPDX-License-Identifier: GPL-3.0-or-later -+ */ -+package net.raphimc.viabedrock.api.model.container; -+ -+import com.viaversion.nbt.io.TagRegistry; -+import com.viaversion.nbt.tag.*; -+import com.viaversion.viaversion.libs.gson.JsonArray; -+import com.viaversion.viaversion.libs.gson.JsonParser; -+import net.raphimc.viabedrock.api.chunk.blockstate.BlockRegistryNames; -+import net.raphimc.viabedrock.protocol.model.BedrockItem; -+import org.junit.jupiter.api.Test; -+import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; -+ -+import java.nio.ByteBuffer; -+import java.nio.ByteOrder; -+import java.nio.charset.CharacterCodingException; -+import java.nio.charset.StandardCharsets; -+import java.nio.file.Files; -+import java.nio.file.Path; -+import java.util.HexFormat; -+import java.util.List; -+import java.util.Map; -+import java.util.Set; -+import java.util.ArrayList; -+ -+import static org.junit.jupiter.api.Assertions.*; -+ -+class CookingRecipeOutputsTest { -+ -+ @Test -+ void outputQueriesIgnoreRootQuantityAndNetworkIdButRetainNativeItemState() { -+ final Object block = new Object(); -+ final var blocks = Map.of(0, block, 1, new Object()); -+ final var registry = new BlockRegistryNames(List.of("minecraft:stone", "minecraft:dirt")); -+ final var query = new BedrockItem(321, (short) 3, (byte) 1); -+ final var result = new BedrockItem(321, Short.MAX_VALUE, (byte) 64); -+ result.setNetId(55); -+ result.setBlockRuntimeId(0); -+ assertTrue(CookingRecipeOutputs.matches(query, result, blocks::get, registry::restrictionBlocks)); -+ assertFalse(CookingRecipeOutputs.matches(result, query, blocks::get, registry::restrictionBlocks)); -+ query.setBlockRuntimeId(0); -+ query.setCanPlace(new String[]{"stone", "dirt", "stone", "missing"}); -+ result.setCanPlace(new String[]{"minecraft:dirt", "minecraft:stone"}); -+ assertTrue(CookingRecipeOutputs.matches(query, result, blocks::get, registry::restrictionBlocks)); -+ result.setBlockRuntimeId(1); -+ assertFalse(CookingRecipeOutputs.matches(query, result, blocks::get, registry::restrictionBlocks)); -+ result.setBlockRuntimeId(0); -+ result.setCanBreak(new String[]{"stone"}); -+ assertFalse(CookingRecipeOutputs.matches(query, result, blocks::get, registry::restrictionBlocks)); -+ result.setCanBreak(new String[0]); -+ result.setBlockingTicks(1); -+ assertFalse(CookingRecipeOutputs.matches(query, result, blocks::get, registry::restrictionBlocks)); -+ } -+ -+ @Test -+ void userdataPreservesNativeFloatingAndTypedListEqualityWithoutReferenceShortcuts() { -+ final var first = new CompoundTag(); -+ final var second = new CompoundTag(); -+ assertTrue(CookingRecipeOutputs.userDataEqual(null, first)); -+ first.putFloat("value", 0F); -+ second.putFloat("value", -0F); -+ assertTrue(CookingRecipeOutputs.userDataEqual(first, second)); -+ first.putFloat("value", Float.NaN); -+ assertFalse(CookingRecipeOutputs.userDataEqual(first, first)); -+ first.put("value", new ListTag<>(IntTag.class)); -+ second.put("value", new ListTag<>(FloatTag.class)); -+ assertFalse(CookingRecipeOutputs.userDataEqual(first, second)); -+ second.put("value", new ListTag<>(IntTag.class)); -+ assertTrue(CookingRecipeOutputs.userDataEqual(first, second)); -+ } -+ -+ @Test -+ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_ITEM_TAG_EQUALITY", matches = ".+") -+ void tagEqualityMatchesOriginalNativeVirtualComparatorsForValidUtf8() throws Exception { -+ final var rows = JsonParser.parseString(Files.readString(Path.of( -+ System.getenv("STACKANVIL_NATIVE_ITEM_TAG_EQUALITY")))).getAsJsonArray(); -+ int compared = 0; -+ for (final var element : rows) { -+ final var row = element.getAsJsonObject(); -+ try { -+ final Tag first = tag(row.getAsJsonArray("left")); -+ final Tag second = row.get("sameReference").getAsBoolean() ? first : tag(row.getAsJsonArray("right")); -+ assertEquals(row.get("result").getAsBoolean(), CookingRecipeOutputs.tagsEqual(first, second), row::toString); -+ compared++; -+ } catch (final CharacterCodingException ignored) { -+ // Java's decoded strings cannot retain malformed native UTF-8 bytes. -+ } -+ } -+ assertTrue(compared > 3000); -+ } -+ -+ @Test -+ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_COOKING_OUTPUT_DATA", matches = ".+") -+ void networkUserdataAndDerivedChargedChildrenMatchTheOriginalOutputMatcher() throws Exception { -+ final var rows = JsonParser.parseString(Files.readString(Path.of( -+ System.getenv("STACKANVIL_NATIVE_COOKING_OUTPUT_DATA")))).getAsJsonObject().getAsJsonArray("rows"); -+ assertFalse(rows.isEmpty()); -+ for (final var element : rows) { -+ final var row = element.getAsJsonObject(); -+ final var query = new BedrockItem(585); -+ final var result = new BedrockItem(585); -+ query.setTag(row.get("left").isJsonNull() ? null : (CompoundTag) tag(row.getAsJsonArray("left"))); -+ result.setTag(row.get("right").isJsonNull() ? null : (CompoundTag) tag(row.getAsJsonArray("right"))); -+ assertEquals(row.get("result").getAsBoolean(), CookingRecipeOutputs.matches( -+ query, result, ignored -> null, ignored -> Set.of()), row::toString); -+ } -+ } -+ -+ @Test -+ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_COOKING_INPUTS", matches = ".+") -+ void outputAdmissionMatchesTheWholeNativeAlternativeExtractor() throws Exception { -+ final var rows = JsonParser.parseString(Files.readString(Path.of( -+ System.getenv("STACKANVIL_NATIVE_COOKING_INPUTS")))).getAsJsonObject().getAsJsonArray("outputRows"); -+ assertFalse(rows.isEmpty()); -+ for (final var element : rows) { -+ final var row = element.getAsJsonObject(); -+ final var query = new BedrockItem(row.get("queryId").getAsInt(), -+ row.get("queryAuxiliary").getAsShort(), row.get("queryCount").getAsByte()); -+ final List actual = new ArrayList<>(); -+ if (CookingRecipeOutputs.matches(query, -+ new BedrockItem(100, row.get("resultAuxiliary").getAsShort(), (byte) 1), ignored -> null, ignored -> Set.of())) { -+ actual.addAll(List.of(1, 2)); -+ } -+ if (CookingRecipeOutputs.matches(query, new BedrockItem(321), ignored -> null, ignored -> Set.of())) { -+ actual.add(-303); -+ } -+ assertEquals(row.getAsJsonArray("expanded").asList().stream() -+ .map(value -> value.getAsJsonObject().get("identifier").getAsInt()).toList(), actual, row::toString); -+ } -+ } -+ -+ private static String string(final String hex) throws CharacterCodingException { -+ return StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(HexFormat.of().parseHex(hex))).toString(); -+ } -+ -+ private static Tag tag(final JsonArray spec) throws CharacterCodingException { -+ final int kind = spec.get(0).getAsInt(); -+ final var value = spec.get(1); -+ if (kind == 9) { -+ final var list = value.getAsJsonArray(); -+ final int type = list.get(0).getAsInt(); -+ @SuppressWarnings("unchecked") -+ final var result = new ListTag<>((Class) (type == 0 ? null : TagRegistry.getClassFor(type))); -+ for (final var entry : list.get(1).getAsJsonArray()) { -+ result.add(tag(entry.getAsJsonArray())); -+ } -+ return result; -+ } else if (kind == 10) { -+ final var result = new CompoundTag(); -+ for (final var entry : value.getAsJsonObject().entrySet()) { -+ result.put(string(entry.getKey()), tag(entry.getValue().getAsJsonArray())); -+ } -+ return result; -+ } -+ final byte[] bytes = HexFormat.of().parseHex(value.getAsString()); -+ final var buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN); -+ return switch (kind) { -+ case 1 -> new ByteTag(buffer.get()); -+ case 2 -> new ShortTag(buffer.getShort()); -+ case 3 -> new IntTag(buffer.getInt()); -+ case 4 -> new LongTag(buffer.getLong()); -+ case 5 -> new FloatTag(buffer.getFloat()); -+ case 6 -> new DoubleTag(buffer.getDouble()); -+ case 7 -> new ByteArrayTag(bytes); -+ case 8 -> new StringTag(string(value.getAsString())); -+ case 11 -> { -+ final int[] values = new int[bytes.length / Integer.BYTES]; -+ buffer.asIntBuffer().get(values); -+ yield new IntArrayTag(values); -+ } -+ default -> throw new IllegalArgumentException("Unsupported native tag fixture type"); -+ }; -+ } -+ -+} diff --git a/src/test/java/net/raphimc/viabedrock/api/model/container/CookingRecipeSourcesTest.java b/src/test/java/net/raphimc/viabedrock/api/model/container/CookingRecipeSourcesTest.java new file mode 100644 index 000000000..4bfe26b70 @@ -5010,10 +5602,10 @@ index 000000000..bb3b16e1d +} diff --git a/src/test/java/net/raphimc/viabedrock/api/model/container/FurnaceRecipePlacementTest.java b/src/test/java/net/raphimc/viabedrock/api/model/container/FurnaceRecipePlacementTest.java new file mode 100644 -index 000000000..80a310a94 +index 000000000..a4ce77f3e --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/api/model/container/FurnaceRecipePlacementTest.java -@@ -0,0 +1,314 @@ +@@ -0,0 +1,418 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -5024,12 +5616,14 @@ index 000000000..80a310a94 +import com.viaversion.nbt.tag.CompoundTag; +import com.viaversion.nbt.tag.ListTag; +import com.viaversion.viaversion.api.connection.UserConnection; ++import net.raphimc.viabedrock.api.model.NativeItemStacking; +import net.raphimc.viabedrock.api.model.container.block.BlastFurnaceContainer; +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.player.InventoryContainer; +import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ContainerEnumName; +import net.raphimc.viabedrock.protocol.model.BedrockItem; ++import net.raphimc.viabedrock.protocol.model.ComponentItemStackingProperties; +import net.raphimc.viabedrock.protocol.model.inventory.ItemStackRequestAction; +import net.raphimc.viabedrock.protocol.model.recipe.ItemDescriptor; +import net.raphimc.viabedrock.protocol.model.recipe.ItemDescriptorType; @@ -5039,9 +5633,26 @@ index 000000000..80a310a94 +import net.raphimc.viabedrock.protocol.storage.CraftingDataStorage; +import org.junit.jupiter.api.Test; + ++import com.viaversion.viaversion.libs.gson.JsonParser; ++import io.netty.buffer.ByteBufUtil; ++import io.netty.buffer.Unpooled; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.TextProcessingEventOrigin; ++import net.raphimc.viabedrock.protocol.model.inventory.ItemStackRequestInfo; ++import net.raphimc.viabedrock.protocol.types.BedrockTypes; ++import net.raphimc.viabedrock.protocol.types.array.ArrayType; ++import net.raphimc.viabedrock.protocol.types.inventory.ItemStackRequestType; ++import net.raphimc.viabedrock.protocol.types.item.BedrockItemType; ++import net.raphimc.viabedrock.protocol.types.item.NetworkItemStackFixtures; ++import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; ++ ++import java.nio.file.Files; ++import java.nio.file.Path; ++import java.util.HexFormat; ++ +import java.lang.reflect.Proxy; +import java.util.Arrays; +import java.util.List; ++import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; @@ -5049,6 +5660,31 @@ index 000000000..80a310a94 +class FurnaceRecipePlacementTest { + + @Test ++ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_FINALIZED_REQUEST_WIRE", matches = ".+") ++ void bulkPlacementEncodesTheFinalizedNativeCookingPayload() throws Exception { ++ final var reference = JsonParser.parseString(Files.readString(Path.of( ++ System.getenv("STACKANVIL_NATIVE_FINALIZED_REQUEST_WIRE")))).getAsJsonObject().getAsJsonObject("cooking"); ++ final var user = user(); ++ final var furnace = new FurnaceContainer(user, (byte) 5, null, null); ++ final var inventory = inventory(user); ++ inventory.setItem(0, item(2, 7, 1000)); ++ inventory.setItem(9, item(2, 5, 1009)); ++ final var plan = plan(recipe("furnace", 2, 1), furnace, inventory, true, 64); ++ assertNotNull(plan); ++ final int requestId = reference.get("requestId").getAsInt(); ++ final var request = new ItemStackRequestInfo(requestId, plan.actions(requestId), List.of(), TextProcessingEventOrigin.unknown); ++ final var codec = new ArrayType<>(new ItemStackRequestType( ++ new BedrockItemType(0, false, NetworkItemStackFixtures.CONTEXT)), BedrockTypes.UNSIGNED_VAR_INT); ++ final var buffer = Unpooled.buffer(); ++ try { ++ codec.write(buffer, new ItemStackRequestInfo[]{request}); ++ assertArrayEquals(HexFormat.of().parseHex(reference.get("wire").getAsString()), ByteBufUtil.getBytes(buffer)); ++ } finally { ++ buffer.release(); ++ } ++ } ++ ++ @Test + void placementUsesEachStationsInputProviderAndLeavesCookingSlotsUntouched() { + final UserConnection user = user(); + final FurnaceContainer[] furnaces = { @@ -5086,7 +5722,7 @@ index 000000000..80a310a94 + } + + @Test -+ void bulkPlacementCombinesPartialStacksAndReferencesTheChangedInputByRequestId() { ++ void bulkPlacementCombinesStacksAndUsesRequestReferencesAfterMutation() { + final UserConnection user = user(); + final FurnaceContainer furnace = new FurnaceContainer(user, (byte) 5, null, null); + final InventoryContainer inventory = inventory(user); @@ -5095,21 +5731,19 @@ index 000000000..80a310a94 + inventory.setItem(10, item(2, 60, 13)); + final var plan = plan(recipe("furnace", 2, 1), furnace, inventory, true, 64); + assertNotNull(plan); -+ final var actions = plan.actions(-7); ++ final var actions = plan.actions(-3); + assertEquals(3, actions.size()); + assertEquals(List.of(3, 20, 41), actions.stream() + .map(action -> ((ItemStackRequestAction.PlaceAction) action).count()).toList()); + assertEquals(0, ((ItemStackRequestAction.PlaceAction) actions.get(0)).destination().stackNetworkId()); -+ assertEquals(-7, ((ItemStackRequestAction.PlaceAction) actions.get(1)).destination().stackNetworkId()); -+ assertEquals(-7, ((ItemStackRequestAction.PlaceAction) actions.get(2)).destination().stackNetworkId()); ++ assertEquals(-3, ((ItemStackRequestAction.PlaceAction) actions.get(1)).destination().stackNetworkId()); ++ assertEquals(-3, ((ItemStackRequestAction.PlaceAction) actions.get(2)).destination().stackNetworkId()); + plan.apply(furnace, inventory); + assertEquals(64, furnace.getItem(0).amount()); + assertTrue(inventory.getItem(0).isEmpty()); + assertTrue(inventory.getItem(9).isEmpty()); + assertEquals(19, inventory.getItem(10).amount()); + assertEquals(13, inventory.getItem(10).netId()); -+ assertThrows(IllegalArgumentException.class, () -> plan.actions(3)); -+ assertThrows(IllegalArgumentException.class, () -> plan.actions(-4)); + } + + @Test @@ -5160,6 +5794,36 @@ index 000000000..80a310a94 + } + + @Test ++ void clearingAcrossMultipleSlotsReferencesTheChangedSourceAndEmptyInput() { ++ final var user = user(); ++ final var furnace = new FurnaceContainer(user, (byte) 5, null, null); ++ final var inventory = inventory(user); ++ furnace.setItem(0, item(9, 64, 17)); ++ inventory.setItem(0, item(9, 60, 20)); ++ inventory.setItem(9, item(2, 3, 29)); ++ final var plan = plan(recipe("furnace", 2, 1), furnace, inventory, false, 64); ++ assertNotNull(plan); ++ final var actions = plan.actions(-7); ++ assertEquals(3, actions.size()); ++ final var firstReturn = (ItemStackRequestAction.PlaceAction) actions.get(0); ++ final var secondReturn = (ItemStackRequestAction.PlaceAction) actions.get(1); ++ final var refill = (ItemStackRequestAction.PlaceAction) actions.get(2); ++ assertEquals(4, firstReturn.count()); ++ assertEquals(17, firstReturn.source().stackNetworkId()); ++ assertEquals(20, firstReturn.destination().stackNetworkId()); ++ assertEquals(60, secondReturn.count()); ++ assertEquals(-7, secondReturn.source().stackNetworkId()); ++ assertEquals(0, secondReturn.destination().stackNetworkId()); ++ assertEquals(29, refill.source().stackNetworkId()); ++ assertEquals(-7, refill.destination().stackNetworkId()); ++ plan.apply(furnace, inventory); ++ assertEquals(1, furnace.getItem(0).amount()); ++ assertEquals(64, inventory.getItem(0).amount()); ++ assertEquals(60, inventory.getItem(1).amount()); ++ assertEquals(2, inventory.getItem(9).amount()); ++ } ++ ++ @Test + void failedClearOrMissingIngredientsNeverMutatesEitherInventory() { + final UserConnection user = user(); + final FurnaceContainer furnace = new FurnaceContainer(user, (byte) 5, null, null); @@ -5179,6 +5843,31 @@ index 000000000..80a310a94 + } + + @Test ++ void returnRejectsDamagedOccupiedStacksWithoutMutatingEitherInventory() { ++ final UserConnection user = user(); ++ final FurnaceContainer furnace = new FurnaceContainer(user, (byte) 5, null, null); ++ final InventoryContainer inventory = inventory(user); ++ for (int slot = 0; slot < inventory.size(); slot++) { ++ inventory.setItem(slot, item(9, 64, 100 + slot)); ++ } ++ inventory.setItem(0, item(2, 3, 100)); ++ final var tag = new CompoundTag(); ++ tag.putInt("Damage", 1); ++ final var destination = item(9, 60, 109); ++ destination.setTag(tag.copy()); ++ inventory.setItem(9, destination); ++ final var previous = item(9, 4, 17); ++ previous.setTag(tag); ++ furnace.setItem(0, previous); ++ final BedrockItem[] before = Arrays.stream(inventory.getItems()).map(BedrockItem::copy).toArray(BedrockItem[]::new); ++ ++ assertNull(plan(recipe("furnace", 2, 1), furnace, inventory, false, 64, ++ new ComponentItemStackingProperties(64, null, false, true))); ++ assertEquals(previous, furnace.getItem(0)); ++ assertArrayEquals(before, inventory.getItems()); ++ } ++ ++ @Test + void broadIngredientSelectsUsableVariantAndBulkChoosesTheLargerStack() { + final UserConnection user = user(); + final FurnaceContainer furnace = new FurnaceContainer(user, (byte) 5, null, null); @@ -5280,8 +5969,15 @@ index 000000000..80a310a94 + + private static FurnaceRecipePlacement.Plan plan(final CraftingDataStorage recipe, + final FurnaceContainer furnace, final InventoryContainer inventory, final boolean max, final int limit) { ++ return plan(recipe, furnace, inventory, max, limit, new ComponentItemStackingProperties(limit, null, true, false)); ++ } ++ ++ private static FurnaceRecipePlacement.Plan plan(final CraftingDataStorage recipe, ++ final FurnaceContainer furnace, final InventoryContainer inventory, final boolean max, final int limit, ++ final ComponentItemStackingProperties properties) { + return FurnaceRecipePlacement.plan(recipe, furnace, inventory, max, item -> limit, + item -> new ItemRecipeKey(item.identifier(), item.data()), ++ (source, destination) -> NativeItemStacking.canMerge(source, destination, properties, BedrockItem::data, values -> Set.of()), + item -> ((ShapelessRecipe) recipe.recipe()).getIngredients().getFirst().matchesItem(null, item)); + } + @@ -5584,10 +6280,10 @@ index 000000000..d2126b57e +} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/packet/FurnaceOptionsPacketTest.java b/src/test/java/net/raphimc/viabedrock/protocol/packet/FurnaceOptionsPacketTest.java new file mode 100644 -index 000000000..7e4f64796 +index 000000000..6e4f72ffe --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/protocol/packet/FurnaceOptionsPacketTest.java -@@ -0,0 +1,156 @@ +@@ -0,0 +1,162 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -5610,7 +6306,8 @@ index 000000000..7e4f64796 +import net.raphimc.viabedrock.protocol.ClientboundBedrockPackets; +import net.raphimc.viabedrock.protocol.ServerboundBedrockPackets; +import net.raphimc.viabedrock.protocol.model.FurnaceOptions; -+import net.raphimc.viabedrock.protocol.storage.FurnaceOptionsStorage; ++import net.raphimc.viabedrock.protocol.model.InventoryOptions; ++import net.raphimc.viabedrock.protocol.storage.InventoryOptionsStorage; +import net.raphimc.viabedrock.protocol.types.BedrockTypes; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; @@ -5634,7 +6331,7 @@ index 000000000..7e4f64796 + assertSame(ServerboundBedrockPackets.SET_PLAYER_FURNACE_OPTIONS, + ServerboundBedrockPackets.getPacket(351)); + final var fixture = new Fixture(); -+ fixture.preferences.changeJavaBook(0, true, true); ++ fixture.preferences.changeJavaInventoryBook(false, true, true); + fixture.preferences.apply(new FurnaceOptions(2, 3, true, 1)); + fixture.preferences.apply(new FurnaceOptions(3, 1, false, 2)); + final var packet = fixture.nativePacket(new FurnaceOptions(1, 4, true, 2)); @@ -5664,8 +6361,8 @@ index 000000000..7e4f64796 + @Test + void unknownEnumsStayNativeAndDoNotResetUnrelatedJavaPreferences() { + final var fixture = new Fixture(); -+ fixture.preferences.changeJavaBook(0, true, false); -+ fixture.preferences.changeJavaBook(1, true, false); ++ fixture.preferences.changeJavaInventoryBook(false, true, false); ++ fixture.preferences.changeJavaFurnaceBook(1, true, false); + final var none = fixture.nativePacket(new FurnaceOptions(0, 0, true, 0)); + InventoryPackets.handleFurnaceOptions(none); + assertTrue(none.isCancelled()); @@ -5685,7 +6382,10 @@ index 000000000..7e4f64796 + assertTrue(invalid.isCancelled()); + final var crafting = fixture.javaPacket(0, true, true); + InventoryPackets.handleJavaRecipeBookSettings(crafting); -+ assertTrue(crafting.isCancelled()); ++ assertFalse(crafting.isCancelled()); ++ assertSame(ServerboundBedrockPackets.SET_PLAYER_INVENTORY_OPTIONS, crafting.getPacketType()); ++ assertEquals(new InventoryOptions(0, 0, true, 2, 0), ++ crafting.get(BedrockTypes.INVENTORY_OPTIONS, 0)); + assertEquals(new FurnaceOptions(0, 0, true, 0), fixture.preferences.options(0)); + } + @@ -5703,7 +6403,7 @@ index 000000000..7e4f64796 + final var reopened = fixture.nativePacket(new FurnaceOptions(2, 1, false, 2)); + InventoryPackets.handleFurnaceOptions(reopened); + assertArrayEquals(new boolean[]{false, false, false, false, true, false, false, false}, snapshot(reopened)); -+ assertNull(new FurnaceOptionsStorage().options(2)); ++ assertNull(new InventoryOptionsStorage().options(2)); + } + + private static boolean[] snapshot(final PacketWrapper packet) { @@ -5715,11 +6415,13 @@ index 000000000..7e4f64796 + } + + private static final class Fixture { -+ private final FurnaceOptionsStorage preferences = new FurnaceOptionsStorage(); ++ private final InventoryOptionsStorage preferences = new InventoryOptionsStorage(); + private final UserConnection user = mock(UserConnection.class); + + private Fixture() { -+ when(this.user.get(FurnaceOptionsStorage.class)).thenReturn(this.preferences); ++ when(this.user.get(InventoryOptionsStorage.class)).thenReturn(this.preferences); ++ when(this.user.get(net.raphimc.viabedrock.protocol.storage.InventoryTracker.class)) ++ .thenReturn(mock(net.raphimc.viabedrock.protocol.storage.InventoryTracker.class)); + when(this.user.get(ChannelStorage.class)).thenReturn(new ChannelStorage()); + final var info = mock(com.viaversion.viaversion.api.connection.ProtocolInfo.class); + when(this.user.getProtocolInfo()).thenReturn(info); @@ -5744,12 +6446,12 @@ index 000000000..7e4f64796 + } + +} -diff --git a/src/test/java/net/raphimc/viabedrock/protocol/packet/NativeFurnaceOptionsPacketTest.java b/src/test/java/net/raphimc/viabedrock/protocol/packet/NativeFurnaceOptionsPacketTest.java +diff --git a/src/test/java/net/raphimc/viabedrock/protocol/packet/InventoryOptionsPacketTest.java b/src/test/java/net/raphimc/viabedrock/protocol/packet/InventoryOptionsPacketTest.java new file mode 100644 -index 000000000..b5e34ba4c +index 000000000..486529afb --- /dev/null -+++ b/src/test/java/net/raphimc/viabedrock/protocol/packet/NativeFurnaceOptionsPacketTest.java -@@ -0,0 +1,214 @@ ++++ b/src/test/java/net/raphimc/viabedrock/protocol/packet/InventoryOptionsPacketTest.java +@@ -0,0 +1,152 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -5757,58 +6459,216 @@ index 000000000..b5e34ba4c + */ +package net.raphimc.viabedrock.protocol.packet; + -+import com.viaversion.viaversion.api.connection.ProtocolInfo; +import com.viaversion.viaversion.api.connection.UserConnection; -+import com.viaversion.viaversion.api.protocol.packet.PacketType; +import com.viaversion.viaversion.api.protocol.packet.PacketWrapper; +import com.viaversion.viaversion.api.protocol.packet.State; ++import com.viaversion.viaversion.api.protocol.remapper.PacketHandler; +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.model.NativeFurnaceOptions; ++import net.raphimc.viabedrock.api.model.container.block.CraftingTableContainer; +import net.raphimc.viabedrock.protocol.BedrockProtocol; ++import net.raphimc.viabedrock.protocol.ClientboundBedrockPackets; +import net.raphimc.viabedrock.protocol.ServerboundBedrockPackets; +import net.raphimc.viabedrock.protocol.model.FurnaceOptions; -+import net.raphimc.viabedrock.protocol.storage.*; ++import net.raphimc.viabedrock.protocol.model.InventoryOptions; ++import net.raphimc.viabedrock.protocol.storage.InventoryOptionsStorage; ++import net.raphimc.viabedrock.protocol.storage.InventoryTracker; +import net.raphimc.viabedrock.protocol.types.BedrockTypes; +import org.junit.jupiter.api.Test; -+import org.mockito.MockedStatic; ++import org.mockito.ArgumentCaptor; + -+import java.nio.charset.StandardCharsets; +import java.util.ArrayList; -+import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + -+class NativeFurnaceOptionsPacketTest { ++class InventoryOptionsPacketTest { + + @Test -+ void lateNegotiationSendsRetainedFamiliesAndWithdrawalStopsBothDirections() throws Exception { -+ try (var fixture = new Fixture()) { -+ final var first = new FurnaceOptions(1, 4, true, 2); -+ final var other = new FurnaceOptions(3, 1, false, 1); -+ fixture.preferences.apply(other); -+ fixture.preferences.apply(first); -+ fixture.preferences.sendNativeSnapshot(fixture.user); -+ assertTrue(fixture.sent.isEmpty()); -+ fixture.channels("minecraft:register"); -+ assertEquals(List.of(first, other), fixture.nativeUpdates()); -+ final var capability = fixture.sent.stream().filter(packet -> packet.getPacketType() == ClientboundPackets26_3.CUSTOM_PAYLOAD -+ && packet.get(Types.STRING, 0).equals("minecraft:register") -+ && new String(packet.get(Types.REMAINING_BYTES, 0), StandardCharsets.UTF_8).equals(NativeFurnaceOptions.CHANNEL)).count(); -+ assertEquals(1, capability); -+ fixture.sent.clear(); -+ fixture.channels("minecraft:unregister"); -+ assertFalse(fixture.channels.hasChannel(NativeFurnaceOptions.CHANNEL)); -+ fixture.preferences.sendNativeSnapshot(fixture.user); -+ fixture.nativeInput(new FurnaceOptions(2, 2, true, 2)); -+ assertNull(fixture.preferences.options(2)); -+ assertTrue(fixture.nativeUpdates().isEmpty()); -+ assertEquals(List.of(first, other), fixture.preferences.snapshot()); -+ fixture.channels("minecraft:register"); -+ assertEquals(List.of(first, other), fixture.nativeUpdates()); ++ void registeredInboundAndJavaChangesPreserveBothLayoutsAndFurnaceBooks() throws Exception { ++ final var protocol = mock(BedrockProtocol.class); ++ InventoryPackets.register(protocol); ++ final var handler = ArgumentCaptor.forClass(PacketHandler.class); ++ verify(protocol).registerClientbound(eq(ClientboundBedrockPackets.SET_PLAYER_INVENTORY_OPTIONS), ++ eq(ClientboundPackets26_3.RECIPE_BOOK_SETTINGS), handler.capture()); ++ final var f = new Fixture(); ++ f.preferences.apply(new FurnaceOptions(1, 4, false, 2)); ++ final var original = new InventoryOptions(5, 3, true, 3, 1); ++ final var inbound = f.nativePacket(original); ++ handler.getValue().handle(inbound); ++ assertArrayEquals(new boolean[]{true, true, true, false, false, false, false, false}, snapshot(inbound)); ++ final var filtering = f.javaPacket(true, false); ++ InventoryPackets.handleJavaRecipeBookSettings(filtering); ++ assertSame(ServerboundBedrockPackets.SET_PLAYER_INVENTORY_OPTIONS, filtering.getPacketType()); ++ assertEquals(new InventoryOptions(5, 3, false, 3, 1), filtering.get(BedrockTypes.INVENTORY_OPTIONS, 0)); ++ final var closed = f.javaPacket(false, true); ++ InventoryPackets.handleJavaRecipeBookSettings(closed); ++ assertEquals(new InventoryOptions(5, 3, true, 1, 1), closed.get(BedrockTypes.INVENTORY_OPTIONS, 0)); ++ final var bytes = Unpooled.buffer(); ++ try { ++ closed.writeToBuffer(bytes); ++ assertEquals(307, Types.VAR_INT.readPrimitive(bytes)); ++ assertEquals(new InventoryOptions(5, 3, true, 1, 1), BedrockTypes.INVENTORY_OPTIONS.read(bytes)); ++ assertFalse(bytes.isReadable()); ++ } finally { ++ bytes.release(); ++ } ++ assertEquals(new FurnaceOptions(1, 4, false, 2), f.preferences.options(1)); ++ } ++ ++ @Test ++ void realMenuOwnershipReprojectsTheSharedJavaBookWithoutOverwritingNativeLayouts() throws Exception { ++ final var f = new Fixture(); ++ final var original = new InventoryOptions(6, 2, true, 1, 2); ++ f.preferences.applyInventory(original, false); ++ f.preferences.apply(new FurnaceOptions(1, 4, false, 2)); ++ final var packets = new ArrayList(); ++ try (var factory = mockStatic(PacketWrapper.class)) { ++ factory.when(() -> PacketWrapper.create(ClientboundPackets26_3.RECIPE_BOOK_SETTINGS, f.user)) ++ .thenAnswer(_ -> { ++ final var packet = spy(new PacketWrapperImpl(ClientboundPackets26_3.RECIPE_BOOK_SETTINGS, null, f.user)); ++ doNothing().when(packet).send(BedrockProtocol.class); ++ packets.add(packet); ++ return packet; ++ }); ++ final var table = new CraftingTableContainer(f.user, (byte) 5, null, null); ++ f.tracker.setCurrentContainer(table); ++ assertEquals(1, packets.size()); ++ assertArrayEquals(new boolean[]{true, true, true, false, false, false, false, false}, snapshot(packets.getFirst())); ++ final var changed = f.javaPacket(false, false); ++ InventoryPackets.handleJavaRecipeBookSettings(changed); ++ assertEquals(new InventoryOptions(6, 2, false, 1, 1), changed.get(BedrockTypes.INVENTORY_OPTIONS, 0)); ++ f.preferences.applyInventory(original, true); ++ f.tracker.markPendingClose(table); ++ assertEquals(2, packets.size()); ++ assertArrayEquals(new boolean[]{false, true, true, false, false, false, false, false}, snapshot(packets.getLast())); ++ assertEquals(original, f.preferences.inventoryOptions()); ++ f.tracker.setCurrentContainerClosed(false); ++ assertEquals(2, packets.size()); ++ } ++ } ++ ++ @Test ++ void unknownLayoutsSurviveFilterChangesAndUnseenPreferencesUseOnlyTheRequestedScreen() { ++ final var preferences = new InventoryOptionsStorage(); ++ preferences.applyInventory(new InventoryOptions(-7, Integer.MAX_VALUE, false, -99, Integer.MIN_VALUE), false); ++ assertEquals(new InventoryOptions(-7, Integer.MAX_VALUE, true, -99, Integer.MIN_VALUE), ++ preferences.changeJavaInventoryBook(false, false, true)); ++ preferences.projectInventory(true); ++ assertEquals(new InventoryOptions(-7, Integer.MAX_VALUE, false, -99, 2), ++ preferences.changeJavaInventoryBook(true, true, false)); ++ assertEquals(new InventoryOptions(0, 0, true, 2, 0), ++ new InventoryOptionsStorage().changeJavaInventoryBook(false, true, true)); ++ assertEquals(new InventoryOptions(0, 0, false, 0, 1), ++ new InventoryOptionsStorage().changeJavaInventoryBook(true, false, false)); ++ } ++ ++ private static boolean[] snapshot(final PacketWrapper packet) { ++ final boolean[] result = new boolean[8]; ++ for (int index = 0; index < result.length; index++) { ++ result[index] = packet.get(Types.BOOLEAN, index); ++ } ++ return result; ++ } ++ ++ private static final class Fixture { ++ final InventoryOptionsStorage preferences = new InventoryOptionsStorage(); ++ final UserConnection user = mock(UserConnection.class); ++ final InventoryTracker tracker = new InventoryTracker(this.user); ++ ++ Fixture() { ++ when(this.user.get(InventoryOptionsStorage.class)).thenReturn(this.preferences); ++ when(this.user.get(InventoryTracker.class)).thenReturn(this.tracker); ++ final var info = mock(com.viaversion.viaversion.api.connection.ProtocolInfo.class); ++ when(this.user.getProtocolInfo()).thenReturn(info); ++ when(info.getClientState()).thenReturn(State.PLAY); ++ } ++ ++ PacketWrapper nativePacket(final InventoryOptions value) { ++ final var packet = new PacketWrapperImpl(ClientboundPackets26_3.RECIPE_BOOK_SETTINGS, null, this.user); ++ packet.write(BedrockTypes.INVENTORY_OPTIONS, value); ++ packet.resetReader(); ++ return packet; ++ } ++ ++ PacketWrapper javaPacket(final boolean open, final boolean filtering) { ++ final var packet = new PacketWrapperImpl(ServerboundBedrockPackets.SET_PLAYER_FURNACE_OPTIONS, null, this.user); ++ packet.write(Types.VAR_INT, 0); ++ packet.write(Types.BOOLEAN, open); ++ packet.write(Types.BOOLEAN, filtering); ++ packet.resetReader(); ++ return packet; ++ } ++ } ++ ++} +diff --git a/src/test/java/net/raphimc/viabedrock/protocol/packet/NativeFurnaceOptionsPacketTest.java b/src/test/java/net/raphimc/viabedrock/protocol/packet/NativeFurnaceOptionsPacketTest.java +new file mode 100644 +index 000000000..d66d6110c +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/protocol/packet/NativeFurnaceOptionsPacketTest.java +@@ -0,0 +1,214 @@ ++/* ++ * 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.ProtocolInfo; ++import com.viaversion.viaversion.api.connection.UserConnection; ++import com.viaversion.viaversion.api.protocol.packet.PacketType; ++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.model.NativeFurnaceOptions; ++import net.raphimc.viabedrock.protocol.BedrockProtocol; ++import net.raphimc.viabedrock.protocol.ServerboundBedrockPackets; ++import net.raphimc.viabedrock.protocol.model.FurnaceOptions; ++import net.raphimc.viabedrock.protocol.storage.*; ++import net.raphimc.viabedrock.protocol.types.BedrockTypes; ++import org.junit.jupiter.api.Test; ++import org.mockito.MockedStatic; ++ ++import java.nio.charset.StandardCharsets; ++import java.util.ArrayList; ++import java.util.List; ++ ++import static org.junit.jupiter.api.Assertions.*; ++import static org.mockito.Mockito.*; ++ ++class NativeFurnaceOptionsPacketTest { ++ ++ @Test ++ void lateNegotiationSendsRetainedFamiliesAndWithdrawalStopsBothDirections() throws Exception { ++ try (var fixture = new Fixture()) { ++ final var first = new FurnaceOptions(1, 4, true, 2); ++ final var other = new FurnaceOptions(3, 1, false, 1); ++ fixture.preferences.apply(other); ++ fixture.preferences.apply(first); ++ fixture.preferences.sendNativeSnapshot(fixture.user); ++ assertTrue(fixture.sent.isEmpty()); ++ fixture.channels("minecraft:register"); ++ assertEquals(List.of(first, other), fixture.nativeUpdates()); ++ final var capability = fixture.sent.stream().filter(packet -> packet.getPacketType() == ClientboundPackets26_3.CUSTOM_PAYLOAD ++ && packet.get(Types.STRING, 0).equals("minecraft:register") ++ && new String(packet.get(Types.REMAINING_BYTES, 0), StandardCharsets.UTF_8).equals(NativeFurnaceOptions.CHANNEL)).count(); ++ assertEquals(1, capability); ++ fixture.sent.clear(); ++ fixture.channels("minecraft:unregister"); ++ assertFalse(fixture.channels.hasChannel(NativeFurnaceOptions.CHANNEL)); ++ fixture.preferences.sendNativeSnapshot(fixture.user); ++ fixture.nativeInput(new FurnaceOptions(2, 2, true, 2)); ++ assertNull(fixture.preferences.options(2)); ++ assertTrue(fixture.nativeUpdates().isEmpty()); ++ assertEquals(List.of(first, other), fixture.preferences.snapshot()); ++ fixture.channels("minecraft:register"); ++ assertEquals(List.of(first, other), fixture.nativeUpdates()); + } + } + @@ -5816,7 +6676,7 @@ index 000000000..b5e34ba4c + void nativeChangesForwardAllFieldsAndSynchronizeJavaWithoutResettingOtherBooks() throws Exception { + try (var fixture = new Fixture()) { + fixture.channels.addChannels(List.of(NativeFurnaceOptions.CHANNEL)); -+ fixture.preferences.changeJavaBook(0, true, true); ++ fixture.preferences.changeJavaInventoryBook(false, true, true); + fixture.preferences.apply(new FurnaceOptions(3, 1, true, 1)); + final var change = new FurnaceOptions(2, 3, false, 2); + fixture.nativeInput(change); @@ -5899,7 +6759,7 @@ index 000000000..b5e34ba4c + private final UserConnection user = mock(UserConnection.class); + private final ProtocolInfo info = mock(ProtocolInfo.class); + private final ChannelStorage channels = new ChannelStorage(); -+ private final FurnaceOptionsStorage preferences = new FurnaceOptionsStorage(); ++ private final InventoryOptionsStorage preferences = new InventoryOptionsStorage(); + private final List sent = new ArrayList<>(); + private final MockedStatic factory = mockStatic(PacketWrapper.class); + @@ -5907,8 +6767,8 @@ index 000000000..b5e34ba4c + when(this.user.getProtocolInfo()).thenReturn(this.info); + when(this.info.getClientState()).thenReturn(State.PLAY); + when(this.user.get(ChannelStorage.class)).thenReturn(this.channels); -+ when(this.user.get(FurnaceOptionsStorage.class)).thenReturn(this.preferences); -+ when(this.user.has(FurnaceOptionsStorage.class)).thenReturn(true); ++ when(this.user.get(InventoryOptionsStorage.class)).thenReturn(this.preferences); ++ when(this.user.has(InventoryOptionsStorage.class)).thenReturn(true); + when(this.user.get(NativeSkinStorage.class)).thenReturn(mock(NativeSkinStorage.class)); + when(this.user.get(PlayerInputStorage.class)).thenReturn(mock(PlayerInputStorage.class)); + when(this.user.get(HudVisibilityStorage.class)).thenReturn(mock(HudVisibilityStorage.class)); @@ -6696,10 +7556,10 @@ index 000000000..6da0ed14c +} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/storage/FurnaceRecipeRequestTest.java b/src/test/java/net/raphimc/viabedrock/protocol/storage/FurnaceRecipeRequestTest.java new file mode 100644 -index 000000000..349cc2219 +index 000000000..f4a2b620f --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/protocol/storage/FurnaceRecipeRequestTest.java -@@ -0,0 +1,316 @@ +@@ -0,0 +1,797 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -6710,13 +7570,16 @@ index 000000000..349cc2219 +import com.viaversion.nbt.tag.CompoundTag; +import com.viaversion.nbt.tag.ListTag; +import com.viaversion.viaversion.api.connection.UserConnection; -+import net.raphimc.viabedrock.api.model.container.CookingRecipeOutputs; ++import net.raphimc.viabedrock.api.model.NativeItemStacking; ++import net.raphimc.viabedrock.api.model.container.NativeItemStackMatcher; +import net.raphimc.viabedrock.api.model.container.block.BlastFurnaceContainer; +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.player.InventoryContainer; +import net.raphimc.viabedrock.api.util.PacketFactory; +import net.raphimc.viabedrock.protocol.model.BedrockItem; ++import net.raphimc.viabedrock.protocol.model.FullContainerName; ++import net.raphimc.viabedrock.protocol.model.ComponentItemStackingProperties; +import net.raphimc.viabedrock.protocol.model.ItemEntry; +import net.raphimc.viabedrock.protocol.model.ItemRegistry; +import net.raphimc.viabedrock.protocol.model.ItemCookingProperties; @@ -6732,6 +7595,15 @@ index 000000000..349cc2219 +import net.raphimc.viabedrock.protocol.model.recipe.ShapelessRecipe; +import net.raphimc.viabedrock.protocol.rewriter.ItemRewriter; +import org.junit.jupiter.api.Test; ++import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; ++import net.raphimc.viabedrock.protocol.types.array.ArrayType; ++import com.viaversion.viaversion.libs.gson.JsonParser; ++import io.netty.buffer.Unpooled; ++import io.netty.buffer.ByteBufUtil; ++import net.raphimc.viabedrock.protocol.types.BedrockTypes; ++import net.raphimc.viabedrock.protocol.types.inventory.ItemStackRequestType; ++import net.raphimc.viabedrock.protocol.types.item.BedrockItemType; ++import net.raphimc.viabedrock.protocol.types.item.NetworkItemStackFixtures; + +import java.lang.reflect.Proxy; +import java.util.HashMap; @@ -6739,6 +7611,12 @@ index 000000000..349cc2219 +import java.util.Map; +import java.util.UUID; +import java.util.Set; ++import java.util.Arrays; ++import java.util.ArrayList; ++import java.util.LinkedHashMap; ++import java.util.HexFormat; ++import java.nio.file.Files; ++import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; @@ -6746,6 +7624,421 @@ index 000000000..349cc2219 +class FurnaceRecipeRequestTest { + + @Test ++ void focusedPlanningRejectsReplacedCursorSnapshots() { ++ for (final boolean revisionOnly : new boolean[]{false, true}) { ++ final var f = fixture(); ++ final var station = new FurnaceContainer(f.user, (byte) 5, null, null); ++ f.inventory.setCurrentContainer(station); ++ f.inventory.getInventoryContainer().setItem(9, item(2, 5, 41)); ++ f.inventory.getHudContainer().setItem(0, item(2, 3, 42)); ++ final var recipe = recipe(); ++ f.user.get(CraftingDataTracker.class).updateCraftingDataList(List.of(recipe), true); ++ f.requests.setPredictingClick(false); ++ when(f.user.get(ItemRewriter.class).maxStackSize(any())).thenAnswer(_ -> { ++ if (revisionOnly) { ++ f.inventory.getHudContainer().markServerUpdate(); ++ } else { ++ f.inventory.getHudContainer().getItem(0).setAmount(4); ++ } ++ return 64; ++ }); ++ try (var packets = mockStatic(PacketFactory.class)) { ++ assertFalse(station.handleNativeRecipeSelection(station.recipeCatalog(), recipe, true)); ++ assertTrue(station.getItem(0).isEmpty()); ++ assertEquals(5, f.inventory.getInventoryContainer().getItem(9).amount()); ++ assertEquals(revisionOnly ? 3 : 4, f.inventory.getHudContainer().getItem(0).amount()); ++ packets.verifyNoInteractions(); ++ when(f.user.get(ItemRewriter.class).maxStackSize(any())).thenReturn(64); ++ assertTrue(station.handleNativeRecipeSelection(station.recipeCatalog(), recipe, true)); ++ assertEquals(5, station.getItem(0).amount()); ++ assertEquals(revisionOnly ? 3 : 4, f.inventory.getHudContainer().getItem(0).amount()); ++ } ++ } ++ } ++ ++ @Test ++ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_COOKING_FOCUS_TRANSFER", matches = ".+") ++ void focusedCarriedSelectionMatchesNativeRequestsAndRetainsEveryCursorSnapshot() throws Exception { ++ final var cases = JsonParser.parseString(Files.readString(Path.of( ++ System.getenv("STACKANVIL_NATIVE_COOKING_FOCUS_TRANSFER")))).getAsJsonObject().getAsJsonArray("cases"); ++ int compared = 0; ++ for (final var value : cases) { ++ final var reference = value.getAsJsonObject(); ++ if (reference.get("controllerMode").getAsInt() != 2) { ++ continue; ++ } ++ final int count = reference.get("heldCount").getAsInt(); ++ final var press = reference.getAsJsonArray("steps").get(1).getAsJsonObject(); ++ final var f = fixture(); ++ final var station = new FurnaceContainer(f.user, (byte) 5, null, null); ++ f.inventory.setCurrentContainer(station); ++ f.inventory.getInventoryContainer().setItem(0, item(2, 7, 1000)); ++ f.inventory.getInventoryContainer().setItem(9, item(2, 5, 1009)); ++ final var carried = count == 0 ? BedrockItem.empty() : item(2, count, 2000); ++ f.inventory.getHudContainer().setItem(0, carried.copy()); ++ final var recipe = recipe(); ++ f.user.get(CraftingDataTracker.class).updateCraftingDataList(List.of(recipe), true); ++ final int nativeId = press.getAsJsonArray("requests").get(0).getAsJsonObject().get("requestId").getAsInt(); ++ for (int index = 0; index < (-nativeId - 3) / 2; index++) { ++ f.requests.nextRequestId(); ++ } ++ f.requests.setPredictingClick(false); ++ final var sent = new ArrayList(); ++ try (var packets = mockStatic(PacketFactory.class)) { ++ packets.when(() -> PacketFactory.sendBedrockInventoryRequest(eq(f.user), any())) ++ .thenAnswer(call -> { ++ sent.add(call.getArgument(1)); ++ return null; ++ }); ++ assertTrue(station.handleNativeRecipeSelection(station.recipeCatalog(), recipe, true)); ++ assertEquals(1, sent.size()); ++ final var codec = new ArrayType<>(new ItemStackRequestType( ++ new BedrockItemType(0, false, NetworkItemStackFixtures.CONTEXT)), BedrockTypes.UNSIGNED_VAR_INT); ++ final var wire = Unpooled.buffer(); ++ try { ++ codec.write(wire, sent.getFirst()); ++ assertArrayEquals(HexFormat.of().parseHex(press.get("wire").getAsString()), ByteBufUtil.getBytes(wire)); ++ } finally { ++ wire.release(); ++ } ++ assertEquals(carried, f.inventory.getHudContainer().getItem(0)); ++ for (final var request : sent.getFirst()) { ++ assertEquals(carried, f.requests.getRequest(request.requestId()).predictedItem(f.inventory.getHudContainer(), 0)); ++ } ++ assertEquals(12, station.getItem(0).amount()); ++ } ++ compared++; ++ } ++ assertEquals(3, compared); ++ } ++ ++ @Test ++ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_AUTOSCOPED_REQUEST_WIRE", matches = ".+") ++ void authoredSelectionSubmitsTheOriginalNativeAutoscopedCookingBytes() throws Exception { ++ final var reference = JsonParser.parseString(Files.readString(Path.of( ++ System.getenv("STACKANVIL_NATIVE_AUTOSCOPED_REQUEST_WIRE")))).getAsJsonObject(); ++ final var f = fixture(); ++ final var station = new FurnaceContainer(f.user, (byte) 5, null, null); ++ f.inventory.setCurrentContainer(station); ++ f.inventory.getInventoryContainer().setItem(0, item(2, 7, 1000)); ++ f.inventory.getInventoryContainer().setItem(9, item(2, 5, 1009)); ++ final var recipe = recipe(); ++ f.user.get(CraftingDataTracker.class).updateCraftingDataList(List.of(recipe), true); ++ f.requests.nextRequestId(); ++ f.requests.nextRequestId(); ++ f.requests.setPredictingClick(false); ++ final var sent = new ArrayList(); ++ try (var packets = mockStatic(PacketFactory.class)) { ++ packets.when(() -> PacketFactory.sendBedrockInventoryRequest(eq(f.user), any())) ++ .thenAnswer(call -> { ++ sent.add(call.getArgument(1)); ++ return null; ++ }); ++ assertTrue(station.handleNativeRecipeSelection(station.recipeCatalog(), recipe, false)); ++ assertEquals(1, sent.size()); ++ final var codec = new ArrayType<>(new ItemStackRequestType( ++ new BedrockItemType(0, false, NetworkItemStackFixtures.CONTEXT)), BedrockTypes.UNSIGNED_VAR_INT); ++ final var buffer = Unpooled.buffer(); ++ try { ++ codec.write(buffer, sent.getFirst()); ++ assertArrayEquals(HexFormat.of().parseHex(reference.get("wire").getAsString()), ByteBufUtil.getBytes(buffer)); ++ } finally { ++ buffer.release(); ++ } ++ assertEquals(7, f.requests.getRequest(sent.getFirst()[0].requestId()).predictedItem(station, 0).amount()); ++ assertEquals(12, f.requests.getRequest(sent.getFirst()[1].requestId()).predictedItem(station, 0).amount()); ++ } ++ } ++ ++ @Test ++ void authoredSelectionUsesFuelThenPlayerSourcesAndReturnsTheOutputThroughPhysicalAliases() { ++ for (int family = 0; family < 3; family++) { ++ final var f = fixture(); ++ final FurnaceContainer station = switch (family) { ++ case 0 -> new FurnaceContainer(f.user, (byte) 5, null, null); ++ case 1 -> new BlastFurnaceContainer(f.user, (byte) 5, null, null); ++ default -> new SmokerContainer(f.user, (byte) 5, null, null); ++ }; ++ final var player = f.inventory.getInventoryContainer(); ++ f.inventory.setCurrentContainer(station); ++ station.setItem(1, item(2, 5, 40)); ++ station.setItem(2, item(4, 3, 41)); ++ player.setItem(0, item(2, 7, 42)); ++ player.setItem(9, item(2, 5, 43)); ++ final var recipe = recipe(List.of("furnace", "blast_furnace", "smoker").get(family), new BedrockItem(4)); ++ f.user.get(CraftingDataTracker.class).updateCraftingDataList(List.of(recipe), true); ++ f.requests.setPredictingClick(false); ++ final var sent = new ArrayList(); ++ try (var packets = mockStatic(PacketFactory.class)) { ++ packets.when(() -> PacketFactory.sendBedrockInventoryRequest(eq(f.user), any())) ++ .thenAnswer(call -> { ++ sent.add(call.getArgument(1)); ++ return null; ++ }); ++ assertTrue(station.handleNativeRecipeSelection(station.recipeCatalog(), recipe, false)); ++ assertEquals(17, station.getItem(0).amount()); ++ assertTrue(station.getItem(1).isEmpty()); ++ assertTrue(station.getItem(2).isEmpty()); ++ assertEquals(4, player.getItem(0).identifier()); ++ assertEquals(3, player.getItem(0).amount()); ++ final var requests = sent.getFirst(); ++ assertEquals(4, requests.length); ++ final var actions = Arrays.stream(requests).map(request -> ++ (ItemStackRequestAction.PlaceAction) request.actions().getFirst()).toList(); ++ assertEquals(station.getFullContainerName(1), actions.getFirst().source().container()); ++ assertEquals(1, actions.getFirst().source().slot()); ++ assertEquals(requests[0].requestId(), actions.get(1).destination().stackNetworkId()); ++ assertEquals(requests[1].requestId(), actions.get(2).destination().stackNetworkId()); ++ assertEquals(requests[1].requestId(), actions.getLast().destination().stackNetworkId()); ++ assertEquals(station.getFullContainerName(2), actions.getLast().source().container()); ++ assertFalse(station.handleNativeRecipeSelection(station.recipeCatalog(), recipe, false)); ++ acknowledge(f, requests); ++ assertFalse(f.requests.hasPending()); ++ } ++ } ++ } ++ ++ @Test ++ void repeatedNativeSelectionRestoresPostPlacementInventoryDeficitsBeforeGenericReturn() { ++ final var f = fixture(); ++ final var station = new FurnaceContainer(f.user, (byte) 5, null, null); ++ final var player = f.inventory.getInventoryContainer(); ++ f.inventory.setCurrentContainer(station); ++ player.setItem(0, item(2, 64, 40)); ++ player.setItem(9, item(2, 64, 41)); ++ final var recipe = recipe(); ++ f.user.get(CraftingDataTracker.class).updateCraftingDataList(List.of(recipe), true); ++ f.requests.setPredictingClick(false); ++ final var sent = new ArrayList(); ++ try (var packets = mockStatic(PacketFactory.class)) { ++ packets.when(() -> PacketFactory.sendBedrockInventoryRequest(eq(f.user), any())) ++ .thenAnswer(call -> { ++ sent.add(call.getArgument(1)); ++ return null; ++ }); ++ assertTrue(station.handleNativeRecipeSelection(station.recipeCatalog(), recipe, false)); ++ acknowledge(f, sent.getLast()); ++ player.getItem(9).setAmount(60); ++ player.markServerUpdate(); ++ assertTrue(station.handleNativeRecipeSelection(station.recipeCatalog(), recipe, false)); ++ assertTrue(station.getItem(0).isEmpty()); ++ assertEquals(64, player.getItem(9).amount()); ++ assertEquals(60, player.getItem(0).amount()); ++ final var returns = sent.getLast(); ++ assertEquals(2, returns.length); ++ final var first = (ItemStackRequestAction.PlaceAction) returns[0].actions().getFirst(); ++ final var second = (ItemStackRequestAction.PlaceAction) returns[1].actions().getFirst(); ++ assertEquals(4, first.count()); ++ assertEquals(9, first.destination().slot()); ++ assertEquals(60, second.count()); ++ assertEquals(returns[0].requestId(), second.source().stackNetworkId()); ++ acknowledge(f, returns); ++ assertTrue(station.handleNativeRecipeSelection(station.recipeCatalog(), recipe, false)); ++ assertEquals(64, station.getItem(0).amount()); ++ } ++ } ++ ++ @Test ++ void matchingIngredientSelectsWithoutAddingItemsAndRepeatingReturnsIt() { ++ final var f = fixture(); ++ final var station = new FurnaceContainer(f.user, (byte) 5, null, null); ++ f.inventory.setCurrentContainer(station); ++ station.setItem(0, item(2, 5, 40)); ++ f.inventory.getInventoryContainer().setItem(9, item(2, 8, 41)); ++ final var recipe = recipe(); ++ f.user.get(CraftingDataTracker.class).updateCraftingDataList(List.of(recipe), true); ++ f.requests.setPredictingClick(false); ++ final var sent = new ArrayList(); ++ try (var packets = mockStatic(PacketFactory.class)) { ++ packets.when(() -> PacketFactory.sendBedrockInventoryRequest(eq(f.user), any())).thenAnswer(call -> { ++ sent.add(call.getArgument(1)); ++ return null; ++ }); ++ assertTrue(station.handleNativeRecipeSelection(station.recipeCatalog(), recipe, false)); ++ assertEquals(5, station.getItem(0).amount()); ++ assertFalse(f.requests.hasPending()); ++ packets.verifyNoInteractions(); ++ f.inventory.getInventoryContainer().getItem(9).setAmount(7); ++ assertTrue(station.handleNativeRecipeSelection(station.recipeCatalog(), recipe, false)); ++ assertTrue(station.getItem(0).isEmpty()); ++ assertEquals(12, f.inventory.getInventoryContainer().getItem(9).amount()); ++ assertEquals(1, sent.getFirst().length); ++ assertEquals(5, ((ItemStackRequestAction.PlaceAction) sent.getFirst()[0].actions().getFirst()).count()); ++ } ++ } ++ ++ @Test ++ void failedNativeSubmissionRestoresSelectionAsWellAsPhysicalItems() { ++ final var f = fixture(); ++ final var station = new FurnaceContainer(f.user, (byte) 5, null, null); ++ f.inventory.setCurrentContainer(station); ++ f.inventory.getInventoryContainer().setItem(9, item(2, 5, 41)); ++ final var recipe = recipe(); ++ f.user.get(CraftingDataTracker.class).updateCraftingDataList(List.of(recipe), true); ++ f.requests.setPredictingClick(false); ++ try (var packets = mockStatic(PacketFactory.class)) { ++ packets.when(() -> PacketFactory.sendBedrockInventoryRequest(eq(f.user), any())) ++ .thenThrow(new IllegalStateException()); ++ assertThrows(IllegalStateException.class, () -> station.handleNativeRecipeSelection(station.recipeCatalog(), recipe, false)); ++ assertFalse(f.requests.hasPending()); ++ assertTrue(station.getItem(0).isEmpty()); ++ assertEquals(item(2, 5, 41), f.inventory.getInventoryContainer().getItem(9)); ++ packets.when(() -> PacketFactory.sendBedrockInventoryRequest(eq(f.user), any())).thenAnswer(_ -> null); ++ assertTrue(station.handleNativeRecipeSelection(station.recipeCatalog(), recipe, false)); ++ assertEquals(5, station.getItem(0).amount()); ++ } ++ } ++ ++ private static void acknowledge(final Fixture fixture, final ItemStackRequestInfo[] batch) { ++ int nextId = 3000; ++ for (final var request : batch) { ++ final var pending = fixture.requests.getRequest(request.requestId()); ++ final var action = (ItemStackRequestAction.PlaceAction) request.actions().getFirst(); ++ final Map> containers = new LinkedHashMap<>(); ++ for (final var slot : List.of(action.source(), action.destination())) { ++ final int index = Byte.toUnsignedInt(slot.slot()); ++ final var owner = fixture.inventory.getContainerFromName(slot.container(), index); ++ final var item = pending.predictedItem(owner, owner.slotFromRequest(index)); ++ containers.computeIfAbsent(slot.container(), _ -> new ArrayList<>()).add(new ItemStackResponseSlotInfo( ++ slot.slot(), slot.slot(), (byte) item.amount(), item.isEmpty() ? 0 : nextId++, "", null, 0)); ++ } ++ fixture.requests.handleResponse(new ItemStackResponseInfo(ItemStackNetResult.Success, request.requestId(), ++ containers.entrySet().stream().map(entry -> new ItemStackResponseContainerInfo(entry.getKey(), entry.getValue())).toList())); ++ } ++ } ++ ++ @Test ++ void nativeSelectionRequiresServerAuthorityConfirmedIdentitiesAndAnEmptyCursor() { ++ final var f = fixture(); ++ final var station = new FurnaceContainer(f.user, (byte) 5, null, null); ++ f.inventory.setCurrentContainer(station); ++ final var source = item(2, 5, 0); ++ f.inventory.getInventoryContainer().setItem(9, source); ++ final var recipe = recipe(); ++ f.user.get(CraftingDataTracker.class).updateCraftingDataList(List.of(recipe), true); ++ final var catalog = station.recipeCatalog(); ++ f.requests.setPredictingClick(false); ++ try (var packets = mockStatic(PacketFactory.class)) { ++ assertFalse(station.handleNativeRecipeSelection(catalog, recipe, false)); ++ source.setNetId(41); ++ f.inventory.getHudContainer().setItem(0, item(3, 1, 42)); ++ assertFalse(station.handleNativeRecipeSelection(catalog, recipe, false)); ++ f.inventory.getHudContainer().setItem(0, BedrockItem.empty()); ++ when(f.user.get(GameSessionStorage.class).isInventoryServerAuthoritative()).thenReturn(false); ++ assertFalse(station.handleNativeRecipeSelection(catalog, recipe, false)); ++ when(f.user.get(GameSessionStorage.class).isInventoryServerAuthoritative()).thenReturn(true); ++ f.user.get(CraftingDataTracker.class).updateCraftingDataList(List.of(), true); ++ assertFalse(station.handleNativeRecipeSelection(catalog, recipe, false)); ++ assertTrue(station.getItem(0).isEmpty()); ++ assertEquals(5, source.amount()); ++ assertFalse(f.requests.hasPending()); ++ packets.verifyNoInteractions(); ++ } ++ } ++ ++ @Test ++ void nativeCollectionUsesDescriptorsButStopsBeforeDifferentUserdata() { ++ final var f = fixture(); ++ final var station = new FurnaceContainer(f.user, (byte) 5, null, null); ++ f.inventory.setCurrentContainer(station); ++ final var first = item(2, 5, 40); ++ final var other = item(2, 3, 41); ++ final var later = item(2, 4, 42); ++ final var firstTag = new CompoundTag(); ++ firstTag.putInt("custom", 1); ++ first.setTag(firstTag); ++ later.setTag(firstTag.copy()); ++ final var otherTag = new CompoundTag(); ++ otherTag.putInt("custom", 2); ++ other.setTag(otherTag); ++ f.inventory.getInventoryContainer().setItem(0, first); ++ f.inventory.getInventoryContainer().setItem(9, other); ++ f.inventory.getInventoryContainer().setItem(10, later); ++ final var recipe = recipe(); ++ f.user.get(CraftingDataTracker.class).updateCraftingDataList(List.of(recipe), true); ++ f.requests.setPredictingClick(false); ++ try (var packets = mockStatic(PacketFactory.class)) { ++ assertTrue(station.handleNativeRecipeSelection(station.recipeCatalog(), recipe, false)); ++ assertEquals(5, station.getItem(0).amount()); ++ assertEquals(firstTag, station.getItem(0).tag()); ++ assertEquals(other, f.inventory.getInventoryContainer().getItem(9)); ++ assertEquals(later, f.inventory.getInventoryContainer().getItem(10)); ++ packets.verify(() -> PacketFactory.sendBedrockInventoryRequest(eq(f.user), any()), times(1)); ++ } ++ } ++ ++ @Test ++ void emptyIngredientReturnCapturesHistoryBeforeOutputReusesAnEmptiedSourceSlot() { ++ final var f = fixture(); ++ final var station = new FurnaceContainer(f.user, (byte) 5, null, null); ++ f.inventory.setCurrentContainer(station); ++ station.setItem(2, item(4, 3, 51)); ++ f.inventory.getInventoryContainer().setItem(9, item(4, 5, 52)); ++ final var recipe = alternative("self", 7, "furnace", "test:output", new BedrockItem(4), true); ++ f.user.get(CraftingDataTracker.class).updateCraftingDataList(List.of(recipe), true); ++ f.requests.setPredictingClick(false); ++ final var sent = new ArrayList(); ++ try (var packets = mockStatic(PacketFactory.class)) { ++ packets.when(() -> PacketFactory.sendBedrockInventoryRequest(eq(f.user), any())).thenAnswer(call -> { ++ sent.add(call.getArgument(1)); ++ return null; ++ }); ++ assertTrue(station.handleNativeRecipeSelection(station.recipeCatalog(), recipe, false)); ++ assertEquals(5, station.getItem(0).amount()); ++ assertTrue(station.getItem(2).isEmpty()); ++ assertTrue(f.inventory.getInventoryContainer().getItem(0).isEmpty()); ++ assertEquals(3, f.inventory.getInventoryContainer().getItem(9).amount()); ++ assertEquals(2, sent.getFirst().length); ++ final var returned = (ItemStackRequestAction.PlaceAction) sent.getFirst()[1].actions().getFirst(); ++ assertEquals(9, returned.destination().slot()); ++ assertEquals(sent.getFirst()[0].requestId(), returned.destination().stackNetworkId()); ++ } ++ } ++ ++ @Test ++ void nativeOutputSelectionChoosesTheLargestUnlockedAlternative() { ++ final var f = fixture(); ++ final var station = new FurnaceContainer(f.user, (byte) 5, null, null); ++ f.inventory.setCurrentContainer(station); ++ f.inventory.getInventoryContainer().setItem(0, item(2, 4, 40)); ++ f.inventory.getInventoryContainer().setItem(9, item(3, 7, 41)); ++ final var main = recipe(); ++ final var alternate = alternative("a-alternate", 8, "furnace", "test:previous", new BedrockItem(4), true); ++ f.user.get(CraftingDataTracker.class).updateCraftingDataList(List.of(main, alternate), true); ++ f.requests.setPredictingClick(false); ++ try (var packets = mockStatic(PacketFactory.class)) { ++ assertTrue(station.handleNativeRecipeSelection(station.recipeCatalog(), main, false)); ++ assertEquals(3, station.getItem(0).identifier()); ++ assertEquals(7, station.getItem(0).amount()); ++ assertEquals(4, f.inventory.getInventoryContainer().getItem(0).amount()); ++ assertTrue(f.inventory.getInventoryContainer().getItem(9).isEmpty()); ++ packets.verify(() -> PacketFactory.sendBedrockInventoryRequest(eq(f.user), any()), times(1)); ++ } ++ } ++ ++ @Test ++ void unavailableSelectionStillTogglesBeforeNewlyAvailableItemsCanBePlaced() { ++ final var f = fixture(); ++ final var station = new FurnaceContainer(f.user, (byte) 5, null, null); ++ f.inventory.setCurrentContainer(station); ++ final var recipe = recipe(); ++ f.user.get(CraftingDataTracker.class).updateCraftingDataList(List.of(recipe), true); ++ f.requests.setPredictingClick(false); ++ try (var packets = mockStatic(PacketFactory.class)) { ++ assertTrue(station.handleNativeRecipeSelection(station.recipeCatalog(), recipe, false)); ++ assertFalse(f.requests.hasPending()); ++ f.inventory.getInventoryContainer().setItem(9, item(2, 5, 41)); ++ assertTrue(station.handleNativeRecipeSelection(station.recipeCatalog(), recipe, false)); ++ assertTrue(station.getItem(0).isEmpty()); ++ assertFalse(f.requests.hasPending()); ++ packets.verifyNoInteractions(); ++ assertTrue(station.handleNativeRecipeSelection(station.recipeCatalog(), recipe, false)); ++ assertEquals(5, station.getItem(0).amount()); ++ packets.verify(() -> PacketFactory.sendBedrockInventoryRequest(eq(f.user), any()), times(1)); ++ } ++ } ++ ++ @Test + void displayedOutputCollectsOnlyMatchingUnlockedAlternativesAndInvalidatesSnapshots() { + final var fixture = fixture(); + final var user = fixture.user(); @@ -6912,6 +8205,48 @@ index 000000000..349cc2219 + } + + @Test ++ void returnUsesNativeMergeRulesAndRejectionRestoresTheOriginalVariants() { ++ final var fixture = fixture(); ++ final var user = fixture.user(); ++ final var inventory = fixture.inventory(); ++ final var furnace = new FurnaceContainer(user, (byte) 5, null, null); ++ inventory.setCurrentContainer(furnace); ++ final var previous = item(3, 2, 17); ++ previous.setData(1); ++ furnace.setItem(0, previous); ++ final var player = inventory.getInventoryContainer(); ++ for (int slot = 0; slot < player.size(); slot++) { ++ player.setItem(slot, item(3, 64, 100 + slot)); ++ } ++ player.setItem(0, item(2, 8, 20)); ++ player.setItem(9, item(3, 62, 29)); ++ final ItemStackRequestInfo[] sent = new ItemStackRequestInfo[1]; ++ try (var packets = mockStatic(PacketFactory.class)) { ++ packets.when(() -> PacketFactory.sendBedrockInventoryRequest(eq(user), any())).thenAnswer(call -> { ++ sent[0] = ((ItemStackRequestInfo[]) call.getArgument(1))[0]; ++ fixture.requests().getRequest(sent[0].requestId()).capturePrediction(inventory); ++ return null; ++ }); ++ assertTrue(furnace.handleRecipePlacement(recipe(), false)); ++ final var returned = (ItemStackRequestAction.PlaceAction) sent[0].actions().getFirst(); ++ assertEquals(17, returned.source().stackNetworkId()); ++ assertEquals(29, returned.destination().stackNetworkId()); ++ assertEquals(2, returned.count()); ++ final var placed = (ItemStackRequestAction.PlaceAction) sent[0].actions().get(1); ++ assertEquals(sent[0].requestId(), placed.destination().stackNetworkId()); ++ assertEquals(64, player.getItem(9).amount()); ++ assertEquals(0, player.getItem(9).data()); ++ assertEquals(1, furnace.getItem(0).amount()); ++ final var pending = fixture.requests().getRequest(sent[0].requestId()); ++ fixture.requests().restoreRejectedRequest(pending); ++ fixture.requests().removeRequest(sent[0].requestId()); ++ assertEquals(previous, furnace.getItem(0)); ++ assertEquals(item(3, 62, 29), player.getItem(9)); ++ assertEquals(item(2, 8, 20), player.getItem(0)); ++ } ++ } ++ ++ @Test + void declaredIngredientMetadataDoesNotReplaceTheNativeDefaultInputQuery() { + final var fixture = fixture(); + final var station = new FurnaceContainer(fixture.user(), (byte) 5, null, null); @@ -6984,14 +8319,20 @@ index 000000000..349cc2219 + call.getArgument(0), call.getArgument(1), call.getArgument(2), null, () -> null)); + when(items.recipeKey(any())).thenAnswer(call -> registry.recipeKey(call.getArgument(0), null)); + when(items.maxStackSize(any())).thenReturn(64); ++ when(items.canMerge(any(), any())).thenAnswer(call -> NativeItemStacking.canMerge( ++ call.getArgument(0), call.getArgument(1), new ComponentItemStackingProperties(64, null, false, false), ++ BedrockItem::data, ignored -> Set.of())); + when(items.matchesRecipeStack(any(), any())).thenAnswer(call -> registry.recipeKey(call.getArgument(0), null) + .matches(registry.recipeKey(call.getArgument(1), null), ignored -> null)); -+ when(items.matchesCookingOutput(any(), any())).thenAnswer(call -> CookingRecipeOutputs.matches( ++ when(items.matchesStackQuery(any(), any())).thenAnswer(call -> NativeItemStackMatcher.matches( + call.getArgument(0), call.getArgument(1), ignored -> null, ignored -> Set.of())); + storage.put(CraftingDataTracker.class, new CraftingDataTracker(user)); + storage.put(InventoryTracker.class, inventory); + storage.put(InventoryRequestTracker.class, requests); + storage.put(ItemRewriter.class, items); ++ final var session = mock(GameSessionStorage.class); ++ when(session.isInventoryServerAuthoritative()).thenReturn(true); ++ storage.put(GameSessionStorage.class, session); + requests.setPredictingClick(true); + return new Fixture(user, inventory, requests); + } @@ -7018,10 +8359,10 @@ index 000000000..349cc2219 +} 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 000000000..4aa761c5b +index 000000000..e4b19b36e --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/protocol/storage/NativeCookingUiStateTest.java -@@ -0,0 +1,286 @@ +@@ -0,0 +1,619 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -7041,13 +8382,22 @@ index 000000000..4aa761c5b +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.NativeCookingAction; ++import net.raphimc.viabedrock.api.model.NativeCookingActionResult; ++import net.raphimc.viabedrock.api.util.PacketFactory; ++import net.raphimc.viabedrock.protocol.packet.MultiStatePackets; ++import net.raphimc.viabedrock.protocol.packet.WorldPackets; ++import net.raphimc.viabedrock.protocol.model.inventory.ItemStackRequestInfo; ++import com.viaversion.viaversion.protocol.packet.PacketWrapperImpl; ++import com.viaversion.viaversion.protocols.v26_2to26_3.packet.ServerboundPackets26_3; +import net.raphimc.viabedrock.api.model.NativeCookingMessage; +import net.raphimc.viabedrock.api.model.NativeCookingProgress; -+import net.raphimc.viabedrock.api.model.container.CookingRecipeOutputs; ++import net.raphimc.viabedrock.api.model.container.NativeItemStackMatcher; +import net.raphimc.viabedrock.api.model.container.block.BlastFurnaceContainer; +import net.raphimc.viabedrock.api.model.container.block.FurnaceContainer; +import net.raphimc.viabedrock.api.model.container.block.SmokerContainer; +import net.raphimc.viabedrock.api.resourcepack.ui.ContainerUi; ++import net.raphimc.viabedrock.api.resourcepack.ui.CookingNavigation; +import net.raphimc.viabedrock.api.resourcepack.ui.CookingUi; +import net.raphimc.viabedrock.protocol.BedrockProtocol; +import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ItemVersion; @@ -7062,9 +8412,12 @@ index 000000000..4aa761c5b +import net.raphimc.viabedrock.protocol.rewriter.ItemRewriter; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; ++import org.mockito.ArgumentCaptor; + +import java.lang.reflect.Proxy; ++import java.nio.charset.StandardCharsets; +import java.util.HashMap; ++import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; @@ -7075,6 +8428,294 @@ index 000000000..4aa761c5b + +class NativeCookingUiStateTest { + ++ @Test ++ void receiverNegotiationAdvertisesTheServerboundChannelAndWithdrawalRetiresHeldInput() throws Exception { ++ final var f = fixture(CookingUi.Family.FURNACE); ++ prepareActions(f); ++ f.station.markServerUpdate(); ++ final var message = f.state.begin(new ContainerUi.Title("container.furnace", true)).message(); ++ final int recipe = message.view().book().visibleRecipes().iterator().next(); ++ 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", NativeCookingMessage.CHANNEL); ++ channels(f, "minecraft:register", NativeCookingProgress.CHANNEL); ++ assertTrue(outbound.isEmpty()); ++ channels(f, "minecraft:register", NativeCookingActionResult.CHANNEL); ++ assertEquals(1, outbound.size()); ++ assertEquals("minecraft:register", outbound.getFirst().get(Types.STRING, 0)); ++ assertArrayEquals(NativeCookingAction.CHANNEL.getBytes(StandardCharsets.UTF_8), outbound.getFirst().get(Types.REMAINING_BYTES, 0)); ++ assertFalse(f.user.get(ChannelStorage.class).hasChannel(NativeCookingAction.CHANNEL)); ++ assertTrue(NativeCookingUiState.actionsEnabled(f.user)); ++ channels(f, "minecraft:register", NativeCookingActionResult.CHANNEL); ++ assertEquals(1, outbound.size()); ++ ++ cookingPacket(f, action(message, recipe, 0, 1)); ++ assertTrue(outbound.getLast().get(NativeCookingActionResult.TYPE, 0).accepted()); ++ assertEquals(10, f.station.getItem(0).amount()); ++ channels(f, "minecraft:unregister", NativeCookingActionResult.CHANNEL); ++ assertFalse(NativeCookingUiState.actionsEnabled(f.user)); ++ assertEquals("minecraft:unregister", outbound.getLast().get(Types.STRING, 0)); ++ assertArrayEquals(NativeCookingAction.CHANNEL.getBytes(StandardCharsets.UTF_8), outbound.getLast().get(Types.REMAINING_BYTES, 0)); ++ final int withdrawn = outbound.size(); ++ cookingPacket(f, action(message, recipe, 2, 2)); ++ assertEquals(withdrawn, outbound.size()); ++ channels(f, "minecraft:unregister", NativeCookingActionResult.CHANNEL); ++ assertEquals(withdrawn, outbound.size()); ++ ++ channels(f, "minecraft:register", NativeCookingActionResult.CHANNEL); ++ assertEquals("minecraft:register", outbound.getLast().get(Types.STRING, 0)); ++ cookingPacket(f, action(message, recipe, 2, 3)); ++ assertFalse(outbound.getLast().get(NativeCookingActionResult.TYPE, 0).accepted()); ++ packets.verify(() -> PacketFactory.sendBedrockInventoryRequest(eq(f.user), any()), times(1)); ++ assertEquals(10, f.station.getItem(0).amount()); ++ when(f.user.get(GameSessionStorage.class).isInventoryServerAuthoritative()).thenReturn(false); ++ NativeCookingUiState.sendUpdate(f.user); ++ assertEquals("minecraft:unregister", outbound.getLast().get(Types.STRING, 0)); ++ assertNull(f.state.update()); ++ } ++ } ++ ++ @Test ++ void capabilityRequiresEveryReceiverAndPlayAuthority() throws Exception { ++ final var required = List.of(NativeCookingMessage.CHANNEL, NativeCookingProgress.CHANNEL, NativeCookingActionResult.CHANNEL); ++ for (final String missing : required) { ++ final var f = fixture(CookingUi.Family.FURNACE); ++ f.user.get(ChannelStorage.class).addChannels(required.stream().filter(channel -> !channel.equals(missing)).toList()); ++ f.user.get(ChannelStorage.class).addChannels(List.of(NativeCookingAction.CHANNEL)); ++ try (var factory = mockStatic(PacketWrapper.class)) { ++ assertFalse(NativeCookingUiState.actionsEnabled(f.user)); ++ f.state.refreshActionCapability(); ++ factory.verifyNoInteractions(); ++ f.user.get(ChannelStorage.class).addChannels(List.of(missing)); ++ when(f.user.getProtocolInfo().getClientState()).thenReturn(State.CONFIGURATION); ++ f.state.refreshActionCapability(); ++ assertFalse(NativeCookingUiState.actionsEnabled(f.user)); ++ factory.verifyNoInteractions(); ++ when(f.user.getProtocolInfo().getClientState()).thenReturn(State.PLAY); ++ when(f.user.get(GameSessionStorage.class).isInventoryServerAuthoritative()).thenReturn(false); ++ f.state.refreshActionCapability(); ++ assertFalse(NativeCookingUiState.actionsEnabled(f.user)); ++ factory.verifyNoInteractions(); ++ when(f.user.get(GameSessionStorage.class).isInventoryServerAuthoritative()).thenReturn(true); ++ final var packet = mock(PacketWrapper.class); ++ factory.when(() -> PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, f.user)).thenReturn(packet); ++ f.state.refreshActionCapability(); ++ verify(packet).write(Types.STRING, "minecraft:register"); ++ f.user.get(ChannelStorage.class).removeChannels(List.of(missing)); ++ f.state.refreshActionCapability(); ++ verify(packet).write(Types.STRING, "minecraft:unregister"); ++ verify(packet, times(2)).send(BedrockProtocol.class); ++ } ++ } ++ } ++ ++ private static void channels(final Fixture fixture, final String operation, final String channel) throws Exception { ++ final var input = Unpooled.buffer(); ++ try { ++ Types.STRING.write(input, operation); ++ input.writeBytes(channel.getBytes(StandardCharsets.UTF_8)); ++ MultiStatePackets.CUSTOM_PAYLOAD_HANDLER.handle(new PacketWrapperImpl(ServerboundPackets26_3.CUSTOM_PAYLOAD, input, fixture.user)); ++ } finally { ++ input.release(); ++ } ++ } ++ ++ @Test ++ void negotiatedRecipePacketsExecuteOnceAndReleaseOnlyTheOriginalGesture() throws Exception { ++ final var f = fixture(CookingUi.Family.FURNACE); ++ prepareActions(f); ++ f.station.markServerUpdate(); ++ final var message = f.state.begin(new ContainerUi.Title("container.furnace", true)).message(); ++ final int recipe = message.view().book().visibleRecipes().iterator().next(); ++ final var press = action(message, recipe, 0, 1); ++ final var sent = new ArrayList(); ++ final var outbound = new ArrayList(); ++ try (var packets = mockStatic(PacketFactory.class); ++ var factory = mockStatic(PacketWrapper.class)) { ++ packets.when(() -> PacketFactory.sendBedrockInventoryRequest(eq(f.user), any())) ++ .thenAnswer(call -> { ++ sent.add(call.getArgument(1)); ++ return null; ++ }); ++ packets.when(() -> PacketFactory.sendJavaContainerSetContent(eq(f.user), any())).thenAnswer(_ -> { ++ NativeCookingUiState.sendUpdate(f.user); ++ return null; ++ }); ++ factory.when(() -> PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, f.user)).thenAnswer(_ -> { ++ final var packet = mock(PacketWrapper.class); ++ outbound.add(packet); ++ return packet; ++ }); ++ cookingPacket(f, press); ++ assertTrue(sent.isEmpty()); ++ assertTrue(outbound.isEmpty()); ++ f.user.get(ChannelStorage.class).addChannels(List.of(NativeCookingMessage.CHANNEL, NativeCookingProgress.CHANNEL, ++ NativeCookingActionResult.CHANNEL)); ++ cookingPacket(f, press); ++ assertEquals(1, sent.size()); ++ assertEquals(2, outbound.size()); ++ assertEquals(2, sent.getFirst().length); ++ assertEquals(10, f.station.getItem(0).amount()); ++ assertFalse(f.state.handleAction(press)); ++ assertTrue(f.state.handleAction(action(message, recipe, 1, 2))); ++ final var snapshot = ArgumentCaptor.forClass(NativeCookingMessage.class); ++ verify(outbound.getFirst()).write(eq(NativeCookingMessage.TYPE), snapshot.capture()); ++ assertEquals(0, snapshot.getValue().view().book().selection().index()); ++ verify(outbound.getLast()).write(eq(NativeCookingActionResult.TYPE), ++ argThat(result -> result.accepted() && !result.current() && result.action().equals(press))); ++ assertTrue(f.state.handleAction(action(message, recipe, 2, 3))); ++ assertEquals(-1, f.state.update().message().view().book().selection().index()); ++ assertFalse(f.state.handleAction(action(message, recipe, 2, 3))); ++ assertEquals(1, sent.size()); ++ assertEquals(10, f.station.getItem(0).amount()); ++ assertTrue(f.user.get(InventoryRequestTracker.class).hasPending()); ++ } ++ } ++ ++ @Test ++ void recipeActionsRequireTheDisplayedCellAndCurrentItemsAndCannotReleaseAnotherMenu() { ++ final var f = fixture(CookingUi.Family.FURNACE); ++ prepareActions(f); ++ f.station.markServerUpdate(); ++ final var message = f.state.begin(new ContainerUi.Title("container.furnace", true)).message(); ++ final int visible = message.view().book().visibleRecipes().iterator().next(); ++ final int hidden = message.view().book().catalog().stream().mapToInt(CookingUi.Recipe::id) ++ .filter(id -> !message.view().book().visibleRecipes().contains(id)).findFirst().orElseThrow(); ++ try (var packets = mockStatic(PacketFactory.class)) { ++ assertFalse(f.state.handleAction(action(message, hidden, 0, 1))); ++ f.inventory.getInventoryContainer().getItem(0).setAmount(6); ++ assertFalse(f.state.handleAction(action(message, visible, 0, 2))); ++ final var next = f.state.update().message(); ++ assertTrue(f.state.handleAction(action(next, visible, 0, 3))); ++ assertEquals(0, f.state.update().message().view().book().selection().index()); ++ f.user.get(InventoryOptionsStorage.class).apply(new FurnaceOptions(1, 2, false, 2)); ++ assertEquals(-1, f.state.update().message().view().book().selection().index()); ++ f.inventory.setCurrentContainerClosed(false); ++ final var replacement = new FurnaceContainer(f.user, f.station.containerId(), null, null); ++ f.inventory.setCurrentContainer(replacement); ++ assertFalse(f.state.handleAction(action(next, visible, 2, 4))); ++ assertTrue(replacement.getItem(0).isEmpty()); ++ packets.verify(() -> PacketFactory.sendBedrockInventoryRequest(eq(f.user), any()), times(1)); ++ } ++ } ++ ++ @Test ++ void focusedCarriedSelectionUsesTheNativeModeBranchWithoutChangingTheCursor() { ++ for (final var family : CookingUi.Family.values()) { ++ for (final int controllerMode : new int[]{0, 1, 2, 3}) { ++ for (final int count : new int[]{1, 64}) { ++ final var f = fixture(family); ++ prepareActions(f); ++ final var carried = f.inventory.getInventoryContainer().getItem(0).copy(); ++ carried.setAmount(count); ++ carried.setNetId(700); ++ f.inventory.getHudContainer().setItem(0, carried.copy()); ++ f.station.markServerUpdate(); ++ final var message = f.state.begin(new ContainerUi.Title("container.furnace", true)).message(); ++ final int recipe = message.view().book().visibleRecipes().iterator().next(); ++ final var sent = new ArrayList(); ++ try (var packets = mockStatic(PacketFactory.class)) { ++ packets.when(() -> PacketFactory.sendBedrockInventoryRequest(eq(f.user), any())) ++ .thenAnswer(call -> { ++ sent.add(call.getArgument(1)); ++ return null; ++ }); ++ assertTrue(f.state.handleAction(contextAction(message, recipe, controllerMode, 1, 1))); ++ assertNull(f.state.update()); ++ assertTrue(sent.isEmpty()); ++ final var press = contextAction(message, recipe, controllerMode, 0, 2); ++ final boolean selected = controllerMode == 2; ++ assertEquals(selected, f.state.handleAction(press)); ++ assertEquals(carried, f.inventory.getHudContainer().getItem(0)); ++ assertEquals(selected ? 10 : 0, f.station.getItem(0).amount()); ++ assertEquals(selected ? 1 : 0, sent.size()); ++ if (selected) { ++ assertEquals(2, sent.getFirst().length); ++ for (final var request : sent.getFirst()) { ++ assertEquals(carried, f.user.get(InventoryRequestTracker.class).getRequest(request.requestId()) ++ .predictedItem(f.inventory.getHudContainer(), 0)); ++ } ++ assertNotNull(f.state.update().message()); ++ assertTrue(f.state.handleAction(contextAction(message, recipe, controllerMode, 2, 3))); ++ assertEquals(-1, f.state.update().message().view().book().selection().index()); ++ } ++ } ++ } ++ } ++ } ++ } ++ ++ @Test ++ void focusReleaseAndCapabilityWithdrawalRetireOnlyTheirOwnedPermission() { ++ final var f = fixture(CookingUi.Family.FURNACE); ++ prepareActions(f); ++ final var carried = f.inventory.getInventoryContainer().getItem(0).copy(); ++ carried.setNetId(700); ++ f.inventory.getHudContainer().setItem(0, carried.copy()); ++ f.station.markServerUpdate(); ++ final var message = f.state.begin(new ContainerUi.Title("container.furnace", true)).message(); ++ final int recipe = message.view().book().visibleRecipes().iterator().next(); ++ try (var packets = mockStatic(PacketFactory.class)) { ++ assertTrue(f.state.handleAction(contextAction(message, recipe, 2, 1, 1))); ++ assertFalse(f.state.handleAction(contextAction(message, recipe, 1, 2, 2))); ++ assertTrue(f.state.handleAction(contextAction(message, recipe, 2, 2, 3))); ++ assertFalse(f.state.handleAction(contextAction(message, recipe, 2, 0, 4))); ++ assertTrue(f.state.handleAction(contextAction(message, recipe, 2, 1, 5))); ++ f.state.refreshActionCapability(); ++ assertFalse(f.state.handleAction(contextAction(message, recipe, 2, 0, 6))); ++ assertTrue(f.state.handleAction(contextAction(message, recipe, 2, 1, 7))); ++ f.inventory.setCurrentContainerClosed(false); ++ final var replacement = new FurnaceContainer(f.user, f.station.containerId(), null, null); ++ f.inventory.setCurrentContainer(replacement); ++ replacement.markServerUpdate(); ++ final var next = f.state.begin(new ContainerUi.Title("container.furnace", true)).message(); ++ final int nextRecipe = next.view().book().visibleRecipes().iterator().next(); ++ assertFalse(f.state.handleAction(contextAction(message, recipe, 2, 2, 8))); ++ assertFalse(f.state.handleAction(contextAction(next, nextRecipe, 2, 0, 1))); ++ assertEquals(carried, f.inventory.getHudContainer().getItem(0)); ++ packets.verify(() -> PacketFactory.sendBedrockInventoryRequest(eq(f.user), any()), never()); ++ } ++ } ++ ++ private static NativeCookingAction contextAction(final NativeCookingMessage message, final int recipe, ++ final int controllerMode, final int phase, final long request) { ++ return new NativeCookingAction(message.menuId(), message.sequence(), message.updateSequence(), message.menuRevision(), ++ message.inventoryRevision(), message.cursorRevision(), recipe, NativeCookingAction.Mode.PRIMARY, controllerMode, phase, request); ++ } ++ ++ private static void prepareActions(final Fixture fixture) { ++ fixture.inventory.getInventoryContainer().getItem(0).setNetId(1000); ++ fixture.inventory.getInventoryContainer().getItem(9).setNetId(1009); ++ when(fixture.items.maxStackSize(any())).thenReturn(64); ++ when(fixture.items.canMerge(any(), any())).thenAnswer(call -> NativeItemStackMatcher.matches( ++ call.getArgument(0), call.getArgument(1), _ -> null, _ -> Set.of())); ++ fixture.user.get(InventoryRequestTracker.class).setPredictingClick(false); ++ } ++ ++ private static NativeCookingAction action(final NativeCookingMessage message, final int recipe, final int phase, final long request) { ++ return new NativeCookingAction(message.menuId(), message.sequence(), message.updateSequence(), message.menuRevision(), ++ message.inventoryRevision(), message.cursorRevision(), recipe, NativeCookingAction.Mode.PRIMARY, 1, phase, request); ++ } ++ ++ private static void cookingPacket(final Fixture fixture, final NativeCookingAction action) throws Exception { ++ final var buffer = Unpooled.buffer(); ++ try { ++ Types.STRING.write(buffer, NativeCookingAction.CHANNEL); ++ NativeCookingAction.TYPE.write(buffer, action); ++ MultiStatePackets.CUSTOM_PAYLOAD_HANDLER.handle(new PacketWrapperImpl(ServerboundPackets26_3.CUSTOM_PAYLOAD, buffer, fixture.user)); ++ } finally { ++ buffer.release(); ++ } ++ } ++ + @BeforeAll + static void initializeItemCodecs() throws ClassNotFoundException { + Class.forName(StructuredDataKey.class.getName()); @@ -7115,6 +8756,35 @@ index 000000000..4aa761c5b + } + + @Test ++ void preferenceChangesPublishNativeCaptionsWithoutChangingItemAuthority() { ++ for (final var family : CookingUi.Family.values()) { ++ final var f = fixture(family); ++ f.station.markServerUpdate(); ++ final var opening = f.state.begin(new ContainerUi.Title("custom title", false)).message(); ++ final int type = switch (family) { ++ case FURNACE -> 1; ++ case BLAST_FURNACE -> 2; ++ case SMOKER -> 3; ++ }; ++ long update = opening.updateSequence(); ++ for (final int tab : new int[]{4, 5}) { ++ for (final boolean filtering : new boolean[]{false, true}) { ++ f.user.get(InventoryOptionsStorage.class).apply(new FurnaceOptions(type, tab, filtering, 2)); ++ final var message = f.state.update().message(); ++ assertEquals(++update, message.updateSequence()); ++ assertEquals(opening.sequence(), message.sequence()); ++ assertEquals(opening.menuRevision(), message.menuRevision()); ++ assertEquals(opening.inventoryRevision(), message.inventoryRevision()); ++ assertEquals(opening.cursorRevision(), message.cursorRevision()); ++ assertEquals(CookingNavigation.tabLabel(tab, family, filtering), message.view().book().tabLabel()); ++ final var decoded = CookingUi.fromProgram(JsonParser.parseString(message.program()).getAsJsonObject()); ++ assertEquals(f.user.get(ResourcePackStorage.class).getCookingUi().resolve(message.view()), decoded.resolve(message.view())); ++ } ++ } ++ } ++ } ++ ++ @Test + void progressDoesNotRetireAuthorityButPredictionsAndPreferencesDo() { + final var f = fixture(CookingUi.Family.FURNACE); + f.station.markServerUpdate(); @@ -7134,7 +8804,7 @@ index 000000000..4aa761c5b + final var predicted = f.state.update().message(); + assertEquals(first.updateSequence() + 1, predicted.updateSequence()); + assertEquals(first.inventoryRevision(), predicted.inventoryRevision()); -+ f.user.get(FurnaceOptionsStorage.class).apply(new FurnaceOptions(1, 2, true, 1)); ++ f.user.get(InventoryOptionsStorage.class).apply(new FurnaceOptions(1, 2, true, 1)); + final var options = f.state.update().message(); + assertEquals(2, options.view().book().tab()); + assertEquals(1, options.view().book().layout()); @@ -7248,7 +8918,11 @@ index 000000000..4aa761c5b + when(session.isInventoryServerAuthoritative()).thenReturn(true); + storage.put(GameSessionStorage.class, session); + storage.put(ChannelStorage.class, new ChannelStorage()); -+ storage.put(FurnaceOptionsStorage.class, new FurnaceOptionsStorage()); ++ storage.put(NativeSkinStorage.class, mock(NativeSkinStorage.class)); ++ storage.put(PlayerInputStorage.class, mock(PlayerInputStorage.class)); ++ storage.put(HudVisibilityStorage.class, mock(HudVisibilityStorage.class)); ++ storage.put(CameraEffectsStorage.class, mock(CameraEffectsStorage.class)); ++ storage.put(InventoryOptionsStorage.class, new InventoryOptionsStorage()); + storage.put(ResourcePackStorage.class, new ResourcePackStorage(List.of())); + final var tracker = new CraftingDataTracker(user); + storage.put(CraftingDataTracker.class, tracker); @@ -7269,7 +8943,7 @@ index 000000000..4aa761c5b + when(items.recipeKey(any())).thenAnswer(call -> registry.recipeKey(call.getArgument(0), null)); + when(items.matchesRecipeStack(any(), any())).thenAnswer(call -> registry.recipeKey(call.getArgument(0), null) + .matches(registry.recipeKey(call.getArgument(1), null), _ -> null)); -+ when(items.matchesCookingOutput(any(), any())).thenAnswer(call -> CookingRecipeOutputs.matches( ++ when(items.matchesStackQuery(any(), any())).thenAnswer(call -> NativeItemStackMatcher.matches( + call.getArgument(0), call.getArgument(1), _ -> null, _ -> Set.of())); + when(items.javaItem(any(BedrockItem.class))).thenAnswer(call -> { + return javaItem(call.getArgument(0)); @@ -7611,6 +9285,58 @@ index 000000000..4f0986e9a + } + +} +diff --git a/src/test/java/net/raphimc/viabedrock/protocol/types/model/InventoryOptionsTypeTest.java b/src/test/java/net/raphimc/viabedrock/protocol/types/model/InventoryOptionsTypeTest.java +new file mode 100644 +index 000000000..c03fe8ef5 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/protocol/types/model/InventoryOptionsTypeTest.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.protocol.types.model; ++ ++import io.netty.buffer.Unpooled; ++import net.raphimc.viabedrock.protocol.model.InventoryOptions; ++import net.raphimc.viabedrock.protocol.types.BedrockTypes; ++import org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++class InventoryOptionsTypeTest { ++ ++ @Test ++ void signedEnumsAndIndependentLayoutsUseTheirNativeWireOrder() { ++ final var buffer = Unpooled.buffer(); ++ try { ++ final var value = new InventoryOptions(6, 3, true, 3, 2); ++ BedrockTypes.INVENTORY_OPTIONS.write(buffer, value); ++ final byte[] expected = {12, 6, 1, 6, 4}; ++ final byte[] actual = new byte[buffer.readableBytes()]; ++ buffer.getBytes(0, actual); ++ assertArrayEquals(expected, actual); ++ assertEquals(value, BedrockTypes.INVENTORY_OPTIONS.read(buffer)); ++ assertFalse(buffer.isReadable()); ++ ++ final byte[] extremes = {1, (byte) 254, 1, 1, (byte) 255, (byte) 255, (byte) 255, ++ (byte) 255, 15, (byte) 254, (byte) 255, (byte) 255, (byte) 255, 15}; ++ buffer.clear().writeBytes(extremes); ++ final var unknown = new InventoryOptions(-1, 127, true, Integer.MIN_VALUE, Integer.MAX_VALUE); ++ assertEquals(unknown, BedrockTypes.INVENTORY_OPTIONS.read(buffer)); ++ assertFalse(buffer.isReadable()); ++ buffer.clear(); ++ BedrockTypes.INVENTORY_OPTIONS.write(buffer, unknown); ++ final byte[] encoded = new byte[buffer.readableBytes()]; ++ buffer.readBytes(encoded); ++ assertArrayEquals(extremes, encoded); ++ } finally { ++ buffer.release(); ++ } ++ } ++ ++} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/types/recipe/CraftingDataReaderTest.java b/src/test/java/net/raphimc/viabedrock/protocol/types/recipe/CraftingDataReaderTest.java index 79f72bab5..d7a838765 100644 --- a/src/test/java/net/raphimc/viabedrock/protocol/types/recipe/CraftingDataReaderTest.java 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 78464310..b9ef6b86 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 @@ -242,3 +242,198 @@ All three cooking controls and same-world reconnect pass. The run exits normally, stops its private display and removes the private observer mod. These observations verify publication and CPU resolution, not authored screen rendering or native action parity. Offline tooling checks, all 264 regressions and patch metadata validation pass. + +## Track native prediction identities in cooking requests + +Cooking requests use the negative request ID for any slot that an earlier action changed. +The native identity callback applies this rule to both transfer endpoints, including a source that becomes empty. +The planner tracks physical slots across hotbar, inventory, and combined player providers. +Cooking sources use providers 28 and 29. Returns use provider 12. + +The earlier serialization fixture used the base manager and omitted the native identity callback. +Its positive IDs describe unpredicted wrappers and do not establish production prediction behavior. +The replacement fixture constructs the native predicted containers and the original ClientScreenData component. +It executes the original client identity callback, transfer mutation, payload conversion, and serialization. +Five complete payloads cover cooking placement and partial or complete moves into empty or occupied slots. + +The manager takes its normal null-Level constructor branch before the controlled world attaches. +Client subclass fields, the one-entity registry, screen bindings, packet view, and stream fields remain controlled fixture data. +The open-request controls do not establish full Player or screen construction, finalization, transport, or live acceptance. +The [request reference](../../../docs/native-cooking-actions.md#request-payload-identities) records these boundaries. +The optional byte comparison now reads `STACKANVIL_NATIVE_FINALIZED_REQUEST_WIRE` from the stronger fixture below. + +## Finalize and flush owned native cooking requests + +A fresh fixture uses the registry owned by the original native request manager and creates its entity through the original helper. +Native weak-reference construction retains ownership, and native detach releases that ownership after the cooking input. +The original request finish queues both Place actions without changing their IDs. +The original enabled flush converts them, constructs the packet, clears and destroys the queue, and invokes the transport interface. +The transport sink calls the original serializer on that actual packet. +Native payload cleanup completes, and a second empty flush emits nothing. +The Java planner and codec match the finalized native payload byte for byte. + +Typed storage and client subclass fields still follow controlled inline layouts. +Screen container bindings, the transport sink, and BinaryStream fields remain supplied boundaries. +Full Player/screen construction, component signals, negotiated mode provenance, outer input/frame ownership, and live acceptance remain unverified. +This evidence does not activate or complete authored cooking actions. + +A separate fresh control installs the native screen lease and delivers input through the inherited wrapper without an explicit request scope. +Its two transfers create two queued requests, and the second destination references the first request ID. +Native flush serializes both requests and preserves registry reference counts. +The input-child vector remains empty, and full screen registration does not execute. +Outer frame ownership and deferred recipe execution can change batching, so this control does not establish full application grouping. + +Validation: all 104 core patches replay. The four-project build passes 2,384 tests with 212 optional skips and no failures or errors. +The 19 focused planner/request tests include the finalized native byte comparison. +Checkstyle and offline validation pass. Authored cooking controls remain unfinished. + +## Native primary action backend + +The core now consumes negotiated version-`4` cooking actions against the exact current presentation and authored recipe cell. +Primary selection follows the native selected-output entry before the ingredient predicate. +It toggles an existing selection, collects ingredient sources from fuel and player providers, and returns ingredient and output items through native history. +Selection-only admission leaves history unchanged. The constructor starts with an empty history. Output return captures the post-placement player history. +Java recipe-book increment and maximum-quantity behavior remain separate. + +Each transfer uses its own request and intermediate prediction, with negative references for earlier slot changes and physical player aliases. +The production primary action matches the native auto-scoped two-source packet byte for byte. +Follow-up phases retain their original gesture identity and do not repeat transfers. +Sequences, item snapshots, channel negotiation, menu ownership, and current authored commands protect action admission. +Intermediate publication waits until action completion. Highlights follow the visible recipe order after preference changes. + +A fresh native prediction fixture executes six press/release steps with automatic scopes, owned finalization, and a flush after each input. +It confirms selection clearing, combined-provider return, refill references, and post-placement history through the original entry and transfer code. +Native constructor and entry evidence identifies the initially empty history and the output comparison that precedes ingredient matching. + +The Fabric presentation remains passive. Physical input bindings, nonempty-cursor branches, cross-input prediction, authoritative model watchers, and live route acceptance remain unfinished. +Full native controller/model construction and independent frame scopes also remain unverified. + +The combined four-project build passes 2,413 tests, with 213 optional skips and no failures or errors. +All 104 core patches replay with the incoming movement changes preserved. +The 43 focused cooking, UI-state, planner, and batch tests pass with both native wire comparisons enabled. +Checkstyle and offline validation pass. All 1,401 core classes match the add-on's embedded core byte for byte. + +## Directional action negotiation + +Cooking action negotiation now follows the payload direction. +The client registers its model, progress, and action-result receivers. The core advertises the separate action channel that it receives. +The core requires play state and server-authoritative inventory before it advertises or consumes actions. +Duplicate receiver registration does not repeat the advertisement. + +Receiver withdrawal removes the advertisement and retires held input without transferring items again. +Re-registering receivers cannot resume the retired gesture. Loss of server authority also withdraws the capability and clears presentation ownership. +Packet regressions use real registration and action handlers without registering the action channel as a client receiver. +The original direction check rejects that valid action. The corrected handler executes it once. +Fabric screen bindings, complete authored controls, and live route acceptance remain unfinished. + +The four-project build passes 2,418 tests, with 213 optional skips and no failures or errors. +All 45 focused cooking and request tests pass with both native wire comparisons enabled. +The 104 core and 38 add-on patches replay. Checkstyle and offline validation pass. +All 1,401 embedded core classes match the rebuilt core byte for byte. + +A private Fabric observer verifies the channel lifecycle against BDS 1.26.51.1. +It observes action sending become unavailable after result-receiver withdrawal, then available after re-registration. +The production receiver retains all 39 slots across 187 frames from furnace, smoker, and blast-furnace menus, with no observer errors. +Both client sessions match all 1,401 core classes and four resources. + +The three Java cooking cases and same-world reconnect pass. Cleanup leaves no owned client, server, or display processes, and the private observer is removed. +The observer sends no inventory actions. These observations establish channel lifecycle, while authored action execution and native screen parity remain unverified. + +## Native cooking captions + +The publisher now uses the shared native tab-label rules. +Disabling filtering selects the all-recipes caption; enabling it selects the furnace or smoker caption. +The inventory tab uses the shared survival key. +Program export preserves the current controller label's translation along with the authoritative title. + +A publisher regression covers all three families, both filter states, and search/inventory tabs. +Preference changes advance the model update while opening and item authority remain stable. +Complete program round trips match the core-resolved scene. +The previous publisher fails this regression; the corrected publisher passes. +Sixty original getter controls establish the shared rules with supplied controller fields and bounded allocation/CRT seams. +Native fallback titles, complete input handling, and full screen parity remain open. + +The final four-project build passes 2,487 tests, with 212 optional skips and no failures or errors. +The native navigation and caption suite runs without skips. +All 104 core and 38 add-on patches replay, and all 1,404 embedded core classes match. + +A controlled BDS 1.26.51.1 client fixture exercises fourteen preference changes and six recipe press/release replies. +All fourteen model caption keys match the native getter cases and the shipped English table. +Eleven visible caption draws match their exported translations. +Furnace, smoker, and blast-furnace cooking and same-world reconnect pass. +Two runtime inventories match 2,520 classes and four data resources each. +Normal cleanup removes the private observer, restores the driver, and leaves no managed-profile JVM. +The scene projection removes unfinished controls and mappings, so it does not establish full default-screen or native pixel parity. + + +## Focused carried-item recipe actions + +The backend retains an initial mode-`2` focus before the primary recipe press. +That focus permits selection with a carried Item while direct unfocused presses keep their empty-cursor check. +Focus itself transfers nothing and does not manufacture a selection snapshot. +Terminal release requires its original opening, snapshot, and controller context. +Capability withdrawal and menu replacement retire the permission. + +Five fresh native controls execute the original graph, held-slot getter, focus entry, recipe effects, provider transfers, and owned request serialization. +Mode-`2` counts `0`, `1`, and `64` retain the cursor while placing twelve ingredients through two requests. +Carried controls in modes `1` and `3` produce no requests. +All three production mode-`2` request payloads match the original serialized bytes, including the changed destination's negative request reference. +Every request prediction retains the carried stack. + +The [focused transfer reference](../../../docs/native-cooking-actions.md#focused-carried-item-transfers) lists the original functions and supplied interfaces. +Mandatory tests cover all three cooking families, mode-specific admission, exact-context release, capability loss, and reused menu identity. +The Java mouse adapter remains mode `1`. +Physical mode-`2` events, carried-item screen controls, full controller construction, server acceptance of that carried path, and native pixels remain unfinished. + +The planner also rechecks player-UI contents and server revision after item metadata callbacks. +Changed contents or a revision-only replacement reject before station/player mutation or request submission. +The newer cursor remains intact, and a fresh retry retains the original selection history. + +The final combined suites pass 2,621 tests, with 163 optional skips and no failures or errors. +All four projects build, and the final core rerun includes the integrated movement reference fixtures. +Cooking, camera, retained particle, attachable, and Iris GPU comparisons run without skips. +All 1,413 core classes match both packaged copies, and all 1,139 production client classes match the add-on. +The affected core and add-on stacks replay successfully. + +A fresh BDS 1.26.51.1 route verifies version-five mouse actions in furnace, smoker, and blast-furnace screens. +All six press/release replies retain context `1` and their original authority across newer frames. +Each real ingredient starts cooking, all three ordinary follow-up cases pass, and same-world reconnect passes. +Both runtime inventories match 2,551 project class resources and four data resources. +Normal cleanup removes the observer, restores the private driver change, and leaves no managed-profile JVM. +The controlled scene projection preserves all 39 real slots while it removes unfinished controls and mappings. +This run does not establish unchanged authored screens, physical mode-`2` input, carried-item screen support, or native pixels. + +## Player inventory options + +Crafting-book changes now reach Bedrock player-inventory preferences instead of the furnace-only handler. +The native snapshot retains both tabs, filtering, and independent inventory and workbench layouts. +Menu ownership changes select the corresponding layout for Java's shared crafting book. +Pending close and recovery restore the inventory projection. + +Java can change open and filter states. It cannot express native tab selection or every native layout. +Filter-only changes retain the exact native layout, including recipe-book-only and unknown values. +The other screen's layout and both tabs remain intact. +An unseen snapshot uses native None sentinels for fields that Java cannot supply. + +`InventoryOptionsStorage` replaces the furnace-specific storage and owns all four Java book settings. +Player updates retain the three furnace books, and furnace updates retain the player book. +The existing native furnace channel keeps its wire format and negotiation behavior. + +The [1.26.50-beta.26 schema](https://mojang.github.io/bedrock-protocol-docs/1.26.50-preview.26/packets/set-player-inventory-options-packet/) describes the signed VarInt fields and boolean. +The target 1.26.51.1/protocol 2193 original packet reports ID 307. +Its complete descriptor confirms field order and raw signed values across four samples, including integer extremes. +The capture supplies a typed output sink. It does not execute compressed native wire writers. +Committed tests verify Java wire bytes, packet routing, independent layouts, unknown values, furnace retention, and menu transitions. + +This completes the options transport and Java projection needed by player inventory. +Authored player-inventory presentation, the live player renderer, native tabs/search, controller input, and visible native parity remain unfinished. +The full stack passes 2,798 tests, with 163 optional skips and no failures or errors. +All 1,450 core classes match both packaged copies, and all 1,185 client classes match the add-on. +Eleven options regressions execute the packaged core and reject four deliberately incorrect variants. +The rebuilt add-on passes 35 screen regressions and matches the existing 1,940 native UI cases across 14 corpora. +The bundle contains the exact two client JARs. +A fresh BDS 1.26.51.1 route passes the crafting-book sticks and furnace-book cooking scenarios. +These live checks verify the Java workflow; they do not establish native screen or pixel parity. + +All five goal areas and the four original reports remain open. +These changes target release 0.3.53. 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 b8b7cdd5..948f6197 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 b35a8e52a80c970400c4f65ce906124f49200dda Mon Sep 17 00:00:00 2001 +From 2e9b38e77f0d2e76d29b80e4be170c655057f8c9 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 @@ -24,19 +24,110 @@ its event. Apply the same method range to Java translation so malformed acknowledgments cannot complete a valid active use. Boundary packet tests cover negative values, 16, 17, and signed integer extremes. This does not resolve local prediction or late acknowledgments for a new same-item use. + +Admit food use with the advertised native food component and current +hunger limits before emitting a transaction or StartUsingItem input. Java +sends USE_ITEM before its local food predicate, while BDS accepts those +transactions even after ordinary food reaches full hunger. A target-build +fixture consumes an extra apple at full hunger without this client gate. + +Retain ordered food component updates by connection registry identity. +Preserve can_always_eat and creative use; do not infer food behavior from +the mapped Java item or a fixed whitelist. Leave items without advertised +food data on their existing route. The captured 1.26.51.1 registry contains +this property for component and legacy foods, including apple, honey, +chorus fruit and both golden apple variants. + +Targeted model and real packet controls verify full-hunger rejection before +input flags and inventory writes, changed native hunger maxima, always +edible food, creative use and omitted/replaced registry definitions. +The source format is also documented by Microsoft's minecraft:food item +component reference. Full custom item use-cycle and rejection/prediction +reconciliation remain separate requirements. + +Keep unsupported offhand use on its existing container-resynchronization +path. The new eligibility check applies to the main-hand use transaction. +Both raw hand paths have targeted packet controls; neither changes the +canonical selected stack or queues a rejected use flag. --- - .../protocol/packet/InteractionPackets.java | 40 +++++++- + .../protocol/model/ItemFoodProperties.java | 27 +++++ + .../protocol/model/ItemRegistry.java | 14 +++ + .../protocol/packet/InteractionPackets.java | 51 +++++++++- + .../protocol/rewriter/ItemRewriter.java | 11 +++ + .../model/ItemFoodPropertiesTest.java | 64 ++++++++++++ .../packet/CompletedItemUsePacketTest.java | 98 +++++++++++++++++++ + .../packet/FoodUseAdmissionPacketTest.java | 98 +++++++++++++++++++ .../packet/InteractionPacketsTest.java | 31 ++++++ - 3 files changed, 166 insertions(+), 3 deletions(-) + 8 files changed, 391 insertions(+), 3 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 +diff --git a/src/main/java/net/raphimc/viabedrock/protocol/model/ItemFoodProperties.java b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemFoodProperties.java +new file mode 100644 +index 000000000..c4e63834c +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemFoodProperties.java +@@ -0,0 +1,27 @@ ++/* ++ * 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.model; ++ ++import com.viaversion.nbt.tag.ByteTag; ++import com.viaversion.nbt.tag.CompoundTag; ++ ++/** Advertised native food eligibility, independent of the mapped Java item. */ ++public record ItemFoodProperties(boolean food, boolean canAlwaysEat) { ++ ++ public static final ItemFoodProperties NONE = new ItemFoodProperties(false, false); ++ ++ public ItemFoodProperties withComponentData(final CompoundTag components) { ++ if (!(components.get("minecraft:food") instanceof CompoundTag component)) { ++ return this; ++ } ++ return new ItemFoodProperties(true, component.get("can_always_eat") instanceof ByteTag value && value.asByte() != 0); ++ } ++ ++ public boolean allowsUse(final float hunger, final float maximumHunger, final boolean creative) { ++ return !this.food || this.canAlwaysEat || creative || hunger < maximumHunger; ++ } ++ ++} +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 88f258dbb..33a3cd66c 100644 +--- a/src/main/java/net/raphimc/viabedrock/protocol/model/ItemRegistry.java ++++ b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemRegistry.java +@@ -223,6 +223,20 @@ public final class ItemRegistry { + return "item." + Key.stripMinecraftNamespace(identifier); + } + ++ /** Food component updates retain omitted components and resolve through the current registry identity. */ ++ public Map foodProperties() { ++ final Map properties = new HashMap<>(); ++ for (final ItemEntry entry : this.registrations) { ++ final CompoundTag data = entry.componentData(); ++ if (data != null && data.get("components") instanceof CompoundTag components) { ++ properties.compute(entry.identifier(), (identifier, previous) -> ++ (previous == null ? ItemFoodProperties.NONE : previous).withComponentData(components)); ++ } ++ } ++ properties.keySet().retainAll(this.ids.keySet()); ++ return Map.copyOf(properties); ++ } ++ + public Set componentItems() { + final Map factories = this.factories(); + return this.ids.keySet().stream().filter(identifier -> factories.get(identifier) == ItemVersion.DataDriven) 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 023c7c9e6..44ed87e3a 100644 +index 023c7c9e6..1c86f3205 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/InteractionPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/InteractionPackets.java -@@ -43,6 +43,7 @@ import net.raphimc.viabedrock.protocol.ServerboundBedrockPackets; +@@ -43,10 +43,12 @@ 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.*; @@ -44,7 +135,12 @@ index 023c7c9e6..44ed87e3a 100644 import net.raphimc.viabedrock.protocol.data.enums.java.generated.GameMode; import net.raphimc.viabedrock.protocol.data.enums.java.generated.InteractionHand; import net.raphimc.viabedrock.protocol.data.enums.java.generated.PlayerActionAction; -@@ -55,10 +56,17 @@ import net.raphimc.viabedrock.protocol.storage.InventoryTracker; + import net.raphimc.viabedrock.protocol.model.BedrockItem; ++import net.raphimc.viabedrock.protocol.model.ItemFoodProperties; + import net.raphimc.viabedrock.protocol.model.Position3f; + import net.raphimc.viabedrock.protocol.storage.BlockInteractionSoundTracker; + import net.raphimc.viabedrock.protocol.storage.ChunkTracker; +@@ -55,10 +57,17 @@ import net.raphimc.viabedrock.protocol.storage.InventoryTracker; import net.raphimc.viabedrock.protocol.types.BedrockTypes; import java.util.List; @@ -62,7 +158,7 @@ index 023c7c9e6..44ed87e3a 100644 private InteractionPackets() { } -@@ -225,7 +233,6 @@ public final class InteractionPackets { +@@ -225,7 +234,6 @@ public final class InteractionPackets { wrapper.read(Types.FLOAT); // yaw wrapper.read(Types.FLOAT); // pitch @@ -70,9 +166,15 @@ index 023c7c9e6..44ed87e3a 100644 if (javaHand < 0 || javaHand >= InteractionHand.values().length) { wrapper.cancel(); return; -@@ -247,7 +254,9 @@ public final class InteractionPackets { +@@ -246,8 +254,15 @@ public final class InteractionPackets { + openBook.send(BedrockProtocol.class); return; } ++ if (hand == InteractionHand.MAIN_HAND && !canStartFoodUse(clientPlayer, itemRewriter.foodProperties(usedItem))) { ++ // Java sends USE_ITEM before its local food check. Native clients admit eating before sending. ++ wrapper.cancel(); ++ return; ++ } final BedrockInventoryTransaction transaction = beginItemUse(clientPlayer, hand, - inventoryContainer.getSelectedHotbarSlot(), usedItem, itemIdentifier); + inventoryContainer.getSelectedHotbarSlot(), usedItem, itemIdentifier, @@ -81,7 +183,7 @@ index 023c7c9e6..44ed87e3a 100644 if (transaction == null) { wrapper.cancel(); if (hand == InteractionHand.OFF_HAND && !"minecraft:shield".equals(itemIdentifier)) { -@@ -339,6 +348,24 @@ public final class InteractionPackets { +@@ -339,6 +354,24 @@ public final class InteractionPackets { } } }); @@ -106,10 +208,15 @@ index 023c7c9e6..44ed87e3a 100644 protocol.registerClientbound(ClientboundBedrockPackets.SET_ENTITY_LINK, null, wrapper -> { wrapper.cancel(); final EntityTracker entityTracker = wrapper.user().get(EntityTracker.class); -@@ -348,6 +375,10 @@ public final class InteractionPackets { +@@ -348,6 +381,15 @@ public final class InteractionPackets { } ++ static boolean canStartFoodUse(final ClientPlayerEntity player, final ItemFoodProperties food) { ++ final var hunger = player.attributes().get("minecraft:player.hunger"); ++ return hunger == null || food.allowsUse(hunger.computeClampedValue(), hunger.computeMaxValue(), player.javaGameMode() == GameMode.CREATIVE); ++ } ++ + static boolean isContinuousUseItem(final String identifier, final Set tags) { + return identifier != null && (tags.contains("minecraft:is_food") || CONTINUOUS_USE_ITEMS.contains(identifier)); + } @@ -117,7 +224,7 @@ index 023c7c9e6..44ed87e3a 100644 static void sendPassengerUpdates(final PacketWrapper source, final EntityTracker entityTracker) { for (Entity vehicle : entityTracker.drainPassengerUpdates()) { final PacketWrapper passengersPacket = PacketWrapper.create(ClientboundPackets26_3.SET_PASSENGERS, -@@ -369,7 +400,7 @@ public final class InteractionPackets { +@@ -369,7 +411,7 @@ public final class InteractionPackets { static BedrockInventoryTransaction beginItemUse(final ClientPlayerEntity player, final InteractionHand hand, final int hotbarSlot, final BedrockItem item, @@ -126,7 +233,7 @@ index 023c7c9e6..44ed87e3a 100644 if (item.isEmpty()) { return null; } -@@ -381,6 +412,9 @@ public final class InteractionPackets { +@@ -381,6 +423,9 @@ public final class InteractionPackets { return null; } player.setShieldBlockingHand(null); @@ -136,6 +243,119 @@ index 023c7c9e6..44ed87e3a 100644 return new BedrockInventoryTransaction( 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 9eb3fb9fe..ef32aca46 100644 +--- a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemRewriter.java ++++ b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemRewriter.java +@@ -59,6 +59,7 @@ import net.raphimc.viabedrock.protocol.model.ItemStackingProperties; + import net.raphimc.viabedrock.protocol.model.ItemCookingProperties; + import net.raphimc.viabedrock.protocol.model.ItemCooldown; + import net.raphimc.viabedrock.protocol.model.ItemAnimationData; ++import net.raphimc.viabedrock.protocol.model.ItemFoodProperties; + import net.raphimc.viabedrock.protocol.model.ItemArmorData; + import net.raphimc.viabedrock.protocol.model.ItemUseModifiers; + import net.raphimc.viabedrock.protocol.rewriter.item.BundleItemRewriter; +@@ -85,6 +86,7 @@ import java.util.logging.Level; + public class ItemRewriter extends StoredObject implements NetworkItemStackContext { + + private final ItemRegistry registry; ++ private final Map foodProperties; + + private final Map animationData = new HashMap<>(); + private final Map armorData = new HashMap<>(); +@@ -126,6 +128,14 @@ public class ItemRewriter extends StoredObject implements NetworkItemStackContex + return this.armorData.getOrDefault(identifier, ItemArmorData.vanilla(identifier)); + } + ++ public ItemFoodProperties foodProperties(final BedrockItem item) { ++ if (item == null || item.isEmpty()) { ++ return ItemFoodProperties.NONE; ++ } ++ final String identifier = this.items.inverse().get(item.identifier()); ++ return identifier == null ? ItemFoodProperties.NONE : this.foodProperties.getOrDefault(identifier, ItemFoodProperties.NONE); ++ } ++ + public ItemUseModifiers useModifiers(final BedrockItem item) { + final String identifier = this.items.inverse().get(item.identifier()); + if ("minecraft:shield".equals(identifier)) { +@@ -144,6 +154,7 @@ public class ItemRewriter extends StoredObject implements NetworkItemStackContex + super(user); + + this.registry = new ItemRegistry(itemEntries); ++ this.foodProperties = this.registry.foodProperties(); + this.items = this.registry.ids(); + this.componentItems = this.registry.componentItems(); + this.stackingProperties = this.registry.stackingProperties(); +diff --git a/src/test/java/net/raphimc/viabedrock/protocol/model/ItemFoodPropertiesTest.java b/src/test/java/net/raphimc/viabedrock/protocol/model/ItemFoodPropertiesTest.java +new file mode 100644 +index 000000000..cb86b0a5c +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/protocol/model/ItemFoodPropertiesTest.java +@@ -0,0 +1,64 @@ ++/* ++ * 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.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.*; ++ ++class ItemFoodPropertiesTest { ++ ++ @Test ++ void preservesOmittedComponentsAndAppliesCurrentDefinitionUpdates() { ++ final CompoundTag components = new CompoundTag(); ++ final CompoundTag food = new CompoundTag(); ++ components.put("minecraft:food", food); ++ final var ordinary = ItemFoodProperties.NONE.withComponentData(components); ++ assertTrue(ordinary.food()); ++ assertFalse(ordinary.canAlwaysEat()); ++ assertSame(ordinary, ordinary.withComponentData(new CompoundTag())); ++ food.putBoolean("can_always_eat", true); ++ final var always = ordinary.withComponentData(components); ++ assertTrue(always.canAlwaysEat()); ++ food.remove("can_always_eat"); ++ assertFalse(always.withComponentData(components).canAlwaysEat()); ++ } ++ ++ @Test ++ void comparesNativeHungerLimitsWithoutRestrictingCreativeOrAlwaysEdibleFood() { ++ final var food = new ItemFoodProperties(true, false); ++ assertFalse(food.allowsUse(20F, 20F, false)); ++ assertTrue(food.allowsUse(Math.nextDown(20F), 20F, false)); ++ assertFalse(food.allowsUse(12F, 12F, false)); ++ assertTrue(food.allowsUse(20F, 20F, true)); ++ assertTrue(new ItemFoodProperties(true, true).allowsUse(20F, 20F, false)); ++ assertTrue(ItemFoodProperties.NONE.allowsUse(20F, 20F, false)); ++ } ++ ++ @Test ++ void retainsFoodAcrossOmittedUpdatesAndDropsDefinitionsThatLoseTheirRegistryIdentity() { ++ final CompoundTag components = new CompoundTag(); ++ final CompoundTag food = new CompoundTag(); ++ food.putBoolean("can_always_eat", true); ++ components.put("minecraft:food", food); ++ final CompoundTag data = new CompoundTag(); ++ data.put("components", components); ++ final var registry = new ItemRegistry(new ItemEntry[]{ ++ new ItemEntry("test:first", 1, true, ItemVersion.DataDriven, data), ++ new ItemEntry("test:first", 2, true, ItemVersion.None, new CompoundTag()), ++ new ItemEntry("test:second", 1, true, ItemVersion.DataDriven, null) ++ }); ++ assertEquals(new ItemFoodProperties(true, true), registry.foodProperties().get("test:first")); ++ final var replaced = new ItemRegistry(new ItemEntry[]{ ++ new ItemEntry("test:first", 1, true, ItemVersion.DataDriven, data), ++ new ItemEntry("test:second", 1, true, ItemVersion.DataDriven, null) ++ }); ++ assertFalse(replaced.foodProperties().containsKey("test:first")); ++ } ++ ++} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/packet/CompletedItemUsePacketTest.java b/src/test/java/net/raphimc/viabedrock/protocol/packet/CompletedItemUsePacketTest.java new file mode 100644 index 000000000..45f4a6a31 @@ -240,6 +460,110 @@ index 000000000..45f4a6a31 + } + +} +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 000000000..2a897a27c +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/protocol/packet/FoodUseAdmissionPacketTest.java +@@ -0,0 +1,98 @@ ++/* ++ * 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.google.common.collect.ImmutableBiMap; ++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.ServerboundPackets26_3; ++import net.raphimc.viabedrock.api.model.entity.ClientPlayerEntity; ++import net.raphimc.viabedrock.protocol.BedrockProtocol; ++import net.raphimc.viabedrock.api.util.PacketFactory; ++import net.raphimc.viabedrock.protocol.data.enums.java.generated.InteractionHand; ++import net.raphimc.viabedrock.protocol.model.BedrockItem; ++import net.raphimc.viabedrock.protocol.model.ItemFoodProperties; ++import net.raphimc.viabedrock.protocol.model.PlayerAbilities; ++import net.raphimc.viabedrock.protocol.rewriter.ItemRewriter; ++import net.raphimc.viabedrock.protocol.storage.EntityTracker; ++import net.raphimc.viabedrock.protocol.storage.InventoryRequestTracker; ++import net.raphimc.viabedrock.protocol.storage.InventoryTracker; ++import io.netty.buffer.Unpooled; ++import org.junit.jupiter.params.ParameterizedTest; ++import org.junit.jupiter.params.provider.EnumSource; ++ ++import java.lang.reflect.Proxy; ++import java.util.HashMap; ++import java.util.Map; ++import java.util.UUID; ++ ++import static org.junit.jupiter.api.Assertions.*; ++import static org.mockito.Mockito.*; ++ ++class FoodUseAdmissionPacketTest { ++ ++ @ParameterizedTest ++ @EnumSource(InteractionHand.class) ++ void rejectsFullHungerUseWithoutLosingOffhandResynchronization(final InteractionHand hand) throws Exception { ++ 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); ++ final EntityTracker entities = new EntityTracker(user); ++ stores.put(EntityTracker.class, entities); ++ final var player = entities.addEntity(new ClientPlayerEntity(user, 1, UUID.randomUUID(), new PlayerAbilities(1, (byte) 0, (byte) 0)), false); ++ 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 held = new BedrockItem(1); ++ held.setAmount(8); ++ inventory.getInventoryContainer().setItem(0, held); ++ inventory.getOffhandContainer().setItem(0, held); ++ final var items = mock(ItemRewriter.class); ++ when(items.getItems()).thenReturn(hand == InteractionHand.MAIN_HAND ? ImmutableBiMap.of("test:food", 1) : ImmutableBiMap.of()); ++ when(items.foodProperties(held)).thenReturn(new ItemFoodProperties(true, false)); ++ stores.put(ItemRewriter.class, items); ++ final var input = Unpooled.buffer(); ++ try (var packets = mockStatic(PacketFactory.class)) { ++ Types.VAR_INT.writePrimitive(input, hand.ordinal()); ++ Types.VAR_INT.writePrimitive(input, 1); ++ input.writeFloat(0F).writeFloat(0F); ++ final var packet = new PacketWrapperImpl(ServerboundPackets26_3.USE_ITEM, input, user); ++ new TestProtocol().translate(packet); ++ assertTrue(packet.isCancelled()); ++ assertFalse(input.isReadable()); ++ assertTrue(player.authInputData().isEmpty()); ++ assertEquals(8, held.amount()); ++ if (hand == InteractionHand.OFF_HAND) { ++ packets.verify(() -> PacketFactory.sendJavaContainerSetContent(user, inventory.getInventoryContainer())); ++ } else { ++ packets.verifyNoInteractions(); ++ } ++ } 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.serverboundMappings.mappedPacket(State.PLAY, packet.getId()); ++ mapping.applyType(packet); ++ mapping.handler().handle(packet); ++ } ++ } ++ ++} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/packet/InteractionPacketsTest.java b/src/test/java/net/raphimc/viabedrock/protocol/packet/InteractionPacketsTest.java new file mode 100644 index 000000000..0bf1b7059 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 new file mode 100644 index 00000000..9b52ca0c --- /dev/null +++ b/patches/viabedrock/deferred/0008-signal-sustained-bedrock-item-use.pr.md @@ -0,0 +1,87 @@ +# Sustained item use and food admission + +Java sends an air-use packet before its local food eligibility check. +Bedrock Dedicated Server accepts that transaction even at full hunger. +Holding ordinary food therefore consumes an extra item after the Java client stops its eating animation. + +This patch checks advertised native food properties before sending the main-hand transaction or setting `StartUsingItem`. +It uses the current native hunger value and maximum. +Food with `can_always_eat` and creative use retain their existing admission. +Items without advertised food data retain their existing route. +Unsupported offhand use retains its container resynchronization. + +## Target evidence + +The target is Bedrock 1.26.51.1, protocol 2193, with Java 26.3. +Its recorded `ITEM_REGISTRY` supplies `minecraft:food.can_always_eat` for component and legacy items. +The inspected definitions include apple, rotten flesh, honey, chorus fruit, and both golden apple variants. +The implementation retains ordered component updates and resolves them through the current registry identity. +It does not infer native food behavior from a Java item or a fixed whitelist. + +Microsoft documents the property in the [food component reference](https://learn.microsoft.com/en-us/minecraft/creator/reference/content/itemreference/examples/itemcomponents/minecraft_food?view=minecraft-bedrock-stable). +The target capture establishes that this property exists in the pinned version. +This admission change uses captured metadata and server behavior, without claiming an original-instruction oracle for the complete eligibility predicate. + +The existing completion handling decodes the signed item ID and use method from `COMPLETED_USING_ITEM`. +It ignores replaced held items and methods outside the native range of zero through sixteen. +The owning patch records the original handler trace and packet controls. + +## Regression controls + +Model tests cover omitted and replaced component definitions, registry identity replacement, native hunger maxima, creative use, and always-edible food. +Actual packet mappings cover both raw hand paths. +A rejected main-hand request changes neither the canonical inventory nor the queued use flag. +Offhand requests retain the existing container resynchronization. +Existing completion controls still cover signed IDs, method boundaries, and replaced items. + +A private BDS fixture starts with hunger four and sixteen apples. +The original candidate consumes five apples in both grounded and delayed-correction phases. +Each phase includes a server use start at hunger twenty. +The fixed candidate consumes four apples and stops at hunger twenty, leaving twelve apples. +Both ordinary-food phases record four starts, four completions, and no start at full hunger. + +A separate full-hunger golden-apple phase starts with eight items and completes four uses. +All five starts occur at full hunger; releasing the key cancels the final incomplete use. +The server and client settle at hunger twenty with four golden apples. + +The direct run executes five historical replays with fifty-four physical steps. +Three replays occur during active eating and preserve use timers of twelve, twenty-eight, and eighteen ticks. +Every replay boundary preserves tick count, hunger, held count, and active-use state. +The fixture samples a removed player after disconnect; those later errors are excluded from active-session evidence. +The read-only client observers report no errors. + +The same three-phase fixture also passes through ViaProxy. +Its ordinary-food phases each stop after four completions, with no start at full hunger. +The full-hunger golden-apple phase completes four uses and cancels the fifth on release. +The proxy and client artifacts match their full-build checksums. + +The proxy run executes six historical replays with fifty-five physical steps. +Three active-use replays preserve timers of twenty-three, seventeen, and ten ticks. +Every replay boundary preserves tick count, hunger, held count, and active-use state. +Its private inbound control delivers 101,773 events in order, with no pending events at shutdown. +Observed delays range from 300.003 to 304.419 milliseconds. +The first, shorter proxy capture stops during the final hold and remains excluded from these results. + +The tested food candidate passes 2,520 tests across all four projects, with 215 optional skips and no failures or errors. +All 1,411 core classes match both packaged copies. +Offline tooling, static, and patch metadata checks pass. + +The combined build also preserves the newer explicit-binding renderer update. +It passes 2,523 full-build tests, with 216 optional skips and no failures or errors. +All 1,411 core classes match both packaged copies. +A focused run passes 8 native binding, native slot, and Iris GPU controls without skips. +The core source tree and proxy artifact match the live-tested candidate. +All 119 compared movement/input entries and mixin configurations also match. +Only two indexed production client classes change, both in the renderer. +This comparison does not establish fresh whole-package runtime or visible rendering parity. + +## Boundaries + +These controls establish the observed ordinary-food overconsumption fix and the stated replay counter preservation. +They do not establish all native custom-food constructors, ability exceptions, use cycles, or trajectories. +Late completion for a new use of the same item still needs verification. +Prediction, rejection, and reconciliation across every negotiated inventory authority mode remain unfinished. +The full five-area production goal remains active. + +This patch remains deferred behind [ViaBedrock #276](https://github.com/ViaVersionAddons/ViaBedrock/pull/276). +It does not belong in an independent upstream PR branch. 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 d2646faf..6b8a1f90 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 4656bc5388ec7b828a72b1a1cb2e0d5b33a655c8 Mon Sep 17 00:00:00 2001 +From dc5bd4b9362ba5cc60c93f4d572481cf7676c6bd 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 @@ -187,10 +187,10 @@ index 000000000..fcbaa4e46 + +} 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 9fc66a838..729b1bfd0 100644 +index a1a76a4d9..4cec1d2cb 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/InventoryPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/InventoryPackets.java -@@ -91,14 +91,19 @@ import net.raphimc.viabedrock.protocol.data.enums.bedrock.ComplexInventoryTransa +@@ -92,14 +92,19 @@ 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; @@ -210,7 +210,7 @@ index 9fc66a838..729b1bfd0 100644 import net.raphimc.viabedrock.protocol.model.recipe.EnchantData; import net.raphimc.viabedrock.protocol.rewriter.BlockStateRewriter; import net.raphimc.viabedrock.protocol.rewriter.ItemRewriter; -@@ -113,6 +118,7 @@ import net.raphimc.viabedrock.protocol.types.recipe.CraftingDataReader; +@@ -114,6 +119,7 @@ import net.raphimc.viabedrock.protocol.types.recipe.CraftingDataReader; import java.util.ArrayList; import java.util.List; @@ -218,7 +218,7 @@ index 9fc66a838..729b1bfd0 100644 import java.util.logging.Level; public final class InventoryPackets { -@@ -321,22 +327,33 @@ public final class InventoryPackets { +@@ -342,22 +348,33 @@ public final class InventoryPackets { wrapper.cancel(); final ItemRewriter itemRewriter = wrapper.user().get(ItemRewriter.class); final int groups = wrapper.read(BedrockTypes.UNSIGNED_VAR_INT); @@ -259,7 +259,7 @@ index 9fc66a838..729b1bfd0 100644 }); protocol.registerClientbound( ClientboundBedrockPackets.CONTAINER_OPEN, -@@ -1100,41 +1117,7 @@ public final class InventoryPackets { +@@ -1133,41 +1150,7 @@ public final class InventoryPackets { protocol.registerServerbound( ServerboundPackets26_3.SET_CREATIVE_MODE_SLOT, null, @@ -302,7 +302,7 @@ index 9fc66a838..729b1bfd0 100644 protocol.registerServerbound( ServerboundPackets26_3.CUSTOM_CLICK_ACTION, ServerboundBedrockPackets.MODAL_FORM_RESPONSE, -@@ -1241,7 +1224,7 @@ public final class InventoryPackets { +@@ -1279,7 +1262,7 @@ public final class InventoryPackets { if (container == null) { if (containerId == ContainerID.CONTAINER_ID_INVENTORY.getValue()) { wrapper.user().get(InventoryRequestTracker.class) @@ -311,7 +311,7 @@ index 9fc66a838..729b1bfd0 100644 } wrapper.cancel(); return; -@@ -1807,31 +1790,116 @@ public final class InventoryPackets { +@@ -1851,31 +1834,116 @@ public final class InventoryPackets { return "container." + name; } @@ -451,18 +451,18 @@ index 9fc66a838..729b1bfd0 100644 public static boolean handleLegacyOffhandClick(final UserConnection user, final Container container, 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 87e674a07..f1d71e43c 100644 +index ef32aca46..a9540e7a7 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemRewriter.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemRewriter.java -@@ -48,6 +48,7 @@ import net.raphimc.viabedrock.protocol.data.BedrockMappingData; +@@ -50,6 +50,7 @@ import net.raphimc.viabedrock.protocol.data.BedrockMappingData; import net.raphimc.viabedrock.protocol.data.ProtocolConstants; import net.raphimc.viabedrock.protocol.data.generated.bedrock.CustomItemTags; import net.raphimc.viabedrock.protocol.model.BedrockItem; +import net.raphimc.viabedrock.protocol.model.CreativeCatalog; import net.raphimc.viabedrock.protocol.model.ItemEntry; import net.raphimc.viabedrock.protocol.model.ItemRegistry; - import net.raphimc.viabedrock.protocol.model.ItemStackHeader; -@@ -73,8 +74,8 @@ import net.raphimc.viabedrock.protocol.types.item.NetworkItemStackContext; + import net.raphimc.viabedrock.protocol.model.ItemNameContext; +@@ -78,8 +79,8 @@ import net.raphimc.viabedrock.protocol.types.item.NetworkItemStackContext; import java.util.HashMap; import java.util.HashSet; @@ -472,7 +472,7 @@ index 87e674a07..f1d71e43c 100644 import java.util.Set; import java.util.logging.Level; -@@ -106,7 +107,7 @@ public class ItemRewriter extends StoredObject implements NetworkItemStackContex +@@ -113,7 +114,7 @@ public class ItemRewriter extends StoredObject implements NetworkItemStackContex private final Type optionalNewItemType; private final Type newItemArrayType; private final Type stackRequestResultType; @@ -481,7 +481,7 @@ index 87e674a07..f1d71e43c 100644 static { // TODO: Add missing item nbt rewriters -@@ -465,29 +466,13 @@ public class ItemRewriter extends StoredObject implements NetworkItemStackContex +@@ -450,29 +451,13 @@ public class ItemRewriter extends StoredObject implements NetworkItemStackContex "Translating Java items to Bedrock is not yet supported"); } @@ -517,7 +517,7 @@ index 87e674a07..f1d71e43c 100644 public BedrockItem[] bedrockItems(final Item[] javaItems) { 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 c695b43ce..cda506982 100644 +index d08578907..8544e1ab3 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; @@ -528,7 +528,7 @@ index c695b43ce..cda506982 100644 import com.viaversion.viaversion.api.protocol.packet.PacketWrapper; import com.viaversion.viaversion.api.type.Types; import com.viaversion.nbt.tag.IntTag; -@@ -278,6 +279,10 @@ public class InventoryRequestTracker extends StoredObject { +@@ -351,6 +352,10 @@ public class InventoryRequestTracker extends StoredObject { return this.queueInteraction(new QueuedButtonClick(container, button)); } @@ -539,7 +539,7 @@ index c695b43ce..cda506982 100644 boolean queueInteraction(final QueuedInteraction interaction) { if (this.recovery.isActive() || this.queuedInteractions.size() >= MAX_QUEUED_INTERACTIONS) { this.queuedInteractions.clear(); -@@ -763,6 +768,25 @@ public class InventoryRequestTracker extends StoredObject { +@@ -927,6 +932,25 @@ public class InventoryRequestTracker extends StoredObject { boolean apply(); } @@ -681,7 +681,7 @@ index 000000000..dfb8c95ff + +} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/packet/InventoryPacketsTest.java b/src/test/java/net/raphimc/viabedrock/protocol/packet/InventoryPacketsTest.java -index 6cf158244..8281f2e79 100644 +index a17e52fd4..d43f62d99 100644 --- a/src/test/java/net/raphimc/viabedrock/protocol/packet/InventoryPacketsTest.java +++ b/src/test/java/net/raphimc/viabedrock/protocol/packet/InventoryPacketsTest.java @@ -17,15 +17,25 @@ package net.raphimc.viabedrock.protocol.packet; 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 ed244e88..cc0f2dff 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 4ecf2d26e5794e25d2e4cfed8697d101a978c2d7 Mon Sep 17 00:00:00 2001 +From e4faf8acffd27a775a413080f43fbc08b044346e 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 @@ -10,16 +10,16 @@ Route creative breaks through Bedrock player actions while keeping survival comp .../api/model/entity/ClientPlayerEntity.java | 30 +++ .../inventory/ItemStackRequestAction.java | 10 +- .../protocol/packet/ClientPlayerPackets.java | 172 ++++++++++++++++-- - .../storage/InventoryRequestTracker.java | 81 +++++++++ + .../storage/InventoryRequestTracker.java | 80 ++++++++ .../types/inventory/ItemStackActionType.java | 4 + .../packet/ClientPlayerPacketsTest.java | 60 ++++++ .../InventoryRequestTrackerMiningTest.java | 55 ++++++ .../types/inventory/ItemStackCodecTest.java | 10 + - 8 files changed, 401 insertions(+), 21 deletions(-) + 8 files changed, 400 insertions(+), 21 deletions(-) create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/storage/InventoryRequestTrackerMiningTest.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 -index 7da0a19ff..cf1ed21e3 100644 +index dcae62809..9a31422f6 100644 --- a/src/main/java/net/raphimc/viabedrock/api/model/entity/ClientPlayerEntity.java +++ b/src/main/java/net/raphimc/viabedrock/api/model/entity/ClientPlayerEntity.java @@ -46,6 +46,8 @@ import net.raphimc.viabedrock.api.model.FlightMovementContext; @@ -40,7 +40,7 @@ index 7da0a19ff..cf1ed21e3 100644 private Set inputFlags = EnumSet.noneOf(InputFlag.class); private Set prevInputFlags = EnumSet.noneOf(InputFlag.class); private boolean horizontalCollision; -@@ -646,6 +650,32 @@ public class ClientPlayerEntity extends PlayerEntity { +@@ -651,6 +655,32 @@ public class ClientPlayerEntity extends PlayerEntity { this.blockBreakingInfo = blockBreakingInfo; } @@ -102,7 +102,7 @@ index 7b9310f5a..45cf7a958 100644 implements ItemStackRequestAction { @Override diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/ClientPlayerPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/ClientPlayerPackets.java -index 6f3ca3945..70f601cf1 100644 +index e95834c6a..9448cb517 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/ClientPlayerPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/ClientPlayerPackets.java @@ -18,6 +18,9 @@ @@ -115,7 +115,7 @@ index 6f3ca3945..70f601cf1 100644 import com.viaversion.viaversion.api.minecraft.BlockPosition; import com.viaversion.viaversion.api.minecraft.Vector3d; import com.viaversion.viaversion.api.minecraft.entities.EntityTypes26_3; -@@ -65,11 +68,14 @@ import net.raphimc.viabedrock.protocol.model.inventory.BedrockInventoryTransacti +@@ -66,11 +69,14 @@ import net.raphimc.viabedrock.protocol.model.inventory.BedrockInventoryTransacti import net.raphimc.viabedrock.protocol.model.inventory.InventoryActionData; import net.raphimc.viabedrock.protocol.model.inventory.InventorySource; import net.raphimc.viabedrock.protocol.model.inventory.InventoryTransactionData; @@ -130,7 +130,7 @@ index 6f3ca3945..70f601cf1 100644 import java.util.Locale; import java.util.List; -@@ -151,6 +157,96 @@ public final class ClientPlayerPackets { +@@ -152,6 +158,96 @@ public final class ClientPlayerPackets { wrapper.write(BedrockTypes.OPTIONAL_POSITION_3F, null); } @@ -227,7 +227,7 @@ index 6f3ca3945..70f601cf1 100644 public static void register(final BedrockProtocol protocol) { protocol.registerClientbound(ClientboundBedrockPackets.RESPAWN, ClientboundPackets26_3.RESPAWN, wrapper -> { final Position3f position = wrapper.read(BedrockTypes.POSITION_3F); // position -@@ -421,45 +517,59 @@ public final class ClientPlayerPackets { +@@ -458,45 +554,59 @@ public final class ClientPlayerPackets { final boolean isMining = action == PlayerActionAction.START_DESTROY_BLOCK || action == PlayerActionAction.ABORT_DESTROY_BLOCK || action == PlayerActionAction.STOP_DESTROY_BLOCK || action == PlayerActionAction.CHANGE_DESTROY_DIRECTION; if (isMining && (gameSession.isImmutableWorld() || !clientPlayer.abilities().getBooleanValue(AbilitiesIndex.Mine))) { @@ -297,7 +297,7 @@ index 6f3ca3945..70f601cf1 100644 } chunkTracker.handleBlockChange(position, 0, chunkTracker.bedrockAirId()); -@@ -651,6 +761,8 @@ public final class ClientPlayerPackets { +@@ -689,6 +799,8 @@ public final class ClientPlayerPackets { if (!clientPlayer.isInitiallySpawned() || clientPlayer.isDead()) { clientPlayer.setShieldBlockingHand(null); @@ -306,8 +306,8 @@ index 6f3ca3945..70f601cf1 100644 wrapper.cancel(); return; } -@@ -778,8 +890,23 @@ public final class ClientPlayerPackets { - predictionStorage.remember(clientPlayer.age(), prediction); +@@ -816,8 +928,23 @@ public final class ClientPlayerPackets { + predictionStorage.remember(clientPlayer.age(), predictions.clockFrame()); wrapper.write(BedrockTypes.UNSIGNED_VAR_LONG, (long) clientPlayer.age()); // tick wrapper.write(BedrockTypes.POSITION_3F, velocity); // delta - wrapper.write(Types.BOOLEAN, false); // no item interaction @@ -332,7 +332,7 @@ index 6f3ca3945..70f601cf1 100644 final boolean hasBlockActions = clientPlayer.authInputData().contains(PlayerAuthInputData.PerformBlockActions); wrapper.write(Types.BOOLEAN, hasBlockActions); if (hasBlockActions) { -@@ -806,6 +933,8 @@ public final class ClientPlayerPackets { +@@ -844,6 +971,8 @@ public final class ClientPlayerPackets { clientPlayer.authInputData().clear(); clientPlayer.authInputBlockActions().clear(); @@ -341,7 +341,7 @@ index 6f3ca3945..70f601cf1 100644 }); protocol.registerServerbound(ServerboundPackets26_3.PLAYER_ABILITIES, null, wrapper -> { wrapper.cancel(); -@@ -844,19 +973,22 @@ public final class ClientPlayerPackets { +@@ -882,19 +1011,22 @@ public final class ClientPlayerPackets { return; } @@ -373,7 +373,7 @@ index 6f3ca3945..70f601cf1 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 2723e75ce..8ec946ec8 100644 +index 0f3889de3..9c82aaf3e 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; @@ -384,23 +384,15 @@ index 2723e75ce..8ec946ec8 100644 import com.viaversion.viaversion.libs.fastutil.ints.Int2ObjectOpenHashMap; import net.raphimc.viabedrock.ViaBedrock; -@@ -35,6 +36,7 @@ import net.raphimc.viabedrock.protocol.model.inventory.BedrockInventoryTransacti - import net.raphimc.viabedrock.protocol.model.inventory.InventoryTransactionData; - import net.raphimc.viabedrock.protocol.rewriter.InventoryTransactionRewriter; - import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.InteractPacketPayload_Action; -+import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ContainerEnumName; - 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; -@@ -68,6 +70,7 @@ public class InventoryRequestTracker extends StoredObject { - private boolean closeTemporaryInventory; - private boolean predictingClick; +@@ -75,6 +76,7 @@ public class InventoryRequestTracker extends StoredObject { private CraftedItemSynchronization craftedItemSynchronization; + private TransferBatch transferBatch; + private Set batchChanges; + private MineBlockRequest mineBlockRequest; public InventoryRequestTracker(final UserConnection user) { super(user); -@@ -107,6 +110,19 @@ public class InventoryRequestTracker extends StoredObject { +@@ -174,6 +176,19 @@ public class InventoryRequestTracker extends StoredObject { this.predictingClick = predictingClick; } @@ -420,7 +412,7 @@ index 2723e75ce..8ec946ec8 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)); -@@ -224,6 +240,11 @@ public class InventoryRequestTracker extends StoredObject { +@@ -301,6 +316,11 @@ public class InventoryRequestTracker extends StoredObject { PacketFactory.sendJavaContainerSetContent(this.user(), this.user().get(InventoryTracker.class).getInventoryContainer()); } @@ -432,7 +424,7 @@ index 2723e75ce..8ec946ec8 100644 if (!this.hasPending()) { return; } -@@ -299,6 +320,13 @@ public class InventoryRequestTracker extends StoredObject { +@@ -380,6 +400,13 @@ public class InventoryRequestTracker extends StoredObject { } public void handleResponse(final ItemStackResponseInfo response) { @@ -446,7 +438,7 @@ index 2723e75ce..8ec946ec8 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. -@@ -459,6 +487,56 @@ public class InventoryRequestTracker extends StoredObject { +@@ -603,6 +630,56 @@ public class InventoryRequestTracker extends StoredObject { return item; } @@ -503,7 +495,7 @@ index 2723e75ce..8ec946ec8 100644 void drainQueuedInteractions() { final InventoryTracker tracker = this.user().get(InventoryTracker.class); while (!this.hasPending() && tracker.getPendingCloseContainer() == null -@@ -475,6 +553,9 @@ public class InventoryRequestTracker extends StoredObject { +@@ -619,6 +696,9 @@ public class InventoryRequestTracker extends StoredObject { } } 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 a8ee7a8a..542f4842 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 ad154240b03d4fd4e2c7ee83adf118f0aea7c04a Mon Sep 17 00:00:00 2001 +From 00b4acdf6d97ab3931f7d6ab1f031b5587e07be7 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 @@ -751,7 +751,7 @@ index 000000000..ae295bc3d + +} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/BedrockProtocol.java b/src/main/java/net/raphimc/viabedrock/protocol/BedrockProtocol.java -index 860aa0e7e..06ee90fd2 100644 +index 817fe139b..c44cffb4e 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/BedrockProtocol.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/BedrockProtocol.java @@ -122,6 +122,7 @@ public class BedrockProtocol @@ -878,10 +878,10 @@ index 000000000..ea4d95614 + +} 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 729b1bfd0..4992227d3 100644 +index 4cec1d2cb..37ced8c75 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/InventoryPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/InventoryPackets.java -@@ -78,6 +78,7 @@ import net.raphimc.viabedrock.api.model.container.ChestContainer; +@@ -79,6 +79,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; @@ -889,7 +889,7 @@ index 729b1bfd0..4992227d3 100644 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; -@@ -393,6 +394,11 @@ public final class InventoryPackets { +@@ -414,6 +415,11 @@ public final class InventoryPackets { return; } @@ -901,7 +901,7 @@ index 729b1bfd0..4992227d3 100644 if (inventoryTracker.isAnyScreenOpen()) { ViaBedrock.getPlatform() .getLogger() -@@ -1239,6 +1245,11 @@ public final class InventoryPackets { +@@ -1277,6 +1283,11 @@ public final class InventoryPackets { inventoryTracker.setCurrentContainerClosed(false); return; } @@ -1094,7 +1094,7 @@ index 000000000..3b7f3d813 + +} 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 cda506982..ba46182c8 100644 +index 8544e1ab3..829f807a6 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; @@ -1106,7 +1106,7 @@ index cda506982..ba46182c8 100644 import net.raphimc.viabedrock.api.model.container.player.InventoryContainer; import net.raphimc.viabedrock.api.util.PacketFactory; import net.raphimc.viabedrock.protocol.BedrockProtocol; -@@ -45,6 +47,7 @@ import net.raphimc.viabedrock.protocol.model.inventory.ItemStackResponseContaine +@@ -47,6 +49,7 @@ import net.raphimc.viabedrock.protocol.model.inventory.ItemStackResponseContaine import net.raphimc.viabedrock.protocol.model.inventory.ItemStackResponseInfo; import net.raphimc.viabedrock.protocol.model.inventory.ItemStackResponseSlotInfo; import net.raphimc.viabedrock.protocol.packet.InventoryPackets; @@ -1114,7 +1114,7 @@ index cda506982..ba46182c8 100644 import net.raphimc.viabedrock.protocol.types.BedrockTypes; import java.util.ArrayDeque; -@@ -275,6 +278,14 @@ public class InventoryRequestTracker extends StoredObject { +@@ -348,6 +351,14 @@ public class InventoryRequestTracker extends StoredObject { }); } @@ -1129,7 +1129,7 @@ index cda506982..ba46182c8 100644 public boolean queueButtonClick(final Container container, final int button) { return this.queueInteraction(new QueuedButtonClick(container, button)); } -@@ -474,6 +485,16 @@ public class InventoryRequestTracker extends StoredObject { +@@ -566,6 +577,16 @@ public class InventoryRequestTracker extends StoredObject { } } @@ -1143,10 +1143,10 @@ index cda506982..ba46182c8 100644 + changed.add(temporary); + } + } - tracker.getInventoryContainer().updateCraftingOutput(); - if (response.result() == ItemStackNetResult.Success) { - this.drainQueuedInteractions(); -@@ -798,6 +819,22 @@ public class InventoryRequestTracker extends StoredObject { + if (this.batchChanges != null) { + this.batchChanges.addAll(changed); + return; +@@ -962,6 +983,22 @@ public class InventoryRequestTracker extends StoredObject { } } @@ -1170,7 +1170,7 @@ index cda506982..ba46182c8 100644 @Override diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryTracker.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryTracker.java -index d439b6b5d..891b27e1d 100644 +index 6d498eac1..e0568afa5 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryTracker.java @@ -28,6 +28,8 @@ import net.raphimc.viabedrock.ViaBedrock; @@ -1182,9 +1182,9 @@ index d439b6b5d..891b27e1d 100644 import net.raphimc.viabedrock.api.model.container.dynamic.BundleContainer; import net.raphimc.viabedrock.api.model.container.player.ArmorContainer; import net.raphimc.viabedrock.api.model.container.player.HudContainer; -@@ -286,6 +288,18 @@ public class InventoryTracker extends StoredObject { - - public void tick() { +@@ -305,6 +307,18 @@ public class InventoryTracker extends StoredObject { + screens.tick(System.nanoTime()); + } this.user().get(InventoryRequestTracker.class).tick(); + if (this.currentContainer instanceof InputContainer temporary && temporary.isClosing()) { + return; @@ -1201,7 +1201,7 @@ index d439b6b5d..891b27e1d 100644 if (this.currentContainer != null && this.currentContainer.position() != null) { if (this.currentContainer.type() == ContainerType.INVENTORY) { return; -@@ -439,6 +453,11 @@ public class InventoryTracker extends StoredObject { +@@ -467,6 +481,11 @@ public class InventoryTracker extends StoredObject { } private void forceCloseCurrentContainer() { @@ -1211,8 +1211,8 @@ index d439b6b5d..891b27e1d 100644 + return; + } this.markPendingClose(this.currentContainer); - PacketFactory.sendJavaContainerClose( - this.user(), this.pendingCloseContainer.javaContainerId()); + final var screens = this.user().get(PlayerInventoryScreenTracker.class); + if (!(this.pendingCloseContainer instanceof InventoryContainer) || screens == null || !screens.ownsClose()) { diff --git a/src/test/java/net/raphimc/viabedrock/api/model/container/InventoryPlacementTest.java b/src/test/java/net/raphimc/viabedrock/api/model/container/InventoryPlacementTest.java new file mode 100644 index 000000000..342048a2f 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 abc516d2..4aca27fa 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 4d5e55625f2d1539bc53ecd5322da0cefad1f935 Mon Sep 17 00:00:00 2001 +From 45953f483e0d91dc7ec1e731ab88994b07660a58 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 @@ -368,10 +368,10 @@ index a3b933c2a..6ef8f93d7 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 4992227d3..8eda040b1 100644 +index 37ced8c75..74c9248b1 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/InventoryPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/InventoryPackets.java -@@ -494,6 +494,7 @@ public final class InventoryPackets { +@@ -518,6 +518,7 @@ public final class InventoryPackets { container = new SmithingContainer( wrapper.user(), containerId, title, containerPosition); 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 ed66f921..b61bc147 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 6b7e3fe658b1da8750bd57cdabf8ae6989243cb9 Mon Sep 17 00:00:00 2001 +From 131f827d480f30cb99d005f4475ddea11b954942 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(movement): synchronize glide rockets with auth input @@ -38,7 +38,7 @@ counter isolation, descriptor copies, and the final-rocket debit. 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 -index cf1ed21e3..8604b9618 100644 +index 9a31422f6..28408501a 100644 --- a/src/main/java/net/raphimc/viabedrock/api/model/entity/ClientPlayerEntity.java +++ b/src/main/java/net/raphimc/viabedrock/api/model/entity/ClientPlayerEntity.java @@ -87,6 +87,7 @@ public class ClientPlayerEntity extends PlayerEntity { @@ -49,7 +49,7 @@ index cf1ed21e3..8604b9618 100644 private Set inputFlags = EnumSet.noneOf(InputFlag.class); private Set prevInputFlags = EnumSet.noneOf(InputFlag.class); private boolean horizontalCollision; -@@ -667,6 +668,10 @@ public class ClientPlayerEntity extends PlayerEntity { +@@ -672,6 +673,10 @@ public class ClientPlayerEntity extends PlayerEntity { return this.authInputItemInteraction; } @@ -61,10 +61,10 @@ index cf1ed21e3..8604b9618 100644 this.authInputItemInteraction = transaction; if (transaction != null) { diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/ClientPlayerPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/ClientPlayerPackets.java -index 70f601cf1..f8bec6d8f 100644 +index 9448cb517..a308e867a 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/ClientPlayerPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/ClientPlayerPackets.java -@@ -247,6 +247,22 @@ public final class ClientPlayerPackets { +@@ -248,6 +248,22 @@ public final class ClientPlayerPackets { ItemUsePredictedResult.Success, ItemUseClientCooldownState.Off))); } @@ -87,7 +87,7 @@ index 70f601cf1..f8bec6d8f 100644 public static void register(final BedrockProtocol protocol) { protocol.registerClientbound(ClientboundBedrockPackets.RESPAWN, ClientboundPackets26_3.RESPAWN, wrapper -> { final Position3f position = wrapper.read(BedrockTypes.POSITION_3F); // position -@@ -763,6 +779,7 @@ public final class ClientPlayerPackets { +@@ -801,6 +817,7 @@ public final class ClientPlayerPackets { clientPlayer.setShieldBlockingHand(null); clientPlayer.setAuthInputItemInteraction(null); clientPlayer.setAuthInputItemStackRequest(null); @@ -95,7 +95,7 @@ index 70f601cf1..f8bec6d8f 100644 wrapper.cancel(); return; } -@@ -894,7 +911,7 @@ public final class ClientPlayerPackets { +@@ -932,7 +949,7 @@ public final class ClientPlayerPackets { wrapper.write(Types.BOOLEAN, itemInteraction != null); if (itemInteraction != null) { wrapper.write(wrapper.user().get(InventoryTransactionRewriter.class) @@ -104,7 +104,7 @@ index 70f601cf1..f8bec6d8f 100644 } final ItemStackRequestInfo itemStackRequest = clientPlayer.authInputItemStackRequest(); wrapper.write(Types.BOOLEAN, itemStackRequest != null); -@@ -935,6 +952,8 @@ public final class ClientPlayerPackets { +@@ -973,6 +990,8 @@ public final class ClientPlayerPackets { clientPlayer.authInputBlockActions().clear(); clientPlayer.setAuthInputItemInteraction(null); clientPlayer.setAuthInputItemStackRequest(null); @@ -114,7 +114,7 @@ index 70f601cf1..f8bec6d8f 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 44ed87e3a..d8b8dcbce 100644 +index 1c86f3205..3672ef40d 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/InteractionPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/InteractionPackets.java @@ -34,6 +34,7 @@ import net.raphimc.viabedrock.protocol.model.inventory.BedrockInventoryTransacti @@ -125,7 +125,7 @@ index 44ed87e3a..d8b8dcbce 100644 import net.raphimc.viabedrock.protocol.rewriter.InventoryTransactionRewriter; import net.raphimc.viabedrock.protocol.rewriter.ItemRewriter; import net.raphimc.viabedrock.protocol.BedrockProtocol; -@@ -53,6 +54,7 @@ import net.raphimc.viabedrock.protocol.storage.BlockInteractionSoundTracker; +@@ -54,6 +55,7 @@ import net.raphimc.viabedrock.protocol.storage.BlockInteractionSoundTracker; import net.raphimc.viabedrock.protocol.storage.ChunkTracker; import net.raphimc.viabedrock.protocol.storage.EntityTracker; import net.raphimc.viabedrock.protocol.storage.InventoryTracker; @@ -133,8 +133,8 @@ index 44ed87e3a..d8b8dcbce 100644 import net.raphimc.viabedrock.protocol.types.BedrockTypes; import java.util.List; -@@ -253,7 +255,8 @@ public final class InteractionPackets { - openBook.send(BedrockProtocol.class); +@@ -259,7 +261,8 @@ public final class InteractionPackets { + wrapper.cancel(); return; } - final BedrockInventoryTransaction transaction = beginItemUse(clientPlayer, hand, @@ -143,7 +143,7 @@ index 44ed87e3a..d8b8dcbce 100644 inventoryContainer.getSelectedHotbarSlot(), usedItem, itemIdentifier, itemIdentifier == null ? Set.of() : BedrockProtocol.MAPPINGS.getBedrockItemTags().getOrDefault(itemIdentifier, Set.of())); -@@ -288,22 +291,20 @@ public final class InteractionPackets { +@@ -294,22 +297,20 @@ public final class InteractionPackets { PacketFactory.sendJavaBlockChangedAck(wrapper.user(), sequence); return; } @@ -169,8 +169,8 @@ index 44ed87e3a..d8b8dcbce 100644 final InventoryTracker inventoryTracker = wrapper.user().get(InventoryTracker.class); final BedrockItem usedItem = inventoryTracker.getInventoryContainer().getSelectedHotbarItem(); if (usedItem.hasBlockState()) { -@@ -375,10 +376,66 @@ public final class InteractionPackets { - +@@ -386,10 +387,66 @@ public final class InteractionPackets { + return hunger == null || food.allowsUse(hunger.computeClampedValue(), hunger.computeMaxValue(), player.javaGameMode() == GameMode.CREATIVE); } + static BedrockInventoryTransaction beginItemUse(final ClientPlayerEntity player, @@ -236,7 +236,7 @@ index 44ed87e3a..d8b8dcbce 100644 static void sendPassengerUpdates(final PacketWrapper source, final EntityTracker entityTracker) { for (Entity vehicle : entityTracker.drainPassengerUpdates()) { final PacketWrapper passengersPacket = PacketWrapper.create(ClientboundPackets26_3.SET_PASSENGERS, -@@ -398,30 +455,4 @@ public final class InteractionPackets { +@@ -409,30 +466,4 @@ public final class InteractionPackets { } } diff --git a/patches/viabedrock/deferred/0016-translate-bedrock-cartography-in-core.patch b/patches/viabedrock/deferred/0016-translate-bedrock-cartography-in-core.patch index c0b2bcbf..95cfbe24 100644 --- a/patches/viabedrock/deferred/0016-translate-bedrock-cartography-in-core.patch +++ b/patches/viabedrock/deferred/0016-translate-bedrock-cartography-in-core.patch @@ -1,4 +1,4 @@ -From 3bd358ad9085fca22490df9f7188b10b7a897f23 Mon Sep 17 00:00:00 2001 +From 5e52a486d694632be83c3ff2de2d1c67cb942039 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 cartography in core @@ -634,7 +634,7 @@ index 6ef8f93d7..9d1c1819e 100644 public static final String CREAKING_HEART = "creaking_heart"; public static final String DAYLIGHT_DETECTOR = "daylight_detector"; 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 8eda040b1..0df6ae247 100644 +index 74c9248b1..cdeae906f 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/InventoryPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/InventoryPackets.java @@ -73,6 +73,7 @@ import net.lenni0451.mcstructs_bedrock.text.utils.BedrockTextUtils; @@ -644,18 +644,18 @@ index 8eda040b1..0df6ae247 100644 +import net.raphimc.viabedrock.api.model.CartographyState; import net.raphimc.viabedrock.api.chunk.BedrockBlockEntity; import net.raphimc.viabedrock.api.model.container.ChestContainer; - import net.raphimc.viabedrock.api.model.container.Container; -@@ -245,6 +246,9 @@ public final class InventoryPackets { - wrapper.cancel(); - return; + import net.raphimc.viabedrock.api.model.container.NativeStorageContainers; +@@ -263,6 +264,9 @@ public final class InventoryPackets { } + final var screens = wrapper.user().get(PlayerInventoryScreenTracker.class); + final boolean correlatedClose = container instanceof InventoryContainer && screens != null && screens.ownsClose(); + if (container instanceof CartographyContainer cartography) { + cartography.closeNameDialog(); + } inventoryTracker.setCurrentContainerClosed(serverInitiated); - if (!serverInitiated && (container instanceof InventoryContainer || inventoryTracker.getCurrentForm() != null)) { + if (correlatedClose) { wrapper.cancel(); -@@ -494,6 +498,7 @@ public final class InventoryPackets { +@@ -518,6 +522,7 @@ public final class InventoryPackets { container = new SmithingContainer( wrapper.user(), containerId, title, containerPosition); @@ -663,7 +663,7 @@ index 8eda040b1..0df6ae247 100644 case LOOM -> container = new LoomContainer(wrapper.user(), containerId, title, containerPosition); case STONECUTTER -> container = -@@ -558,6 +563,13 @@ public final class InventoryPackets { +@@ -582,6 +587,13 @@ public final class InventoryPackets { } container.resolveBlockBinding(); inventoryTracker.setCurrentContainer(container, openContext); @@ -677,7 +677,7 @@ index 8eda040b1..0df6ae247 100644 wrapper.write(Types.VAR_INT, (int) containerId); // container id final int javaMenuId = screenType == ContainerType.CONTAINER && size == 54 -@@ -611,6 +623,13 @@ public final class InventoryPackets { +@@ -635,6 +647,13 @@ public final class InventoryPackets { (byte) containerId, containerName, storageItem); if (container != null && container.setItems(items)) { container.markServerUpdate(); @@ -691,7 +691,7 @@ index 8eda040b1..0df6ae247 100644 wrapper.user().get(InventoryRequestTracker.class) .handleAuthoritativeContent(container); wrapper.user().get(InventoryRequestTracker.class) -@@ -777,6 +796,9 @@ public final class InventoryPackets { +@@ -805,6 +824,9 @@ public final class InventoryPackets { .refreshCraftingOutput(); tracker.sendJavaUpdateRecipes(wrapper.user()); NativeCookingUiState.sendUpdate(wrapper.user()); @@ -701,7 +701,7 @@ index 8eda040b1..0df6ae247 100644 }); protocol.registerClientbound( ClientboundBedrockPackets.UNLOCKED_RECIPES, -@@ -1098,6 +1120,9 @@ public final class InventoryPackets { +@@ -1131,6 +1153,9 @@ public final class InventoryPackets { if (wrapper.user().get(InventoryTracker.class).getCurrentContainer() instanceof AnvilContainer anvil) { anvil.setRenameText(name); @@ -711,7 +711,7 @@ index 8eda040b1..0df6ae247 100644 } }); protocol.registerServerbound( -@@ -1132,6 +1157,13 @@ public final class InventoryPackets { +@@ -1165,6 +1190,13 @@ public final class InventoryPackets { final String id = wrapper.read(Types.STRING); // id final CompoundTag payload = (CompoundTag) wrapper.read(Types.CUSTOM_CLICK_ACTION_TAG); // payload diff --git a/patches/viabedrock/deferred/0021-confirm-survival-offhand-transfers.patch b/patches/viabedrock/deferred/0021-confirm-survival-offhand-transfers.patch index 7a9166aa..57fe752b 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 86960a45738232040fd9b40404fca1d5d7bbd530 Mon Sep 17 00:00:00 2001 +From a95a62c66e2ba07c410ddf73b42228593f246efa 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 @@ -17,18 +17,28 @@ Block recipe placement during pending legacy offhand transfers, recovery, and synthetic cursor ownership. Extend the shared recipe readiness guard at this patch, where the legacy state becomes available, so furnace and crafting placement cannot race an unconfirmed offhand operation. + +Keep client-authoritative authored menu actions behind the same pending +legacy offhand/cursor and recovery ownership checks. Reject activation +while a prior transfer owns the inventory, preserve an already active +presentation while its action waits, and always permit deactivation. +Verify the actual native control channel, rejected activation status, +and separation from the authoritative request session in a packet test. --- .../protocol/packet/InventoryPackets.java | 143 +++++++++++++++- - .../storage/InventoryRequestTracker.java | 152 ++++++++++++++++-- + .../protocol/packet/MultiStatePackets.java | 18 +- + .../storage/InventoryRequestTracker.java | 155 +++++++++++++++++- .../protocol/packet/InventoryPacketsTest.java | 66 ++++++++ + .../NativeContainerOffhandGuardTest.java | 95 +++++++++++ .../storage/InventoryRequestTrackerTest.java | 75 +++++++++ - 4 files changed, 420 insertions(+), 16 deletions(-) + 6 files changed, 534 insertions(+), 18 deletions(-) + 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 c166c0851..9fc66a838 100644 +index 60243c409..a1a76a4d9 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/InventoryPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/InventoryPackets.java -@@ -580,7 +580,6 @@ public final class InventoryPackets { +@@ -604,7 +604,6 @@ public final class InventoryPackets { wrapper.read(BedrockTypes.FULL_CONTAINER_NAME); // container name final BedrockItem storageItem = wrapper.read(itemRewriter.newItemType()); // storage item @@ -36,7 +46,7 @@ index c166c0851..9fc66a838 100644 final InventoryTracker inventoryTracker = wrapper.user().get(InventoryTracker.class); final Container container = -@@ -590,6 +589,10 @@ public final class InventoryPackets { +@@ -614,6 +613,10 @@ public final class InventoryPackets { container.markServerUpdate(); wrapper.user().get(InventoryRequestTracker.class) .handleAuthoritativeContent(container); @@ -47,7 +57,7 @@ index c166c0851..9fc66a838 100644 if (container.type() == ContainerType.HUD) { inventoryTracker.getInventoryContainer().updateCraftingOutput(); } -@@ -619,7 +622,6 @@ public final class InventoryPackets { +@@ -643,7 +646,6 @@ public final class InventoryPackets { final BedrockItem storageItem = wrapper.read(itemRewriter.optionalNewItemType()); // storage item final BedrockItem item = wrapper.read(itemRewriter.newItemType()); // item @@ -55,7 +65,7 @@ index c166c0851..9fc66a838 100644 final InventoryTracker inventoryTracker = wrapper.user().get(InventoryTracker.class); final Container container = -@@ -629,6 +631,20 @@ public final class InventoryPackets { +@@ -653,6 +655,20 @@ public final class InventoryPackets { container.markServerUpdate(); wrapper.user().get(InventoryRequestTracker.class) .handleAuthoritativeSlot(container, slot); @@ -76,7 +86,7 @@ index c166c0851..9fc66a838 100644 if (container.type() == ContainerType.HUD && ((slot >= 28 && slot <= 31) || slot == 50)) { inventoryTracker.getInventoryContainer().refreshCraftingOutput(); -@@ -913,6 +929,8 @@ public final class InventoryPackets { +@@ -946,6 +962,8 @@ public final class InventoryPackets { } final Container container = inventoryTracker.getContainerServerbound((byte) containerId); @@ -85,7 +95,7 @@ index c166c0851..9fc66a838 100644 final var nativeContainer = wrapper.user().get(NativeContainerUiState.class); if (nativeContainer != null && nativeContainer.blocksVanillaClicks()) { if (container != null) { -@@ -920,10 +938,11 @@ public final class InventoryPackets { +@@ -953,10 +971,11 @@ public final class InventoryPackets { } return; } @@ -99,7 +109,7 @@ index c166c0851..9fc66a838 100644 final boolean requestOpen = !requestTracker.isWaitingForInventoryOpen(); if (!requestTracker.queueInventoryOpenClick( revision, slot, button, action)) { -@@ -946,13 +965,15 @@ public final class InventoryPackets { +@@ -979,13 +998,15 @@ public final class InventoryPackets { wrapper.cancel(); return; } @@ -117,7 +127,7 @@ index c166c0851..9fc66a838 100644 if (!container.handleClick(revision, slot, button, action)) { if (container.type() != ContainerType.INVENTORY) { PacketFactory.sendJavaContainerSetContent( -@@ -1017,7 +1038,10 @@ public final class InventoryPackets { +@@ -1050,7 +1071,10 @@ public final class InventoryPackets { return; } final InventoryRequestTracker requestTracker = wrapper.user().get(InventoryRequestTracker.class); @@ -129,7 +139,7 @@ index c166c0851..9fc66a838 100644 if (!requestTracker.queueButtonClick(container, button)) { PacketFactory.sendJavaContainerSetContent(wrapper.user(), container); } -@@ -1810,6 +1834,111 @@ public final class InventoryPackets { +@@ -1854,6 +1878,111 @@ public final class InventoryPackets { new InventoryTransactionData.NormalTransactionData()); } @@ -241,11 +251,47 @@ index c166c0851..9fc66a838 100644 private static BedrockItem readCreativeItem(final PacketWrapper wrapper, final ItemRewriter itemRewriter) throws InformativeException { return readCreativeItem(wrapper, itemRewriter.getItems().getOrDefault("minecraft:shield", 0)); +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 4fef2c18d..d08153ca5 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; + + import net.raphimc.viabedrock.api.model.PlayerInventoryScreenRequest; + import net.raphimc.viabedrock.protocol.storage.PlayerInventoryScreenTracker; ++import net.raphimc.viabedrock.protocol.storage.GameSessionStorage; ++ ++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; +@@ -239,8 +243,18 @@ public final class MultiStatePackets { + && wrapper.user().get(ChannelStorage.class).hasChannel(NativeContainerStatus.CHANNEL)) { + try { + final var action = wrapper.read(NativeContainerAction.TYPE); +- final boolean accepted = wrapper.user().get(NativeContainerUiState.class) +- .handleAction(action); ++ final var state = wrapper.user().get(NativeContainerUiState.class); ++ final var requests = wrapper.user().get(InventoryRequestTracker.class); ++ final var session = wrapper.user().get(GameSessionStorage.class); ++ final boolean ready = session != null && requests != null ++ && (session.isInventoryServerAuthoritative() ++ || requests.canStartRecipePlacement() && !requests.isPredictingClick()); ++ if (!ready && action.mode() == NativeContainerAction.Mode.ACTIVATE) { ++ state.handleAction(new NativeContainerAction(action.menuId(), action.sequence(), action.revision(), ++ NativeContainerAction.Mode.DEACTIVATE, -1)); ++ } ++ final boolean accepted = (ready || action.mode() == NativeContainerAction.Mode.DEACTIVATE) ++ && state.handleAction(action); + if (action.mode() == NativeContainerAction.Mode.ACTIVATE) { + 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 8ec946ec8..c695b43ce 100644 +index 9c82aaf3e..d08578907 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryRequestTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryRequestTracker.java -@@ -43,6 +43,7 @@ import net.raphimc.viabedrock.protocol.model.BedrockItem; +@@ -45,6 +45,7 @@ import net.raphimc.viabedrock.protocol.model.BedrockItem; import net.raphimc.viabedrock.protocol.model.inventory.ItemStackResponseContainerInfo; import net.raphimc.viabedrock.protocol.model.inventory.ItemStackResponseInfo; import net.raphimc.viabedrock.protocol.model.inventory.ItemStackResponseSlotInfo; @@ -253,9 +299,9 @@ index 8ec946ec8..c695b43ce 100644 import net.raphimc.viabedrock.protocol.types.BedrockTypes; import java.util.ArrayDeque; -@@ -71,6 +72,10 @@ public class InventoryRequestTracker extends StoredObject { - private boolean predictingClick; - private CraftedItemSynchronization craftedItemSynchronization; +@@ -77,6 +78,10 @@ public class InventoryRequestTracker extends StoredObject { + private TransferBatch transferBatch; + private Set batchChanges; private MineBlockRequest mineBlockRequest; + private PendingLegacyOffhand pendingLegacyOffhand; + private PendingLegacyCursorPlacement pendingLegacyCursorPlacement; @@ -264,7 +310,7 @@ index 8ec946ec8..c695b43ce 100644 public InventoryRequestTracker(final UserConnection user) { super(user); -@@ -99,13 +104,85 @@ public class InventoryRequestTracker extends StoredObject { +@@ -165,13 +170,85 @@ public class InventoryRequestTracker extends StoredObject { } public boolean canStartRecipePlacement() { @@ -351,7 +397,7 @@ index 8ec946ec8..c695b43ce 100644 public void setPredictingClick(final boolean predictingClick) { this.predictingClick = predictingClick; } -@@ -125,7 +202,7 @@ public class InventoryRequestTracker extends StoredObject { +@@ -191,7 +268,7 @@ public class InventoryRequestTracker extends StoredObject { public boolean queueClick(final Container container, final int revision, final short slot, final byte button, final ContainerInput action) { @@ -360,7 +406,7 @@ index 8ec946ec8..c695b43ce 100644 } public boolean isWaitingForInventoryOpen() { -@@ -164,15 +241,17 @@ public class InventoryRequestTracker extends StoredObject { +@@ -237,15 +314,17 @@ public class InventoryRequestTracker extends StoredObject { public void handleInventoryOpen(final Container container) { for (final InventoryOpenClick click : this.pendingInventoryOpenClicks.drain()) { @@ -381,7 +427,7 @@ index 8ec946ec8..c695b43ce 100644 PacketFactory.sendJavaContainerSetContent(this.user(), container); } } -@@ -234,6 +313,28 @@ public class InventoryRequestTracker extends StoredObject { +@@ -310,6 +389,31 @@ public class InventoryRequestTracker extends StoredObject { } public void tick() { @@ -398,19 +444,22 @@ index 8ec946ec8..c695b43ce 100644 + this.recoveringLegacyOffhand = true; + this.recovery.begin(System.nanoTime(), false, true); + tracker.closeScreenForInventoryRecovery(); -+ final PacketWrapper interact = PacketWrapper.create(ServerboundBedrockPackets.INTERACT, this.user()); -+ interact.write(Types.UNSIGNED_BYTE, -+ (short) InteractPacketPayload_Action.OpenInventory.getValue()); -+ interact.write(BedrockTypes.UNSIGNED_VAR_LONG, -+ this.user().get(EntityTracker.class).getClientPlayer().runtimeId()); -+ interact.write(BedrockTypes.OPTIONAL_POSITION_3F, null); -+ interact.sendToServer(BedrockProtocol.class); ++ final var screens = this.user().get(PlayerInventoryScreenTracker.class); ++ if (screens == null || !screens.hasPendingOpen()) { ++ final PacketWrapper interact = PacketWrapper.create(ServerboundBedrockPackets.INTERACT, this.user()); ++ interact.write(Types.UNSIGNED_BYTE, ++ (short) InteractPacketPayload_Action.OpenInventory.getValue()); ++ interact.write(BedrockTypes.UNSIGNED_VAR_LONG, ++ this.user().get(EntityTracker.class).getClientPlayer().runtimeId()); ++ interact.write(BedrockTypes.OPTIONAL_POSITION_3F, null); ++ interact.sendToServer(BedrockProtocol.class); ++ } + this.requestInventoryRefresh(); + } if (this.pendingInventoryOpenClicks.expire( System.nanoTime(), RESPONSE_TIMEOUT_NANOS)) { this.clearQueuedInteractions(); -@@ -246,6 +347,11 @@ public class InventoryRequestTracker extends StoredObject { +@@ -322,6 +426,11 @@ public class InventoryRequestTracker extends StoredObject { this.mineBlockRequest = null; } if (!this.hasPending()) { @@ -422,7 +471,7 @@ index 8ec946ec8..c695b43ce 100644 return; } final long now = System.nanoTime(); -@@ -301,13 +407,15 @@ public class InventoryRequestTracker extends StoredObject { +@@ -380,7 +489,7 @@ public class InventoryRequestTracker extends StoredObject { this.closeTemporaryInventoryIfIdle(); } } @@ -431,15 +480,16 @@ index 8ec946ec8..c695b43ce 100644 return; } final InventoryTracker tracker = this.user().get(InventoryTracker.class); - if (this.recovery.observe(container, tracker.getHudContainer(), fullContent, slot)) { +@@ -388,6 +497,8 @@ public class InventoryRequestTracker extends StoredObject { this.requests.clear(); + this.transferBatch = null; this.craftedItemSynchronization = null; + this.recoveringLegacyOffhand = false; + this.legacyOffhandCursor = null; this.resetRecovery(); tracker.getInventoryContainer().updateCraftingOutput(); PacketFactory.sendJavaContainerSetContent(this.user(), tracker.getInventoryContainer()); -@@ -539,7 +647,8 @@ public class InventoryRequestTracker extends StoredObject { +@@ -682,7 +793,8 @@ public class InventoryRequestTracker extends StoredObject { void drainQueuedInteractions() { final InventoryTracker tracker = this.user().get(InventoryTracker.class); @@ -449,7 +499,7 @@ index 8ec946ec8..c695b43ce 100644 && !this.isWaitingForInventoryOpen() && !this.queuedInteractions.isEmpty()) { final QueuedInteraction interaction = this.queuedInteractions.removeFirst(); -@@ -556,6 +665,29 @@ public class InventoryRequestTracker extends StoredObject { +@@ -699,6 +811,29 @@ public class InventoryRequestTracker extends StoredObject { private record MineBlockRequest(int requestId, int hotbarSlot, int stackNetworkId, long sentAtNanos) { } @@ -479,7 +529,7 @@ index 8ec946ec8..c695b43ce 100644 static final class PendingInventoryOpenClicks { private final ArrayDeque clicks = new ArrayDeque<>(); -@@ -631,12 +763,14 @@ public class InventoryRequestTracker extends StoredObject { +@@ -792,12 +927,14 @@ public class InventoryRequestTracker extends StoredObject { boolean apply(); } @@ -497,7 +547,7 @@ index 8ec946ec8..c695b43ce 100644 } diff --git a/src/test/java/net/raphimc/viabedrock/protocol/packet/InventoryPacketsTest.java b/src/test/java/net/raphimc/viabedrock/protocol/packet/InventoryPacketsTest.java -index 7dccb3cc7..6cf158244 100644 +index 7dccb3cc7..a17e52fd4 100644 --- a/src/test/java/net/raphimc/viabedrock/protocol/packet/InventoryPacketsTest.java +++ b/src/test/java/net/raphimc/viabedrock/protocol/packet/InventoryPacketsTest.java @@ -123,6 +123,72 @@ class InventoryPacketsTest { @@ -518,13 +568,13 @@ index 7dccb3cc7..6cf158244 100644 + assertEquals(InventorySourceType.Container_Inventory, take.source().type()); + assertEquals(ContainerID.CONTAINER_ID_PLAYER_ONLY_UI.getValue(), take.source().containerId()); + assertEquals(0, take.slot()); -+ assertSame(shield, take.fromItem()); ++ assertEquals(shield, take.fromItem()); + assertTrue(take.toItem().isEmpty()); + assertEquals(InventorySourceType.Container_Inventory, place.source().type()); + assertEquals(ContainerID.CONTAINER_ID_OFFHAND.getValue(), place.source().containerId()); + assertEquals(0, place.slot()); + assertTrue(place.fromItem().isEmpty()); -+ assertSame(shield, place.toItem()); ++ assertEquals(shield, place.toItem()); + } + + @Test @@ -541,13 +591,13 @@ index 7dccb3cc7..6cf158244 100644 + assertEquals(InventorySourceType.Container_Inventory, take.source().type()); + assertEquals(ContainerID.CONTAINER_ID_OFFHAND.getValue(), take.source().containerId()); + assertEquals(0, take.slot()); -+ assertSame(shield, take.fromItem()); ++ assertEquals(shield, take.fromItem()); + assertTrue(take.toItem().isEmpty()); + assertEquals(InventorySourceType.Container_Inventory, place.source().type()); + assertEquals(ContainerID.CONTAINER_ID_PLAYER_ONLY_UI.getValue(), place.source().containerId()); + assertEquals(0, place.slot()); + assertTrue(place.fromItem().isEmpty()); -+ assertSame(shield, place.toItem()); ++ assertEquals(shield, place.toItem()); + } + + @Test @@ -562,17 +612,118 @@ index 7dccb3cc7..6cf158244 100644 + final InventoryActionData place = transaction.actions().get(1); + assertEquals(ContainerID.CONTAINER_ID_PLAYER_ONLY_UI.getValue(), take.source().containerId()); + assertEquals(0, take.slot()); -+ assertSame(shield, take.fromItem()); ++ assertEquals(shield, take.fromItem()); + assertTrue(take.toItem().isEmpty()); + assertEquals(ContainerID.CONTAINER_ID_INVENTORY.getValue(), place.source().containerId()); + assertEquals(9, place.slot()); + assertTrue(place.fromItem().isEmpty()); -+ assertSame(shield, place.toItem()); ++ assertEquals(shield, place.toItem()); + } + @Test void entityBackedGenericContainerKeepsTheWireTypeAndUsesEntitySlots() { final UserConnection user = (UserConnection) Proxy.newProxyInstance( +diff --git a/src/test/java/net/raphimc/viabedrock/protocol/packet/NativeContainerOffhandGuardTest.java b/src/test/java/net/raphimc/viabedrock/protocol/packet/NativeContainerOffhandGuardTest.java +new file mode 100644 +index 000000000..90dd0b300 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/protocol/packet/NativeContainerOffhandGuardTest.java +@@ -0,0 +1,95 @@ ++/* ++ * 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.ProtocolInfo; ++import com.viaversion.viaversion.api.connection.UserConnection; ++import com.viaversion.viaversion.api.protocol.packet.State; ++import com.viaversion.viaversion.api.protocol.packet.PacketWrapper; ++import com.viaversion.viaversion.api.type.Types; ++import com.viaversion.viaversion.protocol.packet.PacketWrapperImpl; ++import com.viaversion.viaversion.protocols.v26_2to26_3.packet.ServerboundPackets26_3; ++import com.viaversion.viaversion.protocols.v26_2to26_3.packet.ClientboundPackets26_3; ++import io.netty.buffer.Unpooled; ++import java.util.Map; ++import net.raphimc.viabedrock.api.model.NativeContainerAction; ++import net.raphimc.viabedrock.api.model.NativeContainerMessage; ++import net.raphimc.viabedrock.api.model.NativeContainerStatus; ++import net.raphimc.viabedrock.protocol.model.BedrockItem; ++import net.raphimc.viabedrock.protocol.storage.ChannelStorage; ++import net.raphimc.viabedrock.protocol.storage.GameSessionStorage; ++import net.raphimc.viabedrock.protocol.storage.InventoryRequestTracker; ++import net.raphimc.viabedrock.protocol.storage.NativeContainerUiState; ++import org.junit.jupiter.api.Test; ++import org.mockito.ArgumentCaptor; ++import static org.junit.jupiter.api.Assertions.*; ++import static org.mockito.Mockito.*; ++ ++final class NativeContainerOffhandGuardTest { ++ ++ @Test ++ void pendingOffhandOwnershipBlocksNativeTransfersButAlwaysAllowsDeactivation() throws Exception { ++ final var user = mock(UserConnection.class); ++ final var info = mock(ProtocolInfo.class); ++ final var game = mock(GameSessionStorage.class); ++ final var state = mock(NativeContainerUiState.class); ++ final var requests = new InventoryRequestTracker(user); ++ final var channels = new ChannelStorage(); ++ channels.addChannels(java.util.List.of(NativeContainerMessage.CURRENT_CHANNEL, NativeContainerStatus.CHANNEL)); ++ final Map, Object> stores = Map.of(GameSessionStorage.class, game, NativeContainerUiState.class, state, ++ InventoryRequestTracker.class, requests, ChannelStorage.class, channels); ++ when(user.get(any())).thenAnswer(call -> stores.get(call.getArgument(0))); ++ when(user.getProtocolInfo()).thenReturn(info); ++ when(info.getClientState()).thenReturn(State.PLAY); ++ final var transfer = new NativeContainerAction(6, 1, 0, NativeContainerAction.Mode.AUTO_PLACE_ONE, 9); ++ send(user, transfer); ++ verify(state).handleAction(transfer); ++ clearInvocations(state); ++ when(user.get(GameSessionStorage.class)).thenReturn(null); ++ send(user, transfer); ++ verifyNoInteractions(state); ++ when(user.get(GameSessionStorage.class)).thenReturn(game); ++ requests.awaitLegacyOffhand(new BedrockItem(1)); ++ assertTrue(requests.isWaitingForLegacyOffhand()); ++ send(user, transfer); ++ verifyNoInteractions(state); ++ final var deactivate = new NativeContainerAction(6, 1, 0, NativeContainerAction.Mode.DEACTIVATE, -1); ++ try (var packets = mockStatic(PacketWrapper.class)) { ++ final var response = mock(PacketWrapper.class); ++ packets.when(() -> PacketWrapper.create( ++ ClientboundPackets26_3.CUSTOM_PAYLOAD, user)).thenReturn(response); ++ final var activate = new NativeContainerAction(6, 1, 0, NativeContainerAction.Mode.ACTIVATE, -1); ++ send(user, activate); ++ verify(state).handleAction(deactivate); ++ verify(state, never()).handleAction(activate); ++ final var status = ArgumentCaptor.forClass(NativeContainerStatus.class); ++ verify(response).write(eq(NativeContainerStatus.TYPE), status.capture()); ++ assertFalse(status.getValue().accepted()); ++ } ++ clearInvocations(state); ++ send(user, deactivate); ++ verify(state).handleAction(deactivate); ++ // The server-authoritative session checks its own pending request/response ownership. ++ when(game.isInventoryServerAuthoritative()).thenReturn(true); ++ send(user, transfer); ++ verify(state).handleAction(transfer); ++ } ++ ++ private static void send(final UserConnection user, final NativeContainerAction action) throws Exception { ++ final var input = Unpooled.buffer(); ++ try { ++ Types.STRING.write(input, NativeContainerMessage.CURRENT_CHANNEL); ++ NativeContainerAction.TYPE.write(input, action); ++ final var packet = new PacketWrapperImpl(ServerboundPackets26_3.CUSTOM_PAYLOAD, input, user); ++ MultiStatePackets.CUSTOM_PAYLOAD_HANDLER.handle(packet); ++ assertTrue(packet.isCancelled()); ++ assertFalse(input.isReadable()); ++ } finally { ++ input.release(); ++ } ++ } ++ ++} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/storage/InventoryRequestTrackerTest.java b/src/test/java/net/raphimc/viabedrock/protocol/storage/InventoryRequestTrackerTest.java index c7b0cf7ba..b69f1868d 100644 --- a/src/test/java/net/raphimc/viabedrock/protocol/storage/InventoryRequestTrackerTest.java diff --git a/patches/viabedrock/deferred/0021-confirm-survival-offhand-transfers.pr.md b/patches/viabedrock/deferred/0021-confirm-survival-offhand-transfers.pr.md index 471b6a8e..21bcd85e 100644 --- a/patches/viabedrock/deferred/0021-confirm-survival-offhand-transfers.pr.md +++ b/patches/viabedrock/deferred/0021-confirm-survival-offhand-transfers.pr.md @@ -5,3 +5,8 @@ Legacy offhand and cursor transfers now request full inventory snapshots when th They use the shared `InventoryMismatch` transaction after reopening the inventory. The recovery barrier still requires fresh cursor and equipment state before it clears the pending transfer. The complete core build passes with 557 passing tests and five optional skips, including the legacy transfer and recovery tests. + + +Native authored menu actions also respect pending legacy offhand and cursor ownership. +The control channel rejects activation during recovery and permits deactivation at all times. +The packet test checks rejected activation status and prevents a transfer from reaching the menu backend while an offhand transfer is pending. diff --git a/patches/viabedrock/deferred/0022-define-bedrock-request-and-recipe-codecs.patch b/patches/viabedrock/deferred/0022-define-bedrock-request-and-recipe-codecs.patch index 6edab7c4..f2acdee4 100644 --- a/patches/viabedrock/deferred/0022-define-bedrock-request-and-recipe-codecs.patch +++ b/patches/viabedrock/deferred/0022-define-bedrock-request-and-recipe-codecs.patch @@ -1,4 +1,4 @@ -From 1344483803a10175ba0150834ac8bcbc120a65fa Mon Sep 17 00:00:00 2001 +From 06da6ba513f31ba0a34cb0abf5dd9c86e7e2d507 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): define Bedrock request and recipe codecs @@ -28,7 +28,6 @@ so request and recipe consumers share these invariants from the start. --- .../generated/InventorySourceType.java | 2 +- .../generated/ItemStackRequestActionType.java | 22 +- - .../protocol/model/BedrockItem.java | 2 +- .../inventory/ItemStackRequestAction.java | 124 ++++++++++ .../model/inventory/ItemStackRequestInfo.java | 28 +++ .../inventory/ItemStackRequestSlotInfo.java | 23 ++ @@ -63,7 +62,7 @@ so request and recipe consumers share these invariants from the start. .../types/recipe/CraftingDataReaderTest.java | 85 +++++++ .../recipe/NetworkItemDescriptorTypeTest.java | 98 ++++++++ .../generator/BedrockDataEnumGenerator.java | 6 + - 37 files changed, 2247 insertions(+), 18 deletions(-) + 36 files changed, 2246 insertions(+), 17 deletions(-) create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/model/inventory/ItemStackRequestAction.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/model/inventory/ItemStackRequestInfo.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/model/inventory/ItemStackRequestSlotInfo.java @@ -141,19 +140,6 @@ index e4c39a974..2730feb35 100644 ; private static final Int2ObjectMap BY_VALUE = new Int2ObjectOpenHashMap<>(); -diff --git a/src/main/java/net/raphimc/viabedrock/protocol/model/BedrockItem.java b/src/main/java/net/raphimc/viabedrock/protocol/model/BedrockItem.java -index a17eab494..20cf478fb 100644 ---- a/src/main/java/net/raphimc/viabedrock/protocol/model/BedrockItem.java -+++ b/src/main/java/net/raphimc/viabedrock/protocol/model/BedrockItem.java -@@ -191,7 +191,7 @@ public class BedrockItem implements Item { - - @Override - public boolean isEmpty() { -- return this.id == 0 || this.id == -1 || this.amount <= 0; -+ return this.id == 0 || this.id == -1 || this.amount == 0; - } - - public boolean isDifferent(final BedrockItem o) { diff --git a/src/main/java/net/raphimc/viabedrock/protocol/model/inventory/ItemStackRequestAction.java b/src/main/java/net/raphimc/viabedrock/protocol/model/inventory/ItemStackRequestAction.java new file mode 100644 index 000000000..7b9310f5a diff --git a/patches/viabedrock/series.json b/patches/viabedrock/series.json index f839c7fb..c2a35c9d 100644 --- a/patches/viabedrock/series.json +++ b/patches/viabedrock/series.json @@ -356,7 +356,14 @@ }, { "file": "0092-retain-completed-client-prediction-frames.patch", - "title": "feat(movement): retain completed client prediction frames" + "title": "feat(movement): retain completed client prediction frames", + "prerequisites": [ + { + "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." + } + ] }, { "file": "0093-complete-login-before-resource-pack-negotiation.patch", @@ -385,6 +392,11 @@ "project": "viabedrock", "patch": "0095-share-native-ui-programs-and-resources.patch", "reason": "Land the shared UI program and scene contracts before registering presentation channels." + }, + { + "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." } ] }, @@ -428,6 +440,39 @@ "reason": "Land the test-source configuration before these packet regression tests." } ] + }, + { + "file": "0101-snapshot-inventory-action-items.patch", + "title": "fix(inventory): snapshot transaction action items", + "prerequisites": [ + { + "project": "viabedrock", + "patch": "0001-cache-converted-resource-packs.patch", + "reason": "Land the test-source configuration before this ownership regression test." + } + ] + }, + { + "file": "0101-normalize-native-nbt-floating-values.patch", + "title": "fix(nbt): preserve native scalar values", + "prerequisites": [ + { + "project": "viabedrock", + "patch": "0001-cache-converted-resource-packs.patch", + "reason": "Land the test-source configuration before these binary-NBT regression tests." + } + ] + }, + { + "file": "0102-match-native-language-parsing-and-formatting.patch", + "title": "fix(text): match native language parsing and formatting", + "prerequisites": [ + { + "project": "viabedrock", + "patch": "0101-normalize-native-nbt-floating-values.patch", + "reason": "Land the lossless native byte/string bridge before language parsing and formatting." + } + ] } ], "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 69712c66..647eced5 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 c6500300f6a44768cdb2e35c45d3e41d955268e4 Mon Sep 17 00:00:00 2001 +From c127834d82ab90997f4da6e2cd9089bb9ddde615 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 @@ -171,10 +171,10 @@ Actual metadata rewrite tests cover each color and the undyed state. 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 9439629f2..8911a1f36 100644 +index 17f2ac36e..bfda20f9c 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 -@@ -82,10 +82,12 @@ public class Entity { +@@ -94,10 +94,12 @@ public class Entity { protected String name; protected int age; protected boolean hasBossBar; @@ -187,7 +187,7 @@ index 9439629f2..8911a1f36 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; -@@ -103,6 +105,24 @@ public class Entity { +@@ -115,6 +117,24 @@ public class Entity { return scaleData == null ? 1F : ((Number) scaleData.value()).floatValue(); } @@ -212,7 +212,7 @@ index 9439629f2..8911a1f36 100644 public final Map entityProperties() { return Map.copyOf(this.entityProperties); } -@@ -145,6 +165,18 @@ public class Entity { +@@ -159,6 +179,18 @@ public class Entity { setEntityData.send(BedrockProtocol.class); } @@ -231,7 +231,7 @@ index 9439629f2..8911a1f36 100644 public final void updateEntityData(final EntityData[] entityData, final List javaEntityData) { for (EntityData data : entityData) { final ActorDataIds dataId = ActorDataIds.getByValue(data.id()); -@@ -251,6 +283,14 @@ public class Entity { +@@ -265,6 +297,14 @@ public class Entity { return this.javaType; } @@ -246,7 +246,7 @@ index 9439629f2..8911a1f36 100644 public Position3f position() { return this.position; } -@@ -380,6 +420,14 @@ public class Entity { +@@ -453,6 +493,14 @@ public class Entity { return this.mountRuntimeId; } @@ -261,7 +261,7 @@ index 9439629f2..8911a1f36 100644 public final int getJavaEntityDataIndex(final String fieldName) { final int index = BedrockProtocol.MAPPINGS.getJavaEntityDataFields().get(this.javaType).indexOf(fieldName); if (index == -1) { -@@ -477,4 +525,7 @@ public class Entity { +@@ -577,4 +625,7 @@ public class Entity { payload.send(BedrockProtocol.class); } @@ -538,7 +538,7 @@ index b98af5268..3514995f8 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 a33fe4c5d..ee5087560 100644 +index bfe6eacf6..5e2219690 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; @@ -560,7 +560,7 @@ index a33fe4c5d..ee5087560 100644 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; -@@ -42,8 +41,6 @@ import net.raphimc.viabedrock.api.model.entity.ClientPlayerEntity; +@@ -44,8 +43,6 @@ import net.raphimc.viabedrock.api.model.entity.ClientPlayerEntity; import net.raphimc.viabedrock.api.model.entity.CustomEntity; import net.raphimc.viabedrock.api.model.entity.Entity; import net.raphimc.viabedrock.api.model.entity.LivingEntity; @@ -569,7 +569,7 @@ index a33fe4c5d..ee5087560 100644 import net.raphimc.viabedrock.api.resourcepack.definition.EntityDefinitions; import net.raphimc.viabedrock.api.util.MathUtil; import net.raphimc.viabedrock.api.util.PacketFactory; -@@ -52,6 +49,7 @@ import net.raphimc.viabedrock.api.util.TextUtil; +@@ -54,6 +51,7 @@ 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; @@ -577,7 +577,7 @@ index a33fe4c5d..ee5087560 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; -@@ -59,6 +57,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.*; @@ -585,7 +585,7 @@ index a33fe4c5d..ee5087560 100644 import net.raphimc.viabedrock.protocol.rewriter.ItemRewriter; import net.raphimc.viabedrock.protocol.storage.EntityPropertyTracker; import net.raphimc.viabedrock.protocol.storage.ChannelStorage; -@@ -76,6 +75,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); @@ -598,7 +598,7 @@ index a33fe4c5d..ee5087560 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) { -@@ -139,7 +144,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 @@ -607,7 +607,7 @@ index a33fe4c5d..ee5087560 100644 final EntityLink[] links = wrapper.read(BedrockTypes.ENTITY_LINK_ARRAY); // entity links final Entity entity; -@@ -187,7 +192,7 @@ public final class EntityPackets { +@@ -191,7 +196,7 @@ public final class EntityPackets { if (entity instanceof LivingEntity livingEntity) { livingEntity.updateAttributes(attributes); } @@ -616,7 +616,7 @@ index a33fe4c5d..ee5087560 100644 for (EntityLink link : links) { entityTracker.applyEntityLink(link); } -@@ -569,53 +574,6 @@ public final class EntityPackets { +@@ -586,53 +591,6 @@ public final class EntityPackets { wrapper.write(Types.VAR_INT, entity.javaId()); // entity id wrapper.write(VersionedTypes.V26_3.entityDataList, javaEntityData); // entity data }); @@ -670,7 +670,7 @@ index a33fe4c5d..ee5087560 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 -@@ -719,8 +677,18 @@ public final class EntityPackets { +@@ -736,8 +694,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()) { @@ -689,7 +689,7 @@ index a33fe4c5d..ee5087560 100644 } else { wrapper.cancel(); } -@@ -740,6 +708,10 @@ public final class EntityPackets { +@@ -757,6 +725,10 @@ public final class EntityPackets { wrapper.write(Types.VAR_INT, collectorEntity.javaId()); // collector entity id wrapper.write(Types.VAR_INT, 0); // amount }); @@ -699,12 +699,12 @@ index a33fe4c5d..ee5087560 100644 + ); } - private static Entity writeEntityMotion(final PacketWrapper wrapper, final long runtimeId, final Position3f motion) { + 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 6dc1a00d2..7a2863735 100644 +index 85f378192..deab5b284 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/JoinPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/JoinPackets.java -@@ -291,7 +291,7 @@ public final class JoinPackets { +@@ -292,7 +292,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 @@ -714,7 +714,7 @@ index 6dc1a00d2..7a2863735 100644 wrapper.read(BedrockTypes.UUID); // world template id wrapper.read(Types.BOOLEAN); // client side generation diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/OtherPlayerPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/OtherPlayerPackets.java -index 04eb4f2e1..55811996e 100644 +index 530025a2e..8e4fc4b65 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/OtherPlayerPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/OtherPlayerPackets.java @@ -44,6 +44,7 @@ import net.raphimc.viabedrock.protocol.data.enums.java.generated.EquipmentSlot; diff --git a/patches/viabedrock/upstreamable/0008-discard-stale-passenger-links.patch b/patches/viabedrock/upstreamable/0008-discard-stale-passenger-links.patch index dcba16f4..f018d627 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 b3b17a97e3c30fae117181281011619ef91392f4 Mon Sep 17 00:00:00 2001 +From fe7f650a1e0902cd70d80c02045937d26af16af1 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 @@ -12,17 +12,48 @@ On unlink or vehicle removal, seed the player position from the vehicle and sche Retain the resolved mount for remote passengers as well as the local player, and clear it when a mount changes, unlinks or disappears. Expose the optional finite native rider vector at metadata 56 without replacing absent anchors with a zero vector. Sparse metadata already retains this value independently of passenger-link order. The licensed Bedrock 1.26.51.1 protocol 2193 Hive recording sends player rider anchors [0,1.72,-0.1]. Two separately rotated chairs place the player source eye at vehicle position plus this vector rotated by negative vehicle yaw, with less than 1e-7 position error. The vector is in native actor-origin units; Java adapters must subtract the passenger eye offset once. Core link and metadata retention supports the native client adapter; ordinary Java clients behind ViaProxy still use Java passenger attachments unless an adapter receives these native inputs. + +Ignore unknown link roles before changing pending relationships, and skip +mounting for an already-listed passenger. Both accepted link roles use the +same native insertion rule: a new player precedes a resolved non-player +first passenger, while an existing first player or unresolved first +passenger retains its place. Other passengers append. Reject self-mounts +before retaining a link. Wire role 1 is not a seat-promotion request. + +The pinned 1.26.51.1 client constructs packet 41 at 1416b5540. Dispatcher +141733530 reaches receiver 1413456e0 and ActorLinkQueueComponent. Native +consumer 145947f80 checks the real actor unique ID and vehicle component +before start-riding, angular-velocity and locator updates. Its complete +instruction fixture covers 160 cases, with world actor lookup and mounting +or removal side effects supplied at the fixture boundary. + +Base Actor insertion 1419fe0f0 and Mob wrapper 14207d700 match 224 complete +instruction cases. Both PassengerComponent back-references and vehicle +vector mutations execute natively, including empty, full, spare and aligned +allocation cases. Actual Actor and Player constructor flag-writing segments +bind player priority to actor kind. Four complete start-riding controls +reject self-mounts. Core matches 40 resolved membership cases and both link +roles across 448 insertion comparisons. + +Native executable SHA-256: +537c0aee2e79afbdc94b44b28e00f466ae62bc50e2733d953b430db9dbaa9ee7. +The has_rider getter separately passes 288 native contexts, including +unresolved members. Java resolved passengers are not a proven replacement +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. --- - .../api/model/entity/ClientPlayerEntity.java | 17 +++ - .../viabedrock/api/model/entity/Entity.java | 15 ++ + .../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 | 65 +++------ + .../protocol/packet/InteractionPackets.java | 65 ++-- .../protocol/packet/OtherPlayerPackets.java | 7 +- - .../protocol/storage/EntityTracker.java | 120 +++++++++++++++- - .../api/model/entity/EntityRiderSeatTest.java | 36 +++++ - .../protocol/storage/EntityTrackerTest.java | 131 ++++++++++++++++++ - 9 files changed, 363 insertions(+), 49 deletions(-) + .../protocol/storage/EntityTracker.java | 126 +++++++- + .../api/model/entity/EntityRiderSeatTest.java | 36 +++ + .../protocol/storage/EntityTrackerTest.java | 278 ++++++++++++++++++ + 9 files changed, 516 insertions(+), 49 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 @@ -276,7 +307,7 @@ index a906dcd80..931d0cbbd 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 aebd19b61..e00b8b176 100644 +index aebd19b61..6b6fb7ffe 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; @@ -333,7 +364,7 @@ index aebd19b61..e00b8b176 100644 return entity; } -@@ -119,9 +131,112 @@ public class EntityTracker extends StoredObject { +@@ -119,9 +131,118 @@ public class EntityTracker extends StoredObject { this.entities.remove(entity.uniqueId()); this.runtimeIdToUniqueId.remove(entity.runtimeId()); this.javaIdToUniqueId.remove(entity.javaId()); @@ -356,6 +387,12 @@ index aebd19b61..e00b8b176 100644 } + public void applyEntityLink(final EntityLink link) { ++ if (link.type() == null) { ++ return; ++ } ++ if (link.type() != ActorLinkType.None && link.fromEntityUniqueId() == link.toEntityUniqueId()) { ++ return; ++ } + final LinkKey key = new LinkKey(link.fromEntityUniqueId(), link.toEntityUniqueId()); + this.pendingEntityLinks.remove(key); + if (link.type() == ActorLinkType.None) { @@ -383,7 +420,7 @@ index aebd19b61..e00b8b176 100644 + this.pendingEntityLinks.put(key, link); + return; + } -+ this.applyResolvedEntityLink(vehicle, passenger, link.type()); ++ this.applyResolvedEntityLink(vehicle, passenger); + } + + public List drainPassengerUpdates() { @@ -406,27 +443,27 @@ index aebd19b61..e00b8b176 100644 + final Entity passenger = this.getEntityByUid(link.toEntityUniqueId()); + if (vehicle != null && passenger != null) { + links.remove(); -+ this.applyResolvedEntityLink(vehicle, passenger, link.type()); ++ this.applyResolvedEntityLink(vehicle, passenger); + } + } + } + -+ private void applyResolvedEntityLink(final Entity vehicle, final Entity passenger, -+ final ActorLinkType type) { ++ private void applyResolvedEntityLink(final Entity vehicle, final Entity passenger) { ++ if (vehicle.passengers().contains(passenger.uniqueId())) { ++ return; ++ } + final Entity previousVehicle = this.getEntityByRid(passenger.mountEntityRuntimeId()); + if (previousVehicle != null && previousVehicle != vehicle) { + previousVehicle.removePassenger(passenger.uniqueId()); + this.passengerUpdates.add(previousVehicle.uniqueId()); + } -+ final List previousPassengers = List.copyOf(vehicle.passengers()); -+ if (type == ActorLinkType.Riding) { ++ final Entity firstPassenger = vehicle.passengers().isEmpty() ? null : this.getEntityByUid(vehicle.passengers().getFirst()); ++ if (passenger instanceof PlayerEntity && firstPassenger != null && !(firstPassenger instanceof PlayerEntity)) { + vehicle.addPrimaryPassenger(passenger.uniqueId()); + } else { + vehicle.addPassenger(passenger.uniqueId()); + } -+ if (!vehicle.passengers().equals(previousPassengers)) { -+ this.passengerUpdates.add(vehicle.uniqueId()); -+ } ++ this.passengerUpdates.add(vehicle.uniqueId()); + passenger.setMountEntityRuntimeId(vehicle.runtimeId()); + } + @@ -446,7 +483,7 @@ index aebd19b61..e00b8b176 100644 public void spawnItemFrame(final BlockPosition position, final BlockState blockState) { this.removeItemFrame(position); -@@ -240,6 +355,9 @@ public class EntityTracker extends StoredObject { +@@ -240,6 +361,9 @@ public class EntityTracker extends StoredObject { return this.idCounter.getAndIncrement(); } @@ -500,10 +537,10 @@ index 000000000..25f752186 +} 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 000000000..004043a37 +index 000000000..f618cb94a --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/protocol/storage/EntityTrackerTest.java -@@ -0,0 +1,131 @@ +@@ -0,0 +1,278 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -519,18 +556,24 @@ index 000000000..004043a37 +import com.viaversion.viaversion.api.minecraft.entities.EntityTypes26_3; +import net.raphimc.viabedrock.api.model.entity.ClientPlayerEntity; +import net.raphimc.viabedrock.api.model.entity.Entity; ++import net.raphimc.viabedrock.api.model.entity.PlayerEntity; +import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ActorLinkType; +import net.raphimc.viabedrock.protocol.model.EntityLink; +import net.raphimc.viabedrock.protocol.model.PlayerAbilities; +import net.raphimc.viabedrock.protocol.model.Position3f; +import org.junit.jupiter.api.Test; + ++import java.io.IOException; +import java.lang.reflect.Proxy; ++import java.nio.file.Files; ++import java.nio.file.Path; ++import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; ++import static org.junit.jupiter.api.Assumptions.assumeTrue; + +class EntityTrackerTest { + @@ -582,7 +625,7 @@ index 000000000..004043a37 + } + + @Test -+ void riderPrecedesPassengerRegardlessOfLinkArrivalOrder() { ++ void nonPlayerRidersRetainTheirArrivalOrder() { + final EntityTracker tracker = new EntityTracker(user()); + final Entity vehicle = tracker.addEntity(10, 100, EntityTypes26_3.OAK_BOAT.identifier(), EntityTypes26_3.OAK_BOAT); + tracker.addEntity(11, 101, EntityTypes26_3.PIG.identifier(), EntityTypes26_3.PIG); @@ -592,7 +635,7 @@ index 000000000..004043a37 + tracker.applyEntityLink(link(10, 12, ActorLinkType.Riding)); + tracker.applyEntityLink(link(10, 12, ActorLinkType.Riding)); + -+ assertEquals(List.of(12L, 11L), vehicle.passengers()); ++ assertEquals(List.of(11L, 12L), vehicle.passengers()); + } + + @Test @@ -625,6 +668,147 @@ index 000000000..004043a37 + assertEquals(-1, passenger.mountEntityRuntimeId()); + } + ++ @Test ++ void repeatedRiderLinkPreservesExistingSeatOwnership() { ++ final UserConnection user = user(); ++ final EntityTracker tracker = new EntityTracker(user); ++ final Entity vehicle = tracker.addEntity(10, 100, EntityTypes26_3.OAK_BOAT.identifier(), EntityTypes26_3.OAK_BOAT); ++ final Entity first = actor(tracker, user, 11, false); ++ final Entity second = actor(tracker, user, 12, true); ++ vehicle.addPassenger(first.uniqueId()); ++ vehicle.addPassenger(second.uniqueId()); ++ first.setMountEntityRuntimeId(vehicle.runtimeId()); ++ second.setMountEntityRuntimeId(vehicle.runtimeId()); ++ ++ tracker.applyEntityLink(link(10, 12, ActorLinkType.Riding)); ++ ++ assertEquals(List.of(first.uniqueId(), second.uniqueId()), vehicle.passengers()); ++ assertEquals(vehicle.runtimeId(), first.mountEntityRuntimeId()); ++ assertEquals(vehicle.runtimeId(), second.mountEntityRuntimeId()); ++ assertEquals(List.of(), tracker.drainPassengerUpdates()); ++ } ++ ++ @Test ++ void unknownRoleCannotCreateOrReplacePendingMembership() { ++ final EntityTracker tracker = new EntityTracker(user()); ++ final Entity vehicle = tracker.addEntity(10, 100, EntityTypes26_3.OAK_BOAT.identifier(), EntityTypes26_3.OAK_BOAT); ++ tracker.applyEntityLink(link(10, 11, ActorLinkType.Riding)); ++ tracker.applyEntityLink(link(10, 11, null)); ++ tracker.applyEntityLink(link(10, 12, null)); ++ ++ final Entity rider = tracker.addEntity(11, 101, EntityTypes26_3.PIG.identifier(), EntityTypes26_3.PIG); ++ final Entity unrelated = tracker.addEntity(12, 102, EntityTypes26_3.PIG.identifier(), EntityTypes26_3.PIG); ++ tracker.drainPassengerUpdates(); ++ tracker.applyEntityLink(link(10, 12, null)); ++ ++ assertEquals(List.of(rider.uniqueId()), vehicle.passengers()); ++ assertEquals(vehicle.runtimeId(), rider.mountEntityRuntimeId()); ++ assertEquals(-1, unrelated.mountEntityRuntimeId()); ++ assertEquals(List.of(), tracker.drainPassengerUpdates()); ++ } ++ ++ @Test ++ void matchesNativeResolvedMembershipDecisions() throws IOException { ++ final String fixture = System.getenv("STACKANVIL_NATIVE_ENTITY_LINKS"); ++ assumeTrue(fixture != null); ++ final List cases = Files.readAllLines(Path.of(fixture)); ++ int compared = 0; ++ for (final String line : cases.subList(1, cases.size())) { ++ final String[] sample = line.split("\t"); ++ final EntityTracker tracker = new EntityTracker(user()); ++ final Entity vehicle = tracker.addEntity(101, 1001, EntityTypes26_3.OAK_BOAT.identifier(), EntityTypes26_3.OAK_BOAT); ++ final Entity passenger = tracker.addEntity(202, 2002, EntityTypes26_3.PIG.identifier(), EntityTypes26_3.PIG); ++ final boolean member = Integer.parseInt(sample[0]) != 0; ++ if (member) { ++ vehicle.addPassenger(passenger.uniqueId()); ++ passenger.setMountEntityRuntimeId(vehicle.runtimeId()); ++ } ++ final boolean added = Integer.parseInt(sample[4]) != 0; ++ final boolean removed = Integer.parseInt(sample[5]) != 0; ++ ++ tracker.applyEntityLink(new EntityLink(vehicle.uniqueId(), passenger.uniqueId(), ++ ActorLinkType.getByValue(Integer.parseInt(sample[1])), Integer.parseInt(sample[2]) != 0, ++ Integer.parseInt(sample[3]) != 0, 1.25F)); ++ ++ final boolean retained = added || member && !removed; ++ assertEquals(retained ? List.of(passenger.uniqueId()) : List.of(), vehicle.passengers()); ++ assertEquals(retained ? vehicle.runtimeId() : -1, passenger.mountEntityRuntimeId()); ++ assertEquals(added || removed ? List.of(vehicle) : List.of(), tracker.drainPassengerUpdates()); ++ compared++; ++ } ++ assertEquals(40, compared); ++ } ++ ++ @Test ++ void playerPrecedesNonPlayerWithoutReplacingAnExistingPlayer() { ++ final UserConnection user = user(); ++ final EntityTracker tracker = new EntityTracker(user); ++ final Entity vehicle = tracker.addEntity(10, 100, EntityTypes26_3.OAK_BOAT.identifier(), EntityTypes26_3.OAK_BOAT); ++ actor(tracker, user, 11, false); ++ actor(tracker, user, 12, true); ++ actor(tracker, user, 13, true); ++ tracker.applyEntityLink(link(10, 11, ActorLinkType.Passenger)); ++ tracker.applyEntityLink(link(10, 12, ActorLinkType.Passenger)); ++ assertEquals(List.of(12L, 11L), vehicle.passengers()); ++ ++ tracker.applyEntityLink(link(10, 13, ActorLinkType.Riding)); ++ ++ assertEquals(List.of(12L, 11L, 13L), vehicle.passengers()); ++ } ++ ++ @Test ++ void selfMountCannotBecomePendingOrResolvedMembership() { ++ final EntityTracker tracker = new EntityTracker(user()); ++ tracker.applyEntityLink(link(10, 10, ActorLinkType.Riding)); ++ final Entity actor = tracker.addEntity(10, 100, EntityTypes26_3.PIG.identifier(), EntityTypes26_3.PIG); ++ tracker.applyEntityLink(link(10, 10, ActorLinkType.Passenger)); ++ ++ assertEquals(List.of(), actor.passengers()); ++ assertEquals(-1, actor.mountEntityRuntimeId()); ++ assertEquals(List.of(), tracker.drainPassengerUpdates()); ++ } ++ ++ @Test ++ void matchesNativeSeatInsertionsForBothLinkRoles() throws IOException { ++ final String fixture = System.getenv("STACKANVIL_NATIVE_SEAT_INSERTIONS"); ++ assumeTrue(fixture != null); ++ final List cases = Files.readAllLines(Path.of(fixture)); ++ int compared = 0; ++ for (final String line : cases.subList(1, cases.size())) { ++ final String[] sample = line.split("\t"); ++ for (final ActorLinkType role : List.of(ActorLinkType.Riding, ActorLinkType.Passenger)) { ++ final UserConnection user = user(); ++ final EntityTracker tracker = new EntityTracker(user); ++ final Entity vehicle = tracker.addEntity(101, 1101, EntityTypes26_3.PIG.identifier(), EntityTypes26_3.PIG); ++ final Entity passenger = actor(tracker, user, 202, Integer.parseInt(sample[0]) != 0); ++ final List expected = new ArrayList<>(); ++ for (int i = 0; i < Integer.parseInt(sample[3]); i++) { ++ final long id = 203L + i; ++ expected.add(id); ++ if (Integer.parseInt(sample[2]) != 0) { ++ actor(tracker, user, id, i == 0 && Integer.parseInt(sample[1]) != 0); ++ } ++ vehicle.addPassenger(id); ++ } ++ expected.add(Integer.parseInt(sample[4]), passenger.uniqueId()); ++ ++ tracker.applyEntityLink(link(vehicle.uniqueId(), passenger.uniqueId(), role)); ++ ++ assertEquals(expected, vehicle.passengers()); ++ assertEquals(vehicle.runtimeId(), passenger.mountEntityRuntimeId()); ++ assertEquals(List.of(vehicle), tracker.drainPassengerUpdates()); ++ compared++; ++ } ++ } ++ assertEquals(448, compared); ++ } ++ ++ private static Entity actor(final EntityTracker tracker, final UserConnection user, final long id, final boolean player) { ++ return player ? tracker.addEntity(new PlayerEntity(user, id + 1000, tracker.getNextJavaEntityId(), UUID.randomUUID(), ++ new PlayerAbilities(id, (byte) 0, (byte) 0)), false) ++ : tracker.addEntity(id, id + 1000, EntityTypes26_3.PIG.identifier(), EntityTypes26_3.PIG); ++ } ++ + private static EntityLink link(final long vehicle, final long passenger, final ActorLinkType type) { + return new EntityLink(vehicle, passenger, type, false, false, 0F); + } 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 de0e4377..a8ac1b12 100644 --- a/patches/viabedrock/upstreamable/0008-discard-stale-passenger-links.pr.md +++ b/patches/viabedrock/upstreamable/0008-discard-stale-passenger-links.pr.md @@ -1,5 +1,86 @@ -Resolve links received before their actors, preserve rider ordering, 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 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. 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. -The complete core suite passes 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 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. + + +## 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. diff --git a/patches/viabedrock/upstreamable/0015-transport-native-resources-and-sound-commands.patch b/patches/viabedrock/upstreamable/0015-transport-native-resources-and-sound-commands.patch index cc4feb6b..5223d236 100644 --- a/patches/viabedrock/upstreamable/0015-transport-native-resources-and-sound-commands.patch +++ b/patches/viabedrock/upstreamable/0015-transport-native-resources-and-sound-commands.patch @@ -1,4 +1,4 @@ -From 2d476cce805cf67bf1656238c1a27f540431a651 Mon Sep 17 00:00:00 2001 +From 98c4343550693a9e3ebbcfa7b5e04fd9cf846eac 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(effects): transport native resources and sound commands @@ -144,36 +144,64 @@ translations. An overlay without language files reuses the original table. This avoids duplicating every bundled locale for each world actor type. Tests cover structural reuse, overlay priority and locale fallback. -Carry both native block layers, exact runtime state IDs and immutable state properties through the negotiated block_identifiers_v2 channel. Aim-assist discovery checks the extra layer before the primary layer when liquid targeting is enabled; primary-only names cannot reproduce this. Snapshots include native air and both palettes, and incremental updates retain the layer. Keep particle expiration on the primary layer. +Carry both native block layers, exact runtime state IDs and immutable state properties through the negotiated block_identifiers_v3 channel. Aim-assist discovery checks the extra layer before the primary layer when liquid targeting is enabled; primary-only names cannot reproduce this. Snapshots include native air and both palettes, and incremental updates retain the layer. Keep particle expiration on the primary layer. Bound dictionary messages by encoded byte size as well as entry count, and bound the retained definition union. Tests cover independent layer replacement and updates, missing all-air palettes, negative and hashed IDs, dimension/forget lifecycles, state properties, and packed/wire boundaries. This supplies authoritative inputs for aim execution; selection geometry, block tags and actual aim targeting remain separate work. + +Carry the forward Java liquid state in each native definition. The target +1.26.51.1 selector at 142d80600 chooses any non-air secondary block before +falling back to the primary layer, even when bedrock cannot be waterlogged +in Java. A pinned-binary probe executes the selector and both real layer +getters for 180 primary/secondary, coordinate and chunk-availability cases. +Only chunk-provider and leaf palette-cell lookup are supplied boundaries; +material classification, meshing and movement are outside that probe. + +Version the negotiated identity channel and codec together. Keep unknown +native definitions distinct from known nonfluid states. Publish replacement +section arrays through a concurrent world snapshot so client render workers +can query the same authoritative layers without reading mutable arrays or +locking every fluid lookup. Resets replace the complete world atomically. + +Translate liquid over native air into its forward Java fluid block in both +full chunks and individual block lookups. There is no primary geometry to +preserve in this case. Leaving it as Java air causes render-section empty +checks to discard the section before client mesh integration can run. +Keep exact native layer identities in the mirror for effects and queries. +Share liquid classification between this translation and its dictionary. + +The regression test exercises all four liquid identifiers across 16 +mapped depths, full secondary-only sections, dense fluid/non-air counts +and individual removals. Introduce the existing Mockito test dependency +at this first use; later feature patches reuse it. --- - build.gradle | 6 + - .../api/model/NativeBlockIdentifiers.java | 231 ++++++++++++++++++ - .../api/model/NativeBlockSection.java | 143 +++++++++++ - .../viabedrock/api/model/NativeBlockView.java | 111 +++++++++ + build.gradle | 7 + + .../api/model/NativeBlockIdentifiers.java | 236 +++++++++++++++++ + .../api/model/NativeBlockSection.java | 143 ++++++++++ + .../viabedrock/api/model/NativeBlockView.java | 146 ++++++++++ .../viabedrock/api/model/SoundCaptions.java | 94 +++++++ .../api/model/SoundPlaybackMessage.java | 76 ++++++ - .../api/resourcepack/NativeActorLibrary.java | 136 +++++++++++ - .../resourcepack/NativeResourceArchive.java | 153 ++++++++++++ - .../api/resourcepack/SoundTranslations.java | 70 ++++++ - .../api/resourcepack/WaveSound.java | 228 +++++++++++++++++ + .../api/resourcepack/NativeActorLibrary.java | 136 ++++++++++ + .../resourcepack/NativeResourceArchive.java | 153 +++++++++++ + .../api/resourcepack/SoundTranslations.java | 70 +++++ + .../api/resourcepack/WaveSound.java | 228 ++++++++++++++++ .../http/ConvertedResourcePackCache.java | 2 +- .../protocol/packet/MultiStatePackets.java | 6 + - .../protocol/packet/WorldEffectPackets.java | 29 ++- + .../protocol/packet/WorldEffectPackets.java | 29 +- + .../protocol/rewriter/BlockStateRewriter.java | 12 + .../rewriter/ResourcePackRewriter.java | 6 + - .../NativeEffectsResourceRewriter.java | 41 ++++ - .../protocol/storage/ChunkTracker.java | 92 +++++++ - .../protocol/storage/ResourcePackStorage.java | 43 +++- - .../api/model/NativeBlockViewTest.java | 211 ++++++++++++++++ + .../NativeEffectsResourceRewriter.java | 41 +++ + .../protocol/storage/ChunkTracker.java | 105 +++++++- + .../protocol/storage/ResourcePackStorage.java | 43 ++- + .../api/model/NativeBlockViewTest.java | 250 ++++++++++++++++++ .../api/model/SoundCaptionsTest.java | 80 ++++++ - .../resourcepack/NativeActorLibraryTest.java | 186 ++++++++++++++ + .../resourcepack/NativeActorLibraryTest.java | 186 +++++++++++++ .../NativeResourceArchiveTest.java | 51 ++++ .../resourcepack/SoundTranslationsTest.java | 64 +++++ - .../api/resourcepack/WaveSoundTest.java | 182 ++++++++++++++ + .../api/resourcepack/WaveSoundTest.java | 182 +++++++++++++ .../protocol/packet/SoundUpdateTest.java | 5 + .../storage/NativeBlockSectionTest.java | 60 +++++ - 25 files changed, 2298 insertions(+), 8 deletions(-) + .../storage/NativeFluidLayersTest.java | 113 ++++++++ + 27 files changed, 2514 insertions(+), 10 deletions(-) create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeBlockIdentifiers.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeBlockSection.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeBlockView.java @@ -191,12 +219,21 @@ Bound dictionary messages by encoded byte size as well as entry count, and bound create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/SoundTranslationsTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/WaveSoundTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/storage/NativeBlockSectionTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/storage/NativeFluidLayersTest.java diff --git a/build.gradle b/build.gradle -index 0ae10d4af..d493a516d 100644 +index 0ae10d4af..a597bcbbc 100644 --- a/build.gradle +++ b/build.gradle -@@ -172,3 +172,9 @@ registerToolTask("sortJson", "net.raphimc.viabedrock.tool.JsonSorter", +@@ -123,6 +123,7 @@ dependencies { + testImplementation "com.google.guava:guava:33.7.1-jre" + testImplementation "io.netty:netty-handler:4.2.18.Final" + testImplementation "org.junit.jupiter:junit-jupiter:6.1.3" ++ testImplementation "org.mockito:mockito-core:5.24.0" + testRuntimeOnly "org.junit.platform:junit-platform-launcher:6.1.3" + } + +@@ -172,3 +173,9 @@ registerToolTask("sortJson", "net.raphimc.viabedrock.tool.JsonSorter", "Sorts data asset json files in place. Options: --file (required), --deep") registerToolTask("convertResourcePack", "net.raphimc.viabedrock.test.ResourcePackConverterTest", "Converts a bedrock .mcpack to a java resource pack. Options: --input (required), --output") @@ -208,10 +245,10 @@ index 0ae10d4af..d493a516d 100644 +} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/NativeBlockIdentifiers.java b/src/main/java/net/raphimc/viabedrock/api/model/NativeBlockIdentifiers.java new file mode 100644 -index 000000000..af5f1843a +index 000000000..7cc87f221 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/model/NativeBlockIdentifiers.java -@@ -0,0 +1,231 @@ +@@ -0,0 +1,236 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -235,12 +272,12 @@ index 000000000..af5f1843a +public sealed interface NativeBlockIdentifiers permits NativeBlockIdentifiers.Reset, NativeBlockIdentifiers.Section, + NativeBlockIdentifiers.Update, NativeBlockIdentifiers.Dictionary, NativeBlockIdentifiers.Forget { + -+ String CHANNEL = "viabedrock:block_identifiers_v2"; ++ String CHANNEL = "viabedrock:block_identifiers_v3"; + 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) != 2 ++ if (Types.VAR_INT.readPrimitive(buffer) != 3 + || Types.VAR_INT.readPrimitive(buffer) != ProtocolConstants.BEDROCK_PROTOCOL_VERSION) { + throw new IllegalArgumentException("Unsupported native block identity message"); + } @@ -284,6 +321,7 @@ index 000000000..af5f1843a + for (int index = 0; index < count; index++) { + final int state = buffer.readInt(); + final String identifier = text(buffer); ++ final int fluidJavaId = buffer.readInt(); + final int propertyCount = Types.VAR_INT.readPrimitive(buffer); + if (propertyCount < 0 || propertyCount > 64) { + throw new IllegalArgumentException("Invalid native block property count"); @@ -295,7 +333,7 @@ index 000000000..af5f1843a + throw new IllegalArgumentException("Duplicate native block property"); + } + } -+ names.add(new Name(state, identifier, properties)); ++ names.add(new Name(state, identifier, properties, fluidJavaId)); + if (buffer.readerIndex() - start > MAX_DICTIONARY_BYTES) { + throw new IllegalArgumentException("Native block dictionary exceeds its byte limit"); + } @@ -308,7 +346,7 @@ index 000000000..af5f1843a + + @Override + public void write(final ByteBuf buffer, final NativeBlockIdentifiers value) { -+ Types.VAR_INT.writePrimitive(buffer, 2); ++ Types.VAR_INT.writePrimitive(buffer, 3); + 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()); @@ -338,6 +376,7 @@ index 000000000..af5f1843a + for (final Name name : dictionary.names()) { + buffer.writeInt(name.state()); + Types.STRING.write(buffer, name.identifier()); ++ buffer.writeInt(name.fluidJavaId()); + Types.VAR_INT.writePrimitive(buffer, name.properties().size()); + for (final var property : name.properties().entrySet()) { + Types.STRING.write(buffer, property.getKey()); @@ -405,9 +444,12 @@ index 000000000..af5f1843a + } + } + -+ record Name(int state, String identifier, Map properties) { ++ record Name(int state, String identifier, Map properties, int fluidJavaId) { + public Name { + checkText(identifier); ++ if (fluidJavaId < -1) { ++ throw new IllegalArgumentException("Invalid native fluid Java state"); ++ } + properties = Map.copyOf(properties); + if (properties.size() > 64) { + throw new IllegalArgumentException("Native block properties exceed limits"); @@ -419,7 +461,7 @@ index 000000000..af5f1843a + } + + public int encodedSize() { -+ int size = 4 + textSize(this.identifier) + varIntSize(this.properties.size()); ++ int size = 8 + textSize(this.identifier) + varIntSize(this.properties.size()); + for (final var property : this.properties.entrySet()) { + size += textSize(property.getKey()) + textSize(property.getValue()); + } @@ -594,10 +636,10 @@ index 000000000..ec1862263 +} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/NativeBlockView.java b/src/main/java/net/raphimc/viabedrock/api/model/NativeBlockView.java new file mode 100644 -index 000000000..c0e1a08ac +index 000000000..621fab759 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/model/NativeBlockView.java -@@ -0,0 +1,111 @@ +@@ -0,0 +1,146 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -607,73 +649,73 @@ index 000000000..c0e1a08ac + +import java.util.HashMap; +import java.util.Map; ++import java.util.concurrent.ConcurrentHashMap; + +/** A client mirror of both native block layers and state properties, with no reverse Java mapping. */ +public final class NativeBlockView { + -+ private final Map chunks = new HashMap<>(); -+ private NativeBlockIdentifiers.Reset world; -+ private NativeBlockIdentifiers.Name air; -+ private final Map names = new HashMap<>(); -+ private int dictionaryBytes; ++ private volatile Snapshot snapshot; + -+ public void clear() { -+ this.chunks.clear(); -+ this.names.clear(); -+ this.world = null; -+ this.air = null; -+ this.dictionaryBytes = 0; ++ public synchronized void clear() { ++ this.snapshot = null; + } + + public boolean ready() { -+ return this.world != null; ++ return this.snapshot != null; + } + -+ public void accept(final NativeBlockIdentifiers message) { ++ public synchronized void accept(final NativeBlockIdentifiers message) { + if (message instanceof NativeBlockIdentifiers.Reset reset) { -+ this.clear(); -+ this.world = reset; -+ this.air = new NativeBlockIdentifiers.Name(reset.air(), "minecraft:air", Map.of()); -+ } else if (this.world != null && message.dimension() == this.world.dimension()) { ++ this.snapshot = new Snapshot(reset); ++ return; ++ } ++ final Snapshot current = this.snapshot; ++ if (current != null && message.dimension() == current.world.dimension()) { + if (message instanceof NativeBlockIdentifiers.Section section) { -+ final long index = (long) section.sectionY() - (this.world.minY() >> 4); -+ if (index < 0 || index >= this.world.height() >> 4) { ++ final long index = (long) section.sectionY() - (current.world.minY() >> 4); ++ if (index < 0 || index >= current.world.height() >> 4) { + throw new IllegalArgumentException("Native section lies outside the world"); + } + final long key = key(section.chunkX(), section.chunkZ()); -+ if (!this.chunks.containsKey(key) && this.chunks.size() >= 8192) { ++ if (!current.chunks.containsKey(key) && current.chunks.size() >= 8192) { + throw new IllegalArgumentException("Native block view exceeds its chunk limit"); + } -+ this.chunks.computeIfAbsent(key, ignored -> new NativeBlockSection[2][this.world.height() >> 4])[section.layer()][(int) index] = section.blocks(); ++ final NativeBlockSection[][] previous = current.chunks.get(key); ++ final NativeBlockSection[][] layers = previous == null ++ ? new NativeBlockSection[2][current.world.height() >> 4] : copyLayers(previous, section.layer()); ++ layers[section.layer()][(int) index] = section.blocks(); ++ current.chunks.put(key, layers); + } else if (message instanceof NativeBlockIdentifiers.Update update) { + final var position = update.position(); -+ final var layers = this.chunks.get(key(position.x() >> 4, position.z() >> 4)); -+ final int index = this.sectionIndex(position.y()); ++ final var layers = current.chunks.get(key(position.x() >> 4, position.z() >> 4)); ++ final int index = sectionIndex(current.world, position.y()); + if (layers != null && index >= 0 && index < layers[update.layer()].length) { -+ final var sections = layers[update.layer()]; ++ final var replacement = copyLayers(layers, update.layer()); ++ final var sections = replacement[update.layer()]; + final var previous = sections[index] != null ? sections[index] -+ : new NativeBlockSection(new int[]{this.world.air()}, new long[0]); ++ : new NativeBlockSection(new int[]{current.world.air()}, new long[0]); + sections[index] = previous.with(NativeBlockSection.index(position.x(), position.y(), position.z()), update.state()); ++ current.chunks.put(key(position.x() >> 4, position.z() >> 4), replacement); + } + } else if (message instanceof NativeBlockIdentifiers.Dictionary dictionary) { + final Map updates = new HashMap<>(); + for (final var name : dictionary.names()) { + updates.put(name.state(), name); + } -+ int bytes = this.dictionaryBytes; -+ int states = this.names.size(); ++ int bytes = current.dictionaryBytes; ++ int states = current.names.size(); + for (final var name : updates.values()) { -+ final var previous = this.names.get(name.state()); ++ final var previous = current.names.get(name.state()); + bytes += name.encodedSize() - (previous == null ? 0 : previous.encodedSize()); + states += previous == null ? 1 : 0; + } + if (states > 1_000_000 || bytes > 64 * 1024 * 1024) { + throw new IllegalArgumentException("Native block dictionary exceeds its retained limits"); + } -+ this.names.putAll(updates); -+ this.dictionaryBytes = bytes; ++ current.names.putAll(updates); ++ current.dictionaryBytes = bytes; + } else if (message instanceof NativeBlockIdentifiers.Forget forget) { -+ this.chunks.remove(key(forget.chunkX(), forget.chunkZ())); ++ current.chunks.remove(key(forget.chunkX(), forget.chunkZ())); + } + } + } @@ -686,28 +728,63 @@ index 000000000..c0e1a08ac + /** Returns the authoritative state, native air outside loaded data, or null before reset/for unknown IDs. */ + public NativeBlockIdentifiers.Name state(final int x, final int y, final int z, final int layer) { + NativeBlockIdentifiers.layer(layer); -+ if (this.world == null) { ++ return state(this.snapshot, x, y, z, layer); ++ } ++ ++ /** Java liquid state selected from native layers, -1 for no fluid, or null for unknown data. */ ++ public Integer fluidJavaId(final int x, final int y, final int z) { ++ final Snapshot current = this.snapshot; ++ final var secondary = state(current, x, y, z, 1); ++ if (secondary == null || secondary.identifier().isEmpty()) { ++ return null; ++ } ++ final var selected = secondary.identifier().equals("minecraft:air") ? state(current, x, y, z, 0) : secondary; ++ return selected == null || selected.identifier().isEmpty() ? null : selected.fluidJavaId(); ++ } ++ ++ private static NativeBlockIdentifiers.Name state(final Snapshot current, final int x, final int y, final int z, final int layer) { ++ if (current == null) { + return null; + } -+ final var layers = this.chunks.get(key(x >> 4, z >> 4)); -+ final int index = this.sectionIndex(y); ++ final var layers = current.chunks.get(key(x >> 4, z >> 4)); ++ final int index = sectionIndex(current.world, y); + // ChunkTracker uses native air outside loaded sections and the dimension's vertical bounds. + if (layers == null || index < 0 || index >= layers[layer].length || layers[layer][index] == null) { -+ return this.air; ++ return current.air; + } + final int state = layers[layer][index].state(NativeBlockSection.index(x, y, z)); -+ return state == this.world.air() ? this.air : this.names.get(state); ++ return state == current.world.air() ? current.air : current.names.get(state); + } + -+ private int sectionIndex(final int y) { -+ final long offset = (long) y - this.world.minY(); -+ return offset < 0 || offset >= this.world.height() ? -1 : (int) (offset >> 4); ++ private static int sectionIndex(final NativeBlockIdentifiers.Reset world, final int y) { ++ final long offset = (long) y - world.minY(); ++ return offset < 0 || offset >= world.height() ? -1 : (int) (offset >> 4); ++ } ++ ++ private static NativeBlockSection[][] copyLayers(final NativeBlockSection[][] previous, final int layer) { ++ final var copy = previous.clone(); ++ copy[layer] = previous[layer].clone(); ++ return copy; + } + + private static long key(final int x, final int z) { + return (long) x << 32 | z & 0xFFFFFFFFL; + } + ++ /** Published section arrays and sections are immutable; writers replace only the changed layer. */ ++ private static final class Snapshot { ++ private final NativeBlockIdentifiers.Reset world; ++ private final NativeBlockIdentifiers.Name air; ++ private final Map chunks = new ConcurrentHashMap<>(); ++ private final Map names = new ConcurrentHashMap<>(); ++ private int dictionaryBytes; ++ ++ private Snapshot(final NativeBlockIdentifiers.Reset world) { ++ this.world = world; ++ this.air = new NativeBlockIdentifiers.Name(world.air(), "minecraft:air", Map.of(), -1); ++ } ++ } ++ +} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/SoundCaptions.java b/src/main/java/net/raphimc/viabedrock/api/model/SoundCaptions.java new file mode 100644 @@ -1618,6 +1695,29 @@ index 3fb2051c8..9924e3a01 100644 static void writePlaySound(final PacketWrapper wrapper, final PlaySound sound, final int soundId, final int category, final long seed) { wrapper.write(Types.SOUND_EVENT, Holder.of(soundId)); wrapper.write(Types.VAR_INT, category); +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 2cb4a79b6..10854c61a 100644 +--- a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/BlockStateRewriter.java ++++ b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/BlockStateRewriter.java +@@ -221,6 +221,18 @@ public class BlockStateRewriter implements StorableObject { + return this.blockStateIdMappings.get(bedrockBlockStateId); + } + ++ /** Forward liquid state, preserving its mapped depth/falling properties; -1 for nonliquid or unknown IDs. */ ++ public int fluidJavaId(final int bedrockBlockStateId) { ++ final BlockState state = this.blockState(bedrockBlockStateId); ++ if (state == null) { ++ return -1; ++ } ++ return switch (state.namespacedIdentifier()) { ++ case "minecraft:water", "minecraft:flowing_water", "minecraft:lava", "minecraft:flowing_lava" -> this.javaId(bedrockBlockStateId); ++ default -> -1; ++ }; ++ } ++ + public int waterlog(final int javaBlockStateId) { + if (BedrockProtocol.MAPPINGS.getJavaPreWaterloggedBlockStates().contains(javaBlockStateId)) { + return javaBlockStateId; diff --git a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ResourcePackRewriter.java b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ResourcePackRewriter.java index 07a19fa95..21454ca33 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ResourcePackRewriter.java @@ -1697,7 +1797,7 @@ index 000000000..d045933a0 + +} 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 a5da475d8..bd7380781 100644 +index a5da475d8..e6e0f41b7 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java @@ -26,6 +26,7 @@ import com.viaversion.viaversion.api.minecraft.blockentity.BlockEntity; @@ -1735,7 +1835,25 @@ index a5da475d8..bd7380781 100644 final PacketWrapper unloadChunk = PacketWrapper.create(ClientboundPackets26_3.FORGET_LEVEL_CHUNK, this.user()); unloadChunk.write(Types.CHUNK_POSITION, chunkPos); // chunk position unloadChunk.send(BedrockProtocol.class); -@@ -449,6 +456,9 @@ public class ChunkTracker extends StoredObject { +@@ -278,10 +285,15 @@ public class ChunkTracker extends StoredObject { + remappedBlockState = ProtocolConstants.JAVA_AIR_ID; + } + +- if (blockState0 != this.bedrockAirId() && blockPalettes.size() > 1) { ++ if (blockPalettes.size() > 1) { + final int blockState1 = blockPalettes.get(1).idAt(sectionX, sectionY, sectionZ); + if (blockState1 != this.bedrockAirId()) { +- if (CustomBlockTags.WATER.equals(blockStateRewriter.tag(blockState1))) { // Waterlogging ++ if (blockState0 == this.bedrockAirId()) { ++ final int fluid = blockStateRewriter.fluidJavaId(blockState1); ++ if (fluid != -1) { ++ remappedBlockState = fluid; ++ } ++ } else if (CustomBlockTags.WATER.equals(blockStateRewriter.tag(blockState1))) { // Waterlogging + final int waterloggedBlockState = blockStateRewriter.waterlog(remappedBlockState); + if (waterloggedBlockState != -1) { + remappedBlockState = waterloggedBlockState; +@@ -449,6 +461,9 @@ public class ChunkTracker extends StoredObject { } if (prevBlockState != blockState) { @@ -1745,7 +1863,7 @@ index a5da475d8..bd7380781 100644 this.invalidateJavaBlockStates(blockPosition.x() >> 4, blockPosition.y() >> 4, blockPosition.z() >> 4); final int chunkX = blockPosition.x() >> 4; final int chunkZ = blockPosition.z() >> 4; -@@ -489,6 +499,87 @@ public class ChunkTracker extends StoredObject { +@@ -489,6 +504,87 @@ public class ChunkTracker extends StoredObject { return section; } @@ -1788,7 +1906,7 @@ index a5da475d8..bd7380781 100644 + } + 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()); ++ block == null ? Map.of() : block.properties(), this.user().get(BlockStateRewriter.class).fluidJavaId(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)); @@ -1833,7 +1951,7 @@ index a5da475d8..bd7380781 100644 public void sendChunkInNextTick(final int chunkX, final int chunkZ) { final BedrockChunk chunk = this.getChunk(chunkX, chunkZ); if (chunk == null || !isChunkFullyLoaded(chunk)) { -@@ -510,6 +601,7 @@ public class ChunkTracker extends StoredObject { +@@ -510,6 +606,7 @@ public class ChunkTracker extends StoredObject { levelChunkWithLight.write(this.chunkType, remappedChunk); // chunk this.writeLightData(levelChunkWithLight, buildFullLightPacketData(light)); levelChunkWithLight.send(BedrockProtocol.class); @@ -1841,6 +1959,17 @@ index a5da475d8..bd7380781 100644 final long chunkKey = ChunkPosition.chunkKey(chunkX, chunkZ); this.chunkLight.put(chunkKey, light); this.lightVersions.merge(chunkKey, 1L, Long::sum); +@@ -1026,6 +1123,10 @@ public class ChunkTracker extends StoredObject { + } + final int blockState0 = layer0.idAt(x, y, z); + if (blockState0 == airId) { ++ final int fluid = blockStateRewriter.fluidJavaId(blockState1); ++ if (fluid != -1) { ++ remappedBlockPalette.setIdAt(x, y, z, fluid); ++ } + continue; + } + diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/ResourcePackStorage.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/ResourcePackStorage.java index d3dc43159..ea436f259 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/ResourcePackStorage.java @@ -1924,10 +2053,10 @@ index d3dc43159..ea436f259 100644 } diff --git a/src/test/java/net/raphimc/viabedrock/api/model/NativeBlockViewTest.java b/src/test/java/net/raphimc/viabedrock/api/model/NativeBlockViewTest.java new file mode 100644 -index 000000000..de26cb3e4 +index 000000000..9140b86b2 --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/api/model/NativeBlockViewTest.java -@@ -0,0 +1,211 @@ +@@ -0,0 +1,250 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -1986,8 +2115,8 @@ index 000000000..de26cb3e4 + assertNull(view.block(-31, -63, -47)); + view.accept(new NativeBlockIdentifiers.Reset(0, -64, 384, 0)); + view.accept(new NativeBlockIdentifiers.Dictionary(0, List.of( -+ new NativeBlockIdentifiers.Name(1, "test:native_carrier_a", Map.of("variant", "a")), -+ new NativeBlockIdentifiers.Name(2, "test:native_carrier_b", Map.of("variant", "b"))))); ++ new NativeBlockIdentifiers.Name(1, "test:native_carrier_a", Map.of("variant", "a"), -1), ++ new NativeBlockIdentifiers.Name(2, "test:native_carrier_b", Map.of("variant", "b"), -1)))); + view.accept(new NativeBlockIdentifiers.Section(0, -2, -4, -3, 0, NativeBlockSection.create(index -> 1))); + final BlockPosition position = new BlockPosition(-31, -63, -47); + view.accept(new NativeBlockIdentifiers.Update(1, position, 0, 2)); @@ -2015,7 +2144,7 @@ index 000000000..de26cb3e4 + final var section = NativeBlockSection.create(index -> index * 17 - 4096); + final List messages = List.of( + new NativeBlockIdentifiers.Reset(0, -64, 384, 0), -+ new NativeBlockIdentifiers.Dictionary(0, List.of(new NativeBlockIdentifiers.Name(Integer.MIN_VALUE, "test:custom", Map.of("test:mode", "mixed_α")))), ++ 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), + new NativeBlockIdentifiers.Forget(0, -5, 7)); @@ -2047,9 +2176,9 @@ index 000000000..de26cb3e4 + @Test + void keepsLayersAndSameNamedStatesIndependentAcrossSnapshotsAndChanges() { + final var view = new NativeBlockView(); -+ final var primary = new NativeBlockIdentifiers.Name(-17, "test:state", Map.of("variant", "primary")); -+ final var extra = new NativeBlockIdentifiers.Name(41, "test:state", Map.of("variant", "extra", "liquid_depth", "0")); -+ final var changed = new NativeBlockIdentifiers.Name(43, "test:state", Map.of("variant", "changed")); ++ final var primary = new NativeBlockIdentifiers.Name(-17, "test:state", Map.of("variant", "primary"), -1); ++ final var extra = new NativeBlockIdentifiers.Name(41, "test:state", Map.of("variant", "extra", "liquid_depth", "0"), -1); ++ final var changed = new NativeBlockIdentifiers.Name(43, "test:state", Map.of("variant", "changed"), -1); + final var position = new BlockPosition(-31, -63, -47); + view.accept(new NativeBlockIdentifiers.Reset(0, -64, 384, Integer.MIN_VALUE)); + view.accept(new NativeBlockIdentifiers.Dictionary(0, List.of(primary, extra, changed))); @@ -2078,12 +2207,50 @@ index 000000000..de26cb3e4 + } + + @Test ++ void selectsTheNativeFluidLayerWithoutInferringFromTheJavaCarrier() { ++ final var view = new NativeBlockView(); ++ final var position = new BlockPosition(-17, -1, -33); ++ final var definitions = List.of( ++ new NativeBlockIdentifiers.Name(0, "minecraft:air", Map.of(), -1), ++ new NativeBlockIdentifiers.Name(1, "minecraft:bedrock", Map.of(), -1), ++ new NativeBlockIdentifiers.Name(2, "minecraft:water", Map.of("liquid_depth", "0"), 100), ++ new NativeBlockIdentifiers.Name(3, "minecraft:flowing_water", Map.of("liquid_depth", "5"), 105), ++ new NativeBlockIdentifiers.Name(4, "minecraft:lava", Map.of("liquid_depth", "8"), 208), ++ new NativeBlockIdentifiers.Name(5, "minecraft:snow_layer", Map.of(), -1), ++ new NativeBlockIdentifiers.Name(6, "minecraft:air", Map.of(), -1)); ++ assertNull(view.fluidJavaId(position.x(), position.y(), position.z())); ++ view.accept(new NativeBlockIdentifiers.Reset(0, -64, 384, 0)); ++ view.accept(new NativeBlockIdentifiers.Dictionary(0, definitions)); ++ view.accept(new NativeBlockIdentifiers.Section(0, -2, -1, -3, 0, NativeBlockSection.create(index -> 0))); ++ for (final var primary : definitions) { ++ for (final var secondary : definitions) { ++ view.accept(new NativeBlockIdentifiers.Update(0, position, 0, primary.state())); ++ view.accept(new NativeBlockIdentifiers.Update(0, position, 1, secondary.state())); ++ final var selected = secondary.identifier().equals("minecraft:air") ? primary : secondary; ++ assertEquals(selected.fluidJavaId(), view.fluidJavaId(position.x(), position.y(), position.z())); ++ assertEquals(-1, view.fluidJavaId(position.x(), -65, position.z())); ++ assertEquals(-1, view.fluidJavaId(position.x(), 320, position.z())); ++ assertEquals(-1, view.fluidJavaId(0, position.y(), 0)); ++ } ++ } ++ view.accept(new NativeBlockIdentifiers.Update(0, position, 1, Integer.MIN_VALUE)); ++ assertNull(view.fluidJavaId(position.x(), position.y(), position.z())); ++ view.accept(new NativeBlockIdentifiers.Dictionary(0, List.of( ++ new NativeBlockIdentifiers.Name(Integer.MIN_VALUE, "", Map.of(), -1)))); ++ assertNull(view.fluidJavaId(position.x(), position.y(), position.z())); ++ view.accept(new NativeBlockIdentifiers.Forget(0, -2, -3)); ++ assertEquals(-1, view.fluidJavaId(position.x(), position.y(), position.z())); ++ view.clear(); ++ assertNull(view.fluidJavaId(position.x(), position.y(), position.z())); ++ } ++ ++ @Test + void boundsDictionaryBytesAndCopiesStateProperties() { + final Map properties = new HashMap<>(); + for (int index = 0; index < 64; index++) { + properties.put("€".repeat(250) + index, "€".repeat(256)); + } -+ final var name = new NativeBlockIdentifiers.Name(17, "test:block", properties); ++ final var name = new NativeBlockIdentifiers.Name(17, "test:block", properties, -1); + properties.clear(); + assertEquals(64, name.properties().size()); + final var buffer = Unpooled.buffer(); @@ -2094,13 +2261,14 @@ index 000000000..de26cb3e4 + 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, 2); ++ Types.VAR_INT.writePrimitive(buffer, 3); + 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(17); + Types.STRING.write(buffer, "test:block"); ++ buffer.writeInt(-1); + Types.VAR_INT.writePrimitive(buffer, 65); + assertThrows(IllegalArgumentException.class, () -> NativeBlockIdentifiers.TYPE.read(buffer)); + } finally { @@ -2122,11 +2290,11 @@ index 000000000..de26cb3e4 + 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, 2); ++ Types.VAR_INT.writePrimitive(buffer, 3); + Types.VAR_INT.writePrimitive(buffer, ProtocolConstants.BEDROCK_PROTOCOL_VERSION + 1); + assertThrows(IllegalArgumentException.class, () -> NativeBlockIdentifiers.TYPE.read(buffer)); + buffer.clear(); -+ Types.VAR_INT.writePrimitive(buffer, 2); ++ Types.VAR_INT.writePrimitive(buffer, 3); + Types.VAR_INT.writePrimitive(buffer, ProtocolConstants.BEDROCK_PROTOCOL_VERSION); + buffer.writeByte(1); + Types.VAR_INT.writePrimitive(buffer, 0); @@ -2821,5 +2989,124 @@ index 000000000..7c458f492 + } + +} +diff --git a/src/test/java/net/raphimc/viabedrock/protocol/storage/NativeFluidLayersTest.java b/src/test/java/net/raphimc/viabedrock/protocol/storage/NativeFluidLayersTest.java +new file mode 100644 +index 000000000..3d2b30e0c +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/protocol/storage/NativeFluidLayersTest.java +@@ -0,0 +1,113 @@ ++/* ++ * 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.google.common.collect.HashBiMap; ++import com.viaversion.viaversion.api.connection.UserConnection; ++import com.viaversion.viaversion.api.minecraft.chunks.Chunk; ++import com.viaversion.viaversion.api.minecraft.chunks.PaletteType; ++import com.viaversion.viaversion.libs.fastutil.ints.IntOpenHashSet; ++import net.raphimc.viabedrock.api.chunk.BedrockChunk; ++import net.raphimc.viabedrock.api.chunk.datapalette.BedrockDataPalette; ++import net.raphimc.viabedrock.api.chunk.section.BedrockChunkSection; ++import net.raphimc.viabedrock.api.chunk.section.BedrockChunkSectionImpl; ++import net.raphimc.viabedrock.api.model.BlockState; ++import net.raphimc.viabedrock.protocol.BedrockProtocol; ++import net.raphimc.viabedrock.protocol.data.ProtocolConstants; ++import net.raphimc.viabedrock.protocol.rewriter.BlockStateRewriter; ++import org.junit.jupiter.api.Test; ++ ++import java.util.HashMap; ++import java.util.Map; ++import java.util.stream.IntStream; ++ ++import static org.junit.jupiter.api.Assertions.assertEquals; ++import static org.mockito.ArgumentMatchers.anyInt; ++import static org.mockito.Mockito.*; ++ ++final class NativeFluidLayersTest { ++ ++ @Test ++ void keepsSecondaryOnlyFluidSectionsVisibleAndUpdatesTheirCounts() throws Exception { ++ final var javaStates = HashBiMap.create(); ++ javaStates.put(BlockState.fromString("minecraft:air"), ProtocolConstants.JAVA_AIR_ID); ++ for (int depth = 0; depth < 16; depth++) { ++ javaStates.put(BlockState.fromString("minecraft:water[level=" + depth + "]"), depth + 1); ++ javaStates.put(BlockState.fromString("minecraft:lava[level=" + depth + "]"), depth + 17); ++ } ++ final var previous = replaceMappings(Map.of( ++ "javaBlockStates", javaStates, ++ "javaFluidBlockStates", new IntOpenHashSet(IntStream.rangeClosed(1, 32).toArray()), ++ "javaHeightMapBlockStates", Map.of("motion_blocking", new IntOpenHashSet()), ++ "javaBiomes", HashBiMap.create(Map.of("the_void", 0)))); ++ try { ++ final int air = -128; ++ final var nativeStates = new HashMap(); ++ final var javaIds = new HashMap(); ++ javaIds.put(air, ProtocolConstants.JAVA_AIR_ID); ++ final var blocks = mock(BlockStateRewriter.class, withSettings().stubOnly()); ++ when(blocks.blockState(anyInt())).thenAnswer(call -> nativeStates.get(call.getArgument(0))); ++ when(blocks.javaId(anyInt())).thenAnswer(call -> javaIds.getOrDefault(call.getArgument(0), -1)); ++ when(blocks.fluidJavaId(anyInt())).thenCallRealMethod(); ++ final var user = mock(UserConnection.class); ++ when(user.get(BlockStateRewriter.class)).thenReturn(blocks); ++ final var chunks = mock(ChunkTracker.class, withSettings().defaultAnswer(CALLS_REAL_METHODS).stubOnly()); ++ doReturn(user).when(chunks).user(); ++ doReturn(air).when(chunks).bedrockAirId(); ++ final var height = ChunkTracker.class.getDeclaredField("worldHeight"); ++ height.setAccessible(true); ++ height.set(chunks, 16); ++ final var remap = ChunkTracker.class.getDeclaredMethod("remapChunk", BedrockChunk.class); ++ remap.setAccessible(true); ++ int nativeId = 100; ++ for (final String identifier : new String[]{"water", "flowing_water", "lava", "flowing_lava"}) { ++ for (int depth = 0; depth < 16; depth++, nativeId++) { ++ final int javaId = (identifier.endsWith("water") ? 1 : 17) + depth; ++ nativeStates.put(nativeId, BlockState.fromString("minecraft:" + identifier + "[liquid_depth=" + depth + "]")); ++ javaIds.put(nativeId, javaId); ++ final var section = new BedrockChunkSectionImpl(); ++ final var primary = new BedrockDataPalette(); ++ primary.addId(air); ++ final var secondary = new BedrockDataPalette(); ++ secondary.addId(nativeId); ++ section.addPalette(PaletteType.BLOCKS, primary); ++ section.addPalette(PaletteType.BLOCKS, secondary); ++ final var source = new BedrockChunk(-2, -3, new BedrockChunkSection[]{section}); ++ final var full = ((Chunk) remap.invoke(chunks, source)).getSections()[0]; ++ assertEquals(4096, full.getNonAirBlocksCount()); ++ assertEquals(4096, full.getFluidCount()); ++ assertEquals(javaId, full.palette(PaletteType.BLOCKS).idAt(4095)); ++ assertEquals(javaId, chunks.getJavaBlockState(section, 3, 7, 11)); ++ secondary.setIdAt(3, 7, 11, air); ++ final var changed = ((Chunk) remap.invoke(chunks, source)).getSections()[0]; ++ assertEquals(4095, changed.getNonAirBlocksCount()); ++ assertEquals(4095, changed.getFluidCount()); ++ assertEquals(ProtocolConstants.JAVA_AIR_ID, changed.palette(PaletteType.BLOCKS).idAt(3, 7, 11)); ++ assertEquals(ProtocolConstants.JAVA_AIR_ID, chunks.getJavaBlockState(section, 3, 7, 11)); ++ assertEquals(javaId, chunks.getJavaBlockState(section, 4, 7, 11)); ++ } ++ } ++ nativeStates.put(99, BlockState.fromString("minecraft:bedrock")); ++ javaIds.put(99, 33); ++ assertEquals(-1, blocks.fluidJavaId(99)); ++ assertEquals(-1, blocks.fluidJavaId(Integer.MIN_VALUE)); ++ } finally { ++ replaceMappings(previous); ++ } ++ } ++ ++ private static Map replaceMappings(final Map values) throws Exception { ++ final Map previous = new HashMap<>(); ++ for (final var entry : values.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()); ++ } ++ return previous; ++ } ++ ++} -- 2.56.0 diff --git a/patches/viabedrock/upstreamable/0015-transport-native-resources-and-sound-commands.pr.md b/patches/viabedrock/upstreamable/0015-transport-native-resources-and-sound-commands.pr.md index 2573d8a0..fb40abb6 100644 --- a/patches/viabedrock/upstreamable/0015-transport-native-resources-and-sound-commands.pr.md +++ b/patches/viabedrock/upstreamable/0015-transport-native-resources-and-sound-commands.pr.md @@ -366,7 +366,7 @@ The conversion cache version changes so older cached packs cannot omit the marke ## Native world inputs for aim assistance -The negotiated `block_identifiers_v2` channel carries both block layers, native runtime IDs and immutable state properties. +The negotiated `block_identifiers_v3` channel carries both block layers, native runtime IDs and immutable state properties. Snapshots include the dimension's native air ID and both palettes. Updates retain their layer, and dimension resets and chunk removal clear both layers. This preserves distinctions that Java block mappings can lose. @@ -391,3 +391,56 @@ All four projects build, with 2,104 passing Java tests, 152 optional skips and n ## Upstream prerequisites - [viabedrock patch](../../viabedrock/upstreamable/0015-decode-native-sound-packet-layouts.pr.md): Land the sound wire models before native playback transport. + + +## Native fluid queries + +The version-three block dictionary also carries each liquid's forward Java state. +Known nonfluid blocks carry `-1`; unknown native definitions remain distinct. +Water and lava retain their mapped level and falling properties. +The client no longer needs a Java waterlogged variant to identify the secondary fluid. +This includes the solid bedrock state found in the recorded Galaxite lobby. + +The native 1.26.51.1 selector at `142d80600` chooses any non-air secondary block before the primary block. +A fresh probe passed 180 cases against the pinned binary, SHA-256 `537c0aee2e79afbdc94b44b28e00f466ae62bc50e2733d953b430db9dbaa9ee7`. +It executes the selector and both real layer getters, with supplied chunk-provider and palette-cell boundaries. +The cases cover six block identities, negative coordinates, vertical bounds and unavailable chunks. +The probe excludes registration, material classification, meshing and movement. + +The mirror publishes immutable replacement section arrays through a concurrent world snapshot. +Each fluid query captures one world snapshot, including its dimension bounds and dictionaries. +Render workers can query it without invoking client lifecycle changes or taking the writer lock. +Targeted tests cover layer selection, air aliases, unknown definitions, lifecycle changes and the revised wire format. + +The earlier query-only validation passed 2,360 tests, with 152 optional skips and no failures. +This verifies the shared data and client query integration, not complete native water rendering or fluid simulation. +The Java waterlogging warning remains because ordinary Java clients still cannot represent that solid block's extra fluid layer. + + +Two fresh 40-second Galaxite replays validate the getters against recorded native fluid layers. +Each run retains 7,174 secondary-fluid cells and samples 256 of them. +The plain client reports zero mismatches through the client world and vanilla render-region getters. +The Iris client also checks Sodium 0.9.2's `LevelSlice`, with zero mismatches across all three query surfaces. +The region and Sodium getter probes construct query objects for the current client level; they do not test generated fluid meshes. +Transport verification passes; the broader rendering check still fails on unresolved mappings, including the camera texture. + + +## Sections with only secondary fluid + +A liquid over native air now translates to its forward Java fluid block in full chunks and individual block lookups. +No primary geometry is lost in this case. +The resulting non-air and fluid counts prevent render-section empty checks from discarding the section before meshing. +The native mirror still retains the exact primary air and secondary liquid identities. +Ordinary Java clients also receive this translation. + +The regression test covers all four water/lava identifiers across 16 forward depth mappings. +It checks full secondary-only sections, their 4,096-cell counts, individual removals and adjacent cells. +Known nonfluid and unknown identities do not become fluids. +The test framework dependency now lives in this first consumer; later patches reuse it. +These tests verify translation and section metadata, not native fluid face occlusion or physical simulation. + + +Current validation totals 2,364 passing project tests, 152 optional skips and no failures. +The final plain and Iris/Sodium Galaxite replays pass transport checks and retain secondary-fluid meshes. +A synthetic client-side wet/dry/wet control also verifies two native-fluid rebuild requests over an unchanged Java bedrock block in each run. +The shared runtime sidecar records mesh counts, the control scope and remaining rendering audit failures. 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 c1627057..ee2abb9a 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 3ec10265e21d4608b57adfd56e6744bc70bd603c Mon Sep 17 00:00:00 2001 +From 4f0c277ce5d14b588e0410d37285f15a25c64414 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 @@ -16,16 +16,148 @@ Mojang 1.26.51 metadata archive establishes the field order and omission of the tick carried by SetActorMotion. Registered-handler tests verify the encoded Java packet, unsigned runtime IDs, ground transitions, and removed actors without replacing their positions. + +Keep the last received movement packet position and rotation separate +from the mutable actor transform. Native delta handler 14133ef30 merges +omitted fields from the cached absolute payload, and publisher 14103f580 +writes that cache before its ignored-local-transform guard. Live +prediction or other actor writes must not become received packet data. + +Retain both absolute and sparse updates before local admission. Preserve +the initial actor placement as the bootstrap, then retain all six spatial +fields exclusively through these movement packets. A removed actor has +no cache update; a replacement actor owns separate state. + +The matching Bedrock 1.26.51.1 executable/protocol2193 passes 512 fresh +handler/publisher controls over all six omission bits, ground states, +distinct live positions and removed actors. Registered Java handler tests +compare the native outputs and verify independent live state and encoded +remote positions/rotations. These cases do not establish complete actor +construction, interpolation ticking, all lifetimes or full trajectories. + +Seed received angles with the native spawn bootstrap's wrapped byte +conversion. Native helper 14103ff60 normalizes each float angle, divides +by 360 and multiplies by 256 before truncating to a signed byte. Retaining +the original spawn floats changes omitted angles in the first sparse +packet. Keep the live spawn rotation independent from this packed cache. + +Original inline proxy initializers, spawn bootstrap and sparse handlers +pass 1024 additional controls, including wrap boundaries and large finite +angles. Actual registered Java handlers match their position and angle +outputs. Both deliberate regressions, full-float retention and unwrapped +byte conversion, fail the mandatory and native-reference comparisons. +Surrounding spawn flow and allocation, complete interpolation phases and +native trajectories remain separate verification requirements. + +Retain movement flags and every unsigned 64-bit interpolation-duration +bit with the received packet cache, including updates ignored by local +transform admission. Native delta handler 14133ef30 creates an absolute +payload with high flag bits set, explicit ground/teleport/local-force/ +force-completion bits and the complete duration. Absolute packets replace +that metadata with their wire flags and native zero duration. The spawn +bootstrap preserves constructor flags and duration. Do not narrow the +counter source before native target construction consumes it. + +Another 192 original handler/publisher controls cover force completion, +ground state, removed actors and unsigned durations through UINT64_MAX. +Registered Java handlers match the received metadata and spatial fields. +Both duration truncation and dropped force completion fail the mandatory +regression and native comparison. Full interpolation target application, +normal phases, actor ownership and native trajectories remain unfinished. --- - .../protocol/packet/EntityPackets.java | 49 +++++---- - .../packet/EntityMovementPacketsTest.java | 30 +++++ - .../packet/MotionPredictionPacketTest.java | 104 ++++++++++++++++++ - 3 files changed, 161 insertions(+), 22 deletions(-) + .../viabedrock/api/model/entity/Entity.java | 45 +++ + .../protocol/packet/EntityPackets.java | 74 ++-- + .../packet/EntityMovementPacketsTest.java | 30 ++ + .../packet/MotionPredictionPacketTest.java | 104 ++++++ + .../packet/SpatialPacketCacheTest.java | 316 ++++++++++++++++++ + 5 files changed, 538 insertions(+), 31 deletions(-) create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/packet/EntityMovementPacketsTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/packet/MotionPredictionPacketTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/packet/SpatialPacketCacheTest.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 5addfdbde..163c52677 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 +@@ -26,6 +26,7 @@ import com.viaversion.viaversion.api.type.types.version.VersionedTypes; + import com.viaversion.viaversion.protocols.v26_2to26_3.packet.ClientboundPackets26_3; + import net.raphimc.viabedrock.ViaBedrock; + import net.raphimc.viabedrock.api.util.EnumUtil; ++import net.raphimc.viabedrock.api.util.MathUtil; + import net.raphimc.viabedrock.protocol.BedrockProtocol; + import net.raphimc.viabedrock.protocol.ClientboundBedrockPackets; + import net.raphimc.viabedrock.protocol.data.enums.bedrock.ActorDataIds; +@@ -56,6 +57,10 @@ public class Entity { + * x, y, z + */ + protected Position3f position; ++ private Position3f lastReceivedPosition; ++ private Position3f lastReceivedRotation; ++ private short lastReceivedMovementFlags = 0xFF; ++ private long lastReceivedInterpolationTicks; + /** + * pitch, yaw, headYaw + */ +@@ -183,7 +188,35 @@ public class Entity { + return this.position; + } + ++ /** Packet state is independent of later prediction and live actor updates. */ ++ public Position3f lastReceivedPosition() { ++ return this.lastReceivedPosition; ++ } ++ ++ public Position3f lastReceivedRotation() { ++ return this.lastReceivedRotation == null ? Position3f.ZERO : this.lastReceivedRotation; ++ } ++ ++ public short lastReceivedMovementFlags() { ++ return this.lastReceivedMovementFlags; ++ } ++ ++ /** The native packet carries an unsigned 64-bit duration; preserve its complete bit pattern. */ ++ public long lastReceivedInterpolationTicks() { ++ return this.lastReceivedInterpolationTicks; ++ } ++ ++ public void retainSpatialUpdate(final Position3f position, final Position3f rotation, final short flags, final long interpolationTicks) { ++ this.lastReceivedPosition = position; ++ this.lastReceivedRotation = rotation; ++ this.lastReceivedMovementFlags = flags; ++ this.lastReceivedInterpolationTicks = interpolationTicks; ++ } ++ + public void setPosition(final Position3f position) { ++ if (this.lastReceivedPosition == null) { ++ this.lastReceivedPosition = position; ++ } + this.position = position; + } + +@@ -192,9 +225,21 @@ public class Entity { + } + + public void setRotation(final Position3f rotation) { ++ if (this.lastReceivedRotation == null) { ++ this.lastReceivedRotation = new Position3f(receivedAngle(rotation.x()), receivedAngle(rotation.y()), receivedAngle(rotation.z())); ++ } + this.rotation = rotation; + } + ++ private static float receivedAngle(final float angle) { ++ float wrapped = (angle + 180F) % 360F; ++ if (wrapped < 0F) { ++ wrapped += 360F; ++ } ++ // The native spawn cache packs normalized angles with division before multiplication. ++ return MathUtil.byte2Float((byte) ((wrapped - 180F) / 360F * 256F)); ++ } ++ + public boolean isOnGround() { + 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 b5d08775d..2958f5566 100644 +index b5d08775d..fbf977223 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 { @@ -40,18 +172,54 @@ index b5d08775d..2958f5566 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); -@@ -278,9 +283,7 @@ public final class EntityPackets { +@@ -203,6 +208,8 @@ public final class EntityPackets { + return; + } + ++ entity.retainSpatialUpdate(position, new Position3f(pitch, yaw, headYaw), flags, 0); ++ + if (entity == entityTracker.getClientPlayer()) { + if (!teleported && !forceMoveLocalEntity) { + wrapper.cancel(); +@@ -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); +- wrapper.read(Types.BOOLEAN); // force completion +- wrapper.read(BedrockTypes.UNSIGNED_VAR_LONG); // ticks ++ final boolean forceCompletion = wrapper.read(Types.BOOLEAN); ++ final long interpolationTicks = wrapper.read(BedrockTypes.UNSIGNED_VAR_LONG); + + final Entity entity = entityTracker.getEntityByRid(entityRuntimeId); + if (entity == null) { +@@ -273,14 +280,23 @@ public final class EntityPackets { + return; + } + ++ final Position3f position = mergePosition(entity.lastReceivedPosition(), hasX, x, hasY, y, hasZ, z); ++ final Position3f previousRotation = entity.lastReceivedRotation(); ++ final Position3f rotation = new Position3f( ++ hasPitch ? MathUtil.byte2Float(pitch) : previousRotation.x(), ++ hasYaw ? MathUtil.byte2Float(yaw) : previousRotation.y(), ++ hasHeadYaw ? MathUtil.byte2Float(headYaw) : previousRotation.z()); ++ // The native delta handler builds an absolute payload with its reserved flag bits set. ++ final short flags = (short) (0xF0 | (onGround ? 1 : 0) | (teleported ? 2 : 0) ++ | (forceMoveLocalEntity ? 4 : 0) | (forceCompletion ? 8 : 0)); ++ entity.retainSpatialUpdate(position, rotation, flags, interpolationTicks); ++ + if (entity == entityTracker.getClientPlayer()) { + if (!teleported && !forceMoveLocalEntity) { wrapper.cancel(); return; } - - entity.setPosition(new Position3f(x, y, z)); - -+ entity.setPosition(mergePosition(entity.position(), hasX, x, hasY, y, hasZ, z)); ++ entity.setPosition(position); if (teleported) { wrapper.setPacketType(ClientboundPackets26_3.PLAYER_POSITION); entityTracker.getClientPlayer().writePlayerPositionPacketToClient(wrapper, Relative.union(Relative.ROTATION, Relative.VELOCITY), true); -@@ -299,15 +302,7 @@ public final class EntityPackets { +@@ -299,23 +315,9 @@ public final class EntityPackets { return; } @@ -64,11 +232,20 @@ index b5d08775d..2958f5566 100644 - if (hasZ) { - entity.setPosition(new Position3f(entity.position().x(), entity.position().y(), z)); - } -+ entity.setPosition(mergePosition(entity.position(), hasX, x, hasY, y, hasZ, z)); - if (hasPitch) { - entity.setRotation(new Position3f(MathUtil.byte2Float(pitch), entity.rotation().y(), entity.rotation().z())); +- if (hasPitch) { +- entity.setRotation(new Position3f(MathUtil.byte2Float(pitch), entity.rotation().y(), entity.rotation().z())); +- } +- if (hasYaw) { +- entity.setRotation(new Position3f(entity.rotation().x(), MathUtil.byte2Float(yaw), entity.rotation().z())); +- } ++ entity.setPosition(position); ++ entity.setRotation(rotation); + if (hasHeadYaw) { +- entity.setRotation(new Position3f(entity.rotation().x(), entity.rotation().y(), MathUtil.byte2Float(headYaw))); + PacketFactory.sendJavaRotateHead(wrapper.user(), entity); } -@@ -332,20 +327,19 @@ public final class EntityPackets { + entity.setOnGround(onGround); +@@ -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 -> { @@ -98,7 +275,7 @@ index b5d08775d..2958f5566 100644 }); protocol.registerClientbound(ClientboundBedrockPackets.REMOVE_ENTITY, ClientboundPackets26_3.REMOVE_ENTITIES, wrapper -> { final EntityTracker entityTracker = wrapper.user().get(EntityTracker.class); -@@ -705,6 +699,17 @@ public final class EntityPackets { +@@ -705,6 +706,17 @@ public final class EntityPackets { }); } @@ -262,5 +439,327 @@ index 000000000..43ed285c9 + } + +} +diff --git a/src/test/java/net/raphimc/viabedrock/protocol/packet/SpatialPacketCacheTest.java b/src/test/java/net/raphimc/viabedrock/protocol/packet/SpatialPacketCacheTest.java +new file mode 100644 +index 000000000..cdfe2c6a0 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/protocol/packet/SpatialPacketCacheTest.java +@@ -0,0 +1,316 @@ ++/* ++ * 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.google.gson.JsonArray; ++import com.google.gson.JsonParser; ++import com.viaversion.viaversion.api.connection.UserConnection; ++import com.viaversion.viaversion.api.minecraft.entities.EntityTypes26_3; ++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.ByteBuf; ++import io.netty.buffer.ByteBufUtil; ++import io.netty.buffer.Unpooled; ++import net.raphimc.viabedrock.api.model.entity.ClientPlayerEntity; ++import net.raphimc.viabedrock.api.model.entity.Entity; ++import net.raphimc.viabedrock.api.util.MathUtil; ++import net.raphimc.viabedrock.api.util.PacketFactory; ++import net.raphimc.viabedrock.protocol.BedrockProtocol; ++import net.raphimc.viabedrock.protocol.ClientboundBedrockPackets; ++import net.raphimc.viabedrock.protocol.model.Position3f; ++import net.raphimc.viabedrock.protocol.storage.EntityTracker; ++import net.raphimc.viabedrock.protocol.types.BedrockTypes; ++import org.junit.jupiter.api.Test; ++import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; ++ ++import java.nio.file.Files; ++import java.nio.file.Path; ++import java.util.UUID; ++import java.util.function.Consumer; ++ ++import static org.junit.jupiter.api.Assertions.*; ++import static org.mockito.Mockito.*; ++ ++final class SpatialPacketCacheTest { ++ ++ private static final long RUNTIME_ID = 0x100000002L; ++ private static final Position3f NETWORK_POSITION = new Position3f(10.25F, 20.5F, 30.75F); ++ private static final Position3f NETWORK_ROTATION = rotation(12, 200, 254); ++ ++ @Test ++ void ignoredLocalPacketsRetainTheirStreamBeforePredictionChangesLiveCoordinates() throws Exception { ++ final var context = new Context(); ++ final var player = context.local(new Position3f(100, 200, 300)); ++ assertTrue(context.absolute().cancelled()); ++ assertEquals(NETWORK_POSITION, player.lastReceivedPosition()); ++ assertEquals(NETWORK_ROTATION, player.lastReceivedRotation()); ++ when(player.position()).thenReturn(new Position3f(400, 500, 600)); ++ when(player.rotation()).thenReturn(new Position3f(90, 90, 90)); ++ assertTrue(context.delta(2 | 32, true).cancelled()); ++ assertEquals(new Position3f(10.25F, -2.5F, 30.75F), player.lastReceivedPosition()); ++ assertEquals(new Position3f(NETWORK_ROTATION.x(), NETWORK_ROTATION.y(), MathUtil.byte2Float((byte) 45)), player.lastReceivedRotation()); ++ assertEquals(new Position3f(400, 500, 600), player.position()); ++ assertEquals(new Position3f(90, 90, 90), player.rotation()); ++ } ++ ++ @Test ++ void remoteSparsePacketsUseReceivedCoordinatesAndAnglesInsteadOfLiveActorWrites() throws Exception { ++ final var context = new Context(); ++ final Entity actor = new Entity(context.user, 21, RUNTIME_ID, "minecraft:player", 301, ++ UUID.randomUUID(), EntityTypes26_3.PLAYER); ++ when(context.tracker.getEntityByRid(RUNTIME_ID)).thenReturn(actor); ++ actor.setPosition(new Position3f(1, 2, 3)); ++ actor.setRotation(new Position3f(4, 5, 6)); ++ try (var packets = mockStatic(PacketFactory.class)) { ++ assertFalse(context.absolute().cancelled()); ++ actor.setPosition(new Position3f(100, 200, 300)); ++ actor.setRotation(new Position3f(40, 50, 60)); ++ final var translated = context.delta(2, true); ++ final var expected = new Position3f(10.25F, -2.5F, 30.75F); ++ assertEquals(expected, actor.position()); ++ assertEquals(NETWORK_ROTATION, actor.rotation()); ++ final ByteBuf output = Unpooled.wrappedBuffer(translated.bytes()); ++ try { ++ assertEquals(ClientboundPackets26_3.ENTITY_POSITION_SYNC.getId(), Types.VAR_INT.read(output)); ++ assertEquals(actor.javaId(), Types.VAR_INT.read(output)); ++ assertEquals(1, Types.VAR_INT.read(output)); ++ assertEquals(1, Types.VAR_INT.read(output)); ++ assertEquals(expected.x(), Types.DOUBLE.read(output)); ++ assertEquals(expected.y(), Types.DOUBLE.read(output)); ++ assertEquals(expected.z(), Types.DOUBLE.read(output)); ++ assertEquals(3, Types.VAR_INT.read(output)); ++ assertEquals(NETWORK_ROTATION.y(), Types.FLOAT.read(output)); ++ assertEquals(NETWORK_ROTATION.x(), Types.FLOAT.read(output)); ++ assertTrue(Types.BOOLEAN.read(output)); ++ assertEquals(0, output.readableBytes()); ++ } finally { ++ output.release(); ++ } ++ } ++ } ++ ++ @Test ++ void firstSparseUpdateUsesPackedSpawnAnglesWithoutChangingTheSpawnRotation() throws Exception { ++ final var context = new Context(); ++ final Entity actor = new Entity(context.user, 21, RUNTIME_ID, "minecraft:player", 301, ++ UUID.randomUUID(), EntityTypes26_3.PLAYER); ++ when(context.tracker.getEntityByRid(RUNTIME_ID)).thenReturn(actor); ++ actor.setPosition(NETWORK_POSITION); ++ final var spawnRotation = new Position3f(170.25F, -181.125F, 540.5F); ++ actor.setRotation(spawnRotation); ++ assertEquals(spawnRotation, actor.rotation()); ++ assertEquals((short) 0xFF, actor.lastReceivedMovementFlags()); ++ assertEquals(0, actor.lastReceivedInterpolationTicks()); ++ actor.setPosition(new Position3f(100, 200, 300)); ++ actor.setRotation(new Position3f(40, 50, 60)); ++ try (var packets = mockStatic(PacketFactory.class)) { ++ assertFalse(context.delta(2 | 32, true).cancelled()); ++ assertEquals(new Position3f(NETWORK_POSITION.x(), -2.5F, NETWORK_POSITION.z()), actor.position()); ++ assertEquals(rotation(121, 127, 45), actor.rotation()); ++ assertEquals(actor.rotation(), actor.lastReceivedRotation()); ++ } ++ } ++ ++ @Test ++ void ignoredUpdatesRetainForceCompletionAndUnsignedTicksUntilAnAbsolutePacketReplacesThem() throws Exception { ++ final var context = new Context(); ++ final var player = context.local(new Position3f(100, 200, 300)); ++ assertTrue(context.absolute((short) 8).cancelled()); ++ assertEquals((short) 8, player.lastReceivedMovementFlags()); ++ assertEquals(0, player.lastReceivedInterpolationTicks()); ++ assertTrue(context.delta(0, false, true, Long.MIN_VALUE).cancelled()); ++ assertEquals((short) 0xF8, player.lastReceivedMovementFlags()); ++ assertEquals(Long.MIN_VALUE, player.lastReceivedInterpolationTicks()); ++ assertTrue(context.absolute().cancelled()); ++ assertEquals((short) 0, player.lastReceivedMovementFlags()); ++ assertEquals(0, player.lastReceivedInterpolationTicks()); ++ assertEquals(NETWORK_POSITION, player.lastReceivedPosition()); ++ assertEquals(NETWORK_ROTATION, player.lastReceivedRotation()); ++ } ++ ++ @Test ++ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_SPATIAL_METADATA", matches = ".+") ++ void matchesNativeCompleteMovementCacheAcrossUnsignedDurationsAndForceCompletion() throws Exception { ++ final var reference = JsonParser.parseString(Files.readString(Path.of(System.getenv("STACKANVIL_NATIVE_SPATIAL_METADATA")))).getAsJsonObject(); ++ for (var value : reference.getAsJsonArray("rows")) { ++ final var row = value.getAsJsonObject(); ++ final var context = new Context(); ++ final float live = row.get("liveCoordinate").getAsFloat(); ++ final var player = context.local(new Position3f(live, live + 1, live + 2)); ++ context.absolute(); ++ if (row.get("removed").getAsBoolean()) { ++ when(context.tracker.getEntityByRid(RUNTIME_ID)).thenReturn(null); ++ } ++ assertTrue(context.delta(row.get("mask").getAsInt(), row.get("ground").getAsBoolean(), ++ row.get("forceCompletion").getAsBoolean(), Long.parseUnsignedLong(row.get("incomingTicks").getAsString())).cancelled()); ++ assertEquals(row.get("flags").getAsShort(), player.lastReceivedMovementFlags()); ++ assertEquals(Long.parseUnsignedLong(row.get("tick").getAsString()), player.lastReceivedInterpolationTicks()); ++ assertEquals(vector(row.getAsJsonArray("position")), player.lastReceivedPosition()); ++ final var angles = row.getAsJsonArray("rotationBytes"); ++ assertEquals(rotation(angles.get(0).getAsInt(), angles.get(1).getAsInt(), angles.get(2).getAsInt()), player.lastReceivedRotation()); ++ assertEquals(new Position3f(live, live + 1, live + 2), player.position()); ++ } ++ } ++ ++ @Test ++ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_SPATIAL_BOOTSTRAP", matches = ".+") ++ void matchesNativeSpawnBootstrapAndFirstSparsePacketAcrossEveryOmittedField() throws Exception { ++ final var reference = JsonParser.parseString(Files.readString(Path.of(System.getenv("STACKANVIL_NATIVE_SPATIAL_BOOTSTRAP")))).getAsJsonObject(); ++ try (var packets = mockStatic(PacketFactory.class)) { ++ for (var value : reference.getAsJsonArray("rows")) { ++ final var row = value.getAsJsonObject(); ++ final var context = new Context(); ++ final Entity actor = new Entity(context.user, 21, RUNTIME_ID, "minecraft:player", 301, ++ UUID.randomUUID(), EntityTypes26_3.PLAYER); ++ when(context.tracker.getEntityByRid(RUNTIME_ID)).thenReturn(actor); ++ actor.setPosition(vector(row.getAsJsonArray("spawnPosition"))); ++ final var spawnRotation = vector(row.getAsJsonArray("spawnRotation")); ++ actor.setRotation(spawnRotation); ++ assertEquals(spawnRotation, actor.rotation()); ++ final float live = row.get("liveCoordinate").getAsFloat(); ++ actor.setPosition(new Position3f(live, live + 1, live + 2)); ++ actor.setRotation(Position3f.ZERO); ++ assertFalse(context.delta(row.get("mask").getAsInt(), false).cancelled()); ++ assertEquals(vector(row.getAsJsonArray("position")), actor.position()); ++ assertEquals(actor.position(), actor.lastReceivedPosition()); ++ final var angles = row.getAsJsonArray("rotationBytes"); ++ assertEquals(rotation(angles.get(0).getAsInt(), angles.get(1).getAsInt(), angles.get(2).getAsInt()), actor.rotation()); ++ assertEquals(actor.rotation(), actor.lastReceivedRotation()); ++ } ++ } ++ } ++ ++ @Test ++ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_SPATIAL_CACHE", matches = ".+") ++ void matchesOriginalNativeSparseMergeAndIgnoredLocalCachePublication() throws Exception { ++ final var reference = JsonParser.parseString(Files.readString(Path.of(System.getenv("STACKANVIL_NATIVE_SPATIAL_CACHE")))).getAsJsonObject(); ++ for (var value : reference.getAsJsonArray("rows")) { ++ final var row = value.getAsJsonObject(); ++ final var context = new Context(); ++ final float live = row.get("liveCoordinate").getAsFloat(); ++ final var player = context.local(new Position3f(live, live + 1, live + 2)); ++ context.absolute(); ++ if (row.get("removed").getAsBoolean()) { ++ when(context.tracker.getEntityByRid(RUNTIME_ID)).thenReturn(null); ++ } ++ assertTrue(context.delta(row.get("mask").getAsInt(), row.get("ground").getAsBoolean()).cancelled()); ++ assertEquals(vector(row.getAsJsonArray("position")), player.lastReceivedPosition()); ++ final var angles = row.getAsJsonArray("rotationBytes"); ++ assertEquals(rotation(angles.get(0).getAsInt(), angles.get(1).getAsInt(), angles.get(2).getAsInt()), player.lastReceivedRotation()); ++ assertEquals(new Position3f(live, live + 1, live + 2), player.position()); ++ } ++ } ++ ++ private static Position3f vector(final JsonArray values) { ++ return new Position3f(values.get(0).getAsFloat(), values.get(1).getAsFloat(), values.get(2).getAsFloat()); ++ } ++ ++ private static Position3f rotation(final int pitch, final int yaw, final int headYaw) { ++ return new Position3f(MathUtil.byte2Float((byte) pitch), MathUtil.byte2Float((byte) yaw), MathUtil.byte2Float((byte) headYaw)); ++ } ++ ++ private static final class Context { ++ private final UserConnection user = mock(UserConnection.class); ++ private final EntityTracker tracker = mock(EntityTracker.class); ++ private final TestProtocol protocol = new TestProtocol(); ++ ++ private Context() { ++ when(this.user.get(EntityTracker.class)).thenReturn(this.tracker); ++ } ++ ++ private ClientPlayerEntity local(final Position3f position) { ++ final var player = mock(ClientPlayerEntity.class, CALLS_REAL_METHODS); ++ when(player.position()).thenReturn(position); ++ when(player.rotation()).thenReturn(Position3f.ZERO); ++ when(this.tracker.getClientPlayer()).thenReturn(player); ++ when(this.tracker.getEntityByRid(RUNTIME_ID)).thenReturn(player); ++ return player; ++ } ++ ++ private Translation absolute() throws Exception { ++ return this.absolute((short) 0); ++ } ++ ++ private Translation absolute(final short flags) throws Exception { ++ return this.translate(ClientboundBedrockPackets.MOVE_ENTITY_ABSOLUTE, input -> { ++ BedrockTypes.UNSIGNED_VAR_LONG.write(input, RUNTIME_ID); ++ Types.UNSIGNED_BYTE.write(input, flags); ++ BedrockTypes.POSITION_3F.write(input, NETWORK_POSITION); ++ for (int angle : new int[]{12, 200, 254}) { ++ Types.BYTE.write(input, (byte) angle); ++ } ++ }); ++ } ++ ++ private Translation delta(final int mask, final boolean onGround) throws Exception { ++ return this.delta(mask, onGround, false, 0x100000005L); ++ } ++ ++ private Translation delta(final int mask, final boolean onGround, final boolean forceCompletion, final long interpolationTicks) throws Exception { ++ return this.translate(ClientboundBedrockPackets.MOVE_ENTITY_DELTA, input -> { ++ BedrockTypes.UNSIGNED_VAR_LONG.write(input, RUNTIME_ID); ++ final float[] positions = {-1.25F, -2.5F, -3.75F}; ++ final int[] angles = {240, 30, 45}; ++ for (int axis = 0; axis < 3; axis++) { ++ final boolean supplied = (mask & 1 << axis) != 0; ++ Types.BOOLEAN.write(input, supplied); ++ if (supplied) { ++ BedrockTypes.FLOAT_LE.write(input, positions[axis]); ++ } ++ } ++ for (int axis = 0; axis < 3; axis++) { ++ final boolean supplied = (mask & 1 << axis + 3) != 0; ++ Types.BOOLEAN.write(input, supplied); ++ if (supplied) { ++ Types.BYTE.write(input, (byte) angles[axis]); ++ } ++ } ++ Types.BOOLEAN.write(input, onGround); ++ Types.BOOLEAN.write(input, false); ++ Types.BOOLEAN.write(input, false); ++ Types.BOOLEAN.write(input, forceCompletion); ++ BedrockTypes.UNSIGNED_VAR_LONG.write(input, interpolationTicks); ++ }); ++ } ++ ++ private Translation translate(final ClientboundBedrockPackets type, final Consumer encode) throws Exception { ++ final ByteBuf input = Unpooled.buffer(); ++ final ByteBuf output = Unpooled.buffer(); ++ try { ++ encode.accept(input); ++ final var packet = new PacketWrapperImpl(type, input, this.user); ++ this.protocol.translate(packet); ++ assertEquals(0, input.readableBytes()); ++ if (!packet.isCancelled()) { ++ packet.writeToBuffer(output); ++ } ++ return new Translation(packet.isCancelled(), ByteBufUtil.getBytes(output)); ++ } finally { ++ input.release(); ++ output.release(); ++ } ++ } ++ } ++ ++ private record Translation(boolean cancelled, byte[] bytes) { ++ } ++ ++ private static final class TestProtocol extends BedrockProtocol { ++ private TestProtocol() { ++ EntityPackets.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/upstreamable/0018-preserve-sparse-actor-spatial-updates.pr.md b/patches/viabedrock/upstreamable/0018-preserve-sparse-actor-spatial-updates.pr.md index 8f791f50..96798642 100644 --- a/patches/viabedrock/upstreamable/0018-preserve-sparse-actor-spatial-updates.pr.md +++ b/patches/viabedrock/upstreamable/0018-preserve-sparse-actor-spatial-updates.pr.md @@ -1,5 +1,78 @@ # Bedrock spatial updates +## Complete received packet metadata, October 23, 2026 + +The received cache now retains the native movement flags and every unsigned 64-bit interpolation tick. +Sparse packets retain ground, teleport, forced-local, and force-completion bits with the native reserved high bits. +Absolute packets replace these flags and reset the duration to zero, as the native absolute constructor does. +Spawn bootstrap keeps the constructor flags and duration. +Live actor changes remain independent from this received state. + +The original sparse handler and full cache publisher pass another 192 controls. +They cover force completion, ground state, removed actors, and durations through `UINT64_MAX`. +Ready-level and actor lookup callbacks plus native local-control storage remain supplied boundaries. +Registered Java handlers match the native flags, duration bits, position, and angles. +All seven packet-cache tests pass without skips. +Truncated durations and dropped force completion each fail the mandatory regression and native comparison. +The restored implementation passes both comparisons and Checkstyle. + +This change preserves the complete source for later interpolation target construction. +The existing Java interpolation path still needs replacement with retained native components and their real phases. +These controls do not establish teleport or forced-local admission, complete actor lifetimes, live trajectories, or full interpolation parity. + +## Independent received movement state, October 23, 2026 + +Sparse actor movement now fills omitted coordinates and rotations from the last received movement packet. +Live prediction and other actor writes no longer replace that source. +Both absolute and sparse packets update the retained stream before the local-player admission check. +An ignored local transform therefore still supplies the next sparse packet. +Unknown and removed actors receive no update, and each actor instance owns its retained state. +Initial actor placement supplies the bootstrap before movement packets arrive. +Spawn angles use the native wrap and byte conversion before they enter the received cache. +The live spawn rotation keeps its original float values. + +The pinned 1.26.51.1 executable routes delta packets through dispatcher `141732ef0`, callback slot `710`, and derived client handler `14133ef30`. +That handler merges omitted fields from the proxy packet cache. +The original publisher `14103f580` writes the complete received payload before checking whether to ignore the local transform. +The base callback table contains empty handlers, so its entries alone do not establish the derived client behavior. + +There are 512 fresh original-handler controls over all six omission bits, ground states, two distinct live positions, and removed actors. +The actual native cache publisher runs and records 256 updates before ignored local transforms. +The fixture supplies ready-level and actor-lookup callbacks plus valid synthetic actor-tag storage. +Later interpolation, complete actor construction, full component lifetimes, and live trajectories remain outside these controls. + +Registered Java packet tests match received coordinates and rotations in all 512 native cases. +Mandatory regressions also check ignored local updates and the encoded remote position and rotation after unrelated live writes. +Two private production variants merge live state or discard ignored cache updates. +Both variants fail all three relevant regressions; the restored implementation passes the focused suite and Checkstyle. + +The combined four-project build passes 2,533 tests, with 214 optional skips and no failures or errors. +All 1,411 core classes match both packaged copies byte for byte. +The native cache comparison runs without skips. +Offline tooling, static, and patch metadata checks pass. +These results do not claim a fresh live trajectory or a complete interpolation port. + +The native spawn path calls `14103ff60` after proxy construction. +That function stores the runtime ID and position, then wraps and packs the three angles into bytes. +It divides by 360 before multiplication by 256. +The previous bootstrap retained full spawn angles, so the first sparse packet used different omitted angles. +For example, the native cache stores pitch 170.15625 for spawn pitch 170.25. + +A further 1,024 controls execute two original inline initializers, the complete spawn bootstrap, and the original sparse handler and cache publisher. +They cover all six omission bits, distinct live positions, wrap boundaries, and large finite angles. +The fixture supplies allocation, native frame data, ready-level and actor lookup callbacks, local-control storage, and the CRT remainder operation. +Surrounding spawn-handler flow, complete actor construction, and live packet delivery remain outside this comparison. + +The current combined build passes 2,540 tests, with 215 optional skips and no failures or errors. +All five packet-cache tests run without skips, including both native references. +All 1,411 core classes match the add-on and proxy packages byte for byte. +Both deliberate spawn regressions fail the mandatory and native-reference tests. +The restored implementation passes both comparisons and Checkstyle. +The full 104-patch core stack replays successfully. + +This fixes the packet-source prerequisite for native interpolation and collider refresh. +Retained interpolation components, force-completion routing, complete input phases, and native trajectory parity remain unfinished. + Partial movement packets preserve every omitted coordinate. This applies to remote actors and forced local-player corrections. ## Spatial optimization hints 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 ccc2cf6f..0a976d8d 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 017d830cc5142f967a39f9d7e9ac54cd9bbfb9f1 Mon Sep 17 00:00:00 2001 +From 430e72b60aafdf417d6d338bbd1d9156466aa4c2 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 @@ -153,11 +153,11 @@ index 66f15233c..6347d81bf 100644 return this.sprinting; } 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 5addfdbde..f73238e76 100644 +index 163c52677..767c62d66 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 -@@ -183,6 +183,14 @@ public class Entity { - return this.position; +@@ -213,6 +213,14 @@ public class Entity { + this.lastReceivedInterpolationTicks = interpolationTicks; } + public Position3f javaPosition() { @@ -169,9 +169,9 @@ index 5addfdbde..f73238e76 100644 + } + public void setPosition(final Position3f position) { - this.position = position; - } -@@ -191,6 +199,14 @@ public class Entity { + if (this.lastReceivedPosition == null) { + this.lastReceivedPosition = position; +@@ -224,6 +232,14 @@ public class Entity { return this.rotation; } @@ -184,8 +184,8 @@ index 5addfdbde..f73238e76 100644 + } + public void setRotation(final Position3f rotation) { - this.rotation = rotation; - } + 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 f67b7f7d8..4bdf42be8 100644 --- a/src/main/java/net/raphimc/viabedrock/api/util/PacketFactory.java @@ -378,7 +378,7 @@ index 40f226bd1..ea042ac77 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 2958f5566..fcb0ca41a 100644 +index fbf977223..1f49fa20c 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java @@ -133,12 +133,12 @@ public final class EntityPackets { @@ -398,7 +398,7 @@ index 2958f5566..fcb0ca41a 100644 wrapper.write(Types.VAR_INT, 0); // data wrapper.send(BedrockProtocol.class); wrapper.cancel(); -@@ -223,11 +223,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 2958f5566..fcb0ca41a 100644 wrapper.write(Types.BOOLEAN, entity.isOnGround()); // on ground } return; -@@ -241,10 +241,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 2958f5566..fcb0ca41a 100644 wrapper.write(Types.FLOAT, pitch); // pitch wrapper.write(Types.BOOLEAN, entity.isOnGround()); // on ground -@@ -292,11 +292,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 2958f5566..fcb0ca41a 100644 wrapper.write(Types.BOOLEAN, entity.isOnGround()); // on ground } return; -@@ -319,11 +319,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 @@ -457,7 +457,7 @@ index 2958f5566..fcb0ca41a 100644 }); protocol.registerClientbound(ClientboundBedrockPackets.SET_ENTITY_MOTION, ClientboundPackets26_3.SET_ENTITY_MOTION, wrapper -> { 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 e00b8b176..fb0efe925 100644 +index 6b6fb7ffe..2b5551f6b 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/EntityTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/EntityTracker.java @@ -78,7 +78,9 @@ public class EntityTracker extends StoredObject { diff --git a/patches/viabedrock/upstreamable/0024-ignore-overlapping-subchunk-responses.patch b/patches/viabedrock/upstreamable/0024-ignore-overlapping-subchunk-responses.patch index 9ccca79c..2b1f6124 100644 --- a/patches/viabedrock/upstreamable/0024-ignore-overlapping-subchunk-responses.patch +++ b/patches/viabedrock/upstreamable/0024-ignore-overlapping-subchunk-responses.patch @@ -1,4 +1,4 @@ -From 0db4e2480545dcda486af996823a68a712e68ecb Mon Sep 17 00:00:00 2001 +From ea272c7d948ad40115eb7aefbc70682099863ed7 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(chunks): ignore overlapping subchunk responses @@ -98,7 +98,7 @@ index 673ee7dea..abe6a2f99 100644 } 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 bd7380781..08fb252d0 100644 +index e6e0f41b7..5c7f11dcf 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java @@ -178,6 +178,12 @@ public class ChunkTracker extends StoredObject { @@ -114,7 +114,7 @@ index bd7380781..08fb252d0 100644 this.dirtyChunks.remove(chunkKey); this.javaBlockStateCache.remove(chunkKey); this.chunkLight.remove(chunkKey); -@@ -389,11 +395,25 @@ public class ChunkTracker extends StoredObject { +@@ -394,11 +400,25 @@ public class ChunkTracker extends StoredObject { return; } final SubChunkPosition position = new SubChunkPosition(chunkX, subChunkY, chunkZ); @@ -140,7 +140,7 @@ index bd7380781..08fb252d0 100644 public boolean mergeSubChunk(final int chunkX, final int subChunkY, final int chunkZ, final BedrockChunkSection other, final List blockEntities) { if (!this.isInLoadDistance(chunkX, chunkZ)) { return false; -@@ -412,15 +432,33 @@ public class ChunkTracker extends StoredObject { +@@ -417,15 +437,33 @@ public class ChunkTracker extends StoredObject { return false; } diff --git a/patches/viabedrock/upstreamable/0030-propagate-increased-block-light-incrementally.patch b/patches/viabedrock/upstreamable/0030-propagate-increased-block-light-incrementally.patch index fa182e3f..0e07b7c9 100644 --- a/patches/viabedrock/upstreamable/0030-propagate-increased-block-light-incrementally.patch +++ b/patches/viabedrock/upstreamable/0030-propagate-increased-block-light-incrementally.patch @@ -1,4 +1,4 @@ -From 667d434f62acaf6718f2a38454e802578b568e23 Mon Sep 17 00:00:00 2001 +From 06b6e0412a925b314f8b0afd94a8800b9c39f98e 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] perf(lighting): propagate increased block light incrementally @@ -478,7 +478,7 @@ index c0d73df3c..04637e823 100644 return Integer.parseInt(blockState.properties().getOrDefault(property, String.valueOf(fallback))); } catch (final NumberFormatException e) { 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 08fb252d0..f76a7cf03 100644 +index 5c7f11dcf..a59c1b55f 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java @@ -67,12 +67,17 @@ import net.raphimc.viabedrock.api.model.NativeBlockIdentifiers; @@ -528,7 +528,7 @@ index 08fb252d0..f76a7cf03 100644 this.chunkLight.remove(chunkKey); this.lightDirtyChunks.remove(chunkKey); this.lightVersions.remove(chunkKey); -@@ -483,8 +493,10 @@ public class ChunkTracker extends StoredObject { +@@ -488,8 +498,10 @@ public class ChunkTracker extends StoredObject { } final DataPalette palette = section.palettes(PaletteType.BLOCKS).get(layer); final int prevBlockState = palette.idAt(sectionX, sectionY, sectionZ); @@ -539,7 +539,7 @@ index 08fb252d0..f76a7cf03 100644 final String tag = blockStateRewriter.tag(blockState); int remappedBlockState = this.getJavaBlockState(blockPosition); -@@ -498,14 +510,14 @@ public class ChunkTracker extends StoredObject { +@@ -503,14 +515,14 @@ public class ChunkTracker extends StoredObject { this.sendNativeBlocks(new NativeBlockIdentifiers.Update(this.dimension.ordinal(), blockPosition, layer, blockState)); } this.invalidateJavaBlockStates(blockPosition.x() >> 4, blockPosition.y() >> 4, blockPosition.z() >> 4); @@ -561,7 +561,7 @@ index 08fb252d0..f76a7cf03 100644 if (CustomBlockTags.ITEM_FRAME.equals(tag)) { entityTracker.spawnItemFrame(blockPosition, blockStateRewriter.blockState(blockState)); -@@ -531,6 +543,87 @@ public class ChunkTracker extends StoredObject { +@@ -536,6 +548,87 @@ public class ChunkTracker extends StoredObject { return new IntObjectImmutablePair<>(remappedBlockState, null); } @@ -649,7 +649,7 @@ index 08fb252d0..f76a7cf03 100644 public BedrockChunkSection handleBlockPalette(final BedrockChunkSection section) { this.replaceLegacyBlocks(section); this.resolvePersistentIds(section); -@@ -812,17 +905,21 @@ public class ChunkTracker extends StoredObject { +@@ -817,17 +910,21 @@ public class ChunkTracker extends StoredObject { private ChunkLight computeChunkLight(final int chunkX, final int chunkZ) { final int[][][] regionStates = new int[9][][]; @@ -675,7 +675,7 @@ index 08fb252d0..f76a7cf03 100644 for (int dz = -1; dz <= 1; dz++) { for (int dx = -1; dx <= 1; dx++) { final BedrockChunk neighbor = this.chunks.get(ChunkPosition.chunkKey(chunkX + dx, chunkZ + dz)); -@@ -832,9 +929,10 @@ public class ChunkTracker extends StoredObject { +@@ -837,9 +934,10 @@ public class ChunkTracker extends StoredObject { final int chunkIndex = (dz + 1) * 3 + (dx + 1); regionStates[chunkIndex] = this.snapshotChunkStates(neighbor); @@ -687,7 +687,7 @@ index 08fb252d0..f76a7cf03 100644 } private int[][] snapshotChunkStates(final BedrockChunk chunk) { -@@ -845,27 +943,53 @@ public class ChunkTracker extends StoredObject { +@@ -850,27 +948,53 @@ public class ChunkTracker extends StoredObject { return states.clone(); } @@ -758,7 +758,7 @@ index 08fb252d0..f76a7cf03 100644 this.pendingLightComputations.remove(chunkKey); if (!this.user().getChannel().isActive()) { return; -@@ -895,7 +1019,6 @@ public class ChunkTracker extends StoredObject { +@@ -900,7 +1024,6 @@ public class ChunkTracker extends StoredObject { this.writeLightData(lightUpdate, lightData); lightUpdate.send(BedrockProtocol.class); }); @@ -766,7 +766,7 @@ index 08fb252d0..f76a7cf03 100644 } private int[][] getJavaBlockStates(final BedrockChunk chunk) { -@@ -913,13 +1036,12 @@ public class ChunkTracker extends StoredObject { +@@ -918,13 +1041,12 @@ public class ChunkTracker extends StoredObject { /** * Converts the bedrock block states of a chunk section into java block states, indexed with @@ -782,7 +782,7 @@ index 08fb252d0..f76a7cf03 100644 } final BlockStateRewriter blockStateRewriter = this.user().get(BlockStateRewriter.class); -@@ -929,6 +1051,10 @@ public class ChunkTracker extends StoredObject { +@@ -934,6 +1056,10 @@ public class ChunkTracker extends StoredObject { final int javaId = blockStateRewriter.javaId(layer0.idByIndex(i)); entryJavaIds[i] = javaId != -1 ? javaId : ProtocolConstants.JAVA_AIR_ID; } @@ -793,7 +793,7 @@ index 08fb252d0..f76a7cf03 100644 if (layer0.size() == 1) { if (entryJavaIds[0] != ProtocolConstants.JAVA_AIR_ID) { Arrays.fill(states, entryJavaIds[0]); -@@ -942,7 +1068,37 @@ public class ChunkTracker extends StoredObject { +@@ -947,7 +1073,37 @@ public class ChunkTracker extends StoredObject { } } } @@ -832,7 +832,7 @@ index 08fb252d0..f76a7cf03 100644 } private void invalidateJavaBlockStates(final int chunkX, final int subChunkY, final int chunkZ) { -@@ -954,6 +1110,10 @@ public class ChunkTracker extends StoredObject { +@@ -959,6 +1115,10 @@ public class ChunkTracker extends StoredObject { final int sectionIndex = subChunkY + Math.abs(this.minY >> 4); if (sectionIndex >= 0 && sectionIndex < chunkStates.length) { chunkStates[sectionIndex] = null; @@ -843,7 +843,7 @@ index 08fb252d0..f76a7cf03 100644 } } -@@ -1038,21 +1198,48 @@ public class ChunkTracker extends StoredObject { +@@ -1043,23 +1203,50 @@ public class ChunkTracker extends StoredObject { this.sendChunk(chunkPos.chunkX(), chunkPos.chunkZ()); } @@ -876,10 +876,10 @@ index 08fb252d0..f76a7cf03 100644 + this.pendingLightComputations.remove(chunkKey); + task.close(); + throw failure; -+ } -+ } -+ } -+ + } + } + } + + static OptionalLong takeLightUpdate(final LongSet dirty, final LongSet pending, final LongPredicate loaded) { + final var iterator = dirty.iterator(); + while (iterator.hasNext()) { @@ -892,15 +892,17 @@ index 08fb252d0..f76a7cf03 100644 + // Keep the newer column's refresh queued until that worker releases this key. + if (pending.contains(key)) { + continue; - } ++ } + iterator.remove(); + return OptionalLong.of(key); - } ++ } + return OptionalLong.empty(); - } - ++ } ++ private long distanceToCenterSquared(final int chunkX, final int chunkZ) { -@@ -1352,6 +1539,63 @@ public class ChunkTracker extends StoredObject { + final long dx = (long) chunkX - this.centerX; + final long dz = (long) chunkZ - this.centerZ; +@@ -1361,6 +1548,63 @@ public class ChunkTracker extends StoredObject { } } diff --git a/patches/viabedrock/upstreamable/0033-refresh-connected-door-states.patch b/patches/viabedrock/upstreamable/0033-refresh-connected-door-states.patch index d0f3c502..d059842d 100644 --- a/patches/viabedrock/upstreamable/0033-refresh-connected-door-states.patch +++ b/patches/viabedrock/upstreamable/0033-refresh-connected-door-states.patch @@ -1,4 +1,4 @@ -From 0a3cbc499f386a098eab1304d0beed75955387b5 Mon Sep 17 00:00:00 2001 +From e48d787a69a169345b97d418826c47e2c59a0ca5 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): refresh connected door states @@ -31,7 +31,7 @@ index abe6a2f99..7767b8f8a 100644 protocol.registerClientbound(ClientboundBedrockPackets.BLOCK_ENTITY_DATA, ClientboundPackets26_3.BLOCK_ENTITY_DATA, new PacketHandlers() { @Override 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 f76a7cf03..6694918f2 100644 +index a59c1b55f..3e6c27ff8 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java @@ -252,6 +252,12 @@ public class ChunkTracker extends StoredObject { @@ -68,7 +68,7 @@ index f76a7cf03..6694918f2 100644 public boolean isConnectedBlock(final BlockPosition position) { final BlockState state = this.getUnconnectedJavaBlockStateModel(position); return ConnectedBlockShapes.isConnected(state); -@@ -760,6 +780,28 @@ public class ChunkTracker extends StoredObject { +@@ -765,6 +785,28 @@ public class ChunkTracker extends StoredObject { candidates.forEach(this::sendConnectedUpdate); } @@ -97,7 +97,7 @@ index f76a7cf03..6694918f2 100644 private void refreshNeighboringConnectedBorders(final int chunkX, final int chunkZ) { this.refreshConnectedBorder(chunkX - 1, chunkZ, 15, 16, 0, 16); this.refreshConnectedBorder(chunkX + 1, chunkZ, 0, 1, 0, 16); -@@ -1363,15 +1405,17 @@ public class ChunkTracker extends StoredObject { +@@ -1372,15 +1414,17 @@ public class ChunkTracker extends StoredObject { } final int sectionY = this.minY + (idx << 4); diff --git a/patches/viabedrock/upstreamable/0034-acknowledge-block-placement-after-server-updates.patch b/patches/viabedrock/upstreamable/0034-acknowledge-block-placement-after-server-updates.patch index c880184c..ae581bf2 100644 --- a/patches/viabedrock/upstreamable/0034-acknowledge-block-placement-after-server-updates.patch +++ b/patches/viabedrock/upstreamable/0034-acknowledge-block-placement-after-server-updates.patch @@ -1,4 +1,4 @@ -From baae500bd534e01c6a8e49db396888e57fae467d Mon Sep 17 00:00:00 2001 +From 8d115010fad19314e7d90e469b7611fda14c8d23 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(interaction): acknowledge block placement after server @@ -136,7 +136,7 @@ index 000000000..59307abb2 + +} 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 4ced685e3..94521c248 100644 +index 7f7a70005..2483b18c6 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java @@ -52,6 +52,7 @@ import net.raphimc.viabedrock.api.chunk.section.BedrockChunkSection; @@ -155,7 +155,7 @@ index 4ced685e3..94521c248 100644 private int centerX = 0; private int centerZ = 0; -@@ -489,6 +491,16 @@ public class ChunkTracker extends StoredObject { +@@ -494,6 +496,16 @@ public class ChunkTracker extends StoredObject { return true; } @@ -172,7 +172,7 @@ index 4ced685e3..94521c248 100644 public IntObjectPair handleBlockChange(final BlockPosition blockPosition, final int layer, final int blockState) { final BedrockChunkSection section = this.getChunkSection(blockPosition); if (section == null) { -@@ -1195,6 +1207,10 @@ public class ChunkTracker extends StoredObject { +@@ -1200,6 +1212,10 @@ public class ChunkTracker extends StoredObject { } public void tick() { diff --git a/patches/viabedrock/upstreamable/0045-translate-native-block-events-and-sounds.patch b/patches/viabedrock/upstreamable/0045-translate-native-block-events-and-sounds.patch index d78fd376..dc20b917 100644 --- a/patches/viabedrock/upstreamable/0045-translate-native-block-events-and-sounds.patch +++ b/patches/viabedrock/upstreamable/0045-translate-native-block-events-and-sounds.patch @@ -1,4 +1,4 @@ -From b4651fc67fed99bbf460c306d953673e01ddad76 Mon Sep 17 00:00:00 2001 +From 65498a39e29b216834a7721d289a0c1ea4bd5f71 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(sound): resolve shared Bedrock sounds by event @@ -11,25 +11,12 @@ source sound mappings unchanged for other packet types. Validate the override entries against the loaded Bedrock events and Java sound registry so stale mappings fail during startup. --- - build.gradle | 1 + .../protocol/data/BedrockMappingData.java | 38 +++++++++++++++++++ .../protocol/packet/WorldEffectPackets.java | 2 +- .../data/custom/sound_event_overrides.json | 17 +++++++++ - 4 files changed, 57 insertions(+), 1 deletion(-) + 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 src/main/resources/assets/viabedrock/data/custom/sound_event_overrides.json -diff --git a/build.gradle b/build.gradle -index d493a516d..7b696c370 100644 ---- a/build.gradle -+++ b/build.gradle -@@ -73,6 +73,7 @@ repositories { - } - - dependencies { -+ testImplementation "org.mockito:mockito-core:5.24.0" - compileOnly "com.viaversion:viaversion-common:5.12.0" - compileOnly "org.yaml:snakeyaml:2.7" - compileOnly "com.google.guava:guava:33.7.2-jre" diff --git a/src/main/java/net/raphimc/viabedrock/protocol/data/BedrockMappingData.java b/src/main/java/net/raphimc/viabedrock/protocol/data/BedrockMappingData.java index 04637e823..4ec82372b 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/data/BedrockMappingData.java diff --git a/patches/viabedrock/upstreamable/0046-refresh-bed-colors-on-repeated-block-updates.patch b/patches/viabedrock/upstreamable/0046-refresh-bed-colors-on-repeated-block-updates.patch index 360187a1..64ef53b6 100644 --- a/patches/viabedrock/upstreamable/0046-refresh-bed-colors-on-repeated-block-updates.patch +++ b/patches/viabedrock/upstreamable/0046-refresh-bed-colors-on-repeated-block-updates.patch @@ -1,4 +1,4 @@ -From 12e30a9ab2abe23fbac70014f7396e5892dc8349 Mon Sep 17 00:00:00 2001 +From 01fe48fc676c5a0c02457ef476fca701748d1219 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): refresh bed colors on repeated block updates @@ -9,10 +9,10 @@ A bed block entity can change color while its base Bedrock runtime ID stays the 1 file changed, 18 insertions(+), 15 deletions(-) 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 6694918f2..4ced685e3 100644 +index 3e6c27ff8..7f7a70005 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java -@@ -525,7 +525,8 @@ public class ChunkTracker extends StoredObject { +@@ -530,7 +530,8 @@ public class ChunkTracker extends StoredObject { entityTracker.removeItemFrame(blockPosition); } @@ -22,7 +22,7 @@ index 6694918f2..4ced685e3 100644 if (layer <= 1 && this.nativeBlocksEnabled()) { this.sendNativeBlocks(new NativeBlockIdentifiers.Update(this.dimension.ordinal(), blockPosition, layer, blockState)); } -@@ -541,22 +542,24 @@ public class ChunkTracker extends StoredObject { +@@ -546,22 +547,24 @@ public class ChunkTracker extends StoredObject { if (CustomBlockTags.ITEM_FRAME.equals(tag)) { entityTracker.spawnItemFrame(blockPosition, blockStateRewriter.blockState(blockState)); 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 356343b2..51f599c5 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 c658e052d216c2ac7f31150a02ef6bdee671c40e Mon Sep 17 00:00:00 2001 +From 6b6129656d4a8b7a044d8c1d288ea0e209bf0b65 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 @@ -314,6 +314,32 @@ ordinary folded block lookup. The 58 installed builtin groups come from the original target initializer and registry installation routines. Compare canonical registered identities in Java; native pointer-hash collisions and dynamic native factory installation remain unverified. + +Expand reserved visuals onto unused powered door states and mushroom face +combinations after the existing note and wire carriers. Keep occupied +vanilla states excluded and preserve all unassigned Java selectors, +including waxed copper model aliases. Do not apply upper-door connection +rewrites to custom carriers. Continue sharing visuals after capacity is +reached and report any remaining exhaustion explicitly. + +The private protocol-2193 Galaxite recording declares 3,661 custom states. +The previous 2,629-carrier limit left 734 states mapped to coarse dirt. +The expanded 3,500-carrier pool maps every state onto 3,340 visuals. +Independent comparisons against Java 26.3 assets verify 4,246 selectors +across 27 block types, including 701 custom assignments. + +Resolve block geometry by identifier even when stored below models/entity +or another models directory. Preserve the known local transforms of an +orphan bone, while rejecting cyclic and duplicate hierarchies. This +repairs 88 orphan states and one shared-geometry lookup in the recording; +the complete native orphan-loading path remains unverified. + +Retain authored vertices outside Java's cuboid range using an explicit +extended-bounds annotation consumed by the client add-on. Never crop or +fit block geometry to an item. Reject non-finite coordinates or values +beyond the bounded 64-block model envelope. Eight recorded fancy-table +states require this extension; clients without the add-on retain Java's +loader limitations. Broader matched native visual parity remains open. --- Data Asset Sources.md | 27 + .../chunk/blockstate/BlockItemDescriptor.java | 29 + @@ -340,13 +366,13 @@ collisions and dynamic native factory installation remain unverified. .../protocol/packet/JoinPackets.java | 31 +- .../protocol/packet/ResourcePackPackets.java | 47 +- .../protocol/packet/VoxelShapePackets.java | 35 + - .../protocol/rewriter/BlockStateRewriter.java | 228 ++-- + .../protocol/rewriter/BlockStateRewriter.java | 231 ++-- .../rewriter/ResourcePackRewriter.java | 2 + .../CustomBlockCullingCompiler.java | 162 +++ - .../resourcepack/CustomBlockModel.java | 565 +++++++++ - .../CustomBlockResourceRewriter.java | 233 ++++ - .../CustomBlockStateDefinitions.java | 153 +++ - .../protocol/storage/ChunkTracker.java | 33 +- + .../resourcepack/CustomBlockModel.java | 587 +++++++++ + .../CustomBlockResourceRewriter.java | 237 ++++ + .../CustomBlockStateDefinitions.java | 225 ++++ + .../protocol/storage/ChunkTracker.java | 40 +- .../storage/CustomBlockPackStorage.java | 37 + .../protocol/storage/ResourcePackStorage.java | 44 +- .../protocol/storage/VoxelShapeRegistry.java | 125 ++ @@ -368,17 +394,17 @@ collisions and dynamic native factory installation remain unverified. .../protocol/model/BlockPropertiesTest.java | 85 ++ .../protocol/model/BlockTraitStatesTest.java | 291 +++++ .../protocol/model/CustomBlockStatesTest.java | 139 ++ - .../rewriter/CustomBlockBoneGeometryTest.java | 177 +++ + .../rewriter/CustomBlockBoneGeometryTest.java | 213 ++++ .../CustomBlockModelSelectionTest.java | 512 ++++++++ .../CustomBlockPlaneGeometryTest.java | 170 +++ .../CustomBlockCullingCompilerTest.java | 140 +++ .../CustomBlockMaterialVisibilityTest.java | 106 ++ - .../CustomBlockStateDefinitionsTest.java | 218 ++++ + .../CustomBlockStateDefinitionsTest.java | 286 +++++ .../protocol/storage/ChunkTrackerTest.java | 22 + .../storage/CustomBlockPackStorageTest.java | 31 + .../storage/VoxelShapeRegistryTest.java | 130 ++ .../storage/WorldPacketBufferTest.java | 230 ++++ - 63 files changed, 9239 insertions(+), 109 deletions(-) + 63 files changed, 9451 insertions(+), 109 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 @@ -1909,7 +1935,7 @@ index 000000000..2cac120f6 + +} diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/definition/ModelDefinitions.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/definition/ModelDefinitions.java -index d267ff7fe..f4e91c4de 100644 +index d267ff7fe..6f116c12c 100644 --- a/src/main/java/net/raphimc/viabedrock/api/resourcepack/definition/ModelDefinitions.java +++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/definition/ModelDefinitions.java @@ -31,6 +31,7 @@ import java.util.logging.Level; @@ -1920,15 +1946,15 @@ index d267ff7fe..f4e91c4de 100644 public ModelDefinitions(final ResourcePackStorage resourcePackStorage) { for (ResourcePack pack : resourcePackStorage.getPackStackBottomToTop()) { -@@ -39,6 +40,8 @@ public class ModelDefinitions { - for (BedrockGeometryModel bedrockGeometry : BedrockGeometryParser.parse(pack.content().getString(modelPath))) { +@@ -40,6 +41,8 @@ public class ModelDefinitions { if (modelPath.startsWith("models/entity/")) { this.entityModels.put(bedrockGeometry.getIdentifier(), bedrockGeometry); -+ } else if (modelPath.startsWith("models/blocks/") || modelPath.startsWith("models/block/")) { -+ this.blockModels.put(bedrockGeometry.getIdentifier(), bedrockGeometry); } ++ // Blocks resolve geometry by identifier, including models stored with entities. ++ this.blockModels.put(bedrockGeometry.getIdentifier(), bedrockGeometry); } } catch (final Throwable e) { + ViaBedrock.getPlatform().getLogger().log(Level.WARNING, "Failed to parse model definition " + modelPath + " in pack " + pack.key(), e); @@ -56,4 +59,8 @@ public class ModelDefinitions { return Collections.unmodifiableMap(this.entityModels); } @@ -2651,7 +2677,7 @@ index 000000000..9c9d66373 + +} 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 2cb4a79b6..095ee51fd 100644 +index 10854c61a..51681e3de 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,31 @@ @@ -2721,7 +2747,7 @@ index 2cb4a79b6..095ee51fd 100644 for (BlockProperties blockProperty : blockProperties) { final String identifier = blockProperty.name().toLowerCase(Locale.ROOT); if (bedrockBlockIdentifiers.contains(identifier)) { -@@ -67,102 +81,90 @@ public class BlockStateRewriter implements StorableObject { +@@ -67,102 +81,95 @@ public class BlockStateRewriter implements StorableObject { } if (!effectiveBlockProperties.containsKey(identifier)) { @@ -2814,17 +2840,16 @@ index 2cb4a79b6..095ee51fd 100644 + ViaBedrock.getPlatform().getLogger().log(Level.WARNING, "Failed to compile custom block " + entry.getKey(), e); + } + } ++ int exhaustedStates = 0; + for (BedrockBlockState customState : orderedCustomStates) { -+ if (visuals.size() == carriers.size()) { -+ break; -+ } + try { + final CustomBlockDefinition definition = compiledDefinitions.get(customState.namespacedIdentifier()); + if (definition == null) { + continue; + } + final CompoundTag components = definition.resolve(customState.blockStateTag().getCompoundTag("states")); -+ final int carrier = carriers.get(visuals.size()); ++ // 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); + if (visual != null) { + final VisualKey key = new VisualKey(visual.textures(), @@ -2833,11 +2858,13 @@ index 2cb4a79b6..095ee51fd 100644 + final Integer sharedCarrier = sharedVisuals.get(key); + if (sharedCarrier != null) { + customJavaStates.put(customState, sharedCarrier); - } else { -- throw new RuntimeException("Missing enabled_states tag for trait " + name); ++ } else if (visuals.size() < carriers.size()) { + visuals.put(carrier, visual); + sharedVisuals.put(key, carrier); + customJavaStates.put(customState, carrier); + } else { +- throw new RuntimeException("Missing enabled_states tag for trait " + name); ++ exhaustedStates++; } } + } catch (final Exception e) { @@ -2864,7 +2891,10 @@ index 2cb4a79b6..095ee51fd 100644 - blockStateTag.putInt("network_id", BlockStateHasher.hash(blockStateTag)); - - customBlockStates.add(BedrockBlockState.fromNbt(blockStateTag)); -- } ++ if (exhaustedStates != 0) { ++ ViaBedrock.getPlatform().getLogger().warning("Custom block carrier capacity exhausted: " + exhaustedStates ++ + " states could not be assigned after filling " + carriers.size() + " carriers"); + } + resourcePackStorage.setCustomBlockVisuals(visuals); + final Map stateMappings = new LinkedHashMap<>(); + customJavaStates.forEach((state, carrier) -> stateMappings.put(state.toBlockStateString(), carrier)); @@ -2889,7 +2919,7 @@ index 2cb4a79b6..095ee51fd 100644 this.validBlockStates.computeIfAbsent(bedrockBlockState.namespacedIdentifier(), k -> new IntLinkedOpenHashSet()).add(bedrockId); if (blockTags.containsKey(bedrockBlockState.namespacedIdentifier())) { -@@ -172,6 +174,8 @@ public class BlockStateRewriter implements StorableObject { +@@ -172,6 +179,8 @@ public class BlockStateRewriter implements StorableObject { if (bedrockToJavaBlockStates.containsKey(bedrockBlockState)) { final int javaId = javaBlockStates.get(bedrockToJavaBlockStates.get(bedrockBlockState)); this.blockStateIdMappings.put(bedrockId, javaId); @@ -2898,7 +2928,7 @@ index 2cb4a79b6..095ee51fd 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,10 +190,21 @@ public class BlockStateRewriter implements StorableObject { +@@ -186,10 +195,21 @@ 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. } @@ -2921,7 +2951,7 @@ index 2cb4a79b6..095ee51fd 100644 } public int bedrockId(final CompoundTag bedrockBlockStateTag) { -@@ -206,11 +221,37 @@ public class BlockStateRewriter implements StorableObject { +@@ -206,11 +226,37 @@ public class BlockStateRewriter implements StorableObject { } public int bedrockId(final BlockState bedrockBlockState) { @@ -2961,8 +2991,8 @@ index 2cb4a79b6..095ee51fd 100644 } public int bedrockId(final int legacyBlockStateId) { -@@ -221,6 +262,15 @@ public class BlockStateRewriter implements StorableObject { - return this.blockStateIdMappings.get(bedrockBlockStateId); +@@ -233,6 +279,15 @@ public class BlockStateRewriter implements StorableObject { + }; } + public void disableCustomBlocks(final ResourcePackStorage storage) { @@ -2977,7 +3007,7 @@ index 2cb4a79b6..095ee51fd 100644 public int waterlog(final int javaBlockStateId) { if (BedrockProtocol.MAPPINGS.getJavaPreWaterloggedBlockStates().contains(javaBlockStateId)) { return javaBlockStateId; -@@ -238,4 +288,8 @@ public class BlockStateRewriter implements StorableObject { +@@ -250,4 +305,8 @@ public class BlockStateRewriter implements StorableObject { return this.blockStateTags.get(bedrockBlockStateId); } @@ -3176,10 +3206,10 @@ index 000000000..5a7981fc9 +} 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 000000000..0b3d11dc3 +index 000000000..f0a19728a --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/resourcepack/CustomBlockModel.java -@@ -0,0 +1,565 @@ +@@ -0,0 +1,587 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -3212,6 +3242,9 @@ index 000000000..0b3d11dc3 +/** Compiles static Bedrock block components in block units, without item-display fitting. */ +public final class CustomBlockModel { + ++ public static final String EXTENDED_BOUNDS = "viabedrock_extended_bounds"; ++ public static final float MAX_COORDINATE = 1024F; ++ + private CustomBlockModel() { + } + @@ -3319,6 +3352,7 @@ index 000000000..0b3d11dc3 + transformModel(model, transformation); + applyFaceMaterials(model, faceLighting, directionMethods); + omitInvisibleFaces(model, visibleSprites); ++ markExtendedBounds(model); + final boolean occluding = fullCube && opaque && transformation.identity(); + return new CustomBlockVisual(state, javaState, textureBindings.values().iterator().next(), JsonUtil.sort(model, Comparator.naturalOrder()).toString(), renderMethod, + boxes(components.getCompoundTag("minecraft:collision_box"), false, transformation), @@ -3328,6 +3362,25 @@ index 000000000..0b3d11dc3 + byteLight(components, "minecraft:light_dampening", "lightLevel", 15)), textureBindings, textureMethods, CustomBlockOffset.fromComponent(components.getCompoundTag("minecraft:random_offset"))); + } + ++ private static void markExtendedBounds(final JsonObject model) { ++ for (JsonElement value : model.getAsJsonArray("elements")) { ++ final JsonObject element = value.getAsJsonObject(); ++ boolean extended = false; ++ for (String key : List.of("from", "to")) { ++ for (JsonElement coordinate : element.getAsJsonArray(key)) { ++ final double number = coordinate.getAsDouble(); ++ if (!Double.isFinite(number) || Math.abs(number) > MAX_COORDINATE) { ++ throw new IllegalArgumentException("Custom block geometry exceeds supported bounds"); ++ } ++ extended |= number < -16 || number > 32; ++ } ++ } ++ if (extended) { ++ element.addProperty(EXTENDED_BOUNDS, true); ++ } ++ } ++ } ++ + private static void omitInvisibleFaces(final JsonObject model, final Map visibleSprites) { + final JsonObject textures = model.getAsJsonObject("textures"); + final JsonArray elements = new JsonArray(); @@ -3483,9 +3536,8 @@ index 000000000..0b3d11dc3 + break; + } + bone = bones.get(bone.getParent()); -+ if (bone == null) { -+ throw new IllegalArgumentException("Missing geometry parent bone"); -+ } ++ // Exported packs can retain a parent name after removing that bone. ++ // The remaining chain still supplies its own model-space transforms. + } + } + return model; @@ -3747,10 +3799,10 @@ index 000000000..0b3d11dc3 +} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/resourcepack/CustomBlockResourceRewriter.java b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/resourcepack/CustomBlockResourceRewriter.java new file mode 100644 -index 000000000..08f58f84a +index 000000000..085f9fef4 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/resourcepack/CustomBlockResourceRewriter.java -@@ -0,0 +1,233 @@ +@@ -0,0 +1,237 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2023-2026 RK_01/RaphiMC and contributors @@ -3823,7 +3875,11 @@ index 000000000..08f58f84a + return BedrockProtocol.MAPPINGS.getJavaBlockStates().entrySet().stream() + .filter(entry -> CustomBlockStateDefinitions.carrier(entry.getKey())) + .filter(entry -> !occupied.contains(entry.getKey())) -+ .sorted(Comparator., Boolean>comparing(entry -> !entry.getKey().namespacedIdentifier().equals("minecraft:note_block")) ++ .sorted(Comparator.>comparingInt(entry -> switch (entry.getKey().namespacedIdentifier()) { ++ case "minecraft:note_block" -> 0; ++ case "minecraft:redstone_wire" -> 1; ++ default -> 2; ++ }) + .thenComparingInt(Map.Entry::getValue)) + .map(Map.Entry::getValue).toList(); + } @@ -3986,10 +4042,10 @@ index 000000000..08f58f84a +} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/resourcepack/CustomBlockStateDefinitions.java b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/resourcepack/CustomBlockStateDefinitions.java new file mode 100644 -index 000000000..57bfb204d +index 000000000..a063da135 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/resourcepack/CustomBlockStateDefinitions.java -@@ -0,0 +1,153 @@ +@@ -0,0 +1,225 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -4014,7 +4070,13 @@ index 000000000..57bfb204d + } + + static boolean carrier(final BlockState state) { -+ return state.namespacedIdentifier().equals("minecraft:note_block") || state.namespacedIdentifier().equals("minecraft:redstone_wire"); ++ return state.namespacedIdentifier().equals("minecraft:note_block") || state.namespacedIdentifier().equals("minecraft:redstone_wire") ++ || state.identifier().endsWith("_door") && state.hasProperty("powered", "true") ++ || mushroom(state); ++ } ++ ++ private static boolean mushroom(final BlockState state) { ++ return List.of("mushroom_stem", "red_mushroom_block", "brown_mushroom_block").contains(state.identifier()); + } + + static Map create(final Map states, final Map assignments) { @@ -4022,6 +4084,10 @@ index 000000000..57bfb204d + final JsonObject variants = new JsonObject(); + final List vanillaWire = new ArrayList<>(); + final Map customWire = new LinkedHashMap<>(); ++ final Map doors = new LinkedHashMap<>(); ++ final var assignedDoors = states.entrySet().stream() ++ .filter(entry -> entry.getKey().identifier().endsWith("_door") && assignments.containsKey(entry.getValue())) ++ .map(entry -> entry.getKey().identifier()).collect(java.util.stream.Collectors.toSet()); + for (Map.Entry state : states.entrySet()) { + if (state.getKey().namespacedIdentifier().equals("minecraft:note_block")) { + final String serialized = state.getKey().toBlockStateString(); @@ -4034,8 +4100,36 @@ index 000000000..57bfb204d + } else { + customWire.put(state.getKey(), custom); + } ++ } else if (assignedDoors.contains(state.getKey().identifier())) { ++ final BlockState door = state.getKey(); ++ final JsonObject doorVariants = doors.computeIfAbsent(door.identifier(), ignored -> new JsonObject()); ++ final String serialized = door.toBlockStateString(); ++ final String selector = serialized.substring(serialized.indexOf('[') + 1, serialized.length() - 1); ++ final String custom = assignments.get(state.getValue()); ++ if (custom != null) { ++ doorVariants.add(selector, model(custom, 0)); ++ } else { ++ final boolean open = door.hasProperty("open", "true"); ++ final boolean right = door.hasProperty("hinge", "right"); ++ final int facing = switch (door.properties().get("facing")) { ++ case "east" -> 0; ++ case "south" -> 90; ++ case "west" -> 180; ++ case "north" -> 270; ++ default -> throw new IllegalArgumentException("Unknown door facing"); ++ }; ++ final String name = "minecraft:block/" + (door.identifier().startsWith("waxed_") ? door.identifier().substring(6) : door.identifier()) ++ + (door.hasProperty("half", "upper") ? "_top" : "_bottom") ++ + (right ? "_right" : "_left") + (open ? "_open" : ""); ++ doorVariants.add(selector, model(name, Math.floorMod(facing + (open ? right ? -90 : 90 : 0), 360))); ++ } + } + } ++ doors.forEach((name, doorVariants) -> { ++ final JsonObject definition = new JsonObject(); ++ definition.add("variants", doorVariants); ++ result.put(name, definition); ++ }); + if (!variants.isEmpty()) { + final JsonObject note = new JsonObject(); + note.add("variants", variants); @@ -4067,6 +4161,40 @@ index 000000000..57bfb204d + wire.add("multipart", parts); + result.put("redstone_wire", wire); + } ++ states.keySet().stream().filter(CustomBlockStateDefinitions::mushroom).map(BlockState::identifier).distinct().forEach(name -> { ++ final var blockStates = states.entrySet().stream().filter(entry -> entry.getKey().identifier().equals(name)).toList(); ++ if (blockStates.stream().noneMatch(entry -> assignments.containsKey(entry.getValue()))) { ++ return; ++ } ++ final JsonArray parts = new JsonArray(); ++ final JsonObject vanilla = condition(blockStates.stream().filter(entry -> !assignments.containsKey(entry.getValue())).map(Map.Entry::getKey).toList()); ++ if (vanilla != null) { ++ final List directions = List.of("north", "east", "south", "west", "up", "down"); ++ for (int index = 0; index < directions.size(); index++) { ++ for (boolean outside : List.of(false, true)) { ++ final JsonObject face = part(and(vanilla, properties(Map.of(directions.get(index), Boolean.toString(outside)))), ++ "minecraft:block/" + (outside ? name : "mushroom_block_inside"), index < 4 ? index * 90 : 0); ++ final JsonObject applied = face.getAsJsonObject("apply"); ++ if (index >= 4) { ++ applied.addProperty("x", index == 4 ? 270 : 90); ++ } ++ if (outside && index != 0) { ++ applied.addProperty("uvlock", true); ++ } ++ parts.add(face); ++ } ++ } ++ } ++ blockStates.forEach(entry -> { ++ final String custom = assignments.get(entry.getValue()); ++ if (custom != null) { ++ parts.add(part(properties(entry.getKey().properties()), custom, 0)); ++ } ++ }); ++ final JsonObject definition = new JsonObject(); ++ definition.add("multipart", parts); ++ result.put(name, definition); ++ }); + return result; + } + @@ -4144,7 +4272,7 @@ index 000000000..57bfb204d + +} 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 94521c248..6b7b9b0ec 100644 +index 2483b18c6..b0b0a4e9c 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java @@ -52,6 +52,7 @@ import net.raphimc.viabedrock.api.chunk.section.BedrockChunkSection; @@ -4183,7 +4311,28 @@ index 94521c248..6b7b9b0ec 100644 final GameSessionStorage gameSession = user.get(GameSessionStorage.class); final CompoundTag registries = gameSession.getJavaRegistries(); final String dimensionKey = this.dimension.getKey(); -@@ -309,7 +317,7 @@ public class ChunkTracker extends StoredObject { +@@ -255,6 +263,10 @@ public class ChunkTracker extends StoredObject { + final int javaId = this.getUnconnectedJavaBlockState(blockPosition); + final BlockState state = BedrockProtocol.MAPPINGS.getJavaBlockStates().inverse().get(javaId); + if (isUpperDoor(state)) { ++ final ResourcePackStorage resourcePacks = this.user().get(ResourcePackStorage.class); ++ if (resourcePacks != null && resourcePacks.getCustomBlockVisuals().containsKey(javaId)) { ++ return javaId; ++ } + final BlockPosition lowerPosition = new BlockPosition(blockPosition.x(), blockPosition.y() - 1, blockPosition.z()); + final BlockState lowerState = this.getUnconnectedJavaBlockStateModel(lowerPosition); + final BlockState connected = connectUpperDoor(state, lowerState); +@@ -275,7 +287,8 @@ public class ChunkTracker extends StoredObject { + static BlockState connectUpperDoor(final BlockState upper, final BlockState lower) { + if (!isUpperDoor(upper) || lower == null || !lower.hasProperty("half", "lower") + || !lower.properties().containsKey("open") +- || !upper.namespacedIdentifier().equals(lower.namespacedIdentifier())) { ++ || !upper.namespacedIdentifier().equals(lower.namespacedIdentifier()) ++ || !Objects.equals(upper.properties().get("powered"), lower.properties().get("powered"))) { + return upper; + } + // Bedrock's upper half can retain a closed open_bit while the lower half opens. +@@ -309,7 +322,7 @@ public class ChunkTracker extends StoredObject { return false; } final Integer javaId = BedrockProtocol.MAPPINGS.getJavaBlockStates().get(state); @@ -4192,7 +4341,7 @@ index 94521c248..6b7b9b0ec 100644 } public int getJavaBlockState(final BedrockChunkSection section, final int sectionX, final int sectionY, final int sectionZ) { -@@ -548,7 +556,7 @@ public class ChunkTracker extends StoredObject { +@@ -553,7 +566,7 @@ public class ChunkTracker extends StoredObject { // still changes filtering, and both adding and removing it require a relight. this.invalidateLightRegion(blockPosition.x() >> 4, blockPosition.z() >> 4); } else if (changesLight(previousJavaState, currentJavaState, @@ -4201,7 +4350,7 @@ index 94521c248..6b7b9b0ec 100644 this.updateLightAfterBlockChange(blockPosition, previousJavaState, currentJavaState); } -@@ -578,6 +586,19 @@ public class ChunkTracker extends StoredObject { +@@ -583,6 +596,19 @@ public class ChunkTracker extends StoredObject { return new IntObjectImmutablePair<>(remappedBlockState, null); } @@ -4221,7 +4370,7 @@ index 94521c248..6b7b9b0ec 100644 static boolean changesLight(final int previousState, final int currentState, final byte[] emission, final byte[] opacity) { return emission[previousState] != emission[currentState] || opacity[previousState] != opacity[currentState]; } -@@ -585,8 +606,8 @@ public class ChunkTracker extends StoredObject { +@@ -590,8 +616,8 @@ public class ChunkTracker extends StoredObject { private void updateLightAfterBlockChange(final BlockPosition position, final int previousState, final int currentState) { final int chunkX = position.x() >> 4; final int chunkZ = position.z() >> 4; @@ -4232,7 +4381,7 @@ index 94521c248..6b7b9b0ec 100644 final int previousEmission = emission[previousState] & 0xFF; final int currentEmission = emission[currentState] & 0xFF; if (opacity[previousState] != opacity[currentState] || currentEmission <= previousEmission) { -@@ -967,7 +988,7 @@ public class ChunkTracker extends StoredObject { +@@ -972,7 +998,7 @@ public class ChunkTracker extends StoredObject { final BitSet[][] secondaryWater = new BitSet[9][]; secondaryWater[4] = this.snapshotSecondaryWater(chunk); return LightEngine.computeCenterLight(regionStates, secondaryWater, this.worldHeight >> 4, this.skyLight, @@ -4241,7 +4390,7 @@ index 94521c248..6b7b9b0ec 100644 } /** -@@ -1046,7 +1067,7 @@ public class ChunkTracker extends StoredObject { +@@ -1051,7 +1077,7 @@ public class ChunkTracker extends StoredObject { this.pendingLightComputations.add(chunkKey); task.execute(LIGHT_EXECUTOR, this.user().getChannel().eventLoop(), () -> LightEngine.computeCenterLight(snapshot.states(), snapshot.secondaryWater(), this.worldHeight >> 4, this.skyLight, @@ -8776,10 +8925,10 @@ index 000000000..a35a91430 +} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/rewriter/CustomBlockBoneGeometryTest.java b/src/test/java/net/raphimc/viabedrock/protocol/rewriter/CustomBlockBoneGeometryTest.java new file mode 100644 -index 000000000..a347e5aa6 +index 000000000..61c3a60cb --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/protocol/rewriter/CustomBlockBoneGeometryTest.java -@@ -0,0 +1,177 @@ +@@ -0,0 +1,213 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -8903,12 +9052,44 @@ index 000000000..a347e5aa6 + } + + @Test -+ void rejectsInvalidBoneHierarchiesWithoutDroppingTransforms() { -+ for (String parents : List.of("\"parent\":\"root\"", "\"parent\":\"missing\"")) { -+ final ResourcePackStorage storage = storage("{\"name\":\"root\"," + parents -+ + ",\"cubes\":[{\"origin\":[0,0,0],\"size\":[1,1,1],\"uv\":[0,0]}]}"); -+ assertThrows(IllegalArgumentException.class, () -> compile(storage)); ++ void preservesOrphanTransformsAndStillRejectsCycles() { ++ final String cube = "\"pivot\":[1,0,0],\"rotation\":[0,90,0]," ++ + "\"cubes\":[{\"origin\":[0,0,0],\"size\":[2,4,6],\"uv\":[0,0]}]}"; ++ final var orphan = storage("{\"name\":\"child\",\"parent\":\"removed\"," + cube); ++ final var root = storage("{\"name\":\"child\"," + cube); ++ assertEquals(compile(root), compile(orphan)); ++ for (String bones : List.of("{\"name\":\"child\",\"parent\":\"child\"," + cube, ++ "{\"name\":\"parent\",\"parent\":\"child\"},{\"name\":\"child\",\"parent\":\"parent\"," + cube)) { ++ assertThrows(IllegalArgumentException.class, () -> compile(storage(bones))); ++ } ++ } ++ ++ @Test ++ void resolvesBlockGeometryFromEntityAndSharedModelDirectories() { ++ final String bones = "{\"name\":\"root\",\"cubes\":[{\"origin\":[-4,0,-4],\"size\":[8,8,8],\"uv\":[0,0]}]}"; ++ final var expected = compile(storage(bones)); ++ for (String path : List.of("models/entity/test.json", "models/shared/test.json")) { ++ assertEquals(expected, compile(storage(bones, path))); ++ } ++ } ++ ++ @Test ++ void marksExtendedCoordinatesWithoutCroppingOrScalingTheAuthoredGeometry() { ++ final var model = compile(storage(""" ++ {"name":"root","cubes":[{"origin":[-4,2,-25],"size":[8,3,50],"uv":[0,0]}]} ++ """)); ++ final var element = model.getAsJsonArray("elements").get(0).getAsJsonObject(); ++ assertTrue(element.get(CustomBlockModel.EXTENDED_BOUNDS).getAsBoolean()); ++ final double[][] expected = {{4, 2, -17}, {12, 5, 33}}; ++ for (int bound = 0; bound < 2; bound++) { ++ final var coordinates = element.getAsJsonArray(bound == 0 ? "from" : "to"); ++ for (int axis = 0; axis < 3; axis++) { ++ assertEquals(expected[bound][axis], coordinates.get(axis).getAsDouble(), 1E-5); ++ } + } ++ assertThrows(IllegalArgumentException.class, () -> compile(storage(""" ++ {"name":"root","cubes":[{"origin":[0,0,0],"size":[4096,1,1],"uv":[0,0]}]} ++ """))); + } + + private static double[] rotated(final JsonObject rotation, final double x, final double y, final double z) { @@ -8948,11 +9129,15 @@ index 000000000..a347e5aa6 + } + + private static ResourcePackStorage storage(final String bones) { ++ return storage(bones, "models/blocks/test.json"); ++ } ++ ++ private static ResourcePackStorage storage(final String bones, final String modelPath) { + final InMemoryContent content = new InMemoryContent(); + content.putString("manifest.json", "{\"format_version\":3,\"header\":{\"uuid\":\"" + UUID.randomUUID() + "\",\"version\":\"1.0.0\",\"name\":\"test\"}}"); + content.putString("textures/terrain_texture.json", "{\"texture_data\":{\"skin\":{\"textures\":\"textures/blocks/skin\"}}}"); + content.putPngImage("textures/blocks/skin.png", new BufferedImage(64, 64, BufferedImage.TYPE_INT_RGB)); -+ content.putString("models/blocks/test.json", "{\"format_version\":\"1.12.0\",\"minecraft:geometry\":[{\"description\":{\"identifier\":\"geometry.test\",\"texture_width\":64,\"texture_height\":64},\"bones\":[" + bones + "]}]}"); ++ content.putString(modelPath, "{\"format_version\":\"1.12.0\",\"minecraft:geometry\":[{\"description\":{\"identifier\":\"geometry.test\",\"texture_width\":64,\"texture_height\":64},\"bones\":[" + bones + "]}]}"); + return new ResourcePackStorage(List.of(new ResourcePack(content))); + } + @@ -9911,10 +10096,10 @@ index 000000000..93b635019 +} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/rewriter/resourcepack/CustomBlockStateDefinitionsTest.java b/src/test/java/net/raphimc/viabedrock/protocol/rewriter/resourcepack/CustomBlockStateDefinitionsTest.java new file mode 100644 -index 000000000..54c570e4c +index 000000000..7fe228fa9 --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/protocol/rewriter/resourcepack/CustomBlockStateDefinitionsTest.java -@@ -0,0 +1,218 @@ +@@ -0,0 +1,286 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -10004,6 +10189,74 @@ index 000000000..54c570e4c + assertFalse(CustomBlockStateDefinitions.carrier(stone)); + } + ++ @Test ++ void reservesOnlyPoweredDoorsAndPreservesEveryUnassignedDoorVariant() { ++ final Map states = new LinkedHashMap<>(); ++ for (String facing : List.of("east", "south", "west", "north")) { ++ for (String half : List.of("lower", "upper")) { ++ for (String hinge : List.of("left", "right")) { ++ for (String open : List.of("false", "true")) { ++ for (String powered : List.of("false", "true")) { ++ states.put(new BlockState("oak_door", Map.of("facing", facing, "half", half, ++ "hinge", hinge, "open", open, "powered", powered)), states.size()); ++ } ++ } ++ } ++ } ++ } ++ final Map assignments = new LinkedHashMap<>(); ++ states.forEach((state, id) -> { ++ assertEquals(state.hasProperty("powered", "true"), CustomBlockStateDefinitions.carrier(state)); ++ if (state.hasProperty("powered", "true") && id % 5 == 0) { ++ assignments.put(id, "test:custom/" + id); ++ } ++ }); ++ assertTrue(CustomBlockStateDefinitions.create(states, Map.of()).isEmpty()); ++ final var variants = CustomBlockStateDefinitions.create(states, assignments).get("oak_door").getAsJsonObject("variants"); ++ assertEquals(states.size(), variants.size()); ++ states.forEach((state, id) -> { ++ final var selected = variants.entrySet().stream().filter(entry -> matchesVariant(entry.getKey(), state)).toList(); ++ assertEquals(1, selected.size()); ++ final AppliedModel actual = applied(selected.getFirst().getValue().getAsJsonObject()); ++ if (assignments.containsKey(id)) { ++ assertEquals(new AppliedModel(assignments.get(id), 0), actual); ++ } else { ++ final int facing = List.of("east", "south", "west", "north").indexOf(state.properties().get("facing")) * 90; ++ final boolean open = state.hasProperty("open", "true"); ++ final boolean right = state.hasProperty("hinge", "right"); ++ assertEquals(Math.floorMod(facing + (open ? right ? -90 : 90 : 0), 360), actual.rotation()); ++ assertEquals("minecraft:block/oak_door_" + (state.hasProperty("half", "upper") ? "top" : "bottom") ++ + (right ? "_right" : "_left") + (open ? "_open" : ""), actual.name()); ++ } ++ }); ++ } ++ ++ @Test ++ void isolatesCustomMushroomStatesFromAllSixVanillaFaceLayers() { ++ for (String name : List.of("mushroom_stem", "red_mushroom_block", "brown_mushroom_block")) { ++ final Map states = new LinkedHashMap<>(); ++ final var directions = List.of("north", "east", "south", "west", "up", "down"); ++ for (int mask = 0; mask < 64; mask++) { ++ final Map faces = new LinkedHashMap<>(); ++ for (int index = 0; index < directions.size(); index++) { ++ faces.put(directions.get(index), Boolean.toString((mask & 1 << index) != 0)); ++ } ++ states.put(new BlockState(name, faces), mask); ++ } ++ final var definition = CustomBlockStateDefinitions.create(states, Map.of(17, "test:custom")).get(name); ++ states.forEach((state, id) -> { ++ assertTrue(CustomBlockStateDefinitions.carrier(state)); ++ final var selected = models(definition, state); ++ if (id == 17) { ++ assertEquals(List.of(new AppliedModel("test:custom", 0)), selected); ++ } else { ++ assertEquals(6, selected.size()); ++ assertEquals(Integer.bitCount(id), selected.stream().filter(model -> model.name().equals("minecraft:block/" + name)).count()); ++ } ++ }); ++ } ++ } ++ + private static Map wireStates() { + final Map states = new LinkedHashMap<>(); + for (String north : List.of("none", "side", "up")) { 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 74b8d334..50a87ea4 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 @@ -465,3 +465,35 @@ Java matches all 116 qualified and unqualified builtin resolution cases. A regression also covers primary/group collisions, partial groups, aliases, duplicates and case-sensitive lookup. Native factory installation and pointer-hash collisions remain unverified. The optional comparison reads `STACKANVIL_NATIVE_RESTRICTION_GROUPS`. + + +## Galaxite conversion failures + +The saved protocol-2193 Galaxite recording declares 3,661 custom states. +The previous carrier limit maps 734 states to coarse dirt. +The expanded pool provides 3,500 carriers and assigns all states to 3,340 visuals. +Existing note and wire carrier ordering stays stable. +Unused powered doors and mushroom face combinations supply the extra carriers. +Occupied vanilla mappings remain excluded. +An independent Java 26.3 asset comparison verifies 4,246 selectors across 27 block types. +That comparison includes 701 custom assignments and preserves every unassigned selector. + +The recording also exposes 88 states with orphaned geometry bones. +Preserve their remaining local transforms and retain cycle rejection. +One block references geometry stored under `models/entity`. +Block lookup now resolves identifiers throughout the pack's `models` directory. +These checks establish conversion coverage, not complete native orphan-loading parity. + +Eight fancy-table states contain vertices outside Java's cuboid range. +Core preserves their vertices and marks the elements for the client add-on. +The extension rejects non-finite coordinates and values beyond its bounded 64-block envelope. +Clients without the add-on retain Java's loader limitation. +Live rendering and broader native comparisons remain separate validation. + + +The combined build now passes 2,354 tests across all four projects. +There are 152 optional fixture skips and no failures or errors. +A fresh default-OpenGL Galaxite run reaches spawn and completes a 30-second playable capture. +The accepted server packs produce no mapping, geometry-conversion, or Java model-loading errors. +Forced software Vulkan and a later longer run disconnect before spawn. +The [survey follow-up](../../../docs/lobby-survey.md#galaxite-block-conversion-follow-up-22-october) records the validation limits and broader audit. diff --git a/patches/viabedrock/upstreamable/0055-use-shulker-facing-from-bedrock-data.patch b/patches/viabedrock/upstreamable/0055-use-shulker-facing-from-bedrock-data.patch index f428d6bf..5211e48d 100644 --- a/patches/viabedrock/upstreamable/0055-use-shulker-facing-from-bedrock-data.patch +++ b/patches/viabedrock/upstreamable/0055-use-shulker-facing-from-bedrock-data.patch @@ -1,4 +1,4 @@ -From 905e46e9b48ef4af8144f2f49ce6e668eb899831 Mon Sep 17 00:00:00 2001 +From 7e107fb747e8c52ad089b81b97a758b19a7ca101 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(block-entities): use shulker facing from Bedrock data @@ -98,7 +98,7 @@ index 000000000..e3b7072cb + +} 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 6b7b9b0ec..fe5b2a9ce 100644 +index b0b0a4e9c..194d0822f 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java @@ -63,6 +63,7 @@ import net.raphimc.viabedrock.protocol.data.generated.bedrock.CustomBlockTags; @@ -121,8 +121,8 @@ index 6b7b9b0ec..fe5b2a9ce 100644 + } + } if (isUpperDoor(state)) { - final BlockPosition lowerPosition = new BlockPosition(blockPosition.x(), blockPosition.y() - 1, blockPosition.z()); - final BlockState lowerState = this.getUnconnectedJavaBlockStateModel(lowerPosition); + final ResourcePackStorage resourcePacks = this.user().get(ResourcePackStorage.class); + if (resourcePacks != null && resourcePacks.getCustomBlockVisuals().containsKey(javaId)) { diff --git a/src/test/java/net/raphimc/viabedrock/protocol/rewriter/blockentity/ShulkerBoxBlockEntityRewriterTest.java b/src/test/java/net/raphimc/viabedrock/protocol/rewriter/blockentity/ShulkerBoxBlockEntityRewriterTest.java new file mode 100644 index 000000000..c4956e2dc diff --git a/patches/viabedrock/upstreamable/0064-evaluate-named-actor-properties.patch b/patches/viabedrock/upstreamable/0064-evaluate-named-actor-properties.patch index f3a24b09..63f3f786 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 905acc3a72c657191d8da9f6763903cf45cc35f3 Mon Sep 17 00:00:00 2001 +From 79a0f2e772bc2018a70e850bbd99cfb5106fb4ac 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 @@ -1208,7 +1208,7 @@ index bf3919338..1431bc51d 100644 private class ItemDisplayEntity extends Entity { 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 f73238e76..993d0258c 100644 +index 767c62d66..1ee31d5b6 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 @@ -21,6 +21,11 @@ import com.viaversion.viaversion.api.connection.UserConnection; @@ -1223,7 +1223,7 @@ index f73238e76..993d0258c 100644 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; -@@ -62,6 +67,11 @@ public class Entity { +@@ -67,6 +72,11 @@ public class Entity { protected Position3f rotation = Position3f.ZERO; protected boolean onGround; protected final Map entityData = new EnumMap<>(ActorDataIds.class); @@ -1235,7 +1235,7 @@ index f73238e76..993d0258c 100644 protected String name; protected int age; protected boolean hasBossBar; -@@ -80,11 +90,35 @@ public class Entity { +@@ -85,11 +95,35 @@ public class Entity { this.javaType = javaType; } @@ -1271,7 +1271,7 @@ index f73238e76..993d0258c 100644 if (this.hasBossBar) { this.hasBossBar = false; final PacketWrapper bossEvent = PacketWrapper.create(ClientboundPackets26_3.BOSS_EVENT, this.user); -@@ -294,6 +328,83 @@ public class Entity { +@@ -339,6 +373,83 @@ public class Entity { } protected void onEntityDataChanged() { @@ -1449,7 +1449,7 @@ index c33df2aad..d91eae86b 100644 return this.channels.contains(channel); } 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 fb0efe925..4dc7863c0 100644 +index 2b5551f6b..d28765276 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/EntityTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/EntityTracker.java @@ -63,6 +63,7 @@ public class EntityTracker extends StoredObject { @@ -1470,7 +1470,7 @@ index fb0efe925..4dc7863c0 100644 return entity; } -@@ -310,6 +314,27 @@ public class EntityTracker extends StoredObject { +@@ -316,6 +320,27 @@ public class EntityTracker extends StoredObject { } } diff --git a/patches/viabedrock/upstreamable/0064-share-native-float-molang-runtime.patch b/patches/viabedrock/upstreamable/0064-share-native-float-molang-runtime.patch index 546d4423..097ce6b0 100644 --- a/patches/viabedrock/upstreamable/0064-share-native-float-molang-runtime.patch +++ b/patches/viabedrock/upstreamable/0064-share-native-float-molang-runtime.patch @@ -1,4 +1,4 @@ -From f854af27e36b3ee089b7a508d8728496de4aef97 Mon Sep 17 00:00:00 2001 +From 315743f1b9cd808a8d76f5cf660f23803b051ef4 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(molang): share bounded native float evaluation @@ -44,10 +44,10 @@ world context and visible native parity are outside that fixture. create mode 100644 src/test/java/net/raphimc/viabedrock/api/util/MoLangMathTest.java diff --git a/build.gradle b/build.gradle -index 7b696c370..d615b5922 100644 +index a597bcbbc..1e8edb94c 100644 --- a/build.gradle +++ b/build.gradle -@@ -112,8 +112,8 @@ dependencies { +@@ -111,8 +111,8 @@ dependencies { api(cubeConverterDependency) { transitive = false } diff --git a/patches/viabedrock/upstreamable/0065-discard-replaced-dimension-work.patch b/patches/viabedrock/upstreamable/0065-discard-replaced-dimension-work.patch index 5fb1a01c..e3b3418f 100644 --- a/patches/viabedrock/upstreamable/0065-discard-replaced-dimension-work.patch +++ b/patches/viabedrock/upstreamable/0065-discard-replaced-dimension-work.patch @@ -1,4 +1,4 @@ -From 8fb242629789574fe5d22f962ab3861ad99880de Mon Sep 17 00:00:00 2001 +From 2df26c7e01155af8d3a371f9d8d07a912c1c9ef2 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(chunks): discard work from replaced dimension trackers @@ -23,10 +23,10 @@ replace the dimension tracker and reject a submission before retrying. create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/task/ChunkTrackerTickTaskTest.java 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 fe5b2a9ce..d0d302350 100644 +index 194d0822f..9e018b7ff 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java -@@ -1077,7 +1077,7 @@ public class ChunkTracker extends StoredObject { +@@ -1087,7 +1087,7 @@ public class ChunkTracker extends StoredObject { () -> LightEngine.computeCenterLight(snapshot.states(), snapshot.secondaryWater(), this.worldHeight >> 4, this.skyLight, this.blockLightEmission, this.blockOpacity), (result, error) -> { this.pendingLightComputations.remove(chunkKey); diff --git a/patches/viabedrock/upstreamable/0071-bound-repeated-block-mapping-diagnostics.patch b/patches/viabedrock/upstreamable/0071-bound-repeated-block-mapping-diagnostics.patch index 0f083e84..5f0d2cd0 100644 --- a/patches/viabedrock/upstreamable/0071-bound-repeated-block-mapping-diagnostics.patch +++ b/patches/viabedrock/upstreamable/0071-bound-repeated-block-mapping-diagnostics.patch @@ -1,4 +1,4 @@ -From 311a2f39c9a94771a03e5c1495fce9149a1eb7e5 Mon Sep 17 00:00:00 2001 +From ade7cd65d14976fda2935290afd1665f13c4c619 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] perf(chunks): bound repeated block mapping diagnostics @@ -82,7 +82,7 @@ index 000000000..3fd00291d + +} 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 d0d302350..85783ab1e 100644 +index 9e018b7ff..e2f2db7f4 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java @@ -125,6 +125,8 @@ public class ChunkTracker extends StoredObject { @@ -94,7 +94,7 @@ index d0d302350..85783ab1e 100644 public ChunkTracker(final UserConnection user, final Dimension dimension) { super(user); this.dimension = dimension; -@@ -335,7 +337,7 @@ public class ChunkTracker extends StoredObject { +@@ -340,7 +342,7 @@ public class ChunkTracker extends StoredObject { final int blockState0 = blockPalettes.get(0).idAt(sectionX, sectionY, sectionZ); int remappedBlockState = blockStateRewriter.javaId(blockState0); if (remappedBlockState == -1) { @@ -103,7 +103,7 @@ index d0d302350..85783ab1e 100644 remappedBlockState = ProtocolConstants.JAVA_AIR_ID; } -@@ -347,10 +349,10 @@ public class ChunkTracker extends StoredObject { +@@ -357,10 +359,10 @@ public class ChunkTracker extends StoredObject { if (waterloggedBlockState != -1) { remappedBlockState = waterloggedBlockState; } else { @@ -116,7 +116,7 @@ index d0d302350..85783ab1e 100644 } } } -@@ -1385,7 +1387,7 @@ public class ChunkTracker extends StoredObject { +@@ -1395,7 +1397,7 @@ public class ChunkTracker extends StoredObject { if (javaBlockState != -1) { return javaBlockState; } else { @@ -125,7 +125,7 @@ index d0d302350..85783ab1e 100644 return ProtocolConstants.JAVA_AIR_ID; } }); -@@ -1441,10 +1443,10 @@ public class ChunkTracker extends StoredObject { +@@ -1455,10 +1457,10 @@ public class ChunkTracker extends StoredObject { if (waterloggedBlockState != -1) { remappedBlockPalette.setIdAt(x, y, z, waterloggedBlockState); } else { diff --git a/patches/viabedrock/upstreamable/0073-compose-all-native-actor-flag-words.patch b/patches/viabedrock/upstreamable/0073-compose-all-native-actor-flag-words.patch index 16101bff..4a7b4de7 100644 --- a/patches/viabedrock/upstreamable/0073-compose-all-native-actor-flag-words.patch +++ b/patches/viabedrock/upstreamable/0073-compose-all-native-actor-flag-words.patch @@ -1,4 +1,4 @@ -From 96cbf58f227b123c17b7f8abf199db27b0b25c89 Mon Sep 17 00:00:00 2001 +From c6b78b45f04886929b92771334f7d6761b0cd62d 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(entities): retain all three unsigned actor flag words @@ -22,10 +22,10 @@ without protocol mappings or server fixtures. create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/entity/EntityFlagsTest.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 993d0258c..60f00bb0a 100644 +index 1ee31d5b6..1bcd41b99 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 -@@ -49,6 +49,8 @@ import java.util.logging.Level; +@@ -50,6 +50,8 @@ import java.util.logging.Level; public class Entity { @@ -34,7 +34,7 @@ index 993d0258c..60f00bb0a 100644 protected final UserConnection user; protected final long uniqueId; protected final long runtimeId; -@@ -260,10 +262,13 @@ public class Entity { +@@ -305,10 +307,13 @@ public class Entity { public Set entityFlags() { BigInteger combinedFlags = BigInteger.ZERO; if (this.entityData.containsKey(ActorDataIds.RESERVED_0)) { diff --git a/patches/viabedrock/upstreamable/0077-translate-bedrock-npc-conversations.patch b/patches/viabedrock/upstreamable/0077-translate-bedrock-npc-conversations.patch index 9e5da856..245897a3 100644 --- a/patches/viabedrock/upstreamable/0077-translate-bedrock-npc-conversations.patch +++ b/patches/viabedrock/upstreamable/0077-translate-bedrock-npc-conversations.patch @@ -1,4 +1,4 @@ -From af23ec60fb539df6a804d8e33cf61eefe5f6ccd6 Mon Sep 17 00:00:00 2001 +From fc834ba7ed5c3079720b089e655afacc406cd270 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(dialogue): translate Bedrock NPC conversations into Java @@ -305,7 +305,7 @@ index 000000000..521161d5b + +} 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 4dc7863c0..8db734269 100644 +index d28765276..5d50da293 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/EntityTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/EntityTracker.java @@ -135,6 +135,10 @@ public class EntityTracker extends StoredObject { diff --git a/patches/viabedrock/upstreamable/0079-finish-transport-initiated-raknet-disconnects.patch b/patches/viabedrock/upstreamable/0079-finish-transport-initiated-raknet-disconnects.patch index aafc851a..2fc4b1cd 100644 --- a/patches/viabedrock/upstreamable/0079-finish-transport-initiated-raknet-disconnects.patch +++ b/patches/viabedrock/upstreamable/0079-finish-transport-initiated-raknet-disconnects.patch @@ -1,4 +1,4 @@ -From 070e51eda7b9bc01de2ce460ac17f482de62862c Mon Sep 17 00:00:00 2001 +From 22dc3f73f50e1a630c3c3920cb6cef526c4a7ed6 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(network): finish transport-initiated RakNet disconnects @@ -24,10 +24,10 @@ before closing; no runtime-library reflection or client workaround is needed. create mode 100644 src/test/java/net/raphimc/viabedrock/netty/DisconnectHandlerTest.java diff --git a/build.gradle b/build.gradle -index d615b5922..411f129d9 100644 +index 1e8edb94c..6f272d8fb 100644 --- a/build.gradle +++ b/build.gradle -@@ -80,6 +80,7 @@ dependencies { +@@ -79,6 +79,7 @@ dependencies { compileOnly "io.netty:netty-handler:4.2.19.Final" compileOnly "com.google.code.gson:gson:2.14.0" compileOnly "org.cloudburstmc.netty:netty-transport-raknet:2.0.0.CR4-20260921.144528-1" diff --git a/patches/viabedrock/upstreamable/0085-resolve-server-effects-relative-to-their-actor.patch b/patches/viabedrock/upstreamable/0085-resolve-server-effects-relative-to-their-actor.patch index dd718297..9029e86b 100644 --- a/patches/viabedrock/upstreamable/0085-resolve-server-effects-relative-to-their-actor.patch +++ b/patches/viabedrock/upstreamable/0085-resolve-server-effects-relative-to-their-actor.patch @@ -1,4 +1,4 @@ -From e963f22d8ded2180f0e06bfcab9344de68f7799e Mon Sep 17 00:00:00 2001 +From 97f62b8a6e99b77cf6f9639e2b6ac101619f49c4 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(particles): route server effects through native playback @@ -77,7 +77,7 @@ client bindings. Direct and ViaProxy share the same core hash implementation. create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/model/SpawnParticleEffectTest.java diff --git a/build.gradle b/build.gradle -index 411f129d9..7864cb6a1 100644 +index 6f272d8fb..bff519ae1 100644 --- a/build.gradle +++ b/build.gradle @@ -16,6 +16,12 @@ checkstyleMain { 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 5c4330ca..4b371190 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 a5c8226a24fdfa0897412a1ffbfcde3714e4d56b Mon Sep 17 00:00:00 2001 +From 8eca9773cccf00e86aeed79dad2389586a4be598 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 000000000..e47da430c + +} 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 fcb0ca41a..7648c69fa 100644 +index 1f49fa20c..8e0cbe982 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 @@ diff --git a/patches/viabedrock/upstreamable/0087-inherit-client-entity-descriptions-across-layers.patch b/patches/viabedrock/upstreamable/0087-inherit-client-entity-descriptions-across-layers.patch index d078e56b..646b2b47 100644 --- a/patches/viabedrock/upstreamable/0087-inherit-client-entity-descriptions-across-layers.patch +++ b/patches/viabedrock/upstreamable/0087-inherit-client-entity-descriptions-across-layers.patch @@ -1,4 +1,4 @@ -From 6c4aa732969b77010eb5c58b36b38e6b34b78e05 Mon Sep 17 00:00:00 2001 +From 54b490eaa6cb136e054ec25c68c2fbea50eb04b0 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(resource-packs): inherit client entity descriptions @@ -17,12 +17,12 @@ Expose the same immutable description merger for licensed appearance readers. Th create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/definition/EntityDefinitionsTest.java diff --git a/build.gradle b/build.gradle -index 7864cb6a1..eeb1c586e 100644 +index bff519ae1..b62e3af6d 100644 --- a/build.gradle +++ b/build.gradle -@@ -81,6 +81,13 @@ repositories { +@@ -80,6 +80,13 @@ repositories { + dependencies { - testImplementation "org.mockito:mockito-core:5.24.0" compileOnly "com.viaversion:viaversion-common:5.12.0" + + testImplementation "com.viaversion:viaversion-common:5.12.0" diff --git a/patches/viabedrock/upstreamable/0089-resolve-and-transport-native-camera-instructions.patch b/patches/viabedrock/upstreamable/0089-resolve-and-transport-native-camera-instructions.patch index 358285e7..dd53f4ac 100644 --- a/patches/viabedrock/upstreamable/0089-resolve-and-transport-native-camera-instructions.patch +++ b/patches/viabedrock/upstreamable/0089-resolve-and-transport-native-camera-instructions.patch @@ -1,4 +1,4 @@ -From 128b2991ae41cfb6a1bb68b2a6998a6806f00b5c Mon Sep 17 00:00:00 2001 +From bc6c05627688badae4c67104d7a525b33fe42eaf 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(camera): resolve server presets and camera movement @@ -332,6 +332,48 @@ uses the complete official UCRT oracle and verified native sine table. Native eye/component producers, the separate scalar getter 144760c30, world selection geometry/tags, actor filtering and live input/reticle application remain separate integration work. + +Execute fixed-boom presets through the shared attached-camera runtime. +Retain fixed orientation independently of actor yaw and mouse orbit input, +use native pitch boundaries and fixed-orientation attachment space, and +supply measured distance to avoidance. Preserve default and override +rotation, offsets, attachment generations, independent preset instances, +and clear/replacement lifecycle. Generalize the shared rendering mode to +BOOM without changing its existing wire ordinal. Negotiate follow state +version two on camera_follow_v2 before sending attached position commands; +older follow-only peers cannot interpret the added preset-kind field. + +192 pinned 1.26.51.1/build51061372/protocol2193 controls execute native +fixed-boom default/starting callbacks 1466f7e90/1466f7f30, default/explicit +attachment 14677ec00/14677ecb0, attachment matrix 14677e5a0, fixed transform +14677eed0, offset composition 1466f73f0 and look-at 14677b300. Parsed +components, actor origin, radius, offsets, starting rotation and optional +attachment points are supplied. Empty ECS registration, point-provider +results and float CRT math are boundaries. These controls exclude actual +player/actor pivot production, constructor and packet flow, complete ECS +scheduling, avoidance/world type lookup and GPU rendering. The existing +follow frame and avoidance comparisons remain enabled separately. + +Retire lost fixed-boom attachments per camera instance after their bound +native actor lifetime disappears or is replaced. Resume the default player +attachment without adopting a reused identity until a new attachment +command. Preserve orbit's separate retained-component behavior. Keep native +identity available while Java render entities or bounds are pending so a +missing render sample does not masquerade as native actor removal. + +32 pinned 1.26.51.1 controls execute original callbacks 14677e970/14677ecb0, +optional point provider 14672dbf0, weak-actor lookup 14672dd10, center kernel +1419f8180 and transform 14677e5a0. Native lookup rejects removed actors, +stale sparse generations, absent type/sparse/data storage, expired worlds +and absent client levels. Fixed failure clears the handle, releases its +weak reference and requests component erasure. Orbit retains its handle. +Default fixed attachment, boom, offset and look-at kernels then run against +a supplied local-player origin, matching the source default-view exclusion +and caller ordering. Typed registries, generations, actor positions/AABBs, +weak controls and component inputs are supplied. Client-level/weak-world +resolution, attachment pool lookup/erasure and float CRT calls remain +boundaries. These controls exclude complete ECS scheduling, native player +pivot production, actual post-loss orbit origin lookup and GPU rendering. --- .../api/model/CameraAimAssistActivation.java | 118 +++++ .../api/model/CameraAimAssistBlocks.java | 436 ++++++++++++++++++ @@ -349,7 +391,8 @@ application remain separate integration work. .../viabedrock/api/model/CameraEase.java | 140 ++++++ .../viabedrock/api/model/CameraFadeState.java | 95 ++++ .../api/model/CameraFadeTimeline.java | 91 ++++ - .../api/model/CameraFollowRuntime.java | 182 ++++++++ + .../viabedrock/api/model/CameraFixedBoom.java | 34 ++ + .../api/model/CameraFollowRuntime.java | 207 +++++++++ .../api/model/CameraFollowState.java | 96 ++++ .../viabedrock/api/model/CameraFovState.java | 133 ++++++ .../api/model/CameraFovTimeline.java | 67 +++ @@ -374,7 +417,7 @@ application remain separate integration work. .../protocol/model/CameraAimAssistData.java | 249 ++++++++++ .../model/CameraAimAssistRegistry.java | 132 ++++++ .../protocol/model/CameraInstruction.java | 248 ++++++++++ - .../model/CameraPositionController.java | 269 +++++++++++ + .../model/CameraPositionController.java | 271 +++++++++++ .../protocol/model/CameraPreset.java | 169 +++++++ .../protocol/model/CameraPresetRegistry.java | 108 +++++ .../model/CameraSplineDefinition.java | 166 +++++++ @@ -398,7 +441,7 @@ application remain separate integration work. .../api/model/CameraAudioListenerTest.java | 52 +++ .../api/model/CameraAvoidanceTest.java | 128 +++++ .../api/model/CameraFadeTimelineTest.java | 117 +++++ - .../api/model/CameraFollowRuntimeTest.java | 228 +++++++++ + .../api/model/CameraFollowRuntimeTest.java | 358 ++++++++++++++ .../api/model/CameraFovTimelineTest.java | 132 ++++++ .../viabedrock/api/model/CameraOrbitTest.java | 135 ++++++ .../api/model/CameraPoseTimelineTest.java | 152 ++++++ @@ -411,11 +454,11 @@ application remain separate integration work. .../model/CameraAimAssistBridgeTest.java | 113 +++++ .../model/CameraAimAssistDataTest.java | 238 ++++++++++ .../protocol/model/CameraInstructionTest.java | 81 ++++ - .../model/CameraPositionControllerTest.java | 346 ++++++++++++++ + .../model/CameraPositionControllerTest.java | 373 +++++++++++++++ .../protocol/model/CameraPresetTest.java | 250 ++++++++++ .../model/CameraSplineDefinitionTest.java | 213 +++++++++ .../storage/CameraEffectsStorageTest.java | 255 ++++++++++ - 82 files changed, 11145 insertions(+), 10 deletions(-) + 83 files changed, 11363 insertions(+), 10 deletions(-) create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/CameraAimAssistActivation.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/CameraAimAssistBlocks.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/CameraAimAssistClientState.java @@ -432,6 +475,7 @@ application remain separate integration work. create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/CameraEase.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/CameraFadeState.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/CameraFadeTimeline.java + create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/CameraFixedBoom.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/CameraFollowRuntime.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/CameraFollowState.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/CameraFovState.java @@ -2069,7 +2113,7 @@ index 000000000..ad79cb977 +} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/CameraAvoidance.java b/src/main/java/net/raphimc/viabedrock/api/model/CameraAvoidance.java new file mode 100644 -index 000000000..f396b16f1 +index 000000000..8d3d3c884 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/model/CameraAvoidance.java @@ -0,0 +1,171 @@ @@ -2222,8 +2266,8 @@ index 000000000..f396b16f1 + vertical = List.copyOf(vertical); + } + -+ /** The pinned follow_orbit definition overrides the native constructor's spring and minimum. */ -+ public static Settings followOrbit() { ++ /** The pinned follow_orbit and fixed_boom definitions share these constructor overrides. */ ++ public static Settings boom() { + return new Settings(0, .25F, List.of(), List.of()); + } + } @@ -2588,12 +2632,52 @@ index 000000000..a9f293d4d + } + +} +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/CameraFixedBoom.java b/src/main/java/net/raphimc/viabedrock/api/model/CameraFixedBoom.java +new file mode 100644 +index 000000000..2d85086ca +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/model/CameraFixedBoom.java +@@ -0,0 +1,34 @@ ++/* ++ * 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 net.raphimc.viabedrock.api.model.CameraPose.Vector; ++ ++/** Fixed-boom orientation and attachment space, independent of orbit input and actor yaw. */ ++final class CameraFixedBoom { ++ ++ private CameraFixedBoom() { ++ } ++ ++ static Vector offset(final float radius, final float pitch, final float yaw) { ++ // Native uses a stored upper-pitch polar constant and computes the lower boundary separately. ++ final float polar = pitch > 89.9F ? .0017453432083129883F ++ : -(Math.max(pitch, -89.9F) * CameraPose.DEGREES - CameraPose.PI * .5F); ++ final float azimuth = yaw * CameraPose.DEGREES; ++ final float sine = (float) Math.sin(polar); ++ return new Vector((float) Math.cos(azimuth) * radius * sine, ++ (float) Math.cos(polar) * radius, (float) Math.sin(azimuth) * radius * sine); ++ } ++ ++ static float attachmentYaw(final float yaw) { ++ float wrapped = ((yaw + 90F) + 180F) % 360F; ++ if (wrapped < 0) { ++ wrapped += 360F; ++ } ++ return wrapped - 180F; ++ } ++ ++} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/CameraFollowRuntime.java b/src/main/java/net/raphimc/viabedrock/api/model/CameraFollowRuntime.java new file mode 100644 -index 000000000..68ad4a294 +index 000000000..d6a582462 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/model/CameraFollowRuntime.java -@@ -0,0 +1,182 @@ +@@ -0,0 +1,207 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -2627,17 +2711,22 @@ index 000000000..68ad4a294 + } + if (this.state == null || next.attachmentSequence() != this.state.attachmentSequence()) { + this.attachmentLifetime = null; ++ this.instances.values().forEach(instance -> instance.attachmentRetired = false); + } + final var ids = new HashSet(); + for (final var preset : next.presets()) { + ids.add(preset.id()); + final var instance = this.instances.computeIfAbsent(preset.id(), id -> new Instance(preset)); -+ if (!instance.preset.equals(preset)) { ++ if (instance.preset.fixed() != preset.fixed()) { ++ this.instances.put(preset.id(), new Instance(preset)); ++ continue; ++ } ++ if (!instance.preset.equals(preset) && !preset.fixed()) { + final var target = instance.preset.rotationSequence() == preset.rotationSequence() + ? new CameraOrbit.Target(preset.radius(), instance.orbit.target().yaw(), instance.orbit.target().polar()) : target(preset); + instance.orbit = new CameraOrbit(settings(preset), instance.orbit.state(), target); -+ instance.preset = preset; + } ++ instance.preset = preset; + } + this.instances.keySet().retainAll(ids); + this.state = next; @@ -2648,7 +2737,7 @@ index 000000000..68ad4a294 + return false; + } + final var active = this.instances.get(this.state.activePreset()); -+ if (active == null) { ++ if (active == null || active.preset.fixed()) { + return false; + } + active.orbit.input(pitchDelta, yawDelta); @@ -2660,25 +2749,35 @@ index 000000000..68ad4a294 + return; + } + final boolean attached = this.state.actorUniqueId() != null; -+ final Actor actor = attached ? actors.apply(this.state.actorUniqueId()) : localPlayer; -+ if (actor == null) { -+ return; -+ } -+ if (attached) { -+ if (this.attachmentLifetime == null) { -+ this.attachmentLifetime = actor.lifetime; -+ } -+ if (!this.attachmentLifetime.equals(actor.lifetime)) { -+ return; -+ } ++ final Actor attachedActor = attached ? actors.apply(this.state.actorUniqueId()) : null; ++ final boolean lost = attached && this.attachmentLifetime != null ++ && (attachedActor == null || !this.attachmentLifetime.equals(attachedActor.lifetime)); ++ if (attached && attachedActor != null && this.attachmentLifetime == null) { ++ this.attachmentLifetime = attachedActor.lifetime; + } + for (final var instance : this.instances.values()) { + final var p = instance.preset; -+ final Vector origin = attached ? actor.origin : actor.origin.add(new Vector(0, this.state.playerOriginOffset(), 0)); -+ final var localPoint = attached ? actor.center.subtract(origin) : Vector.ZERO; -+ final Vector pivot = origin.add(attachment(localPoint, p.entityOffset(), actor.yaw)); -+ instance.orbit.input(0, 0); -+ final Vector position = instance.orbit.advance(delta, pivot, instance.renderedPosition != null ? instance.renderedPosition : pivot, Float.MAX_VALUE); ++ // Fixed-boom drops its failed weak attachment. Orbit retains its component and previous pose. ++ if (lost && p.fixed()) { ++ instance.attachmentRetired = true; ++ } ++ final boolean useAttachment = attached && !instance.attachmentRetired; ++ final Actor actor = useAttachment ? attachedActor : localPlayer; ++ if (actor == null || actor.sample == null || useAttachment && lost) { ++ continue; ++ } ++ final Sample sample = actor.sample; ++ final Vector origin = useAttachment ? sample.origin : sample.origin.add(new Vector(0, this.state.playerOriginOffset(), 0)); ++ final var localPoint = useAttachment ? sample.center.subtract(origin) : Vector.ZERO; ++ final float attachmentYaw = p.fixed() ? CameraFixedBoom.attachmentYaw(p.yaw()) : sample.yaw; ++ final Vector pivot = origin.add(attachment(localPoint, p.entityOffset(), attachmentYaw)); ++ final Vector position; ++ if (p.fixed()) { ++ position = pivot.add(CameraFixedBoom.offset(p.radius(), p.pitch(), p.yaw())); ++ } else { ++ instance.orbit.input(0, 0); ++ position = instance.orbit.advance(delta, pivot, instance.renderedPosition != null ? instance.renderedPosition : pivot, Float.MAX_VALUE); ++ } + final Vector boom = position.subtract(pivot); + final float squared = boom.x() * boom.x() + boom.y() * boom.y() + boom.z() * boom.z(); + if (squared < Math.ulp(1F)) { @@ -2691,7 +2790,7 @@ index 000000000..68ad4a294 + instance.orientation = CameraTargetTracker.lookAt(direction, instance.orientation); + // The pinned camera definition's near plane supplies avoidance clearance. + // Native samples the subject origin, then avoids around the pivot including its view offset. -+ instance.renderedPosition = instance.avoidance.advance(delta, shiftedPivot, target, p.radius(), .025F, ++ instance.renderedPosition = instance.avoidance.advance(delta, shiftedPivot, target, p.fixed() ? null : p.radius(), .025F, + world.insideSolidBlock(origin), null, world); + } + } @@ -2742,15 +2841,24 @@ index 000000000..68ad4a294 + return new CameraOrbit.Target(p.radius(), p.yaw() * CameraPose.DEGREES, -(p.pitch() * CameraPose.DEGREES - CameraPose.PI * .5F)); + } + -+ /** Translated entity position and native physical center, sampled at the same render time. */ -+ public record Actor(UUID lifetime, Vector origin, Vector center, float yaw) { ++ /** Native actor identity stays available while its render entity or physical bounds are pending. */ ++ public record Actor(UUID lifetime, Sample sample) { + public Actor { -+ if (lifetime == null || origin == null || center == null || !Float.isFinite(yaw)) { ++ if (lifetime == null) { + throw new IllegalArgumentException("Invalid follow actor"); + } + } + } + ++ /** Translated entity position and native physical center, sampled at the same render time. */ ++ public record Sample(Vector origin, Vector center, float yaw) { ++ public Sample { ++ if (origin == null || center == null || !Float.isFinite(yaw)) { ++ throw new IllegalArgumentException("Invalid follow actor sample"); ++ } ++ } ++ } ++ + public interface World extends CameraAvoidance.Raycast { + boolean insideSolidBlock(Vector subjectOrigin); + } @@ -2761,6 +2869,7 @@ index 000000000..68ad4a294 + private final CameraAvoidance avoidance; + private Vector renderedPosition; + private Quaternion orientation; ++ private boolean attachmentRetired; + + private CameraPose pose(final float fov, final int renderingMode) { + return this.renderedPosition == null ? null : new CameraPose(this.renderedPosition, this.orientation, fov, renderingMode); @@ -2769,8 +2878,8 @@ index 000000000..68ad4a294 + private Instance(final CameraFollowState.Preset preset) { + this.preset = preset; + final var target = target(preset); -+ this.orbit = new CameraOrbit(settings(preset), new CameraOrbit.State(target.radius(), target.yaw(), target.polar(), 0, 0, 0), target); -+ this.avoidance = new CameraAvoidance(CameraAvoidance.Settings.followOrbit(), new CameraAvoidance.State(target.radius(), 0)); ++ this.orbit = preset.fixed() ? null : new CameraOrbit(settings(preset), new CameraOrbit.State(target.radius(), target.yaw(), target.polar(), 0, 0, 0), target); ++ this.avoidance = new CameraAvoidance(CameraAvoidance.Settings.boom(), new CameraAvoidance.State(target.radius(), 0)); + this.orientation = CameraPose.fromDegrees(Vector.ZERO, preset.pitch(), preset.yaw() + 90, 0, 0).orientation(); + } + } @@ -2778,7 +2887,7 @@ index 000000000..68ad4a294 +} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/CameraFollowState.java b/src/main/java/net/raphimc/viabedrock/api/model/CameraFollowState.java new file mode 100644 -index 000000000..80507aa2f +index 000000000..5853b6609 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/model/CameraFollowState.java @@ -0,0 +1,96 @@ @@ -2802,12 +2911,12 @@ index 000000000..80507aa2f +public record CameraFollowState(long sequence, long epoch, long positionSequence, long activePreset, + long attachmentSequence, Long actorUniqueId, float playerOriginOffset, List presets) { + -+ public static final String CHANNEL = "viabedrock:camera_follow"; ++ public static final String CHANNEL = "viabedrock:camera_follow_v2"; + public static final int MAX_PRESETS = 4096; + public static final Type TYPE = new Type<>(CameraFollowState.class) { + @Override + public CameraFollowState read(final ByteBuf b) { -+ if (b.readableBytes() > 1 << 20 || Types.VAR_INT.readPrimitive(b) != 1) { ++ if (b.readableBytes() > 1 << 20 || Types.VAR_INT.readPrimitive(b) != 2) { + throw new IllegalArgumentException("Unsupported camera follow state"); + } + final long sequence = b.readLong(); @@ -2824,7 +2933,7 @@ index 000000000..80507aa2f + final var presets = new ArrayList(count); + for (int i = 0; i < count; i++) { + presets.add(new Preset(b.readLong(), b.readLong(), b.readFloat(), b.readFloat(), b.readFloat(), -+ b.readFloat(), b.readFloat(), new Vector(b.readFloat(), b.readFloat(), b.readFloat()), b.readFloat(), b.readFloat())); ++ b.readFloat(), b.readFloat(), new Vector(b.readFloat(), b.readFloat(), b.readFloat()), b.readFloat(), b.readFloat(), b.readBoolean())); + } + if (b.isReadable()) { + throw new IllegalArgumentException("Trailing camera follow fields"); @@ -2834,7 +2943,7 @@ index 000000000..80507aa2f + + @Override + public void write(final ByteBuf b, final CameraFollowState s) { -+ Types.VAR_INT.writePrimitive(b, 1); ++ Types.VAR_INT.writePrimitive(b, 2); + b.writeLong(s.sequence).writeLong(s.epoch).writeLong(s.positionSequence).writeLong(s.activePreset).writeLong(s.attachmentSequence); + b.writeBoolean(s.actorUniqueId != null); + if (s.actorUniqueId != null) { @@ -2845,7 +2954,7 @@ index 000000000..80507aa2f + for (final var p : s.presets) { + b.writeLong(p.id).writeLong(p.rotationSequence).writeFloat(p.radius).writeFloat(p.pitch).writeFloat(p.yaw) + .writeFloat(p.viewX).writeFloat(p.viewY).writeFloat(p.entityOffset.x()).writeFloat(p.entityOffset.y()).writeFloat(p.entityOffset.z()) -+ .writeFloat(p.minYaw).writeFloat(p.maxYaw); ++ .writeFloat(p.minYaw).writeFloat(p.maxYaw).writeBoolean(p.fixed); + } + } + }; @@ -2868,7 +2977,7 @@ index 000000000..80507aa2f + } + + public record Preset(long id, long rotationSequence, float radius, float pitch, float yaw, float viewX, float viewY, -+ Vector entityOffset, float minYaw, float maxYaw) { ++ Vector entityOffset, float minYaw, float maxYaw, boolean fixed) { + public Preset { + if (id < 0 || rotationSequence < 0 || !Float.isFinite(radius) || radius < 0 || !Float.isFinite(pitch) || !Float.isFinite(yaw) + || !Float.isFinite(viewX) || !Float.isFinite(viewY) || entityOffset == null || !Float.isFinite(minYaw) || !Float.isFinite(maxYaw) || minYaw > maxYaw) { @@ -3453,7 +3562,7 @@ index 000000000..84f83a7c6 +} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/CameraPositionState.java b/src/main/java/net/raphimc/viabedrock/api/model/CameraPositionState.java new file mode 100644 -index 000000000..54f87b01e +index 000000000..cd84eb2a4 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/model/CameraPositionState.java @@ -0,0 +1,125 @@ @@ -3578,7 +3687,7 @@ index 000000000..54f87b01e + THIRD_PERSON, + THIRD_PERSON_FRONT, + FREE, -+ FOLLOW_ORBIT ++ BOOM + } + +} @@ -5001,10 +5110,10 @@ index 000000000..e1662c631 + +} 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 60f00bb0a..c86ed106b 100644 +index 1bcd41b99..70c675dbf 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 -@@ -207,6 +207,10 @@ public class Entity { +@@ -212,6 +212,10 @@ public class Entity { return this.javaId; } @@ -5798,10 +5907,10 @@ index 000000000..fcbcc1e04 +} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/model/CameraPositionController.java b/src/main/java/net/raphimc/viabedrock/protocol/model/CameraPositionController.java new file mode 100644 -index 000000000..1a98be2bd +index 000000000..881b05176 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/protocol/model/CameraPositionController.java -@@ -0,0 +1,269 @@ +@@ -0,0 +1,271 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -5865,7 +5974,8 @@ index 000000000..1a98be2bd + long active = -1; + for (int i = 0; i < this.registry.presets().size(); i++) { + final var resolved = this.registry.get(i); -+ if (resolved == null || resolved.base() != CameraPresetRegistry.Base.FOLLOW_ORBIT) { ++ if (resolved == null || resolved.base() != CameraPresetRegistry.Base.FOLLOW_ORBIT ++ && resolved.base() != CameraPresetRegistry.Base.FIXED_BOOM) { + continue; + } + final var p = resolved.preset(); @@ -5875,7 +5985,8 @@ index 000000000..1a98be2bd + final var offset = override.entityOffset != null ? override.entityOffset : p.entityOffset(); + presets.add(new CameraFollowState.Preset(i, this.rotationSequences.getOrDefault((long) i, 0L), p.radius() == null ? 10 : p.radius(), + rotation.x(), rotation.y(), view == null ? 0 : view.x(), view == null ? 0 : view.y(), -+ offset == null ? CameraPose.Vector.ZERO : vector(offset), p.minYawLimit() == null ? -180 : p.minYawLimit(), p.maxYawLimit() == null ? 180 : p.maxYawLimit())); ++ offset == null ? CameraPose.Vector.ZERO : vector(offset), p.minYawLimit() == null ? -180 : p.minYawLimit(), p.maxYawLimit() == null ? 180 : p.maxYawLimit(), ++ resolved.base() == CameraPresetRegistry.Base.FIXED_BOOM)); + if (this.activePreset == i) { + active = i; + } @@ -5926,7 +6037,7 @@ index 000000000..1a98be2bd + case THIRD_PERSON -> Mode.THIRD_PERSON; + case THIRD_PERSON_FRONT -> Mode.THIRD_PERSON_FRONT; + case FREE -> Mode.FREE; -+ case FOLLOW_ORBIT -> Mode.FOLLOW_ORBIT; ++ case FOLLOW_ORBIT, FIXED_BOOM -> Mode.BOOM; + default -> + throw new IllegalArgumentException( + "Unsupported camera base " + resolved.base()); @@ -5949,7 +6060,7 @@ index 000000000..1a98be2bd + new CameraPose.Vector( + value(preset.posX()), value(preset.posY()), value(preset.posZ())); + final CameraInstruction.Vec2 defaultRotation = -+ mode == Mode.FOLLOW_ORBIT ? followRotation(preset) : new CameraInstruction.Vec2(value(preset.rotX()), value(preset.rotY())); ++ mode == Mode.BOOM ? followRotation(preset) : new CameraInstruction.Vec2(value(preset.rotX()), value(preset.rotY())); + if (set.facing() != null) { + final CameraPose.Vector from = + values.position != null ? values.position : defaultPosition; @@ -6813,7 +6924,7 @@ index 172699ee1..1c33adf24 100644 protocol.cancelServerbound(ServerboundPackets26_3.CHAT_ACK); diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/CameraEffectsStorage.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/CameraEffectsStorage.java new file mode 100644 -index 000000000..5de216d9e +index 000000000..901c2d326 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/CameraEffectsStorage.java @@ -0,0 +1,371 @@ @@ -7163,7 +7274,7 @@ index 000000000..5de216d9e + return; + } + final CameraPositionState state = this.position.state(); -+ if (state.mode() == CameraPositionState.Mode.FOLLOW_ORBIT && !this.canSend(CameraFollowState.CHANNEL)) { ++ if (state.mode() == CameraPositionState.Mode.BOOM && !this.canSend(CameraFollowState.CHANNEL)) { + return; + } + final PacketWrapper packet = PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, this.user()); @@ -7189,10 +7300,10 @@ index 000000000..5de216d9e + +} 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 8db734269..06c8b260d 100644 +index 5d50da293..136722c73 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/EntityTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/EntityTracker.java -@@ -327,6 +327,10 @@ public class EntityTracker extends StoredObject { +@@ -333,6 +333,10 @@ public class EntityTracker extends StoredObject { this.sendNativeActorSnapshots(); } @@ -8720,7 +8831,7 @@ index 000000000..48c7f2087 +} diff --git a/src/test/java/net/raphimc/viabedrock/api/model/CameraAvoidanceTest.java b/src/test/java/net/raphimc/viabedrock/api/model/CameraAvoidanceTest.java new file mode 100644 -index 000000000..55a84585f +index 000000000..8b6cd3e75 --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/api/model/CameraAvoidanceTest.java @@ -0,0 +1,128 @@ @@ -8749,7 +8860,7 @@ index 000000000..55a84585f + + @Test + void minimumCanExpandTargetBelowItsBoundary() { -+ final var avoidance = new CameraAvoidance(CameraAvoidance.Settings.followOrbit(), new CameraAvoidance.State(10, 0)); ++ final var avoidance = new CameraAvoidance(CameraAvoidance.Settings.boom(), new CameraAvoidance.State(10, 0)); + final Vector target = new Vector(.1F, 0, 0); + assertVector(new Vector(.25F, 0, 0), avoidance.advance(.05F, Vector.ZERO, target, .1F, .1F, false, null, (start, end) -> null)); + final var calls = new AtomicInteger(); @@ -8977,10 +9088,10 @@ index 000000000..26a5c62ba +} diff --git a/src/test/java/net/raphimc/viabedrock/api/model/CameraFollowRuntimeTest.java b/src/test/java/net/raphimc/viabedrock/api/model/CameraFollowRuntimeTest.java new file mode 100644 -index 000000000..ab49e8144 +index 000000000..b1ab59d20 --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/api/model/CameraFollowRuntimeTest.java -@@ -0,0 +1,228 @@ +@@ -0,0 +1,358 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -9103,9 +9214,139 @@ index 000000000..ab49e8144 + } + + @Test ++ void fixedBoomRetainsOrientationAcrossActorRotationAndRejectsOrbitInput() { ++ final var runtime = new CameraFollowRuntime(); ++ final var p = new CameraFollowState.Preset(0, 0, 10, 45, 45, 2, -1, new Vector(1, 2, 3), -180, 180, true); ++ final UUID lifetime = UUID.randomUUID(); ++ final var first = actor(lifetime, 0); ++ runtime.accept(state(1, 1, 0, 1, 9L, List.of(p))); ++ runtime.advance(.05F, null, id -> first, AIR); ++ final var initial = runtime.active(1, 66, 5); ++ assertFalse(runtime.input(1, 30, 90)); ++ final var turned = new CameraFollowRuntime.Actor(lifetime, new CameraFollowRuntime.Sample(first.sample().origin(), first.sample().center(), 170)); ++ runtime.advance(.05F, null, id -> turned, AIR); ++ assertEquals(initial, runtime.active(1, 66, 5)); ++ final var moved = actor(lifetime, 3); ++ runtime.advance(.05F, null, id -> moved, AIR); ++ assertEquals(initial.position().x() + 3, runtime.active(1, 66, 5).position().x(), .00001F); ++ assertEquals(initial.orientation(), runtime.active(1, 66, 5).orientation()); ++ runtime.accept(state(2, 2, 0, 1, 9L, List.of(new CameraFollowState.Preset(0, 1, 10, 0, -90, ++ 0, 0, Vector.ZERO, -180, 180, true)))); ++ runtime.advance(.05F, null, id -> first, AIR); ++ assertEquals(-10, runtime.active(2, 66, 5).position().z(), .00001F); ++ } ++ ++ @Test ++ void nativeFixedBoomCallbacksMatchDefaultAndExplicitAttachmentFrames() throws Exception { ++ final String source = System.getenv("STACKANVIL_NATIVE_CAMERA_FIXED_BOOM"); ++ assumeTrue(source != null); ++ final var cases = JsonParser.parseString(Files.readString(Path.of(source))).getAsJsonObject().getAsJsonArray("cases"); ++ for (final var element : cases) { ++ assertNativeFrame(element.getAsJsonObject(), AIR); ++ } ++ assertEquals(192, cases.size()); ++ } ++ ++ @Test ++ void fixedRetirementUsesTheLocalPlayerWithoutAdoptingReplacementsOrResettingOrbitState() { ++ final var runtime = new CameraFollowRuntime(); ++ final var fixed = new CameraFollowState.Preset(0, 0, 10, 0, 0, 0, 0, Vector.ZERO, -180, 180, true); ++ final var presets = List.of(fixed, preset(1, 0, 10)); ++ final var local = actor(UUID.randomUUID(), 20); ++ final var first = actor(UUID.randomUUID(), 0); ++ runtime.accept(state(1, 1, 0, 1, 9L, presets)); ++ runtime.advance(.05F, local, id -> first, AIR); ++ assertVector(new Vector(10, .9F, 0), runtime.active(1, 66, 5).position()); ++ runtime.advance(.05F, local, id -> null, AIR); ++ assertVector(new Vector(30, 1.62F, 0), runtime.active(1, 66, 5).position()); ++ final var replacement = actor(UUID.randomUUID(), 99); ++ runtime.accept(state(2, 2, 1, 1, 9L, presets)); ++ runtime.advance(.05F, local, id -> replacement, AIR); ++ assertVector(new Vector(10, .9F, 0), runtime.active(2, 66, 5).position()); ++ runtime.accept(state(3, 3, 0, 1, 9L, presets)); ++ runtime.advance(.05F, local, id -> first, AIR); ++ assertVector(new Vector(30, 1.62F, 0), runtime.active(3, 66, 5).position()); ++ runtime.advance(.05F, local, id -> replacement, AIR); ++ assertVector(new Vector(30, 1.62F, 0), runtime.active(3, 66, 5).position()); ++ runtime.accept(state(4, 4, 0, 2, 9L, presets)); ++ runtime.advance(.05F, local, id -> replacement, AIR); ++ assertVector(new Vector(109, .9F, 0), runtime.active(4, 66, 5).position()); ++ } ++ ++ @Test ++ void pendingRenderSamplesKeepActorIdentityAndDoNotRetireAnAttachment() { ++ final var runtime = new CameraFollowRuntime(); ++ final var p = new CameraFollowState.Preset(0, 0, 10, 0, 0, 0, 0, Vector.ZERO, -180, 180, true); ++ final UUID lifetime = UUID.randomUUID(); ++ final var local = actor(UUID.randomUUID(), 20); ++ runtime.accept(state(1, 1, 0, 1, 9L, List.of(p))); ++ runtime.advance(.05F, local, id -> new CameraFollowRuntime.Actor(lifetime, null), AIR); ++ assertNull(runtime.active(1, 66, 5)); ++ runtime.advance(.05F, local, id -> actor(lifetime, 3), AIR); ++ assertVector(new Vector(13, .9F, 0), runtime.active(1, 66, 5).position()); ++ runtime.advance(.05F, local, id -> new CameraFollowRuntime.Actor(lifetime, null), AIR); ++ assertVector(new Vector(13, .9F, 0), runtime.active(1, 66, 5).position()); ++ runtime.advance(.05F, local, id -> actor(lifetime, 4), AIR); ++ assertVector(new Vector(14, .9F, 0), runtime.active(1, 66, 5).position()); ++ } ++ ++ @Test ++ void fixedRetirementWaitsForTheLocalPlayerWithoutReacquiringTheDeadGeneration() { ++ final var runtime = new CameraFollowRuntime(); ++ final var p = new CameraFollowState.Preset(0, 0, 10, 0, 0, 0, 0, Vector.ZERO, -180, 180, true); ++ final var first = actor(UUID.randomUUID(), 0); ++ runtime.accept(state(1, 1, 0, 1, 9L, List.of(p))); ++ runtime.advance(.05F, null, id -> first, AIR); ++ final var retained = runtime.active(1, 66, 5); ++ runtime.advance(.05F, null, id -> null, AIR); ++ assertEquals(retained, runtime.active(1, 66, 5)); ++ final var replacement = actor(UUID.randomUUID(), 99); ++ runtime.advance(.05F, actor(UUID.randomUUID(), 20), id -> replacement, AIR); ++ assertVector(new Vector(30, 1.62F, 0), runtime.active(1, 66, 5).position()); ++ } ++ ++ @Test ++ void matchesNativeWeakActorCleanupAndDefaultFrameAfterFixedRetirement() throws Exception { ++ final String source = System.getenv("STACKANVIL_NATIVE_CAMERA_ATTACHMENT_RETIREMENT"); ++ assumeTrue(source != null); ++ final var rows = JsonParser.parseString(Files.readString(Path.of(source))).getAsJsonObject().getAsJsonArray("rows"); ++ for (final var item : rows) { ++ final var row = item.getAsJsonObject(); ++ final var runtime = new CameraFollowRuntime(); ++ final var p = new CameraFollowState.Preset(0, 0, 10, 45, 45, 0, 0, new Vector(1, 2, 3), -180, 180, ++ row.get("fixed").getAsBoolean()); ++ final UUID lifetime = UUID.randomUUID(); ++ final var origin = vector(row.getAsJsonArray("actorOrigin")); ++ final var center = vector(row.getAsJsonArray("actorCenter")); ++ final var local = new CameraFollowRuntime.Actor(UUID.randomUUID(), new CameraFollowRuntime.Sample(new Vector(4, 70, 2), Vector.ZERO, 0)); ++ final var first = new CameraFollowRuntime.Actor(lifetime, new CameraFollowRuntime.Sample(origin, center, 25)); ++ runtime.accept(state(1, 1, 0, 1, 9L, List.of(p))); ++ runtime.advance(1F / 60, local, id -> first, AIR); ++ final String failure = row.get("failure").getAsString(); ++ final CameraFollowRuntime.Actor next = failure.equals("valid") ++ ? new CameraFollowRuntime.Actor(lifetime, new CameraFollowRuntime.Sample(origin, center, 170)) ++ : failure.equals("stale-generation") ++ ? new CameraFollowRuntime.Actor(UUID.randomUUID(), new CameraFollowRuntime.Sample(origin, center, 170)) : null; ++ runtime.advance(1F / 60, local, id -> next, AIR); ++ final var actual = runtime.active(1, 66, 5); ++ final var expected = row.getAsJsonObject("frame"); ++ final var position = vector(expected.getAsJsonArray("position")); ++ assertEquals(position.x(), actual.position().x(), .00002F, row::toString); ++ assertEquals(position.y(), actual.position().y(), .00002F, row::toString); ++ assertEquals(position.z(), actual.position().z(), .00002F, row::toString); ++ final var q = expected.getAsJsonArray("orientation"); ++ final float dot = actual.orientation().x() * q.get(0).getAsFloat() + actual.orientation().y() * q.get(1).getAsFloat() ++ + actual.orientation().z() * q.get(2).getAsFloat() + actual.orientation().w() * q.get(3).getAsFloat(); ++ assertEquals(1, Math.abs(dot), .00001F, row::toString); ++ } ++ assertEquals(32, rows.size()); ++ } ++ ++ @Test + void followPayloadBoundsAndPreservesSignedActorIds() { + for (final long id : new long[]{Long.MIN_VALUE, -1, Long.MAX_VALUE}) { -+ final var state = state(3, 2, 0, 4, id, List.of(preset(0, 1, 10))); ++ final var state = state(3, 2, 0, 4, id, List.of(preset(0, 1, 10), ++ new CameraFollowState.Preset(1, 1, 5, 100, 720, 2, -1, Vector.ZERO, -180, 180, true))); + final var buffer = Unpooled.buffer(); + try { + CameraFollowState.TYPE.write(buffer, state); @@ -9172,10 +9413,10 @@ index 000000000..ab49e8144 + final var origin = vector(sample.getAsJsonArray("origin")); + final boolean attached = sample.get("attached").getAsBoolean(); + final var feet = attached ? origin : origin.subtract(new Vector(0, 1.62F, 0)); -+ final var actor = new CameraFollowRuntime.Actor(UUID.randomUUID(), feet, feet.add(new Vector(0, .9F, 0)), sample.get("actorYaw").getAsFloat()); ++ final var actor = new CameraFollowRuntime.Actor(UUID.randomUUID(), new CameraFollowRuntime.Sample(feet, feet.add(new Vector(0, .9F, 0)), sample.has("actorYaw") ? sample.get("actorYaw").getAsFloat() : 123F)); + final var view = sample.getAsJsonArray("view"); + final var preset = new CameraFollowState.Preset(0, 0, sample.get("radius").getAsFloat(), sample.get("pitch").getAsFloat(), sample.get("yaw").getAsFloat(), -+ view.get(0).getAsFloat(), view.get(1).getAsFloat(), vector(sample.getAsJsonArray("entityOffset")), -180, 180); ++ view.get(0).getAsFloat(), view.get(1).getAsFloat(), vector(sample.getAsJsonArray("entityOffset")), -180, 180, sample.has("fixed") && sample.get("fixed").getAsBoolean()); + final var runtime = new CameraFollowRuntime(); + runtime.accept(state(1, 1, 0, 1, sample.get("attached").getAsBoolean() ? 9L : null, List.of(preset))); + runtime.advance(1F / 60, actor, id -> actor, world); @@ -9197,11 +9438,11 @@ index 000000000..ab49e8144 + } + + private static CameraFollowState.Preset preset(final long id, final long revision, final float radius) { -+ return new CameraFollowState.Preset(id, revision, radius, 0, 0, 0, 0, Vector.ZERO, -180, 180); ++ return new CameraFollowState.Preset(id, revision, radius, 0, 0, 0, 0, Vector.ZERO, -180, 180, false); + } + + private static CameraFollowRuntime.Actor actor(final UUID lifetime, final float x) { -+ return new CameraFollowRuntime.Actor(lifetime, new Vector(x, 0, 0), new Vector(x, .9F, 0), 0); ++ return new CameraFollowRuntime.Actor(lifetime, new CameraFollowRuntime.Sample(new Vector(x, 0, 0), new Vector(x, .9F, 0), 0)); + } + + private static Vector vector(final JsonArray value) { @@ -11142,10 +11383,10 @@ index 000000000..91c678497 +} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/model/CameraPositionControllerTest.java b/src/test/java/net/raphimc/viabedrock/protocol/model/CameraPositionControllerTest.java new file mode 100644 -index 000000000..daf333e53 +index 000000000..81fd6523f --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/protocol/model/CameraPositionControllerTest.java -@@ -0,0 +1,346 @@ +@@ -0,0 +1,373 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -11206,13 +11447,40 @@ index 000000000..daf333e53 + } + + @Test ++ void fixedBoomCommandsResolveDefaultsOverridesAndAttachmentWithoutOrbitInput() { ++ final var registry = new CameraPresetRegistry(List.of(preset("minecraft:fixed_boom", "", 0), ++ preset("example:fixed", "minecraft:fixed_boom", 0), preset("minecraft:follow_orbit", "", 0))); ++ final var controller = new CameraPositionController(); ++ controller.resetPresets(registry); ++ assertTrue(controller.apply(set(1, null, null, null), registry)); ++ assertEquals(CameraPositionState.Mode.BOOM, controller.state().mode()); ++ var selected = controller.follow(1.62F).presets().get(1); ++ assertTrue(selected.fixed()); ++ assertEquals(10, selected.radius()); ++ assertEquals(45, selected.pitch()); ++ assertEquals(45, selected.yaw()); ++ assertFalse(controller.follow(1.62F).presets().get(2).fixed()); ++ controller.apply(set(1, null, new CameraInstruction.Vec2(100, 720), null), registry); ++ selected = controller.follow(1.62F).presets().get(1); ++ assertEquals(100, selected.pitch()); ++ assertEquals(720, selected.yaw()); ++ assertTrue(selected.rotationSequence() > 0); ++ controller.apply(new CameraInstruction(null, null, null, null, null, null, null, -9L, null), registry); ++ assertEquals(-9, controller.follow(1.62F).actorUniqueId()); ++ controller.apply(set(1, null, null, true), registry); ++ selected = controller.follow(1.62F).presets().get(1); ++ assertEquals(45, selected.pitch()); ++ assertEquals(45, selected.yaw()); ++ } ++ ++ @Test + void followOverridesAndAttachmentsKeepIndependentPresetState() { + final var registry = new CameraPresetRegistry(List.of(preset("minecraft:free", "", 0), + preset("minecraft:follow_orbit", "", 0), preset("example:follow", "minecraft:follow_orbit", 0))); + final var controller = new CameraPositionController(); + controller.apply(new CameraInstruction(new CameraInstruction.SetCamera(1, null, null, new CameraInstruction.Vec2(30, 60), null, + new CameraInstruction.Vec2(2, 3), new CameraInstruction.Vec3(1, 2, 3), null, false), null, null, null, null, null, null, null, null), registry); -+ assertEquals(CameraPositionState.Mode.FOLLOW_ORBIT, controller.state().mode()); ++ assertEquals(CameraPositionState.Mode.BOOM, controller.state().mode()); + assertTrue(controller.state().playerEffects()); + final long positionSequence = controller.state().sequence(); + controller.apply(new CameraInstruction(null, true, null, null, null, null, null, -9L, null), registry); @@ -11969,7 +12237,7 @@ index 000000000..96f771f04 +} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/storage/CameraEffectsStorageTest.java b/src/test/java/net/raphimc/viabedrock/protocol/storage/CameraEffectsStorageTest.java new file mode 100644 -index 000000000..292126ebd +index 000000000..6bb453655 --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/protocol/storage/CameraEffectsStorageTest.java @@ -0,0 +1,255 @@ @@ -12165,7 +12433,7 @@ index 000000000..292126ebd + when(user.getProtocolInfo()).thenReturn(info); + when(info.getServerState()).thenReturn(State.PLAY); + final var channels = new ChannelStorage(); -+ channels.addChannels(List.of(CameraPositionState.CHANNEL)); ++ channels.addChannels(List.of(CameraPositionState.CHANNEL, "viabedrock:camera_follow")); + when(user.get(ChannelStorage.class)).thenReturn(channels); + final var entities = mock(EntityTracker.class); + final var player = mock(ClientPlayerEntity.class); @@ -12186,7 +12454,7 @@ index 000000000..292126ebd + storage.sendSnapshot(); + final var positions = ArgumentCaptor.forClass(CameraPositionState.class); + verify(packet).write(eq(CameraPositionState.TYPE), positions.capture()); -+ assertEquals(CameraPositionState.Mode.FOLLOW_ORBIT, positions.getValue().mode()); ++ assertEquals(CameraPositionState.Mode.BOOM, positions.getValue().mode()); + final var follow = ArgumentCaptor.forClass(CameraFollowState.class); + verify(packet).write(eq(CameraFollowState.TYPE), follow.capture()); + assertEquals(positions.getValue().sequence(), follow.getValue().positionSequence()); diff --git a/patches/viabedrock/upstreamable/0089-resolve-and-transport-native-camera-instructions.pr.md b/patches/viabedrock/upstreamable/0089-resolve-and-transport-native-camera-instructions.pr.md index 2ba2d83b..a99cc3d1 100644 --- a/patches/viabedrock/upstreamable/0089-resolve-and-transport-native-camera-instructions.pr.md +++ b/patches/viabedrock/upstreamable/0089-resolve-and-transport-native-camera-instructions.pr.md @@ -764,3 +764,91 @@ All four projects build. The final core suite has 1,274 passing tests and 32 optional skips; the add-on has 816 passes and 120 optional skips. Both Checkstyle suites, offline validation and stack health pass. The additional surface test changes no production classes in the built artifact. + +## Fixed-boom execution + +Fixed-boom presets now execute through the shared attached-camera runtime. +The position controller accepts the native base and retains independent defaults, rotation overrides, view offsets, entity offsets, and attachment generations. +The shared `BOOM` rendering mode keeps its existing wire ordinal. +The changed follow payload negotiates `viabedrock:camera_follow_v2` and version two before core sends an attached position command. +A peer with only the earlier follow channel does not receive the changed format. + +Fixed-boom orientation stays independent of actor yaw and orbit input. +Pitch clamps at the native boundaries of ±89.9°. +The upper boundary uses the native stored polar constant, while the lower boundary retains its separate float calculation. +Entity offsets rotate in fixed-camera space. +Avoidance receives measured boom distance instead of an orbit target radius. +The pinned fixed-boom and follow-orbit definitions share a zero relaxation spring and minimum distance of 0.25 blocks. + +The reference uses Bedrock 1.26.51.1/build51061372, protocol 2193. +Its binary SHA-256 is `537c0aee2e79afbdc94b44b28e00f466ae62bc50e2733d953b430db9dbaa9ee7`. +All 192 controls execute default/starting callbacks `0x1466f7e90`/`0x1466f7f30`, default/explicit attachment `0x14677ec00`/`0x14677ecb0`, and matrix `0x14677e5a0`. +They also execute fixed transform `0x14677eed0`, offset composition `0x1466f73f0`, and look-at `0x14677b300`. +Parsed components, actor origin, radius, offsets, starting rotation, and attachment points are supplied inputs. +Empty ECS registration, optional point-provider results, and float CRT math remain supplied boundaries. +These controls exclude actual pivot producers, constructor and packet flow, complete ECS scheduling, avoidance/world lookup, and GPU rendering. + +Mandatory tests cover command defaults and overrides, camera replacement, actor movement and rotation, input rejection, payload negotiation, and attachment lifetime retention. +The production runtime matches all 192 fixed frames and the existing 256 follow frames and 768 follow collision frames. +Three deliberate variants fail: actor-yaw attachment space, missing pitch boundaries, and rejection of fixed-boom commands. +Actual native player pivots, complete control-scheme mappings, native geometry providers, and visible parity remain open. + +The final combined four-project build passes 2,600 tests, with 167 optional skips and no failures or errors. +Native fixed-boom, follow-frame, follow-collision, and avoidance comparisons run without skips. +Retained particle, attachable, and Iris GPU comparisons also run without skips. +All 1,412 core classes match the add-on and proxy copies, and all 1,139 production client classes match the add-on. +The 104-patch core stack and 38-patch add-on stack replay successfully. +This build includes the newer native interpolation packet metadata. + +A fresh BDS 1.26.51.1 session exercises the exact installed artifact over direct NetherNet. +Read-only observations verify 48 camera samples across eleven stages. +The default fixed view keeps its position while the player turns without moving. +Explicit rotation and player translation preserve the expected offsets. +An armor-stand attachment keeps fixed orientation through actor rotation, follows actor translation, and detaches to the player. +The run also verifies view/entity offsets, the orbit switch, and clear. +Installed artifact checksums match, and all owned client, display, and server processes stop normally. +This run verifies the stated Java route, while native pivot height, control schemes, full lifecycle coverage, and native pixels remain unverified. + + +## Fixed attachment retirement + +Fixed-boom now retires an attachment after its observed native actor lifetime disappears or changes. +It resumes the default player attachment, including later player movement. +A normal camera snapshot or preset reselection cannot restore the retired attachment. +A new attachment command permits a new actor lifetime. +Orbit retains its separate component behavior. + +The add-on keeps native identity available while the Java entity or physical bounds remain pending. +These pending samples retain the previous frame without false retirement. +If the local player is unavailable after retirement, the runtime waits without reacquiring the old attachment. + +The pinned binary and version match the fixed-boom reference above. +Thirty-two native controls cover fixed and orbit callbacks, both attachment point enums, and eight actor lookup states. +They execute callbacks `0x14677e970`/`0x14677ecb0`, point provider `0x14672dbf0`, weak lookup `0x14672dd10`, center `0x1419f8180`, and matrix `0x14677e5a0`. +The controls cover valid actors, removed actors, stale generations, missing sparse/type/data storage, expired worlds, and absent client levels. +Failed fixed lookup clears the weak handle, releases its reference, and requests attachment component erasure. +Orbit retains its handle in the same controls. +The original default attachment, fixed transform, offset, and look-at functions then use a supplied player origin. +The default fixed view excludes the attachment component, and the attachment system orders its default pass after the explicit pass. + +Typed registries, actor positions, bounds, generations, weak controls, and parsed components remain supplied inputs. +Client-level and weak-world resolution, attachment pool lookup and erasure, and float CRT calls remain supplied boundaries. +These controls do not execute the full ECS scheduler or actual player pivot producers. +Retained orbit controls supply an unchanged origin; they do not prove native post-loss orbit origin lookup or complete frozen-pose behavior. + +Mandatory tests cover retirement, identity reuse, ordinary snapshots, new attachment commands, mixed presets, and pending samples. +Four deliberate variants fail: missing fixed retirement, orbit retirement, retirement reset on ordinary snapshots, and pending samples treated as removal. +The production runtime matches all 32 native frames and retains the earlier fixed, follow, collision, and avoidance comparisons. + +A fresh BDS 1.26.51.1 session over direct NetherNet verifies 42 actual camera observations across nine stages. +The exact installed build covers attachment, actor removal, player movement, preset reselection, replacement presence, explicit reattachment, detach, and clear. +The live replacement has a new actor ID; native and unit controls cover reused identity. +Installed artifact checksums match, and all nine owned client, display, and server process identities are gone after normal shutdown. + +Initial command-time identity ownership before the first observed lifetime remains open. +Native player pivots, complete ECS scheduling, post-loss orbit origin lookup, and native pixels also remain unverified. + +The combined four-project build passes 2,604 tests, with 167 optional skips and no failures or errors. +Native camera, particle, attachable, and Iris GPU comparisons run without skips. +All 1,413 core classes match both packaged copies, and all 1,139 production client classes match the add-on. +The core and add-on stacks replay successfully. 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 f8ae78ac..313e2437 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 aaa154f20c0c04bfdb150bed6fe905fb9abd3a26 Mon Sep 17 00:00:00 2001 +From a21c7c7627a7c3b1f69d460543215bfc4c78625d 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 @@ -29,9 +29,9 @@ grounded state and clear horizontal collision on the release frame. Apply corrected player velocity in all poses and retain zero-velocity resets. Java relative velocity flags previously discarded this authority outside -gliding. Corrections without usable client history must still apply rather -than disappear due to a tick window check. This preserves authoritative -position and motion; input-history resimulation remains a separate gap. +gliding. Connections without the negotiated physical correction channel apply +these values directly. Reserve tick zero as the target receiver does. +Full native history admission and input resimulation remain required. Native 1.26.51.1 protocol 2193 pool-floor jumping holds Jumping, JumpDown and WantUp without StartJumping. The old grounded-plus-held heuristic @@ -433,85 +433,343 @@ A packet-level regression translates the captured native Y into exact Java feet and returns four completed frames to the same native anchor. This corrects the shared origin conversion; native aim selection, camera update scheduling and complete physics trajectory parity remain separate. + +Native 1.26.51.1 protocol 2193 correction packet 161 dispatches through +1417342f0 and 14131ab90 to 144359100. Its original target selector and +replay lookup reject tick zero before command allocation. Preserve all +unsigned tick bits in the negotiated state, including negative Java longs. +The native lookup also compares the tick with the oldest retained clock. +Matching that cutoff requires actual snapshot history, still incomplete. +An 864-case instruction probe verifies these entry gates and full command +payload copying, with ECS, relation resolution, allocator and dispatch +boundaries. It does not establish command application or physics replay. + +Apply the native vehicle correction velocity through Java entity motion, +including authoritative zero resets. MOVE_VEHICLE carries position and +rotation only. Java 26.3 applies entity motion to delta movement and its +vehicle position handler preserves that value. The original native command +144373f30 passes 48 controls for StateVector writes and conditional vehicle +rotation/angular writes. Typed store providers and the ground-tag setter +are fixture boundaries. Packet tests cover boat and horse delivery with +nonzero motion followed by zero. Historical replay and client ground or +angular reconciliation remain incomplete. + +Configure emitted-input clock retention from START_GAME using the native +1..1000 history bound. Retain clocks without a completed prediction and +reset the ring on discontinuities, including duplicate or backward clocks, +while preserving contiguous unsigned wrap and the connection frame watermark. +Reject reserved zero and unsigned clocks older than the oldest retained +input before player or vehicle mutation, motion delivery or payload emission. +Do not impose an unverified future-tick upper bound. + +Pinned 1.26.51.1 instructions execute 512 joined receiver, queue, comparator, +policy and replay controls with an explicit arithmetic physics witness. +Historical position commands compare against the next retained snapshot, +queue there and apply during replay rather than at receipt. Twelve original +installer/constructor controls establish the native ushort capacity clamp. +These probes do not establish malformed signed wire-size conversion or +native world physics. This change implements clock admission and retention; +actual historical component restoration and client physics replay remain open. + +Retain local movement-effect input admissions and ordered server commands +through historical component replay. Snapshot complete effect values before +simulation, restore the first dirty frame, then apply queued unadjusted +commands, captured inputs and countdown. Refresh later snapshots during +replay so subsequent corrections retain already-corrected history. +Preserve zero-duration marker lifetime, pending command transfer and commands +with a zero movement mask until another command requests replay. + +Transport the exact oldest retained unsigned input clock in movement-effect +revision 3. Clamp historical dispatch against that clock, including short +histories and clock discontinuities, instead of inventing a fixed window. +Do not accept revision 2 through a compatibility alias. + +The pinned native receiver, complete capture caller, frame capture, input +fill and replay execute together in 128 controls. Four additional controls +verify that clock discontinuities discard old frames but retain pending +commands and movement masks. Original generated effect inputs reproduce +28 prior activity mismatches; production now matches all 336 immediate +controls and all 84 first historical replays. These controls do not establish +position restoration, native world physics or complete trajectory parity. + +Verify the packed actor snapshot path before selecting mutable history +semantics. Original143276a40 and14327b6f0/14327b7c0 clone and restore +MovementEffectsComponent5c92321d at cached table slot72, including timestamps, +durations and kind. Eighteen controls execute those owned vector copies. +The earlier presence-only probe populated slot76, which is another type; +its claimed effect snapshot semantics were incorrect. The336 generated +input controls remain bounded by their supplied snapshot implementation. + +Retain independent effect snapshots to prevent unrelated timers from aging +twice during a rewind. Refresh subsequent snapshots before commands and +input. Both regressions fail with the previous journal and pass with the +snapshot implementation. Sixty-four original correction/capture/replay +controls now use packed history comparison, capture and restore, including +StateVector36, AABBShape32, rotation16, angular20 and Offsets68. Ten original +VehicleComponent vector controls retain independent historical membership +and order. Controlled ECS/cache providers, extra restore-copy registration, +input phases and world physics remain separate boundaries and gaps. + +Reject correction packets when no input clock is retained, matching the +original receiver before actor state can mutate. This includes initial +startup and START_GAME history reconfiguration. Future clocks remain +eligible for native command retention. Original receiver controls cover +128 player/vehicle cases, including 36 empty histories, and packet +regressions fail before the fix and pass afterward. Full physical actor +restoration and resimulation remain separate unfinished work. + +Expose owned movement-effect values to the client physical replay adapter. +Keep the live prediction clock separate from historical effect reads. Apply +retained effect commands at their input frame and replace local admissions +when physical replay reevaluates sulfur contact. + +Publish owned snapshots of native flow accumulation and cached Swift Sneak +input state. The add-on restores these values with its actor graph; the +core keeps command history and clock ownership. + +Keep the completed frame watermark when a play listener is replaced on +one connection. Discard old actor values and queued commands without +making new prediction frames look like replays to the remote core. + +Pending passenger links can resolve during actor registration, before the +spawn handler initializes vehicle coordinates. Keep prediction unavailable +until player and vehicle positions exist. The ordinary metadata update +then publishes the initialized mount context. This prevents the reproduced +BDS mount-spawn disconnect without granting authority to a partial actor. + +Queue negotiated player and vehicle corrections against retained physical +frames instead of immediately changing the translated actor. Revision two +carries lifetime identity, exact velocity, optional boat angular velocity, +coordinate conventions and the emitted input-to-client-frame binding. +Keep Java position packets paired for their ordinary acknowledgments; +legacy connections retain direct position and motion handling. + +The original 1.26.51.1 receiver at 0x144359100 supplies 128 clock controls: +reject zero and expired unsigned clocks, queue the following snapshot, +cap before incrementing, keep current-clock commands for the next capture, +and retain future commands without starting replay. Reproject the clock +when the client receives the correction, since it can have advanced since +core emitted the payload. Comparator 0x144373d90 uses float squared-vector +tolerance 0.00001 and optional angular tolerance 0.0001, with strict greater +comparison and separate float operations. + +Preserve the current vehicle authority context while queuing a correction. +Discard unconsumed optional physics without manufacturing a new control +lease or losing the completed frame watermark. Validate exact typed +payloads and exercise paired player/boat/horse packets, full-precision +motion, unsigned wrap, delayed receipt, and the native comparator rules. +Historical world/collision inputs and full native pre/post-input phase +alignment remain separate requirements; fixture arithmetic does not +establish native trajectory parity. + +Port the native collision replay input boundary rather than freezing a +whole historical world. CopyCollisionShapesRewindSystem at 0x148b73aa0 +reuses captured shape/reference vectors and fetch bounds only when float +squared position distance is strictly below 4. The live gather kernel at +0x1431c2f50 caps motion at 16, builds step and lower-inset bounds, and queries +only uncovered cache regions. Keep native float plane traversal, 1e-5 +advances, ordered deduplication and 0.001 volume merge comparisons. + +Expose the used query/reuse calculations to the client physical input +adapter. Fresh execution controls compare 128 original gather cases and +64 original reuse cases with the Java implementation, including nonempty +prior vectors. Native world traversal, actor-reference lifetimes, nearby +solid refresh and complete movement trajectories remain separate from +these supplied-store and supplied-world-record instruction controls. + +Port the native world visitor's X/Z/Y loaded-block order and float query +padding. Scan unavailable chunks in X-first order with eight-block margins +and preserve their full-height solid barriers in the retained geometry. +Reproduce the native floor below the world's minimum height. + +The 1.26.51.1 visitor at 0x142d883c0 supplies this behavior. Production Java +traversal and null-source barriers match 64 fresh original instruction +controls. World, chunk state and block shapes are explicit fixture sources; +real block shape generation, native border blocks, component readiness and +complete native collision scheduling remain separate requirements. + +Keep ordinary absolute and sparse spatial updates in the received cache, +then decline their transforms for locally controlled boats and horses. +The native first passenger controls a boat. Horses additionally require +both saddle and tame flags. Resolve that passenger through live unique +and runtime identities rather than reusing prediction eligibility, which +has separate gameplay and capability gates. Teleport and forced-local +bits still admit transforms; force completion alone does not. + +Original 1.26.51.1 client wrappers 148b5eeb0 and 148b5ef20 plus vehicle +setter 148b5ebb0 pass 1,152 controls. Original publisher 14103f580 passes +640 cache/admission controls, including unsigned runtime IDs, stale tags, +forced flags and ordinary ignored updates. Five registered-handler and +ownership tests match 256 representable native owner cases and both +packet forms for all 640 admission cases. Two deliberate production +regressions fail the corresponding tests. Full native control-system +installation, custom actor mapping, retained interpolation and native +vehicle correction trajectories remain unfinished. + +Preserve SET_ENTITY_MOTION server ticks for negotiated physical replay. +The 1.26.51.1 command constructor144359a80, receiver142890810 and +application144376520 apply full float motion immediately and retain it +for the following input snapshot. Unlike corrective positions, old motion +clocks clamp to the oldest input. Future clocks remain queued without +forcing replay. Tick zero and clients without the channel retain the +existing standard velocity translation. + +Fresh original instruction controls cover48 receiver/application cases, +including old/current/future unsigned clocks and both controller outcomes. +Registered Java packet tests and optional native timing comparisons cover +the negotiated path. Component stores, allocator/CRT, snapshots and simple +physical steps are supplied native boundaries. Complete live trajectories, +remote actor interpolation and collider-source integration remain open. + +Pair completed player and controlled vehicle samples before checking the +tracked rider position. A delayed 1.26.51.1 BDS boat trace supplies 95 +matching vehicle samples whose rider positions differ from the tracked +player. Rejecting the rider first loses every shared input-frame binding +and prevents negotiated vehicle corrections from entering replay. + +Consume the completed frame once, validate player position for unmounted +inputs, and bind mounted clocks only after the vehicle position, shared +frame identity, actor, context and chunk admission checks pass. Preserve +watermarks after rejection so old samples cannot bind later input ticks. +Nine focused regression cases cover the stale rider and rejected pairs. + +Retain admitted native movement separately from the receive cache and +publish it with actor lifetime and revision. Collider inputs use the +newest admitted network position before a retained interpolation target, +with native float box arithmetic. Client prediction resets its sources +independently of packet admission; ownership and native input phases +remain explicit. + +Pinned Bedrock 1.26.51.1 instructions establish the movement/motion +position getters and composed publisher/refresh routing. The registered +refresh comparison covers all 256 source and box cases. Receive clocks, +complete remote interpolation, custom actor component classification, +and full native trajectories remain separate work. + +Publish source bootstrap only after its actor snapshot, and clear source +and bounds publication caches on actor retirement. A serialized +publisher-to-registry regression catches both premature source delivery +and missing identical dependencies after re-publication. + +Retire remote-player interpolation for admitted MOVE_PLAYER respawn and +teleport branches. Send an explicit clear action in revision two of the +unreleased spatial codec; do not reinterpret MoveActor flags or overwrite +the separate received/admitted positions. Skip the native riding branch +when its actor resolves, and preserve historical captures during replay. + +Original Bedrock 1.26.51.1/build51061372 protocol2193 instructions compose +the player mode helper, real RemotePlayer virtual, target writers, +respawn/teleport clearing, position-request stores and collider refresh in +3456 cases. Component stores, ready/throttle inputs, region query, CRT +and downstream notifications are supplied boundaries. Four incorrect +variants fail. Normal-player admission producers, local-player virtuals, +riding pose effects and complete native phases remain separate work. --- - build.gradle | 31 +- - .../api/model/CollisionContact.java | 80 ++ - .../viabedrock/api/model/FlightMovement.java | 83 ++ - .../api/model/FlightMovementContext.java | 50 + - .../viabedrock/api/model/FluidCurrent.java | 44 + - .../viabedrock/api/model/GeyserMovement.java | 87 ++ - .../viabedrock/api/model/GlideMovement.java | 64 ++ - .../viabedrock/api/model/MovementEffect.java | 99 ++ - .../api/model/MovementEffectTimeline.java | 105 ++ - .../api/model/PlayerCorrection.java | 45 + - .../api/model/PlayerCorrectionTracker.java | 27 + - .../viabedrock/api/model/PlayerPosture.java | 63 ++ - .../api/model/PlayerPrediction.java | 88 ++ - .../api/model/SneakingInputState.java | 21 + - .../viabedrock/api/model/SwiftSneakState.java | 25 + - .../model/UnderwaterMovementAttribute.java | 42 + - .../api/model/VehiclePrediction.java | 75 ++ - .../api/model/VehiclePredictionContext.java | 90 ++ - .../viabedrock/api/model/WaterMovement.java | 69 ++ - .../api/model/entity/ClientPlayerEntity.java | 112 +- - .../viabedrock/api/model/entity/Entity.java | 65 +- - .../api/model/entity/LivingEntity.java | 32 +- - .../api/model/entity/PlayerEntity.java | 2 +- - .../definition/ItemDefinitions.java | 13 + - .../raphimc/viabedrock/api/util/MathUtil.java | 6 +- - .../viabedrock/protocol/BedrockProtocol.java | 3 + - .../protocol/model/BedrockItem.java | 19 + - .../protocol/model/ItemUseModifiers.java | 50 + - .../protocol/packet/ClientPlayerPackets.java | 79 +- - .../protocol/packet/JoinPackets.java | 1 + - .../packet/MovementEffectPackets.java | 54 + - .../protocol/packet/MultiStatePackets.java | 44 + - .../protocol/packet/WorldPackets.java | 1 + - .../protocol/rewriter/ItemDataRewriter.java | 7 +- - .../protocol/rewriter/ItemRewriter.java | 19 + - .../protocol/storage/EntityTracker.java | 25 + - .../protocol/storage/PlayerInputStorage.java | 11 +- - .../storage/PlayerPredictionStorage.java | 106 ++ - .../storage/VehiclePredictionStorage.java | 145 +++ - .../api/model/CollisionContactTest.java | 60 ++ - .../api/model/FlightMovementTest.java | 112 ++ - .../api/model/FluidCurrentTest.java | 56 + - .../model/GeyserMovementReferenceTest.java | 50 + - .../api/model/GeyserMovementTest.java | 131 +++ - .../api/model/GlideMovementTest.java | 115 ++ - .../model/MovementControllerOrderTest.java | 52 + - .../api/model/MovementEffectTest.java | 105 ++ - .../api/model/MovementEffectTimelineTest.java | 253 +++++ - .../api/model/PlayerCorrectionTest.java | 67 ++ - .../api/model/PlayerPostureTest.java | 152 +++ - .../api/model/PlayerPredictionTest.java | 179 ++++ - .../api/model/SneakingInputStateTest.java | 32 + - .../api/model/SwiftSneakStateTest.java | 32 + - .../UnderwaterMovementAttributeTest.java | 35 + - .../api/model/VehiclePredictionTest.java | 114 ++ - .../api/model/WaterMovementTest.java | 88 ++ - .../ClientPlayerMovementAttributeTest.java | 17 +- - .../entity/FlightMovementSnapshotTest.java | 189 ++++ - .../entity/NativeActorPublicationTest.java | 156 +++ - .../UnderwaterMovementTransportTest.java | 129 +++ - .../api/util/MathUtilMovementTest.java | 44 + - .../model/BedrockItemEnchantmentTest.java | 73 ++ - .../protocol/model/ItemUseModifiersTest.java | 55 + - .../packet/ClientPlayerPacketsTest.java | 32 +- - .../packet/CompletedFramePositionTest.java | 992 ++++++++++++++++++ - .../packet/MovementEffectPacketsTest.java | 116 ++ - .../packet/PlayerCorrectionPacketTest.java | 184 ++++ - .../packet/PlayerPredictionPacketTest.java | 87 ++ - .../protocol/storage/EntityTrackerTest.java | 4 +- - 69 files changed, 5636 insertions(+), 57 deletions(-) + build.gradle | 29 + + .../api/model/ActorMotionCommand.java | 72 ++ + .../api/model/ActorSpatialCommand.java | 80 ++ + .../api/model/CollisionContact.java | 80 ++ + .../viabedrock/api/model/FlightMovement.java | 83 ++ + .../api/model/FlightMovementContext.java | 50 + + .../viabedrock/api/model/FluidCurrent.java | 57 + + .../viabedrock/api/model/GeyserMovement.java | 87 ++ + .../viabedrock/api/model/GlideMovement.java | 64 + + .../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 | 432 +++++++ + .../api/model/NativeActorStates.java | 30 + + .../api/model/NativeMovementSources.java | 137 ++ + .../api/model/PlayerCorrection.java | 138 ++ + .../api/model/PlayerCorrectionTracker.java | 36 + + .../viabedrock/api/model/PlayerPosture.java | 63 + + .../api/model/PlayerPrediction.java | 88 ++ + .../api/model/SneakingInputState.java | 33 + + .../viabedrock/api/model/SwiftSneakState.java | 32 + + .../model/UnderwaterMovementAttribute.java | 42 + + .../api/model/VehiclePrediction.java | 75 ++ + .../api/model/VehiclePredictionContext.java | 90 ++ + .../viabedrock/api/model/WaterMovement.java | 69 + + .../api/model/entity/ClientPlayerEntity.java | 123 +- + .../viabedrock/api/model/entity/Entity.java | 120 +- + .../api/model/entity/LivingEntity.java | 32 +- + .../api/model/entity/PlayerEntity.java | 2 +- + .../definition/ItemDefinitions.java | 13 + + .../raphimc/viabedrock/api/util/MathUtil.java | 6 +- + .../viabedrock/protocol/BedrockProtocol.java | 3 + + .../protocol/model/BedrockItem.java | 19 + + .../protocol/model/ItemUseModifiers.java | 50 + + .../protocol/packet/ClientPlayerPackets.java | 132 +- + .../protocol/packet/EntityPackets.java | 63 +- + .../protocol/packet/JoinPackets.java | 2 + + .../packet/MovementEffectPackets.java | 54 + + .../protocol/packet/MultiStatePackets.java | 48 +- + .../protocol/packet/OtherPlayerPackets.java | 6 +- + .../protocol/packet/WorldPackets.java | 1 + + .../protocol/rewriter/ItemDataRewriter.java | 7 +- + .../protocol/rewriter/ItemRewriter.java | 19 + + .../protocol/storage/EntityTracker.java | 43 + + .../protocol/storage/PlayerInputStorage.java | 11 +- + .../storage/PlayerPredictionStorage.java | 133 ++ + .../storage/VehiclePredictionStorage.java | 149 +++ + .../api/model/ActorMotionCommandTest.java | 76 ++ + .../api/model/ActorSpatialCommandTest.java | 70 + + .../api/model/CollisionContactTest.java | 60 + + .../api/model/FlightMovementTest.java | 112 ++ + .../api/model/FluidCurrentTest.java | 56 + + .../model/GeyserMovementReferenceTest.java | 50 + + .../api/model/GeyserMovementTest.java | 131 ++ + .../api/model/GlideMovementTest.java | 115 ++ + .../api/model/MovementCollisionQueryTest.java | 53 + + .../api/model/MovementCollisionWorldTest.java | 54 + + .../model/MovementControllerOrderTest.java | 52 + + .../model/MovementCorrectionTimingTest.java | 40 + + .../model/MovementEffectPhysicsStateTest.java | 84 ++ + .../api/model/MovementEffectReplayTest.java | 181 +++ + .../api/model/MovementEffectTest.java | 127 ++ + .../api/model/MovementEffectTimelineTest.java | 253 ++++ + .../api/model/NativeMovementSourcesTest.java | 126 ++ + .../api/model/PlayerCorrectionTest.java | 101 ++ + .../api/model/PlayerPostureTest.java | 152 +++ + .../api/model/PlayerPredictionTest.java | 256 ++++ + .../api/model/SneakingInputStateTest.java | 32 + + .../api/model/SwiftSneakStateTest.java | 32 + + .../UnderwaterMovementAttributeTest.java | 35 + + .../api/model/VehiclePredictionTest.java | 114 ++ + .../api/model/WaterMovementTest.java | 88 ++ + .../ClientPlayerMovementAttributeTest.java | 17 +- + .../entity/FlightMovementSnapshotTest.java | 189 +++ + .../entity/NativeActorPublicationTest.java | 214 ++++ + .../UnderwaterMovementTransportTest.java | 129 ++ + .../api/util/MathUtilMovementTest.java | 44 + + .../model/BedrockItemEnchantmentTest.java | 73 ++ + .../protocol/model/ItemUseModifiersTest.java | 55 + + .../packet/ClientPlayerPacketsTest.java | 32 +- + .../packet/CompletedFramePositionTest.java | 1127 +++++++++++++++++ + .../CompletedPredictionPairingTest.java | 132 ++ + .../packet/ControlledVehicleSpatialTest.java | 233 ++++ + .../packet/MovementEffectPacketsTest.java | 116 ++ + .../packet/PlayerCorrectionPacketTest.java | 254 ++++ + .../packet/PlayerPredictionPacketTest.java | 87 ++ + .../packet/PlayerSpatialResetTest.java | 147 +++ + .../packet/TickedMotionPacketTest.java | 156 +++ + .../protocol/storage/EntityTrackerTest.java | 4 +- + 90 files changed, 8701 insertions(+), 72 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 create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/FlightMovement.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/FlightMovementContext.java 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/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/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 create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/PlayerPosture.java @@ -526,15 +784,23 @@ update scheduling and complete physics trajectory parity remain separate. create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/packet/MovementEffectPackets.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/storage/PlayerPredictionStorage.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/storage/VehiclePredictionStorage.java + create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/ActorMotionCommandTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/ActorSpatialCommandTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/CollisionContactTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/FlightMovementTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/FluidCurrentTest.java 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/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 + create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/MovementCorrectionTimingTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/MovementEffectPhysicsStateTest.java + 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/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/PlayerPostureTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/PlayerPredictionTest.java @@ -550,12 +816,16 @@ update scheduling and complete physics trajectory parity remain separate. create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/model/BedrockItemEnchantmentTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/model/ItemUseModifiersTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/packet/CompletedFramePositionTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/packet/CompletedPredictionPairingTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/packet/ControlledVehicleSpatialTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/packet/MovementEffectPacketsTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/packet/PlayerCorrectionPacketTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/packet/PlayerPredictionPacketTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/packet/PlayerSpatialResetTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/packet/TickedMotionPacketTest.java diff --git a/build.gradle b/build.gradle -index eeb1c586e..58e965818 100644 +index b62e3af6d..51269fb3c 100644 --- a/build.gradle +++ b/build.gradle @@ -16,6 +16,21 @@ checkstyleMain { @@ -580,21 +850,6 @@ index eeb1c586e..58e965818 100644 configurations.testImplementation.extendsFrom configurations.compileOnly testing.suites.test { -@@ -79,13 +94,13 @@ repositories { - } - - dependencies { -- testImplementation "org.mockito:mockito-core:5.24.0" - compileOnly "com.viaversion:viaversion-common:5.12.0" - - testImplementation "com.viaversion:viaversion-common:5.12.0" - testImplementation "com.google.guava:guava:33.7.1-jre" - testImplementation "io.netty:netty-handler:4.2.18.Final" - testImplementation "org.junit.jupiter:junit-jupiter:6.1.3" -+ testImplementation "org.mockito:mockito-core:5.24.0" - testRuntimeOnly "org.junit.platform:junit-platform-launcher:6.1.3" - - compileOnly "org.yaml:snakeyaml:2.7" @@ -197,3 +212,17 @@ configurations.testImplementation.extendsFrom configurations.compileOnly testing.suites.test { useJUnitJupiter("6.1.3") @@ -613,6 +868,170 @@ index eeb1c586e..58e965818 100644 + return ["-javaagent:${this.classpath.singleFile}"] + } +} +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/ActorMotionCommand.java b/src/main/java/net/raphimc/viabedrock/api/model/ActorMotionCommand.java +new file mode 100644 +index 000000000..5bb7082dc +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/model/ActorMotionCommand.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 com.viaversion.viaversion.api.type.Type; ++import com.viaversion.viaversion.api.type.Types; ++import io.netty.buffer.ByteBuf; ++import java.util.Objects; ++import java.util.UUID; ++import net.raphimc.viabedrock.protocol.data.ProtocolConstants; ++import net.raphimc.viabedrock.protocol.model.Position3f; ++ ++/** Full-precision live velocity plus its placement in the actor's retained input history. */ ++public record ActorMotionCommand(int entityId, UUID uuid, UUID lifetime, long vehicleEpoch, ++ MovementCorrectionTiming timing, Position3f motion) { ++ ++ public static final String CHANNEL = "viabedrock:actor_motion_v1"; ++ public static final int MAX_PAYLOAD_BYTES = 160; ++ public static final Type TYPE = new Type<>(ActorMotionCommand.class) { ++ @Override ++ public ActorMotionCommand read(final ByteBuf buffer) { ++ if (buffer.readableBytes() > MAX_PAYLOAD_BYTES || Types.VAR_INT.readPrimitive(buffer) != 1 ++ || Types.VAR_INT.readPrimitive(buffer) != ProtocolConstants.BEDROCK_PROTOCOL_VERSION) { ++ throw new IllegalArgumentException("Unsupported actor motion command"); ++ } ++ final var value = new ActorMotionCommand(Types.VAR_INT.readPrimitive(buffer), Types.UUID.read(buffer), Types.UUID.read(buffer), ++ Types.VAR_LONG.readPrimitive(buffer), new MovementCorrectionTiming(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)), new Position3f(buffer.readFloat(), buffer.readFloat(), buffer.readFloat())); ++ if (buffer.isReadable()) { ++ throw new IllegalArgumentException("Trailing actor motion command data"); ++ } ++ return value; ++ } ++ ++ @Override ++ public void write(final ByteBuf buffer, final ActorMotionCommand value) { ++ Types.VAR_INT.writePrimitive(buffer, 1); ++ Types.VAR_INT.writePrimitive(buffer, ProtocolConstants.BEDROCK_PROTOCOL_VERSION); ++ Types.VAR_INT.writePrimitive(buffer, value.entityId); ++ Types.UUID.write(buffer, value.uuid); ++ Types.UUID.write(buffer, value.lifetime); ++ Types.VAR_LONG.writePrimitive(buffer, value.vehicleEpoch); ++ Types.VAR_LONG.writePrimitive(buffer, value.timing.serverTick()); ++ Types.VAR_LONG.writePrimitive(buffer, value.timing.inputTick()); ++ Types.VAR_LONG.writePrimitive(buffer, value.timing.inputFrame()); ++ Types.VAR_LONG.writePrimitive(buffer, value.timing.historyStartTick()); ++ Types.VAR_LONG.writePrimitive(buffer, value.timing.followingFrame()); ++ buffer.writeFloat(value.motion.x()); ++ buffer.writeFloat(value.motion.y()); ++ buffer.writeFloat(value.motion.z()); ++ } ++ }; ++ ++ public ActorMotionCommand { ++ Objects.requireNonNull(uuid); ++ Objects.requireNonNull(lifetime); ++ Objects.requireNonNull(timing); ++ Objects.requireNonNull(motion); ++ if (timing.serverTick() == 0 || !Float.isFinite(motion.x()) || !Float.isFinite(motion.y()) || !Float.isFinite(motion.z())) { ++ throw new IllegalArgumentException("Invalid actor motion command"); ++ } ++ } ++ ++ public boolean vehicle() { ++ return this.vehicleEpoch != -1; ++ } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/ActorSpatialCommand.java b/src/main/java/net/raphimc/viabedrock/api/model/ActorSpatialCommand.java +new file mode 100644 +index 000000000..2df5cf0f4 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/model/ActorSpatialCommand.java +@@ -0,0 +1,80 @@ ++/* ++ * 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 io.netty.buffer.ByteBuf; ++import net.raphimc.viabedrock.protocol.data.ProtocolConstants; ++import net.raphimc.viabedrock.protocol.model.Position3f; ++ ++import java.util.Objects; ++import java.util.UUID; ++ ++/** Admitted native movement, independent of both the receive cache and Java interpolation. */ ++public record ActorSpatialCommand(Action action, int entityId, UUID uuid, UUID lifetime, boolean localPlayer, long revision, ++ Position3f position, short flags, long duration, boolean predictionComponent, ++ boolean headRotation, boolean clientPredicted, float originOffset) { ++ ++ public static final String CHANNEL = "viabedrock:actor_spatial"; ++ public static final int MAX_PAYLOAD_BYTES = 128; ++ public static final Type TYPE = new Type<>(ActorSpatialCommand.class) { ++ @Override ++ public ActorSpatialCommand read(final ByteBuf buffer) { ++ if (buffer.readableBytes() > MAX_PAYLOAD_BYTES || Types.VAR_INT.readPrimitive(buffer) != 2 ++ || Types.VAR_INT.readPrimitive(buffer) != ProtocolConstants.BEDROCK_PROTOCOL_VERSION) { ++ throw new IllegalArgumentException("Unsupported actor spatial command"); ++ } ++ final var result = new ActorSpatialCommand(readAction(buffer), Types.VAR_INT.readPrimitive(buffer), Types.UUID.read(buffer), ++ Types.UUID.read(buffer), buffer.readBoolean(), buffer.readLong(), ++ new Position3f(buffer.readFloat(), buffer.readFloat(), buffer.readFloat()), buffer.readUnsignedByte(), ++ buffer.readLong(), buffer.readBoolean(), buffer.readBoolean(), buffer.readBoolean(), buffer.readFloat()); ++ if (buffer.isReadable()) { ++ throw new IllegalArgumentException("Trailing actor spatial command data"); ++ } ++ return result; ++ } ++ ++ @Override ++ public void write(final ByteBuf buffer, final ActorSpatialCommand value) { ++ Types.VAR_INT.writePrimitive(buffer, 2); ++ Types.VAR_INT.writePrimitive(buffer, ProtocolConstants.BEDROCK_PROTOCOL_VERSION); ++ buffer.writeByte(value.action.ordinal()); ++ Types.VAR_INT.writePrimitive(buffer, value.entityId); ++ Types.UUID.write(buffer, value.uuid); ++ Types.UUID.write(buffer, value.lifetime); ++ buffer.writeBoolean(value.localPlayer).writeLong(value.revision); ++ buffer.writeFloat(value.position.x()).writeFloat(value.position.y()).writeFloat(value.position.z()); ++ buffer.writeByte(value.flags).writeLong(value.duration).writeBoolean(value.predictionComponent) ++ .writeBoolean(value.headRotation).writeBoolean(value.clientPredicted).writeFloat(value.originOffset); ++ } ++ }; ++ ++ private static Action readAction(final ByteBuf buffer) { ++ return switch (buffer.readUnsignedByte()) { ++ case 0 -> Action.MOVE; ++ case 1 -> Action.CLEAR_INTERPOLATION; ++ default -> throw new IllegalArgumentException("Unknown actor spatial action"); ++ }; ++ } ++ ++ public enum Action { ++ MOVE, ++ CLEAR_INTERPOLATION ++ } ++ ++ public ActorSpatialCommand { ++ Objects.requireNonNull(action); ++ Objects.requireNonNull(uuid); ++ Objects.requireNonNull(lifetime); ++ Objects.requireNonNull(position); ++ if (revision < 0 || flags < 0 || flags > 255 || !Float.isFinite(originOffset) ++ || !Float.isFinite(position.x()) || !Float.isFinite(position.y()) || !Float.isFinite(position.z())) { ++ throw new IllegalArgumentException("Invalid actor spatial command"); ++ } ++ } ++ ++} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/CollisionContact.java b/src/main/java/net/raphimc/viabedrock/api/model/CollisionContact.java new file mode 100644 index 000000000..925b0654a @@ -846,10 +1265,10 @@ index 000000000..9c6102bcf +} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/FluidCurrent.java b/src/main/java/net/raphimc/viabedrock/api/model/FluidCurrent.java new file mode 100644 -index 000000000..45fe897b6 +index 000000000..54566a4cb --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/model/FluidCurrent.java -@@ -0,0 +1,44 @@ +@@ -0,0 +1,57 @@ +/* + * This file is part of ViaBedrock - https://github.com/ViaVersionAddons/ViaBedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -869,6 +1288,16 @@ index 000000000..45fe897b6 + private float y; + private float z; + ++ public State snapshot() { ++ return new State(this.x, this.y, this.z); ++ } ++ ++ public void restore(final State state) { ++ this.x = state.x(); ++ this.y = state.y(); ++ this.z = state.z(); ++ } ++ + public void reset() { + this.x = this.y = this.z = 0F; + } @@ -893,6 +1322,9 @@ index 000000000..45fe897b6 + return new Position3f(normalizedX * strength + motion.x(), normalizedY * strength + motion.y(), normalizedZ * strength + motion.z()); + } + ++ public record State(float x, float y, float z) { ++ } ++ +} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/GeyserMovement.java b/src/main/java/net/raphimc/viabedrock/api/model/GeyserMovement.java new file mode 100644 @@ -1057,12 +1489,341 @@ index 000000000..e3df1f47a + } + +} +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 000000000..2f0075e6a +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/model/MovementCollisionQuery.java +@@ -0,0 +1,181 @@ ++/* ++ * 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 net.raphimc.viabedrock.protocol.model.Position3f; ++ ++import java.util.ArrayList; ++import java.util.List; ++ ++/** Native 1.26.51.1 collision-cache admission, swept query, and uncovered-region traversal. */ ++public final class MovementCollisionQuery { ++ ++ private MovementCollisionQuery() { ++ } ++ ++ public static boolean reuses(final Position3f captured, final Position3f corrected) { ++ final float x = captured.x() - corrected.x(); ++ final float y = captured.y() - corrected.y(); ++ final float z = captured.z() - corrected.z(); ++ return z * z + (y * y + x * x) < 4F; ++ } ++ ++ public static Position3f cap(final Position3f motion) { ++ final float squared = (motion.x() * motion.x() + motion.y() * motion.y()) + motion.z() * motion.z(); ++ if (!(squared > 256F)) { ++ return motion; ++ } ++ final float length = (float) Math.sqrt(squared); ++ return new Position3f((motion.x() / length) * 16F, (motion.y() / length) * 16F, (motion.z() / length) * 16F); ++ } ++ ++ /** Includes upward stepping, the lower inset probe, and native vertical query padding. */ ++ public static CollisionContact.Box bounds(final CollisionContact.Box body, final Position3f motion, final float stepHeight) { ++ final float[] box = normalized(values(body)); ++ final float[] swept = grow(box, motion.x(), motion.y(), motion.z()); ++ final float[] step = union(union(box, swept), union(grow(box, 0F, stepHeight, 0F), ++ grow(box, motion.x(), stepHeight + motion.y(), motion.z()))); ++ final float[] inset = box.clone(); ++ for (int axis : new int[]{0, 2}) { ++ float min = box[axis] + 0.025F; ++ float max = box[axis + 3] - 0.025F; ++ if (max < min) { ++ min = (box[axis] + box[axis + 3]) * 0.5F; ++ max = min; ++ } ++ inset[axis] = min; ++ inset[axis + 3] = max; ++ } ++ final float lower = stepHeight * 1.01F; ++ inset[1] -= lower; ++ inset[4] -= lower; ++ final float[] query = union(step, grow(inset, motion.x(), 0F, motion.z())); ++ query[1] = Math.min(query[1], (-0.2F - Math.abs(motion.y())) + box[1]); ++ query[4] = Math.max(query[4], box[4] + 0.08F); ++ return box(union(query, box)); ++ } ++ ++ public static boolean contains(final CollisionContact.Box outer, final CollisionContact.Box inner) { ++ return outer != null && outer.minX() <= inner.minX() && outer.minY() <= inner.minY() && outer.minZ() <= inner.minZ() ++ && outer.maxX() >= inner.maxX() && outer.maxY() >= inner.maxY() && outer.maxZ() >= inner.maxZ(); ++ } ++ ++ /** Preserve native plane order, float advances, deduplication, and volume-based merging. */ ++ public static List missing(final CollisionContact.Box query, final CollisionContact.Box cached) { ++ if (contains(cached, query)) { ++ return List.of(); ++ } ++ if (cached == null || cached.maxX() <= cached.minX() || cached.maxY() <= cached.minY() || cached.maxZ() <= cached.minZ() ++ || cached.maxX() <= query.minX() || query.maxX() <= cached.minX() ++ || cached.maxY() <= query.minY() || query.maxY() <= cached.minY() ++ || cached.maxZ() <= query.minZ() || query.maxZ() <= cached.minZ()) { ++ return List.of(query); ++ } ++ final float[] requested = values(query); ++ final float[] previous = values(cached); ++ final float[] size = new float[3]; ++ final float[][] planes = new float[3][2]; ++ for (int axis = 0; axis < 3; axis++) { ++ size[axis] = requested[axis + 3] - requested[axis]; ++ planes[axis][0] = previous[axis] - requested[axis]; ++ planes[axis][1] = previous[axis + 3] - requested[axis]; ++ } ++ final List pending = new ArrayList<>(); ++ final List cells = new ArrayList<>(); ++ pending.add(new float[3]); ++ for (int index = 0; index < pending.size(); index++) { ++ final float[] origin = pending.get(index); ++ final float[] ends = new float[3]; ++ boolean interior = true; ++ for (int axis = 0; axis < 3; axis++) { ++ final int first = before(planes[axis], origin[axis]); ++ final int last = before(planes[axis], size[axis]); ++ if (first == last) { ++ ends[axis] = size[axis]; ++ interior &= first == 1; ++ } else { ++ ends[axis] = planes[axis][first]; ++ final float[] candidate = origin.clone(); ++ candidate[axis] = ends[axis] + 0.00001F; ++ if (pending.stream().noneMatch(prior -> same(candidate, prior))) { ++ pending.add(candidate); ++ } ++ interior &= first != 0; ++ } ++ } ++ final float[] cell = normalized(new float[]{origin[0], origin[1], origin[2], ends[0], ends[1], ends[2]}); ++ if (!interior && cell[0] < cell[3] && cell[1] < cell[4] && cell[2] < cell[5]) { ++ cells.add(cell); ++ } ++ } ++ final List emitted = new ArrayList<>(); ++ for (float[] cell : cells) { ++ boolean merged = false; ++ for (float[] prior : emitted) { ++ // Native compares a local candidate with the already translated emitted boxes. ++ final float[] combined = union(cell, prior); ++ if (Math.abs(volume(combined) - (volume(prior) + volume(cell))) <= 0.001F) { ++ System.arraycopy(combined, 0, prior, 0, 6); ++ merged = true; ++ break; ++ } ++ } ++ if (!merged) { ++ final float[] translated = new float[6]; ++ for (int index = 0; index < 6; index++) { ++ translated[index] = cell[index] + requested[index % 3]; ++ } ++ emitted.add(translated); ++ } ++ } ++ return emitted.stream().map(MovementCollisionQuery::box).toList(); ++ } ++ ++ private static int before(final float[] planes, final float value) { ++ return (planes[0] < value ? 1 : 0) + (planes[1] < value ? 1 : 0); ++ } ++ ++ private static boolean same(final float[] a, final float[] b) { ++ return Math.abs(a[0] - b[0]) <= 0x1.0p-23F && Math.abs(a[1] - b[1]) <= 0x1.0p-23F ++ && Math.abs(a[2] - b[2]) <= 0x1.0p-23F; ++ } ++ ++ private static float volume(final float[] box) { ++ return ((box[3] - box[0]) * (box[4] - box[1])) * (box[5] - box[2]); ++ } ++ ++ private static float[] normalized(final float[] box) { ++ return new float[]{Math.min(box[0], box[3]), Math.min(box[1], box[4]), Math.min(box[2], box[5]), ++ Math.max(box[0], box[3]), Math.max(box[1], box[4]), Math.max(box[2], box[5])}; ++ } ++ ++ private static float[] union(final float[] a, final float[] b) { ++ return new float[]{Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.min(a[2], b[2]), ++ Math.max(a[3], b[3]), Math.max(a[4], b[4]), Math.max(a[5], b[5])}; ++ } ++ ++ private static float[] grow(final float[] box, final float x, final float y, final float z) { ++ final float[] result = box.clone(); ++ final float[] motion = {x, y, z}; ++ for (int axis = 0; axis < 3; axis++) { ++ if (motion[axis] < 0F) { ++ result[axis] += motion[axis]; ++ } else if (motion[axis] > 0F) { ++ result[axis + 3] += motion[axis]; ++ } ++ } ++ return result; ++ } ++ ++ private static float[] values(final CollisionContact.Box box) { ++ return new float[]{box.minX(), box.minY(), box.minZ(), box.maxX(), box.maxY(), box.maxZ()}; ++ } ++ ++ private static CollisionContact.Box box(final float[] values) { ++ return new CollisionContact.Box(values[0], values[1], values[2], values[3], values[4], values[5]); ++ } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/MovementCollisionWorld.java b/src/main/java/net/raphimc/viabedrock/api/model/MovementCollisionWorld.java +new file mode 100644 +index 000000000..9ec345123 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/model/MovementCollisionWorld.java +@@ -0,0 +1,74 @@ ++/* ++ * 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 java.util.function.Consumer; ++ ++/** Native 1.26.51.1 collision-world traversal and unavailable-region geometry. */ ++public final class MovementCollisionWorld { ++ ++ private MovementCollisionWorld() { ++ } ++ ++ /** The native chunk scan advances X first and includes eight blocks of horizontal padding. */ ++ public static void unloaded(final CollisionContact.Box query, final ColumnPredicate loaded, final Consumer shapes) { ++ final int minX = (floor(query.minX()) - 8) >> 4; ++ final int minZ = (floor(query.minZ()) - 8) >> 4; ++ final int maxX = (floor(query.maxX()) + 8) >> 4; ++ final int maxZ = (floor(query.maxZ()) + 8) >> 4; ++ for (int z = minZ; z <= maxZ; z++) { ++ for (int x = minX; x <= maxX; x++) { ++ if (!loaded.test(x, z)) { ++ shapes.accept(new CollisionContact.Box(x * 16, -100000F, z * 16, x * 16 + 16, 100000F, z * 16 + 16)); ++ } ++ } ++ } ++ } ++ ++ public static CollisionContact.Box belowWorld(final CollisionContact.Box query, final int minimumY) { ++ return query.maxY() < minimumY ? new CollisionContact.Box(query.minX(), -Float.MAX_VALUE, query.minZ(), ++ query.maxX(), (short) (minimumY - 40), query.maxZ()) : null; ++ } ++ ++ /** Loaded columns advance X/Z, then visit Y from bottom to top, including the upper endpoint. */ ++ public static void blocks(final CollisionContact.Box query, final int minimumY, final int maximumY, ++ final ColumnPredicate loaded, final BlockVisitor visitor) { ++ final int minY = Math.max(floor(query.minY() - 1F), minimumY); ++ final int maxY = Math.min(floor(query.maxY() + 1F), maximumY); ++ if (minY >= maxY) { ++ return; ++ } ++ final int minX = floor(query.minX() - 1F); ++ final int minZ = floor(query.minZ() - 1F); ++ final int maxX = floor(query.maxX() + 1F); ++ final int maxZ = floor(query.maxZ() + 1F); ++ for (int x = minX; x <= maxX; x++) { ++ for (int z = minZ; z <= maxZ; z++) { ++ if (loaded.test(x, z)) { ++ for (int y = minY; y <= maxY; y++) { ++ visitor.accept(x, y, z); ++ } ++ } ++ } ++ } ++ } ++ ++ private static int floor(final float value) { ++ final int integer = (int) value; ++ return value < integer ? integer - 1 : integer; ++ } ++ ++ @FunctionalInterface ++ public interface ColumnPredicate { ++ boolean test(int x, int z); ++ } ++ ++ @FunctionalInterface ++ public interface BlockVisitor { ++ void accept(int x, int y, int z); ++ } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/MovementCorrectionTiming.java b/src/main/java/net/raphimc/viabedrock/api/model/MovementCorrectionTiming.java +new file mode 100644 +index 000000000..3018f5d39 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/model/MovementCorrectionTiming.java +@@ -0,0 +1,56 @@ ++/* ++ * 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; ++ ++/** Native unsigned input clocks bound to the connection's monotonically increasing physics frames. */ ++public record MovementCorrectionTiming(long serverTick, long inputTick, long inputFrame, long historyStartTick, long followingFrame) { ++ ++ public MovementCorrectionTiming { ++ if (inputFrame < 0 || followingFrame < -1) { ++ throw new IllegalArgumentException("Invalid correction frame binding"); ++ } ++ } ++ ++ /** The receiver queues into the following snapshot, capped at the newest retained snapshot. */ ++ public Placement locate(final long oldestFrame, final long completedFrame) { ++ return this.locate(oldestFrame, completedFrame, false); ++ } ++ ++ /** Explicit velocity commands clamp old clocks to the oldest retained native input. */ ++ public Placement locateMotion(final long oldestFrame, final long completedFrame) { ++ return this.locate(oldestFrame, completedFrame, true); ++ } ++ ++ private Placement locate(final long oldestFrame, final long completedFrame, final boolean clampExpired) { ++ if (this.serverTick == 0 || oldestFrame < 0 || completedFrame < oldestFrame || this.inputFrame > completedFrame) { ++ return null; ++ } ++ final long age = this.inputTick - this.historyStartTick; ++ final long oldest = Long.compareUnsigned(age, this.inputFrame) <= 0 ++ ? Math.max(oldestFrame, this.inputFrame - age) : oldestFrame; ++ if (oldest > completedFrame) { ++ return null; ++ } ++ final long clock = this.inputTick + completedFrame - this.inputFrame; ++ final long firstTick = this.inputTick + oldest - this.inputFrame; ++ final long serverTick = clampExpired && Long.compareUnsigned(this.serverTick, firstTick) < 0 ? firstTick : this.serverTick; ++ if (Long.compareUnsigned(serverTick, firstTick) < 0) { ++ return null; ++ } ++ if (serverTick == clock) { ++ return completedFrame == Long.MAX_VALUE ? null : new Placement(completedFrame + 1, true, true); ++ } ++ final long offset = serverTick - firstTick; ++ final long last = completedFrame - oldest; ++ // Cap before adding one: unsigned MAX_VALUE must not wrap back to the oldest frame. ++ final long target = Long.compareUnsigned(offset, last) < 0 && this.followingFrame >= oldest && this.followingFrame <= completedFrame ++ ? this.followingFrame : Long.compareUnsigned(offset, last) >= 0 ? completedFrame : oldest + offset + 1; ++ return new Placement(target, Long.compareUnsigned(serverTick, clock) < 0, false); ++ } ++ ++ public record Placement(long frame, boolean dirty, boolean pending) { } ++ ++} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/MovementEffect.java b/src/main/java/net/raphimc/viabedrock/api/model/MovementEffect.java new file mode 100644 -index 000000000..6f5ceb6f7 +index 000000000..28720bcdf --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/model/MovementEffect.java -@@ -0,0 +1,99 @@ +@@ -0,0 +1,100 @@ +/* + * This file is part of ViaBedrock - https://github.com/ViaVersionAddons/ViaBedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -1077,24 +1838,24 @@ index 000000000..6f5ceb6f7 +import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.MovementEffectType; + +/** Confirmed effect, exact history binding and the latest paired input clock. */ -+public record MovementEffect(int entityId, int type, int duration, long serverTick, long clientFrame, long inputTick, long inputFrame) { ++public record MovementEffect(int entityId, int type, int duration, long serverTick, long clientFrame, long inputTick, long inputFrame, long historyStartTick) { + -+ public static final String CHANNEL = "viabedrock:movement_effect_v2"; ++ public static final String CHANNEL = "viabedrock:movement_effect_v3"; + public static final Type TYPE = new Type<>(MovementEffect.class) { + @Override + public MovementEffect read(final ByteBuf buffer) { -+ if (Types.VAR_INT.readPrimitive(buffer) != 2 ++ if (Types.VAR_INT.readPrimitive(buffer) != 3 + || Types.VAR_INT.readPrimitive(buffer) != ProtocolConstants.BEDROCK_PROTOCOL_VERSION) { + throw new IllegalArgumentException("Unsupported movement effect"); + } + return new MovementEffect(Types.VAR_INT.readPrimitive(buffer), Types.VAR_INT.readPrimitive(buffer), + 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), Types.VAR_LONG.readPrimitive(buffer), Types.VAR_LONG.readPrimitive(buffer)); + } + + @Override + public void write(final ByteBuf buffer, final MovementEffect effect) { -+ Types.VAR_INT.writePrimitive(buffer, 2); ++ Types.VAR_INT.writePrimitive(buffer, 3); + Types.VAR_INT.writePrimitive(buffer, ProtocolConstants.BEDROCK_PROTOCOL_VERSION); + Types.VAR_INT.writePrimitive(buffer, effect.entityId()); + Types.VAR_INT.writePrimitive(buffer, effect.type()); @@ -1103,12 +1864,13 @@ index 000000000..6f5ceb6f7 + Types.VAR_LONG.writePrimitive(buffer, effect.clientFrame()); + Types.VAR_LONG.writePrimitive(buffer, effect.inputTick()); + Types.VAR_LONG.writePrimitive(buffer, effect.inputFrame()); ++ Types.VAR_LONG.writePrimitive(buffer, effect.historyStartTick()); + } + }; + + public MovementEffect { + if (clientFrame < -1 || inputFrame < -1) { -+ throw new IllegalArgumentException("Invalid movement effect frame identity"); ++ throw new IllegalArgumentException("Invalid movement effect context"); + } + } + @@ -1164,10 +1926,10 @@ index 000000000..6f5ceb6f7 +} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/MovementEffectTimeline.java b/src/main/java/net/raphimc/viabedrock/api/model/MovementEffectTimeline.java new file mode 100644 -index 000000000..6ff5cb106 +index 000000000..56b29385a --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/model/MovementEffectTimeline.java -@@ -0,0 +1,105 @@ +@@ -0,0 +1,432 @@ +/* + * This file is part of ViaBedrock - https://github.com/ViaVersionAddons/ViaBedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -1178,8 +1940,14 @@ index 000000000..6ff5cb106 +import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.MovementEffectType; + +import java.util.EnumMap; ++import java.util.ArrayList; +import java.util.HashMap; ++import java.util.HashSet; ++import java.util.LinkedHashMap; ++import java.util.List; +import java.util.Map; ++import java.util.SequencedMap; ++import java.util.Set; +import java.util.function.IntPredicate; + +/** Client frame clock, local effect prediction and server confirmations. */ @@ -1187,8 +1955,88 @@ index 000000000..6ff5cb106 + + private long completedFrame = -1; + private final Map> effects = new HashMap<>(); ++ private final SequencedMap history = new LinkedHashMap<>(); ++ private final List pending = new ArrayList<>(); ++ private final Set movementMasks = new HashSet<>(); ++ private Frame capturing; ++ ++ /** Open the input record before local world and player simulation. Repeated calls do not replace it. */ ++ public void beginFrame() { ++ if (this.capturing != null) { ++ return; ++ } ++ this.capturing = new Frame(this.completedFrame + 1); ++ this.history.put(this.capturing.id, this.capturing); ++ this.effects.forEach((entityId, states) -> { ++ final Map snapshot = new EnumMap<>(MovementEffectType.class); ++ states.forEach((type, state) -> snapshot.put(type, new EffectSnapshot(state.effect, state.predictedFrame, ++ state.remainingAt(this.completedFrame), state.presentAt(this.completedFrame)))); ++ this.capturing.effects.put(entityId, snapshot); ++ }); ++ for (Command command : this.pending) { ++ this.enqueue(this.capturing, command, true); ++ } ++ this.pending.clear(); ++ if (this.history.size() > 1000) { ++ this.history.pollFirstEntry(); ++ } ++ } + -+ public long completeFrame() { ++ public long frameId() { ++ return this.completedFrame + 1; ++ } ++ ++ /** Immutable values for one actor; command and frame ownership remain in this timeline. */ ++ public EffectValues captureValues(final int entityId) { ++ return new EffectValues(this.effects.getOrDefault(entityId, Map.of())); ++ } ++ ++ public void restoreValues(final int entityId, final EffectValues snapshot) { ++ final Map restored = new EnumMap<>(MovementEffectType.class); ++ restored.putAll(snapshot.values); ++ this.effects.put(entityId, restored); ++ } ++ ++ public long pendingPhysicalFrame(final IntPredicate predictedActor) { ++ return this.history.values().stream() ++ .filter(frame -> frame.dirtyActors.stream().anyMatch(id -> predictedActor.test(id) && this.movementMasks.contains(id))) ++ .mapToLong(frame -> frame.id).findFirst().orElse(-1); ++ } ++ ++ /** Called after restored snapshots and between-frame writes, before the recorded physical input. */ ++ public void applyPhysicalCommands(final long frameId, final IntPredicate predictedActor) { ++ final Frame frame = this.history.get(frameId); ++ if (frame == null) { ++ return; ++ } ++ for (int entityId : Set.copyOf(this.effects.keySet())) { ++ if (!predictedActor.test(entityId)) { ++ continue; ++ } ++ final Map snapshot = new EnumMap<>(MovementEffectType.class); ++ this.effects.get(entityId).forEach((type, value) -> snapshot.put(type, ++ new EffectSnapshot(value.effect, value.predictedFrame, value.remainingAt(frameId - 1), value.presentAt(frameId - 1)))); ++ frame.effects.put(entityId, snapshot); ++ } ++ frame.inputs.removeIf(input -> predictedActor.test(input.entityId())); ++ for (Command command : frame.commands) { ++ final MovementEffect effect = command.effect; ++ if (!predictedActor.test(effect.entityId())) { ++ continue; ++ } ++ this.effects.computeIfAbsent(effect.entityId(), ignored -> new EnumMap<>(MovementEffectType.class)) ++ .put(MovementEffectType.getByValue(effect.type()), ++ new ReplayValue(effect, -1, effect.duration() < -1 ? 0 : effect.duration(), true).snapshot(frameId - 1)); ++ } ++ } ++ ++ public void finishPhysicalReplay(final IntPredicate predictedActor) { ++ this.movementMasks.removeIf(predictedActor::test); ++ this.history.values().forEach(frame -> frame.dirtyActors.removeIf(predictedActor::test)); ++ } ++ ++ public long completeFrame() { ++ this.capturing = null; + return ++this.completedFrame; + } + @@ -1197,9 +2045,18 @@ index 000000000..6ff5cb106 + if (type == null) { + return; + } ++ if (effect.inputFrame() >= 0) { ++ final long age = effect.inputTick() - effect.historyStartTick(); ++ if (Long.compareUnsigned(age, effect.inputFrame()) <= 0) { ++ final long oldestFrame = effect.inputFrame() - age; ++ while (!this.history.isEmpty() && this.history.firstEntry().getKey() < oldestFrame) { ++ this.history.pollFirstEntry(); ++ } ++ } ++ } + final Map actorEffects = this.effects.computeIfAbsent(effect.entityId(), ignored -> new EnumMap<>(MovementEffectType.class)); + final EffectState previous = actorEffects.get(type); -+ final EffectState incoming = new EffectState(effect, -1); ++ final EffectState incoming = this.state(effect, -1); + final boolean knownIncomingState = effect.hasTiming(); + final boolean forced = previous != null && knownIncomingState + && previous.activeAt(this.completedFrame) != incoming.activeAt(this.completedFrame); @@ -1212,34 +2069,169 @@ index 000000000..6ff5cb106 + || forced) { + actorEffects.put(type, incoming); + } ++ // Native command masks compare marker presence, including the zero-duration marker, ++ // with incoming activity. The command remains queued even when its immediate write is ignored. ++ this.schedule(new Command(effect, (previous != null && previous.presentAt(this.completedFrame)) ++ != incoming.activeAt(this.completedFrame))); + } + + public boolean hasGlideBoost(final int entityId) { + return this.hasEffect(entityId, MovementEffectType.GLIDE_BOOST); + } + ++ public boolean hasGlideBoost(final int entityId, final long completedFrame) { ++ final EffectState state = this.effects.getOrDefault(entityId, Map.of()).get(MovementEffectType.GLIDE_BOOST); ++ return state != null && state.activeAt(completedFrame); ++ } ++ + public boolean hasDolphinBoost(final int entityId) { + return this.hasEffect(entityId, MovementEffectType.DOLPHIN_BOOST); + } + ++ public boolean hasDolphinBoost(final int entityId, final long completedFrame) { ++ final EffectState state = this.effects.getOrDefault(entityId, Map.of()).get(MovementEffectType.DOLPHIN_BOOST); ++ return state != null && state.activeAt(completedFrame); ++ } ++ + public boolean hasGeyserBoost(final int entityId) { + return this.hasEffect(entityId, MovementEffectType.GEYSER_BOOST); + } + ++ public boolean hasGeyserBoost(final int entityId, final long completedFrame) { ++ final EffectState state = this.effects.getOrDefault(entityId, Map.of()).get(MovementEffectType.GEYSER_BOOST); ++ return state != null && state.activeAt(completedFrame); ++ } ++ + /** The native sulfur ticker refreshes a 100-tick effect without shortening a longer active effect. */ + public void predictGeyserBoost(final int entityId) { ++ this.predictGeyserBoost(entityId, this.completedFrame + 1); ++ } ++ ++ public void predictGeyserBoost(final int entityId, final long frame) { ++ final long completedFrame = frame - 1; + final MovementEffectType type = MovementEffectType.GEYSER_BOOST; + final Map actorEffects = this.effects.computeIfAbsent(entityId, ignored -> new EnumMap<>(MovementEffectType.class)); + final EffectState state = actorEffects.get(type); + final MovementEffect previous = state == null ? null : state.effect(); -+ final long frame = this.completedFrame + 1; -+ if (state != null && state.activeAt(this.completedFrame) -+ && (state.remainingAt(this.completedFrame) == -1 || state.remainingAt(this.completedFrame) > 100)) { ++ if (state != null && state.activeAt(completedFrame) ++ && (state.remainingAt(completedFrame) == -1 || state.remainingAt(completedFrame) > 100)) { + return; + } + // Keep the wire tick as evidence, separately from the native local ordering timestamp. -+ actorEffects.put(type, new EffectState(new MovementEffect(entityId, type.getValue(), 100, -+ previous == null ? 0 : previous.serverTick(), frame, 0, -1), frame)); ++ final MovementEffect admission = new MovementEffect(entityId, type.getValue(), 100, ++ previous == null ? 0 : previous.serverTick(), frame, 0, -1, 0); ++ actorEffects.put(type, this.state(admission, frame, completedFrame)); ++ final Frame input = this.history.get(frame); ++ if (input != null) { ++ input.inputs.add(admission); ++ } ++ } ++ ++ private EffectState state(final MovementEffect effect, final long predictedFrame) { ++ return this.state(effect, predictedFrame, this.completedFrame); ++ } ++ ++ private EffectState state(final MovementEffect effect, final long predictedFrame, final long completedFrame) { ++ final long frame = predictedFrame < 0 ? Math.max(0, completedFrame) : completedFrame + 1; ++ final int remaining = effect.remainingAt(frame); ++ final long markerThrough = remaining == -1 ? Long.MAX_VALUE ++ : frame + remaining - (predictedFrame < 0 ? 0 : 1); ++ return new EffectState(effect, predictedFrame, markerThrough, -1, 0); ++ } ++ ++ private void schedule(final Command command) { ++ final MovementEffect effect = command.effect; ++ if (effect.inputFrame() < 0 || this.history.isEmpty()) { ++ return; ++ } ++ final long oldest = this.history.firstEntry().getKey(); ++ final long clock = effect.inputTick() + this.completedFrame - effect.inputFrame(); ++ final long tick = Long.compareUnsigned(effect.serverTick(), effect.historyStartTick()) < 0 ++ ? effect.historyStartTick() : effect.serverTick(); ++ final boolean future = Long.compareUnsigned(tick, clock) > 0; ++ final long target; ++ if (future) { ++ target = this.completedFrame; ++ } else { ++ final long age = clock - tick; ++ final long correctedFrame; ++ if (tick == effect.serverTick() && effect.clientFrame() >= 0) { ++ correctedFrame = Math.max(oldest, effect.clientFrame()); ++ } else if (Long.compareUnsigned(age, this.completedFrame - oldest) >= 0) { ++ correctedFrame = oldest; ++ } else { ++ correctedFrame = this.completedFrame - age; ++ } ++ target = correctedFrame + 1; ++ } ++ final Frame frame = this.history.get(target); ++ if (frame != null) { ++ this.enqueue(frame, command, !future); ++ } else if (target == this.completedFrame + 1) { ++ this.pending.add(command); ++ } ++ } ++ ++ private void enqueue(final Frame frame, final Command command, final boolean dirty) { ++ frame.commands.add(command); ++ if (dirty) { ++ frame.dirtyActors.add(command.effect.entityId()); ++ } ++ if (command.forcesMovement) { ++ this.movementMasks.add(command.effect.entityId()); ++ } ++ } ++ ++ /** Replay mutable effect values in native command/input/countdown order, before current physics. */ ++ public void replayCorrections() { ++ if (this.history.isEmpty()) { ++ return; ++ } ++ for (int entityId : Set.copyOf(this.movementMasks)) { ++ final Long dirty = this.history.values().stream().filter(frame -> frame.dirtyActors.contains(entityId)) ++ .map(frame -> frame.id).findFirst().orElse(null); ++ if (dirty == null) { ++ continue; ++ } ++ final Map states = this.effects.computeIfAbsent(entityId, ignored -> new EnumMap<>(MovementEffectType.class)); ++ final Map values = new EnumMap<>(MovementEffectType.class); ++ final Map restored = this.history.get(dirty).effects.get(entityId); ++ if (restored != null) { ++ restored.forEach((type, snapshot) -> values.put(type, snapshot.restore())); ++ } ++ for (Frame frame : this.history.values()) { ++ if (frame.id < dirty) { ++ continue; ++ } ++ // Native replay refreshes subsequent snapshots before their commands and inputs. ++ if (frame.id != dirty) { ++ final Map snapshot = new EnumMap<>(MovementEffectType.class); ++ values.forEach((type, value) -> snapshot.put(type, value.capture())); ++ frame.effects.put(entityId, snapshot); ++ } ++ for (Command command : frame.commands) { ++ if (command.effect.entityId() == entityId) { ++ final MovementEffect effect = command.effect; ++ values.put(MovementEffectType.getByValue(effect.type()), new ReplayValue(effect, -1, ++ effect.duration() < -1 ? 0 : effect.duration(), true)); ++ } ++ } ++ for (MovementEffect input : frame.inputs) { ++ if (input.entityId() == entityId) { ++ values.put(MovementEffectType.getByValue(input.type()), new ReplayValue(input, frame.id, input.duration(), true)); ++ } ++ } ++ // An open frame still has to run physical movement, which consumes the duration ++ // before countdown. Completed frames replay their original countdown now. ++ if (frame.id <= this.completedFrame) { ++ values.values().forEach(ReplayValue::countdown); ++ } ++ frame.dirtyActors.remove(entityId); ++ } ++ states.clear(); ++ values.forEach((type, value) -> states.put(type, value.snapshot(this.completedFrame))); ++ this.movementMasks.remove(entityId); ++ } + } + + private boolean hasEffect(final int entityId, final MovementEffectType type) { @@ -1250,16 +2242,56 @@ index 000000000..6ff5cb106 + + public void retainEntities(final IntPredicate present) { + this.effects.keySet().removeIf(id -> !present.test(id)); ++ this.movementMasks.removeIf(id -> !present.test(id)); ++ this.pending.removeIf(command -> !present.test(command.effect.entityId())); ++ for (Frame frame : this.history.values()) { ++ frame.effects.keySet().removeIf(id -> !present.test(id)); ++ frame.commands.removeIf(command -> !present.test(command.effect.entityId())); ++ frame.inputs.removeIf(input -> !present.test(input.entityId())); ++ frame.dirtyActors.removeIf(id -> !present.test(id)); ++ } + } + + public void clear() { -+ this.effects.clear(); ++ this.resetHistory(); + this.completedFrame = -1; + } + -+ private record EffectState(MovementEffect effect, long predictedFrame) { ++ /** A replacement play listener discards actor state without reusing the connection's completed frame IDs. */ ++ public void resetHistory() { ++ this.effects.clear(); ++ this.history.clear(); ++ this.pending.clear(); ++ this.movementMasks.clear(); ++ this.capturing = null; ++ } ++ ++ public static final class EffectValues { ++ ++ private final Map values; ++ ++ private EffectValues(final Map values) { ++ this.values = Map.copyOf(values); ++ } ++ ++ @Override ++ public boolean equals(final Object other) { ++ return other instanceof EffectValues snapshot && this.values.equals(snapshot.values); ++ } ++ ++ @Override ++ public int hashCode() { ++ return this.values.hashCode(); ++ } ++ ++ } ++ ++ private record EffectState(MovementEffect effect, long predictedFrame, long markerThroughFrame, long snapshotFrame, int remaining) { + + private int remainingAt(final long completedFrame) { ++ if (this.snapshotFrame >= 0) { ++ return this.remaining == -1 ? -1 : (int) Math.max(0, this.remaining - (completedFrame - this.snapshotFrame)); ++ } + // Server snapshots describe the completed frame. Native movement consumes their + // remaining duration before countdown. Local admission occurs in the next physics step. + final long frame = this.predictedFrame < 0 ? Math.max(0, completedFrame) : completedFrame + 1; @@ -1270,15 +2302,291 @@ index 000000000..6ff5cb106 + final int remaining = this.remainingAt(completedFrame); + return remaining == -1 || remaining > 0; + } ++ ++ private boolean presentAt(final long completedFrame) { ++ return completedFrame <= this.markerThroughFrame; ++ } ++ } ++ ++ private record EffectSnapshot(MovementEffect effect, long predictedFrame, int remaining, boolean present) { ++ ++ private ReplayValue restore() { ++ return new ReplayValue(this.effect, this.predictedFrame, this.remaining, this.present); ++ } ++ } ++ ++ private record Command(MovementEffect effect, boolean forcesMovement) { ++ } ++ ++ private static final class Frame { ++ private final long id; ++ private final Map> effects = new HashMap<>(); ++ private final List commands = new ArrayList<>(); ++ private final List inputs = new ArrayList<>(); ++ private final Set dirtyActors = new HashSet<>(); ++ ++ private Frame(final long id) { ++ this.id = id; ++ } ++ } ++ ++ private static final class ReplayValue { ++ private final MovementEffect effect; ++ private final long predictedFrame; ++ private int remaining; ++ private boolean present; ++ ++ private ReplayValue(final MovementEffect effect, final long predictedFrame, final int remaining, final boolean present) { ++ this.effect = effect; ++ this.predictedFrame = predictedFrame; ++ this.remaining = remaining; ++ this.present = present; ++ } ++ ++ private EffectSnapshot capture() { ++ return new EffectSnapshot(this.effect, this.predictedFrame, this.remaining, this.present); ++ } ++ ++ private void countdown() { ++ if (this.remaining > 0) { ++ this.remaining--; ++ } else if (this.remaining == 0) { ++ this.present = false; ++ } ++ } ++ ++ private EffectState snapshot(final long frame) { ++ final long markerThrough = !this.present ? frame - 1 : this.remaining == -1 ? Long.MAX_VALUE : frame + this.remaining; ++ return new EffectState(this.effect, this.predictedFrame, markerThrough, frame, this.remaining); ++ } ++ } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/NativeActorStates.java b/src/main/java/net/raphimc/viabedrock/api/model/NativeActorStates.java +index 82d9ed3a8..34711a6a4 100644 +--- a/src/main/java/net/raphimc/viabedrock/api/model/NativeActorStates.java ++++ b/src/main/java/net/raphimc/viabedrock/api/model/NativeActorStates.java +@@ -10,6 +10,7 @@ import net.raphimc.viabedrock.protocol.data.enums.bedrock.ActorFlags; + import java.util.HashMap; + import java.util.Map; + import java.util.UUID; ++import java.util.function.BiConsumer; + + /** Client-thread registry with native lifetime checks and local Java identity resolution. */ + public final class NativeActorStates { +@@ -19,6 +20,7 @@ public final class NativeActorStates { + private final Map uniqueIds = new HashMap<>(); + private final Map blocking = new HashMap<>(); + private final Map bounds = new HashMap<>(); ++ private final Map movement = new HashMap<>(); + private long tick; + private final Map animations = new HashMap<>(); + +@@ -27,6 +29,9 @@ public final class NativeActorStates { + if (message instanceof NativeActorMessage.Snapshot snapshot) { + final var previous = this.actors.put(uuid, snapshot); + if (previous != null) { ++ if (previous.localPlayer() != snapshot.localPlayer()) { ++ this.movement.remove(uuid); ++ } + this.runtimeIds.remove(previous.runtimeId(), uuid); + if (previous.uniqueId() != null) { + this.uniqueIds.remove(previous.uniqueId(), uuid); +@@ -36,6 +41,7 @@ public final class NativeActorStates { + this.bounds.remove(uuid); + } + if (!previous.lifetime().equals(snapshot.lifetime())) { ++ this.movement.remove(uuid); + this.animations.remove(uuid); + } + } +@@ -73,6 +79,29 @@ public final class NativeActorStates { + this.blocking.remove(uuid); + this.animations.remove(uuid); + this.bounds.remove(uuid); ++ this.movement.remove(uuid); ++ } ++ ++ public void apply(final ActorSpatialCommand message, final UUID localPlayer) { ++ final UUID uuid = message.localPlayer() ? java.util.Objects.requireNonNull(localPlayer) : message.uuid(); ++ final var actor = this.actors.get(uuid); ++ if (actor == null || !actor.lifetime().equals(message.lifetime()) || actor.localPlayer() != message.localPlayer()) { ++ return; ++ } ++ final var current = this.movement.get(uuid); ++ if (current == null) { ++ this.movement.put(uuid, new NativeMovementSources(message)); ++ } else { ++ current.apply(message); ++ } ++ } ++ ++ public NativeMovementSources movement(final UUID uuid) { ++ return this.movement.get(uuid); ++ } ++ ++ public void forEachMovement(final BiConsumer action) { ++ this.movement.forEach(action); + } + + public void apply(final NativeActorBoundsMessage message, final UUID localPlayer) { +@@ -134,6 +163,7 @@ public final class NativeActorStates { + this.uniqueIds.clear(); + this.blocking.clear(); + this.bounds.clear(); ++ this.movement.clear(); + this.tick = 0; + this.animations.clear(); + } +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 000000000..76bf5d591 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/model/NativeMovementSources.java +@@ -0,0 +1,137 @@ ++/* ++ * 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 net.raphimc.viabedrock.protocol.model.Position3f; ++ ++/** Retained network position and interpolation inputs used by the native solid-shape refresh. */ ++public final class NativeMovementSources { ++ ++ private ActorSpatialCommand command; ++ private Position3f historyPosition; ++ private Interpolation interpolation = Interpolation.EMPTY; ++ private QueuedTarget queued; ++ private boolean disabled; ++ ++ public NativeMovementSources(final ActorSpatialCommand command) { ++ this.command = command; ++ this.historyPosition = command.predictionComponent() ? command.position() : null; ++ if (command.revision() != 0) { ++ this.receive(command); ++ } ++ } ++ ++ public void apply(final ActorSpatialCommand next) { ++ if (next.entityId() != this.command.entityId() || !next.lifetime().equals(this.command.lifetime())) { ++ throw new IllegalArgumentException("Spatial command belongs to another actor"); ++ } ++ if (next.revision() < this.command.revision()) { ++ return; ++ } ++ final boolean movement = next.revision() != this.command.revision(); ++ this.command = next; ++ if (movement) { ++ this.receive(next); ++ } ++ } ++ ++ private void receive(final ActorSpatialCommand next) { ++ if (next.action() == ActorSpatialCommand.Action.CLEAR_INTERPOLATION) { ++ this.interpolation = Interpolation.EMPTY; ++ return; ++ } ++ if (next.predictionComponent() && !this.disabled) { ++ this.historyPosition = next.position(); ++ return; ++ } ++ final int steps = Long.compareUnsigned(next.duration(), 3) < 0 ? 3 : (int) next.duration(); ++ final boolean forced = (next.flags() & 8) != 0; ++ var current = this.interpolation; ++ if (!forced && current.mustComplete && current.positionSteps != 0) { ++ this.queued = new QueuedTarget(next.position(), steps); ++ } else { ++ current = new Interpolation(next.position(), steps, steps, 0, forced); ++ this.queued = null; ++ } ++ if (next.headRotation()) { ++ current = new Interpolation(current.target, current.positionSteps, 3, 3, current.mustComplete); ++ } ++ this.interpolation = current; ++ } ++ ++ /** Normal interpolation, queued target and prediction-input phases, before collision gathering. */ ++ public void beforePhysics(final Position3f position) { ++ final var current = this.interpolation; ++ this.interpolation = new Interpolation(current.target, decrement(current.positionSteps), ++ decrement(current.rotationSteps), decrement(current.headSteps), current.mustComplete); ++ if (this.queued != null) { ++ if (this.interpolation.mustComplete && this.interpolation.positionSteps != 0) { ++ this.queued = new QueuedTarget(this.queued.target, Math.max(2, this.queued.steps) - 1); ++ } else { ++ this.interpolation = new Interpolation(this.queued.target, this.queued.steps, this.queued.steps, 0, false); ++ this.queued = null; ++ } ++ } ++ if (this.command.predictionComponent()) { ++ if (this.command.clientPredicted()) { ++ this.disabled = true; ++ this.historyPosition = null; ++ this.interpolation = Interpolation.EMPTY; ++ this.queued = null; ++ } else { ++ if (this.disabled) { ++ this.historyPosition = position; ++ } ++ this.disabled = false; ++ } ++ } ++ } ++ ++ public ActorSpatialCommand command() { ++ return this.command; ++ } ++ ++ public Interpolation interpolation() { ++ return this.interpolation; ++ } ++ ++ public Position3f position(final Interpolation historical) { ++ return select(this.historyPosition, this.command.predictionComponent() && !this.disabled, historical); ++ } ++ ++ public static Position3f select(final Position3f history, final boolean historyEligible, final Interpolation interpolation) { ++ return historyEligible && history != null ? history : interpolation.active() ? interpolation.target : null; ++ } ++ ++ /** All operations round as native floats before the box normalizes its extrema. */ ++ public static CollisionContact.Box bounds(final Position3f point, final float width, final float height, final float originOffset) { ++ final float half = width * .5F; ++ final float minX = point.x() - half; ++ final float maxX = point.x() + half; ++ final float minY = point.y() - originOffset; ++ final float maxY = minY + height; ++ final float minZ = point.z() - half; ++ final float maxZ = point.z() + half; ++ return new CollisionContact.Box(maxX >= minX ? minX : maxX, maxY >= minY ? minY : maxY, maxZ >= minZ ? minZ : maxZ, ++ maxX <= minX ? minX : maxX, maxY <= minY ? minY : maxY, maxZ <= minZ ? minZ : maxZ); ++ } ++ ++ private static int decrement(final int value) { ++ return value > 0 ? value - 1 : value; ++ } ++ ++ public record Interpolation(Position3f target, int positionSteps, int rotationSteps, int headSteps, boolean mustComplete) { ++ public static final Interpolation EMPTY = new Interpolation(Position3f.ZERO, 0, 0, 0, false); ++ ++ public boolean active() { ++ return this.positionSteps != 0 || this.rotationSteps != 0 || this.headSteps != 0; ++ } ++ } ++ ++ private record QueuedTarget(Position3f target, int steps) { + } + +} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/PlayerCorrection.java b/src/main/java/net/raphimc/viabedrock/api/model/PlayerCorrection.java new file mode 100644 -index 000000000..b8f02685d +index 000000000..28d53d634 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/model/PlayerCorrection.java -@@ -0,0 +1,45 @@ +@@ -0,0 +1,138 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -1290,46 +2598,139 @@ index 000000000..b8f02685d +import com.viaversion.viaversion.api.type.Types; +import io.netty.buffer.ByteBuf; +import net.raphimc.viabedrock.protocol.data.ProtocolConstants; ++import net.raphimc.viabedrock.protocol.model.Position2f; ++import net.raphimc.viabedrock.protocol.model.Position3f; ++ ++import java.util.Objects; ++import java.util.UUID; + -+/** Authoritative state absent from Java's player-position packet, paired with that packet's teleport ID. */ -+public record PlayerCorrection(int entityId, int teleportId, long tick, boolean onGround) { ++/** Exact native correction, paired with a Java player or controlled-vehicle position packet. */ ++public record PlayerCorrection(int entityId, UUID uuid, UUID lifetime, int teleportId, long vehicleEpoch, ++ MovementCorrectionTiming timing, Position3f position, Position3f motion, ++ Position2f rotation, Float angularVelocity, float originOffset, float yawOffset, boolean onGround) { + -+ public static final String CHANNEL = "viabedrock:player_correction_v1"; ++ public static final String CHANNEL = "viabedrock:player_correction_v2"; + public static final Type TYPE = new Type<>(PlayerCorrection.class) { + @Override + public PlayerCorrection read(final ByteBuf buffer) { -+ if (Types.VAR_INT.readPrimitive(buffer) != 1 ++ if (Types.VAR_INT.readPrimitive(buffer) != 2 + || Types.VAR_INT.readPrimitive(buffer) != ProtocolConstants.BEDROCK_PROTOCOL_VERSION) { + throw new IllegalArgumentException("Unsupported player correction"); + } -+ return new PlayerCorrection(Types.VAR_INT.readPrimitive(buffer), Types.VAR_INT.readPrimitive(buffer), -+ Types.VAR_LONG.readPrimitive(buffer), buffer.readBoolean()); ++ final PlayerCorrection correction = new PlayerCorrection(Types.VAR_INT.readPrimitive(buffer), Types.UUID.read(buffer), ++ Types.UUID.read(buffer), Types.VAR_INT.readPrimitive(buffer), Types.VAR_LONG.readPrimitive(buffer), ++ new MovementCorrectionTiming(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)), ++ readVector(buffer), readVector(buffer), new Position2f(buffer.readFloat(), buffer.readFloat()), ++ buffer.readBoolean() ? buffer.readFloat() : null, buffer.readFloat(), buffer.readFloat(), buffer.readBoolean()); ++ if (buffer.isReadable()) { ++ throw new IllegalArgumentException("Unexpected player correction fields"); ++ } ++ return correction; + } + + @Override + public void write(final ByteBuf buffer, final PlayerCorrection value) { -+ Types.VAR_INT.writePrimitive(buffer, 1); ++ Types.VAR_INT.writePrimitive(buffer, 2); + Types.VAR_INT.writePrimitive(buffer, ProtocolConstants.BEDROCK_PROTOCOL_VERSION); -+ Types.VAR_INT.writePrimitive(buffer, value.entityId()); -+ Types.VAR_INT.writePrimitive(buffer, value.teleportId()); -+ Types.VAR_LONG.writePrimitive(buffer, value.tick()); -+ buffer.writeBoolean(value.onGround()); ++ Types.VAR_INT.writePrimitive(buffer, value.entityId); ++ Types.UUID.write(buffer, value.uuid); ++ Types.UUID.write(buffer, value.lifetime); ++ Types.VAR_INT.writePrimitive(buffer, value.teleportId); ++ Types.VAR_LONG.writePrimitive(buffer, value.vehicleEpoch); ++ Types.VAR_LONG.writePrimitive(buffer, value.timing.serverTick()); ++ Types.VAR_LONG.writePrimitive(buffer, value.timing.inputTick()); ++ Types.VAR_LONG.writePrimitive(buffer, value.timing.inputFrame()); ++ Types.VAR_LONG.writePrimitive(buffer, value.timing.historyStartTick()); ++ Types.VAR_LONG.writePrimitive(buffer, value.timing.followingFrame()); ++ writeVector(buffer, value.position); ++ writeVector(buffer, value.motion); ++ buffer.writeFloat(value.rotation.x()); ++ buffer.writeFloat(value.rotation.y()); ++ buffer.writeBoolean(value.angularVelocity != null); ++ if (value.angularVelocity != null) { ++ buffer.writeFloat(value.angularVelocity); ++ } ++ buffer.writeFloat(value.originOffset); ++ buffer.writeFloat(value.yawOffset); ++ buffer.writeBoolean(value.onGround); + } + }; + + public PlayerCorrection { -+ if (teleportId == 0 || tick < 0) { ++ Objects.requireNonNull(uuid, "uuid"); ++ Objects.requireNonNull(lifetime, "lifetime"); ++ Objects.requireNonNull(timing, "timing"); ++ if (entityId < 0 || (teleportId == 0 ? vehicleEpoch < 0 : vehicleEpoch != -1)) { + throw new IllegalArgumentException("Invalid player correction identity"); + } ++ requireFinite(position); ++ requireFinite(motion); ++ if (rotation == null || !Float.isFinite(rotation.x()) || !Float.isFinite(rotation.y()) ++ || angularVelocity != null && !Float.isFinite(angularVelocity) ++ || !Float.isFinite(originOffset) || !Float.isFinite(yawOffset)) { ++ throw new IllegalArgumentException("Non-finite player correction"); ++ } ++ } ++ ++ public boolean vehicle() { ++ return this.teleportId == 0; ++ } ++ ++ public Position3f javaPosition() { ++ return this.position.subtract(0F, this.originOffset, 0F); ++ } ++ ++ public float javaYaw() { ++ return this.rotation.y() - this.yawOffset; ++ } ++ ++ /** Match native float subtraction, multiplication and addition order, without wrapped-angle heuristics. */ ++ public boolean differs(final Position3f javaPosition, final Position3f velocity, final boolean ground, ++ final Position2f javaRotation, final Float angular) { ++ final Position3f nativePosition = javaPosition.add(0F, this.originOffset, 0F); ++ return this.onGround != ground || squaredDifference(this.position, nativePosition) > 0.00001F ++ || squaredDifference(this.motion, velocity) > 0.00001F ++ || this.vehicle() && (squaredDifference(this.rotation, new Position2f(javaRotation.x(), javaRotation.y() + this.yawOffset)) > 0.00001F ++ || this.angularVelocity != null && angular != null && Math.abs(angular - this.angularVelocity) > 0.0001F); ++ } ++ ++ private static float squaredDifference(final Position3f a, final Position3f b) { ++ final float x = a.x() - b.x(); ++ final float y = a.y() - b.y(); ++ final float z = a.z() - b.z(); ++ return (y * y + x * x) + z * z; ++ } ++ ++ private static float squaredDifference(final Position2f a, final Position2f b) { ++ final float x = a.x() - b.x(); ++ final float y = a.y() - b.y(); ++ return y * y + x * x; ++ } ++ ++ private static void requireFinite(final Position3f value) { ++ if (value == null || !Float.isFinite(value.x()) || !Float.isFinite(value.y()) || !Float.isFinite(value.z())) { ++ throw new IllegalArgumentException("Non-finite player correction"); ++ } ++ } ++ ++ private static Position3f readVector(final ByteBuf buffer) { ++ return new Position3f(buffer.readFloat(), buffer.readFloat(), buffer.readFloat()); ++ } ++ ++ private static void writeVector(final ByteBuf buffer, final Position3f value) { ++ buffer.writeFloat(value.x()); ++ buffer.writeFloat(value.y()); ++ buffer.writeFloat(value.z()); + } + +} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/PlayerCorrectionTracker.java b/src/main/java/net/raphimc/viabedrock/api/model/PlayerCorrectionTracker.java new file mode 100644 -index 000000000..132277833 +index 000000000..1a719b95d --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/model/PlayerCorrectionTracker.java -@@ -0,0 +1,27 @@ +@@ -0,0 +1,36 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -1337,6 +2738,8 @@ index 000000000..132277833 + */ +package net.raphimc.viabedrock.api.model; + ++import net.raphimc.viabedrock.protocol.model.Position3f; ++ +/** Connection-scoped pairing for a correction payload followed by its standard Java position packet. */ +public final class PlayerCorrectionTracker { + @@ -1346,10 +2749,17 @@ index 000000000..132277833 + this.pending = correction; + } + -+ public PlayerCorrection consume(final int entityId, final int teleportId) { ++ public PlayerCorrection consumePlayer(final int teleportId) { ++ final PlayerCorrection correction = this.pending; ++ this.clear(); ++ return correction != null && !correction.vehicle() && correction.teleportId() == teleportId ? correction : null; ++ } ++ ++ public PlayerCorrection consumeVehicle(final Position3f position, final float yaw, final float pitch) { + final PlayerCorrection correction = this.pending; + this.clear(); -+ return correction != null && correction.entityId() == entityId && correction.teleportId() == teleportId ? correction : null; ++ return correction != null && correction.vehicle() && correction.javaPosition().equals(position) ++ && correction.javaYaw() == yaw && correction.rotation().x() == pitch ? correction : null; + } + + public void clear() { @@ -1522,10 +2932,10 @@ index 000000000..8608d8c05 +} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/SneakingInputState.java b/src/main/java/net/raphimc/viabedrock/api/model/SneakingInputState.java new file mode 100644 -index 000000000..34c380566 +index 000000000..754a2494b --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/model/SneakingInputState.java -@@ -0,0 +1,21 @@ +@@ -0,0 +1,33 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -1546,13 +2956,25 @@ index 000000000..34c380566 + return inputScale; + } + ++ public State snapshot() { ++ return new State(this.swiftSneak.level(), this.scale); ++ } ++ ++ public void restore(final State state) { ++ this.swiftSneak.restore(state.swiftSneakLevel()); ++ this.scale = state.scale(); ++ } ++ ++ public record State(int swiftSneakLevel, float scale) { ++ } ++ +} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/SwiftSneakState.java b/src/main/java/net/raphimc/viabedrock/api/model/SwiftSneakState.java new file mode 100644 -index 000000000..72cfd857b +index 000000000..db527e9b1 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/model/SwiftSneakState.java -@@ -0,0 +1,25 @@ +@@ -0,0 +1,32 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -1573,6 +2995,13 @@ index 000000000..72cfd857b + } + } + ++ public void restore(final int level) { ++ if (level < 0) { ++ throw new IllegalArgumentException("Negative Swift Sneak level"); ++ } ++ this.level = level; ++ } ++ + public int level() { + return this.level; + } @@ -1879,7 +3308,7 @@ index 000000000..bda54dd7d + +} 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 -index f69b430bf..7da0a19ff 100644 +index f69b430bf..dcae62809 100644 --- a/src/main/java/net/raphimc/viabedrock/api/model/entity/ClientPlayerEntity.java +++ b/src/main/java/net/raphimc/viabedrock/api/model/entity/ClientPlayerEntity.java @@ -21,6 +21,7 @@ import com.viaversion.viaversion.api.connection.UserConnection; @@ -1944,7 +3373,7 @@ index f69b430bf..7da0a19ff 100644 @Override public void tick() { super.tick(); -@@ -131,6 +151,11 @@ public class ClientPlayerEntity extends PlayerEntity { +@@ -131,6 +151,16 @@ public class ClientPlayerEntity extends PlayerEntity { } public void writePlayerPositionPacketToClient(final PacketWrapper wrapper, final Set relatives, final boolean fakeTeleport) { @@ -1953,23 +3382,34 @@ index f69b430bf..7da0a19ff 100644 + + public void writePlayerPositionPacketToClient(final PacketWrapper wrapper, final Set relatives, + final boolean fakeTeleport, final Position3f velocity) { ++ this.writePlayerPositionPacketToClient(wrapper, relatives, fakeTeleport, velocity, this.position); ++ } ++ ++ public void writePlayerPositionPacketToClient(final PacketWrapper wrapper, final Set relatives, ++ final boolean fakeTeleport, final Position3f velocity, final Position3f position) { this.serverSideTeleportConfirmed = false; this.pendingTeleportId = this.teleportId.getAndIncrement() * (fakeTeleport ? -1 : 1); if (!fakeTeleport) { -@@ -141,9 +166,9 @@ public class ClientPlayerEntity extends PlayerEntity { - wrapper.write(Types.DOUBLE, relatives.contains(Relative.X) ? 0D : (double) this.position.x()); // x - wrapper.write(Types.DOUBLE, relatives.contains(Relative.Y) ? 0D : (double) (this.position.y() - this.eyeOffset())); // y - wrapper.write(Types.DOUBLE, relatives.contains(Relative.Z) ? 0D : (double) this.position.z()); // z +@@ -138,12 +168,12 @@ public class ClientPlayerEntity extends PlayerEntity { + } + + wrapper.write(Types.VAR_INT, this.pendingTeleportId); // teleport id +- wrapper.write(Types.DOUBLE, relatives.contains(Relative.X) ? 0D : (double) this.position.x()); // x +- wrapper.write(Types.DOUBLE, relatives.contains(Relative.Y) ? 0D : (double) (this.position.y() - this.eyeOffset())); // y +- wrapper.write(Types.DOUBLE, relatives.contains(Relative.Z) ? 0D : (double) this.position.z()); // z - wrapper.write(Types.DOUBLE, 0D); // velocity x - wrapper.write(Types.DOUBLE, 0D); // velocity y - wrapper.write(Types.DOUBLE, 0D); // velocity z ++ wrapper.write(Types.DOUBLE, relatives.contains(Relative.X) ? 0D : (double) position.x()); // x ++ wrapper.write(Types.DOUBLE, relatives.contains(Relative.Y) ? 0D : (double) (position.y() - this.eyeOffset())); // y ++ wrapper.write(Types.DOUBLE, relatives.contains(Relative.Z) ? 0D : (double) position.z()); // z + wrapper.write(Types.DOUBLE, (double) velocity.x()); // velocity x + wrapper.write(Types.DOUBLE, (double) velocity.y()); // velocity y + wrapper.write(Types.DOUBLE, (double) velocity.z()); // velocity z wrapper.write(Types.FLOAT, relatives.contains(Relative.Y_ROT) ? 0F : this.rotation.y()); // yaw wrapper.write(Types.FLOAT, relatives.contains(Relative.X_ROT) ? 0F : this.rotation.x()); // pitch wrapper.write(Types.INT, EnumUtil.getIntBitmaskFromEnumSet(relatives, Relative::ordinal)); // flags -@@ -176,6 +201,16 @@ public class ClientPlayerEntity extends PlayerEntity { +@@ -176,6 +206,16 @@ public class ClientPlayerEntity extends PlayerEntity { animate.sendToServer(BedrockProtocol.class); } @@ -1986,7 +3426,7 @@ index f69b430bf..7da0a19ff 100644 public void updatePlayerPosition(final short flags) { final boolean newOnGround = (flags & MovePlayerFlag.ON_GROUND.getBit()) != 0; -@@ -228,6 +263,11 @@ public class ClientPlayerEntity extends PlayerEntity { +@@ -228,6 +268,11 @@ public class ClientPlayerEntity extends PlayerEntity { this.horizontalCollision = (flags & MovePlayerFlag.HORIZONTAL_COLLISION.getBit()) != 0; } @@ -1998,7 +3438,7 @@ index f69b430bf..7da0a19ff 100644 public void confirmTeleport(final int teleportId) { if (teleportId == 0) { return; -@@ -281,6 +321,10 @@ public class ClientPlayerEntity extends PlayerEntity { +@@ -281,6 +326,10 @@ public class ClientPlayerEntity extends PlayerEntity { public void setPosition(final Position3f position) { super.setPosition(position); this.prevPosition = position; @@ -2009,7 +3449,7 @@ index f69b430bf..7da0a19ff 100644 } @Override -@@ -341,6 +385,36 @@ public class ClientPlayerEntity extends PlayerEntity { +@@ -341,6 +390,36 @@ public class ClientPlayerEntity extends PlayerEntity { javaAbilities.write(Types.BYTE, flags); // flags javaAbilities.write(Types.FLOAT, abilities.getFloatValue(AbilitiesIndex.FlySpeed)); // fly speed javaAbilities.write(Types.FLOAT, abilities.getFloatValue(AbilitiesIndex.WalkSpeed)); // walk speed @@ -2046,7 +3486,7 @@ index f69b430bf..7da0a19ff 100644 } public boolean isInitiallySpawned() { -@@ -357,6 +431,7 @@ public class ClientPlayerEntity extends PlayerEntity { +@@ -357,6 +436,7 @@ public class ClientPlayerEntity extends PlayerEntity { public void setDimensionChangeInfo(final DimensionChangeInfo dimensionChangeInfo) { this.dimensionChangeInfo = dimensionChangeInfo; @@ -2054,7 +3494,7 @@ index f69b430bf..7da0a19ff 100644 } public Set inputFlags() { -@@ -427,11 +502,13 @@ public class ClientPlayerEntity extends PlayerEntity { +@@ -427,11 +507,13 @@ public class ClientPlayerEntity extends PlayerEntity { return this.bedrockSneaking; } @@ -2072,7 +3512,7 @@ index f69b430bf..7da0a19ff 100644 } if (shouldSneak != this.bedrockSneaking) { this.addAuthInputData(shouldSneak ? PlayerAuthInputData.StartSneaking : PlayerAuthInputData.StopSneaking); -@@ -520,6 +597,10 @@ public class ClientPlayerEntity extends PlayerEntity { +@@ -520,6 +602,10 @@ public class ClientPlayerEntity extends PlayerEntity { public void updateJavaGameMode() { this.javaGameMode = GameTypeRewriter.getEffectiveGameMode(this.gameType, this.gameSession.getLevelGameType()); @@ -2083,7 +3523,7 @@ index f69b430bf..7da0a19ff 100644 final PlayerAbilities.AbilitiesLayer abilitiesLayer = this.abilities.getOrCreateCacheLayer(); switch (this.javaGameMode) { -@@ -544,6 +625,7 @@ public class ClientPlayerEntity extends PlayerEntity { +@@ -544,6 +630,7 @@ public class ClientPlayerEntity extends PlayerEntity { abilitiesLayer.setAbility(AbilitiesIndex.NoClip, false); } } @@ -2091,7 +3531,7 @@ index f69b430bf..7da0a19ff 100644 } public boolean checkCancelSwingPacket() { -@@ -618,6 +700,11 @@ public class ClientPlayerEntity extends PlayerEntity { +@@ -618,6 +705,11 @@ public class ClientPlayerEntity extends PlayerEntity { } private boolean preMove(final Position3f newPosition, final Position3f newRotation, final boolean newOnGround) { @@ -2103,7 +3543,7 @@ index f69b430bf..7da0a19ff 100644 final ChunkTracker chunkTracker = this.user.get(ChunkTracker.class); // Allow position packet which is sent immediately after confirming a teleport -@@ -657,15 +744,8 @@ public class ClientPlayerEntity extends PlayerEntity { +@@ -657,15 +749,8 @@ public class ClientPlayerEntity extends PlayerEntity { return false; } @@ -2122,10 +3562,10 @@ index f69b430bf..7da0a19ff 100644 public record DimensionChangeInfo(Long loadingScreenId) { 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 c86ed106b..c66bef8b3 100644 +index 70c675dbf..37fa0d48a 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 -@@ -22,24 +22,27 @@ import com.viaversion.viaversion.api.minecraft.entities.EntityTypes26_3; +@@ -22,14 +22,14 @@ import com.viaversion.viaversion.api.minecraft.entities.EntityTypes26_3; import com.viaversion.viaversion.api.minecraft.entitydata.EntityData; import com.viaversion.viaversion.api.protocol.packet.PacketWrapper; import com.viaversion.viaversion.api.protocol.packet.State; @@ -2138,10 +3578,12 @@ index c86ed106b..c66bef8b3 100644 import com.viaversion.viaversion.protocols.v26_2to26_3.packet.ClientboundPackets26_3; import net.raphimc.viabedrock.ViaBedrock; +import net.raphimc.viabedrock.api.model.MovementEffect; ++import net.raphimc.viabedrock.api.model.ActorSpatialCommand; +import net.raphimc.viabedrock.api.model.NativeActorMessage; import net.raphimc.viabedrock.api.util.EnumUtil; + import net.raphimc.viabedrock.api.util.MathUtil; import net.raphimc.viabedrock.protocol.BedrockProtocol; - import net.raphimc.viabedrock.protocol.ClientboundBedrockPackets; +@@ -37,10 +37,14 @@ import net.raphimc.viabedrock.protocol.ClientboundBedrockPackets; import net.raphimc.viabedrock.protocol.data.enums.bedrock.ActorDataIds; import net.raphimc.viabedrock.protocol.data.enums.bedrock.ActorFlags; import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.DataItemType; @@ -2156,7 +3598,7 @@ index c86ed106b..c66bef8b3 100644 import net.raphimc.viabedrock.protocol.types.BedrockTypes; import net.raphimc.viabedrock.protocol.types.entitydata.EntityDataTypesBedrock; -@@ -54,6 +57,7 @@ public class Entity { +@@ -55,6 +59,7 @@ public class Entity { protected final UserConnection user; protected final long uniqueId; protected final long runtimeId; @@ -2164,7 +3606,29 @@ index c86ed106b..c66bef8b3 100644 protected final String type; protected final int javaId; protected final UUID javaUuid; -@@ -157,6 +161,7 @@ public class Entity { +@@ -68,6 +73,12 @@ public class Entity { + private Position3f lastReceivedRotation; + private short lastReceivedMovementFlags = 0xFF; + private long lastReceivedInterpolationTicks; ++ private Position3f admittedPosition; ++ private ActorSpatialCommand.Action spatialAction = ActorSpatialCommand.Action.MOVE; ++ private short admittedMovementFlags = 0xFF; ++ private long admittedInterpolationTicks; ++ private long spatialRevision; ++ private ActorSpatialCommand publishedSpatialCommand; + /** + * pitch, yaw, headYaw + */ +@@ -125,6 +136,8 @@ public class Entity { + if (this.publishedNativeState != null && this.nativeStateAvailable()) { + this.sendNativeActorMessage(new NativeActorMessage.Remove(this.javaUuid(), this.nativeLifetime, this instanceof ClientPlayerEntity)); + this.publishedNativeState = null; ++ this.publishedNativeBounds = null; ++ this.publishedSpatialCommand = null; + } + if (this.hasBossBar) { + this.hasBossBar = false; +@@ -162,6 +175,7 @@ public class Entity { // ViaBedrock.getPlatform().getLogger().log(Level.WARNING, "Received unknown entity data: " + dataId + " for entity type: " + this.type); } } @@ -2172,7 +3636,7 @@ index c86ed106b..c66bef8b3 100644 this.onEntityDataChanged(); } -@@ -195,6 +200,31 @@ public class Entity { +@@ -200,6 +214,31 @@ public class Entity { return this.uniqueId; } @@ -2204,7 +3668,31 @@ index c86ed106b..c66bef8b3 100644 public long runtimeId() { return this.runtimeId; } -@@ -231,7 +261,20 @@ public class Entity { +@@ -253,6 +292,23 @@ public class Entity { + this.lastReceivedInterpolationTicks = interpolationTicks; + } + ++ /** Retain admitted network movement independently of ignored updates and live simulation. */ ++ public void admitSpatialUpdate(final Position3f position, final short flags, final long interpolationTicks) { ++ this.spatialAction = ActorSpatialCommand.Action.MOVE; ++ this.admittedPosition = position; ++ this.admittedMovementFlags = flags; ++ this.admittedInterpolationTicks = interpolationTicks; ++ this.spatialRevision = Math.incrementExact(this.spatialRevision); ++ this.sendNativeSpatialCommand(false); ++ } ++ ++ /** Retire interpolation independently of the absolute/delta receive cache and admitted history. */ ++ public final void clearSpatialInterpolation() { ++ this.spatialAction = ActorSpatialCommand.Action.CLEAR_INTERPOLATION; ++ this.spatialRevision = Math.incrementExact(this.spatialRevision); ++ this.sendNativeSpatialCommand(false); ++ } ++ + public Position3f javaPosition() { + return this.position.subtract(0F, this.eyeOffset(), 0F); + } +@@ -261,10 +317,24 @@ public class Entity { this.setPosition(position.add(0F, this.eyeOffset(), 0F)); } @@ -2214,6 +3702,10 @@ index c86ed106b..c66bef8b3 100644 + } + public void setPosition(final Position3f position) { + if (this.lastReceivedPosition == null) { + this.lastReceivedPosition = position; ++ this.admittedPosition = position; + } + this.updatePosition(position); + final VehiclePredictionStorage vehicles = this.user != null ? this.user.get(VehiclePredictionStorage.class) : null; + if (vehicles != null) { @@ -2225,7 +3717,7 @@ index c86ed106b..c66bef8b3 100644 this.position = position; } -@@ -300,16 +343,19 @@ public class Entity { +@@ -345,16 +415,19 @@ public class Entity { public void addPassenger(final long passengerRuntimeId) { if (!this.passengers.contains(passengerRuntimeId)) { this.passengers.add(passengerRuntimeId); @@ -2245,7 +3737,7 @@ index c86ed106b..c66bef8b3 100644 } public List passengers() { -@@ -318,6 +364,14 @@ public class Entity { +@@ -363,6 +436,15 @@ public class Entity { public void setMountEntityRuntimeId(final long runtimeId) { this.mountRuntimeId = runtimeId; @@ -2257,10 +3749,11 @@ index c86ed106b..c66bef8b3 100644 + if (vehicles != null) { + vehicles.refresh(); + } ++ this.sendNativeSpatialCommand(false); } public long mountEntityRuntimeId() { -@@ -336,7 +390,12 @@ public class Entity { +@@ -381,7 +463,12 @@ public class Entity { return EntityMetadataRewriter.rewrite(this.user, this, id, entityData, javaEntityData); } @@ -2273,9 +3766,43 @@ index c86ed106b..c66bef8b3 100644 this.sendNativeActorSnapshot(); } -diff --git a/src/main/java/net/raphimc/viabedrock/api/model/entity/LivingEntity.java b/src/main/java/net/raphimc/viabedrock/api/model/entity/LivingEntity.java -index bd5e91117..d79489fc2 100644 ---- a/src/main/java/net/raphimc/viabedrock/api/model/entity/LivingEntity.java +@@ -422,6 +509,33 @@ public class Entity { + this.publishedNativeBounds = bounds; + } + } ++ this.sendNativeSpatialCommand(force); ++ } ++ ++ public final void sendNativeSpatialCommand(final boolean force) { ++ if (this.publishedNativeState == null || !this.nativeStateAvailable() || this.admittedPosition == null ++ || !this.user.get(ChannelStorage.class).hasChannel(ActorSpatialCommand.CHANNEL) ++ || this.user.get(EntityTracker.class).getEntityByUid(this.uniqueId()) != this) { ++ return; ++ } ++ final var command = this.nativeSpatialCommand(); ++ if (force || !command.equals(this.publishedSpatialCommand)) { ++ final PacketWrapper payload = PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, this.user); ++ payload.write(Types.STRING, ActorSpatialCommand.CHANNEL); ++ payload.write(ActorSpatialCommand.TYPE, command); ++ payload.send(BedrockProtocol.class); ++ this.publishedSpatialCommand = command; ++ } ++ } ++ ++ public final ActorSpatialCommand nativeSpatialCommand() { ++ 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. ++ final boolean clientPredicted = tracker != null && this instanceof AbstractHorseEntity && tracker.isLocallyControlledVehicle(this); ++ return new ActorSpatialCommand(this.spatialAction, this.javaId(), this.javaUuid(), this.nativeLifetime, ++ this instanceof ClientPlayerEntity, this.spatialRevision, this.admittedPosition, ++ this.admittedMovementFlags, this.admittedInterpolationTicks, !(this instanceof PlayerEntity), ++ this instanceof LivingEntity, clientPredicted, this.eyeOffset()); + } + + public final NativeActorBoundsMessage nativeActorBounds() { +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/entity/LivingEntity.java b/src/main/java/net/raphimc/viabedrock/api/model/entity/LivingEntity.java +index bd5e91117..d79489fc2 100644 +--- a/src/main/java/net/raphimc/viabedrock/api/model/entity/LivingEntity.java +++ b/src/main/java/net/raphimc/viabedrock/api/model/entity/LivingEntity.java @@ -21,10 +21,12 @@ import com.viaversion.viaversion.api.connection.UserConnection; import com.viaversion.viaversion.api.minecraft.entities.EntityTypes26_3; @@ -2568,14 +4095,15 @@ index 000000000..72b248f9e + +} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/ClientPlayerPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/ClientPlayerPackets.java -index eb0c16ab4..f5a1a75bf 100644 +index eb0c16ab4..5d8802320 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/ClientPlayerPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/ClientPlayerPackets.java -@@ -37,7 +37,13 @@ import net.raphimc.viabedrock.api.util.EnumUtil; +@@ -37,7 +37,14 @@ import net.raphimc.viabedrock.api.util.EnumUtil; import net.raphimc.viabedrock.api.util.MathUtil; import net.raphimc.viabedrock.api.util.PacketFactory; import net.raphimc.viabedrock.protocol.BedrockProtocol; +import net.raphimc.viabedrock.api.model.PlayerCorrection; ++import net.raphimc.viabedrock.api.model.MovementCorrectionTiming; import net.raphimc.viabedrock.api.model.PlayerInput; +import net.raphimc.viabedrock.api.model.PlayerPrediction; +import net.raphimc.viabedrock.api.model.VehiclePrediction; @@ -2585,7 +4113,7 @@ index eb0c16ab4..f5a1a75bf 100644 import net.raphimc.viabedrock.protocol.ClientboundBedrockPackets; import net.raphimc.viabedrock.protocol.ServerboundBedrockPackets; import net.raphimc.viabedrock.protocol.data.ProtocolConstants; -@@ -49,6 +55,7 @@ import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.*; +@@ -49,6 +56,7 @@ import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.*; import net.raphimc.viabedrock.protocol.data.enums.java.*; import net.raphimc.viabedrock.protocol.data.enums.java.generated.*; import net.raphimc.viabedrock.protocol.model.BedrockItem; @@ -2593,7 +4121,7 @@ index eb0c16ab4..f5a1a75bf 100644 import net.raphimc.viabedrock.protocol.model.Position2f; import net.raphimc.viabedrock.protocol.model.Position3f; import net.raphimc.viabedrock.protocol.provider.EmoteProvider; -@@ -149,6 +156,7 @@ public final class ClientPlayerPackets { +@@ -149,6 +157,7 @@ public final class ClientPlayerPackets { wrapper.write(Types.VAR_INT, 64); // sea level wrapper.write(Types.BYTE, (byte) (RespawnKeepFlag.ATTRIBUTE_MODIFIERS.getBit() | RespawnKeepFlag.ENTITY_DATA.getBit())); // keep data mask wrapper.send(BedrockProtocol.class); @@ -2601,7 +4129,7 @@ index eb0c16ab4..f5a1a75bf 100644 inventoryTracker.getArmorContainer().sendAttributes(); clientPlayer.sendAttribute("minecraft:health"); // Ensure health is synced clientPlayer.setAbilities(clientPlayer.abilities()); // Java client always resets abilities on respawn. Resend them -@@ -191,32 +199,33 @@ public final class ClientPlayerPackets { +@@ -191,32 +200,45 @@ public final class ClientPlayerPackets { } }); protocol.registerClientbound(ClientboundBedrockPackets.CORRECT_PLAYER_MOVE_PREDICTION, ClientboundPackets26_3.PLAYER_POSITION, wrapper -> { @@ -2618,33 +4146,81 @@ index eb0c16ab4..f5a1a75bf 100644 - wrapper.read(BedrockTypes.POSITION_3F); // position delta + final Position3f correctedVelocity = wrapper.read(BedrockTypes.POSITION_3F); // corrected velocity final Position2f vehicleRotation = wrapper.read(BedrockTypes.POSITION_2F); - if (wrapper.read(Types.BOOLEAN)) { - wrapper.read(BedrockTypes.FLOAT_LE); // vehicle angular velocity - } +- if (wrapper.read(Types.BOOLEAN)) { +- wrapper.read(BedrockTypes.FLOAT_LE); // vehicle angular velocity +- } ++ final Float angularVelocity = wrapper.read(Types.BOOLEAN) ? wrapper.read(BedrockTypes.FLOAT_LE) : null; final boolean onGround = wrapper.read(Types.BOOLEAN); - final long tick = wrapper.read(BedrockTypes.UNSIGNED_VAR_LONG); -+ final long correctionTick = wrapper.read(BedrockTypes.UNSIGNED_VAR_LONG); - final ClientPlayerEntity clientPlayer = wrapper.user().get(EntityTracker.class).getClientPlayer(); +- final ClientPlayerEntity clientPlayer = wrapper.user().get(EntityTracker.class).getClientPlayer(); - if (tick > clientPlayer.age() || tick < clientPlayer.age() - gameSession.getMovementRewindHistorySize()) { -- wrapper.cancel(); -- return; -- } ++ final long correctionTick = wrapper.read(BedrockTypes.UNSIGNED_VAR_LONG); ++ if (!wrapper.user().get(PlayerPredictionStorage.class).acceptsCorrectionTick(correctionTick)) { + wrapper.cancel(); + return; + } ++ final ClientPlayerEntity clientPlayer = wrapper.user().get(EntityTracker.class).getClientPlayer(); ++ final PlayerPredictionStorage predictions = wrapper.user().get(PlayerPredictionStorage.class); ++ final boolean replay = predictions.inputFrame() >= 0 && wrapper.user().get(ChannelStorage.class).hasChannel(PlayerCorrection.CHANNEL); ++ final MovementCorrectionTiming timing = replay ? new MovementCorrectionTiming(correctionTick, predictions.inputTick(), ++ predictions.inputFrame(), predictions.oldestInputTick(), predictions.clientFrame(correctionTick + 1)) : null; switch (rewindType) { case Player -> { ++ if (replay) { ++ clientPlayer.writePlayerPositionPacketToClient(wrapper, Relative.ROTATION, true, correctedVelocity, position); ++ final PacketWrapper state = PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, wrapper.user()); ++ state.write(Types.STRING, PlayerCorrection.CHANNEL); ++ state.write(PlayerCorrection.TYPE, new PlayerCorrection(clientPlayer.javaId(), clientPlayer.javaUuid(), ++ clientPlayer.nativeActorSnapshot().lifetime(), wrapper.get(Types.VAR_INT, 0), -1, timing, ++ position, correctedVelocity, new Position2f(0F, 0F), null, clientPlayer.eyeOffset(), 0F, onGround)); ++ state.send(BedrockProtocol.class); ++ predictions.discardPending(); ++ break; ++ } clientPlayer.setPosition(position); clientPlayer.setOnGround(onGround); - clientPlayer.writePlayerPositionPacketToClient(wrapper, Relative.union(Relative.ROTATION, Relative.VELOCITY), true); + clientPlayer.writePlayerPositionPacketToClient(wrapper, Relative.ROTATION, true, correctedVelocity); -+ if (wrapper.user().get(ChannelStorage.class).hasChannel(PlayerCorrection.CHANNEL)) { ++ + } + case Vehicle -> { + final Entity vehicle = predictedVehicle(wrapper.user().get(EntityTracker.class), clientPlayer); +@@ -224,10 +246,34 @@ public final class ClientPlayerPackets { + wrapper.cancel(); + return; + } ++ final VehiclePredictionStorage vehiclePredictions = wrapper.user().get(VehiclePredictionStorage.class); ++ if (replay && vehiclePredictions != null && vehiclePredictions.context().vehicle() != null ++ && vehiclePredictions.context().vehicle().javaUuid().equals(vehicle.javaUuid())) { ++ final float yawOffset = vehicle.rotation().y() - vehicle.javaRotation().y(); ++ final PlayerCorrection correction = new PlayerCorrection(vehicle.javaId(), vehicle.javaUuid(), ++ vehicle.nativeActorSnapshot().lifetime(), 0, vehiclePredictions.context().epoch(), timing, ++ position, correctedVelocity, vehicleRotation, angularVelocity, vehicle.eyeOffset(), yawOffset, onGround); + final PacketWrapper state = PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, wrapper.user()); + state.write(Types.STRING, PlayerCorrection.CHANNEL); -+ state.write(PlayerCorrection.TYPE, new PlayerCorrection(clientPlayer.javaId(), wrapper.get(Types.VAR_INT, 0), correctionTick, onGround)); ++ state.write(PlayerCorrection.TYPE, correction); + state.send(BedrockProtocol.class); ++ wrapper.setPacketType(ClientboundPackets26_3.MOVE_VEHICLE); ++ wrapper.write(Types.DOUBLE, (double) correction.javaPosition().x()); ++ wrapper.write(Types.DOUBLE, (double) correction.javaPosition().y()); ++ wrapper.write(Types.DOUBLE, (double) correction.javaPosition().z()); ++ wrapper.write(Types.FLOAT, correction.javaYaw()); ++ wrapper.write(Types.FLOAT, vehicleRotation.x()); ++ vehiclePredictions.discardPending(); ++ break; + } - } - case Vehicle -> { - final Entity vehicle = predictedVehicle(wrapper.user().get(EntityTracker.class), clientPlayer); -@@ -521,7 +530,7 @@ public final class ClientPlayerPackets { + vehicle.setPosition(position); + vehicle.setRotation(new Position3f(vehicleRotation.x(), vehicleRotation.y(), vehicleRotation.y())); + vehicle.setOnGround(onGround); + clientPlayer.vehiclePositionDelta(vehicle); ++ final PacketWrapper motion = PacketWrapper.create(ClientboundPackets26_3.SET_ENTITY_MOTION, wrapper.user()); ++ motion.write(Types.VAR_INT, vehicle.javaId()); ++ motion.write(Types.LOW_PRECISION_VECTOR, new Vector3d(correctedVelocity.x(), correctedVelocity.y(), correctedVelocity.z())); ++ motion.send(BedrockProtocol.class); + wrapper.setPacketType(ClientboundPackets26_3.MOVE_VEHICLE); + wrapper.write(Types.DOUBLE, (double) position.x()); + wrapper.write(Types.DOUBLE, (double) vehicle.javaPosition().y()); +@@ -521,7 +567,7 @@ public final class ClientPlayerPackets { final Entity vehicle = predictedVehicle(wrapper.user().get(EntityTracker.class), wrapper.user().get(EntityTracker.class).getClientPlayer()); if (vehicle != null) { @@ -2653,18 +4229,19 @@ index eb0c16ab4..f5a1a75bf 100644 vehicle.setJavaRotation(new Position3f(pitch, yaw, yaw)); vehicle.setOnGround(onGround); } -@@ -553,12 +562,23 @@ public final class ClientPlayerPackets { +@@ -553,12 +599,24 @@ public final class ClientPlayerPackets { protocol.registerServerbound(ServerboundPackets26_3.CLIENT_TICK_END, ServerboundBedrockPackets.PLAYER_AUTH_INPUT, wrapper -> { final EntityTracker entityTracker = wrapper.user().get(EntityTracker.class); final ClientPlayerEntity clientPlayer = entityTracker.getClientPlayer(); + final PlayerPredictionStorage predictionStorage = wrapper.user().get(PlayerPredictionStorage.class); -+ final PlayerPrediction prediction = predictionStorage.take(clientPlayer.javaPosition()); final Position3f prevPosition = clientPlayer.prevPosition(); final boolean prevOnGround = clientPlayer.prevOnGround(); final Set prevInputFlags = clientPlayer.prevInputFlags(); + final Entity vehicle = predictedVehicle(entityTracker, clientPlayer); + final VehiclePredictionStorage vehicleStorage = wrapper.user().get(VehiclePredictionStorage.class); -+ final VehiclePrediction vehiclePrediction = vehicleStorage != null ? vehicleStorage.take(vehicle, prediction) : null; ++ final CompletedPredictions predictions = completedPredictions(predictionStorage, vehicleStorage, vehicle, clientPlayer.javaPosition()); ++ final PlayerPrediction prediction = predictions.player(); ++ final VehiclePrediction vehiclePrediction = predictions.vehicle(); + if (vehiclePrediction != null) { + vehicle.setPredictedJavaPosition(vehiclePrediction.position()); + } @@ -2678,7 +4255,7 @@ index eb0c16ab4..f5a1a75bf 100644 clientPlayer.addAuthInputData(PlayerAuthInputData.StartJumping); } -@@ -582,10 +602,10 @@ public final class ClientPlayerPackets { +@@ -582,10 +640,10 @@ public final class ClientPlayerPackets { final PlayerInputStorage playerInput = wrapper.user().get(PlayerInputStorage.class); clientPlayer.authInputData().addAll(playerInput.takeButtonEvents()); clientPlayer.addAuthInputData(PlayerAuthInputData.BlockBreakingDelayEnabled); @@ -2691,7 +4268,7 @@ index eb0c16ab4..f5a1a75bf 100644 clientPlayer.addAuthInputData(PlayerAuthInputData.HorizontalCollision); } if (clientPlayer.inputFlags().contains(InputFlag.FORWARD)) { -@@ -622,9 +642,24 @@ public final class ClientPlayerPackets { +@@ -622,9 +680,24 @@ public final class ClientPlayerPackets { clientPlayer.setShieldBlockingHand(null); } } @@ -2718,7 +4295,7 @@ index eb0c16ab4..f5a1a75bf 100644 final Position3f inputPosition; final Position3f positionDelta; if (vehicle != null) { -@@ -637,8 +672,15 @@ public final class ClientPlayerPackets { +@@ -637,8 +710,15 @@ public final class ClientPlayerPackets { inputPosition = clientPlayer.position(); positionDelta = inputPosition.subtract(prevPosition); } @@ -2735,7 +4312,7 @@ index eb0c16ab4..f5a1a75bf 100644 velocity = positionDelta; } else { float dx = positionDelta.x() * 0.98F; -@@ -661,7 +703,7 @@ public final class ClientPlayerPackets { +@@ -661,7 +741,7 @@ public final class ClientPlayerPackets { wrapper.write(BedrockTypes.FLOAT_LE, clientPlayer.rotation().x()); // pitch wrapper.write(BedrockTypes.FLOAT_LE, clientPlayer.rotation().y()); // yaw wrapper.write(BedrockTypes.POSITION_3F, inputPosition); // predicted player or vehicle position @@ -2744,15 +4321,15 @@ index eb0c16ab4..f5a1a75bf 100644 wrapper.write(BedrockTypes.FLOAT_LE, clientPlayer.rotation().z()); // head yaw if (Via.getManager().getProviders().get(EmoteProvider.class).isEmoting(wrapper.user())) { clientPlayer.addAuthInputData(PlayerAuthInputData.Emoting); -@@ -677,6 +719,7 @@ public final class ClientPlayerPackets { +@@ -677,6 +757,7 @@ public final class ClientPlayerPackets { wrapper.write(BedrockTypes.VAR_INT, NewInteractionModel.Touch.getValue()); // interaction mode wrapper.write(BedrockTypes.FLOAT_LE, clientPlayer.rotation().x()); // interact pitch wrapper.write(BedrockTypes.FLOAT_LE, clientPlayer.rotation().y()); // interact yaw -+ predictionStorage.remember(clientPlayer.age(), prediction); ++ predictionStorage.remember(clientPlayer.age(), predictions.clockFrame()); wrapper.write(BedrockTypes.UNSIGNED_VAR_LONG, (long) clientPlayer.age()); // tick wrapper.write(BedrockTypes.POSITION_3F, velocity); // delta wrapper.write(Types.BOOLEAN, false); // no item interaction -@@ -700,7 +743,7 @@ public final class ClientPlayerPackets { +@@ -700,7 +781,7 @@ public final class ClientPlayerPackets { wrapper.write(BedrockTypes.VAR_LONG, vehicle.uniqueId()); } wrapper.write(BedrockTypes.POSITION_2F, vehicle != null @@ -2761,11 +4338,165 @@ index eb0c16ab4..f5a1a75bf 100644 : new Position2f(0F, 0F)); // analog move vector wrapper.write(BedrockTypes.POSITION_3F, MathUtil.calculateCameraOrientation(clientPlayer.rotation().y(), clientPlayer.rotation().x())); // camera orientation wrapper.write(BedrockTypes.POSITION_2F, playerInput.rawInput().movement()); // raw move vector +@@ -761,7 +842,20 @@ public final class ClientPlayerPackets { + }); + } + ++ static CompletedPredictions completedPredictions(final PlayerPredictionStorage players, final VehiclePredictionStorage vehicles, ++ final Entity vehicle, final Position3f playerPosition) { ++ final PlayerPrediction frame = players.take(); ++ final PlayerPrediction player = frame != null && frame.matches(playerPosition) ? frame : null; ++ // Passenger position packets update the vehicle, so the tracked rider position can be stale. ++ // Validate the vehicle itself while retaining the shared frame identity for the input clock. ++ final VehiclePrediction mounted = vehicles != null ? vehicles.take(vehicle, frame) : null; ++ return new CompletedPredictions(player, mounted, vehicle == null ? player : mounted != null ? frame : null); ++ } ++ + private ClientPlayerPackets() { + } + ++ record CompletedPredictions(PlayerPrediction player, VehiclePrediction vehicle, PlayerPrediction clockFrame) { ++ } ++ + } +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 8e0cbe982..c5e23c91b 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 + import com.viaversion.viaversion.util.Key; + import net.raphimc.viabedrock.ViaBedrock; + import net.raphimc.viabedrock.api.model.NativeActorMessage; ++import net.raphimc.viabedrock.api.model.ActorMotionCommand; ++import net.raphimc.viabedrock.api.model.MovementCorrectionTiming; + 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; + import net.raphimc.viabedrock.protocol.storage.EntityPropertyTracker; + import net.raphimc.viabedrock.protocol.storage.ChannelStorage; + import net.raphimc.viabedrock.protocol.storage.EntityTracker; ++import net.raphimc.viabedrock.protocol.storage.PlayerPredictionStorage; ++import net.raphimc.viabedrock.protocol.storage.VehiclePredictionStorage; + import net.raphimc.viabedrock.protocol.storage.GameSessionStorage; + import net.raphimc.viabedrock.protocol.storage.ResourcePackStorage; + import net.raphimc.viabedrock.protocol.types.BedrockTypes; +@@ -250,11 +254,13 @@ public final class EntityPackets { + + entity.retainSpatialUpdate(position, new Position3f(pitch, yaw, headYaw), flags, 0); + ++ if (!teleported && !forceMoveLocalEntity ++ && (entity == entityTracker.getClientPlayer() || entityTracker.isLocallyControlledVehicle(entity))) { ++ wrapper.cancel(); ++ return; ++ } + if (entity == entityTracker.getClientPlayer()) { +- if (!teleported && !forceMoveLocalEntity) { +- wrapper.cancel(); +- return; +- } ++ entity.admitSpatialUpdate(position, flags, 0); + entity.setPosition(position); + + if (teleported) { +@@ -275,6 +281,7 @@ public final class EntityPackets { + return; + } + ++ entity.admitSpatialUpdate(position, flags, 0); + entity.setPosition(position); + entity.setRotation(new Position3f(pitch, yaw, headYaw)); + entity.setOnGround(onGround); +@@ -331,11 +338,13 @@ public final class EntityPackets { + | (forceMoveLocalEntity ? 4 : 0) | (forceCompletion ? 8 : 0)); + entity.retainSpatialUpdate(position, rotation, flags, interpolationTicks); + ++ if (!teleported && !forceMoveLocalEntity ++ && (entity == entityTracker.getClientPlayer() || entityTracker.isLocallyControlledVehicle(entity))) { ++ wrapper.cancel(); ++ return; ++ } + if (entity == entityTracker.getClientPlayer()) { +- if (!teleported && !forceMoveLocalEntity) { +- wrapper.cancel(); +- return; +- } ++ entity.admitSpatialUpdate(position, flags, interpolationTicks); + entity.setPosition(position); + if (teleported) { + wrapper.setPacketType(ClientboundPackets26_3.PLAYER_POSITION); +@@ -355,6 +364,7 @@ public final class EntityPackets { + return; + } + ++ entity.admitSpatialUpdate(position, flags, interpolationTicks); + entity.setPosition(position); + entity.setRotation(rotation); + if (hasHeadYaw) { +@@ -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 +- wrapper.read(BedrockTypes.UNSIGNED_VAR_LONG); // tick +- writeEntityMotion(wrapper, entityRuntimeId, motion); ++ final long tick = wrapper.read(BedrockTypes.UNSIGNED_VAR_LONG); ++ writeTickedEntityMotion(wrapper, entityRuntimeId, motion, tick); + }); + protocol.registerClientbound(ClientboundBedrockPackets.MOTION_PREDICTION_HINTS, ClientboundPackets26_3.SET_ENTITY_MOTION, wrapper -> { + final long entityRuntimeId = wrapper.read(BedrockTypes.UNSIGNED_VAR_LONG); // entity runtime id +@@ -746,6 +756,39 @@ public final class EntityPackets { + }); + } + ++ private static void writeTickedEntityMotion(final PacketWrapper wrapper, final long runtimeId, final Position3f motion, final long tick) { ++ final EntityTracker tracker = wrapper.user().get(EntityTracker.class); ++ final Entity entity = tracker.getEntityByRid(runtimeId); ++ final PlayerPredictionStorage predictions = wrapper.user().get(PlayerPredictionStorage.class); ++ final ChannelStorage channels = wrapper.user().get(ChannelStorage.class); ++ if (entity == null || tick == 0 || predictions == null || predictions.inputFrame() < 0 ++ || channels == null || !channels.hasChannel(ActorMotionCommand.CHANNEL) ++ || entity != tracker.getClientPlayer() && !tracker.isLocallyControlledVehicle(entity)) { ++ writeEntityMotion(wrapper, runtimeId, motion); ++ return; ++ } ++ long vehicleEpoch = -1; ++ if (entity != tracker.getClientPlayer()) { ++ final VehiclePredictionStorage vehicles = wrapper.user().get(VehiclePredictionStorage.class); ++ final var context = vehicles == null ? null : vehicles.context(); ++ if (context == null || context.vehicle() == null || context.vehicle().javaId() != entity.javaId() ++ || context.vehicle().runtimeId() != entity.runtimeId() || context.vehicle().uniqueId() != entity.uniqueId() ++ || !context.vehicle().javaUuid().equals(entity.javaUuid()) ++ || !context.vehicle().lifetime().equals(entity.nativeActorSnapshot().lifetime())) { ++ writeEntityMotion(wrapper, runtimeId, motion); ++ return; ++ } ++ vehicleEpoch = context.epoch(); ++ } ++ final long retainedTick = Long.compareUnsigned(tick, predictions.oldestInputTick()) < 0 ? predictions.oldestInputTick() : tick; ++ final var timing = new MovementCorrectionTiming(tick, predictions.inputTick(), predictions.inputFrame(), ++ predictions.oldestInputTick(), predictions.clientFrame(retainedTick + 1)); ++ wrapper.setPacketType(ClientboundPackets26_3.CUSTOM_PAYLOAD); ++ wrapper.write(Types.STRING, ActorMotionCommand.CHANNEL); ++ wrapper.write(ActorMotionCommand.TYPE, new ActorMotionCommand(entity.javaId(), entity.javaUuid(), ++ entity.nativeActorSnapshot().lifetime(), vehicleEpoch, timing, motion)); ++ } ++ + private static Entity writeEntityMotion(final PacketWrapper wrapper, final long runtimeId, final Position3f motion) { + 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 006d49ff8..441186cd0 100644 +index 006d49ff8..4dc2e0ef7 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/JoinPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/JoinPackets.java -@@ -596,6 +596,7 @@ public final class JoinPackets { +@@ -365,6 +365,7 @@ public final class JoinPackets { + gameSession.setBedrockVanillaVersion(version); + gameSession.setFlatGenerator(generatorType == GeneratorType.Flat); + gameSession.setMovementRewindHistorySize(rewindHistorySize); ++ wrapper.user().get(PlayerPredictionStorage.class).configureHistory(rewindHistorySize); + gameSession.setLevelGameType(levelGameType); + gameSession.setLevelTime(levelTime); + gameSession.setHardcoreMode(hardcore); +@@ -596,6 +597,7 @@ public final class JoinPackets { clientPlayer.createTeam(); clientPlayer.sendInitialEntityData(); @@ -2775,7 +4506,7 @@ index 006d49ff8..441186cd0 100644 clientPlayer.setAbilities(clientPlayer.abilities()); diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/MovementEffectPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/MovementEffectPackets.java new file mode 100644 -index 000000000..42f03cc06 +index 000000000..4f888b69e --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/MovementEffectPackets.java @@ -0,0 +1,54 @@ @@ -2817,7 +4548,7 @@ index 000000000..42f03cc06 + final long frame = predictions.clientFrame(tick); + final boolean localPlayer = entity == wrapper.user().get(EntityTracker.class).getClientPlayer(); + final MovementEffect effect = new MovementEffect(entity.javaId(), type, duration, tick, frame, -+ localPlayer ? predictions.inputTick() : 0, localPlayer ? predictions.inputFrame() : -1); ++ localPlayer ? predictions.inputTick() : 0, localPlayer ? predictions.inputFrame() : -1, predictions.oldestInputTick()); + entity.setMovementEffect(effect); + if (wrapper.user().get(ChannelStorage.class).hasChannel(MovementEffect.CHANNEL)) { + wrapper.setPacketType(ClientboundPackets26_3.CUSTOM_PAYLOAD); @@ -2834,10 +4565,14 @@ index 000000000..42f03cc06 + +} 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 910b5a13d..4653d8293 100644 +index 910b5a13d..6c8e9b600 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java -@@ -37,7 +37,12 @@ import net.raphimc.viabedrock.api.model.NativeActorMessage; +@@ -34,10 +34,16 @@ import net.raphimc.viabedrock.api.model.LocatorBarSnapshot; + import net.raphimc.viabedrock.api.model.NativeBlockIdentifiers; + import net.raphimc.viabedrock.api.model.NativeCustomEntityMessage; + import net.raphimc.viabedrock.api.model.NativeActorMessage; ++import net.raphimc.viabedrock.api.model.ActorSpatialCommand; import net.raphimc.viabedrock.api.model.NativeActorBoundsMessage; import net.raphimc.viabedrock.api.modinterface.ViaBedrockUtilityInterface; import net.raphimc.viabedrock.api.model.PlayerInput; @@ -2850,7 +4585,7 @@ index 910b5a13d..4653d8293 100644 import net.raphimc.viabedrock.api.util.PacketFactory; import net.raphimc.viabedrock.protocol.BedrockProtocol; import net.raphimc.viabedrock.protocol.ClientboundBedrockPackets; -@@ -57,6 +62,8 @@ import net.raphimc.viabedrock.protocol.storage.ClientSettingsStorage; +@@ -57,6 +63,8 @@ import net.raphimc.viabedrock.protocol.storage.ClientSettingsStorage; import net.raphimc.viabedrock.protocol.storage.ChunkRadiusNegotiation; import net.raphimc.viabedrock.protocol.storage.PacketSyncStorage; import net.raphimc.viabedrock.protocol.storage.PlayerInputStorage; @@ -2859,7 +4594,7 @@ index 910b5a13d..4653d8293 100644 import net.raphimc.viabedrock.protocol.storage.HudVisibilityStorage; import net.raphimc.viabedrock.protocol.storage.CameraEffectsStorage; import net.raphimc.viabedrock.protocol.types.BedrockTypes; -@@ -176,12 +183,40 @@ public final class MultiStatePackets { +@@ -176,12 +184,40 @@ public final class MultiStatePackets { if (channels.contains(PlayerInputPermissions.CHANNEL) && wrapper.user().has(PlayerInputStorage.class)) { wrapper.user().get(PlayerInputStorage.class).withdrawRawInput(); } @@ -2900,7 +4635,17 @@ index 910b5a13d..4653d8293 100644 if (channel.equals(PlayerInput.CHANNEL)) { if (wrapper.user().getProtocolInfo().getClientState() == State.PLAY && wrapper.user().get(ChannelStorage.class).hasChannel(PlayerInputPermissions.CHANNEL)) { -@@ -213,6 +248,15 @@ public final class MultiStatePackets { +@@ -202,7 +238,8 @@ public final class MultiStatePackets { + } + wrapper.user().get(NativeSkinStorage.class).flush(); + if ((channels.contains(NativeActorMessage.CHANNEL) || channels.contains(NativeActorMessage.CURRENT_CHANNEL) +- || channels.contains(NativeActorBoundsMessage.CHANNEL)) && wrapper.user().has(EntityTracker.class)) { ++ || channels.contains(NativeActorBoundsMessage.CHANNEL) ++ || channels.contains(ActorSpatialCommand.CHANNEL)) && wrapper.user().has(EntityTracker.class)) { + wrapper.user().get(EntityTracker.class).sendNativeActorSnapshots(); + } + if (channels.contains(NativeCustomEntityMessage.CHANNEL) +@@ -213,6 +250,15 @@ public final class MultiStatePackets { && wrapper.user().has(ChunkTracker.class)) { wrapper.user().get(ChunkTracker.class).sendNativeBlockSnapshot(); } @@ -2916,6 +4661,30 @@ index 910b5a13d..4653d8293 100644 wrapper.user().get(PlayerInputStorage.class).sendSnapshot(); wrapper.user().get(HudVisibilityStorage.class).sendSnapshot(); wrapper.user().get(CameraEffectsStorage.class).sendSnapshot(); +diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/OtherPlayerPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/OtherPlayerPackets.java +index 5b3311913..36233420b 100644 +--- a/src/main/java/net/raphimc/viabedrock/protocol/packet/OtherPlayerPackets.java ++++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/OtherPlayerPackets.java +@@ -157,7 +157,7 @@ public final class OtherPlayerPackets { + final Position3f rotation = wrapper.read(BedrockTypes.POSITION_3F); // rotation + final PositionMode mode = PositionMode.getByValue(wrapper.read(Types.BYTE), PositionMode.OnlyHeadRot); // mode + final boolean onGround = wrapper.read(Types.BOOLEAN); // on ground +- wrapper.read(BedrockTypes.UNSIGNED_VAR_LONG); // riding entity runtime id ++ final long ridingEntityRuntimeId = wrapper.read(BedrockTypes.UNSIGNED_VAR_LONG); // riding entity runtime id + if (wrapper.read(Types.BOOLEAN) && mode == PositionMode.Teleport) { + wrapper.read(BedrockTypes.INT_LE); // teleportation cause + wrapper.read(BedrockTypes.INT_LE); // entity type +@@ -187,6 +187,10 @@ public final class OtherPlayerPackets { + entity.setPosition(position); + entity.setRotation(rotation); + entity.setOnGround(onGround); ++ if (entity instanceof PlayerEntity && !(entity instanceof ClientPlayerEntity) && (mode == PositionMode.Teleport || mode == PositionMode.Respawn) ++ && (ridingEntityRuntimeId == 0 || entityTracker.getEntityByRid(ridingEntityRuntimeId) == null)) { ++ entity.clearSpatialInterpolation(); ++ } + + if ((mode == PositionMode.Teleport || mode == PositionMode.Respawn) && entity instanceof ClientPlayerEntity clientPlayer) { + wrapper.setPacketType(ClientboundPackets26_3.PLAYER_POSITION); 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 0655a9d42..f61b4f387 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/WorldPackets.java @@ -3010,10 +4779,18 @@ index e2fce5701..353a67ede 100644 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/EntityTracker.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/EntityTracker.java -index 06c8b260d..1fcdc5f8e 100644 +index 136722c73..d50c27994 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/EntityTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/EntityTracker.java -@@ -325,6 +325,15 @@ public class EntityTracker extends StoredObject { +@@ -43,6 +43,7 @@ import net.raphimc.viabedrock.api.chunk.BedrockBlockEntity; + import net.raphimc.viabedrock.api.model.BlockState; + import net.raphimc.viabedrock.api.model.entity.*; + import net.raphimc.viabedrock.protocol.BedrockProtocol; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.ActorFlags; + 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 { public void markNativeActorsJoined() { this.nativeActorsJoined = true; this.sendNativeActorSnapshots(); @@ -3029,7 +4806,7 @@ index 06c8b260d..1fcdc5f8e 100644 } public List getEntities() { -@@ -335,6 +344,22 @@ public class EntityTracker extends StoredObject { +@@ -341,6 +351,22 @@ public class EntityTracker extends StoredObject { this.entities.values().forEach(entity -> entity.sendNativeActorSnapshot(true)); } @@ -3052,6 +4829,30 @@ index 06c8b260d..1fcdc5f8e 100644 public void sendNativeCustomEntitySnapshot() { for (Entity entity : this.entities.values()) { if (entity instanceof CustomEntity customEntity) { +@@ -384,6 +410,23 @@ public class EntityTracker extends StoredObject { + return this.itemFramesByJavaId.get(javaId); + } + ++ /** Native vehicle control follows the first passenger, with saddle and tame gates for horses. */ ++ public boolean isLocallyControlledVehicle(final Entity vehicle) { ++ if (!(vehicle instanceof BoatEntity || vehicle instanceof AbstractHorseEntity) ++ || this.getEntityByRid(vehicle.runtimeId()) != vehicle || vehicle.passengers().isEmpty()) { ++ return false; ++ } ++ if (vehicle instanceof AbstractHorseEntity) { ++ final Set flags = vehicle.entityFlags(); ++ if (!(flags.contains(ActorFlags.SADDLED) && flags.contains(ActorFlags.TAMED))) { ++ return false; ++ } ++ } ++ final Entity controller = this.getEntityByUid(vehicle.passengers().getFirst()); ++ return controller != null && controller == this.getClientPlayer() ++ && this.getEntityByRid(controller.runtimeId()) == controller; ++ } ++ + public ClientPlayerEntity getClientPlayer() { + return this.clientPlayerEntity; + } 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 95391851d..f8bf20f85 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/PlayerInputStorage.java @@ -3088,10 +4889,10 @@ index 95391851d..f8bf20f85 100644 } diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/PlayerPredictionStorage.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/PlayerPredictionStorage.java new file mode 100644 -index 000000000..c03b839c6 +index 000000000..e676aa716 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/PlayerPredictionStorage.java -@@ -0,0 +1,106 @@ +@@ -0,0 +1,133 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -3105,16 +4906,15 @@ index 000000000..c03b839c6 +import net.raphimc.viabedrock.api.model.SneakingInputState; + +import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.PlayerAuthInputData; -+import net.raphimc.viabedrock.protocol.model.Position3f; + +import java.util.EnumSet; +import java.util.Set; + +public final class PlayerPredictionStorage implements StorableObject { + -+ private static final int HISTORY_SIZE = 512; -+ private final long[] inputTicks = new long[HISTORY_SIZE]; -+ private final long[] clientFrames = new long[HISTORY_SIZE]; ++ private static final int DEFAULT_HISTORY_SIZE = 512; ++ private long[] inputTicks = new long[DEFAULT_HISTORY_SIZE]; ++ private long[] clientFrames = new long[DEFAULT_HISTORY_SIZE]; + private int historyIndex; + private int historyCount; + private PlayerPrediction pending; @@ -3144,36 +4944,64 @@ index 000000000..c03b839c6 + this.pending = null; + } + -+ public PlayerPrediction take(final Position3f position) { ++ /** Consume the shared physical frame before validating the player or controlled vehicle position. */ ++ public PlayerPrediction take() { + final PlayerPrediction prediction = this.pending; + this.pending = null; -+ return prediction != null && prediction.matches(position) ? prediction : null; ++ return prediction; ++ } ++ ++ /** START_GAME supplies the native history limit; the client bounds it to 1..1000 frames. */ ++ public void configureHistory(final int capacity) { ++ final int size = Math.clamp(capacity, 1, 1000); ++ this.inputTicks = new long[size]; ++ this.clientFrames = new long[size]; ++ this.historyIndex = 0; ++ this.historyCount = 0; ++ this.inputTick = 0; ++ this.inputFrame = -1; ++ this.discardPending(); + } + + /** Record an emitted input tick and invalidate its clock binding when no matching frame exists. */ + public void remember(final long inputTick, final PlayerPrediction prediction) { ++ // Native capture starts a new history on a clock discontinuity. Unsigned wrap is contiguous. ++ if (this.historyCount != 0 && inputTick != this.inputTick + 1) { ++ this.historyIndex = 0; ++ this.historyCount = 0; ++ } + this.inputTick = inputTick; + this.inputFrame = prediction == null ? -1 : prediction.frameId(); -+ if (prediction == null) { -+ return; -+ } + this.inputTicks[this.historyIndex] = inputTick; -+ this.clientFrames[this.historyIndex] = prediction.frameId(); -+ this.historyIndex = (this.historyIndex + 1) % HISTORY_SIZE; -+ this.historyCount = Math.min(this.historyCount + 1, HISTORY_SIZE); ++ this.clientFrames[this.historyIndex] = this.inputFrame; ++ this.historyIndex = (this.historyIndex + 1) % this.inputTicks.length; ++ this.historyCount = Math.min(this.historyCount + 1, this.inputTicks.length); ++ } ++ ++ /** Native lookup requires a retained input and rejects zero and older unsigned clocks. */ ++ public boolean acceptsCorrectionTick(final long tick) { ++ if (tick == 0 || this.historyCount == 0) { ++ return false; ++ } ++ final int oldest = (this.historyIndex - this.historyCount + this.inputTicks.length) % this.inputTicks.length; ++ return Long.compareUnsigned(tick, this.inputTicks[oldest]) >= 0; + } + + public long inputTick() { + return this.inputTick; + } + ++ public long oldestInputTick() { ++ return this.historyCount == 0 ? 0 : this.inputTicks[(this.historyIndex - this.historyCount + this.inputTicks.length) % this.inputTicks.length]; ++ } ++ + public long inputFrame() { + return this.inputFrame; + } + + public long clientFrame(final long inputTick) { + for (int offset = 1; offset <= this.historyCount; offset++) { -+ final int index = (this.historyIndex - offset + HISTORY_SIZE) % HISTORY_SIZE; ++ final int index = (this.historyIndex - offset + this.inputTicks.length) % this.inputTicks.length; + if (this.inputTicks[index] == inputTick) { + return this.clientFrames[index]; + } @@ -3200,10 +5028,10 @@ index 000000000..c03b839c6 +} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/VehiclePredictionStorage.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/VehiclePredictionStorage.java new file mode 100644 -index 000000000..18db6ce32 +index 000000000..0920a3c8c --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/VehiclePredictionStorage.java -@@ -0,0 +1,145 @@ +@@ -0,0 +1,149 @@ +/* + * This file is part of ViaBedrock - https://github.com/ViaVersionAddons/ViaBedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -3302,14 +5130,14 @@ index 000000000..18db6ce32 + private Entity eligibleVehicle() { + final EntityTracker tracker = this.user().get(EntityTracker.class); + final ClientPlayerEntity player = tracker != null ? tracker.getClientPlayer() : null; -+ if (!this.enabled() || this.exhausted || player == null || !player.isInitiallySpawned() || player.isDead() ++ if (!this.enabled() || this.exhausted || player == null || player.position() == null || !player.isInitiallySpawned() || player.isDead() + || player.dimensionChangeInfo() != null || !player.canReportVehiclePrediction() + || player.javaGameMode() == GameMode.SPECTATOR + || this.user().get(ChunkTracker.class).isInUnloadedChunkSection(player.position())) { + return null; + } + final Entity candidate = tracker.getEntityByRid(player.mountEntityRuntimeId()); -+ if (candidate == null || tracker.getEntityByUid(candidate.uniqueId()) != candidate || candidate.mountEntityRuntimeId() != -1 ++ if (candidate == null || candidate.position() == null || tracker.getEntityByUid(candidate.uniqueId()) != candidate || candidate.mountEntityRuntimeId() != -1 + || candidate.passengers().isEmpty() || candidate.passengers().getFirst() != player.uniqueId() + || !(candidate.javaType().isOrHasParent(EntityTypes26_3.ABSTRACT_BOAT) + || candidate.javaType().isOrHasParent(EntityTypes26_3.ABSTRACT_HORSE))) { @@ -3332,6 +5160,10 @@ index 000000000..18db6ce32 + this.pending = prediction; + } + ++ public void discardPending() { ++ this.pending = null; ++ } ++ + public VehiclePrediction take(final Entity vehicle, final PlayerPrediction playerFrame) { + this.refresh(); + final VehiclePrediction frame = this.pending; @@ -3349,6 +5181,164 @@ index 000000000..18db6ce32 + } + +} +diff --git a/src/test/java/net/raphimc/viabedrock/api/model/ActorMotionCommandTest.java b/src/test/java/net/raphimc/viabedrock/api/model/ActorMotionCommandTest.java +new file mode 100644 +index 000000000..4a1b11009 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/api/model/ActorMotionCommandTest.java +@@ -0,0 +1,76 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.model; ++ ++import com.google.gson.JsonParser; ++import io.netty.buffer.Unpooled; ++import java.nio.file.Files; ++import java.nio.file.Path; ++import java.util.UUID; ++import net.raphimc.viabedrock.protocol.model.Position3f; ++import org.junit.jupiter.api.Test; ++import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; ++import static org.junit.jupiter.api.Assertions.*; ++ ++class ActorMotionCommandTest { ++ ++ @Test ++ void preservesUnsignedClocksAndRawVelocityWhileRejectingMalformedPayloads() { ++ final var value = new ActorMotionCommand(41, UUID.randomUUID(), UUID.randomUUID(), -1, ++ new MovementCorrectionTiming(-1, 105, 25, 100, -1), new Position3f(-0F, Float.MIN_VALUE, 1.2345679F)); ++ final var buffer = Unpooled.buffer(); ++ try { ++ ActorMotionCommand.TYPE.write(buffer, value); ++ assertTrue(buffer.readableBytes() <= ActorMotionCommand.MAX_PAYLOAD_BYTES); ++ final int size = buffer.readableBytes(); ++ assertEquals(value, ActorMotionCommand.TYPE.read(buffer)); ++ for (int length = 0; length < size; length++) { ++ final var truncated = buffer.slice(0, length); ++ assertThrows(RuntimeException.class, () -> ActorMotionCommand.TYPE.read(truncated)); ++ } ++ buffer.readerIndex(0).writeByte(1); ++ assertThrows(IllegalArgumentException.class, () -> ActorMotionCommand.TYPE.read(buffer)); ++ assertThrows(IllegalArgumentException.class, () -> new ActorMotionCommand(41, value.uuid(), value.lifetime(), -1, ++ value.timing(), new Position3f(Float.NaN, 0, 0))); ++ assertThrows(IllegalArgumentException.class, () -> new ActorMotionCommand(41, value.uuid(), value.lifetime(), -1, ++ new MovementCorrectionTiming(0, 105, 25, 100, -1), value.motion())); ++ } finally { ++ buffer.release(); ++ } ++ } ++ ++ @Test ++ void clampsExpiredMotionWithoutChangingCorrectionAdmissionOrUnsignedFuturePlacement() { ++ final var expired = new MovementCorrectionTiming(99, 105, 25, 100, -1); ++ assertNull(expired.locate(20, 25)); ++ assertEquals(new MovementCorrectionTiming.Placement(21, true, false), expired.locateMotion(20, 25)); ++ assertEquals(new MovementCorrectionTiming.Placement(25, true, false), expired.locateMotion(24, 25)); ++ assertEquals(new MovementCorrectionTiming.Placement(26, true, true), new MovementCorrectionTiming(105, 105, 25, 100, -1).locateMotion(20, 25)); ++ assertEquals(new MovementCorrectionTiming.Placement(25, false, false), new MovementCorrectionTiming(-1, 105, 25, 100, -1).locateMotion(20, 25)); ++ assertEquals(new MovementCorrectionTiming.Placement(23, true, false), new MovementCorrectionTiming(99, 105, 25, 100, 23).locateMotion(20, 25)); ++ } ++ ++ @Test ++ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_TICKED_MOTION", matches = ".+") ++ void matchesOriginalReceiverQueueAndDirtyPlacement() throws Exception { ++ final var reference = JsonParser.parseString(Files.readString(Path.of(System.getenv("STACKANVIL_NATIVE_TICKED_MOTION")))).getAsJsonObject(); ++ int compared = 0; ++ for (var entry : reference.getAsJsonArray("rows")) { ++ final var row = entry.getAsJsonObject(); ++ final long tick = Long.parseUnsignedLong(row.get("tick").getAsString()); ++ final var placement = new MovementCorrectionTiming(tick, 105, 5, 100, -1).locateMotion(0, 5); ++ assertNotNull(placement); ++ assertEquals(row.get("pendingQueue").getAsInt() != 0, placement.pending()); ++ if (!placement.pending()) { ++ assertEquals(row.getAsJsonArray("queued").get(0).getAsLong(), placement.frame()); ++ } ++ assertEquals(!row.getAsJsonArray("dirty").isEmpty(), row.get("accept").getAsBoolean() && placement.dirty() && !placement.pending()); ++ compared++; ++ } ++ assertEquals(reference.get("cases").getAsInt(), compared); ++ } ++ ++} +diff --git a/src/test/java/net/raphimc/viabedrock/api/model/ActorSpatialCommandTest.java b/src/test/java/net/raphimc/viabedrock/api/model/ActorSpatialCommandTest.java +new file mode 100644 +index 000000000..a3173cc03 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/api/model/ActorSpatialCommandTest.java +@@ -0,0 +1,70 @@ ++/* ++ * 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 io.netty.buffer.Unpooled; ++import net.raphimc.viabedrock.protocol.model.Position3f; ++import org.junit.jupiter.api.Test; ++ ++import java.util.Map; ++import java.util.Set; ++import java.util.UUID; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++class ActorSpatialCommandTest { ++ ++ @Test ++ void preservesUnsignedDurationAndRejectsTrailingData() { ++ final var command = command(UUID.randomUUID(), UUID.randomUUID(), 19, Long.MIN_VALUE); ++ final var buffer = Unpooled.buffer(); ++ try { ++ ActorSpatialCommand.TYPE.write(buffer, command); ++ assertEquals(command, ActorSpatialCommand.TYPE.read(buffer)); ++ buffer.clear(); ++ ActorSpatialCommand.TYPE.write(buffer, command); ++ buffer.writeByte(0); ++ assertThrows(IllegalArgumentException.class, () -> ActorSpatialCommand.TYPE.read(buffer)); ++ } finally { ++ buffer.release(); ++ } ++ } ++ ++ @Test ++ void registryRetiresSourcesAndRejectsLateCommandsFromAnEarlierLifetime() { ++ final var registry = new NativeActorStates(); ++ final UUID uuid = UUID.randomUUID(); ++ final UUID first = UUID.randomUUID(); ++ final UUID replacement = UUID.randomUUID(); ++ registry.apply(snapshot(uuid, first), null); ++ registry.apply(command(uuid, first, 1, 3), null); ++ assertNotNull(registry.movement(uuid)); ++ registry.apply(snapshot(uuid, replacement), null); ++ assertNull(registry.movement(uuid)); ++ registry.apply(command(uuid, first, 2, 3), null); ++ assertNull(registry.movement(uuid)); ++ registry.apply(command(uuid, replacement, 0, 3), null); ++ final var current = registry.movement(uuid); ++ registry.apply(new NativeActorMessage.Remove(uuid, first, false), null); ++ assertSame(current, registry.movement(uuid)); ++ registry.apply(new NativeActorMessage.Remove(uuid, replacement, false), null); ++ assertNull(registry.movement(uuid)); ++ registry.apply(snapshot(uuid, replacement), null); ++ registry.apply(command(uuid, replacement, 0, 3), null); ++ registry.clear(); ++ assertNull(registry.movement(uuid)); ++ } ++ ++ private static ActorSpatialCommand command(final UUID uuid, final UUID lifetime, final long revision, final long duration) { ++ return new ActorSpatialCommand(ActorSpatialCommand.Action.MOVE, 7, uuid, lifetime, false, revision, new Position3f(1.25F, -2.5F, 3.75F), ++ (short) 0xFF, duration, true, false, false, .375F); ++ } ++ ++ private static NativeActorMessage.Snapshot snapshot(final UUID uuid, final UUID lifetime) { ++ return new NativeActorMessage.Snapshot(uuid, lifetime, false, 7, "minecraft:boat", "", 1F, Set.of(), 0, 0, null, Map.of(), 7L); ++ } ++ ++} diff --git a/src/test/java/net/raphimc/viabedrock/api/model/CollisionContactTest.java b/src/test/java/net/raphimc/viabedrock/api/model/CollisionContactTest.java new file mode 100644 index 000000000..c79bb3f28 @@ -3909,6 +5899,125 @@ index 000000000..be74f06d7 + } + +} +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 000000000..88f1b3b20 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/api/model/MovementCollisionQueryTest.java +@@ -0,0 +1,53 @@ ++/* ++ * 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 net.raphimc.viabedrock.protocol.model.Position3f; ++import org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++class MovementCollisionQueryTest { ++ ++ @Test ++ void reusesOnlyStrictlyInsideTheNativeSquaredPositionLimit() { ++ assertTrue(MovementCollisionQuery.reuses(Position3f.ZERO, Position3f.ZERO)); ++ assertTrue(MovementCollisionQuery.reuses(Position3f.ZERO, new Position3f(Math.nextDown(2F), 0F, 0F))); ++ assertFalse(MovementCollisionQuery.reuses(Position3f.ZERO, new Position3f(2F, 0F, 0F))); ++ assertFalse(MovementCollisionQuery.reuses(Position3f.ZERO, new Position3f(-2F, 0F, 0F))); ++ assertTrue(MovementCollisionQuery.reuses(Position3f.ZERO, new Position3f(1F, 1F, 1F))); ++ assertFalse(MovementCollisionQuery.reuses(Position3f.ZERO, new Position3f(1.5F, 1.5F, 0F))); ++ assertFalse(MovementCollisionQuery.reuses(Position3f.ZERO, new Position3f(Float.NaN, 0F, 0F))); ++ assertFalse(MovementCollisionQuery.reuses(Position3f.ZERO, new Position3f(Float.POSITIVE_INFINITY, 0F, 0F))); ++ } ++ ++ @Test ++ void capsTheWholeMotionVectorBeforeBuildingStepAndLowerInsetBounds() { ++ final Position3f motion = new Position3f(12F, 0F, 16F); ++ assertEquals(new Position3f(9.6F, 0F, 12.8F), MovementCollisionQuery.cap(motion)); ++ final Position3f boundary = new Position3f(16F, 0F, 0F); ++ assertSame(boundary, MovementCollisionQuery.cap(boundary)); ++ final var body = new CollisionContact.Box(0F, 0F, 0F, 0.6F, 1.8F, 0.6F); ++ final var query = MovementCollisionQuery.bounds(body, new Position3f(1F, -0.08F, 0F), 0.6F); ++ assertEquals(new CollisionContact.Box(0F, -0.606F, 0F, 1.6F, 2.4F, 0.6F), query); ++ assertTrue(MovementCollisionQuery.contains(query, body)); ++ } ++ ++ @Test ++ void leavesContainedQueriesAloneAndFetchesDisjointRegionsOnce() { ++ final var query = new CollisionContact.Box(-1F, -1F, -1F, 1F, 1F, 1F); ++ assertTrue(MovementCollisionQuery.missing(query, query).isEmpty()); ++ assertEquals(java.util.List.of(query), MovementCollisionQuery.missing(query, null)); ++ assertEquals(java.util.List.of(query), MovementCollisionQuery.missing(query, ++ new CollisionContact.Box(1F, -1F, -1F, 3F, 1F, 1F))); ++ final var half = new CollisionContact.Box(0F, 0F, 0F, 0.5F, 1F, 1F); ++ final var whole = new CollisionContact.Box(0F, 0F, 0F, 1F, 1F, 1F); ++ final var missing = MovementCollisionQuery.missing(whole, half); ++ assertEquals(1, missing.size()); ++ assertEquals(new CollisionContact.Box(0.50001F, 0.00001F, 0.00001F, 1F, 1F, 1F), missing.getFirst()); ++ } ++ ++} +diff --git a/src/test/java/net/raphimc/viabedrock/api/model/MovementCollisionWorldTest.java b/src/test/java/net/raphimc/viabedrock/api/model/MovementCollisionWorldTest.java +new file mode 100644 +index 000000000..9123e0e0d +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/api/model/MovementCollisionWorldTest.java +@@ -0,0 +1,54 @@ ++/* ++ * 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 java.util.ArrayList; ++import java.util.List; ++import org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++class MovementCollisionWorldTest { ++ ++ @Test ++ void visitsPaddedColumnsInNativeOrderAndSkipsUnavailableColumns() { ++ final var query = new CollisionContact.Box(-0.2F, -0.2F, -0.2F, 0.2F, 0.2F, 0.2F); ++ final List> columns = new ArrayList<>(); ++ final List> blocks = new ArrayList<>(); ++ MovementCollisionWorld.blocks(query, -1, 1, (x, z) -> { ++ columns.add(List.of(x, z)); ++ return x == 0 && z == 0 || x == 1 && z == -1; ++ }, (x, y, z) -> blocks.add(List.of(x, y, z))); ++ assertEquals(List.of(-2, -2), columns.getFirst()); ++ assertEquals(List.of(1, 1), columns.getLast()); ++ assertEquals(16, columns.size()); ++ assertEquals(List.of(List.of(0, -1, 0), List.of(0, 0, 0), List.of(0, 1, 0), ++ List.of(1, -1, -1), List.of(1, 0, -1), List.of(1, 1, -1)), blocks); ++ MovementCollisionWorld.blocks(query, 1, 1, (x, z) -> { ++ fail(); ++ return true; ++ }, (x, y, z) -> fail()); ++ } ++ ++ @Test ++ void scansPaddedChunksBeforeCreatingUnavailableAndBelowWorldBarriers() { ++ final var query = new CollisionContact.Box(-0.2F, -60F, -0.2F, 0.2F, -58F, 0.2F); ++ final List> chunks = new ArrayList<>(); ++ final List barriers = new ArrayList<>(); ++ MovementCollisionWorld.unloaded(query, (x, z) -> { ++ chunks.add(List.of(x, z)); ++ return x == -1 && z == -1; ++ }, barriers::add); ++ assertEquals(List.of(List.of(-1, -1), List.of(0, -1), List.of(-1, 0), List.of(0, 0)), chunks); ++ assertEquals(List.of(new CollisionContact.Box(0F, -100000F, -16F, 16F, 100000F, 0F), ++ new CollisionContact.Box(-16F, -100000F, 0F, 0F, 100000F, 16F), ++ new CollisionContact.Box(0F, -100000F, 0F, 16F, 100000F, 16F)), barriers); ++ assertEquals(new CollisionContact.Box(-0.2F, -Float.MAX_VALUE, -0.2F, 0.2F, -56F, 0.2F), ++ MovementCollisionWorld.belowWorld(query, -16)); ++ assertNull(MovementCollisionWorld.belowWorld(query, -64)); ++ } ++ ++} diff --git a/src/test/java/net/raphimc/viabedrock/api/model/MovementControllerOrderTest.java b/src/test/java/net/raphimc/viabedrock/api/model/MovementControllerOrderTest.java new file mode 100644 index 000000000..3416ab840 @@ -3967,76 +6076,421 @@ index 000000000..3416ab840 + } + +} -diff --git a/src/test/java/net/raphimc/viabedrock/api/model/MovementEffectTest.java b/src/test/java/net/raphimc/viabedrock/api/model/MovementEffectTest.java +diff --git a/src/test/java/net/raphimc/viabedrock/api/model/MovementCorrectionTimingTest.java b/src/test/java/net/raphimc/viabedrock/api/model/MovementCorrectionTimingTest.java new file mode 100644 -index 000000000..80e6fcda6 +index 000000000..4d03a8b09 --- /dev/null -+++ b/src/test/java/net/raphimc/viabedrock/api/model/MovementEffectTest.java -@@ -0,0 +1,105 @@ ++++ b/src/test/java/net/raphimc/viabedrock/api/model/MovementCorrectionTimingTest.java +@@ -0,0 +1,40 @@ +/* -+ * This file is part of ViaBedrock - https://github.com/ViaVersionAddons/ViaBedrock -+ * Copyright (C) 2026 ViaVersion and contributors ++ * 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 com.google.gson.JsonParser; -+ -+import java.nio.file.Files; -+import java.nio.file.Path; +import org.junit.jupiter.api.Test; -+ +import static org.junit.jupiter.api.Assertions.*; -+import static org.junit.jupiter.api.Assumptions.assumeTrue; + -+class MovementEffectTest { ++class MovementCorrectionTimingTest { + + @Test -+ void expiresAgainstTheOriginalFrameInsteadOfReceiptTime() { -+ final MovementEffect effect = new MovementEffect(10, 0, 20, 81, 7, 0, -1); -+ assertFalse(effect.activeAt(6)); -+ assertTrue(effect.activeAt(7)); -+ assertTrue(effect.activeAt(26)); -+ assertFalse(effect.activeAt(27)); -+ assertFalse(effect.activeAt(100)); -+ assertFalse(new MovementEffect(10, 0, 20, 81, -1, 0, -1).activeAt(7)); -+ assertTrue(new MovementEffect(10, 0, -1, 81, 7, 0, -1).activeAt(Long.MAX_VALUE)); -+ assertTrue(new MovementEffect(10, 0, -1, 81, -1, 0, -1).activeAt(Long.MAX_VALUE)); -+ assertFalse(new MovementEffect(10, 0, 0, 81, 7, 0, -1).activeAt(7)); -+ assertFalse(new MovementEffect(10, 0, -2, 81, 7, 0, -1).activeAt(7)); ++ void queuesTheFollowingSnapshotAndRejectsExpiredUnsignedClocks() { ++ assertNull(new MovementCorrectionTiming(0, 105, 25, 100, -1).locate(20, 25)); ++ assertNull(new MovementCorrectionTiming(99, 105, 25, 100, -1).locate(20, 25)); ++ assertEquals(new MovementCorrectionTiming.Placement(21, true, false), new MovementCorrectionTiming(100, 105, 25, 100, -1).locate(20, 25)); ++ assertEquals(new MovementCorrectionTiming.Placement(25, true, false), new MovementCorrectionTiming(104, 105, 25, 100, -1).locate(20, 25)); ++ assertEquals(new MovementCorrectionTiming.Placement(26, true, true), new MovementCorrectionTiming(105, 105, 25, 100, -1).locate(20, 25)); ++ assertEquals(new MovementCorrectionTiming.Placement(25, false, false), new MovementCorrectionTiming(-1, 105, 25, 100, -1).locate(20, 25)); + } + + @Test -+ void agesMissingHistoryAgainstThePairedInputClock() { -+ final MovementEffect startup = new MovementEffect(1, 2, 100, 0, -1, 53, 8); -+ assertEquals(-1, startup.clientFrame()); -+ assertEquals(47, startup.remainingAt(8)); -+ assertEquals(46, startup.remainingAt(9)); -+ assertEquals(1, startup.remainingAt(54)); ++ void projectsTheClockAtReceiptAndIntersectsBothHistoryWindows() { ++ assertEquals(new MovementCorrectionTiming.Placement(26, true, false), new MovementCorrectionTiming(105, 105, 25, 100, -1).locate(20, 28)); ++ assertEquals(new MovementCorrectionTiming.Placement(29, true, true), new MovementCorrectionTiming(108, 105, 25, 100, -1).locate(20, 28)); ++ assertNull(new MovementCorrectionTiming(102, 105, 25, 100, -1).locate(24, 28)); ++ assertNull(new MovementCorrectionTiming(100, 105, 25, 100, -1).locate(26, 25)); ++ assertNull(new MovementCorrectionTiming(100, 105, 25, 100, -1).locate(20, 24)); ++ } ++ ++ @Test ++ void preservesNativeWrapOrderingAndCapsBeforeIncrementing() { ++ assertEquals(new MovementCorrectionTiming.Placement(24, false, false), new MovementCorrectionTiming(-1, 1, 25, -4, -1).locate(20, 25)); ++ assertNull(new MovementCorrectionTiming(1, 1, 25, -4, -1).locate(20, 25)); ++ assertEquals(new MovementCorrectionTiming.Placement(25, false, false), new MovementCorrectionTiming(-1, 5, 25, 0, 20).locate(20, 25)); ++ assertEquals(new MovementCorrectionTiming.Placement(23, true, false), new MovementCorrectionTiming(100, 105, 25, 100, 23).locate(20, 25)); ++ } ++ ++} +diff --git a/src/test/java/net/raphimc/viabedrock/api/model/MovementEffectPhysicsStateTest.java b/src/test/java/net/raphimc/viabedrock/api/model/MovementEffectPhysicsStateTest.java +new file mode 100644 +index 000000000..1d566cf19 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/api/model/MovementEffectPhysicsStateTest.java +@@ -0,0 +1,84 @@ ++/* ++ * 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 net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.MovementEffectType; ++import org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++final class MovementEffectPhysicsStateTest { ++ ++ @Test ++ void keepsOwnedValuesAcrossLiveMutationAndRepeatedRestoration() { ++ final var timeline = new MovementEffectTimeline(); ++ timeline.beginFrame(); ++ timeline.predictGeyserBoost(1); ++ timeline.completeFrame(); ++ final var saved = timeline.captureValues(1); ++ timeline.accept(new MovementEffect(1, MovementEffectType.GEYSER_BOOST.getValue(), 0, 5, 0, 5, 0, 5)); ++ assertFalse(timeline.hasGeyserBoost(1)); ++ timeline.restoreValues(1, saved); ++ assertTrue(timeline.hasGeyserBoost(1)); ++ timeline.accept(new MovementEffect(1, MovementEffectType.GEYSER_BOOST.getValue(), 0, 6, 0, 6, 0, 5)); ++ timeline.restoreValues(1, saved); ++ assertEquals(saved, timeline.captureValues(1)); ++ assertTrue(timeline.hasGeyserBoost(1)); ++ } ++ ++ @Test ++ void appliesHistoricalCommandsBeforeTheirPhysicalInputAndKeepsTheLiveClock() { ++ final var timeline = new MovementEffectTimeline(); ++ final var empty = timeline.captureValues(1); ++ for (int frame = 0; frame < 3; frame++) { ++ timeline.beginFrame(); ++ timeline.completeFrame(); ++ } ++ timeline.accept(new MovementEffect(1, MovementEffectType.GEYSER_BOOST.getValue(), 3, 100, 0, 102, 2, 100)); ++ assertEquals(1, timeline.pendingPhysicalFrame(id -> id == 1)); ++ timeline.restoreValues(1, empty); ++ timeline.applyPhysicalCommands(1, id -> id == 1); ++ assertTrue(timeline.hasGeyserBoost(1, 0)); ++ assertTrue(timeline.hasGeyserBoost(1, 2)); ++ assertFalse(timeline.hasGeyserBoost(1, 3)); ++ assertEquals(3, timeline.frameId()); ++ timeline.finishPhysicalReplay(id -> id == 1); ++ assertEquals(-1, timeline.pendingPhysicalFrame(id -> id == 1)); ++ } ++ ++ @Test ++ void reevaluatesLocalAdmissionAtTheHistoricalClockWithoutRewindingAnotherActor() { ++ final var timeline = new MovementEffectTimeline(); ++ for (int frame = 0; frame < 8; frame++) { ++ timeline.beginFrame(); ++ timeline.completeFrame(); ++ } ++ timeline.predictGeyserBoost(1, 2); ++ timeline.predictGeyserBoost(2, 8); ++ assertTrue(timeline.hasGeyserBoost(1, 100)); ++ assertFalse(timeline.hasGeyserBoost(1, 101)); ++ assertTrue(timeline.hasGeyserBoost(2, 101)); ++ assertEquals(8, timeline.frameId()); ++ } ++ ++ @Test ++ void replacesPlayStateWithoutReusingCompletedConnectionFrames() { ++ final var timeline = new MovementEffectTimeline(); ++ timeline.beginFrame(); ++ timeline.predictGeyserBoost(1); ++ timeline.completeFrame(); ++ timeline.beginFrame(); ++ timeline.resetHistory(); ++ assertEquals(1, timeline.frameId()); ++ assertFalse(timeline.hasGeyserBoost(1)); ++ assertEquals(-1, timeline.pendingPhysicalFrame(id -> true)); ++ timeline.beginFrame(); ++ assertEquals(1, timeline.completeFrame()); ++ timeline.clear(); ++ assertEquals(0, timeline.frameId()); ++ } ++ ++} +diff --git a/src/test/java/net/raphimc/viabedrock/api/model/MovementEffectReplayTest.java b/src/test/java/net/raphimc/viabedrock/api/model/MovementEffectReplayTest.java +new file mode 100644 +index 000000000..125f1d671 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/api/model/MovementEffectReplayTest.java +@@ -0,0 +1,181 @@ ++/* ++ * 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.google.gson.JsonParser; ++import org.junit.jupiter.api.Test; ++ ++import java.nio.file.Files; ++import java.nio.file.Path; ++ ++import static org.junit.jupiter.api.Assertions.*; ++import static org.junit.jupiter.api.Assumptions.assumeTrue; ++ ++class MovementEffectReplayTest { ++ ++ @Test ++ void restoresUnrelatedEffectDurationsBeforeReplayingTheirCountdowns() { ++ final var timeline = new MovementEffectTimeline(); ++ timeline.accept(new MovementEffect(1, 0, 10, 100, 0, 0, -1, 0)); ++ for (int frame = 0; frame < 6; frame++) { ++ timeline.beginFrame(); ++ timeline.completeFrame(); ++ } ++ timeline.accept(new MovementEffect(1, 2, 100, 102, 2, 105, 5, 100)); ++ timeline.replayCorrections(); ++ expiresGlideAfter(timeline, 5); ++ } ++ ++ @Test ++ void refreshesLaterSnapshotsAfterAnEarlierCorrection() { ++ final var timeline = new MovementEffectTimeline(); ++ timeline.accept(new MovementEffect(1, 0, 20, 100, 0, 0, -1, 0)); ++ for (int frame = 0; frame < 6; frame++) { ++ timeline.beginFrame(); ++ timeline.completeFrame(); ++ } ++ timeline.accept(new MovementEffect(1, 0, 100, 101, 1, 105, 5, 100)); ++ timeline.accept(new MovementEffect(1, 2, 100, 101, 1, 105, 5, 100)); ++ timeline.replayCorrections(); ++ timeline.accept(new MovementEffect(1, 1, 100, 103, 3, 105, 5, 100)); ++ timeline.replayCorrections(); ++ expiresGlideAfter(timeline, 96); ++ } ++ ++ private static void expiresGlideAfter(final MovementEffectTimeline timeline, final int frames) { ++ for (int frame = 0; frame < frames; frame++) { ++ assertTrue(timeline.hasGlideBoost(1)); ++ timeline.beginFrame(); ++ timeline.replayCorrections(); ++ timeline.completeFrame(); ++ } ++ assertFalse(timeline.hasGlideBoost(1)); ++ } ++ ++ @Test ++ void replaysCapturedAdmissionAfterRemovalWithoutReadingTheCurrentSource() { ++ final var timeline = history(true); ++ timeline.accept(new MovementEffect(1, 2, 0, 102, 2, 105, 5, 100)); ++ assertFalse(timeline.hasGeyserBoost(1)); ++ timeline.replayCorrections(); ++ expiresAfter(timeline, 99); ++ } ++ ++ @Test ++ void replaysTheOriginalDurationAtTheOldestClockInsteadOfItsExpiredImmediateValue() { ++ final var timeline = history(false); ++ timeline.accept(new MovementEffect(1, 2, 20, 0, -1, 105, 5, 100)); ++ assertFalse(timeline.hasGeyserBoost(1)); ++ timeline.replayCorrections(); ++ expiresAfter(timeline, 15); ++ } ++ ++ @Test ++ void preservesMaskZeroCommandsUntilAnotherKindRequestsMovementReplay() { ++ final var timeline = history(false); ++ timeline.accept(new MovementEffect(1, 2, 20, 102, 2, 105, 5, 100)); ++ timeline.replayCorrections(); ++ assertTrue(timeline.hasGeyserBoost(1)); ++ timeline.accept(new MovementEffect(1, 0, -1, 104, 4, 105, 5, 100)); ++ timeline.replayCorrections(); ++ expiresAfter(timeline, 17); ++ assertTrue(timeline.hasGlideBoost(1)); ++ } ++ ++ @Test ++ void transfersCurrentCommandsBeforePhysicsAndCountsDownEachFrameOnce() { ++ final var timeline = history(false); ++ timeline.accept(new MovementEffect(1, 2, 2, 105, 5, 105, 5, 100)); ++ timeline.beginFrame(); ++ timeline.beginFrame(); ++ timeline.replayCorrections(); ++ assertTrue(timeline.hasGeyserBoost(1)); ++ timeline.completeFrame(); ++ assertTrue(timeline.hasGeyserBoost(1)); ++ timeline.beginFrame(); ++ timeline.replayCorrections(); ++ timeline.completeFrame(); ++ assertFalse(timeline.hasGeyserBoost(1)); ++ } ++ ++ @Test ++ void boundsClampedReplayByTheTransportedOldestClock() { ++ final var timeline = history(false); ++ timeline.accept(new MovementEffect(1, 2, 2, 0, -1, 105, 5, 104)); ++ timeline.replayCorrections(); ++ expiresAfter(timeline, 1); ++ } ++ ++ @Test ++ void removedActorsCannotRecoverOldInputsOrQueuedCommands() { ++ final var timeline = history(true); ++ timeline.accept(new MovementEffect(1, 2, 0, 102, 2, 105, 5, 100)); ++ timeline.retainEntities(id -> id != 1); ++ timeline.accept(new MovementEffect(1, 2, 0, 102, 2, 105, 5, 100)); ++ timeline.replayCorrections(); ++ assertFalse(timeline.hasGeyserBoost(1)); ++ timeline.clear(); ++ assertEquals(0, timeline.completeFrame()); ++ } ++ ++ @Test ++ void matchesNativeInputOrderingWithTheRecordedSnapshotBoundary() throws Exception { ++ // These older input controls supply snapshot storage. They do not establish packed snapshot semantics. ++ final String reference = System.getenv("STACKANVIL_MOVEMENT_INPUT_REPLAY_REFERENCE"); ++ assumeTrue(reference != null); ++ for (final var entry : JsonParser.parseString(Files.readString(Path.of(reference))).getAsJsonArray()) { ++ final var row = entry.getAsJsonObject(); ++ final var timeline = new MovementEffectTimeline(); ++ final boolean captured = row.get("inputMode").getAsString().equals("captured"); ++ for (int frame = 0; frame < 6; frame++) { ++ if (captured) { ++ timeline.beginFrame(); ++ } ++ if (row.get("previousActive").getAsBoolean()) { ++ timeline.predictGeyserBoost(1); ++ } ++ if (!captured) { ++ timeline.beginFrame(); ++ } ++ timeline.completeFrame(); ++ } ++ final long tick = row.get("tick").getAsLong(); ++ timeline.accept(new MovementEffect(1, 2, row.get("duration").getAsInt(), tick, ++ tick >= 100 && tick <= 105 ? tick - 100 : -1, 105, 5, 100)); ++ final int live = row.getAsJsonArray("live").get(1).getAsInt(); ++ assertEquals(live == -1 || live > 0, timeline.hasGeyserBoost(1)); ++ timeline.replayCorrections(); ++ final int replayed = row.getAsJsonObject("first").getAsJsonArray("state").get(1).getAsInt(); ++ assertEquals(replayed == -1 || replayed > 0, timeline.hasGeyserBoost(1)); ++ } ++ } ++ ++ private static MovementEffectTimeline history(final boolean captureInputs) { ++ final var timeline = new MovementEffectTimeline(); ++ for (int frame = 0; frame < 6; frame++) { ++ if (captureInputs) { ++ timeline.beginFrame(); ++ } ++ timeline.predictGeyserBoost(1); ++ if (!captureInputs) { ++ timeline.beginFrame(); ++ } ++ timeline.completeFrame(); ++ } ++ return timeline; ++ } ++ ++ private static void expiresAfter(final MovementEffectTimeline timeline, final int frames) { ++ for (int step = 0; step < frames; step++) { ++ assertTrue(timeline.hasGeyserBoost(1)); ++ timeline.beginFrame(); ++ timeline.replayCorrections(); ++ timeline.completeFrame(); ++ } ++ assertFalse(timeline.hasGeyserBoost(1)); ++ } ++ ++} +diff --git a/src/test/java/net/raphimc/viabedrock/api/model/MovementEffectTest.java b/src/test/java/net/raphimc/viabedrock/api/model/MovementEffectTest.java +new file mode 100644 +index 000000000..dffd3d959 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/api/model/MovementEffectTest.java +@@ -0,0 +1,127 @@ ++/* ++ * 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 io.netty.buffer.Unpooled; ++import com.viaversion.viaversion.api.type.Types; ++import net.raphimc.viabedrock.protocol.data.ProtocolConstants; ++import com.google.gson.JsonParser; ++ ++import java.nio.file.Files; ++import java.nio.file.Path; ++import org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.*; ++import static org.junit.jupiter.api.Assumptions.assumeTrue; ++ ++class MovementEffectTest { ++ ++ @Test ++ void transportsUnsignedHistoryClocksAndRejectsContextsWithoutThem() { ++ final var buffer = Unpooled.buffer(); ++ try { ++ for (long oldest : new long[]{0, 100, Long.MIN_VALUE, -1}) { ++ final var effect = new MovementEffect(1, 2, 100, 105, 5, 105, 5, oldest); ++ MovementEffect.TYPE.write(buffer, effect); ++ assertEquals(effect, MovementEffect.TYPE.read(buffer)); ++ assertFalse(buffer.isReadable()); ++ buffer.clear(); ++ } ++ Types.VAR_INT.writePrimitive(buffer, 2); ++ Types.VAR_INT.writePrimitive(buffer, ++ ProtocolConstants.BEDROCK_PROTOCOL_VERSION); ++ assertThrows(IllegalArgumentException.class, () -> MovementEffect.TYPE.read(buffer)); ++ } finally { ++ buffer.release(); ++ } ++ } ++ ++ @Test ++ void expiresAgainstTheOriginalFrameInsteadOfReceiptTime() { ++ final MovementEffect effect = new MovementEffect(10, 0, 20, 81, 7, 0, -1, 0); ++ assertFalse(effect.activeAt(6)); ++ assertTrue(effect.activeAt(7)); ++ assertTrue(effect.activeAt(26)); ++ assertFalse(effect.activeAt(27)); ++ assertFalse(effect.activeAt(100)); ++ assertFalse(new MovementEffect(10, 0, 20, 81, -1, 0, -1, 0).activeAt(7)); ++ assertTrue(new MovementEffect(10, 0, -1, 81, 7, 0, -1, 0).activeAt(Long.MAX_VALUE)); ++ assertTrue(new MovementEffect(10, 0, -1, 81, -1, 0, -1, 0).activeAt(Long.MAX_VALUE)); ++ assertFalse(new MovementEffect(10, 0, 0, 81, 7, 0, -1, 0).activeAt(7)); ++ assertFalse(new MovementEffect(10, 0, -2, 81, 7, 0, -1, 0).activeAt(7)); ++ } ++ ++ @Test ++ void agesMissingHistoryAgainstThePairedInputClock() { ++ final MovementEffect startup = new MovementEffect(1, 2, 100, 0, -1, 53, 8, 0); ++ assertEquals(-1, startup.clientFrame()); ++ assertEquals(47, startup.remainingAt(8)); ++ assertEquals(46, startup.remainingAt(9)); ++ assertEquals(1, startup.remainingAt(54)); + assertFalse(startup.activeAt(55)); -+ final MovementEffect evicted = new MovementEffect(1, 0, 1000, 1, -1, 513, 100); ++ final MovementEffect evicted = new MovementEffect(1, 0, 1000, 1, -1, 513, 100, 0); + assertEquals(488, evicted.remainingAt(100)); + assertFalse(evicted.activeAt(588)); + // Actual input ticks can advance more than completed frames during a prediction gap. -+ final MovementEffect gap = new MovementEffect(1, 1, 100, 100, 5, 180, 8); ++ final MovementEffect gap = new MovementEffect(1, 1, 100, 100, 5, 180, 8, 0); + assertEquals(20, gap.remainingAt(8)); + assertFalse(gap.activeAt(28)); -+ final MovementEffect future = new MovementEffect(1, 0, 100, 514, -1, 513, 100); ++ final MovementEffect future = new MovementEffect(1, 0, 100, 514, -1, 513, 100, 0); + assertEquals(100, future.remainingAt(100)); + assertTrue(future.activeAt(199)); + assertFalse(future.activeAt(200)); -+ assertFalse(new MovementEffect(1, 0, 100, 1, -1, 513, -1).activeAt(100)); ++ assertFalse(new MovementEffect(1, 0, 100, 1, -1, 513, -1, 0).activeAt(100)); + } + + @Test + void usesNativeUnsignedClockComparisonAndSignedDurationWrap() { -+ assertEquals(97, new MovementEffect(1, 0, 100, 0x7FFF_FFFEL, -1, 0, -1).adjustedDuration(0x8000_0001L)); -+ assertEquals(97, new MovementEffect(1, 0, 100, 0xFFFF_FFFEL, -1, 0, -1).adjustedDuration(0x1_0000_0001L)); -+ assertEquals(100, new MovementEffect(1, 0, 100, Long.MIN_VALUE, -1, 0, -1).adjustedDuration(Long.MAX_VALUE)); -+ assertEquals(0, new MovementEffect(1, 0, Integer.MAX_VALUE, 0, -1, 0, -1).adjustedDuration(0xFFFF_FFFFL)); -+ assertEquals(-1, new MovementEffect(1, 0, -1, 0, -1, 0, -1).adjustedDuration(-1)); -+ assertEquals(0, new MovementEffect(1, 0, Integer.MIN_VALUE, 0, -1, 0, -1).adjustedDuration(0)); ++ assertEquals(97, new MovementEffect(1, 0, 100, 0x7FFF_FFFEL, -1, 0, -1, 0).adjustedDuration(0x8000_0001L)); ++ assertEquals(97, new MovementEffect(1, 0, 100, 0xFFFF_FFFEL, -1, 0, -1, 0).adjustedDuration(0x1_0000_0001L)); ++ assertEquals(100, new MovementEffect(1, 0, 100, Long.MIN_VALUE, -1, 0, -1, 0).adjustedDuration(Long.MAX_VALUE)); ++ assertEquals(0, new MovementEffect(1, 0, Integer.MAX_VALUE, 0, -1, 0, -1, 0).adjustedDuration(0xFFFF_FFFFL)); ++ assertEquals(-1, new MovementEffect(1, 0, -1, 0, -1, 0, -1, 0).adjustedDuration(-1)); ++ assertEquals(0, new MovementEffect(1, 0, Integer.MIN_VALUE, 0, -1, 0, -1, 0).adjustedDuration(0)); + } + + @Test @@ -4049,7 +6503,7 @@ index 000000000..80e6fcda6 + final long tick = Long.parseUnsignedLong(scenario.get("tick").getAsString()); + final long clock = Long.parseUnsignedLong(scenario.get("clock").getAsString()); + final MovementEffect effect = new MovementEffect(1, scenario.get("kind").getAsInt(), -+ scenario.get("duration").getAsInt(), tick, -1, clock, 10); ++ scenario.get("duration").getAsInt(), tick, -1, clock, 10, 0); + assertEquals(scenario.get("adjusted").getAsInt(), effect.adjustedDuration(clock)); + assertEquals(scenario.get("adjusted").getAsInt(), effect.remainingAt(10)); + } @@ -4063,7 +6517,7 @@ index 000000000..80e6fcda6 + for (int duration : new int[]{-1, 0, 1, Integer.MAX_VALUE}) { + for (long tick : new long[]{0, 127, 128, Long.MIN_VALUE, -1}) { + for (long frame : new long[]{-1, 0, Long.MAX_VALUE}) { -+ final MovementEffect effect = new MovementEffect(99, type, duration, tick, frame, tick ^ Long.MIN_VALUE, frame); ++ final MovementEffect effect = new MovementEffect(99, type, duration, tick, frame, tick ^ Long.MIN_VALUE, frame, 0); + MovementEffect.TYPE.write(buffer, effect); + assertEquals(effect, MovementEffect.TYPE.read(buffer)); + assertFalse(buffer.isReadable()); @@ -4080,7 +6534,7 @@ index 000000000..80e6fcda6 +} diff --git a/src/test/java/net/raphimc/viabedrock/api/model/MovementEffectTimelineTest.java b/src/test/java/net/raphimc/viabedrock/api/model/MovementEffectTimelineTest.java new file mode 100644 -index 000000000..2a1a73934 +index 000000000..04bfaf9b1 --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/api/model/MovementEffectTimelineTest.java @@ -0,0 +1,253 @@ @@ -4119,7 +6573,7 @@ index 000000000..2a1a73934 + timeline.completeFrame(); + } + final MovementEffect effect = new MovementEffect(1, 2, scenario.get("duration").getAsInt(), -+ scenario.get("tick").getAsLong(), -1, scenario.get("clock").getAsLong(), 10); ++ scenario.get("tick").getAsLong(), -1, scenario.get("clock").getAsLong(), 10, 0); + timeline.accept(effect); + for (final var step : scenario.getAsJsonArray("steps")) { + assertEquals(step.getAsJsonObject().get("velocity").getAsFloat() != 0F, timeline.hasGeyserBoost(1)); @@ -4137,7 +6591,7 @@ index 000000000..2a1a73934 + confirmed.completeFrame(); + predicted.completeFrame(); + } -+ final MovementEffect effect = new MovementEffect(1, 2, duration, 105, 10, 105, 10); ++ final MovementEffect effect = new MovementEffect(1, 2, duration, 105, 10, 105, 10, 0); + confirmed.accept(effect); + predicted.predictGeyserBoost(1); + for (int step = 0; step <= 100; step++) { @@ -4158,7 +6612,7 @@ index 000000000..2a1a73934 + } + timeline.predictGeyserBoost(1); + timeline.completeFrame(); -+ final MovementEffect future = new MovementEffect(1, 2, 20, 150, -1, 105, 5); ++ final MovementEffect future = new MovementEffect(1, 2, 20, 150, -1, 105, 5, 0); + timeline.accept(future); + for (int frame = 5; frame < 15; frame++) { + assertTrue(timeline.hasGeyserBoost(1)); @@ -4172,9 +6626,9 @@ index 000000000..2a1a73934 + assertFalse(timeline.hasGeyserBoost(1)); + timeline.accept(future); + assertFalse(timeline.hasGeyserBoost(1)); -+ timeline.accept(new MovementEffect(1, 0, -1, -1, -1, Long.MIN_VALUE, 25)); ++ timeline.accept(new MovementEffect(1, 0, -1, -1, -1, Long.MIN_VALUE, 25, 0)); + assertTrue(timeline.hasGlideBoost(1)); -+ timeline.accept(new MovementEffect(1, 0, 0, -1, -1, Long.MIN_VALUE, 25)); ++ timeline.accept(new MovementEffect(1, 0, 0, -1, -1, Long.MIN_VALUE, 25, 0)); + assertFalse(timeline.hasGlideBoost(1)); + timeline.clear(); + assertFalse(timeline.hasGlideBoost(1)); @@ -4186,7 +6640,7 @@ index 000000000..2a1a73934 + for (int frame = 0; frame < 10; frame++) { + timeline.completeFrame(); + } -+ final MovementEffect startup = new MovementEffect(1, 2, 100, 0, -1, 53, 8); ++ final MovementEffect startup = new MovementEffect(1, 2, 100, 0, -1, 53, 8, 0); + timeline.accept(startup); + assertTrue(timeline.hasGeyserBoost(1)); + for (int frame = 10; frame <= 55; frame++) { @@ -4196,9 +6650,9 @@ index 000000000..2a1a73934 + timeline.accept(startup); + assertFalse(timeline.hasGeyserBoost(1)); + timeline.predictGeyserBoost(1); -+ timeline.accept(new MovementEffect(1, 2, 100, 0, -1, 200, 55)); ++ timeline.accept(new MovementEffect(1, 2, 100, 0, -1, 200, 55, 0)); + assertFalse(timeline.hasGeyserBoost(1)); -+ timeline.accept(new MovementEffect(1, 2, 400, 0, -1, 200, 55)); ++ timeline.accept(new MovementEffect(1, 2, 400, 0, -1, 200, 55, 0)); + timeline.predictGeyserBoost(1); + for (int frame = 55; frame < 200; frame++) { + timeline.completeFrame(); @@ -4214,19 +6668,19 @@ index 000000000..2a1a73934 + for (int frame = 0; frame < 10; frame++) { + timeline.completeFrame(); + } -+ timeline.accept(new MovementEffect(1, 2, 20, 10, 10, 0, -1)); ++ timeline.accept(new MovementEffect(1, 2, 20, 10, 10, 0, -1, 0)); + timeline.completeFrame(); + timeline.completeFrame(); + assertTrue(timeline.hasGeyserBoost(1)); -+ timeline.accept(new MovementEffect(1, 2, 0, 9, 9, 0, -1)); ++ timeline.accept(new MovementEffect(1, 2, 0, 9, 9, 0, -1, 0)); + timeline.completeFrame(); + assertFalse(timeline.hasGeyserBoost(1)); -+ timeline.accept(new MovementEffect(1, 2, 100, 8, 8, 0, -1)); ++ timeline.accept(new MovementEffect(1, 2, 100, 8, 8, 0, -1, 0)); + for (int frame = 0; frame < 25; frame++) { + timeline.completeFrame(); + } + assertTrue(timeline.hasGeyserBoost(1)); -+ timeline.accept(new MovementEffect(1, 2, -1, 0, 0, 0, -1)); ++ timeline.accept(new MovementEffect(1, 2, -1, 0, 0, 0, -1, 0)); + for (int frame = 0; frame < 100; frame++) { + timeline.completeFrame(); + } @@ -4236,7 +6690,7 @@ index 000000000..2a1a73934 + @Test + void refreshesLocallyAdmittedGeysersAndExpiresAfterTheLastAdmission() { + final MovementEffectTimeline timeline = new MovementEffectTimeline(); -+ timeline.accept(new MovementEffect(1, 2, 100, 0, -1, 0, -1)); ++ timeline.accept(new MovementEffect(1, 2, 100, 0, -1, 0, -1, 0)); + assertFalse(timeline.hasGeyserBoost(1)); + timeline.predictGeyserBoost(1); + for (int frame = 0; frame < 17; frame++) { @@ -4250,28 +6704,28 @@ index 000000000..2a1a73934 + timeline.completeFrame(); + } + assertFalse(timeline.hasGeyserBoost(1)); -+ timeline.accept(new MovementEffect(1, 2, 100, 0, -1, 0, -1)); ++ timeline.accept(new MovementEffect(1, 2, 100, 0, -1, 0, -1, 0)); + assertFalse(timeline.hasGeyserBoost(1)); + } + + @Test + void localAdmissionPreservesLongerEffectsAndAllowsForcedTransitions() { + final MovementEffectTimeline timeline = new MovementEffectTimeline(); -+ timeline.accept(new MovementEffect(1, 2, 150, 81, 0, 0, -1)); ++ timeline.accept(new MovementEffect(1, 2, 150, 81, 0, 0, -1, 0)); + timeline.predictGeyserBoost(1); + for (int i = 0; i < 100; i++) { + timeline.completeFrame(); + } + assertTrue(timeline.hasGeyserBoost(1)); + timeline.predictGeyserBoost(1); -+ timeline.accept(new MovementEffect(1, 2, 5, 180, 99, 0, -1)); ++ timeline.accept(new MovementEffect(1, 2, 5, 180, 99, 0, -1, 0)); + for (int i = 0; i < 10; i++) { + timeline.completeFrame(); + } + assertTrue(timeline.hasGeyserBoost(1)); -+ timeline.accept(new MovementEffect(1, 2, 0, 180, 99, 0, -1)); ++ timeline.accept(new MovementEffect(1, 2, 0, 180, 99, 0, -1, 0)); + assertFalse(timeline.hasGeyserBoost(1)); -+ timeline.accept(new MovementEffect(1, 2, -1, 181, 100, 0, -1)); ++ timeline.accept(new MovementEffect(1, 2, -1, 181, 100, 0, -1, 0)); + timeline.predictGeyserBoost(1); + for (int i = 0; i < 101; i++) { + timeline.completeFrame(); @@ -4287,21 +6741,21 @@ index 000000000..2a1a73934 + for (int frame = 0; frame < 10; frame++) { + assertEquals(frame, timeline.completeFrame()); + } -+ timeline.accept(new MovementEffect(1, 0, 20, 100, 0, 0, -1)); ++ timeline.accept(new MovementEffect(1, 0, 20, 100, 0, 0, -1, 0)); + assertTrue(timeline.hasGlideBoost(1)); + assertFalse(timeline.hasGlideBoost(2)); -+ timeline.accept(new MovementEffect(1, 0, 1, 99, 0, 0, -1)); ++ timeline.accept(new MovementEffect(1, 0, 1, 99, 0, 0, -1, 0)); + assertFalse(timeline.hasGlideBoost(1)); -+ timeline.accept(new MovementEffect(1, 0, 20, 98, 0, 0, -1)); ++ timeline.accept(new MovementEffect(1, 0, 20, 98, 0, 0, -1, 0)); + assertTrue(timeline.hasGlideBoost(1)); + for (int frame = 10; frame <= 20; frame++) { + assertTrue(timeline.hasGlideBoost(1)); + timeline.completeFrame(); + } + assertFalse(timeline.hasGlideBoost(1)); -+ timeline.accept(new MovementEffect(1, 0, 5, 101, 0, 0, -1)); ++ timeline.accept(new MovementEffect(1, 0, 5, 101, 0, 0, -1, 0)); + assertFalse(timeline.hasGlideBoost(1)); -+ timeline.accept(new MovementEffect(1, 0, -1, 102, -1, 0, -1)); ++ timeline.accept(new MovementEffect(1, 0, -1, 102, -1, 0, -1, 0)); + assertTrue(timeline.hasGlideBoost(1)); + } + @@ -4309,12 +6763,12 @@ index 000000000..2a1a73934 + void keepsActorsAndEffectKindsIndependentAndClearsConnectionState() { + final MovementEffectTimeline timeline = new MovementEffectTimeline(); + assertEquals(0, timeline.completeFrame()); -+ timeline.accept(new MovementEffect(1, 0, -1, Long.MIN_VALUE, 0, 0, -1)); -+ timeline.accept(new MovementEffect(1, 0, 0, Long.MAX_VALUE, 0, 0, -1)); ++ timeline.accept(new MovementEffect(1, 0, -1, Long.MIN_VALUE, 0, 0, -1, 0)); ++ timeline.accept(new MovementEffect(1, 0, 0, Long.MAX_VALUE, 0, 0, -1, 0)); + assertFalse(timeline.hasGlideBoost(1)); -+ timeline.accept(new MovementEffect(1, 0, -1, 0, 0, 0, -1)); -+ timeline.accept(new MovementEffect(1, 1, 1, Long.MIN_VALUE, 0, 0, -1)); -+ timeline.accept(new MovementEffect(2, 2, -1, 0, 0, 0, -1)); ++ timeline.accept(new MovementEffect(1, 0, -1, 0, 0, 0, -1, 0)); ++ timeline.accept(new MovementEffect(1, 1, 1, Long.MIN_VALUE, 0, 0, -1, 0)); ++ timeline.accept(new MovementEffect(2, 2, -1, 0, 0, 0, -1, 0)); + assertTrue(timeline.hasGlideBoost(1)); + assertTrue(timeline.hasDolphinBoost(1)); + assertFalse(timeline.hasGlideBoost(2)); @@ -4326,7 +6780,7 @@ index 000000000..2a1a73934 + assertFalse(timeline.hasDolphinBoost(1)); + timeline.retainEntities(id -> id != 1); + assertFalse(timeline.hasGlideBoost(1)); -+ timeline.accept(new MovementEffect(2, 0, 1, 0, 1, 0, -1)); ++ timeline.accept(new MovementEffect(2, 0, 1, 0, 1, 0, -1, 0)); + assertTrue(timeline.hasGlideBoost(2)); + assertEquals(2, timeline.completeFrame()); + assertFalse(timeline.hasGlideBoost(2)); @@ -4337,12 +6791,144 @@ index 000000000..2a1a73934 + } + +} +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 000000000..bdb0afe80 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/api/model/NativeMovementSourcesTest.java +@@ -0,0 +1,126 @@ ++/* ++ * 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.google.gson.JsonArray; ++import com.google.gson.JsonParser; ++import net.raphimc.viabedrock.protocol.model.Position3f; ++import org.junit.jupiter.api.Test; ++import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; ++ ++import java.nio.file.Files; ++import java.nio.file.Path; ++import java.util.UUID; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++class NativeMovementSourcesTest { ++ ++ private static final UUID UUID_VALUE = UUID.randomUUID(); ++ private static final UUID LIFETIME = UUID.randomUUID(); ++ private static final Position3f FIRST = new Position3f(10.25F, 20.5F, 30.75F); ++ private static final Position3f SECOND = new Position3f(-11.5F, 25F, 42F); ++ ++ @Test ++ void retainsAdmittedHistoryAcrossTicksAndDuplicateOrStaleCommands() { ++ final var sources = new NativeMovementSources(command(0, FIRST, true, false, false, 0, 0)); ++ sources.apply(command(2, SECOND, true, false, false, 0, 0)); ++ sources.beforePhysics(Position3f.ZERO); ++ sources.apply(command(1, FIRST, true, false, false, 0, 0)); ++ sources.apply(command(2, SECOND, true, false, false, 0, 0)); ++ assertEquals(SECOND, sources.position(NativeMovementSources.Interpolation.EMPTY)); ++ assertFalse(sources.interpolation().active()); ++ } ++ ++ @Test ++ void queuesUnforcedTargetsUntilForcedPositionCompletesAndAgesTheQueue() { ++ final var sources = new NativeMovementSources(command(1, FIRST, false, true, false, 8, 3)); ++ sources.apply(command(2, SECOND, false, true, false, 0, 9)); ++ assertEquals(FIRST, sources.interpolation().target()); ++ sources.beforePhysics(Position3f.ZERO); ++ sources.beforePhysics(Position3f.ZERO); ++ sources.beforePhysics(Position3f.ZERO); ++ assertEquals(SECOND, sources.interpolation().target()); ++ assertEquals(7, sources.interpolation().positionSteps()); ++ assertEquals(7, sources.interpolation().rotationSteps()); ++ assertEquals(0, sources.interpolation().headSteps()); ++ assertFalse(sources.interpolation().mustComplete()); ++ } ++ ++ @Test ++ void unsignedDurationKeepsLowSignedCountersAndIndependentHeadOverrides() { ++ final var negative = new NativeMovementSources(command(1, FIRST, false, false, false, 0, 0x8000_0000L)); ++ negative.beforePhysics(Position3f.ZERO); ++ assertEquals(Integer.MIN_VALUE, negative.interpolation().positionSteps()); ++ assertEquals(FIRST, negative.position(negative.interpolation())); ++ final var zero = new NativeMovementSources(command(1, FIRST, false, false, false, 0, 0x1_0000_0000L)); ++ assertNull(zero.position(zero.interpolation())); ++ final var head = new NativeMovementSources(command(1, FIRST, false, true, false, 0, 0x1_0000_0000L)); ++ assertEquals(0, head.interpolation().positionSteps()); ++ assertEquals(3, head.interpolation().rotationSteps()); ++ assertEquals(3, head.interpolation().headSteps()); ++ assertEquals(FIRST, head.position(head.interpolation())); ++ } ++ ++ @Test ++ void clientPredictionClearsHistoryAndTargetsThenSeedsTheCurrentPoseOnRelease() { ++ final var sources = new NativeMovementSources(command(0, FIRST, true, true, true, 0, 0)); ++ sources.beforePhysics(Position3f.ZERO); ++ assertNull(sources.position(sources.interpolation())); ++ sources.apply(command(1, SECOND, true, true, true, 8, 3)); ++ assertEquals(SECOND, sources.position(sources.interpolation())); ++ sources.beforePhysics(Position3f.ZERO); ++ assertNull(sources.position(sources.interpolation())); ++ sources.apply(command(1, SECOND, true, true, false, 8, 3)); ++ sources.beforePhysics(FIRST); ++ assertEquals(FIRST, sources.position(sources.interpolation())); ++ } ++ ++ @Test ++ void historicalInterpolationDoesNotReadTheNewLiveTarget() { ++ final var sources = new NativeMovementSources(command(1, FIRST, false, false, false, 0, 3)); ++ final var historical = sources.interpolation(); ++ sources.apply(command(2, SECOND, false, false, false, 0, 3)); ++ assertEquals(FIRST, sources.position(historical)); ++ assertEquals(SECOND, sources.position(sources.interpolation())); ++ } ++ ++ @Test ++ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_COLLIDER_REFRESH", matches = ".+") ++ void matchesOriginalNativeSourcePriorityAndFloatBoxes() throws Exception { ++ final var input = JsonParser.parseString(Files.readString(Path.of(System.getenv("STACKANVIL_NATIVE_COLLIDER_REFRESH")))).getAsJsonObject(); ++ assertEquals(256, input.get("cases").getAsInt()); ++ for (var value : input.getAsJsonArray("rows")) { ++ final var row = value.getAsJsonObject(); ++ final var counters = row.getAsJsonArray("interpolationWords"); ++ final var interpolation = new NativeMovementSources.Interpolation(point(row.getAsJsonArray("interpolationTarget")), ++ counters.get(0).getAsInt(), counters.get(1).getAsInt(), counters.get(2).getAsInt(), false); ++ final boolean eligible = row.get("predictionComponent").getAsBoolean() && !row.get("predictionDisabled").getAsBoolean() ++ && row.get("predictionHistoryEnabled").getAsBoolean() && row.get("historyCount").getAsInt() != 0; ++ final var source = NativeMovementSources.select(point(row.getAsJsonArray("predictionPosition")), eligible, ++ row.get("interpolatorComponent").getAsBoolean() ? interpolation : NativeMovementSources.Interpolation.EMPTY); ++ final var actual = source != null && row.get("shapePresent").getAsBoolean() && row.get("offsetPresent").getAsBoolean() ++ ? NativeMovementSources.bounds(source, row.get("width").getAsFloat(), row.get("height").getAsFloat(), row.get("originOffset").getAsFloat()) ++ : box(row.getAsJsonArray("retained")); ++ assertEquals(box(row.getAsJsonArray("result")), actual, () -> "Native case " + row.get("id")); ++ } ++ } ++ ++ private static ActorSpatialCommand command(final long revision, final Position3f position, final boolean prediction, ++ final boolean head, final boolean clientPredicted, final int flags, final long duration) { ++ return new ActorSpatialCommand(ActorSpatialCommand.Action.MOVE, 7, UUID_VALUE, LIFETIME, false, revision, position, (short) flags, duration, prediction, head, clientPredicted, .375F); ++ } ++ ++ private static Position3f point(final JsonArray values) { ++ return new Position3f(values.get(0).getAsFloat(), values.get(1).getAsFloat(), values.get(2).getAsFloat()); ++ } ++ ++ private static CollisionContact.Box box(final JsonArray values) { ++ return new CollisionContact.Box(values.get(0).getAsFloat(), values.get(1).getAsFloat(), values.get(2).getAsFloat(), ++ values.get(3).getAsFloat(), values.get(4).getAsFloat(), values.get(5).getAsFloat()); ++ } ++ ++} diff --git a/src/test/java/net/raphimc/viabedrock/api/model/PlayerCorrectionTest.java b/src/test/java/net/raphimc/viabedrock/api/model/PlayerCorrectionTest.java new file mode 100644 -index 000000000..f0cb66ce9 +index 000000000..3955d614e --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/api/model/PlayerCorrectionTest.java -@@ -0,0 +1,67 @@ +@@ -0,0 +1,101 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -4353,60 +6939,94 @@ index 000000000..f0cb66ce9 +import com.viaversion.viaversion.api.type.Types; +import io.netty.buffer.Unpooled; +import net.raphimc.viabedrock.protocol.data.ProtocolConstants; ++import net.raphimc.viabedrock.protocol.model.Position2f; ++import net.raphimc.viabedrock.protocol.model.Position3f; +import org.junit.jupiter.api.Test; -+ ++import java.util.UUID; +import static org.junit.jupiter.api.Assertions.*; + +class PlayerCorrectionTest { + ++ private static final UUID ACTOR = UUID.randomUUID(); ++ private static final UUID LIFETIME = UUID.randomUUID(); ++ ++ private static PlayerCorrection correction(final int teleport, final long tick, final Float angular) { ++ return new PlayerCorrection(42, ACTOR, LIFETIME, teleport, teleport == 0 ? 7 : -1, ++ new MovementCorrectionTiming(tick, 105, 5, 100, -1), ++ new Position3f(2F, 65.62F, -3F), new Position3f(0.25F, -0.5F, 1F), ++ new Position2f(5F, 90F), angular, 1.62F, 90F, true); ++ } ++ + @Test -+ void retainsCorrectionIdentityAndRejectsIncompatibleWireFormats() { ++ void retainsUnsignedClocksExactMotionAndOptionalAngularState() { + final var buffer = Unpooled.buffer(); + try { -+ for (boolean ground : new boolean[]{false, true}) { -+ for (long tick : new long[]{0, 1, Long.MAX_VALUE}) { -+ final PlayerCorrection correction = new PlayerCorrection(42, Integer.MIN_VALUE, tick, ground); -+ PlayerCorrection.TYPE.write(buffer, correction); -+ assertEquals(correction, PlayerCorrection.TYPE.read(buffer)); -+ assertFalse(buffer.isReadable()); -+ buffer.clear(); ++ for (int teleport : new int[]{0, Integer.MIN_VALUE}) { ++ for (long tick : new long[]{0, 1, Long.MAX_VALUE, Long.MIN_VALUE, -1}) { ++ for (Float angular : new Float[]{null, 0F, 0.75F}) { ++ final var correction = correction(teleport, tick, angular); ++ PlayerCorrection.TYPE.write(buffer, correction); ++ assertEquals(correction, PlayerCorrection.TYPE.read(buffer)); ++ assertFalse(buffer.isReadable()); ++ buffer.clear(); ++ } + } + } -+ for (int version : new int[]{0, 2}) { ++ for (int version : new int[]{0, 1, 3}) { + Types.VAR_INT.writePrimitive(buffer, version); + assertThrows(IllegalArgumentException.class, () -> PlayerCorrection.TYPE.read(buffer)); + buffer.clear(); + } -+ Types.VAR_INT.writePrimitive(buffer, 1); ++ Types.VAR_INT.writePrimitive(buffer, 2); + Types.VAR_INT.writePrimitive(buffer, ProtocolConstants.BEDROCK_PROTOCOL_VERSION + 1); + assertThrows(IllegalArgumentException.class, () -> PlayerCorrection.TYPE.read(buffer)); -+ assertThrows(IllegalArgumentException.class, () -> new PlayerCorrection(42, 0, 0, true)); -+ assertThrows(IllegalArgumentException.class, () -> new PlayerCorrection(42, -1, -1, true)); ++ buffer.clear(); ++ PlayerCorrection.TYPE.write(buffer, correction(-3, 100, null)); ++ buffer.writeByte(1); ++ assertThrows(IllegalArgumentException.class, () -> PlayerCorrection.TYPE.read(buffer)); ++ assertThrows(IllegalArgumentException.class, () -> correction(-3, 100, Float.NaN)); + } finally { + buffer.release(); + } + } + + @Test -+ void appliesOnlyToTheMatchingPlayerAndPositionPacketOnce() { -+ final PlayerCorrectionTracker tracker = new PlayerCorrectionTracker(); -+ final PlayerCorrection correction = new PlayerCorrection(42, -3, 10, false); -+ tracker.accept(correction); -+ assertSame(correction, tracker.consume(42, -3)); -+ assertNull(tracker.consume(42, -3)); -+ tracker.accept(correction); -+ assertNull(tracker.consume(43, -3)); -+ assertNull(tracker.consume(42, -3)); -+ tracker.accept(correction); -+ assertNull(tracker.consume(42, -4)); -+ assertNull(tracker.consume(42, -3)); -+ tracker.accept(correction); ++ void consumesOnlyThePairedPacketAndDoesNotRequireTodaysActorToStillExist() { ++ final var tracker = new PlayerCorrectionTracker(); ++ final var player = correction(-3, 100, null); ++ tracker.accept(player); ++ assertSame(player, tracker.consumePlayer(-3)); ++ assertNull(tracker.consumePlayer(-3)); ++ tracker.accept(player); ++ assertNull(tracker.consumePlayer(-4)); ++ assertNull(tracker.consumePlayer(-3)); ++ final var vehicle = correction(0, 100, 0.75F); ++ tracker.accept(vehicle); ++ assertSame(vehicle, tracker.consumeVehicle(vehicle.javaPosition(), vehicle.javaYaw(), vehicle.rotation().x())); ++ tracker.accept(vehicle); ++ assertNull(tracker.consumeVehicle(Position3f.ZERO, vehicle.javaYaw(), vehicle.rotation().x())); ++ assertNull(tracker.consumeVehicle(vehicle.javaPosition(), vehicle.javaYaw(), vehicle.rotation().x())); ++ tracker.accept(player); + tracker.clear(); -+ assertNull(tracker.consume(42, -3)); -+ tracker.accept(correction); -+ final PlayerCorrection replacement = new PlayerCorrection(42, -4, 11, true); -+ tracker.accept(replacement); -+ assertSame(replacement, tracker.consume(42, -4)); ++ assertNull(tracker.consumePlayer(-3)); ++ } ++ ++ @Test ++ void comparesNativeFloatSquaredToleranceAndOnlyPresentVehicleFields() { ++ final var correction = correction(0, 100, 0.75F); ++ final var position = correction.javaPosition(); ++ final var motion = correction.motion(); ++ final var rotation = new Position2f(5F, 0F); ++ assertFalse(correction.differs(position, motion, true, rotation, 0.75F)); ++ assertFalse(correction.differs(position.add(0.003F, 0F, 0F), motion, true, rotation, 0.75F)); ++ assertTrue(correction.differs(position.add(0.004F, 0F, 0F), motion, true, rotation, 0.75F)); ++ assertTrue(correction.differs(position, motion.add(0F, 0.004F, 0F), true, rotation, 0.75F)); ++ assertTrue(correction.differs(position, motion, false, rotation, 0.75F)); ++ assertTrue(correction.differs(position, motion, true, new Position2f(5.004F, 0F), 0.75F)); ++ assertFalse(correction.differs(position, motion, true, rotation, null)); ++ assertFalse(correction.differs(position, motion, true, rotation, 0.75009F)); ++ assertTrue(correction.differs(position, motion, true, rotation, 0.7502F)); ++ assertFalse(correction(-3, 100, null).differs(position, motion, true, new Position2f(90F, 180F), 2F)); + } + +} @@ -4570,10 +7190,10 @@ index 000000000..8d870a5fb +} diff --git a/src/test/java/net/raphimc/viabedrock/api/model/PlayerPredictionTest.java b/src/test/java/net/raphimc/viabedrock/api/model/PlayerPredictionTest.java new file mode 100644 -index 000000000..4a37d9558 +index 000000000..4b403b7a2 --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/api/model/PlayerPredictionTest.java -@@ -0,0 +1,179 @@ +@@ -0,0 +1,256 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -4587,6 +7207,8 @@ index 000000000..4a37d9558 +import net.raphimc.viabedrock.protocol.model.Position3f; +import net.raphimc.viabedrock.protocol.storage.PlayerPredictionStorage; +import org.junit.jupiter.api.Test; ++import org.junit.jupiter.params.ParameterizedTest; ++import org.junit.jupiter.params.provider.ValueSource; + +import static net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.PlayerAuthInputData.*; +import static org.junit.jupiter.api.Assertions.*; @@ -4660,22 +7282,22 @@ index 000000000..4a37d9558 + } + + @Test -+ void consumesOnlyOneMatchingFrameAndDiscardsTeleportStaleness() { ++ void consumesTheNewestFrameOnceAndDiscardsAuthoritativePositionStaleness() { + final var storage = new PlayerPredictionStorage(); + final var frame = new PlayerPrediction(42, new Position3f(0.5F, 101F, 0.5F), Position3f.ZERO, + PlayerPosture.STANDING, false, false, true, false, 1F); + storage.record(frame); -+ assertEquals(frame, storage.take(frame.position().add(0F, 0.00001F, 0F))); -+ assertNull(storage.take(frame.position())); ++ assertEquals(frame, storage.take()); ++ assertNull(storage.take()); + final var misplaced = new PlayerPrediction(43, frame.position(), frame.motion(), frame.posture(), false, false, true, false, 1F); + storage.record(misplaced); -+ assertNull(storage.take(misplaced.position().add(0F, 5F, 0F))); -+ assertNull(storage.take(frame.position())); ++ storage.discardPending(); ++ assertNull(storage.take()); + final var next = new PlayerPrediction(44, frame.position(), frame.motion(), frame.posture(), false, false, false, false, 1F); + final var replacement = new PlayerPrediction(45, frame.position(), Position3f.ZERO, frame.posture(), false, false, false, false, 1F); + storage.record(next); + storage.record(replacement); -+ assertEquals(replacement, storage.take(frame.position())); ++ assertEquals(replacement, storage.take()); + } + + @Test @@ -4687,27 +7309,27 @@ index 000000000..4a37d9558 + storage.record(older); + storage.record(newer); + storage.record(older); -+ assertEquals(newer, storage.take(Position3f.ZERO)); ++ assertEquals(newer, storage.take()); + storage.record(newer); -+ assertNull(storage.take(Position3f.ZERO)); ++ assertNull(storage.take()); + } + + @Test -+ void retainsFrameIdentityAfterPositionRejectionAndResetsOnNewConnection() { ++ void retainsFrameIdentityAfterDiscardAndResetsOnNewConnection() { + final var storage = new PlayerPredictionStorage(); + final var frame = new PlayerPrediction(Long.MAX_VALUE, Position3f.ZERO, Position3f.ZERO, + PlayerPosture.STANDING, false, false, false, false, 1F); + storage.record(frame); -+ assertNull(storage.take(new Position3f(1F, 0F, 0F))); ++ storage.discardPending(); + storage.record(frame); + storage.record(new PlayerPrediction(0, Position3f.ZERO, Position3f.ZERO, + PlayerPosture.STANDING, false, false, false, false, 1F)); -+ assertNull(storage.take(Position3f.ZERO)); ++ assertNull(storage.take()); + final var reconnected = new PlayerPredictionStorage(); + final var first = new PlayerPrediction(0, Position3f.ZERO, Position3f.ZERO, + PlayerPosture.STANDING, false, false, false, false, 1F); + reconnected.record(first); -+ assertEquals(first, reconnected.take(Position3f.ZERO)); ++ assertEquals(first, reconnected.take()); + } + + @Test @@ -4727,13 +7349,88 @@ index 000000000..4a37d9558 + assertEquals(-1, storage.inputFrame()); + assertEquals(1513, storage.clientFrame(513)); + assertEquals(-1, storage.clientFrame(1)); -+ assertEquals(1002, storage.clientFrame(2)); ++ assertEquals(-1, storage.clientFrame(2)); ++ assertEquals(1003, storage.clientFrame(3)); + assertEquals(-1, storage.clientFrame(514)); + assertEquals(-1, new PlayerPredictionStorage().clientFrame(513)); + } + -+ @Test -+ void emitsIndependentSwimmingAndCrawlingEdgesOncePerConnection() { ++ @ParameterizedTest ++ @ValueSource(ints = {0, 1, 2, 3, 512, 1000, 1001, 65535}) ++ void boundsHistoryByTheServerLimitAndRetainsMissingPredictionClocks(final int capacity) { ++ final var storage = new PlayerPredictionStorage(); ++ storage.configureHistory(capacity); ++ final int size = Math.clamp(capacity, 1, 1000); ++ final var prediction = frame(Position3f.ZERO, false); ++ for (long tick = 100; tick <= 100 + size; tick++) { ++ storage.remember(tick, prediction); ++ } ++ assertFalse(storage.acceptsCorrectionTick(100)); ++ assertTrue(storage.acceptsCorrectionTick(101)); ++ assertTrue(storage.acceptsCorrectionTick(101 + size)); ++ assertTrue(storage.acceptsCorrectionTick(-1)); ++ assertEquals(-1, storage.clientFrame(100)); ++ assertEquals(prediction.frameId(), storage.clientFrame(101)); ++ storage.remember(101 + size, null); ++ assertFalse(storage.acceptsCorrectionTick(101)); ++ assertTrue(storage.acceptsCorrectionTick(102)); ++ assertEquals(-1, storage.clientFrame(101)); ++ assertEquals(-1, storage.inputFrame()); ++ } ++ ++ @Test ++ void startsNewHistoryOnClockGapsDuplicatesAndRewindsWithoutResettingFrameIdentity() { ++ final var storage = new PlayerPredictionStorage(); ++ storage.configureHistory(3); ++ final var prediction = frame(Position3f.ZERO, false); ++ storage.record(prediction); ++ storage.remember(100, prediction); ++ storage.remember(101, null); ++ assertEquals(prediction.frameId(), storage.clientFrame(100)); ++ storage.remember(105, prediction); ++ assertEquals(-1, storage.clientFrame(100)); ++ assertFalse(storage.acceptsCorrectionTick(104)); ++ storage.remember(106, null); ++ assertEquals(prediction.frameId(), storage.clientFrame(105)); ++ storage.remember(106, null); ++ assertEquals(-1, storage.clientFrame(105)); ++ assertFalse(storage.acceptsCorrectionTick(105)); ++ storage.remember(104, null); ++ assertFalse(storage.acceptsCorrectionTick(103)); ++ assertTrue(storage.acceptsCorrectionTick(104)); ++ storage.configureHistory(2); ++ assertEquals(-1, storage.inputFrame()); ++ assertFalse(storage.acceptsCorrectionTick(1)); ++ storage.remember(1, null); ++ assertTrue(storage.acceptsCorrectionTick(1)); ++ storage.record(prediction); ++ assertNull(storage.take()); ++ } ++ ++ @Test ++ void preservesUnsignedClockBoundariesAndReservesZeroEvenWhenCaptured() { ++ final var storage = new PlayerPredictionStorage(); ++ storage.configureHistory(3); ++ final var prediction = frame(Position3f.ZERO, false); ++ storage.remember(Long.MAX_VALUE, prediction); ++ storage.remember(Long.MIN_VALUE, null); ++ assertEquals(prediction.frameId(), storage.clientFrame(Long.MAX_VALUE)); ++ assertFalse(storage.acceptsCorrectionTick(Long.MAX_VALUE - 1)); ++ assertTrue(storage.acceptsCorrectionTick(Long.MIN_VALUE)); ++ storage.remember(-1, prediction); ++ assertEquals(-1, storage.clientFrame(Long.MAX_VALUE)); ++ storage.remember(0, null); ++ storage.remember(1, null); ++ assertEquals(prediction.frameId(), storage.clientFrame(-1)); ++ assertFalse(storage.acceptsCorrectionTick(0)); ++ assertFalse(storage.acceptsCorrectionTick(1)); ++ storage.remember(2, null); ++ assertTrue(storage.acceptsCorrectionTick(1)); ++ assertFalse(storage.acceptsCorrectionTick(0)); ++ } ++ ++ @Test ++ void emitsIndependentSwimmingAndCrawlingEdgesOncePerConnection() { + final var storage = new PlayerPredictionStorage(); + assertTrue(storage.postureTransitions(PlayerPosture.STANDING).isEmpty()); + final var swimming = new PlayerPosture(true, false, false); @@ -5316,10 +8013,10 @@ index 000000000..aa3e68c3d +} diff --git a/src/test/java/net/raphimc/viabedrock/api/model/entity/NativeActorPublicationTest.java b/src/test/java/net/raphimc/viabedrock/api/model/entity/NativeActorPublicationTest.java new file mode 100644 -index 000000000..df7db4832 +index 000000000..a80bf1da3 --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/api/model/entity/NativeActorPublicationTest.java -@@ -0,0 +1,156 @@ +@@ -0,0 +1,214 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -5338,6 +8035,9 @@ index 000000000..df7db4832 +import com.viaversion.viaversion.protocols.v26_2to26_3.packet.ClientboundPackets26_3; +import io.netty.buffer.Unpooled; +import net.raphimc.viabedrock.api.model.NativeActorMessage; ++import net.raphimc.viabedrock.api.model.NativeActorStates; ++import net.raphimc.viabedrock.api.model.ActorSpatialCommand; ++import net.raphimc.viabedrock.protocol.model.Position3f; +import net.raphimc.viabedrock.api.model.NativeActorBoundsMessage; +import com.viaversion.viaversion.api.minecraft.entitydata.EntityData; +import net.raphimc.viabedrock.protocol.data.enums.bedrock.ActorDataIds; @@ -5352,6 +8052,7 @@ index 000000000..df7db4832 + +import java.util.ArrayList; +import java.util.List; ++import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; @@ -5360,6 +8061,60 @@ index 000000000..df7db4832 +class NativeActorPublicationTest { + + @Test ++ void metadataPublishesBeforeSpatialBootstrapAndRemovalRetiresItsDependencies() throws Exception { ++ final var user = mock(UserConnection.class); ++ final var info = mock(ProtocolInfo.class); ++ final var tracker = spy(new EntityTracker(user)); ++ final var channels = new ChannelStorage(); ++ when(user.get(any())).thenAnswer(call -> mock((Class) call.getArgument(0))); ++ when(user.getProtocolInfo()).thenReturn(info); ++ when(info.getServerState()).thenReturn(State.PLAY); ++ when(user.get(EntityTracker.class)).thenReturn(tracker); ++ when(user.get(ChannelStorage.class)).thenReturn(channels); ++ when(tracker.nativeActorsJoined()).thenReturn(true); ++ channels.addChannels(Set.of(NativeActorMessage.CURRENT_CHANNEL, NativeActorBoundsMessage.CHANNEL, ActorSpatialCommand.CHANNEL)); ++ final var actor = new BoatEntity(user, 170, 91, "minecraft:boat", 7, UUID.randomUUID(), EntityTypes26_3.OAK_BOAT); ++ when(tracker.getEntityByUid(actor.uniqueId())).thenReturn(actor); ++ actor.setPosition(new Position3f(.5F, 70.375F, .5F)); ++ 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)); ++ final var registry = new NativeActorStates(); ++ try (var factory = mockStatic(PacketWrapper.class)) { ++ factory.when(() -> PacketWrapper.create(any(PacketType.class), eq(user))).thenAnswer(call -> { ++ final var packet = spy(new PacketWrapperImpl(call.getArgument(0), null, user)); ++ doAnswer(_ -> { ++ final var bytes = Unpooled.buffer(); ++ try { ++ packet.writeToBuffer(bytes); ++ assertEquals(ClientboundPackets26_3.CUSTOM_PAYLOAD.getId(), Types.VAR_INT.readPrimitive(bytes)); ++ final String channel = Types.STRING.read(bytes); ++ if (channel.equals(ActorSpatialCommand.CHANNEL)) { ++ registry.apply(ActorSpatialCommand.TYPE.read(bytes), null); ++ } else if (channel.equals(NativeActorBoundsMessage.CHANNEL)) { ++ registry.apply(NativeActorBoundsMessage.TYPE.read(bytes), null); ++ } else { ++ registry.apply(NativeActorMessage.CURRENT_TYPE.read(bytes), null); ++ } ++ assertFalse(bytes.isReadable()); ++ } finally { ++ bytes.release(); ++ } ++ return null; ++ }).when(packet).send(eq(BedrockProtocol.class), eq(true)); ++ return packet; ++ }); ++ actor.onEntityDataChanged(); ++ assertNotNull(registry.movement(actor.javaUuid())); ++ assertEquals(actor.position(), registry.movement(actor.javaUuid()).position(registry.movement(actor.javaUuid()).interpolation())); ++ actor.remove(); ++ assertNull(registry.movement(actor.javaUuid())); ++ actor.sendNativeActorSnapshot(); ++ assertNotNull(registry.movement(actor.javaUuid())); ++ assertNotNull(registry.bounds(actor.javaUuid())); ++ } ++ } ++ ++ @Test + void registrationUpgradeAndDowngradeReemitEqualStateWithTheSelectedCodec() throws Exception { + final var user = mock(UserConnection.class); + final var info = mock(ProtocolInfo.class); @@ -5879,10 +8634,10 @@ index ad55fc55c..93bdd824d 100644 final ClientPlayerEntity player = player(); diff --git a/src/test/java/net/raphimc/viabedrock/protocol/packet/CompletedFramePositionTest.java b/src/test/java/net/raphimc/viabedrock/protocol/packet/CompletedFramePositionTest.java new file mode 100644 -index 000000000..df1b9c280 +index 000000000..6d8d7a856 --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/protocol/packet/CompletedFramePositionTest.java -@@ -0,0 +1,992 @@ +@@ -0,0 +1,1127 @@ +/* + * This file is part of ViaBedrock - https://github.com/ViaVersionAddons/ViaBedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -5901,11 +8656,13 @@ index 000000000..df1b9c280 +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 com.viaversion.viaversion.protocols.v26_2to26_3.packet.ServerboundPackets26_3; +import io.netty.buffer.Unpooled; +import net.raphimc.viabedrock.api.model.PlayerInputPermissions; +import net.raphimc.viabedrock.api.model.PlayerPosture; +import net.raphimc.viabedrock.api.model.PlayerPrediction; ++import net.raphimc.viabedrock.api.model.PlayerCorrection; +import net.raphimc.viabedrock.api.model.NativeActorMessage; +import net.raphimc.viabedrock.api.model.VehiclePrediction; +import net.raphimc.viabedrock.api.model.VehiclePredictionContext; @@ -5924,6 +8681,9 @@ index 000000000..df1b9c280 +import net.raphimc.viabedrock.protocol.model.BedrockItem; +import net.raphimc.viabedrock.protocol.model.PlayerAbilities; +import net.raphimc.viabedrock.protocol.model.Position3f; ++import net.raphimc.viabedrock.protocol.model.EntityLink; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ActorLinkType; ++import com.viaversion.viaversion.api.minecraft.entitydata.EntityData; +import net.raphimc.viabedrock.protocol.rewriter.InventoryTransactionRewriter; +import net.raphimc.viabedrock.protocol.provider.EmoteProvider; +import net.raphimc.viabedrock.protocol.storage.ChannelStorage; @@ -6115,7 +8875,7 @@ index 000000000..df1b9c280 + fixture.accept(prediction(2, new Position3f(0.0001F, 64F, 0F))); + assertEquals(boat.position(), fixture.tick().position()); + assertEquals(original, fixture.player.position()); -+ assertEquals(2, fixture.predictions.clientFrame(2)); ++ assertEquals(-1, fixture.predictions.clientFrame(2)); + fixture.player.setMountEntityRuntimeId(-1); + fixture.accept(prediction(3, new Position3f(0.0001F, 64F, 0F))); + final Position3f corrected = new Position3f(3F, 67.62F, -1F); @@ -6244,6 +9004,28 @@ index 000000000..df1b9c280 + } + + @Test ++ void resolvesAnEarlyPassengerLinkWithoutGrantingPredictionBeforeTheVehicleHasCoordinates() { ++ try (Fixture fixture = new Fixture(true, true, true)) { ++ when(fixture.chunks.isInUnloadedChunkSection(any())).thenAnswer(call -> { ++ java.util.Objects.requireNonNull(call.getArgument(0)); ++ return false; ++ }); ++ final Entity boat = new BoatEntity(fixture.user, 2, 3, "minecraft:boat", 2, ++ UUID.randomUUID(), EntityTypes26_3.OAK_BOAT); ++ fixture.tracker.applyEntityLink(new EntityLink(boat.uniqueId(), fixture.player.uniqueId(), ++ ActorLinkType.Riding, false, false, 0F)); ++ fixture.tracker.addEntity(boat, false); ++ assertEquals(boat.runtimeId(), fixture.player.mountEntityRuntimeId()); ++ assertNull(fixture.vehicles.context().vehicle()); ++ ++ boat.setPosition(new Position3f(6F, 62F + boat.eyeOffset(), -10F)); ++ boat.updateEntityData(new EntityData[0], new ArrayList<>()); ++ assertEquals(boat.javaUuid(), fixture.vehicles.context().vehicle().javaUuid()); ++ assertEquals(boat.runtimeId(), fixture.vehicles.context().vehicle().runtimeId()); ++ } ++ } ++ ++ @Test + void foreignVehicleIdentityCannotReplaceFreshPendingPhysics() throws Exception { + try (Fixture fixture = new Fixture(true, true, true)) { + final Entity boat = fixture.mount(EntityTypes26_3.OAK_BOAT); @@ -6271,7 +9053,7 @@ index 000000000..df1b9c280 + } + + @Test -+ void vehiclePhysicsRequiresMatchingAdmittedPlayerFrameAndStandardVehicleCoordinates() throws Exception { ++ void vehiclePhysicsPairsSharedFramesAndValidatesVehicleCoordinatesIndependentlyOfTheRider() throws Exception { + try (Fixture fixture = new Fixture(true, true, true)) { + final Entity boat = fixture.mount(EntityTypes26_3.OAK_BOAT); + final var context = fixture.vehicles.context(); @@ -6284,7 +9066,8 @@ index 000000000..df1b9c280 + assertEquals(Position3f.ZERO, fixture.tick().velocity()); + fixture.accept(prediction(4, fixture.player.javaPosition().add(2F, 0F, 0F))); + fixture.acceptVehicle(new VehiclePrediction(4, context, boat.javaPosition(), motion)); -+ assertEquals(Position3f.ZERO, fixture.tick().velocity()); ++ assertEquals(motion, fixture.tick().velocity()); ++ assertEquals(4, fixture.predictions.inputFrame()); + fixture.accept(prediction(5, fixture.player.javaPosition())); + fixture.acceptVehicle(new VehiclePrediction(5, context, boat.javaPosition().add(2F, 0F, 0F), motion)); + assertEquals(Position3f.ZERO, fixture.tick().velocity()); @@ -6296,6 +9079,100 @@ index 000000000..df1b9c280 + } + + @Test ++ void preservesAuthoritativeVehicleVelocityIncludingZeroResets() throws Exception { ++ for (final EntityTypes26_3 type : new EntityTypes26_3[]{EntityTypes26_3.OAK_BOAT, EntityTypes26_3.HORSE}) { ++ try (Fixture fixture = new Fixture(true, true, true)) { ++ final Entity vehicle = fixture.mount(type); ++ fixture.tick(); ++ for (final Position3f velocity : new Position3f[]{new Position3f(-0.25F, 0.5F, 1.5F), Position3f.ZERO}) { ++ fixture.corrections.clear(); ++ final Position3f position = vehicle.position().add(1F, 0F, 0F); ++ final PacketWrapper corrected = fixture.correct(position, 1, velocity); ++ assertEquals(ClientboundPackets26_3.MOVE_VEHICLE, corrected.getPacketType()); ++ assertEquals(position, vehicle.position()); ++ final var motions = fixture.corrections.stream() ++ .filter(packet -> packet.getPacketType() == ClientboundPackets26_3.SET_ENTITY_MOTION).toList(); ++ assertEquals(1, motions.size()); ++ final var bytes = Unpooled.buffer(); ++ try { ++ motions.getFirst().writeToBuffer(bytes); ++ assertEquals(ClientboundPackets26_3.SET_ENTITY_MOTION.getId(), Types.VAR_INT.readPrimitive(bytes)); ++ assertEquals(vehicle.javaId(), Types.VAR_INT.readPrimitive(bytes)); ++ final var actual = Types.LOW_PRECISION_VECTOR.read(bytes); ++ assertEquals(velocity.x(), actual.x(), 0.001); ++ assertEquals(velocity.y(), actual.y(), 0.001); ++ assertEquals(velocity.z(), actual.z(), 0.001); ++ assertFalse(bytes.isReadable()); ++ } finally { ++ bytes.release(); ++ } ++ } ++ } ++ } ++ } ++ ++ @Test ++ void negotiatedVehicleCorrectionQueuesExactKinematicsWithoutSnappingOrReplacingControl() throws Exception { ++ for (var type : List.of(EntityTypes26_3.OAK_BOAT, EntityTypes26_3.HORSE)) { ++ try (Fixture fixture = new Fixture(true, true, true)) { ++ final Entity vehicle = fixture.mount(type); ++ fixture.channels.addChannels(List.of(PlayerCorrection.CHANNEL)); ++ fixture.accept(prediction(0, fixture.player.javaPosition())); ++ fixture.acceptVehicle(new VehiclePrediction(0, fixture.vehicles.context(), vehicle.javaPosition(), Position3f.ZERO)); ++ fixture.tick(); ++ final var context = fixture.vehicles.context(); ++ final var before = vehicle.position(); ++ final var rotation = new Position2f(5F, 60F); ++ final var motion = new Position3f(0.123456F, -0.7654321F, 1.25F); ++ vehicle.setOnGround(true); ++ fixture.corrections.clear(); ++ final var packet = fixture.correct(before.add(1F, 2F, -3F), 1, motion, rotation, 0.75F); ++ assertEquals(ClientboundPackets26_3.MOVE_VEHICLE, packet.getPacketType()); ++ assertEquals(before, vehicle.position()); ++ assertTrue(vehicle.isOnGround()); ++ assertEquals(context, fixture.vehicles.context()); ++ assertEquals(1, fixture.corrections.size()); ++ final var correction = fixture.corrections.getFirst().get(PlayerCorrection.TYPE, 0); ++ assertEquals(context.epoch(), correction.vehicleEpoch()); ++ assertEquals(vehicle.javaId(), correction.entityId()); ++ assertEquals(vehicle.javaUuid(), correction.uuid()); ++ assertEquals(vehicle.nativeActorSnapshot().lifetime(), correction.lifetime()); ++ assertEquals(before.add(1F, 2F, -3F), correction.position()); ++ assertEquals(motion, correction.motion()); ++ assertEquals(rotation, correction.rotation()); ++ assertEquals(0.75F, correction.angularVelocity()); ++ assertEquals(1, correction.timing().inputTick()); ++ assertEquals(0, correction.timing().inputFrame()); ++ assertEquals(vehicle.eyeOffset(), correction.originOffset()); ++ assertEquals(type == EntityTypes26_3.OAK_BOAT ? 90F : 0F, correction.yawOffset()); ++ } ++ } ++ } ++ ++ @Test ++ void expiredVehicleCorrectionCannotChangePositionGroundMotionOrMountIdentity() throws Exception { ++ for (var type : List.of(EntityTypes26_3.OAK_BOAT, EntityTypes26_3.HORSE)) { ++ try (Fixture fixture = new Fixture(true, true, true)) { ++ fixture.predictions.configureHistory(2); ++ final Entity vehicle = fixture.mount(type); ++ vehicle.setOnGround(true); ++ fixture.tick(); ++ fixture.tick(); ++ fixture.tick(); ++ final Position3f position = vehicle.position(); ++ final var context = fixture.vehicles.context(); ++ fixture.corrections.clear(); ++ final PacketWrapper rejected = fixture.correct(position.add(1F, 1F, 1F), 1, new Position3f(1F, 2F, 3F)); ++ assertTrue(rejected.isCancelled()); ++ assertEquals(position, vehicle.position()); ++ assertTrue(vehicle.isOnGround()); ++ assertEquals(context, fixture.vehicles.context()); ++ assertTrue(fixture.corrections.isEmpty()); ++ } ++ } ++ } ++ ++ @Test + void tinyAuthorityCorrectionInvalidatesMountEpochWithoutResettingEitherWatermarkOrPlayerHistory() throws Exception { + try (Fixture fixture = new Fixture(true, true, true)) { + final Entity boat = fixture.mount(EntityTypes26_3.OAK_BOAT); @@ -6806,18 +9683,31 @@ index 000000000..df1b9c280 + } + + private void correct(final Position3f position, final int rewindType) throws Exception { ++ this.correct(position, rewindType, Position3f.ZERO); ++ } ++ ++ private PacketWrapper correct(final Position3f position, final int rewindType, final Position3f velocity) throws Exception { ++ return this.correct(position, rewindType, velocity, new Position2f(0F, 0F), null); ++ } ++ ++ private PacketWrapper correct(final Position3f position, final int rewindType, final Position3f velocity, ++ final Position2f rotation, final Float angular) throws Exception { + final var bytes = Unpooled.buffer(); + try { + Types.BYTE.write(bytes, (byte) rewindType); + BedrockTypes.POSITION_3F.write(bytes, position); -+ BedrockTypes.POSITION_3F.write(bytes, Position3f.ZERO); -+ BedrockTypes.POSITION_2F.write(bytes, new Position2f(0F, 0F)); -+ Types.BOOLEAN.write(bytes, false); ++ BedrockTypes.POSITION_3F.write(bytes, velocity); ++ BedrockTypes.POSITION_2F.write(bytes, rotation); ++ Types.BOOLEAN.write(bytes, angular != null); ++ if (angular != null) { ++ BedrockTypes.FLOAT_LE.write(bytes, angular); ++ } + Types.BOOLEAN.write(bytes, false); + BedrockTypes.UNSIGNED_VAR_LONG.write(bytes, 1L); + final PacketWrapper wrapper = new PacketWrapperImpl(ClientboundBedrockPackets.CORRECT_PLAYER_MOVE_PREDICTION, bytes, this.user); + this.protocol.correct(wrapper); + assertFalse(bytes.isReadable()); ++ return wrapper; + } finally { + bytes.release(); + } @@ -6875,9 +9765,386 @@ index 000000000..df1b9c280 + } + +} +diff --git a/src/test/java/net/raphimc/viabedrock/protocol/packet/CompletedPredictionPairingTest.java b/src/test/java/net/raphimc/viabedrock/protocol/packet/CompletedPredictionPairingTest.java +new file mode 100644 +index 000000000..8da74028f +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/protocol/packet/CompletedPredictionPairingTest.java +@@ -0,0 +1,132 @@ ++/* ++ * 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.viaversion.api.connection.UserConnection; ++import net.raphimc.viabedrock.api.model.PlayerPosture; ++import net.raphimc.viabedrock.api.model.PlayerPrediction; ++import net.raphimc.viabedrock.api.model.VehiclePrediction; ++import net.raphimc.viabedrock.api.model.VehiclePredictionContext; ++import net.raphimc.viabedrock.api.model.entity.Entity; ++import net.raphimc.viabedrock.protocol.model.Position3f; ++import net.raphimc.viabedrock.protocol.storage.ChunkTracker; ++import net.raphimc.viabedrock.protocol.storage.PlayerPredictionStorage; ++import net.raphimc.viabedrock.protocol.storage.VehiclePredictionStorage; ++import org.junit.jupiter.api.Test; ++import org.junit.jupiter.params.ParameterizedTest; ++import org.junit.jupiter.params.provider.EnumSource; ++ ++import java.util.UUID; ++ ++import static org.junit.jupiter.api.Assertions.*; ++import static org.mockito.Mockito.*; ++ ++class CompletedPredictionPairingTest { ++ ++ private static final Position3f RIDER = new Position3f(0.50786763F, 71.12046F, 0.5082658F); ++ private static final Position3f BOAT = new Position3f(0.50786763F, 71.53296F, 0.5082658F); ++ ++ @Test ++ void bindsValidVehicleFramesWhenTheTrackedRiderPositionIsStale() throws Exception { ++ final var fixture = new Fixture(); ++ final var frame = fixture.record(154, RIDER); ++ final var completed = ClientPlayerPackets.completedPredictions(fixture.players, fixture.vehicles, fixture.vehicle, ++ new Position3f(0.5F, 72F, 0.5F)); ++ assertNull(completed.player()); ++ assertEquals(frame, completed.vehicle()); ++ assertEquals(154, completed.clockFrame().frameId()); ++ fixture.players.remember(291, completed.clockFrame()); ++ assertEquals(154, fixture.players.clientFrame(291)); ++ assertEquals(154, fixture.players.inputFrame()); ++ assertNull(ClientPlayerPackets.completedPredictions(fixture.players, fixture.vehicles, fixture.vehicle, RIDER).clockFrame()); ++ } ++ ++ @ParameterizedTest ++ @EnumSource(RejectedVehicle.class) ++ void rejectsMountedClocksFromUnpairedOrInvalidVehicles(final RejectedVehicle rejection) throws Exception { ++ final var fixture = new Fixture(); ++ fixture.record(rejection == RejectedVehicle.FRAME ? 155 : 154, RIDER); ++ switch (rejection) { ++ case PLAYER -> fixture.players.discardPending(); ++ case VEHICLE -> fixture.vehicles.discardPending(); ++ case FRAME -> { ++ fixture.players.discardPending(); ++ fixture.players.record(player(156, RIDER)); ++ } ++ case CONTEXT -> set(fixture.vehicles, "context", new VehiclePredictionContext(5, fixture.context.vehicle())); ++ case ACTOR -> set(fixture.vehicles, "vehicle", mock(Entity.class)); ++ case POSITION -> when(fixture.vehicle.javaPosition()).thenReturn(BOAT.add(1F, 0F, 0F)); ++ case UNLOADED -> when(fixture.chunks.isInUnloadedChunkSection(any())).thenReturn(true); ++ } ++ final var completed = ClientPlayerPackets.completedPredictions(fixture.players, fixture.vehicles, fixture.vehicle, RIDER); ++ assertNull(completed.vehicle()); ++ assertNull(completed.clockFrame()); ++ fixture.players.remember(291, completed.clockFrame()); ++ assertEquals(-1, fixture.players.inputFrame()); ++ } ++ ++ @Test ++ void unmountedClocksStillRequireMatchingPlayerPositionsAndCannotReuseRejectedFrames() { ++ final var players = new PlayerPredictionStorage(); ++ final var first = player(154, RIDER); ++ players.record(first); ++ final var rejected = ClientPlayerPackets.completedPredictions(players, null, null, RIDER.add(1F, 0F, 0F)); ++ assertNull(rejected.player()); ++ assertNull(rejected.clockFrame()); ++ players.record(first); ++ assertNull(ClientPlayerPackets.completedPredictions(players, null, null, RIDER).clockFrame()); ++ final var next = player(155, RIDER); ++ players.record(next); ++ final var accepted = ClientPlayerPackets.completedPredictions(players, null, null, RIDER.add(0F, 0.00001F, 0F)); ++ assertEquals(next, accepted.player()); ++ assertEquals(next, accepted.clockFrame()); ++ assertNull(accepted.vehicle()); ++ } ++ ++ private static PlayerPrediction player(final long frame, final Position3f position) { ++ return new PlayerPrediction(frame, position, Position3f.ZERO, PlayerPosture.STANDING, false, false, false, false, 1F); ++ } ++ ++ private static void set(final Object target, final String name, final Object value) throws ReflectiveOperationException { ++ final var field = target.getClass().getDeclaredField(name); ++ field.setAccessible(true); ++ field.set(target, value); ++ } ++ ++ private enum RejectedVehicle { ++ PLAYER, VEHICLE, FRAME, CONTEXT, ACTOR, POSITION, UNLOADED ++ } ++ ++ private static final class Fixture { ++ ++ private final PlayerPredictionStorage players = new PlayerPredictionStorage(); ++ private final Entity vehicle = mock(Entity.class); ++ private final ChunkTracker chunks = mock(ChunkTracker.class); ++ private final VehiclePredictionStorage vehicles; ++ private final VehiclePredictionContext context = new VehiclePredictionContext(4, ++ new VehiclePredictionContext.Identity(8, UUID.randomUUID(), UUID.randomUUID(), 12, 17)); ++ ++ private Fixture() throws ReflectiveOperationException { ++ final var user = mock(UserConnection.class); ++ when(user.get(ChunkTracker.class)).thenReturn(this.chunks); ++ this.vehicles = spy(new VehiclePredictionStorage(user)); ++ doNothing().when(this.vehicles).refresh(); ++ set(this.vehicles, "vehicle", this.vehicle); ++ set(this.vehicles, "context", this.context); ++ when(this.vehicle.javaPosition()).thenReturn(BOAT); ++ when(this.vehicle.position()).thenReturn(BOAT); ++ } ++ ++ private VehiclePrediction record(final long frame, final Position3f position) { ++ this.players.record(player(frame, position)); ++ final var prediction = new VehiclePrediction(frame, this.context, BOAT, new Position3f(0.1F, 0F, 0F)); ++ this.vehicles.record(prediction); ++ return prediction; ++ } ++ ++ } ++ ++} +diff --git a/src/test/java/net/raphimc/viabedrock/protocol/packet/ControlledVehicleSpatialTest.java b/src/test/java/net/raphimc/viabedrock/protocol/packet/ControlledVehicleSpatialTest.java +new file mode 100644 +index 000000000..fbed1ac0b +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/protocol/packet/ControlledVehicleSpatialTest.java +@@ -0,0 +1,233 @@ ++/* ++ * 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.google.gson.JsonParser; ++import com.viaversion.viaversion.api.connection.UserConnection; ++import com.viaversion.viaversion.api.minecraft.entities.EntityTypes26_3; ++import com.viaversion.viaversion.api.minecraft.entitydata.EntityData; ++import com.viaversion.viaversion.api.protocol.packet.PacketWrapper; ++import com.viaversion.viaversion.api.protocol.packet.State; ++import com.viaversion.viaversion.protocol.packet.PacketWrapperImpl; ++import io.netty.buffer.Unpooled; ++import net.raphimc.viabedrock.api.model.entity.AbstractHorseEntity; ++import net.raphimc.viabedrock.api.model.entity.BoatEntity; ++import net.raphimc.viabedrock.api.model.entity.ClientPlayerEntity; ++import net.raphimc.viabedrock.api.model.entity.Entity; ++import net.raphimc.viabedrock.api.model.entity.PlayerEntity; ++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.ActorDataIds; ++import net.raphimc.viabedrock.protocol.types.entitydata.EntityDataTypesBedrock; ++import net.raphimc.viabedrock.protocol.model.Position3f; ++import net.raphimc.viabedrock.protocol.storage.EntityTracker; ++import net.raphimc.viabedrock.protocol.types.BedrockTypes; ++import org.junit.jupiter.api.Test; ++import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; ++ ++import java.nio.file.Files; ++import java.nio.file.Path; ++import java.util.UUID; ++ ++import static org.junit.jupiter.api.Assertions.*; ++import static org.mockito.Mockito.*; ++ ++final class ControlledVehicleSpatialTest { ++ ++ private static final Position3f LIVE = new Position3f(100, 200, 300); ++ private static final Position3f RECEIVED = new Position3f(10.25F, 20.5F, 30.75F); ++ ++ @Test ++ void bothPacketFormsRetainButIgnoreUnforcedLocalVehicleTransforms() throws Exception { ++ try (var packets = mockStatic(PacketFactory.class)) { ++ for (boolean horse : new boolean[]{false, true}) { ++ for (boolean delta : new boolean[]{false, true}) { ++ for (int flags = 0; flags < 16; flags++) { ++ final var context = new Context(horse); ++ context.horseFlags(true, true); ++ context.vehicle.addPassenger(context.local.uniqueId()); ++ final boolean ignored = context.translate(delta, flags); ++ assertEquals((flags & 6) == 0, ignored); ++ assertEquals(RECEIVED, context.vehicle.lastReceivedPosition()); ++ assertEquals((short) (delta ? flags | 0xF0 : flags), context.vehicle.lastReceivedMovementFlags()); ++ assertEquals(delta ? Long.MIN_VALUE : 0, context.vehicle.lastReceivedInterpolationTicks()); ++ assertEquals(ignored ? LIVE : RECEIVED, context.vehicle.position()); ++ final var admitted = context.vehicle.nativeSpatialCommand(); ++ assertEquals(ignored ? LIVE : RECEIVED, admitted.position()); ++ assertEquals(ignored ? 0 : 1, admitted.revision()); ++ assertEquals(horse, admitted.clientPredicted()); ++ } ++ } ++ } ++ } ++ } ++ ++ @Test ++ void controlFollowsTheFirstPassengerAndRequiresBothNativeHorseFlags() { ++ for (boolean horse : new boolean[]{false, true}) { ++ for (boolean tamed : new boolean[]{false, true}) { ++ for (boolean saddled : new boolean[]{false, true}) { ++ final var context = new Context(horse); ++ context.horseFlags(tamed, saddled); ++ assertFalse(context.tracker.isLocallyControlledVehicle(context.vehicle)); ++ context.vehicle.addPassenger(context.remote.uniqueId()); ++ context.vehicle.addPassenger(context.local.uniqueId()); ++ assertFalse(context.tracker.isLocallyControlledVehicle(context.vehicle)); ++ context.vehicle.addPrimaryPassenger(context.local.uniqueId()); ++ assertEquals(!horse || tamed && saddled, context.tracker.isLocallyControlledVehicle(context.vehicle)); ++ context.vehicle.removePassenger(context.local.uniqueId()); ++ assertFalse(context.tracker.isLocallyControlledVehicle(context.vehicle)); ++ } ++ } ++ } ++ } ++ ++ @Test ++ void aStaleControllerRuntimeIdentityCannotSuppressAnIncomingTransform() throws Exception { ++ final var context = new Context(false); ++ context.vehicle.addPassenger(context.local.uniqueId()); ++ assertTrue(context.tracker.isLocallyControlledVehicle(context.vehicle)); ++ final long controllerRuntimeId = context.local.runtimeId(); ++ doReturn(null).when(context.tracker).getEntityByRid(controllerRuntimeId); ++ assertFalse(context.tracker.isLocallyControlledVehicle(context.vehicle)); ++ try (var packets = mockStatic(PacketFactory.class)) { ++ assertFalse(context.translate(true, 0)); ++ assertEquals(RECEIVED, context.vehicle.position()); ++ } ++ } ++ ++ @Test ++ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_VEHICLE_CONTROL", matches = ".+") ++ void matchesOriginalNativeVehicleControlForRepresentablePlayerMembership() throws Exception { ++ final var reference = JsonParser.parseString(Files.readString(Path.of(System.getenv("STACKANVIL_NATIVE_VEHICLE_CONTROL")))).getAsJsonObject(); ++ for (var value : reference.getAsJsonArray("rows")) { ++ final var row = value.getAsJsonObject(); ++ if (!row.get("player").getAsString().equals("present") || row.get("localPlayer").getAsString().equals("stale")) { ++ continue; ++ } ++ final var context = new Context(row.get("kind").getAsString().equals("horse")); ++ context.horseFlags(row.get("tamed").getAsBoolean(), row.get("saddled").getAsBoolean()); ++ final boolean localFirst = row.get("localPlayer").getAsString().equals("present") && row.get("localSeat").getAsInt() == 0; ++ if (row.get("controllerCount").getAsInt() > 0) { ++ context.vehicle.addPassenger(localFirst ? context.local.uniqueId() : context.remote.uniqueId()); ++ } ++ if (row.get("controllerCount").getAsInt() > 1) { ++ context.vehicle.addPassenger(localFirst ? context.remote.uniqueId() : context.local.uniqueId()); ++ } ++ assertEquals(row.get("controlled").getAsBoolean(), context.tracker.isLocallyControlledVehicle(context.vehicle)); ++ } ++ } ++ ++ @Test ++ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_SPATIAL_CONTROL", matches = ".+") ++ void registeredPacketsMatchTheOriginalNativeCacheAndControlAdmissionGate() throws Exception { ++ final var reference = JsonParser.parseString(Files.readString(Path.of(System.getenv("STACKANVIL_NATIVE_SPATIAL_CONTROL")))).getAsJsonObject(); ++ try (var packets = mockStatic(PacketFactory.class)) { ++ for (var value : reference.getAsJsonArray("rows")) { ++ final var row = value.getAsJsonObject(); ++ for (boolean delta : new boolean[]{false, true}) { ++ final var context = new Context(false, Long.parseUnsignedLong(row.get("runtimeId").getAsString())); ++ final String mode = row.get("controlMode").getAsString(); ++ if (!mode.equals("absent")) { ++ context.vehicle.addPassenger(context.local.uniqueId()); ++ } ++ if (mode.equals("stale")) { ++ final long runtimeId = context.local.runtimeId(); ++ doReturn(null).when(context.tracker).getEntityByRid(runtimeId); ++ } else if (mode.equals("missingPage") || mode.equals("emptyRange")) { ++ final long uniqueId = context.local.uniqueId(); ++ doReturn(null).when(context.tracker).getEntityByUid(uniqueId); ++ } ++ final boolean ignored = context.translate(delta, row.get("flags").getAsInt()); ++ assertEquals(!row.get("acceptedTransform").getAsBoolean(), ignored); ++ assertEquals(RECEIVED, context.vehicle.lastReceivedPosition()); ++ assertEquals(ignored ? LIVE : RECEIVED, context.vehicle.position()); ++ } ++ } ++ } ++ } ++ ++ private static final class Context { ++ private final UserConnection user = mock(UserConnection.class); ++ private final EntityTracker tracker = spy(new EntityTracker(this.user)); ++ private final ClientPlayerEntity local = mock(ClientPlayerEntity.class); ++ private final PlayerEntity remote = mock(PlayerEntity.class); ++ private final Entity vehicle; ++ private final TestProtocol protocol = new TestProtocol(); ++ ++ private Context(final boolean horse) { ++ this(horse, 0x100000002L); ++ } ++ ++ private Context(final boolean horse, final long runtimeId) { ++ when(this.local.uniqueId()).thenReturn(1L); ++ when(this.local.runtimeId()).thenReturn(101L); ++ when(this.local.javaId()).thenReturn(1); ++ when(this.remote.uniqueId()).thenReturn(2L); ++ when(this.remote.runtimeId()).thenReturn(102L); ++ when(this.remote.javaId()).thenReturn(2); ++ this.vehicle = horse ++ ? new AbstractHorseEntity(this.user, 21, runtimeId, "minecraft:horse", 301, UUID.randomUUID(), EntityTypes26_3.HORSE) ++ : new BoatEntity(this.user, 21, runtimeId, "minecraft:boat", 301, UUID.randomUUID(), EntityTypes26_3.OAK_BOAT); ++ this.tracker.addEntity(this.local, false); ++ this.tracker.addEntity(this.remote, false); ++ this.tracker.addEntity(this.vehicle, false); ++ when(this.user.get(EntityTracker.class)).thenReturn(this.tracker); ++ this.vehicle.setPosition(LIVE); ++ } ++ ++ private void horseFlags(final boolean tamed, final boolean saddled) { ++ this.vehicle.entityData().put(ActorDataIds.RESERVED_0, new EntityData(0, EntityDataTypesBedrock.LONG, ++ (tamed ? 1L << 28 : 0) | (saddled ? 1L << 8 : 0))); ++ } ++ ++ private boolean translate(final boolean delta, final int flags) throws Exception { ++ final var input = Unpooled.buffer(); ++ try { ++ BedrockTypes.UNSIGNED_VAR_LONG.write(input, this.vehicle.runtimeId()); ++ if (delta) { ++ for (float coordinate : new float[]{RECEIVED.x(), RECEIVED.y(), RECEIVED.z()}) { ++ input.writeBoolean(true); ++ BedrockTypes.FLOAT_LE.write(input, coordinate); ++ } ++ for (int angle : new int[]{12, 20, 30}) { ++ input.writeBoolean(true); ++ input.writeByte(angle); ++ } ++ for (int bit = 0; bit < 4; bit++) { ++ input.writeBoolean((flags & 1 << bit) != 0); ++ } ++ BedrockTypes.UNSIGNED_VAR_LONG.write(input, Long.MIN_VALUE); ++ } else { ++ input.writeByte(flags); ++ BedrockTypes.POSITION_3F.write(input, RECEIVED); ++ input.writeBytes(new byte[]{12, 20, 30}); ++ } ++ final var wrapper = new PacketWrapperImpl(delta ? ClientboundBedrockPackets.MOVE_ENTITY_DELTA ++ : ClientboundBedrockPackets.MOVE_ENTITY_ABSOLUTE, input, this.user); ++ this.protocol.translate(wrapper); ++ assertFalse(input.isReadable()); ++ return wrapper.isCancelled(); ++ } finally { ++ input.release(); ++ } ++ } ++ } ++ ++ private static final class TestProtocol extends BedrockProtocol { ++ private TestProtocol() { ++ EntityPackets.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); ++ } ++ } ++ ++} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/packet/MovementEffectPacketsTest.java b/src/test/java/net/raphimc/viabedrock/protocol/packet/MovementEffectPacketsTest.java new file mode 100644 -index 000000000..d672e393d +index 000000000..1f5c82798 --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/protocol/packet/MovementEffectPacketsTest.java @@ -0,0 +1,116 @@ @@ -6965,7 +10232,7 @@ index 000000000..d672e393d + BedrockTypes.UNSIGNED_VAR_LONG.writePrimitive(input, tick); + final PacketWrapper packet = new PacketWrapperImpl(ClientboundBedrockPackets.MOVEMENT_EFFECT, input, user); + new TestProtocol().translate(packet); -+ final MovementEffect effect = new MovementEffect(player.javaId(), type, -1, tick, tick == 91 ? 42 : -1, 91, 42); ++ final MovementEffect effect = new MovementEffect(player.javaId(), type, -1, tick, tick == 91 ? 42 : -1, 91, 42, predictions.oldestInputTick()); + assertEquals(effect, player.movementEffect(MovementEffectType.getByValue(type))); + assertEquals(!supported, packet.isCancelled()); + if (supported) { @@ -6999,10 +10266,10 @@ index 000000000..d672e393d +} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/packet/PlayerCorrectionPacketTest.java b/src/test/java/net/raphimc/viabedrock/protocol/packet/PlayerCorrectionPacketTest.java new file mode 100644 -index 000000000..79e1d1557 +index 000000000..7aea0fedc --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/protocol/packet/PlayerCorrectionPacketTest.java -@@ -0,0 +1,184 @@ +@@ -0,0 +1,254 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -7024,6 +10291,8 @@ index 000000000..79e1d1557 +import com.viaversion.viaversion.protocols.v26_2to26_3.packet.ClientboundPackets26_3; +import io.netty.buffer.Unpooled; +import net.raphimc.viabedrock.api.model.PlayerCorrection; ++import net.raphimc.viabedrock.api.model.PlayerPrediction; ++import net.raphimc.viabedrock.api.model.PlayerPosture; +import net.raphimc.viabedrock.api.model.entity.ClientPlayerEntity; +import net.raphimc.viabedrock.protocol.BedrockProtocol; +import net.raphimc.viabedrock.protocol.ClientboundBedrockPackets; @@ -7034,6 +10303,7 @@ index 000000000..79e1d1557 +import net.raphimc.viabedrock.protocol.model.Position3f; +import net.raphimc.viabedrock.protocol.storage.EntityTracker; +import net.raphimc.viabedrock.protocol.storage.ChannelStorage; ++import net.raphimc.viabedrock.protocol.storage.PlayerPredictionStorage; +import net.raphimc.viabedrock.protocol.types.BedrockTypes; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterEach; @@ -7086,7 +10356,46 @@ index 000000000..79e1d1557 + } + + @ParameterizedTest -+ @ValueSource(longs = {0, 1, 10, 11}) ++ @ValueSource(ints = {0, 1}) ++ void ignoresCorrectionsWithoutRetainedInputsBeforeReadingActorState(final int rewindType) throws Exception { ++ for (boolean reconfigured : new boolean[]{false, true}) { ++ final PlayerPredictionStorage predictions = new PlayerPredictionStorage(); ++ if (reconfigured) { ++ predictions.remember(1, null); ++ predictions.configureHistory(3); ++ } ++ final UserConnection user = (UserConnection) Proxy.newProxyInstance(UserConnection.class.getClassLoader(), ++ new Class[]{UserConnection.class}, (proxy, method, args) -> { ++ if (method.getName().equals("get") && args[0] == PlayerPredictionStorage.class) { ++ return predictions; ++ } ++ throw new AssertionError(method.getName()); ++ }); ++ for (long tick : new long[]{0, 1, -1}) { ++ final var input = Unpooled.buffer(); ++ try { ++ input.writeByte(rewindType); ++ BedrockTypes.POSITION_3F.write(input, new Position3f(1F, 70F, 2F)); ++ BedrockTypes.POSITION_3F.write(input, new Position3f(-0.25F, 0.5F, 1.25F)); ++ BedrockTypes.POSITION_2F.write(input, new Position2f(20F, 30F)); ++ input.writeBoolean(true); ++ BedrockTypes.FLOAT_LE.writePrimitive(input, 0.75F); ++ input.writeBoolean(true); ++ BedrockTypes.UNSIGNED_VAR_LONG.writePrimitive(input, tick); ++ final PacketWrapper packet = new PacketWrapperImpl(ClientboundBedrockPackets.CORRECT_PLAYER_MOVE_PREDICTION, input, user); ++ new TestProtocol().translate(packet); ++ assertTrue(packet.isCancelled()); ++ assertFalse(input.isReadable()); ++ assertTrue(this.sent.isEmpty()); ++ } finally { ++ input.release(); ++ } ++ } ++ } ++ } ++ ++ @ParameterizedTest ++ @ValueSource(longs = {0, 1, 7, 8, 9, 10, 11, Long.MAX_VALUE, Long.MIN_VALUE, -1}) + void appliesAuthoritativeVelocityForEveryPoseAndCorrectionAge(final long tick) throws Exception { + for (boolean negotiated : new boolean[]{false, true}) { + for (boolean onGround : new boolean[]{false, true}) { @@ -7098,6 +10407,12 @@ index 000000000..79e1d1557 + channels.addChannels(List.of(PlayerCorrection.CHANNEL)); + } + final EntityTracker[] trackers = new EntityTracker[1]; ++ final PlayerPredictionStorage predictions = new PlayerPredictionStorage(); ++ predictions.configureHistory(3); ++ for (long retainedTick = 8; retainedTick <= 10; retainedTick++) { ++ predictions.remember(retainedTick, negotiated ? new PlayerPrediction(retainedTick - 8, Position3f.ZERO, ++ Position3f.ZERO, PlayerPosture.STANDING, false, false, false, false, 1F) : null); ++ } + final ProtocolInfo info = (ProtocolInfo) Proxy.newProxyInstance(ProtocolInfo.class.getClassLoader(), + new Class[]{ProtocolInfo.class}, (proxy, method, args) -> method.getName().equals("getServerState") ? State.PLAY : null); + final UserConnection user = (UserConnection) Proxy.newProxyInstance(UserConnection.class.getClassLoader(), @@ -7110,6 +10425,8 @@ index 000000000..79e1d1557 + return trackers[0]; + } else if (args[0] == ChannelStorage.class) { + return channels; ++ } else if (args[0] == PlayerPredictionStorage.class) { ++ return predictions; + } + } + return null; @@ -7123,6 +10440,7 @@ index 000000000..79e1d1557 + player.tick(); + } + final Position3f position = new Position3f(2.5F, 65.62F, -3.5F); ++ final Position3f previousPosition = player.position(); + final var input = Unpooled.buffer(); + final var output = Unpooled.buffer(); + try { @@ -7136,6 +10454,14 @@ index 000000000..79e1d1557 + BedrockTypes.UNSIGNED_VAR_LONG.writePrimitive(input, tick); + final PacketWrapper packet = new PacketWrapperImpl(ClientboundBedrockPackets.CORRECT_PLAYER_MOVE_PREDICTION, input, user); + new TestProtocol().translate(packet); ++ if (Long.compareUnsigned(tick, 8) < 0) { ++ assertTrue(packet.isCancelled()); ++ assertEquals(previousPosition, player.position()); ++ assertEquals(!onGround, player.isOnGround()); ++ assertTrue(this.sent.isEmpty()); ++ assertFalse(input.isReadable()); ++ continue; ++ } + assertFalse(packet.isCancelled()); + packet.writeToBuffer(output); + assertEquals(ClientboundPackets26_3.PLAYER_POSITION.getId(), Types.VAR_INT.readPrimitive(output)); @@ -7146,7 +10472,18 @@ index 000000000..79e1d1557 + final PacketWrapper state = this.sent.getFirst(); + assertEquals(ClientboundPackets26_3.CUSTOM_PAYLOAD, state.getPacketType()); + assertEquals(PlayerCorrection.CHANNEL, state.get(Types.STRING, 0)); -+ assertEquals(new PlayerCorrection(player.javaId(), teleportId, tick, onGround), state.get(PlayerCorrection.TYPE, 0)); ++ final var correction = state.get(PlayerCorrection.TYPE, 0); ++ assertEquals(player.javaId(), correction.entityId()); ++ assertEquals(player.javaUuid(), correction.uuid()); ++ assertEquals(player.nativeActorSnapshot().lifetime(), correction.lifetime()); ++ assertEquals(teleportId, correction.teleportId()); ++ assertEquals(tick, correction.timing().serverTick()); ++ assertEquals(10, correction.timing().inputTick()); ++ assertEquals(2, correction.timing().inputFrame()); ++ assertEquals(8, correction.timing().historyStartTick()); ++ assertEquals(position, correction.position()); ++ assertEquals(velocity, correction.motion()); ++ assertEquals(onGround, correction.onGround()); + } + assertEquals(position.x(), output.readDouble()); + assertEquals(position.y() - player.eyeOffset(), output.readDouble()); @@ -7158,8 +10495,8 @@ index 000000000..79e1d1557 + output.readFloat(); + final int flags = output.readInt(); + assertEquals(0, flags & ((1 << Relative.DELTA_X.ordinal()) | (1 << Relative.DELTA_Y.ordinal()) | (1 << Relative.DELTA_Z.ordinal()))); -+ assertEquals(position, player.position()); -+ assertEquals(onGround, player.isOnGround()); ++ assertEquals(negotiated ? previousPosition : position, player.position()); ++ assertEquals(negotiated ? !onGround : onGround, player.isOnGround()); + assertFalse(input.isReadable()); + assertFalse(output.isReadable()); + } finally { @@ -7189,7 +10526,7 @@ index 000000000..79e1d1557 +} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/packet/PlayerPredictionPacketTest.java b/src/test/java/net/raphimc/viabedrock/protocol/packet/PlayerPredictionPacketTest.java new file mode 100644 -index 000000000..0c1d02d0b +index 000000000..2bc45bb05 --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/protocol/packet/PlayerPredictionPacketTest.java @@ -0,0 +1,87 @@ @@ -7240,13 +10577,13 @@ index 000000000..0c1d02d0b + final PlayerPrediction previous = new PlayerPrediction(42, Position3f.ZERO, new Position3f(0F, -0.022315647F, 0F), + new PlayerPosture(true, false, false), false, false, false, true, 1F); + accept(user, previous); -+ final PlayerPrediction consumed = predictions.take(Position3f.ZERO); ++ final PlayerPrediction consumed = predictions.take(); + assertEquals(previous, consumed); + predictions.remember(91, consumed); + assertEquals(42, predictions.clientFrame(91)); + + accept(user, previous); -+ final PlayerPrediction replay = predictions.take(Position3f.ZERO); ++ final PlayerPrediction replay = predictions.take(); + assertNull(replay); + predictions.remember(92, replay); + assertEquals(-1, predictions.clientFrame(92)); @@ -7258,7 +10595,7 @@ index 000000000..0c1d02d0b + PlayerPosture.STANDING, true, true, true, false, 1F); + accept(user, current); + accept(user, previous); -+ final PlayerPrediction next = predictions.take(Position3f.ZERO); ++ final PlayerPrediction next = predictions.take(); + assertEquals(current, next); + predictions.remember(93, next); + assertEquals(43, predictions.clientFrame(93)); @@ -7280,11 +10617,326 @@ index 000000000..0c1d02d0b + } + +} +diff --git a/src/test/java/net/raphimc/viabedrock/protocol/packet/PlayerSpatialResetTest.java b/src/test/java/net/raphimc/viabedrock/protocol/packet/PlayerSpatialResetTest.java +new file mode 100644 +index 000000000..2ef7f72b8 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/protocol/packet/PlayerSpatialResetTest.java +@@ -0,0 +1,147 @@ ++/* ++ * 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.viaversion.api.connection.ProtocolInfo; ++import com.viaversion.viaversion.api.connection.UserConnection; ++import com.viaversion.viaversion.api.minecraft.entities.EntityTypes26_3; ++import com.viaversion.viaversion.api.protocol.packet.PacketType; ++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.model.ActorSpatialCommand; ++import net.raphimc.viabedrock.api.model.NativeActorMessage; ++import net.raphimc.viabedrock.api.model.NativeActorStates; ++import net.raphimc.viabedrock.api.model.entity.Entity; ++import net.raphimc.viabedrock.api.model.entity.PlayerEntity; ++import net.raphimc.viabedrock.api.util.PacketFactory; ++import net.raphimc.viabedrock.protocol.BedrockProtocol; ++import net.raphimc.viabedrock.protocol.ClientboundBedrockPackets; ++import net.raphimc.viabedrock.protocol.model.PlayerAbilities; ++import net.raphimc.viabedrock.protocol.model.Position3f; ++import net.raphimc.viabedrock.protocol.storage.ChannelStorage; ++import net.raphimc.viabedrock.protocol.storage.EntityTracker; ++import net.raphimc.viabedrock.protocol.types.BedrockTypes; ++import org.junit.jupiter.api.Test; ++ ++import java.util.Set; ++import java.util.UUID; ++ ++import static org.junit.jupiter.api.Assertions.*; ++import static org.mockito.Mockito.*; ++ ++class PlayerSpatialResetTest { ++ ++ @Test ++ void remoteResetModesRetireOnlyTheirNativeBranchAndKeepHistoricalTargets() throws Exception { ++ for (int mode = 0; mode < 4; mode++) { ++ for (boolean riding : new boolean[]{false, true}) { ++ for (boolean vehiclePresent : new boolean[]{false, true}) { ++ final var user = mock(UserConnection.class); ++ final var info = mock(ProtocolInfo.class); ++ final var tracker = spy(new EntityTracker(user)); ++ final var channels = new ChannelStorage(); ++ when(user.get(any())).thenAnswer(call -> mock((Class) call.getArgument(0))); ++ when(user.getProtocolInfo()).thenReturn(info); ++ when(info.getServerState()).thenReturn(State.PLAY); ++ when(user.get(EntityTracker.class)).thenReturn(tracker); ++ when(user.get(ChannelStorage.class)).thenReturn(channels); ++ when(tracker.nativeActorsJoined()).thenReturn(false); ++ channels.addChannels(Set.of(NativeActorMessage.CURRENT_CHANNEL, ActorSpatialCommand.CHANNEL)); ++ final var player = new PlayerEntity(user, 101, 7, UUID.randomUUID(), new PlayerAbilities(21, (byte) 0, (byte) 0)); ++ tracker.addEntity(player, false); ++ when(tracker.getEntityByUid(player.uniqueId())).thenReturn(player); ++ final var vehicle = new Entity(user, 22, 102, "minecraft:boat", 8, UUID.randomUUID(), EntityTypes26_3.OAK_BOAT); ++ if (vehiclePresent) { ++ tracker.addEntity(vehicle, false); ++ } ++ final var first = new Position3f(10.25F, 20.5F, 30.75F); ++ final var next = new Position3f(-11.5F, 25F, 42F); ++ player.setPosition(first); ++ player.retainSpatialUpdate(first, Position3f.ZERO, (short) 8, 3); ++ final var registry = new NativeActorStates(); ++ try (var packets = mockStatic(PacketWrapper.class); ++ var helpers = mockStatic(PacketFactory.class)) { ++ packets.when(() -> PacketWrapper.create(any(PacketType.class), eq(user))).thenAnswer(call -> { ++ final var packet = spy(new PacketWrapperImpl(call.getArgument(0), null, user)); ++ doAnswer(_ -> { ++ final var bytes = Unpooled.buffer(); ++ try { ++ packet.writeToBuffer(bytes); ++ assertEquals(ClientboundPackets26_3.CUSTOM_PAYLOAD.getId(), Types.VAR_INT.readPrimitive(bytes)); ++ final String channel = Types.STRING.read(bytes); ++ if (channel.equals(ActorSpatialCommand.CHANNEL)) { ++ registry.apply(ActorSpatialCommand.TYPE.read(bytes), null); ++ } else { ++ registry.apply(NativeActorMessage.CURRENT_TYPE.read(bytes), null); ++ } ++ assertFalse(bytes.isReadable()); ++ } finally { ++ bytes.release(); ++ } ++ return null; ++ }).when(packet).send(eq(BedrockProtocol.class), eq(true)); ++ return packet; ++ }); ++ when(tracker.nativeActorsJoined()).thenReturn(true); ++ player.sendNativeActorSnapshot(); ++ player.admitSpatialUpdate(first, (short) 8, 3); ++ final var source = registry.movement(player.javaUuid()); ++ final var historical = source.interpolation(); ++ assertTrue(historical.active()); ++ final long revision = source.command().revision(); ++ final var input = Unpooled.buffer(); ++ try { ++ BedrockTypes.UNSIGNED_VAR_LONG.write(input, player.runtimeId()); ++ BedrockTypes.POSITION_3F.write(input, next); ++ BedrockTypes.POSITION_3F.write(input, new Position3f(12, 24, 48)); ++ input.writeByte(mode).writeBoolean(true); ++ BedrockTypes.UNSIGNED_VAR_LONG.write(input, riding ? vehicle.runtimeId() : 0L); ++ input.writeBoolean(false); ++ BedrockTypes.UNSIGNED_VAR_LONG.write(input, 200L); ++ final var packet = new PacketWrapperImpl(ClientboundBedrockPackets.MOVE_PLAYER, input, user); ++ new TestProtocol().translate(packet); ++ assertFalse(input.isReadable()); ++ } finally { ++ input.release(); ++ } ++ final boolean reset = (mode == 1 || mode == 2) && !(riding && vehiclePresent); ++ assertEquals(!reset, source.interpolation().active()); ++ assertEquals(revision + (reset ? 1 : 0), source.command().revision()); ++ assertEquals(reset ? ActorSpatialCommand.Action.CLEAR_INTERPOLATION : ActorSpatialCommand.Action.MOVE, source.command().action()); ++ assertEquals(first, source.position(historical)); ++ assertEquals(first, player.lastReceivedPosition()); ++ assertEquals((short) 8, player.lastReceivedMovementFlags()); ++ assertEquals(3, player.lastReceivedInterpolationTicks()); ++ if (reset) { ++ assertFalse(source.interpolation().mustComplete()); ++ assertNull(source.position(source.interpolation())); ++ player.admitSpatialUpdate(next, (short) 0, 3); ++ assertTrue(source.interpolation().active()); ++ assertEquals(next, source.position(source.interpolation())); ++ } ++ } ++ } ++ } ++ } ++ } ++ ++ private static final class TestProtocol extends BedrockProtocol { ++ private TestProtocol() { ++ OtherPlayerPackets.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); ++ } ++ } ++ ++} +diff --git a/src/test/java/net/raphimc/viabedrock/protocol/packet/TickedMotionPacketTest.java b/src/test/java/net/raphimc/viabedrock/protocol/packet/TickedMotionPacketTest.java +new file mode 100644 +index 000000000..caaa63066 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/protocol/packet/TickedMotionPacketTest.java +@@ -0,0 +1,156 @@ ++/* ++ * 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.connection.ProtocolInfo; ++import com.viaversion.viaversion.api.minecraft.entities.EntityTypes26_3; ++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 java.lang.reflect.Proxy; ++import java.util.List; ++import java.util.UUID; ++import net.raphimc.viabedrock.api.model.ActorMotionCommand; ++import net.raphimc.viabedrock.api.model.VehiclePredictionContext; ++import net.raphimc.viabedrock.api.model.entity.BoatEntity; ++import net.raphimc.viabedrock.protocol.storage.VehiclePredictionStorage; ++import net.raphimc.viabedrock.api.model.PlayerPrediction; ++import net.raphimc.viabedrock.api.model.PlayerPosture; ++import net.raphimc.viabedrock.api.model.entity.ClientPlayerEntity; ++import net.raphimc.viabedrock.api.model.entity.Entity; ++import net.raphimc.viabedrock.protocol.BedrockProtocol; ++import net.raphimc.viabedrock.protocol.ClientboundBedrockPackets; ++import net.raphimc.viabedrock.protocol.model.PlayerAbilities; ++import net.raphimc.viabedrock.protocol.model.Position3f; ++import net.raphimc.viabedrock.protocol.storage.ChannelStorage; ++import net.raphimc.viabedrock.protocol.storage.EntityTracker; ++import net.raphimc.viabedrock.protocol.storage.PlayerPredictionStorage; ++import net.raphimc.viabedrock.protocol.types.BedrockTypes; ++import org.junit.jupiter.api.Test; ++import static org.junit.jupiter.api.Assertions.*; ++import static org.mockito.Mockito.*; ++ ++class TickedMotionPacketTest { ++ ++ @Test ++ void transportsTickedLocalMotionOnlyWithNegotiatedInputHistory() throws Exception { ++ final var predictions = new PlayerPredictionStorage(); ++ for (int frame = 20; frame <= 25; frame++) { ++ predictions.remember(frame + 80, new PlayerPrediction(frame, Position3f.ZERO, Position3f.ZERO, ++ PlayerPosture.STANDING, false, false, false, false, 1F)); ++ } ++ final var channels = new ChannelStorage(); ++ final ProtocolInfo info = (ProtocolInfo) Proxy.newProxyInstance( ++ UserConnection.class.getClassLoader(), new Class[]{ProtocolInfo.class}, ++ (proxy, method, args) -> method.getName().equals("getServerState") ? State.CONFIGURATION : null); ++ final EntityTracker[] holder = new EntityTracker[1]; ++ final UserConnection user = (UserConnection) Proxy.newProxyInstance(UserConnection.class.getClassLoader(), new Class[]{UserConnection.class}, ++ (proxy, method, args) -> method.getName().equals("getProtocolInfo") ? info : method.getName().equals("get") ? args[0] == EntityTracker.class ? holder[0] ++ : args[0] == PlayerPredictionStorage.class ? predictions : args[0] == ChannelStorage.class ? channels : null : null); ++ final var tracker = holder[0] = new EntityTracker(user); ++ final var player = new ClientPlayerEntity(user, 2, UUID.randomUUID(), new PlayerAbilities(1, (byte) 0, (byte) 0)); ++ player.setPosition(Position3f.ZERO); ++ tracker.addEntity(player, false); ++ final var remote = tracker.addEntity(new Entity(user, 3, 4, "minecraft:pig", 32, UUID.randomUUID(), EntityTypes26_3.PIG), false); ++ final var motion = new Position3f(1.2345679F, -.12345679F, Float.MIN_VALUE); ++ for (boolean negotiated : new boolean[]{false, true}) { ++ if (negotiated) { ++ channels.addChannels(List.of(ActorMotionCommand.CHANNEL)); ++ } ++ for (Entity actor : List.of(player, remote)) { ++ for (long tick : new long[]{0, 99, 102, 105, -1}) { ++ final var input = Unpooled.buffer(); ++ try { ++ BedrockTypes.UNSIGNED_VAR_LONG.writePrimitive(input, actor.runtimeId()); ++ BedrockTypes.POSITION_3F.write(input, motion); ++ BedrockTypes.UNSIGNED_VAR_LONG.writePrimitive(input, tick); ++ final PacketWrapper packet = new PacketWrapperImpl(ClientboundBedrockPackets.SET_ENTITY_MOTION, input, user); ++ new TestProtocol().translate(packet); ++ if (negotiated && actor == player && tick != 0) { ++ assertEquals(ClientboundPackets26_3.CUSTOM_PAYLOAD, packet.getPacketType()); ++ assertEquals(ActorMotionCommand.CHANNEL, packet.get(Types.STRING, 0)); ++ final var command = packet.get(ActorMotionCommand.TYPE, 0); ++ assertEquals(tick, command.timing().serverTick()); ++ assertEquals(25, command.timing().inputFrame()); ++ assertEquals(tick == 99 ? 21 : tick == 102 ? 23 : -1, command.timing().followingFrame()); ++ assertEquals(motion, command.motion()); ++ assertEquals(player.nativeActorSnapshot().lifetime(), command.lifetime()); ++ assertFalse(command.vehicle()); ++ } else { ++ assertEquals(ClientboundPackets26_3.SET_ENTITY_MOTION, packet.getPacketType()); ++ assertEquals(actor.javaId(), packet.get(Types.VAR_INT, 0)); ++ } ++ assertFalse(input.isReadable()); ++ } finally { ++ input.release(); ++ } ++ } ++ } ++ } ++ } ++ ++ @Test ++ void bindsControlledVehicleMotionToEveryNativeIdentityField() throws Exception { ++ final UserConnection user = mock(UserConnection.class); ++ final EntityTracker tracker = mock(EntityTracker.class); ++ final VehiclePredictionStorage vehicles = mock(VehiclePredictionStorage.class); ++ final ClientPlayerEntity player = mock(ClientPlayerEntity.class); ++ final var channels = new ChannelStorage(); ++ channels.addChannels(List.of(ActorMotionCommand.CHANNEL)); ++ final var predictions = new PlayerPredictionStorage(); ++ predictions.remember(105, new PlayerPrediction(25, Position3f.ZERO, Position3f.ZERO, PlayerPosture.STANDING, ++ false, false, false, false, 1F)); ++ when(user.get(EntityTracker.class)).thenReturn(tracker); ++ when(user.get(PlayerPredictionStorage.class)).thenReturn(predictions); ++ when(user.get(VehiclePredictionStorage.class)).thenReturn(vehicles); ++ when(user.get(ChannelStorage.class)).thenReturn(channels); ++ when(tracker.getClientPlayer()).thenReturn(player); ++ final var boat = new BoatEntity(user, 3, 4, "minecraft:boat", 32, UUID.randomUUID(), EntityTypes26_3.OAK_BOAT); ++ when(tracker.getEntityByRid(boat.runtimeId())).thenReturn(boat); ++ when(tracker.isLocallyControlledVehicle(boat)).thenReturn(true); ++ final UUID lifetime = boat.nativeActorSnapshot().lifetime(); ++ for (int changed = 0; changed < 6; changed++) { ++ final var identity = new VehiclePredictionContext.Identity(boat.javaId() + (changed == 1 ? 1 : 0), ++ changed == 2 ? UUID.randomUUID() : boat.javaUuid(), changed == 3 ? UUID.randomUUID() : lifetime, ++ boat.runtimeId() + (changed == 4 ? 1 : 0), boat.uniqueId() + (changed == 5 ? 1 : 0)); ++ when(vehicles.context()).thenReturn(new VehiclePredictionContext(7, identity)); ++ final var input = Unpooled.buffer(); ++ try { ++ BedrockTypes.UNSIGNED_VAR_LONG.writePrimitive(input, boat.runtimeId()); ++ BedrockTypes.POSITION_3F.write(input, new Position3f(.5F, 1, -.5F)); ++ BedrockTypes.UNSIGNED_VAR_LONG.writePrimitive(input, 105); ++ final PacketWrapper packet = new PacketWrapperImpl(ClientboundBedrockPackets.SET_ENTITY_MOTION, input, user); ++ new TestProtocol().translate(packet); ++ if (changed == 0) { ++ assertEquals(ClientboundPackets26_3.CUSTOM_PAYLOAD, packet.getPacketType()); ++ assertEquals(7, packet.get(ActorMotionCommand.TYPE, 0).vehicleEpoch()); ++ } else { ++ assertEquals(ClientboundPackets26_3.SET_ENTITY_MOTION, packet.getPacketType()); ++ } ++ assertFalse(input.isReadable()); ++ } finally { ++ input.release(); ++ } ++ } ++ } ++ ++ private static final class TestProtocol extends BedrockProtocol { ++ TestProtocol() { ++ EntityPackets.register(this); ++ } ++ ++ void translate(final PacketWrapper packet) throws Exception { ++ final var mapping = this.clientboundMappings.mappedPacket(State.PLAY, packet.getId()); ++ mapping.applyType(packet); ++ mapping.handler().handle(packet); ++ } ++ } ++ ++} 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 004043a37..907e52d82 100644 +index f618cb94a..2b4db6015 100644 --- a/src/test/java/net/raphimc/viabedrock/protocol/storage/EntityTrackerTest.java +++ b/src/test/java/net/raphimc/viabedrock/protocol/storage/EntityTrackerTest.java -@@ -64,14 +64,14 @@ class EntityTrackerTest { +@@ -70,14 +70,14 @@ class EntityTrackerTest { tracker.addEntity(player, false); final Entity vehicle = tracker.addEntity(10, 100, EntityTypes26_3.PIG.identifier(), EntityTypes26_3.PIG); vehicle.setPosition(new Position3f(12F, 64F, 8F)); 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 d1b65ded..1eb53c86 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 @@ -1,5 +1,216 @@ # Completed player prediction frames +## Ticked velocity commands, October 23, 2026 + +`SET_ENTITY_MOTION` now preserves its server tick for clients with the negotiated physical replay channel. +The previous handler discarded that tick and translated every velocity into an immediate Java packet. +The new payload carries full float motion, the input clock binding, and the actor identity. +Tick zero and clients without this channel retain the existing standard packet path. + +Native constructor `144359a80`, receiver `142890810`, and application `144376520` establish two effects. +The receiver applies motion to the live actor immediately and retains the same command for historical input. +Old motion ticks clamp to the oldest retained input. +A past tick queues the command before the following input and can mark that input dirty. +The current tick enters the next input. +Future ticks retain the command without forcing replay. +Position corrections keep their separate expired-tick rejection rule. + +The client applies live motion without also recording an untimed external impulse. +It retains motion and position commands in packet receipt order. +Replay reapplies each motion command at its historical input before physical execution. +Actor UUID, native lifetime, world, connection, and vehicle epoch and control lease constrain admission. +Mounted player motion remains eligible. + +Fresh controls execute forty-eight original command constructions, receiver calls, applications, and replay decisions. +They cover old, current, future, and unsigned maximum ticks, three velocities, and both supplied controller outcomes. +Registered Java packet tests cover negotiation, full float transport, tick zero, remote actors, and stale vehicle identity fields. +Optional Java timing comparisons cover all forty-eight cases. +Optional client journal comparisons cover the twenty-four accepted-controller cases and compare final position and velocity. +Additional journal tests retain the impulse through a later correction and reject historical application after authority changes. + +The native fixture supplies component stores, allocation interfaces, snapshots, and simple physical inputs. +It does not execute the complete network handler, actor factory, or native world physics. +Live delayed knockback and mounted trajectories still require verification. +Remote history sampling, exact collider source selection, and all five goal areas remain unfinished. + +All four project builds pass. +The final suites pass 2,656 tests, with 163 optional skips and no failures or errors. +The complete retained reference set remains enabled, including the new motion controls. +All 1,421 core classes match the standalone, add-on, and proxy packages. +The compiled core also matches its packaged build-constant replacements. +All 1,143 production client classes match the add-on. +The source trees and package hashes remain unchanged after the reference-enabled test runs. +Offline validation passes 271 tooling tests, static checks, and patch metadata checks. + +## Locally controlled vehicle spatial updates, October 23, 2026 + +Ordinary absolute and sparse movement packets now retain their source data without replacing a locally controlled vehicle pose. +Teleport and forced-local bits still admit the transform. +Force completion alone does not admit it. +The existing received cache keeps every packet field before this check. +The [spatial-update owner](0018-preserve-sparse-actor-spatial-updates.pr.md) supplies that cache and its packet API prerequisite. + +Native boat wrapper `148b5eeb0`, horse wrapper `148b5ef20`, and client vehicle setter `148b5ebb0` pass 1,152 controls. +The first passenger controls a boat only with valid player and local-player membership. +Horses additionally require both saddle and tame flags, at native bit positions eight and twenty-eight. +A local player in the second passenger position does not control the vehicle. +The fixture supplies ordered vehicle membership and registry stores. +Original passenger selection, flag checks, sparse membership, and marker decisions execute without substitution. +Registry discovery, generic insertion, and virtual erasure remain explicit boundaries. + +Another 640 controls execute received-cache publication and the complete native local-control gate in `14103f580`. +They cover all four movement flag bits, unsigned runtime IDs, entity generations, stale tags, missing pages, and empty ranges. +Accepted transforms stop immediately after this gate at an observation boundary. +Ignored transforms return through the original function epilogue. +Later prediction routing and interpolation remain outside this fixture. + +The production tracker resolves the first passenger through its live unique and runtime identities. +It uses the existing boat and horse entity classes and their current native flags. +Prediction eligibility has separate gameplay and capability checks, so it does not supply this ownership predicate. +All five focused tests run without skips. +They compare 256 representable native ownership cases and both registered packet handlers against all 640 admission cases. +Mandatory checks also cover controller changes, saddle and tame changes, unsigned duration retention, forced packets, and stale controller identity. +Removing the vehicle gate or accepting either horse flag causes the corresponding regressions to fail. +The restored implementation passes the focused suite and test Checkstyle. + +Full native control-system installation, custom actor classification, historical ownership transitions, retained interpolation, and complete vehicle trajectories remain unfinished. +These results do not establish full movement parity or resolve either documented collider-refresh mismatch. + +## Native world collision traversal, October 23, 2026 + +The physical adapter now visits loaded blocks in native X/Z/Y order. +The visitor uses float-rounded padding and clamps the vertical interval to the world height. +It scans unavailable chunks in X-first order with eight blocks of horizontal padding. +Those chunks supply solid barriers from Y -100000 to Y 100000. +A separate native floor sits forty blocks below the minimum world height. + +Each frame retains this geometry with its existing collision inputs. +A chunk arrival does not replace a retained barrier during a contained replay query. +At the strict reuse boundary, replay queries current chunk availability. +The adapter never creates chunks during collision queries. + +The shared production code matches 64 fresh executions of native visitor `142d883c0` from Bedrock 1.26.51.1, protocol 2193. +The fixture supplies chunk states, loaded columns, and block shapes. +The comparison covers traversal, chunk scans, and null-source barrier geometry. +Native border callbacks execute in the fixture but remain outside the Java comparison. + +Client tests use voxel collision clipping to verify unavailable boundaries, later chunk arrival, fresh queries, and the below-world floor. +Removing only unavailable-chunk barrier emission changes the expected clipped displacement from 0.4 to 1.0 and fails the regression. +The restored implementation passes the focused suite. + +The combined build passes 2,511 tests, with 215 optional skips and no failures or errors. +All 1,410 core classes match the add-on and proxy distributions byte for byte. +Offline tooling, static, and patch metadata checks pass. + +The installed world-traversal candidate completes a direct native BDS run with a private 300 ms inbound delay. +Sixteen correction replays execute 171 historical physical steps. +Observers record 87 historical collision caches, including 34 with retained nonempty geometry. +Eighteen ordinary collision gathers contain the native unavailable-chunk barrier geometry during loading. +Every replay boundary preserves tick count, food level, and held-item count. +The live chunk-arrival clipping case remains unverified; its targeted unit control passes. +Eight inbound events remain delayed at normal shutdown, and the final command awaits the next capture. + +The input hold finishes, but these observers record no active item use at replay boundaries. +This run does not establish correct feeding or delayed replay during active food use. +The client and server exit normally, and the original private fixtures return to their prior state. + +The live run precedes the preserved attachable-slot update. +All 126 movement/input classes and the complete mixin configuration match the final combined build byte for byte. +The core source tree also remains unchanged. +This establishes the tested movement code in the combined package, not a fresh runtime check of the whole package. + +Native block-shape generation and source references, border-block permissions, all chunk readiness stages, and near-unloaded flag consumers remain unfinished. +Exact actor refresh, native input phases, both connection routes, and complete trajectory parity also remain open. + +## Retained collision queries, October 23, 2026 + +The shared query code now reproduces native collision-cache admission and query extension. +The native build is Bedrock 1.26.51.1, protocol 2193. +Its executable SHA-256 is `537c0aee2e79afbdc94b44b28e00f466ae62bc50e2733d953b430db9dbaa9ee7`. + +The copy callback at `148b73aa0` requires squared displacement below four. +The gather kernel at `1431c2f50` caps motion at sixteen blocks and queries uncovered regions. +It appends new geometry and replaces the query bounds with the current request bounds. +The shared code preserves native float operations, plane order, small advances, and volume-based merging. + +Fresh controls compare the production Java code with 192 original native instruction results. +They cover 128 gather cases and 64 reuse cases, including nonempty retained geometry and strict boundary rejection. +The fixture supplies world queries, allocation, and typed stores. +These controls establish cache arithmetic, not native world traversal or complete movement parity. + +The add-on retains immutable collision inputs in each physical frame. +Managed interpolation, unloaded-region state, native block traversal, and complete input phases remain unfinished. +The combined four-project build passes 2,505 tests, with 214 optional skips and no failures or errors. +All 1,407 core classes match the add-on and proxy artifacts byte for byte. +Offline validation passes the tooling, static, and patch metadata checks. + +The exact installed artifact also passes a direct BDS 1.26.51.1 run with a private 300 ms inbound delay. +The delay applies only to the owned client channel and preserves packet contents and event order. +Fifteen correction replays execute 180 retained physical steps. +Read-only observers record 90 historical collision caches, including 12 with retained nonempty geometry. +They record 180 historical collision gathers and 95 additional region fetches. +Every replay boundary preserves the live tick count, food level, and held-item count. +Both clients and servers exit normally, and the private fixtures return to their original state. + +The input driver completes both requested holds, but these runs record no active item use at replay boundaries. +Delayed replay during active food use remains unverified. +The changed-world geometry case remains a unit control; no live wall-removal result is claimed. + +The earlier sections describe their respective artifacts and verification limits. + +## Queued positional corrections, October 23, 2026 + +Revision two carries exact position, velocity, optional boat angular velocity, native lifetime, coordinate offsets, and retained input clocks. +Core pairs each command with a standard Java position packet and preserves its normal acknowledgment. +The negotiated path leaves current translated position and mount control unchanged until the client reports its final coordinates. +Connections without a completed frame binding retain the direct correction path. + +The client projects the native clock at receipt and intersects both retained history windows. +The original native receiver supplies 128 passing clock controls for player and vehicle targets. +These cover unsigned wrap, expired inputs, pending commands, future commands, and capped following-snapshot placement. +The native comparator uses separate float operations and strict squared-vector tolerance `0.00001F`. +Optional boat angular state uses absolute tolerance `0.0001F`. +The original command controls establish current-state replacement and independent float rounding of bounding-box faces. +Typed stores, actor lookup, captured input methods, and arithmetic fixture physics remain evidence boundaries. + +Full core and add-on builds pass after both stacks replay. +The combined four-project build passes 2,497 tests, with 213 optional skips and no failures or errors. +All 1,406 core classes match both the add-on and proxy artifacts byte for byte. +Offline validation passes 271 tooling tests, static checks, and patch metadata checks. + +Packet tests cover queued player, boat, and horse corrections without immediate state mutation or lossy velocity packets. +Additional tests cover delayed receipt, unsigned clocks, native comparison tolerances, and optional angular state. + +A private native BDS geyser run reaches 1,233 completed frames and receives 23 correction commands. +The client applies 22 commands. The last current-clock command awaits a capture after recorder shutdown. +Three rewinds run four historical physics steps without changing live tick count, food level, or held-item count. +Four command applications preserve active item use. Eleven apples finish during ordinary live input. +These controls do not establish delayed historical replay during active consumption or live vehicle correction parity. +Historical collision inputs, managed interpolation, complete native input phases, and native trajectory parity remain unfinished. + +## Owned state for physical replay + +The movement timeline exposes independent effect values for the client adapter's physical snapshots. +Historical effect queries and local admissions use the replayed frame's clock. +Queued commands run before the recorded physical input. +Fluid-current and sneaking state expose owned values for restoration. +A replacement play listener clears actor history while preserving its connection's completed frame watermark. + +Vehicle prediction requires initialized positions for both the player and mount. +Pending passenger links can resolve before the spawn handler sets the vehicle position. +Prediction remains unavailable during that interval. The normal metadata update then publishes the initialized mount context. +A regression reproduces the earlier null-position disconnect and verifies subsequent context publication. + +Current builds include the incoming cooking controls and pass 2,497 tests, with 213 optional skips and no errors. +All 104 core patches and 38 add-on patches replay. +The add-on contains all 1,406 rebuilt core classes unchanged. +Offline validation passes 271 tooling tests, static checks, and patch metadata checks. + +The add-on now executes historical physics for movement-effect commands. +Player and vehicle position corrections still use their existing snap path. +Their command transport, exact native phase alignment, and full native trajectory parity remain incomplete. +Earlier sections retain the evidence and limits of their respective artifacts. + ## Advancing frame identities Completed client frame IDs increase throughout each connection, including stationary and mounted samples. @@ -87,20 +298,208 @@ The rest of the movement matrix remains unverified by these cases. `CORRECT_PLAYER_MOVE_PREDICTION` carries position, motion, grounded state, and a simulation tick. The [nearby packet reference](https://mojang.github.io/bedrock-protocol-docs/1.26.50-preview.26/packets/correct-player-move-prediction-packet/) describes this layout for protocol 2192. Native protocol 2193 captures confirm the layout and zero-velocity corrections around fixture teleports. -The movement guide requires immediate application when the correction tick lies outside available history. +The movement guide describes immediate application outside available history. +The pinned correction receiver has the more specific admission gates below. -Core previously discarded motion outside gliding and canceled corrections outside its tick window. -It now applies absolute motion for every player pose, including zero-velocity resets. +Core previously discarded motion outside gliding and used an age-based tick window. +It now applies absolute motion for accepted corrections in every player pose, including zero-velocity resets. Unknown rewind types cancel the Java output before logging the unsupported value. The gliding-only implementation moves out of the deferred rocket patch into this general correction handler. -Four parameterized test cases cover zero, past, current, and future ticks. +Ten parameterized cases cover zero, expired, retained, current, future, and unsigned boundary ticks. Each case exercises both gliding states and zero and nonzero motion through the actual packet mapping. They verify position, motion, relative flags, grounded state, and complete packet consumption. +Tick zero consumes the packet without a position update or negotiated correction payload. + +The queued correction section records the current replay path. +The direct path remains available when a connection lacks the negotiated frame binding. +Native trajectory parity and complete historical world inputs remain unverified. + +## Native correction admission, October 22, 2026 + +The pinned 1.26.51.1 receiver rejects tick zero before command allocation. +An 864-case instruction probe verifies the original correction entry, target selector, replay lookup, and command payload copy. +The cases cover player and vehicle targets, unknown rewind types, grounded state, unsigned tick boundaries, and retained history bounds. + +Packet getter `143901ab0` returns ID 161 for vtable `14e897660`. +Dispatcher `1417342f0` selects recipient slot `0x230`, which calls `14131ab90` and then `144359100`. +Selector `144358dd0` requires the matching player or vehicle replay component. +Lookup `144358cd0` rejects zero and compares the correction tick with the oldest retained clock as unsigned values. + +Core now consumes tick-zero packets without moving the player or emitting a correction payload. +The shared correction payload also preserves the upper half of the unsigned tick range. +Negative Java longs retain those wire bits instead of failing validation. +Packet regressions failed before both changes. + +The probe supplies ECS storage, relation resolution, allocation, and the command dispatch boundary. +It does not execute command application, retained snapshot restoration, input replay, or world physics. +The original generic replay probes remain useful evidence for those separate paths. +Current core implements the clock admission rule described below. +Historical component restoration and physics resimulation remain incomplete. +The component journal described below resolves the 28 recorded effect activity mismatches. +Position and velocity resimulation remain incomplete. + +## Retained correction clocks, October 22, 2026 + +Core now configures clock retention from the `START_GAME` history size, bounded to 1 through 1,000 entries. +Every emitted input retains its clock, including inputs without a completed prediction payload. +Such inputs evict old bindings without assigning an unrelated client frame. +Clock gaps, duplicates, and backward changes start a new history. +Contiguous unsigned wrap preserves the history. +The connection frame watermark survives history changes. + +The correction handler requires a retained input. +It rejects zero and unsigned clocks older than the oldest retained input before any player or vehicle mutation. +Rejected packets emit neither motion nor a negotiated correction payload. +Future clocks remain eligible because the native receiver has no corresponding upper-bound rejection. +These clocks describe emitted inputs. They do not provide historical component snapshots or replay physics. + +Twelve controls execute the original native installer block and history constructor. +They verify the capacity bound across the native ushort range. +They do not establish conversion from malformed signed wire sizes into that native field. +The earlier 344 capture controls establish clock continuity, null-input capture, eviction, and unsigned wrap. + +A new 512-case probe joins the original receiver, target selector, unsigned lookup, queue insertion, comparator, client/server policy, and replay loop. +Historical position commands compare against the next retained snapshot and enter its command queue when a difference requires replay. +The original client policy marks that frame dirty, then replay applies the correction before its captured input. +The probe completes 84 historical replays and observes no immediate position-command application at receipt. +Current-clock commands enter the pending queue. Future commands enter the current frame queue without an immediate dirty mark in these controls. + +The probe supplies ECS stores, snapshot storage, captured input methods, allocation, metrics, and grounded-tag insertion. +Its physics step uses explicit float32 position addition as an ordering witness. +It does not execute native world physics or prove client trajectories. +The cached snapshot-blob descriptor path also remains outside this probe. + +A separate 16-control probe continues current-tick corrections through the original pending-command transfer block. +Fourteen changed controls transfer their commands, set the movement mask, and mark the next captured frame dirty. +Replay then applies each correction before that frame's input. Two unchanged controls queue no command. +Frame construction, snapshot storage, input methods, and arithmetic physics remain explicit fixture boundaries. +This probe does not execute the complete capture function or native world physics. + +Regression tests fail against the previous missing-frame retention and stale correction handling. +The new tests cover server capacity, clock discontinuities, unsigned boundaries, missing frames, and stale player, boat, and horse corrections. +Production clock admission also matches 1,500 checks derived from 300 native capture and wrap controls. +After integration with the newer cooking controls, the four-project build passes 2,382 tests, with 212 optional skips and no failures or errors. +All 104 core patches replay, and all 1,386 embedded core classes match the rebuilt core. +Offline validation passes. +Full historical replay, component reconciliation, and the reported pearl/wind-charge rollback remain unfinished. + +A fresh 30-second BDS recording reaches gameplay and produces a structurally complete journal. +It records 90 player corrections and two vehicle corrections, so movement mismatches remain visible. +Each correction matches the last emitted input tick or trails it by one tick, which makes current-tick command transfer a required next step. +The client does not stop after the recorder sends SIGTERM. The recorder correctly refuses to release its runtime dependencies. +After private thread diagnostics, only the birth-verified recording JVM requires forced termination. +The remaining owned launcher, server, and display stop, then the runtime copies release. +This capture remains incomplete because normal teardown fails. It does not establish movement parity or resolve the shutdown cause. + +## Captured movement-effect inputs, October 22, 2026 + +The effect timeline now retains ordered local admissions and server commands in a bounded frame journal. +The client opens each record before simulation, reconciles effects before local player physics, and completes the existing frame clock after movement. +Server commands precede captured inputs. Completed replay frames then run the effect countdown. +The current open frame consumes its duration during physics before its normal countdown. + +Native effect snapshots copy timestamps, durations, and kinds into an owned vector. +The journal restores the first dirty frame before commands, admissions, and countdowns. +It refreshes later snapshots before their commands so repeated corrections retain the corrected history. +This prevents unrelated effects from consuming their past countdowns twice. +Captured geyser admissions can restore a 100-tick effect after a server removal. +Other effect kinds also receive each replayed countdown. +Commands with zero movement masks remain queued until a later command requests replay. +Zero-duration marker presence remains separate from active duration for command masks. + +Movement-effect payload revision 3 carries the exact oldest retained unsigned input clock. +Core derives this clock from emitted inputs, including inputs without completed predictions. +The client clamps command dispatch against this clock and discards expired input records. +A fixed window cannot represent short histories or clock discontinuities. +Revision 2 does not provide this context and rejects without a compatibility alias. +The add-on patch records its shared-core prerequisite in the series metadata. + +The original receiver, complete capture caller, full capture function, cached-input fill, and replay now execute together in 128 native controls. +The original capture function allocates and inserts the actual new frame. +Its ECS snapshot store, allocator, clock, input methods, and arithmetic physics remain explicit fixture boundaries. +Four additional controls verify that clock discontinuities remove old frames but preserve pending commands and movement masks. +These controls do not execute native world physics or prove client trajectories. + +The generated-input controls match all 336 immediate cases and all 84 first replay cases under their supplied snapshot boundary. +Those controls establish input and command order, not packed snapshot semantics. +The targeted regressions also cover pending transfer, ordinary countdown, clamped clocks, zero-mask command retention, actor removal, and strict context transport. +The shared input fixture uses `STACKANVIL_MOVEMENT_INPUT_REPLAY_REFERENCE` and remains optional when the private evidence is absent. + +The earlier presence-only snapshot probe used cached table slot 76, whose component hash is `fa93038f`. +MovementEffectsComponent `5c92321d` uses slot 72, snapshot bit 70, and vector offset `0x5e0`. +The earlier claim about effect snapshot semantics was incorrect. +Eighteen fresh native controls capture and restore complete timestamps, durations, and kinds into independent owned vectors. +The source vector changes after capture, and the destination differs before restore. +Original packed capture `143276a40` and restore `14327b6f0`/`14327b7c0` recover the captured values. +Typed-store discovery, allocation, generic CRT memory copy, and the extra restore-copy list remain fixture boundaries. + +Two regressions fail with the previous journal and pass with independent snapshots. +They cover an unrelated effect countdown and a later correction after an earlier rewind. + +Sixty-four additional controls execute original packed history comparison, frame capture, packed capture, snapshot restore, packed restore, and replay together. +Twenty-four cases replay, with no mismatches against the explicit arithmetic witness. +All six initial history blobs also use original packed capture. +The copied components include StateVector (36 bytes), AABBShape (32), actor rotation (16), angular state (20), and Offsets (68). +Typed-store and cache construction, the extra restore-copy list, presence transitions, input methods, and world physics remain boundaries. +These controls keep grounded tags absent. + +Ten native VehicleComponent controls preserve independent membership vectors after the live vector changes. +They cover empty, single, multiple, and aligned large vectors, with empty and allocated destinations. +Captured and restored vectors preserve member order and do not alias the live vector. +Actor lifetime, mount graph changes, seat ownership, and client vehicle physics remain unverified. + +**Incomplete:** This replays effect components without restoring historical player or vehicle positions and velocities. +Full physics steps, mutable attributes, posture and collision state, zero-duration impulses, and complete trajectory comparisons still need integration. +A later 30-second BDS probe reaches gameplay and completes normal cleanup. +Read-only hooks observe all 16 local admissions in open records and retain each admission through completion. +The probe also observes 18 effect confirmations and one replay entry with a pending movement mask. +This establishes the producer and frame lifecycle, not complete native physics or trajectories. +The cause of the earlier shutdown failure remains unproven. + + +The integrated four-project build preserves the incoming cooking fixes and passes 2,402 tests, with 212 optional skips and no failures. +All 104 core patches and 38 add-on patches replay without conflicts. +All 1,393 embedded core classes match the rebuilt core. +Checkstyle and offline patch validation pass. +The separate cooking regression suite also passes all 13 cases with the private native wire reference enabled. + +The later client build records 602 live physics frames with populated historical effect snapshots and no observer errors. +All 17 observed local admissions remain present at completion. The observer records 20 effect confirmations and one masked replay entry. +Normal JVM shutdown hangs again, so this recording remains incomplete. +Resolved native diagnostics place the VM thread in a mutex wait inside the mapped datachannel library while Java threads wait at a safepoint. +The precise cause and packaged native binary identity remain unproven. +After diagnostics, the birth-verified recording JVM terminates, owned descendants exit, and the private server and display stop. +The original server fixture returns, and recording runtime copies release. +This establishes snapshot and admission wiring. It does not establish a successful lifecycle or native trajectories. + +## Vehicle correction motion, October 22, 2026 + +Core now sends authoritative vehicle motion before the Java vehicle position update. +Both nonzero corrections and zero resets preserve the chosen boat or horse identity. +The Java position packet carries no motion field. +Java 26.3 bytecode confirms that entity motion updates delta movement and that vehicle position updates preserve this value. + +A separate 48-case probe executes the complete native correction command `144373f30`. +It verifies position and velocity writes in the original `StateVectorComponent` path. +It also verifies conditional vehicle rotation and optional angular writes. +The fixture supplies typed storage providers and the grounded-tag setter boundary. +No hooks replace the position, velocity, rotation, angular, or bounding-box writes. + +The packet regression failed before motion delivery existed. +It covers a nonzero correction followed by a zero reset for both a boat and a horse. +Full historical replay, paired client ground state, angular state, and native trajectory comparisons remain open. + +Final integrated validation passes 1,500 core tests and 843 add-on tests, with 212 optional skips and no failures or errors. +All 104 core patches reproduce the tested source tree. +All 1,386 core classes match the core embedded in the rebuilt add-on. +The full four-project build passes. + +A fresh 45-second NetherNet recording against BDS 1.26.51.1 completes with gameplay acknowledgments and normal cleanup. +It records 873 mounted inputs, including 40 moving inputs, and 43 actual vehicle corrections. +The client stays mounted and connected, but repeated corrections demonstrate a remaining movement mismatch. +This recording does not establish native boat trajectories or historical replay parity. -**Incomplete:** Core applies corrections immediately because it has no usable movement history for replay. -Corrections inside history still need rewind and input resimulation. -Vehicle velocity, angular velocity, and movement-related metadata, attributes, and effects need matching reconciliation. ## Authoritative grounded state @@ -1091,7 +1490,8 @@ The runtime candidate remains private; no installation or gameplay launch occurs Original instructions pass 560 vehicle-view checks, 22 sender cases, six sender transitions, 192 controlling-seat writer cases and 24 prediction callbacks. Thirty cleanup controls establish generation-valid erase dispatch for supplied movement-tick markers. Actual storage erasure and cleanup execution order remain unresolved. -Native corrected vehicle velocity application, complete prediction replay, collision and strict-BDS/native trajectories also remain unresolved. +Full historical vehicle correction, client ground and angular state, collision, and strict-BDS/native trajectories remain unresolved. +The later vehicle correction section records standard motion delivery. Add-on0025 must provide the negotiated completed local mount sample before this optional core path can activate in a real client. ### Keep the sparse metadata hook with its consumer @@ -1139,3 +1539,333 @@ All 1,032 outgoing auth-input packets retain native Y `71.62001037597656`. The first sample precedes the teleport and retains the recording's separate StartGame position. Every owned process completes normal cleanup. The general rendering audit reports unresolved mappings, so this run establishes movement conversion only. + +## Empty history and physical snapshot ownership + +The correction handler now rejects packets before the first emitted input and after history configuration clears retained inputs. +It consumes the complete packet before rejection and leaves player, vehicle, motion, and correction payload state unchanged. +The first emitted input enables correction admission, including inputs without a completed physics sample. + +An additional 128 controls execute the original correction receiver and command insertion for player and vehicle targets. +All 36 empty-history controls retain unchanged live state and queue no correction. +The remaining controls cover one-frame and six-frame histories, unsigned boundaries, pending commands, and retained command placement. +Future commands remain eligible for retention without an immediate dirty mark. +The fixture supplies typed stores, snapshots, input objects, actor lookup, allocation, and scalar state. +These controls do not execute world physics. + +Three regressions fail before the change. +They pass after the change, with all 30 focused prediction and correction packet tests. +The packet controls reject both rewind targets before reading actor state. +They also cover history configuration after a previous input. + +Separate packed snapshot controls establish copied bytes for 56 scalar component types across 168 patterns. +The live source changes after capture, and the restore target starts with different bytes. +The controls retain the captured values and preserve untouched padding. +The copied fields include jump state, rotation, seat offsets, passenger identity, boat movement, movement attributes, and move input. +Move input restores 95 bytes, while boat movement restores 20 bytes and movement attributes restore 72 bytes. +These are copied-byte counts, not complete C++ object sizes or verified producer layouts. +Breathable, jump control, and depenetration need constructed fixtures. +Managed containers and component creation or removal also need separate ownership checks. + +Sixteen controls also execute the native correction command after packed snapshot restoration and before captured input. +The position command preserves the restored previous position and replaces current position and velocity. +It rebuilds the bounding box from the restored shape and position offset. +It changes the current rotation values and preserves the previous rotation values. +When angular velocity is present, it replaces the third float in `BoatMovementComponent`. +Otherwise, that field retains its captured value. +The other boat movement fields remain unchanged in these controls. +Input callbacks, arithmetic physics, constructed stores, and actor lookup remain fixture boundaries. + +The Java simulation phases still need separation before physical replay can use these snapshots. +`LocalPlayer.aiStep` polls current input and sends actions. +`Player.aiStep` advances inventory and pickups. +`LivingEntity.aiStep` advances equipment, applies block effects, and pushes other entities. +Full historical player and vehicle restoration, safe physical replay, and seat ownership remain unfinished. + +## Mounted input clock pairing + +A private delayed BDS trace supplies 95 completed boat samples with matching vehicle positions, frame identities, and mount contexts. +Every paired rider position differs from the tracked player position. +The previous player-position check discards all 95 shared frame identities before vehicle admission. +Consequently, none of those inputs can admit a negotiated vehicle correction into replay. + +The packet handler now consumes the completed frame before actor-specific position checks. +Unmounted inputs still require a matching player position. +Mounted inputs require an admitted vehicle sample with the same frame identity and current mount context. +Vehicle actor identity, position, and loaded-chunk checks remain in the vehicle storage. +A rejected vehicle sample cannot bind the input clock through a matching rider position. +Frame watermarks still prevent rejected or consumed payloads from binding later inputs. + +The stale-rider regression fails before the change and passes after it. +Nine focused cases cover valid pairs, missing samples, mismatched frames, stale contexts, different actors, displaced vehicles, unloaded chunks, and unmounted rejection. +Existing packet tests now exercise valid paired samples when they expect negotiated vehicle correction. +The combined stack includes the newer native language patch. +All 105 core patches replay, and all four project builds pass. +The full suites pass 2,675 cases, with 163 optional skips and no failures or errors. +All retained native references run, including the five language fixtures. +All 1,428 core classes match both embedded copies; all 1,143 client classes match the add-on. +Compiled core classes match after the four expected build-constant replacements. + +Two private BDS 1.26.51.1 sessions apply 300 ms inbound delay and complete standing impulses, mounting, boat impulses, release, reacquisition, dismount, and zero velocity. + +| Route | Vehicle frames paired and bound | Historical vehicle corrections | Replay boundaries | Replayed physical steps | +| --- | ---: | ---: | ---: | ---: | +| Direct NetherNet | 158 | 10 | 17 | 103 | +| ViaProxy | 159 | 12 | 19 | 116 | + +Every replay preserves the live player tick, food level, and held-item count. +Every historical vehicle correction retains the current mount epoch, controlling passenger, and local authority. +Each route receives four nonzero-tick player velocity commands with the original native tick and float values. +Each command applies once live and once in its following historical input frame. +Both mounted player impulses have tick zero and retain standard delivery. +The fixture emits no nonzero-tick vehicle velocity packet, so it does not exercise that negotiated command variant. + +Both runtime add-on checksums match the combined artifact; the proxy checksum also matches on its route. +Both clients, recorders, and isolated servers stop normally, and their owned process identities disappear. +Read-only observers report no errors in these completed runs. +The original server fixture remains unchanged. +A separate first attempt ends before the sequence because its reused player drowns. +The private fixture now resets initial health and disables drowning damage; motion and mount phases remain unchanged. + +These checks establish live clock binding and correction execution on both routes. +Exact native trajectories, collider refresh, retained interpolation, and complete mount lifetime and seat ownership remain unfinished. + +## Native packet source for collider refresh + +The collider source is the newest network-history message position. +It is independent of the completed Java physics frame and the current Java transform. +The native movement getter at `1401c5610` returns the position at message offset `0x28`. +The motion getter at `142887770` returns the retained movement position at offset `0x44`. +It does not return the raw or accumulated velocity. + +A combined check executes the original prediction constructor, all three packet enqueue functions, both position getters, and collider refresh. +All 192 cases pass, including motion before the first movement and consecutive motion messages. +A second check executes the original packet publisher, admission gate, history insertion, statistics update, target writer, and collider refresh together. +All 768 cases pass across movement flags, local control, prediction disablement, actor predictability, provider state, and seeded histories. +These checks target Bedrock 1.26.51.1, build 51061372, protocol 2193. +The executable SHA-256 is `537c0aee2e79afbdc94b44b28e00f466ae62bc50e2733d953b430db9dbaa9ee7`. + +The receive cache updates before the local-control gate. +An ignored ordinary update therefore changes the receive cache without changing the admitted history or interpolation target. +A predictable actor can enqueue a message while prediction is disabled. +Collider refresh still rejects that history source until the prediction component permits it. +A missing global provider permits history use through the original `1410400c0` guard. +A configured provider supplies its own boolean through the native virtual callback. + +Collider refresh first selects enabled, nonempty prediction history. +Otherwise, any nonzero interpolation counter selects the retained interpolation target, including negative counters. +Without either source, the retained collider stays unchanged. +This source lookup does not compare packet receive clocks. +The separate interpolation sampler still requires receive timing and native scheduling. + +Three negative controls fail after replacement of the motion getter, bypass of local-control admission, or bypass of prediction disablement. +The fixtures supply allocation, CRT, TLS, clock interfaces, component membership, shape inputs, and selected registry callbacks. +They exclude actor factory classification, intent producers, history sampling, scheduler integration, and complete live trajectories. +The client adapter now integrates these source inputs. The next section records the implementation and its remaining limits. + +## Retained sources in the client bridge + +The core now retains admitted movement separately from the raw receive cache. +Only accepted absolute or delta updates advance the admitted revision. +Ignored local-control updates still populate the receive cache for later sparse updates. +Local predicted transforms cannot overwrite the admitted position. +The negotiated `viabedrock:actor_spatial` channel publishes the position, flags, unsigned duration, actor identity, revision, and bridge component profile. +Capability replay publishes the retained source after the actor snapshot. + +The client registry rejects stale lifetimes and retires sources with their actor. +Duplicate revisions can update control inputs without restarting interpolation. +The source model retains forced and queued targets, signed counters, and the native rotation override. +The normal client phase advances counters and handles prediction reset or resume before local physics. +Historical replay does not advance the live source model. +Each cached collider captures immutable interpolation inputs while eligible network history remains live. + +The supported bridge profiles use player exclusion from base prediction and the verified local horse control predicate. +Twenty additional instruction controls execute the original vehicle-intent initialization stores. +All twenty initialize the client-prediction byte to zero, preserve surrounding guards, and match the complete 72-byte payload. +Sparse insertion and slot lookup remain supplied boundaries. +The existing horse writer controls establish the later saddle, tame, and controller gates. + +The registered Java comparison matches all 256 original source-selector and float-box cases. +The packet regressions also verify that rejected vehicle updates preserve their previous admitted position and revision. +Focused source, codec, registry, and packet checks pass. +The native constructor and source controls use the executable identity recorded in the previous section. + +History use currently follows the native guard's absent-provider default. +The configured provider and its Java setting remain unmapped. +Complete custom actor/component admission, optional head-component behavior, remote sampling, and native pose phases remain unfinished. +Fresh collider membership still uses the existing Java world adapter. +These limits prevent a claim of complete native collision or trajectory parity. + +The [client source validation](../../viafabricplus-bedrock/upstreamable/0025-send-completed-local-physics-to-viabedrock.pr.md#combined-source-validation) records the combined build and fresh delayed sessions. +Both recorded selector counterexamples now pass their registered client regressions. +The live routes check collider refreshes against independent float calculations and retain valid mounted input bindings. +Normal `MOVE_PLAYER` source production and the remaining native component and simulation phases remain unfinished. + +The source publisher waits until its actor snapshot is available before it emits the first source command. +When it publishes native removal, it clears the cached source and dimension publications. +Equal inputs therefore initialize the receiver again after re-publication. +A packet-codec regression feeds the real actor registry through ordinary metadata publication and removal. +It fails first for premature source delivery, then for retained publication caches, and passes with both fixes. + +## Player movement source boundary + +Native `MOVE_PLAYER` uses callback slot `0x708` and handler `0x14133e990` in the pinned target. +It does not enter the absolute/delta publisher used by the source integration above. +Original execution covers 2,048 combinations of client gates, packet admission, actor lookup, riding lookup, mode, ground state, and optional fields. +The accepted cases reach either the player-mode effect or the riding rotation, head, and vehicle effects. +The ordinary branch also computes motion from the received position and previous native position before its mode effect. +These checks supply virtual queries, packet admission, actor lookup, diagnostics, and downstream effects. +They establish callback routing, rather than complete player movement or collider-source behavior. +The composed controls below execute the reset effects. Normal source admission and riding pose ownership remain unfinished. + +## Remote player reset sources + +Remote player respawn and teleport packets now publish an explicit interpolation clear. +A resolved riding actor selects the separate native riding branch and suppresses this clear. +Normal movement and head-only packets retain their existing source state. +The clear retires live interpolation without changing the absolute/delta receive cache or a historical capture. +Later admitted movement can establish a new source. +The second revision of the unreleased spatial codec distinguishes movement from clearing. + +The original player mode helper composes with the real remote-player virtual, target writers, reset paths, position-request stores, and collider refresh. +All 3,456 controls pass across four modes, ready/throttle inputs, region results, missing interpolators, signed counters, forced state, and three positions. +The positions include negative coordinates and chunk boundaries. +The respawn and teleport paths execute their original interpolation clearing. +Four deliberate variants fail: ignored readiness, omitted target publication, all modes treated as normal, and omitted teleport clearing. + +A serialized packet-to-registry regression covers all four modes, riding references, and present or missing vehicles. +It also preserves the prior historical target and receive cache, then admits later movement. +The consumer without clear handling fails; restored production passes. +All sixteen focused core checks pass with their native references enabled. + +These native controls supply actor/component stores, ready/throttle inputs, region results, float CRT, and downstream reset/event boundaries. +The original ready-field setter executes with a supplied input in this earlier corpus. +The readiness controls below extend that evidence through its native producer and tick-system callback. +Normal remote-player source admission, complete region lookup, local-player virtuals, riding pose effects, and native scheduling remain unfinished. +The full five-area goal remains active. + +A fresh target BDS fixture uses a simulated player in a separate test-world copy with Beta APIs enabled. +Each direct and proxy capture contains two remote-player teleport packets and receives two matching clear commands for that UUID. +Both preserve the admitted position and actor lifetime, with inactive interpolation after each clear. +The fixture also respawns the simulated player, but the recorded packets contain no respawn mode. +Live targets before the clears are inactive; active-target retirement retains native and serialized regression coverage. +Observers report no errors, runtime package hashes match, and all recorded run-owned identities exit normally. +The original server configuration, script, and manifest are restored. +The [client validation](../../viafabricplus-bedrock/upstreamable/0025-send-completed-local-physics-to-viabedrock.pr.md#remote-player-reset-validation) records the combined build and delayed movement checks. + +## Native player throttling + +Further original execution covers the optional throttle installation and its later update transitions. +This research uses the same Bedrock 1.26.51.1 client image and protocol 2193 as the reset controls. +It adds no production admission rule. + +The `ADD_PLAYER` handler checks a client configuration virtual before installing `UpdateAndRenderThrottleComponent`. +It creates a missing component with both bytes zero and preserves an existing component. +Sixteen controls execute the original configuration call, sparse membership checks, and payload initialization. +Generic storage lookup, insertion, and payload allocation remain supplied boundaries. +Another 640 controls replace that supplied boolean with the original configuration setter, enabled predicate, and value accessors. +The setter stores its enabled flag, integer threshold, and float scalar without changing neighboring fields. +The enabled predicate requires a stored flag of exactly one. +It then compares an option integer against the stored threshold with signed less-than-or-equal admission. +Threshold equality, both signed extrema, and the float scalar's negative zero bits retain original execution. +The option object and integer remain supplied. +The packet controls below cover the setter's network caller; runtime update timing remains unverified. + +The component has separate selection and interpolation-admission bytes. +The original signed budget comparison can reject a selection attempt. +An admitted selection sets both bytes and clears interpolation when the previous admission byte was false. +It also copies the current native position into the previous position in that case. +The throttle callback clears the selection byte. +If that byte was already false, the callback also disables interpolation admission, clears interpolation, and removes `ActorTickNeededComponent`. +An admitted update preserves its enabled admission byte across the callback. + +The native registration names this callback `ClientDisplayThrottleTick` and supplies category value ten. +The original callback executes both sparse intersection traversals across 2,560 controls. +Each control composes the budget branch, callback, normal player packet effect, position request, and collider refresh. +The earlier 1,280 direct-helper controls remain separate evidence for the same transitions. +They are not an additional set of independent player scenarios. +Six deliberate variants fail assertions: ignored budget, retained disabled target, retained first-admission target, ignored configuration, ignored threshold, and reinitialized existing component. + +These controls supply budget inputs, priority notifications, component stores, registry lookup, and marker erasure. +They also retain the earlier supplied readiness input, region query, notifications, and float CRT. +They do not establish complete priority collection, budget production, scheduler ordering, actor factory behavior, or native trajectories. +The readiness controls below verify the setter caller and tick-system selection. +Normal player source integration still needs complete membership producers, the option implementation, priority membership, and full phase effects and timing. +Java actor age, visibility, and smoothing cannot stand in for these native decisions. + +## Native network throttle producer + +The native `NETWORK_SETTINGS` dispatcher reaches handler `0x141330030` through the client callback table. +The handler supplies the configuration setter's enabled flag, unsigned-byte threshold, and float scalar. +An enabled packet preserves its threshold and scalar bits. +A disabled packet clears the stored threshold and scalar, including stale values from an earlier configuration. +The current translator discards these fields; complete player-source integration must retain their behavior. + +Another 3,584 controls compose the original handler prefix, configuration accessors, player-component installation, and budget calculation. +The budget reads the option integer and scalar, multiplies as native floats, then truncates to a signed integer. +NaN, infinity, and out-of-range products produce the native invalid-conversion result. +Retaining disabled configuration values fails its control. +Rounding a budget instead of truncating it also fails; one native case requires 63 rather than 64. +The earlier 512 packet-prefix controls are a subset of this composed coverage. + +These controls supply decoded packet objects, an empty compression connection table, option values, and ECS storage boundaries. +They stop before the handler's authentication continuation. +The empty-range sort is an asserted boundary; the controls do not establish priority collection or entity selection order. +Packet decoding, complete scheduler timing and membership producers, and the actual option implementation remain unfinished. + +## Native player readiness + +Readiness comes from the result of `Actor::tick`, published through the remote-player virtual setter. +`ActorLegacyTickSystem` selects `ActorOwnerComponent` and `ActorTickNeededComponent`, then invokes the tick wrapper. +It handles entities with and without `PlayerComponent` in separate passes. +Only actors with `RemotePlayerComponent` receive the readiness publication. +The wrapper removes the tick-needed marker after a tick attempt, including a failed attempt. +A nonzero skip flag leaves readiness and the marker unchanged. + +The original wrapper, actor tick gates, setter, following normal movement, and collider refresh pass 4,608 controls. +They cover region results, global-actor and actor-type gates, remote-player membership, prior readiness, and absent, live, or expired weak region references. +Another 1,728 controls execute the original tick-system callback and both passes. +Instruction observations confirm all three player traversals and both non-player traversals. +Missing required membership or a stale tick-needed generation prevents dispatch. +The following movement and collider paths also execute, with their input combinations sampled by case index. +Eight deliberately incorrect executions fail assertions across the two corpora. + +The native type metadata identifies the required early-return state as `DimensionStateComponent`. +These controls set its first 32-bit value nonzero. +The original remote-player normal tick therefore returns before its full player physics and inventory body. +They supply actor/world state, sparse stores, initialized compiler TLS state, generic storage discovery and erasure, region results, notifications, and float CRT. +The system is called directly; registration order does not establish scheduler execution order. +These earlier controls exclude membership production, full normal-tick effects, riding behavior, actual region implementation, and phase timing. +The lifecycle controls below extend producer, region, and dispatch-order coverage. +This evidence introduces no production runtime change and does not establish normal player source parity. + + +## Native remote-player tick lifecycle + +The original `FlagRemotePlayersForTickingSystem` selects `BlockSourceComponent`, `ActorComponent`, and `RemotePlayerComponent`. +Its marker producer locks the weak region reference and adds `ActorTickNeededComponent` only when absent. +The original `BlockSource::getWeakRef` initializes the marker payload; existing payloads remain unchanged. + +A separate control executes complete native client system registration and the ordinary `EntitySystems::tick` dispatcher. +It observes 473 dispatches from 487 registrations under the supplied client options. +Remote-player flagging runs before `ClientDisplayThrottleTick`, followed later by `ActorLegacyTickSystem`. +The order control observes all system bodies and custom controllers through return boundaries. +It proves that entry's dispatch order, not the effects of every intervening system or packet interleaving. + +Another 23,040 controls compose the original remote-player flagging callback, marker constructor, throttle callback, legacy tick, readiness setter, and following normal movement. +All three flagging traversals execute with valid cached component views. +The throttle callback can retire a newly created marker before the legacy tick, leaving prior readiness unchanged. +These controls also execute the concrete `BlockSource::hasBlock`, chunk lookup, and weak-cache paths. +Fresh lookups require native chunk stage 2 or 11 according to the source flag, and reject the raw chunk availability flag. +Two four-step sequences verify live-cache reuse after a stage drop, immediate flag rejection, and a fresh lookup after weak expiry. +Five deliberately incorrect variants fail assertions: dropped marker production, existing-marker reinitialization, ignored availability flag, lowered stage threshold, and ignored live cache. + +The original startup initializer supplies the invalid-chunk sentinel used by the source constructor and availability check. +This includes chunk `(0, 0)` in the verified input set; the zero-filled PE image alone does not represent initialized process state. +The [pinned SDK layout](https://github.com/LiteLDev/LeviLamina/blob/e0c75244af2f7576058976ab6d75a17e10de3f92/src/mc/world/level/chunk/LevelChunk.h) names byte `0x82` as `mReadOnly`; this remains naming evidence, separate from the executed target behavior. + +These controls supply cached component views, generic sparse insertion and erasure/destruction, chunk records, load stages, source flags, and retained reference owners. +They retain the nonzero `DimensionStateComponent` early return and the earlier notification, CRT, and optional-component boundaries. +The native source factory and constructor execute with supplied allocator, level/listener, and thread-ID boundaries. +Complete phase effects, chunk-state producers, other membership producers, priority collection, options, riding, packet timing, and normal player source integration remain unfinished. +No production runtime code changes in this milestone. diff --git a/patches/viabedrock/upstreamable/0093-complete-login-before-resource-pack-negotiation.patch b/patches/viabedrock/upstreamable/0093-complete-login-before-resource-pack-negotiation.patch index 25d68d38..8a18ee5a 100644 --- a/patches/viabedrock/upstreamable/0093-complete-login-before-resource-pack-negotiation.patch +++ b/patches/viabedrock/upstreamable/0093-complete-login-before-resource-pack-negotiation.patch @@ -1,4 +1,4 @@ -From 9fdaa5c4128bb41bc6d059f1796c2cbacb152b17 Mon Sep 17 00:00:00 2001 +From 6cf10f24deb60cdb6c2fbedc8d2212004e6b961a 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(login): complete login before resource pack negotiation @@ -13,7 +13,7 @@ Advance only the server protocol state after the first success. Leave the Java c create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/packet/LoginTransitionTest.java 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 441186cd0..8a4e67e99 100644 +index 4dc2e0ef7..45e28836e 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/JoinPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/JoinPackets.java @@ -136,6 +136,11 @@ public final class JoinPackets { diff --git a/patches/viabedrock/upstreamable/0094-synchronize-authoritative-entity-references.patch b/patches/viabedrock/upstreamable/0094-synchronize-authoritative-entity-references.patch index 10efa2bb..9d9e0629 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 9a0d642efa015ef3ed6e65135a983f6dbd4b67f3 Mon Sep 17 00:00:00 2001 +From 6f2503a2da5686dc4c0843dbe629674a82a7fcc3 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 @@ -284,10 +284,10 @@ index 000000000..7501bb791 + +} 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 c66bef8b3..9439629f2 100644 +index 37fa0d48a..17f2ac36e 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 -@@ -49,6 +49,7 @@ import net.raphimc.viabedrock.protocol.types.entitydata.EntityDataTypesBedrock; +@@ -51,6 +51,7 @@ import net.raphimc.viabedrock.protocol.types.entitydata.EntityDataTypesBedrock; import java.math.BigInteger; import java.util.*; import java.util.logging.Level; @@ -295,7 +295,7 @@ index c66bef8b3..9439629f2 100644 public class Entity { -@@ -132,6 +133,7 @@ public class Entity { +@@ -146,6 +147,7 @@ public class Entity { bossEvent.write(Types.VAR_INT, BossEventOperationType.REMOVE.ordinal()); // operation bossEvent.send(BedrockProtocol.class); } @@ -304,10 +304,10 @@ index c66bef8b3..9439629f2 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 7648c69fa..a33fe4c5d 100644 +index c5e23c91b..bfe6eacf6 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/EntityPackets.java -@@ -179,9 +179,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 7648c69fa..a33fe4c5d 100644 if (entity instanceof LivingEntity livingEntity) { livingEntity.updateAttributes(attributes); -@@ -220,6 +221,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 7648c69fa..a33fe4c5d 100644 final List javaEntityData = new ArrayList<>(); entity.updateEntityData(entityData, javaEntityData); -@@ -447,6 +449,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(); @@ -336,10 +336,10 @@ index 7648c69fa..a33fe4c5d 100644 final PacketWrapper setEntityData = PacketWrapper.create(ClientboundPackets26_3.SET_ENTITY_DATA, wrapper.user()); setEntityData.write(Types.VAR_INT, entity.javaId()); // entity id 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 4653d8293..245f0da36 100644 +index 6c8e9b600..05a0cf8c1 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java -@@ -264,6 +264,10 @@ public final class MultiStatePackets { +@@ -266,6 +266,10 @@ public final class MultiStatePackets { if (channels.contains(ViaBedrockUtilityInterface.CONFIRM_CHANNEL)) { ViaBedrockUtilityInterface.confirmPresence(wrapper.user()); } @@ -351,7 +351,7 @@ index 4653d8293..245f0da36 100644 }; diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/OtherPlayerPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/OtherPlayerPackets.java -index 5b3311913..04eb4f2e1 100644 +index 36233420b..530025a2e 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/OtherPlayerPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/OtherPlayerPackets.java @@ -135,6 +135,7 @@ public final class OtherPlayerPackets { @@ -708,10 +708,10 @@ index 000000000..995874f83 + +} 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 1fcdc5f8e..b9a48f7ec 100644 +index d50c27994..3d7f067ef 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/EntityTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/EntityTracker.java -@@ -61,6 +61,8 @@ import java.util.logging.Level; +@@ -62,6 +62,8 @@ import java.util.logging.Level; public class EntityTracker extends StoredObject { @@ -720,7 +720,7 @@ index 1fcdc5f8e..b9a48f7ec 100644 private final AtomicInteger idCounter = new AtomicInteger(2); private boolean nativeActorsJoined; -@@ -390,6 +392,23 @@ public class EntityTracker extends StoredObject { +@@ -397,6 +399,23 @@ public class EntityTracker extends StoredObject { return this.entities.get(uniqueId.longValue()); } 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 35373c21..7ae4a96a 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 79e64b7a52c6566beaa75ea5d4bf84f938dc4668 Mon Sep 17 00:00:00 2001 +From fa3c6f67fb2e9dca5ea41b27d8dd05ca61998c64 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 @@ -344,38 +344,1021 @@ Tests resolve every bundled family and retain all slot addresses through program export and replay. Native visual and route verification remains. Resolve native #alpha and #propagateAlpha bindings on each control and restore control-local defaults for descendants. The saved target CubeCraft HUD uses these bindings. Bump the converted resource identity so existing caches do not retain omitted opacity values. + +Execute authored count-one chest actions on client-authoritative servers +through normal InventoryTransaction packets. Preserve both before/after +slot states and native hotbar-first merge selection without requiring +modern stack network IDs or waiting for ItemStackResponse. Publish the +Java prediction after transport succeeds; subsequent server contents and +slot packets remain authoritative. Bind activation to the current menu +and exported presentation, and block ordinary Java clicks while active. + +Share the native runtime-ID registry, immutable item definitions and +constructor-owned stack limits with the authoritative inventory adapter. +Keep request/response handling in its deferred patch. This also moves the +common presentation-channel handler and lifetime cleanup to the UI owner. + +Research targets official Bedrock 1.26.51.1 build51061372 protocol2193, +executable SHA256537c0aee2e79afbdc94b44b28e00f466ae62bc50e2733d953b430db9dbaa9ee7. +Native normal wrapper14266bd30, outgoing body1427324a0 and packet14272c690 +preserve constructor-owned container/player actions and balanced lifetime +cleanup. Existing native return and factory controls establish the shared +selection/limit inputs. These bounded controls do not establish the full +Player/controller sender, negotiated authority flag, serialized native +bytes or live server acceptance. The initial client-authoritative adapter +handles authored 27/54-slot chest presentations; other native providers +remain separate implementation work. + +Keep the stable-registry full query API for cooking output queries, +including wildcard auxiliary values and query-sided Block identity. +Share its numeric userdata comparison with occupied-slot merge eligibility. +The cooking transfer helper 14c54c010 calls full query comparator14248dac0; +that does not establish the ordinary authored container destination rules. + +Extend authored client-authoritative count-one actions to the ordinary +five-slot hopper and nine-slot dispenser/dropper providers. Move those +storage definitions and their opening/presentation foundation to this +first consumer, retaining modern request slot names in the inventory +adapter. Share exact provider/type/size and presentation-kind validation +between both inventory modes rather than maintaining chest-only gates. +An actor-resolved HopperContainer can retain generic CONTAINER on the +wire; accept that five-slot provider without changing its close identity. + +Pinned 1.26.51.1 redstone UI definitions use container_items with 5x1 +hopper and 3x3 dispenser/dropper grids, through the common container-item +controls. Reuse ordinary balanced normal transactions and direct external +slot identities. The graphical/controller and live server paths remain +unverified for these new client-authoritative families. Tests execute +current authored bindings, activation, merge debits/credits, family codec +negotiation and rejection of mismatched providers and workstation layouts. + +Use the native occupied-slot merge predicate for authored count-one +transfers. Destination durability, typed Unbreakable data and constructed +stack limits determine stackability; subtype items compare exact effective +auxiliary values, while ordinary items ignore auxiliary differences. +Retain native userdata, restriction and blocking-state comparisons rather +than Java object equality. Share the userdata comparator with later +recipe queries, keeping their full transfer identity check separate. + +Pinned 1.26.51.1/build 51061372/protocol 2193 registers auto-place-one in +state 12. Its entry 144e4bf10 reaches 144dfb400 and virtual auto-placement; +base 1433eb800 plans through 1433e9e90/1433eab10. Occupied capacity reaches +147c98ca0 through 1431b69d0 and calls 142488fe0 source-first. Complete +native 142488fe0/142488c90 controls match 756 component-backed cases with +real construction, registry ownership, binary NBT loading and cleanup. +The prior raw planner fails the native destination-selection regression. + +The native controls use standalone ComponentItem 259 without associated +Block, restrictions or charged children. Effective block auxiliary uses +the existing canonical descriptor resolver; unresolved subtype metadata +cannot establish occupied-slot compatibility. Registry replacement +lifetimes, restriction hash collisions, specialized Item overrides and +complete controller/wire/live behavior remain unverified. + +Require the converted resource pack to be loaded on the Java client before +publishing a native container program. A negotiated payload channel alone +does not make its hashed images available. Without those resources, the +client skips every missing panel image while still drawing inventory items. +Keep the ordinary menu until resource loading succeeds, without consuming +a presentation sequence. A packet regression fails before this guard and +passes after it, then proves the first loaded presentation still uses +sequence one. This identifies a missing-background failure path, not the +confirmed cause of the reported WildMC screenshot. + +Resolve custom item names through the constructed registry factory class. +Use the shared unfiltered selector for Java CUSTOM_NAME so an empty or +wrongly typed display.Name on a native written book falls back to title. +A present but wrongly typed title selects empty custom text. Component +replacements keep the base custom-name dispatch even if a later entry +uses legacy flags. Removed runtime-ID owners do not acquire names. + +The original 1.26.51.1 executable predicate143f6d580 and getter143f6d2c0 +confirm title/custom-name precedence in 1,000 typed controls. Existing +native registry lifecycle controls establish the ordered factory policy. +Targeted tests compose that policy with book naming across first and +subsequent Legacy/DataDriven/None updates. This does not implement all +ordinary key families, secondary labels, native language selection or +the authored search field. + +Resolve ComponentItem description keys from registry construction and ordered +component readers, rather than the join handler's entry-version filter. All +explicit construction precedes updates; an existing component receives None +and Legacy entries as well. Late fallback construction ignores earlier +metadata. Keep the real builtin component description separate from factory +version flags, and retain its observed default until explicit reconstruction. + +Use these keys for mapped and unmapped Java items, preserving native naming +through the shared language engine without changing unrelated component +loaders. Original native controls cover 23 final descriptions and their class +selection. The old packaged converter fails twelve of twenty applicable +component cases. Five deliberate constructor, version, fallback, empty-value +and builtin-default regressions fail focused behavioral assertions. Full +ordinary-key providers, secondary labels, client asset precedence and cooking +search transport remain separate work. + +Retain constructor-owned ordinary keys for 559 builtin definitions across 14 +receiver methods that ignore descriptors and userdata. Use these keys in +production item conversion through the current native language engine. +Explicit generic component construction replaces this dispatch before +ordered component name readers; later flags cannot select another class. +Block, potion and other descriptor-dependent methods remain separate +providers rather than inheriting a default-key snapshot. + +A registered legacy factory can upgrade to a block mapping without a current +block-state set. Preserve ordinary naming and the existing missing-visual +fallback in that case instead of dereferencing an absent state collection. + +Dispatch thirteen descriptor-dependent name methods from their actual +constructor descriptions and variants for 41 builtin definitions. Preserve +typed NBT presence, signed-short metadata, low-byte and color masks, fish +bucket precedence, exploration labels and book title selection. Runtime +conversion supplies stack metadata and userdata through the same registry +identity and component-reconstruction checks as invariant names. + +Use a shared name strategy for invariant and descriptor methods rather than +leaving a parallel default-key path. Block, potion and compound providers +still require their separate native state, language and recipe owners. + +Compose descriptor-owned ordinary names with the typed native append path through the current language translator and argument formatter. Keep Java custom-name overrides separate from the underlying ordinary component, and share the same append implementation with the lazy primary selector. Native selector/I18n composition controls cover complete format failure and token expansion rather than only a deterministic lookup boundary. + +Retain constructor-owned block name providers and compiled block-definition +keys separately from runtime IDs and Java display mappings. Resolve the +actual auxiliary block state before selecting the name. Preserve native +empty-name, leaf, wool/carpet and sapling fallback decisions, and retain +component reconstruction and registry ownership as dispatch boundaries. +Keep block lookup lazy so other item methods do not acquire a world-source +dependency. Potion and arrow version ownership remains separate work. + +Resolve potion, splash and lingering names through the actual constructor +mode, compiled potion registry fields and current native language formatter. +Retain missing, empty and unchanged-translation fallback decisions. These +receivers share the lazy connection-owned name context with block names; +component reconstruction and runtime-ID ownership continue to gate dispatch. + +Use a separate native BaseGameVersion value read from StartGame level settings. +Its parser preserves partial versions, wildcard and beta, strips accepted +suffixes before feature comparisons, and rejects malformed or overflowing +16-bit components. The existing loose session conversion differs on eighteen +of thirty-six original parser/comparator inputs and cannot drive these gates. +The original StartGame handler copies this version member into the fifth +client configure argument; bounded original copy/dispatch controls cover all +thirty-six parsed records. The complete packet decoder and client lifecycle +remain outside those native controls. + +Resolve the compiled ArrowItem receiver through the same native potion +registry. Auxiliary zero delegates to the ordinary constructor description; +other descriptors select potion auxiliary minus one, with the native empty +arrow fallback. Preserve absent descriptors and raw description bytes. The +version source stays lazy for ordinary arrows, and this receiver does not +request block or language callbacks. Component reconstruction and runtime +owner removal still retire builtin dispatch. + +Resolve compound primary keys from the target's 38 compiled CompoundType names. +Previously, unmapped compound metadata inherited the Salt name from the Java +carrier mapping. That changes tooltip text and the names used by native search. +Preserve the unsigned-byte auxiliary conversion, absent-descriptor value 255, +fixed native prefix and empty suffix outside the compiled enum table. + +The original alias initializer registers sugar, ink sac and charcoal. Their +recipe hashes are disjoint from every 16-bit auxiliary value of the actual +compound constructor. Keep this provider bound to that constructor and retain +component reconstruction and current runtime-owner admission before dispatch. +The general cross-item compound classifier remains a separate native concern. --- - .../viabedrock/protocol/BedrockProtocol.java | 3 + - .../protocol/data/BedrockMappingData.java | 15 +- - .../protocol/packet/HudPackets.java | 51 +- - .../protocol/packet/InventoryPackets.java | 597 +++++++++++++----- - .../protocol/packet/JoinPackets.java | 1 + - .../protocol/packet/MultiStatePackets.java | 1 + - .../protocol/packet/NpcDialoguePackets.java | 417 +++++++++--- - .../protocol/rewriter/ItemDataRewriter.java | 1 - - .../protocol/storage/EntityTracker.java | 4 +- - .../protocol/storage/InventoryTracker.java | 16 +- - .../storage/NativeContainerUiState.java | 130 ++++ - .../storage/NativeHudActionbarState.java | 37 ++ - .../protocol/storage/NativeHudTitleState.java | 81 +++ - .../resourcepack/ui/HudActionbarUiTest.java | 108 ++++ - .../api/resourcepack/ui/HudTitleUiTest.java | 176 ++++++ - .../packet/ModalFormTransitionTest.java | 123 ++++ - .../NativeContainerOpeningPacketTest.java | 395 ++++++++++++ - .../protocol/packet/NativeHudPacketTest.java | 160 +++++ - .../packet/NpcDialoguePacketTest.java | 301 +++++++-- - .../storage/NativeContainerUiStateTest.java | 85 +++ - 20 files changed, 2386 insertions(+), 316 deletions(-) + Data Asset Sources.md | 33 + + .../chunk/blockstate/BuiltinBlockNames.java | 46 + + .../viabedrock/api/model/NativeItemNames.java | 6 + + .../api/model/NativeItemStacking.java | 85 + + .../api/model/container/ChestContainer.java | 3 +- + .../container/NativeContainerActionSlots.java | 75 + + .../NativeContainerLegacyActions.java | 130 + + .../container/NativeContainerTransfer.java | 73 + + .../container/NativeItemStackMatcher.java | 41 + + .../container/NativeStorageContainers.java | 49 + + .../container/block/Generic3x3Container.java | 47 + + .../container/block/HopperContainer.java | 47 + + .../definition/ItemDefinitions.java | 5 - + .../viabedrock/protocol/BedrockProtocol.java | 3 + + .../protocol/data/BedrockMappingData.java | 15 +- + .../protocol/model/BaseGameVersion.java | 53 + + .../protocol/model/BedrockItem.java | 2 +- + .../protocol/model/BuiltinItemFactories.java | 89 + + .../protocol/model/BuiltinItemName.java | 245 + + .../protocol/model/BuiltinPotionNames.java | 62 + + .../ComponentItemStackingProperties.java | 40 + + .../viabedrock/protocol/model/ItemEntry.java | 10 + + .../protocol/model/ItemNameContext.java | 23 + + .../protocol/model/ItemRegistry.java | 231 + + .../model/ItemStackingProperties.java | 36 + + .../model/LegacyItemStackingProperties.java | 54 + + .../protocol/packet/HudPackets.java | 51 +- + .../protocol/packet/InventoryPackets.java | 653 +- + .../protocol/packet/JoinPackets.java | 4 +- + .../protocol/packet/MultiStatePackets.java | 38 + + .../protocol/packet/NpcDialoguePackets.java | 417 +- + .../protocol/rewriter/BlockStateRewriter.java | 7 + + .../protocol/rewriter/ItemDataRewriter.java | 1 - + .../protocol/rewriter/ItemRewriter.java | 100 +- + .../protocol/storage/EntityTracker.java | 4 +- + .../protocol/storage/GameSessionStorage.java | 10 + + .../protocol/storage/InventoryTracker.java | 25 +- + .../storage/NativeContainerUiState.java | 130 + + .../storage/NativeHudActionbarState.java | 37 + + .../protocol/storage/NativeHudTitleState.java | 81 + + .../data/bedrock/block_name_keys.json | 1385 +++ + .../data/bedrock/item_factories.json | 10012 ++++++++++++++++ + .../viabedrock/data/bedrock/potion_names.json | 193 + + .../api/model/NativeItemNamesTest.java | 62 + + .../api/model/NativeItemStackingTest.java | 149 + + .../NativeContainerLegacyActionsTest.java | 290 + + .../NativeContainerTransferTest.java | 90 + + .../container/NativeItemStackMatcherTest.java | 179 + + .../resourcepack/ui/HudActionbarUiTest.java | 108 + + .../api/resourcepack/ui/HudTitleUiTest.java | 176 + + .../protocol/model/BuiltinItemNameTest.java | 159 + + .../model/ItemRegistryComponentNamesTest.java | 84 + + .../model/ItemRegistryOrdinaryNamesTest.java | 72 + + .../protocol/model/ItemRegistryTest.java | 338 + + .../packet/ModalFormTransitionTest.java | 123 + + .../NativeContainerOpeningPacketTest.java | 485 + + .../protocol/packet/NativeHudPacketTest.java | 160 + + .../packet/NpcDialoguePacketTest.java | 301 +- + .../storage/NativeContainerUiStateTest.java | 85 + + 59 files changed, 17166 insertions(+), 346 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/NativeItemStacking.java + create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/NativeContainerActionSlots.java + create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/NativeContainerLegacyActions.java + create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/NativeContainerTransfer.java + create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/NativeItemStackMatcher.java + create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/NativeStorageContainers.java + create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/block/Generic3x3Container.java + create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/container/block/HopperContainer.java + create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/model/BaseGameVersion.java + create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/model/BuiltinItemFactories.java + create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/model/BuiltinItemName.java + create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/model/BuiltinPotionNames.java + create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/model/ComponentItemStackingProperties.java + create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/model/ItemNameContext.java + create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/model/ItemRegistry.java + create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/model/ItemStackingProperties.java + create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/model/LegacyItemStackingProperties.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/storage/NativeContainerUiState.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/storage/NativeHudActionbarState.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/storage/NativeHudTitleState.java + create mode 100644 src/main/resources/assets/viabedrock/data/bedrock/block_name_keys.json + create mode 100644 src/main/resources/assets/viabedrock/data/bedrock/item_factories.json + create mode 100644 src/main/resources/assets/viabedrock/data/bedrock/potion_names.json + create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/NativeItemStackingTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/container/NativeContainerLegacyActionsTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/container/NativeContainerTransferTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/container/NativeItemStackMatcherTest.java 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/ItemRegistryComponentNamesTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/model/ItemRegistryOrdinaryNamesTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/model/ItemRegistryTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/packet/ModalFormTransitionTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/packet/NativeContainerOpeningPacketTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/packet/NativeHudPacketTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/storage/NativeContainerUiStateTest.java +diff --git a/Data Asset Sources.md b/Data Asset Sources.md +index 399698635..d706a2afa 100644 +--- a/Data Asset Sources.md ++++ b/Data Asset Sources.md +@@ -142,3 +142,36 @@ Curated native block aliases for Bedrock 1.26.51.1, build 51061372, protocol 219 + The matching executable and private registry lookup probes supply these identities. + Keep primary registrations ahead of alias lookup. + Namespace fallback and complete runtime registration still need native comparison. ++ ++ ++### data/bedrock/item_factories.json ++ ++Curated constructor defaults and cooking traits for Bedrock 1.26.51.1, build 51061372, protocol 2193. ++The matching native executable has SHA-256 `537c0aee2e79afbdc94b44b28e00f466ae62bc50e2733d953b430db9dbaa9ee7`. ++Private constructor and typed component-loader probes supply the values. ++The table retains factory identity separately from advertised runtime IDs. ++Component descriptions record actual native key dispatch separately from the factory version. ++The pre-packet catalog has one component-name getter: the warped-fungus-on-a-stick definition. ++Its constructor supplies the complete default key. Explicit data-driven reconstruction selects the ordinary component reader instead. ++Ordered native controls verify construction before updates and fallback construction after updates. ++Ordinary names retain 559 constructor-owned keys across fourteen methods that ignore descriptors and userdata. ++The initialized native registry confirms these keys after element-table initialization and completes all 2,630 cleanup callbacks. ++Descriptor names retain constructor descriptions and variants for 41 definitions across thirteen additional native methods. ++The providers read exact descriptor metadata and typed userdata for buckets, dyes, books, maps, beds, banners, shields, and education items. ++A composed private corpus contains 10,906 original native outputs from two initialized registry contexts. ++The map context runs its complete structure-identity CRT initializer before key production. ++Its 266 results replace the same input tuples in the base corpus; 66 keys change after initialization. ++Both native contexts complete their original cleanup assertions. Another 9,089 bounded controls retain typed field and constructor behavior. ++Block-backed item providers retain 1,343 actual constructor associations across five native methods. ++They distinguish associated defaults, auxiliary-resolved blocks, empty names, wool/carpet flags, and the sapling registry fallback. ++The 131 subtype transforms all retain their identity auxiliary virtual. ++Potion, arrow and compound keys still require separate providers. ++It covers stacking state and cooking categories; it does not define icons, use actions or equipment behavior. ++ ++### data/bedrock/block_name_keys.json ++ ++Compiled block-definition primary keys from Bedrock 1.26.51.1, build 51061372, protocol 2193. ++The native control executes all 1,379 actual registered definitions after the real element-list initializer. ++It completes all 2,629 static cleanup callbacks without tracked allocations or locks. ++These are definition-owned keys, separate from item factory identity, runtime IDs, auxiliary state resolution, and localization. ++Asset-defined blocks and custom display-name components remain outside this table. +diff --git a/src/main/java/net/raphimc/viabedrock/api/chunk/blockstate/BuiltinBlockNames.java b/src/main/java/net/raphimc/viabedrock/api/chunk/blockstate/BuiltinBlockNames.java +new file mode 100644 +index 000000000..c95819118 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/chunk/blockstate/BuiltinBlockNames.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.chunk.blockstate; ++ ++import com.viaversion.viaversion.libs.gson.JsonParser; ++import net.raphimc.viabedrock.protocol.data.ProtocolConstants; ++ ++import java.io.IOException; ++import java.io.InputStreamReader; ++import java.nio.charset.StandardCharsets; ++import java.util.HashMap; ++import java.util.Map; ++ ++/** Compiled native block-definition keys, before localization or item-specific dispatch. */ ++public final class BuiltinBlockNames { ++ ++ private static final Map NAMES = load(); ++ ++ private BuiltinBlockNames() { } ++ ++ /** Null leaves asset-defined and custom block names unresolved. An empty native key stays empty. */ ++ public static String get(final String identifier) { ++ return NAMES.get(identifier); ++ } ++ ++ private static Map load() { ++ try (var stream = BuiltinBlockNames.class.getResourceAsStream("/assets/viabedrock/data/bedrock/block_name_keys.json")) { ++ if (stream == null) { ++ throw new IllegalStateException("Missing native block name keys"); ++ } ++ final var data = JsonParser.parseReader(new InputStreamReader(stream, StandardCharsets.UTF_8)).getAsJsonObject(); ++ if (data.get("format").getAsInt() != 1 || data.get("protocol").getAsInt() != ProtocolConstants.BEDROCK_PROTOCOL_VERSION) { ++ throw new IllegalStateException("Native block names do not match the target protocol"); ++ } ++ final Map names = new HashMap<>(); ++ data.getAsJsonObject("names").entrySet().forEach(entry -> names.put(entry.getKey(), entry.getValue().getAsString())); ++ return Map.copyOf(names); ++ } catch (final IOException error) { ++ throw new IllegalStateException("Failed to load native block name keys", error); ++ } ++ } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/NativeItemNames.java b/src/main/java/net/raphimc/viabedrock/api/model/NativeItemNames.java +index 7b2cd8d20..3c9c3a460 100644 +--- a/src/main/java/net/raphimc/viabedrock/api/model/NativeItemNames.java ++++ b/src/main/java/net/raphimc/viabedrock/api/model/NativeItemNames.java +@@ -43,6 +43,12 @@ public final class NativeItemNames { + if (custom != null) { + return custom; + } ++ return ordinary(data, ordinaryName, translations, formatter); ++ } ++ ++ /** Definition-owned ordinary text, including its typed userdata append path. */ ++ public static String ordinary(final CompoundTag data, final Supplier ordinaryName, ++ final Function translations, final BiFunction, String> formatter) { + final String name = ordinaryName.get(); + if (data == null || !(data.get("AppendCustomName") instanceof ByteTag append) || append.asByte() == 0) { + return name; +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/NativeItemStacking.java b/src/main/java/net/raphimc/viabedrock/api/model/NativeItemStacking.java +new file mode 100644 +index 000000000..9bd1f5b70 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/model/NativeItemStacking.java +@@ -0,0 +1,85 @@ ++/* ++ * 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.FloatTag; ++import com.viaversion.nbt.tag.ListTag; ++import com.viaversion.nbt.tag.Tag; ++import net.raphimc.viabedrock.protocol.model.BedrockItem; ++import net.raphimc.viabedrock.protocol.model.ItemStackingProperties; ++ ++import java.util.Objects; ++import java.util.Set; ++import java.util.function.Function; ++ ++/** Native occupied-slot merge eligibility, separate from transfer identity and capacity. */ ++public final class NativeItemStacking { ++ ++ public static boolean canMerge(final BedrockItem source, final BedrockItem destination, ++ final ItemStackingProperties properties, ++ final Function auxiliary, ++ final Function> restrictions) { ++ if (source == null || destination == null || source.isEmpty() || destination.isEmpty() ++ || source.identifier() != destination.identifier() || properties == null ++ || !properties.stackable(destination)) { ++ return false; ++ } ++ if (properties.stackedByData()) { ++ final Short sourceAuxiliary = auxiliary.apply(source); ++ final Short destinationAuxiliary = auxiliary.apply(destination); ++ if (sourceAuxiliary == null || !sourceAuxiliary.equals(destinationAuxiliary)) { ++ return false; ++ } ++ } ++ return userDataEqual(source.tag(), destination.tag()) ++ && source.blockingTicks() == destination.blockingTicks() ++ && restrictions.apply(source.canPlace()).equals(restrictions.apply(destination.canPlace())) ++ && restrictions.apply(source.canBreak()).equals(restrictions.apply(destination.canBreak())); ++ } ++ ++ public static boolean userDataEqual(final CompoundTag first, final CompoundTag second) { ++ if (first == null) { ++ return second == null || second.isEmpty(); ++ } ++ return second == null ? first.isEmpty() : tagsEqual(first, second); ++ } ++ ++ /** Native Tag equality uses numeric floating comparison and retains empty-list element types. */ ++ public static boolean tagsEqual(final Tag first, final Tag second) { ++ if (first == null || second == null || first.getClass() != second.getClass()) { ++ return false; ++ } ++ return switch (first) { ++ case FloatTag value -> value.asFloat() == ((FloatTag) second).asFloat(); ++ case DoubleTag value -> value.asDouble() == ((DoubleTag) second).asDouble(); ++ case ListTag values -> { ++ final var other = (ListTag) second; ++ boolean equal = Objects.equals(values.getElementType(), other.getElementType()) && values.size() == other.size(); ++ for (int index = 0; equal && index < values.size(); index++) { ++ equal = tagsEqual(values.get(index), other.get(index)); ++ } ++ yield equal; ++ } ++ case CompoundTag values -> { ++ final var other = (CompoundTag) second; ++ boolean equal = values.keySet().equals(other.keySet()); ++ for (final String key : values.keySet()) { ++ if (!equal || !tagsEqual(values.get(key), other.get(key))) { ++ equal = false; ++ break; ++ } ++ } ++ yield equal; ++ } ++ default -> first.equals(second); ++ }; ++ } ++ ++ private NativeItemStacking() { } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/container/ChestContainer.java b/src/main/java/net/raphimc/viabedrock/api/model/container/ChestContainer.java +index a37f1b071..913255f8f 100644 +--- a/src/main/java/net/raphimc/viabedrock/api/model/container/ChestContainer.java ++++ b/src/main/java/net/raphimc/viabedrock/api/model/container/ChestContainer.java +@@ -26,7 +26,8 @@ import net.raphimc.viabedrock.protocol.data.generated.bedrock.CustomBlockTags; + public class ChestContainer extends Container { + + public ChestContainer(final UserConnection user, final byte containerId, final TextComponent title, final BlockPosition position, final int size) { +- super(user, containerId, ContainerType.CONTAINER, title, position, size, CustomBlockTags.CHEST, CustomBlockTags.TRAPPED_CHEST); ++ super(user, containerId, ContainerType.CONTAINER, title, position, size, CustomBlockTags.CHEST, CustomBlockTags.TRAPPED_CHEST, ++ CustomBlockTags.BARREL, CustomBlockTags.SHULKER_BOX, CustomBlockTags.ENDER_CHEST); + } + + } +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/container/NativeContainerActionSlots.java b/src/main/java/net/raphimc/viabedrock/api/model/container/NativeContainerActionSlots.java +new file mode 100644 +index 000000000..fb22c8666 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/model/container/NativeContainerActionSlots.java +@@ -0,0 +1,75 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.model.container; ++ ++import com.viaversion.viaversion.api.connection.UserConnection; ++import com.viaversion.viaversion.libs.gson.JsonElement; ++import com.viaversion.viaversion.libs.gson.JsonPrimitive; ++import java.util.ArrayList; ++import java.util.HashMap; ++import java.util.Map; ++import java.util.Set; ++import net.raphimc.viabedrock.api.model.NativeItemHoverText; ++import net.raphimc.viabedrock.api.resourcepack.ui.ContainerUi; ++import net.raphimc.viabedrock.protocol.model.BedrockItem; ++import net.raphimc.viabedrock.protocol.storage.InventoryTracker; ++ ++/** Resolve action scope from the current authoritative menu and authored program. */ ++public final class NativeContainerActionSlots { ++ ++ private NativeContainerActionSlots() { } ++ ++ public static Set resolve( ++ final UserConnection user, ++ final Container menu, ++ final int menuId, ++ final ContainerUi.Kind kind, ++ final String title, ++ final ContainerUi program) { ++ final var tracker = user.get(InventoryTracker.class); ++ final var slots = new ArrayList(); ++ for (int index = 0; index < menu.size(); index++) { ++ slots.add(new ContainerUi.Slot(index, presentation(menu.getItem(index)))); ++ } ++ final var inventory = tracker.getInventoryContainer(); ++ for (int index = 0; index < 36; index++) { ++ final int bedrockSlot = index < 27 ? index + 9 : index - 27; ++ slots.add( ++ new ContainerUi.Slot( ++ menu.size() + index, presentation(inventory.getItem(bedrockSlot)))); ++ } ++ final var scene = ++ program.resolve( ++ new ContainerUi.MenuView( ++ menuId, ++ kind, ++ title, ++ slots, ++ presentation(tracker.getHudContainer().getItem(0)), kind.initialData())); ++ if (scene == null || !scene.getAsJsonArray("missing_templates").isEmpty()) { ++ return Set.of(); ++ } ++ final var scope = ContainerUi.actionScope(scene, menu.size()); ++ for (final int slot : scope.referencedSlots()) { ++ final var item = menu.getItem(slot); ++ if (NativeItemHoverText.custom(item.isEmpty(), item.tag()) == null) { ++ return Set.of(); ++ } ++ } ++ return scope.subset() ? scope.slots() : Set.of(); ++ } ++ ++ private static Map presentation(final BedrockItem item) { ++ final Map values = new HashMap<>(); ++ values.put("#inventory_stack_count", new JsonPrimitive(item.isEmpty() ? 0 : item.amount())); ++ final String hover = NativeItemHoverText.custom(item.isEmpty(), item.tag()); ++ if (hover != null) { ++ values.put("#hover_text", new JsonPrimitive(hover)); ++ } ++ return values; ++ } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/container/NativeContainerLegacyActions.java b/src/main/java/net/raphimc/viabedrock/api/model/container/NativeContainerLegacyActions.java +new file mode 100644 +index 000000000..54ee49d7f +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/model/container/NativeContainerLegacyActions.java +@@ -0,0 +1,130 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.model.container; ++ ++import com.viaversion.viaversion.api.connection.UserConnection; ++import com.viaversion.viaversion.api.protocol.packet.PacketWrapper; ++import com.viaversion.viaversion.libs.gson.JsonParser; ++import java.util.List; ++import java.util.Set; ++import java.util.function.BiPredicate; ++import java.util.function.Consumer; ++import java.util.function.ToIntFunction; ++import net.raphimc.viabedrock.api.model.NativeContainerMessage; ++import net.raphimc.viabedrock.api.resourcepack.ui.ContainerUi; ++import net.raphimc.viabedrock.api.util.PacketFactory; ++import net.raphimc.viabedrock.protocol.BedrockProtocol; ++import net.raphimc.viabedrock.protocol.ServerboundBedrockPackets; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.ComplexInventoryTransaction_Type; ++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.model.BedrockItem; ++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.GameSessionStorage; ++import net.raphimc.viabedrock.protocol.storage.InventoryTracker; ++import net.raphimc.viabedrock.protocol.storage.NativeContainerUiState; ++ ++/** Normal inventory transactions for a client-authoritative authored storage presentation. */ ++public final class NativeContainerLegacyActions implements NativeContainerUiState.Actions { ++ ++ private final UserConnection user; ++ private final Container menu; ++ private final int menuId; ++ private final ContainerUi.Kind kind; ++ private final String title; ++ private final ContainerUi program; ++ ++ private NativeContainerLegacyActions(final UserConnection user, final Container menu, ++ final NativeContainerMessage message) { ++ this.user = user; ++ this.menu = menu; ++ this.menuId = message.menuId(); ++ this.kind = message.kind(); ++ this.title = message.conditionTitle(); ++ this.program = ContainerUi.fromProgram(JsonParser.parseString(message.program()).getAsJsonObject()); ++ } ++ ++ public static NativeContainerLegacyActions create(final UserConnection user, final NativeContainerMessage message) { ++ final Container menu = user.get(InventoryTracker.class).getCurrentContainer(); ++ if (user.get(GameSessionStorage.class).isInventoryServerAuthoritative() ++ || !NativeStorageContainers.matches(menu, message.kind()) ++ || Byte.toUnsignedInt(menu.javaContainerId()) != message.menuId()) { ++ return null; ++ } ++ return new NativeContainerLegacyActions(user, menu, message); ++ } ++ ++ @Override ++ public boolean current() { ++ final var inventory = this.user.get(InventoryTracker.class); ++ return !this.user.get(GameSessionStorage.class).isInventoryServerAuthoritative() ++ && inventory.getCurrentContainer() == this.menu ++ && inventory.getPendingCloseContainer() == null; ++ } ++ ++ private Set actionSlots() { ++ return this.current() ? NativeContainerActionSlots.resolve(this.user, this.menu, this.menuId, ++ this.kind, this.title, this.program) : Set.of(); ++ } ++ ++ @Override ++ public boolean hasActions() { ++ return !this.actionSlots().isEmpty(); ++ } ++ ++ @Override ++ public boolean autoPlaceOne(final int revision, final int sourceSlot) { ++ final boolean sent = this.autoPlaceOne(sourceSlot, ++ this.user.get(ItemRewriter.class)::maxStackSize, ++ this.user.get(ItemRewriter.class)::canMerge, transaction -> { ++ final var packet = PacketWrapper.create(ServerboundBedrockPackets.INVENTORY_TRANSACTION, this.user); ++ packet.write(this.user.get(InventoryTransactionRewriter.class).getInventoryTransactionType(), transaction); ++ packet.sendToServer(BedrockProtocol.class); ++ }); ++ if (sent) { ++ // Client-authoritative transactions have no ItemStackResponse to advance the Java view. ++ PacketFactory.sendJavaContainerSetContent(this.user, this.menu); ++ PacketFactory.sendJavaContainerSetContent(this.user, ++ this.user.get(InventoryTracker.class).getInventoryContainer()); ++ } ++ return sent; ++ } ++ ++ boolean autoPlaceOne(final int sourceSlot, final ToIntFunction maximumStackSize, ++ final BiPredicate canMerge, ++ final Consumer sender) { ++ if (!this.actionSlots().contains(sourceSlot) || sourceSlot < 0 || sourceSlot >= this.menu.size()) { ++ return false; ++ } ++ final var inventory = this.user.get(InventoryTracker.class).getInventoryContainer(); ++ final BedrockItem source = this.menu.getItem(sourceSlot); ++ final var plan = NativeContainerTransfer.plan(source, inventory.getItems(), ++ maximumStackSize.applyAsInt(source), canMerge, slot -> true); ++ if (plan == null) { ++ return false; ++ } ++ final var transaction = new BedrockInventoryTransaction(0, List.of(), List.of( ++ new InventoryActionData(source(this.menu), sourceSlot, plan.sourceBefore(), plan.sourceAfter()), ++ new InventoryActionData(source(inventory), plan.destinationSlot(), plan.destinationBefore(), plan.destinationAfter())), ++ ComplexInventoryTransaction_Type.NormalTransaction, new InventoryTransactionData.NormalTransactionData()); ++ // Keep both tracked slots unchanged if serialization or transport fails. ++ sender.accept(transaction); ++ this.menu.setItem(sourceSlot, plan.sourceAfter()); ++ inventory.setItem(plan.destinationSlot(), plan.destinationAfter()); ++ return true; ++ } ++ ++ private static InventorySource source(final Container container) { ++ return new InventorySource(InventorySourceType.Container_Inventory, ++ container.containerId(), InventorySourceFlags.No_Flag); ++ } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/container/NativeContainerTransfer.java b/src/main/java/net/raphimc/viabedrock/api/model/container/NativeContainerTransfer.java +new file mode 100644 +index 000000000..6c6f6c7fe +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/model/container/NativeContainerTransfer.java +@@ -0,0 +1,73 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.model.container; ++ ++import com.viaversion.nbt.tag.ByteTag; ++import java.util.function.BiPredicate; ++import java.util.function.IntPredicate; ++import net.raphimc.viabedrock.protocol.model.BedrockItem; ++ ++/** The ordinary container_items count-one action, independent of inventory authority. */ ++public final class NativeContainerTransfer { ++ ++ public static Plan plan(final BedrockItem source, final BedrockItem[] inventory, ++ final int maximumStackSize, final BiPredicate canMerge, ++ final IntPredicate destinationAllowed) { ++ if (inventory.length != 36 || maximumStackSize < 1 || maximumStackSize > 255) { ++ throw new IllegalArgumentException("Unsupported native inventory provider"); ++ } ++ if (source.isEmpty()) { ++ return null; ++ } ++ requireUnlocked(source); ++ // Native combined provider order is hotbar 0..8, then inventory 9..35. ++ for (final boolean merge : new boolean[] {true, false}) { ++ for (int slot = 0; slot < inventory.length; slot++) { ++ final BedrockItem destination = inventory[slot]; ++ if (!destinationAllowed.test(slot)) { ++ continue; ++ } ++ if (merge ? destination.isEmpty() || !canMerge.test(source, destination) ++ || destination.amount() >= maximumStackSize : !destination.isEmpty()) { ++ continue; ++ } ++ requireUnlocked(destination); ++ return new Plan(slot, source.copy(), destination.copy()); ++ } ++ } ++ return null; ++ } ++ ++ private static void requireUnlocked(final BedrockItem item) { ++ if (item.tag() != null && item.tag().get("minecraft:item_lock") instanceof ByteTag value ++ && value.asByte() != 0) { ++ throw new IllegalArgumentException("Native locked-item transfer permission is not supported"); ++ } ++ } ++ ++ private NativeContainerTransfer() { } ++ ++ public record Plan(int destinationSlot, BedrockItem sourceBefore, BedrockItem destinationBefore) { ++ ++ public BedrockItem sourceAfter() { ++ if (this.sourceBefore.amount() == 1) { ++ return BedrockItem.empty(); ++ } ++ final BedrockItem result = this.sourceBefore.copy(); ++ result.setAmount(result.amount() - 1); ++ return result; ++ } ++ ++ public BedrockItem destinationAfter() { ++ final BedrockItem result = this.destinationBefore.isEmpty() ++ ? this.sourceBefore.copy() : this.destinationBefore.copy(); ++ result.setAmount(this.destinationBefore.amount() + 1); ++ return result; ++ } ++ ++ } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/container/NativeItemStackMatcher.java b/src/main/java/net/raphimc/viabedrock/api/model/container/NativeItemStackMatcher.java +new file mode 100644 +index 000000000..02f107b88 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/model/container/NativeItemStackMatcher.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.container; ++ ++import net.raphimc.viabedrock.protocol.model.BedrockItem; ++import net.raphimc.viabedrock.api.model.NativeItemStacking; ++ ++import java.util.Set; ++import java.util.function.Function; ++ ++/** Native item queries compare network-loaded stacks in one stable connection registry. */ ++public final class NativeItemStackMatcher { ++ ++ public static boolean matches(final BedrockItem query, final BedrockItem candidate, ++ final Function blocks, final Function> restrictions) { ++ if (query == null || candidate == null || query.isEmpty() || candidate.isEmpty() ++ || query.identifier() != candidate.identifier() ++ || query.data() != candidate.data() && query.data() != Short.MAX_VALUE && candidate.data() != Short.MAX_VALUE) { ++ return false; ++ } ++ if (query.hasBlockState()) { ++ final Object block = blocks.apply(query.blockRuntimeId()); ++ if (!candidate.hasBlockState() || block == null || block != blocks.apply(candidate.blockRuntimeId())) { ++ return false; ++ } ++ } ++ // The full network userdata also determines chargedItem construction in the same registry. ++ // Do not use this comparison for native stacks whose child has been mutated independently. ++ return NativeItemStacking.userDataEqual(query.tag(), candidate.tag()) ++ && query.blockingTicks() == candidate.blockingTicks() ++ && restrictions.apply(query.canPlace()).equals(restrictions.apply(candidate.canPlace())) ++ && restrictions.apply(query.canBreak()).equals(restrictions.apply(candidate.canBreak())); ++ } ++ ++ private NativeItemStackMatcher() { ++ } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/container/NativeStorageContainers.java b/src/main/java/net/raphimc/viabedrock/api/model/container/NativeStorageContainers.java +new file mode 100644 +index 000000000..dcc42e653 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/model/container/NativeStorageContainers.java +@@ -0,0 +1,49 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.model.container; ++ ++import net.raphimc.viabedrock.api.model.container.block.Generic3x3Container; ++import net.raphimc.viabedrock.api.model.container.block.HopperContainer; ++import net.raphimc.viabedrock.api.resourcepack.ui.ContainerUi; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ContainerType; ++ ++/** Ordinary external storage providers with direct container slot identities. */ ++public final class NativeStorageContainers { ++ ++ public static boolean supported(final Container container) { ++ if (container == null) { ++ return false; ++ } ++ // Actor-backed hopper opens can retain the wire's generic CONTAINER type. ++ // Their resolved provider, not that raw type alone, owns the five slots. ++ return switch (container.type()) { ++ case CONTAINER -> container.getClass() == ChestContainer.class ++ && (container.size() == 27 || container.size() == 54) ++ || container.getClass() == HopperContainer.class && container.size() == 5; ++ case MINECART_CHEST, CHEST_BOAT -> container.getClass() == ChestContainer.class && container.size() == 27; ++ case HOPPER, MINECART_HOPPER -> container.getClass() == HopperContainer.class && container.size() == 5; ++ case DISPENSER, DROPPER -> container.getClass() == Generic3x3Container.class && container.size() == 9; ++ default -> false; ++ }; ++ } ++ ++ public static boolean matches(final Container container, final ContainerUi.Kind kind) { ++ if (!supported(container) || kind == null || container.size() != kind.containerSlots()) { ++ return false; ++ } ++ return switch (kind) { ++ case CHEST_3_ROWS, CHEST_6_ROWS, ENDER_CHEST, SHULKER_BOX, BARREL -> container.getClass() == ChestContainer.class; ++ case HOPPER -> container.getClass() == HopperContainer.class; ++ case DISPENSER -> container.type() == ContainerType.DISPENSER; ++ case DROPPER -> container.type() == ContainerType.DROPPER; ++ case BREWING_STAND -> false; ++ }; ++ } ++ ++ private NativeStorageContainers() { ++ } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/container/block/Generic3x3Container.java b/src/main/java/net/raphimc/viabedrock/api/model/container/block/Generic3x3Container.java +new file mode 100644 +index 000000000..1d4e45f17 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/model/container/block/Generic3x3Container.java +@@ -0,0 +1,47 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2023-2025 RK_01/RaphiMC and contributors ++ * ++ * This program is free software: you can redistribute it and/or modify ++ * it under the terms of the GNU General Public License as published by ++ * the Free Software Foundation, either version 3 of the License, or ++ * (at your option) any later version. ++ * ++ * This program is distributed in the hope that it will be useful, ++ * but WITHOUT ANY WARRANTY; without even the implied warranty of ++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++ * GNU General Public License for more details. ++ * ++ * You should have received a copy of the GNU General Public License ++ * along with this program. If not, see . ++ */ ++package net.raphimc.viabedrock.api.model.container.block; ++ ++import com.viaversion.viaversion.api.connection.UserConnection; ++import com.viaversion.viaversion.api.minecraft.BlockPosition; ++import com.viaversion.viaversion.libs.mcstructs.text.TextComponent; ++ ++import net.raphimc.viabedrock.api.model.container.Container; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ContainerType; ++import net.raphimc.viabedrock.protocol.data.generated.bedrock.CustomBlockTags; ++ ++public class Generic3x3Container extends Container { ++ ++ public Generic3x3Container( ++ final UserConnection user, ++ final byte containerId, ++ final ContainerType type, ++ final TextComponent title, ++ final BlockPosition position) { ++ super( ++ user, ++ containerId, ++ type, ++ title, ++ position, ++ 9, ++ CustomBlockTags.DISPENSER, ++ CustomBlockTags.DROPPER); ++ } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/container/block/HopperContainer.java b/src/main/java/net/raphimc/viabedrock/api/model/container/block/HopperContainer.java +new file mode 100644 +index 000000000..f11d7be62 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/model/container/block/HopperContainer.java +@@ -0,0 +1,47 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2023-2026 RK_01/RaphiMC and contributors ++ * ++ * This program is free software: you can redistribute it and/or modify ++ * it under the terms of the GNU General Public License as published by ++ * the Free Software Foundation, either version 3 of the License, or ++ * (at your option) any later version. ++ * ++ * This program is distributed in the hope that it will be useful, ++ * but WITHOUT ANY WARRANTY; without even the implied warranty of ++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++ * GNU General Public License for more details. ++ * ++ * You should have received a copy of the GNU General Public License ++ * along with this program. If not, see . ++ */ ++package net.raphimc.viabedrock.api.model.container.block; ++ ++import com.viaversion.viaversion.api.connection.UserConnection; ++import com.viaversion.viaversion.api.minecraft.BlockPosition; ++import com.viaversion.viaversion.libs.mcstructs.text.TextComponent; ++ ++import net.raphimc.viabedrock.api.model.container.Container; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ContainerType; ++import net.raphimc.viabedrock.protocol.data.generated.bedrock.CustomBlockTags; ++ ++public class HopperContainer extends Container { ++ ++ public HopperContainer( ++ final UserConnection user, ++ final byte containerId, ++ final TextComponent title, ++ final BlockPosition position) { ++ this(user, containerId, ContainerType.HOPPER, title, position); ++ } ++ ++ public HopperContainer( ++ final UserConnection user, ++ final byte containerId, ++ final ContainerType type, ++ final TextComponent title, ++ final BlockPosition position) { ++ super(user, containerId, type, title, position, 5, CustomBlockTags.HOPPER); ++ } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/definition/ItemDefinitions.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/definition/ItemDefinitions.java +index 8a5a49326..f8282f25a 100644 +--- a/src/main/java/net/raphimc/viabedrock/api/resourcepack/definition/ItemDefinitions.java ++++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/definition/ItemDefinitions.java +@@ -85,11 +85,6 @@ public class ItemDefinitions { + } + } + } +- if (components.get("minecraft:display_name") instanceof CompoundTag displayName) { +- if (displayName.get("value") instanceof StringTag value) { +- itemDefinition.displayNameComponent = value.getValue(); +- } +- } + } + this.items.put(identifier, itemDefinition); + } diff --git a/src/main/java/net/raphimc/viabedrock/protocol/BedrockProtocol.java b/src/main/java/net/raphimc/viabedrock/protocol/BedrockProtocol.java index 4423aa368..dae6ee85a 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/BedrockProtocol.java @@ -432,12 +1415,931 @@ index 4ec82372b..b680d5a83 100644 + if (id < 0) { + throw new IllegalArgumentException("Unknown Java menu: " + identifier); + } -+ return id; ++ return id; ++ } ++ + public CompoundTag getJavaTags() { + return this.javaTags; + } +diff --git a/src/main/java/net/raphimc/viabedrock/protocol/model/BaseGameVersion.java b/src/main/java/net/raphimc/viabedrock/protocol/model/BaseGameVersion.java +new file mode 100644 +index 000000000..2979ba81d +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/protocol/model/BaseGameVersion.java +@@ -0,0 +1,53 @@ ++/* ++ * 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 java.util.regex.Pattern; ++ ++/** Native world-version parsing strips suffixes before comparing feature gates. */ ++public record BaseGameVersion(int major, int minor, int patch, boolean valid, boolean wildcard) { ++ ++ public static final BaseGameVersion INVALID = new BaseGameVersion(0, 0, 0, false, false); ++ private static final Pattern VERSION = Pattern.compile("^(0|[1-9]\\d{0,4})(\\.(0|[1-9]\\d{0,4})(\\.(0|[1-9]\\d{0,4})(?:\\-((?:(?:(?:(?:0[0-9]*[A-Za-z-])|[1-9A-Za-z-])[0-9A-Za-z-]*)|0)(?:\\.(?:(?:(?:(?:0[0-9]*[A-Za-z-])|[1-9A-Za-z-])[0-9A-Za-z-]*)|0))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?)?)?"); ++ ++ public BaseGameVersion { ++ if (major < 0 || major > 0xFFFF || minor < 0 || minor > 0xFFFF || patch < 0 || patch > 0xFFFF) { ++ throw new IllegalArgumentException("Native version components must fit unsigned shorts"); ++ } ++ } ++ ++ public static BaseGameVersion parse(final String value) { ++ if (value.equals("*")) { ++ return new BaseGameVersion(0, 0, 0, true, true); ++ } ++ if (value.equals("beta")) { ++ return new BaseGameVersion(9999, 9999, 9999, true, false); ++ } ++ final var matcher = VERSION.matcher(value); ++ if (!matcher.matches()) { ++ return INVALID; ++ } ++ final int major = Integer.parseInt(matcher.group(1)); ++ final int minor = matcher.group(3) == null ? 0 : Integer.parseInt(matcher.group(3)); ++ final int patch = matcher.group(5) == null ? 0 : Integer.parseInt(matcher.group(5)); ++ return major > 0xFFFF || minor > 0xFFFF || patch > 0xFFFF ++ ? INVALID : new BaseGameVersion(major, minor, patch, true, false); ++ } ++ ++ public boolean isLowerThan(final BaseGameVersion other) { ++ if (this.wildcard) { ++ return false; ++ } ++ if (other.wildcard) { ++ return true; ++ } ++ if (this.major != other.major) { ++ return this.major < other.major; ++ } ++ return this.minor != other.minor ? this.minor < other.minor : this.patch < other.patch; ++ } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/protocol/model/BedrockItem.java b/src/main/java/net/raphimc/viabedrock/protocol/model/BedrockItem.java +index a17eab494..20cf478fb 100644 +--- a/src/main/java/net/raphimc/viabedrock/protocol/model/BedrockItem.java ++++ b/src/main/java/net/raphimc/viabedrock/protocol/model/BedrockItem.java +@@ -191,7 +191,7 @@ public class BedrockItem implements Item { + + @Override + public boolean isEmpty() { +- return this.id == 0 || this.id == -1 || this.amount <= 0; ++ return this.id == 0 || this.id == -1 || this.amount == 0; + } + + public boolean isDifferent(final BedrockItem o) { +diff --git a/src/main/java/net/raphimc/viabedrock/protocol/model/BuiltinItemFactories.java b/src/main/java/net/raphimc/viabedrock/protocol/model/BuiltinItemFactories.java +new file mode 100644 +index 000000000..928f2175b +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/protocol/model/BuiltinItemFactories.java +@@ -0,0 +1,89 @@ ++/* ++ * 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.viaversion.libs.gson.JsonParser; ++import net.raphimc.viabedrock.protocol.data.ProtocolConstants; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ItemVersion; ++ ++import java.io.IOException; ++import java.io.InputStreamReader; ++import java.nio.charset.StandardCharsets; ++import java.util.HashMap; ++import java.util.Map; ++ ++/** Constructor-owned native factories, independent of advertised runtime-ID assignments. */ ++final class BuiltinItemFactories { ++ ++ private static final Map FACTORIES = load(); ++ ++ static Definition get(final String identifier) { ++ return FACTORIES.get(identifier); ++ } ++ ++ private static Map load() { ++ final String path = "/assets/viabedrock/data/bedrock/item_factories.json"; ++ try (var stream = BuiltinItemFactories.class.getResourceAsStream(path)) { ++ if (stream == null) { ++ throw new IllegalStateException("Missing native item factories"); ++ } ++ final var data = JsonParser.parseReader(new InputStreamReader(stream, StandardCharsets.UTF_8)).getAsJsonObject(); ++ if (data.get("format").getAsInt() != 1 || data.get("protocol").getAsInt() != ProtocolConstants.BEDROCK_PROTOCOL_VERSION) { ++ throw new IllegalStateException("Native item factories do not match the target protocol"); ++ } ++ final var componentDescriptions = data.getAsJsonObject("componentDescriptions"); ++ final var ordinaryNames = data.getAsJsonObject("ordinaryNames"); ++ final Map names = new HashMap<>(); ++ ordinaryNames.entrySet().forEach(entry -> names.put(entry.getKey(), new BuiltinItemName.Fixed(entry.getValue().getAsString()))); ++ data.getAsJsonObject("descriptorNames").entrySet().forEach(entry -> { ++ final var definition = entry.getValue().getAsJsonObject(); ++ names.put(entry.getKey(), new BuiltinItemName.Descriptor(BuiltinItemName.Kind.valueOf(definition.get("kind").getAsString()), ++ definition.get("description").getAsString(), definition.get("variant").getAsInt())); ++ }); ++ data.getAsJsonObject("blockNames").entrySet().forEach(entry -> { ++ final var definition = entry.getValue().getAsJsonObject(); ++ names.put(entry.getKey(), new BuiltinItemName.Block(BuiltinItemName.BlockKind.valueOf(definition.get("kind").getAsString()), ++ definition.get("block").getAsString(), definition.has("description") ? definition.get("description").getAsString() : null, ++ definition.has("fallback") ? definition.get("fallback").getAsString() : null)); ++ }); ++ data.getAsJsonObject("potionNames").entrySet().forEach(entry -> ++ names.put(entry.getKey(), new BuiltinItemName.Potion(entry.getValue().getAsInt()))); ++ data.getAsJsonObject("arrowNames").entrySet().forEach(entry -> ++ names.put(entry.getKey(), new BuiltinItemName.Arrow(entry.getValue().getAsString()))); ++ data.getAsJsonArray("compoundNames").forEach(entry -> ++ names.put(entry.getAsString(), new BuiltinItemName.Compound())); ++ final Map factories = new HashMap<>(); ++ for (final var element : data.getAsJsonArray("factories")) { ++ final var factory = element.getAsJsonObject(); ++ final ItemVersion version = ItemVersion.getByValue(factory.get("version").getAsInt()); ++ final int maximum = factory.get("maximum").getAsInt(); ++ final boolean stackedByData = factory.get("stackedByData").getAsBoolean(); ++ final ItemStackingProperties stacking = switch (version) { ++ case DataDriven -> new ComponentItemStackingProperties(maximum, null, stackedByData, factory.get("damageable").getAsBoolean()); ++ case Legacy -> new LegacyItemStackingProperties(maximum, stackedByData, factory.get("maximumDamage").getAsShort(), ++ factory.has("fixedMaximum") ? factory.get("fixedMaximum").getAsInt() : null, ++ factory.has("forcedSubtype") && factory.get("forcedSubtype").getAsBoolean()); ++ default -> throw new IllegalStateException("Invalid native item factory version"); ++ }; ++ for (final var name : factory.getAsJsonArray("identifiers")) { ++ final String identifier = name.getAsString(); ++ final String description = componentDescriptions.has(identifier) ? componentDescriptions.get(identifier).getAsString() : null; ++ if (factories.putIfAbsent(identifier, new Definition(version, stacking, description, names.get(identifier))) != null) { ++ throw new IllegalStateException("Duplicate native item factory: " + identifier); ++ } ++ } ++ } ++ return Map.copyOf(factories); ++ } catch (final IOException error) { ++ throw new IllegalStateException("Failed to load native item factories", error); ++ } ++ } ++ ++ private BuiltinItemFactories() { } ++ ++ record Definition(ItemVersion version, ItemStackingProperties stacking, String componentDescription, BuiltinItemName ordinaryName) { } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/protocol/model/BuiltinItemName.java b/src/main/java/net/raphimc/viabedrock/protocol/model/BuiltinItemName.java +new file mode 100644 +index 000000000..60f61d7be +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/protocol/model/BuiltinItemName.java +@@ -0,0 +1,245 @@ ++/* ++ * 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 com.viaversion.nbt.tag.IntTag; ++import com.viaversion.nbt.tag.StringTag; ++ ++import java.util.List; ++import java.util.Map; ++import java.util.Set; ++ ++/** Constructor-owned name dispatch over a concrete descriptor and its unfiltered userdata. */ ++sealed interface BuiltinItemName permits BuiltinItemName.Fixed, BuiltinItemName.Descriptor, BuiltinItemName.Block, BuiltinItemName.Potion, BuiltinItemName.Arrow, BuiltinItemName.Compound { ++ ++ String key(String identifier, Short auxiliaryData, CompoundTag data, ItemNameContext context); ++ ++ record Fixed(String value) implements BuiltinItemName { ++ @Override ++ public String key(final String identifier, final Short auxiliaryData, final CompoundTag data, final ItemNameContext context) { ++ return this.value; ++ } ++ } ++ ++ record Descriptor(Kind kind, String description, int variant) implements BuiltinItemName { ++ ++ private static final List COLORS = List.of( ++ "black", "red", "green", "brown", "blue", "purple", "cyan", "silver", "gray", "pink", "lime", "yellow", "lightBlue", "magenta", "orange", "white"); ++ private static final List BED_COLORS = List.of( ++ "white", "orange", "magenta", "lightBlue", "yellow", "lime", "pink", "gray", "silver", "cyan", "purple", "blue", "brown", "green", "red", "black"); ++ private static final List EXTENDED_COLORS = List.of(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 0, 3, 4, 15); ++ private static final List MEDICINES = List.of("blindness", "nausea", "poison", "weakness"); ++ private static final Set MODERN_DYES = Set.of("minecraft:black_dye", "minecraft:brown_dye", "minecraft:blue_dye", "minecraft:white_dye"); ++ private static final Map EXPLORATION_MAPS = Map.ofEntries( ++ Map.entry(3, "monument"), Map.entry(4, "mansion"), Map.entry(5, "buried_treasure"), ++ Map.entry(7, "village_snowy"), Map.entry(8, "village_taiga"), Map.entry(9, "village_plains"), ++ Map.entry(10, "village_savanna"), Map.entry(11, "village_desert"), Map.entry(12, "jungle_temple"), ++ Map.entry(13, "swamp_hut"), Map.entry(14, "trial_chambers"), Map.entry(15, "abandoned_camp_bamboo_jungle"), ++ Map.entry(16, "abandoned_camp_birch_forest"), Map.entry(17, "abandoned_camp_cherry_grove"), ++ Map.entry(18, "abandoned_camp_dappled_forest"), Map.entry(19, "abandoned_camp_extreme_hills_plus_trees"), ++ Map.entry(20, "abandoned_camp_flower_forest"), Map.entry(21, "abandoned_camp_pale_garden"), ++ Map.entry(22, "abandoned_camp_swampland"), Map.entry(23, "buried_ancient_city"), ++ Map.entry(24, "buried_mineshaft"), Map.entry(25, "desert_pyramid"), Map.entry(26, "warm_ocean_ruins")); ++ ++ @Override ++ public String key(final String identifier, final Short auxiliaryData, final CompoundTag data, final ItemNameContext context) { ++ return switch (this.kind) { ++ case DYE -> "item.dye." + COLORS.get(this.variant) ++ + (auxiliaryData != null && MODERN_DYES.contains(identifier) ? "_new.name" : ".name"); ++ case BUCKET -> bucket(this.variant, data); ++ case WRITTEN_BOOK -> this.writtenBook(data); ++ case EMPTY_MAP -> auxiliaryData != null && auxiliaryData == 2 ? "item.emptyLocatorMap.name" : "item.emptyMap.name"; ++ case FILLED_MAP -> this.filledMap(auxiliaryData, data); ++ case BED -> { ++ final int color = auxiliaryData == null ? 16 : Short.toUnsignedInt(auxiliaryData); ++ yield this.description + '.' + BED_COLORS.get(color < 16 ? color : 10) + ".name"; ++ } ++ case BALLOON -> this.colored(auxiliaryData == null ? -1 : 15 - auxiliaryData); ++ case BANNER -> { ++ final Integer type = integer(data, "Type"); ++ yield type != null && type != 0 ? type == 1 ? this.description + ".illager_captain.name" : "" ++ : this.colored(auxiliaryData == null ? -1 : Short.toUnsignedInt(auxiliaryData)); ++ } ++ case SHIELD -> { ++ final Integer color = integer(data, "Base"); ++ yield data != null && data.contains("Base") ? this.colored(color == null ? 0 : color) : this.description + ".name"; ++ } ++ case MEDICINE -> { ++ final int value = auxiliaryData == null ? 255 : auxiliaryData & 255; ++ yield this.description + '.' + (value < MEDICINES.size() ? MEDICINES.get(value) : "") + ".name"; ++ } ++ case GLOW_STICK, SPARKLER -> { ++ final int color = (auxiliaryData == null ? Short.MAX_VALUE : auxiliaryData) & 31; ++ yield (this.kind == Kind.SPARKLER ? "item.sparkler." : "item.glow_stick.") + COLORS.get(color < 16 ? color : 5) + ".name"; ++ } ++ case BOARD -> "tile.chalkboard." + switch (auxiliaryData == null ? 0 : auxiliaryData & 255) { ++ case 1 -> "twoByOne"; ++ case 2 -> "threeByTwo"; ++ default -> "oneByOne"; ++ } + ".name"; ++ }; ++ } ++ ++ private String colored(final int color) { ++ return this.description + '.' + COLORS.get(color >= 0 && color < EXTENDED_COLORS.size() ? EXTENDED_COLORS.get(color) : 5) + ".name"; ++ } ++ ++ private String writtenBook(final CompoundTag data) { ++ final String title = (this.variant & 255) == 1 && data != null && data.contains("filteredTitle") ++ ? string(data, "filteredTitle") : string(data, "title"); ++ return title.isEmpty() ? this.description + ".name" : title; ++ } ++ ++ private String filledMap(final Short auxiliaryData, final CompoundTag data) { ++ final String exploration = EXPLORATION_MAPS.get(auxiliaryData == null ? (int) Short.MAX_VALUE : auxiliaryData.intValue()); ++ if (exploration != null) { ++ return "item.map.exploration." + exploration + ".name"; ++ } ++ final Integer number = integer(data, "map_name_index"); ++ return data == null ? this.description + ".name" : '%' + this.description + ".name " + (number == null ? 0 : number); ++ } ++ ++ private static String bucket(final int definitionVariant, final CompoundTag data) { ++ final int variant = definitionVariant & 0xFFFF; ++ final String name; ++ if (!string(data, "CustomName").isEmpty() || (variant == 4 || variant == 12) && !string(data, "BodyID").isEmpty() ++ || variant == 4 && !string(data, "GroupName").isEmpty()) { ++ name = "bucketCustomFish"; ++ } else { ++ name = switch (variant) { ++ case 1 -> "milk"; ++ case 2 -> "bucketFish"; ++ case 3 -> "bucketSalmon"; ++ case 4 -> "bucketTropical"; ++ case 5 -> "bucketPuffer"; ++ case 8 -> "bucketWater"; ++ case 10 -> "bucketLava"; ++ case 11 -> "bucketPowderSnow"; ++ case 12 -> "bucketAxolotl"; ++ case 13 -> "bucketTadpole"; ++ case 14 -> "bucketSulfurCube"; ++ default -> "bucket"; ++ }; ++ } ++ return "item." + name + ".name"; ++ } ++ ++ private static String string(final CompoundTag data, final String name) { ++ return data != null && data.get(name) instanceof StringTag value ? value.getValue() : ""; ++ } ++ ++ private static Integer integer(final CompoundTag data, final String name) { ++ return data != null && data.get(name) instanceof IntTag value ? value.asInt() : null; ++ } ++ } ++ ++ record Block(BlockKind kind, String block, String description, String fallback) implements BuiltinItemName { ++ ++ @Override ++ public String key(final String identifier, final Short auxiliaryData, final CompoundTag data, final ItemNameContext context) { ++ final var blockNames = context.blockNames(); ++ if (this.kind == BlockKind.WOOL) { ++ final String name = auxiliaryData == null || auxiliaryData == Short.MAX_VALUE || this.block == null ++ ? null : blockNames.apply(this.block, null); ++ return name == null ? this.fallback : name; ++ } ++ String name = this.block == null || this.kind == BlockKind.SAPLING && auxiliaryData == null ++ ? null : blockNames.apply(this.block, auxiliaryData == null ? Short.MAX_VALUE : auxiliaryData); ++ if (this.kind == BlockKind.SAPLING) { ++ return name == null ? blockNames.apply("minecraft:sapling", null) : name.isEmpty() ? this.description + ".name" : name; ++ } ++ if (name == null && this.block != null) { ++ name = blockNames.apply(this.block, null); ++ } ++ if (name == null) { ++ name = ""; ++ } ++ return this.kind == BlockKind.LEAVES && name.isEmpty() ? this.description + ".name" : name; ++ } ++ } ++ ++ record Potion(int mode) implements BuiltinItemName { ++ ++ public Potion { ++ if (mode < 0 || mode > 2) { ++ throw new IllegalArgumentException("Invalid native potion item mode"); ++ } ++ } ++ ++ @Override ++ public String key(final String identifier, final Short auxiliaryData, final CompoundTag data, final ItemNameContext context) { ++ final var definition = BuiltinPotionNames.get(auxiliaryData, context.baseGameVersion().get()); ++ final String description = definition.description(); ++ final String effect = definition.effect(); ++ final String content = !description.isEmpty() ++ ? "%potion.prefix." + description + (effect == null ? " %item.potion.name" : " %potion." + effect + ".postfix") ++ : effect == null ? "%item.emptyPotion.name" : "%potion." + effect + ".postfix"; ++ final String phrase = switch (this.mode) { ++ case 1 -> "%potion.prefix.grenade "; ++ case 2 -> "%potion.prefix.linger "; ++ default -> ""; ++ } + content; ++ if (!phrase.replace("%", "").equals(context.translate().apply(phrase))) { ++ return phrase; ++ } ++ final String fallback = !description.isEmpty() ? description : effect == null ? "emptyPotion" : effect; ++ return "%potion." + fallback + switch (this.mode) { ++ case 1 -> ".splash.name"; ++ case 2 -> ".linger.name"; ++ default -> ".name"; ++ }; ++ } ++ } ++ ++ record Arrow(String description) implements BuiltinItemName { ++ ++ @Override ++ public String key(final String identifier, final Short auxiliaryData, final CompoundTag data, final ItemNameContext context) { ++ if (auxiliaryData != null && auxiliaryData == 0) { ++ return this.description + ".name"; ++ } ++ final short potion = (short) ((auxiliaryData == null ? Short.MAX_VALUE : auxiliaryData) - 1); ++ final var definition = BuiltinPotionNames.get(potion, context.baseGameVersion().get()); ++ final String effect = !definition.description().isEmpty() ? definition.description() ++ : definition.effect() == null ? "empty" : definition.effect(); ++ return "tipped_arrow.effect." + effect; ++ } ++ } ++ ++ record Compound() implements BuiltinItemName { ++ ++ private static final List COMPOUNDS = List.of( ++ "salt", "sodiumoxide", "sodiumhydroxide", ++ "magnesiumnitrate", "ironsulfide", "lithiumhydride", ++ "sodiumhydride", "calciumbromide", "magnesiumoxide", ++ "sodiumacetate", "luminol", "charcoal", ++ "sugar", "aluminumoxide", "borontrioxide", ++ "soap", "polyethylene", "garbage", ++ "magnesiumsalts", "sulfate", "bariumsulfate", ++ "potassiumchloride", "mercuricchloride", "ceriumchloride", ++ "tungstenchloride", "calciumchloride", "water", ++ "glue", "hypochlorite", "crudeoil", ++ "latex", "potassiumiodide", "sodiumfluoride", ++ "benzene", "ink", "hydrogenperoxide", ++ "ammonia", "sodiumhypochlorite"); ++ ++ @Override ++ public String key(final String identifier, final Short auxiliaryData, final CompoundTag data, final ItemNameContext context) { ++ final int value = auxiliaryData == null ? 255 : auxiliaryData & 255; ++ return "item.compound." + (value < COMPOUNDS.size() ? COMPOUNDS.get(value) : "") + ".name"; ++ } ++ } ++ ++ enum BlockKind { ++ BLOCK, LEAVES, WOOL, SAPLING ++ } ++ ++ enum Kind { ++ DYE, BUCKET, WRITTEN_BOOK, EMPTY_MAP, FILLED_MAP, BED, BALLOON, BANNER, SHIELD, MEDICINE, GLOW_STICK, SPARKLER, BOARD ++ } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/protocol/model/BuiltinPotionNames.java b/src/main/java/net/raphimc/viabedrock/protocol/model/BuiltinPotionNames.java +new file mode 100644 +index 000000000..b97b93744 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/protocol/model/BuiltinPotionNames.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.protocol.model; ++ ++import com.viaversion.viaversion.libs.gson.JsonParser; ++import net.raphimc.viabedrock.protocol.data.ProtocolConstants; ++ ++import java.io.IOException; ++import java.io.InputStreamReader; ++import java.nio.charset.StandardCharsets; ++import java.util.ArrayList; ++import java.util.List; ++ ++/** Original potion registration fields, with the effect registry's world-version gates. */ ++final class BuiltinPotionNames { ++ ++ private static final List DEFINITIONS = load(); ++ ++ static Definition get(final Short auxiliaryData, final BaseGameVersion baseGameVersion) { ++ final int auxiliary = auxiliaryData == null ? Short.MAX_VALUE : Short.toUnsignedInt(auxiliaryData); ++ final Definition definition = auxiliary < DEFINITIONS.size() ? DEFINITIONS.get(auxiliary) : DEFINITIONS.getFirst(); ++ return definition.minimumVersion() != null && baseGameVersion.isLowerThan(definition.minimumVersion()) ++ ? DEFINITIONS.getFirst() : definition; ++ } ++ ++ private static List load() { ++ try (var stream = BuiltinPotionNames.class.getResourceAsStream("/assets/viabedrock/data/bedrock/potion_names.json")) { ++ if (stream == null) { ++ throw new IllegalStateException("Missing native potion names"); ++ } ++ final var data = JsonParser.parseReader(new InputStreamReader(stream, StandardCharsets.UTF_8)).getAsJsonObject(); ++ if (data.get("format").getAsInt() != 1 || data.get("protocol").getAsInt() != ProtocolConstants.BEDROCK_PROTOCOL_VERSION) { ++ throw new IllegalStateException("Native potion names do not match the target protocol"); ++ } ++ final List definitions = new ArrayList<>(); ++ for (final var value : data.getAsJsonArray("entries")) { ++ final var definition = value.getAsJsonObject(); ++ final BaseGameVersion minimumVersion = definition.has("minimumVersion") ++ ? BaseGameVersion.parse(definition.get("minimumVersion").getAsString()) : null; ++ if (minimumVersion != null && !minimumVersion.valid()) { ++ throw new IllegalStateException("Invalid native potion version gate"); ++ } ++ definitions.add(new Definition(definition.get("description").getAsString(), ++ definition.has("effect") ? definition.get("effect").getAsString() : null, minimumVersion)); ++ } ++ if (definitions.isEmpty()) { ++ throw new IllegalStateException("Native potion registration has no default entry"); ++ } ++ return List.copyOf(definitions); ++ } catch (final IOException error) { ++ throw new IllegalStateException("Failed to load native potion names", error); ++ } ++ } ++ ++ private BuiltinPotionNames() { } ++ ++ record Definition(String description, String effect, BaseGameVersion minimumVersion) { } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/protocol/model/ComponentItemStackingProperties.java b/src/main/java/net/raphimc/viabedrock/protocol/model/ComponentItemStackingProperties.java +new file mode 100644 +index 000000000..0c0c7b002 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/protocol/model/ComponentItemStackingProperties.java +@@ -0,0 +1,40 @@ ++/* ++ * 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.ByteTag; ++import com.viaversion.nbt.tag.CompoundTag; ++import com.viaversion.nbt.tag.IntTag; ++ ++/** Compiled Item flags and persistent components have separate update lifetimes. */ ++public record ComponentItemStackingProperties(int compiledMaximum, Integer componentMaximum, ++ boolean stackedByData, boolean damageable) implements ItemStackingProperties { ++ ++ static final ComponentItemStackingProperties DEFAULT = new ComponentItemStackingProperties(64, null, false, false); ++ ++ @Override ++ public ComponentItemStackingProperties withComponentData(final CompoundTag components) { ++ int compiledMaximum = this.compiledMaximum; ++ boolean stackedByData = this.stackedByData; ++ if (components.get("item_properties") instanceof CompoundTag properties) { ++ compiledMaximum = properties.get("max_stack_size") instanceof IntTag size ? size.asInt() & 0xFF : 0; ++ stackedByData = properties.get("stacked_by_data") instanceof ByteTag flag && flag.asByte() != 0; ++ } ++ Integer componentMaximum = this.componentMaximum; ++ if (components.get("minecraft:max_stack_size") instanceof CompoundTag size) { ++ componentMaximum = size.get("value") instanceof ByteTag value ? Byte.toUnsignedInt(value.asByte()) : 1; ++ } ++ // Once installed, omitted or mistyped component updates do not remove durability. ++ final boolean damageable = this.damageable || components.get("minecraft:durability") instanceof CompoundTag; ++ return new ComponentItemStackingProperties(compiledMaximum, componentMaximum, stackedByData, damageable); ++ } ++ ++ @Override ++ public int maximum() { ++ return this.componentMaximum != null ? this.componentMaximum : this.compiledMaximum; ++ } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/protocol/model/ItemEntry.java b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemEntry.java +index 319d0e8e8..fb504c391 100644 +--- a/src/main/java/net/raphimc/viabedrock/protocol/model/ItemEntry.java ++++ b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemEntry.java +@@ -21,4 +21,14 @@ import com.viaversion.nbt.tag.CompoundTag; + import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ItemVersion; + + public record ItemEntry(String identifier, int id, boolean componentBased, ItemVersion version, CompoundTag componentData) { ++ ++ public ItemEntry { ++ componentData = componentData == null ? null : componentData.copy(); ++ } ++ ++ @Override ++ public CompoundTag componentData() { ++ return this.componentData == null ? null : this.componentData.copy(); ++ } ++ + } +diff --git a/src/main/java/net/raphimc/viabedrock/protocol/model/ItemNameContext.java b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemNameContext.java +new file mode 100644 +index 000000000..20d57645c +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemNameContext.java +@@ -0,0 +1,23 @@ ++/* ++ * 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 java.util.Objects; ++import java.util.function.BiFunction; ++import java.util.function.Function; ++import java.util.function.Supplier; ++ ++/** Connection-owned sources stay lazy until the receiving native name method needs them. */ ++public record ItemNameContext(BiFunction blockNames, Function translate, ++ Supplier baseGameVersion) { ++ ++ public ItemNameContext { ++ Objects.requireNonNull(blockNames); ++ Objects.requireNonNull(translate); ++ Objects.requireNonNull(baseGameVersion); ++ } ++ ++} +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 000000000..8e1217d5a +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemRegistry.java +@@ -0,0 +1,231 @@ ++/* ++ * 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.google.common.collect.BiMap; ++import com.google.common.collect.HashBiMap; ++import com.google.common.collect.ImmutableBiMap; ++import com.viaversion.nbt.tag.CompoundTag; ++import com.viaversion.nbt.tag.StringTag; ++import com.viaversion.viaversion.util.Key; ++import net.raphimc.viabedrock.api.model.NativeItemNames; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ItemVersion; ++ ++import java.util.HashMap; ++import java.util.HashSet; ++import java.util.LinkedHashMap; ++import java.util.List; ++import java.util.Map; ++import java.util.Set; ++import java.util.stream.Collectors; ++ ++/** One connection's item IDs and the complete definitions that own those IDs. */ ++public final class ItemRegistry { ++ ++ private final BiMap ids; ++ private final Map definitions; ++ private final List entries; ++ private final List registrations; ++ private final boolean writtenBookNames; ++ private final Map componentDescriptions; ++ ++ public ItemRegistry(final ItemEntry[] entries) { ++ final BiMap ids = HashBiMap.create(entries.length); ++ final Map definitions = new LinkedHashMap<>(); ++ for (final ItemEntry entry : entries) { ++ // The native runtime-ID pass skips the invalid-ID sentinel, including later references. ++ if (entry.id() == -1) { ++ continue; ++ } ++ final String previousIdentifier = ids.inverse().remove(entry.id()); ++ if (previousIdentifier != null) { ++ definitions.remove(previousIdentifier); ++ } ++ ids.put(entry.identifier(), entry.id()); ++ definitions.remove(entry.identifier()); ++ definitions.put(entry.identifier(), entry); ++ } ++ this.registrations = List.of(entries.clone()); ++ this.ids = ImmutableBiMap.copyOf(ids); ++ this.definitions = Map.copyOf(definitions); ++ this.entries = List.copyOf(definitions.values()); ++ this.writtenBookNames = this.factories().get("minecraft:written_book") == ItemVersion.Legacy; ++ this.componentDescriptions = this.resolveComponentDescriptions(); + } + - public CompoundTag getJavaTags() { - return this.javaTags; - } ++ public BiMap ids() { ++ return this.ids; ++ } ++ ++ public List entries() { ++ return this.entries; ++ } ++ ++ /** Ordered network updates, including entries whose ID or identifier was subsequently replaced. */ ++ public List registrations() { ++ return this.registrations; ++ } ++ ++ public ItemEntry definition(final String identifier) { ++ return this.definitions.get(identifier); ++ } ++ ++ public ItemEntry definition(final int id) { ++ final String identifier = this.ids.inverse().get(id); ++ return identifier != null ? this.definitions.get(identifier) : null; ++ } ++ ++ /** The constructed item class owns custom-name dispatch, independently of its final network flags. */ ++ public String customName(final String identifier, final CompoundTag data) { ++ if (!this.ids.containsKey(identifier)) { ++ return null; ++ } ++ return NativeItemNames.customName(data, this.writtenBookNames && identifier.equals("minecraft:written_book")); ++ } ++ ++ /** Null selects a different native key method; component descriptions are already complete keys. */ ++ public String componentDescription(final String identifier) { ++ return this.componentDescriptions.get(identifier); ++ } ++ ++ /** Component construction replaces builtin dispatch; other names retain their native receiver's key. */ ++ public String ordinaryNameKey(final String identifier, final Short auxiliaryData, final CompoundTag data, ++ final ItemNameContext context) { ++ if (!this.ids.containsKey(identifier)) { ++ return null; ++ } ++ final String component = this.componentDescriptions.get(identifier); ++ if (component != null) { ++ return component; ++ } ++ final BuiltinItemFactories.Definition builtin = BuiltinItemFactories.get(identifier); ++ return builtin != null && builtin.ordinaryName() != null ? builtin.ordinaryName().key(identifier, auxiliaryData, data, context) : null; ++ } ++ ++ private Map resolveComponentDescriptions() { ++ final Map descriptions = new HashMap<>(); ++ for (final ItemEntry entry : this.registrations) { ++ final BuiltinItemFactories.Definition builtin = BuiltinItemFactories.get(entry.identifier()); ++ if (builtin != null && builtin.componentDescription() != null) { ++ descriptions.putIfAbsent(entry.identifier(), builtin.componentDescription()); ++ } ++ } ++ final Set networkDefinitions = new HashSet<>(); ++ for (final var construction : this.constructionVersions().entrySet()) { ++ if (construction.getValue() == ItemVersion.DataDriven) { ++ networkDefinitions.add(construction.getKey()); ++ descriptions.put(construction.getKey(), ordinaryDescription(construction.getKey())); ++ } ++ } ++ // All construction runs before the ordered component readers. The entry's ++ // version does not restrict updates to a constructed ComponentItem. ++ for (final ItemEntry entry : this.registrations) { ++ if (!networkDefinitions.contains(entry.identifier())) { ++ continue; ++ } ++ final CompoundTag data = entry.componentData(); ++ if (data != null && data.get("components") instanceof CompoundTag components ++ && components.get("minecraft:display_name") instanceof CompoundTag displayName ++ && displayName.get("value") instanceof StringTag value) { ++ descriptions.put(entry.identifier(), value.getValue().isEmpty() ++ ? ordinaryDescription(entry.identifier()) : value.getValue()); ++ } ++ } ++ final Map factories = this.factories(); ++ for (final ItemEntry entry : this.entries) { ++ if (BuiltinItemFactories.get(entry.identifier()) == null && factories.get(entry.identifier()) == ItemVersion.DataDriven) { ++ // Unknown None entries receive their fallback class after all ++ // component readers. Their earlier metadata cannot name it. ++ descriptions.putIfAbsent(entry.identifier(), ordinaryDescription(entry.identifier())); ++ } ++ } ++ descriptions.keySet().retainAll(this.ids.keySet()); ++ return Map.copyOf(descriptions); ++ } ++ ++ private static String ordinaryDescription(final String identifier) { ++ return "item." + Key.stripMinecraftNamespace(identifier); ++ } ++ ++ public Set componentItems() { ++ final Map factories = this.factories(); ++ return this.ids.keySet().stream().filter(identifier -> factories.get(identifier) == ItemVersion.DataDriven) ++ .collect(Collectors.toUnmodifiableSet()); ++ } ++ ++ /** Constructor-owned defaults precede every ordered network update, including None entries. */ ++ public Map stackingProperties() { ++ final Map factories = this.factories(); ++ final Map properties = new HashMap<>(); ++ for (final ItemEntry entry : this.registrations) { ++ final BuiltinItemFactories.Definition builtin = BuiltinItemFactories.get(entry.identifier()); ++ if (builtin != null) { ++ properties.putIfAbsent(entry.identifier(), builtin.stacking()); ++ } ++ } ++ for (final Map.Entry construction : this.constructionVersions().entrySet()) { ++ if (construction.getValue() == ItemVersion.DataDriven) { ++ properties.put(construction.getKey(), ComponentItemStackingProperties.DEFAULT); ++ } else { ++ properties.putIfAbsent(construction.getKey(), LegacyItemStackingProperties.DEFAULT); ++ } ++ } ++ for (final ItemEntry entry : this.registrations) { ++ final ItemStackingProperties previous = properties.get(entry.identifier()); ++ final CompoundTag data = entry.componentData(); ++ if (previous != null && data != null && data.get("components") instanceof CompoundTag components) { ++ properties.put(entry.identifier(), previous.withComponentData(components)); ++ } ++ } ++ final Map result = new HashMap<>(); ++ for (final String identifier : this.ids.keySet()) { ++ final ItemStackingProperties state = properties.get(identifier); ++ if (state != null) { ++ result.put(identifier, state); ++ } else { ++ // Unknown None entries are constructed after updates. ++ result.put(identifier, factories.get(identifier) == ItemVersion.DataDriven ++ ? ComponentItemStackingProperties.DEFAULT : LegacyItemStackingProperties.DEFAULT); ++ } ++ } ++ return Map.copyOf(result); ++ } ++ ++ /** The first explicit registration selects the named factory; subsequent entries only update it. */ ++ private Map constructionVersions() { ++ final Map versions = new LinkedHashMap<>(); ++ for (final ItemEntry entry : this.registrations) { ++ if (entry.version() != ItemVersion.None) { ++ versions.putIfAbsent(entry.identifier(), entry.version()); ++ } ++ } ++ return versions; ++ } ++ ++ private Map factories() { ++ final Map factories = new HashMap<>(); ++ for (final ItemEntry entry : this.registrations) { ++ final BuiltinItemFactories.Definition builtin = BuiltinItemFactories.get(entry.identifier()); ++ if (builtin != null) { ++ factories.putIfAbsent(entry.identifier(), builtin.version()); ++ } ++ } ++ for (final Map.Entry construction : this.constructionVersions().entrySet()) { ++ if (construction.getValue() == ItemVersion.DataDriven) { ++ factories.put(construction.getKey(), ItemVersion.DataDriven); ++ } else { ++ factories.putIfAbsent(construction.getKey(), ItemVersion.Legacy); ++ } ++ } ++ for (final ItemEntry entry : this.registrations) { ++ if (entry.id() != -1) { ++ factories.putIfAbsent(entry.identifier(), entry.componentBased() ? ItemVersion.DataDriven : ItemVersion.Legacy); ++ } ++ } ++ return factories; ++ } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/protocol/model/ItemStackingProperties.java b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemStackingProperties.java +new file mode 100644 +index 000000000..06361312a +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/protocol/model/ItemStackingProperties.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.protocol.model; ++ ++import com.viaversion.nbt.tag.ByteTag; ++import com.viaversion.nbt.tag.CompoundTag; ++import com.viaversion.nbt.tag.IntTag; ++ ++/** Stacking state belongs to the constructed native Item factory. */ ++public sealed interface ItemStackingProperties permits ComponentItemStackingProperties, LegacyItemStackingProperties { ++ ++ ItemStackingProperties withComponentData(CompoundTag components); ++ ++ int maximum(); ++ ++ boolean stackedByData(); ++ ++ boolean damageable(); ++ ++ default boolean stackable(final BedrockItem item) { ++ if (this.maximum() < 2) { ++ return false; ++ } ++ if (!this.damageable() || item.tag() == null) { ++ return true; ++ } ++ if (item.tag().get("Unbreakable") instanceof ByteTag flag && flag.asByte() != 0) { ++ return true; ++ } ++ return !(item.tag().get("Damage") instanceof IntTag damage) || damage.asShort() <= 0; ++ } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/protocol/model/LegacyItemStackingProperties.java b/src/main/java/net/raphimc/viabedrock/protocol/model/LegacyItemStackingProperties.java +new file mode 100644 +index 000000000..2922971d7 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/protocol/model/LegacyItemStackingProperties.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.model; ++ ++import com.viaversion.nbt.tag.ByteTag; ++import com.viaversion.nbt.tag.CompoundTag; ++import com.viaversion.nbt.tag.IntTag; ++ ++/** Ordinary Item definitions update flat namespaced scalars independently. */ ++public record LegacyItemStackingProperties(int compiledMaximum, boolean compiledStackedByData, short maximumDamage, ++ Integer fixedMaximum, boolean forcedSubtype) implements ItemStackingProperties { ++ ++ static final LegacyItemStackingProperties DEFAULT = new LegacyItemStackingProperties(64, false, (short) 0); ++ ++ public LegacyItemStackingProperties(final int maximum, final boolean stackedByData, final short maximumDamage) { ++ this(maximum, stackedByData, maximumDamage, null, false); ++ } ++ ++ @Override ++ public int maximum() { ++ return this.fixedMaximum != null ? this.fixedMaximum : this.compiledMaximum; ++ } ++ ++ @Override ++ public boolean stackedByData() { ++ return this.forcedSubtype || this.compiledStackedByData; ++ } ++ ++ @Override ++ public LegacyItemStackingProperties withComponentData(final CompoundTag components) { ++ int maximum = this.compiledMaximum; ++ if (components.contains("minecraft:max_stack_size")) { ++ maximum = components.get("minecraft:max_stack_size") instanceof IntTag size ? size.asInt() & 0xFF : 0; ++ } ++ boolean stackedByData = this.compiledStackedByData; ++ if (components.contains("minecraft:stacked_by_data")) { ++ stackedByData = components.get("minecraft:stacked_by_data") instanceof ByteTag flag && flag.asByte() != 0; ++ } ++ short maximumDamage = this.maximumDamage; ++ if (components.contains("minecraft:max_damage")) { ++ maximumDamage = components.get("minecraft:max_damage") instanceof IntTag damage ? damage.asShort() : 0; ++ } ++ return new LegacyItemStackingProperties(maximum, stackedByData, maximumDamage, this.fixedMaximum, this.forcedSubtype); ++ } ++ ++ @Override ++ public boolean damageable() { ++ return this.maximumDamage > 0; ++ } ++ ++} 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 6d9d3f9d7..d42057443 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/HudPackets.java @@ -530,13 +2432,15 @@ index 6d9d3f9d7..d42057443 100644 case Clear, Reset -> { wrapper.setPacketType(ClientboundPackets26_3.CLEAR_TITLES); 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 39b8a7e4e..040fe1681 100644 +index 39b8a7e4e..d22d76165 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/InventoryPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/InventoryPackets.java -@@ -17,6 +17,12 @@ +@@ -17,6 +17,14 @@ */ package net.raphimc.viabedrock.protocol.packet; ++import net.raphimc.viabedrock.protocol.storage.GameSessionStorage; ++ +import com.viaversion.viaversion.libs.gson.JsonParser; +import net.raphimc.viabedrock.api.resourcepack.ui.ServerFormUi; +import net.raphimc.viabedrock.api.model.NativeFormMessage; @@ -546,7 +2450,18 @@ index 39b8a7e4e..040fe1681 100644 import com.viaversion.nbt.tag.CompoundTag; import com.viaversion.nbt.tag.ListTag; import com.viaversion.nbt.tag.Tag; -@@ -69,6 +75,7 @@ import net.raphimc.viabedrock.api.util.TextUtil; +@@ -61,6 +69,10 @@ import net.raphimc.viabedrock.ViaBedrock; + import net.raphimc.viabedrock.api.model.CommandBlockOpen; + import net.raphimc.viabedrock.api.chunk.BedrockBlockEntity; + import net.raphimc.viabedrock.api.model.container.ChestContainer; ++import net.raphimc.viabedrock.api.model.container.NativeStorageContainers; ++import net.raphimc.viabedrock.api.model.container.block.HopperContainer; ++import net.raphimc.viabedrock.api.model.container.block.Generic3x3Container; ++import net.raphimc.viabedrock.protocol.data.generated.bedrock.CustomBlockTags; + import net.raphimc.viabedrock.api.model.container.Container; + import net.raphimc.viabedrock.api.model.container.player.InventoryContainer; + import net.raphimc.viabedrock.api.model.entity.Entity; +@@ -69,6 +81,7 @@ 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; @@ -554,7 +2469,7 @@ index 39b8a7e4e..040fe1681 100644 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; -@@ -78,6 +85,7 @@ import net.raphimc.viabedrock.protocol.model.FullContainerName; +@@ -78,6 +91,7 @@ import net.raphimc.viabedrock.protocol.model.FullContainerName; import net.raphimc.viabedrock.protocol.rewriter.BlockStateRewriter; import net.raphimc.viabedrock.protocol.rewriter.ItemRewriter; import net.raphimc.viabedrock.protocol.storage.*; @@ -562,7 +2477,7 @@ index 39b8a7e4e..040fe1681 100644 import net.raphimc.viabedrock.protocol.types.BedrockTypes; import java.util.ArrayList; -@@ -90,6 +98,103 @@ public final class InventoryPackets { +@@ -90,6 +104,103 @@ public final class InventoryPackets { private static final int DIALOG_FAKE_BUTTON_WIDTH = 300; private static final String DIALOG_FAKE_BUTTON_TEXT = "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."; @@ -666,7 +2581,7 @@ index 39b8a7e4e..040fe1681 100644 public static void register(final BedrockProtocol protocol) { protocol.registerClientbound(ClientboundBedrockPackets.PLAYER_START_ITEM_COOLDOWN, ClientboundPackets26_3.COOLDOWN, wrapper -> { -@@ -109,7 +214,8 @@ public final class InventoryPackets { +@@ -109,7 +220,8 @@ public final class InventoryPackets { return; } final BlockPosition position = wrapper.read(BedrockTypes.BLOCK_POSITION); // position @@ -676,7 +2591,7 @@ index 39b8a7e4e..040fe1681 100644 if (inventoryTracker.isAnyScreenOpen()) { ViaBedrock.getPlatform().getLogger().log(Level.WARNING, "Server tried to open container while another container is open"); -@@ -117,11 +223,10 @@ public final class InventoryPackets { +@@ -117,11 +229,15 @@ public final class InventoryPackets { wrapper.cancel(); return; } @@ -687,12 +2602,27 @@ index 39b8a7e4e..040fe1681 100644 - } + final BedrockBlockEntity blockEntity = actorId == -1 ? chunkTracker.getBlockEntity(position) : null; + final ContainerTitle openingTitle = containerTitle(wrapper.user(), type, actor, blockEntity, -+ new TranslationComponent("container." + blockStateRewriter.tag(chunkTracker.getBlockState(position)))); ++ new TranslationComponent(switch (type) { ++ case HOPPER, MINECART_HOPPER -> "container.hopper"; ++ case DISPENSER -> "container.dispenser"; ++ case DROPPER -> "container.dropper"; ++ default -> "container." + blockStateRewriter.tag(chunkTracker.getBlockState(position)); ++ })); + final TextComponent title = openingTitle.caption(); final Container container; switch (type) { -@@ -153,40 +258,16 @@ public final class InventoryPackets { +@@ -131,6 +247,9 @@ public final class InventoryPackets { + return; + } + case CONTAINER -> container = new ChestContainer(wrapper.user(), containerId, title, position, 27); ++ case HOPPER, MINECART_HOPPER -> container = new HopperContainer(wrapper.user(), containerId, type, title, ++ actorId == -1 ? position : null); ++ case DISPENSER, DROPPER -> container = new Generic3x3Container(wrapper.user(), containerId, type, title, position); + case COMMAND_BLOCK -> { + wrapper.cancel(); + if (!WorldPackets.openNativeCommandBlock(wrapper.user(), containerId, blockEntity)) { +@@ -153,40 +272,16 @@ public final class InventoryPackets { inventoryTracker.setCurrentContainer(container); wrapper.write(Types.VAR_INT, (int) containerId); // container id @@ -700,7 +2630,7 @@ index 39b8a7e4e..040fe1681 100644 + final int javaMenuId = BedrockProtocol.MAPPINGS.getBedrockToJavaContainers().get(type); + wrapper.write(Types.VAR_INT, javaMenuId); // type wrapper.write(Types.TAG, TextUtil.textComponentToNbt(title)); // title -+ sendNativeContainerPresentation(wrapper, javaMenuId, openingTitle.presentation()); ++ sendNativeContainerPresentation(wrapper, nativeContainerKind(type, blockStateRewriter.tag(chunkTracker.getBlockState(position)), container.size()), openingTitle.presentation()); }); protocol.registerClientbound(ClientboundBedrockPackets.CONTAINER_CLOSE, ClientboundPackets26_3.CONTAINER_CLOSE, new PacketHandlers() { @Override @@ -737,7 +2667,7 @@ index 39b8a7e4e..040fe1681 100644 } }); protocol.registerClientbound(ClientboundBedrockPackets.INVENTORY_CONTENT, ClientboundPackets26_3.CONTAINER_SET_CONTENT, wrapper -> { -@@ -227,134 +308,10 @@ public final class InventoryPackets { +@@ -227,134 +322,10 @@ public final class InventoryPackets { wrapper.cancel(); } }); @@ -876,7 +2806,19 @@ index 39b8a7e4e..040fe1681 100644 protocol.registerClientbound(ClientboundBedrockPackets.CLOSE_FORM, ClientboundPackets26_3.CLEAR_DIALOG, wrapper -> { final InventoryTracker inventoryTracker = wrapper.user().get(InventoryTracker.class); if (inventoryTracker.getCurrentForm() != null) { -@@ -490,6 +447,15 @@ public final class InventoryPackets { +@@ -452,6 +423,11 @@ public final class InventoryPackets { + wrapper.cancel(); + return; + } ++ final var nativeContainer = wrapper.user().get(NativeContainerUiState.class); ++ if (nativeContainer != null && nativeContainer.blocksVanillaClicks()) { ++ PacketFactory.sendJavaContainerSetContent(wrapper.user(), container); ++ return; ++ } + if (!container.handleClick(revision, slot, button, action)) { + if (container.type() != ContainerType.INVENTORY) { + PacketFactory.sendJavaContainerSetContent(wrapper.user(), inventoryTracker.getInventoryContainer()); +@@ -490,6 +466,15 @@ public final class InventoryPackets { return; } @@ -892,7 +2834,7 @@ index 39b8a7e4e..040fe1681 100644 inventoryTracker.setCurrentForm(null); if (payload.contains("exit") && payload.getBoolean("exit")) { wrapper.write(BedrockTypes.UNSIGNED_VAR_INT, formId); // id -@@ -633,6 +599,305 @@ public final class InventoryPackets { +@@ -633,6 +618,342 @@ public final class InventoryPackets { }); } @@ -917,23 +2859,60 @@ index 39b8a7e4e..040fe1681 100644 + return data != null && data.dataType() == EntityDataTypesBedrock.STRING && data.value() instanceof String value ? value : ""; + } + -+ static void sendNativeContainerPresentation(final PacketWrapper wrapper, final int javaMenuId, final ContainerUi.Title title) { -+ if (!wrapper.user().get(ChannelStorage.class).hasChannel(NativeContainerMessage.CHANNEL)) { ++ static ContainerUi.Kind nativeContainerKind(final ContainerType type, final String blockTag, final int size) { ++ return switch (type) { ++ case CONTAINER -> { ++ if (size == 54) { ++ yield ContainerUi.Kind.CHEST_6_ROWS; ++ } ++ if (size != 27) { ++ yield null; ++ } ++ yield CustomBlockTags.BARREL.equals(blockTag) ? ContainerUi.Kind.BARREL ++ : CustomBlockTags.SHULKER_BOX.equals(blockTag) ? ContainerUi.Kind.SHULKER_BOX ++ : CustomBlockTags.ENDER_CHEST.equals(blockTag) ? ContainerUi.Kind.ENDER_CHEST ++ : ContainerUi.Kind.CHEST_3_ROWS; ++ } ++ case MINECART_CHEST, CHEST_BOAT -> size == 27 ? ContainerUi.Kind.CHEST_3_ROWS : null; ++ 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; ++ default -> null; ++ }; ++ } ++ ++ static void sendNativeContainerPresentation(final PacketWrapper wrapper, final ContainerUi.Kind kind, final ContainerUi.Title title) { ++ final var channels = wrapper.user().get(ChannelStorage.class); ++ final boolean families = channels.hasChannel(NativeContainerMessage.FAMILIES_CHANNEL); ++ if (kind == null || kind == ContainerUi.Kind.BREWING_STAND || !kind.legacy() && !families) { ++ return; ++ } ++ final boolean nativeActions = channels.hasChannel(NativeContainerMessage.CURRENT_CHANNEL) ++ && channels.hasChannel(net.raphimc.viabedrock.api.model.NativeContainerStatus.CHANNEL) ++ && !wrapper.user().get(GameSessionStorage.class).isInventoryServerAuthoritative() ++ && NativeStorageContainers.matches(wrapper.user().get(InventoryTracker.class).getCurrentContainer(), kind); ++ if (!nativeActions && !families && !channels.hasChannel(NativeContainerMessage.CHANNEL)) { ++ return; ++ } ++ final var resources = wrapper.user().get(ResourcePackStorage.class); ++ if (!resources.isLoadedOnJavaClient()) { + return; + } -+ final ContainerUi.Kind kind = javaMenuId == BedrockProtocol.MAPPINGS.getJavaMenuId("minecraft:generic_9x3") -+ ? ContainerUi.Kind.CHEST_3_ROWS : javaMenuId == BedrockProtocol.MAPPINGS.getJavaMenuId("minecraft:generic_9x6") -+ ? ContainerUi.Kind.CHEST_6_ROWS : null; -+ final var message = wrapper.user().get(NativeContainerUiState.class).prepare( -+ wrapper.get(Types.VAR_INT, 0), kind, title, wrapper.user().get(ResourcePackStorage.class).getContainerUi(), List.of()); ++ final var state = wrapper.user().get(NativeContainerUiState.class); ++ final var ui = resources.getContainerUi(); ++ final var message = nativeActions ? state.prepareActions(wrapper.get(Types.VAR_INT, 0), kind, title, ui, ++ value -> net.raphimc.viabedrock.api.model.container.NativeContainerLegacyActions.create(wrapper.user(), value)) ++ : state.prepare(wrapper.get(Types.VAR_INT, 0), kind, title, ui, List.of()); + if (message == null) { + return; + } + wrapper.send(BedrockProtocol.class); + wrapper.cancel(); + final PacketWrapper presentation = PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, wrapper.user()); -+ presentation.write(Types.STRING, NativeContainerMessage.CHANNEL); -+ presentation.write(NativeContainerMessage.TYPE, message); ++ presentation.write(Types.STRING, families ? NativeContainerMessage.FAMILIES_CHANNEL ++ : nativeActions ? NativeContainerMessage.CURRENT_CHANNEL : NativeContainerMessage.CHANNEL); ++ presentation.write(families ? NativeContainerMessage.FAMILIES_TYPE ++ : nativeActions ? NativeContainerMessage.CURRENT_TYPE : NativeContainerMessage.TYPE, message); + presentation.send(BedrockProtocol.class); + } + @@ -1198,7 +3177,7 @@ index 39b8a7e4e..040fe1681 100644 private static void sendBookEdits(final UserConnection user, final int slot, final List previousPages, final List pages, final String title) { for (int index = 0; index < pages.size(); index++) { -@@ -687,4 +952,12 @@ public final class InventoryPackets { +@@ -687,4 +1008,12 @@ public final class InventoryPackets { private InventoryPackets() { } @@ -1212,10 +3191,27 @@ index 39b8a7e4e..040fe1681 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 8a4e67e99..6dc1a00d2 100644 +index 45e28836e..85f378192 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/JoinPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/JoinPackets.java -@@ -592,6 +592,7 @@ public final class JoinPackets { +@@ -266,6 +266,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 ++ gameSession.setBedrockBaseGameVersion(BaseGameVersion.parse(vanillaVersion)); + wrapper.read(BedrockTypes.INT_LE); // limited world width + wrapper.read(BedrockTypes.INT_LE); // limited world height + wrapper.read(Types.BOOLEAN); // nether type +@@ -364,7 +365,7 @@ public final class JoinPackets { + } + + if (!inventoryServerAuthoritative) { +- ViaBedrock.getPlatform().getLogger().log(Level.INFO, "This server uses client authoritative inventories. This is not supported yet."); ++ ViaBedrock.getPlatform().getLogger().log(Level.INFO, "This server uses client-authoritative inventory with limited action support."); + } + + gameSession.setBedrockVanillaVersion(version); +@@ -593,6 +594,7 @@ public final class JoinPackets { user.get(EntityTracker.class).sendNativeCustomEntitySnapshot(); user.get(PlayerInputStorage.class).markJoined(); user.get(PlayerInputStorage.class).sendSnapshot(); @@ -1224,10 +3220,68 @@ index 8a4e67e99..6dc1a00d2 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 245f0da36..a7bd34044 100644 +index 05a0cf8c1..9ba7444b2 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java -@@ -257,6 +257,7 @@ public final class MultiStatePackets { +@@ -17,6 +17,16 @@ + */ + package net.raphimc.viabedrock.protocol.packet; + ++import com.viaversion.viaversion.api.protocol.packet.PacketWrapper; ++ ++import net.raphimc.viabedrock.api.model.NativeContainerMessage; ++ ++import net.raphimc.viabedrock.api.model.NativeContainerStatus; ++ ++import net.raphimc.viabedrock.api.model.NativeContainerAction; ++ ++import net.raphimc.viabedrock.protocol.storage.NativeContainerUiState; ++ + import com.viaversion.viaversion.api.protocol.packet.State; + import com.viaversion.viaversion.api.protocol.remapper.PacketHandler; + import com.viaversion.viaversion.api.type.Types; +@@ -178,6 +188,27 @@ public final class MultiStatePackets { + } + return; + } ++ if (channel.equals(NativeContainerMessage.CURRENT_CHANNEL)) { ++ if (wrapper.user().getProtocolInfo().getClientState() == State.PLAY ++ && wrapper.user().get(ChannelStorage.class).hasChannel(channel) ++ && wrapper.user().get(ChannelStorage.class).hasChannel(NativeContainerStatus.CHANNEL)) { ++ try { ++ final var action = wrapper.read(NativeContainerAction.TYPE); ++ final boolean accepted = wrapper.user().get(NativeContainerUiState.class) ++ .handleAction(action); ++ if (action.mode() == NativeContainerAction.Mode.ACTIVATE) { ++ final var status = PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, wrapper.user()); ++ status.write(Types.STRING, NativeContainerStatus.CHANNEL); ++ status.write(NativeContainerStatus.TYPE, ++ new NativeContainerStatus(action.menuId(), action.sequence(), accepted)); ++ status.send(BedrockProtocol.class); ++ } ++ } catch (final IllegalArgumentException | IndexOutOfBoundsException malformed) { ++ // Invalid input never reaches prediction or ordinary Java inventory handling. ++ } ++ } ++ return; ++ } + 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); +@@ -187,6 +218,12 @@ public final class MultiStatePackets { + if (channels.contains(PlayerInputPermissions.CHANNEL) && wrapper.user().has(PlayerPredictionStorage.class)) { + wrapper.user().get(PlayerPredictionStorage.class).discardPending(); + } ++ if ((channels.contains(NativeContainerMessage.CURRENT_CHANNEL) ++ || channels.contains(NativeContainerMessage.FAMILIES_CHANNEL) ++ || channels.contains(NativeContainerStatus.CHANNEL)) ++ && wrapper.user().has(NativeContainerUiState.class)) { ++ wrapper.user().get(NativeContainerUiState.class).clearActions(); ++ } + if (wrapper.user().has(VehiclePredictionStorage.class)) { + wrapper.user().get(VehiclePredictionStorage.class).refresh(); + } +@@ -259,6 +296,7 @@ public final class MultiStatePackets { if (channels.contains(FlightMovementContext.CHANNEL) && wrapper.user().has(EntityTracker.class)) { wrapper.user().get(EntityTracker.class).sendFlightMovementSnapshot(); } @@ -1732,6 +3786,31 @@ index 521161d5b..a9fcc0c78 100644 final PacketWrapper wrapper = source.create(ServerboundBedrockPackets.NPC_REQUEST); 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 51681e3de..5f3299d05 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; + 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.BlockState; + import net.raphimc.viabedrock.api.chunk.blockstate.BlockRegistryNames; +@@ -251,6 +252,12 @@ public class BlockStateRewriter implements StorableObject { + return this.blockItemStates.resolve(identifier, metadata); + } + ++ /** Resolve the canonical block before selecting its compiled native name. Null metadata selects its default. */ ++ public String blockItemNameKey(final String identifier, final Short metadata) { ++ final BedrockBlockState state = this.blockItemStates.resolve(identifier, metadata == null ? Short.MAX_VALUE : metadata); ++ return state == null ? null : BuiltinBlockNames.get(state.namespacedIdentifier()); ++ } ++ + public BlockItemDescriptor blockItemRecipeDescriptor(final String identifier, final short metadata) { + 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 f98365142..1baab2219 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemDataRewriter.java @@ -1744,19 +3823,235 @@ index f98365142..1baab2219 100644 if (DYED_LEATHER_ARMOR_ITEMS.contains(identifier) && 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 353a67ede..234767d2c 100644 +--- a/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemRewriter.java ++++ b/src/main/java/net/raphimc/viabedrock/protocol/rewriter/ItemRewriter.java +@@ -18,7 +18,6 @@ + package net.raphimc.viabedrock.protocol.rewriter; + + import com.google.common.collect.BiMap; +-import com.google.common.collect.HashBiMap; + import com.viaversion.nbt.tag.CompoundTag; + import com.viaversion.nbt.tag.IntTag; + import com.viaversion.nbt.tag.NumberTag; +@@ -39,15 +38,20 @@ 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; ++import net.raphimc.viabedrock.api.model.NativeItemNames; ++import net.raphimc.viabedrock.api.model.container.NativeItemStackMatcher; ++import net.raphimc.viabedrock.api.model.NativeItemStacking; + import net.raphimc.viabedrock.api.model.NativeItemStackData; + import net.raphimc.viabedrock.api.resourcepack.definition.ItemDefinitions; + import net.raphimc.viabedrock.api.util.TextUtil; + import net.raphimc.viabedrock.protocol.BedrockProtocol; + import net.raphimc.viabedrock.protocol.data.BedrockMappingData; + import net.raphimc.viabedrock.protocol.data.ProtocolConstants; +-import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ItemVersion; + import net.raphimc.viabedrock.protocol.data.generated.bedrock.CustomItemTags; + import net.raphimc.viabedrock.protocol.model.BedrockItem; ++import net.raphimc.viabedrock.protocol.model.ItemRegistry; ++import net.raphimc.viabedrock.protocol.model.ItemNameContext; ++import net.raphimc.viabedrock.protocol.model.ItemStackingProperties; + import net.raphimc.viabedrock.protocol.model.ItemEntry; + import net.raphimc.viabedrock.protocol.model.ItemCooldown; + import net.raphimc.viabedrock.protocol.model.ItemAnimationData; +@@ -56,6 +60,7 @@ 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; ++import net.raphimc.viabedrock.protocol.storage.GameSessionStorage; + import net.raphimc.viabedrock.protocol.storage.MapTracker; + import net.raphimc.viabedrock.protocol.storage.ResourcePackStorage; + import net.raphimc.viabedrock.protocol.types.BedrockTypes; +@@ -71,6 +76,8 @@ import java.util.logging.Level; + + public class ItemRewriter extends StoredObject { + ++ private final ItemRegistry registry; ++ + private final Map animationData = new HashMap<>(); + private final Map armorData = new HashMap<>(); + private final Map useModifiers = new HashMap<>(); +@@ -83,6 +90,7 @@ public class ItemRewriter extends StoredObject { + private static final Map ITEM_NBT_REWRITERS = new HashMap<>(); + + private final BiMap items; ++ private final Map stackingProperties; + private final Set componentItems; + private final Int2ObjectMap blockItemValidBlockStates; + private final Type itemType; +@@ -122,11 +130,11 @@ public class ItemRewriter extends StoredObject { + public ItemRewriter(final UserConnection user, final ItemEntry[] itemEntries) { + super(user); + +- this.items = HashBiMap.create(itemEntries.length); +- this.componentItems = new HashSet<>(); ++ this.registry = new ItemRegistry(itemEntries); ++ this.items = this.registry.ids(); ++ this.componentItems = this.registry.componentItems(); ++ this.stackingProperties = this.registry.stackingProperties(); + for (ItemEntry itemEntry : itemEntries) { +- 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,9 +145,6 @@ public class ItemRewriter extends StoredObject { + this.cooldowns.put(itemEntry.identifier(), cooldown); + } + } +- if (itemEntry.version() == ItemVersion.DataDriven || (itemEntry.version() == ItemVersion.None && itemEntry.componentBased())) { +- this.componentItems.add(itemEntry.identifier()); +- } + } + final Set blockItems = new HashSet<>(BedrockProtocol.MAPPINGS.getBedrockBlockItems()); + blockItems.removeIf(identifier -> !this.items.containsKey(identifier)); +@@ -200,7 +205,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()); +- javaItemMapping = newBlockItemMappings.get(this.user().get(BlockStateRewriter.class).blockState(validBlockStates.firstInt())); ++ javaItemMapping = validBlockStates != null && !validBlockStates.isEmpty() ++ ? newBlockItemMappings.get(this.user().get(BlockStateRewriter.class).blockState(validBlockStates.firstInt())) : null; + } else { + javaItemMapping = null; + } +@@ -222,13 +228,17 @@ public class ItemRewriter extends StoredObject { + } + } + ++ final String ordinaryNameKey = this.registry.ordinaryNameKey(identifier, bedrockItem.data(), bedrockItem.tag(), ++ new ItemNameContext((block, metadata) -> this.user().get(BlockStateRewriter.class).blockItemNameKey(block, metadata), ++ key -> this.user().get(ResourcePackStorage.class).getTexts().translate(key), ++ () -> this.user().get(GameSessionStorage.class).getBedrockBaseGameVersion())); + final Item javaItem; + if (javaItemMapping != null) { + final StructuredDataContainer data = ProtocolConstants.createStructuredDataContainer(); + if (javaItemMapping.overrideTag() != null) { + ItemDataRewriter.applyJavaOverride(javaItemMapping.overrideTag(), data, BedrockProtocol.MAPPINGS.getJavaRegistries()); + } +- if (javaItemMapping.name() != null) { ++ if (javaItemMapping.name() != null && ordinaryNameKey == null) { + 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 +250,14 @@ public class ItemRewriter extends StoredObject { + final StructuredDataContainer data = ProtocolConstants.createStructuredDataContainer(); + + if (itemDefinition != null) { +- if (itemDefinition.displayNameComponent() != null) { +- data.set(StructuredDataKey.ITEM_NAME, TextUtil.stringToNbt(resourcePackStorage.getTexts().translate(itemDefinition.displayNameComponent()))); +- } else if (this.componentItems.contains(identifier)) { +- data.set(StructuredDataKey.ITEM_NAME, TextUtil.stringToNbt(resourcePackStorage.getTexts().get("item." + Key.stripMinecraftNamespace(identifier)))); +- } else { +- data.set(StructuredDataKey.ITEM_NAME, TextUtil.stringToNbt(resourcePackStorage.getTexts().get("item." + Key.stripMinecraftNamespace(identifier) + ".name"))); ++ if (ordinaryNameKey == null) { ++ if (itemDefinition.displayNameComponent() != null) { ++ data.set(StructuredDataKey.ITEM_NAME, TextUtil.stringToNbt(resourcePackStorage.getTexts().translate(itemDefinition.displayNameComponent()))); ++ } else if (this.componentItems.contains(identifier)) { ++ data.set(StructuredDataKey.ITEM_NAME, TextUtil.stringToNbt(resourcePackStorage.getTexts().get("item." + Key.stripMinecraftNamespace(identifier)))); ++ } else { ++ data.set(StructuredDataKey.ITEM_NAME, TextUtil.stringToNbt(resourcePackStorage.getTexts().get("item." + Key.stripMinecraftNamespace(identifier) + ".name"))); ++ } + } + + if (itemDefinition.iconComponent() != null && resourcePackStorage.isLoadedOnJavaClient()) { +@@ -254,13 +266,21 @@ public class ItemRewriter extends StoredObject { + } else { + data.set(StructuredDataKey.LORE, new Tag[]{TextUtil.stringToNbt("§7[ViaBedrock] Custom item: " + identifier)}); + } +- } else { ++ } else if (ordinaryNameKey == null) { + ViaBedrock.getPlatform().getLogger().log(Level.WARNING, "Missing bedrock -> java item mapping for " + identifier); + data.set(StructuredDataKey.ITEM_NAME, TextUtil.stringToNbt("§cMissing item: " + identifier)); + } + javaItem = new StructuredItem(BedrockProtocol.MAPPINGS.getJavaItems().get("minecraft:paper"), bedrockItem.amount(), data); + } + ++ if (ordinaryNameKey != null) { ++ final var texts = this.user().get(ResourcePackStorage.class).getTexts(); ++ final String ordinaryName = NativeItemNames.ordinary(bedrockItem.tag(), () -> texts.translate(ordinaryNameKey), ++ texts::translate, (key, arguments) -> texts.translate(key, arguments.toArray())); ++ javaItem.dataContainer().set(StructuredDataKey.ITEM_NAME, ++ TextUtil.stringToNbt(ordinaryName)); ++ } ++ + ItemCooldown cooldown = this.cooldowns.get(identifier); + if (cooldown == null) { + final ItemDefinitions.ItemDefinition definition = this.user().get(ResourcePackStorage.class).getItems().get(identifier); +@@ -277,10 +297,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())); + } +- if (bedrockTag.get("display") instanceof CompoundTag display) { +- if (display.contains("Name")) { // Bedrock client defaults to empty string if the type is wrong +- javaItem.dataContainer().set(StructuredDataKey.CUSTOM_NAME, TextUtil.stringToNbt(display.getString("Name", ""))); +- } ++ final String customName = this.registry.customName(identifier, bedrockTag); ++ if (customName != null) { ++ javaItem.dataContainer().set(StructuredDataKey.CUSTOM_NAME, TextUtil.stringToNbt(customName)); + } + } + +@@ -372,6 +391,43 @@ public class ItemRewriter extends StoredObject { + return this.componentItems; + } + ++ /** Match network-loaded stack queries while their connection registry remains current. */ ++ public boolean matchesStackQuery(final BedrockItem query, final BedrockItem candidate) { ++ if (query == null || candidate == null || this.registry.definition(query.identifier()) == null ++ || this.registry.definition(candidate.identifier()) == null) { ++ return false; ++ } ++ final var blocks = this.user().get(BlockStateRewriter.class); ++ return NativeItemStackMatcher.matches(query, candidate, blocks::blockState, blocks::restrictionBlocks); ++ } ++ ++ public boolean canMerge(final BedrockItem source, final BedrockItem destination) { ++ if (destination == null || destination.isEmpty()) { ++ return false; ++ } ++ final String identifier = this.items.inverse().get(destination.identifier()); ++ final var properties = identifier == null ? null : this.stackingProperties.get(identifier); ++ return NativeItemStacking.canMerge(source, destination, properties, ++ this::stackingAuxiliary, this.user().get(BlockStateRewriter.class)::restrictionBlocks); ++ } ++ ++ private Short stackingAuxiliary(final BedrockItem item) { ++ if (!item.hasBlockState() || item.data() == Short.MAX_VALUE) { ++ return item.data(); ++ } ++ final var block = this.user().get(BlockStateRewriter.class).blockItemRecipeDescriptor(item.blockRuntimeId()); ++ return block == null ? null : block.auxiliary(); ++ } ++ ++ public int maxStackSize(final BedrockItem item) { ++ if (item == null || item.isEmpty()) { ++ return 64; ++ } ++ final String identifier = this.items.inverse().get(item.identifier()); ++ final var properties = identifier == null ? null : this.stackingProperties.get(identifier); ++ return properties == null ? 0 : properties.maximum(); ++ } ++ + public Type itemType() { + return this.itemType; + } 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 b9a48f7ec..a04c95390 100644 +index 3d7f067ef..817633947 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; - import net.raphimc.viabedrock.api.model.BlockState; +@@ -44,6 +44,7 @@ import net.raphimc.viabedrock.api.model.BlockState; import net.raphimc.viabedrock.api.model.entity.*; import net.raphimc.viabedrock.protocol.BedrockProtocol; + import net.raphimc.viabedrock.protocol.data.enums.bedrock.ActorFlags; +import net.raphimc.viabedrock.protocol.packet.NpcDialoguePackets; 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; -@@ -138,8 +139,9 @@ public class EntityTracker extends StoredObject { +@@ -139,8 +140,9 @@ public class EntityTracker extends StoredObject { this.entities.remove(entity.uniqueId()); final NpcDialogueStorage npcDialogue = this.user().get(NpcDialogueStorage.class); @@ -1767,25 +4062,69 @@ index b9a48f7ec..a04c95390 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 79dea4c47..42bbe4cef 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.fastutil.ints.IntIntImmutablePair; + import com.viaversion.viaversion.libs.fastutil.ints.IntIntPair; + import com.viaversion.viaversion.libs.mcstructs.text.TextComponent; + import net.raphimc.viabedrock.protocol.BedrockProtocol; ++import net.raphimc.viabedrock.protocol.model.BaseGameVersion; + import net.raphimc.viabedrock.protocol.data.JavaRegistries; + 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 { + 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 movementRewindHistorySize; + private GameType levelGameType; +@@ -102,6 +104,14 @@ public class GameSessionStorage extends StoredObject { + this.availableEntityIdentifiers.add(entityIdentifier); + } + ++ public BaseGameVersion getBedrockBaseGameVersion() { ++ return this.bedrockBaseGameVersion; ++ } ++ ++ public void setBedrockBaseGameVersion(final BaseGameVersion bedrockBaseGameVersion) { ++ this.bedrockBaseGameVersion = bedrockBaseGameVersion; ++ } ++ + public Semver getBedrockVanillaVersion() { + return this.bedrockVanillaVersion; + } diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryTracker.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryTracker.java -index 8fa38f207..3e0723358 100644 +index 8fa38f207..cf0850a00 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/InventoryTracker.java -@@ -125,6 +125,13 @@ public class InventoryTracker extends StoredObject { - this.pendingCloseContainer = null; +@@ -115,6 +115,7 @@ public class InventoryTracker extends StoredObject { + this.currentContainer = null; + } + this.pendingCloseContainer = container; ++ this.clearNativeContainerActions(); } + public void setCurrentContainerClosed(final boolean serverInitiated) { +@@ -123,6 +124,14 @@ public class InventoryTracker extends StoredObject { + } + this.currentContainer = null; + this.pendingCloseContainer = null; ++ this.clearNativeContainerActions(); ++ } ++ + /** A server form replaces the inventory screen before its close acknowledgement arrives. */ + public void closeContainerForForm() { + if (this.currentContainer != null) { + this.forceCloseCurrentContainer(); + } -+ } -+ + } + public void closeCurrentForm() { - if (this.currentForm == null) { - throw new IllegalStateException("There is no form currently open"); -@@ -168,10 +175,13 @@ public class InventoryTracker extends StoredObject { +@@ -168,10 +177,13 @@ public class InventoryTracker extends StoredObject { return this.currentContainer != null || this.pendingCloseContainer != null; } @@ -1802,6 +4141,20 @@ index 8fa38f207..3e0723358 100644 } public InventoryContainer getInventoryContainer() { +@@ -190,6 +202,13 @@ public class InventoryTracker extends StoredObject { + return this.hudContainer; + } + ++ private void clearNativeContainerActions() { ++ final var state = this.user() == null ? null : this.user().get(NativeContainerUiState.class); ++ if (state != null) { ++ state.clearActions(); ++ } ++ } ++ + public Container getCurrentContainer() { + return this.currentContainer; + } diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/NativeContainerUiState.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/NativeContainerUiState.java new file mode 100644 index 000000000..c7caa5a6a @@ -2061,10 +4414,12439 @@ index 000000000..044447735 + out < 0 ? this.fadeOut : Math.min(out, 72000)); + } + -+ private int ticks() { -+ return this.fadeIn + this.stay + this.fadeOut; ++ private int ticks() { ++ return this.fadeIn + this.stay + this.fadeOut; ++ } ++ ++ } ++ ++} +diff --git a/src/main/resources/assets/viabedrock/data/bedrock/block_name_keys.json b/src/main/resources/assets/viabedrock/data/bedrock/block_name_keys.json +new file mode 100644 +index 000000000..5269489a9 +--- /dev/null ++++ b/src/main/resources/assets/viabedrock/data/bedrock/block_name_keys.json +@@ -0,0 +1,1385 @@ ++{ ++ "format": 1, ++ "protocol": 2193, ++ "names": { ++ "minecraft:acacia_button": "tile.acacia_button.name", ++ "minecraft:acacia_door": "tile.acacia_door.name", ++ "minecraft:acacia_double_slab": "tile.double_wooden_slab.acacia.name", ++ "minecraft:acacia_fence": "tile.acaciaFence.name", ++ "minecraft:acacia_fence_gate": "tile.acacia_fence_gate.name", ++ "minecraft:acacia_hanging_sign": "tile.acacia_hanging_sign.name", ++ "minecraft:acacia_leaves": "tile.leaves.acacia.name", ++ "minecraft:acacia_log": "tile.log.acacia.name", ++ "minecraft:acacia_planks": "tile.planks.acacia.name", ++ "minecraft:acacia_pressure_plate": "tile.acacia_pressure_plate.name", ++ "minecraft:acacia_sapling": "tile.sapling.acacia.name", ++ "minecraft:acacia_shelf": "tile.acacia_shelf.name", ++ "minecraft:acacia_slab": "tile.wooden_slab.acacia.name", ++ "minecraft:acacia_stairs": "tile.acacia_stairs.name", ++ "minecraft:acacia_standing_sign": "tile.acacia_standing_sign.name", ++ "minecraft:acacia_trapdoor": "tile.acacia_trapdoor.name", ++ "minecraft:acacia_wall_sign": "tile.acacia_wall_sign.name", ++ "minecraft:acacia_wood": "tile.wood.acacia.name", ++ "minecraft:activator_rail": "tile.activator_rail.name", ++ "minecraft:air": "tile.air.name", ++ "minecraft:allium": "tile.red_flower.allium.name", ++ "minecraft:allow": "tile.allow.name", ++ "minecraft:amethyst_block": "tile.amethyst_block.name", ++ "minecraft:amethyst_cluster": "tile.amethyst_cluster.name", ++ "minecraft:ancient_debris": "tile.ancient_debris.name", ++ "minecraft:andesite": "tile.stone.andesite.name", ++ "minecraft:andesite_double_slab": "tile.double_stone_slab3.andesite.name", ++ "minecraft:andesite_slab": "tile.stone_slab3.andesite.name", ++ "minecraft:andesite_stairs": "tile.andesite_stairs.name", ++ "minecraft:andesite_wall": "tile.cobblestone_wall.andesite.name", ++ "minecraft:anvil": "tile.anvil.intact.name", ++ "minecraft:azalea": "tile.azalea.name", ++ "minecraft:azalea_leaves": "tile.azalea_leaves.name", ++ "minecraft:azalea_leaves_flowered": "tile.azalea_leaves_flowered.name", ++ "minecraft:azure_bluet": "tile.red_flower.houstonia.name", ++ "minecraft:bamboo": "tile.bamboo.name", ++ "minecraft:bamboo_block": "tile.bamboo_block.name", ++ "minecraft:bamboo_button": "tile.bamboo_button.name", ++ "minecraft:bamboo_door": "tile.bamboo_door.name", ++ "minecraft:bamboo_double_slab": "tile.bamboo_double_slab.name", ++ "minecraft:bamboo_fence": "tile.bamboo_fence.name", ++ "minecraft:bamboo_fence_gate": "tile.bamboo_fence_gate.name", ++ "minecraft:bamboo_hanging_sign": "tile.bamboo_hanging_sign.name", ++ "minecraft:bamboo_mosaic": "tile.bamboo_mosaic.name", ++ "minecraft:bamboo_mosaic_double_slab": "tile.bamboo_mosaic_double_slab.name", ++ "minecraft:bamboo_mosaic_slab": "tile.bamboo_mosaic_slab.name", ++ "minecraft:bamboo_mosaic_stairs": "tile.bamboo_mosaic_stairs.name", ++ "minecraft:bamboo_planks": "tile.bamboo_planks.name", ++ "minecraft:bamboo_pressure_plate": "tile.bamboo_pressure_plate.name", ++ "minecraft:bamboo_sapling": "tile.bamboo_sapling.name", ++ "minecraft:bamboo_shelf": "tile.bamboo_shelf.name", ++ "minecraft:bamboo_slab": "tile.bamboo_slab.name", ++ "minecraft:bamboo_stairs": "tile.bamboo_stairs.name", ++ "minecraft:bamboo_standing_sign": "tile.bamboo_standing_sign.name", ++ "minecraft:bamboo_trapdoor": "tile.bamboo_trapdoor.name", ++ "minecraft:bamboo_wall_sign": "tile.bamboo_wall_sign.name", ++ "minecraft:barrel": "tile.barrel.name", ++ "minecraft:barrier": "tile.barrier.name", ++ "minecraft:basalt": "tile.basalt.name", ++ "minecraft:beacon": "tile.beacon.name", ++ "minecraft:bed": "tile.bed.name", ++ "minecraft:bedrock": "tile.bedrock.name", ++ "minecraft:bee_nest": "tile.bee_nest.name", ++ "minecraft:beehive": "tile.beehive.name", ++ "minecraft:beetroot": "tile.beetroot.name", ++ "minecraft:bell": "tile.bell.name", ++ "minecraft:big_dripleaf": "tile.big_dripleaf.name", ++ "minecraft:birch_button": "tile.birch_button.name", ++ "minecraft:birch_door": "tile.birch_door.name", ++ "minecraft:birch_double_slab": "tile.double_wooden_slab.birch.name", ++ "minecraft:birch_fence": "tile.birchFence.name", ++ "minecraft:birch_fence_gate": "tile.birch_fence_gate.name", ++ "minecraft:birch_hanging_sign": "tile.birch_hanging_sign.name", ++ "minecraft:birch_leaves": "tile.leaves.birch.name", ++ "minecraft:birch_log": "tile.log.birch.name", ++ "minecraft:birch_planks": "tile.planks.birch.name", ++ "minecraft:birch_pressure_plate": "tile.birch_pressure_plate.name", ++ "minecraft:birch_sapling": "tile.sapling.birch.name", ++ "minecraft:birch_shelf": "tile.birch_shelf.name", ++ "minecraft:birch_slab": "tile.wooden_slab.birch.name", ++ "minecraft:birch_stairs": "tile.birch_stairs.name", ++ "minecraft:birch_standing_sign": "tile.birch_standing_sign.name", ++ "minecraft:birch_trapdoor": "tile.birch_trapdoor.name", ++ "minecraft:birch_wall_sign": "tile.birch_wall_sign.name", ++ "minecraft:birch_wood": "tile.wood.birch.name", ++ "minecraft:black_candle": "tile.black_candle.name", ++ "minecraft:black_candle_cake": "tile.black_candle_cake.name", ++ "minecraft:black_carpet": "tile.carpet.black.name", ++ "minecraft:black_concrete": "tile.concrete.black.name", ++ "minecraft:black_concrete_powder": "tile.concretePowder.black.name", ++ "minecraft:black_glazed_terracotta": "tile.glazedTerracotta.black.name", ++ "minecraft:black_shulker_box": "tile.shulkerBoxBlack.name", ++ "minecraft:black_stained_glass": "tile.stained_glass.black.name", ++ "minecraft:black_stained_glass_pane": "tile.stained_glass_pane.black.name", ++ "minecraft:black_terracotta": "tile.stained_hardened_clay.black.name", ++ "minecraft:black_wool": "tile.wool.black.name", ++ "minecraft:blackstone": "tile.blackstone.name", ++ "minecraft:blackstone_double_slab": "tile.blackstone_double_slab.name", ++ "minecraft:blackstone_slab": "tile.blackstone_slab.name", ++ "minecraft:blackstone_stairs": "tile.blackstone_stairs.name", ++ "minecraft:blackstone_wall": "tile.blackstone_wall.name", ++ "minecraft:blast_furnace": "tile.blast_furnace.name", ++ "minecraft:blue_candle": "tile.blue_candle.name", ++ "minecraft:blue_candle_cake": "tile.blue_candle_cake.name", ++ "minecraft:blue_carpet": "tile.carpet.blue.name", ++ "minecraft:blue_concrete": "tile.concrete.blue.name", ++ "minecraft:blue_concrete_powder": "tile.concretePowder.blue.name", ++ "minecraft:blue_glazed_terracotta": "tile.glazedTerracotta.blue.name", ++ "minecraft:blue_ice": "tile.blue_ice.name", ++ "minecraft:blue_orchid": "tile.red_flower.blueOrchid.name", ++ "minecraft:blue_shulker_box": "tile.shulkerBoxBlue.name", ++ "minecraft:blue_stained_glass": "tile.stained_glass.blue.name", ++ "minecraft:blue_stained_glass_pane": "tile.stained_glass_pane.blue.name", ++ "minecraft:blue_terracotta": "tile.stained_hardened_clay.blue.name", ++ "minecraft:blue_wool": "tile.wool.blue.name", ++ "minecraft:bone_block": "tile.bone_block.name", ++ "minecraft:bookshelf": "tile.bookshelf.name", ++ "minecraft:border_block": "tile.border_block.name", ++ "minecraft:brain_coral": "tile.coral.pink.name", ++ "minecraft:brain_coral_block": "tile.coral_block.pink.name", ++ "minecraft:brain_coral_fan": "tile.coral_fan.pink_fan.name", ++ "minecraft:brain_coral_wall_fan": "tile.brain_coral_wall_fan.name", ++ "minecraft:brewing_stand": "item.brewing_stand.name", ++ "minecraft:brick_block": "tile.brick_block.name", ++ "minecraft:brick_double_slab": "tile.double_stone_slab.brick.name", ++ "minecraft:brick_slab": "tile.stone_slab.brick.name", ++ "minecraft:brick_stairs": "tile.brick_stairs.name", ++ "minecraft:brick_wall": "tile.cobblestone_wall.brick.name", ++ "minecraft:brown_candle": "tile.brown_candle.name", ++ "minecraft:brown_candle_cake": "tile.brown_candle_cake.name", ++ "minecraft:brown_carpet": "tile.carpet.brown.name", ++ "minecraft:brown_concrete": "tile.concrete.brown.name", ++ "minecraft:brown_concrete_powder": "tile.concretePowder.brown.name", ++ "minecraft:brown_glazed_terracotta": "tile.glazedTerracotta.brown.name", ++ "minecraft:brown_mushroom": "tile.brown_mushroom.name", ++ "minecraft:brown_mushroom_block": "tile.brown_mushroom_block.cap.name", ++ "minecraft:brown_shulker_box": "tile.shulkerBoxBrown.name", ++ "minecraft:brown_stained_glass": "tile.stained_glass.brown.name", ++ "minecraft:brown_stained_glass_pane": "tile.stained_glass_pane.brown.name", ++ "minecraft:brown_terracotta": "tile.stained_hardened_clay.brown.name", ++ "minecraft:brown_wool": "tile.wool.brown.name", ++ "minecraft:bubble_column": "tile.bubble_column.name", ++ "minecraft:bubble_coral": "tile.coral.purple.name", ++ "minecraft:bubble_coral_block": "tile.coral_block.purple.name", ++ "minecraft:bubble_coral_fan": "tile.coral_fan.purple_fan.name", ++ "minecraft:bubble_coral_wall_fan": "tile.bubble_coral_wall_fan.name", ++ "minecraft:budding_amethyst": "tile.budding_amethyst.name", ++ "minecraft:bush": "tile.bush.name", ++ "minecraft:cactus": "tile.cactus.name", ++ "minecraft:cactus_flower": "tile.cactus_flower.name", ++ "minecraft:cake": "tile.cake.name", ++ "minecraft:calcite": "tile.calcite.name", ++ "minecraft:calibrated_sculk_sensor": "tile.calibrated_sculk_sensor.name", ++ "minecraft:camera": "tile.camera.name", ++ "minecraft:campfire": "tile.campfire.name", ++ "minecraft:candle": "tile.candle.name", ++ "minecraft:candle_cake": "tile.candle_cake.name", ++ "minecraft:carrots": "tile.carrots.name", ++ "minecraft:cartography_table": "tile.cartography_table.name", ++ "minecraft:carved_pumpkin": "tile.carved_pumpkin.name", ++ "minecraft:cauldron": "tile.cauldron.name", ++ "minecraft:cave_vines": "tile.cave_vines.name", ++ "minecraft:cave_vines_body_with_berries": "tile.cave_vines_body_with_berries.name", ++ "minecraft:cave_vines_head_with_berries": "tile.cave_vines_head_with_berries.name", ++ "minecraft:chain_command_block": "tile.chain_command_block.name", ++ "minecraft:chalkboard": "tile.chalkboard.name", ++ "minecraft:chemical_heat": "tile.chemical_heat.name", ++ "minecraft:cherry_button": "tile.cherry_button.name", ++ "minecraft:cherry_door": "tile.cherry_door.name", ++ "minecraft:cherry_double_slab": "tile.cherry_double_slab.name", ++ "minecraft:cherry_fence": "tile.cherry_fence.name", ++ "minecraft:cherry_fence_gate": "tile.cherry_fence_gate.name", ++ "minecraft:cherry_hanging_sign": "tile.cherry_hanging_sign.name", ++ "minecraft:cherry_leaves": "tile.cherry_leaves.name", ++ "minecraft:cherry_log": "tile.cherry_log.name", ++ "minecraft:cherry_planks": "tile.cherry_planks.name", ++ "minecraft:cherry_pressure_plate": "tile.cherry_pressure_plate.name", ++ "minecraft:cherry_sapling": "tile.cherry_sapling.name", ++ "minecraft:cherry_shelf": "tile.cherry_shelf.name", ++ "minecraft:cherry_slab": "tile.cherry_slab.name", ++ "minecraft:cherry_stairs": "tile.cherry_stairs.name", ++ "minecraft:cherry_standing_sign": "tile.cherry_standing_sign.name", ++ "minecraft:cherry_trapdoor": "tile.cherry_trapdoor.name", ++ "minecraft:cherry_wall_sign": "tile.cherry_wall_sign.name", ++ "minecraft:cherry_wood": "tile.cherry_wood.name", ++ "minecraft:chest": "tile.chest.name", ++ "minecraft:chipped_anvil": "tile.anvil.slightlyDamaged.name", ++ "minecraft:chiseled_bookshelf": "tile.chiseled_bookshelf.name", ++ "minecraft:chiseled_cinnabar": "tile.chiseled_cinnabar.name", ++ "minecraft:chiseled_copper": "tile.chiseled_copper.name", ++ "minecraft:chiseled_deepslate": "tile.chiseled_deepslate.name", ++ "minecraft:chiseled_nether_bricks": "tile.chiseled_nether_bricks.name", ++ "minecraft:chiseled_polished_blackstone": "tile.chiseled_polished_blackstone.name", ++ "minecraft:chiseled_quartz_block": "tile.quartz_block.chiseled.name", ++ "minecraft:chiseled_red_sandstone": "tile.red_sandstone.chiseled.name", ++ "minecraft:chiseled_resin_bricks": "tile.chiseled_resin_bricks.name", ++ "minecraft:chiseled_sandstone": "tile.sandstone.chiseled.name", ++ "minecraft:chiseled_stone_bricks": "tile.stonebrick.chiseled.name", ++ "minecraft:chiseled_sulfur": "tile.chiseled_sulfur.name", ++ "minecraft:chiseled_tuff": "tile.chiseled_tuff.name", ++ "minecraft:chiseled_tuff_bricks": "tile.chiseled_tuff_bricks.name", ++ "minecraft:chorus_flower": "tile.chorus_flower.name", ++ "minecraft:chorus_plant": "tile.chorus_plant.name", ++ "minecraft:cinnabar": "tile.cinnabar.name", ++ "minecraft:cinnabar_brick_double_slab": "tile.cinnabar_brick_double_slab.name", ++ "minecraft:cinnabar_brick_slab": "tile.cinnabar_brick_slab.name", ++ "minecraft:cinnabar_brick_stairs": "tile.cinnabar_brick_stairs.name", ++ "minecraft:cinnabar_brick_wall": "tile.cinnabar_brick_wall.name", ++ "minecraft:cinnabar_bricks": "tile.cinnabar_bricks.name", ++ "minecraft:cinnabar_double_slab": "tile.cinnabar_double_slab.name", ++ "minecraft:cinnabar_slab": "tile.cinnabar_slab.name", ++ "minecraft:cinnabar_stairs": "tile.cinnabar_stairs.name", ++ "minecraft:cinnabar_wall": "tile.cinnabar_wall.name", ++ "minecraft:clay": "tile.clay.name", ++ "minecraft:client_request_placeholder_block": "tile.client_request_placeholder_block.name", ++ "minecraft:closed_eyeblossom": "tile.closed_eyeblossom.name", ++ "minecraft:coal_block": "tile.coal_block.name", ++ "minecraft:coal_ore": "tile.coal_ore.name", ++ "minecraft:coarse_dirt": "tile.dirt.coarse.name", ++ "minecraft:cobbled_deepslate": "tile.cobbled_deepslate.name", ++ "minecraft:cobbled_deepslate_double_slab": "tile.cobbled_deepslate_double_slab.name", ++ "minecraft:cobbled_deepslate_slab": "tile.cobbled_deepslate_slab.name", ++ "minecraft:cobbled_deepslate_stairs": "tile.cobbled_deepslate_stairs.name", ++ "minecraft:cobbled_deepslate_wall": "tile.cobbled_deepslate_wall.name", ++ "minecraft:cobblestone": "tile.cobblestone.name", ++ "minecraft:cobblestone_double_slab": "tile.double_stone_slab.cobble.name", ++ "minecraft:cobblestone_slab": "tile.stone_slab.cobble.name", ++ "minecraft:cobblestone_wall": "tile.cobblestone_wall.normal.name", ++ "minecraft:cocoa": "tile.cocoa.name", ++ "minecraft:colored_torch_blue": "tile.colored_torch.blue.name", ++ "minecraft:colored_torch_green": "tile.colored_torch.green.name", ++ "minecraft:colored_torch_purple": "tile.colored_torch.purple.name", ++ "minecraft:colored_torch_red": "tile.colored_torch.red.name", ++ "minecraft:command_block": "tile.command_block.name", ++ "minecraft:composter": "tile.composter.name", ++ "minecraft:compound_creator": "tile.compoundcreator.name", ++ "minecraft:conduit": "tile.conduit.name", ++ "minecraft:copper_bars": "tile.copper_bars.name", ++ "minecraft:copper_block": "tile.copper_block.name", ++ "minecraft:copper_bulb": "tile.copper_bulb.name", ++ "minecraft:copper_chain": "tile.copper_chain.name", ++ "minecraft:copper_chest": "tile.copper_chest.name", ++ "minecraft:copper_door": "tile.copper_door.name", ++ "minecraft:copper_golem_statue": "tile.copper_golem_statue.name", ++ "minecraft:copper_grate": "tile.copper_grate.name", ++ "minecraft:copper_lantern": "tile.copper_lantern.name", ++ "minecraft:copper_ore": "tile.copper_ore.name", ++ "minecraft:copper_torch": "tile.copper_torch.name", ++ "minecraft:copper_trapdoor": "tile.copper_trapdoor.name", ++ "minecraft:cornflower": "tile.red_flower.cornflower.name", ++ "minecraft:cracked_deepslate_bricks": "tile.cracked_deepslate_bricks.name", ++ "minecraft:cracked_deepslate_tiles": "tile.cracked_deepslate_tiles.name", ++ "minecraft:cracked_nether_bricks": "tile.cracked_nether_bricks.name", ++ "minecraft:cracked_polished_blackstone_bricks": "tile.cracked_polished_blackstone_bricks.name", ++ "minecraft:cracked_stone_bricks": "tile.stonebrick.cracked.name", ++ "minecraft:crafter": "tile.crafter.name", ++ "minecraft:crafting_table": "tile.crafting_table.name", ++ "minecraft:creaking_heart": "tile.creaking_heart.name", ++ "minecraft:creeper_head": "item.skull.creeper.name", ++ "minecraft:crimson_button": "tile.crimson_button.name", ++ "minecraft:crimson_door": "tile.crimson_door.name", ++ "minecraft:crimson_double_slab": "tile.crimson_double_slab.name", ++ "minecraft:crimson_fence": "tile.crimson_fence.name", ++ "minecraft:crimson_fence_gate": "tile.crimson_fence_gate.name", ++ "minecraft:crimson_fungus": "tile.crimson_fungus.name", ++ "minecraft:crimson_hanging_sign": "tile.crimson_hanging_sign.name", ++ "minecraft:crimson_hyphae": "tile.crimson_hyphae.name", ++ "minecraft:crimson_nylium": "tile.crimson_nylium.name", ++ "minecraft:crimson_planks": "tile.crimson_planks.name", ++ "minecraft:crimson_pressure_plate": "tile.crimson_pressure_plate.name", ++ "minecraft:crimson_roots": "tile.crimson_roots.crimsonRoots.name", ++ "minecraft:crimson_shelf": "tile.crimson_shelf.name", ++ "minecraft:crimson_slab": "tile.crimson_slab.name", ++ "minecraft:crimson_stairs": "tile.crimson_stairs.name", ++ "minecraft:crimson_standing_sign": "tile.crimson_standing_sign.name", ++ "minecraft:crimson_stem": "tile.crimson_stem.name", ++ "minecraft:crimson_trapdoor": "tile.crimson_trapdoor.name", ++ "minecraft:crimson_wall_sign": "tile.crimson_wall_sign.name", ++ "minecraft:crying_obsidian": "tile.crying_obsidian.name", ++ "minecraft:cut_copper": "tile.cut_copper.name", ++ "minecraft:cut_copper_slab": "tile.cut_copper_slab.name", ++ "minecraft:cut_copper_stairs": "tile.cut_copper_stairs.name", ++ "minecraft:cut_red_sandstone": "tile.red_sandstone.cut.name", ++ "minecraft:cut_red_sandstone_double_slab": "tile.double_stone_slab4.cut_red_sandstone.name", ++ "minecraft:cut_red_sandstone_slab": "tile.stone_slab4.cut_red_sandstone.name", ++ "minecraft:cut_sandstone": "tile.sandstone.cut.name", ++ "minecraft:cut_sandstone_double_slab": "tile.double_stone_slab4.cut_sandstone.name", ++ "minecraft:cut_sandstone_slab": "tile.stone_slab4.cut_sandstone.name", ++ "minecraft:cyan_candle": "tile.cyan_candle.name", ++ "minecraft:cyan_candle_cake": "tile.cyan_candle_cake.name", ++ "minecraft:cyan_carpet": "tile.carpet.cyan.name", ++ "minecraft:cyan_concrete": "tile.concrete.cyan.name", ++ "minecraft:cyan_concrete_powder": "tile.concretePowder.cyan.name", ++ "minecraft:cyan_glazed_terracotta": "tile.glazedTerracotta.cyan.name", ++ "minecraft:cyan_shulker_box": "tile.shulkerBoxCyan.name", ++ "minecraft:cyan_stained_glass": "tile.stained_glass.cyan.name", ++ "minecraft:cyan_stained_glass_pane": "tile.stained_glass_pane.cyan.name", ++ "minecraft:cyan_terracotta": "tile.stained_hardened_clay.cyan.name", ++ "minecraft:cyan_wool": "tile.wool.cyan.name", ++ "minecraft:damaged_anvil": "tile.anvil.veryDamaged.name", ++ "minecraft:dandelion": "tile.yellow_flower.dandelion.name", ++ "minecraft:dark_oak_button": "tile.dark_oak_button.name", ++ "minecraft:dark_oak_door": "tile.dark_oak_door.name", ++ "minecraft:dark_oak_double_slab": "tile.double_wooden_slab.big_oak.name", ++ "minecraft:dark_oak_fence": "tile.darkOakFence.name", ++ "minecraft:dark_oak_fence_gate": "tile.dark_oak_fence_gate.name", ++ "minecraft:dark_oak_hanging_sign": "tile.dark_oak_hanging_sign.name", ++ "minecraft:dark_oak_leaves": "tile.leaves.big_oak.name", ++ "minecraft:dark_oak_log": "tile.log.big_oak.name", ++ "minecraft:dark_oak_planks": "tile.planks.big_oak.name", ++ "minecraft:dark_oak_pressure_plate": "tile.dark_oak_pressure_plate.name", ++ "minecraft:dark_oak_sapling": "tile.sapling.big_oak.name", ++ "minecraft:dark_oak_shelf": "tile.dark_oak_shelf.name", ++ "minecraft:dark_oak_slab": "tile.wooden_slab.big_oak.name", ++ "minecraft:dark_oak_stairs": "tile.dark_oak_stairs.name", ++ "minecraft:dark_oak_trapdoor": "tile.dark_oak_trapdoor.name", ++ "minecraft:dark_oak_wood": "tile.wood.dark_oak.name", ++ "minecraft:dark_prismarine": "tile.prismarine.dark.name", ++ "minecraft:dark_prismarine_double_slab": "tile.double_stone_slab2.prismarine.dark.name", ++ "minecraft:dark_prismarine_slab": "tile.stone_slab2.prismarine.dark.name", ++ "minecraft:dark_prismarine_stairs": "tile.dark_prismarine_stairs.name", ++ "minecraft:darkoak_standing_sign": "tile.darkoak_standing_sign.name", ++ "minecraft:darkoak_wall_sign": "tile.darkoak_wall_sign.name", ++ "minecraft:daylight_detector": "tile.daylight_detector.name", ++ "minecraft:daylight_detector_inverted": "tile.daylight_detector_inverted.name", ++ "minecraft:dead_brain_coral": "tile.coral.pink_dead.name", ++ "minecraft:dead_brain_coral_block": "tile.coral_block.pink_dead.name", ++ "minecraft:dead_brain_coral_fan": "tile.coral_fan_dead.pink_fan.name", ++ "minecraft:dead_brain_coral_wall_fan": "tile.dead_brain_coral_wall_fan.name", ++ "minecraft:dead_bubble_coral": "tile.coral.purple_dead.name", ++ "minecraft:dead_bubble_coral_block": "tile.coral_block.purple_dead.name", ++ "minecraft:dead_bubble_coral_fan": "tile.coral_fan_dead.purple_fan.name", ++ "minecraft:dead_bubble_coral_wall_fan": "tile.dead_bubble_coral_wall_fan.name", ++ "minecraft:dead_fire_coral": "tile.coral.red_dead.name", ++ "minecraft:dead_fire_coral_block": "tile.coral_block.red_dead.name", ++ "minecraft:dead_fire_coral_fan": "tile.coral_fan_dead.red_fan.name", ++ "minecraft:dead_fire_coral_wall_fan": "tile.dead_fire_coral_wall_fan.name", ++ "minecraft:dead_horn_coral": "tile.coral.yellow_dead.name", ++ "minecraft:dead_horn_coral_block": "tile.coral_block.yellow_dead.name", ++ "minecraft:dead_horn_coral_fan": "tile.coral_fan_dead.yellow_fan.name", ++ "minecraft:dead_horn_coral_wall_fan": "tile.dead_horn_coral_wall_fan.name", ++ "minecraft:dead_tube_coral": "tile.coral.blue_dead.name", ++ "minecraft:dead_tube_coral_block": "tile.coral_block.blue_dead.name", ++ "minecraft:dead_tube_coral_fan": "tile.coral_fan_dead.blue_fan.name", ++ "minecraft:dead_tube_coral_wall_fan": "tile.dead_tube_coral_wall_fan.name", ++ "minecraft:deadbush": "tile.deadbush.name", ++ "minecraft:decorated_pot": "tile.decorated_pot.name", ++ "minecraft:deepslate": "tile.deepslate.name", ++ "minecraft:deepslate_brick_double_slab": "tile.deepslate_brick_double_slab.name", ++ "minecraft:deepslate_brick_slab": "tile.deepslate_brick_slab.name", ++ "minecraft:deepslate_brick_stairs": "tile.deepslate_brick_stairs.name", ++ "minecraft:deepslate_brick_wall": "tile.deepslate_brick_wall.name", ++ "minecraft:deepslate_bricks": "tile.deepslate_bricks.name", ++ "minecraft:deepslate_coal_ore": "tile.deepslate_coal_ore.name", ++ "minecraft:deepslate_copper_ore": "tile.deepslate_copper_ore.name", ++ "minecraft:deepslate_diamond_ore": "tile.deepslate_diamond_ore.name", ++ "minecraft:deepslate_emerald_ore": "tile.deepslate_emerald_ore.name", ++ "minecraft:deepslate_gold_ore": "tile.deepslate_gold_ore.name", ++ "minecraft:deepslate_iron_ore": "tile.deepslate_iron_ore.name", ++ "minecraft:deepslate_lapis_ore": "tile.deepslate_lapis_ore.name", ++ "minecraft:deepslate_redstone_ore": "tile.deepslate_redstone_ore.name", ++ "minecraft:deepslate_tile_double_slab": "tile.deepslate_tile_double_slab.name", ++ "minecraft:deepslate_tile_slab": "tile.deepslate_tile_slab.name", ++ "minecraft:deepslate_tile_stairs": "tile.deepslate_tile_stairs.name", ++ "minecraft:deepslate_tile_wall": "tile.deepslate_tile_wall.name", ++ "minecraft:deepslate_tiles": "tile.deepslate_tiles.name", ++ "minecraft:deny": "tile.deny.name", ++ "minecraft:deprecated_anvil": "tile.anvil.intact.name", ++ "minecraft:deprecated_purpur_block_1": "tile.purpur_block.chiseled.name", ++ "minecraft:deprecated_purpur_block_2": "tile.deprecated_purpur_block_2.name", ++ "minecraft:detector_rail": "tile.detector_rail.name", ++ "minecraft:diamond_block": "tile.diamond_block.name", ++ "minecraft:diamond_ore": "tile.diamond_ore.name", ++ "minecraft:diorite": "tile.stone.diorite.name", ++ "minecraft:diorite_double_slab": "tile.double_stone_slab3.diorite.name", ++ "minecraft:diorite_slab": "tile.stone_slab3.diorite.name", ++ "minecraft:diorite_stairs": "tile.diorite_stairs.name", ++ "minecraft:diorite_wall": "tile.cobblestone_wall.diorite.name", ++ "minecraft:dirt": "tile.dirt.default.name", ++ "minecraft:dirt_with_roots": "tile.dirt_with_roots.name", ++ "minecraft:dispenser": "tile.dispenser.name", ++ "minecraft:double_cut_copper_slab": "tile.double_cut_copper_slab.name", ++ "minecraft:dragon_egg": "tile.dragon_egg.name", ++ "minecraft:dragon_head": "item.skull.dragon.name", ++ "minecraft:dried_ghast": "tile.dried_ghast.name", ++ "minecraft:dried_kelp_block": "tile.dried_kelp_block.name", ++ "minecraft:dripstone_block": "tile.dripstone_block.name", ++ "minecraft:dropper": "tile.dropper.name", ++ "minecraft:element_0": "tile.element.unknown.name", ++ "minecraft:element_1": "tile.element.h.name", ++ "minecraft:element_10": "tile.element.ne.name", ++ "minecraft:element_100": "tile.element.fm.name", ++ "minecraft:element_101": "tile.element.md.name", ++ "minecraft:element_102": "tile.element.no.name", ++ "minecraft:element_103": "tile.element.lr.name", ++ "minecraft:element_104": "tile.element.rf.name", ++ "minecraft:element_105": "tile.element.db.name", ++ "minecraft:element_106": "tile.element.sg.name", ++ "minecraft:element_107": "tile.element.bh.name", ++ "minecraft:element_108": "tile.element.hs.name", ++ "minecraft:element_109": "tile.element.mt.name", ++ "minecraft:element_11": "tile.element.na.name", ++ "minecraft:element_110": "tile.element.ds.name", ++ "minecraft:element_111": "tile.element.rg.name", ++ "minecraft:element_112": "tile.element.cn.name", ++ "minecraft:element_113": "tile.element.nh.name", ++ "minecraft:element_114": "tile.element.fl.name", ++ "minecraft:element_115": "tile.element.mc.name", ++ "minecraft:element_116": "tile.element.lv.name", ++ "minecraft:element_117": "tile.element.ts.name", ++ "minecraft:element_118": "tile.element.og.name", ++ "minecraft:element_12": "tile.element.mg.name", ++ "minecraft:element_13": "tile.element.al.name", ++ "minecraft:element_14": "tile.element.si.name", ++ "minecraft:element_15": "tile.element.p.name", ++ "minecraft:element_16": "tile.element.s.name", ++ "minecraft:element_17": "tile.element.cl.name", ++ "minecraft:element_18": "tile.element.ar.name", ++ "minecraft:element_19": "tile.element.k.name", ++ "minecraft:element_2": "tile.element.he.name", ++ "minecraft:element_20": "tile.element.ca.name", ++ "minecraft:element_21": "tile.element.sc.name", ++ "minecraft:element_22": "tile.element.ti.name", ++ "minecraft:element_23": "tile.element.v.name", ++ "minecraft:element_24": "tile.element.cr.name", ++ "minecraft:element_25": "tile.element.mn.name", ++ "minecraft:element_26": "tile.element.fe.name", ++ "minecraft:element_27": "tile.element.co.name", ++ "minecraft:element_28": "tile.element.ni.name", ++ "minecraft:element_29": "tile.element.cu.name", ++ "minecraft:element_3": "tile.element.li.name", ++ "minecraft:element_30": "tile.element.zn.name", ++ "minecraft:element_31": "tile.element.ga.name", ++ "minecraft:element_32": "tile.element.ge.name", ++ "minecraft:element_33": "tile.element.as.name", ++ "minecraft:element_34": "tile.element.se.name", ++ "minecraft:element_35": "tile.element.br.name", ++ "minecraft:element_36": "tile.element.kr.name", ++ "minecraft:element_37": "tile.element.rb.name", ++ "minecraft:element_38": "tile.element.sr.name", ++ "minecraft:element_39": "tile.element.y.name", ++ "minecraft:element_4": "tile.element.be.name", ++ "minecraft:element_40": "tile.element.zr.name", ++ "minecraft:element_41": "tile.element.nb.name", ++ "minecraft:element_42": "tile.element.mo.name", ++ "minecraft:element_43": "tile.element.tc.name", ++ "minecraft:element_44": "tile.element.ru.name", ++ "minecraft:element_45": "tile.element.rh.name", ++ "minecraft:element_46": "tile.element.pd.name", ++ "minecraft:element_47": "tile.element.ag.name", ++ "minecraft:element_48": "tile.element.cd.name", ++ "minecraft:element_49": "tile.element.in.name", ++ "minecraft:element_5": "tile.element.b.name", ++ "minecraft:element_50": "tile.element.sn.name", ++ "minecraft:element_51": "tile.element.sb.name", ++ "minecraft:element_52": "tile.element.te.name", ++ "minecraft:element_53": "tile.element.i.name", ++ "minecraft:element_54": "tile.element.xe.name", ++ "minecraft:element_55": "tile.element.cs.name", ++ "minecraft:element_56": "tile.element.ba.name", ++ "minecraft:element_57": "tile.element.la.name", ++ "minecraft:element_58": "tile.element.ce.name", ++ "minecraft:element_59": "tile.element.pr.name", ++ "minecraft:element_6": "tile.element.c.name", ++ "minecraft:element_60": "tile.element.nd.name", ++ "minecraft:element_61": "tile.element.pm.name", ++ "minecraft:element_62": "tile.element.sm.name", ++ "minecraft:element_63": "tile.element.eu.name", ++ "minecraft:element_64": "tile.element.gd.name", ++ "minecraft:element_65": "tile.element.tb.name", ++ "minecraft:element_66": "tile.element.dy.name", ++ "minecraft:element_67": "tile.element.ho.name", ++ "minecraft:element_68": "tile.element.er.name", ++ "minecraft:element_69": "tile.element.tm.name", ++ "minecraft:element_7": "tile.element.n.name", ++ "minecraft:element_70": "tile.element.yb.name", ++ "minecraft:element_71": "tile.element.lu.name", ++ "minecraft:element_72": "tile.element.hf.name", ++ "minecraft:element_73": "tile.element.ta.name", ++ "minecraft:element_74": "tile.element.w.name", ++ "minecraft:element_75": "tile.element.re.name", ++ "minecraft:element_76": "tile.element.os.name", ++ "minecraft:element_77": "tile.element.ir.name", ++ "minecraft:element_78": "tile.element.pt.name", ++ "minecraft:element_79": "tile.element.au.name", ++ "minecraft:element_8": "tile.element.o.name", ++ "minecraft:element_80": "tile.element.hg.name", ++ "minecraft:element_81": "tile.element.tl.name", ++ "minecraft:element_82": "tile.element.pb.name", ++ "minecraft:element_83": "tile.element.bi.name", ++ "minecraft:element_84": "tile.element.po.name", ++ "minecraft:element_85": "tile.element.at.name", ++ "minecraft:element_86": "tile.element.rn.name", ++ "minecraft:element_87": "tile.element.fr.name", ++ "minecraft:element_88": "tile.element.ra.name", ++ "minecraft:element_89": "tile.element.ac.name", ++ "minecraft:element_9": "tile.element.f.name", ++ "minecraft:element_90": "tile.element.th.name", ++ "minecraft:element_91": "tile.element.pa.name", ++ "minecraft:element_92": "tile.element.u.name", ++ "minecraft:element_93": "tile.element.np.name", ++ "minecraft:element_94": "tile.element.pu.name", ++ "minecraft:element_95": "tile.element.am.name", ++ "minecraft:element_96": "tile.element.cm.name", ++ "minecraft:element_97": "tile.element.bk.name", ++ "minecraft:element_98": "tile.element.cf.name", ++ "minecraft:element_99": "tile.element.es.name", ++ "minecraft:element_constructor": "tile.elementconstructor.name", ++ "minecraft:emerald_block": "tile.emerald_block.name", ++ "minecraft:emerald_ore": "tile.emerald_ore.name", ++ "minecraft:enchanting_table": "tile.enchanting_table.name", ++ "minecraft:end_brick_stairs": "tile.end_brick_stairs.name", ++ "minecraft:end_bricks": "tile.end_bricks.name", ++ "minecraft:end_gateway": "tile.end_gateway.name", ++ "minecraft:end_portal": "tile.end_portal.name", ++ "minecraft:end_portal_frame": "tile.end_portal_frame.name", ++ "minecraft:end_rod": "tile.end_rod.name", ++ "minecraft:end_stone": "tile.end_stone.name", ++ "minecraft:end_stone_brick_double_slab": "tile.double_stone_slab3.end_brick.name", ++ "minecraft:end_stone_brick_slab": "tile.stone_slab3.end_brick.name", ++ "minecraft:end_stone_brick_wall": "tile.cobblestone_wall.end_brick.name", ++ "minecraft:ender_chest": "tile.ender_chest.name", ++ "minecraft:exposed_chiseled_copper": "tile.exposed_chiseled_copper.name", ++ "minecraft:exposed_copper": "tile.exposed_copper.name", ++ "minecraft:exposed_copper_bars": "tile.exposed_copper_bars.name", ++ "minecraft:exposed_copper_bulb": "tile.exposed_copper_bulb.name", ++ "minecraft:exposed_copper_chain": "tile.exposed_copper_chain.name", ++ "minecraft:exposed_copper_chest": "tile.exposed_copper_chest.name", ++ "minecraft:exposed_copper_door": "tile.exposed_copper_door.name", ++ "minecraft:exposed_copper_golem_statue": "tile.exposed_copper_golem_statue.name", ++ "minecraft:exposed_copper_grate": "tile.exposed_copper_grate.name", ++ "minecraft:exposed_copper_lantern": "tile.exposed_copper_lantern.name", ++ "minecraft:exposed_copper_trapdoor": "tile.exposed_copper_trapdoor.name", ++ "minecraft:exposed_cut_copper": "tile.exposed_cut_copper.name", ++ "minecraft:exposed_cut_copper_slab": "tile.exposed_cut_copper_slab.name", ++ "minecraft:exposed_cut_copper_stairs": "tile.exposed_cut_copper_stairs.name", ++ "minecraft:exposed_double_cut_copper_slab": "tile.exposed_double_cut_copper_slab.name", ++ "minecraft:exposed_lightning_rod": "tile.exposed_lightning_rod.name", ++ "minecraft:farmland": "tile.farmland.name", ++ "minecraft:fence_gate": "tile.fence_gate.name", ++ "minecraft:fern": "tile.tallgrass.fern.name", ++ "minecraft:fire": "tile.fire.name", ++ "minecraft:fire_coral": "tile.coral.red.name", ++ "minecraft:fire_coral_block": "tile.coral_block.red.name", ++ "minecraft:fire_coral_fan": "tile.coral_fan.red_fan.name", ++ "minecraft:fire_coral_wall_fan": "tile.fire_coral_wall_fan.name", ++ "minecraft:firefly_bush": "tile.firefly_bush.name", ++ "minecraft:fletching_table": "tile.fletching_table.name", ++ "minecraft:flower_pot": "tile.flower_pot.name", ++ "minecraft:flowering_azalea": "tile.flowering_azalea.name", ++ "minecraft:flowing_lava": "tile.flowing_lava.name", ++ "minecraft:flowing_water": "tile.flowing_water.name", ++ "minecraft:frame": "tile.frame.name", ++ "minecraft:frog_spawn": "tile.frog_spawn.name", ++ "minecraft:frosted_ice": "tile.frosted_ice.name", ++ "minecraft:furnace": "tile.furnace.name", ++ "minecraft:gilded_blackstone": "tile.gilded_blackstone.name", ++ "minecraft:glass": "tile.glass.name", ++ "minecraft:glass_pane": "tile.glass_pane.name", ++ "minecraft:glow_frame": "tile.glow_frame.name", ++ "minecraft:glow_lichen": "tile.glow_lichen.name", ++ "minecraft:glowingobsidian": "tile.glowingobsidian.name", ++ "minecraft:glowstone": "tile.glowstone.name", ++ "minecraft:gold_block": "tile.gold_block.name", ++ "minecraft:gold_ore": "tile.gold_ore.name", ++ "minecraft:golden_dandelion": "tile.golden_dandelion.name", ++ "minecraft:golden_rail": "tile.golden_rail.name", ++ "minecraft:granite": "tile.stone.granite.name", ++ "minecraft:granite_double_slab": "tile.double_stone_slab3.granite.name", ++ "minecraft:granite_slab": "tile.stone_slab3.granite.name", ++ "minecraft:granite_stairs": "tile.granite_stairs.name", ++ "minecraft:granite_wall": "tile.cobblestone_wall.granite.name", ++ "minecraft:grass_block": "tile.grass.name", ++ "minecraft:grass_path": "tile.grass_path.name", ++ "minecraft:gravel": "tile.gravel.name", ++ "minecraft:gray_candle": "tile.gray_candle.name", ++ "minecraft:gray_candle_cake": "tile.gray_candle_cake.name", ++ "minecraft:gray_carpet": "tile.carpet.gray.name", ++ "minecraft:gray_concrete": "tile.concrete.gray.name", ++ "minecraft:gray_concrete_powder": "tile.concretePowder.gray.name", ++ "minecraft:gray_glazed_terracotta": "tile.glazedTerracotta.gray.name", ++ "minecraft:gray_shulker_box": "tile.shulkerBoxGray.name", ++ "minecraft:gray_stained_glass": "tile.stained_glass.gray.name", ++ "minecraft:gray_stained_glass_pane": "tile.stained_glass_pane.gray.name", ++ "minecraft:gray_terracotta": "tile.stained_hardened_clay.gray.name", ++ "minecraft:gray_wool": "tile.wool.gray.name", ++ "minecraft:green_candle": "tile.green_candle.name", ++ "minecraft:green_candle_cake": "tile.green_candle_cake.name", ++ "minecraft:green_carpet": "tile.carpet.green.name", ++ "minecraft:green_concrete": "tile.concrete.green.name", ++ "minecraft:green_concrete_powder": "tile.concretePowder.green.name", ++ "minecraft:green_glazed_terracotta": "tile.glazedTerracotta.green.name", ++ "minecraft:green_shulker_box": "tile.shulkerBoxGreen.name", ++ "minecraft:green_stained_glass": "tile.stained_glass.green.name", ++ "minecraft:green_stained_glass_pane": "tile.stained_glass_pane.green.name", ++ "minecraft:green_terracotta": "tile.stained_hardened_clay.green.name", ++ "minecraft:green_wool": "tile.wool.green.name", ++ "minecraft:grindstone": "tile.grindstone.name", ++ "minecraft:hanging_roots": "tile.hanging_roots.name", ++ "minecraft:hard_black_stained_glass": "tile.hard_stained_glass.black.name", ++ "minecraft:hard_black_stained_glass_pane": "tile.hard_stained_glass_pane.black.name", ++ "minecraft:hard_blue_stained_glass": "tile.hard_stained_glass.blue.name", ++ "minecraft:hard_blue_stained_glass_pane": "tile.hard_stained_glass_pane.blue.name", ++ "minecraft:hard_brown_stained_glass": "tile.hard_stained_glass.brown.name", ++ "minecraft:hard_brown_stained_glass_pane": "tile.hard_stained_glass_pane.brown.name", ++ "minecraft:hard_cyan_stained_glass": "tile.hard_stained_glass.cyan.name", ++ "minecraft:hard_cyan_stained_glass_pane": "tile.hard_stained_glass_pane.cyan.name", ++ "minecraft:hard_glass": "tile.hard_glass.name", ++ "minecraft:hard_glass_pane": "tile.hard_glass_pane.name", ++ "minecraft:hard_gray_stained_glass": "tile.hard_stained_glass.gray.name", ++ "minecraft:hard_gray_stained_glass_pane": "tile.hard_stained_glass_pane.gray.name", ++ "minecraft:hard_green_stained_glass": "tile.hard_stained_glass.green.name", ++ "minecraft:hard_green_stained_glass_pane": "tile.hard_stained_glass_pane.green.name", ++ "minecraft:hard_light_blue_stained_glass": "tile.hard_stained_glass.light_blue.name", ++ "minecraft:hard_light_blue_stained_glass_pane": "tile.hard_stained_glass_pane.light_blue.name", ++ "minecraft:hard_light_gray_stained_glass": "tile.hard_stained_glass.silver.name", ++ "minecraft:hard_light_gray_stained_glass_pane": "tile.hard_stained_glass_pane.silver.name", ++ "minecraft:hard_lime_stained_glass": "tile.hard_stained_glass.lime.name", ++ "minecraft:hard_lime_stained_glass_pane": "tile.hard_stained_glass_pane.lime.name", ++ "minecraft:hard_magenta_stained_glass": "tile.hard_stained_glass.magenta.name", ++ "minecraft:hard_magenta_stained_glass_pane": "tile.hard_stained_glass_pane.magenta.name", ++ "minecraft:hard_orange_stained_glass": "tile.hard_stained_glass.orange.name", ++ "minecraft:hard_orange_stained_glass_pane": "tile.hard_stained_glass_pane.orange.name", ++ "minecraft:hard_pink_stained_glass": "tile.hard_stained_glass.pink.name", ++ "minecraft:hard_pink_stained_glass_pane": "tile.hard_stained_glass_pane.pink.name", ++ "minecraft:hard_purple_stained_glass": "tile.hard_stained_glass.purple.name", ++ "minecraft:hard_purple_stained_glass_pane": "tile.hard_stained_glass_pane.purple.name", ++ "minecraft:hard_red_stained_glass": "tile.hard_stained_glass.red.name", ++ "minecraft:hard_red_stained_glass_pane": "tile.hard_stained_glass_pane.red.name", ++ "minecraft:hard_white_stained_glass": "tile.hard_stained_glass.white.name", ++ "minecraft:hard_white_stained_glass_pane": "tile.hard_stained_glass_pane.white.name", ++ "minecraft:hard_yellow_stained_glass": "tile.hard_stained_glass.yellow.name", ++ "minecraft:hard_yellow_stained_glass_pane": "tile.hard_stained_glass_pane.yellow.name", ++ "minecraft:hardened_clay": "tile.hardened_clay.name", ++ "minecraft:hay_block": "tile.hay_block.name", ++ "minecraft:heavy_core": "tile.heavy_core.name", ++ "minecraft:heavy_weighted_pressure_plate": "tile.heavy_weighted_pressure_plate.name", ++ "minecraft:honey_block": "tile.honey_block.name", ++ "minecraft:honeycomb_block": "tile.honeycomb_block.name", ++ "minecraft:hopper": "tile.hopper.name", ++ "minecraft:horn_coral": "tile.coral.yellow.name", ++ "minecraft:horn_coral_block": "tile.coral_block.yellow.name", ++ "minecraft:horn_coral_fan": "tile.coral_fan.yellow_fan.name", ++ "minecraft:horn_coral_wall_fan": "tile.horn_coral_wall_fan.name", ++ "minecraft:ice": "tile.ice.name", ++ "minecraft:infested_chiseled_stone_bricks": "tile.monster_egg.chiseledbrick.name", ++ "minecraft:infested_cobblestone": "tile.monster_egg.cobble.name", ++ "minecraft:infested_cracked_stone_bricks": "tile.monster_egg.crackedbrick.name", ++ "minecraft:infested_deepslate": "tile.infested_deepslate.name", ++ "minecraft:infested_mossy_stone_bricks": "tile.monster_egg.mossybrick.name", ++ "minecraft:infested_stone": "tile.monster_egg.stone.name", ++ "minecraft:infested_stone_bricks": "tile.monster_egg.brick.name", ++ "minecraft:info_update": "tile.info_update.name", ++ "minecraft:info_update2": "tile.info_update2.name", ++ "minecraft:invisible_bedrock": "tile.invisibleBedrock.name", ++ "minecraft:iron_bars": "tile.iron_bars.name", ++ "minecraft:iron_block": "tile.iron_block.name", ++ "minecraft:iron_chain": "tile.iron_chain.name", ++ "minecraft:iron_door": "tile.iron_door.name", ++ "minecraft:iron_ore": "tile.iron_ore.name", ++ "minecraft:iron_trapdoor": "tile.iron_trapdoor.name", ++ "minecraft:jigsaw": "tile.jigsaw.name", ++ "minecraft:jukebox": "tile.jukebox.name", ++ "minecraft:jungle_button": "tile.jungle_button.name", ++ "minecraft:jungle_door": "tile.jungle_door.name", ++ "minecraft:jungle_double_slab": "tile.double_wooden_slab.jungle.name", ++ "minecraft:jungle_fence": "tile.jungleFence.name", ++ "minecraft:jungle_fence_gate": "tile.jungle_fence_gate.name", ++ "minecraft:jungle_hanging_sign": "tile.jungle_hanging_sign.name", ++ "minecraft:jungle_leaves": "tile.leaves.jungle.name", ++ "minecraft:jungle_log": "tile.log.jungle.name", ++ "minecraft:jungle_planks": "tile.planks.jungle.name", ++ "minecraft:jungle_pressure_plate": "tile.jungle_pressure_plate.name", ++ "minecraft:jungle_sapling": "tile.sapling.jungle.name", ++ "minecraft:jungle_shelf": "tile.jungle_shelf.name", ++ "minecraft:jungle_slab": "tile.wooden_slab.jungle.name", ++ "minecraft:jungle_stairs": "tile.jungle_stairs.name", ++ "minecraft:jungle_standing_sign": "tile.jungle_standing_sign.name", ++ "minecraft:jungle_trapdoor": "tile.jungle_trapdoor.name", ++ "minecraft:jungle_wall_sign": "tile.jungle_wall_sign.name", ++ "minecraft:jungle_wood": "tile.wood.jungle.name", ++ "minecraft:kelp": "tile.kelp.name", ++ "minecraft:lab_table": "tile.labtable.name", ++ "minecraft:ladder": "tile.ladder.name", ++ "minecraft:lantern": "tile.lantern.name", ++ "minecraft:lapis_block": "tile.lapis_block.name", ++ "minecraft:lapis_ore": "tile.lapis_ore.name", ++ "minecraft:large_amethyst_bud": "tile.large_amethyst_bud.name", ++ "minecraft:large_fern": "tile.double_plant.fern.name", ++ "minecraft:lava": "tile.lava.name", ++ "minecraft:leaf_litter": "tile.leaf_litter.name", ++ "minecraft:lectern": "tile.lectern.name", ++ "minecraft:lever": "tile.lever.name", ++ "minecraft:light_block_0": "tile.light_block.name", ++ "minecraft:light_block_1": "tile.light_block.name", ++ "minecraft:light_block_10": "tile.light_block.name", ++ "minecraft:light_block_11": "tile.light_block.name", ++ "minecraft:light_block_12": "tile.light_block.name", ++ "minecraft:light_block_13": "tile.light_block.name", ++ "minecraft:light_block_14": "tile.light_block.name", ++ "minecraft:light_block_15": "tile.light_block.name", ++ "minecraft:light_block_2": "tile.light_block.name", ++ "minecraft:light_block_3": "tile.light_block.name", ++ "minecraft:light_block_4": "tile.light_block.name", ++ "minecraft:light_block_5": "tile.light_block.name", ++ "minecraft:light_block_6": "tile.light_block.name", ++ "minecraft:light_block_7": "tile.light_block.name", ++ "minecraft:light_block_8": "tile.light_block.name", ++ "minecraft:light_block_9": "tile.light_block.name", ++ "minecraft:light_blue_candle": "tile.light_blue_candle.name", ++ "minecraft:light_blue_candle_cake": "tile.light_blue_candle_cake.name", ++ "minecraft:light_blue_carpet": "tile.carpet.lightBlue.name", ++ "minecraft:light_blue_concrete": "tile.concrete.lightBlue.name", ++ "minecraft:light_blue_concrete_powder": "tile.concretePowder.lightBlue.name", ++ "minecraft:light_blue_glazed_terracotta": "tile.glazedTerracotta.light_blue.name", ++ "minecraft:light_blue_shulker_box": "tile.shulkerBoxLightBlue.name", ++ "minecraft:light_blue_stained_glass": "tile.stained_glass.light_blue.name", ++ "minecraft:light_blue_stained_glass_pane": "tile.stained_glass_pane.light_blue.name", ++ "minecraft:light_blue_terracotta": "tile.stained_hardened_clay.lightBlue.name", ++ "minecraft:light_blue_wool": "tile.wool.lightBlue.name", ++ "minecraft:light_gray_candle": "tile.light_gray_candle.name", ++ "minecraft:light_gray_candle_cake": "tile.light_gray_candle_cake.name", ++ "minecraft:light_gray_carpet": "tile.carpet.silver.name", ++ "minecraft:light_gray_concrete": "tile.concrete.silver.name", ++ "minecraft:light_gray_concrete_powder": "tile.concretePowder.silver.name", ++ "minecraft:light_gray_shulker_box": "tile.shulkerBoxSilver.name", ++ "minecraft:light_gray_stained_glass": "tile.stained_glass.silver.name", ++ "minecraft:light_gray_stained_glass_pane": "tile.stained_glass_pane.silver.name", ++ "minecraft:light_gray_terracotta": "tile.stained_hardened_clay.silver.name", ++ "minecraft:light_gray_wool": "tile.wool.silver.name", ++ "minecraft:light_weighted_pressure_plate": "tile.light_weighted_pressure_plate.name", ++ "minecraft:lightning_rod": "tile.lightning_rod.name", ++ "minecraft:lilac": "tile.double_plant.syringa.name", ++ "minecraft:lily_of_the_valley": "tile.red_flower.lilyOfTheValley.name", ++ "minecraft:lime_candle": "tile.lime_candle.name", ++ "minecraft:lime_candle_cake": "tile.lime_candle_cake.name", ++ "minecraft:lime_carpet": "tile.carpet.lime.name", ++ "minecraft:lime_concrete": "tile.concrete.lime.name", ++ "minecraft:lime_concrete_powder": "tile.concretePowder.lime.name", ++ "minecraft:lime_glazed_terracotta": "tile.glazedTerracotta.lime.name", ++ "minecraft:lime_shulker_box": "tile.shulkerBoxLime.name", ++ "minecraft:lime_stained_glass": "tile.stained_glass.lime.name", ++ "minecraft:lime_stained_glass_pane": "tile.stained_glass_pane.lime.name", ++ "minecraft:lime_terracotta": "tile.stained_hardened_clay.lime.name", ++ "minecraft:lime_wool": "tile.wool.lime.name", ++ "minecraft:lit_blast_furnace": "tile.lit_blast_furnace.name", ++ "minecraft:lit_deepslate_redstone_ore": "tile.lit_deepslate_redstone_ore.name", ++ "minecraft:lit_furnace": "tile.lit_furnace.name", ++ "minecraft:lit_pumpkin": "tile.lit_pumpkin.name", ++ "minecraft:lit_redstone_lamp": "tile.lit_redstone_lamp.name", ++ "minecraft:lit_redstone_ore": "tile.lit_redstone_ore.name", ++ "minecraft:lit_smoker": "tile.lit_smoker.name", ++ "minecraft:lodestone": "tile.lodestone.name", ++ "minecraft:loom": "tile.loom.name", ++ "minecraft:magenta_candle": "tile.magenta_candle.name", ++ "minecraft:magenta_candle_cake": "tile.magenta_candle_cake.name", ++ "minecraft:magenta_carpet": "tile.carpet.magenta.name", ++ "minecraft:magenta_concrete": "tile.concrete.magenta.name", ++ "minecraft:magenta_concrete_powder": "tile.concretePowder.magenta.name", ++ "minecraft:magenta_glazed_terracotta": "tile.glazedTerracotta.magenta.name", ++ "minecraft:magenta_shulker_box": "tile.shulkerBoxMagenta.name", ++ "minecraft:magenta_stained_glass": "tile.stained_glass.magenta.name", ++ "minecraft:magenta_stained_glass_pane": "tile.stained_glass_pane.magenta.name", ++ "minecraft:magenta_terracotta": "tile.stained_hardened_clay.magenta.name", ++ "minecraft:magenta_wool": "tile.wool.magenta.name", ++ "minecraft:magma": "tile.magma.name", ++ "minecraft:mangrove_button": "tile.mangrove_button.name", ++ "minecraft:mangrove_door": "tile.mangrove_door.name", ++ "minecraft:mangrove_double_slab": "tile.mangrove_double_slab.name", ++ "minecraft:mangrove_fence": "tile.mangrove_fence.name", ++ "minecraft:mangrove_fence_gate": "tile.mangrove_fence_gate.name", ++ "minecraft:mangrove_hanging_sign": "tile.mangrove_hanging_sign.name", ++ "minecraft:mangrove_leaves": "tile.mangrove_leaves.name", ++ "minecraft:mangrove_log": "tile.mangrove_log.name", ++ "minecraft:mangrove_planks": "tile.mangrove_planks.name", ++ "minecraft:mangrove_pressure_plate": "tile.mangrove_pressure_plate.name", ++ "minecraft:mangrove_propagule": "tile.mangrove_propagule.name", ++ "minecraft:mangrove_roots": "tile.mangrove_roots.name", ++ "minecraft:mangrove_shelf": "tile.mangrove_shelf.name", ++ "minecraft:mangrove_slab": "tile.mangrove_slab.name", ++ "minecraft:mangrove_stairs": "tile.mangrove_stairs.name", ++ "minecraft:mangrove_standing_sign": "tile.mangrove_standing_sign.name", ++ "minecraft:mangrove_trapdoor": "tile.mangrove_trapdoor.name", ++ "minecraft:mangrove_wall_sign": "tile.mangrove_wall_sign.name", ++ "minecraft:mangrove_wood": "tile.mangrove_wood.name", ++ "minecraft:material_reducer": "tile.materialreducer.name", ++ "minecraft:medium_amethyst_bud": "tile.medium_amethyst_bud.name", ++ "minecraft:melon_block": "tile.melon_block.name", ++ "minecraft:melon_stem": "tile.melon_stem.name", ++ "minecraft:mob_spawner": "tile.mob_spawner.name", ++ "minecraft:moss_block": "tile.moss_block.name", ++ "minecraft:moss_carpet": "tile.moss_carpet.name", ++ "minecraft:mossy_cobblestone": "tile.mossy_cobblestone.name", ++ "minecraft:mossy_cobblestone_double_slab": "tile.double_stone_slab2.mossy_cobblestone.name", ++ "minecraft:mossy_cobblestone_slab": "tile.stone_slab2.mossy_cobblestone.name", ++ "minecraft:mossy_cobblestone_stairs": "tile.mossy_cobblestone_stairs.name", ++ "minecraft:mossy_cobblestone_wall": "tile.cobblestone_wall.mossy.name", ++ "minecraft:mossy_stone_brick_double_slab": "tile.double_stone_slab4.mossy_stone_brick.name", ++ "minecraft:mossy_stone_brick_slab": "tile.stone_slab4.mossy_stone_brick.name", ++ "minecraft:mossy_stone_brick_stairs": "tile.mossy_stone_brick_stairs.name", ++ "minecraft:mossy_stone_brick_wall": "tile.cobblestone_wall.mossy_stone_brick.name", ++ "minecraft:mossy_stone_bricks": "tile.stonebrick.mossy.name", ++ "minecraft:moving_block": "tile.moving_block.name", ++ "minecraft:mud": "tile.mud.name", ++ "minecraft:mud_brick_double_slab": "tile.mud_brick_double_slab.name", ++ "minecraft:mud_brick_slab": "tile.mud_brick_slab.name", ++ "minecraft:mud_brick_stairs": "tile.mud_brick_stairs.name", ++ "minecraft:mud_brick_wall": "tile.mud_brick_wall.name", ++ "minecraft:mud_bricks": "tile.mud_bricks.name", ++ "minecraft:muddy_mangrove_roots": "tile.muddy_mangrove_roots.name", ++ "minecraft:mushroom_stem": "tile.brown_mushroom_block.stem.name", ++ "minecraft:mycelium": "tile.mycelium.name", ++ "minecraft:nether_brick": "tile.nether_brick.name", ++ "minecraft:nether_brick_double_slab": "tile.double_stone_slab.nether_brick.name", ++ "minecraft:nether_brick_fence": "tile.nether_brick_fence.name", ++ "minecraft:nether_brick_slab": "tile.stone_slab.nether_brick.name", ++ "minecraft:nether_brick_stairs": "tile.nether_brick_stairs.name", ++ "minecraft:nether_brick_wall": "tile.cobblestone_wall.nether_brick.name", ++ "minecraft:nether_gold_ore": "tile.nether_gold_ore.name", ++ "minecraft:nether_sprouts": "tile.nether_sprouts.name", ++ "minecraft:nether_wart": "tile.nether_wart.name", ++ "minecraft:nether_wart_block": "tile.nether_wart_block.name", ++ "minecraft:netherite_block": "tile.netherite_block.name", ++ "minecraft:netherrack": "tile.netherrack.name", ++ "minecraft:netherreactor": "tile.netherreactor.name", ++ "minecraft:normal_stone_double_slab": "tile.double_stone_slab4.stone.name", ++ "minecraft:normal_stone_slab": "tile.stone_slab4.stone.name", ++ "minecraft:normal_stone_stairs": "tile.normal_stone_stairs.name", ++ "minecraft:noteblock": "tile.noteblock.name", ++ "minecraft:oak_double_slab": "tile.double_wooden_slab.oak.name", ++ "minecraft:oak_fence": "tile.fence.name", ++ "minecraft:oak_hanging_sign": "tile.oak_hanging_sign.name", ++ "minecraft:oak_leaves": "tile.leaves.oak.name", ++ "minecraft:oak_log": "tile.log.oak.name", ++ "minecraft:oak_planks": "tile.planks.oak.name", ++ "minecraft:oak_sapling": "tile.sapling.oak.name", ++ "minecraft:oak_shelf": "tile.oak_shelf.name", ++ "minecraft:oak_slab": "tile.wooden_slab.oak.name", ++ "minecraft:oak_stairs": "tile.oak_stairs.name", ++ "minecraft:oak_wood": "tile.wood.oak.name", ++ "minecraft:observer": "tile.observer.name", ++ "minecraft:obsidian": "tile.obsidian.name", ++ "minecraft:ochre_froglight": "tile.ochre_froglight.name", ++ "minecraft:open_eyeblossom": "tile.open_eyeblossom.name", ++ "minecraft:orange_candle": "tile.orange_candle.name", ++ "minecraft:orange_candle_cake": "tile.orange_candle_cake.name", ++ "minecraft:orange_carpet": "tile.carpet.orange.name", ++ "minecraft:orange_concrete": "tile.concrete.orange.name", ++ "minecraft:orange_concrete_powder": "tile.concretePowder.orange.name", ++ "minecraft:orange_glazed_terracotta": "tile.glazedTerracotta.orange.name", ++ "minecraft:orange_poplar_leaves": "tile.orange_poplar_leaves.name", ++ "minecraft:orange_shulker_box": "tile.shulkerBoxOrange.name", ++ "minecraft:orange_stained_glass": "tile.stained_glass.orange.name", ++ "minecraft:orange_stained_glass_pane": "tile.stained_glass_pane.orange.name", ++ "minecraft:orange_terracotta": "tile.stained_hardened_clay.orange.name", ++ "minecraft:orange_tulip": "tile.red_flower.tulipOrange.name", ++ "minecraft:orange_wool": "tile.wool.orange.name", ++ "minecraft:oxeye_daisy": "tile.red_flower.oxeyeDaisy.name", ++ "minecraft:oxidized_chiseled_copper": "tile.oxidized_chiseled_copper.name", ++ "minecraft:oxidized_copper": "tile.oxidized_copper.name", ++ "minecraft:oxidized_copper_bars": "tile.oxidized_copper_bars.name", ++ "minecraft:oxidized_copper_bulb": "tile.oxidized_copper_bulb.name", ++ "minecraft:oxidized_copper_chain": "tile.oxidized_copper_chain.name", ++ "minecraft:oxidized_copper_chest": "tile.oxidized_copper_chest.name", ++ "minecraft:oxidized_copper_door": "tile.oxidized_copper_door.name", ++ "minecraft:oxidized_copper_golem_statue": "tile.oxidized_copper_golem_statue.name", ++ "minecraft:oxidized_copper_grate": "tile.oxidized_copper_grate.name", ++ "minecraft:oxidized_copper_lantern": "tile.oxidized_copper_lantern.name", ++ "minecraft:oxidized_copper_trapdoor": "tile.oxidized_copper_trapdoor.name", ++ "minecraft:oxidized_cut_copper": "tile.oxidized_cut_copper.name", ++ "minecraft:oxidized_cut_copper_slab": "tile.oxidized_cut_copper_slab.name", ++ "minecraft:oxidized_cut_copper_stairs": "tile.oxidized_cut_copper_stairs.name", ++ "minecraft:oxidized_double_cut_copper_slab": "tile.oxidized_double_cut_copper_slab.name", ++ "minecraft:oxidized_lightning_rod": "tile.oxidized_lightning_rod.name", ++ "minecraft:packed_ice": "tile.packed_ice.name", ++ "minecraft:packed_mud": "tile.packed_mud.name", ++ "minecraft:pale_hanging_moss": "tile.pale_hanging_moss.name", ++ "minecraft:pale_moss_block": "tile.pale_moss_block.name", ++ "minecraft:pale_moss_carpet": "tile.pale_moss_carpet.name", ++ "minecraft:pale_oak_button": "tile.pale_oak_button.name", ++ "minecraft:pale_oak_door": "tile.pale_oak_door.name", ++ "minecraft:pale_oak_double_slab": "tile.pale_oak_double_slab.name", ++ "minecraft:pale_oak_fence": "tile.pale_oak_fence.name", ++ "minecraft:pale_oak_fence_gate": "tile.pale_oak_fence_gate.name", ++ "minecraft:pale_oak_hanging_sign": "tile.pale_oak_hanging_sign.name", ++ "minecraft:pale_oak_leaves": "tile.pale_oak_leaves.name", ++ "minecraft:pale_oak_log": "tile.pale_oak_log.name", ++ "minecraft:pale_oak_planks": "tile.pale_oak_planks.name", ++ "minecraft:pale_oak_pressure_plate": "tile.pale_oak_pressure_plate.name", ++ "minecraft:pale_oak_sapling": "tile.pale_oak_sapling.name", ++ "minecraft:pale_oak_shelf": "tile.pale_oak_shelf.name", ++ "minecraft:pale_oak_slab": "tile.pale_oak_slab.name", ++ "minecraft:pale_oak_stairs": "tile.pale_oak_stairs.name", ++ "minecraft:pale_oak_standing_sign": "tile.pale_oak_standing_sign.name", ++ "minecraft:pale_oak_trapdoor": "tile.pale_oak_trapdoor.name", ++ "minecraft:pale_oak_wall_sign": "tile.pale_oak_wall_sign.name", ++ "minecraft:pale_oak_wood": "tile.pale_oak_wood.name", ++ "minecraft:pearlescent_froglight": "tile.pearlescent_froglight.name", ++ "minecraft:peony": "tile.double_plant.paeonia.name", ++ "minecraft:petrified_oak_double_slab": "tile.double_stone_slab.wood.name", ++ "minecraft:petrified_oak_slab": "tile.stone_slab.wood.name", ++ "minecraft:piglin_head": "item.skull.piglin.name", ++ "minecraft:pink_candle": "tile.pink_candle.name", ++ "minecraft:pink_candle_cake": "tile.pink_candle_cake.name", ++ "minecraft:pink_carpet": "tile.carpet.pink.name", ++ "minecraft:pink_concrete": "tile.concrete.pink.name", ++ "minecraft:pink_concrete_powder": "tile.concretePowder.pink.name", ++ "minecraft:pink_glazed_terracotta": "tile.glazedTerracotta.pink.name", ++ "minecraft:pink_petals": "tile.pink_petals.name", ++ "minecraft:pink_shulker_box": "tile.shulkerBoxPink.name", ++ "minecraft:pink_stained_glass": "tile.stained_glass.pink.name", ++ "minecraft:pink_stained_glass_pane": "tile.stained_glass_pane.pink.name", ++ "minecraft:pink_terracotta": "tile.stained_hardened_clay.pink.name", ++ "minecraft:pink_tulip": "tile.red_flower.tulipPink.name", ++ "minecraft:pink_wool": "tile.wool.pink.name", ++ "minecraft:piston": "tile.piston.name", ++ "minecraft:piston_arm_collision": "tile.piston_arm_collision.name", ++ "minecraft:pitcher_crop": "tile.pitcher_crop.name", ++ "minecraft:pitcher_plant": "tile.pitcher_plant.name", ++ "minecraft:player_head": "item.skull.char.name", ++ "minecraft:podzol": "tile.podzol.name", ++ "minecraft:pointed_dripstone": "tile.pointed_dripstone.name", ++ "minecraft:polished_andesite": "tile.stone.andesiteSmooth.name", ++ "minecraft:polished_andesite_double_slab": "tile.double_stone_slab3.andesite.smooth.name", ++ "minecraft:polished_andesite_slab": "tile.stone_slab3.andesite.smooth.name", ++ "minecraft:polished_andesite_stairs": "tile.polished_andesite_stairs.name", ++ "minecraft:polished_basalt": "tile.polished_basalt.name", ++ "minecraft:polished_blackstone": "tile.polished_blackstone.name", ++ "minecraft:polished_blackstone_brick_double_slab": "tile.polished_blackstone_brick_double_slab.name", ++ "minecraft:polished_blackstone_brick_slab": "tile.polished_blackstone_brick_slab.name", ++ "minecraft:polished_blackstone_brick_stairs": "tile.polished_blackstone_brick_stairs.name", ++ "minecraft:polished_blackstone_brick_wall": "tile.polished_blackstone_brick_wall.name", ++ "minecraft:polished_blackstone_bricks": "tile.polished_blackstone_bricks.name", ++ "minecraft:polished_blackstone_button": "tile.polished_blackstone_button.name", ++ "minecraft:polished_blackstone_double_slab": "tile.polished_blackstone_double_slab.name", ++ "minecraft:polished_blackstone_pressure_plate": "tile.polished_blackstone_pressure_plate.name", ++ "minecraft:polished_blackstone_slab": "tile.polished_blackstone_slab.name", ++ "minecraft:polished_blackstone_stairs": "tile.polished_blackstone_stairs.name", ++ "minecraft:polished_blackstone_wall": "tile.polished_blackstone_wall.name", ++ "minecraft:polished_cinnabar": "tile.polished_cinnabar.name", ++ "minecraft:polished_cinnabar_double_slab": "tile.polished_cinnabar_double_slab.name", ++ "minecraft:polished_cinnabar_slab": "tile.polished_cinnabar_slab.name", ++ "minecraft:polished_cinnabar_stairs": "tile.polished_cinnabar_stairs.name", ++ "minecraft:polished_cinnabar_wall": "tile.polished_cinnabar_wall.name", ++ "minecraft:polished_deepslate": "tile.polished_deepslate.name", ++ "minecraft:polished_deepslate_double_slab": "tile.polished_deepslate_double_slab.name", ++ "minecraft:polished_deepslate_slab": "tile.polished_deepslate_slab.name", ++ "minecraft:polished_deepslate_stairs": "tile.polished_deepslate_stairs.name", ++ "minecraft:polished_deepslate_wall": "tile.polished_deepslate_wall.name", ++ "minecraft:polished_diorite": "tile.stone.dioriteSmooth.name", ++ "minecraft:polished_diorite_double_slab": "tile.double_stone_slab3.diorite.smooth.name", ++ "minecraft:polished_diorite_slab": "tile.stone_slab3.diorite.smooth.name", ++ "minecraft:polished_diorite_stairs": "tile.polished_diorite_stairs.name", ++ "minecraft:polished_granite": "tile.stone.graniteSmooth.name", ++ "minecraft:polished_granite_double_slab": "tile.double_stone_slab3.granite.smooth.name", ++ "minecraft:polished_granite_slab": "tile.stone_slab3.granite.smooth.name", ++ "minecraft:polished_granite_stairs": "tile.polished_granite_stairs.name", ++ "minecraft:polished_sulfur": "tile.polished_sulfur.name", ++ "minecraft:polished_sulfur_double_slab": "tile.polished_sulfur_double_slab.name", ++ "minecraft:polished_sulfur_slab": "tile.polished_sulfur_slab.name", ++ "minecraft:polished_sulfur_stairs": "tile.polished_sulfur_stairs.name", ++ "minecraft:polished_sulfur_wall": "tile.polished_sulfur_wall.name", ++ "minecraft:polished_tuff": "tile.polished_tuff.name", ++ "minecraft:polished_tuff_double_slab": "tile.polished_tuff_double_slab.name", ++ "minecraft:polished_tuff_slab": "tile.polished_tuff_slab.name", ++ "minecraft:polished_tuff_stairs": "tile.polished_tuff_stairs.name", ++ "minecraft:polished_tuff_wall": "tile.polished_tuff_wall.name", ++ "minecraft:poplar_button": "tile.poplar_button.name", ++ "minecraft:poplar_door": "tile.poplar_door.name", ++ "minecraft:poplar_double_slab": "tile.poplar_double_slab.name", ++ "minecraft:poplar_fence": "tile.poplar_fence.name", ++ "minecraft:poplar_fence_gate": "tile.poplar_fence_gate.name", ++ "minecraft:poplar_hanging_sign": "tile.poplar_hanging_sign.name", ++ "minecraft:poplar_log": "tile.poplar_log.name", ++ "minecraft:poplar_planks": "tile.poplar_planks.name", ++ "minecraft:poplar_pressure_plate": "tile.poplar_pressure_plate.name", ++ "minecraft:poplar_sapling": "tile.poplar_sapling.name", ++ "minecraft:poplar_shelf": "tile.poplar_shelf.name", ++ "minecraft:poplar_slab": "tile.poplar_slab.name", ++ "minecraft:poplar_stairs": "tile.poplar_stairs.name", ++ "minecraft:poplar_standing_sign": "tile.poplar_standing_sign.name", ++ "minecraft:poplar_trapdoor": "tile.poplar_trapdoor.name", ++ "minecraft:poplar_wall_sign": "tile.poplar_wall_sign.name", ++ "minecraft:poplar_wood": "tile.poplar_wood.name", ++ "minecraft:poppy": "tile.red_flower.poppy.name", ++ "minecraft:portal": "tile.portal.name", ++ "minecraft:potatoes": "tile.potatoes.name", ++ "minecraft:potent_sulfur": "tile.potent_sulfur.name", ++ "minecraft:powder_snow": "tile.powder_snow.name", ++ "minecraft:powered_comparator": "tile.powered_comparator.name", ++ "minecraft:powered_repeater": "tile.powered_repeater.name", ++ "minecraft:prismarine": "tile.prismarine.rough.name", ++ "minecraft:prismarine_brick_double_slab": "tile.double_stone_slab2.prismarine.bricks.name", ++ "minecraft:prismarine_brick_slab": "tile.stone_slab2.prismarine.bricks.name", ++ "minecraft:prismarine_bricks": "tile.prismarine.bricks.name", ++ "minecraft:prismarine_bricks_stairs": "tile.prismarine_bricks_stairs.name", ++ "minecraft:prismarine_double_slab": "tile.double_stone_slab2.prismarine.rough.name", ++ "minecraft:prismarine_slab": "tile.stone_slab2.prismarine.rough.name", ++ "minecraft:prismarine_stairs": "tile.prismarine_stairs.name", ++ "minecraft:prismarine_wall": "tile.cobblestone_wall.prismarine.name", ++ "minecraft:pumpkin": "tile.pumpkin.name", ++ "minecraft:pumpkin_stem": "tile.pumpkin_stem.name", ++ "minecraft:purple_candle": "tile.purple_candle.name", ++ "minecraft:purple_candle_cake": "tile.purple_candle_cake.name", ++ "minecraft:purple_carpet": "tile.carpet.purple.name", ++ "minecraft:purple_concrete": "tile.concrete.purple.name", ++ "minecraft:purple_concrete_powder": "tile.concretePowder.purple.name", ++ "minecraft:purple_glazed_terracotta": "tile.glazedTerracotta.purple.name", ++ "minecraft:purple_shulker_box": "tile.shulkerBoxPurple.name", ++ "minecraft:purple_stained_glass": "tile.stained_glass.purple.name", ++ "minecraft:purple_stained_glass_pane": "tile.stained_glass_pane.purple.name", ++ "minecraft:purple_terracotta": "tile.stained_hardened_clay.purple.name", ++ "minecraft:purple_wool": "tile.wool.purple.name", ++ "minecraft:purpur_block": "tile.purpur_block.default.name", ++ "minecraft:purpur_double_slab": "tile.double_stone_slab2.purpur.name", ++ "minecraft:purpur_pillar": "tile.purpur_block.lines.name", ++ "minecraft:purpur_slab": "tile.stone_slab2.purpur.name", ++ "minecraft:purpur_stairs": "tile.purpur_stairs.name", ++ "minecraft:quartz_block": "tile.quartz_block.default.name", ++ "minecraft:quartz_bricks": "tile.quartz_bricks.name", ++ "minecraft:quartz_double_slab": "tile.double_stone_slab.quartz.name", ++ "minecraft:quartz_ore": "tile.quartz_ore.name", ++ "minecraft:quartz_pillar": "tile.quartz_block.lines.name", ++ "minecraft:quartz_slab": "tile.stone_slab.quartz.name", ++ "minecraft:quartz_stairs": "tile.quartz_stairs.name", ++ "minecraft:rail": "tile.rail.name", ++ "minecraft:raw_copper_block": "tile.raw_copper_block.name", ++ "minecraft:raw_gold_block": "tile.raw_gold_block.name", ++ "minecraft:raw_iron_block": "tile.raw_iron_block.name", ++ "minecraft:red_candle": "tile.red_candle.name", ++ "minecraft:red_candle_cake": "tile.red_candle_cake.name", ++ "minecraft:red_carpet": "tile.carpet.red.name", ++ "minecraft:red_concrete": "tile.concrete.red.name", ++ "minecraft:red_concrete_powder": "tile.concretePowder.red.name", ++ "minecraft:red_glazed_terracotta": "tile.glazedTerracotta.red.name", ++ "minecraft:red_mushroom": "tile.red_mushroom.name", ++ "minecraft:red_mushroom_block": "tile.red_mushroom_block.name", ++ "minecraft:red_nether_brick": "tile.red_nether_brick.name", ++ "minecraft:red_nether_brick_double_slab": "tile.double_stone_slab2.red_nether_brick.name", ++ "minecraft:red_nether_brick_slab": "tile.stone_slab2.red_nether_brick.name", ++ "minecraft:red_nether_brick_stairs": "tile.red_nether_brick_stairs.name", ++ "minecraft:red_nether_brick_wall": "tile.cobblestone_wall.red_nether_brick.name", ++ "minecraft:red_poplar_leaves": "tile.red_poplar_leaves.name", ++ "minecraft:red_sand": "tile.sand.red.name", ++ "minecraft:red_sandstone": "tile.red_sandstone.name", ++ "minecraft:red_sandstone_double_slab": "tile.double_stone_slab2.red_sandstone.name", ++ "minecraft:red_sandstone_slab": "tile.stone_slab2.red_sandstone.name", ++ "minecraft:red_sandstone_stairs": "tile.red_sandstone_stairs.name", ++ "minecraft:red_sandstone_wall": "tile.cobblestone_wall.red_sandstone.name", ++ "minecraft:red_shulker_box": "tile.shulkerBoxRed.name", ++ "minecraft:red_stained_glass": "tile.stained_glass.red.name", ++ "minecraft:red_stained_glass_pane": "tile.stained_glass_pane.red.name", ++ "minecraft:red_terracotta": "tile.stained_hardened_clay.red.name", ++ "minecraft:red_tulip": "tile.red_flower.tulipRed.name", ++ "minecraft:red_wool": "tile.wool.red.name", ++ "minecraft:redstone_block": "tile.redstone_block.name", ++ "minecraft:redstone_lamp": "tile.redstone_lamp.name", ++ "minecraft:redstone_ore": "tile.redstone_ore.name", ++ "minecraft:redstone_torch": "tile.redstone_torch.name", ++ "minecraft:redstone_wire": "tile.redstone_wire.name", ++ "minecraft:reeds": "tile.reeds.name", ++ "minecraft:reinforced_deepslate": "tile.reinforced_deepslate.name", ++ "minecraft:repeating_command_block": "tile.repeating_command_block.name", ++ "minecraft:reserved6": "tile.reserved6.name", ++ "minecraft:resin_block": "tile.resin_block.name", ++ "minecraft:resin_brick_double_slab": "tile.resin_brick_double_slab.name", ++ "minecraft:resin_brick_slab": "tile.resin_brick_slab.name", ++ "minecraft:resin_brick_stairs": "tile.resin_brick_stairs.name", ++ "minecraft:resin_brick_wall": "tile.resin_brick_wall.name", ++ "minecraft:resin_bricks": "tile.resin_bricks.name", ++ "minecraft:resin_clump": "tile.resin_clump.name", ++ "minecraft:respawn_anchor": "tile.respawn_anchor.name", ++ "minecraft:rose_bush": "tile.double_plant.rose.name", ++ "minecraft:sand": "tile.sand.default.name", ++ "minecraft:sandstone": "tile.sandstone.name", ++ "minecraft:sandstone_double_slab": "tile.double_stone_slab.sand.name", ++ "minecraft:sandstone_slab": "tile.stone_slab.sand.name", ++ "minecraft:sandstone_stairs": "tile.sandstone_stairs.name", ++ "minecraft:sandstone_wall": "tile.cobblestone_wall.sandstone.name", ++ "minecraft:scaffolding": "tile.scaffolding.name", ++ "minecraft:sculk": "tile.sculk.name", ++ "minecraft:sculk_catalyst": "tile.sculk_catalyst.name", ++ "minecraft:sculk_sensor": "tile.sculk_sensor.name", ++ "minecraft:sculk_shrieker": "tile.sculk_shrieker.name", ++ "minecraft:sculk_vein": "tile.sculk_vein.name", ++ "minecraft:sea_lantern": "tile.seaLantern.name", ++ "minecraft:sea_pickle": "tile.sea_pickle.name", ++ "minecraft:seagrass": "tile.seagrass.seagrass.name", ++ "minecraft:short_dry_grass": "tile.short_dry_grass.name", ++ "minecraft:short_grass": "tile.tallgrass.grass.name", ++ "minecraft:shroomlight": "tile.shroomlight.name", ++ "minecraft:silver_glazed_terracotta": "tile.glazedTerracotta.silver.name", ++ "minecraft:skeleton_skull": "item.skull.skeleton.name", ++ "minecraft:slime": "tile.slime.name", ++ "minecraft:small_amethyst_bud": "tile.small_amethyst_bud.name", ++ "minecraft:small_dripleaf_block": "tile.small_dripleaf_block.name", ++ "minecraft:smithing_table": "tile.smithing_table.name", ++ "minecraft:smoker": "tile.smoker.name", ++ "minecraft:smooth_basalt": "tile.smooth_basalt.name", ++ "minecraft:smooth_quartz": "tile.quartz_block.smooth.name", ++ "minecraft:smooth_quartz_double_slab": "tile.double_stone_slab4.smooth_quartz.name", ++ "minecraft:smooth_quartz_slab": "tile.stone_slab4.smooth_quartz.name", ++ "minecraft:smooth_quartz_stairs": "tile.smooth_quartz_stairs.name", ++ "minecraft:smooth_red_sandstone": "tile.red_sandstone.smooth.name", ++ "minecraft:smooth_red_sandstone_double_slab": "tile.double_stone_slab3.red_sandstone.smooth.name", ++ "minecraft:smooth_red_sandstone_slab": "tile.stone_slab3.red_sandstone.smooth.name", ++ "minecraft:smooth_red_sandstone_stairs": "tile.smooth_red_sandstone_stairs.name", ++ "minecraft:smooth_sandstone": "tile.sandstone.smooth.name", ++ "minecraft:smooth_sandstone_double_slab": "tile.double_stone_slab2.sandstone.smooth.name", ++ "minecraft:smooth_sandstone_slab": "tile.stone_slab2.sandstone.smooth.name", ++ "minecraft:smooth_sandstone_stairs": "tile.smooth_sandstone_stairs.name", ++ "minecraft:smooth_stone": "tile.smooth_stone.name", ++ "minecraft:smooth_stone_double_slab": "tile.double_stone_slab.stone.name", ++ "minecraft:smooth_stone_slab": "tile.stone_slab.stone.name", ++ "minecraft:sniffer_egg": "tile.sniffer_egg.name", ++ "minecraft:snow": "tile.snow.name", ++ "minecraft:snow_layer": "tile.snow_layer.name", ++ "minecraft:soul_campfire": "tile.soul_campfire.name", ++ "minecraft:soul_fire": "tile.soul_fire.name", ++ "minecraft:soul_lantern": "tile.soul_lantern.name", ++ "minecraft:soul_sand": "tile.soul_sand.name", ++ "minecraft:soul_soil": "tile.soul_soil.name", ++ "minecraft:soul_torch": "tile.soul_torch.name", ++ "minecraft:sponge": "tile.sponge.dry.name", ++ "minecraft:spore_blossom": "tile.spore_blossom.name", ++ "minecraft:spruce_button": "tile.spruce_button.name", ++ "minecraft:spruce_door": "tile.spruce_door.name", ++ "minecraft:spruce_double_slab": "tile.double_wooden_slab.spruce.name", ++ "minecraft:spruce_fence": "tile.spruceFence.name", ++ "minecraft:spruce_fence_gate": "tile.spruce_fence_gate.name", ++ "minecraft:spruce_hanging_sign": "tile.spruce_hanging_sign.name", ++ "minecraft:spruce_leaves": "tile.leaves.spruce.name", ++ "minecraft:spruce_log": "tile.log.spruce.name", ++ "minecraft:spruce_planks": "tile.planks.spruce.name", ++ "minecraft:spruce_pressure_plate": "tile.spruce_pressure_plate.name", ++ "minecraft:spruce_sapling": "tile.sapling.spruce.name", ++ "minecraft:spruce_shelf": "tile.spruce_shelf.name", ++ "minecraft:spruce_slab": "tile.wooden_slab.spruce.name", ++ "minecraft:spruce_stairs": "tile.spruce_stairs.name", ++ "minecraft:spruce_standing_sign": "tile.spruce_standing_sign.name", ++ "minecraft:spruce_trapdoor": "tile.spruce_trapdoor.name", ++ "minecraft:spruce_wall_sign": "tile.spruce_wall_sign.name", ++ "minecraft:spruce_wood": "tile.wood.spruce.name", ++ "minecraft:standing_banner": "tile.standing_banner.name", ++ "minecraft:standing_sign": "tile.standing_sign.name", ++ "minecraft:sticky_piston": "tile.sticky_piston.name", ++ "minecraft:sticky_piston_arm_collision": "tile.sticky_piston_arm_collision.name", ++ "minecraft:stone": "tile.stone.stone.name", ++ "minecraft:stone_brick_double_slab": "tile.double_stone_slab.smoothStoneBrick.name", ++ "minecraft:stone_brick_slab": "tile.stone_slab.smoothStoneBrick.name", ++ "minecraft:stone_brick_stairs": "tile.stone_brick_stairs.name", ++ "minecraft:stone_brick_wall": "tile.cobblestone_wall.stone_brick.name", ++ "minecraft:stone_bricks": "tile.stonebrick.default.name", ++ "minecraft:stone_button": "tile.stone_button.name", ++ "minecraft:stone_pressure_plate": "tile.stone_pressure_plate.name", ++ "minecraft:stone_stairs": "tile.stone_stairs.name", ++ "minecraft:stonecutter": "tile.stonecutter.name", ++ "minecraft:stonecutter_block": "tile.stonecutter_block.name", ++ "minecraft:straw_bed": "tile.straw_bed.name", ++ "minecraft:stripped_acacia_log": "tile.stripped_acacia_log.name", ++ "minecraft:stripped_acacia_wood": "tile.wood.stripped.acacia.name", ++ "minecraft:stripped_bamboo_block": "tile.stripped_bamboo_block.name", ++ "minecraft:stripped_birch_log": "tile.stripped_birch_log.name", ++ "minecraft:stripped_birch_wood": "tile.wood.stripped.birch.name", ++ "minecraft:stripped_cherry_log": "tile.stripped_cherry_log.name", ++ "minecraft:stripped_cherry_wood": "tile.stripped_cherry_wood.name", ++ "minecraft:stripped_crimson_hyphae": "tile.stripped_crimson_hyphae.name", ++ "minecraft:stripped_crimson_stem": "tile.stripped_crimson_stem.name", ++ "minecraft:stripped_dark_oak_log": "tile.stripped_dark_oak_log.name", ++ "minecraft:stripped_dark_oak_wood": "tile.wood.stripped.dark_oak.name", ++ "minecraft:stripped_jungle_log": "tile.stripped_jungle_log.name", ++ "minecraft:stripped_jungle_wood": "tile.wood.stripped.jungle.name", ++ "minecraft:stripped_mangrove_log": "tile.stripped_mangrove_log.name", ++ "minecraft:stripped_mangrove_wood": "tile.stripped_mangrove_wood.name", ++ "minecraft:stripped_oak_log": "tile.stripped_oak_log.name", ++ "minecraft:stripped_oak_wood": "tile.wood.stripped.oak.name", ++ "minecraft:stripped_pale_oak_log": "tile.stripped_pale_oak_log.name", ++ "minecraft:stripped_pale_oak_wood": "tile.stripped_pale_oak_wood.name", ++ "minecraft:stripped_poplar_log": "tile.stripped_poplar_log.name", ++ "minecraft:stripped_poplar_wood": "tile.stripped_poplar_wood.name", ++ "minecraft:stripped_spruce_log": "tile.stripped_spruce_log.name", ++ "minecraft:stripped_spruce_wood": "tile.wood.stripped.spruce.name", ++ "minecraft:stripped_warped_hyphae": "tile.stripped_warped_hyphae.name", ++ "minecraft:stripped_warped_stem": "tile.stripped_warped_stem.name", ++ "minecraft:structure_block": "tile.structure_block.name", ++ "minecraft:structure_void": "tile.structure_void.name", ++ "minecraft:sulfur": "tile.sulfur.name", ++ "minecraft:sulfur_brick_double_slab": "tile.sulfur_brick_double_slab.name", ++ "minecraft:sulfur_brick_slab": "tile.sulfur_brick_slab.name", ++ "minecraft:sulfur_brick_stairs": "tile.sulfur_brick_stairs.name", ++ "minecraft:sulfur_brick_wall": "tile.sulfur_brick_wall.name", ++ "minecraft:sulfur_bricks": "tile.sulfur_bricks.name", ++ "minecraft:sulfur_double_slab": "tile.sulfur_double_slab.name", ++ "minecraft:sulfur_slab": "tile.sulfur_slab.name", ++ "minecraft:sulfur_spike": "tile.sulfur_spike.name", ++ "minecraft:sulfur_stairs": "tile.sulfur_stairs.name", ++ "minecraft:sulfur_wall": "tile.sulfur_wall.name", ++ "minecraft:sunflower": "tile.double_plant.sunflower.name", ++ "minecraft:suspicious_gravel": "tile.suspicious_gravel.name", ++ "minecraft:suspicious_sand": "tile.suspicious_sand.name", ++ "minecraft:sweet_berry_bush": "tile.sweet_berry_bush.name", ++ "minecraft:tall_dry_grass": "tile.tall_dry_grass.name", ++ "minecraft:tall_grass": "tile.double_plant.grass.name", ++ "minecraft:target": "tile.target.name", ++ "minecraft:tinted_glass": "tile.tinted_glass.name", ++ "minecraft:tnt": "tile.tnt.name", ++ "minecraft:torch": "tile.torch.name", ++ "minecraft:torchflower": "tile.torchflower.name", ++ "minecraft:torchflower_crop": "tile.torchflower_crop.name", ++ "minecraft:trapdoor": "tile.trapdoor.name", ++ "minecraft:trapped_chest": "tile.trapped_chest.name", ++ "minecraft:trial_spawner": "tile.trial_spawner.name", ++ "minecraft:trip_wire": "tile.tripWire.name", ++ "minecraft:tripwire_hook": "tile.tripwire_hook.name", ++ "minecraft:tube_coral": "tile.coral.blue.name", ++ "minecraft:tube_coral_block": "tile.coral_block.blue.name", ++ "minecraft:tube_coral_fan": "tile.coral_fan.blue_fan.name", ++ "minecraft:tube_coral_wall_fan": "tile.tube_coral_wall_fan.name", ++ "minecraft:tuff": "tile.tuff.name", ++ "minecraft:tuff_brick_double_slab": "tile.tuff_brick_double_slab.name", ++ "minecraft:tuff_brick_slab": "tile.tuff_brick_slab.name", ++ "minecraft:tuff_brick_stairs": "tile.tuff_brick_stairs.name", ++ "minecraft:tuff_brick_wall": "tile.tuff_brick_wall.name", ++ "minecraft:tuff_bricks": "tile.tuff_bricks.name", ++ "minecraft:tuff_double_slab": "tile.tuff_double_slab.name", ++ "minecraft:tuff_slab": "tile.tuff_slab.name", ++ "minecraft:tuff_stairs": "tile.tuff_stairs.name", ++ "minecraft:tuff_wall": "tile.tuff_wall.name", ++ "minecraft:turtle_egg": "tile.turtle_egg.name", ++ "minecraft:twisting_vines": "tile.twisting_vines.name", ++ "minecraft:underwater_tnt": "tile.tnt.name", ++ "minecraft:underwater_torch": "tile.underwater_torch.name", ++ "minecraft:undyed_shulker_box": "tile.shulkerBox.name", ++ "minecraft:unknown": "tile.unknown.name", ++ "minecraft:unlit_redstone_torch": "tile.unlit_redstone_torch.name", ++ "minecraft:unpowered_comparator": "tile.unpowered_comparator.name", ++ "minecraft:unpowered_repeater": "tile.unpowered_repeater.name", ++ "minecraft:vault": "tile.vault.name", ++ "minecraft:verdant_froglight": "tile.verdant_froglight.name", ++ "minecraft:vine": "tile.vine.name", ++ "minecraft:wall_banner": "tile.wall_banner.name", ++ "minecraft:wall_sign": "tile.wall_sign.name", ++ "minecraft:warped_button": "tile.warped_button.name", ++ "minecraft:warped_door": "tile.warped_door.name", ++ "minecraft:warped_double_slab": "tile.warped_double_slab.name", ++ "minecraft:warped_fence": "tile.warped_fence.name", ++ "minecraft:warped_fence_gate": "tile.warped_fence_gate.name", ++ "minecraft:warped_fungus": "tile.warped_fungus.name", ++ "minecraft:warped_hanging_sign": "tile.warped_hanging_sign.name", ++ "minecraft:warped_hyphae": "tile.warped_hyphae.name", ++ "minecraft:warped_nylium": "tile.warped_nylium.name", ++ "minecraft:warped_planks": "tile.warped_planks.name", ++ "minecraft:warped_pressure_plate": "tile.warped_pressure_plate.name", ++ "minecraft:warped_roots": "tile.warped_roots.warpedRoots.name", ++ "minecraft:warped_shelf": "tile.warped_shelf.name", ++ "minecraft:warped_slab": "tile.warped_slab.name", ++ "minecraft:warped_stairs": "tile.warped_stairs.name", ++ "minecraft:warped_standing_sign": "tile.warped_standing_sign.name", ++ "minecraft:warped_stem": "tile.warped_stem.name", ++ "minecraft:warped_trapdoor": "tile.warped_trapdoor.name", ++ "minecraft:warped_wall_sign": "tile.warped_wall_sign.name", ++ "minecraft:warped_wart_block": "tile.warped_wart_block.name", ++ "minecraft:water": "tile.water.name", ++ "minecraft:waterlily": "tile.waterlily.name", ++ "minecraft:waxed_chiseled_copper": "tile.waxed_chiseled_copper.name", ++ "minecraft:waxed_copper": "tile.waxed_copper.name", ++ "minecraft:waxed_copper_bars": "tile.waxed_copper_bars.name", ++ "minecraft:waxed_copper_bulb": "tile.waxed_copper_bulb.name", ++ "minecraft:waxed_copper_chain": "tile.waxed_copper_chain.name", ++ "minecraft:waxed_copper_chest": "tile.waxed_copper_chest.name", ++ "minecraft:waxed_copper_door": "tile.waxed_copper_door.name", ++ "minecraft:waxed_copper_golem_statue": "tile.waxed_copper_golem_statue.name", ++ "minecraft:waxed_copper_grate": "tile.waxed_copper_grate.name", ++ "minecraft:waxed_copper_lantern": "tile.waxed_copper_lantern.name", ++ "minecraft:waxed_copper_trapdoor": "tile.waxed_copper_trapdoor.name", ++ "minecraft:waxed_cut_copper": "tile.waxed_cut_copper.name", ++ "minecraft:waxed_cut_copper_slab": "tile.waxed_cut_copper_slab.name", ++ "minecraft:waxed_cut_copper_stairs": "tile.waxed_cut_copper_stairs.name", ++ "minecraft:waxed_double_cut_copper_slab": "tile.waxed_double_cut_copper_slab.name", ++ "minecraft:waxed_exposed_chiseled_copper": "tile.waxed_exposed_chiseled_copper.name", ++ "minecraft:waxed_exposed_copper": "tile.waxed_exposed_copper.name", ++ "minecraft:waxed_exposed_copper_bars": "tile.waxed_exposed_copper_bars.name", ++ "minecraft:waxed_exposed_copper_bulb": "tile.waxed_exposed_copper_bulb.name", ++ "minecraft:waxed_exposed_copper_chain": "tile.waxed_exposed_copper_chain.name", ++ "minecraft:waxed_exposed_copper_chest": "tile.waxed_exposed_copper_chest.name", ++ "minecraft:waxed_exposed_copper_door": "tile.waxed_exposed_copper_door.name", ++ "minecraft:waxed_exposed_copper_golem_statue": "tile.waxed_exposed_copper_golem_statue.name", ++ "minecraft:waxed_exposed_copper_grate": "tile.waxed_exposed_copper_grate.name", ++ "minecraft:waxed_exposed_copper_lantern": "tile.waxed_exposed_copper_lantern.name", ++ "minecraft:waxed_exposed_copper_trapdoor": "tile.waxed_exposed_copper_trapdoor.name", ++ "minecraft:waxed_exposed_cut_copper": "tile.waxed_exposed_cut_copper.name", ++ "minecraft:waxed_exposed_cut_copper_slab": "tile.waxed_exposed_cut_copper_slab.name", ++ "minecraft:waxed_exposed_cut_copper_stairs": "tile.waxed_exposed_cut_copper_stairs.name", ++ "minecraft:waxed_exposed_double_cut_copper_slab": "tile.waxed_exposed_double_cut_copper_slab.name", ++ "minecraft:waxed_exposed_lightning_rod": "tile.waxed_exposed_lightning_rod.name", ++ "minecraft:waxed_lightning_rod": "tile.waxed_lightning_rod.name", ++ "minecraft:waxed_oxidized_chiseled_copper": "tile.waxed_oxidized_chiseled_copper.name", ++ "minecraft:waxed_oxidized_copper": "tile.waxed_oxidized_copper.name", ++ "minecraft:waxed_oxidized_copper_bars": "tile.waxed_oxidized_copper_bars.name", ++ "minecraft:waxed_oxidized_copper_bulb": "tile.waxed_oxidized_copper_bulb.name", ++ "minecraft:waxed_oxidized_copper_chain": "tile.waxed_oxidized_copper_chain.name", ++ "minecraft:waxed_oxidized_copper_chest": "tile.waxed_oxidized_copper_chest.name", ++ "minecraft:waxed_oxidized_copper_door": "tile.waxed_oxidized_copper_door.name", ++ "minecraft:waxed_oxidized_copper_golem_statue": "tile.waxed_oxidized_copper_golem_statue.name", ++ "minecraft:waxed_oxidized_copper_grate": "tile.waxed_oxidized_copper_grate.name", ++ "minecraft:waxed_oxidized_copper_lantern": "tile.waxed_oxidized_copper_lantern.name", ++ "minecraft:waxed_oxidized_copper_trapdoor": "tile.waxed_oxidized_copper_trapdoor.name", ++ "minecraft:waxed_oxidized_cut_copper": "tile.waxed_oxidized_cut_copper.name", ++ "minecraft:waxed_oxidized_cut_copper_slab": "tile.waxed_oxidized_cut_copper_slab.name", ++ "minecraft:waxed_oxidized_cut_copper_stairs": "tile.waxed_oxidized_cut_copper_stairs.name", ++ "minecraft:waxed_oxidized_double_cut_copper_slab": "tile.waxed_oxidized_double_cut_copper_slab.name", ++ "minecraft:waxed_oxidized_lightning_rod": "tile.waxed_oxidized_lightning_rod.name", ++ "minecraft:waxed_weathered_chiseled_copper": "tile.waxed_weathered_chiseled_copper.name", ++ "minecraft:waxed_weathered_copper": "tile.waxed_weathered_copper.name", ++ "minecraft:waxed_weathered_copper_bars": "tile.waxed_weathered_copper_bars.name", ++ "minecraft:waxed_weathered_copper_bulb": "tile.waxed_weathered_copper_bulb.name", ++ "minecraft:waxed_weathered_copper_chain": "tile.waxed_weathered_copper_chain.name", ++ "minecraft:waxed_weathered_copper_chest": "tile.waxed_weathered_copper_chest.name", ++ "minecraft:waxed_weathered_copper_door": "tile.waxed_weathered_copper_door.name", ++ "minecraft:waxed_weathered_copper_golem_statue": "tile.waxed_weathered_copper_golem_statue.name", ++ "minecraft:waxed_weathered_copper_grate": "tile.waxed_weathered_copper_grate.name", ++ "minecraft:waxed_weathered_copper_lantern": "tile.waxed_weathered_copper_lantern.name", ++ "minecraft:waxed_weathered_copper_trapdoor": "tile.waxed_weathered_copper_trapdoor.name", ++ "minecraft:waxed_weathered_cut_copper": "tile.waxed_weathered_cut_copper.name", ++ "minecraft:waxed_weathered_cut_copper_slab": "tile.waxed_weathered_cut_copper_slab.name", ++ "minecraft:waxed_weathered_cut_copper_stairs": "tile.waxed_weathered_cut_copper_stairs.name", ++ "minecraft:waxed_weathered_double_cut_copper_slab": "tile.waxed_weathered_double_cut_copper_slab.name", ++ "minecraft:waxed_weathered_lightning_rod": "tile.waxed_weathered_lightning_rod.name", ++ "minecraft:weathered_chiseled_copper": "tile.weathered_chiseled_copper.name", ++ "minecraft:weathered_copper": "tile.weathered_copper.name", ++ "minecraft:weathered_copper_bars": "tile.weathered_copper_bars.name", ++ "minecraft:weathered_copper_bulb": "tile.weathered_copper_bulb.name", ++ "minecraft:weathered_copper_chain": "tile.weathered_copper_chain.name", ++ "minecraft:weathered_copper_chest": "tile.weathered_copper_chest.name", ++ "minecraft:weathered_copper_door": "tile.weathered_copper_door.name", ++ "minecraft:weathered_copper_golem_statue": "tile.weathered_copper_golem_statue.name", ++ "minecraft:weathered_copper_grate": "tile.weathered_copper_grate.name", ++ "minecraft:weathered_copper_lantern": "tile.weathered_copper_lantern.name", ++ "minecraft:weathered_copper_trapdoor": "tile.weathered_copper_trapdoor.name", ++ "minecraft:weathered_cut_copper": "tile.weathered_cut_copper.name", ++ "minecraft:weathered_cut_copper_slab": "tile.weathered_cut_copper_slab.name", ++ "minecraft:weathered_cut_copper_stairs": "tile.weathered_cut_copper_stairs.name", ++ "minecraft:weathered_double_cut_copper_slab": "tile.weathered_double_cut_copper_slab.name", ++ "minecraft:weathered_lightning_rod": "tile.weathered_lightning_rod.name", ++ "minecraft:web": "tile.web.name", ++ "minecraft:weeping_vines": "tile.weeping_vines.name", ++ "minecraft:wet_sponge": "tile.sponge.wet.name", ++ "minecraft:wheat": "tile.wheat.name", ++ "minecraft:white_candle": "tile.white_candle.name", ++ "minecraft:white_candle_cake": "tile.white_candle_cake.name", ++ "minecraft:white_carpet": "tile.carpet.white.name", ++ "minecraft:white_concrete": "tile.concrete.white.name", ++ "minecraft:white_concrete_powder": "tile.concretePowder.white.name", ++ "minecraft:white_glazed_terracotta": "tile.glazedTerracotta.white.name", ++ "minecraft:white_shulker_box": "tile.shulkerBoxWhite.name", ++ "minecraft:white_stained_glass": "tile.stained_glass.white.name", ++ "minecraft:white_stained_glass_pane": "tile.stained_glass_pane.white.name", ++ "minecraft:white_terracotta": "tile.stained_hardened_clay.white.name", ++ "minecraft:white_tulip": "tile.red_flower.tulipWhite.name", ++ "minecraft:white_wool": "tile.wool.white.name", ++ "minecraft:wildflowers": "tile.wildflowers.name", ++ "minecraft:wither_rose": "tile.wither_rose.name", ++ "minecraft:wither_skeleton_skull": "item.skull.wither.name", ++ "minecraft:wooden_button": "tile.wooden_button.name", ++ "minecraft:wooden_door": "tile.wooden_door.name", ++ "minecraft:wooden_pressure_plate": "tile.wooden_pressure_plate.name", ++ "minecraft:yellow_candle": "tile.yellow_candle.name", ++ "minecraft:yellow_candle_cake": "tile.yellow_candle_cake.name", ++ "minecraft:yellow_carpet": "tile.carpet.yellow.name", ++ "minecraft:yellow_concrete": "tile.concrete.yellow.name", ++ "minecraft:yellow_concrete_powder": "tile.concretePowder.yellow.name", ++ "minecraft:yellow_glazed_terracotta": "tile.glazedTerracotta.yellow.name", ++ "minecraft:yellow_poplar_leaves": "tile.yellow_poplar_leaves.name", ++ "minecraft:yellow_shulker_box": "tile.shulkerBoxYellow.name", ++ "minecraft:yellow_stained_glass": "tile.stained_glass.yellow.name", ++ "minecraft:yellow_stained_glass_pane": "tile.stained_glass_pane.yellow.name", ++ "minecraft:yellow_terracotta": "tile.stained_hardened_clay.yellow.name", ++ "minecraft:yellow_wool": "tile.wool.yellow.name", ++ "minecraft:zombie_head": "item.skull.zombie.name" ++ } ++} +diff --git a/src/main/resources/assets/viabedrock/data/bedrock/item_factories.json b/src/main/resources/assets/viabedrock/data/bedrock/item_factories.json +new file mode 100644 +index 000000000..a5147cdb6 +--- /dev/null ++++ b/src/main/resources/assets/viabedrock/data/bedrock/item_factories.json +@@ -0,0 +1,10012 @@ ++{ ++ "format": 1, ++ "gameVersion": "1.26.51.1", ++ "protocol": 2193, ++ "factories": [ ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 0, ++ "damageable": false, ++ "identifiers": [ ++ "minecraft:black_harness", ++ "minecraft:black_shulker_box", ++ "minecraft:blue_harness", ++ "minecraft:blue_shulker_box", ++ "minecraft:bordure_indented_banner_pattern", ++ "minecraft:brown_harness", ++ "minecraft:brown_shulker_box", ++ "minecraft:chest_minecart", ++ "minecraft:command_block_minecart", ++ "minecraft:copper_horse_armor", ++ "minecraft:copper_nautilus_armor", ++ "minecraft:creeper_banner_pattern", ++ "minecraft:cyan_harness", ++ "minecraft:cyan_shulker_box", ++ "minecraft:diamond_horse_armor", ++ "minecraft:diamond_nautilus_armor", ++ "minecraft:enchanted_book", ++ "minecraft:field_masoned_banner_pattern", ++ "minecraft:flow_banner_pattern", ++ "minecraft:flower_banner_pattern", ++ "minecraft:globe_banner_pattern", ++ "minecraft:goat_horn", ++ "minecraft:golden_horse_armor", ++ "minecraft:golden_nautilus_armor", ++ "minecraft:gray_harness", ++ "minecraft:gray_shulker_box", ++ "minecraft:green_harness", ++ "minecraft:green_shulker_box", ++ "minecraft:guster_banner_pattern", ++ "minecraft:hopper_minecart", ++ "minecraft:iron_horse_armor", ++ "minecraft:iron_nautilus_armor", ++ "minecraft:leather_horse_armor", ++ "minecraft:light_blue_harness", ++ "minecraft:light_blue_shulker_box", ++ "minecraft:light_gray_harness", ++ "minecraft:light_gray_shulker_box", ++ "minecraft:lime_harness", ++ "minecraft:lime_shulker_box", ++ "minecraft:magenta_harness", ++ "minecraft:magenta_shulker_box", ++ "minecraft:minecart", ++ "minecraft:mojang_banner_pattern", ++ "minecraft:netherite_horse_armor", ++ "minecraft:netherite_nautilus_armor", ++ "minecraft:orange_harness", ++ "minecraft:orange_shulker_box", ++ "minecraft:photo_item", ++ "minecraft:piglin_banner_pattern", ++ "minecraft:pink_harness", ++ "minecraft:pink_shulker_box", ++ "minecraft:portfolio", ++ "minecraft:purple_harness", ++ "minecraft:purple_shulker_box", ++ "minecraft:red_harness", ++ "minecraft:red_shulker_box", ++ "minecraft:saddle", ++ "minecraft:skull_banner_pattern", ++ "minecraft:spyglass", ++ "minecraft:suspicious_stew", ++ "minecraft:tnt_minecart", ++ "minecraft:totem_of_undying", ++ "minecraft:undyed_shulker_box", ++ "minecraft:white_harness", ++ "minecraft:white_shulker_box", ++ "minecraft:writable_book", ++ "minecraft:yellow_harness", ++ "minecraft:yellow_shulker_box" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 195, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:chainmail_boots", ++ "minecraft:iron_boots" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 240, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:chainmail_chestplate", ++ "minecraft:iron_chestplate" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 165, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:chainmail_helmet", ++ "minecraft:copper_leggings", ++ "minecraft:iron_helmet" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 225, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:chainmail_leggings", ++ "minecraft:iron_leggings" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 143, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:copper_boots" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 176, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:copper_chestplate" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 121, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:copper_helmet" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 429, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:diamond_boots" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 528, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:diamond_chestplate" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 363, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:diamond_helmet" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 495, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:diamond_leggings" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 432, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:elytra" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 91, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:golden_boots" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 112, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:golden_chestplate" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 77, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:golden_helmet" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 105, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:golden_leggings" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 65, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:leather_boots" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 80, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:leather_chestplate" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 55, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:leather_helmet" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 75, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:leather_leggings" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 481, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:netherite_boots" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 592, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:netherite_chestplate" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 407, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:netherite_helmet" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 555, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:netherite_leggings" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 275, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:turtle_helmet" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": true, ++ "maximumDamage": 0, ++ "damageable": false, ++ "forcedSubtype": true, ++ "identifiers": [ ++ "minecraft:acacia_boat", ++ "minecraft:acacia_chest_boat", ++ "minecraft:bamboo_chest_raft", ++ "minecraft:bamboo_raft", ++ "minecraft:birch_boat", ++ "minecraft:birch_chest_boat", ++ "minecraft:cherry_boat", ++ "minecraft:cherry_chest_boat", ++ "minecraft:dark_oak_boat", ++ "minecraft:dark_oak_chest_boat", ++ "minecraft:jungle_boat", ++ "minecraft:jungle_chest_boat", ++ "minecraft:mangrove_boat", ++ "minecraft:mangrove_chest_boat", ++ "minecraft:oak_boat", ++ "minecraft:oak_chest_boat", ++ "minecraft:pale_oak_boat", ++ "minecraft:pale_oak_chest_boat", ++ "minecraft:poplar_boat", ++ "minecraft:poplar_chest_boat", ++ "minecraft:spruce_boat", ++ "minecraft:spruce_chest_boat" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 26, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:carrot_on_a_stick" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 32, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:golden_axe", ++ "minecraft:golden_hoe", ++ "minecraft:golden_pickaxe", ++ "minecraft:golden_shovel", ++ "minecraft:golden_sword" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 59, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:wooden_axe", ++ "minecraft:wooden_hoe", ++ "minecraft:wooden_pickaxe", ++ "minecraft:wooden_shovel", ++ "minecraft:wooden_sword" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 64, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:brush", ++ "minecraft:flint_and_steel" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 131, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:stone_axe", ++ "minecraft:stone_hoe", ++ "minecraft:stone_pickaxe", ++ "minecraft:stone_shovel", ++ "minecraft:stone_sword" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 190, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:copper_axe", ++ "minecraft:copper_hoe", ++ "minecraft:copper_pickaxe", ++ "minecraft:copper_shovel", ++ "minecraft:copper_sword" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 238, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:shears" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 250, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:iron_axe", ++ "minecraft:iron_hoe", ++ "minecraft:iron_pickaxe", ++ "minecraft:iron_shovel", ++ "minecraft:iron_sword", ++ "minecraft:trident" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 336, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:shield" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 384, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:bow" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 464, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:crossbow" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 500, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:mace" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 1561, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:diamond_axe", ++ "minecraft:diamond_hoe", ++ "minecraft:diamond_pickaxe", ++ "minecraft:diamond_shovel", ++ "minecraft:diamond_sword" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 2031, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:netherite_axe", ++ "minecraft:netherite_hoe", ++ "minecraft:netherite_pickaxe", ++ "minecraft:netherite_shovel", ++ "minecraft:netherite_sword" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": true, ++ "maximumDamage": 0, ++ "damageable": false, ++ "identifiers": [ ++ "minecraft:bed", ++ "minecraft:lingering_potion", ++ "minecraft:medicine", ++ "minecraft:potion", ++ "minecraft:splash_potion" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": true, ++ "maximumDamage": 100, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:glow_stick", ++ "minecraft:sparkler" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 16, ++ "stackedByData": false, ++ "maximumDamage": 0, ++ "damageable": false, ++ "identifiers": [ ++ "minecraft:acacia_hanging_sign", ++ "minecraft:acacia_sign", ++ "minecraft:bamboo_hanging_sign", ++ "minecraft:bamboo_sign", ++ "minecraft:birch_hanging_sign", ++ "minecraft:birch_sign", ++ "minecraft:black_cushion", ++ "minecraft:blue_cushion", ++ "minecraft:blue_egg", ++ "minecraft:brown_cushion", ++ "minecraft:brown_egg", ++ "minecraft:cherry_hanging_sign", ++ "minecraft:cherry_sign", ++ "minecraft:crimson_hanging_sign", ++ "minecraft:crimson_sign", ++ "minecraft:cyan_cushion", ++ "minecraft:dark_oak_hanging_sign", ++ "minecraft:dark_oak_sign", ++ "minecraft:egg", ++ "minecraft:ender_pearl", ++ "minecraft:gray_cushion", ++ "minecraft:green_cushion", ++ "minecraft:ice_bomb", ++ "minecraft:jungle_hanging_sign", ++ "minecraft:jungle_sign", ++ "minecraft:light_blue_cushion", ++ "minecraft:light_gray_cushion", ++ "minecraft:lime_cushion", ++ "minecraft:magenta_cushion", ++ "minecraft:mangrove_hanging_sign", ++ "minecraft:mangrove_sign", ++ "minecraft:oak_hanging_sign", ++ "minecraft:oak_sign", ++ "minecraft:orange_cushion", ++ "minecraft:pale_oak_hanging_sign", ++ "minecraft:pale_oak_sign", ++ "minecraft:pink_cushion", ++ "minecraft:poplar_hanging_sign", ++ "minecraft:poplar_sign", ++ "minecraft:purple_cushion", ++ "minecraft:red_cushion", ++ "minecraft:spruce_hanging_sign", ++ "minecraft:spruce_sign", ++ "minecraft:straw_bed", ++ "minecraft:warped_hanging_sign", ++ "minecraft:warped_sign", ++ "minecraft:white_cushion", ++ "minecraft:written_book", ++ "minecraft:yellow_cushion" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 16, ++ "stackedByData": true, ++ "maximumDamage": 0, ++ "damageable": false, ++ "identifiers": [ ++ "minecraft:banner", ++ "minecraft:board" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 64, ++ "stackedByData": false, ++ "maximumDamage": 0, ++ "damageable": false, ++ "identifiers": [ ++ "minecraft:acacia_button", ++ "minecraft:acacia_door", ++ "minecraft:acacia_fence", ++ "minecraft:acacia_fence_gate", ++ "minecraft:acacia_leaves", ++ "minecraft:acacia_log", ++ "minecraft:acacia_planks", ++ "minecraft:acacia_pressure_plate", ++ "minecraft:acacia_shelf", ++ "minecraft:acacia_stairs", ++ "minecraft:acacia_standing_sign", ++ "minecraft:acacia_trapdoor", ++ "minecraft:acacia_wall_sign", ++ "minecraft:acacia_wood", ++ "minecraft:activator_rail", ++ "minecraft:air", ++ "minecraft:allium", ++ "minecraft:allow", ++ "minecraft:amethyst_block", ++ "minecraft:amethyst_cluster", ++ "minecraft:amethyst_shard", ++ "minecraft:ancient_debris", ++ "minecraft:andesite", ++ "minecraft:andesite_stairs", ++ "minecraft:andesite_wall", ++ "minecraft:angler_pottery_sherd", ++ "minecraft:anvil", ++ "minecraft:apple", ++ "minecraft:archer_pottery_sherd", ++ "minecraft:armadillo_scute", ++ "minecraft:armor_stand", ++ "minecraft:arms_up_pottery_sherd", ++ "minecraft:azalea", ++ "minecraft:azure_bluet", ++ "minecraft:baked_potato", ++ "minecraft:bamboo", ++ "minecraft:bamboo_block", ++ "minecraft:bamboo_button", ++ "minecraft:bamboo_door", ++ "minecraft:bamboo_fence", ++ "minecraft:bamboo_fence_gate", ++ "minecraft:bamboo_mosaic", ++ "minecraft:bamboo_mosaic_stairs", ++ "minecraft:bamboo_planks", ++ "minecraft:bamboo_pressure_plate", ++ "minecraft:bamboo_sapling", ++ "minecraft:bamboo_shelf", ++ "minecraft:bamboo_stairs", ++ "minecraft:bamboo_standing_sign", ++ "minecraft:bamboo_trapdoor", ++ "minecraft:bamboo_wall_sign", ++ "minecraft:banner_pattern", ++ "minecraft:barrel", ++ "minecraft:barrier", ++ "minecraft:basalt", ++ "minecraft:beacon", ++ "minecraft:bedrock", ++ "minecraft:bee_nest", ++ "minecraft:beef", ++ "minecraft:beehive", ++ "minecraft:beetroot", ++ "minecraft:beetroot_seeds", ++ "minecraft:beetroot_soup", ++ "minecraft:bell", ++ "minecraft:big_dripleaf", ++ "minecraft:birch_button", ++ "minecraft:birch_door", ++ "minecraft:birch_fence", ++ "minecraft:birch_fence_gate", ++ "minecraft:birch_leaves", ++ "minecraft:birch_log", ++ "minecraft:birch_planks", ++ "minecraft:birch_pressure_plate", ++ "minecraft:birch_shelf", ++ "minecraft:birch_stairs", ++ "minecraft:birch_standing_sign", ++ "minecraft:birch_trapdoor", ++ "minecraft:birch_wall_sign", ++ "minecraft:birch_wood", ++ "minecraft:black_candle", ++ "minecraft:black_candle_cake", ++ "minecraft:black_carpet", ++ "minecraft:black_concrete", ++ "minecraft:black_concrete_powder", ++ "minecraft:black_glazed_terracotta", ++ "minecraft:black_stained_glass", ++ "minecraft:black_stained_glass_pane", ++ "minecraft:black_terracotta", ++ "minecraft:black_wool", ++ "minecraft:blackstone", ++ "minecraft:blackstone_stairs", ++ "minecraft:blackstone_wall", ++ "minecraft:blade_pottery_sherd", ++ "minecraft:blast_furnace", ++ "minecraft:blaze_powder", ++ "minecraft:blaze_rod", ++ "minecraft:bleach", ++ "minecraft:blue_candle", ++ "minecraft:blue_candle_cake", ++ "minecraft:blue_carpet", ++ "minecraft:blue_concrete", ++ "minecraft:blue_concrete_powder", ++ "minecraft:blue_glazed_terracotta", ++ "minecraft:blue_ice", ++ "minecraft:blue_orchid", ++ "minecraft:blue_stained_glass", ++ "minecraft:blue_stained_glass_pane", ++ "minecraft:blue_terracotta", ++ "minecraft:blue_wool", ++ "minecraft:boat", ++ "minecraft:bolt_armor_trim_smithing_template", ++ "minecraft:bone", ++ "minecraft:bone_block", ++ "minecraft:book", ++ "minecraft:bookshelf", ++ "minecraft:border_block", ++ "minecraft:bowl", ++ "minecraft:brain_coral", ++ "minecraft:brain_coral_block", ++ "minecraft:brain_coral_wall_fan", ++ "minecraft:bread", ++ "minecraft:breeze_rod", ++ "minecraft:brewer_pottery_sherd", ++ "minecraft:brick", ++ "minecraft:brick_block", ++ "minecraft:brick_stairs", ++ "minecraft:brick_wall", ++ "minecraft:brown_candle", ++ "minecraft:brown_candle_cake", ++ "minecraft:brown_carpet", ++ "minecraft:brown_concrete", ++ "minecraft:brown_concrete_powder", ++ "minecraft:brown_glazed_terracotta", ++ "minecraft:brown_mushroom", ++ "minecraft:brown_stained_glass", ++ "minecraft:brown_stained_glass_pane", ++ "minecraft:brown_terracotta", ++ "minecraft:brown_wool", ++ "minecraft:bubble_column", ++ "minecraft:bubble_coral", ++ "minecraft:bubble_coral_block", ++ "minecraft:bubble_coral_wall_fan", ++ "minecraft:budding_amethyst", ++ "minecraft:burn_pottery_sherd", ++ "minecraft:bush", ++ "minecraft:cactus", ++ "minecraft:cactus_flower", ++ "minecraft:calcite", ++ "minecraft:calibrated_sculk_sensor", ++ "minecraft:camera", ++ "minecraft:candle", ++ "minecraft:candle_cake", ++ "minecraft:carpet", ++ "minecraft:carrot", ++ "minecraft:carrots", ++ "minecraft:cartography_table", ++ "minecraft:carved_pumpkin", ++ "minecraft:cave_vines", ++ "minecraft:cave_vines_body_with_berries", ++ "minecraft:cave_vines_head_with_berries", ++ "minecraft:chain_command_block", ++ "minecraft:chalkboard", ++ "minecraft:chemical_heat", ++ "minecraft:chemistry_table", ++ "minecraft:cherry_button", ++ "minecraft:cherry_door", ++ "minecraft:cherry_fence", ++ "minecraft:cherry_fence_gate", ++ "minecraft:cherry_leaves", ++ "minecraft:cherry_log", ++ "minecraft:cherry_planks", ++ "minecraft:cherry_pressure_plate", ++ "minecraft:cherry_sapling", ++ "minecraft:cherry_shelf", ++ "minecraft:cherry_stairs", ++ "minecraft:cherry_standing_sign", ++ "minecraft:cherry_trapdoor", ++ "minecraft:cherry_wall_sign", ++ "minecraft:cherry_wood", ++ "minecraft:chest", ++ "minecraft:chest_boat", ++ "minecraft:chicken", ++ "minecraft:chipped_anvil", ++ "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:chorus_fruit", ++ "minecraft:chorus_plant", ++ "minecraft:cinnabar", ++ "minecraft:cinnabar_brick_stairs", ++ "minecraft:cinnabar_brick_wall", ++ "minecraft:cinnabar_bricks", ++ "minecraft:cinnabar_stairs", ++ "minecraft:cinnabar_wall", ++ "minecraft:clay", ++ "minecraft:clay_ball", ++ "minecraft:client_request_placeholder_block", ++ "minecraft:clock", ++ "minecraft:closed_eyeblossom", ++ "minecraft:coal_block", ++ "minecraft:coal_ore", ++ "minecraft:coarse_dirt", ++ "minecraft:coast_armor_trim_smithing_template", ++ "minecraft:cobbled_deepslate", ++ "minecraft:cobbled_deepslate_stairs", ++ "minecraft:cobbled_deepslate_wall", ++ "minecraft:cobblestone", ++ "minecraft:cobblestone_wall", ++ "minecraft:cocoa", ++ "minecraft:cod", ++ "minecraft:colored_torch_blue", ++ "minecraft:colored_torch_bp", ++ "minecraft:colored_torch_green", ++ "minecraft:colored_torch_purple", ++ "minecraft:colored_torch_red", ++ "minecraft:colored_torch_rg", ++ "minecraft:command_block", ++ "minecraft:compass", ++ "minecraft:composter", ++ "minecraft:compound_creator", ++ "minecraft:concrete", ++ "minecraft:concrete_powder", ++ "minecraft:conduit", ++ "minecraft:cooked_beef", ++ "minecraft:cooked_chicken", ++ "minecraft:cooked_cod", ++ "minecraft:cooked_mutton", ++ "minecraft:cooked_porkchop", ++ "minecraft:cooked_rabbit", ++ "minecraft:cooked_salmon", ++ "minecraft:cookie", ++ "minecraft:copper_bars", ++ "minecraft:copper_bulb", ++ "minecraft:copper_chain", ++ "minecraft:copper_chest", ++ "minecraft:copper_door", ++ "minecraft:copper_golem_statue", ++ "minecraft:copper_grate", ++ "minecraft:copper_ingot", ++ "minecraft:copper_lantern", ++ "minecraft:copper_nugget", ++ "minecraft:copper_ore", ++ "minecraft:copper_torch", ++ "minecraft:copper_trapdoor", ++ "minecraft:coral", ++ "minecraft:coral_block", ++ "minecraft:coral_fan", ++ "minecraft:coral_fan_dead", ++ "minecraft:cornflower", ++ "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_button", ++ "minecraft:crimson_door", ++ "minecraft:crimson_fence", ++ "minecraft:crimson_fence_gate", ++ "minecraft:crimson_fungus", ++ "minecraft:crimson_hyphae", ++ "minecraft:crimson_nylium", ++ "minecraft:crimson_planks", ++ "minecraft:crimson_pressure_plate", ++ "minecraft:crimson_shelf", ++ "minecraft:crimson_stairs", ++ "minecraft:crimson_standing_sign", ++ "minecraft:crimson_stem", ++ "minecraft:crimson_trapdoor", ++ "minecraft:crimson_wall_sign", ++ "minecraft:crying_obsidian", ++ "minecraft:cut_red_sandstone", ++ "minecraft:cut_sandstone", ++ "minecraft:cyan_candle", ++ "minecraft:cyan_candle_cake", ++ "minecraft:cyan_carpet", ++ "minecraft:cyan_concrete", ++ "minecraft:cyan_concrete_powder", ++ "minecraft:cyan_glazed_terracotta", ++ "minecraft:cyan_stained_glass", ++ "minecraft:cyan_stained_glass_pane", ++ "minecraft:cyan_terracotta", ++ "minecraft:cyan_wool", ++ "minecraft:damaged_anvil", ++ "minecraft:danger_pottery_sherd", ++ "minecraft:dark_oak_button", ++ "minecraft:dark_oak_door", ++ "minecraft:dark_oak_fence", ++ "minecraft:dark_oak_fence_gate", ++ "minecraft:dark_oak_leaves", ++ "minecraft:dark_oak_log", ++ "minecraft:dark_oak_planks", ++ "minecraft:dark_oak_pressure_plate", ++ "minecraft:dark_oak_shelf", ++ "minecraft:dark_oak_stairs", ++ "minecraft:dark_oak_trapdoor", ++ "minecraft:dark_oak_wood", ++ "minecraft:dark_prismarine", ++ "minecraft:dark_prismarine_stairs", ++ "minecraft:darkoak_standing_sign", ++ "minecraft:darkoak_wall_sign", ++ "minecraft:daylight_detector", ++ "minecraft:daylight_detector_inverted", ++ "minecraft:dead_brain_coral", ++ "minecraft:dead_brain_coral_block", ++ "minecraft:dead_brain_coral_wall_fan", ++ "minecraft:dead_bubble_coral", ++ "minecraft:dead_bubble_coral_block", ++ "minecraft:dead_bubble_coral_wall_fan", ++ "minecraft:dead_fire_coral", ++ "minecraft:dead_fire_coral_block", ++ "minecraft:dead_fire_coral_wall_fan", ++ "minecraft:dead_horn_coral", ++ "minecraft:dead_horn_coral_block", ++ "minecraft:dead_horn_coral_wall_fan", ++ "minecraft:dead_tube_coral", ++ "minecraft:dead_tube_coral_block", ++ "minecraft:dead_tube_coral_wall_fan", ++ "minecraft:deadbush", ++ "minecraft:decorated_pot", ++ "minecraft:deepslate", ++ "minecraft:deepslate_brick_stairs", ++ "minecraft:deepslate_brick_wall", ++ "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_tile_wall", ++ "minecraft:deepslate_tiles", ++ "minecraft:deny", ++ "minecraft:deprecated_anvil", ++ "minecraft:deprecated_purpur_block_1", ++ "minecraft:deprecated_purpur_block_2", ++ "minecraft:detector_rail", ++ "minecraft:diamond", ++ "minecraft:diamond_block", ++ "minecraft:diamond_ore", ++ "minecraft:diorite", ++ "minecraft:diorite_stairs", ++ "minecraft:diorite_wall", ++ "minecraft:dirt", ++ "minecraft:dirt_with_roots", ++ "minecraft:disc_fragment_5", ++ "minecraft:dispenser", ++ "minecraft:double_plant", ++ "minecraft:double_stone_block_slab", ++ "minecraft:double_stone_block_slab2", ++ "minecraft:double_stone_block_slab3", ++ "minecraft:double_stone_block_slab4", ++ "minecraft:dragon_breath", ++ "minecraft:dragon_egg", ++ "minecraft:dried_ghast", ++ "minecraft:dried_kelp", ++ "minecraft:dried_kelp_block", ++ "minecraft:dripstone_block", ++ "minecraft:dropper", ++ "minecraft:dune_armor_trim_smithing_template", ++ "minecraft:dye", ++ "minecraft:echo_shard", ++ "minecraft:element_constructor", ++ "minecraft:emerald", ++ "minecraft:emerald_block", ++ "minecraft:emerald_ore", ++ "minecraft:enchanted_golden_apple", ++ "minecraft:enchanting_table", ++ "minecraft:end_brick_stairs", ++ "minecraft:end_bricks", ++ "minecraft:end_gateway", ++ "minecraft:end_portal", ++ "minecraft:end_rod", ++ "minecraft:end_stone", ++ "minecraft:end_stone_brick_wall", ++ "minecraft:ender_chest", ++ "minecraft:ender_eye", ++ "minecraft:experience_bottle", ++ "minecraft:explorer_pottery_sherd", ++ "minecraft:exposed_chiseled_copper", ++ "minecraft:exposed_copper", ++ "minecraft:exposed_copper_bars", ++ "minecraft:exposed_copper_bulb", ++ "minecraft:exposed_copper_chain", ++ "minecraft:exposed_copper_chest", ++ "minecraft:exposed_copper_door", ++ "minecraft:exposed_copper_golem_statue", ++ "minecraft:exposed_copper_grate", ++ "minecraft:exposed_copper_lantern", ++ "minecraft:exposed_copper_trapdoor", ++ "minecraft:exposed_cut_copper", ++ "minecraft:exposed_cut_copper_stairs", ++ "minecraft:exposed_lightning_rod", ++ "minecraft:eye_armor_trim_smithing_template", ++ "minecraft:feather", ++ "minecraft:fence", ++ "minecraft:fence_gate", ++ "minecraft:fermented_spider_eye", ++ "minecraft:fern", ++ "minecraft:fire", ++ "minecraft:fire_charge", ++ "minecraft:fire_coral", ++ "minecraft:fire_coral_block", ++ "minecraft:fire_coral_wall_fan", ++ "minecraft:firefly_bush", ++ "minecraft:firework_rocket", ++ "minecraft:firework_star", ++ "minecraft:fletching_table", ++ "minecraft:flint", ++ "minecraft:flow_armor_trim_smithing_template", ++ "minecraft:flow_pottery_sherd", ++ "minecraft:flowering_azalea", ++ "minecraft:flowing_lava", ++ "minecraft:flowing_water", ++ "minecraft:friend_pottery_sherd", ++ "minecraft:frog_spawn", ++ "minecraft:frosted_ice", ++ "minecraft:furnace", ++ "minecraft:ghast_tear", ++ "minecraft:gilded_blackstone", ++ "minecraft:glass", ++ "minecraft:glass_bottle", ++ "minecraft:glass_pane", ++ "minecraft:glistering_melon_slice", ++ "minecraft:glow_ink_sac", ++ "minecraft:glow_lichen", ++ "minecraft:glowingobsidian", ++ "minecraft:glowstone", ++ "minecraft:glowstone_dust", ++ "minecraft:gold_block", ++ "minecraft:gold_ingot", ++ "minecraft:gold_nugget", ++ "minecraft:gold_ore", ++ "minecraft:golden_apple", ++ "minecraft:golden_carrot", ++ "minecraft:golden_dandelion", ++ "minecraft:golden_rail", ++ "minecraft:granite", ++ "minecraft:granite_stairs", ++ "minecraft:granite_wall", ++ "minecraft:grass_block", ++ "minecraft:grass_path", ++ "minecraft:gravel", ++ "minecraft:gray_candle", ++ "minecraft:gray_candle_cake", ++ "minecraft:gray_carpet", ++ "minecraft:gray_concrete", ++ "minecraft:gray_concrete_powder", ++ "minecraft:gray_glazed_terracotta", ++ "minecraft:gray_stained_glass", ++ "minecraft:gray_stained_glass_pane", ++ "minecraft:gray_terracotta", ++ "minecraft:gray_wool", ++ "minecraft:green_candle", ++ "minecraft:green_candle_cake", ++ "minecraft:green_carpet", ++ "minecraft:green_concrete", ++ "minecraft:green_concrete_powder", ++ "minecraft:green_glazed_terracotta", ++ "minecraft:green_stained_glass", ++ "minecraft:green_stained_glass_pane", ++ "minecraft:green_terracotta", ++ "minecraft:green_wool", ++ "minecraft:grindstone", ++ "minecraft:gunpowder", ++ "minecraft:guster_pottery_sherd", ++ "minecraft:hanging_roots", ++ "minecraft:hard_black_stained_glass", ++ "minecraft:hard_black_stained_glass_pane", ++ "minecraft:hard_blue_stained_glass", ++ "minecraft:hard_blue_stained_glass_pane", ++ "minecraft:hard_brown_stained_glass", ++ "minecraft:hard_brown_stained_glass_pane", ++ "minecraft:hard_cyan_stained_glass", ++ "minecraft:hard_cyan_stained_glass_pane", ++ "minecraft:hard_glass", ++ "minecraft:hard_glass_pane", ++ "minecraft:hard_gray_stained_glass", ++ "minecraft:hard_gray_stained_glass_pane", ++ "minecraft:hard_green_stained_glass", ++ "minecraft:hard_green_stained_glass_pane", ++ "minecraft:hard_light_blue_stained_glass", ++ "minecraft:hard_light_blue_stained_glass_pane", ++ "minecraft:hard_light_gray_stained_glass", ++ "minecraft:hard_light_gray_stained_glass_pane", ++ "minecraft:hard_lime_stained_glass", ++ "minecraft:hard_lime_stained_glass_pane", ++ "minecraft:hard_magenta_stained_glass", ++ "minecraft:hard_magenta_stained_glass_pane", ++ "minecraft:hard_orange_stained_glass", ++ "minecraft:hard_orange_stained_glass_pane", ++ "minecraft:hard_pink_stained_glass", ++ "minecraft:hard_pink_stained_glass_pane", ++ "minecraft:hard_purple_stained_glass", ++ "minecraft:hard_purple_stained_glass_pane", ++ "minecraft:hard_red_stained_glass", ++ "minecraft:hard_red_stained_glass_pane", ++ "minecraft:hard_stained_glass", ++ "minecraft:hard_stained_glass_pane", ++ "minecraft:hard_white_stained_glass", ++ "minecraft:hard_white_stained_glass_pane", ++ "minecraft:hard_yellow_stained_glass", ++ "minecraft:hard_yellow_stained_glass_pane", ++ "minecraft:hardened_clay", ++ "minecraft:hay_block", ++ "minecraft:heart_of_the_sea", ++ "minecraft:heart_pottery_sherd", ++ "minecraft:heartbreak_pottery_sherd", ++ "minecraft:heavy_core", ++ "minecraft:heavy_weighted_pressure_plate", ++ "minecraft:honey_block", ++ "minecraft:honey_bottle", ++ "minecraft:honeycomb", ++ "minecraft:honeycomb_block", ++ "minecraft:horn_coral", ++ "minecraft:horn_coral_block", ++ "minecraft:horn_coral_wall_fan", ++ "minecraft:host_armor_trim_smithing_template", ++ "minecraft:howl_pottery_sherd", ++ "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_bars", ++ "minecraft:iron_block", ++ "minecraft:iron_chain", ++ "minecraft:iron_door", ++ "minecraft:iron_ingot", ++ "minecraft:iron_nugget", ++ "minecraft:iron_ore", ++ "minecraft:iron_trapdoor", ++ "minecraft:item.bed", ++ "minecraft:item.beetroot", ++ "minecraft:item.brewing_stand", ++ "minecraft:item.cake", ++ "minecraft:item.camera", ++ "minecraft:item.campfire", ++ "minecraft:item.cauldron", ++ "minecraft:item.flower_pot", ++ "minecraft:item.frame", ++ "minecraft:item.glow_frame", ++ "minecraft:item.hopper", ++ "minecraft:item.kelp", ++ "minecraft:item.nether_sprouts", ++ "minecraft:item.nether_wart", ++ "minecraft:item.reeds", ++ "minecraft:item.soul_campfire", ++ "minecraft:item.straw_bed", ++ "minecraft:item.wheat", ++ "minecraft:jigsaw", ++ "minecraft:jukebox", ++ "minecraft:jungle_button", ++ "minecraft:jungle_door", ++ "minecraft:jungle_fence", ++ "minecraft:jungle_fence_gate", ++ "minecraft:jungle_leaves", ++ "minecraft:jungle_log", ++ "minecraft:jungle_planks", ++ "minecraft:jungle_pressure_plate", ++ "minecraft:jungle_shelf", ++ "minecraft:jungle_stairs", ++ "minecraft:jungle_standing_sign", ++ "minecraft:jungle_trapdoor", ++ "minecraft:jungle_wall_sign", ++ "minecraft:jungle_wood", ++ "minecraft:lab_table", ++ "minecraft:ladder", ++ "minecraft:lantern", ++ "minecraft:lapis_block", ++ "minecraft:lapis_ore", ++ "minecraft:large_amethyst_bud", ++ "minecraft:large_fern", ++ "minecraft:lava", ++ "minecraft:lead", ++ "minecraft:leaf_litter", ++ "minecraft:leather", ++ "minecraft:leaves", ++ "minecraft:leaves2", ++ "minecraft:lectern", ++ "minecraft:lever", ++ "minecraft:light_block", ++ "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_blue_candle", ++ "minecraft:light_blue_candle_cake", ++ "minecraft:light_blue_carpet", ++ "minecraft:light_blue_concrete", ++ "minecraft:light_blue_concrete_powder", ++ "minecraft:light_blue_glazed_terracotta", ++ "minecraft:light_blue_stained_glass", ++ "minecraft:light_blue_stained_glass_pane", ++ "minecraft:light_blue_terracotta", ++ "minecraft:light_blue_wool", ++ "minecraft:light_gray_candle", ++ "minecraft:light_gray_candle_cake", ++ "minecraft:light_gray_carpet", ++ "minecraft:light_gray_concrete", ++ "minecraft:light_gray_concrete_powder", ++ "minecraft:light_gray_stained_glass", ++ "minecraft:light_gray_stained_glass_pane", ++ "minecraft:light_gray_terracotta", ++ "minecraft:light_gray_wool", ++ "minecraft:light_weighted_pressure_plate", ++ "minecraft:lightning_rod", ++ "minecraft:lilac", ++ "minecraft:lily_of_the_valley", ++ "minecraft:lime_candle", ++ "minecraft:lime_candle_cake", ++ "minecraft:lime_carpet", ++ "minecraft:lime_concrete", ++ "minecraft:lime_concrete_powder", ++ "minecraft:lime_glazed_terracotta", ++ "minecraft:lime_stained_glass", ++ "minecraft:lime_stained_glass_pane", ++ "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:lodestone_compass", ++ "minecraft:log", ++ "minecraft:log2", ++ "minecraft:loom", ++ "minecraft:magenta_candle", ++ "minecraft:magenta_candle_cake", ++ "minecraft:magenta_carpet", ++ "minecraft:magenta_concrete", ++ "minecraft:magenta_concrete_powder", ++ "minecraft:magenta_glazed_terracotta", ++ "minecraft:magenta_stained_glass", ++ "minecraft:magenta_stained_glass_pane", ++ "minecraft:magenta_terracotta", ++ "minecraft:magenta_wool", ++ "minecraft:magma", ++ "minecraft:magma_cream", ++ "minecraft:mangrove_button", ++ "minecraft:mangrove_door", ++ "minecraft:mangrove_fence", ++ "minecraft:mangrove_fence_gate", ++ "minecraft:mangrove_log", ++ "minecraft:mangrove_planks", ++ "minecraft:mangrove_pressure_plate", ++ "minecraft:mangrove_propagule", ++ "minecraft:mangrove_roots", ++ "minecraft:mangrove_shelf", ++ "minecraft:mangrove_stairs", ++ "minecraft:mangrove_standing_sign", ++ "minecraft:mangrove_trapdoor", ++ "minecraft:mangrove_wall_sign", ++ "minecraft:mangrove_wood", ++ "minecraft:material_reducer", ++ "minecraft:medium_amethyst_bud", ++ "minecraft:melon_block", ++ "minecraft:melon_seeds", ++ "minecraft:melon_slice", ++ "minecraft:melon_stem", ++ "minecraft:miner_pottery_sherd", ++ "minecraft:mob_spawner", ++ "minecraft:monster_egg", ++ "minecraft:moss_block", ++ "minecraft:moss_carpet", ++ "minecraft:mossy_cobblestone", ++ "minecraft:mossy_cobblestone_stairs", ++ "minecraft:mossy_cobblestone_wall", ++ "minecraft:mossy_stone_brick_stairs", ++ "minecraft:mossy_stone_brick_wall", ++ "minecraft:mossy_stone_bricks", ++ "minecraft:mourner_pottery_sherd", ++ "minecraft:moving_block", ++ "minecraft:mud", ++ "minecraft:mud_brick_stairs", ++ "minecraft:mud_brick_wall", ++ "minecraft:mud_bricks", ++ "minecraft:muddy_mangrove_roots", ++ "minecraft:mushroom_stem", ++ "minecraft:mushroom_stew", ++ "minecraft:mutton", ++ "minecraft:mycelium", ++ "minecraft:name_tag", ++ "minecraft:nautilus_shell", ++ "minecraft:nether_brick", ++ "minecraft:nether_brick_fence", ++ "minecraft:nether_brick_stairs", ++ "minecraft:nether_brick_wall", ++ "minecraft:nether_gold_ore", ++ "minecraft:nether_star", ++ "minecraft:nether_wart", ++ "minecraft:nether_wart_block", ++ "minecraft:netherbrick", ++ "minecraft:netherite_block", ++ "minecraft:netherite_ingot", ++ "minecraft:netherite_scrap", ++ "minecraft:netherite_upgrade_smithing_template", ++ "minecraft:netherrack", ++ "minecraft:netherreactor", ++ "minecraft:normal_stone_stairs", ++ "minecraft:noteblock", ++ "minecraft:oak_fence", ++ "minecraft:oak_leaves", ++ "minecraft:oak_log", ++ "minecraft:oak_planks", ++ "minecraft:oak_shelf", ++ "minecraft:oak_stairs", ++ "minecraft:oak_wood", ++ "minecraft:observer", ++ "minecraft:obsidian", ++ "minecraft:ochre_froglight", ++ "minecraft:open_eyeblossom", ++ "minecraft:orange_candle", ++ "minecraft:orange_candle_cake", ++ "minecraft:orange_carpet", ++ "minecraft:orange_concrete", ++ "minecraft:orange_concrete_powder", ++ "minecraft:orange_glazed_terracotta", ++ "minecraft:orange_poplar_leaves", ++ "minecraft:orange_stained_glass", ++ "minecraft:orange_stained_glass_pane", ++ "minecraft:orange_terracotta", ++ "minecraft:orange_tulip", ++ "minecraft:orange_wool", ++ "minecraft:oxeye_daisy", ++ "minecraft:oxidized_chiseled_copper", ++ "minecraft:oxidized_copper", ++ "minecraft:oxidized_copper_bars", ++ "minecraft:oxidized_copper_bulb", ++ "minecraft:oxidized_copper_chain", ++ "minecraft:oxidized_copper_chest", ++ "minecraft:oxidized_copper_door", ++ "minecraft:oxidized_copper_golem_statue", ++ "minecraft:oxidized_copper_grate", ++ "minecraft:oxidized_copper_lantern", ++ "minecraft:oxidized_copper_trapdoor", ++ "minecraft:oxidized_cut_copper", ++ "minecraft:oxidized_cut_copper_stairs", ++ "minecraft:oxidized_lightning_rod", ++ "minecraft:packed_ice", ++ "minecraft:packed_mud", ++ "minecraft:painting", ++ "minecraft:pale_hanging_moss", ++ "minecraft:pale_moss_block", ++ "minecraft:pale_moss_carpet", ++ "minecraft:pale_oak_button", ++ "minecraft:pale_oak_door", ++ "minecraft:pale_oak_fence", ++ "minecraft:pale_oak_fence_gate", ++ "minecraft:pale_oak_leaves", ++ "minecraft:pale_oak_log", ++ "minecraft:pale_oak_planks", ++ "minecraft:pale_oak_pressure_plate", ++ "minecraft:pale_oak_sapling", ++ "minecraft:pale_oak_shelf", ++ "minecraft:pale_oak_stairs", ++ "minecraft:pale_oak_standing_sign", ++ "minecraft:pale_oak_trapdoor", ++ "minecraft:pale_oak_wall_sign", ++ "minecraft:pale_oak_wood", ++ "minecraft:paper", ++ "minecraft:pearlescent_froglight", ++ "minecraft:peony", ++ "minecraft:phantom_membrane", ++ "minecraft:pink_candle", ++ "minecraft:pink_candle_cake", ++ "minecraft:pink_carpet", ++ "minecraft:pink_concrete", ++ "minecraft:pink_concrete_powder", ++ "minecraft:pink_glazed_terracotta", ++ "minecraft:pink_petals", ++ "minecraft:pink_stained_glass", ++ "minecraft:pink_stained_glass_pane", ++ "minecraft:pink_terracotta", ++ "minecraft:pink_tulip", ++ "minecraft:pink_wool", ++ "minecraft:piston", ++ "minecraft:piston_arm_collision", ++ "minecraft:pitcher_crop", ++ "minecraft:pitcher_plant", ++ "minecraft:pitcher_pod", ++ "minecraft:planks", ++ "minecraft:plenty_pottery_sherd", ++ "minecraft:podzol", ++ "minecraft:pointed_dripstone", ++ "minecraft:poisonous_potato", ++ "minecraft:polished_andesite", ++ "minecraft:polished_andesite_stairs", ++ "minecraft:polished_basalt", ++ "minecraft:polished_blackstone", ++ "minecraft:polished_blackstone_brick_stairs", ++ "minecraft:polished_blackstone_brick_wall", ++ "minecraft:polished_blackstone_bricks", ++ "minecraft:polished_blackstone_button", ++ "minecraft:polished_blackstone_pressure_plate", ++ "minecraft:polished_blackstone_stairs", ++ "minecraft:polished_blackstone_wall", ++ "minecraft:polished_cinnabar", ++ "minecraft:polished_cinnabar_stairs", ++ "minecraft:polished_cinnabar_wall", ++ "minecraft:polished_deepslate", ++ "minecraft:polished_deepslate_stairs", ++ "minecraft:polished_deepslate_wall", ++ "minecraft:polished_diorite", ++ "minecraft:polished_diorite_stairs", ++ "minecraft:polished_granite", ++ "minecraft:polished_granite_stairs", ++ "minecraft:polished_sulfur", ++ "minecraft:polished_sulfur_stairs", ++ "minecraft:polished_sulfur_wall", ++ "minecraft:polished_tuff", ++ "minecraft:polished_tuff_stairs", ++ "minecraft:polished_tuff_wall", ++ "minecraft:poplar_button", ++ "minecraft:poplar_door", ++ "minecraft:poplar_fence", ++ "minecraft:poplar_fence_gate", ++ "minecraft:poplar_log", ++ "minecraft:poplar_planks", ++ "minecraft:poplar_pressure_plate", ++ "minecraft:poplar_sapling", ++ "minecraft:poplar_shelf", ++ "minecraft:poplar_stairs", ++ "minecraft:poplar_standing_sign", ++ "minecraft:poplar_trapdoor", ++ "minecraft:poplar_wall_sign", ++ "minecraft:poplar_wood", ++ "minecraft:popped_chorus_fruit", ++ "minecraft:poppy", ++ "minecraft:porkchop", ++ "minecraft:portal", ++ "minecraft:potato", ++ "minecraft:potatoes", ++ "minecraft:potent_sulfur", ++ "minecraft:powder_snow", ++ "minecraft:powered_comparator", ++ "minecraft:powered_repeater", ++ "minecraft:prismarine", ++ "minecraft:prismarine_bricks", ++ "minecraft:prismarine_bricks_stairs", ++ "minecraft:prismarine_crystals", ++ "minecraft:prismarine_shard", ++ "minecraft:prismarine_stairs", ++ "minecraft:prismarine_wall", ++ "minecraft:prize_pottery_sherd", ++ "minecraft:pufferfish", ++ "minecraft:pumpkin", ++ "minecraft:pumpkin_pie", ++ "minecraft:pumpkin_seeds", ++ "minecraft:pumpkin_stem", ++ "minecraft:purple_candle", ++ "minecraft:purple_candle_cake", ++ "minecraft:purple_carpet", ++ "minecraft:purple_concrete", ++ "minecraft:purple_concrete_powder", ++ "minecraft:purple_glazed_terracotta", ++ "minecraft:purple_stained_glass", ++ "minecraft:purple_stained_glass_pane", ++ "minecraft:purple_terracotta", ++ "minecraft:purple_wool", ++ "minecraft:purpur_block", ++ "minecraft:purpur_pillar", ++ "minecraft:purpur_stairs", ++ "minecraft:quartz", ++ "minecraft:quartz_block", ++ "minecraft:quartz_bricks", ++ "minecraft:quartz_ore", ++ "minecraft:quartz_pillar", ++ "minecraft:quartz_stairs", ++ "minecraft:rabbit", ++ "minecraft:rabbit_foot", ++ "minecraft:rabbit_hide", ++ "minecraft:rabbit_stew", ++ "minecraft:rail", ++ "minecraft:raiser_armor_trim_smithing_template", ++ "minecraft:rapid_fertilizer", ++ "minecraft:raw_copper", ++ "minecraft:raw_copper_block", ++ "minecraft:raw_gold", ++ "minecraft:raw_gold_block", ++ "minecraft:raw_iron", ++ "minecraft:raw_iron_block", ++ "minecraft:recovery_compass", ++ "minecraft:red_candle", ++ "minecraft:red_candle_cake", ++ "minecraft:red_carpet", ++ "minecraft:red_concrete", ++ "minecraft:red_concrete_powder", ++ "minecraft:red_flower", ++ "minecraft:red_glazed_terracotta", ++ "minecraft:red_mushroom", ++ "minecraft:red_nether_brick", ++ "minecraft:red_nether_brick_stairs", ++ "minecraft:red_nether_brick_wall", ++ "minecraft:red_poplar_leaves", ++ "minecraft:red_sand", ++ "minecraft:red_sandstone", ++ "minecraft:red_sandstone_stairs", ++ "minecraft:red_sandstone_wall", ++ "minecraft:red_stained_glass", ++ "minecraft:red_stained_glass_pane", ++ "minecraft:red_terracotta", ++ "minecraft:red_tulip", ++ "minecraft:red_wool", ++ "minecraft:redstone", ++ "minecraft:redstone_block", ++ "minecraft:redstone_lamp", ++ "minecraft:redstone_ore", ++ "minecraft:redstone_torch", ++ "minecraft:redstone_wire", ++ "minecraft:reinforced_deepslate", ++ "minecraft:repeating_command_block", ++ "minecraft:reserved6", ++ "minecraft:resin_block", ++ "minecraft:resin_brick", ++ "minecraft:resin_brick_stairs", ++ "minecraft:resin_brick_wall", ++ "minecraft:resin_bricks", ++ "minecraft:resin_clump", ++ "minecraft:rib_armor_trim_smithing_template", ++ "minecraft:rose_bush", ++ "minecraft:rotten_flesh", ++ "minecraft:salmon", ++ "minecraft:sand", ++ "minecraft:sandstone", ++ "minecraft:sandstone_stairs", ++ "minecraft:sandstone_wall", ++ "minecraft:sapling", ++ "minecraft:scaffolding", ++ "minecraft:scrape_pottery_sherd", ++ "minecraft:sculk", ++ "minecraft:sculk_catalyst", ++ "minecraft:sculk_sensor", ++ "minecraft:sculk_shrieker", ++ "minecraft:sculk_vein", ++ "minecraft:sea_lantern", ++ "minecraft:sea_pickle", ++ "minecraft:sentry_armor_trim_smithing_template", ++ "minecraft:shaper_armor_trim_smithing_template", ++ "minecraft:sheaf_pottery_sherd", ++ "minecraft:shelter_pottery_sherd", ++ "minecraft:short_dry_grass", ++ "minecraft:short_grass", ++ "minecraft:shroomlight", ++ "minecraft:shulker_box", ++ "minecraft:shulker_shell", ++ "minecraft:silence_armor_trim_smithing_template", ++ "minecraft:silver_glazed_terracotta", ++ "minecraft:skull", ++ "minecraft:skull_pottery_sherd", ++ "minecraft:slime", ++ "minecraft:slime_ball", ++ "minecraft:small_amethyst_bud", ++ "minecraft:small_dripleaf_block", ++ "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:sniffer_egg", ++ "minecraft:snort_pottery_sherd", ++ "minecraft:snout_armor_trim_smithing_template", ++ "minecraft:snow", ++ "minecraft:soul_fire", ++ "minecraft:soul_lantern", ++ "minecraft:soul_sand", ++ "minecraft:soul_soil", ++ "minecraft:soul_torch", ++ "minecraft:spawn_egg", ++ "minecraft:spider_eye", ++ "minecraft:spire_armor_trim_smithing_template", ++ "minecraft:sponge", ++ "minecraft:spore_blossom", ++ "minecraft:spruce_button", ++ "minecraft:spruce_door", ++ "minecraft:spruce_fence", ++ "minecraft:spruce_fence_gate", ++ "minecraft:spruce_leaves", ++ "minecraft:spruce_log", ++ "minecraft:spruce_planks", ++ "minecraft:spruce_pressure_plate", ++ "minecraft:spruce_shelf", ++ "minecraft:spruce_stairs", ++ "minecraft:spruce_standing_sign", ++ "minecraft:spruce_trapdoor", ++ "minecraft:spruce_wall_sign", ++ "minecraft:spruce_wood", ++ "minecraft:stained_glass", ++ "minecraft:stained_glass_pane", ++ "minecraft:stained_hardened_clay", ++ "minecraft:standing_banner", ++ "minecraft:standing_sign", ++ "minecraft:stick", ++ "minecraft:sticky_piston", ++ "minecraft:sticky_piston_arm_collision", ++ "minecraft:stone", ++ "minecraft:stone_block_slab", ++ "minecraft:stone_block_slab2", ++ "minecraft:stone_block_slab3", ++ "minecraft:stone_block_slab4", ++ "minecraft:stone_brick_stairs", ++ "minecraft:stone_brick_wall", ++ "minecraft:stone_bricks", ++ "minecraft:stone_button", ++ "minecraft:stone_pressure_plate", ++ "minecraft:stone_stairs", ++ "minecraft:stonebrick", ++ "minecraft:stonecutter", ++ "minecraft:stonecutter_block", ++ "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:structure_void", ++ "minecraft:sugar", ++ "minecraft:sulfur", ++ "minecraft:sulfur_brick_stairs", ++ "minecraft:sulfur_brick_wall", ++ "minecraft:sulfur_bricks", ++ "minecraft:sulfur_spike", ++ "minecraft:sulfur_stairs", ++ "minecraft:sulfur_wall", ++ "minecraft:sunflower", ++ "minecraft:suspicious_gravel", ++ "minecraft:suspicious_sand", ++ "minecraft:sweet_berries", ++ "minecraft:sweet_berry_bush", ++ "minecraft:tall_dry_grass", ++ "minecraft:tall_grass", ++ "minecraft:tallgrass", ++ "minecraft:target", ++ "minecraft:tide_armor_trim_smithing_template", ++ "minecraft:tinted_glass", ++ "minecraft:tnt", ++ "minecraft:torch", ++ "minecraft:torchflower", ++ "minecraft:torchflower_crop", ++ "minecraft:torchflower_seeds", ++ "minecraft:trapdoor", ++ "minecraft:trapped_chest", ++ "minecraft:trial_spawner", ++ "minecraft:trip_wire", ++ "minecraft:tripwire_hook", ++ "minecraft:tropical_fish", ++ "minecraft:tube_coral", ++ "minecraft:tube_coral_block", ++ "minecraft:tube_coral_wall_fan", ++ "minecraft:tuff", ++ "minecraft:tuff_brick_stairs", ++ "minecraft:tuff_brick_wall", ++ "minecraft:tuff_bricks", ++ "minecraft:tuff_stairs", ++ "minecraft:tuff_wall", ++ "minecraft:turtle_egg", ++ "minecraft:turtle_scute", ++ "minecraft:twisting_vines", ++ "minecraft:underwater_tnt", ++ "minecraft:underwater_torch", ++ "minecraft:unknown", ++ "minecraft:unlit_redstone_torch", ++ "minecraft:unpowered_comparator", ++ "minecraft:unpowered_repeater", ++ "minecraft:vault", ++ "minecraft:verdant_froglight", ++ "minecraft:vex_armor_trim_smithing_template", ++ "minecraft:vine", ++ "minecraft:wall_banner", ++ "minecraft:wall_sign", ++ "minecraft:ward_armor_trim_smithing_template", ++ "minecraft:warped_button", ++ "minecraft:warped_door", ++ "minecraft:warped_fence", ++ "minecraft:warped_fence_gate", ++ "minecraft:warped_fungus", ++ "minecraft:warped_hyphae", ++ "minecraft:warped_nylium", ++ "minecraft:warped_planks", ++ "minecraft:warped_pressure_plate", ++ "minecraft:warped_shelf", ++ "minecraft:warped_stairs", ++ "minecraft:warped_standing_sign", ++ "minecraft:warped_stem", ++ "minecraft:warped_trapdoor", ++ "minecraft:warped_wall_sign", ++ "minecraft:warped_wart_block", ++ "minecraft:water", ++ "minecraft:waterlily", ++ "minecraft:waxed_chiseled_copper", ++ "minecraft:waxed_copper", ++ "minecraft:waxed_copper_bars", ++ "minecraft:waxed_copper_bulb", ++ "minecraft:waxed_copper_chain", ++ "minecraft:waxed_copper_chest", ++ "minecraft:waxed_copper_door", ++ "minecraft:waxed_copper_golem_statue", ++ "minecraft:waxed_copper_grate", ++ "minecraft:waxed_copper_lantern", ++ "minecraft:waxed_copper_trapdoor", ++ "minecraft:waxed_cut_copper", ++ "minecraft:waxed_cut_copper_stairs", ++ "minecraft:waxed_exposed_chiseled_copper", ++ "minecraft:waxed_exposed_copper", ++ "minecraft:waxed_exposed_copper_bars", ++ "minecraft:waxed_exposed_copper_bulb", ++ "minecraft:waxed_exposed_copper_chain", ++ "minecraft:waxed_exposed_copper_chest", ++ "minecraft:waxed_exposed_copper_door", ++ "minecraft:waxed_exposed_copper_golem_statue", ++ "minecraft:waxed_exposed_copper_grate", ++ "minecraft:waxed_exposed_copper_lantern", ++ "minecraft:waxed_exposed_copper_trapdoor", ++ "minecraft:waxed_exposed_cut_copper", ++ "minecraft:waxed_exposed_cut_copper_stairs", ++ "minecraft:waxed_exposed_lightning_rod", ++ "minecraft:waxed_lightning_rod", ++ "minecraft:waxed_oxidized_chiseled_copper", ++ "minecraft:waxed_oxidized_copper", ++ "minecraft:waxed_oxidized_copper_bars", ++ "minecraft:waxed_oxidized_copper_bulb", ++ "minecraft:waxed_oxidized_copper_chain", ++ "minecraft:waxed_oxidized_copper_chest", ++ "minecraft:waxed_oxidized_copper_door", ++ "minecraft:waxed_oxidized_copper_golem_statue", ++ "minecraft:waxed_oxidized_copper_grate", ++ "minecraft:waxed_oxidized_copper_lantern", ++ "minecraft:waxed_oxidized_copper_trapdoor", ++ "minecraft:waxed_oxidized_cut_copper", ++ "minecraft:waxed_oxidized_cut_copper_stairs", ++ "minecraft:waxed_oxidized_lightning_rod", ++ "minecraft:waxed_weathered_chiseled_copper", ++ "minecraft:waxed_weathered_copper", ++ "minecraft:waxed_weathered_copper_bars", ++ "minecraft:waxed_weathered_copper_bulb", ++ "minecraft:waxed_weathered_copper_chain", ++ "minecraft:waxed_weathered_copper_chest", ++ "minecraft:waxed_weathered_copper_door", ++ "minecraft:waxed_weathered_copper_golem_statue", ++ "minecraft:waxed_weathered_copper_grate", ++ "minecraft:waxed_weathered_copper_lantern", ++ "minecraft:waxed_weathered_copper_trapdoor", ++ "minecraft:waxed_weathered_cut_copper", ++ "minecraft:waxed_weathered_cut_copper_stairs", ++ "minecraft:waxed_weathered_lightning_rod", ++ "minecraft:wayfinder_armor_trim_smithing_template", ++ "minecraft:weathered_chiseled_copper", ++ "minecraft:weathered_copper", ++ "minecraft:weathered_copper_bars", ++ "minecraft:weathered_copper_bulb", ++ "minecraft:weathered_copper_chain", ++ "minecraft:weathered_copper_chest", ++ "minecraft:weathered_copper_door", ++ "minecraft:weathered_copper_golem_statue", ++ "minecraft:weathered_copper_grate", ++ "minecraft:weathered_copper_lantern", ++ "minecraft:weathered_copper_trapdoor", ++ "minecraft:weathered_cut_copper", ++ "minecraft:weathered_cut_copper_stairs", ++ "minecraft:weathered_lightning_rod", ++ "minecraft:web", ++ "minecraft:weeping_vines", ++ "minecraft:wet_sponge", ++ "minecraft:wheat", ++ "minecraft:wheat_seeds", ++ "minecraft:white_candle", ++ "minecraft:white_candle_cake", ++ "minecraft:white_carpet", ++ "minecraft:white_concrete", ++ "minecraft:white_concrete_powder", ++ "minecraft:white_glazed_terracotta", ++ "minecraft:white_stained_glass", ++ "minecraft:white_stained_glass_pane", ++ "minecraft:white_terracotta", ++ "minecraft:white_tulip", ++ "minecraft:white_wool", ++ "minecraft:wild_armor_trim_smithing_template", ++ "minecraft:wildflowers", ++ "minecraft:wither_rose", ++ "minecraft:wood", ++ "minecraft:wooden_button", ++ "minecraft:wooden_door", ++ "minecraft:wooden_pressure_plate", ++ "minecraft:wooden_slab", ++ "minecraft:wool", ++ "minecraft:yellow_candle", ++ "minecraft:yellow_candle_cake", ++ "minecraft:yellow_carpet", ++ "minecraft:yellow_concrete", ++ "minecraft:yellow_concrete_powder", ++ "minecraft:yellow_glazed_terracotta", ++ "minecraft:yellow_poplar_leaves", ++ "minecraft:yellow_stained_glass", ++ "minecraft:yellow_stained_glass_pane", ++ "minecraft:yellow_terracotta", ++ "minecraft:yellow_wool" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 64, ++ "stackedByData": true, ++ "maximumDamage": 0, ++ "damageable": false, ++ "identifiers": [ ++ "minecraft:acacia_double_slab", ++ "minecraft:acacia_sapling", ++ "minecraft:acacia_slab", ++ "minecraft:agent_spawn_egg", ++ "minecraft:allay_spawn_egg", ++ "minecraft:andesite_double_slab", ++ "minecraft:andesite_slab", ++ "minecraft:armadillo_spawn_egg", ++ "minecraft:axolotl_spawn_egg", ++ "minecraft:azalea_leaves", ++ "minecraft:azalea_leaves_flowered", ++ "minecraft:balloon", ++ "minecraft:bamboo_double_slab", ++ "minecraft:bamboo_mosaic_double_slab", ++ "minecraft:bamboo_mosaic_slab", ++ "minecraft:bamboo_slab", ++ "minecraft:bat_spawn_egg", ++ "minecraft:bee_spawn_egg", ++ "minecraft:birch_double_slab", ++ "minecraft:birch_sapling", ++ "minecraft:birch_slab", ++ "minecraft:black_dye", ++ "minecraft:blackstone_double_slab", ++ "minecraft:blackstone_slab", ++ "minecraft:blaze_spawn_egg", ++ "minecraft:blue_dye", ++ "minecraft:bogged_spawn_egg", ++ "minecraft:bone_meal", ++ "minecraft:brain_coral_fan", ++ "minecraft:breeze_spawn_egg", ++ "minecraft:brick_double_slab", ++ "minecraft:brick_slab", ++ "minecraft:brown_dye", ++ "minecraft:brown_mushroom_block", ++ "minecraft:bubble_coral_fan", ++ "minecraft:camel_husk_spawn_egg", ++ "minecraft:camel_spawn_egg", ++ "minecraft:cat_spawn_egg", ++ "minecraft:cave_spider_spawn_egg", ++ "minecraft:cherry_double_slab", ++ "minecraft:cherry_slab", ++ "minecraft:chicken_spawn_egg", ++ "minecraft:cinnabar_brick_double_slab", ++ "minecraft:cinnabar_brick_slab", ++ "minecraft:cinnabar_double_slab", ++ "minecraft:cinnabar_slab", ++ "minecraft:cobbled_deepslate_double_slab", ++ "minecraft:cobbled_deepslate_slab", ++ "minecraft:cobblestone_double_slab", ++ "minecraft:cobblestone_slab", ++ "minecraft:cocoa_beans", ++ "minecraft:cod_spawn_egg", ++ "minecraft:compound", ++ "minecraft:copper_block", ++ "minecraft:copper_golem_spawn_egg", ++ "minecraft:cow_spawn_egg", ++ "minecraft:creaking_spawn_egg", ++ "minecraft:creeper_head", ++ "minecraft:creeper_spawn_egg", ++ "minecraft:crimson_double_slab", ++ "minecraft:crimson_roots", ++ "minecraft:crimson_slab", ++ "minecraft:cut_copper", ++ "minecraft:cut_copper_slab", ++ "minecraft:cut_copper_stairs", ++ "minecraft:cut_red_sandstone_double_slab", ++ "minecraft:cut_red_sandstone_slab", ++ "minecraft:cut_sandstone_double_slab", ++ "minecraft:cut_sandstone_slab", ++ "minecraft:cyan_dye", ++ "minecraft:dandelion", ++ "minecraft:dark_oak_double_slab", ++ "minecraft:dark_oak_sapling", ++ "minecraft:dark_oak_slab", ++ "minecraft:dark_prismarine_double_slab", ++ "minecraft:dark_prismarine_slab", ++ "minecraft:dead_brain_coral_fan", ++ "minecraft:dead_bubble_coral_fan", ++ "minecraft:dead_fire_coral_fan", ++ "minecraft:dead_horn_coral_fan", ++ "minecraft:dead_tube_coral_fan", ++ "minecraft:deepslate_brick_double_slab", ++ "minecraft:deepslate_brick_slab", ++ "minecraft:deepslate_tile_double_slab", ++ "minecraft:deepslate_tile_slab", ++ "minecraft:diorite_double_slab", ++ "minecraft:diorite_slab", ++ "minecraft:dolphin_spawn_egg", ++ "minecraft:donkey_spawn_egg", ++ "minecraft:double_cut_copper_slab", ++ "minecraft:dragon_head", ++ "minecraft:drowned_spawn_egg", ++ "minecraft:elder_guardian_spawn_egg", ++ "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:empty_map", ++ "minecraft:end_portal_frame", ++ "minecraft:end_stone_brick_double_slab", ++ "minecraft:end_stone_brick_slab", ++ "minecraft:ender_dragon_spawn_egg", ++ "minecraft:enderman_spawn_egg", ++ "minecraft:endermite_spawn_egg", ++ "minecraft:evoker_spawn_egg", ++ "minecraft:exposed_cut_copper_slab", ++ "minecraft:exposed_double_cut_copper_slab", ++ "minecraft:farmland", ++ "minecraft:filled_map", ++ "minecraft:fire_coral_fan", ++ "minecraft:fox_spawn_egg", ++ "minecraft:frog_spawn_egg", ++ "minecraft:ghast_spawn_egg", ++ "minecraft:glow_squid_spawn_egg", ++ "minecraft:goat_spawn_egg", ++ "minecraft:granite_double_slab", ++ "minecraft:granite_slab", ++ "minecraft:gray_dye", ++ "minecraft:green_dye", ++ "minecraft:guardian_spawn_egg", ++ "minecraft:happy_ghast_spawn_egg", ++ "minecraft:hoglin_spawn_egg", ++ "minecraft:horn_coral_fan", ++ "minecraft:horse_spawn_egg", ++ "minecraft:husk_spawn_egg", ++ "minecraft:ink_sac", ++ "minecraft:iron_golem_spawn_egg", ++ "minecraft:jungle_double_slab", ++ "minecraft:jungle_sapling", ++ "minecraft:jungle_slab", ++ "minecraft:lapis_lazuli", ++ "minecraft:light_blue_dye", ++ "minecraft:light_gray_dye", ++ "minecraft:lime_dye", ++ "minecraft:llama_spawn_egg", ++ "minecraft:magenta_dye", ++ "minecraft:magma_cube_spawn_egg", ++ "minecraft:mangrove_double_slab", ++ "minecraft:mangrove_leaves", ++ "minecraft:mangrove_slab", ++ "minecraft:mooshroom_spawn_egg", ++ "minecraft:mossy_cobblestone_double_slab", ++ "minecraft:mossy_cobblestone_slab", ++ "minecraft:mossy_stone_brick_double_slab", ++ "minecraft:mossy_stone_brick_slab", ++ "minecraft:mud_brick_double_slab", ++ "minecraft:mud_brick_slab", ++ "minecraft:mule_spawn_egg", ++ "minecraft:nautilus_spawn_egg", ++ "minecraft:nether_brick_double_slab", ++ "minecraft:nether_brick_slab", ++ "minecraft:normal_stone_double_slab", ++ "minecraft:normal_stone_slab", ++ "minecraft:npc_spawn_egg", ++ "minecraft:oak_double_slab", ++ "minecraft:oak_sapling", ++ "minecraft:oak_slab", ++ "minecraft:ocelot_spawn_egg", ++ "minecraft:ominous_bottle", ++ "minecraft:orange_dye", ++ "minecraft:oxidized_cut_copper_slab", ++ "minecraft:oxidized_double_cut_copper_slab", ++ "minecraft:pale_oak_double_slab", ++ "minecraft:pale_oak_slab", ++ "minecraft:panda_spawn_egg", ++ "minecraft:parched_spawn_egg", ++ "minecraft:parrot_spawn_egg", ++ "minecraft:petrified_oak_double_slab", ++ "minecraft:petrified_oak_slab", ++ "minecraft:phantom_spawn_egg", ++ "minecraft:pig_spawn_egg", ++ "minecraft:piglin_brute_spawn_egg", ++ "minecraft:piglin_head", ++ "minecraft:piglin_spawn_egg", ++ "minecraft:pillager_spawn_egg", ++ "minecraft:pink_dye", ++ "minecraft:player_head", ++ "minecraft:polar_bear_spawn_egg", ++ "minecraft:polished_andesite_double_slab", ++ "minecraft:polished_andesite_slab", ++ "minecraft:polished_blackstone_brick_double_slab", ++ "minecraft:polished_blackstone_brick_slab", ++ "minecraft:polished_blackstone_double_slab", ++ "minecraft:polished_blackstone_slab", ++ "minecraft:polished_cinnabar_double_slab", ++ "minecraft:polished_cinnabar_slab", ++ "minecraft:polished_deepslate_double_slab", ++ "minecraft:polished_deepslate_slab", ++ "minecraft:polished_diorite_double_slab", ++ "minecraft:polished_diorite_slab", ++ "minecraft:polished_granite_double_slab", ++ "minecraft:polished_granite_slab", ++ "minecraft:polished_sulfur_double_slab", ++ "minecraft:polished_sulfur_slab", ++ "minecraft:polished_tuff_double_slab", ++ "minecraft:polished_tuff_slab", ++ "minecraft:poplar_double_slab", ++ "minecraft:poplar_slab", ++ "minecraft:prismarine_brick_double_slab", ++ "minecraft:prismarine_brick_slab", ++ "minecraft:prismarine_double_slab", ++ "minecraft:prismarine_slab", ++ "minecraft:pufferfish_spawn_egg", ++ "minecraft:purple_dye", ++ "minecraft:purpur_double_slab", ++ "minecraft:purpur_slab", ++ "minecraft:quartz_double_slab", ++ "minecraft:quartz_slab", ++ "minecraft:rabbit_spawn_egg", ++ "minecraft:ravager_spawn_egg", ++ "minecraft:red_dye", ++ "minecraft:red_mushroom_block", ++ "minecraft:red_nether_brick_double_slab", ++ "minecraft:red_nether_brick_slab", ++ "minecraft:red_sandstone_double_slab", ++ "minecraft:red_sandstone_slab", ++ "minecraft:resin_brick_double_slab", ++ "minecraft:resin_brick_slab", ++ "minecraft:respawn_anchor", ++ "minecraft:salmon_spawn_egg", ++ "minecraft:sandstone_double_slab", ++ "minecraft:sandstone_slab", ++ "minecraft:seagrass", ++ "minecraft:sheep_spawn_egg", ++ "minecraft:shulker_spawn_egg", ++ "minecraft:silverfish_spawn_egg", ++ "minecraft:skeleton_horse_spawn_egg", ++ "minecraft:skeleton_skull", ++ "minecraft:skeleton_spawn_egg", ++ "minecraft:slime_spawn_egg", ++ "minecraft:smooth_quartz_double_slab", ++ "minecraft:smooth_quartz_slab", ++ "minecraft:smooth_red_sandstone_double_slab", ++ "minecraft:smooth_red_sandstone_slab", ++ "minecraft:smooth_sandstone_double_slab", ++ "minecraft:smooth_sandstone_slab", ++ "minecraft:smooth_stone_double_slab", ++ "minecraft:smooth_stone_slab", ++ "minecraft:sniffer_spawn_egg", ++ "minecraft:snow_golem_spawn_egg", ++ "minecraft:snow_layer", ++ "minecraft:spider_spawn_egg", ++ "minecraft:spruce_double_slab", ++ "minecraft:spruce_sapling", ++ "minecraft:spruce_slab", ++ "minecraft:squid_spawn_egg", ++ "minecraft:stone_brick_double_slab", ++ "minecraft:stone_brick_slab", ++ "minecraft:stray_spawn_egg", ++ "minecraft:strider_spawn_egg", ++ "minecraft:sulfur_brick_double_slab", ++ "minecraft:sulfur_brick_slab", ++ "minecraft:sulfur_cube_spawn_egg", ++ "minecraft:sulfur_double_slab", ++ "minecraft:sulfur_slab", ++ "minecraft:tadpole_spawn_egg", ++ "minecraft:trader_llama_spawn_egg", ++ "minecraft:tropical_fish_spawn_egg", ++ "minecraft:tube_coral_fan", ++ "minecraft:tuff_brick_double_slab", ++ "minecraft:tuff_brick_slab", ++ "minecraft:tuff_double_slab", ++ "minecraft:tuff_slab", ++ "minecraft:turtle_spawn_egg", ++ "minecraft:vex_spawn_egg", ++ "minecraft:villager_spawn_egg", ++ "minecraft:vindicator_spawn_egg", ++ "minecraft:wandering_trader_spawn_egg", ++ "minecraft:warden_spawn_egg", ++ "minecraft:warped_double_slab", ++ "minecraft:warped_roots", ++ "minecraft:warped_slab", ++ "minecraft:waxed_cut_copper_slab", ++ "minecraft:waxed_double_cut_copper_slab", ++ "minecraft:waxed_exposed_cut_copper_slab", ++ "minecraft:waxed_exposed_double_cut_copper_slab", ++ "minecraft:waxed_oxidized_cut_copper_slab", ++ "minecraft:waxed_oxidized_double_cut_copper_slab", ++ "minecraft:waxed_weathered_cut_copper_slab", ++ "minecraft:waxed_weathered_double_cut_copper_slab", ++ "minecraft:weathered_cut_copper_slab", ++ "minecraft:weathered_double_cut_copper_slab", ++ "minecraft:white_dye", ++ "minecraft:witch_spawn_egg", ++ "minecraft:wither_skeleton_skull", ++ "minecraft:wither_skeleton_spawn_egg", ++ "minecraft:wither_spawn_egg", ++ "minecraft:wolf_spawn_egg", ++ "minecraft:yellow_dye", ++ "minecraft:zoglin_spawn_egg", ++ "minecraft:zombie_head", ++ "minecraft:zombie_horse_spawn_egg", ++ "minecraft:zombie_nautilus_spawn_egg", ++ "minecraft:zombie_pigman_spawn_egg", ++ "minecraft:zombie_spawn_egg", ++ "minecraft:zombie_villager_spawn_egg" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 1, ++ "stackedByData": true, ++ "maximumDamage": 0, ++ "damageable": false, ++ "fixedMaximum": 1, ++ "identifiers": [ ++ "minecraft:axolotl_bucket", ++ "minecraft:cod_bucket", ++ "minecraft:lava_bucket", ++ "minecraft:milk_bucket", ++ "minecraft:powder_snow_bucket", ++ "minecraft:pufferfish_bucket", ++ "minecraft:salmon_bucket", ++ "minecraft:sulfur_cube_bucket", ++ "minecraft:tadpole_bucket", ++ "minecraft:tropical_fish_bucket", ++ "minecraft:water_bucket" ++ ] ++ }, ++ { ++ "version": 0, ++ "maximum": 16, ++ "stackedByData": true, ++ "maximumDamage": 0, ++ "damageable": false, ++ "fixedMaximum": 16, ++ "identifiers": [ ++ "minecraft:bucket" ++ ] ++ }, ++ { ++ "version": 1, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 0, ++ "damageable": false, ++ "identifiers": [ ++ "minecraft:music_disc_11", ++ "minecraft:music_disc_13", ++ "minecraft:music_disc_5", ++ "minecraft:music_disc_blocks", ++ "minecraft:music_disc_bounce", ++ "minecraft:music_disc_cat", ++ "minecraft:music_disc_chirp", ++ "minecraft:music_disc_creator", ++ "minecraft:music_disc_creator_music_box", ++ "minecraft:music_disc_far", ++ "minecraft:music_disc_lava_chicken", ++ "minecraft:music_disc_mall", ++ "minecraft:music_disc_mellohi", ++ "minecraft:music_disc_otherside", ++ "minecraft:music_disc_pigstep", ++ "minecraft:music_disc_precipice", ++ "minecraft:music_disc_relic", ++ "minecraft:music_disc_stal", ++ "minecraft:music_disc_strad", ++ "minecraft:music_disc_tears", ++ "minecraft:music_disc_wait", ++ "minecraft:music_disc_ward" ++ ] ++ }, ++ { ++ "version": 1, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 384, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:fishing_rod" ++ ] ++ }, ++ { ++ "version": 1, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 100, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:warped_fungus_on_a_stick" ++ ] ++ }, ++ { ++ "version": 1, ++ "maximum": 1, ++ "stackedByData": false, ++ "maximumDamage": 64, ++ "damageable": true, ++ "identifiers": [ ++ "minecraft:wolf_armor" ++ ] ++ }, ++ { ++ "version": 1, ++ "maximum": 16, ++ "stackedByData": false, ++ "maximumDamage": 0, ++ "damageable": false, ++ "identifiers": [ ++ "minecraft:snowball" ++ ] ++ }, ++ { ++ "version": 1, ++ "maximum": 64, ++ "stackedByData": false, ++ "maximumDamage": 0, ++ "damageable": false, ++ "identifiers": [ ++ "minecraft:brewing_stand", ++ "minecraft:cake", ++ "minecraft:campfire", ++ "minecraft:comparator", ++ "minecraft:flower_pot", ++ "minecraft:frame", ++ "minecraft:glow_frame", ++ "minecraft:hopper", ++ "minecraft:kelp", ++ "minecraft:nether_sprouts", ++ "minecraft:repeater", ++ "minecraft:soul_campfire", ++ "minecraft:string", ++ "minecraft:sugar_cane" ++ ] ++ }, ++ { ++ "version": 1, ++ "maximum": 64, ++ "stackedByData": true, ++ "maximumDamage": 0, ++ "damageable": false, ++ "identifiers": [ ++ "minecraft:arrow", ++ "minecraft:cauldron", ++ "minecraft:charcoal", ++ "minecraft:coal" ++ ] ++ } ++ ], ++ "cooking": { ++ "food": [], ++ "blocks": [ ++ "minecraft:acacia_button", ++ "minecraft:acacia_double_slab", ++ "minecraft:acacia_fence", ++ "minecraft:acacia_fence_gate", ++ "minecraft:acacia_leaves", ++ "minecraft:acacia_log", ++ "minecraft:acacia_planks", ++ "minecraft:acacia_pressure_plate", ++ "minecraft:acacia_sapling", ++ "minecraft:acacia_shelf", ++ "minecraft:acacia_slab", ++ "minecraft:acacia_stairs", ++ "minecraft:acacia_standing_sign", ++ "minecraft:acacia_trapdoor", ++ "minecraft:acacia_wall_sign", ++ "minecraft:acacia_wood", ++ "minecraft:activator_rail", ++ "minecraft:air", ++ "minecraft:allium", ++ "minecraft:allow", ++ "minecraft:amethyst_block", ++ "minecraft:amethyst_cluster", ++ "minecraft:ancient_debris", ++ "minecraft:andesite", ++ "minecraft:andesite_double_slab", ++ "minecraft:andesite_slab", ++ "minecraft:andesite_stairs", ++ "minecraft:andesite_wall", ++ "minecraft:anvil", ++ "minecraft:azalea", ++ "minecraft:azalea_leaves", ++ "minecraft:azalea_leaves_flowered", ++ "minecraft:azure_bluet", ++ "minecraft:bamboo", ++ "minecraft:bamboo_block", ++ "minecraft:bamboo_button", ++ "minecraft:bamboo_double_slab", ++ "minecraft:bamboo_fence", ++ "minecraft:bamboo_fence_gate", ++ "minecraft:bamboo_mosaic", ++ "minecraft:bamboo_mosaic_double_slab", ++ "minecraft:bamboo_mosaic_slab", ++ "minecraft:bamboo_mosaic_stairs", ++ "minecraft:bamboo_planks", ++ "minecraft:bamboo_pressure_plate", ++ "minecraft:bamboo_sapling", ++ "minecraft:bamboo_shelf", ++ "minecraft:bamboo_slab", ++ "minecraft:bamboo_stairs", ++ "minecraft:bamboo_standing_sign", ++ "minecraft:bamboo_trapdoor", ++ "minecraft:bamboo_wall_sign", ++ "minecraft:barrel", ++ "minecraft:barrier", ++ "minecraft:basalt", ++ "minecraft:beacon", ++ "minecraft:bedrock", ++ "minecraft:bee_nest", ++ "minecraft:beehive", ++ "minecraft:bell", ++ "minecraft:big_dripleaf", ++ "minecraft:birch_button", ++ "minecraft:birch_double_slab", ++ "minecraft:birch_fence", ++ "minecraft:birch_fence_gate", ++ "minecraft:birch_leaves", ++ "minecraft:birch_log", ++ "minecraft:birch_planks", ++ "minecraft:birch_pressure_plate", ++ "minecraft:birch_sapling", ++ "minecraft:birch_shelf", ++ "minecraft:birch_slab", ++ "minecraft:birch_stairs", ++ "minecraft:birch_standing_sign", ++ "minecraft:birch_trapdoor", ++ "minecraft:birch_wall_sign", ++ "minecraft:birch_wood", ++ "minecraft:black_candle", ++ "minecraft:black_candle_cake", ++ "minecraft:black_carpet", ++ "minecraft:black_concrete", ++ "minecraft:black_concrete_powder", ++ "minecraft:black_glazed_terracotta", ++ "minecraft:black_shulker_box", ++ "minecraft:black_stained_glass", ++ "minecraft:black_stained_glass_pane", ++ "minecraft:black_terracotta", ++ "minecraft:black_wool", ++ "minecraft:blackstone", ++ "minecraft:blackstone_double_slab", ++ "minecraft:blackstone_slab", ++ "minecraft:blackstone_stairs", ++ "minecraft:blackstone_wall", ++ "minecraft:blast_furnace", ++ "minecraft:blue_candle", ++ "minecraft:blue_candle_cake", ++ "minecraft:blue_carpet", ++ "minecraft:blue_concrete", ++ "minecraft:blue_concrete_powder", ++ "minecraft:blue_glazed_terracotta", ++ "minecraft:blue_ice", ++ "minecraft:blue_orchid", ++ "minecraft:blue_shulker_box", ++ "minecraft:blue_stained_glass", ++ "minecraft:blue_stained_glass_pane", ++ "minecraft:blue_terracotta", ++ "minecraft:blue_wool", ++ "minecraft:bone_block", ++ "minecraft:bookshelf", ++ "minecraft:border_block", ++ "minecraft:brain_coral", ++ "minecraft:brain_coral_block", ++ "minecraft:brain_coral_fan", ++ "minecraft:brain_coral_wall_fan", ++ "minecraft:brick_block", ++ "minecraft:brick_double_slab", ++ "minecraft:brick_slab", ++ "minecraft:brick_stairs", ++ "minecraft:brick_wall", ++ "minecraft:brown_candle", ++ "minecraft:brown_candle_cake", ++ "minecraft:brown_carpet", ++ "minecraft:brown_concrete", ++ "minecraft:brown_concrete_powder", ++ "minecraft:brown_glazed_terracotta", ++ "minecraft:brown_mushroom", ++ "minecraft:brown_mushroom_block", ++ "minecraft:brown_shulker_box", ++ "minecraft:brown_stained_glass", ++ "minecraft:brown_stained_glass_pane", ++ "minecraft:brown_terracotta", ++ "minecraft:brown_wool", ++ "minecraft:bubble_column", ++ "minecraft:bubble_coral", ++ "minecraft:bubble_coral_block", ++ "minecraft:bubble_coral_fan", ++ "minecraft:bubble_coral_wall_fan", ++ "minecraft:budding_amethyst", ++ "minecraft:bush", ++ "minecraft:cactus", ++ "minecraft:cactus_flower", ++ "minecraft:calcite", ++ "minecraft:calibrated_sculk_sensor", ++ "minecraft:candle", ++ "minecraft:candle_cake", ++ "minecraft:carrots", ++ "minecraft:cartography_table", ++ "minecraft:carved_pumpkin", ++ "minecraft:cave_vines", ++ "minecraft:cave_vines_body_with_berries", ++ "minecraft:cave_vines_head_with_berries", ++ "minecraft:chain_command_block", ++ "minecraft:chalkboard", ++ "minecraft:chemical_heat", ++ "minecraft:cherry_button", ++ "minecraft:cherry_double_slab", ++ "minecraft:cherry_fence", ++ "minecraft:cherry_fence_gate", ++ "minecraft:cherry_leaves", ++ "minecraft:cherry_log", ++ "minecraft:cherry_planks", ++ "minecraft:cherry_pressure_plate", ++ "minecraft:cherry_sapling", ++ "minecraft:cherry_shelf", ++ "minecraft:cherry_slab", ++ "minecraft:cherry_stairs", ++ "minecraft:cherry_standing_sign", ++ "minecraft:cherry_trapdoor", ++ "minecraft:cherry_wall_sign", ++ "minecraft:cherry_wood", ++ "minecraft:chest", ++ "minecraft:chipped_anvil", ++ "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:chorus_plant", ++ "minecraft:cinnabar", ++ "minecraft:cinnabar_brick_double_slab", ++ "minecraft:cinnabar_brick_slab", ++ "minecraft:cinnabar_brick_stairs", ++ "minecraft:cinnabar_brick_wall", ++ "minecraft:cinnabar_bricks", ++ "minecraft:cinnabar_double_slab", ++ "minecraft:cinnabar_slab", ++ "minecraft:cinnabar_stairs", ++ "minecraft:cinnabar_wall", ++ "minecraft:clay", ++ "minecraft:client_request_placeholder_block", ++ "minecraft:closed_eyeblossom", ++ "minecraft:coal_block", ++ "minecraft:coal_ore", ++ "minecraft:coarse_dirt", ++ "minecraft:cobbled_deepslate", ++ "minecraft:cobbled_deepslate_double_slab", ++ "minecraft:cobbled_deepslate_slab", ++ "minecraft:cobbled_deepslate_stairs", ++ "minecraft:cobbled_deepslate_wall", ++ "minecraft:cobblestone", ++ "minecraft:cobblestone_double_slab", ++ "minecraft:cobblestone_slab", ++ "minecraft:cobblestone_wall", ++ "minecraft:cocoa", ++ "minecraft:colored_torch_blue", ++ "minecraft:colored_torch_green", ++ "minecraft:colored_torch_purple", ++ "minecraft:colored_torch_red", ++ "minecraft:command_block", ++ "minecraft:composter", ++ "minecraft:compound_creator", ++ "minecraft:conduit", ++ "minecraft:copper_bars", ++ "minecraft:copper_block", ++ "minecraft:copper_bulb", ++ "minecraft:copper_chain", ++ "minecraft:copper_chest", ++ "minecraft:copper_golem_statue", ++ "minecraft:copper_grate", ++ "minecraft:copper_lantern", ++ "minecraft:copper_ore", ++ "minecraft:copper_torch", ++ "minecraft:copper_trapdoor", ++ "minecraft:cornflower", ++ "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:creeper_head", ++ "minecraft:crimson_button", ++ "minecraft:crimson_double_slab", ++ "minecraft:crimson_fence", ++ "minecraft:crimson_fence_gate", ++ "minecraft:crimson_fungus", ++ "minecraft:crimson_hyphae", ++ "minecraft:crimson_nylium", ++ "minecraft:crimson_planks", ++ "minecraft:crimson_pressure_plate", ++ "minecraft:crimson_roots", ++ "minecraft:crimson_shelf", ++ "minecraft:crimson_slab", ++ "minecraft:crimson_stairs", ++ "minecraft:crimson_standing_sign", ++ "minecraft:crimson_stem", ++ "minecraft:crimson_trapdoor", ++ "minecraft:crimson_wall_sign", ++ "minecraft:crying_obsidian", ++ "minecraft:cut_copper", ++ "minecraft:cut_copper_slab", ++ "minecraft:cut_copper_stairs", ++ "minecraft:cut_red_sandstone", ++ "minecraft:cut_red_sandstone_double_slab", ++ "minecraft:cut_red_sandstone_slab", ++ "minecraft:cut_sandstone", ++ "minecraft:cut_sandstone_double_slab", ++ "minecraft:cut_sandstone_slab", ++ "minecraft:cyan_candle", ++ "minecraft:cyan_candle_cake", ++ "minecraft:cyan_carpet", ++ "minecraft:cyan_concrete", ++ "minecraft:cyan_concrete_powder", ++ "minecraft:cyan_glazed_terracotta", ++ "minecraft:cyan_shulker_box", ++ "minecraft:cyan_stained_glass", ++ "minecraft:cyan_stained_glass_pane", ++ "minecraft:cyan_terracotta", ++ "minecraft:cyan_wool", ++ "minecraft:damaged_anvil", ++ "minecraft:dandelion", ++ "minecraft:dark_oak_button", ++ "minecraft:dark_oak_double_slab", ++ "minecraft:dark_oak_fence", ++ "minecraft:dark_oak_fence_gate", ++ "minecraft:dark_oak_leaves", ++ "minecraft:dark_oak_log", ++ "minecraft:dark_oak_planks", ++ "minecraft:dark_oak_pressure_plate", ++ "minecraft:dark_oak_sapling", ++ "minecraft:dark_oak_shelf", ++ "minecraft:dark_oak_slab", ++ "minecraft:dark_oak_stairs", ++ "minecraft:dark_oak_trapdoor", ++ "minecraft:dark_oak_wood", ++ "minecraft:dark_prismarine", ++ "minecraft:dark_prismarine_double_slab", ++ "minecraft:dark_prismarine_slab", ++ "minecraft:dark_prismarine_stairs", ++ "minecraft:darkoak_standing_sign", ++ "minecraft:darkoak_wall_sign", ++ "minecraft:daylight_detector", ++ "minecraft:daylight_detector_inverted", ++ "minecraft:dead_brain_coral", ++ "minecraft:dead_brain_coral_block", ++ "minecraft:dead_brain_coral_fan", ++ "minecraft:dead_brain_coral_wall_fan", ++ "minecraft:dead_bubble_coral", ++ "minecraft:dead_bubble_coral_block", ++ "minecraft:dead_bubble_coral_fan", ++ "minecraft:dead_bubble_coral_wall_fan", ++ "minecraft:dead_fire_coral", ++ "minecraft:dead_fire_coral_block", ++ "minecraft:dead_fire_coral_fan", ++ "minecraft:dead_fire_coral_wall_fan", ++ "minecraft:dead_horn_coral", ++ "minecraft:dead_horn_coral_block", ++ "minecraft:dead_horn_coral_fan", ++ "minecraft:dead_horn_coral_wall_fan", ++ "minecraft:dead_tube_coral", ++ "minecraft:dead_tube_coral_block", ++ "minecraft:dead_tube_coral_fan", ++ "minecraft:dead_tube_coral_wall_fan", ++ "minecraft:deadbush", ++ "minecraft:decorated_pot", ++ "minecraft:deepslate", ++ "minecraft:deepslate_brick_double_slab", ++ "minecraft:deepslate_brick_slab", ++ "minecraft:deepslate_brick_stairs", ++ "minecraft:deepslate_brick_wall", ++ "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_double_slab", ++ "minecraft:deepslate_tile_slab", ++ "minecraft:deepslate_tile_stairs", ++ "minecraft:deepslate_tile_wall", ++ "minecraft:deepslate_tiles", ++ "minecraft:deny", ++ "minecraft:deprecated_anvil", ++ "minecraft:deprecated_purpur_block_1", ++ "minecraft:deprecated_purpur_block_2", ++ "minecraft:detector_rail", ++ "minecraft:diamond_block", ++ "minecraft:diamond_ore", ++ "minecraft:diorite", ++ "minecraft:diorite_double_slab", ++ "minecraft:diorite_slab", ++ "minecraft:diorite_stairs", ++ "minecraft:diorite_wall", ++ "minecraft:dirt", ++ "minecraft:dirt_with_roots", ++ "minecraft:dispenser", ++ "minecraft:double_cut_copper_slab", ++ "minecraft:dragon_egg", ++ "minecraft:dragon_head", ++ "minecraft:dried_ghast", ++ "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:enchanting_table", ++ "minecraft:end_brick_stairs", ++ "minecraft:end_bricks", ++ "minecraft:end_gateway", ++ "minecraft:end_portal", ++ "minecraft:end_portal_frame", ++ "minecraft:end_rod", ++ "minecraft:end_stone", ++ "minecraft:end_stone_brick_double_slab", ++ "minecraft:end_stone_brick_slab", ++ "minecraft:end_stone_brick_wall", ++ "minecraft:ender_chest", ++ "minecraft:exposed_chiseled_copper", ++ "minecraft:exposed_copper", ++ "minecraft:exposed_copper_bars", ++ "minecraft:exposed_copper_bulb", ++ "minecraft:exposed_copper_chain", ++ "minecraft:exposed_copper_chest", ++ "minecraft:exposed_copper_golem_statue", ++ "minecraft:exposed_copper_grate", ++ "minecraft:exposed_copper_lantern", ++ "minecraft:exposed_copper_trapdoor", ++ "minecraft:exposed_cut_copper", ++ "minecraft:exposed_cut_copper_slab", ++ "minecraft:exposed_cut_copper_stairs", ++ "minecraft:exposed_double_cut_copper_slab", ++ "minecraft:exposed_lightning_rod", ++ "minecraft:farmland", ++ "minecraft:fence_gate", ++ "minecraft:fern", ++ "minecraft:fire", ++ "minecraft:fire_coral", ++ "minecraft:fire_coral_block", ++ "minecraft:fire_coral_fan", ++ "minecraft:fire_coral_wall_fan", ++ "minecraft:firefly_bush", ++ "minecraft:fletching_table", ++ "minecraft:flowering_azalea", ++ "minecraft:flowing_lava", ++ "minecraft:flowing_water", ++ "minecraft:frog_spawn", ++ "minecraft:frosted_ice", ++ "minecraft:furnace", ++ "minecraft:gilded_blackstone", ++ "minecraft:glass", ++ "minecraft:glass_pane", ++ "minecraft:glow_lichen", ++ "minecraft:glowingobsidian", ++ "minecraft:glowstone", ++ "minecraft:gold_block", ++ "minecraft:gold_ore", ++ "minecraft:golden_dandelion", ++ "minecraft:golden_rail", ++ "minecraft:granite", ++ "minecraft:granite_double_slab", ++ "minecraft:granite_slab", ++ "minecraft:granite_stairs", ++ "minecraft:granite_wall", ++ "minecraft:grass_block", ++ "minecraft:grass_path", ++ "minecraft:gravel", ++ "minecraft:gray_candle", ++ "minecraft:gray_candle_cake", ++ "minecraft:gray_carpet", ++ "minecraft:gray_concrete", ++ "minecraft:gray_concrete_powder", ++ "minecraft:gray_glazed_terracotta", ++ "minecraft:gray_shulker_box", ++ "minecraft:gray_stained_glass", ++ "minecraft:gray_stained_glass_pane", ++ "minecraft:gray_terracotta", ++ "minecraft:gray_wool", ++ "minecraft:green_candle", ++ "minecraft:green_candle_cake", ++ "minecraft:green_carpet", ++ "minecraft:green_concrete", ++ "minecraft:green_concrete_powder", ++ "minecraft:green_glazed_terracotta", ++ "minecraft:green_shulker_box", ++ "minecraft:green_stained_glass", ++ "minecraft:green_stained_glass_pane", ++ "minecraft:green_terracotta", ++ "minecraft:green_wool", ++ "minecraft:grindstone", ++ "minecraft:hanging_roots", ++ "minecraft:hard_black_stained_glass", ++ "minecraft:hard_black_stained_glass_pane", ++ "minecraft:hard_blue_stained_glass", ++ "minecraft:hard_blue_stained_glass_pane", ++ "minecraft:hard_brown_stained_glass", ++ "minecraft:hard_brown_stained_glass_pane", ++ "minecraft:hard_cyan_stained_glass", ++ "minecraft:hard_cyan_stained_glass_pane", ++ "minecraft:hard_glass", ++ "minecraft:hard_glass_pane", ++ "minecraft:hard_gray_stained_glass", ++ "minecraft:hard_gray_stained_glass_pane", ++ "minecraft:hard_green_stained_glass", ++ "minecraft:hard_green_stained_glass_pane", ++ "minecraft:hard_light_blue_stained_glass", ++ "minecraft:hard_light_blue_stained_glass_pane", ++ "minecraft:hard_light_gray_stained_glass", ++ "minecraft:hard_light_gray_stained_glass_pane", ++ "minecraft:hard_lime_stained_glass", ++ "minecraft:hard_lime_stained_glass_pane", ++ "minecraft:hard_magenta_stained_glass", ++ "minecraft:hard_magenta_stained_glass_pane", ++ "minecraft:hard_orange_stained_glass", ++ "minecraft:hard_orange_stained_glass_pane", ++ "minecraft:hard_pink_stained_glass", ++ "minecraft:hard_pink_stained_glass_pane", ++ "minecraft:hard_purple_stained_glass", ++ "minecraft:hard_purple_stained_glass_pane", ++ "minecraft:hard_red_stained_glass", ++ "minecraft:hard_red_stained_glass_pane", ++ "minecraft:hard_white_stained_glass", ++ "minecraft:hard_white_stained_glass_pane", ++ "minecraft:hard_yellow_stained_glass", ++ "minecraft:hard_yellow_stained_glass_pane", ++ "minecraft:hardened_clay", ++ "minecraft:hay_block", ++ "minecraft:heavy_core", ++ "minecraft:heavy_weighted_pressure_plate", ++ "minecraft:honey_block", ++ "minecraft:honeycomb_block", ++ "minecraft:horn_coral", ++ "minecraft:horn_coral_block", ++ "minecraft:horn_coral_fan", ++ "minecraft:horn_coral_wall_fan", ++ "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_bars", ++ "minecraft:iron_block", ++ "minecraft:iron_chain", ++ "minecraft:iron_ore", ++ "minecraft:iron_trapdoor", ++ "minecraft:jigsaw", ++ "minecraft:jukebox", ++ "minecraft:jungle_button", ++ "minecraft:jungle_double_slab", ++ "minecraft:jungle_fence", ++ "minecraft:jungle_fence_gate", ++ "minecraft:jungle_leaves", ++ "minecraft:jungle_log", ++ "minecraft:jungle_planks", ++ "minecraft:jungle_pressure_plate", ++ "minecraft:jungle_sapling", ++ "minecraft:jungle_shelf", ++ "minecraft:jungle_slab", ++ "minecraft:jungle_stairs", ++ "minecraft:jungle_standing_sign", ++ "minecraft:jungle_trapdoor", ++ "minecraft:jungle_wall_sign", ++ "minecraft:jungle_wood", ++ "minecraft:lab_table", ++ "minecraft:ladder", ++ "minecraft:lantern", ++ "minecraft:lapis_block", ++ "minecraft:lapis_ore", ++ "minecraft:large_amethyst_bud", ++ "minecraft:large_fern", ++ "minecraft:lava", ++ "minecraft:leaf_litter", ++ "minecraft:lectern", ++ "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_blue_candle", ++ "minecraft:light_blue_candle_cake", ++ "minecraft:light_blue_carpet", ++ "minecraft:light_blue_concrete", ++ "minecraft:light_blue_concrete_powder", ++ "minecraft:light_blue_glazed_terracotta", ++ "minecraft:light_blue_shulker_box", ++ "minecraft:light_blue_stained_glass", ++ "minecraft:light_blue_stained_glass_pane", ++ "minecraft:light_blue_terracotta", ++ "minecraft:light_blue_wool", ++ "minecraft:light_gray_candle", ++ "minecraft:light_gray_candle_cake", ++ "minecraft:light_gray_carpet", ++ "minecraft:light_gray_concrete", ++ "minecraft:light_gray_concrete_powder", ++ "minecraft:light_gray_shulker_box", ++ "minecraft:light_gray_stained_glass", ++ "minecraft:light_gray_stained_glass_pane", ++ "minecraft:light_gray_terracotta", ++ "minecraft:light_gray_wool", ++ "minecraft:light_weighted_pressure_plate", ++ "minecraft:lightning_rod", ++ "minecraft:lilac", ++ "minecraft:lily_of_the_valley", ++ "minecraft:lime_candle", ++ "minecraft:lime_candle_cake", ++ "minecraft:lime_carpet", ++ "minecraft:lime_concrete", ++ "minecraft:lime_concrete_powder", ++ "minecraft:lime_glazed_terracotta", ++ "minecraft:lime_shulker_box", ++ "minecraft:lime_stained_glass", ++ "minecraft:lime_stained_glass_pane", ++ "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_candle", ++ "minecraft:magenta_candle_cake", ++ "minecraft:magenta_carpet", ++ "minecraft:magenta_concrete", ++ "minecraft:magenta_concrete_powder", ++ "minecraft:magenta_glazed_terracotta", ++ "minecraft:magenta_shulker_box", ++ "minecraft:magenta_stained_glass", ++ "minecraft:magenta_stained_glass_pane", ++ "minecraft:magenta_terracotta", ++ "minecraft:magenta_wool", ++ "minecraft:magma", ++ "minecraft:mangrove_button", ++ "minecraft:mangrove_double_slab", ++ "minecraft:mangrove_fence", ++ "minecraft:mangrove_fence_gate", ++ "minecraft:mangrove_leaves", ++ "minecraft:mangrove_log", ++ "minecraft:mangrove_planks", ++ "minecraft:mangrove_pressure_plate", ++ "minecraft:mangrove_propagule", ++ "minecraft:mangrove_roots", ++ "minecraft:mangrove_shelf", ++ "minecraft:mangrove_slab", ++ "minecraft:mangrove_stairs", ++ "minecraft:mangrove_standing_sign", ++ "minecraft:mangrove_trapdoor", ++ "minecraft:mangrove_wall_sign", ++ "minecraft:mangrove_wood", ++ "minecraft:material_reducer", ++ "minecraft:medium_amethyst_bud", ++ "minecraft:melon_block", ++ "minecraft:melon_stem", ++ "minecraft:mob_spawner", ++ "minecraft:moss_block", ++ "minecraft:moss_carpet", ++ "minecraft:mossy_cobblestone", ++ "minecraft:mossy_cobblestone_double_slab", ++ "minecraft:mossy_cobblestone_slab", ++ "minecraft:mossy_cobblestone_stairs", ++ "minecraft:mossy_cobblestone_wall", ++ "minecraft:mossy_stone_brick_double_slab", ++ "minecraft:mossy_stone_brick_slab", ++ "minecraft:mossy_stone_brick_stairs", ++ "minecraft:mossy_stone_brick_wall", ++ "minecraft:mossy_stone_bricks", ++ "minecraft:moving_block", ++ "minecraft:mud", ++ "minecraft:mud_brick_double_slab", ++ "minecraft:mud_brick_slab", ++ "minecraft:mud_brick_stairs", ++ "minecraft:mud_brick_wall", ++ "minecraft:mud_bricks", ++ "minecraft:muddy_mangrove_roots", ++ "minecraft:mushroom_stem", ++ "minecraft:mycelium", ++ "minecraft:nether_brick", ++ "minecraft:nether_brick_double_slab", ++ "minecraft:nether_brick_fence", ++ "minecraft:nether_brick_slab", ++ "minecraft:nether_brick_stairs", ++ "minecraft:nether_brick_wall", ++ "minecraft:nether_gold_ore", ++ "minecraft:nether_wart_block", ++ "minecraft:netherite_block", ++ "minecraft:netherrack", ++ "minecraft:netherreactor", ++ "minecraft:normal_stone_double_slab", ++ "minecraft:normal_stone_slab", ++ "minecraft:normal_stone_stairs", ++ "minecraft:noteblock", ++ "minecraft:oak_double_slab", ++ "minecraft:oak_fence", ++ "minecraft:oak_leaves", ++ "minecraft:oak_log", ++ "minecraft:oak_planks", ++ "minecraft:oak_sapling", ++ "minecraft:oak_shelf", ++ "minecraft:oak_slab", ++ "minecraft:oak_stairs", ++ "minecraft:oak_wood", ++ "minecraft:observer", ++ "minecraft:obsidian", ++ "minecraft:ochre_froglight", ++ "minecraft:open_eyeblossom", ++ "minecraft:orange_candle", ++ "minecraft:orange_candle_cake", ++ "minecraft:orange_carpet", ++ "minecraft:orange_concrete", ++ "minecraft:orange_concrete_powder", ++ "minecraft:orange_glazed_terracotta", ++ "minecraft:orange_poplar_leaves", ++ "minecraft:orange_shulker_box", ++ "minecraft:orange_stained_glass", ++ "minecraft:orange_stained_glass_pane", ++ "minecraft:orange_terracotta", ++ "minecraft:orange_tulip", ++ "minecraft:orange_wool", ++ "minecraft:oxeye_daisy", ++ "minecraft:oxidized_chiseled_copper", ++ "minecraft:oxidized_copper", ++ "minecraft:oxidized_copper_bars", ++ "minecraft:oxidized_copper_bulb", ++ "minecraft:oxidized_copper_chain", ++ "minecraft:oxidized_copper_chest", ++ "minecraft:oxidized_copper_golem_statue", ++ "minecraft:oxidized_copper_grate", ++ "minecraft:oxidized_copper_lantern", ++ "minecraft:oxidized_copper_trapdoor", ++ "minecraft:oxidized_cut_copper", ++ "minecraft:oxidized_cut_copper_slab", ++ "minecraft:oxidized_cut_copper_stairs", ++ "minecraft:oxidized_double_cut_copper_slab", ++ "minecraft:oxidized_lightning_rod", ++ "minecraft:packed_ice", ++ "minecraft:packed_mud", ++ "minecraft:pale_hanging_moss", ++ "minecraft:pale_moss_block", ++ "minecraft:pale_moss_carpet", ++ "minecraft:pale_oak_button", ++ "minecraft:pale_oak_double_slab", ++ "minecraft:pale_oak_fence", ++ "minecraft:pale_oak_fence_gate", ++ "minecraft:pale_oak_leaves", ++ "minecraft:pale_oak_log", ++ "minecraft:pale_oak_planks", ++ "minecraft:pale_oak_pressure_plate", ++ "minecraft:pale_oak_sapling", ++ "minecraft:pale_oak_shelf", ++ "minecraft:pale_oak_slab", ++ "minecraft:pale_oak_stairs", ++ "minecraft:pale_oak_standing_sign", ++ "minecraft:pale_oak_trapdoor", ++ "minecraft:pale_oak_wall_sign", ++ "minecraft:pale_oak_wood", ++ "minecraft:pearlescent_froglight", ++ "minecraft:peony", ++ "minecraft:petrified_oak_double_slab", ++ "minecraft:petrified_oak_slab", ++ "minecraft:piglin_head", ++ "minecraft:pink_candle", ++ "minecraft:pink_candle_cake", ++ "minecraft:pink_carpet", ++ "minecraft:pink_concrete", ++ "minecraft:pink_concrete_powder", ++ "minecraft:pink_glazed_terracotta", ++ "minecraft:pink_petals", ++ "minecraft:pink_shulker_box", ++ "minecraft:pink_stained_glass", ++ "minecraft:pink_stained_glass_pane", ++ "minecraft:pink_terracotta", ++ "minecraft:pink_tulip", ++ "minecraft:pink_wool", ++ "minecraft:piston", ++ "minecraft:piston_arm_collision", ++ "minecraft:pitcher_crop", ++ "minecraft:pitcher_plant", ++ "minecraft:player_head", ++ "minecraft:podzol", ++ "minecraft:pointed_dripstone", ++ "minecraft:polished_andesite", ++ "minecraft:polished_andesite_double_slab", ++ "minecraft:polished_andesite_slab", ++ "minecraft:polished_andesite_stairs", ++ "minecraft:polished_basalt", ++ "minecraft:polished_blackstone", ++ "minecraft:polished_blackstone_brick_double_slab", ++ "minecraft:polished_blackstone_brick_slab", ++ "minecraft:polished_blackstone_brick_stairs", ++ "minecraft:polished_blackstone_brick_wall", ++ "minecraft:polished_blackstone_bricks", ++ "minecraft:polished_blackstone_button", ++ "minecraft:polished_blackstone_double_slab", ++ "minecraft:polished_blackstone_pressure_plate", ++ "minecraft:polished_blackstone_slab", ++ "minecraft:polished_blackstone_stairs", ++ "minecraft:polished_blackstone_wall", ++ "minecraft:polished_cinnabar", ++ "minecraft:polished_cinnabar_double_slab", ++ "minecraft:polished_cinnabar_slab", ++ "minecraft:polished_cinnabar_stairs", ++ "minecraft:polished_cinnabar_wall", ++ "minecraft:polished_deepslate", ++ "minecraft:polished_deepslate_double_slab", ++ "minecraft:polished_deepslate_slab", ++ "minecraft:polished_deepslate_stairs", ++ "minecraft:polished_deepslate_wall", ++ "minecraft:polished_diorite", ++ "minecraft:polished_diorite_double_slab", ++ "minecraft:polished_diorite_slab", ++ "minecraft:polished_diorite_stairs", ++ "minecraft:polished_granite", ++ "minecraft:polished_granite_double_slab", ++ "minecraft:polished_granite_slab", ++ "minecraft:polished_granite_stairs", ++ "minecraft:polished_sulfur", ++ "minecraft:polished_sulfur_double_slab", ++ "minecraft:polished_sulfur_slab", ++ "minecraft:polished_sulfur_stairs", ++ "minecraft:polished_sulfur_wall", ++ "minecraft:polished_tuff", ++ "minecraft:polished_tuff_double_slab", ++ "minecraft:polished_tuff_slab", ++ "minecraft:polished_tuff_stairs", ++ "minecraft:polished_tuff_wall", ++ "minecraft:poplar_button", ++ "minecraft:poplar_double_slab", ++ "minecraft:poplar_fence", ++ "minecraft:poplar_fence_gate", ++ "minecraft:poplar_log", ++ "minecraft:poplar_planks", ++ "minecraft:poplar_pressure_plate", ++ "minecraft:poplar_sapling", ++ "minecraft:poplar_shelf", ++ "minecraft:poplar_slab", ++ "minecraft:poplar_stairs", ++ "minecraft:poplar_standing_sign", ++ "minecraft:poplar_trapdoor", ++ "minecraft:poplar_wall_sign", ++ "minecraft:poplar_wood", ++ "minecraft:poppy", ++ "minecraft:portal", ++ "minecraft:potatoes", ++ "minecraft:potent_sulfur", ++ "minecraft:powder_snow", ++ "minecraft:powered_comparator", ++ "minecraft:powered_repeater", ++ "minecraft:prismarine", ++ "minecraft:prismarine_brick_double_slab", ++ "minecraft:prismarine_brick_slab", ++ "minecraft:prismarine_bricks", ++ "minecraft:prismarine_bricks_stairs", ++ "minecraft:prismarine_double_slab", ++ "minecraft:prismarine_slab", ++ "minecraft:prismarine_stairs", ++ "minecraft:prismarine_wall", ++ "minecraft:pumpkin", ++ "minecraft:pumpkin_stem", ++ "minecraft:purple_candle", ++ "minecraft:purple_candle_cake", ++ "minecraft:purple_carpet", ++ "minecraft:purple_concrete", ++ "minecraft:purple_concrete_powder", ++ "minecraft:purple_glazed_terracotta", ++ "minecraft:purple_shulker_box", ++ "minecraft:purple_stained_glass", ++ "minecraft:purple_stained_glass_pane", ++ "minecraft:purple_terracotta", ++ "minecraft:purple_wool", ++ "minecraft:purpur_block", ++ "minecraft:purpur_double_slab", ++ "minecraft:purpur_pillar", ++ "minecraft:purpur_slab", ++ "minecraft:purpur_stairs", ++ "minecraft:quartz_block", ++ "minecraft:quartz_bricks", ++ "minecraft:quartz_double_slab", ++ "minecraft:quartz_ore", ++ "minecraft:quartz_pillar", ++ "minecraft:quartz_slab", ++ "minecraft:quartz_stairs", ++ "minecraft:rail", ++ "minecraft:raw_copper_block", ++ "minecraft:raw_gold_block", ++ "minecraft:raw_iron_block", ++ "minecraft:red_candle", ++ "minecraft:red_candle_cake", ++ "minecraft:red_carpet", ++ "minecraft:red_concrete", ++ "minecraft:red_concrete_powder", ++ "minecraft:red_glazed_terracotta", ++ "minecraft:red_mushroom", ++ "minecraft:red_mushroom_block", ++ "minecraft:red_nether_brick", ++ "minecraft:red_nether_brick_double_slab", ++ "minecraft:red_nether_brick_slab", ++ "minecraft:red_nether_brick_stairs", ++ "minecraft:red_nether_brick_wall", ++ "minecraft:red_poplar_leaves", ++ "minecraft:red_sand", ++ "minecraft:red_sandstone", ++ "minecraft:red_sandstone_double_slab", ++ "minecraft:red_sandstone_slab", ++ "minecraft:red_sandstone_stairs", ++ "minecraft:red_sandstone_wall", ++ "minecraft:red_shulker_box", ++ "minecraft:red_stained_glass", ++ "minecraft:red_stained_glass_pane", ++ "minecraft:red_terracotta", ++ "minecraft:red_tulip", ++ "minecraft:red_wool", ++ "minecraft:redstone_block", ++ "minecraft:redstone_lamp", ++ "minecraft:redstone_ore", ++ "minecraft:redstone_torch", ++ "minecraft:redstone_wire", ++ "minecraft:reinforced_deepslate", ++ "minecraft:repeating_command_block", ++ "minecraft:reserved6", ++ "minecraft:resin_block", ++ "minecraft:resin_brick_double_slab", ++ "minecraft:resin_brick_slab", ++ "minecraft:resin_brick_stairs", ++ "minecraft:resin_brick_wall", ++ "minecraft:resin_bricks", ++ "minecraft:resin_clump", ++ "minecraft:respawn_anchor", ++ "minecraft:rose_bush", ++ "minecraft:sand", ++ "minecraft:sandstone", ++ "minecraft:sandstone_double_slab", ++ "minecraft:sandstone_slab", ++ "minecraft:sandstone_stairs", ++ "minecraft:sandstone_wall", ++ "minecraft:scaffolding", ++ "minecraft:sculk", ++ "minecraft:sculk_catalyst", ++ "minecraft:sculk_sensor", ++ "minecraft:sculk_shrieker", ++ "minecraft:sculk_vein", ++ "minecraft:sea_lantern", ++ "minecraft:sea_pickle", ++ "minecraft:seagrass", ++ "minecraft:short_dry_grass", ++ "minecraft:short_grass", ++ "minecraft:shroomlight", ++ "minecraft:silver_glazed_terracotta", ++ "minecraft:skeleton_skull", ++ "minecraft:slime", ++ "minecraft:small_amethyst_bud", ++ "minecraft:small_dripleaf_block", ++ "minecraft:smithing_table", ++ "minecraft:smoker", ++ "minecraft:smooth_basalt", ++ "minecraft:smooth_quartz", ++ "minecraft:smooth_quartz_double_slab", ++ "minecraft:smooth_quartz_slab", ++ "minecraft:smooth_quartz_stairs", ++ "minecraft:smooth_red_sandstone", ++ "minecraft:smooth_red_sandstone_double_slab", ++ "minecraft:smooth_red_sandstone_slab", ++ "minecraft:smooth_red_sandstone_stairs", ++ "minecraft:smooth_sandstone", ++ "minecraft:smooth_sandstone_double_slab", ++ "minecraft:smooth_sandstone_slab", ++ "minecraft:smooth_sandstone_stairs", ++ "minecraft:smooth_stone", ++ "minecraft:smooth_stone_double_slab", ++ "minecraft:smooth_stone_slab", ++ "minecraft:sniffer_egg", ++ "minecraft:snow", ++ "minecraft:snow_layer", ++ "minecraft:soul_fire", ++ "minecraft:soul_lantern", ++ "minecraft:soul_sand", ++ "minecraft:soul_soil", ++ "minecraft:soul_torch", ++ "minecraft:sponge", ++ "minecraft:spore_blossom", ++ "minecraft:spruce_button", ++ "minecraft:spruce_double_slab", ++ "minecraft:spruce_fence", ++ "minecraft:spruce_fence_gate", ++ "minecraft:spruce_leaves", ++ "minecraft:spruce_log", ++ "minecraft:spruce_planks", ++ "minecraft:spruce_pressure_plate", ++ "minecraft:spruce_sapling", ++ "minecraft:spruce_shelf", ++ "minecraft:spruce_slab", ++ "minecraft:spruce_stairs", ++ "minecraft:spruce_standing_sign", ++ "minecraft:spruce_trapdoor", ++ "minecraft:spruce_wall_sign", ++ "minecraft:spruce_wood", ++ "minecraft:standing_banner", ++ "minecraft:standing_sign", ++ "minecraft:sticky_piston", ++ "minecraft:sticky_piston_arm_collision", ++ "minecraft:stone", ++ "minecraft:stone_brick_double_slab", ++ "minecraft:stone_brick_slab", ++ "minecraft:stone_brick_stairs", ++ "minecraft:stone_brick_wall", ++ "minecraft:stone_bricks", ++ "minecraft:stone_button", ++ "minecraft:stone_pressure_plate", ++ "minecraft:stone_stairs", ++ "minecraft:stonecutter", ++ "minecraft:stonecutter_block", ++ "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:structure_void", ++ "minecraft:sulfur", ++ "minecraft:sulfur_brick_double_slab", ++ "minecraft:sulfur_brick_slab", ++ "minecraft:sulfur_brick_stairs", ++ "minecraft:sulfur_brick_wall", ++ "minecraft:sulfur_bricks", ++ "minecraft:sulfur_double_slab", ++ "minecraft:sulfur_slab", ++ "minecraft:sulfur_spike", ++ "minecraft:sulfur_stairs", ++ "minecraft:sulfur_wall", ++ "minecraft:sunflower", ++ "minecraft:suspicious_gravel", ++ "minecraft:suspicious_sand", ++ "minecraft:sweet_berry_bush", ++ "minecraft:tall_dry_grass", ++ "minecraft:tall_grass", ++ "minecraft:target", ++ "minecraft:tinted_glass", ++ "minecraft:tnt", ++ "minecraft:torch", ++ "minecraft:torchflower", ++ "minecraft:torchflower_crop", ++ "minecraft:trapdoor", ++ "minecraft:trapped_chest", ++ "minecraft:trial_spawner", ++ "minecraft:trip_wire", ++ "minecraft:tripwire_hook", ++ "minecraft:tube_coral", ++ "minecraft:tube_coral_block", ++ "minecraft:tube_coral_fan", ++ "minecraft:tube_coral_wall_fan", ++ "minecraft:tuff", ++ "minecraft:tuff_brick_double_slab", ++ "minecraft:tuff_brick_slab", ++ "minecraft:tuff_brick_stairs", ++ "minecraft:tuff_brick_wall", ++ "minecraft:tuff_bricks", ++ "minecraft:tuff_double_slab", ++ "minecraft:tuff_slab", ++ "minecraft:tuff_stairs", ++ "minecraft:tuff_wall", ++ "minecraft:turtle_egg", ++ "minecraft:twisting_vines", ++ "minecraft:underwater_tnt", ++ "minecraft:underwater_torch", ++ "minecraft:undyed_shulker_box", ++ "minecraft:unknown", ++ "minecraft:unlit_redstone_torch", ++ "minecraft:unpowered_comparator", ++ "minecraft:unpowered_repeater", ++ "minecraft:vault", ++ "minecraft:verdant_froglight", ++ "minecraft:vine", ++ "minecraft:wall_banner", ++ "minecraft:wall_sign", ++ "minecraft:warped_button", ++ "minecraft:warped_double_slab", ++ "minecraft:warped_fence", ++ "minecraft:warped_fence_gate", ++ "minecraft:warped_fungus", ++ "minecraft:warped_hyphae", ++ "minecraft:warped_nylium", ++ "minecraft:warped_planks", ++ "minecraft:warped_pressure_plate", ++ "minecraft:warped_roots", ++ "minecraft:warped_shelf", ++ "minecraft:warped_slab", ++ "minecraft:warped_stairs", ++ "minecraft:warped_standing_sign", ++ "minecraft:warped_stem", ++ "minecraft:warped_trapdoor", ++ "minecraft:warped_wall_sign", ++ "minecraft:warped_wart_block", ++ "minecraft:water", ++ "minecraft:waterlily", ++ "minecraft:waxed_chiseled_copper", ++ "minecraft:waxed_copper", ++ "minecraft:waxed_copper_bars", ++ "minecraft:waxed_copper_bulb", ++ "minecraft:waxed_copper_chain", ++ "minecraft:waxed_copper_chest", ++ "minecraft:waxed_copper_golem_statue", ++ "minecraft:waxed_copper_grate", ++ "minecraft:waxed_copper_lantern", ++ "minecraft:waxed_copper_trapdoor", ++ "minecraft:waxed_cut_copper", ++ "minecraft:waxed_cut_copper_slab", ++ "minecraft:waxed_cut_copper_stairs", ++ "minecraft:waxed_double_cut_copper_slab", ++ "minecraft:waxed_exposed_chiseled_copper", ++ "minecraft:waxed_exposed_copper", ++ "minecraft:waxed_exposed_copper_bars", ++ "minecraft:waxed_exposed_copper_bulb", ++ "minecraft:waxed_exposed_copper_chain", ++ "minecraft:waxed_exposed_copper_chest", ++ "minecraft:waxed_exposed_copper_golem_statue", ++ "minecraft:waxed_exposed_copper_grate", ++ "minecraft:waxed_exposed_copper_lantern", ++ "minecraft:waxed_exposed_copper_trapdoor", ++ "minecraft:waxed_exposed_cut_copper", ++ "minecraft:waxed_exposed_cut_copper_slab", ++ "minecraft:waxed_exposed_cut_copper_stairs", ++ "minecraft:waxed_exposed_double_cut_copper_slab", ++ "minecraft:waxed_exposed_lightning_rod", ++ "minecraft:waxed_lightning_rod", ++ "minecraft:waxed_oxidized_chiseled_copper", ++ "minecraft:waxed_oxidized_copper", ++ "minecraft:waxed_oxidized_copper_bars", ++ "minecraft:waxed_oxidized_copper_bulb", ++ "minecraft:waxed_oxidized_copper_chain", ++ "minecraft:waxed_oxidized_copper_chest", ++ "minecraft:waxed_oxidized_copper_golem_statue", ++ "minecraft:waxed_oxidized_copper_grate", ++ "minecraft:waxed_oxidized_copper_lantern", ++ "minecraft:waxed_oxidized_copper_trapdoor", ++ "minecraft:waxed_oxidized_cut_copper", ++ "minecraft:waxed_oxidized_cut_copper_slab", ++ "minecraft:waxed_oxidized_cut_copper_stairs", ++ "minecraft:waxed_oxidized_double_cut_copper_slab", ++ "minecraft:waxed_oxidized_lightning_rod", ++ "minecraft:waxed_weathered_chiseled_copper", ++ "minecraft:waxed_weathered_copper", ++ "minecraft:waxed_weathered_copper_bars", ++ "minecraft:waxed_weathered_copper_bulb", ++ "minecraft:waxed_weathered_copper_chain", ++ "minecraft:waxed_weathered_copper_chest", ++ "minecraft:waxed_weathered_copper_golem_statue", ++ "minecraft:waxed_weathered_copper_grate", ++ "minecraft:waxed_weathered_copper_lantern", ++ "minecraft:waxed_weathered_copper_trapdoor", ++ "minecraft:waxed_weathered_cut_copper", ++ "minecraft:waxed_weathered_cut_copper_slab", ++ "minecraft:waxed_weathered_cut_copper_stairs", ++ "minecraft:waxed_weathered_double_cut_copper_slab", ++ "minecraft:waxed_weathered_lightning_rod", ++ "minecraft:weathered_chiseled_copper", ++ "minecraft:weathered_copper", ++ "minecraft:weathered_copper_bars", ++ "minecraft:weathered_copper_bulb", ++ "minecraft:weathered_copper_chain", ++ "minecraft:weathered_copper_chest", ++ "minecraft:weathered_copper_golem_statue", ++ "minecraft:weathered_copper_grate", ++ "minecraft:weathered_copper_lantern", ++ "minecraft:weathered_copper_trapdoor", ++ "minecraft:weathered_cut_copper", ++ "minecraft:weathered_cut_copper_slab", ++ "minecraft:weathered_cut_copper_stairs", ++ "minecraft:weathered_double_cut_copper_slab", ++ "minecraft:weathered_lightning_rod", ++ "minecraft:web", ++ "minecraft:weeping_vines", ++ "minecraft:wet_sponge", ++ "minecraft:white_candle", ++ "minecraft:white_candle_cake", ++ "minecraft:white_carpet", ++ "minecraft:white_concrete", ++ "minecraft:white_concrete_powder", ++ "minecraft:white_glazed_terracotta", ++ "minecraft:white_shulker_box", ++ "minecraft:white_stained_glass", ++ "minecraft:white_stained_glass_pane", ++ "minecraft:white_terracotta", ++ "minecraft:white_tulip", ++ "minecraft:white_wool", ++ "minecraft:wildflowers", ++ "minecraft:wither_rose", ++ "minecraft:wither_skeleton_skull", ++ "minecraft:wooden_button", ++ "minecraft:wooden_pressure_plate", ++ "minecraft:yellow_candle", ++ "minecraft:yellow_candle_cake", ++ "minecraft:yellow_carpet", ++ "minecraft:yellow_concrete", ++ "minecraft:yellow_concrete_powder", ++ "minecraft:yellow_glazed_terracotta", ++ "minecraft:yellow_poplar_leaves", ++ "minecraft:yellow_shulker_box", ++ "minecraft:yellow_stained_glass", ++ "minecraft:yellow_stained_glass_pane", ++ "minecraft:yellow_terracotta", ++ "minecraft:yellow_wool", ++ "minecraft:zombie_head" ++ ], ++ "blockAliases": { ++ "minecraft:item.bed": "minecraft:bed", ++ "minecraft:item.beetroot": "minecraft:beetroot", ++ "minecraft:item.brewing_stand": "minecraft:brewing_stand", ++ "minecraft:item.cake": "minecraft:cake", ++ "minecraft:item.camera": "minecraft:camera", ++ "minecraft:item.campfire": "minecraft:campfire", ++ "minecraft:item.cauldron": "minecraft:cauldron", ++ "minecraft:item.flower_pot": "minecraft:flower_pot", ++ "minecraft:item.frame": "minecraft:frame", ++ "minecraft:item.glow_frame": "minecraft:glow_frame", ++ "minecraft:item.hopper": "minecraft:hopper", ++ "minecraft:item.kelp": "minecraft:kelp", ++ "minecraft:item.nether_sprouts": "minecraft:nether_sprouts", ++ "minecraft:item.nether_wart": "minecraft:nether_wart", ++ "minecraft:item.reeds": "minecraft:reeds", ++ "minecraft:item.soul_campfire": "minecraft:soul_campfire", ++ "minecraft:item.straw_bed": "minecraft:straw_bed", ++ "minecraft:item.wheat": "minecraft:wheat" ++ }, ++ "replacementBlockItems": [] ++ }, ++ "componentDescriptions": { ++ "minecraft:warped_fungus_on_a_stick": "item.warped_fungus_on_a_stick.name" ++ }, ++ "ordinaryNames": { ++ "minecraft:acacia_boat": "item.boat.acacia.name", ++ "minecraft:acacia_chest_boat": "item.chest_boat.acacia.name", ++ "minecraft:acacia_door": "item.acacia_door.name", ++ "minecraft:acacia_hanging_sign": "item.acacia_hanging_sign.name", ++ "minecraft:acacia_sign": "item.acacia_sign.name", ++ "minecraft:agent_spawn_egg": "item.spawn_egg.entity.agent.name", ++ "minecraft:air": "item.air.name", ++ "minecraft:allay_spawn_egg": "item.spawn_egg.entity.allay.name", ++ "minecraft:amethyst_shard": "item.amethyst_shard.name", ++ "minecraft:angler_pottery_sherd": "item.angler_pottery_sherd.name", ++ "minecraft:apple": "item.apple.name", ++ "minecraft:archer_pottery_sherd": "item.archer_pottery_sherd.name", ++ "minecraft:armadillo_scute": "item.armadillo_scute.name", ++ "minecraft:armadillo_spawn_egg": "item.spawn_egg.entity.armadillo.name", ++ "minecraft:armor_stand": "item.armor_stand.name", ++ "minecraft:arms_up_pottery_sherd": "item.arms_up_pottery_sherd.name", ++ "minecraft:axolotl_spawn_egg": "item.spawn_egg.entity.axolotl.name", ++ "minecraft:baked_potato": "item.baked_potato.name", ++ "minecraft:bamboo_chest_raft": "item.chest_boat.bamboo.name", ++ "minecraft:bamboo_door": "item.bamboo_door.name", ++ "minecraft:bamboo_hanging_sign": "item.bamboo_hanging_sign.name", ++ "minecraft:bamboo_raft": "item.boat.bamboo.name", ++ "minecraft:bamboo_sign": "item.bamboo_sign.name", ++ "minecraft:banner_pattern": "item.banner_pattern.name", ++ "minecraft:bat_spawn_egg": "item.spawn_egg.entity.bat.name", ++ "minecraft:bee_spawn_egg": "item.spawn_egg.entity.bee.name", ++ "minecraft:beef": "item.beef.name", ++ "minecraft:beetroot": "item.beetroot.name", ++ "minecraft:beetroot_seeds": "item.beetroot_seeds.name", ++ "minecraft:beetroot_soup": "item.beetroot_soup.name", ++ "minecraft:birch_boat": "item.boat.birch.name", ++ "minecraft:birch_chest_boat": "item.chest_boat.birch.name", ++ "minecraft:birch_door": "item.birch_door.name", ++ "minecraft:birch_hanging_sign": "item.birch_hanging_sign.name", ++ "minecraft:birch_sign": "item.birch_sign.name", ++ "minecraft:black_cushion": "item.cushion.black.name", ++ "minecraft:black_harness": "item.black_harness.name", ++ "minecraft:blade_pottery_sherd": "item.blade_pottery_sherd.name", ++ "minecraft:blaze_powder": "item.blaze_powder.name", ++ "minecraft:blaze_rod": "item.blaze_rod.name", ++ "minecraft:blaze_spawn_egg": "item.spawn_egg.entity.blaze.name", ++ "minecraft:bleach": "item.bleach.name", ++ "minecraft:blue_cushion": "item.cushion.blue.name", ++ "minecraft:blue_egg": "item.blue_egg.name", ++ "minecraft:blue_harness": "item.blue_harness.name", ++ "minecraft:boat": "item.boat.name", ++ "minecraft:bogged_spawn_egg": "item.spawn_egg.entity.bogged.name", ++ "minecraft:bolt_armor_trim_smithing_template": "item.bolt_armor_trim_smithing_template.name", ++ "minecraft:bone": "item.bone.name", ++ "minecraft:bone_meal": "item.dye.white.name", ++ "minecraft:book": "item.book.name", ++ "minecraft:bordure_indented_banner_pattern": "item.bordure_indented_banner_pattern.name", ++ "minecraft:bow": "item.bow.name", ++ "minecraft:bowl": "item.bowl.name", ++ "minecraft:bread": "item.bread.name", ++ "minecraft:breeze_rod": "item.breeze_rod.name", ++ "minecraft:breeze_spawn_egg": "item.spawn_egg.entity.breeze.name", ++ "minecraft:brewer_pottery_sherd": "item.brewer_pottery_sherd.name", ++ "minecraft:brewing_stand": "item.brewing_stand.name", ++ "minecraft:brick": "item.brick.name", ++ "minecraft:brown_cushion": "item.cushion.brown.name", ++ "minecraft:brown_egg": "item.brown_egg.name", ++ "minecraft:brown_harness": "item.brown_harness.name", ++ "minecraft:brush": "item.brush.name", ++ "minecraft:burn_pottery_sherd": "item.burn_pottery_sherd.name", ++ "minecraft:cake": "item.cake.name", ++ "minecraft:camel_husk_spawn_egg": "item.spawn_egg.entity.camel_husk.name", ++ "minecraft:camel_spawn_egg": "item.spawn_egg.entity.camel.name", ++ "minecraft:camera": "item.camera.name", ++ "minecraft:campfire": "tile.campfire.name", ++ "minecraft:carpet": "item.carpet.name", ++ "minecraft:carrot": "item.carrot.name", ++ "minecraft:carrot_on_a_stick": "item.carrotOnAStick.name", ++ "minecraft:cat_spawn_egg": "item.spawn_egg.entity.cat.name", ++ "minecraft:cauldron": "item.cauldron.name", ++ "minecraft:cave_spider_spawn_egg": "item.spawn_egg.entity.cave_spider.name", ++ "minecraft:chainmail_boots": "item.chainmail_boots.name", ++ "minecraft:chainmail_chestplate": "item.chainmail_chestplate.name", ++ "minecraft:chainmail_helmet": "item.chainmail_helmet.name", ++ "minecraft:chainmail_leggings": "item.chainmail_leggings.name", ++ "minecraft:charcoal": "item.charcoal.name", ++ "minecraft:chemistry_table": "item.chemistry_table.name", ++ "minecraft:cherry_boat": "item.boat.cherry.name", ++ "minecraft:cherry_chest_boat": "item.chest_boat.cherry.name", ++ "minecraft:cherry_door": "item.cherry_door.name", ++ "minecraft:cherry_hanging_sign": "item.cherry_hanging_sign.name", ++ "minecraft:cherry_sign": "item.cherry_sign.name", ++ "minecraft:chest_boat": "item.chest_boat.name", ++ "minecraft:chest_minecart": "item.chest_minecart.name", ++ "minecraft:chicken": "item.chicken.name", ++ "minecraft:chicken_spawn_egg": "item.spawn_egg.entity.chicken.name", ++ "minecraft:chorus_fruit": "item.chorus_fruit.name", ++ "minecraft:clay_ball": "item.clay_ball.name", ++ "minecraft:clock": "item.clock.name", ++ "minecraft:coal": "item.coal.name", ++ "minecraft:coast_armor_trim_smithing_template": "item.coast_armor_trim_smithing_template.name", ++ "minecraft:cocoa_beans": "item.dye.brown.name", ++ "minecraft:cod": "item.fish.name", ++ "minecraft:cod_spawn_egg": "item.spawn_egg.entity.cod.name", ++ "minecraft:colored_torch_bp": "item.colored_torch_bp.name", ++ "minecraft:colored_torch_rg": "item.colored_torch_rg.name", ++ "minecraft:command_block_minecart": "item.command_block_minecart.name", ++ "minecraft:comparator": "item.comparator.name", ++ "minecraft:compass": "item.compass.name", ++ "minecraft:concrete": "item.concrete.name", ++ "minecraft:concrete_powder": "item.concrete_powder.name", ++ "minecraft:cooked_beef": "item.cooked_beef.name", ++ "minecraft:cooked_chicken": "item.cooked_chicken.name", ++ "minecraft:cooked_cod": "item.cooked_fish.name", ++ "minecraft:cooked_mutton": "item.muttonCooked.name", ++ "minecraft:cooked_porkchop": "item.cooked_porkchop.name", ++ "minecraft:cooked_rabbit": "item.cooked_rabbit.name", ++ "minecraft:cooked_salmon": "item.cooked_salmon.name", ++ "minecraft:cookie": "item.cookie.name", ++ "minecraft:copper_axe": "item.copper_axe.name", ++ "minecraft:copper_boots": "item.copper_boots.name", ++ "minecraft:copper_chestplate": "item.copper_chestplate.name", ++ "minecraft:copper_door": "item.copper_door.name", ++ "minecraft:copper_golem_spawn_egg": "item.spawn_egg.entity.copper_golem.name", ++ "minecraft:copper_helmet": "item.copper_helmet.name", ++ "minecraft:copper_hoe": "item.copper_hoe.name", ++ "minecraft:copper_horse_armor": "item.copper_horse_armor.name", ++ "minecraft:copper_ingot": "item.copper_ingot.name", ++ "minecraft:copper_leggings": "item.copper_leggings.name", ++ "minecraft:copper_nautilus_armor": "item.copper_nautilus_armor.name", ++ "minecraft:copper_nugget": "item.copper_nugget.name", ++ "minecraft:copper_pickaxe": "item.copper_pickaxe.name", ++ "minecraft:copper_shovel": "item.copper_shovel.name", ++ "minecraft:copper_sword": "item.copper_sword.name", ++ "minecraft:coral": "item.coral.name", ++ "minecraft:coral_block": "item.coral_block.name", ++ "minecraft:coral_fan": "item.coral_fan.name", ++ "minecraft:coral_fan_dead": "item.coral_fan_dead.name", ++ "minecraft:cow_spawn_egg": "item.spawn_egg.entity.cow.name", ++ "minecraft:creaking_spawn_egg": "item.spawn_egg.entity.creaking.name", ++ "minecraft:creeper_banner_pattern": "item.creeper_banner_pattern.name", ++ "minecraft:creeper_spawn_egg": "item.spawn_egg.entity.creeper.name", ++ "minecraft:crimson_door": "item.crimson_door.name", ++ "minecraft:crimson_hanging_sign": "item.crimson_hanging_sign.name", ++ "minecraft:crimson_sign": "item.crimson_sign.name", ++ "minecraft:crossbow": "item.crossbow.name", ++ "minecraft:cyan_cushion": "item.cushion.cyan.name", ++ "minecraft:cyan_harness": "item.cyan_harness.name", ++ "minecraft:danger_pottery_sherd": "item.danger_pottery_sherd.name", ++ "minecraft:dark_oak_boat": "item.boat.big_oak.name", ++ "minecraft:dark_oak_chest_boat": "item.chest_boat.big_oak.name", ++ "minecraft:dark_oak_door": "item.dark_oak_door.name", ++ "minecraft:dark_oak_hanging_sign": "item.dark_oak_hanging_sign.name", ++ "minecraft:dark_oak_sign": "item.darkoak_sign.name", ++ "minecraft:diamond": "item.diamond.name", ++ "minecraft:diamond_axe": "item.diamond_axe.name", ++ "minecraft:diamond_boots": "item.diamond_boots.name", ++ "minecraft:diamond_chestplate": "item.diamond_chestplate.name", ++ "minecraft:diamond_helmet": "item.diamond_helmet.name", ++ "minecraft:diamond_hoe": "item.diamond_hoe.name", ++ "minecraft:diamond_horse_armor": "item.horsearmordiamond.name", ++ "minecraft:diamond_leggings": "item.diamond_leggings.name", ++ "minecraft:diamond_nautilus_armor": "item.diamond_nautilus_armor.name", ++ "minecraft:diamond_pickaxe": "item.diamond_pickaxe.name", ++ "minecraft:diamond_shovel": "item.diamond_shovel.name", ++ "minecraft:diamond_sword": "item.diamond_sword.name", ++ "minecraft:disc_fragment_5": "item.disc_fragment.name", ++ "minecraft:dolphin_spawn_egg": "item.spawn_egg.entity.dolphin.name", ++ "minecraft:donkey_spawn_egg": "item.spawn_egg.entity.donkey.name", ++ "minecraft:double_plant": "item.double_plant.name", ++ "minecraft:double_stone_block_slab": "item.double_stone_block_slab.name", ++ "minecraft:double_stone_block_slab2": "item.double_stone_block_slab2.name", ++ "minecraft:double_stone_block_slab3": "item.double_stone_block_slab3.name", ++ "minecraft:double_stone_block_slab4": "item.double_stone_block_slab4.name", ++ "minecraft:dragon_breath": "item.dragon_breath.name", ++ "minecraft:dried_kelp": "item.dried_kelp.name", ++ "minecraft:drowned_spawn_egg": "item.spawn_egg.entity.drowned.name", ++ "minecraft:dune_armor_trim_smithing_template": "item.dune_armor_trim_smithing_template.name", ++ "minecraft:dye": "item.dye.name", ++ "minecraft:echo_shard": "item.echo_shard.name", ++ "minecraft:egg": "item.egg.name", ++ "minecraft:elder_guardian_spawn_egg": "item.spawn_egg.entity.elder_guardian.name", ++ "minecraft:elytra": "item.elytra.name", ++ "minecraft:emerald": "item.emerald.name", ++ "minecraft:enchanted_book": "item.enchanted_book.name", ++ "minecraft:enchanted_golden_apple": "item.appleEnchanted.name", ++ "minecraft:ender_dragon_spawn_egg": "item.spawn_egg.entity.ender_dragon.name", ++ "minecraft:ender_eye": "item.ender_eye.name", ++ "minecraft:ender_pearl": "item.ender_pearl.name", ++ "minecraft:enderman_spawn_egg": "item.spawn_egg.entity.enderman.name", ++ "minecraft:endermite_spawn_egg": "item.spawn_egg.entity.endermite.name", ++ "minecraft:evoker_spawn_egg": "item.spawn_egg.entity.evocation_illager.name", ++ "minecraft:experience_bottle": "item.experience_bottle.name", ++ "minecraft:explorer_pottery_sherd": "item.explorer_pottery_sherd.name", ++ "minecraft:exposed_copper_door": "item.exposed_copper_door.name", ++ "minecraft:eye_armor_trim_smithing_template": "item.eye_armor_trim_smithing_template.name", ++ "minecraft:feather": "item.feather.name", ++ "minecraft:fence": "item.fence.name", ++ "minecraft:fermented_spider_eye": "item.fermented_spider_eye.name", ++ "minecraft:field_masoned_banner_pattern": "item.field_masoned_banner_pattern.name", ++ "minecraft:fire_charge": "item.fireball.name", ++ "minecraft:firework_rocket": "item.fireworks.name", ++ "minecraft:firework_star": "item.fireworksCharge.name", ++ "minecraft:fishing_rod": "item.fishing_rod.name", ++ "minecraft:flint": "item.flint.name", ++ "minecraft:flint_and_steel": "item.flint_and_steel.name", ++ "minecraft:flow_armor_trim_smithing_template": "item.flow_armor_trim_smithing_template.name", ++ "minecraft:flow_banner_pattern": "item.flow_banner_pattern.name", ++ "minecraft:flow_pottery_sherd": "item.flow_pottery_sherd.name", ++ "minecraft:flower_banner_pattern": "item.flower_banner_pattern.name", ++ "minecraft:flower_pot": "item.flower_pot.name", ++ "minecraft:fox_spawn_egg": "item.spawn_egg.entity.fox.name", ++ "minecraft:frame": "item.frame.name", ++ "minecraft:friend_pottery_sherd": "item.friend_pottery_sherd.name", ++ "minecraft:frog_spawn_egg": "item.spawn_egg.entity.frog.name", ++ "minecraft:ghast_spawn_egg": "item.spawn_egg.entity.ghast.name", ++ "minecraft:ghast_tear": "item.ghast_tear.name", ++ "minecraft:glass_bottle": "item.glass_bottle.name", ++ "minecraft:glistering_melon_slice": "item.speckled_melon.name", ++ "minecraft:globe_banner_pattern": "item.globe_banner_pattern.name", ++ "minecraft:glow_frame": "item.glow_frame.name", ++ "minecraft:glow_ink_sac": "item.glow_ink_sac.name", ++ "minecraft:glow_squid_spawn_egg": "item.spawn_egg.entity.glow_squid.name", ++ "minecraft:glowstone_dust": "item.glowstone_dust.name", ++ "minecraft:goat_horn": "item.goat_horn.name", ++ "minecraft:goat_spawn_egg": "item.spawn_egg.entity.goat.name", ++ "minecraft:gold_ingot": "item.gold_ingot.name", ++ "minecraft:gold_nugget": "item.gold_nugget.name", ++ "minecraft:golden_apple": "item.golden_apple.name", ++ "minecraft:golden_axe": "item.golden_axe.name", ++ "minecraft:golden_boots": "item.golden_boots.name", ++ "minecraft:golden_carrot": "item.golden_carrot.name", ++ "minecraft:golden_chestplate": "item.golden_chestplate.name", ++ "minecraft:golden_helmet": "item.golden_helmet.name", ++ "minecraft:golden_hoe": "item.golden_hoe.name", ++ "minecraft:golden_horse_armor": "item.horsearmorgold.name", ++ "minecraft:golden_leggings": "item.golden_leggings.name", ++ "minecraft:golden_nautilus_armor": "item.golden_nautilus_armor.name", ++ "minecraft:golden_pickaxe": "item.golden_pickaxe.name", ++ "minecraft:golden_shovel": "item.golden_shovel.name", ++ "minecraft:golden_sword": "item.golden_sword.name", ++ "minecraft:gray_cushion": "item.cushion.gray.name", ++ "minecraft:gray_harness": "item.gray_harness.name", ++ "minecraft:green_cushion": "item.cushion.green.name", ++ "minecraft:green_harness": "item.green_harness.name", ++ "minecraft:guardian_spawn_egg": "item.spawn_egg.entity.guardian.name", ++ "minecraft:gunpowder": "item.gunpowder.name", ++ "minecraft:guster_banner_pattern": "item.guster_banner_pattern.name", ++ "minecraft:guster_pottery_sherd": "item.guster_pottery_sherd.name", ++ "minecraft:happy_ghast_spawn_egg": "item.spawn_egg.entity.happy_ghast.name", ++ "minecraft:hard_stained_glass": "item.hard_stained_glass.name", ++ "minecraft:hard_stained_glass_pane": "item.hard_stained_glass_pane.name", ++ "minecraft:heart_of_the_sea": "item.heart_of_the_sea.name", ++ "minecraft:heart_pottery_sherd": "item.heart_pottery_sherd.name", ++ "minecraft:heartbreak_pottery_sherd": "item.heartbreak_pottery_sherd.name", ++ "minecraft:hoglin_spawn_egg": "item.spawn_egg.entity.hoglin.name", ++ "minecraft:honey_bottle": "item.honey_bottle.name", ++ "minecraft:honeycomb": "item.honeycomb.name", ++ "minecraft:hopper": "tile.hopper.name", ++ "minecraft:hopper_minecart": "item.hopper_minecart.name", ++ "minecraft:horse_spawn_egg": "item.spawn_egg.entity.horse.name", ++ "minecraft:host_armor_trim_smithing_template": "item.host_armor_trim_smithing_template.name", ++ "minecraft:howl_pottery_sherd": "item.howl_pottery_sherd.name", ++ "minecraft:husk_spawn_egg": "item.spawn_egg.entity.husk.name", ++ "minecraft:ice_bomb": "item.ice_bomb.name", ++ "minecraft:iron_axe": "item.iron_axe.name", ++ "minecraft:iron_boots": "item.iron_boots.name", ++ "minecraft:iron_chestplate": "item.iron_chestplate.name", ++ "minecraft:iron_door": "item.iron_door.name", ++ "minecraft:iron_golem_spawn_egg": "item.spawn_egg.entity.iron_golem.name", ++ "minecraft:iron_helmet": "item.iron_helmet.name", ++ "minecraft:iron_hoe": "item.iron_hoe.name", ++ "minecraft:iron_horse_armor": "item.horsearmoriron.name", ++ "minecraft:iron_ingot": "item.iron_ingot.name", ++ "minecraft:iron_leggings": "item.iron_leggings.name", ++ "minecraft:iron_nautilus_armor": "item.iron_nautilus_armor.name", ++ "minecraft:iron_nugget": "item.iron_nugget.name", ++ "minecraft:iron_pickaxe": "item.iron_pickaxe.name", ++ "minecraft:iron_shovel": "item.iron_shovel.name", ++ "minecraft:iron_sword": "item.iron_sword.name", ++ "minecraft:jungle_boat": "item.boat.jungle.name", ++ "minecraft:jungle_chest_boat": "item.chest_boat.jungle.name", ++ "minecraft:jungle_door": "item.jungle_door.name", ++ "minecraft:jungle_hanging_sign": "item.jungle_hanging_sign.name", ++ "minecraft:jungle_sign": "item.jungle_sign.name", ++ "minecraft:kelp": "item.kelp.name", ++ "minecraft:lead": "item.lead.name", ++ "minecraft:leather": "item.leather.name", ++ "minecraft:leather_boots": "item.leather_boots.name", ++ "minecraft:leather_chestplate": "item.leather_chestplate.name", ++ "minecraft:leather_helmet": "item.leather_helmet.name", ++ "minecraft:leather_horse_armor": "item.horsearmorleather.name", ++ "minecraft:leather_leggings": "item.leather_leggings.name", ++ "minecraft:leaves": "item.leaves.name", ++ "minecraft:leaves2": "item.leaves2.name", ++ "minecraft:light_block": "item.light_block.name", ++ "minecraft:light_blue_cushion": "item.cushion.lightBlue.name", ++ "minecraft:light_blue_harness": "item.light_blue_harness.name", ++ "minecraft:light_gray_cushion": "item.cushion.silver.name", ++ "minecraft:light_gray_harness": "item.light_gray_harness.name", ++ "minecraft:lime_cushion": "item.cushion.lime.name", ++ "minecraft:lime_harness": "item.lime_harness.name", ++ "minecraft:llama_spawn_egg": "item.spawn_egg.entity.llama.name", ++ "minecraft:lodestone_compass": "item.lodestonecompass.name", ++ "minecraft:log": "item.log.name", ++ "minecraft:log2": "item.log2.name", ++ "minecraft:mace": "item.mace.name", ++ "minecraft:magenta_cushion": "item.cushion.magenta.name", ++ "minecraft:magenta_harness": "item.magenta_harness.name", ++ "minecraft:magma_cream": "item.magma_cream.name", ++ "minecraft:magma_cube_spawn_egg": "item.spawn_egg.entity.magma_cube.name", ++ "minecraft:mangrove_boat": "item.boat.mangrove.name", ++ "minecraft:mangrove_chest_boat": "item.chest_boat.mangrove.name", ++ "minecraft:mangrove_door": "item.mangrove_door.name", ++ "minecraft:mangrove_hanging_sign": "item.mangrove_hanging_sign.name", ++ "minecraft:mangrove_sign": "item.mangrove_sign.name", ++ "minecraft:melon_seeds": "item.melon_seeds.name", ++ "minecraft:melon_slice": "item.melon.name", ++ "minecraft:minecart": "item.minecart.name", ++ "minecraft:miner_pottery_sherd": "item.miner_pottery_sherd.name", ++ "minecraft:mojang_banner_pattern": "item.mojang_banner_pattern.name", ++ "minecraft:monster_egg": "item.monster_egg.name", ++ "minecraft:mooshroom_spawn_egg": "item.spawn_egg.entity.mooshroom.name", ++ "minecraft:mourner_pottery_sherd": "item.mourner_pottery_sherd.name", ++ "minecraft:mule_spawn_egg": "item.spawn_egg.entity.mule.name", ++ "minecraft:mushroom_stew": "item.mushroom_stew.name", ++ "minecraft:music_disc_11": "item.record.name", ++ "minecraft:music_disc_13": "item.record.name", ++ "minecraft:music_disc_5": "item.record.name", ++ "minecraft:music_disc_blocks": "item.record.name", ++ "minecraft:music_disc_bounce": "item.record.name", ++ "minecraft:music_disc_cat": "item.record.name", ++ "minecraft:music_disc_chirp": "item.record.name", ++ "minecraft:music_disc_creator": "item.record.name", ++ "minecraft:music_disc_creator_music_box": "item.record.name", ++ "minecraft:music_disc_far": "item.record.name", ++ "minecraft:music_disc_lava_chicken": "item.record.name", ++ "minecraft:music_disc_mall": "item.record.name", ++ "minecraft:music_disc_mellohi": "item.record.name", ++ "minecraft:music_disc_otherside": "item.record.name", ++ "minecraft:music_disc_pigstep": "item.record.name", ++ "minecraft:music_disc_precipice": "item.record.name", ++ "minecraft:music_disc_relic": "item.record.name", ++ "minecraft:music_disc_stal": "item.record.name", ++ "minecraft:music_disc_strad": "item.record.name", ++ "minecraft:music_disc_tears": "item.record.name", ++ "minecraft:music_disc_wait": "item.record.name", ++ "minecraft:music_disc_ward": "item.record.name", ++ "minecraft:mutton": "item.muttonRaw.name", ++ "minecraft:name_tag": "item.name_tag.name", ++ "minecraft:nautilus_shell": "item.nautilus_shell.name", ++ "minecraft:nautilus_spawn_egg": "item.spawn_egg.entity.nautilus.name", ++ "minecraft:nether_sprouts": "tile.nether_sprouts.name", ++ "minecraft:nether_star": "item.netherStar.name", ++ "minecraft:nether_wart": "item.nether_wart.name", ++ "minecraft:netherbrick": "item.netherbrick.name", ++ "minecraft:netherite_axe": "item.netherite_axe.name", ++ "minecraft:netherite_boots": "item.netherite_boots.name", ++ "minecraft:netherite_chestplate": "item.netherite_chestplate.name", ++ "minecraft:netherite_helmet": "item.netherite_helmet.name", ++ "minecraft:netherite_hoe": "item.netherite_hoe.name", ++ "minecraft:netherite_horse_armor": "item.netherite_horse_armor.name", ++ "minecraft:netherite_ingot": "item.netherite_ingot.name", ++ "minecraft:netherite_leggings": "item.netherite_leggings.name", ++ "minecraft:netherite_nautilus_armor": "item.netherite_nautilus_armor.name", ++ "minecraft:netherite_pickaxe": "item.netherite_pickaxe.name", ++ "minecraft:netherite_scrap": "item.netherite_scrap.name", ++ "minecraft:netherite_shovel": "item.netherite_shovel.name", ++ "minecraft:netherite_sword": "item.netherite_sword.name", ++ "minecraft:netherite_upgrade_smithing_template": "item.netherite_upgrade_smithing_template.name", ++ "minecraft:npc_spawn_egg": "item.spawn_egg.entity.npc.name", ++ "minecraft:oak_boat": "item.boat.oak.name", ++ "minecraft:oak_chest_boat": "item.chest_boat.oak.name", ++ "minecraft:oak_hanging_sign": "item.oak_hanging_sign.name", ++ "minecraft:oak_sign": "item.sign.name", ++ "minecraft:ocelot_spawn_egg": "item.spawn_egg.entity.ocelot.name", ++ "minecraft:ominous_bottle": "item.ominous_bottle.name", ++ "minecraft:orange_cushion": "item.cushion.orange.name", ++ "minecraft:orange_harness": "item.orange_harness.name", ++ "minecraft:oxidized_copper_door": "item.oxidized_copper_door.name", ++ "minecraft:painting": "item.painting.name", ++ "minecraft:pale_oak_boat": "item.boat.pale_oak.name", ++ "minecraft:pale_oak_chest_boat": "item.chest_boat.pale_oak.name", ++ "minecraft:pale_oak_door": "item.pale_oak_door.name", ++ "minecraft:pale_oak_hanging_sign": "item.pale_oak_hanging_sign.name", ++ "minecraft:pale_oak_sign": "item.pale_oak_sign.name", ++ "minecraft:panda_spawn_egg": "item.spawn_egg.entity.panda.name", ++ "minecraft:paper": "item.paper.name", ++ "minecraft:parched_spawn_egg": "item.spawn_egg.entity.parched.name", ++ "minecraft:parrot_spawn_egg": "item.spawn_egg.entity.parrot.name", ++ "minecraft:phantom_membrane": "item.phantom_membrane.name", ++ "minecraft:phantom_spawn_egg": "item.spawn_egg.entity.phantom.name", ++ "minecraft:photo_item": "item.photo.name", ++ "minecraft:pig_spawn_egg": "item.spawn_egg.entity.pig.name", ++ "minecraft:piglin_banner_pattern": "item.piglin_banner_pattern.name", ++ "minecraft:piglin_brute_spawn_egg": "item.spawn_egg.entity.piglin_brute.name", ++ "minecraft:piglin_spawn_egg": "item.spawn_egg.entity.piglin.name", ++ "minecraft:pillager_spawn_egg": "item.spawn_egg.entity.pillager.name", ++ "minecraft:pink_cushion": "item.cushion.pink.name", ++ "minecraft:pink_harness": "item.pink_harness.name", ++ "minecraft:pitcher_pod": "item.pitcher_pod.name", ++ "minecraft:planks": "item.planks.name", ++ "minecraft:plenty_pottery_sherd": "item.plenty_pottery_sherd.name", ++ "minecraft:poisonous_potato": "item.poisonous_potato.name", ++ "minecraft:polar_bear_spawn_egg": "item.spawn_egg.entity.polar_bear.name", ++ "minecraft:poplar_boat": "item.boat.poplar.name", ++ "minecraft:poplar_chest_boat": "item.chest_boat.poplar.name", ++ "minecraft:poplar_door": "item.poplar_door.name", ++ "minecraft:poplar_hanging_sign": "item.poplar_hanging_sign.name", ++ "minecraft:poplar_sign": "item.poplar_sign.name", ++ "minecraft:popped_chorus_fruit": "item.chorus_fruit_popped.name", ++ "minecraft:porkchop": "item.porkchop.name", ++ "minecraft:portfolio": "item.portfolio.name", ++ "minecraft:potato": "item.potato.name", ++ "minecraft:prismarine_crystals": "item.prismarine_crystals.name", ++ "minecraft:prismarine_shard": "item.prismarine_shard.name", ++ "minecraft:prize_pottery_sherd": "item.prize_pottery_sherd.name", ++ "minecraft:pufferfish": "item.pufferfish.name", ++ "minecraft:pufferfish_spawn_egg": "item.spawn_egg.entity.pufferfish.name", ++ "minecraft:pumpkin_pie": "item.pumpkin_pie.name", ++ "minecraft:pumpkin_seeds": "item.pumpkin_seeds.name", ++ "minecraft:purple_cushion": "item.cushion.purple.name", ++ "minecraft:purple_harness": "item.purple_harness.name", ++ "minecraft:quartz": "item.quartz.name", ++ "minecraft:rabbit": "item.rabbit.name", ++ "minecraft:rabbit_foot": "item.rabbit_foot.name", ++ "minecraft:rabbit_hide": "item.rabbit_hide.name", ++ "minecraft:rabbit_spawn_egg": "item.spawn_egg.entity.rabbit.name", ++ "minecraft:rabbit_stew": "item.rabbit_stew.name", ++ "minecraft:raiser_armor_trim_smithing_template": "item.raiser_armor_trim_smithing_template.name", ++ "minecraft:rapid_fertilizer": "item.rapid_fertilizer.name", ++ "minecraft:ravager_spawn_egg": "item.spawn_egg.entity.ravager.name", ++ "minecraft:raw_copper": "item.raw_copper.name", ++ "minecraft:raw_gold": "item.raw_gold.name", ++ "minecraft:raw_iron": "item.raw_iron.name", ++ "minecraft:recovery_compass": "item.recovery_compass.name", ++ "minecraft:red_cushion": "item.cushion.red.name", ++ "minecraft:red_flower": "item.red_flower.name", ++ "minecraft:red_harness": "item.red_harness.name", ++ "minecraft:redstone": "item.redstone.name", ++ "minecraft:repeater": "item.repeater.name", ++ "minecraft:resin_brick": "item.resin_brick.name", ++ "minecraft:rib_armor_trim_smithing_template": "item.rib_armor_trim_smithing_template.name", ++ "minecraft:rotten_flesh": "item.rotten_flesh.name", ++ "minecraft:saddle": "item.saddle.name", ++ "minecraft:salmon": "item.salmon.name", ++ "minecraft:salmon_spawn_egg": "item.spawn_egg.entity.salmon.name", ++ "minecraft:sapling": "item.sapling.name", ++ "minecraft:scrape_pottery_sherd": "item.scrape_pottery_sherd.name", ++ "minecraft:sentry_armor_trim_smithing_template": "item.sentry_armor_trim_smithing_template.name", ++ "minecraft:shaper_armor_trim_smithing_template": "item.shaper_armor_trim_smithing_template.name", ++ "minecraft:sheaf_pottery_sherd": "item.sheaf_pottery_sherd.name", ++ "minecraft:shears": "item.shears.name", ++ "minecraft:sheep_spawn_egg": "item.spawn_egg.entity.sheep.name", ++ "minecraft:shelter_pottery_sherd": "item.shelter_pottery_sherd.name", ++ "minecraft:shulker_box": "item.shulker_box.name", ++ "minecraft:shulker_shell": "item.shulker_shell.name", ++ "minecraft:shulker_spawn_egg": "item.spawn_egg.entity.shulker.name", ++ "minecraft:silence_armor_trim_smithing_template": "item.silence_armor_trim_smithing_template.name", ++ "minecraft:silverfish_spawn_egg": "item.spawn_egg.entity.silverfish.name", ++ "minecraft:skeleton_horse_spawn_egg": "item.spawn_egg.entity.skeleton_horse.name", ++ "minecraft:skeleton_spawn_egg": "item.spawn_egg.entity.skeleton.name", ++ "minecraft:skull": "item.skull.name", ++ "minecraft:skull_banner_pattern": "item.skull_banner_pattern.name", ++ "minecraft:skull_pottery_sherd": "item.skull_pottery_sherd.name", ++ "minecraft:slime_ball": "item.slime_ball.name", ++ "minecraft:slime_spawn_egg": "item.spawn_egg.entity.slime.name", ++ "minecraft:sniffer_spawn_egg": "item.spawn_egg.entity.sniffer.name", ++ "minecraft:snort_pottery_sherd": "item.snort_pottery_sherd.name", ++ "minecraft:snout_armor_trim_smithing_template": "item.snout_armor_trim_smithing_template.name", ++ "minecraft:snow_golem_spawn_egg": "item.spawn_egg.entity.snow_golem.name", ++ "minecraft:snowball": "item.snowball.name", ++ "minecraft:soul_campfire": "tile.soul_campfire.name", ++ "minecraft:spawn_egg": "item.spawn_egg.name", ++ "minecraft:spider_eye": "item.spider_eye.name", ++ "minecraft:spider_spawn_egg": "item.spawn_egg.entity.spider.name", ++ "minecraft:spire_armor_trim_smithing_template": "item.spire_armor_trim_smithing_template.name", ++ "minecraft:spruce_boat": "item.boat.spruce.name", ++ "minecraft:spruce_chest_boat": "item.chest_boat.spruce.name", ++ "minecraft:spruce_door": "item.spruce_door.name", ++ "minecraft:spruce_hanging_sign": "item.spruce_hanging_sign.name", ++ "minecraft:spruce_sign": "item.spruce_sign.name", ++ "minecraft:spyglass": "item.spyglass.name", ++ "minecraft:squid_spawn_egg": "item.spawn_egg.entity.squid.name", ++ "minecraft:stained_glass": "item.stained_glass.name", ++ "minecraft:stained_glass_pane": "item.stained_glass_pane.name", ++ "minecraft:stained_hardened_clay": "item.stained_hardened_clay.name", ++ "minecraft:stick": "item.stick.name", ++ "minecraft:stone_axe": "item.stone_axe.name", ++ "minecraft:stone_block_slab": "item.stone_block_slab.name", ++ "minecraft:stone_block_slab2": "item.stone_block_slab2.name", ++ "minecraft:stone_block_slab3": "item.stone_block_slab3.name", ++ "minecraft:stone_block_slab4": "item.stone_block_slab4.name", ++ "minecraft:stone_hoe": "item.stone_hoe.name", ++ "minecraft:stone_pickaxe": "item.stone_pickaxe.name", ++ "minecraft:stone_shovel": "item.stone_shovel.name", ++ "minecraft:stone_sword": "item.stone_sword.name", ++ "minecraft:stonebrick": "item.stonebrick.name", ++ "minecraft:straw_bed": "item.straw_bed.name", ++ "minecraft:stray_spawn_egg": "item.spawn_egg.entity.stray.name", ++ "minecraft:strider_spawn_egg": "item.spawn_egg.entity.strider.name", ++ "minecraft:string": "item.string.name", ++ "minecraft:sugar": "item.sugar.name", ++ "minecraft:sugar_cane": "item.reeds.name", ++ "minecraft:sulfur_cube_spawn_egg": "item.spawn_egg.entity.sulfur_cube.name", ++ "minecraft:suspicious_stew": "item.suspicious_stew.name", ++ "minecraft:sweet_berries": "item.sweet_berries.name", ++ "minecraft:tadpole_spawn_egg": "item.spawn_egg.entity.tadpole.name", ++ "minecraft:tallgrass": "item.tallgrass.name", ++ "minecraft:tide_armor_trim_smithing_template": "item.tide_armor_trim_smithing_template.name", ++ "minecraft:tnt_minecart": "item.tnt_minecart.name", ++ "minecraft:torchflower_seeds": "item.torchflower_seeds.name", ++ "minecraft:totem_of_undying": "item.totem.name", ++ "minecraft:trader_llama_spawn_egg": "item.spawn_egg.entity.trader_llama.name", ++ "minecraft:trident": "item.trident.name", ++ "minecraft:tropical_fish": "item.clownfish.name", ++ "minecraft:tropical_fish_spawn_egg": "item.spawn_egg.entity.tropicalfish.name", ++ "minecraft:turtle_helmet": "item.turtle_helmet.name", ++ "minecraft:turtle_scute": "item.turtle_shell_piece.name", ++ "minecraft:turtle_spawn_egg": "item.spawn_egg.entity.turtle.name", ++ "minecraft:vex_armor_trim_smithing_template": "item.vex_armor_trim_smithing_template.name", ++ "minecraft:vex_spawn_egg": "item.spawn_egg.entity.vex.name", ++ "minecraft:villager_spawn_egg": "item.spawn_egg.entity.villager_v2.name", ++ "minecraft:vindicator_spawn_egg": "item.spawn_egg.entity.vindicator.name", ++ "minecraft:wandering_trader_spawn_egg": "item.spawn_egg.entity.wandering_trader.name", ++ "minecraft:ward_armor_trim_smithing_template": "item.ward_armor_trim_smithing_template.name", ++ "minecraft:warden_spawn_egg": "item.spawn_egg.entity.warden.name", ++ "minecraft:warped_door": "item.warped_door.name", ++ "minecraft:warped_hanging_sign": "item.warped_hanging_sign.name", ++ "minecraft:warped_sign": "item.warped_sign.name", ++ "minecraft:waxed_copper_door": "item.waxed_copper_door.name", ++ "minecraft:waxed_exposed_copper_door": "item.waxed_exposed_copper_door.name", ++ "minecraft:waxed_oxidized_copper_door": "item.waxed_oxidized_copper_door.name", ++ "minecraft:waxed_weathered_copper_door": "item.waxed_weathered_copper_door.name", ++ "minecraft:wayfinder_armor_trim_smithing_template": "item.wayfinder_armor_trim_smithing_template.name", ++ "minecraft:weathered_copper_door": "item.weathered_copper_door.name", ++ "minecraft:wheat": "item.wheat.name", ++ "minecraft:wheat_seeds": "item.wheat_seeds.name", ++ "minecraft:white_cushion": "item.cushion.white.name", ++ "minecraft:white_harness": "item.white_harness.name", ++ "minecraft:wild_armor_trim_smithing_template": "item.wild_armor_trim_smithing_template.name", ++ "minecraft:witch_spawn_egg": "item.spawn_egg.entity.witch.name", ++ "minecraft:wither_skeleton_spawn_egg": "item.spawn_egg.entity.wither_skeleton.name", ++ "minecraft:wither_spawn_egg": "item.spawn_egg.entity.wither.name", ++ "minecraft:wolf_armor": "item.wolf_armor.name", ++ "minecraft:wolf_spawn_egg": "item.spawn_egg.entity.wolf.name", ++ "minecraft:wood": "item.wood.name", ++ "minecraft:wooden_axe": "item.wooden_axe.name", ++ "minecraft:wooden_door": "item.wooden_door.name", ++ "minecraft:wooden_hoe": "item.wooden_hoe.name", ++ "minecraft:wooden_pickaxe": "item.wooden_pickaxe.name", ++ "minecraft:wooden_shovel": "item.wooden_shovel.name", ++ "minecraft:wooden_slab": "item.wooden_slab.name", ++ "minecraft:wooden_sword": "item.wooden_sword.name", ++ "minecraft:wool": "item.wool.name", ++ "minecraft:writable_book": "item.writable_book.name", ++ "minecraft:yellow_cushion": "item.cushion.yellow.name", ++ "minecraft:yellow_harness": "item.yellow_harness.name", ++ "minecraft:zoglin_spawn_egg": "item.spawn_egg.entity.zoglin.name", ++ "minecraft:zombie_horse_spawn_egg": "item.spawn_egg.entity.zombie_horse.name", ++ "minecraft:zombie_nautilus_spawn_egg": "item.spawn_egg.entity.zombie_nautilus.name", ++ "minecraft:zombie_pigman_spawn_egg": "item.spawn_egg.entity.zombie_pigman.name", ++ "minecraft:zombie_spawn_egg": "item.spawn_egg.entity.zombie.name", ++ "minecraft:zombie_villager_spawn_egg": "item.spawn_egg.entity.zombie_villager_v2.name" ++ }, ++ "descriptorNames": { ++ "minecraft:axolotl_bucket": { ++ "kind": "BUCKET", ++ "description": "item.axolotl_bucket", ++ "variant": 12 ++ }, ++ "minecraft:balloon": { ++ "kind": "BALLOON", ++ "description": "item.balloon", ++ "variant": 0 ++ }, ++ "minecraft:banner": { ++ "kind": "BANNER", ++ "description": "item.banner", ++ "variant": 0 ++ }, ++ "minecraft:bed": { ++ "kind": "BED", ++ "description": "item.bed", ++ "variant": 0 ++ }, ++ "minecraft:black_dye": { ++ "kind": "DYE", ++ "description": "item.black_dye", ++ "variant": 0 ++ }, ++ "minecraft:blue_dye": { ++ "kind": "DYE", ++ "description": "item.blue_dye", ++ "variant": 4 ++ }, ++ "minecraft:board": { ++ "kind": "BOARD", ++ "description": "item.board", ++ "variant": 0 ++ }, ++ "minecraft:brown_dye": { ++ "kind": "DYE", ++ "description": "item.brown_dye", ++ "variant": 3 ++ }, ++ "minecraft:bucket": { ++ "kind": "BUCKET", ++ "description": "item.bucket", ++ "variant": 0 ++ }, ++ "minecraft:cod_bucket": { ++ "kind": "BUCKET", ++ "description": "item.cod_bucket", ++ "variant": 2 ++ }, ++ "minecraft:cyan_dye": { ++ "kind": "DYE", ++ "description": "item.cyan_dye", ++ "variant": 6 ++ }, ++ "minecraft:empty_map": { ++ "kind": "EMPTY_MAP", ++ "description": "item.emptymap", ++ "variant": 0 ++ }, ++ "minecraft:filled_map": { ++ "kind": "FILLED_MAP", ++ "description": "item.map", ++ "variant": 0 ++ }, ++ "minecraft:glow_stick": { ++ "kind": "GLOW_STICK", ++ "description": "item.glow_stick", ++ "variant": 0 ++ }, ++ "minecraft:gray_dye": { ++ "kind": "DYE", ++ "description": "item.gray_dye", ++ "variant": 8 ++ }, ++ "minecraft:green_dye": { ++ "kind": "DYE", ++ "description": "item.green_dye", ++ "variant": 2 ++ }, ++ "minecraft:ink_sac": { ++ "kind": "DYE", ++ "description": "item.ink_sac", ++ "variant": 0 ++ }, ++ "minecraft:lapis_lazuli": { ++ "kind": "DYE", ++ "description": "item.lapis_lazuli", ++ "variant": 4 ++ }, ++ "minecraft:lava_bucket": { ++ "kind": "BUCKET", ++ "description": "item.lava_bucket", ++ "variant": 10 ++ }, ++ "minecraft:light_blue_dye": { ++ "kind": "DYE", ++ "description": "item.light_blue_dye", ++ "variant": 12 ++ }, ++ "minecraft:light_gray_dye": { ++ "kind": "DYE", ++ "description": "item.light_gray_dye", ++ "variant": 7 ++ }, ++ "minecraft:lime_dye": { ++ "kind": "DYE", ++ "description": "item.lime_dye", ++ "variant": 10 ++ }, ++ "minecraft:magenta_dye": { ++ "kind": "DYE", ++ "description": "item.magenta_dye", ++ "variant": 13 ++ }, ++ "minecraft:medicine": { ++ "kind": "MEDICINE", ++ "description": "item.medicine", ++ "variant": 0 ++ }, ++ "minecraft:milk_bucket": { ++ "kind": "BUCKET", ++ "description": "item.milk_bucket", ++ "variant": 1 ++ }, ++ "minecraft:orange_dye": { ++ "kind": "DYE", ++ "description": "item.orange_dye", ++ "variant": 14 ++ }, ++ "minecraft:pink_dye": { ++ "kind": "DYE", ++ "description": "item.pink_dye", ++ "variant": 9 ++ }, ++ "minecraft:powder_snow_bucket": { ++ "kind": "BUCKET", ++ "description": "item.powder_snow_bucket", ++ "variant": 11 ++ }, ++ "minecraft:pufferfish_bucket": { ++ "kind": "BUCKET", ++ "description": "item.pufferfish_bucket", ++ "variant": 5 ++ }, ++ "minecraft:purple_dye": { ++ "kind": "DYE", ++ "description": "item.purple_dye", ++ "variant": 5 ++ }, ++ "minecraft:red_dye": { ++ "kind": "DYE", ++ "description": "item.red_dye", ++ "variant": 1 ++ }, ++ "minecraft:salmon_bucket": { ++ "kind": "BUCKET", ++ "description": "item.salmon_bucket", ++ "variant": 3 ++ }, ++ "minecraft:shield": { ++ "kind": "SHIELD", ++ "description": "item.shield", ++ "variant": 0 ++ }, ++ "minecraft:sparkler": { ++ "kind": "SPARKLER", ++ "description": "item.sparkler", ++ "variant": 0 ++ }, ++ "minecraft:sulfur_cube_bucket": { ++ "kind": "BUCKET", ++ "description": "item.sulfur_cube_bucket", ++ "variant": 14 ++ }, ++ "minecraft:tadpole_bucket": { ++ "kind": "BUCKET", ++ "description": "item.tadpole_bucket", ++ "variant": 13 ++ }, ++ "minecraft:tropical_fish_bucket": { ++ "kind": "BUCKET", ++ "description": "item.tropical_fish_bucket", ++ "variant": 4 ++ }, ++ "minecraft:water_bucket": { ++ "kind": "BUCKET", ++ "description": "item.water_bucket", ++ "variant": 8 ++ }, ++ "minecraft:white_dye": { ++ "kind": "DYE", ++ "description": "item.white_dye", ++ "variant": 15 ++ }, ++ "minecraft:written_book": { ++ "kind": "WRITTEN_BOOK", ++ "description": "item.written_book", ++ "variant": 0 ++ }, ++ "minecraft:yellow_dye": { ++ "kind": "DYE", ++ "description": "item.yellow_dye", ++ "variant": 11 ++ } ++ }, ++ "blockNames": { ++ "minecraft:acacia_button": { ++ "kind": "BLOCK", ++ "block": "minecraft:acacia_button" ++ }, ++ "minecraft:acacia_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:acacia_double_slab" ++ }, ++ "minecraft:acacia_fence": { ++ "kind": "BLOCK", ++ "block": "minecraft:acacia_fence" ++ }, ++ "minecraft:acacia_fence_gate": { ++ "kind": "BLOCK", ++ "block": "minecraft:acacia_fence_gate" ++ }, ++ "minecraft:acacia_leaves": { ++ "kind": "BLOCK", ++ "block": "minecraft:acacia_leaves" ++ }, ++ "minecraft:acacia_log": { ++ "kind": "BLOCK", ++ "block": "minecraft:acacia_log" ++ }, ++ "minecraft:acacia_planks": { ++ "kind": "BLOCK", ++ "block": "minecraft:acacia_planks" ++ }, ++ "minecraft:acacia_pressure_plate": { ++ "kind": "BLOCK", ++ "block": "minecraft:acacia_pressure_plate" ++ }, ++ "minecraft:acacia_sapling": { ++ "kind": "SAPLING", ++ "block": "minecraft:acacia_sapling", ++ "description": "item.acacia_sapling" ++ }, ++ "minecraft:acacia_shelf": { ++ "kind": "BLOCK", ++ "block": "minecraft:acacia_shelf" ++ }, ++ "minecraft:acacia_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:acacia_slab" ++ }, ++ "minecraft:acacia_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:acacia_stairs" ++ }, ++ "minecraft:acacia_standing_sign": { ++ "kind": "BLOCK", ++ "block": "minecraft:acacia_standing_sign" ++ }, ++ "minecraft:acacia_trapdoor": { ++ "kind": "BLOCK", ++ "block": "minecraft:acacia_trapdoor" ++ }, ++ "minecraft:acacia_wall_sign": { ++ "kind": "BLOCK", ++ "block": "minecraft:acacia_wall_sign" ++ }, ++ "minecraft:acacia_wood": { ++ "kind": "BLOCK", ++ "block": "minecraft:acacia_wood" ++ }, ++ "minecraft:activator_rail": { ++ "kind": "BLOCK", ++ "block": "minecraft:activator_rail" ++ }, ++ "minecraft:allium": { ++ "kind": "BLOCK", ++ "block": "minecraft:allium" ++ }, ++ "minecraft:allow": { ++ "kind": "BLOCK", ++ "block": "minecraft:allow" ++ }, ++ "minecraft:amethyst_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:amethyst_block" ++ }, ++ "minecraft:amethyst_cluster": { ++ "kind": "BLOCK", ++ "block": "minecraft:amethyst_cluster" ++ }, ++ "minecraft:ancient_debris": { ++ "kind": "BLOCK", ++ "block": "minecraft:ancient_debris" ++ }, ++ "minecraft:andesite": { ++ "kind": "BLOCK", ++ "block": "minecraft:andesite" ++ }, ++ "minecraft:andesite_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:andesite_double_slab" ++ }, ++ "minecraft:andesite_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:andesite_slab" ++ }, ++ "minecraft:andesite_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:andesite_stairs" ++ }, ++ "minecraft:andesite_wall": { ++ "kind": "BLOCK", ++ "block": "minecraft:andesite_wall" ++ }, ++ "minecraft:anvil": { ++ "kind": "BLOCK", ++ "block": "minecraft:anvil" ++ }, ++ "minecraft:azalea": { ++ "kind": "BLOCK", ++ "block": "minecraft:azalea" ++ }, ++ "minecraft:azalea_leaves": { ++ "kind": "LEAVES", ++ "block": "minecraft:azalea_leaves", ++ "description": "item.azalea_leaves" ++ }, ++ "minecraft:azalea_leaves_flowered": { ++ "kind": "LEAVES", ++ "block": "minecraft:azalea_leaves_flowered", ++ "description": "item.azalea_leaves_flowered" ++ }, ++ "minecraft:azure_bluet": { ++ "kind": "BLOCK", ++ "block": "minecraft:azure_bluet" ++ }, ++ "minecraft:bamboo": { ++ "kind": "BLOCK", ++ "block": "minecraft:bamboo" ++ }, ++ "minecraft:bamboo_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:bamboo_block" ++ }, ++ "minecraft:bamboo_button": { ++ "kind": "BLOCK", ++ "block": "minecraft:bamboo_button" ++ }, ++ "minecraft:bamboo_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:bamboo_double_slab" ++ }, ++ "minecraft:bamboo_fence": { ++ "kind": "BLOCK", ++ "block": "minecraft:bamboo_fence" ++ }, ++ "minecraft:bamboo_fence_gate": { ++ "kind": "BLOCK", ++ "block": "minecraft:bamboo_fence_gate" ++ }, ++ "minecraft:bamboo_mosaic": { ++ "kind": "BLOCK", ++ "block": "minecraft:bamboo_mosaic" ++ }, ++ "minecraft:bamboo_mosaic_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:bamboo_mosaic_double_slab" ++ }, ++ "minecraft:bamboo_mosaic_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:bamboo_mosaic_slab" ++ }, ++ "minecraft:bamboo_mosaic_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:bamboo_mosaic_stairs" ++ }, ++ "minecraft:bamboo_planks": { ++ "kind": "BLOCK", ++ "block": "minecraft:bamboo_planks" ++ }, ++ "minecraft:bamboo_pressure_plate": { ++ "kind": "BLOCK", ++ "block": "minecraft:bamboo_pressure_plate" ++ }, ++ "minecraft:bamboo_sapling": { ++ "kind": "BLOCK", ++ "block": "minecraft:bamboo_sapling" ++ }, ++ "minecraft:bamboo_shelf": { ++ "kind": "BLOCK", ++ "block": "minecraft:bamboo_shelf" ++ }, ++ "minecraft:bamboo_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:bamboo_slab" ++ }, ++ "minecraft:bamboo_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:bamboo_stairs" ++ }, ++ "minecraft:bamboo_standing_sign": { ++ "kind": "BLOCK", ++ "block": "minecraft:bamboo_standing_sign" ++ }, ++ "minecraft:bamboo_trapdoor": { ++ "kind": "BLOCK", ++ "block": "minecraft:bamboo_trapdoor" ++ }, ++ "minecraft:bamboo_wall_sign": { ++ "kind": "BLOCK", ++ "block": "minecraft:bamboo_wall_sign" ++ }, ++ "minecraft:barrel": { ++ "kind": "BLOCK", ++ "block": "minecraft:barrel" ++ }, ++ "minecraft:barrier": { ++ "kind": "BLOCK", ++ "block": "minecraft:barrier" ++ }, ++ "minecraft:basalt": { ++ "kind": "BLOCK", ++ "block": "minecraft:basalt" ++ }, ++ "minecraft:beacon": { ++ "kind": "BLOCK", ++ "block": "minecraft:beacon" ++ }, ++ "minecraft:bedrock": { ++ "kind": "BLOCK", ++ "block": "minecraft:bedrock" ++ }, ++ "minecraft:bee_nest": { ++ "kind": "BLOCK", ++ "block": "minecraft:bee_nest" ++ }, ++ "minecraft:beehive": { ++ "kind": "BLOCK", ++ "block": "minecraft:beehive" ++ }, ++ "minecraft:bell": { ++ "kind": "BLOCK", ++ "block": "minecraft:bell" ++ }, ++ "minecraft:big_dripleaf": { ++ "kind": "BLOCK", ++ "block": "minecraft:big_dripleaf" ++ }, ++ "minecraft:birch_button": { ++ "kind": "BLOCK", ++ "block": "minecraft:birch_button" ++ }, ++ "minecraft:birch_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:birch_double_slab" ++ }, ++ "minecraft:birch_fence": { ++ "kind": "BLOCK", ++ "block": "minecraft:birch_fence" ++ }, ++ "minecraft:birch_fence_gate": { ++ "kind": "BLOCK", ++ "block": "minecraft:birch_fence_gate" ++ }, ++ "minecraft:birch_leaves": { ++ "kind": "BLOCK", ++ "block": "minecraft:birch_leaves" ++ }, ++ "minecraft:birch_log": { ++ "kind": "BLOCK", ++ "block": "minecraft:birch_log" ++ }, ++ "minecraft:birch_planks": { ++ "kind": "BLOCK", ++ "block": "minecraft:birch_planks" ++ }, ++ "minecraft:birch_pressure_plate": { ++ "kind": "BLOCK", ++ "block": "minecraft:birch_pressure_plate" ++ }, ++ "minecraft:birch_sapling": { ++ "kind": "SAPLING", ++ "block": "minecraft:birch_sapling", ++ "description": "item.birch_sapling" ++ }, ++ "minecraft:birch_shelf": { ++ "kind": "BLOCK", ++ "block": "minecraft:birch_shelf" ++ }, ++ "minecraft:birch_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:birch_slab" ++ }, ++ "minecraft:birch_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:birch_stairs" ++ }, ++ "minecraft:birch_standing_sign": { ++ "kind": "BLOCK", ++ "block": "minecraft:birch_standing_sign" ++ }, ++ "minecraft:birch_trapdoor": { ++ "kind": "BLOCK", ++ "block": "minecraft:birch_trapdoor" ++ }, ++ "minecraft:birch_wall_sign": { ++ "kind": "BLOCK", ++ "block": "minecraft:birch_wall_sign" ++ }, ++ "minecraft:birch_wood": { ++ "kind": "BLOCK", ++ "block": "minecraft:birch_wood" ++ }, ++ "minecraft:black_candle": { ++ "kind": "BLOCK", ++ "block": "minecraft:black_candle" ++ }, ++ "minecraft:black_candle_cake": { ++ "kind": "BLOCK", ++ "block": "minecraft:black_candle_cake" ++ }, ++ "minecraft:black_carpet": { ++ "kind": "WOOL", ++ "block": "minecraft:black_carpet", ++ "fallback": "tile.carpet.name" ++ }, ++ "minecraft:black_concrete": { ++ "kind": "BLOCK", ++ "block": "minecraft:black_concrete" ++ }, ++ "minecraft:black_concrete_powder": { ++ "kind": "BLOCK", ++ "block": "minecraft:black_concrete_powder" ++ }, ++ "minecraft:black_glazed_terracotta": { ++ "kind": "BLOCK", ++ "block": "minecraft:black_glazed_terracotta" ++ }, ++ "minecraft:black_shulker_box": { ++ "kind": "BLOCK", ++ "block": "minecraft:black_shulker_box" ++ }, ++ "minecraft:black_stained_glass": { ++ "kind": "BLOCK", ++ "block": "minecraft:black_stained_glass" ++ }, ++ "minecraft:black_stained_glass_pane": { ++ "kind": "BLOCK", ++ "block": "minecraft:black_stained_glass_pane" ++ }, ++ "minecraft:black_terracotta": { ++ "kind": "BLOCK", ++ "block": "minecraft:black_terracotta" ++ }, ++ "minecraft:black_wool": { ++ "kind": "WOOL", ++ "block": "minecraft:black_wool", ++ "fallback": "tile.wool.name" ++ }, ++ "minecraft:blackstone": { ++ "kind": "BLOCK", ++ "block": "minecraft:blackstone" ++ }, ++ "minecraft:blackstone_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:blackstone_double_slab" ++ }, ++ "minecraft:blackstone_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:blackstone_slab" ++ }, ++ "minecraft:blackstone_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:blackstone_stairs" ++ }, ++ "minecraft:blackstone_wall": { ++ "kind": "BLOCK", ++ "block": "minecraft:blackstone_wall" ++ }, ++ "minecraft:blast_furnace": { ++ "kind": "BLOCK", ++ "block": "minecraft:blast_furnace" ++ }, ++ "minecraft:blue_candle": { ++ "kind": "BLOCK", ++ "block": "minecraft:blue_candle" ++ }, ++ "minecraft:blue_candle_cake": { ++ "kind": "BLOCK", ++ "block": "minecraft:blue_candle_cake" ++ }, ++ "minecraft:blue_carpet": { ++ "kind": "WOOL", ++ "block": "minecraft:blue_carpet", ++ "fallback": "tile.carpet.name" ++ }, ++ "minecraft:blue_concrete": { ++ "kind": "BLOCK", ++ "block": "minecraft:blue_concrete" ++ }, ++ "minecraft:blue_concrete_powder": { ++ "kind": "BLOCK", ++ "block": "minecraft:blue_concrete_powder" ++ }, ++ "minecraft:blue_glazed_terracotta": { ++ "kind": "BLOCK", ++ "block": "minecraft:blue_glazed_terracotta" ++ }, ++ "minecraft:blue_ice": { ++ "kind": "BLOCK", ++ "block": "minecraft:blue_ice" ++ }, ++ "minecraft:blue_orchid": { ++ "kind": "BLOCK", ++ "block": "minecraft:blue_orchid" ++ }, ++ "minecraft:blue_shulker_box": { ++ "kind": "BLOCK", ++ "block": "minecraft:blue_shulker_box" ++ }, ++ "minecraft:blue_stained_glass": { ++ "kind": "BLOCK", ++ "block": "minecraft:blue_stained_glass" ++ }, ++ "minecraft:blue_stained_glass_pane": { ++ "kind": "BLOCK", ++ "block": "minecraft:blue_stained_glass_pane" ++ }, ++ "minecraft:blue_terracotta": { ++ "kind": "BLOCK", ++ "block": "minecraft:blue_terracotta" ++ }, ++ "minecraft:blue_wool": { ++ "kind": "WOOL", ++ "block": "minecraft:blue_wool", ++ "fallback": "tile.wool.name" ++ }, ++ "minecraft:bone_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:bone_block" ++ }, ++ "minecraft:bookshelf": { ++ "kind": "BLOCK", ++ "block": "minecraft:bookshelf" ++ }, ++ "minecraft:border_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:border_block" ++ }, ++ "minecraft:brain_coral": { ++ "kind": "BLOCK", ++ "block": "minecraft:brain_coral" ++ }, ++ "minecraft:brain_coral_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:brain_coral_block" ++ }, ++ "minecraft:brain_coral_fan": { ++ "kind": "BLOCK", ++ "block": "minecraft:brain_coral_fan" ++ }, ++ "minecraft:brain_coral_wall_fan": { ++ "kind": "BLOCK", ++ "block": "minecraft:brain_coral_wall_fan" ++ }, ++ "minecraft:brick_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:brick_block" ++ }, ++ "minecraft:brick_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:brick_double_slab" ++ }, ++ "minecraft:brick_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:brick_slab" ++ }, ++ "minecraft:brick_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:brick_stairs" ++ }, ++ "minecraft:brick_wall": { ++ "kind": "BLOCK", ++ "block": "minecraft:brick_wall" ++ }, ++ "minecraft:brown_candle": { ++ "kind": "BLOCK", ++ "block": "minecraft:brown_candle" ++ }, ++ "minecraft:brown_candle_cake": { ++ "kind": "BLOCK", ++ "block": "minecraft:brown_candle_cake" ++ }, ++ "minecraft:brown_carpet": { ++ "kind": "WOOL", ++ "block": "minecraft:brown_carpet", ++ "fallback": "tile.carpet.name" ++ }, ++ "minecraft:brown_concrete": { ++ "kind": "BLOCK", ++ "block": "minecraft:brown_concrete" ++ }, ++ "minecraft:brown_concrete_powder": { ++ "kind": "BLOCK", ++ "block": "minecraft:brown_concrete_powder" ++ }, ++ "minecraft:brown_glazed_terracotta": { ++ "kind": "BLOCK", ++ "block": "minecraft:brown_glazed_terracotta" ++ }, ++ "minecraft:brown_mushroom": { ++ "kind": "BLOCK", ++ "block": "minecraft:brown_mushroom" ++ }, ++ "minecraft:brown_mushroom_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:brown_mushroom_block" ++ }, ++ "minecraft:brown_shulker_box": { ++ "kind": "BLOCK", ++ "block": "minecraft:brown_shulker_box" ++ }, ++ "minecraft:brown_stained_glass": { ++ "kind": "BLOCK", ++ "block": "minecraft:brown_stained_glass" ++ }, ++ "minecraft:brown_stained_glass_pane": { ++ "kind": "BLOCK", ++ "block": "minecraft:brown_stained_glass_pane" ++ }, ++ "minecraft:brown_terracotta": { ++ "kind": "BLOCK", ++ "block": "minecraft:brown_terracotta" ++ }, ++ "minecraft:brown_wool": { ++ "kind": "WOOL", ++ "block": "minecraft:brown_wool", ++ "fallback": "tile.wool.name" ++ }, ++ "minecraft:bubble_column": { ++ "kind": "BLOCK", ++ "block": "minecraft:bubble_column" ++ }, ++ "minecraft:bubble_coral": { ++ "kind": "BLOCK", ++ "block": "minecraft:bubble_coral" ++ }, ++ "minecraft:bubble_coral_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:bubble_coral_block" ++ }, ++ "minecraft:bubble_coral_fan": { ++ "kind": "BLOCK", ++ "block": "minecraft:bubble_coral_fan" ++ }, ++ "minecraft:bubble_coral_wall_fan": { ++ "kind": "BLOCK", ++ "block": "minecraft:bubble_coral_wall_fan" ++ }, ++ "minecraft:budding_amethyst": { ++ "kind": "BLOCK", ++ "block": "minecraft:budding_amethyst" ++ }, ++ "minecraft:bush": { ++ "kind": "BLOCK", ++ "block": "minecraft:bush" ++ }, ++ "minecraft:cactus": { ++ "kind": "BLOCK", ++ "block": "minecraft:cactus" ++ }, ++ "minecraft:cactus_flower": { ++ "kind": "BLOCK", ++ "block": "minecraft:cactus_flower" ++ }, ++ "minecraft:calcite": { ++ "kind": "BLOCK", ++ "block": "minecraft:calcite" ++ }, ++ "minecraft:calibrated_sculk_sensor": { ++ "kind": "BLOCK", ++ "block": "minecraft:calibrated_sculk_sensor" ++ }, ++ "minecraft:candle": { ++ "kind": "BLOCK", ++ "block": "minecraft:candle" ++ }, ++ "minecraft:candle_cake": { ++ "kind": "BLOCK", ++ "block": "minecraft:candle_cake" ++ }, ++ "minecraft:carrots": { ++ "kind": "BLOCK", ++ "block": "minecraft:carrots" ++ }, ++ "minecraft:cartography_table": { ++ "kind": "BLOCK", ++ "block": "minecraft:cartography_table" ++ }, ++ "minecraft:carved_pumpkin": { ++ "kind": "BLOCK", ++ "block": "minecraft:carved_pumpkin" ++ }, ++ "minecraft:cave_vines": { ++ "kind": "BLOCK", ++ "block": "minecraft:cave_vines" ++ }, ++ "minecraft:cave_vines_body_with_berries": { ++ "kind": "BLOCK", ++ "block": "minecraft:cave_vines_body_with_berries" ++ }, ++ "minecraft:cave_vines_head_with_berries": { ++ "kind": "BLOCK", ++ "block": "minecraft:cave_vines_head_with_berries" ++ }, ++ "minecraft:chain_command_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:chain_command_block" ++ }, ++ "minecraft:chalkboard": { ++ "kind": "BLOCK", ++ "block": "minecraft:chalkboard" ++ }, ++ "minecraft:chemical_heat": { ++ "kind": "BLOCK", ++ "block": "minecraft:chemical_heat" ++ }, ++ "minecraft:cherry_button": { ++ "kind": "BLOCK", ++ "block": "minecraft:cherry_button" ++ }, ++ "minecraft:cherry_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:cherry_double_slab" ++ }, ++ "minecraft:cherry_fence": { ++ "kind": "BLOCK", ++ "block": "minecraft:cherry_fence" ++ }, ++ "minecraft:cherry_fence_gate": { ++ "kind": "BLOCK", ++ "block": "minecraft:cherry_fence_gate" ++ }, ++ "minecraft:cherry_leaves": { ++ "kind": "BLOCK", ++ "block": "minecraft:cherry_leaves" ++ }, ++ "minecraft:cherry_log": { ++ "kind": "BLOCK", ++ "block": "minecraft:cherry_log" ++ }, ++ "minecraft:cherry_planks": { ++ "kind": "BLOCK", ++ "block": "minecraft:cherry_planks" ++ }, ++ "minecraft:cherry_pressure_plate": { ++ "kind": "BLOCK", ++ "block": "minecraft:cherry_pressure_plate" ++ }, ++ "minecraft:cherry_sapling": { ++ "kind": "BLOCK", ++ "block": "minecraft:cherry_sapling" ++ }, ++ "minecraft:cherry_shelf": { ++ "kind": "BLOCK", ++ "block": "minecraft:cherry_shelf" ++ }, ++ "minecraft:cherry_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:cherry_slab" ++ }, ++ "minecraft:cherry_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:cherry_stairs" ++ }, ++ "minecraft:cherry_standing_sign": { ++ "kind": "BLOCK", ++ "block": "minecraft:cherry_standing_sign" ++ }, ++ "minecraft:cherry_trapdoor": { ++ "kind": "BLOCK", ++ "block": "minecraft:cherry_trapdoor" ++ }, ++ "minecraft:cherry_wall_sign": { ++ "kind": "BLOCK", ++ "block": "minecraft:cherry_wall_sign" ++ }, ++ "minecraft:cherry_wood": { ++ "kind": "BLOCK", ++ "block": "minecraft:cherry_wood" ++ }, ++ "minecraft:chest": { ++ "kind": "BLOCK", ++ "block": "minecraft:chest" ++ }, ++ "minecraft:chipped_anvil": { ++ "kind": "BLOCK", ++ "block": "minecraft:chipped_anvil" ++ }, ++ "minecraft:chiseled_bookshelf": { ++ "kind": "BLOCK", ++ "block": "minecraft:chiseled_bookshelf" ++ }, ++ "minecraft:chiseled_cinnabar": { ++ "kind": "BLOCK", ++ "block": "minecraft:chiseled_cinnabar" ++ }, ++ "minecraft:chiseled_copper": { ++ "kind": "BLOCK", ++ "block": "minecraft:chiseled_copper" ++ }, ++ "minecraft:chiseled_deepslate": { ++ "kind": "BLOCK", ++ "block": "minecraft:chiseled_deepslate" ++ }, ++ "minecraft:chiseled_nether_bricks": { ++ "kind": "BLOCK", ++ "block": "minecraft:chiseled_nether_bricks" ++ }, ++ "minecraft:chiseled_polished_blackstone": { ++ "kind": "BLOCK", ++ "block": "minecraft:chiseled_polished_blackstone" ++ }, ++ "minecraft:chiseled_quartz_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:chiseled_quartz_block" ++ }, ++ "minecraft:chiseled_red_sandstone": { ++ "kind": "BLOCK", ++ "block": "minecraft:chiseled_red_sandstone" ++ }, ++ "minecraft:chiseled_resin_bricks": { ++ "kind": "BLOCK", ++ "block": "minecraft:chiseled_resin_bricks" ++ }, ++ "minecraft:chiseled_sandstone": { ++ "kind": "BLOCK", ++ "block": "minecraft:chiseled_sandstone" ++ }, ++ "minecraft:chiseled_stone_bricks": { ++ "kind": "BLOCK", ++ "block": "minecraft:chiseled_stone_bricks" ++ }, ++ "minecraft:chiseled_sulfur": { ++ "kind": "BLOCK", ++ "block": "minecraft:chiseled_sulfur" ++ }, ++ "minecraft:chiseled_tuff": { ++ "kind": "BLOCK", ++ "block": "minecraft:chiseled_tuff" ++ }, ++ "minecraft:chiseled_tuff_bricks": { ++ "kind": "BLOCK", ++ "block": "minecraft:chiseled_tuff_bricks" ++ }, ++ "minecraft:chorus_flower": { ++ "kind": "BLOCK", ++ "block": "minecraft:chorus_flower" ++ }, ++ "minecraft:chorus_plant": { ++ "kind": "BLOCK", ++ "block": "minecraft:chorus_plant" ++ }, ++ "minecraft:cinnabar": { ++ "kind": "BLOCK", ++ "block": "minecraft:cinnabar" ++ }, ++ "minecraft:cinnabar_brick_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:cinnabar_brick_double_slab" ++ }, ++ "minecraft:cinnabar_brick_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:cinnabar_brick_slab" ++ }, ++ "minecraft:cinnabar_brick_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:cinnabar_brick_stairs" ++ }, ++ "minecraft:cinnabar_brick_wall": { ++ "kind": "BLOCK", ++ "block": "minecraft:cinnabar_brick_wall" ++ }, ++ "minecraft:cinnabar_bricks": { ++ "kind": "BLOCK", ++ "block": "minecraft:cinnabar_bricks" ++ }, ++ "minecraft:cinnabar_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:cinnabar_double_slab" ++ }, ++ "minecraft:cinnabar_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:cinnabar_slab" ++ }, ++ "minecraft:cinnabar_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:cinnabar_stairs" ++ }, ++ "minecraft:cinnabar_wall": { ++ "kind": "BLOCK", ++ "block": "minecraft:cinnabar_wall" ++ }, ++ "minecraft:clay": { ++ "kind": "BLOCK", ++ "block": "minecraft:clay" ++ }, ++ "minecraft:client_request_placeholder_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:client_request_placeholder_block" ++ }, ++ "minecraft:closed_eyeblossom": { ++ "kind": "BLOCK", ++ "block": "minecraft:closed_eyeblossom" ++ }, ++ "minecraft:coal_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:coal_block" ++ }, ++ "minecraft:coal_ore": { ++ "kind": "BLOCK", ++ "block": "minecraft:coal_ore" ++ }, ++ "minecraft:coarse_dirt": { ++ "kind": "BLOCK", ++ "block": "minecraft:coarse_dirt" ++ }, ++ "minecraft:cobbled_deepslate": { ++ "kind": "BLOCK", ++ "block": "minecraft:cobbled_deepslate" ++ }, ++ "minecraft:cobbled_deepslate_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:cobbled_deepslate_double_slab" ++ }, ++ "minecraft:cobbled_deepslate_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:cobbled_deepslate_slab" ++ }, ++ "minecraft:cobbled_deepslate_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:cobbled_deepslate_stairs" ++ }, ++ "minecraft:cobbled_deepslate_wall": { ++ "kind": "BLOCK", ++ "block": "minecraft:cobbled_deepslate_wall" ++ }, ++ "minecraft:cobblestone": { ++ "kind": "BLOCK", ++ "block": "minecraft:cobblestone" ++ }, ++ "minecraft:cobblestone_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:cobblestone_double_slab" ++ }, ++ "minecraft:cobblestone_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:cobblestone_slab" ++ }, ++ "minecraft:cobblestone_wall": { ++ "kind": "BLOCK", ++ "block": "minecraft:cobblestone_wall" ++ }, ++ "minecraft:cocoa": { ++ "kind": "BLOCK", ++ "block": "minecraft:cocoa" ++ }, ++ "minecraft:colored_torch_blue": { ++ "kind": "BLOCK", ++ "block": "minecraft:colored_torch_blue" ++ }, ++ "minecraft:colored_torch_green": { ++ "kind": "BLOCK", ++ "block": "minecraft:colored_torch_green" ++ }, ++ "minecraft:colored_torch_purple": { ++ "kind": "BLOCK", ++ "block": "minecraft:colored_torch_purple" ++ }, ++ "minecraft:colored_torch_red": { ++ "kind": "BLOCK", ++ "block": "minecraft:colored_torch_red" ++ }, ++ "minecraft:command_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:command_block" ++ }, ++ "minecraft:composter": { ++ "kind": "BLOCK", ++ "block": "minecraft:composter" ++ }, ++ "minecraft:compound_creator": { ++ "kind": "BLOCK", ++ "block": "minecraft:compound_creator" ++ }, ++ "minecraft:conduit": { ++ "kind": "BLOCK", ++ "block": "minecraft:conduit" ++ }, ++ "minecraft:copper_bars": { ++ "kind": "BLOCK", ++ "block": "minecraft:copper_bars" ++ }, ++ "minecraft:copper_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:copper_block" ++ }, ++ "minecraft:copper_bulb": { ++ "kind": "BLOCK", ++ "block": "minecraft:copper_bulb" ++ }, ++ "minecraft:copper_chain": { ++ "kind": "BLOCK", ++ "block": "minecraft:copper_chain" ++ }, ++ "minecraft:copper_chest": { ++ "kind": "BLOCK", ++ "block": "minecraft:copper_chest" ++ }, ++ "minecraft:copper_golem_statue": { ++ "kind": "BLOCK", ++ "block": "minecraft:copper_golem_statue" ++ }, ++ "minecraft:copper_grate": { ++ "kind": "BLOCK", ++ "block": "minecraft:copper_grate" ++ }, ++ "minecraft:copper_lantern": { ++ "kind": "BLOCK", ++ "block": "minecraft:copper_lantern" ++ }, ++ "minecraft:copper_ore": { ++ "kind": "BLOCK", ++ "block": "minecraft:copper_ore" ++ }, ++ "minecraft:copper_torch": { ++ "kind": "BLOCK", ++ "block": "minecraft:copper_torch" ++ }, ++ "minecraft:copper_trapdoor": { ++ "kind": "BLOCK", ++ "block": "minecraft:copper_trapdoor" ++ }, ++ "minecraft:cornflower": { ++ "kind": "BLOCK", ++ "block": "minecraft:cornflower" ++ }, ++ "minecraft:cracked_deepslate_bricks": { ++ "kind": "BLOCK", ++ "block": "minecraft:cracked_deepslate_bricks" ++ }, ++ "minecraft:cracked_deepslate_tiles": { ++ "kind": "BLOCK", ++ "block": "minecraft:cracked_deepslate_tiles" ++ }, ++ "minecraft:cracked_nether_bricks": { ++ "kind": "BLOCK", ++ "block": "minecraft:cracked_nether_bricks" ++ }, ++ "minecraft:cracked_polished_blackstone_bricks": { ++ "kind": "BLOCK", ++ "block": "minecraft:cracked_polished_blackstone_bricks" ++ }, ++ "minecraft:cracked_stone_bricks": { ++ "kind": "BLOCK", ++ "block": "minecraft:cracked_stone_bricks" ++ }, ++ "minecraft:crafter": { ++ "kind": "BLOCK", ++ "block": "minecraft:crafter" ++ }, ++ "minecraft:crafting_table": { ++ "kind": "BLOCK", ++ "block": "minecraft:crafting_table" ++ }, ++ "minecraft:creaking_heart": { ++ "kind": "BLOCK", ++ "block": "minecraft:creaking_heart" ++ }, ++ "minecraft:creeper_head": { ++ "kind": "BLOCK", ++ "block": "minecraft:creeper_head" ++ }, ++ "minecraft:crimson_button": { ++ "kind": "BLOCK", ++ "block": "minecraft:crimson_button" ++ }, ++ "minecraft:crimson_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:crimson_double_slab" ++ }, ++ "minecraft:crimson_fence": { ++ "kind": "BLOCK", ++ "block": "minecraft:crimson_fence" ++ }, ++ "minecraft:crimson_fence_gate": { ++ "kind": "BLOCK", ++ "block": "minecraft:crimson_fence_gate" ++ }, ++ "minecraft:crimson_fungus": { ++ "kind": "BLOCK", ++ "block": "minecraft:crimson_fungus" ++ }, ++ "minecraft:crimson_hyphae": { ++ "kind": "BLOCK", ++ "block": "minecraft:crimson_hyphae" ++ }, ++ "minecraft:crimson_nylium": { ++ "kind": "BLOCK", ++ "block": "minecraft:crimson_nylium" ++ }, ++ "minecraft:crimson_planks": { ++ "kind": "BLOCK", ++ "block": "minecraft:crimson_planks" ++ }, ++ "minecraft:crimson_pressure_plate": { ++ "kind": "BLOCK", ++ "block": "minecraft:crimson_pressure_plate" ++ }, ++ "minecraft:crimson_roots": { ++ "kind": "BLOCK", ++ "block": "minecraft:crimson_roots" ++ }, ++ "minecraft:crimson_shelf": { ++ "kind": "BLOCK", ++ "block": "minecraft:crimson_shelf" ++ }, ++ "minecraft:crimson_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:crimson_slab" ++ }, ++ "minecraft:crimson_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:crimson_stairs" ++ }, ++ "minecraft:crimson_standing_sign": { ++ "kind": "BLOCK", ++ "block": "minecraft:crimson_standing_sign" ++ }, ++ "minecraft:crimson_stem": { ++ "kind": "BLOCK", ++ "block": "minecraft:crimson_stem" ++ }, ++ "minecraft:crimson_trapdoor": { ++ "kind": "BLOCK", ++ "block": "minecraft:crimson_trapdoor" ++ }, ++ "minecraft:crimson_wall_sign": { ++ "kind": "BLOCK", ++ "block": "minecraft:crimson_wall_sign" ++ }, ++ "minecraft:crying_obsidian": { ++ "kind": "BLOCK", ++ "block": "minecraft:crying_obsidian" ++ }, ++ "minecraft:cut_copper": { ++ "kind": "BLOCK", ++ "block": "minecraft:cut_copper" ++ }, ++ "minecraft:cut_copper_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:cut_copper_slab" ++ }, ++ "minecraft:cut_copper_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:cut_copper_stairs" ++ }, ++ "minecraft:cut_red_sandstone": { ++ "kind": "BLOCK", ++ "block": "minecraft:cut_red_sandstone" ++ }, ++ "minecraft:cut_red_sandstone_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:cut_red_sandstone_double_slab" ++ }, ++ "minecraft:cut_red_sandstone_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:cut_red_sandstone_slab" ++ }, ++ "minecraft:cut_sandstone": { ++ "kind": "BLOCK", ++ "block": "minecraft:cut_sandstone" ++ }, ++ "minecraft:cut_sandstone_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:cut_sandstone_double_slab" ++ }, ++ "minecraft:cut_sandstone_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:cut_sandstone_slab" ++ }, ++ "minecraft:cyan_candle": { ++ "kind": "BLOCK", ++ "block": "minecraft:cyan_candle" ++ }, ++ "minecraft:cyan_candle_cake": { ++ "kind": "BLOCK", ++ "block": "minecraft:cyan_candle_cake" ++ }, ++ "minecraft:cyan_carpet": { ++ "kind": "WOOL", ++ "block": "minecraft:cyan_carpet", ++ "fallback": "tile.carpet.name" ++ }, ++ "minecraft:cyan_concrete": { ++ "kind": "BLOCK", ++ "block": "minecraft:cyan_concrete" ++ }, ++ "minecraft:cyan_concrete_powder": { ++ "kind": "BLOCK", ++ "block": "minecraft:cyan_concrete_powder" ++ }, ++ "minecraft:cyan_glazed_terracotta": { ++ "kind": "BLOCK", ++ "block": "minecraft:cyan_glazed_terracotta" ++ }, ++ "minecraft:cyan_shulker_box": { ++ "kind": "BLOCK", ++ "block": "minecraft:cyan_shulker_box" ++ }, ++ "minecraft:cyan_stained_glass": { ++ "kind": "BLOCK", ++ "block": "minecraft:cyan_stained_glass" ++ }, ++ "minecraft:cyan_stained_glass_pane": { ++ "kind": "BLOCK", ++ "block": "minecraft:cyan_stained_glass_pane" ++ }, ++ "minecraft:cyan_terracotta": { ++ "kind": "BLOCK", ++ "block": "minecraft:cyan_terracotta" ++ }, ++ "minecraft:cyan_wool": { ++ "kind": "WOOL", ++ "block": "minecraft:cyan_wool", ++ "fallback": "tile.wool.name" ++ }, ++ "minecraft:damaged_anvil": { ++ "kind": "BLOCK", ++ "block": "minecraft:damaged_anvil" ++ }, ++ "minecraft:dandelion": { ++ "kind": "BLOCK", ++ "block": "minecraft:dandelion" ++ }, ++ "minecraft:dark_oak_button": { ++ "kind": "BLOCK", ++ "block": "minecraft:dark_oak_button" ++ }, ++ "minecraft:dark_oak_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:dark_oak_double_slab" ++ }, ++ "minecraft:dark_oak_fence": { ++ "kind": "BLOCK", ++ "block": "minecraft:dark_oak_fence" ++ }, ++ "minecraft:dark_oak_fence_gate": { ++ "kind": "BLOCK", ++ "block": "minecraft:dark_oak_fence_gate" ++ }, ++ "minecraft:dark_oak_leaves": { ++ "kind": "BLOCK", ++ "block": "minecraft:dark_oak_leaves" ++ }, ++ "minecraft:dark_oak_log": { ++ "kind": "BLOCK", ++ "block": "minecraft:dark_oak_log" ++ }, ++ "minecraft:dark_oak_planks": { ++ "kind": "BLOCK", ++ "block": "minecraft:dark_oak_planks" ++ }, ++ "minecraft:dark_oak_pressure_plate": { ++ "kind": "BLOCK", ++ "block": "minecraft:dark_oak_pressure_plate" ++ }, ++ "minecraft:dark_oak_sapling": { ++ "kind": "SAPLING", ++ "block": "minecraft:dark_oak_sapling", ++ "description": "item.dark_oak_sapling" ++ }, ++ "minecraft:dark_oak_shelf": { ++ "kind": "BLOCK", ++ "block": "minecraft:dark_oak_shelf" ++ }, ++ "minecraft:dark_oak_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:dark_oak_slab" ++ }, ++ "minecraft:dark_oak_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:dark_oak_stairs" ++ }, ++ "minecraft:dark_oak_trapdoor": { ++ "kind": "BLOCK", ++ "block": "minecraft:dark_oak_trapdoor" ++ }, ++ "minecraft:dark_oak_wood": { ++ "kind": "BLOCK", ++ "block": "minecraft:dark_oak_wood" ++ }, ++ "minecraft:dark_prismarine": { ++ "kind": "BLOCK", ++ "block": "minecraft:dark_prismarine" ++ }, ++ "minecraft:dark_prismarine_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:dark_prismarine_double_slab" ++ }, ++ "minecraft:dark_prismarine_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:dark_prismarine_slab" ++ }, ++ "minecraft:dark_prismarine_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:dark_prismarine_stairs" ++ }, ++ "minecraft:darkoak_standing_sign": { ++ "kind": "BLOCK", ++ "block": "minecraft:darkoak_standing_sign" ++ }, ++ "minecraft:darkoak_wall_sign": { ++ "kind": "BLOCK", ++ "block": "minecraft:darkoak_wall_sign" ++ }, ++ "minecraft:daylight_detector": { ++ "kind": "BLOCK", ++ "block": "minecraft:daylight_detector" ++ }, ++ "minecraft:daylight_detector_inverted": { ++ "kind": "BLOCK", ++ "block": "minecraft:daylight_detector_inverted" ++ }, ++ "minecraft:dead_brain_coral": { ++ "kind": "BLOCK", ++ "block": "minecraft:dead_brain_coral" ++ }, ++ "minecraft:dead_brain_coral_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:dead_brain_coral_block" ++ }, ++ "minecraft:dead_brain_coral_fan": { ++ "kind": "BLOCK", ++ "block": "minecraft:dead_brain_coral_fan" ++ }, ++ "minecraft:dead_brain_coral_wall_fan": { ++ "kind": "BLOCK", ++ "block": "minecraft:dead_brain_coral_wall_fan" ++ }, ++ "minecraft:dead_bubble_coral": { ++ "kind": "BLOCK", ++ "block": "minecraft:dead_bubble_coral" ++ }, ++ "minecraft:dead_bubble_coral_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:dead_bubble_coral_block" ++ }, ++ "minecraft:dead_bubble_coral_fan": { ++ "kind": "BLOCK", ++ "block": "minecraft:dead_bubble_coral_fan" ++ }, ++ "minecraft:dead_bubble_coral_wall_fan": { ++ "kind": "BLOCK", ++ "block": "minecraft:dead_bubble_coral_wall_fan" ++ }, ++ "minecraft:dead_fire_coral": { ++ "kind": "BLOCK", ++ "block": "minecraft:dead_fire_coral" ++ }, ++ "minecraft:dead_fire_coral_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:dead_fire_coral_block" ++ }, ++ "minecraft:dead_fire_coral_fan": { ++ "kind": "BLOCK", ++ "block": "minecraft:dead_fire_coral_fan" ++ }, ++ "minecraft:dead_fire_coral_wall_fan": { ++ "kind": "BLOCK", ++ "block": "minecraft:dead_fire_coral_wall_fan" ++ }, ++ "minecraft:dead_horn_coral": { ++ "kind": "BLOCK", ++ "block": "minecraft:dead_horn_coral" ++ }, ++ "minecraft:dead_horn_coral_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:dead_horn_coral_block" ++ }, ++ "minecraft:dead_horn_coral_fan": { ++ "kind": "BLOCK", ++ "block": "minecraft:dead_horn_coral_fan" ++ }, ++ "minecraft:dead_horn_coral_wall_fan": { ++ "kind": "BLOCK", ++ "block": "minecraft:dead_horn_coral_wall_fan" ++ }, ++ "minecraft:dead_tube_coral": { ++ "kind": "BLOCK", ++ "block": "minecraft:dead_tube_coral" ++ }, ++ "minecraft:dead_tube_coral_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:dead_tube_coral_block" ++ }, ++ "minecraft:dead_tube_coral_fan": { ++ "kind": "BLOCK", ++ "block": "minecraft:dead_tube_coral_fan" ++ }, ++ "minecraft:dead_tube_coral_wall_fan": { ++ "kind": "BLOCK", ++ "block": "minecraft:dead_tube_coral_wall_fan" ++ }, ++ "minecraft:deadbush": { ++ "kind": "BLOCK", ++ "block": "minecraft:deadbush" ++ }, ++ "minecraft:decorated_pot": { ++ "kind": "BLOCK", ++ "block": "minecraft:decorated_pot" ++ }, ++ "minecraft:deepslate": { ++ "kind": "BLOCK", ++ "block": "minecraft:deepslate" ++ }, ++ "minecraft:deepslate_brick_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:deepslate_brick_double_slab" ++ }, ++ "minecraft:deepslate_brick_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:deepslate_brick_slab" ++ }, ++ "minecraft:deepslate_brick_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:deepslate_brick_stairs" ++ }, ++ "minecraft:deepslate_brick_wall": { ++ "kind": "BLOCK", ++ "block": "minecraft:deepslate_brick_wall" ++ }, ++ "minecraft:deepslate_bricks": { ++ "kind": "BLOCK", ++ "block": "minecraft:deepslate_bricks" ++ }, ++ "minecraft:deepslate_coal_ore": { ++ "kind": "BLOCK", ++ "block": "minecraft:deepslate_coal_ore" ++ }, ++ "minecraft:deepslate_copper_ore": { ++ "kind": "BLOCK", ++ "block": "minecraft:deepslate_copper_ore" ++ }, ++ "minecraft:deepslate_diamond_ore": { ++ "kind": "BLOCK", ++ "block": "minecraft:deepslate_diamond_ore" ++ }, ++ "minecraft:deepslate_emerald_ore": { ++ "kind": "BLOCK", ++ "block": "minecraft:deepslate_emerald_ore" ++ }, ++ "minecraft:deepslate_gold_ore": { ++ "kind": "BLOCK", ++ "block": "minecraft:deepslate_gold_ore" ++ }, ++ "minecraft:deepslate_iron_ore": { ++ "kind": "BLOCK", ++ "block": "minecraft:deepslate_iron_ore" ++ }, ++ "minecraft:deepslate_lapis_ore": { ++ "kind": "BLOCK", ++ "block": "minecraft:deepslate_lapis_ore" ++ }, ++ "minecraft:deepslate_redstone_ore": { ++ "kind": "BLOCK", ++ "block": "minecraft:deepslate_redstone_ore" ++ }, ++ "minecraft:deepslate_tile_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:deepslate_tile_double_slab" ++ }, ++ "minecraft:deepslate_tile_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:deepslate_tile_slab" ++ }, ++ "minecraft:deepslate_tile_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:deepslate_tile_stairs" ++ }, ++ "minecraft:deepslate_tile_wall": { ++ "kind": "BLOCK", ++ "block": "minecraft:deepslate_tile_wall" ++ }, ++ "minecraft:deepslate_tiles": { ++ "kind": "BLOCK", ++ "block": "minecraft:deepslate_tiles" ++ }, ++ "minecraft:deny": { ++ "kind": "BLOCK", ++ "block": "minecraft:deny" ++ }, ++ "minecraft:deprecated_anvil": { ++ "kind": "BLOCK", ++ "block": "minecraft:deprecated_anvil" ++ }, ++ "minecraft:deprecated_purpur_block_1": { ++ "kind": "BLOCK", ++ "block": "minecraft:deprecated_purpur_block_1" ++ }, ++ "minecraft:deprecated_purpur_block_2": { ++ "kind": "BLOCK", ++ "block": "minecraft:deprecated_purpur_block_2" ++ }, ++ "minecraft:detector_rail": { ++ "kind": "BLOCK", ++ "block": "minecraft:detector_rail" ++ }, ++ "minecraft:diamond_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:diamond_block" ++ }, ++ "minecraft:diamond_ore": { ++ "kind": "BLOCK", ++ "block": "minecraft:diamond_ore" ++ }, ++ "minecraft:diorite": { ++ "kind": "BLOCK", ++ "block": "minecraft:diorite" ++ }, ++ "minecraft:diorite_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:diorite_double_slab" ++ }, ++ "minecraft:diorite_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:diorite_slab" ++ }, ++ "minecraft:diorite_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:diorite_stairs" ++ }, ++ "minecraft:diorite_wall": { ++ "kind": "BLOCK", ++ "block": "minecraft:diorite_wall" ++ }, ++ "minecraft:dirt": { ++ "kind": "BLOCK", ++ "block": "minecraft:dirt" ++ }, ++ "minecraft:dirt_with_roots": { ++ "kind": "BLOCK", ++ "block": "minecraft:dirt_with_roots" ++ }, ++ "minecraft:dispenser": { ++ "kind": "BLOCK", ++ "block": "minecraft:dispenser" ++ }, ++ "minecraft:double_cut_copper_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:double_cut_copper_slab" ++ }, ++ "minecraft:dragon_egg": { ++ "kind": "BLOCK", ++ "block": "minecraft:dragon_egg" ++ }, ++ "minecraft:dragon_head": { ++ "kind": "BLOCK", ++ "block": "minecraft:dragon_head" ++ }, ++ "minecraft:dried_ghast": { ++ "kind": "BLOCK", ++ "block": "minecraft:dried_ghast" ++ }, ++ "minecraft:dried_kelp_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:dried_kelp_block" ++ }, ++ "minecraft:dripstone_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:dripstone_block" ++ }, ++ "minecraft:dropper": { ++ "kind": "BLOCK", ++ "block": "minecraft:dropper" ++ }, ++ "minecraft:element_0": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_0" ++ }, ++ "minecraft:element_1": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_1" ++ }, ++ "minecraft:element_10": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_10" ++ }, ++ "minecraft:element_100": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_100" ++ }, ++ "minecraft:element_101": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_101" ++ }, ++ "minecraft:element_102": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_102" ++ }, ++ "minecraft:element_103": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_103" ++ }, ++ "minecraft:element_104": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_104" ++ }, ++ "minecraft:element_105": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_105" ++ }, ++ "minecraft:element_106": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_106" ++ }, ++ "minecraft:element_107": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_107" ++ }, ++ "minecraft:element_108": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_108" ++ }, ++ "minecraft:element_109": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_109" ++ }, ++ "minecraft:element_11": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_11" ++ }, ++ "minecraft:element_110": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_110" ++ }, ++ "minecraft:element_111": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_111" ++ }, ++ "minecraft:element_112": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_112" ++ }, ++ "minecraft:element_113": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_113" ++ }, ++ "minecraft:element_114": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_114" ++ }, ++ "minecraft:element_115": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_115" ++ }, ++ "minecraft:element_116": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_116" ++ }, ++ "minecraft:element_117": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_117" ++ }, ++ "minecraft:element_118": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_118" ++ }, ++ "minecraft:element_12": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_12" ++ }, ++ "minecraft:element_13": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_13" ++ }, ++ "minecraft:element_14": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_14" ++ }, ++ "minecraft:element_15": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_15" ++ }, ++ "minecraft:element_16": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_16" ++ }, ++ "minecraft:element_17": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_17" ++ }, ++ "minecraft:element_18": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_18" ++ }, ++ "minecraft:element_19": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_19" ++ }, ++ "minecraft:element_2": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_2" ++ }, ++ "minecraft:element_20": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_20" ++ }, ++ "minecraft:element_21": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_21" ++ }, ++ "minecraft:element_22": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_22" ++ }, ++ "minecraft:element_23": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_23" ++ }, ++ "minecraft:element_24": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_24" ++ }, ++ "minecraft:element_25": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_25" ++ }, ++ "minecraft:element_26": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_26" ++ }, ++ "minecraft:element_27": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_27" ++ }, ++ "minecraft:element_28": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_28" ++ }, ++ "minecraft:element_29": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_29" ++ }, ++ "minecraft:element_3": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_3" ++ }, ++ "minecraft:element_30": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_30" ++ }, ++ "minecraft:element_31": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_31" ++ }, ++ "minecraft:element_32": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_32" ++ }, ++ "minecraft:element_33": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_33" ++ }, ++ "minecraft:element_34": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_34" ++ }, ++ "minecraft:element_35": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_35" ++ }, ++ "minecraft:element_36": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_36" ++ }, ++ "minecraft:element_37": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_37" ++ }, ++ "minecraft:element_38": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_38" ++ }, ++ "minecraft:element_39": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_39" ++ }, ++ "minecraft:element_4": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_4" ++ }, ++ "minecraft:element_40": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_40" ++ }, ++ "minecraft:element_41": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_41" ++ }, ++ "minecraft:element_42": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_42" ++ }, ++ "minecraft:element_43": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_43" ++ }, ++ "minecraft:element_44": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_44" ++ }, ++ "minecraft:element_45": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_45" ++ }, ++ "minecraft:element_46": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_46" ++ }, ++ "minecraft:element_47": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_47" ++ }, ++ "minecraft:element_48": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_48" ++ }, ++ "minecraft:element_49": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_49" ++ }, ++ "minecraft:element_5": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_5" ++ }, ++ "minecraft:element_50": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_50" ++ }, ++ "minecraft:element_51": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_51" ++ }, ++ "minecraft:element_52": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_52" ++ }, ++ "minecraft:element_53": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_53" ++ }, ++ "minecraft:element_54": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_54" ++ }, ++ "minecraft:element_55": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_55" ++ }, ++ "minecraft:element_56": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_56" ++ }, ++ "minecraft:element_57": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_57" ++ }, ++ "minecraft:element_58": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_58" ++ }, ++ "minecraft:element_59": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_59" ++ }, ++ "minecraft:element_6": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_6" ++ }, ++ "minecraft:element_60": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_60" ++ }, ++ "minecraft:element_61": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_61" ++ }, ++ "minecraft:element_62": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_62" ++ }, ++ "minecraft:element_63": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_63" ++ }, ++ "minecraft:element_64": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_64" ++ }, ++ "minecraft:element_65": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_65" ++ }, ++ "minecraft:element_66": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_66" ++ }, ++ "minecraft:element_67": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_67" ++ }, ++ "minecraft:element_68": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_68" ++ }, ++ "minecraft:element_69": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_69" ++ }, ++ "minecraft:element_7": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_7" ++ }, ++ "minecraft:element_70": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_70" ++ }, ++ "minecraft:element_71": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_71" ++ }, ++ "minecraft:element_72": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_72" ++ }, ++ "minecraft:element_73": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_73" ++ }, ++ "minecraft:element_74": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_74" ++ }, ++ "minecraft:element_75": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_75" ++ }, ++ "minecraft:element_76": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_76" ++ }, ++ "minecraft:element_77": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_77" ++ }, ++ "minecraft:element_78": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_78" ++ }, ++ "minecraft:element_79": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_79" ++ }, ++ "minecraft:element_8": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_8" ++ }, ++ "minecraft:element_80": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_80" ++ }, ++ "minecraft:element_81": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_81" ++ }, ++ "minecraft:element_82": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_82" ++ }, ++ "minecraft:element_83": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_83" ++ }, ++ "minecraft:element_84": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_84" ++ }, ++ "minecraft:element_85": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_85" ++ }, ++ "minecraft:element_86": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_86" ++ }, ++ "minecraft:element_87": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_87" ++ }, ++ "minecraft:element_88": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_88" ++ }, ++ "minecraft:element_89": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_89" ++ }, ++ "minecraft:element_9": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_9" ++ }, ++ "minecraft:element_90": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_90" ++ }, ++ "minecraft:element_91": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_91" ++ }, ++ "minecraft:element_92": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_92" ++ }, ++ "minecraft:element_93": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_93" ++ }, ++ "minecraft:element_94": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_94" ++ }, ++ "minecraft:element_95": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_95" ++ }, ++ "minecraft:element_96": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_96" ++ }, ++ "minecraft:element_97": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_97" ++ }, ++ "minecraft:element_98": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_98" ++ }, ++ "minecraft:element_99": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_99" ++ }, ++ "minecraft:element_constructor": { ++ "kind": "BLOCK", ++ "block": "minecraft:element_constructor" ++ }, ++ "minecraft:emerald_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:emerald_block" ++ }, ++ "minecraft:emerald_ore": { ++ "kind": "BLOCK", ++ "block": "minecraft:emerald_ore" ++ }, ++ "minecraft:enchanting_table": { ++ "kind": "BLOCK", ++ "block": "minecraft:enchanting_table" ++ }, ++ "minecraft:end_brick_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:end_brick_stairs" ++ }, ++ "minecraft:end_bricks": { ++ "kind": "BLOCK", ++ "block": "minecraft:end_bricks" ++ }, ++ "minecraft:end_gateway": { ++ "kind": "BLOCK", ++ "block": "minecraft:end_gateway" ++ }, ++ "minecraft:end_portal": { ++ "kind": "BLOCK", ++ "block": "minecraft:end_portal" ++ }, ++ "minecraft:end_portal_frame": { ++ "kind": "BLOCK", ++ "block": "minecraft:end_portal_frame" ++ }, ++ "minecraft:end_rod": { ++ "kind": "BLOCK", ++ "block": "minecraft:end_rod" ++ }, ++ "minecraft:end_stone": { ++ "kind": "BLOCK", ++ "block": "minecraft:end_stone" ++ }, ++ "minecraft:end_stone_brick_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:end_stone_brick_double_slab" ++ }, ++ "minecraft:end_stone_brick_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:end_stone_brick_slab" ++ }, ++ "minecraft:end_stone_brick_wall": { ++ "kind": "BLOCK", ++ "block": "minecraft:end_stone_brick_wall" ++ }, ++ "minecraft:ender_chest": { ++ "kind": "BLOCK", ++ "block": "minecraft:ender_chest" ++ }, ++ "minecraft:exposed_chiseled_copper": { ++ "kind": "BLOCK", ++ "block": "minecraft:exposed_chiseled_copper" ++ }, ++ "minecraft:exposed_copper": { ++ "kind": "BLOCK", ++ "block": "minecraft:exposed_copper" ++ }, ++ "minecraft:exposed_copper_bars": { ++ "kind": "BLOCK", ++ "block": "minecraft:exposed_copper_bars" ++ }, ++ "minecraft:exposed_copper_bulb": { ++ "kind": "BLOCK", ++ "block": "minecraft:exposed_copper_bulb" ++ }, ++ "minecraft:exposed_copper_chain": { ++ "kind": "BLOCK", ++ "block": "minecraft:exposed_copper_chain" ++ }, ++ "minecraft:exposed_copper_chest": { ++ "kind": "BLOCK", ++ "block": "minecraft:exposed_copper_chest" ++ }, ++ "minecraft:exposed_copper_golem_statue": { ++ "kind": "BLOCK", ++ "block": "minecraft:exposed_copper_golem_statue" ++ }, ++ "minecraft:exposed_copper_grate": { ++ "kind": "BLOCK", ++ "block": "minecraft:exposed_copper_grate" ++ }, ++ "minecraft:exposed_copper_lantern": { ++ "kind": "BLOCK", ++ "block": "minecraft:exposed_copper_lantern" ++ }, ++ "minecraft:exposed_copper_trapdoor": { ++ "kind": "BLOCK", ++ "block": "minecraft:exposed_copper_trapdoor" ++ }, ++ "minecraft:exposed_cut_copper": { ++ "kind": "BLOCK", ++ "block": "minecraft:exposed_cut_copper" ++ }, ++ "minecraft:exposed_cut_copper_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:exposed_cut_copper_slab" ++ }, ++ "minecraft:exposed_cut_copper_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:exposed_cut_copper_stairs" ++ }, ++ "minecraft:exposed_double_cut_copper_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:exposed_double_cut_copper_slab" ++ }, ++ "minecraft:exposed_lightning_rod": { ++ "kind": "BLOCK", ++ "block": "minecraft:exposed_lightning_rod" ++ }, ++ "minecraft:farmland": { ++ "kind": "BLOCK", ++ "block": "minecraft:farmland" ++ }, ++ "minecraft:fence_gate": { ++ "kind": "BLOCK", ++ "block": "minecraft:fence_gate" ++ }, ++ "minecraft:fern": { ++ "kind": "BLOCK", ++ "block": "minecraft:fern" ++ }, ++ "minecraft:fire": { ++ "kind": "BLOCK", ++ "block": "minecraft:fire" ++ }, ++ "minecraft:fire_coral": { ++ "kind": "BLOCK", ++ "block": "minecraft:fire_coral" ++ }, ++ "minecraft:fire_coral_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:fire_coral_block" ++ }, ++ "minecraft:fire_coral_fan": { ++ "kind": "BLOCK", ++ "block": "minecraft:fire_coral_fan" ++ }, ++ "minecraft:fire_coral_wall_fan": { ++ "kind": "BLOCK", ++ "block": "minecraft:fire_coral_wall_fan" ++ }, ++ "minecraft:firefly_bush": { ++ "kind": "BLOCK", ++ "block": "minecraft:firefly_bush" ++ }, ++ "minecraft:fletching_table": { ++ "kind": "BLOCK", ++ "block": "minecraft:fletching_table" ++ }, ++ "minecraft:flowering_azalea": { ++ "kind": "BLOCK", ++ "block": "minecraft:flowering_azalea" ++ }, ++ "minecraft:flowing_lava": { ++ "kind": "BLOCK", ++ "block": "minecraft:flowing_lava" ++ }, ++ "minecraft:flowing_water": { ++ "kind": "BLOCK", ++ "block": "minecraft:flowing_water" ++ }, ++ "minecraft:frog_spawn": { ++ "kind": "BLOCK", ++ "block": "minecraft:frog_spawn" ++ }, ++ "minecraft:frosted_ice": { ++ "kind": "BLOCK", ++ "block": "minecraft:frosted_ice" ++ }, ++ "minecraft:furnace": { ++ "kind": "BLOCK", ++ "block": "minecraft:furnace" ++ }, ++ "minecraft:gilded_blackstone": { ++ "kind": "BLOCK", ++ "block": "minecraft:gilded_blackstone" ++ }, ++ "minecraft:glass": { ++ "kind": "BLOCK", ++ "block": "minecraft:glass" ++ }, ++ "minecraft:glass_pane": { ++ "kind": "BLOCK", ++ "block": "minecraft:glass_pane" ++ }, ++ "minecraft:glow_lichen": { ++ "kind": "BLOCK", ++ "block": "minecraft:glow_lichen" ++ }, ++ "minecraft:glowingobsidian": { ++ "kind": "BLOCK", ++ "block": "minecraft:glowingobsidian" ++ }, ++ "minecraft:glowstone": { ++ "kind": "BLOCK", ++ "block": "minecraft:glowstone" ++ }, ++ "minecraft:gold_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:gold_block" ++ }, ++ "minecraft:gold_ore": { ++ "kind": "BLOCK", ++ "block": "minecraft:gold_ore" ++ }, ++ "minecraft:golden_dandelion": { ++ "kind": "BLOCK", ++ "block": "minecraft:golden_dandelion" ++ }, ++ "minecraft:golden_rail": { ++ "kind": "BLOCK", ++ "block": "minecraft:golden_rail" ++ }, ++ "minecraft:granite": { ++ "kind": "BLOCK", ++ "block": "minecraft:granite" ++ }, ++ "minecraft:granite_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:granite_double_slab" ++ }, ++ "minecraft:granite_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:granite_slab" ++ }, ++ "minecraft:granite_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:granite_stairs" ++ }, ++ "minecraft:granite_wall": { ++ "kind": "BLOCK", ++ "block": "minecraft:granite_wall" ++ }, ++ "minecraft:grass_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:grass_block" ++ }, ++ "minecraft:grass_path": { ++ "kind": "BLOCK", ++ "block": "minecraft:grass_path" ++ }, ++ "minecraft:gravel": { ++ "kind": "BLOCK", ++ "block": "minecraft:gravel" ++ }, ++ "minecraft:gray_candle": { ++ "kind": "BLOCK", ++ "block": "minecraft:gray_candle" ++ }, ++ "minecraft:gray_candle_cake": { ++ "kind": "BLOCK", ++ "block": "minecraft:gray_candle_cake" ++ }, ++ "minecraft:gray_carpet": { ++ "kind": "WOOL", ++ "block": "minecraft:gray_carpet", ++ "fallback": "tile.carpet.name" ++ }, ++ "minecraft:gray_concrete": { ++ "kind": "BLOCK", ++ "block": "minecraft:gray_concrete" ++ }, ++ "minecraft:gray_concrete_powder": { ++ "kind": "BLOCK", ++ "block": "minecraft:gray_concrete_powder" ++ }, ++ "minecraft:gray_glazed_terracotta": { ++ "kind": "BLOCK", ++ "block": "minecraft:gray_glazed_terracotta" ++ }, ++ "minecraft:gray_shulker_box": { ++ "kind": "BLOCK", ++ "block": "minecraft:gray_shulker_box" ++ }, ++ "minecraft:gray_stained_glass": { ++ "kind": "BLOCK", ++ "block": "minecraft:gray_stained_glass" ++ }, ++ "minecraft:gray_stained_glass_pane": { ++ "kind": "BLOCK", ++ "block": "minecraft:gray_stained_glass_pane" ++ }, ++ "minecraft:gray_terracotta": { ++ "kind": "BLOCK", ++ "block": "minecraft:gray_terracotta" ++ }, ++ "minecraft:gray_wool": { ++ "kind": "WOOL", ++ "block": "minecraft:gray_wool", ++ "fallback": "tile.wool.name" ++ }, ++ "minecraft:green_candle": { ++ "kind": "BLOCK", ++ "block": "minecraft:green_candle" ++ }, ++ "minecraft:green_candle_cake": { ++ "kind": "BLOCK", ++ "block": "minecraft:green_candle_cake" ++ }, ++ "minecraft:green_carpet": { ++ "kind": "WOOL", ++ "block": "minecraft:green_carpet", ++ "fallback": "tile.carpet.name" ++ }, ++ "minecraft:green_concrete": { ++ "kind": "BLOCK", ++ "block": "minecraft:green_concrete" ++ }, ++ "minecraft:green_concrete_powder": { ++ "kind": "BLOCK", ++ "block": "minecraft:green_concrete_powder" ++ }, ++ "minecraft:green_glazed_terracotta": { ++ "kind": "BLOCK", ++ "block": "minecraft:green_glazed_terracotta" ++ }, ++ "minecraft:green_shulker_box": { ++ "kind": "BLOCK", ++ "block": "minecraft:green_shulker_box" ++ }, ++ "minecraft:green_stained_glass": { ++ "kind": "BLOCK", ++ "block": "minecraft:green_stained_glass" ++ }, ++ "minecraft:green_stained_glass_pane": { ++ "kind": "BLOCK", ++ "block": "minecraft:green_stained_glass_pane" ++ }, ++ "minecraft:green_terracotta": { ++ "kind": "BLOCK", ++ "block": "minecraft:green_terracotta" ++ }, ++ "minecraft:green_wool": { ++ "kind": "WOOL", ++ "block": "minecraft:green_wool", ++ "fallback": "tile.wool.name" ++ }, ++ "minecraft:grindstone": { ++ "kind": "BLOCK", ++ "block": "minecraft:grindstone" ++ }, ++ "minecraft:hanging_roots": { ++ "kind": "BLOCK", ++ "block": "minecraft:hanging_roots" ++ }, ++ "minecraft:hard_black_stained_glass": { ++ "kind": "BLOCK", ++ "block": "minecraft:hard_black_stained_glass" ++ }, ++ "minecraft:hard_black_stained_glass_pane": { ++ "kind": "BLOCK", ++ "block": "minecraft:hard_black_stained_glass_pane" ++ }, ++ "minecraft:hard_blue_stained_glass": { ++ "kind": "BLOCK", ++ "block": "minecraft:hard_blue_stained_glass" ++ }, ++ "minecraft:hard_blue_stained_glass_pane": { ++ "kind": "BLOCK", ++ "block": "minecraft:hard_blue_stained_glass_pane" ++ }, ++ "minecraft:hard_brown_stained_glass": { ++ "kind": "BLOCK", ++ "block": "minecraft:hard_brown_stained_glass" ++ }, ++ "minecraft:hard_brown_stained_glass_pane": { ++ "kind": "BLOCK", ++ "block": "minecraft:hard_brown_stained_glass_pane" ++ }, ++ "minecraft:hard_cyan_stained_glass": { ++ "kind": "BLOCK", ++ "block": "minecraft:hard_cyan_stained_glass" ++ }, ++ "minecraft:hard_cyan_stained_glass_pane": { ++ "kind": "BLOCK", ++ "block": "minecraft:hard_cyan_stained_glass_pane" ++ }, ++ "minecraft:hard_glass": { ++ "kind": "BLOCK", ++ "block": "minecraft:hard_glass" ++ }, ++ "minecraft:hard_glass_pane": { ++ "kind": "BLOCK", ++ "block": "minecraft:hard_glass_pane" ++ }, ++ "minecraft:hard_gray_stained_glass": { ++ "kind": "BLOCK", ++ "block": "minecraft:hard_gray_stained_glass" ++ }, ++ "minecraft:hard_gray_stained_glass_pane": { ++ "kind": "BLOCK", ++ "block": "minecraft:hard_gray_stained_glass_pane" ++ }, ++ "minecraft:hard_green_stained_glass": { ++ "kind": "BLOCK", ++ "block": "minecraft:hard_green_stained_glass" ++ }, ++ "minecraft:hard_green_stained_glass_pane": { ++ "kind": "BLOCK", ++ "block": "minecraft:hard_green_stained_glass_pane" ++ }, ++ "minecraft:hard_light_blue_stained_glass": { ++ "kind": "BLOCK", ++ "block": "minecraft:hard_light_blue_stained_glass" ++ }, ++ "minecraft:hard_light_blue_stained_glass_pane": { ++ "kind": "BLOCK", ++ "block": "minecraft:hard_light_blue_stained_glass_pane" ++ }, ++ "minecraft:hard_light_gray_stained_glass": { ++ "kind": "BLOCK", ++ "block": "minecraft:hard_light_gray_stained_glass" ++ }, ++ "minecraft:hard_light_gray_stained_glass_pane": { ++ "kind": "BLOCK", ++ "block": "minecraft:hard_light_gray_stained_glass_pane" ++ }, ++ "minecraft:hard_lime_stained_glass": { ++ "kind": "BLOCK", ++ "block": "minecraft:hard_lime_stained_glass" ++ }, ++ "minecraft:hard_lime_stained_glass_pane": { ++ "kind": "BLOCK", ++ "block": "minecraft:hard_lime_stained_glass_pane" ++ }, ++ "minecraft:hard_magenta_stained_glass": { ++ "kind": "BLOCK", ++ "block": "minecraft:hard_magenta_stained_glass" ++ }, ++ "minecraft:hard_magenta_stained_glass_pane": { ++ "kind": "BLOCK", ++ "block": "minecraft:hard_magenta_stained_glass_pane" ++ }, ++ "minecraft:hard_orange_stained_glass": { ++ "kind": "BLOCK", ++ "block": "minecraft:hard_orange_stained_glass" ++ }, ++ "minecraft:hard_orange_stained_glass_pane": { ++ "kind": "BLOCK", ++ "block": "minecraft:hard_orange_stained_glass_pane" ++ }, ++ "minecraft:hard_pink_stained_glass": { ++ "kind": "BLOCK", ++ "block": "minecraft:hard_pink_stained_glass" ++ }, ++ "minecraft:hard_pink_stained_glass_pane": { ++ "kind": "BLOCK", ++ "block": "minecraft:hard_pink_stained_glass_pane" ++ }, ++ "minecraft:hard_purple_stained_glass": { ++ "kind": "BLOCK", ++ "block": "minecraft:hard_purple_stained_glass" ++ }, ++ "minecraft:hard_purple_stained_glass_pane": { ++ "kind": "BLOCK", ++ "block": "minecraft:hard_purple_stained_glass_pane" ++ }, ++ "minecraft:hard_red_stained_glass": { ++ "kind": "BLOCK", ++ "block": "minecraft:hard_red_stained_glass" ++ }, ++ "minecraft:hard_red_stained_glass_pane": { ++ "kind": "BLOCK", ++ "block": "minecraft:hard_red_stained_glass_pane" ++ }, ++ "minecraft:hard_white_stained_glass": { ++ "kind": "BLOCK", ++ "block": "minecraft:hard_white_stained_glass" ++ }, ++ "minecraft:hard_white_stained_glass_pane": { ++ "kind": "BLOCK", ++ "block": "minecraft:hard_white_stained_glass_pane" ++ }, ++ "minecraft:hard_yellow_stained_glass": { ++ "kind": "BLOCK", ++ "block": "minecraft:hard_yellow_stained_glass" ++ }, ++ "minecraft:hard_yellow_stained_glass_pane": { ++ "kind": "BLOCK", ++ "block": "minecraft:hard_yellow_stained_glass_pane" ++ }, ++ "minecraft:hardened_clay": { ++ "kind": "BLOCK", ++ "block": "minecraft:hardened_clay" ++ }, ++ "minecraft:hay_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:hay_block" ++ }, ++ "minecraft:heavy_core": { ++ "kind": "BLOCK", ++ "block": "minecraft:heavy_core" ++ }, ++ "minecraft:heavy_weighted_pressure_plate": { ++ "kind": "BLOCK", ++ "block": "minecraft:heavy_weighted_pressure_plate" ++ }, ++ "minecraft:honey_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:honey_block" ++ }, ++ "minecraft:honeycomb_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:honeycomb_block" ++ }, ++ "minecraft:horn_coral": { ++ "kind": "BLOCK", ++ "block": "minecraft:horn_coral" ++ }, ++ "minecraft:horn_coral_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:horn_coral_block" ++ }, ++ "minecraft:horn_coral_fan": { ++ "kind": "BLOCK", ++ "block": "minecraft:horn_coral_fan" ++ }, ++ "minecraft:horn_coral_wall_fan": { ++ "kind": "BLOCK", ++ "block": "minecraft:horn_coral_wall_fan" ++ }, ++ "minecraft:ice": { ++ "kind": "BLOCK", ++ "block": "minecraft:ice" ++ }, ++ "minecraft:infested_chiseled_stone_bricks": { ++ "kind": "BLOCK", ++ "block": "minecraft:infested_chiseled_stone_bricks" ++ }, ++ "minecraft:infested_cobblestone": { ++ "kind": "BLOCK", ++ "block": "minecraft:infested_cobblestone" ++ }, ++ "minecraft:infested_cracked_stone_bricks": { ++ "kind": "BLOCK", ++ "block": "minecraft:infested_cracked_stone_bricks" ++ }, ++ "minecraft:infested_deepslate": { ++ "kind": "BLOCK", ++ "block": "minecraft:infested_deepslate" ++ }, ++ "minecraft:infested_mossy_stone_bricks": { ++ "kind": "BLOCK", ++ "block": "minecraft:infested_mossy_stone_bricks" ++ }, ++ "minecraft:infested_stone": { ++ "kind": "BLOCK", ++ "block": "minecraft:infested_stone" ++ }, ++ "minecraft:infested_stone_bricks": { ++ "kind": "BLOCK", ++ "block": "minecraft:infested_stone_bricks" ++ }, ++ "minecraft:info_update": { ++ "kind": "BLOCK", ++ "block": "minecraft:info_update" ++ }, ++ "minecraft:info_update2": { ++ "kind": "BLOCK", ++ "block": "minecraft:info_update2" ++ }, ++ "minecraft:invisible_bedrock": { ++ "kind": "BLOCK", ++ "block": "minecraft:invisible_bedrock" ++ }, ++ "minecraft:iron_bars": { ++ "kind": "BLOCK", ++ "block": "minecraft:iron_bars" ++ }, ++ "minecraft:iron_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:iron_block" ++ }, ++ "minecraft:iron_chain": { ++ "kind": "BLOCK", ++ "block": "minecraft:iron_chain" ++ }, ++ "minecraft:iron_ore": { ++ "kind": "BLOCK", ++ "block": "minecraft:iron_ore" ++ }, ++ "minecraft:iron_trapdoor": { ++ "kind": "BLOCK", ++ "block": "minecraft:iron_trapdoor" ++ }, ++ "minecraft:item.bed": { ++ "kind": "BLOCK", ++ "block": "minecraft:bed" ++ }, ++ "minecraft:item.beetroot": { ++ "kind": "BLOCK", ++ "block": "minecraft:beetroot" ++ }, ++ "minecraft:item.brewing_stand": { ++ "kind": "BLOCK", ++ "block": "minecraft:brewing_stand" ++ }, ++ "minecraft:item.cake": { ++ "kind": "BLOCK", ++ "block": "minecraft:cake" ++ }, ++ "minecraft:item.camera": { ++ "kind": "BLOCK", ++ "block": "minecraft:camera" ++ }, ++ "minecraft:item.campfire": { ++ "kind": "BLOCK", ++ "block": "minecraft:campfire" ++ }, ++ "minecraft:item.cauldron": { ++ "kind": "BLOCK", ++ "block": "minecraft:cauldron" ++ }, ++ "minecraft:item.flower_pot": { ++ "kind": "BLOCK", ++ "block": "minecraft:flower_pot" ++ }, ++ "minecraft:item.frame": { ++ "kind": "BLOCK", ++ "block": "minecraft:frame" ++ }, ++ "minecraft:item.glow_frame": { ++ "kind": "BLOCK", ++ "block": "minecraft:glow_frame" ++ }, ++ "minecraft:item.hopper": { ++ "kind": "BLOCK", ++ "block": "minecraft:hopper" ++ }, ++ "minecraft:item.kelp": { ++ "kind": "BLOCK", ++ "block": "minecraft:kelp" ++ }, ++ "minecraft:item.nether_sprouts": { ++ "kind": "BLOCK", ++ "block": "minecraft:nether_sprouts" ++ }, ++ "minecraft:item.nether_wart": { ++ "kind": "BLOCK", ++ "block": "minecraft:nether_wart" ++ }, ++ "minecraft:item.reeds": { ++ "kind": "BLOCK", ++ "block": "minecraft:reeds" ++ }, ++ "minecraft:item.soul_campfire": { ++ "kind": "BLOCK", ++ "block": "minecraft:soul_campfire" ++ }, ++ "minecraft:item.straw_bed": { ++ "kind": "BLOCK", ++ "block": "minecraft:straw_bed" ++ }, ++ "minecraft:item.wheat": { ++ "kind": "BLOCK", ++ "block": "minecraft:wheat" ++ }, ++ "minecraft:jigsaw": { ++ "kind": "BLOCK", ++ "block": "minecraft:jigsaw" ++ }, ++ "minecraft:jukebox": { ++ "kind": "BLOCK", ++ "block": "minecraft:jukebox" ++ }, ++ "minecraft:jungle_button": { ++ "kind": "BLOCK", ++ "block": "minecraft:jungle_button" ++ }, ++ "minecraft:jungle_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:jungle_double_slab" ++ }, ++ "minecraft:jungle_fence": { ++ "kind": "BLOCK", ++ "block": "minecraft:jungle_fence" ++ }, ++ "minecraft:jungle_fence_gate": { ++ "kind": "BLOCK", ++ "block": "minecraft:jungle_fence_gate" ++ }, ++ "minecraft:jungle_leaves": { ++ "kind": "BLOCK", ++ "block": "minecraft:jungle_leaves" ++ }, ++ "minecraft:jungle_log": { ++ "kind": "BLOCK", ++ "block": "minecraft:jungle_log" ++ }, ++ "minecraft:jungle_planks": { ++ "kind": "BLOCK", ++ "block": "minecraft:jungle_planks" ++ }, ++ "minecraft:jungle_pressure_plate": { ++ "kind": "BLOCK", ++ "block": "minecraft:jungle_pressure_plate" ++ }, ++ "minecraft:jungle_sapling": { ++ "kind": "SAPLING", ++ "block": "minecraft:jungle_sapling", ++ "description": "item.jungle_sapling" ++ }, ++ "minecraft:jungle_shelf": { ++ "kind": "BLOCK", ++ "block": "minecraft:jungle_shelf" ++ }, ++ "minecraft:jungle_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:jungle_slab" ++ }, ++ "minecraft:jungle_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:jungle_stairs" ++ }, ++ "minecraft:jungle_standing_sign": { ++ "kind": "BLOCK", ++ "block": "minecraft:jungle_standing_sign" ++ }, ++ "minecraft:jungle_trapdoor": { ++ "kind": "BLOCK", ++ "block": "minecraft:jungle_trapdoor" ++ }, ++ "minecraft:jungle_wall_sign": { ++ "kind": "BLOCK", ++ "block": "minecraft:jungle_wall_sign" ++ }, ++ "minecraft:jungle_wood": { ++ "kind": "BLOCK", ++ "block": "minecraft:jungle_wood" ++ }, ++ "minecraft:lab_table": { ++ "kind": "BLOCK", ++ "block": "minecraft:lab_table" ++ }, ++ "minecraft:ladder": { ++ "kind": "BLOCK", ++ "block": "minecraft:ladder" ++ }, ++ "minecraft:lantern": { ++ "kind": "BLOCK", ++ "block": "minecraft:lantern" ++ }, ++ "minecraft:lapis_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:lapis_block" ++ }, ++ "minecraft:lapis_ore": { ++ "kind": "BLOCK", ++ "block": "minecraft:lapis_ore" ++ }, ++ "minecraft:large_amethyst_bud": { ++ "kind": "BLOCK", ++ "block": "minecraft:large_amethyst_bud" ++ }, ++ "minecraft:large_fern": { ++ "kind": "BLOCK", ++ "block": "minecraft:large_fern" ++ }, ++ "minecraft:lava": { ++ "kind": "BLOCK", ++ "block": "minecraft:lava" ++ }, ++ "minecraft:leaf_litter": { ++ "kind": "BLOCK", ++ "block": "minecraft:leaf_litter" ++ }, ++ "minecraft:lectern": { ++ "kind": "BLOCK", ++ "block": "minecraft:lectern" ++ }, ++ "minecraft:lever": { ++ "kind": "BLOCK", ++ "block": "minecraft:lever" ++ }, ++ "minecraft:light_block_0": { ++ "kind": "BLOCK", ++ "block": "minecraft:light_block_0" ++ }, ++ "minecraft:light_block_1": { ++ "kind": "BLOCK", ++ "block": "minecraft:light_block_1" ++ }, ++ "minecraft:light_block_10": { ++ "kind": "BLOCK", ++ "block": "minecraft:light_block_10" ++ }, ++ "minecraft:light_block_11": { ++ "kind": "BLOCK", ++ "block": "minecraft:light_block_11" ++ }, ++ "minecraft:light_block_12": { ++ "kind": "BLOCK", ++ "block": "minecraft:light_block_12" ++ }, ++ "minecraft:light_block_13": { ++ "kind": "BLOCK", ++ "block": "minecraft:light_block_13" ++ }, ++ "minecraft:light_block_14": { ++ "kind": "BLOCK", ++ "block": "minecraft:light_block_14" ++ }, ++ "minecraft:light_block_15": { ++ "kind": "BLOCK", ++ "block": "minecraft:light_block_15" ++ }, ++ "minecraft:light_block_2": { ++ "kind": "BLOCK", ++ "block": "minecraft:light_block_2" ++ }, ++ "minecraft:light_block_3": { ++ "kind": "BLOCK", ++ "block": "minecraft:light_block_3" ++ }, ++ "minecraft:light_block_4": { ++ "kind": "BLOCK", ++ "block": "minecraft:light_block_4" ++ }, ++ "minecraft:light_block_5": { ++ "kind": "BLOCK", ++ "block": "minecraft:light_block_5" ++ }, ++ "minecraft:light_block_6": { ++ "kind": "BLOCK", ++ "block": "minecraft:light_block_6" ++ }, ++ "minecraft:light_block_7": { ++ "kind": "BLOCK", ++ "block": "minecraft:light_block_7" ++ }, ++ "minecraft:light_block_8": { ++ "kind": "BLOCK", ++ "block": "minecraft:light_block_8" ++ }, ++ "minecraft:light_block_9": { ++ "kind": "BLOCK", ++ "block": "minecraft:light_block_9" ++ }, ++ "minecraft:light_blue_candle": { ++ "kind": "BLOCK", ++ "block": "minecraft:light_blue_candle" ++ }, ++ "minecraft:light_blue_candle_cake": { ++ "kind": "BLOCK", ++ "block": "minecraft:light_blue_candle_cake" ++ }, ++ "minecraft:light_blue_carpet": { ++ "kind": "WOOL", ++ "block": "minecraft:light_blue_carpet", ++ "fallback": "tile.carpet.name" ++ }, ++ "minecraft:light_blue_concrete": { ++ "kind": "BLOCK", ++ "block": "minecraft:light_blue_concrete" ++ }, ++ "minecraft:light_blue_concrete_powder": { ++ "kind": "BLOCK", ++ "block": "minecraft:light_blue_concrete_powder" ++ }, ++ "minecraft:light_blue_glazed_terracotta": { ++ "kind": "BLOCK", ++ "block": "minecraft:light_blue_glazed_terracotta" ++ }, ++ "minecraft:light_blue_shulker_box": { ++ "kind": "BLOCK", ++ "block": "minecraft:light_blue_shulker_box" ++ }, ++ "minecraft:light_blue_stained_glass": { ++ "kind": "BLOCK", ++ "block": "minecraft:light_blue_stained_glass" ++ }, ++ "minecraft:light_blue_stained_glass_pane": { ++ "kind": "BLOCK", ++ "block": "minecraft:light_blue_stained_glass_pane" ++ }, ++ "minecraft:light_blue_terracotta": { ++ "kind": "BLOCK", ++ "block": "minecraft:light_blue_terracotta" ++ }, ++ "minecraft:light_blue_wool": { ++ "kind": "WOOL", ++ "block": "minecraft:light_blue_wool", ++ "fallback": "tile.wool.name" ++ }, ++ "minecraft:light_gray_candle": { ++ "kind": "BLOCK", ++ "block": "minecraft:light_gray_candle" ++ }, ++ "minecraft:light_gray_candle_cake": { ++ "kind": "BLOCK", ++ "block": "minecraft:light_gray_candle_cake" ++ }, ++ "minecraft:light_gray_carpet": { ++ "kind": "WOOL", ++ "block": "minecraft:light_gray_carpet", ++ "fallback": "tile.carpet.name" ++ }, ++ "minecraft:light_gray_concrete": { ++ "kind": "BLOCK", ++ "block": "minecraft:light_gray_concrete" ++ }, ++ "minecraft:light_gray_concrete_powder": { ++ "kind": "BLOCK", ++ "block": "minecraft:light_gray_concrete_powder" ++ }, ++ "minecraft:light_gray_shulker_box": { ++ "kind": "BLOCK", ++ "block": "minecraft:light_gray_shulker_box" ++ }, ++ "minecraft:light_gray_stained_glass": { ++ "kind": "BLOCK", ++ "block": "minecraft:light_gray_stained_glass" ++ }, ++ "minecraft:light_gray_stained_glass_pane": { ++ "kind": "BLOCK", ++ "block": "minecraft:light_gray_stained_glass_pane" ++ }, ++ "minecraft:light_gray_terracotta": { ++ "kind": "BLOCK", ++ "block": "minecraft:light_gray_terracotta" ++ }, ++ "minecraft:light_gray_wool": { ++ "kind": "WOOL", ++ "block": "minecraft:light_gray_wool", ++ "fallback": "tile.wool.name" ++ }, ++ "minecraft:light_weighted_pressure_plate": { ++ "kind": "BLOCK", ++ "block": "minecraft:light_weighted_pressure_plate" ++ }, ++ "minecraft:lightning_rod": { ++ "kind": "BLOCK", ++ "block": "minecraft:lightning_rod" ++ }, ++ "minecraft:lilac": { ++ "kind": "BLOCK", ++ "block": "minecraft:lilac" ++ }, ++ "minecraft:lily_of_the_valley": { ++ "kind": "BLOCK", ++ "block": "minecraft:lily_of_the_valley" ++ }, ++ "minecraft:lime_candle": { ++ "kind": "BLOCK", ++ "block": "minecraft:lime_candle" ++ }, ++ "minecraft:lime_candle_cake": { ++ "kind": "BLOCK", ++ "block": "minecraft:lime_candle_cake" ++ }, ++ "minecraft:lime_carpet": { ++ "kind": "WOOL", ++ "block": "minecraft:lime_carpet", ++ "fallback": "tile.carpet.name" ++ }, ++ "minecraft:lime_concrete": { ++ "kind": "BLOCK", ++ "block": "minecraft:lime_concrete" ++ }, ++ "minecraft:lime_concrete_powder": { ++ "kind": "BLOCK", ++ "block": "minecraft:lime_concrete_powder" ++ }, ++ "minecraft:lime_glazed_terracotta": { ++ "kind": "BLOCK", ++ "block": "minecraft:lime_glazed_terracotta" ++ }, ++ "minecraft:lime_shulker_box": { ++ "kind": "BLOCK", ++ "block": "minecraft:lime_shulker_box" ++ }, ++ "minecraft:lime_stained_glass": { ++ "kind": "BLOCK", ++ "block": "minecraft:lime_stained_glass" ++ }, ++ "minecraft:lime_stained_glass_pane": { ++ "kind": "BLOCK", ++ "block": "minecraft:lime_stained_glass_pane" ++ }, ++ "minecraft:lime_terracotta": { ++ "kind": "BLOCK", ++ "block": "minecraft:lime_terracotta" ++ }, ++ "minecraft:lime_wool": { ++ "kind": "WOOL", ++ "block": "minecraft:lime_wool", ++ "fallback": "tile.wool.name" ++ }, ++ "minecraft:lit_blast_furnace": { ++ "kind": "BLOCK", ++ "block": "minecraft:lit_blast_furnace" ++ }, ++ "minecraft:lit_deepslate_redstone_ore": { ++ "kind": "BLOCK", ++ "block": "minecraft:lit_deepslate_redstone_ore" ++ }, ++ "minecraft:lit_furnace": { ++ "kind": "BLOCK", ++ "block": "minecraft:lit_furnace" ++ }, ++ "minecraft:lit_pumpkin": { ++ "kind": "BLOCK", ++ "block": "minecraft:lit_pumpkin" ++ }, ++ "minecraft:lit_redstone_lamp": { ++ "kind": "BLOCK", ++ "block": "minecraft:lit_redstone_lamp" ++ }, ++ "minecraft:lit_redstone_ore": { ++ "kind": "BLOCK", ++ "block": "minecraft:lit_redstone_ore" ++ }, ++ "minecraft:lit_smoker": { ++ "kind": "BLOCK", ++ "block": "minecraft:lit_smoker" ++ }, ++ "minecraft:lodestone": { ++ "kind": "BLOCK", ++ "block": "minecraft:lodestone" ++ }, ++ "minecraft:loom": { ++ "kind": "BLOCK", ++ "block": "minecraft:loom" ++ }, ++ "minecraft:magenta_candle": { ++ "kind": "BLOCK", ++ "block": "minecraft:magenta_candle" ++ }, ++ "minecraft:magenta_candle_cake": { ++ "kind": "BLOCK", ++ "block": "minecraft:magenta_candle_cake" ++ }, ++ "minecraft:magenta_carpet": { ++ "kind": "WOOL", ++ "block": "minecraft:magenta_carpet", ++ "fallback": "tile.carpet.name" ++ }, ++ "minecraft:magenta_concrete": { ++ "kind": "BLOCK", ++ "block": "minecraft:magenta_concrete" ++ }, ++ "minecraft:magenta_concrete_powder": { ++ "kind": "BLOCK", ++ "block": "minecraft:magenta_concrete_powder" ++ }, ++ "minecraft:magenta_glazed_terracotta": { ++ "kind": "BLOCK", ++ "block": "minecraft:magenta_glazed_terracotta" ++ }, ++ "minecraft:magenta_shulker_box": { ++ "kind": "BLOCK", ++ "block": "minecraft:magenta_shulker_box" ++ }, ++ "minecraft:magenta_stained_glass": { ++ "kind": "BLOCK", ++ "block": "minecraft:magenta_stained_glass" ++ }, ++ "minecraft:magenta_stained_glass_pane": { ++ "kind": "BLOCK", ++ "block": "minecraft:magenta_stained_glass_pane" ++ }, ++ "minecraft:magenta_terracotta": { ++ "kind": "BLOCK", ++ "block": "minecraft:magenta_terracotta" ++ }, ++ "minecraft:magenta_wool": { ++ "kind": "WOOL", ++ "block": "minecraft:magenta_wool", ++ "fallback": "tile.wool.name" ++ }, ++ "minecraft:magma": { ++ "kind": "BLOCK", ++ "block": "minecraft:magma" ++ }, ++ "minecraft:mangrove_button": { ++ "kind": "BLOCK", ++ "block": "minecraft:mangrove_button" ++ }, ++ "minecraft:mangrove_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:mangrove_double_slab" ++ }, ++ "minecraft:mangrove_fence": { ++ "kind": "BLOCK", ++ "block": "minecraft:mangrove_fence" ++ }, ++ "minecraft:mangrove_fence_gate": { ++ "kind": "BLOCK", ++ "block": "minecraft:mangrove_fence_gate" ++ }, ++ "minecraft:mangrove_leaves": { ++ "kind": "LEAVES", ++ "block": "minecraft:mangrove_leaves", ++ "description": "item.mangrove_leaves" ++ }, ++ "minecraft:mangrove_log": { ++ "kind": "BLOCK", ++ "block": "minecraft:mangrove_log" ++ }, ++ "minecraft:mangrove_planks": { ++ "kind": "BLOCK", ++ "block": "minecraft:mangrove_planks" ++ }, ++ "minecraft:mangrove_pressure_plate": { ++ "kind": "BLOCK", ++ "block": "minecraft:mangrove_pressure_plate" ++ }, ++ "minecraft:mangrove_propagule": { ++ "kind": "BLOCK", ++ "block": "minecraft:mangrove_propagule" ++ }, ++ "minecraft:mangrove_roots": { ++ "kind": "BLOCK", ++ "block": "minecraft:mangrove_roots" ++ }, ++ "minecraft:mangrove_shelf": { ++ "kind": "BLOCK", ++ "block": "minecraft:mangrove_shelf" ++ }, ++ "minecraft:mangrove_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:mangrove_slab" ++ }, ++ "minecraft:mangrove_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:mangrove_stairs" ++ }, ++ "minecraft:mangrove_standing_sign": { ++ "kind": "BLOCK", ++ "block": "minecraft:mangrove_standing_sign" ++ }, ++ "minecraft:mangrove_trapdoor": { ++ "kind": "BLOCK", ++ "block": "minecraft:mangrove_trapdoor" ++ }, ++ "minecraft:mangrove_wall_sign": { ++ "kind": "BLOCK", ++ "block": "minecraft:mangrove_wall_sign" ++ }, ++ "minecraft:mangrove_wood": { ++ "kind": "BLOCK", ++ "block": "minecraft:mangrove_wood" ++ }, ++ "minecraft:material_reducer": { ++ "kind": "BLOCK", ++ "block": "minecraft:material_reducer" ++ }, ++ "minecraft:medium_amethyst_bud": { ++ "kind": "BLOCK", ++ "block": "minecraft:medium_amethyst_bud" ++ }, ++ "minecraft:melon_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:melon_block" ++ }, ++ "minecraft:melon_stem": { ++ "kind": "BLOCK", ++ "block": "minecraft:melon_stem" ++ }, ++ "minecraft:mob_spawner": { ++ "kind": "BLOCK", ++ "block": "minecraft:mob_spawner" ++ }, ++ "minecraft:moss_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:moss_block" ++ }, ++ "minecraft:moss_carpet": { ++ "kind": "BLOCK", ++ "block": "minecraft:moss_carpet" ++ }, ++ "minecraft:mossy_cobblestone": { ++ "kind": "BLOCK", ++ "block": "minecraft:mossy_cobblestone" ++ }, ++ "minecraft:mossy_cobblestone_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:mossy_cobblestone_double_slab" ++ }, ++ "minecraft:mossy_cobblestone_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:mossy_cobblestone_slab" ++ }, ++ "minecraft:mossy_cobblestone_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:mossy_cobblestone_stairs" ++ }, ++ "minecraft:mossy_cobblestone_wall": { ++ "kind": "BLOCK", ++ "block": "minecraft:mossy_cobblestone_wall" ++ }, ++ "minecraft:mossy_stone_brick_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:mossy_stone_brick_double_slab" ++ }, ++ "minecraft:mossy_stone_brick_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:mossy_stone_brick_slab" ++ }, ++ "minecraft:mossy_stone_brick_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:mossy_stone_brick_stairs" ++ }, ++ "minecraft:mossy_stone_brick_wall": { ++ "kind": "BLOCK", ++ "block": "minecraft:mossy_stone_brick_wall" ++ }, ++ "minecraft:mossy_stone_bricks": { ++ "kind": "BLOCK", ++ "block": "minecraft:mossy_stone_bricks" ++ }, ++ "minecraft:moving_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:moving_block" ++ }, ++ "minecraft:mud": { ++ "kind": "BLOCK", ++ "block": "minecraft:mud" ++ }, ++ "minecraft:mud_brick_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:mud_brick_double_slab" ++ }, ++ "minecraft:mud_brick_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:mud_brick_slab" ++ }, ++ "minecraft:mud_brick_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:mud_brick_stairs" ++ }, ++ "minecraft:mud_brick_wall": { ++ "kind": "BLOCK", ++ "block": "minecraft:mud_brick_wall" ++ }, ++ "minecraft:mud_bricks": { ++ "kind": "BLOCK", ++ "block": "minecraft:mud_bricks" ++ }, ++ "minecraft:muddy_mangrove_roots": { ++ "kind": "BLOCK", ++ "block": "minecraft:muddy_mangrove_roots" ++ }, ++ "minecraft:mushroom_stem": { ++ "kind": "BLOCK", ++ "block": "minecraft:mushroom_stem" ++ }, ++ "minecraft:mycelium": { ++ "kind": "BLOCK", ++ "block": "minecraft:mycelium" ++ }, ++ "minecraft:nether_brick": { ++ "kind": "BLOCK", ++ "block": "minecraft:nether_brick" ++ }, ++ "minecraft:nether_brick_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:nether_brick_double_slab" ++ }, ++ "minecraft:nether_brick_fence": { ++ "kind": "BLOCK", ++ "block": "minecraft:nether_brick_fence" ++ }, ++ "minecraft:nether_brick_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:nether_brick_slab" ++ }, ++ "minecraft:nether_brick_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:nether_brick_stairs" ++ }, ++ "minecraft:nether_brick_wall": { ++ "kind": "BLOCK", ++ "block": "minecraft:nether_brick_wall" ++ }, ++ "minecraft:nether_gold_ore": { ++ "kind": "BLOCK", ++ "block": "minecraft:nether_gold_ore" ++ }, ++ "minecraft:nether_wart_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:nether_wart_block" ++ }, ++ "minecraft:netherite_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:netherite_block" ++ }, ++ "minecraft:netherrack": { ++ "kind": "BLOCK", ++ "block": "minecraft:netherrack" ++ }, ++ "minecraft:netherreactor": { ++ "kind": "BLOCK", ++ "block": "minecraft:netherreactor" ++ }, ++ "minecraft:normal_stone_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:normal_stone_double_slab" ++ }, ++ "minecraft:normal_stone_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:normal_stone_slab" ++ }, ++ "minecraft:normal_stone_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:normal_stone_stairs" ++ }, ++ "minecraft:noteblock": { ++ "kind": "BLOCK", ++ "block": "minecraft:noteblock" ++ }, ++ "minecraft:oak_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:oak_double_slab" ++ }, ++ "minecraft:oak_fence": { ++ "kind": "BLOCK", ++ "block": "minecraft:oak_fence" ++ }, ++ "minecraft:oak_leaves": { ++ "kind": "BLOCK", ++ "block": "minecraft:oak_leaves" ++ }, ++ "minecraft:oak_log": { ++ "kind": "BLOCK", ++ "block": "minecraft:oak_log" ++ }, ++ "minecraft:oak_planks": { ++ "kind": "BLOCK", ++ "block": "minecraft:oak_planks" ++ }, ++ "minecraft:oak_sapling": { ++ "kind": "SAPLING", ++ "block": "minecraft:oak_sapling", ++ "description": "item.oak_sapling" ++ }, ++ "minecraft:oak_shelf": { ++ "kind": "BLOCK", ++ "block": "minecraft:oak_shelf" ++ }, ++ "minecraft:oak_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:oak_slab" ++ }, ++ "minecraft:oak_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:oak_stairs" ++ }, ++ "minecraft:oak_wood": { ++ "kind": "BLOCK", ++ "block": "minecraft:oak_wood" ++ }, ++ "minecraft:observer": { ++ "kind": "BLOCK", ++ "block": "minecraft:observer" ++ }, ++ "minecraft:obsidian": { ++ "kind": "BLOCK", ++ "block": "minecraft:obsidian" ++ }, ++ "minecraft:ochre_froglight": { ++ "kind": "BLOCK", ++ "block": "minecraft:ochre_froglight" ++ }, ++ "minecraft:open_eyeblossom": { ++ "kind": "BLOCK", ++ "block": "minecraft:open_eyeblossom" ++ }, ++ "minecraft:orange_candle": { ++ "kind": "BLOCK", ++ "block": "minecraft:orange_candle" ++ }, ++ "minecraft:orange_candle_cake": { ++ "kind": "BLOCK", ++ "block": "minecraft:orange_candle_cake" ++ }, ++ "minecraft:orange_carpet": { ++ "kind": "WOOL", ++ "block": "minecraft:orange_carpet", ++ "fallback": "tile.carpet.name" ++ }, ++ "minecraft:orange_concrete": { ++ "kind": "BLOCK", ++ "block": "minecraft:orange_concrete" ++ }, ++ "minecraft:orange_concrete_powder": { ++ "kind": "BLOCK", ++ "block": "minecraft:orange_concrete_powder" ++ }, ++ "minecraft:orange_glazed_terracotta": { ++ "kind": "BLOCK", ++ "block": "minecraft:orange_glazed_terracotta" ++ }, ++ "minecraft:orange_poplar_leaves": { ++ "kind": "BLOCK", ++ "block": "minecraft:orange_poplar_leaves" ++ }, ++ "minecraft:orange_shulker_box": { ++ "kind": "BLOCK", ++ "block": "minecraft:orange_shulker_box" ++ }, ++ "minecraft:orange_stained_glass": { ++ "kind": "BLOCK", ++ "block": "minecraft:orange_stained_glass" ++ }, ++ "minecraft:orange_stained_glass_pane": { ++ "kind": "BLOCK", ++ "block": "minecraft:orange_stained_glass_pane" ++ }, ++ "minecraft:orange_terracotta": { ++ "kind": "BLOCK", ++ "block": "minecraft:orange_terracotta" ++ }, ++ "minecraft:orange_tulip": { ++ "kind": "BLOCK", ++ "block": "minecraft:orange_tulip" ++ }, ++ "minecraft:orange_wool": { ++ "kind": "WOOL", ++ "block": "minecraft:orange_wool", ++ "fallback": "tile.wool.name" ++ }, ++ "minecraft:oxeye_daisy": { ++ "kind": "BLOCK", ++ "block": "minecraft:oxeye_daisy" ++ }, ++ "minecraft:oxidized_chiseled_copper": { ++ "kind": "BLOCK", ++ "block": "minecraft:oxidized_chiseled_copper" ++ }, ++ "minecraft:oxidized_copper": { ++ "kind": "BLOCK", ++ "block": "minecraft:oxidized_copper" ++ }, ++ "minecraft:oxidized_copper_bars": { ++ "kind": "BLOCK", ++ "block": "minecraft:oxidized_copper_bars" ++ }, ++ "minecraft:oxidized_copper_bulb": { ++ "kind": "BLOCK", ++ "block": "minecraft:oxidized_copper_bulb" ++ }, ++ "minecraft:oxidized_copper_chain": { ++ "kind": "BLOCK", ++ "block": "minecraft:oxidized_copper_chain" ++ }, ++ "minecraft:oxidized_copper_chest": { ++ "kind": "BLOCK", ++ "block": "minecraft:oxidized_copper_chest" ++ }, ++ "minecraft:oxidized_copper_golem_statue": { ++ "kind": "BLOCK", ++ "block": "minecraft:oxidized_copper_golem_statue" ++ }, ++ "minecraft:oxidized_copper_grate": { ++ "kind": "BLOCK", ++ "block": "minecraft:oxidized_copper_grate" ++ }, ++ "minecraft:oxidized_copper_lantern": { ++ "kind": "BLOCK", ++ "block": "minecraft:oxidized_copper_lantern" ++ }, ++ "minecraft:oxidized_copper_trapdoor": { ++ "kind": "BLOCK", ++ "block": "minecraft:oxidized_copper_trapdoor" ++ }, ++ "minecraft:oxidized_cut_copper": { ++ "kind": "BLOCK", ++ "block": "minecraft:oxidized_cut_copper" ++ }, ++ "minecraft:oxidized_cut_copper_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:oxidized_cut_copper_slab" ++ }, ++ "minecraft:oxidized_cut_copper_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:oxidized_cut_copper_stairs" ++ }, ++ "minecraft:oxidized_double_cut_copper_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:oxidized_double_cut_copper_slab" ++ }, ++ "minecraft:oxidized_lightning_rod": { ++ "kind": "BLOCK", ++ "block": "minecraft:oxidized_lightning_rod" ++ }, ++ "minecraft:packed_ice": { ++ "kind": "BLOCK", ++ "block": "minecraft:packed_ice" ++ }, ++ "minecraft:packed_mud": { ++ "kind": "BLOCK", ++ "block": "minecraft:packed_mud" ++ }, ++ "minecraft:pale_hanging_moss": { ++ "kind": "BLOCK", ++ "block": "minecraft:pale_hanging_moss" ++ }, ++ "minecraft:pale_moss_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:pale_moss_block" ++ }, ++ "minecraft:pale_moss_carpet": { ++ "kind": "BLOCK", ++ "block": "minecraft:pale_moss_carpet" ++ }, ++ "minecraft:pale_oak_button": { ++ "kind": "BLOCK", ++ "block": "minecraft:pale_oak_button" ++ }, ++ "minecraft:pale_oak_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:pale_oak_double_slab" ++ }, ++ "minecraft:pale_oak_fence": { ++ "kind": "BLOCK", ++ "block": "minecraft:pale_oak_fence" ++ }, ++ "minecraft:pale_oak_fence_gate": { ++ "kind": "BLOCK", ++ "block": "minecraft:pale_oak_fence_gate" ++ }, ++ "minecraft:pale_oak_leaves": { ++ "kind": "BLOCK", ++ "block": "minecraft:pale_oak_leaves" ++ }, ++ "minecraft:pale_oak_log": { ++ "kind": "BLOCK", ++ "block": "minecraft:pale_oak_log" ++ }, ++ "minecraft:pale_oak_planks": { ++ "kind": "BLOCK", ++ "block": "minecraft:pale_oak_planks" ++ }, ++ "minecraft:pale_oak_pressure_plate": { ++ "kind": "BLOCK", ++ "block": "minecraft:pale_oak_pressure_plate" ++ }, ++ "minecraft:pale_oak_sapling": { ++ "kind": "BLOCK", ++ "block": "minecraft:pale_oak_sapling" ++ }, ++ "minecraft:pale_oak_shelf": { ++ "kind": "BLOCK", ++ "block": "minecraft:pale_oak_shelf" ++ }, ++ "minecraft:pale_oak_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:pale_oak_slab" ++ }, ++ "minecraft:pale_oak_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:pale_oak_stairs" ++ }, ++ "minecraft:pale_oak_standing_sign": { ++ "kind": "BLOCK", ++ "block": "minecraft:pale_oak_standing_sign" ++ }, ++ "minecraft:pale_oak_trapdoor": { ++ "kind": "BLOCK", ++ "block": "minecraft:pale_oak_trapdoor" ++ }, ++ "minecraft:pale_oak_wall_sign": { ++ "kind": "BLOCK", ++ "block": "minecraft:pale_oak_wall_sign" ++ }, ++ "minecraft:pale_oak_wood": { ++ "kind": "BLOCK", ++ "block": "minecraft:pale_oak_wood" ++ }, ++ "minecraft:pearlescent_froglight": { ++ "kind": "BLOCK", ++ "block": "minecraft:pearlescent_froglight" ++ }, ++ "minecraft:peony": { ++ "kind": "BLOCK", ++ "block": "minecraft:peony" ++ }, ++ "minecraft:petrified_oak_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:petrified_oak_double_slab" ++ }, ++ "minecraft:petrified_oak_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:petrified_oak_slab" ++ }, ++ "minecraft:piglin_head": { ++ "kind": "BLOCK", ++ "block": "minecraft:piglin_head" ++ }, ++ "minecraft:pink_candle": { ++ "kind": "BLOCK", ++ "block": "minecraft:pink_candle" ++ }, ++ "minecraft:pink_candle_cake": { ++ "kind": "BLOCK", ++ "block": "minecraft:pink_candle_cake" ++ }, ++ "minecraft:pink_carpet": { ++ "kind": "WOOL", ++ "block": "minecraft:pink_carpet", ++ "fallback": "tile.carpet.name" ++ }, ++ "minecraft:pink_concrete": { ++ "kind": "BLOCK", ++ "block": "minecraft:pink_concrete" ++ }, ++ "minecraft:pink_concrete_powder": { ++ "kind": "BLOCK", ++ "block": "minecraft:pink_concrete_powder" ++ }, ++ "minecraft:pink_glazed_terracotta": { ++ "kind": "BLOCK", ++ "block": "minecraft:pink_glazed_terracotta" ++ }, ++ "minecraft:pink_petals": { ++ "kind": "BLOCK", ++ "block": "minecraft:pink_petals" ++ }, ++ "minecraft:pink_shulker_box": { ++ "kind": "BLOCK", ++ "block": "minecraft:pink_shulker_box" ++ }, ++ "minecraft:pink_stained_glass": { ++ "kind": "BLOCK", ++ "block": "minecraft:pink_stained_glass" ++ }, ++ "minecraft:pink_stained_glass_pane": { ++ "kind": "BLOCK", ++ "block": "minecraft:pink_stained_glass_pane" ++ }, ++ "minecraft:pink_terracotta": { ++ "kind": "BLOCK", ++ "block": "minecraft:pink_terracotta" ++ }, ++ "minecraft:pink_tulip": { ++ "kind": "BLOCK", ++ "block": "minecraft:pink_tulip" ++ }, ++ "minecraft:pink_wool": { ++ "kind": "WOOL", ++ "block": "minecraft:pink_wool", ++ "fallback": "tile.wool.name" ++ }, ++ "minecraft:piston": { ++ "kind": "BLOCK", ++ "block": "minecraft:piston" ++ }, ++ "minecraft:piston_arm_collision": { ++ "kind": "BLOCK", ++ "block": "minecraft:piston_arm_collision" ++ }, ++ "minecraft:pitcher_crop": { ++ "kind": "BLOCK", ++ "block": "minecraft:pitcher_crop" ++ }, ++ "minecraft:pitcher_plant": { ++ "kind": "BLOCK", ++ "block": "minecraft:pitcher_plant" ++ }, ++ "minecraft:player_head": { ++ "kind": "BLOCK", ++ "block": "minecraft:player_head" ++ }, ++ "minecraft:podzol": { ++ "kind": "BLOCK", ++ "block": "minecraft:podzol" ++ }, ++ "minecraft:pointed_dripstone": { ++ "kind": "BLOCK", ++ "block": "minecraft:pointed_dripstone" ++ }, ++ "minecraft:polished_andesite": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_andesite" ++ }, ++ "minecraft:polished_andesite_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_andesite_double_slab" ++ }, ++ "minecraft:polished_andesite_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_andesite_slab" ++ }, ++ "minecraft:polished_andesite_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_andesite_stairs" ++ }, ++ "minecraft:polished_basalt": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_basalt" ++ }, ++ "minecraft:polished_blackstone": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_blackstone" ++ }, ++ "minecraft:polished_blackstone_brick_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_blackstone_brick_double_slab" ++ }, ++ "minecraft:polished_blackstone_brick_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_blackstone_brick_slab" ++ }, ++ "minecraft:polished_blackstone_brick_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_blackstone_brick_stairs" ++ }, ++ "minecraft:polished_blackstone_brick_wall": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_blackstone_brick_wall" ++ }, ++ "minecraft:polished_blackstone_bricks": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_blackstone_bricks" ++ }, ++ "minecraft:polished_blackstone_button": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_blackstone_button" ++ }, ++ "minecraft:polished_blackstone_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_blackstone_double_slab" ++ }, ++ "minecraft:polished_blackstone_pressure_plate": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_blackstone_pressure_plate" ++ }, ++ "minecraft:polished_blackstone_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_blackstone_slab" ++ }, ++ "minecraft:polished_blackstone_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_blackstone_stairs" ++ }, ++ "minecraft:polished_blackstone_wall": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_blackstone_wall" ++ }, ++ "minecraft:polished_cinnabar": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_cinnabar" ++ }, ++ "minecraft:polished_cinnabar_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_cinnabar_double_slab" ++ }, ++ "minecraft:polished_cinnabar_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_cinnabar_slab" ++ }, ++ "minecraft:polished_cinnabar_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_cinnabar_stairs" ++ }, ++ "minecraft:polished_cinnabar_wall": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_cinnabar_wall" ++ }, ++ "minecraft:polished_deepslate": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_deepslate" ++ }, ++ "minecraft:polished_deepslate_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_deepslate_double_slab" ++ }, ++ "minecraft:polished_deepslate_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_deepslate_slab" ++ }, ++ "minecraft:polished_deepslate_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_deepslate_stairs" ++ }, ++ "minecraft:polished_deepslate_wall": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_deepslate_wall" ++ }, ++ "minecraft:polished_diorite": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_diorite" ++ }, ++ "minecraft:polished_diorite_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_diorite_double_slab" ++ }, ++ "minecraft:polished_diorite_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_diorite_slab" ++ }, ++ "minecraft:polished_diorite_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_diorite_stairs" ++ }, ++ "minecraft:polished_granite": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_granite" ++ }, ++ "minecraft:polished_granite_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_granite_double_slab" ++ }, ++ "minecraft:polished_granite_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_granite_slab" ++ }, ++ "minecraft:polished_granite_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_granite_stairs" ++ }, ++ "minecraft:polished_sulfur": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_sulfur" ++ }, ++ "minecraft:polished_sulfur_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_sulfur_double_slab" ++ }, ++ "minecraft:polished_sulfur_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_sulfur_slab" ++ }, ++ "minecraft:polished_sulfur_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_sulfur_stairs" ++ }, ++ "minecraft:polished_sulfur_wall": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_sulfur_wall" ++ }, ++ "minecraft:polished_tuff": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_tuff" ++ }, ++ "minecraft:polished_tuff_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_tuff_double_slab" ++ }, ++ "minecraft:polished_tuff_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_tuff_slab" ++ }, ++ "minecraft:polished_tuff_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_tuff_stairs" ++ }, ++ "minecraft:polished_tuff_wall": { ++ "kind": "BLOCK", ++ "block": "minecraft:polished_tuff_wall" ++ }, ++ "minecraft:poplar_button": { ++ "kind": "BLOCK", ++ "block": "minecraft:poplar_button" ++ }, ++ "minecraft:poplar_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:poplar_double_slab" ++ }, ++ "minecraft:poplar_fence": { ++ "kind": "BLOCK", ++ "block": "minecraft:poplar_fence" ++ }, ++ "minecraft:poplar_fence_gate": { ++ "kind": "BLOCK", ++ "block": "minecraft:poplar_fence_gate" ++ }, ++ "minecraft:poplar_log": { ++ "kind": "BLOCK", ++ "block": "minecraft:poplar_log" ++ }, ++ "minecraft:poplar_planks": { ++ "kind": "BLOCK", ++ "block": "minecraft:poplar_planks" ++ }, ++ "minecraft:poplar_pressure_plate": { ++ "kind": "BLOCK", ++ "block": "minecraft:poplar_pressure_plate" ++ }, ++ "minecraft:poplar_sapling": { ++ "kind": "BLOCK", ++ "block": "minecraft:poplar_sapling" ++ }, ++ "minecraft:poplar_shelf": { ++ "kind": "BLOCK", ++ "block": "minecraft:poplar_shelf" ++ }, ++ "minecraft:poplar_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:poplar_slab" ++ }, ++ "minecraft:poplar_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:poplar_stairs" ++ }, ++ "minecraft:poplar_standing_sign": { ++ "kind": "BLOCK", ++ "block": "minecraft:poplar_standing_sign" ++ }, ++ "minecraft:poplar_trapdoor": { ++ "kind": "BLOCK", ++ "block": "minecraft:poplar_trapdoor" ++ }, ++ "minecraft:poplar_wall_sign": { ++ "kind": "BLOCK", ++ "block": "minecraft:poplar_wall_sign" ++ }, ++ "minecraft:poplar_wood": { ++ "kind": "BLOCK", ++ "block": "minecraft:poplar_wood" ++ }, ++ "minecraft:poppy": { ++ "kind": "BLOCK", ++ "block": "minecraft:poppy" ++ }, ++ "minecraft:portal": { ++ "kind": "BLOCK", ++ "block": "minecraft:portal" ++ }, ++ "minecraft:potatoes": { ++ "kind": "BLOCK", ++ "block": "minecraft:potatoes" ++ }, ++ "minecraft:potent_sulfur": { ++ "kind": "BLOCK", ++ "block": "minecraft:potent_sulfur" ++ }, ++ "minecraft:powder_snow": { ++ "kind": "BLOCK", ++ "block": "minecraft:powder_snow" ++ }, ++ "minecraft:powered_comparator": { ++ "kind": "BLOCK", ++ "block": "minecraft:powered_comparator" ++ }, ++ "minecraft:powered_repeater": { ++ "kind": "BLOCK", ++ "block": "minecraft:powered_repeater" ++ }, ++ "minecraft:prismarine": { ++ "kind": "BLOCK", ++ "block": "minecraft:prismarine" ++ }, ++ "minecraft:prismarine_brick_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:prismarine_brick_double_slab" ++ }, ++ "minecraft:prismarine_brick_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:prismarine_brick_slab" ++ }, ++ "minecraft:prismarine_bricks": { ++ "kind": "BLOCK", ++ "block": "minecraft:prismarine_bricks" ++ }, ++ "minecraft:prismarine_bricks_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:prismarine_bricks_stairs" ++ }, ++ "minecraft:prismarine_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:prismarine_double_slab" ++ }, ++ "minecraft:prismarine_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:prismarine_slab" ++ }, ++ "minecraft:prismarine_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:prismarine_stairs" ++ }, ++ "minecraft:prismarine_wall": { ++ "kind": "BLOCK", ++ "block": "minecraft:prismarine_wall" ++ }, ++ "minecraft:pumpkin": { ++ "kind": "BLOCK", ++ "block": "minecraft:pumpkin" ++ }, ++ "minecraft:pumpkin_stem": { ++ "kind": "BLOCK", ++ "block": "minecraft:pumpkin_stem" ++ }, ++ "minecraft:purple_candle": { ++ "kind": "BLOCK", ++ "block": "minecraft:purple_candle" ++ }, ++ "minecraft:purple_candle_cake": { ++ "kind": "BLOCK", ++ "block": "minecraft:purple_candle_cake" ++ }, ++ "minecraft:purple_carpet": { ++ "kind": "WOOL", ++ "block": "minecraft:purple_carpet", ++ "fallback": "tile.carpet.name" ++ }, ++ "minecraft:purple_concrete": { ++ "kind": "BLOCK", ++ "block": "minecraft:purple_concrete" ++ }, ++ "minecraft:purple_concrete_powder": { ++ "kind": "BLOCK", ++ "block": "minecraft:purple_concrete_powder" ++ }, ++ "minecraft:purple_glazed_terracotta": { ++ "kind": "BLOCK", ++ "block": "minecraft:purple_glazed_terracotta" ++ }, ++ "minecraft:purple_shulker_box": { ++ "kind": "BLOCK", ++ "block": "minecraft:purple_shulker_box" ++ }, ++ "minecraft:purple_stained_glass": { ++ "kind": "BLOCK", ++ "block": "minecraft:purple_stained_glass" ++ }, ++ "minecraft:purple_stained_glass_pane": { ++ "kind": "BLOCK", ++ "block": "minecraft:purple_stained_glass_pane" ++ }, ++ "minecraft:purple_terracotta": { ++ "kind": "BLOCK", ++ "block": "minecraft:purple_terracotta" ++ }, ++ "minecraft:purple_wool": { ++ "kind": "WOOL", ++ "block": "minecraft:purple_wool", ++ "fallback": "tile.wool.name" ++ }, ++ "minecraft:purpur_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:purpur_block" ++ }, ++ "minecraft:purpur_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:purpur_double_slab" ++ }, ++ "minecraft:purpur_pillar": { ++ "kind": "BLOCK", ++ "block": "minecraft:purpur_pillar" ++ }, ++ "minecraft:purpur_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:purpur_slab" ++ }, ++ "minecraft:purpur_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:purpur_stairs" ++ }, ++ "minecraft:quartz_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:quartz_block" ++ }, ++ "minecraft:quartz_bricks": { ++ "kind": "BLOCK", ++ "block": "minecraft:quartz_bricks" ++ }, ++ "minecraft:quartz_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:quartz_double_slab" ++ }, ++ "minecraft:quartz_ore": { ++ "kind": "BLOCK", ++ "block": "minecraft:quartz_ore" ++ }, ++ "minecraft:quartz_pillar": { ++ "kind": "BLOCK", ++ "block": "minecraft:quartz_pillar" ++ }, ++ "minecraft:quartz_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:quartz_slab" ++ }, ++ "minecraft:quartz_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:quartz_stairs" ++ }, ++ "minecraft:rail": { ++ "kind": "BLOCK", ++ "block": "minecraft:rail" ++ }, ++ "minecraft:raw_copper_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:raw_copper_block" ++ }, ++ "minecraft:raw_gold_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:raw_gold_block" ++ }, ++ "minecraft:raw_iron_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:raw_iron_block" ++ }, ++ "minecraft:red_candle": { ++ "kind": "BLOCK", ++ "block": "minecraft:red_candle" ++ }, ++ "minecraft:red_candle_cake": { ++ "kind": "BLOCK", ++ "block": "minecraft:red_candle_cake" ++ }, ++ "minecraft:red_carpet": { ++ "kind": "WOOL", ++ "block": "minecraft:red_carpet", ++ "fallback": "tile.carpet.name" ++ }, ++ "minecraft:red_concrete": { ++ "kind": "BLOCK", ++ "block": "minecraft:red_concrete" ++ }, ++ "minecraft:red_concrete_powder": { ++ "kind": "BLOCK", ++ "block": "minecraft:red_concrete_powder" ++ }, ++ "minecraft:red_glazed_terracotta": { ++ "kind": "BLOCK", ++ "block": "minecraft:red_glazed_terracotta" ++ }, ++ "minecraft:red_mushroom": { ++ "kind": "BLOCK", ++ "block": "minecraft:red_mushroom" ++ }, ++ "minecraft:red_mushroom_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:red_mushroom_block" ++ }, ++ "minecraft:red_nether_brick": { ++ "kind": "BLOCK", ++ "block": "minecraft:red_nether_brick" ++ }, ++ "minecraft:red_nether_brick_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:red_nether_brick_double_slab" ++ }, ++ "minecraft:red_nether_brick_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:red_nether_brick_slab" ++ }, ++ "minecraft:red_nether_brick_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:red_nether_brick_stairs" ++ }, ++ "minecraft:red_nether_brick_wall": { ++ "kind": "BLOCK", ++ "block": "minecraft:red_nether_brick_wall" ++ }, ++ "minecraft:red_poplar_leaves": { ++ "kind": "BLOCK", ++ "block": "minecraft:red_poplar_leaves" ++ }, ++ "minecraft:red_sand": { ++ "kind": "BLOCK", ++ "block": "minecraft:red_sand" ++ }, ++ "minecraft:red_sandstone": { ++ "kind": "BLOCK", ++ "block": "minecraft:red_sandstone" ++ }, ++ "minecraft:red_sandstone_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:red_sandstone_double_slab" ++ }, ++ "minecraft:red_sandstone_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:red_sandstone_slab" ++ }, ++ "minecraft:red_sandstone_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:red_sandstone_stairs" ++ }, ++ "minecraft:red_sandstone_wall": { ++ "kind": "BLOCK", ++ "block": "minecraft:red_sandstone_wall" ++ }, ++ "minecraft:red_shulker_box": { ++ "kind": "BLOCK", ++ "block": "minecraft:red_shulker_box" ++ }, ++ "minecraft:red_stained_glass": { ++ "kind": "BLOCK", ++ "block": "minecraft:red_stained_glass" ++ }, ++ "minecraft:red_stained_glass_pane": { ++ "kind": "BLOCK", ++ "block": "minecraft:red_stained_glass_pane" ++ }, ++ "minecraft:red_terracotta": { ++ "kind": "BLOCK", ++ "block": "minecraft:red_terracotta" ++ }, ++ "minecraft:red_tulip": { ++ "kind": "BLOCK", ++ "block": "minecraft:red_tulip" ++ }, ++ "minecraft:red_wool": { ++ "kind": "WOOL", ++ "block": "minecraft:red_wool", ++ "fallback": "tile.wool.name" ++ }, ++ "minecraft:redstone_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:redstone_block" ++ }, ++ "minecraft:redstone_lamp": { ++ "kind": "BLOCK", ++ "block": "minecraft:redstone_lamp" ++ }, ++ "minecraft:redstone_ore": { ++ "kind": "BLOCK", ++ "block": "minecraft:redstone_ore" ++ }, ++ "minecraft:redstone_torch": { ++ "kind": "BLOCK", ++ "block": "minecraft:redstone_torch" ++ }, ++ "minecraft:redstone_wire": { ++ "kind": "BLOCK", ++ "block": "minecraft:redstone_wire" ++ }, ++ "minecraft:reinforced_deepslate": { ++ "kind": "BLOCK", ++ "block": "minecraft:reinforced_deepslate" ++ }, ++ "minecraft:repeating_command_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:repeating_command_block" ++ }, ++ "minecraft:reserved6": { ++ "kind": "BLOCK", ++ "block": "minecraft:reserved6" ++ }, ++ "minecraft:resin_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:resin_block" ++ }, ++ "minecraft:resin_brick_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:resin_brick_double_slab" ++ }, ++ "minecraft:resin_brick_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:resin_brick_slab" ++ }, ++ "minecraft:resin_brick_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:resin_brick_stairs" ++ }, ++ "minecraft:resin_brick_wall": { ++ "kind": "BLOCK", ++ "block": "minecraft:resin_brick_wall" ++ }, ++ "minecraft:resin_bricks": { ++ "kind": "BLOCK", ++ "block": "minecraft:resin_bricks" ++ }, ++ "minecraft:resin_clump": { ++ "kind": "BLOCK", ++ "block": "minecraft:resin_clump" ++ }, ++ "minecraft:respawn_anchor": { ++ "kind": "BLOCK", ++ "block": "minecraft:respawn_anchor" ++ }, ++ "minecraft:rose_bush": { ++ "kind": "BLOCK", ++ "block": "minecraft:rose_bush" ++ }, ++ "minecraft:sand": { ++ "kind": "BLOCK", ++ "block": "minecraft:sand" ++ }, ++ "minecraft:sandstone": { ++ "kind": "BLOCK", ++ "block": "minecraft:sandstone" ++ }, ++ "minecraft:sandstone_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:sandstone_double_slab" ++ }, ++ "minecraft:sandstone_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:sandstone_slab" ++ }, ++ "minecraft:sandstone_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:sandstone_stairs" ++ }, ++ "minecraft:sandstone_wall": { ++ "kind": "BLOCK", ++ "block": "minecraft:sandstone_wall" ++ }, ++ "minecraft:scaffolding": { ++ "kind": "BLOCK", ++ "block": "minecraft:scaffolding" ++ }, ++ "minecraft:sculk": { ++ "kind": "BLOCK", ++ "block": "minecraft:sculk" ++ }, ++ "minecraft:sculk_catalyst": { ++ "kind": "BLOCK", ++ "block": "minecraft:sculk_catalyst" ++ }, ++ "minecraft:sculk_sensor": { ++ "kind": "BLOCK", ++ "block": "minecraft:sculk_sensor" ++ }, ++ "minecraft:sculk_shrieker": { ++ "kind": "BLOCK", ++ "block": "minecraft:sculk_shrieker" ++ }, ++ "minecraft:sculk_vein": { ++ "kind": "BLOCK", ++ "block": "minecraft:sculk_vein" ++ }, ++ "minecraft:sea_lantern": { ++ "kind": "BLOCK", ++ "block": "minecraft:sea_lantern" ++ }, ++ "minecraft:sea_pickle": { ++ "kind": "BLOCK", ++ "block": "minecraft:sea_pickle" ++ }, ++ "minecraft:seagrass": { ++ "kind": "BLOCK", ++ "block": "minecraft:seagrass" ++ }, ++ "minecraft:short_dry_grass": { ++ "kind": "BLOCK", ++ "block": "minecraft:short_dry_grass" ++ }, ++ "minecraft:short_grass": { ++ "kind": "BLOCK", ++ "block": "minecraft:short_grass" ++ }, ++ "minecraft:shroomlight": { ++ "kind": "BLOCK", ++ "block": "minecraft:shroomlight" ++ }, ++ "minecraft:silver_glazed_terracotta": { ++ "kind": "BLOCK", ++ "block": "minecraft:silver_glazed_terracotta" ++ }, ++ "minecraft:skeleton_skull": { ++ "kind": "BLOCK", ++ "block": "minecraft:skeleton_skull" ++ }, ++ "minecraft:slime": { ++ "kind": "BLOCK", ++ "block": "minecraft:slime" ++ }, ++ "minecraft:small_amethyst_bud": { ++ "kind": "BLOCK", ++ "block": "minecraft:small_amethyst_bud" ++ }, ++ "minecraft:small_dripleaf_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:small_dripleaf_block" ++ }, ++ "minecraft:smithing_table": { ++ "kind": "BLOCK", ++ "block": "minecraft:smithing_table" ++ }, ++ "minecraft:smoker": { ++ "kind": "BLOCK", ++ "block": "minecraft:smoker" ++ }, ++ "minecraft:smooth_basalt": { ++ "kind": "BLOCK", ++ "block": "minecraft:smooth_basalt" ++ }, ++ "minecraft:smooth_quartz": { ++ "kind": "BLOCK", ++ "block": "minecraft:smooth_quartz" ++ }, ++ "minecraft:smooth_quartz_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:smooth_quartz_double_slab" ++ }, ++ "minecraft:smooth_quartz_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:smooth_quartz_slab" ++ }, ++ "minecraft:smooth_quartz_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:smooth_quartz_stairs" ++ }, ++ "minecraft:smooth_red_sandstone": { ++ "kind": "BLOCK", ++ "block": "minecraft:smooth_red_sandstone" ++ }, ++ "minecraft:smooth_red_sandstone_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:smooth_red_sandstone_double_slab" ++ }, ++ "minecraft:smooth_red_sandstone_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:smooth_red_sandstone_slab" ++ }, ++ "minecraft:smooth_red_sandstone_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:smooth_red_sandstone_stairs" ++ }, ++ "minecraft:smooth_sandstone": { ++ "kind": "BLOCK", ++ "block": "minecraft:smooth_sandstone" ++ }, ++ "minecraft:smooth_sandstone_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:smooth_sandstone_double_slab" ++ }, ++ "minecraft:smooth_sandstone_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:smooth_sandstone_slab" ++ }, ++ "minecraft:smooth_sandstone_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:smooth_sandstone_stairs" ++ }, ++ "minecraft:smooth_stone": { ++ "kind": "BLOCK", ++ "block": "minecraft:smooth_stone" ++ }, ++ "minecraft:smooth_stone_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:smooth_stone_double_slab" ++ }, ++ "minecraft:smooth_stone_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:smooth_stone_slab" ++ }, ++ "minecraft:sniffer_egg": { ++ "kind": "BLOCK", ++ "block": "minecraft:sniffer_egg" ++ }, ++ "minecraft:snow": { ++ "kind": "BLOCK", ++ "block": "minecraft:snow" ++ }, ++ "minecraft:snow_layer": { ++ "kind": "BLOCK", ++ "block": "minecraft:snow_layer" ++ }, ++ "minecraft:soul_fire": { ++ "kind": "BLOCK", ++ "block": "minecraft:soul_fire" ++ }, ++ "minecraft:soul_lantern": { ++ "kind": "BLOCK", ++ "block": "minecraft:soul_lantern" ++ }, ++ "minecraft:soul_sand": { ++ "kind": "BLOCK", ++ "block": "minecraft:soul_sand" ++ }, ++ "minecraft:soul_soil": { ++ "kind": "BLOCK", ++ "block": "minecraft:soul_soil" ++ }, ++ "minecraft:soul_torch": { ++ "kind": "BLOCK", ++ "block": "minecraft:soul_torch" ++ }, ++ "minecraft:sponge": { ++ "kind": "BLOCK", ++ "block": "minecraft:sponge" ++ }, ++ "minecraft:spore_blossom": { ++ "kind": "BLOCK", ++ "block": "minecraft:spore_blossom" ++ }, ++ "minecraft:spruce_button": { ++ "kind": "BLOCK", ++ "block": "minecraft:spruce_button" ++ }, ++ "minecraft:spruce_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:spruce_double_slab" ++ }, ++ "minecraft:spruce_fence": { ++ "kind": "BLOCK", ++ "block": "minecraft:spruce_fence" ++ }, ++ "minecraft:spruce_fence_gate": { ++ "kind": "BLOCK", ++ "block": "minecraft:spruce_fence_gate" ++ }, ++ "minecraft:spruce_leaves": { ++ "kind": "BLOCK", ++ "block": "minecraft:spruce_leaves" ++ }, ++ "minecraft:spruce_log": { ++ "kind": "BLOCK", ++ "block": "minecraft:spruce_log" ++ }, ++ "minecraft:spruce_planks": { ++ "kind": "BLOCK", ++ "block": "minecraft:spruce_planks" ++ }, ++ "minecraft:spruce_pressure_plate": { ++ "kind": "BLOCK", ++ "block": "minecraft:spruce_pressure_plate" ++ }, ++ "minecraft:spruce_sapling": { ++ "kind": "SAPLING", ++ "block": "minecraft:spruce_sapling", ++ "description": "item.spruce_sapling" ++ }, ++ "minecraft:spruce_shelf": { ++ "kind": "BLOCK", ++ "block": "minecraft:spruce_shelf" ++ }, ++ "minecraft:spruce_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:spruce_slab" ++ }, ++ "minecraft:spruce_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:spruce_stairs" ++ }, ++ "minecraft:spruce_standing_sign": { ++ "kind": "BLOCK", ++ "block": "minecraft:spruce_standing_sign" ++ }, ++ "minecraft:spruce_trapdoor": { ++ "kind": "BLOCK", ++ "block": "minecraft:spruce_trapdoor" ++ }, ++ "minecraft:spruce_wall_sign": { ++ "kind": "BLOCK", ++ "block": "minecraft:spruce_wall_sign" ++ }, ++ "minecraft:spruce_wood": { ++ "kind": "BLOCK", ++ "block": "minecraft:spruce_wood" ++ }, ++ "minecraft:standing_banner": { ++ "kind": "BLOCK", ++ "block": "minecraft:standing_banner" ++ }, ++ "minecraft:standing_sign": { ++ "kind": "BLOCK", ++ "block": "minecraft:standing_sign" ++ }, ++ "minecraft:sticky_piston": { ++ "kind": "BLOCK", ++ "block": "minecraft:sticky_piston" ++ }, ++ "minecraft:sticky_piston_arm_collision": { ++ "kind": "BLOCK", ++ "block": "minecraft:sticky_piston_arm_collision" ++ }, ++ "minecraft:stone": { ++ "kind": "BLOCK", ++ "block": "minecraft:stone" ++ }, ++ "minecraft:stone_brick_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:stone_brick_double_slab" ++ }, ++ "minecraft:stone_brick_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:stone_brick_slab" ++ }, ++ "minecraft:stone_brick_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:stone_brick_stairs" ++ }, ++ "minecraft:stone_brick_wall": { ++ "kind": "BLOCK", ++ "block": "minecraft:stone_brick_wall" ++ }, ++ "minecraft:stone_bricks": { ++ "kind": "BLOCK", ++ "block": "minecraft:stone_bricks" ++ }, ++ "minecraft:stone_button": { ++ "kind": "BLOCK", ++ "block": "minecraft:stone_button" ++ }, ++ "minecraft:stone_pressure_plate": { ++ "kind": "BLOCK", ++ "block": "minecraft:stone_pressure_plate" ++ }, ++ "minecraft:stone_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:stone_stairs" ++ }, ++ "minecraft:stonecutter": { ++ "kind": "BLOCK", ++ "block": "minecraft:stonecutter" ++ }, ++ "minecraft:stonecutter_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:stonecutter_block" ++ }, ++ "minecraft:stripped_acacia_log": { ++ "kind": "BLOCK", ++ "block": "minecraft:stripped_acacia_log" ++ }, ++ "minecraft:stripped_acacia_wood": { ++ "kind": "BLOCK", ++ "block": "minecraft:stripped_acacia_wood" ++ }, ++ "minecraft:stripped_bamboo_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:stripped_bamboo_block" ++ }, ++ "minecraft:stripped_birch_log": { ++ "kind": "BLOCK", ++ "block": "minecraft:stripped_birch_log" ++ }, ++ "minecraft:stripped_birch_wood": { ++ "kind": "BLOCK", ++ "block": "minecraft:stripped_birch_wood" ++ }, ++ "minecraft:stripped_cherry_log": { ++ "kind": "BLOCK", ++ "block": "minecraft:stripped_cherry_log" ++ }, ++ "minecraft:stripped_cherry_wood": { ++ "kind": "BLOCK", ++ "block": "minecraft:stripped_cherry_wood" ++ }, ++ "minecraft:stripped_crimson_hyphae": { ++ "kind": "BLOCK", ++ "block": "minecraft:stripped_crimson_hyphae" ++ }, ++ "minecraft:stripped_crimson_stem": { ++ "kind": "BLOCK", ++ "block": "minecraft:stripped_crimson_stem" ++ }, ++ "minecraft:stripped_dark_oak_log": { ++ "kind": "BLOCK", ++ "block": "minecraft:stripped_dark_oak_log" ++ }, ++ "minecraft:stripped_dark_oak_wood": { ++ "kind": "BLOCK", ++ "block": "minecraft:stripped_dark_oak_wood" ++ }, ++ "minecraft:stripped_jungle_log": { ++ "kind": "BLOCK", ++ "block": "minecraft:stripped_jungle_log" ++ }, ++ "minecraft:stripped_jungle_wood": { ++ "kind": "BLOCK", ++ "block": "minecraft:stripped_jungle_wood" ++ }, ++ "minecraft:stripped_mangrove_log": { ++ "kind": "BLOCK", ++ "block": "minecraft:stripped_mangrove_log" ++ }, ++ "minecraft:stripped_mangrove_wood": { ++ "kind": "BLOCK", ++ "block": "minecraft:stripped_mangrove_wood" ++ }, ++ "minecraft:stripped_oak_log": { ++ "kind": "BLOCK", ++ "block": "minecraft:stripped_oak_log" ++ }, ++ "minecraft:stripped_oak_wood": { ++ "kind": "BLOCK", ++ "block": "minecraft:stripped_oak_wood" ++ }, ++ "minecraft:stripped_pale_oak_log": { ++ "kind": "BLOCK", ++ "block": "minecraft:stripped_pale_oak_log" ++ }, ++ "minecraft:stripped_pale_oak_wood": { ++ "kind": "BLOCK", ++ "block": "minecraft:stripped_pale_oak_wood" ++ }, ++ "minecraft:stripped_poplar_log": { ++ "kind": "BLOCK", ++ "block": "minecraft:stripped_poplar_log" ++ }, ++ "minecraft:stripped_poplar_wood": { ++ "kind": "BLOCK", ++ "block": "minecraft:stripped_poplar_wood" ++ }, ++ "minecraft:stripped_spruce_log": { ++ "kind": "BLOCK", ++ "block": "minecraft:stripped_spruce_log" ++ }, ++ "minecraft:stripped_spruce_wood": { ++ "kind": "BLOCK", ++ "block": "minecraft:stripped_spruce_wood" ++ }, ++ "minecraft:stripped_warped_hyphae": { ++ "kind": "BLOCK", ++ "block": "minecraft:stripped_warped_hyphae" ++ }, ++ "minecraft:stripped_warped_stem": { ++ "kind": "BLOCK", ++ "block": "minecraft:stripped_warped_stem" ++ }, ++ "minecraft:structure_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:structure_block" ++ }, ++ "minecraft:structure_void": { ++ "kind": "BLOCK", ++ "block": "minecraft:structure_void" ++ }, ++ "minecraft:sulfur": { ++ "kind": "BLOCK", ++ "block": "minecraft:sulfur" ++ }, ++ "minecraft:sulfur_brick_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:sulfur_brick_double_slab" ++ }, ++ "minecraft:sulfur_brick_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:sulfur_brick_slab" ++ }, ++ "minecraft:sulfur_brick_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:sulfur_brick_stairs" ++ }, ++ "minecraft:sulfur_brick_wall": { ++ "kind": "BLOCK", ++ "block": "minecraft:sulfur_brick_wall" ++ }, ++ "minecraft:sulfur_bricks": { ++ "kind": "BLOCK", ++ "block": "minecraft:sulfur_bricks" ++ }, ++ "minecraft:sulfur_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:sulfur_double_slab" ++ }, ++ "minecraft:sulfur_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:sulfur_slab" ++ }, ++ "minecraft:sulfur_spike": { ++ "kind": "BLOCK", ++ "block": "minecraft:sulfur_spike" ++ }, ++ "minecraft:sulfur_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:sulfur_stairs" ++ }, ++ "minecraft:sulfur_wall": { ++ "kind": "BLOCK", ++ "block": "minecraft:sulfur_wall" ++ }, ++ "minecraft:sunflower": { ++ "kind": "BLOCK", ++ "block": "minecraft:sunflower" ++ }, ++ "minecraft:suspicious_gravel": { ++ "kind": "BLOCK", ++ "block": "minecraft:suspicious_gravel" ++ }, ++ "minecraft:suspicious_sand": { ++ "kind": "BLOCK", ++ "block": "minecraft:suspicious_sand" ++ }, ++ "minecraft:sweet_berry_bush": { ++ "kind": "BLOCK", ++ "block": "minecraft:sweet_berry_bush" ++ }, ++ "minecraft:tall_dry_grass": { ++ "kind": "BLOCK", ++ "block": "minecraft:tall_dry_grass" ++ }, ++ "minecraft:tall_grass": { ++ "kind": "BLOCK", ++ "block": "minecraft:tall_grass" ++ }, ++ "minecraft:target": { ++ "kind": "BLOCK", ++ "block": "minecraft:target" ++ }, ++ "minecraft:tinted_glass": { ++ "kind": "BLOCK", ++ "block": "minecraft:tinted_glass" ++ }, ++ "minecraft:tnt": { ++ "kind": "BLOCK", ++ "block": "minecraft:tnt" ++ }, ++ "minecraft:torch": { ++ "kind": "BLOCK", ++ "block": "minecraft:torch" ++ }, ++ "minecraft:torchflower": { ++ "kind": "BLOCK", ++ "block": "minecraft:torchflower" ++ }, ++ "minecraft:torchflower_crop": { ++ "kind": "BLOCK", ++ "block": "minecraft:torchflower_crop" ++ }, ++ "minecraft:trapdoor": { ++ "kind": "BLOCK", ++ "block": "minecraft:trapdoor" ++ }, ++ "minecraft:trapped_chest": { ++ "kind": "BLOCK", ++ "block": "minecraft:trapped_chest" ++ }, ++ "minecraft:trial_spawner": { ++ "kind": "BLOCK", ++ "block": "minecraft:trial_spawner" ++ }, ++ "minecraft:trip_wire": { ++ "kind": "BLOCK", ++ "block": "minecraft:trip_wire" ++ }, ++ "minecraft:tripwire_hook": { ++ "kind": "BLOCK", ++ "block": "minecraft:tripwire_hook" ++ }, ++ "minecraft:tube_coral": { ++ "kind": "BLOCK", ++ "block": "minecraft:tube_coral" ++ }, ++ "minecraft:tube_coral_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:tube_coral_block" ++ }, ++ "minecraft:tube_coral_fan": { ++ "kind": "BLOCK", ++ "block": "minecraft:tube_coral_fan" ++ }, ++ "minecraft:tube_coral_wall_fan": { ++ "kind": "BLOCK", ++ "block": "minecraft:tube_coral_wall_fan" ++ }, ++ "minecraft:tuff": { ++ "kind": "BLOCK", ++ "block": "minecraft:tuff" ++ }, ++ "minecraft:tuff_brick_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:tuff_brick_double_slab" ++ }, ++ "minecraft:tuff_brick_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:tuff_brick_slab" ++ }, ++ "minecraft:tuff_brick_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:tuff_brick_stairs" ++ }, ++ "minecraft:tuff_brick_wall": { ++ "kind": "BLOCK", ++ "block": "minecraft:tuff_brick_wall" ++ }, ++ "minecraft:tuff_bricks": { ++ "kind": "BLOCK", ++ "block": "minecraft:tuff_bricks" ++ }, ++ "minecraft:tuff_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:tuff_double_slab" ++ }, ++ "minecraft:tuff_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:tuff_slab" ++ }, ++ "minecraft:tuff_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:tuff_stairs" ++ }, ++ "minecraft:tuff_wall": { ++ "kind": "BLOCK", ++ "block": "minecraft:tuff_wall" ++ }, ++ "minecraft:turtle_egg": { ++ "kind": "BLOCK", ++ "block": "minecraft:turtle_egg" ++ }, ++ "minecraft:twisting_vines": { ++ "kind": "BLOCK", ++ "block": "minecraft:twisting_vines" ++ }, ++ "minecraft:underwater_tnt": { ++ "kind": "BLOCK", ++ "block": "minecraft:underwater_tnt" ++ }, ++ "minecraft:underwater_torch": { ++ "kind": "BLOCK", ++ "block": "minecraft:underwater_torch" ++ }, ++ "minecraft:undyed_shulker_box": { ++ "kind": "BLOCK", ++ "block": "minecraft:undyed_shulker_box" ++ }, ++ "minecraft:unknown": { ++ "kind": "BLOCK", ++ "block": "minecraft:unknown" ++ }, ++ "minecraft:unlit_redstone_torch": { ++ "kind": "BLOCK", ++ "block": "minecraft:unlit_redstone_torch" ++ }, ++ "minecraft:unpowered_comparator": { ++ "kind": "BLOCK", ++ "block": "minecraft:unpowered_comparator" ++ }, ++ "minecraft:unpowered_repeater": { ++ "kind": "BLOCK", ++ "block": "minecraft:unpowered_repeater" ++ }, ++ "minecraft:vault": { ++ "kind": "BLOCK", ++ "block": "minecraft:vault" ++ }, ++ "minecraft:verdant_froglight": { ++ "kind": "BLOCK", ++ "block": "minecraft:verdant_froglight" ++ }, ++ "minecraft:vine": { ++ "kind": "BLOCK", ++ "block": "minecraft:vine" ++ }, ++ "minecraft:wall_banner": { ++ "kind": "BLOCK", ++ "block": "minecraft:wall_banner" ++ }, ++ "minecraft:wall_sign": { ++ "kind": "BLOCK", ++ "block": "minecraft:wall_sign" ++ }, ++ "minecraft:warped_button": { ++ "kind": "BLOCK", ++ "block": "minecraft:warped_button" ++ }, ++ "minecraft:warped_double_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:warped_double_slab" ++ }, ++ "minecraft:warped_fence": { ++ "kind": "BLOCK", ++ "block": "minecraft:warped_fence" ++ }, ++ "minecraft:warped_fence_gate": { ++ "kind": "BLOCK", ++ "block": "minecraft:warped_fence_gate" ++ }, ++ "minecraft:warped_fungus": { ++ "kind": "BLOCK", ++ "block": "minecraft:warped_fungus" ++ }, ++ "minecraft:warped_hyphae": { ++ "kind": "BLOCK", ++ "block": "minecraft:warped_hyphae" ++ }, ++ "minecraft:warped_nylium": { ++ "kind": "BLOCK", ++ "block": "minecraft:warped_nylium" ++ }, ++ "minecraft:warped_planks": { ++ "kind": "BLOCK", ++ "block": "minecraft:warped_planks" ++ }, ++ "minecraft:warped_pressure_plate": { ++ "kind": "BLOCK", ++ "block": "minecraft:warped_pressure_plate" ++ }, ++ "minecraft:warped_roots": { ++ "kind": "BLOCK", ++ "block": "minecraft:warped_roots" ++ }, ++ "minecraft:warped_shelf": { ++ "kind": "BLOCK", ++ "block": "minecraft:warped_shelf" ++ }, ++ "minecraft:warped_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:warped_slab" ++ }, ++ "minecraft:warped_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:warped_stairs" ++ }, ++ "minecraft:warped_standing_sign": { ++ "kind": "BLOCK", ++ "block": "minecraft:warped_standing_sign" ++ }, ++ "minecraft:warped_stem": { ++ "kind": "BLOCK", ++ "block": "minecraft:warped_stem" ++ }, ++ "minecraft:warped_trapdoor": { ++ "kind": "BLOCK", ++ "block": "minecraft:warped_trapdoor" ++ }, ++ "minecraft:warped_wall_sign": { ++ "kind": "BLOCK", ++ "block": "minecraft:warped_wall_sign" ++ }, ++ "minecraft:warped_wart_block": { ++ "kind": "BLOCK", ++ "block": "minecraft:warped_wart_block" ++ }, ++ "minecraft:water": { ++ "kind": "BLOCK", ++ "block": "minecraft:water" ++ }, ++ "minecraft:waterlily": { ++ "kind": "BLOCK", ++ "block": "minecraft:waterlily" ++ }, ++ "minecraft:waxed_chiseled_copper": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_chiseled_copper" ++ }, ++ "minecraft:waxed_copper": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_copper" ++ }, ++ "minecraft:waxed_copper_bars": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_copper_bars" ++ }, ++ "minecraft:waxed_copper_bulb": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_copper_bulb" ++ }, ++ "minecraft:waxed_copper_chain": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_copper_chain" ++ }, ++ "minecraft:waxed_copper_chest": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_copper_chest" ++ }, ++ "minecraft:waxed_copper_golem_statue": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_copper_golem_statue" ++ }, ++ "minecraft:waxed_copper_grate": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_copper_grate" ++ }, ++ "minecraft:waxed_copper_lantern": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_copper_lantern" ++ }, ++ "minecraft:waxed_copper_trapdoor": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_copper_trapdoor" ++ }, ++ "minecraft:waxed_cut_copper": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_cut_copper" ++ }, ++ "minecraft:waxed_cut_copper_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_cut_copper_slab" ++ }, ++ "minecraft:waxed_cut_copper_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_cut_copper_stairs" ++ }, ++ "minecraft:waxed_double_cut_copper_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_double_cut_copper_slab" ++ }, ++ "minecraft:waxed_exposed_chiseled_copper": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_exposed_chiseled_copper" ++ }, ++ "minecraft:waxed_exposed_copper": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_exposed_copper" ++ }, ++ "minecraft:waxed_exposed_copper_bars": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_exposed_copper_bars" ++ }, ++ "minecraft:waxed_exposed_copper_bulb": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_exposed_copper_bulb" ++ }, ++ "minecraft:waxed_exposed_copper_chain": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_exposed_copper_chain" ++ }, ++ "minecraft:waxed_exposed_copper_chest": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_exposed_copper_chest" ++ }, ++ "minecraft:waxed_exposed_copper_golem_statue": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_exposed_copper_golem_statue" ++ }, ++ "minecraft:waxed_exposed_copper_grate": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_exposed_copper_grate" ++ }, ++ "minecraft:waxed_exposed_copper_lantern": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_exposed_copper_lantern" ++ }, ++ "minecraft:waxed_exposed_copper_trapdoor": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_exposed_copper_trapdoor" ++ }, ++ "minecraft:waxed_exposed_cut_copper": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_exposed_cut_copper" ++ }, ++ "minecraft:waxed_exposed_cut_copper_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_exposed_cut_copper_slab" ++ }, ++ "minecraft:waxed_exposed_cut_copper_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_exposed_cut_copper_stairs" ++ }, ++ "minecraft:waxed_exposed_double_cut_copper_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_exposed_double_cut_copper_slab" ++ }, ++ "minecraft:waxed_exposed_lightning_rod": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_exposed_lightning_rod" ++ }, ++ "minecraft:waxed_lightning_rod": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_lightning_rod" ++ }, ++ "minecraft:waxed_oxidized_chiseled_copper": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_oxidized_chiseled_copper" ++ }, ++ "minecraft:waxed_oxidized_copper": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_oxidized_copper" ++ }, ++ "minecraft:waxed_oxidized_copper_bars": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_oxidized_copper_bars" ++ }, ++ "minecraft:waxed_oxidized_copper_bulb": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_oxidized_copper_bulb" ++ }, ++ "minecraft:waxed_oxidized_copper_chain": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_oxidized_copper_chain" ++ }, ++ "minecraft:waxed_oxidized_copper_chest": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_oxidized_copper_chest" ++ }, ++ "minecraft:waxed_oxidized_copper_golem_statue": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_oxidized_copper_golem_statue" ++ }, ++ "minecraft:waxed_oxidized_copper_grate": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_oxidized_copper_grate" ++ }, ++ "minecraft:waxed_oxidized_copper_lantern": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_oxidized_copper_lantern" ++ }, ++ "minecraft:waxed_oxidized_copper_trapdoor": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_oxidized_copper_trapdoor" ++ }, ++ "minecraft:waxed_oxidized_cut_copper": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_oxidized_cut_copper" ++ }, ++ "minecraft:waxed_oxidized_cut_copper_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_oxidized_cut_copper_slab" ++ }, ++ "minecraft:waxed_oxidized_cut_copper_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_oxidized_cut_copper_stairs" ++ }, ++ "minecraft:waxed_oxidized_double_cut_copper_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_oxidized_double_cut_copper_slab" ++ }, ++ "minecraft:waxed_oxidized_lightning_rod": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_oxidized_lightning_rod" ++ }, ++ "minecraft:waxed_weathered_chiseled_copper": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_weathered_chiseled_copper" ++ }, ++ "minecraft:waxed_weathered_copper": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_weathered_copper" ++ }, ++ "minecraft:waxed_weathered_copper_bars": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_weathered_copper_bars" ++ }, ++ "minecraft:waxed_weathered_copper_bulb": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_weathered_copper_bulb" ++ }, ++ "minecraft:waxed_weathered_copper_chain": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_weathered_copper_chain" ++ }, ++ "minecraft:waxed_weathered_copper_chest": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_weathered_copper_chest" ++ }, ++ "minecraft:waxed_weathered_copper_golem_statue": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_weathered_copper_golem_statue" ++ }, ++ "minecraft:waxed_weathered_copper_grate": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_weathered_copper_grate" ++ }, ++ "minecraft:waxed_weathered_copper_lantern": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_weathered_copper_lantern" ++ }, ++ "minecraft:waxed_weathered_copper_trapdoor": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_weathered_copper_trapdoor" ++ }, ++ "minecraft:waxed_weathered_cut_copper": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_weathered_cut_copper" ++ }, ++ "minecraft:waxed_weathered_cut_copper_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_weathered_cut_copper_slab" ++ }, ++ "minecraft:waxed_weathered_cut_copper_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_weathered_cut_copper_stairs" ++ }, ++ "minecraft:waxed_weathered_double_cut_copper_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_weathered_double_cut_copper_slab" ++ }, ++ "minecraft:waxed_weathered_lightning_rod": { ++ "kind": "BLOCK", ++ "block": "minecraft:waxed_weathered_lightning_rod" ++ }, ++ "minecraft:weathered_chiseled_copper": { ++ "kind": "BLOCK", ++ "block": "minecraft:weathered_chiseled_copper" ++ }, ++ "minecraft:weathered_copper": { ++ "kind": "BLOCK", ++ "block": "minecraft:weathered_copper" ++ }, ++ "minecraft:weathered_copper_bars": { ++ "kind": "BLOCK", ++ "block": "minecraft:weathered_copper_bars" ++ }, ++ "minecraft:weathered_copper_bulb": { ++ "kind": "BLOCK", ++ "block": "minecraft:weathered_copper_bulb" ++ }, ++ "minecraft:weathered_copper_chain": { ++ "kind": "BLOCK", ++ "block": "minecraft:weathered_copper_chain" ++ }, ++ "minecraft:weathered_copper_chest": { ++ "kind": "BLOCK", ++ "block": "minecraft:weathered_copper_chest" ++ }, ++ "minecraft:weathered_copper_golem_statue": { ++ "kind": "BLOCK", ++ "block": "minecraft:weathered_copper_golem_statue" ++ }, ++ "minecraft:weathered_copper_grate": { ++ "kind": "BLOCK", ++ "block": "minecraft:weathered_copper_grate" ++ }, ++ "minecraft:weathered_copper_lantern": { ++ "kind": "BLOCK", ++ "block": "minecraft:weathered_copper_lantern" ++ }, ++ "minecraft:weathered_copper_trapdoor": { ++ "kind": "BLOCK", ++ "block": "minecraft:weathered_copper_trapdoor" ++ }, ++ "minecraft:weathered_cut_copper": { ++ "kind": "BLOCK", ++ "block": "minecraft:weathered_cut_copper" ++ }, ++ "minecraft:weathered_cut_copper_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:weathered_cut_copper_slab" ++ }, ++ "minecraft:weathered_cut_copper_stairs": { ++ "kind": "BLOCK", ++ "block": "minecraft:weathered_cut_copper_stairs" ++ }, ++ "minecraft:weathered_double_cut_copper_slab": { ++ "kind": "BLOCK", ++ "block": "minecraft:weathered_double_cut_copper_slab" ++ }, ++ "minecraft:weathered_lightning_rod": { ++ "kind": "BLOCK", ++ "block": "minecraft:weathered_lightning_rod" ++ }, ++ "minecraft:web": { ++ "kind": "BLOCK", ++ "block": "minecraft:web" ++ }, ++ "minecraft:weeping_vines": { ++ "kind": "BLOCK", ++ "block": "minecraft:weeping_vines" ++ }, ++ "minecraft:wet_sponge": { ++ "kind": "BLOCK", ++ "block": "minecraft:wet_sponge" ++ }, ++ "minecraft:white_candle": { ++ "kind": "BLOCK", ++ "block": "minecraft:white_candle" ++ }, ++ "minecraft:white_candle_cake": { ++ "kind": "BLOCK", ++ "block": "minecraft:white_candle_cake" ++ }, ++ "minecraft:white_carpet": { ++ "kind": "WOOL", ++ "block": "minecraft:white_carpet", ++ "fallback": "tile.carpet.name" ++ }, ++ "minecraft:white_concrete": { ++ "kind": "BLOCK", ++ "block": "minecraft:white_concrete" ++ }, ++ "minecraft:white_concrete_powder": { ++ "kind": "BLOCK", ++ "block": "minecraft:white_concrete_powder" ++ }, ++ "minecraft:white_glazed_terracotta": { ++ "kind": "BLOCK", ++ "block": "minecraft:white_glazed_terracotta" ++ }, ++ "minecraft:white_shulker_box": { ++ "kind": "BLOCK", ++ "block": "minecraft:white_shulker_box" ++ }, ++ "minecraft:white_stained_glass": { ++ "kind": "BLOCK", ++ "block": "minecraft:white_stained_glass" ++ }, ++ "minecraft:white_stained_glass_pane": { ++ "kind": "BLOCK", ++ "block": "minecraft:white_stained_glass_pane" ++ }, ++ "minecraft:white_terracotta": { ++ "kind": "BLOCK", ++ "block": "minecraft:white_terracotta" ++ }, ++ "minecraft:white_tulip": { ++ "kind": "BLOCK", ++ "block": "minecraft:white_tulip" ++ }, ++ "minecraft:white_wool": { ++ "kind": "WOOL", ++ "block": "minecraft:white_wool", ++ "fallback": "tile.wool.name" ++ }, ++ "minecraft:wildflowers": { ++ "kind": "BLOCK", ++ "block": "minecraft:wildflowers" ++ }, ++ "minecraft:wither_rose": { ++ "kind": "BLOCK", ++ "block": "minecraft:wither_rose" ++ }, ++ "minecraft:wither_skeleton_skull": { ++ "kind": "BLOCK", ++ "block": "minecraft:wither_skeleton_skull" ++ }, ++ "minecraft:wooden_button": { ++ "kind": "BLOCK", ++ "block": "minecraft:wooden_button" ++ }, ++ "minecraft:wooden_pressure_plate": { ++ "kind": "BLOCK", ++ "block": "minecraft:wooden_pressure_plate" ++ }, ++ "minecraft:yellow_candle": { ++ "kind": "BLOCK", ++ "block": "minecraft:yellow_candle" ++ }, ++ "minecraft:yellow_candle_cake": { ++ "kind": "BLOCK", ++ "block": "minecraft:yellow_candle_cake" ++ }, ++ "minecraft:yellow_carpet": { ++ "kind": "WOOL", ++ "block": "minecraft:yellow_carpet", ++ "fallback": "tile.carpet.name" ++ }, ++ "minecraft:yellow_concrete": { ++ "kind": "BLOCK", ++ "block": "minecraft:yellow_concrete" ++ }, ++ "minecraft:yellow_concrete_powder": { ++ "kind": "BLOCK", ++ "block": "minecraft:yellow_concrete_powder" ++ }, ++ "minecraft:yellow_glazed_terracotta": { ++ "kind": "BLOCK", ++ "block": "minecraft:yellow_glazed_terracotta" ++ }, ++ "minecraft:yellow_poplar_leaves": { ++ "kind": "BLOCK", ++ "block": "minecraft:yellow_poplar_leaves" ++ }, ++ "minecraft:yellow_shulker_box": { ++ "kind": "BLOCK", ++ "block": "minecraft:yellow_shulker_box" ++ }, ++ "minecraft:yellow_stained_glass": { ++ "kind": "BLOCK", ++ "block": "minecraft:yellow_stained_glass" ++ }, ++ "minecraft:yellow_stained_glass_pane": { ++ "kind": "BLOCK", ++ "block": "minecraft:yellow_stained_glass_pane" ++ }, ++ "minecraft:yellow_terracotta": { ++ "kind": "BLOCK", ++ "block": "minecraft:yellow_terracotta" ++ }, ++ "minecraft:yellow_wool": { ++ "kind": "WOOL", ++ "block": "minecraft:yellow_wool", ++ "fallback": "tile.wool.name" ++ }, ++ "minecraft:zombie_head": { ++ "kind": "BLOCK", ++ "block": "minecraft:zombie_head" ++ } ++ }, ++ "potionNames": { ++ "minecraft:lingering_potion": 2, ++ "minecraft:potion": 0, ++ "minecraft:splash_potion": 1 ++ }, ++ "arrowNames": { ++ "minecraft:arrow": "item.arrow" ++ }, ++ "compoundNames": [ ++ "minecraft:compound" ++ ] ++} +diff --git a/src/main/resources/assets/viabedrock/data/bedrock/potion_names.json b/src/main/resources/assets/viabedrock/data/bedrock/potion_names.json +new file mode 100644 +index 000000000..256564782 +--- /dev/null ++++ b/src/main/resources/assets/viabedrock/data/bedrock/potion_names.json +@@ -0,0 +1,193 @@ ++{ ++ "format": 1, ++ "protocol": 2193, ++ "entries": [ ++ { ++ "description": "" ++ }, ++ { ++ "description": "mundane" ++ }, ++ { ++ "description": "mundane.extended" ++ }, ++ { ++ "description": "thick" ++ }, ++ { ++ "description": "awkward" ++ }, ++ { ++ "description": "", ++ "effect": "nightVision" ++ }, ++ { ++ "description": "", ++ "effect": "nightVision" ++ }, ++ { ++ "description": "", ++ "effect": "invisibility" ++ }, ++ { ++ "description": "", ++ "effect": "invisibility" ++ }, ++ { ++ "description": "", ++ "effect": "jump" ++ }, ++ { ++ "description": "", ++ "effect": "jump" ++ }, ++ { ++ "description": "", ++ "effect": "jump" ++ }, ++ { ++ "description": "", ++ "effect": "fireResistance" ++ }, ++ { ++ "description": "", ++ "effect": "fireResistance" ++ }, ++ { ++ "description": "", ++ "effect": "moveSpeed" ++ }, ++ { ++ "description": "", ++ "effect": "moveSpeed" ++ }, ++ { ++ "description": "", ++ "effect": "moveSpeed" ++ }, ++ { ++ "description": "", ++ "effect": "moveSlowdown" ++ }, ++ { ++ "description": "", ++ "effect": "moveSlowdown" ++ }, ++ { ++ "description": "", ++ "effect": "waterBreathing" ++ }, ++ { ++ "description": "", ++ "effect": "waterBreathing" ++ }, ++ { ++ "description": "", ++ "effect": "heal" ++ }, ++ { ++ "description": "", ++ "effect": "heal" ++ }, ++ { ++ "description": "", ++ "effect": "harm" ++ }, ++ { ++ "description": "", ++ "effect": "harm" ++ }, ++ { ++ "description": "", ++ "effect": "poison" ++ }, ++ { ++ "description": "", ++ "effect": "poison" ++ }, ++ { ++ "description": "", ++ "effect": "poison" ++ }, ++ { ++ "description": "", ++ "effect": "regeneration" ++ }, ++ { ++ "description": "", ++ "effect": "regeneration" ++ }, ++ { ++ "description": "", ++ "effect": "regeneration" ++ }, ++ { ++ "description": "", ++ "effect": "damageBoost" ++ }, ++ { ++ "description": "", ++ "effect": "damageBoost" ++ }, ++ { ++ "description": "", ++ "effect": "damageBoost" ++ }, ++ { ++ "description": "", ++ "effect": "weakness" ++ }, ++ { ++ "description": "", ++ "effect": "weakness" ++ }, ++ { ++ "description": "", ++ "effect": "wither" ++ }, ++ { ++ "description": "", ++ "effect": "turtleMaster" ++ }, ++ { ++ "description": "", ++ "effect": "turtleMaster" ++ }, ++ { ++ "description": "", ++ "effect": "turtleMaster" ++ }, ++ { ++ "description": "", ++ "effect": "slowFalling" ++ }, ++ { ++ "description": "", ++ "effect": "slowFalling" ++ }, ++ { ++ "description": "", ++ "effect": "moveSlowdown" ++ }, ++ { ++ "description": "", ++ "effect": "windCharged", ++ "minimumVersion": "1.21.0" ++ }, ++ { ++ "description": "", ++ "effect": "weaving", ++ "minimumVersion": "1.21.0" ++ }, ++ { ++ "description": "", ++ "effect": "oozing", ++ "minimumVersion": "1.21.0" ++ }, ++ { ++ "description": "", ++ "effect": "infested", ++ "minimumVersion": "1.21.0" ++ } ++ ] ++} +diff --git a/src/test/java/net/raphimc/viabedrock/api/model/NativeItemNamesTest.java b/src/test/java/net/raphimc/viabedrock/api/model/NativeItemNamesTest.java +index fa4932829..742aafb3e 100644 +--- a/src/test/java/net/raphimc/viabedrock/api/model/NativeItemNamesTest.java ++++ b/src/test/java/net/raphimc/viabedrock/api/model/NativeItemNamesTest.java +@@ -12,15 +12,24 @@ import com.viaversion.nbt.tag.StringTag; + import com.viaversion.nbt.tag.Tag; + import com.viaversion.viaversion.libs.gson.JsonObject; + import com.viaversion.viaversion.libs.gson.JsonParser; ++import io.netty.buffer.Unpooled; ++import net.raphimc.viabedrock.api.resourcepack.ResourcePack; ++import net.raphimc.viabedrock.api.resourcepack.content.Content; ++import net.raphimc.viabedrock.api.resourcepack.definition.TextDefinitions; ++import net.raphimc.viabedrock.protocol.types.BedrockTypes; ++import net.raphimc.viabedrock.protocol.storage.ResourcePackStorage; + import org.junit.jupiter.api.Test; + import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + + import java.nio.file.Files; + import java.nio.file.Path; + import java.util.ArrayList; ++import java.util.HashMap; ++import java.util.HexFormat; + import java.util.List; + + import static org.junit.jupiter.api.Assertions.*; ++import static org.mockito.Mockito.*; + + class NativeItemNamesTest { + +@@ -104,6 +113,59 @@ class NativeItemNamesTest { + return translations.has(key) ? translations.get(key).getAsString() : key; + } + ++ @Test ++ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_PRIMARY_COMPOSITION", matches = ".+") ++ void primaryAndOrdinaryNamesComposeWithTheNativeLanguageManager() throws Exception { ++ final var fixture = JsonParser.parseString(Files.readString(Path.of( ++ System.getenv("STACKANVIL_NATIVE_PRIMARY_COMPOSITION")))).getAsJsonObject(); ++ final var hex = HexFormat.of(); ++ int cases = 0; ++ for (final var element : fixture.getAsJsonArray("rows")) { ++ final var row = element.getAsJsonObject(); ++ final var table = row.has("translations") ? row.getAsJsonObject("translations") : fixture.getAsJsonObject("translations"); ++ final var translations = new HashMap(); ++ table.entrySet().forEach(entry -> translations.put(entry.getKey(), entry.getValue().getAsString())); ++ final var content = mock(Content.class); ++ when(content.contains("texts/en_US.lang")).thenReturn(true); ++ when(content.getLang("texts/en_US.lang")).thenReturn(translations); ++ final var pack = mock(ResourcePack.class); ++ when(pack.content()).thenReturn(content); ++ final var storage = mock(ResourcePackStorage.class); ++ when(storage.getPackStackBottomToTop()).thenReturn(List.of(pack)); ++ final var texts = new TextDefinitions(storage); ++ final CompoundTag data = row.get("data").isJsonNull() ? null : compound(row.getAsJsonObject("data")); ++ final String key = row.get("ordinaryKey").getAsString(); ++ final boolean registered = row.get("registered").getAsBoolean(); ++ final boolean writtenBook = row.get("writtenBook").getAsBoolean(); ++ final byte[] expected = hex.parseHex(row.get("result").getAsString()); ++ assertNativeBytes(expected, NativeItemNames.primary(registered, data, writtenBook, ++ () -> texts.translate(key), texts::translate, (body, arguments) -> texts.translate(body, arguments.toArray())), ++ "native primary composition " + cases); ++ if (registered && NativeItemNames.customName(data, writtenBook) == null) { ++ assertNativeBytes(expected, NativeItemNames.ordinary(data, ++ () -> texts.translate(key), texts::translate, (body, arguments) -> texts.translate(body, arguments.toArray())), ++ "native ordinary composition " + cases); ++ } ++ cases++; ++ } ++ assertEquals(2442, cases); ++ } ++ ++ private static void assertNativeBytes(final byte[] expected, final String actual, final String context) { ++ final var buffer = Unpooled.buffer(); ++ try { ++ BedrockTypes.TAG_LE.write(buffer, new StringTag(actual)); ++ assertEquals(8, buffer.readUnsignedByte()); ++ assertEquals(0, buffer.readUnsignedShortLE()); ++ final byte[] bytes = new byte[buffer.readUnsignedShortLE()]; ++ buffer.readBytes(bytes); ++ assertFalse(buffer.isReadable()); ++ assertArrayEquals(expected, bytes, context); ++ } finally { ++ buffer.release(); ++ } ++ } ++ + private static CompoundTag compound(final JsonObject object) { + final var result = new CompoundTag(); + for (final var entry : object.entrySet()) { +diff --git a/src/test/java/net/raphimc/viabedrock/api/model/NativeItemStackingTest.java b/src/test/java/net/raphimc/viabedrock/api/model/NativeItemStackingTest.java +new file mode 100644 +index 000000000..75b7dbfbd +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/api/model/NativeItemStackingTest.java +@@ -0,0 +1,149 @@ ++/* ++ * 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; ++import com.viaversion.nbt.tag.IntTag; ++import com.viaversion.nbt.tag.ListTag; ++import com.viaversion.viaversion.libs.gson.JsonParser; ++import io.netty.buffer.Unpooled; ++import net.raphimc.viabedrock.api.model.container.NativeContainerTransfer; ++import net.raphimc.viabedrock.protocol.model.BedrockItem; ++import net.raphimc.viabedrock.protocol.model.ComponentItemStackingProperties; ++import net.raphimc.viabedrock.protocol.types.BedrockTypes; ++import org.junit.jupiter.api.Test; ++import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; ++ ++import java.nio.file.Files; ++import java.nio.file.Path; ++import java.util.Arrays; ++import java.util.HexFormat; ++import java.util.Set; ++import java.util.function.Function; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++final class NativeItemStackingTest { ++ ++ @Test ++ void comparesAuxiliaryValuesOnlyForSubtypeItemsWithoutWildcardMatching() { ++ final var source = new BedrockItem(259, (short) 0, (byte) 4); ++ final var destination = new BedrockItem(259, (short) 1, (byte) 1); ++ assertTrue(canMerge(source, destination, false, false)); ++ assertFalse(canMerge(source, destination, true, false)); ++ source.setData(Short.MAX_VALUE); ++ assertTrue(canMerge(source, destination, false, false)); ++ assertFalse(canMerge(source, destination, true, false)); ++ destination.setData(Short.MAX_VALUE); ++ assertTrue(canMerge(source, destination, true, false)); ++ destination.setIdentifier(260); ++ assertFalse(canMerge(source, destination, false, false)); ++ } ++ ++ @Test ++ void usesDestinationDurabilityAndTypedUnbreakableStateBeforeMerging() { ++ final var tag = new CompoundTag(); ++ tag.putInt("Damage", 1); ++ final var source = new BedrockItem(259, (short) 0, (byte) 4, tag); ++ final var destination = source.copy(); ++ assertFalse(canMerge(source, destination, false, true)); ++ assertTrue(canMerge(source, destination, false, false)); ++ source.tag().putByte("Unbreakable", (byte) 1); ++ destination.setTag(source.tag().copy()); ++ assertTrue(canMerge(source, destination, false, true)); ++ source.tag().putInt("Unbreakable", 1); ++ destination.setTag(source.tag().copy()); ++ assertFalse(canMerge(source, destination, false, true)); ++ source.tag().putInt("Damage", 65535); ++ destination.setTag(source.tag().copy()); ++ assertTrue(canMerge(source, destination, false, true)); ++ } ++ ++ @Test ++ void keepsNativeUserdataRestrictionAndBlockingComparisons() { ++ final var source = new BedrockItem(259); ++ final var destination = source.copy(); ++ destination.setTag(new CompoundTag()); ++ assertTrue(canMerge(source, destination, false, false)); ++ source.setTag(new CompoundTag()); ++ source.tag().putFloat("value", 0F); ++ destination.tag().putFloat("value", -0F); ++ assertTrue(canMerge(source, destination, false, false)); ++ source.tag().put("value", new ListTag<>(IntTag.class)); ++ destination.tag().put("value", new ListTag<>(FloatTag.class)); ++ assertFalse(canMerge(source, destination, false, false)); ++ destination.setTag(source.tag().copy()); ++ destination.setBlockingTicks(1); ++ assertFalse(canMerge(source, destination, false, false)); ++ destination.setBlockingTicks(0); ++ source.setCanPlace(new String[] {"minecraft:stone", "minecraft:dirt", "minecraft:stone"}); ++ destination.setCanPlace(new String[] {"minecraft:dirt", "minecraft:stone"}); ++ assertTrue(canMerge(source, destination, false, false)); ++ destination.setCanBreak(new String[] {"minecraft:stone"}); ++ assertFalse(canMerge(source, destination, false, false)); ++ } ++ ++ @Test ++ void comparesEffectiveBlockAuxiliaryValuesInsteadOfRawRuntimeIds() { ++ final var source = new BedrockItem(259); ++ final var destination = source.copy(); ++ source.setBlockRuntimeId(1); ++ destination.setBlockRuntimeId(2); ++ final var properties = new ComponentItemStackingProperties(64, null, true, false); ++ final Function auxiliary = item -> item.blockRuntimeId() < 3 ? (short) 7 : (short) 8; ++ assertTrue(NativeItemStacking.canMerge(source, destination, properties, auxiliary, NativeItemStackingTest::restrictions)); ++ destination.setBlockRuntimeId(3); ++ assertFalse(NativeItemStacking.canMerge(source, destination, properties, auxiliary, NativeItemStackingTest::restrictions)); ++ } ++ ++ @Test ++ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_CONTAINER_MERGE", matches = ".+") ++ void matchesNativeMergePredicatesAndOccupiedDestinationSelection() throws Exception { ++ final var reference = JsonParser.parseString(Files.readString(Path.of( ++ System.getenv("STACKANVIL_NATIVE_CONTAINER_MERGE")))).getAsJsonObject(); ++ for (final var element : reference.getAsJsonArray("cases")) { ++ final var row = element.getAsJsonObject(); ++ final var properties = new ComponentItemStackingProperties(row.get("maximum").getAsInt(), null, ++ row.get("stackedByData").getAsBoolean(), row.get("damageable").getAsBoolean()); ++ final var source = new BedrockItem(259, row.get("sourceAux").getAsShort(), (byte) 4); ++ final var destination = new BedrockItem(259, row.get("destinationAux").getAsShort(), (byte) 1); ++ if (!row.get("nbtHex").isJsonNull()) { ++ final var bytes = Unpooled.wrappedBuffer(HexFormat.of().parseHex(row.get("nbtHex").getAsString())); ++ try { ++ source.setTag((CompoundTag) BedrockTypes.TAG_LE.read(bytes)); ++ assertFalse(bytes.isReadable()); ++ destination.setTag(source.tag().copy()); ++ } finally { ++ bytes.release(); ++ } ++ } ++ final boolean expected = row.get("nativeCanMerge").getAsBoolean(); ++ assertEquals(row.get("nativeStackable").getAsBoolean(), properties.stackable(destination), row::toString); ++ assertEquals(expected, NativeItemStacking.canMerge(source, destination, properties, ++ BedrockItem::data, NativeItemStackingTest::restrictions), row::toString); ++ final var inventory = BedrockItem.emptyArray(36); ++ inventory[0] = destination; ++ final var plan = NativeContainerTransfer.plan(source, inventory, properties.maximum(), ++ (from, to) -> NativeItemStacking.canMerge(from, to, properties, ++ BedrockItem::data, NativeItemStackingTest::restrictions), slot -> true); ++ assertNotNull(plan); ++ assertEquals(expected && properties.maximum() > destination.amount() ? 0 : 1, plan.destinationSlot(), row::toString); ++ } ++ } ++ ++ private static boolean canMerge(final BedrockItem source, final BedrockItem destination, ++ final boolean subtype, final boolean damageable) { ++ return NativeItemStacking.canMerge(source, destination, ++ new ComponentItemStackingProperties(64, null, subtype, damageable), ++ BedrockItem::data, NativeItemStackingTest::restrictions); ++ } ++ ++ private static Set restrictions(final String[] values) { ++ return Set.copyOf(Arrays.asList(values)); ++ } ++ ++} +diff --git a/src/test/java/net/raphimc/viabedrock/api/model/container/NativeContainerLegacyActionsTest.java b/src/test/java/net/raphimc/viabedrock/api/model/container/NativeContainerLegacyActionsTest.java +new file mode 100644 +index 000000000..88dd3e3f2 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/api/model/container/NativeContainerLegacyActionsTest.java +@@ -0,0 +1,290 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.model.container; ++ ++import java.util.Set; ++ ++import com.viaversion.nbt.tag.CompoundTag; ++import com.viaversion.viaversion.api.connection.UserConnection; ++import com.viaversion.viaversion.libs.gson.JsonParser; ++import java.util.Map; ++import java.util.List; ++import net.raphimc.viabedrock.api.model.container.block.HopperContainer; ++import net.raphimc.viabedrock.api.model.container.block.Generic3x3Container; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ContainerType; ++import java.util.concurrent.atomic.AtomicReference; ++import net.raphimc.viabedrock.api.model.NativeContainerAction; ++import net.raphimc.viabedrock.api.model.NativeContainerMessage; ++import net.raphimc.viabedrock.api.model.container.player.HudContainer; ++import net.raphimc.viabedrock.api.model.container.player.InventoryContainer; ++import net.raphimc.viabedrock.api.resourcepack.ui.ContainerUi; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.ComplexInventoryTransaction_Type; ++import net.raphimc.viabedrock.protocol.model.BedrockItem; ++import net.raphimc.viabedrock.protocol.model.inventory.BedrockInventoryTransaction; ++import net.raphimc.viabedrock.protocol.storage.GameSessionStorage; ++import net.raphimc.viabedrock.protocol.storage.InventoryTracker; ++import net.raphimc.viabedrock.protocol.storage.NativeContainerUiState; ++import net.raphimc.viabedrock.protocol.rewriter.ItemRewriter; ++import net.raphimc.viabedrock.protocol.rewriter.InventoryTransactionRewriter; ++import net.raphimc.viabedrock.protocol.types.item.NetworkItemStackDescriptorType; ++import com.viaversion.viaversion.libs.fastutil.ints.Int2ObjectOpenHashMap; ++import io.netty.buffer.Unpooled; ++import org.junit.jupiter.api.Test; ++import static org.junit.jupiter.api.Assertions.*; ++import static org.mockito.Mockito.*; ++ ++final class NativeContainerLegacyActionsTest { ++ ++ @Test ++ void sendsBalancedNormalTransactionAndReadsCorrectionsWithoutWaitingForModernResponses() { ++ final var fixture = new Fixture(); ++ fixture.inventory.setItem(3, named(63)); ++ fixture.inventory.setItem(9, named(62)); ++ final var sent = new AtomicReference(); ++ assertTrue(fixture.actions.autoPlaceOne(9, item -> 64, NativeContainerLegacyActionsTest::matches, sent::set)); ++ final var transaction = sent.get(); ++ assertEquals(0, transaction.legacyRequestId()); ++ assertEquals(ComplexInventoryTransaction_Type.NormalTransaction, transaction.transactionType()); ++ assertEquals(2, transaction.actions().size()); ++ final var debit = transaction.actions().get(0); ++ final var credit = transaction.actions().get(1); ++ assertEquals(6, debit.source().containerId()); ++ assertEquals(9, debit.slot()); ++ assertEquals(4, debit.fromItem().amount()); ++ assertEquals(3, debit.toItem().amount()); ++ assertEquals(0, credit.source().containerId()); ++ assertEquals(3, credit.slot()); ++ assertEquals(63, credit.fromItem().amount()); ++ assertEquals(64, credit.toItem().amount()); ++ assertEquals(3, fixture.menu.getItem(9).amount()); ++ assertEquals(64, fixture.inventory.getItem(3).amount()); ++ assertEquals(62, fixture.inventory.getItem(9).amount()); ++ assertEquals(0, fixture.menu.getItem(9).netId()); ++ fixture.menu.setItem(9, named(4)); ++ fixture.inventory.setItem(3, named(60)); ++ assertTrue(fixture.actions.autoPlaceOne(9, item -> 64, NativeContainerLegacyActionsTest::matches, sent::set)); ++ assertEquals(4, sent.get().actions().get(0).fromItem().amount()); ++ assertEquals(60, sent.get().actions().get(1).fromItem().amount()); ++ assertEquals(61, fixture.inventory.getItem(3).amount()); ++ } ++ ++ @Test ++ void encodesNormalActionsWithLegacyNetworkItemDescriptors() { ++ final var fixture = new Fixture(); ++ final var itemRewriter = mock(ItemRewriter.class); ++ when(fixture.user.get(ItemRewriter.class)).thenReturn(itemRewriter); ++ when(itemRewriter.newItemType()).thenReturn( ++ new NetworkItemStackDescriptorType(0, ++ new Int2ObjectOpenHashMap<>(), false)); ++ final var codec = new InventoryTransactionRewriter(fixture.user) ++ .getInventoryTransactionType(); ++ final var sent = new AtomicReference(); ++ assertTrue(fixture.actions.autoPlaceOne(9, item -> 64, NativeContainerLegacyActionsTest::matches, sent::set)); ++ final var wire = Unpooled.buffer(); ++ try { ++ codec.write(wire, sent.get()); ++ final byte[] header = new byte[4]; ++ wire.getBytes(0, header); ++ assertArrayEquals(new byte[] {0, 0, 0, 2}, header); ++ final var decoded = codec.read(wire); ++ assertEquals(ComplexInventoryTransaction_Type.NormalTransaction, decoded.transactionType()); ++ assertEquals(0, decoded.legacyRequestId()); ++ assertEquals(2, decoded.actions().size()); ++ final var debit = decoded.actions().get(0); ++ final var credit = decoded.actions().get(1); ++ assertEquals(6, debit.source().containerId()); ++ assertEquals(9, debit.slot()); ++ assertEquals(4, debit.fromItem().amount()); ++ assertEquals(3, debit.toItem().amount()); ++ assertEquals(0, credit.source().containerId()); ++ assertEquals(0, credit.slot()); ++ assertTrue(credit.fromItem().isEmpty()); ++ assertEquals(1, credit.toItem().amount()); ++ assertEquals(debit.fromItem().tag(), credit.toItem().tag()); ++ assertNull(credit.toItem().netId()); ++ assertFalse(wire.isReadable()); ++ } finally { ++ wire.release(); ++ } ++ } ++ ++ @Test ++ void rejectsTransportFailuresAndStaleOrUnauthorizedActionsWithoutMutatingSlots() { ++ final var fixture = new Fixture(); ++ assertThrows(IllegalStateException.class, () -> fixture.actions.autoPlaceOne(9, item -> 64, NativeContainerLegacyActionsTest::matches, ++ transaction -> { ++ throw new IllegalStateException("Transport failed"); ++ })); ++ assertEquals(4, fixture.menu.getItem(9).amount()); ++ assertTrue(fixture.inventory.getItem(0).isEmpty()); ++ assertFalse(fixture.actions.autoPlaceOne(10, item -> 64, NativeContainerLegacyActionsTest::matches, transaction -> fail())); ++ when(fixture.game.isInventoryServerAuthoritative()).thenReturn(true); ++ assertFalse(fixture.actions.autoPlaceOne(9, item -> 64, NativeContainerLegacyActionsTest::matches, transaction -> fail())); ++ when(fixture.game.isInventoryServerAuthoritative()).thenReturn(false); ++ when(fixture.tracker.getCurrentContainer()).thenReturn(new ChestContainer(fixture.user, (byte) 6, null, null, 27)); ++ assertFalse(fixture.actions.autoPlaceOne(9, item -> 64, NativeContainerLegacyActionsTest::matches, transaction -> fail())); ++ assertFalse(fixture.state.handleAction(new NativeContainerAction(6, fixture.message.sequence(), 0, ++ NativeContainerAction.Mode.ACTIVATE, -1))); ++ assertFalse(fixture.state.blocksVanillaClicks()); ++ } ++ ++ @Test ++ void activationWaitsForAuthoredItemsAndRejectsAnOlderPresentation() { ++ final var fixture = new Fixture(); ++ final var activation = new NativeContainerAction(6, fixture.message.sequence(), 0, ++ NativeContainerAction.Mode.ACTIVATE, -1); ++ assertTrue(fixture.state.handleAction(activation)); ++ fixture.menu.clearItems(); ++ assertFalse(fixture.state.handleAction(activation)); ++ assertFalse(fixture.state.blocksVanillaClicks()); ++ fixture.menu.setItem(9, named(4)); ++ assertNotNull(fixture.state.prepareActions(6, ContainerUi.Kind.CHEST_3_ROWS, ++ new ContainerUi.Title("Actual", false), program(), value -> NativeContainerLegacyActions.create(fixture.user, value))); ++ assertFalse(fixture.state.handleAction(activation)); ++ } ++ ++ @Test ++ void sendsNativeCountOneActionsForEachRedstoneStorageFamily() { ++ for (final var kind : List.of(ContainerUi.Kind.HOPPER, ContainerUi.Kind.DISPENSER, ContainerUi.Kind.DROPPER)) { ++ final var fixture = new Fixture(kind); ++ fixture.inventory.setItem(3, named(63)); ++ assertTrue(fixture.state.handleAction(new NativeContainerAction(6, fixture.message.sequence(), 0, ++ NativeContainerAction.Mode.ACTIVATE, -1))); ++ assertTrue(fixture.state.blocksVanillaClicks()); ++ final var sent = new AtomicReference(); ++ assertTrue(fixture.actions.autoPlaceOne(fixture.sourceSlot, item -> 64, NativeContainerLegacyActionsTest::matches, sent::set)); ++ final var transaction = sent.get(); ++ assertEquals(ComplexInventoryTransaction_Type.NormalTransaction, transaction.transactionType()); ++ assertEquals(0, transaction.legacyRequestId()); ++ assertEquals(2, transaction.actions().size()); ++ final var debit = transaction.actions().get(0); ++ final var credit = transaction.actions().get(1); ++ assertEquals(6, debit.source().containerId()); ++ assertEquals(fixture.sourceSlot, debit.slot()); ++ assertEquals(4, debit.fromItem().amount()); ++ assertEquals(3, debit.toItem().amount()); ++ assertEquals(0, credit.source().containerId()); ++ assertEquals(3, credit.slot()); ++ assertEquals(63, credit.fromItem().amount()); ++ assertEquals(64, credit.toItem().amount()); ++ assertEquals(3, fixture.menu.getItem(fixture.sourceSlot).amount()); ++ assertEquals(64, fixture.inventory.getItem(3).amount()); ++ when(fixture.tracker.getPendingCloseContainer()).thenReturn(fixture.menu); ++ assertFalse(fixture.actions.autoPlaceOne(fixture.sourceSlot, item -> 64, NativeContainerLegacyActionsTest::matches, ignored -> fail())); ++ } ++ } ++ ++ @Test ++ void rejectsMismatchedStorageIdentityAndWorkstationLookalikes() { ++ final var fixture = new Fixture(ContainerUi.Kind.HOPPER); ++ assertFalse(NativeStorageContainers.matches(fixture.menu, ContainerUi.Kind.BREWING_STAND)); ++ assertFalse(NativeStorageContainers.matches(new ChestContainer(fixture.user, (byte) 6, null, null, 5), ContainerUi.Kind.HOPPER)); ++ assertFalse(NativeStorageContainers.supported(new HopperContainer(fixture.user, (byte) 6, ContainerType.DISPENSER, null, null))); ++ assertFalse(NativeStorageContainers.supported(new Container(fixture.user, (byte) 6, ContainerType.HOPPER, null, null, 5) { })); ++ assertTrue(NativeStorageContainers.matches(new HopperContainer(fixture.user, (byte) 6, ContainerType.MINECART_HOPPER, null, null), ContainerUi.Kind.HOPPER)); ++ assertTrue(NativeStorageContainers.matches(new HopperContainer(fixture.user, (byte) 6, ContainerType.CONTAINER, null, null), ContainerUi.Kind.HOPPER)); ++ final var dispenser = new Generic3x3Container(fixture.user, (byte) 6, ContainerType.DISPENSER, null, null); ++ assertTrue(NativeStorageContainers.matches(dispenser, ContainerUi.Kind.DISPENSER)); ++ assertFalse(NativeStorageContainers.matches(dispenser, ContainerUi.Kind.DROPPER)); ++ } ++ ++ private static ContainerUi redstoneProgram(final ContainerUi.Kind kind, final int slot) { ++ final String host = switch (kind) { ++ case HOPPER -> "hopper_screen"; ++ case DISPENSER -> "dispenser_screen"; ++ case DROPPER -> "dropper_screen"; ++ default -> throw new IllegalArgumentException(); ++ }; ++ final int columns = kind == ContainerUi.Kind.HOPPER ? 5 : 3; ++ return ContainerUi.fromProgram(JsonParser.parseString(""" ++ {"definitions": { ++ "redstone.%s": {"parent":"", "value":{"$screen_content":"redstone.panel"}}, ++ "redstone.panel": {"parent":"", "value":{"type":"grid", "collection_name":"container_items", ++ "grid_dimensions":[%d,%d], "controls":[{"action":{"type":"button", "grid_position":[%d,%d], ++ "bindings":[ ++ {"binding_type":"collection", "binding_collection_name":"container_items", "binding_name":"#hover_text", "binding_name_override":"#caption"}, ++ {"binding_type":"view", "source_property_name":"(not (#caption = ''))", "target_property_name":"#visible"}], ++ "button_mappings":[{"from_button_id":"button.menu_select", "to_button_id":"button.container_auto_place_one", "mapping_type":"pressed"}]}}]}} ++ }} ++ """.formatted(host, columns, kind.containerSlots() / columns, slot % columns, slot / columns)).getAsJsonObject()); ++ } ++ ++ private static boolean matches(final BedrockItem query, final BedrockItem candidate) { ++ return NativeItemStackMatcher.matches(query, candidate, ignored -> null, ignored -> Set.of()); ++ } ++ ++ private static BedrockItem named(final int amount) { ++ final var display = new CompoundTag(); ++ display.putString("Name", "Action"); ++ final var tag = new CompoundTag(); ++ tag.put("display", display); ++ final var item = new BedrockItem(1); ++ item.setAmount(amount); ++ item.setNetId(0); ++ item.setTag(tag); ++ return item; ++ } ++ ++ private static ContainerUi program() { ++ return ContainerUi.fromProgram(JsonParser.parseString(""" ++ {"definitions": { ++ "chest.small_chest_screen": {"parent":"", "value":{"$screen_content":"chest.small_chest_panel"}}, ++ "chest.small_chest_panel": {"parent":"", "value":{"type":"grid", "collection_name":"container_items", ++ "grid_dimensions":[27,1], "controls":[{"action":{"type":"button", "grid_position":[9,0], ++ "bindings":[ ++ {"binding_type":"collection", "binding_collection_name":"container_items", "binding_name":"#hover_text", "binding_name_override":"#caption"}, ++ {"binding_type":"view", "source_property_name":"(not (#caption = ''))", "target_property_name":"#visible"}], ++ "button_mappings":[{"from_button_id":"button.menu_select", "to_button_id":"button.container_auto_place_one", "mapping_type":"pressed"}]}}]}} ++ }} ++ """).getAsJsonObject()); ++ } ++ ++ private static final class Fixture { ++ final UserConnection user = mock(UserConnection.class); ++ final InventoryTracker tracker = mock(InventoryTracker.class); ++ final GameSessionStorage game = mock(GameSessionStorage.class); ++ final NativeContainerUiState state = new NativeContainerUiState(); ++ final Container menu; ++ final int sourceSlot; ++ final InventoryContainer inventory = new InventoryContainer(this.user) { ++ @Override ++ protected void onSlotChanged(final int slot, final BedrockItem previous, final BedrockItem next) { } ++ }; ++ final NativeContainerMessage message; ++ final NativeContainerLegacyActions actions; ++ ++ Fixture() { ++ this(ContainerUi.Kind.CHEST_3_ROWS); ++ } ++ ++ Fixture(final ContainerUi.Kind kind) { ++ this.menu = switch (kind) { ++ case HOPPER -> new HopperContainer(this.user, (byte) 6, null, null); ++ case DISPENSER -> new Generic3x3Container(this.user, (byte) 6, ContainerType.DISPENSER, null, null); ++ case DROPPER -> new Generic3x3Container(this.user, (byte) 6, ContainerType.DROPPER, null, null); ++ default -> new ChestContainer(this.user, (byte) 6, null, null, 27); ++ }; ++ this.sourceSlot = kind == ContainerUi.Kind.CHEST_3_ROWS ? 9 : kind.containerSlots() - 1; ++ final Map, Object> stores = Map.of(InventoryTracker.class, this.tracker, ++ GameSessionStorage.class, this.game, NativeContainerUiState.class, this.state); ++ when(this.user.get(any())).thenAnswer(call -> stores.get(call.getArgument(0))); ++ when(this.tracker.getCurrentContainer()).thenReturn(this.menu); ++ when(this.tracker.getInventoryContainer()).thenReturn(this.inventory); ++ when(this.tracker.getHudContainer()).thenReturn(new HudContainer(this.user)); ++ this.menu.setItem(this.sourceSlot, named(4)); ++ final var backend = new AtomicReference(); ++ this.message = this.state.prepareActions(6, kind, ++ new ContainerUi.Title("Actual", false), kind == ContainerUi.Kind.CHEST_3_ROWS ? program() : redstoneProgram(kind, this.sourceSlot), value -> { ++ final var actions = NativeContainerLegacyActions.create(this.user, value); ++ backend.set(actions); ++ return actions; ++ }); ++ assertNotNull(this.message); ++ this.actions = backend.get(); ++ } ++ } ++ ++} +diff --git a/src/test/java/net/raphimc/viabedrock/api/model/container/NativeContainerTransferTest.java b/src/test/java/net/raphimc/viabedrock/api/model/container/NativeContainerTransferTest.java +new file mode 100644 +index 000000000..53926519c +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/api/model/container/NativeContainerTransferTest.java +@@ -0,0 +1,90 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.model.container; ++ ++import com.viaversion.nbt.tag.CompoundTag; ++import net.raphimc.viabedrock.api.model.NativeItemStacking; ++import net.raphimc.viabedrock.protocol.model.BedrockItem; ++import net.raphimc.viabedrock.protocol.model.ComponentItemStackingProperties; ++import org.junit.jupiter.api.Test; ++import java.util.Set; ++import static org.junit.jupiter.api.Assertions.*; ++ ++final class NativeContainerTransferTest { ++ ++ @Test ++ void selectsEligibleMergesBeforeEmptySlotsInNativeProviderOrder() { ++ final var source = item(4); ++ final var inventory = BedrockItem.emptyArray(36); ++ inventory[2] = item(64); ++ inventory[9] = item(63); ++ inventory[35] = item(62); ++ final var merge = NativeContainerTransfer.plan(source, inventory, 64, (from, to) -> !from.isDifferent(to), slot -> true); ++ assertNotNull(merge); ++ assertEquals(9, merge.destinationSlot()); ++ final var later = NativeContainerTransfer.plan(source, inventory, 64, (from, to) -> !from.isDifferent(to), slot -> slot != 9); ++ assertNotNull(later); ++ assertEquals(35, later.destinationSlot()); ++ final var empty = NativeContainerTransfer.plan(source, inventory, 64, (from, to) -> !from.isDifferent(to), slot -> slot < 9); ++ assertNotNull(empty); ++ assertEquals(0, empty.destinationSlot()); ++ assertEquals(4, source.amount()); ++ assertEquals(63, inventory[9].amount()); ++ } ++ ++ @Test ++ void preservesUnsignedCountsAndRemovesOnlyTheFinalSourceItem() { ++ for (final int sourceCount : new int[] {1, 64, 128, 255}) { ++ final var source = item(sourceCount); ++ final var inventory = BedrockItem.emptyArray(36); ++ inventory[0] = item(254); ++ final var plan = NativeContainerTransfer.plan(source, inventory, 255, (from, to) -> !from.isDifferent(to), slot -> true); ++ assertNotNull(plan); ++ assertEquals(sourceCount - 1, plan.sourceAfter().amount()); ++ assertEquals(sourceCount == 1, plan.sourceAfter().isEmpty()); ++ assertEquals(255, plan.destinationAfter().amount()); ++ assertFalse(plan.destinationAfter().isEmpty()); ++ assertEquals(sourceCount + 254, plan.sourceAfter().amount() + plan.destinationAfter().amount()); ++ assertEquals(sourceCount, source.amount()); ++ assertEquals(254, inventory[0].amount()); ++ } ++ } ++ ++ @Test ++ void selectsDestinationsUsingNativeSubtypeAndDurabilityEligibility() { ++ final var source = item(4); ++ final var inventory = BedrockItem.emptyArray(36); ++ inventory[0] = item(60); ++ inventory[0].setData(1); ++ final var ordinary = new ComponentItemStackingProperties(64, null, false, false); ++ final var merge = NativeContainerTransfer.plan(source, inventory, 64, ++ (from, to) -> NativeItemStacking.canMerge(from, to, ordinary, BedrockItem::data, values -> Set.of()), ++ slot -> true); ++ assertNotNull(merge); ++ assertEquals(0, merge.destinationSlot()); ++ ++ final var tag = new CompoundTag(); ++ tag.putInt("Damage", 1); ++ source.setTag(tag); ++ inventory[0].setData(source.data()); ++ inventory[0].setTag(tag.copy()); ++ final var damageable = new ComponentItemStackingProperties(64, null, false, true); ++ final var separate = NativeContainerTransfer.plan(source, inventory, 64, ++ (from, to) -> NativeItemStacking.canMerge(from, to, damageable, BedrockItem::data, values -> Set.of()), ++ slot -> true); ++ assertNotNull(separate); ++ assertEquals(1, separate.destinationSlot()); ++ assertEquals(4, source.amount()); ++ assertEquals(60, inventory[0].amount()); ++ } ++ ++ private static BedrockItem item(final int count) { ++ final var item = new BedrockItem(1); ++ item.setAmount(count); ++ return item; ++ } ++ ++} +diff --git a/src/test/java/net/raphimc/viabedrock/api/model/container/NativeItemStackMatcherTest.java b/src/test/java/net/raphimc/viabedrock/api/model/container/NativeItemStackMatcherTest.java +new file mode 100644 +index 000000000..eb0ed4345 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/api/model/container/NativeItemStackMatcherTest.java +@@ -0,0 +1,179 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.model.container; ++ ++import com.viaversion.nbt.io.TagRegistry; ++import com.viaversion.nbt.tag.*; ++import com.viaversion.viaversion.libs.gson.JsonArray; ++import com.viaversion.viaversion.libs.gson.JsonParser; ++import net.raphimc.viabedrock.api.chunk.blockstate.BlockRegistryNames; ++import net.raphimc.viabedrock.protocol.model.BedrockItem; ++import net.raphimc.viabedrock.api.model.NativeItemStacking; ++import org.junit.jupiter.api.Test; ++import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; ++ ++import java.nio.ByteBuffer; ++import java.nio.ByteOrder; ++import java.nio.charset.CharacterCodingException; ++import java.nio.charset.StandardCharsets; ++import java.nio.file.Files; ++import java.nio.file.Path; ++import java.util.HexFormat; ++import java.util.List; ++import java.util.Map; ++import java.util.Set; ++import java.util.ArrayList; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++class NativeItemStackMatcherTest { ++ ++ @Test ++ void queriesIgnoreRootQuantityAndNetworkIdButRetainNativeItemState() { ++ final Object block = new Object(); ++ final var blocks = Map.of(0, block, 1, new Object()); ++ final var registry = new BlockRegistryNames(List.of("minecraft:stone", "minecraft:dirt")); ++ final var query = new BedrockItem(321, (short) 3, (byte) 1); ++ final var result = new BedrockItem(321, Short.MAX_VALUE, (byte) 64); ++ result.setNetId(55); ++ result.setBlockRuntimeId(0); ++ assertTrue(NativeItemStackMatcher.matches(query, result, blocks::get, registry::restrictionBlocks)); ++ assertFalse(NativeItemStackMatcher.matches(result, query, blocks::get, registry::restrictionBlocks)); ++ query.setBlockRuntimeId(0); ++ query.setCanPlace(new String[]{"stone", "dirt", "stone", "missing"}); ++ result.setCanPlace(new String[]{"minecraft:dirt", "minecraft:stone"}); ++ assertTrue(NativeItemStackMatcher.matches(query, result, blocks::get, registry::restrictionBlocks)); ++ result.setBlockRuntimeId(1); ++ assertFalse(NativeItemStackMatcher.matches(query, result, blocks::get, registry::restrictionBlocks)); ++ result.setBlockRuntimeId(0); ++ result.setCanBreak(new String[]{"stone"}); ++ assertFalse(NativeItemStackMatcher.matches(query, result, blocks::get, registry::restrictionBlocks)); ++ result.setCanBreak(new String[0]); ++ result.setBlockingTicks(1); ++ assertFalse(NativeItemStackMatcher.matches(query, result, blocks::get, registry::restrictionBlocks)); ++ } ++ ++ @Test ++ void userdataPreservesNativeFloatingAndTypedListEqualityWithoutReferenceShortcuts() { ++ final var first = new CompoundTag(); ++ final var second = new CompoundTag(); ++ assertTrue(NativeItemStacking.userDataEqual(null, first)); ++ first.putFloat("value", 0F); ++ second.putFloat("value", -0F); ++ assertTrue(NativeItemStacking.userDataEqual(first, second)); ++ first.putFloat("value", Float.NaN); ++ assertFalse(NativeItemStacking.userDataEqual(first, first)); ++ first.put("value", new ListTag<>(IntTag.class)); ++ second.put("value", new ListTag<>(FloatTag.class)); ++ assertFalse(NativeItemStacking.userDataEqual(first, second)); ++ second.put("value", new ListTag<>(IntTag.class)); ++ assertTrue(NativeItemStacking.userDataEqual(first, second)); ++ } ++ ++ @Test ++ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_ITEM_TAG_EQUALITY", matches = ".+") ++ void tagEqualityMatchesOriginalNativeVirtualComparatorsForValidUtf8() throws Exception { ++ final var rows = JsonParser.parseString(Files.readString(Path.of( ++ System.getenv("STACKANVIL_NATIVE_ITEM_TAG_EQUALITY")))).getAsJsonArray(); ++ int compared = 0; ++ for (final var element : rows) { ++ final var row = element.getAsJsonObject(); ++ try { ++ final Tag first = tag(row.getAsJsonArray("left")); ++ final Tag second = row.get("sameReference").getAsBoolean() ? first : tag(row.getAsJsonArray("right")); ++ assertEquals(row.get("result").getAsBoolean(), NativeItemStacking.tagsEqual(first, second), row::toString); ++ compared++; ++ } catch (final CharacterCodingException ignored) { ++ // Java's decoded strings cannot retain malformed native UTF-8 bytes. ++ } ++ } ++ assertTrue(compared > 3000); ++ } ++ ++ @Test ++ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_COOKING_OUTPUT_DATA", matches = ".+") ++ void networkUserdataAndDerivedChargedChildrenMatchTheOriginalOutputMatcher() throws Exception { ++ final var rows = JsonParser.parseString(Files.readString(Path.of( ++ System.getenv("STACKANVIL_NATIVE_COOKING_OUTPUT_DATA")))).getAsJsonObject().getAsJsonArray("rows"); ++ assertFalse(rows.isEmpty()); ++ for (final var element : rows) { ++ final var row = element.getAsJsonObject(); ++ final var query = new BedrockItem(585); ++ final var result = new BedrockItem(585); ++ query.setTag(row.get("left").isJsonNull() ? null : (CompoundTag) tag(row.getAsJsonArray("left"))); ++ result.setTag(row.get("right").isJsonNull() ? null : (CompoundTag) tag(row.getAsJsonArray("right"))); ++ assertEquals(row.get("result").getAsBoolean(), NativeItemStackMatcher.matches( ++ query, result, ignored -> null, ignored -> Set.of()), row::toString); ++ } ++ } ++ ++ @Test ++ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_COOKING_INPUTS", matches = ".+") ++ void outputAdmissionMatchesTheWholeNativeAlternativeExtractor() throws Exception { ++ final var rows = JsonParser.parseString(Files.readString(Path.of( ++ System.getenv("STACKANVIL_NATIVE_COOKING_INPUTS")))).getAsJsonObject().getAsJsonArray("outputRows"); ++ assertFalse(rows.isEmpty()); ++ for (final var element : rows) { ++ final var row = element.getAsJsonObject(); ++ final var query = new BedrockItem(row.get("queryId").getAsInt(), ++ row.get("queryAuxiliary").getAsShort(), row.get("queryCount").getAsByte()); ++ final List actual = new ArrayList<>(); ++ if (NativeItemStackMatcher.matches(query, ++ new BedrockItem(100, row.get("resultAuxiliary").getAsShort(), (byte) 1), ignored -> null, ignored -> Set.of())) { ++ actual.addAll(List.of(1, 2)); ++ } ++ if (NativeItemStackMatcher.matches(query, new BedrockItem(321), ignored -> null, ignored -> Set.of())) { ++ actual.add(-303); ++ } ++ assertEquals(row.getAsJsonArray("expanded").asList().stream() ++ .map(value -> value.getAsJsonObject().get("identifier").getAsInt()).toList(), actual, row::toString); + } ++ } ++ ++ private static String string(final String hex) throws CharacterCodingException { ++ return StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(HexFormat.of().parseHex(hex))).toString(); ++ } + ++ private static Tag tag(final JsonArray spec) throws CharacterCodingException { ++ final int kind = spec.get(0).getAsInt(); ++ final var value = spec.get(1); ++ if (kind == 9) { ++ final var list = value.getAsJsonArray(); ++ final int type = list.get(0).getAsInt(); ++ @SuppressWarnings("unchecked") ++ final var result = new ListTag<>((Class) (type == 0 ? null : TagRegistry.getClassFor(type))); ++ for (final var entry : list.get(1).getAsJsonArray()) { ++ result.add(tag(entry.getAsJsonArray())); ++ } ++ return result; ++ } else if (kind == 10) { ++ final var result = new CompoundTag(); ++ for (final var entry : value.getAsJsonObject().entrySet()) { ++ result.put(string(entry.getKey()), tag(entry.getValue().getAsJsonArray())); ++ } ++ return result; ++ } ++ final byte[] bytes = HexFormat.of().parseHex(value.getAsString()); ++ final var buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN); ++ return switch (kind) { ++ case 1 -> new ByteTag(buffer.get()); ++ case 2 -> new ShortTag(buffer.getShort()); ++ case 3 -> new IntTag(buffer.getInt()); ++ case 4 -> new LongTag(buffer.getLong()); ++ case 5 -> new FloatTag(buffer.getFloat()); ++ case 6 -> new DoubleTag(buffer.getDouble()); ++ case 7 -> new ByteArrayTag(bytes); ++ case 8 -> new StringTag(string(value.getAsString())); ++ case 11 -> { ++ final int[] values = new int[bytes.length / Integer.BYTES]; ++ buffer.asIntBuffer().get(values); ++ yield new IntArrayTag(values); ++ } ++ default -> throw new IllegalArgumentException("Unsupported native tag fixture type"); ++ }; + } + +} @@ -2364,6 +17146,683 @@ index 000000000..36633fc97 + } + +} +diff --git a/src/test/java/net/raphimc/viabedrock/protocol/model/BuiltinItemNameTest.java b/src/test/java/net/raphimc/viabedrock/protocol/model/BuiltinItemNameTest.java +new file mode 100644 +index 000000000..1024026f9 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/protocol/model/BuiltinItemNameTest.java +@@ -0,0 +1,159 @@ ++/* ++ * 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.ByteTag; ++import com.viaversion.nbt.tag.CompoundTag; ++import com.viaversion.nbt.tag.IntTag; ++import com.viaversion.nbt.tag.ShortTag; ++import com.viaversion.nbt.tag.StringTag; ++import com.viaversion.viaversion.libs.gson.JsonElement; ++import com.viaversion.viaversion.libs.gson.JsonObject; ++import com.viaversion.viaversion.libs.gson.JsonParser; ++import io.netty.buffer.Unpooled; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ItemVersion; ++import net.raphimc.viabedrock.protocol.types.BedrockTypes; ++import org.junit.jupiter.api.Test; ++import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; ++ ++import java.nio.file.Files; ++import java.nio.file.Path; ++import java.util.ArrayList; ++import java.util.EnumSet; ++import java.util.HexFormat; ++import java.util.List; ++import java.util.Locale; ++import java.util.Set; ++import java.util.TreeSet; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++class BuiltinItemNameTest { ++ ++ private static final ItemNameContext NAMES = new ItemNameContext((block, metadata) -> fail("Non-block provider requested a block name"), java.util.function.Function.identity(), () -> BaseGameVersion.INVALID); ++ ++ @Test ++ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_DESCRIPTOR_NAMES", matches = ".+") ++ void registeredDescriptorsMatchActualNativeReceiversAndParsedUserdata() throws Exception { ++ final var fixture = read(Path.of(System.getenv("STACKANVIL_NATIVE_DESCRIPTOR_NAMES"))); ++ final var identifiers = new TreeSet(); ++ fixture.getAsJsonArray("nativeReceiverKeys").forEach(value -> identifiers.add(value.getAsJsonObject().get("name").getAsString())); ++ int id = 1; ++ final var entries = new ArrayList(); ++ for (final String identifier : identifiers) { ++ entries.add(new ItemEntry(identifier, id++, false, ItemVersion.None, null)); ++ } ++ final var registry = new ItemRegistry(entries.toArray(ItemEntry[]::new)); ++ for (final var value : fixture.getAsJsonArray("nativeReceiverKeys")) { ++ final var row = value.getAsJsonObject(); ++ final CompoundTag data; ++ if (row.get("nbtHex").isJsonNull()) { ++ data = null; ++ } else { ++ final var buffer = Unpooled.wrappedBuffer(HexFormat.of().parseHex(row.get("nbtHex").getAsString())); ++ try { ++ data = (CompoundTag) BedrockTypes.TAG_LE.read(buffer); ++ assertFalse(buffer.isReadable()); ++ } finally { ++ buffer.release(); ++ } ++ } ++ assertKey(row.get("resultHex").getAsString(), registry.ordinaryNameKey(row.get("name").getAsString(), auxiliary(row), data, NAMES), row.toString()); ++ } ++ assertEquals(41, identifiers.size()); ++ assertEquals(10906, fixture.getAsJsonArray("nativeReceiverKeys").size()); ++ } ++ ++ @Test ++ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_DESCRIPTOR_KEY_REFERENCES", matches = ".+") ++ void descriptorMethodsMatchNativeTypedTagsAndDefinitionFields() throws Exception { ++ final var directory = Path.of(System.getenv("STACKANVIL_NATIVE_DESCRIPTOR_KEY_REFERENCES")); ++ final var covered = EnumSet.noneOf(BuiltinItemName.Kind.class); ++ for (final String file : List.of("item-variant-key-reference.json", "item-state-key-reference.json", "map-key-reference.json", "shield-key-reference.json", "dye-key-reference.json")) { ++ for (final var value : read(directory.resolve(file)).getAsJsonArray("rows")) { ++ final var row = value.getAsJsonObject(); ++ if (row.has("forcedBlackHash") && row.get("forcedBlackHash").getAsBoolean()) { ++ continue; // Runtime descriptors construct their identifier hash from the identifier bytes. ++ } ++ final String family = switch (file) { ++ case "map-key-reference.json" -> "FILLED_MAP"; ++ case "shield-key-reference.json" -> "SHIELD"; ++ case "dye-key-reference.json" -> "DYE"; ++ default -> row.get("family").getAsString().toUpperCase(Locale.ROOT); ++ }; ++ final BuiltinItemName.Kind kind; ++ try { ++ kind = BuiltinItemName.Kind.valueOf(family); ++ } catch (final IllegalArgumentException ignored) { ++ continue; ++ } ++ final String description = row.has("base") ? string(row.get("base").getAsString()) : "item.probe"; ++ final int variant = row.has("color") ? row.get("color").getAsInt() : row.has("variant") ? row.get("variant").getAsInt() : 0; ++ final String identifier = row.has("identifier") ? string(row.get("identifier").getAsString()) : ""; ++ final Short auxiliary = kind == BuiltinItemName.Kind.DYE ++ ? row.get("descriptorPresent").getAsBoolean() ? (short) 255 : null : auxiliary(row); ++ final var method = new BuiltinItemName.Descriptor(kind, description, variant); ++ assertKey(row.get("result").getAsString(), method.key(identifier, auxiliary, data(row.get("root")), NAMES), row.toString()); ++ covered.add(kind); ++ } ++ } ++ assertEquals(Set.of(BuiltinItemName.Kind.values()), covered); ++ } ++ ++ private static JsonObject read(final Path path) throws Exception { ++ return JsonParser.parseString(Files.readString(path)).getAsJsonObject(); ++ } ++ ++ private static Short auxiliary(final JsonObject row) { ++ return row.has("aux") && !row.get("aux").isJsonNull() ? row.get("aux").getAsShort() : null; ++ } ++ ++ private static CompoundTag data(final JsonElement element) { ++ if (element == null || element.isJsonNull()) { ++ return null; ++ } ++ final var tag = new CompoundTag(); ++ for (final var entry : element.getAsJsonObject().entrySet()) { ++ final var value = entry.getValue().getAsJsonArray(); ++ tag.put(entry.getKey(), switch (value.get(0).getAsInt()) { ++ case 1 -> new ByteTag(value.get(1).getAsByte()); ++ case 2 -> new ShortTag(value.get(1).getAsShort()); ++ case 3 -> new IntTag(value.get(1).getAsInt()); ++ case 8 -> new StringTag(string(value.get(1).getAsString())); ++ default -> throw new IllegalArgumentException("Unsupported native tag fixture"); ++ }); ++ } ++ return tag; ++ } ++ ++ private static String string(final String hex) { ++ final byte[] bytes = HexFormat.of().parseHex(hex); ++ final var buffer = Unpooled.buffer(); ++ try { ++ buffer.writeByte(8).writeShortLE(0).writeShortLE(bytes.length).writeBytes(bytes); ++ return ((StringTag) BedrockTypes.TAG_LE.read(buffer)).getValue(); ++ } finally { ++ buffer.release(); ++ } ++ } ++ ++ private static void assertKey(final String expected, final String actual, final String context) { ++ assertNotNull(actual); ++ final var buffer = Unpooled.buffer(); ++ try { ++ BedrockTypes.TAG_LE.write(buffer, new StringTag(actual)); ++ assertEquals(8, buffer.readUnsignedByte()); ++ assertEquals(0, buffer.readUnsignedShortLE()); ++ final byte[] bytes = new byte[buffer.readUnsignedShortLE()]; ++ buffer.readBytes(bytes); ++ assertFalse(buffer.isReadable()); ++ assertArrayEquals(HexFormat.of().parseHex(expected), bytes, context); ++ } finally { ++ buffer.release(); ++ } ++ } ++ ++} +diff --git a/src/test/java/net/raphimc/viabedrock/protocol/model/ItemRegistryComponentNamesTest.java b/src/test/java/net/raphimc/viabedrock/protocol/model/ItemRegistryComponentNamesTest.java +new file mode 100644 +index 000000000..518d4e949 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/protocol/model/ItemRegistryComponentNamesTest.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.protocol.model; ++ ++import com.viaversion.nbt.tag.CompoundTag; ++import com.viaversion.viaversion.libs.gson.JsonParser; ++import io.netty.buffer.Unpooled; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ItemVersion; ++import net.raphimc.viabedrock.protocol.types.BedrockTypes; ++import org.junit.jupiter.api.Test; ++import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; ++ ++import java.nio.file.Files; ++import java.nio.file.Path; ++import java.util.ArrayList; ++import java.util.HexFormat; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++class ItemRegistryComponentNamesTest { ++ ++ @Test ++ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_COMPONENT_DESCRIPTIONS", matches = ".+") ++ void componentDescriptionsFollowActualNativeConstructionAndOrderedReaders() throws Exception { ++ final var fixture = JsonParser.parseString(Files.readString(Path.of( ++ System.getenv("STACKANVIL_NATIVE_COMPONENT_DESCRIPTIONS")))).getAsJsonObject(); ++ int compared = 0; ++ for (final var input : fixture.getAsJsonArray("sets")) { ++ final var set = input.getAsJsonObject(); ++ final var entries = new ArrayList(); ++ for (final var value : set.getAsJsonArray("registrations")) { ++ final var row = value.getAsJsonObject(); ++ final var buffer = Unpooled.wrappedBuffer(HexFormat.of().parseHex(row.get("nbtHex").getAsString())); ++ try { ++ final var tag = (CompoundTag) BedrockTypes.TAG_LE.read(buffer); ++ assertFalse(buffer.isReadable()); ++ entries.add(new ItemEntry(row.get("name").getAsString(), row.get("id").getAsInt(), ++ row.get("componentBased").getAsBoolean(), ItemVersion.getByValue(row.get("version").getAsInt()), tag)); ++ } finally { ++ buffer.release(); ++ } ++ } ++ final var registry = new ItemRegistry(entries.toArray(ItemEntry[]::new)); ++ for (final var value : set.getAsJsonArray("outputs")) { ++ final var row = value.getAsJsonObject(); ++ final String expected = row.get("component").getAsBoolean() ? row.get("description").getAsString() : null; ++ assertEquals(expected, registry.componentDescription(row.get("identifier").getAsString()), row::toString); ++ compared++; ++ } ++ } ++ assertEquals(23, compared); ++ } ++ ++ @Test ++ void componentNamesRemainBoundToTheirRegistrySnapshotAndRegisteredIdentity() { ++ final String identifier = "names:first"; ++ final var name = new CompoundTag(); ++ name.putString("value", "first.key"); ++ final var components = new CompoundTag(); ++ components.put("minecraft:display_name", name); ++ final var data = new CompoundTag(); ++ data.put("components", components); ++ final var entry = new ItemEntry(identifier, 20, true, ItemVersion.DataDriven, data); ++ final var registry = new ItemRegistry(new ItemEntry[]{entry}); ++ final String selected = registry.componentDescription(identifier); ++ name.putString("value", "later.key"); ++ final var next = new ItemRegistry(new ItemEntry[]{ ++ new ItemEntry(identifier, 20, true, ItemVersion.DataDriven, data)}); ++ assertNotEquals(selected, next.componentDescription(identifier)); ++ assertEquals(selected, registry.componentDescription(identifier)); ++ ++ final String replacement = "names:replacement"; ++ final var reused = new ItemRegistry(new ItemEntry[]{entry, ++ new ItemEntry(replacement, 20, true, ItemVersion.None, data)}); ++ assertNull(reused.componentDescription(identifier)); ++ assertNotNull(reused.componentDescription(replacement)); ++ assertNotEquals(next.componentDescription(identifier), reused.componentDescription(replacement)); ++ assertEquals(selected, registry.componentDescription(identifier)); ++ } ++ ++} +diff --git a/src/test/java/net/raphimc/viabedrock/protocol/model/ItemRegistryOrdinaryNamesTest.java b/src/test/java/net/raphimc/viabedrock/protocol/model/ItemRegistryOrdinaryNamesTest.java +new file mode 100644 +index 000000000..fc21ff889 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/protocol/model/ItemRegistryOrdinaryNamesTest.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.protocol.model; ++ ++import com.viaversion.nbt.tag.CompoundTag; ++import com.viaversion.viaversion.libs.gson.JsonParser; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ItemVersion; ++import net.raphimc.viabedrock.api.chunk.blockstate.BuiltinBlockNames; ++import org.junit.jupiter.api.Test; ++import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; ++import org.junit.jupiter.params.ParameterizedTest; ++import org.junit.jupiter.params.provider.ValueSource; ++ ++import java.nio.file.Files; ++import java.nio.file.Path; ++import java.util.ArrayList; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++class ItemRegistryOrdinaryNamesTest { ++ ++ private static final ItemNameContext NAMES = new ItemNameContext((block, auxiliary) -> BuiltinBlockNames.get(block), java.util.function.Function.identity(), () -> BaseGameVersion.INVALID); ++ ++ @Test ++ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_INVARIANT_NAMES", matches = ".+") ++ void ordinaryKeysMatchNativeFactoryReceivers() throws Exception { ++ final var fixture = JsonParser.parseString(Files.readString(Path.of( ++ System.getenv("STACKANVIL_NATIVE_INVARIANT_NAMES")))).getAsJsonObject(); ++ final var entries = new ArrayList(); ++ for (final var value : fixture.getAsJsonArray("rows")) { ++ entries.add(new ItemEntry(value.getAsJsonObject().get("name").getAsString(), ++ entries.size() + 1, false, ItemVersion.None, null)); ++ } ++ final var registry = new ItemRegistry(entries.toArray(ItemEntry[]::new)); ++ for (final var value : fixture.getAsJsonArray("rows")) { ++ final var row = value.getAsJsonObject(); ++ assertEquals(row.get("result").getAsString(), registry.ordinaryNameKey(row.get("name").getAsString(), (short) 0, null, NAMES), row::toString); ++ } ++ assertEquals(559, entries.size()); ++ } ++ ++ @ParameterizedTest ++ @ValueSource(strings = {"minecraft:coal", "minecraft:tropical_fish_bucket", "minecraft:white_dye", "minecraft:filled_map"}) ++ void nativeKeysFollowConstructionAndRuntimeIdentityRatherThanNetworkFlags(final String identifier) { ++ final var inherited = new ItemEntry(identifier, 20, false, ItemVersion.None, null); ++ final var name = new CompoundTag(); ++ name.putString("value", "replacement.key"); ++ final var components = new CompoundTag(); ++ components.put("minecraft:display_name", name); ++ final var data = new CompoundTag(); ++ data.put("components", components); ++ final var legacy = new ItemEntry(identifier, 20, true, ItemVersion.Legacy, data); ++ final var component = new ItemEntry(identifier, 20, false, ItemVersion.DataDriven, null); ++ final var initial = new ItemRegistry(new ItemEntry[]{inherited}); ++ final var retained = new ItemRegistry(new ItemEntry[]{inherited, legacy, component}); ++ final var recreated = new ItemRegistry(new ItemEntry[]{inherited, component, legacy}); ++ assertEquals(initial.ordinaryNameKey(identifier, (short) 0, null, NAMES), retained.ordinaryNameKey(identifier, (short) 0, null, NAMES)); ++ assertNotEquals(initial.ordinaryNameKey(identifier, (short) 0, null, NAMES), recreated.ordinaryNameKey(identifier, (short) 0, null, NAMES)); ++ assertEquals(recreated.componentDescription(identifier), recreated.ordinaryNameKey(identifier, (short) 0, null, NAMES)); ++ ++ final var replaced = new ItemRegistry(new ItemEntry[]{inherited, ++ new ItemEntry("minecraft:charcoal", 20, false, ItemVersion.None, null)}); ++ assertNull(replaced.ordinaryNameKey(identifier, (short) 0, null, NAMES)); ++ assertNotNull(replaced.ordinaryNameKey("minecraft:charcoal", (short) 0, null, NAMES)); ++ assertNotEquals(initial.ordinaryNameKey(identifier, (short) 0, null, NAMES), replaced.ordinaryNameKey("minecraft:charcoal", (short) 0, null, NAMES)); ++ assertEquals(initial.ordinaryNameKey(identifier, (short) 0, null, NAMES), retained.ordinaryNameKey(identifier, (short) 0, null, NAMES)); ++ } ++ ++} +diff --git a/src/test/java/net/raphimc/viabedrock/protocol/model/ItemRegistryTest.java b/src/test/java/net/raphimc/viabedrock/protocol/model/ItemRegistryTest.java +new file mode 100644 +index 000000000..eaf0eb96f +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/protocol/model/ItemRegistryTest.java +@@ -0,0 +1,338 @@ ++/* ++ * 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.ByteTag; ++import com.viaversion.nbt.tag.CompoundTag; ++import com.viaversion.nbt.tag.ListTag; ++import com.viaversion.nbt.tag.LongTag; ++import io.netty.buffer.Unpooled; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ItemVersion; ++import net.raphimc.viabedrock.protocol.types.BedrockTypes; ++import org.junit.jupiter.api.Test; ++ ++import java.util.List; ++import java.util.Map; ++import java.util.Set; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++class ItemRegistryTest { ++ ++ @Test ++ void customNamesFollowTheConstructedBookClassAcrossOrderedUpdates() { ++ final String book = "minecraft:written_book"; ++ final List updates = List.of( ++ new ItemEntry[]{entry(book, 20, false, ItemVersion.Legacy)}, ++ new ItemEntry[]{entry(book, 20, true, ItemVersion.None)}, ++ new ItemEntry[]{entry(book, 20, false, ItemVersion.Legacy), entry(book, 21, true, ItemVersion.DataDriven)}, ++ new ItemEntry[]{entry(book, 20, true, ItemVersion.DataDriven), entry(book, 21, false, ItemVersion.Legacy)}, ++ new ItemEntry[]{entry(book, 20, true, ItemVersion.DataDriven), entry(book, 21, false, ItemVersion.None)}); ++ for (int index = 0; index < updates.size(); index++) { ++ final var registry = new ItemRegistry(updates.get(index)); ++ final boolean nativeBook = index < 3; ++ final var data = new CompoundTag(); ++ data.putString("title", "Title"); ++ data.putString("filteredTitle", "Filtered"); ++ assertEquals(nativeBook ? "Title" : null, registry.customName(book, data)); ++ final var display = new CompoundTag(); ++ display.putInt("Name", 7); ++ data.put("display", display); ++ assertEquals(nativeBook ? "Title" : "", registry.customName(book, data)); ++ display.putString("Name", "Named"); ++ assertEquals("Named", registry.customName(book, data)); ++ } ++ final var removed = new ItemRegistry(new ItemEntry[]{entry(book, 20, false, ItemVersion.Legacy), ++ entry("test:replacement", 20, false, ItemVersion.Legacy)}); ++ final var data = new CompoundTag(); ++ data.putString("title", "Title"); ++ assertNull(removed.customName(book, data)); ++ assertNull(removed.customName("test:replacement", data)); ++ data.putInt("title", 7); ++ assertEquals("", new ItemRegistry(updates.getFirst()).customName(book, data)); ++ } ++ ++ @Test ++ void stackSizeConstructionFinishesBeforeOrderedUpdatesIncludingInvalidIds() { ++ final String identifier = "test:component"; ++ final var compiled = new CompoundTag(); ++ compiled.putInt("max_stack_size", 16); ++ final var first = new CompoundTag(); ++ first.put("item_properties", compiled); ++ final var named = new CompoundTag(); ++ named.putByte("value", (byte) 32); ++ final var second = new CompoundTag(); ++ second.put("minecraft:max_stack_size", named); ++ final var last = new CompoundTag(); ++ last.put("item_properties", new CompoundTag()); ++ final var registry = new ItemRegistry(new ItemEntry[]{ ++ componentEntry(identifier, 20, ItemVersion.None, first), ++ entry(identifier, 21, false, ItemVersion.DataDriven), ++ componentEntry(identifier, -1, ItemVersion.None, second), ++ componentEntry(identifier, 22, ItemVersion.DataDriven, last), ++ entry(identifier, 23, false, ItemVersion.None)}); ++ final var sizes = registry.stackingProperties(); ++ assertEquals(Map.of(identifier, new ComponentItemStackingProperties(0, 32, false, false)), sizes); ++ assertEquals(23, registry.ids().get(identifier)); ++ assertThrows(UnsupportedOperationException.class, sizes::clear); ++ } ++ ++ @Test ++ void runtimeFallbackDoesNotReceiveEarlierUpdatesAndConstructedFactoriesKeepTheSize() { ++ final var properties = new CompoundTag(); ++ properties.putInt("max_stack_size", 1); ++ final var components = new CompoundTag(); ++ components.put("item_properties", properties); ++ final var registry = new ItemRegistry(new ItemEntry[]{ ++ componentEntry("test:fallback", 20, ItemVersion.None, components), ++ entry("test:fallback", 21, false, ItemVersion.None), ++ componentEntry("test:legacy", 22, ItemVersion.DataDriven, components), ++ entry("test:legacy", 23, true, ItemVersion.Legacy), ++ componentEntry("test:removed", 24, ItemVersion.DataDriven, components), ++ entry("test:owner", 24, false, ItemVersion.DataDriven), ++ entry("test:invalid", -1, true, ItemVersion.DataDriven)}); ++ assertEquals(Map.of("test:fallback", ComponentItemStackingProperties.DEFAULT, "test:owner", ComponentItemStackingProperties.DEFAULT, "test:legacy", new ComponentItemStackingProperties(1, null, false, false)), registry.stackingProperties()); ++ } ++ ++ @Test ++ void firstComponentFactoryReceivesLaterLegacyUpdatesUsingItsOwnLayout() { ++ final var compiled = new CompoundTag(); ++ compiled.putInt("max_stack_size", 1); ++ final var componentUpdate = new CompoundTag(); ++ componentUpdate.put("item_properties", compiled); ++ componentUpdate.put("minecraft:durability", new CompoundTag()); ++ final var legacyUpdate = new CompoundTag(); ++ legacyUpdate.putInt("minecraft:max_stack_size", 16); ++ legacyUpdate.putByte("minecraft:stacked_by_data", (byte) 1); ++ legacyUpdate.putInt("minecraft:max_damage", 65537); ++ final var registry = new ItemRegistry(new ItemEntry[]{ ++ componentEntry("test:item", 20, ItemVersion.DataDriven, componentUpdate), ++ componentEntry("test:item", -1, ItemVersion.None, legacyUpdate), ++ entry("test:item", 21, true, ItemVersion.Legacy), ++ entry("test:item", 22, false, ItemVersion.None)}); ++ assertEquals(Map.of("test:item", new ComponentItemStackingProperties(1, null, false, true)), registry.stackingProperties()); ++ assertEquals(Set.of("test:item"), registry.componentItems()); ++ assertEquals(22, registry.ids().get("test:item")); ++ } ++ ++ @Test ++ void firstLegacyFactorySurvivesLaterComponentRegistrationAndKeepsItsLayout() { ++ final var legacy = new CompoundTag(); ++ legacy.putInt("minecraft:max_stack_size", 32); ++ legacy.putByte("minecraft:stacked_by_data", (byte) 1); ++ legacy.putInt("minecraft:max_damage", 20); ++ final var compiled = new CompoundTag(); ++ compiled.putInt("max_stack_size", 16); ++ final var component = new CompoundTag(); ++ component.put("item_properties", compiled); ++ component.put("minecraft:durability", new CompoundTag()); ++ final var registry = new ItemRegistry(new ItemEntry[]{ ++ componentEntry("test:item", 20, ItemVersion.Legacy, legacy), ++ componentEntry("test:item", 21, ItemVersion.DataDriven, component)}); ++ assertEquals(Map.of("test:item", new LegacyItemStackingProperties(32, true, (short) 20)), ++ registry.stackingProperties()); ++ assertTrue(registry.componentItems().isEmpty()); ++ assertEquals(21, registry.ids().get("test:item")); ++ } ++ ++ @Test ++ void lateOrdinaryFallbackConstructionDoesNotConsumeEarlierLegacyUpdates() { ++ final var update = new CompoundTag(); ++ update.putInt("minecraft:max_stack_size", 16); ++ update.putInt("minecraft:max_damage", 64); ++ final var data = new CompoundTag(); ++ data.put("components", update); ++ final var registry = new ItemRegistry(new ItemEntry[]{new ItemEntry("test:item", 20, false, ItemVersion.None, data)}); ++ assertEquals(Map.of("test:item", LegacyItemStackingProperties.DEFAULT), registry.stackingProperties()); ++ } ++ ++ @Test ++ void networkConstructionReplacesBuiltinStackSizeState() { ++ final var registry = new ItemRegistry(new ItemEntry[]{ ++ entry("minecraft:apple", 20, true, ItemVersion.None), ++ entry("minecraft:stone", 21, true, ItemVersion.None), ++ entry("minecraft:diamond_sword", 22, false, ItemVersion.DataDriven)}); ++ assertEquals(Map.of("minecraft:apple", LegacyItemStackingProperties.DEFAULT, ++ "minecraft:stone", LegacyItemStackingProperties.DEFAULT, ++ "minecraft:diamond_sword", ComponentItemStackingProperties.DEFAULT), registry.stackingProperties()); ++ } ++ ++ @Test ++ void legacyRegistrationKeepsBuiltinDurabilityAndSubtypeDefaults() { ++ final var registry = new ItemRegistry(new ItemEntry[]{ ++ entry("minecraft:netherite_sword", 20, false, ItemVersion.Legacy), ++ entry("minecraft:fishing_rod", 21, false, ItemVersion.Legacy), ++ entry("minecraft:arrow", 22, false, ItemVersion.Legacy)}); ++ final var properties = registry.stackingProperties(); ++ assertEquals(new LegacyItemStackingProperties(1, false, (short) 2031), properties.get("minecraft:netherite_sword")); ++ assertEquals(new ComponentItemStackingProperties(1, null, false, true), properties.get("minecraft:fishing_rod")); ++ assertEquals(new ComponentItemStackingProperties(64, null, true, false), properties.get("minecraft:arrow")); ++ assertEquals(Set.of("minecraft:fishing_rod", "minecraft:arrow"), registry.componentItems()); ++ } ++ ++ @Test ++ void builtinOverridesSurviveNoneUpdatesButExplicitComponentConstructionReplacesThem() { ++ final var update = new CompoundTag(); ++ update.putInt("minecraft:max_stack_size", 32); ++ update.putByte("minecraft:stacked_by_data", (byte) 0); ++ final var registry = new ItemRegistry(new ItemEntry[]{ ++ componentEntry("minecraft:birch_boat", 20, ItemVersion.None, update), ++ componentEntry("minecraft:bucket", 21, ItemVersion.Legacy, update), ++ componentEntry("minecraft:milk_bucket", 22, ItemVersion.None, update), ++ entry("minecraft:spruce_boat", 23, false, ItemVersion.DataDriven), ++ entry("minecraft:lava_bucket", 24, false, ItemVersion.DataDriven)}); ++ final var properties = registry.stackingProperties(); ++ assertEquals(32, properties.get("minecraft:birch_boat").maximum()); ++ assertTrue(properties.get("minecraft:birch_boat").stackedByData()); ++ assertEquals(16, properties.get("minecraft:bucket").maximum()); ++ assertFalse(properties.get("minecraft:bucket").stackedByData()); ++ assertEquals(1, properties.get("minecraft:milk_bucket").maximum()); ++ assertFalse(properties.get("minecraft:milk_bucket").stackedByData()); ++ assertEquals(ComponentItemStackingProperties.DEFAULT, properties.get("minecraft:spruce_boat")); ++ assertEquals(ComponentItemStackingProperties.DEFAULT, properties.get("minecraft:lava_bucket")); ++ } ++ ++ private static ItemEntry componentEntry(final String identifier, final int id, final ItemVersion version, final CompoundTag components) { ++ final var data = new CompoundTag(); ++ data.put("components", components); ++ return new ItemEntry(identifier, id, true, version, data); ++ } ++ ++ @Test ++ void idReplacementRemovesThePreviousDefinitionsAndComponentMembership() { ++ final var first = entry("test:first", -7, true, ItemVersion.DataDriven); ++ final var moved = entry(first.identifier(), 20, false, ItemVersion.None); ++ final var replacement = entry("test:replacement", 20, false, ItemVersion.Legacy); ++ final var reused = entry("test:reused", -7, true, ItemVersion.None); ++ final var registry = new ItemRegistry(new ItemEntry[]{first, moved, replacement, reused}); ++ ++ assertNull(registry.definition(first.identifier())); ++ assertSame(replacement, registry.definition(20)); ++ assertSame(reused, registry.definition(-7)); ++ assertEquals(List.of(replacement, reused), registry.entries()); ++ assertEquals(List.of(first, moved, replacement, reused), registry.registrations()); ++ assertEquals(Map.of(replacement.identifier(), 20, reused.identifier(), -7), registry.ids()); ++ assertEquals(Set.of(reused.identifier()), registry.componentItems()); ++ assertThrows(UnsupportedOperationException.class, () -> registry.ids().inverse().remove(20)); ++ assertThrows(UnsupportedOperationException.class, () -> registry.entries().clear()); ++ assertThrows(UnsupportedOperationException.class, () -> registry.componentItems().clear()); ++ } ++ ++ @Test ++ void repeatedNamesKeepOnlyTheLastOwnerAndItsActualVersion() { ++ final var component = entry("test:item", 50, false, ItemVersion.DataDriven); ++ final var legacy = entry(component.identifier(), 51, true, ItemVersion.Legacy); ++ final ItemEntry[] input = {component, legacy}; ++ final var registry = new ItemRegistry(input); ++ input[1] = component; ++ ++ assertNull(registry.definition(50)); ++ assertSame(legacy, registry.definition(51)); ++ assertSame(legacy, registry.definition(legacy.identifier())); ++ assertEquals(List.of(legacy), registry.entries()); ++ assertEquals(List.of(component, legacy), registry.registrations()); ++ assertThrows(UnsupportedOperationException.class, () -> registry.registrations().clear()); ++ assertEquals(Set.of("test:item"), registry.componentItems()); ++ } ++ ++ @Test ++ void runtimeIdFallbackConstructsUnknownReferences() { ++ final var builtin = entry("minecraft:hopper", 50, true, ItemVersion.None); ++ final var reference = entry("test:unknown", 51, true, ItemVersion.None); ++ final var legacy = entry("test:legacy", 52, true, ItemVersion.Legacy); ++ final var component = entry("test:component", 53, false, ItemVersion.DataDriven); ++ final var registry = new ItemRegistry(new ItemEntry[]{builtin, reference, legacy, component}); ++ ++ assertSame(builtin, registry.definition(50)); ++ assertSame(reference, registry.definition(51)); ++ assertSame(legacy, registry.definition(52)); ++ assertSame(component, registry.definition(53)); ++ assertNull(registry.definition(54)); ++ ++ final var laterReference = entry(legacy.identifier(), 54, false, ItemVersion.None); ++ final var replacedRegistry = new ItemRegistry(new ItemEntry[]{legacy, laterReference}); ++ assertNull(replacedRegistry.definition(52)); ++ assertSame(laterReference, replacedRegistry.definition(54)); ++ } ++ ++ @Test ++ void completeNetworkDefinitionsRemainStableAcrossInputAndConsumerMutation() { ++ final var flags = new CompoundTag(); ++ flags.put("replaceBlockItem", new ByteTag((byte) 255)); ++ final var opaqueValues = new ListTag<>(LongTag.class); ++ opaqueValues.add(new LongTag(Long.MIN_VALUE)); ++ flags.put("opaque", opaqueValues); ++ final var components = new CompoundTag(); ++ components.put("minecraft:block_placer", flags); ++ final var data = new CompoundTag(); ++ data.put("components", components); ++ final var definition = new ItemEntry("test:item", 19, true, ItemVersion.DataDriven, data); ++ final var registry = new ItemRegistry(new ItemEntry[]{definition}); ++ final var expected = data.copy(); ++ ++ flags.remove("replaceBlockItem"); ++ opaqueValues.getValue().clear(); ++ data.getValue().clear(); ++ definition.componentData().getValue().clear(); ++ registry.definition(19).componentData().getCompoundTag("components").getValue().clear(); ++ ++ assertEquals(expected, registry.definition(19).componentData()); ++ final var buffer = Unpooled.buffer(); ++ try { ++ BedrockTypes.ITEM_ENTRY.write(buffer, registry.definition(19)); ++ final var decoded = BedrockTypes.ITEM_ENTRY.read(buffer); ++ assertEquals(definition, decoded); ++ assertEquals(0, buffer.readableBytes()); ++ decoded.componentData().getValue().clear(); ++ assertEquals(expected, decoded.componentData()); ++ } finally { ++ buffer.release(); ++ } ++ } ++ ++ @Test ++ void missingDataAndUnknownIdsDoNotAcquireDefinitions() { ++ final var definition = entry("test:item", 32767, false, ItemVersion.None); ++ final var registry = new ItemRegistry(new ItemEntry[]{definition}); ++ assertNull(registry.definition(32767).componentData()); ++ assertNull(registry.definition(0)); ++ assertNull(registry.definition("test:missing")); ++ assertTrue(new ItemRegistry(new ItemEntry[0]).entries().isEmpty()); ++ } ++ ++ @Test ++ void legacyReferencesPreserveExistingBuiltinFactoriesAndTheirComponentLayout() { ++ final String identifier = "minecraft:hopper"; ++ final var flat = new CompoundTag(); ++ flat.putInt("minecraft:max_stack_size", 32); ++ final var registry = new ItemRegistry(new ItemEntry[]{componentEntry(identifier, 20, ItemVersion.Legacy, flat)}); ++ assertEquals(Set.of(identifier), registry.componentItems()); ++ assertEquals(Map.of(identifier, ComponentItemStackingProperties.DEFAULT), registry.stackingProperties()); ++ } ++ ++ @Test ++ void advertisedItemsDoNotBecomeBuiltinFactoriesBeforeNetworkConstruction() { ++ for (final boolean flag : new boolean[]{false, true}) { ++ final var properties = new CompoundTag(); ++ properties.putInt("max_stack_size", 1); ++ final var components = new CompoundTag(); ++ components.put("item_properties", properties); ++ final var data = new CompoundTag(); ++ data.put("components", components); ++ final var registry = new ItemRegistry(new ItemEntry[]{new ItemEntry("minecraft:bundle", 20, flag, ItemVersion.None, data)}); ++ assertEquals(flag ? Set.of("minecraft:bundle") : Set.of(), registry.componentItems()); ++ assertEquals(flag ? ComponentItemStackingProperties.DEFAULT : LegacyItemStackingProperties.DEFAULT, ++ registry.stackingProperties().get("minecraft:bundle")); ++ } ++ final var apple = new ItemRegistry(new ItemEntry[]{entry("minecraft:apple", 20, true, ItemVersion.None)}); ++ assertTrue(apple.componentItems().isEmpty()); ++ } ++ ++ private static ItemEntry entry(final String identifier, final int id, final boolean componentBased, final ItemVersion version) { ++ return new ItemEntry(identifier, id, componentBased, version, null); ++ } ++ ++} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/packet/ModalFormTransitionTest.java b/src/test/java/net/raphimc/viabedrock/protocol/packet/ModalFormTransitionTest.java new file mode 100644 index 000000000..51cdb2bf1 @@ -2495,10 +17954,10 @@ index 000000000..51cdb2bf1 +} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/packet/NativeContainerOpeningPacketTest.java b/src/test/java/net/raphimc/viabedrock/protocol/packet/NativeContainerOpeningPacketTest.java new file mode 100644 -index 000000000..b582dc508 +index 000000000..3472fd3b4 --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/protocol/packet/NativeContainerOpeningPacketTest.java -@@ -0,0 +1,395 @@ +@@ -0,0 +1,485 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -2547,6 +18006,11 @@ index 000000000..b582dc508 +import java.util.Map; +import java.util.UUID; +import net.raphimc.viabedrock.api.model.NativeContainerMessage; ++import net.raphimc.viabedrock.api.model.NativeContainerStatus; ++import net.raphimc.viabedrock.api.model.container.Container; ++import net.raphimc.viabedrock.api.model.container.block.HopperContainer; ++import net.raphimc.viabedrock.api.model.container.block.Generic3x3Container; ++import net.raphimc.viabedrock.protocol.storage.GameSessionStorage; +import net.raphimc.viabedrock.api.resourcepack.ResourcePack; +import net.raphimc.viabedrock.api.resourcepack.content.InMemoryContent; +import net.raphimc.viabedrock.api.resourcepack.ui.ContainerUi; @@ -2603,7 +18067,43 @@ index 000000000..b582dc508 + "large_chest_panel@chest.small_chest_panel":{}} + """); + content.putString("texts/en_US.lang", "actor.key=Social Menu\n"); -+ return new ResourcePackStorage(List.of(new ResourcePack(content))); ++ final var resources = new ResourcePackStorage(List.of(new ResourcePack(content))); ++ resources.setLoadedOnJavaClient(); ++ return resources; ++ } ++ ++ @Test ++ void withholdsNativePresentationUntilItsConvertedResourcesLoad() throws Exception { ++ final var user = mock(UserConnection.class); ++ final var channels = new ChannelStorage(); ++ channels.addChannels(List.of(NativeContainerMessage.CHANNEL)); ++ final var resources = spy(packs()); ++ when(resources.isLoadedOnJavaClient()).thenReturn(false); ++ final Map, Object> storage = Map.of(ChannelStorage.class, channels, ++ NativeContainerUiState.class, new NativeContainerUiState(), ResourcePackStorage.class, resources); ++ when(user.get(any())).thenAnswer(call -> storage.get(call.getArgument(0))); ++ final var sent = new ArrayList(); ++ final var open = outbound(ClientboundPackets26_3.OPEN_SCREEN, user, sent); ++ open.write(Types.VAR_INT, 7); ++ try (var factory = mockStatic(PacketWrapper.class)) { ++ factory.when(() -> PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, user)) ++ .thenAnswer(call -> outbound(ClientboundPackets26_3.CUSTOM_PAYLOAD, user, sent)); ++ InventoryPackets.sendNativeContainerPresentation(open, ContainerUi.Kind.CHEST_3_ROWS, new ContainerUi.Title("", false)); ++ assertFalse(open.isCancelled()); ++ assertTrue(sent.isEmpty()); ++ when(resources.isLoadedOnJavaClient()).thenReturn(true); ++ InventoryPackets.sendNativeContainerPresentation(open, ContainerUi.Kind.CHEST_3_ROWS, new ContainerUi.Title("", false)); ++ assertTrue(open.isCancelled()); ++ assertEquals(2, sent.size()); ++ final var payload = Unpooled.wrappedBuffer(sent.get(1)); ++ try { ++ Types.VAR_INT.readPrimitive(payload); ++ Types.STRING.read(payload); ++ assertEquals(1, NativeContainerMessage.TYPE.read(payload).sequence()); ++ } finally { ++ payload.release(); ++ } ++ } + } + + @Test @@ -2624,7 +18124,7 @@ index 000000000..b582dc508 + open.write(Types.VAR_INT, 7); + open.write(Types.VAR_INT, menuType); + open.write(Types.TAG, new StringTag("Actual display title")); -+ InventoryPackets.sendNativeContainerPresentation(open, menuType, new ContainerUi.Title("Raw authored title", false)); ++ InventoryPackets.sendNativeContainerPresentation(open, family.equals("minecraft:generic_9x3") ? ContainerUi.Kind.CHEST_3_ROWS : ContainerUi.Kind.CHEST_6_ROWS, new ContainerUi.Title("Raw authored title", false)); + assertTrue(open.isCancelled()); + } + } @@ -2666,15 +18166,64 @@ index 000000000..b582dc508 + when(user.get(any())).thenAnswer(call -> storage.get(call.getArgument(0))); + final var sent = new ArrayList(); + final var open = outbound(ClientboundPackets26_3.OPEN_SCREEN, user, sent); -+ InventoryPackets.sendNativeContainerPresentation(open, BedrockProtocol.MAPPINGS.getJavaMenuId("minecraft:generic_9x3"), new ContainerUi.Title("Actual", false)); ++ InventoryPackets.sendNativeContainerPresentation(open, ContainerUi.Kind.CHEST_3_ROWS, new ContainerUi.Title("Actual", false)); + channels.addChannels(List.of(NativeContainerMessage.CHANNEL)); + open.write(Types.VAR_INT, 7); -+ InventoryPackets.sendNativeContainerPresentation(open, BedrockProtocol.MAPPINGS.getJavaMenuId("minecraft:furnace"), new ContainerUi.Title("Actual", false)); ++ InventoryPackets.sendNativeContainerPresentation(open, null, new ContainerUi.Title("Actual", false)); + assertFalse(open.isCancelled()); + assertTrue(sent.isEmpty()); + } + + @Test ++ void requiresFamilyNegotiationBeforeOfferingRedstoneStorageActions() throws Exception { ++ for (final var type : List.of(ContainerType.HOPPER, ContainerType.DISPENSER, ContainerType.DROPPER)) { ++ final var user = mock(UserConnection.class); ++ final var tracker = mock(InventoryTracker.class); ++ final var channels = new ChannelStorage(); ++ channels.addChannels(List.of(NativeContainerMessage.CURRENT_CHANNEL, NativeContainerStatus.CHANNEL)); ++ final var state = new NativeContainerUiState(); ++ final Container menu = type == ContainerType.HOPPER ? new HopperContainer(user, (byte) 7, null, null) ++ : new Generic3x3Container(user, (byte) 7, type, null, null); ++ final Map, Object> storage = Map.of(ChannelStorage.class, channels, NativeContainerUiState.class, state, ++ ResourcePackStorage.class, packs(), InventoryTracker.class, tracker, GameSessionStorage.class, mock(GameSessionStorage.class)); ++ when(user.get(any())).thenAnswer(call -> storage.get(call.getArgument(0))); ++ when(tracker.getCurrentContainer()).thenReturn(menu); ++ final var kind = InventoryPackets.nativeContainerKind(type, null, menu.size()); ++ assertNotNull(kind); ++ final List sent = new ArrayList<>(); ++ final var open = outbound(ClientboundPackets26_3.OPEN_SCREEN, user, sent); ++ open.write(Types.VAR_INT, 7); ++ open.write(Types.VAR_INT, BedrockProtocol.MAPPINGS.getJavaMenuId(type == ContainerType.HOPPER ? "minecraft:hopper" : "minecraft:generic_3x3")); ++ open.write(Types.TAG, new StringTag("Storage")); ++ try (var factory = mockStatic(PacketWrapper.class)) { ++ factory.when(() -> PacketWrapper.create(ClientboundPackets26_3.CUSTOM_PAYLOAD, user)).thenAnswer(call -> outbound( ++ ClientboundPackets26_3.CUSTOM_PAYLOAD, user, sent)); ++ InventoryPackets.sendNativeContainerPresentation(open, kind, new ContainerUi.Title("Storage", false)); ++ assertFalse(open.isCancelled()); ++ assertTrue(sent.isEmpty()); ++ channels.addChannels(List.of(NativeContainerMessage.FAMILIES_CHANNEL)); ++ InventoryPackets.sendNativeContainerPresentation(open, kind, new ContainerUi.Title("Storage", false)); ++ assertTrue(open.isCancelled()); ++ } ++ assertEquals(2, sent.size()); ++ final var payload = Unpooled.wrappedBuffer(sent.get(1)); ++ try { ++ assertEquals(ClientboundPackets26_3.CUSTOM_PAYLOAD.getId(), Types.VAR_INT.readPrimitive(payload)); ++ assertEquals(NativeContainerMessage.FAMILIES_CHANNEL, Types.STRING.read(payload)); ++ final var message = NativeContainerMessage.FAMILIES_TYPE.read(payload); ++ assertEquals(kind, message.kind()); ++ assertEquals(7, message.menuId()); ++ assertEquals(1, message.sequence()); ++ assertTrue(JsonParser.parseString(message.program()).getAsJsonObject().get("awaiting_contents").getAsBoolean()); ++ assertFalse(payload.isReadable()); ++ assertFalse(state.blocksVanillaClicks()); ++ } finally { ++ payload.release(); ++ } ++ } ++ } ++ ++ @Test + void unsupportedProgramsDoNotConsumePresentationSequence() { + final var state = new NativeContainerUiState(); + assertNull(state.prepare(7, null, new ContainerUi.Title("Actual", false), packs().getContainerUi(), List.of())); 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 db28acb0..2697244b 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 @@ -215,3 +215,503 @@ The complete core build passes 1,153 tests with no failures; 30 optional tests r ## Upstream prerequisites - [viabedrock patch](../../viabedrock/upstreamable/0095-share-native-ui-programs-and-resources.pr.md): Land the shared UI program and scene contracts before registering presentation channels. +- [Native block data](0054-render-native-block-state-geometry-and-materials.pr.md): Land canonical auxiliary descriptors and restriction lookup before native subtype-aware merge checks. + + +## Client-authoritative chest actions + +Authored count-one chest buttons now use normal inventory transactions when the server selects client authority. +The adapter sends both slot changes with their before and after items. +It does not require modern stack IDs or wait for an item stack response. +It publishes Java prediction after transport succeeds and accepts later server slot and content corrections. + +Both inventory modes use the same native runtime-ID registry, immutable definitions, constructor-owned stack limits, action scope and destination selection. +The native combined provider checks compatible occupied slots before empty slots, with hotbar slots before main inventory slots. +Activation requires the current menu and exported presentation. +Closing the menu or withdrawing its channels clears the action state. + +Native constructor controls target Bedrock 1.26.51.1, build 51061372, protocol 2193. +The executable SHA-256 is `537c0aee2e79afbdc94b44b28e00f466ae62bc50e2733d953b430db9dbaa9ee7`. +The controls execute normal wrapper `14266bd30`, outgoing body `1427324a0` and packet constructor `14272c690` with owned ordinary item stacks. +Both boolean input cases preserve the supplied container and player slot changes and complete cleanup with balanced item lifetimes. +These controls do not verify full controller execution, authority negotiation, native wire bytes or live acceptance. +The native graphical capture stops at a Microsoft licensing-response error before a game window opens. + +Targeted tests cover count conservation, unsigned stack counts, merge order, scoped activation, stale presentations, transport failure and correction before the next action. +The production codec test writes and reads normal transactions with legacy network-item descriptors. +It does not compare captured native bytes. +The adapter handles authored 27-slot and 54-slot chests, five-slot hoppers, and nine-slot dispensers and droppers. +Workstation actions and other native providers remain incomplete. + + +The full four-project build passes 2,371 tests, with 152 optional tests skipped and no failures. +A 180-second live Fabric capture reaches the Galaxite lobby and receives three game-selection forms after two responses. +It contains no container-open packet and does not exercise the new chest adapter. +Live server acceptance of that adapter remains unverified. + +## Native stack queries + +Recipe output admission uses the stable-registry full stack query API, `ItemRewriter.matchesStackQuery`. +It preserves wildcard auxiliary values, query-sided Block identity, numeric NBT equality, canonical restriction sets and blocking ticks. +The cooking transfer helper `14c54c010` uses comparator `14248dac0` before its action. +That helper does not establish ordinary authored container merge eligibility. + +The shared query controls cover native NBT comparisons, populated userdata and output admission. +The ordinary occupied-slot predicate uses the same userdata comparator and separate stacking rules. +Independently mutated native charged children remain outside the retained network-stack representation. + +## Client-authoritative redstone storage actions + +Authored hopper, dispenser, and dropper buttons now use normal transactions in client-authoritative sessions. +Both authority modes share exact provider, type, size, and presentation-family checks. +The ordinary storage definitions and their opening foundation now precede their first action consumer. +Modern request-slot names remain in the deferred inventory adapter. + +Actor-backed hoppers can retain the generic `CONTAINER` wire type after entity-based screen resolution. +Accept the resolved five-slot `HopperContainer` without rewriting that wire identity. +The former authoritative provider check rejected that valid combination. +Reject unrelated classes, wrong sizes, mismatched redstone families, and brewing workstations. +Require family-channel negotiation before sending these programs, and use the matching message codec. + +The pinned Bedrock 1.26.51.1 `ui/redstone_screen.json` supplies the layout evidence. +Its hopper uses a five-by-one `container_items` grid. +The dispenser and dropper use three-by-three grids through the common container-item controls. +The adapters reuse the existing direct external slots and balanced ordinary transaction semantics. +This establishes implementation coverage and layout provenance, without native graphical or live acceptance claims. + +All 55 targeted cases pass, with no skips or failures, and both Checkstyle tasks pass. +The new cases execute authored bindings and activation for each redstone family. +They inspect normal transaction identity, debit and credit snapshots, merge counts, tracked state, close rejection, and family codec negotiation. +Authoritative provider cases include generic-wire and explicit hopper-minecart identities. +All 104 core patches replay successfully. +Live client-authoritative acceptance remains unverified for these new families. + +The complete four-project build passes 2,345 tests, with 212 optional skips and no failures or errors. +This run enables the NBT equality, populated-userdata, and cooking-admission fixtures. +Other optional fixture controls remain skipped. +All Checkstyle and publication tasks pass, and offline validation passes. + +## Native occupied-slot merges + + +Both inventory modes now use the native occupied-slot merge predicate for authored count-one transfers. +Destination durability, typed `Unbreakable` data and constructed stack limits determine stackability. +Subtype items compare exact effective auxiliary values, including `32767` as an ordinary value. +Other items ignore auxiliary differences. +Native userdata, restriction and blocking-state comparisons remain part of merge eligibility. +Recipe output queries reuse the userdata comparator and retain their separate full identity check. + +The pinned native state registration binds `button.container_auto_place_one` to state 12. +Its entry `144e4bf10` calls `144dfb400`, which invokes model auto-placement. +Base planner `1433eb800` reaches `1433e9e90` and `1433eab10`. +Occupied capacity passes through `1431b69d0` and `147c98ca0`, which calls `142488fe0` with the source first. +The executor also uses that predicate. +This linkage comes from static inspection of the verified target executable. + +Fresh controls execute the complete native merge and destination stackability predicates for 756 cases. +They use real ComponentItem construction, registry ownership, binary NBT parsing and cleanup. +The matrix covers stack limits, subtype flags, durability, typed damage, unbreakable fields and auxiliary values. +Java predicate and destination-selection comparisons pass for every case. +The old raw planner fails the destination-selection regression. + +These controls use standalone Item 259 without an associated Block, restrictions or a charged child. +Block auxiliary comparison uses the existing canonical descriptor resolver. +Unresolved subtype metadata cannot establish occupied-slot compatibility. +Definition replacement lifetimes, restriction hash collisions and specialized Item overrides remain unverified. +The controls do not establish complete controller execution, native wire bytes or live server acceptance. + +The combined core build preserves the concurrent NBT fix and redstone provider support. +It passes 1,485 tests, with 90 optional tests skipped and no failures. +The run includes the 756 merge cases, native tag comparisons, populated-userdata controls and cooking-admission controls. +Main, test and tool Checkstyle and Maven POM generation pass. +All 104 exported core patches replay to the tested source tree. + +## Inventory image failure report + +The current release report shows inventory items without their panel background. +The reporter opened the screen with E. +The original active client screen type remains unknown. +A verified failure path allowed a native container program before the converted pack loaded. +The core now keeps its ordinary menu until those resources load, without advancing presentation identity. +The shared client image loader now reports missing or corrupt pack images through the existing screen failure handler. +It preserves asynchronous remote-image loading and can recover when a new pack source supplies valid pixels. + +Both regressions fail before their fixes and pass after them. +The full core build passes 1,494 tests with 90 optional skips, including the concurrent cooking return fix. +The paired add-on build passes 843 tests with 122 optional skips. +Both have zero failures or errors. +All 104 core and 38 add-on patches replay to their identical tested source trees. +Main, test and tool Checkstyle, both publication POMs, add-on access-widener validation and four patch metadata tests pass. + +This fixes two asset-failure paths and does not confirm the screenshot's exact cause. +It does not establish native pixels or GUI scale parity. +The same report includes pearl/wind-charge rollback, repeated eating and a missing locator bar. +Those symptoms remain under investigation. +Fresh public WildMC joins completed on October 23, 2026, after the storage cleanup. +The published 0.3.43 add-on and pinned ViaFabricPlus match their release checksums in the installed private client. +The release client shows complete backgrounds for its lobby inventory, native server selector, WildFFA inventory, and native `/kits` menu. +After the Default kit claim, the release inventory closely matches the reported item arrangement and retains its background. +Screen observations identify E as an ordinary `InventoryScreen` and the two menus as `BedrockNativeContainerScreen`. +The current build also shows complete lobby, Practice, and WildFFA inventories and native menus. +The six kit icons and player-inventory layout match the native WildFFA `/kits` menu. +The reporter used E, but these E controls retain an ordinary inventory screen. +The server-driven path to that layout remains unverified. + +The initial current and release captures show locator markers and receive `LOCATOR_BAR` packets. +The first current capture records 1,015 packets, and the release capture records 6,909 packets. +The initial clients use a plain profile, software rendering, an 854 by 480 window, and Java GUI scale two. +Further release controls use the reported 1920 by 1013 resolution with Java GUI scales two and four. +The native `/kits` background remains complete in both scale combinations. +Each capture completes initialization, spawn, gameplay acknowledgments, and normal cleanup of all eight owned process identities. +The release run includes a private observer that reads screen and menu state on the client thread. + +These controls do not reproduce the reported fault or its server-driven screen transition. +They do not establish the original mod configuration or official native pixel parity. +The pearl/wind-charge and repeated-eating reports remain open. +Raw journals, server packs, screenshots, account data, and observer output stay private. + + +## Registry-owned custom names + +Java item conversion now uses the registry's constructed class to select custom names. +Native written books fall back to `title` when `display.Name` is empty or has the wrong type. +A wrongly typed, present title selects empty custom text. Filtered fields do not replace the unfiltered name. + +Component replacements keep base custom-name dispatch, even after a later legacy update. +The final network flag does not select this behavior. Removed runtime-ID owners cannot acquire custom names. +The shared selector and registry tests pass all 19 focused tests without skips, including 2,286 retained native comparisons. +Ordinary-key production, secondary labels, language selection, search transport, and authored text editing remain unfinished. + + +The current four-project build passes 2,644 tests, with 163 optional skips and no failures or errors. +All 104 core patches and 38 client patches replay. All three selector tests run without skips. +All 1,419 core classes match the embedded add-on and proxy copies. All 1,139 client classes match the add-on. +Four focused packaged checks pass, including the registry's book-class dispatch. +The packaged selector matches all 2,286 applicable native rows. Production class origins are verified against the current standalone core JAR. +This checkpoint does not establish full cooking search or native screen and pixel parity. +## Component description construction and conversion + +The connection registry now resolves component description keys from actual construction and ordered metadata updates. +Explicit construction runs before every component reader. An existing component item accepts valid name strings from later None and Legacy entries. +Empty strings restore its ordinary description. Omitted and malformed values retain the previous name. +Unknown None entries receive their fallback class after these readers, so earlier metadata cannot name that new definition. + +The builtin catalog records actual component-name dispatch separately from the factory version. +Its one default component description belongs to the warped-fungus-on-a-stick definition. +The recorded default retains that complete key. Explicit data-driven reconstruction selects the ordinary mutable component definition. +First explicit construction and final registered identity stay separate from later entry flags. + +Java conversion uses these keys for mapped vanilla items and custom items through the resource text engine. +This name path uses its own registry snapshot and preserves the existing loaders for other item fields. +The superseded network-name reader and its three tests are removed. Resource-pack name parsing remains in the item-definition loader. +Custom-name presence and written-book class selection retain their separate native dispatch. + +Original native execution supplies 23 final descriptions across ordered construction, update, type, empty-value, and fallback cases. +The latest authored admission control records 17 inputs, 16 actual virtual reader calls, and ten final definitions. +All 2,627 static cleanup callbacks complete without owned allocation or lock leaks. +The older thirteen-definition control supplies the remaining final descriptions. +These are authored inputs with actual native construction and getters. They do not establish normal packet identity translation or client asset precedence. + +The old packaged Java converter fails twelve of twenty applicable component-name cases. +Both focused tests pass, including all 23 native results and registry snapshot/identity replacement. +Five deliberate regressions fail: filtering updates by their entry version, choosing the latest constructor, retaining a name after an empty value, applying metadata before fallback construction, and overwriting the builtin component default. + +Another fresh native run executes 1,946 actual factory keys with an explicitly absent descriptor and no userdata. +Three potion methods remain deferred because they require a language owner. +All 1,949 factory profiles remain available, and the full native cleanup succeeds. +An earlier run exhausted its allocator during unrelated named-stack queries after the requested keys had run. +The verified control supplies an empty query fixture and retains the original allocator and cleanup assertions. + +Full ordinary-key providers, appended primary text, secondary labels, asset precedence, cooking query transport, and unchanged authored-screen acceptance remain unfinished. + +The final combined build passes 2,665 tests, with 163 optional skips and no failures or errors. +Both new tests run without skips. The three superseded reader tests are removed; their thirteen native results remain in the registry checks. +All 105 core and 38 client patches replay. All 1,427 compiled core classes match the standalone JAR after four standard token replacements. +Both embedded core copies match, and all 1,143 client classes match the add-on. +The new factory-description resource matches all three packaged copies. +The final packaged converter passes all twenty component cases, with five verified production class origins. + +A fresh ordinary BDS 1.26.51.1 route passes furnace, smoker, blast furnace, and same-world reconnect. +Both selected client instances use the final add-on, and normal cleanup stops the private display. +This route does not inject the authored name-update cases or establish native screens and pixels. + +## Constructor-owned ordinary names + +The registry now retains 559 builtin translation keys across fourteen native methods that ignore descriptors and userdata. +These include base and alias descriptions, spawn eggs, boats, chest boats, cushions, coal, records, and fixed specialized keys. +Java item conversion resolves these keys through the current native language engine. +Explicit component construction replaces builtin naming before ordered metadata readers. Later network flags cannot select a different class. +Removed runtime-ID owners cannot retain a name. + +The initialized native control queries 1,946 factory keys and retains all 1,949 constructor profiles. +It runs the real element registry initializer and completes all 2,630 static cleanup callbacks without tracked allocation or lock leaks. +Initialization corrects 118 element keys; the 559 descriptor-independent keys remain unchanged. +Three potion methods still require a language owner. + +The installed 1.26.50 block archive contains exactly the 98 definitions absent from the compiled default block registry. +These are asset definitions, separate from aliases. Their presence does not establish native asset loading or final name selection. +Block, potion, and other descriptor-dependent methods still need their own providers. + +Two registry tests cover all 559 native keys, construction precedence, network flags, and runtime identity replacement. +Three deliberate regressions fail assertions: guessing identifier suffixes, retaining builtin dispatch after reconstruction, and ignoring runtime ownership. + +A packaged conversion control uses 2,076 typed protocol-2193 item registrations from the versioned cross-implementation resource fixture. +Its accepted language layer supplies distinct translations and exercises the real parser, lookup, converter, and text serializer. +All 557 applicable builtin names pass. Two explicitly reconstructed component items retain their separate naming path. +The previous package passes seven cases and fails 550, including 41 conversion exceptions. +The converter now handles a missing upgraded block-state set through its existing missing-visual fallback. +This control verifies supplied registry and language inputs. It does not establish fresh native packet decoding, asset precedence, or visible UI parity. + +The combined build passes 2,681 tests, with 163 optional skips and no failures or errors. +All 105 core and 38 client patches replay. All 1,428 compiled core classes match their packaged bytes after four standard token replacements. +Both embedded core copies match. All 1,143 client classes and the updated factory-name resource match the final artifacts. +The final converter also retains all twenty component-description checks. +A fresh ordinary BDS route passes all three cooking families and same-world reconnect. +Both isolated launchers select the final add-on through the same installed integration instance. +Normal cleanup stops the private display and all observed owned processes. This route does not inject name fixtures or establish native screen parity. + +## Descriptor-aware ordinary names + +Builtin definitions now retain name providers for 41 definitions across thirteen additional native methods. +These cover buckets, dyes, written books, empty and filled maps, beds, balloons, banners, shields, medicine, glow sticks, sparklers, and boards. +Providers use constructor descriptions and variants separately from stack metadata, runtime IDs, and later factory flags. +The converter passes the actual descriptor metadata and unfiltered userdata to the registry. +Explicit component construction still takes precedence. Removed runtime owners cannot retain builtin naming. + +Typed userdata reads preserve native distinctions between integers, other numeric tags, strings, absent values, and empty compounds. +Book filtering follows field presence and the constructor variant. Bucket naming uses its definition variant rather than stack metadata. +Map naming preserves exploration identities, signed map numbers, and the difference between absent and empty userdata. +Color providers retain each method's native masks, unsigned conversions, extended colors, and invalid-value defaults. + +Original Bedrock 1.26.51.1 controls supply 10,906 composed results across all 41 actual registry receivers. +The base context retains 10,640 results for forty definitions. A second initialized context supplies all 266 filled-map results. +Both contexts query identical map input tuples and native methods. No expected key strings are authored for the composition. +The second context runs the complete structure-identity CRT initializer before registry and key startup. +That initialization corrects 66 cold-context map results. The other 200 map results remain unchanged. +The base context completes 2,630 cleanup callbacks; the map context completes 2,662. Both retain zero tracked allocations and locks. +The base control constructs, copies, and destroys 2,419 native userdata compounds. The map control constructs another 59 typed compounds. +These controls supply descriptor transport and userdata boundaries. They do not execute complete native descriptors, network decoding, world ownership, or client asset loading. + +Another 9,089 bounded original-method controls cover typed tags, constructor variants, presence, and bit masks. +Nine focused registry and provider tests pass without skips. +Five deliberate regressions fail assertions: stack metadata as a bucket variant, numeric-tag coercion, absent/empty conflation, content-based filtered-title selection, and stale runtime ownership. + +The packaged converter passes all 8,282 applicable nonnegative descriptor cases with no exceptions or missing runtime owners. +The previous package passes 1,010 and fails 7,272 under the same initialized fixture. +The control supplies a protocol-2193 registry and an accepted language layer, then exercises the real NBT parser, name provider, converter, and serializer. +It verifies ordinary item names, not final custom-name append composition or visible UI parity. +All 557 fixed-name checks and twenty component-description checks also pass against the final package. + +The combined build passes 2,686 tests, with 163 optional skips and no failures or errors. +All 105 core patches replay. All 1,432 compiled core classes match the standalone package after the four standard token replacements. +Both embedded core copies match. All 1,143 client classes and the updated factory resource match the final artifacts. +Block providers and primary append composition gain the scoped coverage below. +Potion, arrow and compound providers, secondary labels, cooking query transport, and unchanged authored-screen acceptance remain unfinished. + +A fresh ordinary BDS 1.26.51.1 run passes furnace, smoker, blast furnace, and same-world reconnect with the final add-on. +Both isolated launchers select that package through the same installed integration instance. +Normal cleanup stops all observed run-owned processes and the private display. This run does not inject descriptor fixtures or establish native screen parity. + +## Ordinary name append composition + +Production conversion now applies the native userdata append path to modeled ordinary name providers. +One implementation supplies the lazy primary selector and the underlying Java `ITEM_NAME` component. +Java `CUSTOM_NAME` keeps its separate override. Custom names still bypass ordinary production in the primary selector. +The append flag requires a nonzero byte tag. Group names precede body formatting and raw custom suffixes. +Group and color strings use the current language translator. Body formatting receives the native one-argument or two-argument color vector. +Unsupported ordinary providers retain their existing conversion path. + +The target executable's I18n constructor selects the same vtable used by ordinary names. +Its two relevant slots execute the original no-argument wrapper and language-manager formatter. +A fresh control composes those virtual calls with the complete primary selector, ordinary append path, typed NBT lookups, and native dictionary lookup. +All 2,442 results match the shared implementation through the current language engine. +These include 2,286 retained selector inputs and 156 additional format, lookup, empty-value, malformed-byte, and custom-precedence cases. +The control supplies selected English table state, descriptor and tag interfaces, null shared ownership blocks, allocator/CRT calls, and single-thread locks. +It does not run the complete language constructor, asset loading, locale acquisition, native ownership lifetime, or graphical UI. + +Four focused tests pass without skips. Four deliberate mutations fail assertions after successful compilation and test startup. +They admit numeric append flags, remove group precedence, skip color translation, or omit the second color argument. + +Another native control executes the actual coal key method and records 39 primary and underlying ordinary name pairs. +Its supplied tables use lowercase keys and nonempty values without embedded NUL or malformed bytes, matching the language-file parser's contract. +The packaged control passes those raw files through the real parser and verifies the parsed table before conversion. +All 39 ordinary and 39 primary results match. The previous package passes six paired cases and fails 33. +Five production class origins point to the selected core package. The control uses the protocol-2193 registry and preserves builtin construction. +This establishes conversion for supplied inputs, not native packet decoding, complete asset precedence, or visible parity. + +All 8,282 descriptor, 557 fixed-name, and twenty component-description converter checks remain green. +The combined build passes 2,687 tests, with 163 optional skips and no failures or errors. +Both embedded core copies retain all 1,432 classes, and all 1,143 client classes match the add-on. +The four-project build replays the complete stacks. Block, potion, arrow, compound, secondary labels, query transport, and unchanged authored screens remain open. + +A fresh ordinary BDS run passes all three cooking families and same-world reconnect with the final add-on. +Both selected launcher paths resolve that package through the shared installed integration instance. +Normal cleanup stops every observed run-owned process and the private display. This run does not inject authored append metadata or establish native screens and pixels. + + +## Compiled block-backed item names + +Block-backed builtin items now use their native constructor associations and canonical auxiliary block states to select ordinary name keys. +Generic block, leaf, wool/carpet and sapling providers preserve their separate native default and empty-name decisions. +Component reconstruction replaces builtin dispatch, and removed runtime owners cannot retain a name. +The block source stays lazy for other item methods. +Compound providers remain separate work. + +The compiled table retains 1,379 native block-definition keys. +All keys match the completed initialized native definition control. +All 1,343 item associations, receiver methods and applicable constructor descriptions match the completed native factory control. +Those two controls finish 2,629 and 2,630 static cleanup callbacks respectively, with no tracked allocations or locks left. +They use the supplied native version and experiment context, rather than proving later asset or client ownership. + +Private block/leaf, wool and sapling controls cover 4,092 original native cases. +Four deliberately incorrect fallbacks fail assertions. +The packaged converter passes all 1,337 auxiliary-32767 cases from the native absent/sentinel equivalence control. +The previous package passes 220 and fails 1,117 under the same registry and language inputs. +Both runs report no conversion exceptions or missing runtime owners. +These results cover the declared sentinel inputs, not every auxiliary value or authored-screen pixels. +The broader 96,696-case native receiver audit continues separately; partial results do not count as completed validation. + +The final combined build passes 2,703 repository tests, with 163 optional skips and no failures or errors. +All 105 core and 38 client patches replay. +All 1,441 compiled core classes match the standalone package after the four standard replacements for build constants. +Both embedded core copies match. All 1,144 client classes and both changed data resources match their final artifacts. +The four block oracle tests remain private and run against the final package. +A fresh ordinary BDS smoke check covers all three cooking families and same-world reconnect. +This smoke check does not inject authored block-name controls or establish native screen parity. + +## World version ownership for potion names + +Potion and arrow names now use the version source described here. +The target remains Bedrock 1.26.51.1, build 51061372, protocol 2193, with executable SHA-256 `537c0aee2e79afbdc94b44b28e00f466ae62bc50e2733d953b430db9dbaa9ee7`. + +The native identity string names `LegacyClientNetworkHandler::handle(..., StartGamePacket)` at `1413323c0`. +Its copy call at `141332536` takes level settings from packet offset `30`. +The original settings copy at `1407f5740` copies the version member at offset `220` through `141e20310`. +The resulting local supplies the fifth argument of the client configure call at `1413340ab`. +The actual client constructor installs vtable `14e775c50`, whose configure slot is `18`. +The earlier `98` offset used the adjacent table's start and did not establish the caller. +Native schema registration also binds the named Base Game Version field to that settings member. + +Thirty-six controls execute the complete native string parser and comparator, followed by original local-copy and virtual-dispatch instructions. +Inputs include partial versions, prerelease and build suffixes, wildcard, `beta`, malformed strings, whitespace and 16-bit component boundaries. +Native local and registered static cleanup leave no tracked allocations or locks after the parser control. +Reusing the existing loose session conversion disagrees on eighteen inputs. A fixed client-build version disagrees on twenty-four. +For example, native parsing removes a valid prerelease suffix before these comparisons and rejects a fourth numeric component. +These results reject direct use of the existing loose session version. +The connection now stores a separate native `BaseGameVersion` value parsed directly from Start Game. +Potion receivers obtain this value lazily through the shared name context. + +The caller control supplies packet storage, frame storage, client and level holders, and the module allocation. +It executes the settings copy only through its version member, with declared boundaries for unrelated typed members. +It executes the constructor's vptr store and observes the configure entry, rather than running either complete function. +The parser uses the established allocator, CRT, OS and TLS hosting. The CFG dispatcher is also a declared boundary. +These checks do not run the packet wire decoder, complete registry initialization, game session lifetime, authored screens or visible pixels. +The earlier 2,844 potion key/I18n cases remain supplied-context evidence. + + +## Native potion primary names + +Potion, splash and lingering names now follow the constructor-owned mode and native potion registry fields. +The compiled table contains 47 definitions, including the four effect registrations gated at world version 1.21.0. +The provider uses the current language engine to select the composed phrase or native fallback key. +Missing, empty, unchanged, nested and binary translation values retain their distinct behavior. +Absent or invalid auxiliary values use the native default entry. +Component reconstruction replaces this provider, and removed runtime owners cannot retain it. +Other providers keep their lazy block, language and version sources. + +The native version model retains partial numeric versions, wildcard and `beta`. +It validates the original grammar and unsigned-short component range, then removes accepted suffixes before feature comparisons. +Invalid input retains the native zero version. The complete parser/comparator corpus still passes all 36 inputs against the packaged class. + +All 5,688 original potion key/I18n results match the packaged registry and provider across four version contexts. +The fresh malformed-version and prerelease contexts receive byte records produced by the separate native parser control. +They execute original effect/potion construction, registration, mode getters, name selection and I18n formatting. +The native registries contain 47, 43, 43 and 47 entries in the modern, legacy, malformed and prerelease contexts respectively. +Registry field comparisons pass in all four contexts. +The receiver corpus also covers requests for entries absent from an older registry. +The selected language tables, descriptor transport and declared allocator/CRT/TLS/intern hosting remain supplied. + +The packaged converter passes all 3,504 applicable cases through raw language parsing, the typed protocol-2193 registry and item serialization. +The previous package passes 48 and fails 3,456 under the same inputs. +The conversion control excludes 288 non-stack descriptors and 1,896 language inputs that raw language files cannot represent. +The separate receiver comparison covers those descriptor and dictionary inputs. +Four private wrong variants fail behavioral assertions: fixed modern version, unconditional phrase selection, empty translation treated as missing, and invalid version treated as modern. +The block providers retain all 4,092 native receiver comparisons and 1,337 packaged sentinel cases. +Their private context checks also reject language or version access from block methods. +New string-based oracle sources stay private; existing repository tests follow the required context API. + +The combined build passes 2,703 tests with 163 optional skips. +All 1,446 core classes match source and both packaged consumers; all 1,144 client classes match the final add-on. +The three naming resources match source in the standalone core, add-on and proxy. +A fresh final-artifact BDS run passes all three cooking families and same-world reconnect. +Both selected launches use the exact final add-on. All nine observed owned identities retire, and the private display stops normally. + +These checks do not establish complete native packet decoding, asset/locale ownership, authored screen acceptance or visible potion text. +Compound names, secondary labels and cooking query transport remain open. + + +## Native arrow primary names + +Arrow names now use the compiled ArrowItem constructor description and the shared native potion registry. +Auxiliary zero uses the ordinary description key and skips version lookup. +Other descriptors select potion auxiliary minus one. Absent, out-of-range and unavailable entries keep the native empty-arrow fallback. +Nonempty potion descriptions take precedence over effect names. +The arrow receiver requests neither block lookup nor language formatting. The converter supplies the shared name and text composition afterward. +Component reconstruction and runtime-owner retirement still replace or remove builtin dispatch. + +The initialized native factory control identifies one receiver: `minecraft:arrow`, description `item.arrow`, key method `1426866b0`. +All 1,264 original key results match across modern, legacy, malformed-version and prerelease contexts. +The corpus includes absent and negative descriptors, sentinel values, and empty, NUL-containing and malformed UTF-8 descriptions. +Of those results, 316 use the actual constructor description and pass through the registered provider too. +Fresh malformed and prerelease controls consume the records from the complete native version parser control and run original potion construction, registration and arrow key selection. +Their descriptor transport and allocator/CRT/TLS/intern hosting remain declared boundaries. + +All 584 applicable packaged conversions pass through the typed protocol-2193 registry, raw language files and item serialization. +Missing dictionaries and known literal translations provide independent expected display values. +The previous package passes eight and fails 576 under the same inputs. +The control excludes 48 non-stack and 1,896 row/table combinations with other descriptions; the receiver corpus covers those fields separately. +Five provider class origins and eight converter class origins match the tested package. +Four private wrong variants fail assertions: omitted auxiliary offset, absent descriptor treated as ordinary, fixed modern version and eager version lookup. + +All 5,688 potion receiver comparisons and 1,337 packaged block sentinel cases still pass. +The complete build passes 2,703 tests with 163 optional skips. +All 1,447 core classes match source and both consumers; all 1,144 client classes match the add-on. +Naming resources match source in the standalone core, add-on and proxy. +A fresh final-artifact BDS run passes all three cooking families and same-world reconnect. +Both selected launches use the exact final add-on; all nine observed owned identities retire, and the private display stops normally. + +These checks do not establish complete native packet decoding, registry/session ownership, asset/locale acquisition, authored screens or visible arrow text. +Compound names, secondary labels and cooking query transport remain open. + +## Native compound primary names + +Compound names now use the original 38-entry enum table and the low auxiliary byte. +An absent descriptor selects 255. Values outside the table retain the empty suffix in `item.compound..name`. +The provider requests no block, language or world-version callback. Component reconstruction and runtime-owner retirement retain control over dispatch. + +The target is Bedrock 1.26.51.1, build 51061372, protocol 2193. +Original key `148e310b0`, selector `148e313a0`, enum `148e307d0` and recipe hash `1424e54b0` execute in 8,704 bounded cases. +These controls supply plain descriptors, typed alias-map chains and registry interfaces. +A separate initialized registry control constructs the actual compound receiver and its recipe aliases, then checks 263 keys. +Its three aliases belong to sugar, ink sac and charcoal. Their recipe hashes are disjoint from every compound auxiliary value. +That disjointness check uses the native hash formula across 65,536 inputs; it does not claim that many native calls. +The initialized control completes all 2,630 registered cleanup callbacks, locale cleanup, allocation tracking and lock checks. +Plain descriptor transport remains a supplied interface; complete packet and proxy lifetimes remain unverified. + +All 512 applicable packaged conversions pass through a typed protocol-2193 registry, raw language parsing and final item text serialization. +The previous package fails 510 of them. Missing dictionaries and independently supplied literal translations determine the expected display text. +The conversion control excludes 8,448 rows with other descriptor IDs, supplied alias maps or absent items. +The actual-registry control separately checks alias admission, component replacement, owner retirement and lazy source access. +Four private wrong variants fail behavioral assertions: a 16-bit mask, absent descriptor as salt, unknown type as garbage and eager version lookup. +Every captured constructor now has a primary name provider or component description, covering 1,949 definitions. +This table coverage does not establish secondary labels, locale acquisition, query transport, authored screens or visible compound text. + +All 1,264 arrow keys, 5,688 potion keys and 1,337 packaged block sentinel cases still pass. +The complete build passes 2,703 tests with 163 optional skips. +All 1,448 core classes match source and both consumers; all 1,144 client classes match the add-on. +The three naming resources match source in the standalone core, add-on and proxy. + +A fresh BDS run passes all three cooking families and same-world reconnect with the final installed add-on. +Both launches match its checksum. All nine observed owned identities retire, and the private display stops normally. +Java cooking screens handle these controls. Unsupported authored preference mappings still prevent native screen acceptance. +The client source tree is unchanged by this naming update. 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 0117f060..5f7dfea7 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 de430a4138f5fa3a6f9f51c24c453c4448725e56 Mon Sep 17 00:00:00 2001 +From aa607d8a2bb3e1ae73af6f9addfed149305d37cc 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 @@ -100,13 +100,140 @@ the action envelope. Version three rejects older envelopes because their missing input mode cannot safely default to Java placement semantics. Echo the mode in the exact request identity and reject unknown mode bytes; this contract does not establish native quantity or cursor behavior. + +Preserve native cooking event phases 0, 1, and 2 separately from input +commands and transfer quantities. The original state dispatcher uses phase 0 +for initial primary selection and phase 2 for selected-state release. +Action and result envelopes use version 5 so earlier inputs cannot silently +gain a default controller mode or phase. Physical input mapping and the +complete authored cooking action backend remain separate unfinished work. + +Bind authored recipe authority to the exact sorted, visible collection cell, +not only membership in the visible recipe set. Reject a missing or malformed +collection index and an ID from another visible cell. Preserve integer JSON +identity instead of coercing real indices such as 7.0. Native 1.26.51.1 event +callbacks retain typed collection indices and resolve recipe_book through +the active tab; the separate slot resolver differs on the inventory tab. + +Two behavioral regressions fail before this validation change. Additional +private fixtures execute 240 original event-source controls and six genuine +selection/release steps with the native cooking manager and input graph. +Recipe data and external game interfaces remain controlled; full cooking +controller ownership, placement, repeated selection, wire requests, and +live acceptance remain unverified. + +Publish the pinned cooking controller's seven tab and layout constants. +Original publisher146c44040 supplies food/items/blocks/search/inventory +indices 1..5 and survival/recipe-book layouts 1/2. Without these globals, +authored grouped toggles resolve without their forced selection indices. +The bundled-scene regression retains the indices across export/import for +all three cooking families and fails before this change. This restores +control identity; complete input handlers and visual parity remain open. + +Export cooking programs from the same MenuView as their snapshot. Include +the live tab label in translation dependency discovery alongside the +authoritative title. Controller labels can change after native tab and +filter updates without appearing as literals in the authored UI closure. +Omitting them left localized search captions as raw keys on the client. + +A full-scene and action-scope round trip over all three cooking families +preserves accepted-pack translations. Removing the live label dependency +fails the regression; restoring it passes. Native caption source keys, +text callbacks, input mapping and complete screen parity remain separate +verification requirements. + +Use the pinned146c94ae0 tab-label getter rules in shared navigation. Search +uses the all-recipes key while filtering is disabled and family-specific +keys while enabled. The inventory tab uses craftingScreen.tab.survival. +Sixty complete native getter controls bind these branches with explicit +allocator/CRT boundaries and ingredient descriptor identities. Restoring +either earlier rule independently fails the native comparison. + +Transport the native controller input mode separately from the recipe +command and phase. All four verified numeric modes remain distinct, and +unknown modes or earlier payload versions reject. The active-device mode +getter and focused recipe path have separate native references; this +context field does not assert a physical device mapping. The Fabric mouse +adapter sends its explicit mouse context instead of inferring mode two. + +Publish reusable cooking search and text-coalescing APIs before connecting +the menu transport. Preserve raw pending text until a ready tick, apply +only the latest edit, and retain both applied and pending text when the +native guarded mode-two clear declines the event. Model-clear and settings +effects remain the integrating caller's responsibility. + +Search uses the target's Unicode 5.0 compatibility composition and full +case folding. Repackage the checksum-pinned public utf8proc 1.1.5 tables +with their MIT and Unicode notices; do not substitute a modern Unicode +library. Invalid UTF-8 preserves the whole input, while valid results end +at NUL. Match normalized primary names first, then normalized secondary +labels after the native byte-formatting automaton. Keep name producers +lazy and supplied by the caller, rather than using Java preview names. + +Native fixtures and full-codepoint public comparisons verify these shared +primitives. Production name/label acquisition, repeated native eligibility +checks, exact menu ownership, transport, physical edit admission and the +actual search widget remain required integration work. These APIs do not +make the unfinished default cooking screen actionable. + +Share native unfiltered primary-name selection and custom-name dispatch. +Preserve empty and wrongly typed custom-name presence, and use a written +book title when its display name is empty. Keep filtered names separate. +Apply ordinary appended group/body/custom text with typed-byte admission +and preserve the native color-argument order. Definition-owned ordinary +names and language lookup remain caller responsibilities. + +Fresh original-executable controls cover 1,299 base/component/coal/append +cases and 1,000 custom/book cases. The shared selector matches all 2,286 +applicable selection/append rows. Key dispatch controls remain separate. +Three deliberate name-selection regressions fail assertion checks. These +controls do not establish full registry, localization, screen or GPU parity. + +Resolve complete authored cooking screen hosts through the shared control +expander instead of selecting only their content panel. Preserve screen +input mappings, profile variables, policies, overlays and inherited control +hierarchy in direct and transported scenes. Require an actual screen root; +independent authored screens must declare their own content graph. +The pinned default Furnace, Blast Furnace and Smoker hosts resolve with no +missing templates and round-trip exactly through program export. Existing +slot/recipe authority, titles, layering and payload bounds remain enforced. +This preserves the authored contract; unsupported client controller and +screen-policy behavior still requires implementation and admission checks. + +Finalize UI inheritance in declaration load order. Fill absent members from +non-null ancestor fields, retain explicit local nulls, and reuse completed +parents. Stop at missing or visited ancestors, including native self +references, without weakening recursive control expansion bounds. Apply +array modifications to a fresh finalized template. Preserve declaration +order in exported closures so transported programs resolve identically. +Use the same finalizer for accepted declarations and native HUD baselines. + +Original 1.26.51.1 registry constructor 1403f3f20, finalizer 14043efd0 and +lookup 1403f69f0 confirm these semantics. Typed loader-output records cover +250 three-node parent graphs and the exact furnace_pocket self declaration. +All 751 lookups match Java directly and after export, 1,502 comparisons. +The capture stops at 140440ed0 before external scheduling and cleanup; +it does not establish resource-loader ordering, complete native screen +construction, physical input, pixels or full lifecycle parity. + +Share recipe collection partitioning between cooking and grouped player +recipes. Preserve stable four-state buckets, contiguous catalog identity, +first-member representatives, and icon/count-based expansion history. +Ungrouped filtering retains its existing visible-prefix semantics. + +Original 1.26.51.1 routines 143445150, 143446b90, 143447710 and 143447ab0 +provide 4,128 collection cases and ten retained-state steps. Six additional +steps distinguish catalog identity from icon/count header identity. Item +copying, icon comparison, availability evaluation and external callbacks +remain supplied boundaries. Recipe acquisition, full player controller, +physical input and screen parity remain separate integration work. --- build.gradle | 11 + .../api/model/NativeContainerAction.java | 93 + .../api/model/NativeContainerData.java | 70 + .../api/model/NativeContainerMessage.java | 112 ++ .../api/model/NativeContainerStatus.java | 60 + - .../api/model/NativeCookingAction.java | 86 + + .../api/model/NativeCookingAction.java | 96 ++ .../api/model/NativeCookingActionResult.java | 43 + .../api/model/NativeCookingControl.java | 73 + .../api/model/NativeCookingMessage.java | 304 ++++ @@ -115,22 +242,27 @@ this contract does not establish native quantity or cursor behavior. .../api/model/NativeFormMessage.java | 56 + .../api/model/NativeHudMessage.java | 60 + .../api/model/NativeItemHoverText.java | 106 ++ + .../viabedrock/api/model/NativeItemNames.java | 69 + .../api/model/NativeNpcDialogueMessage.java | 60 + .../http/ConvertedResourcePackCache.java | 4 +- .../api/resourcepack/ui/BrewingStandUi.java | 44 + .../api/resourcepack/ui/ContainerUi.java | 511 ++++++ - .../resourcepack/ui/CookingNavigation.java | 70 + - .../resourcepack/ui/CookingRecipeOrder.java | 218 +++ - .../api/resourcepack/ui/CookingUi.java | 405 +++++ + .../resourcepack/ui/CookingNavigation.java | 83 + + .../resourcepack/ui/CookingRecipeOrder.java | 206 +++ + .../resourcepack/ui/CookingRecipeSearch.java | 74 + + .../resourcepack/ui/CookingSearchText.java | 45 + + .../api/resourcepack/ui/CookingUi.java | 433 +++++ .../api/resourcepack/ui/FormBindings.java | 290 ++++ .../api/resourcepack/ui/HudActionbarUi.java | 20 + .../api/resourcepack/ui/HudFactoryUi.java | 132 ++ .../api/resourcepack/ui/HudRootUi.java | 35 + .../api/resourcepack/ui/HudTitleUi.java | 28 + - .../api/resourcepack/ui/JsonUiProgram.java | 1495 +++++++++++++++++ + .../api/resourcepack/ui/JsonUiProgram.java | 1498 +++++++++++++++++ + .../ui/NativeSearchNormalization.java | 189 +++ .../api/resourcepack/ui/NpcDialogueUi.java | 316 ++++ .../resourcepack/ui/NpcEditorBindings.java | 329 ++++ .../api/resourcepack/ui/NpcEditorState.java | 322 ++++ + .../api/resourcepack/ui/RecipeCollection.java | 239 +++ .../api/resourcepack/ui/ServerFormUi.java | 253 +++ .../resourcepack/ui/UiAsepriteMetadata.java | 95 ++ .../api/resourcepack/ui/UiBindings.java | 43 + @@ -140,29 +272,36 @@ this contract does not establish native quantity or cursor behavior. .../rewriter/ResourcePackRewriter.java | 2 + .../resourcepack/JsonUiResourceRewriter.java | 36 + .../protocol/storage/ResourcePackStorage.java | 41 +- + .../resources/META-INF/licenses/utf8proc.txt | 70 + + .../viabedrock/data/search_unicode_5_0.bin.gz | Bin 0 -> 45618 bytes .../api/model/NativeContainerActionTest.java | 88 + .../api/model/NativeContainerDataTest.java | 45 + .../api/model/NativeContainerMessageTest.java | 98 ++ - .../model/NativeCookingActionResultTest.java | 61 + - .../api/model/NativeCookingActionTest.java | 102 ++ + .../model/NativeCookingActionResultTest.java | 63 + + .../api/model/NativeCookingActionTest.java | 170 ++ .../api/model/NativeCookingControlTest.java | 99 ++ - .../api/model/NativeCookingMessageTest.java | 422 +++++ + .../api/model/NativeCookingMessageTest.java | 424 +++++ .../api/model/NativeCookingProgressTest.java | 38 + .../api/model/NativeFormMessageTest.java | 51 + .../api/model/NativeHudMessageTest.java | 38 + .../api/model/NativeItemHoverTextTest.java | 56 + + .../api/model/NativeItemNamesTest.java | 123 ++ .../model/NativeNpcDialogueMessageTest.java | 59 + .../resourcepack/ui/BrewingStandUiTest.java | 49 + .../api/resourcepack/ui/ContainerUiTest.java | 403 +++++ - .../ui/CookingNavigationTest.java | 65 + + .../ui/CookingNavigationTest.java | 87 + .../resourcepack/ui/CookingProgressTest.java | 47 + .../ui/CookingRecipeOrderTest.java | 82 + - .../api/resourcepack/ui/CookingUiTest.java | 549 ++++++ + .../ui/CookingRecipeSearchTest.java | 75 + + .../ui/CookingSearchTextTest.java | 90 + + .../api/resourcepack/ui/CookingUiTest.java | 646 +++++++ .../ui/FormCollectionActivationTest.java | 97 ++ .../api/resourcepack/ui/HudRootUiTest.java | 90 + - .../resourcepack/ui/JsonUiProgramTest.java | 475 ++++++ + .../resourcepack/ui/JsonUiProgramTest.java | 556 ++++++ + .../ui/NativeSearchNormalizationTest.java | 46 + .../resourcepack/ui/NpcDialogueUiTest.java | 108 ++ .../resourcepack/ui/NpcEditorStateTest.java | 209 +++ + .../resourcepack/ui/RecipeCollectionTest.java | 151 ++ .../ui/ServerCustomFormUiTest.java | 146 ++ .../ui/ServerFormUiSliderStateTest.java | 126 ++ .../api/resourcepack/ui/ServerFormUiTest.java | 427 +++++ @@ -170,7 +309,8 @@ this contract does not establish native quantity or cursor behavior. .../api/resourcepack/ui/UiExpressionTest.java | 68 + .../api/resourcepack/ui/UiFontsTest.java | 192 +++ .../api/resourcepack/ui/UiTexturesTest.java | 175 ++ - 69 files changed, 11137 insertions(+), 2 deletions(-) + .../generator/SearchUnicodeDataGenerator.java | 126 ++ + 82 files changed, 12748 insertions(+), 2 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 @@ -184,11 +324,14 @@ this contract does not establish native quantity or cursor behavior. 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 create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeItemHoverText.java + create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeItemNames.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/NativeNpcDialogueMessage.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/BrewingStandUi.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/ContainerUi.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingNavigation.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingRecipeOrder.java + create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingRecipeSearch.java + 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/HudActionbarUi.java @@ -196,9 +339,11 @@ this contract does not establish native quantity or cursor behavior. create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/HudRootUi.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/HudTitleUi.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/JsonUiProgram.java + create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/NativeSearchNormalization.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/NpcDialogueUi.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/NpcEditorBindings.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/NpcEditorState.java + create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/RecipeCollection.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/ServerFormUi.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/UiAsepriteMetadata.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/UiBindings.java @@ -206,6 +351,8 @@ this contract does not establish native quantity or cursor behavior. create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/UiFonts.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/UiTextures.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/rewriter/resourcepack/JsonUiResourceRewriter.java + create mode 100644 src/main/resources/META-INF/licenses/utf8proc.txt + create mode 100644 src/main/resources/assets/viabedrock/data/search_unicode_5_0.bin.gz create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/NativeContainerActionTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/NativeContainerDataTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/NativeContainerMessageTest.java @@ -217,18 +364,23 @@ this contract does not establish native quantity or cursor behavior. 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 + create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/NativeItemNamesTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/NativeNpcDialogueMessageTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/BrewingStandUiTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/ContainerUiTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingNavigationTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingProgressTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingRecipeOrderTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingRecipeSearchTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingSearchTextTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingUiTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/FormCollectionActivationTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/HudRootUiTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/JsonUiProgramTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/NativeSearchNormalizationTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/NpcDialogueUiTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/NpcEditorStateTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/RecipeCollectionTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/ServerCustomFormUiTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/ServerFormUiSliderStateTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/ServerFormUiTest.java @@ -236,9 +388,10 @@ this contract does not establish native quantity or cursor behavior. create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/UiExpressionTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/UiFontsTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/UiTexturesTest.java + create mode 100644 src/tool/java/net/raphimc/viabedrock/tool/generator/SearchUnicodeDataGenerator.java diff --git a/build.gradle b/build.gradle -index 58e965818..e9814cbf0 100644 +index 51269fb3c..7c771c896 100644 --- a/build.gradle +++ b/build.gradle @@ -226,3 +226,14 @@ class MockitoAgentArgumentProvider implements org.gradle.process.CommandLineArgu @@ -617,10 +770,10 @@ index 000000000..0c57acdc4 +} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/NativeCookingAction.java b/src/main/java/net/raphimc/viabedrock/api/model/NativeCookingAction.java new file mode 100644 -index 000000000..6351e042a +index 000000000..49f6f0dfc --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/model/NativeCookingAction.java -@@ -0,0 +1,86 @@ +@@ -0,0 +1,96 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -633,22 +786,29 @@ index 000000000..6351e042a +import io.netty.buffer.ByteBuf; +import net.raphimc.viabedrock.protocol.data.ProtocolConstants; + -+/** A recipe selection refers to one exact server-owned presentation and inventory snapshot. */ ++/** ++ * A native command and event phase refer to one exact server-owned presentation and inventory snapshot. ++ * The controller mode is its native active-device lookup result, separate from the command and phase. ++ * Neither field establishes a physical event mapping or transfer quantity. ++ */ +public record NativeCookingAction(int menuId, long sequence, long updateSequence, long menuRevision, -+ long inventoryRevision, long cursorRevision, int recipeId, Mode mode, long requestSequence) { ++ long inventoryRevision, long cursorRevision, int recipeId, Mode mode, ++ int controllerInputMode, int eventPhase, long requestSequence) { + -+ public static final String CHANNEL = "viabedrock:native_cooking_action_v3"; ++ public static final String CHANNEL = "viabedrock:native_cooking_action_v5"; ++ static final int MAX_ENCODED_BYTES = 83; + public static final Type TYPE = new Type<>(NativeCookingAction.class) { + @Override + public NativeCookingAction read(final ByteBuf buffer) { -+ if (buffer.readableBytes() > 81 || Types.VAR_INT.readPrimitive(buffer) != 3 ++ if (buffer.readableBytes() > MAX_ENCODED_BYTES || Types.VAR_INT.readPrimitive(buffer) != 5 + || Types.VAR_INT.readPrimitive(buffer) != ProtocolConstants.BEDROCK_PROTOCOL_VERSION) { + throw new IllegalArgumentException("Unsupported native cooking action"); + } + final var action = new NativeCookingAction(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), Types.VAR_INT.readPrimitive(buffer), Mode.read(buffer.readUnsignedByte()), Types.VAR_LONG.readPrimitive(buffer)); ++ Types.VAR_LONG.readPrimitive(buffer), Types.VAR_INT.readPrimitive(buffer), Mode.read(buffer.readUnsignedByte()), ++ buffer.readUnsignedByte(), buffer.readUnsignedByte(), Types.VAR_LONG.readPrimitive(buffer)); + if (buffer.isReadable()) { + throw new IllegalArgumentException("Trailing native cooking action fields"); + } @@ -657,7 +817,7 @@ index 000000000..6351e042a + + @Override + public void write(final ByteBuf buffer, final NativeCookingAction action) { -+ Types.VAR_INT.writePrimitive(buffer, 3); ++ Types.VAR_INT.writePrimitive(buffer, 5); + Types.VAR_INT.writePrimitive(buffer, ProtocolConstants.BEDROCK_PROTOCOL_VERSION); + Types.VAR_INT.writePrimitive(buffer, action.menuId); + Types.VAR_LONG.writePrimitive(buffer, action.sequence); @@ -667,13 +827,16 @@ index 000000000..6351e042a + Types.VAR_LONG.writePrimitive(buffer, action.cursorRevision); + Types.VAR_INT.writePrimitive(buffer, action.recipeId); + buffer.writeByte(action.mode.wire); ++ buffer.writeByte(action.controllerInputMode); ++ buffer.writeByte(action.eventPhase); + Types.VAR_LONG.writePrimitive(buffer, action.requestSequence); + } + }; + + public NativeCookingAction { + if (menuId < 0 || menuId > 255 || sequence <= 0 || updateSequence <= 0 -+ || menuRevision < 0 || inventoryRevision < 0 || cursorRevision < 0 || recipeId < 0 || mode == null || requestSequence <= 0) { ++ || menuRevision < 0 || inventoryRevision < 0 || cursorRevision < 0 || recipeId < 0 || mode == null ++ || controllerInputMode < 0 || controllerInputMode > 3 || eventPhase < 0 || eventPhase > 2 || requestSequence <= 0) { + throw new IllegalArgumentException("Invalid native cooking action identity"); + } + } @@ -709,7 +872,7 @@ index 000000000..6351e042a +} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/NativeCookingActionResult.java b/src/main/java/net/raphimc/viabedrock/api/model/NativeCookingActionResult.java new file mode 100644 -index 000000000..105946d46 +index 000000000..e8159f787 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/model/NativeCookingActionResult.java @@ -0,0 +1,43 @@ @@ -727,11 +890,11 @@ index 000000000..105946d46 +/** A completed selection can resume only while its exact inventory snapshot remains current. */ +public record NativeCookingActionResult(NativeCookingAction action, boolean accepted, boolean current) { + -+ public static final String CHANNEL = "viabedrock:native_cooking_action_result_v3"; ++ public static final String CHANNEL = "viabedrock:native_cooking_action_result_v5"; + public static final Type TYPE = new Type<>(NativeCookingActionResult.class) { + @Override + public NativeCookingActionResult read(final ByteBuf buffer) { -+ if (buffer.readableBytes() < 3 || buffer.readableBytes() > 83) { ++ if (buffer.readableBytes() < 3 || buffer.readableBytes() > NativeCookingAction.MAX_ENCODED_BYTES + 2) { + throw new IllegalArgumentException("Invalid native cooking action result length"); + } + final var action = NativeCookingAction.TYPE.read(buffer.readSlice(buffer.readableBytes() - 2)); @@ -837,7 +1000,7 @@ index 000000000..0ab33dd59 +} 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 000000000..88d563677 +index 000000000..b40a8a90c --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/model/NativeCookingMessage.java @@ -0,0 +1,304 @@ @@ -979,7 +1142,7 @@ index 000000000..88d563677 + final long updateSequence, final long menuRevision, + final long inventoryRevision, final long cursorRevision, final Map recipeItems) { + return new NativeCookingMessage(view.menuId(), sequence, updateSequence, menuRevision, inventoryRevision, cursorRevision, -+ ui.exportProgram(view.family(), view.title()).toString(), GsonUtil.getGson().toJson(view), recipeItems); ++ ui.exportProgram(view).toString(), GsonUtil.getGson().toJson(view), recipeItems); + } + + @Override @@ -1493,6 +1656,81 @@ index 000000000..8b78d4c0b + } + +} +diff --git a/src/main/java/net/raphimc/viabedrock/api/model/NativeItemNames.java b/src/main/java/net/raphimc/viabedrock/api/model/NativeItemNames.java +new file mode 100644 +index 000000000..7b2cd8d20 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/model/NativeItemNames.java +@@ -0,0 +1,69 @@ ++/* ++ * 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.ByteTag; ++import com.viaversion.nbt.tag.CompoundTag; ++import com.viaversion.nbt.tag.StringTag; ++ ++import java.util.List; ++import java.util.function.BiFunction; ++import java.util.function.Function; ++import java.util.function.Supplier; ++ ++/** Native unfiltered name selection. The definition owns ordinary names and localization. */ ++public final class NativeItemNames { ++ ++ private NativeItemNames() { } ++ ++ /** Null selects the ordinary name; a present tag can select an empty custom name. */ ++ public static String customName(final CompoundTag data, final boolean writtenBook) { ++ if (data == null) { ++ return null; ++ } ++ final CompoundTag display = data.get("display") instanceof CompoundTag value ? value : null; ++ if ((display == null || !display.contains("Name")) && (!writtenBook || !data.contains("title"))) { ++ return null; ++ } ++ final String name = string(display, "Name"); ++ return writtenBook && name.isEmpty() ? string(data, "title") : name; ++ } ++ ++ /** Registration, rather than stack count, controls whether the native item definition exists. */ ++ public static String primary(final boolean registered, final CompoundTag data, final boolean writtenBook, ++ final Supplier ordinaryName, final Function translations, ++ final BiFunction, String> formatter) { ++ if (!registered) { ++ return ""; ++ } ++ final String custom = customName(data, writtenBook); ++ if (custom != null) { ++ return custom; ++ } ++ final String name = ordinaryName.get(); ++ if (data == null || !(data.get("AppendCustomName") instanceof ByteTag append) || append.asByte() == 0) { ++ return name; ++ } ++ final String group = string(data, "GroupName"); ++ if (!group.isEmpty()) { ++ return name + translations.apply(group); ++ } ++ final String body = string(data, "BodyID"); ++ if (!body.isEmpty()) { ++ final String first = string(data, "ColorID"); ++ final String second = string(data, "Color2ID"); ++ final List arguments = !first.isEmpty() && !second.isEmpty() ++ ? List.of(translations.apply(first), translations.apply(second)) : List.of(translations.apply(first)); ++ return name + formatter.apply(body, arguments); ++ } ++ return name + string(data, "CustomName"); ++ } ++ ++ private static String string(final CompoundTag data, final String key) { ++ return data != null && data.get(key) instanceof StringTag value ? value.getValue() : ""; ++ } ++ ++} diff --git a/src/main/java/net/raphimc/viabedrock/api/model/NativeNpcDialogueMessage.java b/src/main/java/net/raphimc/viabedrock/api/model/NativeNpcDialogueMessage.java new file mode 100644 index 000000000..0e2c78baf @@ -2157,10 +2395,10 @@ index 000000000..cd1d083d2 +} 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 000000000..3ff53041b +index 000000000..3a96be76d --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingNavigation.java -@@ -0,0 +1,70 @@ +@@ -0,0 +1,83 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -2177,6 +2415,19 @@ index 000000000..3ff53041b + + private CookingNavigation() { } + ++ public static String tabLabel(final int tab, final CookingUi.Family family, final boolean filtering) { ++ Objects.requireNonNull(family); ++ return switch (tab) { ++ case 1 -> "furnaceScreen.tab.food"; ++ case 2 -> "furnaceScreen.tab.items"; ++ case 3 -> "furnaceScreen.tab.blocks"; ++ case 4 -> !filtering ? "craftingScreen.tab.allRecipes" ++ : family == CookingUi.Family.SMOKER ? "furnaceScreen.tab.search.filterSmoker" : "furnaceScreen.tab.search.filterFurnace"; ++ case 5 -> "craftingScreen.tab.survival"; ++ default -> ""; ++ }; ++ } ++ + public static List tabs(final boolean forcedRecipeBook, final int food, final int items, final int blocks) { + final List tabs = new ArrayList<>(5); + if (forcedRecipeBook) { @@ -2233,10 +2484,10 @@ index 000000000..3ff53041b +} diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingRecipeOrder.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingRecipeOrder.java new file mode 100644 -index 000000000..4190b60cc +index 000000000..5d8e463b5 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingRecipeOrder.java -@@ -0,0 +1,218 @@ +@@ -0,0 +1,206 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -2296,19 +2547,7 @@ index 000000000..4190b60cc + /** Native ungrouped collections expose a stable prefix of evaluator buckets 0, 1 and 2. */ + public static List visibleOrder(final List entries, final ToIntFunction evaluation, + final boolean filtering) { -+ final List> buckets = List.of(new ArrayList<>(), new ArrayList<>(), new ArrayList<>()); -+ for (final T entry : entries) { -+ final int state = evaluation.applyAsInt(entry); -+ if (state < 0 || state > 3) { -+ throw new IllegalArgumentException("Invalid native cooking evaluator state"); -+ } -+ if (state != 3) { -+ // Without filtering, states 1 and 2 share one bucket. State 0 -+ // remains first and state 3 stays outside the visible prefix. -+ buckets.get(filtering || state == 0 ? state : 1).add(entry); -+ } -+ } -+ return buckets.stream().flatMap(List::stream).toList(); ++ return RecipeCollection.visibleOrder(entries, evaluation, filtering); + } + + private static void sort(final List entries, final ToIntFunction priority, @@ -2455,12 +2694,143 @@ index 000000000..4190b60cc + private record RegistryEntry(T entry, byte[] key) { } + +} +diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingRecipeSearch.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingRecipeSearch.java +new file mode 100644 +index 000000000..94c1b0728 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingRecipeSearch.java +@@ -0,0 +1,74 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.resourcepack.ui; ++ ++import java.io.ByteArrayOutputStream; ++import java.nio.charset.StandardCharsets; ++import java.util.Arrays; ++import java.util.Objects; ++import java.util.function.Supplier; ++ ++/** Searches native item names first, then formatting-free display labels on a miss. */ ++public final class CookingRecipeSearch { ++ ++ private final byte[] query; ++ ++ public CookingRecipeSearch(final String query) { ++ this(query.getBytes(StandardCharsets.UTF_8)); ++ } ++ ++ public CookingRecipeSearch(final byte[] query) { ++ this.query = NativeSearchNormalization.normalize(Objects.requireNonNull(query)); ++ } ++ ++ /** Name producers remain lazy. Empty queries need neither producer; a primary match skips the label. */ ++ public boolean matches(final Supplier primaryName, final Supplier secondaryLabel) { ++ return this.query.length == 0 || this.contains(NativeSearchNormalization.normalize(primaryName.get())) ++ || this.contains(NativeSearchNormalization.normalize(stripFormatting(secondaryLabel.get()))); ++ } ++ ++ /** Native search drops incomplete section signs and recognizes only digits and lowercase a through w. */ ++ public static byte[] stripFormatting(final byte[] input) { ++ final var output = new ByteArrayOutputStream(input.length); ++ int state = 0; ++ for (final byte value : input) { ++ final int next = Byte.toUnsignedInt(value); ++ if (state == 0) { ++ if (next == 0xC2) { ++ state = 1; ++ } else { ++ output.write(next); ++ } ++ } else if (state == 1) { ++ if (next == 0xA7) { ++ state = 2; ++ } else { ++ output.write(0xC2); ++ output.write(next); ++ state = 0; ++ } ++ } else { ++ if (!(next >= '0' && next <= '9' || next >= 'a' && next <= 'w')) { ++ output.write(0xC2); ++ output.write(0xA7); ++ output.write(next); ++ } ++ state = 0; ++ } ++ } ++ return output.toByteArray(); ++ } ++ ++ private boolean contains(final byte[] text) { ++ for (int offset = 0; offset <= text.length - this.query.length; offset++) { ++ if (Arrays.equals(this.query, 0, this.query.length, text, offset, offset + this.query.length)) { ++ return true; ++ } ++ } ++ return false; ++ } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingSearchText.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingSearchText.java +new file mode 100644 +index 000000000..00df0b136 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingSearchText.java +@@ -0,0 +1,45 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.resourcepack.ui; ++ ++import java.util.Objects; ++ ++/** The furnace edit callback retains only the latest raw value until its controller is ready. */ ++public final class CookingSearchText { ++ ++ private String value = ""; ++ private String pending; ++ ++ /** Physical edit-box admission and connection ownership belong to the caller. */ ++ public void edit(final String text) { ++ this.pending = Objects.requireNonNull(text); ++ } ++ ++ public String value() { ++ return this.value; ++ } ++ ++ /** Returns whether the caller must apply the latest text to its model. Repeated equal edits still apply. */ ++ public boolean tick(final boolean ready) { ++ if (!ready || this.pending == null) { ++ return false; ++ } ++ this.value = this.pending; ++ this.pending = null; ++ return true; ++ } ++ ++ /** A handled clear also requires the caller's native model-clear and settings effects. */ ++ public boolean clear(final boolean guarded, final int inputMode) { ++ if (guarded && inputMode == 2) { ++ return false; ++ } ++ this.value = ""; ++ this.pending = null; ++ return true; ++ } ++ ++} 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 000000000..2084a14b6 +index 000000000..8bd1da2ae --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingUi.java -@@ -0,0 +1,405 @@ +@@ -0,0 +1,433 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -2500,10 +2870,13 @@ index 000000000..2084a14b6 + this.title = title; + } + -+ public JsonObject exportProgram(final Family family, final ContainerUi.Title title) { -+ Objects.requireNonNull(family); -+ Objects.requireNonNull(title); -+ final JsonObject exported = this.program.export(Set.of(family.host, "furnace.furnace_panel"), new JsonPrimitive(title.raw())); ++ public JsonObject exportProgram(final MenuView menu) { ++ final Family family = menu.family; ++ final ContainerUi.Title title = menu.title; ++ 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); + if (!exported.getAsJsonObject("definitions").has(family.host)) { + throw new IllegalArgumentException("Missing native cooking host"); + } @@ -2539,9 +2912,10 @@ index 000000000..2084a14b6 + if (this.family != null && menu.family != this.family) { + throw new IllegalArgumentException("Cooking program belongs to another family"); + } -+ final var resolved = this.program.resolveScreen(menu.family.host, "furnace.furnace_panel", ++ final var resolved = this.program.resolve(menu.family.host, + new Controller(menu, this.title == null ? menu.title : this.title)); -+ if (resolved.root() == null || !resolved.requiredDefinitions().contains(menu.family.host) || !resolved.missingTemplates().isEmpty()) { ++ 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"); + } + final JsonObject scene = new JsonObject(); @@ -2566,7 +2940,7 @@ index 000000000..2084a14b6 + || !scene.getAsJsonArray("missing_templates").isEmpty()) { + throw new IllegalArgumentException("Incomplete native cooking scene"); + } -+ final Set available = new HashSet<>(); ++ final List available = new ArrayList<>(); + for (final Recipe recipe : book.recipes()) { + if (book.visibleRecipes.contains(recipe.id)) { + available.add(recipe.id); @@ -2593,7 +2967,7 @@ index 000000000..2084a14b6 + } + + private static void collectActions(final JsonObject node, final boolean parentVisible, final boolean parentEnabled, -+ final Set available, final Map> actions, final Set owners) { ++ final List available, final Map> actions, final Set owners) { + if (!owners.contains(node)) { + return; + } @@ -2610,9 +2984,10 @@ index 000000000..2084a14b6 + || node.has("inventory_slot") || node.has("inventory_actions") || !node.get("recipe_actions").isJsonArray()) { + throw new IllegalArgumentException("Unbound native cooking recipe control"); + } -+ final int id = node.get("recipe_id").getAsBigDecimal().intValueExact(); -+ if (!available.contains(id)) { -+ throw new IllegalArgumentException("Native cooking control refers to an unavailable recipe"); ++ final int id = recipeInteger(node.get("recipe_id")); ++ final int index = recipeInteger(node.get("collection_index")); ++ if (index < 0 || index >= available.size() || available.get(index) != id) { ++ throw new IllegalArgumentException("Native cooking control does not match its collection cell"); + } + for (final JsonElement value : node.getAsJsonArray("recipe_actions")) { + final JsonObject mapping = value.getAsJsonObject(); @@ -2633,6 +3008,22 @@ index 000000000..2084a14b6 + } + } + ++ private static int recipeInteger(final JsonElement value) { ++ if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber()) { ++ throw new IllegalArgumentException("Invalid native cooking control integer"); ++ } ++ // JsonCpp distinguishes integer values from integral reals such as 7.0. ++ final String encoded = value.getAsString(); ++ if (encoded.indexOf('.') >= 0 || encoded.indexOf('e') >= 0 || encoded.indexOf('E') >= 0) { ++ throw new IllegalArgumentException("Invalid native cooking control integer"); ++ } ++ try { ++ return value.getAsBigDecimal().intValueExact(); ++ } catch (final ArithmeticException invalid) { ++ throw new IllegalArgumentException("Native cooking control integer is out of range", invalid); ++ } ++ } ++ + private static boolean stateFlag(final JsonObject node, final String key, final boolean fallback) { + if (!node.has(key)) { + return fallback; @@ -2770,6 +3161,13 @@ index 000000000..2084a14b6 + private Controller(final MenuView menu, final ContainerUi.Title title) { + super(3, menu.slots, menu.cursor, title, true); + this.selection = menu.book.selection; ++ this.globals.put("$food_index", new JsonPrimitive(1)); ++ this.globals.put("$items_index", new JsonPrimitive(2)); ++ this.globals.put("$blocks_index", new JsonPrimitive(3)); ++ this.globals.put("$search_index", new JsonPrimitive(4)); ++ this.globals.put("$inventory_index", new JsonPrimitive(5)); ++ this.globals.put("$survival_layout_index", new JsonPrimitive(1)); ++ this.globals.put("$recipe_book_layout_index", new JsonPrimitive(2)); + this.registerSingleton("furnace_ingredient_items", 0, ""); + this.registerSingleton("furnace_fuel_items", 1, ""); + this.registerSingleton("furnace_output_items", 2, ""); @@ -3403,10 +3801,10 @@ index 000000000..4f11b0d3f +} 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 000000000..19c365e38 +index 000000000..33a6a77f3 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/JsonUiProgram.java -@@ -0,0 +1,1495 @@ +@@ -0,0 +1,1498 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -3454,6 +3852,8 @@ index 000000000..19c365e38 + private static final Set HOST_FILES = Set.of("ui/server_form.json", "ui/chest_screen.json", "ui/redstone_screen.json", "ui/inventory_screen.json", "ui/npc_interact_screen.json", "ui/hud_screen.json", "ui/ui_common.json", "ui/_global_variables.json"); + private final Map definitions = new LinkedHashMap<>(); + private final Map nativeDefinitions = new LinkedHashMap<>(); ++ private volatile Map templates; ++ private volatile Map nativeTemplates; + private final Map globals = new HashMap<>(); + private final Map textureFiles = new LinkedHashMap<>(); + private final UiFonts fonts = new UiFonts(); @@ -3618,7 +4018,7 @@ index 000000000..19c365e38 + final String declared = parts.length == 2 ? parts[1] : parts[0]; + final String base = parts.length == 2 && !declared.contains(".") + ? UiExpression.text(inherited.get("$__namespace")) + "." + declared : declared; -+ final JsonObject template = this.template(base, new HashSet<>(), required); ++ final JsonObject template = this.template(base, required); + if (template == null && parts.length == 2) { + return null; + } @@ -3644,7 +4044,7 @@ index 000000000..19c365e38 + if (UiExpression.truth(resolveValue(control.get("ignored"), values, 0))) { + return null; + } -+ JsonObject original = nativeOverrides == null ? null : this.nativeTemplate(base, new HashSet<>()); ++ JsonObject original = nativeOverrides == null ? null : this.nativeTemplate(base); + if (nativeOverrides != null) { + if (original == null) { + original = new JsonObject(); @@ -3701,25 +4101,11 @@ index 000000000..19c365e38 + return true; + } + -+ private JsonObject nativeTemplate(final String name, final Set active) { -+ final Definition definition = this.nativeDefinitions.get(name); -+ if (definition == null) { -+ return null; -+ } -+ if (!active.add(name) || active.size() > 64) { -+ throw new IllegalArgumentException("Recursive native HUD inheritance"); -+ } -+ try { -+ final JsonObject result = definition.parent.isEmpty() ? new JsonObject() : this.nativeTemplate(definition.parent, active); -+ if (result == null) { -+ return null; -+ } -+ merge(result, definition.value); -+ applyModifications(result); -+ return result; -+ } finally { -+ active.remove(name); ++ private JsonObject nativeTemplate(final String name) { ++ if (this.nativeTemplates == null) { ++ this.nativeTemplates = inheritDefinitions(this.nativeDefinitions); + } ++ return templateCopy(this.nativeTemplates.get(name)); + } + + /** Selects authored factory hosts before expansion, so unrelated HUD collections need no controller data. */ @@ -3742,7 +4128,7 @@ index 000000000..19c365e38 + final String declared = parts.length == 2 ? parts[1] : parts[0]; + final String base = parts.length == 2 && !declared.contains(".") + ? UiExpression.text(inherited.get("$__namespace")) + "." + declared : declared; -+ final JsonObject template = this.template(base, new HashSet<>(), required); ++ final JsonObject template = this.template(base, required); + final JsonObject control = template == null ? new JsonObject() : template; + merge(control, overrides); + applyModifications(control); @@ -3782,7 +4168,7 @@ index 000000000..19c365e38 + + public Resolution resolveScreen(final String host, final String fallback, final UiBindings bindings) { + final Session session = new Session(bindings); -+ final JsonObject screen = this.template(host, new HashSet<>(), session.requiredDefinitions); ++ final JsonObject screen = this.template(host, session.requiredDefinitions); + final Map values = new HashMap<>(session.initial); + if (screen != null) { + declarations(screen, values); @@ -3818,13 +4204,13 @@ index 000000000..19c365e38 + } + final JsonObject result = new JsonObject(); + final JsonObject definitions = new JsonObject(); -+ for (final String name : closure) { -+ final Definition definition = this.definitions.get(name); -+ if (definition != null) { ++ for (final var entry : this.definitions.entrySet()) { ++ if (closure.contains(entry.getKey())) { ++ final Definition definition = entry.getValue(); + final JsonObject value = new JsonObject(); + value.add("value", definition.value.deepCopy()); + value.addProperty("parent", definition.parent); -+ definitions.add(name, value); ++ definitions.add(entry.getKey(), value); + } + } + result.add("definitions", definitions); @@ -3954,45 +4340,60 @@ index 000000000..19c365e38 + + private List definitionPaths(final String name) { + final List paths = new ArrayList<>(); ++ final Set visited = new HashSet<>(); + String current = name; -+ while (this.definitions.containsKey(current)) { -+ if (paths.size() >= 64 || paths.contains(current)) { -+ throw new IllegalArgumentException("Recursive UI inheritance"); -+ } -+ paths.addFirst(current); ++ // Native inheritance stops at visited declarations, including self references. ++ while (this.definitions.containsKey(current) && visited.add(current)) { ++ paths.add(current); + current = this.definitions.get(current).parent; + } ++ Collections.reverse(paths); + return paths; + } + -+ private JsonObject template(final String name, final Set active, final Set required) { -+ final Definition definition = this.definitions.get(name); -+ if (definition == null) { -+ return null; ++ private JsonObject template(final String name, final Set required) { ++ if (this.templates == null) { ++ this.templates = inheritDefinitions(this.definitions); + } + if (required != null) { -+ required.add(name); -+ } -+ if (!active.add(name) || active.size() > 64) { -+ throw new IllegalArgumentException("Recursive UI inheritance"); -+ } -+ try { -+ final JsonObject result; -+ if (definition.parent.isEmpty()) { -+ result = new JsonObject(); -+ } else { -+ final JsonObject parent = this.template(definition.parent, active, required); -+ if (parent == null) { -+ throw new MissingControlException(definition.parent); ++ required.addAll(this.definitionPaths(name)); ++ } ++ return templateCopy(this.templates.get(name)); ++ } ++ ++ private static Map inheritDefinitions(final Map definitions) { ++ final Map resolved = new LinkedHashMap<>(); ++ for (final var entry : definitions.entrySet()) { ++ final JsonObject value = entry.getValue().value.deepCopy(); ++ final Set visited = new HashSet<>(); ++ visited.add(entry.getKey()); ++ String parent = entry.getValue().parent; ++ while (definitions.containsKey(parent) && visited.add(parent)) { ++ final Definition ancestor = definitions.get(parent); ++ final JsonObject inherited = resolved.getOrDefault(parent, ancestor.value); ++ for (final var member : inherited.entrySet()) { ++ if (!member.getValue().isJsonNull() && !value.has(member.getKey())) { ++ value.add(member.getKey(), member.getValue().deepCopy()); ++ } ++ } ++ // Native finalization reuses earlier completed declarations, including their nulls. ++ if (resolved.containsKey(parent)) { ++ break; + } -+ result = parent.deepCopy(); ++ parent = ancestor.parent; + } -+ merge(result, definition.value); -+ applyModifications(result); -+ return result; -+ } finally { -+ active.remove(name); ++ resolved.put(entry.getKey(), value); ++ } ++ return Map.copyOf(resolved); ++ } ++ ++ private static JsonObject templateCopy(final JsonObject definition) { ++ if (definition == null) { ++ return null; + } ++ final JsonObject result = definition.deepCopy(); ++ applyModifications(result); ++ return result; + } + + private static void merge(final JsonObject target, final JsonObject source) { @@ -4310,7 +4711,7 @@ index 000000000..19c365e38 + final String declaredBase = parts.length == 2 ? parts[1] : name; + final String baseName = parts.length == 2 && !declaredBase.contains(".") + ? UiExpression.text(inherited.get("$__namespace")) + "." + declaredBase : declaredBase; -+ final JsonObject base = JsonUiProgram.this.template(baseName, new HashSet<>(), this.requiredDefinitions); ++ final JsonObject base = JsonUiProgram.this.template(baseName, this.requiredDefinitions); + if (base == null && parts.length == 2) { + throw new MissingControlException(baseName); + } @@ -4332,7 +4733,7 @@ index 000000000..19c365e38 + if (definition.parent.isEmpty()) { + merge(control, definition.value); + } else { -+ control = JsonUiProgram.this.template(definition.parent, new HashSet<>(), this.requiredDefinitions); ++ control = JsonUiProgram.this.template(definition.parent, this.requiredDefinitions); + merge(control, overrides); + merge(control, definition.value); + templatePaths = JsonUiProgram.this.definitionPaths(definition.parent); @@ -4583,7 +4984,7 @@ index 000000000..19c365e38 + if (tracks.size() >= 256) { + throw new IllegalArgumentException("UI animation graph exceeds track limit"); + } -+ final JsonObject definition = JsonUiProgram.this.template(next, new HashSet<>(), this.requiredDefinitions); ++ final JsonObject definition = JsonUiProgram.this.template(next, this.requiredDefinitions); + if (definition == null) { + throw new MissingControlException(next); + } @@ -4902,6 +5303,201 @@ index 000000000..19c365e38 + private record ManualGrid(String collection, int columns) { } + +} +diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/NativeSearchNormalization.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/NativeSearchNormalization.java +new file mode 100644 +index 000000000..67ec101a4 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/NativeSearchNormalization.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 ++ * ++ * Normalization adapted from utf8proc 1.1.5, Copyright (c) 2009 ++ * Public Software Group e. V., Berlin, Germany, under the MIT license. ++ * The algorithm and Unicode data representation have been modified for Java. ++ * See META-INF/licenses/utf8proc.txt for both the software and Unicode notices. ++ */ ++package net.raphimc.viabedrock.api.resourcepack.ui; ++ ++import com.viaversion.viaversion.libs.fastutil.ints.IntArrayList; ++import java.io.ByteArrayOutputStream; ++import java.io.DataInputStream; ++import java.io.IOException; ++import java.util.Objects; ++import java.util.zip.GZIPInputStream; ++ ++/** The pinned native search uses Unicode 5.0 compatibility composition and case folding. */ ++public final class NativeSearchNormalization { ++ ++ private static final Property[] PROPERTIES = new Property[0x110000]; ++ private static final int[] COMPOSITIONS; ++ ++ static { ++ try (var input = new DataInputStream(new GZIPInputStream(Objects.requireNonNull( ++ NativeSearchNormalization.class.getResourceAsStream("/assets/viabedrock/data/search_unicode_5_0.bin.gz"))))) { ++ final int count = input.readInt(); ++ for (int index = 0; index < count; index++) { ++ final int codePoint = input.readInt(); ++ final int combining = input.readInt(); ++ final int first = input.readInt(); ++ final int second = input.readInt(); ++ final boolean excluded = input.readInt() != 0; ++ PROPERTIES[codePoint] = new Property(combining, first, second, excluded, readSequence(input), readSequence(input)); ++ } ++ COMPOSITIONS = readSequence(input); ++ if (input.read() != -1) { ++ throw new IOException("Unexpected search normalization data"); ++ } ++ } catch (final IOException error) { ++ throw new ExceptionInInitializerError(error); ++ } ++ } ++ ++ private NativeSearchNormalization() { } ++ ++ /** Invalid UTF-8 preserves the complete input; successful normalization ends at the first NUL. */ ++ public static byte[] normalize(final byte[] input) { ++ final var points = new IntArrayList(input.length); ++ for (int index = 0; index < input.length; ) { ++ final int lead = Byte.toUnsignedInt(input[index]); ++ final int length = lead < 0x80 ? 1 : lead >= 0xC0 && lead < 0xE0 ? 2 ++ : lead >= 0xE0 && lead < 0xF0 ? 3 : lead >= 0xF0 && lead < 0xF8 ? 4 : 0; ++ if (length == 0 || length > input.length - index) { ++ return input.clone(); ++ } ++ int codePoint = lead & (length == 1 ? 0x7F : 0x7F >> length); ++ for (int part = 1; part < length; part++) { ++ final int next = Byte.toUnsignedInt(input[index + part]); ++ if ((next & 0xC0) != 0x80) { ++ return input.clone(); ++ } ++ codePoint = (codePoint << 6) | (next & 0x3F); ++ } ++ if (length > 1 && codePoint < (length == 2 ? 0x80 : length == 3 ? 0x800 : 0x10000) ++ || codePoint >= 0x110000 || codePoint >= 0xD800 && codePoint < 0xE000 ++ || codePoint >= 0xFDD0 && codePoint < 0xFDF0 || (codePoint & 0xFFFF) >= 0xFFFE) { ++ return input.clone(); ++ } ++ decompose(codePoint, points); ++ index += length; ++ } ++ for (int index = 1; index < points.size(); index++) { ++ final int combining = combining(points.getInt(index)); ++ if (combining == 0) { ++ continue; ++ } ++ int position = index; ++ while (position > 0 && combining(points.getInt(position - 1)) > combining) { ++ final int previous = points.getInt(position - 1); ++ points.set(position - 1, points.getInt(position)); ++ points.set(position, previous); ++ position--; ++ } ++ } ++ int count = 0; ++ int starter = -1; ++ int maximumCombining = -1; ++ for (int index = 0; index < points.size(); index++) { ++ final int codePoint = points.getInt(index); ++ final int combining = combining(codePoint); ++ if (starter >= 0 && combining > maximumCombining) { ++ final int composed = compose(points.getInt(starter), codePoint); ++ if (composed >= 0) { ++ points.set(starter, composed); ++ continue; ++ } ++ } ++ if (combining > 0) { ++ maximumCombining = Math.max(maximumCombining, combining); ++ } else { ++ starter = count; ++ maximumCombining = -1; ++ } ++ points.set(count++, codePoint); ++ } ++ final var output = new ByteArrayOutputStream(); ++ for (int index = 0; index < count; index++) { ++ final int codePoint = points.getInt(index); ++ if (codePoint == 0) { ++ break; ++ } ++ if (codePoint < 0x80) { ++ output.write(codePoint); ++ } else if (codePoint < 0x800) { ++ output.write(0xC0 | codePoint >> 6); ++ output.write(0x80 | codePoint & 0x3F); ++ } else if (codePoint < 0x10000) { ++ output.write(0xE0 | codePoint >> 12); ++ output.write(0x80 | codePoint >> 6 & 0x3F); ++ output.write(0x80 | codePoint & 0x3F); ++ } else { ++ output.write(0xF0 | codePoint >> 18); ++ output.write(0x80 | codePoint >> 12 & 0x3F); ++ output.write(0x80 | codePoint >> 6 & 0x3F); ++ output.write(0x80 | codePoint & 0x3F); ++ } ++ } ++ return output.toByteArray(); ++ } ++ ++ private static void decompose(final int codePoint, final IntArrayList output) { ++ final int syllable = codePoint - 0xAC00; ++ if (syllable >= 0 && syllable < 11172) { ++ output.add(0x1100 + syllable / 588); ++ output.add(0x1161 + syllable % 588 / 28); ++ final int trailing = syllable % 28; ++ if (trailing != 0) { ++ output.add(0x11A7 + trailing); ++ } ++ return; ++ } ++ final Property property = PROPERTIES[codePoint]; ++ if (property != null) { ++ final int[] sequence = property.fold.length != 0 ? property.fold : property.decomposition; ++ if (sequence.length != 0) { ++ for (final int part : sequence) { ++ decompose(part, output); ++ } ++ return; ++ } ++ } ++ output.add(codePoint); ++ } ++ ++ private static int combining(final int codePoint) { ++ final Property property = PROPERTIES[codePoint]; ++ return property == null ? 0 : property.combining; ++ } ++ ++ private static int compose(final int first, final int second) { ++ if (first >= 0x1100 && first < 0x1113 && second >= 0x1161 && second < 0x1176) { ++ return 0xAC00 + (first - 0x1100) * 588 + (second - 0x1161) * 28; ++ } ++ if (first >= 0xAC00 && first < 0xD7A4 && (first - 0xAC00) % 28 == 0 && second >= 0x11A7 && second < 0x11C3) { ++ return first + second - 0x11A7; ++ } ++ final Property start = PROPERTIES[first]; ++ final Property end = PROPERTIES[second]; ++ if (start == null || end == null || start.first < 0 || end.second < 0) { ++ return -1; ++ } ++ final int composed = COMPOSITIONS[start.first + end.second]; ++ return composed >= 0 && (PROPERTIES[composed] == null || !PROPERTIES[composed].excluded) ? composed : -1; ++ } ++ ++ private static int[] readSequence(final DataInputStream input) throws IOException { ++ final int[] sequence = new int[input.readInt()]; ++ for (int index = 0; index < sequence.length; index++) { ++ sequence[index] = input.readInt(); ++ } ++ return sequence; ++ } ++ ++ private record Property(int combining, int first, int second, boolean excluded, int[] decomposition, int[] fold) { } ++ ++} diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/NpcDialogueUi.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/NpcDialogueUi.java new file mode 100644 index 000000000..af24ba9bc @@ -5887,12 +6483,12 @@ index 000000000..c5ebc59ab + } + +} -diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/ServerFormUi.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/ServerFormUi.java +diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/RecipeCollection.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/RecipeCollection.java new file mode 100644 -index 000000000..d2ec92159 +index 000000000..bd1aee15d --- /dev/null -+++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/ServerFormUi.java -@@ -0,0 +1,253 @@ ++++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/RecipeCollection.java +@@ -0,0 +1,239 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -5900,21 +6496,266 @@ index 000000000..d2ec92159 + */ +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.JsonNull; -+import com.viaversion.viaversion.libs.gson.JsonObject; -+import com.viaversion.viaversion.libs.gson.JsonParseException; -+import com.viaversion.viaversion.libs.gson.JsonPrimitive; -+import java.nio.charset.StandardCharsets; +import java.util.ArrayList; -+import java.util.Collections; -+import java.util.LinkedHashMap; +import java.util.List; -+import java.util.Map; -+import java.util.Set; -+import java.util.function.UnaryOperator; -+import net.raphimc.viabedrock.protocol.storage.ResourcePackStorage; ++import java.util.Objects; ++import java.util.function.ToIntFunction; ++ ++/** Native recipe buckets, contiguous groups and retained header expansion. */ ++public final class RecipeCollection { ++ ++ private final Source source; ++ private List> groupedRows = List.of(); ++ private Snapshot snapshot = new Snapshot<>(List.of(), 0, List.of(), List.of()); ++ private boolean grouping; ++ ++ public RecipeCollection(final Source source) { ++ this.source = Objects.requireNonNull(source); ++ } ++ ++ private Partition partition(final List entries, final boolean grouping, final boolean filtering) { ++ final List> buckets = List.of(new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), new ArrayList<>()); ++ final List representatives = new ArrayList<>(); ++ final List run = new ArrayList<>(); ++ G pending = null; ++ int runState = 3; ++ for (final T entry : entries) { ++ if (!this.source.valid(entry)) { ++ if (grouping) { ++ buckets.get(3).add(entry); ++ } ++ continue; ++ } ++ final int state = grouping && !filtering ? 1 : checkedState(this.source.evaluate(entry)); ++ final G group = grouping ? this.source.group(entry) : null; ++ if (group != null) { ++ if (group == pending) { ++ if (state == 0 || state == 1 && runState != 0) { ++ runState = state; ++ } ++ } else { ++ finishRun(buckets, representatives, run, runState); ++ runState = state; ++ pending = group; ++ } ++ run.add(entry); ++ } else { ++ finishRun(buckets, representatives, run, runState); ++ pending = null; ++ buckets.get(grouping || filtering || state == 0 || state == 3 ? state : 1).add(entry); ++ } ++ } ++ finishRun(buckets, representatives, run, runState); ++ final int eligible = buckets.get(0).size() + buckets.get(1).size() + buckets.get(2).size(); ++ final List ordered = buckets.stream().flatMap(List::stream).toList(); ++ return new Partition<>(ordered, eligible, representatives); ++ } ++ ++ public Snapshot refresh(final List entries, final boolean grouping, final boolean filtering, ++ final boolean resetExpansion) { ++ final Partition partition = this.partition(entries, grouping, filtering); ++ this.grouping = grouping; ++ final List> rows; ++ if (grouping) { ++ final List expanded = new ArrayList<>(); ++ if (!resetExpansion) { ++ for (final var row : this.groupedRows) { ++ if (row instanceof Header header && header.expanded) { ++ expanded.add(header.group); ++ } ++ } ++ } ++ this.groupedRows = this.grouped(partition.ordered, expanded); ++ rows = project(this.groupedRows); ++ } else { ++ rows = partition.ordered.subList(0, partition.eligibleCount).stream().>map(Item::new).toList(); ++ } ++ this.snapshot = new Snapshot<>(partition.ordered, partition.eligibleCount, rows, partition.representatives); ++ return this.snapshot; ++ } ++ ++ private static int checkedState(final int state) { ++ if (state < 0 || state > 3) { ++ throw new IllegalArgumentException("Invalid native recipe evaluator state"); ++ } ++ return state; ++ } ++ ++ private static void finishRun(final List> buckets, final List representatives, ++ final List run, final int state) { ++ if (run.isEmpty()) { ++ return; ++ } ++ if (state < 2) { ++ representatives.add(run.getFirst()); ++ } ++ buckets.get(state).addAll(run); ++ run.clear(); ++ } ++ ++ private List> grouped(final List entries, final List expanded) { ++ final List> rows = new ArrayList<>(); ++ G previous = null; ++ for (final T entry : entries) { ++ if (!this.source.valid(entry)) { ++ continue; ++ } ++ final G group = this.source.group(entry); ++ if (group == null) { ++ rows.add(new Item<>(entry)); ++ previous = null; ++ } else { ++ if (group != previous) { ++ final boolean open = expanded.stream().anyMatch(old -> this.source.sameHeader(old, group)); ++ rows.add(new Header<>(group, open)); ++ } ++ rows.add(new Member<>(entry, group)); ++ previous = group; ++ } ++ } ++ return List.copyOf(rows); ++ } ++ ++ private static List> project(final List> rows) { ++ final List> visible = new ArrayList<>(); ++ boolean collapsed = false; ++ for (final var row : rows) { ++ if (!(row instanceof Member)) { ++ collapsed = row instanceof Header header && !header.expanded; ++ } ++ if (!(row instanceof Member) || !collapsed) { ++ visible.add(row); ++ } ++ } ++ return List.copyOf(visible); ++ } ++ ++ /** Header matching includes the native icon and count, independently of group identity. */ ++ public Toggle toggleHeader(final G selected) { ++ Objects.requireNonNull(selected); ++ if (!this.grouping) { ++ return null; ++ } ++ for (int index = 0; index < this.groupedRows.size(); index++) { ++ if (!(this.groupedRows.get(index) instanceof Header header) ++ || !this.source.sameHeader(header.group, selected)) { ++ continue; ++ } ++ final var replacement = new Header(header.group, !header.expanded); ++ final List> updated = new ArrayList<>(this.groupedRows); ++ updated.set(index, replacement); ++ this.groupedRows = List.copyOf(updated); ++ final List> rows = project(this.groupedRows); ++ this.snapshot = new Snapshot<>(this.snapshot.ordered, this.snapshot.eligibleCount, rows, this.snapshot.representatives); ++ int members = 0; ++ for (int child = index + 1; child < updated.size() && updated.get(child) instanceof Member; child++) { ++ members++; ++ } ++ return new Toggle(rows.indexOf(replacement), members, replacement.expanded); ++ } ++ return null; ++ } ++ ++ public Snapshot snapshot() { ++ return this.snapshot; ++ } ++ ++ public static List visibleOrder(final List entries, final ToIntFunction evaluation, final boolean filtering) { ++ final var collection = new RecipeCollection(new Source<>() { ++ @Override ++ public int evaluate(final T entry) { ++ return evaluation.applyAsInt(entry); ++ } ++ ++ @Override ++ public Void group(final T entry) { ++ return null; ++ } ++ ++ @Override ++ public boolean sameHeader(final Void first, final Void second) { ++ return false; ++ } ++ }); ++ final var partition = collection.partition(entries, false, filtering); ++ return partition.ordered.subList(0, partition.eligibleCount); ++ } ++ ++ public interface Source { ++ int evaluate(T entry); ++ ++ default boolean valid(final T entry) { ++ return true; ++ } ++ ++ /** Return a stable catalog object for an eligible native group, or null for an individual item. */ ++ G group(T entry); ++ ++ boolean sameHeader(G first, G second); ++ } ++ ++ public record Snapshot(List ordered, int eligibleCount, List> rows, List representatives) { ++ public Snapshot { ++ ordered = List.copyOf(ordered); ++ rows = List.copyOf(rows); ++ representatives = List.copyOf(representatives); ++ if (eligibleCount < 0 || eligibleCount > ordered.size()) { ++ throw new IllegalArgumentException("Invalid recipe prefix"); ++ } ++ } ++ ++ public List eligibleItems() { ++ return this.ordered.subList(0, this.eligibleCount); ++ } ++ } ++ ++ public sealed interface Row permits Item, Header, Member { ++ } ++ ++ public record Item(T entry) implements Row { ++ } ++ ++ public record Header(G group, boolean expanded) implements Row { ++ } ++ ++ public record Member(T entry, G group) implements Row { ++ } ++ ++ public record Toggle(int headerIndex, int memberCount, boolean expanded) { ++ } ++ ++ private record Partition(List ordered, int eligibleCount, List representatives) { ++ } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/ServerFormUi.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/ServerFormUi.java +new file mode 100644 +index 000000000..d2ec92159 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ui/ServerFormUi.java +@@ -0,0 +1,253 @@ ++/* ++ * 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.JsonNull; ++import com.viaversion.viaversion.libs.gson.JsonObject; ++import com.viaversion.viaversion.libs.gson.JsonParseException; ++import com.viaversion.viaversion.libs.gson.JsonPrimitive; ++import java.nio.charset.StandardCharsets; ++import java.util.ArrayList; ++import java.util.Collections; ++import java.util.LinkedHashMap; ++import java.util.List; ++import java.util.Map; ++import java.util.Set; ++import java.util.function.UnaryOperator; ++import net.raphimc.viabedrock.protocol.storage.ResourcePackStorage; +import net.raphimc.viabedrock.api.model.NativeFormMessage; + +/** Form response identities and presentation over the shared JSON UI program. */ @@ -7092,6 +7933,877 @@ index be5da1c9c..6b21d2da5 100644 public ScoreboardUiDefinitions getScoreboardUi() { return this.scoreboardUi; } +diff --git a/src/main/resources/META-INF/licenses/utf8proc.txt b/src/main/resources/META-INF/licenses/utf8proc.txt +new file mode 100644 +index 000000000..b3faf931d +--- /dev/null ++++ b/src/main/resources/META-INF/licenses/utf8proc.txt +@@ -0,0 +1,70 @@ ++ViaBedrock modifies the utf8proc 1.1.5 normalization algorithm for Java and ++repackages its Unicode 5.0 data in search_unicode_5_0.bin.gz. ++SearchUnicodeDataGenerator reproduces the modified data from public sources. ++Source: https://www.public-software-group.org/pub/projects/utf8proc/v1.1.5/utf8proc-v1.1.5.tar.gz ++Source utf8proc_data.c SHA-256: 5e437f8ae4b0ecfd076c57e4a9e43e5a3f20fc10649e95b4c34ee6b2da7346a0 ++ ++ ++Copyright (c) 2009 Public Software Group e. V., Berlin, Germany ++ ++Permission is hereby granted, free of charge, to any person obtaining a ++copy of this software and associated documentation files (the "Software"), ++to deal in the Software without restriction, including without limitation ++the rights to use, copy, modify, merge, publish, distribute, sublicense, ++and/or sell copies of the Software, and to permit persons to whom the ++Software is furnished to do so, subject to the following conditions: ++ ++The above copyright notice and this permission notice shall be included in ++all copies or substantial portions of the Software. ++ ++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR ++IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, ++FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE ++AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER ++LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING ++FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER ++DEALINGS IN THE SOFTWARE. ++ ++ ++This software distribution contains derived data from a modified version of ++the Unicode data files. The following license applies to that data: ++ ++COPYRIGHT AND PERMISSION NOTICE ++ ++Copyright (c) 1991-2007 Unicode, Inc. All rights reserved. Distributed ++under the Terms of Use in http://www.unicode.org/copyright.html. ++ ++Permission is hereby granted, free of charge, to any person obtaining a ++copy of the Unicode data files and any associated documentation (the "Data ++Files") or Unicode software and any associated documentation (the ++"Software") to deal in the Data Files or Software without restriction, ++including without limitation the rights to use, copy, modify, merge, ++publish, distribute, and/or sell copies of the Data Files or Software, and ++to permit persons to whom the Data Files or Software are furnished to do ++so, provided that (a) the above copyright notice(s) and this permission ++notice appear with all copies of the Data Files or Software, (b) both the ++above copyright notice(s) and this permission notice appear in associated ++documentation, and (c) there is clear notice in each modified Data File or ++in the Software as well as in the documentation associated with the Data ++File(s) or Software that the data or software has been modified. ++ ++THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY ++KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF ++MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF ++THIRD PARTY RIGHTS. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS ++INCLUDED IN THIS NOTICE BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR ++CONSEQUENTIAL DAMAGES, OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF ++USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER ++TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR ++PERFORMANCE OF THE DATA FILES OR SOFTWARE. ++ ++Except as contained in this notice, the name of a copyright holder shall ++not be used in advertising or otherwise to promote the sale, use or other ++dealings in these Data Files or Software without prior written ++authorization of the copyright holder. ++ ++ ++Unicode and the Unicode logo are trademarks of Unicode, Inc., and may be ++registered in some jurisdictions. All other trademarks and registered ++trademarks mentioned herein are the property of their respective owners. ++ +diff --git a/src/main/resources/assets/viabedrock/data/search_unicode_5_0.bin.gz b/src/main/resources/assets/viabedrock/data/search_unicode_5_0.bin.gz +new file mode 100644 +index 0000000000000000000000000000000000000000..c2f60757f24f312f8a7b6a019d9e642c0e7bd91d +GIT binary patch +literal 45618 +zcmXt;2UHWw_xLUNkf(rvigXnL6(J8Pq4N}^JfuAfQo|E4K$Hs3p&nO?s@o8JC6at^ZSTW%{rN{R!Pki7)kF?ul +zVOU0%UTk%=6|t@2$lhac0?R1E@%V;1MrMU~gMNIY1}#&*XMY4%Un9ZVB_4$#Yvd2| +zGF*cYZcPCvk3PXSN7l>v3~v+IIE5Y@Hew%veW-CCg>!Ld +z4laXhmQfAcAfdML326ha(@|JbO%si4A2mrWg)8KR1BEEb*q7E8zQVvkLf=BdbcvopB +zE7cEBqLcnr{t?I8@mogW(dXU3`*O>lj_5g6wH;ZPo|)}E5?;=;(>d;DdDgf$IF~o1 +zBkV?I%h7fC+{9O_^p94a;-H4Tp1Lf-*YyS5vR11O_}sEsa`Hc_Zai4mJK|K>8?4=~ +z$}|dZ{nIUrEtm6tvE_CHTdzHn!Yw~Op0OqdRUhkv2NQ79#2FSF%I#fomv32R3@fsB +z;UaP@=d-d$8uzbKW_hSRuT?roe(yb(yWM4|yG0Lhllw$BF>1@$V7hYHmx6DEaX4)P +z<-6c{y(55qDt6gP$AUK-G5Zqi$258Ye9LS=qNncL5RgHwH3?!ZLdyp9^%&Vns(!oj +zb@~Y~>dHaJo89~xdW_e|iKh)oo36n-#R;a+QyZq&=_ovG?n7Gd5-p~Kx3sRtBfo$K +z!-U2lDlJ(U&MRr&R`HB$&`#ttFm>lkja-!1uF_G$B$a@Np*O%(Ms`i!8B*%%9(Q5A +zNFY~cmV$?6&P^~lKPLzVqga`_2}bMuy1*O(NwEUAU1aA-%*eq*b}YZ)7158n2T#zJ9j73d0%UUEqk$ +zjYn}Tbr?V*SF^uLH3eUkF*m+{T0uM9Q0f3>6?3!~879xD(Vrwv2_RXF`|a<(aLmkB +zZ3_q$JL-B{dl3W>KjY_T+&`q7Buu+~5$n1V5+c6+fMJS)h;RS2(B>CzyQgOw`MhN$ +zVXD?W{RIFz8{xZ#U=4?_U3j;lc^it@G=(B;K?Pzs(~^q87CTVEPvXjp5c4HSDzHya +zepB;&;Gb&+Yrdy=h*^*r%uGQ-L{J^xklqs~djF@X6`j +z6ut0m)3#r%TAP2r&t@S^p)tiAT_qT2GSuR$XVNq?S~YXg{F>0^QZQSZMDIPj;`MC +z#RkF*Q+Cf-?uZz!)IfOK6zUKoo4MghjcNq04NQjov2wO;F^WN#F}pH3FjFBTnH5F& +zHqCuZ6ftOObfO5 +zR%}MSH~M*tWq!;`;|l;5jNCPaP8fvT{+czTt`uE>ThATZ8^rHNA)1DCa@Vc6tw5u~ +zCJo)*dwtv$Q~L0|)?1`cL)fj_7XV1bKB*W0sW=h6zTY&`WEZ2dmC4v-if5Tp>k`)k +zTtl6UVxG5r8VbUsZFu(9jZ1BN_J%3WogGb}91rt44ass3Hbf#=^5n>YNFX_KAfKBE +z(~H=@wBbpQS0F9TmV3?(kOawTA$nTTKr|hJ#8PJhZ+*?eLnEA55v*aIVq}UaZrvcH +z3b-~vQqQ^)>tOWj-+!frmIyv6tzw{cDC#6L?9SJ$K~ytu +zwhFyrolFASM?Y^-F4!Gpi{gCIK^efcfgTBarPzs@Jy^hKmO+a00byoA$5&^?G65c5 +zdJOi){!IWuQ<~m5HW4Opa2#K5vll_nLJBL^w{=V)lBhEcrHh4psqeq` +zbj-}_4Zv)@lUQSHg}>zR6bD61??w(p2CPJiU$-gVRm3l=;|+7#jmYapV!h47 +zoW0n`Y&Lo(Z`?IE1E5&XdtC!is?P$?ALWr{tK$1evttiIj7$a|&&a&vxBasVH?96N +z?lC7G3;uGvn?-fT(N1?W1bKz(G5|#Ov4eq-xW@P@ +z%UW%a-%xYkto^rF+4I!w<66HZasNs)T?;Ft`ufog2~o2c1%_z;&4ooAztPD3l(%jSn|A(bi}9#FPLXk-`8qvTo2g +zUZJkWq>FUaP?Xqt9 +zG2EB6k|hM|%Ua$IYeNIw@?>D2leCO@O1cdS33+iCol?5{!6v1v}E)08b4?tH`AP+7NJjZ_TKQ$4HHpyQxd{sq{Fy`o#LB8ppYtl#?m +z#9LO?P>>%Lu~W3(_~kXuu*(F2j-rO1@S^o?8&Wu9vm6cO-hmoU#Y +zOY5%5OOCbL`Xh>$7)zap`)3k02a_jDW}SKlAa4MvlPO&_o^tbzYov=6M!Kr!>4s{X +znyZ4FnPxcKmc}{gRukmDN!8=-6n<@E2B=%la6a(tt|kwx8|(9xrvbid4dL4BN_T_( +z*ix7e;ui964tvo3uwf$iCcv=^D<45g;0yu^{}K10mUn +z3|a~Jv%&6k+WZB(G(;=50Gx%uViOjP?`Y9zva;ZF6K}CG+L;eMq8YULJ-Z+SZ{R?h +zvAdVI_@KOUKBHzXyX}F=;*$=|qA!U(->Z2jHmK&_{)ir(c$$(}iIjerTKQN=`Oc$j +z2^sRfCPF0_cJPb+w4+o0>C#%4znZR8T3s$hcWNOj4T8t}PL?cH+KFiCCLoZNb~?T_ +zUHg8y?L8)jK*E3SVp9(as69)SDxy<<5`M(I&idraIB7-B5Bo%$UvNXqNX9ail<=i-2$Ju@Z$!8sFF=mnr|u%G^!woy~4Ymh_m^e_|oyF_+g{ +zb$rPurE)q+klqDY_)4YAW#19$ddR|bgryr+sS?MhrKX!O6Fe>}GM3fJ5;Nq_r-l>$ +z+des^nV>0Z$S?NROIGh>J^0v^avT-YzN1$97dqW%9GA2>4Tz)WwF{ZM4Z}U@rO!pj +zm-w_!xXE+e(-{DB8hrlvGR^UUXc=>p2}CR9fx_> +zj{nCEtW+5yI^HDUYjI2u5QoWYS2cGNSN!WEIK8UdI9XlhQ3H3{sEpbl0zND_Dc-A3DA*=yPjb46onS- +zWzuoHzhcWz5xNrom6l~>UP`1-iYu|3`%`P( +zC_Q6M_w}fUDtk1ov;!KLXiAGmvK0eAly1!HuN@<>DF~=+qarM +zrtKwLQ{3xAe{NQ=+hK3u>5GfE?~ntreIGu6-=g +zJNbuq_g0iJ!C*M)m!5J9_J?#W14cPLs3OHpRo4Lf?$I#{Yw>S#L+TCVjOeEaPe^g| +zYhDX@mlkf`rZO+} +zJk(fbCl#A#e(K5|=H+~h_^+q6!XhkD0gR2sma=)<$cNc>Avjv$vHrf9Er`0Z7P2oRqL;w_I5v=w?rTCWgj%SZWv(v7J1W> +zqud+xWnU))BiopomWBZP{RV~NK}S-r#1PJd*f|*mg3Ht_ekXRGjWq(B?>=snlFt&$ +z&e}YRx8Le1<W;!Dm2;rxWz-ra+^MBoVhqvYMcY^o9E?mFkYl)efu9XjPxWEph=bHhR +zwpn#8S-d9AVkbw^6mRMD+4Oi2pS*Zn_l}+cb64EqyR-Gy1 +z;d>$fcT3m-t^ee%R5(xSu0LOT119$D+zI~niQ2jCbVPg8hW7I%{=CA*2<8LYzf)Y# +za>+Z35F8$U%&2#wNgy^!{f&*^-ag(ISvxPTYaB(02d$r8z9|L!tX{^(Y8I1STvt>& +zZ}~0W>^D1~SNK2$L=)V+T;?OOEI$==BO-n!LofomaPymlmNTox3qP@O4*yVb;{m>0 +z<%bSqGfZIpxWGD#FSC!R0`HT^meM-t#Eb4XFFT%kN2WCvHOuhp@(-icO$X~gokB%OgxGFoBf%0OO3 +z=G%}Pv-H!0*Ce?oz)8{*n^|auV2=S|ZR3#Wc=%6;%N2D8nqYG$uSO}QthDeS8~hlB +z&4&ZRI>uo|g1q_~6IaMS7H=!#-AUAS%4&zx*8h7tpa;n*2Jj35UNzWnA#Q6H_}fy7seo6&Gj%8WuMNgd +zTsW{N6$2>X_ffa?XhrBW{UKlOS0o!q@@nfxI#HwpYDCv54WygWqXd8$^vm99@htFn=o>DkLM3mDCk0rtyfs0D9qA1=eMZf+;@n*3lck_^Td;C_QkfKZm-kUnIHeY%&C +z4eX22)D?OSxW4#xjuIB|Dk5|#Whv!Jh*|gyX~UGg7CvK+^I91|QUZRjTwg0??+-^C +zEjP5-fxi4yN!Pb~$A7YSTR7lV#G_E>B>kaHo{oIf`o0pRIu;X+5py?(Yr<0(Z*R)7xhn8=tL!PDi*e{&TtE^Fq&HhKc{ZZUjcTnEPqo +z-`T}t$?(DSc=PK%=$FQz2+!bS5$^PFZ}@0E%Nd^qrV)e6BchE`4fdi<56%I@E3O2W +zT=zfiYO(b2!Sp!xbzj++#wzHEpUoQ6h+oP{(gSC9Qv)8*)q@c=KneM6b3Z5$F752Bp@ +ziPbpJ1sCH#&C3PG`#4L&TGc#t8>c3@ +zH_2DcUF(oukqC@#?7R+)2z^)V9{}~*f?2q9IjYN +zP^x5$k^|w#*cC4bfGhRv$H&B==dns2{woU43|?(q3(=AnP|Pl|<4bQs5Amf-4-cMH +z4AIXKyQ?AMeo`^S$X!5Dvf_G&hqc$KXGvU$1yL{m_WLgeh+F>6HlFNK+bb@;o+mJk{OI2P~9|w>~R(9xsw6hY$(i83!6Rn^J^4 +zpZ;Q%=-oZ~b}OPMP3tudkK{{-S&MgkM5*Vx4Yz$3xy#a;;Y){E33q(7QIB&AR#6G~ +zlHIv-XZ*a*jirgkJg1dPlUytCWkMnI1z-5%V|v%(6gGWzZ7ih8pOdK8mTsc#sU)|L +zxq;z%kna`gUG!>lxAv8A;{KzqOLZ@5$lcnMljP5<@1nhj$@izt8%|UJ|T~-!@*CrHwE1I!EyK&V1Y>R5EaaS19HnwDXXSi+p4Lag7 +zIr`qOQ?Ki%L;W9PqXcfWW&V?pJ1{oXv_!O1!)u9WU9B>tNK&fCwJW`(uEW=RE&VHna|?^Em9HNA~@qFKH!bK +z?(uNBmls;tRaQa+y)D!h`VjM+wIRt59V3qqdBJS|3+!RIl8;WKVfFu=iu5q_e~gA+ +zwvNmtMU~5o5k|~|>BwotO)J_=+LWpEVMcA?RgC`kZer +zTUVnO{+!yE?N~i{^=B#aw%YN<;@pU{$x;`{k;_-ghl>9Spuv1|I+3VKYRH8s`_#3e +z9Z{hu`_$|+LA5K>$fBq2#?3AHvrpa_CKN2}wl_}iG~*AD5yxg($Z>|n(=--qA>a*8 +z-ZX7DaZgZsQ?~zLoKZx>?io}Y>uIrUc{PxAy#^^On##nJT9Q+N*lw5CyMni7e@$>b +zI6S?T$}Xm6beU{`VTEgD*iz(-RzaAwo^I;98I+@cOe@}HA05@v$2whYwP7{(q))p( +zyk(^$94Z&60SM4&Qu3IptmxX7mD&8;(@bZbD58avnYsbv4&PpP +z5bqF)soa-S;Y_I5+f=`P%?W1`!1AP)e4I>LJMykuu~spj2mh89IpFERP7r;o +z&$bs`XYlP5@a-(}nQEYN;2rPANaN^fJqfZbk9^@HfAB0(?aOhg^kO}2MZ1W=@3{gu +z)Dmu*(moXlWD;RsMsD9Vz>Z4)-F2OHLl<1Oc6E0{Plzbxjku#1FAM#`rt0V{ppCWf +zp}l7k)T+6p>%sWfCBcFneU32;0jXnH8TpVwVj8tSHnP4IZ-?q9Bup`|WY6#aEWg^{ +zNUixtXMaDC$*k)WcCZI5zRgn>qcU)O;SY{sb>qtJx|N7h!Q?C1XKwn-rT67Y$nUE! +zwdyFQ$*ZqC7q|hs*H_+Qme1A%0eiLclC^h5@dhGFAW)AsPakV@RtKs{- +zr1W+SVz2?8I>1o!Ic0UjLggKwYxo{nG_rd>Nk2CUBV;prK3n_ie3E`%QjWHM)p=5v +zl}v8m@oeqk`QpM?^E9lMR+^H=5LCD~U-x!=z{-Y+>`>RB404TM!mwvPNxvY;T_*Pt +zDB$tW`6T_1Nlx1ORX9?YicD_bz3j$}PB9AIYI<@$Nxv{@N?ZEp-@sU>1f@;1Vl%gA +z{6Jl9v7Df$mBz0*gb3(`>BV8IIO8U{qb9)fGVB>XTF*Uqv8elEGjDy?C;4bSXm_H* +zhJS()x_xHc^F}gTlGX!o7i)STHgn<04aeCZfk3SbIeo3!SLvo7KVI`-SQ{TJIC@6PZ0y!+zpp!v>CJzvr6S}Za{XHW#mE3g$DUIysOJyk=5{YO +zf^RC_QROgh)~V0DSDnzVKA+|*R-YAviXG^Ei9WjPPL?jL6K%eQsS7eM0c}ma;H({Z +z0*l{i33+d)z;Y^E@u;oTKc;LRo*s>o0>0ccyt;{~W!<3z0}Fo{Zf<+?89Q?FXBB+` +zYOb5kD9h1E870kQ<27%J8M>-=i($_BJ(R{2(eZA9_1Api*Ls_64a9!2tsh92ow(9C +z_+Ngs;xo$j+HDB?BrI{^D(-sYSPAuZl&;V&zsLCL +z5fjr^dTENH>*L~uw5ZRk(9U)johH$}kQRY4@O&M`jxbDvsz&dX#u@mb&V^idYEZ48 +zSwyJ~`=FyZ!5v91&{6E5a*|fwcJcVnnuXl0=J<3ENvF>pdh{({!>-(+#ayF98|4XO +zcg7}^C)w#`HDCh30bEf``5cxOwv$Y}iXWT6J`AeHyY`Bo1yj~%inSfU6_M!+9++0u +zpOT@!D>MFGS-?Z-!zO&9_(uHJKKxx>v;W`X+3upwVX7_=wcMYW^RX^*C>2?2x0?+n +zJr7(^SL!X7e8LQ}+hMQTWi8!|?z*9rD&-XL^w}gTX=N%(;*L-{>BF&NQ+B7wS^X +z@O7c}#zXdzv!hZ=t@_UDLHbTv>{rFDrcuvZ)bn>9Q-bA0>xfSdu^ymab@!5841#fp +zBCEohy0gli^l^tcc)Eq*>*CMQw1fKnLa>G%zby7QPs=!+COXQt)dBmaf*Z@1Bdb*- +zRt9KEeuq@orZTLl$c)_BdWat|@=jS!v4X`FL8M~9&nlFeX0nXg*r)nQr$ubbEmC=B +zT|-ZeW{WU_#?A)z>8Ur$1E`DoL{n3h{4htBQP9-E8k&Vt3seyJsljEs1{%V6vT +zb5HgV*P*VAH^@(7}g5n5+ScL%K*utVUg-Bn~(`Z#>xjBg*nJ$XA9TUl8;7ht#&`E?@uA +z{@4kBOc)hH9@n{elF +zBs=DL3o}2&BqV!ffKxT{mCHcK&-R64DIp!(P-?)sXYStv3oW54nqo%WqFhkct6Vr9>Q%>ABmL_PTGCRb!etQeWLU%kt@aoBFU1%`nv$cN#54?mkn3AdQ#D+BnTa^TtkUSzQ2 +zTa}#HzV4XRlFQls+svw!0ou>S!=9|jZ*rLbo)n8&72H%!ip4C!X|W<3p1YiW(oK?h +z8~{tV5DcF%i>%096Wp{$l1e@b#e_TWoF3dYB@E?$IPpRq=u`2Mbdf$>+^WH4kc#2} +zkcyGk{x+BTW4O}RAJP@m&z0^Rvf?F-w2XK*{T%*TAJKtyG2p+YQ-hrIoF;w{`EZAV +zJjhG-(d2)wOOPaBpOfVN?J&w(NQ#}{gna<64bTAhg=b?Y^jMF7_m&+s +zcD^yscp +zex!@^f0mN~weipQtLu5izpGoyc!FD(;ROA5z|iCwEnGzd&yXwh)#VFSj$^#AhQrp%J_k5T!?3kW@Jk5a7tU80_jBEge9S~W7E2Y{^k4}g1MMVfL#-V!ugkdy>t^ +ziuz+GT3J#7qggX0UNV&YHr%``rwrJ~_71Ih9m_>(+D#cP3U1PpCUckk!MIW?oPOv# +zr6%4x0EA6$u>+0mP{eSh)V$rpVc7O+!;|nMpK6Npm3X( +zc?7FE=2>Z2`hGQK4;V0-6(r2M_R}&|Gb>jvw6drnu9r0lzTd)LJzLhX_!^;1lp_EDXZyS@WzNl +zEsFzwp&><2*gEM_^T+R~pxX0O?W@yjaQ$WgnvgxFDWxsFv5pIbK}>A!r;HV4X%&kY +zq^&D$OmiV7_ulN+P^dc~`Rx4fc?{>6(dKRa@&Qcm^P#YHJG^dt6-B#)H_c9H;)IzI +z&1c`S--MuZ6AP-IK?{!9AGI`c7OMNoxQ{50p^MMZJNH49z+XH?tKPJKJX%~F%jG*mGFHI(&*MD%qjQLoK +zu!4*V3k7NCX%3=friK2mPa5Re0>r7hTZq+g52HBp~`fQn`wc2{wISJr{z? +zUP%(d4`6x=qH#lX%|Qv7mUDsBXwezOx{@AhxYB4JYq|xjt_bu?;ubz(<%}1h#fRoS +z45_+eGS(|0j`$*Hx%}S#*h#%RArJXOvO9;#`7vJ^kgMdX)dpeWWSu2q)%|9!i*ZDG +z-M&DsK@DvWa!OyrJAImAAZt)_DkJ3N+_8q=`fZ8tm|9#Ba3y@Z+9ezq7>|425pZUE2GD +z3(Sr_{^90C6hPTJRMla&FZLoLWiRsYp!|S-9DL=tw^LUF9zjejSBL?=6aMHeBBvYR +ziv7kzqvg!>W%vJfJkMaLciS&4_Vw^a3T9o{qYp*=&AOT +zgJKs5b#TP$0}tGl4d*FhqU)U%%VUJP7=+iu->Mv9;*OB_kEjH~`h`6MrjvOb{K@nb#9%$Lf +z@)=06(9;dpMIXwE$+6ut +zw6BR++ooMnT7DwdyvgLbJT#@w)iy*AWvea@RJjtP7=6R~W9c69C3XLOAwwl)J4i}x$C$4Db +z|4cJEd7iD|h18lQ;& +z(v;7}e4pJ~jhGjuBDbWD@1}g?6eb9pFfzQUZNx-Fvse$9W#{yF;FpMtTz_T08C0#O +z;7^0Ua##K~j~PpJRa!jvhCEqfx_0qRxjm9%PfT=GVtNuM>!J|9!kSNYF8=+m_^ruy +z*w`402mK)?U_=pRR|gS0Uxa|V_GO!_EeqH_pJl~|);u`8Uqke-3fVFe{Cu>mj1o;h +zp>MsevZ*v>POE9C&Vu~3y|C%-xuI2$AVI$v38|m2OwG!B1-}Kk7f24RdW0;j4Aeak +z^bYMJBEAK=f0T3(Q($`TzJbsuZTk0n+-35I`n9z(m00Lh_uXSdWDj@s7<&;v>z=BJ +zb3-d{?wS?4C%x_wg_sw}Q^O!gWxv~7ni60~HeEBnsvqg@h;P_PA}Qvi*GIg96i5zD +zdFaRRH-&!@8MNvd33Pir-Xg+Tp5pR5uKP1nFL_a7X-s-}d0NPvlbIyK~X?PdM= +z0h+Sj!+9}`l@{E4Hv__y9GdXZ$5uFFiHT#1i{3f?2d0AFejg2Z#ya#6(^{=hF@JW3 +z_?L5hOd#n{%PoTB@MIsicICdHx9<|p`Mvf~SD(J+-g%c#^sxMt{uS3kv$j)NtL`2` +zih=OLX?S$xKZdmZ$WXaex@LZyh}^u>R^*n-<>i;8B{{)*0%Tpt3f;06XBA{I} +zoU_x<_{0jQHz9TE_l2ioCmQv;c=Imne6Wi2+=v!(?pH*}AXWXZp(Y*fh +zv~u7iVlA`JP$QExxR$Xl5@g%@`rj@!*)MG(dBRC?5x^Et>9fZHf+5FHU5(h?t;`_X +zRIJ8#0sV)T`9){O$ZJnDFMlk1AT>6)CUf_pm{3W?UpkZGC1F3r_HR|y2gm5Wtj`v1 +zWQ5()X?j*-n4i-nUup;je#4xLeZ6o6XF}vjBcZIoNV;1C!*= +z-f#GUSm1wFg8!7$C8X&4LHpaw3~HSr7?>n=_I}$4ViR=2e#UzD&u0+dFEI)oR94yL +zMjxls +z9;+bgQ0#LLQ@=$_hxm2m%GwEHV<0d)Pu$r(bH>ZsHL$E=0mb)e;~O4t73 +z)sSjtVbDnfUHMA_9)4!sUSPfd1))fEyh3a|Ky-X0$$kI-SW{IS_%~)kC2=^{;#$!g +z*W54vi>hY->oajqF@Ed1H>-F$efos0$JgJiwz$NlrpVGsjzib~^{DE*cs{esyf?E+nq +zd{GkULdY-T4_wMUhnU(bHc&bPVqOOM9u(A}qkoJScA~Os^8CNgKKHrNV`w{L(t#Sy +zvy|rldH3wP9p@Kmz`z%C4l_Xck5W3W7l&x8mHW{-=J2ju+X>vA8}KpYp*0H2A2Y72 +z9IkRG=&!>bUbnewJ?8NF;C4_o{-5Qn~_) +z_*82hR82}QQ~F^%=5!5upV7|;D%d-lYV!k~CHs+)!5rNVkM4_Bs&};zcA|ID)ayw} +zb@EPhle(t{=Je~-bsE86$5bX_>*8R^hNO<{aB~kgRC@UNR(X}t;)kk;etHEW!G|Ca +z@GmmKhtSbq{!d&i*o50_Zk9%IR=$hIEiu+cnhYepv?FHjo2^2&KX +zpRWyNT+R&wgv9#{XM{K}f@rCuMFZMOR|yDabf2U`T+OsZiA}`qT7oj>11t1XB6xaN +z$2e@QlyC)Ig8m>GwlgD!|B1In=*AQYkU4l;s4m-30Q|D8;x%3MVHORY_T_+K|h6D*s?b#$_bqhrW|eue$0G^+_XMYO68|*qP)H?DRA8qLrNu +zO6^^lD-9T2ne$!mS(#hUX?QmGMV@{u#N*qqCj2{OLE(+T`RcOr0<~r3BkZ@2;YTkF +z@qfepvs11x`S%2P?hNM5x?W&n_y=-QqH*mh0~=H6e3#XI&#oaZX;eG!JKljEe2;i! +z-J%;Gsibww-TGeMi3<)nx9h9iv)}7XjMN{WO0TN&6{{ysuX!`ucRb4Y4}bOVe;)Ba +zF_k{Kb@;2F`EL=2&(~&cXh7?~n12b-uQbw4A<4Wq@j7v4Yqd7xolZTs+%ty%QrZX| +z9DWWwO4iI+CqG?n30)7}xa1b|8lW`wf*Q6Sy75n?K@$HVR={Aj_w?LiMEMVigKoX< +z(&b9dv6j~FmI>4_q5W4DZZV?)*I9mFcUUkq=~O+D(Y|%!hTL9debE<NAksHkT9I+Z`~s3grDLYd2K2U%&7) +zEue1i6wZS4h#{rjoICmf_#C?G2u|Rham#iJ*|R)?o#dg11S>O^-j=5#1-k2#xsk`n +zl00VR&GFv4WUHeW`cWeA4q$(ZNLyS;D{b+5B}(}Ej>^-tSaF7UY`U}fp5oSv{-bK&It?$OlMwq@bnPBLOT~)1VDkjfgYd9WfK=IQ;SL{QYNFg-DiT*Xpz*0S-l@D=yLfx}VPT)9-{F +z(FM3WK6BOLw?$2+{Vb|5^Xp$5Ar|a-)cvB^Ir3F1HPTC{Ju>~W?ApBx0ffW^ +z*14W3<%JJTM;56vjPcl~8#_snUWVf%Da&a<~tAaM=QnUF;QFl|dOUpT~A_+h1X7v^-vv=4g(ho;qvx(0&OeOF2 +zO-@FdLp)jLYegjJ~0v^+R`mT&*h_k +zPmBn77V)slI11OJr?>Dy8nlQJW7xtf72im(nE!Lw`~r#V>q8$3eNpy`+|82>|qs> +z(!b3emT(|WKMrnf3pO3o0(h1GLr=$#vsMzWldpk25Frjgt;K^$2?w(N2ugqq#G6}J +zbl-@{3$M=%MB3s5FlF-6Kl!$~_=(OJ1yuwR7!5@QYCM@>m)4k_1Q!Ow=%<%A{sdSvFO;gh~abxS1236QTWx2)wE_YLi{ +zT;E`o_bzwak^Iln*A=3g&(qiMgl)+qYye;K0F7f3lsiqW&q~hMiZ3(&EO#7qebXk@ +zJs)F7^8a{g?aqGlGl)!;>+2#2W5M;lxKZ-%PQJ8jho(}2_%i*^az|iP=Ed^ujwPLE +zd)>(eQGa)6nhvF`963SMz60ndsCcl?80UU0V^tZYuQVD(N&)-%e%H~*_Ej{c|zjxE7JE%BPX?3JkXp4toirqWu%A#TX5 +zHlA)v#SF|yU#@wI&LPMzL_=AGEYcv=Z0bFs?iR@GScE&m@S`1Y8llE|WjYfGRHYcJ +zg>}wEmJ0tO3Pvne8h&&FP9xe_uUuyWfwVQ_rg!n!9g4A9Oy`VtiLm#CU<9${@G?6q +z(cE_0s9j>XT8c)CGyLcYJh%upAMoGv_$|*K%0|kmol7d%`Et4Ogxd9Ztf8CSmni>6 +z3UB*T8Fs#$PE4q!#baS0xo=R->Fs@1z2#ha8!;htGaid`l^gDtqOqc&=1KvLl*R2! +zn3nl+`2XE>ncd@~vBIErd^A=lbVgu$o6L$9ZKMorU-E06FP9@W+&dNlZ8ZF7p*Foe +z%d#a^&6m^15;U&GV_{CSF=CCBn(a%2xcTz7v4qDK@mQRrT(W`$jpYqBCj{*E<6pV< +zY}`X%DodR&(w&lz%Ukiibu0qfWq7va-H`~jidkFjw=c;+R +z;P{oNg1ky~jRwe`3jPPWY`#Iqm{}PQAC<_q*F0Fn|Iq(DAE9l`%#EL2K0W9z$+ZA` +zxejmG=ovFp<151J72{?XPY)iKLCOWLAJ_}mvq9H2 +zX8sjl5mv7nXS9BLFy{g{QC&ms@m>M`hkn95O2?Qf9&bcDJ!pA>>kjt9b!_D68Z%GE +z^QI-T<^DPl$N$)y4FvAcjTd7&XU1{Dh*`l1_gzB`L*O)Gko{wCDiF9sGhT#r&NLPZ +zBk}|z+{w6YfoTMS{bP?92;9M#2Om{b-D;y%NiJH}x^Br9KS}Gfuh?m0DVURzAlJbk +z66;(0T0njrwGmk+Mf~X +z%(mYprWonY3lqA$QJdf-Fy+A7s9zzs%&{Hz+ESk#-ff}E@cmh_eOQEYPN}J@hWA0c +zUtvsR;`U)Vro~|abm4;6!SY6vld^R%f=qj_EeRJW4-w{g9VqPG?;hH-+_-mEDe7BD +z7_1_1=^CMqVz&8)?Bg;5dtIczn~>Y&?7FPER>I&@nI?LBreYRy}E3UIB^?mr6h-8JezYQ(fUyyGy& +z^|HF!Q3ii8_Utv+p<%&G@M?#q;TUQRChEL+ilZ)2-c08@@bUg1FYg)F)Vi*X>O^I_ +zoPdIgLLe##*g%lpB7y=cprC>XW+Es}N~i)MQBeWuMUfgr1W}6go~S@V5kd`8g9#yE +zfEZdT$+f)A>#rjwnJ{h(uY(V$}5MG0>(U@4yRRw~y9F +za|U>?XT0|*hfy*%uftO95RFDiMHFvEdap7W*F&_;nN3k6+Q4k>VC?$6=K2Aj1?I3S +z^w0|#%#v~#*?ycGJM7jnh?OW*B46K#JgRlDqKYq9($(-~T0&6={*-X|hqBf|`^iGx +zlCB0)^Yb+L&lhq+Y^sAnxHNXFb0skS6S;|MI}?Jr0YS#I^NO>>s>0ignK;t_N*yo>Sw2j}YwuZxf`DKByz&dhw-)S@wG|(KA`#Z%HI+am@8VJ#GqO?0dcE +zsV2l;;%=u=>b!aQ-Os^Rt%$vX-A>*<>k5%oZIE(K9%3(e5Bx33B5CpGMCdh8ht0Nn +zwp<}(|0GsT1$~5d;<-X=WgGn?UE(?BcSIJ+0(!C`LG23sf`2ygT=<>OOWb&p&SOlu +z?x4gjUwY_XB8-*0v1uCa@>#2*9yR}jUc=ur@H>&6Lox%yGkPl|eSSKJ9UrfRqK#MYXG8&e +zGd%$lJI(YRp|4Ao(WeNI!}`Q#NU`n!YkJeCBNol{%|;#`pKjJd584E^hE8y!J;LdA +z{1XGK)2&xw1r+DjMv@t>d3>%S=2>WPJ;@ByJdVYfVVXI=cf=w>Yj^z4iA992NAFYS +zOpTyybLKhyDOTNbIOQYv@t_M{>s>lJEG2K&J&?f#IaB;^S|XyIS*=x8 +z-$Z35{zl#5uK(!tPZXbOc!lnD7T1q^$cYpw#3tRB+Nkgz28;Y)i6xI410Mj4zJ%Np +z`<7Sw{rJR_CCvMYD91-4Zc8_S4+;YRs5UA4w6*HO2jDXVy@zA@H)giTWbE72cHxQ! +zM_f7K|FEMZRv;V(;kWd;scG{izSL;36JQH`XSeERFuy +zIPrOd*16&RW(iDKGOTL;T@q}sGbfk-x`LX4K&sSET&6 +zBs2rpa}yuTReQYD)l^Vr^)0XTL>6pO?(J%NAapDDtZQ}wQE5>m!7tvG`=DEpBkCn3 +zo?L4VoO#up2evYD9{#0e`=4MppQ~d^i%~%GeXu@NLtN}x91Nl~OD1NQcB$ZGblv1G=*@jH@jktOvr<(2 +zX4KWb!~Q|bGrCvI{bz`7+=PJ*KdHXaXQ(m#f$g3P{BybUkKgN8*ef^X|3DtI4l=nu +zk~E>10SZ*&SXD?l +zU%BqhA5oTa!Yr&G+h#v?H}d%B;Ie6eWv(LZFftsg`fmJ=InC +z>%Es#rFD?WNbg?dru_GP6T)$tGJ*VHt6o5?ZxynsX+O-QuZ>g&02O3(-T@WJ&wvU) +z9GZlUtb`M~E&xedei* +zwL=YwtF&D^%bVhW{OioCz>J9unwj)9=X_v8*X?frt{;hjEAld|VAuO`W)d)91~Bxx +z`*Wb|D62%c-zlIv{|3PFnydom>m313a9ythIHI>eMcfJCG<*Ta`=%d2+gd(ccxWet +zhfR|Zo>1+6;53%(a^@eum)Zx+KrsCvJd%)KTPPPEiPnhyZuoQ3iJj$5SkCdsAIb{> +z_foX*R3aHb`C!!+z~?;i9uT>^gk6HuejUzLptH`e>v$fxj_;e=g-N*yfS}(1`ZHCm +zgA86WuPZmn^?Z0QHPv^ShTJpN2h+b$Dl|}grur^NAYy#oi`I!TzV4YuV6%GHF!$hs +ziOj}Ul3D$kA*_yBePBT=G{%?uV5F8{FD1yzn6puc_Li$H-Sv*^8Kn52BPM!<6>tCi +zfeH%$;6+UI3MYQ_AmbY+zj@x_^Mm`5{mwObKf`pod&|{55|qt@enIV~Y$SHVko4N{ +zEIA?efeN_)fvbXi_b$!bTBO&!vt*w=j4OHhgxC8*Z(@iID(?La9Uy6ngiL)pxxnch +z`9^?Ky1mC_A4r;VKqk~IF>XaHq1iJrEwKRc_v!i3T)ceb+#U3bpb~{nY-( +z$lxfSHN7r$7?x)F(>L*f3Vq-KgZn!La$JqOUq*srl7&e+Z|B7cV}O_{qojb!*obVOB{)88kXK|p!QyC`j9u| +zE_doE>2=FR%H45W@@w8j%3ZxWtOCt!r&`bXGxAyX*Qdvt&l_rDyr_GKNRPbhVLcMh +zA9$dzhplSIJ@A;m9+qJid|TFoe?3fxD8=YEER}=8p|9BvlwNjtyXk6G2nv0G(a$T* +zrC;BZRQ8tkK-qjfN7e&%kw;&5|8D +zG+rM6COmdWRyP#fWzx*&u@_duLSOL4c?-mX$ +zrU~%`dAk>EcIy)QJn{y034LZFFTWG|R;57R)a?(4uKuoGvLm~C_?2x`?@h1E?>^6+ +z)S4UmGw`L1l;=miO9`=>-*Vn2mdM`kv&FTDX}g!G#|i0XLV5u}R~q;;@MZW`nfraV +zu99L^7pxDUevd9qk-{GRayYB*wEQ0NftshV82?TgJr~|#P=U`bviSGiKDQ3|{T@B+ +zCPx0_`|gBW2cW-4drW?5IH&8D+j=^0xA;KQQ`i{aM|@RO)Hy|{q}6J3w;Y7O|; +z5z%$|{%@C~N}sxt<4Suq`})tW<`c?gE_s%2%kfYxHJQ`*`A_Kn!wDMoHMo!9a1{_W +z%>9|P8W}S&jZrBBRzBo)QnS;knDS7|LcDO~lV>u> +z;=`VSLwd>X-U*L(WL`4rDTicU>g5n0r!@|T$z1YHZXCW;v%@Tka`uOlU)0yPs`a!> +zbGFoXH}Xy9Y%@L-y#vHe%+s%L9mP;PGB34`kI*;ttqKKURE +zT7;t@ci!hrqLHYI^q?*QsoXRX@46Y9E_DnP1_fe%XA;+mSA7d%r2^}qhJ!c`UB!G* +zcSBq`eDaTazCrg3&BWR>1C)CpZ};>w!kZ7@t4rwf-f5mVIvytXefSNX*!E;7M%S$3 +zgbzqpCBrsJ&;N??Tlv75-98mHu$lap?gBsZr^7N(Px2=d_KWjG?4zg}^xJa|)u(=5 +zDSyi#ZCudHz{X90D7U*SV>d1Zn~9|(kD=X8hFZ3phP^sZkt+tJ#~ni}-czL?=^gP- +z&iVQ%VxO{JrikO24<-Q`r=$x^0yIQ5l^X$ZEqm;cUgk@ifkWQ!9nfY`r6qURyTz)C +zo>L4oJF||z#P4tc--!^1k5yzlXckoP8pYnAYaJ8s%_)bPY= +z_6OlUienxT9xWdr^y~D?^vktzm{{mezGw&W5G(LG#`5R-b3wuk{sbSh4QT?im3lC9 +zv?FEC*`KDykD4K%_{yK5*Fg)E?W0*|#R^|YJ9(#SCtKhCiBU<^TzTwm^&GqVvsQh6 +z<)fJ_X#Y10{}FAiWGvcM7yl_VJK(_>hImx@?w@Txt98Gn%%F?(BP1ex_X1ZLl7y`-*h#~Z1ga(VbNjT8DbG6chIlx=NUbVAh|JPhymPBB6U4S +zBQL^uhkR_g(O}2{W-$Fsj>KSSCm-|9$=j}Q>GbriHu^i0LRvB?Sxs6~XAqX0_ogrh +zY>vB71G=d?3l5~3)POQ=8i}LloeC)*L6;&4qAAbbXnakH(D4p!PPD)c9Au!t2;;Uh +zkJms9c2{^|7Yh7jBk()W+c*DbLy-kZgiGqqG9ziT#0LhnmEvW_cKO9^W(KUPKICbUiPg +z=wn7tp+sTn+6Bzt4Vy({rrVb3{dpGj!%UXz(|)L&nPXA`QtertiM~WnaSd10)9^Vz +z>n71VdEQ`~Uo$Qit +zNimB$`e;GK6OXeRNErB`$61ZMoG8LMIYpV>T@}3#^wF|rGRaj)>Qi*8Gq^w`1wOv$ +z6t9z6Lp(E}m3a=nd7J6U#9#Xop%mI1vqSOceT;?35h^!&Ue1@On9j6+-caY4E^u!# +z|C`H6-I$dv2~5YsU-kYdfPZ?vGCfG0q0Hx8LFDHQ1-4P88dU5*zH_2U7r;MvpH^ouWJ@AJ>B5zcB{vS%_Vst-C8_dGvcFb +zAa{LG<^j0QANre(yRomMEJUsia!FlZ{2@{c54*c3^63{R)CNTE60_I4O~Uh^2ds@{ +zaOA$Xq-a|to*`DaaSk!%nmVWNUIt%F(!C5Gj?vv2d1dBwWPf2y+nPzl1yGU1_zBWi +z<(yW9zTdu#DU(hr=0qxp5AHm_I`Sxb?c4x7c;@ib6-}tz0}F^-OT$!3V&?rZ=g6&2_X#Yv$Ov3emyC2i4H>7IAJZ +ztnfodsSFbBk*4LBw2h3=!XI~c#Ht>h){S;v%@&eHGw3A5y<8pr +zhCMW(beHzTJ0~L9Bdv;j<5bU~L8TW*5($t4W}L!T?wj5PSC4;<8Gq!FZXM{;U{UK6 +zRj4|e>6@c&6MQzRP&K3Uu3IJFq`+K1aP{1G!vydjTTEtP6IL7^`c6yEeR`Ek3BR)7`S&BwXpc1?pZ_Kys0#uDLm=&|9+4 +zWsj(5_N;LV?J%=pjx2E`!R}^~0sefrvUgzF8@(7!8^Rpcq)FL3kbGz<4W_xF8?$B; +z;v65Pc?H0rEBhrz*0Xk`?0;W~xFbJkwv#>&lJI=TGRGRb_V%yS$ds)5}{qr +zEc}>H#V3=jW0CgT#RsiCvv-IOTJNNO{qoa5Vxf5Pwz_ypW5$#y)1fW7 +zf_O=5hO;ZVbVrVDjar0F#o?BrK*Pey<8!(e?su8DznX@BV0~Mhco<~Qs5~gKQ1M(( +zeDKc>+&#m>jzQP#LxCgjV`a^!R?nJ6)*bmludzVc)#Q6Uc@wXq +zYi@mMHq&#IxVPK0;rLQZ-G)h4x%HR&kRw|As+o1{yN(sRj))J0-^CZeUJ+=#n6{eR +z0fvN%JAe1%TtP+a`Vi9Tve8r^Xl@QFT2Eaa98HxlIKEj*>8f +zQeTfQtpPQ-+3n(3@kT*>Fube!6mv;Fe9=+VE!h)YhRoMgnL=(AKdyXK +zw^I|U5i$aQXi~_o;+GK4f!)H@KG#mO4-H0)MHaJnMipA$C5+biohq@*ge|^Gnfp)( +zYLpL|vgqxOFRo1zwe0O)e6atRsOP6e*$8WwhKl%_Ke5fHNhvZf$AS%<^em*6n8hvj +zD|oTNn48A0UpXt-j%twSWp+eas40QMk|qvh4%eUT1idLgs$=ov);+qkw|${p8rj$y +zWzwvir_fTOHyvJu=lCPq4h^O)z@amTtTOwU+r(ffOu+vNw}{0eyrLy?ZcQ1P$OhhHJ@Us=CGxL={_Um>4g +zS+|5))O)~-zBLRm+k?y*Im* +z1`&F^YjVn(DbqdQrM+_MV}ovs3m*QUWLXd?KV^eUN6ea3qtyn#g-LlffzTJV=B)~; +zsod%!-N67oo@NB{uv|tPe*6ZkkeJFHT9zoBNTt0|UsW3P>{@-TU!^qYt_LTqKUc7N +z+2;OXt}Is5K^*1Egbd!+mM1= +zirOPtg$VX~F<>Dbv=#w|I2WDFp`eC2p8;#=REOo}$_dT=1jY59jEXBc!)loD1X3Y_ +zUAqCljAfZGZK`i4*ZKY_<(UIQ3{~tiA6JS-{mda*49)&z*CA~&y!YFmr*SAJf}N+7 +zN}!0?M5*jT1bg2GTp==b%?4epTLMLJ1;?y^WT5_}gKaUqy#~g%7@ku?95DVWQb>4B +zIO3Vgq1byzPAZi^`H0>^;;46ilzFH6`Cxi=CtJyWxc!K0ubN+k=c +zJ87u2EaOmbt3;)eg}{2???@=UCSQ2k@S2`oV0?CA5nj?cwAIp!GkIrI)OLufnUJk% +zX@z=Pf85%O^NxECAexS5dZlI=OD`AdmMjETwxTSoP;Yer!pVBRY{^1kZLdvAmhp)t +z_7SZ}-?5u8+aYQb28d63QEj|9@N1(_eN(fH0~fg#UYxJ&cv4{z-mrJOXE~O2URzMN +zgRJ8qV9Liw(?tifIyc?v=7s7h^y*fmg%xTjcnPmmvQXVVO)Mz!ER5timF^Ef)a{x5z%K6?x-* +zd3kJ*ytp8S@WJy)LiAda4?=Rm0ECF{^*iDd-A13Zh85D1`KSbNa*^q@L9`p1ud^Qq$!Z{SN_7YCcQUVg +zduff{F^pFuxbW_9(MIu%o>JsPO_>!$4f|AKPcoBy{kekG1>Dajrd1&>nO?lEma^Zp +z9EGjVwm=@H+i=l`rk=LptC$jlPQRDyDO(lR`?J_~`^+78MMM8&Y%i=%rrLXxv{JC= +zIQ32Z3sMUw+<1jrBH0$V4in9@Q3_f?%QXzqlGm+L}^BWFaXs`nY+H +ziIuG>^_nxZ(9&x@r-FS4F!$3SsWSSjOPlAs=1&gGm;t`~Ttu3Tz6cyk(kl(ByfDFx{@^Gx1j%(GiYvKWXM}?Bu8gUKd8iriS>+0dTP)o7g +zyJ{-dUh}0_jIN~(_cwmVnioFn6Wc&2YX$jMv$q1H85rA|x|g?NEX8tnl_Jl2&3_$Q +zZ_6rt*4JSJq@HZGgV~x=MYNGtz$}|Kwx(3O_uKEM%IJqy+{hX3*XzMT3!n9|9ZgFt +zz2-{?Wek7~T%5=o?hk|3lM0{pU2ozGySwEA*7=P{C9NRiLRz4frC4s}9BR0~@dj;+ +zHWACp+2mF5HAI}eed_g3BeDn<-YG1>kpzia3!dP30=UM4CvZ!!skh(>;0ZQ$7Cgb_ +zB_a^pU)D8tgdva(imcloDCS0CjK4lq9GwR9d8CUNbf^ebW^ +zaqXiOgRHZ0?W6Ydtfr9!1}&K1a-KxiDKRcMPnxLr+U(sQ>qmU=ZC`J}V_PTq)d6A& +z1z4(O2d9oOs5TR;=SjHgAl}Fk2H7xXYCBH(qA5DOj)o(uI!VKKHhAwuKY +zlhlS;)_|znP-c)X1;K}oFvxG`Q0++-A6Jzbu00U|Hy{`JOQ1XfYeZ0E!F$ly3bo{Y +z^Lh`=-k2!3rHE*mT!u(O&;id$iU>I6c!wpA6-nFc&T!3z`lhfL++g1H5eAuQb3La$ +z$^PAK^m!8QMD1L7TziteXYQ;0v0&&M@|*p!U{Sa+wql4M>Ae-}H_U!YIQN)Xt{V$R +zzB7eMLiowpdSElv>lGF}R-5BQ!+8>}9&QJdgoqQ}B_Zg|^FU+q&DQfIT&SZUz1`j2 +z3tn)ZR9#*B%rCCp-FobwYS_GBp5sxs&^z`}nQ&{&| +z#%Zj@O2_K_^Q3Cr57q}jj5DL#-Ax5q?d~@HhJ^jG_`G+k=4Jb~jb?GnUL1f}iUgHi +zKL8oy_?s(yBJJp+4d!AIi>l%3E2xIK2K4usY8@yE3{drU} +zYw^qW=V`3_^O$7T;*kVojRkxoXvC(@0>1G_0v)ja=q>T8)UsdXUL;k!z;Ohp0d^PA +zzzK8sTZ+aJj|j=WDXhiq`k0|3jIn;C0%I)hKH?Fu +zk;Ha)H9-qW(KsTuoYL=TVY|CpHSH3qI^(7QaF4RW0f8#ejm6)7z1nF=4(G*9p(qu^kgGdPoZq}YPpanL0*VyB;{$h?-6Fr8F-8Jq +zLq9RbNXKMQUf)^w8JpS&1;*Hj=m~35^@myk^=`Y~YG&l114yz+A32^}|&~2ClrD}s1 +zZBfSl13Jm)?tX4`0A0ArJiIr0H*a%5eV%TrqU3_^^Akj>Km^3s4b2tD^>Iduk)P!P +z_-n)~3HRw(iG97FWq$0ykss`na}{6T?Sx7>ENS{~{)~oMz~XV6OxFfQf@oG2)4qIBJ)&9v#u)GFePDL +zm^NwF^(dBY+Man%m!{Bn?Ms+3X_hRH4O1u0x`MG`$4IkeSuESGy$h!szZ%fqh0}{) +zz1ZG`(#`q2!}uWBih2@Den{q{DZ9a(=j1^|s(`sM70s{}MpA=VLe!l@8#&_! +zsqH5E!60KZ(xI)&uw-jFW$_eUi%*sy#Ti9$Nql)E>m|f@@=bumMkJEH5sDJDDj}ky`&<|Zi%&&By50G43KB8;3 +zGXIG;{SOG|m1a51v>P_`=TeduSvL7p-FD}PvD37O8T{7(#k~W`x>ldM4DT0X2ckO-?|-t) +zyZrZ6{6q5@-~ys^dJ-vynGe$pGp&2=G2cUi~wsQ4ct$u5~JHU +zH4^0{VwrbA2J({BG}oOz7NiJaV7=X3S1sMo4YV2+kocVMxE-QG@6-+Xs>1Blty4=! +zD&~ClfU1ag0Y-Y`X~VNfy@gc-PV2C{GUJ`gT#C9>kSz?-o0g_#;X?pLd$480dCXaQ_kX|>GG47o=+>6ngR^KDh0ZY&^ +z(1-5D*TQZ(>!}oZgUX<-TRVbR$!2>ev=RwhCmxcT*_DdV8FRQ)l|FB>hJo5%npR@2 +z^mFDME>>mE8)Trim)V2yfA2zOrel!OthKg>Jc|I^*LviyO+4E)tIU(r%So +z_+s1(5crwaQ1=0R`p +zUe`WmZf92eO~L17_bMeAwvwA!YB@5^$bNIgwS){2>!yVfPs?i2VPNYEP@*_kM}nzap=c(CK+QiKZ*?)TUg7k_6A +zTj_d%vB(QeF4f5aUfoUxf+>^Ar8}=95t1GC39g7v&KIB6G?hLd&}!73sg848p5pUR +zSsX9_RlPr{(R?pcJ2BVbd890^T>9pj2A}1W(u0%QObfyveY$mORrmZSc#41@kqY=B +zmV`fivr&rPJL-FFQnjKc=9Ig#KKleFx%JM}HJStTNritc +zfAIm@Tz9A-GNtzM#M~cUj-P#2AE+<~fX9Hq17x6zSnhqA6lgU{)0mAI-nBE4eV8VwTleYYV0+&$mng7l}M4Mqd`)i;>6_S3#Jo%U$99q45{MD>US; +zz$;Q~BMpl8j#H0zjiV2$+s7ik5y{$}x^q;YFPXZFxQq=&(0X->=M&Bm +zNXIrD?{q@Hd`J{jv +zsW9~Wc{~^3=P?s5E9^C*%Vu6s37^){NVu}r;^!vwdOnQU`L78bc!QyTfrtcu61COf +zvpVEhLNYrudFB-={X!?NQ1jeewC58NHP`bA9~nm7s*Jl@M9-8TJCLR~gA~ObKal-r +zl>2@1{q)?Z$EUikemh%!j>h@m4mZ*yj +zcoM+;tbH}{3SZampTRh!dr$Y2-ME?FxsCddZ|JK{u>nt3eC{+J9BV8hg2hqNJ@_ll +zldXeRm7oNNW{12B=v-r+mmgk-G;)iw@33XOGMd#YkG-r;@hc|YJ?W=Fw8r2amM{3s +ze0$1_qxiLtcLeBqiQp9Db1MxK;RXq9l}?G{pNJN^s+Dl@+MX#p;x;|G*SW@_3x}3g +z7^<@O3_2HnU)qa%et3L=`dT4OA#8jCC3JWPHoIT- +zC3%mxbkFLQW?tiklcT0fD@3jX+Pu{HBvpZEy%|S|1SWuZz3>-6;DS4BuvZ3H=Z@Z? +z%8*LK=25XLrgxU^=pA8) +za3b4R%&I~}>sp~F?G=gE4D8DJm8G@D%AAzasjqcJzmtB71l5-YskPZ78(e2CGlN#5 +zmxoUA5?$w|d8%JAIiPL#QjpUfwxUC!!N +zUC44Q!+~!>SpN8^E_$u#lJI7%jPd{$KQXo+;+aP=Gme6q#pBiym7sLEK`Zp6#8ISiIWvw@@XndfmF_X`!l1@G0&Ih(Gm~^) +zW~4$;*93POkFi%FwQ-cdQB&VETya%i>gU=!oje4+V5jexNJ+xL1Z?(xLAn +zKcXU}vSnX_H#?+q@qY&{D=abP#LlC=rTw~dYlNm>mh`0CzO10b4z53cX +zO3f@zYhCT-Xev{p6?(G%s37txsFL>*E*MsQNtTSjIOJVAndne{iBGqOO^Pnv{1DZfrq4Gh|@338LL+6*4YBN +z&Zs-38znwVMP`ZX=@#JPv(P)Gp~`~3uZ&SP{(Iow!1%2+2ZU>WV#qLwg^o3|6qheOx +z^-a4)6smsVHm6v;z)IF^;_hy-jdwX!A7~%VDe1+H;t|)Z8AerHm!$4)+fkRK?)*0S +z7yF29CE$&Br7ZcG&5!1HJ4Qp6qK%H97zNmbeUQbgH<3-WkLGu$m=9^uM#pJ3Fu9F) +zrSwJp#j7`&Mlzh@sO5-1*YI1WnDexxUY))*T(j}6HRT3l*)`=RphjfU2I8A*6)ED= +zTAO^@KB8$Qc;i(LV1o6Yh(}!BA-D1B5B){;gJ|MMoLj$3QdfT4c(+T-)7lqp4|aK< +zu;(GnAf>q=tet~_UOet&NWcLbm!;X$@@?Z?F1$yzX>F`6-pbL8tpsavvIsyF1&9&= +z5&ZHCmrmkn^i!nOZ>KG&`5R$9HKv#n^GS@%%gj{{!oi@C6mgA-dvLjk#+C3^wik!V +z5iXSHkn~FvoWc@m>WQffD{2n1jT&viudhJWwj5wGTCRGi4l%z +z4*F%5rI=lRDKO;_fpG;u@U*(KP@`0dcV8;jihVnj)xNU*7w2RyLGh)vw`5PK$1e&t +z_}>CLe+7d6D!MA;nC8IAI=ohEI$z2CF>mr9UAp;K9I#EITNOelM}q+xQ|xtx>^Tg0ZPO`f%BGK2$?Xi2+erK)d=U*R +z;!dY;R&qOpj=vfOm=h;HLbUBQDu6;sy(wR5mF$$va@s|p8lzP~bd5EgA`%X#clJ8D +zREtNL@5m*b%&_);d8t^d$G!0{5Izo!ec&OG9ObLwc3&n|v7>KeFrHT*u}P7!yU +zv{~608)l@fraqM0xQ1gn+)jd3UJgYQz+{&&BkZS@@c0Nya8{tMa%h5Op!lkfN8k6H +z$y%jey|4AJ^`Zjy}YPT4gw5JZ#TE9@NeZWB18W=9h)8#}jt +zw+RX@Hh8!UE2?C}GnYr}J@2bRi%y`w8KN&UX+4_S(P~4R9QA}=VowkTeaSVDsWDZ7 +z8T{7LdYMUkqp9rdPigv&F(DfJLg)zyKmU#CG~{f%AWDBE7}{cmVfiP7a<#Q*(GVF +z8|3Gjv{2rEue`nzw|C=j<)9xvi|S7|AD@U00~q+3?(frBdAo5P*6)6w(@Wikfb?JLh6D2BQul9wWGp=eTHKk$0Yk+GqGo&+ +z)sGTdJ14%mUTle|tgpnSzhX)XQ|aBY0QXPgi?o&mqoPlk4nMmYA_;QrrypZ#FDsOy +zIa(Zr9$r6~uND}oG~eSa%6Go`UH>P&d!bxJP@eOh2xZ?FD8~w;z(-?I&>4Nk>MnqjLTkrITA}u`!aA5r*0CrkNq^DG +zsc|@%M9XNjhHnOp##TVT>obCh+!Nc;w;Q{INi?*M&qeqYbJ;3-hIWjx?(Hm!R^{jY +z2CZOCcP$Jy@Ojxz&>cE(b +zqKjpHnQjjzHfug@jirGDg~Ttvh*Q5R@h{sy;@=##k3yhmqfrP9Em`!6Kw|W;ME-Kb +z;M-);E6mn8+fI>p$ll;ik$2qb_+ztMha>T|dsnvnAO^_+7$C$D0Q2ng&dHyhVjj7C +zSR$Vv36$Y02oY6oDt~tKuu%U9G$GFD&u$$C!+4ZF@|KI8AQ^B1g9$)?CR<_8TagH{8_$;ty@5&aLzDCnKd!|86dE~NjwJ)YoUD_lT~gI&uOjf44zp`=qML) +z_d5xb(~TgTjXtW@u?~>a4KrmTAExO92}J)zbuFQN>XPko678FESl0Jnezq*wLiL^J +zZEu-5^R-xkz;RJWpOX==`Yir`IQ%_#)y)=SM?7WrmKdpYfk7sR)$Q +zT^*iq7VR@!7ax6e#wN0r_9-*enWpbQ=UkrywO77=g!58+#1dcaC})O5j~pmY=l}LW +z-TGK$s1PLp+shCBQ5fa1IITmJ2w?1ct=|?)rNJ +zcO+EeLXdZ5;6}X^D%5c*Okh&8I9~9#@kdec+acD1Zw+EW>iO{)0s^Tg +zi%;vN+dCtR>KTEt=TY~)s})D@9c;ndevl5&rbN{=c23j~{rc)}F8GYo^{5k0i$3FY +z1L}km$)^g9B(6tJB=+1KJc2vfO#K6cS*QL{kZTb0a!M4jt!7u?=46Fcvr6$2v8e2; +zr)Z6*G_^)#qUKV-xQUNr2u2ExqE*P5HT&*fqC|Oo*fDTJqQDsv6Vi6bF$5yX_Z=k~ +z#)R}78ZV3T5NO71benM4?@el$5-WM&17_TrL*tk~3fQeb&La~&n7(Wmm{9@SXt9IZ +zT!b00p9?CJ~2UD-};ux39FbS{Q}HU*%~2>?J-05kN9Tmg*?Xkq1c=o$FvF{u9|TDn?8e{i?7;WPlBgS(}jX7wq%RDl*5Knvw202-~? +zk9&apeL(6000+zM(D_L&6779$XEslc7C%JP(JEJeUWVX;yZ0H626s!rOK9l|5AtZ~ +z;0I`0y6l5Tv~=lt*Hw{psd?9Bk#vc9YP`f1)qIqi@!7D4h;r5z%9h(d^eF=8IeiM> +zd0C&b>HJ-v!gF5Gr*NGq`V@}yl0Jp)OxCBcx{xXu8@_2{+L_a3SWGIuudqE}e6kaXPtfd=j-rwOje1C{=} +zl!Y#+ij58bTv+&uboWpi=1N{3@9E0gtXg`pGV-;d5qlRvdHGlsd2>e-@9pMvY!G`_ +zS(F0ub+G@eT8(%j?guL<9x^15=8G +zg?~l@14zs+Y!?6nK!7PlSJoa2{wE{}HCB&IKSeFsT$;CaW#$V@Jj~w(9&K26+SpY7 +z)&bPvq*L+uWZZtNd#Bwlvf6CPQ-<`)@E6vuFfSM0Q^VTR{BwDZ2atztPQ_!Ai}z!p +z|3_NQY4o|gn+G6=@h9U^%tz&nK)HDt2wl{ftUO!!lp(e9?F(yXn3oIuiDB(&W1u3~ +z#VGT%@t1H7z*i6WZXEz0#-EB$N~Z3|ns)~7A}h=`K4t77r0patlqyU0;SbxW#-oyz +zrLi`hX}hS@WM%1JK8#RvG0OBuh|{{BFl1L2zp%u@Lca4rD~n&)dZo%zSfqoEQoKOo +zQ8`24(yzt$+u}^6rsI^6`G&@95elOV4BXdS!r-65e;Q@ZNF-B%)8ajboXvy&6zyEy +z#orQ!_zXUTS7V9HvY)O7I@l<&M1@ni7-i0=eayT3QyTE?Dpi)^9j(i|JPp0$-8H*; +zv@TJocFyAQt~A{2rgB}#bw2CIsDNsV!rnQ@!cv#i$Io-qXtkCkYKtVx1Hkn=~4 +z7y12{;NLc1XRtnUP1P7K%;?*swq=Rd_UI{ac&vcKB3|N+;sm!z7-K$DQn1dzWUwld +z1g-7JNFMztpGHQiF`U7~2<50{as+*Ee88XGcE6 +zut~tzB*tRhNpECOu}NNVCFA#uOxC;q!jbW~oDp_#UIvvBRsMgW{oe`^1q(-w-!rmV +z@34NPH!_&mBrl$l@ed|49LQ2Oe$U8dy~Bo*-pCODa;!#>-pB}IlYa2>SnvAiu}Pdw +z)NRt6m4AJ_Fs`!yX<-CIm*Cg6YPLfWJxff?0;FMu9CcH*2n+W5+$hb0_<~IxO%NvU_ +zQ0U|H%aF)+)D)5v13WnP01plY;K5C0MDI?GA2y` +z>GeRmAHXC6Otg?Wv00qNs^i*#89q#uah3q6$rC#30jD33O$4$rKsFM{7K8{E-mvk2 +z6E}tAC`B#vhjg|FF;VD}d>{@E1?E@==9mPkHvolTKq2zqb7GSI2SioD;s0X2X5&}D +zB;br;VGv#hF_-`e#*pdFyY90HU+z86z4ya8&psdWCu^<$@cWNzJ?4W`Yaui9 +zIaH6(p)>|{n$n<1z$>V^EgEk8q!g(VI4O{07Ze#(!n2CGv$Jz&bJEFkXL5MvGlyu- +zRn44{`_KqD4pp${HXg$bPa%Z36f(CceAK4Hzd~(b4s8b9C5DZGS9FcU7ZXSd(`JHM +zWrI2pi|_!wo7yzY5vfii*8j4NOQ)uZhLh?4d_*qZ(=|vg7OBQL+lF;-B%i4nhWI7= +zX1rz*9z*mkV0nV0)@Tv3J{xQQSFx4+z0nk({O-gw=jp90!hfmhd4K;ed6B1djdRw0 +zU4(Qli2k3-94r|5&aQDz``1Ot8r%O_9ILuy4K2%3407$2c5*gm3l^|Q_!2!+9Wt!D +z|1aE#vN1-;WT)1ECg&8xe{QD^%z;>>(Va%#2StcAa3FrbssDu!QmT9e#yS7zN0+?@ +zMAB2az> +zSX9|zqFGM$I!u^}s&3q56IEf*`LMnMF>U#;Fs1$(&Tt`LM)+ev8WkNsL^Pkn0t5ev +z=HzT(IiK0^|KcM(MP)PP{~u-lJxqUX%BNOu#D8H;eo_^>3B%~ay0*>~|4vnb;wxD; +zMyl_CJIp`lXg_^er+HtNjY)*9@ayym7rnWOde0tb4b?~6vQUX{RKCV^pv@-QmPH01 +zLQe7!;% +z)FuNd8KN2>&53~|7rpt^Wo5yLUTLlnDIHEQS54$J+T*PIE2?Zz>J^oP+T=tYqz~)t +zTHrmmkV=OVoH>@wvTV%r1-vip(jb-x0NyA+2SGfB|cylvU9~^DVLULO$k*eqkDGTFVQ+caR=BhLo +zz4`NYDEZ`P2rjM^GC$M)P=dKhdh}K}@w~4Jx-`iV+T+A-EwXe%-q5n_Z9+8a`gSoC +z(Y`0k##Cqp6dZi%qc(My!Tfip0BgPZF#L&%h2-TYu{b|A!yKBFdMlJXwH%6WlRJr2 +zA*Op!wA(-{!Q50$utqDiLRY!&@tQZ14JfHA8^gB4Anr6XZBPdvwaH9pekj*Hj^WI8 +z);JkvdMA^dg?UObC$-6pjQXfe*J&_uf6<`*!`)i1EA>%SXIarY_Kl*&l7oBE>l1tcGclHA*Fx!7PJNNM+{F +zhjr~_*bn32HKabsLKYe&Lqw|338RDF{B9|*La9l&VR>i5SKI2hW-AtA^qXr?^9+nZ +zG+~Xie(oe?W1K1_ZCF>)PR+tNRhpa)Xh^EeoMRg0lLN=(KIy=-kwu;W(3}kw#(CXK +zRHTxmHz*+$LfU2iZdhkq24AV`@?ou9%3zjYZrW0q{2Q&vMHK0wM&}qz@vn)G)!;O7 +zpFRxY2d911;B*Sp1_xg=K^Bn63=7r3kk)9ABkfa2cQ)W?*UPd2pKy|80|}9>6ah(( +zdFe2SPr$7SWOlbK8*rGH=eD&WCBTiLG&<P&$tqH`+zn63Dq2 +zk{mn^aJpxYgMS*9t3mQ-LVFy%sA#aq!N++d5s;LQ`=~)O_8L+;vOo{E(vcONpn$0* +zM1X2sF3Sd_N@emekiRy4BnOWP72QP-PLAe?fJ2XAi5h4g5vD+DVGaQJ$7qF+~K1IQo%y295W!$8jF+(TwZ8{&bGECD1qD!;GX5ebxlJ6GMiRKjH(A=kU +zF+kw&{&c +zF%Pz~#*bWA>qii7O592h+EWNy9K@AzWYz?^-GD%JyC@J)zY6wdPXw$z(^g2iDzX}Q +zROH`iGa%J(56bot1ljI=oUE*Lafr*3^^RG#tFnu +z4;K3%*#?uh-rVijfl-GIb{?akQxzcsv@T^fz|`TgY=8|L)_M#Bxyz(a2ZoNs!}i=E +zr-}gWxqdMYHV=~CA&_!{$OhO=eKx=*4ADHVB=b^sG}VruMszhgr$l2`P$pjlXqjDb +zs3?tVIzfTmTk|;$c$t|OA_DShI7UM^kQ1!{@^clE1+YI%+$4e%b$iSf2cL0}?pzIc +z`KctIW(7D(ylEkhOrMaA;pB$z5f044bHh?KXfT@J0cf}`CktSerX?akn>7u13TRBV_yt^z@p>Z5Y%~=pW$b$Q0H|Y7|Fl*uO0jr_`tk!}~Nfy99Yq7z>dO6}T4C>ABTJn8uvcrK_8F-$tvI^~y +zJJhvlgL@k^5;~u^Uws|l#E#qx+p=W)q0DY<$3o?FGemyLz +z&udGnORQ{}f8WV5S-l^p*DF>^5VvQ&ch{d#V$zO*Xc*UOAHLf=^lTjMiL6Sgi)Vop)baa-lb@Y_iE;@OM7qPi;3h1x?HO +zXD7|K*I%H-(+myO0XqCeouqWX#AW?&lHRgd4&$cuAo=GaHymzwumG^SmA +zNLtQwO^|kS3ntT!$~e-K6j?uby1KwE^_s(J9bfLO#_KoFvI-}ceJKpSn{fX~tMk-l +ze#VF+&$yE-k8FMfXl>UU|5D_5P)x$$vbAr%KQn-qag61q-Lp}z$kbS^e0e51d0A8y +z$?Y@6VN=|HyL$ls*M>)FSWquYeyBk{>ND+et-BoU7B6q=(;j>Em^^T8&(kb!s(n-e)5 +zVw2)@f2%gVv;H&f0o{J2X58?oYRBzVR%C7R^V_9vl&Pp1V`a8%MV|^^JE)>cD)48< +zt^yNY7Lq?{Ue)LS>!fic%`qg^W!7ZHYZOYIA_ltwD7EqsBwT-)v^@X7J+sn~F)ep` +z#Ud1c4)Kr3tfdD<0~uk&HQ(cz+W7o`+^%3=6`#gD)e!ye6r=oRMiS0 +z`liT=mvL4)R!Ow7iWvRnC+YMSsc;IUdR+FqwS@U$6XK8kBu8BstO=N^jJ~{cpk~9D +zYu|}$m?$G0*hE)F4w;-qs1=(^?|B$13SqI`}r=!Wp*|!fnBN=6fw6~&(l8U=# +zvIeUH&6|z;#=eylR1(qhp_(K6_h?B2WyJ?MYsIb%9P~++`^38>ibwAxH5e!BMt!R!rrt$TC~Yh=5mR-`l9PN +zxhCF|>tdenGkMjqsv4;;8>sX*$vL|!X^cphH^TZvRk;7hZ(eLVFSmdFQf^-nR%wdx +z?^4_^Wk?j+=jC-72xEFhsj*|kk)2HD+J;Z{3hFkT%lE6lIh9=0o4obk2##OhkxYhrf-u+HF2;deJR35H_4$(K8c3 +zcc60QEN@_iYXy{&z1Euh627VPT;jP6>xk@V$B)NQeR&kOM2F{ev>lM +z1RQ*{4RK4^pNtJ)wmkk6AiLiP9shu>BDYTmo)?cYZ%zh$5bi#dV_6PvPbD?(aIZq| +zYw|xBEc!H8T)U_Y{P2wVc}-Xo*po`ib$3+J+NT3AiAOKWZzQ$2Sd4W_GZHaqBTV2?{El#(ekt;q9*57TGjpkLq-yIOA +zEhtrn$FsGGZrpq=hrF&smMI>cT3P=2?Jx`sDb~+~B>*pW{n*(Xzr-?;GO64od3N&9 +zc^Td{${ZJg1eN44`OdOtC-SmfZ6d;r9Cutia~X{|%b%uj8pA79Ig$Rc&QwhTTjym1 +z_{@(h%ukMtY@`qDtVG=G2NgS}yd%zD@7UHm6w4&HDH@36w`C_@dSSKcwsB~AbJ*W^ +zv@A*tjc6h*m@21`|7s|s*M(uLC|nBpm=-%@xV+VU2zi}~HSVZXh13~;zZw~!@G1&# +zA}K7^cU1=08dq?sAHGVcx|2@!Q-g?O%&f;s9?Ok*@`kPD9jo0}OPlB!@yZW7TRuxm +z*&vSnaJK2A1V)~?IMVz2g<{0#gx6N(A8WbiA+ArRUO2b2VG6%XuL|YmAT9n?uC9+sR@h +zwmxM4Uy>7K@JO+KkhE+{JbFns3k->XG+X;N>~qKN2i2zS6hW_(!{8BE6Te@L%#*|l +zDYEU5s=nLy0FEgrlbKtrc$W6D;=@4 +z0fKx)6_R=_EE7vGfs7-*uDN3GD*5hOC$=sqPIvXs*qPuv8{-OGR|y@yb7ZGQRqk8= +zB6j9nu~)~LUG78FqQRU`*@e`D>im)bO;hZjEm8CV3m{fjqSQF;lDAlhne6pWI +zPT|h%avw_ac1yeyI}?19{zpyhT4YdhvE%WJvYog5rjK)j!u>NQdd9e69WK$yw7X7h +zoihbppVAKH_zr_dG7Gni&-|>qx;s^H(XF>(b__kLT}^R&gw=>Qju7YetSWT-=VAH- +zeT}&PJhQhoLU0c`q}{|_a*7;TLUwJG>fH;U6u3QazEin-l|*5T=_|h#*5tc*@Cg0^ +zyI#pSoqr>CCivpUZ^8xXcG!TRBX%a0sVaT8+kJ>PTf9sn9`(rd?auD>gBkF&qWoVKU<`q&N((N2|eXZ`9X13uW}-cu3I+}vkg +zc{{8!q`qIv9BlD)EBSn`kaRDqO5FN&ZHrx{~lc7z4CPX%oi +zw6xHE;xqWtG?Rq%;cfGw&wFIZP^avnYp6d7E7Q5~#G|>>NpTY7+C)oAO_(1^VEhCa +zu=Z)makf@V4bnH}N0{#bT+lgtn;|y(RGRxf7YyfYi`{8CF*?54JUBS=T1Qc1Zh9pz~}RvY5Fj^P_l4Z7=4)I80g$V`&` +z2UY)2Ca6+dSkI>uUgX8j5LYGxKG@;jY0D$J5LY^5bp*#XTORb4XMc=tKbQQ5<9##) +z4#W_0ww$zV#ANPQc$(!{UQ_(w=RFl>OctYa=Av&n2mr5TVjjU-5tT#P>8f!+*N|iZ%sqx(X8OH=#@GFJpa&Vw6ocW;3 +zBZwv-$d47|#WhpAcmcct<}wd)tl5_v;wdD3x0J2DL~-jVab|17Er02krE8m0YqW?r +zm+yRVAiDD0YkC6@NNO`oUktQ}6t08QyS&+2@Ery7>!=e(GWS^!VBL!7hew4)(zVU4 +zJS~DH9un3|oziN^gKK(#x$f#zHpOB3Akr9J2?9Iap49sPskj)^{>VsXI`?;0`&6(8 +zKiVUXEls2!v-C(%MCsSc=-_}SinVdN@|&@OHFtboW^$iJRf30Qb_WxkB3w?iNDr;) +z0sABiYwFx0UAi@AE5X+Kt5fg7D4f>L@qOp=N3;uMC?Cpx1-}&$~XA +z_%{h@Wm3Dw!HQ#!O7I<}el>c$aw%JP#!~MzcT2r5wG7jt-IfA>I=|d$S>7ul2Z~2a +z_iExl3zBcklf}+#w)|K(en-GZ^6@2V=5*d7*hOhGmSqtV5PWkUZ$#18B6s0aB#PvxpVt*TVn_&X5n-nhFX+NhQPQ +z)h|8aA1TG7#4d}I$QnzOk@o4pXV-UL^0|L;;`yT1Z!3cDBoMbSOAgY06d%6OS^FT9oY1D6LC`NMm$|BH3bLq-Fd1WM3pjknYyu^J%ys-C?!G +z(k~Y8ismn^YnHgJ_4h;~*TB5X|?5)Z3$mZi{Q=c6?bDBB#RRq&dls|AKEu{|BIMhvG4s8Efw4Dl&B6bISYb|_in_x +zHxCjCgYJ&1n*#{1SUj3H9p@dx7CR5NA8T@C>tvSn67$uxsBm?{%(+7$4+Igb3Z495 +z;jAg#!hA5v`;Vn;U9ZJ8`8;?yjl$iB);6cL);Y4%dKrmlb7E)K%+K*ZEX*PE%e$7P +zTbMnCd1`#GwC(MX_dtaEP>sb`|9XqELW_R^{i=p +zp?H^D5$Rt(89?WT9pzXGIPIIkijG_{9?sPj4(4CW6&p8l^BbuKU=?-P>(0fR1@HVX +D< NativeCookingActionResult.TYPE.read(partial)); ++ assertEquals(result, NativeCookingActionResult.TYPE.read(valid)); ++ assertFalse(valid.isReadable()); + } finally { -+ partial.release(); ++ valid.release(); + } -+ } -+ for (int flag = wire.writerIndex() - 2; flag < wire.writerIndex(); flag++) { -+ final var malformed = wire.copy(); -+ try { -+ malformed.setByte(flag, 2); -+ assertThrows(IllegalArgumentException.class, () -> NativeCookingActionResult.TYPE.read(malformed)); -+ } finally { -+ malformed.release(); ++ for (int length = 0; length < wire.readableBytes(); length++) { ++ final var partial = wire.copy(0, length); ++ try { ++ assertThrows(RuntimeException.class, () -> NativeCookingActionResult.TYPE.read(partial)); ++ } finally { ++ partial.release(); ++ } ++ } ++ for (int flag = wire.writerIndex() - 2; flag < wire.writerIndex(); flag++) { ++ final var malformed = wire.copy(); ++ try { ++ malformed.setByte(flag, 2); ++ assertThrows(IllegalArgumentException.class, () -> NativeCookingActionResult.TYPE.read(malformed)); ++ } finally { ++ malformed.release(); ++ } + } ++ wire.writeByte(0); ++ assertThrows(RuntimeException.class, () -> NativeCookingActionResult.TYPE.read(wire)); ++ wire.clear().writeZero(NativeCookingAction.MAX_ENCODED_BYTES + 3); ++ assertThrows(IllegalArgumentException.class, () -> NativeCookingActionResult.TYPE.read(wire)); ++ } finally { ++ wire.release(); + } -+ wire.writeByte(0); -+ assertThrows(RuntimeException.class, () -> NativeCookingActionResult.TYPE.read(wire)); -+ wire.clear().writeZero(84); -+ assertThrows(IllegalArgumentException.class, () -> NativeCookingActionResult.TYPE.read(wire)); -+ } finally { -+ wire.release(); + } + } + } @@ -7410,10 +9124,10 @@ index 000000000..0383d7251 +} diff --git a/src/test/java/net/raphimc/viabedrock/api/model/NativeCookingActionTest.java b/src/test/java/net/raphimc/viabedrock/api/model/NativeCookingActionTest.java new file mode 100644 -index 000000000..80dd3cef5 +index 000000000..a1fa7e2d8 --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/api/model/NativeCookingActionTest.java -@@ -0,0 +1,102 @@ +@@ -0,0 +1,170 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -7432,48 +9146,72 @@ index 000000000..80dd3cef5 + @Test + void snapshotIdentitiesRoundTripWithoutNarrowingAndRejectIncompleteEnvelopes() { + for (final var mode : NativeCookingAction.Mode.values()) { -+ for (final var action : List.of(new NativeCookingAction(0, 1, 1, 0, 0, 0, 0, mode, 1), -+ new NativeCookingAction(255, Long.MAX_VALUE, Long.MAX_VALUE, Long.MAX_VALUE, -+ Long.MAX_VALUE, Long.MAX_VALUE, Integer.MAX_VALUE, mode, Long.MAX_VALUE))) { -+ final var wire = Unpooled.buffer(); -+ try { -+ NativeCookingAction.TYPE.write(wire, action); -+ for (int length = 0; length < wire.readableBytes(); length++) { -+ final var partial = wire.copy(0, length); ++ for (final int phase : new int[]{0, 1, 2}) { ++ for (final var action : List.of(new NativeCookingAction(0, 1, 1, 0, 0, 0, 0, mode, 1, phase, 1), ++ new NativeCookingAction(255, Long.MAX_VALUE, Long.MAX_VALUE, Long.MAX_VALUE, ++ Long.MAX_VALUE, Long.MAX_VALUE, Integer.MAX_VALUE, mode, 1, phase, Long.MAX_VALUE))) { ++ final var wire = Unpooled.buffer(); ++ try { ++ NativeCookingAction.TYPE.write(wire, action); ++ for (int length = 0; length < wire.readableBytes(); length++) { ++ final var partial = wire.copy(0, length); ++ try { ++ assertThrows(RuntimeException.class, () -> NativeCookingAction.TYPE.read(partial)); ++ } finally { ++ partial.release(); ++ } ++ } ++ final var valid = wire.copy(); + try { -+ assertThrows(RuntimeException.class, () -> NativeCookingAction.TYPE.read(partial)); ++ assertEquals(action, NativeCookingAction.TYPE.read(valid)); ++ assertFalse(valid.isReadable()); + } finally { -+ partial.release(); ++ valid.release(); + } -+ } -+ final var valid = wire.copy(); -+ try { -+ assertEquals(action, NativeCookingAction.TYPE.read(valid)); -+ assertFalse(valid.isReadable()); ++ wire.writeByte(0); ++ assertThrows(IllegalArgumentException.class, () -> NativeCookingAction.TYPE.read(wire)); ++ wire.readerIndex(0); ++ wire.writerIndex(wire.writerIndex() - 1); ++ for (final int previousVersion : new int[]{3, 4}) { ++ wire.readerIndex(0); ++ wire.setByte(0, previousVersion); ++ assertThrows(IllegalArgumentException.class, () -> NativeCookingAction.TYPE.read(wire)); ++ } ++ wire.clear(); ++ Types.VAR_INT.writePrimitive(wire, 5); ++ Types.VAR_INT.writePrimitive(wire, 0); ++ assertThrows(IllegalArgumentException.class, () -> NativeCookingAction.TYPE.read(wire)); ++ wire.clear(); ++ wire.writeZero(NativeCookingAction.MAX_ENCODED_BYTES + 1); ++ assertThrows(IllegalArgumentException.class, () -> NativeCookingAction.TYPE.read(wire)); + } finally { -+ valid.release(); ++ wire.release(); + } -+ wire.writeByte(0); -+ assertThrows(IllegalArgumentException.class, () -> NativeCookingAction.TYPE.read(wire)); -+ wire.readerIndex(0); -+ wire.writerIndex(wire.writerIndex() - 1); -+ wire.setByte(0, 2); -+ assertThrows(IllegalArgumentException.class, () -> NativeCookingAction.TYPE.read(wire)); -+ wire.clear(); -+ Types.VAR_INT.writePrimitive(wire, 3); -+ Types.VAR_INT.writePrimitive(wire, 0); -+ assertThrows(IllegalArgumentException.class, () -> NativeCookingAction.TYPE.read(wire)); -+ wire.clear(); -+ wire.writeZero(82); -+ assertThrows(IllegalArgumentException.class, () -> NativeCookingAction.TYPE.read(wire)); -+ } finally { -+ wire.release(); + } + } + } + } + + @Test ++ void rejectsActionsWithoutNativeEventPhase() { ++ final var wire = Unpooled.buffer(); ++ try { ++ Types.VAR_INT.writePrimitive(wire, 3); ++ Types.VAR_INT.writePrimitive(wire, net.raphimc.viabedrock.protocol.data.ProtocolConstants.BEDROCK_PROTOCOL_VERSION); ++ Types.VAR_INT.writePrimitive(wire, 0); ++ for (final long field : new long[]{1, 1, 0, 0, 0}) { ++ Types.VAR_LONG.writePrimitive(wire, field); ++ } ++ Types.VAR_INT.writePrimitive(wire, 0); ++ wire.writeByte(0); ++ Types.VAR_LONG.writePrimitive(wire, 1); ++ assertThrows(IllegalArgumentException.class, () -> NativeCookingAction.TYPE.read(wire)); ++ } finally { ++ wire.release(); ++ } ++ } ++ ++ @Test + void invalidWireIdentitiesNeverBecomeSelections() { + for (final long[] identity : List.of(new long[]{-1, 1, 1, 0, 0, 0, 0, 1}, + new long[]{256, 1, 1, 0, 0, 0, 0, 1}, new long[]{0, 0, 1, 0, 0, 0, 0, 1}, @@ -7483,7 +9221,7 @@ index 000000000..80dd3cef5 + new long[]{0, 1, 1, 0, 0, 0, 0, 0}, new long[]{0, 1, 1, 0, 0, 0, 0, -1})) { + final var wire = Unpooled.buffer(); + try { -+ Types.VAR_INT.writePrimitive(wire, 3); ++ Types.VAR_INT.writePrimitive(wire, 5); + Types.VAR_INT.writePrimitive(wire, net.raphimc.viabedrock.protocol.data.ProtocolConstants.BEDROCK_PROTOCOL_VERSION); + Types.VAR_INT.writePrimitive(wire, (int) identity[0]); + for (int field = 1; field < 6; field++) { @@ -7491,6 +9229,8 @@ index 000000000..80dd3cef5 + } + Types.VAR_INT.writePrimitive(wire, (int) identity[6]); + wire.writeByte(0); ++ wire.writeByte(1); ++ wire.writeByte(0); + Types.VAR_LONG.writePrimitive(wire, identity[7]); + assertThrows(IllegalArgumentException.class, () -> NativeCookingAction.TYPE.read(wire)); + } finally { @@ -7500,19 +9240,61 @@ index 000000000..80dd3cef5 + } + + @Test -+ void unknownOrMissingInputModesCannotBecomePrimaryActions() { -+ final var action = new NativeCookingAction(0, 1, 1, 0, 0, 0, 0, NativeCookingAction.Mode.PRIMARY, 1); ++ void unknownEventPhasesCannotBecomeSelections() { ++ final var action = new NativeCookingAction(0, 1, 1, 0, 0, 0, 0, NativeCookingAction.Mode.PRIMARY, 1, 0, 1); ++ for (final int phase : new int[]{3, 255}) { ++ final var wire = Unpooled.buffer(); ++ try { ++ NativeCookingAction.TYPE.write(wire, action); ++ wire.setByte(wire.writerIndex() - 2, phase); ++ assertThrows(IllegalArgumentException.class, () -> NativeCookingAction.TYPE.read(wire)); ++ } finally { ++ wire.release(); ++ } ++ } ++ for (final int phase : new int[]{-1, 3, 256}) { ++ assertThrows(IllegalArgumentException.class, ++ () -> new NativeCookingAction(0, 1, 1, 0, 0, 0, 0, NativeCookingAction.Mode.PRIMARY, 1, phase, 1)); ++ } ++ } ++ ++ @Test ++ void unknownCommandsCannotBecomePrimaryActions() { ++ final var action = new NativeCookingAction(0, 1, 1, 0, 0, 0, 0, NativeCookingAction.Mode.PRIMARY, 1, 0, 1); + for (final int mode : new int[]{3, 255}) { + final var wire = Unpooled.buffer(); + try { + NativeCookingAction.TYPE.write(wire, action); -+ wire.setByte(wire.writerIndex() - 2, mode); ++ wire.setByte(wire.writerIndex() - 4, mode); + assertThrows(IllegalArgumentException.class, () -> NativeCookingAction.TYPE.read(wire)); + } finally { + wire.release(); + } + } -+ assertThrows(IllegalArgumentException.class, () -> new NativeCookingAction(0, 1, 1, 0, 0, 0, 0, null, 1)); ++ assertThrows(IllegalArgumentException.class, () -> new NativeCookingAction(0, 1, 1, 0, 0, 0, 0, null, 1, 0, 1)); ++ } ++ ++ @Test ++ void controllerModesStayDistinctFromCommandsAndRejectUnknownValues() { ++ for (final int context : new int[]{0, 1, 2, 3}) { ++ final var action = new NativeCookingAction(0, 1, 1, 0, 0, 0, 0, NativeCookingAction.Mode.PRIMARY, context, 1, 1); ++ final var wire = Unpooled.buffer(); ++ try { ++ NativeCookingAction.TYPE.write(wire, action); ++ assertEquals(action, NativeCookingAction.TYPE.read(wire)); ++ for (final int invalid : new int[]{4, 255}) { ++ wire.readerIndex(0); ++ wire.setByte(wire.writerIndex() - 3, invalid); ++ assertThrows(IllegalArgumentException.class, () -> NativeCookingAction.TYPE.read(wire)); ++ } ++ } finally { ++ wire.release(); ++ } ++ } ++ for (final int invalid : new int[]{-1, 4, 256}) { ++ assertThrows(IllegalArgumentException.class, () -> new NativeCookingAction(0, 1, 1, 0, 0, 0, 0, ++ NativeCookingAction.Mode.PRIMARY, invalid, 1, 1)); ++ } + } + +} @@ -7623,10 +9405,10 @@ index 000000000..83cedd248 +} 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 000000000..b815ac12c +index 000000000..f2ee7e842 --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/api/model/NativeCookingMessageTest.java -@@ -0,0 +1,422 @@ +@@ -0,0 +1,424 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -7736,9 +9518,11 @@ index 000000000..b815ac12c + final var ui = new CookingUi(new ResourcePackStorage(List.of()).getJsonUiProgram()); + final var message = NativeCookingMessage.create(ui, view(CookingUi.Family.FURNACE), 1, 1, 0, 0, 0, items()); + assertThrows(IllegalArgumentException.class, () -> copy(message, 8, message.program(), message.snapshot())); -+ final var foreign = ui.exportProgram(CookingUi.Family.SMOKER, message.view().title()); ++ final var foreign = ui.exportProgram(view(CookingUi.Family.SMOKER)); + assertThrows(IllegalArgumentException.class, () -> copy(message, message.menuId(), foreign.toString(), message.snapshot())); -+ final var renamed = ui.exportProgram(CookingUi.Family.FURNACE, new ContainerUi.Title("Another title", false)); ++ final var source = message.view(); ++ final var renamed = ui.exportProgram(new CookingUi.MenuView(source.menuId(), source.family(), ++ new ContainerUi.Title("Another title", false), source.slots(), source.cursor(), source.progress(), source.book())); + assertThrows(IllegalArgumentException.class, () -> copy(message, message.menuId(), renamed.toString(), message.snapshot())); + final var incomplete = JsonParser.parseString(message.snapshot()).getAsJsonObject(); + incomplete.getAsJsonArray("slots").remove(0); @@ -7748,7 +9532,7 @@ index 000000000..b815ac12c + @Test + void rejectsTruncationTrailingBytesIncompatibleProtocolAndInvalidIdentities() { + final var program = new JsonUiProgram(List.of(Map.of("ui/cooking.json", """ -+ {"namespace":"furnace","furnace_screen":{"$screen_content":"furnace.custom"}, ++ {"namespace":"furnace","furnace_screen":{"type":"screen","$screen_content":"furnace.custom","controls":[{"root_screen_panel@$screen_content":{}}]}, + "custom":{"type":"panel"}} + """.getBytes(StandardCharsets.UTF_8)))); + final var source = Unpooled.buffer(); @@ -8256,6 +10040,135 @@ index 000000000..e05a2dea0 + } + +} +diff --git a/src/test/java/net/raphimc/viabedrock/api/model/NativeItemNamesTest.java b/src/test/java/net/raphimc/viabedrock/api/model/NativeItemNamesTest.java +new file mode 100644 +index 000000000..fa4932829 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/api/model/NativeItemNamesTest.java +@@ -0,0 +1,123 @@ ++/* ++ * 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.ByteTag; ++import com.viaversion.nbt.tag.CompoundTag; ++import com.viaversion.nbt.tag.IntTag; ++import com.viaversion.nbt.tag.StringTag; ++import com.viaversion.nbt.tag.Tag; ++import com.viaversion.viaversion.libs.gson.JsonObject; ++import com.viaversion.viaversion.libs.gson.JsonParser; ++import org.junit.jupiter.api.Test; ++import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; ++ ++import java.nio.file.Files; ++import java.nio.file.Path; ++import java.util.ArrayList; ++import java.util.List; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++class NativeItemNamesTest { ++ ++ @Test ++ void keepsCustomPresenceDistinctFromEmptyTextAndDefinitionAbsence() { ++ final var data = new CompoundTag(); ++ final var display = new CompoundTag(); ++ data.put("display", display); ++ assertNull(NativeItemNames.customName(data, false)); ++ display.put("Name", new IntTag(7)); ++ data.put("title", new StringTag("Title")); ++ assertEquals("", NativeItemNames.customName(data, false)); ++ assertEquals("Title", NativeItemNames.customName(data, true)); ++ assertEquals("Title", NativeItemNames.primary(true, data, true, ++ () -> fail("Custom names must skip ordinary production"), key -> fail(), (key, args) -> fail())); ++ assertEquals("", NativeItemNames.primary(false, data, true, ++ () -> fail("Missing definitions must skip ordinary production"), key -> fail(), (key, args) -> fail())); ++ final String custom = "Custom\0" + (char) 0xDCFF; ++ display.put("Name", new StringTag(custom)); ++ display.put("FilteredName", new StringTag("Filtered")); ++ data.put("filteredTitle", new StringTag("Filtered title")); ++ assertEquals(custom, NativeItemNames.customName(data, true)); ++ } ++ ++ @Test ++ void appliesTypedAppendPrecedenceAndRetainsTheEmptyFirstFormatArgument() { ++ final var data = new CompoundTag(); ++ data.put("AppendCustomName", new IntTag(1)); ++ data.put("CustomName", new StringTag(" suffix")); ++ assertEquals("Base", NativeItemNames.primary(true, data, false, () -> "Base", key -> fail(), (key, args) -> fail())); ++ data.put("AppendCustomName", new ByteTag((byte) -1)); ++ assertEquals("Base suffix", NativeItemNames.primary(true, data, false, () -> "Base", key -> fail(), (key, args) -> fail())); ++ data.put("GroupName", new StringTag("group")); ++ data.put("BodyID", new StringTag("body")); ++ final List calls = new ArrayList<>(); ++ assertEquals("Base group", NativeItemNames.primary(true, data, false, () -> "Base", key -> { ++ calls.add(key); ++ return " " + key; ++ }, (key, args) -> fail())); ++ assertEquals(List.of("group"), calls); ++ data.put("GroupName", new StringTag("")); ++ data.put("Color2ID", new StringTag("second")); ++ assertEquals("Base formatted", NativeItemNames.primary(true, data, false, () -> "Base", key -> key, (key, args) -> { ++ assertEquals("body", key); ++ assertEquals(List.of(""), args); ++ return " formatted"; ++ })); ++ data.put("ColorID", new StringTag("first")); ++ assertEquals("Base formatted", NativeItemNames.primary(true, data, false, () -> "Base", key -> key, (key, args) -> { ++ assertEquals(List.of("first", "second"), args); ++ return " formatted"; ++ })); ++ } ++ ++ @Test ++ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_ITEM_NAMES", matches = ".+") ++ void matchesNativeUnfilteredSelectionAndAppendCases() throws Exception { ++ final var fixture = JsonParser.parseString(Files.readString(Path.of(System.getenv("STACKANVIL_NATIVE_ITEM_NAMES")))).getAsJsonObject(); ++ final var translations = fixture.getAsJsonObject("translations"); ++ int cases = 0; ++ for (final var element : fixture.getAsJsonArray("rows")) { ++ final var row = element.getAsJsonObject(); ++ final var data = row.get("data").isJsonNull() ? null : compound(row.getAsJsonObject("data")); ++ final boolean writtenBook = row.get("writtenBook").getAsBoolean(); ++ final String expectedCustom = row.get("custom").isJsonNull() ? null : row.get("custom").getAsString(); ++ assertEquals(expectedCustom, NativeItemNames.customName(data, writtenBook), "custom case " + cases); ++ assertEquals(row.get("primary").getAsString(), NativeItemNames.primary(row.get("registered").getAsBoolean(), data, writtenBook, ++ () -> row.get("ordinary").getAsString(), key -> translate(translations, key), (key, args) -> { ++ String text = translate(translations, key); ++ for (int index = 0; index < args.size(); index++) { ++ text = text.replace("%" + (index + 1), args.get(index)); ++ } ++ return text; ++ }), "primary case " + cases); ++ cases++; ++ } ++ assertEquals(fixture.get("cases").getAsInt(), cases); ++ } ++ ++ private static String translate(final JsonObject translations, final String key) { ++ return translations.has(key) ? translations.get(key).getAsString() : key; ++ } ++ ++ private static CompoundTag compound(final JsonObject object) { ++ final var result = new CompoundTag(); ++ for (final var entry : object.entrySet()) { ++ final var value = entry.getValue().getAsJsonArray(); ++ final Tag tag = switch (value.get(0).getAsInt()) { ++ case 1 -> new ByteTag(value.get(1).getAsByte()); ++ case 3 -> new IntTag(value.get(1).getAsInt()); ++ case 8 -> new StringTag(value.get(1).getAsString()); ++ case 10 -> compound(value.get(1).getAsJsonObject()); ++ default -> throw new IllegalArgumentException("Unsupported fixture tag"); ++ }; ++ result.put(entry.getKey(), tag); ++ } ++ return result; ++ } ++ ++} diff --git a/src/test/java/net/raphimc/viabedrock/api/model/NativeNpcDialogueMessageTest.java b/src/test/java/net/raphimc/viabedrock/api/model/NativeNpcDialogueMessageTest.java new file mode 100644 index 000000000..15e846266 @@ -8787,10 +10700,10 @@ index 000000000..7e5618a1c +} 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 000000000..e50c333f0 +index 000000000..00b24f79d --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingNavigationTest.java -@@ -0,0 +1,65 @@ +@@ -0,0 +1,87 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -8799,6 +10712,7 @@ index 000000000..e50c333f0 +package net.raphimc.viabedrock.api.resourcepack.ui; + +import com.viaversion.viaversion.libs.gson.JsonParser; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ContainerEnumName; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; @@ -8809,6 +10723,27 @@ index 000000000..e50c333f0 +final class CookingNavigationTest { + + @Test ++ void captionsMatchTheNativeGetterAcrossFamiliesTabsAndFiltering() throws Exception { ++ final String fixture = System.getenv("STACKANVIL_NATIVE_COOKING_LABELS"); ++ assumeTrue(fixture != null && Files.isRegularFile(Path.of(fixture))); ++ final var reference = JsonParser.parseString(Files.readString(Path.of(fixture))).getAsJsonObject(); ++ assertEquals(51061372, reference.get("build").getAsInt()); ++ assertEquals(60, reference.getAsJsonArray("rows").size()); ++ for (final var element : reference.getAsJsonArray("rows")) { ++ final var row = element.getAsJsonObject(); ++ final var family = CookingUi.Family.valueOf(row.get("family").getAsString()); ++ final var ingredient = switch (family) { ++ case FURNACE -> ContainerEnumName.FurnaceIngredientContainer; ++ case BLAST_FURNACE -> ContainerEnumName.BlastFurnaceIngredientContainer; ++ case SMOKER -> ContainerEnumName.SmokerIngredientContainer; ++ }; ++ assertEquals(ingredient.getValue(), row.get("nativeIngredientContainer").getAsInt()); ++ assertEquals(row.get("label").getAsString(), CookingNavigation.tabLabel(row.get("tab").getAsInt(), ++ family, row.get("filtering").getAsBoolean())); ++ } ++ } ++ ++ @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)); @@ -8997,12 +10932,189 @@ index 000000000..5c5140c88 + private record KeyedEntry(int identity, String key) { } + +} +diff --git a/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingRecipeSearchTest.java b/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingRecipeSearchTest.java +new file mode 100644 +index 000000000..eb308ff8c +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingRecipeSearchTest.java +@@ -0,0 +1,75 @@ ++/* ++ * 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.JsonParser; ++import java.nio.charset.StandardCharsets; ++import java.nio.file.Files; ++import java.nio.file.Path; ++import java.util.concurrent.atomic.AtomicInteger; ++import org.junit.jupiter.api.Test; ++import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; ++import static org.junit.jupiter.api.Assertions.*; ++ ++class CookingRecipeSearchTest { ++ ++ @Test ++ void normalizesTheQueryAndKeepsNameProductionLazy() { ++ assertTrue(new CookingRecipeSearch("").matches(() -> fail("Unexpected primary lookup"), () -> fail("Unexpected label lookup"))); ++ assertTrue(new CookingRecipeSearch("STRASSE").matches(() -> bytes("Straße"), () -> fail("Unexpected label lookup"))); ++ assertTrue(new CookingRecipeSearch("CAFÉ").matches(() -> bytes("other"), () -> bytes("§aCafé"))); ++ assertFalse(new CookingRecipeSearch("§acafé").matches(() -> bytes("other"), () -> bytes("§aCafé"))); ++ assertFalse(new CookingRecipeSearch(" beef ").matches(() -> bytes("Beef"), () -> bytes(""))); ++ assertFalse(new CookingRecipeSearch("beef raw").matches(() -> bytes("Raw Beef"), () -> bytes(""))); ++ assertFalse(new CookingRecipeSearch("be" + Character.toString(0x200B) + "ef").matches(() -> bytes("Beef"), () -> bytes(""))); ++ final byte[] query = bytes("BEEF"); ++ final var search = new CookingRecipeSearch(query); ++ query[0] = 'x'; ++ assertTrue(search.matches(() -> bytes("Beef"), () -> fail("Unexpected label lookup"))); ++ } ++ ++ @Test ++ void strippingUsesTheNativeByteAutomatonIncludingIncompleteAndUnknownCodes() { ++ assertArrayEquals(bytes("A§ZB§xC"), CookingRecipeSearch.stripFormatting(bytes("§aA§ZB§xC§w"))); ++ assertArrayEquals(bytes("A"), CookingRecipeSearch.stripFormatting(bytes("A§"))); ++ assertArrayEquals(bytes("A"), CookingRecipeSearch.stripFormatting(new byte[]{'A', (byte) 0xC2})); ++ assertArrayEquals(new byte[]{(byte) 0xC2, (byte) 0xC2, (byte) 0xA7, 'a'}, ++ CookingRecipeSearch.stripFormatting(new byte[]{(byte) 0xC2, (byte) 0xC2, (byte) 0xA7, 'a'})); ++ } ++ ++ @Test ++ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_COOKING_SEARCH", matches = ".+") ++ void matchesTheCapturedPredicateCompositionAndLazyNameBoundaries() throws Exception { ++ final var data = JsonParser.parseString(Files.readString(Path.of(System.getenv("STACKANVIL_NATIVE_COOKING_SEARCH")))).getAsJsonObject(); ++ int cases = 0; ++ for (final var element : data.getAsJsonArray("rows")) { ++ final var row = element.getAsJsonObject(); ++ final var primary = new AtomicInteger(); ++ final var secondary = new AtomicInteger(); ++ final var search = new CookingRecipeSearch(row.get("query").getAsString()); ++ final boolean admitted = row.get("filtering").getAsBoolean() || row.get("eligible").getAsBoolean(); ++ final boolean match = admitted && search.matches( ++ () -> { ++ primary.incrementAndGet(); ++ return bytes(row.get("primary").getAsString()); ++ }, ++ () -> { ++ secondary.incrementAndGet(); ++ return bytes(row.get("secondary").getAsString()); ++ }); ++ assertEquals(row.get("result").getAsInt(), match ? row.get("availability").getAsInt() : 3); ++ assertEquals(row.get("primaryCalls").getAsInt(), primary.get()); ++ assertEquals(row.get("secondaryCalls").getAsInt(), secondary.get()); ++ cases++; ++ } ++ assertEquals(304, cases); ++ } ++ ++ private static byte[] bytes(final String value) { ++ return value.getBytes(StandardCharsets.UTF_8); ++ } ++ ++} +diff --git a/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingSearchTextTest.java b/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingSearchTextTest.java +new file mode 100644 +index 000000000..b60776001 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingSearchTextTest.java +@@ -0,0 +1,90 @@ ++/* ++ * 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.JsonParser; ++import java.nio.file.Files; ++import java.nio.file.Path; ++import java.util.List; ++import org.junit.jupiter.api.Test; ++import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; ++import static org.junit.jupiter.api.Assertions.*; ++ ++class CookingSearchTextTest { ++ ++ @Test ++ void readinessCoalescesRawEditsWithoutApplyingOrDiscardingThemEarly() { ++ final var text = new CookingSearchText(); ++ text.edit("old"); ++ text.edit("a\0B煤炭 🔥"); ++ assertFalse(text.tick(false)); ++ assertEquals("", text.value()); ++ assertTrue(text.tick(true)); ++ assertEquals("a\0B煤炭 🔥", text.value()); ++ assertFalse(text.tick(true)); ++ text.edit(text.value()); ++ assertTrue(text.tick(true)); ++ text.edit(""); ++ assertTrue(text.tick(true)); ++ assertEquals("", text.value()); ++ assertFalse(text.tick(true)); ++ } ++ ++ @Test ++ void guardedClearPreservesBothAppliedAndPendingTextOnlyForModeTwo() { ++ for (final int mode : List.of(0, 1, 2, 3)) { ++ final var text = new CookingSearchText(); ++ text.edit("applied"); ++ assertTrue(text.tick(true)); ++ text.edit("pending"); ++ assertEquals(mode != 2, text.clear(true, mode)); ++ assertEquals(mode == 2 ? "applied" : "", text.value()); ++ assertEquals(mode == 2, text.tick(true)); ++ assertEquals(mode == 2 ? "pending" : "", text.value()); ++ text.edit("discarded"); ++ assertTrue(text.clear(false, mode)); ++ assertEquals("", text.value()); ++ assertFalse(text.tick(true)); ++ } ++ } ++ ++ @Test ++ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_COOKING_SEARCH_EDITS", matches = ".+") ++ void matchesTheExecutedEditTickAndClearCallbacks() throws Exception { ++ final var data = JsonParser.parseString(Files.readString(Path.of(System.getenv("STACKANVIL_NATIVE_COOKING_SEARCH_EDITS")))).getAsJsonObject(); ++ int edits = 0; ++ for (final var element : data.getAsJsonArray("editCases")) { ++ final var row = element.getAsJsonObject(); ++ final var text = new CookingSearchText(); ++ for (final var value : row.getAsJsonArray("batch")) { ++ text.edit(value.getAsString()); ++ } ++ final boolean ready = row.get("ready").getAsBoolean(); ++ assertEquals(row.get("firstTickResult").getAsInt() != 0, text.tick(ready)); ++ if (!ready) { ++ assertTrue(text.tick(true)); ++ } ++ assertEquals(row.getAsJsonArray("applied").get(0).getAsString(), text.value()); ++ assertFalse(text.tick(true)); ++ edits++; ++ } ++ int clears = 0; ++ for (final var element : data.getAsJsonArray("clearCases")) { ++ final var row = element.getAsJsonObject(); ++ final var text = new CookingSearchText(); ++ if (row.get("pending").getAsBoolean()) { ++ text.edit("pending query over fifteen bytes"); ++ } ++ assertEquals(row.get("handled").getAsInt() != 0, text.clear(row.get("guard").getAsBoolean(), row.get("inputMode").getAsInt())); ++ assertEquals("", text.value()); ++ assertEquals(row.get("pendingAfter").getAsBoolean(), text.tick(true)); ++ clears++; ++ } ++ assertEquals(22, edits); ++ assertEquals(16, clears); ++ } ++ ++} 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 000000000..b4c2d9b78 +index 000000000..8af320fa5 --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/CookingUiTest.java -@@ -0,0 +1,549 @@ +@@ -0,0 +1,646 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -9027,6 +11139,7 @@ index 000000000..b4c2d9b78 +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; ++import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; @@ -9034,9 +11147,49 @@ index 000000000..b4c2d9b78 +class CookingUiTest { + + @Test ++ void exportsTranslationsFromTheCurrentControllerAlongsideStaticDeclarations() { ++ final var content = new InMemoryContent(); ++ content.putString("manifest.json", "{\"format_version\":2,\"header\":{\"uuid\":\"" + UUID.randomUUID() ++ + "\",\"version\":[1,0,0],\"name\":\"test\"}}"); ++ content.putString("texts/en_US.lang", "controller.title=Localized title\ncontroller.tab=Localized tab\n"); ++ final var ui = new ResourcePackStorage(List.of(new ResourcePack(content))).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(), "controller.tab", source.searchText(), ++ source.selection(), source.catalog(), source.visibleRecipes()); ++ final var view = new CookingUi.MenuView(initial.menuId(), family, new ContainerUi.Title("controller.title", true), ++ initial.slots(), initial.cursor(), initial.progress(), book); ++ final var direct = ui.resolve(view); ++ final var roundTrip = CookingUi.fromProgram(ui.exportProgram(view)).resolve(view); ++ assertEquals(direct, roundTrip); ++ assertEquals(CookingUi.actionScope(direct, book), CookingUi.actionScope(roundTrip, book)); ++ } ++ } ++ ++ @Test ++ void nativeToggleGroupsRetainTheirIndicesThroughProgramReplay() { ++ final var ui = new ResourcePackStorage(List.of()).getCookingUi(); ++ for (final var family : CookingUi.Family.values()) { ++ final var view = menu(family); ++ final var restored = CookingUi.fromProgram(ui.exportProgram(view)); ++ final var scene = ui.resolve(view); ++ assertEquals(scene, restored.resolve(view)); ++ final var toggles = nodes(scene.getAsJsonObject("root")).stream() ++ .filter(node -> "toggle".equals(node.get("type").getAsString()) && node.get("visible").getAsBoolean()).toList(); ++ final var navigation = toggles.stream().filter(node -> "navigation_tab".equals(node.get("toggle_name").getAsString())) ++ .map(node -> node.get("toggle_group_forced_index").getAsInt()).collect(Collectors.toSet()); ++ assertEquals(family == CookingUi.Family.FURNACE ? Set.of(1, 2, 3, 4) : Set.of(4), navigation); ++ assertEquals(Set.of(1, 2), toggles.stream().filter(node -> "layout_toggle".equals(node.get("toggle_name").getAsString())) ++ .map(node -> node.get("toggle_group_forced_index").getAsInt()).collect(Collectors.toSet())); ++ } ++ } ++ ++ @Test + void desktopInventoryBackgroundsRemainIndependentOfRecipeAndSelectionState() { + final var ui = new CookingUi(new JsonUiProgram(List.of(Map.of("ui/furnace_backgrounds.json", """ -+ {"namespace":"furnace","furnace_screen":{"$screen_content":"furnace.background_probe"}, ++ {"namespace":"furnace","furnace_screen":{"type":"screen","$screen_content":"furnace.background_probe","controls":[{"root_screen_panel@$screen_content":{}}]}, + "background_probe":{"type":"panel","controls":[ + {"inventory":{"type":"grid","collection_name":"inventory_items","grid_item_template":"furnace.cell"}}, + {"hotbar":{"type":"grid","collection_name":"hotbar_items","grid_item_template":"furnace.cell"}}, @@ -9063,7 +11216,7 @@ index 000000000..b4c2d9b78 + new CookingUi.Selection(selected, 0), catalog, source.visibleRecipes()); + final var view = new CookingUi.MenuView(initial.menuId(), initial.family(), initial.title(), slots, + presentation, initial.progress(), book); -+ final var scene = CookingUi.fromProgram(ui.exportProgram(view.family(), view.title())).resolve(view); ++ final var scene = CookingUi.fromProgram(ui.exportProgram(view)).resolve(view); + int cells = 0; + for (final var node : nodes(scene.getAsJsonObject("root"))) { + if (!node.has("layer")) { @@ -9086,9 +11239,16 @@ index 000000000..b4c2d9b78 + final var ui = new ResourcePackStorage(List.of()).getCookingUi(); + for (final var family : CookingUi.Family.values()) { + final var view = menu(family); -+ final var exported = ui.exportProgram(family, view.title()); ++ final var exported = ui.exportProgram(view); + final var replay = CookingUi.fromProgram(exported); -+ assertEquals(ui.resolve(view), replay.resolve(view)); ++ final var scene = replay.resolve(view); ++ assertEquals(ui.resolve(view), scene); ++ final var root = scene.getAsJsonObject("root"); ++ assertEquals("screen", root.get("type").getAsString()); ++ assertTrue(root.get("close_on_player_hurt").getAsBoolean()); ++ assertTrue(root.getAsJsonArray("button_mappings").asList().stream().map(JsonElement::getAsJsonObject) ++ .anyMatch(mapping -> mapping.get("from_button_id").getAsString().equals("button.menu_tab_left") ++ && mapping.get("to_button_id").getAsString().equals("button.left_panel_tab_decrement"))); + final var renamed = new CookingUi.MenuView(view.menuId(), family, new ContainerUi.Title("Java caption", false), + view.slots(), view.cursor(), view.progress(), view.book()); + assertEquals(replay.resolve(view), replay.resolve(renamed)); @@ -9106,10 +11266,10 @@ index 000000000..b4c2d9b78 + final var upper = cookingPack(namespace, nativePath, 251, false); + final var storage = new ResourcePackStorage(List.of(upper, lower)); + final var scene = storage.getCookingUi().resolve(menu(family)); -+ assertEquals(251, scene.getAsJsonObject("root").getAsJsonArray("size").get(0).getAsInt()); ++ assertEquals(251, content(scene).getAsJsonArray("size").get(0).getAsInt()); + final var reversed = new ResourcePackStorage(List.of(lower, upper)); -+ assertEquals(173, reversed.getCookingUi().resolve(menu(family)) -+ .getAsJsonObject("root").getAsJsonArray("size").get(0).getAsInt()); ++ assertEquals(173, content(reversed.getCookingUi().resolve(menu(family))) ++ .getAsJsonArray("size").get(0).getAsInt()); + } + } + @@ -9122,8 +11282,8 @@ index 000000000..b4c2d9b78 + final var unlisted = new ResourcePackStorage(List.of(cookingPack(namespace, path, 317, false))); + assertEquals(baseline, unlisted.getCookingUi().resolve(menu(family))); + final var declared = new ResourcePackStorage(List.of(cookingPack(namespace, path, 317, true))); -+ assertEquals(317, declared.getCookingUi().resolve(menu(family)) -+ .getAsJsonObject("root").getAsJsonArray("size").get(0).getAsInt()); ++ assertEquals(317, content(declared.getCookingUi().resolve(menu(family))) ++ .getAsJsonArray("size").get(0).getAsInt()); + } + } + @@ -9153,14 +11313,14 @@ index 000000000..b4c2d9b78 + @Test + void independentPackRootsRoundTripWithoutInventingMissingDefaultPanels() { + final var override = new JsonUiProgram(List.of(Map.of("ui/furnace_override.json", """ -+ {"namespace":"furnace","furnace_screen":{"$screen_content":"furnace.custom"}, ++ {"namespace":"furnace","furnace_screen":{"type":"screen","$screen_content":"furnace.custom","controls":[{"root_screen_panel@$screen_content":{}}]}, + "custom":{"type":"panel","controls":[{"caption":{"type":"label","text":"$container_title"}}]}} + """.getBytes(StandardCharsets.UTF_8)))); + final var ui = new CookingUi(override); + final var initial = menu(CookingUi.Family.FURNACE); + final var view = new CookingUi.MenuView(initial.menuId(), initial.family(), new ContainerUi.Title("Authored caption", false), + initial.slots(), initial.cursor(), initial.progress(), initial.book()); -+ final var exported = ui.exportProgram(view.family(), view.title()); ++ final var exported = ui.exportProgram(view); + assertEquals(ui.resolve(view), CookingUi.fromProgram(exported).resolve(view)); + assertEquals(view.title().raw(), nodes(ui.resolve(view).getAsJsonObject("root")).getLast().get("text").getAsString()); + exported.getAsJsonObject("globals").addProperty("$localize_title", "false"); @@ -9168,13 +11328,13 @@ index 000000000..b4c2d9b78 + final var missingPanel = new JsonUiProgram(List.of(Map.of("ui/furnace_override.json", """ + {"namespace":"furnace","furnace_screen":{"type":"screen"}} + """.getBytes(StandardCharsets.UTF_8)))); -+ assertThrows(IllegalArgumentException.class, () -> new CookingUi(missingPanel).resolve(view)); ++ assertTrue(new CookingUi(missingPanel).resolve(view).getAsJsonObject("root").getAsJsonArray("children").isEmpty()); + } + + @Test + void authoredLayoutBindingsUseNativeDefaultsAndForcedRecipeBookState() { + final var program = new JsonUiProgram(List.of(Map.of("ui/furnace_layout.json", """ -+ {"namespace":"furnace","furnace_screen":{"$screen_content":"furnace.layout_probe"}, ++ {"namespace":"furnace","furnace_screen":{"type":"screen","$screen_content":"furnace.layout_probe","controls":[{"root_screen_panel@$screen_content":{}}]}, + "layout_probe":{"type":"panel","controls":[ + {"inventory":{"type":"panel","bindings":[{"binding_name":"#is_survival_layout","binding_name_override":"#visible"}]}}, + {"recipes":{"type":"panel","bindings":[{"binding_name":"#is_recipe_book_layout","binding_name_override":"#visible"}]}} @@ -9189,7 +11349,7 @@ index 000000000..b4c2d9b78 + final var view = new CookingUi.MenuView(initial.menuId(), initial.family(), initial.title(), initial.slots(), + initial.cursor(), initial.progress(), book); + final var scene = new CookingUi(program).resolve(view); -+ final var children = scene.getAsJsonObject("root").getAsJsonArray("children"); ++ final var children = content(scene).getAsJsonArray("children"); + assertEquals(layout, book.layout()); + assertEquals(!forced && layout == 1, children.get(0).getAsJsonObject().get("visible").getAsBoolean()); + assertEquals(forced || layout == 0 || layout >= 2, children.get(1).getAsJsonObject().get("visible").getAsBoolean()); @@ -9200,7 +11360,7 @@ index 000000000..b4c2d9b78 + @Test + void authoredSearchBoxesRetainNativeTextThroughProgramReplay() { + final var program = new JsonUiProgram(List.of(Map.of("ui/furnace_search.json", """ -+ {"namespace":"furnace","furnace_screen":{"$screen_content":"furnace.search_probe"}, ++ {"namespace":"furnace","furnace_screen":{"type":"screen","$screen_content":"furnace.search_probe","controls":[{"root_screen_panel@$screen_content":{}}]}, + "search_probe":{"type":"edit_box","bindings":[ + {"binding_name":"#text_box_item_name","binding_name_override":"#text_edit_box_text"} + ]}} @@ -9214,15 +11374,15 @@ index 000000000..b4c2d9b78 + source.catalog(), source.visibleRecipes()); + final var view = new CookingUi.MenuView(initial.menuId(), CookingUi.Family.FURNACE, initial.title(), initial.slots(), + initial.cursor(), initial.progress(), book); -+ final var replay = CookingUi.fromProgram(ui.exportProgram(view.family(), view.title())); -+ assertEquals(query, replay.resolve(view).getAsJsonObject("root").get("text_edit_box_text").getAsString()); ++ final var replay = CookingUi.fromProgram(ui.exportProgram(view)); ++ assertEquals(query, content(replay.resolve(view)).get("text_edit_box_text").getAsString()); + } + } + + @Test + void selectionFollowsResolvedCollectionIndicesAfterSortingAndFiltering() { + final var program = new JsonUiProgram(List.of(Map.of("ui/furnace_selection.json", """ -+ {"namespace":"furnace","furnace_screen":{"$screen_content":"furnace.selection_probe"}, ++ {"namespace":"furnace","furnace_screen":{"type":"screen","$screen_content":"furnace.selection_probe","controls":[{"root_screen_panel@$screen_content":{}}]}, + "selection_probe":{"type":"panel","controls":[ + {"recipes":{"type":"grid","collection_name":"recipe_book","grid_item_template":"furnace.cell"}}, + {"inventory":{"type":"grid","collection_name":"inventory_items","grid_item_template":"furnace.cell"}}, @@ -9247,7 +11407,7 @@ index 000000000..b4c2d9b78 + new CookingUi.Selection(collection, index), source.catalog(), visible); + final var view = new CookingUi.MenuView(initial.menuId(), initial.family(), initial.title(), initial.slots(), + initial.cursor(), initial.progress(), book); -+ final var replay = CookingUi.fromProgram(ui.exportProgram(view.family(), view.title())); ++ final var replay = CookingUi.fromProgram(ui.exportProgram(view)); + final var cells = nodes(replay.resolve(view).getAsJsonObject("root")).stream() + .filter(node -> node.has("collection_index") && node.has("visible")).toList(); + assertEquals(39 + visible.size(), cells.size()); @@ -9267,7 +11427,7 @@ index 000000000..b4c2d9b78 + @Test + void categoryTabsCloseEmptyGapsAlongTheNativeLayoutAxis() { + final var program = new JsonUiProgram(List.of(Map.of("ui/furnace_tabs.json", """ -+ {"namespace":"furnace","furnace_screen":{"$screen_content":"furnace.tab_probe"}, ++ {"namespace":"furnace","furnace_screen":{"type":"screen","$screen_content":"furnace.tab_probe","controls":[{"root_screen_panel@$screen_content":{}}]}, + "tab_probe":{"type":"panel","controls":[ + {"food":{"type":"panel","offset":"#offset","bindings":[{"binding_name":"#food_tab_offset","binding_name_override":"#offset"}]}}, + {"items":{"type":"panel","offset":"#offset","bindings":[{"binding_name":"#items_tab_offset","binding_name_override":"#offset"}]}}, @@ -9284,7 +11444,7 @@ index 000000000..b4c2d9b78 + source.tabLabel(), source.searchText(), 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 children = new CookingUi(program).resolve(view).getAsJsonObject("root").getAsJsonArray("children"); ++ final var children = content(new CookingUi(program).resolve(view)).getAsJsonArray("children"); + for (int category = 0; category < 3; category++) { + final var offset = children.get(category).getAsJsonObject().getAsJsonArray("offset"); + final int distance = expectedShifts[food * 2 + items][category] * (forced ? 29 : 25); @@ -9311,7 +11471,7 @@ index 000000000..b4c2d9b78 + && node.get("collection_index").getAsInt() == 0)); + } + final var recipes = nodes.stream().filter(node -> node.has("recipe_id")).toList(); -+ assertEquals(Set.of(7, 19), recipes.stream().map(node -> node.get("recipe_id").getAsInt()).collect(java.util.stream.Collectors.toSet())); ++ assertEquals(Set.of(7, 19), recipes.stream().map(node -> node.get("recipe_id").getAsInt()).collect(Collectors.toSet())); + assertTrue(recipes.stream().allMatch(node -> !node.has("inventory_slot") && !node.has("inventory_actions"))); + assertTrue(recipes.stream().anyMatch(node -> node.has("recipe_actions") && node.get("recipe_id").getAsInt() == 7)); + assertTrue(recipes.stream().filter(node -> node.get("collection_index").getAsInt() == 0) @@ -9342,6 +11502,49 @@ index 000000000..b4c2d9b78 + } + + @Test ++ void recipeAuthorityRequiresTheRecipeAtItsVisibleCollectionIndex() { ++ final var ui = new ResourcePackStorage(List.of()).getCookingUi(); ++ final var menu = menu(CookingUi.Family.FURNACE); ++ for (final boolean changeRecipe : new boolean[]{false, true}) { ++ final var scene = ui.resolve(menu); ++ final var control = nodes(scene.getAsJsonObject("root")).stream() ++ .filter(node -> node.has("recipe_actions") && node.get("collection_index").getAsInt() == 0) ++ .findFirst().orElseThrow(); ++ assertEquals(19, control.get("recipe_id").getAsInt()); ++ control.addProperty(changeRecipe ? "recipe_id" : "collection_index", changeRecipe ? 7 : 1); ++ assertThrows(IllegalArgumentException.class, () -> CookingUi.actionScope(scene, menu.book())); ++ } ++ final var source = menu.book(); ++ final var book = new CookingUi.Book(source.layout(), source.forcedRecipeBookLayout(), source.tab(), source.filtering(), ++ source.foodCount(), source.itemCount(), source.blockCount(), source.tabLabel(), source.searchText(), ++ source.selection(), source.catalog(), Set.of(7)); ++ final var view = new CookingUi.MenuView(menu.menuId(), menu.family(), menu.title(), menu.slots(), menu.cursor(), menu.progress(), book); ++ final var scene = ui.resolve(view); ++ assertEquals(Set.of(7), CookingUi.actionScope(scene, book).actions().keySet()); ++ final var control = nodes(scene.getAsJsonObject("root")).stream().filter(node -> node.has("recipe_actions")).findFirst().orElseThrow(); ++ assertEquals(0, control.get("collection_index").getAsInt()); ++ control.addProperty("collection_index", 1); ++ assertThrows(IllegalArgumentException.class, () -> CookingUi.actionScope(scene, book)); ++ } ++ ++ @Test ++ void recipeAuthorityRejectsMissingAndNonIntegerCollectionIndices() { ++ final var menu = menu(CookingUi.Family.FURNACE); ++ final var ui = new ResourcePackStorage(List.of()).getCookingUi(); ++ final var original = ui.resolve(menu); ++ for (final JsonElement index : List.of(new JsonPrimitive(0.0), new JsonPrimitive("0"), new JsonPrimitive(true), ++ JsonNull.INSTANCE, new JsonPrimitive(-1), new JsonPrimitive(Integer.MAX_VALUE), new JsonPrimitive(2147483648L))) { ++ final var scene = original.deepCopy(); ++ final var control = nodes(scene.getAsJsonObject("root")).stream().filter(node -> node.has("recipe_actions")).findFirst().orElseThrow(); ++ control.add("collection_index", index); ++ assertThrows(IllegalArgumentException.class, () -> CookingUi.actionScope(scene, menu.book())); ++ } ++ final var control = nodes(original.getAsJsonObject("root")).stream().filter(node -> node.has("recipe_actions")).findFirst().orElseThrow(); ++ control.remove("collection_index"); ++ assertThrows(IllegalArgumentException.class, () -> CookingUi.actionScope(original, menu.book())); ++ } ++ ++ @Test + void recipeAuthorityRequiresKnownStatesOnlyAlongPathsThatCanDispatchRecipes() { + final var menu = menu(CookingUi.Family.FURNACE); + final var scene = new ResourcePackStorage(List.of()).getCookingUi().resolve(menu); @@ -9408,7 +11611,7 @@ index 000000000..b4c2d9b78 + for (final boolean filtering : new boolean[]{false, true}) { + final var book = new CookingUi.Book(source.layout(), source.forcedRecipeBookLayout(), source.tab(), filtering, + evaluations.length, 0, 0, source.tabLabel(), source.searchText(), source.selection(), catalog, -+ IntStream.range(0, evaluations.length).boxed().collect(java.util.stream.Collectors.toSet())); ++ IntStream.range(0, evaluations.length).boxed().collect(Collectors.toSet())); + final var expected = filtering ? List.of(1, 5, 3, 6, 0, 4) : List.of(1, 5, 0, 3, 4, 6); + assertEquals(expected, book.recipes().stream().map(CookingUi.Recipe::id).toList()); + assertEquals(catalog, book.catalog()); @@ -9526,7 +11729,7 @@ index 000000000..b4c2d9b78 + assertThrows(IllegalArgumentException.class, () -> new CookingUi.Book(2, false, 1, false, 1, 1, 1, "", "", new CookingUi.Selection("", -1), List.of(recipe, recipe), Set.of(recipe.id()))); + assertThrows(IllegalArgumentException.class, () -> new CookingUi.Book(2, false, 1, false, 1, 1, 1, "", "", new CookingUi.Selection("", -1), List.of(recipe), Set.of(999))); + final var brokenOverride = new JsonUiProgram(List.of(Map.of("ui/furnace_override.json", """ -+ {"namespace":"furnace","furnace_screen":{"$screen_content":"furnace.broken"}, ++ {"namespace":"furnace","furnace_screen":{"type":"screen","$screen_content":"furnace.broken","controls":[{"root_screen_panel@$screen_content":{}}]}, + "broken":{"type":"panel","controls":[{"missing@furnace.missing":{}}]}} + """.getBytes(StandardCharsets.UTF_8)))); + assertThrows(IllegalArgumentException.class, () -> new CookingUi(brokenOverride).resolve(valid)); @@ -9542,6 +11745,12 @@ index 000000000..b4c2d9b78 + new CookingUi.Progress(100, 100, 0, Integer.MAX_VALUE), book); + } + ++ private static JsonObject content(final JsonObject scene) { ++ return nodes(scene.getAsJsonObject("root")).stream() ++ .filter(node -> node.has("name") && node.get("name").getAsString().equals("root_screen_panel")) ++ .findFirst().orElseThrow(); ++ } ++ + private static List nodes(final JsonObject root) { + final List result = new ArrayList<>(); + result.add(root); @@ -9753,10 +11962,10 @@ index 000000000..de9cde9f8 +} 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 000000000..ea1922289 +index 000000000..86db18f09 --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/JsonUiProgramTest.java -@@ -0,0 +1,475 @@ +@@ -0,0 +1,556 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -9952,6 +12161,87 @@ index 000000000..ea1922289 + } + + @Test ++ void selfReferencingDefinitionsPreserveTheirControlsAcrossExport() { ++ final var ui = program(""" ++ {"namespace":"example","root@example.root":{"type":"panel","size":[80,40],"controls":[ ++ {"caption":{"type":"label","text":"Ready"}}]}} ++ """); ++ final var scene = ui.resolve("example.root", new Bindings()); ++ assertEquals(80, scene.root().getAsJsonArray("size").get(0).getAsInt()); ++ assertEquals("Ready", scene.root().getAsJsonArray("children").get(0).getAsJsonObject().get("text").getAsString()); ++ assertEquals(Set.of("example.root"), scene.requiredDefinitions()); ++ assertEquals(scene.root(), JsonUiProgram.fromProgram(ui.export(scene.requiredDefinitions(), new JsonObject())) ++ .resolve("example.root", new Bindings()).root()); ++ } ++ ++ @Test ++ void cyclicInheritanceRetainsEachDefinitionsNearestValues() { ++ final var ui = program(""" ++ {"namespace":"example","first@second":{"type":"panel","alpha":0.25}, ++ "second@third":{"alpha":0.75},"third@first":{"size":[80,40]}} ++ """); ++ for (final var expected : Map.of("first", 0.25, "second", 0.75, "third", 0.25).entrySet()) { ++ final String name = "example." + expected.getKey(); ++ final var scene = ui.resolve(name, new Bindings()); ++ assertEquals(expected.getValue(), scene.root().get("alpha").getAsDouble()); ++ assertEquals(80, scene.root().getAsJsonArray("size").get(0).getAsInt()); ++ assertEquals(Set.of("example.first", "example.second", "example.third"), scene.requiredDefinitions()); ++ assertEquals(scene.root(), JsonUiProgram.fromProgram(ui.export(scene.requiredDefinitions(), new JsonObject())) ++ .resolve(name, new Bindings()).root()); ++ } ++ } ++ ++ @Test ++ void absentParentsAndAncestorNullsDoNotDiscardDeclaredControls() { ++ final var ui = program(""" ++ {"namespace":"example","first@absent":{"type":"label","text":"Inherited"}, ++ "second@first":{"text":null},"third@second":{}} ++ """); ++ assertEquals("Inherited", ui.resolve("example.first", new Bindings()).root().get("text").getAsString()); ++ assertTrue(ui.resolve("example.second", new Bindings()).root().get("text").isJsonNull()); ++ final var scene = ui.resolve("example.third", new Bindings()); ++ assertFalse(scene.root().has("text")); ++ assertEquals(Set.of("example.first", "example.second", "example.third"), scene.requiredDefinitions()); ++ assertEquals(scene.root(), JsonUiProgram.fromProgram(ui.export(scene.requiredDefinitions(), new JsonObject())) ++ .resolve("example.third", new Bindings()).root()); ++ final var reversed = program(""" ++ {"namespace":"example","third@second":{},"second@first":{"text":null}, ++ "first@absent":{"type":"label","text":"Inherited"}} ++ """); ++ final var forward = reversed.resolve("example.third", new Bindings()); ++ assertEquals("Inherited", forward.root().get("text").getAsString()); ++ assertEquals(forward.root(), JsonUiProgram.fromProgram(reversed.export(forward.requiredDefinitions(), new JsonObject())) ++ .resolve("example.third", new Bindings()).root()); ++ } ++ ++ @Test ++ void longInheritanceChainsResolveWithoutAllowingRecursiveControlExpansion() { ++ final JsonObject document = new JsonObject(); ++ document.addProperty("namespace", "example"); ++ final JsonObject base = new JsonObject(); ++ base.addProperty("type", "label"); ++ base.addProperty("text", "Inherited"); ++ document.add("base", base); ++ String parent = "base"; ++ for (int depth = 0; depth < 96; depth++) { ++ final String name = "ancestor_" + depth; ++ document.add(name + "@" + parent, new JsonObject()); ++ parent = name; ++ } ++ final var ui = program(document.toString()); ++ final String root = "example." + parent; ++ final var scene = ui.resolve(root, new Bindings()); ++ assertEquals("Inherited", scene.root().get("text").getAsString()); ++ assertEquals(97, scene.requiredDefinitions().size()); ++ assertEquals(scene.root(), JsonUiProgram.fromProgram(ui.export(scene.requiredDefinitions(), new JsonObject())) ++ .resolve(root, new Bindings()).root()); ++ final var recursive = program(""" ++ {"namespace":"example","root":{"type":"panel","controls":[{"child@example.root":{}}]}} ++ """); ++ assertThrows(IllegalArgumentException.class, () -> recursive.resolve("example.root", new Bindings())); ++ } ++ ++ @Test + void partialDefinitionsPreserveTheirParentUntilAnExplicitParentChangesIt() { + final String lower = """ + {"namespace":"example","first":{"type":"label","text":"First","size":[10,10]}, @@ -10232,6 +12522,58 @@ index 000000000..ea1922289 + } + +} +diff --git a/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/NativeSearchNormalizationTest.java b/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/NativeSearchNormalizationTest.java +new file mode 100644 +index 000000000..b79b89724 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/NativeSearchNormalizationTest.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.resourcepack.ui; ++ ++import java.nio.charset.StandardCharsets; ++import org.junit.jupiter.api.Test; ++import static org.junit.jupiter.api.Assertions.*; ++ ++class NativeSearchNormalizationTest { ++ ++ @Test ++ void composesCompatibilityAndFullCaseFoldWithoutDroppingDefaultIgnorables() { ++ assertEquals("strasse ffi café kક", text("Straße ffi CAFÉ Kક")); ++ assertEquals("ii̇ıi", text("Iİıi")); ++ assertEquals("οσοσ", text("ΟΣος")); ++ assertEquals("가각", text("가각")); ++ final String zeroWidth = Character.toString(0x200B); ++ final String softHyphen = Character.toString(0x00AD); ++ assertEquals("a" + zeroWidth + "b" + softHyphen, text("A" + zeroWidth + "B" + softHyphen)); ++ // These letters acquired case-fold mappings after the target's Unicode 5.0 tables. ++ assertEquals("Აẞ", text("Აẞ")); ++ } ++ ++ @Test ++ void validatesTheWholeInputBeforeNulTruncationAndReturnsIndependentBytes() { ++ assertArrayEquals(new byte[]{'a'}, NativeSearchNormalization.normalize(new byte[]{'A', 0, 'B'})); ++ for (final byte[] input : new byte[][]{ ++ {(byte) 0xFF}, {(byte) 0xC0, (byte) 0xAF}, {'A', 0, (byte) 0xFF}, ++ {(byte) 0xED, (byte) 0xA0, (byte) 0x80}, {(byte) 0xEF, (byte) 0xB7, (byte) 0x90}, ++ {(byte) 0xF4, (byte) 0x90, (byte) 0x80, (byte) 0x80}, {(byte) 0xE2, (byte) 0x82}}) { ++ final byte[] result = NativeSearchNormalization.normalize(input); ++ assertArrayEquals(input, result); ++ assertNotSame(input, result); ++ result[0] ^= 1; ++ assertNotEquals(input[0], result[0]); ++ } ++ } ++ ++ private static String text(final String value) { ++ return new String(NativeSearchNormalization.normalize(value.getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8); ++ } ++ ++} diff --git a/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/NpcDialogueUiTest.java b/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/NpcDialogueUiTest.java new file mode 100644 index 000000000..6cadc7f3c @@ -10561,6 +12903,163 @@ index 000000000..a7430d107 + } + +} +diff --git a/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/RecipeCollectionTest.java b/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/RecipeCollectionTest.java +new file mode 100644 +index 000000000..d59b17e88 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/RecipeCollectionTest.java +@@ -0,0 +1,151 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.resourcepack.ui; ++ ++import java.util.ArrayList; ++import java.util.List; ++import org.junit.jupiter.api.Test; ++import static org.junit.jupiter.api.Assertions.*; ++ ++class RecipeCollectionTest { ++ ++ @Test ++ void partitionsWholeContiguousRunsAndKeepsRepresentativeIdentity() { ++ final var source = new Source(); ++ final var first = new Group(1000, 1); ++ final var second = new Group(1001, 1); ++ final var entries = List.of(entry(1, 2, first), entry(2, 0, first), entry(3, 1, second), entry(4, 3, second)); ++ final var collection = new RecipeCollection<>(source); ++ final var result = collection.refresh(entries.reversed(), true, true, false); ++ assertEquals(List.of(2, 1, 4, 3), ids(result.ordered())); ++ assertEquals(List.of(4, 2), ids(result.representatives())); ++ assertEquals(4, result.eligibleCount()); ++ assertEquals(List.of(new RecipeCollection.Header(first, false), ++ new RecipeCollection.Header(second, false)), result.rows()); ++ assertEquals(List.of(4, 3, 2, 1), source.evaluations); ++ } ++ ++ @Test ++ void preservesFirstStateForHiddenAndUnavailableRunsUntilAnEligibleMemberAppears() { ++ final var first = new Group(1000, 1); ++ final var second = new Group(1001, 1); ++ final var collection = new RecipeCollection<>(new Source()); ++ final var result = collection.refresh(List.of(entry(1, 3, first), entry(2, 2, first), ++ entry(3, 2, second), entry(4, 3, second)), true, true, false); ++ assertEquals(List.of(3, 4, 1, 2), ids(result.ordered())); ++ assertEquals(2, result.eligibleCount()); ++ assertTrue(result.representatives().isEmpty()); ++ // Grouped projection includes valid rows beyond the flat eligible prefix. ++ assertEquals(2, result.rows().size()); ++ } ++ ++ @Test ++ void skipsEvaluationOnlyForUnfilteredGroupedRows() { ++ final var source = new Source(); ++ final var collection = new RecipeCollection<>(source); ++ final var entries = List.of(entry(1, 3, null), entry(2, 2, null), entry(3, 0, null), entry(4, 1, null)); ++ assertEquals(List.of(1, 2, 3, 4), ids(collection.refresh(entries, true, false, false).ordered())); ++ assertTrue(source.evaluations.isEmpty()); ++ assertEquals(List.of(3, 2, 4), ids(collection.refresh(entries, false, false, false).eligibleItems())); ++ assertEquals(List.of(1, 2, 3, 4), source.evaluations); ++ source.evaluations.clear(); ++ assertEquals(List.of(3, 4, 2), ids(collection.refresh(entries, false, true, false).eligibleItems())); ++ assertEquals(List.of(1, 2, 3, 4), source.evaluations); ++ } ++ ++ @Test ++ void retainsExpansionAcrossOrderingFilteringAndFlatRefreshUntilReset() { ++ final var first = new Group(1000, 1); ++ final var second = new Group(1001, 1); ++ final var entries = List.of(entry(1, 2, first), entry(2, 0, first), entry(3, 1, second), entry(4, 3, second)); ++ final var collection = new RecipeCollection<>(new Source()); ++ collection.refresh(entries, true, false, false); ++ assertEquals(new RecipeCollection.Toggle(0, 2, true), collection.toggleHeader(first)); ++ final var expanded = collection.snapshot(); ++ assertEquals(4, expanded.rows().size()); ++ assertEquals(4, collection.refresh(entries.reversed(), true, true, false).rows().size()); ++ collection.refresh(entries, false, false, false); ++ assertNull(collection.toggleHeader(first)); ++ assertEquals(4, collection.refresh(entries, true, false, false).rows().size()); ++ assertEquals(2, collection.refresh(entries, true, false, true).rows().size()); ++ assertEquals(4, expanded.rows().size()); ++ assertThrows(UnsupportedOperationException.class, () -> expanded.rows().clear()); ++ assertThrows(UnsupportedOperationException.class, () -> expanded.ordered().clear()); ++ } ++ ++ @Test ++ void separatesCatalogGroupIdentityFromHeaderIconAndCount() { ++ final var first = new Group(1000, 1); ++ final var alias = new Group(1000, 1); ++ final var changedCount = new Group(1000, 2); ++ final var collection = new RecipeCollection<>(new Source()); ++ collection.refresh(List.of(entry(1, 0, first), entry(2, 0, alias)), true, true, false); ++ assertEquals(2, collection.snapshot().rows().size()); ++ assertEquals(new RecipeCollection.Toggle(0, 1, true), collection.toggleHeader(alias)); ++ assertEquals(List.of(new RecipeCollection.Header(first, true), ++ new RecipeCollection.Member(entry(1, 0, first), first), ++ new RecipeCollection.Header(alias, false)), collection.snapshot().rows()); ++ assertEquals(4, collection.refresh(List.of(entry(2, 0, alias), entry(1, 0, first)), true, true, false).rows().size()); ++ assertNull(collection.toggleHeader(changedCount)); ++ assertEquals(List.of(new RecipeCollection.Header(changedCount, false)), ++ collection.refresh(List.of(entry(3, 0, changedCount)), true, true, false).rows()); ++ } ++ ++ @Test ++ void skipsInvalidEntriesWithoutBreakingPendingGroupsOrEvaluatingThem() { ++ final var source = new Source(); ++ final var group = new Group(1000, 1); ++ final var entries = List.of(entry(1, 2, group), new Entry(2, -1, null, false), entry(3, 0, group)); ++ final var collection = new RecipeCollection<>(source); ++ final var result = collection.refresh(entries, true, true, false); ++ assertEquals(List.of(1, 3, 2), ids(result.ordered())); ++ assertEquals(List.of(1), ids(result.representatives())); ++ assertEquals(List.of(1, 3), source.evaluations); ++ assertEquals(2, result.eligibleCount()); ++ assertEquals(1, result.rows().size()); ++ assertEquals(List.of(3, 1), ids(collection.refresh(entries, false, true, false).ordered())); ++ assertThrows(IllegalArgumentException.class, () -> collection.refresh(List.of(entry(1, 4, null)), false, false, false)); ++ assertDoesNotThrow(() -> collection.refresh(List.of(entry(1, 4, null)), true, false, false)); ++ } ++ ++ private static Entry entry(final int identity, final int state, final Group group) { ++ return new Entry(identity, state, group, true); ++ } ++ ++ private static List ids(final List entries) { ++ return entries.stream().map(Entry::identity).toList(); ++ } ++ ++ private record Entry(int identity, int state, Group group, boolean valid) { } ++ ++ private record Group(int icon, int count) { } ++ ++ private static final class Source implements RecipeCollection.Source { ++ private final List evaluations = new ArrayList<>(); ++ ++ @Override ++ public int evaluate(final Entry entry) { ++ this.evaluations.add(entry.identity); ++ return entry.state; ++ } ++ ++ @Override ++ public boolean valid(final Entry entry) { ++ return entry.valid; ++ } ++ ++ @Override ++ public Group group(final Entry entry) { ++ return entry.group; ++ } ++ ++ @Override ++ public boolean sameHeader(final Group first, final Group second) { ++ return first.equals(second); ++ } ++ } ++ ++} diff --git a/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/ServerCustomFormUiTest.java b/src/test/java/net/raphimc/viabedrock/api/resourcepack/ui/ServerCustomFormUiTest.java new file mode 100644 index 000000000..f2193a65c @@ -11849,5 +14348,137 @@ index 000000000..29be2f0c8 + } + +} +diff --git a/src/tool/java/net/raphimc/viabedrock/tool/generator/SearchUnicodeDataGenerator.java b/src/tool/java/net/raphimc/viabedrock/tool/generator/SearchUnicodeDataGenerator.java +new file mode 100644 +index 000000000..468017ff8 +--- /dev/null ++++ b/src/tool/java/net/raphimc/viabedrock/tool/generator/SearchUnicodeDataGenerator.java +@@ -0,0 +1,126 @@ ++/* ++ * 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 ++ * ++ * Repackages licensed utf8proc 1.1.5 Unicode data into a modified Java representation. ++ * Copyright (c) 2009 Public Software Group e. V.; Copyright (c) 1991-2007 Unicode, Inc. ++ * See META-INF/licenses/utf8proc.txt for the retained permission notices. ++ */ ++package net.raphimc.viabedrock.tool.generator; ++ ++import java.io.DataOutputStream; ++import java.io.IOException; ++import java.nio.charset.StandardCharsets; ++import java.nio.file.Files; ++import java.nio.file.Path; ++import java.security.MessageDigest; ++import java.util.ArrayList; ++import java.util.Arrays; ++import java.util.HexFormat; ++import java.util.List; ++import java.util.regex.Pattern; ++import java.util.zip.GZIPOutputStream; ++import net.raphimc.viabedrock.tool.ToolArgs; ++import net.raphimc.viabedrock.tool.ToolPaths; ++ ++/** Generates search data from public utf8proc sources, independently of any game installation. */ ++public final class SearchUnicodeDataGenerator { ++ ++ // https://www.public-software-group.org/pub/projects/utf8proc/v1.1.5/utf8proc-v1.1.5.tar.gz ++ private static final String SOURCE_DIGEST = "5e437f8ae4b0ecfd076c57e4a9e43e5a3f20fc10649e95b4c34ee6b2da7346a0"; ++ ++ private SearchUnicodeDataGenerator() { } ++ ++ public static void main(final String[] args) throws Exception { ++ final ToolArgs toolArgs = ToolArgs.parse(args); ++ final byte[] source = Files.readAllBytes(Path.of(toolArgs.require("source", "Pass utf8proc 1.1.5 utf8proc_data.c."))); ++ if (!SOURCE_DIGEST.equals(HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(source)))) { ++ throw new IllegalArgumentException("Search normalization requires the pinned utf8proc 1.1.5 data"); ++ } ++ final String text = new String(source, StandardCharsets.UTF_8); ++ final int[] sequences = integers(section(text, "utf8proc_sequences")); ++ final int[] stage1 = integers(section(text, "utf8proc_stage1table")); ++ final int[] stage2 = integers(section(text, "utf8proc_stage2table")); ++ final int[] combinations = integers(section(text, "utf8proc_combinations")); ++ final var properties = new ArrayList(); ++ final var rows = Pattern.compile("\\{([^{}]+)}").matcher(section(text, "utf8proc_properties")); ++ while (rows.find()) { ++ final String[] fields = rows.group(1).split(","); ++ properties.add(new Property(Integer.parseInt(fields[1].trim()), Integer.parseInt(fields[9].trim()), ++ Integer.parseInt(fields[10].trim()), Boolean.parseBoolean(fields[11].trim()), ++ sequence(fields[4].trim(), sequences), fields.length > 15 ? sequence(fields[15].trim(), sequences) : new int[0])); ++ } ++ if (stage1.length != 4352 || stage2.length != 23296 || properties.size() != 5618 || combinations.length != 22500) { ++ throw new IllegalArgumentException("Unexpected pinned Unicode table dimensions"); ++ } ++ final List points = new ArrayList<>(); ++ for (int codePoint = 0; codePoint < 0x110000; codePoint++) { ++ if (properties.get(stage2[stage1[codePoint >> 8] + (codePoint & 255)]).needed()) { ++ points.add(codePoint); ++ } ++ } ++ final Path output = toolArgs.path("output", ToolPaths.ASSETS.resolve("data/search_unicode_5_0.bin.gz")); ++ Files.createDirectories(output.toAbsolutePath().getParent()); ++ try (var data = new DataOutputStream(new GZIPOutputStream(Files.newOutputStream(output)))) { ++ data.writeInt(points.size()); ++ for (final int codePoint : points) { ++ final Property property = properties.get(stage2[stage1[codePoint >> 8] + (codePoint & 255)]); ++ data.writeInt(codePoint); ++ data.writeInt(property.combining); ++ data.writeInt(property.first); ++ data.writeInt(property.second); ++ data.writeInt(property.excluded ? 1 : 0); ++ writeSequence(data, property.decomposition); ++ writeSequence(data, property.fold); ++ } ++ writeSequence(data, combinations); ++ } ++ } ++ ++ private static String section(final String source, final String name) { ++ final String prefix = name + "[] = {"; ++ final int start = source.indexOf(prefix); ++ if (start < 0) { ++ throw new IllegalArgumentException("Missing Unicode table " + name); ++ } ++ return source.substring(start + prefix.length(), source.indexOf("};", start)); ++ } ++ ++ private static int[] integers(final String source) { ++ return Arrays.stream(source.split(",")).map(String::trim).filter(value -> !value.isEmpty()).mapToInt(Integer::parseInt).toArray(); ++ } ++ ++ private static int[] sequence(final String pointer, final int[] sequences) { ++ if ("NULL".equals(pointer)) { ++ return new int[0]; ++ } ++ final String prefix = "utf8proc_sequences + "; ++ if (!pointer.startsWith(prefix)) { ++ throw new IllegalArgumentException("Unexpected Unicode sequence pointer"); ++ } ++ final int first = Integer.parseInt(pointer.substring(prefix.length())); ++ int end = first; ++ while (sequences[end] != -1) { ++ end++; ++ } ++ return Arrays.copyOfRange(sequences, first, end); ++ } ++ ++ private static void writeSequence(final DataOutputStream output, final int[] sequence) throws IOException { ++ output.writeInt(sequence.length); ++ for (final int part : sequence) { ++ output.writeInt(part); ++ } ++ } ++ ++ private record Property(int combining, int first, int second, boolean excluded, int[] decomposition, int[] fold) { ++ ++ private boolean needed() { ++ return this.combining != 0 || this.first >= 0 || this.second >= 0 || this.excluded ++ || this.decomposition.length != 0 || this.fold.length != 0; ++ } ++ ++ } ++ ++} -- 2.56.0 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 da1b490a..480f9b33 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 @@ -19,6 +19,12 @@ The full stack replays and all four projects build. Java verification passes 2,204 tests, with 152 optional skips and no failures or errors. The focused cooking, transport and container suite passes all 61 tests. +The cooking controller now supplies its seven native tab and layout constants. +The pinned publisher at `0x146c44040` assigns food/items/blocks/search/inventory indices `1..5` and survival/recipe-book layouts `1/2`. +Without these globals, grouped toggles lose their forced indices during scene resolution. +The bundled-scene regression checks resolved navigation and layout groups across export/import in all three cooking families. +It fails before this change. This restores control identity; complete pointer, keyboard, controller, and visual behavior require separate evidence. + ## Brewing program and counters The pinned Bedrock 1.26.51.1 assets supply the brewing screen, three indexed bottle cells, and fixed ingredient and fuel collections. @@ -214,3 +220,283 @@ This contract does not establish native quantity or cursor behavior. The complete four-project build passes 2,344 tests, with 152 optional skips. Core Checkstyle and the mode-preserving request/result codec tests pass. All 102 core patches replay successfully. Offline tooling and patch metadata validation pass. + +## Cooking command phases + +Cooking action and result envelopes use version 5 and retain the controller mode and native event phase independently of the command. +Phases `0`, `1`, and `2` remain distinct through serialization and acknowledgement. +The original state-dispatch fixtures use phase `0` for initial primary selection and phase `2` for selected-state release. +The payload does not assign physical device meanings or transfer quantities to these phases. +Phase-free version 3 actions and unsupported phase values reject instead of acquiring a default phase. + +Six targeted codec tests pass, including all command/phase combinations, identity bounds, incomplete envelopes, strict reply flags, and unsupported phases. +The old codec fails the added phase-free-envelope regression because it accepts that action. +All 104 core patches replay to the identical tested tree. +The four-project build passes 2,362 tests with 212 optional skips and no failures or errors. +Core passes 1,496 tests; the add-on passes 843. +Main/test Checkstyle and offline validation pass. +All 1,386 embedded core class entries match the newly built core. +These transport prerequisites do not advertise cooking actions or complete their input and transaction backend. + +## Registered recipe input evidence + +A fresh private fixture runs the original service registry and scheduler startup before the base input and router constructors. +Eight native worker entries initialize their own readiness fields and reach condition waits with separate stacks and TLS. +The original scheduler publishes the queue lifetime handle; the fixture does not supply game readiness or lifetime flags. +Ordinary OS, CRT, allocation, and authored source/focus access remain explicit external boundaries. + +Original recipe registration, callback cloning, callback maps, event dispatch, and phase callbacks pass 48 raw-event controls. +Another 576 controls parse typed payloads through the original JsonCpp reader and parser. +Integer collection indices survive. Real `7.0`, strings, Boolean values, and other tested non-integer types yield index `-1`. +Phase `1` retains the index, command, source, and supplied screen coordinates; phase `2` clears the retained index. + +The 288 uniform-result and 96 independent-result controls preserve phase order and combine callback results with bitwise OR. +The router remains a recording boundary in these fixtures. +These checks do not establish physical input mapping, recipe-provider index validity, native selection effects, transactions, or live acceptance. +The [cooking reference](../../../docs/native-cooking-actions.md#input-event-delivery) records the raw fields and remaining boundaries. + +## Bind recipe authority to the displayed cell + +The scope validator previously accepted any visible recipe ID, independently of the control's collection index. +A control could name another visible recipe or omit its native index and still receive action authority. +The validator now compares both fields with the exact sorted and filtered visible collection. +It rejects missing, real-valued, negative, out-of-range, and mismatched indices. +Recipe previews retain their separate identity and cannot become inventory slot actions. + +Two behavioral regressions fail against the previous implementation. +They cover cross-cell identity changes, compacted visible indices, missing fields, JSON types, and integer bounds. +The focused cooking scene, message, and action suite passes 37 tests before the final stack replay. + +A fresh native fixture separately passes 240 source-resolution controls and six installed selection/release steps. +The original cooking manager, registered input graph, ingredient matcher, selected Item/name update, and release reset execute. +Matching ingredients select the output without item transfers; release clears the input state while retaining that selected output. +The fixture supplies recipe data and external game interfaces. It does not establish placement, repeated selection, full controller ownership, or live acceptance. +The [cooking reference](../../../docs/native-cooking-actions.md#resolve-recipe-event-sources) records the source map and evidence limits. + +All 104 core patches replay successfully after the change. +The full build passes 2,368 tests, with 212 optional skips and no failures or errors. +Main and test Checkstyle pass, as does offline stack validation. +All 1,386 core classes match the core JAR embedded in the Fabric add-on. + +A separate fresh first-placement fixture also reaches native request action assembly. +A single raw recipe click moves seven hotbar items, then five inventory items, into the empty ingredient slot. +The second Place action retains the first source's positive stack ID as its destination ID. +This result covers controlled native action objects, not complete request serialization or live acceptance. +The [first-placement reference](../../../docs/native-cooking-actions.md#controlled-first-placement) records the fixture boundaries. + +## Verify repeated native placement + +A fresh native process passes thirty input steps and physical snapshots across five quantity cases. +Primary selection fills the ingredient slot up to the supplied Item's capacity of 64. +Selecting the same recipe again returns its ingredients to combined player provider 12 and clears the selected output. +A third selection places the returned items again. Release preserves inventory while resetting the input state. +The fixture verifies conservation and the shared player-provider views. Cursor provider 59 remains empty. + +Each six-event sequence shares one open native request. Request submission and server acknowledgments do not occur. +The full controller/model-owner, other recipe types, complete serialization, live acceptance, and visual parity remain unverified. +The [repeated-placement reference](../../../docs/native-cooking-actions.md#repeat-placement-and-return) records the quantities and implementation implications. + +## Controller text in cooking programs + +Cooking exports now use the same menu snapshot as the published model. +Translation discovery includes the current tab label and authoritative title. +Computed search captions can therefore use the accepted language pack after export and import. +The exporter retains language keys and the existing translation lookup. + +The regression compares complete scenes and action scopes for all three cooking families. +It fails when export omits the controller label and passes with the dependency restored. +Default smoker captions still carry a Java key; their native fallback source remains unverified. +The pinned desktop search tab inherits an inline text field from `common.text_edit_control`. +Its parent follows `#is_left_tab_search`. A resolved scene from another tab did not establish the field's absence. +Native text callbacks, full input handling, and pixel parity remain open in production. +The [cooking action reference](../../../docs/native-cooking-actions.md#search-edit-callbacks) records 38 native edit/tick/clear controls and 304 search predicate controls. +The original predicate checks normalized primary names, then normalized secondary labels without formatting codes. +The primary helper selects custom or ordinary names. The ordinary secondary label includes enchantment text. +Complete name and label production, language selection, physical editing, and the field's enabled binding still need evidence. + +The pinned native tab-label getter also distinguishes filtered search captions from the all-recipes caption. +Its inventory tab uses the shared crafting survival key. +The shared navigation model now retains these exact keys. +Sixty unchanged native getter controls cover all families, both filter states, valid tabs, and signed invalid tabs. +The fixture verifies ingredient descriptors against the generated target enum. +Restoring either earlier label rule fails the native comparison; the corrected model passes. +Allocation and CRT copies are bounded test boundaries. +Field producers, final translation, and pixels remain separate checks. + +The final four-project build passes 2,487 tests, with 212 optional skips and no failures or errors. +The native navigation and caption suite runs without skips. +All 104 core and 38 add-on patches replay, and all 1,404 embedded core classes match. + +A controlled BDS 1.26.51.1 client fixture exercises fourteen preference changes and six recipe press/release replies. +All fourteen model caption keys match the native getter cases and the shipped English table. +Eleven visible caption draws match their exported translations. +Furnace, smoker, and blast-furnace cooking and same-world reconnect pass. +Two runtime inventories match 2,520 classes and four data resources each. +Normal cleanup removes the private observer, restores the driver, and leaves no managed-profile JVM. +The scene projection removes unfinished controls and mappings, so it does not establish full default-screen or native pixel parity. + + +## Mode-dependent recipe focus evidence + +New native controls distinguish direct recipe presses from the mode-`2` focus path. +Across 288 three-event controls, the original state-`45` entry stores the focused collection and index. +The following recipe press enters state `25` with a nonempty carried Item. +Modes `0`, `1`, and `3` retain their direct-press empty-cursor requirement. +The recipe-selection entry remains a recording boundary, so this evidence does not establish transfer effects. + +Seventy-two original getter controls establish that the input mode comes from the active device's map entry. +They cover missing device IDs and entries, all four tested mode values, and colliding bucket positions. +Active-device ID acquisition, physical event production, and complete controller construction remain supplied or unverified boundaries. +The [focus reference](../../../docs/native-cooking-actions.md#mode-dependent-recipe-focus) records functions, typed inputs, and limits. + +Version-five actions now transport the verified controller mode separately from command and phase. +Earlier versions reject instead of receiving an implicit mode or phase. +The core and client sessions implement initial mode-`2` focus and its owned carried-item selection path. +The Java mouse adapter supplies mode `1`; physical mode-`2` production and complete active-device integration remain unfinished. +The [transfer reference](../../../docs/native-cooking-actions.md#focused-carried-item-transfers) records the subsequent original effects and serialized requests. +Default carried-item screen controls and full native parity remain separate requirements. + +The final combined suites pass 2,621 tests, with 163 optional skips and no failures or errors. +All four projects build, and the final core rerun includes the integrated movement reference fixtures. +Cooking, camera, retained particle, attachable, and Iris GPU comparisons run without skips. +All 1,413 core classes match both packaged copies, and all 1,139 production client classes match the add-on. +The affected core and add-on stacks replay successfully. + +A fresh BDS 1.26.51.1 route verifies version-five mouse actions in furnace, smoker, and blast-furnace screens. +All six press/release replies retain context `1` and their original authority across newer frames. +Each real ingredient starts cooking, all three ordinary follow-up cases pass, and same-world reconnect passes. +Both runtime inventories match 2,551 project class resources and four data resources. +Normal cleanup removes the observer, restores the private driver change, and leaves no managed-profile JVM. +The controlled scene projection preserves all 39 real slots while it removes unfinished controls and mappings. +This run does not establish unchanged authored screens, physical mode-`2` input, carried-item screen support, or native pixels. + +## Shared cooking search APIs + +The shared UI patch now exports the query matcher, target normalizer, and raw edit-coalescing state. +These APIs do not register a channel or replace the default cooking screen. +The production menu still publishes an empty query until name producers, transport, and widget integration are complete. + +`CookingRecipeSearch` normalizes the query once and requests primary names lazily. +A primary match skips the secondary-label producer. An empty query skips both producers. +The secondary branch removes native formatting codes before normalization. +The matcher does not trim spaces, split words, or substitute Java preview names. + +`CookingSearchText` retains the latest pending raw string until a ready tick. +Equal edits still apply. An empty string remains a pending edit until that tick. +A guarded mode-two clear preserves applied and pending text. Other clear events discard pending text and apply an empty value. +The caller remains responsible for model-clear effects, settings refresh, physical field admission, and exact menu ownership. +Repeated native eligibility checks and full recipe-predicate state remain outside these APIs. + +The target uses utf8proc 1.1.5 tables for Unicode 5.0. +All 4,352 first-stage entries, 23,296 second-stage entries, 5,618 property records, and 22,500 composition entries match the immutable target data. +The data generator uses the checksum-pinned public source and retains its MIT and Unicode notices. +Its 45,618-byte compressed resource reproduces exactly. An altered public source fails before an existing output changes. + +Fresh comparisons cover 17,987 executions of the original native normalizer and 1,122,310 production Java inputs against the licensed public algorithm. +Both comparisons report zero differences. The Java cases include every code point, invalid encodings, NUL handling, and mixed composition sequences. +Eight focused tests pass without skips. Retained native controls cover 304 matching compositions, 22 edit/tick cases, and 16 clear cases. +Those fixtures supply names, labels, model interfaces, and caller effects. They do not establish production name acquisition or physical editing. +Four deliberate regressions fail: unnormalized queries, query formatting removal, early edits, and pending-text loss after a guarded clear. + +All four projects build with 2,634 passing tests, 163 optional skips, and no failures or errors. +The core and client stacks replay all 104 and 38 patches. +All eight search tests run without skips in this build. +The final packaged normalizer repeats the 1,122,310-input comparison with zero differences. +Its class and resource load from the verified JAR. All 1,417 core classes match both packaged copies. +The generated data and license notices also match across the standalone core, embedded core, and proxy. +All 1,139 production client classes match the add-on. + + +## Unfiltered primary names + +`NativeItemNames` shares custom-name presence, written-book title fallback, and ordinary appended text. +It preserves typed-byte append admission, group/body precedence, and color argument order. +The caller still owns the ordinary definition name and language lookup. +The native primary selector returns unfiltered text. Filtered display names and titles remain separate. + +Fresh controls execute the original 1.26.51.1 selector `142489b10`, base custom methods, written-book predicate `143f6d580`, and getter `143f6d2c0`. +The shared selector matches 2,286 applicable native rows. Three deliberate selector regressions fail by assertion. +The controls supply typed tag virtuals, locale, component and descriptor interfaces, the allocator, and CRT boundaries. +They do not establish full registry initialization, final language selection, physical editing, or native pixels. + +A separate complete native registry capture records all 1,949 constructor-owned naming dispatch tables. +All use ordinary-name method `1424ce2a0`. Only `written_book` overrides the custom-name predicate and getter. +The capture completes all 2,627 static cleanup callbacks without owned allocation or lock leaks. +Pre-packet constructor facts do not establish later component updates or full ordinary-key and secondary-label production. + + +The current four-project build passes 2,644 tests, with 163 optional skips and no failures or errors. +All 104 core patches and 38 client patches replay. All three selector tests run without skips. +All 1,419 core classes match the embedded add-on and proxy copies. All 1,139 client classes match the add-on. +Four focused packaged checks pass, including the registry's book-class dispatch. +The packaged selector matches all 2,286 applicable native rows. Production class origins are verified against the current standalone core JAR. +This checkpoint does not establish full cooking search or native screen and pixel parity. + +## Complete cooking screen hosts + +Cooking resolution now expands the authored screen host through the shared control expander. +It retains host input mappings, policies, variables, overlays and inherited hierarchy. +Independent cooking programs declare a screen root and their own content graph. +The resolver no longer invents a fallback content panel for an empty authored screen. +Other domain consumers retain their existing resolution APIs. + +The pinned desktop Furnace, Blast Furnace and Smoker hosts resolve without missing templates. +Each complete host round-trips exactly through program export and is accepted by the shared client scene parser. +The scenes remain within the existing payload and node limits. +All 23 targeted cooking program tests pass with title authority, layering, recipe/slot identity, selection and action scopes preserved. +These structural checks do not establish screen-controller, device or native pixel parity. +The combined build passes 2,745 tests with 163 optional skips. +Packaged production classes pass three desktop host checks and two real screen interaction regressions. +Pocket expansion reaches a self-parent in the original `furnace_screen_pocket.json`. +Neither acquired vanilla UI archive supplies a lower definition for it. +The native inheritance evidence below supersedes the earlier recursion rejection. + +## Native declaration inheritance + +Finalize declarations in load order, then expand their controls. +Retain local values, including nulls, and copy only absent, non-null fields from ancestors. +Reuse earlier finalized parents. Stop at a missing or visited declaration, including a self reference. +Keep nested objects and arrays intact. Apply array modifications to a fresh finalized template. +Export closures in declaration order so transported programs retain the same behavior. +The native HUD baseline uses the same finalizer. +Recursive control expansion keeps its separate limits. + +Original Bedrock 1.26.51.1 constructor `1403f3f20`, finalizer `14043efd0`, and lookup `1403f69f0` supply the reference results. +The pinned executable SHA-256 is `537c0aee2e79afbdc94b44b28e00f466ae62bc50e2733d953b430db9dbaa9ee7`. +Typed loader-output records cover 250 three-node parent graphs and the exact pocket declaration. +The 751 lookups match Java directly and after export, for 1,502 comparisons. +The cases include cycles, absent parents, shallow replacement, explicit nulls, and completed-parent ordering. +The capture stops at `140440ed0` after inheritance and registry population, before external scheduling and cleanup. +It supplies allocator, CRT, stack-probe, profiler, and diagnostic boundaries. +It does not replace inheritance, sorting, visited-state, or lookup routines. +Resource-loader ordering, complete screen construction, physical input, pixels, and full lifecycle parity remain unproven. + +Four new regression tests cover self references, cyclic precedence, null and missing-parent behavior, export order, and long chains. +The control-recursion regression still rejects unbounded expansion. + +The four-project build passes 2,749 tests, with 163 optional skips and no failures or errors. +Checkstyle passes after the full stack replay. +All 1,448 core classes match the embedded add-on and proxy copies. All 1,181 client classes match the add-on. +The packaged core repeats all 1,502 native comparisons with its class origin checked. +Three deliberate regressions fail: cycle rejection, ignored finalized parents, and reversed export order. +All six desktop/pocket Furnace, Blast Furnace, and Smoker hosts resolve without missing templates. +Each host round-trips exactly and passes the shared client scene parser. +The existing packaged host interaction regressions remain covered by the full client tests. +The production cooking controller still selects the desktop profile. The private profile projection does not establish device wiring or full screen admission. + +## Shared native recipe collections + +Use one partitioner for cooking's flat prefix and grouped recipe collections. +Preserve contiguous catalog identity, evaluator precedence, first-member representatives and header expansion history. +Header history compares icon plus count independently of catalog identity. +Grouped refreshes without filtering skip evaluation; flat refreshes retain the existing cooking behavior. + +Original Bedrock 1.26.51.1 routines `143445150`, `143446b90`, `143447710` and `143447ab0` supply 4,128 cases and sixteen retained-state steps. +The Java implementation matches their ordering, visible rows, evaluator calls and expansion notifications. +Six regressions cover the shared behavior and the existing cooking tests remain applicable. +Native vector allocation and cleanup execute; item ownership operations, icon equality, availability evaluation and external callbacks use supplied boundaries. +The complete player catalog producer, unlocks, craftability, action admission, full controller, physical input and screen parity remain unfinished. + +The final four-project build passes 2,823 tests with 163 optional skips and no failures or errors. +Checkstyle passes after replay. All 1,465 core classes match both embedded copies, and all 1,191 client classes match the tested add-on. +The packaged core repeats all 4,144 collection comparisons with its production class origin verified. diff --git a/patches/viabedrock/upstreamable/0098-forward-native-spear-jab-transactions.patch b/patches/viabedrock/upstreamable/0098-forward-native-spear-jab-transactions.patch index 4bb47b4b..9b044267 100644 --- a/patches/viabedrock/upstreamable/0098-forward-native-spear-jab-transactions.patch +++ b/patches/viabedrock/upstreamable/0098-forward-native-spear-jab-transactions.patch @@ -1,4 +1,4 @@ -From 1b1043cf6e2247a1b79b68e13bb3d43f8ae5187a Mon Sep 17 00:00:00 2001 +From e9d4732c2b8c5ffe33007afcd6fbcfbe0ed7b6da 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): forward native spear jab transactions @@ -13,7 +13,7 @@ A Bedrock 1.26.51.1 native capture confirms air and entity jabs use action 3. Lu create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/packet/SpearJabTest.java diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/ClientPlayerPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/ClientPlayerPackets.java -index f5a1a75bf..fd35e4baa 100644 +index 5d8802320..e4b65084e 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/ClientPlayerPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/ClientPlayerPackets.java @@ -24,6 +24,7 @@ import com.viaversion.viaversion.api.minecraft.entities.EntityTypes26_3; @@ -24,7 +24,7 @@ index f5a1a75bf..fd35e4baa 100644 import com.viaversion.viaversion.api.type.Types; import com.viaversion.viaversion.protocols.v26_2to26_3.packet.ClientboundPackets26_3; import com.viaversion.viaversion.protocols.v26_2to26_3.packet.ServerboundPackets26_3; -@@ -100,6 +101,28 @@ public final class ClientPlayerPackets { +@@ -101,6 +102,28 @@ public final class ClientPlayerPackets { } } @@ -53,7 +53,7 @@ index f5a1a75bf..fd35e4baa 100644 static Entity predictedVehicle(final EntityTracker entityTracker, final ClientPlayerEntity clientPlayer) { final Entity vehicle = entityTracker.getEntityByRid(clientPlayer.mountEntityRuntimeId()); if (vehicle == null || !(vehicle.javaType().isOrHasParent(EntityTypes26_3.ABSTRACT_BOAT) -@@ -418,7 +441,17 @@ public final class ClientPlayerPackets { +@@ -455,7 +478,17 @@ public final class ClientPlayerPackets { chunkTracker.handleBlockChange(position, 0, chunkTracker.bedrockAirId()); PacketFactory.sendJavaBlockUpdate(wrapper.user(), position, ProtocolConstants.JAVA_AIR_ID); } diff --git a/patches/viabedrock/upstreamable/0099-bridge-native-actor-picking-hitboxes.patch b/patches/viabedrock/upstreamable/0099-bridge-native-actor-picking-hitboxes.patch index f51136a5..824ebcf3 100644 --- a/patches/viabedrock/upstreamable/0099-bridge-native-actor-picking-hitboxes.patch +++ b/patches/viabedrock/upstreamable/0099-bridge-native-actor-picking-hitboxes.patch @@ -1,4 +1,4 @@ -From c4fe2e4d8bcdf14e948d3853d241d1f8f80abc98 Mon Sep 17 00:00:00 2001 +From 0edc4d8be4e2c015c0682743e8655fba966af047 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(entities): bridge authoritative native picking hitboxes @@ -261,18 +261,18 @@ index 000000000..466dff8b3 + +} 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 8911a1f36..b99ec84bd 100644 +index bfda20f9c..c80751bb9 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 -@@ -29,6 +29,7 @@ import com.viaversion.viaversion.protocols.v26_2to26_3.packet.ClientboundPackets - import net.raphimc.viabedrock.ViaBedrock; +@@ -30,6 +30,7 @@ import net.raphimc.viabedrock.ViaBedrock; import net.raphimc.viabedrock.api.model.MovementEffect; + import net.raphimc.viabedrock.api.model.ActorSpatialCommand; import net.raphimc.viabedrock.api.model.NativeActorMessage; +import net.raphimc.viabedrock.api.model.NativeActorHitboxes; import net.raphimc.viabedrock.api.util.EnumUtil; + import net.raphimc.viabedrock.api.util.MathUtil; import net.raphimc.viabedrock.protocol.BedrockProtocol; - import net.raphimc.viabedrock.protocol.ClientboundBedrockPackets; -@@ -110,6 +111,10 @@ public class Entity { +@@ -122,6 +123,10 @@ public class Entity { return new BoundingBoxDimensions(this.boundingBoxDimension(ActorDataIds.RESERVED_053), this.boundingBoxDimension(ActorDataIds.RESERVED_054)); } @@ -283,7 +283,7 @@ index 8911a1f36..b99ec84bd 100644 private Optional boundingBoxDimension(final ActorDataIds id) { final EntityData data = this.entityData.get(id); if (data == null || !(data.value() instanceof Number number)) { -@@ -318,6 +323,7 @@ public class Entity { +@@ -378,6 +383,7 @@ public class Entity { private void updatePosition(final Position3f position) { this.position = position; @@ -291,7 +291,7 @@ index 8911a1f36..b99ec84bd 100644 } public Position3f rotation() { -@@ -447,6 +453,13 @@ public class Entity { +@@ -520,6 +526,13 @@ public class Entity { protected void onEntityDataChanged() { this.refreshVehiclePrediction(); this.sendNativeActorSnapshot(); @@ -306,10 +306,10 @@ index 8911a1f36..b99ec84bd 100644 public final NativeActorMessage.Snapshot nativeActorSnapshot() { diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/ClientPlayerPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/ClientPlayerPackets.java -index fd35e4baa..a82837482 100644 +index e4b65084e..abe574edb 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/ClientPlayerPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/ClientPlayerPackets.java -@@ -528,7 +528,7 @@ public final class ClientPlayerPackets { +@@ -565,7 +565,7 @@ public final class ClientPlayerPackets { wrapper.write(wrapper.user().get(ItemRewriter.class).newItemType(), inventoryContainer.getSelectedHotbarItem()); // held item wrapper.write(BedrockTypes.POSITION_3F, entityTracker.getClientPlayer().position()); // player position final Vector3d location = wrapper.read(Types.LOW_PRECISION_VECTOR); // location @@ -319,18 +319,18 @@ index fd35e4baa..a82837482 100644 }); protocol.registerServerbound(ServerboundPackets26_3.MOVE_PLAYER_STATUS_ONLY, null, wrapper -> { 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 a7bd34044..d30ed8f75 100644 +index 9ba7444b2..c5177d0ca 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java -@@ -35,6 +35,7 @@ import net.raphimc.viabedrock.api.model.NativeBlockIdentifiers; - import net.raphimc.viabedrock.api.model.NativeCustomEntityMessage; +@@ -46,6 +46,7 @@ import net.raphimc.viabedrock.api.model.NativeCustomEntityMessage; import net.raphimc.viabedrock.api.model.NativeActorMessage; + import net.raphimc.viabedrock.api.model.ActorSpatialCommand; import net.raphimc.viabedrock.api.model.NativeActorBoundsMessage; +import net.raphimc.viabedrock.api.model.NativeActorPickingMessage; import net.raphimc.viabedrock.api.modinterface.ViaBedrockUtilityInterface; import net.raphimc.viabedrock.api.model.PlayerInput; import net.raphimc.viabedrock.api.model.PlayerPrediction; -@@ -192,6 +193,7 @@ public final class MultiStatePackets { +@@ -230,6 +231,7 @@ public final class MultiStatePackets { if ((channels.contains(NativeActorMessage.CHANNEL) || channels.contains(NativeActorMessage.CURRENT_CHANNEL)) && wrapper.user().has(EntityTracker.class)) { wrapper.user().get(EntityTracker.class).sendNativeActorSnapshots(); @@ -338,8 +338,8 @@ index a7bd34044..d30ed8f75 100644 } return; } -@@ -240,6 +242,10 @@ public final class MultiStatePackets { - || channels.contains(NativeActorBoundsMessage.CHANNEL)) && wrapper.user().has(EntityTracker.class)) { +@@ -279,6 +281,10 @@ public final class MultiStatePackets { + || channels.contains(ActorSpatialCommand.CHANNEL)) && wrapper.user().has(EntityTracker.class)) { wrapper.user().get(EntityTracker.class).sendNativeActorSnapshots(); } + if ((channels.contains(NativeActorPickingMessage.CHANNEL) || channels.contains(NativeActorMessage.CHANNEL) || channels.contains(NativeActorMessage.CURRENT_CHANNEL)) @@ -350,10 +350,10 @@ index a7bd34044..d30ed8f75 100644 && wrapper.user().has(EntityTracker.class)) { wrapper.user().get(EntityTracker.class).sendNativeCustomEntitySnapshot(); 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 a04c95390..de715d0a2 100644 +index 817633947..7999876c6 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/EntityTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/EntityTracker.java -@@ -41,16 +41,20 @@ import com.viaversion.viaversion.protocols.v26_2to26_3.packet.ClientboundPackets +@@ -41,6 +41,8 @@ import com.viaversion.viaversion.protocols.v26_2to26_3.packet.ClientboundPackets import net.raphimc.viabedrock.ViaBedrock; import net.raphimc.viabedrock.api.chunk.BedrockBlockEntity; import net.raphimc.viabedrock.api.model.BlockState; @@ -361,7 +361,8 @@ index a04c95390..de715d0a2 100644 +import net.raphimc.viabedrock.api.model.NativeActorPickingMessage; import net.raphimc.viabedrock.api.model.entity.*; import net.raphimc.viabedrock.protocol.BedrockProtocol; - import net.raphimc.viabedrock.protocol.packet.NpcDialoguePackets; + import net.raphimc.viabedrock.protocol.data.enums.bedrock.ActorFlags; +@@ -48,10 +50,12 @@ import net.raphimc.viabedrock.protocol.packet.NpcDialoguePackets; 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; @@ -374,7 +375,7 @@ index a04c95390..de715d0a2 100644 import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; -@@ -65,7 +69,9 @@ public class EntityTracker extends StoredObject { +@@ -66,7 +70,9 @@ public class EntityTracker extends StoredObject { private final EntityReferenceTracker references = new EntityReferenceTracker(this::getEntityByUid); private final AtomicInteger idCounter = new AtomicInteger(2); @@ -384,7 +385,7 @@ index a04c95390..de715d0a2 100644 private boolean nativeActorsJoined; private ClientPlayerEntity clientPlayerEntity = null; private final Long2ObjectMap entities = new Long2ObjectOpenHashMap<>(); -@@ -78,6 +84,8 @@ public class EntityTracker extends StoredObject { +@@ -79,6 +85,8 @@ public class EntityTracker extends StoredObject { public EntityTracker(final UserConnection user) { super(user); @@ -393,7 +394,7 @@ index a04c95390..de715d0a2 100644 } public Entity addEntity(final long uniqueId, final long runtimeId, final String type, final EntityTypes26_3 javaType) { -@@ -329,6 +337,7 @@ public class EntityTracker extends StoredObject { +@@ -336,6 +344,7 @@ public class EntityTracker extends StoredObject { public void markNativeActorsJoined() { this.nativeActorsJoined = true; this.sendNativeActorSnapshots(); @@ -401,7 +402,7 @@ index a04c95390..de715d0a2 100644 this.sendMovementEffectSnapshots(); this.sendUnderwaterMovementSnapshots(); this.sendFlightMovementSnapshot(); -@@ -348,6 +357,41 @@ public class EntityTracker extends StoredObject { +@@ -355,6 +364,41 @@ public class EntityTracker extends StoredObject { this.entities.values().forEach(entity -> entity.sendNativeActorSnapshot(true)); } @@ -443,7 +444,7 @@ index a04c95390..de715d0a2 100644 public void sendMovementEffectSnapshots() { if (this.nativeActorsJoined) { this.entities.values().forEach(Entity::sendMovementEffectSnapshot); -@@ -400,12 +444,26 @@ public class EntityTracker extends StoredObject { +@@ -407,12 +451,26 @@ public class EntityTracker extends StoredObject { public void entitySpawned(final Entity entity) { EntityReferenceTracker.sendUpdates(this.user(), this.references.actorSpawned(entity)); @@ -470,7 +471,7 @@ index a04c95390..de715d0a2 100644 EntityReferenceTracker.sendUpdates(user, tracker.references.actorRemoved(entity)); } } -@@ -416,10 +474,27 @@ public class EntityTracker extends StoredObject { +@@ -423,10 +481,27 @@ public class EntityTracker extends StoredObject { } public Entity getEntityByJid(final int javaId) { diff --git a/patches/viabedrock/upstreamable/0101-normalize-native-nbt-floating-values.patch b/patches/viabedrock/upstreamable/0101-normalize-native-nbt-floating-values.patch new file mode 100644 index 00000000..531d879c --- /dev/null +++ b/patches/viabedrock/upstreamable/0101-normalize-native-nbt-floating-values.patch @@ -0,0 +1,621 @@ +From 905b6970e079475ca6ad7b824cfddca0ce9074bd Mon Sep 17 00:00:00 2001 +From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> +Date: Thu, 22 Oct 2026 17:53:48 +0200 +Subject: [PATCH] fix(nbt): preserve native scalar values + +NBT stores byte strings even when their contents are not valid UTF-8. +Replacing malformed bytes during Java decoding can collapse distinct keys +and make distinct item names compare equal. Retain those bytes through +isolated low surrogates in the internal Java representation, preserve +valid surrogate pairs, and restore the original bytes on output. + +Use the lossless codec for little-endian and network-NBT string IO. Keep +their framing and numeric representations intact. Ordinary packet text +continues to use its existing Unicode decoder. Copies and the Java NBT +envelope preserve the retained bytes without a synthetic tag field. + +Binary NBT loads float NaNs and non-finite doubles as positive zero, while +preserving float infinities, signed zero and finite bits. Apply those +numeric rules at the binary-NBT stream boundary without mutating source +tags. Generic little-endian and network-NBT numbers keep their behavior. + +Evidence targets Bedrock 1.26.51.1, protocol 2193, executable SHA-256 +537c0aee2e79afbdc94b44b28e00f466ae62bc50e2733d953b430db9dbaa9ee7. +Complete parsed ItemStack construction142484090/1424a3e70, binary userdata +loader1424d06e0, NBT parser142555580 and comparator14254a410 retain raw keys, +display names and string lists in 340 inputs and 680 directional checks. +Typed registries, virtual interfaces and runtime/allocator hosting remain +supplied boundaries; this is not full packet decoding or native pixels. + +The numeric loader and writer slices14254b9f0/14254b2e0 agree on all 28 +scalar bit patterns. The writer probe stops before abstract output +dispatch and does not establish full native packet serialization. + +Tests cover distinct malformed keys/names, nested lists, Java NBT transit, +every byte pair, every Unicode scalar and mixed malformed inputs. Each +restored old stream, or broken surrogate-pair handling, fails its relevant +behavioral regression. Native comparisons remain separate from complete +live malformed-NBT inventory acceptance and the unfinished search UI. +--- + .../io/LittleEndianByteBufInputStream.java | 5 +- + .../io/LittleEndianByteBufOutputStream.java | 7 +- + .../raphimc/viabedrock/api/io/NbtUtf8.java | 65 ++++++++ + .../api/io/NetworkByteBufInputStream.java | 2 +- + .../api/io/NetworkByteBufOutputStream.java | 2 +- + .../protocol/types/primitive/TagLeType.java | 44 ++++- + .../viabedrock/api/io/NbtUtf8Test.java | 42 +++++ + .../types/primitive/NativeNbtStringsTest.java | 152 ++++++++++++++++++ + .../types/primitive/TagLeTypeTest.java | 141 ++++++++++++++++ + 9 files changed, 450 insertions(+), 10 deletions(-) + create mode 100644 src/main/java/net/raphimc/viabedrock/api/io/NbtUtf8.java + create mode 100644 src/test/java/net/raphimc/viabedrock/api/io/NbtUtf8Test.java + create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/types/primitive/NativeNbtStringsTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/types/primitive/TagLeTypeTest.java + +diff --git a/src/main/java/net/raphimc/viabedrock/api/io/LittleEndianByteBufInputStream.java b/src/main/java/net/raphimc/viabedrock/api/io/LittleEndianByteBufInputStream.java +index e795d35bd..6c35b470d 100644 +--- a/src/main/java/net/raphimc/viabedrock/api/io/LittleEndianByteBufInputStream.java ++++ b/src/main/java/net/raphimc/viabedrock/api/io/LittleEndianByteBufInputStream.java +@@ -21,7 +21,6 @@ import io.netty.buffer.ByteBuf; + import io.netty.buffer.ByteBufInputStream; + + import java.io.IOException; +-import java.nio.charset.StandardCharsets; + + public class LittleEndianByteBufInputStream extends ByteBufInputStream { + +@@ -70,7 +69,9 @@ public class LittleEndianByteBufInputStream extends ByteBufInputStream { + + @Override + public String readUTF() throws IOException { +- return (String) this.buffer.readCharSequence(this.readUnsignedShort(), StandardCharsets.UTF_8); ++ final byte[] bytes = new byte[this.readUnsignedShort()]; ++ this.buffer.readBytes(bytes); ++ return NbtUtf8.decode(bytes); + } + + } +diff --git a/src/main/java/net/raphimc/viabedrock/api/io/LittleEndianByteBufOutputStream.java b/src/main/java/net/raphimc/viabedrock/api/io/LittleEndianByteBufOutputStream.java +index cc5c89891..f6f45b0bd 100644 +--- a/src/main/java/net/raphimc/viabedrock/api/io/LittleEndianByteBufOutputStream.java ++++ b/src/main/java/net/raphimc/viabedrock/api/io/LittleEndianByteBufOutputStream.java +@@ -19,10 +19,8 @@ package net.raphimc.viabedrock.api.io; + + import io.netty.buffer.ByteBuf; + import io.netty.buffer.ByteBufOutputStream; +-import io.netty.buffer.ByteBufUtil; + + import java.io.IOException; +-import java.nio.charset.StandardCharsets; + + public class LittleEndianByteBufOutputStream extends ByteBufOutputStream { + +@@ -66,8 +64,9 @@ public class LittleEndianByteBufOutputStream extends ByteBufOutputStream { + + @Override + public void writeUTF(final String v) throws IOException { +- this.writeShort(ByteBufUtil.utf8Bytes(v)); +- this.buffer.writeCharSequence(v, StandardCharsets.UTF_8); ++ final byte[] bytes = NbtUtf8.encode(v); ++ this.writeShort(bytes.length); ++ this.buffer.writeBytes(bytes); + } + + } +diff --git a/src/main/java/net/raphimc/viabedrock/api/io/NbtUtf8.java b/src/main/java/net/raphimc/viabedrock/api/io/NbtUtf8.java +new file mode 100644 +index 000000000..7ea26f5a7 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/io/NbtUtf8.java +@@ -0,0 +1,65 @@ ++/* ++ * 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.io; ++ ++import java.io.ByteArrayOutputStream; ++import java.nio.ByteBuffer; ++import java.nio.CharBuffer; ++import java.nio.charset.StandardCharsets; ++ ++/** NBT byte strings in Java. Malformed UTF-8 bytes use isolated low surrogates. */ ++public final class NbtUtf8 { ++ ++ private NbtUtf8() { } ++ ++ public static String decode(final byte[] bytes) { ++ final var input = ByteBuffer.wrap(bytes); ++ final var output = CharBuffer.allocate(bytes.length); ++ final var decoder = StandardCharsets.UTF_8.newDecoder(); ++ while (true) { ++ final var result = decoder.decode(input, output, true); ++ if (result.isUnderflow()) { ++ decoder.flush(output); ++ output.flip(); ++ return output.toString(); ++ } ++ if (!result.isMalformed()) { ++ throw new IllegalStateException("Unexpected UTF-8 decoder result: " + result); ++ } ++ for (int index = 0; index < result.length(); index++) { ++ output.put((char) (0xDC00 | (input.get() & 0xFF))); ++ } ++ } ++ } ++ ++ public static byte[] encode(final String value) { ++ ByteArrayOutputStream output = null; ++ int start = 0; ++ int index = 0; ++ while (index < value.length()) { ++ final char character = value.charAt(index); ++ if (Character.isHighSurrogate(character) && index + 1 < value.length() && Character.isLowSurrogate(value.charAt(index + 1))) { ++ index += 2; ++ } else if (character >= 0xDC80 && character <= 0xDCFF) { ++ if (output == null) { ++ output = new ByteArrayOutputStream(value.length()); ++ } ++ output.writeBytes(value.substring(start, index).getBytes(StandardCharsets.UTF_8)); ++ output.write(character & 0xFF); ++ start = index + 1; ++ index++; ++ } else { ++ index++; ++ } ++ } ++ if (output == null) { ++ return value.getBytes(StandardCharsets.UTF_8); ++ } ++ output.writeBytes(value.substring(start).getBytes(StandardCharsets.UTF_8)); ++ return output.toByteArray(); ++ } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/api/io/NetworkByteBufInputStream.java b/src/main/java/net/raphimc/viabedrock/api/io/NetworkByteBufInputStream.java +index c6fb2fa92..35a2c293c 100644 +--- a/src/main/java/net/raphimc/viabedrock/api/io/NetworkByteBufInputStream.java ++++ b/src/main/java/net/raphimc/viabedrock/api/io/NetworkByteBufInputStream.java +@@ -53,7 +53,7 @@ public class NetworkByteBufInputStream extends LittleEndianByteBufInputStream { + @Override + public String readUTF() throws IOException { + try { +- return BedrockTypes.STRING.read(this.buffer); ++ return NbtUtf8.decode(BedrockTypes.BYTE_ARRAY.read(this.buffer)); + } catch (final Exception e) { + throw new IOException(e); + } +diff --git a/src/main/java/net/raphimc/viabedrock/api/io/NetworkByteBufOutputStream.java b/src/main/java/net/raphimc/viabedrock/api/io/NetworkByteBufOutputStream.java +index f1e0c2c3e..82940324a 100644 +--- a/src/main/java/net/raphimc/viabedrock/api/io/NetworkByteBufOutputStream.java ++++ b/src/main/java/net/raphimc/viabedrock/api/io/NetworkByteBufOutputStream.java +@@ -53,7 +53,7 @@ public class NetworkByteBufOutputStream extends LittleEndianByteBufOutputStream + @Override + public void writeUTF(final String s) throws IOException { + try { +- BedrockTypes.STRING.write(this.buffer, s); ++ BedrockTypes.BYTE_ARRAY.write(this.buffer, NbtUtf8.encode(s)); + } catch (final Exception e) { + throw new IOException(e); + } +diff --git a/src/main/java/net/raphimc/viabedrock/protocol/types/primitive/TagLeType.java b/src/main/java/net/raphimc/viabedrock/protocol/types/primitive/TagLeType.java +index a3762f984..1c15a8214 100644 +--- a/src/main/java/net/raphimc/viabedrock/protocol/types/primitive/TagLeType.java ++++ b/src/main/java/net/raphimc/viabedrock/protocol/types/primitive/TagLeType.java +@@ -44,7 +44,7 @@ public class TagLeType extends Type { + + try { + BedrockTypes.UTF8_STRING.read(buffer); +- return TagRegistry.read(id, new LittleEndianByteBufInputStream(buffer), TagLimiter.noop(), 0); ++ return TagRegistry.read(id, new TagInput(buffer), TagLimiter.noop(), 0); + } catch (final IOException e) { + throw new UncheckedIOException(e); + } +@@ -60,10 +60,50 @@ public class TagLeType extends Type { + buffer.writeByte(value.getTagId()); + BedrockTypes.UTF8_STRING.write(buffer, ""); + try { +- value.write(new LittleEndianByteBufOutputStream(buffer)); ++ value.write(new TagOutput(buffer)); + } catch (final IOException e) { + throw new UncheckedIOException(e); + } + } + ++ // Binary NBT preserves float infinities, but replaces float NaNs and all ++ // non-finite doubles with positive zero. Keep this out of generic LE IO. ++ private static final class TagInput extends LittleEndianByteBufInputStream { ++ ++ private TagInput(final ByteBuf buffer) { ++ super(buffer); ++ } ++ ++ @Override ++ public float readFloat() { ++ final float value = super.readFloat(); ++ return Float.isNaN(value) ? 0F : value; ++ } ++ ++ @Override ++ public double readDouble() { ++ final double value = super.readDouble(); ++ return Double.isFinite(value) ? value : 0D; ++ } ++ ++ } ++ ++ private static final class TagOutput extends LittleEndianByteBufOutputStream { ++ ++ private TagOutput(final ByteBuf buffer) { ++ super(buffer); ++ } ++ ++ @Override ++ public void writeFloat(final float value) { ++ super.writeFloat(Float.isNaN(value) ? 0F : value); ++ } ++ ++ @Override ++ public void writeDouble(final double value) { ++ super.writeDouble(Double.isFinite(value) ? value : 0D); ++ } ++ ++ } ++ + } +diff --git a/src/test/java/net/raphimc/viabedrock/api/io/NbtUtf8Test.java b/src/test/java/net/raphimc/viabedrock/api/io/NbtUtf8Test.java +new file mode 100644 +index 000000000..949a9985f +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/api/io/NbtUtf8Test.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.io; ++ ++import java.nio.charset.StandardCharsets; ++import java.util.Random; ++import org.junit.jupiter.api.Test; ++import static org.junit.jupiter.api.Assertions.*; ++ ++class NbtUtf8Test { ++ ++ @Test ++ void retainsEveryBytePairAndMixedMalformedSequences() { ++ for (int value = 0; value <= 0xFFFF; value++) { ++ final byte[] bytes = {(byte) (value >> 8), (byte) value}; ++ assertArrayEquals(bytes, NbtUtf8.encode(NbtUtf8.decode(bytes)), Integer.toHexString(value)); ++ } ++ final var random = new Random(2193); ++ for (int index = 0; index < 1024; index++) { ++ final byte[] bytes = new byte[random.nextInt(512)]; ++ random.nextBytes(bytes); ++ assertArrayEquals(bytes, NbtUtf8.encode(NbtUtf8.decode(bytes))); ++ } ++ } ++ ++ @Test ++ void preservesAllUnicodeScalarsIncludingSurrogateEscapeRangeInPairs() { ++ for (int point = 0; point <= Character.MAX_CODE_POINT; point++) { ++ if (point >= Character.MIN_SURROGATE && point <= Character.MAX_SURROGATE) { ++ continue; ++ } ++ final String text = new String(Character.toChars(point)); ++ final byte[] bytes = text.getBytes(StandardCharsets.UTF_8); ++ assertEquals(text, NbtUtf8.decode(bytes)); ++ assertArrayEquals(bytes, NbtUtf8.encode(text)); ++ } ++ } ++ ++} +diff --git a/src/test/java/net/raphimc/viabedrock/protocol/types/primitive/NativeNbtStringsTest.java b/src/test/java/net/raphimc/viabedrock/protocol/types/primitive/NativeNbtStringsTest.java +new file mode 100644 +index 000000000..c0ae20888 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/protocol/types/primitive/NativeNbtStringsTest.java +@@ -0,0 +1,152 @@ ++/* ++ * 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.types.primitive; ++ ++import com.viaversion.nbt.tag.CompoundTag; ++import com.viaversion.nbt.tag.ListTag; ++import com.viaversion.nbt.tag.StringTag; ++import com.viaversion.nbt.tag.Tag; ++import com.viaversion.viaversion.api.type.Type; ++import com.viaversion.viaversion.api.type.Types; ++import com.viaversion.viaversion.libs.gson.JsonParser; ++import io.netty.buffer.ByteBuf; ++import io.netty.buffer.Unpooled; ++import java.nio.file.Files; ++import java.nio.file.Path; ++import java.util.HexFormat; ++import java.util.List; ++import net.raphimc.viabedrock.protocol.types.BedrockTypes; ++import org.junit.jupiter.api.Test; ++import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; ++import static org.junit.jupiter.api.Assertions.*; ++ ++class NativeNbtStringsTest { ++ ++ @Test ++ void distinctMalformedNamesRemainUnequalAfterDecodingAndCopying() throws Exception { ++ for (final var type : List.of(BedrockTypes.TAG_LE, BedrockTypes.NETWORK_TAG)) { ++ final var first = compound(type, new byte[]{'n'}, new byte[]{(byte) 0xFF}); ++ final var second = compound(type, new byte[]{'n'}, new byte[]{(byte) 0x80}); ++ try { ++ final var a = (CompoundTag) type.read(first); ++ final var b = (CompoundTag) type.read(second); ++ assertNotEquals(a, b); ++ assertNotEquals(a.copy(), b.copy()); ++ assertNotEquals(a.getString("n"), b.getString("n")); ++ } finally { ++ first.release(); ++ second.release(); ++ } ++ } ++ } ++ ++ @Test ++ void nestedKeysAndStringListsRoundTripWithoutCollapsingOrReplacingBytes() throws Exception { ++ for (final var type : List.of(BedrockTypes.TAG_LE, BedrockTypes.NETWORK_TAG)) { ++ final var input = Unpooled.buffer(); ++ final var encoded = Unpooled.buffer(); ++ final var envelope = Unpooled.buffer(); ++ try { ++ input.writeByte(CompoundTag.ID); ++ text(input, type, new byte[0]); ++ for (final byte[] key : new byte[][]{{(byte) 0xC0, (byte) 0xAF}, {(byte) 0xC1, (byte) 0xAF}}) { ++ input.writeByte(ListTag.ID); ++ text(input, type, key); ++ input.writeByte(StringTag.ID); ++ if (type == BedrockTypes.TAG_LE) { ++ input.writeIntLE(2); ++ } else { ++ BedrockTypes.VAR_INT.writePrimitive(input, 2); ++ } ++ text(input, type, new byte[]{'a', 0, (byte) 0xFF, 'b'}); ++ text(input, type, new byte[]{(byte) 0xF0, (byte) 0x90, (byte) 0x82, (byte) 0x80}); ++ } ++ input.writeByte(0); ++ final byte[] original = bytes(input); ++ final var value = (CompoundTag) type.read(input); ++ assertFalse(input.isReadable()); ++ assertEquals(2, value.size()); ++ final var copy = value.copy(); ++ assertNotSame(value, copy); ++ assertEquals(value, copy); ++ Types.NAMED_COMPOUND_TAG.write(envelope, copy); ++ final var restored = Types.NAMED_COMPOUND_TAG.read(envelope); ++ assertEquals(copy, restored); ++ assertFalse(envelope.isReadable()); ++ type.write(encoded, restored); ++ assertArrayEquals(original, bytes(encoded)); ++ } finally { ++ input.release(); ++ encoded.release(); ++ envelope.release(); ++ } ++ } ++ } ++ ++ @Test ++ void ordinaryPacketTextStillUsesItsExistingUnicodeDecoder() throws Exception { ++ final var input = Unpooled.buffer(); ++ try { ++ BedrockTypes.BYTE_ARRAY.write(input, new byte[]{(byte) 0xFF}); ++ assertEquals(0xFFFD, BedrockTypes.STRING.read(input).codePointAt(0)); ++ assertFalse(input.isReadable()); ++ } finally { ++ input.release(); ++ } ++ } ++ ++ @Test ++ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NBT_UTF8_REFERENCE", matches = ".+") ++ void preservesTheNativeLoadedBinaryTagTrees() throws Exception { ++ final var data = JsonParser.parseString(Files.readString(Path.of(System.getenv("STACKANVIL_NBT_UTF8_REFERENCE")))).getAsJsonObject(); ++ final var hex = HexFormat.of(); ++ int count = 0; ++ for (final var element : data.getAsJsonArray("rows")) { ++ final byte[] packet = hex.parseHex(element.getAsJsonObject().get("packet").getAsString()); ++ final var input = Unpooled.wrappedBuffer(packet, 3, packet.length - 11); ++ final var encoded = Unpooled.buffer(); ++ try { ++ final var value = (CompoundTag) BedrockTypes.TAG_LE.read(input); ++ assertFalse(input.isReadable()); ++ BedrockTypes.TAG_LE.write(encoded, value.copy()); ++ assertArrayEquals(java.util.Arrays.copyOfRange(packet, 3, packet.length - 8), bytes(encoded)); ++ assertEquals(4, value.size()); ++ count++; ++ } finally { ++ input.release(); ++ encoded.release(); ++ } ++ } ++ assertEquals(340, count); ++ } ++ ++ private static ByteBuf compound(final Type type, final byte[] key, final byte[] value) throws Exception { ++ final var buffer = Unpooled.buffer(); ++ buffer.writeByte(CompoundTag.ID); ++ text(buffer, type, new byte[0]); ++ buffer.writeByte(StringTag.ID); ++ text(buffer, type, key); ++ text(buffer, type, value); ++ buffer.writeByte(0); ++ return buffer; ++ } ++ ++ private static void text(final ByteBuf buffer, final Type type, final byte[] value) throws Exception { ++ if (type == BedrockTypes.TAG_LE) { ++ buffer.writeShortLE(value.length); ++ } else { ++ BedrockTypes.UNSIGNED_VAR_INT.writePrimitive(buffer, value.length); ++ } ++ buffer.writeBytes(value); ++ } ++ ++ private static byte[] bytes(final ByteBuf buffer) { ++ final byte[] bytes = new byte[buffer.readableBytes()]; ++ buffer.getBytes(buffer.readerIndex(), bytes); ++ return bytes; ++ } ++ ++} +diff --git a/src/test/java/net/raphimc/viabedrock/protocol/types/primitive/TagLeTypeTest.java b/src/test/java/net/raphimc/viabedrock/protocol/types/primitive/TagLeTypeTest.java +new file mode 100644 +index 000000000..238db0566 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/protocol/types/primitive/TagLeTypeTest.java +@@ -0,0 +1,141 @@ ++/* ++ * 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.types.primitive; ++ ++import com.viaversion.nbt.tag.CompoundTag; ++import com.viaversion.nbt.tag.DoubleTag; ++import com.viaversion.nbt.tag.FloatTag; ++import com.viaversion.nbt.tag.ListTag; ++import com.viaversion.nbt.tag.Tag; ++import io.netty.buffer.ByteBuf; ++import io.netty.buffer.Unpooled; ++import net.raphimc.viabedrock.api.io.LittleEndianByteBufInputStream; ++import net.raphimc.viabedrock.api.io.LittleEndianByteBufOutputStream; ++import net.raphimc.viabedrock.protocol.types.BedrockTypes; ++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 java.util.List; ++import java.util.stream.Stream; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++class TagLeTypeTest { ++ ++ @ParameterizedTest ++ @MethodSource("nativeNumbers") ++ void matchesNativeBinaryNbtWithoutMutatingSourceTags(final int kind, final long input, final long expected) { ++ for (final boolean nested : new boolean[]{false, true}) { ++ final ByteBuf buffer = Unpooled.buffer(); ++ final ByteBuf encoded = Unpooled.buffer(); ++ try { ++ writeHeader(buffer, kind, nested); ++ if (kind == 5) { ++ buffer.writeIntLE((int) input); ++ } else { ++ buffer.writeLongLE(input); ++ } ++ if (nested) { ++ buffer.writeByte(0); ++ } ++ final Tag decoded = BedrockTypes.TAG_LE.read(buffer); ++ assertEquals(expected, bits(scalar(decoded, nested))); ++ assertFalse(buffer.isReadable()); ++ ++ final Tag original = kind == 5 ? new FloatTag(Float.intBitsToFloat((int) input)) : new DoubleTag(Double.longBitsToDouble(input)); ++ final Tag source; ++ if (nested) { ++ final CompoundTag compound = new CompoundTag(); ++ compound.put("n", new ListTag<>(List.of(original))); ++ source = compound; ++ } else { ++ source = original; ++ } ++ BedrockTypes.TAG_LE.write(encoded, source); ++ buffer.clear(); ++ writeHeader(buffer, kind, nested); ++ assertEquals(buffer, encoded.readSlice(buffer.readableBytes())); ++ assertEquals(expected, kind == 5 ? encoded.readUnsignedIntLE() : encoded.readLongLE()); ++ if (nested) { ++ assertEquals(0, encoded.readByte()); ++ } ++ assertFalse(encoded.isReadable()); ++ assertEquals(input, bits(original)); ++ } finally { ++ buffer.release(); ++ encoded.release(); ++ } ++ } ++ } ++ ++ @Test ++ void keepsGenericLittleEndianIoUnchanged() { ++ final ByteBuf buffer = Unpooled.buffer(); ++ try { ++ final var output = new LittleEndianByteBufOutputStream(buffer); ++ output.writeFloat(Float.intBitsToFloat(0xFFC00001)); ++ output.writeDouble(Double.NEGATIVE_INFINITY); ++ final var input = new LittleEndianByteBufInputStream(buffer); ++ assertEquals(0xFFC00001, Float.floatToRawIntBits(input.readFloat())); ++ assertEquals(Double.NEGATIVE_INFINITY, input.readDouble()); ++ } finally { ++ buffer.release(); ++ } ++ } ++ ++ private static void writeHeader(final ByteBuf buffer, final int kind, final boolean nested) { ++ buffer.writeByte(nested ? 10 : kind).writeShortLE(0); ++ if (nested) { ++ buffer.writeByte(9).writeShortLE(1).writeByte('n').writeByte(kind).writeIntLE(1); ++ } ++ } ++ ++ private static Tag scalar(final Tag root, final boolean nested) { ++ return nested ? ((CompoundTag) root).getListTag("n").getValue().getFirst() : root; ++ } ++ ++ private static long bits(final Tag tag) { ++ return tag instanceof FloatTag value ? Integer.toUnsignedLong(Float.floatToRawIntBits(value.asFloat())) : Double.doubleToRawLongBits(((DoubleTag) tag).asDouble()); ++ } ++ ++ // Bedrock 1.26.51.1, protocol 2193: complete binary userdata loader ++ // 0x1424D06E0 and scalar writer normalization at 0x14254B9F0/0x14254B2E0. ++ private static Stream nativeNumbers() { ++ return Stream.of( ++ Arguments.of(5, 0x0L, 0x0L), ++ Arguments.of(5, 0x80000000L, 0x80000000L), ++ Arguments.of(5, 0x1L, 0x1L), ++ Arguments.of(5, 0x80000001L, 0x80000001L), ++ Arguments.of(5, 0x7FFFFFL, 0x7FFFFFL), ++ Arguments.of(5, 0x800000L, 0x800000L), ++ Arguments.of(5, 0x3F800000L, 0x3F800000L), ++ Arguments.of(5, 0x7F7FFFFFL, 0x7F7FFFFFL), ++ Arguments.of(5, 0x7F800000L, 0x7F800000L), ++ Arguments.of(5, 0xFF800000L, 0xFF800000L), ++ Arguments.of(5, 0x7FC00000L, 0x0L), ++ Arguments.of(5, 0xFFC00000L, 0x0L), ++ Arguments.of(5, 0x7F800001L, 0x0L), ++ Arguments.of(5, 0xFF800001L, 0x0L), ++ Arguments.of(6, 0x0L, 0x0L), ++ Arguments.of(6, 0x8000000000000000L, 0x8000000000000000L), ++ Arguments.of(6, 0x1L, 0x1L), ++ Arguments.of(6, 0x8000000000000001L, 0x8000000000000001L), ++ Arguments.of(6, 0xFFFFFFFFFFFFFL, 0xFFFFFFFFFFFFFL), ++ Arguments.of(6, 0x10000000000000L, 0x10000000000000L), ++ Arguments.of(6, 0x3FF0000000000000L, 0x3FF0000000000000L), ++ Arguments.of(6, 0x7FEFFFFFFFFFFFFFL, 0x7FEFFFFFFFFFFFFFL), ++ Arguments.of(6, 0x7FF0000000000000L, 0x0L), ++ Arguments.of(6, 0xFFF0000000000000L, 0x0L), ++ Arguments.of(6, 0x7FF8000000000000L, 0x0L), ++ Arguments.of(6, 0xFFF8000000000000L, 0x0L), ++ Arguments.of(6, 0x7FF0000000000001L, 0x0L), ++ Arguments.of(6, 0xFFF0000000000001L, 0x0L) ++ ); ++ } ++ ++} +-- +2.56.0 diff --git a/patches/viabedrock/upstreamable/0101-normalize-native-nbt-floating-values.pr.md b/patches/viabedrock/upstreamable/0101-normalize-native-nbt-floating-values.pr.md new file mode 100644 index 00000000..7db695ee --- /dev/null +++ b/patches/viabedrock/upstreamable/0101-normalize-native-nbt-floating-values.pr.md @@ -0,0 +1,59 @@ +# Preserve native NBT scalar values + +Bedrock NBT strings retain raw bytes, including malformed UTF-8. +The previous decoder replaced malformed bytes and could collapse distinct keys or make distinct item names compare equal. +The codec now retains those bytes through an internal Java representation and restores them during output. +Valid Unicode, including surrogate pairs, keeps its normal representation. +Both little-endian and network-NBT strings use this codec. Their framing stays unchanged. +Ordinary packet text keeps its existing Unicode decoder. +Copies and a Java NBT envelope preserve the retained strings without extra tag fields. + +Binary NBT also previously retained float NaNs and non-finite doubles. +Native Bedrock loads those values as positive zero before item-stack comparisons. +Binary reads and writes now apply the native numeric rules without changing source tags. +They preserve float infinities, signed zero, subnormal values, and finite values. +Generic little-endian and network-NBT numbers keep their existing behavior. + +## Native evidence + +The target is Bedrock 1.26.51.1, build 51061372, protocol 2193. +Its executable SHA-256 is `537c0aee2e79afbdc94b44b28e00f466ae62bc50e2733d953b430db9dbaa9ee7`. +Fresh controls execute parsed ItemStack constructors `0x142484090` and `0x1424a3e70`, userdata loader `0x1424d06e0`, and NBT parser `0x142555580`. +Native comparator `0x14254a410` checks every loaded tree in both directions against supplied byte-exact tag trees. +All 340 inputs and 680 comparisons retain keys, nested `display/Name`, and string-list bytes. +Inputs include every single byte, malformed sequences, NUL, valid surrogate-pair encodings, and random byte strings. +The checked item references balance after destruction. + +The fixtures supply typed registries, Item interfaces, restriction resolution, allocator, CRT, and runtime hosting. +A missing host lookup reference was regenerated through 15,048 original callback and lookup cases. +The old property-reference path now reads the current protocol-2193 property-values asset. +No native instruction, policy result, branch, or assertion changes for these host file mappings. +These controls do not establish outer packet decoding, complete client ownership, visible text, or live malformed-NBT inventory acceptance. +The Java network-NBT string framing checks remain separate from original binary userdata execution. + +Earlier numeric controls execute the complete binary userdata loader and writer normalization at `0x14254b9f0..0x14254ba00` and `0x14254b2e0..0x14254b317`. +Both numeric paths agree on all 28 tested scalar patterns. Native stack copies preserve the loaded bits. +The writer probe stops before abstract output dispatch and does not establish complete native packet serialization. + +## Validation + +Three behavioral regressions fail before the string correction, including the retained native tag trees. +All six focused string tests pass after the correction and against the fresh native reference. +They cover distinct names and keys, nested lists, copies, Java NBT transit, and the ordinary packet-text decoder. +The codec also retains all 65,536 byte pairs, all Unicode scalars, and 1,024 mixed malformed inputs. +Five negative controls restore each old stream or break surrogate-pair handling. Each fails by behavioral assertion. + +The existing numeric tests retain direct and nested wire checks, original tag bits, and generic little-endian numeric behavior. +Native name acquisition, search transport, physical text input, and complete inventory or GUI parity remain unfinished. + +The full four-project build passes 2,640 tests, with 163 optional skips and no failures or errors. +The core and client stacks replay all 104 and 38 patches. +All 35 focused NBT scalar/string checks run without skips. +All 1,418 core classes match both packaged copies. All 1,139 production client classes match the add-on. +The packaged core passes the six string checks with verified class origins. +It rejects merging distinct raw names, accepts an identical raw copy, and retains malformed input for the search matcher. + +The unmodified ordinary BDS 1.26.51.1 route passes furnace, smoker, blast furnace, and same-world reconnect. +Final item counts match all three cases. Installed add-on and running proxy JARs match the current build. +Normal cleanup stops the private display and leaves no managed-profile JVM or private observer. +This route does not inject malformed NBT or establish native authored-screen or pixel parity. diff --git a/patches/viabedrock/upstreamable/0101-snapshot-inventory-action-items.patch b/patches/viabedrock/upstreamable/0101-snapshot-inventory-action-items.patch new file mode 100644 index 00000000..a38d6397 --- /dev/null +++ b/patches/viabedrock/upstreamable/0101-snapshot-inventory-action-items.patch @@ -0,0 +1,99 @@ +From 1cb05c336bac5ef4cdff3567f234a891fe7d13a8 Mon Sep 17 00:00:00 2001 +From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> +Date: Thu, 22 Oct 2026 17:57:52 +0200 +Subject: [PATCH] fix(inventory): snapshot transaction action items + +Take separate deep copies of each before and after stack when constructing +an inventory action. Inventory predictions and server updates can mutate +the original stack before packet serialization; retaining its reference +can then rewrite an already planned debit or credit. Separate snapshots +also prevent a shared input stack from aliasing both sides of the action. + +Pinned Bedrock 1.26.51.1, build 51061372, protocol 2193 constructs cached +before/after stacks by value in InventoryAction constructor 1401d4ad0, +including independent ItemStack copies through 142481ef0. The inspected +executable SHA-256 is +537c0aee2e79afbdc94b44b28e00f466ae62bc50e2733d953b430db9dbaa9ee7. +This changes Java ownership only, without changing item comparison rules, +transaction sources or wire layout. The regression fails without the +copies and covers counts, auxiliary data, nested NBT, restriction arrays, +blocking state, runtime identity and network IDs. +--- + .../model/inventory/InventoryActionData.java | 6 +++ + .../inventory/InventoryActionDataTest.java | 50 +++++++++++++++++++ + 2 files changed, 56 insertions(+) + create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/model/inventory/InventoryActionDataTest.java + +diff --git a/src/main/java/net/raphimc/viabedrock/protocol/model/inventory/InventoryActionData.java b/src/main/java/net/raphimc/viabedrock/protocol/model/inventory/InventoryActionData.java +index 5f3b0581d..913fd228b 100644 +--- a/src/main/java/net/raphimc/viabedrock/protocol/model/inventory/InventoryActionData.java ++++ b/src/main/java/net/raphimc/viabedrock/protocol/model/inventory/InventoryActionData.java +@@ -20,4 +20,10 @@ package net.raphimc.viabedrock.protocol.model.inventory; + import net.raphimc.viabedrock.protocol.model.BedrockItem; + + public record InventoryActionData(InventorySource source, int slot, BedrockItem fromItem, BedrockItem toItem) { ++ ++ public InventoryActionData { ++ fromItem = fromItem.copy(); ++ toItem = toItem.copy(); ++ } ++ + } +diff --git a/src/test/java/net/raphimc/viabedrock/protocol/model/inventory/InventoryActionDataTest.java b/src/test/java/net/raphimc/viabedrock/protocol/model/inventory/InventoryActionDataTest.java +new file mode 100644 +index 000000000..167f4dc12 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/protocol/model/inventory/InventoryActionDataTest.java +@@ -0,0 +1,50 @@ ++/* ++ * 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.inventory; ++ ++import com.viaversion.nbt.tag.CompoundTag; ++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.model.BedrockItem; ++import org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++final class InventoryActionDataTest { ++ ++ @Test ++ void retainsIndependentBeforeAndAfterSnapshotsAcrossItemMutation() { ++ final var nested = new CompoundTag(); ++ nested.putInt("value", 4); ++ final var tag = new CompoundTag(); ++ tag.put("nested", nested); ++ final var item = new BedrockItem(257, (short) 2, (byte) 4, tag, ++ new String[] {"minecraft:stone"}, new String[] {"minecraft:dirt"}, 8, 9, 10); ++ final var expected = item.copy(); ++ final var action = new InventoryActionData(new InventorySource(InventorySourceType.Container_Inventory, ++ (byte) 6, InventorySourceFlags.No_Flag), 9, item, item); ++ ++ item.setAmount(7); ++ item.setData(3); ++ item.setNetId(11); ++ item.setBlockRuntimeId(12); ++ item.setBlockingTicks(13); ++ nested.putInt("value", 14); ++ item.canPlace()[0] = "minecraft:gravel"; ++ item.canBreak()[0] = "minecraft:sand"; ++ ++ assertEquals(expected, action.fromItem()); ++ assertEquals(expected, action.toItem()); ++ assertNotSame(item, action.fromItem()); ++ assertNotSame(action.fromItem(), action.toItem()); ++ ++ action.toItem().setAmount(1); ++ action.toItem().tag().getCompoundTag("nested").putInt("value", 15); ++ action.toItem().canPlace()[0] = "minecraft:sand"; ++ assertEquals(expected, action.fromItem()); ++ } ++ ++} +-- +2.56.0 diff --git a/patches/viabedrock/upstreamable/0101-snapshot-inventory-action-items.pr.md b/patches/viabedrock/upstreamable/0101-snapshot-inventory-action-items.pr.md new file mode 100644 index 00000000..93c44137 --- /dev/null +++ b/patches/viabedrock/upstreamable/0101-snapshot-inventory-action-items.pr.md @@ -0,0 +1,29 @@ +Inventory actions now own separate snapshots of their before and after items. +Later changes to the original items cannot alter a queued transaction. +Passing the same item on both sides also creates independent snapshots. + +## Evidence + +The inspected native client is Bedrock 1.26.51.1, build 51061372, protocol 2193. +Its executable SHA-256 is `537c0aee2e79afbdc94b44b28e00f466ae62bc50e2733d953b430db9dbaa9ee7`. +Action constructor `1401d4ad0` creates cached descriptors and copies each item through `142481ef0`. +Existing private constructor probes also preserve action counts after the input stacks are destroyed. + +This patch changes item ownership at construction. +It preserves transaction sources, comparison rules and wire layout. +Native wire parity and live server acceptance remain separate checks. + +## Testing + +The regression fails against the original reference-only record. +It covers count, auxiliary data, nested NBT, restriction arrays, blocking state, runtime identity and network ID changes. +It also checks that mutation of the after item cannot alter the before item. +Focused action and authored chest tests and main/test Checkstyle pass before the deferred patches apply. +The full core build passes 1,442 tests, with 95 optional tests skipped and no failures. +Main, test and tool Checkstyle and Maven POM generation pass. +All 103 core patches replay successfully. +The offhand patch compares item contents instead of requiring shared object identity. + +## Upstream prerequisite + +The cache patch supplies the test-source configuration. diff --git a/patches/viabedrock/upstreamable/0102-match-native-language-parsing-and-formatting.patch b/patches/viabedrock/upstreamable/0102-match-native-language-parsing-and-formatting.patch new file mode 100644 index 00000000..10999b79 --- /dev/null +++ b/patches/viabedrock/upstreamable/0102-match-native-language-parsing-and-formatting.patch @@ -0,0 +1,1036 @@ +From cb43535fea70e1cb996fe74f23b616f4103fbc90 Mon Sep 17 00:00:00 2001 +From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> +Date: Fri, 23 Oct 2026 10:11:46 +0200 +Subject: [PATCH] fix(text): match native language parsing and formatting + +Parse Bedrock language bytes before Java text conversion. Preserve native +ASCII key folding, value whitespace, escapes, NUL boundaries, duplicate +handling and raw UTF-8 identity. Keep parsed tables immutable and discard +their cache when the owned file changes. + +Resolve exact keys before the native folded-key cache and retain whole +operation fallback, percent-token expansion and argument substitution. +Sequential arguments advance subsequent numeric references. Keep the +existing accepted English source policy; locale acquisition and language +selection remain separate work. + +Use this engine in resource text and early disconnect/kick messages so +camel-case keys still resolve after native parser folding. Existing text +snapshots retain their tables when a content file is replaced. + +Fresh original 1.26.51.1 execution verifies 1,099 file cases, 6,689 lookup +cases, five lookup-cache transitions, 752 formatter cases, 777 fallback +cases and 780 same-table parser/lookup/formatter compositions. The latter +supplies no translation lookup result. Ten focused checks pass; restoring +the old parser and three deliberate regressions fail behavioral assertions. + +The probes declare their controlled raw inputs, language tables, argument +vectors, allocator/CRT and single-thread lock boundaries. They do not +establish full locale, asset-provider, client ownership or visible parity. +--- + .../api/resourcepack/content/Content.java | 16 +- + .../resourcepack/definition/LanguageFile.java | 117 +++++++++++++++ + .../definition/LanguageLookup.java | 53 +++++++ + .../definition/LanguageTranslations.java | 140 ++++++++++++++++++ + .../definition/TextDefinitions.java | 34 ++++- + .../protocol/packet/JoinPackets.java | 4 +- + .../protocol/packet/MultiStatePackets.java | 10 +- + .../content/ContentLanguageCacheTest.java | 7 +- + .../definition/LanguageFileTest.java | 126 ++++++++++++++++ + .../definition/LanguageLookupTest.java | 82 ++++++++++ + .../definition/LanguageTranslationsTest.java | 127 ++++++++++++++++ + .../definition/TextDefinitionsTest.java | 76 ++++++++++ + 12 files changed, 764 insertions(+), 28 deletions(-) + create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/definition/LanguageFile.java + create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/definition/LanguageLookup.java + create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/definition/LanguageTranslations.java + create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/definition/LanguageFileTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/definition/LanguageLookupTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/definition/LanguageTranslationsTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/definition/TextDefinitionsTest.java + +diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/content/Content.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/content/Content.java +index 642bbdbd2..ad30a6607 100644 +--- a/src/main/java/net/raphimc/viabedrock/api/resourcepack/content/Content.java ++++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/content/Content.java +@@ -20,6 +20,7 @@ package net.raphimc.viabedrock.api.resourcepack.content; + import com.viaversion.viaversion.libs.gson.JsonObject; + import com.viaversion.viaversion.util.GsonUtil; + import net.raphimc.viabedrock.api.util.JsonUtil; ++import net.raphimc.viabedrock.api.resourcepack.definition.LanguageFile; + + import org.apache.commons.io.output.UnsynchronizedByteArrayOutputStream; + +@@ -33,7 +34,6 @@ import java.io.UncheckedIOException; + import java.nio.charset.StandardCharsets; + import java.util.*; + import java.util.concurrent.ConcurrentHashMap; +-import java.util.stream.Collectors; + + public abstract class Content { + +@@ -64,6 +64,7 @@ public abstract class Content { + + public final boolean put(final String path, final byte[] data) { + final boolean replaced = this.putBytes(path, data); ++ this.langCache.remove(path); + this.storedZipEntries.remove(path); + return replaced; + } +@@ -71,6 +72,7 @@ public abstract class Content { + /** Stores an already compressed payload without another deflation pass when exporting the ZIP. */ + public final boolean putStored(final String path, final byte[] data) { + final boolean replaced = this.putBytes(path, data); ++ this.langCache.remove(path); + this.storedZipEntries.add(path); + return replaced; + } +@@ -108,15 +110,9 @@ public abstract class Content { + } + + public Map getLang(final String path) { +- return this.langCache.computeIfAbsent(path, k -> { +- final List lines = this.getLines(k); +- return Collections.unmodifiableMap(lines.stream() +- .filter(line -> !line.startsWith("##")) +- .filter(line -> line.contains("=")) +- .map(line -> line.contains("##") ? line.substring(0, line.indexOf("##")) : line) +- .map(String::trim) +- .map(line -> line.split("=", 2)) +- .collect(Collectors.toMap(parts -> parts[0], parts -> parts[1], (o, n) -> n))); ++ return this.langCache.computeIfAbsent(path, key -> { ++ final byte[] input = this.get(key); ++ return input == null ? Map.of() : LanguageFile.parse(input).translations(); + }); + } + +diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/definition/LanguageFile.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/definition/LanguageFile.java +new file mode 100644 +index 000000000..c6e0b7990 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/definition/LanguageFile.java +@@ -0,0 +1,117 @@ ++/* ++ * 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.definition; ++ ++import net.raphimc.viabedrock.api.io.NbtUtf8; ++import java.io.ByteArrayOutputStream; ++import java.util.ArrayList; ++import java.util.HashMap; ++import java.util.List; ++import java.util.Map; ++import java.util.Set; ++ ++/** Byte-oriented Bedrock language-file parsing, before text display conversion. */ ++public final class LanguageFile { ++ ++ private LanguageFile() { } ++ ++ public static Result parse(final byte[] input) { ++ return parse(input, Set.of(), Set.of(), new byte[0]); ++ } ++ ++ public static Result parse(final byte[] input, final Set excluded, final Set included, final byte[] prefix) { ++ final var translations = new HashMap(); ++ final var missingSeparators = new ArrayList(); ++ final var key = new ByteArrayOutputStream(); ++ final var value = new ByteArrayOutputStream(); ++ State state = State.KEY_START; ++ int line = 1; ++ boolean special = false; ++ final int start = input.length >= 3 && input[0] == (byte) 0xEF && input[1] == (byte) 0xBB && input[2] == (byte) 0xBF ? 3 : 0; ++ int index = start; ++ while (index <= input.length) { ++ final int character = index == input.length ? 0 : Byte.toUnsignedInt(input[index]); ++ index++; ++ if (character == '\r') { ++ continue; ++ } ++ if (character == '\n' || character == 0) { ++ if (state == State.KEY) { ++ missingSeparators.add(new MissingSeparator(line, character == '\n')); ++ } ++ if (key.size() != 0 && value.size() != 0) { ++ final byte[] keyBytes = key.toByteArray(); ++ final String keyName = NbtUtf8.decode(keyBytes); ++ if (!excluded.contains(keyName) && (included.isEmpty() || included.contains(keyName))) { ++ final byte[] prefixed = new byte[Math.addExact(prefix.length, keyBytes.length)]; ++ System.arraycopy(prefix, 0, prefixed, 0, prefix.length); ++ System.arraycopy(keyBytes, 0, prefixed, prefix.length, keyBytes.length); ++ translations.put(NbtUtf8.decode(prefixed), NbtUtf8.decode(value.toByteArray())); ++ } ++ } ++ key.reset(); ++ value.reset(); ++ state = State.KEY_START; ++ if (character == '\n') { ++ line++; ++ } ++ continue; ++ } ++ switch (state) { ++ case KEY_START -> { ++ if (character == '\t' || character == ' ') { ++ continue; ++ } ++ if (character == '#' && index < input.length && input[index] == '#') { ++ state = State.COMMENT; ++ index++; ++ } else { ++ key.write(lowercase(character)); ++ state = State.KEY; ++ } ++ } ++ case KEY -> { ++ if (character == '=') { ++ state = State.VALUE; ++ } else { ++ key.write(lowercase(character)); ++ } ++ } ++ case VALUE -> { ++ if (character == '\t' || character == '#' && index < input.length && input[index] == '#') { ++ state = State.COMMENT; ++ } else if (character == '\\' && index < input.length) { ++ final int next = Byte.toUnsignedInt(input[index]); ++ if (next == 'n' || next == '\\') { ++ value.write(next == 'n' ? '\n' : '\\'); ++ index++; ++ } else { ++ value.write(character); ++ } ++ } else { ++ special |= character == '~'; ++ value.write(character); ++ } ++ } ++ case COMMENT -> { } ++ } ++ } ++ return new Result(Map.copyOf(translations), special, List.copyOf(missingSeparators)); ++ } ++ ++ private static int lowercase(final int character) { ++ return character >= 'A' && character <= 'Z' ? character + 'a' - 'A' : character; ++ } ++ ++ private enum State { ++ KEY_START, KEY, VALUE, COMMENT ++ } ++ ++ public record MissingSeparator(int line, boolean newline) { } ++ ++ public record Result(Map translations, boolean special, List missingSeparators) { } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/definition/LanguageLookup.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/definition/LanguageLookup.java +new file mode 100644 +index 000000000..1e340cb67 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/definition/LanguageLookup.java +@@ -0,0 +1,53 @@ ++/* ++ * 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.definition; ++ ++import net.raphimc.viabedrock.api.io.NbtUtf8; ++import java.util.Map; ++import java.util.concurrent.ConcurrentHashMap; ++import java.util.function.Function; ++ ++/** Native exact-key lookup followed by cached or ASCII-folded language lookup. */ ++public final class LanguageLookup implements Function { ++ ++ private final Function primary; ++ private final Map folded = new ConcurrentHashMap<>(); ++ ++ /** Replacing the language table requires a new lookup, discarding its folded-key cache. */ ++ public LanguageLookup(final Function primary) { ++ this.primary = primary; ++ } ++ ++ /** Null denotes an absent translation. Empty values still resolve and enter the folded cache. */ ++ @Override ++ public byte[] apply(final byte[] key) { ++ if (key.length == 0) { ++ return null; ++ } ++ final byte[] exact = this.primary.apply(key); ++ if (exact != null) { ++ return exact.clone(); ++ } ++ final String cacheKey = NbtUtf8.decode(key); ++ final byte[] cached = this.folded.get(cacheKey); ++ if (cached != null) { ++ return cached.clone(); ++ } ++ final byte[] lowercase = key.clone(); ++ for (int index = 0; index < lowercase.length; index++) { ++ if (lowercase[index] >= 'A' && lowercase[index] <= 'Z') { ++ lowercase[index] += 'a' - 'A'; ++ } ++ } ++ final byte[] result = this.primary.apply(lowercase); ++ if (result == null) { ++ return null; ++ } ++ this.folded.putIfAbsent(cacheKey, result.clone()); ++ return result.clone(); ++ } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/definition/LanguageTranslations.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/definition/LanguageTranslations.java +new file mode 100644 +index 000000000..da03a231a +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/definition/LanguageTranslations.java +@@ -0,0 +1,140 @@ ++/* ++ * 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.definition; ++ ++import java.io.ByteArrayOutputStream; ++import java.util.ArrayList; ++import java.util.Arrays; ++import java.util.List; ++import java.util.function.Function; ++ ++/** Native language token expansion and argument substitution over UTF-8 bytes. */ ++public final class LanguageTranslations { ++ ++ private LanguageTranslations() { } ++ ++ /** The lookup returns null for an absent key, preserving explicitly empty translations. */ ++ public static byte[] translate(final byte[] text, final List arguments, final Function lookup) { ++ return translate(text, arguments, List.of(lookup)); ++ } ++ ++ /** Try each language for the entire operation, including argument translation. */ ++ public static byte[] translate(final byte[] text, final List arguments, final List> languages) { ++ for (final Function language : languages) { ++ final byte[] translated = tryTranslate(text, arguments, language); ++ if (translated != null) { ++ return translated; ++ } ++ } ++ return text.clone(); ++ } ++ ++ static byte[] tryTranslate(final byte[] text, final List arguments, final Function lookup) { ++ final byte[] resolved; ++ if (indexOf(text, (byte) '%', 0) >= 0) { ++ final var output = new ByteArrayOutputStream(text.length); ++ int tokenStart = -1; ++ for (int index = 0; index <= text.length; index++) { ++ final int character = index == text.length ? 0 : Byte.toUnsignedInt(text[index]); ++ if (tokenStart >= 0) { ++ if (isKeyCharacter(character)) { ++ continue; ++ } ++ final byte[] key = Arrays.copyOfRange(text, tokenStart, index); ++ final byte[] value = resolve(key, lookup); ++ output.writeBytes(value == null ? key : value); ++ tokenStart = -1; ++ if (character != 0) { ++ output.write(character); ++ } ++ } else if (character == '%') { ++ tokenStart = index + 1; ++ } else if (character != 0) { ++ output.write(character); ++ } ++ } ++ resolved = output.toByteArray(); ++ } else { ++ final byte[] value = resolve(text, lookup); ++ if (value == null) { ++ // Retry the next language before evaluating any arguments. ++ return null; ++ } ++ resolved = value.clone(); ++ } ++ if (arguments.isEmpty()) { ++ return resolved; ++ } ++ final var translatedArguments = new ArrayList(arguments.size()); ++ for (final byte[] argument : arguments) { ++ final byte[] value = argument.length != 0 && argument[0] == '%' ++ ? resolve(Arrays.copyOfRange(argument, 1, argument.length), lookup) : null; ++ translatedArguments.add(value == null ? argument : value); ++ } ++ return substitute(resolved, translatedArguments); ++ } ++ ++ private static byte[] resolve(final byte[] key, final Function lookup) { ++ return key.length == 0 ? null : lookup.apply(key); ++ } ++ ++ private static boolean isKeyCharacter(final int character) { ++ return character >= '0' && character <= '9' || character >= 'A' && character <= 'Z' ++ || character >= 'a' && character <= 'z' || character == '-' || character == '.' || character == '_'; ++ } ++ ++ private static byte[] substitute(byte[] text, final List arguments) { ++ int position; ++ while ((position = firstMarker(text, (byte) '$')) >= 0) { ++ text = replace(text, position, 2, new byte[0]); ++ } ++ int sequential = 0; ++ while ((position = firstMarker(text, (byte) '%')) >= 0) { ++ final byte[] argument = sequential < arguments.size() ? arguments.get(sequential) : new byte[0]; ++ text = replace(text, position, 2, argument); ++ sequential++; ++ } ++ int cursor = 0; ++ while ((position = indexOf(text, (byte) '%', cursor)) >= 0 && position + 1 < text.length) { ++ final int digit = Byte.toUnsignedInt(text[position + 1]); ++ final long argumentIndex = (long) sequential + digit - '1'; ++ cursor = position + 1; ++ if (digit >= '0' && digit <= '9' && argumentIndex >= 0 && argumentIndex < arguments.size()) { ++ final byte[] argument = arguments.get((int) argumentIndex); ++ text = replace(text, position, 2, argument); ++ cursor = position + argument.length; ++ } ++ } ++ return text; ++ } ++ ++ private static int firstMarker(final byte[] text, final byte prefix) { ++ for (int index = 0; index + 1 < text.length; index++) { ++ if (text[index] == prefix && (text[index + 1] == 's' || text[index + 1] == 'd')) { ++ return index; ++ } ++ } ++ return -1; ++ } ++ ++ private static int indexOf(final byte[] text, final byte value, final int start) { ++ for (int index = start; index < text.length; index++) { ++ if (text[index] == value) { ++ return index; ++ } ++ } ++ return -1; ++ } ++ ++ private static byte[] replace(final byte[] text, final int position, final int count, final byte[] replacement) { ++ final byte[] result = new byte[Math.addExact(text.length - count, replacement.length)]; ++ System.arraycopy(text, 0, result, 0, position); ++ System.arraycopy(replacement, 0, result, position, replacement.length); ++ System.arraycopy(text, position + count, result, position + replacement.length, text.length - position - count); ++ return result; ++ } ++ ++} +diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/definition/TextDefinitions.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/definition/TextDefinitions.java +index 95d2d5b3d..58ec94669 100644 +--- a/src/main/java/net/raphimc/viabedrock/api/resourcepack/definition/TextDefinitions.java ++++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/definition/TextDefinitions.java +@@ -17,34 +17,52 @@ + */ + package net.raphimc.viabedrock.api.resourcepack.definition; + +-import net.lenni0451.mcstructs_bedrock.text.utils.BedrockTranslator; ++import net.raphimc.viabedrock.api.io.NbtUtf8; + import net.raphimc.viabedrock.api.resourcepack.ResourcePack; + import net.raphimc.viabedrock.protocol.storage.ResourcePackStorage; + ++import java.util.Arrays; + import java.util.HashMap; ++import java.util.List; + import java.util.Map; + import java.util.function.Function; + +-// https://wiki.bedrock.dev/concepts/text-and-translations.html + public class TextDefinitions { + +- private final Map translations; ++ private final LanguageLookup language; + + public TextDefinitions(final ResourcePackStorage resourcePackStorage) { +- this.translations = new HashMap<>(); +- for (ResourcePack pack : resourcePackStorage.getPackStackBottomToTop()) { ++ this(resourcePackStorage.getPackStackBottomToTop()); ++ } ++ ++ /** Uses already accepted layers in their resolved bottom-to-top order. */ ++ public TextDefinitions(final Iterable packs) { ++ final Map translations = new HashMap<>(); ++ for (final ResourcePack pack : packs) { + if (pack.content().contains("texts/en_US.lang")) { +- this.translations.putAll(pack.content().getLang("texts/en_US.lang")); ++ translations.putAll(pack.content().getLang("texts/en_US.lang")); + } + } ++ final Map snapshot = Map.copyOf(translations); ++ this.language = new LanguageLookup(key -> { ++ final String value = snapshot.get(NbtUtf8.decode(key)); ++ return value == null ? null : NbtUtf8.encode(value); ++ }); + } + + public String translate(final String text, final Object... args) { +- return BedrockTranslator.translate(text, this.lookup(), args); ++ return NbtUtf8.decode(this.translate(NbtUtf8.encode(text), Arrays.stream(args) ++ .map(argument -> NbtUtf8.encode(String.valueOf(argument))).toList())); ++ } ++ ++ /** Native bytes from the current English table, before conversion to Java display text. */ ++ public byte[] translate(final byte[] text, final List arguments) { ++ return LanguageTranslations.translate(text, arguments, this.language); + } + + public String get(final String key) { +- return this.translations.getOrDefault(key, key); ++ final byte[] value = this.language.apply(NbtUtf8.encode(key)); ++ return value == null ? key : NbtUtf8.decode(value); + } + + public Function lookup() { +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 deab5b284..f57454026 100644 +--- a/src/main/java/net/raphimc/viabedrock/protocol/packet/JoinPackets.java ++++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/JoinPackets.java +@@ -39,6 +39,7 @@ import com.viaversion.viaversion.protocols.v26_2to26_3.packet.ClientboundPackets + import net.raphimc.viabedrock.ViaBedrock; + import net.raphimc.viabedrock.api.model.entity.ClientPlayerEntity; + import net.raphimc.viabedrock.api.resourcepack.definition.ItemDefinitions; ++import net.raphimc.viabedrock.api.resourcepack.definition.TextDefinitions; + import net.raphimc.viabedrock.api.util.BitSets; + import net.raphimc.viabedrock.api.util.PacketFactory; + import net.raphimc.viabedrock.api.util.StringUtil; +@@ -502,7 +503,8 @@ public final class JoinPackets { + } + + private static void writePlayStatusKickMessage(final PacketWrapper wrapper, final PlayStatus status) { +- final Map translations = BedrockProtocol.MAPPINGS.getBedrockResourcePacks().get(DataValues.VANILLA_RESOURCE_PACK_KEY).content().getLang("texts/en_US.lang"); ++ final TextDefinitions translations = new TextDefinitions(List.of( ++ BedrockProtocol.MAPPINGS.getBedrockResourcePacks().get(DataValues.VANILLA_RESOURCE_PACK_KEY))); + + switch (status) { + case LoginFailed_ClientOld -> PacketFactory.writeJavaDisconnect(wrapper, translations.get("disconnectionScreen.outdatedClient")); +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 c5177d0ca..4e73e7bf0 100644 +--- a/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java ++++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/MultiStatePackets.java +@@ -36,7 +36,6 @@ import com.viaversion.viaversion.protocols.v26_2to26_3.packet.ClientboundConfigu + import com.viaversion.viaversion.protocols.v26_2to26_3.packet.ClientboundPackets26_3; + import com.viaversion.viaversion.protocols.v26_2to26_3.packet.ServerboundPackets26_3; + import com.viaversion.viaversion.util.Key; +-import net.lenni0451.mcstructs_bedrock.text.utils.BedrockTranslator; + import net.raphimc.viabedrock.api.model.CameraAimAssistActivation; + import net.raphimc.viabedrock.ViaBedrock; + import net.raphimc.viabedrock.api.model.CommandBlockEdit; +@@ -71,6 +70,7 @@ import net.raphimc.viabedrock.protocol.storage.PlayerWaypointTracker; + import net.raphimc.viabedrock.protocol.storage.EntityTracker; + import net.raphimc.viabedrock.protocol.storage.ChunkTracker; + import net.raphimc.viabedrock.protocol.storage.ClientSettingsStorage; ++import net.raphimc.viabedrock.api.resourcepack.definition.TextDefinitions; + import net.raphimc.viabedrock.protocol.storage.ChunkRadiusNegotiation; + import net.raphimc.viabedrock.protocol.storage.PacketSyncStorage; + import net.raphimc.viabedrock.protocol.storage.PlayerInputStorage; +@@ -83,8 +83,6 @@ import net.raphimc.viabedrock.protocol.types.BedrockTypes; + import java.nio.charset.StandardCharsets; + import java.util.Arrays; + import java.util.List; +-import java.util.Map; +-import java.util.function.Function; + import java.util.logging.Level; + + public final class MultiStatePackets { +@@ -93,11 +91,11 @@ public final class MultiStatePackets { + final DisconnectFailReason disconnectReason = DisconnectFailReason.getByValue(wrapper.read(BedrockTypes.VAR_INT), DisconnectFailReason.Unknown); // reason + final boolean hasMessage = wrapper.read(BedrockTypes.UNSIGNED_VAR_INT) == 0; // has message + if (hasMessage) { +- final Map translations = BedrockProtocol.MAPPINGS.getBedrockResourcePacks().get(DataValues.VANILLA_RESOURCE_PACK_KEY).content().getLang("texts/en_US.lang"); +- final Function translator = k -> translations.getOrDefault(k, k); ++ final TextDefinitions translations = new TextDefinitions(List.of( ++ BedrockProtocol.MAPPINGS.getBedrockResourcePacks().get(DataValues.VANILLA_RESOURCE_PACK_KEY))); + final String rawMessage = wrapper.read(BedrockTypes.STRING); // message + wrapper.read(BedrockTypes.STRING); // filtered message +- final String translatedMessage = BedrockTranslator.translate(rawMessage, translator, new Object[0]); ++ final String translatedMessage = translations.translate(rawMessage); + PacketFactory.writeJavaDisconnect(wrapper, translatedMessage + " §r(Reason: " + disconnectReason + ")"); + } else { + PacketFactory.writeJavaDisconnect(wrapper, null); +diff --git a/src/test/java/net/raphimc/viabedrock/api/resourcepack/content/ContentLanguageCacheTest.java b/src/test/java/net/raphimc/viabedrock/api/resourcepack/content/ContentLanguageCacheTest.java +index c0f072f08..8ca795d19 100644 +--- a/src/test/java/net/raphimc/viabedrock/api/resourcepack/content/ContentLanguageCacheTest.java ++++ b/src/test/java/net/raphimc/viabedrock/api/resourcepack/content/ContentLanguageCacheTest.java +@@ -27,7 +27,7 @@ class ContentLanguageCacheTest { + final AtomicInteger reads = new AtomicInteger(); + final InMemoryContent content = new InMemoryContent() { + @Override +- public List getLines(final String path) { ++ public byte[] get(final String path) { + reads.incrementAndGet(); + try { + if (!started.await(5, TimeUnit.SECONDS)) { +@@ -37,9 +37,10 @@ class ContentLanguageCacheTest { + Thread.currentThread().interrupt(); + throw new IllegalStateException(interrupted); + } +- return List.of("a=first", "a=last", "b=value ## comment", "## ignored"); ++ return super.get(path); + } + }; ++ content.putString("texts/en_US.lang", "a=first\na=last\nb=value ## comment\n## ignored"); + final var executor = Executors.newFixedThreadPool(connections); + try { + final List>> tables = new ArrayList<>(); +@@ -50,7 +51,7 @@ class ContentLanguageCacheTest { + })); + } + final Map first = tables.get(0).get(5, TimeUnit.SECONDS); +- assertEquals(Map.of("a", "last", "b", "value"), first); ++ assertEquals(Map.of("a", "last", "b", "value "), first); + for (final var table : tables) { + assertSame(first, table.get(5, TimeUnit.SECONDS)); + } +diff --git a/src/test/java/net/raphimc/viabedrock/api/resourcepack/definition/LanguageFileTest.java b/src/test/java/net/raphimc/viabedrock/api/resourcepack/definition/LanguageFileTest.java +new file mode 100644 +index 000000000..4e26c27dd +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/api/resourcepack/definition/LanguageFileTest.java +@@ -0,0 +1,126 @@ ++/* ++ * 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.definition; ++ ++import com.viaversion.viaversion.libs.gson.JsonArray; ++import com.viaversion.viaversion.libs.gson.JsonObject; ++import com.viaversion.viaversion.libs.gson.JsonParser; ++import net.raphimc.viabedrock.api.io.NbtUtf8; ++import net.raphimc.viabedrock.api.resourcepack.ResourcePack; ++import net.raphimc.viabedrock.api.resourcepack.content.InMemoryContent; ++import net.raphimc.viabedrock.protocol.storage.ResourcePackStorage; ++import java.nio.file.Files; ++import java.nio.file.Path; ++import java.util.ArrayList; ++import java.util.HashMap; ++import java.util.HashSet; ++import java.util.HexFormat; ++import java.util.List; ++import java.util.Map; ++import java.util.Set; ++import java.util.UUID; ++import org.junit.jupiter.api.Assumptions; ++import org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++final class LanguageFileTest { ++ ++ @Test ++ void parsingPreservesValueBytesAndAnEmptyDuplicateDoesNotEraseItsPredecessor() { ++ final byte[] input = NbtUtf8.encode("KEY=One\\nTwo\tComment\nkey=\nmissing"); ++ final byte[] bom = {(byte) 0xEF, (byte) 0xBB, (byte) 0xBF}; ++ final byte[] document = new byte[bom.length + input.length]; ++ System.arraycopy(bom, 0, document, 0, bom.length); ++ System.arraycopy(input, 0, document, bom.length, input.length); ++ final var result = LanguageFile.parse(document); ++ assertEquals(Map.of("key", "One\nTwo"), result.translations()); ++ assertEquals(List.of(new LanguageFile.MissingSeparator(3, false)), result.missingSeparators()); ++ } ++ ++ @Test ++ void byteParsingFiltersPrefixesAndDiagnosticsAgreeWithNativeFiles() throws Exception { ++ final var hex = HexFormat.of(); ++ for (final var element : reference().getAsJsonArray("cases")) { ++ final var row = element.getAsJsonObject(); ++ final var result = LanguageFile.parse(hex.parseHex(row.get("raw").getAsString()), keys(row.getAsJsonArray("exclude")), ++ keys(row.getAsJsonArray("include")), hex.parseHex(row.get("prefix").getAsString())); ++ final var expected = new HashMap(); ++ for (final var entry : row.getAsJsonArray("translations")) { ++ final var pair = entry.getAsJsonArray(); ++ expected.put(NbtUtf8.decode(hex.parseHex(pair.get(0).getAsString())), NbtUtf8.decode(hex.parseHex(pair.get(1).getAsString()))); ++ } ++ assertEquals(expected, result.translations(), row::toString); ++ assertEquals(row.get("special").getAsBoolean(), result.special(), row::toString); ++ final var diagnostics = new ArrayList(); ++ for (final var entry : row.getAsJsonArray("diagnostics")) { ++ final var diagnostic = entry.getAsJsonObject(); ++ diagnostics.add(new LanguageFile.MissingSeparator(diagnostic.get("line").getAsInt(), diagnostic.get("ending").getAsString().equals("newline"))); ++ } ++ assertEquals(diagnostics, result.missingSeparators(), row::toString); ++ } ++ } ++ ++ @Test ++ void actualPackNamesAndReloadsUseNativeFileContentsAndLookup() throws Exception { ++ final var document = reference(); ++ final var hex = HexFormat.of(); ++ int compared = 0; ++ for (final var element : document.getAsJsonArray("cases")) { ++ final var row = element.getAsJsonObject(); ++ if (!row.getAsJsonArray("exclude").isEmpty() || !row.getAsJsonArray("include").isEmpty() || !row.get("prefix").getAsString().isEmpty()) { ++ continue; ++ } ++ final var content = content(hex.parseHex(row.get("raw").getAsString())); ++ final var packs = List.of(new ResourcePack(content)); ++ final var texts = new ResourcePackStorage(packs).getTexts(); ++ final var bootstrapTexts = new TextDefinitions(packs); ++ for (final var query : row.getAsJsonArray("queries")) { ++ final var nativeQuery = query.getAsJsonObject(); ++ final byte[] key = hex.parseHex(nativeQuery.get("key").getAsString()); ++ final byte[] expected = nativeQuery.get("present").getAsBoolean() ? hex.parseHex(nativeQuery.get("result").getAsString()) : key; ++ assertArrayEquals(expected, NbtUtf8.encode(texts.get(NbtUtf8.decode(key))), row::toString); ++ assertArrayEquals(expected, NbtUtf8.encode(texts.lookup().apply(NbtUtf8.decode(key))), row::toString); ++ assertArrayEquals(expected, NbtUtf8.encode(bootstrapTexts.get(NbtUtf8.decode(key))), row::toString); ++ assertArrayEquals(hex.parseHex(nativeQuery.get("formatted").getAsString()), texts.translate(key, List.of()), row::toString); ++ assertArrayEquals(hex.parseHex(nativeQuery.get("formatted").getAsString()), bootstrapTexts.translate(key, List.of()), row::toString); ++ compared++; ++ } ++ } ++ assertTrue(compared > 1000); ++ final var lifecycle = document.getAsJsonObject("cacheLifecycle"); ++ final var content = content(hex.parseHex(lifecycle.get("beforeRaw").getAsString())); ++ final var before = new ResourcePackStorage(List.of(new ResourcePack(content))).getTexts(); ++ final var first = lifecycle.getAsJsonObject("before"); ++ final byte[] key = hex.parseHex(first.get("key").getAsString()); ++ assertArrayEquals(hex.parseHex(first.get("result").getAsString()), NbtUtf8.encode(before.get(NbtUtf8.decode(key)))); ++ content.put("texts/en_US.lang", hex.parseHex(lifecycle.get("afterRaw").getAsString())); ++ final var after = new ResourcePackStorage(List.of(new ResourcePack(content))).getTexts(); ++ assertArrayEquals(hex.parseHex(lifecycle.getAsJsonObject("after").get("result").getAsString()), NbtUtf8.encode(after.get(NbtUtf8.decode(key)))); ++ } ++ ++ private static JsonObject reference() throws Exception { ++ final String path = System.getenv("STACKANVIL_LANGUAGE_FILE_REFERENCE"); ++ Assumptions.assumeTrue(path != null); ++ return JsonParser.parseString(Files.readString(Path.of(path))).getAsJsonObject(); ++ } ++ ++ private static Set keys(final JsonArray values) { ++ final var result = new HashSet(); ++ values.forEach(value -> result.add(NbtUtf8.decode(HexFormat.of().parseHex(value.getAsString())))); ++ return result; ++ } ++ ++ private static InMemoryContent content(final byte[] raw) { ++ final var content = new InMemoryContent(); ++ content.putString("manifest.json", "{\"format_version\":2,\"header\":{\"uuid\":\"" + UUID.randomUUID() ++ + "\",\"version\":[1,0,0],\"name\":\"Language file probe\"}}"); ++ content.put("texts/en_US.lang", raw); ++ content.putString("texts/languages.json", "[\"en_US\"]"); ++ return content; ++ } ++ ++} +diff --git a/src/test/java/net/raphimc/viabedrock/api/resourcepack/definition/LanguageLookupTest.java b/src/test/java/net/raphimc/viabedrock/api/resourcepack/definition/LanguageLookupTest.java +new file mode 100644 +index 000000000..fa3a12f24 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/api/resourcepack/definition/LanguageLookupTest.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.resourcepack.definition; ++ ++import com.viaversion.viaversion.libs.gson.JsonArray; ++import com.viaversion.viaversion.libs.gson.JsonParser; ++import net.raphimc.viabedrock.api.io.NbtUtf8; ++import java.nio.file.Files; ++import java.nio.file.Path; ++import java.util.HashMap; ++import java.util.HexFormat; ++import org.junit.jupiter.api.Assumptions; ++import org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++final class LanguageLookupTest { ++ ++ @Test ++ void foldingCachesValuesWithoutSharingMutableResults() { ++ final var table = new HashMap(); ++ table.put("name", new byte[]{1, 2}); ++ final var lookup = new LanguageLookup(key -> table.get(NbtUtf8.decode(key))); ++ final byte[] key = NbtUtf8.encode("NAME"); ++ final byte[] first = lookup.apply(key); ++ first[0] = 9; ++ table.put("name", new byte[]{3, 4}); ++ assertArrayEquals(new byte[]{1, 2}, lookup.apply(key)); ++ final var replacement = new LanguageLookup(raw -> table.get(NbtUtf8.decode(raw))); ++ assertArrayEquals(new byte[]{3, 4}, replacement.apply(key)); ++ assertNull(lookup.apply(new byte[0])); ++ } ++ ++ @Test ++ void exactLookupAsciiFoldingAndCacheLifecycleAgreeWithNativeLanguageTables() throws Exception { ++ final String path = System.getenv("STACKANVIL_LANGUAGE_LOOKUP_REFERENCE"); ++ Assumptions.assumeTrue(path != null); ++ final var hex = HexFormat.of(); ++ final var document = JsonParser.parseString(Files.readString(Path.of(path))).getAsJsonObject(); ++ for (final String group : new String[]{"cases", "lifecycle"}) { ++ final var translations = new HashMap(); ++ final var calls = new JsonArray(); ++ LanguageLookup lookup = null; ++ int epoch = -1; ++ for (final var element : document.getAsJsonArray(group)) { ++ final var row = element.getAsJsonObject(); ++ translations.clear(); ++ for (final var translation : row.getAsJsonArray("translations")) { ++ final var entry = translation.getAsJsonArray(); ++ translations.put(entry.get(0).getAsString(), hex.parseHex(entry.get(1).getAsString())); ++ } ++ if (epoch != row.get("epoch").getAsInt()) { ++ epoch = row.get("epoch").getAsInt(); ++ lookup = new LanguageLookup(key -> { ++ final String encoded = hex.formatHex(key); ++ calls.add(encoded); ++ return translations.get(encoded); ++ }); ++ } ++ calls.asList().clear(); ++ final byte[] result = lookup.apply(hex.parseHex(row.get("key").getAsString())); ++ if (row.get("present").getAsBoolean()) { ++ assertArrayEquals(hex.parseHex(row.get("result").getAsString()), result, row::toString); ++ } else { ++ assertNull(result, row::toString); ++ } ++ final var expected = new JsonArray(); ++ for (final var call : row.getAsJsonArray("calls")) { ++ final var entry = call.getAsJsonArray(); ++ if (entry.get(0).getAsString().equals("primary")) { ++ expected.add(entry.get(1)); ++ } ++ } ++ assertEquals(expected, calls, row::toString); ++ } ++ } ++ } ++ ++} +diff --git a/src/test/java/net/raphimc/viabedrock/api/resourcepack/definition/LanguageTranslationsTest.java b/src/test/java/net/raphimc/viabedrock/api/resourcepack/definition/LanguageTranslationsTest.java +new file mode 100644 +index 000000000..8916873f5 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/api/resourcepack/definition/LanguageTranslationsTest.java +@@ -0,0 +1,127 @@ ++/* ++ * 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.definition; ++ ++import com.viaversion.viaversion.libs.gson.JsonArray; ++import com.viaversion.viaversion.libs.gson.JsonParser; ++import net.raphimc.viabedrock.api.io.NbtUtf8; ++import java.nio.file.Files; ++import java.nio.file.Path; ++import java.util.ArrayList; ++import java.util.HashMap; ++import java.util.HexFormat; ++import java.util.List; ++import java.util.Map; ++import java.util.function.Function; ++import org.junit.jupiter.api.Assumptions; ++import org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++final class LanguageTranslationsTest { ++ ++ @Test ++ void indexedArgumentsFollowConsumedSequentialArgumentsAndMissingTextSkipsThem() { ++ final var table = Map.of("format", "%s/%1/%2", "arg", "Arg"); ++ final var calls = new ArrayList(); ++ final Function lookup = raw -> { ++ final String key = NbtUtf8.decode(raw); ++ calls.add(key); ++ final String value = table.get(key); ++ return value == null ? null : NbtUtf8.encode(value); ++ }; ++ final var arguments = List.of(NbtUtf8.encode("%arg"), NbtUtf8.encode("B"), NbtUtf8.encode("C")); ++ assertEquals("Arg/B/C", NbtUtf8.decode(LanguageTranslations.translate(NbtUtf8.encode("format"), arguments, lookup))); ++ calls.clear(); ++ assertEquals("missing", NbtUtf8.decode(LanguageTranslations.translate(NbtUtf8.encode("missing"), arguments, lookup))); ++ assertEquals(List.of("missing"), calls); ++ } ++ ++ @Test ++ void wholeOperationRetriesAgreeWithNativeLanguageManager() throws Exception { ++ final String path = System.getenv("STACKANVIL_LANGUAGE_FALLBACK_REFERENCE"); ++ Assumptions.assumeTrue(path != null); ++ final var hex = HexFormat.of(); ++ final var cases = JsonParser.parseString(Files.readString(Path.of(path))).getAsJsonObject().getAsJsonArray("cases"); ++ for (final var element : cases) { ++ final var row = element.getAsJsonObject(); ++ final var tables = new ArrayList>(); ++ final var attempts = new JsonArray(); ++ for (final var table : row.getAsJsonArray("tables")) { ++ final int index = tables.size(); ++ final var translations = new HashMap(); ++ for (final var entry : table.getAsJsonArray()) { ++ final var pair = entry.getAsJsonArray(); ++ translations.put(NbtUtf8.decode(hex.parseHex(pair.get(0).getAsString())), hex.parseHex(pair.get(1).getAsString())); ++ } ++ final var lookup = new LanguageLookup(key -> translations.get(NbtUtf8.decode(key))); ++ tables.add(key -> { ++ if (attempts.isEmpty() || attempts.get(attempts.size() - 1).getAsInt() != index) { ++ attempts.add(index); ++ } ++ return lookup.apply(key); ++ }); ++ } ++ final var order = new ArrayList(); ++ if (!row.get("explicit").isJsonNull()) { ++ order.add(row.get("explicit").getAsInt()); ++ } ++ if (!row.get("selected").isJsonNull()) { ++ order.add(row.get("selected").getAsInt()); ++ for (final var available : row.getAsJsonArray("order")) { ++ final int index = available.getAsInt(); ++ if (NbtUtf8.decode(hex.parseHex(row.getAsJsonArray("names").get(index).getAsString())).equals("neutral")) { ++ order.add(index); ++ } ++ } ++ } ++ final List> languages = order.stream().map(tables::get).toList(); ++ final var arguments = new ArrayList(); ++ row.getAsJsonArray("arguments").forEach(argument -> arguments.add(hex.parseHex(argument.getAsString()))); ++ final byte[] key = hex.parseHex(row.get("key").getAsString()); ++ assertArrayEquals(hex.parseHex(row.get("result").getAsString()), LanguageTranslations.translate(key, arguments, languages), row::toString); ++ if (key.length != 0) { ++ assertEquals(row.getAsJsonArray("attempts"), attempts, row::toString); ++ } ++ } ++ } ++ ++ @Test ++ void rawTokenExpansionAndArgumentEvaluationAgreeWithNativeFormatter() throws Exception { ++ final String path = System.getenv("STACKANVIL_LANGUAGE_FORMAT_REFERENCE"); ++ Assumptions.assumeTrue(path != null); ++ final var hex = HexFormat.of(); ++ final var document = JsonParser.parseString(Files.readString(Path.of(path))).getAsJsonObject(); ++ for (final var element : document.getAsJsonArray("cases")) { ++ final var row = element.getAsJsonObject(); ++ final var translations = new HashMap(); ++ for (final var translation : row.getAsJsonArray("translations")) { ++ final var entry = translation.getAsJsonArray(); ++ translations.put(entry.get(0).getAsString(), hex.parseHex(entry.get(1).getAsString())); ++ } ++ final var arguments = new ArrayList(); ++ for (final var argument : row.getAsJsonArray("arguments")) { ++ arguments.add(hex.parseHex(argument.getAsString())); ++ } ++ final var calls = new JsonArray(); ++ final byte[] result = LanguageTranslations.translate(hex.parseHex(row.get("key").getAsString()), arguments, key -> { ++ final String encoded = hex.formatHex(key); ++ calls.add(encoded); ++ return translations.get(encoded); ++ }); ++ // Empty native lookup returns absent without consulting the dictionary. ++ final var expectedCalls = new JsonArray(); ++ for (final var call : row.getAsJsonArray("lookups")) { ++ if (!call.getAsString().isEmpty()) { ++ expectedCalls.add(call); ++ } ++ } ++ assertArrayEquals(hex.parseHex(row.get("result").getAsString()), result, row::toString); ++ assertEquals(expectedCalls, calls, row::toString); ++ } ++ } ++ ++} +diff --git a/src/test/java/net/raphimc/viabedrock/api/resourcepack/definition/TextDefinitionsTest.java b/src/test/java/net/raphimc/viabedrock/api/resourcepack/definition/TextDefinitionsTest.java +new file mode 100644 +index 000000000..4e8d5804e +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/api/resourcepack/definition/TextDefinitionsTest.java +@@ -0,0 +1,76 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.resourcepack.definition; ++ ++import com.viaversion.viaversion.libs.gson.JsonParser; ++import net.raphimc.viabedrock.api.io.NbtUtf8; ++import net.raphimc.viabedrock.api.resourcepack.ResourcePack; ++import net.raphimc.viabedrock.api.resourcepack.content.InMemoryContent; ++import net.raphimc.viabedrock.protocol.storage.ResourcePackStorage; ++import org.junit.jupiter.api.Test; ++import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; ++ ++import java.nio.file.Files; ++import java.nio.file.Path; ++import java.util.ArrayList; ++import java.util.HexFormat; ++import java.util.List; ++import java.util.Map; ++import java.util.UUID; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++class TextDefinitionsTest { ++ ++ @Test ++ void acceptedLayersAndSourceReplacementKeepIndependentLookupSnapshots() throws Exception { ++ final var lower = pack(NbtUtf8.encode("Key=Lower\nretained=Trailing \n")); ++ final var upper = pack(NbtUtf8.encode("KEY=Upper\n")); ++ final var before = new ResourcePackStorage(List.of(upper, lower)).getTexts(); ++ assertEquals("Upper", before.get("KEY")); ++ assertEquals("Trailing ", before.get("retained")); ++ final var parsed = upper.content().getLang("texts/en_US.lang"); ++ assertThrows(UnsupportedOperationException.class, () -> parsed.put("key", "Changed")); ++ upper.content().put("texts/en_US.lang", NbtUtf8.encode("key=After\n")); ++ final var after = new TextDefinitions(List.of(lower, upper)); ++ assertEquals("Upper", before.get("KEY")); ++ assertEquals("After", after.get("KEY")); ++ assertEquals(Map.of("key", "After"), upper.content().getLang("texts/en_US.lang")); ++ upper.content().putStored("texts/en_US.lang", NbtUtf8.encode("key=\n")); ++ assertEquals("Lower", new TextDefinitions(List.of(lower, upper)).get("KEY")); ++ assertEquals("After", after.get("KEY")); ++ } ++ ++ @Test ++ @EnabledIfEnvironmentVariable(named = "STACKANVIL_LANGUAGE_COMPOSITION_REFERENCE", matches = ".+") ++ void acceptedPackParsingLookupAndArgumentFormattingMatchNativeComposition() throws Exception { ++ final var fixture = JsonParser.parseString(Files.readString(Path.of(System.getenv("STACKANVIL_LANGUAGE_COMPOSITION_REFERENCE")))).getAsJsonObject(); ++ final var hex = HexFormat.of(); ++ int cases = 0; ++ for (final var element : fixture.getAsJsonArray("cases")) { ++ final var row = element.getAsJsonObject(); ++ final var texts = new ResourcePackStorage(List.of(pack(hex.parseHex(row.get("raw").getAsString())))).getTexts(); ++ final byte[] key = hex.parseHex(row.get("key").getAsString()); ++ final List arguments = new ArrayList<>(); ++ row.getAsJsonArray("arguments").forEach(argument -> arguments.add(hex.parseHex(argument.getAsString()))); ++ final byte[] expected = hex.parseHex(row.get("result").getAsString()); ++ assertArrayEquals(expected, texts.translate(key, arguments), "native byte case " + cases); ++ assertArrayEquals(expected, NbtUtf8.encode(texts.translate(NbtUtf8.decode(key), arguments.stream().map(NbtUtf8::decode).toArray())), ++ "native string case " + cases); ++ cases++; ++ } ++ assertEquals(780, cases); ++ } ++ ++ private static ResourcePack pack(final byte[] raw) throws Exception { ++ final var content = new InMemoryContent(); ++ content.putString("manifest.json", "{\"format_version\":2,\"header\":{\"uuid\":\"" + UUID.randomUUID() ++ + "\",\"version\":[1,0,0],\"name\":\"Language producer control\"}}"); ++ content.put("texts/en_US.lang", raw); ++ return new ResourcePack(content); ++ } ++ ++} +-- +2.56.0 diff --git a/patches/viabedrock/upstreamable/0102-match-native-language-parsing-and-formatting.pr.md b/patches/viabedrock/upstreamable/0102-match-native-language-parsing-and-formatting.pr.md new file mode 100644 index 00000000..8f86d446 --- /dev/null +++ b/patches/viabedrock/upstreamable/0102-match-native-language-parsing-and-formatting.pr.md @@ -0,0 +1,32 @@ +# Native language parsing and formatting + +The text path previously used a generic language parser and formatter. +It lost native key folding, raw byte identity, whitespace, and argument semantics. +This patch uses the native byte parser, lookup cache, and formatter in resource text and early disconnect messages. +A file replacement invalidates its parsed cache. Existing text snapshots retain their own tables. + +The existing accepted English source policy stays in effect. +Locale acquisition, language registration and selection, full asset-provider ownership, and visible parity remain separate work. + +Fresh original 1.26.51.1 controls cover 1,099 parser cases, 6,689 lookup cases, five cache transitions, 752 formatter cases, and 777 fallback cases. +Another 780 compositions run the real parser, map/cache lookup, and formatter on the same table without a supplied lookup result. +All ten focused checks pass without skips. Restoring the old parser and three deliberate regressions fail behavioral assertions. + +The probes supply declared raw files, controlled tables, argument vectors, and allocator, CRT, and single-thread lock boundaries. +They do not establish full native locale, provider, client, or rendering ownership. +The NBT scalar prerequisite supplies the lossless byte/string bridge. + + +The existing cache-concurrency test now supplies actual file bytes to the new reader. +A fresh native control confirms its trailing value space. The test retains all reader-count, table-identity, and immutability assertions. + +The combined build includes the newer ticked actor-motion replay changes. +It passes 2,666 tests, with 163 optional skips and no failures or errors. +All 105 core and 38 client patches replay. All ten new language tests run without skips. +Eleven packaged tests pass, including content-cache concurrency, with eleven verified production class origins. +All 1,427 core classes match the embedded add-on and proxy copies. All 1,143 production client classes match the add-on. +Build outputs match the relevant packaged classes after standard build-token replacement. + +The ordinary BDS 1.26.51.1 route passes all three cooking families and same-world reconnect. +All final cooking counts match. The active integration copies use the current add-on, and normal cleanup stops the private display. +This route does not establish native authored-screen acceptance or pixels. diff --git a/patches/viafabricplus-bedrock/series.json b/patches/viafabricplus-bedrock/series.json index a19f1ea7..5e465d39 100644 --- a/patches/viafabricplus-bedrock/series.json +++ b/patches/viafabricplus-bedrock/series.json @@ -161,7 +161,14 @@ }, { "file": "0025-send-completed-local-physics-to-viabedrock.patch", - "title": "feat(movement): send completed local physics to ViaBedrock" + "title": "feat(movement): send completed local physics to ViaBedrock", + "prerequisites": [ + { + "project": "viabedrock", + "patch": "0092-retain-completed-client-prediction-frames.patch", + "reason": "Publish completed prediction, movement-effect history context and component replay before client frame integration." + } + ] }, { "file": "0026-apply-native-fence-geometry-in-bedrock-sessions.patch", 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 f257ae17..108f2dfb 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 53f611c4d47f085b6176b8c439bbf80f5f2bfb10 Mon Sep 17 00:00:00 2001 +From e0e79d29b2d8466655aafeaa778f4efae8671d19 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 @@ -428,11 +428,54 @@ path without a spurious parse warning; malformed non-null payloads still report a failure. Co-authored-by: Miles Tech Studios <291174896+milesalexg-tech@users.noreply.github.com> + +Carry the native ordinary actor alpha reference through Iris's compiled +pipeline override and bind scope. Iris 1.11.7 shares ExtendedShader and +FallbackShader programs between native and Java pipelines; mutating the +program or its ShaderKey leaks the cutoff into unrelated draws. Restore +the prior thread scope even if a bind throws, and restore each fallback +program's original uniform on ordinary Java binds. + +Bedrock 1.26.51.1/protocol 2193 ordinary Actor AlphaTest retains sampled +texture alpha strictly above 0.5. The owning native shader evidence also +applies to supplied player bodies. Iris's entity cutout family uses 0.1. +Actual GL uniform readbacks verify native 0.5 and Java 0.1 in the same +shared programs. This carries the reference only: shader-pack-authored +comparison functions and post-shader alpha differ from the native +source-texture test and still require separate parity work. + +Keep ordinary native actor coverage independent of shader-pack output +alpha and Iris alpha-function overrides. Carry the original UV attribute +through ordinary vertex/fragment programs, test the original unit-zero +texture with the native strict comparison, and retain Iris's generated +comparison only for Java draws. Bind native selection and reference after +all Iris uniform updates, including when a program is shared with Java. +Register a separate external sampler so gtexture replacement cannot change +the native source. Apply the same coverage contract to synthesized fallback +programs without rewriting shader-pack authored discards. + +Use Iris 1.11.7 and its GLSL AST API as compile-only dependencies. Iris is +still optional and none of its classes or parser libraries are embedded. +Geometry and tessellation programs retain the existing reference bridge +until their varying transport is implemented and verified. This change +does not establish arbitrary shader-pack visual parity. --- + build.gradle.kts | 6 + + settings.gradle.kts | 3 + .../bedrock/ViaFabricPlusBedrock.java | 17 ++ .../access/IBedrockActorRenderType.java | 14 + .../access/IBedrockDynamicTexture.java | 10 + + .../access/IBedrockShaderPipeline.java | 10 + + .../compat/iris/MixinIrisAlphaSampler.java | 29 ++ + .../compat/iris/MixinIrisAlphaTransform.java | 30 ++ + .../compat/iris/MixinIrisAlphaUniforms.java | 26 ++ + .../compat/iris/MixinIrisFallbackAlpha.java | 56 ++++ + .../iris/MixinIrisFallbackTransform.java | 40 +++ + .../compat/iris/MixinIrisJavaAlphaTest.java | 29 ++ + .../compat/iris/MixinIrisMaterialAlpha.java | 40 +++ .../mixin/compat/iris/MixinIrisPipelines.java | 23 ++ + .../compat/iris/MixinNativeShaderBinding.java | 30 ++ + .../iris/MixinNativeShaderCompilation.java | 29 ++ .../render/MixinAbstractClientPlayer.java | 38 +++ .../render/MixinBedrockActorPipelines.java | 27 ++ .../render/MixinBedrockActorRenderType.java | 47 +++ @@ -465,16 +508,19 @@ Co-authored-by: Miles Tech Studios <291174896+milesalexg-tech@users.noreply.gith .../bedrock/render/BedrockPlayerSkins.java | 287 ++++++++++++++++++ .../render/BedrockRenderSnapshotCache.java | 48 +++ .../bedrock/render/BedrockRidingAnchors.java | 73 +++++ + .../bedrock/render/BedrockShaderAlpha.java | 29 ++ .../bedrock/render/BedrockShaderEmission.java | 181 +++++++++++ - .../render/BedrockShaderPipelines.java | 28 ++ + .../render/BedrockShaderPipelines.java | 40 +++ .../bedrock/render/BedrockSkinImages.java | 105 +++++++ .../render/CustomEntityRenderStore.java | 71 +++++ .../render/NativeCustomEntityPayload.java | 24 ++ .../bedrock/render/NativeSkinPayload.java | 22 ++ + .../render/compat/iris/BedrockIrisAlpha.java | 68 +++++ + .../compat/iris/BedrockIrisAlphaUniforms.java | 31 ++ .../shaders/core/bedrock_actor.fsh | 46 +++ .../shaders/core/bedrock_actor.vsh | 47 +++ .../viafabricplus-bedrock.accesswidener | 6 + - .../viafabricplus-bedrock.mixins.json | 17 ++ + .../viafabricplus-bedrock.mixins.json | 26 ++ .../render/BedrockAcceptedResourcesTest.java | 118 +++++++ .../BedrockActorMaterialDefinitionsTest.java | 165 ++++++++++ .../render/BedrockActorMaterialsTest.java | 48 +++ @@ -488,13 +534,25 @@ Co-authored-by: Miles Tech Studios <291174896+milesalexg-tech@users.noreply.gith .../render/BedrockPlayerMaterialsTest.java | 42 +++ .../BedrockRenderSnapshotCacheTest.java | 123 ++++++++ .../render/BedrockRidingAnchorsTest.java | 149 +++++++++ - .../render/BedrockShaderPipelinesTest.java | 49 +++ + .../render/BedrockShaderAlphaTest.java | 31 ++ + .../render/BedrockShaderPipelinesTest.java | 52 ++++ .../bedrock/render/BedrockSkinImagesTest.java | 82 +++++ .../render/CustomEntityRenderStoreTest.java | 62 ++++ - 62 files changed, 4190 insertions(+) + .../compat/iris/BedrockIrisAlphaGpuTest.java | 201 ++++++++++++ + 79 files changed, 4902 insertions(+) create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockActorRenderType.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockDynamicTexture.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockShaderPipeline.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinIrisAlphaSampler.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinIrisAlphaTransform.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinIrisAlphaUniforms.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinIrisFallbackAlpha.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinIrisFallbackTransform.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinIrisJavaAlphaTest.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinIrisMaterialAlpha.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinIrisPipelines.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinNativeShaderBinding.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinNativeShaderCompilation.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinAbstractClientPlayer.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinBedrockActorPipelines.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinBedrockActorRenderType.java @@ -527,12 +585,15 @@ Co-authored-by: Miles Tech Studios <291174896+milesalexg-tech@users.noreply.gith create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerSkins.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockRenderSnapshotCache.java 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/BedrockShaderPipelines.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockSkinImages.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/CustomEntityRenderStore.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/NativeCustomEntityPayload.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/NativeSkinPayload.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/compat/iris/BedrockIrisAlpha.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/compat/iris/BedrockIrisAlphaUniforms.java 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/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockAcceptedResourcesTest.java @@ -548,10 +609,43 @@ Co-authored-by: Miles Tech Studios <291174896+milesalexg-tech@users.noreply.gith create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerMaterialsTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockRenderSnapshotCacheTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockRidingAnchorsTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderAlphaTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderPipelinesTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockSkinImagesTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/CustomEntityRenderStoreTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/compat/iris/BedrockIrisAlphaGpuTest.java +diff --git a/build.gradle.kts b/build.gradle.kts +index 9251ef3..67ac977 100644 +--- a/build.gradle.kts ++++ b/build.gradle.kts +@@ -23,6 +23,12 @@ tasks.withType().configureEach { + + dependencies { + implementation(viaFabricPlusDependency) ++ // Optional integration only. Iris supplies these classes when installed. ++ compileOnly("maven.modrinth:iris:vTN4NRGW") ++ compileOnly("io.github.douira:glsl-transformer:3.0.0-pre3") ++ testImplementation("maven.modrinth:iris:vTN4NRGW") ++ testImplementation("io.github.douira:glsl-transformer:3.0.0-pre3") ++ testImplementation("org.lwjgl:lwjgl-egl:3.4.3") + + jarInJar(viaBedrockDependency) { + exclude(group = "com.mojang", module = "brigadier") +diff --git a/settings.gradle.kts b/settings.gradle.kts +index c4d1e0e..e1bd73b 100644 +--- a/settings.gradle.kts ++++ b/settings.gradle.kts +@@ -14,6 +14,9 @@ plugins { + + dependencyResolutionManagement { + repositories { ++ maven("https://api.modrinth.com/maven") { ++ content { includeModule("maven.modrinth", "iris") } ++ } + val stackAnvilMavenRepo = providers.gradleProperty("stackanvilMavenRepo") + if (stackAnvilMavenRepo.isPresent) { + exclusiveContent { diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java b/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java index a8f239a..4f85837 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java @@ -623,6 +717,314 @@ index 0000000..50fc1c5 +public interface IBedrockDynamicTexture { + long bedrock$uploadGeneration(); +} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockShaderPipeline.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockShaderPipeline.java +new file mode 100644 +index 0000000..7582ea8 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockShaderPipeline.java +@@ -0,0 +1,10 @@ ++/* ++ * 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.access; ++ ++public interface IBedrockShaderPipeline { ++ void bedrock$alphaReference(float reference); ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinIrisAlphaSampler.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinIrisAlphaSampler.java +new file mode 100644 +index 0000000..a352f54 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinIrisAlphaSampler.java +@@ -0,0 +1,29 @@ ++/* ++ * 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.compat.iris; ++ ++import com.llamalad7.mixinextras.injector.wrapoperation.Operation; ++import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; ++import com.viaversion.viafabricplus.bedrock.render.compat.iris.BedrockIrisAlpha; ++import net.irisshaders.iris.gl.sampler.SamplerHolder; ++import org.spongepowered.asm.mixin.Mixin; ++import org.spongepowered.asm.mixin.Pseudo; ++import org.spongepowered.asm.mixin.injection.At; ++ ++@Pseudo ++@Mixin(targets = "net.irisshaders.iris.samplers.IrisSamplers", remap = false) ++public abstract class MixinIrisAlphaSampler { ++ @WrapOperation(method = "addLevelSamplers", at = @At(value = "INVOKE", ++ target = "Lnet/irisshaders/iris/gl/sampler/SamplerHolder;addExternalSampler(I[Ljava/lang/String;)V")) ++ private static void bedrock$source(final SamplerHolder samplers, final int unit, final String[] names, ++ final Operation original) { ++ original.call(samplers, unit, names); ++ if (unit == 0) { ++ // Register separately: a gtexture override must not replace the native source as well. ++ samplers.addExternalSampler(0, BedrockIrisAlpha.SAMPLER); ++ } ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinIrisAlphaTransform.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinIrisAlphaTransform.java +new file mode 100644 +index 0000000..806fbc7 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinIrisAlphaTransform.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.injection.mixin.compat.iris; ++ ++import com.viaversion.viafabricplus.bedrock.render.compat.iris.BedrockIrisAlpha; ++import io.github.douira.glsl_transformer.ast.node.TranslationUnit; ++import io.github.douira.glsl_transformer.ast.query.Root; ++import io.github.douira.glsl_transformer.ast.transform.ASTParser; ++import net.irisshaders.iris.pipeline.transform.parameter.VanillaParameters; ++import org.spongepowered.asm.mixin.Mixin; ++import org.spongepowered.asm.mixin.Pseudo; ++import org.spongepowered.asm.mixin.injection.At; ++import org.spongepowered.asm.mixin.injection.Inject; ++import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; ++ ++@Pseudo ++@Mixin(targets = { ++ "net.irisshaders.iris.pipeline.transform.transformer.VanillaTransformer", ++ "net.irisshaders.iris.pipeline.transform.transformer.VanillaCoreTransformer" ++}, remap = false) ++public abstract class MixinIrisAlphaTransform { ++ @Inject(method = "transform", at = @At("RETURN")) ++ private static void bedrock$sourceAlpha(final ASTParser parser, final TranslationUnit tree, final Root root, ++ final VanillaParameters parameters, final CallbackInfo ci) { ++ BedrockIrisAlpha.transform(parser, tree, parameters); ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinIrisAlphaUniforms.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinIrisAlphaUniforms.java +new file mode 100644 +index 0000000..a5d1694 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinIrisAlphaUniforms.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.injection.mixin.compat.iris; ++ ++import com.viaversion.viafabricplus.bedrock.render.compat.iris.BedrockIrisAlpha; ++import net.irisshaders.iris.gl.state.FogMode; ++import net.irisshaders.iris.gl.uniform.DynamicUniformHolder; ++import net.irisshaders.iris.gl.uniform.UniformType; ++import org.spongepowered.asm.mixin.Mixin; ++import org.spongepowered.asm.mixin.Pseudo; ++import org.spongepowered.asm.mixin.injection.At; ++import org.spongepowered.asm.mixin.injection.Inject; ++import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; ++ ++@Pseudo ++@Mixin(targets = "net.irisshaders.iris.uniforms.CommonUniforms", remap = false) ++public abstract class MixinIrisAlphaUniforms { ++ @Inject(method = "addDynamicUniforms", at = @At("RETURN")) ++ private static void bedrock$register(final DynamicUniformHolder uniforms, final FogMode fog, final CallbackInfo ci) { ++ uniforms.externallyManagedUniform(BedrockIrisAlpha.ENABLED, UniformType.INT); ++ uniforms.externallyManagedUniform(BedrockIrisAlpha.REFERENCE, UniformType.FLOAT); ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinIrisFallbackAlpha.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinIrisFallbackAlpha.java +new file mode 100644 +index 0000000..ec75763 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinIrisFallbackAlpha.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.injection.mixin.compat.iris; ++ ++import com.llamalad7.mixinextras.injector.wrapoperation.Operation; ++import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; ++import com.mojang.renderpearl.backend.opengl.GlProgram; ++import com.viaversion.viafabricplus.bedrock.render.BedrockShaderAlpha; ++import com.viaversion.viafabricplus.bedrock.render.compat.iris.BedrockIrisAlphaUniforms; ++import org.lwjgl.opengl.GL46C; ++import org.spongepowered.asm.mixin.Mixin; ++import org.spongepowered.asm.mixin.Pseudo; ++import org.spongepowered.asm.mixin.Unique; ++import org.spongepowered.asm.mixin.injection.At; ++import org.spongepowered.asm.mixin.injection.Inject; ++import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; ++ ++@Pseudo ++@Mixin(targets = "net.irisshaders.iris.pipeline.programs.FallbackShader", remap = false) ++public abstract class MixinIrisFallbackAlpha { ++ @Unique ++ private int bedrock$alphaLocation = -1; ++ @Unique ++ private float bedrock$alphaReference; ++ @Unique ++ private BedrockIrisAlphaUniforms bedrock$sourceAlpha; ++ ++ @Inject(method = "", at = @At("RETURN")) ++ private void bedrock$locations(final CallbackInfo ci) { ++ this.bedrock$sourceAlpha = new BedrockIrisAlphaUniforms(((GlProgram) (Object) this).getProgramId()); ++ } ++ ++ @Inject(method = "iris$setupState", at = @At("RETURN")) ++ private void bedrock$bindSourceAlpha(final CallbackInfo ci) { ++ this.bedrock$sourceAlpha.bind(); ++ } ++ ++ @WrapOperation(method = "", at = @At(value = "INVOKE", target = "Lorg/lwjgl/opengl/GL46C;glUniform1f(IF)V")) ++ private void bedrock$rememberAlpha(final int location, final float reference, final Operation original) { ++ this.bedrock$alphaLocation = location; ++ this.bedrock$alphaReference = reference; ++ original.call(location, reference); ++ } ++ ++ @Inject(method = "iris$setupState", at = @At(value = "INVOKE", ++ target = "Lcom/mojang/renderpearl/backend/opengl/GlStateManager;_glUseProgram(I)V", shift = At.Shift.AFTER)) ++ private void bedrock$alphaReference(final CallbackInfo ci) { ++ if (this.bedrock$alphaLocation >= 0) { ++ // Fallback programs are shared too, so restore their original value on every Java draw. ++ GL46C.glUniform1f(this.bedrock$alphaLocation, BedrockShaderAlpha.reference(this.bedrock$alphaReference)); ++ } ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinIrisFallbackTransform.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinIrisFallbackTransform.java +new file mode 100644 +index 0000000..c52008a +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinIrisFallbackTransform.java +@@ -0,0 +1,40 @@ ++/* ++ * 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.compat.iris; ++ ++import com.llamalad7.mixinextras.injector.ModifyReturnValue; ++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.render.compat.iris.BedrockIrisAlpha; ++import net.irisshaders.iris.gl.blending.AlphaTest; ++import net.irisshaders.iris.gl.state.ShaderAttributeInputs; ++import org.spongepowered.asm.mixin.Mixin; ++import org.spongepowered.asm.mixin.Pseudo; ++import org.spongepowered.asm.mixin.injection.At; ++ ++@Pseudo ++@Mixin(targets = "net.irisshaders.iris.pipeline.fallback.ShaderSynthesizer", remap = false) ++public abstract class MixinIrisFallbackTransform { ++ @ModifyReturnValue(method = "vsh", at = @At("RETURN")) ++ private static String bedrock$vertex(final String source, @Local(argsOnly = true) final ShaderAttributeInputs inputs) { ++ return inputs.hasTex() ? BedrockIrisAlpha.fallback(source, true) : source; ++ } ++ ++ @ModifyReturnValue(method = "fsh", at = @At("RETURN")) ++ private static String bedrock$fragment(final String source, @Local(argsOnly = true) final ShaderAttributeInputs inputs) { ++ return inputs.hasTex() ? BedrockIrisAlpha.fallback(source, false) : source; ++ } ++ ++ @WrapOperation(method = "fsh", at = @At(value = "INVOKE", ++ target = "Lnet/irisshaders/iris/gl/blending/AlphaTest;toExpression(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;")) ++ private static String bedrock$javaAlpha(final AlphaTest test, final String alpha, final String reference, ++ final String indent, final Operation original, ++ @Local(argsOnly = true) final ShaderAttributeInputs inputs) { ++ final String expression = original.call(test, alpha, reference, indent); ++ return inputs.hasTex() ? BedrockIrisAlpha.javaAlphaTest(expression) : expression; ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinIrisJavaAlphaTest.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinIrisJavaAlphaTest.java +new file mode 100644 +index 0000000..7be98d5 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinIrisJavaAlphaTest.java +@@ -0,0 +1,29 @@ ++/* ++ * 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.compat.iris; ++ ++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.render.compat.iris.BedrockIrisAlpha; ++import net.irisshaders.iris.gl.blending.AlphaTest; ++import net.irisshaders.iris.pipeline.transform.parameter.Parameters; ++import org.spongepowered.asm.mixin.Mixin; ++import org.spongepowered.asm.mixin.Pseudo; ++import org.spongepowered.asm.mixin.injection.At; ++ ++@Pseudo ++@Mixin(targets = "net.irisshaders.iris.pipeline.transform.transformer.CommonTransformer", remap = false) ++public abstract class MixinIrisJavaAlphaTest { ++ @WrapOperation(method = "transform", at = @At(value = "INVOKE", ++ target = "Lnet/irisshaders/iris/gl/blending/AlphaTest;toExpression(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;")) ++ private static String bedrock$javaAlpha(final AlphaTest test, final String alpha, final String reference, ++ final String indent, final Operation original, ++ @Local(argsOnly = true) final Parameters parameters) { ++ final String expression = original.call(test, alpha, reference, indent); ++ return BedrockIrisAlpha.supports(parameters) ? BedrockIrisAlpha.javaAlphaTest(expression) : expression; ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinIrisMaterialAlpha.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinIrisMaterialAlpha.java +new file mode 100644 +index 0000000..da86a05 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinIrisMaterialAlpha.java +@@ -0,0 +1,40 @@ ++/* ++ * 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.compat.iris; ++ ++import com.llamalad7.mixinextras.injector.ModifyExpressionValue; ++import com.mojang.renderpearl.backend.opengl.GlProgram; ++import com.viaversion.viafabricplus.bedrock.render.BedrockShaderAlpha; ++import com.viaversion.viafabricplus.bedrock.render.compat.iris.BedrockIrisAlphaUniforms; ++import org.spongepowered.asm.mixin.Mixin; ++import org.spongepowered.asm.mixin.Pseudo; ++import org.spongepowered.asm.mixin.Unique; ++import org.spongepowered.asm.mixin.injection.At; ++import org.spongepowered.asm.mixin.injection.Inject; ++import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; ++ ++@Pseudo ++@Mixin(targets = "net.irisshaders.iris.pipeline.programs.ExtendedShader", remap = false) ++public abstract class MixinIrisMaterialAlpha { ++ @Unique ++ private BedrockIrisAlphaUniforms bedrock$sourceAlpha; ++ ++ @Inject(method = "", at = @At("RETURN")) ++ private void bedrock$locations(final CallbackInfo ci) { ++ this.bedrock$sourceAlpha = new BedrockIrisAlphaUniforms(((GlProgram) (Object) this).getProgramId()); ++ } ++ ++ @Inject(method = "iris$setupState", at = @At("RETURN")) ++ private void bedrock$bindSourceAlpha(final CallbackInfo ci) { ++ this.bedrock$sourceAlpha.bind(); ++ } ++ ++ @ModifyExpressionValue(method = "iris$setupState", at = @At(value = "FIELD", ++ target = "Lnet/irisshaders/iris/pipeline/programs/ExtendedShader;alphaTest:F")) ++ private float bedrock$alphaReference(final float reference) { ++ return BedrockShaderAlpha.reference(reference); ++ } ++} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinIrisPipelines.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinIrisPipelines.java new file mode 100644 index 0000000..0e2a633 @@ -652,6 +1054,77 @@ index 0000000..0e2a633 + return BedrockShaderPipelines.equivalent(pipeline); + } +} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinNativeShaderBinding.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinNativeShaderBinding.java +new file mode 100644 +index 0000000..6ec822a +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinNativeShaderBinding.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.injection.mixin.compat.iris; ++ ++import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; ++import com.llamalad7.mixinextras.injector.wrapoperation.Operation; ++import com.mojang.renderpearl.backend.opengl.GlRenderPipeline; ++import com.viaversion.viafabricplus.bedrock.injection.access.IBedrockShaderPipeline; ++import com.viaversion.viafabricplus.bedrock.render.BedrockShaderAlpha; ++import org.spongepowered.asm.mixin.Mixin; ++import org.spongepowered.asm.mixin.Unique; ++ ++@Mixin(GlRenderPipeline.class) ++public abstract class MixinNativeShaderBinding implements IBedrockShaderPipeline { ++ @Unique ++ private float bedrock$alphaReference = Float.NaN; ++ ++ @Override ++ public void bedrock$alphaReference(final float reference) { ++ this.bedrock$alphaReference = reference; ++ } ++ ++ @WrapMethod(method = "bind") ++ private void bedrock$material(final Operation original) { ++ BedrockShaderAlpha.bind(this.bedrock$alphaReference, () -> original.call()); ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinNativeShaderCompilation.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinNativeShaderCompilation.java +new file mode 100644 +index 0000000..3f8a48d +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinNativeShaderCompilation.java +@@ -0,0 +1,29 @@ ++/* ++ * 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.compat.iris; ++ ++import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; ++import com.llamalad7.mixinextras.injector.wrapoperation.Operation; ++import com.mojang.blaze3d.systems.RenderSystem; ++import com.mojang.renderpearl.api.pipeline.CompiledRenderPipeline; ++import com.mojang.renderpearl.api.pipeline.RenderPipeline; ++import com.mojang.renderpearl.frontend.FrontendRenderPipeline; ++import com.viaversion.viafabricplus.bedrock.injection.access.IBedrockShaderPipeline; ++import com.viaversion.viafabricplus.bedrock.render.BedrockShaderPipelines; ++import org.spongepowered.asm.mixin.Mixin; ++ ++@Mixin(RenderSystem.class) ++public abstract class MixinNativeShaderCompilation { ++ @WrapMethod(method = "getCompiledPipelineNullable") ++ private static CompiledRenderPipeline bedrock$material(final RenderPipeline pipeline, final Operation original) { ++ // Wrap the complete method so this sees Iris's cached override, including cancelled returns. ++ final CompiledRenderPipeline compiled = original.call(pipeline); ++ if (compiled instanceof FrontendRenderPipeline frontend && frontend.backendRenderPipeline() instanceof IBedrockShaderPipeline material) { ++ material.bedrock$alphaReference(BedrockShaderPipelines.alphaReference(pipeline)); ++ } ++ return compiled; ++ } ++} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinAbstractClientPlayer.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinAbstractClientPlayer.java new file mode 100644 index 0000000..13cd684 @@ -1691,7 +2164,7 @@ index 0000000..8e46c00 +} 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..9e8d844 +index 0000000..fac68bb --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorRenderTypes.java @@ -0,0 +1,87 @@ @@ -1750,7 +2223,7 @@ index 0000000..9e8d844 + final var shaderPipeline = emissive ? RenderPipelines.EYES + : ignoreLighting ? RenderPipelines.ENTITY_CUTOUT_Z_OFFSET + : cull ? RenderPipelines.ENTITY_CUTOUT_CULL : RenderPipelines.ENTITY_CUTOUT; -+ return BedrockShaderPipelines.register(builder.build(), shaderPipeline); ++ return BedrockShaderPipelines.register(builder.build(), shaderPipeline, emissive ? Float.NaN : 0.5F); + } + + public static int lightCoords(final int light, final boolean ignoreLighting) { @@ -2996,6 +3469,41 @@ index 0000000..79525c0 + } + } +} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderAlpha.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderAlpha.java +new file mode 100644 +index 0000000..7887528 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderAlpha.java +@@ -0,0 +1,29 @@ ++/* ++ * 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; ++ ++/** The material of the GPU pipeline being bound, rather than the shared shader program. */ ++public final class BedrockShaderAlpha { ++ private static final ThreadLocal REFERENCE = ThreadLocal.withInitial(() -> new float[] {Float.NaN}); ++ ++ private BedrockShaderAlpha() {} ++ ++ public static float reference(final float fallback) { ++ final float value = REFERENCE.get()[0]; ++ return Float.isNaN(value) ? fallback : value; ++ } ++ ++ public static void bind(final float reference, final Runnable action) { ++ final float[] current = REFERENCE.get(); ++ final float previous = current[0]; ++ current[0] = reference; ++ try { ++ action.run(); ++ } finally { ++ current[0] = previous; ++ } ++ } ++} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderEmission.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderEmission.java new file mode 100644 index 0000000..ae21632 @@ -3185,10 +3693,10 @@ index 0000000..ae21632 +} 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..a8c9053 +index 0000000..c0a83ed --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderPipelines.java -@@ -0,0 +1,28 @@ +@@ -0,0 +1,40 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -3203,19 +3711,31 @@ index 0000000..a8c9053 + +/** Shader-pack equivalents for native pipelines, independent of an installed shader mod. */ +public final class BedrockShaderPipelines { -+ private static final Map EQUIVALENTS = Collections.synchronizedMap(new IdentityHashMap<>()); ++ private static final Map MATERIALS = Collections.synchronizedMap(new IdentityHashMap<>()); + + private BedrockShaderPipelines() { + } + + public static RenderPipeline register(final RenderPipeline nativePipeline, final RenderPipeline shaderPipeline) { -+ EQUIVALENTS.put(nativePipeline, shaderPipeline); ++ return register(nativePipeline, shaderPipeline, Float.NaN); ++ } ++ ++ public static RenderPipeline register(final RenderPipeline nativePipeline, final RenderPipeline shaderPipeline, final float alphaReference) { ++ MATERIALS.put(nativePipeline, new Material(shaderPipeline, alphaReference)); + return nativePipeline; + } + + public static RenderPipeline equivalent(final RenderPipeline pipeline) { -+ return EQUIVALENTS.getOrDefault(pipeline, pipeline); ++ final Material material = MATERIALS.get(pipeline); ++ return material == null ? pipeline : material.pipeline(); ++ } ++ ++ public static float alphaReference(final RenderPipeline pipeline) { ++ final Material material = MATERIALS.get(pipeline); ++ return material == null ? Float.NaN : material.alphaReference(); + } ++ ++ private record Material(RenderPipeline pipeline, float alphaReference) {} +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockSkinImages.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockSkinImages.java new file mode 100644 @@ -3463,6 +3983,117 @@ index 0000000..5b31c39 + @Override + public Type type() { return TYPE; } +} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/compat/iris/BedrockIrisAlpha.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/compat/iris/BedrockIrisAlpha.java +new file mode 100644 +index 0000000..db0f807 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/compat/iris/BedrockIrisAlpha.java +@@ -0,0 +1,68 @@ ++/* ++ * 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.compat.iris; ++ ++import io.github.douira.glsl_transformer.ast.node.TranslationUnit; ++import io.github.douira.glsl_transformer.ast.transform.ASTInjectionPoint; ++import io.github.douira.glsl_transformer.ast.transform.ASTParser; ++import io.github.douira.glsl_transformer.ast.transform.JobParameters; ++import io.github.douira.glsl_transformer.ast.transform.SingleASTTransformer; ++import net.irisshaders.iris.pipeline.transform.PatchShaderType; ++import net.irisshaders.iris.pipeline.transform.parameter.Parameters; ++import net.irisshaders.iris.pipeline.transform.parameter.VanillaParameters; ++ ++/** Native coverage uses the original actor texture, before shader-pack color or UV changes. */ ++public final class BedrockIrisAlpha { ++ public static final String ENABLED = "iris_bedrock_AlphaEnabled"; ++ public static final String REFERENCE = "iris_bedrock_AlphaReference"; ++ public static final String SAMPLER = "iris_bedrock_SourceTexture"; ++ private static final String UV = "iris_bedrock_SourceUv"; ++ ++ private BedrockIrisAlpha() {} ++ ++ public static boolean supports(final Parameters parameters) { ++ // Extra stages need their own varying transport. Do not create an unlinked interface. ++ return parameters instanceof VanillaParameters vanilla && vanilla.inputs.hasTex() ++ && !vanilla.isClouds() && !vanilla.hasGeometry && !vanilla.hasTesselation; ++ } ++ ++ public static String javaAlphaTest(final String expression) { ++ return "if (" + ENABLED + " == 0) {\n" + expression + "\n}\n"; ++ } ++ ++ public static void transform(final ASTParser parser, final TranslationUnit tree, final Parameters parameters) { ++ if (!supports(parameters)) return; ++ if (parameters.type == PatchShaderType.VERTEX) { ++ vertex(parser, tree, "iris_UV0"); ++ } else if (parameters.type == PatchShaderType.FRAGMENT) { ++ fragment(parser, tree); ++ } ++ } ++ ++ public static String fallback(final String source, final boolean vertex) { ++ final var transformer = new SingleASTTransformer(); ++ transformer.setTransformation(tree -> { ++ if (vertex) vertex(transformer, tree, "UV0"); ++ else fragment(transformer, tree); ++ }); ++ return transformer.transform(source); ++ } ++ ++ private static void vertex(final ASTParser parser, final TranslationUnit tree, final String attribute) { ++ tree.parseAndInjectNode(parser, ASTInjectionPoint.BEFORE_DECLARATIONS, "out vec2 " + UV + ";"); ++ tree.prependMainFunctionBody(parser, UV + " = " + attribute + ";"); ++ } ++ ++ private static void fragment(final ASTParser parser, final TranslationUnit tree) { ++ tree.parseAndInjectNodes(parser, ASTInjectionPoint.BEFORE_DECLARATIONS, ++ "in vec2 " + UV + ";", ++ "uniform int " + ENABLED + ";", ++ "uniform float " + REFERENCE + ";", ++ "uniform sampler2D " + SAMPLER + ";"); ++ tree.prependMainFunctionBody(parser, "if (" + ENABLED + " != 0 && !(texture(" ++ + SAMPLER + ", " + UV + ").a > " + REFERENCE + ")) { discard; }"); ++ } ++} +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..4fff8de +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/compat/iris/BedrockIrisAlphaUniforms.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.render.compat.iris; ++ ++import com.viaversion.viafabricplus.bedrock.render.BedrockShaderAlpha; ++import org.lwjgl.opengl.GL46C; ++ ++/** Locations belong to one program; values belong to each pipeline bind. */ ++public final class BedrockIrisAlphaUniforms { ++ private final int enabled; ++ private final int reference; ++ private final int sampler; ++ ++ public BedrockIrisAlphaUniforms(final int program) { ++ this.enabled = GL46C.glGetUniformLocation(program, BedrockIrisAlpha.ENABLED); ++ this.reference = GL46C.glGetUniformLocation(program, BedrockIrisAlpha.REFERENCE); ++ this.sampler = GL46C.glGetUniformLocation(program, BedrockIrisAlpha.SAMPLER); ++ } ++ ++ /** Call after Iris updates uniforms and samplers, while this program is current. */ ++ public void bind() { ++ final float nativeReference = BedrockShaderAlpha.reference(Float.NaN); ++ GL46C.glUniform1i(this.enabled, Float.isNaN(nativeReference) ? 0 : 1); ++ GL46C.glUniform1f(this.reference, Float.isNaN(nativeReference) ? 0F : nativeReference); ++ // A pack may replace gtexture. This separate sampler always reads the actor's unit zero. ++ GL46C.glUniform1i(this.sampler, 0); ++ } ++} 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..8039501 @@ -3583,18 +4214,27 @@ index 35145bc..f42de8f 100644 +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; diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json -index b44b3ca..404aac2 100644 +index b44b3ca..fd73b87 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -5,6 +5,7 @@ +@@ -5,6 +5,16 @@ "compatibilityLevel": "JAVA_25", "priority": 1001, "client": [ + "compat.iris.MixinIrisPipelines", ++ "compat.iris.MixinNativeShaderCompilation", ++ "compat.iris.MixinNativeShaderBinding", ++ "compat.iris.MixinIrisMaterialAlpha", ++ "compat.iris.MixinIrisFallbackAlpha", ++ "compat.iris.MixinIrisAlphaTransform", ++ "compat.iris.MixinIrisJavaAlphaTest", ++ "compat.iris.MixinIrisFallbackTransform", ++ "compat.iris.MixinIrisAlphaUniforms", ++ "compat.iris.MixinIrisAlphaSampler", "jjwt.MixinClasses", "jjwt.MixinDefaultJwtParserBuilder", "core.access.MixinConfirmScreen", -@@ -45,6 +46,22 @@ +@@ -45,6 +55,22 @@ "features.misc.MixinClientSuggestionProvider", "features.misc.MixinInventoryScreen", "features.misc.MixinMinecraft", @@ -4929,12 +5569,49 @@ index 0000000..e3eadc3 + } + } +} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderAlphaTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderAlphaTest.java +new file mode 100644 +index 0000000..0827204 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderAlphaTest.java +@@ -0,0 +1,31 @@ ++package com.viaversion.viafabricplus.bedrock.render; ++ ++import java.util.concurrent.CompletableFuture; ++import org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++final class BedrockShaderAlphaTest { ++ @Test ++ void scopesTheReferenceToEachBindingAndRestoresNestedJavaAndNativeDraws() { ++ assertEquals(0.1F, BedrockShaderAlpha.reference(0.1F)); ++ BedrockShaderAlpha.bind(0.5F, () -> { ++ assertEquals(0.5F, BedrockShaderAlpha.reference(0.1F)); ++ BedrockShaderAlpha.bind(Float.NaN, () -> assertEquals(0.1F, BedrockShaderAlpha.reference(0.1F))); ++ assertEquals(0.5F, BedrockShaderAlpha.reference(0.1F)); ++ BedrockShaderAlpha.bind(0F, () -> assertEquals(0F, BedrockShaderAlpha.reference(0.1F))); ++ assertEquals(0.5F, BedrockShaderAlpha.reference(0.1F)); ++ }); ++ assertEquals(0.1F, BedrockShaderAlpha.reference(0.1F)); ++ } ++ ++ @Test ++ void restoresTheBindingAfterFailureAndDoesNotAffectOtherThreads() { ++ final var failure = new IllegalStateException(); ++ assertSame(failure, assertThrows(IllegalStateException.class, () -> BedrockShaderAlpha.bind(0.5F, () -> { ++ assertEquals(0.1F, CompletableFuture.supplyAsync(() -> BedrockShaderAlpha.reference(0.1F)).join()); ++ throw failure; ++ }))); ++ assertEquals(0.1F, BedrockShaderAlpha.reference(0.1F)); ++ } ++} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderPipelinesTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderPipelinesTest.java new file mode 100644 -index 0000000..cbdb67c +index 0000000..f9c2ba3 --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderPipelinesTest.java -@@ -0,0 +1,49 @@ +@@ -0,0 +1,52 @@ +package com.viaversion.viafabricplus.bedrock.render; + +import com.mojang.renderpearl.api.pipeline.RenderPipeline; @@ -4957,6 +5634,8 @@ index 0000000..cbdb67c + : unlit ? RenderPipelines.ENTITY_CUTOUT_Z_OFFSET + : cull ? RenderPipelines.ENTITY_CUTOUT_CULL : RenderPipelines.ENTITY_CUTOUT; + assertSame(expected, BedrockShaderPipelines.equivalent(actor)); ++ if (emissive) assertTrue(Float.isNaN(BedrockShaderPipelines.alphaReference(actor))); ++ else assertEquals(0.5F, BedrockShaderPipelines.alphaReference(actor)); + assertEquals(expected.getVertexFormatBindings(), actor.getVertexFormatBindings()); + assertEquals(cull, actor.isCull()); + assertSame(actor, BedrockActorRenderTypes.selectPipeline(cull, unlit, emissive, nether)); @@ -4980,6 +5659,7 @@ index 0000000..cbdb67c + final var custom = RenderPipeline.builder(RenderPipelines.ENTITY_SNIPPET) + .withLocation(Identifier.fromNamespaceAndPath("test", "shader_equivalent")).build(); + assertSame(custom, BedrockShaderPipelines.equivalent(custom)); ++ assertTrue(Float.isNaN(BedrockShaderPipelines.alphaReference(custom))); + assertSame(custom, BedrockShaderPipelines.register(custom, RenderPipelines.EYES)); + assertSame(RenderPipelines.EYES, BedrockShaderPipelines.equivalent(custom)); + } @@ -5140,5 +5820,212 @@ index 0000000..101df6e + } + +} +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..4d10e53 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/compat/iris/BedrockIrisAlphaGpuTest.java +@@ -0,0 +1,201 @@ ++package com.viaversion.viafabricplus.bedrock.render.compat.iris; ++ ++import com.viaversion.viafabricplus.bedrock.render.BedrockShaderAlpha; ++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; ++import java.util.Set; ++import net.irisshaders.iris.gl.blending.AlphaTest; ++import net.irisshaders.iris.gl.blending.AlphaTestFunction; ++import net.irisshaders.iris.gl.state.ShaderAttributeInputs; ++import net.irisshaders.iris.pipeline.transform.Patch; ++import net.irisshaders.iris.pipeline.transform.PatchShaderType; ++import net.irisshaders.iris.pipeline.transform.parameter.VanillaParameters; ++import org.junit.jupiter.api.Test; ++import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; ++import org.lwjgl.egl.EGL; ++import org.lwjgl.opengl.GL; ++import org.lwjgl.system.MemoryStack; ++ ++import static org.junit.jupiter.api.Assertions.*; ++import static org.lwjgl.egl.EGL10.*; ++import static org.lwjgl.egl.EGL14.*; ++import static org.lwjgl.egl.KHRCreateContext.*; ++import static org.lwjgl.opengl.GL33C.*; ++ ++/** Real driver coverage checks. Enable with EGL_PLATFORM=surfaceless on Mesa machines. */ ++@EnabledIfEnvironmentVariable(named = "STACKANVIL_IRIS_ALPHA_GPU_TESTS", matches = "1") ++final class BedrockIrisAlphaGpuTest { ++ @Test ++ void testsSourceCoverageAndRestoresEveryJavaComparisonInSharedPrograms() { ++ final long display = eglGetDisplay(EGL_DEFAULT_DISPLAY); ++ assertNotEquals(EGL_NO_DISPLAY, display); ++ long surface = EGL_NO_SURFACE; ++ long context = EGL_NO_CONTEXT; ++ try (var stack = MemoryStack.stackPush()) { ++ final var major = stack.mallocInt(1); ++ final var minor = stack.mallocInt(1); ++ assertTrue(eglInitialize(display, major, minor)); ++ EGL.createDisplayCapabilities(display, major.get(0), minor.get(0)); ++ assertTrue(eglBindAPI(EGL_OPENGL_API)); ++ final var configs = stack.mallocPointer(1); ++ final var count = stack.mallocInt(1); ++ assertTrue(eglChooseConfig(display, stack.ints(EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, ++ EGL_RENDERABLE_TYPE, EGL_OPENGL_BIT, EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, ++ EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8, EGL_NONE), configs, count)); ++ assertTrue(count.get(0) > 0); ++ surface = eglCreatePbufferSurface(display, configs.get(0), stack.ints(EGL_WIDTH, 1, EGL_HEIGHT, 1, EGL_NONE)); ++ assertNotEquals(EGL_NO_SURFACE, surface); ++ context = eglCreateContext(display, configs.get(0), EGL_NO_CONTEXT, ++ stack.ints(EGL_CONTEXT_MAJOR_VERSION_KHR, 3, EGL_CONTEXT_MINOR_VERSION_KHR, 3, ++ EGL_CONTEXT_OPENGL_PROFILE_MASK_KHR, EGL_CONTEXT_OPENGL_CORE_PROFILE_BIT_KHR, EGL_NONE)); ++ assertNotEquals(EGL_NO_CONTEXT, context); ++ assertTrue(eglMakeCurrent(display, surface, surface, context)); ++ GL.createCapabilities(); ++ verifyPrograms(); ++ assertEquals(GL_NO_ERROR, glGetError()); ++ } finally { ++ GL.setCapabilities(null); ++ eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); ++ if (context != EGL_NO_CONTEXT) eglDestroyContext(display, context); ++ if (surface != EGL_NO_SURFACE) eglDestroySurface(display, surface); ++ eglTerminate(display); ++ } ++ } ++ ++ private static void verifyPrograms() { ++ final int vao = glGenVertexArrays(); ++ final int source = glGenTextures(); ++ final int override = glGenTextures(); ++ try { ++ glBindVertexArray(vao); ++ glVertexAttrib2f(0, 0.25F, 0.5F); ++ glViewport(0, 0, 1, 1); ++ glDisable(GL_BLEND); ++ glDisable(GL_DEPTH_TEST); ++ for (boolean fallback : new boolean[] {false, true}) { ++ for (var function : AlphaTestFunction.values()) { ++ final int program = program(function, fallback); ++ try { ++ glUseProgram(program); ++ glUniform1i(glGetUniformLocation(program, "gtexture"), 1); ++ glUniform1f(glGetUniformLocation(program, "iris_currentAlphaTest"), 0.5F); ++ final var uniforms = new BedrockIrisAlphaUniforms(program); ++ for (float alpha : new float[] {0F, 127F / 255F, 0.5F, 128F / 255F, 1F}) { ++ texture(0, source, alpha, 1F - alpha); ++ texture(1, override, 1F, 1F); ++ for (float outputAlpha : new float[] {0F, 0.25F, 0.5F, 0.75F, 1F}) { ++ glUniform1f(glGetUniformLocation(program, "outputAlpha"), outputAlpha); ++ // Reuse the same program in both directions, without recompilation. ++ for (float reference : new float[] {0.5F, Float.NaN, 0.5F, Float.NaN}) { ++ BedrockShaderAlpha.bind(reference, uniforms::bind); ++ glClearColor(1, 0, 0, 1); ++ glClear(GL_COLOR_BUFFER_BIT); ++ glDrawArrays(GL_TRIANGLES, 0, 3); ++ final boolean kept = Float.isNaN(reference) ? compare(function, outputAlpha) : alpha > 0.5F; ++ try (var stack = MemoryStack.stackPush()) { ++ final var pixel = stack.malloc(4); ++ glReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); ++ assertEquals(kept ? 0 : 255, Byte.toUnsignedInt(pixel.get(0)), ++ () -> function + " source=" + alpha + " output=" + outputAlpha + " native=" + !Float.isNaN(reference)); ++ assertEquals(kept ? 255 : 0, Byte.toUnsignedInt(pixel.get(1))); ++ if (kept) assertEquals(Math.round(outputAlpha * 255), Byte.toUnsignedInt(pixel.get(3)), 1); ++ } ++ } ++ } ++ } ++ } finally { ++ glUseProgram(0); ++ glDeleteProgram(program); ++ } ++ } ++ } ++ } finally { ++ glDeleteTextures(source); ++ glDeleteTextures(override); ++ glDeleteVertexArrays(vao); ++ } ++ } ++ ++ private static boolean compare(final AlphaTestFunction function, final float alpha) { ++ return switch (function) { ++ case NEVER -> false; ++ case LESS -> alpha < 0.5F; ++ case EQUAL -> alpha == 0.5F; ++ case LEQUAL -> alpha <= 0.5F; ++ case GREATER -> alpha > 0.5F; ++ case NOTEQUAL -> alpha != 0.5F; ++ case GEQUAL -> alpha >= 0.5F; ++ case ALWAYS -> true; ++ }; ++ } ++ ++ private static void texture(final int unit, final int texture, final float first, final float second) { ++ 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); ++ try (var stack = MemoryStack.stackPush()) { ++ final var pixels = stack.floats(0F, 1F, 0F, first, 0F, 1F, 0F, second); ++ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA32F, 2, 1, 0, GL_RGBA, GL_FLOAT, pixels); ++ } ++ } ++ ++ private static int program(final AlphaTestFunction function, final boolean fallback) { ++ final String attribute = fallback ? "UV0" : "iris_UV0"; ++ final String vertex = """ ++ #version 330 core ++ layout(location=0) in vec2 %s; ++ out vec2 packUv; ++ void main() { ++ packUv = vec2(0.75, 0.5); ++ vec2 pos = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2); ++ gl_Position = vec4(pos * 2.0 - 1.0, 0.0, 1.0); ++ } ++ """.formatted(attribute); ++ final String fragment = """ ++ #version 330 core ++ uniform sampler2D gtexture; ++ uniform float outputAlpha; ++ uniform float iris_currentAlphaTest; ++ in vec2 packUv; ++ out vec4 fragColor; ++ void main() { ++ fragColor = vec4(texture(gtexture, packUv).rgb, outputAlpha); ++ %s ++ } ++ """.formatted(BedrockIrisAlpha.javaAlphaTest( ++ new AlphaTest(function, 0.5F).toExpression("fragColor.a", "iris_currentAlphaTest", ""))); ++ 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)); ++ return program; ++ } finally { ++ glDeleteShader(vs); ++ glDeleteShader(fs); ++ } ++ } ++ ++ private static String transformed(final String source, final PatchShaderType type, final boolean fallback) { ++ if (fallback) return BedrockIrisAlpha.fallback(source, type == PatchShaderType.VERTEX); ++ final var parameters = new VanillaParameters(Patch.VANILLA, new Object2ObjectOpenHashMap<>(), Set.of(), ++ AlphaTest.ALWAYS, false, false, false, new ShaderAttributeInputs(true, true, false, false, false), false, false); ++ parameters.type = type; ++ final var transformer = new SingleASTTransformer(); ++ transformer.setTransformation(tree -> BedrockIrisAlpha.transform(transformer, tree, parameters)); ++ return transformer.transform(source); ++ } ++ ++ 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; ++ } ++} -- 2.56.0 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 ee71f515..9731d92e 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 @@ -333,3 +333,74 @@ tooling tests pass. The fresh 130-second client replay installs all 273 skins an geometry records, with no geometry-parse warnings. Transport verification passes, and all seven owned Java/display processes stop normally. Rendering verification still fails on unresolved mappings. These checks do not establish native visual parity. + + +## Native alpha references with Iris + +Ordinary native actors and supplied player bodies now retain their 0.5 alpha reference when Iris replaces the shader. +The target Bedrock 1.26.51.1/protocol 2193 Actor AlphaTest shader keeps texture alpha strictly above 0.5. +The native shader evidence is recorded above. +Iris 1.11.7 for Minecraft 26.3 uses 0.1 for its entity cutout family. + +Keep the reference on the compiled GPU pipeline, since Iris shares programs between native and Java draws. +A binding scope supplies the reference during Iris shader setup and restores its previous value even after failure. +The generated fallback shader also restores its original uniform on each Java bind. +Native emissive passes retain their existing material behavior. + +Validation: + +- All 38 add-on patches replay successfully. +- Converter, core, and add-on builds pass 2,352 tests, with 152 optional tests skipped and no failures or errors. +- Scope tests cover nested native and Java binds, exceptions, and separate threads. +- A complete 40-second replay without Iris passes transport checks and has no mixin failures. +- A Galaxite replay with Iris 1.11.7 and Complementary Reimagined r5.9.3 reaches gameplay without mixin failures. +- GL uniform readbacks verify native 0.5 and Java 0.1 within two shared ExtendedShader programs. +- A private shader pack that omits entity programs exercises Iris fallback shaders. +- GL readbacks verify native 0.5 and Java 0.1 within one shared FallbackShader program. + +Those Iris validation runs report unresolved marker geometry and a missing camera item texture. +The CubeConverter patch addresses the marker declaration with omitted fields. +Those Iris runs also reject the font atlas in `galaxite:gem_bag_particle`; the owning particle patch addresses that separate loader failure. +The fallback diagnostic also intentionally differs from the locked shader pack and fails that profile check. +This change carries the alpha reference through Iris. +It does not establish parity for shader-pack comparison overrides, post-shader alpha, source-texture sampling, or native image output. +Raw recordings, the diagnostic shader pack, observer code, and uniform logs remain private. + +## Iris source alpha coverage + +Ordinary native materials now test their original texture alpha before the shader pack changes color or texture coordinates. +The native branch keeps alpha strictly above 0.5 and skips Iris's generated output-alpha comparison. +Java draws retain that comparison, including the pack's chosen function. +Shader-pack authored discard statements remain intact. + +The vertex transform retains the original UV attribute in a separate varying. +A separate external sampler reads texture unit zero, even when the pack replaces `gtexture`. +Each program caches its uniform locations. +Each bind selects native or Java behavior after Iris updates its own uniforms and samplers. +The synthesized fallback shaders use the same coverage contract. +Iris and its GLSL parser remain optional compile-only dependencies and are absent from the packaged add-on and published POM. + +Validation: + +- All 38 add-on patches replay. All four builds pass 2,499 tests, with 212 optional skips and no failures or errors. +- A Mesa EGL test checks 1,600 rendered pixels across ordinary and fallback transforms, all eight comparison functions, and shared-program binds. +- Source alpha includes exact 0.5 and values on either side. Output alpha varies independently of coverage. +- A different texture on the shader-pack sampler and altered pack UVs cannot change native coverage. +- Four private negative controls restore the pack comparison, sample the pack texture, use altered UVs, or retain native mode on Java draws. + Each control fails the pixel regression. The unchanged implementation passes. +- A fresh CubeCraft recording completes gameplay with Iris 1.11.7 and Complementary Reimagined r5.9.3. +- Actual ExtendedShader GL readbacks verify native selection, reference 0.5, and source unit zero. + Four programs also serve Java draws, where native selection resets correctly. +- A private final-pass-only shader pack exercises FallbackShader programs. + GL readbacks verify the same native state and Java reset in shared fallback programs. + This diagnostic intentionally differs from the locked production shader profile. +- A complete plain-client replay without Iris passes transport checks with no class-loading or mixin failures. +- All seven client sessions stop normally. Birth-identity checks find no remaining owned Java or display processes. +- Offline validation passes all 271 tooling tests, static checks, and patch metadata checks. + +The fresh Galaxite join disconnects with End of stream before its capture completes. +The CubeCraft replay passes transport checks but still reports unresolved mappings. +These results do not establish complete native image parity. +Geometry and tessellation programs retain the earlier reference bridge until their UV transport is implemented and verified. +Emissive material integration and arbitrary shader-pack output behavior remain separate work. +Raw recordings, shader diagnostics, observer code, and pixel-control logs remain private. diff --git a/patches/viafabricplus-bedrock/upstreamable/0003-classic-dressing-room.patch b/patches/viafabricplus-bedrock/upstreamable/0003-classic-dressing-room.patch index 008e0a8e..fe5246cf 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0003-classic-dressing-room.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0003-classic-dressing-room.patch @@ -1,4 +1,4 @@ -From 5e6638c396371a94f2b7c3f44eda83d272b511dd Mon Sep 17 00:00:00 2001 +From bfaeb1451a3f1f1fc495d4a0f5e281dd8cc1856b 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 classic Dressing Room @@ -2185,10 +2185,10 @@ index 364ec25..49f0538 100644 "bedrock_realms.viafabricplus.availability_check": "Checking availability...", "bedrock_realms.viafabricplus.no_worlds": "No worlds found :(", diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json -index 404aac2..38f830c 100644 +index fd73b87..f88d04e 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -61,6 +61,7 @@ +@@ -70,6 +70,7 @@ "features.render.MixinBedrockRidingPosition", "features.render.MixinEntityRenderDispatcher", "features.render.MixinMinecraftSkinLifecycle", 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 f0dad333..3bdd608b 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 eb72f949bfbefd6b47e3c35b80c288fbd9a80055 Mon Sep 17 00:00:00 2001 +From 9fe44496599bc4fead04962bb6564a63c28978af 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 @@ -83,7 +83,7 @@ positions, UV corners and normals through reflected model transforms. 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 9251ef3..d61e407 100644 +index 67ac977..cc719df 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -22,6 +22,7 @@ tasks.withType().configureEach { @@ -92,8 +92,8 @@ index 9251ef3..d61e407 100644 dependencies { + testImplementation("org.mockito:mockito-core:5.24.0") implementation(viaFabricPlusDependency) - - jarInJar(viaBedrockDependency) { + // Optional integration only. Iris supplies these classes when installed. + compileOnly("maven.modrinth:iris:vTN4NRGW") diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinEntityRenderDispatcher.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinEntityRenderDispatcher.java index 629db3e..c343816 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinEntityRenderDispatcher.java @@ -1185,10 +1185,10 @@ index 0000000..3b16598 + } +} diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json -index 38f830c..3b7dae4 100644 +index f88d04e..4134ac7 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -63,6 +63,7 @@ +@@ -72,6 +72,7 @@ "features.render.MixinMinecraftSkinLifecycle", "features.render.MixinLivingEntitySkinLayers", "features.render.MixinPlayerSkinWidget", 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 985fa90e..997b7e75 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 dc3608063990ed52b585eb4e193bc43197c77f91 Mon Sep 17 00:00:00 2001 +From 684780aa5bd07f294f0a732d1b6d8651c1ef3566 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 @@ -1064,10 +1064,10 @@ index 49f0538..99dd937 100644 "screen.viafabricplus.bedrock_dressing_room": "Bedrock Dressing Room", "screen.viafabricplus.accept_invite": "Accept an invitation code", diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json -index 3b7dae4..de445f7 100644 +index 4134ac7..23ea6ee 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -15,6 +15,7 @@ +@@ -24,6 +24,7 @@ "core.connection.MixinConnectScreen_1", "core.connection.MixinConnection", "core.connection.MixinConnection_1", 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 e2bc1ef9..9bf1dba7 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 209d735babc64c1741d569155061d26c289178f3 Mon Sep 17 00:00:00 2001 +From c9631cae9fffa4444da2c5adf76a6c8a2e5d38ad 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 @@ -93,6 +93,171 @@ remain fixture boundaries; file decoding and rendering are not claimed. Pose tests cover bundled and accepted libraries and direct JSON graphs, including case-colliding complete definitions, preserved non-ASCII characters, unchanged reference spelling and unrelated animation retention. + +Allow ordinary pack-relative particle atlas paths instead of requiring the +textures/ folder. Galaxite's captured gem_bag_particle selects font/glyph_E1, +whose PNG is already retained in the shared native resource archive. Remove +the emitter parser and resource lookup folder guards together; keep the +existing archive path validation and image bounds. + +The target Bedrock 1.26.51.1/protocol 2193 particle reader 142152f40 reads +and copies the texture field without adding or requiring a folder prefix. +An instruction probe executes 142153906..142153a4b with native JSON field +and string helpers: nine string cases, including font/custom/root names, +retain their values; three non-string cases fail the type check. This +probe enters after material dispatch and excludes complete schema loading, +subsequent resource lookup and rendering. CRT memory/string operations, +allocation and diagnostic backends are host boundaries. Do not infer +acceptance by later resource stages from the field reader alone. + +Tests exercise generated PNGs outside textures/ through the native actor +archive, pack reconstruction, particle decoding and emitter output. A +29-connection resource audit finds the real Galaxite font atlas and five +invisible underscore placeholders outside the conventional texture folder. + +Read client-world, direct chunk, vanilla render-region and optional Sodium +fluid queries from the authoritative native layers. Use the core's forward +liquid-state mapping rather than trying to waterlog Java carriers. Preserve +mapped water/lava level and falling properties; unknown definitions and +ordinary Java worlds retain vanilla queries. Render workers read the +thread-safe mirror without invoking Minecraft lifecycle mutation. + +The target 1.26.51.1 native selector at 142d80600 chooses a non-air extra +layer before primary, verified across 180 pinned-binary cases with real +layer getters and supplied chunk-provider/palette boundaries. + +The mesh compilers retain the selected native fluid beside each primary +block and skip a cell only when both its primary geometry and fluid are +empty. Forward native neighbor fluids through face and height reads, +including Sodium's hidden-fluid exposure walk, without replacing the +primary blocks used for shape and occlusion. Preserve vanilla fallback +when the native view is unavailable or a definition is unknown. Query +primitive cell coordinates directly, avoiding per-cell and extra neighbor +position allocations in the chunk builders. + +This integrates authoritative layer inputs with the existing mesh +algorithms. Native face occlusion, vertex-height parity and complete +physical simulation remain unverified; the selector probe does not +establish those behaviors. + +Invalidate affected mesh sections when native fluid selection changes, +even if the primary Java carrier stays identical. Include a two-block horizontal +and one-block vertical halo for face/height samples and Sodium 0.9.2's +five-by-five hidden-fluid exposure walk. Full snapshots and chunk +removal invalidate their regions; new Java chunks retain their normal +packet-driven rebuild. Scope the native update tracker to each reset +and clear it with the view on level/connection changes. Bound section +coordinates before calling the client's render invalidation API. + +Tests cover wet/dry transitions, equivalent aliases, unchanged fluid, +negative boundary coordinates, dimension resets, forget/clear and integer +limits. This makes layer updates visible without depending on an unrelated +Java block change to trigger a rebuild. + +Share the existing inherited-basis reset with equipped model bindings. +Native composition 0x141bc3d20 retains the translated origin and clears +the basis for entity-relative rotation after accepting a copied matrix. +Keep the common operation in the actor transform helper so explicit +bindings and ordinary bones use the same rule. Ordinary composition +retains its position, rotation and scale order. + +Start each emitter with a fresh unbound query context before lifetime +configuration and the first creation expression. Animation pre-effect +variables survive, but captured actor queries and animation clocks do +not. Detach missing-query tracking from the animation owner so an unbound +creation expression cannot invalidate the owner's rendering context. +Retain refreshed actor/count queries on loop restarts and reused children. + +Bedrock 1.26.51.1/build51061372/protocol2193, binary SHA-256 +537c0aee2e79afbdc94b44b28e00f466ae62bc50e2733d953b430db9dbaa9ee7: +original factory 14216ba90 through first creation dispatch in 142157210 +initializes actor and count-owner pointers to zero even with a dirty +allocation. Eleven original scalar/count callbacks return zero against +that captured context. Allocation, bounded CRT copies, mutex operations, +resource lookup and the component callback are supplied boundaries. +Random-variable registration is explicitly skipped. This proves the +constructor query boundary, not JSON/Molang evaluation, actor admission, +child lifetime parity or visible native rendering. + +Refresh common particle actor queries when sampling attachments, rather +than retaining the last actor draw's motion, water state and metadata. +Server particle packets now share native property, variant and spell-color +inputs with animation-bound effects. Keep each sampled map immutable and +preserve unrelated graph inputs and emitter/particle variables. Retire +server bindings when their captured native actor lifetime changes. + +Eight pinned 1.26.51.1 controls run original initialization/context refresh +1421603e0/142159bd0 once, then original speed and water callbacks +14220ecd0/14220f040 after in-place ECS and motion changes on the same actor. +Actor resolution, level/physical-component getters, variable access and +expression callbacks are supplied. No later context refresh, native actor +animation, property evaluation, live server or GPU runs in these controls. +This supports fresh actor reads, not complete native query producer parity. + +Model manual child handles for their owning emitter's full lifetime. An +expired registered child still receives initialization, while a retired +handle stays initialized and does not create a replacement. Failed initial +creation remains retryable. Weak references prevent cached retired handles +from retaining entire child runtimes. Parent retirement expires its valid +manual children without deleting their particles or adopting another +emitter that occupies a retired registry slot. + +Remove the manual-rate admission check from child construction. Ordinary +rate components have zero manual capacity but still own valid emitters and +can emit automatically on later updates. Preserve native expiry callbacks +before closing emission, and remove parent counts/registry membership before +its child retirement callbacks. Full-manager shutdown keeps its separate +clear path. + +Forty-six pinned 1.26.51.1 controls execute leaf dispatcher 142164cb0, +resource lookup 142158740, force expiry 14215cc60/142158940, manual emission +142160c20, and literal/zero manual-capacity getters 1462a4d10/140076c20. +Parent destructor 142170320 runs from entry through child retirement, +stopping before cache/resource cleanup at 1421703c2. Cache entries, handles, +components and emitter memory are supplied. Factory returns, initialization, +context refresh, birth allocation and source expressions remain boundaries. +An expiry callback tail-calls original emission to observe flag ordering. +Native instant and steady rate tables use the zero manual-capacity getter. +The controls exclude native JSON/Molang evaluation, actual particle +allocation, complete object destruction, live server behavior and pixels. + +Preserve native manual-child cache traversal for retirement callbacks. +Use the effect name's FNV-1 hash, eight initial buckets, native collision +placement and growth/rebucketing order. Failed factory attempts reserve a +cache entry before construction and retain that entry for retry, including +unavailable child definitions. Rollback clears initialization without +removing its ordering slot. Keep weak successful/stale handle semantics. + +Thirty-two pinned native controls execute full leaf dispatcher 142164cb0 +from an empty cache, including insertion, collisions and rehashes. They +record 2,870 insertion/retry receipts and run parent destructor 142170320 +through its child loop, stopping before cleanup at 1421703c2. Inputs include +parsed names/hashes, empty cache layout, handles and child records. Allocator, +bounded exact CRT copies/comparisons, integer ceilf, free, failed factory +returns and expiry callbacks are supplied boundaries. Cases include real +FNV-1 names, artificial full-hash/bucket collisions, retries and growth +through 513 entries. Native name parsing, resource construction, complete +destruction, reentrant manager allocation, live server and GPU remain open. + +Preserve native particle rate composition instead of requiring exactly one +rate. Retain resource order, sum automatic requests against shared capacity +and cached rate state, and initialize capacity from manual getters before +creation expressions. Count manual births so instant rates observe them. +An effect without rates still runs its lifetime and event graph. + +128 controls for pinned 1.26.51.1/build51061372/protocol2193 execute complete +factory 14216ba90, original reset 142157210, automatic update 142158a40 +through births, and full manual emission 142160c20. The controls compare +1,152 emission steps across all permutations of zero through three rates, +capacity limits, zero capacities and manual/automatic action order. +Resource/material/statistics lookups, manager capacity reservation, +allocation, bounded copies, mutex operations, query refresh, actor absence, +birth allocation, floorf and fmodf are supplied boundaries. Reset skips its +random-variable registration segment. Parsed component arrays and literal +expressions are inputs. Native JSON admission, expression VM execution, +particle aging/motion, complete rendering and pixels remain unverified. +The target-version schema lists the rate components independently. +Four deliberate variants fail these native population comparisons. --- .gitignore | 4 +- build.gradle.kts | 10 + @@ -113,28 +278,30 @@ characters, unchanged reference spelling and unrelated animation retention. .../appearance/BedrockLevelSounds.java | 164 +++ .../bedrock/appearance/BedrockLimbSide.java | 67 + .../appearance/BedrockMarketplaceAssets.java | 330 +++++ - .../bedrock/appearance/BedrockMolang.java | 331 +++++ + .../bedrock/appearance/BedrockMolang.java | 337 +++++ .../appearance/BedrockMolangColor.java | 40 + .../appearance/BedrockMolangVariables.java | 108 ++ - .../appearance/BedrockNativeBlocks.java | 61 + + .../appearance/BedrockNativeBlockUpdates.java | 72 ++ + .../appearance/BedrockNativeBlocks.java | 87 ++ .../appearance/BedrockParticleAttachment.java | 167 +++ .../appearance/BedrockParticleBlocks.java | 67 + + .../appearance/BedrockParticleChildren.java | 93 ++ .../appearance/BedrockParticleCollision.java | 210 ++++ .../appearance/BedrockParticleCounts.java | 58 + .../appearance/BedrockParticleCurves.java | 173 +++ .../appearance/BedrockParticleDefinition.java | 261 ++++ - .../appearance/BedrockParticleEffect.java | 870 +++++++++++++ + .../appearance/BedrockParticleEffect.java | 898 ++++++++++++++ .../appearance/BedrockParticleEvents.java | 170 +++ .../appearance/BedrockParticleFacing.java | 151 +++ - .../appearance/BedrockParticleFrames.java | 49 + + .../appearance/BedrockParticleFrames.java | 50 + .../appearance/BedrockParticleGraph.java | 44 + .../BedrockParticleInterpolation.java | 56 + .../appearance/BedrockParticleLayouts.java | 413 +++++++ .../appearance/BedrockParticleLifetime.java | 100 ++ .../BedrockParticleLifetimeEvents.java | 64 + .../appearance/BedrockParticlePending.java | 44 + - .../appearance/BedrockParticlePlayback.java | 379 ++++++ - .../appearance/BedrockParticleQueries.java | 39 + + .../appearance/BedrockParticlePlayback.java | 393 ++++++ + .../appearance/BedrockParticleQueries.java | 61 + .../BedrockParticleRenderTypes.java | 93 ++ .../appearance/BedrockParticleShape.java | 165 +++ .../appearance/BedrockParticleSpace.java | 43 + @@ -144,8 +311,8 @@ characters, unchanged reference spelling and unrelated animation retention. .../appearance/BedrockPersonaSources.java | 228 ++++ .../appearance/BedrockPlayerAnimations.java | 33 + .../appearance/BedrockPlayerControllers.java | 217 ++++ - .../appearance/BedrockResourceLibrary.java | 439 +++++++ - .../appearance/BedrockServerParticles.java | 98 ++ + .../appearance/BedrockResourceLibrary.java | 438 +++++++ + .../appearance/BedrockServerParticles.java | 102 ++ .../appearance/BedrockServerResources.java | 46 + .../appearance/BedrockServerSounds.java | 69 ++ .../bedrock/appearance/BedrockSoundBank.java | 137 +++ @@ -158,7 +325,11 @@ characters, unchanged reference spelling and unrelated animation retention. .../NativeParticlePlaybackPayload.java | 22 + .../appearance/SoundPlaybackPayload.java | 22 + .../access/IBedrockChannelAccess.java | 12 + + .../access/IBedrockNativeFluidView.java | 14 + .../injection/access/IBedrockSoundEngine.java | 15 + + .../compat/sodium/MixinSodiumNativeFluid.java | 36 + + .../sodium/MixinSodiumNativeFluidMesh.java | 44 + + .../MixinSodiumNativeFluidNeighbors.java | 57 + .../particle/MixinBedrockParticleEngine.java | 30 + .../MixinBedrockParticlePipelines.java | 26 + .../MixinBedrockParticleRenderType.java | 19 + @@ -169,6 +340,10 @@ characters, unchanged reference spelling and unrelated animation retention. .../render/MixinLivingEntityRenderer.java | 26 + .../render/MixinNativeActorPickingEntity.java | 41 + .../MixinNativeActorPickingLocalPlayer.java | 31 + + .../render/MixinNativeChunkFluid.java | 29 + + .../features/render/MixinNativeFluidMesh.java | 44 + + .../render/MixinNativeFluidNeighbors.java | 78 ++ + .../render/MixinNativeRegionFluid.java | 35 + .../features/sound/MixinBedrockChannel.java | 17 + .../sound/MixinBedrockChannelAccess.java | 32 + .../sound/MixinBedrockPcmChannel.java | 25 + @@ -180,12 +355,13 @@ characters, unchanged reference spelling and unrelated animation retention. .../render/BedrockActorPickingState.java | 99 ++ .../bedrock/render/BedrockActorState.java | 100 ++ .../bedrock/render/BedrockAnimatedModel.java | 190 +++ - .../bedrock/render/BedrockBoneTransform.java | 27 + + .../bedrock/render/BedrockBoneTransform.java | 31 + .../render/BedrockFirstPersonActor.java | 38 + .../render/BedrockFirstPersonCamera.java | 61 + .../render/BedrockFirstPersonInput.java | 41 + .../BedrockFirstPersonItemRendering.java | 27 + .../render/BedrockFirstPersonProjection.java | 28 + + .../bedrock/render/BedrockFluidRendering.java | 34 + .../bedrock/render/BedrockGeometry.java | 23 +- .../render/BedrockGeometryQueries.java | 77 ++ .../bedrock/render/BedrockModelLocators.java | 122 ++ @@ -206,7 +382,7 @@ characters, unchanged reference spelling and unrelated animation retention. .../licenses/brarchive-extractor.txt | 21 + .../licenses/vgmstream.txt | 23 + .../shaders/core/bedrock_particle.fsh | 52 + - .../viafabricplus-bedrock.mixins.json | 19 +- + .../viafabricplus-bedrock.mixins.json | 26 +- .../appearance/BedrockActorAnimationTest.java | 699 +++++++++++ .../BedrockActorRenderSelectionTest.java | 71 ++ .../appearance/BedrockActorScaleTest.java | 77 ++ @@ -226,30 +402,34 @@ characters, unchanged reference spelling and unrelated animation retention. .../appearance/BedrockMolangColorTest.java | 110 ++ .../bedrock/appearance/BedrockMolangTest.java | 231 ++++ .../BedrockMolangVariablesTest.java | 72 ++ + .../BedrockNativeBlockUpdatesTest.java | 70 ++ + .../appearance/BedrockNativeFluidTest.java | 34 + .../appearance/BedrockParticleAgingTest.java | 118 ++ .../appearance/BedrockParticleBlocksTest.java | 149 +++ + .../BedrockParticleChildrenTest.java | 300 +++++ .../BedrockParticleCollisionEventsTest.java | 175 +++ .../BedrockParticleCollisionTest.java | 124 ++ .../appearance/BedrockParticleCountsTest.java | 216 ++++ .../appearance/BedrockParticleCurvesTest.java | 120 ++ .../BedrockParticleDefinitionTest.java | 190 +++ .../BedrockParticleDispatchTest.java | 101 ++ - .../appearance/BedrockParticleEffectTest.java | 676 ++++++++++ + .../appearance/BedrockParticleEffectTest.java | 673 ++++++++++ .../appearance/BedrockParticleEventsTest.java | 301 +++++ .../appearance/BedrockParticleFacingTest.java | 153 +++ - .../appearance/BedrockParticleFramesTest.java | 112 ++ + .../appearance/BedrockParticleFramesTest.java | 110 ++ .../appearance/BedrockParticleGraphTest.java | 74 ++ .../BedrockParticleInterpolationTest.java | 164 +++ .../BedrockParticleLifetimeTest.java | 148 +++ .../BedrockParticlePendingTest.java | 62 + - .../BedrockParticleQueriesTest.java | 99 ++ + .../BedrockParticleQueriesTest.java | 260 ++++ + .../appearance/BedrockParticleRatesTest.java | 143 +++ .../BedrockParticleRegistryTest.java | 130 ++ .../BedrockParticleRenderTypesTest.java | 69 ++ .../appearance/BedrockParticleShapeTest.java | 164 +++ .../appearance/BedrockParticleSoundTest.java | 176 +++ .../appearance/BedrockParticleSpaceTest.java | 160 +++ .../appearance/BedrockPcmStreamTest.java | 63 + - .../BedrockResourceLibraryTest.java | 534 ++++++++ + .../BedrockResourceLibraryTest.java | 571 +++++++++ .../BedrockServerActorResourcesTest.java | 140 +++ .../BedrockServerParticlesTest.java | 70 ++ .../BedrockServerResourcesTest.java | 72 ++ @@ -268,6 +448,7 @@ characters, unchanged reference spelling and unrelated animation retention. .../render/BedrockFirstPersonInputTest.java | 135 ++ .../BedrockFirstPersonItemRenderingTest.java | 53 + .../BedrockFirstPersonProjectionTest.java | 88 ++ + .../render/BedrockFluidRenderingTest.java | 39 + .../render/BedrockGeometryQueriesTest.java | 129 ++ .../render/BedrockHumanoidVisibilityTest.java | 85 ++ .../render/BedrockModelLocatorsTest.java | 112 ++ @@ -276,7 +457,7 @@ characters, unchanged reference spelling and unrelated animation retention. .../render/BedrockPlayerModelTest.java | 104 ++ .../render/BedrockViewBobbingTest.java | 93 ++ .../render/BedrockWalkingAnimationTest.java | 65 + - 182 files changed, 22554 insertions(+), 15 deletions(-) + 198 files changed, 23980 insertions(+), 15 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/BedrockActorScale.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockActorVisibility.java @@ -295,9 +476,11 @@ characters, unchanged reference spelling and unrelated animation retention. create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockMolang.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockMolangColor.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockMolangVariables.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockNativeBlockUpdates.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockNativeBlocks.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleAttachment.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleBlocks.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleChildren.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleCollision.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleCounts.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleCurves.java @@ -337,7 +520,11 @@ characters, unchanged reference spelling and unrelated animation retention. create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/appearance/NativeParticlePlaybackPayload.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/appearance/SoundPlaybackPayload.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockChannelAccess.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockNativeFluidView.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockSoundEngine.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/sodium/MixinSodiumNativeFluid.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/sodium/MixinSodiumNativeFluidMesh.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/sodium/MixinSodiumNativeFluidNeighbors.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/particle/MixinBedrockParticleEngine.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/particle/MixinBedrockParticlePipelines.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/particle/MixinBedrockParticleRenderType.java @@ -348,6 +535,10 @@ characters, unchanged reference spelling and unrelated animation retention. create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinLivingEntityRenderer.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeActorPickingEntity.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeActorPickingLocalPlayer.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeChunkFluid.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeFluidMesh.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeFluidNeighbors.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeRegionFluid.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/sound/MixinBedrockChannel.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/sound/MixinBedrockChannelAccess.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/sound/MixinBedrockPcmChannel.java @@ -365,6 +556,7 @@ characters, unchanged reference spelling and unrelated animation retention. create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockFirstPersonInput.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockFirstPersonItemRendering.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockFirstPersonProjection.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockFluidRendering.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockGeometryQueries.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockModelLocators.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockModelTransforms.java @@ -401,8 +593,11 @@ characters, unchanged reference spelling and unrelated animation retention. create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockMolangColorTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockMolangTest.java 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/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 create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleCollisionEventsTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleCollisionTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleCountsTest.java @@ -418,6 +613,7 @@ characters, unchanged reference spelling and unrelated animation retention. create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleLifetimeTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticlePendingTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleQueriesTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleRatesTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleRegistryTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleRenderTypesTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleShapeTest.java @@ -443,6 +639,7 @@ characters, unchanged reference spelling and unrelated animation retention. create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockFirstPersonInputTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockFirstPersonItemRenderingTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockFirstPersonProjectionTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockFluidRenderingTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockGeometryQueriesTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockHumanoidVisibilityTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockModelLocatorsTest.java @@ -466,12 +663,12 @@ index d845caa..e1f9fb5 100644 +# Bundled package helper build output +/native/persona-assets/target/ diff --git a/build.gradle.kts b/build.gradle.kts -index d61e407..d7886b4 100644 +index cc719df..159fe40 100644 --- a/build.gradle.kts +++ b/build.gradle.kts -@@ -25,6 +25,10 @@ dependencies { - testImplementation("org.mockito:mockito-core:5.24.0") - implementation(viaFabricPlusDependency) +@@ -31,6 +31,10 @@ dependencies { + testImplementation("io.github.douira:glsl-transformer:3.0.0-pre3") + testImplementation("org.lwjgl:lwjgl-egl:3.4.3") + jarInJar("org.redlance.mochafloats:runtime:6.1.0") { + exclude(group = "io.netty") @@ -480,7 +677,7 @@ index d61e407..d7886b4 100644 jarInJar(viaBedrockDependency) { exclude(group = "com.mojang", module = "brigadier") exclude(group = "at.yawk.lz4", module = "lz4-java") -@@ -56,4 +60,10 @@ dependencies { +@@ -62,4 +66,10 @@ dependencies { tasks.test { useJUnitPlatform() @@ -3879,10 +4076,10 @@ index 0000000..b887250 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockMolang.java b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockMolang.java new file mode 100644 -index 0000000..b3f847c +index 0000000..84a333c --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockMolang.java -@@ -0,0 +1,331 @@ +@@ -0,0 +1,337 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -4011,6 +4208,12 @@ index 0000000..b3f847c + + void queries(final Map queries) { this.queries = queries; } + ++ /** Starts an independent query context without discarding pre-effect variables. */ ++ void resetQueries(final Map queries) { ++ this.queries(queries, 0, 0, 0); ++ this.missingQueries = new java.util.HashSet<>(); ++ } ++ + void addQueries(final Map queries) { + final Map merged = new HashMap<>(this.queries); + merged.putAll(queries); @@ -4374,12 +4577,90 @@ index 0000000..5ba86c7 + } + } +} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockNativeBlockUpdates.java b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockNativeBlockUpdates.java +new file mode 100644 +index 0000000..105cc63 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockNativeBlockUpdates.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.appearance; ++ ++import net.raphimc.viabedrock.api.model.NativeBlockIdentifiers; ++import net.raphimc.viabedrock.api.model.NativeBlockView; ++ ++import java.util.Objects; ++ ++/** Keeps render invalidation aligned with the authoritative native fluid view. */ ++final class BedrockNativeBlockUpdates { ++ // Sodium 0.9.2's hidden-fluid exposure walk samples a five-by-five horizontal area. ++ private static final int HORIZONTAL_RADIUS = 2; ++ private static final int VERTICAL_RADIUS = 1; ++ private final NativeBlockView view; ++ private NativeBlockIdentifiers.Reset world; ++ ++ BedrockNativeBlockUpdates(final NativeBlockView view) { ++ this.view = view; ++ } ++ ++ void clear() { ++ this.view.clear(); ++ this.world = null; ++ } ++ ++ SectionRange accept(final NativeBlockIdentifiers message) { ++ if (message instanceof NativeBlockIdentifiers.Reset reset) { ++ this.view.accept(reset); ++ this.world = reset; ++ return null; ++ } ++ if (this.world == null || message.dimension() != this.world.dimension()) return null; ++ if (message instanceof NativeBlockIdentifiers.Update update) { ++ final var pos = update.position(); ++ final Integer previous = this.view.fluidJavaId(pos.x(), pos.y(), pos.z()); ++ this.view.accept(update); ++ if (Objects.equals(previous, this.view.fluidJavaId(pos.x(), pos.y(), pos.z()))) return null; ++ return bounds((long) pos.x() - HORIZONTAL_RADIUS, (long) pos.y() - VERTICAL_RADIUS, (long) pos.z() - HORIZONTAL_RADIUS, ++ (long) pos.x() + HORIZONTAL_RADIUS, (long) pos.y() + VERTICAL_RADIUS, (long) pos.z() + HORIZONTAL_RADIUS); ++ } ++ this.view.accept(message); ++ if (message instanceof NativeBlockIdentifiers.Section section) { ++ final long x = (long) section.chunkX() << 4; ++ final long y = (long) section.sectionY() << 4; ++ final long z = (long) section.chunkZ() << 4; ++ return bounds(x - HORIZONTAL_RADIUS, y - VERTICAL_RADIUS, z - HORIZONTAL_RADIUS, ++ x + 15 + HORIZONTAL_RADIUS, y + 15 + VERTICAL_RADIUS, z + 15 + HORIZONTAL_RADIUS); ++ } ++ if (message instanceof NativeBlockIdentifiers.Forget forget) { ++ final long x = (long) forget.chunkX() << 4; ++ final long z = (long) forget.chunkZ() << 4; ++ return bounds(x - HORIZONTAL_RADIUS, (long) this.world.minY() - VERTICAL_RADIUS, z - HORIZONTAL_RADIUS, ++ x + 15 + HORIZONTAL_RADIUS, (long) this.world.minY() + this.world.height() - 1 + VERTICAL_RADIUS, z + 15 + HORIZONTAL_RADIUS); ++ } ++ return null; ++ } ++ ++ private static SectionRange bounds(final long minX, final long minY, final long minZ, ++ final long maxX, final long maxY, final long maxZ) { ++ return new SectionRange(section(minX), section(minY), section(minZ), section(maxX), section(maxY), section(maxZ)); ++ } ++ ++ private static int section(final long coordinate) { ++ return (int) Math.clamp(coordinate >> 4, Integer.MIN_VALUE >> 4, Integer.MAX_VALUE >> 4); ++ } ++ ++ record SectionRange(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) {} ++} 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..1e3b984 +index 0000000..3515be1 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockNativeBlocks.java -@@ -0,0 +1,61 @@ +@@ -0,0 +1,87 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -4390,13 +4671,18 @@ index 0000000..1e3b984 +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.network.Connection; ++import net.minecraft.world.level.Level; ++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.NativeBlockView; + +/** Client lifecycle adapter for the core's authoritative block mirror. */ +public final class BedrockNativeBlocks { + private static final NativeBlockView VIEW = new NativeBlockView(); -+ private static ClientLevel level; ++ private static final BedrockNativeBlockUpdates UPDATES = new BedrockNativeBlockUpdates(VIEW); ++ private static volatile ClientLevel level; + private static volatile long generation; + + private BedrockNativeBlocks() {} @@ -4409,7 +4695,12 @@ index 0000000..1e3b984 + if (receivedGeneration != generation || minecraft.level == null || !connection.isConnected() + || listener == null || listener.getConnection() != connection) return; + synchronizeLevel(minecraft); -+ VIEW.accept(message); ++ final var dirty = UPDATES.accept(message); ++ // A new Java chunk will invalidate its own sections when its packet follows the native snapshot. ++ if (dirty != null && (!(message instanceof NativeBlockIdentifiers.Section section) ++ || minecraft.level.hasChunk(section.chunkX(), section.chunkZ()))) { ++ minecraft.level.setSectionRangeDirty(dirty.minX(), dirty.minY(), dirty.minZ(), dirty.maxX(), dirty.maxY(), dirty.maxZ()); ++ } + }); + } + @@ -4418,7 +4709,7 @@ index 0000000..1e3b984 + final long clearedGeneration = ++generation; + minecraft.execute(() -> { + if (generation != clearedGeneration) return; -+ VIEW.clear(); ++ UPDATES.clear(); + level = null; + }); + } @@ -4429,6 +4720,22 @@ index 0000000..1e3b984 + return VIEW.state(x, y, z, layer); + } + ++ /** 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; ++ return decodeFluid(VIEW.fluidJavaId(x, y, z)); ++ } ++ ++ static FluidState decodeFluid(final Integer javaId) { ++ if (javaId == null) return null; ++ if (javaId == -1) return Fluids.EMPTY.defaultFluidState(); ++ final var state = Block.BLOCK_STATE_REGISTRY.byId(javaId); ++ if (state == null) return null; ++ final var fluid = state.getFluidState(); ++ return fluid.getType() == Fluids.WATER || fluid.getType() == Fluids.FLOWING_WATER ++ || fluid.getType() == Fluids.LAVA || fluid.getType() == Fluids.FLOWING_LAVA ? fluid : null; ++ } ++ + static BedrockParticleBlocks.World world() { + final Minecraft minecraft = Minecraft.getInstance(); + synchronizeLevel(minecraft); @@ -4437,7 +4744,7 @@ index 0000000..1e3b984 + + private static void synchronizeLevel(final Minecraft minecraft) { + if (level == minecraft.level) return; -+ VIEW.clear(); ++ UPDATES.clear(); + level = minecraft.level; + } +} @@ -4687,6 +4994,105 @@ index 0000000..1d508e5 + return (int) Math.floor(position + (float) origin); + } +} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleChildren.java b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleChildren.java +new file mode 100644 +index 0000000..0c178b0 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleChildren.java +@@ -0,0 +1,93 @@ ++/* ++ * 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.lang.ref.WeakReference; ++import java.util.ArrayList; ++import java.util.HashMap; ++import java.util.List; ++import java.util.Map; ++import java.util.function.Consumer; ++import java.util.function.Predicate; ++import java.util.function.Supplier; ++import java.util.function.ToLongFunction; ++ ++/** Manual child handles stay initialized after retirement and expire with their owning emitter. */ ++final class BedrockParticleChildren { ++ private final Map> children = new HashMap<>(); ++ private final List> order = new ArrayList<>(); ++ private final ToLongFunction hash; ++ private int buckets = 8; ++ ++ BedrockParticleChildren(final ToLongFunction hash) { this.hash = hash; } ++ ++ Resolution acquire(final K key, final Predicate registered, final Supplier factory) { ++ Entry entry = this.children.get(key); ++ if (entry == null) { ++ entry = new Entry<>(this.hash.applyAsLong(key)); ++ if (this.children.size() == this.buckets) this.grow(); ++ this.insert(entry); ++ this.children.put(key, entry); ++ } ++ if (entry.initialized) { ++ final T child = entry.child.get(); ++ if (child != null && registered.test(child)) return new Resolution<>(child, false); ++ entry.child.clear(); ++ return new Resolution<>(null, false); ++ } ++ final T child = factory.get(); ++ if (child != null) { ++ entry.child = new WeakReference<>(child); ++ entry.initialized = true; ++ } ++ return new Resolution<>(child, child != null); ++ } ++ ++ void discard(final K key, final T child) { ++ final Entry entry = this.children.get(key); ++ if (entry != null && entry.child.get() == child) { ++ entry.child.clear(); ++ entry.initialized = false; ++ } ++ } ++ ++ void retire(final Predicate registered, final Consumer expire) { ++ try { ++ for (final Entry entry : this.order) { ++ final T child = entry.child.get(); ++ if (child != null && registered.test(child)) expire.accept(child); ++ } ++ } finally { ++ this.children.clear(); ++ this.order.clear(); ++ this.buckets = 8; ++ } ++ } ++ ++ private void grow() { ++ this.buckets *= this.buckets < 512 ? 8 : 2; ++ final var previous = List.copyOf(this.order); ++ this.order.clear(); ++ previous.forEach(this::insert); ++ } ++ ++ private void insert(final Entry entry) { ++ final long bucket = entry.hash & (this.buckets - 1); ++ int index = 0; ++ while (index < this.order.size() && (this.order.get(index).hash & (this.buckets - 1)) != bucket) index++; ++ this.order.add(index, entry); ++ } ++ ++ private static final class Entry { ++ private final long hash; ++ private WeakReference child = new WeakReference<>(null); ++ private boolean initialized; ++ ++ private Entry(final long hash) { this.hash = hash; } ++ } ++ ++ record Resolution(T child, boolean created) {} ++} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleCollision.java b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleCollision.java new file mode 100644 index 0000000..f12d3f8 @@ -5415,10 +5821,10 @@ index 0000000..bebb9e5 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleEffect.java b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleEffect.java new file mode 100644 -index 0000000..a569642 +index 0000000..cb4a905 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleEffect.java -@@ -0,0 +1,870 @@ +@@ -0,0 +1,898 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -5460,7 +5866,8 @@ index 0000000..a569642 + final String identifier, texture, material; + final boolean lighting; + private final BedrockMolang.Program creation, update, particleCreation, particleUpdate, particleRender; -+ private final BedrockMolang.Program count, rate, capacity, lifetime, expiration; ++ private final BedrockMolang.Program lifetime, expiration; ++ private final List rates; + private final BedrockParticleLifetime emitterLifetime; + private final BedrockMolang.Program rotation, rotationRate; + private final BedrockParticleShape shape; @@ -5473,7 +5880,6 @@ index 0000000..a569642 + private final Tint tint; + private final BedrockParticleSpace space; + private final BedrockParticleCurves curves; -+ private final boolean manual; + private final BedrockParticleEvents events; + private final BedrockParticleLifetimeEvents particleEvents; + private final BedrockEmitterEvents emitterEvents; @@ -5492,10 +5898,23 @@ index 0000000..a569642 + for (final String component : components.keySet()) + if (!COMPONENTS.contains(component)) throw new IOException("Unsupported particle component: " + component); + this.blocks = BedrockParticleBlocks.read(components); -+ this.manual = components.has("minecraft:emitter_rate_manual"); -+ if ((components.has("minecraft:emitter_rate_instant") ? 1 : 0) -+ + (components.has("minecraft:emitter_rate_steady") ? 1 : 0) + (this.manual ? 1 : 0) != 1) -+ throw new IOException("The particle effect needs one emission rate"); ++ final List rates = new ArrayList<>(); ++ for (final var component : components.entrySet()) { ++ final RateKind kind = switch (component.getKey()) { ++ case "minecraft:emitter_rate_instant" -> RateKind.INSTANT; ++ case "minecraft:emitter_rate_steady" -> RateKind.STEADY; ++ case "minecraft:emitter_rate_manual" -> RateKind.MANUAL; ++ default -> null; ++ }; ++ if (kind == null) continue; ++ final JsonObject rate = component.getValue().getAsJsonObject(); ++ rates.add(new Rate(kind, switch (kind) { ++ case INSTANT -> program(rate, "num_particles", 10); ++ case STEADY -> program(rate, "spawn_rate", 1); ++ case MANUAL -> program(rate, "max_particles", 50); ++ }, kind == RateKind.STEADY ? program(rate, "max_particles", 50) : null)); ++ } ++ this.rates = List.copyOf(rates); + this.particleEvents = BedrockParticleLifetimeEvents.read(object(components, "particle_lifetime_events")); + this.emitterEvents = BedrockEmitterEvents.read(object(components, "emitter_lifetime_events")); + final JsonObject emitterInit = object(components, "emitter_initialization"); @@ -5507,11 +5926,6 @@ index 0000000..a569642 + this.particleRender = program(particleInit, "per_render_expression", 0); + this.emitterLifetime = BedrockParticleLifetime.read(components); + this.space = BedrockParticleSpace.read(components); -+ this.count = components.has("minecraft:emitter_rate_instant") -+ ? program(object(components, "emitter_rate_instant"), "num_particles", 10) : null; -+ final JsonObject steady = object(components, "emitter_rate_steady"); -+ this.rate = program(steady, "spawn_rate", 1); -+ this.capacity = program(this.manual ? object(components, "emitter_rate_manual") : steady, "max_particles", 50); + this.shape = BedrockParticleShape.read(components); + final JsonElement speed = components.get("minecraft:particle_initial_speed"); + this.speed = InitialSpeed.read(speed); @@ -5528,7 +5942,6 @@ index 0000000..a569642 + final JsonObject parameter = components.getAsJsonObject("minecraft:particle_motion_parametric"); + this.parametric = parameter == null ? null : ParametricMotion.read(parameter); + this.billboard = Billboard.read(object(components, "particle_appearance_billboard")); -+ if (this.renderable() && !this.texture.startsWith("textures/")) throw new IOException("Invalid particle texture"); + if (!Set.of("particles_alpha", "particles_blend", "particles_opaque", "particles_add").contains(this.material)) + throw new IOException("Unsupported particle material: " + this.material); + this.tint = Tint.read(object(components, "particle_appearance_tinting").get("color")); @@ -5540,7 +5953,6 @@ index 0000000..a569642 + catch (RuntimeException invalid) { throw new IOException("Invalid particle effect", invalid); } + } + -+ boolean manual() { return this.manual; } + + boolean renderable() { return this.billboard.hasArea(); } + @@ -5590,8 +6002,7 @@ index 0000000..a569642 + final BedrockParticleBlocks.World world, final Map countQueries) { + this.world = world; + this.countQueries = Map.copyOf(countQueries); -+ environment.addQueries(BedrockParticleCounts.UNBOUND); -+ environment.addQueries(Map.of("spellcolor", BedrockMolangColor.ZERO)); ++ environment.resetQueries(BedrockParticleQueries.sample(null, new Vector3f(), net.minecraft.world.phys.Vec3.ZERO)); + this.environment = environment; + this.random = random; + this.baseOrigin = transform.origin(); @@ -5603,13 +6014,14 @@ index 0000000..a569642 + } + + private void restart() { -+ this.maximum = 0; ++ this.maximum = this.manualLimit(); + this.emitted = 0; + randomVariables(this.environment, this.random, "emitter"); + this.environment.evaluate(creation, 0); + } + + boolean alive() { return !this.clock.expired() || !this.particles.isEmpty(); } ++ void expire() { this.clock.expire(() -> this.emitterEvent(emitterEvents.lifetime().expiration())); } + int size() { return this.particles.size(); } + void dispatcher(final Consumer dispatcher) { this.dispatcher = java.util.Objects.requireNonNull(dispatcher); } + net.minecraft.world.phys.Vec3 baseOrigin() { return this.baseOrigin; } @@ -5634,27 +6046,7 @@ index 0000000..a569642 + if (!this.contextValid && space.position()) this.particles.clear(); + }); + final float seconds = (float) frame.nanos() / 1_000_000_000F; -+ int requested = 0; -+ if (frame.emitting()) { -+ if (count != null) { -+ if (this.emitted == 0) { -+ this.maximum = 1000; -+ requested = boundedCount(this.environment.evaluate(count, 10), 1000); -+ } -+ } else if (!manual) { -+ if (this.maximum == 0) { -+ this.maximum = boundedCount(this.environment.evaluate(capacity, 50), 1000); -+ this.cachedRate = Math.max(0, this.environment.evaluate(rate, 1)); -+ } -+ this.remainder += seconds; -+ final float interval = 1F / this.cachedRate; -+ if (this.remainder >= interval) { -+ requested = boundedCount((float) Math.floor(this.remainder / interval), 1000); -+ this.remainder %= interval; -+ this.cachedRate = Math.max(0, this.environment.evaluate(rate, 1)); -+ } -+ } -+ } ++ final int requested = frame.emitting() ? this.automaticRequests(seconds) : 0; + final int room = Math.min(Math.max(0, available.getAsInt()), this.maximum - this.particles.size()); + for (int index = 0; index < Math.min(requested, room); index++) { + if (available.getAsInt() <= 0) break; @@ -5719,13 +6111,51 @@ index 0000000..a569642 + for (final Particle particle : this.particles) particle.environment.queries(queries); + } + ++ private int automaticRequests(final float seconds) { ++ int requested = 0; ++ // Native components share emitter capacity and rate state, and sum requests in resource order. ++ for (final Rate rate : rates) { ++ switch (rate.kind) { ++ case INSTANT -> { ++ if (this.emitted == 0) { ++ requested += boundedCount(this.environment.evaluate(rate.expression, 10), 1000); ++ this.maximum = 1000; ++ } ++ } ++ case STEADY -> { ++ if (this.maximum == 0) { ++ this.maximum = boundedCount(this.environment.evaluate(rate.capacity, 50), 1000); ++ this.cachedRate = Math.max(0, this.environment.evaluate(rate.expression, 1)); ++ } ++ this.remainder += seconds; ++ final float interval = 1F / this.cachedRate; ++ if (this.remainder >= interval) { ++ requested += boundedCount((float) Math.floor(this.remainder / interval), 1000); ++ this.remainder %= interval; ++ this.cachedRate = Math.max(0, this.environment.evaluate(rate.expression, 1)); ++ } ++ } ++ case MANUAL -> { } ++ } ++ } ++ return requested; ++ } ++ ++ private int manualLimit() { ++ int limit = 0; ++ for (final Rate rate : rates) ++ if (rate.kind == RateKind.MANUAL) ++ limit = Math.max(limit, manualCapacity(this.environment.evaluate(rate.expression, 50))); ++ return limit; ++ } ++ + boolean emit(final Vector3f position, final Vector3f velocity, final int available) { -+ if (!manual || this.clock.expired() || available <= 0 || !position.isFinite() || !velocity.isFinite()) return false; -+ final int limit = manualCapacity(this.environment.evaluate(capacity, 50)); -+ if (this.particles.size() >= limit) return false; ++ if (this.clock.expired() || available <= 0 || !position.isFinite() || !velocity.isFinite()) return false; ++ if (this.particles.size() >= this.manualLimit()) return false; + final Particle particle = new Particle(this.environment.copy(), this.random, null, new Vector3f(), position, velocity); + this.particles.add(particle); + this.event(particleEvents.creation(), particle); ++ this.emitted++; + return true; + } + @@ -6256,6 +6686,10 @@ index 0000000..a569642 + } + } + ++ private enum RateKind { INSTANT, STEADY, MANUAL } ++ ++ private record Rate(RateKind kind, BedrockMolang.Program expression, BedrockMolang.Program capacity) {} ++ + static int manualCapacity(final float value) { + // Native converts through unsigned 64-bit before applying the 2,000-particle cap. + return !Float.isFinite(value) || value <= -1 || value >= 2000 ? 2000 : (int) value; @@ -6624,10 +7058,10 @@ index 0000000..7767a3f +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleFrames.java b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleFrames.java new file mode 100644 -index 0000000..ee4ccaf +index 0000000..74c20e9 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleFrames.java -@@ -0,0 +1,49 @@ +@@ -0,0 +1,50 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -6649,11 +7083,10 @@ index 0000000..ee4ccaf + + static void advance(final List emitters, final BedrockParticleCounts counts, + final Function effect, final Predicate alive, -+ final Consumer update, final ToIntFunction population) { -+ final var batch = batch(emitters, emitter -> { -+ if (alive.test(emitter)) return true; ++ final Consumer retire, final Consumer update, final ToIntFunction population) { ++ final var batch = batch(emitters, alive, emitter -> { + counts.removed(effect.apply(emitter)); -+ return false; ++ retire.accept(emitter); + }); + final Map particles = new HashMap<>(); + for (final T emitter : batch) { @@ -6664,14 +7097,16 @@ index 0000000..ee4ccaf + counts.publish(particles); + } + -+ static List batch(final List emitters, final Predicate alive) { ++ static List batch(final List emitters, final Predicate alive, final Consumer retire) { + int index = 0; + while (index < emitters.size()) { + if (alive.test(emitters.get(index))) { + index++; + } else { ++ final T retired = emitters.get(index); + final T last = emitters.removeLast(); + if (index < emitters.size()) emitters.set(index, last); ++ retire.accept(retired); + } + } + return List.copyOf(emitters); @@ -7210,7 +7645,7 @@ index 0000000..e638562 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleLifetime.java b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleLifetime.java new file mode 100644 -index 0000000..5c01d5b +index 0000000..c827ffb --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleLifetime.java @@ -0,0 +1,100 @@ @@ -7299,7 +7734,7 @@ index 0000000..5c01d5b + return new Frame(elapsed, age, lifetime, emitting, previous, this.age, first); + } + -+ private void expire(final Runnable event) { ++ void expire(final Runnable event) { + if (this.expired) return; + event.run(); + this.expired = true; @@ -7436,10 +7871,10 @@ index 0000000..812303f +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticlePlayback.java b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticlePlayback.java new file mode 100644 -index 0000000..c2db680 +index 0000000..37df6f5 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticlePlayback.java -@@ -0,0 +1,379 @@ +@@ -0,0 +1,393 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -7470,6 +7905,8 @@ index 0000000..c2db680 + +import java.awt.image.BufferedImage; +import java.io.IOException; ++import java.nio.charset.StandardCharsets; ++import net.raphimc.viabedrock.api.util.Fnv1; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; @@ -7553,25 +7990,15 @@ index 0000000..c2db680 + try { + final var sampledAttachment = attachment == null ? null : attachment.get(); + if (!valid(transform.origin()) || attachment != null && sampledAttachment == null) return false; -+ Playing playing = manual == null ? null : parent.manualChildren.get(key); -+ if (playing != null && !playing.emitter.alive()) { -+ parent.manualChildren.remove(key); -+ playing = null; -+ } -+ if (playing == null) { -+ if (EMITTERS.size() >= 512 || manual != null && !loaded.effect.manual()) return false; -+ // Animation roots supply their initial variables before creation; event children own fresh variables. -+ if (parent == null) environment.evaluate(initialization, 0); -+ final BedrockParticleBlocks.World world = BedrockNativeBlocks.world(); -+ final var emitter = loaded.effect.emitter(environment, ThreadLocalRandom.current(), transform, world, COUNTS.queries(loaded.effect.identifier)); -+ playing = new Playing(key, loaded, emitter, attachment, depth); -+ final Playing source = playing; -+ emitter.dispatcher(action -> dispatch(source, action)); -+ EMITTERS.add(playing); -+ created = playing; -+ COUNTS.created(loaded.effect.identifier); -+ if (manual != null) parent.manualChildren.put(key, playing); ++ final Playing playing; ++ if (manual == null) playing = created = create(key, loaded, environment, initialization, transform, attachment, depth, parent); ++ else { ++ final var result = parent.manualChildren.acquire(key, EMITTERS::contains, ++ () -> create(key, loaded, environment, initialization, transform, attachment, depth, parent)); ++ playing = result.child(); ++ if (result.created()) created = playing; + } ++ if (playing == null) return false; + playing.emitter.queryContext(sampledAttachment, minecraft.gameRenderer.mainCamera().position()); + if (parent != null) playing.emitter.initialize(initialization); + if (manual != null) playing.emitter.emit(manual.position(), manual.velocity(), available()); @@ -7580,13 +8007,33 @@ index 0000000..c2db680 + if (created != null) { + EMITTERS.remove(created); + COUNTS.removed(loaded.effect.identifier); -+ if (manual != null) parent.manualChildren.remove(key); ++ retire(created); ++ if (manual != null) parent.manualChildren.discard(key, created); + } + ViaFabricPlusBedrock.impl().logger().warn("Could not initialize Bedrock particle {}", key.event, invalid); + return false; + } + } + ++ private static Playing create(final Key key, final Loaded loaded, final BedrockMolang environment, ++ final BedrockMolang.Program initialization, final BedrockParticleAttachment.Matrix transform, ++ final Supplier attachment, final int depth, final Playing parent) { ++ if (loaded == null || EMITTERS.size() >= 512) return null; ++ // Animation roots supply their initial variables before creation; event children own fresh variables. ++ if (parent == null) environment.evaluate(initialization, 0); ++ final var emitter = loaded.effect.emitter(environment, ThreadLocalRandom.current(), transform, ++ BedrockNativeBlocks.world(), COUNTS.queries(loaded.effect.identifier)); ++ final var playing = new Playing(key, loaded, emitter, attachment, depth); ++ emitter.dispatcher(action -> dispatch(playing, action)); ++ EMITTERS.add(playing); ++ COUNTS.created(loaded.effect.identifier); ++ return playing; ++ } ++ ++ private static void retire(final Playing playing) { ++ playing.manualChildren.retire(EMITTERS::contains, child -> child.emitter.expire()); ++ } ++ + private static int available() { + int population = 0; + for (final Playing playing : EMITTERS) population += playing.emitter.size(); @@ -7617,7 +8064,7 @@ index 0000000..c2db680 + } : null; + final Key key = new Key(parent.key.resources, spawn.effect()); + final Loaded loaded = loaded(key); -+ if (loaded != null) start(key, loaded, new BedrockMolang(), spawn.initialization(), transform, ++ start(key, loaded, new BedrockMolang(), spawn.initialization(), transform, + attachment, parent.depth + 1, manual ? request : null, parent); + } + @@ -7724,7 +8171,7 @@ index 0000000..c2db680 + new Matrix4f().rotation(camera.rotation()).get(new float[16])); + // Prune before capturing the batch. Children appended during updates wait for the next batch. + BedrockParticleFrames.advance(EMITTERS, COUNTS, playing -> playing.loaded.effect.identifier, -+ playing -> playing.emitter.alive(), playing -> { ++ playing -> playing.emitter.alive(), BedrockParticlePlayback::retire, playing -> { + final var attachment = playing.attachment == null ? null : playing.attachment.get(); + final var update = playing.interpolation.update(elapsed); + if (update.isPresent()) { @@ -7795,7 +8242,9 @@ index 0000000..c2db680 + } catch (RuntimeException invalid) { pixels.close(); throw invalid; } + } + -+ private record Key(BedrockResourceLibrary resources, String event) {} ++ private record Key(BedrockResourceLibrary resources, String event) { ++ private long nativeHash() { return Fnv1.fnv1_64(this.event.getBytes(StandardCharsets.UTF_8)); } ++ } + private record Rendering(Identifier texture, RenderType type, long bytes) {} + private record Loaded(BedrockParticleEffect effect, Optional rendering) {} + private static final class Playing { @@ -7805,7 +8254,7 @@ index 0000000..c2db680 + final Supplier attachment; + final int depth; + final BedrockParticleInterpolation interpolation = new BedrockParticleInterpolation(BedrockParticleInterpolation.PC_FRAMES); -+ final Map manualChildren = new HashMap<>(); ++ final BedrockParticleChildren manualChildren = new BedrockParticleChildren<>(Key::nativeHash); + + Playing(final Key key, final Loaded loaded, final BedrockParticleEffect.Emitter emitter, + final Supplier attachment, final int depth) { @@ -7821,10 +8270,10 @@ index 0000000..c2db680 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleQueries.java b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleQueries.java new file mode 100644 -index 0000000..7263ab7 +index 0000000..826b710 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleQueries.java -@@ -0,0 +1,39 @@ +@@ -0,0 +1,61 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -7833,6 +8282,8 @@ index 0000000..7263ab7 +package com.viaversion.viafabricplus.bedrock.appearance; + +import net.minecraft.world.phys.Vec3; ++import net.minecraft.world.entity.Entity; ++import net.raphimc.viabedrock.api.model.NativeActorMessage; +import org.joml.Vector3f; + +import java.util.HashMap; @@ -7844,6 +8295,26 @@ index 0000000..7263ab7 + + private BedrockParticleQueries() {} + ++ /** Refreshes common actor inputs without advancing the actor's animation or locator pose. */ ++ public static Map actor(final Entity actor, final NativeActorMessage.Snapshot state, ++ final Map rendered) { ++ final Map queries = new HashMap<>(rendered); ++ queries.put("is_alive", actor.isAlive()); ++ queries.put("is_on_ground", actor.onGround()); ++ queries.put("is_in_water", actor.isInWater()); ++ queries.put("ground_speed", groundSpeed(actor.getDeltaMovement())); ++ final Map properties = state == null ? Map.of() : state.properties(); ++ queries.put("variant", state == null ? 0 : state.variant()); ++ queries.put("mark_variant", state == null ? 0 : state.markVariant()); ++ queries.put("property", (BedrockActorAnimation.Query) args -> args.isEmpty() ? 0F ++ : properties.getOrDefault(String.valueOf(args.getFirst()), 0F)); ++ queries.put("has_property", (BedrockActorAnimation.Query) args -> !args.isEmpty() ++ && properties.containsKey(String.valueOf(args.getFirst()))); ++ queries.put("spellcolor", state == null || state.spellColor() == null ? BedrockMolangColor.ZERO ++ : BedrockMolangColor.fromArgb(state.spellColor())); ++ return Map.copyOf(queries); ++ } ++ + static Map sample(final BedrockParticleAttachment attachment, final Vector3f origin, final Vec3 camera) { + final Map queries = new HashMap<>(); + if (attachment != null && attachment.hasActor()) queries.putAll(attachment.queries()); @@ -8926,10 +9397,10 @@ index 0000000..47da5ae +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockResourceLibrary.java b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockResourceLibrary.java new file mode 100644 -index 0000000..9dd9348 +index 0000000..d302d89 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockResourceLibrary.java -@@ -0,0 +1,439 @@ +@@ -0,0 +1,438 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -9124,7 +9595,6 @@ index 0000000..9dd9348 + + byte[] particleTexture(final String path) throws IOException { + BedrockBuiltinAssets.validPath(path); -+ if (!path.startsWith("textures/")) throw new IOException("Invalid particle texture path"); + for (int index = this.packs.size() - 1; index >= 0; index--) { + final Map files = this.packs.get(index).files; + if (path.endsWith(".png") || path.endsWith(".tga")) { @@ -9371,10 +9841,10 @@ index 0000000..9dd9348 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockServerParticles.java b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockServerParticles.java new file mode 100644 -index 0000000..ceb82a0 +index 0000000..8b9d1f6 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockServerParticles.java -@@ -0,0 +1,98 @@ +@@ -0,0 +1,102 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -9383,6 +9853,7 @@ index 0000000..ceb82a0 +package com.viaversion.viafabricplus.bedrock.appearance; + +import com.viaversion.viafabricplus.bedrock.ViaFabricPlusBedrock; ++import com.viaversion.viafabricplus.bedrock.render.BedrockActorState; +import io.netty.buffer.Unpooled; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientLevel; @@ -9393,6 +9864,7 @@ index 0000000..ceb82a0 +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.phys.Vec3; +import net.raphimc.viabedrock.api.model.NativeParticlePlaybackMessage; ++import net.raphimc.viabedrock.api.model.NativeActorMessage; +import net.raphimc.viabedrock.protocol.model.Position3f; + +import java.io.IOException; @@ -9417,12 +9889,15 @@ index 0000000..ceb82a0 + final Entity actor = level.getEntity(message.actor().javaId()); + if (actor == null || !actor.getUUID().equals(message.actor().javaUuid()) || actor.isRemoved()) return; + final Vec3 offset = position(message.effect().position()); ++ final var owner = BedrockActorState.lifetime(actor.getUUID()); + origin = Vec3.ZERO; + attachment = () -> { + if (minecraft.level != level || actor.isRemoved() || level.getEntity(actor.getId()) != actor) return null; ++ final var current = BedrockActorState.state(actor.getUUID()); ++ if (owner != null && (current == null || !owner.equals(current.lifetime()))) return null; + final float partialTick = minecraft.getDeltaTracker().getGameTimeDeltaPartialTick(false); + return new BedrockParticleAttachment(actor.getPosition(partialTick), actor.getYRot(partialTick), null, -+ actor.getInterpolatedBoundingBox(partialTick), queries(actor)).withOffset(offset); ++ actor.getInterpolatedBoundingBox(partialTick), queries(actor, current)).withOffset(offset); + }; + } + try { @@ -9442,11 +9917,10 @@ index 0000000..ceb82a0 + return new Vec3(position.x(), position.y(), position.z()); + } + -+ private static Map queries(final Entity actor) { -+ return Map.of("is_alive", actor.isAlive(), "is_on_ground", actor.onGround(), "is_in_water", actor.isInWater(), -+ "ground_speed", BedrockParticleQueries.groundSpeed(actor.getDeltaMovement()), "is_sneaking", actor.isShiftKeyDown(), ++ private static Map queries(final Entity actor, final NativeActorMessage.Snapshot state) { ++ return BedrockParticleQueries.actor(actor, state, Map.of("is_sneaking", actor.isShiftKeyDown(), + "is_riding", actor.isPassenger(), "is_swimming", actor.isSwimming(), -+ "is_gliding", actor instanceof LivingEntity living && living.isFallFlying()); ++ "is_gliding", actor instanceof LivingEntity living && living.isFallFlying())); + } + + private static void fallback(final NativeParticlePlaybackMessage message, final ClientLevel level) { @@ -10751,6 +11225,26 @@ index 0000000..08369d6 +public interface IBedrockChannelAccess { + void viaFabricPlusBedrock$release(ChannelAccess.ChannelHandle handle, Runnable cleanup); +} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockNativeFluidView.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockNativeFluidView.java +new file mode 100644 +index 0000000..43ab0b3 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockNativeFluidView.java +@@ -0,0 +1,14 @@ ++/* ++ * 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.access; ++ ++import net.minecraft.world.level.material.FluidState; ++import org.jetbrains.annotations.Nullable; ++ ++/** Position-aware native fluid input for chunk render views. Null preserves the Java fallback. */ ++public interface IBedrockNativeFluidView { ++ @Nullable FluidState bedrock$fluidState(int x, int y, int z); ++} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockSoundEngine.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockSoundEngine.java new file mode 100644 index 0000000..fc4c038 @@ -10772,6 +11266,161 @@ index 0000000..fc4c038 + ChannelAccess viaFabricPlusBedrock$channels(); + float viaFabricPlusBedrock$categoryGain(float volume, SoundSource source); +} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/sodium/MixinSodiumNativeFluid.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/sodium/MixinSodiumNativeFluid.java +new file mode 100644 +index 0000000..13a9218 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/sodium/MixinSodiumNativeFluid.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.injection.mixin.compat.sodium; ++ ++import com.viaversion.viafabricplus.bedrock.appearance.BedrockNativeBlocks; ++import com.viaversion.viafabricplus.bedrock.injection.access.IBedrockNativeFluidView; ++import net.minecraft.client.multiplayer.ClientLevel; ++import net.minecraft.core.BlockPos; ++import net.minecraft.world.level.material.FluidState; ++import org.spongepowered.asm.mixin.Final; ++import org.spongepowered.asm.mixin.Mixin; ++import org.spongepowered.asm.mixin.Pseudo; ++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.CallbackInfoReturnable; ++ ++@Pseudo ++@Mixin(targets = "net.caffeinemc.mods.sodium.client.world.LevelSlice", remap = false) ++public abstract class MixinSodiumNativeFluid implements IBedrockNativeFluidView { ++ @Shadow @Final private ClientLevel level; ++ ++ @Inject(method = "getFluidState(Lnet/minecraft/core/BlockPos;)Lnet/minecraft/world/level/material/FluidState;", at = @At("HEAD"), cancellable = true) ++ private void nativeFluid(final BlockPos position, final CallbackInfoReturnable cir) { ++ final var fluid = this.bedrock$fluidState(position.getX(), position.getY(), position.getZ()); ++ if (fluid != null) cir.setReturnValue(fluid); ++ } ++ ++ @Override ++ public FluidState bedrock$fluidState(final int x, final int y, final int z) { ++ return BedrockNativeBlocks.fluidState(this.level, x, y, z); ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/sodium/MixinSodiumNativeFluidMesh.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/sodium/MixinSodiumNativeFluidMesh.java +new file mode 100644 +index 0000000..2bcf926 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/sodium/MixinSodiumNativeFluidMesh.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.injection.mixin.compat.sodium; ++ ++import com.llamalad7.mixinextras.injector.ModifyExpressionValue; ++import com.llamalad7.mixinextras.injector.wrapoperation.Operation; ++import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; ++import com.llamalad7.mixinextras.sugar.Share; ++import com.llamalad7.mixinextras.sugar.ref.LocalRef; ++import com.viaversion.viafabricplus.bedrock.render.BedrockFluidRendering; ++import net.minecraft.world.level.BlockGetter; ++import net.minecraft.world.level.block.state.BlockState; ++import net.minecraft.world.level.material.FluidState; ++import org.spongepowered.asm.mixin.Mixin; ++import org.spongepowered.asm.mixin.Pseudo; ++import org.spongepowered.asm.mixin.injection.At; ++import org.spongepowered.asm.mixin.injection.Coerce; ++ ++@Pseudo ++@Mixin(targets = "net.caffeinemc.mods.sodium.client.render.chunk.compile.tasks.ChunkBuilderMeshingTask", remap = false) ++public abstract class MixinSodiumNativeFluidMesh { ++ @WrapOperation(method = "execute", at = @At(value = "INVOKE", target = "Lnet/caffeinemc/mods/sodium/client/world/LevelSlice;getBlockState(III)Lnet/minecraft/world/level/block/state/BlockState;")) ++ private BlockState nativeFluidInput(@Coerce final Object level, final int x, final int y, final int z, ++ final Operation original, @Share("bedrockFluid") final LocalRef fluid) { ++ final var primary = original.call(level, x, y, z); ++ fluid.set(BedrockFluidRendering.fluid((BlockGetter) level, x, y, z, primary.getFluidState())); ++ return primary; ++ } ++ ++ @ModifyExpressionValue(method = "execute", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/block/state/BlockState;isAir()Z")) ++ private boolean skipEmptyCell(final boolean original, ++ @Share("bedrockFluid") final LocalRef fluid) { ++ return original && fluid.get().isEmpty(); ++ } ++ ++ @ModifyExpressionValue(method = "execute", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/block/state/BlockState;getFluidState()Lnet/minecraft/world/level/material/FluidState;")) ++ private FluidState nativeFluid(final FluidState original, ++ @Share("bedrockFluid") final LocalRef fluid) { ++ return fluid.get(); ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/sodium/MixinSodiumNativeFluidNeighbors.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/sodium/MixinSodiumNativeFluidNeighbors.java +new file mode 100644 +index 0000000..ade97ee +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/sodium/MixinSodiumNativeFluidNeighbors.java +@@ -0,0 +1,57 @@ ++/* ++ * 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.compat.sodium; ++ ++import com.llamalad7.mixinextras.injector.ModifyExpressionValue; ++import com.llamalad7.mixinextras.sugar.Local; ++import com.viaversion.viafabricplus.bedrock.render.BedrockFluidRendering; ++import net.minecraft.client.renderer.block.BlockAndTintGetter; ++import net.minecraft.core.BlockPos; ++import net.minecraft.core.Direction; ++import net.minecraft.world.level.BlockGetter; ++import net.minecraft.world.level.material.FluidState; ++import org.spongepowered.asm.mixin.Mixin; ++import org.spongepowered.asm.mixin.Pseudo; ++import org.spongepowered.asm.mixin.injection.At; ++ ++@Pseudo ++@Mixin(targets = "net.caffeinemc.mods.sodium.client.render.chunk.compile.pipeline.DefaultFluidRenderer", remap = false) ++public abstract class MixinSodiumNativeFluidNeighbors { ++ @ModifyExpressionValue(method = "isFullBlockFluidSideVisible", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/block/state/BlockState;getFluidState()Lnet/minecraft/world/level/material/FluidState;")) ++ private FluidState nativeNeighbor(final FluidState original, ++ @Local(argsOnly = true) final BlockGetter level, ++ @Local(argsOnly = true) final BlockPos position, ++ @Local(argsOnly = true) final Direction direction) { ++ return BedrockFluidRendering.fluid(level, position.getX() + direction.getStepX(), position.getY() + direction.getStepY(), position.getZ() + direction.getStepZ(), original); ++ } ++ ++ @ModifyExpressionValue(method = "sampleFluidHeight(Lnet/minecraft/client/renderer/block/BlockAndTintGetter;Lnet/minecraft/world/level/material/Fluid;Lnet/minecraft/core/BlockPos;)F", ++ at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/block/state/BlockState;getFluidState()Lnet/minecraft/world/level/material/FluidState;")) ++ private FluidState nativeHeight(final FluidState original, ++ @Local(argsOnly = true) final BlockAndTintGetter level, ++ @Local(argsOnly = true) final BlockPos position) { ++ return BedrockFluidRendering.fluid(level, position, original); ++ } ++ ++ @ModifyExpressionValue(method = "visitExposureNeighbor", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/block/state/BlockState;getFluidState()Lnet/minecraft/world/level/material/FluidState;", ordinal = 0)) ++ private FluidState nativeAboveExposure(final FluidState original, ++ @Local(argsOnly = true) final BlockAndTintGetter level, ++ @Local(argsOnly = true) final BlockPos position, ++ @Local(argsOnly = true, ordinal = 0) final int x, ++ @Local(argsOnly = true, ordinal = 1) final int z) { ++ return BedrockFluidRendering.fluid(level, position.getX() + x, position.getY() + 1, position.getZ() + z, original); ++ } ++ ++ @ModifyExpressionValue(method = "visitExposureNeighbor", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/block/state/BlockState;getFluidState()Lnet/minecraft/world/level/material/FluidState;", ordinal = 1)) ++ private FluidState nativeExposure(final FluidState original, ++ @Local(argsOnly = true) final BlockAndTintGetter level, ++ @Local(argsOnly = true) final BlockPos position, ++ @Local(argsOnly = true, ordinal = 0) final int x, ++ @Local(argsOnly = true, ordinal = 1) final int z) { ++ return BedrockFluidRendering.fluid(level, position.getX() + x, position.getY(), position.getZ() + z, original); ++ } ++ ++} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/particle/MixinBedrockParticleEngine.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/particle/MixinBedrockParticleEngine.java new file mode 100644 index 0000000..3b998a1 @@ -11142,44 +11791,254 @@ index 0000000..49b7770 + return BedrockActorPicking.filter(original, camera); + } +} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/sound/MixinBedrockChannel.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/sound/MixinBedrockChannel.java +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeChunkFluid.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeChunkFluid.java new file mode 100644 -index 0000000..12e7853 +index 0000000..6c1b4a4 --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/sound/MixinBedrockChannel.java -@@ -0,0 +1,17 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeChunkFluid.java +@@ -0,0 +1,29 @@ +/* + * 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.sound; ++package com.viaversion.viafabricplus.bedrock.injection.mixin.features.render; + -+import com.mojang.blaze3d.audio.Channel; ++import com.viaversion.viafabricplus.bedrock.appearance.BedrockNativeBlocks; ++import net.minecraft.world.level.Level; ++import net.minecraft.world.level.chunk.LevelChunk; ++import net.minecraft.world.level.material.FluidState; ++import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; -+import org.spongepowered.asm.mixin.gen.Accessor; -+import org.spongepowered.asm.mixin.gen.Invoker; ++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.CallbackInfoReturnable; + -+@Mixin(Channel.class) -+public interface MixinBedrockChannel { -+ @Accessor("source") int viaFabricPlusBedrock$source(); -+ @Invoker("removeProcessedBuffers") int viaFabricPlusBedrock$removeProcessedBuffers(); ++/** Level queries and direct chunk queries share the authoritative native fluid layer. */ ++@Mixin(LevelChunk.class) ++public abstract class MixinNativeChunkFluid { ++ @Shadow @Final private Level level; ++ ++ @Inject(method = "getFluidState(III)Lnet/minecraft/world/level/material/FluidState;", at = @At("HEAD"), cancellable = true) ++ private void nativeFluid(final int x, final int y, final int z, final CallbackInfoReturnable cir) { ++ final var fluid = BedrockNativeBlocks.fluidState(this.level, x, y, z); ++ if (fluid != null) cir.setReturnValue(fluid); ++ } +} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/sound/MixinBedrockChannelAccess.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/sound/MixinBedrockChannelAccess.java +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeFluidMesh.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeFluidMesh.java new file mode 100644 -index 0000000..80b66e9 +index 0000000..0fff4d5 --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/sound/MixinBedrockChannelAccess.java -@@ -0,0 +1,32 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeFluidMesh.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.injection.mixin.features.sound; ++package com.viaversion.viafabricplus.bedrock.injection.mixin.features.render; + -+import com.viaversion.viafabricplus.bedrock.injection.access.IBedrockChannelAccess; -+import net.minecraft.client.sounds.ChannelAccess; ++import com.llamalad7.mixinextras.injector.ModifyExpressionValue; ++import com.llamalad7.mixinextras.injector.wrapoperation.Operation; ++import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; ++import com.llamalad7.mixinextras.sugar.Share; ++import com.llamalad7.mixinextras.sugar.ref.LocalRef; ++import com.viaversion.viafabricplus.bedrock.render.BedrockFluidRendering; ++import net.minecraft.client.renderer.chunk.RenderSectionRegion; ++import net.minecraft.client.renderer.chunk.SectionCompiler; ++import net.minecraft.core.BlockPos; ++import net.minecraft.world.level.BlockGetter; ++import net.minecraft.world.level.block.state.BlockState; ++import net.minecraft.world.level.material.FluidState; ++import org.spongepowered.asm.mixin.Mixin; ++import org.spongepowered.asm.mixin.injection.At; ++ ++@Mixin(SectionCompiler.class) ++public abstract class MixinNativeFluidMesh { ++ @WrapOperation(method = "compile", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/renderer/chunk/RenderSectionRegion;getBlockState(Lnet/minecraft/core/BlockPos;)Lnet/minecraft/world/level/block/state/BlockState;")) ++ private BlockState nativeFluidInput(final RenderSectionRegion level, final BlockPos position, ++ final Operation original, @Share("bedrockFluid") final LocalRef fluid) { ++ final var primary = original.call(level, position); ++ fluid.set(BedrockFluidRendering.fluid(level, position, primary.getFluidState())); ++ return primary; ++ } ++ ++ @ModifyExpressionValue(method = "compile", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/block/state/BlockState;isAir()Z")) ++ private boolean skipEmptyCell(final boolean original, ++ @Share("bedrockFluid") final LocalRef fluid) { ++ return original && fluid.get().isEmpty(); ++ } ++ ++ @ModifyExpressionValue(method = "compile", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/block/state/BlockState;getFluidState()Lnet/minecraft/world/level/material/FluidState;")) ++ private FluidState nativeFluid(final FluidState original, ++ @Share("bedrockFluid") final LocalRef fluid) { ++ return fluid.get(); ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeFluidNeighbors.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeFluidNeighbors.java +new file mode 100644 +index 0000000..bc67176 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeFluidNeighbors.java +@@ -0,0 +1,78 @@ ++/* ++ * 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.llamalad7.mixinextras.injector.ModifyExpressionValue; ++import com.llamalad7.mixinextras.sugar.Local; ++import com.viaversion.viafabricplus.bedrock.render.BedrockFluidRendering; ++import net.minecraft.client.renderer.block.BlockAndTintGetter; ++import net.minecraft.client.renderer.block.FluidRenderer; ++import net.minecraft.core.BlockPos; ++import net.minecraft.world.level.material.FluidState; ++import org.spongepowered.asm.mixin.Mixin; ++import org.spongepowered.asm.mixin.injection.At; ++ ++@Mixin(FluidRenderer.class) ++public abstract class MixinNativeFluidNeighbors { ++ @ModifyExpressionValue(method = "tesselate", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/block/state/BlockState;getFluidState()Lnet/minecraft/world/level/material/FluidState;", ordinal = 0)) ++ private FluidState nativeDown(final FluidState original, ++ @Local(argsOnly = true) final BlockAndTintGetter level, ++ @Local(argsOnly = true) final BlockPos position) { ++ return BedrockFluidRendering.fluid(level, position.getX(), position.getY() - 1, position.getZ(), original); ++ } ++ ++ @ModifyExpressionValue(method = "tesselate", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/block/state/BlockState;getFluidState()Lnet/minecraft/world/level/material/FluidState;", ordinal = 1)) ++ private FluidState nativeUp(final FluidState original, ++ @Local(argsOnly = true) final BlockAndTintGetter level, ++ @Local(argsOnly = true) final BlockPos position) { ++ return BedrockFluidRendering.fluid(level, position.getX(), position.getY() + 1, position.getZ(), original); ++ } ++ ++ @ModifyExpressionValue(method = "tesselate", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/block/state/BlockState;getFluidState()Lnet/minecraft/world/level/material/FluidState;", ordinal = 2)) ++ private FluidState nativeNorth(final FluidState original, ++ @Local(argsOnly = true) final BlockAndTintGetter level, ++ @Local(argsOnly = true) final BlockPos position) { ++ return BedrockFluidRendering.fluid(level, position.getX(), position.getY(), position.getZ() - 1, original); ++ } ++ ++ @ModifyExpressionValue(method = "tesselate", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/block/state/BlockState;getFluidState()Lnet/minecraft/world/level/material/FluidState;", ordinal = 3)) ++ private FluidState nativeSouth(final FluidState original, ++ @Local(argsOnly = true) final BlockAndTintGetter level, ++ @Local(argsOnly = true) final BlockPos position) { ++ return BedrockFluidRendering.fluid(level, position.getX(), position.getY(), position.getZ() + 1, original); ++ } ++ ++ @ModifyExpressionValue(method = "tesselate", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/block/state/BlockState;getFluidState()Lnet/minecraft/world/level/material/FluidState;", ordinal = 4)) ++ private FluidState nativeWest(final FluidState original, ++ @Local(argsOnly = true) final BlockAndTintGetter level, ++ @Local(argsOnly = true) final BlockPos position) { ++ return BedrockFluidRendering.fluid(level, position.getX() - 1, position.getY(), position.getZ(), original); ++ } ++ ++ @ModifyExpressionValue(method = "tesselate", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/block/state/BlockState;getFluidState()Lnet/minecraft/world/level/material/FluidState;", ordinal = 5)) ++ private FluidState nativeEast(final FluidState original, ++ @Local(argsOnly = true) final BlockAndTintGetter level, ++ @Local(argsOnly = true) final BlockPos position) { ++ return BedrockFluidRendering.fluid(level, position.getX() + 1, position.getY(), position.getZ(), original); ++ } ++ ++ @ModifyExpressionValue(method = "getHeight(Lnet/minecraft/client/renderer/block/BlockAndTintGetter;Lnet/minecraft/world/level/material/Fluid;Lnet/minecraft/core/BlockPos;)F", ++ at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/block/state/BlockState;getFluidState()Lnet/minecraft/world/level/material/FluidState;")) ++ private FluidState nativeHeight(final FluidState original, ++ @Local(argsOnly = true) final BlockAndTintGetter level, ++ @Local(argsOnly = true) final BlockPos position) { ++ return BedrockFluidRendering.fluid(level, position, original); ++ } ++ ++ @ModifyExpressionValue(method = "getHeight(Lnet/minecraft/client/renderer/block/BlockAndTintGetter;Lnet/minecraft/world/level/material/Fluid;Lnet/minecraft/core/BlockPos;Lnet/minecraft/world/level/block/state/BlockState;Lnet/minecraft/world/level/material/FluidState;)F", ++ at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/block/state/BlockState;getFluidState()Lnet/minecraft/world/level/material/FluidState;")) ++ private FluidState nativeAboveHeight(final FluidState original, ++ @Local(argsOnly = true) final BlockAndTintGetter level, ++ @Local(argsOnly = true) final BlockPos position) { ++ return BedrockFluidRendering.fluid(level, position.getX(), position.getY() + 1, position.getZ(), original); ++ } ++ ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeRegionFluid.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeRegionFluid.java +new file mode 100644 +index 0000000..8a77e86 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeRegionFluid.java +@@ -0,0 +1,35 @@ ++/* ++ * 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.viaversion.viafabricplus.bedrock.appearance.BedrockNativeBlocks; ++import com.viaversion.viafabricplus.bedrock.injection.access.IBedrockNativeFluidView; ++import net.minecraft.client.multiplayer.ClientLevel; ++import net.minecraft.client.renderer.chunk.RenderSectionRegion; ++import net.minecraft.core.BlockPos; ++import net.minecraft.world.level.material.FluidState; ++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.CallbackInfoReturnable; ++ ++@Mixin(RenderSectionRegion.class) ++public abstract class MixinNativeRegionFluid implements IBedrockNativeFluidView { ++ @Shadow @Final private ClientLevel level; ++ ++ @Inject(method = "getFluidState(Lnet/minecraft/core/BlockPos;)Lnet/minecraft/world/level/material/FluidState;", at = @At("HEAD"), cancellable = true) ++ private void nativeFluid(final BlockPos position, final CallbackInfoReturnable cir) { ++ final var fluid = this.bedrock$fluidState(position.getX(), position.getY(), position.getZ()); ++ if (fluid != null) cir.setReturnValue(fluid); ++ } ++ ++ @Override ++ public FluidState bedrock$fluidState(final int x, final int y, final int z) { ++ return BedrockNativeBlocks.fluidState(this.level, x, y, z); ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/sound/MixinBedrockChannel.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/sound/MixinBedrockChannel.java +new file mode 100644 +index 0000000..12e7853 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/sound/MixinBedrockChannel.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.sound; ++ ++import com.mojang.blaze3d.audio.Channel; ++import org.spongepowered.asm.mixin.Mixin; ++import org.spongepowered.asm.mixin.gen.Accessor; ++import org.spongepowered.asm.mixin.gen.Invoker; ++ ++@Mixin(Channel.class) ++public interface MixinBedrockChannel { ++ @Accessor("source") int viaFabricPlusBedrock$source(); ++ @Invoker("removeProcessedBuffers") int viaFabricPlusBedrock$removeProcessedBuffers(); ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/sound/MixinBedrockChannelAccess.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/sound/MixinBedrockChannelAccess.java +new file mode 100644 +index 0000000..80b66e9 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/sound/MixinBedrockChannelAccess.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.injection.mixin.features.sound; ++ ++import com.viaversion.viafabricplus.bedrock.injection.access.IBedrockChannelAccess; ++import net.minecraft.client.sounds.ChannelAccess; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; @@ -11842,10 +12701,10 @@ index 0000000..1c1c8c8 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockBoneTransform.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockBoneTransform.java new file mode 100644 -index 0000000..3841693 +index 0000000..1cde5b9 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockBoneTransform.java -@@ -0,0 +1,27 @@ +@@ -0,0 +1,31 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -11865,13 +12724,17 @@ index 0000000..3841693 + parent.translate(bone.x, bone.y, bone.z); + if (entityRelative) { + // Native preserves the translated origin, then clears the entire inherited basis. -+ parent.m00(1).m01(0).m02(0).m03(0) -+ .m10(0).m11(1).m12(0).m13(0) -+ .m20(0).m21(0).m22(1).m23(0); ++ resetBasis(parent); + } + return parent.rotateZYX(bone.zRot, bone.yRot, bone.xRot) + .scale(bone.xScale, bone.yScale, bone.zScale); + } ++ ++ static Matrix4f resetBasis(final Matrix4f matrix) { ++ return matrix.m00(1).m01(0).m02(0).m03(0) ++ .m10(0).m11(1).m12(0).m13(0) ++ .m20(0).m21(0).m22(1).m23(0); ++ } +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockFirstPersonActor.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockFirstPersonActor.java new file mode 100644 @@ -12098,6 +12961,46 @@ index 0000000..9234b83 + return Math.clamp(fov, 5F, 130F); + } +} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockFluidRendering.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockFluidRendering.java +new file mode 100644 +index 0000000..1f5bc09 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockFluidRendering.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.render; ++ ++import com.viaversion.viafabricplus.bedrock.appearance.BedrockNativeBlocks; ++import com.viaversion.viafabricplus.bedrock.injection.access.IBedrockNativeFluidView; ++import net.minecraft.core.BlockPos; ++import net.minecraft.world.level.BlockGetter; ++import net.minecraft.world.level.Level; ++import net.minecraft.world.level.material.FluidState; ++ ++/** Supplies native fluid without replacing the primary block used for geometry and occlusion. */ ++public final class BedrockFluidRendering { ++ private BedrockFluidRendering() {} ++ ++ public static FluidState fluid(final BlockGetter level, final BlockPos position, final FluidState fallback) { ++ return fluid(level, position.getX(), position.getY(), position.getZ(), fallback); ++ } ++ ++ public static FluidState fluid(final BlockGetter level, final int x, final int y, final int z, final FluidState fallback) { ++ final FluidState nativeFluid; ++ if (level instanceof IBedrockNativeFluidView view) { ++ nativeFluid = view.bedrock$fluidState(x, y, z); ++ } else if (level instanceof Level world) { ++ nativeFluid = BedrockNativeBlocks.fluidState(world, x, y, z); ++ } else { ++ nativeFluid = null; ++ } ++ return nativeFluid != null ? nativeFluid : fallback; ++ } ++} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockGeometry.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockGeometry.java index 437a11a..27208bf 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockGeometry.java @@ -13095,10 +13998,24 @@ index 0000000..ff0be71 + #endif +} diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json -index de445f7..346c44f 100644 +index 23ea6ee..4785e69 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -47,6 +47,8 @@ +@@ -5,6 +5,13 @@ + "compatibilityLevel": "JAVA_25", + "priority": 1001, + "client": [ ++ "features.render.MixinNativeFluidMesh", ++ "features.render.MixinNativeFluidNeighbors", ++ "compat.sodium.MixinSodiumNativeFluidMesh", ++ "compat.sodium.MixinSodiumNativeFluidNeighbors", ++ "features.render.MixinNativeChunkFluid", ++ "features.render.MixinNativeRegionFluid", ++ "compat.sodium.MixinSodiumNativeFluid", + "compat.iris.MixinIrisPipelines", + "compat.iris.MixinNativeShaderCompilation", + "compat.iris.MixinNativeShaderBinding", +@@ -56,6 +63,8 @@ "features.misc.MixinClientSuggestionProvider", "features.misc.MixinInventoryScreen", "features.misc.MixinMinecraft", @@ -13107,7 +14024,7 @@ index de445f7..346c44f 100644 "features.render.MixinAbstractClientPlayer", "features.render.MixinPlayerInfoSkin", "features.render.MixinPlayerSkinRenderCache", -@@ -63,6 +65,11 @@ +@@ -72,6 +81,11 @@ "features.render.MixinEntityRenderDispatcher", "features.render.MixinMinecraftSkinLifecycle", "features.render.MixinLivingEntitySkinLayers", @@ -13119,7 +14036,7 @@ index de445f7..346c44f 100644 "features.render.MixinPlayerSkinWidget", "features.render.MixinGuiSkinRenderer", "features.movement.MixinEntity", -@@ -71,7 +78,17 @@ +@@ -80,7 +94,17 @@ "features.movement.MixinHoneyBlock", "features.movement.MixinLivingEntity", "features.movement.MixinLocalPlayer", @@ -15684,7 +16601,7 @@ index 0000000..fec4182 +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockEmitterEventsTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockEmitterEventsTest.java new file mode 100644 -index 0000000..c1f7f37 +index 0000000..9967f68 --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockEmitterEventsTest.java @@ -0,0 +1,181 @@ @@ -15754,12 +16671,12 @@ index 0000000..c1f7f37 + definition.add("events", graph); + final List observed = new ArrayList<>(); + final var environment = new BedrockMolang(); ++ final var base = vector(row, "base"); ++ final var emitter = BedrockParticleEffect.read(document).emitter(environment, new Random(4), new Vec3(base.x, base.y, base.z)); + environment.queries(Map.of("trace", (BedrockActorAnimation.Query) arguments -> { + observed.add(((Number) arguments.getFirst()).intValue()); + return 0; + }), 0, 0, 0); -+ final var base = vector(row, "base"); -+ final var emitter = BedrockParticleEffect.read(document).emitter(environment, new Random(4), new Vec3(base.x, base.y, base.z)); + final List emitterActions = new java.util.ArrayList<>(); + emitter.dispatcher(emitterActions::add); + for (final var update : row.getAsJsonArray("steps")) { @@ -17036,9 +17953,125 @@ index 0000000..7b4f70f + } + +} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockNativeBlockUpdatesTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockNativeBlockUpdatesTest.java +new file mode 100644 +index 0000000..f07cb9f +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockNativeBlockUpdatesTest.java +@@ -0,0 +1,70 @@ ++/* ++ * 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.viaversion.api.minecraft.BlockPosition; ++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.Test; ++ ++import java.util.List; ++import java.util.Map; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++final class BedrockNativeBlockUpdatesTest { ++ @Test ++ void invalidatesFluidChangesAcrossSectionBoundariesWithoutRepeatingEquivalentUpdates() { ++ final var view = new NativeBlockView(); ++ final var updates = new BedrockNativeBlockUpdates(view); ++ final var position = new BlockPosition(-17, -1, -33); ++ assertNull(updates.accept(new NativeBlockIdentifiers.Reset(0, -64, 384, 0))); ++ assertNull(updates.accept(new NativeBlockIdentifiers.Dictionary(0, List.of( ++ new NativeBlockIdentifiers.Name(1, "minecraft:bedrock", Map.of(), -1), ++ new NativeBlockIdentifiers.Name(2, "minecraft:water", Map.of(), 100), ++ new NativeBlockIdentifiers.Name(3, "minecraft:water", Map.of(), 100))))); ++ assertEquals(new BedrockNativeBlockUpdates.SectionRange(-3, -2, -4, -1, 0, -2), updates.accept( ++ new NativeBlockIdentifiers.Section(0, -2, -1, -3, 0, NativeBlockSection.create(index -> 1)))); ++ final var boundary = new BedrockNativeBlockUpdates.SectionRange(-2, -1, -3, -1, 0, -2); ++ assertEquals(boundary, updates.accept(new NativeBlockIdentifiers.Update(0, position, 1, 2))); ++ final var withinTwoCells = new BlockPosition(-18, -1, -34); ++ assertEquals(boundary, updates.accept(new NativeBlockIdentifiers.Update(0, withinTwoCells, 1, 2))); ++ assertNull(updates.accept(new NativeBlockIdentifiers.Update(0, position, 1, 2))); ++ assertNull(updates.accept(new NativeBlockIdentifiers.Update(0, position, 1, 3))); ++ assertEquals(3, view.state(position.x(), position.y(), position.z(), 1).state()); ++ assertNull(updates.accept(new NativeBlockIdentifiers.Update(1, position, 1, 0))); ++ assertEquals(100, view.fluidJavaId(position.x(), position.y(), position.z())); ++ assertEquals(boundary, updates.accept(new NativeBlockIdentifiers.Update(0, position, 1, 0))); ++ assertEquals(new BedrockNativeBlockUpdates.SectionRange(-3, -5, -4, -1, 20, -2), ++ updates.accept(new NativeBlockIdentifiers.Forget(0, -2, -3))); ++ updates.clear(); ++ assertFalse(view.ready()); ++ assertNull(updates.accept(new NativeBlockIdentifiers.Update(0, position, 1, 2))); ++ updates.accept(new NativeBlockIdentifiers.Reset(1, -64, 384, 0)); ++ assertNull(updates.accept(new NativeBlockIdentifiers.Section(0, -2, -1, -3, 0, NativeBlockSection.create(index -> 2)))); ++ assertEquals(-1, view.fluidJavaId(position.x(), position.y(), position.z())); ++ } ++ ++ @Test ++ void keepsTheDirtyRegionBoundedAtTheIntegerCoordinateLimits() { ++ final var view = new NativeBlockView(); ++ final var updates = new BedrockNativeBlockUpdates(view); ++ updates.accept(new NativeBlockIdentifiers.Reset(0, 0, 16, 0)); ++ updates.accept(new NativeBlockIdentifiers.Dictionary(0, List.of( ++ new NativeBlockIdentifiers.Name(1, "minecraft:water", Map.of(), 100)))); ++ final var position = new BlockPosition(Integer.MAX_VALUE, 15, Integer.MIN_VALUE); ++ updates.accept(new NativeBlockIdentifiers.Section(0, position.x() >> 4, 0, position.z() >> 4, ++ 0, NativeBlockSection.create(index -> 0))); ++ final var dirty = updates.accept(new NativeBlockIdentifiers.Update(0, position, 1, 1)); ++ assertEquals(Integer.MAX_VALUE >> 4, dirty.minX()); ++ assertEquals(dirty.minX(), dirty.maxX()); ++ assertEquals(Integer.MIN_VALUE >> 4, dirty.minZ()); ++ assertEquals(dirty.minZ(), dirty.maxZ()); ++ assertEquals(0, dirty.minY()); ++ assertEquals(1, dirty.maxY()); ++ } ++} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockNativeFluidTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockNativeFluidTest.java +new file mode 100644 +index 0000000..61d128e +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockNativeFluidTest.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.appearance; ++ ++import net.minecraft.SharedConstants; ++import net.minecraft.server.Bootstrap; ++import net.minecraft.world.level.block.Block; ++import net.minecraft.world.level.block.Blocks; ++import net.minecraft.world.level.material.Fluids; ++import org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++final class BedrockNativeFluidTest { ++ @Test ++ void preservesAllMappedLiquidLevelsAndRejectsNonliquidDictionaryStates() { ++ SharedConstants.tryDetectVersion(); ++ Bootstrap.bootStrap(); ++ for (final var block : new Block[]{Blocks.WATER, Blocks.LAVA}) { ++ for (final var state : block.getStateDefinition().getPossibleStates()) { ++ assertSame(state.getFluidState(), BedrockNativeBlocks.decodeFluid(Block.getId(state))); ++ } ++ } ++ assertSame(Fluids.EMPTY.defaultFluidState(), BedrockNativeBlocks.decodeFluid(-1)); ++ assertNull(BedrockNativeBlocks.decodeFluid(null)); ++ assertNull(BedrockNativeBlocks.decodeFluid(Integer.MAX_VALUE)); ++ assertNull(BedrockNativeBlocks.decodeFluid(Block.getId(Blocks.BEDROCK.defaultBlockState()))); ++ assertNull(BedrockNativeBlocks.decodeFluid(Block.getId(Blocks.OAK_SLAB.defaultBlockState()))); ++ assertNull(BedrockNativeBlocks.fluidState(null, 0, 0, 0)); ++ } ++} 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..a9c2713 +index 0000000..70d7c4b --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleAgingTest.java @@ -0,0 +1,118 @@ @@ -17094,6 +18127,7 @@ index 0000000..a9c2713 + final List observed = new ArrayList<>(); + final int[] born = {0}; + final var environment = new BedrockMolang(); ++ final var emitter = BedrockParticleEffect.read(document).emitter(environment, new Random(1), Vec3.ZERO); + environment.queries(Map.of( + "birth_position", (BedrockActorAnimation.Query) _ -> born[0] + 1, + "born", (BedrockActorAnimation.Query) _ -> { observed.add(new Callback("birth", 0)); return ++born[0]; }, @@ -17102,7 +18136,6 @@ index 0000000..a9c2713 + observed.add(new Callback("expiration", ((Number) args.getFirst()).floatValue())); + return config.get("expire").getAsBoolean() ? 1 : 0; + }), 0, 0, 0); -+ final var emitter = BedrockParticleEffect.read(document).emitter(environment, new Random(1), Vec3.ZERO); + for (final var item : row.getAsJsonArray("steps")) { + final var step = item.getAsJsonObject(); + observed.clear(); @@ -17162,7 +18195,7 @@ index 0000000..a9c2713 +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleBlocksTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleBlocksTest.java new file mode 100644 -index 0000000..16ee205 +index 0000000..c3e6069 --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleBlocksTest.java @@ -0,0 +1,149 @@ @@ -17259,8 +18292,8 @@ index 0000000..16ee205 + final var view = new NativeBlockView(); + view.accept(new NativeBlockIdentifiers.Reset(0, -64, 384, 0)); + view.accept(new NativeBlockIdentifiers.Dictionary(0, List.of( -+ new NativeBlockIdentifiers.Name(17, "test:target", Map.of()), -+ new NativeBlockIdentifiers.Name(41, "test:other", Map.of())))); ++ new NativeBlockIdentifiers.Name(17, "test:target", Map.of(), -1), ++ new NativeBlockIdentifiers.Name(41, "test:other", Map.of(), -1)))); + view.accept(new NativeBlockIdentifiers.Section(0, -2, -4, -3, 0, + NativeBlockSection.create(index -> 17))); + final var filter = new BedrockParticleBlocks(List.of(new BedrockParticleBlocks.Filter(Set.of("test:target"), true))); @@ -17315,6 +18348,312 @@ index 0000000..16ee205 + return new Vector3f(value.get(0).getAsFloat(), value.get(1).getAsFloat(), value.get(2).getAsFloat()); + } +} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleChildrenTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleChildrenTest.java +new file mode 100644 +index 0000000..64127e7 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleChildrenTest.java +@@ -0,0 +1,300 @@ ++package com.viaversion.viafabricplus.bedrock.appearance; ++ ++import com.google.gson.JsonObject; ++import com.google.gson.JsonParser; ++import com.google.gson.JsonPrimitive; ++import net.minecraft.world.phys.Vec3; ++import net.raphimc.viabedrock.api.util.Fnv1; ++import java.nio.charset.StandardCharsets; ++import org.joml.Vector3f; ++import org.junit.jupiter.api.Test; ++import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; ++ ++import java.nio.file.Files; ++import java.nio.file.Path; ++import java.util.ArrayList; ++import java.util.IdentityHashMap; ++import java.util.HashMap; ++import java.util.List; ++import java.util.Map; ++import java.util.Random; ++import java.util.function.Supplier; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++final class BedrockParticleChildrenTest { ++ @Test ++ void keepsFailedSlotsInNativeGrowthOrderWhenLaterAttemptsSucceed() { ++ final var children = new BedrockParticleChildren(_ -> 7L); ++ for (int index = 0; index < 8; index++) ++ assertNull(children.acquire(index, _ -> true, () -> null).child()); ++ assertTrue(children.acquire(8, _ -> true, () -> 8).created()); ++ for (int index = 7; index >= 0; index--) { ++ final int child = index; ++ assertTrue(children.acquire(index, _ -> true, () -> child).created()); ++ } ++ final var retired = new ArrayList(); ++ children.retire(_ -> true, retired::add); ++ assertEquals(List.of(8, 0, 1, 2, 3, 4, 5, 6, 7), retired); ++ } ++ ++ @Test ++ void dispatchesActualExpiryCallbacksInNativeBucketOrder() throws Exception { ++ final var children = new BedrockParticleChildren(index -> index == 2 ? 1L : 0L); ++ final var registered = new ArrayList(); ++ final var expired = new ArrayList(); ++ for (int index = 0; index < 3; index++) { ++ final int childIndex = index; ++ final var child = new Child(0, new ArrayList<>()); ++ child.environment.addQueries(Map.of("expiry", (BedrockActorAnimation.Query) _ -> { ++ expired.add(childIndex); return 0F; ++ })); ++ registered.add(child); ++ children.acquire(index, registered::contains, () -> child); ++ } ++ children.retire(registered::contains, child -> child.emitter.expire()); ++ assertEquals(List.of(1, 0, 2), expired); ++ assertTrue(registered.stream().noneMatch(child -> child.emitter.alive())); ++ } ++ ++ @Test ++ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_PARTICLE_CHILD_ORDER", matches = ".+") ++ void matchesOriginalCacheInsertionRehashAndDestructorTraversal() throws Exception { ++ final var fixture = JsonParser.parseString(Files.readString(Path.of(System.getenv("STACKANVIL_NATIVE_PARTICLE_CHILD_ORDER")))).getAsJsonObject(); ++ int cases = 0; ++ for (final var item : fixture.getAsJsonArray("rows")) { ++ final var row = item.getAsJsonObject(); ++ final Map hashes = new HashMap<>(); ++ final Map registered = new HashMap<>(); ++ final var names = row.getAsJsonArray("names"); ++ for (int index = 0; index < names.size(); index++) { ++ final String key = names.get(index).getAsString(); ++ hashes.put(key, Long.parseUnsignedLong(row.getAsJsonArray("hashes").get(index).getAsString())); ++ if (!registered.containsKey(key)) registered.put(key, registered.size()); ++ } ++ final var children = new BedrockParticleChildren(key -> hashes.get(key)); ++ for (final var name : names) ++ assertNull(children.acquire(name.getAsString(), _ -> true, () -> null).child()); ++ // Successful retries run in a different order without changing the original cache traversal. ++ final var retry = new ArrayList<>(registered.keySet()); ++ java.util.Collections.sort(retry, java.util.Comparator.reverseOrder()); ++ for (final String key : retry) ++ assertTrue(children.acquire(key, registered::containsValue, () -> registered.get(key)).created()); ++ final var actual = new ArrayList(); ++ children.retire(registered::containsValue, actual::add); ++ final var expected = row.getAsJsonArray("retirement").asList().stream().map(value -> value.getAsInt()).toList(); ++ assertEquals(expected, actual, "native cache case " + cases++); ++ } ++ assertEquals(32, cases); ++ } ++ ++ @Test ++ void retainsInitializedHandlesAcrossExpiryAndRetirementWithoutAdoptingReplacements() throws Exception { ++ final var children = new BedrockParticleChildren(BedrockParticleChildrenTest::hash); ++ final List registered = new ArrayList<>(); ++ final var first = new Child(4, new ArrayList<>()); ++ final int[] factories = {0}; ++ final Supplier factory = () -> { factories[0]++; registered.add(first); return first; }; ++ assertTrue(children.acquire("effect", registered::contains, factory).created()); ++ first.emitter.expire(); ++ assertFalse(first.emitter.alive()); ++ final var expired = children.acquire("effect", registered::contains, factory); ++ assertSame(first, expired.child()); ++ assertFalse(expired.created()); ++ registered.remove(first); ++ final var replacement = new Child(4, new ArrayList<>()); ++ registered.add(replacement); ++ assertNull(children.acquire("effect", registered::contains, factory).child()); ++ assertNull(children.acquire("effect", registered::contains, factory).child()); ++ assertEquals(1, factories[0]); ++ final var otherOwner = new BedrockParticleChildren(BedrockParticleChildrenTest::hash); ++ assertSame(replacement, otherOwner.acquire("effect", registered::contains, () -> replacement).child()); ++ assertNull(children.acquire("missing", registered::contains, () -> null).child()); ++ final var retried = children.acquire("missing", registered::contains, () -> replacement); ++ assertTrue(retried.created()); ++ children.discard("missing", first); ++ assertSame(replacement, children.acquire("missing", registered::contains, () -> fail()).child()); ++ children.discard("missing", replacement); ++ assertTrue(children.acquire("missing", registered::contains, () -> replacement).created()); ++ } ++ ++ @Test ++ void keepsAutomaticTargetsRegisteredWhenTheirManualCapacityIsZero() throws Exception { ++ final var children = new BedrockParticleChildren(BedrockParticleChildrenTest::hash); ++ final var registered = new ArrayList(); ++ final var emitter = BedrockParticleEffect.read(BedrockParticleEffectTest.burst()) ++ .emitter(new BedrockMolang(), new Random(1), Vec3.ZERO); ++ final var result = children.acquire("automatic", registered::contains, () -> { ++ registered.add(emitter); return emitter; ++ }); ++ assertTrue(result.created()); ++ assertFalse(emitter.emit(new Vector3f(), new Vector3f(), 10)); ++ emitter.advance(0, 10, null, (x, y, z) -> List.of()); ++ assertEquals(2, emitter.size()); ++ assertSame(emitter, children.acquire("automatic", registered::contains, () -> fail()).child()); ++ children.retire(registered::contains, BedrockParticleEffect.Emitter::expire); ++ assertEquals(2, emitter.size()); ++ } ++ ++ @Test ++ void expiresOwnedChildrenOnRetirementAndLetsTheirParticlesDrainBeforeExpiringGrandchildren() throws Exception { ++ final var parentDocument = BedrockParticleEffectTest.burst(); ++ final var components = parentDocument.getAsJsonObject("particle_effect").getAsJsonObject("components"); ++ components.getAsJsonObject("minecraft:emitter_rate_instant").addProperty("num_particles", 0); ++ components.getAsJsonObject("minecraft:emitter_lifetime_once").addProperty("active_time", 0); ++ final var parent = BedrockParticleEffect.read(parentDocument).emitter(new BedrockMolang(), new Random(1), Vec3.ZERO); ++ final var child = new Child(4, new ArrayList<>()); ++ final var grandchild = new Child(4, new ArrayList<>()); ++ final var registered = new ArrayList<>(List.of(parent, child.emitter, grandchild.emitter)); ++ final var effects = new IdentityHashMap(); ++ effects.put(parent, "parent"); effects.put(child.emitter, "child"); effects.put(grandchild.emitter, "grandchild"); ++ final var ownership = new IdentityHashMap>(); ++ registered.forEach(emitter -> ownership.put(emitter, new BedrockParticleChildren<>(BedrockParticleChildrenTest::hash))); ++ ownership.get(parent).acquire("child", registered::contains, () -> child.emitter); ++ ownership.get(child.emitter).acquire("grandchild", registered::contains, () -> grandchild.emitter); ++ final var counts = new BedrockParticleCounts(); ++ effects.values().forEach(counts::created); ++ final var parentCounts = new BedrockMolang(); parentCounts.queries(counts.queries("parent")); ++ child.environment.addQueries(Map.of("expiry", (BedrockActorAnimation.Query) _ -> { ++ assertFalse(registered.contains(parent)); ++ assertEquals(0, value(parentCounts, "q.effect_emitter_count")); ++ return 0F; ++ })); ++ assertTrue(child.emitter.emit(new Vector3f(), new Vector3f(), 10)); ++ parent.advance(0, 0, null, (x, y, z) -> List.of()); ++ final var updated = new ArrayList(); ++ final Runnable frame = () -> BedrockParticleFrames.advance(registered, counts, effects::get, ++ BedrockParticleEffect.Emitter::alive, ++ emitter -> ownership.get(emitter).retire(registered::contains, BedrockParticleEffect.Emitter::expire), ++ emitter -> { updated.add(emitter); emitter.advance(0, 10, null, (x, y, z) -> List.of()); }, ++ BedrockParticleEffect.Emitter::size); ++ frame.run(); ++ assertEquals(List.of(grandchild.emitter, child.emitter), registered); ++ assertEquals(registered, updated); ++ assertEquals(1, child.emitter.size()); ++ assertFalse(child.emitter.emit(new Vector3f(), new Vector3f(), 10)); ++ assertTrue(grandchild.emitter.alive()); ++ child.emitter.advance(200_000_000, 10, null, (x, y, z) -> List.of()); ++ child.emitter.advance(200_000_000, 10, null, (x, y, z) -> List.of()); ++ assertFalse(child.emitter.alive()); ++ updated.clear(); frame.run(); ++ assertEquals(List.of(grandchild.emitter), registered); ++ assertFalse(grandchild.emitter.alive()); ++ updated.clear(); frame.run(); ++ assertTrue(registered.isEmpty()); ++ assertTrue(updated.isEmpty()); ++ assertEquals(0, value(parentCounts, "q.total_emitter_count")); ++ } ++ ++ @Test ++ void runsExpiryOnceBeforeClosingManualEmissionAndPreservesExistingParticles() throws Exception { ++ final var trace = new ArrayList(); ++ final var child = new Child(4, trace); ++ child.environment.addQueries(Map.of("expiry", (BedrockActorAnimation.Query) _ -> { ++ trace.add("expiry"); ++ assertTrue(child.emitter.emit(new Vector3f(7, 8, 9), new Vector3f(), 10)); ++ return 0F; ++ })); ++ child.emitter.expire(); child.emitter.expire(); ++ assertEquals(List.of("expiry", "birth"), trace); ++ assertEquals(1, child.emitter.size()); ++ assertTrue(child.emitter.alive()); ++ assertFalse(child.emitter.emit(new Vector3f(), new Vector3f(), 10)); ++ child.emitter.advance(50_000_000, 10, null, (x, y, z) -> List.of()); ++ assertEquals(1, child.emitter.size()); ++ } ++ ++ @Test ++ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_PARTICLE_CHILD_LIFETIMES", matches = ".+") ++ void matchesNativeCachedHandleAdmissionAcrossRepeatedManualRequests() throws Exception { ++ int cases = 0; ++ for (final var entry : fixture().getAsJsonArray("rows")) { ++ final var row = entry.getAsJsonObject(); ++ if (!row.get("mode").getAsString().equals("dispatch")) continue; ++ cases++; ++ final var trace = new ArrayList(); ++ final var child = new Child(row.get("capacity").getAsInt(), trace); ++ final String scenario = row.get("scenario").getAsString(); ++ if (scenario.equals("expired")) { child.emitter.expire(); trace.clear(); } ++ final var registered = new ArrayList(); ++ final var children = new BedrockParticleChildren(BedrockParticleChildrenTest::hash); ++ if (!scenario.startsWith("new")) children.acquire("effect", _ -> true, () -> child); ++ if (List.of("live", "expired").contains(scenario)) registered.add(child); ++ for (int step = 0; step < 2; step++) { ++ trace.clear(); ++ final var result = children.acquire("effect", registered::contains, () -> { ++ trace.add("factory"); ++ if (scenario.equals("new-failed")) return null; ++ registered.add(child); return child; ++ }); ++ if (result.child() != null) { ++ child.emitter.initialize(program("q.initialize();")); ++ child.emitter.emit(new Vector3f(7, 8, 9), row.get("kind").getAsInt() == 2 ? new Vector3f(2, 3, 4) : new Vector3f(), 100); ++ } ++ trace.add("source"); ++ final var expected = row.getAsJsonArray("trace").get(step).getAsJsonArray().asList().stream() ++ .map(value -> value.getAsString()).filter(value -> !value.equals("context")).toList(); ++ assertEquals(expected, trace, row.toString()); ++ } ++ } ++ assertEquals(32, cases); ++ } ++ ++ @Test ++ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_PARTICLE_CHILD_LIFETIMES", matches = ".+") ++ void matchesNativeOwnerRetirementAndStaleHandleGates() throws Exception { ++ int cases = 0; ++ for (final var entry : fixture().getAsJsonArray("rows")) { ++ final var row = entry.getAsJsonObject(); ++ if (!row.get("mode").getAsString().equals("retire")) continue; ++ cases++; ++ final var trace = new ArrayList(); ++ final int capacity = row.get("capacity").getAsInt(); ++ final var child = new Child(capacity, trace); ++ final String scenario = row.get("scenario").getAsString(); ++ if (scenario.equals("expired")) child.emitter.expire(); ++ child.environment.addQueries(Map.of("expiry", (BedrockActorAnimation.Query) _ -> { ++ trace.add("expiry"); child.emitter.emit(new Vector3f(7, 8, 9), new Vector3f(2, 3, 4), 100); return 0F; ++ })); ++ final var children = new BedrockParticleChildren(BedrockParticleChildrenTest::hash); ++ children.acquire("effect", _ -> true, () -> child); trace.clear(); ++ if (!scenario.equals("shutdown")) children.retire(_ -> scenario.equals("live") || scenario.equals("expired"), value -> value.emitter.expire()); ++ final var expected = row.getAsJsonArray("trace").get(0).getAsJsonArray().asList().stream() ++ .map(value -> value.getAsString()).filter(value -> !value.equals("context") && !value.startsWith("expired_during_callback=")).toList(); ++ assertEquals(expected, trace, row.toString()); ++ assertEquals(!row.get("expiredAfterRetirement").getAsBoolean() && capacity > 0, ++ child.emitter.emit(new Vector3f(), new Vector3f(), 100)); ++ } ++ assertEquals(14, cases); ++ } ++ ++ private static long hash(final String name) { return Fnv1.fnv1_64(name.getBytes(StandardCharsets.UTF_8)); } ++ ++ private static JsonObject fixture() throws Exception { ++ return JsonParser.parseString(Files.readString(Path.of(System.getenv("STACKANVIL_NATIVE_PARTICLE_CHILD_LIFETIMES")))).getAsJsonObject(); ++ } ++ private static BedrockMolang.Program program(final String expression) throws java.io.IOException { ++ return BedrockMolang.Program.parse(new JsonPrimitive(expression), 0); ++ } ++ private static float value(final BedrockMolang environment, final String expression) { ++ try { return environment.evaluate(program(expression), 0); } ++ catch (java.io.IOException invalid) { throw new AssertionError(invalid); } ++ } ++ private static final class Child { ++ private final BedrockMolang environment = new BedrockMolang(); ++ private final BedrockParticleEffect.Emitter emitter; ++ private Child(final int capacity, final List trace) throws Exception { ++ final var document = BedrockParticleEventsTest.manual(); ++ final var effect = document.getAsJsonObject("particle_effect"); ++ final var components = effect.getAsJsonObject("components"); ++ components.getAsJsonObject("minecraft:emitter_rate_manual").addProperty("max_particles", capacity); ++ components.add("minecraft:particle_lifetime_expression", JsonParser.parseString("{\"max_lifetime\":0.1}")); ++ components.add("minecraft:particle_lifetime_events", JsonParser.parseString("{\"creation_event\":\"birth\"}")); ++ components.add("minecraft:emitter_lifetime_events", JsonParser.parseString("{\"expiration_event\":\"expiry\"}")); ++ effect.add("events", JsonParser.parseString("{\"birth\":{\"expression\":\"q.birth();\"},\"expiry\":{\"expression\":\"q.expiry();\"}}")); ++ this.emitter = BedrockParticleEffect.read(document).emitter(this.environment, new Random(1), Vec3.ZERO); ++ this.environment.addQueries(Map.of("initialize", (BedrockActorAnimation.Query) _ -> { trace.add("initialize"); return 0F; }, ++ "birth", (BedrockActorAnimation.Query) _ -> { trace.add("birth"); return 0F; })); ++ } ++ } ++} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleCollisionEventsTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleCollisionEventsTest.java new file mode 100644 index 0000000..a044376 @@ -17628,7 +18967,7 @@ index 0000000..bc368c4 +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleCountsTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleCountsTest.java new file mode 100644 -index 0000000..ec88b01 +index 0000000..25b53bc --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleCountsTest.java @@ -0,0 +1,216 @@ @@ -17705,7 +19044,7 @@ index 0000000..ec88b01 + final int[] index = {0}; + // The fixture chooses the native initialization gate. Only batch publication is compared here. + if (frame.get("index").getAsInt() > 0) { -+ BedrockParticleFrames.advance(registered, counts, node -> node.effect, node -> node.alive, node -> { ++ BedrockParticleFrames.advance(registered, counts, node -> node.effect, node -> node.alive, _ -> {}, node -> { + sample(samples.get(index[0]++).getAsJsonObject(), "before_update", node); + node.updates++; + node.size++; @@ -17745,7 +19084,7 @@ index 0000000..ec88b01 + final var document = BedrockParticleEffectTest.burst(); + final var components = document.getAsJsonObject("particle_effect").getAsJsonObject("components"); + components.add("minecraft:emitter_initialization", JsonParser.parseString(""" -+ {"creation_expression":"v.prior=q.effect_emitter_count;v.actor=q.scale;"} ++ {"creation_expression":"v.prior=q.effect_emitter_count;v.actor=q.ground_speed;"} + """)); + components.add("minecraft:particle_appearance_billboard", JsonParser.parseString(""" + {"size":["q.effect_particle_count", "q.total_emitter_count"]} @@ -17753,11 +19092,11 @@ index 0000000..ec88b01 + final var counts = new BedrockParticleCounts(); + counts.created("test:burst"); + final var environment = new BedrockMolang(); -+ environment.queries(Map.of("scale", 7F)); ++ environment.queries(Map.of("ground_speed", 7F)); + final var emitter = BedrockParticleEffect.read(document).emitter(environment, new Random(1), + BedrockParticleAttachment.Matrix.IDENTITY, null, counts.queries("test:burst")); + assertEquals(0, evaluate(environment, "v.prior")); -+ assertEquals(7, evaluate(environment, "v.actor")); ++ assertEquals(0, evaluate(environment, "v.actor")); + counts.created("test:burst"); + emitter.queryContext(null, Vec3.ZERO); + emitter.initialize(program("v.registered=q.effect_emitter_count;")); @@ -18172,7 +19511,7 @@ index 0000000..93eb9f7 +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleDispatchTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleDispatchTest.java new file mode 100644 -index 0000000..2701ccd +index 0000000..778d29f --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleDispatchTest.java @@ -0,0 +1,101 @@ @@ -18217,11 +19556,6 @@ index 0000000..2701ccd + if (step.has("child_initialization")) expected.add(1); + if (step.has("source_expression")) expected.add(2); + } -+ source.queries(Map.of("trace", (BedrockActorAnimation.Query) _ -> { -+ assertEquals(expected.contains(1) ? 7 : 0, childEnvironment.evaluate(initialized, 0)); -+ actual.add(2); -+ return 0; -+ })); + final var parentDocument = BedrockParticleEffectTest.burst(); + final var definition = parentDocument.getAsJsonObject("particle_effect"); + definition.add("events", JsonParser.parseString(""" @@ -18234,6 +19568,11 @@ index 0000000..2701ccd + components.getAsJsonObject("minecraft:emitter_rate_instant").addProperty("num_particles", 0); + components.add("minecraft:emitter_lifetime_events", JsonParser.parseString("{\"creation_event\":\"run\"}")); + final var parent = BedrockParticleEffect.read(parentDocument).emitter(source, new Random(1), Vec3.ZERO); ++ source.queries(Map.of("trace", (BedrockActorAnimation.Query) _ -> { ++ assertEquals(expected.contains(1) ? 7 : 0, childEnvironment.evaluate(initialized, 0)); ++ actual.add(2); ++ return 0; ++ })); + final String binding = row.get("binding").getAsString(); + final BedrockParticleAttachment attachment = binding.equals("none") ? null : binding.equals("actor") + ? new BedrockParticleAttachment(Vec3.ZERO, 180, null, null, Map.of()) @@ -18279,10 +19618,10 @@ index 0000000..2701ccd +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleEffectTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleEffectTest.java new file mode 100644 -index 0000000..03382cb +index 0000000..e3de532 --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleEffectTest.java -@@ -0,0 +1,676 @@ +@@ -0,0 +1,673 @@ +package com.viaversion.viafabricplus.bedrock.appearance; + +import static org.junit.jupiter.api.Assertions.*; @@ -18786,12 +20125,12 @@ index 0000000..03382cb + {"creation_expression":"v.phase=0;", "per_render_expression":"v.phase=v.phase+1;"} + """)); + components.add("minecraft:particle_appearance_billboard", json(""" -+ {"size":["v.phase+q.scale",0.25],"facing_camera_mode":"rotate_xyz"} ++ {"size":["v.phase+q.ground_speed",0.25],"facing_camera_mode":"rotate_xyz"} + """)); + final var environment = new BedrockMolang(); + environment.variable("phase", 9); -+ environment.queries(Map.of("scale", 2), 0, 0, 0); + final var emitter = BedrockParticleEffect.read(document).emitter(environment.copy(), new Random(1), Vec3.ZERO); ++ emitter.queryContext(new BedrockParticleAttachment(Vec3.ZERO, 180, null, null, Map.of("ground_speed", 2F)), Vec3.ZERO); + emitter.advance(0, 10, null, (x, y, z) -> List.of()); + final var first = BedrockParticleEffectTest.sample(emitter); + assertEquals(2, first.size()); @@ -18848,9 +20187,6 @@ index 0000000..03382cb + @Test + void rejectsComponentsThatNeedAnotherRuntime() throws Exception { + final var document = burst(); -+ components(document).add("minecraft:emitter_rate_manual", new JsonObject()); -+ assertThrows(IOException.class, () -> BedrockParticleEffect.read(document)); -+ components(document).remove("minecraft:emitter_rate_manual"); + components(document).add("minecraft:particle_appearance_billboard", json("{\"facing_camera_mode\":\"invalid\"}")); + assertThrows(IOException.class, () -> BedrockParticleEffect.read(document)); + } @@ -19427,10 +20763,10 @@ index 0000000..eda5846 +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleFramesTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleFramesTest.java new file mode 100644 -index 0000000..3ed65e9 +index 0000000..a98e723 --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleFramesTest.java -@@ -0,0 +1,112 @@ +@@ -0,0 +1,110 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -19476,10 +20812,8 @@ index 0000000..3ed65e9 + final List actual = new ArrayList<>(); + // Native initialization and skip gates select whether a batch runs. This test compares batch semantics. + if (step.get("index").getAsInt() > 0 && !step.get("skip").getAsBoolean()) { -+ final var batch = BedrockParticleFrames.batch(emitters, node -> { -+ if (!node.alive) actual.add(new Receipt("destroyed", node.id)); -+ return node.alive; -+ }); ++ final var batch = BedrockParticleFrames.batch(emitters, node -> node.alive, ++ node -> actual.add(new Receipt("destroyed", node.id))); + for (final Node node : batch) { + actual.add(new Receipt("update", node.id)); + node.updates++; @@ -19524,12 +20858,12 @@ index 0000000..3ed65e9 + components.getAsJsonObject("minecraft:emitter_lifetime_once").addProperty("active_time", 0); + final var emitter = BedrockParticleEffect.read(document).emitter(new BedrockMolang(), new Random(1), Vec3.ZERO); + final var emitters = new ArrayList<>(List.of(emitter)); -+ final var first = BedrockParticleFrames.batch(emitters, BedrockParticleEffect.Emitter::alive); ++ final var first = BedrockParticleFrames.batch(emitters, BedrockParticleEffect.Emitter::alive, _ -> {}); + assertEquals(1, first.size()); + emitter.advance(1, 0, null, (x, y, z) -> List.of()); + assertFalse(emitter.alive()); + assertEquals(1, emitters.size()); -+ assertTrue(BedrockParticleFrames.batch(emitters, BedrockParticleEffect.Emitter::alive).isEmpty()); ++ assertTrue(BedrockParticleFrames.batch(emitters, BedrockParticleEffect.Emitter::alive, _ -> {}).isEmpty()); + assertTrue(emitters.isEmpty()); + assertEquals(1, first.size()); + } @@ -19795,7 +21129,7 @@ index 0000000..2bf35e1 +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleLifetimeTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleLifetimeTest.java new file mode 100644 -index 0000000..6db31b3 +index 0000000..b348f55 --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleLifetimeTest.java @@ -0,0 +1,148 @@ @@ -19851,9 +21185,9 @@ index 0000000..6db31b3 + // The native probe supplies the creation callback and its zero initial counter. + environment.variable("creations", 0); + final int[] births = {0}; -+ environment.queries(java.util.Map.of("birth", (BedrockActorAnimation.Query) _ -> ++births[0]), 0, 0, 0); + components.add("minecraft:particle_initialization", JsonParser.parseString("{\"creation_expression\":\"q.birth();\"}")); + final var emitter = BedrockParticleEffect.read(document).emitter(environment, new Random(1), Vec3.ZERO); ++ environment.queries(java.util.Map.of("birth", (BedrockActorAnimation.Query) _ -> ++births[0]), 0, 0, 0); + int creations = 1; + for (final var step : row.getAsJsonArray("steps")) { + final var expected = step.getAsJsonObject(); @@ -20017,17 +21351,22 @@ index 0000000..8759f93 +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleQueriesTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleQueriesTest.java new file mode 100644 -index 0000000..e64e910 +index 0000000..a10e267 --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleQueriesTest.java -@@ -0,0 +1,99 @@ +@@ -0,0 +1,260 @@ +package com.viaversion.viafabricplus.bedrock.appearance; + +import com.google.gson.JsonArray; +import com.google.gson.JsonParser; +import net.minecraft.world.phys.Vec3; ++import net.minecraft.world.entity.Entity; ++import net.minecraft.SharedConstants; ++import net.minecraft.server.Bootstrap; ++import net.raphimc.viabedrock.api.model.NativeActorMessage; +import org.joml.Vector3f; +import org.junit.jupiter.api.Test; ++import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import java.nio.file.Files; @@ -20036,10 +21375,166 @@ index 0000000..e64e910 +import java.util.List; +import java.util.Map; +import java.util.Random; ++import java.util.Set; ++import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; ++import static org.mockito.Mockito.*; + +final class BedrockParticleQueriesTest { ++ @BeforeAll ++ static void bootstrap() { ++ SharedConstants.tryDetectVersion(); ++ Bootstrap.bootStrap(); ++ } ++ ++ @Test ++ void refreshesExistingParticlesFromActorChangesWithoutAnotherActorRender() throws Exception { ++ final var actor = mock(Entity.class); ++ when(actor.isAlive()).thenReturn(true); ++ when(actor.onGround()).thenReturn(true); ++ when(actor.isInWater()).thenReturn(true); ++ when(actor.getDeltaMovement()).thenReturn(new Vec3(.15, 0, 0)); ++ final var first = snapshot(2, 3, 0xff204060, Map.of("growth", 4F, "enabled", true, "style", "wide")); ++ final Map rendered = Map.of("ground_speed", 99F, "is_in_water", false, ++ "variant", 99F, "mark_variant", 99F, "geometry_scale", 2F, ++ "property", (BedrockActorAnimation.Query) _ -> 99F, "spellcolor", BedrockMolangColor.ZERO); ++ final var document = BedrockParticleEffectTest.burst(); ++ final var components = document.getAsJsonObject("particle_effect").getAsJsonObject("components"); ++ components.add("minecraft:particle_initialization", JsonParser.parseString("{\"creation_expression\":\"v.local=7;\"}")); ++ components.add("minecraft:particle_appearance_billboard", JsonParser.parseString(""" ++ {"size":["v.local+q.ground_speed+q.property('growth')+q.geometry_scale", ++ "q.is_in_water+q.variant+q.mark_variant"]} ++ """)); ++ components.add("minecraft:particle_appearance_tinting", JsonParser.parseString(""" ++ {"color":["q.spellcolor.r","q.spellcolor.g","q.spellcolor.b","q.spellcolor.a"]} ++ """)); ++ final var environment = new BedrockMolang(); ++ final var emitter = BedrockParticleEffect.read(document).emitter(environment, new Random(1), Vec3.ZERO); ++ final var sampled = attachment(BedrockParticleQueries.actor(actor, first, rendered)); ++ emitter.queryContext(sampled, Vec3.ZERO); ++ emitter.advance(0, 10, sampled, (x, y, z) -> List.of()); ++ assertEquals(16, BedrockParticleEffectTest.sample(emitter).getFirst().width()); ++ assertEquals(6, BedrockParticleEffectTest.sample(emitter).getFirst().height()); ++ assertEquals(0xff204060, BedrockParticleEffectTest.sample(emitter).getFirst().color()); ++ assertEquals(1, evaluate(environment, "q.has_property('growth')&&q.property('enabled')&&q.property('style')=='wide'")); ++ when(actor.isAlive()).thenReturn(false); ++ when(actor.onGround()).thenReturn(false); ++ when(actor.isInWater()).thenReturn(false); ++ when(actor.getDeltaMovement()).thenReturn(new Vec3(0, -.25, 0)); ++ final var second = snapshot(4, 5, 0xff806040, Map.of("growth", 1F)); ++ emitter.queryContext(attachment(BedrockParticleQueries.actor(actor, second, rendered)), Vec3.ZERO); ++ assertEquals(15, BedrockParticleEffectTest.sample(emitter).getFirst().width()); ++ assertEquals(9, BedrockParticleEffectTest.sample(emitter).getFirst().height()); ++ assertEquals(0xff806040, BedrockParticleEffectTest.sample(emitter).getFirst().color()); ++ assertEquals(0, evaluate(environment, "q.is_alive+q.is_on_ground+q.has_property('enabled')+q.property('missing')")); ++ final var captured = new BedrockMolang(); ++ captured.queries(BedrockParticleQueries.sample(sampled, new Vector3f(), Vec3.ZERO)); ++ assertEquals(3, evaluate(captured, "q.ground_speed")); ++ assertEquals(4, evaluate(captured, "q.property('growth')")); ++ assertEquals(99F, rendered.get("ground_speed")); ++ assertThrows(UnsupportedOperationException.class, () -> sampled.queries().put("ground_speed", 0F)); ++ } ++ ++ @Test ++ void clearsStaleNativeMetadataWhenTheCurrentActorHasNoSnapshot() throws Exception { ++ final var actor = mock(Entity.class); ++ when(actor.getDeltaMovement()).thenReturn(Vec3.ZERO); ++ final var old = BedrockParticleQueries.actor(actor, snapshot(3, 4, 0xff123456, Map.of("growth", 7)), Map.of()); ++ final var current = new BedrockMolang(); ++ current.queries(BedrockParticleQueries.actor(actor, null, old)); ++ assertEquals(0, evaluate(current, "q.property('growth')+q.has_property('growth')+q.variant+q.mark_variant" ++ + "+q.spellcolor.r+q.spellcolor.g+q.spellcolor.b+q.spellcolor.a")); ++ assertTrue(current.hasCompleteQueries()); ++ } ++ ++ @Test ++ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_PARTICLE_ACTOR_QUERIES", matches = ".+") ++ void matchesNativeCallbacksAfterActorMotionAndWaterChangeWithoutRendering() throws Exception { ++ final var fixture = JsonParser.parseString(Files.readString(Path.of(System.getenv("STACKANVIL_NATIVE_PARTICLE_ACTOR_QUERIES")))).getAsJsonObject(); ++ final var actor = mock(Entity.class); ++ for (final var element : fixture.getAsJsonArray("rows")) { ++ final var row = element.getAsJsonObject(); ++ when(actor.getDeltaMovement()).thenReturn(vector(row.getAsJsonArray("motion"))); ++ when(actor.isInWater()).thenReturn(row.get("water").getAsBoolean()); ++ final var environment = new BedrockMolang(); ++ environment.queries(BedrockParticleQueries.actor(actor, null, Map.of("ground_speed", 99F, "is_in_water", false))); ++ assertEquals(row.get("ground_speed").getAsFloat(), evaluate(environment, "q.ground_speed")); ++ assertEquals(row.get("is_in_water").getAsFloat(), evaluate(environment, "q.is_in_water")); ++ } ++ assertEquals(8, fixture.getAsJsonArray("rows").size()); ++ } ++ ++ private static NativeActorMessage.Snapshot snapshot(final int variant, final int markVariant, ++ final Integer color, final Map properties) { ++ return new NativeActorMessage.Snapshot(UUID.randomUUID(), UUID.randomUUID(), false, 1, ++ "test:actor", "", 1, Set.of(), variant, markVariant, color, properties); ++ } ++ ++ @Test ++ void createsAnUnboundContextWithoutLosingPreEffectVariablesOrChangingTheActor() throws Exception { ++ final var queries = List.of("ground_speed", "is_in_water", "anim_time", "delta_time", "frame_alpha", ++ "life_time", "distance_from_camera", "effect_emitter_count", "effect_particle_count", ++ "total_emitter_count", "total_particle_count"); ++ final Map actorQueries = new HashMap<>(); ++ queries.forEach(name -> actorQueries.put(name, 7F)); ++ final int[] propertyCalls = {0}; ++ actorQueries.put("property", (BedrockActorAnimation.Query) _ -> { propertyCalls[0]++; return 9F; }); ++ final var actor = new BedrockMolang(); ++ actor.queries(actorQueries, 11, 12, 13); ++ evaluate(actor, "v.parameter=q.ground_speed+q.property('growth');"); ++ final var captured = actor.copy(); ++ final var document = BedrockParticleEffectTest.burst(); ++ final var components = document.getAsJsonObject("particle_effect").getAsJsonObject("components"); ++ final var initialization = new com.google.gson.JsonObject(); ++ initialization.addProperty("creation_expression", String.join(";", queries.stream() ++ .map(name -> "v." + name + "=q." + name).toList()) + ";v.property=q.property('growth');"); ++ components.add("minecraft:emitter_initialization", initialization); ++ components.add("minecraft:emitter_lifetime_once", JsonParser.parseString(""" ++ {"active_time":"v.duration=q.ground_speed+q.anim_time+q.delta_time;return v.parameter;"} ++ """)); ++ final var emitter = BedrockParticleEffect.read(document).emitter(captured, new Random(1), new Vec3(3, 4, 12)); ++ for (final String query : queries) assertEquals(0, evaluate(captured, "v." + query), query); ++ assertEquals(0, evaluate(captured, "v.duration+v.property")); ++ assertEquals(16, evaluate(captured, "v.parameter")); ++ assertEquals(1, propertyCalls[0]); ++ assertFalse(captured.hasCompleteQueries()); ++ assertTrue(actor.hasCompleteQueries()); ++ assertEquals(11, evaluate(actor, "q.anim_time")); ++ assertEquals(7, evaluate(actor, "q.ground_speed")); ++ emitter.queryContext(attachment(actorQueries), Vec3.ZERO); ++ emitter.initialize(BedrockMolang.Program.parse(new com.google.gson.JsonPrimitive( ++ "v.initialized=v.parameter+q.ground_speed+q.property('growth');"), 0)); ++ assertEquals(32, evaluate(captured, "v.initialized")); ++ assertEquals(2, propertyCalls[0]); ++ } ++ ++ @Test ++ void keepsRefreshedQueriesAndVariablesWhenAnEmitterRestarts() throws Exception { ++ final var document = BedrockParticleEffectTest.burst(); ++ final var components = document.getAsJsonObject("particle_effect").getAsJsonObject("components"); ++ components.remove("minecraft:emitter_lifetime_once"); ++ components.add("minecraft:emitter_lifetime_looping", JsonParser.parseString("{\"active_time\":0.05,\"sleep_time\":0}")); ++ components.add("minecraft:emitter_initialization", JsonParser.parseString(""" ++ {"creation_expression":"v.creations=(v.creations??0)+1;v.speed=q.ground_speed;v.generic=q.anim_time+q.delta_time+q.life_time;"} ++ """)); ++ final var environment = new BedrockMolang(); ++ environment.variable("parameter", 8); ++ environment.queries(Map.of("ground_speed", 99F), 91, 92, 93); ++ final var emitter = BedrockParticleEffect.read(document).emitter(environment, new Random(1), Vec3.ZERO); ++ assertEquals(1, evaluate(environment, "v.creations")); ++ assertEquals(0, evaluate(environment, "v.speed+v.generic")); ++ final var actor = attachment(Map.of("ground_speed", 4F)); ++ emitter.queryContext(actor, Vec3.ZERO); ++ emitter.advance(0, 0, actor, (x, y, z) -> List.of()); ++ emitter.advance(50_000_001, 0, actor, (x, y, z) -> List.of()); ++ assertEquals(2, evaluate(environment, "v.creations")); ++ assertEquals(4, evaluate(environment, "v.speed")); ++ assertEquals(0, evaluate(environment, "v.generic")); ++ assertEquals(8, evaluate(environment, "v.parameter")); ++ assertTrue(emitter.alive()); ++ } ++ + @Test + @EnabledIfEnvironmentVariable(named = "STACKANVIL_PARTICLE_REFERENCE", matches = ".+") + void matchesNativeActorQueriesCameraDistanceAndSeparateClocks() throws Exception { @@ -20120,6 +21615,155 @@ index 0000000..e64e910 + return environment.evaluate(BedrockMolang.Program.parse(new com.google.gson.JsonPrimitive(expression), 0), 0); + } +} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleRatesTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleRatesTest.java +new file mode 100644 +index 0000000..0d368a3 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleRatesTest.java +@@ -0,0 +1,143 @@ ++package com.viaversion.viafabricplus.bedrock.appearance; ++ ++import com.google.gson.JsonObject; ++import com.google.gson.JsonParser; ++import net.minecraft.world.phys.Vec3; ++import org.joml.Vector3f; ++import org.junit.jupiter.api.Test; ++import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; ++ ++import java.nio.file.Files; ++import java.nio.file.Path; ++import java.util.List; ++import java.util.Random; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++final class BedrockParticleRatesTest { ++ @Test ++ void runsEventOnlyEmittersWithoutInventingAnEmissionRate() throws Exception { ++ final var document = definition(); ++ document.getAsJsonObject("particle_effect").add("events", json(""" ++ {"created":{"expression":"v.created=v.created+1;"}, ++ "expired":{"expression":"v.expired=v.expired+1;"}} ++ """)); ++ components(document).add("minecraft:emitter_lifetime_events", json(""" ++ {"creation_event":"created","expiration_event":"expired"} ++ """)); ++ final var environment = new BedrockMolang(); ++ environment.variable("created", 0); ++ environment.variable("expired", 0); ++ final var emitter = emitter(document, environment); ++ emitter.advance(0, 1000, null, (x, y, z) -> List.of()); ++ assertEquals(1, value(environment, "created")); ++ assertEquals(0, emitter.size()); ++ assertFalse(emitter.emit(new Vector3f(), new Vector3f(), 1000)); ++ emitter.expire(); ++ assertEquals(1, value(environment, "expired")); ++ assertFalse(emitter.alive()); ++ } ++ ++ @Test ++ void sumsRequestsInResourceOrderUsingSharedCapacityAndRateState() throws Exception { ++ for (final boolean steadyFirst : List.of(false, true)) { ++ final var document = definition(); ++ for (final String kind : steadyFirst ? List.of("steady", "instant") : List.of("instant", "steady")) ++ components(document).add("minecraft:emitter_rate_" + kind, ++ kind.equals("steady") ? json("{\"spawn_rate\":20,\"max_particles\":5}") ++ : json("{\"num_particles\":3}")); ++ final var emitter = emitter(document, new BedrockMolang()); ++ emitter.advance(50_000_000, 1000, null, (x, y, z) -> List.of()); ++ assertEquals(steadyFirst ? 4 : 3, emitter.size()); ++ emitter.advance(50_000_000, 1000, null, (x, y, z) -> List.of()); ++ assertEquals(steadyFirst ? 5 : 3, emitter.size()); ++ } ++ } ++ ++ @Test ++ void evaluatesManualCapacityBeforeCreationAndCountsManualBirthsAgainstInstantEmission() throws Exception { ++ final var document = definition(); ++ components(document).add("minecraft:emitter_rate_manual", json(""" ++ {"max_particles":"v.calls=v.calls+1;return 2;"} ++ """)); ++ components(document).add("minecraft:emitter_rate_instant", json("{\"num_particles\":10}")); ++ components(document).add("minecraft:emitter_initialization", json(""" ++ {"creation_expression":"v.initial_calls=v.calls;"} ++ """)); ++ final var environment = new BedrockMolang(); ++ environment.variable("calls", 0); ++ final var emitter = emitter(document, environment); ++ assertEquals(1, value(environment, "initial_calls")); ++ assertTrue(emitter.emit(new Vector3f(), new Vector3f(), 1000)); ++ emitter.advance(0, 1000, null, (x, y, z) -> List.of()); ++ assertEquals(1, emitter.size()); ++ assertTrue(emitter.emit(new Vector3f(), new Vector3f(), 1000)); ++ assertFalse(emitter.emit(new Vector3f(), new Vector3f(), 1000)); ++ assertEquals(4, value(environment, "calls")); ++ assertEquals(2, emitter.size()); ++ } ++ ++ @Test ++ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_PARTICLE_RATES", matches = ".+") ++ void matchesOriginalFactoryAndCombinedAutomaticAndManualRateDispatch() throws Exception { ++ final var fixture = json(Files.readString(Path.of(System.getenv("STACKANVIL_NATIVE_PARTICLE_RATES")))); ++ int cases = 0, steps = 0; ++ for (final var item : fixture.getAsJsonArray("rows")) { ++ final var row = item.getAsJsonObject(); ++ assertFalse(row.has("error"), row.toString()); ++ assertEquals(1, row.get("registered").getAsInt()); ++ final var configuration = row.getAsJsonObject("configuration"); ++ final var document = definition(); ++ for (final var kind : row.getAsJsonArray("kinds")) { ++ final var rate = new JsonObject(); ++ switch (kind.getAsString()) { ++ case "instant" -> rate.add("num_particles", configuration.get("instant")); ++ case "steady" -> { ++ rate.add("spawn_rate", configuration.get("steady")); ++ rate.add("max_particles", configuration.get("capacity")); ++ } ++ case "manual" -> rate.add("max_particles", configuration.get("manual")); ++ default -> fail("Unknown native rate"); ++ } ++ components(document).add("minecraft:emitter_rate_" + kind.getAsString(), rate); ++ } ++ final var emitter = emitter(document, new BedrockMolang()); ++ for (final var step : row.getAsJsonArray("steps")) { ++ final var expected = step.getAsJsonObject(); ++ final int before = emitter.size(); ++ final String context = "rate case " + cases + ", step " + steps++; ++ if (expected.get("action").getAsString().equals("manual")) ++ assertEquals(expected.get("births").getAsInt() > 0, ++ emitter.emit(new Vector3f(), new Vector3f(), 1000), context); ++ else emitter.advance(expected.get("nanos").getAsLong(), 1000, null, (x, y, z) -> List.of()); ++ assertEquals(expected.get("births").getAsInt(), emitter.size() - before, context); ++ assertEquals(expected.get("population").getAsInt(), emitter.size(), context); ++ } ++ cases++; ++ } ++ assertEquals(128, cases); ++ assertEquals(1152, steps); ++ } ++ ++ private static JsonObject definition() { ++ final var document = BedrockParticleEffectTest.burst(); ++ components(document).remove("minecraft:emitter_rate_instant"); ++ components(document).remove("minecraft:emitter_lifetime_once"); ++ components(document).getAsJsonObject("minecraft:particle_lifetime_expression").addProperty("max_lifetime", 10); ++ return document; ++ } ++ ++ private static JsonObject components(final JsonObject document) { ++ return document.getAsJsonObject("particle_effect").getAsJsonObject("components"); ++ } ++ ++ private static BedrockParticleEffect.Emitter emitter(final JsonObject document, final BedrockMolang environment) throws Exception { ++ return BedrockParticleEffect.read(document).emitter(environment, new Random(1), Vec3.ZERO); ++ } ++ ++ private static JsonObject json(final String text) { return JsonParser.parseString(text).getAsJsonObject(); } ++ ++ private static float value(final BedrockMolang environment, final String name) throws Exception { ++ return environment.evaluate(BedrockMolang.Program.parse(new com.google.gson.JsonPrimitive("v." + name), -1), -1); ++ } ++} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleRegistryTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockParticleRegistryTest.java new file mode 100644 index 0000000..9fb002e @@ -20920,10 +22564,10 @@ index 0000000..1aa8e73 +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockResourceLibraryTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockResourceLibraryTest.java new file mode 100644 -index 0000000..3f29d2a +index 0000000..1e033e8 --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockResourceLibraryTest.java -@@ -0,0 +1,534 @@ +@@ -0,0 +1,571 @@ +package com.viaversion.viafabricplus.bedrock.appearance; + +import static org.junit.jupiter.api.Assertions.*; @@ -20945,6 +22589,43 @@ index 0000000..3f29d2a +final class BedrockResourceLibraryTest { + + @Test ++ void decodesAndEmitsParticlesUsingPackRelativeTexturePaths() throws Exception { ++ final var source = new java.awt.image.BufferedImage(2, 1, java.awt.image.BufferedImage.TYPE_INT_ARGB); ++ source.setRGB(0, 0, 0xFFFF8000); ++ source.setRGB(1, 0, 0x800080FF); ++ final var encoded = new java.io.ByteArrayOutputStream(); ++ assertTrue(javax.imageio.ImageIO.write(source, "png", encoded)); ++ for (final String path : List.of("font/glyph_E1", "custom/atlas", "atlas")) { ++ final var document = BedrockParticleEffectTest.burst(); ++ final var parameters = document.getAsJsonObject("particle_effect").getAsJsonObject("description") ++ .getAsJsonObject("basic_render_parameters"); ++ parameters.addProperty("texture", path); ++ final var content = new net.raphimc.viabedrock.api.resourcepack.content.InMemoryContent(); ++ content.putString("manifest.json", "{\"format_version\":2,\"header\":{\"name\":\"test\",\"uuid\":\"" ++ + java.util.UUID.randomUUID() + "\",\"version\":[1,0,0]}}"); ++ content.putString("particles/probe.json", document.toString()); ++ content.put(path + ".png", encoded.toByteArray()); ++ final var pack = new net.raphimc.viabedrock.api.resourcepack.ResourcePack(content); ++ final byte[] archive = net.raphimc.viabedrock.api.resourcepack.NativeActorLibrary.encode(List.of(pack), ignored -> true); ++ final var layers = net.raphimc.viabedrock.api.resourcepack.NativeActorLibrary.decode(new java.io.ByteArrayInputStream(archive)); ++ final var resources = BedrockResourceLibrary.withServerPacks(null, layers.stream() ++ .map(net.raphimc.viabedrock.api.resourcepack.NativeActorLibrary.Layer::resources).toList()); ++ final var effect = BedrockParticleEffect.read(resources.particle("test:burst")); ++ final var image = BedrockParticleTexture.read(effect, resources).orElseThrow(); ++ assertEquals(2, image.getWidth()); ++ assertEquals(1, image.getHeight()); ++ assertEquals(source.getRGB(0, 0), image.getRGB(0, 0)); ++ assertEquals(source.getRGB(1, 0), image.getRGB(1, 0)); ++ final var emitter = effect.emitter(new BedrockMolang(), new java.util.Random(1), net.minecraft.world.phys.Vec3.ZERO); ++ emitter.advance(0, 10, null, (x, y, z) -> List.of()); ++ assertEquals(2, BedrockParticleEffectTest.sample(emitter).size()); ++ parameters.addProperty("texture", "../" + path); ++ assertThrows(IOException.class, () -> BedrockParticleEffect.read(document)); ++ assertThrows(IOException.class, () -> resources.particleTexture("../" + path)); ++ } ++ } ++ ++ @Test + void registersAsciiFoldedAnimationNamesButKeepsReferenceSpelling() throws Exception { + final String canonical = "animation.native.ÉİΣ😀upper"; + final String uppercase = "ANIMATION.Native.ÉİΣ😀UPPER"; @@ -23480,6 +25161,51 @@ index 0000000..7116a95 + return JsonParser.parseString(Files.readString(Path.of(System.getenv("STACKANVIL_HAND_PROJECTION")))).getAsJsonObject(); + } +} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockFluidRenderingTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockFluidRenderingTest.java +new file mode 100644 +index 0000000..0f0d87e +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockFluidRenderingTest.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; ++ ++import com.viaversion.viafabricplus.bedrock.injection.access.IBedrockNativeFluidView; ++import net.minecraft.SharedConstants; ++import net.minecraft.core.BlockPos; ++import net.minecraft.server.Bootstrap; ++import net.minecraft.world.level.BlockGetter; ++import net.minecraft.world.level.material.Fluids; ++import org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.*; ++import static org.mockito.Mockito.*; ++ ++final class BedrockFluidRenderingTest { ++ @Test ++ void resolvesEachCellIndependentlyAndDistinguishesDryFromUnknownNativeData() { ++ SharedConstants.tryDetectVersion(); ++ Bootstrap.bootStrap(); ++ final var world = mock(BlockGetter.class, withSettings().extraInterfaces(IBedrockNativeFluidView.class)); ++ final var nativeView = (IBedrockNativeFluidView) world; ++ final var origin = new BlockPos(-17, -1, -33); ++ final var water = Fluids.WATER.defaultFluidState(); ++ final var lava = Fluids.FLOWING_LAVA.defaultFluidState(); ++ final var dry = Fluids.EMPTY.defaultFluidState(); ++ when(nativeView.bedrock$fluidState(origin.getX(), origin.getY(), origin.getZ())).thenReturn(water); ++ when(nativeView.bedrock$fluidState(origin.getX(), origin.getY() + 1, origin.getZ())).thenReturn(lava); ++ when(nativeView.bedrock$fluidState(origin.getX(), origin.getY() - 1, origin.getZ())).thenReturn(dry); ++ assertSame(water, BedrockFluidRendering.fluid(world, origin, dry)); ++ assertSame(lava, BedrockFluidRendering.fluid(world, origin.above(), dry)); ++ assertSame(dry, BedrockFluidRendering.fluid(world, origin.below(), water)); ++ assertSame(water, BedrockFluidRendering.fluid(world, origin.north(), water)); ++ assertSame(water, BedrockFluidRendering.fluid(mock(BlockGetter.class), origin, water)); ++ } ++} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockGeometryQueriesTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockGeometryQueriesTest.java new file mode 100644 index 0000000..43e497e 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 59885dac..7af6610a 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 @@ -1872,7 +1872,7 @@ This move preserves the payload format and does not change native dimension calc ## Native world inputs -The block mirror now consumes the core's negotiated `block_identifiers_v2` channel. +The block mirror now consumes the core's negotiated `block_identifiers_v3` channel. It retains both native layers, runtime IDs and state properties for client world queries. Particle expiration continues to read only the primary layer. Its integration test verifies that changing the extra layer cannot change a primary-layer filter result. @@ -2020,3 +2020,303 @@ Terrain and scoreboards appear in each destination. A verified ray hit on the co The player graph resolves 200 animations and 12 controllers and produces changing sampled poses without graph-loading errors. All 191 skins and supplied geometry records install, with 4,993 remote-player render frames and 107,666 custom-actor render frames. The six owned processes close normally. Native visual parity, including cosmetic-preview facing direction, remains open. + + +## Particle atlas paths outside the textures folder + +Particle definitions now accept validated paths relative to the resource pack root. +They no longer require the `textures/` prefix in either the emitter parser or the image lookup. +This fixes Galaxite's `galaxite:gem_bag_particle`, which selects the existing `font/glyph_E1.png` atlas. +The shared native actor archive already retains PNG and TGA images outside the textures folder. +Existing archive path validation and image limits remain in place. + +Native evidence comes from Bedrock 1.26.51.1, protocol 2193, executable SHA-256 `537c0aee2e79afbdc94b44b28e00f466ae62bc50e2733d953b430db9dbaa9ee7`. +Function `142152f40` reads the texture field and copies its string without adding or checking a folder prefix. +A private probe executes instructions `142153906..142153a4b` with the original native JSON and string helpers. +Nine string cases retain their values, including font, custom-folder, root-level, long, and Unicode paths. +Three non-string cases fail the native type check. +The probe enters after material dispatch and excludes complete schema loading, later resource lookup, and rendering. +CRT byte/string operations, allocation, frees, and diagnostic backends use declared host boundaries. +Field admission alone does not prove that a later native resource lookup succeeds. + +Validation: + +- All 38 add-on patches replay successfully. +- Converter, core, and add-on builds pass 2,353 tests, with 152 optional skips and no failures or errors. +- A targeted test carries generated PNGs through the native actor archive, pack reconstruction, image decoding, and emitter output. +- That test covers font, custom-folder, and root-level paths, retained alpha, and rejected traversal paths. +- A content-hash audit checks 1,516 particle documents across 82 exported pack archives from 29 recorded connections. +- Outside the textures folder, it finds the Galaxite atlas and five invisible underscore placeholders. +- The complete plain Galaxite replay passes transport checks and extracts 4,112 gem-bag particle quads across 384 native particle frames. +- The matching Iris replay passes transport checks and extracts 1,614 gem-bag quads across 73 native particle frames. +- Both routes have no particle-load or observer errors. + +Those particle validation runs also report unresolved marker geometry and the camera item texture. +The CubeConverter patch now retains the marker declaration when optional fields are absent. +A missing waterlogged block-state warning also remains in the client log. +This change does not establish native pixel parity or complete particle material behavior under arbitrary shader packs. +Raw packs, native exports, observer code, and recordings remain private. + + +## Authoritative fluid queries + +Client-world and direct chunk queries now select fluid from the native block mirror. +Vanilla render regions and the optional Sodium 0.9.2 `LevelSlice` getter use the same source. +The adapter preserves all mapped water and lava levels, including falling states. +It retains vanilla queries for ordinary Java worlds, unknown native definitions and unavailable native world data. +It also rejects nonliquid Java states in fluid dictionary entries. + +The target-native layer selector passed 180 pinned-binary cases, as recorded in the core transport sidecar. +The client decoder tests every Java water and lava block state. +The earlier query-only validation passed 2,360 tests, with 152 optional skips and no failures. + +The mesh integration below now replaces direct block-state fluid reads in both supported mesh builders. +Native fluid face occlusion, vertex-height parity, complete current simulation and native visual parity remain open. + + +Two fresh 40-second Galaxite replays validate the getters against recorded native fluid layers. +Each run retains 7,174 secondary-fluid cells and samples 256 of them. +The plain client reports zero mismatches through the client world and vanilla render-region getters. +The Iris client also checks Sodium 0.9.2's `LevelSlice`, with zero mismatches across all three query surfaces. +The region and Sodium getter probes construct query objects for the current client level; they do not test generated fluid meshes. +Transport verification passes; the broader rendering check still fails on unresolved mappings, including the camera texture. + + +## Secondary fluids in chunk meshes + +The core represents fluid over native air as a Java fluid block so section-level empty checks retain it. +Both vanilla and Sodium 0.9.2 chunk compilers retain the native fluid beside each primary block. +A cell is skipped only when its primary block is air and its selected fluid is empty. +The fluid renderer receives the selected fluid while the primary block still supplies geometry and occlusion shapes. +This includes secondary fluids whose Java carrier has no waterlogged state, and fluids with an air primary block. + +Neighbor fluid reads now use the corresponding native positions for face selection and height sampling. +Sodium's hidden-fluid exposure walk also reads the native current and upper cells. +The lookup uses primitive coordinates in the mesh loop and neighbor paths to avoid extra position allocations. +Unknown native definitions and ordinary Java worlds retain the original Java fluid result. +A known dry native cell can override a fluid-bearing Java carrier. + +Current project test results total 2,364 passing tests, 152 optional skips and no failures. +Targeted tests distinguish known dry states from unavailable native data and keep adjacent fluid selections independent. +The native selector evidence remains the 180-case pinned-binary probe in the core transport sidecar. +This change does not replace the existing face-occlusion or vertex-height algorithms with a proven native equivalent. + + +Native fluid updates now invalidate their affected render sections even when the Java carrier state stays unchanged. +The invalidation range covers two horizontal blocks and one vertical block, including Sodium 0.9.2's five-by-five hidden-fluid exposure walk. +Equivalent fluid selections do not schedule another rebuild. +Section replacements and chunk removal also invalidate their dependent regions. +The update tracker follows dimension resets and connection/level clears; dirty coordinates stay within the integer block-coordinate section range. +Targeted tests cover wet/dry changes, equivalent runtime aliases, near-boundary samples, resets, ignored dimensions, chunk removal and integer limits. + + +Two final 40-second Galaxite replays pass transport verification without mixin or observer failures. +The plain renderer emits 80 vertices for fluids whose native primary layer is air; Sodium emits 72. +These counters include repeated chunk builds and do not measure distinct blocks or pixel equivalence. +Java now represents those cells as fluid blocks, so whole sections also survive empty-section checks. +The plain renderer also emits 4604 vertices for fluids absent from the primary Java block state. +Sodium culls the sampled solid-bedrock fluids with its existing occlusion rules; that difference does not establish native parity. + +Each replay includes a synthetic client-side control on the recorded wet bedrock state. +It removes and restores the native secondary liquid without changing Java block state 88. +The world fluid query transitions wet, dry and wet, and each run records two rebuild invalidations attributed to those exact control messages. +The original layer is restored before the test ends. +This verifies the update bridge, not native server inventory actions or native fluid simulation. +The broader rendering audit still fails on existing unresolved mappings, including the camera texture and unregistered `galaxite:loading_fx` actor. + + +## Share the native basis reset + +The actor transform helper exposes its existing basis reset for equipped bone bindings. +Ordinary composition retains its translation, rotation, and scale order. +The reset preserves the translated origin and clears the inherited basis. +Native composition `0x141bc3d20` uses this operation after a binding supplies its copied owner matrix. +The equipment patch records the native binding controls and their boundaries. + +All four builds pass 2,522 tests, with 212 optional skips and no failures or errors. +The native binding fixture, native slot fixture, and Iris GPU regression run without skips. +All 1,410 core classes match both packaged copies. All 1,134 production client classes match the add-on. + +## Particle creation query context + +A new emitter now starts with an unbound query context before lifetime configuration and its first creation expression. +Animation pre-effect variables survive. Captured actor queries and animation clocks do not. +Previously, animation roots retained actor queries until their first context refresh, while event children started with fresh queries. +Missing-query tracking now belongs to the new emitter, so unbound creation cannot invalidate its animation owner's query coverage. +Later context refreshes, child initialization, loop restarts, and reused emitters keep their existing query and variable behavior. + +The pinned Bedrock 1.26.51.1 executable initializes the actor and count-owner pointers to zero before first creation. +The probe runs original factory `0x14216ba90` through first creation dispatch in `0x142157210`, starting with an allocation filled with `0xa5`. +The supplied transform has translation `(3, 4, 12)`, and the manager camera is `(-1, 8, 10)`. +Eleven original scalar and count callbacks return zero against that captured context. +They cover actor speed and water state, generic animation clocks, camera distance, and all four particle counts. +The binary SHA-256 is `537c0aee2e79afbdc94b44b28e00f466ae62bc50e2733d953b430db9dbaa9ee7`. +Allocation, bounded CRT copies, mutex operations, resource lookup, and the component callback are supplied boundaries. +Random-variable registration is explicitly skipped. +This verifies the constructor query boundary. It does not execute native JSON/Molang evaluation, actor admission, or visible rendering. + +Two regressions cover unbound construction, lifetime expressions, pre-effect variables, owner query coverage, refreshed initialization, and loop restarts. +Existing schedule controls install their synthetic tracing queries after construction. +Their callbacks observe scheduling and dispatch rather than testing actor-query inheritance. +Private licensed asset comparisons use files from the checksum-verified versioned archive, with unchanged decoded particle expectations and native receipts. + +Both new regressions fail against the previous implementation and pass after the correction. +Two deliberate variants retain shared missing-query tracking or reset refreshed queries on every loop restart. +Each fails its corresponding regression. The restored implementation passes. + +The combined four-project build passes 2,574 tests, with 167 optional skips and no failures or errors. +The retained native particle fixtures, native attachable comparisons, and Iris GPU regression run without skips. +Licensed asset comparisons cover 115 base particle definitions, 108 versioned overlays, and real drip sound resolution and decoding. +All 1,411 core classes match both packaged copies, and all 1,134 production client classes match the add-on. +This checkpoint verifies CPU context boundaries and existing Java integration tests. +Fresh live particle rendering, actor admission and query producers, complete child lifetimes, and native visible parity remain open. + +## Fresh common particle actor inputs + +Particle attachment sampling now refreshes common actor inputs from the current Java actor and transported native snapshot. +The refreshed queries are `is_alive`, `is_on_ground`, `is_in_water`, `ground_speed`, `variant`, `mark_variant`, `property`, `has_property`, and `spellcolor`. +Packet-driven particles receive native metadata that their previous eight-query map omitted. +Player, custom-actor, and world-actor animation attachments use the same sampler. +They preserve unrelated graph inputs and the existing locator pose without advancing the actor animation again. +Each returned map retains its own immutable values, including typed native properties. +An absent native snapshot clears retained properties, variants, and spell color. +Server bindings reject changes to a captured native lifetime. +Player bindings also reject a changed lifetime before actor removal, while preserving their existing retained-registry path. + +Eight controls execute the pinned native initialization gate `0x1421603e0` and context refresh `0x142159bd0` once. +They then change motion and water components in place on the same bound actor. +Original callbacks `0x14220ecd0` and `0x14220f040` read every changed value without another context refresh, actor animation, or render pass. +The target and binary checksum are the same as the constructor controls above. +Actor resolution, level and physical-component getters, variable access, and expression dispatch are supplied boundaries. +Native property evaluation, live server behavior, and GPU drawing do not run in these controls. + +Eight query tests pass, including the new native comparison and two producer regressions. +Existing particles change size and tint after new actor samples without another actor render in the Java fixture. +The tests retain particle variables, graph inputs, earlier snapshots, immutable maps, and typed properties. +They also remove stale metadata when no current native snapshot exists. + +Remaining render-derived queries still use their existing inputs. +This change does not establish fresh locator animation, every native query producer, complete actor or child lifetimes, or native visible parity. + +Three private variants keep stale actor inputs, retain missing metadata, or discard unrelated graph inputs. +Each fails its corresponding particle regression. The corrected sampler passes. +All four builds pass 2,577 tests, with 167 optional skips and no failures or errors. +The new native live-query comparison, retained particle fixtures, attachable comparisons, and Iris GPU regression run without skips. +All 1,411 core classes match both packaged copies, and all 1,134 production client classes match the add-on. + +## Manual child ownership and retirement + +Manual child handles now belong to their parent emitter until that parent retires. +A registered child still receives initialization after its emission lifetime ends. +A retired child leaves an initialized handle, so later requests cannot create a replacement or adopt another emitter in its registry slot. +Failed initial creation remains retryable. +Weak references keep those cached handles from retaining retired child runtimes. + +Parent retirement expires its valid manual children and preserves their existing particles until natural death. +Grandchildren expire when their own parent retires, rather than when an ancestor first expires. +Registry removal and count updates precede retirement callbacks. +The manager retains its single traversal, so an earlier child that expires during later retirement can remain until the next batch. +Expiry callbacks retain the native ordering before emission closes. +Full-manager shutdown keeps its separate clear path and does not dispatch these retirement events. + +Child construction also accepts automatic rate targets. +Their native manual-capacity slot returns zero, while the emitter remains available for ordinary updates. +The previous admission check rejected those targets before construction. + +Forty-six controls use the pinned Bedrock 1.26.51.1 executable and checksum recorded above. +They cover two manual request kinds, repeated requests, initial factory failure, registered expiry, stale generations, invalid slots, absent arrays, null children, and shutdown. +Original leaf dispatcher `0x142164cb0`, weak resource lookup `0x142158740`, force expiry `0x14215cc60`/`0x142158940`, and manual emission `0x142160c20` execute. +The controls also execute original capacity getters `0x1462a4d10` with literal four and `0x140076c20` with zero. +Native instant and steady rate tables use the zero manual-capacity getter. + +Parent destructor `0x142170320` executes through its child retirement loop, stopping before cache/resource cleanup at `0x1421703c2`. +Cache entries, generational handles, components, and emitter memory are supplied inputs. +Factory returns, initialization callbacks, context refresh, birth allocation, and source expressions are supplied boundaries. +The controlled expiry callback observes the native flag and tail-calls original manual emission. +The existing manager instructions decrement the effect count and retire the slot before invoking destruction. +These controls do not execute the native JSON/Molang VM, actual particle allocation, complete destruction, live server behavior, or pixels. + +Nineteen focused tests pass, including the retained manager/count/dispatch schedules and all 46 new native comparisons. +The production ownership and emitter tests cover stale handles, independent owners, failed creation, rollback identity, automatic targets, particle draining, and grandchild retirement. +Four private variants recreate stale handles, omit child expiry, close emission before callbacks, or update counts after callbacks. +Each fails the relevant regressions. The restored implementation passes. + +Complete native registry producers, actor removal lifetimes, graph/resource limits, and live particle rendering remain open. +The broader five-area production goal remains active. + +All four builds pass 2,583 tests, with 167 optional skips and no failures or errors. +The child lifetime comparisons, retained particle fixtures, attachable comparisons, and Iris GPU regression run without skips. +All 1,411 core classes match both packaged copies, and all 1,136 production client classes match the add-on. +Reentrant emitter allocation during native destruction remains unverified. + +## Native manual-child retirement order + +Manual children now retire in the native cache traversal order. +The cache uses each validated effect name's FNV-1 hash, eight initial buckets, and the target collision and growth rules. +Bucket growth rebuilds traversal before inserting the next key. +Java map iteration previously changed the order of expiry callbacks. + +Failed creation reserves an uninitialized slot before the factory runs. +Retries and rollback retain that slot, including its effect on bucket growth and later traversal. +Unavailable definitions now reach the same cache path. +Successful and retired handles keep their existing weak-reference and ownership behavior. + +Thirty-two controls execute original leaf dispatcher `0x142164cb0` from an empty native cache. +They record 2,870 insertion/retry observations across forward, reverse, and shuffled names, full-hash collisions, masked-bucket collisions, and growth through 513 entries. +The parent destructor then traverses the resulting native cache through `0x1421703c2`. +Production Java retirement matches every native traversal. +The pinned executable version and checksum remain the same as the lifetime controls above. + +Parsed names and hashes, the empty cache layout, manager handles, and child records are supplied inputs. +Allocator calls, exact bounded CRT copies/comparisons, integer `ceilf`, deallocation, failed factory returns, and expiry callbacks are supplied boundaries. +These controls do not execute native name parsing, resource construction, complete destruction, reentrant manager allocation, live server behavior, or GPU drawing. +Artificial hash collisions are explicit test inputs. + +Mandatory tests cover failed-slot growth and actual emitter expiry callbacks in bucket order. +The native fixture also checks successful retries in a different order from their original failed attempts. +Four private variants use Java map order, preserve insertion order, discard failed entries, or skip native rehashing. +Each fails its native order comparison. The restored implementation passes. + +Reentrant manager allocation during expiry remains unverified. +Complete registry producers, actor removal lifetimes, resource admission, and native visible parity also remain open. + +The combined four-project build passes 2,588 tests, with 168 optional skips and no failures or errors. +The new child-order fixture, child-lifetime comparisons, retained particle fixtures, attachable comparisons, and Iris GPU regression run without skips. +All 1,411 core classes match both packaged copies, and all 1,137 production client classes match the add-on. +The newer received actor spatial-state change remains integrated in this build. + +## Native particle rate composition + +The runtime accepts effects with zero, one, or several rate components. +An effect without rates still runs its lifetime and event graph. +Automatic rates add their requests in resource order and share emitter capacity, rate cache, and interval remainder. +Manual capacity initializes before creation expressions and refreshes for each manual request. +Manual births also update the shared birth count, so an instant component observes earlier manual emission. + +For example, steady emission before an instant burst can add both requests in the first update. +Reversing those components can leave the steady rate cache at zero after the instant component sets capacity. +The runtime preserves that order-dependent result. + +The reference uses Bedrock 1.26.51.1/build51061372, protocol 2193. +The binary SHA-256 is `537c0aee2e79afbdc94b44b28e00f466ae62bc50e2733d953b430db9dbaa9ee7`. +All 128 controls execute factory `0x14216ba90`, reset `0x142157210`, automatic update `0x142158a40`, and manual emission `0x142160c20`. +Automatic updates stop after births, before particle aging, and acknowledge the native first-update flag. +The controls compare 1,152 emission steps across every permutation of zero through three rates, capacity limits, and manual/automatic action order. +The target-version particle schema lists these components independently. + +Parsed resource arrays and literal expressions are supplied inputs. +Resource, material, and statistics lookups, capacity reservation, allocation, copies, mutex operations, query refresh, actor absence, and birth allocation are supplied boundaries. +The controls supply `floorf` and `fmodf` and skip random-variable registration during reset. +They do not establish native JSON admission, expression VM behavior, particle aging, particle motion, or visible rendering parity. + +Focused tests cover event-only expiry, ordered request sums, initialization timing, and manual births before instant emission. +All 128 native scenarios pass through the production emitter implementation. +Four deliberate variants fail native population comparisons: maximum instead of sum, sorted component order, omitted initial manual capacity, and omitted manual birth counts. +The existing native lifetime, count, dispatch, and child-ownership comparisons also pass in the focused run. + +All four combined builds pass 2,593 tests, with 169 optional skips and no failures or errors. +The four rate tests, retained particle comparisons, attachable comparisons, and Iris GPU regression run without skips. +All 1,411 core classes match the embedded add-on and proxy copies. +All 1,139 production client classes match the packaged add-on byte for byte. +The full 38-patch add-on stack and 104-patch core stack replay successfully. +The combined build includes the newer native spawn-angle cache change. diff --git a/patches/viafabricplus-bedrock/upstreamable/0009-manage-character-creator-accounts-and-wardrobes.patch b/patches/viafabricplus-bedrock/upstreamable/0009-manage-character-creator-accounts-and-wardrobes.patch index 86f3bb78..5a231132 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0009-manage-character-creator-accounts-and-wardrobes.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0009-manage-character-creator-accounts-and-wardrobes.patch @@ -1,10 +1,16 @@ -From 576195a2eb567c3f771e90c55496cb2f3d6ad3bd Mon Sep 17 00:00:00 2001 +From 717eda3c2a20a1005300a2b626340b4ce85d8a7f 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(appearance): manage Character Creator accounts and wardrobes Keep character slots, persona recipe assembly, owned catalogs, purchases and emote account selection above the shared native rendering runtime. Preserve signed appearance selection, classic skin changes and account-specific asset lifetimes. + +Refresh common actor inputs and native properties for attached player +particles at each attachment sample, even without another body render. +Keep the sampled locator pose and unrelated graph inputs. Reject a +changed native actor lifetime before reading its metadata, while retaining +the existing registry-only path after actor removal with the same owner. --- .../bedrock/ViaFabricPlusBedrock.java | 3 + .../appearance/BedrockAppearanceStore.java | 186 +- @@ -36,7 +42,7 @@ Keep character slots, persona recipe assembly, owned catalogs, purchases and emo .../render/MixinMinecraftSkinLifecycle.java | 12 + .../features/render/MixinOptionsEmote.java | 34 + .../bedrock/render/BedrockPlayerRenderer.java | 7 +- - .../bedrock/render/BedrockPlayerSkins.java | 656 ++++++- + .../bedrock/render/BedrockPlayerSkins.java | 657 ++++++- .../screen/BedrockCharacterSlotsScreen.java | 1401 +++++++++++++++ .../screen/BedrockClassicSkinPackScreen.java | 83 +- .../screen/BedrockDressingRoomScreen.java | 88 +- @@ -71,7 +77,7 @@ Keep character slots, persona recipe assembly, owned catalogs, purchases and emo .../appearance/BedrockPersonaTintTest.java | 280 +++ .../BedrockPlayerAnimationsTest.java | 90 + .../render/BedrockActorQuerySnapshotTest.java | 89 + - 65 files changed, 14275 insertions(+), 107 deletions(-) + 65 files changed, 14276 insertions(+), 107 deletions(-) create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockCharacterProfiles.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockClassicAssets.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockEmotePlayback.java @@ -6361,7 +6367,7 @@ index f00c62a..5ddf747 100644 } 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 7164045..4018b14 100644 +index 7164045..fde1d8c 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerSkins.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerSkins.java @@ -19,37 +19,63 @@ @@ -6530,7 +6536,7 @@ index 7164045..4018b14 100644 return null; } if (appearance.renderer == null || appearance.rendererEpoch != rendererEpoch) { -@@ -212,13 +273,449 @@ public final class BedrockPlayerSkins { +@@ -212,13 +273,450 @@ public final class BedrockPlayerSkins { BedrockGeometry.playerModel(surface.geometry().model(), appearance.slim, surface.geometry().meshes()), surface.texture())).toList(); appearance.renderer = new BedrockPlayerRenderer(rendererContext, @@ -6909,8 +6915,8 @@ index 7164045..4018b14 100644 + for (final var particle : particles) { + if (particle.attached()) { + particle.play(net.minecraft.world.phys.Vec3.ZERO, -+ () -> Minecraft.getInstance().level != world || entity.isRemoved() -+ && (registryOwner == null || !registryOwner.equals(BedrockActorState.lifetime(entity.getUUID()))) ++ () -> Minecraft.getInstance().level != world || entity.isRemoved() && registryOwner == null ++ || registryOwner != null && !registryOwner.equals(BedrockActorState.lifetime(entity.getUUID())) + ? null : particleAttachment(appearance, geometry, entity, particle.locator())); + } else { + final var position = effectPosition(appearance, geometry, entity, state.bodyRot, particle.locator()); @@ -6930,7 +6936,8 @@ index 7164045..4018b14 100644 + if (entity.isRemoved()) + return BedrockParticleAttachment.registry(new net.minecraft.world.phys.Vec3(entity.xo, entity.yo, entity.zo), + entity.position(), entity.yBodyRotO, entity.yBodyRot, partialTick, world); -+ return new BedrockParticleAttachment(position, yaw, world, entity.getBoundingBox(), appearance.particleQueries); ++ return new BedrockParticleAttachment(position, yaw, world, entity.getBoundingBox(), ++ BedrockParticleQueries.actor(entity, BedrockActorState.state(entity.getUUID()), appearance.particleQueries)); + } + + private static net.minecraft.world.phys.Vec3 effectPosition( @@ -6981,7 +6988,7 @@ index 7164045..4018b14 100644 public static AvatarRenderer renderer(final PlayerSkin skin, final float ageInTicks) { if (skin == null || skin.body() == null) { return null; -@@ -238,10 +735,55 @@ public final class BedrockPlayerSkins { +@@ -238,10 +736,55 @@ public final class BedrockPlayerSkins { public static Model.Simple previewModel(final Identifier texture) { final UUID id = PLAYER_BY_TEXTURE.get(texture); final Appearance appearance = id == null ? null : APPEARANCES.get(id); @@ -7040,7 +7047,7 @@ index 7164045..4018b14 100644 } /** Supplies the additional animated surfaces in the same pose as a menu's primary skin. */ -@@ -251,14 +793,19 @@ public final class BedrockPlayerSkins { +@@ -251,14 +794,19 @@ public final class BedrockPlayerSkins { if (appearance == null || appearance.surfaces.isEmpty()) return List.of(); if (appearance.previewSurfaces == null) appearance.previewSurfaces = appearance.surfaces.stream().map(surface -> new PreviewSurface( @@ -7062,7 +7069,7 @@ index 7164045..4018b14 100644 public static void rendererContext(final EntityRendererProvider.Context context) { BedrockActorResources.clear(); -@@ -271,6 +818,7 @@ public final class BedrockPlayerSkins { +@@ -271,6 +819,7 @@ public final class BedrockPlayerSkins { APPEARANCES.values().forEach(BedrockPlayerSkins::release); APPEARANCES.clear(); PLAYER_BY_TEXTURE.clear(); @@ -7070,7 +7077,7 @@ index 7164045..4018b14 100644 SKIN_ASSEMBLER.clear(); skinConnection = null; } -@@ -288,7 +836,8 @@ public final class BedrockPlayerSkins { +@@ -288,7 +837,8 @@ public final class BedrockPlayerSkins { } private static Identifier textureId(final UUID playerUuid, final String kind) { @@ -7080,7 +7087,7 @@ index 7164045..4018b14 100644 } private static boolean supportedImage(final BufferedImage image) { -@@ -314,7 +863,10 @@ public final class BedrockPlayerSkins { +@@ -314,7 +864,10 @@ public final class BedrockPlayerSkins { static String geometryName(final String resourcePatch, final String binding) { try { @@ -7092,7 +7099,7 @@ index 7164045..4018b14 100644 final var name = geometry.get(binding); return name != null && name.isJsonPrimitive() && name.getAsJsonPrimitive().isString() ? name.getAsString() : ""; -@@ -344,7 +896,8 @@ public final class BedrockPlayerSkins { +@@ -344,7 +897,8 @@ public final class BedrockPlayerSkins { final List models = BedrockGeometryModel.fromJson(data); final BedrockGeometryModel model = models.stream().filter(value -> value.getIdentifier().equals(name)).findFirst() .orElseGet(() -> models.isEmpty() ? null : models.getFirst()); @@ -7102,7 +7109,7 @@ index 7164045..4018b14 100644 } catch (RuntimeException e) { LOGGER.warn("Failed to parse Bedrock player geometry", e); return null; -@@ -371,7 +924,8 @@ public final class BedrockPlayerSkins { +@@ -371,7 +925,8 @@ public final class BedrockPlayerSkins { final Map animatedModels = new HashMap<>(); for (final var entry : models) { final JsonObject model = entry.getAsJsonObject(); @@ -7112,7 +7119,7 @@ index 7164045..4018b14 100644 if (name.equals(identifier)) { if (body != null) { return null; -@@ -410,7 +964,8 @@ public final class BedrockPlayerSkins { +@@ -410,7 +965,8 @@ public final class BedrockPlayerSkins { final JsonObject model = animatedModels.get(type); final var animation = animationByType.get(type); if (model == null) { @@ -7122,7 +7129,7 @@ index 7164045..4018b14 100644 return null; } continue; -@@ -425,7 +980,8 @@ public final class BedrockPlayerSkins { +@@ -425,7 +981,8 @@ public final class BedrockPlayerSkins { BedrockGeometry.playerModel(parsed, false, meshes); surfaces.add(new Surface(parsed, Map.copyOf(meshes), animation)); } @@ -7132,7 +7139,7 @@ index 7164045..4018b14 100644 if (!coverage.getOrDefault(part, false)) { return null; } -@@ -436,7 +992,8 @@ public final class BedrockPlayerSkins { +@@ -436,7 +993,8 @@ public final class BedrockPlayerSkins { return null; } BedrockGeometry.playerModel(parsedBody, false, bodyMeshes); @@ -7142,7 +7149,7 @@ index 7164045..4018b14 100644 } catch (RuntimeException e) { LOGGER.warn("Failed to parse Bedrock persona geometry", e); return null; -@@ -473,12 +1030,21 @@ public final class BedrockPlayerSkins { +@@ -473,12 +1031,21 @@ public final class BedrockPlayerSkins { } } } @@ -7167,7 +7174,7 @@ index 7164045..4018b14 100644 } } return meshes; -@@ -500,7 +1066,13 @@ public final class BedrockPlayerSkins { +@@ -500,7 +1067,13 @@ public final class BedrockPlayerSkins { return vector; } @@ -7182,7 +7189,7 @@ index 7164045..4018b14 100644 Surface face() { return this.surfaces.stream().filter(surface -> surface.animation().type() == 1).findFirst().orElse(null); } -@@ -518,13 +1090,23 @@ public final class BedrockPlayerSkins { +@@ -518,13 +1091,23 @@ public final class BedrockPlayerSkins { private final Identifier headId; private final long started = System.nanoTime(); private final Geometry geometry; @@ -10552,10 +10559,10 @@ index 0000000..a91a83e + "f76b43c4-052c-ec24-bbc9-389f8b612668": "persona_top" +} diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json -index 346c44f..43f5b43 100644 +index 4785e69..66baeef 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -67,8 +67,14 @@ +@@ -83,8 +83,14 @@ "features.render.MixinLivingEntitySkinLayers", "features.render.MixinLivingEntityRenderer", "features.render.MixinLivingEntityBodyRotation", diff --git a/patches/viafabricplus-bedrock/upstreamable/0009-manage-character-creator-accounts-and-wardrobes.pr.md b/patches/viafabricplus-bedrock/upstreamable/0009-manage-character-creator-accounts-and-wardrobes.pr.md index 6b7ddd7c..edcf92af 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0009-manage-character-creator-accounts-and-wardrobes.pr.md +++ b/patches/viafabricplus-bedrock/upstreamable/0009-manage-character-creator-accounts-and-wardrobes.pr.md @@ -1093,3 +1093,11 @@ Equipment setup runs after the owner's per-frame resets and before its animation ## Upstream prerequisites - [viafabricplus-bedrock patch](../../viafabricplus-bedrock/upstreamable/0008-share-native-actor-and-resource-runtime.pr.md): Land shared actor and resource playback before account appearance selection. + +## Refresh attached player particle inputs + +Attached player particles sample current common actor inputs and native properties independently of the next body render. +The existing locator pose and unrelated graph inputs remain available. +A changed native lifetime retires the binding before metadata can come from another owner. +Actor removal with the same retained registry owner keeps its existing path. +See the [shared runtime evidence](0008-share-native-actor-and-resource-runtime.pr.md#fresh-common-particle-actor-inputs) for tested queries, native boundaries, and remaining limits. diff --git a/patches/viafabricplus-bedrock/upstreamable/0010-show-server-creative-items-in-a-bedrock-tab.patch b/patches/viafabricplus-bedrock/upstreamable/0010-show-server-creative-items-in-a-bedrock-tab.patch index fefb06a7..93dd8113 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0010-show-server-creative-items-in-a-bedrock-tab.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0010-show-server-creative-items-in-a-bedrock-tab.patch @@ -1,4 +1,4 @@ -From c080e2ac2449b3a6b3745532a796742610b096b1 Mon Sep 17 00:00:00 2001 +From a33ac728417bcec61ec20b58ac9a59f884d2b58b Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:55:35 +0200 Subject: [PATCH] feat(bedrock): show server creative items in a Bedrock tab @@ -364,10 +364,10 @@ index ab90032..db52332 100644 "bedrock_settings.viafabricplus.account": "Account for Bedrock Edition", "bedrock_settings.viafabricplus.account.login": "Click to log in", diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json -index 43f5b43..4c04e2b 100644 +index 66baeef..788e1b7 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -43,6 +43,9 @@ +@@ -59,6 +59,9 @@ "features.block.MixinLecternBlock", "features.block.MixinSeaPickleBlock", "features.block.MixinTrapDoorBlock", diff --git a/patches/viafabricplus-bedrock/upstreamable/0013-resolve-licensed-native-vanilla-images.patch b/patches/viafabricplus-bedrock/upstreamable/0013-resolve-licensed-native-vanilla-images.patch index aa43e0d4..d7c1221a 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0013-resolve-licensed-native-vanilla-images.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0013-resolve-licensed-native-vanilla-images.patch @@ -1,4 +1,4 @@ -From 7ceb9083bfa43aecdb2bad3e8780bd7f7c0b35b2 Mon Sep 17 00:00:00 2001 +From 4832a53dc21244d5df1b465518d92b92756ad4db 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(resources): resolve bundled native vanilla images @@ -522,10 +522,10 @@ index 0000000..6ebabf6 + private static byte[] bytes(final String json) { return json.getBytes(StandardCharsets.UTF_8); } +} 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 1c55be6..9f76d8a 100644 +index f12b71a..685e7f4 100644 --- a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockResourceLibraryTest.java +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockResourceLibraryTest.java -@@ -119,6 +119,25 @@ final class BedrockResourceLibraryTest { +@@ -156,6 +156,25 @@ final class BedrockResourceLibraryTest { files.put("library/" + pack + "/" + path, json.getBytes(StandardCharsets.UTF_8)); } diff --git a/patches/viafabricplus-bedrock/upstreamable/0014-evaluate-custom-actors-with-shared-animation-graphs.patch b/patches/viafabricplus-bedrock/upstreamable/0014-evaluate-custom-actors-with-shared-animation-graphs.patch index 1b1f018d..42503faa 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0014-evaluate-custom-actors-with-shared-animation-graphs.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0014-evaluate-custom-actors-with-shared-animation-graphs.patch @@ -1,4 +1,4 @@ -From fa62121ee8eee7e702082d78cb31961f73753622 Mon Sep 17 00:00:00 2001 +From cc0337769aff0e53e42f3c8dbf85d0ab28b844ea 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 custom actors with shared animation @@ -107,12 +107,18 @@ execute registry/sparse flag reader 141e14790 for flags 24/8/108. A private missing actor bindings, live/recycled generations and stale flags. The camel graph requires these values before its authored seat-locator pose can be sampled; this does not establish mount camera scheduling. + +Sample current common actor inputs and native metadata for bound custom +entity particles instead of retaining the values from the last submitted +actor frame. Keep existing resource, lifetime, and actor-instance guards, +locator sampling, and unrelated graph inputs. The shared runtime patch +records the native callback evidence and its supplied boundaries. --- .../bedrock/ViaFabricPlusBedrock.java | 5 + .../bedrock/render/BedrockActorFrame.java | 56 +++++ .../bedrock/render/BedrockActorState.java | 10 + .../render/BedrockActorUvAnimation.java | 21 ++ - .../bedrock/render/BedrockEntityRenderer.java | 181 ++++++++++++++- + .../bedrock/render/BedrockEntityRenderer.java | 183 +++++++++++++++- .../bedrock/render/BedrockGeometry.java | 17 +- .../render/BedrockModelLocatorIndex.java | 77 +++++++ .../bedrock/render/BedrockModelLocators.java | 2 +- @@ -122,7 +128,7 @@ pose can be sampled; this does not establish mount camera scheduling. .../render/BedrockActorUvAnimationTest.java | 63 ++++++ .../render/BedrockCustomActorModelTest.java | 57 +++++ .../render/BedrockModelLocatorIndexTest.java | 207 ++++++++++++++++++ - 14 files changed, 910 insertions(+), 12 deletions(-) + 14 files changed, 912 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorFrame.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorUvAnimation.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockModelLocatorIndex.java @@ -280,16 +286,17 @@ index 0000000..c7d9e7b + } +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockEntityRenderer.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockEntityRenderer.java -index 8c62842..e81030d 100644 +index 8c62842..342a99b 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockEntityRenderer.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockEntityRenderer.java -@@ -19,6 +19,15 @@ +@@ -19,6 +19,16 @@ package com.viaversion.viafabricplus.bedrock.render; import com.mojang.blaze3d.vertex.PoseStack; +import com.viaversion.viafabricplus.bedrock.appearance.BedrockActorAnimation; +import com.viaversion.viafabricplus.bedrock.appearance.BedrockBoneAnimation; +import com.viaversion.viafabricplus.bedrock.appearance.BedrockParticleAttachment; ++import com.viaversion.viafabricplus.bedrock.appearance.BedrockParticleQueries; +import java.io.IOException; +import java.lang.ref.WeakReference; +import java.util.Optional; @@ -299,7 +306,7 @@ index 8c62842..e81030d 100644 import com.mojang.math.Axis; import com.viaversion.viafabricplus.bedrock.ViaFabricPlusBedrock; import com.viaversion.viafabricplus.bedrock.render.CustomEntityRenderStore.RenderData; -@@ -46,19 +55,27 @@ import net.raphimc.viabedrock.api.model.entity.CustomEntity; +@@ -46,19 +56,27 @@ import net.raphimc.viabedrock.api.model.entity.CustomEntity; import net.raphimc.viabedrock.api.util.StringUtil; import net.raphimc.viabedrock.protocol.storage.ResourcePackStorage; import org.cube.converter.model.impl.bedrock.BedrockGeometryModel; @@ -328,7 +335,7 @@ index 8c62842..e81030d 100644 private ResourcePackStorage currentPacks; private BedrockActorMaterialDefinitions materialDefinitions; -@@ -88,6 +105,10 @@ public final class BedrockEntityRenderer extends EntityRenderer model = this.models.computeIfAbsent(modelKey, _ -> BedrockGeometry.entityModel(geometry, material.getValue())); final var alphaTest = this.materialDefinitions.alphaTest(material.getKey()); final RenderType type; @@ -467,7 +474,8 @@ index 8c62842..e81030d 100644 + || this.actors.get(entity) != actor + || !java.util.Objects.equals(actor.lifetime, BedrockActorState.lifetime(entity.getUUID()))) return null; + final var matrix = this.effectLocator(actor, locator, entity.position(), entity.getYRot()); -+ return new BedrockParticleAttachment(entity.position(), entity.getYRot(), matrix, entity.getBoundingBox(), actor.queries); ++ return new BedrockParticleAttachment(entity.position(), entity.getYRot(), matrix, entity.getBoundingBox(), ++ BedrockParticleQueries.actor(entity, BedrockActorState.state(entity.getUUID()), actor.queries)); + }); + } + } @@ -524,7 +532,7 @@ index 8c62842..e81030d 100644 static void applyActorTransform(final PoseStack poseStack, final float yaw, final float scale) { // Scale around the native actor origin before compensating for the Java model origin. poseStack.scale(scale, scale, scale); -@@ -176,17 +319,39 @@ public final class BedrockEntityRenderer extends EntityRenderer models = List.of(); @@ -694,7 +702,7 @@ index 73a8827..0e8d0d3 100644 if (!model.has("bones")) return Map.of(); for (final var entry : model.getAsJsonArray("bones")) { 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 4018b14..19568ae 100644 +index fde1d8c..ff97ae3 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerSkins.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerSkins.java @@ -346,6 +346,7 @@ public final class BedrockPlayerSkins { diff --git a/patches/viafabricplus-bedrock/upstreamable/0014-evaluate-custom-actors-with-shared-animation-graphs.pr.md b/patches/viafabricplus-bedrock/upstreamable/0014-evaluate-custom-actors-with-shared-animation-graphs.pr.md index f5e7a7ed..9bc00f04 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0014-evaluate-custom-actors-with-shared-animation-graphs.pr.md +++ b/patches/viafabricplus-bedrock/upstreamable/0014-evaluate-custom-actors-with-shared-animation-graphs.pr.md @@ -190,3 +190,10 @@ Native actor pose timing and the camera's mount-offset scheduling remain separat The production world-query adapter matches 1,120 native scalar results across 224 live contexts. A private camel scene consumes both sitting and standing flags during native model submissions. The world actor patch records the full build and runtime results. + +## Refresh attached custom-actor particle inputs + +Bound custom-actor particles sample current common actor inputs and native metadata independently of another actor submission. +Resource, actor-instance, and native lifetime guards retain their existing ownership checks. +Locator poses and unrelated graph inputs keep their existing sampling. +See the [shared runtime evidence](0008-share-native-actor-and-resource-runtime.pr.md#fresh-common-particle-actor-inputs) for tested queries, native boundaries, and remaining limits. diff --git a/patches/viafabricplus-bedrock/upstreamable/0014-reproduce-the-native-bedrock-classic-lightmap.patch b/patches/viafabricplus-bedrock/upstreamable/0014-reproduce-the-native-bedrock-classic-lightmap.patch index 79074e15..29d5afbe 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0014-reproduce-the-native-bedrock-classic-lightmap.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0014-reproduce-the-native-bedrock-classic-lightmap.patch @@ -1,4 +1,4 @@ -From 616713a851ce5517ed8a9f47f82cdf40b40a46b5 Mon Sep 17 00:00:00 2001 +From c9487f3ef950b4c71f11a2a11a81a7d104519658 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(render): reproduce native Bedrock classic lighting @@ -748,10 +748,10 @@ index 0000000..2a2ae69 + fragColor = vec4(color, 1.0); +} diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json -index c4bc0d1..fe1cbd8 100644 +index eb6bf61..fc0709b 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -79,6 +79,15 @@ +@@ -96,6 +96,15 @@ "features.render.MixinBedrockNameTags", "features.render.MixinBedrockNameTagMetadata", "features.render.MixinBedrockNameTagRenderer", diff --git a/patches/viafabricplus-bedrock/upstreamable/0016-render-server-player-costumes.patch b/patches/viafabricplus-bedrock/upstreamable/0016-render-server-player-costumes.patch index 597901bd..f4bc53e0 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0016-render-server-player-costumes.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0016-render-server-player-costumes.patch @@ -1,4 +1,4 @@ -From 8dae1fa63428d91f0d367e77cdc9aa3fe1ce20be Mon Sep 17 00:00:00 2001 +From ce9913a3cd3a3fce1d61cf81fdcc1fa87b89abbb 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(players): render server player controllers and costumes @@ -150,7 +150,7 @@ shared library without duplicating every effect asset for every actor. .../appearance/BedrockActorAnimation.java | 12 + .../appearance/BedrockEntityResources.java | 129 ++++++++ .../appearance/BedrockPlayerControllers.java | 22 +- - .../appearance/BedrockResourceLibrary.java | 11 +- + .../appearance/BedrockResourceLibrary.java | 10 +- .../bedrock/render/BedrockPersonaLayer.java | 1 + .../render/BedrockPlayerCostumeLayer.java | 32 ++ .../bedrock/render/BedrockPlayerQueries.java | 22 ++ @@ -163,7 +163,7 @@ shared library without duplicating every effect asset for every actor. .../BedrockServerActorVisibilityTest.java | 45 +++ .../render/BedrockPlayerGeometryTest.java | 32 ++ .../render/BedrockPlayerQueriesTest.java | 37 +++ - 16 files changed, 1017 insertions(+), 18 deletions(-) + 16 files changed, 1017 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockEntityResources.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerCostumeLayer.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerQueries.java @@ -378,7 +378,7 @@ index 47da5ae..dd43bf8 100644 (float) this.value(molang, animation.offsetX()).getAsNumber(), (float) this.value(molang, animation.offsetY()).getAsNumber(), (float) this.value(molang, animation.scaleX()).getAsNumber(), (float) this.value(molang, animation.scaleY()).getAsNumber()); diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockResourceLibrary.java b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockResourceLibrary.java -index 9dd9348..9a394b9 100644 +index d302d89..9a394b9 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockResourceLibrary.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockResourceLibrary.java @@ -45,9 +45,15 @@ final class BedrockResourceLibrary { @@ -407,14 +407,6 @@ index 9dd9348..9a394b9 100644 } static boolean packName(final String name) { -@@ -192,7 +198,6 @@ final class BedrockResourceLibrary { - - byte[] particleTexture(final String path) throws IOException { - BedrockBuiltinAssets.validPath(path); -- if (!path.startsWith("textures/")) throw new IOException("Invalid particle texture path"); - for (int index = this.packs.size() - 1; index >= 0; index--) { - final Map files = this.packs.get(index).files; - if (path.endsWith(".png") || path.endsWith(".tga")) { diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPersonaLayer.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPersonaLayer.java index fe79e8a..de629ad 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPersonaLayer.java @@ -511,7 +503,7 @@ index 5ddf747..2b4a386 100644 return this.nativeMaterial(this.getTextureLocation(state)); } 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 19568ae..b686afe 100644 +index ff97ae3..274bca7 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerSkins.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerSkins.java @@ -33,9 +33,10 @@ import com.viaversion.viafabricplus.bedrock.appearance.BedrockClassicAssets; @@ -870,7 +862,7 @@ index 19568ae..b686afe 100644 queries.put("target_y_rotation", state.yRot); queries.put("body_y_rotation", state.bodyRot); queries.put("walk_distance", entity instanceof BedrockWalkingAnimation.Provider walking -@@ -823,6 +1076,8 @@ public final class BedrockPlayerSkins { +@@ -824,6 +1077,8 @@ public final class BedrockPlayerSkins { BedrockItemMetadata.clear(); SKIN_ASSEMBLER.clear(); skinConnection = null; @@ -879,7 +871,7 @@ index 19568ae..b686afe 100644 } private static void release(final Appearance appearance) { -@@ -835,6 +1090,7 @@ public final class BedrockPlayerSkins { +@@ -836,6 +1091,7 @@ public final class BedrockPlayerSkins { for (final AnimatedSurface surface : appearance.surfaces) { textureManager.release(surface.texture()); } @@ -887,7 +879,7 @@ index 19568ae..b686afe 100644 } private static Identifier textureId(final UUID playerUuid, final String kind) { -@@ -886,11 +1142,15 @@ public final class BedrockPlayerSkins { +@@ -887,11 +1143,15 @@ public final class BedrockPlayerSkins { 3, geometryName(skin.skinResourcePatch(), "animated_128x128")), skin.geometryData(), skin.skinData(), skin.animations()); } @@ -905,7 +897,7 @@ index 19568ae..b686afe 100644 if (parsed.isJsonNull()) { return null; } -@@ -1111,6 +1371,28 @@ public final class BedrockPlayerSkins { +@@ -1112,6 +1372,28 @@ public final class BedrockPlayerSkins { private Map previewPose = Map.of(); private List previewSurfaces; private long rendererEpoch; @@ -1259,10 +1251,10 @@ index 0000000..1260a74 + } +} 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 3f29d2a..1c55be6 100644 +index 1e033e8..f12b71a 100644 --- a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockResourceLibraryTest.java +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockResourceLibraryTest.java -@@ -119,6 +119,90 @@ final class BedrockResourceLibraryTest { +@@ -156,6 +156,90 @@ final class BedrockResourceLibraryTest { files.put("library/" + pack + "/" + path, json.getBytes(StandardCharsets.UTF_8)); } diff --git a/patches/viafabricplus-bedrock/upstreamable/0016-reproduce-native-classic-cloud-distance-fade.patch b/patches/viafabricplus-bedrock/upstreamable/0016-reproduce-native-classic-cloud-distance-fade.patch index a268358f..6b93bb77 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0016-reproduce-native-classic-cloud-distance-fade.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0016-reproduce-native-classic-cloud-distance-fade.patch @@ -1,4 +1,4 @@ -From ee126fde9ca047cd410c143c63b24bbadb8ad532 Mon Sep 17 00:00:00 2001 +From 8f2a1331638bc0cfadcbb5db446d97e2c3cee018 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(render): reproduce native classic cloud distance fade @@ -328,10 +328,10 @@ index 0000000..d41b705 + #endif +} diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json -index ac2b27f..64e5f9e 100644 +index ae3fc7f..73c5300 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -76,6 +76,9 @@ +@@ -93,6 +93,9 @@ "features.render.MixinBedrockActorRenderType", "features.render.MixinBedrockActorSubmission", "features.render.MixinBedrockDynamicTexture", 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 b3c5957f..27e5acc7 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 f625921820f3cac48453fe6d558e7af0d446a47f Mon Sep 17 00:00:00 2001 +From 95e16a435ddb67280bfc2db7e385dfa5e4de5ed8 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 @@ -165,8 +165,24 @@ selection must not silently replace nonzero native material sampling. Remove the obsolete flat directional-expression hook. Ordinary states and unannotated faces retain Java behavior. Native global graphics-setting dispatch remains a separate verification requirement. + +Read the core compiler's explicit extended-bounds annotation when loading +converted cuboids. Preserve native vertices beyond Java's -16..32 range +without changing ordinary model validation. Reject malformed, non-finite, +or excessively large coordinates before model baking. + +The protocol-2193 Galaxite pack contains eight fancy-table states with +vertices at -17 and 33. Java otherwise rejects those models completely. +The hook uses the same bounded coordinate envelope as core and retains +ordinary faces, rotations, culling, and lighting. Core's expanded carrier +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. --- .../bedrock/block/BedrockBlockFace.java | 22 +++ + .../block/BedrockBlockModelBounds.java | 38 ++++ .../block/BedrockCustomBlockResources.java | 81 +++++++++ .../bedrock/block/BedrockCustomBlocks.java | 137 +++++++++++++++ .../bedrock/block/BedrockCustomCulling.java | 50 ++++++ @@ -181,6 +197,7 @@ dispatch remains a separate verification requirement. .../mixin/features/block/MixinBlockFace.java | 44 +++++ .../features/block/MixinBlockFaceBakery.java | 41 +++++ .../block/MixinBlockFaceDeserializer.java | 35 ++++ + .../MixinBlockModelElementDeserializer.java | 26 +++ .../block/MixinCustomBlockCollisions.java | 39 +++++ .../block/MixinCustomBlockConnection.java | 24 +++ .../block/MixinCustomBlockCulling.java | 34 ++++ @@ -189,14 +206,16 @@ dispatch remains a separate verification requirement. .../block/MixinCustomBlockSession.java | 24 +++ .../features/block/MixinCustomBlockState.java | 162 ++++++++++++++++++ .../block/MixinRedstoneWireBlock.java | 29 ++++ - .../viafabricplus-bedrock.mixins.json | 16 ++ + .../viafabricplus-bedrock.mixins.json | 17 ++ + .../block/BedrockBlockModelBoundsTest.java | 32 ++++ .../block/BedrockCustomBlockLightingTest.java | 54 ++++++ .../BedrockCustomBlockResourcesTest.java | 91 ++++++++++ .../block/BedrockCustomBlocksTest.java | 89 ++++++++++ .../block/BedrockCustomCullingTest.java | 51 ++++++ .../block/BedrockTerrainLightingTest.java | 142 +++++++++++++++ - 29 files changed, 1545 insertions(+) + 32 files changed, 1642 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 create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/block/BedrockCustomBlocks.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/block/BedrockCustomCulling.java @@ -211,6 +230,7 @@ dispatch remains a separate verification requirement. create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/block/MixinBlockFace.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/block/MixinBlockFaceBakery.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/block/MixinBlockFaceDeserializer.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/block/MixinBlockModelElementDeserializer.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/block/MixinCustomBlockCollisions.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/block/MixinCustomBlockConnection.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/block/MixinCustomBlockCulling.java @@ -219,6 +239,7 @@ dispatch remains a separate verification requirement. create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/block/MixinCustomBlockSession.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/block/MixinCustomBlockState.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/block/MixinRedstoneWireBlock.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/block/BedrockBlockModelBoundsTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/block/BedrockCustomBlockLightingTest.java 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 @@ -253,6 +274,50 @@ index 0000000..a21e2ec + void viaFabricPlusBedrock$lighting(CustomBlockLighting lighting); +} + +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/block/BedrockBlockModelBounds.java b/src/main/java/com/viaversion/viafabricplus/bedrock/block/BedrockBlockModelBounds.java +new file mode 100644 +index 0000000..ca9480f +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/block/BedrockBlockModelBounds.java +@@ -0,0 +1,38 @@ ++/* ++ * 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 com.google.gson.JsonObject; ++import com.google.gson.JsonParseException; ++import net.minecraft.util.GsonHelper; ++import net.raphimc.viabedrock.protocol.rewriter.resourcepack.CustomBlockModel; ++import org.jetbrains.annotations.Nullable; ++import org.joml.Vector3f; ++ ++/** Reads explicitly annotated native vertices without changing ordinary Java model validation. */ ++public final class BedrockBlockModelBounds { ++ ++ private BedrockBlockModelBounds() { ++ } ++ ++ public static @Nullable Vector3f position(final JsonObject element, final String key) { ++ if (!GsonHelper.getAsBoolean(element, CustomBlockModel.EXTENDED_BOUNDS, false)) { ++ return null; ++ } ++ final var coordinates = GsonHelper.getAsJsonArray(element, key); ++ if (coordinates.size() != 3) { ++ throw new JsonParseException("Expected three custom block coordinates"); ++ } ++ final float[] values = new float[3]; ++ for (int axis = 0; axis < values.length; axis++) { ++ values[axis] = GsonHelper.convertToFloat(coordinates.get(axis), key); ++ if (!Float.isFinite(values[axis]) || Math.abs(values[axis]) > CustomBlockModel.MAX_COORDINATE) { ++ throw new JsonParseException("Custom block geometry exceeds supported bounds"); ++ } ++ } ++ return new Vector3f(values); ++ } ++} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/block/BedrockCustomBlockResources.java b/src/main/java/com/viaversion/viafabricplus/bedrock/block/BedrockCustomBlockResources.java new file mode 100644 index 0000000..0681cfb @@ -1017,6 +1082,38 @@ index 0000000..882640c + } + } +} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/block/MixinBlockModelElementDeserializer.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/block/MixinBlockModelElementDeserializer.java +new file mode 100644 +index 0000000..76ea1f0 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/block/MixinBlockModelElementDeserializer.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.injection.mixin.features.block; ++ ++import com.google.gson.JsonObject; ++import com.viaversion.viafabricplus.bedrock.block.BedrockBlockModelBounds; ++import org.joml.Vector3f; ++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.CallbackInfoReturnable; ++ ++@Mixin(targets = "net.minecraft.client.resources.model.cuboid.CuboidModelElement$Deserializer") ++public abstract class MixinBlockModelElementDeserializer { ++ ++ @Inject(method = "getPosition", at = @At("HEAD"), cancellable = true) ++ private static void readNativePosition(final JsonObject element, final String key, final CallbackInfoReturnable cir) { ++ final Vector3f position = BedrockBlockModelBounds.position(element, key); ++ if (position != null) { ++ cir.setReturnValue(position); ++ } ++ } ++} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/block/MixinCustomBlockCollisions.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/block/MixinCustomBlockCollisions.java new file mode 100644 index 0000000..11c2108 @@ -1466,13 +1563,13 @@ index 0000000..c05289d + +} diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json -index 4c04e2b..c4bc0d1 100644 +index 788e1b7..eb6bf61 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -6,6 +6,10 @@ - "priority": 1001, - "client": [ - "compat.iris.MixinIrisPipelines", +@@ -22,6 +22,10 @@ + "compat.iris.MixinIrisFallbackTransform", + "compat.iris.MixinIrisAlphaUniforms", + "compat.iris.MixinIrisAlphaSampler", + "compat.sodium.MixinSodiumBlockLighting", + "compat.sodium.MixinSodiumLightData", + "compat.sodium.MixinSodiumMutableQuad", @@ -1480,7 +1577,7 @@ index 4c04e2b..c4bc0d1 100644 "jjwt.MixinClasses", "jjwt.MixinDefaultJwtParserBuilder", "core.access.MixinConfirmScreen", -@@ -30,10 +34,21 @@ +@@ -46,10 +50,22 @@ "features.block.MixinBambooStalkBlock", "features.block.MixinBedBlock", "features.block.MixinBlockBehaviour_Properties", @@ -1494,6 +1591,7 @@ index 4c04e2b..c4bc0d1 100644 + "features.block.MixinCustomBlockResources", + "features.block.MixinBlockFace", + "features.block.MixinBlockFaceDeserializer", ++ "features.block.MixinBlockModelElementDeserializer", + "features.block.MixinBlockFaceBakery", + "features.block.MixinCustomBlockCulling", + "features.block.MixinCustomBlockConnection", @@ -1502,7 +1600,7 @@ index 4c04e2b..c4bc0d1 100644 "features.block.MixinDoorBlock", "features.block.MixinDragonEggBlock", "features.block.MixinEndPortalFrameBlock", -@@ -41,6 +56,7 @@ +@@ -57,6 +73,7 @@ "features.block.MixinLadderBlock", "features.block.MixinLanternBlock", "features.block.MixinLecternBlock", @@ -1510,6 +1608,44 @@ index 4c04e2b..c4bc0d1 100644 "features.block.MixinSeaPickleBlock", "features.block.MixinTrapDoorBlock", "features.creative.MixinCreativeModeInventoryScreen", +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/block/BedrockBlockModelBoundsTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/block/BedrockBlockModelBoundsTest.java +new file mode 100644 +index 0000000..140eeb9 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/block/BedrockBlockModelBoundsTest.java +@@ -0,0 +1,32 @@ ++package com.viaversion.viafabricplus.bedrock.block; ++ ++import com.google.gson.JsonParser; ++import com.google.gson.JsonParseException; ++import net.raphimc.viabedrock.protocol.rewriter.resourcepack.CustomBlockModel; ++import org.joml.Vector3f; ++import org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++class BedrockBlockModelBoundsTest { ++ ++ @Test ++ void retainsExplicitExtendedCoordinatesAndLeavesOrdinaryValidationToJava() { ++ final var element = JsonParser.parseString("{\"from\":[4,2,-17],\"to\":[12,5,33]}").getAsJsonObject(); ++ assertNull(BedrockBlockModelBounds.position(element, "from")); ++ element.addProperty(CustomBlockModel.EXTENDED_BOUNDS, true); ++ assertEquals(new Vector3f(4, 2, -17), BedrockBlockModelBounds.position(element, "from")); ++ assertEquals(new Vector3f(12, 5, 33), BedrockBlockModelBounds.position(element, "to")); ++ element.addProperty(CustomBlockModel.EXTENDED_BOUNDS, false); ++ assertNull(BedrockBlockModelBounds.position(element, "to")); ++ } ++ ++ @Test ++ void rejectsMalformedOrUnboundedExtendedGeometry() { ++ for (String coordinates : new String[]{"[]", "[1,2]", "[1,2,3,4]", "[null,0,0]", "[4096,0,0]", "[0,-4096,0]", "[0,0,1e400]"}) { ++ final var element = JsonParser.parseString("{\"from\":" + coordinates + "}").getAsJsonObject(); ++ element.addProperty(CustomBlockModel.EXTENDED_BOUNDS, true); ++ assertThrows(JsonParseException.class, () -> BedrockBlockModelBounds.position(element, "from")); ++ } ++ } ++} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/block/BedrockCustomBlockLightingTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/block/BedrockCustomBlockLightingTest.java new file mode 100644 index 0000000..49dcd6e 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 9259a40d..9aba4f7f 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 @@ -272,4 +272,20 @@ A preserved old-artifact runtime control fails on the nonzero-strength flat path Final route verification is recorded in the coverage ledger. Native global graphics-setting dispatch, actual world classification, propagation, enhanced lighting, and native visible comparisons remain required. -Final direct and ViaProxy plain runs each pass 1,200 exact corner checks and 120 actual flat dispatches. Both Iris routes pass actual enable, disable, re-enable, and resource reload controls, preserving ordinary rendering and later emissive processing. Software Mesa compatibility does not establish native visible lighting parity.\nThe [combined runtime record](../../../docs/bedrock-coverage.md#final-combined-runtime-verification) retains the complete scene gates and remaining requirements.\n \ No newline at end of file +Final direct and ViaProxy plain runs each pass 1,200 exact corner checks and 120 actual flat dispatches. Both Iris routes pass actual enable, disable, re-enable, and resource reload controls, preserving ordinary rendering and later emissive processing. Software Mesa compatibility does not establish native visible lighting parity. +The [combined runtime record](../../../docs/bedrock-coverage.md#final-combined-runtime-verification) retains the complete scene gates and remaining requirements. + + +## Extended converted geometry + +Eight recorded Galaxite fancy-table states contain vertices at -17 or 33. +Java rejects their models because its cuboid range is -16 through 32. +Read core's explicit extended-bounds annotation and preserve those vertices. +Ordinary Java models retain their existing validation. +Malformed arrays, non-finite values, and oversized coordinates remain invalid. +The parser uses the same bounded coordinate envelope as core. + +The focused tests cover coordinate preservation and invalid input. +The combined build passes 2,354 tests, with 152 optional fixture skips and no failures or errors. +A fresh playable Galaxite recording accepts the converted packs without Java model-loading errors. +This exercises the injected loader, while complete native appearance parity remains unverified. 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 c0ca0a4d..edbc5387 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 b347a1a499e3f180b0ade409a146e5bd304fc760 Mon Sep 17 00:00:00 2001 +From 4573ee1843dd2a0600f357df9dc8af86ae968b37 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 @@ -129,24 +129,95 @@ Prepare an immutable attachable-description index and shared animation resources Require every drawable held bone to reach an authored owner binding or a legacy copied hand bone. Prepare ancestry once per resource/model and resolve dynamic bindings each frame. Unbound mob equipment retains the existing item renderer without applying player parent setup. Bedrock 1.26.51.1 defines the Happy Ghast harness body as a 67-unit cube without a player binding. Happy Ghast enables its wearable attachables. Native bows use rightitem texture meshes; shields and tridents use item-slot bindings. Structural tests retain those supported paths and reject disconnected unbound surfaces. + +Match the target item_slot_to_bone_name callback rather than swapping its +result for Java handedness. Bedrock 1.26.51.1/build 51061372/protocol 2193 +callback 0x1422265b0 maps main_hand to rightitem and off_hand to leftitem. +It returns every other string unchanged, reads only the first argument, +and returns an empty string for absent or non-string arguments. The only +owner access checks its presence; owner animation handedness is separate. +The licensed PE SHA-256 remains +537c0aee2e79afbdc94b44b28e00f466ae62bc50e2733d953b430db9dbaa9ee7. + +Execute 104 unchanged native callback controls for owner presence, zero +and multiple arguments, both hand slots, body/custom names, empty strings, +and numeric values. Expression evaluation supplies typed values; logging +and empty temporary cleanup use bounded boundaries. Compare all 52 +owner-present results with the Java query. Exercise authored dynamic +binding selection against owner offsets and numeric binding rejection. +This verifies slot-query semantics, not native transform or image parity. + +Share the equipped item's sampled Molang scope between animation, +render-controller selection and bone bindings. Keep compiled binding +programs immutable and evaluate them in authored bone order. Persistent +variables follow the existing per-owner, slot, item and perspective +playback lifetime instead of a global geometry cache. Standalone selection +also lends its own scope when the raw animation graph is unavailable. +Inspect declared binding ancestry before parent_setup without evaluating +expressions before initialization. Require a string result for owner names. + +The same pinned native resolver at 0x141c38f40 derives its child context +from the selected component at +0x88. Initialization, pre_animation, +animation update and binding evaluation receive that child context. +Parent_setup receives the separate parent context. The binding gate at +0x141c3a5cc accepts only Value kind 1. Twelve bounded unchanged instruction +controls trace these arguments and the result-kind gate. Context setup, +script dispatch, animation update and expression evaluation are explicit +return boundaries. Component selection and frame locals are supplied. +These controls do not execute the full resolver or Molang VM. + +Regression controls cover shared phase writes, ordered bones, owner and +perspective isolation, replacement and rewind, standalone selection, +declaration inspection, string results and actual drawing matrices. +Native component lifetime, render-controller internals, nested binding +transforms, missing-bone behavior and visible parity remain unverified. + +Use the selected owner's complete bone matrix for explicit bindings. +The previous path multiplied the item's unbound global transform into +that matrix, so authored pivots, rotations and item animation moved the +attachment again. Native binding clears local position and rotation, +sets scale to one and retains entity-relative basis handling. Independent +bound children replace their own matrices without parent inversion. +Retain the authored Java pose for subsequent unbound frames, locators, +and cube-local geometry. Remove redundant reconstruction of global poses. + +Evidence uses the same pinned Bedrock executable. Complete original bone +constructors at 0x141c1b620 establish native pivots and default offsets. +Original rest setup at 0x141c32b7a preserves the coordinate conversion. +Binding selection at 0x141c3a65c searches owner name hashes, copies the +matrix and resets local channels. Complete hierarchy 0x141c22010 and +composition 0x141bc3d20 then resolve all three bones in each fixture. +Ninety-six controls cover nonzero pivots, authored and animated rotations, +nested bindings, reflected and singular owners, and entity-relative bones. +Parsed definition lookup and empty CRT copies are bounded. Animation +samples, binding names and owner matrices are supplied inputs. + +All four regressions fail against the previous implementation and pass +after the correction. Compare complete matrices and locators with the +native controls. Check actual emitted Java cube vertices under the bound +matrices, repeated binding, owner-matrix ownership, and binding removal. +Native JSON/Molang admission, cube assembly, missing-name behavior, +component lifetime and complete image parity remain separate checks. --- - .../appearance/BedrockActorAnimation.java | 61 ++- - .../appearance/BedrockAttachableBindings.java | 51 ++ + .../appearance/BedrockActorAnimation.java | 66 ++- + .../appearance/BedrockAttachableBindings.java | 57 +++ .../BedrockAttachableSelection.java | 51 ++ .../appearance/BedrockEquipmentResources.java | 123 +++++ + .../appearance/BedrockPlayerControllers.java | 5 + .../render/MixinAvatarAnimationRenderer.java | 18 +- .../render/MixinAvatarAnimationState.java | 11 +- .../MixinBedrockFirstPersonVisibility.java | 8 + .../bedrock/render/BedrockActorResources.java | 3 + - .../bedrock/render/BedrockAnimatedModel.java | 91 +++- + .../bedrock/render/BedrockAnimatedModel.java | 89 +++- .../bedrock/render/BedrockEquipmentCache.java | 41 ++ .../BedrockEquippedAttachableLayer.java | 35 ++ - .../render/BedrockHeldAttachableBindings.java | 73 +++ - .../render/BedrockPlayerAttachables.java | 450 ++++++++++++++++++ + .../render/BedrockHeldAttachableBindings.java | 78 +++ + .../render/BedrockPlayerAttachables.java | 451 ++++++++++++++++++ .../bedrock/render/BedrockPlayerModel.java | 8 + .../bedrock/render/BedrockPlayerSkins.java | 38 +- .../bedrock/render/BedrockTextureMeshes.java | 109 +++++ - .../BedrockAttachableBindingsTest.java | 31 ++ + .../BedrockAttachableBindingsTest.java | 84 ++++ + .../BedrockAttachableScopeTest.java | 150 ++++++ .../BedrockAttachableSelectionTest.java | 46 ++ .../BedrockEquipmentResourcesTest.java | 86 ++++ .../BedrockEquippedAnimationTest.java | 46 ++ @@ -156,10 +227,11 @@ Bedrock 1.26.51.1 defines the Happy Ghast harness body as a 67-unit cube without .../render/BedrockAttachableMatricesTest.java | 150 ++++++ .../render/BedrockCostumeVisibilityTest.java | 45 ++ .../render/BedrockEquippedAttachableTest.java | 98 ++++ - .../BedrockHeldAttachableBindingsTest.java | 69 +++ + .../render/BedrockExplicitBindingsTest.java | 191 ++++++++ + .../BedrockHeldAttachableBindingsTest.java | 125 +++++ .../render/BedrockPlayerModelTest.java | 58 +++ .../render/BedrockTextureMeshesTest.java | 87 ++++ - 29 files changed, 2004 insertions(+), 23 deletions(-) + 32 files changed, 2474 insertions(+), 23 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 @@ -169,17 +241,19 @@ Bedrock 1.26.51.1 defines the Happy Ghast harness body as a 67-unit cube without create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerAttachables.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 create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockAttachableSelectionTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockEquipmentResourcesTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockEquippedAnimationTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockAttachableMatricesTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockCostumeVisibilityTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockEquippedAttachableTest.java + 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/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 -index 38720a4..f18d9a8 100644 +index 38720a4..7b57b93 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockActorAnimation.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockActorAnimation.java @@ -28,6 +28,7 @@ import net.raphimc.viabedrock.api.model.NativeAnimationControllers; @@ -204,18 +278,18 @@ index 38720a4..f18d9a8 100644 - return serverActor(packs, "entity/", "minecraft:client_entity", identifier); + final var effective = packs.getEntities().getDescription(identifier); + return effective == null ? null : serverActor(packs, JsonParser.parseString(effective.toString()).getAsJsonObject()); -+ } -+ -+ /** Runs on the equipment preparation worker before the immutable storage is published. */ -+ static void prepareEquipment(final ResourcePackStorage packs) throws IOException { -+ attachableDescriptions(packs); -+ serverResources(packs); } - private static BedrockActorAnimation serverActor(final ResourcePackStorage packs, - final String folder, final String field, final String identifier) throws IOException { - final var effective = field.equals("minecraft:client_entity") ? packs.getEntities().getDescription(identifier) : null; - JsonObject entity = effective == null ? null : JsonParser.parseString(effective.toString()).getAsJsonObject(); ++ /** Runs on the equipment preparation worker before the immutable storage is published. */ ++ static void prepareEquipment(final ResourcePackStorage packs) throws IOException { ++ attachableDescriptions(packs); ++ serverResources(packs); ++ } ++ + private static Map attachableDescriptions(final ResourcePackStorage packs) throws IOException { + synchronized (ATTACHABLE_DESCRIPTIONS) { + final var cached = ATTACHABLE_DESCRIPTIONS.get(packs); @@ -274,12 +348,24 @@ index 38720a4..f18d9a8 100644 private void sound(final BedrockAnimationSound sound) { if (this.soundOutput == null) return; final SoundCue cue = this.soundCue(sound); +@@ -664,6 +695,11 @@ public final class BedrockActorAnimation { + return controllers.select(this.molang, true); + } + ++ /** Resolves bone bindings in the equipped item's sampled variable scope. */ ++ public Map bindings(final BedrockAttachableBindings bindings) throws IOException { ++ return bindings.evaluate(this.molang); ++ } ++ + /** Evaluates draw-time scale after the sampled graph, without rerunning animation scripts. */ + public BedrockBoneAnimation.Vector scale() { + return BedrockActorAnimation.this.scale.sample(this.molang); diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockAttachableBindings.java b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockAttachableBindings.java new file mode 100644 -index 0000000..fd5f23f +index 0000000..3e4fe61 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockAttachableBindings.java -@@ -0,0 +1,51 @@ +@@ -0,0 +1,57 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -290,43 +376,49 @@ index 0000000..fd5f23f +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import java.io.IOException; -+import java.util.HashMap; ++import java.util.Collections; ++import java.util.LinkedHashMap; +import java.util.Map; ++import java.util.Set; + +/** Authored binding expressions use the equipped slot and the owner's native skeleton. */ +public final class BedrockAttachableBindings { -+ private final Map programs = new HashMap<>(); -+ private final BedrockMolang molang = new BedrockMolang(); ++ private final Map programs; + + public BedrockAttachableBindings(final JsonObject geometry) throws IOException { ++ final Map programs = new LinkedHashMap<>(); + if (geometry.has("bones")) for (final var value : geometry.getAsJsonArray("bones")) { + final var bone = value.getAsJsonObject(); -+ if (bone.has("binding")) this.programs.put(bone.get("name").getAsString(), ++ if (bone.has("binding")) programs.put(bone.get("name").getAsString(), + BedrockMolang.Program.parse(new JsonPrimitive(bone.get("binding").getAsString()), 0)); + } ++ this.programs = Collections.unmodifiableMap(programs); + } + -+ public Map evaluate(final BedrockActorAnimation.Frame frame) throws IOException { -+ this.molang.context(frame.context()); -+ this.molang.variables(frame.variables()); -+ this.molang.queries(frame.queries(), 0, 0, 0); -+ final Map result = new HashMap<>(); ++ /** Declared anchors can be inspected before the equipped item's scripts run. */ ++ public Set bones() { ++ return this.programs.keySet(); ++ } ++ ++ Map evaluate(final BedrockMolang molang) throws IOException { ++ final Map result = new LinkedHashMap<>(); + for (final var entry : this.programs.entrySet()) { -+ final String bone = this.molang.value(entry.getValue()).getAsString(); ++ final var value = molang.value(entry.getValue()); ++ if (!value.isString()) throw new IOException("Attachable binding did not return an owner bone name"); ++ final String bone = value.getAsString(); + if (bone.isBlank()) throw new IOException("Attachable binding did not resolve an owner bone"); + result.put(entry.getKey(), bone); + } + return Map.copyOf(result); + } + -+ public static Map queries(final boolean mainHandRight) { ++ public static Map queries() { + return Map.of("item_slot_to_bone_name", (BedrockActorAnimation.Query) arguments -> { -+ if (arguments.size() != 1) return ""; -+ return switch (String.valueOf(arguments.getFirst())) { -+ case "main_hand" -> mainHandRight ? "rightItem" : "leftItem"; -+ case "off_hand" -> mainHandRight ? "leftItem" : "rightItem"; -+ case "head" -> "head"; -+ default -> ""; ++ if (arguments.isEmpty() || !(arguments.getFirst() instanceof String slot)) return ""; ++ return switch (slot) { ++ case "main_hand" -> "rightitem"; ++ case "off_hand" -> "leftitem"; ++ default -> slot; + }; + }); + } @@ -517,6 +609,22 @@ index 0000000..8646b14 + failed = false; + } +} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockPlayerControllers.java b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockPlayerControllers.java +index dd43bf8..7285afb 100644 +--- a/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockPlayerControllers.java ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockPlayerControllers.java +@@ -88,6 +88,11 @@ public final class BedrockPlayerControllers { + return this.select(this.molang, false); + } + ++ /** Resolves bindings after standalone selection when no animation graph is available. */ ++ public Map bindings(final BedrockAttachableBindings bindings) throws IOException { ++ return bindings.evaluate(this.molang); ++ } ++ + /** Selects draws in a sampled actor scope without rerunning its scripts. */ + Selection select(final BedrockMolang molang, final boolean includeBody) throws IOException { + molang.binding("geometry", this.entity.getGeometries()); diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinAvatarAnimationRenderer.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinAvatarAnimationRenderer.java index 5f01d8a..36e46c4 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinAvatarAnimationRenderer.java @@ -639,7 +747,7 @@ index da726c2..fc18adc 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 1c1c8c8..fd0b735 100644 +index 1c1c8c8..fdc7a79 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockAnimatedModel.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockAnimatedModel.java @@ -6,6 +6,7 @@ @@ -692,7 +800,7 @@ index 1c1c8c8..fd0b735 100644 } Map drawingMatrices() { -@@ -168,9 +174,92 @@ public final class BedrockAnimatedModel { +@@ -168,9 +174,90 @@ public final class BedrockAnimatedModel { target.jacket.loadPose(target.body.storePose()); } @@ -707,20 +815,18 @@ index 1c1c8c8..fd0b735 100644 + }); + } + -+ /** Explicit bindings parent the attachable's model origin to the selected owner bone. */ ++ /** Explicit bindings replace local bone animation with the selected owner's complete matrix. */ + void bind(final Map deformations, final Map ownerBones, + final Map bindings) { + this.bind(deformations); -+ final Map local = new HashMap<>(); -+ // Capture local transforms without the copied-bone deformations before adding explicit parents. -+ this.boundMatrices.clear(); -+ this.parts.values().forEach(part -> this.global(part, local)); -+ this.bind(deformations); + bindings.forEach((name, owner) -> { + final var part = this.parts.get(normalize(name)); + final var parent = ownerBones.get(normalize(owner)); -+ if (part != null && parent != null) this.boundMatrices.put(part, -+ new Matrix4f(parent).translate(0, -24, 0).mul(local.get(part))); ++ if (part != null && parent != null) { ++ final var matrix = new Matrix4f(parent); ++ if (this.entityRelative.contains(part)) BedrockBoneTransform.resetBasis(matrix); ++ this.boundMatrices.put(part, matrix); ++ } + }); + } + @@ -875,10 +981,10 @@ index 0000000..2c20075 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockHeldAttachableBindings.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockHeldAttachableBindings.java new file mode 100644 -index 0000000..3ede812 +index 0000000..63a5274 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockHeldAttachableBindings.java -@@ -0,0 +1,73 @@ +@@ -0,0 +1,78 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -932,6 +1038,11 @@ index 0000000..3ede812 + this.ancestry = List.copyOf(ancestry); + } + ++ boolean hasBindingPath(final Set declarations) { ++ return this.ancestry != null && !this.ancestry.isEmpty() && this.ancestry.stream() ++ .allMatch(chain -> chain.stream().anyMatch(bone -> bone.copiedItem() || declarations.contains(bone.name()))); ++ } ++ + Optional> ownerBones(final Map bindings) { + if (this.ancestry == null) return Optional.empty(); + final Set owners = new HashSet<>(); @@ -954,10 +1065,10 @@ index 0000000..3ede812 +} 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..5c2d6b6 +index 0000000..fd1b9d4 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerAttachables.java -@@ -0,0 +1,450 @@ +@@ -0,0 +1,451 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -1061,7 +1172,7 @@ index 0000000..5c2d6b6 + if (packs == null) return List.of(); + final List result = new ArrayList<>(); + final Map queries = new HashMap<>(input.queries()); -+ queries.putAll(BedrockAttachableBindings.queries(entity.getMainArm() == net.minecraft.world.entity.HumanoidArm.RIGHT)); ++ queries.putAll(BedrockAttachableBindings.queries()); + 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()); @@ -1076,7 +1187,7 @@ index 0000000..5c2d6b6 + final var resource = RESOURCES.computeIfAbsent(identity.identifier(), _ -> load(identity.identifier(), packs)).orElse(null); + if (resource == null) continue; + if ((slot == EquipmentSlot.MAINHAND || slot == EquipmentSlot.OFFHAND) -+ && !hasHeldGeometry(resource, packs, new BedrockActorAnimation.Frame(queries, input.variables(), context))) continue; ++ && !hasHeldGeometry(resource, packs)) continue; + final String key = slotName(slot) + firstPerson; + var setup = resource.parentSetup.get(key); + if (setup == null) { @@ -1101,7 +1212,7 @@ index 0000000..5c2d6b6 + final Map queries = new HashMap<>(input == null ? BedrockPlayerSkins.actorQueries(entity, state, partialTick) : input.queries()); + queries.putAll(BedrockPlayerSkins.geometryQueries(state)); + queries.put("is_invisible", state.isInvisible); -+ queries.putAll(BedrockAttachableBindings.queries(entity.getMainArm() == net.minecraft.world.entity.HumanoidArm.RIGHT)); ++ queries.putAll(BedrockAttachableBindings.queries()); + 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()); @@ -1137,7 +1248,9 @@ index 0000000..5c2d6b6 + final float elapsed = entry.elapsed(state.ageInTicks); + final Map pose = equipped.playback == null ? Map.of() + : equipped.playback.sample(elapsed, new BedrockActorAnimation.Frame(itemQueries, variables, context), false, null); -+ final var selection = equipped.controllers.select(elapsed, properties, itemQueries, variables, context); ++ final var selection = equipped.playback == null ++ ? equipped.controllers.select(elapsed, properties, itemQueries, variables, context) ++ : equipped.playback.renderControllers(equipped.controllers); + final List slotSurfaces = new ArrayList<>(); + boolean supported = true; + for (final var selected : selection.surfaces()) { @@ -1163,7 +1276,8 @@ index 0000000..5c2d6b6 + && geometry.getParents().stream().allMatch(parent -> parent.getCubes().isEmpty()) + || texture == null || material == null) { supported = false; break; } + final var binding = BINDINGS.get(selected.geometry()); -+ final Map bindings = binding == null ? Map.of() : binding.evaluate(new BedrockActorAnimation.Frame(itemQueries, variables, context)); ++ final Map bindings = binding == null ? Map.of() : equipped.playback == null ++ ? equipped.controllers.bindings(binding) : equipped.playback.bindings(binding); + if (hand) { + var heldBindings = equipped.resource.heldBindings.get(modelKey); + if (heldBindings == null) { @@ -1205,14 +1319,12 @@ index 0000000..5c2d6b6 + return List.copyOf(result); + } + -+ private static boolean hasHeldGeometry(final Resource resource, final ResourcePackStorage packs, -+ final BedrockActorAnimation.Frame frame) throws IOException { ++ private static boolean hasHeldGeometry(final Resource resource, final ResourcePackStorage packs) { + for (final String identifier : resource.definition.getGeometries().values()) { + final var binding = BINDINGS.get(identifier); -+ final var bindings = binding == null ? Map.of() : binding.evaluate(frame); + final var geometry = resource.heldBindings.computeIfAbsent(identifier, + _ -> new BedrockHeldAttachableBindings(packs.getModels().entityModels().get(identifier), textureMeshBones(identifier))); -+ if (geometry.ownerBones(bindings).isPresent()) return true; ++ if (geometry.hasBindingPath(binding == null ? Set.of() : binding.bones())) return true; + } + return false; + } @@ -1433,7 +1545,7 @@ index 3baf776..bd68d57 100644 + } 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 b686afe..a10aebc 100644 +index 274bca7..2c46b34 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerSkins.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerSkins.java @@ -78,6 +78,7 @@ import java.util.HashMap; @@ -1525,7 +1637,7 @@ index b686afe..a10aebc 100644 static Map geometryQueries(final AvatarRenderState state) { final Geometry geometry = geometry(appearance(state.skin), true); -@@ -1066,10 +1094,12 @@ public final class BedrockPlayerSkins { +@@ -1067,10 +1095,12 @@ public final class BedrockPlayerSkins { BedrockActorResources.clear(); rendererContext = context; rendererEpoch++; @@ -1655,10 +1767,10 @@ index 0000000..af2632d +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockAttachableBindingsTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockAttachableBindingsTest.java new file mode 100644 -index 0000000..fbafa43 +index 0000000..ab5c71a --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockAttachableBindingsTest.java -@@ -0,0 +1,31 @@ +@@ -0,0 +1,84 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -1667,28 +1779,237 @@ index 0000000..fbafa43 +package com.viaversion.viafabricplus.bedrock.appearance; + +import com.google.gson.JsonParser; ++import java.io.IOException; ++import java.nio.file.Files; ++import java.nio.file.Path; ++import java.util.ArrayList; +import java.util.Map; +import org.junit.jupiter.api.Test; ++import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import static org.junit.jupiter.api.Assertions.*; + +final class BedrockAttachableBindingsTest { + @Test -+ void bindsHeldItemsByHandednessAndMovesTheUsingSpyglassToTheHead() throws Exception { ++ void resolvesNativeSlotsAndMovesTheUsingSpyglassWithoutSwappingTheOwnerSkeleton() throws Exception { + final var binding = new BedrockAttachableBindings(JsonParser.parseString(""" + {"bones":[{"name":"scope","binding":"q.item_slot_to_bone_name(q.main_hand_item_use_duration > 0 ? 'head' : c.item_slot)"}]} + """).getAsJsonObject()); -+ final var transforms = Map.of("rightItem", 5F, "leftItem", -5F, "head", 0F); -+ for (final boolean right : new boolean[]{true,false}) { -+ final var queries = new java.util.HashMap<>(BedrockAttachableBindings.queries(right)); ++ final var transforms = Map.of("rightitem", 5F, "leftitem", -5F, "head", 0F, "waist", 13F, "scope_anchor", 27F); ++ for (final boolean leftHanded : new boolean[]{false, true}) { ++ final var queries = new java.util.HashMap<>(BedrockAttachableBindings.queries()); ++ queries.put("is_left_handed", leftHanded); + queries.put("main_hand_item_use_duration", 0F); -+ for (final String slot : new String[]{"main_hand", "off_hand"}) { -+ final var resolved = binding.evaluate(new BedrockActorAnimation.Frame(queries, Map.of(), Map.of("item_slot", slot))); -+ assertEquals((slot.equals("main_hand") == right) ? 5F : -5F, transforms.get(resolved.get("scope"))); ++ final var slots = Map.of("main_hand", 5F, "off_hand", -5F, "head", 0F, "waist", 13F, "scope_anchor", 27F); ++ for (final var slot : slots.entrySet()) { ++ final var resolved = evaluate(binding, new BedrockActorAnimation.Frame(queries, Map.of(), Map.of("item_slot", slot.getKey()))); ++ assertEquals(slot.getValue(), transforms.get(resolved.get("scope"))); + } + queries.put("main_hand_item_use_duration", 1F); -+ assertEquals(0F, transforms.get(binding.evaluate(new BedrockActorAnimation.Frame(queries, Map.of(), Map.of("item_slot", "main_hand"))).get("scope"))); ++ assertEquals(0F, transforms.get(evaluate(binding, new BedrockActorAnimation.Frame(queries, Map.of(), Map.of("item_slot", "main_hand"))).get("scope"))); ++ } ++ } ++ ++ @Test ++ void usesTheFirstNativeStringArgumentAndRejectsMissingOrNumericBindings() throws Exception { ++ final var binding = new BedrockAttachableBindings(JsonParser.parseString(""" ++ {"bones":[{"name":"grip","binding":"q.item_slot_to_bone_name(c.item_slot, 17)"}]} ++ """).getAsJsonObject()); ++ final var queries = BedrockAttachableBindings.queries(); ++ final var ownerOffsets = Map.of("custom_anchor", 19F); ++ assertEquals(19F, ownerOffsets.get(evaluate(binding, new BedrockActorAnimation.Frame(queries, Map.of(), ++ Map.of("item_slot", "custom_anchor"))).get("grip"))); ++ assertThrows(IOException.class, () -> evaluate(binding, new BedrockActorAnimation.Frame(queries, Map.of(), Map.of("item_slot", 19)))); ++ final var query = (BedrockActorAnimation.Query) queries.get("item_slot_to_bone_name"); ++ assertEquals("", query.evaluate(java.util.List.of())); ++ } ++ ++ @Test ++ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_ATTACHABLE_SLOTS", matches = ".+") ++ void matchesTheUnchangedNativeSlotCallback() throws Exception { ++ final var reference = JsonParser.parseString(Files.readString(Path.of(System.getenv("STACKANVIL_NATIVE_ATTACHABLE_SLOTS")))).getAsJsonObject(); ++ assertEquals("537c0aee2e79afbdc94b44b28e00f466ae62bc50e2733d953b430db9dbaa9ee7", reference.get("sha256").getAsString()); ++ final var query = (BedrockActorAnimation.Query) BedrockAttachableBindings.queries().get("item_slot_to_bone_name"); ++ int checked = 0; ++ for (final var value : reference.getAsJsonArray("rows")) { ++ final var row = value.getAsJsonObject(); ++ // The client installs this query only while evaluating an actual equipped owner's graph. ++ if (!row.get("hasOwner").getAsBoolean()) continue; ++ final var arguments = new ArrayList(); ++ final int count = row.get("count").getAsInt(); ++ if (count > 0) arguments.add(row.get("argument").isJsonNull() ? 19D : row.get("argument").getAsString()); ++ while (arguments.size() < count) arguments.add(17D); ++ final String actual = (String) query.evaluate(arguments); ++ long hash = actual.isEmpty() ? 0L : 0xcbf29ce484222325L; ++ for (int index = 0; index < actual.length(); index++) hash = hash * 0x100000001b3L ^ actual.charAt(index); ++ assertEquals(Long.parseUnsignedLong(row.get("resultHash").getAsString(), 16), hash, row.toString()); ++ checked++; ++ } ++ assertEquals(52, checked); ++ } ++ private static Map evaluate(final BedrockAttachableBindings bindings, ++ final BedrockActorAnimation.Frame frame) throws Exception { ++ final var empty = new com.google.gson.JsonObject(); ++ final var playback = BedrockActorAnimation.read(empty, Map.of(), empty, empty, "1.10.0").playback(); ++ playback.sample(0, frame); ++ return playback.bindings(bindings); ++ } ++ ++} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockAttachableScopeTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockAttachableScopeTest.java +new file mode 100644 +index 0000000..beb8edf +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockAttachableScopeTest.java +@@ -0,0 +1,150 @@ ++/* ++ * 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.google.gson.JsonParser; ++import java.io.IOException; ++import java.nio.charset.StandardCharsets; ++import java.util.List; ++import java.util.Map; ++import java.util.Set; ++import java.util.UUID; ++import net.raphimc.viabedrock.api.resourcepack.NativeActorLibrary; ++import net.raphimc.viabedrock.protocol.storage.ResourcePackStorage; ++import org.junit.jupiter.api.Test; ++import static org.junit.jupiter.api.Assertions.*; ++ ++final class BedrockAttachableScopeTest { ++ @Test ++ void sharesOrderedBindingWritesWithTheSampledGraphAndRenderControllers() throws Exception { ++ final var packs = resources(); ++ final var playback = BedrockActorAnimation.serverAttachable(packs, "test:item").playback(); ++ final var controllers = controllers(packs); ++ final var bindings = bindings(); ++ playback.sample(0, frame(false, "main_hand")); ++ assertEquals(10F, playback.renderControllers(controllers).surfaces().getFirst().lightMultiplier()); ++ assertEquals(Map.of("z", "rightitem", "a", "head"), playback.bindings(bindings)); ++ assertEquals(11F, playback.renderControllers(controllers).surfaces().getFirst().lightMultiplier()); ++ assertEquals(1F, playback.expression("v.binding_count", -1)); ++ // Rendering twice at one sample time must not repeat initialization or pre-animation. ++ playback.sample(0, frame(false, "main_hand")); ++ assertEquals(11F, playback.renderControllers(controllers).surfaces().getFirst().lightMultiplier()); ++ playback.sample(1, frame(false, "off_hand")); ++ assertEquals(21F, playback.renderControllers(controllers).surfaces().getFirst().lightMultiplier()); ++ assertEquals(Map.of("z", "leftitem", "a", "head"), playback.bindings(bindings)); ++ assertEquals(22F, playback.renderControllers(controllers).surfaces().getFirst().lightMultiplier()); ++ } ++ ++ @Test ++ void keepsOwnersPerspectivesAndReplacementLifetimesIndependent() throws Exception { ++ final var packs = resources(); ++ final var graph = BedrockActorAnimation.serverAttachable(packs, "test:item"); ++ final var body = graph.playback(); ++ final var hands = body.fork(); ++ final var otherOwner = graph.playback(); ++ final var controllers = controllers(packs); ++ final var bindings = bindings(); ++ for (int time = 0; time < 3; time++) { ++ body.sample(time, frame(false, "main_hand")); ++ assertEquals("geometry.body", body.renderControllers(controllers).surfaces().getFirst().geometry()); ++ assertEquals(Map.of("z", "rightitem", "a", "head"), body.bindings(bindings)); ++ } ++ hands.sample(0, frame(true, "off_hand")); ++ assertEquals("geometry.hands", hands.renderControllers(controllers).surfaces().getFirst().geometry()); ++ assertEquals(Map.of("z", "leftitem", "a", "head"), hands.bindings(bindings)); ++ otherOwner.sample(0, frame(false, "custom_anchor")); ++ otherOwner.renderControllers(controllers); ++ assertEquals(Map.of("z", "custom_anchor", "a", "head"), otherOwner.bindings(bindings)); ++ assertEquals(3F, body.expression("v.binding_count", -1)); ++ assertEquals(1F, hands.expression("v.binding_count", -1)); ++ assertEquals(1F, otherOwner.expression("v.binding_count", -1)); ++ ++ body.sample(0, frame(false, "head")); ++ assertEquals(10F, body.renderControllers(controllers).surfaces().getFirst().lightMultiplier()); ++ assertEquals(Map.of("z", "head", "a", "head"), body.bindings(bindings)); ++ final var replacement = graph.playback(); ++ replacement.sample(0, frame(false, "waist")); ++ assertEquals(10F, replacement.renderControllers(controllers).surfaces().getFirst().lightMultiplier()); ++ assertEquals(Map.of("z", "waist", "a", "head"), replacement.bindings(bindings)); ++ } ++ ++ @Test ++ void usesTheStandaloneControllerScopeWhenTheRawGraphIsUnavailable() throws Exception { ++ final var packs = resources(); ++ final var first = controllers(packs); ++ final var second = controllers(packs); ++ final var bindings = new BedrockAttachableBindings(JsonParser.parseString(""" ++ {"bones":[{"name":"grip","binding":"v.bound=(v.bound ?? 0)+1;return v.samples == v.bound && v.initialized == 1 ? q.item_slot_to_bone_name(c.item_slot) : 'head';"}]} ++ """).getAsJsonObject()); ++ for (int time = 0; time < 2; time++) { ++ final var frame = frame(false, "main_hand"); ++ first.select(time, Map.of(), frame.queries(), frame.variables(), frame.context()); ++ assertEquals(Map.of("grip", "rightitem"), first.bindings(bindings)); ++ } ++ final var frame = frame(true, "off_hand"); ++ second.select(0, Map.of(), frame.queries(), frame.variables(), frame.context()); ++ assertEquals(Map.of("grip", "leftitem"), second.bindings(bindings)); ++ } ++ ++ @Test ++ void inspectsDeclarationsWithoutExecutingBindingsAndRequiresNativeStringResults() throws Exception { ++ final var packs = resources(); ++ final var playback = BedrockActorAnimation.serverAttachable(packs, "test:item").playback(); ++ final var bindings = bindings(); ++ assertEquals(Set.of("z", "a"), bindings.bones()); ++ assertThrows(UnsupportedOperationException.class, () -> bindings.bones().clear()); ++ playback.sample(0, frame(false, "main_hand")); ++ playback.renderControllers(controllers(packs)); ++ assertEquals(Map.of("z", "rightitem", "a", "head"), playback.bindings(bindings)); ++ for (final String expression : List.of("19", "''")) { ++ final var geometry = JsonParser.parseString("{\"bones\":[{\"name\":\"grip\",\"binding\":\"" + expression + "\"}]}").getAsJsonObject(); ++ assertThrows(IOException.class, () -> playback.bindings(new BedrockAttachableBindings(geometry))); + } + } ++ ++ private static BedrockAttachableBindings bindings() throws IOException { ++ return new BedrockAttachableBindings(JsonParser.parseString(""" ++ {"bones":[ ++ {"name":"z","binding":"v.binding_count=(v.binding_count ?? 0)+1;return q.item_slot_to_bone_name(c.item_slot);"}, ++ {"name":"a","binding":"return v.binding_count == v.samples && v.initialized == 1 && v.entered == 7 && v.selected == v.samples+7 ? 'head' : 'waist';"}]} ++ """).getAsJsonObject()); ++ } ++ ++ private static BedrockActorAnimation.Frame frame(final boolean firstPerson, final String slot) { ++ return new BedrockActorAnimation.Frame(BedrockAttachableBindings.queries(), Map.of(), ++ Map.of("item_slot", slot, "is_first_person", firstPerson ? 1 : 0)); ++ } ++ ++ private static BedrockPlayerControllers controllers(final ResourcePackStorage packs) throws IOException { ++ return new BedrockPlayerControllers(packs.getAttachables().attachables().get("test:item").attachableData(), ++ packs.getRenderControllers().renderControllers()); ++ } ++ ++ private static ResourcePackStorage resources() throws IOException { ++ return ResourcePackStorage.nativeActors(List.of(new NativeActorLibrary.Layer(true, Map.of( ++ "manifest.json", ("{\"format_version\":3,\"header\":{\"uuid\":\"" + UUID.randomUUID() ++ + "\",\"version\":\"1.0.0\",\"name\":\"fixture\"}}").getBytes(StandardCharsets.UTF_8), ++ "attachables/test.json", """ ++ {"format_version":"1.10.0","minecraft:attachable":{"description":{ ++ "identifier":"test:item","animations":{"pose":"controller.animation.pose"}, ++ "scripts":{"initialize":["v.initialized=(v.initialized ?? 0)+1;v.samples=0;"], ++ "pre_animation":["v.samples=v.samples+1;"],"animate":["pose"]}, ++ "render_controllers":["controller.render.item"], ++ "geometry":{"default":"geometry.body","hands":"geometry.hands"}, ++ "textures":{"default":"textures/item"},"materials":{"default":"entity_alphatest"}}}} ++ """.getBytes(StandardCharsets.UTF_8), ++ "animation_controllers/pose.json", """ ++ {"format_version":"1.10.0","animation_controllers":{"controller.animation.pose":{ ++ "states":{"default":{"on_entry":["v.entered=7;"]}}}}} ++ """.getBytes(StandardCharsets.UTF_8), ++ "render_controllers/item.json", """ ++ {"format_version":"1.8.0","render_controllers":{"controller.render.item":{ ++ "geometry":"v.selected=v.samples+(v.entered ?? 0);return c.is_first_person ? Geometry.hands : Geometry.default;", ++ "textures":["Texture.default"],"materials":[{"*":"Material.default"}], ++ "light_color_multiplier":"v.samples*10+(v.binding_count ?? 0)"}}} ++ """.getBytes(StandardCharsets.UTF_8))))); ++ } +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockAttachableSelectionTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockAttachableSelectionTest.java new file mode 100644 @@ -2379,27 +2700,276 @@ index 0000000..d9f0c69 + } + +} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockExplicitBindingsTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockExplicitBindingsTest.java +new file mode 100644 +index 0000000..fac920f +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockExplicitBindingsTest.java +@@ -0,0 +1,191 @@ ++/* ++ * 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.JsonArray; ++import com.google.gson.JsonParser; ++import com.mojang.blaze3d.vertex.PoseStack; ++import com.mojang.blaze3d.vertex.VertexConsumer; ++import com.viaversion.viafabricplus.bedrock.appearance.BedrockBoneAnimation; ++import java.nio.file.Files; ++import java.nio.file.Path; ++import java.util.ArrayList; ++import java.util.HashMap; ++import java.util.LinkedHashMap; ++import java.util.List; ++import java.util.Map; ++import net.minecraft.client.model.geom.ModelPart; ++import org.cube.converter.model.impl.bedrock.BedrockGeometryModel; ++import org.joml.Matrix4f; ++import org.joml.Vector3f; ++import org.junit.jupiter.api.Test; ++import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; ++import static org.junit.jupiter.api.Assertions.*; ++import static org.mockito.ArgumentMatchers.*; ++import static org.mockito.Mockito.*; ++ ++final class BedrockExplicitBindingsTest { ++ @Test ++ void replacesTheAnimatedBoneAndCarriesItsOwnerThroughLocatorsAndVertices() throws Exception { ++ final var model = model(); ++ final var root = model.model().root().getChild("root"); ++ final var socket = root.getChild("socket"); ++ final var tip = socket.getChild("tip"); ++ final var pose = Map.of("root", transform(false), "socket", transform(false)); ++ for (final float reflection : new float[]{1, -1}) { ++ model.pose(pose); ++ final var owner = new Matrix4f().translation(6, 22, 2).rotateY(.4F).scale(2 * reflection, 1, .5F) ++ .rotateZ(.3F); ++ model.bind(Map.of(), Map.of("rightitem", owner), Map.of("socket", "rightitem")); ++ assertTrue(owner.equals(model.boneMatrices().get("socket"), .000002F)); ++ final var expectedTip = new Matrix4f(owner).translate(4, -7, -4) ++ .rotateZYX(radians(30), radians(20), radians(10)); ++ assertTrue(expectedTip.equals(model.boneMatrices().get("tip"), .000005F)); ++ assertTrue(owner.equals(model.locatorTransform(new BedrockModelLocators.Locator("socket", 0, 0, 0, 0, 0, 0, false)), .000002F)); ++ assertVertices(model, List.of(root, socket), socket, owner); ++ assertVertices(model, List.of(root, socket, tip), tip, expectedTip); ++ assertEquals(10F, socket.x); ++ assertEquals(-13F, socket.y); ++ assertEquals(4F, socket.xScale); ++ } ++ } ++ ++ @Test ++ void bindsNestedBonesIndependentlyOfSingularParentsAndRestoresTheUnboundPose() throws Exception { ++ final var model = model(); ++ model.pose(Map.of("root", transform(false), "socket", transform(false), "tip", transform(false))); ++ final var unbound = model.boneMatrices(); ++ final var root = new Matrix4f().translation(1, 2, 3).scale(0); ++ final var socket = new Matrix4f().translation(-6, 22, 2).rotateX(-.3F).scale(-1, 2, 3); ++ final var tip = new Matrix4f().translation(1, 7, -3).rotateZ(.8F); ++ final var owners = Map.of("rootanchor", root, "socketanchor", socket, "tipanchor", tip); ++ for (final var order : List.of(List.of("root", "socket", "tip"), List.of("tip", "socket", "root"))) { ++ final Map bindings = new LinkedHashMap<>(); ++ order.forEach(name -> bindings.put(name, name + "_anchor")); ++ model.bind(Map.of(), owners, bindings); ++ assertTrue(root.equals(model.boneMatrices().get("root"), .000002F)); ++ assertTrue(socket.equals(model.boneMatrices().get("socket"), .000002F)); ++ assertTrue(tip.equals(model.boneMatrices().get("tip"), .000002F)); ++ } ++ model.bind(Map.of(), Map.of(), Map.of()); ++ unbound.forEach((name, matrix) -> assertTrue(matrix.equals(model.boneMatrices().get(name), .000002F))); ++ model.bind(Map.of("root", new Matrix4f().scale(0)), owners, Map.of("socket", "socket_anchor")); ++ assertEquals(0, model.boneMatrices().get("root").determinant()); ++ final var selected = new Matrix4f(socket); ++ socket.identity(); ++ assertTrue(selected.equals(model.boneMatrices().get("socket"), .000002F)); ++ model.pose(Map.of()); ++ assertFalse(model.usesNativeMatrices()); ++ } ++ ++ @Test ++ void clearsTheCopiedBasisForEntityRelativeRotationWithoutKeepingLocalAnimation() throws Exception { ++ final var model = model(); ++ for (final var owner : List.of( ++ new Matrix4f().translation(2, -3, 4).rotateZYX(.5F, -.2F, .7F).scale(-2, 3, .5F), ++ new Matrix4f().translation(-5, 9, 3).scale(0))) { ++ model.pose(Map.of("socket", transform(true))); ++ model.bind(Map.of(), Map.of("rightitem", owner), Map.of("socket", "rightitem")); ++ final var expected = new Matrix4f().translation(owner.getTranslation(new Vector3f())); ++ assertTrue(expected.equals(model.boneMatrices().get("socket"), .000002F)); ++ } ++ } ++ ++ @Test ++ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_EXPLICIT_BINDINGS", matches = ".+") ++ void matchesOriginalNativeBindingSelectionAndRecursiveBoneMatrices() throws Exception { ++ final var reference = JsonParser.parseString(Files.readString(Path.of(System.getenv("STACKANVIL_NATIVE_EXPLICIT_BINDINGS")))).getAsJsonObject(); ++ assertEquals("537c0aee2e79afbdc94b44b28e00f466ae62bc50e2733d953b430db9dbaa9ee7", reference.get("sha256").getAsString()); ++ final var geometry = BedrockGeometryModel.fromJson(reference.get("geometry").toString()).getFirst(); ++ for (final var value : reference.getAsJsonArray("rows")) { ++ final var row = value.getAsJsonObject(); ++ final var model = BedrockGeometry.animatedModel(geometry, Map.of()); ++ final Map poses = new HashMap<>(); ++ row.getAsJsonObject("poses").entrySet().forEach(entry -> { ++ final var pose = entry.getValue().getAsJsonObject(); ++ poses.put(entry.getKey(), new BedrockBoneAnimation.Transform(vector(pose.getAsJsonArray("position")), ++ vector(pose.getAsJsonArray("rotation")), vector(pose.getAsJsonArray("scale")), pose.get("relative").getAsBoolean())); ++ }); ++ final Map owners = new HashMap<>(); ++ row.getAsJsonObject("owners").entrySet().forEach(entry -> owners.put(entry.getKey(), new Matrix4f().set(floats(entry.getValue().getAsJsonArray())))); ++ final Map bindings = new HashMap<>(); ++ row.getAsJsonObject("bindings").entrySet().forEach(entry -> bindings.put(entry.getKey(), entry.getValue().getAsString())); ++ model.pose(poses); ++ model.bind(Map.of(), owners, bindings); ++ for (final var entry : row.getAsJsonObject("matrices").entrySet()) { ++ final float[] expected = floats(entry.getValue().getAsJsonArray()); ++ assertArrayEquals(expected, model.boneMatrices().get(entry.getKey()).get(new float[16]), .00006F, row.toString()); ++ final var locator = new BedrockModelLocators.Locator(entry.getKey(), 0, 0, 0, 0, 0, 0, false); ++ assertArrayEquals(expected, model.locatorTransform(locator).get(new float[16]), .00006F, row.toString()); ++ } ++ } ++ assertEquals(96, reference.getAsJsonArray("rows").size()); ++ } ++ ++ private static void assertVertices(final BedrockAnimatedModel model, final List path, ++ final ModelPart bone, final Matrix4f expectedBone) { ++ final var cubePart = bone.getChild("cube_0"); ++ final var cube = cubePart.getRandomCube(net.minecraft.util.RandomSource.create()); ++ final var expected = new Matrix4f(expectedBone).m30(expectedBone.m30() / 16F) ++ .m31(expectedBone.m31() / 16F).m32(expectedBone.m32() / 16F) ++ .translate(cubePart.x / 16F, cubePart.y / 16F, cubePart.z / 16F) ++ .rotateZYX(cubePart.zRot, cubePart.yRot, cubePart.xRot); ++ final var consumer = mock(VertexConsumer.class); ++ final var emitted = new ArrayList(); ++ doAnswer(call -> { ++ emitted.add(new Vector3f((float) call.getArgument(0), (float) call.getArgument(1), (float) call.getArgument(2))); ++ return null; ++ }).when(consumer).addVertex(anyFloat(), anyFloat(), anyFloat(), anyInt(), anyFloat(), anyFloat(), anyInt(), anyInt(), anyFloat(), anyFloat(), anyFloat()); ++ final var poses = new PoseStack(); ++ BedrockModelTransforms.visit(model.model().root(), poses, () -> { ++ apply(model.model().root(), poses); ++ path.forEach(part -> apply(part, poses)); ++ apply(cubePart, poses); ++ assertTrue(expected.equals(poses.last().pose(), .000005F)); ++ cube.compile(poses.last(), consumer, 42, 23, -1); ++ }); ++ int index = 0; ++ for (final var polygon : cube.polygons) for (final var vertex : polygon.vertices()) { ++ final var transformed = expected.transformPosition(new Vector3f(vertex.worldX(), vertex.worldY(), vertex.worldZ())); ++ assertEquals(0, transformed.distance(emitted.get(index++)), .000005F); ++ } ++ assertEquals(24, emitted.size()); ++ } ++ ++ private static void apply(final ModelPart part, final PoseStack poses) { ++ BedrockModelTransforms.transform(part, poses, () -> part.translateAndRotate(poses)); ++ } ++ ++ private static BedrockBoneAnimation.Transform transform(final boolean relative) { ++ return new BedrockBoneAnimation.Transform(new BedrockBoneAnimation.Vector(7, 8, -2), ++ new BedrockBoneAnimation.Vector(45, -30, 5), new BedrockBoneAnimation.Vector(4, .5F, 2), relative); ++ } ++ ++ private static float radians(final float degrees) { ++ return (float) Math.toRadians(degrees); ++ } ++ ++ private static BedrockBoneAnimation.Vector vector(final JsonArray array) { ++ return new BedrockBoneAnimation.Vector(array.get(0).getAsFloat(), array.get(1).getAsFloat(), array.get(2).getAsFloat()); ++ } ++ ++ private static float[] floats(final JsonArray array) { ++ final float[] result = new float[array.size()]; ++ for (int index = 0; index < result.length; index++) result[index] = array.get(index).getAsFloat(); ++ return result; ++ } ++ ++ private static BedrockAnimatedModel model() throws Exception { ++ return BedrockGeometry.animatedModel(BedrockGeometryModel.fromJson(""" ++ {"minecraft:geometry":[{"description":{"identifier":"geometry.test","texture_width":64,"texture_height":64},"bones":[ ++ {"name":"root","pivot":[1,2,3],"rotation":[15,30,20]}, ++ {"name":"socket","parent":"root","pivot":[4,7,9],"rotation":[25,-10,35], ++ "cubes":[{"origin":[3,5,6],"size":[2,3,4],"uv":[0,0]}]}, ++ {"name":"tip","parent":"socket","pivot":[8,14,5],"rotation":[10,20,30], ++ "cubes":[{"origin":[7,12,2],"size":[2,3,4],"uv":[0,0]}]}]}]} ++ """).getFirst(), Map.of()); ++ } ++} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockHeldAttachableBindingsTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockHeldAttachableBindingsTest.java new file mode 100644 -index 0000000..8ae3afb +index 0000000..f413924 --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockHeldAttachableBindingsTest.java -@@ -0,0 +1,69 @@ +@@ -0,0 +1,125 @@ +package com.viaversion.viafabricplus.bedrock.render; + ++import com.google.gson.JsonParser; ++import com.viaversion.viafabricplus.bedrock.appearance.BedrockActorAnimation; ++import com.viaversion.viafabricplus.bedrock.appearance.BedrockAttachableBindings; ++import java.nio.charset.StandardCharsets; ++import java.util.List; +import java.util.Map; +import java.util.Set; ++import java.util.UUID; ++import net.raphimc.viabedrock.api.resourcepack.NativeActorLibrary; ++import net.raphimc.viabedrock.protocol.storage.ResourcePackStorage; +import org.cube.converter.model.impl.bedrock.BedrockGeometryModel; +import org.junit.jupiter.api.Test; ++import org.joml.Matrix4f; +import static org.junit.jupiter.api.Assertions.*; + +final class BedrockHeldAttachableBindingsTest { + @Test ++ void routesAuthoredSlotQueriesThroughTheOwnerMatricesWithoutKeepingThePreviousAnchor() throws Exception { ++ final String bones = """ ++ {"name":"socket","pivot":[0,0,0],"binding":"q.item_slot_to_bone_name(c.item_slot)"}, ++ {"name":"blade","parent":"socket","pivot":[2,3,4], ++ "cubes":[{"origin":[1,2,3],"size":[2,2,2],"uv":[0,0]}]} ++ """; ++ final var geometry = geometry(bones); ++ final var resolver = new BedrockAttachableBindings(JsonParser.parseString("{\"bones\":[" + bones + "]}").getAsJsonObject()); ++ final var ancestry = new BedrockHeldAttachableBindings(geometry); ++ final var packs = ResourcePackStorage.nativeActors(List.of(new NativeActorLibrary.Layer(true, Map.of( ++ "manifest.json", ("{\"format_version\":3,\"header\":{\"uuid\":\"" + UUID.randomUUID() ++ + "\",\"version\":\"1.0.0\",\"name\":\"fixture\"}}").getBytes(StandardCharsets.UTF_8), ++ "attachables/test.json", """ ++ {"format_version":"1.10.0","minecraft:attachable":{"description":{"identifier":"test:item"}}} ++ """.getBytes(StandardCharsets.UTF_8))))); ++ final var playback = BedrockActorAnimation.serverAttachable(packs, "test:item").playback(); ++ int frame = 0; ++ final var model = BedrockGeometry.animatedModel(geometry, Map.of()); ++ final var socket = model.model().root().getChild("socket"); ++ final var blade = socket.getChild("blade"); ++ final var right = new Matrix4f().translation(6, 22, 2).rotateY(.4F).scale(2, 1, .5F); ++ final var left = new Matrix4f().translation(-6, 22, 2).rotateX(-.3F); ++ final var custom = new Matrix4f().translation(1, 7, -3).rotateZ(.8F).scale(-1, 2, 3); ++ final var owners = Map.of("rightitem", right, "leftitem", left, "customanchor", custom); ++ final var slots = Map.of("main_hand", right, "off_hand", left, "custom_anchor", custom); ++ for (int pass = 0; pass < 2; pass++) for (final var slot : slots.entrySet()) { ++ playback.sample(frame++, new BedrockActorAnimation.Frame(BedrockAttachableBindings.queries(), ++ Map.of(), Map.of("item_slot", slot.getKey()))); ++ final var bindings = playback.bindings(resolver); ++ assertEquals(1, ancestry.ownerBones(bindings).orElseThrow().size()); ++ model.pose(Map.of()); ++ model.bind(Map.of(), owners, bindings); ++ assertTrue(slot.getValue().equals(model.boneMatrices().get("socket"), .000002F)); ++ final var expected = new Matrix4f(slot.getValue()).translate(2, -3, 4); ++ expected.m30(expected.m30() / 16F).m31(expected.m31() / 16F).m32(expected.m32() / 16F); ++ assertTrue(expected.equals(model.drawingMatrices().get(blade), .000002F)); ++ } ++ } ++ ++ @Test + void rejectsMobBodyEquipmentWithoutChangingBoundOrLegacyHeldModels() throws Exception { + final var harness = geometry(""" + {"name":"body","pivot":[0,0,0],"cubes":[{"origin":[-33.5,0.5,-33.5],"size":[67,67,67],"uv":[0,0]}]}, + {"name":"goggles","parent":"body","pivot":[0,46,-22],"cubes":[{"origin":[-33.95,28.05,-33.95],"size":[67.9,23.9,23.9],"uv":[0,0]}]} + """); ++ assertFalse(new BedrockHeldAttachableBindings(harness).hasBindingPath(Set.of())); ++ assertTrue(new BedrockHeldAttachableBindings(harness).hasBindingPath(Set.of("body"))); ++ assertFalse(new BedrockHeldAttachableBindings(harness).hasBindingPath(Set.of("goggles"))); + assertTrue(new BedrockHeldAttachableBindings(harness).ownerBones(Map.of()).isEmpty()); + // A server can deliberately replace this wearable with a handheld model through an authored binding. + assertEquals(Set.of("rightItem"), new BedrockHeldAttachableBindings(harness).ownerBones(Map.of("body", "rightItem")).orElseThrow()); @@ -2429,6 +2999,7 @@ index 0000000..8ae3afb + """); + assertTrue(bow.getParents().getFirst().getCubes().isEmpty()); + final var prepared = new BedrockHeldAttachableBindings(bow, Set.of("rightitem")); ++ assertTrue(prepared.hasBindingPath(Set.of())); + assertEquals(Set.of("rightitem"), prepared.ownerBones(Map.of()).orElseThrow()); + assertEquals(Set.of("leftItem"), prepared.ownerBones(Map.of("rightitem", "leftItem")).orElseThrow()); + assertTrue(new BedrockHeldAttachableBindings(bow).ownerBones(Map.of()).isEmpty()); @@ -2440,11 +3011,14 @@ index 0000000..8ae3afb + {"name":"grip","pivot":[0,0,0],"cubes":[{"origin":[0,0,0],"size":[1,1,1],"uv":[0,0]}]}, + {"name":"body","pivot":[0,0,0],"cubes":[{"origin":[0,0,0],"size":[67,67,67],"uv":[0,0]}]} + """); ++ assertFalse(new BedrockHeldAttachableBindings(mixed).hasBindingPath(Set.of("grip"))); ++ assertTrue(new BedrockHeldAttachableBindings(mixed).hasBindingPath(Set.of("grip", "body"))); + assertTrue(new BedrockHeldAttachableBindings(mixed).ownerBones(Map.of("grip", "rightItem")).isEmpty()); + final var cycle = geometry(""" + {"name":"one","parent":"two","pivot":[0,0,0],"cubes":[{"origin":[0,0,0],"size":[1,1,1],"uv":[0,0]}]}, + {"name":"two","parent":"one","pivot":[0,0,0]} + """); ++ assertFalse(new BedrockHeldAttachableBindings(cycle).hasBindingPath(Set.of("one", "two"))); + assertTrue(new BedrockHeldAttachableBindings(cycle).ownerBones(Map.of()).isEmpty()); + assertTrue(new BedrockHeldAttachableBindings(mixed).ownerBones(Map.of("grip", "")).isEmpty()); + } 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 94a50f83..6e365f6f 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 @@ -62,8 +62,129 @@ The saved CubeCraft pack retains 107 attachables and 24 equipped models, compare ## Native held equipment -Supported held attachables now render in both first-person and world views. Evaluate authored bone bindings with the equipment slot and handedness. Bind each model to the owner's complete bone matrix. First-person equipment keeps its own playback lifetime. +Supported held attachables now render in both first-person and world views. Evaluate authored bone bindings with the equipment slot. Bind each model to the owner's complete bone matrix. First-person equipment keeps its own playback lifetime. Current equipment supplies parent setup before owner animations on every frame. This also restores native armor visibility on ordinary classic skins. First-person bindings check the selected owner's bones before replacing a Java item draw. Unsupported surfaces, enchanted items and banner-pattern items retain Java rendering. -Focused tests cover handedness, spyglass bindings and owner transforms. These checks do not establish complete native item parity. +Focused tests cover slot selection, spyglass bindings and owner transforms. These checks do not establish complete native item parity. + +## Native item-slot bone lookup + +The target callback maps `main_hand` to `rightitem` and `off_hand` to `leftitem`. +It does not inspect handedness after checking that an associated owner exists. +Other strings pass through unchanged, including body and custom bone names. +The callback reads the first argument and returns an empty string for missing or non-string arguments. +Owner animation state remains separate from this lookup. + +The previous Java query swapped hand names for left-handed owners and rejected most other slots. +Both owner setup and attachable evaluation now use the verified native lookup. +Remove the handedness parameter instead of retaining a second query implementation. + +Evidence uses Bedrock 1.26.51.1, build 51061372, protocol 2193, callback `0x1422265b0`. +The executable SHA-256 is `537c0aee2e79afbdc94b44b28e00f466ae62bc50e2733d953b430db9dbaa9ee7`. +The [Creator query reference](https://learn.microsoft.com/en-us/minecraft/creator/reference/content/molangreference/examples/molangconcepts/queryfunctions/query_item_slot_to_bone_name) +describes the query's purpose. The pinned callback establishes the behavior above. + +Validation: + +- Execute 104 unchanged native callback controls, including absent owners, empty and multiple arguments, both hands, other strings, and numeric values. +- Supply typed expression results at the native evaluator boundary. Bound error logging and empty temporary cleanup separately. +- Compare all 52 owner-present native results with the Java query through the optional `STACKANVIL_NATIVE_ATTACHABLE_SLOTS` fixture. +- Ten focused tests pass without skips. They cover authored query evaluation, numeric rejection, model admission, full matrices, and repeated slot changes. +- The model regression resolves both hands and a custom slot, then checks the actual bone and drawing matrices across six selections. +- Three private negative controls reject other names, swap hand bones, or reject additional arguments. Each fails the native comparison; restored code passes. +- All four builds pass 2,510 tests, with 212 optional skips and no failures or errors. This includes the existing Iris GPU regression. +- All 1,407 core classes match the add-on and proxy copies. All 1,134 final production client classes match the built add-on. + +These checks verify callback semantics and the Java model path. +They do not establish full native Molang admission, context producers, or binding variable ownership. +Nested binding transforms, missing-bone behavior, and visible native parity also remain unverified. +Those remain part of the attachable work. Raw native controls and diagnostic variants remain private. + + +## Share the equipped item scope + +Animation, render-controller selection, and bone bindings now use the same sampled scope for each equipped item. +Previously, controller selection repeated scripts in a second scope, while the global geometry cache retained binding variables for unrelated items. +Compiled binding definitions now contain no mutable evaluator. +Binding variables follow the existing owner, slot, item, and perspective playback lifetime. +Standalone controller selection also supplies its scope when the raw animation graph is unavailable. + +Bindings run in authored bone order and require string results. +The early held-geometry check inspects declared ancestry without running expressions before item initialization. +The later drawing check still resolves actual owner bones before replacing Java equipment. + +Evidence uses the same pinned Bedrock executable identified in the preceding section. +Resolver `0x141c38f40` derives the child Molang context from the selected component at offset `0x88`. +Initialization, pre-animation, animation update, and binding evaluation receive that child context. +Parent setup receives the separate parent context. +The gate at `0x141c3a5cc` accepts only string values, represented by kind 1. + +Twelve bounded instruction controls cover four supplied components, two parent contexts, and value kinds 0, 1, and 2. +These controls run unchanged argument setup and result-kind instructions. +Context setup, script dispatch, animation update, and expression evaluation remain explicit return boundaries. +Component selection and frame locals are supplied inputs. +This evidence establishes argument identity and the string gate, rather than complete resolver or Molang execution. + +Fifteen focused controls pass, including the existing native slot comparison and actual model matrices. +Four new tests cover shared phase writes, bone order, owner and perspective isolation, replacement, rewind, standalone selection, declaration inspection, and result types. +Four private diagnostic variants share the first item scope, reorder bones, coerce numeric results, or separate controller state. +Each variant fails its relevant regression. Restored code passes. + +All four builds pass 2,518 tests, with 212 optional skips and no failures or errors. +The native slot fixture and Iris GPU regression run without skips. +All 1,410 core classes match both packaged copies. All 1,134 production client classes match the add-on. + +The current launcher completes a plain CubeCraft replay with playable spawn, complete payloads, and resource-pack loading. +Its verification report matches the earlier plain replay, including the unresolved `cubecraft:empty.entity` mapping and overall rendering failure. +The client records no equipment-evaluation or runtime errors. Build artifact checksums remain unchanged across the run. +Normal cleanup removes the mutable runtime files and leaves no owned Java or display process. +These scene checks do not establish native equipment pixel parity. + +Native component lifetime, internal controller evaluation, nested binding transforms, missing-bone behavior, and visible parity remain unverified. +The native instruction controls and diagnostic variants remain private. + + +## Match explicit binding matrices + +Explicit bindings now replace the bone transform with the selected owner matrix. +Previously, Java multiplied the item's unbound global transform into that matrix. +Authored pivots, rotations, and animation therefore moved the bound item again. +Bound descendants now select their own owner independently, including beneath singular parents. +Entity-relative rotation preserves the owner origin and clears its inherited basis. +The authored Java pose remains available after binding removal. + +The same pinned executable supplies the transform evidence: + +- Complete bone constructor `0x141c1b620` establishes pivots, relative coordinates, and zero default render offsets. +- Original rest setup `0x141c32b7a..0x141c32c0b` preserves the Y sign and rotation conversion. +- Original pose reset `0x141bce57c..0x141bce5af` restores those rest values before supplied animation samples. +- Owner lookup and copy `0x141c3a65c..0x141c3a8fe` search hashes, copy all sixteen matrix fields, and reset local channels. +- Complete hierarchy `0x141c22010` and composition `0x141bc3d20` resolve the final matrices. + +Ninety-six controls cover three nested binding arrangements, four owner matrix families, optional root bindings, entity-relative rotation, and animation. +The controls run 288 complete bone constructors and 144 owner lookups with matrix copies. +They include authored nonzero pivots, ordinary unbound bones, animated ancestors, reflected scale, shear, and singular matrices. +Parsed definition lookup and empty CRT copies remain explicit boundaries. +Animation samples, resolved binding names, and owner matrices are supplied inputs. +Native JSON and Molang parsing, cube assembly, GPU drawing, and pixels do not run in these controls. + +Four Java regressions fail against the previous implementation and pass after the correction. +The native comparison covers complete bone and locator matrices across all 96 controls. +Other regressions cover actual emitted cube vertices, nested owners, copied-bone ancestors, matrix ownership, repeated binding, and binding removal. +Four private variants retain local transforms, retain a relative basis, clear an ordinary basis, or chain an independent owner through its parent. +Each variant fails the native matrix comparison. + +All four builds pass 2,522 tests, with 212 optional skips and no failures or errors. +The native binding fixture, native slot fixture, and Iris GPU regression run without skips. +All 1,410 core classes match both packaged copies. All 1,134 production client classes match the add-on. + +The current add-on completes a plain CubeCraft replay with playable spawn, complete payloads, and resource-pack loading. +The installed add-on matches the full-build artifact byte for byte. +Its verification report matches the previous replay, including the unresolved `cubecraft:empty.entity` mapping and overall rendering failure. +The client records no equipment-evaluation or runtime errors. Build artifact checksums remain unchanged. +Normal cleanup leaves no owned Java or display process. +These scene checks do not establish native equipment pixel parity. + +Native missing-name behavior, component lifetime, external render-offset producers, full resource admission, native cube assembly, and visible parity remain unverified. +The original binary, synthetic native fixtures, and diagnostic variants remain private. diff --git a/patches/viafabricplus-bedrock/upstreamable/0018-preserve-authoritative-bedrock-cartography-results.patch b/patches/viafabricplus-bedrock/upstreamable/0018-preserve-authoritative-bedrock-cartography-results.patch index eadf0eed..52b67cb4 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0018-preserve-authoritative-bedrock-cartography-results.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0018-preserve-authoritative-bedrock-cartography-results.patch @@ -1,4 +1,4 @@ -From 07e374711287c0f205a88e5285790356ae37744c Mon Sep 17 00:00:00 2001 +From af6acd48c42d22a94a6038e054beb456f976e32e 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(inventory): render authoritative native Bedrock @@ -627,10 +627,10 @@ index 6e1452c..eadb404 100644 + "cartography.viafabricplus.missing_images": "Built-in images unavailable" } diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json -index 64e5f9e..ac14803 100644 +index 73c5300..6a653de 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -62,6 +62,10 @@ +@@ -79,6 +79,10 @@ "features.creative.MixinCreativeModeInventoryScreen", "features.creative.MixinCreativeModeTab_ItemDisplayBuilder", "features.creative.MixinCreativeModeTabs", diff --git a/patches/viafabricplus-bedrock/upstreamable/0018-render-native-sidebar-title-images.patch b/patches/viafabricplus-bedrock/upstreamable/0018-render-native-sidebar-title-images.patch index 46ebfec5..03b845e1 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0018-render-native-sidebar-title-images.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0018-render-native-sidebar-title-images.patch @@ -1,4 +1,4 @@ -From 2aca92184327f76624e37315cd8065c640c7f625 Mon Sep 17 00:00:00 2001 +From ca6d924ea9e3e23bac5a23d9e236aa3f4cdc45ba 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(scoreboard): render native sidebar title images @@ -386,10 +386,10 @@ index 0000000..ca33ec1 + } +} diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json -index fe1cbd8..ac2b27f 100644 +index fc0709b..ae3fc7f 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -79,6 +79,7 @@ +@@ -96,6 +96,7 @@ "features.render.MixinBedrockNameTags", "features.render.MixinBedrockNameTagMetadata", "features.render.MixinBedrockNameTagRenderer", diff --git a/patches/viafabricplus-bedrock/upstreamable/0020-apply-server-movement-and-camera-permissions.patch b/patches/viafabricplus-bedrock/upstreamable/0020-apply-server-movement-and-camera-permissions.patch index 87f6900d..6dbd557e 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0020-apply-server-movement-and-camera-permissions.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0020-apply-server-movement-and-camera-permissions.patch @@ -1,4 +1,4 @@ -From a1da83b424f17a2707753504ef3926565703f9cd Mon Sep 17 00:00:00 2001 +From 59c4da79f4cefc8f64d7c172b817c05d8e1df1f1 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(input): apply server movement and camera permissions @@ -660,10 +660,10 @@ index 0000000..25d47e7 + } +} diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json -index ac14803..f2bbaa7 100644 +index 6a653de..9c547b6 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -114,6 +114,12 @@ +@@ -131,6 +131,12 @@ "features.render.MixinBedrockModelPartTransforms", "features.render.MixinPlayerSkinWidget", "features.render.MixinGuiSkinRenderer", diff --git a/patches/viafabricplus-bedrock/upstreamable/0021-apply-server-element-visibility-restrictions.patch b/patches/viafabricplus-bedrock/upstreamable/0021-apply-server-element-visibility-restrictions.patch index 20a04ffd..22a0d9b9 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0021-apply-server-element-visibility-restrictions.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0021-apply-server-element-visibility-restrictions.patch @@ -1,4 +1,4 @@ -From 88ff784fe2212e46ad6b238e210caf93a8134f03 Mon Sep 17 00:00:00 2001 +From 5b79bae64353363202caf41708c18d085409847b 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): apply server element visibility restrictions @@ -256,10 +256,10 @@ index 0000000..9f4d7b5 + +} diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json -index f2bbaa7..8c31f08 100644 +index 9c547b6..faabab7 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -136,7 +136,9 @@ +@@ -153,7 +153,9 @@ "features.sound.MixinBedrockSoundEngine", "features.particle.MixinBedrockParticleEngine", "features.particle.MixinBedrockParticlePipelines", 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 0cdaab4a..f78738a2 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 7e0a4dd15d31e5343d9791ed22aabd1c67739fa8 Mon Sep 17 00:00:00 2001 +From 56f6e5d1906d5bd3877d869c79c38bcda2ca13fc 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 @@ -107,14 +107,31 @@ Two actual client recordings verify exact request/reply sequences, complete/empty/partial aim objects, native command restoration and normal cleanup. Reticle, target/world queries, interaction rotation, local F5 transitions and full native event scheduling remain open. + +Consume the generalized attached-camera rendering mode and negotiated +camera_follow_v2 state. Fixed-boom and orbit presets use the shared core +frame runtime before blend, shake, projection, culling and sound-listener +extraction. Fixed presets retain orientation across actor rotation and +ignore orbit input. The existing native actor identity, lifetime, bounds +and origin-offset producers remain shared. Their complete native pivot +and world geometry semantics still need comparison; this integration does +not establish full native camera or pixel parity. + +Keep native attachment identity separate from render-sample readiness. +An actor with a native lifetime but pending Java entity or matching native +bounds remains a known actor with no ready frame sample. Only disappearance +of the native identity is reported as actor loss. This lets the shared +runtime retire failed fixed-boom attachments without treating delayed +render metadata as removal, while preserving existing target-tracker +behavior until its physical center is available. --- .../bedrock/ViaFabricPlusBedrock.java | 44 +++++ .../camera/BedrockCameraAimAssist.java | 70 +++++++ .../bedrock/camera/BedrockCameraFade.java | 40 ++++ .../bedrock/camera/BedrockCameraFov.java | 68 +++++++ - .../bedrock/camera/BedrockCameraPosition.java | 176 ++++++++++++++++++ + .../bedrock/camera/BedrockCameraPosition.java | 180 ++++++++++++++++++ .../bedrock/camera/BedrockCameraShake.java | 49 +++++ - .../bedrock/camera/BedrockCameraWorld.java | 95 ++++++++++ + .../bedrock/camera/BedrockCameraWorld.java | 95 +++++++++ .../camera/CameraAimAssistControlPayload.java | 22 +++ .../camera/CameraAimAssistPayload.java | 22 +++ .../bedrock/camera/CameraFadePayload.java | 22 +++ @@ -126,15 +143,15 @@ transitions and full native event scheduling remain open. .../bedrock/camera/CameraTrackingPayload.java | 22 +++ .../render/MixinBedrockCameraFade.java | 23 +++ .../render/MixinBedrockCameraFov.java | 23 +++ - .../render/MixinBedrockCameraPerspective.java | 21 +++ - .../render/MixinBedrockCameraShake.java | 73 ++++++++ + .../render/MixinBedrockCameraPerspective.java | 21 ++ + .../render/MixinBedrockCameraShake.java | 73 +++++++ .../render/MixinBedrockFollowInput.java | 23 +++ .../render/MixinMinecraftHudVisibility.java | 6 + .../sound/MixinBedrockCameraListener.java | 30 +++ .../bedrock/settings/BedrockSettings.java | 4 + .../viafabricplus-bedrock/lang/en_us.json | 1 + .../viafabricplus-bedrock.mixins.json | 8 +- - 26 files changed, 955 insertions(+), 1 deletion(-) + 26 files changed, 959 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 @@ -417,10 +434,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..1b56e6b +index 0000000..09af5ad --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/camera/BedrockCameraPosition.java -@@ -0,0 +1,176 @@ +@@ -0,0 +1,180 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -483,7 +500,7 @@ index 0000000..1b56e6b + + public static void turn(final float pitchDelta, final float yawDelta) { + final var requested = PENDING.isEmpty() ? state : PENDING.getLast(); -+ if (requested.mode() == Mode.FOLLOW_ORBIT && Float.isFinite(pitchDelta) && Float.isFinite(yawDelta)) { ++ if (requested.mode() == Mode.BOOM && Float.isFinite(pitchDelta) && Float.isFinite(yawDelta)) { + FOLLOW.input(requested.sequence(), pitchDelta, yawDelta); + } + } @@ -518,14 +535,14 @@ index 0000000..1b56e6b + if (preparingTarget) { + final CameraPositionState requested = PENDING.isEmpty() ? state : PENDING.getLast(); + // Detached targets have their own poses. Normal alignment supplies the local blend input. -+ mode = requested.mode() == Mode.FREE || requested.mode() == Mode.FOLLOW_ORBIT ? Mode.LOCAL : requested.mode(); ++ mode = requested.mode() == Mode.FREE || requested.mode() == Mode.BOOM ? Mode.LOCAL : requested.mode(); + } else { + mode = state.mode() == Mode.LOCAL || rendered == null ? Mode.LOCAL : Mode.values()[rendered.renderingMode()]; + } + return switch (mode) { + case LOCAL -> local; + case FIRST_PERSON -> CameraType.FIRST_PERSON; -+ case THIRD_PERSON, FREE, FOLLOW_ORBIT -> CameraType.THIRD_PERSON_BACK; ++ case THIRD_PERSON, FREE, BOOM -> CameraType.THIRD_PERSON_BACK; + case THIRD_PERSON_FRONT -> CameraType.THIRD_PERSON_FRONT; + }; + } @@ -555,9 +572,9 @@ index 0000000..1b56e6b + return null; + } + final CameraPose tracked = TRACKING.active(state.sequence(), normal.fov(), Mode.FREE.ordinal()); -+ final CameraPose follow = FOLLOW.active(state.sequence(), normal.fov(), Mode.FOLLOW_ORBIT.ordinal()); ++ final CameraPose follow = FOLLOW.active(state.sequence(), normal.fov(), Mode.BOOM.ordinal()); + final CameraPose target = switch (state.mode()) { -+ case FOLLOW_ORBIT -> follow != null ? follow : rendered != null ? rendered : normal; ++ case BOOM -> follow != null ? follow : rendered != null ? rendered : normal; + case FREE -> tracked != null ? tracked : CameraPose.fromDegrees(state.position(), state.pitch(), state.yaw(), normal.fov(), Mode.FREE.ordinal()); + default -> new CameraPose(normal.position(), normal.orientation(), normal.fov(), state.mode().ordinal()); + }; @@ -567,7 +584,8 @@ index 0000000..1b56e6b + + private static CameraTrackingRuntime.Actor targetActor(final long uniqueId) { + final var actor = followActor(uniqueId); -+ return actor == null ? null : new CameraTrackingRuntime.Actor(actor.lifetime(), new CameraTargetTracker.Sample(actor.center(), actor.yaw())); ++ return actor == null || actor.sample() == null ? null ++ : new CameraTrackingRuntime.Actor(actor.lifetime(), new CameraTargetTracker.Sample(actor.sample().center(), actor.sample().yaw())); + } + + private static CameraFollowRuntime.Actor localFollowActor() { @@ -577,7 +595,7 @@ index 0000000..1b56e6b + if (snapshot == null) return null; + final var position = player.getPosition(partialTick); + final var origin = new CameraPose.Vector((float) position.x, (float) position.y, (float) position.z); -+ return new CameraFollowRuntime.Actor(snapshot.lifetime(), origin, origin, player.getYRot(partialTick)); ++ return new CameraFollowRuntime.Actor(snapshot.lifetime(), new CameraFollowRuntime.Sample(origin, origin, player.getYRot(partialTick))); + } + + private static CameraFollowRuntime.Actor followActor(final long uniqueId) { @@ -585,16 +603,19 @@ index 0000000..1b56e6b + final var uuid = BedrockActorState.uuidByUniqueId(uniqueId); + if (level == null || uuid == null) return null; + final var snapshot = BedrockActorState.state(uuid); -+ final var bounds = BedrockActorState.bounds(uuid); ++ if (snapshot == null) return null; + final var entity = level.getEntity(uuid); -+ if (entity == null || snapshot == null || bounds == null || !bounds.lifetime().equals(snapshot.lifetime()) || bounds.height() == null) return null; ++ final var bounds = BedrockActorState.bounds(uuid); ++ if (entity == null || entity.isRemoved() || bounds == null || !bounds.lifetime().equals(snapshot.lifetime()) || bounds.height() == null) ++ return new CameraFollowRuntime.Actor(snapshot.lifetime(), null); + if (!Float.isFinite(bounds.height()) || bounds.height() < 0 || !Float.isFinite(snapshot.scale()) || snapshot.scale() < 0 -+ || !Float.isFinite(bounds.height() * snapshot.scale()) || bounds.height() * snapshot.scale() >= 1E9F) return null; ++ || !Float.isFinite(bounds.height() * snapshot.scale()) || bounds.height() * snapshot.scale() >= 1E9F) ++ return new CameraFollowRuntime.Actor(snapshot.lifetime(), null); + final float height = NativeActorBoundsMessage.scaledDimension(bounds.height(), snapshot.scale()); + final var position = entity.getPosition(partialTick); -+ return new CameraFollowRuntime.Actor(snapshot.lifetime(), ++ return new CameraFollowRuntime.Actor(snapshot.lifetime(), new CameraFollowRuntime.Sample( + new CameraPose.Vector((float) position.x, (float) position.y, (float) position.z), -+ new CameraPose.Vector((float) position.x, (float) position.y + height * .5F, (float) position.z), entity.getYRot(partialTick)); ++ new CameraPose.Vector((float) position.x, (float) position.y + height * .5F, (float) position.z), entity.getYRot(partialTick))); + } +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/camera/BedrockCameraShake.java b/src/main/java/com/viaversion/viafabricplus/bedrock/camera/BedrockCameraShake.java @@ -1096,7 +1117,7 @@ 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..6c43224 +index 0000000..bce161d --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinBedrockCameraShake.java @@ -0,0 +1,73 @@ @@ -1153,7 +1174,7 @@ index 0000000..6c43224 + final var angles = pose.orientation().angles(); + this.setRotation(180F - angles.y() / CameraPose.DEGREES, -angles.x() / CameraPose.DEGREES); + this.setPosition(new Vec3(pose.position().x(), pose.position().y(), pose.position().z())); -+ this.detached = pose.renderingMode() == CameraPositionState.Mode.FOLLOW_ORBIT.ordinal() ++ this.detached = pose.renderingMode() == CameraPositionState.Mode.BOOM.ordinal() + || pose.renderingMode() == CameraPositionState.Mode.FREE.ordinal() + || pose.renderingMode() == CameraPositionState.Mode.THIRD_PERSON.ordinal() + || pose.renderingMode() == CameraPositionState.Mode.THIRD_PERSON_FRONT.ordinal(); @@ -1304,10 +1325,10 @@ 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 8c31f08..f5e1eea 100644 +index faabab7..103a613 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -110,6 +110,10 @@ +@@ -127,6 +127,10 @@ "features.render.MixinBedrockFirstPersonVisibility", "features.render.MixinBedrockFirstPersonBobbing", "features.render.MixinBedrockFirstPersonProjection", @@ -1318,7 +1339,7 @@ index 8c31f08..f5e1eea 100644 "features.render.MixinBedrockHandItemLifecycle", "features.render.MixinBedrockModelPartTransforms", "features.render.MixinPlayerSkinWidget", -@@ -134,11 +138,13 @@ +@@ -151,11 +155,13 @@ "features.sound.MixinBedrockSoundManager", "features.sound.MixinBedrockSoundCaptions", "features.sound.MixinBedrockSoundEngine", 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 27df927e..de6f627a 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 @@ -261,7 +261,7 @@ This reception supplies state for native aim execution. World queries, selected hits, reticles, and interaction rotation remain incomplete. The activation request section below records the camera-preset settings path. The shared control policy matches 2,048 native lookup results, but current-camera identifier production remains unverified. -Fixed-boom rendering and full control-scheme movement mappings remain separate unfinished work. +The fixed-boom section below records its frame integration. Full control-scheme movement mappings remain unfinished. An isolated connection to BDS 1.26.51.1 records 39 client-thread observations, including 32 accepted aim views. It verifies six setting transitions, all five control bytes, free-camera context and clear, and lifetime-validated actor bindings. @@ -295,3 +295,50 @@ A non-override camera restores settings from a real `/aimassist` command. The runs contain 39 and 38 client-thread observations, reach gameplay, and close normally. All owned Java, display, and server identities are gone; artifact checksums match and private fixture changes are restored. Local F5 transitions, native target selection, reticles, interaction rotation, and full event scheduling remain unverified. + +## Fixed-boom frame integration + +The add-on now consumes the version-two attached-camera payload and shared `BOOM` mode. +Fixed-boom and orbit instances run before the camera blend, shake, projection, culling, and sound-listener extraction. +The active preset selects the fixed or orbit path. +Fixed presets ignore orbit deltas and retain their orientation while their actor moves or turns. +The native actor identity, lifetime, physical bounds, and player-origin inputs remain shared with follow-orbit. +Clear and disconnect use the existing lifecycle. + +Core compares 192 original native fixed-boom frame controls and retains its existing follow-frame and collision comparisons. +Those controls supply actor pivots and component inputs. +They do not establish complete native pivot production, input mappings, world geometry, ECS scheduling, or pixels. +The [core camera notes](../../viabedrock/upstreamable/0089-resolve-and-transport-native-camera-instructions.pr.md#fixed-boom-execution) record the functions and boundaries. + +The exact installed combined build completes a fresh BDS 1.26.51.1 run over direct NetherNet. +All eleven stages pass across 48 read-only observations of the actual Minecraft camera. +The stages cover fixed defaults, isolated player turning, explicit rotation, player movement, entity attachment, actor turning and movement, detachment, orbit switching, and clear. +View and entity offsets retain their expected axes. +Installed checksums match, and all owned client, display, and server processes stop normally. +The final four-project build passes 2,600 tests, with 167 optional skips and no failures or errors. +Both packaged core copies match all 1,412 source classes, and the add-on matches all 1,139 production client classes. +These results retain the native pivot, input, geometry, and visible-parity limits above. + + +## Attachment identity and pending frames + +The add-on now supplies native actor identity separately from a ready render sample. +A missing Java entity or matching physical bounds retains the known identity until those inputs become available. +The shared runtime therefore distinguishes pending render data from native actor loss. +Target tracking still waits for its physical center. + +Fixed-boom retires a lost observed attachment and resumes the local player. +Ordinary snapshots and preset reselection cannot reacquire it; a new attachment command can. +Orbit retains its existing separate behavior. +The [core retirement notes](../../viabedrock/upstreamable/0089-resolve-and-transport-native-camera-instructions.pr.md#fixed-attachment-retirement) record 32 original native controls and their boundaries. + +The exact installed combined build passes a fresh BDS 1.26.51.1 run over direct NetherNet. +Read-only observations verify 42 camera samples across nine stages, including removal, player movement, replacement presence, reattachment, detach, and clear. +Artifact checksums match, and all nine owned client, display, and server identities are gone after normal shutdown. +The live replacement uses a new actor ID; unit and native controls cover reused identity. +Initial command-time ownership, native pivot height, post-loss orbit origin lookup, complete scheduling, and native pixels remain unverified. + +The combined four-project build passes 2,604 tests, with 167 optional skips and no failures or errors. +Native camera, particle, attachable, and Iris GPU comparisons run without skips. +All 1,413 core classes match both packaged copies, and all 1,139 production client classes match the add-on. +The core and add-on stacks replay successfully. 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 026c7517..7eefdff6 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 b4b3e4a8923ade70518ecc1c5cb5c76df7614a3e Mon Sep 17 00:00:00 2001 +From 20235cdeeab898d9a3969640debb8e7e749adb9c 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 @@ -420,58 +420,298 @@ Visit shape boxes directly to reduce allocations while falling. The matching Bedrock 1.26.51.1 contact kernel remains unchanged. Regression tests cover all axes, both movement directions, map coordinates and edge support; the old adapter fails the new contact controls. + +Open the movement-effect input record before local simulation and reconcile +queued component commands before local player physics. Retain admission +order and complete the record with the existing completed frame clock. +The shared core journal preserves native command/input/countdown ordering +and the exact oldest wire clock through movement-effect revision 3. + +Component commands share the physical frame lifecycle. The native +generated-input oracle covers duration activity and queue ordering. +Native world trajectory parity remains unverified. + +Advance the physical frame and effect clock even when prediction transport +is unavailable. The channel advertisement controls payload publication, +not local effect lifetimes or captured input record completion. + +Retain typed actor graph snapshots and replay actual ClientLevel root and +passenger ticks with recorded input polls. Restore owned fluid trackers, +attributes and modifier ownership, poses, seat links, boat state and horse +jump state. Refresh each later snapshot before its queued commands and +physical input. The ordinary current physics step still executes once. + +Keep inventory, item consumption, equipment ticking, pickups and portal +presentation outside historical execution. Record equipment and item-use +observations as physical inputs. Journal exterior motion and passenger +operations instead of replacing corrected motion with old position deltas. +Use a thread-scoped guard for the active connection's packets, sounds and +particles. Preserve live consumption state and network bookkeeping. + +Restore current state for actors that no longer belong to local prediction, +while preserving the jointly replayed seat graph. Bind snapshots to actual +actor instances, world, UUID and native lifetime. New authority epochs use +a fresh observed vehicle state, rather than a former control lease. + +Native effect timestamps select historical input frames. Negotiated +positional and vehicle corrections now use the same physical history. +Native trajectory parity and the complete movement goal remain unverified. + +Capture only each frame's actual physical and seat participants. Refresh +historical snapshots against their original layout, so visiting more mounts +cannot inflate every later snapshot or equipment observation. Retire old +participant index references with history eviction. Validate both ends of +each frame before replay, including actors introduced between captures. + +Consume revision-two player and vehicle corrections as retained frame +commands. Reproject native unsigned clocks at receipt, compare current +kinematics against the retained snapshot with native float tolerances, +and run the actual recorded root/passenger tick suffix when required. +Keep current-clock commands for the upcoming capture and retain future +commands without dirtying physics. Apply exact motion, current rotation, +optional boat angular velocity and independently float-rounded bounds. +Preserve the restored previous transform until ordinary input processing. + +Pair commands with standard Java position packets while preserving their +normal acknowledgments, block-prediction bookkeeping and mining stop. +Skip only paired position/interpolation writes. Expired world, actor, +lifetime and control bindings consume their paired packet without snapping +another actor. Send final standard coordinates before the completed +prediction payload so translated state agrees with replayed physics. + +Fence commands by actual actor/world/lifetime and continuous mount control. +Accepted identity transfers retire the lease even if dismount and remount +occur between physics frames; same-identity authority revisions do not +create a new lease. Recheck command applicability on every later replay. +Commands that lose authority cannot restore or execute their old physics. + +The core's 128 original native receiver controls verify clock placement. +Targeted tests verify conditional replay, expired ownership, exact native +command bounds, previous transform preservation, optional boat angular +state and same-identity revision handling. Historical collisions, managed +interpolation and complete native input phase alignment remain unfinished. + +Additional identity regressions reject a reused Java ID, another live actor, +a replaced world, a new native lifetime, removed actors, missing historical +primary control, and a different continuous control lease. + +A direct native BDS 1.26.51.1 geyser run records 1,233 completed physics +frames, 23 incoming correction commands and 22 applications. The last +current-clock command awaits a capture after recorder shutdown. Three +rewinds execute four historical physical steps; food, held count and tick +count remain unchanged across those boundaries. Four command applications +also preserve active item use. Eleven apples finish during live input. +These controls do not establish delayed historical replay during active +consumption, live vehicle correction parity, or complete native trajectories. + +Retain each ordinary movement request's immutable collision geometry and +query bounds as physical input. Replay receives a separate mutable cache, +so querying newly exposed regions cannot replace the original frame input. +Native 1.26.51.1 copies retained shapes only below squared distance four, +caps request motion at sixteen blocks, and appends newly queried regions. +Port its float query expansion, partition order and cache-bound updates. + +Bind cached actor shapes to their original world, instance, UUID and native +lifetime before refreshing their current geometry. Stale references retain +the captured shape. Exact native MovementInterpolator sampling, unloaded +region state and complete collision traversal parity remain unfinished. + +Verify cache arithmetic against 192 original native instruction controls. +Targeted tests cover changed live geometry, repeated replay isolation, the +strict reuse boundary, actor identity and zero-displacement input capture. + +Gather world shapes in the native X/Z/Y traversal rather than Java's block +collision iterator. Query only available columns without creating chunks, +and include the native padded unavailable-chunk barriers and below-world +floor. Retained frames keep those barriers after a live chunk arrives; the +strict reuse boundary discards them and queries current availability. + +The shared visitor matches 64 fresh original native instruction controls. +Client controls exercise actual voxel collision clipping, repeated replay +after chunk arrival, fresh queries at the reuse boundary, stationary world +geometry and below-world clipping. Removing barrier emission fails the +expected 0.4-block clipping control with an unobstructed 1.0 displacement. + +Native block shape generation, border-block permissions, all chunk-ready +stages and the near-unloaded component flag still need separate producer +and consumer verification. Actor refresh and full trajectory parity remain +unfinished. + +Receive full-precision actor motion commands through a negotiated channel. +Apply each command once to the live actor without recording an untimed +external impulse. Keep its native input-tick placement in the physical +journal, sharing receipt order with position corrections. Old motion ticks +clamp to the oldest retained input; present ticks enter the next input, +and future commands remain available without forcing replay. + +Bind every command to its connection, level, actor UUID/native lifetime, +and controlled vehicle epoch/lease. Mounted player motion remains eligible. +Retain the original velocity when a later correction reexecutes that frame. +Fresh1.26.51.1 original constructor/receiver/application/replay controls +cover48 cases. Optional Java journal comparisons cover24 accepted-controller +cases; remaining controls exercise native controller rejection separately. +Native component tables and simple physics are supplied fixture inputs; +full live knockback/mount trajectories and remote collider sources remain +unfinished. + +Keep replay ownership validation separate from restored actor metadata. +Historical frames retain their original native lifetime and properties, +but actor, collider and command admission compare that lifetime with the +current registry. Replaced, removed or cleared actors cannot become valid +again because a previous frame restores their metadata. + +Five real-registry regressions cover retirement, same-lifetime updates +and historical recapture. Independent negative variants reject historical +actor validation, historical collider validation and relabeling restored +physics with the live replacement lifetime. + +Use negotiated admitted native movement for replay collider refresh, +with retained interpolation targets and native float dimensions/origin +offsets. Do not follow Java transforms or Java interpolation handlers. +Capture immutable interpolation inputs with each cached shape; read +eligible network history live and keep current native lifetime +validation. + +The native source-selector and box reference has 256 cases. Actual +previous collider code fails four of six new registry regressions, +including both recorded source-selection counterexamples. Normal source- +counter advancement stays outside historical replay. Complete remote +sampling, factory classification and native world trajectories remain +separate work. + +Use the explicit movement action when constructing source commands in +registry and collider regressions. The shared source codec now carries +remote-player interpolation clears; the client consumes them through its +existing lifetime-checked source channel and historical captures remain +immutable. --- build.gradle.kts | 2 +- - .../bedrock/ViaFabricPlusBedrock.java | 8 + + .../bedrock/ViaFabricPlusBedrock.java | 14 + .../access/IBedrockFlightMotion.java | 14 + .../access/IBedrockPlayerPosture.java | 14 + - .../mixin/features/movement/MixinAvatar.java | 27 ++ + .../movement/MixinAgePhysicsState.java | 37 ++ + .../mixin/features/movement/MixinAvatar.java | 27 + + .../movement/MixinBoatPhysicsState.java | 79 +++ + .../MixinClientInputPhysicsState.java | 26 + + .../MixinClientLevelPhysicsReplay.java | 36 ++ + .../MixinConnectionPhysicsReplay.java | 24 + + .../movement/MixinElytraPhysicsState.java | 36 ++ .../mixin/features/movement/MixinEntity.java | 25 +- - .../movement/MixinEntityFluidInteraction.java | 16 ++ - ...tyFluidInteraction_CurrentAccumulator.java | 49 ++-- - .../movement/MixinFireworkRocketEntity.java | 28 ++ - .../features/movement/MixinLivingEntity.java | 140 +++++++++ - .../features/movement/MixinLocalPlayer.java | 230 ++++++++++++++- - .../mixin/features/movement/MixinPlayer.java | 128 +++++++-- - .../MixinPlayerCorrectionPacketListener.java | 23 ++ - .../MixinPotentSulfurBlockEntity.java | 44 +++ - .../features/movement/MixinVoxelShape.java | 28 ++ - .../bedrock/input/BedrockMovementEffects.java | 96 +++++++ - .../input/BedrockPlayerCorrections.java | 44 +++ - .../input/BedrockVehiclePredictions.java | 43 +++ - .../bedrock/input/FlightMovementPayload.java | 24 ++ - .../bedrock/input/MovementEffectPayload.java | 24 ++ - .../input/PlayerCorrectionPayload.java | 24 ++ - .../input/PlayerPredictionPayload.java | 24 ++ - .../input/UnderwaterMovementPayload.java | 24 ++ - .../VehiclePredictionContextPayload.java | 24 ++ - .../input/VehiclePredictionPayload.java | 24 ++ - .../input/VehiclePredictionSession.java | 84 ++++++ - .../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 +++++ - .../viafabricplus-bedrock.mixins.json | 5 + - .../input/BedrockVehiclePredictionsTest.java | 155 ++++++++++ - .../input/VehiclePredictionPayloadTest.java | 75 +++++ - .../input/VehiclePredictionSessionTest.java | 266 ++++++++++++++++++ - .../movement/BedrockClimbingPhysicsTest.java | 53 ++++ - .../BedrockCollisionContactsTest.java | 95 +++++++ - .../movement/BedrockFlightPhysicsTest.java | 130 +++++++++ - .../movement/BedrockFluidPhysicsTest.java | 121 ++++++++ - .../movement/BedrockGeyserPhysicsTest.java | 101 +++++++ - .../movement/BedrockPlayerPostureTest.java | 45 +++ - 42 files changed, 2614 insertions(+), 68 deletions(-) + .../movement/MixinEntityCollisionReplay.java | 54 ++ + .../movement/MixinEntityFluidInteraction.java | 16 + + ...tyFluidInteraction_CurrentAccumulator.java | 73 ++- + .../MixinEntityFluidPhysicsState.java | 51 ++ + .../MixinEntityFluidTrackerState.java | 33 ++ + .../movement/MixinEntityPhysicsState.java | 202 ++++++++ + .../movement/MixinFireworkRocketEntity.java | 28 + + .../movement/MixinGameModePhysicsReplay.java | 29 ++ + .../movement/MixinHorsePhysicsState.java | 63 +++ + .../movement/MixinLevelPhysicsReplay.java | 25 + + .../features/movement/MixinLivingEntity.java | 140 +++++ + .../movement/MixinLivingPhysicsState.java | 145 ++++++ + .../features/movement/MixinLocalPlayer.java | 361 ++++++++++++- + .../movement/MixinMobEffectPhysicsState.java | 18 + + .../movement/MixinParticlePhysicsReplay.java | 24 + + .../movement/MixinPassengerPhysicsReplay.java | 39 ++ + .../mixin/features/movement/MixinPlayer.java | 186 ++++++- + .../MixinPlayerCorrectionPacketListener.java | 79 +++ + .../MixinPotentSulfurBlockEntity.java | 44 ++ + .../movement/MixinSoundPhysicsReplay.java | 23 + + .../movement/MixinSwingPhysicsState.java | 32 ++ + .../features/movement/MixinVoxelShape.java | 28 + + .../movement/MixinWalkPhysicsState.java | 32 ++ + .../bedrock/input/ActorMotionPayload.java | 22 + + .../input/BedrockInputPermissions.java | 4 +- + .../bedrock/input/BedrockMovementEffects.java | 161 ++++++ + .../bedrock/input/BedrockPhysicalReplay.java | 483 ++++++++++++++++++ + .../input/BedrockPlayerCorrections.java | 63 +++ + .../input/BedrockVehiclePredictions.java | 48 ++ + .../bedrock/input/FlightMovementPayload.java | 24 + + .../bedrock/input/MovementEffectPayload.java | 24 + + .../input/PlayerCorrectionPayload.java | 24 + + .../input/PlayerPredictionPayload.java | 24 + + .../input/UnderwaterMovementPayload.java | 24 + + .../VehiclePredictionContextPayload.java | 24 + + .../input/VehiclePredictionPayload.java | 24 + + .../input/VehiclePredictionSession.java | 91 ++++ + .../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/replay/ActorSpatialPayload.java | 22 + + .../replay/AttributePhysicsState.java | 84 +++ + .../movement/replay/EffectPhysicsState.java | 90 ++++ + .../movement/replay/FluidPhysicsState.java | 26 + + .../movement/replay/PhysicalActorState.java | 91 ++++ + .../movement/replay/PhysicalBoundingBox.java | 16 + + .../replay/PhysicalCollisionCache.java | 98 ++++ + .../movement/replay/PhysicalCollisions.java | 104 ++++ + .../movement/replay/PhysicalGraphState.java | 90 ++++ + .../movement/replay/PhysicalInput.java | 20 + + .../movement/replay/PhysicalItemUse.java | 21 + + .../replay/PhysicalMovementCorrection.java | 35 ++ + .../replay/PhysicalReplayContext.java | 234 +++++++++ + .../replay/PhysicalReplayHistory.java | 190 +++++++ + .../movement/replay/PhysicalState.java | 140 +++++ + .../movement/replay/PhysicalStateAccess.java | 43 ++ + .../bedrock/render/BedrockActorState.java | 39 +- + .../viafabricplus-bedrock.accesswidener | 5 + + .../viafabricplus-bedrock.mixins.json | 25 + + .../input/BedrockVehiclePredictionsTest.java | 155 ++++++ + .../input/VehiclePredictionPayloadTest.java | 75 +++ + .../input/VehiclePredictionSessionTest.java | 284 ++++++++++ + .../movement/BedrockClimbingPhysicsTest.java | 53 ++ + .../BedrockCollisionContactsTest.java | 95 ++++ + .../movement/BedrockFlightPhysicsTest.java | 130 +++++ + .../movement/BedrockFluidPhysicsTest.java | 121 +++++ + .../movement/BedrockGeyserPhysicsTest.java | 101 ++++ + .../movement/BedrockPlayerPostureTest.java | 45 ++ + .../replay/AttributePhysicsStateTest.java | 101 ++++ + .../replay/PhysicalBoundingBoxTest.java | 21 + + .../replay/PhysicalCollisionCacheTest.java | 204 ++++++++ + .../PhysicalCorrectionIdentityTest.java | 149 ++++++ + .../movement/replay/PhysicalItemUseTest.java | 41 ++ + .../replay/PhysicalMotionReplayTest.java | 102 ++++ + .../PhysicalMovementCorrectionTest.java | 80 +++ + .../replay/PhysicalReplayContextTest.java | 90 ++++ + .../replay/PhysicalReplayHistoryTest.java | 272 ++++++++++ + .../movement/replay/PhysicalStateTest.java | 124 +++++ + .../replay/PhysicalWorldCollisionTest.java | 111 ++++ + .../render/PhysicalColliderSourcesTest.java | 151 ++++++ + .../render/PhysicalReplayLifetimeTest.java | 151 ++++++ + 96 files changed, 7479 insertions(+), 74 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/mixin/features/movement/MixinAgePhysicsState.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinAvatar.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinBoatPhysicsState.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinClientInputPhysicsState.java + 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/MixinConnectionPhysicsReplay.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 + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinEntityFluidTrackerState.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinEntityPhysicsState.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinFireworkRocketEntity.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinGameModePhysicsReplay.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinHorsePhysicsState.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinLevelPhysicsReplay.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinLivingPhysicsState.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinMobEffectPhysicsState.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinParticlePhysicsReplay.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinPassengerPhysicsReplay.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinPlayerCorrectionPacketListener.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinPotentSulfurBlockEntity.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinSoundPhysicsReplay.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinSwingPhysicsState.java 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/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 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 @@ -488,6 +728,22 @@ support; the old adapter fails the new contact controls. create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/movement/BedrockFluidPhysics.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/movement/BedrockGeyserPhysics.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/movement/BedrockPlayerPosture.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/ActorSpatialPayload.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/AttributePhysicsState.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/EffectPhysicsState.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/FluidPhysicsState.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalActorState.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalBoundingBox.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalCollisionCache.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalCollisions.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalGraphState.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalInput.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalItemUse.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalMovementCorrection.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalReplayContext.java + 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/BedrockVehiclePredictionsTest.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 @@ -497,26 +753,53 @@ support; the old adapter fails the new contact controls. create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockFluidPhysicsTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockGeyserPhysicsTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockPlayerPostureTest.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/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 + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalItemUseTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalMotionReplayTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalMovementCorrectionTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalReplayContextTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalReplayHistoryTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalStateTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalWorldCollisionTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/PhysicalColliderSourcesTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/PhysicalReplayLifetimeTest.java diff --git a/build.gradle.kts b/build.gradle.kts -index d7886b4..cb6e557 100644 +index 159fe40..0340595 100644 --- a/build.gradle.kts +++ b/build.gradle.kts -@@ -22,8 +22,8 @@ tasks.withType().configureEach { +@@ -22,7 +22,6 @@ tasks.withType().configureEach { } dependencies { - testImplementation("org.mockito:mockito-core:5.24.0") implementation(viaFabricPlusDependency) + // Optional integration only. Iris supplies these classes when installed. + compileOnly("maven.modrinth:iris:vTN4NRGW") +@@ -30,6 +29,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") + testImplementation("org.mockito:mockito-core:5.24.0") 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 f4ae50f..d731fe6 100644 +index f4ae50f..214970c 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java -@@ -66,6 +66,10 @@ import com.viaversion.viafabricplus.bedrock.hud.BedrockHudVisibility; +@@ -21,6 +21,7 @@ + + package com.viaversion.viafabricplus.bedrock; + ++import com.viaversion.viafabricplus.bedrock.movement.replay.ActorSpatialPayload; + import com.viaversion.viafabricplus.bedrock.render.BedrockPlayerSkins; + import com.viaversion.viafabricplus.bedrock.render.NativeSkinPayload; + import com.viaversion.viafabricplus.bedrock.render.NativeCustomEntityPayload; +@@ -66,6 +67,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; @@ -527,7 +810,19 @@ index f4ae50f..d731fe6 100644 import com.viaversion.viafabricplus.bedrock.appearance.BedrockAppearanceStore; import com.viaversion.viafabricplus.bedrock.appearance.ViaFabricPlusSkinProvider; import com.viaversion.viafabricplus.bedrock.appearance.ViaFabricPlusBuiltinResourcePackProvider; -@@ -141,6 +145,10 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { +@@ -125,6 +130,11 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { + ClientPlayConnectionEvents.INIT.register((listener, client) -> + ClientPlayNetworking.registerReceiver(NativeActorBoundsPayload.TYPE, + (payload, context) -> BedrockActorState.accept(listener.getConnection(), payload.message()))); ++ PayloadTypeRegistry.clientboundPlay().register(ActorSpatialPayload.TYPE, ++ ActorSpatialPayload.CODEC); ++ ClientPlayConnectionEvents.INIT.register((listener, client) -> ++ ClientPlayNetworking.registerReceiver(ActorSpatialPayload.TYPE, ++ (payload, context) -> BedrockActorState.accept(listener.getConnection(), payload.message()))); + PayloadTypeRegistry.clientboundPlay().register(NativeAnimationPayload.TYPE, NativeAnimationPayload.CODEC); + ClientPlayConnectionEvents.INIT.register((listener, client) -> + ClientPlayNetworking.registerReceiver(NativeAnimationPayload.TYPE, +@@ -141,6 +151,10 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { ClientPlayNetworking.registerGlobalReceiver(NativeParticlePlaybackPayload.TYPE, (payload, context) -> BedrockServerParticles.accept(payload.message())); PayloadTypeRegistry.serverboundPlay().register(RawInputPayload.TYPE, RawInputPayload.CODEC); @@ -578,6 +873,49 @@ index 0000000..c0be88a + PlayerPosture viaFabricPlusBedrock$posture(); + +} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinAgePhysicsState.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinAgePhysicsState.java +new file mode 100644 +index 0000000..4f709fe +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinAgePhysicsState.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.injection.mixin.features.movement; ++ ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalState; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalStateAccess; ++import net.minecraft.network.syncher.EntityDataAccessor; ++import net.minecraft.world.entity.AgeableMob; ++import org.spongepowered.asm.mixin.Final; ++import org.spongepowered.asm.mixin.Mixin; ++import org.spongepowered.asm.mixin.Shadow; ++ ++@Mixin(AgeableMob.class) ++public abstract class MixinAgePhysicsState implements PhysicalStateAccess.Age { ++ @Shadow @Final private static EntityDataAccessor DATA_BABY_ID; ++ @Shadow @Final private static EntityDataAccessor AGE_LOCKED; ++ @Shadow protected int age; ++ @Shadow protected int forcedAge; ++ @Shadow protected int forcedAgeTimer; ++ @Shadow protected int ageLockParticleTimer; ++ ++ @Override ++ public void captureAgePhysics(final PhysicalState.Builder state) { ++ final var data = ((AgeableMob) (Object) this).getEntityData(); ++ state.capture(Field.BABY, data.get(DATA_BABY_ID), value -> data.set(DATA_BABY_ID, value)); ++ state.capture(Field.LOCKED, data.get(AGE_LOCKED), value -> data.set(AGE_LOCKED, value)); ++ state.capture(Field.AGE, this.age, value -> this.age = value); ++ state.capture(Field.FORCED_AGE, this.forcedAge, value -> this.forcedAge = value); ++ state.capture(Field.FORCED_TIMER, this.forcedAgeTimer, value -> this.forcedAgeTimer = value); ++ state.capture(Field.PARTICLE_TIMER, this.ageLockParticleTimer, value -> this.ageLockParticleTimer = value); ++ } ++ ++ private enum Field { BABY, LOCKED, AGE, FORCED_AGE, FORCED_TIMER, PARTICLE_TIMER } ++} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinAvatar.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinAvatar.java new file mode 100644 index 0000000..e2f91b4 @@ -611,6 +949,237 @@ 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 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinBoatPhysicsState.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.injection.mixin.features.movement; ++ ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalState; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalStateAccess; ++import net.minecraft.network.syncher.EntityDataAccessor; ++import net.minecraft.world.entity.vehicle.boat.AbstractBoat; ++import org.spongepowered.asm.mixin.Final; ++import org.spongepowered.asm.mixin.Mixin; ++import org.spongepowered.asm.mixin.Shadow; ++ ++@Mixin(AbstractBoat.class) ++public abstract class MixinBoatPhysicsState implements PhysicalStateAccess.Boat { ++ @Shadow private float outOfControlTicks; ++ @Shadow private float deltaRotation; ++ @Shadow private boolean inputLeft; ++ @Shadow private boolean inputRight; ++ @Shadow private boolean inputUp; ++ @Shadow private boolean inputDown; ++ @Shadow private double waterLevel; ++ @Shadow private float landFriction; ++ @Shadow private AbstractBoat.Status status; ++ @Shadow private AbstractBoat.Status oldStatus; ++ @Shadow private double lastYd; ++ @Shadow private boolean isAboveBubbleColumn; ++ @Shadow private boolean bubbleColumnDirectionIsDown; ++ @Shadow private float bubbleMultiplier; ++ @Shadow private float bubbleAngle; ++ @Shadow private float bubbleAngleO; ++ @Shadow @Final private float[] paddlePositions; ++ @Shadow @Final private static EntityDataAccessor DATA_ID_PADDLE_LEFT; ++ @Shadow @Final private static EntityDataAccessor DATA_ID_PADDLE_RIGHT; ++ @Shadow @Final private static EntityDataAccessor DATA_ID_BUBBLE_TIME; ++ ++ @Override ++ public void captureBoatPhysics(final PhysicalState.Builder state) { ++ final var data = ((AbstractBoat) (Object) this).getEntityData(); ++ state.capture(Field.PADDLE_LEFT, data.get(DATA_ID_PADDLE_LEFT), value -> data.set(DATA_ID_PADDLE_LEFT, value)); ++ state.capture(Field.PADDLE_RIGHT, data.get(DATA_ID_PADDLE_RIGHT), value -> data.set(DATA_ID_PADDLE_RIGHT, value)); ++ state.capture(Field.BUBBLE_TIME, data.get(DATA_ID_BUBBLE_TIME), value -> data.set(DATA_ID_BUBBLE_TIME, value)); ++ state.capture(Field.PADDLE_POSITION_LEFT, this.paddlePositions[0], value -> this.paddlePositions[0] = value); ++ state.capture(Field.PADDLE_POSITION_RIGHT, this.paddlePositions[1], value -> this.paddlePositions[1] = value); ++ state.capture(Field.OUT_OF_CONTROL_TICKS, this.outOfControlTicks, value -> this.outOfControlTicks = value); ++ state.capture(Field.DELTA_ROTATION, this.deltaRotation, value -> this.deltaRotation = value); ++ state.capture(Field.INPUT_LEFT, this.inputLeft, value -> this.inputLeft = value); ++ state.capture(Field.INPUT_RIGHT, this.inputRight, value -> this.inputRight = value); ++ state.capture(Field.INPUT_UP, this.inputUp, value -> this.inputUp = value); ++ state.capture(Field.INPUT_DOWN, this.inputDown, value -> this.inputDown = value); ++ state.capture(Field.WATER_LEVEL, this.waterLevel, value -> this.waterLevel = value); ++ state.capture(Field.LAND_FRICTION, this.landFriction, value -> this.landFriction = value); ++ state.capture(Field.STATUS, this.status, value -> this.status = value); ++ state.capture(Field.OLD_STATUS, this.oldStatus, value -> this.oldStatus = value); ++ state.capture(Field.LAST_YD, this.lastYd, value -> this.lastYd = value); ++ state.capture(Field.IS_ABOVE_BUBBLE_COLUMN, this.isAboveBubbleColumn, value -> this.isAboveBubbleColumn = value); ++ state.capture(Field.BUBBLE_COLUMN_DIRECTION_IS_DOWN, this.bubbleColumnDirectionIsDown, value -> this.bubbleColumnDirectionIsDown = value); ++ state.capture(Field.BUBBLE_MULTIPLIER, this.bubbleMultiplier, value -> this.bubbleMultiplier = value); ++ state.capture(Field.BUBBLE_ANGLE, this.bubbleAngle, value -> this.bubbleAngle = value); ++ state.capture(Field.BUBBLE_ANGLE_O, this.bubbleAngleO, value -> this.bubbleAngleO = value); ++ } ++ ++ @Override ++ public float angularVelocity() { ++ return this.deltaRotation; ++ } ++ ++ @Override ++ public void correctAngularVelocity(final float value) { ++ this.deltaRotation = value; ++ } ++ ++ private enum Field { ++ PADDLE_LEFT, PADDLE_RIGHT, BUBBLE_TIME, PADDLE_POSITION_LEFT, PADDLE_POSITION_RIGHT, ++ OUT_OF_CONTROL_TICKS, DELTA_ROTATION, INPUT_LEFT, INPUT_RIGHT, INPUT_UP, INPUT_DOWN, WATER_LEVEL, LAND_FRICTION, STATUS, OLD_STATUS, LAST_YD, IS_ABOVE_BUBBLE_COLUMN, BUBBLE_COLUMN_DIRECTION_IS_DOWN, BUBBLE_MULTIPLIER, BUBBLE_ANGLE, BUBBLE_ANGLE_O ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinClientInputPhysicsState.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinClientInputPhysicsState.java +new file mode 100644 +index 0000000..786969b +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinClientInputPhysicsState.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.injection.mixin.features.movement; ++ ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalInput; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalStateAccess; ++import net.minecraft.client.player.ClientInput; ++import net.minecraft.world.entity.player.Input; ++import net.minecraft.world.phys.Vec2; ++import org.spongepowered.asm.mixin.Mixin; ++import org.spongepowered.asm.mixin.Shadow; ++ ++@Mixin(ClientInput.class) ++public abstract class MixinClientInputPhysicsState implements PhysicalStateAccess.Input { ++ @Shadow public Input keyPresses; ++ @Shadow protected Vec2 moveVector; ++ ++ @Override ++ public void restorePhysicalInput(final PhysicalInput value) { ++ this.keyPresses = value.keys(); ++ this.moveVector = new Vec2(value.strafe(), value.forward()); ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinClientLevelPhysicsReplay.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinClientLevelPhysicsReplay.java +new file mode 100644 +index 0000000..44c191c +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinClientLevelPhysicsReplay.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.injection.mixin.features.movement; ++ ++import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; ++import com.llamalad7.mixinextras.injector.wrapoperation.Operation; ++import com.viaversion.viafabricplus.bedrock.input.BedrockPhysicalReplay; ++import com.viaversion.viafabricplus.bedrock.render.BedrockActorState; ++import net.minecraft.client.multiplayer.ClientLevel; ++import net.minecraft.world.entity.Entity; ++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(ClientLevel.class) ++public abstract class MixinClientLevelPhysicsReplay { ++ @Inject(method = "tickEntities", at = @At("HEAD")) ++ private void captureBeforeEntitySimulation(final CallbackInfo ci) { ++ BedrockActorState.beforePhysics((ClientLevel) (Object) this); ++ BedrockPhysicalReplay.preparePhysics((ClientLevel) (Object) this); ++ } ++ ++ @WrapMethod(method = "tickNonPassenger") ++ private void recordRoot(final Entity actor, final Operation original) { ++ BedrockPhysicalReplay.rootTick((ClientLevel) (Object) this, actor, () -> original.call(actor)); ++ } ++ ++ @WrapMethod(method = "tickPassenger") ++ private void recordPassenger(final Entity vehicle, final Entity passenger, final Operation original) { ++ BedrockPhysicalReplay.passengerTick((ClientLevel) (Object) this, vehicle, passenger, () -> original.call(vehicle, passenger)); ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinConnectionPhysicsReplay.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinConnectionPhysicsReplay.java +new file mode 100644 +index 0000000..683d816 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinConnectionPhysicsReplay.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.movement; ++ ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalReplayContext; ++import io.netty.channel.ChannelFutureListener; ++import net.minecraft.network.Connection; ++import net.minecraft.network.protocol.Packet; ++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(Connection.class) ++public abstract class MixinConnectionPhysicsReplay { ++ @Inject(method = "send(Lnet/minecraft/network/protocol/Packet;Lio/netty/channel/ChannelFutureListener;Z)V", at = @At("HEAD"), cancellable = true) ++ private void retainSingleLivePacketStream(final Packet packet, final ChannelFutureListener listener, ++ final boolean flush, final CallbackInfo ci) { ++ if (PhysicalReplayContext.suppressesNetwork((Connection) (Object) this)) ci.cancel(); ++ } ++} +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 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinElytraPhysicsState.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.injection.mixin.features.movement; ++ ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalState; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalStateAccess; ++import net.minecraft.world.entity.ElytraAnimationState; ++import org.spongepowered.asm.mixin.Mixin; ++import org.spongepowered.asm.mixin.Shadow; ++ ++@Mixin(ElytraAnimationState.class) ++public abstract class MixinElytraPhysicsState implements PhysicalStateAccess.MotionAnimation { ++ @Shadow private float rotX; ++ @Shadow private float rotY; ++ @Shadow private float rotZ; ++ @Shadow private float rotXOld; ++ @Shadow private float rotYOld; ++ @Shadow private float rotZOld; ++ ++ @Override ++ public void captureMotionAnimation(final PhysicalState.Builder state) { ++ state.capture(Field.ROT_X, this.rotX, value -> this.rotX = value); ++ state.capture(Field.ROT_Y, this.rotY, value -> this.rotY = value); ++ state.capture(Field.ROT_Z, this.rotZ, value -> this.rotZ = value); ++ state.capture(Field.ROT_X_OLD, this.rotXOld, value -> this.rotXOld = value); ++ state.capture(Field.ROT_Y_OLD, this.rotYOld, value -> this.rotYOld = value); ++ state.capture(Field.ROT_Z_OLD, this.rotZOld, value -> this.rotZOld = value); ++ } ++ ++ private enum Field { ++ ROT_X, ROT_Y, ROT_Z, ROT_X_OLD, ROT_Y_OLD, ROT_Z_OLD ++ } ++} 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 236c05d..dda48a4 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinEntity.java @@ -674,6 +1243,66 @@ index 236c05d..dda48a4 100644 } else { return box; } +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinEntityCollisionReplay.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinEntityCollisionReplay.java +new file mode 100644 +index 0000000..082ce4d +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinEntityCollisionReplay.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.injection.mixin.features.movement; ++ ++import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; ++import com.llamalad7.mixinextras.injector.wrapoperation.Operation; ++import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; ++import com.llamalad7.mixinextras.sugar.Local; ++import com.llamalad7.mixinextras.sugar.ref.LocalRef; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalCollisions; ++import net.minecraft.world.entity.Entity; ++import net.minecraft.world.level.Level; ++import net.minecraft.world.phys.AABB; ++import net.minecraft.world.phys.Vec3; ++import net.minecraft.world.phys.shapes.CollisionContext; ++import net.minecraft.world.phys.shapes.VoxelShape; ++import org.spongepowered.asm.mixin.Mixin; ++import org.spongepowered.asm.mixin.injection.At; ++import java.util.List; ++ ++@Mixin(Entity.class) ++public abstract class MixinEntityCollisionReplay { ++ @WrapOperation(method = "move", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/entity/Entity;collide(Lnet/minecraft/world/phys/Vec3;)Lnet/minecraft/world/phys/Vec3;")) ++ private Vec3 recordCollisionInputs(final Entity actor, final Vec3 motion, final Operation original, ++ @Local(argsOnly = true) final LocalRef requested) { ++ return PhysicalCollisions.collide(actor, motion, effective -> { ++ requested.set(effective); ++ return original.call(actor, effective); ++ }); ++ } ++ ++ @WrapOperation(method = "collide", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/Level;getEntityCollisions(Lnet/minecraft/world/entity/Entity;Lnet/minecraft/world/phys/AABB;)Ljava/util/List;")) ++ private List gatherActorsWithTheRecordedQuery(final Level level, final Entity actor, final AABB bounds, ++ final Operation> original) { ++ return PhysicalCollisions.active(actor) ? List.of() : original.call(level, actor, bounds); ++ } ++ ++ @WrapMethod(method = "collectCollidersIgnoringWorldBorder(Lnet/minecraft/world/entity/Entity;Lnet/minecraft/world/level/Level;Ljava/util/List;Lnet/minecraft/world/phys/AABB;)Ljava/util/List;") ++ private static List recordedActorColliders(final Entity actor, final Level level, final List entities, ++ final AABB bounds, final Operation> original) { ++ final var captured = PhysicalCollisions.active(actor) ? PhysicalCollisions.collect(level) : null; ++ return captured != null ? captured : original.call(actor, level, entities, bounds); ++ } ++ ++ @WrapMethod(method = "collectCollidersIgnoringWorldBorder(Lnet/minecraft/world/phys/shapes/CollisionContext;Lnet/minecraft/world/level/Level;Ljava/util/List;Lnet/minecraft/world/phys/AABB;)Ljava/util/List;") ++ private static List recordedContextColliders(final CollisionContext context, final Level level, final List entities, ++ final AABB bounds, final Operation> original) { ++ final var captured = PhysicalCollisions.collect(level); ++ return captured != null ? captured : original.call(context, level, entities, bounds); ++ } ++} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinEntityFluidInteraction.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinEntityFluidInteraction.java index 9eb826a..1823e14 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinEntityFluidInteraction.java @@ -711,10 +1340,10 @@ index 9eb826a..1823e14 100644 @Definition(id = "box", local = @Local(type = AABB.class, name = "box")) @Definition(id = "minY", field = "Lnet/minecraft/world/phys/AABB;minY:D") diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinEntityFluidInteraction_CurrentAccumulator.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinEntityFluidInteraction_CurrentAccumulator.java -index 122cd78..6052199 100644 +index 122cd78..6936521 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinEntityFluidInteraction_CurrentAccumulator.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinEntityFluidInteraction_CurrentAccumulator.java -@@ -21,38 +21,47 @@ +@@ -21,38 +21,67 @@ package com.viaversion.viafabricplus.bedrock.injection.mixin.features.movement; @@ -723,6 +1352,9 @@ index 122cd78..6052199 100644 -import com.llamalad7.mixinextras.injector.ModifyExpressionValue; -import com.llamalad7.mixinextras.sugar.Local; import com.viaversion.viafabricplus.bedrock.input.BedrockInputPermissions; ++import com.viaversion.viafabricplus.bedrock.movement.replay.FluidPhysicsState; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalStateAccess; ++import org.spongepowered.asm.mixin.Shadow; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.EntityFluidInteraction; -import net.minecraft.world.entity.player.Player; @@ -738,8 +1370,8 @@ index 122cd78..6052199 100644 +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; @Mixin(EntityFluidInteraction.CurrentAccumulator.class) - public abstract class MixinEntityFluidInteraction_CurrentAccumulator { - +-public abstract class MixinEntityFluidInteraction_CurrentAccumulator { +- - // Bedrock normalizes the accumulated current for players as well - @Definition(id = "entity", local = @Local(type = Entity.class, argsOnly = true)) - @Definition(id = "Player", type = Player.class) @@ -747,6 +1379,25 @@ index 122cd78..6052199 100644 - @ModifyExpressionValue(method = "applyTo", at = @At("MIXINEXTRAS:EXPRESSION")) - private boolean normalizeInsteadScale(final boolean original) { - return !BedrockInputPermissions.isBedrockConnection() && original; ++public abstract class MixinEntityFluidInteraction_CurrentAccumulator implements PhysicalStateAccess.FluidAccumulator { ++ @Shadow private double height; ++ @Shadow private Vec3 accumulatedCurrent; ++ @Shadow private int currentCount; ++ ++ @Override ++ public FluidPhysicsState.Current captureFluidCurrent() { ++ return new FluidPhysicsState.Current(this.height, this.accumulatedCurrent, this.currentCount, this.bedrockCurrent.snapshot()); ++ } ++ ++ @Override ++ public void restoreFluidCurrent(final FluidPhysicsState.Current state) { ++ this.height = state.height(); ++ this.accumulatedCurrent = state.current(); ++ this.currentCount = state.count(); ++ this.bedrockCurrent.restore(state.nativeCurrent()); ++ } ++ ++ + @Unique + private final FluidCurrent bedrockCurrent = new FluidCurrent(); + @@ -782,12 +1433,12 @@ index 122cd78..6052199 100644 } } -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinFireworkRocketEntity.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinFireworkRocketEntity.java +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinEntityFluidPhysicsState.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinEntityFluidPhysicsState.java new file mode 100644 -index 0000000..b648ca3 +index 0000000..d6ee17b --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinFireworkRocketEntity.java -@@ -0,0 +1,28 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinEntityFluidPhysicsState.java +@@ -0,0 +1,51 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -795,44 +1446,483 @@ index 0000000..b648ca3 + */ +package com.viaversion.viafabricplus.bedrock.injection.mixin.features.movement; + -+import com.llamalad7.mixinextras.injector.wrapoperation.Operation; -+import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; -+import com.viaversion.viafabricplus.bedrock.input.BedrockInputPermissions; -+import net.minecraft.client.player.LocalPlayer; -+import net.minecraft.world.entity.LivingEntity; -+import net.minecraft.world.entity.projectile.FireworkRocketEntity; -+import net.minecraft.world.phys.Vec3; ++import com.viaversion.viafabricplus.bedrock.movement.replay.FluidPhysicsState; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalState; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalStateAccess; ++import it.unimi.dsi.fastutil.objects.Reference2ObjectMap; ++import net.minecraft.tags.TagKey; ++import net.minecraft.world.entity.EntityFluidInteraction; ++import net.minecraft.world.level.material.Fluid; ++import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; -+import org.spongepowered.asm.mixin.injection.At; ++import org.spongepowered.asm.mixin.Shadow; + -+@Mixin(FireworkRocketEntity.class) -+public abstract class MixinFireworkRocketEntity { ++import java.util.HashMap; ++import java.util.List; + -+ @WrapOperation(method = "tick", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/entity/LivingEntity;setDeltaMovement(Lnet/minecraft/world/phys/Vec3;)V")) -+ private void deferLocalRocketImpulseToGlideTravel(final LivingEntity entity, final Vec3 movement, final Operation original) { -+ if (!(entity instanceof LocalPlayer) || !BedrockInputPermissions.isBedrockConnection()) { -+ original.call(entity, movement); -+ } ++@Mixin(EntityFluidInteraction.class) ++public abstract class MixinEntityFluidPhysicsState implements PhysicalStateAccess.Fluid { ++ @Shadow @Final private List fluidTrackers; ++ @Shadow @Final private Reference2ObjectMap, EntityFluidInteraction.CurrentAccumulator> currentAccumulators; ++ ++ @Override ++ public void captureFluidPhysics(final PhysicalState.Builder state) { ++ final var trackers = this.fluidTrackers.stream() ++ .map(tracker -> ((PhysicalStateAccess.FluidTracker) tracker).captureFluidTracker()).toList(); ++ final var currents = new HashMap, FluidPhysicsState.Current>(); ++ this.currentAccumulators.forEach((tag, current) -> currents.put(tag, ++ ((PhysicalStateAccess.FluidAccumulator) current).captureFluidCurrent())); ++ state.capture(Field.FLUIDS, new FluidPhysicsState(trackers, currents), value -> { ++ this.fluidTrackers.clear(); ++ for (var saved : value.trackers()) { ++ final var tracker = new EntityFluidInteraction.Tracker(saved.type()); ++ ((PhysicalStateAccess.FluidTracker) tracker).restoreFluidTracker(saved); ++ this.fluidTrackers.add(tracker); ++ } ++ this.currentAccumulators.clear(); ++ value.currents().forEach((tag, saved) -> { ++ final var accumulator = new EntityFluidInteraction.CurrentAccumulator(); ++ ((PhysicalStateAccess.FluidAccumulator) accumulator).restoreFluidCurrent(saved); ++ this.currentAccumulators.put(tag, accumulator); ++ }); ++ }); + } + ++ private enum Field { FLUIDS } +} -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 1c9125e..bf44991 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,167 @@ - - 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.injector.ModifyReturnValue; +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinEntityFluidTrackerState.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinEntityFluidTrackerState.java +new file mode 100644 +index 0000000..c42bd53 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinEntityFluidTrackerState.java +@@ -0,0 +1,33 @@ ++/* ++ * 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.viaversion.viafabricplus.bedrock.movement.replay.FluidPhysicsState; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalStateAccess; ++import net.minecraft.core.Holder; ++import net.minecraft.world.entity.EntityFluidInteraction; ++import net.minecraft.world.level.material.Fluid; ++import org.spongepowered.asm.mixin.Final; ++import org.spongepowered.asm.mixin.Mixin; ++import org.spongepowered.asm.mixin.Shadow; ++ ++@Mixin(EntityFluidInteraction.Tracker.class) ++public abstract class MixinEntityFluidTrackerState implements PhysicalStateAccess.FluidTracker { ++ @Shadow @Final private Holder fluidType; ++ @Shadow private double height; ++ @Shadow private boolean eyesInside; ++ ++ @Override ++ public FluidPhysicsState.Tracker captureFluidTracker() { ++ return new FluidPhysicsState.Tracker(this.fluidType, this.height, this.eyesInside); ++ } ++ ++ @Override ++ public void restoreFluidTracker(final FluidPhysicsState.Tracker state) { ++ this.height = state.height(); ++ this.eyesInside = state.eyesInside(); ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinEntityPhysicsState.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinEntityPhysicsState.java +new file mode 100644 +index 0000000..9c39f20 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinEntityPhysicsState.java +@@ -0,0 +1,202 @@ ++/* ++ * 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.viaversion.viafabricplus.bedrock.movement.replay.PhysicalState; ++import com.viaversion.viafabricplus.bedrock.input.BedrockPhysicalReplay; ++import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; -+import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; - import com.viaversion.viafabricplus.bedrock.input.BedrockInputPermissions; -+import com.viaversion.viafabricplus.bedrock.input.BedrockMovementEffects; -+import com.viaversion.viafabricplus.bedrock.movement.BedrockGeyserPhysics; ++import net.minecraft.world.entity.MoverType; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalBoundingBox; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalReplayContext; ++import org.spongepowered.asm.mixin.injection.At; ++import org.spongepowered.asm.mixin.injection.Inject; ++import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalStateAccess; ++import net.minecraft.core.BlockPos; ++import net.minecraft.world.entity.Entity; ++import net.minecraft.world.entity.EntityDimensions; ++import net.minecraft.world.entity.EntityFluidInteraction; ++import net.minecraft.world.level.block.state.BlockState; ++import net.minecraft.world.phys.Vec3; ++import org.spongepowered.asm.mixin.Final; ++import org.spongepowered.asm.mixin.Mixin; ++import org.spongepowered.asm.mixin.Shadow; ++ ++import java.util.Optional; ++import java.util.List; ++import java.util.ArrayDeque; ++import java.util.Objects; ++import it.unimi.dsi.fastutil.longs.LongSet; ++import it.unimi.dsi.fastutil.longs.LongOpenHashSet; ++import com.google.common.collect.ImmutableList; ++ ++@Mixin(Entity.class) ++public abstract class MixinEntityPhysicsState implements PhysicalStateAccess.Base { ++ @Shadow private double xo; ++ @Shadow private double yo; ++ @Shadow private double zo; ++ @Shadow private double xOld; ++ @Shadow private double yOld; ++ @Shadow private double zOld; ++ @Shadow private float yRotO; ++ @Shadow private float xRotO; ++ @Shadow private boolean horizontalCollision; ++ @Shadow private boolean verticalCollision; ++ @Shadow private boolean verticalCollisionBelow; ++ @Shadow private boolean minorHorizontalCollision; ++ @Shadow private Vec3 stuckSpeedMultiplier; ++ @Shadow private float moveDist; ++ @Shadow private float flyDist; ++ @Shadow private double fallDistance; ++ @Shadow private float nextStep; ++ @Shadow private boolean noPhysics; ++ @Shadow private int tickCount; ++ @Shadow private int remainingFireTicks; ++ @Shadow private boolean wasTouchingWater; ++ @Shadow private boolean wasEyeInWater; ++ @Shadow private boolean firstTick; ++ @Shadow private int boardingCooldown; ++ @Shadow private long pistonDeltasGameTime; ++ @Shadow private EntityDimensions dimensions; ++ @Shadow private float eyeHeight; ++ @Shadow private boolean isInPowderSnow; ++ @Shadow private boolean wasInPowderSnow; ++ @Shadow private Optional mainSupportingBlockPos; ++ @Shadow private boolean onGroundNoBlocks; ++ @Shadow private Vec3 lastKnownSpeed; ++ @Shadow private Vec3 lastKnownPosition; ++ @Shadow private BlockState inBlockState; ++ @Shadow private int invulnerableTime; ++ @Shadow @Final private ArrayDeque movementThisTick; ++ @Shadow @Final private List finalMovementsThisTick; ++ @Shadow @Final private LongSet visitedBlocks; ++ @Shadow private Entity vehicle; ++ @Shadow private ImmutableList passengers; ++ @Shadow protected abstract boolean getSharedFlag(int flag); ++ @Shadow protected abstract void setSharedFlag(int flag, boolean value); ++ @Shadow @Final private double[] pistonDeltas; ++ @Shadow @Final private EntityFluidInteraction fluidInteraction; ++ ++ @Override ++ public void captureBasePhysics(final PhysicalState.Builder state) { ++ final Entity entity = (Entity) (Object) this; ++ state.snapshotLate(Field.INVULNERABLE_TIME, this.invulnerableTime, value -> this.invulnerableTime = value); ++ state.capture(Field.AIR, entity.getAirSupply(), entity::setAirSupply); ++ state.capture(Field.FROZEN, entity.getTicksFrozen(), entity::setTicksFrozen); ++ state.capture(Field.MOVEMENT_TRACE, List.copyOf(this.movementThisTick), value -> { ++ this.movementThisTick.clear(); ++ this.movementThisTick.addAll(value); ++ }); ++ state.capture(Field.FINAL_MOVEMENTS, List.copyOf(this.finalMovementsThisTick), value -> { ++ this.finalMovementsThisTick.clear(); ++ this.finalMovementsThisTick.addAll(value); ++ }); ++ state.capture(Field.VISITED_BLOCKS, this.visitedBlocks, value -> { ++ this.visitedBlocks.clear(); ++ this.visitedBlocks.addAll(value); ++ }, LongOpenHashSet::new, Objects::equals); ++ this.captureRelationships(state); ++ state.capture(Field.POSE, entity.getPose(), entity::setPose); ++ state.capture(Field.SHIFT, entity.isShiftKeyDown(), entity::setShiftKeyDown); ++ state.capture(Field.SPRINT, entity.isSprinting(), entity::setSprinting); ++ state.capture(Field.SWIMMING, entity.isSwimming(), entity::setSwimming); ++ state.capture(Field.FALL_FLYING, this.getSharedFlag(7), value -> this.setSharedFlag(7, value)); ++ state.capture(Field.NO_GRAVITY, entity.isNoGravity(), entity::setNoGravity); ++ state.snapshotLate(Field.POSITION, entity.position(), entity::setPos); ++ state.snapshotLate(Field.MOTION, entity.getDeltaMovement(), entity::setDeltaMovement); ++ state.captureLate(Field.Y_ROT, entity.getYRot(), entity::setYRot); ++ state.captureLate(Field.X_ROT, entity.getXRot(), entity::setXRot); ++ state.snapshotLate(Field.ON_GROUND, entity.onGround(), entity::setOnGround); ++ state.snapshotLate(Field.PISTON_X, this.pistonDeltas[0], value -> this.pistonDeltas[0] = value); ++ state.snapshotLate(Field.PISTON_Y, this.pistonDeltas[1], value -> this.pistonDeltas[1] = value); ++ state.snapshotLate(Field.PISTON_Z, this.pistonDeltas[2], value -> this.pistonDeltas[2] = value); ++ state.snapshotLate(Field.XO, this.xo, value -> this.xo = value); ++ state.snapshotLate(Field.YO, this.yo, value -> this.yo = value); ++ state.snapshotLate(Field.ZO, this.zo, value -> this.zo = value); ++ state.snapshotLate(Field.X_OLD, this.xOld, value -> this.xOld = value); ++ state.snapshotLate(Field.Y_OLD, this.yOld, value -> this.yOld = value); ++ state.snapshotLate(Field.Z_OLD, this.zOld, value -> this.zOld = value); ++ state.snapshotLate(Field.Y_ROT_O, this.yRotO, value -> this.yRotO = value); ++ state.snapshotLate(Field.X_ROT_O, this.xRotO, value -> this.xRotO = value); ++ state.snapshotLate(Field.HORIZONTAL_COLLISION, this.horizontalCollision, value -> this.horizontalCollision = value); ++ state.snapshotLate(Field.VERTICAL_COLLISION, this.verticalCollision, value -> this.verticalCollision = value); ++ state.snapshotLate(Field.VERTICAL_COLLISION_BELOW, this.verticalCollisionBelow, value -> this.verticalCollisionBelow = value); ++ state.snapshotLate(Field.MINOR_HORIZONTAL_COLLISION, this.minorHorizontalCollision, value -> this.minorHorizontalCollision = value); ++ state.snapshotLate(Field.STUCK_SPEED_MULTIPLIER, this.stuckSpeedMultiplier, value -> this.stuckSpeedMultiplier = value); ++ state.snapshotLate(Field.MOVE_DIST, this.moveDist, value -> this.moveDist = value); ++ state.snapshotLate(Field.FLY_DIST, this.flyDist, value -> this.flyDist = value); ++ state.snapshotLate(Field.FALL_DISTANCE, this.fallDistance, value -> this.fallDistance = value); ++ state.snapshotLate(Field.NEXT_STEP, this.nextStep, value -> this.nextStep = value); ++ state.snapshotLate(Field.NO_PHYSICS, this.noPhysics, value -> this.noPhysics = value); ++ state.snapshotLate(Field.TICK_COUNT, this.tickCount, value -> this.tickCount = value); ++ state.capture(Field.REMAINING_FIRE_TICKS, this.remainingFireTicks, value -> this.remainingFireTicks = value); ++ state.snapshotLate(Field.WAS_TOUCHING_WATER, this.wasTouchingWater, value -> this.wasTouchingWater = value); ++ state.snapshotLate(Field.WAS_EYE_IN_WATER, this.wasEyeInWater, value -> this.wasEyeInWater = value); ++ state.snapshotLate(Field.FIRST_TICK, this.firstTick, value -> this.firstTick = value); ++ state.snapshotLate(Field.BOARDING_COOLDOWN, this.boardingCooldown, value -> this.boardingCooldown = value); ++ state.snapshotLate(Field.PISTON_DELTAS_GAME_TIME, this.pistonDeltasGameTime, value -> this.pistonDeltasGameTime = value); ++ state.captureLate(Field.DIMENSIONS, this.dimensions, value -> this.dimensions = value); ++ state.captureLate(Field.EYE_HEIGHT, this.eyeHeight, value -> this.eyeHeight = value); ++ state.capture(Field.IS_IN_POWDER_SNOW, this.isInPowderSnow, value -> this.isInPowderSnow = value); ++ state.capture(Field.WAS_IN_POWDER_SNOW, this.wasInPowderSnow, value -> this.wasInPowderSnow = value); ++ state.snapshotLate(Field.MAIN_SUPPORTING_BLOCK_POS, this.mainSupportingBlockPos, value -> this.mainSupportingBlockPos = value); ++ state.snapshotLate(Field.ON_GROUND_NO_BLOCKS, this.onGroundNoBlocks, value -> this.onGroundNoBlocks = value); ++ state.snapshotLate(Field.LAST_KNOWN_SPEED, this.lastKnownSpeed, value -> this.lastKnownSpeed = value); ++ state.snapshotLate(Field.LAST_KNOWN_POSITION, this.lastKnownPosition, value -> this.lastKnownPosition = value); ++ state.snapshotLate(Field.IN_BLOCK_STATE, this.inBlockState, value -> this.inBlockState = value); ++ ((PhysicalStateAccess.Fluid) this.fluidInteraction).captureFluidPhysics(state); ++ // Pose and position restoration can rebuild the box. Preserve its independently captured value last. ++ state.captureLate(Field.BOX, new PhysicalBoundingBox(entity.getBoundingBox(), entity.position()), ++ value -> entity.setBoundingBox(value.bounds()), ++ (previous, next) -> entity.setBoundingBox(next.at(entity.position()))); ++ } ++ ++ @Override ++ public void captureRelationships(final PhysicalState.Builder state) { ++ state.snapshot(Field.VEHICLE, this.vehicle, value -> this.vehicle = value); ++ state.snapshot(Field.PASSENGERS, List.copyOf(this.passengers), value -> this.passengers = ImmutableList.copyOf(value)); ++ } ++ ++ @WrapMethod(method = "stopRiding") ++ private void journalLocalDismount(final Operation original) { ++ BedrockPhysicalReplay.dismount((Entity) (Object) this, original::call); ++ } ++ ++ @Inject(method = "handlePortal", at = @At("HEAD"), cancellable = true) ++ private void retainSinglePortalTick(final CallbackInfo ci) { ++ if (PhysicalReplayContext.replaying()) ci.cancel(); ++ } ++ ++ @WrapMethod(method = "push(DDD)V") ++ private void journalExternalPush(final double x, final double y, final double z, final Operation original) { ++ BedrockPhysicalReplay.motion((Entity) (Object) this, () -> original.call(x, y, z)); ++ } ++ ++ @WrapMethod(method = "addDeltaMovement") ++ private void journalExternalImpulse(final Vec3 impulse, final Operation original) { ++ BedrockPhysicalReplay.motion((Entity) (Object) this, () -> original.call(impulse)); ++ } ++ ++ @WrapMethod(method = "lerpMotion") ++ private void journalAuthoritativeMotion(final Vec3 motion, final Operation original) { ++ BedrockPhysicalReplay.motion((Entity) (Object) this, () -> original.call(motion)); ++ } ++ ++ @WrapMethod(method = "setDeltaMovement(Lnet/minecraft/world/phys/Vec3;)V") ++ private void journalExplicitMotion(final Vec3 motion, final Operation original) { ++ BedrockPhysicalReplay.motion((Entity) (Object) this, () -> original.call(motion)); ++ } ++ ++ @WrapMethod(method = "move") ++ private void journalWorldMovement(final MoverType type, final Vec3 motion, final Operation original) { ++ BedrockPhysicalReplay.motion((Entity) (Object) this, () -> original.call(type, motion)); ++ } ++ ++ private enum Field { ++ INVULNERABLE_TIME, AIR, FROZEN, MOVEMENT_TRACE, FINAL_MOVEMENTS, VISITED_BLOCKS, VEHICLE, PASSENGERS, POSITION, MOTION, Y_ROT, X_ROT, ON_GROUND, NO_GRAVITY, POSE, SHIFT, SPRINT, SWIMMING, FALL_FLYING, PISTON_X, PISTON_Y, PISTON_Z, XO, YO, ZO, X_OLD, Y_OLD, Z_OLD, Y_ROT_O, X_ROT_O, HORIZONTAL_COLLISION, VERTICAL_COLLISION, VERTICAL_COLLISION_BELOW, MINOR_HORIZONTAL_COLLISION, STUCK_SPEED_MULTIPLIER, MOVE_DIST, FLY_DIST, FALL_DISTANCE, NEXT_STEP, NO_PHYSICS, TICK_COUNT, REMAINING_FIRE_TICKS, WAS_TOUCHING_WATER, WAS_EYE_IN_WATER, FIRST_TICK, BOARDING_COOLDOWN, PISTON_DELTAS_GAME_TIME, DIMENSIONS, EYE_HEIGHT, IS_IN_POWDER_SNOW, WAS_IN_POWDER_SNOW, MAIN_SUPPORTING_BLOCK_POS, ON_GROUND_NO_BLOCKS, LAST_KNOWN_SPEED, LAST_KNOWN_POSITION, IN_BLOCK_STATE, BOX ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinFireworkRocketEntity.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinFireworkRocketEntity.java +new file mode 100644 +index 0000000..b648ca3 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinFireworkRocketEntity.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.injection.mixin.features.movement; ++ ++import com.llamalad7.mixinextras.injector.wrapoperation.Operation; ++import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; ++import com.viaversion.viafabricplus.bedrock.input.BedrockInputPermissions; ++import net.minecraft.client.player.LocalPlayer; ++import net.minecraft.world.entity.LivingEntity; ++import net.minecraft.world.entity.projectile.FireworkRocketEntity; ++import net.minecraft.world.phys.Vec3; ++import org.spongepowered.asm.mixin.Mixin; ++import org.spongepowered.asm.mixin.injection.At; ++ ++@Mixin(FireworkRocketEntity.class) ++public abstract class MixinFireworkRocketEntity { ++ ++ @WrapOperation(method = "tick", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/entity/LivingEntity;setDeltaMovement(Lnet/minecraft/world/phys/Vec3;)V")) ++ private void deferLocalRocketImpulseToGlideTravel(final LivingEntity entity, final Vec3 movement, final Operation original) { ++ if (!(entity instanceof LocalPlayer) || !BedrockInputPermissions.isBedrockConnection()) { ++ original.call(entity, movement); ++ } ++ } ++ ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinGameModePhysicsReplay.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinGameModePhysicsReplay.java +new file mode 100644 +index 0000000..32666b0 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinGameModePhysicsReplay.java +@@ -0,0 +1,29 @@ ++/* ++ * 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.ModifyReturnValue; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalReplayContext; ++import net.minecraft.client.multiplayer.MultiPlayerGameMode; ++import net.minecraft.world.level.GameType; ++import org.spongepowered.asm.mixin.Mixin; ++import org.spongepowered.asm.mixin.Shadow; ++import org.spongepowered.asm.mixin.injection.At; ++ ++@Mixin(MultiPlayerGameMode.class) ++public abstract class MixinGameModePhysicsReplay { ++ @Shadow private GameType localPlayerMode; ++ ++ @ModifyReturnValue(method = "getPlayerMode", at = @At("RETURN")) ++ private GameType useRecordedGameMode(final GameType live) { ++ return PhysicalReplayContext.gameMode(live); ++ } ++ ++ @ModifyReturnValue(method = "isSpectator", at = @At("RETURN")) ++ private boolean useRecordedSpectatorMode(final boolean live) { ++ return PhysicalReplayContext.gameMode(this.localPlayerMode) == GameType.SPECTATOR; ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinHorsePhysicsState.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinHorsePhysicsState.java +new file mode 100644 +index 0000000..fcfb195 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinHorsePhysicsState.java +@@ -0,0 +1,63 @@ ++/* ++ * 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.viaversion.viafabricplus.bedrock.movement.replay.PhysicalState; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalStateAccess; ++import net.minecraft.network.syncher.EntityDataAccessor; ++import net.minecraft.world.entity.animal.equine.AbstractHorse; ++import org.spongepowered.asm.mixin.Final; ++import org.spongepowered.asm.mixin.Mixin; ++import org.spongepowered.asm.mixin.Shadow; ++ ++import java.util.Objects; ++import java.util.function.UnaryOperator; ++ ++@Mixin(AbstractHorse.class) ++public abstract class MixinHorsePhysicsState implements PhysicalStateAccess.Horse { ++ @Shadow private int eatingCounter; ++ @Shadow private int mouthCounter; ++ @Shadow private int standCounter; ++ @Shadow private int tailCounter; ++ @Shadow private int sprintCounter; ++ @Shadow private float playerJumpPendingScale; ++ @Shadow private boolean allowStandSliding; ++ @Shadow private float eatAnim; ++ @Shadow private float eatAnimO; ++ @Shadow private float standAnim; ++ @Shadow private float standAnimO; ++ @Shadow private float mouthAnim; ++ @Shadow private float mouthAnimO; ++ @Shadow @Final private static EntityDataAccessor DATA_ID_FLAGS; ++ ++ @Override ++ public void captureHorsePhysics(final PhysicalState.Builder state) { ++ final var data = ((AbstractHorse) (Object) this).getEntityData(); ++ state.capture(Field.FLAGS, data.get(DATA_ID_FLAGS), value -> data.set(DATA_ID_FLAGS, value), ++ UnaryOperator.identity(), Objects::equals, (previous, next) -> { ++ final int changed = previous ^ next; ++ data.set(DATA_ID_FLAGS, (byte) ((data.get(DATA_ID_FLAGS) & ~changed) | (next & changed))); ++ }); ++ state.capture(Field.EATING_COUNTER, this.eatingCounter, value -> this.eatingCounter = value); ++ state.capture(Field.MOUTH_COUNTER, this.mouthCounter, value -> this.mouthCounter = value); ++ state.capture(Field.STAND_COUNTER, this.standCounter, value -> this.standCounter = value); ++ state.capture(Field.TAIL_COUNTER, this.tailCounter, value -> this.tailCounter = value); ++ state.capture(Field.SPRINT_COUNTER, this.sprintCounter, value -> this.sprintCounter = value); ++ state.capture(Field.PLAYER_JUMP_PENDING_SCALE, this.playerJumpPendingScale, value -> this.playerJumpPendingScale = value); ++ state.capture(Field.ALLOW_STAND_SLIDING, this.allowStandSliding, value -> this.allowStandSliding = value); ++ state.capture(Field.EAT_ANIM, this.eatAnim, value -> this.eatAnim = value); ++ state.capture(Field.EAT_ANIM_O, this.eatAnimO, value -> this.eatAnimO = value); ++ state.capture(Field.STAND_ANIM, this.standAnim, value -> this.standAnim = value); ++ state.capture(Field.STAND_ANIM_O, this.standAnimO, value -> this.standAnimO = value); ++ state.capture(Field.MOUTH_ANIM, this.mouthAnim, value -> this.mouthAnim = value); ++ state.capture(Field.MOUTH_ANIM_O, this.mouthAnimO, value -> this.mouthAnimO = value); ++ } ++ ++ private enum Field { ++ FLAGS, ++ EATING_COUNTER, MOUTH_COUNTER, STAND_COUNTER, TAIL_COUNTER, SPRINT_COUNTER, PLAYER_JUMP_PENDING_SCALE, ALLOW_STAND_SLIDING, EAT_ANIM, EAT_ANIM_O, STAND_ANIM, STAND_ANIM_O, MOUTH_ANIM, MOUTH_ANIM_O ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinLevelPhysicsReplay.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinLevelPhysicsReplay.java +new file mode 100644 +index 0000000..b6ee81c +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinLevelPhysicsReplay.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.movement; ++ ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalReplayContext; ++import net.minecraft.world.level.Level; ++import net.minecraft.world.level.LevelAccessor; ++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.CallbackInfoReturnable; ++ ++@Mixin(LevelAccessor.class) ++public interface MixinLevelPhysicsReplay { ++ @Inject(method = "getGameTime", at = @At("HEAD"), cancellable = true) ++ private void useRecordedPhysicalTime(final CallbackInfoReturnable cir) { ++ if ((Object) this instanceof Level level) { ++ final Long time = PhysicalReplayContext.gameTime(level); ++ if (time != null) cir.setReturnValue(time); ++ } ++ } ++} +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 1c9125e..bf44991 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,167 @@ + + 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.injector.ModifyReturnValue; ++import com.llamalad7.mixinextras.injector.wrapoperation.Operation; ++import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; + import com.viaversion.viafabricplus.bedrock.input.BedrockInputPermissions; ++import com.viaversion.viafabricplus.bedrock.input.BedrockMovementEffects; ++import com.viaversion.viafabricplus.bedrock.movement.BedrockGeyserPhysics; +import com.viaversion.viafabricplus.bedrock.movement.BedrockClimbingPhysics; +import com.viaversion.viafabricplus.bedrock.movement.BedrockFlightPhysics; +import com.viaversion.viafabricplus.bedrock.injection.access.IBedrockFlightMotion; @@ -988,11 +2078,162 @@ index 1c9125e..bf44991 100644 @Redirect(method = "getFluidFallingAdjustedMovement", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/entity/LivingEntity;isSprinting()Z")) private boolean changeFluidGravityCondition(final LivingEntity instance) { return BedrockInputPermissions.isBedrockConnection() ? instance.isSwimming() : instance.isSprinting(); +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinLivingPhysicsState.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinLivingPhysicsState.java +new file mode 100644 +index 0000000..3cad3f3 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinLivingPhysicsState.java +@@ -0,0 +1,145 @@ ++/* ++ * 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.viaversion.viafabricplus.bedrock.movement.replay.AttributePhysicsState; ++import com.viaversion.viafabricplus.bedrock.movement.replay.EffectPhysicsState; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalState; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalReplayContext; ++import net.minecraft.network.syncher.EntityDataAccessor; ++import net.minecraft.world.entity.EquipmentSlot; ++import net.minecraft.world.entity.WalkAnimationState; ++import net.minecraft.world.entity.ElytraAnimationState; ++import net.minecraft.world.entity.EntityEquipment; ++import net.minecraft.world.entity.Entity; ++import com.llamalad7.mixinextras.injector.v2.WrapWithCondition; ++import org.spongepowered.asm.mixin.injection.At; ++import org.spongepowered.asm.mixin.injection.Inject; ++import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; ++import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalStateAccess; ++import net.minecraft.core.BlockPos; ++import net.minecraft.core.Holder; ++import net.minecraft.world.effect.MobEffect; ++import net.minecraft.world.effect.MobEffectInstance; ++import net.minecraft.world.entity.LivingEntity; ++import net.minecraft.world.item.ItemStack; ++import org.spongepowered.asm.mixin.Final; ++import org.spongepowered.asm.mixin.Mixin; ++import org.spongepowered.asm.mixin.Shadow; ++ ++import java.util.Map; ++import java.util.Objects; ++import java.util.Optional; ++import java.util.function.UnaryOperator; ++ ++@Mixin(LivingEntity.class) ++public abstract class MixinLivingPhysicsState implements PhysicalStateAccess.Living { ++ @Shadow private boolean discardFriction; ++ @Shadow private int hurtTime; ++ @Shadow private int hurtDuration; ++ @Shadow private int deathTime; ++ @Shadow private int damageCooldownTime; ++ @Shadow private boolean dead; ++ @Shadow private int noActionTime; ++ @Shadow private float lastHurt; ++ @Shadow private boolean jumping; ++ @Shadow private float xxa; ++ @Shadow private float yya; ++ @Shadow private float zza; ++ @Shadow private double lerpYHeadRot; ++ @Shadow private int lerpHeadSteps; ++ @Shadow private float speed; ++ @Shadow private int noJumpDelay; ++ @Shadow private int fallFlyTicks; ++ @Shadow private Optional lastClimbablePos; ++ @Shadow private int autoSpinAttackTicks; ++ @Shadow private float swimAmount; ++ @Shadow private float swimAmountO; ++ @Shadow private float yBodyRot; ++ @Shadow private float yBodyRotO; ++ @Shadow private float yHeadRot; ++ @Shadow private float yHeadRotO; ++ @Shadow @Final private Map, MobEffectInstance> activeEffects; ++ @Shadow @Final private LivingEntity.SwingState swingState; ++ @Shadow @Final public WalkAnimationState walkAnimation; ++ @Shadow @Final public ElytraAnimationState elytraAnimationState; ++ @Shadow @Final protected static EntityDataAccessor DATA_LIVING_ENTITY_FLAGS; ++ ++ @Override ++ public void captureLivingPhysics(final PhysicalState.Builder state) { ++ final LivingEntity entity = (LivingEntity) (Object) this; ++ ((PhysicalStateAccess.MotionAnimation) this.swingState).captureMotionAnimation(state); ++ ((PhysicalStateAccess.MotionAnimation) this.walkAnimation).captureMotionAnimation(state); ++ ((PhysicalStateAccess.MotionAnimation) this.elytraAnimationState).captureMotionAnimation(state); ++ state.capture(Field.ATTRIBUTES, AttributePhysicsState.capture(entity.getAttributes()), ++ value -> value.restore(entity.getAttributes()), UnaryOperator.identity(), Objects::equals, ++ (previous, next) -> next.applyChanges(previous, entity.getAttributes())); ++ state.capture(Field.EFFECTS, EffectPhysicsState.capture(this.activeEffects), ++ value -> value.restore(this.activeEffects), UnaryOperator.identity(), Objects::equals, ++ (previous, next) -> next.applyChanges(previous, this.activeEffects)); ++ // Item-use bits belong to the live consumption cycle. Only spin state belongs to physical replay. ++ state.capture(Field.SPIN_FLAG, (byte) (entity.getEntityData().get(DATA_LIVING_ENTITY_FLAGS) & 4), ++ value -> entity.getEntityData().set(DATA_LIVING_ENTITY_FLAGS, ++ (byte) ((entity.getEntityData().get(DATA_LIVING_ENTITY_FLAGS) & ~4) | value))); ++ state.capture(Field.SLEEPING_POSITION, entity.getSleepingPos().map(BlockPos::immutable), value -> { ++ if (value.isPresent()) entity.setSleepingPos(value.get()); ++ else entity.clearSleepingPos(); ++ }); ++ state.capture(Field.HEALTH, entity.getHealth(), entity::setHealth); ++ state.capture(Field.DISCARD_FRICTION, this.discardFriction, value -> this.discardFriction = value); ++ state.capture(Field.HURT_TIME, this.hurtTime, value -> this.hurtTime = value); ++ state.capture(Field.HURT_DURATION, this.hurtDuration, value -> this.hurtDuration = value); ++ state.capture(Field.DEATH_TIME, this.deathTime, value -> this.deathTime = value); ++ state.capture(Field.DAMAGE_COOLDOWN_TIME, this.damageCooldownTime, value -> this.damageCooldownTime = value); ++ state.capture(Field.DEAD, this.dead, value -> this.dead = value); ++ state.capture(Field.NO_ACTION_TIME, this.noActionTime, value -> this.noActionTime = value); ++ state.capture(Field.LAST_HURT, this.lastHurt, value -> this.lastHurt = value); ++ state.capture(Field.JUMPING, this.jumping, value -> this.jumping = value); ++ state.capture(Field.XXA, this.xxa, value -> this.xxa = value); ++ state.capture(Field.YYA, this.yya, value -> this.yya = value); ++ state.capture(Field.ZZA, this.zza, value -> this.zza = value); ++ state.capture(Field.LERP_Y_HEAD_ROT, this.lerpYHeadRot, value -> this.lerpYHeadRot = value); ++ state.capture(Field.LERP_HEAD_STEPS, this.lerpHeadSteps, value -> this.lerpHeadSteps = value); ++ state.capture(Field.SPEED, this.speed, value -> this.speed = value); ++ state.capture(Field.NO_JUMP_DELAY, this.noJumpDelay, value -> this.noJumpDelay = value); ++ state.capture(Field.FALL_FLY_TICKS, this.fallFlyTicks, value -> this.fallFlyTicks = value); ++ state.capture(Field.LAST_CLIMBABLE_POS, this.lastClimbablePos, value -> this.lastClimbablePos = value); ++ state.capture(Field.AUTO_SPIN_ATTACK_TICKS, this.autoSpinAttackTicks, value -> this.autoSpinAttackTicks = value); ++ state.capture(Field.SWIM_AMOUNT, this.swimAmount, value -> this.swimAmount = value); ++ state.capture(Field.SWIM_AMOUNT_O, this.swimAmountO, value -> this.swimAmountO = value); ++ state.capture(Field.Y_BODY_ROT, this.yBodyRot, value -> this.yBodyRot = value); ++ state.capture(Field.Y_BODY_ROT_O, this.yBodyRotO, value -> this.yBodyRotO = value); ++ state.capture(Field.Y_HEAD_ROT, this.yHeadRot, value -> this.yHeadRot = value); ++ state.capture(Field.Y_HEAD_ROT_O, this.yHeadRotO, value -> this.yHeadRotO = value); ++ } ++ ++ @WrapWithCondition(method = "aiStep", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/entity/EntityEquipment;tick(Lnet/minecraft/world/entity/Entity;)V")) ++ private boolean retainSingleEquipmentTick(final EntityEquipment equipment, final Entity entity) { ++ return !PhysicalReplayContext.replaying(); ++ } ++ ++ @Inject(method = "updatingUsingItem", at = @At("HEAD"), cancellable = true) ++ private void retainSingleItemUseTick(final CallbackInfo ci) { ++ if (PhysicalReplayContext.replaying()) ci.cancel(); ++ } ++ ++ @Inject(method = "getItemBySlot", at = @At("HEAD"), cancellable = true) ++ private void readOwnedPhysicalEquipment(final EquipmentSlot slot, final CallbackInfoReturnable cir) { ++ final ItemStack item = PhysicalReplayContext.equipment((LivingEntity) (Object) this, slot); ++ if (item != null) cir.setReturnValue(item); ++ } ++ ++ @WrapWithCondition(method = "checkAutoSpinAttack", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/entity/LivingEntity;doAutoAttackOnTouch(Lnet/minecraft/world/entity/LivingEntity;)V")) ++ private boolean retainSingleSpinAttackDamage(final LivingEntity attacker, final LivingEntity target) { ++ return !PhysicalReplayContext.replaying(); ++ } ++ ++ private enum Field { ++ ATTRIBUTES, EFFECTS, SPIN_FLAG, SLEEPING_POSITION, HEALTH, ++ DISCARD_FRICTION, HURT_TIME, HURT_DURATION, DEATH_TIME, DAMAGE_COOLDOWN_TIME, DEAD, NO_ACTION_TIME, LAST_HURT, JUMPING, XXA, YYA, ZZA, LERP_Y_HEAD_ROT, LERP_HEAD_STEPS, SPEED, NO_JUMP_DELAY, FALL_FLY_TICKS, LAST_CLIMBABLE_POS, AUTO_SPIN_ATTACK_TICKS, SWIM_AMOUNT, SWIM_AMOUNT_O, Y_BODY_ROT, Y_BODY_ROT_O, Y_HEAD_ROT, Y_HEAD_ROT_O ++ } ++} 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 e5a8167..c15ef5e 100644 +index e5a8167..76e4813 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,248 @@ +@@ -21,22 +21,379 @@ package com.viaversion.viafabricplus.bedrock.injection.mixin.features.movement; @@ -1009,6 +2250,16 @@ index e5a8167..c15ef5e 100644 +import com.viaversion.viafabricplus.bedrock.input.BedrockVehiclePredictions; +import com.viaversion.viafabricplus.bedrock.input.VehiclePredictionPayload; +import com.viaversion.viafabricplus.bedrock.movement.BedrockFluidPhysics; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalState; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalReplayContext; ++import net.minecraft.client.tutorial.Tutorial; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalStateAccess; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalInput; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalItemUse; ++import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; ++import net.minecraft.world.item.ItemStack; ++import net.minecraft.world.entity.MoverType; ++import com.viaversion.viafabricplus.bedrock.input.BedrockPhysicalReplay; +import com.viaversion.viafabricplus.bedrock.movement.BedrockFlightPhysics; +import com.viaversion.viafabricplus.bedrock.movement.BedrockPlayerPosture; +import com.viaversion.viafabricplus.bedrock.movement.BedrockClimbingPhysics; @@ -1043,7 +2294,7 @@ index e5a8167..c15ef5e 100644 @Mixin(LocalPlayer.class) -public abstract class MixinLocalPlayer { -+public abstract class MixinLocalPlayer extends AbstractClientPlayer implements IBedrockPlayerPosture { ++public abstract class MixinLocalPlayer extends AbstractClientPlayer implements IBedrockPlayerPosture, PhysicalStateAccess.Local { + + @WrapWithCondition(method = "onSyncedDataUpdated", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/player/LocalPlayer;startUsingItem(Lnet/minecraft/world/InteractionHand;)V")) + private boolean retainNativeLocalUseStart(final LocalPlayer player, final InteractionHand hand) { @@ -1107,6 +2358,9 @@ index e5a8167..c15ef5e 100644 + + @Inject(method = "aiStep", at = @At("HEAD")) + private void rememberPreviousSwimming(final CallbackInfo ci) { ++ if (BedrockInputPermissions.isBedrockConnection()) { ++ BedrockMovementEffects.replayCorrections(); ++ } + this.viaFabricPlusBedrock$wasSwimming = this.isSwimming(); + this.viaFabricPlusBedrock$inputInWater = this.isInWater(); + this.viaFabricPlusBedrock$groundJump = false; @@ -1213,12 +2467,12 @@ index e5a8167..c15ef5e 100644 + @Inject(method = "sendChanges", at = @At("RETURN")) + private void sendBedrockPrediction(final CallbackInfo ci) { + final LocalPlayer player = (LocalPlayer) (Object) this; -+ if (!BedrockInputPermissions.isBedrockConnection() || !player.connection.hasClientLoaded() -+ || !ClientPlayNetworking.canSend(PlayerPredictionPayload.TYPE)) { ++ if (!BedrockInputPermissions.isBedrockConnection() || !player.connection.hasClientLoaded()) { + return; + } + final long frame = BedrockMovementEffects.completeFrame(); -+ if (frame < 0) { ++ BedrockPhysicalReplay.synchronizeCorrectedPosition(player); ++ if (frame < 0 || !ClientPlayNetworking.canSend(PlayerPredictionPayload.TYPE)) { + return; + } + final Vec3 motion = player.getDeltaMovement(); @@ -1240,14 +2494,231 @@ index e5a8167..c15ef5e 100644 private boolean allowNonSwimWaterSprinting(final LocalPlayer instance, final boolean allowedInShallowWater) { - return this.isSprintingPossible(allowedInShallowWater || BedrockInputPermissions.isBedrockConnection() && (instance.isSwimming() || instance.onGround())); + return this.isSprintingPossible(allowedInShallowWater || BedrockInputPermissions.isBedrockConnection()); ++ } ++ ++ @Shadow private boolean crouching; ++ @Shadow private int sprintTriggerTime; ++ @Shadow private int jumpRidingTicks; ++ @Shadow private float jumpRidingScale; ++ @Shadow private boolean autoJumpEnabled; ++ @Shadow private int autoJumpTime; ++ @Shadow private boolean wasFallFlying; ++ ++ @Shadow private boolean startedUsingItem; ++ @Shadow private InteractionHand usingItemHand; ++ ++ @WrapMethod(method = "move") ++ private void retainCompleteLocalWorldMovement(final MoverType type, final Vec3 motion, final Operation original) { ++ BedrockPhysicalReplay.motion((LocalPlayer) (Object) this, () -> original.call(type, motion)); ++ } ++ ++ @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)); ++ original.call(); ++ return; ++ } ++ final PhysicalItemUse historical = PhysicalReplayContext.itemUse((LocalPlayer) (Object) this); ++ final boolean active = this.startedUsingItem; ++ final InteractionHand hand = this.usingItemHand; ++ final int remaining = this.useItemRemaining; ++ final ItemStack item = this.useItem; ++ final byte flags = (byte) (this.entityData.get(DATA_LIVING_ENTITY_FLAGS) & 3); ++ this.startedUsingItem = historical.active(); ++ this.usingItemHand = historical.hand(); ++ this.useItemRemaining = historical.remaining(); ++ this.useItem = historical.item(); ++ this.entityData.set(DATA_LIVING_ENTITY_FLAGS, (byte) ((this.entityData.get(DATA_LIVING_ENTITY_FLAGS) & ~3) | historical.flags())); ++ try { ++ original.call(); ++ } finally { ++ this.startedUsingItem = active; ++ this.usingItemHand = hand; ++ this.useItemRemaining = remaining; ++ this.useItem = item; ++ this.entityData.set(DATA_LIVING_ENTITY_FLAGS, (byte) ((this.entityData.get(DATA_LIVING_ENTITY_FLAGS) & ~3) | flags)); ++ } ++ } ++ ++ @Inject(method = "tick", at = @At("HEAD"), cancellable = true) ++ private void replayOnlyPlayerPhysics(final CallbackInfo ci) { ++ if (PhysicalReplayContext.replaying()) { ++ ((PhysicalStateAccess.Player) this).replayPlayerTick(); ++ ci.cancel(); ++ } ++ } ++ ++ @WrapOperation(method = "aiStep", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/player/AbstractClientPlayer;aiStep()V")) ++ private void retainPhysicalAiWithoutInventoryOrPickup(final LocalPlayer player, final Operation original) { ++ if (PhysicalReplayContext.replaying()) ((PhysicalStateAccess.Player) player).replayPlayerAiStep(); ++ else original.call(player); ++ } ++ ++ @WrapOperation(method = "aiStep", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/player/ClientInput;tick()V")) ++ private void pollRecordedPhysicalInput(final ClientInput input, final Operation original) { ++ PhysicalReplayContext.poll(input, () -> original.call(input)); ++ } ++ ++ @WrapWithCondition(method = "aiStep", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/player/LocalPlayer;handlePortalTransitionEffect(Z)V")) ++ private boolean avoidReplayingPortalUi(final LocalPlayer player, final boolean active) { ++ return !PhysicalReplayContext.replaying(); ++ } ++ ++ @WrapWithCondition(method = "aiStep", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/player/LocalPlayer;processPortalCooldown()V")) ++ private boolean retainSinglePortalCooldownTick(final LocalPlayer player) { ++ return !PhysicalReplayContext.replaying(); ++ } ++ ++ @WrapWithCondition(method = "aiStep", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/player/LocalPlayer;tickSpinningEffect()V")) ++ private boolean avoidReplayingScreenRotation(final LocalPlayer player) { ++ return !PhysicalReplayContext.replaying(); ++ } ++ ++ @WrapWithCondition(method = "aiStep", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/tutorial/Tutorial;onInput(Lnet/minecraft/client/player/ClientInput;)V")) ++ private boolean avoidReplayingTutorialInput(final Tutorial tutorial, final ClientInput input) { ++ return !PhysicalReplayContext.replaying(); ++ } ++ ++ @ModifyReturnValue(method = "isControlledCamera", at = @At("RETURN")) ++ private boolean useHistoricalCameraOwnership(final boolean original) { ++ return PhysicalReplayContext.replaying() ++ ? PhysicalReplayContext.camera(net.minecraft.client.Minecraft.getInstance().getCameraEntity()) == (Object) this : original; ++ } ++ ++ @Override ++ public void captureLocalPhysics(final PhysicalState.Builder state) { ++ state.capture(PhysicsField.PERMISSIONS, BedrockInputPermissions.current(), PhysicalReplayContext::restorePermissions); ++ state.capture(PhysicsField.GAME_MODE, net.minecraft.client.Minecraft.getInstance().gameMode.getPlayerMode(), PhysicalReplayContext::restoreGameMode); ++ state.capture(PhysicsField.INPUT, PhysicalInput.capture(this.input), value -> value.restore(this.input)); ++ state.capture(PhysicsField.POSTURE, this.viaFabricPlusBedrock$posture, value -> this.viaFabricPlusBedrock$posture = value); ++ state.capture(PhysicsField.WAS_SWIMMING, this.viaFabricPlusBedrock$wasSwimming, value -> this.viaFabricPlusBedrock$wasSwimming = value); ++ state.capture(PhysicsField.INPUT_IN_WATER, this.viaFabricPlusBedrock$inputInWater, value -> this.viaFabricPlusBedrock$inputInWater = value); ++ state.capture(PhysicsField.GROUND_JUMP, this.viaFabricPlusBedrock$groundJump, value -> this.viaFabricPlusBedrock$groundJump = value); ++ state.capture(PhysicsField.SNEAKING_INPUT, this.viaFabricPlusBedrock$sneakingInput.snapshot(), this.viaFabricPlusBedrock$sneakingInput::restore); ++ state.capture(PhysicsField.SNEAKING_SCALE, this.viaFabricPlusBedrock$sneakingScale, value -> this.viaFabricPlusBedrock$sneakingScale = value); ++ state.capture(PhysicsField.ITEM_USE_SCALE, this.viaFabricPlusBedrock$itemUseScale, value -> this.viaFabricPlusBedrock$itemUseScale = value); ++ state.capture(PhysicsField.CROUCHING, this.crouching, value -> this.crouching = value); ++ state.capture(PhysicsField.SPRINT_TRIGGER_TIME, this.sprintTriggerTime, value -> this.sprintTriggerTime = value); ++ state.capture(PhysicsField.JUMP_RIDING_TICKS, this.jumpRidingTicks, value -> this.jumpRidingTicks = value); ++ state.capture(PhysicsField.JUMP_RIDING_SCALE, this.jumpRidingScale, value -> this.jumpRidingScale = value); ++ state.capture(PhysicsField.AUTO_JUMP_ENABLED, this.autoJumpEnabled, value -> this.autoJumpEnabled = value); ++ state.capture(PhysicsField.AUTO_JUMP_TIME, this.autoJumpTime, value -> this.autoJumpTime = value); ++ state.capture(PhysicsField.WAS_FALL_FLYING, this.wasFallFlying, value -> this.wasFallFlying = value); ++ } ++ ++ private enum PhysicsField { ++ PERMISSIONS, GAME_MODE, INPUT, POSTURE, WAS_SWIMMING, INPUT_IN_WATER, GROUND_JUMP, SNEAKING_INPUT, SNEAKING_SCALE, ITEM_USE_SCALE, ++ CROUCHING, SPRINT_TRIGGER_TIME, JUMP_RIDING_TICKS, JUMP_RIDING_SCALE, AUTO_JUMP_ENABLED, AUTO_JUMP_TIME, WAS_FALL_FLYING } } +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinMobEffectPhysicsState.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinMobEffectPhysicsState.java +new file mode 100644 +index 0000000..91dcfed +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinMobEffectPhysicsState.java +@@ -0,0 +1,18 @@ ++/* ++ * 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.viaversion.viafabricplus.bedrock.movement.replay.EffectPhysicsState; ++import net.minecraft.world.effect.MobEffectInstance; ++import org.spongepowered.asm.mixin.Mixin; ++import org.spongepowered.asm.mixin.gen.Accessor; ++ ++@Mixin(MobEffectInstance.class) ++public interface MixinMobEffectPhysicsState extends EffectPhysicsState.HiddenEffect { ++ @Override ++ @Accessor("hiddenEffect") ++ MobEffectInstance hiddenEffect(); ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinParticlePhysicsReplay.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinParticlePhysicsReplay.java +new file mode 100644 +index 0000000..57f7df0 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinParticlePhysicsReplay.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.movement; ++ ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalReplayContext; ++import net.minecraft.client.particle.Particle; ++import net.minecraft.client.particle.ParticleEngine; ++import net.minecraft.core.particles.ParticleOptions; ++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.CallbackInfoReturnable; ++ ++@Mixin(ParticleEngine.class) ++public abstract class MixinParticlePhysicsReplay { ++ @Inject(method = "createParticle", at = @At("HEAD"), cancellable = true) ++ private void avoidReplayingParticles(final ParticleOptions options, final double x, final double y, final double z, ++ final double dx, final double dy, final double dz, final CallbackInfoReturnable cir) { ++ if (PhysicalReplayContext.replaying()) cir.setReturnValue(null); ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinPassengerPhysicsReplay.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinPassengerPhysicsReplay.java +new file mode 100644 +index 0000000..3dda60f +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinPassengerPhysicsReplay.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.injection.mixin.features.movement; ++ ++import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; ++import com.llamalad7.mixinextras.injector.wrapoperation.Operation; ++import com.viaversion.viafabricplus.bedrock.input.BedrockPhysicalReplay; ++import net.minecraft.client.Minecraft; ++import net.minecraft.client.multiplayer.ClientPacketListener; ++import net.minecraft.network.protocol.PacketUtils; ++import net.minecraft.network.protocol.game.ClientboundSetPassengersPacket; ++import org.spongepowered.asm.mixin.Mixin; ++ ++import java.util.Arrays; ++import java.util.Objects; ++ ++@Mixin(ClientPacketListener.class) ++public abstract class MixinPassengerPhysicsReplay { ++ @WrapMethod(method = "handleSetEntityPassengersPacket") ++ private void recordPhysicalSeatCommands(final ClientboundSetPassengersPacket packet, final Operation original) { ++ final Minecraft client = Minecraft.getInstance(); ++ PacketUtils.ensureRunningOnSameThread(packet, (ClientPacketListener) (Object) this, client.packetProcessor()); ++ if (client.getConnection() != (Object) this) { ++ original.call(packet); ++ return; ++ } ++ final var level = client.level; ++ final var vehicle = level == null ? null : level.getEntity(packet.getVehicle()); ++ if (vehicle == null) { ++ original.call(packet); ++ return; ++ } ++ final var passengers = Arrays.stream(packet.getPassengers()).mapToObj(level::getEntity).filter(Objects::nonNull).toList(); ++ BedrockPhysicalReplay.passengers(level, vehicle, passengers, () -> original.call(packet)); ++ } ++} 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 49a37ac..b71f6de 100644 +index 49a37ac..9c58d42 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 -@@ -22,36 +22,106 @@ +@@ -22,36 +22,110 @@ package com.viaversion.viafabricplus.bedrock.injection.mixin.features.movement; import com.llamalad7.mixinextras.injector.v2.WrapWithCondition; @@ -1257,6 +2728,10 @@ index 49a37ac..b71f6de 100644 +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 net.minecraft.world.entity.ai.attributes.Attributes; ++import net.minecraft.util.Mth; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalStateAccess; +import com.viaversion.viafabricplus.bedrock.movement.BedrockFlightPhysics; +import net.raphimc.viabedrock.api.model.FlightMovement; +import net.raphimc.viabedrock.protocol.model.Position3f; @@ -1287,22 +2762,22 @@ index 49a37ac..b71f6de 100644 @Mixin(Player.class) -public abstract class MixinPlayer extends Avatar { -+public abstract class MixinPlayer extends Avatar implements IBedrockFlightMotion { ++public abstract class MixinPlayer extends Avatar implements IBedrockFlightMotion, PhysicalStateAccess.Player { + + @Unique + private Vec3 viaFabricPlusBedrock$flightMotion; + + @Unique + private float viaFabricPlusBedrock$flightFriction; -+ -+ @Unique + + @Unique +- private int viaFabricPlusBedrock$ticksSinceSwimming; + private float viaFabricPlusBedrock$flightInputX; + + @Unique + private float viaFabricPlusBedrock$flightInputZ; - - @Unique -- private int viaFabricPlusBedrock$ticksSinceSwimming; ++ ++ @Unique + private boolean viaFabricPlusBedrock$flightInputPrepared; + + @Override @@ -1361,7 +2836,7 @@ index 49a37ac..b71f6de 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 +133,16 @@ public abstract class MixinPlayer extends Avatar { +@@ -63,21 +137,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) { @@ -1388,7 +2863,7 @@ index 49a37ac..b71f6de 100644 this.setDeltaMovement(this.getDeltaMovement().x(), 0, this.getDeltaMovement().z()); } } -@@ -88,9 +153,20 @@ public abstract class MixinPlayer extends Avatar { +@@ -88,9 +157,20 @@ public abstract class MixinPlayer extends Avatar { return instance.isSwimming(); } @@ -1412,7 +2887,7 @@ index 49a37ac..b71f6de 100644 instance.setDeltaMovement(instance.getDeltaMovement().x(), 0, instance.getDeltaMovement().z()); return false; } -@@ -99,12 +175,18 @@ public abstract class MixinPlayer extends Avatar { +@@ -99,12 +179,72 @@ 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)) @@ -1431,15 +2906,69 @@ index 49a37ac..b71f6de 100644 instance.setDeltaMovement(movement); } + this.viaFabricPlusBedrock$flightInputPrepared = false; ++ } ++ ++ @Shadow protected int jumpTriggerTime; ++ @Shadow protected boolean wasUnderwater; ++ ++ @Shadow protected abstract boolean updateIsUnderwater(); ++ @Shadow protected abstract void updatePlayerPose(); ++ ++ @Override ++ public void replayPlayerTick() { ++ final Player player = (Player) (Object) this; ++ this.noPhysics = player.isSpectator(); ++ if (player.isSpectator() || this.isPassenger()) this.setOnGround(false); ++ this.updateIsUnderwater(); ++ super.tick(); ++ final double x = Mth.clamp(this.getX(), -2.9999999E7, 2.9999999E7); ++ final double z = Mth.clamp(this.getZ(), -2.9999999E7, 2.9999999E7); ++ if (x != this.getX() || z != this.getZ()) this.setPos(x, this.getY(), z); ++ this.updatePlayerPose(); ++ } ++ ++ @Override ++ public void replayPlayerAiStep() { ++ if (this.jumpTriggerTime > 0) this.jumpTriggerTime--; ++ if (((Player) (Object) this).getAbilities().flying && !this.isPassenger()) this.resetFallDistance(); ++ super.aiStep(); ++ this.yHeadRot = this.getYRot(); ++ this.setSpeed((float) this.getAttributeValue(Attributes.MOVEMENT_SPEED)); ++ } ++ ++ @Override ++ public void capturePlayerPhysics(final PhysicalState.Builder state) { ++ final Player player = (Player) (Object) this; ++ final var abilities = player.getAbilities(); ++ state.capture(PhysicsField.FLYING, abilities.flying, value -> abilities.flying = value); ++ state.capture(PhysicsField.MAY_FLY, abilities.mayfly, value -> abilities.mayfly = value); ++ state.capture(PhysicsField.INVULNERABLE, abilities.invulnerable, value -> abilities.invulnerable = value); ++ state.capture(PhysicsField.INSTANT_BUILD, abilities.instabuild, value -> abilities.instabuild = value); ++ state.capture(PhysicsField.MAY_BUILD, abilities.mayBuild, value -> abilities.mayBuild = value); ++ state.capture(PhysicsField.FLYING_SPEED, abilities.getFlyingSpeed(), abilities::setFlyingSpeed); ++ state.capture(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); ++ state.capture(PhysicsField.FLIGHT_INPUT_Z, this.viaFabricPlusBedrock$flightInputZ, value -> this.viaFabricPlusBedrock$flightInputZ = value); ++ state.capture(PhysicsField.FLIGHT_PREPARED, this.viaFabricPlusBedrock$flightInputPrepared, value -> this.viaFabricPlusBedrock$flightInputPrepared = value); ++ } ++ ++ 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 } } diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinPlayerCorrectionPacketListener.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinPlayerCorrectionPacketListener.java new file mode 100644 -index 0000000..906163e +index 0000000..599b47f --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinPlayerCorrectionPacketListener.java -@@ -0,0 +1,23 @@ +@@ -0,0 +1,79 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -1447,25 +2976,81 @@ index 0000000..906163e + */ +package com.viaversion.viafabricplus.bedrock.injection.mixin.features.movement; + ++import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; ++import com.llamalad7.mixinextras.injector.wrapoperation.Operation; ++import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; ++import com.llamalad7.mixinextras.injector.v2.WrapWithCondition; +import com.viaversion.viafabricplus.bedrock.input.BedrockPlayerCorrections; ++import com.viaversion.viafabricplus.bedrock.input.BedrockPhysicalReplay; ++import com.viaversion.viafabricplus.bedrock.input.BedrockInputPermissions; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientPacketListener; ++import net.minecraft.network.protocol.PacketUtils; ++import net.minecraft.network.protocol.game.ClientboundMoveVehiclePacket; +import net.minecraft.network.protocol.game.ClientboundPlayerPositionPacket; ++import net.minecraft.world.entity.Entity; ++import net.minecraft.world.entity.InterpolationHandler; ++import net.minecraft.world.entity.PositionMoveRotation; ++import net.minecraft.world.entity.Relative; +import org.spongepowered.asm.mixin.Mixin; ++import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; -+import org.spongepowered.asm.mixin.injection.Inject; -+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; ++import java.util.Set; + +@Mixin(ClientPacketListener.class) +public abstract class MixinPlayerCorrectionPacketListener { -+ @Inject(method = "handleMovePlayer", at = @At("TAIL")) -+ private void applyCorrectionGroundState(final ClientboundPlayerPositionPacket packet, final CallbackInfo ci) { -+ BedrockPlayerCorrections.apply(Minecraft.getInstance().player, packet.id()); ++ @Unique private boolean viaFabricPlusBedrock$playerCorrection; ++ @Unique private boolean viaFabricPlusBedrock$vehicleCorrection; ++ ++ @WrapMethod(method = "handleMovePlayer") ++ private void queuePlayerCorrection(final ClientboundPlayerPositionPacket packet, final Operation original) { ++ final Minecraft client = Minecraft.getInstance(); ++ PacketUtils.ensureRunningOnSameThread(packet, (ClientPacketListener) (Object) this, client.packetProcessor()); ++ final boolean paired = client.getConnection() == (Object) this && BedrockPlayerCorrections.consumePlayer(packet.id()); ++ final boolean previous = this.viaFabricPlusBedrock$playerCorrection; ++ this.viaFabricPlusBedrock$playerCorrection = paired; ++ try { ++ original.call(packet); ++ } finally { ++ this.viaFabricPlusBedrock$playerCorrection = previous; ++ } ++ if (!paired && client.getConnection() == (Object) this && BedrockInputPermissions.isBedrockConnection()) BedrockPhysicalReplay.clear(); ++ } ++ ++ @WrapOperation(method = "handleMovePlayer", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/multiplayer/ClientPacketListener;setValuesFromPositionPacket(Lnet/minecraft/world/entity/PositionMoveRotation;Ljava/util/Set;Lnet/minecraft/world/entity/Entity;Z)Z")) ++ private boolean deferPlayerTransform(final PositionMoveRotation change, final Set relatives, final Entity entity, ++ final boolean teleport, final Operation original) { ++ return !this.viaFabricPlusBedrock$playerCorrection && original.call(change, relatives, entity, teleport); ++ } ++ ++ @WrapMethod(method = "handleMoveVehicle") ++ private void queueVehicleCorrection(final ClientboundMoveVehiclePacket packet, final Operation original) { ++ final Minecraft client = Minecraft.getInstance(); ++ PacketUtils.ensureRunningOnSameThread(packet, (ClientPacketListener) (Object) this, client.packetProcessor()); ++ final boolean paired = client.getConnection() == (Object) this && BedrockPlayerCorrections.consumeVehicle(packet); ++ final boolean previous = this.viaFabricPlusBedrock$vehicleCorrection; ++ this.viaFabricPlusBedrock$vehicleCorrection = paired; ++ try { ++ original.call(packet); ++ } finally { ++ this.viaFabricPlusBedrock$vehicleCorrection = previous; ++ } ++ if (!paired && client.getConnection() == (Object) this && BedrockInputPermissions.isBedrockConnection()) BedrockPhysicalReplay.clear(); ++ } ++ ++ @WrapWithCondition(method = "handleMoveVehicle", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/entity/Entity;absSnapTo(DDDFF)V")) ++ private boolean deferVehicleTransform(final Entity actor, final double x, final double y, final double z, final float yaw, final float pitch) { ++ return !this.viaFabricPlusBedrock$vehicleCorrection; ++ } ++ ++ @WrapWithCondition(method = "handleMoveVehicle", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/entity/InterpolationHandler;cancel()V")) ++ private boolean preserveVehicleInterpolation(final InterpolationHandler interpolation) { ++ return !this.viaFabricPlusBedrock$vehicleCorrection; + } +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinPotentSulfurBlockEntity.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinPotentSulfurBlockEntity.java new file mode 100644 -index 0000000..485eabe +index 0000000..67c7756 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinPotentSulfurBlockEntity.java @@ -0,0 +1,44 @@ @@ -1501,7 +3086,7 @@ index 0000000..485eabe + @Inject(method = "lambda$static$5", at = @At("HEAD")) + private static void admitNativeLocalTravel(final Level level, final BlockPos position, final BlockState state, + final PotentSulfurBlockEntity blockEntity, final CallbackInfo ci) { -+ BedrockGeyserPhysics.admit(level, position); ++ com.viaversion.viafabricplus.bedrock.input.BedrockPhysicalReplay.worldPhysics(level, () -> BedrockGeyserPhysics.admit(level, position)); + } + + @WrapOperation(method = "lambda$static$5", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/Level;getEntitiesOfClass(Ljava/lang/Class;Lnet/minecraft/world/phys/AABB;Ljava/util/function/Predicate;)Ljava/util/List;")) @@ -1513,6 +3098,73 @@ index 0000000..485eabe + } + +} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinSoundPhysicsReplay.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinSoundPhysicsReplay.java +new file mode 100644 +index 0000000..0559ba4 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinSoundPhysicsReplay.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.movement; ++ ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalReplayContext; ++import net.minecraft.client.resources.sounds.SoundInstance; ++import net.minecraft.client.sounds.SoundEngine; ++import net.minecraft.client.sounds.SoundManager; ++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.CallbackInfoReturnable; ++ ++@Mixin(SoundManager.class) ++public abstract class MixinSoundPhysicsReplay { ++ @Inject(method = "play", at = @At("HEAD"), cancellable = true) ++ private void avoidReplayingSound(final SoundInstance sound, final CallbackInfoReturnable cir) { ++ if (PhysicalReplayContext.replaying()) cir.setReturnValue(SoundEngine.PlayResult.NOT_STARTED); ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinSwingPhysicsState.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinSwingPhysicsState.java +new file mode 100644 +index 0000000..eaa2e05 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinSwingPhysicsState.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.injection.mixin.features.movement; ++ ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalState; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalStateAccess; ++import net.minecraft.world.entity.LivingEntity; ++import org.spongepowered.asm.mixin.Mixin; ++import org.spongepowered.asm.mixin.Shadow; ++ ++@Mixin(LivingEntity.SwingState.class) ++public abstract class MixinSwingPhysicsState implements PhysicalStateAccess.MotionAnimation { ++ @Shadow private LivingEntity.SwingDescription currentSwing; ++ @Shadow private int ticks; ++ @Shadow private float oldAnimation; ++ @Shadow private float animation; ++ ++ @Override ++ public void captureMotionAnimation(final PhysicalState.Builder state) { ++ state.capture(Field.CURRENT_SWING, this.currentSwing, value -> this.currentSwing = value); ++ state.capture(Field.TICKS, this.ticks, value -> this.ticks = value); ++ state.capture(Field.OLD_ANIMATION, this.oldAnimation, value -> this.oldAnimation = value); ++ state.capture(Field.ANIMATION, this.animation, value -> this.animation = value); ++ } ++ ++ private enum Field { ++ CURRENT_SWING, TICKS, OLD_ANIMATION, ANIMATION ++ } ++} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinVoxelShape.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinVoxelShape.java new file mode 100644 index 0000000..39a52c2 @@ -1547,12 +3199,100 @@ index 0000000..39a52c2 + } + +} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinWalkPhysicsState.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinWalkPhysicsState.java +new file mode 100644 +index 0000000..eba7584 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/movement/MixinWalkPhysicsState.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.injection.mixin.features.movement; ++ ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalState; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalStateAccess; ++import net.minecraft.world.entity.WalkAnimationState; ++import org.spongepowered.asm.mixin.Mixin; ++import org.spongepowered.asm.mixin.Shadow; ++ ++@Mixin(WalkAnimationState.class) ++public abstract class MixinWalkPhysicsState implements PhysicalStateAccess.MotionAnimation { ++ @Shadow private float speedOld; ++ @Shadow private float speed; ++ @Shadow private float position; ++ @Shadow private float positionScale; ++ ++ @Override ++ public void captureMotionAnimation(final PhysicalState.Builder state) { ++ state.capture(Field.SPEED_OLD, this.speedOld, value -> this.speedOld = value); ++ state.capture(Field.SPEED, this.speed, value -> this.speed = value); ++ state.capture(Field.POSITION, this.position, value -> this.position = value); ++ state.capture(Field.POSITION_SCALE, this.positionScale, value -> this.positionScale = value); ++ } ++ ++ private enum Field { ++ SPEED_OLD, SPEED, POSITION, POSITION_SCALE ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/input/ActorMotionPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/input/ActorMotionPayload.java +new file mode 100644 +index 0000000..d84e2a6 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/input/ActorMotionPayload.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; ++import net.raphimc.viabedrock.api.model.ActorMotionCommand; ++ ++public record ActorMotionPayload(ActorMotionCommand command) implements CustomPacketPayload { ++ public static final Type TYPE = new Type<>(Identifier.parse(ActorMotionCommand.CHANNEL)); ++ public static final StreamCodec CODEC = CustomPacketPayload.codec( ++ (payload, buffer) -> ActorMotionCommand.TYPE.write(buffer, payload.command), ++ buffer -> new ActorMotionPayload(ActorMotionCommand.TYPE.read(buffer))); ++ ++ @Override ++ public Type type() { return TYPE; } ++} +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 ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/input/BedrockInputPermissions.java +@@ -23,7 +23,7 @@ public final class BedrockInputPermissions { + private BedrockInputPermissions() {} + + public static PlayerInputPermissions current() { +- return permissions; ++ return com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalReplayContext.permissions(permissions); + } + + public static boolean active() { +@@ -64,7 +64,7 @@ public final class BedrockInputPermissions { + } + + public static Input filter(final Input input, final boolean passenger) { +- final int filtered = permissions.filterInput(sample(input).flags(), passenger); ++ final int filtered = current().filterInput(sample(input).flags(), passenger); + return new Input((filtered & 1) != 0, (filtered & 2) != 0, (filtered & 4) != 0, + (filtered & 8) != 0, (filtered & 16) != 0, (filtered & 32) != 0, (filtered & 64) != 0); + } diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/input/BedrockMovementEffects.java b/src/main/java/com/viaversion/viafabricplus/bedrock/input/BedrockMovementEffects.java new file mode 100644 -index 0000000..fb16ec7 +index 0000000..b5ac638 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/input/BedrockMovementEffects.java -@@ -0,0 +1,96 @@ +@@ -0,0 +1,161 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -1561,10 +3301,15 @@ index 0000000..fb16ec7 +package com.viaversion.viafabricplus.bedrock.input; + +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; ++import net.minecraft.client.multiplayer.ClientPacketListener; ++import net.minecraft.network.Connection; ++import java.lang.ref.WeakReference; +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.raphimc.viabedrock.api.model.MovementEffectTimeline; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalReplayContext; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalState; +import net.raphimc.viabedrock.api.model.FlightMovementContext; +import org.jetbrains.annotations.Nullable; + @@ -1574,6 +3319,8 @@ index 0000000..fb16ec7 + +public final class BedrockMovementEffects { + private static final MovementEffectTimeline TIMELINE = new MovementEffectTimeline(); ++ private static WeakReference clockOwner = new WeakReference<>(null); ++ private static ClientPacketListener listenerOwner; + private static final Map UNDERWATER_SPEEDS = new HashMap<>(); + private static final Map FLIGHT_CONTEXTS = new HashMap<>(); + @@ -1583,6 +3330,22 @@ index 0000000..fb16ec7 + PayloadTypeRegistry.clientboundPlay().register(MovementEffectPayload.TYPE, MovementEffectPayload.CODEC); + PayloadTypeRegistry.clientboundPlay().register(UnderwaterMovementPayload.TYPE, UnderwaterMovementPayload.CODEC); + PayloadTypeRegistry.clientboundPlay().register(FlightMovementPayload.TYPE, FlightMovementPayload.CODEC); ++ ClientPlayConnectionEvents.INIT.register((listener, client) -> { ++ if (clockOwner.get() == listener.getConnection()) TIMELINE.resetHistory(); ++ else TIMELINE.clear(); ++ clockOwner = new WeakReference<>(listener.getConnection()); ++ listenerOwner = listener; ++ BedrockPhysicalReplay.clear(); ++ UNDERWATER_SPEEDS.clear(); ++ FLIGHT_CONTEXTS.clear(); ++ }); ++ ClientTickEvents.START_CLIENT_TICK.register(client -> { ++ if (client.player != null && client.player.connection.hasClientLoaded() ++ && BedrockInputPermissions.isBedrockConnection()) { ++ TIMELINE.beginFrame(); ++ BedrockPhysicalReplay.begin(client.player, TIMELINE.frameId()); ++ } ++ }); + ClientPlayConnectionEvents.INIT.register((listener, client) -> + ClientPlayNetworking.registerReceiver(MovementEffectPayload.TYPE, + (payload, context) -> { @@ -1605,7 +3368,10 @@ index 0000000..fb16ec7 + } + })); + ClientPlayConnectionEvents.DISCONNECT.register((listener, client) -> { -+ TIMELINE.clear(); ++ if (listenerOwner != listener) return; ++ listenerOwner = null; ++ BedrockPhysicalReplay.clear(); ++ TIMELINE.resetHistory(); + UNDERWATER_SPEEDS.clear(); + FLIGHT_CONTEXTS.clear(); + }); @@ -1621,24 +3387,63 @@ index 0000000..fb16ec7 + }); + } + ++ public static long frameId() { ++ return TIMELINE.frameId(); ++ } ++ ++ public static long pendingPhysicalFrame(final IntPredicate predictedActor) { ++ return TIMELINE.pendingPhysicalFrame(predictedActor); ++ } ++ ++ public static void applyPhysicalCommands(final long frame, final IntPredicate predictedActor) { ++ TIMELINE.applyPhysicalCommands(frame, predictedActor); ++ } ++ ++ public static void finishPhysicalReplay(final IntPredicate predictedActor) { ++ TIMELINE.finishPhysicalReplay(predictedActor); ++ } ++ ++ public static void capturePhysicalValues(final int entityId, final PhysicalState.Builder state) { ++ state.snapshot(PhysicalField.EFFECTS, TIMELINE.captureValues(entityId), value -> TIMELINE.restoreValues(entityId, value)); ++ state.capture(PhysicalField.UNDERWATER_SPEED, UNDERWATER_SPEEDS.get(entityId), value -> { ++ if (value == null) UNDERWATER_SPEEDS.remove(entityId); ++ else UNDERWATER_SPEEDS.put(entityId, value); ++ }); ++ state.capture(PhysicalField.FLIGHT, FLIGHT_CONTEXTS.get(entityId), value -> { ++ if (value == null) FLIGHT_CONTEXTS.remove(entityId); ++ else FLIGHT_CONTEXTS.put(entityId, value); ++ }); ++ } ++ ++ private enum PhysicalField { EFFECTS, UNDERWATER_SPEED, FLIGHT } ++ + public static long completeFrame() { ++ BedrockPhysicalReplay.complete(TIMELINE.frameId()); + return TIMELINE.completeFrame(); + } + ++ static void replayValuesWithoutRetainedPhysics() { ++ TIMELINE.replayCorrections(); ++ } ++ ++ public static void replayCorrections() { ++ if (!PhysicalReplayContext.replaying() && !BedrockPhysicalReplay.active()) TIMELINE.replayCorrections(); ++ } ++ + public static boolean hasGlideBoost(final int entityId) { -+ return TIMELINE.hasGlideBoost(entityId); ++ return TIMELINE.hasGlideBoost(entityId, PhysicalReplayContext.completedFrame(TIMELINE.frameId() - 1)); + } + + public static boolean hasDolphinBoost(final int entityId) { -+ return TIMELINE.hasDolphinBoost(entityId); ++ return TIMELINE.hasDolphinBoost(entityId, PhysicalReplayContext.completedFrame(TIMELINE.frameId() - 1)); + } + + public static boolean hasGeyserBoost(final int entityId) { -+ return TIMELINE.hasGeyserBoost(entityId); ++ return TIMELINE.hasGeyserBoost(entityId, PhysicalReplayContext.completedFrame(TIMELINE.frameId() - 1)); + } + + public static void predictGeyserBoost(final int entityId) { -+ TIMELINE.predictGeyserBoost(entityId); ++ TIMELINE.predictGeyserBoost(entityId, PhysicalReplayContext.completedFrame(TIMELINE.frameId() - 1) + 1); + } + + public static @Nullable Float underwaterSpeed(final int entityId) { @@ -1649,12 +3454,12 @@ index 0000000..fb16ec7 + } + +} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/input/BedrockPlayerCorrections.java b/src/main/java/com/viaversion/viafabricplus/bedrock/input/BedrockPlayerCorrections.java +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..3857fae +index 0000000..3370817 --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/input/BedrockPlayerCorrections.java -@@ -0,0 +1,44 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/input/BedrockPhysicalReplay.java +@@ -0,0 +1,483 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -1662,124 +3467,637 @@ index 0000000..3857fae + */ +package com.viaversion.viafabricplus.bedrock.input; + -+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.player.LocalPlayer; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalActorState; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalGraphState; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalReplayContext; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalReplayHistory; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalMovementCorrection; ++import com.viaversion.viafabricplus.bedrock.render.BedrockActorState; +import net.raphimc.viabedrock.api.model.PlayerCorrection; -+import net.raphimc.viabedrock.api.model.PlayerCorrectionTracker; ++import net.raphimc.viabedrock.api.model.ActorMotionCommand; ++import net.minecraft.world.phys.Vec3; ++import net.raphimc.viabedrock.api.model.VehiclePredictionContext; ++import net.minecraft.network.protocol.game.ServerboundMovePlayerPacket; ++import net.minecraft.network.protocol.game.ServerboundMoveVehiclePacket; ++import java.util.Objects; ++import net.minecraft.client.Minecraft; ++import net.minecraft.client.multiplayer.ClientLevel; ++import net.minecraft.client.player.LocalPlayer; ++import net.minecraft.world.entity.Entity; ++import net.minecraft.world.entity.animal.equine.AbstractHorse; ++import net.minecraft.world.entity.vehicle.boat.AbstractBoat; ++import net.minecraft.world.level.Level; + -+public final class BedrockPlayerCorrections { -+ private static final PlayerCorrectionTracker TRACKER = new PlayerCorrectionTracker(); ++import java.util.Collections; ++import java.util.IdentityHashMap; ++import java.util.Set; ++import java.util.List; ++import java.util.ArrayList; ++import java.util.ArrayDeque; ++import java.util.Map; + -+ private BedrockPlayerCorrections() {} ++/** Connection-owned recording and real execution of the client's historical physical tick graph. */ ++public final class BedrockPhysicalReplay { ++ private static final int HISTORY_CAPACITY = 1000; ++ private static Session session; + -+ public static void register() { -+ PayloadTypeRegistry.clientboundPlay().register(PlayerCorrectionPayload.TYPE, PlayerCorrectionPayload.CODEC); -+ ClientPlayConnectionEvents.INIT.register((listener, client) -> { -+ TRACKER.clear(); -+ ClientPlayNetworking.registerReceiver(PlayerCorrectionPayload.TYPE, -+ (payload, context) -> { -+ if (context.client().getConnection() == listener && BedrockInputPermissions.isBedrockConnection()) { -+ TRACKER.accept(payload.correction()); -+ } -+ }); -+ }); -+ ClientPlayConnectionEvents.DISCONNECT.register((listener, client) -> TRACKER.clear()); -+ } ++ private BedrockPhysicalReplay() {} + -+ public static void apply(final LocalPlayer player, final int teleportId) { -+ if (player == null || !BedrockInputPermissions.isBedrockConnection()) { -+ TRACKER.clear(); ++ public static void begin(final LocalPlayer player, final long frame) { ++ final Minecraft client = Minecraft.getInstance(); ++ if (client.getConnection() != player.connection || client.level != player.level() || !player.connection.getConnection().isConnected()) { ++ clear(); + return; + } -+ final PlayerCorrection correction = TRACKER.consume(player.getId(), teleportId); -+ if (correction != null && !player.isPassenger()) { -+ player.setOnGround(correction.onGround()); -+ } ++ if (session == null || session.player != player || session.level != player.level() ++ || session.player.connection != player.connection) session = new Session(player); ++ session.begin(frame); + } -+} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/input/BedrockVehiclePredictions.java b/src/main/java/com/viaversion/viafabricplus/bedrock/input/BedrockVehiclePredictions.java -new file mode 100644 -index 0000000..d7477bb ---- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/input/BedrockVehiclePredictions.java -@@ -0,0 +1,43 @@ -+/* -+ * 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.render.BedrockActorState; -+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.player.LocalPlayer; -+import net.raphimc.viabedrock.api.model.VehiclePrediction; -+import org.jetbrains.annotations.Nullable; ++ public static void preparePhysics(final ClientLevel level) { ++ if (session != null && session.level == level) session.prepareFrame(); ++ } + -+public final class BedrockVehiclePredictions { -+ private static final VehiclePredictionSession SESSION = new VehiclePredictionSession(); ++ public static void complete(final long frame) { ++ if (session != null) { ++ session.prepareFrame(); ++ session.history.complete(frame); ++ session.openFrame = false; ++ session.frameActors.clear(); ++ session.frameMembers.clear(); ++ } ++ } + -+ private BedrockVehiclePredictions() {} ++ public static void controlChanged() { ++ if (session != null) session.controlLease++; ++ } + -+ public static void register() { -+ PayloadTypeRegistry.serverboundPlay().register(VehiclePredictionPayload.TYPE, VehiclePredictionPayload.CODEC); -+ PayloadTypeRegistry.clientboundPlay().register(VehiclePredictionContextPayload.TYPE, VehiclePredictionContextPayload.CODEC); -+ ClientPlayConnectionEvents.INIT.register((listener, client) -> { -+ SESSION.begin(listener); -+ ClientPlayNetworking.registerReceiver(VehiclePredictionContextPayload.TYPE, (payload, context) -> { -+ if (context.client().getConnection() == listener) { -+ SESSION.accept(listener, payload.context()); -+ } -+ }); -+ }); -+ ClientPlayConnectionEvents.DISCONNECT.register((listener, client) -> SESSION.end(listener)); ++ public static void correct(final PlayerCorrection correction) { ++ final Minecraft client = Minecraft.getInstance(); ++ if (session != null && client.player == session.player && client.level == session.level ++ && client.getConnection() == session.player.connection && session.player.connection.getConnection().isConnected()) { ++ session.commands.add(new Correction(correction)); ++ } + } + -+ public static @Nullable VehiclePrediction capture(final LocalPlayer player, final long frame) { -+ if (!BedrockInputPermissions.isBedrockConnection() || !player.connection.hasClientLoaded() -+ || !player.connection.getConnection().isConnected() || !ClientPlayNetworking.canSend(VehiclePredictionPayload.TYPE) -+ || !BedrockActorState.supportsUniqueIds() || player.getVehicle() == null) { -+ return null; ++ public static void motion(final ActorMotionCommand command) { ++ final Minecraft client = Minecraft.getInstance(); ++ if (session != null && client.player == session.player && client.level == session.level ++ && client.getConnection() == session.player.connection && session.player.connection.getConnection().isConnected()) { ++ session.receiveMotion(command); + } -+ return SESSION.sample(player.connection, player, BedrockActorState.state(player.getVehicle().getUUID()), frame); + } -+} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/input/FlightMovementPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/input/FlightMovementPayload.java -new file mode 100644 -index 0000000..b56fdfb ---- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/input/FlightMovementPayload.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.FlightMovementContext; ++ public static void synchronizeCorrectedPosition(final LocalPlayer player) { ++ if (session == null || session.player != player || !session.positionSync) return; ++ session.positionSync = false; ++ player.connection.send(new ServerboundMovePlayerPacket.PosRot(player.getX(), player.getY(), player.getZ(), ++ player.getYRot(), player.getXRot(), player.onGround(), player.horizontalCollision)); ++ final Entity vehicle = player.getRootVehicle(); ++ if (vehicle != player && vehicle.getControllingPassenger() == player && vehicle.isLocalInstanceAuthoritative()) { ++ player.connection.send(ServerboundMoveVehiclePacket.fromEntity(vehicle)); ++ } ++ } + -+public record FlightMovementPayload(FlightMovementContext context) implements CustomPacketPayload { -+ public static final Type TYPE = new Type<>(Identifier.parse(FlightMovementContext.CHANNEL)); -+ public static final StreamCodec CODEC = CustomPacketPayload.codec( -+ (payload, buffer) -> FlightMovementContext.TYPE.write(buffer, payload.context), -+ buffer -> new FlightMovementPayload(FlightMovementContext.TYPE.read(buffer))); ++ public static boolean active() { ++ return session != null && session.openFrame; ++ } + -+ @Override -+ public Type type() { -+ return TYPE; ++ public static void clear() { ++ session = null; + } -+} -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 ++ ++ public static void rootTick(final ClientLevel level, final Entity actor, final Runnable tick) { ++ if (PhysicalReplayContext.replaying()) { ++ tick.run(); ++ return; ++ } ++ if (session != null && session.level == level && actor == session.player.getRootVehicle() ++ && (actor == session.player || (actor instanceof AbstractBoat || actor instanceof AbstractHorse) ++ && actor.getControllingPassenger() == session.player && actor.isLocalInstanceAuthoritative())) { ++ session.record(tick); ++ } else tick.run(); ++ } ++ ++ public static void passengerTick(final ClientLevel level, final Entity vehicle, final Entity passenger, final Runnable tick) { ++ if (PhysicalReplayContext.replaying()) { ++ if (PhysicalReplayContext.predicts(passenger)) tick.run(); ++ return; ++ } ++ if (session != null && session.level == level && passenger == session.player && !PhysicalReplayContext.recording()) { ++ // An unowned mount's transform is an observation, not a second locally simulated vehicle. ++ session.trackVehicle(vehicle); ++ final PhysicalActorState observation = PhysicalActorState.capture(vehicle); ++ session.record(() -> { ++ if (PhysicalReplayContext.replaying()) observation.restore(); ++ tick.run(); ++ }); ++ } else tick.run(); ++ } ++ ++ public static void passengers(final ClientLevel level, final Entity vehicle, final List passengers, final Runnable live) { ++ if (session == null || session.level != level || !session.relevant(vehicle, passengers)) { ++ live.run(); ++ return; ++ } ++ session.trackVehicle(vehicle); ++ final Set involved = Collections.newSetFromMap(new IdentityHashMap<>()); ++ involved.add(vehicle); ++ involved.addAll(vehicle.getPassengers()); ++ involved.addAll(passengers); ++ for (Entity passenger : List.copyOf(involved)) { ++ if (passenger.getVehicle() != null) involved.add(passenger.getVehicle()); ++ } ++ session.trackMembers(involved); ++ final List observations = involved.stream() ++ .filter(actor -> actor != session.player && actor.getControllingPassenger() != session.player) ++ .filter(actor -> actor == vehicle || actor.isVehicle()) ++ .map(PhysicalActorState::capture).toList(); ++ final LocalPlayer player = session.player; ++ final Runnable historical = () -> { ++ observations.forEach(PhysicalActorState::restore); ++ final boolean mounted = vehicle.hasIndirectPassenger(player); ++ vehicle.ejectPassengers(); ++ for (Entity passenger : passengers) { ++ passenger.startRiding(vehicle, true, false); ++ if (passenger == player && !mounted && vehicle instanceof AbstractBoat) { ++ player.yRotO = vehicle.getYRot(); ++ player.setYRot(vehicle.getYRot()); ++ player.setYHeadRot(vehicle.getYRot()); ++ } ++ } ++ }; ++ session.admit(involved, live, historical); ++ } ++ ++ public static void dismount(final Entity actor, final Runnable action) { ++ if (PhysicalReplayContext.replaying() || PhysicalReplayContext.recording() || session == null ++ || !session.allActors().contains(actor)) { ++ action.run(); ++ return; ++ } ++ final Set involved = Collections.newSetFromMap(new IdentityHashMap<>()); ++ involved.add(actor); ++ if (actor.getVehicle() != null) involved.add(actor.getVehicle()); ++ session.trackMembers(involved); ++ session.admit(involved, action, action); ++ } ++ ++ public static void motion(final Entity actor, final Runnable action) { ++ if (session != null && session.applyingMotion) action.run(); ++ else if (PhysicalReplayContext.replaying()) { ++ if (PhysicalReplayContext.predicts(actor)) action.run(); ++ } else if (session != null && session.actors.contains(actor) && !PhysicalReplayContext.recording()) { ++ if (session.openFrame && session.prepared) session.record(Set.of(actor), action); ++ else { ++ final var recorded = session.observe(Set.of(actor), action); ++ recorded.record(); ++ session.external.add(recorded::replay); ++ } ++ } else action.run(); ++ } ++ ++ public static void worldPhysics(final Level level, final Runnable action) { ++ if (session != null && session.level == level && !PhysicalReplayContext.replaying()) session.record(action); ++ else action.run(); ++ } ++ ++ private sealed interface Command permits Correction, Motion {} ++ private record Correction(PlayerCorrection value) implements Command {} ++ private record Motion(ActorMotionCommand value) implements Command {} ++ ++ private static final class Session { ++ private final LocalPlayer player; ++ private final ClientLevel level; ++ private final Set actors = Collections.newSetFromMap(new IdentityHashMap<>()); ++ private final Set members = Collections.newSetFromMap(new IdentityHashMap<>()); ++ private final Set frameActors = Collections.newSetFromMap(new IdentityHashMap<>()); ++ private final Set frameMembers = Collections.newSetFromMap(new IdentityHashMap<>()); ++ private final Map lastSeen = new IdentityHashMap<>(); ++ private final PhysicalReplayHistory history; ++ private final List external = new ArrayList<>(); ++ private long frame; ++ private boolean openFrame; ++ private boolean prepared; ++ private Entity controlledRoot; ++ private long controlEpoch = -1; ++ private long controlLease; ++ private VehiclePredictionContext.Identity controlIdentity; ++ private final List commands = new ArrayList<>(); ++ private boolean applyingMotion; ++ private boolean positionSync; ++ ++ private Session(final LocalPlayer player) { ++ this.player = player; ++ this.level = (ClientLevel) player.level(); ++ this.actors.add(player); ++ this.history = new PhysicalReplayHistory<>(HISTORY_CAPACITY, this::captureFrame, PhysicalGraphState::recapture, PhysicalGraphState::restore, ++ (previous, next) -> next.changesSince(previous)::apply, state -> state.valid(this.player), ++ PhysicalReplayContext::frame, frame -> BedrockMovementEffects.applyPhysicalCommands(frame, this::predicted)); ++ } ++ ++ private boolean predicted(final int id) { ++ return this.actors.stream().anyMatch(actor -> actor.getId() == id ++ && (actor == this.player || actor instanceof AbstractBoat || actor instanceof AbstractHorse)); ++ } ++ ++ private void begin(final long frame) { ++ if (this.openFrame && this.frame == frame) return; ++ this.frame = frame; ++ this.openFrame = true; ++ this.prepared = false; ++ } ++ ++ private void prepareFrame() { ++ if (!this.openFrame || this.prepared) return; ++ this.prepared = true; ++ final long frame = this.frame; ++ final Entity root = this.player.getRootVehicle(); ++ if (root != this.player) this.trackVehicle(root); ++ this.frameParticipants(); ++ // Eviction releases old participant references without invalidating a newer, independent graph. ++ this.lastSeen.entrySet().removeIf(entry -> entry.getKey().isRemoved() || entry.getKey().level() != this.level ++ || entry.getValue() < frame - HISTORY_CAPACITY + 1); ++ this.actors.removeIf(actor -> !this.lastSeen.containsKey(actor)); ++ this.members.removeIf(actor -> !this.lastSeen.containsKey(actor)); ++ final Entity controlled = (root instanceof AbstractBoat || root instanceof AbstractHorse) ++ && root.getControllingPassenger() == this.player && root.isLocalInstanceAuthoritative() ? root : null; ++ final var context = BedrockVehiclePredictions.context(this.player); ++ final long epoch = controlled != null && context != null && context.vehicle() != null ++ && context.vehicle().javaId() == controlled.getId() && context.vehicle().javaUuid().equals(controlled.getUUID()) ++ ? context.epoch() : -1; ++ final var identity = controlled != null && epoch >= 0 ? context.vehicle() : null; ++ if (this.controlledRoot != controlled || !Objects.equals(this.controlIdentity, identity)) this.controlLease++; ++ this.controlIdentity = identity; ++ if (controlled != null && (this.controlledRoot != controlled || this.controlEpoch != epoch)) { ++ final var observation = PhysicalActorState.capture(controlled); ++ this.external.add(observation::restore); ++ } ++ this.controlledRoot = controlled; ++ this.controlEpoch = epoch; ++ this.history.begin(frame); ++ this.history.admitExternal(this.external); ++ this.external.clear(); ++ this.scheduleCommands(); ++ final long dirty = BedrockMovementEffects.pendingPhysicalFrame(this::predicted); ++ if (dirty >= 0) this.history.markDirty(dirty); ++ if (dirty >= 0 || this.history.needsReplay()) { ++ boolean replayed = false; ++ final boolean flying = this.player.getAbilities().flying; ++ if (this.history.needsReplay()) { ++ final Set all = this.allActors(); ++ final Set predicted = this.predictedActors(); ++ final var network = new IdentityHashMap(); ++ final var unowned = new IdentityHashMap(); ++ all.forEach(actor -> { ++ network.put(actor, new NetworkFlags(actor.needsSync, actor.syncPosition, actor.syncVelocity)); ++ if (!predicted.contains(actor)) unowned.put(actor, PhysicalActorState.capture(actor)); ++ }); ++ try (var ignored = PhysicalReplayContext.enter(this.level, this.player.connection.getConnection(), all)) { ++ replayed = this.history.replay(); ++ if (replayed) { ++ final var relationships = all.stream().map(PhysicalActorState::relationships).toList(); ++ unowned.values().forEach(PhysicalActorState::restore); ++ relationships.forEach(PhysicalActorState::restore); ++ BedrockMovementEffects.finishPhysicalReplay(this::predicted); ++ } ++ } finally { ++ network.forEach((actor, flags) -> { ++ actor.needsSync = flags.needsSync; ++ actor.syncPosition = flags.position; ++ actor.syncVelocity = flags.velocity; ++ }); ++ } ++ } ++ if (replayed && flying != this.player.getAbilities().flying) this.player.onUpdateAbilities(); ++ if (!replayed && dirty >= 0) { ++ // No actor snapshot from that epoch survives a teleport, respawn, or lifetime replacement. ++ BedrockMovementEffects.replayValuesWithoutRetainedPhysics(); ++ this.history.clear(); ++ this.history.begin(frame); ++ } ++ if (this.history.before(frame) == null) this.history.begin(frame); ++ } ++ } ++ ++ private void scheduleCommands() { ++ for (Command command : this.commands) { ++ switch (command) { ++ case Correction correction -> this.scheduleCorrection(correction.value); ++ case Motion motion -> this.scheduleMotion(motion.value); ++ } ++ } ++ this.commands.clear(); ++ } ++ ++ private void scheduleCorrection(final PlayerCorrection correction) { ++ final Entity actor = correction.entityId() == this.player.getId() ? this.player : this.level.getEntity(correction.entityId()); ++ if (actor == null || actor.level() != this.level || actor.isRemoved() || !actor.getUUID().equals(correction.uuid()) ++ || !Objects.equals(BedrockActorState.liveLifetime(actor.getUUID()), correction.lifetime())) return; ++ if (correction.vehicle()) { ++ final var context = BedrockVehiclePredictions.context(this.player); ++ if (actor != this.controlledRoot || this.controlIdentity == null || context == null ++ || context.epoch() != correction.vehicleEpoch() || !this.controlIdentity.javaUuid().equals(correction.uuid()) ++ || !this.controlIdentity.lifetime().equals(correction.lifetime())) return; ++ } else if (actor != this.player || this.player.isPassenger()) return; ++ final var placement = correction.timing().locate(this.history.oldestFrame(), this.history.completedFrame()); ++ if (placement == null) return; ++ 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 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)); ++ } ++ ++ private Entity motionActor(final ActorMotionCommand command) { ++ final Entity actor = command.entityId() == this.player.getId() ? this.player : this.level.getEntity(command.entityId()); ++ if (actor == null || actor.level() != this.level || actor.isRemoved() || !actor.getUUID().equals(command.uuid()) ++ || !Objects.equals(BedrockActorState.liveLifetime(actor.getUUID()), command.lifetime())) return null; ++ if (!command.vehicle()) return actor == this.player ? actor : null; ++ final var context = BedrockVehiclePredictions.context(this.player); ++ return actor.getControllingPassenger() == this.player && actor.isLocalInstanceAuthoritative() ++ && context != null && context.vehicle() != null && context.epoch() == command.vehicleEpoch() ++ && context.vehicle().javaId() == actor.getId() && context.vehicle().javaUuid().equals(command.uuid()) ++ && context.vehicle().lifetime().equals(command.lifetime()) ? actor : null; ++ } ++ ++ private void receiveMotion(final ActorMotionCommand command) { ++ final Entity actor = this.motionActor(command); ++ if (actor == null) return; ++ // The native receiver applies now and also queues the same value for historical input. ++ this.applyingMotion = true; ++ try { ++ actor.lerpMotion(new Vec3(command.motion().x(), command.motion().y(), command.motion().z())); ++ } finally { ++ this.applyingMotion = false; ++ } ++ this.commands.add(new Motion(command)); ++ } ++ ++ private void scheduleMotion(final ActorMotionCommand command) { ++ final Entity actor = this.motionActor(command); ++ if (actor == null) return; ++ final long lease = this.controlLease; ++ this.history.scheduleMotion(command.timing(), state -> (!command.vehicle() || this.controlLease == lease) ++ && state.acceptsMotion(actor, this.player, command, this.controlLease), ++ () -> { ++ actor.lerpMotion(new Vec3(command.motion().x(), command.motion().y(), command.motion().z())); ++ this.positionSync = true; ++ }); ++ } ++ ++ private Set allActors() { ++ final Set all = Collections.newSetFromMap(new IdentityHashMap<>()); ++ all.addAll(this.actors); ++ all.addAll(this.members); ++ return all; ++ } ++ ++ private boolean relevant(final Entity vehicle, final List passengers) { ++ return this.actors.contains(vehicle) || vehicle.hasIndirectPassenger(this.player) || passengers.contains(this.player); ++ } ++ ++ private void trackVehicle(final Entity vehicle) { ++ this.actors.add(vehicle); ++ this.frameActors.add(vehicle); ++ this.lastSeen.put(vehicle, this.frame); ++ this.trackMembers(vehicle.getPassengers()); ++ } ++ ++ private void trackMembers(final Iterable members) { ++ members.forEach(actor -> { ++ this.members.add(actor); ++ this.frameMembers.add(actor); ++ this.lastSeen.put(actor, this.frame); ++ }); ++ } ++ ++ private Set frameParticipants() { ++ final Set result = Collections.newSetFromMap(new IdentityHashMap<>()); ++ final var pending = new ArrayDeque(); ++ pending.add(this.player.getRootVehicle()); ++ pending.add(this.player); ++ pending.addAll(this.frameActors); ++ pending.addAll(this.frameMembers); ++ while (!pending.isEmpty()) { ++ final Entity actor = pending.removeFirst(); ++ if (!result.add(actor)) continue; ++ pending.addAll(actor.getPassengers()); ++ if (actor.getVehicle() != null) pending.add(actor.getVehicle()); ++ this.lastSeen.put(actor, this.frame); ++ this.members.add(actor); ++ } ++ return result; ++ } ++ ++ private PhysicalGraphState captureFrame() { ++ final Set physical = Collections.newSetFromMap(new IdentityHashMap<>()); ++ physical.add(this.player); ++ physical.add(this.player.getRootVehicle()); ++ physical.addAll(this.frameActors); ++ return PhysicalGraphState.capture(physical, this.frameParticipants(), this.controlLease); ++ } ++ ++ private void admit(final Set involved, final Runnable live, final Runnable historical) { ++ final var step = new PhysicalReplayContext.PhysicalStep(this.level.getGameTime(), involved, ++ Minecraft.getInstance().getCameraEntity(), this.frameParticipants(), live, historical); ++ step.record(); ++ if (this.openFrame && this.prepared) this.history.record(step::replay); ++ else this.external.add(step::replay); ++ } ++ ++ private void record(final Runnable step) { ++ this.record(this.predictedActors(), step); ++ } ++ ++ private void record(final Set predictedActors, final Runnable step) { ++ if (!this.openFrame) { ++ step.run(); ++ return; ++ } ++ this.prepareFrame(); ++ final var recorded = this.observe(predictedActors, step); ++ recorded.record(); ++ this.history.record(recorded::replay); ++ } ++ ++ private PhysicalReplayContext.PhysicalStep observe(final Set predictedActors, final Runnable step) { ++ return new PhysicalReplayContext.PhysicalStep(this.level.getGameTime(), predictedActors, ++ Minecraft.getInstance().getCameraEntity(), this.frameParticipants(), step); ++ } ++ ++ private record NetworkFlags(boolean needsSync, boolean position, boolean velocity) {} ++ ++ private Set predictedActors() { ++ final Set result = Collections.newSetFromMap(new IdentityHashMap<>()); ++ result.add(this.player); ++ final Entity root = this.player.getRootVehicle(); ++ if ((root instanceof AbstractBoat || root instanceof AbstractHorse) && root.getControllingPassenger() == this.player ++ && root.isLocalInstanceAuthoritative()) result.add(root); ++ return result; ++ } ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/input/BedrockPlayerCorrections.java b/src/main/java/com/viaversion/viafabricplus/bedrock/input/BedrockPlayerCorrections.java +new file mode 100644 +index 0000000..8db1520 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/input/BedrockPlayerCorrections.java +@@ -0,0 +1,63 @@ ++/* ++ * 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.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.network.protocol.game.ClientboundMoveVehiclePacket; ++import net.raphimc.viabedrock.protocol.model.Position3f; ++import net.raphimc.viabedrock.api.model.PlayerCorrection; ++import net.raphimc.viabedrock.api.model.PlayerCorrectionTracker; ++ ++public final class BedrockPlayerCorrections { ++ private static final PlayerCorrectionTracker TRACKER = new PlayerCorrectionTracker(); ++ ++ private BedrockPlayerCorrections() {} ++ ++ public static void register() { ++ PayloadTypeRegistry.clientboundPlay().register(PlayerCorrectionPayload.TYPE, PlayerCorrectionPayload.CODEC); ++ PayloadTypeRegistry.clientboundPlay().register(ActorMotionPayload.TYPE, ActorMotionPayload.CODEC); ++ ClientPlayConnectionEvents.INIT.register((listener, client) -> { ++ TRACKER.clear(); ++ ClientPlayNetworking.registerReceiver(ActorMotionPayload.TYPE, (payload, context) -> { ++ if (context.client().getConnection() == listener && BedrockInputPermissions.isBedrockConnection()) { ++ BedrockPhysicalReplay.motion(payload.command()); ++ } ++ }); ++ ClientPlayNetworking.registerReceiver(PlayerCorrectionPayload.TYPE, ++ (payload, context) -> { ++ if (context.client().getConnection() == listener && BedrockInputPermissions.isBedrockConnection()) { ++ TRACKER.accept(payload.correction()); ++ } ++ }); ++ }); ++ ClientPlayConnectionEvents.DISCONNECT.register((listener, client) -> { ++ if (client.getConnection() == null || client.getConnection() == listener) TRACKER.clear(); ++ }); ++ } ++ ++ public static boolean consumePlayer(final int teleportId) { ++ return submit(TRACKER.consumePlayer(teleportId)); ++ } ++ ++ public static boolean consumeVehicle(final ClientboundMoveVehiclePacket packet) { ++ final var target = packet.movingTo(); ++ final var position = target.position(); ++ return submit(TRACKER.consumeVehicle(new Position3f((float) position.x, (float) position.y, (float) position.z), target.yRot(), target.xRot())); ++ } ++ ++ private static boolean submit(final PlayerCorrection correction) { ++ if (!BedrockInputPermissions.isBedrockConnection()) { ++ TRACKER.clear(); ++ return false; ++ } ++ if (correction == null) return false; ++ // A paired packet is acknowledged even when its old world, lifetime, or retained frame is gone. ++ BedrockPhysicalReplay.correct(correction); ++ return true; ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/input/BedrockVehiclePredictions.java b/src/main/java/com/viaversion/viafabricplus/bedrock/input/BedrockVehiclePredictions.java +new file mode 100644 +index 0000000..61ad9d3 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/input/BedrockVehiclePredictions.java +@@ -0,0 +1,48 @@ ++/* ++ * 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.render.BedrockActorState; ++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.player.LocalPlayer; ++import net.raphimc.viabedrock.api.model.VehiclePrediction; ++import net.raphimc.viabedrock.api.model.VehiclePredictionContext; ++import org.jetbrains.annotations.Nullable; ++ ++public final class BedrockVehiclePredictions { ++ private static final VehiclePredictionSession SESSION = new VehiclePredictionSession(); ++ ++ private BedrockVehiclePredictions() {} ++ ++ public static void register() { ++ PayloadTypeRegistry.serverboundPlay().register(VehiclePredictionPayload.TYPE, VehiclePredictionPayload.CODEC); ++ PayloadTypeRegistry.clientboundPlay().register(VehiclePredictionContextPayload.TYPE, VehiclePredictionContextPayload.CODEC); ++ ClientPlayConnectionEvents.INIT.register((listener, client) -> { ++ SESSION.begin(listener); ++ ClientPlayNetworking.registerReceiver(VehiclePredictionContextPayload.TYPE, (payload, context) -> { ++ if (context.client().getConnection() == listener) { ++ if (SESSION.accept(listener, payload.context())) BedrockPhysicalReplay.controlChanged(); ++ } ++ }); ++ }); ++ ClientPlayConnectionEvents.DISCONNECT.register((listener, client) -> SESSION.end(listener)); ++ } ++ ++ static @Nullable VehiclePredictionContext context(final LocalPlayer player) { ++ return SESSION.context(player.connection); ++ } ++ ++ public static @Nullable VehiclePrediction capture(final LocalPlayer player, final long frame) { ++ if (!BedrockInputPermissions.isBedrockConnection() || !player.connection.hasClientLoaded() ++ || !player.connection.getConnection().isConnected() || !ClientPlayNetworking.canSend(VehiclePredictionPayload.TYPE) ++ || !BedrockActorState.supportsUniqueIds() || player.getVehicle() == null) { ++ return null; ++ } ++ return SESSION.sample(player.connection, player, BedrockActorState.state(player.getVehicle().getUUID()), frame); ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/input/FlightMovementPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/input/FlightMovementPayload.java +new file mode 100644 +index 0000000..b56fdfb +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/input/FlightMovementPayload.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.FlightMovementContext; ++ ++public record FlightMovementPayload(FlightMovementContext context) implements CustomPacketPayload { ++ public static final Type TYPE = new Type<>(Identifier.parse(FlightMovementContext.CHANNEL)); ++ public static final StreamCodec CODEC = CustomPacketPayload.codec( ++ (payload, buffer) -> FlightMovementContext.TYPE.write(buffer, payload.context), ++ buffer -> new FlightMovementPayload(FlightMovementContext.TYPE.read(buffer))); ++ ++ @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 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/input/MovementEffectPayload.java @@ -1960,10 +4278,10 @@ index 0000000..1fe3266 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/input/VehiclePredictionSession.java b/src/main/java/com/viaversion/viafabricplus/bedrock/input/VehiclePredictionSession.java new file mode 100644 -index 0000000..46eeaaf +index 0000000..949adc6 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/input/VehiclePredictionSession.java -@@ -0,0 +1,84 @@ +@@ -0,0 +1,91 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -2007,11 +4325,18 @@ index 0000000..46eeaaf + } + } + -+ void accept(final ClientPacketListener listener, final VehiclePredictionContext value) { ++ boolean accept(final ClientPacketListener listener, final VehiclePredictionContext value) { + if (this.owner == listener && (this.context == null || value.epoch() > this.context.epoch() + || value.equals(this.context))) { ++ final boolean changed = !java.util.Objects.equals(this.context == null ? null : this.context.vehicle(), value.vehicle()); + this.context = value; ++ return changed; + } ++ return false; ++ } ++ ++ @Nullable VehiclePredictionContext context(final ClientPacketListener listener) { ++ return this.owner == listener ? this.context : null; + } + + @Nullable VehiclePrediction sample(final ClientPacketListener listener, final LocalPlayer player, final NativeActorMessage.Snapshot actor, final long frame) { @@ -2207,7 +4532,7 @@ index 0000000..cf98df7 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/movement/BedrockFlightPhysics.java b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/BedrockFlightPhysics.java new file mode 100644 -index 0000000..57dd131 +index 0000000..301a431 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/BedrockFlightPhysics.java @@ -0,0 +1,61 @@ @@ -2234,7 +4559,7 @@ index 0000000..57dd131 + + public static boolean applies(final LocalPlayer player) { + return BedrockInputPermissions.isBedrockConnection() && player.getAbilities().flying -+ && Minecraft.getInstance().getCameraEntity() == player && !player.isPassenger() ++ && com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalReplayContext.camera(Minecraft.getInstance().getCameraEntity()) == player && !player.isPassenger() + && BedrockMovementEffects.flightContext(player.getId()) != null; + } + @@ -2509,218 +4834,2676 @@ index 0000000..0f102d6 + } + +} -diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json -index f5e1eea..dd87258 100644 ---- a/src/main/resources/viafabricplus-bedrock.mixins.json -+++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -124,12 +124,17 @@ - "features.input.MixinMouseHandler", - "features.input.MixinInputPermissionConnection", - "features.input.MixinMinecraftInputPermissions", -+ "features.movement.MixinAvatar", - "features.movement.MixinEntity", -+ "features.movement.MixinVoxelShape", - "features.movement.MixinEntityFluidInteraction", - "features.movement.MixinEntityFluidInteraction_CurrentAccumulator", - "features.movement.MixinHoneyBlock", - "features.movement.MixinLivingEntity", -+ "features.movement.MixinPotentSulfurBlockEntity", -+ "features.movement.MixinFireworkRocketEntity", - "features.movement.MixinLocalPlayer", -+ "features.movement.MixinPlayerCorrectionPacketListener", - "features.movement.MixinPlayer", - "features.sound.MixinBedrockChannelAccess", - "features.sound.MixinBedrockChannel", -diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/input/BedrockVehiclePredictionsTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/input/BedrockVehiclePredictionsTest.java +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/ActorSpatialPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/ActorSpatialPayload.java new file mode 100644 -index 0000000..4f2bef0 +index 0000000..43f2a05 --- /dev/null -+++ b/src/test/java/com/viaversion/viafabricplus/bedrock/input/BedrockVehiclePredictionsTest.java -@@ -0,0 +1,155 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/ActorSpatialPayload.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 com.mojang.authlib.GameProfile; -+import com.viaversion.viafabricplus.bedrock.render.BedrockActorState; -+import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; -+import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; -+import net.minecraft.SharedConstants; -+import net.minecraft.core.BlockPos; -+import net.minecraft.world.entity.vehicle.boat.AbstractBoat; -+import net.minecraft.world.phys.Vec3; -+import net.raphimc.viabedrock.api.model.NativeActorMessage; -+import net.raphimc.viabedrock.api.model.VehiclePredictionContext; -+import net.minecraft.client.Minecraft; -+import net.minecraft.client.multiplayer.ClientLevel; -+import net.minecraft.client.multiplayer.ClientPacketListener; -+import net.minecraft.client.player.LocalPlayer; -+import net.minecraft.server.Bootstrap; -+import net.minecraft.world.entity.player.Input; -+import org.junit.jupiter.api.BeforeAll; -+import org.junit.jupiter.api.Test; ++package com.viaversion.viafabricplus.bedrock.movement.replay; + -+import java.util.UUID; ++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.ActorSpatialCommand; ++ ++public record ActorSpatialPayload(ActorSpatialCommand message) implements CustomPacketPayload { ++ public static final Type TYPE = new Type<>(Identifier.parse(ActorSpatialCommand.CHANNEL)); ++ public static final StreamCodec CODEC = CustomPacketPayload.codec( ++ (payload, buffer) -> ActorSpatialCommand.TYPE.write(buffer, payload.message), ++ buffer -> new ActorSpatialPayload(ActorSpatialCommand.TYPE.read(buffer))); ++ ++ @Override ++ public Type type() { return TYPE; } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/AttributePhysicsState.java b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/AttributePhysicsState.java +new file mode 100644 +index 0000000..9763c0f +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/AttributePhysicsState.java +@@ -0,0 +1,84 @@ ++/* ++ * 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.core.Holder; ++import net.minecraft.resources.Identifier; ++import net.minecraft.world.entity.ai.attributes.Attribute; ++import net.minecraft.world.entity.ai.attributes.AttributeInstance; ++import net.minecraft.world.entity.ai.attributes.AttributeMap; ++import net.minecraft.world.entity.ai.attributes.AttributeModifier; ++ ++import java.util.HashMap; +import java.util.Map; +import java.util.Set; -+import java.util.concurrent.atomic.AtomicReference; ++import java.util.stream.Collectors; ++ ++/** Base values and modifier ownership are independent of cached attribute totals and dirty synchronization sets. */ ++public record AttributePhysicsState(Map, Value> values, Set> update, Set> sync) { ++ public AttributePhysicsState { ++ values = Map.copyOf(values); ++ update = Set.copyOf(update); ++ sync = Set.copyOf(sync); ++ } ++ ++ public static AttributePhysicsState capture(final AttributeMap attributes) { ++ final Map, Value> values = new HashMap<>(); ++ attributes.attributes.forEach((holder, attribute) -> { ++ final Map modifiers = new HashMap<>(); ++ attribute.getModifiers().forEach(modifier -> modifiers.put(modifier.id(), ++ new Modifier(modifier, attribute.getPermanentModifiers().contains(modifier)))); ++ values.put(holder, new Value(attribute.getBaseValue(), modifiers)); ++ }); ++ return new AttributePhysicsState(values, ++ attributes.getAttributesToUpdate().stream().map(AttributeInstance::getAttribute).collect(Collectors.toSet()), ++ attributes.getAttributesToSync().stream().map(AttributeInstance::getAttribute).collect(Collectors.toSet())); ++ } ++ ++ public void restore(final AttributeMap attributes) { ++ attributes.attributes.keySet().removeIf(holder -> !this.values.containsKey(holder)); ++ this.values.forEach((holder, value) -> { ++ final AttributeInstance attribute = attributes.getInstance(holder); ++ attribute.removeModifiers(); ++ attribute.setBaseValue(value.base()); ++ value.modifiers().values().forEach(modifier -> modifier.apply(attribute)); ++ }); ++ attributes.getAttributesToUpdate().clear(); ++ this.update.forEach(holder -> attributes.getAttributesToUpdate().add(attributes.getInstance(holder))); ++ attributes.getAttributesToSync().clear(); ++ this.sync.forEach(holder -> attributes.getAttributesToSync().add(attributes.getInstance(holder))); ++ } ++ ++ public void applyChanges(final AttributePhysicsState previous, final AttributeMap attributes) { ++ this.values.forEach((holder, next) -> { ++ final Value before = previous.values.get(holder); ++ final AttributeInstance attribute = attributes.getInstance(holder); ++ if (before == null || Double.compare(before.base(), next.base()) != 0) attribute.setBaseValue(next.base()); ++ if (before != null) { ++ before.modifiers().keySet().stream().filter(id -> !next.modifiers().containsKey(id)).forEach(attribute::removeModifier); ++ } ++ next.modifiers().forEach((id, modifier) -> { ++ if (before == null || !modifier.equals(before.modifiers().get(id))) { ++ attribute.removeModifier(id); ++ modifier.apply(attribute); ++ } ++ }); ++ }); ++ } ++ ++ public record Value(double base, Map modifiers) { ++ public Value { ++ modifiers = Map.copyOf(modifiers); ++ } ++ } ++ ++ public record Modifier(AttributeModifier value, boolean permanent) { ++ private void apply(final AttributeInstance attribute) { ++ if (this.permanent) attribute.addPermanentModifier(this.value); ++ else attribute.addTransientModifier(this.value); ++ } ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/EffectPhysicsState.java b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/EffectPhysicsState.java +new file mode 100644 +index 0000000..2a916f9 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/EffectPhysicsState.java +@@ -0,0 +1,90 @@ ++/* ++ * 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.core.Holder; ++import net.minecraft.world.effect.MobEffect; ++import net.minecraft.world.effect.MobEffectInstance; ++ ++import java.util.HashMap; ++import java.util.Map; ++import java.util.Objects; ++ ++public record EffectPhysicsState(Map, Value> values) { ++ public EffectPhysicsState { ++ values = Map.copyOf(values); ++ } ++ ++ public static EffectPhysicsState capture(final Map, MobEffectInstance> effects) { ++ final Map, Value> values = new HashMap<>(); ++ effects.forEach((type, effect) -> values.put(type, Value.capture(effect))); ++ return new EffectPhysicsState(values); ++ } ++ ++ public void restore(final Map, MobEffectInstance> effects) { ++ effects.clear(); ++ this.values.forEach((type, value) -> effects.put(type, value.create())); ++ } ++ ++ public void applyChanges(final EffectPhysicsState previous, final Map, MobEffectInstance> effects) { ++ previous.values.keySet().stream().filter(type -> !this.values.containsKey(type)).forEach(effects::remove); ++ this.values.forEach((type, value) -> { ++ if (!value.equals(previous.values.get(type))) effects.put(type, value.create()); ++ }); ++ } ++ ++ public interface HiddenEffect { ++ MobEffectInstance hiddenEffect(); ++ } ++ ++ /** The blend copy is owned but excluded from physical equality; rendering does not author movement commands. */ ++ public static final class Value { ++ private final Holder type; ++ private final int duration; ++ private final int amplifier; ++ private final boolean ambient; ++ private final boolean visible; ++ private final boolean icon; ++ private final Value hidden; ++ private final MobEffectInstance blend; ++ ++ private Value(final MobEffectInstance effect, final Value hidden) { ++ this.type = effect.getEffect(); ++ this.duration = effect.getDuration(); ++ this.amplifier = effect.getAmplifier(); ++ this.ambient = effect.isAmbient(); ++ this.visible = effect.isVisible(); ++ this.icon = effect.showIcon(); ++ this.hidden = hidden; ++ this.blend = new MobEffectInstance(effect); ++ this.blend.copyBlendState(effect); ++ } ++ ++ private static Value capture(final MobEffectInstance effect) { ++ final MobEffectInstance hidden = ((HiddenEffect) effect).hiddenEffect(); ++ return new Value(effect, hidden == null ? null : capture(hidden)); ++ } ++ ++ private MobEffectInstance create() { ++ final var result = new MobEffectInstance(this.type, this.duration, this.amplifier, ++ this.ambient, this.visible, this.icon, this.hidden == null ? null : this.hidden.create()); ++ result.copyBlendState(this.blend); ++ return result; ++ } ++ ++ @Override ++ public boolean equals(final Object other) { ++ return other instanceof Value value && this.type.equals(value.type) && this.duration == value.duration ++ && this.amplifier == value.amplifier && this.ambient == value.ambient && this.visible == value.visible ++ && this.icon == value.icon && Objects.equals(this.hidden, value.hidden); ++ } ++ ++ @Override ++ public int hashCode() { ++ return Objects.hash(this.type, this.duration, this.amplifier, this.ambient, this.visible, this.icon, this.hidden); ++ } ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/FluidPhysicsState.java b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/FluidPhysicsState.java +new file mode 100644 +index 0000000..dd2b859 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/FluidPhysicsState.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.movement.replay; ++ ++import net.minecraft.core.Holder; ++import net.minecraft.tags.TagKey; ++import net.minecraft.world.level.material.Fluid; ++import net.minecraft.world.phys.Vec3; ++import net.raphimc.viabedrock.api.model.FluidCurrent; ++ ++import java.util.List; ++import java.util.Map; ++ ++/** Value records own the tracker list and accumulator map instead of retaining live mutable containers. */ ++public record FluidPhysicsState(List trackers, Map, Current> currents) { ++ public FluidPhysicsState { ++ trackers = List.copyOf(trackers); ++ currents = Map.copyOf(currents); ++ } ++ ++ public record Tracker(Holder type, double height, boolean eyesInside) {} ++ public record Current(double height, Vec3 current, int count, FluidCurrent.State nativeCurrent) {} ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalActorState.java b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalActorState.java +new file mode 100644 +index 0000000..10d5a13 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalActorState.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.movement.replay; ++ ++import net.raphimc.viabedrock.api.model.PlayerCorrection; ++import net.raphimc.viabedrock.api.model.ActorMotionCommand; ++import net.raphimc.viabedrock.protocol.model.Position2f; ++import net.raphimc.viabedrock.protocol.model.Position3f; ++ ++import com.viaversion.viafabricplus.bedrock.render.BedrockActorState; ++import net.minecraft.client.multiplayer.ClientLevel; ++import net.minecraft.client.player.LocalPlayer; ++import net.minecraft.world.entity.Entity; ++import net.minecraft.world.entity.LivingEntity; ++import net.minecraft.world.entity.AgeableMob; ++import net.minecraft.world.entity.animal.equine.AbstractHorse; ++import net.minecraft.world.entity.player.Player; ++import net.minecraft.world.entity.vehicle.boat.AbstractBoat; ++ ++import java.util.Objects; ++import net.raphimc.viabedrock.api.model.NativeActorMessage; ++import java.util.UUID; ++ ++public record PhysicalActorState(Entity actor, ClientLevel level, int id, UUID uuid, UUID nativeLifetime, NativeActorMessage.Snapshot metadata, boolean physical, CorrectionValues correction, PhysicalState state) { ++ public static PhysicalActorState capture(final Entity actor) { ++ final var builder = PhysicalState.builder(actor); ++ com.viaversion.viafabricplus.bedrock.input.BedrockMovementEffects.capturePhysicalValues(actor.getId(), builder); ++ ((PhysicalStateAccess.Base) actor).captureBasePhysics(builder); ++ if (actor instanceof LivingEntity) ((PhysicalStateAccess.Living) actor).captureLivingPhysics(builder); ++ if (actor instanceof AgeableMob) ((PhysicalStateAccess.Age) actor).captureAgePhysics(builder); ++ if (actor instanceof Player) ((PhysicalStateAccess.Player) actor).capturePlayerPhysics(builder); ++ if (actor instanceof LocalPlayer) ((PhysicalStateAccess.Local) actor).captureLocalPhysics(builder); ++ if (actor instanceof AbstractBoat) ((PhysicalStateAccess.Boat) actor).captureBoatPhysics(builder); ++ if (actor instanceof AbstractHorse) ((PhysicalStateAccess.Horse) actor).captureHorsePhysics(builder); ++ return new PhysicalActorState(actor, (ClientLevel) actor.level(), actor.getId(), actor.getUUID(), ++ BedrockActorState.lifetime(actor.getUUID()), BedrockActorState.state(actor.getUUID()), true, CorrectionValues.capture(actor), builder.build()); ++ } ++ ++ public static PhysicalActorState relationships(final Entity actor) { ++ final var builder = PhysicalState.builder(actor); ++ ((PhysicalStateAccess.Base) actor).captureRelationships(builder); ++ return new PhysicalActorState(actor, (ClientLevel) actor.level(), actor.getId(), actor.getUUID(), ++ BedrockActorState.lifetime(actor.getUUID()), BedrockActorState.state(actor.getUUID()), false, null, builder.build()); ++ } ++ ++ public boolean accepts(final PlayerCorrection command, final LocalPlayer player) { ++ return this.physical && this.valid(player) && this.id == command.entityId() && this.uuid.equals(command.uuid()) ++ && Objects.equals(this.nativeLifetime, command.lifetime()) && this.correction != null ++ && (command.vehicle() ? this.correction.authoritative && player.getUUID().equals(this.correction.controller) ++ : this.actor == player && !this.correction.passenger); ++ } ++ ++ public boolean accepts(final ActorMotionCommand command, final LocalPlayer player) { ++ return this.physical && this.valid(player) && this.id == command.entityId() && this.uuid.equals(command.uuid()) ++ && Objects.equals(this.nativeLifetime, command.lifetime()) && this.correction != null ++ && (command.vehicle() ? this.correction.authoritative && player.getUUID().equals(this.correction.controller) : this.actor == player); ++ } ++ ++ public record CorrectionValues(Position3f position, ++ Position3f motion, boolean onGround, ++ Position2f rotation, Float angular, ++ boolean passenger, boolean authoritative, UUID controller) { ++ private static CorrectionValues capture(final Entity actor) { ++ final var velocity = actor.getDeltaMovement(); ++ return new CorrectionValues(new Position3f((float) actor.getX(), (float) actor.getY(), (float) actor.getZ()), ++ new Position3f((float) velocity.x, (float) velocity.y, (float) velocity.z), actor.onGround(), ++ new Position2f(actor.getXRot(), actor.getYRot()), ++ actor instanceof AbstractBoat ? ((PhysicalStateAccess.Boat) actor).angularVelocity() : null, ++ actor.isPassenger(), actor.isLocalInstanceAuthoritative(), ++ actor.getControllingPassenger() == null ? null : actor.getControllingPassenger().getUUID()); ++ } ++ ++ public boolean differs(final PlayerCorrection command) { ++ return command.differs(this.position, this.motion, this.onGround, this.rotation, this.angular); ++ } ++ } ++ ++ public void restore() { ++ PhysicalReplayContext.actor(this.uuid, this.metadata); ++ this.state.restore(); ++ } ++ ++ public boolean valid(final LocalPlayer player) { ++ return !this.actor.isRemoved() && this.actor.level() == this.level && this.actor.getId() == this.id ++ && this.actor.getUUID().equals(this.uuid) && (this.actor == player || this.level.getEntity(this.id) == this.actor) ++ && Objects.equals(BedrockActorState.liveLifetime(this.uuid), this.nativeLifetime); ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalBoundingBox.java b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalBoundingBox.java +new file mode 100644 +index 0000000..b9515b0 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalBoundingBox.java +@@ -0,0 +1,16 @@ ++/* ++ * 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.world.phys.AABB; ++import net.minecraft.world.phys.Vec3; ++ ++/** Snapshot restoration keeps exact bounds. A later shape command uses the corrected actor position. */ ++public record PhysicalBoundingBox(AABB bounds, Vec3 position) { ++ public AABB at(final Vec3 corrected) { ++ return this.bounds.move(corrected.subtract(this.position)); ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalCollisionCache.java b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalCollisionCache.java +new file mode 100644 +index 0000000..091689f +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalCollisionCache.java +@@ -0,0 +1,98 @@ ++/* ++ * 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 com.viaversion.viafabricplus.bedrock.render.BedrockActorState; ++import net.minecraft.world.entity.Entity; ++import net.minecraft.world.level.Level; ++import net.minecraft.world.phys.AABB; ++import net.minecraft.world.phys.shapes.Shapes; ++import net.minecraft.world.phys.shapes.VoxelShape; ++import net.raphimc.viabedrock.api.model.CollisionContact; ++import net.raphimc.viabedrock.api.model.MovementCollisionQuery; ++import net.raphimc.viabedrock.api.model.NativeMovementSources; ++import net.raphimc.viabedrock.api.model.NativeActorBoundsMessage; ++import net.raphimc.viabedrock.protocol.model.Position3f; ++ ++import java.util.ArrayList; ++import java.util.List; ++import java.util.Objects; ++import java.util.UUID; ++import java.util.function.Function; ++ ++/** Each execution owns its mutable query cache; retained inputs own immutable geometry. */ ++public final class PhysicalCollisionCache { ++ private final Position3f position; ++ private CollisionContact.Box bounds; ++ private final List shapes = new ArrayList<>(); ++ ++ public PhysicalCollisionCache(final Position3f position, final Snapshot captured) { ++ this.position = position; ++ if (captured != null && MovementCollisionQuery.reuses(captured.position, position)) { ++ this.bounds = captured.bounds; ++ captured.shapes.forEach(shape -> this.shapes.add(shape.refresh())); ++ } ++ } ++ ++ public List gather(final CollisionContact.Box requested, final Function> fetch) { ++ final var missing = MovementCollisionQuery.missing(requested, this.bounds); ++ if (!missing.isEmpty()) { ++ for (var region : missing) this.shapes.addAll(fetch.apply(region)); ++ this.bounds = requested; ++ } ++ return this.shapes.stream().map(shape -> Shapes.create(shape.bounds)).toList(); ++ } ++ ++ public Snapshot snapshot() { ++ return new Snapshot(this.position, this.bounds, this.shapes); ++ } ++ ++ public record Shape(AABB bounds, Owner owner) { ++ public static Shape fixed(final AABB bounds) { ++ return new Shape(bounds, null); ++ } ++ ++ public static Shape actor(final Entity actor) { ++ final var movement = BedrockActorState.movement(actor.getUUID()); ++ return new Shape(actor.getBoundingBox(), new Owner(actor, actor.level(), actor.getId(), actor.getUUID(), ++ BedrockActorState.lifetime(actor.getUUID()), movement == null ? NativeMovementSources.Interpolation.EMPTY : movement.interpolation())); ++ } ++ ++ private Shape refresh() { ++ if (this.owner == null || !this.owner.valid()) return this; ++ final var movement = BedrockActorState.movement(this.owner.uuid); ++ final var dimensions = BedrockActorState.bounds(this.owner.uuid); ++ final var metadata = BedrockActorState.state(this.owner.uuid); ++ if (movement == null || dimensions == null || metadata == null || dimensions.width() == null || dimensions.height() == null ++ || !movement.command().lifetime().equals(this.owner.lifetime)) return this; ++ final var point = movement.position(this.owner.interpolation); ++ if (point == null) return this; ++ final CollisionContact.Box refreshed; ++ try { ++ refreshed = NativeMovementSources.bounds(point, ++ NativeActorBoundsMessage.scaledDimension(dimensions.width(), metadata.scale()), ++ NativeActorBoundsMessage.scaledDimension(dimensions.height(), metadata.scale()), movement.command().originOffset()); ++ } catch (IllegalArgumentException unavailableDimensions) { ++ return this; ++ } ++ return new Shape(new AABB(refreshed.minX(), refreshed.minY(), refreshed.minZ(), refreshed.maxX(), refreshed.maxY(), refreshed.maxZ()), this.owner); ++ } ++ } ++ ++ public record Owner(Entity actor, Level level, int id, UUID uuid, UUID lifetime, NativeMovementSources.Interpolation interpolation) { ++ private boolean valid() { ++ return !this.actor.isRemoved() && this.actor.level() == this.level && this.actor.getId() == this.id ++ && this.actor.getUUID().equals(this.uuid) && this.level.getEntity(this.id) == this.actor ++ && Objects.equals(BedrockActorState.liveLifetime(this.uuid), this.lifetime); ++ } ++ } ++ ++ public record Snapshot(Position3f position, CollisionContact.Box bounds, List shapes) { ++ public Snapshot { ++ shapes = List.copyOf(shapes); ++ } ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalCollisions.java b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalCollisions.java +new file mode 100644 +index 0000000..b9659b3 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalCollisions.java +@@ -0,0 +1,104 @@ ++/* ++ * 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.core.BlockPos; ++import net.minecraft.world.entity.Entity; ++import net.minecraft.world.entity.EntitySelector; ++import net.minecraft.world.level.Level; ++import net.minecraft.world.level.chunk.status.ChunkStatus; ++import net.minecraft.world.phys.AABB; ++import net.minecraft.world.phys.Vec3; ++import net.minecraft.world.phys.shapes.CollisionContext; ++import net.minecraft.world.phys.shapes.VoxelShape; ++import net.raphimc.viabedrock.api.model.CollisionContact; ++import net.raphimc.viabedrock.api.model.MovementCollisionQuery; ++import net.raphimc.viabedrock.api.model.MovementCollisionWorld; ++import net.raphimc.viabedrock.protocol.model.Position3f; ++ ++import java.util.ArrayList; ++import java.util.List; ++import java.util.function.Function; ++ ++/** The native movement request gathers once, including the native step and inset regions. */ ++public final class PhysicalCollisions { ++ private static final ThreadLocal CURRENT = new ThreadLocal<>(); ++ ++ private PhysicalCollisions() {} ++ ++ public static Vec3 collide(final Entity actor, final Vec3 requested, final Function original) { ++ final PhysicalCollisionCache cache = PhysicalReplayContext.collisions(actor); ++ if (cache == null) return original.apply(requested); ++ final Position3f motion = position(requested); ++ final Position3f capped = MovementCollisionQuery.cap(motion); ++ // Preserve precise Java contact boundaries unless the native request actually hits its motion cap. ++ final Vec3 effective = capped == motion ? requested : new Vec3(capped.x(), capped.y(), capped.z()); ++ final Move previous = CURRENT.get(); ++ CURRENT.set(new Move(actor, cache, MovementCollisionQuery.bounds(box(actor.getBoundingBox()), capped, actor.maxUpStep()))); ++ try { ++ // Native captures a collision request even when its resulting displacement is zero. ++ collect(actor.level()); ++ return original.apply(effective); ++ } finally { ++ if (previous == null) CURRENT.remove(); ++ else CURRENT.set(previous); ++ } ++ } ++ ++ public static boolean active(final Entity actor) { ++ final Move move = CURRENT.get(); ++ return move != null && move.actor == actor; ++ } ++ ++ public static List collect(final Level level) { ++ final Move move = CURRENT.get(); ++ return move != null && move.actor.level() == level ? move.cache.gather(move.query, region -> fetch(move.actor, region)) : null; ++ } ++ ++ private static List fetch(final Entity actor, final CollisionContact.Box region) { ++ final Level level = actor.level(); ++ final AABB bounds = box(region); ++ final List boxes = new ArrayList<>(); ++ for (var collider : level.getEntities(actor, bounds.inflate(1E-7), EntitySelector.NO_SPECTATORS.and(actor::canCollideWith))) { ++ boxes.add(PhysicalCollisionCache.Shape.actor(collider)); ++ } ++ MovementCollisionWorld.unloaded(region, (x, z) -> loaded(level, x, z), shape -> boxes.add(PhysicalCollisionCache.Shape.fixed(box(shape)))); ++ final var below = MovementCollisionWorld.belowWorld(region, level.getMinY()); ++ if (below != null) boxes.add(PhysicalCollisionCache.Shape.fixed(box(below))); ++ final var context = CollisionContext.of(actor); ++ final var position = new BlockPos.MutableBlockPos(); ++ MovementCollisionWorld.blocks(region, level.getMinY(), level.getMaxY(), (x, z) -> loaded(level, x >> 4, z >> 4), (x, y, z) -> { ++ position.set(x, y, z); ++ final var block = level.getBlockState(position); ++ copy(block.getCollisionShape(level, position, context).move(x, y, z), boxes); ++ }); ++ final var border = level.getWorldBorder(); ++ if (border.isInsideCloseToBorder(actor, bounds)) copy(border.getCollisionShape(), boxes); ++ return boxes; ++ } ++ ++ private static boolean loaded(final Level level, final int x, final int z) { ++ return level.getChunkSource().getChunk(x, z, ChunkStatus.FULL, false) != null; ++ } ++ ++ private static void copy(final VoxelShape shape, final List target) { ++ shape.forAllBoxes((minX, minY, minZ, maxX, maxY, maxZ) -> target.add(PhysicalCollisionCache.Shape.fixed(new AABB(minX, minY, minZ, maxX, maxY, maxZ)))); ++ } ++ ++ static Position3f position(final Vec3 position) { ++ return new Position3f((float) position.x, (float) position.y, (float) position.z); ++ } ++ ++ private static AABB box(final CollisionContact.Box box) { ++ return new AABB(box.minX(), box.minY(), box.minZ(), box.maxX(), box.maxY(), box.maxZ()); ++ } ++ ++ private static CollisionContact.Box box(final AABB box) { ++ return new CollisionContact.Box((float) box.minX, (float) box.minY, (float) box.minZ, (float) box.maxX, (float) box.maxY, (float) box.maxZ); ++ } ++ ++ private record Move(Entity actor, PhysicalCollisionCache cache, CollisionContact.Box query) {} ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalGraphState.java b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalGraphState.java +new file mode 100644 +index 0000000..ff9d3ab +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalGraphState.java +@@ -0,0 +1,90 @@ ++/* ++ * 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.raphimc.viabedrock.api.model.PlayerCorrection; ++import net.raphimc.viabedrock.api.model.ActorMotionCommand; ++ ++import net.minecraft.client.player.LocalPlayer; ++import net.minecraft.world.entity.Entity; ++ ++import java.util.ArrayList; ++import java.util.Collections; ++import java.util.IdentityHashMap; ++import java.util.List; ++import java.util.Map; ++ ++/** Player, vehicle, and seat members share one capture and one chronological replay. */ ++public final class PhysicalGraphState { ++ private final Map actors; ++ private final long controlLease; ++ ++ private PhysicalGraphState(final Map actors, final long controlLease) { ++ this.actors = Collections.unmodifiableMap(new IdentityHashMap<>(actors)); ++ this.controlLease = controlLease; ++ } ++ ++ public static PhysicalGraphState capture(final Iterable actors, final Iterable members, final long controlLease) { ++ final Map values = new IdentityHashMap<>(); ++ actors.forEach(actor -> values.put(actor, PhysicalActorState.capture(actor))); ++ members.forEach(actor -> { ++ if (!values.containsKey(actor)) values.put(actor, PhysicalActorState.relationships(actor)); ++ }); ++ return new PhysicalGraphState(values, controlLease); ++ } ++ ++ public PhysicalActorState correctionTarget(final Entity actor, final LocalPlayer player, ++ final PlayerCorrection command, final long currentLease) { ++ final PhysicalActorState state = this.actors.get(actor); ++ return state != null && state.accepts(command, player) && (!command.vehicle() || this.controlLease == currentLease) ? state : null; ++ } ++ ++ public boolean acceptsMotion(final Entity actor, final LocalPlayer player, final ActorMotionCommand command, final long currentLease) { ++ final PhysicalActorState state = this.actors.get(actor); ++ return state != null && state.accepts(command, player) && (!command.vehicle() || this.controlLease == currentLease); ++ } ++ ++ public boolean valid(final LocalPlayer player) { ++ return this.actors.values().stream().allMatch(actor -> actor.valid(player)); ++ } ++ ++ public void restore() { ++ this.actors.values().forEach(PhysicalActorState::restore); ++ } ++ ++ /** Refresh the historical graph's participants, independently of today's mount and seat layout. */ ++ public PhysicalGraphState recapture() { ++ final Map values = new IdentityHashMap<>(); ++ this.actors.forEach((actor, previous) -> values.put(actor, ++ previous.physical() ? PhysicalActorState.capture(actor) : PhysicalActorState.relationships(actor))); ++ return new PhysicalGraphState(values, this.controlLease); ++ } ++ ++ public Changes changesSince(final PhysicalGraphState previous) { ++ final List changes = new ArrayList<>(); ++ this.actors.forEach((actor, value) -> { ++ final PhysicalActorState before = previous.actors.get(actor); ++ if (before == null || before.physical() != value.physical()) changes.add(value::restore); ++ else { ++ if (!java.util.Objects.equals(value.metadata(), before.metadata())) { ++ changes.add(() -> PhysicalReplayContext.actor(value.uuid(), value.metadata())); ++ } ++ changes.add(value.state().changesSince(before.state())::apply); ++ } ++ }); ++ return new Changes(changes); ++ } ++ ++ public record Changes(List values) { ++ public Changes { ++ values = List.copyOf(values); ++ } ++ ++ public void apply() { ++ this.values.forEach(Runnable::run); ++ } ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalInput.java b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalInput.java +new file mode 100644 +index 0000000..4ea4d8c +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalInput.java +@@ -0,0 +1,20 @@ ++/* ++ * 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.client.player.ClientInput; ++import net.minecraft.world.entity.player.Input; ++ ++/** Raw input polling is recorded separately from posture, item-use scaling, and automatic jumping. */ ++public record PhysicalInput(Input keys, float strafe, float forward) { ++ public static PhysicalInput capture(final ClientInput input) { ++ return new PhysicalInput(input.keyPresses, input.getMoveVector().x, input.getMoveVector().y); ++ } ++ ++ public void restore(final ClientInput input) { ++ ((PhysicalStateAccess.Input) input).restorePhysicalInput(this); ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalItemUse.java b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalItemUse.java +new file mode 100644 +index 0000000..f3a8d05 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalItemUse.java +@@ -0,0 +1,21 @@ ++/* ++ * 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.world.InteractionHand; ++import net.minecraft.world.item.ItemStack; ++ ++/** Movement observes item use; replay never rewinds the live consumption cycle or its inventory. */ ++public record PhysicalItemUse(boolean active, InteractionHand hand, int remaining, byte flags, ItemStack item) { ++ public PhysicalItemUse { ++ item = item.copy(); ++ } ++ ++ @Override ++ public ItemStack item() { ++ return this.item.copy(); ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalMovementCorrection.java b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalMovementCorrection.java +new file mode 100644 +index 0000000..79ccffe +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalMovementCorrection.java +@@ -0,0 +1,35 @@ ++/* ++ * 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.world.entity.Entity; ++import net.minecraft.world.entity.vehicle.boat.AbstractBoat; ++import net.minecraft.world.phys.AABB; ++import net.minecraft.world.phys.Vec3; ++import net.raphimc.viabedrock.api.model.PlayerCorrection; ++ ++/** The native command changes current kinematics, preserving the restored previous transform. */ ++public final class PhysicalMovementCorrection { ++ private PhysicalMovementCorrection() {} ++ ++ public static void apply(final Entity actor, final PlayerCorrection correction) { ++ final var position = correction.javaPosition(); ++ actor.setPos(position.x(), position.y(), position.z()); ++ actor.setDeltaMovement(new Vec3(correction.motion().x(), correction.motion().y(), correction.motion().z())); ++ actor.setOnGround(correction.onGround()); ++ if (correction.vehicle()) { ++ actor.setXRot(correction.rotation().x()); ++ actor.setYRot(correction.javaYaw()); ++ if (correction.angularVelocity() != null && actor instanceof AbstractBoat) { ++ ((PhysicalStateAccess.Boat) actor).correctAngularVelocity(correction.angularVelocity()); ++ } ++ } ++ final float halfWidth = actor.getBbWidth() * 0.5F; ++ final float minY = correction.position().y() - correction.originOffset(); ++ actor.setBoundingBox(new AABB(correction.position().x() - halfWidth, minY, correction.position().z() - halfWidth, ++ correction.position().x() + halfWidth, minY + actor.getBbHeight(), correction.position().z() + halfWidth)); ++ } ++} +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..10e89c1 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalReplayContext.java +@@ -0,0 +1,234 @@ ++/* ++ * 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.world.entity.Entity; ++import net.minecraft.world.entity.EquipmentSlot; ++import net.minecraft.world.entity.LivingEntity; ++import net.minecraft.world.item.ItemStack; ++import net.minecraft.network.Connection; ++import net.minecraft.world.level.GameType; ++import net.raphimc.viabedrock.api.model.PlayerInputPermissions; ++import java.util.EnumMap; ++import java.util.IdentityHashMap; ++import net.minecraft.world.level.Level; ++import net.raphimc.viabedrock.api.model.NativeActorMessage; ++ ++import java.util.HashMap; ++import java.util.Map; ++import java.util.Set; ++import java.util.UUID; ++import java.util.function.Supplier; ++ ++/** Historical physics runs on the client thread. Other threads retain normal network and world behavior. */ ++public final class PhysicalReplayContext { ++ private static final ThreadLocal CURRENT = new ThreadLocal<>(); ++ private static final ThreadLocal RECORDING = new ThreadLocal<>(); ++ ++ private PhysicalReplayContext() {} ++ ++ public static Scope enter(final Level level, final Connection connection, final Set predictedActors) { ++ if (CURRENT.get() != null) throw new IllegalStateException("Physical replay is already running"); ++ CURRENT.set(new State(level, connection, predictedActors)); ++ return CURRENT::remove; ++ } ++ ++ public static boolean replaying() { ++ return CURRENT.get() != null; ++ } ++ ++ public static boolean predicts(final Entity actor) { ++ final State state = CURRENT.get(); ++ return state != null && (state.step == null ? state.actors : state.step.predictedActors).contains(actor); ++ } ++ ++ public static boolean recording() { ++ return RECORDING.get() != null; ++ } ++ ++ public static boolean suppressesNetwork(final Connection connection) { ++ final State state = CURRENT.get(); ++ return state != null && state.connection == connection; ++ } ++ ++ public static PlayerInputPermissions permissions(final PlayerInputPermissions live) { ++ final State state = CURRENT.get(); ++ return state != null && state.permissions != null ? state.permissions : live; ++ } ++ ++ public static void restorePermissions(final PlayerInputPermissions value) { ++ CURRENT.get().permissions = value; ++ } ++ ++ public static GameType gameMode(final GameType live) { ++ final State state = CURRENT.get(); ++ return state != null && state.gameMode != null ? state.gameMode : live; ++ } ++ ++ public static void restoreGameMode(final GameType value) { ++ CURRENT.get().gameMode = value; ++ } ++ ++ public static Entity camera(final Entity liveCamera) { ++ final State state = CURRENT.get(); ++ return state != null && state.step != null ? state.step.camera : liveCamera; ++ } ++ ++ public static void recordItemUse(final Entity actor, final PhysicalItemUse use) { ++ final PhysicalStep recording = RECORDING.get(); ++ if (recording != null) recording.itemUse.put(actor, use); ++ } ++ ++ public static PhysicalItemUse itemUse(final Entity actor) { ++ final State state = CURRENT.get(); ++ if (state == null || state.step == null || !state.step.itemUse.containsKey(actor)) { ++ throw new IllegalStateException("Historical physics requested unrecorded item-use input"); ++ } ++ return state.step.itemUse.get(actor); ++ } ++ ++ public static ItemStack equipment(final LivingEntity actor, final EquipmentSlot slot) { ++ final State state = CURRENT.get(); ++ if (state == null || state.step == null) return null; ++ final var equipment = state.step.equipment.get(actor); ++ return equipment == null ? null : equipment.get(slot).copy(); ++ } ++ ++ public static void frame(final long frame) { ++ CURRENT.get().frame = frame; ++ } ++ ++ public static long completedFrame(final long liveFrame) { ++ final State state = CURRENT.get(); ++ return state == null ? liveFrame : state.frame - 1; ++ } ++ ++ public static Long gameTime(final Level level) { ++ final State state = CURRENT.get(); ++ return state != null && state.level == level && state.step != null ? state.step.gameTime : null; ++ } ++ ++ public static NativeActorMessage.Snapshot actor(final UUID uuid, final Supplier live) { ++ final State state = CURRENT.get(); ++ return state != null && state.metadata.containsKey(uuid) ? state.metadata.get(uuid) : live.get(); ++ } ++ ++ public static void actor(final UUID uuid, final NativeActorMessage.Snapshot value) { ++ CURRENT.get().metadata.put(uuid, value); ++ } ++ ++ public static PhysicalCollisionCache collisions(final Entity actor) { ++ final State state = CURRENT.get(); ++ if (state != null) { ++ if (state.step == null || !state.step.predictedActors.contains(actor)) return null; ++ return state.collisions.computeIfAbsent(actor, entity -> new PhysicalCollisionCache( ++ PhysicalCollisions.position(entity.position()), state.step.collisionInputs.get(entity))); ++ } ++ final PhysicalStep recording = RECORDING.get(); ++ if (recording == null || !recording.predictedActors.contains(actor)) return null; ++ 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) { ++ final State state = CURRENT.get(); ++ if (state != null) { ++ if (state.step == null || state.poll >= state.step.inputs.size()) { ++ throw new IllegalStateException("Historical physics requested an unrecorded input poll"); ++ } ++ state.step.inputs.get(state.poll++).restore(input); ++ } else { ++ livePoll.run(); ++ final PhysicalStep recording = RECORDING.get(); ++ if (recording != null) recording.inputs.add(PhysicalInput.capture(input)); ++ } ++ } ++ ++ public static final class PhysicalStep { ++ private final long gameTime; ++ private final Runnable run; ++ private final Runnable historical; ++ private final Set predictedActors; ++ private final Entity camera; ++ private final Map itemUse = new IdentityHashMap<>(); ++ private final Map> equipment = new IdentityHashMap<>(); ++ private final java.util.List inputs = new java.util.ArrayList<>(); ++ private final Map collisionInputs = new IdentityHashMap<>(); ++ private final Map collisions = new IdentityHashMap<>(); ++ ++ public PhysicalStep(final long gameTime, final Set predictedActors, final Entity camera, ++ final Iterable actors, final Runnable run) { ++ this(gameTime, predictedActors, camera, actors, run, run); ++ } ++ ++ public PhysicalStep(final long gameTime, final Set predictedActors, final Entity camera, ++ final Iterable actors, final Runnable run, final Runnable historical) { ++ this.gameTime = gameTime; ++ this.run = run; ++ this.historical = historical; ++ final Set owned = java.util.Collections.newSetFromMap(new IdentityHashMap<>()); ++ owned.addAll(predictedActors); ++ this.predictedActors = java.util.Collections.unmodifiableSet(owned); ++ this.camera = camera; ++ actors.forEach(actor -> { ++ if (!(actor instanceof LivingEntity living)) return; ++ final Map equipment = new EnumMap<>(EquipmentSlot.class); ++ for (EquipmentSlot slot : EquipmentSlot.values()) equipment.put(slot, living.getItemBySlot(slot).copy()); ++ this.equipment.put(living, equipment); ++ }); ++ } ++ ++ public void record() { ++ final PhysicalStep previous = RECORDING.get(); ++ RECORDING.set(this); ++ try { ++ this.run.run(); ++ } finally { ++ this.collisions.forEach((actor, cache) -> this.collisionInputs.put(actor, cache.snapshot())); ++ this.collisions.clear(); ++ if (previous == null) RECORDING.remove(); ++ else RECORDING.set(previous); ++ } ++ } ++ ++ public void replay() { ++ final State state = CURRENT.get(); ++ state.step = this; ++ state.poll = 0; ++ state.collisions.clear(); ++ try { ++ this.historical.run(); ++ if (state.poll != this.inputs.size()) throw new IllegalStateException("Historical physics skipped a recorded input poll"); ++ } finally { ++ state.step = null; ++ state.poll = 0; ++ state.collisions.clear(); ++ } ++ } ++ } ++ ++ public interface Scope extends AutoCloseable { ++ @Override void close(); ++ } ++ ++ private static final class State { ++ private final Level level; ++ private final Connection connection; ++ private final Set actors; ++ private final Map metadata = new HashMap<>(); ++ private PlayerInputPermissions permissions; ++ private GameType gameMode; ++ private long frame = -1; ++ private PhysicalStep step; ++ private final Map collisions = new IdentityHashMap<>(); ++ private int poll; ++ ++ private State(final Level level, final Connection connection, final Set actors) { ++ this.level = level; ++ this.connection = connection; ++ this.actors = actors; ++ } ++ } ++} +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 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalReplayHistory.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.movement.replay; ++ ++import net.raphimc.viabedrock.api.model.MovementCorrectionTiming; ++ ++import java.util.ArrayList; ++import java.util.LinkedHashMap; ++import java.util.List; ++import java.util.SequencedMap; ++import java.util.function.BiFunction; ++import java.util.function.Consumer; ++import java.util.function.Predicate; ++import java.util.function.LongConsumer; ++import java.util.function.Supplier; ++import java.util.function.UnaryOperator; ++ ++/** One bounded journal for an actor graph. Current input is executed by the ordinary client tick exactly once. */ ++public final class PhysicalReplayHistory { ++ private final int capacity; ++ private final Supplier capture; ++ private final UnaryOperator recapture; ++ private final Consumer restore; ++ private final BiFunction changes; ++ private final Predicate valid; ++ private final LongConsumer beginReplayFrame; ++ private final LongConsumer applyFrameCommands; ++ private final SequencedMap> frames = new LinkedHashMap<>(); ++ private final List> pending = new ArrayList<>(); ++ private Frame open; ++ private long completed = -1; ++ ++ public PhysicalReplayHistory(final int capacity, final Supplier capture, final UnaryOperator recapture, final Consumer restore, ++ final BiFunction changes, final Predicate valid, final LongConsumer beginReplayFrame, final LongConsumer applyFrameCommands) { ++ this.capacity = Math.clamp(capacity, 1, 1000); ++ this.capture = capture; ++ this.recapture = recapture; ++ this.restore = restore; ++ this.changes = changes; ++ this.valid = valid; ++ this.beginReplayFrame = beginReplayFrame; ++ this.applyFrameCommands = applyFrameCommands; ++ } ++ ++ public void begin(final long frame) { ++ if (this.open != null && this.open.id == frame) return; ++ if (frame < 0) throw new IllegalArgumentException("Negative physical frame"); ++ if (this.open != null || !this.frames.isEmpty() && frame != this.completed + 1) this.clear(); ++ final S state = this.capture.get(); ++ final Frame previous = this.frames.get(this.completed); ++ final Runnable external = previous == null ? () -> {} : this.changes.apply(previous.after, state); ++ this.open = new Frame<>(frame, state, external); ++ this.frames.put(frame, this.open); ++ this.open.commands.addAll(this.pending); ++ this.pending.clear(); ++ while (this.frames.size() > this.capacity) this.frames.pollFirstEntry(); ++ } ++ ++ public void admitExternal(final List commands) { ++ if (this.open != null) this.open.externalCommands.addAll(commands); ++ } ++ ++ public void record(final Runnable physicalStep) { ++ if (this.open != null) this.open.steps.add(physicalStep); ++ } ++ ++ public void complete(final long frame) { ++ if (this.open == null || this.open.id != frame) return; ++ this.open.after = this.capture.get(); ++ this.completed = frame; ++ this.open = null; ++ } ++ ++ public boolean markDirty(final long frame) { ++ final Frame target = this.frames.get(frame); ++ if (target == null) return false; ++ target.dirty = true; ++ return true; ++ } ++ ++ public long oldestFrame() { ++ return this.frames.isEmpty() ? -1 : this.frames.firstEntry().getKey(); ++ } ++ ++ public long completedFrame() { ++ return this.completed; ++ } ++ ++ public S before(final long frame) { ++ final Frame value = this.frames.get(frame); ++ return value == null ? null : value.before; ++ } ++ ++ public boolean schedule(final long frame, final Predicate applicable, final Runnable command, final boolean dirty) { ++ final Frame target = this.frames.get(frame); ++ if (target != null) { ++ target.commands.add(new Command<>(applicable, command, dirty)); ++ return true; ++ } ++ if (!this.frames.isEmpty() && frame == this.completed + 1 && this.open == null) { ++ this.pending.add(new Command<>(applicable, command, true)); ++ return true; ++ } ++ return false; ++ } ++ ++ /** The live velocity is already applied; retain its native tick placement independently. */ ++ public boolean scheduleMotion(final MovementCorrectionTiming timing, final Predicate applicable, final Runnable action) { ++ final var placement = timing.locateMotion(this.oldestFrame(), this.completedFrame()); ++ return placement != null && this.schedule(placement.frame(), applicable, action, placement.dirty() || placement.pending()); ++ } ++ ++ public boolean needsReplay() { ++ return this.frames.values().stream().anyMatch(frame -> frame.dirty ++ || frame.commands.stream().anyMatch(command -> command.dirty && command.applicable.test(frame.before))); ++ } ++ ++ /** Refresh each later snapshot after external writes and before that frame's authoritative commands. */ ++ public boolean replay() { ++ final Frame first = this.frames.values().stream().filter(frame -> frame.dirty || frame.commands.stream().anyMatch(command -> command.dirty && command.applicable.test(frame.before))).findFirst().orElse(null); ++ if (first == null) return false; ++ // Validate the entire suffix before restoring any actor or seat pointer. ++ if (this.frames.values().stream().filter(frame -> frame.id >= first.id) ++ .anyMatch(frame -> !this.valid.test(frame.before) || frame.after != null && !this.valid.test(frame.after))) { ++ this.clear(); ++ return false; ++ } ++ this.beginReplayFrame.accept(first.id); ++ this.restore.accept(first.before); ++ for (Frame frame : this.frames.values()) { ++ if (frame.id < first.id) continue; ++ if (frame != first) this.beginReplayFrame.accept(frame.id); ++ if (frame != first) { ++ frame.external.run(); ++ frame.externalCommands.forEach(Runnable::run); ++ frame.before = this.recapture.apply(frame.before); ++ } ++ this.applyFrameCommands.accept(frame.id); ++ frame.commands.forEach(command -> { ++ if (command.applicable.test(frame.before)) command.action.run(); ++ command.dirty = false; ++ }); ++ if (frame.after != null) { ++ frame.steps.forEach(Runnable::run); ++ frame.after = this.recapture.apply(frame.after); ++ } ++ frame.dirty = false; ++ } ++ return true; ++ } ++ ++ public void clear() { ++ this.frames.clear(); ++ this.pending.clear(); ++ this.open = null; ++ this.completed = -1; ++ } ++ ++ private static final class Command { ++ private final Predicate applicable; ++ private final Runnable action; ++ private boolean dirty; ++ ++ private Command(final Predicate applicable, final Runnable action, final boolean dirty) { ++ this.applicable = applicable; ++ this.action = action; ++ this.dirty = dirty; ++ } ++ } ++ ++ private static final class Frame { ++ private final long id; ++ private S before; ++ private S after; ++ private final Runnable external; ++ private final List externalCommands = new ArrayList<>(); ++ private final List steps = new ArrayList<>(); ++ private final List> commands = new ArrayList<>(); ++ private boolean dirty; ++ ++ private Frame(final long id, final S before, final Runnable external) { ++ this.id = id; ++ this.before = before; ++ this.external = external; ++ } ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalState.java b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalState.java +new file mode 100644 +index 0000000..6dd6b16 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalState.java +@@ -0,0 +1,140 @@ ++/* ++ * 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 java.util.ArrayList; ++import java.util.Collections; ++import java.util.LinkedHashMap; ++import java.util.List; ++import java.util.Map; ++import java.util.Objects; ++import java.util.function.BiPredicate; ++import java.util.function.BiConsumer; ++import java.util.function.Consumer; ++import java.util.function.UnaryOperator; ++ ++/** Owned physical component values. Setters are typed at capture; mutable values are copied on both boundaries. */ ++public final class PhysicalState { ++ private final Object owner; ++ private final Map, Value> values; ++ ++ private PhysicalState(final Object owner, final Map, Value> values) { ++ this.owner = owner; ++ this.values = Collections.unmodifiableMap(new LinkedHashMap<>(values)); ++ } ++ ++ public static Builder builder(final Object owner) { ++ return new Builder(owner); ++ } ++ ++ public void restore() { ++ this.values.values().forEach(Value::restore); ++ } ++ ++ /** Keep only changes arriving between completed physics and the next capture, not old predicted motion. */ ++ public Changes changesSince(final PhysicalState previous) { ++ if (this.owner != previous.owner || !this.values.keySet().equals(previous.values.keySet())) { ++ throw new IllegalArgumentException("Physical states belong to different actors or component layouts"); ++ } ++ final List changes = new ArrayList<>(); ++ this.values.forEach((key, value) -> { ++ if (value.reconcile != null && !value.sameValue(previous.values.get(key))) { ++ changes.add(value.changeFrom(previous.values.get(key))); ++ } ++ }); ++ return new Changes(changes); ++ } ++ ++ public static final class Changes { ++ private final List values; ++ ++ private Changes(final List values) { ++ this.values = List.copyOf(values); ++ } ++ ++ public void apply() { ++ this.values.forEach(Runnable::run); ++ } ++ ++ public boolean isEmpty() { ++ return this.values.isEmpty(); ++ } ++ } ++ ++ public static final class Builder { ++ private final Object owner; ++ private final Map, Value> values = new LinkedHashMap<>(); ++ private final Map, Value> lateValues = new LinkedHashMap<>(); ++ ++ private Builder(final Object owner) { ++ this.owner = Objects.requireNonNull(owner); ++ } ++ ++ /** This component has its own authoritative command journal and must not be admitted twice. */ ++ public Builder snapshot(final Enum key, final T value, final Consumer setter) { ++ return this.capture(key, value, setter, UnaryOperator.identity(), Objects::equals, null); ++ } ++ ++ public Builder capture(final Enum key, final T value, final Consumer setter) { ++ return this.capture(key, value, setter, UnaryOperator.identity(), Objects::equals); ++ } ++ ++ public Builder capture(final Enum key, final T value, final Consumer setter, ++ final UnaryOperator copy, final BiPredicate equality) { ++ return this.capture(key, value, setter, copy, equality, (previous, next) -> setter.accept(next)); ++ } ++ ++ public Builder capture(final Enum key, final T value, final Consumer setter, ++ final UnaryOperator copy, final BiPredicate equality, ++ final BiConsumer reconcile) { ++ return this.add(key, value, setter, copy, equality, reconcile, false); ++ } ++ ++ public Builder captureLate(final Enum key, final T value, final Consumer setter) { ++ return this.captureLate(key, value, setter, (previous, next) -> setter.accept(next)); ++ } ++ ++ public Builder captureLate(final Enum key, final T value, final Consumer setter, final BiConsumer reconcile) { ++ return this.add(key, value, setter, UnaryOperator.identity(), Objects::equals, reconcile, true); ++ } ++ ++ public Builder snapshotLate(final Enum key, final T value, final Consumer setter) { ++ return this.add(key, value, setter, UnaryOperator.identity(), Objects::equals, null, true); ++ } ++ ++ private Builder add(final Enum key, final T value, final Consumer setter, final UnaryOperator copy, ++ final BiPredicate equality, final BiConsumer reconcile, final boolean late) { ++ if (this.values.containsKey(key) || this.lateValues.containsKey(key)) { ++ throw new IllegalArgumentException("Duplicate physical component: " + key); ++ } ++ (late ? this.lateValues : this.values).put(key, new Value<>(copy.apply(value), setter, copy, equality, reconcile)); ++ return this; ++ } ++ ++ public PhysicalState build() { ++ final Map, Value> ordered = new LinkedHashMap<>(this.values); ++ ordered.putAll(this.lateValues); ++ return new PhysicalState(this.owner, ordered); ++ } ++ } ++ ++ private record Value(T value, Consumer setter, UnaryOperator copy, BiPredicate equality, BiConsumer reconcile) { ++ private void restore() { ++ this.setter.accept(this.copy.apply(this.value)); ++ } ++ ++ @SuppressWarnings("unchecked") ++ private Runnable changeFrom(final Value previous) { ++ return () -> this.reconcile.accept(this.copy.apply((T) previous.value), this.copy.apply(this.value)); ++ } ++ ++ @SuppressWarnings("unchecked") ++ private boolean sameValue(final Value other) { ++ // A component key has one type within one actor's capture layout. ++ return this.equality.test(this.value, (T) other.value); ++ } ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalStateAccess.java b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalStateAccess.java +new file mode 100644 +index 0000000..f724541 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalStateAccess.java +@@ -0,0 +1,43 @@ ++/* ++ * 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; ++ ++/** Each layer contributes its own typed fields, including fields private to its Minecraft class. */ ++public final class PhysicalStateAccess { ++ private PhysicalStateAccess() {} ++ ++ public interface MotionAnimation { void captureMotionAnimation(PhysicalState.Builder state); } ++ public interface Input { ++ void restorePhysicalInput(PhysicalInput value); ++ } ++ public interface Base { ++ void captureBasePhysics(PhysicalState.Builder state); ++ void captureRelationships(PhysicalState.Builder state); ++ } ++ public interface Age { void captureAgePhysics(PhysicalState.Builder state); } ++ public interface Living { void captureLivingPhysics(PhysicalState.Builder state); } ++ public interface Player { ++ void capturePlayerPhysics(PhysicalState.Builder state); ++ void replayPlayerTick(); ++ void replayPlayerAiStep(); ++ } ++ public interface Local { void captureLocalPhysics(PhysicalState.Builder state); } ++ public interface Boat { ++ void captureBoatPhysics(PhysicalState.Builder state); ++ float angularVelocity(); ++ void correctAngularVelocity(float value); ++ } ++ public interface Horse { void captureHorsePhysics(PhysicalState.Builder state); } ++ public interface FluidTracker { ++ FluidPhysicsState.Tracker captureFluidTracker(); ++ void restoreFluidTracker(FluidPhysicsState.Tracker state); ++ } ++ public interface FluidAccumulator { ++ FluidPhysicsState.Current captureFluidCurrent(); ++ void restoreFluidCurrent(FluidPhysicsState.Current state); ++ } ++ public interface Fluid { void captureFluidPhysics(PhysicalState.Builder state); } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorState.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorState.java +index cb1aa85..3574702 100644 +--- a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorState.java ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorState.java +@@ -6,9 +6,14 @@ + package com.viaversion.viafabricplus.bedrock.render; + + import com.viaversion.viafabricplus.bedrock.appearance.BedrockMolangColor; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalReplayContext; + import net.minecraft.client.Minecraft; + import net.minecraft.network.Connection; + import net.raphimc.viabedrock.api.model.NativeActorMessage; ++import net.raphimc.viabedrock.api.model.ActorSpatialCommand; ++import net.raphimc.viabedrock.api.model.NativeMovementSources; ++import net.raphimc.viabedrock.protocol.model.Position3f; ++import net.minecraft.client.multiplayer.ClientLevel; + import net.raphimc.viabedrock.api.model.NativeActorBoundsMessage; + import net.raphimc.viabedrock.api.model.NativeActorStates; + import net.raphimc.viabedrock.api.model.NativeAnimationControllers; +@@ -30,7 +35,31 @@ public final class BedrockActorState { + } + + public static void accept(final Connection connection, final NativeActorBoundsMessage message) { +- accept(connection, localPlayer -> ACTORS.apply(message, localPlayer)); ++ accept(connection, localPlayer -> apply(message, localPlayer)); ++ } ++ ++ public static void accept(final Connection connection, final ActorSpatialCommand message) { ++ accept(connection, localPlayer -> apply(message, localPlayer)); ++ } ++ ++ static void apply(final ActorSpatialCommand message, final UUID localPlayer) { ++ ACTORS.apply(message, localPlayer); ++ } ++ ++ static void apply(final NativeActorBoundsMessage message, final UUID localPlayer) { ++ ACTORS.apply(message, localPlayer); ++ } ++ ++ public static NativeMovementSources movement(final UUID uuid) { return ACTORS.movement(uuid); } ++ ++ public static void beforePhysics(final ClientLevel level) { ++ if (PhysicalReplayContext.replaying()) return; ++ ACTORS.forEachMovement((uuid, sources) -> { ++ final var actor = level.getEntity(sources.command().entityId()); ++ if (actor != null && !actor.isRemoved() && actor.getUUID().equals(uuid)) { ++ sources.beforePhysics(new Position3f((float) actor.getX(), (float) actor.getY() + sources.command().originOffset(), (float) actor.getZ())); ++ } ++ }); + } + + public static NativeActorBoundsMessage bounds(final UUID uuid) { return ACTORS.bounds(uuid); } +@@ -56,7 +85,7 @@ public final class BedrockActorState { + } + + public static NativeActorMessage.Snapshot state(final UUID uuid) { +- return ACTORS.get(uuid); ++ return PhysicalReplayContext.actor(uuid, () -> ACTORS.get(uuid)); + } + + public static NativeActorMessage.Snapshot stateByRuntimeId(final long runtimeId) { +@@ -74,6 +103,12 @@ public final class BedrockActorState { + return state == null ? null : state.lifetime(); + } + ++ /** Ownership checks use the current registry even while physics reads restored metadata. */ ++ public static UUID liveLifetime(final UUID uuid) { ++ final var state = ACTORS.get(uuid); ++ return state == null ? null : state.lifetime(); ++ } ++ + public static NativeAnimationControllers animations(final UUID uuid) { + return ACTORS.animations(uuid); + } +diff --git a/src/main/resources/viafabricplus-bedrock.accesswidener b/src/main/resources/viafabricplus-bedrock.accesswidener +index f42de8f..8e8672c 100644 +--- a/src/main/resources/viafabricplus-bedrock.accesswidener ++++ b/src/main/resources/viafabricplus-bedrock.accesswidener +@@ -1,5 +1,10 @@ + accessWidener v1 official + ++accessible class net/minecraft/world/entity/Entity$Movement ++accessible field net/minecraft/world/entity/ai/attributes/AttributeMap attributes Ljava/util/Map; ++ ++accessible class net/minecraft/world/entity/EntityFluidInteraction$Tracker ++accessible method net/minecraft/world/entity/EntityFluidInteraction$CurrentAccumulator ()V + accessible class net/minecraft/world/entity/EntityFluidInteraction$CurrentAccumulator + + 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 103a613..1dd5634 100644 +--- a/src/main/resources/viafabricplus-bedrock.mixins.json ++++ b/src/main/resources/viafabricplus-bedrock.mixins.json +@@ -141,12 +141,37 @@ + "features.input.MixinMouseHandler", + "features.input.MixinInputPermissionConnection", + "features.input.MixinMinecraftInputPermissions", ++ "features.movement.MixinAvatar", + "features.movement.MixinEntity", ++ "features.movement.MixinEntityPhysicsState", ++ "features.movement.MixinLivingPhysicsState", ++ "features.movement.MixinSwingPhysicsState", ++ "features.movement.MixinWalkPhysicsState", ++ "features.movement.MixinElytraPhysicsState", ++ "features.movement.MixinMobEffectPhysicsState", ++ "features.movement.MixinEntityFluidPhysicsState", ++ "features.movement.MixinEntityFluidTrackerState", ++ "features.movement.MixinClientInputPhysicsState", ++ "features.movement.MixinBoatPhysicsState", ++ "features.movement.MixinHorsePhysicsState", ++ "features.movement.MixinAgePhysicsState", ++ "features.movement.MixinClientLevelPhysicsReplay", ++ "features.movement.MixinEntityCollisionReplay", ++ "features.movement.MixinPassengerPhysicsReplay", ++ "features.movement.MixinLevelPhysicsReplay", ++ "features.movement.MixinGameModePhysicsReplay", ++ "features.movement.MixinConnectionPhysicsReplay", ++ "features.movement.MixinSoundPhysicsReplay", ++ "features.movement.MixinParticlePhysicsReplay", ++ "features.movement.MixinVoxelShape", + "features.movement.MixinEntityFluidInteraction", + "features.movement.MixinEntityFluidInteraction_CurrentAccumulator", + "features.movement.MixinHoneyBlock", + "features.movement.MixinLivingEntity", ++ "features.movement.MixinPotentSulfurBlockEntity", ++ "features.movement.MixinFireworkRocketEntity", + "features.movement.MixinLocalPlayer", ++ "features.movement.MixinPlayerCorrectionPacketListener", + "features.movement.MixinPlayer", + "features.sound.MixinBedrockChannelAccess", + "features.sound.MixinBedrockChannel", +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/input/BedrockVehiclePredictionsTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/input/BedrockVehiclePredictionsTest.java +new file mode 100644 +index 0000000..4f2bef0 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/input/BedrockVehiclePredictionsTest.java +@@ -0,0 +1,155 @@ ++/* ++ * 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.mojang.authlib.GameProfile; ++import com.viaversion.viafabricplus.bedrock.render.BedrockActorState; ++import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; ++import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; ++import net.minecraft.SharedConstants; ++import net.minecraft.core.BlockPos; ++import net.minecraft.world.entity.vehicle.boat.AbstractBoat; ++import net.minecraft.world.phys.Vec3; ++import net.raphimc.viabedrock.api.model.NativeActorMessage; ++import net.raphimc.viabedrock.api.model.VehiclePredictionContext; ++import net.minecraft.client.Minecraft; ++import net.minecraft.client.multiplayer.ClientLevel; ++import net.minecraft.client.multiplayer.ClientPacketListener; ++import net.minecraft.client.player.LocalPlayer; ++import net.minecraft.server.Bootstrap; ++import net.minecraft.world.entity.player.Input; ++import org.junit.jupiter.api.BeforeAll; ++import org.junit.jupiter.api.Test; ++ ++import java.util.UUID; ++import java.util.Map; ++import java.util.Set; ++import java.util.concurrent.atomic.AtomicReference; ++ ++import static org.junit.jupiter.api.Assertions.*; ++import static org.mockito.Mockito.*; ++ ++final class BedrockVehiclePredictionsTest { ++ @BeforeAll ++ static void initializeGame() { ++ SharedConstants.tryDetectVersion(); ++ Bootstrap.bootStrap(); ++ } ++ ++ @Test ++ void leavesOrdinaryJavaAndUnnegotiatedSessionsWithoutOptionalFrames() { ++ final LocalPlayer player = mock(LocalPlayer.class); ++ try (final var permissions = mockStatic(BedrockInputPermissions.class); ++ final var networking = mockStatic(ClientPlayNetworking.class)) { ++ assertNull(BedrockVehiclePredictions.capture(player, 1)); ++ networking.verifyNoInteractions(); ++ verifyNoInteractions(player); ++ } ++ } ++ ++ @Test ++ void actualPlayRegistrationPairsIdentityAndDropsOldCallbacksAcrossReconnect() { ++ final var listener = mock(ClientPacketListener.class, RETURNS_DEEP_STUBS); ++ when(listener.getLocalGameProfile()).thenReturn(new GameProfile(UUID.randomUUID(), "vehicle-test")); ++ when(listener.hasClientLoaded()).thenReturn(true); ++ when(listener.getConnection().isConnected()).thenReturn(true); ++ final var level = mock(ClientLevel.class, RETURNS_DEEP_STUBS); ++ final var game = mock(Minecraft.class); ++ when(game.getConnection()).thenReturn(listener); ++ final LocalPlayer player = mock(LocalPlayer.class, withSettings().useConstructor( ++ game, level, listener, null, null, Input.EMPTY, false, null, null)); ++ final var mount = mock(AbstractBoat.class); ++ final var uuid = UUID.randomUUID(); ++ final var lifetime = UUID.randomUUID(); ++ when(player.getVehicle()).thenReturn(mount); ++ when(player.getRootVehicle()).thenReturn(mount); ++ when(player.level()).thenReturn(level); ++ when(player.blockPosition()).thenReturn(BlockPos.ZERO); ++ when(player.isAlive()).thenReturn(true); ++ when(mount.level()).thenReturn(level); ++ when(mount.getId()).thenReturn(2); ++ when(mount.getUUID()).thenReturn(uuid); ++ when(mount.blockPosition()).thenReturn(BlockPos.ZERO); ++ when(mount.getControllingPassenger()).thenReturn(player); ++ when(mount.isLocalInstanceAuthoritative()).thenReturn(true); ++ when(mount.isAlive()).thenReturn(true); ++ when(mount.getDeltaMovement()).thenReturn(new Vec3(-.125, .25, .375)); ++ when(level.getEntity(2)).thenReturn(mount); ++ when(level.hasChunkAt(any())).thenReturn(true); ++ final var actor = new NativeActorMessage.Snapshot(uuid, lifetime, false, 23, "minecraft:boat", "", 1F, ++ Set.of(), 0, 0, null, Map.of(), -17L); ++ final var context = new VehiclePredictionContext(1, new VehiclePredictionContext.Identity(2, uuid, lifetime, 23, -17)); ++ final AtomicReference> receiver = new AtomicReference<>(); ++ try (final var permissions = mockStatic(BedrockInputPermissions.class); ++ final var networking = mockStatic(ClientPlayNetworking.class); ++ final var actors = mockStatic(BedrockActorState.class)) { ++ permissions.when(BedrockInputPermissions::isBedrockConnection).thenReturn(true); ++ networking.when(() -> ClientPlayNetworking.canSend(VehiclePredictionPayload.TYPE)).thenReturn(true); ++ networking.when(() -> ClientPlayNetworking.registerReceiver(eq(VehiclePredictionContextPayload.TYPE), any())) ++ .thenAnswer(call -> { receiver.set(call.getArgument(1)); return true; }); ++ actors.when(BedrockActorState::supportsUniqueIds).thenReturn(true); ++ actors.when(() -> BedrockActorState.state(uuid)).thenReturn(actor); ++ BedrockVehiclePredictions.register(); ++ ClientPlayConnectionEvents.INIT.invoker().onPlayInit(listener, game); ++ assertNull(BedrockVehiclePredictions.capture(player, 7)); ++ final var callback = mock(ClientPlayNetworking.Context.class); ++ when(callback.client()).thenReturn(game); ++ receiver.get().receive(new VehiclePredictionContextPayload(context), callback); ++ final var frame = BedrockVehiclePredictions.capture(player, 7); ++ assertNotNull(frame); ++ assertEquals(7, frame.frameId()); ++ assertEquals(context, frame.context()); ++ assertEquals(-.125F, frame.motion().x()); ++ receiver.get().receive(new VehiclePredictionContextPayload(context), callback); ++ assertNull(BedrockVehiclePredictions.capture(player, 7)); ++ assertNotNull(BedrockVehiclePredictions.capture(player, 8)); ++ final var oldReceiver = receiver.get(); ++ final var next = mock(ClientPacketListener.class); ++ ClientPlayConnectionEvents.INIT.invoker().onPlayInit(next, game); ++ oldReceiver.receive(new VehiclePredictionContextPayload(context), callback); ++ ClientPlayConnectionEvents.DISCONNECT.invoker().onPlayDisconnect(listener, game); ++ assertNull(BedrockVehiclePredictions.capture(player, 9)); ++ when(game.getConnection()).thenReturn(next); ++ receiver.get().receive(new VehiclePredictionContextPayload(context), callback); ++ ClientPlayConnectionEvents.DISCONNECT.invoker().onPlayDisconnect(next, game); ++ assertNull(BedrockVehiclePredictions.capture(player, 10)); ++ } finally { ++ ClientPlayConnectionEvents.DISCONNECT.invoker().onPlayDisconnect(listener, game); ++ } ++ } ++ ++ @Test ++ void checksJoinedConnectionCapabilitiesAndActorRevisionBeforeSampling() { ++ final var listener = mock(ClientPacketListener.class, RETURNS_DEEP_STUBS); ++ when(listener.getLocalGameProfile()).thenReturn(new GameProfile(UUID.randomUUID(), "vehicle-test")); ++ final var level = mock(ClientLevel.class, RETURNS_DEEP_STUBS); ++ final LocalPlayer player = mock(LocalPlayer.class, withSettings().useConstructor( ++ mock(Minecraft.class), level, listener, null, null, Input.EMPTY, false, null, null)); ++ assertSame(listener, player.connection); ++ try (final var permissions = mockStatic(BedrockInputPermissions.class); ++ final var networking = mockStatic(ClientPlayNetworking.class); ++ final var actors = mockStatic(BedrockActorState.class)) { ++ permissions.when(BedrockInputPermissions::isBedrockConnection).thenReturn(true); ++ assertNull(BedrockVehiclePredictions.capture(player, 1)); ++ networking.verifyNoInteractions(); ++ when(listener.hasClientLoaded()).thenReturn(true); ++ assertNull(BedrockVehiclePredictions.capture(player, 1)); ++ networking.verifyNoInteractions(); ++ when(listener.getConnection().isConnected()).thenReturn(true); ++ assertNull(BedrockVehiclePredictions.capture(player, 1)); ++ actors.verifyNoInteractions(); ++ networking.when(() -> ClientPlayNetworking.canSend(VehiclePredictionPayload.TYPE)).thenReturn(true); ++ assertNull(BedrockVehiclePredictions.capture(player, 1)); ++ actors.verify(BedrockActorState::supportsUniqueIds); ++ actors.clearInvocations(); ++ actors.when(BedrockActorState::supportsUniqueIds).thenReturn(true); ++ assertNull(BedrockVehiclePredictions.capture(player, 1)); ++ verify(player).getVehicle(); ++ actors.verify(BedrockActorState::supportsUniqueIds); ++ actors.verifyNoMoreInteractions(); ++ } ++ } ++} +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 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/input/VehiclePredictionPayloadTest.java +@@ -0,0 +1,75 @@ ++/* ++ * 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 io.netty.buffer.Unpooled; ++import net.minecraft.SharedConstants; ++import net.minecraft.core.RegistryAccess; ++import net.minecraft.network.RegistryFriendlyByteBuf; ++import net.minecraft.server.Bootstrap; ++import net.raphimc.viabedrock.api.model.VehiclePrediction; ++import net.raphimc.viabedrock.api.model.VehiclePredictionContext; ++import net.raphimc.viabedrock.protocol.model.Position3f; ++import org.junit.jupiter.api.BeforeAll; ++import org.junit.jupiter.api.Test; ++ ++import java.util.UUID; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++final class VehiclePredictionPayloadTest { ++ @BeforeAll ++ static void initializeGame() { ++ SharedConstants.tryDetectVersion(); ++ Bootstrap.bootStrap(); ++ } ++ ++ @Test ++ void transportsContextAndCompletedMotionThroughActualFabricCodecs() { ++ final var context = context(); ++ final var prediction = new VehiclePrediction(7, context, new Position3f(7, 63, -9), new Position3f(-.125F, .25F, .375F)); ++ final var buffer = new RegistryFriendlyByteBuf(Unpooled.buffer(), RegistryAccess.EMPTY); ++ try { ++ VehiclePredictionContextPayload.CODEC.encode(buffer, new VehiclePredictionContextPayload(context)); ++ assertEquals(context, VehiclePredictionContextPayload.CODEC.decode(buffer).context()); ++ assertFalse(buffer.isReadable()); ++ buffer.clear(); ++ VehiclePredictionPayload.CODEC.encode(buffer, new VehiclePredictionPayload(prediction)); ++ final var decoded = VehiclePredictionPayload.CODEC.decode(buffer).prediction(); ++ assertEquals(prediction, decoded); ++ assertEquals(-17L, decoded.context().vehicle().uniqueId()); ++ assertEquals(23L, decoded.context().vehicle().runtimeId()); ++ assertFalse(buffer.isReadable()); ++ } finally { ++ buffer.release(); ++ } ++ } ++ ++ @Test ++ void transportsClearAndRefusesTrailingContextOrFrameData() { ++ final var buffer = new RegistryFriendlyByteBuf(Unpooled.buffer(), RegistryAccess.EMPTY); ++ try { ++ final var clear = new VehiclePredictionContextPayload(new VehiclePredictionContext(8, null)); ++ VehiclePredictionContextPayload.CODEC.encode(buffer, clear); ++ assertEquals(clear, VehiclePredictionContextPayload.CODEC.decode(buffer)); ++ buffer.clear(); ++ VehiclePredictionContextPayload.CODEC.encode(buffer, clear); ++ buffer.writeByte(1); ++ assertThrows(IllegalArgumentException.class, () -> VehiclePredictionContextPayload.CODEC.decode(buffer)); ++ buffer.clear(); ++ VehiclePredictionPayload.CODEC.encode(buffer, new VehiclePredictionPayload(new VehiclePrediction(8, context(), ++ new Position3f(0, 0, 0), new Position3f(0, 0, 0)))); ++ buffer.writeByte(1); ++ assertThrows(IllegalArgumentException.class, () -> VehiclePredictionPayload.CODEC.decode(buffer)); ++ } finally { ++ buffer.release(); ++ } ++ } ++ ++ private static VehiclePredictionContext context() { ++ return new VehiclePredictionContext(4, new VehiclePredictionContext.Identity(2, UUID.randomUUID(), UUID.randomUUID(), 23, -17)); ++ } ++} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/input/VehiclePredictionSessionTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/input/VehiclePredictionSessionTest.java +new file mode 100644 +index 0000000..eac5b9e +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/input/VehiclePredictionSessionTest.java +@@ -0,0 +1,284 @@ ++/* ++ * 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.SharedConstants; ++import net.minecraft.client.multiplayer.ClientLevel; ++import net.minecraft.client.multiplayer.ClientPacketListener; ++import net.minecraft.client.player.LocalPlayer; ++import net.minecraft.core.BlockPos; ++import net.minecraft.server.Bootstrap; ++import net.minecraft.world.entity.Entity; ++import net.minecraft.world.entity.animal.equine.AbstractHorse; ++import net.minecraft.world.entity.vehicle.boat.AbstractBoat; ++import net.minecraft.world.phys.Vec3; ++import net.raphimc.viabedrock.api.model.NativeActorMessage; ++import net.raphimc.viabedrock.api.model.VehiclePredictionContext; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.ActorFlags; ++import net.raphimc.viabedrock.protocol.model.Position3f; ++import org.junit.jupiter.api.BeforeAll; ++import org.junit.jupiter.api.Test; ++ ++import java.util.Map; ++import java.util.Set; ++import java.util.UUID; ++ ++import static org.junit.jupiter.api.Assertions.*; ++import static org.mockito.Mockito.*; ++ ++final class VehiclePredictionSessionTest { ++ @BeforeAll ++ static void initializeGame() { ++ SharedConstants.tryDetectVersion(); ++ Bootstrap.bootStrap(); ++ } ++ ++ @Test ++ void fencesEveryAcceptedControlTransferButKeepsTheLeaseForSameActorRevisions() { ++ final Fixture f = new Fixture(false); ++ final var identity = f.context.vehicle(); ++ final long epoch = f.context.epoch(); ++ assertFalse(f.session.accept(f.listener, new VehiclePredictionContext(epoch + 1, identity))); ++ assertFalse(f.session.accept(f.listener, new VehiclePredictionContext(epoch, null))); ++ assertTrue(f.session.accept(f.listener, new VehiclePredictionContext(epoch + 2, null))); ++ assertTrue(f.session.accept(f.listener, new VehiclePredictionContext(epoch + 3, identity))); ++ assertFalse(f.session.accept(f.listener, new VehiclePredictionContext(epoch + 3, identity))); ++ final var replacement = new VehiclePredictionContext.Identity(identity.javaId(), identity.javaUuid(), UUID.randomUUID(), identity.runtimeId(), identity.uniqueId()); ++ assertTrue(f.session.accept(f.listener, new VehiclePredictionContext(epoch + 4, replacement))); ++ assertFalse(f.session.accept(mock(ClientPacketListener.class), new VehiclePredictionContext(epoch + 5, null))); ++ } ++ ++ @Test ++ void samplesActualRootMotionSeparatelyFromCurrentPositionAndPlayerMotion() { ++ final Fixture f = new Fixture(false); ++ final var result = f.sample(7); ++ assertNotNull(result); ++ assertEquals(7, result.frameId()); ++ assertEquals(f.context, result.context()); ++ assertEquals(new Position3f(7, 63, -9), result.position()); ++ assertEquals(new Position3f(-.125F, .25F, .375F), result.motion()); ++ verify(f.player, never()).getDeltaMovement(); ++ verify(f.mount).getDeltaMovement(); ++ assertNull(f.sample(7)); ++ when(f.mount.getDeltaMovement()).thenReturn(Vec3.ZERO); ++ assertEquals(new Position3f(0, 0, 0), f.sample(8).motion()); ++ when(f.mount.getDeltaMovement()).thenReturn(new Vec3(.125, -.25, -.375)); ++ assertEquals(new Position3f(.125F, -.25F, -.375F), f.sample(9).motion()); ++ } ++ ++ @Test ++ void retainsFrameWatermarkAcrossCorrectionClearAndReRegistration() { ++ final Fixture f = new Fixture(false); ++ assertNotNull(f.sample(10)); ++ f.session.accept(f.listener, new VehiclePredictionContext(2, null)); ++ assertNull(f.sample(11)); ++ f.session.accept(f.listener, f.context); ++ assertNull(f.sample(12)); ++ f.context = new VehiclePredictionContext(3, f.context.vehicle()); ++ f.session.accept(f.listener, f.context); ++ assertNull(f.sample(10)); ++ assertNotNull(f.sample(11)); ++ f.session.accept(f.listener, new VehiclePredictionContext(3, null)); ++ assertNotNull(f.sample(12)); ++ f.session.accept(f.listener, f.context); ++ f.session.begin(f.listener); ++ assertNull(f.sample(12)); ++ assertNotNull(f.sample(13)); ++ } ++ ++ @Test ++ void rejectsOldConnectionCallbacksAndStartsFreshConnectionClock() { ++ final Fixture f = new Fixture(false); ++ assertNotNull(f.sample(50)); ++ final ClientPacketListener next = mock(ClientPacketListener.class); ++ f.session.begin(next); ++ assertNull(f.session.context(f.listener)); ++ assertNull(f.session.context(next)); ++ f.session.accept(f.listener, f.context); ++ f.session.end(f.listener); ++ assertNull(f.session.sample(next, f.player, f.actor, 1)); ++ f.session.accept(next, f.context); ++ assertSame(f.context, f.session.context(next)); ++ assertNull(f.sample(51)); ++ assertNotNull(f.session.sample(next, f.player, f.actor, 1)); ++ f.session.end(next); ++ assertNull(f.session.sample(next, f.player, f.actor, 2)); ++ } ++ ++ @Test ++ void rejectsStaleEpochAndConflictingEqualEpochIdentity() { ++ final Fixture f = new Fixture(false); ++ final var identity = f.context.vehicle(); ++ f.session.accept(f.listener, new VehiclePredictionContext(0, null)); ++ f.session.accept(f.listener, new VehiclePredictionContext(1, new VehiclePredictionContext.Identity( ++ identity.javaId(), identity.javaUuid(), UUID.randomUUID(), identity.runtimeId(), identity.uniqueId()))); ++ assertEquals(f.context, f.sample(1).context()); ++ } ++ ++ @Test ++ void requiresExactNativeLifetimeRuntimeAndSignedUniqueIdentity() { ++ final Fixture f = new Fixture(false); ++ final var a = f.actor; ++ f.actor = actor(a.uuid(), UUID.randomUUID(), a.runtimeId(), a.uniqueId(), a.flags()); ++ assertNull(f.sample(1)); ++ f.actor = actor(a.uuid(), a.lifetime(), a.runtimeId() + 1, a.uniqueId(), a.flags()); ++ assertNull(f.sample(1)); ++ f.actor = actor(a.uuid(), a.lifetime(), a.runtimeId(), a.uniqueId() + 1, a.flags()); ++ assertNull(f.sample(1)); ++ f.actor = actor(a.uuid(), a.lifetime(), a.runtimeId(), null, a.flags()); ++ assertNull(f.sample(1)); ++ f.actor = actor(UUID.randomUUID(), a.lifetime(), a.runtimeId(), a.uniqueId(), a.flags()); ++ assertNull(f.sample(1)); ++ f.actor = null; ++ assertNull(f.sample(1)); ++ f.actor = a; ++ assertEquals(-17L, f.sample(1).context().vehicle().uniqueId()); ++ } ++ ++ @Test ++ void requiresExactJavaIdentityAndTrackedObject() { ++ final Fixture f = new Fixture(false); ++ when(f.mount.getId()).thenReturn(3); ++ assertNull(f.sample(1)); ++ when(f.mount.getId()).thenReturn(2); ++ when(f.mount.getUUID()).thenReturn(UUID.randomUUID()); ++ assertNull(f.sample(1)); ++ when(f.mount.getUUID()).thenReturn(f.context.vehicle().javaUuid()); ++ when(f.level.getEntity(2)).thenReturn(mock(AbstractBoat.class)); ++ assertNull(f.sample(1)); ++ when(f.level.getEntity(2)).thenReturn(f.mount); ++ assertNotNull(f.sample(1)); ++ } ++ ++ @Test ++ void rejectsSecondaryNestedNonlocalAndMissingMounts() { ++ final Fixture f = new Fixture(false); ++ when(f.mount.getControllingPassenger()).thenReturn(mock(LocalPlayer.class)); ++ assertNull(f.sample(1)); ++ when(f.mount.getControllingPassenger()).thenReturn(f.player); ++ when(f.player.getRootVehicle()).thenReturn(mock(AbstractBoat.class)); ++ assertNull(f.sample(1)); ++ when(f.player.getRootVehicle()).thenReturn(f.mount); ++ when(f.mount.isLocalInstanceAuthoritative()).thenReturn(false); ++ assertNull(f.sample(1)); ++ when(f.mount.isLocalInstanceAuthoritative()).thenReturn(true); ++ when(f.player.getVehicle()).thenReturn(null); ++ assertNull(f.sample(1)); ++ when(f.player.getVehicle()).thenReturn(mock(Entity.class)); ++ assertNull(f.sample(1)); ++ when(f.player.getVehicle()).thenReturn(f.mount); ++ assertNotNull(f.sample(1)); ++ } ++ ++ @Test ++ void rejectsDeathSpectatorDimensionAndUnloadedChunks() { ++ final Fixture f = new Fixture(false); ++ when(f.player.isAlive()).thenReturn(false); ++ assertNull(f.sample(1)); ++ when(f.player.isAlive()).thenReturn(true); ++ when(f.player.isSpectator()).thenReturn(true); ++ assertNull(f.sample(1)); ++ when(f.player.isSpectator()).thenReturn(false); ++ when(f.mount.isAlive()).thenReturn(false); ++ assertNull(f.sample(1)); ++ when(f.mount.isAlive()).thenReturn(true); ++ when(f.mount.level()).thenReturn(mock(ClientLevel.class)); ++ assertNull(f.sample(1)); ++ when(f.mount.level()).thenReturn(f.level); ++ when(f.level.hasChunkAt(any())).thenReturn(false); ++ assertNull(f.sample(1)); ++ when(f.level.hasChunkAt(any())).thenReturn(true); ++ assertNotNull(f.sample(1)); ++ } ++ ++ @Test ++ void gatesHorseByNativeTamedAndSaddledState() { ++ final Fixture f = new Fixture(true); ++ final var a = f.actor; ++ f.actor = actor(a.uuid(), a.lifetime(), a.runtimeId(), a.uniqueId(), Set.of()); ++ assertNull(f.sample(1)); ++ f.actor = actor(a.uuid(), a.lifetime(), a.runtimeId(), a.uniqueId(), Set.of(ActorFlags.TAMED)); ++ assertNull(f.sample(1)); ++ f.actor = actor(a.uuid(), a.lifetime(), a.runtimeId(), a.uniqueId(), Set.of(ActorFlags.SADDLED)); ++ assertNull(f.sample(1)); ++ f.actor = a; ++ assertNotNull(f.sample(1)); ++ } ++ ++ @Test ++ void rejectsNonfiniteOrOverflowingFloatCoordinatesWithoutConsumingFrame() { ++ final Fixture f = new Fixture(false); ++ when(f.mount.getX()).thenReturn(Double.MAX_VALUE); ++ assertNull(f.sample(1)); ++ when(f.mount.getX()).thenReturn(Double.NaN); ++ assertNull(f.sample(1)); ++ when(f.mount.getX()).thenReturn(7.0000000001); ++ when(f.mount.getDeltaMovement()).thenReturn(new Vec3(0, Double.POSITIVE_INFINITY, 0)); ++ assertNull(f.sample(1)); ++ when(f.mount.getDeltaMovement()).thenReturn(new Vec3(Double.MAX_VALUE, 0, 0)); ++ assertNull(f.sample(1)); ++ when(f.mount.getDeltaMovement()).thenReturn(Vec3.ZERO); ++ assertEquals(7F, f.sample(1).position().x()); ++ } ++ ++ @Test ++ void cannotRebindConsumedFrameAfterLongMaximum() { ++ final Fixture f = new Fixture(false); ++ assertNull(f.sample(-1)); ++ assertNotNull(f.sample(Long.MAX_VALUE)); ++ f.session.accept(f.listener, new VehiclePredictionContext(2, f.context.vehicle())); ++ assertNull(f.sample(Long.MAX_VALUE)); ++ assertNull(f.sample(Long.MIN_VALUE)); ++ assertNull(f.sample(0)); ++ } ++ ++ private static NativeActorMessage.Snapshot actor(final UUID uuid, final UUID lifetime, final long runtimeId, ++ final Long uniqueId, final Set flags) { ++ return new NativeActorMessage.Snapshot(uuid, lifetime, false, runtimeId, "minecraft:boat", "", 1F, ++ flags, 0, 0, null, Map.of(), uniqueId); ++ } ++ ++ private static final class Fixture { ++ final VehiclePredictionSession session = new VehiclePredictionSession(); ++ final ClientPacketListener listener = mock(ClientPacketListener.class); ++ final ClientLevel level = mock(ClientLevel.class); ++ final LocalPlayer player = mock(LocalPlayer.class); ++ final Entity mount; ++ VehiclePredictionContext context; ++ NativeActorMessage.Snapshot actor; ++ ++ Fixture(final boolean horse) { ++ this.mount = horse ? mock(AbstractHorse.class) : mock(AbstractBoat.class); ++ final UUID uuid = UUID.randomUUID(), lifetime = UUID.randomUUID(); ++ this.context = new VehiclePredictionContext(1, new VehiclePredictionContext.Identity(2, uuid, lifetime, 23, -17)); ++ this.actor = actor(uuid, lifetime, 23, -17L, Set.of(ActorFlags.TAMED, ActorFlags.SADDLED)); ++ when(this.player.isAlive()).thenReturn(true); ++ when(this.player.getVehicle()).thenReturn(this.mount); ++ when(this.player.getRootVehicle()).thenReturn(this.mount); ++ when(this.player.level()).thenReturn(this.level); ++ when(this.player.blockPosition()).thenReturn(BlockPos.ZERO); ++ when(this.mount.getControllingPassenger()).thenReturn(this.player); ++ when(this.mount.isLocalInstanceAuthoritative()).thenReturn(true); ++ when(this.mount.isAlive()).thenReturn(true); ++ when(this.mount.level()).thenReturn(this.level); ++ when(this.mount.getId()).thenReturn(2); ++ when(this.mount.getUUID()).thenReturn(uuid); ++ when(this.mount.blockPosition()).thenReturn(BlockPos.ZERO); ++ when(this.mount.getX()).thenReturn(7D); ++ when(this.mount.getY()).thenReturn(63D); ++ when(this.mount.getZ()).thenReturn(-9D); ++ when(this.mount.getDeltaMovement()).thenReturn(new Vec3(-.125, .25, .375)); ++ when(this.level.getEntity(2)).thenReturn(this.mount); ++ when(this.level.hasChunkAt(any())).thenReturn(true); ++ this.session.begin(this.listener); ++ this.session.accept(this.listener, this.context); ++ } ++ ++ net.raphimc.viabedrock.api.model.VehiclePrediction sample(final long frame) { ++ return this.session.sample(this.listener, this.player, this.actor, frame); ++ } ++ } ++} +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 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockClimbingPhysicsTest.java +@@ -0,0 +1,53 @@ ++/* ++ * 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 net.minecraft.core.Direction; ++import net.minecraft.world.phys.AABB; ++import net.minecraft.world.phys.shapes.Shapes; ++import org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.assertEquals; ++import static org.junit.jupiter.api.Assertions.assertTrue; ++ ++class BedrockClimbingPhysicsTest { ++ ++ @Test ++ void advancesJumpClimbingBeforeGravityRatherThanMovingByThePreviousDampedImpulse() { ++ double position = 250; ++ double motion = -0.0784F; ++ for (int tick = 0; tick < 40; tick++) { ++ final double movement = BedrockClimbingPhysics.beforeMovement(motion, true, false); ++ position += movement; ++ motion = BedrockClimbingPhysics.afterGravity((movement - 0.08F) * 0.98F, false); ++ assertEquals(0.11760000884532928, motion, 0.00000001); ++ } ++ assertEquals(258, position, 0.000001); ++ } ++ ++ @Test ++ void clampsNativeDescentAndRetainsSneakingHoldWithoutSuppressingUpwardMotion() { ++ assertEquals(-0.2F, BedrockClimbingPhysics.beforeMovement(-1, false, false)); ++ assertEquals(-0.2744F, (BedrockClimbingPhysics.beforeMovement(-1, false, false) - 0.08F) * 0.98F, 0.00000002); ++ assertEquals(0, BedrockClimbingPhysics.beforeMovement(-1, false, true)); ++ assertEquals(0.1, BedrockClimbingPhysics.beforeMovement(0.1, false, true)); ++ assertEquals(0.2F, BedrockClimbingPhysics.beforeMovement(-1, true, true)); ++ } ++ ++ @Test ++ void wallContactAppliesItsImpulseAfterGravityAndJumpClimbingRetainsCeilingCollision() { ++ assertEquals(0.2F, BedrockClimbingPhysics.afterGravity(-0.0784F, true)); ++ final var body = new AABB(0.2, 0, 0.2, 0.8, 1.8F, 0.8); ++ final double requested = BedrockClimbingPhysics.beforeMovement(-0.0784F, true, false); ++ final double clipped = BedrockCollisionContacts.collide(Shapes.box(0, 1.9, 0, 1, 2.9, 1), Direction.Axis.Y, body, requested); ++ assertEquals(0.1, clipped, 0.000001); ++ assertTrue(clipped < requested); ++ // Entity.restituteMovementAfterCollisions clears velocity at a non-bouncing ceiling. ++ final double collisionMotion = 0; ++ assertEquals(-0.0784F, BedrockClimbingPhysics.afterGravity((collisionMotion - 0.08F) * 0.98F, false), 0.00000001); ++ } ++ ++} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockCollisionContactsTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockCollisionContactsTest.java +new file mode 100644 +index 0000000..9d86f6c +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockCollisionContactsTest.java +@@ -0,0 +1,95 @@ ++/* ++ * 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 net.minecraft.core.Direction; ++import net.minecraft.world.phys.AABB; ++import net.minecraft.world.phys.shapes.Shapes; ++import net.minecraft.world.phys.shapes.VoxelShape; ++import org.junit.jupiter.api.Test; ++ ++import java.util.List; ++ ++import static org.junit.jupiter.api.Assertions.assertEquals; ++import static org.junit.jupiter.api.Assertions.assertFalse; ++import static org.junit.jupiter.api.Assertions.assertTrue; ++ ++class BedrockCollisionContactsTest { ++ ++ @Test ++ void blocksTheRoundedTeleportAtTheFlowingChannelWall() { ++ final VoxelShape wall = Shapes.box(608, 250, 8, 609, 252, 9); ++ final float width = 0.6F; ++ for (final double center : new double[]{7.699999809265137, 7.700000762939453}) { ++ final AABB body = new AABB(608.5 - width / 2D, 250, center - width / 2D, 608.5 + width / 2D, 250 + 1.8F, center + width / 2D); ++ assertEquals(0D, BedrockCollisionContacts.collide(wall, Direction.Axis.Z, body, 0.014), 0D); ++ assertEquals(-0.014, BedrockCollisionContacts.collide(wall, Direction.Axis.Z, body, -0.014)); ++ } ++ } ++ ++ @Test ++ void retainsGapsBetweenSeparateShapeBoxes() { ++ final VoxelShape posts = Shapes.or(Shapes.box(0, 0, 0, 0.25, 1, 1), Shapes.box(0.75, 0, 0, 1, 1, 1)); ++ final AABB throughGap = new AABB(0.3, 0, -1, 0.7, 0.8, -0.1); ++ assertEquals(0.5, BedrockCollisionContacts.collide(posts, Direction.Axis.Z, throughGap, 0.5)); ++ final AABB intoPost = new AABB(0.05, 0, -1, 0.2, 0.8, -0.1); ++ assertEquals(0.1, BedrockCollisionContacts.collide(posts, Direction.Axis.Z, intoPost, 0.5)); ++ assertEquals(0.5, BedrockCollisionContacts.collide(Shapes.empty(), Direction.Axis.Z, intoPost, 0.5)); ++ } ++ ++ @Test ++ void landsExactlyOnFullBlockFacesWithUnroundedJavaBounds() { ++ for (final Direction.Axis axis : Direction.Axis.values()) { ++ for (final double origin : new double[]{0, 250, 608, 8192, -8192}) { ++ final VoxelShape block = Shapes.box(origin, origin, origin, origin + 1, origin + 1, origin + 1); ++ for (final double gap : new double[]{0.014, 0.137, 0.499}) { ++ final double[] lower = {origin + 0.2, origin + 0.2, origin + 0.2}; ++ final double[] upper = {origin + 0.8, origin + 0.8, origin + 0.8}; ++ final int component = axis.ordinal(); ++ lower[component] = origin - gap - 0.6; ++ upper[component] = origin - gap; ++ final AABB approachingPositive = new AABB(lower[0], lower[1], lower[2], upper[0], upper[1], upper[2]); ++ final double positive = BedrockCollisionContacts.collide(block, axis, approachingPositive, 1); ++ assertEquals(origin, approachingPositive.max(axis) + positive, 0D); ++ assertEquals(-1, BedrockCollisionContacts.collide(block, axis, approachingPositive, -1)); ++ ++ lower[component] = origin + 1 + gap; ++ upper[component] = origin + 1 + gap + 0.6; ++ final AABB approachingNegative = new AABB(lower[0], lower[1], lower[2], upper[0], upper[1], upper[2]); ++ final double negative = BedrockCollisionContacts.collide(block, axis, approachingNegative, -1); ++ assertEquals(origin + 1, approachingNegative.min(axis) + negative, 0D); ++ assertEquals(1, BedrockCollisionContacts.collide(block, axis, approachingNegative, 1)); ++ } ++ } ++ } ++ } ++ ++ @Test ++ void clipsRequestsThatReachTheFaceBeforeTheirFloatRoundedDistance() { ++ final VoxelShape floor = Shapes.box(0, 249, 0, 1, 250, 1); ++ final AABB body = new AABB(0.2, 250.014, 0.2, 0.8, 251.814, 0.8); ++ final double requested = -(body.minY - 250) - 0.000001; ++ final double clipped = BedrockCollisionContacts.collide(floor, Direction.Axis.Y, body, requested); ++ assertEquals(250, body.minY + clipped, 0D); ++ assertTrue(clipped > requested); ++ } ++ ++ @Test ++ void edgeGuardRequiresTheSameSupportAsVerticalCollision() { ++ final VoxelShape floor = Shapes.block(); ++ for (final double origin : new double[]{0, 608, -608, 8192, -8192}) { ++ final VoxelShape translated = floor.move(origin, 0, 0); ++ final AABB touchingEdge = new AABB(origin + 0.9999995, 1, 0.2, origin + 1.5999995, 2.8, 0.8); ++ assertTrue(BedrockCollisionContacts.canFallAtLeast(List.of(translated), touchingEdge, 0.6)); ++ assertEquals(-0.6, BedrockCollisionContacts.collide(translated, Direction.Axis.Y, touchingEdge, -0.6)); ++ ++ final AABB supported = touchingEdge.move(-0.05, 0, 0); ++ assertFalse(BedrockCollisionContacts.canFallAtLeast(List.of(translated), supported, 0.6)); ++ assertEquals(0, BedrockCollisionContacts.collide(translated, Direction.Axis.Y, supported, -0.6)); ++ } ++ assertTrue(BedrockCollisionContacts.canFallAtLeast(List.of(), new AABB(0, 1, 0, 0.6, 2.8, 0.6), 0.6)); ++ } ++} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockFlightPhysicsTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockFlightPhysicsTest.java +new file mode 100644 +index 0000000..d94bccc +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockFlightPhysicsTest.java +@@ -0,0 +1,130 @@ ++/* ++ * 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.input.BedrockMovementEffects; ++import net.minecraft.SharedConstants; ++import net.minecraft.client.Minecraft; ++import net.minecraft.client.multiplayer.ClientLevel; ++import net.minecraft.world.level.block.Blocks; ++import net.minecraft.world.phys.Vec3; ++import net.minecraft.client.player.LocalPlayer; ++import net.minecraft.server.Bootstrap; ++import net.minecraft.world.entity.player.Abilities; ++import net.minecraft.world.entity.player.Player; ++import net.raphimc.viabedrock.api.model.FlightMovementContext; ++import org.junit.jupiter.api.BeforeAll; ++import org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.*; ++import static org.mockito.Mockito.*; ++ ++class BedrockFlightPhysicsTest { ++ ++ @BeforeAll ++ static void initializeGame() { ++ SharedConstants.tryDetectVersion(); ++ Bootstrap.bootStrap(); ++ } ++ ++ @Test ++ void keepsRawJumpAndRejectsUnnegotiatedOrUncontrolledActorsAcrossTransitions() { ++ final LocalPlayer player = mock(LocalPlayer.class); ++ final Minecraft client = mock(Minecraft.class); ++ final Abilities abilities = new Abilities(); ++ abilities.flying = true; ++ when(player.getAbilities()).thenReturn(abilities); ++ when(player.isJumping()).thenReturn(true); ++ when(client.getCameraEntity()).thenReturn(player); ++ try (final var connection = mockStatic(BedrockInputPermissions.class); ++ final var game = mockStatic(Minecraft.class); ++ final var effects = mockStatic(BedrockMovementEffects.class)) { ++ game.when(Minecraft::getInstance).thenReturn(client); ++ connection.when(BedrockInputPermissions::isBedrockConnection).thenReturn(false); ++ assertFalse(BedrockFlightPhysics.applies(player)); ++ assertFalse(BedrockFlightPhysics.retainSwimming(player, false, true)); ++ assertTrue(BedrockFlightPhysics.isJumping(player)); ++ connection.when(BedrockInputPermissions::isBedrockConnection).thenReturn(true); ++ assertFalse(BedrockFlightPhysics.applies(player)); ++ assertFalse(BedrockFlightPhysics.retainSwimming(player, false, true)); ++ assertTrue(BedrockFlightPhysics.isJumping(player)); ++ effects.when(() -> BedrockMovementEffects.flightContext(0)) ++ .thenReturn(new FlightMovementContext(0, 1, 0, 1F, false, 1F, 1F)); ++ assertTrue(BedrockFlightPhysics.applies(player)); ++ assertTrue(BedrockFlightPhysics.retainSwimming(player, false, true)); ++ assertFalse(BedrockFlightPhysics.retainSwimming(player, true, false)); ++ assertFalse(BedrockFlightPhysics.retainSwimming(mock(Player.class), false, true)); ++ assertFalse(BedrockFlightPhysics.isJumping(player)); ++ assertTrue(player.isJumping()); ++ abilities.flying = false; ++ assertFalse(BedrockFlightPhysics.retainSwimming(player, false, true)); ++ assertTrue(BedrockFlightPhysics.isJumping(player)); ++ abilities.flying = true; ++ when(player.isPassenger()).thenReturn(true); ++ assertTrue(BedrockFlightPhysics.isJumping(player)); ++ when(player.isPassenger()).thenReturn(false); ++ when(client.getCameraEntity()).thenReturn(null); ++ assertTrue(BedrockFlightPhysics.isJumping(player)); ++ when(client.getCameraEntity()).thenReturn(player); ++ effects.when(() -> BedrockMovementEffects.flightContext(0)).thenReturn(null); ++ assertTrue(BedrockFlightPhysics.isJumping(player)); ++ assertFalse(BedrockFlightPhysics.retainSwimming(player, false, true)); ++ verify(player, never()).setJumping(anyBoolean()); ++ } ++ } ++ @Test ++ void appliesNativeSwimmingOnceAndLeavesJavaAndMissingWaterUnchanged() { ++ final LocalPlayer player = mock(LocalPlayer.class); ++ final Minecraft client = mock(Minecraft.class); ++ final ClientLevel level = mock(ClientLevel.class); ++ final Abilities abilities = new Abilities(); ++ abilities.flying = true; ++ when(player.getAbilities()).thenReturn(abilities); ++ when(client.getCameraEntity()).thenReturn(player); ++ when(player.level()).thenReturn(level); ++ when(player.getEyePosition(0F)).thenReturn(Vec3.ZERO); ++ when(level.getBlockState(any())).thenReturn(Blocks.AIR.defaultBlockState()); ++ when(player.getXRot()).thenReturn(90F); ++ when(player.getDeltaMovement()).thenReturn(new Vec3(0.25, 0.2F, -0.5)); ++ when(player.isSwimming()).thenReturn(true); ++ when(player.isInWater()).thenReturn(true); ++ when(player.isJumping()).thenReturn(true); ++ try (final var connection = mockStatic(BedrockInputPermissions.class); ++ final var game = mockStatic(Minecraft.class); ++ final var effects = mockStatic(BedrockMovementEffects.class)) { ++ game.when(Minecraft::getInstance).thenReturn(client); ++ effects.when(() -> BedrockMovementEffects.flightContext(0)) ++ .thenReturn(new FlightMovementContext(0, 1, 0, 1F, false, 1F, 1F)); ++ BedrockFlightPhysics.applySwimmingControl(player); ++ verify(player, never()).setDeltaMovement(anyDouble(), anyDouble(), anyDouble()); ++ connection.when(BedrockInputPermissions::isBedrockConnection).thenReturn(true); ++ when(player.isInWater()).thenReturn(false); ++ BedrockFlightPhysics.applySwimmingControl(player); ++ verify(player, never()).setDeltaMovement(anyDouble(), anyDouble(), anyDouble()); ++ when(player.isInWater()).thenReturn(true); ++ BedrockFlightPhysics.applySwimmingControl(player); ++ verify(player).setDeltaMovement(0.25, (double) Float.intBitsToFloat(0xBFAE147B), -0.5); ++ clearInvocations(player); ++ when(level.getBlockState(any())).thenReturn(Blocks.STONE.defaultBlockState()); ++ when(player.getDeltaMovement()).thenReturn(new Vec3(0.25, -0.03125F, -0.5)); ++ when(player.getXRot()).thenReturn(Float.intBitsToFloat((int) 3263193103L)); ++ player.xRotO = -90F; ++ BedrockFlightPhysics.applySwimmingControl(player); ++ verify(player).setDeltaMovement(0.25, (double) Float.intBitsToFloat(1066860373), -0.5); ++ clearInvocations(player); ++ player.xRotO = 0F; ++ BedrockFlightPhysics.applySwimmingControl(player); ++ verify(player).setDeltaMovement(0.25, (double) Float.intBitsToFloat(1066859918), -0.5); ++ clearInvocations(player); ++ effects.when(() -> BedrockMovementEffects.flightContext(0)).thenReturn(null); ++ BedrockFlightPhysics.applySwimmingControl(player); ++ verify(player, never()).setDeltaMovement(anyDouble(), anyDouble(), anyDouble()); ++ verify(player, never()).getLookAngle(); ++ verify(player, never()).setJumping(anyBoolean()); ++ } ++ } ++} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockFluidPhysicsTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockFluidPhysicsTest.java +new file mode 100644 +index 0000000..dfed580 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockFluidPhysicsTest.java +@@ -0,0 +1,121 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/florianreuth/viafabricplus-bedrock ++ * Copyright (C) 2021-2026 the original authors ++ * - Florian Reuth ++ * - RK_01/RaphiMC ++ * Copyright (C) 2023-2026 ViaVersion and contributors ++ * ++ * This program is free software: you can redistribute it and/or modify ++ * it under the terms of the GNU General Public License as published by ++ * the Free Software Foundation, either version 3 of the License, or ++ * (at your option) any later version. ++ * ++ * This program is distributed in the hope that it will be useful, ++ * but WITHOUT ANY WARRANTY; without even the implied warranty of ++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++ * GNU General Public License for more details. ++ * ++ * You should have received a copy of the GNU General Public License ++ * along with this program. If not, see . ++ */ ++ ++package com.viaversion.viafabricplus.bedrock.movement; ++ ++import net.minecraft.core.BlockPos; ++import net.minecraft.world.phys.AABB; ++import net.minecraft.world.phys.Vec3; ++import org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.assertEquals; ++import static org.junit.jupiter.api.Assertions.assertFalse; ++import static org.junit.jupiter.api.Assertions.assertTrue; ++ ++class BedrockFluidPhysicsTest { ++ ++ @Test ++ void liquidJumpSuppressionIncludesTheHeadBoundaryAndBothBlendTransitions() { ++ for (final float blend : new float[]{0F, 0.1F, 0.5F, 0.9F, 1F}) { ++ assertTrue(BedrockFluidPhysics.shouldSuppressLiquidJump(blend, true, false)); ++ assertEquals(blend > 0F && blend < 1F, BedrockFluidPhysics.shouldSuppressLiquidJump(blend, true, true)); ++ assertEquals(blend > 0F && blend < 1F, BedrockFluidPhysics.shouldSuppressLiquidJump(blend, false, false)); ++ assertEquals(blend > 0F && blend < 1F, BedrockFluidPhysics.shouldSuppressLiquidJump(blend, false, true)); ++ } ++ } ++ ++ @Test ++ void waterSurfaceUsesTheNativeDepthOffsetForSourceFlowingAndFallingWater() { ++ for (final int blockY : new int[]{-64, -1, 0, 104, 319}) { ++ assertEquals((float) (blockY + 1), BedrockFluidPhysics.waterSurface(blockY, 0)); ++ for (int depth = 8; depth < 16; depth++) { ++ assertEquals(BedrockFluidPhysics.waterSurface(blockY, 0), BedrockFluidPhysics.waterSurface(blockY, depth)); ++ } ++ for (int depth = 1; depth < 8; depth++) { ++ final float surface = BedrockFluidPhysics.waterSurface(blockY, depth); ++ assertTrue(surface > blockY); ++ assertTrue(surface < BedrockFluidPhysics.waterSurface(blockY, depth - 1)); ++ assertEquals(blockY + 1 - depth / 9D, surface, 0.000016); ++ } ++ } ++ } ++ ++ @Test ++ void everyUpwardDirectionCapsOutsideLiquidEvenWhenThePoseCannotChange() { ++ for (final double lookY : new double[]{Double.MIN_VALUE, 0.5, 0.57357644, 0.70710677, 1}) { ++ assertTrue(BedrockFluidPhysics.shouldCapSwimming(lookY, false)); ++ assertFalse(BedrockFluidPhysics.shouldCapSwimming(lookY, true)); ++ } ++ for (final double lookY : new double[]{-1, -0.5, 0}) { ++ assertFalse(BedrockFluidPhysics.shouldCapSwimming(lookY, false)); ++ assertFalse(BedrockFluidPhysics.shouldCapSwimming(lookY, true)); ++ } ++ } ++ ++ @Test ++ void surfaceRetentionUsesTheNativeHorizontalDotProduct() { ++ for (final double lookY : new double[]{0, 0.5, 0.54, -0.70710677, -1}) { ++ assertTrue(BedrockFluidPhysics.canRemainSwimmingAtSurface(new Vec3(0, lookY, Math.sqrt(1 - lookY * lookY)))); ++ } ++ for (final double lookY : new double[]{0.545, 0.57357644, 0.70710677, 1}) { ++ assertFalse(BedrockFluidPhysics.canRemainSwimmingAtSurface(new Vec3(0, lookY, Math.sqrt(1 - lookY * lookY)))); ++ } ++ assertEquals(BedrockFluidPhysics.canRemainSwimmingAtSurface(new Vec3(0.5, 0.6, Math.sqrt(0.39))), ++ BedrockFluidPhysics.canRemainSwimmingAtSurface(new Vec3(Math.sqrt(0.39), 0.6, 0.5))); ++ } ++ ++ @Test ++ void upwardSwimmingRequiresBothBreathingAndBodyProbesBelowAir() { ++ for (final double lookY : new double[]{0.15F, 0.5, 0.70710677}) { ++ assertTrue(BedrockFluidPhysics.canStartSwimming(lookY, false, false)); ++ assertFalse(BedrockFluidPhysics.canStartSwimming(lookY, true, false)); ++ assertFalse(BedrockFluidPhysics.canStartSwimming(lookY, false, true)); ++ assertFalse(BedrockFluidPhysics.canStartSwimming(lookY, true, true)); ++ } ++ for (final double lookY : new double[]{-1, 0, Math.nextDown(0.15F)}) { ++ assertTrue(BedrockFluidPhysics.canStartSwimming(lookY, true, true)); ++ } ++ } ++ ++ @Test ++ void standingBodyProbeCrossesTheWaterBoundaryAtTheNativeCenter() { ++ assertEquals(new BlockPos(-7, 105, 15), BedrockFluidPhysics.aboveBodyCenter(new AABB(-6.8, 103.17427, 15.2, -6.2, 104.97427, 15.8))); ++ assertEquals(new BlockPos(-7, 104, 15), BedrockFluidPhysics.aboveBodyCenter(new AABB(-6.8, 103.06353, 15.2, -6.2, 104.86353, 15.8))); ++ assertEquals(new BlockPos(-1, 0, -1), BedrockFluidPhysics.aboveBodyCenter(new AABB(-0.6, -1.9, -0.6, 0, -0.1, 0))); ++ } ++ ++ @Test ++ void verticalInsetPreservesTheCenterAndNeverExpandsShortBoxes() { ++ for (final double height : new double[]{0, 0.2, 0.6, 0.8, 1.0, 1.8}) { ++ final AABB original = new AABB(-0.3, 104.7, -0.3, 0.3, 104.7 + height, 0.3); ++ final AABB adjusted = BedrockFluidPhysics.interactionBox(original); ++ assertEquals(original.getCenter().y, adjusted.getCenter().y, 1E-12); ++ assertEquals(Math.max(0, height - 0.8), adjusted.getYsize(), 1E-12); ++ assertTrue(adjusted.minY >= original.minY); ++ assertTrue(adjusted.maxY <= original.maxY); ++ assertEquals(original.minX, adjusted.minX); ++ assertEquals(original.maxX, adjusted.maxX); ++ assertEquals(original.minZ, adjusted.minZ); ++ assertEquals(original.maxZ, adjusted.maxZ); ++ } ++ } ++ ++} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockGeyserPhysicsTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockGeyserPhysicsTest.java +new file mode 100644 +index 0000000..f4e2dba +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockGeyserPhysicsTest.java +@@ -0,0 +1,101 @@ ++/* ++ * 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 net.minecraft.SharedConstants; ++import net.minecraft.core.BlockPos; ++import net.minecraft.server.Bootstrap; ++import net.minecraft.world.level.BlockGetter; ++import net.minecraft.world.level.block.Blocks; ++import net.minecraft.world.level.block.PotentSulfurBlock; ++import net.minecraft.world.level.block.entity.BlockEntity; ++import net.minecraft.world.level.block.state.BlockState; ++import net.minecraft.world.level.block.state.properties.BlockStateProperties; ++import net.minecraft.world.level.block.state.properties.PotentSulfurState; ++import net.minecraft.world.level.material.FluidState; ++import net.raphimc.viabedrock.api.model.GeyserMovement; ++import org.junit.jupiter.api.BeforeAll; ++import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; -+import static org.mockito.Mockito.*; + -+final class BedrockVehiclePredictionsTest { ++class BedrockGeyserPhysicsTest { ++ + @BeforeAll -+ static void initializeGame() { ++ static void initializeBlocks() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test -+ void leavesOrdinaryJavaAndUnnegotiatedSessionsWithoutOptionalFrames() { -+ final LocalPlayer player = mock(LocalPlayer.class); -+ try (final var permissions = mockStatic(BedrockInputPermissions.class); -+ final var networking = mockStatic(ClientPlayNetworking.class)) { -+ assertNull(BedrockVehiclePredictions.capture(player, 1)); -+ networking.verifyNoInteractions(); -+ verifyNoInteractions(player); ++ void distinguishesEruptingFromInactiveSulfurStates() { ++ for (final PotentSulfurState state : PotentSulfurState.values()) { ++ final var cell = cell(Blocks.POTENT_SULFUR.defaultBlockState().setValue(PotentSulfurBlock.STATE, state)); ++ assertTrue(cell.colliding()); ++ assertEquals(state == PotentSulfurState.ERUPTING || state == PotentSulfurState.CONTINUOUS, cell.erupting()); ++ assertFalse(cell.sourceLiquid()); + } + } + + @Test -+ void actualPlayRegistrationPairsIdentityAndDropsOldCallbacksAcrossReconnect() { -+ final var listener = mock(ClientPacketListener.class, RETURNS_DEEP_STUBS); -+ when(listener.getLocalGameProfile()).thenReturn(new GameProfile(UUID.randomUUID(), "vehicle-test")); -+ when(listener.hasClientLoaded()).thenReturn(true); -+ when(listener.getConnection().isConnected()).thenReturn(true); -+ final var level = mock(ClientLevel.class, RETURNS_DEEP_STUBS); -+ final var game = mock(Minecraft.class); -+ when(game.getConnection()).thenReturn(listener); -+ final LocalPlayer player = mock(LocalPlayer.class, withSettings().useConstructor( -+ game, level, listener, null, null, Input.EMPTY, false, null, null)); -+ final var mount = mock(AbstractBoat.class); -+ final var uuid = UUID.randomUUID(); -+ final var lifetime = UUID.randomUUID(); -+ when(player.getVehicle()).thenReturn(mount); -+ when(player.getRootVehicle()).thenReturn(mount); -+ when(player.level()).thenReturn(level); -+ when(player.blockPosition()).thenReturn(BlockPos.ZERO); -+ when(player.isAlive()).thenReturn(true); -+ when(mount.level()).thenReturn(level); -+ when(mount.getId()).thenReturn(2); -+ when(mount.getUUID()).thenReturn(uuid); -+ when(mount.blockPosition()).thenReturn(BlockPos.ZERO); -+ when(mount.getControllingPassenger()).thenReturn(player); -+ when(mount.isLocalInstanceAuthoritative()).thenReturn(true); -+ when(mount.isAlive()).thenReturn(true); -+ when(mount.getDeltaMovement()).thenReturn(new Vec3(-.125, .25, .375)); -+ when(level.getEntity(2)).thenReturn(mount); -+ when(level.hasChunkAt(any())).thenReturn(true); -+ final var actor = new NativeActorMessage.Snapshot(uuid, lifetime, false, 23, "minecraft:boat", "", 1F, -+ Set.of(), 0, 0, null, Map.of(), -17L); -+ final var context = new VehiclePredictionContext(1, new VehiclePredictionContext.Identity(2, uuid, lifetime, 23, -17)); -+ final AtomicReference> receiver = new AtomicReference<>(); -+ try (final var permissions = mockStatic(BedrockInputPermissions.class); -+ final var networking = mockStatic(ClientPlayNetworking.class); -+ final var actors = mockStatic(BedrockActorState.class)) { -+ permissions.when(BedrockInputPermissions::isBedrockConnection).thenReturn(true); -+ networking.when(() -> ClientPlayNetworking.canSend(VehiclePredictionPayload.TYPE)).thenReturn(true); -+ networking.when(() -> ClientPlayNetworking.registerReceiver(eq(VehiclePredictionContextPayload.TYPE), any())) -+ .thenAnswer(call -> { receiver.set(call.getArgument(1)); return true; }); -+ actors.when(BedrockActorState::supportsUniqueIds).thenReturn(true); -+ actors.when(() -> BedrockActorState.state(uuid)).thenReturn(actor); -+ BedrockVehiclePredictions.register(); -+ ClientPlayConnectionEvents.INIT.invoker().onPlayInit(listener, game); -+ assertNull(BedrockVehiclePredictions.capture(player, 7)); -+ final var callback = mock(ClientPlayNetworking.Context.class); -+ when(callback.client()).thenReturn(game); -+ receiver.get().receive(new VehiclePredictionContextPayload(context), callback); -+ final var frame = BedrockVehiclePredictions.capture(player, 7); -+ assertNotNull(frame); -+ assertEquals(7, frame.frameId()); -+ assertEquals(context, frame.context()); -+ assertEquals(-.125F, frame.motion().x()); -+ receiver.get().receive(new VehiclePredictionContextPayload(context), callback); -+ assertNull(BedrockVehiclePredictions.capture(player, 7)); -+ assertNotNull(BedrockVehiclePredictions.capture(player, 8)); -+ final var oldReceiver = receiver.get(); -+ final var next = mock(ClientPacketListener.class); -+ ClientPlayConnectionEvents.INIT.invoker().onPlayInit(next, game); -+ oldReceiver.receive(new VehiclePredictionContextPayload(context), callback); -+ ClientPlayConnectionEvents.DISCONNECT.invoker().onPlayDisconnect(listener, game); -+ assertNull(BedrockVehiclePredictions.capture(player, 9)); -+ when(game.getConnection()).thenReturn(next); -+ receiver.get().receive(new VehiclePredictionContextPayload(context), callback); -+ ClientPlayConnectionEvents.DISCONNECT.invoker().onPlayDisconnect(next, game); -+ assertNull(BedrockVehiclePredictions.capture(player, 10)); -+ } finally { -+ ClientPlayConnectionEvents.DISCONNECT.invoker().onPlayDisconnect(listener, game); ++ void distinguishesSourceFlowingFallingAndExtraLayerWater() { ++ final var source = cell(Blocks.WATER.defaultBlockState()); ++ assertTrue(source.mainSourceWater()); ++ assertTrue(source.sourceLiquid()); ++ assertFalse(source.colliding()); ++ for (int depth = 1; depth < 16; depth++) { ++ final var flowing = cell(Blocks.WATER.defaultBlockState().setValue(BlockStateProperties.LEVEL, depth)); ++ assertFalse(flowing.mainSourceWater()); ++ assertFalse(flowing.sourceLiquid()); + } ++ final var wetSlab = cell(Blocks.OAK_SLAB.defaultBlockState().setValue(BlockStateProperties.WATERLOGGED, true)); ++ assertTrue(wetSlab.extraSourceWater()); ++ assertFalse(wetSlab.mainSourceWater()); ++ assertTrue(wetSlab.colliding()); ++ final var drySlab = cell(Blocks.OAK_SLAB.defaultBlockState()); ++ assertFalse(drySlab.extraSourceWater()); + } + + @Test -+ void checksJoinedConnectionCapabilitiesAndActorRevisionBeforeSampling() { -+ final var listener = mock(ClientPacketListener.class, RETURNS_DEEP_STUBS); -+ when(listener.getLocalGameProfile()).thenReturn(new GameProfile(UUID.randomUUID(), "vehicle-test")); -+ final var level = mock(ClientLevel.class, RETURNS_DEEP_STUBS); -+ final LocalPlayer player = mock(LocalPlayer.class, withSettings().useConstructor( -+ mock(Minecraft.class), level, listener, null, null, Input.EMPTY, false, null, null)); -+ assertSame(listener, player.connection); -+ try (final var permissions = mockStatic(BedrockInputPermissions.class); -+ final var networking = mockStatic(ClientPlayNetworking.class); -+ final var actors = mockStatic(BedrockActorState.class)) { -+ permissions.when(BedrockInputPermissions::isBedrockConnection).thenReturn(true); -+ assertNull(BedrockVehiclePredictions.capture(player, 1)); -+ networking.verifyNoInteractions(); -+ when(listener.hasClientLoaded()).thenReturn(true); -+ assertNull(BedrockVehiclePredictions.capture(player, 1)); -+ networking.verifyNoInteractions(); -+ when(listener.getConnection().isConnected()).thenReturn(true); -+ assertNull(BedrockVehiclePredictions.capture(player, 1)); -+ actors.verifyNoInteractions(); -+ networking.when(() -> ClientPlayNetworking.canSend(VehiclePredictionPayload.TYPE)).thenReturn(true); -+ assertNull(BedrockVehiclePredictions.capture(player, 1)); -+ actors.verify(BedrockActorState::supportsUniqueIds); -+ actors.clearInvocations(); -+ actors.when(BedrockActorState::supportsUniqueIds).thenReturn(true); -+ assertNull(BedrockVehiclePredictions.capture(player, 1)); -+ verify(player).getVehicle(); -+ actors.verify(BedrockActorState::supportsUniqueIds); -+ actors.verifyNoMoreInteractions(); ++ void lavaRejectsTheCapWithoutContributingWaterDepth() { ++ final var source = cell(Blocks.LAVA.defaultBlockState()); ++ assertTrue(source.sourceLiquid()); ++ assertFalse(source.mainSourceWater()); ++ assertFalse(source.extraSourceWater()); ++ assertFalse(cell(Blocks.LAVA.defaultBlockState().setValue(BlockStateProperties.LEVEL, 1)).sourceLiquid()); ++ } ++ ++ private static GeyserMovement.Cell cell(final BlockState state) { ++ final BlockGetter level = new BlockGetter() { ++ @Override ++ public BlockEntity getBlockEntity(final BlockPos position) { ++ return null; ++ } ++ ++ @Override ++ public BlockState getBlockState(final BlockPos position) { ++ return state; ++ } ++ ++ @Override ++ public FluidState getFluidState(final BlockPos position) { ++ return state.getFluidState(); ++ } ++ ++ @Override ++ public int getHeight() { ++ return 384; ++ } ++ ++ @Override ++ public int getMinY() { ++ return -64; ++ } ++ }; ++ return BedrockGeyserPhysics.cell(level, BlockPos.ZERO); ++ } ++ ++} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockPlayerPostureTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockPlayerPostureTest.java +new file mode 100644 +index 0000000..b9f9d23 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockPlayerPostureTest.java +@@ -0,0 +1,45 @@ ++/* ++ * 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 net.minecraft.world.entity.Pose; ++import net.minecraft.world.phys.AABB; ++import org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++class BedrockPlayerPostureTest { ++ ++ @Test ++ void usesNativeFloatRoundingAndInsetAtWorldCoordinates() { ++ final var body = new AABB(-7.3, 104.5, 15.7, -6.7, 105.1, 16.3); ++ for (final var pose : new Pose[]{Pose.STANDING, Pose.CROUCHING, Pose.SWIMMING}) { ++ final var probe = BedrockPlayerPosture.probe(body, pose); ++ final float height = pose == Pose.STANDING ? 1.8F : pose == Pose.CROUCHING ? 1.49F : 0.6F; ++ assertEquals((double) ((float) body.minX + 0.01F), probe.minX); ++ assertEquals((double) ((float) body.maxX - 0.01F), probe.maxX); ++ assertEquals((double) ((float) body.minY + 0.01F), probe.minY); ++ assertEquals((double) (((float) body.minY + height) - 0.01F), probe.maxY); ++ assertEquals((double) ((float) body.minZ + 0.01F), probe.minZ); ++ assertEquals((double) ((float) body.maxZ - 0.01F), probe.maxZ); + } ++ final var ceiling = new AABB(-8, 106, 15, -6, 107, 17); ++ assertTrue(BedrockPlayerPosture.probe(body, Pose.STANDING).intersects(ceiling)); ++ assertFalse(BedrockPlayerPosture.probe(body, Pose.CROUCHING).intersects(ceiling)); ++ assertFalse(BedrockPlayerPosture.probe(body, Pose.SWIMMING).intersects(ceiling)); ++ } ++ ++ @Test ++ void collapsesInvertedInsetAxesAndRejectsUnrelatedPoses() { ++ final var probe = BedrockPlayerPosture.probe(new AABB(0, 0, 0, 0.01, 1, 0.01), Pose.SWIMMING); ++ assertEquals(probe.minX, probe.maxX); ++ assertEquals(probe.minZ, probe.maxZ); ++ assertTrue(probe.minY < probe.maxY); ++ assertThrows(IllegalArgumentException.class, ++ () -> BedrockPlayerPosture.probe(new AABB(0, 0, 0, 1, 1, 1), Pose.SLEEPING)); + } ++ +} -diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/input/VehiclePredictionPayloadTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/input/VehiclePredictionPayloadTest.java +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/AttributePhysicsStateTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/AttributePhysicsStateTest.java new file mode 100644 -index 0000000..b76bbfc +index 0000000..8a4b930 --- /dev/null -+++ b/src/test/java/com/viaversion/viafabricplus/bedrock/input/VehiclePredictionPayloadTest.java -@@ -0,0 +1,75 @@ ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/AttributePhysicsStateTest.java +@@ -0,0 +1,101 @@ +/* + * 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; ++package com.viaversion.viafabricplus.bedrock.movement.replay; + -+import io.netty.buffer.Unpooled; +import net.minecraft.SharedConstants; -+import net.minecraft.core.RegistryAccess; -+import net.minecraft.network.RegistryFriendlyByteBuf; ++import net.minecraft.resources.Identifier; +import net.minecraft.server.Bootstrap; -+import net.raphimc.viabedrock.api.model.VehiclePrediction; -+import net.raphimc.viabedrock.api.model.VehiclePredictionContext; -+import net.raphimc.viabedrock.protocol.model.Position3f; ++import net.minecraft.world.entity.ai.attributes.AttributeMap; ++import net.minecraft.world.entity.ai.attributes.AttributeModifier; ++import net.minecraft.world.entity.ai.attributes.Attributes; ++import net.minecraft.world.entity.player.Player; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + -+import java.util.UUID; -+ +import static org.junit.jupiter.api.Assertions.*; + -+final class VehiclePredictionPayloadTest { ++final class AttributePhysicsStateTest { + @BeforeAll + static void initializeGame() { + SharedConstants.tryDetectVersion(); @@ -2728,903 +7511,1647 @@ index 0000000..b76bbfc + } + + @Test -+ void transportsContextAndCompletedMotionThroughActualFabricCodecs() { -+ final var context = context(); -+ final var prediction = new VehiclePrediction(7, context, new Position3f(7, 63, -9), new Position3f(-.125F, .25F, .375F)); -+ final var buffer = new RegistryFriendlyByteBuf(Unpooled.buffer(), RegistryAccess.EMPTY); -+ try { -+ VehiclePredictionContextPayload.CODEC.encode(buffer, new VehiclePredictionContextPayload(context)); -+ assertEquals(context, VehiclePredictionContextPayload.CODEC.decode(buffer).context()); -+ assertFalse(buffer.isReadable()); -+ buffer.clear(); -+ VehiclePredictionPayload.CODEC.encode(buffer, new VehiclePredictionPayload(prediction)); -+ final var decoded = VehiclePredictionPayload.CODEC.decode(buffer).prediction(); -+ assertEquals(prediction, decoded); -+ assertEquals(-17L, decoded.context().vehicle().uniqueId()); -+ assertEquals(23L, decoded.context().vehicle().runtimeId()); -+ assertFalse(buffer.isReadable()); -+ } finally { -+ buffer.release(); -+ } ++ void ownsMaterializedValuesAndRetainsPermanentAndTransientModifierOwnership() { ++ final var attributes = new AttributeMap(Player.createAttributes().build()); ++ final var speed = attributes.getInstance(Attributes.MOVEMENT_SPEED); ++ final var permanent = modifier(1, .25); ++ final var temporary = modifier(2, .5); ++ speed.addPermanentModifier(permanent); ++ speed.addTransientModifier(temporary); ++ final var saved = AttributePhysicsState.capture(attributes); ++ speed.removeModifiers(); ++ speed.setBaseValue(3); ++ saved.restore(attributes); ++ assertEquals(.1F, speed.getBaseValue()); ++ assertTrue(speed.getPermanentModifiers().contains(permanent)); ++ assertFalse(speed.getPermanentModifiers().contains(temporary)); ++ assertEquals(2, speed.getModifiers().size()); ++ speed.removeModifiers(); ++ saved.restore(attributes); ++ assertEquals(saved, AttributePhysicsState.capture(attributes)); + } + + @Test -+ void transportsClearAndRefusesTrailingContextOrFrameData() { -+ final var buffer = new RegistryFriendlyByteBuf(Unpooled.buffer(), RegistryAccess.EMPTY); -+ try { -+ final var clear = new VehiclePredictionContextPayload(new VehiclePredictionContext(8, null)); -+ VehiclePredictionContextPayload.CODEC.encode(buffer, clear); -+ assertEquals(clear, VehiclePredictionContextPayload.CODEC.decode(buffer)); -+ buffer.clear(); -+ VehiclePredictionContextPayload.CODEC.encode(buffer, clear); -+ buffer.writeByte(1); -+ assertThrows(IllegalArgumentException.class, () -> VehiclePredictionContextPayload.CODEC.decode(buffer)); -+ buffer.clear(); -+ VehiclePredictionPayload.CODEC.encode(buffer, new VehiclePredictionPayload(new VehiclePrediction(8, context(), -+ new Position3f(0, 0, 0), new Position3f(0, 0, 0)))); -+ buffer.writeByte(1); -+ assertThrows(IllegalArgumentException.class, () -> VehiclePredictionPayload.CODEC.decode(buffer)); -+ } finally { -+ buffer.release(); -+ } ++ void changesTheServerBaseWithoutReplacingModifiersRecomputedByEarlierPhysics() { ++ final var attributes = new AttributeMap(Player.createAttributes().build()); ++ final var speed = attributes.getInstance(Attributes.MOVEMENT_SPEED); ++ final var before = AttributePhysicsState.capture(attributes); ++ speed.setBaseValue(.3); ++ final var serverUpdate = AttributePhysicsState.capture(attributes); ++ before.restore(attributes); ++ final var predicted = modifier(1, .9); ++ speed.addTransientModifier(predicted); ++ serverUpdate.applyChanges(before, attributes); ++ assertEquals(.3, speed.getBaseValue()); ++ assertEquals(predicted, speed.getModifier(predicted.id())); + } + -+ private static VehiclePredictionContext context() { -+ return new VehiclePredictionContext(4, new VehiclePredictionContext.Identity(2, UUID.randomUUID(), UUID.randomUUID(), 23, -17)); ++ @Test ++ void reconcilesRemovedChangedAndNewModifiersWithoutErasingUnchangedPredictedOnes() { ++ final var attributes = new AttributeMap(Player.createAttributes().build()); ++ final var speed = attributes.getInstance(Attributes.MOVEMENT_SPEED); ++ speed.addPermanentModifier(modifier(1, .2)); ++ speed.addTransientModifier(modifier(2, .3)); ++ final var before = AttributePhysicsState.capture(attributes); ++ speed.removeModifier(modifier(1, .2)); ++ speed.addOrUpdateTransientModifier(modifier(2, .6)); ++ speed.addPermanentModifier(modifier(3, .7)); ++ final var serverUpdate = AttributePhysicsState.capture(attributes); ++ before.restore(attributes); ++ speed.addTransientModifier(modifier(4, .8)); ++ serverUpdate.applyChanges(before, attributes); ++ assertNull(speed.getModifier(modifier(1, .2).id())); ++ assertEquals(modifier(2, .6), speed.getModifier(modifier(2, .6).id())); ++ assertEquals(modifier(4, .8), speed.getModifier(modifier(4, .8).id())); ++ assertTrue(speed.getPermanentModifiers().contains(modifier(3, .7))); ++ assertFalse(speed.getPermanentModifiers().contains(modifier(2, .6))); ++ } ++ ++ @Test ++ void doesNotMaterializeLazyDefaultsAndRestoresAbsenceAfterAnotherPhysicsBranch() { ++ final var attributes = new AttributeMap(Player.createAttributes().build()); ++ attributes.getInstance(Attributes.MOVEMENT_SPEED); ++ final int materialized = attributes.attributes.size(); ++ final var saved = AttributePhysicsState.capture(attributes); ++ assertEquals(materialized, attributes.attributes.size()); ++ assertFalse(attributes.attributes.containsKey(Attributes.GRAVITY)); ++ attributes.getInstance(Attributes.GRAVITY).setBaseValue(.5); ++ saved.restore(attributes); ++ assertFalse(attributes.attributes.containsKey(Attributes.GRAVITY)); ++ assertEquals(.08, attributes.getValue(Attributes.GRAVITY)); ++ } ++ ++ private static AttributeModifier modifier(final int id, final double amount) { ++ return new AttributeModifier(Identifier.withDefaultNamespace(Integer.toString(id)), amount, AttributeModifier.Operation.ADD_VALUE); + } +} -diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/input/VehiclePredictionSessionTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/input/VehiclePredictionSessionTest.java +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..5a56768 +index 0000000..090e854 --- /dev/null -+++ b/src/test/java/com/viaversion/viafabricplus/bedrock/input/VehiclePredictionSessionTest.java -@@ -0,0 +1,266 @@ ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalBoundingBoxTest.java +@@ -0,0 +1,21 @@ +/* + * 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; ++package com.viaversion.viafabricplus.bedrock.movement.replay; + -+import net.minecraft.SharedConstants; ++import net.minecraft.world.phys.AABB; ++import net.minecraft.world.phys.Vec3; ++import org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++final class PhysicalBoundingBoxTest { ++ @Test ++ void reappliesShapeOffsetsAroundCorrectedPositionWithoutMovingTheActorBack() { ++ final var shape = new PhysicalBoundingBox(new AABB(7.75, .5, 2.25, 9.5, 3, 4.25), new Vec3(8, 1, 3)); ++ assertEquals(new AABB(-4.25, 4.5, 8.25, -2.5, 7, 10.25), shape.at(new Vec3(-4, 5, 9))); ++ assertEquals(new AABB(7.75, .5, 2.25, 9.5, 3, 4.25), shape.bounds()); ++ } ++} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalCollisionCacheTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalCollisionCacheTest.java +new file mode 100644 +index 0000000..3548367 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalCollisionCacheTest.java +@@ -0,0 +1,204 @@ ++/* ++ * 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 com.viaversion.viafabricplus.bedrock.render.BedrockActorState; +import net.minecraft.client.multiplayer.ClientLevel; -+import net.minecraft.client.multiplayer.ClientPacketListener; -+import net.minecraft.client.player.LocalPlayer; ++import net.minecraft.client.multiplayer.ClientChunkCache; +import net.minecraft.core.BlockPos; ++import net.minecraft.world.level.block.Blocks; ++import net.minecraft.world.level.chunk.LevelChunk; ++import net.minecraft.world.level.chunk.status.ChunkStatus; ++import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.entity.Entity; -+import net.minecraft.world.entity.animal.equine.AbstractHorse; -+import net.minecraft.world.entity.vehicle.boat.AbstractBoat; ++import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; ++import net.minecraft.world.level.border.WorldBorder; ++import net.raphimc.viabedrock.api.model.CollisionContact; ++import net.raphimc.viabedrock.api.model.ActorSpatialCommand; ++import net.raphimc.viabedrock.api.model.NativeMovementSources; ++import net.raphimc.viabedrock.api.model.NativeActorBoundsMessage; +import net.raphimc.viabedrock.api.model.NativeActorMessage; -+import net.raphimc.viabedrock.api.model.VehiclePredictionContext; -+import net.raphimc.viabedrock.protocol.data.enums.bedrock.ActorFlags; +import net.raphimc.viabedrock.protocol.model.Position3f; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + -+import java.util.Map; ++import java.util.ArrayList; ++import java.util.List; +import java.util.Set; +import java.util.UUID; ++import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + -+final class VehiclePredictionSessionTest { ++final class PhysicalCollisionCacheTest { + @BeforeAll + static void initializeGame() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + -+ @Test -+ void samplesActualRootMotionSeparatelyFromCurrentPositionAndPlayerMotion() { -+ final Fixture f = new Fixture(false); -+ final var result = f.sample(7); -+ assertNotNull(result); -+ assertEquals(7, result.frameId()); -+ assertEquals(f.context, result.context()); -+ assertEquals(new Position3f(7, 63, -9), result.position()); -+ assertEquals(new Position3f(-.125F, .25F, .375F), result.motion()); -+ verify(f.player, never()).getDeltaMovement(); -+ verify(f.mount).getDeltaMovement(); -+ assertNull(f.sample(7)); -+ when(f.mount.getDeltaMovement()).thenReturn(Vec3.ZERO); -+ assertEquals(new Position3f(0, 0, 0), f.sample(8).motion()); -+ when(f.mount.getDeltaMovement()).thenReturn(new Vec3(.125, -.25, -.375)); -+ assertEquals(new Position3f(.125F, -.25F, -.375F), f.sample(9).motion()); ++ private static List boxes(final PhysicalCollisionCache.Snapshot snapshot) { ++ return snapshot.shapes().stream().map(PhysicalCollisionCache.Shape::bounds).toList(); + } + + @Test -+ void retainsFrameWatermarkAcrossCorrectionClearAndReRegistration() { -+ final Fixture f = new Fixture(false); -+ assertNotNull(f.sample(10)); -+ f.session.accept(f.listener, new VehiclePredictionContext(2, null)); -+ assertNull(f.sample(11)); -+ f.session.accept(f.listener, f.context); -+ assertNull(f.sample(12)); -+ f.context = new VehiclePredictionContext(3, f.context.vehicle()); -+ f.session.accept(f.listener, f.context); -+ assertNull(f.sample(10)); -+ assertNotNull(f.sample(11)); -+ f.session.accept(f.listener, new VehiclePredictionContext(3, null)); -+ assertNotNull(f.sample(12)); -+ f.session.accept(f.listener, f.context); -+ f.session.begin(f.listener); -+ assertNull(f.sample(12)); -+ assertNotNull(f.sample(13)); ++ void usesCapturedGeometryAfterAWorldChangeAndFetchesFreshGeometryAtTheNativeDistanceBoundary() { ++ final var query = new CollisionContact.Box(-1F, -1F, -1F, 4F, 3F, 1F); ++ final var oldWall = new AABB(1, 0, 0, 2, 2, 1); ++ final var newWall = new AABB(3, 0, 0, 4, 2, 1); ++ final var calls = new AtomicInteger(); ++ final var source = new ArrayList<>(List.of(PhysicalCollisionCache.Shape.fixed(oldWall))); ++ final var original = new PhysicalCollisionCache(Position3f.ZERO, null); ++ original.gather(query, ignored -> { calls.incrementAndGet(); return source; }); ++ final var captured = original.snapshot(); ++ source.clear(); ++ source.add(PhysicalCollisionCache.Shape.fixed(newWall)); ++ final var replay = new PhysicalCollisionCache(new Position3f(0.25F, 0F, 0F), captured); ++ final var reused = replay.gather(query, ignored -> { calls.incrementAndGet(); return source; }); ++ assertEquals(1, calls.get()); ++ assertEquals(List.of(oldWall), reused.stream().map(shape -> shape.bounds()).toList()); ++ final var outside = new PhysicalCollisionCache(new Position3f(2F, 0F, 0F), captured); ++ final var refreshed = outside.gather(query, ignored -> { calls.incrementAndGet(); return source; }); ++ assertEquals(2, calls.get()); ++ assertEquals(List.of(newWall), refreshed.stream().map(shape -> shape.bounds()).toList()); ++ assertEquals(List.of(oldWall), boxes(captured)); + } + + @Test -+ void rejectsOldConnectionCallbacksAndStartsFreshConnectionClock() { -+ final Fixture f = new Fixture(false); -+ assertNotNull(f.sample(50)); -+ final ClientPacketListener next = mock(ClientPacketListener.class); -+ f.session.begin(next); -+ f.session.accept(f.listener, f.context); -+ f.session.end(f.listener); -+ assertNull(f.session.sample(next, f.player, f.actor, 1)); -+ f.session.accept(next, f.context); -+ assertNull(f.sample(51)); -+ assertNotNull(f.session.sample(next, f.player, f.actor, 1)); -+ f.session.end(next); -+ assertNull(f.session.sample(next, f.player, f.actor, 2)); ++ void queriesOnlyTheNewRegionAndKeepsRetainedInputsIndependentOfLaterReplayCaches() { ++ final var first = new CollisionContact.Box(0F, 0F, 0F, 1F, 1F, 1F); ++ final var second = new CollisionContact.Box(0F, 0F, 0F, 2F, 1F, 1F); ++ final var oldWall = new AABB(0, 0, 0, 0.25, 1, 1); ++ final var addedWall = new AABB(1.5, 0, 0, 1.75, 1, 1); ++ final var original = new PhysicalCollisionCache(Position3f.ZERO, null); ++ original.gather(first, ignored -> List.of(PhysicalCollisionCache.Shape.fixed(oldWall))); ++ final var captured = original.snapshot(); ++ final var replay = new PhysicalCollisionCache(new Position3f(0.5F, 0F, 0F), captured); ++ final List queries = new ArrayList<>(); ++ replay.gather(second, region -> { queries.add(region); return List.of(PhysicalCollisionCache.Shape.fixed(addedWall)); }); ++ assertEquals(List.of(new CollisionContact.Box(1.00001F, 0.00001F, 0.00001F, 2F, 1F, 1F)), queries); ++ assertEquals(List.of(oldWall, addedWall), boxes(replay.snapshot())); ++ assertEquals(List.of(oldWall), boxes(captured)); ++ replay.gather(second, region -> { fail(); return List.of(); }); ++ final var again = new PhysicalCollisionCache(new Position3f(0.5F, 0F, 0F), captured); ++ again.gather(first, region -> { fail(); return List.of(); }); ++ assertEquals(List.of(oldWall), boxes(again.snapshot())); + } + + @Test -+ void rejectsStaleEpochAndConflictingEqualEpochIdentity() { -+ final Fixture f = new Fixture(false); -+ final var identity = f.context.vehicle(); -+ f.session.accept(f.listener, new VehiclePredictionContext(0, null)); -+ f.session.accept(f.listener, new VehiclePredictionContext(1, new VehiclePredictionContext.Identity( -+ identity.javaId(), identity.javaUuid(), UUID.randomUUID(), identity.runtimeId(), identity.uniqueId()))); -+ assertEquals(f.context, f.sample(1).context()); ++ void refreshesMovingColliderGeometryWithoutFollowingRecycledIdsOrMutatingCapturedShapes() { ++ try (var metadata = mockStatic(BedrockActorState.class)) { ++ final Entity collider = mock(Entity.class); ++ final ClientLevel level = mock(ClientLevel.class); ++ final UUID uuid = UUID.randomUUID(), lifetime = UUID.randomUUID(); ++ when(collider.level()).thenReturn(level); ++ when(collider.getId()).thenReturn(7); ++ when(collider.getUUID()).thenReturn(uuid); ++ when(level.getEntity(7)).thenReturn(collider); ++ metadata.when(() -> BedrockActorState.lifetime(uuid)).thenReturn(lifetime); ++ metadata.when(() -> BedrockActorState.liveLifetime(uuid)).thenReturn(lifetime); ++ final var nativeState = new NativeActorMessage.Snapshot(uuid, lifetime, false, 7, "minecraft:boat", "", 1F, Set.of(), 0, 0, null, java.util.Map.of(), 7L); ++ final var movement = new NativeMovementSources(new ActorSpatialCommand(ActorSpatialCommand.Action.MOVE, 7, uuid, lifetime, false, 0, ++ new Position3f(1.5F, 0F, .5F), (short) 0, 0, true, false, false, 0F)); ++ metadata.when(() -> BedrockActorState.state(uuid)).thenReturn(nativeState); ++ metadata.when(() -> BedrockActorState.bounds(uuid)).thenReturn(new NativeActorBoundsMessage(uuid, lifetime, false, 1F, 2F)); ++ metadata.when(() -> BedrockActorState.movement(uuid)).thenReturn(movement); ++ final AABB before = new AABB(1, 0, 0, 2, 2, 1), after = before.move(0.5, 0, 0); ++ when(collider.position()).thenReturn(new Vec3(1.5, 0, 0.5)); ++ when(collider.getBoundingBox()).thenReturn(before); ++ final var region = new CollisionContact.Box(0F, -1F, -1F, 4F, 3F, 2F); ++ final var captured = new PhysicalCollisionCache.Snapshot(Position3f.ZERO, region, ++ List.of(PhysicalCollisionCache.Shape.actor(collider))); ++ when(collider.position()).thenReturn(new Vec3(2, 0, 0.5)); ++ when(collider.getBoundingBox()).thenReturn(after); ++ movement.apply(new ActorSpatialCommand(ActorSpatialCommand.Action.MOVE, 7, uuid, lifetime, false, 1, ++ new Position3f(2F, 0F, .5F), (short) 0, 0, true, false, false, 0F)); ++ final var moving = new PhysicalCollisionCache(Position3f.ZERO, captured); ++ moving.gather(region, ignored -> { fail(); return List.of(); }); ++ assertEquals(List.of(after), boxes(moving.snapshot())); ++ assertEquals(List.of(before), boxes(captured)); ++ when(level.getEntity(7)).thenReturn(mock(Entity.class)); ++ final var replacement = new PhysicalCollisionCache(Position3f.ZERO, captured); ++ replacement.gather(region, ignored -> { fail(); return List.of(); }); ++ assertEquals(List.of(before), boxes(replacement.snapshot())); ++ when(level.getEntity(7)).thenReturn(collider); ++ metadata.when(() -> BedrockActorState.liveLifetime(uuid)).thenReturn(UUID.randomUUID()); ++ final var newLifetime = new PhysicalCollisionCache(Position3f.ZERO, captured); ++ newLifetime.gather(region, ignored -> { fail(); return List.of(); }); ++ assertEquals(List.of(before), boxes(newLifetime.snapshot())); ++ } + } + + @Test -+ void requiresExactNativeLifetimeRuntimeAndSignedUniqueIdentity() { -+ final Fixture f = new Fixture(false); -+ final var a = f.actor; -+ f.actor = actor(a.uuid(), UUID.randomUUID(), a.runtimeId(), a.uniqueId(), a.flags()); -+ assertNull(f.sample(1)); -+ f.actor = actor(a.uuid(), a.lifetime(), a.runtimeId() + 1, a.uniqueId(), a.flags()); -+ assertNull(f.sample(1)); -+ f.actor = actor(a.uuid(), a.lifetime(), a.runtimeId(), a.uniqueId() + 1, a.flags()); -+ assertNull(f.sample(1)); -+ f.actor = actor(a.uuid(), a.lifetime(), a.runtimeId(), null, a.flags()); -+ assertNull(f.sample(1)); -+ f.actor = actor(UUID.randomUUID(), a.lifetime(), a.runtimeId(), a.uniqueId(), a.flags()); -+ assertNull(f.sample(1)); -+ f.actor = null; -+ assertNull(f.sample(1)); -+ f.actor = a; -+ assertEquals(-17L, f.sample(1).context().vehicle().uniqueId()); ++ void recordsZeroDisplacementCollisionRequestsBeforeTheOrdinaryCollisionCallback() { ++ final Entity actor = mock(Entity.class); ++ final ClientLevel level = mock(ClientLevel.class); ++ when(actor.level()).thenReturn(level); ++ when(actor.position()).thenReturn(Vec3.ZERO); ++ when(actor.getBoundingBox()).thenReturn(new AABB(0, 0, 0, 0.6, 1.8, 0.6)); ++ when(actor.maxUpStep()).thenReturn(0.6F); ++ when(level.getWorldBorder()).thenReturn(mock(WorldBorder.class)); ++ when(level.getMinY()).thenReturn(-64); ++ when(level.getMaxY()).thenReturn(319); ++ final var chunks = mock(ClientChunkCache.class); ++ when(level.getChunkSource()).thenReturn(chunks); ++ when(chunks.getChunk(anyInt(), anyInt(), eq(ChunkStatus.FULL), eq(false))).thenReturn(mock(LevelChunk.class)); ++ final var wall = new AABB(1, 0, 0, 2, 1, 1); ++ when(level.getBlockState(any(BlockPos.class))).thenAnswer(invocation -> { ++ final BlockPos position = invocation.getArgument(0); ++ return position.getX() == 1 && position.getY() == 0 && position.getZ() == 0 ? Blocks.STONE.defaultBlockState() : Blocks.AIR.defaultBlockState(); ++ }); ++ final var seen = new ArrayList>(); ++ final var step = new PhysicalReplayContext.PhysicalStep(10, Set.of(actor), actor, List.of(), () -> ++ PhysicalCollisions.collide(actor, Vec3.ZERO, motion -> { ++ seen.add(PhysicalCollisions.collect(level).stream().map(shape -> shape.bounds()).toList()); ++ return motion; ++ })); ++ step.record(); ++ clearInvocations(level); ++ when(level.getBlockState(any(BlockPos.class))).thenReturn(Blocks.AIR.defaultBlockState()); ++ try (var ignored = PhysicalReplayContext.enter(level, null, Set.of(actor))) { ++ step.replay(); ++ } ++ assertEquals(List.of(List.of(wall), List.of(wall)), seen); ++ verify(level, never()).getBlockState(any(BlockPos.class)); ++ assertNull(PhysicalCollisions.collect(level)); + } + + @Test -+ void requiresExactJavaIdentityAndTrackedObject() { -+ final Fixture f = new Fixture(false); -+ when(f.mount.getId()).thenReturn(3); -+ assertNull(f.sample(1)); -+ when(f.mount.getId()).thenReturn(2); -+ when(f.mount.getUUID()).thenReturn(UUID.randomUUID()); -+ assertNull(f.sample(1)); -+ when(f.mount.getUUID()).thenReturn(f.context.vehicle().javaUuid()); -+ when(f.level.getEntity(2)).thenReturn(mock(AbstractBoat.class)); -+ assertNull(f.sample(1)); -+ when(f.level.getEntity(2)).thenReturn(f.mount); -+ assertNotNull(f.sample(1)); ++ void capturesAndRestoresCollisionInputsThroughActualPhysicalStepScopes() { ++ final Entity actor = mock(Entity.class); ++ final Entity other = mock(Entity.class); ++ when(actor.position()).thenReturn(Vec3.ZERO); ++ final var query = new CollisionContact.Box(0F, 0F, 0F, 2F, 2F, 2F); ++ final var source = new ArrayList<>(List.of(PhysicalCollisionCache.Shape.fixed(new AABB(1, 0, 0, 2, 2, 2)))); ++ final var queries = new AtomicInteger(); ++ final List> observed = new ArrayList<>(); ++ final var step = new PhysicalReplayContext.PhysicalStep(10, Set.of(actor), actor, List.of(), () -> { ++ assertNull(PhysicalReplayContext.collisions(other)); ++ final var cache = PhysicalReplayContext.collisions(actor); ++ assertNotNull(cache); ++ final var shapes = cache.gather(query, ignored -> { queries.incrementAndGet(); return source; }); ++ observed.add(shapes.stream().map(shape -> shape.bounds()).toList()); ++ }); ++ assertNull(PhysicalReplayContext.collisions(actor)); ++ step.record(); ++ source.clear(); ++ when(actor.position()).thenReturn(new Vec3(0.25, 0, 0)); ++ try (var ignored = PhysicalReplayContext.enter(null, null, Set.of(actor))) { ++ step.replay(); ++ step.replay(); ++ } ++ assertEquals(1, queries.get()); ++ assertEquals(3, observed.size()); ++ assertEquals(observed.getFirst(), observed.get(1)); ++ assertEquals(observed.getFirst(), observed.getLast()); ++ assertNull(PhysicalReplayContext.collisions(actor)); + } ++} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalCorrectionIdentityTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalCorrectionIdentityTest.java +new file mode 100644 +index 0000000..4fa5486 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalCorrectionIdentityTest.java +@@ -0,0 +1,149 @@ ++/* ++ * 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; + -+ @Test -+ void rejectsSecondaryNestedNonlocalAndMissingMounts() { -+ final Fixture f = new Fixture(false); -+ when(f.mount.getControllingPassenger()).thenReturn(mock(LocalPlayer.class)); -+ assertNull(f.sample(1)); -+ when(f.mount.getControllingPassenger()).thenReturn(f.player); -+ when(f.player.getRootVehicle()).thenReturn(mock(AbstractBoat.class)); -+ assertNull(f.sample(1)); -+ when(f.player.getRootVehicle()).thenReturn(f.mount); -+ when(f.mount.isLocalInstanceAuthoritative()).thenReturn(false); -+ assertNull(f.sample(1)); -+ when(f.mount.isLocalInstanceAuthoritative()).thenReturn(true); -+ when(f.player.getVehicle()).thenReturn(null); -+ assertNull(f.sample(1)); -+ when(f.player.getVehicle()).thenReturn(mock(Entity.class)); -+ assertNull(f.sample(1)); -+ when(f.player.getVehicle()).thenReturn(f.mount); -+ assertNotNull(f.sample(1)); -+ } ++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.minecraft.world.entity.Entity; ++import net.minecraft.world.entity.vehicle.boat.AbstractBoat; ++import net.minecraft.world.phys.Vec3; ++import net.raphimc.viabedrock.api.model.MovementCorrectionTiming; ++import net.raphimc.viabedrock.api.model.PlayerCorrection; ++import net.raphimc.viabedrock.api.model.ActorMotionCommand; ++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 java.util.List; ++import java.util.UUID; ++import static org.junit.jupiter.api.Assertions.*; ++import static org.mockito.Mockito.*; + -+ @Test -+ void rejectsDeathSpectatorDimensionAndUnloadedChunks() { -+ final Fixture f = new Fixture(false); -+ when(f.player.isAlive()).thenReturn(false); -+ assertNull(f.sample(1)); -+ when(f.player.isAlive()).thenReturn(true); -+ when(f.player.isSpectator()).thenReturn(true); -+ assertNull(f.sample(1)); -+ when(f.player.isSpectator()).thenReturn(false); -+ when(f.mount.isAlive()).thenReturn(false); -+ assertNull(f.sample(1)); -+ when(f.mount.isAlive()).thenReturn(true); -+ when(f.mount.level()).thenReturn(mock(ClientLevel.class)); -+ assertNull(f.sample(1)); -+ when(f.mount.level()).thenReturn(f.level); -+ when(f.level.hasChunkAt(any())).thenReturn(false); -+ assertNull(f.sample(1)); -+ when(f.level.hasChunkAt(any())).thenReturn(true); -+ assertNotNull(f.sample(1)); ++final class PhysicalCorrectionIdentityTest { ++ @BeforeAll ++ static void initializeGame() { ++ SharedConstants.tryDetectVersion(); ++ Bootstrap.bootStrap(); + } + + @Test -+ void gatesHorseByNativeTamedAndSaddledState() { -+ final Fixture f = new Fixture(true); -+ final var a = f.actor; -+ f.actor = actor(a.uuid(), a.lifetime(), a.runtimeId(), a.uniqueId(), Set.of()); -+ assertNull(f.sample(1)); -+ f.actor = actor(a.uuid(), a.lifetime(), a.runtimeId(), a.uniqueId(), Set.of(ActorFlags.TAMED)); -+ assertNull(f.sample(1)); -+ f.actor = actor(a.uuid(), a.lifetime(), a.runtimeId(), a.uniqueId(), Set.of(ActorFlags.SADDLED)); -+ assertNull(f.sample(1)); -+ f.actor = a; -+ assertNotNull(f.sample(1)); ++ void rejectsReplacedActorsWorldsAndNativeLifetimesEvenWhenTheJavaIdIsReused() { ++ try (var nativeState = mockStatic(BedrockActorState.class)) { ++ final Fixture f = new Fixture(); ++ nativeState.when(() -> BedrockActorState.lifetime(f.uuid)).thenReturn(f.lifetime); ++ nativeState.when(() -> BedrockActorState.liveLifetime(f.uuid)).thenReturn(f.lifetime); ++ final var graph = f.capture(7); ++ assertNotNull(graph.correctionTarget(f.boat, f.player, f.command, 7)); ++ nativeState.when(() -> BedrockActorState.liveLifetime(f.uuid)).thenReturn(UUID.randomUUID()); ++ assertNull(graph.correctionTarget(f.boat, f.player, f.command, 7)); ++ nativeState.when(() -> BedrockActorState.lifetime(f.uuid)).thenReturn(f.lifetime); ++ nativeState.when(() -> BedrockActorState.liveLifetime(f.uuid)).thenReturn(f.lifetime); ++ when(f.level.getEntity(42)).thenReturn(mock(Entity.class)); ++ assertNull(graph.correctionTarget(f.boat, f.player, f.command, 7)); ++ when(f.level.getEntity(42)).thenReturn(f.boat); ++ when(f.boat.level()).thenReturn(mock(ClientLevel.class)); ++ assertNull(graph.correctionTarget(f.boat, f.player, f.command, 7)); ++ when(f.boat.level()).thenReturn(f.level); ++ when(f.boat.isRemoved()).thenReturn(true); ++ assertNull(graph.correctionTarget(f.boat, f.player, f.command, 7)); ++ when(f.boat.isRemoved()).thenReturn(false); ++ assertNotNull(graph.correctionTarget(f.boat, f.player, f.command, 7)); ++ } + } + + @Test -+ void rejectsNonfiniteOrOverflowingFloatCoordinatesWithoutConsumingFrame() { -+ final Fixture f = new Fixture(false); -+ when(f.mount.getX()).thenReturn(Double.MAX_VALUE); -+ assertNull(f.sample(1)); -+ when(f.mount.getX()).thenReturn(Double.NaN); -+ assertNull(f.sample(1)); -+ when(f.mount.getX()).thenReturn(7.0000000001); -+ when(f.mount.getDeltaMovement()).thenReturn(new Vec3(0, Double.POSITIVE_INFINITY, 0)); -+ assertNull(f.sample(1)); -+ when(f.mount.getDeltaMovement()).thenReturn(new Vec3(Double.MAX_VALUE, 0, 0)); -+ assertNull(f.sample(1)); -+ when(f.mount.getDeltaMovement()).thenReturn(Vec3.ZERO); -+ assertEquals(7F, f.sample(1).position().x()); ++ void requiresHistoricalPrimaryControlAndTheSameContinuousControlLease() { ++ try (var nativeState = mockStatic(BedrockActorState.class)) { ++ final Fixture f = new Fixture(); ++ nativeState.when(() -> BedrockActorState.lifetime(f.uuid)).thenReturn(f.lifetime); ++ nativeState.when(() -> BedrockActorState.liveLifetime(f.uuid)).thenReturn(f.lifetime); ++ final var graph = f.capture(7); ++ assertNotNull(graph.correctionTarget(f.boat, f.player, f.command, 7)); ++ assertNull(graph.correctionTarget(f.boat, f.player, f.command, 9)); ++ when(f.boat.getControllingPassenger()).thenReturn(null); ++ assertNull(f.capture(9).correctionTarget(f.boat, f.player, f.command, 9)); ++ when(f.boat.getControllingPassenger()).thenReturn(f.player); ++ when(f.boat.isLocalInstanceAuthoritative()).thenReturn(false); ++ assertNull(f.capture(9).correctionTarget(f.boat, f.player, f.command, 9)); ++ when(f.boat.isLocalInstanceAuthoritative()).thenReturn(true); ++ assertNotNull(f.capture(9).correctionTarget(f.boat, f.player, f.command, 9)); ++ assertNull(graph.correctionTarget(mock(Entity.class), f.player, f.command, 7)); ++ } + } + + @Test -+ void cannotRebindConsumedFrameAfterLongMaximum() { -+ final Fixture f = new Fixture(false); -+ assertNull(f.sample(-1)); -+ assertNotNull(f.sample(Long.MAX_VALUE)); -+ f.session.accept(f.listener, new VehiclePredictionContext(2, f.context.vehicle())); -+ assertNull(f.sample(Long.MAX_VALUE)); -+ assertNull(f.sample(Long.MIN_VALUE)); -+ assertNull(f.sample(0)); ++ void rejectsHistoricalMotionAfterVehicleLeaseLifetimeOrAuthorityChanges() { ++ try (var nativeState = mockStatic(BedrockActorState.class)) { ++ final Fixture f = new Fixture(); ++ nativeState.when(() -> BedrockActorState.lifetime(f.uuid)).thenReturn(f.lifetime); ++ nativeState.when(() -> BedrockActorState.liveLifetime(f.uuid)).thenReturn(f.lifetime); ++ final var motion = new ActorMotionCommand(42, f.uuid, f.lifetime, 5, f.command.timing(), new Position3f(.5F, 1, 0)); ++ final var graph = f.capture(7); ++ assertTrue(graph.acceptsMotion(f.boat, f.player, motion, 7)); ++ assertFalse(graph.acceptsMotion(f.boat, f.player, motion, 9)); ++ nativeState.when(() -> BedrockActorState.liveLifetime(f.uuid)).thenReturn(UUID.randomUUID()); ++ assertFalse(graph.acceptsMotion(f.boat, f.player, motion, 7)); ++ nativeState.when(() -> BedrockActorState.lifetime(f.uuid)).thenReturn(f.lifetime); ++ nativeState.when(() -> BedrockActorState.liveLifetime(f.uuid)).thenReturn(f.lifetime); ++ when(f.boat.getControllingPassenger()).thenReturn(null); ++ assertFalse(f.capture(7).acceptsMotion(f.boat, f.player, motion, 7)); ++ when(f.boat.getControllingPassenger()).thenReturn(f.player); ++ when(f.boat.isLocalInstanceAuthoritative()).thenReturn(false); ++ assertFalse(f.capture(7).acceptsMotion(f.boat, f.player, motion, 7)); ++ } + } + -+ private static NativeActorMessage.Snapshot actor(final UUID uuid, final UUID lifetime, final long runtimeId, -+ final Long uniqueId, final Set flags) { -+ return new NativeActorMessage.Snapshot(uuid, lifetime, false, runtimeId, "minecraft:boat", "", 1F, -+ flags, 0, 0, null, Map.of(), uniqueId); ++ @Test ++ void admitsMountedPlayerMotionWhileKeepingPositionCorrectionPassengerRules() { ++ final ClientLevel level = mock(ClientLevel.class); ++ final LocalPlayer player = mock(LocalPlayer.class, withSettings().extraInterfaces(PhysicalStateAccess.Base.class, ++ PhysicalStateAccess.Living.class, PhysicalStateAccess.Player.class, PhysicalStateAccess.Local.class)); ++ final UUID uuid = UUID.randomUUID(), lifetime = UUID.randomUUID(); ++ when(player.level()).thenReturn(level); ++ when(player.getId()).thenReturn(1); ++ when(player.getUUID()).thenReturn(uuid); ++ when(player.getDeltaMovement()).thenReturn(Vec3.ZERO); ++ when(player.isPassenger()).thenReturn(true); ++ try (var nativeState = mockStatic(BedrockActorState.class)) { ++ nativeState.when(() -> BedrockActorState.lifetime(uuid)).thenReturn(lifetime); ++ nativeState.when(() -> BedrockActorState.liveLifetime(uuid)).thenReturn(lifetime); ++ final var graph = PhysicalGraphState.capture(List.of(player), List.of(), 7); ++ final var timing = new MovementCorrectionTiming(100, 105, 25, 100, -1); ++ assertTrue(graph.acceptsMotion(player, player, new ActorMotionCommand(1, uuid, lifetime, -1, timing, Position3f.ZERO), 7)); ++ assertNull(graph.correctionTarget(player, player, new PlayerCorrection(1, uuid, lifetime, 1, -1, timing, ++ Position3f.ZERO, Position3f.ZERO, new Position2f(0, 0), null, 1.62001F, 0, false), 7)); ++ } + } + + private static final class Fixture { -+ final VehiclePredictionSession session = new VehiclePredictionSession(); -+ final ClientPacketListener listener = mock(ClientPacketListener.class); -+ final ClientLevel level = mock(ClientLevel.class); -+ final LocalPlayer player = mock(LocalPlayer.class); -+ final Entity mount; -+ VehiclePredictionContext context; -+ NativeActorMessage.Snapshot actor; -+ -+ Fixture(final boolean horse) { -+ this.mount = horse ? mock(AbstractHorse.class) : mock(AbstractBoat.class); -+ final UUID uuid = UUID.randomUUID(), lifetime = UUID.randomUUID(); -+ this.context = new VehiclePredictionContext(1, new VehiclePredictionContext.Identity(2, uuid, lifetime, 23, -17)); -+ this.actor = actor(uuid, lifetime, 23, -17L, Set.of(ActorFlags.TAMED, ActorFlags.SADDLED)); -+ when(this.player.isAlive()).thenReturn(true); -+ when(this.player.getVehicle()).thenReturn(this.mount); -+ when(this.player.getRootVehicle()).thenReturn(this.mount); -+ when(this.player.level()).thenReturn(this.level); -+ when(this.player.blockPosition()).thenReturn(BlockPos.ZERO); -+ when(this.mount.getControllingPassenger()).thenReturn(this.player); -+ when(this.mount.isLocalInstanceAuthoritative()).thenReturn(true); -+ when(this.mount.isAlive()).thenReturn(true); -+ when(this.mount.level()).thenReturn(this.level); -+ when(this.mount.getId()).thenReturn(2); -+ when(this.mount.getUUID()).thenReturn(uuid); -+ when(this.mount.blockPosition()).thenReturn(BlockPos.ZERO); -+ when(this.mount.getX()).thenReturn(7D); -+ when(this.mount.getY()).thenReturn(63D); -+ when(this.mount.getZ()).thenReturn(-9D); -+ when(this.mount.getDeltaMovement()).thenReturn(new Vec3(-.125, .25, .375)); -+ when(this.level.getEntity(2)).thenReturn(this.mount); -+ when(this.level.hasChunkAt(any())).thenReturn(true); -+ this.session.begin(this.listener); -+ this.session.accept(this.listener, this.context); ++ private final ClientLevel level = mock(ClientLevel.class); ++ private final LocalPlayer player = mock(LocalPlayer.class); ++ private final AbstractBoat boat = mock(AbstractBoat.class, withSettings().extraInterfaces(PhysicalStateAccess.Base.class, PhysicalStateAccess.Boat.class)); ++ private final UUID uuid = UUID.randomUUID(); ++ private final UUID lifetime = UUID.randomUUID(); ++ private final PlayerCorrection command = new PlayerCorrection(42, this.uuid, this.lifetime, 0, 5, ++ new MovementCorrectionTiming(100, 105, 25, 100, -1), Position3f.ZERO, Position3f.ZERO, ++ new Position2f(0F, 0F), null, 0.375F, 90F, false); ++ ++ private Fixture() { ++ when(this.boat.level()).thenReturn(this.level); ++ when(this.boat.getId()).thenReturn(42); ++ when(this.boat.getUUID()).thenReturn(this.uuid); ++ when(this.boat.getDeltaMovement()).thenReturn(Vec3.ZERO); ++ when(this.boat.getControllingPassenger()).thenReturn(this.player); ++ when(this.boat.isLocalInstanceAuthoritative()).thenReturn(true); ++ when(this.player.getUUID()).thenReturn(UUID.randomUUID()); ++ when(this.level.getEntity(42)).thenReturn(this.boat); + } + -+ net.raphimc.viabedrock.api.model.VehiclePrediction sample(final long frame) { -+ return this.session.sample(this.listener, this.player, this.actor, frame); ++ private PhysicalGraphState capture(final long lease) { ++ return PhysicalGraphState.capture(List.of(this.boat), List.of(), lease); + } + } +} -diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockClimbingPhysicsTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockClimbingPhysicsTest.java +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalItemUseTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalItemUseTest.java new file mode 100644 -index 0000000..b1d54e6 +index 0000000..4901571 --- /dev/null -+++ b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockClimbingPhysicsTest.java -@@ -0,0 +1,53 @@ ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalItemUseTest.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.movement; ++package com.viaversion.viafabricplus.bedrock.movement.replay; + -+import net.minecraft.core.Direction; -+import net.minecraft.world.phys.AABB; -+import net.minecraft.world.phys.shapes.Shapes; ++import net.minecraft.SharedConstants; ++import net.minecraft.core.component.DataComponentMap; ++import net.minecraft.server.Bootstrap; ++import net.minecraft.world.InteractionHand; ++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 static org.junit.jupiter.api.Assertions.assertEquals; -+import static org.junit.jupiter.api.Assertions.assertTrue; ++import static org.junit.jupiter.api.Assertions.*; + -+class BedrockClimbingPhysicsTest { ++final class PhysicalItemUseTest { ++ @BeforeAll ++ static void initializeGame() { ++ SharedConstants.tryDetectVersion(); ++ Bootstrap.bootStrap(); ++ if (!Items.APPLE.builtInRegistryHolder().areComponentsBound()) { ++ Items.APPLE.builtInRegistryHolder().bindComponents(DataComponentMap.EMPTY); ++ } ++ } + + @Test -+ void advancesJumpClimbingBeforeGravityRatherThanMovingByThePreviousDampedImpulse() { -+ double position = 250; -+ double motion = -0.0784F; -+ for (int tick = 0; tick < 40; tick++) { -+ final double movement = BedrockClimbingPhysics.beforeMovement(motion, true, false); -+ position += movement; -+ motion = BedrockClimbingPhysics.afterGravity((movement - 0.08F) * 0.98F, false); -+ assertEquals(0.11760000884532928, motion, 0.00000001); -+ } -+ assertEquals(258, position, 0.000001); ++ void ownsTheMovementObservationWhileLiveConsumptionAndReplayedReadersChangeTheirStacks() { ++ final ItemStack live = new ItemStack(Items.APPLE, 4); ++ final var input = new PhysicalItemUse(true, InteractionHand.OFF_HAND, 7, (byte) 3, live); ++ live.shrink(1); ++ assertEquals(4, input.item().getCount()); ++ input.item().shrink(2); ++ assertEquals(4, input.item().getCount()); ++ assertEquals(3, live.getCount()); ++ assertEquals(7, input.remaining()); ++ assertEquals(InteractionHand.OFF_HAND, input.hand()); + } ++} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalMotionReplayTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalMotionReplayTest.java +new file mode 100644 +index 0000000..1a223b5 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalMotionReplayTest.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.movement.replay; ++ ++import com.google.gson.JsonArray; ++import com.google.gson.JsonParser; ++import java.nio.file.Files; ++import java.nio.file.Path; ++import net.raphimc.viabedrock.api.model.MovementCorrectionTiming; ++import net.raphimc.viabedrock.protocol.model.Position3f; ++import org.junit.jupiter.api.Test; ++import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; ++import static org.junit.jupiter.api.Assertions.*; + ++class PhysicalMotionReplayTest { + @Test -+ void clampsNativeDescentAndRetainsSneakingHoldWithoutSuppressingUpwardMotion() { -+ assertEquals(-0.2F, BedrockClimbingPhysics.beforeMovement(-1, false, false)); -+ assertEquals(-0.2744F, (BedrockClimbingPhysics.beforeMovement(-1, false, false) - 0.08F) * 0.98F, 0.00000002); -+ assertEquals(0, BedrockClimbingPhysics.beforeMovement(-1, false, true)); -+ assertEquals(0.1, BedrockClimbingPhysics.beforeMovement(0.1, false, true)); -+ assertEquals(0.2F, BedrockClimbingPhysics.beforeMovement(-1, true, true)); ++ void keepsLiveMotionAndReplaysItAtItsOriginalInputBeforeLaterCorrections() { ++ final var actor = new Actor(); ++ actor.completeSixInputs(); ++ final var motion = new Position3f(9.25F, -.5F, 1.125F); ++ actor.velocity = motion; ++ assertTrue(actor.history.scheduleMotion(new MovementCorrectionTiming(102, 105, 5, 100, -1), ignored -> true, () -> actor.velocity = motion)); ++ assertTrue(actor.history.replay()); ++ assertEquals(new Position3f(40.25F, -1.5F, 3.375F), actor.position); ++ assertEquals(new Position3f(13F, -.5F, 1.125F), actor.velocity); ++ // A later correction reexecutes the original velocity command without losing its tick placement. ++ actor.history.schedule(1, ignored -> true, () -> actor.position = Position3f.ZERO, true); ++ assertTrue(actor.history.replay()); ++ assertEquals(new Position3f(39F, -1.5F, 3.375F), actor.position); ++ assertEquals(new Position3f(13F, -.5F, 1.125F), actor.velocity); + } + + @Test -+ void wallContactAppliesItsImpulseAfterGravityAndJumpClimbingRetainsCeilingCollision() { -+ assertEquals(0.2F, BedrockClimbingPhysics.afterGravity(-0.0784F, true)); -+ final var body = new AABB(0.2, 0, 0.2, 0.8, 1.8F, 0.8); -+ final double requested = BedrockClimbingPhysics.beforeMovement(-0.0784F, true, false); -+ final double clipped = BedrockCollisionContacts.collide(Shapes.box(0, 1.9, 0, 1, 2.9, 1), Direction.Axis.Y, body, requested); -+ assertEquals(0.1, clipped, 0.000001); -+ assertTrue(clipped < requested); -+ // Entity.restituteMovementAfterCollisions clears velocity at a non-bouncing ceiling. -+ final double collisionMotion = 0; -+ assertEquals(-0.0784F, BedrockClimbingPhysics.afterGravity((collisionMotion - 0.08F) * 0.98F, false), 0.00000001); ++ void defersCurrentInputAndDropsHistoricalApplicationAfterAuthorityChanges() { ++ final var actor = new Actor(); ++ actor.completeSixInputs(); ++ final var velocity = new Position3f(-3F, .5F, 0); ++ actor.velocity = velocity; ++ actor.history.scheduleMotion(new MovementCorrectionTiming(105, 105, 5, 100, -1), ignored -> true, () -> actor.velocity = velocity); ++ assertFalse(actor.history.replay()); ++ actor.history.begin(6); ++ assertTrue(actor.history.replay()); ++ assertEquals(velocity, actor.velocity); ++ final boolean[] controlled = {true}; ++ actor.history.scheduleMotion(new MovementCorrectionTiming(102, 105, 5, 100, -1), ignored -> controlled[0], () -> actor.velocity = Position3f.ZERO); ++ controlled[0] = false; ++ assertFalse(actor.history.replay()); ++ assertEquals(velocity, actor.velocity); ++ } ++ ++ @Test ++ @EnabledIfEnvironmentVariable(named = "STACKANVIL_NATIVE_TICKED_MOTION", matches = ".+") ++ void matchesOriginalCommandApplicationAndReplayAcrossRetainedClocks() throws Exception { ++ final var reference = JsonParser.parseString(Files.readString(Path.of(System.getenv("STACKANVIL_NATIVE_TICKED_MOTION")))).getAsJsonObject(); ++ int compared = 0; ++ for (var value : reference.getAsJsonArray("rows")) { ++ final var row = value.getAsJsonObject(); ++ if (!row.get("accept").getAsBoolean()) continue; ++ final var actor = new Actor(); ++ actor.completeSixInputs(); ++ final var motion = vector(row.getAsJsonArray("motion")); ++ actor.velocity = motion; ++ final long tick = Long.parseUnsignedLong(row.get("tick").getAsString()); ++ actor.history.scheduleMotion(new MovementCorrectionTiming(tick, 105, 5, 100, -1), ignored -> true, () -> actor.velocity = motion); ++ actor.history.replay(); ++ assertEquals(vector(row.getAsJsonArray("positionAfter")), actor.position); ++ assertEquals(vector(row.getAsJsonArray("afterVector")), actor.velocity); ++ compared++; ++ } ++ assertEquals(reference.get("cases").getAsInt() / 2, compared); ++ } ++ ++ private static Position3f vector(final JsonArray values) { ++ return new Position3f(values.get(0).getAsFloat(), values.get(1).getAsFloat(), values.get(2).getAsFloat()); + } + ++ private record State(Position3f position, Position3f velocity) {} ++ ++ private static final class Actor { ++ private Position3f position = Position3f.ZERO; ++ private Position3f velocity = new Position3f(1, 0, 0); ++ private final PhysicalReplayHistory history = new PhysicalReplayHistory<>(20, ++ this::capture, ignored -> this.capture(), state -> { this.position = state.position; this.velocity = state.velocity; }, ++ (previous, next) -> () -> {}, ignored -> true, frame -> {}, frame -> {}); ++ ++ private State capture() { return new State(this.position, this.velocity); } ++ ++ private void completeSixInputs() { ++ for (int frame = 0; frame < 6; frame++) { ++ this.history.begin(frame); ++ final float acceleration = (frame + 1) * .25F; ++ final Runnable physics = () -> { this.velocity = this.velocity.add(acceleration, 0, 0); this.position = this.position.add(this.velocity); }; ++ this.history.record(physics); ++ physics.run(); ++ this.history.complete(frame); ++ } ++ } ++ } +} -diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockCollisionContactsTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockCollisionContactsTest.java +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalMovementCorrectionTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalMovementCorrectionTest.java new file mode 100644 -index 0000000..9d86f6c +index 0000000..a4ac827 --- /dev/null -+++ b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockCollisionContactsTest.java -@@ -0,0 +1,95 @@ ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalMovementCorrectionTest.java +@@ -0,0 +1,80 @@ +/* + * 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; ++package com.viaversion.viafabricplus.bedrock.movement.replay; + -+import net.minecraft.core.Direction; ++import net.minecraft.SharedConstants; ++import net.minecraft.server.Bootstrap; ++import net.minecraft.world.entity.Entity; ++import net.minecraft.world.entity.vehicle.boat.AbstractBoat; +import net.minecraft.world.phys.AABB; -+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.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.mockito.ArgumentCaptor; ++import java.util.UUID; ++import static org.junit.jupiter.api.Assertions.*; ++import static org.mockito.Mockito.*; + -+import java.util.List; -+ -+import static org.junit.jupiter.api.Assertions.assertEquals; -+import static org.junit.jupiter.api.Assertions.assertFalse; -+import static org.junit.jupiter.api.Assertions.assertTrue; ++final class PhysicalMovementCorrectionTest { ++ @BeforeAll ++ static void initializeGame() { ++ SharedConstants.tryDetectVersion(); ++ Bootstrap.bootStrap(); ++ } + -+class BedrockCollisionContactsTest { ++ private static PlayerCorrection correction(final boolean vehicle, final Float angular) { ++ return new PlayerCorrection(42, UUID.randomUUID(), UUID.randomUUID(), vehicle ? 0 : -3, vehicle ? 7 : -1, ++ new MovementCorrectionTiming(102, 105, 25, 100, -1), new Position3f(5.75F, 0F, 0F), ++ new Position3f(3F, 0F, 0F), new Position2f(40F, 50F), angular, 1.62F, vehicle ? 90F : 0F, true); ++ } + + @Test -+ void blocksTheRoundedTeleportAtTheFlowingChannelWall() { -+ final VoxelShape wall = Shapes.box(608, 250, 8, 609, 252, 9); -+ final float width = 0.6F; -+ for (final double center : new double[]{7.699999809265137, 7.700000762939453}) { -+ final AABB body = new AABB(608.5 - width / 2D, 250, center - width / 2D, 608.5 + width / 2D, 250 + 1.8F, center + width / 2D); -+ assertEquals(0D, BedrockCollisionContacts.collide(wall, Direction.Axis.Z, body, 0.014), 0D); -+ assertEquals(-0.014, BedrockCollisionContacts.collide(wall, Direction.Axis.Z, body, -0.014)); -+ } ++ void appliesNativeCommandWithFloatBoundsAndPreservesPreviousPositionAndRotation() { ++ final Entity actor = mock(Entity.class); ++ when(actor.getBbWidth()).thenReturn(0.6F); ++ when(actor.getBbHeight()).thenReturn(1.8F); ++ actor.xOld = 4.75; ++ actor.yOld = -2; ++ actor.zOld = -3; ++ actor.xRotO = -11; ++ actor.yRotO = -12; ++ PhysicalMovementCorrection.apply(actor, correction(false, null)); ++ verify(actor).setPos(5.75, -1.6200000047683716, 0); ++ verify(actor).setDeltaMovement(new Vec3(3, 0, 0)); ++ verify(actor).setOnGround(true); ++ verify(actor, never()).setXRot(anyFloat()); ++ verify(actor, never()).setYRot(anyFloat()); ++ assertEquals(4.75, actor.xOld); ++ assertEquals(-2, actor.yOld); ++ assertEquals(-3, actor.zOld); ++ assertEquals(-11, actor.xRotO); ++ assertEquals(-12, actor.yRotO); ++ final var box = ArgumentCaptor.forClass(AABB.class); ++ verify(actor).setBoundingBox(box.capture()); ++ // Original 1.26.51.1 command application fixture: each face is rounded separately to float. ++ assertEquals(new AABB(5.449999809265137, -1.6200000047683716, -0.30000001192092896, ++ 6.050000190734863, 0.1799999475479126, 0.30000001192092896), box.getValue()); + } + + @Test -+ void retainsGapsBetweenSeparateShapeBoxes() { -+ final VoxelShape posts = Shapes.or(Shapes.box(0, 0, 0, 0.25, 1, 1), Shapes.box(0.75, 0, 0, 1, 1, 1)); -+ final AABB throughGap = new AABB(0.3, 0, -1, 0.7, 0.8, -0.1); -+ assertEquals(0.5, BedrockCollisionContacts.collide(posts, Direction.Axis.Z, throughGap, 0.5)); -+ final AABB intoPost = new AABB(0.05, 0, -1, 0.2, 0.8, -0.1); -+ assertEquals(0.1, BedrockCollisionContacts.collide(posts, Direction.Axis.Z, intoPost, 0.5)); -+ assertEquals(0.5, BedrockCollisionContacts.collide(Shapes.empty(), Direction.Axis.Z, intoPost, 0.5)); ++ void correctsVehicleRotationAndOnlyReplacesPresentBoatAngularVelocity() { ++ for (Float angular : new Float[]{null, 0F, -0.75F, 0.75F}) { ++ final AbstractBoat boat = mock(AbstractBoat.class, withSettings().extraInterfaces(PhysicalStateAccess.Boat.class)); ++ when(boat.getBbWidth()).thenReturn(0.6F); ++ when(boat.getBbHeight()).thenReturn(1.8F); ++ PhysicalMovementCorrection.apply(boat, correction(true, angular)); ++ verify(boat).setXRot(40F); ++ verify(boat).setYRot(-40F); ++ final var access = (PhysicalStateAccess.Boat) boat; ++ if (angular == null) verify(access, never()).correctAngularVelocity(anyFloat()); ++ else verify(access).correctAngularVelocity(angular); ++ } + } ++} +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 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalReplayContextTest.java +@@ -0,0 +1,90 @@ ++/* ++ * 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; + -+ @Test -+ void landsExactlyOnFullBlockFacesWithUnroundedJavaBounds() { -+ for (final Direction.Axis axis : Direction.Axis.values()) { -+ for (final double origin : new double[]{0, 250, 608, 8192, -8192}) { -+ final VoxelShape block = Shapes.box(origin, origin, origin, origin + 1, origin + 1, origin + 1); -+ for (final double gap : new double[]{0.014, 0.137, 0.499}) { -+ final double[] lower = {origin + 0.2, origin + 0.2, origin + 0.2}; -+ final double[] upper = {origin + 0.8, origin + 0.8, origin + 0.8}; -+ final int component = axis.ordinal(); -+ lower[component] = origin - gap - 0.6; -+ upper[component] = origin - gap; -+ final AABB approachingPositive = new AABB(lower[0], lower[1], lower[2], upper[0], upper[1], upper[2]); -+ final double positive = BedrockCollisionContacts.collide(block, axis, approachingPositive, 1); -+ assertEquals(origin, approachingPositive.max(axis) + positive, 0D); -+ assertEquals(-1, BedrockCollisionContacts.collide(block, axis, approachingPositive, -1)); ++import net.minecraft.client.player.ClientInput; ++import net.minecraft.network.Connection; ++import net.minecraft.network.protocol.PacketFlow; ++import net.minecraft.world.entity.player.Input; ++import net.minecraft.world.phys.Vec2; ++import org.junit.jupiter.api.Test; ++ ++import java.util.List; ++import java.util.Set; ++import java.util.concurrent.CompletableFuture; ++import java.util.concurrent.TimeUnit; ++import java.util.concurrent.atomic.AtomicInteger; + -+ lower[component] = origin + 1 + gap; -+ upper[component] = origin + 1 + gap + 0.6; -+ final AABB approachingNegative = new AABB(lower[0], lower[1], lower[2], upper[0], upper[1], upper[2]); -+ final double negative = BedrockCollisionContacts.collide(block, axis, approachingNegative, -1); -+ assertEquals(origin + 1, approachingNegative.min(axis) + negative, 0D); -+ assertEquals(1, BedrockCollisionContacts.collide(block, axis, approachingNegative, 1)); -+ } -+ } ++import static org.junit.jupiter.api.Assertions.*; ++ ++final class PhysicalReplayContextTest { ++ @Test ++ void confinesNetworkAndWorldOverridesToTheReplayThreadAndClosesOnFailure() throws Exception { ++ assertFalse(PhysicalReplayContext.replaying()); ++ final Connection owned = new Connection(PacketFlow.CLIENTBOUND); ++ final Connection other = new Connection(PacketFlow.CLIENTBOUND); ++ try (var ignored = PhysicalReplayContext.enter(null, owned, Set.of())) { ++ PhysicalReplayContext.frame(8); ++ assertTrue(PhysicalReplayContext.suppressesNetwork(owned)); ++ assertFalse(PhysicalReplayContext.suppressesNetwork(other)); ++ assertEquals(7, PhysicalReplayContext.completedFrame(20)); ++ assertFalse(CompletableFuture.supplyAsync(() -> PhysicalReplayContext.suppressesNetwork(owned)) ++ .get(5, TimeUnit.SECONDS)); ++ assertThrows(IllegalStateException.class, () -> PhysicalReplayContext.enter(null, null, Set.of())); + } ++ assertFalse(PhysicalReplayContext.replaying()); ++ assertFalse(PhysicalReplayContext.suppressesNetwork(null)); ++ assertEquals(20, PhysicalReplayContext.completedFrame(20)); + } + + @Test -+ void clipsRequestsThatReachTheFaceBeforeTheirFloatRoundedDistance() { -+ final VoxelShape floor = Shapes.box(0, 249, 0, 1, 250, 1); -+ final AABB body = new AABB(0.2, 250.014, 0.2, 0.8, 251.814, 0.8); -+ final double requested = -(body.minY - 250) - 0.000001; -+ final double clipped = BedrockCollisionContacts.collide(floor, Direction.Axis.Y, body, requested); -+ assertEquals(250, body.minY + clipped, 0D); -+ assertTrue(clipped > requested); ++ void reusesRecordedInputAndWorldTimeWithoutPollingHardwareAgain() { ++ final TestInput input = new TestInput(); ++ final AtomicInteger polls = new AtomicInteger(); ++ final var step = new PhysicalReplayContext.PhysicalStep(44, Set.of(), null, List.of(), () -> { ++ PhysicalReplayContext.poll(input, () -> { ++ polls.incrementAndGet(); ++ input.set(new Input(true, false, true, false, false, true, false), .25F, .75F); ++ }); ++ if (PhysicalReplayContext.replaying()) assertEquals(44L, PhysicalReplayContext.gameTime(null)); ++ }); ++ step.record(); ++ final PhysicalInput expected = PhysicalInput.capture(input); ++ input.set(new Input(false, true, false, true, true, false, true), -1, -1); ++ try (var ignored = PhysicalReplayContext.enter(null, null, Set.of())) { ++ PhysicalReplayContext.frame(3); ++ step.replay(); ++ assertEquals(expected, PhysicalInput.capture(input)); ++ input.set(new Input(false, false, false, false, false, false, false), 0, 0); ++ step.replay(); ++ assertEquals(expected, PhysicalInput.capture(input)); ++ assertNull(PhysicalReplayContext.gameTime(null)); ++ } ++ assertEquals(1, polls.get()); + } + + @Test -+ void edgeGuardRequiresTheSameSupportAsVerticalCollision() { -+ final VoxelShape floor = Shapes.block(); -+ for (final double origin : new double[]{0, 608, -608, 8192, -8192}) { -+ final VoxelShape translated = floor.move(origin, 0, 0); -+ final AABB touchingEdge = new AABB(origin + 0.9999995, 1, 0.2, origin + 1.5999995, 2.8, 0.8); -+ assertTrue(BedrockCollisionContacts.canFallAtLeast(List.of(translated), touchingEdge, 0.6)); -+ assertEquals(-0.6, BedrockCollisionContacts.collide(translated, Direction.Axis.Y, touchingEdge, -0.6)); ++ void failsBeforePollingHardwareWhenPhysicsRequestsAnUnrecordedInput() { ++ final TestInput input = new TestInput(); ++ final AtomicInteger polls = new AtomicInteger(); ++ try (var ignored = PhysicalReplayContext.enter(null, null, Set.of())) { ++ assertThrows(IllegalStateException.class, () -> PhysicalReplayContext.poll(input, polls::incrementAndGet)); ++ assertEquals(0, polls.get()); ++ } ++ } + -+ final AABB supported = touchingEdge.move(-0.05, 0, 0); -+ assertFalse(BedrockCollisionContacts.canFallAtLeast(List.of(translated), supported, 0.6)); -+ assertEquals(0, BedrockCollisionContacts.collide(translated, Direction.Axis.Y, supported, -0.6)); ++ private static final class TestInput extends ClientInput implements PhysicalStateAccess.Input { ++ private void set(final Input input, final float strafe, final float forward) { ++ this.keyPresses = input; ++ this.moveVector = new Vec2(strafe, forward); ++ } ++ ++ @Override ++ public void restorePhysicalInput(final PhysicalInput value) { ++ this.set(value.keys(), value.strafe(), value.forward()); + } -+ assertTrue(BedrockCollisionContacts.canFallAtLeast(List.of(), new AABB(0, 1, 0, 0.6, 2.8, 0.6), 0.6)); + } +} -diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockFlightPhysicsTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockFlightPhysicsTest.java +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..d94bccc +index 0000000..bed506d --- /dev/null -+++ b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockFlightPhysicsTest.java -@@ -0,0 +1,130 @@ ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalReplayHistoryTest.java +@@ -0,0 +1,272 @@ +/* + * 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; ++package com.viaversion.viafabricplus.bedrock.movement.replay; + -+import com.viaversion.viafabricplus.bedrock.input.BedrockInputPermissions; -+import com.viaversion.viafabricplus.bedrock.input.BedrockMovementEffects; -+import net.minecraft.SharedConstants; -+import net.minecraft.client.Minecraft; -+import net.minecraft.client.multiplayer.ClientLevel; -+import net.minecraft.world.level.block.Blocks; -+import net.minecraft.world.phys.Vec3; -+import net.minecraft.client.player.LocalPlayer; -+import net.minecraft.server.Bootstrap; -+import net.minecraft.world.entity.player.Abilities; -+import net.minecraft.world.entity.player.Player; -+import net.raphimc.viabedrock.api.model.FlightMovementContext; -+import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + ++import java.util.ArrayList; ++import java.util.List; ++import java.util.Map; ++import java.util.Set; ++import java.util.function.Supplier; ++import java.util.stream.Collectors; ++ +import static org.junit.jupiter.api.Assertions.*; -+import static org.mockito.Mockito.*; + -+class BedrockFlightPhysicsTest { ++final class PhysicalReplayHistoryTest { ++ @Test ++ void discardsCommandsAfterAuthorityReplacementWithoutRestoringOrReexecutingPhysics() { ++ final Fixture actor = new Fixture(20); ++ final boolean[] controlled = {true}; ++ actor.tick(0); ++ actor.tick(1); ++ actor.executed.clear(); ++ assertTrue(actor.history.schedule(0, ignored -> controlled[0], () -> actor.motion = 100, true)); ++ controlled[0] = false; ++ final int position = actor.position; ++ assertFalse(actor.history.replay()); ++ assertEquals(position, actor.position); ++ assertTrue(actor.executed.isEmpty()); ++ assertTrue(actor.history.schedule(1, ignored -> true, () -> actor.motion = 10, true)); ++ assertTrue(actor.history.replay()); ++ assertEquals(List.of(1), actor.executed); ++ assertFalse(actor.history.replay()); ++ } + -+ @BeforeAll -+ static void initializeGame() { -+ SharedConstants.tryDetectVersion(); -+ Bootstrap.bootStrap(); ++ @Test ++ void restoresThenRerunsEveryRetainedInputInsteadOfAddingPositionDeltas() { ++ final Fixture actor = new Fixture(20); ++ actor.tick(0); ++ actor.tick(1); ++ actor.tick(2); ++ actor.history.begin(3); ++ actor.executed.clear(); ++ assertTrue(actor.history.schedule(1, ignored -> true, () -> actor.motion = 10, true)); ++ assertTrue(actor.history.replay()); ++ assertEquals(List.of(1, 2), actor.executed); ++ assertEquals(20, actor.position); ++ assertEquals(8, actor.motion); ++ // The open frame is ready for ordinary physics, which still runs exactly once. ++ actor.step(3); ++ actor.history.complete(3); ++ assertEquals(List.of(1, 2, 3), actor.executed); ++ assertEquals(28, actor.position); + } + + @Test -+ void keepsRawJumpAndRejectsUnnegotiatedOrUncontrolledActorsAcrossTransitions() { -+ final LocalPlayer player = mock(LocalPlayer.class); -+ final Minecraft client = mock(Minecraft.class); -+ final Abilities abilities = new Abilities(); -+ abilities.flying = true; -+ when(player.getAbilities()).thenReturn(abilities); -+ when(player.isJumping()).thenReturn(true); -+ when(client.getCameraEntity()).thenReturn(player); -+ try (final var connection = mockStatic(BedrockInputPermissions.class); -+ final var game = mockStatic(Minecraft.class); -+ final var effects = mockStatic(BedrockMovementEffects.class)) { -+ game.when(Minecraft::getInstance).thenReturn(client); -+ connection.when(BedrockInputPermissions::isBedrockConnection).thenReturn(false); -+ assertFalse(BedrockFlightPhysics.applies(player)); -+ assertFalse(BedrockFlightPhysics.retainSwimming(player, false, true)); -+ assertTrue(BedrockFlightPhysics.isJumping(player)); -+ connection.when(BedrockInputPermissions::isBedrockConnection).thenReturn(true); -+ assertFalse(BedrockFlightPhysics.applies(player)); -+ assertFalse(BedrockFlightPhysics.retainSwimming(player, false, true)); -+ assertTrue(BedrockFlightPhysics.isJumping(player)); -+ effects.when(() -> BedrockMovementEffects.flightContext(0)) -+ .thenReturn(new FlightMovementContext(0, 1, 0, 1F, false, 1F, 1F)); -+ assertTrue(BedrockFlightPhysics.applies(player)); -+ assertTrue(BedrockFlightPhysics.retainSwimming(player, false, true)); -+ assertFalse(BedrockFlightPhysics.retainSwimming(player, true, false)); -+ assertFalse(BedrockFlightPhysics.retainSwimming(mock(Player.class), false, true)); -+ assertFalse(BedrockFlightPhysics.isJumping(player)); -+ assertTrue(player.isJumping()); -+ abilities.flying = false; -+ assertFalse(BedrockFlightPhysics.retainSwimming(player, false, true)); -+ assertTrue(BedrockFlightPhysics.isJumping(player)); -+ abilities.flying = true; -+ when(player.isPassenger()).thenReturn(true); -+ assertTrue(BedrockFlightPhysics.isJumping(player)); -+ when(player.isPassenger()).thenReturn(false); -+ when(client.getCameraEntity()).thenReturn(null); -+ assertTrue(BedrockFlightPhysics.isJumping(player)); -+ when(client.getCameraEntity()).thenReturn(player); -+ effects.when(() -> BedrockMovementEffects.flightContext(0)).thenReturn(null); -+ assertTrue(BedrockFlightPhysics.isJumping(player)); -+ assertFalse(BedrockFlightPhysics.retainSwimming(player, false, true)); -+ verify(player, never()).setJumping(anyBoolean()); -+ } ++ void refreshesLaterSnapshotsAndReappliesOnlyExternalCommands() { ++ final Fixture actor = new Fixture(20); ++ actor.tick(0); ++ actor.motion = 6; ++ actor.tick(1); ++ actor.tick(2); ++ actor.history.begin(3); ++ assertTrue(actor.history.schedule(0, ignored -> true, () -> actor.motion = 10, true)); ++ actor.history.replay(); ++ assertEquals(21, actor.position); // 10, then the server's 6, then 5. ++ actor.executed.clear(); ++ assertTrue(actor.history.schedule(2, ignored -> true, () -> actor.motion = -3, true)); ++ actor.history.replay(); ++ assertEquals(List.of(2), actor.executed); ++ assertEquals(13, actor.position); // Refreshed frame two starts at 16, not the original 6. + } ++ + @Test -+ void appliesNativeSwimmingOnceAndLeavesJavaAndMissingWaterUnchanged() { -+ final LocalPlayer player = mock(LocalPlayer.class); -+ final Minecraft client = mock(Minecraft.class); -+ final ClientLevel level = mock(ClientLevel.class); -+ final Abilities abilities = new Abilities(); -+ abilities.flying = true; -+ when(player.getAbilities()).thenReturn(abilities); -+ when(client.getCameraEntity()).thenReturn(player); -+ when(player.level()).thenReturn(level); -+ when(player.getEyePosition(0F)).thenReturn(Vec3.ZERO); -+ when(level.getBlockState(any())).thenReturn(Blocks.AIR.defaultBlockState()); -+ when(player.getXRot()).thenReturn(90F); -+ when(player.getDeltaMovement()).thenReturn(new Vec3(0.25, 0.2F, -0.5)); -+ when(player.isSwimming()).thenReturn(true); -+ when(player.isInWater()).thenReturn(true); -+ when(player.isJumping()).thenReturn(true); -+ try (final var connection = mockStatic(BedrockInputPermissions.class); -+ final var game = mockStatic(Minecraft.class); -+ final var effects = mockStatic(BedrockMovementEffects.class)) { -+ game.when(Minecraft::getInstance).thenReturn(client); -+ effects.when(() -> BedrockMovementEffects.flightContext(0)) -+ .thenReturn(new FlightMovementContext(0, 1, 0, 1F, false, 1F, 1F)); -+ BedrockFlightPhysics.applySwimmingControl(player); -+ verify(player, never()).setDeltaMovement(anyDouble(), anyDouble(), anyDouble()); -+ connection.when(BedrockInputPermissions::isBedrockConnection).thenReturn(true); -+ when(player.isInWater()).thenReturn(false); -+ BedrockFlightPhysics.applySwimmingControl(player); -+ verify(player, never()).setDeltaMovement(anyDouble(), anyDouble(), anyDouble()); -+ when(player.isInWater()).thenReturn(true); -+ BedrockFlightPhysics.applySwimmingControl(player); -+ verify(player).setDeltaMovement(0.25, (double) Float.intBitsToFloat(0xBFAE147B), -0.5); -+ clearInvocations(player); -+ when(level.getBlockState(any())).thenReturn(Blocks.STONE.defaultBlockState()); -+ when(player.getDeltaMovement()).thenReturn(new Vec3(0.25, -0.03125F, -0.5)); -+ when(player.getXRot()).thenReturn(Float.intBitsToFloat((int) 3263193103L)); -+ player.xRotO = -90F; -+ BedrockFlightPhysics.applySwimmingControl(player); -+ verify(player).setDeltaMovement(0.25, (double) Float.intBitsToFloat(1066860373), -0.5); -+ clearInvocations(player); -+ player.xRotO = 0F; -+ BedrockFlightPhysics.applySwimmingControl(player); -+ verify(player).setDeltaMovement(0.25, (double) Float.intBitsToFloat(1066859918), -0.5); -+ clearInvocations(player); -+ effects.when(() -> BedrockMovementEffects.flightContext(0)).thenReturn(null); -+ BedrockFlightPhysics.applySwimmingControl(player); -+ verify(player, never()).setDeltaMovement(anyDouble(), anyDouble(), anyDouble()); -+ verify(player, never()).getLookAngle(); -+ verify(player, never()).setJumping(anyBoolean()); ++ void transfersCurrentTickCommandsIntoTheNextCaptureAndRetainsFutureCommandsWithoutDirtying() { ++ final Fixture actor = new Fixture(20); ++ actor.tick(0); ++ actor.tick(1); ++ assertTrue(actor.history.schedule(1, ignored -> true, () -> actor.motion = 7, false)); ++ assertFalse(actor.history.replay()); ++ assertTrue(actor.history.schedule(2, ignored -> true, () -> actor.motion = 12, true)); ++ actor.history.begin(2); ++ assertTrue(actor.history.replay()); ++ assertEquals(12, actor.motion); ++ assertTrue(actor.history.schedule(0, ignored -> true, () -> actor.position = 8, true)); ++ actor.executed.clear(); ++ actor.history.replay(); ++ assertEquals(List.of(0, 1), actor.executed); ++ assertEquals(16, actor.position); ++ assertEquals(12, actor.motion); ++ } ++ ++ @Test ++ void validatesTheEntireGraphSuffixBeforeAnyRestoreAndDropsDiscontinuousHistory() { ++ final Fixture actor = new Fixture(20); ++ actor.tick(0); ++ actor.tick(1); ++ actor.history.schedule(0, ignored -> true, () -> actor.position = 100, true); ++ actor.valid = false; ++ final int live = actor.position; ++ assertFalse(actor.history.replay()); ++ assertEquals(live, actor.position); ++ assertEquals(2, actor.executed.size()); ++ actor.valid = true; ++ actor.tick(7); ++ assertFalse(actor.history.schedule(1, ignored -> true, () -> actor.position = 100, true)); ++ } ++ ++ @Test ++ void evictsOldFramesAndAllowsSeveralOrderedCommandsForOneFrame() { ++ final Fixture actor = new Fixture(2); ++ actor.tick(0); ++ actor.tick(1); ++ actor.tick(2); ++ assertFalse(actor.history.schedule(0, ignored -> true, () -> actor.position = 100, true)); ++ actor.history.schedule(1, ignored -> true, () -> actor.motion = 10, true); ++ actor.history.schedule(1, ignored -> true, () -> actor.motion *= 2, true); ++ actor.history.replay(); ++ assertEquals(40, actor.position); ++ } ++ ++ @Test ++ void reexecutesAnExplicitVelocityCommandBetweenFramesWithoutRestoringOldPredictedMotion() { ++ final int[] actor = {0, 1}; ++ final Object owner = new Object(); ++ final Supplier capture = () -> PhysicalState.builder(owner) ++ .snapshotLate(Part.POSITION, actor[0], value -> actor[0] = value) ++ .snapshotLate(Part.MOTION, actor[1], value -> actor[1] = value).build(); ++ final PhysicalReplayHistory history = new PhysicalReplayHistory<>(20, capture, ignored -> capture.get(), PhysicalState::restore, ++ (previous, next) -> next.changesSince(previous)::apply, ignored -> true, frame -> {}, frame -> {}); ++ final Runnable physics = () -> { ++ actor[0] += actor[1]; ++ actor[1]--; ++ }; ++ history.begin(0); ++ history.record(physics); ++ physics.run(); ++ history.complete(0); ++ actor[1] = 6; ++ history.begin(1); ++ history.admitExternal(List.of(() -> actor[1] = 6)); ++ history.record(physics); ++ physics.run(); ++ history.complete(1); ++ history.schedule(0, ignored -> true, () -> actor[1] = 10, true); ++ history.replay(); ++ assertArrayEquals(new int[]{16, 5}, actor); ++ history.schedule(1, ignored -> true, () -> actor[1] = 20, true); ++ history.replay(); ++ assertArrayEquals(new int[]{30, 19}, actor); ++ } ++ ++ @Test ++ void refreshesEachHistoricalMountLayoutWithoutAddingTodaysParticipantsToEarlierFrames() { ++ final GraphActor player = new GraphActor(); ++ final GraphActor oldMount = new GraphActor(); ++ final GraphActor nextMount = new GraphActor(); ++ final GraphActor currentMount = new GraphActor(); ++ final GraphFixture graph = new GraphFixture(); ++ graph.active.addAll(List.of(player, oldMount)); ++ graph.tick(0, () -> { player.position++; oldMount.position += 10; }); ++ graph.active.remove(oldMount); ++ graph.active.add(nextMount); ++ graph.tick(1, () -> { player.position += 2; nextMount.position += 20; }); ++ graph.active.remove(nextMount); ++ graph.active.add(currentMount); ++ graph.history.begin(2); ++ 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); ++ assertTrue(graph.history.schedule(1, ignored -> true, () -> player.position = 40, true)); ++ assertTrue(graph.history.replay()); ++ assertEquals(42, player.position); ++ assertEquals(20, nextMount.position); ++ } ++ ++ @Test ++ void validatesActorsIntroducedDuringAFrameBeforeRestoringAnyHistoricalState() { ++ final GraphActor player = new GraphActor(); ++ final GraphActor passenger = new GraphActor(); ++ final GraphFixture graph = new GraphFixture(); ++ graph.active.add(player); ++ graph.history.begin(0); ++ graph.active.add(passenger); ++ final Runnable physics = () -> { player.position++; passenger.position++; }; ++ graph.history.record(physics); ++ physics.run(); ++ graph.history.complete(0); ++ passenger.alive = false; ++ assertTrue(graph.history.schedule(0, ignored -> true, () -> player.position = 100, true)); ++ assertFalse(graph.history.replay()); ++ assertEquals(0, graph.restores); ++ assertEquals(1, player.position); ++ assertEquals(1, passenger.position); ++ } ++ ++ private enum Part { POSITION, MOTION } ++ ++ private record State(int position, int motion) {} ++ ++ private static final class Fixture { ++ private int position; ++ private int motion = 1; ++ private boolean valid = true; ++ private final List executed = new ArrayList<>(); ++ private final PhysicalReplayHistory history; ++ ++ private Fixture(final int capacity) { ++ this.history = new PhysicalReplayHistory<>(capacity, this::capture, ignored -> this.capture(), state -> { ++ this.position = state.position(); ++ this.motion = state.motion(); ++ }, (previous, next) -> () -> { ++ if (previous.position() != next.position()) this.position = next.position(); ++ if (previous.motion() != next.motion()) this.motion = next.motion(); ++ }, ignored -> this.valid, frame -> {}, frame -> {}); ++ } ++ ++ private State capture() { ++ return new State(this.position, this.motion); ++ } ++ ++ private void tick(final int frame) { ++ this.history.begin(frame); ++ this.history.record(() -> this.step(frame)); ++ this.step(frame); ++ this.history.complete(frame); ++ } ++ ++ private void step(final int frame) { ++ this.executed.add(frame); ++ this.position += this.motion; ++ this.motion--; ++ } ++ } ++ ++ private static final class GraphActor { ++ private int position; ++ private boolean alive = true; ++ } ++ ++ private static final class GraphFixture { ++ private final List active = new ArrayList<>(); ++ private final List> refreshedLayouts = new ArrayList<>(); ++ private int restores; ++ private final PhysicalReplayHistory> history = new PhysicalReplayHistory<>(20, ++ () -> this.capture(this.active), previous -> { ++ this.refreshedLayouts.add(previous.keySet()); ++ 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 -> {}); ++ ++ private Map capture(final java.util.Collection actors) { ++ return actors.stream().collect(Collectors.toUnmodifiableMap(actor -> actor, actor -> actor.position)); ++ } ++ ++ private void tick(final long frame, final Runnable physics) { ++ this.history.begin(frame); ++ this.history.record(physics); ++ physics.run(); ++ this.history.complete(frame); + } + } +} -diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockFluidPhysicsTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockFluidPhysicsTest.java +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalStateTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalStateTest.java new file mode 100644 -index 0000000..dfed580 +index 0000000..574d73c --- /dev/null -+++ b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockFluidPhysicsTest.java -@@ -0,0 +1,121 @@ ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalStateTest.java +@@ -0,0 +1,124 @@ +/* -+ * This file is part of ViaFabricPlus Bedrock - https://github.com/florianreuth/viafabricplus-bedrock -+ * Copyright (C) 2021-2026 the original authors -+ * - Florian Reuth -+ * - RK_01/RaphiMC -+ * Copyright (C) 2023-2026 ViaVersion and contributors -+ * -+ * This program is free software: you can redistribute it and/or modify -+ * it under the terms of the GNU General Public License as published by -+ * the Free Software Foundation, either version 3 of the License, or -+ * (at your option) any later version. -+ * -+ * This program is distributed in the hope that it will be useful, -+ * but WITHOUT ANY WARRANTY; without even the implied warranty of -+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -+ * GNU General Public License for more details. -+ * -+ * You should have received a copy of the GNU General Public License -+ * along with this program. If not, see . ++ * 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; + -+package com.viaversion.viafabricplus.bedrock.movement; -+ -+import net.minecraft.core.BlockPos; -+import net.minecraft.world.phys.AABB; -+import net.minecraft.world.phys.Vec3; +import org.junit.jupiter.api.Test; + -+import static org.junit.jupiter.api.Assertions.assertEquals; -+import static org.junit.jupiter.api.Assertions.assertFalse; -+import static org.junit.jupiter.api.Assertions.assertTrue; ++import java.util.ArrayList; ++import java.util.List; ++import java.util.Objects; + -+class BedrockFluidPhysicsTest { ++import static org.junit.jupiter.api.Assertions.*; + ++final class PhysicalStateTest { + @Test -+ void liquidJumpSuppressionIncludesTheHeadBoundaryAndBothBlendTransitions() { -+ for (final float blend : new float[]{0F, 0.1F, 0.5F, 0.9F, 1F}) { -+ assertTrue(BedrockFluidPhysics.shouldSuppressLiquidJump(blend, true, false)); -+ assertEquals(blend > 0F && blend < 1F, BedrockFluidPhysics.shouldSuppressLiquidJump(blend, true, true)); -+ assertEquals(blend > 0F && blend < 1F, BedrockFluidPhysics.shouldSuppressLiquidJump(blend, false, false)); -+ assertEquals(blend > 0F && blend < 1F, BedrockFluidPhysics.shouldSuppressLiquidJump(blend, false, true)); -+ } ++ void ownsMutableValuesAcrossCaptureAndRepeatedRestore() { ++ final Fixture actor = new Fixture(); ++ final PhysicalState saved = actor.capture(); ++ actor.values.add(9); ++ actor.position = 30; ++ saved.restore(); ++ assertEquals(List.of(2, 3), actor.values); ++ assertEquals(1, actor.position); ++ actor.values.clear(); ++ saved.restore(); ++ assertEquals(List.of(2, 3), actor.values); + } + + @Test -+ void waterSurfaceUsesTheNativeDepthOffsetForSourceFlowingAndFallingWater() { -+ for (final int blockY : new int[]{-64, -1, 0, 104, 319}) { -+ assertEquals((float) (blockY + 1), BedrockFluidPhysics.waterSurface(blockY, 0)); -+ for (int depth = 8; depth < 16; depth++) { -+ assertEquals(BedrockFluidPhysics.waterSurface(blockY, 0), BedrockFluidPhysics.waterSurface(blockY, depth)); -+ } -+ for (int depth = 1; depth < 8; depth++) { -+ final float surface = BedrockFluidPhysics.waterSurface(blockY, depth); -+ assertTrue(surface > blockY); -+ assertTrue(surface < BedrockFluidPhysics.waterSurface(blockY, depth - 1)); -+ assertEquals(blockY + 1 - depth / 9D, surface, 0.000016); -+ } -+ } ++ void reappliesExternalChangesWithoutReplacingCorrectedMotionWithOldPrediction() { ++ final Fixture actor = new Fixture(); ++ final PhysicalState completed = actor.capture(); ++ actor.position = 7; // A server position command between completed ticks. ++ final PhysicalState.Changes commands = actor.capture().changesSince(completed); ++ actor.position = 22; ++ actor.motion = -4; // Motion recomputed by an earlier corrected frame. ++ commands.apply(); ++ assertEquals(7, actor.position); ++ assertEquals(-4, actor.motion); + } + + @Test -+ void everyUpwardDirectionCapsOutsideLiquidEvenWhenThePoseCannotChange() { -+ for (final double lookY : new double[]{Double.MIN_VALUE, 0.5, 0.57357644, 0.70710677, 1}) { -+ assertTrue(BedrockFluidPhysics.shouldCapSwimming(lookY, false)); -+ assertFalse(BedrockFluidPhysics.shouldCapSwimming(lookY, true)); -+ } -+ for (final double lookY : new double[]{-1, -0.5, 0}) { -+ assertFalse(BedrockFluidPhysics.shouldCapSwimming(lookY, false)); -+ assertFalse(BedrockFluidPhysics.shouldCapSwimming(lookY, true)); -+ } ++ void preservesCaptureOrderAndDoesNotLetBuilderReuseMutateExistingSnapshot() { ++ final Object owner = new Object(); ++ final List writes = new ArrayList<>(); ++ final var builder = PhysicalState.builder(owner).capture(Field.POSITION, 3, writes::add); ++ final PhysicalState first = builder.build(); ++ builder.capture(Field.MOTION, 4, writes::add); ++ final PhysicalState second = builder.build(); ++ first.restore(); ++ second.restore(); ++ assertEquals(List.of(3, 3, 4), writes); ++ } ++ ++ @Test ++ void rejectsReusingCommandsAcrossActorIdentityOrComponentLayout() { ++ final Fixture first = new Fixture(); ++ final Fixture replacement = new Fixture(); ++ assertThrows(IllegalArgumentException.class, () -> replacement.capture().changesSince(first.capture())); ++ final var incomplete = PhysicalState.builder(first).capture(Field.MOTION, first.motion, value -> first.motion = value).build(); ++ assertThrows(IllegalArgumentException.class, () -> first.capture().changesSince(incomplete)); + } + + @Test -+ void surfaceRetentionUsesTheNativeHorizontalDotProduct() { -+ for (final double lookY : new double[]{0, 0.5, 0.54, -0.70710677, -1}) { -+ assertTrue(BedrockFluidPhysics.canRemainSwimmingAtSurface(new Vec3(0, lookY, Math.sqrt(1 - lookY * lookY)))); -+ } -+ for (final double lookY : new double[]{0.545, 0.57357644, 0.70710677, 1}) { -+ assertFalse(BedrockFluidPhysics.canRemainSwimmingAtSurface(new Vec3(0, lookY, Math.sqrt(1 - lookY * lookY)))); -+ } -+ assertEquals(BedrockFluidPhysics.canRemainSwimmingAtSurface(new Vec3(0.5, 0.6, Math.sqrt(0.39))), -+ BedrockFluidPhysics.canRemainSwimmingAtSurface(new Vec3(Math.sqrt(0.39), 0.6, 0.5))); ++ void copiesArgumentsAgainWhenReconciliationMutatesItsInputs() { ++ final Fixture actor = new Fixture(); ++ final var first = actor.captureReconciled(); ++ actor.values.add(8); ++ final var commands = actor.captureReconciled().changesSince(first); ++ actor.values.clear(); ++ commands.apply(); ++ assertEquals(List.of(8), actor.values); ++ actor.values.clear(); ++ commands.apply(); ++ assertEquals(List.of(8), actor.values); + } + + @Test -+ void upwardSwimmingRequiresBothBreathingAndBodyProbesBelowAir() { -+ for (final double lookY : new double[]{0.15F, 0.5, 0.70710677}) { -+ assertTrue(BedrockFluidPhysics.canStartSwimming(lookY, false, false)); -+ assertFalse(BedrockFluidPhysics.canStartSwimming(lookY, true, false)); -+ assertFalse(BedrockFluidPhysics.canStartSwimming(lookY, false, true)); -+ assertFalse(BedrockFluidPhysics.canStartSwimming(lookY, true, true)); ++ void restoresKinematicsAfterMetadataCallbacksAndDoesNotReadmitOwnedJournalValues() { ++ final Fixture actor = new Fixture(); ++ final var before = PhysicalState.builder(actor) ++ .snapshotLate(Field.POSITION, actor.position, value -> actor.position = value) ++ .capture(Field.MOTION, actor.motion, value -> { ++ actor.motion = value; ++ actor.position = 90; ++ }).build(); ++ actor.position = 18; ++ before.restore(); ++ assertEquals(1, actor.position); ++ assertEquals(5, actor.motion); ++ actor.position = 22; ++ final var after = PhysicalState.builder(actor) ++ .snapshotLate(Field.POSITION, actor.position, value -> actor.position = value) ++ .capture(Field.MOTION, actor.motion, value -> actor.motion = value).build(); ++ assertTrue(after.changesSince(before).isEmpty()); ++ } ++ ++ private static final class Fixture { ++ private int position = 1; ++ private int motion = 5; ++ private List values = new ArrayList<>(List.of(2, 3)); ++ ++ private PhysicalState capture() { ++ return PhysicalState.builder(this) ++ .capture(Field.POSITION, this.position, value -> this.position = value) ++ .capture(Field.MOTION, this.motion, value -> this.motion = value) ++ .capture(Field.VALUES, this.values, value -> this.values = value, ArrayList::new, Objects::equals) ++ .build(); + } -+ for (final double lookY : new double[]{-1, 0, Math.nextDown(0.15F)}) { -+ assertTrue(BedrockFluidPhysics.canStartSwimming(lookY, true, true)); ++ ++ private PhysicalState captureReconciled() { ++ return PhysicalState.builder(this) ++ .capture(Field.VALUES, this.values, value -> this.values = value, ArrayList::new, Objects::equals, ++ (previous, next) -> { ++ next.removeAll(previous); ++ this.values.addAll(next); ++ }).build(); + } + } + -+ @Test -+ void standingBodyProbeCrossesTheWaterBoundaryAtTheNativeCenter() { -+ assertEquals(new BlockPos(-7, 105, 15), BedrockFluidPhysics.aboveBodyCenter(new AABB(-6.8, 103.17427, 15.2, -6.2, 104.97427, 15.8))); -+ assertEquals(new BlockPos(-7, 104, 15), BedrockFluidPhysics.aboveBodyCenter(new AABB(-6.8, 103.06353, 15.2, -6.2, 104.86353, 15.8))); -+ assertEquals(new BlockPos(-1, 0, -1), BedrockFluidPhysics.aboveBodyCenter(new AABB(-0.6, -1.9, -0.6, 0, -0.1, 0))); ++ private enum Field { POSITION, MOTION, VALUES } ++} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalWorldCollisionTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalWorldCollisionTest.java +new file mode 100644 +index 0000000..0ab2da3 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/replay/PhysicalWorldCollisionTest.java +@@ -0,0 +1,111 @@ ++/* ++ * 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.client.multiplayer.ClientChunkCache; ++import net.minecraft.client.multiplayer.ClientLevel; ++import net.minecraft.core.BlockPos; ++import net.minecraft.core.Direction; ++import net.minecraft.server.Bootstrap; ++import net.minecraft.world.entity.Entity; ++import net.minecraft.world.level.block.Blocks; ++import net.minecraft.world.level.border.WorldBorder; ++import net.minecraft.world.level.chunk.LevelChunk; ++import net.minecraft.world.level.chunk.status.ChunkStatus; ++import net.minecraft.world.phys.AABB; ++import net.minecraft.world.phys.Vec3; ++import net.minecraft.world.phys.shapes.Shapes; ++import org.junit.jupiter.api.BeforeAll; ++import org.junit.jupiter.api.Test; ++ ++import java.util.ArrayList; ++import java.util.List; ++import java.util.Set; ++import java.util.concurrent.atomic.AtomicBoolean; ++import java.util.concurrent.atomic.AtomicReference; ++ ++import static org.junit.jupiter.api.Assertions.*; ++import static org.mockito.Mockito.*; ++ ++final class PhysicalWorldCollisionTest { ++ @BeforeAll ++ static void initializeGame() { ++ SharedConstants.tryDetectVersion(); ++ Bootstrap.bootStrap(); ++ } ++ ++ private static ClientLevel world() { ++ final var level = mock(ClientLevel.class); ++ when(level.getMinY()).thenReturn(-64); ++ when(level.getMaxY()).thenReturn(319); ++ when(level.getWorldBorder()).thenReturn(mock(WorldBorder.class)); ++ when(level.getBlockState(any(BlockPos.class))).thenReturn(Blocks.AIR.defaultBlockState()); ++ return level; ++ } ++ ++ private static Entity actor(final ClientLevel level, final AtomicReference position) { ++ final var actor = mock(Entity.class); ++ when(actor.level()).thenReturn(level); ++ when(actor.position()).thenAnswer(ignored -> position.get()); ++ when(actor.getBoundingBox()).thenAnswer(ignored -> new AABB(position.get().x, position.get().y, position.get().z, ++ position.get().x + 0.6, position.get().y + 1.8, position.get().z + 0.6)); ++ when(actor.maxUpStep()).thenReturn(0.6F); ++ return actor; + } + + @Test -+ void verticalInsetPreservesTheCenterAndNeverExpandsShortBoxes() { -+ for (final double height : new double[]{0, 0.2, 0.6, 0.8, 1.0, 1.8}) { -+ final AABB original = new AABB(-0.3, 104.7, -0.3, 0.3, 104.7 + height, 0.3); -+ final AABB adjusted = BedrockFluidPhysics.interactionBox(original); -+ assertEquals(original.getCenter().y, adjusted.getCenter().y, 1E-12); -+ assertEquals(Math.max(0, height - 0.8), adjusted.getYsize(), 1E-12); -+ assertTrue(adjusted.minY >= original.minY); -+ assertTrue(adjusted.maxY <= original.maxY); -+ assertEquals(original.minX, adjusted.minX); -+ assertEquals(original.maxX, adjusted.maxX); -+ assertEquals(original.minZ, adjusted.minZ); -+ assertEquals(original.maxZ, adjusted.maxZ); ++ void retainsUnavailableChunkBarrierAfterArrivalAndFetchesCurrentStateAtTheReuseBoundary() { ++ final var level = world(); ++ final var chunks = mock(ClientChunkCache.class); ++ final var chunk = mock(LevelChunk.class); ++ final var arrived = new AtomicBoolean(); ++ when(level.getChunkSource()).thenReturn(chunks); ++ when(chunks.getChunk(anyInt(), anyInt(), eq(ChunkStatus.FULL), eq(false))).thenAnswer(invocation -> ++ invocation.getArgument(0) == 1 && !arrived.get() ? null : chunk); ++ final var position = new AtomicReference<>(new Vec3(15, 70, 0)); ++ final var actor = actor(level, position); ++ final var displacements = new ArrayList(); ++ final var step = new PhysicalReplayContext.PhysicalStep(10, Set.of(actor), actor, List.of(), () -> ++ PhysicalCollisions.collide(actor, new Vec3(1, 0, 0), motion -> { ++ displacements.add(Shapes.collide(Direction.Axis.X, actor.getBoundingBox(), PhysicalCollisions.collect(level), motion.x)); ++ return motion; ++ })); ++ step.record(); ++ arrived.set(true); ++ clearInvocations(level, chunks); ++ try (var ignored = PhysicalReplayContext.enter(level, null, Set.of(actor))) { ++ step.replay(); ++ verifyNoInteractions(chunks); ++ verify(level, never()).getBlockState(any(BlockPos.class)); ++ position.set(new Vec3(17, 70, 0)); ++ step.replay(); + } ++ assertEquals(3, displacements.size()); ++ assertEquals(0.4, displacements.get(0), 1E-9); ++ assertEquals(displacements.get(0), displacements.get(1)); ++ assertEquals(1, displacements.get(2)); ++ verify(chunks, never()).getChunk(anyInt(), anyInt(), any(), eq(true)); + } + ++ @Test ++ void collidesWithNativeBelowWorldFloorWithoutReadingOutOfHeightBlocks() { ++ final var level = world(); ++ final var chunks = mock(ClientChunkCache.class); ++ when(level.getChunkSource()).thenReturn(chunks); ++ when(chunks.getChunk(anyInt(), anyInt(), eq(ChunkStatus.FULL), eq(false))).thenReturn(mock(LevelChunk.class)); ++ final var actor = actor(level, new AtomicReference<>(new Vec3(0, -100, 0))); ++ final var clipped = new ArrayList(); ++ final var step = new PhysicalReplayContext.PhysicalStep(10, Set.of(actor), actor, List.of(), () -> ++ PhysicalCollisions.collide(actor, new Vec3(0, -16, 0), motion -> { ++ clipped.add(Shapes.collide(Direction.Axis.Y, actor.getBoundingBox(), PhysicalCollisions.collect(level), motion.y)); ++ return motion; ++ })); ++ step.record(); ++ assertEquals(List.of(-4D), clipped); ++ verify(level, never()).getBlockState(any(BlockPos.class)); ++ } +} -diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockGeyserPhysicsTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockGeyserPhysicsTest.java +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/PhysicalColliderSourcesTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/PhysicalColliderSourcesTest.java new file mode 100644 -index 0000000..f4e2dba +index 0000000..d64d727 --- /dev/null -+++ b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockGeyserPhysicsTest.java -@@ -0,0 +1,101 @@ ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/PhysicalColliderSourcesTest.java +@@ -0,0 +1,151 @@ +/* + * 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; ++package com.viaversion.viafabricplus.bedrock.render; + ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalCollisionCache; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalReplayContext; +import net.minecraft.SharedConstants; -+import net.minecraft.core.BlockPos; ++import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.server.Bootstrap; -+import net.minecraft.world.level.BlockGetter; -+import net.minecraft.world.level.block.Blocks; -+import net.minecraft.world.level.block.PotentSulfurBlock; -+import net.minecraft.world.level.block.entity.BlockEntity; -+import net.minecraft.world.level.block.state.BlockState; -+import net.minecraft.world.level.block.state.properties.BlockStateProperties; -+import net.minecraft.world.level.block.state.properties.PotentSulfurState; -+import net.minecraft.world.level.material.FluidState; -+import net.raphimc.viabedrock.api.model.GeyserMovement; ++import net.minecraft.world.entity.Entity; ++import net.minecraft.world.phys.AABB; ++import net.minecraft.world.phys.Vec3; ++import net.raphimc.viabedrock.api.model.ActorSpatialCommand; ++import net.raphimc.viabedrock.api.model.CollisionContact; ++import net.raphimc.viabedrock.api.model.NativeActorBoundsMessage; ++import net.raphimc.viabedrock.api.model.NativeActorMessage; ++import net.raphimc.viabedrock.protocol.model.Position3f; ++import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + ++import java.util.List; ++import java.util.Map; ++import java.util.Set; ++import java.util.UUID; ++ +import static org.junit.jupiter.api.Assertions.*; ++import static org.mockito.Mockito.*; + -+class BedrockGeyserPhysicsTest { ++class PhysicalColliderSourcesTest { ++ ++ private static final AABB RETAINED = new AABB(9.25, 11.5, 13.75, 14.25, 16.5, 18.75); + + @BeforeAll -+ static void initializeBlocks() { ++ static void initializeGame() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + ++ @AfterEach ++ void clearRegistry() { ++ BedrockActorState.clear(); ++ } ++ + @Test -+ void distinguishesEruptingFromInactiveSulfurStates() { -+ for (final PotentSulfurState state : PotentSulfurState.values()) { -+ final var cell = cell(Blocks.POTENT_SULFUR.defaultBlockState().setValue(PotentSulfurBlock.STATE, state)); -+ assertTrue(cell.colliding()); -+ assertEquals(state == PotentSulfurState.ERUPTING || state == PotentSulfurState.CONTINUOUS, cell.erupting()); -+ assertFalse(cell.sourceLiquid()); ++ void movedJavaPoseWithoutANativeSourceKeepsTheRetainedBoxEvenForAPredictedActor() { ++ final var fixture = new Fixture(); ++ final var captured = fixture.capture(); ++ when(fixture.actor.position()).thenReturn(new Vec3(1, 0, 0)); ++ when(fixture.actor.getBoundingBox()).thenReturn(RETAINED.move(1, 0, 0)); ++ try (var ignored = PhysicalReplayContext.enter(fixture.level, null, Set.of(fixture.actor))) { ++ assertEquals(RETAINED, fixture.refresh(captured)); + } + } + + @Test -+ void distinguishesSourceFlowingFallingAndExtraLayerWater() { -+ final var source = cell(Blocks.WATER.defaultBlockState()); -+ assertTrue(source.mainSourceWater()); -+ assertTrue(source.sourceLiquid()); -+ assertFalse(source.colliding()); -+ for (int depth = 1; depth < 16; depth++) { -+ final var flowing = cell(Blocks.WATER.defaultBlockState().setValue(BlockStateProperties.LEVEL, depth)); -+ assertFalse(flowing.mainSourceWater()); -+ assertFalse(flowing.sourceLiquid()); -+ } -+ final var wetSlab = cell(Blocks.OAK_SLAB.defaultBlockState().setValue(BlockStateProperties.WATERLOGGED, true)); -+ assertTrue(wetSlab.extraSourceWater()); -+ assertFalse(wetSlab.mainSourceWater()); -+ assertTrue(wetSlab.colliding()); -+ final var drySlab = cell(Blocks.OAK_SLAB.defaultBlockState()); -+ assertFalse(drySlab.extraSourceWater()); ++ void activeNativeTargetRefreshesAnUnchangedJavaPoseWithTheOriginalFloatBounds() { ++ final var fixture = new Fixture(); ++ BedrockActorState.apply(new NativeActorBoundsMessage(fixture.uuid, fixture.lifetime, false, 3.7F, 1.8F), null); ++ final var target = new Position3f(-17.75273323059082F, -84.07622528076172F, 95.51299285888672F); ++ fixture.command(1, target, false, false, 1.62F); ++ final var movement = BedrockActorState.movement(fixture.uuid); ++ movement.beforePhysics(Position3f.ZERO); ++ movement.beforePhysics(Position3f.ZERO); ++ final var captured = fixture.capture(); ++ assertEquals(new AABB(-19.602733612060547, -85.69622802734375, 93.66299438476562, ++ -15.902732849121094, -83.89622497558594, 97.36299133300781), fixture.refresh(captured)); ++ assertEquals(RETAINED, captured.shapes().getFirst().bounds()); + } + + @Test -+ void lavaRejectsTheCapWithoutContributingWaterDepth() { -+ final var source = cell(Blocks.LAVA.defaultBlockState()); -+ assertTrue(source.sourceLiquid()); -+ assertFalse(source.mainSourceWater()); -+ assertFalse(source.extraSourceWater()); -+ assertFalse(cell(Blocks.LAVA.defaultBlockState().setValue(BlockStateProperties.LEVEL, 1)).sourceLiquid()); ++ void historicalTargetSurvivesLaterPacketsAndLiveCounterCompletion() { ++ final var fixture = new Fixture(); ++ fixture.command(1, new Position3f(10, 20, 30), false, false, 0F); ++ final var captured = fixture.capture(); ++ fixture.command(2, new Position3f(100, 200, 300), false, false, 0F); ++ final var movement = BedrockActorState.movement(fixture.uuid); ++ for (int tick = 0; tick < 3; tick++) movement.beforePhysics(Position3f.ZERO); ++ assertFalse(movement.interpolation().active()); ++ assertEquals(new AABB(7.5, 20, 27.5, 12.5, 25, 32.5), fixture.refresh(captured)); + } + -+ private static GeyserMovement.Cell cell(final BlockState state) { -+ final BlockGetter level = new BlockGetter() { -+ @Override -+ public BlockEntity getBlockEntity(final BlockPos position) { -+ return null; -+ } ++ @Test ++ void liveNetworkHistoryTakesPriorityOverTheJavaPose() { ++ final var fixture = new Fixture(); ++ fixture.command(0, new Position3f(10, 20, 30), true, false, .375F); ++ final var captured = fixture.capture(); ++ fixture.command(1, new Position3f(40, 50, 60), true, false, .375F); ++ when(fixture.actor.getBoundingBox()).thenReturn(RETAINED.move(1000, 1000, 1000)); ++ assertEquals(new AABB(37.5, 49.625, 57.5, 42.5, 54.625, 62.5), fixture.refresh(captured)); ++ } + -+ @Override -+ public BlockState getBlockState(final BlockPos position) { -+ return state; -+ } ++ @Test ++ void normalPhaseAdvancesOnceAndReplayDoesNotAdvanceTheLiveInterpolator() { ++ final var fixture = new Fixture(); ++ fixture.command(1, new Position3f(10, 20, 30), false, false, 0F); ++ final var movement = BedrockActorState.movement(fixture.uuid); ++ BedrockActorState.beforePhysics(fixture.level); ++ assertEquals(2, movement.interpolation().positionSteps()); ++ try (var ignored = PhysicalReplayContext.enter(fixture.level, null, Set.of(fixture.actor))) { ++ BedrockActorState.beforePhysics(fixture.level); ++ } ++ assertEquals(2, movement.interpolation().positionSteps()); ++ } + -+ @Override -+ public FluidState getFluidState(final BlockPos position) { -+ return state.getFluidState(); -+ } ++ @Test ++ void dimensionAbsenceAndLifetimeRetirementPreserveTheCapturedGeometry() { ++ final var fixture = new Fixture(); ++ fixture.command(0, new Position3f(10, 20, 30), true, false, 0F); ++ final var captured = fixture.capture(); ++ BedrockActorState.apply(new NativeActorBoundsMessage(fixture.uuid, fixture.lifetime, false, null, 5F), null); ++ assertEquals(RETAINED, fixture.refresh(captured)); ++ BedrockActorState.apply(new NativeActorMessage.Remove(fixture.uuid, fixture.lifetime, false), null); ++ assertEquals(RETAINED, fixture.refresh(captured)); ++ } + -+ @Override -+ public int getHeight() { -+ return 384; -+ } ++ private static final class Fixture { ++ private final UUID uuid = UUID.randomUUID(), lifetime = UUID.randomUUID(); ++ private final ClientLevel level = mock(ClientLevel.class); ++ private final Entity actor = mock(Entity.class); ++ private final CollisionContact.Box region = new CollisionContact.Box(-1000, -1000, -1000, 1000, 1000, 1000); ++ ++ private Fixture() { ++ BedrockActorState.apply(new NativeActorMessage.Snapshot(this.uuid, this.lifetime, false, 7, ++ "minecraft:player", "", 1F, Set.of(), 0, 0, null, Map.of(), 7L), null); ++ BedrockActorState.apply(new NativeActorBoundsMessage(this.uuid, this.lifetime, false, 5F, 5F), null); ++ when(this.actor.level()).thenReturn(this.level); ++ when(this.actor.getId()).thenReturn(7); ++ when(this.actor.getUUID()).thenReturn(this.uuid); ++ when(this.actor.position()).thenReturn(Vec3.ZERO); ++ when(this.actor.getBoundingBox()).thenReturn(RETAINED); ++ when(this.level.getEntity(7)).thenReturn(this.actor); ++ } + -+ @Override -+ public int getMinY() { -+ return -64; -+ } -+ }; -+ return BedrockGeyserPhysics.cell(level, BlockPos.ZERO); -+ } ++ private void command(final long revision, final Position3f position, final boolean prediction, final boolean controlled, final float offset) { ++ BedrockActorState.apply(new ActorSpatialCommand(ActorSpatialCommand.Action.MOVE, 7, this.uuid, this.lifetime, false, revision, position, ++ (short) 0, 3, prediction, false, controlled, offset), null); ++ } ++ ++ private PhysicalCollisionCache.Snapshot capture() { ++ return new PhysicalCollisionCache.Snapshot(Position3f.ZERO, this.region, List.of(PhysicalCollisionCache.Shape.actor(this.actor))); ++ } + ++ private AABB refresh(final PhysicalCollisionCache.Snapshot captured) { ++ return new PhysicalCollisionCache(Position3f.ZERO, captured).snapshot().shapes().getFirst().bounds(); ++ } ++ } +} -diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockPlayerPostureTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockPlayerPostureTest.java +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/PhysicalReplayLifetimeTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/PhysicalReplayLifetimeTest.java new file mode 100644 -index 0000000..b9f9d23 +index 0000000..e9940c2 --- /dev/null -+++ b/src/test/java/com/viaversion/viafabricplus/bedrock/movement/BedrockPlayerPostureTest.java -@@ -0,0 +1,45 @@ ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/PhysicalReplayLifetimeTest.java +@@ -0,0 +1,151 @@ +/* + * 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; ++package com.viaversion.viafabricplus.bedrock.render; + -+import net.minecraft.world.entity.Pose; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalActorState; ++import com.viaversion.viafabricplus.bedrock.movement.replay.PhysicalCollisionCache; ++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.LocalPlayer; ++import net.minecraft.server.Bootstrap; ++import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.AABB; ++import net.minecraft.world.phys.Vec3; ++import net.raphimc.viabedrock.api.model.CollisionContact; ++import net.raphimc.viabedrock.api.model.ActorSpatialCommand; ++import net.raphimc.viabedrock.api.model.NativeActorBoundsMessage; ++import net.raphimc.viabedrock.api.model.NativeActorMessage; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.ActorFlags; ++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 java.util.List; ++import java.util.Map; ++import java.util.Set; ++import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; ++import static org.mockito.Mockito.*; + -+class BedrockPlayerPostureTest { ++final class PhysicalReplayLifetimeTest { ++ @BeforeAll ++ static void initializeGame() { ++ SharedConstants.tryDetectVersion(); ++ Bootstrap.bootStrap(); ++ } ++ ++ @ParameterizedTest ++ @EnumSource(Retirement.class) ++ void historicalMetadataCannotMakeRetiredActorOrColliderOwnershipValid(final Retirement retirement) { ++ final var fixture = new Fixture(); ++ try { ++ BedrockActorState.apply(fixture.original, null); ++ final var captured = fixture.capture(); ++ final var physical = fixture.physical(); ++ switch (retirement) { ++ case REPLACED -> BedrockActorState.apply(snapshot(fixture.uuid, UUID.randomUUID(), Set.of()), null); ++ case REMOVED -> BedrockActorState.apply(new NativeActorMessage.Remove(fixture.uuid, fixture.lifetime, false), null); ++ case CLEARED -> BedrockActorState.clear(); ++ } ++ when(fixture.actor.getBoundingBox()).thenReturn(fixture.retained.move(1, 0, 0)); ++ try (var ignored = PhysicalReplayContext.enter(fixture.level, null, Set.of(fixture.actor))) { ++ PhysicalReplayContext.actor(fixture.uuid, fixture.original); ++ assertSame(fixture.original, BedrockActorState.state(fixture.uuid)); ++ assertFalse(physical.valid(mock(LocalPlayer.class))); ++ final var replay = new PhysicalCollisionCache(Position3f.ZERO, captured); ++ assertEquals(fixture.retained, replay.snapshot().shapes().getFirst().bounds()); ++ assertSame(fixture.original, BedrockActorState.state(fixture.uuid)); ++ } ++ } finally { ++ BedrockActorState.clear(); ++ } ++ } + + @Test -+ void usesNativeFloatRoundingAndInsetAtWorldCoordinates() { -+ final var body = new AABB(-7.3, 104.5, 15.7, -6.7, 105.1, 16.3); -+ for (final var pose : new Pose[]{Pose.STANDING, Pose.CROUCHING, Pose.SWIMMING}) { -+ final var probe = BedrockPlayerPosture.probe(body, pose); -+ final float height = pose == Pose.STANDING ? 1.8F : pose == Pose.CROUCHING ? 1.49F : 0.6F; -+ assertEquals((double) ((float) body.minX + 0.01F), probe.minX); -+ assertEquals((double) ((float) body.maxX - 0.01F), probe.maxX); -+ assertEquals((double) ((float) body.minY + 0.01F), probe.minY); -+ assertEquals((double) (((float) body.minY + height) - 0.01F), probe.maxY); -+ assertEquals((double) ((float) body.minZ + 0.01F), probe.minZ); -+ assertEquals((double) ((float) body.maxZ - 0.01F), probe.maxZ); ++ void unchangedLiveLifetimeAcceptsOwnershipWhilePhysicsReadsHistoricalMetadata() { ++ final var fixture = new Fixture(); ++ try { ++ BedrockActorState.apply(fixture.original, null); ++ final var captured = fixture.capture(); ++ final var physical = fixture.physical(); ++ final var current = snapshot(fixture.uuid, fixture.lifetime, Set.of(ActorFlags.BLOCKING)); ++ BedrockActorState.apply(current, null); ++ final var bounds = fixture.retained.move(1, 0, 0); ++ when(fixture.actor.getBoundingBox()).thenReturn(bounds); ++ BedrockActorState.apply(new ActorSpatialCommand(ActorSpatialCommand.Action.MOVE, 7, fixture.uuid, fixture.lifetime, false, 1, ++ new Position3f(2.5F, 0F, .5F), (short) 0, 0, true, false, false, 0F), null); ++ try (var ignored = PhysicalReplayContext.enter(fixture.level, null, Set.of(fixture.actor))) { ++ PhysicalReplayContext.actor(fixture.uuid, fixture.original); ++ assertTrue(physical.valid(mock(LocalPlayer.class))); ++ assertEquals(bounds, new PhysicalCollisionCache(Position3f.ZERO, captured).snapshot().shapes().getFirst().bounds()); ++ assertSame(fixture.original, BedrockActorState.state(fixture.uuid)); ++ } ++ assertSame(current, BedrockActorState.state(fixture.uuid)); ++ } finally { ++ BedrockActorState.clear(); + } -+ final var ceiling = new AABB(-8, 106, 15, -6, 107, 17); -+ assertTrue(BedrockPlayerPosture.probe(body, Pose.STANDING).intersects(ceiling)); -+ assertFalse(BedrockPlayerPosture.probe(body, Pose.CROUCHING).intersects(ceiling)); -+ assertFalse(BedrockPlayerPosture.probe(body, Pose.SWIMMING).intersects(ceiling)); ++ } ++ ++ private static NativeActorMessage.Snapshot snapshot(final UUID uuid, final UUID lifetime, final Set flags) { ++ return new NativeActorMessage.Snapshot(uuid, lifetime, false, 17, "minecraft:boat", "", 1F, flags, 0, 0, null, Map.of(), 29L); + } + + @Test -+ void collapsesInvertedInsetAxesAndRejectsUnrelatedPoses() { -+ final var probe = BedrockPlayerPosture.probe(new AABB(0, 0, 0, 0.01, 1, 0.01), Pose.SWIMMING); -+ assertEquals(probe.minX, probe.maxX); -+ assertEquals(probe.minZ, probe.maxZ); -+ assertTrue(probe.minY < probe.maxY); -+ assertThrows(IllegalArgumentException.class, -+ () -> BedrockPlayerPosture.probe(new AABB(0, 0, 0, 1, 1, 1), Pose.SLEEPING)); ++ void recapturedHistoricalPhysicsKeepsItsOriginalOwnershipAndRejectsTheLiveReplacement() { ++ final var fixture = new Fixture(); ++ try { ++ final var current = snapshot(fixture.uuid, UUID.randomUUID(), Set.of()); ++ BedrockActorState.apply(current, null); ++ try (var ignored = PhysicalReplayContext.enter(fixture.level, null, Set.of(fixture.actor))) { ++ PhysicalReplayContext.actor(fixture.uuid, fixture.original); ++ final var relationships = PhysicalActorState.relationships(fixture.actor); ++ assertEquals(fixture.lifetime, relationships.nativeLifetime()); ++ assertSame(fixture.original, relationships.metadata()); ++ assertFalse(relationships.valid(mock(LocalPlayer.class))); ++ assertEquals(fixture.lifetime, PhysicalCollisionCache.Shape.actor(fixture.actor).owner().lifetime()); ++ } ++ assertSame(current, BedrockActorState.state(fixture.uuid)); ++ } finally { ++ BedrockActorState.clear(); ++ } + } + ++ private enum Retirement { REPLACED, REMOVED, CLEARED } ++ ++ private static final class Fixture { ++ private final UUID uuid = UUID.randomUUID(), lifetime = UUID.randomUUID(); ++ private final NativeActorMessage.Snapshot original = snapshot(this.uuid, this.lifetime, Set.of()); ++ private final ClientLevel level = mock(ClientLevel.class); ++ private final Entity actor = mock(Entity.class, withSettings().extraInterfaces(PhysicalStateAccess.Base.class)); ++ private final AABB retained = new AABB(1, 0, 0, 2, 2, 1); ++ ++ private Fixture() { ++ when(this.actor.level()).thenReturn(this.level); ++ when(this.actor.getId()).thenReturn(7); ++ when(this.actor.getUUID()).thenReturn(this.uuid); ++ when(this.level.getEntity(7)).thenReturn(this.actor); ++ when(this.actor.position()).thenReturn(Vec3.ZERO); ++ when(this.actor.getBoundingBox()).thenReturn(this.retained); ++ } ++ ++ private PhysicalCollisionCache.Snapshot capture() { ++ BedrockActorState.apply(new NativeActorBoundsMessage(this.uuid, this.lifetime, false, 1F, 2F), null); ++ BedrockActorState.apply(new ActorSpatialCommand(ActorSpatialCommand.Action.MOVE, 7, this.uuid, this.lifetime, false, 0, ++ new Position3f(1.5F, 0F, .5F), (short) 0, 0, true, false, false, 0F), null); ++ return new PhysicalCollisionCache.Snapshot(Position3f.ZERO, new CollisionContact.Box(0, -1, -1, 4, 3, 2), ++ List.of(PhysicalCollisionCache.Shape.actor(this.actor))); ++ } ++ ++ private PhysicalActorState physical() { ++ return new PhysicalActorState(this.actor, this.level, 7, this.uuid, this.lifetime, this.original, true, null, null); ++ } ++ } +} -- 2.56.0 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 0427b79a..cdc72b2b 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 @@ -1,5 +1,552 @@ # Send local player prediction to core +## Ticked velocity commands, October 23, 2026 + +`SET_ENTITY_MOTION` now preserves its server tick for clients with the negotiated physical replay channel. +The previous handler discarded that tick and translated every velocity into an immediate Java packet. +The new payload carries full float motion, the input clock binding, and the actor identity. +Tick zero and clients without this channel retain the existing standard packet path. + +Native constructor `144359a80`, receiver `142890810`, and application `144376520` establish two effects. +The receiver applies motion to the live actor immediately and retains the same command for historical input. +Old motion ticks clamp to the oldest retained input. +A past tick queues the command before the following input and can mark that input dirty. +The current tick enters the next input. +Future ticks retain the command without forcing replay. +Position corrections keep their separate expired-tick rejection rule. + +The client applies live motion without also recording an untimed external impulse. +It retains motion and position commands in packet receipt order. +Replay reapplies each motion command at its historical input before physical execution. +Actor UUID, native lifetime, world, connection, and vehicle epoch and control lease constrain admission. +Mounted player motion remains eligible. + +Fresh controls execute forty-eight original command constructions, receiver calls, applications, and replay decisions. +They cover old, current, future, and unsigned maximum ticks, three velocities, and both supplied controller outcomes. +Registered Java packet tests cover negotiation, full float transport, tick zero, remote actors, and stale vehicle identity fields. +Optional Java timing comparisons cover all forty-eight cases. +Optional client journal comparisons cover the twenty-four accepted-controller cases and compare final position and velocity. +Additional journal tests retain the impulse through a later correction and reject historical application after authority changes. + +The native fixture supplies component stores, allocation interfaces, snapshots, and simple physical inputs. +It does not execute the complete network handler, actor factory, or native world physics. +Live delayed knockback and mounted trajectories still require verification. +Remote history sampling, exact collider source selection, and all five goal areas remain unfinished. + +All four project builds pass. +The final suites pass 2,656 tests, with 163 optional skips and no failures or errors. +The complete retained reference set remains enabled, including the new motion controls. +All 1,421 core classes match the standalone, add-on, and proxy packages. +The compiled core also matches its packaged build-constant replacements. +All 1,143 production client classes match the add-on. +The source trees and package hashes remain unchanged after the reference-enabled test runs. +Offline validation passes 271 tooling tests, static checks, and patch metadata checks. + +## Native sampling inputs, October 23, 2026 + +The original prediction system and optional component wrapper now execute in 864 controls across 216 groups. +Five performance-counter frequencies produce the independently calculated nanosecond clock, including counters larger than 32 bits. +Four cached view drivers and two entity generations preserve the original sparse membership checks. +The consumer admits head targets through `MobFlagComponent` membership. +Optional head rotation membership does not supply that predicate. +Disabled option records skip consumption in thirty-two cases. +The remaining 832 frames match the private Java consumer and target publisher. +Incorrect head admission and incorrect clock units fail the comparisons. + +Another 108 native controls preserve receive timestamps through queued connection entries and the decoder timestamp assignment. +Original batch splitting passes 252 packet reads and fifty-four empty reads across fifty-four groups. +Every packet from one batch retains the same supplied receive timestamp. +The original uncompressed-header wrapper also preserves that timestamp. +Raw socket receipt, clock creation at the peer boundary, and complete packet dispatch remain supplied inputs. +These controls do not activate the private actor sampler in production. + +## Retained history sampler, October 23, 2026 + +The private Java history port now implements retained sample selection and its surrounding state transitions. +It keeps previous selected messages separate from the received deque and the latest message of each type. +Selection preserves native timestamp comparisons, unsigned sequence ties, startup fallbacks, and the previous selected timestamp floor. +The port also handles marker position requests, forced resume requests, completed sequences, and deque trimming. + +Fresh controls compare 1,936 frames across forty-four original native histories. +They cover both timing profiles, head publication, empty histories, single messages, movement and motion, timestamp ties, regressions, and submillisecond gaps. +Additional cases cross both signed and unsigned sequence boundaries. +The same controls compare initialized-history reset, six forced pose requests, and forty-six marker position requests. +Every frame compares target bytes, state-vector values, retained sequences, marker sequences, prior selections, completed sequence, resume state, and timing windows. +Another 162 earlier sampling cases also match the Java port. + +A selected ending message with a nonzero ground byte applies the complete pose delta. +The airborne branch uses the native whole-millisecond fraction and permits negative fractions in supplied early-time controls. +Grounded application retains float subtraction and addition instead of directly copying the ending coordinates. +Original reset `142888c50` clears history, prior selections, resume state, and statistics. +It preserves the completed sequence, timing profile, configured delay, and shared sequence source. +The supplied reset clock passes through the original performance-counter conversion. + +Four deliberately incorrect Java variants fail these comparisons. +They ignore the ending ground byte, compare sequences as signed values, retain the resume request, or clear the completed sequence during reset. +The original interpolation target publisher executes throughout these controls. +Position requests and forced actor-proxy pose application remain observed boundaries. + +Actual receive clocks, complete factory admission, native system scheduling, actor pose application, and shape integration remain unfinished. +The private port does not replace the production movement path yet. +Both documented collider mismatches remain unresolved, and this checkpoint claims no new live trajectory. + +## Movement construction, timing, and Java port prototype, October 23, 2026 + +Original base actor constructor `1419e3640` passes ninety interpolation construction controls. +Its final sparse membership and generation checks preserve existing interpolation or call original constructor `141a94ba0`. +The latter initializes all forty component bytes. +The original receive-proxy allocation and cache initialization also execute. +Actor-data, built-in-state, definition, and callback initialization remain supplied boundaries. +A null level and empty definition strings do not establish complete derived actor construction or factory behavior. + +Original transfer routine `1408c71e0` passes another two hundred controls, including 128 prediction constructions. +Without valid player membership, it preserves an existing prediction component or constructs one through original `1428888d0`. +A nonzero source actor byte at `0x354` then sets prediction byte `0x80` and the qword at `0x88` to one. +Valid player membership skips these writes; it does not itself remove an existing prediction component. +Original particle-dispatcher pair writes and attribute callback reset also execute. +Actor transfer and initialization helpers, registry discovery, and sparse insertion remain supplied boundaries. +The actual initialization helper can determine membership, so this does not establish prediction admission for every Java actor class. + +Original statistics updater `147cae990` and consumer `142888de0` pass 186 timing comparisons across six long-lived histories. +An independent model compares received intervals and all three old/target/current/step windows after updates. +An instruction observer reads the resulting delay and sample-time calculation without replacing arithmetic or stopping execution. +Normal timing uses the smoothed movement interval; the alternate profile uses the raw combined interval. +A zero interval gives a zero scale; other intervals produce a scale clamped between one and eight. +Effective delay cannot fall below its base delay. +Each statistics step truncates the scaled float difference before adding the old integer value. +Requested sample time also respects the previous selected message's timestamp. +This floor is not the previous interpolated pose's timestamp. +The controls include long gaps, changing intervals, and deliberately reordered timestamps. +They supply clocks, startup interfaces, and a separate global statistics object. + +Private Java 25 prototypes now implement message construction, timing, retained interpolation, queued targets, and grounded adjustment. +Timing matches all 186 native frames. +Interpolation matches 432 packet targets, 512 ticks, 108 queue cases, 132 grounded adjustments, and twenty valid sample-target publications. +History construction matches 192 packets, 4,704 retained-message comparisons, and both initial seed messages. +The comparisons also check the auxiliary marker sequence queue. +That queue uses virtual predicate offset `0x10`, distinct from pose availability at offset `0x08`. +Move byte sixty-five and its copied motion marker control insertion. + +These prototypes remain private while runtime integration remains unfinished. +The retained sampler section records the later independent comparisons of mixed and sequential sampling. +Actual clock production, initialized-history transitions, factory admission, normal/replay phases, and full actor physics still require implementation and verification. +This checkpoint changes no production runtime class and does not resolve either documented collider mismatch. +The combined runtime baseline passes 2,640 tests, with 163 optional skips and no failures or errors. +All 1,418 embedded core classes match across the add-on and proxy packages. +The completed incoming NBT build supplies this baseline. +Its archived reports, source trees, package hashes, and 1,139 production client classes match the combined branch. +This validation does not claim a fresh Gradle build or live run. + +## Packet history construction and vehicle intent writers, October 23, 2026 + +Fresh controls execute the original history enqueue functions instead of stopping at their entry points. +Function `142888190` constructs movement messages, and `1428883f0` and `142888640` construct motion messages through `142887580`. +Both a movement-only sequence and a mixed sequence retain forty-eight messages. +Each sequence compares all retained messages after every insertion, giving 1,176 comparisons per sequence. +Original deque growth, virtual getters, type decisions, and shared-pointer updates execute. + +Stored arrival time cannot fall below the preceding message's stored time. +The signed millisecond interval still uses the supplied arrival difference, including backward arrivals. +Equal arrivals preserve insertion order through the native sequence number. +Movement messages decode packed angle bytes as signed values multiplied by `1/256` and then `360`, with float rounding at each step. +They retain only the ground bit from the supplied movement flags. + +Motion messages copy the latest movement pose when available. +They accumulate earlier motion only when both message types exist and the latest motion follows the latest movement. +The original comparison uses stored time, then unsigned sequence number for a tie. +Motion before the first movement message does not accumulate in these controls. +Both latest-type pointers and their reference counts match after every insertion. +The second motion overload also preserves its supplied byte and distinct constructor flag. +These byte roles remain unnamed here. + +The fixture supplies allocation, CRT memory operations, process-exit destructor registration, TLS initialization, and pinned performance-clock interfaces. +It does not establish real packet receive times, complete history consumption, statistics updates, or complete trajectories. +The earlier publisher and intent fixtures still stop at their own history boundaries. + +Separate controls now execute complete history consumer `142888de0` with two original movement messages. +Seventeen cases compare sampled pose publication and deque trimming, including sub-millisecond boundaries. +The original default delay of two and time factor fifty produce a one-hundred-millisecond lower bound in this fixture. +The sampling fraction uses signed whole milliseconds and float arithmetic. +Two early-time cases precede the latest supplied arrival and serve only as internal negative controls. + +Another one hundred cases execute the consumer together with actual target publisher `141a0b170`. +Valid interpolation storage receives the sampled position and body rotation, a zero head target, and position/body counters of three. +The head counter becomes zero, and the last four component bytes remain unchanged. +The original registry hash lookup and sparse generation checks execute. +Missing, stale, missing-page, and empty-range storage instead publish a position request and update live rotation through the original fallback. +Position-request publication remains an observed boundary. +The StateVector stays unchanged in these movement-only controls. +This establishes the history-to-target handoff, rather than direct replacement of current position. + +The fixture supplies CRT remainder, allocation, TLS, clock imports, and a separate global statistics object. +It starts without prior selected samples or runtime arrival statistics. +Motion sampling, mob/head handling, forced teleport, real receive clocks, actor lifetimes, and complete tick/application trajectories remain unverified. +The helper `14288a7a0` contains diagnostic string formatting and is not the target publication routine. + +Another 1,280 controls execute original vehicle intent writer `14b182f80` against existing intent storage. +The selected controller must have matching context, passenger membership, and mob membership. +Player and simulation membership determine its controlled-by-player byte. +Actor flags forty-four and one hundred twenty-two populate the ground-control and free-camera-control bytes. +The original writer copies the selected entity/context pair and fills intent bytes fifty-six through sixty-three with ones. +Other bytes remain unchanged. +Negative and out-of-range selections, context mismatches, stale generations, missing pages, empty ranges, and missing components are included. + +Original horse prediction writer `14b17f9a0` passes another 600 controls. +It requires horse, movement-tick, actor-flags, and vehicle-intent membership. +It sets the client-predicted byte only when controlled-by-player equals one and both saddled and tamed bits are set. +Invalid membership preserves the complete intent payload. +Original view construction, sparse admission, generation checks, and field writes execute. +Typed registry discovery and startup interfaces remain supplied boundaries. +Actual storage insertion, selected-controller and flag producers, complete scheduling, component lifetimes, and Java mapping remain unfinished. + +The combined four-project build passes 2,570 tests, with 214 optional tests skipped and no failures or errors. +The full core rerun enables the new focused-recipe transfer reference, and all eighteen furnace request tests run without skips. +All 1,413 packaged core classes match the add-on and proxy copies. +Offline validation passes. +This research changes no production runtime class, and both documented collider mismatches remain unresolved. + +## Packet history routing and vehicle intent phases, October 23, 2026 + +The original packet publisher does not send every admitted spatial update directly to the movement interpolator. +A fresh fixture executes publisher `14103f580`, global guard `1410400c0`, and target producer and writer `14103fa60` and `142879300`. +All 5,760 cases retain the complete received cache. +They select 720 ignored transforms, 616 history updates, and 4,424 interpolation targets. +These counts describe the fixture inputs, not gameplay frequencies. + +With a valid prediction component, a nonzero publisher argument from actor offset `0x354` selects history. +Otherwise, a zero prediction flag byte and a permissive global guard select history. +An unavailable global service permits this route. +A valid service supplies its result through an explicit virtual boundary. +Other admitted cases select the interpolator in this fixture. +The target cases compare all forty component bytes after the original writer executes. + +The fixture omits the legacy actor reference at offset `0x1d0`. +It does not establish immediate placement, region admission, or the native distance gate. +History enqueue remains an observed boundary. +The original statistics updater executes against empty history. +Ground-store discovery, generic insertion, virtual erasure, and service lifetime scaffolding remain explicit boundaries. +Complete history contents, clock integration, actor factories, and live trajectories remain required. + +Original prediction constructor `1428888d0` also executes with its statistics, history, and reset callees. +Its first flag byte and byte `0x80` start at zero, and the qword at `0x88` starts at two. +Outer memory guards remain unchanged. +The fixture supplies allocation, CRT memory operations, TLS initialization, and two clock imports. +The PE import table identifies those imports as `_Query_perf_frequency` and `_Query_perf_counter` from `MSVCP140.dll`. +The supplied clock values are 10,000,000 and 12,345,000. +These values establish this constructor control, not real clock synchronization. + +The original intent callback `147caecc0` and prediction reset `142888c50` pass another 5,400 controls. +A valid intent with byte three equal to one sets prediction flag bit zero and resets its internal state. +It clears interpolation bytes zero through thirty-seven only when that component is valid. +It can then remove a queued target, subject to the actor byte at `0x269`. +Otherwise, it copies intent byte two into interpolation byte thirty-seven, or zero without a valid intent. +A previously set prediction bit zero then clears and seeds history from the current pose. +The callback always clears prediction bit one. +A zero remaining flag byte can request history application, again subject to the actor byte at `0x269`. + +These controls cover stale generations, missing pages, empty ranges, absent components, and non-boolean byte values. +They observe 72 interpolation resets, 2,520 history seeds, and 1,680 history application requests. +Rotation and head components are absent in this fixture. +History enqueue, history application, queued-store discovery, and queued erasure remain observed boundaries. +They do not establish complete history consumption or its final physical result. + +Pinned generated headers identify the intent fields as `mControlledByPlayer` and `mIsClientPredicted`. +They identify interpolation byte thirty-seven as `mPlayerControlServerVehicle` and the prediction flag byte as `mDisableConditions`. +The references are [VehicleInputIntentComponent](https://raw.githubusercontent.com/LiteLDev/LeviLamina/e0c75244af2f7576058976ab6d75a17e10de3f92/src/mc/deps/vanilla_components/VehicleInputIntentComponent.h), [MovementInterpolatorComponent](https://raw.githubusercontent.com/LiteLDev/LeviLamina/e0c75244af2f7576058976ab6d75a17e10de3f92/src/mc/entity/components/MovementInterpolatorComponent.h), and [PredictedMovementComponent](https://raw.githubusercontent.com/LiteLDev/LeviLamina/e0c75244af2f7576058976ab6d75a17e10de3f92/src/mc/entity/components/PredictedMovementComponent.h). +Those names guide the target layout comparison. +The target instruction controls establish storage and consumer behavior. +Producer lifetimes, the two actor-byte roles, and Java field mapping remain unverified. + +Fresh manager controls execute the original normal entries and replay entry without changes to native category globals. +They dispatch 361, 358, and 215 systems for categories zero, one, and three. +Normal interpolation capture and tick precede queued targets, position application, and grounded adjustment. +The prediction system follows these interpolation stages. +Replay omits the prediction system, input capture, queued-target handling, and the vehicle input switch request system. +All ECS methods return at explicit boundaries in this dispatch fixture. +This proves manager selection and ordering, not combined physical execution. + +The production port still needs separate packet history, interpolation state, input history, and their actual normal and replay phases. +A Java smoothing flag cannot replace these inputs. +This research changes no runtime class, and both documented collider mismatches remain unresolved. + +## Actor collision refresh inputs, October 23, 2026 + +Fresh native controls now distinguish the refresh sources from the current adapter approximation. +The production adapter still uses a changed actor position or a predicted-actor flag to select the current Java box. +Two private probes against that production class contradict the original native refresh results. +A moved actor without an eligible source incorrectly replaces the retained box. +An active interpolation target with an unchanged current position incorrectly retains the old box. +These probes establish component-boundary mismatches, without claiming a live collision trajectory. + +The original refresh kernel `1482d8e10` passes 256 fresh instruction controls from the pinned Bedrock 1.26.51.1 executable. +Eligible prediction history takes priority and selects its latest ring entry. +An active movement interpolator supplies the fallback target. +All three interpolation counters use integer-bit zero checks, including the third counter. +A missing source retains the existing shape. +Available shape and origin-offset components rebuild bounds with native float arithmetic. +Missing shape or origin-offset components preserve the retained bounds. + +The fixture supplies valid cached ECS stores, history positions through a virtual getter, and the global history guard result. +The original history ring selection, source priority, activation checks, and box reconstruction execute without substitution. +Global guard semantics, component creation, and complete source lifetimes remain outside these controls. + +The original input callback `1431f0530` also passes 140 controls. +It captures the current movement interpolator only with a position counter of at least three and a matching available input frame. +Its original virtual method `14328cfa0` copies all thirty-eight component bytes into the input snapshot and sets the presence flag. +Other snapshot bytes remain unchanged. +This callback captures current interpolation state into history; it does not restore interpolation from history. + +The original queued-target callback `1431ee220` passes 108 controls. +It either applies target coordinates and rotation with new counters, or defers the target under its position-active flag. +The fixture excludes optional marker removal through an explicitly absent marker store. +Original interpolation callback `1431e9ea0` and rotation helper `1431ea5b0` pass another 512 controls. +Positive position counters clear velocity and emit a position request, then decrement. +They do not directly change the StateVector position. +Negative counters keep interpolation active for source selection but do not advance the corresponding axis. +Body rotation also updates the previous rotation across wrap boundaries. +An absent head component leaves the head counter unchanged. +The fixture supplies cached view admission, registry discovery, existing position-request storage, and the CRT remainder operation. +Native component checks, float arithmetic, counter updates, and request publication execute without substitution. + +Another 432 controls execute packet target producer `14103fa60` and target writer `142879300`. +The duration uses an unsigned 64-bit maximum with three, then stores the low signed 32-bit counter. +A current force byte of exactly one and a nonzero position counter defer an incoming unforced target. +Native category bit two updates body and head rotation even while the positional target remains queued. +These controls supply the category bit and existing component stores. +The queue erasure virtual method is an explicit fixture boundary. +Native category producers and complete component lifetimes remain unverified. +Separate consumer and dispatch controls below establish position application and the interpolation sequence. + +The complete native tick system body `1431ea880` passes another 1,152 controls. +It requires `MovementInterpolatorComponent`, `StateVectorComponent`, `ActorRotationComponent`, and `InterpolateMovementNeededComponent`. +It excludes `ProjectileComponent`. +Original iteration and version checks execute across all four supplied dense drivers. +The fixture supplies cached view selection, head admission, registry discovery, CRT remainder, and view cleanup. +Separate marker controls below establish two native admission routines. +Their installation and complete actor lifetimes remain unverified. + +Original position consumer `14901d080` passes 270 controls without arithmetic or membership substitutions. +It copies the requested position into StateVector and preserves the remaining StateVector bytes. +`PaintingFlagComponent` selects translation of the existing bounds. +Other actors rebuild bounds from width, height, origin offset, and the requested position. +The fixture supplies existing components and optional painting membership, including stale generations and missing pages. + +Grounded callback `1431ef070` passes another 132 controls. +An active interpolation counter can snap Y across the floor boundary after addition of the native float `0.01`. +This callback changes only StateVector Y. +It does not receive or update shape data. +Its system additionally requires ground and interpolation markers and excludes minecarts. +These controls do not establish marker lifetimes or later shape scheduling. + +The fresh replay-manager control dispatches all 215 registered replay systems through the original manager and replay entry. +Within interpolation, that sequence runs the tick, position application, and grounded post-tick systems. +It omits input capture and queued-target handling. +All system ECS bodies return at explicit boundaries in this dispatch control. +Original normal entry points `1463b0b00` and `1463b1070` now execute their category selection and native manager traversal. +They select categories zero and one without changes to the native category globals. +They dispatch 361 and 358 systems through original per-system entry points `1463cc280` and `1463cc580`. +Both sequences include capture, tick, queued targets, position application, and grounded post-tick, in that order. +All normal ECS methods return at explicit boundaries, and the fixture supplies an empty post-system callback view. +These dispatch controls establish the phase distinction from replay. +They do not establish complete live scheduling or combined physical trajectories. + +Original admission routines `14902bca0` and `14902b360` pass 1,020 controls over 2,040 actor visits. +Both routines enumerate a supplied source pool of `ActorMovementTickNeededComponent` and add missing `InterpolateMovementNeededComponent` markers. +The second routine can also remove a destination movement-tick marker while retaining interpolation admission. +Matching context values at offset `0x160` and a valid source `ShouldBeSimulatedComponent` preserve that destination movement-tick marker. +Different context values or missing simulation membership permit removal. +The controls cover existing markers, missing markers, stale generations, missing pages, and empty sparse ranges. +They observe 1,632 interpolation marker insertions and 360 movement-tick marker removals. +Registry discovery, generic insertion, and virtual erasure remain explicit fixture boundaries. +Original iteration, membership, context-value comparison, and admission decisions execute without substitution. +The semantic type and producers of the context values remain unverified. +These results require separate production state for movement simulation and interpolation admission. +Native installation, context lifetimes, mount transitions, and their Java component mapping remain unfinished. + +The packet-source prerequisite now lives in the existing core sparse-update owner. +It retains received coordinates and rotations before local admission, independently of the mutable actor pose. +The [spatial update notes](../../viabedrock/upstreamable/0018-preserve-sparse-actor-spatial-updates.pr.md) record 512 original-handler controls and registered packet comparisons. +The actual interpolation component and both refresh sources still require production wiring. +The current Java position and bounding box cannot substitute for those inputs. +Native target producers, frame capture, replay ordering, and actor lifetime checks must agree before replacing the approximation. +No production refresh fix or new whole-package runtime result is claimed by this research section. + +## Food use during historical replay, October 23, 2026 + +A controlled BDS fixture now starts with hunger four and sixteen apples. +The direct route executes five historical replays with fifty-four physical steps. +Three active-use replays preserve use timers of twelve, twenty-eight, and eighteen ticks. +The proxy route executes six replays with fifty-five physical steps. +Three active-use replays preserve timers of twenty-three, seventeen, and ten ticks. +All replay boundaries preserve tick count, hunger, held count, and active-use state. + +Both routes finish each ordinary-food phase at hunger twenty with twelve apples. +Each records four server starts, four completions, and no start at full hunger. +A separate full-hunger golden-apple control completes four uses on each route. +The core item-use owner now rejects ordinary-food requests after hunger reaches its native maximum. +The [item-use notes](../../viabedrock/deferred/0008-signal-sustained-bedrock-item-use.pr.md) record the regression and admission evidence. + +These runs close the active-food replay observation gap for the stated cases on both routes. +They do not establish all inventory authority modes, native use-cycle semantics, mount trajectories, or the complete reported rollback case. +Earlier sections retain their historical test counts, artifacts, and limits. + +## Native world collision traversal, October 23, 2026 + +The physical adapter now visits loaded blocks in native X/Z/Y order. +The visitor uses float-rounded padding and clamps the vertical interval to the world height. +It scans unavailable chunks in X-first order with eight blocks of horizontal padding. +Those chunks supply solid barriers from Y -100000 to Y 100000. +A separate native floor sits forty blocks below the minimum world height. + +Each frame retains this geometry with its existing collision inputs. +A chunk arrival does not replace a retained barrier during a contained replay query. +At the strict reuse boundary, replay queries current chunk availability. +The adapter never creates chunks during collision queries. + +The shared production code matches 64 fresh executions of native visitor `142d883c0` from Bedrock 1.26.51.1, protocol 2193. +The fixture supplies chunk states, loaded columns, and block shapes. +The comparison covers traversal, chunk scans, and null-source barrier geometry. +Native border callbacks execute in the fixture but remain outside the Java comparison. + +Client tests use voxel collision clipping to verify unavailable boundaries, later chunk arrival, fresh queries, and the below-world floor. +Removing only unavailable-chunk barrier emission changes the expected clipped displacement from 0.4 to 1.0 and fails the regression. +The restored implementation passes the focused suite. + +The combined build passes 2,511 tests, with 215 optional skips and no failures or errors. +All 1,410 core classes match the add-on and proxy distributions byte for byte. +Offline tooling, static, and patch metadata checks pass. + +The installed world-traversal candidate completes a direct native BDS run with a private 300 ms inbound delay. +Sixteen correction replays execute 171 historical physical steps. +Observers record 87 historical collision caches, including 34 with retained nonempty geometry. +Eighteen ordinary collision gathers contain the native unavailable-chunk barrier geometry during loading. +Every replay boundary preserves tick count, food level, and held-item count. +The live chunk-arrival clipping case remains unverified; its targeted unit control passes. +Eight inbound events remain delayed at normal shutdown, and the final command awaits the next capture. + +The input hold finishes, but these observers record no active item use at replay boundaries. +This run does not establish correct feeding or delayed replay during active food use. +The client and server exit normally, and the original private fixtures return to their prior state. + +The live run precedes the preserved attachable-slot update. +All 126 movement/input classes and the complete mixin configuration match the final combined build byte for byte. +The core source tree also remains unchanged. +This establishes the tested movement code in the combined package, not a fresh runtime check of the whole package. + +Native block-shape generation and source references, border-block permissions, all chunk readiness stages, and near-unloaded flag consumers remain unfinished. +Exact actor refresh, native input phases, both connection routes, and complete trajectory parity also remain open. + +## Retained collision inputs, October 23, 2026 + +Each physical frame now retains its collision geometry and query bounds. +Replay receives a separate mutable cache, so new queries cannot overwrite the original frame input. +This preserves captured geometry after the live world changes. + +Native Bedrock 1.26.51.1 reuses captured geometry only below squared displacement four. +Its gather kernel caps motion at sixteen blocks and appends geometry from uncovered regions. +The adapter uses the shared native query arithmetic before the ordinary Java collision resolver. +Zero-displacement requests also capture geometry. + +Cached actor shapes retain their original world, instance, UUID, and native lifetime. +A stale reference preserves its captured shape instead of following a replacement actor. +Current actor geometry supplies eligible refreshes; exact native interpolation sampling still needs verification. + +Targeted tests cover changed geometry, the strict reuse boundary, query extension, repeated replay isolation, stale actors, and stationary capture. +The shared production code matches 192 fresh original native instruction controls. +Those controls supply world queries and typed stores, so they do not establish full native trajectories. +Unloaded-region state, native block traversal, managed interpolation, and complete native input phases remain unfinished. +The combined four-project build passes 2,505 tests, with 214 optional skips and no failures or errors. +All 1,407 core classes match the add-on and proxy artifacts byte for byte. +Offline validation passes the tooling, static, and patch metadata checks. + +The exact installed artifact also passes a direct BDS 1.26.51.1 run with a private 300 ms inbound delay. +The delay applies only to the owned client channel and preserves packet contents and event order. +Fifteen correction replays execute 180 retained physical steps. +Read-only observers record 90 historical collision caches, including 12 with retained nonempty geometry. +They record 180 historical collision gathers and 95 additional region fetches. +Every replay boundary preserves the live tick count, food level, and held-item count. +Both clients and servers exit normally, and the private fixtures return to their original state. + +The input driver completes both requested holds, but these runs record no active item use at replay boundaries. +Delayed replay during active food use remains unverified. +The changed-world geometry case remains a unit control; no live wall-removal result is claimed. + +The earlier sections describe their respective artifacts and verification limits. + +## Queued player and mount corrections, October 23, 2026 + +The adapter now schedules negotiated positional corrections against retained physical frames. +It compares the retained state with exact native kinematics before it starts historical execution. +Current-clock commands wait for the upcoming capture. Future commands remain queued without an immediate replay. +Standard Java handlers retain their acknowledgments, block-prediction bookkeeping, and mining stop. +Only paired transform and interpolation writes are suppressed. +Final coordinates precede the completed prediction payload. + +Commands require the recorded actor instance, world, UUID, native lifetime, and historical primary control. +Accepted mount identity transfers retire the continuous control lease, including transfers between two physics frames. +Same-identity authority revisions preserve that lease. +Each later replay checks command applicability again. +Identity regressions cover reused Java IDs, replaced actors, replaced worlds, new lifetimes, removed actors, and lost historical control. + +A direct native BDS 1.26.51.1 run reaches 1,233 completed physics frames. +Read-only observers record 23 real server corrections and 22 command applications. +The final current-clock command remains pending when recording stops. +Three rewinds execute four historical physical steps without changing tick count, food level, or held-item count. +Four command applications preserve active item use and its remaining duration. +Eleven apples finish during live input. The client, server, and private display stop normally. + +The 128 original native clock controls match the used timing code. +The command tests retain previous transforms, use independently rounded float bounds, and replace only present boat angular state. +Full builds pass after all 104 core patches and 38 add-on patches replay. +The combined four-project build passes 2,497 tests, with 213 optional skips and no failures or errors. +All 1,406 core classes match both the add-on and proxy artifacts byte for byte. +Offline validation passes 271 tooling tests, static checks, and patch metadata checks. + +Historical world and collision inputs, managed interpolation, and complete native input phases remain unfinished. +Delayed historical replay during active food use, live vehicle corrections, and complete native trajectory parity remain unverified. + +## Owned historical physics + +Movement-effect corrections now restore typed actor snapshots and execute recorded client physics. +Replay runs root and passenger ticks with their raw input, equipment observations, item-use observations, and historical time. +The ordinary current frame executes once. + +Snapshots own attributes, effect vectors, fluid trackers, posture, collision state, motion, boat state, horse state, and seat relationships. +Actor identity includes the actual entity instance, world, UUID, and native lifetime. +Each frame retains its own participant layout during later snapshot refreshes. +The participant index expires entries with history eviction. +Validation checks both captures of every affected frame before changing any actor or seat. +Unowned actors recover their current state after replay, while the shared seat graph retains the replayed relationships. + +Historical execution skips inventory ticking, item consumption, equipment ticking, pickups, and portal presentation. +Item-use observations temporarily supply movement state without advancing the live consumption cycle. +The packet guard applies only to the owned connection on the replay thread. +Replay also suppresses duplicate sounds and particles. + +The pinned Java 26.3 runtime loads the clock hook on `LevelAccessor`. +The equipment hook uses `EntityEquipment.tick(Entity)`, and the super-AI hook uses the actual local-player receiver. +All 31 movement invocation selectors match the pinned client bytecode. + +Two graph regressions fail with the previous refresh and validation behavior and pass with the revised implementation. +They cover changing mount layouts and actors introduced between a frame's captures. +Current project builds pass 2,482 tests, with 212 optional skips and no errors. +All 104 core patches and 38 add-on patches replay, and all 1,404 embedded core classes match. + +The live-tested artifact completes five native BDS mounts and five dismounts without disconnecting. +Observed snapshots contain at most two participants in that fixture. +One historical replay executes two recorded physical steps and preserves the live tick, food, and held-item count. +A six-second food-use control consumes three apples and ends before client shutdown. +These controls use BDS 1.26.51.1, protocol 2193, and Java 26.3 on the private display. +The live-tested artifact's food-use controls do not overlap a historical rewind. +All 108 movement classes and the mixin resources remain unchanged after the later cooking layout update. +An earlier adapter artifact preserves an active use counter at 31 during a rewind, before the later graph and spawn changes. + +Player and vehicle position corrections still snap and invalidate this physical history. +Their native command transport and input-phase alignment remain required. +Historical world queries, managed interpolation, complete mount ownership, and native trajectories also need further verification. +The affected-server rollback and endless-eating reports remain unresolved by these bounded controls. +Earlier sections retain the evidence and limits of their respective artifacts. + ## Purpose Send completed local physics through the negotiated ViaBedrock prediction channel. @@ -1393,3 +1940,236 @@ Native overlap recovery, stepping, and correction replay remain separate gaps. T The updated Java client starts with the new movement hooks on ViaProxy. A live BDS 1.26.51.1 control jumps on an elevated bedrock platform, then holds sneak and forward at the edge. Java reports exact Y 250 and holds Z 2.738. BDS confirms the player remains on the platform. A read-only runtime sampler captures 750 client frames around the elevated jump, edge, and fall controls. Both actual landings reach exactly `Y = AABB.minY = 250.0`. All 31 shift frames stay at that height. No sampled player box penetrates below the platform. + +## Retained effect inputs and component replay + +The client opens the shared effect input record before local simulation. +It retains local admissions, reconciles queued commands before player physics, and completes the record with the existing prediction frame clock. +Captured inputs therefore remain available after a later server removal or activation. +The shared core journal preserves command/input/countdown order and zero-duration marker presence for command masks. +Movement-effect revision 3 supplies the exact oldest retained wire clock. +The series records the dependency on the owning core movement patch. + +The generated-input controls match 336 immediate effect-activity cases and 84 first replay cases under a supplied snapshot boundary. +They establish input and queue order, with explicit snapshot-store, input-method, and arithmetic-physics boundaries. +The earlier presence-only snapshot oracle populated the wrong component slot. +Fresh packed-store controls establish that movement effects retain complete timestamp, duration, and kind vectors. +The shared journal now restores the first dirty snapshot and refreshes later snapshots before commands and inputs. +This prevents unrelated effects from consuming past countdowns twice and preserves corrected history for later rewinds. +Both regressions fail with the earlier journal and pass with the snapshot fix. +They do not establish historical position restoration, full player or vehicle physics, zero-duration impulses, or native trajectories. + +The physical frame clock advances even when the prediction channel is unavailable. +Channel advertisement controls payload publication. It does not freeze effect durations or leave an input record open. + +A private BDS 1.26.51.1 probe observes actual admission and completion phases with read-only JVM hooks. +All 16 local geyser admissions enter open input records and remain present at completion. +The observer records 18 effect confirmations and one replay entry with a pending movement mask. +Java runs the sulfur block ticker after player physics and before the completed-frame send. +The earlier observer therefore sampled each new input record before the block ticker populated it. + +The 30-second recording reaches gameplay and completes normal client, server, and display cleanup. +This establishes local input capture and retention through completion. +It does not establish full historical physics, native trajectories, or the cause of the earlier shutdown hang. + +The integrated four-project build preserves the incoming cooking fixes and passes 2,402 tests, with 212 optional skips and no failures. +All 104 core patches and 38 add-on patches replay without conflicts. +All 1,393 embedded core classes match the rebuilt core. +Checkstyle and offline patch validation pass. +The separate cooking regression suite also passes all 13 cases with the private native wire reference enabled. + +The snapshot build records 602 live physics frames with populated historical effect values and no observer errors. +All 17 observed admissions remain present at completion, alongside 20 effect confirmations and one masked replay entry. +Normal JVM shutdown hangs again. The isolated run requires manual recovery after private native diagnostics. +The run establishes snapshot wiring, but successful lifecycle and native trajectory parity remain unproven. + +## Live mounted frame pairing + +The existing mounted capture already sends paired player and vehicle samples from the same completed frame. +A delayed BDS trace exposes a core admission error: it rejects the stale tracked rider position before pairing a valid vehicle sample. +The core prediction owner now validates the vehicle independently and retains the shared input clock. +The client capture hook stays unchanged. + +With the combined core fix, direct NetherNet binds all 158 vehicle samples and applies ten historical vehicle corrections. +ViaProxy binds all 159 samples and applies twelve historical vehicle corrections. +The routes replay 103 and 116 physical steps across 17 and 19 replay boundaries. +Every boundary preserves the live player tick, food level, and held-item count. +Each route also applies four ticked player impulses live and in their following historical input frames, including a zero reset. +Both runs complete mounting, release, reacquisition, and dismount, then stop normally. +Runtime checksums match the combined artifacts, and both read-only observers report no errors. + +The complete combined suites pass 2,675 cases, with 163 optional skips. +All 1,143 client classes match the packaged add-on; all 1,428 core classes match both embedded copies. +The fixture emits no nonzero-tick vehicle velocity command. +Native trajectory equality, exact collision/interpolation phases, complete seat ownership, and broader active-use overlap remain unfinished. + +## Live lifetime validation during replay + +Restored metadata retains the actor lifetime from its historical frame. +The previous actor, collider, and command checks read that restored lifetime instead of the current registry lifetime. +A replaced, removed, or cleared actor could therefore pass ownership validation after replay restored its old metadata. + +Ownership checks now read the live registry through a separate lifetime accessor. +Snapshot capture and historical recapture retain their original lifetime and metadata. +The change neither relabels old physics with a replacement lifetime nor replaces historical gameplay properties with current values. + +Five regressions use the actual actor registry with supplied Java entities. +They cover replacement, removal, clear, same-lifetime updates, and historical relationship recapture. +Three retirement cases fail before the fix. +Independent variants restore historical actor validation, restore historical collider validation, or capture live identity for historical physics. +They fail four, three, and one assertions respectively; restored production passes all five cases. +The complete four-project build passes 2,679 cases, with 163 optional skips and no failures or errors. + +Artifact comparisons identify exactly four changed client classes: actor state, collision ownership, replay session, and the registry accessor. +The other 1,139 client classes retain their previous bytes. +All 1,428 core classes match both embedded copies, and all 1,143 client classes match the add-on. + +At the lifetime-validation milestone, both collider source-selection counterexamples still reproduced. +The source integration described below now covers those selector inputs. +Collider verification now records the entire nested class family; the unchanged outer class alone cannot prove unchanged ownership behavior. + +Fresh direct NetherNet and ViaProxy sessions repeat the delayed impulse, mounting, release, reacquisition, dismount, and zero-reset sequence. +Both retain valid frame bindings, historical correction execution, exact float velocity delivery, and unchanged live tick, food, and held-item counts across replay. +Runtime artifacts match the tested build, and all owned processes stop normally without observer errors. +These sessions cover ordinary mount lifecycle and actor removal. +Same-Java-object native lifetime replacement remains real-registry regression coverage, rather than a claim about live server ID reuse. + +## Collider source integration contract + +The [core movement notes](../../viabedrock/upstreamable/0092-retain-completed-client-prediction-frames.pr.md#native-packet-source-for-collider-refresh) record the combined native publisher and collider checks. +The checks distinguish the receive cache, admitted network history, retained interpolation target, and current actor transform. +In particular, an ignored local-control update changes only the receive cache. +A motion message retains its last movement position independently of its velocity. + +The native source lookup itself requires no comparison between proxy and client receive clocks. +Production still needs retained component state, correct admission, actor lifetimes, shape inputs, and native simulation phases. +The interpolation sampler separately requires receive timing. +The implementation below replaces the Java transform heuristic with the retained native source inputs. + +## Native sources for retained collider geometry + +Replay collider refresh now selects an eligible admitted network position before a retained interpolation target. +An actor's current Java position, bounding box, or Java interpolation handler cannot substitute for either source. +Without an eligible source or usable dimensions, refresh preserves the captured geometry. +The existing live lifetime check still protects ownership. + +The registry receives source commands on the current connection and resolves local Java identity through the actor registry. +The normal client phase advances source counters before physical capture. +Each cached shape retains immutable interpolation inputs for its historical frame. +Later packets and live counter completion cannot replace that target during replay. +The refresh reconstructs dimensions and origin offsets with native float operations before it creates a Java box. + +Six new regressions use the actual actor registry with supplied Java entities. +Four fail against the previous production collider code, including both recorded native source-selection counterexamples. +All six pass with the new implementation. +The existing five cache tests and five lifetime tests also pass. +A source-priority variant and a live-target variant each fail their comparison. +These checks establish the recorded selector inputs, historical target retention, and lifecycle behavior under their supplied component profiles. + +The adapter does not replace the complete remote interpolation sampler or native actor pose simulation. +Configured history-provider behavior, custom actor factories, optional head components, fresh collider membership, and exact native phases remain unfinished. +The unchanged five-area goal also retains inventory authority, native screens and controls, camera execution, and rendering integration. + +## Combined source validation + +All four project builds pass with 49 environment settings, including collider and incoming item-name references. +The full suites pass 2,702 tests, with 163 optional skips and no failures or errors. +All 1,437 core classes match the standalone, add-on, and proxy packages. +All 1,144 client production classes match the packaged add-on. +The build verifies the four expected core replacements for build constants. +The native item-name composition comparison runs within these totals. + +Fresh delayed direct and proxy sessions add a moving unoccupied boat to the existing impulse and mount fixture. +The direct route exercises six owned collider refreshes; the proxy route exercises nine. +Every result matches an independent calculation of source priority and native float bounds. +All fifteen boxes differ from both the captured geometry and the current Java box. +Both observers report no errors, runtime hashes match the combined artifacts, and all owned test processes stop normally. + +| Route | Vehicle frames paired and bound | Vehicle corrections | Replay boundaries | Replayed steps | Owned collider refreshes | +| --- | ---: | ---: | ---: | ---: | ---: | +| Direct NetherNet | 158 | 11 | 20 | 124 | 6 | +| ViaProxy | 158 | 12 | 22 | 139 | 9 | + +Each route preserves live tick, food level, and held-item count across every replay. +Each route also applies four ticked player impulses live and in their exact following historical frames. +The fixture emits no nonzero-tick vehicle velocity packet. +The moving boat uses the base prediction profile, so these runs do not exercise the interpolation fallback or local horse reset live. +Those paths retain their registered and bounded controls. +The original server script is restored after each run. + +An earlier direct attempt fails before the fixture starts because the new observer generates an invalid stack-map entry. +The corrected observer reads the completed cache constructor and does not rewrite shape return frames. +The failed attempt is excluded from all live behavior claims. +The production artifacts remain unchanged across that observer correction. + +Configured history-provider behavior, ordinary `MOVE_PLAYER` source production, full component admission, and native interpolation and pose phases remain unfinished. +The live checks do not establish complete native trajectories, fresh collider membership, active-use overlap, or full mount and seat parity. + +## Remote player interpolation retirement + +The shared source channel now carries explicit clears for verified remote-player respawn and teleport branches. +The existing connection, world, actor identity, and lifetime gates still constrain delivery. +A clear retires the live target while historical collider captures retain their original interpolation inputs. +The source, cache, and lifetime regressions use explicit movement actions with the revised codec. +Normal remote-player admission, local-player virtuals, riding pose effects, and complete native phases remain unfinished. +The [core reset notes](../../viabedrock/upstreamable/0092-retain-completed-client-prediction-frames.pr.md#remote-player-reset-sources) retain the original instruction controls and packet regression. + +## Remote player reset validation + +The full four-project build passes 2,703 tests with 49 environment settings, 163 optional skips, and no failures or errors. +All 1,438 core classes match the standalone, add-on, and proxy packages after the four expected replacements for build constants. +All 1,144 client production classes match the add-on. +Both affected patch stacks replay completely. + +Fresh delayed sessions use the final packages and a target BDS simulated player. +Each route captures two native remote-player teleport packets and receives two matching interpolation clears. +Live targets before these clears are inactive. +The fixture's successful simulated-player respawn produces no recorded respawn-mode packet. +Active-target retirement and respawn clearing retain native and serialized regression coverage. +These checks do not establish complete human-player behavior. + +| Route | Vehicle frames paired and bound | Vehicle corrections | Replay boundaries | Replayed steps | Collider refreshes | +| --- | ---: | ---: | ---: | ---: | ---: | +| Direct NetherNet | 160 | 12 | 22 | 142 | 8 | +| ViaProxy | 159 | 9 | 20 | 125 | 9 | + +All seventeen refreshed boxes match independent source selection and float calculations and differ from captured and current Java geometry. +Every replay boundary preserves live tick, food, and held-item count. +Each route retains four ticked player impulses and their exact following historical frames. +Observers report no errors, and all recorded run-owned identities exit normally. +The original server script, manifest, and configuration are restored. +Normal-player source admission, configured providers, local-player virtuals, riding pose, complete native use cycles, complete seat ownership, and native trajectories remain unfinished. + +## Active use during controlled-boat replay + +Fresh direct and ViaProxy runs verify the current full artifacts with a 300 ms inbound delay. +Real mouse holds target the owned game's window on the private display, with the usual zero-volume defaults. +The fixture provides standing and mounted food windows, each starting with hunger four and sixteen apples. +Scripted hunger resets keep food usable during the longer mounted window. +The observer adds read-only use-counter fields; it changes no movement, command, inventory, or use state. + +| Route | Real rewinds | Physical steps | Active-use rewinds | Controlled-boat active use | Boat rewinds with positive counter | +| --- | ---: | ---: | ---: | ---: | ---: | +| Direct NetherNet | 20 | 129 | 17 | 10 | 9 | +| ViaProxy | 20 | 126 | 17 | 10 | 9 | + +All forty replay boundaries preserve live tick, food, held count, active use, remaining use ticks, start state, and use-stack count. +Both routes include positive counters during standing and controlled-boat rewinds. +Each retains four native ticked player impulses in the correct historical frames and applies ten historical vehicle corrections. +Vehicle epoch and controller checks pass. +Neither route receives nonzero-tick vehicle velocity, so that requirement remains open. + +The server confirms six completed apple uses per route: two standing and four during the mounted window. +Client and server settle with twelve apples and the same food value after the final scripted hunger reset. +The client stops using the item after input release. +Observers report no errors, package hashes match the validated build, and all recorded owned identities exit normally. +The original server fixture returns byte-for-byte. + +The first direct attempt failed its private-display identity check before food input; it is excluded. +An earlier proxy attempt failed signaling before receiving any Bedrock packets or starting the fixture; it is also excluded. +The successful proxy attempt adds a read-only local HTTP capability preflight. +These results do not establish a fix for that signaling failure. + +This closes the final-artifact active-use overlap gap for the tested standing and controlled-boat cases. +The affected public-server rollback and eating reports, other authority modes, remaining mounts, complete native use cycles, seat/lifetime parity, and native trajectories remain unverified. +The full five-area implementation goal remains active. 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 426abd03..33312cea 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 88ae3c36897298bb5137ae3ed1169a0b1a3f8f51 Mon Sep 17 00:00:00 2001 +From 41e3be9516a441a52edf107219ca36b2bd3e25b3 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,10 +151,10 @@ index 0000000..4e14218 + } +} diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json -index dd87258..93c52e6 100644 +index 1dd5634..33b88ac 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -53,6 +53,7 @@ +@@ -70,6 +70,7 @@ "features.block.MixinDragonEggBlock", "features.block.MixinEndPortalFrameBlock", "features.block.MixinEnderChestBlock", 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 76831bc8..95c1fc5a 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 15ee4e86ba4bf379d961432d1c485ec384137a11 Mon Sep 17 00:00:00 2001 +From 18121fee01a7659ce528e15f03843e7ce116b57c 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,10 +37,10 @@ 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 d731fe6..9b239aa 100644 +index 214970c..cf1b65b 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java -@@ -29,6 +29,8 @@ import com.viaversion.viafabricplus.bedrock.render.NativeActorBoundsPayload; +@@ -30,6 +30,8 @@ import com.viaversion.viafabricplus.bedrock.render.NativeActorBoundsPayload; import com.viaversion.viafabricplus.bedrock.render.NativeActorIdentityPayload; import com.viaversion.viafabricplus.bedrock.render.NativeActorPickingPayload; import com.viaversion.viafabricplus.bedrock.render.BedrockActorPicking; @@ -49,7 +49,7 @@ index d731fe6..9b239aa 100644 import com.viaversion.viafabricplus.bedrock.render.NativeAnimationPayload; import com.viaversion.viafabricplus.bedrock.render.BedrockActorState; import com.viaversion.viafabricplus.bedrock.render.CustomEntityRenderStore; -@@ -111,6 +113,11 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { +@@ -112,6 +114,11 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { ClientPlayNetworking.registerReceiver(NativeCustomEntityPayload.TYPE, (payload, context) -> CustomEntityRenderStore.accept(listener.getConnection(), payload.message()))); ClientPlayConnectionEvents.DISCONNECT.register((listener, client) -> CustomEntityRenderStore.clear()); @@ -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 93c52e6..4c6e15b 100644 +index 33b88ac..86b160d 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -150,7 +150,10 @@ +@@ -187,7 +187,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 c753ba9a..4ad63320 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 1a804529538fdc390e3b3909b9c9c0a4a6a25db2 Mon Sep 17 00:00:00 2001 +From eb65c5ddf1deb21f96687730cb77d01fc67201db 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,10 +71,10 @@ simulation in the parity ledger until separately verified. create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/settings/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 9b239aa..fa67a11 100644 +index cf1b65b..c9baec1 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java -@@ -30,6 +30,8 @@ import com.viaversion.viafabricplus.bedrock.render.NativeActorIdentityPayload; +@@ -31,6 +31,8 @@ import com.viaversion.viafabricplus.bedrock.render.NativeActorIdentityPayload; import com.viaversion.viafabricplus.bedrock.render.NativeActorPickingPayload; import com.viaversion.viafabricplus.bedrock.render.BedrockActorPicking; import com.viaversion.viafabricplus.bedrock.render.NativeProjectileOwnerPayload; @@ -83,7 +83,7 @@ index 9b239aa..fa67a11 100644 import com.viaversion.viafabricplus.bedrock.render.BedrockProjectileOwners; import com.viaversion.viafabricplus.bedrock.render.NativeAnimationPayload; import com.viaversion.viafabricplus.bedrock.render.BedrockActorState; -@@ -114,6 +116,10 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { +@@ -115,6 +117,10 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { (payload, context) -> CustomEntityRenderStore.accept(listener.getConnection(), payload.message()))); ClientPlayConnectionEvents.DISCONNECT.register((listener, client) -> CustomEntityRenderStore.clear()); PayloadTypeRegistry.clientboundPlay().register(NativeProjectileOwnerPayload.TYPE, NativeProjectileOwnerPayload.CODEC); @@ -95,10 +95,10 @@ index 9b239aa..fa67a11 100644 ClientPlayNetworking.registerReceiver(NativeProjectileOwnerPayload.TYPE, (payload, context) -> BedrockProjectileOwners.accept(listener.getConnection(), payload.message()))); diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockServerParticles.java b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockServerParticles.java -index ceb82a0..409dae9 100644 +index 8b9d1f6..ac74004 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockServerParticles.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockServerParticles.java -@@ -10,22 +10,122 @@ import io.netty.buffer.Unpooled; +@@ -11,23 +11,123 @@ import io.netty.buffer.Unpooled; import net.minecraft.client.Minecraft; import net.minecraft.client.multiplayer.ClientLevel; import net.minecraft.core.RegistryAccess; @@ -113,6 +113,7 @@ index ceb82a0..409dae9 100644 import net.minecraft.world.phys.Vec3; +import net.minecraft.server.packs.resources.ResourceManager; import net.raphimc.viabedrock.api.model.NativeParticlePlaybackMessage; + import net.raphimc.viabedrock.api.model.NativeActorMessage; +import net.raphimc.viabedrock.api.model.NativeFishingEvent; +import net.raphimc.viabedrock.api.model.NativeParticleRange; +import net.raphimc.viabedrock.api.model.MolangVariables; 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 33acf708..232f2a9d 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 235b04492535eb374bedcc84336b6bf8b26cfd7d Mon Sep 17 00:00:00 2001 +From e83b95b75f463eb04ae3968acc5e708452ec4b25 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,63 +112,413 @@ 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. -Ordinary cooking controls remain installed: native activation, quantity -and cursor semantics, search/navigation and the remaining authored input -handlers must be implemented before replacing the actual screen. +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. + +Match authored close commands to their source input and pressed mapping. +Cooking slot buttons also map menu_cancel to try_menu_exit; that mapping +must not close a menu on an ordinary primary or secondary pointer click. +Use one clipped, front-to-back control order for close buttons and slots, +including equal-layer overlays, disabled controls and transparent frames. +A headless screen regression covers both pointer buttons on 63 real menu +slots with the cooking cancel mapping and fails with the original handler. +Native cooking activation and remaining controls are still incomplete. + +Negotiate typed cooking action and result payloads in the Fabric play +session. Retain the original cell and snapshot through follow-up phases, +including synchronous bridge replies and newer predicted frames. Admit +initial input only after resolving its authored scope. Match replies to +the complete action, serialize independent gestures, and require a newer +frame after rejected or consumed authority. Retire pending input after +capability withdrawal, menu replacement, transport ambiguity or timeout. +Keep request floors across same-listener menu reuse. + +Default cooking programs still use ordinary controls. This client transport +does not establish native device equivalence, cross-input prediction, +completed default controls or native visual parity. Regression tests exercise the +state transitions without a GPU or a live server. + +Route admitted authored recipe cells through the production sender and +bind action authority to the installed native screen. Carry the resolved +recipe scope into clipped front-to-back geometry. Consume captured button +presses, drags, releases and duplicate releases independently of vanilla +inventory dispatch; retain the original cell when newer frames replace it. +Declare Java primary/secondary pointer presses as phase zero and releases +as phase two. Native physical device equivalence remains unverified. + +Retain a one-shot terminal release after retirement or ambiguous transport +so a failed presentation cannot strand a primary gesture. Release only on +the same connected listener and actual menu, preserve increasing request +identity, and never retry an ambiguous terminal send. Restore the exact +original screen even after controller ownership retires, without replacing +another screen or a reused menu. Keep unsupported required controls on the ordinary menu rather than +installing an inert authored view. The default search/navigation/help controls, focus, +tertiary/controller bindings and complete native presentation stay open. + +Headless integration tests execute resolved authored cells, the real sender, +frame replacement, same-menu restoration, capability withdrawal and +ambiguous-send cleanup. Disabling pointer routing fails the new interaction +regression. These controls do not prove live primary transfer acceptance, +GPU output or native physical input parity. + +Reject unsupported recipe mapping attributes and require actual boolean +flags. The pointer adapter cannot honor select/deselect constraints and +must not turn those mappings into an unconditional recipe transfer. Both +the missing-attribute guard and string-boolean acceptance are covered by +the authored geometry regression. + +Route supported navigation and filtering toggles through the existing +per-family preference transport. Resolve their real authored group/index +and checked state, keep clipped painter order with slots and recipes, +and wait for a newer matching cooking snapshot before accepting more +input. Do not interrupt pending recipe gestures. Capability loss, timeout +and ambiguous preference sends restore the exact original menu screen. + +The unchanged target callbacks146c99520 and146c99b40 pass 74 controls +over supplied controller/model state, with downstream tab/filter setters, +collection rebuild and settings refresh observed at their call boundary. +Their event index and checked fields remain distinct. Pointer handlers +cover current selection, filtered state, pending snapshots and failure +recovery. Layout, focus, search and controller mappings remain unfinished; +unsupported required controls continue to use ordinary screens. + +Connect direct layout toggles through the same displayed-snapshot +preference route. Preserve current tab and filtering, validate the native +layout indices, skip an already-selected layout and wait for a matching +new model after either direction. Read the cached forced-profile state +before admission; unsupported forced-layout controls restore the original +screen without accepting input. + +The unchanged native callback146c99560 passes 480 controls over supplied +layout, tab, remember flag, input mode and source-validity boundaries. +It refreshes only on a changed layout and has additional saved-tab and +held-selection branches. Those internal states and their producers remain +unimplemented in this preference adapter; full controller, device and +visual parity are not established by this route. + +Carry the native controller mode explicitly in version-five cooking +inputs and retain it on follow-up phases and terminal cancellation. Permit +an initial mode-two primary focus before a primary selection, including +when the focus acknowledgment is still pending. Other initial contexts +retain their prior phase admission. The Java mouse adapter supplies mode +one; this change does not reinterpret a pointer as a native touch device. + +Preserve each focus/selection's own authority, pending-action identity and +release context across newer frames, capability withdrawal and ambiguous +sends. An older focus rejection cannot erase a newer active selection and +strand its terminal release. Reject unrelated commands during the verified +focus path. End focus cancellation at most once without another transfer. + +The core compares the focused carried-item requests with fresh original +native graph, selection, transfer and serialization execution. Client +controls exercise reentrant focus replies, captured context, cancellation, +forged acknowledgments and late focus failure. Physical mode-two event +production, carried-item screen controls and complete native UI parity +remain unfinished; the normal mouse route keeps its existing guard. + +Retain supported cooking scenes while their real menu cursor changes. +Ordinary pickup and put-back keep the same Java menu and slot dispatch; +vanilla carried-item extraction retains its existing ownership. Do not +retire the presentation merely because a stack is carried. The mouse +recipe sender still rejects a direct press with a nonempty cursor. + +The complete pinned tab and filter callbacks146c99520 and146c99b40 do +not read the held item. Admit those preferences with a carried stack, +while retaining the idle-gesture and newer-matching-model checks. Keep +layout changes guarded because callback146c99560 also updates saved tab +and held selection, whose producers remain unfinished. This does not +establish native carried opacity, focused-device input or default UI parity. + +Retain native Input/Toggle state and release captures across bound container +scene refreshes. Queue component changes before applying button scope and +consume emitted checked values instead of inverting controller snapshots. +Compose non-consuming preference events before publishing their final options, +so simultaneous tab and filter changes do not overwrite each other. Keep +local collection/cell selection independent of directional keyboard focus. + +Original Bedrock 1.26.51.1 build51061372 callback registration, receiver and +ScreenView delivery cover632 cases. Another96 composed native factory scenes +cover384 button steps across modes, scope, radio state and capture. The main +container consumer and preference adapter match those steps. Preserve the +explicit admission limits for global button reinjection, unsupported screen +input policies and unresolved toggle collection indices. Default cooking +search/slider handlers, full controller construction and device/pixel parity +remain separate requirements. + +Reject unsupported screen-owned routes and focused slot actions before +input can reach them. Full-scope menus retain ordinary controls rather than +exposing a focused action without its native request handler. + +Use the scroll viewport for pointer clipping so retained offscreen controls +remain eligible for directional selection without receiving hidden clicks. +A regression fails with the owner rectangle and passes with the viewport. + +Admit implicit recipe-cell hover only for cooking presentations that own +its controller path. Consume both hover phases and clear the selection +only while its collection and signed cell index still match. This prevents +an older exit from erasing a newer selection across binding refreshes. +Read collection indices with native integral type and narrowing rules. +Original callback registration, dispatcher and final exit clearing cover +5,832 cases, with collection resolution and first selection/exit virtual +methods supplied as observed boundaries. Full default screens, touch-held +state and storage-item selection predicates remain separate requirements. + +Consume native tab increment/decrement activation events through the shared +CookingNavigation category model. Preserve furnace/filter/layout options, +clear selected cells on a tab refresh and wait for matching authoritative +preferences before accepting another gesture. Support dedicated authored +buttons and navigation input panels, including zero-size global panels. +Require preference transport before enabling those controls. Keep held-item +panel commands, mixed non-consuming inventory routes and screen-host input +preservation explicit remaining integration work. +Original registration, callback dispatch and category-query execution cover +8,064 cases with selected-tab setter and model refresh observed separately. +Pointer and keyboard screen regressions cover phase retention, stale model +responses, carried items, selection clearing and missing capability fallback. + +Retain cooking screen hosts as real scene controls and admit supported +host-owned navigation through the same retained input component state as +buttons and input panels. Preserve the root across authoritative updates +so a held activation does not retrigger. Use explicit authored screen graphs +in interaction fixtures. Reject the unimplemented player-hurt close policy +before enabling a screen instead of silently dropping that host behavior. +The host-navigation regression fails against the prior content-only core +and exercises press, update-while-held, release and subsequent activation. +Full default host event routing, held-input/reset, player-hurt handling, +search/edit controls, devices and native pixels remain required work. + +Route authored VIEW arrow activations through retained input components +instead of navigating directly on Java key callbacks. Repeat and release +phases therefore follow the native mapping and input mode. Focus-enabled +panels participate even without a controller action. Reveal and relayout +the selected control before updating the logical pointer, then deliver +nested hover buttons before the outer controller queue. Retire arrow +phases while preference input is unavailable and across model refreshes. + +Original 1.26.51.1/protocol2193 ScreenView execution matches 147 composed +cases, 2,169 focus/pointer/hover steps and 840 controller button events. +The capture supplies input order and ScreenView fields, uses original +component factories and the base controller, and retains kind-5 pointer +events as an unsupported controller-delivery boundary. Full native screen +construction, focus-tab groups, modal/deflection routing, scroll geometry, +physical devices and pixels remain unfinished. + +Reinject GLOBAL targets through raw component input using the event's +previous phase, then forward the original controller event. Retain +physical pointer coordinates separately from focus hover coordinates. +Implicit GLOBAL hover uses the current View input mode and event time, +not its zero-mode header. Native phase two has no binary controller +callback and must not become a hover exit. Recursive GLOBAL wildcards +remain rejected before input. + +Original raw mapping, GLOBAL recursion, capture priority, focus and hover +execute in 29 View cases and 200 steps. Another 240 composed Toggle and +Furnace callback steps match component state and domain effects; immediate +settings-notification timing remains outside that adapter. Original +component, manager, controller and capture-map node/vector cleanup finish +with no native allocations. View fields/order, bare controls, empty child +controllers and downstream preference setters remain supplied boundaries. +Full controller pointer delivery, focus-tab groups, modal/deflection, +physical input and pixels remain unfinished. + +Retain ordinary cooking screens when GLOBAL targets or cross-control +aliases would authorize inventory, recipe or close actions that still use +a separate direct pointer controller. This capability boundary preserves +action ownership until those receivers use the native event path. Tests +cover preference admission, alias rejection and fallback before actions. + +Route authored VIEW tab activations through visible, enabled navigation_tab +controls in native tree order. Choose the nearest authored index, retain +last-checked and duplicate-index ownership, update the eligible group and +emit its non-notifying change before old/new control-local hover events. +Keep logical and physical View pointers unchanged. Admit keyboard mappings +by their VIEW receiver instead of applying controller-command validation. + +Original 1.26.51.1/protocol2193 traversal, tab mutation and Furnace callback +execution matches 208 cases and 2,117 steps, including 815 group changes +and 906 controller buttons. All 79,461 native frees finish with no native +allocations. Screen update, downstream preference setters and settings +notification timing remain observed boundaries. Default Furnace tabs explicitly disable focus. The active focus-enabled +VIEW lifecycle is integrated below. Full native screen +construction, controller pointer delivery, devices and pixels remain open. + +Use forced native focus selection for focus-enabled VIEW tabs, reveal and +relayout their target, then deliver whole-view hover before the group +change. Retain both stored View pointer positions. Keep non-focus tabs on +their separate group-change-first, control-local hover route. + +Do not manufacture the native selection-latch flag from FocusManager +ownership. Original overlapping/consuming controls show that focus and +hover can have different owners; the extra flag incorrectly activates a +second focused mapping and changes the controller preference twice. + +Original 1.26.51.1/protocol2193 execution matches 204 composed cases and +2,388 steps, with 804 group changes and 1,182 controller button events. +All 127,116 native frees finish with no native allocations. Fixtures +supply active manager state, tree/order, geometry and initial bindings. +Full screen updates and downstream setters remain observation boundaries. +Manager activation and inactive delivery, native scroll reveal, edit-box +selection, Button audio, controller pointer receivers, full +screen construction, physical devices and pixels remain unfinished. + +Apply the actual Button appearance component and its consume_event result +to retained container input. Preserve press state separately from applied +visibility, including suppressed mapping transitions, phase two and +matching scene replacements. Apply named visibility before layout, then +refresh geometry and draw order without creating another component event. + +Keep own visibility/enabled resets distinct from inherited enabled-event +updates. Relay implicit hover buttons directly to the controller queue; +they do not re-enter the sibling Button component. Let raw component +callbacks run before later routed controls read their context. + +Original component, property, hover and ScreenView captures cover 1,268 +cases and 16,900 steps, with 185,262 native frees and balanced owners. +ScreenView raw input returns void; compare its deliveries and retained +state, while measuring consumption at the original Input component. +Full screen construction/update timing, Button audio, pointer receivers, +manager activation, physical devices and pixels remain separate gates. + +Apply native visible/ignored callbacks to retained Input mappings before +layout and use inherited visibility to remove hidden controls from normal +input order. The complete original ScreenView update clears hidden hover, +invalidates mappings on removal, and preserves captures and double-press +history. A captured hidden GLOBAL release still reaches its component; +showing only an ancestor does not erase that release phase. Do not synthesize +implicit-hover controller buttons from the hidden hover callback. + +Compare 144 original 1.26.51.1 full pending-update fixtures across modes, +visible/ignored owner and parent changes, mappings and capture policies. +The 2,592 steps and 1,452 controller buttons match with native allocation +and owner cleanup balanced. Twelve initially hidden native fixtures prove +phase invalidation during initial registration. External notification queue +insertion, the admitted modal root, geometry and clocks are supplied. Full +binding/frame scheduling, modal transitions, devices and pixels remain open. + +Create and index every retained input component before resolving Button +names. Native named setters immediately notify the target Input before its +Button callback, including nested and aliased controls. Keep inherited +visibility, hover exit and normal input registration in the later View +update so subsequent controls in the same raw pass retain native delivery. +Restore matching Java scene copies without manufacturing property events. + +Use the host visibility writer for runtime setters and preserve reentrant +sender updates. The original tree constructor and by-value notification +methods now populate and deduplicate the real pending queues. Complete +native update/drain and ownership cleanup cover 216 composed, self-control +and wildcard fixtures, with no queue/state output supplied by a stub. +Root attachment, trees/geometry, mode and drain boundaries remain supplied. +Binding-expression execution, complete screen construction, physical frame +scheduling, Button audio and devices remain open. + +Restore cached Button visuals onto matching scene copies without replaying +intermediate aliased visibility callbacks or retiring held child mappings. + +Initialize supported cooking Input mappings once after the first accepted +scene is prepared. The complete original View wrapper starts its mappings +at zero, then the initial mode notification queues the root update that +invalidates them to two before activation and controller delivery. +Repeated Java refreshes and resize preparation must preserve held input. + +The original complete wrapper constructor/destructor, resize, mode entry +and activation/frame paths cover 144 initial-input fixtures and 864 raw +steps with 34,020 balanced frees. The actual screen regression fails on +the published implementation before this correction. Bare control trees, +empty external layout work queues and client/CRT services remain supplied; +outer installation, physical notifications, derived cooking controllers +and nonempty authored layout/binding construction remain separate gates. + +Admit the current desktop pointer after Gui.setScreen resynchronizes it +and before Minecraft renders the first frame. Without that notification, +stationary hover selection waits for physical movement. Convert Java HUD +coordinates through the existing native viewport and require the same +screen to remain installed. Refresh and resize do not replay admission. + +The whole original installer and optional wrapper/View pointer path match +96 cases, 480 raw steps and 282 controller buttons against the published +component implementation, with 25,602 balanced native frees. The complete +original client mode callback and its native screen lambda execute another +72 sequences and 216 notifications, with 18,480 balanced frees. Client +services, foreground enumeration, empty layout work and bare trees remain +supplied; physical mode admission and derived cooking controllers stay +separate gates. The screen regression covers post-resync pointer data, +unequal Java/native scales, selection, matching refreshes and stale screens. + +Send player inventory lifecycle messages only after Gui installs the real screen. Share the negotiated route between direct Bedrock and ViaProxy, retain one opening through a native presentation swap, and give ordinary reopens a new token despite Java's persistent menu zero. Accept server close notifications only for the matching connection and screen. Remove the constructor trigger and keep legacy direct opening at the installed-screen boundary. --- - .../bedrock/ViaFabricPlusBedrock.java | 4 + - .../access/IBedrockGuiItemOpacity.java | 12 + - .../MixinNativeContainerGeometry.java | 46 ++ - .../MixinNativeContainerPresentation.java | 26 + - .../render/MixinNativeGuiItemAtlas.java | 28 + - .../render/MixinNativeGuiItemExtraction.java | 31 + - .../render/MixinNativeGuiItemState.java | 19 + - .../render/MixinNativeGuiOversizedItem.java | 31 + - .../container/BedrockFurnacePreferences.java | 55 ++ - .../BedrockNativeContainerScreen.java | 623 ++++++++++++++++++ - .../container/BedrockNativeContainers.java | 262 ++++++++ - .../container/BedrockNativeCooking.java | 77 +++ - .../NativeContainerActionPayload.java | 30 + - .../NativeContainerCurrentPayload.java | 30 + - .../container/NativeContainerDataPayload.java | 30 + - .../NativeContainerFamiliesPayload.java | 30 + - .../container/NativeContainerGeometry.java | 100 +++ - .../container/NativeContainerPayload.java | 22 + - .../container/NativeContainerPending.java | 65 ++ - .../NativeContainerPresentation.java | 44 ++ - ...NativeContainerPresentationController.java | 95 +++ - .../container/NativeContainerScroll.java | 105 +++ - .../container/NativeContainerSession.java | 37 ++ - .../NativeContainerStatusPayload.java | 29 + - .../NativeContainerWorkstationsPayload.java | 30 + - .../container/NativeCookingPayload.java | 23 + - .../container/NativeCookingPresentation.java | 86 +++ - .../NativeCookingPresentationController.java | 81 +++ - .../NativeCookingProgressPayload.java | 23 + - .../NativeFurnaceOptionsPayload.java | 27 + - .../NativeFurnacePreferenceState.java | 44 ++ - .../container/NativeInventoryFlights.java | 65 ++ - .../container/NativeInventoryItemOpacity.java | 34 + - .../NativeInventoryItemPresentation.java | 124 ++++ - .../NativeInventoryPresentation.java | 27 + - .../container/NativeInventoryProgress.java | 70 ++ - .../viafabricplus-bedrock.mixins.json | 6 + - .../BedrockNativeContainerScreenTest.java | 542 +++++++++++++++ - .../NativeContainerGeometryTest.java | 129 ++++ - ...veContainerPresentationControllerTest.java | 91 +++ - .../container/NativeContainerScrollTest.java | 51 ++ - .../container/NativeContainerSessionTest.java | 40 ++ - ...tiveCookingPresentationControllerTest.java | 111 ++++ - .../NativeCookingPresentationTest.java | 125 ++++ - .../NativeFurnacePreferenceStateTest.java | 45 ++ - .../container/NativeInventoryFlightsTest.java | 70 ++ - .../NativeInventoryItemOpacityTest.java | 65 ++ - .../NativeInventoryItemPresentationTest.java | 67 ++ - .../NativeInventoryProgressTest.java | 83 +++ - .../bedrock/screen/ui/NativeUiFontTest.java | 133 ++++ - 50 files changed, 4023 insertions(+) + .../bedrock/ViaFabricPlusBedrock.java | 4 + + .../access/IBedrockGuiItemOpacity.java | 12 + + .../MixinNativeContainerGeometry.java | 46 + + .../MixinNativeContainerScreenAdmission.java | 28 + + .../features/misc/MixinInventoryScreen.java | 61 - + .../MixinNativeContainerPresentation.java | 26 + + .../render/MixinNativeGuiItemAtlas.java | 28 + + .../render/MixinNativeGuiItemExtraction.java | 31 + + .../render/MixinNativeGuiItemState.java | 19 + + .../render/MixinNativeGuiOversizedItem.java | 31 + + .../container/BedrockFurnacePreferences.java | 60 + + .../BedrockNativeContainerScreen.java | 948 +++++++++++ + .../container/BedrockNativeContainers.java | 277 ++++ + .../container/BedrockNativeCooking.java | 145 ++ + .../container/BedrockPlayerInventory.java | 88 ++ + .../NativeContainerActionPayload.java | 30 + + .../NativeContainerCurrentPayload.java | 30 + + .../container/NativeContainerDataPayload.java | 30 + + .../NativeContainerFamiliesPayload.java | 30 + + .../container/NativeContainerGeometry.java | 228 +++ + .../container/NativeContainerInput.java | 501 ++++++ + .../container/NativeContainerPayload.java | 22 + + .../container/NativeContainerPending.java | 65 + + .../NativeContainerPresentation.java | 45 + + ...NativeContainerPresentationController.java | 95 ++ + .../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/NativeCookingPayload.java | 23 + + .../NativeCookingPreferenceControl.java | 63 + + .../container/NativeCookingPresentation.java | 135 ++ + .../NativeCookingPresentationController.java | 119 ++ + .../NativeCookingProgressPayload.java | 23 + + .../NativeFurnaceOptionsPayload.java | 27 + + .../NativeFurnacePreferenceState.java | 44 + + .../container/NativeInventoryFlights.java | 65 + + .../container/NativeInventoryItemOpacity.java | 34 + + .../NativeInventoryItemPresentation.java | 124 ++ + .../NativeInventoryPresentation.java | 30 + + .../container/NativeInventoryProgress.java | 70 + + .../PlayerInventoryScreenClosePayload.java | 22 + + .../PlayerInventoryScreenLifecycle.java | 70 + + .../PlayerInventoryScreenRequestPayload.java | 22 + + .../viafabricplus-bedrock.mixins.json | 8 +- + .../BedrockNativeContainerScreenTest.java | 605 +++++++ + .../container/NativeContainerButtonTest.java | 280 ++++ + .../NativeContainerGeometryTest.java | 366 +++++ + .../container/NativeContainerInputTest.java | 542 +++++++ + ...veContainerPresentationControllerTest.java | 91 ++ + .../container/NativeContainerScrollTest.java | 51 + + .../container/NativeContainerSessionTest.java | 40 + + .../container/NativeCookingActionsTest.java | 346 ++++ + ...tiveCookingPresentationControllerTest.java | 216 +++ + .../NativeCookingPresentationTest.java | 144 ++ + .../container/NativeCookingScreenTest.java | 1387 +++++++++++++++++ + .../NativeFurnacePreferenceStateTest.java | 45 + + .../container/NativeInventoryFlightsTest.java | 70 + + .../NativeInventoryItemOpacityTest.java | 65 + + .../NativeInventoryItemPresentationTest.java | 67 + + .../NativeInventoryProgressTest.java | 83 + + .../PlayerInventoryScreenLifecycleTest.java | 95 ++ + .../bedrock/screen/ui/NativeUiFontTest.java | 133 ++ + 67 files changed, 8816 insertions(+), 62 deletions(-) create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockGuiItemOpacity.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/inventory/MixinNativeContainerGeometry.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/inventory/MixinNativeContainerScreenAdmission.java + delete mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/misc/MixinInventoryScreen.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeContainerPresentation.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeGuiItemAtlas.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeGuiItemExtraction.java @@ -178,11 +528,13 @@ handlers must be implemented before replacing the actual screen. create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockNativeContainerScreen.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockNativeContainers.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockNativeCooking.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockPlayerInventory.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerActionPayload.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerCurrentPayload.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerDataPayload.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerFamiliesPayload.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerGeometry.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerInput.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerPayload.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerPending.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerPresentation.java @@ -191,7 +543,12 @@ handlers must be implemented before replacing the actual screen. create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerSession.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerStatusPayload.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerWorkstationsPayload.java + 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/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 @@ -202,25 +559,33 @@ handlers must be implemented before replacing the actual screen. 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/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/PlayerInventoryScreenClosePayload.java + 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/PlayerInventoryScreenRequestPayload.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 create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerGeometryTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerInputTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerPresentationControllerTest.java 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/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/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/NativeInventoryItemOpacityTest.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/NativeInventoryProgressTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/PlayerInventoryScreenLifecycleTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiFontTest.java diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java b/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java -index 75a63cd..2081e10 100644 +index 491a083a..e2a6e451 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java -@@ -92,6 +92,8 @@ import org.apache.logging.log4j.Logger; +@@ -93,6 +93,8 @@ import org.apache.logging.log4j.Logger; import com.viaversion.viafabricplus.bedrock.screen.ui.BedrockHudOverlays; import com.viaversion.viafabricplus.bedrock.screen.form.BedrockNativeForms; import com.viaversion.viafabricplus.bedrock.screen.npc.BedrockNativeNpcs; @@ -229,7 +594,7 @@ index 75a63cd..2081e10 100644 public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { -@@ -112,6 +114,8 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { +@@ -113,6 +115,8 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { BedrockNativeForms.register(); BedrockHudOverlays.register(); BedrockNativeNpcs.register(); @@ -240,7 +605,7 @@ index 75a63cd..2081e10 100644 ClientPlayConnectionEvents.INIT.register((listener, client) -> diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockGuiItemOpacity.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockGuiItemOpacity.java new file mode 100644 -index 0000000..e6f71c4 +index 00000000..e6f71c4f --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockGuiItemOpacity.java @@ -0,0 +1,12 @@ @@ -258,7 +623,7 @@ index 0000000..e6f71c4 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/inventory/MixinNativeContainerGeometry.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/inventory/MixinNativeContainerGeometry.java new file mode 100644 -index 0000000..6298650 +index 00000000..6298650f --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/inventory/MixinNativeContainerGeometry.java @@ -0,0 +1,46 @@ @@ -308,9 +673,110 @@ index 0000000..6298650 + if (BedrockNativeContainers.replacing(((AbstractContainerScreen) (Object) this).getMenu())) ci.cancel(); + } +} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/inventory/MixinNativeContainerScreenAdmission.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/inventory/MixinNativeContainerScreenAdmission.java +new file mode 100644 +index 00000000..37cf62bd +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/inventory/MixinNativeContainerScreenAdmission.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.injection.mixin.features.inventory; ++ ++import com.viaversion.viafabricplus.bedrock.screen.container.BedrockNativeContainers; ++import net.minecraft.client.gui.Gui; ++import net.minecraft.client.gui.screens.Screen; ++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(Gui.class) ++public abstract class MixinNativeContainerScreenAdmission { ++ @Inject(method = "setScreen", at = @At("RETURN")) ++ private void playerInventoryLifecycle(final Screen screen, final CallbackInfo ci) { ++ BedrockNativeContainers.playerScreenInstalled(); ++ } ++ ++ @Inject(method = "setScreen", at = @At(value = "INVOKE", target = ++ "Lnet/minecraft/client/MouseHandler;resyncMousePosition()V", shift = At.Shift.AFTER)) ++ private void initialPointer(final Screen screen, final CallbackInfo ci) { ++ BedrockNativeContainers.screenInstalled(screen); ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/misc/MixinInventoryScreen.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/misc/MixinInventoryScreen.java +deleted file mode 100644 +index 1349cd68..00000000 +--- a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/misc/MixinInventoryScreen.java ++++ /dev/null +@@ -1,61 +0,0 @@ +-/* +- * This file is part of ViaFabricPlus Bedrock - https://github.com/florianreuth/viafabricplus-bedrock +- * Copyright (C) 2021-2026 the original authors +- * - Florian Reuth +- * - RK_01/RaphiMC +- * Copyright (C) 2023-2026 ViaVersion and contributors +- * +- * This program is free software: you can redistribute it and/or modify +- * it under the terms of the GNU General Public License as published by +- * the Free Software Foundation, either version 3 of the License, or +- * (at your option) any later version. +- * +- * This program is distributed in the hope that it will be useful, +- * but WITHOUT ANY WARRANTY; without even the implied warranty of +- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +- * GNU General Public License for more details. +- * +- * You should have received a copy of the GNU General Public License +- * along with this program. If not, see . +- */ +- +-package com.viaversion.viafabricplus.bedrock.injection.mixin.features.misc; +- +-import com.viaversion.viafabricplus.ViaFabricPlus; +-import com.viaversion.viaversion.api.connection.UserConnection; +-import com.viaversion.viaversion.api.protocol.packet.PacketWrapper; +-import com.viaversion.viaversion.api.type.Types; +-import net.minecraft.client.gui.screens.inventory.InventoryScreen; +-import net.raphimc.viabedrock.api.BedrockProtocolVersion; +-import net.raphimc.viabedrock.protocol.BedrockProtocol; +-import net.raphimc.viabedrock.protocol.ServerboundBedrockPackets; +-import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.InteractPacketPayload_Action; +-import net.raphimc.viabedrock.protocol.storage.EntityTracker; +-import net.raphimc.viabedrock.protocol.types.BedrockTypes; +-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(InventoryScreen.class) +-public abstract class MixinInventoryScreen { +- +- @Inject(method = "", at = @At("RETURN")) +- private void sendBedrockPacket(final CallbackInfo ci) { +- if (!ViaFabricPlus.api().targetVersion().equals(BedrockProtocolVersion.BEDROCK_LATEST)) { +- return; +- } +- +- final UserConnection connection = ViaFabricPlus.api().userConnection(); +- if (connection == null) { +- return; +- } +- +- final PacketWrapper interact = PacketWrapper.create(ServerboundBedrockPackets.INTERACT, connection); +- interact.write(Types.UNSIGNED_BYTE, (short) InteractPacketPayload_Action.OpenInventory.getValue()); // action +- interact.write(BedrockTypes.UNSIGNED_VAR_LONG, connection.get(EntityTracker.class).getClientPlayer().runtimeId()); // target entity runtime id +- interact.write(BedrockTypes.OPTIONAL_POSITION_3F, null); // position +- interact.sendToServer(BedrockProtocol.class); +- } +- +-} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeContainerPresentation.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeContainerPresentation.java new file mode 100644 -index 0000000..19e2421 +index 00000000..19e24214 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeContainerPresentation.java @@ -0,0 +1,26 @@ @@ -342,7 +808,7 @@ index 0000000..19e2421 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeGuiItemAtlas.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeGuiItemAtlas.java new file mode 100644 -index 0000000..cd6b479 +index 00000000..cd6b4791 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeGuiItemAtlas.java @@ -0,0 +1,28 @@ @@ -376,7 +842,7 @@ index 0000000..cd6b479 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeGuiItemExtraction.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeGuiItemExtraction.java new file mode 100644 -index 0000000..d91bb1c +index 00000000..d91bb1c1 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeGuiItemExtraction.java @@ -0,0 +1,31 @@ @@ -413,7 +879,7 @@ index 0000000..d91bb1c +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeGuiItemState.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeGuiItemState.java new file mode 100644 -index 0000000..3c5fe0f +index 00000000..3c5fe0f4 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeGuiItemState.java @@ -0,0 +1,19 @@ @@ -438,7 +904,7 @@ index 0000000..3c5fe0f +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeGuiOversizedItem.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeGuiOversizedItem.java new file mode 100644 -index 0000000..af5ded4 +index 00000000..af5ded45 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinNativeGuiOversizedItem.java @@ -0,0 +1,31 @@ @@ -475,10 +941,10 @@ index 0000000..af5ded4 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockFurnacePreferences.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockFurnacePreferences.java new file mode 100644 -index 0000000..73d9065 +index 00000000..1583ce03 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockFurnacePreferences.java -@@ -0,0 +1,55 @@ +@@ -0,0 +1,60 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -522,13 +988,18 @@ index 0000000..73d9065 + + public static boolean change(final Connection owner, final FurnaceOptions options) { + final var listener = currentListener(owner); -+ if (listener == null || STATE.revision(listener) < 0 -+ || !ClientPlayNetworking.canSend(NativeFurnaceOptionsPayload.TYPE)) return false; ++ if (!available(owner)) return false; + ClientPlayNetworking.send(new NativeFurnaceOptionsPayload(options)); + STATE.accept(listener, options); + return true; + } + ++ static boolean available(final Connection owner) { ++ final var listener = currentListener(owner); ++ return listener != null && STATE.revision(listener) >= 0 ++ && ClientPlayNetworking.canSend(NativeFurnaceOptionsPayload.TYPE); ++ } ++ + private static ClientPacketListener currentListener(final Connection owner) { + final var listener = Minecraft.getInstance().getConnection(); + return owner != null && listener != null && listener.getConnection() == owner ? listener : null; @@ -536,10 +1007,10 @@ index 0000000..73d9065 +} 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..4af590e +index 00000000..8d6d669c --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockNativeContainerScreen.java -@@ -0,0 +1,623 @@ +@@ -0,0 +1,948 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -552,13 +1023,28 @@ index 0000000..4af590e +import com.viaversion.viafabricplus.bedrock.injection.access.IBedrockGuiItemOpacity; +import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiAnimationLifecycle; +import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiImages; ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiInput; ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiToggle; +import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiLayout; +import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiPresentation; +import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiRenderer; +import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiScene; +import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiScreen; +import com.viaversion.viafabricplus.injection.access.core.IConnection; -+ ++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.Objects; ++import java.util.Set; ++import java.util.function.BooleanSupplier; ++import java.util.function.LongSupplier; ++import net.minecraft.client.Minecraft; ++import net.raphimc.viabedrock.protocol.model.FurnaceOptions; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphicsExtractor; @@ -573,21 +1059,10 @@ index 0000000..4af590e +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; +import net.raphimc.viabedrock.api.model.NativeContainerAction; ++import net.raphimc.viabedrock.api.model.NativeCookingAction; +import net.raphimc.viabedrock.api.resourcepack.ui.ContainerUi; +import net.raphimc.viabedrock.protocol.storage.GameRulesStorage; + -+import java.util.ArrayList; -+import java.util.Collections; -+import java.util.Comparator; -+import java.util.HashMap; -+import java.util.IdentityHashMap; -+import java.util.List; -+import java.util.Map; -+import java.util.Objects; -+import java.util.Set; -+import java.util.function.LongSupplier; -+import java.util.function.BooleanSupplier; -+ +/** + * Real menu presentation; generic menus retain vanilla dispatch, scoped count-one controls use the + * negotiated native request backend. @@ -599,7 +1074,7 @@ index 0000000..4af590e + "hover_text_renderer", + "progress_bar_renderer", + "flying_item_renderer", -+ "live_horse_renderer"); ++ "live_horse_renderer", "live_player_renderer"); + private final ClientPacketListener owner; + private final AbstractContainerScreen ordinary; + private final NativeInventoryPresentation presentation; @@ -618,11 +1093,26 @@ index 0000000..4af590e + private NativeUiAnimationLifecycle lifecycle; + private long animationStarted; + private long layoutRevision; ++ private long nativeInputRevision; + private NativeUiLayout.Box root; + private NativeContainerGeometry geometry; + private List draws = List.of(); + private long previousPresentationRevision = -1; + private Map recipePreviews = Map.of(); ++ private Map> recipeActions = Map.of(); ++ private long cookingUpdate = -1; ++ private final Set capturedButtons = new HashSet<>(); ++ private final Set nativeButtons = new HashSet<>(); ++ private final NativeContainerInput nativeInput = new NativeContainerInput(this::revealFocus); ++ private boolean keyboardPressed; ++ private final Set consumedReleases = new HashSet<>(); ++ private RecipePress recipePress; ++ private record RecipePress(int button, int recipe, NativeCookingAction.Mode mode) { } ++ private PreferenceChange preferenceChange; ++ private record PreferenceChange(long update, long sentAt, FurnaceOptions expected) { } ++ ++ boolean presents(final NativeCookingPresentation candidate) { return this.presentation == candidate; } ++ AbstractContainerScreen original() { return this.ordinary; } + private List previous = List.of(); + private ItemStack previousCarried = ItemStack.EMPTY; + private boolean fallingBack; @@ -717,6 +1207,8 @@ index 0000000..4af590e + NativeInventoryItemPresentation.values(this.menu.getCarried(), true, this.showTags), this.showTags); + final var resolved = frame.scene(); + this.recipePreviews = frame.recipes(); ++ this.recipeActions = frame.recipeActions().actions(); ++ this.cookingUpdate = this.presentation instanceof NativeCookingPresentation cooking ? cooking.message().updateSequence() : -1; + this.actionScope = + this.nativeActions + ? ContainerUi.actionScope(resolved, frame.containerSlots()) @@ -729,6 +1221,8 @@ index 0000000..4af590e + } + } + this.scene = NativeUiScene.parse(resolved.toString(), RENDERERS).root(); ++ if (this.presentation instanceof NativeCookingPresentation && this.scene.flag("close_on_player_hurt", false)) ++ throw new IllegalArgumentException("Unsupported native cooking player-hurt screen policy"); + if (this.lifecycle == null) { + this.animationStarted = this.clock.getAsLong(); + this.lifecycle = new NativeUiAnimationLifecycle(this.scene); @@ -737,6 +1231,17 @@ index 0000000..4af590e + this.layout(this.animationTime(), -1, -1); + 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()) ++ || !Set.of("button", "toggle").contains(node.type()) && node.properties().has("button_mappings") ++ && !NativeCookingNavigation.host(node))) ++ throw new IllegalArgumentException("Unsupported native cooking control"); ++ if (this.presentation instanceof NativeCookingPresentation) { ++ for (final var mapping : NativeUiInput.parse(node.properties()).mappings()) { ++ if (mapping.source() == NativeUiInput.symbol("button.menu_ok") && !this.supportsKeyboardInput(node, mapping)) ++ throw new IllegalArgumentException("Unsupported native cooking keyboard action"); ++ } ++ } + final String custom = node.string("renderer", ""); + if (custom.equals("inventory_item_renderer") + && node.flag("disabled_filter_visible", false)) @@ -752,6 +1257,7 @@ index 0000000..4af590e + this.imageRevision = this.images.revision(); + if (this.activated && !this.actionScope.subset()) + throw new IllegalArgumentException("Native inventory action scope changed"); ++ if (this.presentation instanceof NativeCookingPresentation) this.nativeInput.initialize(); + } + + private long animationTime() { return this.clock.getAsLong() - this.animationStarted; } @@ -767,10 +1273,13 @@ index 0000000..4af590e + private void layout(final long now, final double pointerX, final double pointerY) { + final var animations = this.lifecycle.animations(); + this.lifecycle.advance(now); ++ if (this.presentation instanceof NativeCookingPresentation) this.nativeInput.prepare(this.scene); + this.renderer.prepare(this.scene); + this.root = new NativeUiLayout(this.renderer, animations).layout(this.scene, this.width, this.height, this.scroll.offsets(this.scene)); -+ this.draws = NativeUiPresentation.draws(this.root, box -> box.visibleBounds().contains(pointerX, pointerY), node -> animations.opacityAt(node, now)); -+ this.geometry = new NativeContainerGeometry(this.draws, this.menu.slots.size()); ++ 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); ++ if (this.presentation instanceof NativeCookingPresentation) this.nativeInput.layout(this.root, this.draws, this.width, this.height); ++ this.geometry = new NativeContainerGeometry(this.draws, this.menu.slots.size(), this.recipeActions, ++ this.presentation instanceof NativeCookingPresentation); + this.requireCoverage(); + this.vanillaCursor.clear(); + this.cursor(this.root, false); @@ -778,9 +1287,15 @@ index 0000000..4af590e + this.flightOwner = null; + this.anchors(this.root); + this.layoutRevision = animations.layoutRevision(); ++ this.nativeInputRevision = this.nativeInput.visualRevision(); + } + + private void requireCoverage() { ++ if (this.presentation instanceof NativeCookingPresentation cooking ++ && this.geometry.hasLayoutPreferences() && cooking.forcedRecipeBookLayout()) ++ throw new IllegalArgumentException("Forced native recipe-book layout controls are unsupported"); ++ if (this.geometry.hasPreferences() && !BedrockFurnacePreferences.available(this.owner.getConnection())) ++ throw new IllegalStateException("Native cooking preferences are unavailable"); + if (this.actionScope.subset()) { + this.geometry.requireAuthoredCoverage(this.actionScope.slots(), this.menu.slots.size()); + this.requireSubsetMappings(); @@ -860,11 +1375,24 @@ index 0000000..4af590e + + @Override + protected void containerTick() { -+ if (!this.current()) return; ++ if (!this.current()) { ++ if (this.presentation instanceof NativeCookingPresentation) ++ this.fallback(new IllegalStateException("Native cooking presentation retired")); ++ return; ++ } + if (this.activationPending && ++this.activationTicks >= 100) { + this.fallback(new IllegalStateException("Native inventory activation timed out")); + return; + } ++ if (this.waitingForPreference() && this.clock.getAsLong() - this.preferenceChange.sentAt >= 10_000_000_000L) { ++ this.fallback(new IllegalStateException("Native cooking preference update timed out")); ++ return; ++ } ++ if (this.geometry != null && this.geometry.hasPreferences() ++ && !BedrockFurnacePreferences.available(this.owner.getConnection())) { ++ this.fallback(new IllegalStateException("Native cooking preferences are unavailable")); ++ return; ++ } + boolean changed = this.presentation.revision() != this.previousPresentationRevision || this.previous.size() != this.menu.slots.size() + || !ItemStack.matches(this.previousCarried, this.menu.getCarried()); + for (int i = 0; !changed && i < this.previous.size(); i++) @@ -898,10 +1426,13 @@ index 0000000..4af590e + try { + final long now = this.animationTime(); + this.lifecycle.advance(now); -+ if (this.lifecycle.animations().layoutRevision() != this.layoutRevision) this.layout(now, mouseX, mouseY); ++ if (this.lifecycle.animations().layoutRevision() != this.layoutRevision ++ || this.nativeInput.visualRevision() != this.nativeInputRevision) this.layout(now, mouseX, mouseY); + this.draws = NativeUiPresentation.draws(this.root, box -> box.visibleBounds().contains(mouseX, mouseY), -+ node -> this.lifecycle.animations().opacityAt(node, now)); -+ this.geometry = new NativeContainerGeometry(this.draws, this.menu.slots.size()); ++ node -> this.lifecycle.animations().opacityAt(node, now), this.nativeInput::hasButtonState); ++ if (this.presentation instanceof NativeCookingPresentation) this.nativeInput.layout(this.root, this.draws, this.width, this.height); ++ this.geometry = new NativeContainerGeometry(this.draws, this.menu.slots.size(), this.recipeActions, ++ this.presentation instanceof NativeCookingPresentation); + this.requireCoverage(); + this.hoveredSlot = this.nativeHoveredSlot(mouseX, mouseY); + final List ordered = new ArrayList<>(this.draws); @@ -928,7 +1459,7 @@ index 0000000..4af590e + else if (custom.equals("flying_item_renderer") + && box.node() == this.flightOwner) this.flying(graphics, draw.alpha()); + else if (box.node().type().equals("custom") -+ && custom.equals("live_horse_renderer")) ++ && (custom.equals("live_horse_renderer") || custom.equals("live_player_renderer"))) + this.renderer.portrait(graphics, box, mouseX, mouseY, delta, draw.alpha()); + else this.renderer.draw(graphics, draw, this.lifecycle.animations()); + } finally { graphics.disableScissor(); } @@ -1067,6 +1598,7 @@ index 0000000..4af590e + @Override + public boolean mouseScrolled(final double x, final double y, final double horizontal, final double vertical) { + if (!this.current()) return false; ++ if (this.waitingForPreference()) return true; + if (!this.scroll.wheel(this.root, x, y, horizontal, vertical)) return super.mouseScrolled(x, y, horizontal, vertical); + this.scrollAnimation(true); + this.refreshSafely(); @@ -1076,14 +1608,50 @@ index 0000000..4af590e + @Override + public boolean mouseClicked(final MouseButtonEvent event, final boolean doubleClick) { + if (!this.current()) return false; ++ this.consumedReleases.remove(event.button()); ++ final String componentSource = source(event.button()); ++ NativeContainerInput.Result componentEvent = null; ++ if (this.presentation instanceof NativeCookingPresentation) { ++ final var hover = this.nativeInput.pointer(event.x(), event.y(), 1, this.clock.getAsLong(), this.componentEnabled()); ++ componentEvent = this.nativeInput.button(componentSource, true, 1, this.clock.getAsLong(), this.componentEnabled()); ++ if (!this.waitingForPreference()) this.componentEvents(hover); ++ } ++ if (this.waitingForPreference()) { ++ this.capturedButtons.add(event.button()); ++ return true; ++ } ++ if (!this.capturedButtons.isEmpty()) { ++ this.capturedButtons.add(event.button()); ++ return true; ++ } + if (event.button() == InputConstants.MOUSE_BUTTON_LEFT && this.scroll.press(this.root, event.x(), event.y())) { + this.scrollAnimation(false); + this.refreshSafely(); + return true; + } -+ for (final var draw : this.draws.reversed()) if (event.button() == InputConstants.MOUSE_BUTTON_LEFT && draw.enabled() && draw.alpha() > 0 -+ && draw.box().node().string("inventory_command", "").equals("close") -+ && draw.box().visibleBounds().contains(event.x(), event.y())) { this.onClose(); return true; } ++ final String input = switch (event.button()) { ++ case InputConstants.MOUSE_BUTTON_LEFT -> "button.menu_select"; ++ case InputConstants.MOUSE_BUTTON_RIGHT -> "button.menu_secondary_select"; ++ default -> ""; ++ }; ++ if (componentEvent != null && this.componentEvents(componentEvent)) { ++ this.nativeButtons.add(event.button()); ++ return true; ++ } ++ if (this.geometry.closes(event.x(), event.y(), input)) { this.onClose(); return true; } ++ final var preference = this.geometry.preference(event.x(), event.y()); ++ if (preference != null) { ++ this.nativeButtons.add(event.button()); ++ return true; ++ } ++ final var recipe = this.geometry.recipe(event.x(), event.y()); ++ if (recipe != null) { ++ this.capturedButtons.add(event.button()); ++ final var mode = recipe.inputs().get(input); ++ if (mode != null && BedrockNativeCooking.action(this.owner, this.menu, recipe.id(), mode, 0)) ++ this.recipePress = new RecipePress(event.button(), recipe.id(), mode); ++ return true; ++ } + if (this.actionScope.subset()) { + if (this.activated && event.button() == InputConstants.MOUSE_BUTTON_LEFT) { + final int slot = this.geometry.hovered(event.x(), event.y()); @@ -1095,8 +1663,99 @@ index 0000000..4af590e + return super.mouseClicked(event, doubleClick); + } + ++ private static String source(final int button) { ++ return switch (button) { ++ case InputConstants.MOUSE_BUTTON_LEFT -> "button.menu_select"; ++ case InputConstants.MOUSE_BUTTON_RIGHT -> "button.menu_secondary_select"; ++ default -> ""; ++ }; ++ } ++ ++ @Override ++ public void mouseMoved(final double x, final double y) { ++ if (this.current() && this.presentation instanceof NativeCookingPresentation) { ++ final var result = this.nativeInput.pointer(x, y, 1, this.clock.getAsLong(), this.componentEnabled()); ++ if (!this.waitingForPreference()) this.componentEvents(result); ++ } ++ super.mouseMoved(x, y); ++ } ++ ++ private boolean componentEnabled() { ++ return this.presentation instanceof NativeCookingPresentation cooking && cooking.idle() ++ && !this.waitingForPreference() && this.capturedButtons.isEmpty(); ++ } ++ ++ private boolean componentEvents(final NativeContainerInput.Result result) { ++ if (!(this.presentation instanceof NativeCookingPresentation cooking)) return false; ++ final var message = cooking.message(); ++ final var initial = NativeCookingPreferenceControl.snapshot(message.view().family(), message.view().book()); ++ FurnaceOptions desired = initial; ++ boolean handled = false; ++ for (final var event : result.events()) { ++ if (event.button() != null && event.button().phase() != 0 && event.button().phase() != 1) continue; ++ if (event.change() != null) { ++ 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())) { ++ 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(); ++ } ++ 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.node().string("collection", ""); ++ if (event.button().pressed()) cooking.select(collection, event.collectionIndex()); ++ else cooking.deselect(collection, event.collectionIndex()); ++ } ++ } ++ } ++ if (!desired.equals(initial)) { ++ this.preferenceChange = new PreferenceChange(message.updateSequence(), this.clock.getAsLong(), desired); ++ try { ++ if (!BedrockFurnacePreferences.change(this.owner.getConnection(), desired)) ++ throw new IllegalStateException("Unable to send native cooking preference"); ++ } catch (final RuntimeException error) { this.fallback(error); } ++ } ++ return handled; ++ } ++ ++ private FurnaceOptions changePreference(final NativeCookingPreferenceControl control, final NativeUiToggle.Change change, ++ final FurnaceOptions current) { ++ if (!(this.presentation instanceof NativeCookingPresentation cooking) || !cooking.idle() ++ || Minecraft.getInstance().gui.screen() != this ++ || control.kind() == NativeCookingPreferenceControl.Kind.LAYOUT && !this.menu.getCarried().isEmpty()) return current; ++ final var message = cooking.message(); ++ if (message.updateSequence() != this.cookingUpdate) return current; ++ try { ++ return control.options(current, message.view().book().forcedRecipeBookLayout(), change); ++ } catch (final RuntimeException error) { this.fallback(error); return current; } ++ } ++ ++ private boolean waitingForPreference() { ++ if (this.preferenceChange == null || !(this.presentation instanceof NativeCookingPresentation cooking)) return false; ++ final var message = cooking.message(); ++ final var book = message.view().book(); ++ final var expected = this.preferenceChange.expected; ++ if (message.updateSequence() > this.preferenceChange.update && book.tab() == expected.leftTab() ++ && book.filtering() == expected.filtering() && book.effectiveLayout() == expected.layout()) { ++ this.preferenceChange = null; ++ return false; ++ } ++ return true; ++ } ++ + @Override + public boolean mouseDragged(final MouseButtonEvent event, final double dx, final double dy) { ++ if (this.presentation instanceof NativeCookingPresentation) this.mouseMoved(event.x(), event.y()); ++ 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())) { + this.scrollAnimation(false); @@ -1108,6 +1767,25 @@ index 0000000..4af590e + + @Override + public boolean mouseReleased(final MouseButtonEvent event) { ++ if (this.current() && this.presentation instanceof NativeCookingPresentation) { ++ final var hover = this.nativeInput.pointer(event.x(), event.y(), 1, this.clock.getAsLong(), this.componentEnabled()); ++ final var released = this.nativeInput.button(source(event.button()), false, 1, this.clock.getAsLong(), this.componentEnabled()); ++ if (!this.waitingForPreference()) { this.componentEvents(hover); this.componentEvents(released); } ++ } ++ if (this.nativeButtons.remove(event.button())) { ++ this.consumedReleases.add(event.button()); ++ return true; ++ } ++ if (this.capturedButtons.remove(event.button())) { ++ this.consumedReleases.add(event.button()); ++ final var press = this.recipePress; ++ if (press != null && press.button == event.button()) { ++ this.recipePress = null; ++ BedrockNativeCooking.action(this.owner, this.menu, press.recipe, press.mode, 2); ++ } ++ return true; ++ } ++ if (this.consumedReleases.contains(event.button())) return true; + if (!this.current()) return false; + if (event.button() == InputConstants.MOUSE_BUTTON_LEFT && this.scroll.release()) { + this.scrollAnimation(true); @@ -1119,17 +1797,120 @@ index 0000000..4af590e + @Override + public boolean keyPressed(final KeyEvent event) { + if (!this.current()) return false; ++ if (this.waitingForPreference()) { ++ 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 (event.key() == InputConstants.KEY_RETURN || event.key() == InputConstants.KEY_SPACE) this.keyboardPressed = true; ++ } ++ return true; ++ } ++ if (this.presentation instanceof NativeCookingPresentation) { ++ 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); ++ } ++ } + if (!this.actionScope.subset()) return super.keyPressed(event); + if (event.key() == InputConstants.KEY_ESCAPE + || this.minecraft.options.keyInventory.matches(event)) this.onClose(); + return true; + } + ++ 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 (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); ++ } ++ if (node.properties().has("inventory_slot")) ++ return this.nativeActions && this.actionScope.subset() && target == NativeUiInput.symbol("button.container_auto_place_one"); ++ return node.string("inventory_command", "").equals("close") ++ && (target == NativeUiInput.symbol("button.menu_exit") || target == NativeUiInput.symbol("button.try_menu_exit")); ++ } ++ ++ private boolean keyboardEvents(final NativeContainerInput.Result result) { ++ 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")) { ++ 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); ++ if (mode != null && this.recipeActions.getOrDefault(recipe, Set.of()).contains(mode.button())) { ++ if (button.transition() && this.recipePress == null && BedrockNativeCooking.action(this.owner, this.menu, recipe, mode, 0)) ++ this.recipePress = new RecipePress(-1, recipe, mode); ++ handled = true; ++ } ++ } else if (button.transition() && button.target() == NativeUiInput.symbol("button.container_auto_place_one") ++ && node.properties().has("inventory_slot")) { ++ final int slot = node.properties().get("inventory_slot").getAsInt(); ++ if (this.activated && this.actionScope.slots().contains(slot) && this.menu.slots.get(slot).isActive()) ++ 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"))) { ++ this.onClose(); ++ handled = true; ++ } ++ } ++ return handled || result.consumed(); ++ } ++ ++ private static 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"; ++ default -> null; ++ }; ++ } ++ ++ 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 boolean handled = !this.waitingForPreference() && this.keyboardEvents(result); ++ return handled || previous != this.nativeInput.focused() || result.consumed(); ++ } ++ ++ private void revealFocus(final NativeUiScene.Node focused) { ++ if (this.scroll.reveal(this.root, focused)) this.scrollAnimation(true); ++ this.layout(this.animationTime(), -1, -1); ++ } ++ ++ @Override ++ public boolean keyReleased(final KeyEvent event) { ++ final String source = keyboardSource(event.key()); ++ if (this.current() && this.presentation instanceof NativeCookingPresentation && source != null) { ++ 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; ++ if (press != null && press.button == -1) { ++ this.recipePress = null; ++ BedrockNativeCooking.action(this.owner, this.menu, press.recipe, press.mode, 2); ++ } ++ final boolean consumed = this.keyboardPressed; ++ this.keyboardPressed = false; ++ return consumed || handled; ++ } ++ return super.keyReleased(event); ++ } ++ + void fallback(final RuntimeException error) { + if (this.fallingBack) return; + this.fallingBack = true; + try { + this.deactivate(); ++ this.retireCooking(); + } catch (final RuntimeException transportError) { + if (transportError != error) error.addSuppressed(transportError); + } finally { @@ -1139,17 +1920,32 @@ index 0000000..4af590e + "Unsupported native inventory presentation; retaining ordinary menu" + + " controls", + error); -+ if (this.current()) ++ // Controller retirement must not prevent restoring the still-displayed menu. ++ final var client = net.minecraft.client.Minecraft.getInstance(); ++ if (client.gui.screen() == this && client.getConnection() == this.owner ++ && this.owner.getConnection().isConnected() && client.player != null && client.player.containerMenu == this.menu) + BedrockNativeContainers.replace(this.ordinary); + } + } + ++ private void retireCooking() { ++ this.capturedButtons.clear(); ++ this.nativeButtons.clear(); ++ this.keyboardPressed = false; ++ this.consumedReleases.clear(); ++ this.recipePress = null; ++ this.preferenceChange = null; ++ if (this.presentation instanceof NativeCookingPresentation cooking) ++ BedrockNativeCooking.retire(this.owner, this.menu, cooking); ++ } ++ + void discard() { if (this.renderer != null) this.renderer.close(); this.images.close(); } + + @Override + public void removed() { + try { + this.deactivate(); ++ this.retireCooking(); + } catch (final RuntimeException error) { + ViaFabricPlusBedrock.impl() + .logger() @@ -1165,10 +1961,10 @@ index 0000000..4af590e +} 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..ce07400 +index 00000000..04bcafb4 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockNativeContainers.java -@@ -0,0 +1,262 @@ +@@ -0,0 +1,277 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -1184,6 +1980,7 @@ index 0000000..ce07400 +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.gui.screens.Screen; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.client.multiplayer.ClientPacketListener; +import net.minecraft.world.inventory.AbstractContainerMenu; @@ -1214,6 +2011,7 @@ index 0000000..ce07400 + + public static void register() { + BedrockNativeCooking.register(); ++ BedrockPlayerInventory.register(); + PayloadTypeRegistry.clientboundPlay().register(NativeContainerPayload.TYPE, NativeContainerPayload.CODEC); + PayloadTypeRegistry.clientboundPlay() + .register(NativeContainerCurrentPayload.TYPE, NativeContainerCurrentPayload.CODEC); @@ -1428,15 +2226,28 @@ index 0000000..ce07400 + finally { replacing = null; } + } + ++ public static void playerScreenInstalled() { BedrockPlayerInventory.installed(); } ++ ++ /** Called after Java resynchronizes the pointer and before the first frame. */ ++ public static void screenInstalled(final Screen screen) { ++ if (!(screen instanceof BedrockNativeContainerScreen nativeScreen)) return; ++ final var client = Minecraft.getInstance(); ++ 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))); ++ } ++ + /** Suppress menu teardown only during a presentation swap of the same live menu object. */ + public static boolean replacing(final AbstractContainerMenu menu) { return replacing != null && replacing == menu; } +} 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..634d99c +index 00000000..70d29bdb --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockNativeCooking.java -@@ -0,0 +1,77 @@ +@@ -0,0 +1,145 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -1457,6 +2268,8 @@ index 0000000..634d99c +import net.minecraft.world.inventory.BlastFurnaceMenu; +import net.minecraft.world.inventory.FurnaceMenu; +import net.minecraft.world.inventory.SmokerMenu; ++import net.raphimc.viabedrock.api.model.NativeCookingAction; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.InputMode; +import net.raphimc.viabedrock.api.model.NativeCookingMessage; +import net.raphimc.viabedrock.api.resourcepack.ui.CookingUi; + @@ -1470,6 +2283,8 @@ index 0000000..634d99c + static void register() { + PayloadTypeRegistry.clientboundPlay().register(NativeCookingPayload.TYPE, NativeCookingPayload.CODEC); + PayloadTypeRegistry.clientboundPlay().register(NativeCookingProgressPayload.TYPE, NativeCookingProgressPayload.CODEC); ++ PayloadTypeRegistry.clientboundPlay().register(NativeCookingActionResultPayload.TYPE, NativeCookingActionResultPayload.CODEC); ++ PayloadTypeRegistry.serverboundPlay().register(NativeCookingActionPayload.TYPE, NativeCookingActionPayload.CODEC); + ClientPlayConnectionEvents.INIT.register((owner, client) -> { + PRESENTATIONS.begin(owner); + ClientPlayNetworking.registerReceiver(NativeCookingPayload.TYPE, @@ -1479,14 +2294,78 @@ index 0000000..634d99c + final var menu = client.player.containerMenu; + PRESENTATIONS.progress(owner, menu, menu.containerId, payload.progress()); + })); ++ ClientPlayNetworking.registerReceiver(NativeCookingActionResultPayload.TYPE, (payload, context) -> client.execute(() -> { ++ if (!connected(owner) || client.player == null) return; ++ final var menu = client.player.containerMenu; ++ PRESENTATIONS.result(owner, menu, menu.containerId, payload.result()); ++ })); + }); + ClientPlayConnectionEvents.DISCONNECT.register((owner, client) -> PRESENTATIONS.clear(owner)); + ClientTickEvents.END_CLIENT_TICK.register(client -> { + final var menu = client.player == null ? null : client.player.containerMenu; ++ PRESENTATIONS.capability(client.getConnection(), ClientPlayNetworking.canSend(NativeCookingActionPayload.TYPE)); + PRESENTATIONS.tick(client.getConnection(), menu, menu == null ? -1 : menu.containerId); ++ present(client); + }); + } + ++ static boolean action(final ClientPacketListener owner, final AbstractContainerMenu menu, final int recipe, ++ final NativeCookingAction.Mode mode, final int phase) { ++ final var client = Minecraft.getInstance(); ++ if (!connected(owner) || client.player == null || client.player.containerMenu != menu ++ || !(client.gui.screen() instanceof AbstractContainerScreen screen) || screen.getMenu() != menu ++ || phase == 0 && !menu.getCarried().isEmpty()) return false; ++ final var presentation = PRESENTATIONS.current(owner, menu, menu.containerId); ++ if (presentation == null || !(screen instanceof BedrockNativeContainerScreen nativeScreen) || !nativeScreen.presents(presentation)) return false; ++ final boolean available = ClientPlayNetworking.canSend(NativeCookingActionPayload.TYPE); ++ PRESENTATIONS.capability(owner, available); ++ if (!available) return false; ++ try { ++ return PRESENTATIONS.action(owner, menu, menu.containerId, recipe, mode, InputMode.Mouse.getValue(), phase, ++ action -> ClientPlayNetworking.send(new NativeCookingActionPayload(action))); ++ } catch (final RuntimeException failedTransport) { ++ PRESENTATIONS.retire(owner, menu, menu.containerId); ++ ViaFabricPlusBedrock.impl().logger().warn("Unable to send native cooking input; retiring its presentation", failedTransport); ++ return false; ++ } ++ } ++ ++ 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))); ++ else presentation.retire(); ++ } catch (final RuntimeException failedRelease) { ++ ViaFabricPlusBedrock.impl().logger().warn("Unable to release retired native cooking input", failedRelease); ++ } finally { ++ if (PRESENTATIONS.current(owner, menu, menu.containerId) == presentation) PRESENTATIONS.retire(owner, menu, menu.containerId); ++ } ++ } ++ ++ private static void present(final Minecraft client) { ++ final var owner = client.getConnection(); ++ if (!connected(owner) || client.player == null || !ClientPlayNetworking.canSend(NativeCookingActionPayload.TYPE)) return; ++ final var menu = client.player.containerMenu; ++ final var presentation = PRESENTATIONS.current(owner, menu, menu.containerId); ++ if (presentation == null || !(client.gui.screen() instanceof AbstractContainerScreen displayed) || displayed.getMenu() != menu) return; ++ if (displayed instanceof BedrockNativeContainerScreen nativeScreen && nativeScreen.presents(presentation)) return; ++ final var ordinary = displayed instanceof BedrockNativeContainerScreen nativeScreen ? nativeScreen.original() : displayed; ++ final var screen = new BedrockNativeContainerScreen(owner, ordinary, client.player.getInventory(), ordinary.getTitle(), presentation, ++ false, presentation.message().sequence(), System::nanoTime, ++ () -> connected(owner) && client.player != null && client.player.containerMenu == menu ++ && PRESENTATIONS.current(owner, menu, menu.containerId) == presentation); ++ try { ++ screen.prepare(client.font, screen.nativeViewport().width(), screen.nativeViewport().height()); ++ BedrockNativeContainers.replace(screen); ++ } catch (final RuntimeException unsupported) { ++ retire(owner, menu, presentation); ++ BedrockNativeContainers.failedPresentation(screen, unsupported); ++ } ++ } ++ + private static void accept(final ClientPacketListener owner, final NativeCookingMessage message) { + final var client = Minecraft.getInstance(); + if (!connected(owner) || client.player == null) return; @@ -1514,9 +2393,103 @@ index 0000000..634d99c + return owner != null && Minecraft.getInstance().getConnection() == owner && owner.getConnection().isConnected(); + } +} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockPlayerInventory.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockPlayerInventory.java +new file mode 100644 +index 00000000..76a72f68 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockPlayerInventory.java +@@ -0,0 +1,88 @@ ++/* ++ * 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.ViaFabricPlus; ++import com.viaversion.viaversion.api.protocol.packet.PacketWrapper; ++import com.viaversion.viaversion.api.type.Types; ++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.client.gui.screens.Screen; ++import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; ++import net.minecraft.client.gui.screens.inventory.CreativeModeInventoryScreen; ++import net.minecraft.client.multiplayer.ClientPacketListener; ++import net.minecraft.world.inventory.AbstractContainerMenu; ++import net.raphimc.viabedrock.api.BedrockProtocolVersion; ++import net.raphimc.viabedrock.protocol.BedrockProtocol; ++import net.raphimc.viabedrock.protocol.ServerboundBedrockPackets; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.InteractPacketPayload_Action; ++import net.raphimc.viabedrock.protocol.storage.EntityTracker; ++import net.raphimc.viabedrock.protocol.storage.PlayerInventoryScreenTracker; ++import net.raphimc.viabedrock.protocol.types.BedrockTypes; ++ ++/** Bridges the real installed player inventory through both direct translation and ViaProxy. */ ++final class BedrockPlayerInventory { ++ private static final PlayerInventoryScreenLifecycle SCREENS = ++ new PlayerInventoryScreenLifecycle<>(); ++ ++ private BedrockPlayerInventory() {} ++ ++ static void register() { ++ PayloadTypeRegistry.clientboundPlay().register(PlayerInventoryScreenClosePayload.TYPE, PlayerInventoryScreenClosePayload.CODEC); ++ PayloadTypeRegistry.serverboundPlay().register(PlayerInventoryScreenRequestPayload.TYPE, PlayerInventoryScreenRequestPayload.CODEC); ++ ClientPlayConnectionEvents.INIT.register((owner, client) -> { ++ SCREENS.begin(owner); ++ ClientPlayNetworking.registerReceiver(PlayerInventoryScreenClosePayload.TYPE, (payload, context) -> ++ client.execute(() -> closeInstalled(owner, SCREENS.close(owner, payload.close().opening())))); ++ }); ++ ClientPlayConnectionEvents.DISCONNECT.register((owner, client) -> SCREENS.clear(owner)); ++ ClientTickEvents.END_CLIENT_TICK.register(client -> { ++ final var owner = client.getConnection(); ++ closeInstalled(owner, SCREENS.capability(owner, available())); ++ installed(); ++ }); ++ } ++ ++ static void installed() { ++ final var client = Minecraft.getInstance(); ++ final var owner = client.getConnection(); ++ if (owner == null || client.player == null) return; ++ final var displayed = client.gui.screen(); ++ final var menu = client.player.inventoryMenu; ++ final boolean playerInventory = displayed instanceof AbstractContainerScreen container ++ && (container.getMenu() == menu && client.player.containerMenu == menu ++ || displayed instanceof CreativeModeInventoryScreen && client.player.containerMenu == container.getMenu()); ++ SCREENS.installed(owner, playerInventory ? displayed : null, playerInventory ? menu : null, ++ playerInventory && BedrockNativeContainers.replacing(menu), available(), ++ request -> ClientPlayNetworking.send(new PlayerInventoryScreenRequestPayload(request)), BedrockPlayerInventory::legacyOpen); ++ } ++ ++ private static boolean available() { ++ return ClientPlayNetworking.canSend(PlayerInventoryScreenRequestPayload.TYPE); ++ } ++ ++ private static void closeInstalled(final ClientPacketListener owner, final Screen screen) { ++ final var client = Minecraft.getInstance(); ++ if (screen != null && client.getConnection() == owner && client.player != null && client.gui.screen() == screen) ++ client.player.closeContainer(); ++ } ++ ++ private static void legacyOpen() { ++ if (!ViaFabricPlus.api().targetVersion().equals(BedrockProtocolVersion.BEDROCK_LATEST)) return; ++ final var user = ViaFabricPlus.api().userConnection(); ++ if (user == null || user.get(EntityTracker.class) == null) return; ++ final var screens = user.get(PlayerInventoryScreenTracker.class); ++ if (screens != null && screens.ownsClose()) return; ++ final var packet = PacketWrapper.create(ServerboundBedrockPackets.INTERACT, user); ++ packet.write(Types.UNSIGNED_BYTE, (short) InteractPacketPayload_Action.OpenInventory.getValue()); ++ packet.write(BedrockTypes.UNSIGNED_VAR_LONG, user.get(EntityTracker.class).getClientPlayer().runtimeId()); ++ packet.write(BedrockTypes.OPTIONAL_POSITION_3F, null); ++ packet.sendToServer(BedrockProtocol.class); ++ } ++} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerActionPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerActionPayload.java new file mode 100644 -index 0000000..9817c5b +index 00000000..9817c5b6 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerActionPayload.java @@ -0,0 +1,30 @@ @@ -1552,7 +2525,7 @@ index 0000000..9817c5b +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerCurrentPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerCurrentPayload.java new file mode 100644 -index 0000000..e57128e +index 00000000..e57128e1 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerCurrentPayload.java @@ -0,0 +1,30 @@ @@ -1588,7 +2561,7 @@ index 0000000..e57128e +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerDataPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerDataPayload.java new file mode 100644 -index 0000000..7773e13 +index 00000000..7773e13f --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerDataPayload.java @@ -0,0 +1,30 @@ @@ -1624,7 +2597,7 @@ index 0000000..7773e13 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerFamiliesPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerFamiliesPayload.java new file mode 100644 -index 0000000..3387649 +index 00000000..3387649e --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerFamiliesPayload.java @@ -0,0 +1,30 @@ @@ -1660,10 +2633,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..d82572a +index 00000000..2e924829 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerGeometry.java -@@ -0,0 +1,100 @@ +@@ -0,0 +1,228 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -1671,45 +2644,135 @@ index 0000000..d82572a + */ +package com.viaversion.viafabricplus.bedrock.screen.container; + ++import com.google.gson.JsonObject; ++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.HashMap; +import java.util.HashSet; +import java.util.List; ++import java.util.Map; +import java.util.Set; ++import net.raphimc.viabedrock.api.model.NativeCookingAction; + -+/** One authored, clipped slot rectangle for drawing, tooltip picking and every vanilla menu event. */ ++/** Authored, clipped controls share one front-to-back policy for slot and command input. */ +final class NativeContainerGeometry { -+ private final List slots; ++ private static final Set RECIPE_MAPPING_FIELDS = Set.of("from_button_id", "to_button_id", "mapping_type", "ignored", "consume_event"); ++ private final List controls; + private final Set registered = new HashSet<>(); ++ private boolean navigation; + + NativeContainerGeometry(final List draws, final int slotCount) { -+ final List regions = new ArrayList<>(); ++ this(draws, slotCount, Map.of()); ++ } ++ ++ NativeContainerGeometry(final List draws, final int slotCount, ++ final Map> recipeActions) { ++ this(draws, slotCount, recipeActions, false); ++ } ++ ++ NativeContainerGeometry(final List draws, final int slotCount, ++ final Map> recipeActions, final boolean cooking) { ++ final Set reinjected = new HashSet<>(); ++ if (cooking) for (final var draw : draws) { ++ final var input = NativeUiInput.parse(draw.box().node().properties()); ++ for (final var mapping : input.mappings()) if (mapping.scope() == NativeUiInput.Scope.GLOBAL) reinjected.add(mapping.target()); ++ for (final var mapping : input.hoverBindings()) if (mapping.scope() == NativeUiInput.Scope.GLOBAL) reinjected.add(mapping.target()); ++ } ++ final List regions = new ArrayList<>(); + for (final var draw : draws) { + final var box = draw.box(); + final var node = box.node(); -+ validateActions(node); -+ if (!node.properties().has("inventory_actions") -+ || !node.properties().has("inventory_slot")) continue; -+ final int slot = node.properties().get("inventory_slot").getAsInt(); -+ if (slot < 0 || slot >= slotCount) ++ final var recipe = recipe(node, draw.enabled(), recipeActions); ++ final var preference = cooking ? NativeCookingPreferenceControl.parse(node) : null; ++ final boolean navigation = cooking && NativeCookingNavigation.mapped(node); ++ validateActions(node, recipe, cooking, reinjected); ++ final boolean mappedSlot = node.properties().has("inventory_actions") && node.properties().has("inventory_slot"); ++ final boolean command = node.type().equals("button") && node.string("inventory_command", "").equals("close"); ++ 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 int slot = mappedSlot ? node.properties().get("inventory_slot").getAsInt() : -1; ++ if (mappedSlot && (slot < 0 || slot >= slotCount)) + throw new IllegalArgumentException("Invalid native inventory slot"); + // A mapped control remains registered while its entrance animation has zero extent. + // Pointer regions below still require visible, non-empty geometry. -+ this.registered.add(slot); -+ if (draw.enabled() && draw.alpha() > 0 && box.visibleBounds().width() > 0 && box.visibleBounds().height() > 0) -+ regions.add(new SlotRegion(slot, box.bounds(), box.clip(), box.layer())); ++ 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), recipe, preference, box.bounds(), box.clip(), box.layer())); ++ } ++ } ++ // Later draws cover earlier controls when their authored layers are equal. ++ final var frontToBack = new ArrayList<>(regions.reversed()); ++ frontToBack.sort(Comparator.comparingDouble(ControlRegion::layer).reversed()); ++ this.controls = List.copyOf(frontToBack); ++ } ++ ++ private static Set closeInputs(final NativeUiScene.Node node) { ++ if (!node.string("inventory_command", "").equals("close") || !node.properties().has("button_mappings")) return Set.of(); ++ final Set inputs = new HashSet<>(); ++ for (final var element : node.properties().getAsJsonArray("button_mappings")) { ++ final var mapping = element.getAsJsonObject(); ++ if (mapping.has("ignored") && mapping.get("ignored").getAsBoolean()) continue; ++ if (!NativeUiScene.string(mapping, "mapping_type", "").equals("pressed")) continue; ++ if (Set.of("button.menu_exit", "button.try_menu_exit").contains(NativeUiScene.string(mapping, "to_button_id", ""))) ++ inputs.add(NativeUiScene.string(mapping, "from_button_id", "")); ++ } ++ inputs.remove(""); ++ return Set.copyOf(inputs); ++ } ++ ++ private static RecipeControl recipe(final NativeUiScene.Node node, final boolean enabled, final Map> authority) { ++ if (!node.properties().has("recipe_actions")) return null; ++ if (!node.type().equals("button") || !node.string("collection", "").equals("recipe_book") ++ || !node.properties().has("recipe_id") || node.properties().has("inventory_slot") || node.properties().has("inventory_actions")) ++ throw new IllegalArgumentException("Recipe input has no distinct authored cell"); ++ final int id = node.properties().get("recipe_id").getAsBigDecimal().intValueExact(); ++ final Map inputs = new HashMap<>(); ++ for (final var value : node.properties().getAsJsonArray("recipe_actions")) { ++ final var mapping = value.getAsJsonObject(); ++ if (mapping.has("ignored")) { ++ if (!mapping.get("ignored").isJsonPrimitive() || !mapping.get("ignored").getAsJsonPrimitive().isBoolean()) ++ throw new IllegalArgumentException("Invalid native recipe mapping state"); ++ if (mapping.get("ignored").getAsBoolean()) continue; ++ } ++ if (!RECIPE_MAPPING_FIELDS.containsAll(mapping.keySet()) ++ || !mappingString(mapping, "from_button_id") || !mappingString(mapping, "to_button_id") || !mappingString(mapping, "mapping_type") ++ || mapping.has("consume_event") && (!mapping.get("consume_event").isJsonPrimitive() ++ || !mapping.get("consume_event").getAsJsonPrimitive().isBoolean())) ++ throw new IllegalArgumentException("Unsupported native recipe mapping attributes"); ++ final var mode = java.util.Arrays.stream(NativeCookingAction.Mode.values()) ++ .filter(candidate -> candidate.button().equals(NativeUiScene.string(mapping, "to_button_id", ""))).findFirst().orElseThrow(); ++ final String source = NativeUiScene.string(mapping, "from_button_id", ""); ++ if (source.isEmpty() || !NativeUiScene.string(mapping, "mapping_type", "").equals("pressed") ++ || mapping.has("consume_event") && !mapping.get("consume_event").getAsBoolean() ++ || enabled && !authority.getOrDefault(id, Set.of()).contains(mode.button()) ++ || closeInputs(node).contains(source) ++ || !node.properties().getAsJsonArray("button_mappings").contains(mapping)) ++ throw new IllegalArgumentException("Unsupported or unauthorized native recipe mapping"); ++ final var previous = inputs.putIfAbsent(source, mode); ++ if (previous != null && previous != mode) throw new IllegalArgumentException("Ambiguous native recipe input"); + } -+ regions.sort(Comparator.comparingDouble(SlotRegion::layer).reversed()); -+ this.slots = List.copyOf(regions); ++ if (id < 0 || inputs.keySet().stream().noneMatch(Set.of("button.menu_select", "button.menu_secondary_select")::contains)) ++ throw new IllegalArgumentException("Native recipe has no supported pointer input"); ++ return new RecipeControl(id, Map.copyOf(inputs)); + } + -+ private static void validateActions(final NativeUiScene.Node node) { ++ private static boolean mappingString(final JsonObject mapping, final String key) { ++ 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) { + 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 (recipe != null) recipe.inputs.values().forEach(mode -> supported.add(mode.button())); ++ if (recipe != null && cooking) supported.add("button.container_slot_hovered"); + if (node.properties().has("inventory_actions")) { + for (final var action : node.properties().getAsJsonArray("inventory_actions")) + supported.add(action.getAsJsonObject().get("to_button_id").getAsString()); @@ -1718,6 +2781,20 @@ index 0000000..d82572a + supported.add("button.menu_exit"); + supported.add("button.try_menu_exit"); + } ++ if (cooking) { ++ // These commands still use the direct pointer controller below. GLOBAL aliases ++ // 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")) ++ pointerTargets.add(NativeUiInput.symbol(target)); ++ } ++ final var input = NativeUiInput.parse(node.properties()); ++ if (input.mappings().stream().anyMatch(mapping -> pointerTargets.contains(mapping.target()) ++ && (mapping.scope() == NativeUiInput.Scope.GLOBAL || reinjected.contains(mapping.source()))) ++ || input.hoverBindings().stream().anyMatch(mapping -> pointerTargets.contains(mapping.target()) && mapping.scope() == NativeUiInput.Scope.GLOBAL)) ++ throw new IllegalArgumentException("Unsupported native reinjected pointer action"); ++ } + for (final var element : node.properties().getAsJsonArray("button_mappings")) { + final var mapping = element.getAsJsonObject(); + final var ignored = mapping.get("ignored"); @@ -1729,16 +2806,39 @@ index 0000000..d82572a + } + + int hovered(final double x, final double y) { -+ for (final var region : this.slots) if (region.contains(x, y)) return region.slot; ++ for (final var region : this.controls) if (region.contains(x, y)) return region.slot; + return -1; + } + -+ boolean contains(final int slot, final double x, final double y) { -+ for (final var region : this.slots) if (region.slot == slot && region.contains(x, y)) return true; -+ return false; ++ RecipeControl recipe(final double x, final double y) { ++ for (final var region : this.controls) if (region.contains(x, y)) return region.recipe; ++ return null; + } + -+ void requireAuthoredCoverage(final Set requiredSlots, final int slotCount) { ++ record RecipeControl(int id, Map inputs) { } ++ ++ NativeCookingPreferenceControl preference(final double x, final double y) { ++ for (final var region : this.controls) if (region.contains(x, y)) return region.preference; ++ return null; ++ } ++ ++ boolean hasPreferences() { return this.navigation || this.controls.stream().anyMatch(region -> region.preference != null); } ++ ++ boolean hasLayoutPreferences() { ++ return this.controls.stream().anyMatch(region -> region.preference != null ++ && region.preference.kind() == NativeCookingPreferenceControl.Kind.LAYOUT); ++ } ++ ++ boolean contains(final int slot, final double x, final double y) { ++ return slot >= 0 && this.hovered(x, y) == slot; ++ } ++ ++ boolean closes(final double x, final double y, final String input) { ++ for (final var region : this.controls) if (region.contains(x, y)) return region.closeInputs.contains(input); ++ return false; ++ } ++ ++ void requireAuthoredCoverage(final Set requiredSlots, final int slotCount) { + if (requiredSlots.isEmpty()) { + throw new IllegalArgumentException("Native inventory has no actionable slots"); + } @@ -1760,13 +2860,521 @@ index 0000000..d82572a + } + } + -+ record SlotRegion(int slot, NativeUiLayout.Rect bounds, NativeUiLayout.Rect clip, float layer) { ++ private record ControlRegion(int slot, Set closeInputs, 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 00000000..96ca210b +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerInput.java +@@ -0,0 +1,501 @@ ++/* ++ * 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.NativeUiButton; ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiFocus; ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiInput; ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiInputRouter; ++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.NativeUiScroll; ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiToggle; ++import java.math.BigInteger; ++import java.util.ArrayList; ++import java.util.HashMap; ++import java.util.HashSet; ++import java.util.IdentityHashMap; ++import java.util.LinkedHashMap; ++import java.util.List; ++import java.util.Map; ++import java.util.function.BiConsumer; ++import java.util.function.Consumer; ++ ++/** Retains native component state while layout and controller bindings refresh the control tree. */ ++final class NativeContainerInput { ++ private final NativeUiInputRouter> router = new NativeUiInputRouter<>(); ++ private final NativeUiFocus.History history = new NativeUiFocus.History(); ++ private final Consumer revealFocus; ++ private Map, Entry> entries = Map.of(); ++ private Map, NativeUiScene.Node> nodes = Map.of(); ++ private Map indexed = Map.of(); ++ private Map replacements = Map.of(); ++ private List frames = List.of(); ++ private NativeUiLayout.Box root; ++ private NativeUiScene.Node focused; ++ private float pointerX = -1, pointerY = -1; ++ private float inputX = -1, inputY = -1; ++ private float viewportWidth, viewportHeight; ++ private boolean initialized; ++ ++ NativeContainerInput(final Consumer revealFocus) { ++ this.revealFocus = java.util.Objects.requireNonNull(revealFocus); ++ } ++ ++ /** The installed native View retires its mappings before the first controller input. */ ++ void initialize() { ++ if (this.initialized) return; ++ for (final var entry : this.entries.values()) entry.input.invalidateMappings(); ++ this.initialized = true; ++ } ++ ++ void prepare(final NativeUiScene.Node root) { ++ final Map, Entry> current = new LinkedHashMap<>(); ++ final Map indexed = new IdentityHashMap<>(); ++ final Map replacements = new IdentityHashMap<>(); ++ final Map, NativeUiScene.Node> nodes = new HashMap<>(); ++ this.collect(root, List.of(), 0, true, current, indexed, replacements, nodes); ++ this.nodes = nodes; ++ this.entries = current; ++ this.indexed = indexed; ++ this.replacements = replacements; ++ // Every component exists before the deferred name resolver can invoke a child callback. ++ this.updateComponents(root, true, true); ++ this.router.retain(current.keySet()); ++ if (this.focused != null) this.focused = replacements.get(this.focused); ++ } ++ ++ void layout(final NativeUiLayout.Box root, final List draws, final float viewportWidth, final float viewportHeight) { ++ this.viewportWidth = viewportWidth; ++ this.viewportHeight = viewportHeight; ++ this.root = root; ++ this.geometry(root, List.of()); ++ if (!this.replacements.isEmpty()) { ++ this.history.remap(this.replacements, this.targets()); ++ this.replacements = Map.of(); ++ } ++ final List frames = new ArrayList<>(); ++ for (final var draw : draws.reversed()) { ++ final var entry = this.indexed.get(draw.box().node()); ++ if (entry != null) frames.add(new Frame(entry, draw.enabled())); ++ } ++ this.frames = List.copyOf(frames); ++ } ++ ++ private void collect(final NativeUiScene.Node node, final List parent, final int occurrence, ++ final boolean inheritedEnabled, final Map, Entry> current, ++ final Map indexed, ++ final Map replacements, final Map, NativeUiScene.Node> nodes) { ++ final List path = new ArrayList<>(parent); ++ path.add(identity(node, occurrence)); ++ final List key = List.copyOf(path); ++ nodes.put(key, node); ++ final var previousNode = this.nodes.get(key); ++ if (previousNode != null) replacements.put(previousNode, node); ++ final boolean enabled = inheritedEnabled && node.flag("enabled", true); ++ if (node.type().equals("button") || node.type().equals("toggle") || node.properties().has("button_mappings")) { ++ final var input = NativeUiInput.parse(node.properties()); ++ final var toggle = node.type().equals("toggle") ? NativeUiToggle.parse(node.properties()) : null; ++ final var button = node.type().equals("button") ? NativeUiButton.parse(node.properties()) : null; ++ final var previous = this.entries.get(key); ++ final Entry entry; ++ if (previous != null && previous.definition.equals(input) && java.util.Objects.equals(previous.toggleDefinition, toggle) ++ && java.util.Objects.equals(previous.buttonDefinition, button)) { ++ entry = previous; ++ replacements.put(previous.node, node); ++ entry.node = node; ++ } else entry = new Entry(key, node, input, toggle, button, enabled, this::visible); ++ if (toggle != null && (previousNode != node || previous != entry)) { ++ final var value = node.properties().get("toggle_state"); ++ final Boolean binding = value != null && value.isJsonPrimitive() && value.getAsJsonPrimitive().isBoolean() ? value.getAsBoolean() : null; ++ if (binding != null && !binding.equals(entry.binding)) entry.toggle.bindChecked(binding); ++ entry.binding = binding; ++ } ++ if (toggle != null) node.properties().addProperty("toggle_state", entry.toggle.checked()); ++ current.put(key, entry); ++ indexed.put(node, entry); ++ } ++ final Map occurrences = new HashMap<>(); ++ for (final var child : node.children()) { ++ final int next = occurrences.merge(identity(child, 0), 1, Integer::sum) - 1; ++ this.collect(child, key, next, enabled, current, indexed, replacements, nodes); ++ } ++ } ++ ++ private void updateComponents(final NativeUiScene.Node node, final boolean inheritedEnabled, final boolean inheritedVisible) { ++ final boolean enabled = inheritedEnabled && node.flag("enabled", true); ++ final var entry = this.indexed.get(node); ++ if (entry != null) { ++ final boolean enabledChanged = entry.enabled != enabled; ++ entry.enabled = enabled; ++ if (entry.button != null) { ++ entry.button.attach(node, enabled, entry.hovered); ++ if (entry.ownEnabled != node.flag("enabled", true)) entry.button.enabled(node.flag("enabled", true)); ++ } ++ this.visibility(entry); ++ if (entry.button != null && enabledChanged) entry.button.general(17, enabled, entry.hovered); ++ entry.ownEnabled = node.flag("enabled", true); ++ } ++ final boolean visible = inheritedVisible && node.flag("visible", true) && !node.flag("ignored", false); ++ if (entry != null) { ++ if (!visible) { ++ if (entry.visible) entry.input.invalidateMappings(); ++ if (entry.hovered) { ++ entry.hovered = false; ++ if (entry.button != null) entry.button.hover(false, enabled); ++ if (entry.toggle != null) entry.toggle.hover(false); ++ } ++ } ++ entry.visible = visible; ++ } ++ for (final var child : node.children()) this.updateComponents(child, enabled, visible); ++ } ++ ++ private void visible(final NativeUiScene.Node node, final boolean visible) { ++ node.properties().addProperty("visible", visible); ++ final var entry = this.indexed.get(node); ++ if (entry != null) this.visibility(entry); ++ } ++ ++ private void visibility(final Entry entry) { ++ final boolean visible = entry.node.flag("visible", true); ++ final boolean ignored = entry.node.flag("ignored", false); ++ if (entry.ownVisible == visible && entry.ignored == ignored) return; ++ entry.ownVisible = visible; ++ entry.ignored = ignored; ++ // UIControl notifies Input before Button. Inherited visibility and registration wait for the View update. ++ entry.input.invalidateMappings(); ++ if (entry.button != null) entry.button.visibility(visible && !ignored, entry.enabled, entry.hovered); ++ } ++ ++ private void geometry(final NativeUiLayout.Box box, final List ancestors) { ++ final var node = box.node(); ++ final var entry = this.indexed.get(node); ++ if (entry != null) entry.geometry = new NativeUiInput.Geometry(bounds(box.bounds()), ancestors); ++ final List nested = new ArrayList<>(); ++ final var track = node.scroll() ? NativeUiScroll.descendant(box, node.string("scrollbar_track", "")) : null; ++ nested.add(new NativeUiInput.Ancestor(bounds(node.scroll() ? box.scrollViewport() : box.bounds()), node.scroll() || node.flag("clips_children", false), ++ node.scroll(), track == null ? null : bounds(track.bounds()))); ++ nested.addAll(ancestors); ++ for (final var child : box.children()) this.geometry(child, List.copyOf(nested)); ++ } ++ ++ boolean hasButtonState(final NativeUiScene.Node node) { ++ final var entry = this.indexed.get(node); ++ return entry != null && entry.button != null; ++ } ++ ++ long visualRevision() { ++ long revision = 0; ++ for (final var entry : this.entries.values()) if (entry.button != null) revision += entry.button.revision(); ++ return revision; ++ } ++ ++ 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")), occurrence); ++ } ++ ++ private static NativeUiInput.Bounds bounds(final NativeUiLayout.Rect rect) { ++ return new NativeUiInput.Bounds(rect.x(), rect.y(), rect.width(), rect.height()); ++ } ++ ++ Result pointer(final double x, final double y, final int mode, final long nanos, final boolean enabled) { ++ this.inputX = (float) x; ++ this.inputY = (float) y; ++ return this.hover(x, y, mode, nanos, enabled); ++ } ++ ++ 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; ++ return this.hoverAt((float) x, (float) y, mode, nanos, enabled); ++ } ++ ++ private Result hoverAt(final float x, final float y, final int mode, final long nanos, final boolean enabled) { ++ final List events = new ArrayList<>(); ++ boolean consumed = false; ++ for (final var frame : this.frames) { ++ final var entry = frame.entry; ++ final boolean inside = entry.geometry.bounds().contains(x, y) && entry.geometry.insideClip(x, y); ++ final boolean active = frame.enabled && enabled; ++ 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.toggle != null) this.change(entry, entry.toggle.hover(entry.hovered), events); ++ for (final var button : result.hover().events()) events.add(new Event(entry.node, button, null)); ++ consumed |= result.result() == 1; ++ } ++ return this.deliver(consumed, events, mode, nanos, enabled); ++ } ++ ++ 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()); ++ return this.button(NativeUiInput.symbol(source), pressed ? 1 : 0, mode, nanos, active); ++ } ++ ++ private Result button(final int source, final int phase, final int mode, final long nanos, final boolean active) { ++ final List>> controls = new ArrayList<>(); ++ final List events = new ArrayList<>(); ++ final var registered = new HashSet(); ++ for (final var frame : this.frames) { ++ final var entry = frame.entry; ++ registered.add(entry); ++ controls.add(this.control(entry, frame.enabled && active, mode, nanos, events, true)); ++ } ++ // Native captures own weak control references independently of normal input registration. ++ for (final var entry : this.entries.values()) if (!registered.contains(entry)) ++ controls.add(this.control(entry, entry.enabled && active, mode, nanos, events, false)); ++ final var result = this.router.dispatch(source, phase, controls); ++ return this.deliver(result.consumed(), events, mode, nanos, active); ++ } ++ ++ private NativeUiInputRouter.Control> control(final Entry entry, final boolean enabled, final int mode, ++ final long nanos, final List events, final boolean registered) { ++ return new NativeUiInputRouter.Control<>(entry.key, entry.input, () -> this.context(entry, enabled, mode, nanos), ++ event -> this.button(entry, event, enabled, events), registered); ++ } ++ ++ private NativeUiInput.Context context(final Entry entry, final boolean enabled, final int mode, final long nanos) { ++ // FocusManager ownership does not set the edit-box selection flag. ++ final int flags = (enabled ? 0x60 : 0) | (entry.ownVisible ? 4 : 0) | (entry.ignored ? 0 : 8) ++ | (entry.visible ? 0x10 : 0) | (entry.hovered ? 2 : 0); ++ final int mask = entry.buttonDefinition == null ? 0 : entry.buttonDefinition.handlerMask(enabled); ++ return new NativeUiInput.Context(flags, mode, this.inputX, this.inputY, ++ entry.geometry.bounds().contains(this.inputX, this.inputY), entry.geometry.insideClip(this.inputX, this.inputY), ++ entry.geometry.insideAlternate(flags, this.inputX, this.inputY), false, nanos, mask); ++ } ++ ++ 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.toggle != null) this.change(entry, entry.toggle.button(button, enabled), events); ++ events.add(new Event(entry.node, button, null)); ++ } ++ ++ private void change(final Entry entry, final NativeUiToggle.Change change, final List events) { ++ if (change == null) return; ++ for (final var sibling : this.entries.values()) { ++ if (sibling.toggle == null) continue; ++ sibling.toggle.sibling(change); ++ sibling.node.properties().addProperty("toggle_state", sibling.toggle.checked()); ++ } ++ events.add(new Event(entry.node, null, change)); ++ } ++ ++ private Result deliver(final boolean consumed, final List queued, final int mode, final long nanos, final boolean active) { ++ final List nested = new ArrayList<>(); ++ final List forwarded = new ArrayList<>(); ++ for (final var event : queued) { ++ if (event.button != null) { ++ if (event.button.scope() == NativeUiInput.Scope.GLOBAL) ++ nested.addAll(this.button(event.button.target(), event.button.previous(), mode, nanos, active).events()); ++ else if (event.button.scope() == NativeUiInput.Scope.VIEW) { ++ final var direction = direction(event.button.target()); ++ if (active && event.button.transition() && direction != null) nested.addAll(this.navigate(direction, mode, nanos)); ++ final var right = tabDirection(event.button.target()); ++ if (active && event.button.transition() && right != null) nested.addAll(this.navigateTab(right, mode, nanos)); ++ continue; ++ } ++ } ++ forwarded.add(event); ++ } ++ // ScreenView applies VIEW actions before delivering its queued controller events. ++ // Focus changes recursively deliver hover events before that outer queue. ++ nested.addAll(forwarded); ++ return new Result(consumed, List.copyOf(nested)); ++ } ++ ++ 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 (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) { ++ this.focused = target.box().node(); ++ this.history.select(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); ++ } ++ ++ static boolean supportsViewTarget(final int target) { ++ return target == NativeUiInput.symbol("button.menu_select") || direction(target) != null || tabDirection(target) != null; ++ } ++ ++ private static Boolean tabDirection(final int target) { ++ if (target == NativeUiInput.symbol("button.menu_tab_left")) return false; ++ if (target == NativeUiInput.symbol("button.menu_tab_right")) return true; ++ return null; ++ } ++ ++ private List navigationTabs() { ++ final Map indexed = new IdentityHashMap<>(); ++ for (final var entry : this.entries.values()) indexed.put(entry.node, entry); ++ final List tabs = new ArrayList<>(); ++ if (this.root != null) navigationTabs(this.root, indexed, tabs); ++ return tabs; ++ } ++ ++ private static void navigationTabs(final NativeUiLayout.Box box, final Map indexed, final List tabs) { ++ final var node = box.node(); ++ if (!node.flag("visible", true) || node.flag("ignored", false) || !node.flag("enabled", true)) return; ++ final var entry = indexed.get(node); ++ if (entry != null && entry.toggle != null && entry.toggleDefinition.group() == NativeUiInput.symbol("navigation_tab")) tabs.add(entry); ++ for (final var child : box.children()) navigationTabs(child, indexed, tabs); ++ } ++ ++ private List navigateTab(final boolean right, final int mode, final long nanos) { ++ final var tabs = this.navigationTabs(); ++ if (tabs.isEmpty()) return List.of(); ++ int current = 0, minimum = Integer.MAX_VALUE, maximum = Integer.MIN_VALUE; ++ for (final var entry : tabs) if (entry.toggle.checked()) current = entry.toggleDefinition.index(0); ++ int selected = right ? Integer.MAX_VALUE : Integer.MIN_VALUE; ++ boolean found = false; ++ for (final var entry : tabs) { ++ final int index = entry.toggleDefinition.index(0); ++ minimum = Math.min(minimum, index); ++ maximum = Math.max(maximum, index); ++ if (right ? current < index && index < selected : selected < index && index < current) { ++ selected = index; ++ found = true; ++ } ++ } ++ if (!found) { ++ if ((right ? maximum : minimum) != current) return List.of(); ++ selected = right ? minimum : maximum; ++ } ++ if (selected == -1) return List.of(); ++ Entry previous = null, target = null; ++ for (final var entry : tabs) { ++ final boolean checked = entry.toggleDefinition.index(0) == selected; ++ if (checked) target = entry; ++ else if (entry.toggle.checked()) previous = entry; ++ entry.toggle.bindChecked(checked); ++ entry.node.properties().addProperty("toggle_state", checked); ++ } ++ if (target == null) return List.of(); ++ final List events = new ArrayList<>(); ++ 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); ++ if (revealed != null) { ++ final var bounds = revealed.box().bounds(); ++ // A forced tab focus refresh delivers the whole hover queue before its group change, ++ // using the revealed center without replacing either stored View pointer. ++ events.addAll(this.hoverAt(bounds.x() + bounds.width() * .5F, bounds.y() + bounds.height() * .5F, mode, nanos, true).events()); ++ } ++ events.add(new Event(target.node, null, new NativeUiToggle.Change(target.toggleDefinition.group(), selected, true, false, false))); ++ return List.copyOf(events); ++ } ++ events.add(new Event(target.node, null, new NativeUiToggle.Change(target.toggleDefinition.group(), selected, true, false, false))); ++ final List hover = new ArrayList<>(); ++ if (previous != null) this.tabHover(previous, true, hover); ++ this.tabHover(target, false, hover); ++ // View delivers the group change before the old/new tab hover queue. ++ // These control-local pointer calls do not replace the View's logical or physical pointer. ++ events.addAll(this.deliver(false, hover, mode, nanos, true).events()); ++ return List.copyOf(events); ++ } ++ ++ private void tabHover(final Entry entry, final boolean forcedOut, final List events) { ++ final var bounds = entry.geometry.bounds(); ++ final float x = bounds.x() + bounds.width() * .5F, y = bounds.y() + bounds.height() * .5F; ++ final boolean enabled = this.frames.stream().filter(frame -> frame.entry == entry).allMatch(Frame::enabled); ++ final var result = entry.input.pointer(entry.hovered, bounds.contains(x, y) && entry.geometry.insideClip(x, y), forcedOut || !enabled, 0); ++ entry.hovered = result.hover().hovered(); ++ this.change(entry, entry.toggle.hover(entry.hovered), events); ++ for (final var button : result.hover().events()) events.add(new Event(entry.node, button, null)); ++ } ++ ++ private static NativeUiFocus.Direction direction(final int target) { ++ if (target == NativeUiInput.symbol("button.menu_up")) return NativeUiFocus.Direction.UP; ++ if (target == NativeUiInput.symbol("button.menu_down")) return NativeUiFocus.Direction.DOWN; ++ if (target == NativeUiInput.symbol("button.menu_left")) return NativeUiFocus.Direction.LEFT; ++ if (target == NativeUiInput.symbol("button.menu_right")) return NativeUiFocus.Direction.RIGHT; ++ return null; ++ } ++ ++ private List targets() { ++ return this.root == null ? List.of() : NativeUiFocus.targets(this.root, _ -> true); ++ } ++ ++ 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)); } ++ ++ record Event(NativeUiScene.Node node, NativeUiInput.ButtonEvent button, NativeUiToggle.Change change) { ++ int collectionIndex() { ++ final var value = this.node.properties().get("collection_index"); ++ if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber()) return -1; ++ final String encoded = value.getAsString(); ++ if (encoded.indexOf('.') >= 0 || encoded.indexOf('e') >= 0 || encoded.indexOf('E') >= 0) return -1; ++ try { ++ final var integer = new BigInteger(encoded); ++ return integer.bitLength() <= (integer.signum() < 0 ? 63 : 64) ? integer.intValue() : -1; ++ } catch (final NumberFormatException invalid) { return -1; } ++ } ++ } ++ record Result(boolean consumed, List events) {} ++ private record Identity(String name, String type, String slot, String collection, String index, int occurrence) {} ++ private record Frame(Entry entry, boolean enabled) {} ++ ++ private static final class Entry { ++ private final List key; ++ private NativeUiScene.Node node; ++ private final NativeUiInput definition; ++ private final NativeUiToggle toggleDefinition; ++ private final NativeUiButton buttonDefinition; ++ private final NativeUiInput.State input; ++ private final NativeUiToggle.State toggle; ++ private final NativeUiButton.State button; ++ private boolean ownEnabled; ++ private boolean ownVisible; ++ private boolean ignored; ++ private boolean visible = true; ++ private boolean enabled; ++ private NativeUiInput.Geometry geometry; ++ private boolean hovered; ++ private Boolean binding; ++ ++ private Entry(final List key, final NativeUiScene.Node node, final NativeUiInput input, final NativeUiToggle toggle, ++ final NativeUiButton button, final boolean enabled, final BiConsumer visibility) { ++ this.key = key; ++ this.node = node; ++ this.definition = input; ++ this.toggleDefinition = toggle; ++ this.buttonDefinition = button; ++ this.ownEnabled = node.flag("enabled", true); ++ this.ownVisible = node.flag("visible", true); ++ this.ignored = node.flag("ignored", false); ++ this.enabled = enabled; ++ final var options = input.options(); ++ if (options.wildcard() && options.wildcardScope() == NativeUiInput.Scope.GLOBAL) ++ throw new IllegalArgumentException("Unsupported recursive native global wildcard"); ++ if (options.wildcard() && options.wildcardScope() == NativeUiInput.Scope.VIEW ++ || input.mappings().stream().anyMatch(mapping -> mapping.scope() == NativeUiInput.Scope.VIEW ++ && !supportsViewTarget(mapping.target())) ++ || input.hoverBindings().stream().anyMatch(mapping -> mapping.scope() == NativeUiInput.Scope.VIEW ++ && !supportsViewTarget(mapping.target()))) ++ throw new IllegalArgumentException("Unsupported native view-owned input"); ++ if (options.modal() || options.inlineModal() || options.alwaysHandleControllerDirection() ++ || options.preventTouchInput() || options.gamepadDeflectionMode()) ++ throw new IllegalArgumentException("Unsupported native input screen dispatch"); ++ this.input = new NativeUiInput.State(input); ++ if (toggle != null && toggle.forcedIndex() < 0 && !node.string("toggle_grid_collection_name", "").isEmpty()) ++ throw new IllegalArgumentException("Unsupported native toggle collection index"); ++ this.toggle = toggle == null ? null : new NativeUiToggle.State(toggle, 0); ++ this.button = button == null ? null : new NativeUiButton.State(button, visibility); ++ } ++ } ++} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerPayload.java new file mode 100644 -index 0000000..97439d3 +index 00000000..97439d37 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerPayload.java @@ -0,0 +1,22 @@ @@ -1794,7 +3402,7 @@ index 0000000..97439d3 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerPending.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerPending.java new file mode 100644 -index 0000000..3408df8 +index 00000000..3408df8e --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerPending.java @@ -0,0 +1,65 @@ @@ -1865,10 +3473,10 @@ index 0000000..3408df8 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerPresentation.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerPresentation.java new file mode 100644 -index 0000000..cb75023 +index 00000000..fa3b4607 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerPresentation.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 @@ -1880,6 +3488,7 @@ index 0000000..cb75023 +import java.util.List; +import java.util.Map; +import net.raphimc.viabedrock.api.resourcepack.ui.ContainerUi; ++import net.raphimc.viabedrock.api.resourcepack.ui.CookingUi; + +final class NativeContainerPresentation implements NativeInventoryPresentation { + private final ContainerUi program; @@ -1910,12 +3519,12 @@ index 0000000..cb75023 + final Map cursor, final Boolean showTags) { + final var scene = this.program.resolve(new ContainerUi.MenuView(menuId, this.kind, title, slots, cursor, this.data)); + if (scene == null) throw new IllegalArgumentException("Unresolved native inventory presentation"); -+ return new Frame(scene, this.kind.containerSlots(), Map.of()); ++ return new Frame(scene, this.kind.containerSlots(), Map.of(), new CookingUi.RecipeActionScope(Map.of())); + } +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerPresentationController.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerPresentationController.java new file mode 100644 -index 0000000..219a21c +index 00000000..219a21cf --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerPresentationController.java @@ -0,0 +1,95 @@ @@ -2016,10 +3625,10 @@ index 0000000..219a21c +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerScroll.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerScroll.java new file mode 100644 -index 0000000..d9c2763 +index 00000000..110ebd11 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerScroll.java -@@ -0,0 +1,105 @@ +@@ -0,0 +1,143 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -2093,6 +3702,44 @@ index 0000000..d9c2763 + return true; + } + ++ boolean reveal(final NativeUiLayout.Box box, final NativeUiScene.Node selected) { ++ this.animationTarget = null; ++ this.revealPath(box, selected); ++ return this.animationTarget != null; ++ } ++ ++ private boolean revealPath(final NativeUiLayout.Box box, final NativeUiScene.Node selected) { ++ if (box.node() == selected) return true; ++ for (final var child : box.children()) { ++ if (!this.revealPath(child, selected)) continue; ++ if (box.node().scroll()) { ++ final var control = findNode(box, selected); ++ final var viewport = box.scrollViewport().intersect(box.clip()); ++ final boolean horizontal = NativeUiScroll.horizontal(box); ++ final float start = horizontal ? control.bounds().x() : control.bounds().y(); ++ final float extent = horizontal ? control.bounds().width() : control.bounds().height(); ++ final float viewportStart = horizontal ? viewport.x() : viewport.y(); ++ final float viewportExtent = horizontal ? viewport.width() : viewport.height(); ++ final float delta = start < viewportStart ? start - viewportStart : Math.max(0, start + extent - viewportStart - viewportExtent); ++ if (delta != 0) { ++ this.animationTarget = box.node(); ++ this.set(box, this.offset(box) + delta); ++ } ++ } ++ return true; ++ } ++ return false; ++ } ++ ++ private static NativeUiLayout.Box findNode(final NativeUiLayout.Box box, final NativeUiScene.Node node) { ++ if (box.node() == node) return box; ++ for (final var child : box.children()) { ++ final var result = findNode(child, node); ++ if (result != null) return result; ++ } ++ return null; ++ } ++ + boolean release() { + if (this.dragging == null) return false; + this.dragging = null; @@ -2127,7 +3774,7 @@ index 0000000..d9c2763 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerSession.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerSession.java new file mode 100644 -index 0000000..eafe3bb +index 00000000..eafe3bbe --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerSession.java @@ -0,0 +1,37 @@ @@ -2170,7 +3817,7 @@ index 0000000..eafe3bb +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerStatusPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerStatusPayload.java new file mode 100644 -index 0000000..3da9f0f +index 00000000..3da9f0f8 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerStatusPayload.java @@ -0,0 +1,29 @@ @@ -2205,7 +3852,7 @@ index 0000000..3da9f0f +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerWorkstationsPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerWorkstationsPayload.java new file mode 100644 -index 0000000..4de3072 +index 00000000..4de30724 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerWorkstationsPayload.java @@ -0,0 +1,30 @@ @@ -2239,12 +3886,12 @@ index 0000000..4de3072 + return TYPE; + } +} -diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingPayload.java +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingActionPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingActionPayload.java new file mode 100644 -index 0000000..f7159b9 +index 00000000..0c19e061 --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingPayload.java -@@ -0,0 +1,23 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingActionPayload.java +@@ -0,0 +1,22 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -2256,24 +3903,51 @@ index 0000000..f7159b9 +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.NativeCookingMessage; ++import net.raphimc.viabedrock.api.model.NativeCookingAction; + -+public record NativeCookingPayload(NativeCookingMessage message) implements CustomPacketPayload { -+ public static final Type TYPE = new Type<>(Identifier.parse(NativeCookingMessage.CHANNEL)); -+ public static final StreamCodec CODEC = CustomPacketPayload.codec( -+ (payload, buffer) -> NativeCookingMessage.TYPE.write(buffer, payload.message), -+ buffer -> new NativeCookingPayload(NativeCookingMessage.TYPE.read(buffer))); ++public record NativeCookingActionPayload(NativeCookingAction action) implements CustomPacketPayload { ++ public static final Type TYPE = new Type<>(Identifier.parse(NativeCookingAction.CHANNEL)); ++ public static final StreamCodec CODEC = CustomPacketPayload.codec( ++ (payload, buffer) -> NativeCookingAction.TYPE.write(buffer, payload.action), ++ buffer -> new NativeCookingActionPayload(NativeCookingAction.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/NativeCookingActionResultPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingActionResultPayload.java +new file mode 100644 +index 00000000..00bb0e9b +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingActionResultPayload.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; + -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 ++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.NativeCookingActionResult; ++ ++public record NativeCookingActionResultPayload(NativeCookingActionResult result) implements CustomPacketPayload { ++ public static final Type TYPE = new Type<>(Identifier.parse(NativeCookingActionResult.CHANNEL)); ++ public static final StreamCodec CODEC = CustomPacketPayload.codec( ++ (payload, buffer) -> NativeCookingActionResult.TYPE.write(buffer, payload.result), ++ buffer -> new NativeCookingActionResultPayload(NativeCookingActionResult.TYPE.read(buffer))); ++ ++ @Override ++ public Type type() { return TYPE; } ++} +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..fe2a60b +index 00000000..62b9e4da --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingPresentation.java -@@ -0,0 +1,86 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingActions.java +@@ -0,0 +1,158 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -2281,91 +3955,163 @@ index 0000000..fe2a60b + */ +package com.viaversion.viafabricplus.bedrock.screen.container; + -+import com.viaversion.viaversion.libs.gson.JsonElement; -+import com.viaversion.viaversion.libs.gson.JsonParser; +import java.util.HashMap; -+import java.util.List; ++import java.util.HashSet; +import java.util.Map; -+import net.minecraft.world.item.ItemStack; ++import java.util.Objects; ++import java.util.Set; ++import java.util.function.Consumer; ++import java.util.function.LongSupplier; ++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.NativeCookingProgress; -+import net.raphimc.viabedrock.api.resourcepack.ui.ContainerUi; +import net.raphimc.viabedrock.api.resourcepack.ui.CookingUi; + -+/** Authority and progress stay server-owned; genuine client stacks supply rendering and hover data. */ -+final class NativeCookingPresentation implements NativeInventoryPresentation { ++/** One menu's input phases retain their original authority while predicted frames advance. */ ++final class NativeCookingActions { ++ private static final long RESPONSE_TIMEOUT_NANOS = 10_000_000_000L; ++ private final LongSupplier clock; ++ private final Map pending = new HashMap<>(); ++ private final Set emitted = new HashSet<>(); + private NativeCookingMessage message; -+ private CookingUi program; -+ private CookingUi.MenuView view; -+ private Map recipes; -+ private CookingUi.Progress progress; -+ private long dataRevision = -1; -+ private long revision; ++ private Map> commands = Map.of(); ++ private NativeCookingAction gesture; ++ private NativeCookingAction releaseSource; ++ private Set gestureCommands = Set.of(); ++ private long requestSequence; ++ private long invalidatedUpdate; ++ private boolean retired; ++ private boolean dispatching; ++ ++ NativeCookingActions(final NativeCookingMessage message, final LongSupplier clock) { ++ this.message = Objects.requireNonNull(message); ++ this.clock = Objects.requireNonNull(clock); ++ } ++ ++ void update(final NativeCookingMessage message) { ++ if (this.retired || message.menuId() != this.message.menuId() || message.sequence() != this.message.sequence() ++ || message.updateSequence() <= this.message.updateSequence()) ++ throw new IllegalArgumentException("Cooking action authority belongs to another frame"); ++ this.message = message; ++ this.commands = Map.of(); ++ } + -+ NativeCookingPresentation(final NativeCookingMessage message, final Map recipes) { -+ this.replace(message, recipes); ++ void presented(final NativeCookingMessage message, final CookingUi.RecipeActionScope scope) { ++ if (this.retired || message != this.message) ++ throw new IllegalArgumentException("Cooking actions require the displayed frame"); ++ this.commands = scope.actions(); + } + -+ boolean update(final NativeCookingMessage message, final Map recipes) { -+ if (message.menuId() != this.message.menuId() || message.sequence() != this.message.sequence() -+ || message.updateSequence() <= this.message.updateSequence() -+ || message.view().family() != this.view.family()) return false; -+ this.replace(message, recipes); ++ 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; ++ 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; ++ if (phase == 0 || startsFocus) { ++ if (this.gesture != null && (!focused || mode != NativeCookingAction.Mode.PRIMARY) || !this.pending.isEmpty() && !focused ++ || this.message.updateSequence() <= this.invalidatedUpdate ++ || !this.commands.getOrDefault(recipe, Set.of()).contains(mode.button())) return false; ++ original = null; ++ } else { ++ original = this.gesture; ++ if (original == null || original.eventPhase() == 1 && mode != NativeCookingAction.Mode.PRIMARY ++ || original.recipeId() != recipe || !this.gestureCommands.contains(mode.button()) ++ || this.emitted.contains(new Event(mode, phase))) return false; ++ } ++ if (this.requestSequence == Long.MAX_VALUE) { ++ this.retire(); ++ return false; ++ } ++ Objects.requireNonNull(sender); ++ final var action = original == null ++ ? new NativeCookingAction(this.message.menuId(), this.message.sequence(), this.message.updateSequence(), ++ this.message.menuRevision(), this.message.inventoryRevision(), this.message.cursorRevision(), recipe, mode, controllerMode, phase, ++this.requestSequence) ++ : new NativeCookingAction(original.menuId(), original.sequence(), original.updateSequence(), original.menuRevision(), ++ original.inventoryRevision(), original.cursorRevision(), recipe, mode, original.controllerInputMode(), phase, ++this.requestSequence); ++ // A local bridge can return a snapshot and acknowledgment before send() returns. ++ this.pending.put(action.requestSequence(), new Pending(action, this.clock.getAsLong())); ++ if (original == null) { ++ this.gesture = action; ++ this.emitted.clear(); ++ if (mode == NativeCookingAction.Mode.PRIMARY) this.releaseSource = action; ++ this.gestureCommands = this.commands.get(recipe); ++ } ++ this.emitted.add(new Event(mode, phase)); ++ if (phase == 2 && (mode != NativeCookingAction.Mode.TERTIARY || this.gesture.mode() != NativeCookingAction.Mode.PRIMARY)) ++ this.clearGesture(); ++ this.dispatching = true; ++ try { ++ sender.accept(action); ++ return true; ++ } catch (final RuntimeException failedTransport) { ++ // The packet may already have reached core. Never retry an ambiguous send. ++ this.retire(); ++ throw failedTransport; ++ } finally { ++ this.dispatching = false; ++ } ++ } ++ ++ boolean result(final NativeCookingActionResult result) { ++ if (this.expired()) { ++ this.retire(); ++ return false; ++ } ++ final var action = result.action(); ++ final var pending = this.pending.get(action.requestSequence()); ++ if (pending == null || !pending.action.equals(action)) return false; ++ this.pending.remove(action.requestSequence()); ++ if (this.releaseSource != null && this.releaseSource.requestSequence() <= action.requestSequence() ++ && ((action.eventPhase() == 0 || action.equals(this.releaseSource)) && !result.accepted() ++ || action.eventPhase() == 2 && result.accepted() && action.mode() != NativeCookingAction.Mode.TERTIARY)) this.releaseSource = null; ++ if (!result.accepted() && (this.gesture == null || action.requestSequence() >= this.gesture.requestSequence())) this.clearGesture(); ++ if (!result.accepted() || !result.current()) ++ this.invalidatedUpdate = Math.max(this.invalidatedUpdate, action.updateSequence()); + return true; + } + -+ private void replace(final NativeCookingMessage message, final Map recipes) { -+ if (!recipes.keySet().equals(message.recipeItems().keySet()) || recipes.values().stream().anyMatch(ItemStack::isEmpty)) -+ throw new IllegalArgumentException("Cooking previews differ from the authoritative catalog"); -+ final var program = CookingUi.fromProgram(JsonParser.parseString(message.program()).getAsJsonObject()); -+ final var view = message.view(); -+ final Map copies = new HashMap<>(); -+ recipes.forEach((id, item) -> copies.put(id, item.copy())); -+ this.message = message; -+ this.program = program; -+ this.view = view; -+ this.recipes = Map.copyOf(copies); -+ this.progress = view.progress(); -+ this.dataRevision = -1; -+ this.revision++; ++ boolean expired() { ++ final long now = this.clock.getAsLong(); ++ return this.retired || this.pending.values().stream().anyMatch(pending -> now - pending.sentAt >= RESPONSE_TIMEOUT_NANOS); + } + -+ boolean update(final NativeCookingProgress update) { -+ if (update.menuId() != this.message.menuId() || update.sequence() != this.message.sequence() -+ || update.updateSequence() != this.message.updateSequence() || update.dataRevision() <= this.dataRevision) return false; -+ this.progress = update.progress(); -+ this.dataRevision = update.dataRevision(); -+ this.revision++; ++ boolean idle() { return !this.expired() && this.gesture == null && this.pending.isEmpty(); } ++ ++ /** Terminal release is safe after authority retires: phase two never repeats a transfer. */ ++ boolean cancel(final Consumer sender) { ++ this.retire(); ++ final var original = this.releaseSource; ++ this.releaseSource = null; ++ if (original == null || this.requestSequence == Long.MAX_VALUE) return false; ++ final var release = new NativeCookingAction(original.menuId(), original.sequence(), original.updateSequence(), ++ original.menuRevision(), original.inventoryRevision(), original.cursorRevision(), original.recipeId(), ++ NativeCookingAction.Mode.PRIMARY, original.controllerInputMode(), 2, ++this.requestSequence); ++ sender.accept(release); + return true; + } + -+ NativeCookingMessage message() { return this.message; } -+ -+ @Override public long revision() { return this.revision; } ++ void retire() { ++ this.retired = true; ++ this.pending.clear(); ++ this.commands = Map.of(); ++ this.clearGesture(); ++ } + -+ @Override -+ public Frame resolve(final int menuId, final String title, final List slots, -+ final Map cursor, final Boolean showTags) { -+ if (menuId != this.message.menuId()) throw new IllegalArgumentException("Cooking view belongs to another menu"); -+ 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)); -+ values.putAll(recipe.presentation()); -+ 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(), source.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)); -+ CookingUi.actionScope(scene, book); -+ return new Frame(scene, 3, this.recipes); ++ private void clearGesture() { ++ this.gesture = null; ++ this.gestureCommands = Set.of(); ++ this.emitted.clear(); + } ++ ++ private record Event(NativeCookingAction.Mode mode, int phase) { } ++ private record Pending(NativeCookingAction action, long sentAt) { } +} -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 +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..cb08073 +index 00000000..027b2586 --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingPresentationController.java -@@ -0,0 +1,81 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingNavigation.java +@@ -0,0 +1,52 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -2373,26 +4119,334 @@ index 0000000..cb08073 + */ +package com.viaversion.viafabricplus.bedrock.screen.container; + -+import com.viaversion.viaversion.api.minecraft.item.Item; -+import java.util.HashMap; -+import java.util.Map; -+import java.util.function.Function; -+import net.minecraft.world.item.ItemStack; -+import net.raphimc.viabedrock.api.model.NativeCookingMessage; -+import net.raphimc.viabedrock.api.model.NativeCookingProgress; ++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; + -+/** Never retains a presentation for a menu that has not opened or has already closed. */ -+final class NativeCookingPresentationController { -+ private final NativeContainerSession session = new NativeContainerSession<>(); -+ private C owner; -+ private NativeCookingPresentation presentation; ++/** 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"); + -+ void begin(final C owner) { -+ if (owner == null) throw new IllegalArgumentException("Cooking play session is required"); -+ if (this.owner == owner) return; ++ private NativeCookingNavigation() { } ++ ++ static boolean supports(final int target) { ++ return target == NativeUiInput.symbol("button.left_panel_tab_decrement") ++ || target == NativeUiInput.symbol("button.left_panel_tab_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"); ++ 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; ++ 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())); ++ } ++ ++ 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); ++ 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); ++ return new Transition(next.refresh() ? new FurnaceOptions(current.furnaceType(), next.tab(), current.filtering(), current.layout()) : current, ++ next.refresh()); ++ } ++ ++ record Transition(FurnaceOptions options, boolean refresh) { } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingPayload.java +new file mode 100644 +index 00000000..f7159b9c +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingPayload.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.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.NativeCookingMessage; ++ ++public record NativeCookingPayload(NativeCookingMessage message) implements CustomPacketPayload { ++ public static final Type TYPE = new Type<>(Identifier.parse(NativeCookingMessage.CHANNEL)); ++ public static final StreamCodec CODEC = CustomPacketPayload.codec( ++ (payload, buffer) -> NativeCookingMessage.TYPE.write(buffer, payload.message), ++ buffer -> new NativeCookingPayload(NativeCookingMessage.TYPE.read(buffer))); ++ ++ @Override ++ public Type type() { return TYPE; } ++} ++ +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingPreferenceControl.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingPreferenceControl.java +new file mode 100644 +index 00000000..636f3bd5 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingPreferenceControl.java +@@ -0,0 +1,63 @@ ++/* ++ * 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 com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiToggle; ++import net.raphimc.viabedrock.api.resourcepack.ui.CookingUi; ++import net.raphimc.viabedrock.protocol.model.FurnaceOptions; ++ ++/** Controller preferences consume emitted native toggle changes, independently of the original button event. */ ++record NativeCookingPreferenceControl(Kind kind, int index) { ++ static NativeCookingPreferenceControl parse(final NativeUiScene.Node node) { ++ if (!node.type().equals("toggle")) return null; ++ final var state = node.properties().get("toggle_state"); ++ if (state == null || !state.isJsonPrimitive() || !state.getAsJsonPrimitive().isBoolean() ++ || node.properties().has("inventory_slot") || node.properties().has("inventory_actions") ++ || node.properties().has("recipe_actions") || node.properties().has("inventory_command")) ++ throw new IllegalArgumentException("Unbound native cooking preference"); ++ final Kind kind = switch (node.string("toggle_name", "")) { ++ case "navigation_tab" -> Kind.TAB; ++ case "toggle.enable_filtering" -> Kind.FILTER; ++ case "layout_toggle" -> Kind.LAYOUT; ++ default -> throw new IllegalArgumentException("Unsupported native cooking toggle group"); ++ }; ++ final var toggle = NativeUiToggle.parse(node.properties()); ++ if (kind != Kind.FILTER && (toggle.forcedIndex() < 1 || toggle.forcedIndex() > (kind == Kind.TAB ? 5 : 2))) ++ throw new IllegalArgumentException("Native preference has no authored selection index"); ++ final var input = NativeUiInput.parse(node.properties()); ++ if (input.options().wildcard() && input.options().wildcardScope() == NativeUiInput.Scope.GLOBAL) ++ throw new IllegalArgumentException("Unsupported recursive native global wildcard"); ++ return new NativeCookingPreferenceControl(kind, kind == Kind.FILTER ? -1 : toggle.forcedIndex()); ++ } ++ ++ FurnaceOptions options(final FurnaceOptions current, final boolean forcedLayout, final NativeUiToggle.Change change) { ++ final String group = switch (this.kind) { ++ case TAB -> "navigation_tab"; ++ case FILTER -> "toggle.enable_filtering"; ++ case LAYOUT -> "layout_toggle"; ++ }; ++ if (change.group() != NativeUiInput.symbol(group) || this.kind != Kind.FILTER && change.index() != this.index) ++ throw new IllegalArgumentException("Native preference event differs from its control"); ++ if (this.kind == Kind.LAYOUT && forcedLayout) ++ throw new IllegalArgumentException("Unsupported forced native layout preference"); ++ return new FurnaceOptions(current.furnaceType(), this.kind == Kind.TAB ? change.index() : current.leftTab(), ++ this.kind == Kind.FILTER ? change.checked() : current.filtering(), ++ this.kind == Kind.LAYOUT ? change.index() : current.layout()); ++ } ++ ++ static FurnaceOptions snapshot(final CookingUi.Family family, final CookingUi.Book book) { ++ final int type = switch (family) { ++ case FURNACE -> 1; ++ case BLAST_FURNACE -> 2; ++ case SMOKER -> 3; ++ }; ++ return new FurnaceOptions(type, book.tab(), book.filtering(), book.effectiveLayout()); ++ } ++ ++ enum Kind { TAB, FILTER, LAYOUT } ++} +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 00000000..b5463e9b +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingPresentation.java +@@ -0,0 +1,135 @@ ++/* ++ * 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.JsonElement; ++import com.viaversion.viaversion.libs.gson.JsonParser; ++import java.util.HashMap; ++import java.util.List; ++import java.util.Map; ++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.NativeCookingActionResult; ++import net.raphimc.viabedrock.api.model.NativeCookingMessage; ++import net.raphimc.viabedrock.api.model.NativeCookingProgress; ++import net.raphimc.viabedrock.api.resourcepack.ui.ContainerUi; ++import net.raphimc.viabedrock.api.resourcepack.ui.CookingUi; ++ ++/** Authority and progress stay server-owned; genuine client stacks supply rendering and hover data. */ ++final class NativeCookingPresentation implements NativeInventoryPresentation { ++ private final NativeCookingActions actions; ++ private NativeCookingMessage message; ++ private CookingUi program; ++ private CookingUi.MenuView view; ++ private Map recipes; ++ private CookingUi.Progress progress; ++ private CookingUi.Selection selection; ++ private long dataRevision = -1; ++ private long revision; ++ ++ NativeCookingPresentation(final NativeCookingMessage message, final Map recipes) { ++ this(message, recipes, System::nanoTime); ++ } ++ ++ NativeCookingPresentation(final NativeCookingMessage message, final Map recipes, final LongSupplier clock) { ++ this.replace(message, recipes); ++ this.actions = new NativeCookingActions(message, clock); ++ } ++ ++ boolean update(final NativeCookingMessage message, final Map recipes) { ++ if (message.menuId() != this.message.menuId() || message.sequence() != this.message.sequence() ++ || message.updateSequence() <= this.message.updateSequence() ++ || message.view().family() != this.view.family()) return false; ++ this.replace(message, recipes); ++ this.actions.update(message); ++ return true; ++ } ++ ++ private void replace(final NativeCookingMessage message, final Map recipes) { ++ if (!recipes.keySet().equals(message.recipeItems().keySet()) || recipes.values().stream().anyMatch(ItemStack::isEmpty)) ++ throw new IllegalArgumentException("Cooking previews differ from the authoritative catalog"); ++ final var program = CookingUi.fromProgram(JsonParser.parseString(message.program()).getAsJsonObject()); ++ final var view = message.view(); ++ final Map copies = new HashMap<>(); ++ 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(); ++ this.message = message; ++ this.program = program; ++ this.view = view; ++ this.recipes = Map.copyOf(copies); ++ this.progress = view.progress(); ++ this.dataRevision = -1; ++ this.revision++; ++ } ++ ++ boolean update(final NativeCookingProgress update) { ++ if (update.menuId() != this.message.menuId() || update.sequence() != this.message.sequence() ++ || update.updateSequence() != this.message.updateSequence() || update.dataRevision() <= this.dataRevision) return false; ++ this.progress = update.progress(); ++ this.dataRevision = update.dataRevision(); ++ this.revision++; ++ return true; ++ } ++ ++ 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++; ++ 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); ++ } ++ ++ void tabChanged() { ++ this.selection = new CookingUi.Selection("", -1); ++ this.revision++; ++ } ++ ++ boolean idle() { return this.actions.idle(); } ++ ++ 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); ++ } ++ ++ boolean result(final NativeCookingActionResult result) { return this.actions.result(result); } ++ ++ boolean expired() { return this.actions.expired(); } ++ ++ void retire() { this.actions.retire(); } ++ ++ boolean cancel(final Consumer sender) { return this.actions.cancel(sender); } ++ ++ @Override public long revision() { return this.revision; } ++ ++ @Override ++ public Frame resolve(final int menuId, final String title, final List slots, ++ final Map cursor, final Boolean showTags) { ++ if (menuId != this.message.menuId()) throw new IllegalArgumentException("Cooking view belongs to another menu"); ++ 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)); ++ values.putAll(recipe.presentation()); ++ 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)); ++ final var scope = CookingUi.actionScope(scene, book); ++ this.actions.presented(this.message, scope); ++ return new Frame(scene, 3, this.recipes, scope); ++ } ++} +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 00000000..cff51369 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingPresentationController.java +@@ -0,0 +1,119 @@ ++/* ++ * 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.api.minecraft.item.Item; ++import java.util.HashMap; ++import java.util.Map; ++import java.util.function.Consumer; ++import java.util.function.Function; ++import java.util.function.LongSupplier; ++import net.minecraft.world.item.ItemStack; ++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.NativeCookingProgress; ++ ++/** Never retains a presentation for a menu that has not opened or has already closed. */ ++final class NativeCookingPresentationController { ++ private final NativeContainerSession session = new NativeContainerSession<>(); ++ private final LongSupplier clock; ++ private C owner; ++ private NativeCookingPresentation presentation; ++ private boolean capabilityAvailable; ++ ++ NativeCookingPresentationController() { this(System::nanoTime); } ++ ++ NativeCookingPresentationController(final LongSupplier clock) { this.clock = java.util.Objects.requireNonNull(clock); } ++ ++ void begin(final C owner) { ++ if (owner == null) throw new IllegalArgumentException("Cooking play session is required"); ++ if (this.owner == owner) return; ++ this.retirePresentation(); + this.session.clear(); -+ this.presentation = null; + this.owner = owner; ++ this.capabilityAvailable = false; + } + + boolean accept(final C owner, final M menu, final int currentMenuId, @@ -2409,15 +4463,15 @@ index 0000000..cb08073 + 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); -+ final var next = new NativeCookingPresentation(message, items); ++ final var next = new NativeCookingPresentation(message, items, this.clock); + if (!this.session.accept(owner, menu, currentMenuId, message.menuId(), message.sequence())) return false; ++ if (this.presentation != null) this.presentation.retire(); + this.presentation = next; + return true; + } catch (final RuntimeException unsupported) { + // An invalid newer frame cannot leave the older item authority actionable. + if (update || this.session.accept(owner, menu, currentMenuId, message.menuId(), message.sequence())) { -+ this.session.close(); -+ this.presentation = null; ++ this.retirePresentation(); + } + throw unsupported; + } @@ -2432,24 +4486,51 @@ index 0000000..cb08073 + return this.owner == owner && this.session.current(owner, menu, menuId) ? this.presentation : null; + } + ++ boolean action(final C owner, final M menu, final int menuId, final int recipe, final NativeCookingAction.Mode mode, ++ final int controllerMode, final int phase, final Consumer sender) { ++ final var current = this.current(owner, menu, menuId); ++ return current != null && current.action(recipe, mode, controllerMode, phase, sender); ++ } ++ ++ boolean result(final C owner, final M menu, final int menuId, final NativeCookingActionResult result) { ++ final var current = this.current(owner, menu, menuId); ++ return current != null && current.result(result); ++ } ++ ++ void retire(final C owner, final M menu, final int menuId) { ++ if (this.current(owner, menu, menuId) != null) this.retirePresentation(); ++ } ++ ++ void capability(final C owner, final boolean available) { ++ if (this.owner != owner) return; ++ if (this.capabilityAvailable && !available) this.retirePresentation(); ++ this.capabilityAvailable = available; ++ } ++ + void tick(final C owner, final M menu, final int menuId) { -+ if (this.owner == owner && this.current(owner, menu, menuId) == null) { -+ this.session.close(); -+ this.presentation = null; ++ if (this.owner == owner && (this.current(owner, menu, menuId) == null || this.presentation.expired())) { ++ this.retirePresentation(); + } + } + + void clear(final C owner) { + if (this.owner != owner) return; ++ this.retirePresentation(); + this.session.clear(); -+ this.presentation = null; + this.owner = null; ++ this.capabilityAvailable = false; ++ } ++ ++ private void retirePresentation() { ++ if (this.presentation != null) this.presentation.retire(); ++ this.presentation = null; ++ this.session.close(); + } +} + diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingProgressPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingProgressPayload.java new file mode 100644 -index 0000000..4a8745e +index 00000000..4a8745e5 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingProgressPayload.java @@ -0,0 +1,23 @@ @@ -2478,7 +4559,7 @@ index 0000000..4a8745e + 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 +index 00000000..8b1ea673 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeFurnaceOptionsPayload.java @@ -0,0 +1,27 @@ @@ -2511,7 +4592,7 @@ index 0000000..8b1ea67 +} 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 +index 00000000..2de05c4f --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeFurnacePreferenceState.java @@ -0,0 +1,44 @@ @@ -2561,7 +4642,7 @@ index 0000000..2de05c4 +} 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 +index 00000000..0ea51374 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryFlights.java @@ -0,0 +1,65 @@ @@ -2632,7 +4713,7 @@ index 0000000..0ea5137 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryItemOpacity.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryItemOpacity.java new file mode 100644 -index 0000000..3e77ab0 +index 00000000..3e77ab0e --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryItemOpacity.java @@ -0,0 +1,34 @@ @@ -2672,7 +4753,7 @@ index 0000000..3e77ab0 +} 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..e38b8d6 +index 00000000..e38b8d6e --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryItemPresentation.java @@ -0,0 +1,124 @@ @@ -2802,10 +4883,10 @@ index 0000000..e38b8d6 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryPresentation.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryPresentation.java new file mode 100644 -index 0000000..951a85f +index 00000000..7a680f63 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryPresentation.java -@@ -0,0 +1,27 @@ +@@ -0,0 +1,30 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -2817,8 +4898,10 @@ index 0000000..951a85f +import com.viaversion.viaversion.libs.gson.JsonObject; +import java.util.List; +import java.util.Map; ++import java.util.Objects; +import net.minecraft.world.item.ItemStack; +import net.raphimc.viabedrock.api.resourcepack.ui.ContainerUi; ++import net.raphimc.viabedrock.api.resourcepack.ui.CookingUi; + +/** Different native menu controllers share one renderer and the genuine Java inventory event path. */ +sealed interface NativeInventoryPresentation permits NativeContainerPresentation, NativeCookingPresentation { @@ -2826,16 +4909,17 @@ index 0000000..951a85f + + long revision(); + -+ record Frame(JsonObject scene, int containerSlots, Map recipes) { ++ record Frame(JsonObject scene, int containerSlots, Map recipes, CookingUi.RecipeActionScope recipeActions) { + public Frame { + if (scene == null || containerSlots < 0) throw new IllegalArgumentException("Invalid native inventory frame"); + recipes = Map.copyOf(recipes); ++ Objects.requireNonNull(recipeActions); + } + } +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryProgress.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryProgress.java new file mode 100644 -index 0000000..c655dc7 +index 00000000..c655dc74 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryProgress.java @@ -0,0 +1,70 @@ @@ -2909,36 +4993,12 @@ index 0000000..c655dc7 + } + } +} -diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json -index 1008491..f6b927d 100644 ---- a/src/main/resources/viafabricplus-bedrock.mixins.json -+++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -5,6 +5,11 @@ - "compatibilityLevel": "JAVA_25", - "priority": 1001, - "client": [ -+ "features.render.MixinNativeContainerPresentation", -+ "features.render.MixinNativeGuiItemState", -+ "features.render.MixinNativeGuiItemExtraction", -+ "features.render.MixinNativeGuiItemAtlas", -+ "features.render.MixinNativeGuiOversizedItem", - "compat.iris.MixinIrisPipelines", - "compat.sodium.MixinSodiumBlockLighting", - "compat.sodium.MixinSodiumLightData", -@@ -63,6 +68,7 @@ - "features.creative.MixinCreativeModeInventoryScreen", - "features.creative.MixinCreativeModeTab_ItemDisplayBuilder", - "features.creative.MixinCreativeModeTabs", -+ "features.inventory.MixinNativeContainerGeometry", - "features.inventory.MixinCartographyTableMenu", - "features.inventory.MixinCartographyPacketListener", - "features.inventory.MixinBedrockCartographyScreen", -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 +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/PlayerInventoryScreenClosePayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/PlayerInventoryScreenClosePayload.java new file mode 100644 -index 0000000..63cd4e6 +index 00000000..806cc978 --- /dev/null -+++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockNativeContainerScreenTest.java -@@ -0,0 +1,542 @@ ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/PlayerInventoryScreenClosePayload.java +@@ -0,0 +1,22 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -2946,47 +5006,269 @@ index 0000000..63cd4e6 + */ +package com.viaversion.viafabricplus.bedrock.screen.container; + -+import static org.junit.jupiter.api.Assertions.*; -+import static org.mockito.Mockito.*; ++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.PlayerInventoryScreenClose; + -+import com.google.gson.JsonArray; -+import com.google.gson.JsonObject; -+import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiLayout; -+import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiPresentation; -+import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiScene; ++public record PlayerInventoryScreenClosePayload(PlayerInventoryScreenClose close) implements CustomPacketPayload { ++ public static final Type TYPE = new Type<>(Identifier.parse(PlayerInventoryScreenClose.CHANNEL)); ++ public static final StreamCodec CODEC = CustomPacketPayload.codec( ++ (payload, buffer) -> PlayerInventoryScreenClose.TYPE.write(buffer, payload.close), ++ buffer -> new PlayerInventoryScreenClosePayload(PlayerInventoryScreenClose.TYPE.read(buffer))); + -+import net.minecraft.SharedConstants; -+import net.minecraft.client.Minecraft; -+import net.minecraft.client.gui.Font; -+import net.minecraft.client.gui.screens.Screen; -+import net.minecraft.client.multiplayer.ClientPacketListener; -+import net.minecraft.client.multiplayer.MultiPlayerGameMode; -+import net.minecraft.client.player.LocalPlayer; -+import net.minecraft.network.Connection; ++ @Override ++ public Type type() { return TYPE; } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/PlayerInventoryScreenLifecycle.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/PlayerInventoryScreenLifecycle.java +new file mode 100644 +index 00000000..b354ee20 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/PlayerInventoryScreenLifecycle.java +@@ -0,0 +1,70 @@ ++/* ++ * 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.function.Consumer; ++import net.raphimc.viabedrock.api.model.PlayerInventoryScreenRequest; ++ ++/** Installed-screen ownership survives presentation swaps, but never an ordinary reopen of menu zero. */ ++final class PlayerInventoryScreenLifecycle { ++ private O owner; ++ private S screen; ++ private M menu; ++ private long sequence; ++ private long opening; ++ private boolean bridged; ++ ++ void begin(final O owner) { ++ this.owner = owner; ++ this.screen = null; ++ this.menu = null; ++ // Reconfiguration replaces the play listener while the native connection survives. ++ // Keep tokens monotonic across listener replacement and physical reconnects. ++ this.opening = 0; ++ this.bridged = false; ++ } ++ ++ void installed(final O owner, final S screen, final M menu, final boolean replacing, ++ final boolean available, final Consumer send, final Runnable legacyOpen) { ++ if (owner == null || owner != this.owner || this.screen == screen && this.menu == menu) return; ++ if (replacing && this.screen != null && screen != null && this.menu == menu) { ++ this.screen = screen; ++ return; ++ } ++ final long previous = this.opening; ++ final boolean close = this.screen != null && this.bridged && available; ++ this.screen = null; ++ this.menu = null; ++ this.opening = 0; ++ this.bridged = false; ++ if (close) send.accept(new PlayerInventoryScreenRequest(previous, false)); ++ if (screen == null) return; ++ this.screen = screen; ++ this.menu = menu; ++ this.opening = this.sequence = Math.incrementExact(this.sequence); ++ this.bridged = available; ++ if (available) send.accept(new PlayerInventoryScreenRequest(this.opening, true)); ++ else legacyOpen.run(); ++ } ++ ++ S close(final O owner, final long opening) { ++ if (owner != this.owner || !this.bridged || opening != this.opening) return null; ++ final S screen = this.screen; ++ this.screen = null; ++ this.menu = null; ++ this.opening = 0; ++ this.bridged = false; ++ return screen; ++ } ++ ++ S capability(final O owner, final boolean available) { ++ return !available && this.bridged ? this.close(owner, this.opening) : null; ++ } ++ ++ void clear(final O owner) { ++ if (owner == this.owner) this.begin(null); ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/PlayerInventoryScreenRequestPayload.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/PlayerInventoryScreenRequestPayload.java +new file mode 100644 +index 00000000..a1d1f147 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/container/PlayerInventoryScreenRequestPayload.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.PlayerInventoryScreenRequest; ++ ++public record PlayerInventoryScreenRequestPayload(PlayerInventoryScreenRequest request) implements CustomPacketPayload { ++ public static final Type TYPE = new Type<>(Identifier.parse(PlayerInventoryScreenRequest.CHANNEL)); ++ public static final StreamCodec CODEC = CustomPacketPayload.codec( ++ (payload, buffer) -> PlayerInventoryScreenRequest.TYPE.write(buffer, payload.request), ++ buffer -> new PlayerInventoryScreenRequestPayload(PlayerInventoryScreenRequest.TYPE.read(buffer))); ++ ++ @Override ++ public Type type() { return TYPE; } ++} +diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json +index 070607e7..c89207e9 100644 +--- a/src/main/resources/viafabricplus-bedrock.mixins.json ++++ b/src/main/resources/viafabricplus-bedrock.mixins.json +@@ -12,6 +12,11 @@ + "features.render.MixinNativeChunkFluid", + "features.render.MixinNativeRegionFluid", + "compat.sodium.MixinSodiumNativeFluid", ++ "features.render.MixinNativeContainerPresentation", ++ "features.render.MixinNativeGuiItemState", ++ "features.render.MixinNativeGuiItemExtraction", ++ "features.render.MixinNativeGuiItemAtlas", ++ "features.render.MixinNativeGuiOversizedItem", + "compat.iris.MixinIrisPipelines", + "compat.iris.MixinNativeShaderCompilation", + "compat.iris.MixinNativeShaderBinding", +@@ -80,13 +85,14 @@ + "features.creative.MixinCreativeModeInventoryScreen", + "features.creative.MixinCreativeModeTab_ItemDisplayBuilder", + "features.creative.MixinCreativeModeTabs", ++ "features.inventory.MixinNativeContainerGeometry", ++ "features.inventory.MixinNativeContainerScreenAdmission", + "features.inventory.MixinCartographyTableMenu", + "features.inventory.MixinCartographyPacketListener", + "features.inventory.MixinBedrockCartographyScreen", + "features.inventory.MixinBedrockCartographySlot", + "features.misc.MixinBedrockProtocol", + "features.misc.MixinClientSuggestionProvider", +- "features.misc.MixinInventoryScreen", + "features.misc.MixinMinecraft", + "features.render.MixinNativeActorPickingEntity", + "features.render.MixinNativeActorPickingLocalPlayer", +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 00000000..261962c6 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/BedrockNativeContainerScreenTest.java +@@ -0,0 +1,605 @@ ++/* ++ * 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 static org.mockito.Mockito.*; ++ ++import com.google.gson.JsonArray; ++import com.google.gson.JsonObject; ++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; ++import net.minecraft.SharedConstants; ++import net.minecraft.client.Minecraft; ++import net.minecraft.client.gui.Font; ++import net.minecraft.client.gui.screens.Screen; ++import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; ++import net.minecraft.client.gui.screens.inventory.ContainerScreen; ++import net.minecraft.client.gui.screens.inventory.DispenserScreen; ++import net.minecraft.client.gui.screens.inventory.HopperScreen; ++import net.minecraft.client.gui.screens.inventory.ShulkerBoxScreen; ++import net.minecraft.client.multiplayer.ClientPacketListener; ++import net.minecraft.client.multiplayer.MultiPlayerGameMode; ++import net.minecraft.client.player.LocalPlayer; ++import net.minecraft.network.Connection; +import net.minecraft.network.chat.Component; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.entity.EntityEquipment; +import net.minecraft.world.entity.player.Inventory; -+import net.minecraft.world.inventory.ChestMenu; +import net.minecraft.world.inventory.AbstractContainerMenu; -+import net.minecraft.world.inventory.HopperMenu; ++import net.minecraft.world.inventory.ChestMenu; ++import net.minecraft.world.inventory.ContainerInput; +import net.minecraft.world.inventory.DispenserMenu; ++import net.minecraft.world.inventory.HopperMenu; +import net.minecraft.world.inventory.ShulkerBoxMenu; -+import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; -+import net.minecraft.client.gui.screens.inventory.ContainerScreen; -+import net.minecraft.client.gui.screens.inventory.HopperScreen; -+import net.minecraft.client.gui.screens.inventory.DispenserScreen; -+import net.minecraft.client.gui.screens.inventory.ShulkerBoxScreen; -+import net.minecraft.world.inventory.ContainerInput; +import net.minecraft.world.inventory.Slot; +import net.raphimc.viabedrock.api.resourcepack.ui.ContainerUi; -+ +import org.junit.jupiter.api.Test; + -+import java.util.List; -+ +final class BedrockNativeContainerScreenTest { + @Test ++ void inventoryPointerEventsDoNotDispatchTheirCancelBinding() throws Exception { ++ SharedConstants.tryDetectVersion(); ++ Bootstrap.bootStrap(); ++ final var player = mock(LocalPlayer.class); ++ final var inventory = new Inventory(player, new EntityEquipment()); ++ final var menu = ChestMenu.threeRows(7, inventory); ++ player.containerMenu = menu; ++ final var game = mock(Minecraft.class); ++ game.player = player; ++ game.gameMode = mock(MultiPlayerGameMode.class); ++ final var gui = Minecraft.class.getField("gui"); ++ gui.setAccessible(true); ++ gui.set(game, mock(net.minecraft.client.gui.Gui.class)); ++ final var options = Minecraft.class.getDeclaredField("options"); ++ options.setAccessible(true); ++ options.set(game, mock(net.minecraft.client.Options.class)); ++ final var pick = net.minecraft.client.Options.class.getDeclaredField("keyPickItem"); ++ pick.setAccessible(true); ++ pick.set(game.options, mock(net.minecraft.client.KeyMapping.class)); ++ final var clock = new java.util.concurrent.atomic.AtomicLong(); ++ try (var singleton = mockStatic(Minecraft.class)) { ++ singleton.when(Minecraft::getInstance).thenReturn(game); ++ final var ordinary = new ContainerScreen(menu, inventory, Component.empty()); ++ final var screen = new BedrockNativeContainerScreen(mock(ClientPacketListener.class), ordinary, inventory, ++ Component.empty(), new NativeContainerPresentation(program(), ContainerUi.Kind.CHEST_3_ROWS, List.of()), ++ false, 1, clock::get, () -> true); ++ final var minecraft = Screen.class.getDeclaredField("minecraft"); ++ minecraft.setAccessible(true); ++ minecraft.set(screen, game); ++ screen.prepare(mock(Font.class), 640, 320); ++ clock.set(1_000_000_000); ++ screen.containerTick(); ++ final var drawsField = BedrockNativeContainerScreen.class.getDeclaredField("draws"); ++ drawsField.setAccessible(true); ++ @SuppressWarnings("unchecked") ++ final var draws = (List) drawsField.get(screen); ++ int clicked = 0; ++ for (final var draw : draws) { ++ final var node = draw.box().node(); ++ final var bounds = draw.box().visibleBounds(); ++ if (!node.properties().has("inventory_actions") || !node.string("inventory_command", "").equals("close") ++ || !draw.enabled() || draw.alpha() <= 0 || bounds.width() <= 0 || bounds.height() <= 0) continue; ++ final double x = bounds.x() + bounds.width() / 2; ++ final double y = bounds.y() + bounds.height() / 2; ++ assertNotNull(screen.nativeHoveredSlot(x, y)); ++ for (final int button : List.of(com.mojang.blaze3d.platform.InputConstants.MOUSE_BUTTON_LEFT, ++ com.mojang.blaze3d.platform.InputConstants.MOUSE_BUTTON_RIGHT)) { ++ final var event = new net.minecraft.client.input.MouseButtonEvent(x, y, ++ new net.minecraft.client.input.MouseButtonInfo(button, 0)); ++ assertTrue(screen.mouseClicked(event, false)); ++ screen.mouseReleased(event); ++ } ++ clicked++; ++ } ++ assertTrue(clicked >= menu.slots.size()); ++ verify(player, never()).closeContainer(); ++ screen.discard(); ++ } ++ } ++ ++ @Test + void bundledStorageScenesPassTheNativeVisibleControlGate() { + org.junit.jupiter.api.Assumptions.assumeTrue( + ContainerUi.class.getResource("/assets/viabedrock/builtin_ui.zip") != null); @@ -3286,6 +5568,8 @@ index 0000000..63cd4e6 + final var connection = mock(Connection.class); + final var listener = mock(ClientPacketListener.class); + when(listener.getConnection()).thenReturn(connection); ++ when(connection.isConnected()).thenReturn(true); ++ when(game.getConnection()).thenReturn(listener); + try (final var singleton = mockStatic(Minecraft.class)) { + singleton.when(Minecraft::getInstance).thenReturn(game); + final var screen = @@ -3411,7 +5695,10 @@ index 0000000..63cd4e6 + final var cell = + com.viaversion.viaversion.libs.gson.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"}]} ++ {"type":"button","size":[18,18],"button_mappings":[ ++ {"from_button_id":"button.menu_cancel","to_button_id":"button.try_menu_exit","mapping_type":"pressed"}, ++ {"from_button_id":"button.menu_select","to_button_id":"button.container_take_all_place_all","mapping_type":"pressed"} ++ ]} + """) + .getAsJsonObject(); + final var cellDefinition = new com.viaversion.viaversion.libs.gson.JsonObject(); @@ -3481,12 +5768,12 @@ index 0000000..63cd4e6 + return new NativeUiPresentation.Draw(new NativeUiLayout.Box(node, rect, rect, new NativeUiLayout.Size(18, 18), rect, 0, List.of()), 1, 1, true); + } +} -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 +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerButtonTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerButtonTest.java new file mode 100644 -index 0000000..1b5de93 +index 00000000..5a97c2dd --- /dev/null -+++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerGeometryTest.java -@@ -0,0 +1,129 @@ ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerButtonTest.java +@@ -0,0 +1,280 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -3500,12 +5787,471 @@ index 0000000..1b5de93 +import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiPresentation; +import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiScene; +import java.util.List; ++import java.util.Map; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + ++final class NativeContainerButtonTest { ++ @Test ++ void matchingSceneCopiesDoNotReplayAliasedVisibilityCallbacks() { ++ final var input = new NativeContainerInput(_ -> { }); ++ final var owner = nestedInput(false, true); ++ owner.properties().getAsJsonArray("button_mappings").get(0).getAsJsonObject().addProperty("handle_select", false); ++ layout(input, owner); ++ final var first = input.button("button.menu_ok", true, 1, 1, true); ++ assertSame(owner.children().getFirst(), first.events().get(1).node()); ++ assertEquals(2, first.events().get(1).button().previous()); ++ ++ final var replacement = nestedInput(false, true); ++ replacement.properties().getAsJsonArray("button_mappings").get(0).getAsJsonObject().addProperty("handle_select", false); ++ layout(input, replacement); ++ final var repeated = input.button("button.menu_ok", true, 1, 2, true); ++ assertSame(replacement.children().getFirst(), repeated.events().get(1).node()); ++ assertEquals(1, repeated.events().get(1).button().previous()); ++ assertTrue(replacement.children().getFirst().flag("visible", false)); ++ } ++ ++ @Test ++ void namedVisibilityCallbacksPrecedeLaterControlsButRegistrationWaitsForPrepare() { ++ final var input = new NativeContainerInput(_ -> { }); ++ final var owner = nestedInput(false, false); ++ final var child = owner.children().getFirst(); ++ layout(input, owner); ++ final var pressed = input.button("button.menu_ok", true, 1, 1, true); ++ assertEquals(2, pressed.events().size()); ++ assertSame(child, pressed.events().get(1).node()); ++ assertEquals(2, pressed.events().get(1).button().previous()); ++ assertFalse(child.flag("visible", true)); ++ assertTrue(input.enabled(child)); ++ layout(input, owner); ++ assertFalse(input.enabled(child)); ++ assertEquals(1, input.button("button.menu_ok", false, 1, 2, true).events().size()); ++ assertTrue(child.flag("visible", false)); ++ assertFalse(input.enabled(child)); ++ layout(input, owner); ++ assertTrue(input.enabled(child)); ++ } ++ ++ @Test ++ void aLaterParentSetterInvalidatesTheChildsAlreadyDeliveredMapping() { ++ final var input = new NativeContainerInput(_ -> { }); ++ final var owner = nestedInput(true, false); ++ final var child = owner.children().getFirst(); ++ layout(input, owner); ++ final var first = input.button("button.menu_ok", true, 1, 1, true); ++ assertSame(child, first.events().getFirst().node()); ++ assertEquals(0, first.events().getFirst().button().previous()); ++ final var repeated = input.button("button.menu_ok", true, 1, 2, true); ++ assertSame(child, repeated.events().getFirst().node()); ++ assertEquals(2, repeated.events().getFirst().button().previous()); ++ } ++ ++ @Test ++ void initialAliasedSettersReachChildComponentsBeforeTheirFinalVisibleState() { ++ final var input = new NativeContainerInput(_ -> { }); ++ final var owner = nestedInput(true, true); ++ final var child = owner.children().getFirst(); ++ layout(input, owner); ++ assertTrue(child.flag("visible", false)); ++ final var first = input.button("button.menu_ok", true, 1, 1, true); ++ assertSame(child, first.events().getFirst().node()); ++ assertEquals(2, first.events().getFirst().button().previous()); ++ } ++ ++ @Test ++ void aSelfReferenceResetsButtonAppearanceBeforeTheSenderCommitsItsPhase() { ++ final var input = new NativeContainerInput(_ -> { }); ++ final var owner = button(false, true); ++ owner.properties().addProperty("default_control", owner.name()); ++ owner.properties().getAsJsonArray("button_mappings").get(0).getAsJsonObject().addProperty("mapping_type", "global"); ++ layout(input, owner); ++ final var first = input.button("button.menu_ok", true, 1, 1, true); ++ assertEquals(0, first.events().getFirst().button().previous()); ++ assertTrue(owner.flag("visible", false)); ++ assertFalse(owner.children().get(2).flag("visible", true)); ++ final var repeated = input.button("button.menu_ok", true, 1, 2, true); ++ assertEquals(1, repeated.events().getFirst().button().previous()); ++ assertFalse(repeated.events().getFirst().button().transition()); ++ assertFalse(owner.children().get(2).flag("visible", true)); ++ } ++ ++ @Test ++ void hiddenControlsRetireMappingsButDeliverTheirCapturedGlobalRelease() { ++ for (final boolean own : List.of(false, true)) { ++ for (final String property : List.of("visible", "ignored")) { ++ final var input = new NativeContainerInput(_ -> { }); ++ final var button = button(true, true); ++ final var mapping = button.properties().getAsJsonArray("button_mappings").get(0).getAsJsonObject(); ++ mapping.addProperty("mapping_type", "global"); ++ mapping.addProperty("button_up_right_of_first_refusal", true); ++ final var host = new NativeUiScene.Node("host", "panel", properties(100, 100), List.of(button), Integer.MIN_VALUE); ++ final var target = own ? button : host; ++ layout(input, host); ++ input.pointer(5, 5, 1, 0, true); ++ input.button("button.menu_ok", true, 1, 1, true); ++ target.properties().addProperty(property, property.equals("ignored")); ++ layout(input, host); ++ assertTrue(button.children().getFirst().flag("visible", true)); ++ assertTrue(input.captures("button.menu_ok")); ++ assertTrue(input.button("button.menu_ok", true, 1, 2, true).events().isEmpty()); ++ final var release = input.button("button.menu_ok", false, 1, 3, true); ++ assertEquals(1, release.events().size()); ++ assertEquals(2, release.events().getFirst().button().previous()); ++ assertFalse(input.captures("button.menu_ok")); ++ layout(input, host); ++ assertTrue(input.button("button.menu_ok", false, 1, 4, true).events().isEmpty()); ++ target.properties().addProperty(property, property.equals("visible")); ++ layout(input, host); ++ final var reopened = input.button("button.menu_ok", true, 1, 5, true); ++ assertEquals(own ? 2 : 0, reopened.events().getFirst().button().previous()); ++ assertTrue(button.children().get(2).flag("visible", true)); ++ } ++ } ++ } ++ ++ @Test ++ void initiallyHiddenControlsInvalidateMappingsWhenTheScreenAdmitsItsTree() { ++ for (final boolean own : List.of(false, true)) { ++ for (final String property : List.of("visible", "ignored")) { ++ final var input = new NativeContainerInput(_ -> { }); ++ final var button = button(true, true); ++ button.properties().getAsJsonArray("button_mappings").get(0).getAsJsonObject().addProperty("mapping_type", "global"); ++ final var host = new NativeUiScene.Node("host", "panel", properties(100, 100), List.of(button), Integer.MIN_VALUE); ++ final var target = own ? button : host; ++ target.properties().addProperty(property, property.equals("ignored")); ++ layout(input, host); ++ assertTrue(input.button("button.menu_ok", true, 1, 1, true).events().isEmpty()); ++ target.properties().addProperty(property, property.equals("visible")); ++ layout(input, host); ++ assertEquals(2, input.button("button.menu_ok", true, 1, 2, true).events().getFirst().button().previous()); ++ } ++ } ++ } ++ ++ @Test ++ void retainedPressChangesLayoutAndConsumptionBeforeTheNextMatchingScene() { ++ final var input = new NativeContainerInput(_ -> { }); ++ final var original = button(false, true); ++ final var hover = new JsonObject(); ++ hover.addProperty("to_button_id", "fixture.hover"); ++ hover.addProperty("mapping_type", "pressed"); ++ original.properties().getAsJsonArray("button_mappings").add(hover); ++ assertEquals(10, layout(input, original).bounds().width(), 0.0001); ++ assertFalse(input.pointer(5, 5, 1, 0, true).consumed()); ++ assertFalse(input.button("button.menu_ok", true, 1, 1, true).consumed()); ++ assertEquals(30, layout(input, original).bounds().width(), 0.0001); ++ final var replacement = button(false, true); ++ replacement.properties().getAsJsonArray("button_mappings").add(hover.deepCopy()); ++ assertEquals(30, layout(input, replacement).bounds().width(), 0.0001); ++ assertTrue(replacement.children().get(2).flag("visible", true)); ++ assertFalse(input.button("button.menu_ok", false, 1, 2, true).consumed()); ++ assertEquals(20, layout(input, replacement).bounds().width(), 0.0001); ++ } ++ ++ @Test ++ void ancestorAndOwnEnabledChangesKeepTheirSeparatePressLifetimes() { ++ final var input = new NativeContainerInput(_ -> { }); ++ final var button = button(true, true); ++ final var host = new NativeUiScene.Node("host", "panel", properties(100, 100), List.of(button), Integer.MIN_VALUE); ++ layout(input, host); ++ input.pointer(5, 5, 1, 0, true); ++ input.button("button.menu_ok", true, 1, 1, true); ++ host.properties().addProperty("enabled", false); ++ layout(input, host); ++ assertTrue(button.children().getLast().flag("visible", true)); ++ host.properties().addProperty("enabled", true); ++ layout(input, host); ++ assertTrue(button.children().get(2).flag("visible", true)); ++ button.properties().addProperty("enabled", false); ++ layout(input, host); ++ button.properties().addProperty("enabled", true); ++ layout(input, host); ++ assertTrue(button.children().get(1).flag("visible", true)); ++ assertFalse(button.children().get(2).flag("visible", true)); ++ } ++ ++ @Test ++ void suppressedSelectionSurvivesReplacementUntilAnActualPropertyEvent() { ++ final var input = new NativeContainerInput(_ -> { }); ++ final var original = button(true, false); ++ final var originalHost = new NativeUiScene.Node("host", "panel", properties(100, 100), List.of(original), Integer.MIN_VALUE); ++ layout(input, originalHost); ++ input.pointer(5, 5, 1, 0, true); ++ input.button("button.menu_ok", true, 1, 1, true); ++ assertTrue(original.children().get(1).flag("visible", true)); ++ final var replacement = button(true, false); ++ final var host = new NativeUiScene.Node("host", "panel", properties(100, 100), List.of(replacement), Integer.MIN_VALUE); ++ layout(input, host); ++ assertTrue(replacement.children().get(1).flag("visible", true)); ++ assertFalse(replacement.children().get(2).flag("visible", true)); ++ host.properties().addProperty("enabled", false); ++ layout(input, host); ++ host.properties().addProperty("enabled", true); ++ layout(input, host); ++ assertTrue(replacement.children().get(2).flag("visible", true)); ++ } ++ ++ private static NativeUiScene.Node nestedInput(final boolean childFirst, final boolean alias) { ++ final var owner = button(false, true); ++ owner.properties().addProperty("layer", 10); ++ final var mapping = owner.properties().getAsJsonArray("button_mappings").get(0).getAsJsonObject(); ++ mapping.addProperty("mapping_type", "global"); ++ if (alias) { ++ owner.properties().addProperty("hover_control", "default"); ++ owner.properties().addProperty("pressed_control", "default"); ++ } ++ final var children = new java.util.ArrayList<>(owner.children()); ++ final var original = children.getFirst(); ++ original.properties().addProperty("layer", childFirst ? 1 : -1); ++ final var childMapping = mapping.deepCopy(); ++ childMapping.addProperty("to_button_id", "fixture.child"); ++ childMapping.addProperty("consume_event", false); ++ final var mappings = new JsonArray(); ++ mappings.add(childMapping); ++ original.properties().add("button_mappings", mappings); ++ children.set(0, new NativeUiScene.Node(original.name(), "input_panel", original.properties(), original.children(), original.action())); ++ return new NativeUiScene.Node(owner.name(), owner.type(), owner.properties(), List.copyOf(children), owner.action()); ++ } ++ ++ private static NativeUiLayout.Box layout(final NativeContainerInput input, final NativeUiScene.Node node) { ++ input.prepare(node); ++ final var root = new NativeUiLayout(_ -> new NativeUiLayout.Size(0, 0)).layout(node, 100, 100, Map.of()); ++ final var draws = NativeUiPresentation.draws(root, _ -> true, _ -> new NativeUiPresentation.Opacity(1, 1), input::hasButtonState); ++ input.layout(root, draws, 100, 100); ++ return root; ++ } ++ ++ private static NativeUiScene.Node button(final boolean consume, final boolean select) { ++ final var properties = properties(0, 20); ++ properties.getAsJsonArray("size").set(0, new com.google.gson.JsonPrimitive("100%c")); ++ properties.addProperty("consume_event", consume); ++ final var mapping = new JsonObject(); ++ mapping.addProperty("from_button_id", "button.menu_ok"); ++ mapping.addProperty("to_button_id", "button.menu_select"); ++ mapping.addProperty("mapping_type", "pressed"); ++ mapping.addProperty("handle_select", select); ++ final var mappings = new JsonArray(); ++ mappings.add(mapping); ++ properties.add("button_mappings", mappings); ++ final List children = new java.util.ArrayList<>(); ++ for (final String role : List.of("default", "hover", "pressed", "locked")) { ++ final var child = new NativeUiScene.Node(role, "panel", properties((children.size() + 1) * 10, 20), List.of(), Integer.MIN_VALUE); ++ children.add(child); ++ properties.addProperty(role + "_control", role); ++ } ++ return new NativeUiScene.Node("owner", "button", properties, List.copyOf(children), 0); ++ } ++ ++ private static JsonObject properties(final int width, final int height) { ++ final var properties = new JsonObject(); ++ properties.addProperty("anchor_from", "top_left"); ++ properties.addProperty("anchor_to", "top_left"); ++ final var size = new JsonArray(); ++ size.add(width); ++ size.add(height); ++ properties.add("size", size); ++ return properties; ++ } ++} +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 00000000..8d089b79 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerGeometryTest.java +@@ -0,0 +1,366 @@ ++/* ++ * 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 com.google.gson.JsonArray; ++import com.google.gson.JsonObject; ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiLayout; ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiInput; ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiToggle; ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiPresentation; ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiScene; ++import java.util.List; ++import java.util.Map; ++import java.util.Set; ++import net.raphimc.viabedrock.api.model.NativeCookingAction; ++import net.raphimc.viabedrock.api.resourcepack.ui.CookingUi; ++import net.raphimc.viabedrock.protocol.model.FurnaceOptions; ++import org.junit.jupiter.api.Test; ++ +final class NativeContainerGeometryTest { + @Test ++ void layoutPreferencesPreserveTheirNativeFamilyAndRejectForcedProfiles() { ++ final var toggle = preference(); ++ toggle.box().node().properties().addProperty("toggle_name", "layout_toggle"); ++ toggle.box().node().properties().addProperty("toggle_group_forced_index", 1); ++ final var geometry = new NativeContainerGeometry(List.of(toggle), 0, Map.of(), true); ++ assertTrue(geometry.hasLayoutPreferences()); ++ final var control = geometry.preference(9, 1); ++ final var book = new CookingUi.Book(0, false, 3, true, 1, 1, 1, "", "", ++ new CookingUi.Selection("", -1), List.of(), Set.of()); ++ final var types = Map.of(CookingUi.Family.FURNACE, 1, ++ CookingUi.Family.BLAST_FURNACE, 2, ++ CookingUi.Family.SMOKER, 3); ++ types.forEach((family, type) -> assertEquals(new FurnaceOptions(type, 3, true, 1), control.options(NativeCookingPreferenceControl.snapshot(family, book), book.forcedRecipeBookLayout(), new NativeUiToggle.Change(NativeUiInput.symbol("layout_toggle"), 1, false, true, false)))); ++ final var forced = new CookingUi.Book(1, true, 3, true, 1, 1, 1, "", "", ++ book.selection(), book.catalog(), book.visibleRecipes()); ++ assertThrows(IllegalArgumentException.class, () -> control.options(NativeCookingPreferenceControl.snapshot(CookingUi.Family.FURNACE, forced), forced.forcedRecipeBookLayout(), new NativeUiToggle.Change(NativeUiInput.symbol("layout_toggle"), 1, true, false, true))); ++ } ++ ++ @Test ++ void preferenceTogglesAndSlotsShareClippedPainterOrder() { ++ final var slot = draw(0, true, 1, 0, 0, 18, new NativeUiLayout.Rect(0, 0, 100, 100), 1); ++ final var toggle = preference(); ++ final var front = new NativeContainerGeometry(List.of(slot, toggle), 1, Map.of(), true); ++ assertTrue(front.hasPreferences()); ++ assertEquals(0, front.hovered(1, 1)); ++ assertNull(front.preference(1, 1)); ++ assertEquals(-1, front.hovered(9, 1)); ++ assertEquals(new NativeCookingPreferenceControl(NativeCookingPreferenceControl.Kind.TAB, 2), front.preference(9, 1)); ++ assertNull(front.recipe(9, 1)); ++ final var behind = new NativeContainerGeometry(List.of(toggle, slot), 1, Map.of(), true); ++ assertEquals(0, behind.hovered(9, 1)); ++ assertNull(behind.preference(9, 1)); ++ for (final var inactive : List.of(new NativeUiPresentation.Draw(toggle.box(), 0, 1, true), ++ new NativeUiPresentation.Draw(toggle.box(), 1, 1, false))) { ++ final var geometry = new NativeContainerGeometry(List.of(slot, inactive), 1, Map.of(), true); ++ assertEquals(0, geometry.hovered(9, 1)); ++ assertNull(geometry.preference(9, 1)); ++ assertFalse(geometry.hasPreferences()); ++ } ++ } ++ ++ @Test ++ void globalScopePreferencesRemainAdmittedThroughTheirNativeEventAdapter() { ++ final var toggle = preference(); ++ final var mappings = toggle.box().node().properties().getAsJsonArray("button_mappings"); ++ final var alias = mappings.get(0).getAsJsonObject(); ++ mappings.add(alias.deepCopy()); ++ alias.addProperty("from_button_id", "button.menu_ok"); ++ alias.addProperty("scope", "global"); ++ final var geometry = new NativeContainerGeometry(List.of(toggle), 0, Map.of(), true); ++ assertEquals(new NativeCookingPreferenceControl(NativeCookingPreferenceControl.Kind.TAB, 2), geometry.preference(9, 1)); ++ } ++ ++ @Test ++ void globalAliasesCannotAuthorizeASeparateDirectPointerAction() { ++ final var slot = draw(0, true, 1, 0, 0, 18, new NativeUiLayout.Rect(0, 0, 100, 100), 1); ++ final var props = slot.box().node().properties(); ++ final var action = new JsonObject(); ++ action.addProperty("from_button_id", "button.menu_select"); ++ action.addProperty("to_button_id", "button.container_auto_place_one"); ++ action.addProperty("mapping_type", "pressed"); ++ final var mappings = new JsonArray(); ++ mappings.add(action); ++ props.add("button_mappings", mappings); ++ props.getAsJsonArray("inventory_actions").add(action.deepCopy()); ++ assertDoesNotThrow(() -> new NativeContainerGeometry(List.of(slot), 1, Map.of(), true)); ++ action.addProperty("scope", "global"); ++ assertThrows(IllegalArgumentException.class, () -> new NativeContainerGeometry(List.of(slot), 1, Map.of(), true)); ++ action.remove("scope"); ++ final var alias = new JsonObject(); ++ alias.addProperty("from_button_id", "button.menu_ok"); ++ alias.addProperty("to_button_id", "button.menu_select"); ++ alias.addProperty("mapping_type", "global"); ++ alias.addProperty("scope", "global"); ++ final var aliases = new JsonArray(); ++ aliases.add(alias); ++ final var hostProperties = new JsonObject(); ++ hostProperties.add("button_mappings", aliases); ++ final var host = new NativeUiScene.Node("alias", "input_panel", hostProperties, List.of(), Integer.MIN_VALUE); ++ final var hostBox = new NativeUiLayout.Box(host, slot.box().bounds(), slot.box().clip(), slot.box().content(), slot.box().scrollViewport(), 0, List.of()); ++ assertThrows(IllegalArgumentException.class, () -> new NativeContainerGeometry(List.of(slot, new NativeUiPresentation.Draw(hostBox, 1, 1, true)), 1, Map.of(), true)); ++ } ++ ++ @Test ++ void incompletePreferenceStatesAndUnsupportedInputCannotBecomeActionable() { ++ for (int invalid = 0; invalid < 10; invalid++) { ++ final var toggle = preference(); ++ final var properties = toggle.box().node().properties(); ++ final var mapping = properties.getAsJsonArray("button_mappings").get(0).getAsJsonObject(); ++ switch (invalid) { ++ case 0 -> properties.addProperty("toggle_state", "false"); ++ case 1 -> properties.addProperty("toggle_group_forced_index", 2.0); ++ case 2 -> properties.addProperty("toggle_name", "unknown_group"); ++ case 3 -> properties.add("recipe_actions", new JsonArray()); ++ case 4 -> properties.addProperty("toggle_group_forced_index", 0); ++ case 5 -> { mapping.addProperty("from_button_id", "any"); mapping.addProperty("scope", "global"); } ++ case 6 -> properties.addProperty("inventory_slot", 0); ++ case 7 -> { properties.addProperty("toggle_name", "layout_toggle"); properties.addProperty("toggle_group_forced_index", 3); } ++ case 8 -> { properties.addProperty("toggle_name", "layout_toggle"); properties.addProperty("toggle_group_forced_index", "1"); } ++ case 9 -> { properties.addProperty("toggle_name", "layout_toggle"); properties.addProperty("toggle_group_forced_index", 1.0); } ++ } ++ assertThrows(RuntimeException.class, () -> new NativeContainerGeometry(List.of(toggle), 1, Map.of(), true)); ++ } ++ } ++ ++ private static NativeUiPresentation.Draw preference() { ++ final var properties = com.google.gson.JsonParser.parseString(""" ++ {"toggle_name":"navigation_tab","toggle_group_forced_index":2,"toggle_state":false, ++ "button_mappings":[{"from_button_id":"button.menu_select","to_button_id":"button.menu_select","mapping_type":"pressed"}]} ++ """).getAsJsonObject(); ++ final var node = new NativeUiScene.Node("preference", "toggle", properties, List.of(), Integer.MIN_VALUE); ++ final var rect = new NativeUiLayout.Rect(0, 0, 18, 18); ++ return new NativeUiPresentation.Draw(new NativeUiLayout.Box(node, rect, new NativeUiLayout.Rect(8, 0, 10, 18), ++ new NativeUiLayout.Size(18, 18), rect, 1, List.of()), 1, 1, true); ++ } ++ ++ @Test ++ void cancelMappingsDoNotTurnInventoryClicksIntoCloseCommands() { ++ final var slot = draw(0, true, 1, 0, 0, 18, new NativeUiLayout.Rect(0, 0, 100, 100), 1); ++ final var props = slot.box().node().properties(); ++ props.addProperty("inventory_command", "close"); ++ props.add("button_mappings", com.google.gson.JsonParser.parseString(""" ++ [{"from_button_id":"button.menu_cancel","to_button_id":"button.try_menu_exit","mapping_type":"pressed"}, ++ {"from_button_id":"button.menu_select","to_button_id":"button.take_all_place_all","mapping_type":"pressed"}] ++ """)); ++ props.getAsJsonArray("inventory_actions").add(props.getAsJsonArray("button_mappings").get(1).deepCopy()); ++ final var geometry = new NativeContainerGeometry(List.of(slot), 1); ++ assertEquals(0, geometry.hovered(1, 1)); ++ assertFalse(geometry.closes(1, 1, "button.menu_select")); ++ assertFalse(geometry.closes(1, 1, "button.menu_secondary_select")); ++ assertTrue(geometry.closes(1, 1, "button.menu_cancel")); ++ } ++ ++ @Test ++ void closeCommandsAndSlotsUseTheSameClippedPainterOrder() { ++ final var slot = draw(0, true, 1, 0, 0, 18, new NativeUiLayout.Rect(0, 0, 100, 100), 1); ++ final var properties = new JsonObject(); ++ properties.addProperty("inventory_command", "close"); ++ properties.add("button_mappings", com.google.gson.JsonParser.parseString(""" ++ [{"from_button_id":"button.menu_select","to_button_id":"button.menu_exit","mapping_type":"pressed"}, ++ {"from_button_id":"button.menu_secondary_select","to_button_id":"button.menu_exit","mapping_type":"pressed","ignored":true}] ++ """)); ++ final var node = new NativeUiScene.Node("close", "button", properties, List.of(), Integer.MIN_VALUE); ++ final var rect = new NativeUiLayout.Rect(0, 0, 18, 18); ++ final var box = new NativeUiLayout.Box(node, rect, new NativeUiLayout.Rect(8, 0, 10, 18), ++ new NativeUiLayout.Size(18, 18), rect, 1, List.of()); ++ final var close = new NativeUiPresentation.Draw(box, 1, 1, true); ++ final var front = new NativeContainerGeometry(List.of(slot, close), 1); ++ assertEquals(0, front.hovered(1, 1)); ++ assertFalse(front.closes(1, 1, "button.menu_select")); ++ assertEquals(-1, front.hovered(9, 1)); ++ assertFalse(front.contains(0, 9, 1)); ++ assertTrue(front.closes(9, 1, "button.menu_select")); ++ assertFalse(front.closes(9, 1, "button.menu_secondary_select")); ++ final var covered = new NativeContainerGeometry(List.of(close, slot), 1); ++ assertEquals(0, covered.hovered(9, 1)); ++ assertFalse(covered.closes(9, 1, "button.menu_select")); ++ for (final var inactive : List.of(new NativeUiPresentation.Draw(box, 0, 1, true), ++ new NativeUiPresentation.Draw(box, 1, 1, false))) { ++ final var geometry = new NativeContainerGeometry(List.of(slot, inactive), 1); ++ assertEquals(0, geometry.hovered(9, 1)); ++ assertFalse(geometry.closes(9, 1, "button.menu_select")); ++ } ++ } ++ ++ @Test + void itemBoundsDoNotReplaceClippedSlotInputBounds() { + final var button = draw(0, true, 1, 10, 10, 18, new NativeUiLayout.Rect(12, 10, 16, 18), 1); + final var itemProperties = new JsonObject(); @@ -3606,6 +6352,70 @@ index 0000000..1b5de93 + assertThrows(IllegalArgumentException.class, () -> geometry.requireComplete(2)); + } + ++ @Test ++ void recipeCellsShareSlotPainterOrderClippingAndSourceSpecificCommands() { ++ final var slot = draw(0, true, 1, 0, 0, 18, new NativeUiLayout.Rect(0, 0, 100, 100), 1); ++ final var recipe = recipe(7, 1); ++ final var authority = Map.of(7, Set.of(NativeCookingAction.Mode.PRIMARY.button(), NativeCookingAction.Mode.SECONDARY.button())); ++ final var geometry = new NativeContainerGeometry(List.of(slot, recipe), 1, authority); ++ assertEquals(0, geometry.hovered(1, 1)); ++ assertNull(geometry.recipe(1, 1)); ++ assertEquals(-1, geometry.hovered(9, 1)); ++ assertFalse(geometry.contains(0, 9, 1)); ++ assertEquals(7, geometry.recipe(9, 1).id()); ++ assertEquals(NativeCookingAction.Mode.PRIMARY, geometry.recipe(9, 1).inputs().get("button.menu_select")); ++ assertEquals(NativeCookingAction.Mode.SECONDARY, geometry.recipe(9, 1).inputs().get("button.menu_secondary_select")); ++ assertFalse(geometry.closes(9, 1, "button.menu_select")); ++ assertNull(new NativeContainerGeometry(List.of(recipe, slot), 1, authority).recipe(9, 1)); ++ for (final var inactive : List.of(new NativeUiPresentation.Draw(recipe.box(), 0, 1, true), ++ new NativeUiPresentation.Draw(recipe.box(), 1, 1, false))) { ++ final var hidden = new NativeContainerGeometry(List.of(slot, inactive), 1, authority); ++ assertNull(hidden.recipe(9, 1)); ++ assertEquals(0, hidden.hovered(9, 1)); ++ } ++ } ++ ++ @Test ++ void recipeAdmissionRequiresActualScopeAndUnambiguousConsumedMappings() { ++ final var authority = Map.of(7, Set.of(NativeCookingAction.Mode.PRIMARY.button(), NativeCookingAction.Mode.SECONDARY.button())); ++ assertThrows(IllegalArgumentException.class, () -> new NativeContainerGeometry(List.of(recipe(7, 1)), 1)); ++ assertThrows(IllegalArgumentException.class, () -> new NativeContainerGeometry(List.of(recipe(8, 1)), 1, authority)); ++ for (int change = 0; change < 7; change++) { ++ final var recipe = recipe(7, 1); ++ final var props = recipe.box().node().properties(); ++ final var mapping = props.getAsJsonArray("recipe_actions").get(0).getAsJsonObject(); ++ switch (change) { ++ case 0 -> props.addProperty("inventory_slot", 0); ++ case 1 -> { mapping.addProperty("consume_event", false); props.getAsJsonArray("button_mappings").set(0, mapping.deepCopy()); } ++ case 2 -> { mapping.addProperty("mapping_type", "global"); props.getAsJsonArray("button_mappings").set(0, mapping.deepCopy()); } ++ case 3 -> props.getAsJsonArray("button_mappings").add(com.google.gson.JsonParser.parseString(""" ++ {"from_button_id":"button.menu_select","to_button_id":"button.drop_one","mapping_type":"pressed"} ++ """)); ++ case 4 -> { mapping.addProperty("from_button_id", "button.menu_secondary_select"); ++ props.getAsJsonArray("button_mappings").set(0, mapping.deepCopy()); } ++ case 5 -> { mapping.addProperty("handle_select", false); props.getAsJsonArray("button_mappings").set(0, mapping.deepCopy()); } ++ case 6 -> { mapping.addProperty("consume_event", "true"); props.getAsJsonArray("button_mappings").set(0, mapping.deepCopy()); } ++ } ++ assertThrows(RuntimeException.class, () -> new NativeContainerGeometry(List.of(recipe), 1, authority)); ++ } ++ } ++ ++ private static NativeUiPresentation.Draw recipe(final int id, final float layer) { ++ final var props = new JsonObject(); ++ props.addProperty("recipe_id", id); ++ props.addProperty("collection", "recipe_book"); ++ final var mappings = com.google.gson.JsonParser.parseString(""" ++ [{"from_button_id":"button.menu_select","to_button_id":"button.recipe_select","mapping_type":"pressed"}, ++ {"from_button_id":"button.menu_secondary_select","to_button_id":"button.recipe_secondary","mapping_type":"pressed"}] ++ """).getAsJsonArray(); ++ props.add("button_mappings", mappings.deepCopy()); ++ props.add("recipe_actions", mappings); ++ final var node = new NativeUiScene.Node("recipe", "button", props, List.of(), Integer.MIN_VALUE); ++ final var bounds = new NativeUiLayout.Rect(0, 0, 18, 18); ++ return new NativeUiPresentation.Draw(new NativeUiLayout.Box(node, bounds, new NativeUiLayout.Rect(8, 0, 10, 18), ++ new NativeUiLayout.Size(18, 18), bounds, layer, List.of()), 1, 1, true); ++ } ++ + private static NativeUiPresentation.Draw draw(final int slot, final boolean enabled, final float alpha, + final float x, final float y, final float size, final NativeUiLayout.Rect clip, final float layer) { + final var properties = new JsonObject(); @@ -3616,12 +6426,12 @@ index 0000000..1b5de93 + return new NativeUiPresentation.Draw(new NativeUiLayout.Box(node, rect, clip, new NativeUiLayout.Size(size, size), rect, layer, List.of()), alpha, alpha, enabled); + } +} -diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerPresentationControllerTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerPresentationControllerTest.java +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerInputTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerInputTest.java new file mode 100644 -index 0000000..ea7adca +index 00000000..0a64ae80 --- /dev/null -+++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerPresentationControllerTest.java -@@ -0,0 +1,91 @@ ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerInputTest.java +@@ -0,0 +1,542 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -3629,11 +6439,559 @@ index 0000000..ea7adca + */ +package com.viaversion.viafabricplus.bedrock.screen.container; + -+import static org.junit.jupiter.api.Assertions.*; -+ ++import com.google.gson.JsonArray; ++import com.google.gson.JsonObject; ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiLayout; ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiInput; ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiPresentation; ++import com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiScene; ++import java.util.List; +import org.junit.jupiter.api.Test; ++import static org.junit.jupiter.api.Assertions.*; + -+class NativeContainerPresentationControllerTest { ++final class NativeContainerInputTest { ++ @Test ++ void viewTabsUseAuthoredIndicesAndRetainTheirPhaseAcrossRefreshes() { ++ final var input = new NativeContainerInput(_ -> { }); ++ final var root = tabRoot(); ++ directionalRefresh(input, root); ++ final var first = input.button("button.menu_tab_right", true, 1, 0, true); ++ assertEquals(3, first.events().getFirst().change().index()); ++ assertFalse(first.events().getFirst().change().notifyController()); ++ assertTrue(root.children().getFirst().node().flag("toggle_state", false)); ++ assertFalse(root.children().getLast().node().flag("toggle_state", true)); ++ directionalRefresh(input, root); ++ assertTrue(input.button("button.menu_tab_right", true, 1, 1, true).events().isEmpty()); ++ assertTrue(input.button("button.menu_tab_right", false, 1, 2, true).events().isEmpty()); ++ assertEquals(1, input.button("button.menu_tab_right", true, 1, 3, true).events().getFirst().change().index()); ++ } ++ ++ @Test ++ void viewTabsSkipDisabledControlsButRetainZeroExtentAndTransparentToggles() { ++ final var input = new NativeContainerInput(_ -> { }); ++ final var root = tabRoot(); ++ root.children().getFirst().node().properties().addProperty("alpha", 0); ++ root.children().get(1).node().properties().addProperty("enabled", false); ++ directionalRefresh(input, root); ++ assertTrue(input.button("button.menu_tab_right", true, 2, 0, true).events().isEmpty()); ++ assertEquals(3, input.button("button.menu_tab_right", false, 2, 1, true).events().getFirst().change().index()); ++ input.button("button.menu_tab_right", true, 2, 2, true); ++ assertEquals(2, input.button("button.menu_tab_right", false, 2, 3, true).events().getFirst().change().index()); ++ } ++ ++ @Test ++ void focusEnabledViewTabsDeliverHoverBeforeTheirGroupChange() { ++ final var input = new NativeContainerInput(_ -> { }); ++ final var root = tabRoot(); ++ final var target = root.children().getFirst().node(); ++ target.properties().addProperty("focus_enabled", true); ++ final var hover = new JsonObject(); ++ hover.addProperty("to_button_id", "fixture.tab_hover"); ++ hover.addProperty("mapping_type", "pressed"); ++ final var mappings = new JsonArray(); ++ mappings.add(hover); ++ target.properties().add("button_mappings", mappings); ++ directionalRefresh(input, root); ++ final var result = input.button("button.menu_tab_right", true, 1, 0, true); ++ assertSame(target, input.focused()); ++ assertEquals(2, result.events().size()); ++ assertEquals(NativeUiInput.symbol("fixture.tab_hover"), result.events().getFirst().button().target()); ++ assertEquals(3, result.events().getLast().change().index()); ++ assertFalse(result.events().getLast().change().notifyController()); ++ } ++ ++ @Test ++ void focusedViewTabsUseTheGeometryAfterRevealRelayout() { ++ final var retained = new java.util.concurrent.atomic.AtomicReference(); ++ final var root = tabRoot(); ++ final var target = root.children().getFirst().node(); ++ target.properties().addProperty("focus_enabled", true); ++ final var hover = new JsonObject(); ++ hover.addProperty("to_button_id", "fixture.tab_hover"); ++ hover.addProperty("mapping_type", "pressed"); ++ final var mappings = new JsonArray(); ++ mappings.add(hover); ++ target.properties().add("button_mappings", mappings); ++ final var input = new NativeContainerInput(node -> { ++ assertSame(target, node); ++ final var bounds = new NativeUiLayout.Rect(100, 0, 20, 20); ++ final var moved = new NativeUiLayout.Box(target, bounds, root.clip(), new NativeUiLayout.Size(20, 20), bounds, 0, List.of()); ++ final var children = new java.util.ArrayList<>(root.children()); ++ children.set(0, moved); ++ directionalRefresh(retained.get(), new NativeUiLayout.Box(root.node(), root.bounds(), root.clip(), root.content(), root.scrollViewport(), 0, children)); ++ }); ++ retained.set(input); ++ directionalRefresh(input, root); ++ final var result = input.button("button.menu_tab_right", true, 1, 0, true); ++ assertEquals(NativeUiInput.symbol("fixture.tab_hover"), result.events().getFirst().button().target()); ++ assertTrue(result.events().getFirst().button().pressed()); ++ assertEquals(3, result.events().getLast().change().index()); ++ } ++ ++ @Test ++ void focusedOwnershipDoesNotLatchAnotherControlsFocusedButtonMapping() { ++ final var input = new NativeContainerInput(_ -> { }); ++ final var root = tabRoot(); ++ final List children = new java.util.ArrayList<>(); ++ for (final var child : root.children()) { ++ final var properties = child.node().properties(); ++ properties.addProperty("focus_enabled", true); ++ properties.addProperty("consume_hover_events", true); ++ final var mappings = new JsonArray(); ++ final var focused = mapping("button.menu_ok", "button.menu_select", "default"); ++ focused.addProperty("mapping_type", "focused"); ++ focused.addProperty("consume_event", false); ++ mappings.add(focused); ++ properties.add("button_mappings", mappings); ++ final var bounds = new NativeUiLayout.Rect(0, 0, 20, 20); ++ children.add(new NativeUiLayout.Box(child.node(), bounds, root.clip(), new NativeUiLayout.Size(20, 20), bounds, 0, List.of())); ++ } ++ directionalRefresh(input, new NativeUiLayout.Box(root.node(), root.bounds(), root.clip(), root.content(), root.scrollViewport(), 0, children)); ++ input.button("button.menu_tab_right", true, 1, 0, true); ++ assertSame(children.getFirst().node(), input.focused()); ++ final var result = input.button("button.menu_ok", true, 1, 1, true); ++ final var changes = result.events().stream().filter(event -> event.change() != null).toList(); ++ assertEquals(1, changes.size()); ++ assertEquals(2, changes.getFirst().change().index()); ++ assertTrue(children.getFirst().node().flag("toggle_state", false)); ++ assertTrue(children.getLast().node().flag("toggle_state", false)); ++ } ++ ++ private static NativeUiLayout.Box tabRoot() { ++ final var properties = new JsonObject(); ++ properties.addProperty("hover_enabled", false); ++ final var mappings = new JsonArray(); ++ for (final String direction : List.of("left", "right")) ++ mappings.add(mapping("button.menu_tab_" + direction, "button.menu_tab_" + direction, "view")); ++ properties.add("button_mappings", mappings); ++ final List children = new java.util.ArrayList<>(); ++ for (final int index : List.of(3, 1, 2)) { ++ final var toggle = new JsonObject(); ++ toggle.addProperty("toggle_name", "navigation_tab"); ++ toggle.addProperty("toggle_group_forced_index", index); ++ toggle.addProperty("toggle_state", index == 2); ++ final var node = new NativeUiScene.Node("tab" + index, "toggle", toggle, List.of(), Integer.MIN_VALUE); ++ final var bounds = new NativeUiLayout.Rect(index * 30, 0, 0, 0); ++ children.add(new NativeUiLayout.Box(node, bounds, bounds, new NativeUiLayout.Size(0, 0), bounds, 0, List.of())); ++ } ++ final var node = new NativeUiScene.Node("root", "input_panel", properties, children.stream().map(NativeUiLayout.Box::node).toList(), Integer.MIN_VALUE); ++ final var bounds = new NativeUiLayout.Rect(0, 0, 200, 100); ++ return new NativeUiLayout.Box(node, bounds, bounds, new NativeUiLayout.Size(200, 100), bounds, 0, children); ++ } ++ ++ @Test ++ void globalReinjectionPreservesAnUnavailablePhaseBeforeForwardingTheOriginal() { ++ final var input = new NativeContainerInput(_ -> { }); ++ final var properties = new JsonObject(); ++ final var first = mapping("button.menu_right", "fixture.alias", "global"); ++ first.addProperty("input_mode_condition", "gamepad"); ++ final var mappings = new JsonArray(); ++ mappings.add(first); ++ mappings.add(mapping("fixture.alias", "fixture.controller", "default")); ++ properties.add("button_mappings", mappings); ++ final var node = new NativeUiScene.Node("alias", "panel", properties, List.of(), Integer.MIN_VALUE); ++ refresh(input, node); ++ assertTrue(input.button("button.menu_right", true, 1, 0, true).events().isEmpty()); ++ final var result = input.button("button.menu_right", true, 3, 1, true); ++ assertEquals(2, result.events().size()); ++ final var reinjected = result.events().getFirst().button(); ++ assertEquals(NativeUiInput.symbol("fixture.controller"), reinjected.target()); ++ assertEquals(2, reinjected.phase()); ++ assertFalse(reinjected.pressed()); ++ assertFalse(reinjected.transition()); ++ final var original = result.events().getLast().button(); ++ assertEquals(NativeUiInput.symbol("fixture.alias"), original.target()); ++ assertEquals(2, original.previous()); ++ assertTrue(original.pressed()); ++ assertTrue(original.transition()); ++ } ++ ++ @Test ++ void globalHoverReinjectionUsesTheCurrentInputModeInsteadOfItsZeroModeHeader() { ++ final var input = new NativeContainerInput(_ -> { }); ++ final var root = directionalRoot(); ++ final var mappings = new JsonArray(); ++ mappings.add(mapping("button.menu_left", "button.menu_left", "view")); ++ mappings.add(mapping("button.menu_right", "fixture.alias", "global")); ++ mappings.add(mapping("fixture.alias", "button.menu_right", "view")); ++ root.node().properties().add("button_mappings", mappings); ++ final var hover = new JsonObject(); ++ hover.addProperty("to_button_id", "fixture.alias"); ++ hover.addProperty("mapping_type", "pressed"); ++ hover.addProperty("scope", "global"); ++ final var hoverMappings = new JsonArray(); ++ hoverMappings.add(hover); ++ root.children().get(1).node().properties().add("button_mappings", hoverMappings); ++ directionalRefresh(input, root); ++ input.button("button.menu_right", true, 2, 0, true); ++ input.button("button.menu_right", true, 2, 1, true); ++ input.button("button.menu_left", true, 2, 2, true); ++ assertNull(input.focused()); ++ input.button("button.menu_left", false, 2, 3, true); ++ assertSame(root.children().getLast().node(), input.focused()); ++ } ++ ++ @Test ++ void focusHoverDoesNotReplaceThePhysicalPointerUsedForReinjectedClipping() { ++ final var input = new NativeContainerInput(_ -> { }); ++ final var root = directionalRoot(); ++ final var mappings = new JsonArray(); ++ mappings.add(mapping("button.menu_left", "button.menu_left", "view")); ++ mappings.add(mapping("button.menu_right", "fixture.alias", "global")); ++ root.node().properties().add("button_mappings", mappings); ++ final var scoped = mapping("fixture.alias", "fixture.controller", "default"); ++ scoped.addProperty("mapping_type", "pressed"); ++ final var scopedMappings = new JsonArray(); ++ scopedMappings.add(scoped); ++ root.children().get(1).node().properties().add("button_mappings", scopedMappings); ++ final var properties = new JsonObject(); ++ properties.addProperty("clips_children", true); ++ final var clipNode = new NativeUiScene.Node("clip", "panel", properties, List.of(), Integer.MIN_VALUE); ++ final var bounds = new NativeUiLayout.Rect(0, 0, 100, 100); ++ final var clip = new NativeUiLayout.Box(clipNode, bounds, bounds, new NativeUiLayout.Size(100, 100), bounds, 0, root.children()); ++ final var clipped = new NativeUiLayout.Box(root.node(), root.bounds(), root.clip(), root.content(), root.scrollViewport(), 0, List.of(clip)); ++ directionalRefresh(input, clipped); ++ input.pointer(190, 90, 1, 0, true); ++ input.button("button.menu_left", true, 1, 1, true); ++ assertSame(root.children().get(1).node(), input.focused()); ++ for (int step = 0; step < 2; step++) { ++ final var result = input.button("button.menu_right", true, 1, step + 2, true); ++ assertEquals(1, result.events().size()); ++ assertEquals(NativeUiInput.symbol("fixture.alias"), result.events().getFirst().button().target()); ++ } ++ } ++ ++ private static JsonObject mapping(final String source, final String target, final String scope) { ++ final var mapping = new JsonObject(); ++ mapping.addProperty("from_button_id", source); ++ mapping.addProperty("to_button_id", target); ++ mapping.addProperty("mapping_type", "global"); ++ mapping.addProperty("scope", scope); ++ mapping.addProperty("button_up_right_of_first_refusal", false); ++ return mapping; ++ } ++ ++ @Test ++ void focusHoverUsesTheGeometryAfterRevealRelayout() { ++ final var retained = new java.util.concurrent.atomic.AtomicReference(); ++ final var root = directionalRoot(); ++ final var selected = root.children().get(1).node(); ++ final var mapping = new JsonObject(); ++ mapping.addProperty("to_button_id", "fixture.hover"); ++ mapping.addProperty("mapping_type", "pressed"); ++ final var mappings = new JsonArray(); ++ mappings.add(mapping); ++ selected.properties().add("button_mappings", mappings); ++ final var input = new NativeContainerInput(focused -> { ++ assertSame(selected, focused); ++ final var before = root.children().get(1); ++ final var bounds = new NativeUiLayout.Rect(120, 20, 20, 20); ++ final var after = new NativeUiLayout.Box(selected, bounds, before.clip(), before.content(), before.scrollViewport(), 0, List.of()); ++ final var relaid = new NativeUiLayout.Box(root.node(), root.bounds(), root.clip(), root.content(), root.scrollViewport(), 0, ++ List.of(root.children().getFirst(), after, root.children().getLast())); ++ directionalRefresh(retained.get(), relaid); ++ }); ++ retained.set(input); ++ directionalRefresh(input, root); ++ final var events = input.button("button.menu_right", true, 1, 0, true).events(); ++ assertEquals(1, events.size()); ++ assertSame(selected, events.getFirst().node()); ++ assertTrue(events.getFirst().button().pressed()); ++ } ++ ++ @Test ++ void viewDirectionsUseActivationEdgesAndRetainHeldPhasesAcrossRefresh() { ++ final var input = new NativeContainerInput(_ -> { }); ++ var root = directionalRoot(); ++ directionalRefresh(input, root); ++ final var preferred = root.children().get(1).node(); ++ assertTrue(input.button("button.menu_right", true, 1, 0, true).consumed()); ++ assertSame(preferred, input.focused()); ++ input.button("button.menu_right", true, 1, 1, true); ++ assertSame(preferred, input.focused()); ++ root = directionalRoot(); ++ directionalRefresh(input, root); ++ input.button("button.menu_right", true, 1, 2, true); ++ assertSame(root.children().get(1).node(), input.focused()); ++ input.button("button.menu_right", false, 1, 3, true); ++ input.button("button.menu_right", true, 1, 4, true); ++ assertSame(root.children().get(2).node(), input.focused()); ++ ++ final var touch = new NativeContainerInput(_ -> { }); ++ directionalRefresh(touch, root); ++ touch.button("button.menu_right", true, 2, 0, true); ++ assertNull(touch.focused()); ++ touch.button("button.menu_right", false, 2, 1, true); ++ assertSame(root.children().get(1).node(), touch.focused()); ++ } ++ ++ @Test ++ void viewFocusDeliversNestedHoverBeforePreviouslyQueuedControllerButtons() { ++ final var input = new NativeContainerInput(_ -> { }); ++ final var root = directionalRoot(); ++ final var source = root.children().getFirst().node(); ++ final var mapping = new JsonObject(); ++ mapping.addProperty("from_button_id", "button.menu_right"); ++ mapping.addProperty("to_button_id", "fixture.controller"); ++ mapping.addProperty("mapping_type", "global"); ++ mapping.addProperty("consume_event", false); ++ final var mappings = new JsonArray(); ++ mappings.add(mapping); ++ source.properties().add("button_mappings", mappings); ++ final var target = root.children().get(1).node(); ++ final var hover = new JsonObject(); ++ hover.addProperty("to_button_id", "fixture.hover"); ++ hover.addProperty("mapping_type", "pressed"); ++ final var hoverMappings = new JsonArray(); ++ hoverMappings.add(hover); ++ target.properties().add("button_mappings", hoverMappings); ++ directionalRefresh(input, root); ++ final var result = input.button("button.menu_right", true, 1, 0, true); ++ assertEquals(2, result.events().size()); ++ assertSame(target, result.events().getFirst().node()); ++ assertEquals(NativeUiInput.symbol("fixture.hover"), result.events().getFirst().button().target()); ++ assertTrue(result.events().getFirst().button().pressed()); ++ assertSame(source, result.events().getLast().node()); ++ assertEquals(NativeUiInput.symbol("fixture.controller"), result.events().getLast().button().target()); ++ } ++ ++ private static NativeUiLayout.Box directionalRoot() { ++ final var rootNode = navigation(); ++ rootNode.properties().addProperty("hover_enabled", false); ++ final var bounds = new NativeUiLayout.Rect(0, 0, 200, 100); ++ final List children = new java.util.ArrayList<>(); ++ for (int index = 0; index < 3; index++) { ++ final var properties = new JsonObject(); ++ properties.addProperty("focus_enabled", true); ++ properties.addProperty("focus_wrap_enabled", false); ++ properties.addProperty("default_focus_precedence", index == 1 ? 2 : 0); ++ final var node = new NativeUiScene.Node("candidate" + index, "panel", properties, List.of(), Integer.MIN_VALUE); ++ final var rect = new NativeUiLayout.Rect(index * 40, 0, 20, 20); ++ children.add(new NativeUiLayout.Box(node, rect, bounds, new NativeUiLayout.Size(20, 20), bounds, 0, List.of())); ++ } ++ final var scene = new NativeUiScene.Node(rootNode.name(), rootNode.type(), rootNode.properties(), ++ children.stream().map(NativeUiLayout.Box::node).toList(), rootNode.action()); ++ return new NativeUiLayout.Box(scene, bounds, bounds, new NativeUiLayout.Size(200, 100), bounds, 0, List.copyOf(children)); ++ } ++ ++ private static void directionalRefresh(final NativeContainerInput input, final NativeUiLayout.Box root) { ++ final var draws = new java.util.ArrayList(); ++ collectDraws(root, draws); ++ input.prepare(root.node()); ++ input.layout(root, draws, 200, 100); ++ } ++ ++ private static void collectDraws(final NativeUiLayout.Box box, final List draws) { ++ draws.add(new NativeUiPresentation.Draw(box, 1, 1, true)); ++ for (final var child : box.children()) collectDraws(child, draws); ++ } ++ ++ @Test ++ void touchReleaseQueuesItsToggleChangeEvenWhenTheButtonHasViewScope() { ++ final var input = new NativeContainerInput(_ -> { }); ++ final var node = toggle("filter", 0, false); ++ node.properties().getAsJsonArray("button_mappings").get(0).getAsJsonObject().addProperty("scope", "view"); ++ refresh(input, node); ++ input.pointer(5, 5, 1, 0, true); ++ assertTrue(input.button("button.menu_select", true, 2, 0, true).events().isEmpty()); ++ final var release = input.button("button.menu_select", false, 2, 1, true); ++ assertEquals(1, release.events().size()); ++ assertNull(release.events().getFirst().button()); ++ assertTrue(release.events().getFirst().change().checked()); ++ assertTrue(input.button("button.menu_select", true, 2, 2, true).events().isEmpty()); ++ assertFalse(input.button("button.menu_select", false, 2, 3, true).events().getFirst().change().checked()); ++ } ++ ++ @Test ++ void capturedReleasesFollowRetainedControlsButCannotResurrectRemovedControls() { ++ final var input = new NativeContainerInput(_ -> { }); ++ var node = toggle("filter", 0, false); ++ node.properties().getAsJsonArray("button_mappings").get(0).getAsJsonObject().addProperty("button_up_right_of_first_refusal", true); ++ refresh(input, node); ++ input.pointer(5, 5, 1, 0, true); ++ input.button("button.menu_select", true, 1, 0, true); ++ assertTrue(input.captures("button.menu_select")); ++ final var replacement = new NativeUiScene.Node(node.name(), node.type(), node.properties().deepCopy(), List.of(), Integer.MIN_VALUE); ++ replacement.properties().addProperty("toggle_state", false); ++ refresh(input, replacement); ++ assertTrue(replacement.flag("toggle_state", false)); ++ input.pointer(80, 80, 1, 0, true); ++ final var released = input.button("button.menu_select", false, 1, 1, true); ++ assertEquals(1, released.events().size()); ++ assertSame(replacement, released.events().getFirst().node()); ++ assertEquals(1, released.events().getFirst().button().previous()); ++ assertFalse(input.captures("button.menu_select")); ++ input.pointer(5, 5, 1, 0, true); ++ input.button("button.menu_select", true, 1, 2, true); ++ refresh(input); ++ assertFalse(input.captures("button.menu_select")); ++ refresh(input, replacement); ++ input.pointer(80, 80, 1, 0, true); ++ assertTrue(input.button("button.menu_select", false, 1, 3, true).events().isEmpty()); ++ } ++ ++ @Test ++ void radioChangesClearHiddenOrdinarySiblingsButKeepDuplicateIndices() { ++ final var input = new NativeContainerInput(_ -> { }); ++ final var selected = toggle("selected", 1, false); ++ selected.properties().addProperty("radio_toggle_group", true); ++ selected.properties().addProperty("toggle_group_default_selected", 2); ++ final var sibling = toggle("sibling", 2, true); ++ sibling.properties().addProperty("visible", false); ++ final var duplicate = toggle("duplicate", 1, true); ++ duplicate.properties().addProperty("visible", false); ++ refresh(input, selected, sibling, duplicate); ++ input.pointer(5, 5, 1, 0, true); ++ final var result = input.button("button.menu_select", true, 1, 0, true); ++ assertEquals(1, result.events().stream().filter(event -> event.change() != null).count()); ++ assertTrue(selected.flag("toggle_state", false)); ++ assertFalse(sibling.flag("toggle_state", true)); ++ assertTrue(duplicate.flag("toggle_state", false)); ++ } ++ ++ @Test ++ void focusedMappingsUseRetainedFocusAndHoverEventsDoNotMoveFocus() { ++ final var input = new NativeContainerInput(_ -> { }); ++ final var node = toggle("filter", 1, false); ++ node.properties().addProperty("focus_enabled", true); ++ final var mapping = node.properties().getAsJsonArray("button_mappings").get(0).getAsJsonObject(); ++ mapping.addProperty("from_button_id", "button.menu_ok"); ++ mapping.addProperty("mapping_type", "focused"); ++ final var hover = new JsonObject(); ++ hover.addProperty("to_button_id", "button.filter_toggle_hovered"); ++ hover.addProperty("mapping_type", "pressed"); ++ node.properties().getAsJsonArray("button_mappings").add(hover); ++ refresh(input, navigation(), node); ++ final var pointer = input.pointer(5, 5, 1, 0, true); ++ assertNull(input.focused()); ++ assertFalse(pointer.events().getFirst().button().transition()); ++ input.pointer(80, 80, 1, 0, true); ++ assertTrue(input.button("button.menu_ok", true, 1, 0, true).events().isEmpty()); ++ assertTrue(input.button("button.menu_right", true, 1, 1, true).consumed()); ++ assertTrue(input.button("button.menu_ok", true, 1, 1, true).events().getFirst().change().checked()); ++ final var next = new NativeUiScene.Node(node.name(), node.type(), node.properties().deepCopy(), List.of(), Integer.MIN_VALUE); ++ refresh(input, next); ++ assertSame(next, input.focused()); ++ input.button("button.menu_ok", false, 1, 2, true); ++ assertFalse(input.button("button.menu_ok", true, 1, 3, true).events().getFirst().change().checked()); ++ } ++ ++ @Test ++ void pointerAdmissionUsesTheScrollViewportInsteadOfTheOwnerBounds() { ++ final var input = new NativeContainerInput(_ -> { }); ++ final var node = toggle("clipped", 0, false); ++ final var bounds = new NativeUiLayout.Rect(0, 0, 100, 100); ++ final var viewport = new NativeUiLayout.Rect(0, 0, 10, 100); ++ final var child = new NativeUiLayout.Box(node, new NativeUiLayout.Rect(50, 0, 20, 20), viewport, ++ new NativeUiLayout.Size(20, 20), viewport, 0, List.of()); ++ final var scroll = new NativeUiScene.Node("scroll", "scroll_view", new JsonObject(), List.of(node), Integer.MIN_VALUE); ++ final var root = new NativeUiLayout.Box(scroll, bounds, bounds, new NativeUiLayout.Size(100, 100), viewport, 0, List.of(child)); ++ input.prepare(root.node()); ++ input.layout(root, List.of(new NativeUiPresentation.Draw(child, 1, 1, true)), 100, 100); ++ input.pointer(55, 5, 1, 0, true); ++ assertTrue(input.button("button.menu_select", true, 1, 0, true).events().isEmpty()); ++ assertFalse(node.flag("toggle_state", true)); ++ } ++ ++ @Test ++ void unsupportedScreenRoutingIsRejectedBeforeNonPreferenceControlsReceiveInput() { ++ final var input = new NativeContainerInput(_ -> { }); ++ final var properties = toggle("slot", 0, false).properties(); ++ final var mapping = properties.getAsJsonArray("button_mappings").get(0).getAsJsonObject(); ++ mapping.addProperty("scope", "view"); ++ mapping.addProperty("to_button_id", "button.cast_subpage_right"); ++ final var node = new NativeUiScene.Node("slot", "button", properties, List.of(), Integer.MIN_VALUE); ++ assertThrows(IllegalArgumentException.class, () -> refresh(input, node)); ++ mapping.addProperty("scope", "global"); ++ mapping.addProperty("from_button_id", "any"); ++ assertThrows(IllegalArgumentException.class, () -> refresh(input, node)); ++ } ++ ++ @Test ++ void blockedComponentsRetireButtonPhasesWithoutChangingTheirCheckedState() { ++ final var input = new NativeContainerInput(_ -> { }); ++ final var node = toggle("filter", 0, false); ++ refresh(input, node); ++ input.pointer(5, 5, 1, 0, true); ++ assertTrue(input.button("button.menu_select", true, 1, 0, false).events().stream().noneMatch(event -> event.change() != null)); ++ input.button("button.menu_select", false, 1, 1, false); ++ assertFalse(node.flag("toggle_state", true)); ++ assertTrue(input.button("button.menu_select", true, 1, 2, true).events().getFirst().change().checked()); ++ } ++ ++ @Test ++ void slotInputConsumesBeforeTheCoveredPreference() { ++ final var input = new NativeContainerInput(_ -> { }); ++ final var preference = toggle("preference", 1, false); ++ final var slotProperties = preference.properties().deepCopy(); ++ slotProperties.addProperty("inventory_slot", 0); ++ final var slot = new NativeUiScene.Node("slot", "button", slotProperties, List.of(), Integer.MIN_VALUE); ++ refresh(input, preference, slot); ++ input.pointer(5, 5, 1, 0, true); ++ final var result = input.button("button.menu_select", true, 1, 0, true); ++ assertTrue(result.consumed()); ++ assertEquals(1, result.events().size()); ++ assertSame(slot, result.events().getFirst().node()); ++ assertNull(result.events().getFirst().change()); ++ assertFalse(preference.flag("toggle_state", true)); ++ } ++ ++ private static NativeUiScene.Node navigation() { ++ final var properties = new JsonObject(); ++ final var mapping = new JsonObject(); ++ mapping.addProperty("from_button_id", "button.menu_right"); ++ mapping.addProperty("to_button_id", "button.menu_right"); ++ mapping.addProperty("mapping_type", "global"); ++ mapping.addProperty("scope", "view"); ++ properties.add("button_mappings", new JsonArray()); ++ properties.getAsJsonArray("button_mappings").add(mapping); ++ return new NativeUiScene.Node("navigation", "panel", properties, List.of(), Integer.MIN_VALUE); ++ } ++ ++ private static NativeUiScene.Node toggle(final String name, final int index, final boolean checked) { ++ final var properties = new JsonObject(); ++ properties.addProperty("toggle_name", "toggle.enable_filtering"); ++ properties.addProperty("toggle_group_forced_index", index); ++ properties.addProperty("toggle_state", checked); ++ final var mappings = new JsonArray(); ++ final var mapping = new JsonObject(); ++ mapping.addProperty("from_button_id", "button.menu_select"); ++ mapping.addProperty("to_button_id", "button.menu_select"); ++ mapping.addProperty("mapping_type", "pressed"); ++ mappings.add(mapping); ++ properties.add("button_mappings", mappings); ++ return new NativeUiScene.Node(name, "toggle", properties, List.of(), Integer.MIN_VALUE); ++ } ++ ++ private static void refresh(final NativeContainerInput input, final NativeUiScene.Node... nodes) { ++ final var bounds = new NativeUiLayout.Rect(0, 0, 100, 100); ++ final var children = java.util.Arrays.stream(nodes).map(node -> new NativeUiLayout.Box(node, ++ new NativeUiLayout.Rect(0, 0, 20, 20), bounds, new NativeUiLayout.Size(20, 20), bounds, 0, List.of())).toList(); ++ final var rootNode = new NativeUiScene.Node("root", "panel", new JsonObject(), List.of(nodes), Integer.MIN_VALUE); ++ final var root = new NativeUiLayout.Box(rootNode, bounds, bounds, new NativeUiLayout.Size(100, 100), bounds, 0, children); ++ final var draws = children.stream().filter(box -> box.node().flag("visible", true)).map(box -> new NativeUiPresentation.Draw(box, 1, 1, true)).toList(); ++ input.prepare(root.node()); ++ input.layout(root, draws, 100, 100); ++ } ++} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerPresentationControllerTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerPresentationControllerTest.java +new file mode 100644 +index 00000000..ea7adca3 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerPresentationControllerTest.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.screen.container; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++import org.junit.jupiter.api.Test; ++ ++class NativeContainerPresentationControllerTest { + @Test + void waitsForRealContentsThenPresentsOnceAndPreservesPendingAgainstStaleMessages() { + final var controller = new NativeContainerPresentationController(); @@ -3715,7 +7073,7 @@ index 0000000..ea7adca +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerScrollTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerScrollTest.java new file mode 100644 -index 0000000..b7e8ce6 +index 00000000..b7e8ce6a --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerScrollTest.java @@ -0,0 +1,51 @@ @@ -3772,7 +7130,7 @@ index 0000000..b7e8ce6 +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerSessionTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerSessionTest.java new file mode 100644 -index 0000000..adee31b +index 00000000..adee31bd --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeContainerSessionTest.java @@ -0,0 +1,40 @@ @@ -3816,12 +7174,364 @@ index 0000000..adee31b + assertFalse(session.current(second, menu, 3)); + } +} +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 00000000..5380dd7c +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingActionsTest.java +@@ -0,0 +1,346 @@ ++/* ++ * 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.List; ++import java.util.Map; ++import java.util.Set; ++import java.util.concurrent.atomic.AtomicLong; ++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.resourcepack.ui.CookingUi; ++import org.junit.jupiter.api.BeforeAll; ++import org.junit.jupiter.api.Test; ++ ++class NativeCookingActionsTest { ++ private static NativeCookingMessage first; ++ 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()))); ++ ++ @BeforeAll ++ static void bootstrap() throws Exception { ++ NativeCookingPresentationTest.bootstrap(); ++ first = NativeCookingPresentationTest.message(CookingUi.Family.FURNACE, 1); ++ } ++ ++ @Test ++ void followUpPhasesRetainOriginalAuthorityAcrossNewFramesAndOutOfOrderReplies() { ++ final var actions = new NativeCookingActions(first, () -> 0); ++ final var sent = new ArrayList(); ++ actions.presented(first, ALL_COMMANDS); ++ assertTrue(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ final var next = update(2); ++ actions.update(next); ++ assertFalse(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ assertTrue(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 1, sent::add)); ++ assertFalse(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 1, sent::add)); ++ assertTrue(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 2, sent::add)); ++ assertEquals(3, sent.size()); ++ for (int phase = 0; phase < 3; phase++) assertEquals(new NativeCookingAction(first.menuId(), first.sequence(), first.updateSequence(), ++ first.menuRevision(), first.inventoryRevision(), first.cursorRevision(), 7, NativeCookingAction.Mode.PRIMARY, 1, phase, phase + 1), sent.get(phase)); ++ actions.presented(next, ALL_COMMANDS); ++ assertTrue(actions.result(new NativeCookingActionResult(sent.get(2), true, false))); ++ assertFalse(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ assertTrue(actions.result(new NativeCookingActionResult(sent.get(0), true, false))); ++ assertFalse(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ assertTrue(actions.result(new NativeCookingActionResult(sent.get(1), true, false))); ++ assertTrue(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ assertEquals(new NativeCookingAction(next.menuId(), next.sequence(), next.updateSequence(), next.menuRevision(), ++ next.inventoryRevision(), next.cursorRevision(), 7, NativeCookingAction.Mode.PRIMARY, 1, 0, 4), sent.getLast()); ++ } ++ ++ @Test ++ void onlyPresentedCommandsCanStartInputButUpdatedScopesCannotStrandItsRelease() { ++ final var actions = new NativeCookingActions(first, () -> 0); ++ final var sent = new ArrayList(); ++ assertFalse(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ actions.presented(first, new CookingUi.RecipeActionScope(Map.of(7, Set.of(NativeCookingAction.Mode.PRIMARY.button())))); ++ assertFalse(actions.send(8, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ assertFalse(actions.send(7, NativeCookingAction.Mode.SECONDARY, 1, 0, sent::add)); ++ for (final int phase : new int[]{-1, 1, 2, 3}) assertFalse(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, phase, sent::add)); ++ assertTrue(sent.isEmpty()); ++ assertTrue(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ actions.update(update(2)); ++ assertThrows(IllegalArgumentException.class, () -> actions.presented(first, ALL_COMMANDS)); ++ assertFalse(actions.send(7, NativeCookingAction.Mode.SECONDARY, 1, 2, sent::add)); ++ assertFalse(actions.send(8, NativeCookingAction.Mode.PRIMARY, 1, 2, sent::add)); ++ assertTrue(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 2, sent::add)); ++ assertFalse(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 2, sent::add)); ++ assertEquals(2, sent.size()); ++ } ++ ++ @Test ++ void rejectedInputsRequireNewAuthorityAndForgedRepliesCannotAcknowledgeAnotherCommand() { ++ final var actions = new NativeCookingActions(first, () -> 0); ++ final var sent = new ArrayList(); ++ actions.presented(first, ALL_COMMANDS); ++ assertTrue(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ final var original = sent.getFirst(); ++ final var forged = new NativeCookingAction(original.menuId(), original.sequence(), original.updateSequence(), original.menuRevision(), ++ original.inventoryRevision(), original.cursorRevision(), original.recipeId(), NativeCookingAction.Mode.SECONDARY, 1, 0, original.requestSequence()); ++ assertFalse(actions.result(new NativeCookingActionResult(forged, true, true))); ++ assertTrue(actions.result(new NativeCookingActionResult(original, false, true))); ++ assertFalse(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 2, sent::add)); ++ actions.presented(first, ALL_COMMANDS); ++ assertFalse(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ final var next = update(2); ++ actions.update(next); ++ assertFalse(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ actions.presented(next, ALL_COMMANDS); ++ assertTrue(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ assertEquals(2, sent.getLast().requestSequence()); ++ assertFalse(actions.result(new NativeCookingActionResult(original, true, true))); ++ assertFalse(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ assertTrue(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 2, sent::add)); ++ } ++ ++ @Test ++ void acceptedInputCannotReuseAuthorityTheCoreAlreadyConsumed() { ++ final var actions = new NativeCookingActions(first, () -> 0); ++ final var sent = new ArrayList(); ++ actions.presented(first, ALL_COMMANDS); ++ assertTrue(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ assertTrue(actions.result(new NativeCookingActionResult(sent.getLast(), true, false))); ++ assertTrue(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 2, sent::add)); ++ assertTrue(actions.result(new NativeCookingActionResult(sent.getLast(), true, false))); ++ assertFalse(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ final var next = update(2); ++ actions.update(next); ++ actions.presented(next, ALL_COMMANDS); ++ assertTrue(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ } ++ ++ @Test ++ void secondaryAndTertiaryInputsFinishButTertiaryFollowUpRetainsPrimarySelection() { ++ for (final var mode : List.of(NativeCookingAction.Mode.SECONDARY, NativeCookingAction.Mode.TERTIARY)) { ++ final var actions = new NativeCookingActions(first, () -> 0); ++ final var sent = new ArrayList(); ++ actions.presented(first, ALL_COMMANDS); ++ assertTrue(actions.send(7, mode, 1, 0, sent::add)); ++ assertTrue(actions.result(new NativeCookingActionResult(sent.getLast(), true, true))); ++ assertTrue(actions.send(7, mode, 1, 2, sent::add)); ++ assertTrue(actions.result(new NativeCookingActionResult(sent.getLast(), true, true))); ++ assertTrue(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ assertTrue(actions.result(new NativeCookingActionResult(sent.getLast(), true, true))); ++ assertTrue(actions.send(7, NativeCookingAction.Mode.TERTIARY, 1, 2, sent::add)); ++ assertTrue(actions.result(new NativeCookingActionResult(sent.getLast(), true, true))); ++ assertFalse(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ assertTrue(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 2, sent::add)); ++ assertTrue(actions.result(new NativeCookingActionResult(sent.getLast(), true, true))); ++ assertTrue(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ } ++ } ++ ++ @Test ++ void synchronousSnapshotsAndRepliesCannotRacePendingRegistrationOrReenterSending() { ++ final var actions = new NativeCookingActions(first, () -> 0); ++ final var next = update(2); ++ final var sent = new ArrayList(); ++ actions.presented(first, ALL_COMMANDS); ++ assertTrue(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 0, action -> { ++ sent.add(action); ++ actions.update(next); ++ actions.presented(next, ALL_COMMANDS); ++ assertTrue(actions.result(new NativeCookingActionResult(action, true, false))); ++ assertFalse(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 2, sent::add)); ++ })); ++ assertTrue(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 2, action -> { ++ sent.add(action); ++ assertTrue(actions.result(new NativeCookingActionResult(action, true, false))); ++ assertFalse(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ })); ++ assertEquals(first.updateSequence(), sent.getLast().updateSequence()); ++ assertTrue(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ assertEquals(next.updateSequence(), sent.getLast().updateSequence()); ++ } ++ ++ @Test ++ void failedTransportAndTimeoutCannotRetryAmbiguousInputsOrAcceptLateReplies() { ++ final var clock = new AtomicLong(); ++ final var actions = new NativeCookingActions(first, clock::get); ++ final var sent = new ArrayList(); ++ actions.presented(first, ALL_COMMANDS); ++ final var error = new IllegalStateException(); ++ assertSame(error, assertThrows(IllegalStateException.class, () -> actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 0, action -> { ++ sent.add(action); ++ throw error; ++ }))); ++ assertTrue(actions.expired()); ++ assertFalse(actions.result(new NativeCookingActionResult(sent.getLast(), true, true))); ++ assertFalse(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ assertThrows(IllegalArgumentException.class, () -> actions.presented(first, ALL_COMMANDS)); ++ ++ final var waiting = new NativeCookingActions(first, clock::get); ++ waiting.presented(first, ALL_COMMANDS); ++ assertTrue(waiting.send(7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ clock.set(9_999_999_999L); ++ assertFalse(waiting.expired()); ++ clock.incrementAndGet(); ++ assertTrue(waiting.expired()); ++ assertFalse(waiting.result(new NativeCookingActionResult(sent.getLast(), true, true))); ++ assertFalse(waiting.send(7, NativeCookingAction.Mode.PRIMARY, 1, 2, sent::add)); ++ } ++ ++ @Test ++ void terminalCancellationReleasesOriginalAuthorityOnceAfterTimeoutOrAmbiguousTransport() { ++ for (final boolean ambiguous : List.of(false, true)) { ++ final var clock = new AtomicLong(); ++ final var actions = new NativeCookingActions(first, clock::get); ++ actions.presented(first, ALL_COMMANDS); ++ final var sent = new ArrayList(); ++ if (ambiguous) assertThrows(IllegalStateException.class, () -> actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 0, input -> { ++ sent.add(input); ++ throw new IllegalStateException(); ++ })); ++ else { ++ assertTrue(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ actions.update(update(2)); ++ } ++ clock.set(10_000_000_000L); ++ assertTrue(actions.expired()); ++ actions.retire(); ++ assertTrue(actions.cancel(sent::add)); ++ assertEquals(2, sent.size()); ++ final var release = sent.getLast(); ++ assertEquals(new NativeCookingAction(first.menuId(), first.sequence(), first.updateSequence(), first.menuRevision(), ++ first.inventoryRevision(), first.cursorRevision(), 7, NativeCookingAction.Mode.PRIMARY, 1, 2, 2), release); ++ assertFalse(actions.cancel(sent::add)); ++ assertFalse(actions.result(new NativeCookingActionResult(release, true, true))); ++ assertFalse(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ } ++ } ++ ++ @Test ++ void releaseAcknowledgmentsRetireCleanupButFailedReleaseTransportStillPermitsOneTerminalRelease() { ++ final var actions = new NativeCookingActions(first, () -> 0); ++ final var sent = new ArrayList(); ++ actions.presented(first, ALL_COMMANDS); ++ assertTrue(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ assertTrue(actions.result(new NativeCookingActionResult(sent.getLast(), true, true))); ++ assertThrows(IllegalStateException.class, () -> actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 2, input -> { ++ sent.add(input); ++ throw new IllegalStateException(); ++ })); ++ assertTrue(actions.cancel(sent::add)); ++ assertEquals(3, sent.getLast().requestSequence()); ++ assertEquals(first.updateSequence(), sent.getLast().updateSequence()); ++ assertFalse(actions.cancel(sent::add)); ++ for (final boolean accepted : List.of(false, true)) { ++ final var complete = new NativeCookingActions(first, () -> 0); ++ complete.presented(first, ALL_COMMANDS); ++ assertTrue(complete.send(7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ assertTrue(complete.result(new NativeCookingActionResult(sent.getLast(), accepted, true))); ++ if (accepted) { ++ assertTrue(complete.send(7, NativeCookingAction.Mode.PRIMARY, 1, 2, sent::add)); ++ assertTrue(complete.result(new NativeCookingActionResult(sent.getLast(), true, true))); ++ } ++ assertFalse(complete.cancel(sent::add)); ++ } ++ } ++ ++ @Test ++ void cancellationCannotRetryAnAmbiguousTerminalSend() { ++ final var actions = new NativeCookingActions(first, () -> 0); ++ actions.presented(first, ALL_COMMANDS); ++ final var sent = new ArrayList(); ++ assertTrue(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ assertThrows(IllegalStateException.class, () -> actions.cancel(input -> { sent.add(input); throw new IllegalStateException(); })); ++ assertEquals(2, sent.size()); ++ assertFalse(actions.cancel(sent::add)); ++ } ++ ++ @Test ++ void focusStartsOnlyInItsNativeContextAndReleaseKeepsTheSelectedContext() { ++ final var actions = new NativeCookingActions(first, () -> 0); ++ final var sent = new ArrayList(); ++ actions.presented(first, ALL_COMMANDS); ++ for (final int context : new int[]{-1, 0, 1, 3, 4}) ++ assertFalse(actions.send(7, NativeCookingAction.Mode.PRIMARY, context, 1, sent::add)); ++ assertTrue(actions.send(7, NativeCookingAction.Mode.PRIMARY, 2, 1, input -> { ++ sent.add(input); ++ assertTrue(actions.result(new NativeCookingActionResult(input, true, true))); ++ })); ++ assertFalse(actions.idle()); ++ assertFalse(actions.send(7, NativeCookingAction.Mode.SECONDARY, 2, 0, sent::add)); ++ assertFalse(actions.send(7, NativeCookingAction.Mode.PRIMARY, 2, 1, sent::add)); ++ assertTrue(actions.send(7, NativeCookingAction.Mode.PRIMARY, 2, 0, sent::add)); ++ final var press = sent.getLast(); ++ assertFalse(actions.send(7, NativeCookingAction.Mode.PRIMARY, 2, 0, sent::add)); ++ final var next = update(2); ++ actions.update(next); ++ actions.presented(next, new CookingUi.RecipeActionScope(Map.of())); ++ assertTrue(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 2, sent::add)); ++ final var release = sent.getLast(); ++ assertEquals(press.controllerInputMode(), release.controllerInputMode()); ++ assertEquals(press.updateSequence(), release.updateSequence()); ++ assertEquals(press.cursorRevision(), release.cursorRevision()); ++ final var forged = new NativeCookingAction(press.menuId(), press.sequence(), press.updateSequence(), press.menuRevision(), ++ press.inventoryRevision(), press.cursorRevision(), press.recipeId(), press.mode(), 1, press.eventPhase(), press.requestSequence()); ++ assertFalse(actions.result(new NativeCookingActionResult(forged, true, true))); ++ assertTrue(actions.result(new NativeCookingActionResult(release, true, false))); ++ assertTrue(actions.result(new NativeCookingActionResult(press, true, false))); ++ assertEquals(3, sent.size()); ++ assertFalse(actions.cancel(sent::add)); ++ } ++ ++ @Test ++ void focusCancellationReleasesItsOriginalContextAfterNewFramesOrAmbiguousTransport() { ++ for (final boolean ambiguous : new boolean[]{false, true}) { ++ final var actions = new NativeCookingActions(first, () -> 0); ++ final var sent = new ArrayList(); ++ actions.presented(first, ALL_COMMANDS); ++ if (ambiguous) { ++ assertThrows(IllegalStateException.class, () -> actions.send(7, NativeCookingAction.Mode.PRIMARY, 2, 1, input -> { ++ sent.add(input); ++ throw new IllegalStateException(); ++ })); ++ } else { ++ assertTrue(actions.send(7, NativeCookingAction.Mode.PRIMARY, 2, 1, sent::add)); ++ actions.update(update(2)); ++ } ++ assertTrue(actions.cancel(sent::add)); ++ final var focus = sent.getFirst(); ++ final var release = sent.getLast(); ++ assertEquals(2, release.eventPhase()); ++ assertEquals(focus.controllerInputMode(), release.controllerInputMode()); ++ assertEquals(focus.updateSequence(), release.updateSequence()); ++ assertEquals(focus.cursorRevision(), release.cursorRevision()); ++ assertFalse(actions.cancel(sent::add)); ++ assertEquals(2, sent.size()); ++ } ++ } ++ ++ @Test ++ void aLateFocusRejectionCannotStrandANewerSelection() { ++ final var actions = new NativeCookingActions(first, () -> 0); ++ final var sent = new ArrayList(); ++ actions.presented(first, ALL_COMMANDS); ++ assertTrue(actions.send(7, NativeCookingAction.Mode.PRIMARY, 2, 1, sent::add)); ++ final var focus = sent.getFirst(); ++ final var next = update(2); ++ actions.update(next); ++ actions.presented(next, ALL_COMMANDS); ++ assertTrue(actions.send(7, NativeCookingAction.Mode.PRIMARY, 2, 0, sent::add)); ++ final var press = sent.getLast(); ++ assertTrue(actions.result(new NativeCookingActionResult(focus, false, false))); ++ assertTrue(actions.send(7, NativeCookingAction.Mode.PRIMARY, 1, 2, sent::add)); ++ final var release = sent.getLast(); ++ assertEquals(press.updateSequence(), release.updateSequence()); ++ assertEquals(press.controllerInputMode(), release.controllerInputMode()); ++ assertTrue(actions.result(new NativeCookingActionResult(release, true, false))); ++ assertTrue(actions.result(new NativeCookingActionResult(press, true, false))); ++ assertFalse(actions.cancel(sent::add)); ++ } ++ ++ 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/NativeCookingPresentationControllerTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingPresentationControllerTest.java new file mode 100644 -index 0000000..08d992c +index 00000000..906aebfb --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingPresentationControllerTest.java -@@ -0,0 +1,111 @@ +@@ -0,0 +1,216 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -3830,10 +7540,15 @@ index 0000000..08d992c +package com.viaversion.viafabricplus.bedrock.screen.container; + +import java.util.concurrent.atomic.AtomicInteger; ++import java.util.concurrent.atomic.AtomicLong; ++import java.util.ArrayList; ++import java.util.Map; +import net.minecraft.world.item.ItemStack; +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.NativeCookingAction; ++import net.raphimc.viabedrock.api.model.NativeCookingActionResult; +import net.raphimc.viabedrock.api.resourcepack.ui.CookingUi; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; @@ -3846,6 +7561,106 @@ index 0000000..08d992c + static void bootstrap() throws Exception { NativeCookingPresentationTest.bootstrap(); } + + @Test ++ void actionsAndRepliesStayWithThePresentedFrameAndCapabilityWithdrawalRetiresItsGesture() { ++ final var controller = new NativeCookingPresentationController(); ++ final var owner = new Object(); ++ final var menu = new Object(); ++ final var first = message(CookingUi.Family.FURNACE, 1); ++ final var sent = new ArrayList(); ++ controller.begin(owner); ++ assertTrue(controller.accept(owner, menu, 5, first, item -> new ItemStack(Items.IRON_INGOT, 3))); ++ final var presentation = controller.current(owner, menu, 5); ++ // A delayed initial advertisement must not retire a passive presentation. ++ controller.capability(owner, false); ++ assertSame(presentation, controller.current(owner, menu, 5)); ++ assertFalse(controller.action(owner, menu, 5, 7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ present(presentation); ++ controller.capability(owner, true); ++ assertFalse(controller.action(new Object(), menu, 5, 7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ assertFalse(controller.action(owner, new Object(), 5, 7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ assertFalse(controller.action(owner, menu, 6, 7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ assertTrue(sent.isEmpty()); ++ assertTrue(controller.action(owner, menu, 5, 7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ assertTrue(controller.accept(owner, menu, 5, message(CookingUi.Family.FURNACE, 2), item -> new ItemStack(Items.IRON_INGOT, 3))); ++ assertTrue(controller.action(owner, menu, 5, 7, NativeCookingAction.Mode.PRIMARY, 1, 2, sent::add)); ++ assertEquals(first.updateSequence(), sent.getLast().updateSequence()); ++ final var result = new NativeCookingActionResult(sent.getFirst(), true, false); ++ assertFalse(controller.result(new Object(), menu, 5, result)); ++ assertFalse(controller.result(owner, new Object(), 5, result)); ++ assertTrue(controller.result(owner, menu, 5, result)); ++ assertFalse(controller.result(owner, menu, 5, result)); ++ controller.capability(new Object(), false); ++ assertSame(presentation, controller.current(owner, menu, 5)); ++ controller.capability(owner, false); ++ assertNull(controller.current(owner, menu, 5)); ++ assertFalse(presentation.action(7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ assertFalse(controller.result(owner, menu, 5, new NativeCookingActionResult(sent.getLast(), true, false))); ++ controller.capability(owner, true); ++ assertFalse(controller.accept(owner, menu, 5, message(CookingUi.Family.FURNACE, 3), item -> new ItemStack(Items.IRON_INGOT, 3))); ++ assertTrue(controller.accept(owner, menu, 5, sequence(first, 4), item -> new ItemStack(Items.IRON_INGOT, 3))); ++ present(controller.current(owner, menu, 5)); ++ assertTrue(controller.action(owner, menu, 5, 7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ assertEquals(1, sent.getLast().requestSequence()); ++ assertEquals(4, sent.getLast().sequence()); ++ assertFalse(controller.result(owner, menu, 5, result)); ++ } ++ ++ @Test ++ void invalidUpdatesAndMenuReuseCannotRevivePendingInput() { ++ final var controller = new NativeCookingPresentationController(); ++ final var owner = new Object(); ++ final var menu = new Object(); ++ final var first = message(CookingUi.Family.FURNACE, 1); ++ final var sent = new ArrayList(); ++ controller.begin(owner); ++ assertTrue(controller.accept(owner, menu, 5, first, item -> new ItemStack(Items.IRON_INGOT, 3))); ++ final var old = controller.current(owner, menu, 5); ++ present(old); ++ assertTrue(controller.action(owner, menu, 5, 7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ assertThrows(IllegalArgumentException.class, () -> controller.accept(owner, menu, 5, message(CookingUi.Family.FURNACE, 2), item -> ItemStack.EMPTY)); ++ assertFalse(old.result(new NativeCookingActionResult(sent.getFirst(), true, true))); ++ assertTrue(controller.accept(owner, menu, 5, sequence(first, 4), item -> new ItemStack(Items.IRON_INGOT, 3))); ++ final var next = controller.current(owner, menu, 5); ++ present(next); ++ assertTrue(controller.action(owner, menu, 5, 7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ controller.tick(owner, new Object(), 5); ++ assertFalse(next.result(new NativeCookingActionResult(sent.getLast(), true, true))); ++ assertFalse(controller.accept(owner, menu, 5, sequence(first, 4), item -> new ItemStack(Items.IRON_INGOT, 3))); ++ final var nextOwner = new Object(); ++ controller.begin(nextOwner); ++ assertTrue(controller.accept(nextOwner, menu, 5, first, item -> new ItemStack(Items.IRON_INGOT, 3))); ++ present(controller.current(nextOwner, menu, 5)); ++ assertTrue(controller.action(nextOwner, menu, 5, 7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ assertFalse(controller.result(owner, menu, 5, new NativeCookingActionResult(sent.getFirst(), true, true))); ++ } ++ ++ @Test ++ void responseTimeoutRetiresTheOpeningWithoutResettingItsSequenceFloor() { ++ final var clock = new AtomicLong(); ++ final var controller = new NativeCookingPresentationController(clock::get); ++ final var owner = new Object(); ++ final var menu = new Object(); ++ final var first = message(CookingUi.Family.FURNACE, 1); ++ final var sent = new ArrayList(); ++ controller.begin(owner); ++ assertTrue(controller.accept(owner, menu, 5, first, item -> new ItemStack(Items.IRON_INGOT, 3))); ++ present(controller.current(owner, menu, 5)); ++ assertTrue(controller.action(owner, menu, 5, 7, NativeCookingAction.Mode.PRIMARY, 1, 0, sent::add)); ++ assertTrue(controller.accept(owner, menu, 5, message(CookingUi.Family.FURNACE, 2), item -> new ItemStack(Items.IRON_INGOT, 3))); ++ clock.set(10_000_000_000L); ++ controller.tick(owner, menu, 5); ++ assertNull(controller.current(owner, menu, 5)); ++ controller.begin(owner); ++ assertFalse(controller.accept(owner, menu, 5, message(CookingUi.Family.FURNACE, 3), item -> new ItemStack(Items.IRON_INGOT, 3))); ++ assertFalse(controller.result(owner, menu, 5, new NativeCookingActionResult(sent.getFirst(), true, true))); ++ } ++ ++ private static void present(final NativeCookingPresentation presentation) { ++ final var message = presentation.message(); ++ presentation.resolve(message.menuId(), "", message.view().slots(), Map.of(), false); ++ } ++ ++ @Test + void snapshotsAndProgressStayWithTheirConnectionAndExactMenuLifetime() { + final var controller = new NativeCookingPresentationController(); + final var owner = new Object(); @@ -3935,10 +7750,10 @@ index 0000000..08d992c +} 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..c685e84 +index 00000000..0d86d83a --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingPresentationTest.java -@@ -0,0 +1,125 @@ +@@ -0,0 +1,144 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -3992,6 +7807,25 @@ index 0000000..c685e84 + } + + @Test ++ void anOlderCellExitCannotEraseNewSelectionOrAdvanceItsRevision() { ++ 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)); ++ } ++ } ++ ++ @Test + void realRecipesAndProgressResolveWithoutChangingItemAuthority() { + for (final var family : CookingUi.Family.values()) { + final var message = message(family, 1); @@ -4064,9 +7898,1402 @@ index 0000000..c685e84 + Map.of(7, new StructuredItem(BuiltInRegistries.ITEM.getId(Items.IRON_INGOT), 3, new StructuredDataContainer()))); + } +} +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 00000000..47ea1628 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeCookingScreenTest.java +@@ -0,0 +1,1387 @@ ++/* ++ * 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 com.mojang.blaze3d.platform.InputConstants.MOUSE_BUTTON_LEFT; ++import static com.mojang.blaze3d.platform.InputConstants.MOUSE_BUTTON_MIDDLE; ++import static com.mojang.blaze3d.platform.InputConstants.MOUSE_BUTTON_RIGHT; ++ ++import static org.junit.jupiter.api.Assertions.*; ++import static org.mockito.Mockito.*; ++ ++import com.viaversion.viafabricplus.bedrock.ViaFabricPlusBedrock; ++import com.viaversion.viafabricplus.bedrock.screen.container.NativeCookingPreferenceControl.Kind; ++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.MouseHandler; ++import net.minecraft.client.gui.Font; ++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.mojang.blaze3d.platform.Window; ++import net.minecraft.client.input.MouseButtonInfo; ++import net.minecraft.client.multiplayer.ClientPacketListener; ++import net.minecraft.client.multiplayer.MultiPlayerGameMode; ++import net.minecraft.client.player.LocalPlayer; ++import net.minecraft.network.Connection; ++import net.minecraft.network.chat.Component; ++import net.minecraft.world.entity.EntityEquipment; ++import net.minecraft.world.entity.player.Inventory; ++import net.minecraft.world.inventory.FurnaceMenu; ++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.resourcepack.ui.CookingUi; ++import net.raphimc.viabedrock.protocol.model.FurnaceOptions; ++ ++import org.junit.jupiter.api.BeforeAll; ++import org.junit.jupiter.api.Test; ++import org.mockito.MockedStatic; ++ ++import java.util.ArrayList; ++import java.util.List; ++import java.util.Set; ++import java.util.concurrent.atomic.AtomicLong; ++import java.util.concurrent.atomic.AtomicReference; ++ ++class NativeCookingScreenTest { ++ @BeforeAll ++ static void bootstrap() throws Exception { ++ NativeCookingPresentationTest.bootstrap(); ++ } ++ ++ @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 authoredScreenHostOwnsItsGlobalNavigationAcrossAModelRefresh() throws Exception { ++ try (final var f = new Fixture()) { ++ f.enablePreferences(); ++ final var key = new KeyEvent(InputConstants.KEY_RETURN, 0, 0); ++ f.navigation(2, 1, 4, 2, false, NavigationHost.SCREEN); ++ assertTrue(f.screen.keyPressed(key)); ++ assertTrue(f.screen.keyPressed(key)); ++ assertEquals(List.of(new FurnaceOptions(1, 2, true, 2)), f.preferences); ++ f.navigation(3, 1, 2, 2, false, NavigationHost.SCREEN); ++ assertTrue(f.screen.keyPressed(key)); ++ assertEquals(1, f.preferences.size()); ++ assertTrue(f.screen.keyReleased(key)); ++ assertTrue(f.screen.keyPressed(key)); ++ assertTrue(f.screen.keyReleased(key)); ++ assertEquals(List.of(new FurnaceOptions(1, 2, true, 2), new FurnaceOptions(1, 4, true, 2)), f.preferences); ++ assertTrue(f.sent.isEmpty()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ ++ @Test ++ void anUnsupportedPlayerHurtHostPolicyRetainsTheOrdinaryMenuBeforeInput() throws Exception { ++ try (final var f = new Fixture()) { ++ f.enablePreferences(); ++ f.navigation(2, 1, 4, 2, false, NavigationHost.SCREEN); ++ final var before = f.presentation.message(); ++ final var exported = JsonParser.parseString(before.program()).getAsJsonObject(); ++ exported.getAsJsonObject("definitions").getAsJsonObject("furnace.furnace_screen").getAsJsonObject("value") ++ .addProperty("close_on_player_hurt", true); ++ final var changed = 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, changed, _ -> new ItemStack(Items.IRON_INGOT))); ++ f.screen.containerTick(); ++ assertSame(f.ordinary, f.displayed.get()); ++ assertTrue(f.preferences.isEmpty()); ++ assertTrue(f.sent.isEmpty()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ ++ @Test ++ void zeroExtentGlobalNavigationRequiresItsPreferenceChannelBeforeInput() throws Exception { ++ try (final var f = new Fixture()) { ++ f.navigation(2, 1, 4, 2, false, NavigationHost.PANEL); ++ assertSame(f.ordinary, f.displayed.get()); ++ assertTrue(f.preferences.isEmpty()); ++ assertTrue(f.sent.isEmpty()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ ++ @Test ++ void pointerTabNavigationPublishesCurrentOptionsAndWaitsForItsMatchingModel() throws Exception { ++ try (final var f = new Fixture()) { ++ f.enablePreferences(); ++ f.navigation(2, 1, 4, 2, false, NavigationHost.BUTTON); ++ f.menu.setCarried(new ItemStack(Items.IRON_INGOT, 3)); ++ assertTrue(f.screen.mouseClicked(pointer(MOUSE_BUTTON_LEFT, 310, 10), false)); ++ assertTrue(f.screen.mouseClicked(pointer(MOUSE_BUTTON_LEFT, 310, 10), false)); ++ assertEquals(List.of(new FurnaceOptions(1, 2, true, 2)), f.preferences); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_LEFT, 630, 300))); ++ f.navigation(3, -1, 4, 2, false, NavigationHost.BUTTON); ++ assertTrue(f.screen.mouseClicked(pointer(MOUSE_BUTTON_LEFT, 310, 10), false)); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_LEFT, 630, 300))); ++ assertEquals(1, f.preferences.size()); ++ f.navigation(4, -1, 2, 2, false, NavigationHost.BUTTON); ++ assertTrue(f.screen.mouseClicked(pointer(MOUSE_BUTTON_LEFT, 310, 10), false)); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_LEFT, 630, 300))); ++ assertEquals(List.of(new FurnaceOptions(1, 2, true, 2), new FurnaceOptions(1, 4, true, 2)), f.preferences); ++ assertEquals(3, f.menu.getCarried().getCount()); ++ assertTrue(f.sent.isEmpty()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ ++ @Test ++ void globalSlotCommandsRetainTheOrdinaryMenuBeforeAnyAction() 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"); ++ slot.getAsJsonArray("button_mappings").get(0).getAsJsonObject().addProperty("scope", "global"); ++ 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.ordinary, f.displayed.get()); ++ assertTrue(f.sent.isEmpty()); ++ assertTrue(f.preferences.isEmpty()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ ++ @Test ++ void focusedViewTabGroupsTransferFocusAndRetainHeldInputAcrossTheMatchingModel() throws Exception { ++ try (final var f = new Fixture()) { ++ f.enablePreferences(); ++ final var key = new KeyEvent(InputConstants.KEY_RETURN, 0, 0); ++ f.navigation(2, 1, 2, 2, false, NavigationHost.FOCUSED_VIEW_TABS); ++ assertSame(f.screen, f.displayed.get()); ++ final var field = BedrockNativeContainerScreen.class.getDeclaredField("nativeInput"); ++ field.setAccessible(true); ++ final var input = (NativeContainerInput) field.get(f.screen); ++ assertTrue(f.screen.keyPressed(key)); ++ assertEquals(3, input.focused().properties().get("toggle_group_forced_index").getAsInt()); ++ assertEquals(List.of(new FurnaceOptions(1, 3, true, 2)), f.preferences); ++ f.navigation(3, 1, 3, 2, false, NavigationHost.FOCUSED_VIEW_TABS); ++ assertTrue(f.screen.keyPressed(key)); ++ assertEquals(1, f.preferences.size()); ++ assertTrue(f.screen.keyReleased(key)); ++ assertTrue(f.screen.keyPressed(key)); ++ assertEquals(1, input.focused().properties().get("toggle_group_forced_index").getAsInt()); ++ assertTrue(f.screen.keyReleased(key)); ++ assertEquals(List.of(new FurnaceOptions(1, 3, true, 2), new FurnaceOptions(1, 1, true, 2)), f.preferences); ++ assertTrue(f.sent.isEmpty()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ ++ @Test ++ void viewTabGroupsPublishAuthoredSelectionAndKeepHeldInputAcrossTheMatchingModel() throws Exception { ++ try (final var f = new Fixture()) { ++ f.enablePreferences(); ++ final var key = new KeyEvent(InputConstants.KEY_RETURN, 0, 0); ++ f.navigation(2, 1, 2, 2, false, NavigationHost.VIEW_TABS); ++ assertSame(f.screen, f.displayed.get()); ++ assertTrue(f.screen.keyPressed(key)); ++ assertTrue(f.screen.keyPressed(key)); ++ assertEquals(List.of(new FurnaceOptions(1, 3, true, 2)), f.preferences); ++ f.navigation(3, 1, 3, 2, false, NavigationHost.VIEW_TABS); ++ assertTrue(f.screen.keyPressed(key)); ++ assertEquals(1, f.preferences.size()); ++ assertTrue(f.screen.keyReleased(key)); ++ assertTrue(f.screen.keyPressed(key)); ++ assertTrue(f.screen.keyReleased(key)); ++ assertEquals(List.of(new FurnaceOptions(1, 3, true, 2), new FurnaceOptions(1, 1, true, 2)), f.preferences); ++ assertTrue(f.sent.isEmpty()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ ++ @Test ++ void globalScopeTabNavigationPreservesKeyboardOwnershipAcrossTheMatchingModel() throws Exception { ++ try (final var f = new Fixture()) { ++ f.enablePreferences(); ++ final var key = new KeyEvent(InputConstants.KEY_RETURN, 0, 0); ++ f.navigation(2, 1, 4, 2, false, NavigationHost.GLOBAL_PANEL); ++ assertSame(f.screen, f.displayed.get()); ++ assertTrue(f.screen.keyPressed(key)); ++ assertTrue(f.screen.keyPressed(key)); ++ assertTrue(f.screen.keyReleased(key)); ++ assertEquals(List.of(new FurnaceOptions(1, 2, true, 2)), f.preferences); ++ f.navigation(3, 1, 2, 2, false, NavigationHost.GLOBAL_PANEL); ++ assertTrue(f.screen.keyPressed(key)); ++ assertTrue(f.screen.keyReleased(key)); ++ assertEquals(List.of(new FurnaceOptions(1, 2, true, 2), new FurnaceOptions(1, 4, true, 2)), f.preferences); ++ assertTrue(f.sent.isEmpty()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ ++ @Test ++ void globalPanelTabNavigationUsesKeyboardPhasesAndNativeLayoutRules() throws Exception { ++ try (final var f = new Fixture()) { ++ 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)); ++ assertTrue(f.screen.keyReleased(key)); ++ assertEquals(List.of(new FurnaceOptions(1, 2, true, 2)), f.preferences); ++ f.screen.containerTick(); ++ assertFalse(f.recipeSelected()); ++ f.navigation(4, 1, 2, 2, false, NavigationHost.PANEL); ++ assertTrue(f.screen.keyPressed(key)); ++ assertTrue(f.screen.keyReleased(key)); ++ assertEquals(List.of(new FurnaceOptions(1, 2, true, 2), new FurnaceOptions(1, 4, true, 2)), f.preferences); ++ assertTrue(f.sent.isEmpty()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ ++ @Test ++ void recipeHoverSelectionClearsOnExitAndSurvivesUnchangedModelRefreshes() throws Exception { ++ try (final var f = new Fixture()) { ++ final var before = f.presentation.message(); ++ final var exported = JsonParser.parseString(before.program()).getAsJsonObject(); ++ final var recipe = exported.getAsJsonObject("definitions").getAsJsonObject("furnace.recipe").getAsJsonObject("value"); ++ recipe.getAsJsonArray("button_mappings").add(JsonParser.parseString(""" ++ {"to_button_id":"button.container_slot_hovered","mapping_type":"pressed"} ++ """)); ++ recipe.add("controls", JsonParser.parseString(""" ++ [{"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()); ++ assertTrue(f.sent.isEmpty()); ++ assertTrue(f.preferences.isEmpty()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ ++ @Test ++ void unsupportedFocusedSlotActionsRestoreTheOrdinaryMenuBeforeInput() 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("mapping_type", "focused"); ++ slot.addProperty("focus_enabled", true); ++ slot.getAsJsonArray("button_mappings").add(mapping); ++ 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.ordinary, f.displayed.get()); ++ assertTrue(f.sent.isEmpty()); ++ assertTrue(f.preferences.isEmpty()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ ++ @Test ++ void nonConsumingPreferenceEventsComposeBeforePublishingTheFinalOptions() throws Exception { ++ try (final var f = new Fixture()) { ++ f.enablePreferences(); ++ f.preferences(2, Kind.TAB, 4, false, 2); ++ final var before = f.presentation.message(); ++ final var exported = JsonParser.parseString(before.program()).getAsJsonObject(); ++ final var controls = exported.getAsJsonObject("definitions").getAsJsonObject("furnace.furnace_panel") ++ .getAsJsonObject("value").getAsJsonArray("controls"); ++ final var filter = controls.get(controls.size() - 1).getAsJsonObject().getAsJsonObject("preference").deepCopy(); ++ filter.addProperty("toggle_name", "toggle.enable_filtering"); ++ filter.addProperty("consume_hover_events", false); ++ for (final var value : filter.getAsJsonArray("button_mappings")) value.getAsJsonObject().addProperty("consume_event", false); ++ filter.getAsJsonArray("bindings").get(0).getAsJsonObject().addProperty("binding_name", "#filtering_enabled"); ++ final var wrapper = new JsonObject(); ++ wrapper.add("filter_preference", filter); ++ controls.add(wrapper); ++ final var message = 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, message, _ -> new ItemStack(Items.IRON_INGOT))); ++ f.screen.containerTick(); ++ assertSame(f.screen, f.displayed.get()); ++ assertTrue(f.screen.mouseClicked(pointer(MOUSE_BUTTON_LEFT, 310, 10), false)); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_LEFT, 630, 300))); ++ assertEquals(List.of(new FurnaceOptions(1, 2, true, 2)), f.preferences); ++ assertTrue(f.sent.isEmpty()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ ++ @Test ++ void arrowKeysRequireAnAuthoredViewMappingBeforeTheyCanFocusAPreference() throws Exception { ++ try (final var f = new Fixture()) { ++ f.enablePreferences(); ++ f.preferences(2, Kind.FILTER, 4, false, 2); ++ final var before = f.presentation.message(); ++ final var exported = JsonParser.parseString(before.program()).getAsJsonObject(); ++ final var controls = exported.getAsJsonObject("definitions").getAsJsonObject("furnace.furnace_panel") ++ .getAsJsonObject("value").getAsJsonArray("controls"); ++ controls.asList().removeIf(control -> control.getAsJsonObject().has("view_navigation")); ++ final var message = 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, message, _ -> new ItemStack(Items.IRON_INGOT))); ++ f.screen.containerTick(); ++ assertSame(f.screen, f.displayed.get()); ++ assertFalse(f.screen.keyPressed(new KeyEvent(InputConstants.KEY_RIGHT, 0, 0))); ++ assertFalse(f.screen.keyPressed(new KeyEvent(InputConstants.KEY_RETURN, 0, 0))); ++ assertTrue(f.preferences.isEmpty()); ++ assertTrue(f.sent.isEmpty()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ ++ @Test ++ void focusedKeyboardPreferencesUseNativePressPhasesAcrossTheMatchingSnapshot() throws Exception { ++ try (final var f = new Fixture()) { ++ f.enablePreferences(); ++ f.preferences(2, Kind.FILTER, 4, false, 2); ++ assertTrue(f.screen.keyPressed(new KeyEvent(InputConstants.KEY_RIGHT, 0, 0))); ++ final var key = new KeyEvent(InputConstants.KEY_RETURN, 0, 0); ++ assertTrue(f.screen.keyPressed(key)); ++ assertEquals(List.of(new FurnaceOptions(1, 4, true, 2)), f.preferences); ++ assertTrue(f.screen.keyPressed(key)); ++ assertTrue(f.screen.keyReleased(key)); ++ assertEquals(1, f.preferences.size()); ++ f.preferences(3, Kind.FILTER, 4, true, 2); ++ assertTrue(f.screen.keyPressed(key)); ++ assertTrue(f.screen.keyReleased(key)); ++ assertEquals(List.of(new FurnaceOptions(1, 4, true, 2), new FurnaceOptions(1, 4, false, 2)), f.preferences); ++ assertTrue(f.sent.isEmpty()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ ++ @Test ++ void layoutPointersPreserveTabAndFilterAndWaitForBothDirections() throws Exception { ++ try (final var f = new Fixture()) { ++ f.enablePreferences(); ++ f.preferences(2, Kind.LAYOUT, 2, true, 0); ++ assertTrue(f.screen.mouseClicked(pointer(MOUSE_BUTTON_LEFT, 310, 10), false)); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_LEFT, 630, 300))); ++ assertEquals(List.of(new FurnaceOptions(1, 2, true, 1)), f.preferences); ++ f.preferences(3, Kind.LAYOUT, 2, true, 2); ++ assertTrue(f.screen.mouseClicked(pointer(MOUSE_BUTTON_LEFT, 310, 10), false)); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_LEFT, 630, 300))); ++ assertEquals(1, f.preferences.size()); ++ f.preferences(4, Kind.LAYOUT, 2, true, 1); ++ assertTrue(f.screen.mouseClicked(pointer(MOUSE_BUTTON_LEFT, 310, 10), false)); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_LEFT, 310, 10))); ++ assertEquals(1, f.preferences.size()); ++ assertTrue(f.screen.mouseClicked(pointer(MOUSE_BUTTON_LEFT, 350, 10), false)); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_LEFT, 630, 300))); ++ assertEquals( ++ List.of(new FurnaceOptions(1, 2, true, 1), new FurnaceOptions(1, 2, true, 2)), ++ f.preferences); ++ f.preferences(5, Kind.LAYOUT, 2, true, 2); ++ assertSame(f.screen, f.displayed.get()); ++ assertTrue(f.sent.isEmpty()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ ++ @Test ++ void carriedChangesKeepTheScreenAndMouseRecipesResumeOnlyAfterPutBack() throws Exception { ++ for (final int count : List.of(1, 64)) { ++ try (final var f = new Fixture()) { ++ f.menu.slots.get(3).set(new ItemStack(Items.IRON_INGOT, count)); ++ f.menu.setCarried(new ItemStack(Items.IRON_INGOT, count)); ++ f.screen.prepare(mock(Font.class), 640, 320); ++ f.screen.containerTick(); ++ assertSame(f.screen, f.displayed.get()); ++ assertEquals(count, f.menu.slots.get(3).getItem().getCount()); ++ assertEquals(count, f.menu.getCarried().getCount()); ++ assertTrue(f.screen.mouseClicked(f.recipePointer(MOUSE_BUTTON_LEFT), false)); ++ assertTrue(f.screen.mouseDragged(pointer(MOUSE_BUTTON_LEFT, 630, 300), 1, 1)); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_LEFT, 630, 300))); ++ assertTrue(f.sent.isEmpty()); ++ assertTrue( ++ f.controller.accept( ++ f.owner, ++ f.menu, ++ 5, ++ message(2, true), ++ _ -> new ItemStack(Items.IRON_INGOT))); ++ f.screen.containerTick(); ++ assertSame(f.screen, f.displayed.get()); ++ assertEquals(count, f.menu.getCarried().getCount()); ++ f.menu.setCarried(ItemStack.EMPTY); ++ f.screen.containerTick(); ++ assertSame(f.screen, f.displayed.get()); ++ assertTrue(f.screen.mouseClicked(f.recipePointer(MOUSE_BUTTON_LEFT), false)); ++ assertTrue(f.screen.mouseReleased(f.recipePointer(MOUSE_BUTTON_LEFT))); ++ assertEquals( ++ List.of(0, 2), ++ f.sent.stream().map(NativeCookingAction::eventPhase).toList()); ++ assertEquals(count, f.menu.slots.get(3).getItem().getCount()); ++ verifyNoInteractions(f.game.gameMode); ++ verify(f.player, never()).closeContainer(); ++ } ++ } ++ } ++ ++ @Test ++ void carriedItemsPermitTabAndFilterChangesButWaitForTheirMatchingModel() throws Exception { ++ for (final Kind kind : List.of(Kind.TAB, Kind.FILTER)) { ++ for (final int count : List.of(1, 64)) { ++ try (final var f = new Fixture()) { ++ f.enablePreferences(); ++ f.menu.setCarried(new ItemStack(Items.IRON_INGOT, count)); ++ f.preferences(2, kind, 4, false, 2); ++ assertTrue(f.screen.mouseClicked(pointer(MOUSE_BUTTON_LEFT, 310, 10), false)); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_LEFT, 630, 300))); ++ final var expected = ++ new FurnaceOptions( ++ 1, kind == Kind.FILTER ? 4 : 2, kind == Kind.FILTER, 2); ++ assertEquals(List.of(expected), f.preferences); ++ f.preferences(3, kind, 4, false, 2); ++ assertTrue(f.screen.mouseClicked(pointer(MOUSE_BUTTON_LEFT, 310, 10), false)); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_LEFT, 630, 300))); ++ assertEquals(List.of(expected), f.preferences); ++ f.preferences(4, kind, expected.leftTab(), expected.filtering(), 2); ++ assertSame(f.screen, f.displayed.get()); ++ assertEquals(count, f.menu.getCarried().getCount()); ++ assertTrue(f.sent.isEmpty()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ } ++ } ++ ++ @Test ++ void carriedLayoutSelectionRemainsGuardedUntilPutBack() throws Exception { ++ try (final var f = new Fixture()) { ++ f.enablePreferences(); ++ f.menu.setCarried(new ItemStack(Items.IRON_INGOT, 64)); ++ f.preferences(2, Kind.LAYOUT, 2, true, 2); ++ assertTrue(f.screen.mouseClicked(pointer(MOUSE_BUTTON_LEFT, 310, 10), false)); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_LEFT, 630, 300))); ++ assertTrue(f.preferences.isEmpty()); ++ assertEquals(64, f.menu.getCarried().getCount()); ++ f.menu.setCarried(ItemStack.EMPTY); ++ f.screen.containerTick(); ++ assertSame(f.screen, f.displayed.get()); ++ assertTrue(f.screen.mouseClicked(pointer(MOUSE_BUTTON_LEFT, 310, 10), false)); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_LEFT, 630, 300))); ++ assertEquals(List.of(new FurnaceOptions(1, 2, true, 1)), f.preferences); ++ assertTrue(f.sent.isEmpty()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ ++ @Test ++ void forcedProfileLayoutControlsRestoreTheOriginalBeforeAcceptingInput() throws Exception { ++ try (final var f = new Fixture()) { ++ f.enablePreferences(); ++ f.preferences(2, Kind.LAYOUT, 4, false, 2); ++ final var before = f.presentation.message(); ++ final var snapshot = JsonParser.parseString(before.snapshot()).getAsJsonObject(); ++ snapshot.getAsJsonObject("book").addProperty("forcedRecipeBookLayout", true); ++ final var forced = ++ new NativeCookingMessage( ++ before.menuId(), ++ before.sequence(), ++ 3, ++ before.menuRevision(), ++ before.inventoryRevision(), ++ before.cursorRevision(), ++ before.program(), ++ snapshot.toString(), ++ before.recipeItems()); ++ assertTrue( ++ f.controller.accept( ++ f.owner, f.menu, 5, forced, _ -> new ItemStack(Items.IRON_INGOT))); ++ f.screen.containerTick(); ++ assertSame(f.ordinary, f.displayed.get()); ++ assertTrue(f.preferences.isEmpty()); ++ assertTrue(f.sent.isEmpty()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ ++ @Test ++ void tabAndFilterPointersWaitForTheMatchingNewSnapshot() throws Exception { ++ for (final boolean filter : List.of(false, true)) { ++ try (final var f = new Fixture()) { ++ f.enablePreferences(); ++ f.preferences(2, filter ? Kind.FILTER : Kind.TAB, 4, false, 2); ++ assertTrue(f.screen.mouseClicked(pointer(MOUSE_BUTTON_RIGHT, 310, 10), false)); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_RIGHT, 630, 300))); ++ assertTrue(f.preferences.isEmpty()); ++ assertTrue(f.screen.mouseClicked(pointer(MOUSE_BUTTON_LEFT, 310, 10), false)); ++ assertTrue(f.screen.mouseDragged(pointer(MOUSE_BUTTON_LEFT, 630, 300), 320, 290)); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_LEFT, 630, 300))); ++ final var expected = new FurnaceOptions(1, filter ? 4 : 2, filter, 2); ++ assertEquals(List.of(expected), f.preferences); ++ f.preferences(3, filter ? Kind.FILTER : Kind.TAB, 4, false, 2); ++ assertTrue(f.screen.mouseClicked(pointer(MOUSE_BUTTON_LEFT, 310, 10), false)); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_LEFT, 630, 300))); ++ assertEquals(1, f.preferences.size()); ++ f.preferences( ++ 4, ++ filter ? Kind.FILTER : Kind.TAB, ++ expected.leftTab(), ++ expected.filtering(), ++ 2); ++ assertTrue(f.screen.mouseClicked(pointer(MOUSE_BUTTON_LEFT, 310, 10), false)); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_LEFT, 630, 300))); ++ assertEquals(filter ? 2 : 1, f.preferences.size()); ++ if (filter) assertFalse(f.preferences.getLast().filtering()); ++ assertTrue(f.sent.isEmpty()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ } ++ ++ @Test ++ void preferencePointersCannotInterruptRecipeInputAndMissingRepliesRestoreTheOriginal() ++ throws Exception { ++ try (final var f = new Fixture()) { ++ f.enablePreferences(); ++ f.preferences(2, Kind.FILTER, 4, false, 2); ++ assertTrue(f.screen.mouseClicked(f.recipePointer(MOUSE_BUTTON_LEFT), false)); ++ assertTrue(f.screen.mouseReleased(f.recipePointer(MOUSE_BUTTON_LEFT))); ++ assertEquals(2, f.sent.size()); ++ assertTrue(f.screen.mouseClicked(pointer(MOUSE_BUTTON_LEFT, 310, 10), false)); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_LEFT, 310, 10))); ++ assertTrue(f.preferences.isEmpty()); ++ for (final var action : List.copyOf(f.sent)) ++ assertTrue( ++ f.presentation.result(new NativeCookingActionResult(action, true, false))); ++ assertTrue(f.screen.mouseClicked(pointer(MOUSE_BUTTON_LEFT, 310, 10), false)); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_LEFT, 310, 10))); ++ assertEquals(List.of(new FurnaceOptions(1, 4, true, 2)), f.preferences); ++ f.clock.set(10_000_000_000L); ++ f.screen.containerTick(); ++ assertSame(f.ordinary, f.displayed.get()); ++ assertEquals(1, f.preferences.size()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ ++ @Test ++ void preferenceCapabilityAndAmbiguousTransportKeepOrdinaryControlsAvailable() throws Exception { ++ for (final boolean failTransport : List.of(false, true)) { ++ try (final var f = new Fixture()) { ++ f.enablePreferences(); ++ f.preferences(2, Kind.FILTER, 4, false, 2); ++ if (failTransport) { ++ f.networking ++ .when( ++ () -> ++ ClientPlayNetworking.send( ++ any(NativeFurnaceOptionsPayload.class))) ++ .thenThrow(new IllegalStateException("Ambiguous preference transport")); ++ assertTrue(f.screen.mouseClicked(pointer(MOUSE_BUTTON_LEFT, 310, 10), false)); ++ } else { ++ f.networking ++ .when( ++ () -> ++ ClientPlayNetworking.canSend( ++ NativeFurnaceOptionsPayload.TYPE)) ++ .thenReturn(false); ++ f.screen.containerTick(); ++ } ++ assertSame(f.ordinary, f.displayed.get()); ++ assertTrue(f.preferences.isEmpty()); ++ assertTrue(f.sent.isEmpty()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ } ++ ++ @Test ++ void pointerCaptureReleasesTheOriginalCellAfterTheRecipeDisappearsWithoutVanillaDispatch() ++ throws Exception { ++ try (final var f = new Fixture()) { ++ assertTrue(f.screen.mouseClicked(f.recipePointer(MOUSE_BUTTON_LEFT), false)); ++ assertEquals(1, f.sent.size()); ++ assertEquals(NativeCookingAction.Mode.PRIMARY, f.sent.getFirst().mode()); ++ assertTrue(f.screen.mouseClicked(pointer(MOUSE_BUTTON_RIGHT, 300, 300), false)); ++ assertTrue(f.screen.mouseDragged(pointer(MOUSE_BUTTON_LEFT, 300, 300), 279, 279)); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_RIGHT, 300, 300))); ++ assertEquals(1, f.sent.size()); ++ final var original = f.presentation.message(); ++ assertTrue( ++ f.controller.accept( ++ f.owner, ++ f.menu, ++ 5, ++ message(2, false), ++ _ -> new ItemStack(Items.IRON_INGOT))); ++ f.screen.containerTick(); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_LEFT, 300, 300))); ++ assertEquals(2, f.sent.size()); ++ final var release = f.sent.getLast(); ++ assertEquals( ++ new NativeCookingAction( ++ 5, ++ original.sequence(), ++ original.updateSequence(), ++ original.menuRevision(), ++ original.inventoryRevision(), ++ original.cursorRevision(), ++ 7, ++ NativeCookingAction.Mode.PRIMARY, ++ 1, ++ 2, ++ 2), ++ release); ++ f.menu.setCarried(new ItemStack(Items.IRON_INGOT)); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_LEFT, 900, 900))); ++ assertEquals(1, f.menu.getCarried().getCount()); ++ assertEquals(2, f.sent.size()); ++ verifyNoInteractions(f.game.gameMode); ++ verify(f.player, never()).closeContainer(); ++ } ++ } ++ ++ @Test ++ void secondaryClicksUseTheirAuthoredCommandAndUnmappedButtonsCannotReachInventoryInput() ++ throws Exception { ++ try (final var f = new Fixture()) { ++ assertTrue(f.screen.mouseClicked(f.recipePointer(MOUSE_BUTTON_RIGHT), false)); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_RIGHT, 400, 400))); ++ assertEquals( ++ List.of(NativeCookingAction.Mode.SECONDARY, NativeCookingAction.Mode.SECONDARY), ++ f.sent.stream().map(NativeCookingAction::mode).toList()); ++ assertEquals( ++ List.of(0, 2), f.sent.stream().map(NativeCookingAction::eventPhase).toList()); ++ assertTrue(f.screen.mouseClicked(f.recipePointer(MOUSE_BUTTON_MIDDLE), false)); ++ assertTrue(f.screen.mouseDragged(pointer(MOUSE_BUTTON_MIDDLE, 400, 400), 1, 1)); ++ assertTrue(f.screen.mouseReleased(pointer(MOUSE_BUTTON_MIDDLE, 400, 400))); ++ assertEquals(2, f.sent.size()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ ++ @Test ++ void retiredAuthorityRestoresTheExactOriginalScreenAndReleasesOnce() throws Exception { ++ try (final var f = new Fixture()) { ++ assertTrue(f.screen.mouseClicked(f.recipePointer(MOUSE_BUTTON_LEFT), false)); ++ f.controller.retire(f.owner, f.menu, 5); ++ assertNull(f.controller.current(f.owner, f.menu, 5)); ++ f.screen.containerTick(); ++ assertSame(f.ordinary, f.displayed.get()); ++ assertEquals( ++ List.of(0, 2), f.sent.stream().map(NativeCookingAction::eventPhase).toList()); ++ f.screen.containerTick(); ++ assertEquals(2, f.sent.size()); ++ verify(f.game, times(1)).setScreenAndShow(f.ordinary); ++ verify(f.player, never()).closeContainer(); ++ assertFalse(BedrockNativeContainers.replacing(f.menu)); ++ } ++ } ++ ++ @Test ++ void retiredScreenCannotRestoreOverAnotherScreenOrReleaseAgainstAReusedMenu() throws Exception { ++ try (final var f = new Fixture()) { ++ assertTrue(f.screen.mouseClicked(f.recipePointer(MOUSE_BUTTON_LEFT), false)); ++ final var replacement = new FurnaceMenu(5, f.inventory); ++ final var other = mock(AbstractContainerScreen.class); ++ f.player.containerMenu = replacement; ++ f.displayed.set(other); ++ f.controller.tick(f.owner, replacement, 5); ++ f.screen.containerTick(); ++ assertSame(other, f.displayed.get()); ++ assertEquals(1, f.sent.size()); ++ verify(f.game, never()).setScreenAndShow(any()); ++ } ++ } ++ ++ @Test ++ void ambiguousPrimarySendFallsBackWithOneNonTransferringTerminalRelease() throws Exception { ++ try (final var f = new Fixture()) { ++ f.networking ++ .when(() -> ClientPlayNetworking.send(any())) ++ .thenAnswer( ++ call -> { ++ final var action = ++ ((NativeCookingActionPayload) call.getArgument(0)).action(); ++ f.sent.add(action); ++ if (action.eventPhase() == 0) throw new IllegalStateException(); ++ return null; ++ }); ++ assertTrue(f.screen.mouseClicked(f.recipePointer(MOUSE_BUTTON_LEFT), false)); ++ assertNull(f.controller.current(f.owner, f.menu, 5)); ++ f.screen.containerTick(); ++ assertSame(f.ordinary, f.displayed.get()); ++ assertEquals( ++ List.of(0, 2), f.sent.stream().map(NativeCookingAction::eventPhase).toList()); ++ assertEquals(2, f.sent.getLast().requestSequence()); ++ verifyNoInteractions(f.game.gameMode); ++ } ++ } ++ ++ @Test ++ void unsupportedVisibleControlsCannotInstallAnInertCookingScreen() 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 = new JsonObject(); ++ control.add( ++ "unfinished", ++ JsonParser.parseString("{\"type\":\"edit_box\",\"size\":[20,10]}")); ++ panel.getAsJsonArray("controls").add(control); ++ final var unsupported = ++ 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, unsupported, _ -> new ItemStack(Items.IRON_INGOT))); ++ f.screen.containerTick(); ++ assertSame(f.ordinary, f.displayed.get()); ++ assertTrue(f.sent.isEmpty()); ++ } ++ } ++ ++ @Test ++ void productionPresentationInstallsOnlyTheOwnedScreenAndRestoresItWhenCapabilityDisappears() ++ throws Exception { ++ try (final var f = new Fixture(); ++ final var viewport = ++ mockStatic( ++ com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiViewport ++ .class)) { ++ viewport.when(com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiViewport::current) ++ .thenReturn( ++ new com.viaversion.viafabricplus.bedrock.screen.ui.NativeUiViewport( ++ 640, 320, 1, 1)); ++ final var font = Minecraft.class.getField("font"); ++ font.setAccessible(true); ++ font.set(f.game, mock(Font.class)); ++ f.displayed.set(f.ordinary); ++ assertFalse( ++ BedrockNativeCooking.action( ++ f.owner, f.menu, 7, NativeCookingAction.Mode.PRIMARY, 0)); ++ final var present = ++ BedrockNativeCooking.class.getDeclaredMethod("present", Minecraft.class); ++ present.setAccessible(true); ++ present.invoke(null, f.game); ++ final var installed = ++ assertInstanceOf(BedrockNativeContainerScreen.class, f.displayed.get()); ++ try { ++ assertTrue(installed.presents(f.presentation)); ++ assertSame(f.ordinary, installed.original()); ++ assertSame(f.menu, installed.getMenu()); ++ present.invoke(null, f.game); ++ assertSame(installed, f.displayed.get()); ++ final var minecraft = Screen.class.getDeclaredField("minecraft"); ++ minecraft.setAccessible(true); ++ minecraft.set(installed, f.game); ++ assertTrue(installed.mouseClicked(f.recipePointer(MOUSE_BUTTON_LEFT), false)); ++ assertEquals(1, f.sent.size()); ++ f.controller.capability(f.owner, true); ++ f.controller.capability(f.owner, false); ++ f.networking ++ .when(() -> ClientPlayNetworking.canSend(NativeCookingActionPayload.TYPE)) ++ .thenReturn(false); ++ installed.containerTick(); ++ assertSame(f.ordinary, f.displayed.get()); ++ assertEquals(1, f.sent.size()); ++ verify(f.player, never()).closeContainer(); ++ assertFalse(BedrockNativeContainers.replacing(f.menu)); ++ } finally { ++ installed.discard(); ++ } ++ } ++ } ++ ++ private static MouseButtonEvent pointer(final int button, final double x, final double y) { ++ return new MouseButtonEvent(x, y, new MouseButtonInfo(button, 0)); ++ } ++ ++ @SuppressWarnings("unchecked") ++ private static final class Fixture implements AutoCloseable { ++ final MockedStatic singleton; ++ final MockedStatic networking; ++ final MockedStatic entrypoint; ++ final Minecraft game = mock(Minecraft.class); ++ final MouseHandler mouse = mock(MouseHandler.class); ++ final Window window = mock(Window.class); ++ final LocalPlayer player = player(); ++ final Inventory inventory = new Inventory(player, new EntityEquipment()); ++ final FurnaceMenu menu = new FurnaceMenu(5, this.inventory); ++ final ClientPacketListener owner = mock(ClientPacketListener.class); ++ final AbstractContainerScreen ordinary = mock(AbstractContainerScreen.class); ++ final AtomicReference displayed = new AtomicReference<>(); ++ final List sent = new ArrayList<>(); ++ final List preferences = new ArrayList<>(); ++ final AtomicLong clock = new AtomicLong(); ++ final NativeCookingPresentationController< ++ ClientPacketListener, net.minecraft.world.inventory.AbstractContainerMenu> ++ controller; ++ final NativeCookingPresentation presentation; ++ final BedrockNativeContainerScreen screen; ++ final double recipeX, recipeY; ++ ++ MouseButtonEvent recipePointer(final int button) { ++ return pointer(button, this.recipeX, this.recipeY); ++ } ++ ++ boolean recipeSelected() throws Exception { ++ final var field = BedrockNativeContainerScreen.class.getDeclaredField("draws"); ++ field.setAccessible(true); ++ final var draws = (List) field.get(this.screen); ++ return draws.stream().anyMatch(draw -> draw.box().node().name().equals("selected_recipe")); ++ } ++ ++ private static LocalPlayer player() { ++ final var player = mock(LocalPlayer.class); ++ final var level = mock(net.minecraft.client.multiplayer.ClientLevel.class); ++ when(player.level()).thenReturn(level); ++ when(level.recipeAccess()) ++ .thenReturn(mock(net.minecraft.world.item.crafting.RecipeAccess.class)); ++ return player; ++ } ++ ++ Fixture() throws Exception { ++ this.singleton = mockStatic(Minecraft.class); ++ this.networking = mockStatic(ClientPlayNetworking.class); ++ this.entrypoint = mockStatic(ViaFabricPlusBedrock.class); ++ try { ++ this.singleton.when(Minecraft::getInstance).thenReturn(this.game); ++ final var mouseHandler = Minecraft.class.getField("mouseHandler"); ++ mouseHandler.setAccessible(true); ++ mouseHandler.set(this.game, this.mouse); ++ when(this.game.getWindow()).thenReturn(this.window); ++ when(this.window.getWidth()).thenReturn(640); ++ when(this.window.getHeight()).thenReturn(320); ++ when(this.window.getGuiScale()).thenReturn(1); ++ this.game.player = this.player; ++ this.game.gameMode = mock(MultiPlayerGameMode.class); ++ final var options = mock(net.minecraft.client.Options.class); ++ final var optionsField = Minecraft.class.getDeclaredField("options"); ++ optionsField.setAccessible(true); ++ optionsField.set(this.game, options); ++ final var pick = net.minecraft.client.Options.class.getDeclaredField("keyPickItem"); ++ pick.setAccessible(true); ++ pick.set(options, mock(net.minecraft.client.KeyMapping.class)); ++ this.player.containerMenu = this.menu; ++ final var connection = mock(Connection.class); ++ when(connection.isConnected()).thenReturn(true); ++ when(this.owner.getConnection()).thenReturn(connection); ++ when(this.game.getConnection()).thenReturn(this.owner); ++ when(this.player.getInventory()).thenReturn(this.inventory); ++ when(this.ordinary.getMenu()).thenReturn(this.menu); ++ when(this.ordinary.getTitle()).thenReturn(Component.empty()); ++ final var gui = mock(Gui.class); ++ when(gui.screen()).thenAnswer(_ -> this.displayed.get()); ++ final var field = Minecraft.class.getField("gui"); ++ field.setAccessible(true); ++ field.set(this.game, gui); ++ doAnswer( ++ call -> { ++ this.displayed.set(call.getArgument(0)); ++ BedrockNativeContainers.screenInstalled(call.getArgument(0)); ++ return null; ++ }) ++ .when(this.game) ++ .setScreenAndShow(any()); ++ final var implementation = mock(ViaFabricPlusBedrock.class); ++ when(implementation.logger()) ++ .thenReturn(mock(org.apache.logging.log4j.Logger.class)); ++ this.entrypoint.when(ViaFabricPlusBedrock::impl).thenReturn(implementation); ++ final var presentations = ++ BedrockNativeCooking.class.getDeclaredField("PRESENTATIONS"); ++ presentations.setAccessible(true); ++ this.controller = ++ (NativeCookingPresentationController< ++ ClientPacketListener, ++ net.minecraft.world.inventory.AbstractContainerMenu>) ++ presentations.get(null); ++ this.controller.begin(this.owner); ++ assertTrue( ++ this.controller.accept( ++ this.owner, ++ this.menu, ++ 5, ++ message(1, true), ++ _ -> new ItemStack(Items.IRON_INGOT))); ++ this.presentation = this.controller.current(this.owner, this.menu, 5); ++ this.screen = ++ new BedrockNativeContainerScreen( ++ this.owner, ++ this.ordinary, ++ this.inventory, ++ Component.empty(), ++ this.presentation, ++ false, ++ this.presentation.message().sequence(), ++ this.clock::get, ++ () -> ++ this.controller.current(this.owner, this.menu, 5) ++ == this.presentation); ++ final var minecraft = Screen.class.getDeclaredField("minecraft"); ++ minecraft.setAccessible(true); ++ minecraft.set(this.screen, this.game); ++ this.screen.prepare(mock(Font.class), 640, 320); ++ final var drawsField = BedrockNativeContainerScreen.class.getDeclaredField("draws"); ++ drawsField.setAccessible(true); ++ final var draws = ++ (List< ++ com.viaversion.viafabricplus.bedrock.screen.ui ++ .NativeUiPresentation.Draw>) ++ drawsField.get(this.screen); ++ final var cell = ++ draws.stream() ++ .filter( ++ draw -> ++ draw.box() ++ .node() ++ .properties() ++ .has("recipe_actions")) ++ .findFirst() ++ .orElseThrow() ++ .box() ++ .visibleBounds(); ++ this.recipeX = cell.x() + cell.width() / 2; ++ this.recipeY = cell.y() + cell.height() / 2; ++ this.displayed.set(this.screen); ++ this.networking ++ .when(() -> ClientPlayNetworking.canSend(NativeCookingActionPayload.TYPE)) ++ .thenReturn(true); ++ this.networking ++ .when(() -> ClientPlayNetworking.send(any())) ++ .thenAnswer( ++ call -> { ++ final var payload = call.getArgument(0); ++ if (payload instanceof NativeCookingActionPayload action) ++ this.sent.add(action.action()); ++ else if (payload ++ instanceof NativeFurnaceOptionsPayload preference) ++ this.preferences.add(preference.options()); ++ else fail("Unexpected payload"); ++ return null; ++ }); ++ } catch (final Exception | Error failure) { ++ this.entrypoint.close(); ++ this.networking.close(); ++ this.singleton.close(); ++ throw failure; ++ } ++ } ++ ++ void enablePreferences() throws Exception { ++ final var field = BedrockFurnacePreferences.class.getDeclaredField("STATE"); ++ field.setAccessible(true); ++ ((NativeFurnacePreferenceState) field.get(null)) ++ .begin(this.owner); ++ this.networking ++ .when(() -> ClientPlayNetworking.canSend(NativeFurnaceOptionsPayload.TYPE)) ++ .thenReturn(true); ++ } ++ ++ void preferences( ++ final long update, ++ final Kind kind, ++ final int tab, ++ final boolean filtering, ++ final int layout) { ++ final var next = message(update, true); ++ final var exported = JsonParser.parseString(next.program()).getAsJsonObject(); ++ final var panel = ++ exported.getAsJsonObject("definitions") ++ .getAsJsonObject("furnace.furnace_panel") ++ .getAsJsonObject("value"); ++ final var mappings = new com.viaversion.viaversion.libs.gson.JsonArray(); ++ for (final String direction : List.of("up", "down", "left", "right")) { ++ final var mapping = new JsonObject(); ++ mapping.addProperty("from_button_id", "button.menu_" + direction); ++ mapping.addProperty("to_button_id", "button.menu_" + direction); ++ mapping.addProperty("mapping_type", "global"); ++ mapping.addProperty("scope", "view"); ++ mappings.add(mapping); ++ } ++ final var navigation = new JsonObject(); ++ navigation.addProperty("type", "input_panel"); ++ navigation.addProperty("hover_enabled", false); ++ navigation.add("button_mappings", mappings); ++ final var navigationControl = new JsonObject(); ++ navigationControl.add("view_navigation", navigation); ++ panel.getAsJsonArray("controls").add(navigationControl); ++ final var control = ++ JsonParser.parseString( ++ """ ++ {"preference":{"type":"toggle","size":[20,20],"anchor_from":"top_left","anchor_to":"top_left","offset":[300,0], ++ "toggle_name":"navigation_tab","toggle_group_forced_index":2,"focus_enabled":true, ++ "button_mappings":[{"from_button_id":"button.menu_select","to_button_id":"button.menu_select","mapping_type":"pressed"}, ++ {"from_button_id":"button.menu_ok","to_button_id":"button.menu_select","mapping_type":"focused"}], ++ "bindings":[{"binding_name":"#is_left_tab_items","binding_name_override":"#toggle_state"}]}} ++ """) ++ .getAsJsonObject(); ++ if (kind == Kind.FILTER) { ++ final var toggle = control.getAsJsonObject("preference"); ++ toggle.addProperty("toggle_name", "toggle.enable_filtering"); ++ toggle.getAsJsonArray("bindings") ++ .get(0) ++ .getAsJsonObject() ++ .addProperty("binding_name", "#filtering_enabled"); ++ } ++ if (kind == Kind.LAYOUT) { ++ final var toggle = control.getAsJsonObject("preference"); ++ toggle.addProperty("toggle_name", "layout_toggle"); ++ toggle.addProperty("toggle_group_forced_index", 1); ++ toggle.getAsJsonArray("bindings") ++ .get(0) ++ .getAsJsonObject() ++ .addProperty("binding_name", "#is_survival_layout"); ++ final var other = control.deepCopy(); ++ final var opposite = other.getAsJsonObject("preference"); ++ opposite.addProperty("toggle_group_forced_index", 2); ++ opposite.getAsJsonArray("offset") ++ .set(0, new com.viaversion.viaversion.libs.gson.JsonPrimitive(340)); ++ opposite.getAsJsonArray("bindings") ++ .get(0) ++ .getAsJsonObject() ++ .addProperty("binding_name", "#is_recipe_book_layout"); ++ final var sibling = new JsonObject(); ++ sibling.add("other_layout", opposite); ++ panel.getAsJsonArray("controls").add(sibling); ++ } ++ panel.getAsJsonArray("controls").add(control); ++ final var book = next.view().book(); ++ final var changedBook = ++ new CookingUi.Book( ++ layout, ++ book.forcedRecipeBookLayout(), ++ tab, ++ filtering, ++ book.foodCount(), ++ book.itemCount(), ++ book.blockCount(), ++ book.tabLabel(), ++ book.searchText(), ++ book.selection(), ++ book.catalog(), ++ book.visibleRecipes()); ++ final var view = next.view(); ++ final var changedView = ++ new CookingUi.MenuView( ++ view.menuId(), ++ view.family(), ++ view.title(), ++ view.slots(), ++ view.cursor(), ++ view.progress(), ++ changedBook); ++ final var program = CookingUi.fromProgram(exported); ++ final var changed = ++ NativeCookingMessage.create( ++ program, ++ changedView, ++ next.sequence(), ++ update, ++ next.menuRevision(), ++ next.inventoryRevision(), ++ next.cursorRevision(), ++ next.recipeItems()); ++ assertTrue( ++ this.controller.accept( ++ this.owner, ++ this.menu, ++ 5, ++ changed, ++ _ -> new ItemStack(Items.IRON_INGOT))); ++ this.screen.containerTick(); ++ assertSame(this.screen, this.displayed.get()); ++ } ++ ++ @Override ++ public void close() { ++ try { ++ this.screen.discard(); ++ this.controller.clear(this.owner); ++ } finally { ++ this.entrypoint.close(); ++ this.networking.close(); ++ this.singleton.close(); ++ } ++ } ++ ++ void navigation(final long update, final int delta, final int tab, final int layout, final boolean forced, final NavigationHost host) { ++ 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"); ++ recipe.getAsJsonArray("button_mappings").add(JsonParser.parseString(""" ++ {"to_button_id":"button.container_slot_hovered","mapping_type":"pressed"} ++ """)); ++ recipe.add("controls", JsonParser.parseString(""" ++ [{"selected_recipe":{"type":"panel","bindings":[ ++ {"binding_name":"#is_recipe_selected_slot","binding_name_override":"#visible"}]}}] ++ """)); ++ final var control = JsonParser.parseString(host != NavigationHost.BUTTON ? """ ++ {"type":"input_panel","size":[0,0],"button_mappings":[ ++ {"from_button_id":"button.menu_ok","mapping_type":"global"}]} ++ """ : """ ++ {"type":"button","size":[20,20],"offset":[300,0],"anchor_from":"top_left","anchor_to":"top_left", ++ "focus_enabled":true,"button_mappings":[ ++ {"from_button_id":"button.menu_select","mapping_type":"pressed"}, ++ {"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"); ++ 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"); ++ value.getAsJsonObject().addProperty("scope", "view"); ++ } ++ final var controls = exported.getAsJsonObject("definitions").getAsJsonObject("furnace.furnace_panel") ++ .getAsJsonObject("value").getAsJsonArray("controls"); ++ for (final int index : List.of(3, 1, 2)) { ++ final var toggle = new JsonObject(); ++ toggle.addProperty("type", "toggle"); ++ toggle.addProperty("focus_enabled", host == NavigationHost.FOCUSED_VIEW_TABS); ++ toggle.addProperty("toggle_name", "navigation_tab"); ++ toggle.addProperty("toggle_group_forced_index", index); ++ toggle.add("size", JsonParser.parseString("[20,20]")); ++ final var binding = new JsonObject(); ++ binding.addProperty("binding_name", "#is_left_tab_" + List.of("food", "items", "blocks").get(index - 1)); ++ binding.addProperty("binding_name_override", "#toggle_state"); ++ final var bindings = new com.viaversion.viaversion.libs.gson.JsonArray(); ++ bindings.add(binding); ++ toggle.add("bindings", bindings); ++ final var wrapped = new JsonObject(); ++ wrapped.add("tab" + index, toggle); ++ controls.add(wrapped); ++ } ++ } ++ if (host == NavigationHost.GLOBAL_PANEL) { ++ for (final var value : control.getAsJsonArray("button_mappings")) value.getAsJsonObject().addProperty("scope", "global"); ++ } ++ if (host == NavigationHost.SCREEN) { ++ exported.getAsJsonObject("definitions").getAsJsonObject("furnace.furnace_screen").getAsJsonObject("value") ++ .add("button_mappings", control.getAsJsonArray("button_mappings")); ++ } else { ++ final var wrapped = new JsonObject(); ++ wrapped.add("navigation", control); ++ exported.getAsJsonObject("definitions").getAsJsonObject("furnace.furnace_panel").getAsJsonObject("value") ++ .getAsJsonArray("controls").add(wrapped); ++ } ++ final var view = source.view(); ++ final var book = view.book(); ++ final var changedBook = new CookingUi.Book(layout, forced, tab, true, book.foodCount(), book.itemCount(), book.blockCount(), ++ book.tabLabel(), book.searchText(), book.selection(), book.catalog(), book.visibleRecipes()); ++ final var changedView = new CookingUi.MenuView(view.menuId(), view.family(), view.title(), view.slots(), view.cursor(), view.progress(), changedBook); ++ final var changed = NativeCookingMessage.create(CookingUi.fromProgram(exported), changedView, source.sequence(), update, ++ source.menuRevision(), source.inventoryRevision(), source.cursorRevision(), source.recipeItems()); ++ assertTrue(this.controller.accept(this.owner, this.menu, 5, changed, _ -> new ItemStack(Items.IRON_INGOT))); ++ this.screen.containerTick(); ++ } ++ } ++ ++ private static NativeCookingMessage message(final long update, final boolean recipeVisible) { ++ final var source = NativeCookingPresentationTest.message(CookingUi.Family.FURNACE, update); ++ final var previous = source.view(); ++ final var book = previous.book(); ++ final var selected = ++ new CookingUi.Book( ++ book.layout(), ++ book.forcedRecipeBookLayout(), ++ book.tab(), ++ book.filtering(), ++ book.foodCount(), ++ book.itemCount(), ++ book.blockCount(), ++ book.tabLabel(), ++ book.searchText(), ++ book.selection(), ++ book.catalog(), ++ recipeVisible ? Set.of(7) : Set.of()); ++ final var view = ++ new CookingUi.MenuView( ++ 5, ++ previous.family(), ++ previous.title(), ++ previous.slots(), ++ previous.cursor(), ++ previous.progress(), ++ selected); ++ return NativeCookingMessage.create( ++ program(), ++ view, ++ source.sequence(), ++ update, ++ source.menuRevision(), ++ source.inventoryRevision(), ++ source.cursorRevision(), ++ source.recipeItems()); ++ } ++ ++ private enum NavigationHost { BUTTON, PANEL, SCREEN, GLOBAL_PANEL, VIEW_TABS, FOCUSED_VIEW_TABS } ++ ++ private static CookingUi program() { ++ final var definitions = new JsonObject(); ++ final var root = ++ JsonParser.parseString("{\"type\":\"panel\",\"size\":[640,320],\"controls\":[]}") ++ .getAsJsonObject(); ++ definitions.add( ++ "furnace.slot", ++ definition( ++ 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"}]} ++ """) ++ .getAsJsonObject())); ++ definitions.add( ++ "furnace.recipe", ++ definition( ++ JsonParser.parseString( ++ """ ++ {"type":"button","size":[18,18],"button_mappings":[ ++ {"from_button_id":"button.menu_select","to_button_id":"button.recipe_select","mapping_type":"pressed"}, ++ {"from_button_id":"button.menu_secondary_select","to_button_id":"button.recipe_secondary","mapping_type":"pressed"}]} ++ """) ++ .getAsJsonObject())); ++ int y = 60; ++ for (final String collection : ++ List.of( ++ "furnace_ingredient_items", ++ "furnace_fuel_items", ++ "furnace_output_items", ++ "inventory_items", ++ "hotbar_items", ++ "recipe_book")) { ++ final var grid = ++ JsonParser.parseString( ++ "{\"type\":\"grid\",\"anchor_from\":\"top_left\",\"anchor_to\":\"top_left\"}") ++ .getAsJsonObject(); ++ grid.addProperty("collection_name", collection); ++ grid.addProperty( ++ "grid_item_template", ++ collection.equals("recipe_book") ? "furnace.recipe" : "furnace.slot"); ++ grid.add( ++ "grid_dimensions", ++ JsonParser.parseString( ++ collection.equals("inventory_items") ++ ? "[9,3]" ++ : collection.equals("hotbar_items") ? "[9,1]" : "[1,1]")); ++ grid.add( ++ "size", ++ JsonParser.parseString( ++ collection.equals("inventory_items") ++ ? "[162,54]" ++ : collection.equals("hotbar_items") ? "[162,18]" : "[18,18]")); ++ grid.add( ++ "offset", ++ JsonParser.parseString( ++ collection.equals("recipe_book") ++ ? "[20,20]" ++ : collection.equals("hotbar_items") ++ ? "[100,210]" ++ : "[100," + y + "]")); ++ final var control = new JsonObject(); ++ control.add(collection, grid); ++ root.getAsJsonArray("controls").add(control); ++ y += 30; ++ } ++ definitions.add("furnace.furnace_panel", definition(root)); ++ definitions.add( ++ "furnace.furnace_screen", ++ definition( ++ JsonParser.parseString(""" ++ {"type":"screen","size":[640,320],"$screen_content":"furnace.furnace_panel", ++ "controls":[{"root_screen_panel@$screen_content":{}}]} ++ """) ++ .getAsJsonObject())); ++ final var exported = new JsonObject(); ++ exported.add("definitions", definitions); ++ exported.addProperty("cooking_family", "FURNACE"); ++ exported.add( ++ "globals", ++ JsonParser.parseString("{\"$container_title\":\"\",\"$localize_title\":false}")); ++ 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; ++ } ++} 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 new file mode 100644 -index 0000000..a1c26b0 +index 00000000..a1c26b0c --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeFurnacePreferenceStateTest.java @@ -0,0 +1,45 @@ @@ -4117,7 +9344,7 @@ index 0000000..a1c26b0 +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryFlightsTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryFlightsTest.java new file mode 100644 -index 0000000..9646a61 +index 00000000..9646a61c --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryFlightsTest.java @@ -0,0 +1,70 @@ @@ -4193,7 +9420,7 @@ index 0000000..9646a61 +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryItemOpacityTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryItemOpacityTest.java new file mode 100644 -index 0000000..9432c6c +index 00000000..9432c6c3 --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryItemOpacityTest.java @@ -0,0 +1,65 @@ @@ -4264,7 +9491,7 @@ index 0000000..9432c6c +} 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..d705329 +index 00000000..d705329a --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryItemPresentationTest.java @@ -0,0 +1,67 @@ @@ -4337,7 +9564,7 @@ index 0000000..d705329 +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryProgressTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryProgressTest.java new file mode 100644 -index 0000000..2d543d3 +index 00000000..2d543d35 --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/NativeInventoryProgressTest.java @@ -0,0 +1,83 @@ @@ -4424,9 +9651,110 @@ index 0000000..2d543d3 + return new NativeUiLayout.Box(node, rect, rect, new NativeUiLayout.Size(12, 1), rect, 0, List.of()); + } +} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/PlayerInventoryScreenLifecycleTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/PlayerInventoryScreenLifecycleTest.java +new file mode 100644 +index 00000000..c07439c9 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/container/PlayerInventoryScreenLifecycleTest.java +@@ -0,0 +1,95 @@ ++/* ++ * 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 net.raphimc.viabedrock.api.model.PlayerInventoryScreenRequest; ++import org.junit.jupiter.api.Test; ++import static org.junit.jupiter.api.Assertions.*; ++ ++class PlayerInventoryScreenLifecycleTest { ++ @Test ++ void ordinaryReopenGetsANewIdentityEvenWithTheSamePersistentMenu() { ++ final var f = new Fixture(); ++ final var first = new Object(); ++ final var second = new Object(); ++ f.install(first, false, true); ++ f.install(first, false, true); ++ f.install(second, false, true); ++ assertEquals(List.of(new PlayerInventoryScreenRequest(1, true), new PlayerInventoryScreenRequest(1, false), ++ new PlayerInventoryScreenRequest(2, true)), f.sent); ++ assertNull(f.screens.close(f.owner, 1)); ++ assertSame(second, f.screens.close(f.owner, 2)); ++ } ++ ++ @Test ++ void genuinePresentationReplacementRetainsItsOpeningAndClosesExactlyOnce() { ++ final var f = new Fixture(); ++ final var ordinary = new Object(); ++ final var authored = new Object(); ++ f.install(ordinary, false, true); ++ f.install(authored, true, true); ++ assertEquals(List.of(new PlayerInventoryScreenRequest(1, true)), f.sent); ++ assertSame(authored, f.screens.close(f.owner, 1)); ++ f.install(null, false, true); ++ assertEquals(1, f.sent.size()); ++ } ++ ++ @Test ++ void lateCapabilityDoesNotDuplicateALegacyOpeningAndAppliesToTheNextScreen() { ++ final var f = new Fixture(); ++ final var legacy = new Object(); ++ f.install(legacy, false, false); ++ f.install(legacy, false, true); ++ assertEquals(1, f.legacyOpens); ++ assertTrue(f.sent.isEmpty()); ++ assertNull(f.screens.close(f.owner, 1)); ++ f.install(null, false, true); ++ f.install(new Object(), false, true); ++ assertEquals(List.of(new PlayerInventoryScreenRequest(2, true)), f.sent); ++ } ++ ++ @Test ++ void disconnectAndOldConnectionCallbacksNeverCloseANewConnectionsScreen() { ++ final var f = new Fixture(); ++ f.install(new Object(), false, true); ++ final var nextOwner = new Object(); ++ f.screens.begin(nextOwner); ++ final var current = new Object(); ++ f.screens.installed(nextOwner, current, f.menu, false, true, f.sent::add, () -> fail()); ++ f.screens.clear(f.owner); ++ assertNull(f.screens.close(f.owner, 1)); ++ assertSame(current, f.screens.close(nextOwner, 2)); ++ } ++ ++ @Test ++ void capabilityWithdrawalRetiresOnlyABridgedScreen() { ++ final var f = new Fixture(); ++ final var current = new Object(); ++ f.install(current, false, true); ++ assertSame(current, f.screens.capability(f.owner, false)); ++ assertNull(f.screens.capability(f.owner, false)); ++ f.install(new Object(), false, false); ++ assertNull(f.screens.capability(f.owner, false)); ++ assertEquals(1, f.legacyOpens); ++ } ++ ++ private static final class Fixture { ++ private final Object owner = new Object(); ++ private final Object menu = new Object(); ++ private final PlayerInventoryScreenLifecycle screens = new PlayerInventoryScreenLifecycle<>(); ++ private final List sent = new ArrayList<>(); ++ private int legacyOpens; ++ ++ private Fixture() { this.screens.begin(this.owner); } ++ ++ private void install(final Object screen, final boolean replacing, final boolean available) { ++ this.screens.installed(this.owner, screen, screen == null ? null : this.menu, replacing, available, ++ this.sent::add, () -> this.legacyOpens++); ++ } ++ } ++} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiFontTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiFontTest.java new file mode 100644 -index 0000000..e83a8ef +index 00000000..e83a8efc --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiFontTest.java @@ -0,0 +1,133 @@ 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 2d0f6f00..da3b77e7 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 @@ -1,145 +1,706 @@ -Container presentation binds the [shared scene renderer](0029-share-native-ui-scene-rendering.pr.md) to real Java menus and slots. Inventory content, cursor, clicks and close packets remain authoritative. - -## Native storage presentation - -The renderer also imports the core's native container program through a separate negotiated payload. -It binds the actual connection, menu object, menu ID and increasing presentation sequence. -The actual chest, shulker, hopper or dispenser menu owns slots, carried stacks, state revisions and every inventory action. -Version-3 presentation retains all eight core storage variants. -Tests cover each bundled scene's visible-control validation, actual menu identity and pickup, quick-move and drag dispatch. -Native visual and direct/ViaProxy transaction comparisons remain unverified. -Failed presentation restores the original ordinary screen; this remains an unresolved parity defect for a supported scene. - -## Brewing stand - -Version 4 adds the authored brewing screen over a real `BrewingStandMenu`. -Counter updates require the current play listener, menu object, kind, menu ID, and presentation sequence. -The screen uses full native counters independently of Java's short menu properties. -Its slots keep ordinary authoritative inventory operations. Storage count-one providers do not apply to this workstation. - -A fresh BDS 1.26.51.1 test places nether wart through the screen, brews three water bottles, and transfers three awkward potions. -A read-only observer confirms the native screen class and matching core and client counter snapshots through completion. -Screenshots cover progress, completed items, and empty bottle placeholders. -The chest action, ordinary chest transfer, and reconnect checks also pass. -All 1,306 installed core classes match the rebuilt artifact. - -The full build passes 2,162 tests, with 152 optional skips. Core and client stacks replay all 102 and 38 patches. -Native pixel, direct-client, and Iris comparisons remain open. - -Shared UI classes now own bounded scene parsing, layout, images, text, font factors, scroll geometry and visual states. -Modal forms retain their own response model and custom input state. -Native containers use typed slot and cursor annotations, rather than fake form actions. - -Authored slot buttons inherit their real cell extent when their dimensions are absent or default. -Explicit dimensions still apply. Each ordinary native cell has an 18-pixel input rectangle and a 16-pixel item renderer. -The same clipped rectangle controls hovering, tooltips and vanilla click, release and quickcraft handling. -Scrolled rows retain their registration while their hover rectangles respect the viewport. - -Native progress bars use authored dimensions and flags, original endpoint quantization, shadow bounds, rounding, storage minimums and durability colors. -Java damage and count come from the actual stack, including vanilla's quickcraft preview. -The slot decoration hook suppresses duplicate Java bars and counts. -Typed authored count labels use Java count presentation once at their resolved bounds and clip. -The vanilla carried item and its decorations remain authoritative. - -Transient flights compare actual mapped slot states after genuine menu operations or revisions. -They cancel unchanged same-slot counts and match equivalent item losses to gains. -Unmatched additions or removals produce no flight. -Matched collection controls provide top-left coordinates and width divided by sixteen for scale. -Flights use native quintic motion, distance-based duration and the original 0.3-second lifetime. -No synthetic carried-item collection or mouse anchor is added. - -Native lock bytes come from retained pre-conversion user data with exact ByteTag semantics. -Absent or wrong-type values produce zero. The owned Bedrock connection supplies the actual showTags gamerule. -Nonzero values keep ordinary controls if that rule source is unavailable. Empty stacks suppress lock decorations. -Known Java or retained native bundles keep ordinary controls while storage bindings remain unsupported. -Unknown storage values stay absent, rather than fabricated zero amounts. -An active progress bar with invalid amounts or an active unsupported item filter triggers ordinary controls. - -### Evidence and remaining boundaries - -Original native CPU fixtures cover twenty progress cases, seven flight timing cases, four initial queue cases, eight state comparisons and five anchors. -Twenty-five genuine NBT controls and 125 complete UI callback controls establish unsigned typed lock reads, missing-value behavior and showTags-gated decorations. -Native collection marker preference, local Java operation timing, item equivalence and cursor collection membership remain bounded adapter assumptions. -Absent inactive-item filters use ordinary Java item presentation; no false native binding is manufactured. A projected active filter keeps the ordinary screen. -Java device scale supplies endpoint quantization. Count formatting uses Java presentation, not a claimed native formatter. - -Actual accepted-pack three-row and six-row programs resolve all 63 and 90 slot addresses. -Their active slot rectangles and 65 or 92 PNG slice paths pass CPU validation with actual image dimensions. -The paired v9 overlay retains all 63 and 90 typed authored count labels and one chest title per kind. -That shallow same-name definition overlay follows the actual versioned partial pack declarations; original native publication semantics remain under investigation. -A separate export/import probe preserves a supplied OPEN_SCREEN title while retaining the captured condition title. -The private shared form/container suite passes 72 tests with one skipped fixture. -It also rejects an unsupported visible button action while accepting the same hidden control. -Tooltip tests preserve styled runs and leading, repeated and trailing blank lines across CRLF normalization and width wrapping. -The actual Wardrobe capture has sixteen components with two line breaks and no authored width; these now use the multiline path. -These checks do not establish GPU drawing, live transfer behavior, resource reloads or Iris compatibility. -Those final client gates remain required before release. - -The final container, form, tooltip and preserved actor changes pass the complete normal 31-patch replay and clean Gradle check, build and POM gates: 696 tests, 578 passed, 118 optional skips, no failures or errors. Access-widener validation passes. All 1,302 embedded core entries match the paired immutable core. The 38 installed artifacts remain unchanged. Live inventory transfers, resource reloads and Iris compatibility remain required before release. - -## Authored manual container interaction - -A negotiated native action capability permits an intentionally authored subset of real menu slots. -Full template chests still require complete slot coverage. -Every active authored action must map to its real menu slot and clipped pointer rectangle. -Hidden or omitted slots do not receive generic pickup, shift, number-key, drop, double-click or drag actions. - -The client waits for real contents on the same menu object before preparing the presentation. -It waits for server activation acknowledgment before sending a count-one request. -Closing, disconnecting, replacing the menu or receiving stale sequence data retires ownership. -A send or presentation failure disposes the candidate or restores ordinary controls for that exact menu. -Scroll-triggered scene refresh shares the same deterministic fallback path as slot and tick updates. - -Real typed custom-name and lore presentation supplies the native-proven hover subset. -Unsupported default-name, filter or translation inputs retain fallback instead of fabricated descriptions. -The controlled named physical chest projection registers authored slots 9, 17 and 18 from actual item data. -This CPU projection is distinct from actual actor-backed Social delivery or GPU verification. - -Four private screen integration tests pass, including installed presentation failure and transport cleanup. -Additional controller tests cover empty opening followed by real contents and reused-menu rejection. -The final paired clean build passes 730 cases: 612 passed and 118 optional fixtures skipped. -Access-widener validation, check, build and POM generation pass. -The separate compact export replay applies all 99 core and 31 addon patches and reproduces the tested source bytes. -All 1,323 approved core entries also match the freshly shaded ViaProxy artifact, which passes its four tests. -The 38 installed files and previous candidates remain unchanged. - -A separate offline evaluation uses the captured actor name and real typed item names. -It selects the authored manual branch, references slots 0 through 18, and exposes actions for slots 9 through 14. -This does not establish initial actor metadata timing, authored coverage during live preparation, server action acceptance or rendered pixels. -Native controller focus/navigation and complete native pixel parity remain unverified. - -## Upstream prerequisites - -- [viafabricplus-bedrock patch](../../viafabricplus-bedrock/upstreamable/0029-share-native-ui-scene-rendering.pr.md): Land shared scene and GUI actor rendering before container presentation. - -## Constructed Item context (October 22, 2026) - -Inventory Item presentation now uses the core snapshot schema constant. -Schema version three preserves Block presence independently of runtime ID zero. -The complete core and add-on build together; eleven strict-BDS inventory controls and same-world reconnect pass. -The rebuilt profiles embed the matching 1,363 core classes and both block-item resources. -These controls retain the existing Java cooking screens and do not establish native workstation presentation. - -## Cooking receiver and presentation frames (October 22, 2026) - -The production client now receives authoritative cooking snapshots and separate raw progress updates. -It binds them to the play listener, actual menu object, cooking family, opening sequence, and item-update sequence. -Retired updates cannot bind to a later menu with the same numeric ID. -An invalid newer item conversion retires the previous authority. - -Ordinary containers and cooking models share presentation frames and the existing renderer. -Cooking previews retain copied translated stacks, independently of actual inventory-slot identities. -The layout test retains all 39 inventory slots and distinct recipe identities. -Progress changes do not change the original inventory authority. - -The ordinary cooking screen remains installed. -Native activation, recipe quantity and cursor behavior, search, navigation, and remaining authored input handlers still need implementation. -These changes do not establish a completed native cooking screen or visual parity. - -All four projects build with 2,344 passing tests and 152 optional skips. -The 102 core patches and 38 client patches replay successfully. Offline tooling and metadata validation pass. -A read-only live observer verifies the production receiver across furnace, smoker, and blast-furnace menus. -It records 187 valid frames, including 171 applied progress observations, with all 39 actual inventory slots. -Both runtime inventories match 2,390 core and client classes plus four resources. -The three ordinary cooking gameplay cases and same-world reconnect pass. The private observer is removed after normal cleanup. -These observations verify receiver ownership and CPU presentation frames, without establishing authored screen drawing or native action execution. +# Present authored native containers + +Server-authored container geometry needs real menu addresses and item authority. +This patch renders supported programs over the actual Java menu, with its actual slots, cursor, revisions, and ordinary inventory dispatch. +It retains the original screen object for recovery. + +Supported families include template chests, ender chests, barrels, shulker boxes, hoppers, dispensers, droppers, and brewing stands. +Cooking programs use furnace, smoker, and blast-furnace menus with all 39 real inventory slots. +The default cooking program still uses ordinary controls because required handlers remain unfinished. + +## Ownership and admission + +Presentations bind to the current play listener, actual menu object, menu ID, family, and increasing opening sequence. +Cooking item updates also carry snapshot and inventory revisions. +Stale frames reject before item conversion. Invalid newer frames retire the previous authority. +Closing or retiring a menu preserves the sequence floor, so reused numeric IDs cannot revive it. +Reconfiguration on the same physical connection still requires a new play owner. + +Full template menus require complete slot coverage. +Negotiated authored subsets retain only their declared slots and wait for actual contents and server activation acknowledgment. +Hidden or omitted slots cannot receive generic pickup, shift, number-key, drop, double-click, or drag actions in that subset. +Count-one actions use the existing negotiated backend, independently of template inventory dispatch. + +Unknown required renderers, active unsupported item filters, storage bindings, and command mappings retain ordinary controls. +Known bundles retain ordinary controls while their storage bindings remain unsupported. +Native lock decoration reads exact retained ByteTag values and the owned connection's `showTags` rule. +Unknown storage values remain absent, rather than becoming fabricated zero values. + +## Recipe pointer input + +The client registers typed version-four cooking actions and replies. +Initial input requires an installed native screen, its exact owned menu, a resolved recipe scope, an empty cursor, and advertised capability. +The authored cooking backend currently requires server-authoritative inventory. +Recipe previews use copied translated stacks, independently of inventory-slot identities. + +Slots, close commands, and recipe cells share clipped painter order, including equal-layer overlays and inactive controls. +Close commands honor their source input and pressed mapping. +A cooking slot's cancel-to-exit binding cannot close the menu on an ordinary primary or secondary click. + +The Java adapter maps primary and secondary presses to phase zero, and releases to phase two. +Captured buttons retain the original recipe and command when newer scenes replace or remove the cell. +Drags, additional buttons, and duplicate releases cannot dispatch vanilla inventory input. +Unsupported mapping attributes, select/deselect constraints, and string booleans reject before the scene becomes actionable. +This adapter does not establish native physical event equivalence. + +Replies must match the complete pending action. The sender records pending input before transport, including synchronous bridges. +Rejected or consumed authority requires a newer resolved frame before another gesture starts. +Capability loss, menu replacement, transport ambiguity, and response timeout retire the presentation. +A one-shot terminal release retains the original identity after retirement and cannot repeat transfers or retry an ambiguous terminal send. +Fallback restores the exact original screen on the same connected menu, even after controller retirement. +It cannot overwrite a later screen or a reused menu. + +## Preference pointers + +Supported tab, filtering, and layout toggles bind their actual authored group, index, and checked state. +They share clipped painter order with slots, recipe cells, and close commands. +The existing per-family transport carries changes after the installed screen checks its displayed snapshot. +Tab and filter changes permit a carried stack. Layout changes still require an empty cursor. +Preference changes cannot interrupt a recipe gesture. +Further pointer, scroll, and inventory-key input waits for a newer matching model. +Capability loss, ambiguous transport, or a ten-second wait restores the original screen. + +The pinned callbacks `0x146c99520` and `0x146c99b40` pass 74 controls with initialized controller/model state and valid weak ownership. +Tab events use the group index and skip refresh for the current tab. Filtering events publish the new checked byte. +Downstream setters, collection rebuild, and settings refresh remain observed external boundaries. +Layout pointers preserve the current tab/filter, use native indices `1` and `2`, and skip a current-layout selection. +They wait for newer matching model state in either direction. Forced-profile layout controls retain ordinary screens. +The unchanged direct layout callback at `0x146c99560` passes 480 controls over supplied state, profile/input values, and held-source validity. +Its saved-tab and held-selection branches are observed but remain unimplemented in this preference adapter. +Focused mappings and additional device mappings also remain unsupported. +This route does not establish complete default-screen, native input, or visual parity. + +## Rendering and retained behavior + +Authored item bounds, count labels, progress, transient flights, and ordinary slot decoration use genuine menu state. +Native images and item draws carry opacity once through premultiplied submission, including cached item textures. +Animation refresh preserves clocks through hierarchical control and inventory identity. +New controls start at their update time; removed controls retire without inventing slot addresses. + +Native title, multiline tooltip, color, anchor, offset, constraint, and scroll behavior use the shared renderer. +Tooltip wrapping preserves styled runs, CRLF boundaries, and empty lines. +Furnace tab, filter, and layout preferences retain their independent negotiated state per family. +The remaining default cooking controllers still need their UI bindings. + +## Evidence and validation + +The pinned native target is Bedrock 1.26.51.1, build 51061372, protocol 2193. +Native CPU controls cover progress, flights, typed lock reads, UI callbacks, and mixed-unit vector animation. +These include 20 progress cases, seven flight timings, 25 NBT controls, 125 UI callbacks, and 1,536 animation samples across 32 easing selectors. +Their original fixture boundaries remain separate from GPU and complete controller behavior. + +The paired four-project build passes 2,453 tests, with 213 optional skips and no failures or errors. +All 104 core patches and 38 client patches replay. +Checkstyle, access-widener validation, and Maven POM generation pass. All 1,401 embedded core classes match the rebuilt core. +The cooking action, presentation, controller, screen, and geometry suites pass all 42 tests. +All eight shared presentation tests also pass. +Disabling recipe pointer routing fails the interaction regression. +The mapping regression also fails before the unsupported-attribute guard. +Disabling preference or layout admission, or intrinsic toggle hover selection, fails the corresponding interaction and presentation regressions. + +A private live fixture projects supported controls from real cooking scenes without changing the core or server. +It removes unfinished widgets, unsupported mappings, and unsupported renderer leaves. +A private color marker identifies the projection. The ordinary test driver waits twelve seconds before its first panel check. +Projection retains all 39 real inventory addresses, retained recipe metadata, and original mapping entries. +Actual installed screen handlers send fourteen tab/filter/layout changes and receive newer cooking models with the requested state. +These confirm the core round trip, independently of Bedrock settings acknowledgment. +The handlers then send primary presses and releases in all three cooking families. +BDS starts cooking after each placement, with one real ingredient and an empty cursor. +All six production replies match and accept the inputs. +The fixture restores ordinary screens, then the three normal cooking cases and same-world reconnect pass. +Private runtime inventories match all 2,445 core and client class resources plus four data resources. +Raw captures, rendered images, fixture programs, and observers remain private. + +This live fixture is a controlled projection, not an unchanged server-authored screen or native reference image. +It does not establish OS mouse translation, broader recipes and quantities, nonempty-cursor behavior, or native pixel parity. + +## Remaining work + +Complete default search, help, focus, carried-item, tertiary, controller handlers, and layout selection history before replacing the default cooking program. +Navigation and filtering still need their remaining focus and device mappings. +Cross-input prediction, authoritative model watchers, additional workstation families, and complete inventory semantics remain unfinished. +Exit transitions, carried-item opacity, inactive filters, native fonts and pixels, resource reloads, and broader client routes also remain verification gaps. +Actor-backed authored storage acceptance and complete native GUI parity remain unverified. + +## Prerequisite + +- [Shared scene and GUI actor rendering](0029-share-native-ui-scene-rendering.pr.md). + +See the [native cooking reference](../../../docs/native-cooking-actions.md) for dispatch, request, and native evidence boundaries. + + +## Context-preserving recipe focus + +Version-five action sessions carry an explicit native controller mode. +Mode `2` can begin with a primary focus and continue to a primary press, including before the focus reply arrives. +The next press still requires its current displayed cell and command scope. +Follow-up phases and cancellation retain the captured snapshot and controller mode. +An older focus rejection cannot erase a newer selection and strand its release. +Terminal cancellation sends at most once after an ambiguous focus send. + +The production mouse adapter supplies mode `1` and keeps its carried-item guard. +It does not infer a native mode-`2` device from pointer input. +Physical mode-`2` delivery, complete active-device integration, and the default carried-item screen remain unfinished. +Core compares three cursor-preserving request payloads with original native transfer and serialization execution. +Client controls cover reentrant replies, pending focus, newer frames, forged contexts, late failure, and cancellation. +The [focused transfer notes](../../../docs/native-cooking-actions.md#focused-carried-item-transfers) retain the native boundaries. + +The final combined suites pass 2,621 tests, with 163 optional skips and no failures or errors. +All four projects build, and the final core rerun includes the integrated movement reference fixtures. +Cooking, camera, retained particle, attachable, and Iris GPU comparisons run without skips. +All 1,413 core classes match both packaged copies, and all 1,139 production client classes match the add-on. +The affected core and add-on stacks replay successfully. + +A fresh BDS 1.26.51.1 route verifies version-five mouse actions in furnace, smoker, and blast-furnace screens. +All six press/release replies retain context `1` and their original authority across newer frames. +Each real ingredient starts cooking, all three ordinary follow-up cases pass, and same-world reconnect passes. +Both runtime inventories match 2,551 project class resources and four data resources. +Normal cleanup removes the observer, restores the private driver change, and leaves no managed-profile JVM. +The controlled scene projection preserves all 39 real slots while it removes unfinished controls and mappings. +This run does not establish unchanged authored screens, physical mode-`2` input, carried-item screen support, or native pixels. + +## Carried stacks on supported cooking screens + +Supported cooking scenes retain their installed screen when the real menu cursor changes. +Ordinary slot input and Java carried-item extraction keep their existing owners. +The screen no longer falls back merely because the player carries a stack. +Direct mouse recipe presses still require an empty cursor and consume their captured release without ordinary inventory input. + +Complete native tab and filter callbacks `0x146c99520` and `0x146c99b40` contain no held-item admission check. +The adapter now permits those preferences while carrying, with the same idle-gesture and newer-model checks. +Downstream native model setters remain supplied boundaries in the callback fixtures. +Layout changes retain their carried-item guard because saved-tab and held-selection behavior remains unfinished. + +Three new screen regressions fail before the correction and pass after it. +They cover counts `1` and `64`, preparation and refresh, newer snapshots, recipe suppression and resumption, and preference acknowledgment. +Separate negative controls restore the old preference guard or remove the mouse recipe guard. Each breaks the relevant behavioral regression. +The focused client suite passes all 34 tests without skips. +The four-project build passes 2,626 tests, with 163 optional skips and no failures or errors. +All 104 core patches and 38 client patches replay. +The 1,413 core classes match both packaged copies. All 1,139 production client classes match the add-on. + + +A fresh BDS 1.26.51.1 route passes furnace, smoker, and blast-furnace controls with one supplied ingredient each. +Actual screen handlers pick up that real stack, retain the screen, suppress a held recipe gesture, and return the unchanged stack. +The cooking model advances through both cursor transitions. Ordinary request responses do not increment the server revision counter. +Normal recipe input then receives all six matching press/release acknowledgments, and BDS starts cooking in each family. +All three ordinary follow-up cases and same-world reconnect pass with the expected final item counts. + +The private scene projection retains all 39 real slots and recipe metadata while removing unfinished controls and mappings. +Both runtime inventories match 2,551 project class resources and four data resources. +Normal cleanup removes the observer, restores the temporary six-second driver delay, and leaves no managed-profile JVM. +Two earlier failed fixture attempts remain archived separately. They assumed a spare ingredient-independent stack and a response-driven server revision. +This route does not establish carried tab/filter input, stack counts beyond one, unchanged authored screens, native opacity, or native pixels. + +This change does not complete carried-item opacity, native device production, default search/help controls, layout history, or visual parity. + +## Authored toggle input contract + +Published release 0.3.46 rejects all 15 toggle controls in fresh default cooking scenes from the packaged core fixtures. +The furnace fixture has seven controls; smoker and blast-furnace fixtures each have four because their category contents differ. +The resolved scenes retain the target's focused `menu_ok` mappings and the filter's implicit hover mapping. +This confirms the parser rejection seen during the ordinary BDS run. It does not establish unchanged native screen acceptance. + +Original Bedrock 1.26.51.1, build 51061372, protocol 2193 supplies the input contract. +Input dispatch `1445056d0` executes in 4,320 cases covering mapping kind, control flags, device mode, bounds, phase history and consumption. +These cases execute the original pointer eligibility, empty-parent clip walk, cached-name lookup and event construction without replacement hooks. +Their mapping records, control geometry, flags, absent weak owner and fixed queue capacity are supplied boundaries. +Double-press tracking, pointer capture, dirty layouts, parent clips and focus production remain outside this control. + +Another 288 sequences execute 1,440 original dispatch calls through the actual input and toggle vtables. +They run the original ToggleComponent constructor, event handler `144575660`, checked-state setter `144575910` and toggle-change event construction. +Binding publication, visual synchronization, radio sibling propagation and settings notification remain observed host interfaces. +Factory fields, control flags, geometry and event capacity are supplied. Complete construction and native settings persistence remain unverified. + +Focused mapping eligibility uses the native control flags rather than pointer bounds. +Mouse and gamepad toggles activate on a new press; touch toggles activate on a new release. +Repeated phases do not create another toggle change. Radio controls still notify when already checked. +A private Java candidate matches every dispatch case and toggle step. +Four wrong candidates fail behavior assertions: focused input treated as pointer input, touch activation on press, repeated activation and discarded checked-radio notifications. +Those earlier controls used a private candidate. The current source consumer appears below. + +Complete focus lifetime, downstream controller behavior, device translation and full event propagation remain requirements for complete scene admission. +The shared scene already retains focus identifiers, directional overrides and precedence fields; the client must consume their native semantics. +These controls do not establish controller navigation, unchanged authored screens or native pixels. + +### Native focus ownership and hover production + +Original focus admission `14464fd50` executes in 16,384 cases. +It acquires the control owner, publishes the manager, and rejects duplicate registration. +The optional disabled-control component remains unidentified. Its disjoint type ID and presence are supplied inputs. + +Original focus construction `1444fd670`, default selection `14464a7d0` and indexed selection `144651290` execute in 3,840 default-selection cases. +The native ranking uses a zero precedence floor and separate binary32 operations for `y * weight + x`. +Equal scores retain registration order. Maximum-float scores do not select a control. +Geometry, the manager weight, component IDs, owner state and history storage are supplied interfaces. + +Another 1,024 sequences execute 3,072 original directional-manager steps through `14464c7a0` on a four-control grid. +They cover both registration orders, all initial selections, all three-step directional paths, and enabled or disabled wrapping. +Original focus destruction `14450d050` runs after every case. Every focus-owned allocation completes its recorded free. +Supplied managers and control owners remain outside that ledger. +These controls do not establish arbitrary directional scoring, nonempty overrides, parent clipping, scrolling or selection listeners. + +Original hover delivery `144506500` now feeds focused dispatch and the original toggle pipeline in 144 sequences. +All 720 hover calls and 720 dispatch calls execute original hover-state mutation, component callbacks, phase storage and toggle changes. +Hover delivery produces flag `0x02` and honors the delegated-hover marker. +Focused dispatch consumes the resulting flags instead of repeating pointer eligibility. +Strong-owner balance passes. Queued weak-event retirement and complete manager lifetime remain outside these controls. + +A private Java candidate matches all new controls. Six wrong candidates fail behavioral assertions. +The rejected variants ignore authored focus disabling, inherited eligibility, the zero precedence floor, binary32 ranking, wrapping or delegated hover. +These earlier controls leave the selection listener and implicit binding construction outside their execution scope. +The factory and ScreenView controls below extend that evidence. +The [focus and hover notes](../../../docs/native-cooking-actions.md#native-focus-admission-and-hover-delivery) retain the integration limits. + + +### Factory-created focus and input delivery + +The [shared scene change](0029-share-native-ui-scene-rendering.pr.md#native-component-flags-and-focus-defaults) now applies native component flag types and focus defaults. +Its native controls execute the actual input/focus factories and ScreenView selection-to-hover delivery before button dispatch. +Factory-created implicit hover bindings and manager history cleanup now execute in that combined route. +The container mapping admission policy remains unchanged. +Full native input routing, controller event handling, radio siblings, device-origin translation and unchanged authored screen acceptance remain open. + + +## Container component consumer + +Container input now uses the shared native Input/Toggle state and release-capture router. +Stable controls retain phases, captures, checked state and directional focus across controller binding refreshes. +Component changes enter the queue before button scope filtering. +The preference adapter consumes the emitted checked value and composes non-consuming events before publishing the final options. +Blocked components retire phases without changing checked state. +Arrow navigation reveals focused controls. Focused keyboard actions retain their release ownership. + +The implicit filter-hover event selects collection `filter_toggle`, cell zero. +It does not set a hover binding or establish keyboard focus. +Original callback registration and ScreenView/controller delivery cover 632 cases. +Original Input/Toggle factories compose with those callbacks in 96 scenes and 384 button steps. +The actual main container consumer and preference adapter match all 384 steps. +These comparisons supply bare controls, geometry, base callback owners, Furnace fields and downstream model/selection observation boundaries. +They do not construct the complete Furnace controller or verify native devices and pixels. + +All 54 targeted component, geometry, scroll, presentation and screen tests pass. +Screen tests cover focused keyboard preference updates and non-consuming tab/filter changes without vanilla inventory dispatch. +The source parser accepts all 15 unchanged default toggle definitions. +The complete default screens still fall back because search, slider and other handlers remain unfinished. +Remaining VIEW focus-tab routes, recursive GLOBAL wildcards, unresolved toggle collection indices and unsupported modal/controller policies remain explicit admission limits. +This change does not establish complete authored-screen support or resolve the original inventory screenshot report. + +## Recipe hover entry and exit + +Cooking recipe controls now admit implicit collection-hover events. +Pointer entry selects the resolved collection cell. +Pointer exit clears it only if both the collection and index still match. +An older exit cannot erase a newer selection or change its revision. +The current selection survives a model refresh with unchanged authoritative selection. +Collection indices use native integral type checks and signed 32-bit narrowing. + +The original native registrations, three hover closures, dispatcher and final exit clear pass 5,832 cases. +Both exit callbacks execute even when the first returns a handled result. +The fixture supplies collection resolution and observes the first selection/exit virtual methods. +It does not execute their Furnace enum, touch-held state or storage-item predicate internals. +All native-owned allocations are released. + +The screen regression covers entry, retained highlighting through a model update, exit and re-entry without inventory or preference requests. +The presentation regression covers interleaved collection/index exits across all three cooking families. +All 42 targeted screen, presentation, input and geometry tests pass. +Unchanged default screens still require help, drop, search, input-panel and other controller handlers. +The full stack build passes 2,740 tests with 163 optional skips. +Packaged production index and selection consumers match 17,496 native cases across all three cooking families, within the fixture boundaries above. + +## Native tab actions + +Authored tab increment/decrement buttons and navigation-only input panels now use the existing native category transition model. +The controller consumes activation events and preserves furnace type, filtering and layout. +Ordinary survival layout retains the current tab; forced recipe layout still navigates. +A tab refresh clears cell selection, including a transition that returns the current tab. +That clear follows the original setter at `0x146c47780`; its held-item branches remain outside this adapter. +Changes wait for newer matching authoritative options. Tab actions permit a carried stack. +Zero-size global panels require preference transport before input begins. +Non-consuming mappings that could mix navigation with ordinary inventory dispatch retain the original screen. + +Original callback registration, receiver/dispatcher, both tab callbacks and complete native category/vector operations pass 8,064 cases. +Selected-tab mutation and settings refresh remain observed downstream boundaries. +The fixture supplies a base-constructed callback owner, Furnace virtual table, category model and valid weak ownership. +It does not construct the complete Furnace controller. +Screen tests cover pointer and keyboard activation, repeated presses, a stale model response, carried items, selection clearing and missing transport. + +The current default resolved controls lack screen-host tab mappings. +Screen-host input preservation, held-selection/reset commands, help, drop, search, remaining input panels and physical gamepad translation still need integration. +This change does not establish complete native workstation or input parity. +The full stack build passes 2,743 tests with 163 optional skips. +The packaged transition adapter matches 48,384 native cases across all three families and both filter states, within the boundaries above. + +## Screen-host input ownership + +Cooking scenes retain their real authored screen root. +Navigation-only screen hosts now share the existing button/input-panel consumer and retained component phases. +A host-owned press remains held across a model update, then releases before another activation. +The real screen regression fails against the previous content-only core. +Its fixture declares an actual screen and content hierarchy, with no substitute input wrapper. + +The unimplemented player-hurt close policy retains the ordinary menu before input. +The complete default hosts retain focus-tab navigation, layout, cursor/drop and other commands that still need handlers. +Search/edit controls, held-input/reset flow, remaining policies, physical devices and pixels remain open. +Preserving the full host does not establish full default-screen admission. +All four builds pass: 2,745 tests, with 163 optional skips. +Final JARs pass the three desktop host structure checks and both actual host-owned screen regressions, with mandatory production class origins. +Pocket declarations now resolve through the shared definition store. Production pocket profile selection remains unfinished. + + +## Screen-owned arrow input + +Authored VIEW arrows now use retained component phases and activation transitions. +Java key repeats no longer move focus directly. Release retires the authored mapping phase. +Focus-enabled panels participate without a controller action. +The screen reveals and lays out the selected control before the adapter calculates its logical pointer position. +Nested hover buttons reach the controller before the outer button queue. +Preference waits suppress focus changes while still retiring input phases. + +Original 1.26.51.1/protocol2193 execution covers 147 composed ScreenView cases and 2,169 focus, pointer and hover steps. +The production adapter matches those states and all 840 captured controller button events. +The capture includes three input modes, registration orders, precedence types, wrapping, fractional geometry and mixed controller/VIEW queues. +All 91,920 native frees complete with no remaining native allocations. +Seven incorrect selection, type, repeat, pointer, admission and queue-order variants fail the comparisons. +Targeted tests also cover retained phases, reveal relayout, real key handlers and missing authored arrow mappings. + +The fixture supplies ScreenView fields, input order, rectangles and empty controller children. +Original component factories, inline ScreenComponent constructor bytes, mapping, selection, hover and base controller delivery execute. +It does not construct the full screen or establish native input-order construction. +The 1,353 captured kind-5 controller pointer events remain outside this consumer. +Recursive GLOBAL wildcards, focus-tab groups, modal/deflection routing, full scroll geometry, devices and pixels remain unfinished. +The complete default-screen admission gate remains in place. The original reported bugs and all five goal areas remain open. + +All four project builds pass 2,756 tests with 163 optional skips and no failures or errors. +All 1,448 core classes match both embedded copies, and all 1,181 client classes match the final add-on. +The packaged add-on repeats the ranking and composed ScreenView comparisons with mandatory production class origins. +Five packaged screen and input regressions pass, including missing authored keys and reveal relayout. + + +## GLOBAL input reinjection + +GLOBAL mappings now re-inject the target with the previous raw phase before the original controller event arrives. +The previous phase can be two. It must not become a press, release or collection-hover exit. +Implicit hover reinjection uses the current View mode and event time, rather than its zero-mode event header. +Physical pointer coordinates remain separate from focus hover coordinates for raw geometry and clip checks. +Recursive GLOBAL wildcards still retain the ordinary screen before input. +Inventory, recipe and close actions still use a separate direct pointer controller. +Cooking scenes with GLOBAL targets or cross-control aliases that could authorize those actions retain the ordinary screen. +Their complete event receivers remain required integration work. + +The original raw mapping and View pipeline match 29 GLOBAL cases and 200 steps, including 286 controller button events. +These controls cover captured releases, prior phase two, hover recursion, physical/logical coordinates and all three input modes. +Another 48 cases match 240 Toggle/Furnace callback steps through the actual input and preference adapters. +All 49,930 native frees complete across these corpora with no native allocations left. +Ten deliberately incorrect phase, capture, mode, coordinate and ordering variants fail the comparisons. + +The captures supply View fields/order, bare controls, inactive or supplied focus geometry, base callback containers and empty controller children. +Original component factories, mapping, recursion, callbacks and control/manager/controller/capture-map cleanup execute. +Preference setters and refresh remain observed boundaries. Immediate settings-notification timing remains outside the Java callback adapter. +The inspected Furnace constructor chain has no pointer callbacks of its own; complete child-controller population remains unverified. +The 36 new captured kind-5 pointer events remain unported, alongside the earlier pointer-event corpus. +Full screen construction, controller pointer handling, focus-tab groups, modal/deflection policies, physical devices and pixels remain unfinished. +Existing controller-command admission checks remain in place. This work does not resolve the original four reports or complete any of the five goal areas. + +All four project builds pass 2,766 tests with 163 optional skips and no failures or errors. +All 1,448 core classes match both embedded copies, and all 1,181 client classes match the final add-on. +The packaged add-on matches all 3,216 raw-phase steps, the 200 GLOBAL View steps and the 240 Toggle/Furnace callback steps. +It also retains the earlier 2,169 composed arrow steps and 840 controller button events. +Six packaged screen and input regressions pass with mandatory production class origins, including fallback before unsupported GLOBAL slot actions. + +## VIEW tab groups + +VIEW tab events traverse the visible, enabled `navigation_tab` toggles. +They choose the next authored index and wrap from an endpoint. Tree order decides the last checked control and duplicate-index owner. +This route differs from the separate Furnace category callback and its count-based rules. +Eligible toggles update together. The group change precedes old/new tab hover events and retains its native false notification flag. +Control-local hover leaves both View pointer positions unchanged. +Keyboard admission now checks the mapping's VIEW receiver. + +Original 1.26.51.1/protocol2193 execution matches 208 cases and 2,117 steps, including 815 group changes and 906 controller buttons. +The corpus includes all three input modes, retained phases, disabled and hidden controls and parents, duplicate indices and zero-width geometry. +All 79,461 native frees complete with no native allocations left. +Nine incorrect selection, eligibility, phase, notification, ownership and pointer variants fail the comparisons. +The real screen regression covers authored selection, preference synchronization and a held key across the matching model update. + +Fixtures supply the tree, component order, geometry and initial checked state. +Generic index probes also cover values outside cooking-screen admission. Their callback adapter constructs the corresponding preference record separately from installation. +Original factories, traversal, mutation, hover and Furnace callbacks execute; downstream setters, full screen update and settings timing remain observed boundaries. +The 585 captured kind-5 pointer events remain outside the controller consumer. +Focus-enabled VIEW delivery now follows the separate active-manager path described below. Default Furnace tabs explicitly disable focus. +Complete screen construction and admission, devices, pixels, the original reports and all five goal areas remain unfinished. + +All four builds pass 2,770 tests with 163 optional skips and no failures or errors. +All 1,448 core classes match both embedded copies; all 1,181 client classes match the final add-on. +The final add-on repeats the 208 tab cases, the earlier 147 arrow cases and all 29 GLOBAL View cases with mandatory class origins. +Ten packaged screen and input regressions pass, including authored VIEW tab preference synchronization and held input across a matching model update. + +## Focus-enabled VIEW tabs + +Focus-enabled tabs now select their native focus target and reveal it before calculating hover geometry. +Their whole-view hover queue reaches the controller before the group change. +Both stored View pointer positions stay unchanged. Non-focus tabs retain their separate group-change-first, local-hover route. +The screen regression covers focus transfer, preference synchronization and held input across the matching model update. +A separate regression verifies hover after reveal relayout. + +Focus ownership no longer manufactures the native selection-latch flag. +Original overlapping controls can assign hover and focus to different owners. +The old flag activated an extra focused mapping and changed the preference twice. +The combined input corpus rejects that behavior and preserves the actual hover owner. + +Original 1.26.51.1/protocol2193 execution matches 204 composed cases and 2,388 steps, including 804 group changes and 1,182 controller buttons. +The controls cover three input modes, mixed focus settings, radio states, input orders, hover consumption and overlapping or fractional bounds. +All 127,116 native frees finish with no native allocations. Nine incorrect variants fail the comparisons. +Original Input, Toggle, Focus and manager factories, registration, forced selection, reveal/hover and Furnace callbacks execute. +The inline ScreenComponent constructor executes before registration. Component vectors, tree/order, geometry, initial bindings, View fields and active manager state are supplied. + +Full screen updates, downstream setters and settings timing remain observed boundaries. +Six paired inactive-manager probes show different hover delivery. Production manager activation and that inactive path remain unfinished. +The native fixture has no edit-box component. Its selection flag and the separate Button press state still need their native owners. +All 1,284 new kind-5 controller pointer events remain outside this adapter. +Full screen construction and admission, native scroll reveal, devices, pixels, the original reports and all five goal areas remain open. + +All four project builds pass 2,773 tests with 163 optional skips and no failures or errors. +All 1,448 core classes match both embedded copies; all 1,181 client classes match the final add-on. +The final JAR matches both focused-input corpora and repeats the 208 plain-tab, 147 arrow and 29 GLOBAL View cases with mandatory class origins. +Thirteen packaged screen and input regressions pass, including focus transfer, reveal relayout and the false-latch regression. + + +## Native Button consumption and appearance + +Supported cooking controls now retain the native Button press byte independently of their applied visual state. +The control-level `consume_event` result reaches raw input and pointer dispatch. +The edit-box component owns selection flag `0x80`. Button appearance does not create that flag or derive it from focus. + +Raw phases `0` and `1` change the press byte. Phase `2` preserves it. +Selection flags determine whether a transition updates visibility immediately. +Matching scene replacements retain the applied visibility, including a suppressed press that differs from the visible state. +Named setters run before layout, so the selected branch also determines content dimensions and hit bounds. +The renderer preserves those setters instead of choosing another branch during draw collection. + +Named controls use the first breadth-first match. Setter order remains significant for shared state names. +Press and hover do not use a default fallback for missing state children. Disabled state uses the default child without a locked child. +Hover exit, hiding the Button, and disabling the Button clear the press byte. +An ancestor enabled change preserves it and broadcasts general event `17`, which updates visibility. +Attaching the same scene instance does not replay the setters or create a layout loop. + +Raw component callbacks execute before later controls sample their context. +Implicit hover buttons enter the controller queue without another sibling Button callback. +This distinction preserves appearance when hover mappings and ordinary presses share a control. +Capture-priority release routing retains duplicate requests and skipped mapping phases. + +Original 1.26.51.1/protocol 2193 execution supplies these comparisons: + +| Native path | Cases | Steps | +| --- | ---: | ---: | +| Button state and named visibility | 184 | 5,888 | +| Own and ancestor property producers | 64 | 1,280 | +| Button/Input composition | 720 | 7,920 | +| Implicit hover composition | 12 | 84 | +| ScreenView and controller delivery | 288 | 1,728 | +| Overlapping Button propagation | 48 | 288 | +| Total | 1,316 | 17,188 | + +All 201,090 native frees finish with no native allocations. Each fixture verifies all declared owner counts. +The factories, deferred name resolver, callbacks, property setters, ScreenView routing and destructors execute in their declared captures. +The ScreenView entry returns `void`. Its leftover return register is excluded from consumption comparisons. +The original Input component supplies consumption results. Eight incorrect variants fail the comparisons and lifecycle regressions. + +The published 0.3.49 JAR reproduces the old consumption and pressed-child gaps. +The rebuilt JAR matches all six new corpora with mandatory production class origins. +Twenty-one packaged regressions cover the new Button consumers and earlier screen, focus, tab and GLOBAL behavior. +All four builds pass 2,781 tests with 163 optional skips and no failures or errors. +All 1,448 core classes match both embedded copies. All 1,184 client classes match the final add-on. +The packaged add-on also repeats the 192 focused-tab, 12 focused-button, 208 plain-tab, 147 arrow and 29 GLOBAL View cases. + +The fixtures supply trees, input order, geometry, properties, clocks, View fields and inactive or explicitly active manager state. +External UIContext sinks retire shared arguments through the original destructor. They do not replace state or visibility outputs. +The overlap fixture disables hover consumption so both controls acquire hover through the original pointer entry. +Complete screen construction, update/binding order, manager activation, Button audio, edit-box selection and controller pointer receivers remain unfinished. +These comparisons do not establish physical-device behavior or native pixels. The original reports and all five goal areas remain open. + +## Visibility callbacks and input registration + +Supported cooking controls now retain native visibility callbacks and input registration across scene updates. +Own `visible` and `ignored` changes invalidate every mapping phase in both directions. +Hiding an ancestor removes its descendants from normal input order and invalidates their mapping phases. +The hidden hover callback clears Button appearance without synthesizing implicit hover buttons for the controller. +Double-press history and captured control identity survive these transitions. + +A hidden control can still receive its captured GLOBAL release. +Showing only its ancestor preserves that release phase. Showing the control itself triggers its own visibility callback and invalidates the phase. +Deleting the control still retires its captures before another control can reuse the identity. + +The comparison executes these original 1.26.51.1/protocol 2193 producers: + +- `144582b20`: the UIControl setter for visible and ignored state. +- `1448422c0`: the complete View function that drains pending updates. +- `144847310`: inherited visibility and hidden hover propagation. +- `144847620` and `1448491c0`: input admission, mapping invalidation, and sorted registration or removal. +- Original raw View routing, captured releases, base controller delivery, and native destructors. + +The 144 cases cover three input modes, owner and parent changes, visible and ignored state, three mapping kinds, and independent capture policies. +All 2,592 steps and 1,452 controller buttons match the source consumer. +Twelve separate fixtures start with hidden raw flags and prove phase invalidation during initial registration. +Both captures complete 40,932 native frees with balanced allocations and owner counts. +The published 0.3.50 add-on fails the comparison when its first hovered control becomes hidden. + +The fixture supplies the external notification queue and its scheduling boundary. +The real Input factory creates the modal root that establishes the admitted screen context. +Tree, geometry, clocks, input mode, and inactive manager state remain supplied. +No visibility, mapping phase, registration, or capture result uses a replacement function. +The raw View entry returns `void`, so its leftover return register remains excluded. +Whole screen construction, binding scheduling, synchronous named-child updates, modal transitions, manager activation, devices, and pixels remain open. +The four original reports and all five implementation areas remain unfinished. + +The direct visibility callback comparison adds 12 cases, 120 steps, and 2,190 balanced native frees. +Its preserved hover exposes incorrect double-press history clearing that the screen sequence alone cannot distinguish. +The final add-on matches all three new corpora with mandatory production class origins. +Six incorrect variants fail behavioral assertions, including the history-clearing variant against the direct callback corpus. + +Twenty-five packaged regressions pass, including four new mapping, registration, visibility, and capture regressions. +All four builds pass 2,785 tests with 163 optional skips and no failures or errors. +All 1,448 core classes match both embedded copies. All 1,184 client classes match the final add-on. +The final JAR also matches all six earlier Button corpora and the earlier focus, tab, arrow, and GLOBAL comparisons. +The rebuilt modpack contains the exact verified ViaFabricPlus and add-on JARs. +These lifecycle changes remain outside published release 0.3.50. + +## Synchronous named-control visibility + +The host creates and indexes all input components before it resolves Button names. +Named visibility setters notify the target Input before its Button callback. +Inherited visibility, hover exit, and ordinary registration wait for the later View update. +Later controls in the same raw pass therefore retain native delivery and observe the correct mapping phase. +Matching scene copies restore cached appearance without replaying intermediate aliased property changes. + +The new comparison executes the original tree constructor, deferred resolver, by-value notification methods, and complete pending-update drain. +Those notification methods populate and deduplicate the actual queues. The fixture does not insert queue entries directly. +The remaining notification queue at `+b8` has no established consumer in this corpus. Binding expressions remain outside its scope. + +The 216 original 1.26.51.1/protocol 2193 cases cover nested controls, self references, and wildcard callbacks. +All 3,456 steps and 3,012 controller buttons match. All 73,002 native frees complete with balanced owners and allocations. +The prior packaged implementation fails the first named-child phase comparison. +An intermediate implementation also resets held input when cached visuals restore an aliased scene copy. +A separate regression preserves that Java representation boundary without claiming a native scene-copy event. + +Root attachment, control trees, geometry, clocks, input mode, manager state, and drain boundaries remain supplied. +Full screen construction, binding expressions, physical frame scheduling, modal transitions, Button audio, devices, and pixels remain open. +Default/player inventory, the four original reports, and all five implementation areas remain unfinished. + +The final JAR matches all 1,700 cases across twelve Button and visibility corpora. +Seven new incorrect variants fail behavioral assertions, including the scene-restoration regression. +All 33 packaged regressions and the earlier focus, tab, arrow, and GLOBAL comparisons pass. +All four builds pass 2,793 tests with 163 optional skips and no failures or errors. +All 1,448 core classes match both embedded copies. All 1,184 client classes match the final add-on. +The modpack contains the exact verified client JARs. These changes are the candidate for release 0.3.51. + +## Native mode-change reference + +The original ScreenComponent factory branch now executes allocation, inline construction, registration, and its complete property parser. +Its `gamepad_cursor` flag belongs to the component, separate from UIControl flags at a similar offset. +Seventeen property cases match the published Java reader, including integer coercion and rejection of real values. + +The complete original mode-change producer now runs with the real tree context and its notification methods. +All 768 activation combinations preserve Input phases during sibling callbacks, then invalidate them during the queued root update. +Captured control identity survives. Another 36 sequences cover 360 steps, repeated notifications, three mapping kinds, and independent consumption and capture policies. +The kind-7 controller event retains its mode payload. All 171,588 native frees across these three captures balance. +The earlier bare-context probe omitted queue effects and therefore did not establish the complete mode lifecycle. + +The wrapper constructor receives cursor policy from the native hide-cursor option and cursor sensitivity. +The reference still supplies those option values, raw transition and selection fields, root attachment, geometry, clocks, and manager state. +It executes a bounded factory branch rather than the entire control or screen constructor. +Physical notification admission, initial screen-install ordering, derived Furnace controller population, settings ownership, devices, and pixels remain unverified. +Production mode delivery and all five implementation areas remain open. These reference cases do not expand the production parity claim for release 0.3.51. + +## Initial cooking input admission + +Supported cooking screens initialize their input mappings once after the first accepted scene. +The initial phase is two, matching the original View before controller delivery. +Refreshes and resize preparation preserve held input instead of repeating initialization. + +The reference executes the complete original wrapper constructor and destructor, resize, mode notification, activation, and frame paths. +All 144 initial-input cases cover 864 raw steps and 258 controller buttons, with 34,020 balanced native frees. +Bare control trees, geometry, empty layout queues, and external client services remain supplied. +The outer installer, physical notifications, authored layout construction, and derived cooking controllers remain outside this comparison. + +The actual screen regression fails against published release 0.3.51 before this correction. +The four original reports and all five implementation areas remain unfinished. + +All 1,844 cases across thirteen Button, visibility, and initialization corpora match the final JAR. +Three incorrect initialization variants fail behavioral assertions after both baselines pass. +All 34 packaged regressions and the earlier focus, tab, arrow, and GLOBAL comparisons pass. +All four builds pass 2,794 tests with 163 optional skips and no failures or errors. +The embedded core classes, compiled client classes, and exact bundled client JARs match. + +## Stationary pointer admission + +Cooking screens receive the current pointer after Java resynchronizes it, before the first frame. +The Gui hook uses the existing native viewport conversion and requires the same screen to remain installed. +A stationary pointer therefore selects and shows the hovered recipe without a movement event. +Refreshes and resize preparation keep their existing input lifetime. + +The whole original installer now executes resize, initial mode notification, activation, and its optional wrapper/View pointer path. +All 96 cases match the published component implementation across 480 raw steps and 282 controller-button events. +All 25,602 native frees balance. +Client services supply the pointer, viewport, mode, empty screen stack, absent player, and empty service list. +Keyboard context handling remains an explicit external boundary. + +The complete original `handleInputModeChanged` callback also executes its native screen lambda and GUI context construction. +Its 72 sequences cover 576 steps and 216 notifications, including repeated values and held input. +Mode notifications invalidate mappings while capture identities survive. All 18,480 native frees balance. +The one-screen enumeration service, GUI/context notifications, input subsystem, and telemetry remain supplied. +Physical notification admission and the client's actual screen selection policy remain unverified. +This capture does not add production gamepad delivery. + +Java 26.3 calls `Screen.init`, resynchronizes its pointer, then returns from `Gui.setScreen`. +`Minecraft.setScreenAndShow` renders a frame before it returns. +Normal movement delivery requires nonzero accumulated movement. +The hook therefore runs inside `Gui.setScreen` after resynchronization. +The actual screen regression covers fresh pointer coordinates, unequal UI scales, recipe selection, matching model updates, and stale screen ownership. +The initial regression fails before this correction with no compilation or linkage error. + +Bare native controls, empty layout queues, clocks, option values, and external services remain supplied. +Derived cooking controllers, authored layout and bindings, devices, and pixels remain separate gates. +Default/player inventory, other screens, all five implementation areas, and the four original reports remain unfinished. +These changes remain outside published release 0.3.52. + +All four builds pass 2,794 tests with 163 optional skips and no failures or errors. +The final JAR matches 1,940 cases across fourteen Button, visibility, initialization, and admission corpora. +All 35 packaged regressions and the earlier focus, tab, arrow, and GLOBAL comparisons pass. +Three incorrect pointer-admission variants fail behavioral assertions after the unchanged baseline passes. +The isolated Java 26.3 client applies the hook after resynchronization and before the first forced frame. +A passive bytecode observer verifies that placement in the actual transformed Gui class. +This runtime check establishes hook application and order. The screen regression supplies external Minecraft and network services. +The 1,448 core classes match both embedded copies, and all 1,185 compiled client classes match the add-on. +The rebuilt modpack contains the exact verified client JARs. + +## Player inventory opening ownership + +The installed-screen hook now opens the native player inventory through a negotiated bridge. +Direct translation and ViaProxy share the same route. +Java reuses menu zero, so the client gives each real opening a separate token. +A presentation swap retains that token, while an ordinary reopen receives a new one. +The old constructor mixin is removed. + +The core retains retired native opens until acknowledgment and waits for their close acknowledgment before a newer opening. +Retired screen clicks cannot replay into that newer opening. +Independent global actions can finish before the temporary inventory closes. +Server closes, form replacement and timeout target the owning client token. +Tokens stay monotonic across play-listener replacements, including reconfiguration. +An outstanding native open also prevents duplicate recovery requests. + +Sixteen packaged regressions cover lifecycle races, capability changes, connection replacement and bounded codecs. +They require production classes from the final JARs. +All four builds pass 2,817 tests with 163 optional skips. +The full authored player inventory, recipes, tabs, search and physical controller input remain unfinished. +The bridge depends on the deferred core inventory-request work. +Standalone upstream compatibility remains unverified. +The bridge remains outside release 0.3.54. + +The final isolated client completes four physical E-key open/close cycles through ViaProxy and Bedrock 1.26.51.1. +Two use survival inventory and two use the creative picker after a server command changes mode. +Both JVM observers confirm the four matching native acknowledgments without inventory clicks. +The persistent player-menu object stays the same, while opening tokens advance from one through four. +Constructing an unused inventory screen sends no native request. +Private screenshots show the ordinary Java screens. +Direct joins, real reconfiguration, packet-delay recovery and physical controller delivery remain separate gates. 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 f573d5b3..e66fca82 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 cc6e03d8ae215f9d32992db8fb74bc9169a51afb Mon Sep 17 00:00:00 2001 +From 85565af6bc61f6591ee9f607f8ece1455463de7a 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 @@ -195,10 +195,10 @@ listeners that share one still-connected network connection. 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 574728f..75a63cd 100644 +index 46db700..491a083 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java -@@ -90,6 +90,8 @@ import com.viaversion.viafabricplus.bedrock.appearance.ViaFabricPlusEmoteProvide +@@ -91,6 +91,8 @@ import com.viaversion.viafabricplus.bedrock.appearance.ViaFabricPlusEmoteProvide import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import com.viaversion.viafabricplus.bedrock.screen.ui.BedrockHudOverlays; @@ -207,7 +207,7 @@ index 574728f..75a63cd 100644 public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { -@@ -107,7 +109,9 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { +@@ -108,7 +110,9 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { @Override public void onPreSettingsLoading() { @@ -623,7 +623,7 @@ index 0000000..39e82e7 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/form/BedrockFormScene.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/form/BedrockFormScene.java new file mode 100644 -index 0000000..36db2a0 +index 0000000..12a23dd --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/form/BedrockFormScene.java @@ -0,0 +1,60 @@ @@ -644,7 +644,7 @@ index 0000000..36db2a0 + +/** A bounded, core-resolved scene. Layout never interprets server response indices. */ +public record BedrockFormScene(NativeUiScene.Node root, Fallback fallback) { -+ private static final Set RENDERERS = Set.of("hover_text_renderer", "live_horse_renderer"); ++ private static final Set RENDERERS = Set.of("hover_text_renderer", "live_horse_renderer", "live_player_renderer"); + + public static BedrockFormScene parse(final String json) { + final JsonObject scene = JsonParser.parseString(json).getAsJsonObject(); @@ -1172,7 +1172,7 @@ index 0000000..ceb1064 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/screen/npc/BedrockNpcScene.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/npc/BedrockNpcScene.java new file mode 100644 -index 0000000..6fccdb6 +index 0000000..f8e6995 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/npc/BedrockNpcScene.java @@ -0,0 +1,47 @@ @@ -1194,7 +1194,7 @@ index 0000000..6fccdb6 + BedrockNpcScene(final String identifier, final String title, final NativeUiScene.Node root) { + this(identifier, title, root, null); + } -+ static final Set RENDERERS = Set.of("hover_text_renderer", "actor_portrait_renderer", "live_horse_renderer"); ++ static final Set RENDERERS = Set.of("hover_text_renderer", "actor_portrait_renderer", "live_horse_renderer", "live_player_renderer"); + + static BedrockNpcScene parse(final NativeNpcDialogueMessage message) { + final var document = JsonParser.parseString(message.scene()).getAsJsonObject(); 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 99c07e06..a8c528fb 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 8cd6de0ae29c0549ed60d30a8c4c1e420cf62712 Mon Sep 17 00:00:00 2001 +From 095d54047e21bebba23c11b7de66c261f536a44f 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 @@ -269,6 +269,181 @@ visual and transaction comparisons remain required. Bound persistent HUD image metadata with least-recently-used eviction. The previous hard limit rejected valid later icons and retired their title scenes after 256 textures. Keep pack texture ownership in the resource manager and cover eviction, frequent hits and replaced resource sources. Default ordinary image controls to keep_ratio, matching the Bedrock 1.26.51.1 image loader and aspect-fit draw path. Preserve explicit false, slicing, clipping, signed destinations, and source regions. Tests compare default fitting against authored stretching for rectangular images. + +Treat an unavailable or corrupt synchronous pack image as a presentation +failure instead of silently omitting it. Container item extraction can +otherwise continue without its authored panel or slot images. Existing +screen failure handling retains the authoritative ordinary menu. Keep +remote URL loading asynchronous and preserve its pending-image behavior. +A failing regression covers missing resources, cached decode failures and +recovery after a new pack source supplies valid pixels. This verifies the +asset-failure path; it does not identify the exact report cause or establish +native inventory pixels at every GUI scale. + +Treat recipe action annotations as interactive controls independently of +close commands. Select their authored hover and locked branches using the +shared presentation path. A CPU regression covers both enabled states and +pointer states without adding a synthetic close annotation. + +Treat native toggle controls as interactive independently of form response +annotations. Their checked/unchecked hover and locked branches must remain +available to authored inventory groups. The presentation regression covers +all eight checked, enabled and pointer states without a form input marker. +Native device and pixel equivalence remain separate verification gates. + +Preserve authored child order in layout and sort only the complete draw +list by cumulative layer. Native 1.26.51.1 traversal1445ba980 visits +children in vector order and adds each authored layer without a depth +increment. Sorting siblings first reversed equal-layer draws across +nested subtrees, hiding the cooking tab icons behind their backgrounds. +Use reverse authored order to resolve equal-layer action overlap. + +Twelve original traversal controls bind cumulative layers, child order +and inherited visibility. Nested-image and installed-screen pointer +regressions both fail with the previous behavior and pass with this fix. +This does not establish native GPU batching, pixels or full UI parity. + +Use native integer-or-boolean component flag reads, including unsigned +64-bit values. Real values and strings retain the property default. The +original FocusComponent factory disables absent focus_enabled, so shared +actions must not acquire keyboard traversal focus by default. Apply the +same flag reader during scene visibility admission and runtime queries. +This does not replace Java navigation with the full native focus manager. + +Use native directional edge scoring, binary32 geometry, registration-order +ties, explicit identifiers and stop overrides for shared-screen arrows. +Apply nearest clipping ancestors and viewport wrapping rather than Java +widget rectangle traversal. Preserve editable-control key handling and +retained widget identities. Screen invokes its superclass focus path +directly, so route declined arrow keys through the native selector before +changing focus. + +Search original ScrollView viewport subtrees before the global clipped +search, preserving subtree order and the native no-wrap fallback. Apply +FocusContainer stop/contained/custom policies, node-name custom routes, +recursive remembered focus and the native 50-entry weak history. Explicit +focus identifiers bypass those container policies. Retain history while +scene properties refresh on existing widgets and keep offscreen edit +controls eligible for scroll navigation. + +Original component constructors, custom-property parsing, ancestor +registration, viewport weak binding, subtree iteration, directional +selection and destruction execute against the pinned native binary. +The bounded selector matches 9,320 native steps; original history controls +also verify disabled-control fallback and the 50-entry limit. Preserve +native control/screen construction, non-widget controls, scroll physics, +full input routing and physical device translation as separate gates. + +Synchronize widget visibility and editability during viewport reflow so a +newly revealed edit field accepts input before another render pass. Keep +animation advancement outside draw collection to avoid re-entering layout. +A screen regression traverses action and edit controls beyond the clip, +types into the selected field and verifies the original input index. + +Retain native input factory fields and phase-zero mapping state, binary32 +double-press distance and timing, implicit hover/pointer events, mode +conditions and nearest-ancestor/alternate pointer scopes. Source symbols +use the native UTF-8 FNV hash. Private controls match 614 factory cases, +9740 event steps and 2160 pointer-scope cases; incorrect variants fail. + +Route captured releases before the current input order, append repeated +capture requests and retire skipped mapping phases. Original ScreenView +execution supplies 216 matching priority steps. Native Toggle factories, +post-create callbacks and full UIControl destruction supply 143 field/ +initialization controls, 144 button/radio steps and 64 hover/radio steps. +Radio propagation clears different indices in the same group, including +ordinary toggles, while retaining duplicate indices. + +These shared component primitives require domain/controller delivery and +lifecycle integration before unchanged screens can be admitted. Bare +control/screen fields, clock/CRT/allocator boundaries and selected +controller observers remain declared native execution boundaries. Full +control construction, physical input and pixels remain unverified. + +Rank initial focus with native signed-int64 precedence narrowed to int32, +then the original float top-left score. Keep registration order for equal +scores and leave all-negative or unrankable candidates unselected. This +replaces the first-control shortcut used before directional navigation. +Original 1.26.51.1 execution matches 1,920 enabled ranking cases and 19 +typed property reads. Screen-owned routing and physical input remain +separate consumer responsibilities. + +Retain the raw native input phase through component dispatch and release +capture routing. Phase two is neither a press nor a release and cannot be +normalized to boolean. It does not request capture or activation, and it +preserves a completed double until an actual release. Controller press +queries match phase one only. This permits faithful GLOBAL reinjection +of the previous mapping phase rather than manufacturing a fresh press. +Original factories, dispatch, event/capture cleanup and destructors match +3,072 raw-phase steps and 1,008 exact button events. + +Retain the native Button appearance component separately from edit-box +selection and input activation. Keep its press byte separate from applied +visibility, preserve suppressed transitions and raw phase two, and apply +named setters in native order with breadth-first child ownership. Only a +missing locked control uses the default fallback. + +Expose direct visibility/enabled callbacks and general event 17 so own +and inherited property changes retain their different press lifetimes. +Original state and producer comparisons cover 248 cases and 7,168 steps. +Let component effects run before the next routed control reads its +context. Retained Button consumers apply their named visibility before +layout instead of selecting a visual again during draw collection. + +Expose a visibility revision for retained layout and avoid replaying +named setters when the same scene instance attaches again. + +Preserve native Input double-press history when visibility callbacks and +input-order removal invalidate every mapping phase. A retained control can +leave normal input order while its captured release still reaches the real +component. Model registration separately from capture identity so hidden +GLOBAL releases remain deliverable without admitting new hidden presses. +Original 1.26.51.1 UIControl and complete ScreenView pending-update execution +cover 144 visible/ignored parent and owner cases with balanced ownership. +The external notification queue, modal root and input mode are supplied; +full construction, binding scheduling, devices and pixels remain open. + +Create Button components before deferred named-control resolution and let +the admitting host perform their visibility writes. Native UIControl setters +notify sibling Input and Button components synchronously, including nested +and aliased state controls. A host writer preserves that callback ordering +while standalone component owners retain ordinary property writes. + +The original tree constructor, notification producers, deferred resolver, +View routing/update and destructors execute across 192 composed fixtures. +Tree/geometry, modal-root attachment, mode and explicit drain boundaries +remain supplied. Binding expression execution and physical frames remain +separate gates. + +Run sibling handlers inside native Input dispatch before committing the +accepted sender mapping phase. Original 144505990 emits callbacks before +144505950 writes that phase, so reentrant own-visibility invalidation cannot +overwrite the sender's final phase. Wildcard siblings also run before the +following mapping samples its phase. The router uses this component path +for normal and capture-priority delivery; Input-only users omit siblings. + +Restore cached appearance onto matching scene copies through property-only +writes. Replaying aliased runtime setters during that restoration creates +false Input visibility callbacks and resets a held mapping despite no +native control event. Changed references and actual component updates still +use the host writer and retain native reentrant notification ordering. + +Add the separate native live_player_renderer using the actual local actor, +rather than aliasing the signed-UID horse portrait. Keep actor pitch/yaw, +body-yaw history and head-yaw history independent. Original 1.26.51.1 draw +148ddd910 and cursor helper 148de0020 establish min-dimension framing, +swimming/crawling offsets, gliding precedence and independent yaw values. +The private reference executes the actual sparse component accessors and +normal-return player/mount restoration across 672 cases. Renderer/context +boundaries and host float math are supplied; native pixels and successful +renderer variables/effects remain unverified. + +Apply the local-player model correction after Java's PIP translation and +before entity submission. Preserve scoped playback and world actor state; +keep the existing horse path unchanged. Projection tests cover GUI scale +and scissor changes against original native matrices. Actual Java GUI +submission and complete player inventory/controller admission remain +separate verification gates. --- .../bedrock/ViaFabricPlusBedrock.java | 3 + .../appearance/BedrockBoneAnimation.java | 3 + @@ -280,7 +455,7 @@ Default ordinary image controls to keep_ratio, matching the Bedrock 1.26.51.1 im .../render/MixinAvatarAnimationRenderer.java | 2 + .../MixinBedrockRenderingResources.java | 2 + .../render/MixinEntityRenderDispatcher.java | 21 +- - .../render/MixinGuiEntityRenderer.java | 34 + + .../render/MixinGuiEntityRenderer.java | 53 + .../render/MixinNativeHudOverlays.java | 34 + .../render/MixinNativeUiExtraction.java | 40 + .../render/MixinNativeUiPictures.java | 72 ++ @@ -290,38 +465,44 @@ Default ordinary image controls to keep_ratio, matching the Bedrock 1.26.51.1 im .../render/BedrockAnimatedEntityState.java | 16 + .../bedrock/render/BedrockEntityRenderer.java | 6 +- .../bedrock/render/BedrockGeometry.java | 4 +- - .../render/BedrockGuiActorContext.java | 133 +++ + .../render/BedrockGuiActorContext.java | 151 +++ .../render/BedrockGuiActorRenderer.java | 130 +++ .../render/BedrockPlayerAttachables.java | 15 +- .../bedrock/render/BedrockPlayerSkins.java | 43 +- - .../bedrock/screen/ui/BedrockHudOverlays.java | 131 +++ + .../bedrock/screen/ui/BedrockHudOverlays.java | 129 +++ .../screen/ui/NativeHudOverlayState.java | 66 ++ .../bedrock/screen/ui/NativeHudPayload.java | 36 + .../bedrock/screen/ui/NativeHudResources.java | 21 + .../screen/ui/NativeUiActorCamera.java | 60 ++ - .../screen/ui/NativeUiActorPortraits.java | 222 +++++ + .../screen/ui/NativeUiActorPortraits.java | 257 +++++ .../screen/ui/NativeUiAnimationLifecycle.java | 131 +++ .../bedrock/screen/ui/NativeUiAnimations.java | 382 +++++++ .../bedrock/screen/ui/NativeUiAseprite.java | 54 + + .../bedrock/screen/ui/NativeUiButton.java | 167 ++++ .../bedrock/screen/ui/NativeUiColor.java | 55 + .../bedrock/screen/ui/NativeUiController.java | 37 + .../bedrock/screen/ui/NativeUiDimension.java | 93 ++ .../bedrock/screen/ui/NativeUiFlipbook.java | 47 + + .../bedrock/screen/ui/NativeUiFocus.java | 329 ++++++ .../bedrock/screen/ui/NativeUiFont.java | 47 + .../bedrock/screen/ui/NativeUiGraphics.java | 47 + .../bedrock/screen/ui/NativeUiImageCache.java | 229 +++++ .../screen/ui/NativeUiImageRegions.java | 132 +++ - .../bedrock/screen/ui/NativeUiImages.java | 167 ++++ - .../bedrock/screen/ui/NativeUiLayout.java | 525 ++++++++++ + .../bedrock/screen/ui/NativeUiImages.java | 171 ++++ + .../bedrock/screen/ui/NativeUiInput.java | 248 +++++ + .../screen/ui/NativeUiInputRouter.java | 72 ++ + .../bedrock/screen/ui/NativeUiLayout.java | 522 ++++++++++ .../screen/ui/NativeUiMultilineInput.java | 267 +++++ - .../screen/ui/NativeUiPresentation.java | 150 +++ + .../screen/ui/NativeUiPlayerCamera.java | 72 ++ + .../screen/ui/NativeUiPresentation.java | 162 +++ .../bedrock/screen/ui/NativeUiRenderer.java | 153 +++ - .../bedrock/screen/ui/NativeUiScene.java | 93 ++ - .../screen/ui/NativeUiSceneScreen.java | 735 ++++++++++++++ + .../bedrock/screen/ui/NativeUiScene.java | 107 ++ + .../screen/ui/NativeUiSceneScreen.java | 823 +++++++++++++++ .../bedrock/screen/ui/NativeUiScreen.java | 11 + .../bedrock/screen/ui/NativeUiScroll.java | 69 ++ .../bedrock/screen/ui/NativeUiSlider.java | 123 +++ .../bedrock/screen/ui/NativeUiText.java | 64 ++ + .../bedrock/screen/ui/NativeUiToggle.java | 94 ++ .../bedrock/screen/ui/NativeUiViewport.java | 30 + .../shaders/core/bedrock_actor.fsh | 3 + .../viafabricplus-bedrock.accesswidener | 1 + @@ -338,20 +519,27 @@ Default ordinary image controls to keep_ratio, matching the Bedrock 1.26.51.1 im .../ui/NativeUiAnimationLifecycleTest.java | 131 +++ .../screen/ui/NativeUiAnimationsTest.java | 264 +++++ .../screen/ui/NativeUiAsepriteTest.java | 108 ++ + .../bedrock/screen/ui/NativeUiButtonTest.java | 127 +++ .../screen/ui/NativeUiColorAnimationTest.java | 114 +++ + .../bedrock/screen/ui/NativeUiFocusTest.java | 124 +++ .../screen/ui/NativeUiImageCacheTest.java | 142 +++ .../screen/ui/NativeUiImageRegionsTest.java | 217 ++++ - .../bedrock/screen/ui/NativeUiImagesTest.java | 65 ++ + .../bedrock/screen/ui/NativeUiImagesTest.java | 100 ++ + .../screen/ui/NativeUiInputRouterTest.java | 125 +++ + .../bedrock/screen/ui/NativeUiInputTest.java | 200 ++++ .../bedrock/screen/ui/NativeUiLayoutTest.java | 942 ++++++++++++++++++ .../screen/ui/NativeUiMultilineInputTest.java | 210 ++++ - .../screen/ui/NativeUiPresentationTest.java | 190 ++++ + .../screen/ui/NativeUiPlayerCameraTest.java | 132 +++ + .../screen/ui/NativeUiPresentationTest.java | 258 +++++ .../screen/ui/NativeUiRendererTest.java | 71 ++ - .../NativeUiSceneScreenInteractionTest.java | 153 +++ + .../NativeUiSceneScreenInteractionTest.java | 359 +++++++ + .../bedrock/screen/ui/NativeUiSceneTest.java | 40 + .../bedrock/screen/ui/NativeUiSliderTest.java | 120 +++ .../bedrock/screen/ui/NativeUiTextTest.java | 56 ++ .../ui/NativeUiTextureAnimationTest.java | 108 ++ + .../bedrock/screen/ui/NativeUiToggleTest.java | 86 ++ .../screen/ui/NativeUiViewportTest.java | 55 + - 81 files changed, 8662 insertions(+), 24 deletions(-) + 94 files changed, 10972 insertions(+), 24 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/mixin/features/input/MixinNativeUiInput.java @@ -372,17 +560,22 @@ Default ordinary image controls to keep_ratio, matching the Bedrock 1.26.51.1 im create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiAnimationLifecycle.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiAnimations.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiAseprite.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiButton.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiColor.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiController.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiDimension.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiFlipbook.java + 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/NativeUiImageCache.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiImageRegions.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiImages.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiInput.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiInputRouter.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiLayout.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiMultilineInput.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiPlayerCamera.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiPresentation.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiRenderer.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiScene.java @@ -391,6 +584,7 @@ Default ordinary image controls to keep_ratio, matching the Bedrock 1.26.51.1 im create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiScroll.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiSlider.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiText.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiToggle.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiViewport.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockGuiActorAppearanceTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockGuiActorContextTest.java @@ -401,25 +595,32 @@ Default ordinary image controls to keep_ratio, matching the Bedrock 1.26.51.1 im create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiAnimationLifecycleTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiAnimationsTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiAsepriteTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiButtonTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiColorAnimationTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiFocusTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiImageCacheTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiImageRegionsTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiImagesTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiInputRouterTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiInputTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiLayoutTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiMultilineInputTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiPlayerCameraTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiPresentationTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiRendererTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiSceneScreenInteractionTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiSceneTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiSliderTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiTextTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiTextureAnimationTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiToggleTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiViewportTest.java diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java b/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java -index fa67a11..574728f 100644 +index c9baec1..46db700 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.render.NativeCustomEntityPayload; +@@ -28,6 +28,7 @@ import com.viaversion.viafabricplus.bedrock.render.NativeCustomEntityPayload; import com.viaversion.viafabricplus.bedrock.render.NativeActorPayload; import com.viaversion.viafabricplus.bedrock.render.NativeActorBoundsPayload; import com.viaversion.viafabricplus.bedrock.render.NativeActorIdentityPayload; @@ -427,7 +628,7 @@ index fa67a11..574728f 100644 import com.viaversion.viafabricplus.bedrock.render.NativeActorPickingPayload; import com.viaversion.viafabricplus.bedrock.render.BedrockActorPicking; import com.viaversion.viafabricplus.bedrock.render.NativeProjectileOwnerPayload; -@@ -88,6 +89,7 @@ import net.raphimc.viabedrock.protocol.provider.EmoteProvider; +@@ -89,6 +90,7 @@ 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; @@ -435,7 +636,7 @@ index fa67a11..574728f 100644 public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { -@@ -105,6 +107,7 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { +@@ -106,6 +108,7 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { @Override public void onPreSettingsLoading() { @@ -852,10 +1053,10 @@ index c343816..94877d4 100644 } diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinGuiEntityRenderer.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinGuiEntityRenderer.java new file mode 100644 -index 0000000..aa496a9 +index 0000000..144f40a --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinGuiEntityRenderer.java -@@ -0,0 +1,34 @@ +@@ -0,0 +1,53 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -865,16 +1066,35 @@ index 0000000..aa496a9 + +import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; ++import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import com.mojang.blaze3d.vertex.PoseStack; +import com.viaversion.viafabricplus.bedrock.render.BedrockGuiActorContext; +import net.minecraft.client.gui.render.pip.GuiEntityRenderer; +import net.minecraft.client.renderer.SubmitNodeCollector; ++import net.minecraft.client.renderer.entity.EntityRenderDispatcher; ++import net.minecraft.client.renderer.entity.state.EntityRenderState; ++import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.state.gui.pip.GuiEntityRenderState; +import org.spongepowered.asm.mixin.Mixin; ++import org.spongepowered.asm.mixin.injection.At; + +/** Corrects only native GUI actor frames inside Minecraft's ordinary entity submission. */ +@Mixin(GuiEntityRenderer.class) +public abstract class MixinGuiEntityRenderer { ++ @WrapOperation(method = "renderToTexture(Lnet/minecraft/client/renderer/state/gui/pip/GuiEntityRenderState;Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/SubmitNodeCollector;)V", ++ at = @At(value = "INVOKE", target = "Lnet/minecraft/client/renderer/entity/EntityRenderDispatcher;submit(Lnet/minecraft/client/renderer/entity/state/EntityRenderState;Lnet/minecraft/client/renderer/state/level/CameraRenderState;DDDLcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/SubmitNodeCollector;)V")) ++ private void playerModel(final EntityRenderDispatcher dispatcher, final EntityRenderState state, ++ final CameraRenderState camera, final double x, final double y, final double z, ++ final PoseStack pose, final SubmitNodeCollector collector, final Operation original) { ++ final var context = BedrockGuiActorContext.frame(state); ++ if (context == null) { original.call(dispatcher, state, camera, x, y, z, pose, collector); return; } ++ pose.pushPose(); ++ try { ++ context.transform(pose); ++ original.call(dispatcher, state, camera, x, y, z, pose, collector); ++ } finally { pose.popPose(); } ++ } ++ + @WrapMethod(method = "renderToTexture(Lnet/minecraft/client/renderer/state/gui/pip/GuiEntityRenderState;Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/SubmitNodeCollector;)V") + private void nativePerspective(final GuiEntityRenderState state, final PoseStack pose, + final SubmitNodeCollector collector, final Operation original) { @@ -1105,7 +1325,7 @@ index e4aee39..48bc82c 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 9e8d844..0af7920 100644 +index fac68bb..89181b5 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorRenderTypes.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorRenderTypes.java @@ -6,6 +6,8 @@ @@ -1161,7 +1381,7 @@ index 9e8d844..0af7920 100644 + final var shaderPipeline = gui ? RenderPipelines.ENTITY_TRANSLUCENT : emissive ? RenderPipelines.EYES : ignoreLighting ? RenderPipelines.ENTITY_CUTOUT_Z_OFFSET : cull ? RenderPipelines.ENTITY_CUTOUT_CULL : RenderPipelines.ENTITY_CUTOUT; - return BedrockShaderPipelines.register(builder.build(), shaderPipeline); + return BedrockShaderPipelines.register(builder.build(), shaderPipeline, emissive ? Float.NaN : 0.5F); @@ -79,6 +95,19 @@ public final class BedrockActorRenderTypes { return type; } @@ -1205,10 +1425,10 @@ index 0000000..0cd79ee + BedrockActorAnimation.Visibility visibility(); +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockEntityRenderer.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockEntityRenderer.java -index e81030d..fa8da04 100644 +index 342a99b..d8926ae 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockEntityRenderer.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockEntityRenderer.java -@@ -317,7 +317,7 @@ public final class BedrockEntityRenderer extends EntityRenderer models = List.of(); private Map pose = Map.of(); private BedrockActorAnimation.Visibility visibility = BedrockActorAnimation.Visibility.UNSPECIFIED; -@@ -326,8 +326,8 @@ public final class BedrockEntityRenderer extends EntityRenderer 0) || !Float.isFinite(width) || guiScale < 1) + throw new IllegalArgumentException("Invalid native GUI actor scale"); + pose.scale(1, -1, -1F / (width * guiScale)); @@ -1308,6 +1533,7 @@ index 0000000..7fbbdfe + this.source = null; + this.playback = null; + this.selection = null; ++ this.modelTransform = null; + this.sounds.clear(); + this.particles.clear(); + } @@ -1331,20 +1557,32 @@ index 0000000..7fbbdfe + } + + public void camera(final float pitch, final float yaw) { ++ this.pose(pitch, yaw, yaw, yaw, null); ++ } ++ ++ public void pose(final float pitch, final float yaw, final float bodyYaw, final float headYaw, ++ final Matrix4fc modelTransform) { + this.pitch = pitch; + this.yaw = yaw; ++ this.bodyYaw = bodyYaw; ++ this.headYaw = headYaw; ++ this.modelTransform = modelTransform == null ? null : new Matrix4f(modelTransform); + this.selection = null; + this.sounds.clear(); + this.particles.clear(); + } + ++ public void transform(final PoseStack pose) { ++ if (this.modelTransform != null) pose.mulPose(this.modelTransform); ++ } ++ + public static void extracted(final AvatarRenderState state) { + extracted((EntityRenderState) state); + final var current = current(); + if (current != null) { + state.xRot = current.pitch; -+ state.yRot = 0; -+ state.bodyRot = current.yaw; ++ state.yRot = current.headYaw - current.bodyYaw; ++ state.bodyRot = current.bodyYaw; + } + } + @@ -1520,7 +1758,7 @@ index 0000000..411a3c2 + 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 5c2d6b6..81458fe 100644 +index fd1b9d4..8ffa6e9 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 { @@ -1532,7 +1770,7 @@ index 5c2d6b6..81458fe 100644 + : gui.resource(packs, "attachable_selectors", WeakHashMap::new); final List result = new ArrayList<>(); final Map queries = new HashMap<>(input.queries()); - queries.putAll(BedrockAttachableBindings.queries(entity.getMainArm() == net.minecraft.world.entity.HumanoidArm.RIGHT)); + queries.putAll(BedrockAttachableBindings.queries()); @@ -110,7 +113,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)); @@ -1557,7 +1795,7 @@ index 5c2d6b6..81458fe 100644 final Map queries = new HashMap<>(input == null ? BedrockPlayerSkins.actorQueries(entity, state, partialTick) : input.queries()); queries.putAll(BedrockPlayerSkins.geometryQueries(state)); queries.put("is_invisible", state.isInvisible); - queries.putAll(BedrockAttachableBindings.queries(entity.getMainArm() == net.minecraft.world.entity.HumanoidArm.RIGHT)); + queries.putAll(BedrockAttachableBindings.queries()); + if (gui != null) queries.put("is_in_ui", true); queries.put("owner_identifier", "minecraft:player"); queries.put("is_owner_identifier_any", (BedrockActorAnimation.Query) names -> names.contains("minecraft:player")); @@ -1572,7 +1810,7 @@ index 5c2d6b6..81458fe 100644 } catch (IOException | RuntimeException invalid) { if (FAILED_SELECTIONS.add(stack.getItem().toString())) 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 a10aebc..2991542 100644 +index 2c46b34..1e64ca9 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerSkins.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerSkins.java @@ -372,7 +372,9 @@ public final class BedrockPlayerSkins { @@ -1680,10 +1918,10 @@ index a10aebc..2991542 100644 ? walking.bedrockWalkingAnimation().sample(partialTick) : 0F); 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..db19286 +index 0000000..04a8f13 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/BedrockHudOverlays.java -@@ -0,0 +1,131 @@ +@@ -0,0 +1,129 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -1692,7 +1930,6 @@ index 0000000..db19286 +package com.viaversion.viafabricplus.bedrock.screen.ui; + +import com.viaversion.viafabricplus.bedrock.ViaFabricPlusBedrock; -+import java.util.Comparator; +import java.util.Map; +import java.util.EnumMap; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; @@ -1782,7 +2019,6 @@ index 0000000..db19286 + 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)); -+ draws.sort(Comparator.comparingDouble(draw -> draw.box().layer())); + graphics.pose().pushMatrix(); + try { + graphics.pose().scale(viewport.renderScale(), viewport.renderScale()); @@ -2024,10 +2260,10 @@ index 0000000..774496f +} 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..eb27721 +index 0000000..97fba34 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiActorPortraits.java -@@ -0,0 +1,222 @@ +@@ -0,0 +1,257 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -2047,6 +2283,8 @@ index 0000000..eb27721 +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; @@ -2055,6 +2293,7 @@ index 0000000..eb27721 +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; @@ -2156,22 +2395,26 @@ index 0000000..eb27721 + this.contexts.clear(); + this.level = client.level; + } -+ if (!BedrockActorState.supportsUniqueIds()) throw new IllegalArgumentException("Native portrait identity capability is unavailable"); + final String key = this.paths.get(box.node()); + if (key == null) throw new IllegalStateException("Native portrait scene was not prepared"); -+ final long uniqueId = uniqueId(box.node()); -+ final var uuid = BedrockActorState.uuidByUniqueId(uniqueId); ++ 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 = client.level.getEntity(uuid); ++ 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 || snapshot.uniqueId() == null) throw new IllegalArgumentException("Native portrait identity is unavailable"); ++ 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(); @@ -2183,6 +2426,10 @@ index 0000000..eb27721 + 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()); @@ -2197,9 +2444,33 @@ index 0000000..eb27721 + (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 net.raphimc.viabedrock.api.model.NativeActorMessage.Snapshot snapshot, ++ 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; @@ -2835,6 +3106,179 @@ index 0000000..b02e0d5 + } + } +} +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 00000000..bc593e44 +--- /dev/null ++++ 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 ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++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"); ++ } ++ ++ 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); ++ } ++ ++ 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 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 record Reference(NativeUiScene.Node node, List path) {} ++ } ++} 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 @@ -3091,6 +3535,341 @@ index 0000000..399e5e8 + 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 00000000..0d643631 +--- /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; ++ ++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; ++ ++/** Native directional selection, scroll contexts and focus-container policies for admitted controls. */ ++public final class NativeUiFocus { ++ private NativeUiFocus() {} ++ ++ 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; ++ } ++ } ++ 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; ++ } ++ ++ 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; ++ } ++ ++ 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; ++ } ++ } ++ ++ 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); ++ } ++ ++ 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(); ++ }; ++ } ++ ++ 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; ++ } ++ return null; ++ } ++ ++ private static boolean within(final Target target, final NativeUiScene.Node ancestor) { ++ return target.ancestors.stream().anyMatch(parent -> parent.node() == ancestor); ++ } ++ ++ 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; ++ } ++ ++ public static final class History { ++ private final Map>> recent = new IdentityHashMap<>(); ++ private final Map> remembered = new IdentityHashMap<>(); ++ private Target selected; ++ ++ 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; ++ } ++ ++ 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; ++ } ++ } ++ ++ 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; ++ } ++ } ++ if (closest != null) return closest; ++ if (aligned != null) return aligned; ++ return !wrapped && wrapping ++ ? geometry(targets, current, direction, width, height, wrapping, clipCheck, true) : null; ++ } ++ ++ private static String string(final NativeUiScene.Node node, final String key) { ++ return string(node.properties(), key); ++ } ++ ++ 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 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/NativeUiFont.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiFont.java new file mode 100644 index 0000000..d0e61a6 @@ -3572,10 +4351,10 @@ index 0000000..3f3e908 +} 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..8255761 +index 0000000..8475ec8 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiImages.java -@@ -0,0 +1,167 @@ +@@ -0,0 +1,171 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -3700,8 +4479,12 @@ index 0000000..8255761 + 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) { this.failed = true; return null; } -+ if (this.failedImages.get(id) == resource.source()) return null; ++ if (resource == 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 NativeImage pixels = NativeImage.read(input)) { @@ -3716,7 +4499,7 @@ index 0000000..8255761 + this.failedImages.put(id, resource.source()); + if (this.failedImages.size() > MAX_PACK_IMAGES) this.failedImages.pollFirstEntry(); + this.failed = true; -+ return null; ++ throw new IllegalArgumentException("Invalid native UI texture: " + id, error); + } + } + @@ -3743,12 +4526,12 @@ index 0000000..8255761 + + public record Image(Identifier id, int width, int height, Object source) {} +} -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/NativeUiInput.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiInput.java new file mode 100644 -index 0000000..02aa2c8 +index 00000000..2b631814 --- /dev/null -+++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiLayout.java -@@ -0,0 +1,525 @@ ++++ 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 @@ -3756,60 +4539,391 @@ index 0000000..02aa2c8 + */ +package com.viaversion.viafabricplus.bedrock.screen.ui; + -+import com.google.gson.JsonElement; ++import com.google.gson.JsonObject; ++import java.nio.charset.StandardCharsets; +import java.util.ArrayList; -+import java.util.Comparator; -+import java.util.HashMap; -+import java.util.IdentityHashMap; +import java.util.List; -+import java.util.Map; -+ -+/** 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 int work; ++import java.util.function.Consumer; + -+ public NativeUiLayout(final Metrics metrics) { -+ this(metrics, null); ++/** 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)); + } + -+ public NativeUiLayout(final Metrics metrics, final NativeUiAnimations animations) { -+ this.metrics = metrics; -+ this.animations = animations; ++ 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 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 boolean flag(final JsonObject properties, final String key, final boolean fallback) { ++ return NativeUiScene.flag(properties, key, fallback); + } + -+ public Box layout(final NativeUiScene.Node node, final int width, final int height, -+ final Map scroll) { -+ this.measured.clear(); -+ this.visibleNodes.clear(); -+ this.visibility(node, true); -+ this.work = 0; -+ final Size size = this.measure(node, width, height); -+ final Rect viewport = new Rect(0, 0, width, height); -+ return this.place(node, viewport, size, 0, viewport, scroll, null); ++ 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 visibility(final NativeUiScene.Node node, final boolean inherited) { -+ final boolean visible = inherited && node.flag("visible", true) && !node.flag("ignored", false); -+ this.visibleNodes.put(node, visible); -+ for (final var child : node.children()) this.visibility(child, visible); -+ } ++ public static final class State { ++ private final NativeUiInput definition; ++ private final int[] phases; ++ private final java.util.Map presses = new java.util.HashMap<>(); + -+ 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)); ++ public State(final NativeUiInput definition) { ++ this.definition = definition; ++ this.phases = new int[definition.mappings.size()]; ++ } ++ ++ public int phase(final int index) { return this.phases[index]; } ++ ++ /** 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; ++ } ++ 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()); ++ } ++ return new Dispatch(result, List.copyOf(events), List.of()); ++ } ++ ++ /** 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)); ++ } ++ ++ 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); ++ } ++ ++ 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; ++ } ++ } ++ ++ 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/NativeUiInputRouter.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiInputRouter.java +new file mode 100644 +index 00000000..8c0c6f6a +--- /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<>(); ++ ++ 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)); ++ } ++ ++ public void retain(final Set controls) { ++ this.captures.replaceAll((source, captured) -> captured.stream().filter(controls::contains).toList()); ++ this.captures.values().removeIf(List::isEmpty); ++ } ++ ++ 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/NativeUiLayout.java b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiLayout.java +new file mode 100644 +index 0000000..59e5b0b +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiLayout.java +@@ -0,0 +1,522 @@ ++/* ++ * 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; ++import java.util.ArrayList; ++import java.util.HashMap; ++import java.util.IdentityHashMap; ++import java.util.List; ++import java.util.Map; ++ ++/** 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 int work; ++ ++ public NativeUiLayout(final Metrics metrics) { ++ this(metrics, null); ++ } ++ ++ public NativeUiLayout(final Metrics metrics, final NativeUiAnimations animations) { ++ this.metrics = metrics; ++ this.animations = animations; ++ } ++ ++ 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 Box layout(final NativeUiScene.Node node, final int width, final int height, ++ final Map scroll) { ++ this.measured.clear(); ++ this.visibleNodes.clear(); ++ this.visibility(node, true); ++ this.work = 0; ++ final Size size = this.measure(node, width, height); ++ final Rect viewport = new Rect(0, 0, width, height); ++ return this.place(node, viewport, size, 0, viewport, scroll, null); ++ } ++ ++ private void visibility(final NativeUiScene.Node node, final boolean inherited) { ++ final boolean visible = inherited && node.flag("visible", true) && !node.flag("ignored", false); ++ this.visibleNodes.put(node, visible); ++ for (final var child : node.children()) this.visibility(child, visible); ++ } ++ ++ 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)); + } + + private Size measure(final NativeUiScene.Node node, final Measurement environment) { @@ -4024,7 +5138,6 @@ index 0000000..02aa2c8 + children.set(i, scrollContent(children.get(i), contentBox.node, horizontal ? -amount : 0, horizontal ? 0 : -amount, contentClip)); + } + } -+ children.sort(Comparator.comparingDouble(Box::layer)); + 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)); @@ -4039,7 +5152,6 @@ index 0000000..02aa2c8 + 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)); -+ children.sort(Comparator.comparingDouble(Box::layer)); + return new Box(box.node, bounds, box.clip, this.content(box.node, sizes, bounds.width, bounds.height), + bounds, box.layer, List.copyOf(children)); + } @@ -4547,12 +5659,90 @@ index 0000000..92a87a7 + 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 00000000..81c52c36 +--- /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; ++ ++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); ++ } ++ // 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); ++ } ++ ++ 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); ++ } ++ ++ 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(); ++ } ++ ++ /** Logical cursor position, after the current input source supplied its admission and coordinates. */ ++ public record Cursor(float x, float y) {} ++ ++ 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..4f60c1a +index 00000000..1279cdca --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiPresentation.java -@@ -0,0 +1,150 @@ +@@ -0,0 +1,162 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -4562,6 +5752,7 @@ index 0000000..4f60c1a + +import java.util.ArrayList; +import java.util.Collections; ++import java.util.Comparator; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.List; @@ -4630,15 +5821,26 @@ index 0000000..4f60c1a + + public static List draws(final NativeUiLayout.Box root, final Predicate hovered, + final Function opacity) { ++ return draws(root, hovered, opacity, _ -> false); ++ } ++ ++ /** 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, ++ 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 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; @@ -4663,7 +5865,7 @@ index 0000000..4f60c1a + if (childAlpha.potential == 0) return; + final boolean isHovered = node.interactive() && hovered.test(box); + final Set excluded = new HashSet<>(); -+ if (!node.type().equals("slider") && !node.type().equals("slider_box")) { ++ 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"); + } @@ -4685,7 +5887,7 @@ index 0000000..4f60c1a + } + for (final var child : box.children()) { + if (excluded.contains(child.node().name())) continue; -+ collect(child, childAlpha, enabled, hovered, opacity, hidden, result); ++ collect(child, childAlpha, enabled, hovered, opacity, retainedButtons, hidden, result); + } + } + @@ -4864,10 +6066,10 @@ index 0000000..d3e9c23 +} 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..efa2d70 +index 00000000..e0ed6750 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiScene.java -@@ -0,0 +1,93 @@ +@@ -0,0 +1,107 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -4878,6 +6080,7 @@ index 0000000..efa2d70 +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; @@ -4910,10 +6113,7 @@ index 0000000..efa2d70 + 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 JsonElement visibility = value.get("visible"); -+ final boolean visible = inheritedVisible && !(visibility != null && visibility.isJsonPrimitive() -+ && visibility.getAsJsonPrimitive().isBoolean() && !visibility.getAsBoolean()) && !(value.has("ignored") && value.get("ignored").isJsonPrimitive() -+ && value.get("ignored").getAsJsonPrimitive().isBoolean() && value.get("ignored").getAsBoolean()); ++ 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<>(); @@ -4931,6 +6131,24 @@ index 0000000..efa2d70 + return value == null || !value.isJsonPrimitive() ? fallback : value.getAsString(); + } + ++ 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; ++ } ++ } ++ + 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); @@ -4945,14 +6163,12 @@ index 0000000..efa2d70 + } + + public boolean flag(final String key, final boolean fallback) { -+ final JsonElement value = this.properties.get(key); -+ return value != null && value.isJsonPrimitive() && value.getAsJsonPrimitive().isBoolean() -+ ? value.getAsBoolean() : fallback; ++ return NativeUiScene.flag(this.properties, key, fallback); + } + + public boolean interactive() { -+ return this.action != Integer.MIN_VALUE || this.properties.has("input") || this.properties.has("command") -+ || this.properties.has("inventory_actions") || this.properties.has("inventory_command") || this.properties.has("url"); ++ 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"); + } + + public boolean scroll() { @@ -4963,10 +6179,10 @@ index 0000000..efa2d70 +} 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..7734c0c +index 00000000..2a38ec51 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiSceneScreen.java -@@ -0,0 +1,735 @@ +@@ -0,0 +1,823 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -4977,6 +6193,7 @@ index 0000000..7734c0c +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; @@ -4990,8 +6207,10 @@ index 0000000..7734c0c +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; @@ -5000,7 +6219,7 @@ index 0000000..7734c0c +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.ComponentSerialization; + -+/** Draws the authored scene, with ordinary Java event, focus, narration and response handling. */ ++/** 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; @@ -5009,8 +6228,10 @@ index 0000000..7734c0c + 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<>(); @@ -5067,6 +6288,7 @@ index 0000000..7734c0c + 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) { @@ -5130,6 +6352,8 @@ index 0000000..7734c0c + 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())); + } + + private void indexGeometry(final NativeUiLayout.Box box) { @@ -5170,7 +6394,6 @@ index 0000000..7734c0c + this.layout(); + final List draws = this.draws(mouseX, mouseY); + this.synchronize(draws); -+ draws.sort(Comparator.comparingDouble(draw -> draw.box().layer())); + NativeUiScene.Node tooltip = null; + for (final NativeUiPresentation.Draw draw : draws) { + final var box = draw.box(); @@ -5185,7 +6408,8 @@ index 0000000..7734c0c + 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.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); @@ -5215,6 +6439,10 @@ index 0000000..7734c0c + final long now = this.animationTime(); + this.lifecycle.advance(now); + if (this.animations.layoutRevision() != this.layoutRevision) this.layout(); ++ return this.currentDraws(mouseX, mouseY, now); ++ } ++ ++ 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") @@ -5224,6 +6452,7 @@ index 0000000..7734c0c + } + + 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) { @@ -5236,6 +6465,8 @@ index 0000000..7734c0c + } + 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); @@ -5373,6 +6604,43 @@ index 0000000..7734c0c + } + + @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)); ++ } ++ ++ 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); ++ }); ++ } ++ ++ @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)); ++ } ++ ++ @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)) { @@ -5402,18 +6670,42 @@ index 0000000..7734c0c + return 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; ++ }; ++ 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; ++ } ++ ++ 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()); -+ return result; + } + + @Override + public boolean charTyped(final CharacterEvent event) { + final boolean result = super.charTyped(event); -+ this.refreshPendingScene(); -+ if (this.getFocused() instanceof NativeInput input) this.reveal(this.root, input.node()); ++ this.refreshFocusedControl(); + return result; + } + @@ -5524,6 +6816,10 @@ index 0000000..7734c0c + 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(); @@ -5534,6 +6830,14 @@ index 0000000..7734c0c + 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); + } @@ -5678,7 +6982,7 @@ index 0000000..7734c0c + + @Override + public ComponentPath nextFocusPath(final FocusNavigationEvent event) { -+ return this.node.flag("focus_enabled", true) ? super.nextFocusPath(event) : null; ++ return this.node.flag("focus_enabled", false) ? super.nextFocusPath(event) : null; + } + + @Override @@ -5993,6 +7297,106 @@ index 0000000..f1f207d + 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 00000000..e63749c4 +--- /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..82eaa26 @@ -6044,19 +7448,19 @@ index 8039501..8824767 100644 fragColor = apply_fog(color, sphericalVertexDistance, cylindricalVertexDistance, FogEnvironmentalStart, FogEnvironmentalEnd, FogRenderDistanceStart, diff --git a/src/main/resources/viafabricplus-bedrock.accesswidener b/src/main/resources/viafabricplus-bedrock.accesswidener -index f42de8f..bbc61ef 100644 +index 8e8672c..e52389d 100644 --- a/src/main/resources/viafabricplus-bedrock.accesswidener +++ b/src/main/resources/viafabricplus-bedrock.accesswidener -@@ -7,3 +7,4 @@ accessible method net/minecraft/client/renderer/rendertype/RenderType create (Lj +@@ -12,3 +12,4 @@ 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 diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json -index 4c6e15b..1008491 100644 +index 86b160d..070607e 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -119,6 +119,8 @@ +@@ -136,6 +136,8 @@ "features.render.MixinBedrockModelPartTransforms", "features.render.MixinPlayerSkinWidget", "features.render.MixinGuiSkinRenderer", @@ -6065,7 +7469,7 @@ index 4c6e15b..1008491 100644 "features.input.MixinKeyboardInput", "features.input.MixinLocalPlayerInput", "features.input.MixinClientInput", -@@ -153,7 +155,11 @@ +@@ -190,7 +192,11 @@ "features.render.MixinBedrockCameraFade", "features.entities.MixinClientEntityTracking", "features.entities.MixinMinecraftProjectileOwners", @@ -6291,10 +7695,10 @@ index 0000000..d53ff81 + } +} 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 9f76d8a..2b47d0f 100644 +index 685e7f4..7a15392 100644 --- a/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockResourceLibraryTest.java +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockResourceLibraryTest.java -@@ -138,6 +138,31 @@ final class BedrockResourceLibraryTest { +@@ -175,6 +175,31 @@ final class BedrockResourceLibraryTest { assertEquals("server", server.translate("fallback", "de_de")); } @@ -6348,7 +7752,7 @@ index 008c104..6d09113 100644 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/BedrockShaderPipelinesTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderPipelinesTest.java -index cbdb67c..ead4cfb 100644 +index f9c2ba3..e60a5fe 100644 --- a/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderPipelinesTest.java +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderPipelinesTest.java @@ -10,7 +10,6 @@ import static org.junit.jupiter.api.Assertions.*; @@ -6359,7 +7763,7 @@ index cbdb67c..ead4cfb 100644 for (final boolean cull : new boolean[] {false, true}) { for (final boolean unlit : new boolean[] {false, true}) { for (final boolean emissive : new boolean[] {false, true}) { -@@ -29,6 +28,24 @@ final class BedrockShaderPipelinesTest { +@@ -31,6 +30,24 @@ final class BedrockShaderPipelinesTest { } } @@ -7371,12 +8775,12 @@ index 0000000..2a0b370 + return metadata; + } +} -diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiColorAnimationTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiColorAnimationTest.java +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiButtonTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiButtonTest.java new file mode 100644 -index 0000000..a4d6db9 +index 00000000..a6a275df --- /dev/null -+++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiColorAnimationTest.java -@@ -0,0 +1,114 @@ ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiButtonTest.java +@@ -0,0 +1,127 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -7384,32 +8788,165 @@ index 0000000..a4d6db9 + */ +package com.viaversion.viafabricplus.bedrock.screen.ui; + -+import com.google.gson.JsonArray; +import com.google.gson.JsonObject; -+import com.google.gson.JsonParser; +import java.util.List; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + -+final class NativeUiColorAnimationTest { ++final class NativeUiButtonTest { + @Test -+ void interpolatesAllChannelsWithoutClampingUntilImageSubmission() { -+ final var color = track(); -+ color.add("from", JsonParser.parseString("[0.125,1,-0.25,0.8]")); -+ color.add("to", JsonParser.parseString("[1.2,0,0.5,0.2]")); -+ color.addProperty("easing", "out_back"); -+ final var node = node("image", color); -+ final var animations = new NativeUiAnimations(); -+ animations.advance(node, 750_000_000L); -+ // Pinned native updater 144578f30 preserves easing overshoot on all four channels. -+ final var actual = animations.color(node); -+ assertTrue(actual.red() > 1.2F); -+ assertTrue(actual.green() < 0); -+ assertTrue(actual.blue() > .5F); -+ assertTrue(actual.alpha() < .2F); -+ assertEquals(actual.argb(.5F), NativeUiRenderer.imageColor(node, .5F, animations)); -+ assertEquals(255, actual.argb(1) >>> 16 & 255); ++ void deferredReferencesUseThePressStateAfterEarlierVisibilityCallbacks() { ++ final var root = button(true); ++ final var changed = new java.util.ArrayList(); ++ final var state = new NativeUiButton.State(NativeUiButton.parse(root.properties()), (node, visible) -> { ++ node.properties().addProperty("visible", visible); ++ changed.add(node); ++ }); ++ state.button(event(1, true), true, false); ++ assertTrue(state.pressed()); ++ state.visibility(false, true, false); ++ assertFalse(state.pressed()); ++ assertTrue(changed.isEmpty()); ++ state.attach(root, true, false); ++ assertEquals(List.of(root.children().get(3), root.children().get(2), root.children().get(1)), changed); ++ assertTrue(root.children().getFirst().flag("visible", false)); ++ assertFalse(root.children().get(2).flag("visible", true)); ++ } ++ ++ @Test ++ void suppressedTransitionsRetainTheirVisualStateAcrossMatchingSceneCopies() { ++ final var root = button(true); ++ final var state = new NativeUiButton.State(NativeUiButton.parse(root.properties()), root, true, false); ++ state.button(event(1, false), true, false); ++ assertTrue(state.pressed()); ++ assertTrue(root.children().getFirst().flag("visible", false)); ++ final var replacement = button(true); ++ state.attach(replacement, true, false); ++ assertTrue(replacement.children().getFirst().flag("visible", false)); ++ assertFalse(replacement.children().get(2).flag("visible", true)); ++ state.general(17, true, false); ++ assertTrue(replacement.children().get(2).flag("visible", false)); ++ state.button(event(2, true), true, false); ++ assertTrue(state.pressed()); ++ state.hover(false, true); ++ assertFalse(state.pressed()); ++ assertTrue(replacement.children().getFirst().flag("visible", false)); ++ } ++ ++ @Test ++ void ancestorDisablePreservesThePressButTheOwnEnabledCallbackClearsIt() { ++ final var root = button(true); ++ final var state = new NativeUiButton.State(NativeUiButton.parse(root.properties()), root, true, false); ++ state.button(event(1, true), true, false); ++ state.general(17, false, false); ++ assertTrue(state.pressed()); ++ assertTrue(root.children().getLast().flag("visible", false)); ++ state.general(17, true, false); ++ assertTrue(root.children().get(2).flag("visible", false)); ++ state.enabled(false); ++ assertFalse(state.pressed()); ++ assertTrue(root.children().get(2).flag("visible", false)); ++ state.general(17, false, false); ++ assertTrue(root.children().getLast().flag("visible", false)); ++ } ++ ++ @Test ++ void onlyTheMissingLockedStateUsesTheDefaultFallback() { ++ final var child = node("default", List.of()); ++ final var properties = new JsonObject(); ++ properties.addProperty("default_control", child.name()); ++ final var root = new NativeUiScene.Node("owner", "button", properties, List.of(child), Integer.MIN_VALUE); ++ final var state = new NativeUiButton.State(NativeUiButton.parse(properties), root, true, false); ++ state.button(event(1, true), true, false); ++ assertFalse(child.flag("visible", true)); ++ state.general(17, false, false); ++ assertTrue(child.flag("visible", false)); ++ state.enabled(false); ++ state.hover(true, true); ++ assertFalse(child.flag("visible", true)); ++ } ++ ++ @Test ++ void namedSettersUseBreadthFirstOwnershipAndPreserveAliasOrder() { ++ final var deep = node("shared", List.of()); ++ final var shallow = node("shared", List.of()); ++ deep.properties().addProperty("visible", false); ++ shallow.properties().addProperty("visible", false); ++ final var properties = new JsonObject(); ++ properties.addProperty("default_control", shallow.name()); ++ properties.addProperty("pressed_control", shallow.name()); ++ final var root = new NativeUiScene.Node("owner", "button", properties, ++ List.of(node("branch", List.of(deep)), shallow), Integer.MIN_VALUE); ++ final var state = new NativeUiButton.State(NativeUiButton.parse(properties), root, true, false); ++ assertFalse(deep.flag("visible", true)); ++ assertTrue(shallow.flag("visible", false)); ++ state.button(event(1, true), true, false); ++ assertFalse(shallow.flag("visible", true)); ++ assertFalse(deep.flag("visible", true)); ++ final long revision = state.revision(); ++ state.attach(root, true, false); ++ assertEquals(revision, state.revision()); ++ } ++ ++ private static NativeUiScene.Node button(final boolean consume) { ++ final var properties = new JsonObject(); ++ properties.addProperty("consume_event", consume); ++ final List children = List.of("default", "hover", "pressed", "locked").stream() ++ .map(name -> node(name, List.of())).toList(); ++ for (final var child : children) properties.addProperty(child.name() + "_control", child.name()); ++ return new NativeUiScene.Node("owner", "button", properties, children, Integer.MIN_VALUE); ++ } ++ ++ private static NativeUiScene.Node node(final String name, final List children) { ++ final var properties = new JsonObject(); ++ properties.addProperty("visible", true); ++ return new NativeUiScene.Node(name, "panel", properties, children, Integer.MIN_VALUE); ++ } ++ ++ private static NativeUiInput.ButtonEvent event(final int phase, final boolean update) { ++ return new NativeUiInput.ButtonEvent(0, 0, 0, phase, false, 1, NativeUiInput.Scope.DEFAULT, update, update); ++ } ++} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiColorAnimationTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiColorAnimationTest.java +new file mode 100644 +index 0000000..a4d6db9 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiColorAnimationTest.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.JsonArray; ++import com.google.gson.JsonObject; ++import com.google.gson.JsonParser; ++import java.util.List; ++import org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++final class NativeUiColorAnimationTest { ++ @Test ++ void interpolatesAllChannelsWithoutClampingUntilImageSubmission() { ++ final var color = track(); ++ color.add("from", JsonParser.parseString("[0.125,1,-0.25,0.8]")); ++ color.add("to", JsonParser.parseString("[1.2,0,0.5,0.2]")); ++ color.addProperty("easing", "out_back"); ++ final var node = node("image", color); ++ final var animations = new NativeUiAnimations(); ++ animations.advance(node, 750_000_000L); ++ // Pinned native updater 144578f30 preserves easing overshoot on all four channels. ++ final var actual = animations.color(node); ++ assertTrue(actual.red() > 1.2F); ++ assertTrue(actual.green() < 0); ++ assertTrue(actual.blue() > .5F); ++ assertTrue(actual.alpha() < .2F); ++ assertEquals(actual.argb(.5F), NativeUiRenderer.imageColor(node, .5F, animations)); ++ assertEquals(255, actual.argb(1) >>> 16 & 255); + assertEquals(0, actual.argb(1) >>> 8 & 255); + assertEquals(1, animations.opacityAt(node, 750_000_000L).current()); + } @@ -7491,6 +9028,136 @@ index 0000000..a4d6db9 + return new NativeUiScene.Node("", type, properties, List.of(), Integer.MIN_VALUE); + } +} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiFocusTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiFocusTest.java +new file mode 100644 +index 00000000..f0227660 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiFocusTest.java +@@ -0,0 +1,124 @@ ++/* ++ * 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; ++import java.util.List; ++import org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++final class NativeUiFocusTest { ++ @Test ++ void initialFocusRanksPrecedenceBeforeTopLeftPositionAndExcludesNegativeOnlyCandidates() { ++ final var upper = target(0, 0, 100, 100); ++ final var preferred = target(40, 40, 1, 1); ++ preferred.box().node().properties().addProperty("default_focus_precedence", 2); ++ final var targets = List.of(upper, preferred); ++ assertSame(preferred, NativeUiFocus.next(targets, null, NativeUiFocus.Direction.LEFT, 200, 200, new NativeUiFocus.History())); ++ upper.box().node().properties().addProperty("default_focus_precedence", -1); ++ preferred.box().node().properties().addProperty("default_focus_precedence", -2); ++ assertNull(NativeUiFocus.next(targets, null, NativeUiFocus.Direction.RIGHT, 200, 200, new NativeUiFocus.History())); ++ } ++ ++ @Test ++ void initialFocusUsesFloatScoresAndRetainsTheFirstEqualScore() { ++ final var first = target(16_777_216, 1.00001F, 20, 20); ++ final var second = target(16_777_216, 1, 20, 20); ++ assertSame(first, NativeUiFocus.next(List.of(first, second), null, NativeUiFocus.Direction.DOWN, 10, 200, new NativeUiFocus.History())); ++ final var unranked = target(Float.MAX_VALUE, 0, 20, 20); ++ assertNull(NativeUiFocus.next(List.of(unranked), null, NativeUiFocus.Direction.UP, 1, 200, new NativeUiFocus.History())); ++ } ++ ++ @Test ++ void initialFocusNarrowsSignedIntegersAndDoesNotCoerceUnsignedOrRealProperties() { ++ final var first = target(0, 0, 20, 20); ++ final var second = target(40, 0, 20, 20); ++ final var targets = List.of(first, second); ++ second.box().node().properties().addProperty("default_focus_precedence", new BigInteger("4294967299")); ++ assertSame(second, NativeUiFocus.next(targets, null, NativeUiFocus.Direction.UP, 200, 200, new NativeUiFocus.History())); ++ second.box().node().properties().addProperty("default_focus_precedence", new BigInteger("9223372036854775808")); ++ assertSame(first, NativeUiFocus.next(targets, null, NativeUiFocus.Direction.UP, 200, 200, new NativeUiFocus.History())); ++ second.box().node().properties().addProperty("default_focus_precedence", 3.0); ++ assertSame(first, NativeUiFocus.next(targets, null, NativeUiFocus.Direction.UP, 200, 200, new NativeUiFocus.History())); ++ } ++ ++ @Test ++ void measuresTheNearestDirectionalEdgeRatherThanTheCenter() { ++ final var current = target(0, 0, 20, 20); ++ final var nearerCenter = target(100, 0, 10, 10); ++ final var nearerEdge = target(40, 0, 200, 20); ++ assertSame(nearerEdge, NativeUiFocus.next(List.of(current, nearerCenter, nearerEdge), current.box().node(), NativeUiFocus.Direction.RIGHT, 800, 480, new NativeUiFocus.History())); ++ } ++ ++ @Test ++ void equalDistancesRetainRegistrationOrder() { ++ final var current = target(0, 0, 20, 20); ++ final var first = target(50, 0, 20, 20); ++ final var second = target(50, 0, 20, 20); ++ assertSame(first, NativeUiFocus.next(List.of(current, first, second), current.box().node(), NativeUiFocus.Direction.RIGHT, 800, 480, new NativeUiFocus.History())); ++ assertSame(second, NativeUiFocus.next(List.of(current, second, first), current.box().node(), NativeUiFocus.Direction.RIGHT, 800, 480, new NativeUiFocus.History())); ++ } ++ ++ @Test ++ void usesTheNearestClippingAncestorForNavigation() { ++ final var current = target(10, 90, 20, 20).box(); ++ final var next = target(40, 140, 20, 20).box(); ++ final var inner = clip(new NativeUiLayout.Rect(20, 150, 40, 80), next); ++ final var outer = clip(new NativeUiLayout.Rect(0, 0, 100, 100), current, inner); ++ final var targets = NativeUiFocus.targets(outer, _ -> true); ++ assertEquals(2, targets.size()); ++ assertSame(inner.node(), targets.getLast().clip().owner()); ++ assertSame(next.node(), NativeUiFocus.next(targets, current.node(), NativeUiFocus.Direction.DOWN, 800, 480, new NativeUiFocus.History()).box().node()); ++ } ++ ++ @Test ++ void appliesContainerPoliciesAndLetsExplicitOverridesBypassThem() { ++ final var source = target(0, 0, 20, 20).box(); ++ final var inside = target(40, 0, 20, 20).box(); ++ final var destination = target(80, 0, 20, 20).box(); ++ final var inner = group("inner", inside); ++ final var outer = group("outer", source, inner); ++ final var other = group("other", destination); ++ final var root = box(new NativeUiScene.Node("root", "panel", new JsonObject(), List.of(), 0), ++ new NativeUiLayout.Rect(0, 0, 200, 100), List.of(outer, other)); ++ final var targets = NativeUiFocus.targets(root, _ -> true); ++ final var history = new NativeUiFocus.History(); ++ outer.node().properties().addProperty("focus_navigation_mode_right", "contained"); ++ assertSame(inside.node(), NativeUiFocus.next(targets, source.node(), NativeUiFocus.Direction.RIGHT, 200, 100, history).box().node()); ++ outer.node().properties().addProperty("focus_navigation_mode_right", "stop"); ++ assertSame(source.node(), NativeUiFocus.next(targets, source.node(), NativeUiFocus.Direction.RIGHT, 200, 100, history).box().node()); ++ destination.node().properties().addProperty("focus_identifier", "explicit_destination"); ++ source.node().properties().addProperty("focus_change_right", "explicit_destination"); ++ assertSame(destination.node(), NativeUiFocus.next(targets, source.node(), NativeUiFocus.Direction.RIGHT, 200, 100, history).box().node()); ++ } ++ ++ private static NativeUiLayout.Box group(final String name, final NativeUiLayout.Box... children) { ++ final var properties = new JsonObject(); ++ properties.addProperty("focus_container", true); ++ return box(new NativeUiScene.Node(name, "panel", properties, List.of(), 0), ++ new NativeUiLayout.Rect(0, 0, 200, 100), List.of(children)); ++ } ++ ++ private static NativeUiFocus.Target target(final float x, final float y, final float width, final float height) { ++ final var properties = new JsonObject(); ++ properties.addProperty("focus_enabled", true); ++ final var node = new NativeUiScene.Node("", "button", properties, List.of(), Integer.MIN_VALUE); ++ return new NativeUiFocus.Target(box(node, new NativeUiLayout.Rect(x, y, width, height), List.of()), null, List.of()); ++ } ++ ++ private static NativeUiLayout.Box clip(final NativeUiLayout.Rect bounds, final NativeUiLayout.Box... children) { ++ final var properties = new JsonObject(); ++ properties.addProperty("clips_children", true); ++ final var node = new NativeUiScene.Node("", "panel", properties, List.of(), Integer.MIN_VALUE); ++ return box(node, bounds, List.of(children)); ++ } ++ ++ private static NativeUiLayout.Box box(final NativeUiScene.Node node, final NativeUiLayout.Rect bounds, final List children) { ++ return new NativeUiLayout.Box(node, bounds, bounds, new NativeUiLayout.Size(bounds.width(), bounds.height()), bounds, 0, children); ++ } ++} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiImageCacheTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiImageCacheTest.java new file mode 100644 index 0000000..c782dae @@ -7864,10 +9531,10 @@ index 0000000..d0b8045 +} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiImagesTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiImagesTest.java new file mode 100644 -index 0000000..e1067b9 +index 0000000..0d360fd --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiImagesTest.java -@@ -0,0 +1,65 @@ +@@ -0,0 +1,100 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -7878,6 +9545,7 @@ index 0000000..e1067b9 +import com.mojang.blaze3d.platform.NativeImage; +import java.io.ByteArrayInputStream; +import java.io.InputStream; ++import java.io.IOException; +import java.util.Optional; +import net.minecraft.client.Minecraft; +import net.minecraft.server.packs.PackResources; @@ -7929,10 +9597,381 @@ index 0000000..e1067b9 + } + } + ++ @Test ++ void rejectsUnavailablePackImagesAndRecoversWhenTheirSourceChanges() throws Exception { ++ final var game = mock(Minecraft.class); ++ final var resources = mock(ResourceManager.class); ++ when(game.getResourceManager()).thenReturn(resources); ++ when(resources.getResource(any())).thenReturn(Optional.empty()); ++ try (final var singleton = mockStatic(Minecraft.class); ++ final var decoder = mockStatic(NativeImage.class); ++ final var images = new NativeUiImages()) { ++ singleton.when(Minecraft::getInstance).thenReturn(game); ++ assertThrows(IllegalArgumentException.class, () -> images.image(texture(0))); ++ assertTrue(images.failed()); ++ ++ final var resource = mock(Resource.class); ++ when(resources.getResource(any())).thenReturn(Optional.of(resource)); ++ when(resource.source()).thenReturn(mock(PackResources.class)); ++ when(resource.open()).thenThrow(new IOException()); ++ assertThrows(IllegalArgumentException.class, () -> images.image(texture(0))); ++ assertThrows(IllegalArgumentException.class, () -> images.image(texture(0))); ++ verify(resource).open(); ++ ++ final var pixels = mock(NativeImage.class); ++ when(pixels.getWidth()).thenReturn(16); ++ when(pixels.getHeight()).thenReturn(12); ++ when(resource.source()).thenReturn(mock(PackResources.class)); ++ doAnswer(_ -> new ByteArrayInputStream(new byte[0])).when(resource).open(); ++ decoder.when(() -> NativeImage.read(any(InputStream.class))).thenReturn(pixels); ++ final var restored = images.image(texture(0)); ++ assertNotNull(restored); ++ assertEquals(16, restored.width()); ++ assertEquals(12, restored.height()); ++ } ++ } ++ + private static String texture(final int index) { + return "viabedrock:textures/server_ui/icon_" + index + ".png"; + } +} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiInputRouterTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiInputRouterTest.java +new file mode 100644 +index 00000000..69a6bc2d +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiInputRouterTest.java +@@ -0,0 +1,125 @@ ++/* ++ * 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.JsonArray; ++import com.google.gson.JsonObject; ++import java.util.List; ++import java.util.Set; ++import org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++final class NativeUiInputRouterTest { ++ private static final int SOURCE = NativeUiInput.symbol("button.menu_ok"); ++ ++ @Test ++ void aRetainedControlOutsideInputOrderReceivesOnlyItsCapturedRelease() { ++ final var input = state("global", true); ++ final var router = new NativeUiInputRouter(); ++ final var registered = control(0, input, true); ++ router.dispatch(SOURCE, 1, List.of(registered)); ++ input.invalidateMappings(); ++ final var removed = new NativeUiInputRouter.Control<>(0, input, registered.context(), registered.handler(), false); ++ assertTrue(router.dispatch(SOURCE, 1, List.of(removed)).events().isEmpty()); ++ assertTrue(router.captures(SOURCE)); ++ final var release = router.dispatch(SOURCE, 0, List.of(removed)); ++ assertEquals(1, release.events().size()); ++ assertEquals(2, release.events().getFirst().button().previous()); ++ assertEquals(0, input.phase(0)); ++ assertFalse(router.captures(SOURCE)); ++ assertTrue(router.dispatch(SOURCE, 0, List.of(removed)).events().isEmpty()); ++ } ++ ++ @Test ++ void unavailablePhaseDoesNotRetireTheCapturedRelease() { ++ final var router = new NativeUiInputRouter(); ++ final var controls = List.of(control(0, state("global", true), true)); ++ router.dispatch(SOURCE, 1, controls); ++ assertTrue(router.captures(SOURCE)); ++ assertEquals(2, router.dispatch(SOURCE, 2, controls).events().getFirst().button().phase()); ++ assertTrue(router.captures(SOURCE)); ++ router.dispatch(SOURCE, 0, controls); ++ assertFalse(router.captures(SOURCE)); ++ } ++ ++ @Test ++ void capturePrecedesTheCurrentPointerTargetAndSuppressesTheNormalPass() { ++ final var first = state("pressed", true); ++ final var second = state("pressed", true); ++ final var router = new NativeUiInputRouter(); ++ router.dispatch(SOURCE, 1, controls(first, second, 0)); ++ final var release = router.dispatch(SOURCE, 0, controls(first, second, 1)); ++ assertEquals(List.of(0), release.events().stream().map(NativeUiInputRouter.Event::key).toList()); ++ assertTrue(release.consumed()); ++ assertEquals(1, release.events().getFirst().button().previous()); ++ assertEquals(2, first.phase(0)); ++ assertEquals(2, second.phase(0)); ++ assertFalse(router.captures(SOURCE)); ++ } ++ ++ @Test ++ void repeatedCaptureRequestsRemainDistinctAndDoNotReplaceEarlierRequests() { ++ final var input = state("global", false); ++ final var router = new NativeUiInputRouter(); ++ final var controls = List.of(control(0, input, true)); ++ router.dispatch(SOURCE, 1, controls); ++ router.dispatch(SOURCE, 1, controls); ++ final var release = router.dispatch(SOURCE, 0, controls); ++ assertEquals(List.of(1, 0, 0), release.events().stream().map(event -> event.button().previous()).toList()); ++ assertFalse(router.captures(SOURCE)); ++ } ++ ++ @Test ++ void deletedControlsRetireCapturesBeforeAnIdentityCanBeReused() { ++ final var router = new NativeUiInputRouter(); ++ router.dispatch(SOURCE, 1, List.of(control(0, state("global", false), true))); ++ router.retain(Set.of()); ++ assertFalse(router.captures(SOURCE)); ++ final var replacement = state("global", false); ++ assertEquals(1, router.dispatch(SOURCE, 0, List.of(control(0, replacement, true))).events().size()); ++ } ++ ++ @Test ++ void componentEffectsPrecedeTheNextControlsContext() { ++ final var flags = new java.util.concurrent.atomic.AtomicInteger(0x7e); ++ final var second = state("focused", false); ++ final var first = new NativeUiInputRouter.Control<>(0, state("global", false), ++ () -> new NativeUiInput.Context(0x7e, 1, 0, 0, true, true, false, false, 0, 0), ++ _ -> flags.set(0x7c), true); ++ final var following = new NativeUiInputRouter.Control<>(1, second, ++ () -> new NativeUiInput.Context(flags.get(), 1, 0, 0, true, true, false, false, 0, 0), _ -> { }, true); ++ final var result = new NativeUiInputRouter().dispatch(SOURCE, 1, List.of(first, following)); ++ assertEquals(List.of(0), result.events().stream().map(NativeUiInputRouter.Event::key).toList()); ++ assertEquals(2, second.phase(0)); ++ assertFalse(result.consumed()); ++ } ++ ++ private static NativeUiInput.State state(final String kind, final boolean consume) { ++ final var properties = new JsonObject(); ++ final var mapping = new JsonObject(); ++ mapping.addProperty("from_button_id", "button.menu_ok"); ++ mapping.addProperty("to_button_id", "button.menu_select"); ++ mapping.addProperty("mapping_type", kind); ++ mapping.addProperty("consume_event", consume); ++ mapping.addProperty("button_up_right_of_first_refusal", true); ++ final var mappings = new JsonArray(); ++ mappings.add(mapping); ++ properties.add("button_mappings", mappings); ++ return new NativeUiInput.State(NativeUiInput.parse(properties)); ++ } ++ ++ private static List> controls(final NativeUiInput.State first, ++ final NativeUiInput.State second, final int selected) { ++ return List.of(control(0, first, selected == 0), control(1, second, selected == 1)); ++ } ++ ++ private static NativeUiInputRouter.Control control(final int key, final NativeUiInput.State state, ++ final boolean hovered) { ++ return new NativeUiInputRouter.Control<>(key, state, ++ () -> new NativeUiInput.Context(0x7c | (hovered ? 2 : 0), 1, 0, 0, hovered, true, false, false, 0, 0), _ -> { }, true); ++ } ++} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiInputTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiInputTest.java +new file mode 100644 +index 00000000..bec654e8 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiInputTest.java +@@ -0,0 +1,200 @@ ++/* ++ * 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.JsonArray; ++import com.google.gson.JsonObject; ++import java.util.List; ++import org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++final class NativeUiInputTest { ++ private static final int SOURCE = NativeUiInput.symbol("button.menu_ok"); ++ ++ @Test ++ void senderCommitsItsPhaseAfterAReentrantComponentInvalidation() { ++ final var input = state("global", true); ++ final var first = input.dispatch(SOURCE, 1, context(1, 0, 0, 0, true), event -> { ++ assertEquals(0, input.phase(0)); ++ input.invalidateMappings(); ++ }); ++ assertEquals(0, first.events().getFirst().previous()); ++ assertEquals(1, input.phase(0)); ++ final var repeated = input.dispatch(SOURCE, 1, context(1, 0, 0, 1, true), _ -> input.invalidateMappings()); ++ assertEquals(1, repeated.events().getFirst().previous()); ++ assertFalse(repeated.events().getFirst().transition()); ++ assertEquals(1, input.phase(0)); ++ } ++ ++ @Test ++ void wildcardComponentsCanInvalidateTheFollowingMappingBeforeItSamplesItsPhase() { ++ final var properties = properties("global", false); ++ final var wildcard = new JsonObject(); ++ wildcard.addProperty("from_button_id", "any"); ++ properties.getAsJsonArray("button_mappings").add(wildcard); ++ final var input = new NativeUiInput.State(NativeUiInput.parse(properties)); ++ final var result = input.dispatch(SOURCE, 1, context(1, 0, 0, 0, true), _ -> input.invalidateMappings()); ++ assertEquals(2, result.events().size()); ++ assertEquals(2, result.events().get(1).previous()); ++ assertEquals(1, input.phase(0)); ++ } ++ ++ @Test ++ void invalidatingMappingsPreservesDoublePressHistoryAndRetiresEverySource() { ++ final var properties = properties("double_pressed", true); ++ final var other = properties("global", false).getAsJsonArray("button_mappings").get(0).deepCopy().getAsJsonObject(); ++ final int otherSource = NativeUiInput.symbol("button.menu_cancel"); ++ other.addProperty("from_button_id", "button.menu_cancel"); ++ properties.getAsJsonArray("button_mappings").add(other); ++ final var input = new NativeUiInput.State(NativeUiInput.parse(properties)); ++ input.dispatch(SOURCE, 1, context(1, 0, 0, 1_000_000_000L, true)); ++ input.dispatch(otherSource, 1, context(1, 0, 0, 1_050_000_000L, true)); ++ input.invalidateMappings(); ++ assertEquals(2, input.phase(0)); ++ assertEquals(2, input.phase(1)); ++ final var doubled = input.dispatch(SOURCE, 1, context(1, 0, 0, 1_100_000_000L, true)); ++ assertTrue(doubled.events().getFirst().transition()); ++ assertEquals(2, doubled.events().getFirst().previous()); ++ assertEquals(2, input.dispatch(otherSource, 0, context(1, 0, 0, 1_150_000_000L, true)).events().getFirst().previous()); ++ } ++ ++ @Test ++ void unavailablePhaseIsRetainedWithoutActivationOrCapture() { ++ final var input = state("global", true); ++ input.dispatch(SOURCE, 1, context(1, 0, 0, 0, true)); ++ final var unavailable = input.dispatch(SOURCE, 2, context(1, 0, 0, 1, true)); ++ assertEquals(2, input.phase(0)); ++ assertEquals(1, unavailable.events().getFirst().previous()); ++ assertEquals(2, unavailable.events().getFirst().phase()); ++ assertFalse(unavailable.events().getFirst().pressed()); ++ assertFalse(unavailable.events().getFirst().transition()); ++ assertTrue(unavailable.captureRequests().isEmpty()); ++ assertEquals(2, input.dispatch(SOURCE, 0, context(1, 0, 0, 2, true)).events().getFirst().previous()); ++ assertTrue(input.dispatch(SOURCE, 1, context(1, 0, 0, 3, true)).events().getFirst().transition()); ++ } ++ ++ @Test ++ void unavailablePhaseKeepsACompletedDoublePressUntilAnActualRelease() { ++ final var input = state("double_pressed", false); ++ assertTrue(input.dispatch(SOURCE, 1, context(1, 0, 0, 0, true)).events().isEmpty()); ++ assertTrue(input.dispatch(SOURCE, 2, context(1, 0, 0, 1, true)).events().isEmpty()); ++ assertTrue(input.dispatch(SOURCE, 1, context(1, 0, 0, 2, true)).events().getFirst().transition()); ++ assertEquals(2, input.dispatch(SOURCE, 2, context(1, 0, 0, 3, true)).events().getFirst().phase()); ++ assertEquals(2, input.dispatch(SOURCE, 2, context(1, 0, 0, 4, true)).events().getFirst().phase()); ++ assertEquals(0, input.dispatch(SOURCE, 0, context(1, 0, 0, 5, true)).events().getFirst().phase()); ++ assertTrue(input.dispatch(SOURCE, 2, context(1, 0, 0, 6, true)).events().isEmpty()); ++ } ++ ++ @Test ++ void factoryPhaseDistinguishesInitialTouchReleaseFromActivation() { ++ final var input = state("pressed", false); ++ assertFalse(input.dispatch(SOURCE, 0, context(2, 0, 0, 0, true)).events().getFirst().transition()); ++ assertFalse(input.dispatch(SOURCE, 1, context(2, 0, 0, 1, true)).events().getFirst().transition()); ++ assertTrue(input.dispatch(SOURCE, 0, context(2, 0, 0, 2, true)).events().getFirst().transition()); ++ assertFalse(input.dispatch(SOURCE, 0, context(2, 0, 0, 3, true)).events().getFirst().transition()); ++ } ++ ++ @Test ++ void capturedReleaseSurvivesPointerExitOnceAndRequestsCaptureOnRepeatedPresses() { ++ final var input = state("pressed", true); ++ assertEquals(List.of(SOURCE), input.dispatch(SOURCE, 1, context(1, 20, 30, 0, true)).captureRequests()); ++ assertEquals(List.of(SOURCE), input.dispatch(SOURCE, 1, context(1, 20, 30, 1, true)).captureRequests()); ++ final var release = input.dispatch(SOURCE, 0, context(1, 100, 30, 2, false)); ++ assertEquals(1, release.result()); ++ assertEquals(1, release.events().getFirst().previous()); ++ assertEquals(0, input.phase(0)); ++ assertTrue(input.dispatch(SOURCE, 0, context(1, 100, 30, 3, false)).events().isEmpty()); ++ assertEquals(2, input.phase(0)); ++ } ++ ++ @Test ++ void doublePressUsesPerSourceTimeAndFloatDistanceBoundaries() { ++ for (final float distance : new float[]{Math.nextDown(10F), 10F}) { ++ for (final long elapsed : new long[]{499_999_999L, 500_000_000L}) { ++ final var input = state("double_pressed", false); ++ assertTrue(input.dispatch(SOURCE, 1, context(1, 0, 0, 1_000_000_000L, true)).events().isEmpty()); ++ input.dispatch(SOURCE, 0, context(1, 0, 0, 1_000_000_001L, true)); ++ final boolean expected = distance < 10F && elapsed < 500_000_000L; ++ assertEquals(expected, !input.dispatch(SOURCE, 1, ++ context(1, distance, 0, 1_000_000_000L + elapsed, true)).events().isEmpty()); ++ assertEquals(expected, !input.dispatch(SOURCE, 0, ++ context(1, distance, 0, 1_000_000_001L + elapsed, true)).events().isEmpty()); ++ } ++ } ++ } ++ ++ @Test ++ void wildcardEventSurvivesARejectedMappingWithoutConsumingTheScreenEvent() { ++ final var properties = properties("focused", false); ++ final var wildcard = new JsonObject(); ++ wildcard.addProperty("from_button_id", "any"); ++ properties.getAsJsonArray("button_mappings").add(wildcard); ++ final var input = new NativeUiInput.State(NativeUiInput.parse(properties)); ++ final var result = input.dispatch(SOURCE, 1, context(1, 100, 30, 0, false)); ++ assertEquals(0, result.result()); ++ assertEquals(1, result.events().size()); ++ assertEquals(SOURCE, result.events().getFirst().target()); ++ assertEquals(2, result.events().getFirst().previous()); ++ assertTrue(result.events().getFirst().transition()); ++ assertEquals(2, input.phase(0)); ++ } ++ ++ @Test ++ void nearestClipAndAlternateScopeKeepNativeInclusiveGeometry() { ++ final var bounds = new NativeUiInput.Bounds(10, 20, 30, 40); ++ final var inner = new NativeUiInput.Ancestor(new NativeUiInput.Bounds(0, 0, 100, 100), true, false, null); ++ final var outer = new NativeUiInput.Ancestor(new NativeUiInput.Bounds(0, 0, 15, 25), true, false, null); ++ final var geometry = new NativeUiInput.Geometry(bounds, List.of(inner, outer)); ++ assertTrue(geometry.insideClip(40, 60)); ++ assertTrue(bounds.contains(40, 60)); ++ assertFalse(geometry.insideAlternate(0x7c, 40, 60)); ++ assertTrue(geometry.insideAlternate(0x7c, Math.nextUp(40F), 60)); ++ final var scroll = new NativeUiInput.Geometry(bounds, ++ List.of(new NativeUiInput.Ancestor(inner.bounds(), true, true, outer.bounds()))); ++ assertFalse(scroll.insideAlternate(0x7c, 50, 60)); ++ } ++ ++ @Test ++ void hoverNotificationsCarryNoButtonActivationAndPointerEntryHonorsItsOwnGate() { ++ final var properties = properties("focused", false); ++ final var hover = new JsonObject(); ++ hover.addProperty("to_button_id", "button.filter_toggle_hovered"); ++ hover.addProperty("mapping_type", "pressed"); ++ properties.getAsJsonArray("button_mappings").add(hover); ++ properties.addProperty("hover_enabled", false); ++ final var input = new NativeUiInput.State(NativeUiInput.parse(properties)); ++ final var direct = input.hover(false, true, false, 1); ++ assertTrue(direct.changed()); ++ assertFalse(direct.events().getFirst().transition()); ++ assertEquals(0, direct.events().getFirst().source()); ++ assertEquals(0, direct.result()); ++ assertFalse(input.pointer(false, true, false, 0).pointerEvent()); ++ assertTrue(input.pointer(false, true, false, 0).hover().events().isEmpty()); ++ } ++ ++ private static NativeUiInput.State state(final String kind, final boolean capture) { ++ return new NativeUiInput.State(NativeUiInput.parse(properties(kind, capture))); ++ } ++ ++ private static JsonObject properties(final String kind, final boolean capture) { ++ final var properties = new JsonObject(); ++ final var mapping = new JsonObject(); ++ mapping.addProperty("from_button_id", "button.menu_ok"); ++ mapping.addProperty("to_button_id", "button.menu_select"); ++ mapping.addProperty("mapping_type", kind); ++ mapping.addProperty("button_up_right_of_first_refusal", capture); ++ final var mappings = new JsonArray(); ++ mappings.add(mapping); ++ properties.add("button_mappings", mappings); ++ return properties; ++ } ++ ++ private static NativeUiInput.Context context(final int mode, final float x, final float y, final long nanos, ++ final boolean hovered) { ++ return new NativeUiInput.Context(0x7c | (hovered ? 2 : 0), mode, x, y, hovered, true, false, false, nanos, 0); ++ } ++} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiLayoutTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiLayoutTest.java new file mode 100644 index 0000000..340d203 @@ -9097,12 +11136,150 @@ index 0000000..88b55e3 + void run(Minecraft game, Font font) throws Exception; + } +} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiPlayerCameraTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiPlayerCameraTest.java +new file mode 100644 +index 00000000..8b5fd516 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiPlayerCameraTest.java +@@ -0,0 +1,132 @@ ++package com.viaversion.viafabricplus.bedrock.screen.ui; ++ ++import com.google.gson.JsonObject; ++import com.google.gson.JsonParser; ++import com.mojang.blaze3d.vertex.PoseStack; ++import com.viaversion.viafabricplus.bedrock.render.BedrockGuiActorContext; ++import java.nio.file.Files; ++import java.nio.file.Path; ++import java.util.List; ++import java.util.UUID; ++import net.minecraft.client.renderer.entity.state.AvatarRenderState; ++import org.joml.Matrix4f; ++import org.joml.Vector3f; ++import org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.*; ++import static org.junit.jupiter.api.Assumptions.assumeTrue; ++ ++final class NativeUiPlayerCameraTest { ++ private static final NativeUiPlayerCamera.Rotation ROTATION = new NativeUiPlayerCamera.Rotation(37, 41, 7, 11, 23, 29); ++ ++ @Test ++ void projectsTheOriginalStandingAndGlidingMatricesAcrossClipsAndGuiScales() { ++ final var control = new NativeUiLayout.Rect(10, 20, 30, 30); ++ final float[][] matrices = { ++ {30, -2.6226833e-6F, 0, 0, -2.447944e-6F, -28.001217F, .35890564F, 0, ++ 9.412958e-7F, 10.767169F, .93337387F, 0, 25, 35, -990, 1}, ++ {18, -1.5497033e-6F, -2.732545e-7F, 0, -1.57361e-6F, -17.72654F, -3.125667F, 0, ++ 0, -.17364818F, .98480773F, 0, 25, 50, -990, 1} ++ }; ++ for (int index = 0; index < matrices.length; index++) { ++ for (final var viewport : List.of(new NativeUiLayout.Rect(0, 0, 400, 300), ++ new NativeUiLayout.Rect(8, 12, 80, 100), new NativeUiLayout.Rect(20, 30, 10, 10))) { ++ final var camera = NativeUiPlayerCamera.resolve(control, viewport, 0, properties(true), ++ new NativeUiPlayerCamera.Cursor(80, 105), ROTATION, index == 1, false, false); ++ project(camera, viewport, new Matrix4f().set(matrices[index])); ++ } ++ } ++ } ++ ++ @Test ++ void retainsIndependentGuiRotationsAndClearsThePlayerTransformForHorseFrames() { ++ final var context = new BedrockGuiActorContext(UUID.randomUUID(), UUID.randomUUID()); ++ final var camera = NativeUiPlayerCamera.resolve(new NativeUiLayout.Rect(10, 20, 30, 30), ++ new NativeUiLayout.Rect(0, 0, 400, 300), 0, properties(true), ++ new NativeUiPlayerCamera.Cursor(80, 105), ROTATION, false, false, false); ++ context.pose(camera.pitch(), camera.yaw(), camera.bodyYaw(), camera.headYaw(), camera.modelTransform()); ++ final var state = new AvatarRenderState(); ++ context.scoped(() -> { BedrockGuiActorContext.extracted(state); return null; }); ++ assertEquals(21.033003F, state.xRot, .00001F); ++ assertEquals(-18.84F, state.yRot, .00001F); ++ assertEquals(-18.84F, state.bodyRot, .00001F); ++ assertEquals(-37.68F, context.yaw(), .00001F); ++ context.camera(5, 9); ++ context.scoped(() -> { BedrockGuiActorContext.extracted(state); return null; }); ++ assertEquals(5, state.xRot); ++ assertEquals(0, state.yRot); ++ assertEquals(9, state.bodyRot); ++ final var pose = new PoseStack(); ++ context.transform(pose); ++ assertEquals(new Matrix4f(), pose.last().pose()); ++ context.close(); ++ assertNull(BedrockGuiActorContext.frame(state)); ++ } ++ ++ @Test ++ void matchesTheOriginalPlayerRendererReference() throws Exception { ++ final String fixture = System.getenv("STACKANVIL_NATIVE_PLAYER_RENDERER_FIXTURE"); ++ assumeTrue(fixture != null); ++ final var rows = JsonParser.parseString(Files.readString(Path.of(fixture))).getAsJsonObject().getAsJsonArray("rows"); ++ assertEquals(672, rows.size()); ++ for (final var element : rows) { ++ final var row = element.getAsJsonObject(); ++ final var rect = row.getAsJsonArray("rect"); ++ final var control = new NativeUiLayout.Rect(rect.get(0).getAsFloat(), rect.get(1).getAsFloat(), ++ rect.get(2).getAsFloat(), rect.get(3).getAsFloat()); ++ final var viewport = new NativeUiLayout.Rect(0, 0, 400, 300); ++ final var cursorData = row.getAsJsonObject("cursor"); ++ NativeUiPlayerCamera.Cursor cursor = null; ++ if (row.get("mode").getAsInt() == 1) { ++ final var mouse = row.getAsJsonArray("mouse"); ++ final float scale = row.get("guiScale").getAsFloat(); ++ cursor = new NativeUiPlayerCamera.Cursor(mouse.get(0).getAsFloat() * scale, mouse.get(1).getAsFloat() * scale); ++ } else if (cursorData.get("present").getAsBoolean() ++ && (cursorData.get("firstAdmission").getAsBoolean() || cursorData.get("secondAdmission").getAsBoolean())) { ++ final var point = cursorData.getAsJsonArray("position"); ++ cursor = new NativeUiPlayerCamera.Cursor(point.get(0).getAsFloat(), point.get(1).getAsFloat()); ++ } ++ final String pose = row.get("pose").getAsString(); ++ final var camera = NativeUiPlayerCamera.resolve(control, viewport, 0, ++ properties(!row.get("lookAtCursor").isJsonNull() && row.get("lookAtCursor").getAsBoolean()), cursor, ++ ROTATION, pose.contains("glid"), pose.contains("swim"), pose.contains("crawl")); ++ final var result = row.getAsJsonObject("result"); ++ final var actor = result.getAsJsonArray("actorRotation"); ++ assertEquals(actor.get(0).getAsFloat(), camera.pitch(), .00001F); ++ assertEquals(actor.get(1).getAsFloat(), camera.yaw(), .00001F); ++ assertEquals(result.getAsJsonArray("bodyYaw").get(0).getAsFloat(), camera.bodyYaw(), .00001F); ++ if (row.get("headYawComponentPresent").getAsBoolean()) ++ assertEquals(result.getAsJsonArray("headYaw").get(0).getAsFloat(), camera.headYaw(), .00001F); ++ final float[] matrix = new float[16]; ++ for (int index = 0; index < matrix.length; index++) matrix[index] = result.getAsJsonArray("model").get(index).getAsFloat(); ++ project(camera, viewport, new Matrix4f().set(matrix)); ++ } ++ } ++ ++ private static void project(final NativeUiPlayerCamera camera, final NativeUiLayout.Rect viewport, final Matrix4f nativeMatrix) { ++ for (final int scale : new int[] {1, 2, 4}) { ++ final var pose = new PoseStack(); ++ pose.translate((viewport.x() + viewport.width() * .5F) * scale, (viewport.y() + viewport.height() * .5F) * scale, 0); ++ pose.scale(camera.scale() * scale, camera.scale() * scale, -camera.scale() * scale); ++ BedrockGuiActorContext.correctPose(pose, camera.scale(), scale); ++ final var translation = camera.translation(1); ++ pose.translate(translation.x, translation.y, translation.z); ++ pose.mulPose(camera.modelTransform()); ++ for (final var point : List.of(new Vector3f(), new Vector3f(.3F, .9F, -.4F), new Vector3f(-.58F, 2.21F, .51F))) { ++ final var expected = nativeMatrix.transformPosition(new Vector3f(point).add(0, -1.501F * .9375F, 0)); ++ final var actual = pose.last().pose().transformPosition(new Vector3f(point)); ++ assertEquals(expected.x * scale, actual.x, .002F); ++ assertEquals(expected.y * scale, actual.y, .002F); ++ assertEquals(expected.z, actual.z, .001F); ++ } ++ } ++ } ++ ++ private static JsonObject properties(final boolean look) { ++ final var properties = new JsonObject(); ++ final var bag = new JsonObject(); ++ bag.addProperty("#look_at_cursor", look); ++ properties.add("property_bag", bag); ++ return properties; ++ } ++} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiPresentationTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiPresentationTest.java new file mode 100644 -index 0000000..0c4d0d6 +index 0000000..d8bc9ec --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiPresentationTest.java -@@ -0,0 +1,190 @@ +@@ -0,0 +1,258 @@ +package com.viaversion.viafabricplus.bedrock.screen.ui; + +import com.google.gson.JsonArray; @@ -9115,6 +11292,74 @@ index 0000000..0c4d0d6 + +final class NativeUiPresentationTest { + @Test ++ void preservesAuthoredOrderForEqualCumulativeLayersAcrossNestedSubtrees() { ++ final var background = node("background", "image", true); ++ background.properties().addProperty("layer", 2); ++ final var icon = node("icon", "image", true); ++ icon.properties().addProperty("layer", -1); ++ final var content = node("content", "panel", true, icon); ++ content.properties().addProperty("layer", 3); ++ final var sizer = node("sizer", "panel", true, content); ++ for (final boolean reversed : new boolean[]{false, true}) { ++ final var root = node("root", "panel", true, reversed ? sizer : background, reversed ? background : sizer); ++ root.properties().addProperty("layer", 2); ++ final var box = layout(root); ++ assertSame(root.children().getFirst(), box.children().getFirst().node()); ++ final var images = NativeUiPresentation.draws(box, _ -> false).stream() ++ .filter(draw -> draw.box().node().type().equals("image")).toList(); ++ assertEquals(4, images.getFirst().box().layer()); ++ assertEquals(4, images.getLast().box().layer()); ++ assertEquals(reversed ? List.of(icon, background) : List.of(background, icon), ++ images.stream().map(draw -> draw.box().node()).toList()); ++ } ++ icon.properties().addProperty("layer", -2); ++ final var root = node("root", "panel", true, background, sizer); ++ assertEquals(List.of(icon, background), NativeUiPresentation.draws(layout(root), _ -> false).stream() ++ .map(draw -> draw.box().node()).filter(child -> child.type().equals("image")).toList()); ++ } ++ ++ @Test ++ void nativeToggleGroupsSelectCheckedHoverAndLockedStatesWithoutFormAnnotations() { ++ final var states = List.of("checked", "unchecked", "checked_hover", "unchecked_hover", ++ "checked_locked", "unchecked_locked", "checked_locked_hover", "unchecked_locked_hover"); ++ final var children = states.stream().map(name -> node(name, "panel", true)).toList(); ++ final var toggle = node("group", "toggle", true, children.toArray(NativeUiScene.Node[]::new)); ++ children.forEach(child -> toggle.properties().addProperty(child.name() + "_control", child.name())); ++ for (final boolean checked : new boolean[]{false, true}) { ++ toggle.properties().addProperty("toggle_state", checked); ++ for (final boolean enabled : new boolean[]{false, true}) { ++ toggle.properties().addProperty("enabled", enabled); ++ for (final boolean pointed : new boolean[]{false, true}) { ++ final String name = (checked ? "checked" : "unchecked") + (enabled ? "" : "_locked") + (pointed ? "_hover" : ""); ++ final var selected = children.stream().filter(child -> child.name().equals(name)).findFirst().orElseThrow(); ++ assertEquals(List.of(toggle, selected), NativeUiPresentation.draws(layout(toggle), _ -> pointed) ++ .stream().map(draw -> draw.box().node()).toList()); ++ } ++ } ++ } ++ } ++ ++ @Test ++ void recipeCommandsSelectHoverAndLockedBranchesWithoutAnUnrelatedCloseAnnotation() { ++ final var normal = node("normal", "panel", true); ++ final var hover = node("hover", "panel", true); ++ final var locked = node("locked", "panel", true); ++ final var recipe = node("recipe", "button", true, normal, hover, locked); ++ recipe.properties().add("recipe_actions", new JsonArray()); ++ recipe.properties().addProperty("default_control", normal.name()); ++ recipe.properties().addProperty("hover_control", hover.name()); ++ recipe.properties().addProperty("locked_control", locked.name()); ++ for (final boolean enabled : new boolean[]{true, false}) { ++ recipe.properties().addProperty("enabled", enabled); ++ for (final boolean pointed : new boolean[]{false, true}) { ++ final var draws = NativeUiPresentation.draws(layout(recipe), _ -> pointed); ++ final var selected = !enabled ? locked : pointed ? hover : normal; ++ assertEquals(List.of(recipe, selected), draws.stream().map(draw -> draw.box().node()).toList()); ++ } ++ } ++ } ++ ++ @Test + void showsScrollControlsOnlyForOverflowOrAnExplicitAlwaysVisibleOwner() { + for (final boolean grouped : new boolean[] {false, true}) { + final var content = node("content", "panel", true); @@ -9372,10 +11617,10 @@ index 0000000..8ceca53 +} 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..129bb31 +index 00000000..4a0423c9 --- /dev/null +++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiSceneScreenInteractionTest.java -@@ -0,0 +1,153 @@ +@@ -0,0 +1,359 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -9384,23 +11629,30 @@ index 0000000..129bb31 +package com.viaversion.viafabricplus.bedrock.screen.ui; + +import com.google.gson.JsonArray; ++import com.google.gson.JsonNull; +import com.google.gson.JsonObject; ++import com.google.gson.JsonPrimitive; +import com.mojang.blaze3d.platform.InputConstants; +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.StringSplitter; +import net.minecraft.client.gui.Font; ++import net.minecraft.client.gui.navigation.FocusNavigationEvent; ++import net.minecraft.client.input.CharacterEvent; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.client.input.MouseButtonInfo; ++import net.minecraft.client.input.KeyEvent; +import net.minecraft.client.sounds.SoundManager; +import net.minecraft.server.Bootstrap; +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 static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; @@ -9432,6 +11684,36 @@ index 0000000..129bb31 + } + + @Test ++ void overlappingActionsFollowTheSameNestedLayerOrderAsTheirDraws() throws Exception { ++ for (final boolean reversed : new boolean[]{false, true}) { ++ final var background = control("button", 100, 20, 0); ++ background.addProperty("layer", 2); ++ background.addProperty("action", 10); ++ final var foreground = control("button", 100, 20, 0); ++ foreground.addProperty("layer", 2); ++ foreground.addProperty("action", 20); ++ final var wrapper = control("panel", 100, 20, 0); ++ final var nested = new JsonArray(); ++ nested.add(foreground); ++ wrapper.add("children", nested); ++ final var root = control("panel", 200, 100, 0); ++ final var children = new JsonArray(); ++ children.add(reversed ? wrapper : background); ++ children.add(reversed ? background : wrapper); ++ root.add("children", children); ++ final var scene = new JsonObject(); ++ scene.addProperty("format", 1); ++ scene.add("root", root); ++ final var controller = new Controller(NativeUiController.Activation.STAY, ++ NativeUiScene.parse(scene.toString(), Set.of()).root()); ++ withScreen(controller, (game, screen) -> { ++ assertTrue(screen.mouseClicked(event(10, 10), false)); ++ assertEquals(List.of(reversed ? 10 : 20), controller.responses); ++ }); ++ } ++ } ++ ++ @Test + void abandonsAStaleConnectionWithoutSendingCancellation() throws Exception { + final var controller = new Controller(NativeUiController.Activation.STAY); + withScreen(controller, (game, screen) -> { @@ -9443,9 +11725,164 @@ index 0000000..129bb31 + }); + } + ++ @Test ++ void requiresAuthoredFocusWithoutDisablingPointerActions() throws Exception { ++ for (final var entry : List.of(Map.entry(JsonNull.INSTANCE, false), Map.entry(new JsonPrimitive(false), false), ++ Map.entry(new JsonPrimitive(true), true), Map.entry(new JsonPrimitive(0), false), ++ Map.entry(new JsonPrimitive(-1), true), Map.entry(new JsonPrimitive(1), true), ++ Map.entry(new JsonPrimitive(1.0), false), Map.entry(new JsonPrimitive("true"), false))) { ++ final var value = entry.getKey(); ++ final var root = root(); ++ final var target = root.children().get(1); ++ if (!value.isJsonNull()) target.properties().add("focus_enabled", value); ++ final var controller = new Controller(NativeUiController.Activation.STAY, root); ++ withScreen(controller, (game, screen) -> { ++ final var action = screen.getChildAt(10, 40).orElseThrow(); ++ assertEquals(entry.getValue(), action.nextFocusPath(new FocusNavigationEvent.TabNavigation(true)) != null); ++ assertTrue(screen.mouseClicked(event(10, 40), false)); ++ assertEquals(List.of(177), controller.responses); ++ }); ++ } ++ } ++ ++ @Test ++ void arrowInputFollowsAuthoredOverridesStopsAndWrapsWithoutSubmittingResponses() throws Exception { ++ final var root = root(); ++ for (int index = 0; index < root.children().size(); index++) { ++ root.children().get(index).properties().addProperty("focus_enabled", true); ++ root.children().get(index).properties().addProperty("focus_identifier", "control_" + index); ++ } ++ root.children().getFirst().properties().addProperty("focus_change_down", "control_2"); ++ root.children().getLast().properties().addProperty("focus_change_up", "FOCUS_OVERRIDE_STOP"); ++ final var controller = new Controller(NativeUiController.Activation.STAY, root); ++ withScreen(controller, (game, screen) -> { ++ final var first = screen.getChildAt(10, 10).orElseThrow(); ++ final var last = screen.getChildAt(10, 70).orElseThrow(); ++ assertTrue(screen.keyPressed(new KeyEvent(InputConstants.KEY_DOWN, 0, 0))); ++ assertSame(first, screen.getFocused()); ++ assertTrue(screen.keyPressed(new KeyEvent(InputConstants.KEY_DOWN, 0, 0))); ++ assertSame(last, screen.getFocused()); ++ assertTrue(screen.keyPressed(new KeyEvent(InputConstants.KEY_UP, 0, 0))); ++ assertSame(last, screen.getFocused()); ++ assertTrue(screen.keyPressed(new KeyEvent(InputConstants.KEY_DOWN, 0, 0))); ++ assertSame(first, screen.getFocused()); ++ assertTrue(controller.responses.isEmpty()); ++ assertEquals(0, controller.refreshes); ++ }); ++ } ++ ++ @Test ++ void remembersTheLastControlInAGroupAcrossRetainedSceneRefreshes() throws Exception { ++ final var root = control("panel", 200, 100, 0); ++ final var children = new JsonArray(); ++ for (final String name : List.of("left", "right")) { ++ final var group = control("panel", 80, 100, 0); ++ group.addProperty("name", name); ++ group.getAsJsonArray("offset").set(0, new JsonPrimitive(name.equals("right") ? 100 : 0)); ++ group.addProperty("focus_container", true); ++ group.addProperty("use_last_focus", true); ++ final var buttons = new JsonArray(); ++ for (int index = 0; index < 2; index++) { ++ final var button = control("button", 60, 20, index * 30); ++ button.addProperty("focus_enabled", true); ++ button.addProperty("action", index); ++ buttons.add(button); ++ } ++ group.add("children", buttons); ++ children.add(group); ++ } ++ 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()); ++ controller.replaceRootOnRefresh = true; ++ withScreen(controller, (game, screen) -> { ++ final var left = screen.getChildAt(10, 10).orElseThrow(); ++ final var rightBottom = screen.getChildAt(110, 40).orElseThrow(); ++ screen.setFocused(rightBottom); ++ screen.setFocused(left); ++ assertTrue(screen.mouseClicked(event(10, 10), false)); ++ assertEquals(1, controller.refreshes); ++ assertSame(left, screen.getFocused()); ++ assertTrue(screen.keyPressed(new KeyEvent(InputConstants.KEY_RIGHT, 0, 0))); ++ assertSame(rightBottom, screen.getFocused()); ++ assertEquals(1, controller.responses.size()); ++ }); ++ } ++ ++ @ParameterizedTest ++ @ValueSource(booleans = {false, true}) ++ void arrowNavigationRevealsAnOffscreenControlWithoutWrappingWithinTheScrollView(final boolean editable) throws Exception { ++ final var root = control("scroll_view", 100, 50, 0); ++ root.addProperty("scroll_view_port", "viewport"); ++ root.addProperty("scroll_content", "items"); ++ final var viewport = control("panel", 100, 50, 0); ++ viewport.addProperty("name", "viewport"); ++ final var items = control("stack_panel", 100, 120, 0); ++ items.addProperty("name", "items"); ++ final var buttons = new JsonArray(); ++ for (int index = 0; index < 3; index++) { ++ final var button = control(editable && index > 0 ? "edit_box" : "button", 100, 40, 0); ++ if (editable && index > 0) { ++ final var input = new JsonObject(); ++ input.addProperty("index", index); ++ button.add("input", input); ++ } ++ button.addProperty("focus_enabled", true); ++ button.addProperty("action", index); ++ buttons.add(button); ++ } ++ items.add("children", buttons); ++ final var content = new JsonArray(); ++ content.add(items); ++ viewport.add("children", content); ++ final var children = new JsonArray(); ++ children.add(viewport); ++ 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()); ++ if (editable) { ++ controller.values = mock(NativeUiController.Inputs.class); ++ when(controller.values.maxLength(anyInt())).thenReturn(32); ++ when(controller.values.text(anyInt())).thenReturn(""); ++ when(controller.values.placeholder(anyInt())).thenReturn(""); ++ } ++ withScreen(controller, (game, screen) -> { ++ final var first = screen.getChildAt(10, 10).orElseThrow(); ++ screen.setFocused(first); ++ assertTrue(screen.keyPressed(new KeyEvent(InputConstants.KEY_DOWN, 0, 0))); ++ assertNotSame(first, screen.getFocused()); ++ assertTrue(screen.keyPressed(new KeyEvent(InputConstants.KEY_DOWN, 0, 0))); ++ final var last = screen.getFocused(); ++ assertNotSame(first, last); ++ assertSame(last, screen.getChildAt(10, 30).orElseThrow()); ++ assertTrue(last.isFocused()); ++ if (editable) { ++ assertTrue(screen.charTyped(new CharacterEvent('x'))); ++ verify(controller.values).setText(2, "x"); ++ } ++ assertTrue(screen.keyPressed(new KeyEvent(InputConstants.KEY_DOWN, 0, 0))); ++ assertSame(last, screen.getFocused()); ++ assertTrue(controller.responses.isEmpty()); ++ }); ++ } ++ + private static void withScreen(final Controller controller, final Interaction interaction) throws Exception { + final Font font = mock(Font.class); + when(font.getSplitter()).thenReturn(new StringSplitter((c, style) -> 6)); ++ when(font.width(anyString())).thenAnswer(call -> ((String) call.getArgument(0)).length() * 6); ++ when(font.plainSubstrByWidth(anyString(), anyInt())).thenAnswer(call -> { ++ final String value = call.getArgument(0); ++ return value.substring(0, Math.min(value.length(), Math.max(0, ((int) call.getArgument(1)) / 6))); ++ }); ++ when(font.plainSubstrByWidth(anyString(), anyInt(), anyBoolean())).thenAnswer(call -> { ++ final String value = call.getArgument(0); ++ final int length = Math.min(value.length(), Math.max(0, ((int) call.getArgument(1)) / 6)); ++ return (boolean) call.getArgument(2) ? value.substring(value.length() - length) : value.substring(0, length); ++ }); + final Minecraft game = mock(Minecraft.class); + when(game.getSoundManager()).thenReturn(mock(SoundManager.class)); + final var field = Minecraft.class.getField("font"); @@ -9504,13 +11941,20 @@ index 0000000..129bb31 + + private static final class Controller implements NativeUiController { + private final Activation activation; -+ private final NativeUiScene.Node root = NativeUiSceneScreenInteractionTest.root(); ++ private NativeUiScene.Node root; ++ private boolean replaceRootOnRefresh; ++ private Inputs values; + private final List responses = new ArrayList<>(); + private List validated = List.of(); + private int refreshes, cancels; + private boolean current = true; + -+ private Controller(final Activation activation) { this.activation = activation; } ++ private Controller(final Activation activation) { this(activation, NativeUiSceneScreenInteractionTest.root()); } ++ private Controller(final Activation activation, final NativeUiScene.Node root) { ++ this.activation = activation; ++ this.root = root; ++ } ++ @Override public Inputs inputs() { return this.values; } + @Override public String title() { return ""; } + @Override public NativeUiScene.Node root() { return this.root; } + @Override public void validateVisibleControls() { NativeUiScene.validateVisibleControls(this.root, Set.of()); } @@ -9522,13 +11966,66 @@ index 0000000..129bb31 + } + @Override public void cancel() { this.cancels++; } + @Override public void failed(final RuntimeException error) { throw error; } -+ @Override public void refresh() { this.refreshes++; } ++ @Override public void refresh() { ++ this.refreshes++; ++ if (this.replaceRootOnRefresh) this.root = copy(this.root); ++ } ++ private static NativeUiScene.Node copy(final NativeUiScene.Node node) { ++ return new NativeUiScene.Node(node.name(), node.type(), node.properties().deepCopy(), ++ node.children().stream().map(Controller::copy).toList(), node.action()); ++ } + } + + private interface Interaction { + void run(Minecraft game, NativeUiSceneScreen screen) throws Exception; + } +} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiSceneTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiSceneTest.java +new file mode 100644 +index 0000000..c7b4bc4 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiSceneTest.java +@@ -0,0 +1,40 @@ ++package com.viaversion.viafabricplus.bedrock.screen.ui; ++ ++import com.google.gson.JsonObject; ++import com.google.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 NativeUiSceneTest { ++ @Test ++ void preservesNativeIntegerTypesAndPropertyDefaults() { ++ for (final String encoded : List.of("true", "-1", "1", "9223372036854775807", "9223372036854775808", "18446744073709551615")) { ++ assertTrue(node(encoded).flag("enabled", false)); ++ } ++ for (final String encoded : List.of("false", "0", "-0")) { ++ assertFalse(node(encoded).flag("enabled", true)); ++ } ++ for (final String encoded : List.of("null", "\"true\"", "[]", "{}", "1.0", "0.0", "1e0", "-9223372036854775809", "18446744073709551616")) { ++ for (final boolean fallback : new boolean[]{false, true}) assertEquals(fallback, node(encoded).flag("enabled", fallback)); ++ } ++ } ++ ++ @Test ++ void usesTheSameFlagsForVisibilityAdmissionAndResolvedProperties() { ++ for (final String property : List.of("visible", "ignored")) { ++ final JsonObject scene = JsonParser.parseString("{\"format\":1,\"root\":{\"type\":\"unavailable\"}}").getAsJsonObject(); ++ scene.getAsJsonObject("root").addProperty(property, property.equals("visible") ? 0 : 1); ++ final var node = NativeUiScene.parse(scene.toString(), Set.of()).root(); ++ assertEquals(property.equals("ignored"), node.flag(property, property.equals("visible"))); ++ scene.getAsJsonObject("root").addProperty(property, property.equals("visible") ? 1 : 0); ++ assertThrows(IllegalArgumentException.class, () -> NativeUiScene.parse(scene.toString(), Set.of())); ++ } ++ } ++ ++ private static NativeUiScene.Node node(final String encoded) { ++ return new NativeUiScene.Node("", "button", JsonParser.parseString("{\"enabled\":" + encoded + "}").getAsJsonObject(), List.of(), Integer.MIN_VALUE); ++ } ++} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiSliderTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiSliderTest.java new file mode 100644 index 0000000..4e7a747 @@ -9831,6 +12328,98 @@ index 0000000..b8567f0 + return new NativeUiScene.Node("", "image", properties, List.of(), Integer.MIN_VALUE); + } +} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiToggleTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiToggleTest.java +new file mode 100644 +index 00000000..de63cd36 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/screen/ui/NativeUiToggleTest.java +@@ -0,0 +1,86 @@ ++/* ++ * 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 org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++final class NativeUiToggleTest { ++ @Test ++ void radioInitializationUsesItsIndexInsteadOfTheOrdinaryDefaultState() { ++ final var properties = properties(true, 4); ++ properties.addProperty("toggle_default_state", true); ++ properties.addProperty("toggle_group_default_selected", 2); ++ assertFalse(NativeUiToggle.parse(properties).initialChecked(2)); ++ properties.addProperty("toggle_group_forced_index", -1); ++ assertTrue(NativeUiToggle.parse(properties).initialChecked(2)); ++ properties.addProperty("radio_toggle_group", false); ++ assertTrue(NativeUiToggle.parse(properties).initialChecked(0)); ++ } ++ ++ @Test ++ void radioSelectionClearsOtherIndicesIncludingOrdinaryTogglesButPreservesDuplicates() { ++ final var selected = new NativeUiToggle.State(NativeUiToggle.parse(properties(true, 1)), 0); ++ final var duplicate = new NativeUiToggle.State(NativeUiToggle.parse(properties(false, 1)), 0); ++ final var sibling = new NativeUiToggle.State(NativeUiToggle.parse(properties(false, 2)), 0); ++ duplicate.bindChecked(true); ++ sibling.bindChecked(true); ++ final var change = selected.button(button("button.menu_select"), true); ++ assertNotNull(change); ++ duplicate.sibling(change); ++ sibling.sibling(change); ++ assertTrue(selected.checked()); ++ assertTrue(duplicate.checked()); ++ assertFalse(sibling.checked()); ++ assertNotNull(selected.button(button("button.menu_select"), true)); ++ } ++ ++ @Test ++ void directionalButtonsRespectTheComponentGateAndCheckedState() { ++ final var properties = properties(false, -1); ++ final var disabled = new NativeUiToggle.State(NativeUiToggle.parse(properties), 0); ++ assertNull(disabled.button(button("toggle.on"), true)); ++ properties.addProperty("enable_directional_toggling", true); ++ final var input = new NativeUiToggle.State(NativeUiToggle.parse(properties), 0); ++ assertNotNull(input.button(button("toggle.on"), true)); ++ assertNull(input.button(button("toggle.on"), true)); ++ assertTrue(input.checked()); ++ assertNull(input.button(button("toggle.off"), false)); ++ assertNotNull(input.button(button("toggle.off"), true)); ++ assertFalse(input.checked()); ++ } ++ ++ @Test ++ void hoverTogglingPublishesChangesWithoutButtonNotificationAndIgnoresRepeatedEntry() { ++ final var properties = properties(false, -1); ++ properties.addProperty("toggle_on_hover", true); ++ final var input = new NativeUiToggle.State(NativeUiToggle.parse(properties), 0); ++ final var entered = input.hover(true); ++ assertNotNull(entered); ++ assertTrue(entered.checked()); ++ assertFalse(entered.notifyController()); ++ assertNull(input.hover(true)); ++ assertNull(input.hover(false)); ++ assertFalse(input.hover(true).checked()); ++ } ++ ++ private static JsonObject properties(final boolean radio, final int index) { ++ final var properties = new JsonObject(); ++ properties.addProperty("toggle_name", "navigation_tab"); ++ properties.addProperty("radio_toggle_group", radio); ++ properties.addProperty("toggle_group_forced_index", index); ++ properties.addProperty("toggle_on_button", "toggle.on"); ++ properties.addProperty("toggle_off_button", "toggle.off"); ++ return properties; ++ } ++ ++ private static NativeUiInput.ButtonEvent button(final String target) { ++ return new NativeUiInput.ButtonEvent(0, NativeUiInput.symbol(target), 0, 1, true, 1, ++ NativeUiInput.Scope.DEFAULT, true, true); ++ } ++} 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..5ea86d8 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 51356faa..9643b548 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 @@ -1,5 +1,34 @@ Shared native scene, layout, image, text, HUD and GUI actor rendering precedes the form and inventory adapters. +## Player inventory renderer + +The target selects a separate `live_player_renderer` for player inventory. +It reads the local actor directly and retains independent actor rotation, body-yaw history and head-yaw history. +The shared renderer now supports this separate local-player path. +Java PIP applies its native model correction after portrait translation and before entity submission. +Scoped actor, body and head rotations preserve world player state. + +A private reference executes 672 original player draw calls through normal return with an absent renderer result. +The original component lookups, body-yaw getter/setter, matrix arithmetic and player/mount restoration execute. +The cases cover standing, swimming, crawling, gliding, overlapping flags, control shapes and cursor admission paths. +Renderer-context boundaries, actor delivery, flags, cursor admission getters and host float math remain supplied. +GPU output, successful renderer variables, effects and physical input producers remain unverified. + +Three packaged camera regressions cover the native framing and pose fields and reject four deliberately incorrect implementations. +The full stack passes 2,801 tests, with 163 optional skips. +All 1,450 core classes match both packaged copies, and all 1,188 client classes match the add-on. +The prior 35 screen regressions and 1,940 UI input reference cases still pass. + +Two private Java 26.3 clients retain 36 actual PIP submission samples at 854 by 480 and 1920 by 1013 pixels. +Their Java/native GUI scale pairs are 2/1 and 3/4. +The checks cover mouse coordinates, cursor tracking, supplied native pose flags, local actor selection and unchanged world rotation fields. +Private screenshots show the local player in both viewports. +The scene and native flags are supplied. These checks do not reproduce genuine world gliding or a complete authored inventory screen. +Native pixels, successful native renderer variables, converted skins, equipment, emotes, GUI effects, mounted queries and Iris comparisons remain incomplete. + +The [player inventory reference](../../../docs/native-player-inventory.md) records the target functions, pose fields and remaining implementation requirements. +Production still lacks the complete player-inventory controller. These changes remain outside release 0.3.53. + ## Actor-backed UI portraits The shared renderer accepts the pinned `live_horse_renderer` for verified real player actors. @@ -96,3 +125,255 @@ Tests cover more than 256 icons, frequent cache hits, evicted entries and change ## Upstream prerequisites - [viabedrock patch](../../viabedrock/upstreamable/0095-share-native-ui-programs-and-resources.pr.md): Publish shared UI scene and program contracts before the client renderer. + +## Inventory image failure report + +The current release report shows inventory items without their panel background. +The reporter opened the screen with E. +The original active client screen type remains unknown. +A verified failure path allowed a native container program before the converted pack loaded. +The core now keeps its ordinary menu until those resources load, without advancing presentation identity. +The shared client image loader now reports missing or corrupt pack images through the existing screen failure handler. +It preserves asynchronous remote-image loading and can recover when a new pack source supplies valid pixels. + +Both regressions fail before their fixes and pass after them. +The full core build passes 1,494 tests with 90 optional skips, including the concurrent cooking return fix. +The paired add-on build passes 843 tests with 122 optional skips. +Both have zero failures or errors. +All 104 core and 38 add-on patches replay to their identical tested source trees. +Main, test and tool Checkstyle, both publication POMs, add-on access-widener validation and four patch metadata tests pass. + +This fixes two asset-failure paths and does not confirm the screenshot's exact cause. +It does not establish native pixels or GUI scale parity. +The same report includes pearl/wind-charge rollback, repeated eating and a missing locator bar. +Those symptoms remain under investigation. +Fresh public WildMC joins completed on October 23, 2026, after the storage cleanup. +The published 0.3.43 add-on and pinned ViaFabricPlus match their release checksums in the installed private client. +The release client shows complete backgrounds for its lobby inventory, native server selector, WildFFA inventory, and native `/kits` menu. +After the Default kit claim, the release inventory closely matches the reported item arrangement and retains its background. +Screen observations identify E as an ordinary `InventoryScreen` and the two menus as `BedrockNativeContainerScreen`. +The current build also shows complete lobby, Practice, and WildFFA inventories and native menus. +The six kit icons and player-inventory layout match the native WildFFA `/kits` menu. +The reporter used E, but these E controls retain an ordinary inventory screen. +The server-driven path to that layout remains unverified. + +The initial current and release captures show locator markers and receive `LOCATOR_BAR` packets. +The first current capture records 1,015 packets, and the release capture records 6,909 packets. +The initial clients use a plain profile, software rendering, an 854 by 480 window, and Java GUI scale two. +Further release controls use the reported 1920 by 1013 resolution with Java GUI scales two and four. +The native `/kits` background remains complete in both scale combinations. +Each capture completes initialization, spawn, gameplay acknowledgments, and normal cleanup of all eight owned process identities. +The release run includes a private observer that reads screen and menu state on the client thread. + +These controls do not reproduce the reported fault or its server-driven screen transition. +They do not establish the original mod configuration or official native pixel parity. +The pearl/wind-charge and repeated-eating reports remain open. +Raw journals, server packs, screenshots, account data, and observer output stay private. + +## Recipe and toggle states + +Recipe action annotations now select authored hover and locked branches without requiring an unrelated close annotation. +The shared CPU presentation regression covers enabled, disabled, pointed, and unpointed states. +This change does not establish native button pixels or physical device mapping. + +Native toggle groups select checked/unchecked hover and locked branches without a form response annotation. +The presentation regression covers all eight checked, enabled, and pointer combinations. Native pixels and device equivalence remain separate gates. + +## Equal-layer draw order + +Cooking tab icons and their backgrounds can have the same cumulative layer. +The layout previously sorted siblings before flattening their descendants. +That changed the tie order and let tab backgrounds cover their icons. + +Layout now preserves authored child order. +The complete draw list uses a stable sort by cumulative layer. +Overlapping actions give the later authored control priority when their layers match. +HUDs, forms, and containers share the draw order. + +Twelve controls execute the unchanged traversal from Bedrock 1.26.51.1, build 51061372, protocol 2193. +They verify additive layers, authored child order, and inherited visibility. +Nested-image and screen-pointer regressions fail with the previous behavior and pass with the fix. +The native visitor callback, hierarchy, and visibility flags are supplied test boundaries. +Native GPU batching, pixel equality, and full default cooking-screen coverage remain unverified. + +The combined four-project replay and build pass 2,483 tests, with 213 optional skips and no failures or errors. +Checkstyle, access-widener validation, Maven POM generation, and offline patch validation pass. +All 1,404 embedded core classes match the core build. +The captured furnace scene places both tab icons after their backgrounds at matching cumulative layers. + +The private Fabric client projection now visibly renders the food and search tab icons. +Both icon regions previously contained only one background color. +The fixture retains the authored textures, dimensions, and cumulative layers. +This comparison verifies the host rendering correction, with no native reference pixel claim. + +The controlled client run passes furnace, smoker, and blast-furnace cooking, then reconnects to the same world. +Fourteen preference sends receive fourteen newer matching core models; all six recipe press/release replies pass. +All three captures show the tab icons. +Each fixture restores ordinary controls before the normal cooking check. +Two runtime inventories verify 2,520 classes and four data resources each. +The observer is removed, the temporary driver delay is restored, and normal cleanup leaves no managed-profile JVM. +The scene projection removes unfinished controls and mappings, so this does not prove complete default-screen support. + +The combined movement and cooking build repeats these client checks and completes normal reconnect and cleanup. +All 74 shared UI classes match the earlier icon-correction checkpoint. + + +## Native component flags and focus defaults + +Shared scenes now use native boolean and signed/unsigned 64-bit integer component flags. +Real values and strings retain the property's default. Parsing visibility and querying runtime properties use the same reader. +Keyboard traversal requires authored `focus_enabled`; an absent value defaults to false. +The interaction regression preserves pointer actions for controls without traversal focus. + +The original InputComponent and FocusComponent factories execute in 666 cases through original JsonCpp and PropertyView objects. +The production reader matches 5,544 resulting property reads. Four deliberate type-conversion regressions fail. +Factory/property cleanup completes all 25,840 native frees. + +Another 432 sequences feed factory-created components through original ScreenView selection, hover, button dispatch and toggle handling. +The private candidate matches 2,880 sequence steps. Three wrong phase, input-mode and focus variants fail. +Three history-ring controls add 240 selections per input mode. +Together, these controls execute 5,040 screen input calls and 193,283 native frees with balanced owners after every case. + +The factory and screen controls supply component IDs, control ownership, screen/controller fields, four rectangles and empty observer state. +Platform interfaces observe event delivery, bindings, visuals, radio/settings notifications, diagnostics and profiling. +The production port still needs full native navigation and input routing. +Complete controller receivers, physical device-origin translation, radio siblings and unchanged authored screen acceptance remain open. +The [factory and input notes](../../../docs/native-cooking-actions.md#native-component-flags-and-factory-created-input) retain the boundaries. + + +The full four-project replay and build pass 2,706 tests with 163 optional skips and no failures or errors. +The packaged add-on also matches all 5,544 native property reads. +All 1,448 core classes match both embedded copies, and all 1,144 client classes match the add-on. +These checks do not add a fresh live screen or native pixel comparison. + +## Directional focus, containers and scrolling + +The shared selector uses native edge scoring, binary32 arithmetic, nearest ancestor clipping, wrapping and explicit focus overrides. +Original scroll-view viewport searches precede the global clipped search and disable wrapping, including after an unsuccessful search. +Nested viewport candidates retain subtree order. The screen reveals newly focused controls through its retained scroll state. +It updates Java widget visibility during reflow, and an interaction regression types into the newly revealed edit field before another render pass. + +Focus containers apply directional stop, containment and custom routing, plus remembered selection through ancestor containers. +Custom routes use control names. Explicit overrides use focus identifiers and bypass container policies. +The native 50-entry weak history supplies fallback for disabled controls. +Retained scene refreshes remap history to replacement nodes without replacing the widget identities. + +Original native Focus/Input factories, FocusContainer and ScrollView constructors, custom-route property parsing, ancestor registration and viewport binding execute. +The original manager, subtree iterator, geometry and history helpers run before original component/manager/property-bag destruction. +The production selector matches 3,976 geometry steps and 5,344 context steps across 310 scenes. +A separate original history control verifies 36 enabled-state and capacity results, plus retained-node remap checks in the candidate. +All recorded native allocations and declared owner references balance. + +Static rectangles, component IDs, bare controls, focus-container boolean fields and parent/child storage remain supplied inputs. +The fixture retires bare hierarchy references and caller-owned history results. +Complete native control/screen construction, non-widget controls, magnet state, scroll physics, complete controller receivers and physical input remain open. +These controls do not prove native pixels or complete unchanged authored-screen support. +The [directional focus notes](../../../docs/native-cooking-actions.md#native-directional-focus-and-scroll-contexts) describe the boundaries. + + +## Shared native input components + +The shared runtime now parses native input mappings and toggle factory fields. +It retains mapping phases, double-press history, implicit hover events and source-specific release captures. +Toggle state separates component initialization from controller bindings. +Radio propagation clears matching-group siblings with different indices, including ordinary toggles. +Duplicate indices retain their checked state. +The container patch consumes these components and owns its domain callbacks and admission limits. + +Original factories, component handlers, capture routing and destructors provide the comparison corpus. +The actual container consumer also matches 384 composed native Input/Toggle/controller steps. +Complete screen/controller construction, device-origin translation and pixels remain separate requirements. + + +## Initial native focus selection + +Initial focus now ranks the authored precedence before the native float top-left score. +Equal scores retain registration order. All-negative precedences and scores at the initial maximum leave focus empty. +The signed 64-bit property reader narrows accepted integers to 32 bits. +Unsigned-only integers, real values, booleans and other types retain zero. + +The production selector matches 1,920 original native default-ranking cases and 19 factory property reads. +The comparison excludes 1,920 manager-disabled fixtures because manager activation belongs to the caller. +Three deliberate first-control, double-score and unsigned-coercion variants fail these comparisons. +The container consumer now owns authored VIEW arrow delivery and post-focus hover ordering. +Full native factories, focus-tab traversal, controller pointer events, devices and pixels remain unfinished. + + +## Raw native input phases + +Component and capture routing now retain raw phases zero, one and two. +Only phase one requests a press capture or counts as a pressed controller event. +Phase two preserves a completed double press until phase zero releases it. +The original factory, dispatch and cleanup corpus matches 3,216 steps and 1,032 exact button events. +The API carries raw phases through the router; physical callers convert their boolean state at the boundary. +The container patch owns GLOBAL reinjection and its controller capability checks. + + +## Retained Button appearance and component delivery + +The shared runtime now models Button appearance separately from edit-box selection and input activation. +The press byte and applied visibility can differ after a suppressed mapping transition. +Raw phase `2` preserves the press byte. Own visibility and enabled callbacks retain their native reset behavior. +Inherited enabled updates use general event `17` without clearing the press byte. + +Named setters use breadth-first ownership and native alias order. Only a missing locked control uses the default fallback. +A matching scene replacement restores the applied visibility before layout. +The same scene instance does not replay its setters. A visibility revision tells the consumer when layout needs another pass. +The retained presentation path preserves those setters during draw collection. + +Input routing runs component effects before the next control reads its current context. +Container consumers keep implicit hover buttons on their separate controller route. +Original component and property comparisons match 248 cases and 7,168 steps. +Button/Input and hover composition match another 732 cases and 8,004 steps. +The container consumer also matches 336 ScreenView and overlap cases, including capture-priority releases. +Eight incorrect variants fail the native comparisons or lifecycle regressions. +Full screen construction, property-update timing, controller pointer receivers, Button audio, devices and pixels remain separate gates. + +## Input visibility and capture ownership + +Input visibility callbacks invalidate all mapping phases without clearing double-press history. +Removing a control from normal input order also invalidates its phases. +Captured controls retain their identity after removal and receive their captured release before the normal pass. +New presses do not reach an unregistered control. + +The source consumer matches 144 native pending-update cases and 12 initially hidden fixtures. +The complete original View update executes visibility propagation, input registration and removal, capture routing, and controller delivery. +The container patch owns the visibility producer and its retained scene integration. +External notification queue insertion, the admitted root, geometry, clocks, and input mode remain supplied boundaries. +Full screen construction, binding scheduling, modal transitions, physical devices, and native pixels remain unfinished. + +The packaged consumer matches all 144 update cases, 12 initial hidden fixtures, and 12 direct visibility callback cases. +The direct callback corpus exposes an incorrect history-clearing variant that the screen sequence alone cannot distinguish. +Six incorrect variants fail behavioral assertions. All four builds pass 2,785 tests with 163 optional skips. +Twenty-five packaged regressions and the earlier input comparisons pass with mandatory production class origins. + +## Synchronous component callbacks and scene restoration + +Input dispatch now runs sibling callbacks before it commits the sender phase. +A reentrant visibility callback can invalidate other mappings without replacing the sender's final phase. +Wildcard callbacks also run before the following mapping samples its phase and double-press history. + +Button components exist before deferred names bind. +Runtime setters notify the host immediately, while matching scene copies restore cached visuals without property callbacks. +This prevents intermediate alias writes from resetting held child input during a redraw. +The container notes record the original factories, tree notifications, composed comparisons, and remaining lifecycle boundaries. + +The final JAR matches all 1,700 cases across twelve Button and visibility corpora. +Seven new incorrect variants fail behavioral assertions, including the scene-restoration regression. +All 33 packaged regressions and the earlier focus, tab, arrow, and GLOBAL comparisons pass. +All four builds pass 2,793 tests with 163 optional skips and no failures or errors. +All 1,448 core classes match both embedded copies. All 1,184 client classes match the final add-on. +The modpack contains the exact verified client JARs. These changes are the candidate for release 0.3.51. + +## Mode notifications and queued invalidation + +Original mode notifications reset Button appearance before the sender resumes normal routing. +The sibling callback alone preserves Input mapping phases. +The complete producer also queues the root component update and drains the original tree context twice. +That update invalidates the mapping phases to `2` while captured control identity survives. +A reset-only implementation therefore misses part of the native lifecycle. + +The reference covers 768 activation combinations and 36 press/release sequences across three mapping kinds. +Repeated notifications still run the lifecycle when the mode value remains unchanged. +The native controller receives the kind-7 event with its original mode payload. +These are native reference captures. The production mode-notification route remains unimplemented. 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 8ec34e75..ba771a2c 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 1c86ca93e071b8213b9551f59d88737aa1159462 Mon Sep 17 00:00:00 2001 +From a8703275e0c6196021c05fdea3e95a036aba2951 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 @@ -58,15 +58,20 @@ Bundled unsaddled camel tests exercise standing and sitting graph selection. Mounted camera offset timing, native motion/fluid producers and equipped camel rendering remain separate; preserve equipment and unsupported-query fallback. + +Refresh common actor inputs and native metadata when sampling attached +world-actor particles, even when the body is not submitted again. Keep +the existing actor/resource lifetime guards and locator pose. The shared +runtime patch records the fresh native callback evidence and its limits. --- .../BedrockWorldActorAppearance.java | 74 +++++ .../render/MixinEntityRenderDispatcher.java | 16 + .../bedrock/render/BedrockAnimatedModel.java | 7 +- - .../render/BedrockWorldActorRenderer.java | 308 ++++++++++++++++++ + .../render/BedrockWorldActorRenderer.java | 309 ++++++++++++++++++ .../BedrockWorldActorAppearanceTest.java | 187 +++++++++++ .../render/BedrockCustomActorModelTest.java | 7 + .../render/BedrockWorldActorQueryTest.java | 121 +++++++ - 7 files changed, 719 insertions(+), 1 deletion(-) + 7 files changed, 720 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockWorldActorAppearance.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockWorldActorRenderer.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockWorldActorAppearanceTest.java @@ -204,7 +209,7 @@ index 94877d4..7b5265c 100644 cir.setReturnValue((EntityRenderer) this.stackanvil$guiActorRenderer); return; 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 fd0b735..579c780 100644 +index fdc7a79..fd64b5e 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockAnimatedModel.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockAnimatedModel.java @@ -80,12 +80,17 @@ public final class BedrockAnimatedModel { @@ -228,10 +233,10 @@ index fd0b735..579c780 100644 diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockWorldActorRenderer.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockWorldActorRenderer.java new file mode 100644 -index 0000000..216692d +index 0000000..ededfdc --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockWorldActorRenderer.java -@@ -0,0 +1,308 @@ +@@ -0,0 +1,309 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -405,7 +410,8 @@ index 0000000..216692d + || !actor.active || actor.failed || actor.frame == null || BedrockActorResources.get() != generation || this.actors.get(current) != actor + || !actor.lifetime.equals(BedrockActorState.lifetime(current.getUUID()))) return null; + return new BedrockParticleAttachment(current.position(), actor.yaw, -+ this.effectLocator(actor, locator, current.position()), current.getBoundingBox(), actor.queries); ++ this.effectLocator(actor, locator, current.position()), current.getBoundingBox(), ++ BedrockParticleQueries.actor(current, BedrockActorState.state(current.getUUID()), actor.queries)); + }); + } + } diff --git a/patches/viafabricplus-bedrock/upstreamable/0030-use-supported-native-world-actor-definitions.pr.md b/patches/viafabricplus-bedrock/upstreamable/0030-use-supported-native-world-actor-definitions.pr.md index bf3e7f11..ebc0adbe 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0030-use-supported-native-world-actor-definitions.pr.md +++ b/patches/viafabricplus-bedrock/upstreamable/0030-use-supported-native-world-actor-definitions.pr.md @@ -87,3 +87,9 @@ The observer sees both sitting and standing flags, with one selected draw per ac Sitting observations are brief, so this run does not establish complete sit-down or stand-up timing. Both clients and BDS instances exit normally, and the original fixture script is restored. Raw recordings, screenshots and native assets remain private. + +## Refresh attached world-actor particle inputs + +Bound world-actor particles sample current common actor inputs and native metadata independently of another body submission. +The existing resource and actor lifetime guards, locator pose, and unrelated graph inputs remain available. +See the [shared runtime evidence](0008-share-native-actor-and-resource-runtime.pr.md#fresh-common-particle-actor-inputs) for tested queries, native boundaries, and remaining limits. 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 80681001..de5ccb34 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 33977993dc68bf66b97e8006f059df14f3274430 Mon Sep 17 00:00:00 2001 +From 37f064b2e19913ce35d5317ac85f804deda19b4e 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 2081e10..fb6251d 100644 +index e2a6e45..01450e9 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java -@@ -89,6 +89,8 @@ import net.raphimc.viabedrock.protocol.provider.EmoteProvider; +@@ -90,6 +90,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 2081e10..fb6251d 100644 import com.viaversion.viafabricplus.bedrock.screen.ui.BedrockHudOverlays; import com.viaversion.viafabricplus.bedrock.screen.form.BedrockNativeForms; import com.viaversion.viafabricplus.bedrock.screen.npc.BedrockNativeNpcs; -@@ -169,6 +171,8 @@ public final class ViaFabricPlusBedrock implements ViaFabricPlusEntrypoint { +@@ -175,6 +177,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 545727e..3100bd6 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 f6b927d..8f01b46 100644 +index c89207e..92c1bbb 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -145,6 +145,8 @@ +@@ -182,6 +182,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 e47673a9..907b0dca 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 6f17d2564e5d9aadff47469a75d436ec7ec00163 Mon Sep 17 00:00:00 2001 +From dfe1dfe58f3023dffeae483250cca4997cd8c7fc 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 8f01b46..cf6a07f 100644 +index 92c1bbb..1399d23 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -145,6 +145,7 @@ +@@ -182,6 +182,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 f45f27c1..7e67ae0c 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 568fa7ff77fe2e9ff176b5ca1a4f9863746b9aab Mon Sep 17 00:00:00 2001 +From 93d39e5af558957644a101d0764037e618b68599 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 cf6a07f..f4022ad 100644 +index 1399d23..8828e3c 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -148,6 +148,8 @@ +@@ -185,6 +185,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 fc972381..ee6e6b6a 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 9842b7865f47b1730318d9b94e07ae03e56ad779 Mon Sep 17 00:00:00 2001 +From 960a09e82bf7f2427afea8a130c52a5d19524ab9 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,13 +113,13 @@ index 0000000..d3c7c2d + } +} diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json -index f4022ad..54a679d 100644 +index 8828e3c..5f58fc9 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json -@@ -69,6 +69,7 @@ - "features.creative.MixinCreativeModeTab_ItemDisplayBuilder", +@@ -87,6 +87,7 @@ "features.creative.MixinCreativeModeTabs", "features.inventory.MixinNativeContainerGeometry", + "features.inventory.MixinNativeContainerScreenAdmission", + "features.inventory.MixinBucketInteraction", "features.inventory.MixinCartographyTableMenu", "features.inventory.MixinCartographyPacketListener", diff --git a/src/replay/cli.ts b/src/replay/cli.ts index 84ebbfc1..fad1b7bd 100644 --- a/src/replay/cli.ts +++ b/src/replay/cli.ts @@ -46,7 +46,11 @@ import { } from "./native-offline.ts"; import { prepareNativeReplayPrefix } from "./native-prefix.ts"; import { prepareNativeProfile } from "./native-profile.ts"; -import { cleanupReplaySession } from "./session-cleanup.ts"; +import { + cleanupReplaySession, + requestReplayJavaStop, + stopReplayJavaProcesses, +} from "./session-cleanup.ts"; import { hiveTarget, publicTarget } from "./targets.ts"; import { hasGameplayAcknowledgments, @@ -243,6 +247,7 @@ async function buildFabricRecorder(jar: string, build: string): Promise "MixinReplayCamera", "MixinPlayerFrame", "MixinMovementAudit", + "MixinClientShutdown", ], }), { mode: 0o600 }, @@ -574,20 +579,12 @@ async function launchNative( return { pid: child.pid, log }; } -async function stopAll(gamePid?: number): Promise { +async function stopAll(directory: string, gamePid?: number): Promise { if (javaCleanup) { - javaCleanup.captureDescendants(); - if (gamePid) javaCleanup.signal(gamePid, "SIGTERM"); - for (const identity of javaCleanup.all().toReversed()) - javaCleanup.signal(identity.pid, "SIGTERM"); - const until = Date.now() + 15_000; - while ( - Date.now() < until && - javaCleanup.all().some((identity) => javaCleanup!.alive(identity.pid)) - ) - await Bun.sleep(100); - if (javaCleanup.all().some((identity) => javaCleanup!.alive(identity.pid))) - throw new Error("Owned Java processes did not stop normally; forced cleanup refused."); + const processes = javaCleanup; + await stopReplayJavaProcesses(processes, gamePid, undefined, () => + requestReplayJavaStop(directory, () => processes.signal(gamePid!, "SIGTERM")), + ); return; } if (nativeClient && alive(nativeClient.pid)) { @@ -973,7 +970,7 @@ Live add-on recordings accept --allow-transfers to save each connection separate { mode: 0o600 }, ); }, - stopProcesses: () => stopAll(gamePid), + stopProcesses: () => stopAll(directory, gamePid), releaseDependencies: async () => { if (javaDataCleanup) { await javaDataCleanup(); diff --git a/src/replay/fabric/com/enderdash/agent/replay/fabric/ReplayClientControl.java b/src/replay/fabric/com/enderdash/agent/replay/fabric/ReplayClientControl.java new file mode 100644 index 00000000..55278301 --- /dev/null +++ b/src/replay/fabric/com/enderdash/agent/replay/fabric/ReplayClientControl.java @@ -0,0 +1,26 @@ +package com.enderdash.agent.replay.fabric; + +import com.enderdash.agent.replay.PrivateFiles; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** Private recorder control, polled on the client render thread even while gameplay is paused. */ +public final class ReplayClientControl { + private final Path directory; + private boolean requested; + + public ReplayClientControl(Path directory) throws IOException { + this.directory = directory; + try (var output = PrivateFiles.newOutputStream(directory.resolve("client-control-ready"))) { + output.write(1); + } + } + + public void poll(Runnable stop) { + if (!this.requested && Files.exists(this.directory.resolve("stop-client"))) { + this.requested = true; + stop.run(); + } + } +} diff --git a/src/replay/fabric/com/enderdash/agent/replay/fabric/mixin/MixinClientShutdown.java b/src/replay/fabric/com/enderdash/agent/replay/fabric/mixin/MixinClientShutdown.java new file mode 100644 index 00000000..ddda33ca --- /dev/null +++ b/src/replay/fabric/com/enderdash/agent/replay/fabric/mixin/MixinClientShutdown.java @@ -0,0 +1,28 @@ +package com.enderdash.agent.replay.fabric.mixin; + +import com.enderdash.agent.replay.fabric.ReplayClientControl; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +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.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(targets = "net.minecraft.client.Minecraft", remap = false) +public abstract class MixinClientShutdown { + @Unique private ReplayClientControl stackanvil$control; + + @Shadow public abstract void stop(); + + @Inject(method = "runTick(Z)V", at = @At("TAIL")) + private void stackanvil$pollShutdown(boolean render, CallbackInfo callback) throws IOException { + if (this.stackanvil$control == null) { + Path directory = Path.of(Files.readString(Path.of("stackanvil-replay-directory.txt")).trim()); + this.stackanvil$control = new ReplayClientControl(directory); + } + this.stackanvil$control.poll(this::stop); + } +} diff --git a/src/replay/session-cleanup.ts b/src/replay/session-cleanup.ts index 16c9c9fe..0a76a9a2 100644 --- a/src/replay/session-cleanup.ts +++ b/src/replay/session-cleanup.ts @@ -1,3 +1,45 @@ +import { existsSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { OwnedProcesses } from "./owned-processes.ts"; + +/** Let Minecraft disconnect on its render thread before the JVM enters its exit safepoint. */ +export async function requestReplayJavaStop( + directory: string, + signalStartup: () => void, +): Promise { + if (existsSync(join(directory, "client-control-ready"))) { + await writeFile(join(directory, "stop-client"), "", { mode: 0o600 }); + } else { + signalStartup(); + } +} + +/** Keep Prism draining the game's output until the JVM has finished shutting down. */ +export async function stopReplayJavaProcesses( + processes: OwnedProcesses, + gamePid?: number, + waitForExit: (pids: number[]) => Promise = async (pids) => { + const until = Date.now() + 15_000; + while (Date.now() < until && pids.some((pid) => processes.alive(pid))) await Bun.sleep(100); + if (pids.some((pid) => processes.alive(pid))) + throw new Error("Owned Java processes did not stop normally; forced cleanup refused."); + }, + requestStop?: () => Promise, +): Promise { + processes.captureDescendants(); + if (gamePid !== undefined) { + if (processes.alive(gamePid)) { + if (requestStop) await requestStop(); + else processes.signal(gamePid, "SIGTERM"); + } + await waitForExit([gamePid]); + } + processes.captureDescendants(); + for (const identity of processes.all().toReversed()) processes.signal(identity.pid, "SIGTERM"); + await waitForExit(processes.all().map((identity) => identity.pid)); +} + export interface ReplaySessionCleanup { recordOwnership(): Promise; stopProcesses(): Promise; diff --git a/src/replay/verification.ts b/src/replay/verification.ts index b57e5f9b..901202b0 100644 --- a/src/replay/verification.ts +++ b/src/replay/verification.ts @@ -3,8 +3,10 @@ export function clientSessionFailed(log: string, allowTransfers = false): boolea const observed = allowTransfers ? log.replace(/^.*Client disconnected with reason: Transferred to another server\r?$/gm, "") : log; - return /Client disconnected with reason:|Failed to handle packet|ReadTimeoutException|(?:Unreported|Reported) exception thrown!|A fatal error has been detected by the Java Runtime Environment|Mixin transformation .* failed|handlerAdded\(\) has thrown/.test( - observed, + return ( + /Client disconnected with reason:|Failed to handle packet|ReadTimeoutException|(?:Unreported|Reported) exception thrown!|A fatal error has been detected by the Java Runtime Environment|Mixin transformation .* failed|handlerAdded\(\) has thrown/.test( + observed, + ) || /^.*\[Server Connector #\d+\/ERROR\]: Couldn't connect to server\r?$/m.test(observed) ); } diff --git a/test/replay-rendering.test.ts b/test/replay-rendering.test.ts index f6741a21..3dadf0ab 100644 --- a/test/replay-rendering.test.ts +++ b/test/replay-rendering.test.ts @@ -111,3 +111,12 @@ test("live transfers keep later disconnects and packet failures visible", () => } expect(clientSessionFailed(transfer.trimEnd() + ": unexpected", true)).toBeTrue(); }); + +test("connection failures terminate capture even before gameplay or transfer", () => { + const connectionError = "[20:04:19] [Server Connector #1/ERROR]: Couldn't connect to server\n"; + for (const allowTransfers of [false, true]) { + expect(clientSessionFailed(connectionError, allowTransfers)).toBeTrue(); + expect(clientSessionFailed(connectionError.replaceAll("\n", "\r\n"), allowTransfers)).toBeTrue(); + expect(clientSessionFailed("[20:04:19] [Render thread/INFO]: [CHAT] Couldn't connect to server\n", allowTransfers)).toBeFalse(); + } +}); diff --git a/test/replay-session-cleanup.test.ts b/test/replay-session-cleanup.test.ts index 718fdbdd..d98a5ec8 100644 --- a/test/replay-session-cleanup.test.ts +++ b/test/replay-session-cleanup.test.ts @@ -1,11 +1,141 @@ import { expect, test } from "bun:test"; -import { cleanupReplaySession, type ReplaySessionCleanup } from "../src/replay/session-cleanup.ts"; +import { existsSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + cleanupReplaySession, + requestReplayJavaStop, + stopReplayJavaProcesses, + type ReplaySessionCleanup, +} from "../src/replay/session-cleanup.ts"; +import { OwnedProcesses, type ProcessEntry } from "../src/replay/owned-processes.ts"; + +function launcherFixture() { + const entries = new Map([ + [10, { pid: 10, parent: 1, startTicks: "100" }], + [20, { pid: 20, parent: 10, startTicks: "200" }], + [30, { pid: 30, parent: 1, startTicks: "300" }], + ]); + const signals: number[] = []; + const processes = new OwnedProcesses({ + read: (pid) => entries.get(pid), + ids: () => [...entries.keys()], + signal: (pid) => { + signals.push(pid); + }, + }); + processes.capture(10); + return { processes, entries, signals }; +} + +test("keeps the launcher alive throughout asynchronous game shutdown", async () => { + const { processes, entries, signals } = launcherFixture(); + const exiting = Promise.withResolvers(); + const stopping = stopReplayJavaProcesses(processes, 20, async (pids) => { + if (pids.length === 1) { + expect(processes.alive(10)).toBe(true); + await exiting.promise; + } + for (const pid of pids) entries.delete(pid); + }); + expect(signals).toEqual([20]); + expect(entries.has(10)).toBe(true); + exiting.resolve(); + await stopping; + expect(signals).toEqual([20, 10]); + expect([...entries.keys()]).toEqual([30]); +}); + +test("preserves the launcher and refuses further teardown when the game cannot stop", async () => { + const { processes, entries, signals } = launcherFixture(); + const failure = new Error(); + const result = await stopReplayJavaProcesses(processes, 20, async () => { + throw failure; + }).catch((error) => error); + expect(result).toBe(failure); + expect(signals).toEqual([20]); + expect([...entries.keys()]).toEqual([10, 20, 30]); +}); + +test("awaits a normal client quit without signaling the JVM or stopping its launcher early", async () => { + const { processes, entries, signals } = launcherFixture(); + const requested = Promise.withResolvers(); + const stopping = stopReplayJavaProcesses( + processes, + 20, + async (pids) => { + for (const pid of pids) entries.delete(pid); + }, + async () => { + expect(processes.owns(20)).toBe(true); + expect(entries.has(10)).toBe(true); + await requested.promise; + }, + ); + expect(signals).toBeEmpty(); + expect(entries.has(20)).toBe(true); + requested.resolve(); + await stopping; + expect(signals).toEqual([10]); + expect([...entries.keys()]).toEqual([30]); +}); + +test("a reused game PID cannot receive a client quit request", async () => { + const { processes, entries, signals } = launcherFixture(); + processes.captureDescendants(); + entries.set(20, { pid: 20, parent: 10, startTicks: "201" }); + let requested = false; + await expect( + stopReplayJavaProcesses( + processes, + 20, + async () => {}, + async () => { + requested = true; + }, + ), + ).rejects.toBeInstanceOf(Error); + expect(requested).toBe(false); + expect(signals).toBeEmpty(); +}); + +test("only a ready recorder receives a quit marker; startup retains its normal signal path", async () => { + const directory = await mkdtemp(join(tmpdir(), "stackanvil-client-stop-")); + let signals = 0; + try { + await requestReplayJavaStop(directory, () => { + signals++; + }); + expect(signals).toBe(1); + expect(existsSync(join(directory, "stop-client"))).toBe(false); + await writeFile(join(directory, "client-control-ready"), new Uint8Array([1])); + await requestReplayJavaStop(directory, () => { + signals++; + }); + expect(signals).toBe(1); + expect(existsSync(join(directory, "stop-client"))).toBe(true); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("stops captured launcher descendants when startup never identified a game", async () => { + const { processes, entries, signals } = launcherFixture(); + await stopReplayJavaProcesses(processes, undefined, async (pids) => { + for (const pid of pids) entries.delete(pid); + }); + expect(signals).toEqual([20, 10]); + expect([...entries.keys()]).toEqual([30]); +}); function fixture() { const state = { running: true, dependencies: true, display: true }; const cleanup: ReplaySessionCleanup = { recordOwnership: async () => {}, - stopProcesses: async () => { state.running = false; }, + stopProcesses: async () => { + state.running = false; + }, releaseDependencies: async () => { expect(state.running).toBe(false); state.dependencies = false; @@ -21,8 +151,10 @@ function fixture() { test("stops processes and releases dependencies when a full disk prevents writing ownership evidence", async () => { const { state, cleanup } = fixture(); const failure = Object.assign(new Error(), { code: "ENOSPC" }); - cleanup.recordOwnership = async () => { throw failure; }; - const result = await cleanupReplaySession(cleanup).catch(error => error); + cleanup.recordOwnership = async () => { + throw failure; + }; + const result = await cleanupReplaySession(cleanup).catch((error) => error); expect(result).toBe(failure); expect(state).toEqual({ running: false, dependencies: false, display: false }); }); @@ -30,18 +162,25 @@ test("stops processes and releases dependencies when a full disk prevents writin test("retains dependencies and display when owned processes fail to stop", async () => { const { state, cleanup } = fixture(); const failure = new Error(); - cleanup.stopProcesses = async () => { throw failure; }; - const result = await cleanupReplaySession(cleanup).catch(error => error); + cleanup.stopProcesses = async () => { + throw failure; + }; + const result = await cleanupReplaySession(cleanup).catch((error) => error); expect(result).toBe(failure); expect(state).toEqual({ running: true, dependencies: true, display: true }); }); test("closes the display despite a dependency release failure and preserves both errors", async () => { const { state, cleanup } = fixture(); - const reportFailure = new Error(), releaseFailure = new Error(); - cleanup.recordOwnership = async () => { throw reportFailure; }; - cleanup.releaseDependencies = async () => { throw releaseFailure; }; - const result = await cleanupReplaySession(cleanup).catch(error => error); + const reportFailure = new Error(), + releaseFailure = new Error(); + cleanup.recordOwnership = async () => { + throw reportFailure; + }; + cleanup.releaseDependencies = async () => { + throw releaseFailure; + }; + const result = await cleanupReplaySession(cleanup).catch((error) => error); expect(result).toBeInstanceOf(AggregateError); expect(result.errors).toEqual([reportFailure, releaseFailure]); expect(state).toEqual({ running: false, dependencies: true, display: false });