Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Design: Read-only typed loot context

Provisional proposal for #2647. Docs only; approval precedes code. Broaden the existing LootContext snapshot at the condition/function evaluation boundary. Do not provide a mutable Host, live world access, or another loot hook. #2648 owns the narrow tags view. PR #2595 owns LevelRef::roll_loot and its FISHING-shaped input; this proposal neither expands that input nor changes random ownership.

Evidence and current routes

The public NeoForge loot-table documentation describes typed ContextKey<T>, presence tests, required/optional parameter sets, and unregistered custom keys. Its rolling context also contains level, random, luck and dynamic drops. These are distinct from parameters. The page is not pinned to NeoForge 26.3: NeoForge-specific 26.3 behavior is unverified, including custom-key registration and exact set membership. The owner-supplied read list is a later review gate for the subset below.

Vanilla 26.3 bytecode can be inspected with python3 tools/rea_vanilla.py class net.minecraft.world.level.storage.loot.parameters.LootContextParams and the corresponding LootContextParamSets class. All fifteen requested constant names exist. Important type details: TOOL is ItemInstance, ADDITIONAL_COST_COMPONENT_ALLOWED is Unit, not boolean, and vanilla additionally exposes CONTAINER (SlotProvider), absent from the public page's list. Field inspection confirms names and types, not required/optional membership. The set memberships below follow vanilla's parameter set declarations for the sets named; membership of the remaining sets is verified in stage one.

Verified repository-relative owners:

RouteOwner
Existing API snapshot and nested Wire valuescrates/horizon-mod-vocab/src/world/snapshots.rs:104, :129, :140, :174, :187, :199
Condition/function catalog, authoritative servercrates/horizon-mod-vocab/src/hooks/loot_condition.rs:9, loot_function.rs:10
Guarded conversion and dispatch, immutable contextcrates/horizon-client/src/game/modding/api_values/loot_values.rs:7, :24, :174
Engine callbacks and borrowed contextcrates/horizon-world/src/loot.rs:89, :103, :120; forwarding crates/horizon-world/src/native_load_hooks.rs:40, :51
World evaluator: custom condition, context capture, custom functioncrates/horizon-world/src/loot.rs:1583, :1613, :2107
Items evaluator: context capture, custom condition/functioncrates/horizon-items/src/items.rs:2049, :2234, :2627
Place scope shared by both evaluatorscrates/horizon-world/src/loot.rs:187, :200
Existing typed block valuecrates/horizon-mod-vocab/src/block/block_state.rs:9

Both evaluator routes must carry the same typed slots through the common boundary. Blocks, containers, fishing and command-driven item rolls reach the items evaluator; entity drops and world loot reach the world evaluator. Caller-specific context must be carried explicitly, never reconstructed from whichever Game fields happen to be selected. Before implementation, enumerate gameplay producers and prove their parameter-set selection with fixtures; these evaluation owners do not establish complete producer coverage for interaction/enchantment contexts currently missing from the snapshot.

Parameter audit and proposed subset

R means required in the named set; O means allowed but optional. A parameter is forbidden in sets that do not allow it; generic validation is not permission to invent values. The examples below describe the proposed initial supported sets, BLOCK, ENTITY and FISHING, plus the public-doc families relevant to deferred keys. Exact remaining set membership is a stage-one verification prerequisite, not inferred from a parameter's name.

ParameterSnapshot todayProposed API-owned typed formSet semanticswasm
this_entityentity: Option<LootEntity> predicate factsLootEntityR ENTITY; O BLOCK/FISHINGExisting facts cross; no live entity
interacting_entityMissingLootEntityO ENTITY_INTERACT/BLOCK_INTERACT; absent initial setsDeferred capture, then Wire
target_entityMissingLootEntityR ENTITY_INTERACT (which also requires tool); absent initial setsDeferred capture, then Wire
last_damage_playerplayer predicate factsLootEntity constrained to playerO ENTITYExisting facts cross
damage_sourcedamage: Option<LootDamage>LootDamageR ENTITYExisting facts cross; no live DamageSource
attacking_entityNested damage.source, not independent presenceLootEntity independent slotO ENTITY; not allowed in FISHINGFacts cross; independent capture needed
direct_attacking_entityNested damage.direct, not independent presenceLootEntity independent slotO ENTITYFacts cross; independent capture needed
originplace.origin[f64; 3]R BLOCK/ENTITY/FISHINGAlready crosses
block_stateblock identifier only; loses propertiesExisting BlockStateR BLOCKWire exists; producer conversion missing
block_entityplace.block_entity: Option<Tag> saved metadataExisting API Tag snapshotO BLOCKAlready crosses as NBT; live object does not
toolLootTool: item and enchantments onlyExisting LootStack snapshot of instanceR BLOCK/FISHING, including empty instanceWire exists; full instance capture missing
explosion_radiusMissing even though items evaluator has itf32O BLOCKDeferred today; proposed initial subset
enchantment_levelMissingi32R enchantment sets and HIT_BLOCK; absent initial setsDeferred producer, scalar is wireable
enchantment_activeMissingboolR ENCHANTED_LOCATION; absent initial setsDeferred producer, scalar is wireable
additional_cost_component_allowedMissing() presence tokenR VILLAGER_TRADE; absent initial setsDeferred producer; never encode as false
container (additional vanilla key)MissingNo suitable snapshot chosenR CONTAINER_PROCESS (with block_entity, block_state and origin)Unsupported: live SlotProvider has no read-only API projection

These are deliberately snapshots, not claims of full Java object equivalence. Entity properties unavailable in LootEntity, arbitrary entity methods, block-entity methods, live inventory access and damage-source methods cannot cross today: the bridge has only the listed captured Wire facts and no safe read backend during split level work. Native receives exactly the same subset. Do not expose an extra native pointer. Existing entity handles, if added later, must use the existing lifecycle holder identity with no second generation; identity alone grants no reads.

Build first: independent this/attacker/direct/player facts, damage facts, origin, typed block state, saved block-entity Tag, full tool snapshot and explosion radius for BLOCK/ENTITY/FISHING. Retain luck, biome, seed and captured level facts as context metadata, not fabricated ContextKeys. Keep the engine's captured seed convention; do not expose its mutable random or consume extra draws for new slots. Interaction, enchantment, villager-trade and container producers are later work, pending verification and real-read demand.

Typed view and registration

Extend the existing API context in one clean break; remove renamed/redundant fields and update every caller, generated boundary, sample and catalog together. No parallel legacy context. Proposed LootContext::get(&LootKey<T>) -> Result<Option<&T>, LootContextReadError> and require distinguish absent optional (None), disallowed (ParameterNotAllowed), undeclared (NotDeclared) and unsupported (UnsupportedLootParameter). A read requires declaration, as vanilla validates the parameters a function references against the table's parameter set: an undeclared read answers the typed NotDeclared error, never the value and never a None that looks like an absent optional. Missing required values refuse evaluation as MissingRequiredLootParameter before invoking a mod. A typed key cannot be constructed with an arbitrary conflicting T. Presence is explicit: an empty required tool is present, zero radius and false enchantment activity are present, and Unit presence differs from absence.

LootContext, proposed key/set descriptors, error enums and every new crossing snapshot explicitly use #[derive(Wire)] (the repository macro is horizon_mod_api_macros::Wire). Existing LootEntity, LootDamage, LootStack, LootPlace, LootLevel already derive it; BlockState also derives it. Use the existing API Tag representation and shared NBT codec. No engine types, Java objects, serde values as typed parameter payloads, arbitrary Any, or manual per-target encoding. Generate and validate native/wasm dispatch together.

Mods declare every parameter they read, required or optional, when registering a loot kind; a read outside the declaration is NotDeclared. Unknown built-in keys or deferred keys refuse registration with proposed UnsupportedLootParameter { key }; unsupported set families refuse as UnsupportedLootParameterSet { set }. A kind's required reads must be allowed by its declared sets, otherwise LootParameterSetMismatch. Optional reads may be absent, but unsupported capability must never masquerade as absence.

Defer custom keys and custom sets in this first build. Proposed UnsupportedCustomLootKey and UnsupportedCustomLootParameterSet are load-time refusals on both targets. Later, permit namespaced load-time declarations with a fixed API-owned Wire schema, unique owner, duplicate/type-conflict refusals, and explicit producer binding plus required/allowed set descriptors. Freeze resolved numeric slots before world use; no runtime string-map registration. The public docs' unregistered Java keys do not establish a safe cross-process type contract for Horizon.

Safety, cost and action boundaries

This view takes no mutable Host and cannot mutate world, save, run dynamic drops or recursively roll loot. Existing hooks remain their current moments; this is a value contract extension. Capture on the authoritative server before entering mod code and release engine borrows. Owned snapshots remain stable across nested evaluation. Scope restoration must hold on unwind.

Treat declarations, JSON and function output as untrusted. Resolve key/set identifiers at registration; reject type conflicts and invalid registry keys. Reuse shared Wire frame, allocation and NBT budgets cumulatively across the entire context, including equipment and vehicle chains; NBT container depth may reach vanilla's 512 limit, not a new smaller arbitrary cap. Before code, verify shared byte limits against largest legal vanilla instances and raise transport capacity where needed. Reject oversized frames explicitly as proposed LootContextBudgetExceeded, never truncate facts or report missing optional data. Origin/radius must be finite; radius nonnegative. Preserve signed i32 enchantment levels and candidate counts rather than imposing inventory materialization rules here. Function output remains staged and fully validated before one writeback; refused conversion leaves the candidate unchanged.

Proposed failure policy: a caught callback panic or wasm trap discards that callback's staged output, attributes the fault to the mod, and aborts this evaluation with a diagnostic; never partially apply it. Do not claim current runtime containment merely because dispatch tracks attribution. Condition defaults and function output must not turn a malformed required context into a successful roll. No new writes, retirement or journals originate in the view. Existing enclosing actions retain root-commit journal/retirement publication inside lifted passes and must refuse saves while an action savepoint is open.

No listener: retain the existing constant-time subscription guard before conversion; no parameter map allocation, serialization, NBT copy or additional RNG draw. With listeners, capture cost is proportional to declared captured values and encoded bytes; fixed resolved slots make reads O(1). Both evaluator guards and their seed-producing callees must be measured, not just the game adapter.

Acceptance matrix

Run every fixture through an actual custom condition and function on native and wasm and compare typed observations, not just codec round trips. Expected values below are proposed producer contracts.

FixtureConcrete expected observations
Adult zombie at (10,64,20), directly killed by player P using iron swordENTITY: this.kind=minecraft:zombie, baby=false; origin=[10,64,20]; player.kind=minecraft:player; attacker and direct attacker both P's captured facts; damage.kind=minecraft:player_attack. TOOL disallowed in ENTITY: do not infer sword from attacker equipment. Block/radius absent/disallowed.
Same zombie killed by P's arrowAttacker=P; direct.kind=minecraft:arrow; damage.kind=minecraft:arrow; last_damage_player=P.
Zombie falls without player attributionRequired this, origin and damage (minecraft:fall) present; player/attacker/direct absent.
Stone at (2,70,4), bare-hand block rollBLOCK origin=[2.5,70.5,4.5], block_state resolves minecraft:stone, TOOL present as empty instance; block_entity/radius absent.
Same block with explosion decay radius 4Radius=Some(4.0), never absent or zero substitution.
Broken named containerSaved block-entity Tag equals pre-removal metadata including custom name; subsequent removal does not alter snapshot.
FISHING roll with explicit stick and no entityOrigin equals supplied coordinates; TOOL item=minecraft:stick, count=1 and exact components; this absent. PR #2595 retains ownership of roll API semantics.
Deferred enchantment/trade/custom key registrationExact UnsupportedLootParameter, UnsupportedLootParameterSet, or custom refusal; identical native/wasm result before evaluation.
Missing BLOCK state / false / UnitMissing required state refuses before callback; future false bool returns Some(false); future trade token returns Some(()) versus None.
Malformed and excessive inputsNaN/infinite origin, negative radius, oversized nested data refuse without callback or partial output; legal depth-512 data and largest vanilla fixture fit shared bounds.
Panic/trap; nested roll; no listenersNo partial candidate output; outer snapshot unchanged after nested scope; no-listener output and RNG sequence exactly match baseline with zero context conversions.

Proposed build stages

  1. Verify exact vanilla required/allowed sets using REA initializer/lambda evidence; enumerate every gameplay producer of both evaluators. Reconcile the owner-supplied read list. File none in this docs-only change.
  2. Build the named BLOCK/ENTITY/FISHING subset and typed capability registration, replace obsolete snapshot fields, and regenerate both targets and crossing coverage in one PR. Add boundary fixtures before broadening producers.
  3. Implement deferred interaction/enchantment/trade projections only with verified producer routes and matching native/wasm fixtures. Design container and custom-key schemas separately before enabling registration.

Open questions

  • Which additional facts do the owner-supplied reads actually require? Recommendation: retain the initial subset until the owner-supplied read list names its parameters and types; add only API-owned snapshots justified by those reads.
  • What read-only projection should CONTAINER expose? Recommendation: defer rather than grant mutable slot authority or pretend a live SlotProvider is a serializable value.
  • Which shared byte budget accommodates maximum legal tool/block-entity snapshots? Recommendation: establish measured vanilla capacity in stage one and reuse/increase the shared budget; do not introduce a smaller loot-only limit.