mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Render each remote hand with its own item stack
Co-Authored-By: Codex GPT-6.1 <[email protected]>
This commit is contained in:
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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user