Design: typed cauldron interactions
Provisional contract for #1323 and parent #854. This note implements no API. Typed registration, handler shape, prediction and atomicity require maintainer approval before code. The merged #1325 table and #1390 journal are foundations, not a mod registrar.
Vanilla and public counterpart
Reproduce the vanilla inspection with python3 tools/rea_vanilla.py class net.minecraft.core.cauldron.CauldronInteractions, then method queries for bootStrap, addDefaultInteractions, fillBucket, emptyBucket, fillWaterInteraction, fillLavaInteraction, fillPowderSnowInteraction, isUnderWater, dyedItemIteration, bannerInteraction and shulkerBoxInteraction. Query net.minecraft.core.cauldron.CauldronInteraction.Dispatcher get and callers of net.minecraft.core.cauldron.CauldronInteraction interact too. These are pinned vanilla 26.3 bytecode queries; no foreign implementation is used.
Dispatcher.get selects the first matching tag, otherwise the exact item, otherwise DEFAULT. Returning TRY_WITH_EMPTY_HAND from the selected tag handler does not resume the item lookup. All registrations in bootStrap and its default-registration helper are covered below:
| Dispatcher | Registered inputs and behavior |
|---|---|
| All four: EMPTY, WATER, LAVA, POWDER_SNOW | Water bucket replaces with water level 3 and returns bucket. Lava bucket replaces with lava and returns bucket; powder snow bucket replaces with snow level 3 and returns bucket. Lava/snow under water return CONSUME without changing anything. |
| EMPTY additions | Water potion only: water level 1, glass bottle returned; other potion contents pass. |
| WATER additions | Empty bucket succeeds only at level 3, leaving empty cauldron and water bucket. Glass bottle returns a water potion and lowers one level. Water potion below level 3 raises one level and returns glass bottle; full or non-water potion passes. |
| WATER tag | minecraft:cauldron_can_remove_dye: absent DYED_COLOR passes terminally; otherwise removes that component and lowers one level. This is the only tag branch registered by bootstrap. |
| WATER item collections | Every item in Items.BANNER removes the last pattern from one banner, passing if there are no patterns; every item in Items.DYED_SHULKER_BOX returns one uncolored shulker preserving contents/components. Both lower one level. Horizon expands these to the 16 vanilla colors. |
| LAVA addition | Empty bucket always returns lava bucket and empty cauldron. |
| POWDER_SNOW addition | Empty bucket returns powder snow bucket only at level 3, leaving empty cauldron. |
Lowering level 1 gives an empty cauldron. Bucket and bottle exchanges use ItemUtils.createFilledResult; washing uses its non-limited-creative form for banner/shulker. Preserve survival consumption, creative inventory deduplication where enabled, returned-stack components, inventory insertion and leftover drops. Washing has its cleaning statistic and BLOCK_CHANGE from lowering, with no fluid sound/event. Transfers have their existing statistics, sound and FLUID_PLACE/FLUID_PICKUP; a bottle lowering also has BLOCK_CHANGE. Successful handlers check the same guards on the client but mutate only on the server. This is why a prediction result and an authoritative application are separate operations.
The public NeoForge cauldron documentation describes RegisterCauldronInteractionEvent.Dispatcher on the mod event bus, an identifier for a dispatcher, Interaction registration by dispatcher and item, and registerToAll. Its 26.1 migration primer describes tags preceding items. The accessible documentation is labeled 26.1, not 26.3. Exact NeoForge 26.3 duplicate policy, overlapping-tag order, reload lifetime, side behavior, prediction and rollback guarantees remain unverified; no 26.3 Javadoc contract was established. No NeoForge source or patches were read. The Horizon rules below are proposals, not claims of NeoForge parity.
Verified owners and routes
These file:line anchors describe the inspected base and must be checked again before implementation.
| Route | Current owner and required integration |
|---|---|
| Shared vanilla dispatch | crates/horizon-client/src/cauldron.rs:191 private Action; :206 four maps; :242 interact; :265 terminal tag-first branch; :292 item lookup. Replace the private fixed dispatcher with one resolved dispatcher shared by vanilla and mods. Do not add a second general block-use hook. |
| Shared prediction result | crates/horizon-client/src/cauldron.rs:396 outcome runs interact with a dry player. Replace mod execution here with the typed read-only prediction operation. |
| Authoritative player use | crates/horizon-client/src/game.rs:7234 calls use_on_cauldron. game/blocks/cauldrons.rs:28 owns application, with immediate :40 and transactional :79 paths. Every selected mod handler must open an action transaction, including ordinary clicks that currently use the immediate path. |
| Target lookup | crates/horizon-client/src/game.rs:12338 cauldron_at currently filters a name suffix. Resolve registered BlockKeys instead so custom blocks need no naming convention. |
| Server connections | crates/horizon-client/src/game/network/lan_actions/interaction.rs:241 guest_use_item_on reaches use_item under as_guest_at. Both hands, integrated single player, LAN and dedicated server use this authority route. Preserve reach, protection, spectator and secondary-use guards. |
| Mod/fake-player action | crates/horizon-client/src/game/modding/native_server.rs:771 PlayerAction::UseOn calls guest_use_item_on at :778. Same dispatch, player identity and rollback; no separate fake-player implementation. |
| Remote prediction | crates/horizon-client/src/game/network/remote/mod.rs:8901 cauldron_outcome; :9307 remote_cauldron_reacts; definition :9385, dry interact :9430. Today the latter proposes blocks and sound with a copied hand/inventory. Both must select the same handler and stop normal item use on Success/Consume. |
| Journal | crates/horizon-client/src/game/runtime/transactions.rs:192 open; :239 nested close; :426 deferred drops; :437 journal_set_block; :448 deferred effects; :512 root commit. Extend these owners rather than inventing a parallel journal. |
| Non-item contact | crates/horizon-client/src/game.rs:12640 extinguishing uses cauldron::extinguished, not the item dispatcher. Entity contact, precipitation, dripstone and fluid capability transfers are not item interactions and must not invoke this registrar. |
Paths abbreviated as game/ above are under crates/horizon-client/src/. The vanilla caller query identifies AbstractCauldronBlock.useItemOn as the item dispatch moment. Future implementation must test each authority route above and both remote prediction paths.
PR #2835 owns fluid content/capacity, planned game/blocks/machines/cauldron_contents.rs, and native fluid capability integration. That file is absent at this base. Reuse its BlockKey/FluidKey and resolved resource snapshot when integrated; an interaction entry neither registers fluid capacity nor invents a second fluid table. Powder snow need not become a fluid. A fill recipe must respect the registered level schema and content capacity. PR #2697 owns activity/dispatch restructuring, following vocabulary/loading moves. Declare types in vocabulary and generate facade/transport through the owning generator after that work; do not restore old private point registries. Keep future adapters in small dedicated modules: game.rs, native_hooks/shared.rs and modding/api_values.rs must not grow beyond the fork's file-size gate.
Proposed typed registration
One registration moment, provisionally register_cauldron_interaction, corresponding to RegisterCauldronInteractionEvent. Public 26.3 verification remains required: adopt its exact names (Dispatcher and Interaction if verified), side and full contract before shipping, without aliases. No 26.3 counterpart is claimed verified here. Use this moment with typed dispatcher and entry phases if public 26.3 verification requires them. Register by (BlockKey, ItemKey) through a CauldronInteractions registrar with a Host. A custom block opts into a dispatcher explicitly; vanilla blocks come prebound. Several blocks may deliberately share a dispatcher, but binding and conflicts must resolve before publication. A convenience register-to-all expands over the complete prepared dispatcher set, including later mod declarations, never over the live mod list at click time.
Use exact typed items for the initial mod surface. Retain vanilla's tag branch in the same dispatcher; do not expose arbitrary mod tag priority until its counterpart contract is verified. Resolve tags against the authoritative prepared tag snapshot on both sides. Exact entries shadowed by the dye tag stay shadowed, even when washing passes.
Refuse duplicate entries (including attempts to replace vanilla entries) with CauldronRegistrationError::Duplicate, identifying both owners. No override flag, priority integer or last-wins behavior. Dependency order and stable declaration order determine diagnostics, not behavior: a conflicting preparation fails publication rather than choosing a winner. Unknown keys refuse admission as UnknownItem, UnknownBlock or UnknownDispatcher; unknown ordinary held items still pass without a new runtime error. These names are proposed API variants. Unsupported native/wasm callback or codec capability refuses registration as UnsupportedCapability, never silently omitting one target.
Build the entire resolved snapshot at resource preparation and reload, validate, then publish atomically with the existing resource/lifecycle holder. Failed reload retains the prior complete snapshot. Capture that holder in a decision; reload or world closure invalidates the decision through existing identity, without adding a cauldron-specific generation counter. Retain old callback code until its in-flight scopes finish. No retained native pointers or wasm callback indices may survive their owner. Client/server synchronize the existing resource generation and require a matching handler build before prediction.
Handler and prediction contract
Proposed API-owned Wire records: CauldronUse (dimension, target position and state, actor handle, hand, complete immutable held stack, underwater/creative flags and resource identity), CauldronDecision, CauldronPlan, CauldronResult and typed refusal/error enums. Use existing ItemKey, BlockKey, state, stack, lifecycle and handle representations; never serialize internal Action, Rust closures, pointers or borrowed Game state. Every field and result must support the same generated Wire boundary for native and wasm. A callable registration references the runtime's owned callback identity, not a function pointer in a packet.
Split decide, fire, apply. A handler receives a callback-scoped read Host with typed HostSide and returns Pass, Consume, or Success plus a bounded plan. Server fire receives a ServerHost with the game's open transaction for additional supported writes. Client prediction receives only ClientHost reads; it cannot mutate inventories, draw authoritative random, send messages, spawn drops or publish effects. No recursive dispatch from decide; refuse same-action reentry as Reentrant, on both targets. Server callbacks are invoked once per attempt, with nested transaction commits merging into the action. Pass discards all staged writes; Consume may stop item use with an empty plan (vanilla underwater behavior).
The plan describes one target block replacement, an optional exchange of one held item for one returned stack, and optional one sound, one fluid game event and up to two statistics (custom cauldron stat and ITEM_USED, preserving vanilla transfer capacity). Lower-fill BLOCK_CHANGE remains a distinct effect. Washing can edit the held components without an exchange. Helpers implement existing filled-result semantics instead of making mods reconstruct creative and overflow rules. More extensive world changes use existing journal-supported Host operations; unsupported operations refuse the action before any escape. No irreversible I/O during callbacks.
Prediction uses the same decision rules and selected entry but computes only result and visual proposal. Do not call the authoritative handler on the client. Missing matching client resources suppress custom prediction and await the server; they do not guess Pass and launch a second bucket use. Server revalidates target, dimension, actor identity, complete stack/components, relevant reads and resource holder before applying. Changed preconditions produce Stale, discard the entire action, and send normal authoritative correction; never recompute a different interaction inside that attempt.
Track speculative visual effects with the existing use/prediction acknowledgement identity. Confirmation reconciles the proposed block and suppresses duplicate local sound; refusal restores the authoritative block. Inventory, statistics, drops and game events are server-only. One server mutation is committed regardless of whether a client predicted. Preserve Success/Consume/TRY_WITH_EMPTY_HAND semantics and swing behavior across both hands; prediction must not cause a second UseItem after a consuming UseOn.
Atomicity, trust and cost
Open an action child transaction before the server handler, under any enclosing Host transaction. Journal the acting player's full inventory/hand/components and supported attachment writes, stage the block through journal_set_block, and queue sounds, statistics, game events and leftover drops. Validate the whole plan before mutation and again after callback writes. Nested success merges into its parent; only successful root commit publishes effects, block logs and outbound changes. Any refusal, stale read, panic, guest trap or outer abort restores the original inventory and block and discards every effect. Do not execute the callback again at root commit.
The existing journal already buffers cauldron blocks and effects inside a transaction, but the immediate route is not proof that arbitrary mod callbacks are atomic. Before supporting another write domain, prove its undo and publication behavior or return UnsupportedCapability. In lifted passes, defer entity/attachment retirement and journal Remove emission to root commit using the existing lifecycle holder; rollback preserves identity and attachments. Never assign a second generation to restored objects. Refuse saves while an action savepoint is open, including save-all invoked by a handler; extend crates/horizon-client/src/game/world/autosave.rs:6 admit_entity_save (currently a passes_settled check) and audit every save entry so tentative inventory or blocks cannot reach disk. This is a required implementation guarantee, not a claim that every such gate already exists.
Treat decoded plans as untrusted. Validate keys, state property values, current holder, actor scope, hand and stack components before use. Consume count is 0 or 1 and cannot exceed the held count; exchange count obeys the returned item's real stack limit. State must belong to a registered dispatcher and valid property schema; water/snow levels remain 1..3 and lava remains unlayered. Do not impose a smaller capacity than vanilla. Sounds/events/statistics resolve typed keys; reject unknown owned keys, non-finite volume/pitch/positions and out-of-range values rather than clamping malicious data. Proposed sound bounds are volume 0..=1 and pitch 0..=2, preserving these vanilla interactions. Use existing codec/NBT limits for stacks; cap plan at one exchange, block, sound and fluid event, and up to two statistics, with a separate lower-fill event. Finite host-write/nesting budgets must refuse atomically, never truncate valid vanilla work.
Contain native unwinding and wasm traps at callback dispatch, abort the complete action, and apply the runtime's reporting/quarantine policy. A native process abort cannot be recovered. A failing callback must leave the next built-in interaction usable.
This docs-only change has zero runtime cost. Future unused custom dispatch is one indexed (block, item) lookup after the existing constant dye-tag membership test, with no all-mod scan, allocation or Host construction for a miss. Resolve keys, tag membership and register-to-all at preparation. A custom hit pays for one selected handler and bounded plan plus actual journaled writes. Both prediction paths share that indexed dispatcher. Measure miss allocations and callback count before claiming the hot-path requirement complete.
Acceptance matrix for implementation
Vanilla isUnderWater checks the fluid above against FluidTags.WATER; the current game.rs:12350 and remote prediction use an exact water block check. This known discrepancy needs a waterlogged-above oracle case during integration; it is not silently treated as established parity. The immediate adapter also exposes one custom stat while vanilla transfers award ITEM_USED as well: preserve both through the plan and audit the surrounding item-use statistic owner to avoid double awarding.
These are required tests with literal expectations, not checks run by this note. Run on native and wasm and through integrated, LAN, dedicated and fake-player authority routes where applicable.
| Case | Expected values |
|---|---|
| Survival: empty minecraft:cauldron at (0,64,0), test:syrup_bottle count 2, inventory has space | Exactly one selected handler; hand count 1; test:syrup_cauldron level=1; one minecraft:glass_bottle inserted; exactly one authoritative minecraft:item.bottle.empty sound and one minecraft:fluid_place event; no drop. Fixture registers the filled block's integer level 1..3. |
| Same, then transaction refusal or enclosing abort | Empty original block; syrup count 2; no returned glass bottle, drop, stat, committed sound/event or block publication. A committed child cannot escape outer abort. |
| Same with full inventory / last bottle | Full inventory: one glass bottle drop only on root success. Last bottle: hand becomes one glass bottle. Abort restores the initial hand and publishes zero drops. Creative: original hand count remains 2, helper deduplicates returned bottle. |
| Prediction then confirmation | One server handler/application, count 1 and one authoritative sound/event; local sound heard once after reconciliation. Native and wasm return the same Success. Refusal corrects to empty/count 2. |
| Water potion, water cauldron level=1 | level=2, one potion exchanged for glass bottle with existing container rules, one bottle-empty sound and FLUID_PLACE. Full level=3 or non-water potion: Pass, no changes. |
| Dye-tag item also has exact item entry | With DYED_COLOR: only washing, dye removed and water 2→1. Without DYED_COLOR: Pass, exact handler count 0. No change to tag priority after reload. |
| Vanilla buckets and washing collections | All four maps and every color: water/snow extraction only at 3, lava always; underwater lava/snow Consume with unchanged stack/block; unpatterned banner Pass; patterned banner removes last pattern; dyed shulker becomes uncolored preserving contents. |
| Duplicate (minecraft:water_cauldron,test:syrup_bottle), unknown ItemKey, unsupported Wire capability | Duplicate / UnknownItem / UnsupportedCapability respectively; no partial snapshot publication, identical native/wasm diagnostic category. |
| Reload/world close between decision and apply | Stale refusal, no changes/effects, no retargeted handle or callback to unloaded code; failed preparation leaves old table intact. |
| Reentry, panic/trap, invalid count/state/NaN, journal budget exceeded | Whole action refuses, zero partial effects; subsequent vanilla water bottle still raises level 1→2. |
| Save requested inside action, including lifted pass | Refused; zero tentative state persisted, original holder identity survives abort. |
| No custom entry | Existing vanilla result/inventory/event order unchanged; zero mod callbacks and zero custom-dispatch allocations. |
Proposed stages and open questions
File no issues from this note. First obtain exact public 26.3 event/side semantics and approve this contract with the #2835 and #2697 owners. Second add API-owned vocabulary/Wire records, registration validation and one prepared native/wasm snapshot. Third integrate the shared dispatcher, both prediction paths and mandatory action transaction without growing oversized files. Fourth add real test-mod fixtures, adversarial rollback/reload tests and vanilla oracle coverage for every map/tag branch; then measure the miss path. None of these stages is implemented here.
- Counterpart scope: does public NeoForge 26.3 require arbitrary tag registrations, override, or different event phases/sides? Recommendation: obtain its public contract before freezing names; retain exact-item admission and vanilla tag precedence until then. Do not infer semantics from its implementation.
- Budgets: which shared limits cover additional Host writes and nesting without refusing legitimate vanilla interactions? Recommendation: use existing runtime/codec budgets where sufficient and pin the largest vanilla washing/container fixture before approving numeric journal limits.
- Prediction authority: are read-only custom decisions on both sides acceptable when hidden server state affects eligibility? Recommendation: let the server correct predictions; offer explicit server-only prediction suppression using the same selected entry, never an alternate item-use route. Confirm the missing-client-resource handshake behavior before code.