Render each remote hand with its own item stack

Co-Authored-By: Codex GPT-6.1 <[email protected]>
This commit is contained in:
oskal675-designandCodex GPT-6.1 committed 2026-10-31 17:01:21 +05:00
1 parent 3c68c05d0a
commit cf8ed9d8c3
6 files changed
+186 -4

No files matched your search

@@ -0,0 +1,38 @@
//! The stacks passed to item models for an entity's two arms.
use horizon_client::{
Result,
items::ItemStack,
mobs::{Extra, render},
};
use serde_json::Value;
use std::{
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
};
pub fn main() -> Result<()> {
let args: Vec<String> = crate::args().collect();
let mut out = BufWriter::new(File::create(&args[2])?);
for line in BufReader::new(File::open(&args[1])?).lines() {
let case: Value = serde_json::from_str(&line?)?;
let mut extra = Extra::default();
let main = case["main"].as_str().ok_or("main")?;
if main != "air" {
extra.set_main_hand_stack(Some(ItemStack::new(&format!("minecraft:{main}"), 1)));
}
let off = case["off"].as_str().ok_or("off")?;
if off != "air" {
extra.off_hand = Some(format!("minecraft:{off}").into());
}
let left = case["left"].as_bool().ok_or("left")?;
for physical_left in [false, true] {
let stack = render::held_item_stack(&extra, physical_left != left);
writeln!(
out,
"{}",
stack.as_ref().map_or("minecraft:air", |s| &s.item)
)?;
}
}
Ok(())
}
@@ -48,6 +48,7 @@ mod frame_limiter_probe;
mod game_loop_probe;
mod ghost_recipe_fixture;
mod held_drop_fixture;
mod held_item_stack_probe;
mod hotbar_fixture;
mod hud_fixture;
mod input_probe;
@@ -144,6 +145,7 @@ probes! {
game_loop_probe,
ghost_recipe_fixture,
held_drop_fixture,
held_item_stack_probe,
hotbar_fixture,
hud_fixture,
input_probe,
+4 -4
View File
@@ -431,10 +431,10 @@ impl Game<'_> {
// The stack the equipment packet (or the mob's own AI) left:
// its components are what the item model reads, a loaded
// crossbow's `charged_projectiles` included.
mob.extra
.main_hand_stack()
.cloned()
.unwrap_or_else(|| ItemStack::new(item, 1))
let Some(stack) = render::held_item_stack(&mob.extra, off_hand) else {
continue;
};
stack
};
if !local_player && horizon_client::mobs::gear::enchanted(mob, slot) {
// The gear's enchantments are not tracked, only that it has some.
+40
View File
@@ -2123,10 +2123,50 @@ pub fn held_item_transform(
Some(t)
}
/// `ArmedEntityRenderState.extractArmedEntityRenderState`: each arm's item
/// model reads the stack held by that arm. Only the main hand currently
/// retains a full stack; the off hand must not inherit its item or components.
pub fn held_item_stack(extra: &super::Extra, off_hand: bool) -> Option<crate::items::ItemStack> {
let item = if off_hand {
extra.off_hand.as_ref()?
} else {
if let Some(stack) = extra.main_hand_stack() {
return Some(stack.clone());
}
extra.main_hand.as_ref()?
};
Some(crate::items::ItemStack::new(item, 1))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn offhand_does_not_inherit_mainhand_components() {
use crate::items::ItemStack;
let mut extra = super::super::Extra::default();
let mut main = ItemStack::new("minecraft:crossbow", 1);
main.data_mut().custom_name = Some(horizon_world::nbt::Tag::String("Main hand".into()));
extra.set_main_hand_stack(Some(main));
extra.off_hand = Some("minecraft:crossbow".into());
assert_eq!(
held_item_stack(&extra, false)
.unwrap()
.custom_name()
.as_deref(),
Some("Main hand")
);
assert!(
held_item_stack(&extra, true)
.unwrap()
.custom_name()
.is_none()
);
extra.off_hand = None;
assert!(held_item_stack(&extra, true).is_none());
}
/// The elytra's flight yaw (`AvatarRenderer.extractFlightData`) lerps
/// the velocity of the previous tick towards this tick's, so the model
/// does not step once per tick while the view turns (#4104).
@@ -0,0 +1,67 @@
package dev.horizonlab.parity;
import com.google.gson.JsonParser;
import java.nio.file.Files;
import java.nio.file.Path;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.client.renderer.entity.state.ArmedEntityRenderState;
import net.minecraft.client.renderer.item.ItemModelResolver;
import net.minecraft.client.renderer.item.ItemStackRenderState;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.HumanoidArm;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.item.ItemDisplayContext;
import net.minecraft.world.item.ItemStack;
/** Record the actual stacks the shipped extraction method passes to the model resolver. */
public final class VanillaHeldItemStackOracle {
static final class Player extends LocalPlayer {
ItemStack main, off;
HumanoidArm arm;
Player() { super(null, null, null, null, null, null, false, null, null); }
@Override public HumanoidArm getMainArm() { return arm; }
@Override public ItemStack getMainHandItem() { return main; }
@Override public ItemStack getOffhandItem() { return off; }
@Override public ItemStack getItemInHand(InteractionHand hand) {
return hand == InteractionHand.MAIN_HAND ? main : off;
}
@Override public LivingEntity.SwingDescription getCurrentSwing() { return null; }
@Override public float getSwingAnimation(float partial) { return 0; }
}
static final class Resolver extends ItemModelResolver {
final java.io.PrintWriter out;
Resolver(java.io.PrintWriter out) { super(null); this.out = out; }
@Override public void updateForLiving(ItemStackRenderState state, ItemStack stack,
ItemDisplayContext context, LivingEntity entity) {
out.println(BuiltInRegistries.ITEM.getKey(stack.getItem()));
}
}
static ItemStack stack(String id) {
return new ItemStack(BuiltInRegistries.ITEM.getValue(Identifier.parse("minecraft:" + id)));
}
public static void main(String[] args) throws Exception {
net.minecraft.SharedConstants.tryDetectVersion();
net.minecraft.server.Bootstrap.bootStrap();
for (var item : BuiltInRegistries.ITEM) {
item.builtInRegistryHolder().bindComponents(net.minecraft.core.component.DataComponentMap.EMPTY);
}
var allocator = Class.forName("sun.misc.Unsafe");
var field = allocator.getDeclaredField("theUnsafe");
field.setAccessible(true);
var player = (Player) allocator.getMethod("allocateInstance", Class.class)
.invoke(field.get(null), Player.class);
try (var out = new java.io.PrintWriter(Files.newBufferedWriter(Path.of(args[1])))) {
var resolver = new Resolver(out);
for (var line : Files.readAllLines(Path.of(args[0]))) {
var c = JsonParser.parseString(line).getAsJsonObject();
player.main = stack(c.get("main").getAsString());
player.off = stack(c.get("off").getAsString());
player.arm = c.get("left").getAsBoolean() ? HumanoidArm.LEFT : HumanoidArm.RIGHT;
ArmedEntityRenderState.extractArmedEntityRenderState(
player, new ArmedEntityRenderState(), resolver, 0);
}
}
}
}
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env python3
"""Each arm resolves its own held item, including left-handed entities (#5049)."""
import itertools
import json
try:
from . import lab, oracle_harness as harness
except ImportError:
import lab
import oracle_harness as harness
def make_input(build):
items = ['air', 'diamond_sword', 'totem_of_undying', 'shield', 'crossbow']
cases = [dict(main=main, off=off, left=left)
for main, off, left in itertools.product(items, items, [False, True])]
path = build / 'cases.jsonl'
path.write_text(''.join(json.dumps(case) + '\n' for case in cases))
return path, len(cases)
def main():
harness.differential(
'held-item-stack-parity', 'VanillaHeldItemStackOracle', 'held_item_stack_probe',
make_input, 'Actual ArmedEntityRenderState extraction passes each arm its own item; '
'model resolution is recorded, not rendered. Covers empty hands and both main arms. '
'Off-hand components are outside this identity-only regression.',
sources=[lab.ROOT / 'tools/test_held_item_stack_parity.py',
lab.ROOT / 'crates/horizon-client/src/mobs/render.rs',
lab.ROOT / 'crates/horizon-client/src/mobs.rs',
lab.ROOT / 'crates/horizon-client/src/game/mob_blocks.rs'], expected_lines=100)
if __name__ == '__main__':
main()