Design: resource-backed special item renderers
Provisional answer to #876: what resource-backed baked geometry and typed stack-derived state may a special renderer submit? Root must review this design before code. This page implements no API. Geometry belongs in resources; callbacks select and pose baked parts only where ordinary item-model properties cannot express the behavior.
From the modding book of arismoko/horizonbfbda6a
On this page
Reference and boundary
Vanilla 26.3 SpecialModelRenderers.bootstrap registers type identifiers and unbaked codecs. SpecialModelWrapper.update extracts an argument from the stack, creates a layer, records extents, transform and foil, and installs the special renderer plus argument. Verified using tools/rea_vanilla.py method for both methods from /workspace/horizon. The callers query for the wrapper's update returned zero direct references; interface dispatch means that is not a complete context census. Horizon routes are listed below instead.
The named NeoForge counterpart is RegisterSpecialModelRendererEvent. Its 26.3 public Javadoc was inaccessible during this investigation. Public client-item documentation describes stack state preparation and submission, but currently labels itself 26.1. Exact 26.3 registration timing, cancellation, codec shape and capability limits remain unverified. Require public 26.3 documentation before adopting those details; the contract below is proposed Horizon policy, not asserted parity. No foreign renderer source was read.
level-extracted-state.md is absent from arismoko/main at this worktree's base ff35cdee95. PR #2357 is an adjacent proposal only; this design neither copies it nor requires its contents.
Proposed registration, bake and preparation
One register_special_model_renderer client registration moment takes a ClientHost and a typed registrar. Dependency-ordered listeners register a namespaced type, bounded resource schema, declared stack-component inputs and one preparation callback. Duplicate type ownership refuses registration with both owners named; vanilla built-in identifiers cannot be replaced. Resolve identifiers and callback slots at load/bake, not on every draw. No second event bus or compatibility alias is introduced.
API-owned SpecialRendererRegistrationWire, SpecialResourceSchemaWire, SpecialBakeRecipeWire, SpecialStackInputWire, SpecialDrawContextWire, SpecialDrawListWire and SpecialPartInstanceWire define explicit integer widths, enum tags and lengths. Generated native and wasm adapters share these representations and validation; game JSON values, Rust engine structs, GPU pointers and borrowed live stacks never cross the boundary. Wire is an API boundary, not a network synchronization format.
Resources declare named geometry parts, texture/material references, local transforms and conservative extents. Reuse existing model loading/baking and prepared geometry ownership. Bake once per resource reload; callbacks cannot create geometry, compile shaders, read arbitrary files or recursively render items. UnsupportedSpecialRendererCapability refuses registration requesting raw pipelines, live world access, arbitrary codecs, recursive item draws or undeclared stack inputs. Unknown special types produce UnknownSpecialRendererType with resource path and type, replacing the foreign base-model workaround. This refusal applies only to special-model type decoding; unrelated unknown identifiers keep their existing behavior. Built-in specials retain their existing renderer paths. A failed candidate resource reload leaves the previous complete resource set active; initial failure uses the ordinary missing-model diagnostic rather than silently drawing base.
Client-lib decides preparation input; game fires the registered callback with ClientHost and a typed drawing context; client-lib validates and applies in one transaction. Input is an immutable copy of declared component values, display context, owner snapshot, deterministic seed, pose, packed light and foil policy. The drawing context records instances of this recipe's baked parts with local poses and vanilla-space tint. It exposes no device, frame format or live entity. The output is an immutable draw list stored in the ordinary item render state; GUI and world consumers execute it without calling the mod again. Engine display transforms, clipping, lighting, glint, sorting and bounds remain authoritative. Selected parts' transformed bounds must fit declared conservative extents.
Apply rechecks the captured stack revision, resource holder and mod lifetime; changed preconditions refuse the prepared result, never recompute within apply. A later ordinary preparation may retry. Preparation writes and staged draws roll back together. Inside lifted passes, publication, retirement and journal emission wait for root commit; rollback publishes nothing. Saves are refused during an open action savepoint under the existing transaction rule, even though rendering itself adds no persistent state.
Lifetime and colour
Use the existing resource lifecycle holder and its identity for baked references and leases, with resolved part slots. Do not add a special-renderer generation counter. Reload builds a complete candidate, validates/bakes it, then publishes atomically at root commit. Old queued states hold leases until submission ends; they cannot publish into the replacement holder. Retire resources after leases drain. Unload stops new callbacks, drains outstanding callback/draw leases, then unloads native code or wasm stores. Device recreation rebuilds engine GPU objects from resource data without exposing their identity to mods.
Follow mod render colour. Packed ARGB tint and light 0..=15 remain vanilla-space inputs, converted once by the engine. Shader colours are linear BT.709, paper white 1.0, values above 1 allowed. Generated entry/exit boundaries own storage conversion. Texture data channels stay data; coverage does not grant ownership of frame alpha. Shader-pack and SDR/HDR changes preserve this contract and cannot make a callback depend on the frame format.
Current route owners
Verified against base ff35cdee95; paths are relative to crates/. These are integration owners, not claims that the proposed callback already exists.
Decode / foreign workaround
- File:line
horizon-assets/,src/ item_ model/ special.rs:82 :90,:103- Required behavior
- Resolve registered types; report unknown special types
Resource parts and baked extents
- File:line
horizon-client/,src/ item_ render/ special.rs:51 :307;horizon-client/,src/ item_ render.rs:748 :841- Required behavior
- Bake resource recipe and conservative bounds
Shared stack preparation, hands
- File:line
horizon-client/,src/ game/ interface/ item_ draw.rs:376 :398- Required behavior
- Freeze declared inputs and prepare once per resolution
Inventory, cursor and other GUI icons
- File:line
horizon-client/;src/ game/ interface/ inventory_ slots.rs:6 horizon-client/;src/ game/ interface/ item_ draw.rs:455 horizon-client/src/ item_ render/ gui.rs:180 - Required behavior
- Same prepared parts, GUI clipping and lighting
Dropped/ground items
- File:line
horizon-client/,src/ game/ interface/ item_ draw.rs:705 :789,:793- Required behavior
- Copy poses reuse state on GPU and CPU paths
Frames and armor-stand equipment
- File:line
horizon-client/;src/ game/ entities/ frames.rs:749 horizon-client/,src/ game/ entities/ armor_ stands.rs:1786 :1847- Required behavior
- Fixed/head/hand contexts use same preparation
Other world item consumers
- File:line
horizon-client/;src/ game/ blocks/ campfire.rs:172 blocks/,vault.rs:250 blocks/,shelf.rs:230 blocks/under the samebrush.rs:347 game/directory- Required behavior
- Preserve context and engine lighting
Entity/effect and mod-submitted items
- File:line
horizon-client/,src/ game/ items/ potions.rs:2219 items/,totem.rs:224 items/;item_ particles.rs:18 horizon-client/src/ game/ modding/ native_ block_ entity_ draw.rs:1237 - Required behavior
- Share resolver, no recursive callback escape
CPU special submission / GPU submission
- File:line
horizon-client/,src/ item_ render.rs:267 :299;horizon-client/;src/ item_ render/ special.rs:466 horizon-client/src/ game/ interface/ item_ draw.rs:556 - Required behavior
- Consume identical frozen draw list
Reload and GPU resource ownership
- File:line
horizon-client/;src/ game.rs:14259 horizon-client-render/src/ gfx/ prepared_ model.rs:256 - Required behavior
- Rebuild affected item states and retain leased resources
Native and wasm need generated catalog registration, dispatch and callback exports in the eventual implementation; neither adapter exists for this feature today. Both must enter the same game preparation boundary and enforce identical failures. The implementation review must trace first/third-person callers through the shared hand resolver and reload publication through its full owner chain before claiming exhaustive integration.
Untrusted inputs and cost
Proposed extension budgets: 256 parts per recipe, 65536 vertices per part, 16 MiB decoded geometry per recipe, 64 KiB resource parameters and 16 KiB copied stack inputs, 256 instances and 64 KiB encoded draw output per preparation. Use checked arithmetic before allocation; bound encoded and decoded sizes, resource nesting to 32 and identifiers to 256 UTF-8 bytes. Reject invalid tags, indices, foreign/stale part slots and dependency cycles. Transforms and extents must be finite, local translation magnitude at most 1024 and scale magnitude at most 64; zero scale is valid. These extension budgets do not cap vanilla's own models, stack components or draw capacity. Oversized declared stack input refuses this extension preparation without truncating the actual stack.
Catch unwind-capable native callback panics; wasm traps and fuel exhaustion have the same policy: discard the entire draw list and callback transaction, diagnose once and disable the registration for the holder lifetime. Draw the missing-model marker for affected custom layers, retaining independent built-in layers. A later built-in item still renders normally. Native aborts cannot be caught and native callbacks cannot safely be forcibly preempted; wasm gets finite execution fuel. Reject an invalid command atomically, never retain a partially accepted list.
With no registrations, retain the existing built-in branch: one O(1) resolved variant test, zero extension allocations, serialization or listener scans. A registered draw dispatches directly by resolved slot, copies only declared inputs and records bounded commands. GPU/CPU submission has no callback or mod scan. These are performance acceptance requirements, not measured results.
Acceptance matrix
Future implementation tests use original assets, not third-party model files.
| Case | Concrete expected result |
|---|---|
| Original cube A and two separated cubes B; boolean component false/true | Exactly 6/12 resource quads selected in both inventory and hand |
| Same true stack in ground, frame, head and other-player hand | B selected in all four; engine context transforms differ |
| Resource reload changes B to three cubes | True stack selects 18 quads after commit; 0 new draws reference old holder |
| Reload fails with unknown special type | Named error; previous holder remains active; initial load displays missing model |
| Queue old state, reload, drain | Old lease valid until drain; retirement occurs once after root commit |
| Rollback lifted preparation/reload | 0 published draws, retirements or journal entries |
| Save during action savepoint | Refused; no persisted partial state |
| NaN transform, 257 instances, stale part, oversized input | Entire custom layer refused; 0 partial instances |
| Native panic / wasm trap followed by vanilla cube | Custom layer missing marker; vanilla cube still 6 quads |
| Unsupported raw-pipeline capability / duplicate registration | Named registration refusal; no partial type installation |
| White 1, linear RGB 2, encoded tint 0.5 | Shader values 1, 2, approximately 0.21763764; frame alpha engine-owned |
| Native and wasm, same inputs | Identical part slots, transforms, tint and refusal outcomes |
| No listener, repeated built-in scenes | Identical baseline output; 0 extension callbacks/allocations |
Proposed stages (file none)
- Review this contract and obtain public NeoForge 26.3 registration evidence.
- Finalize API-owned Wire schemas, generated native/wasm boundary and refusal diagnostics; connect resource decode/bake to existing lifecycle ownership.
- Integrate shared stack preparation and both submission paths, transactional reload/teardown and colour rules; trace all caller routes above.
- Add original-asset acceptance scenes, failure injection and empty-registration counters, then measure cost. No implementation files are proposed in this note.
Open questions
- Exact NeoForge 26.3 registration contract is unverified. Recommendation: require public 26.3 docs/Javadoc before locking the counterpart's signature and timing; do not infer it from older docs or source.
- R2 prepared-part API and practical extension budgets need implementation review. Recommendation: use the existing lifecycle holder with the budgets above, test representative original scenes, and enlarge only extension limits with measured evidence; preserve vanilla capacity throughout.
Edit this page on GitHub · Spotted something wrong? Open an issue .