Render enderman carried block states with vanilla tints

Use the baked block-state model instead of the block item's model so flowers,
fungi and roots keep their cross geometry and state properties survive. Remove
the item-centering compensation while retaining the vanilla carry transform.

Apply BlockTintSource.color(state) to tinted block-model faces using the active
grass colormap and vanilla default foliage colours. Keep in-world sugar cane
and lily pad colours unchanged. Use BlockModelResolver's fixed model seed 42.

Add a differential adapter checking 6,616 state/tint-index combinations against
Minecraft 26.3 and a scratch-world offscreen screenshot driver. The existing
captured-vertex transform regression now checks block coordinates directly.

Implemented by OpenAI Codex on behalf of NetuosoDEV. NetuosoDEV manually tested
the fix in game and confirmed it works.

Co-Authored-By: OpenAI Codex <[email protected]>
This commit is contained in:
NetuosoDEVandOpenAI Codex committed 2026-10-29 16:23:20 +02:00
1 parent 818a2ec52a
commit d3e2fe8bf3
8 files changed
+271 -31

No files matched your search

@@ -0,0 +1,37 @@
//! Block-model tints without a level, including blank and invalid tint indices.
use horizon_client::{
assets::Assets, blocks::BlockFacts, item_render::ItemRenderer, mesher::MeshTables, models,
};
use horizon_world::registry::BlockStateId;
use serde_json::Value;
use std::{
fs,
io::{BufWriter, Write},
path::Path,
};
pub fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = crate::args().collect();
let input: Value = serde_json::from_slice(&fs::read(&args[1])?)?;
let root = Path::new(input["resources"].as_str().unwrap());
let assets = Assets::load(root)?;
let render = assets.reload(root)?;
let facts = BlockFacts::load(root, assets.registry)?;
let tables = MeshTables::new(
&assets,
&render,
&facts,
models::load_visibility(root, assets.registry)?,
)?;
let grass = ItemRenderer::load(root)?.grass_color(0.5, 1.0) as u32;
let mut output = BufWriter::new(fs::File::create(&args[2])?);
for row in input["cases"].as_array().unwrap() {
let state = row[0].as_u64().unwrap() as u32;
let index = row[1].as_i64().unwrap() as i32;
let color = tables
.default_tint(BlockStateId(state), index, grass)
.map_or(-1, |rgb| (0xFF00_0000 | rgb) as i32);
writeln!(output, "{state} {index} {color}")?;
}
Ok(())
}
@@ -19,6 +19,7 @@ mod block_entity_anim_fixture;
mod block_entity_render_fixture;
mod block_entity_tickers_fixture;
mod block_particles_fixture;
mod block_tint_probe;
mod block_transform_fixture;
mod breaking_fixture;
mod camera_probe;
@@ -109,6 +110,7 @@ probes! {
block_particles_fixture,
block_transform_fixture,
breaking_fixture,
block_tint_probe,
camera_probe,
squid_client_probe,
creative_mining_probe,
+15 -1
View File
@@ -1215,7 +1215,7 @@ impl Game<'_> {
}
}
}
/// Baked block faces with terrain shade or block-item diffuse lighting.
/// Baked block-state faces with default block tints and terrain shade or diffuse lighting.
pub(super) fn block_model_vertices(
&self,
state: BlockStateId,
@@ -1250,6 +1250,20 @@ impl Game<'_> {
} else {
0xFF00_0000 | shade << 16 | shade << 8 | shade
};
let tint = self.pack.context.tables.default_tint(
state,
face.quad.tint_index,
self.pack.item_renderer.grass_color(0.5, 1.0) as u32,
);
let color = if let Some(tint) = tint {
let tint = horizon_client::mobs::render::abgr(tint);
let channel = |shift: u32| {
((color >> shift & 255) * (tint >> shift & 255) / 255) << shift
};
0xFF00_0000 | channel(0) | channel(8) | channel(16)
} else {
color
};
let normal = if diffuse {
use horizon_client::item_render::{direction_vector, transform_normal};
let n = transform_normal(m, direction_vector(face.quad.facing));
+7 -21
View File
@@ -1,6 +1,5 @@
//! Blocks mobs carry: block items in a hand (`ItemInHandLayer`) and the
//! block an enderman holds (`CarriedBlockLayer`), drawn through the same
//! item model pipeline as inventory and dropped block items. Also the block
//! block state an enderman holds (`CarriedBlockLayer`). Also the block
//! models mobs wear (`SnowGolemHeadLayer`, `MushroomCowMushroomLayer`).
use super::Game;
use horizon_client::{
@@ -9,8 +8,7 @@ use horizon_client::{
mobs::{Mob, Species, render},
};
/// `CarriedBlockLayer.submit`, adapted from unit-cube block coordinates to
/// the item pipeline, which centers the model by translating (-0.5, -0.5, -0.5).
/// `CarriedBlockLayer.submit`: block models use coordinates in [0, 1].
fn carried_block_matrix(base: glam::Mat4) -> glam::Mat4 {
base * glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.6875, -0.75))
* glam::Mat4::from_rotation_x(20.0f32.to_radians())
@@ -18,8 +16,6 @@ fn carried_block_matrix(base: glam::Mat4) -> glam::Mat4 {
* glam::Mat4::from_translation(glam::Vec3::new(0.25, 0.1875, 0.25))
* glam::Mat4::from_scale(glam::Vec3::new(-0.5, -0.5, 0.5))
* glam::Mat4::from_rotation_y(90.0f32.to_radians())
// Cancel item centering: vanilla submits the block in [0, 1], not [-0.5, 0.5].
* glam::Mat4::from_translation(glam::Vec3::splat(0.5))
}
/// `CustomHeadLayer.translateToHead` and the banner's `head` display.
@@ -169,15 +165,8 @@ impl Game<'_> {
// CarriedBlockLayer: OverlayTexture.NO_OVERLAY, never the hurt tint.
let base = render::base_transform(mob, alpha as f32, camera);
let t = carried_block_matrix(base);
// Resolve the inventory model's textures and per-quad tints.
let stack = ItemStack::new(&self.res.facts().get(state).name, 1);
let item = self.resolve_item(
&stack,
horizon_assets::item_model::DisplayContext::None,
None,
0,
);
self.submit_item_world(&item, t, light, out);
// Preserve cross models (flowers, fungi) and state properties (facing).
self.block_model_vertices(state, 42, t, light, true, out);
}
}
/// `SnowGolemHeadLayer` and `MushroomCowMushroomLayer`: the carved pumpkin
@@ -343,7 +332,7 @@ mod tests {
use glam::{Mat4, Vec3};
#[test]
fn carried_item_vertices_match_vanilla_block_coordinates() {
fn carried_block_vertices_match_vanilla_block_coordinates() {
// Captured from 26.3 CarriedBlockLayer.submit with an identity PoseStack:
// origin, three unit axes, opposite corner, and the block's center.
let samples = [
@@ -354,18 +343,15 @@ mod tests {
(Vec3::ONE, Vec3::new(-0.3535534, 0.3938461, -0.85688126)),
(Vec3::splat(0.5), Vec3::new(0.0, 0.6287693, -0.77137625)),
];
let item_center = horizon_client::item_render::glam_matrix(
&horizon_assets::item_bake::item_transform_matrix(None, false),
);
for base in [
Mat4::IDENTITY,
Mat4::from_translation(Vec3::new(3.0, 2.0, -7.0))
* Mat4::from_rotation_y(1.2)
* Mat4::from_scale(Vec3::new(-1.0, -1.0, 1.0)),
] {
let item = carried_block_matrix(base) * item_center;
let block = carried_block_matrix(base);
for (point, expected) in samples {
let actual = item.transform_point3(point);
let actual = block.transform_point3(point);
let expected = base.transform_point3(expected);
assert!(
actual.abs_diff_eq(expected, 1e-6),
+31 -9
View File
@@ -263,6 +263,8 @@ enum Tint {
/// `BlockTintSources.grassBlock`: grass in the world, none on its particles.
GrassBlock,
TallGrassUpper,
SugarCane,
LilyPad,
Foliage,
DryFoliage,
Water,
@@ -329,6 +331,10 @@ pub struct MeshTables {
lava_still: u16,
lava_flow: u16,
}
fn stem_color(age: u8) -> u32 {
let a = u32::from(age);
(a * 32) << 16 | (255 - a * 8) << 8 | (a * 4)
}
fn redstone_color(power: u8) -> u32 {
// RedstoneWireBlock.COLORS
let f = f32::from(power) / 15.0;
@@ -377,10 +383,9 @@ impl MeshTables {
([Tint::Grass; 2], 1)
}
}
"fern" | "short_grass" | "potted_fern" | "bush" | "sugar_cane" => {
([Tint::Grass; 2], 1)
}
"fern" | "short_grass" | "potted_fern" | "bush" => ([Tint::Grass; 2], 1),
"grass_block" => ([Tint::GrassBlock; 2], 1),
"sugar_cane" => ([Tint::SugarCane; 2], 1),
"pink_petals" | "wildflowers" => ([Tint::None, Tint::Grass], 2),
"spruce_leaves" => ([Tint::Constant(0x61_9961); 2], 1),
"birch_leaves" => ([Tint::Constant(0x80_A755); 2], 1),
@@ -395,7 +400,7 @@ impl MeshTables {
([Tint::Constant(0xE0_C71C); 2], 1)
}
"melon_stem" | "pumpkin_stem" => ([Tint::Stem(prop("age")); 2], 1),
"lily_pad" => ([Tint::Constant(0x20_8030); 2], 1),
"lily_pad" => ([Tint::LilyPad; 2], 1),
_ => ([Tint::None; 2], 0),
};
// getShadeBrightness overrides; the default is 0.2 for full collision shapes.
@@ -491,6 +496,25 @@ impl MeshTables {
tint_sources: i.tint_count,
})
}
/// `BlockTintSource.color(state)`: block-model colour without a level.
/// `grass` is `GrassColor.getDefaultColor()` from the active pack's colormap.
/// Untinted faces, blank sources and invalid tint indices return `None`.
pub fn default_tint(&self, state: BlockStateId, index: i32, grass: u32) -> Option<u32> {
let info = self.info.get(state.0 as usize)?;
if index < 0 || index >= i32::from(info.tint_count) {
return None;
}
Some(match info.tints[index as usize] {
Tint::None | Tint::SugarCane | Tint::Water => return None,
Tint::Grass | Tint::GrassBlock | Tint::TallGrassUpper => grass & 0xFF_FFFF,
Tint::Foliage => 0x48_B518,
Tint::DryFoliage => 0x5C_3C32,
Tint::LilyPad => 0x71_C35C,
Tint::Constant(c) => c,
Tint::Redstone(p) => redstone_color(p),
Tint::Stem(age) => stem_color(age),
})
}
/// `TerrainParticle`'s colour: tint source 0's `colorAsTerrainParticle`,
/// which is the in-world (biome-blended) colour except on grass blocks,
/// whose dirt crumbs stay untinted. `None` when the state has no source.
@@ -838,17 +862,15 @@ impl Mesher<'_> {
}
Some(match state.tints[(index as usize).min(1)] {
Tint::None => return None,
Tint::Grass | Tint::GrassBlock => tints.get(Resolver::Grass, x, y, z),
Tint::Grass | Tint::GrassBlock | Tint::SugarCane => tints.get(Resolver::Grass, x, y, z),
Tint::TallGrassUpper => tints.get(Resolver::Grass, x, y, z),
Tint::Foliage => tints.get(Resolver::Foliage, x, y, z),
Tint::DryFoliage => tints.get(Resolver::Dry, x, y, z),
Tint::Water => tints.get(Resolver::Water, x, y, z),
Tint::Constant(c) => c,
Tint::LilyPad => 0x20_8030,
Tint::Redstone(p) => redstone_color(p),
Tint::Stem(age) => {
let a = u32::from(age);
(a * 32) << 16 | (255 - a * 8) << 8 | (a * 4)
}
Tint::Stem(age) => stem_color(age),
})
}
#[allow(clippy::too_many_arguments)]
@@ -0,0 +1,34 @@
package dev.horizonlab.parity;
import com.google.gson.JsonParser;
import java.nio.file.Files;
import java.nio.file.Path;
import javax.imageio.ImageIO;
import net.minecraft.SharedConstants;
import net.minecraft.client.color.block.BlockColors;
import net.minecraft.server.Bootstrap;
import net.minecraft.world.level.GrassColor;
import net.minecraft.world.level.block.Block;
/** Original adapter calling the pinned client's BlockTintSource.color(state). */
public final class VanillaBlockTintOracle {
public static void main(String[] args) throws Exception {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
var input = JsonParser.parseString(Files.readString(Path.of(args[0]))).getAsJsonObject();
var root = Path.of(input.get("resources").getAsString());
var image = ImageIO.read(root.resolve("assets/minecraft/textures/colormap/grass.png").toFile());
GrassColor.init(image.getRGB(0, 0, 256, 256, null, 0, 256));
var colors = BlockColors.createDefault();
try (var output = Files.newBufferedWriter(Path.of(args[1]))) {
for (var value : input.getAsJsonArray("cases")) {
var row = value.getAsJsonArray();
int stateId = row.get(0).getAsInt(), index = row.get(1).getAsInt();
var state = Block.stateById(stateId);
var source = index < 0 ? null : colors.getTintSource(state, index);
int color = source == null ? -1 : source.color(state);
output.write(stateId + " " + index + " " + color + "\n");
}
}
}
}
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Capture real carried grass/cross models from a vanilla 26.3 server.
Uses fresh scratch homes and a scratch flat world. The vanilla server supplies
carriedBlockState metadata (including properties); Horizon renders the frames.
Screenshots are evidence for visual review, not an automatic framebuffer diff.
"""
import argparse
import json
import os
from pathlib import Path
import subprocess
import tempfile
import time
import lab
from integration_env import game_environment
from test_dedicated_server_parity import Server
from test_rust_parity import verify_artifacts
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--bin-dir', type=Path, default=lab.ROOT / 'target/fast')
parser.add_argument('--out', type=Path, default=lab.LOCAL / 'enderman-render-e2e')
args = parser.parse_args()
out = args.out.resolve()
out.mkdir(parents=True, exist_ok=True)
_, jars, libraries = verify_artifacts()
with tempfile.TemporaryDirectory(prefix='hz-enderman-') as temporary:
work = Path(temporary)
directory = work / 'server'
directory.mkdir()
port = lab.free_port()
props = {'server-port': str(port), 'server-ip': '127.0.0.1', 'online-mode': 'false', 'white-list': 'false',
'level-type': 'minecraft\\:flat', 'generate-structures': 'false', 'gamemode': 'creative',
'view-distance': '3', 'simulation-distance': '3', 'spawn-protection': '0',
'pause-when-empty-seconds': '-1'}
(directory / 'server.properties').write_text(''.join(f'{k}={v}\n' for k, v in props.items()))
(directory / 'eula.txt').write_text('eula=true\n')
server = Server(['java', '-Xmx2g', '-cp', os.pathsep.join(map(str, [jars['server'], *libraries])),
'net.minecraft.server.Main', 'nogui'], directory, 'vanilla')
socket = work / 'ctl.sock'
env = game_environment(os.environ, work / 'client')
game = None
log = (out / 'client.log').open('w')
def ctl(*words):
reply = subprocess.run([str(args.bin_dir / 'horizon-ctl'), '--socket', str(socket),
'--timeout', '30', *map(str, words)], env=env, cwd=work,
check=True, capture_output=True, text=True, timeout=40)
value = json.loads(reply.stdout)
if 'error' in value:
raise RuntimeError(value)
return value
try:
server.wait_for(r'Done \(', timeout=60)
game = subprocess.Popen([str(args.bin_dir / 'horizon-client'), '--resources', str(lab.RESOURCES),
'--server', f'127.0.0.1:{port}', '--name', 'RenderTest', '--offscreen',
'--width', '1280', '--height', '720', '--render-distance', '4',
'--control-socket', str(socket)], cwd=work, env=env, stdout=log, stderr=log)
for _ in range(150):
if game.poll() is not None:
raise RuntimeError((out / 'client.log').read_text())
if socket.exists():
break
time.sleep(0.1)
ctl('wait-for', 'loaded', '--timeout', '60')
for command in ['gamerule spawn_mobs false', 'gamerule advance_time false', 'time set 6000',
'weather clear', 'fill -9 100 -9 9 100 9 smooth_quartz',
'fill -9 101 -9 9 108 9 air', 'tp RenderTest 0 101 -3 0 10']:
server.console(command)
ctl('wait', 20)
ctl('options-set', 'chatVisibility', '2')
ctl('key', 'f1', 'tap')
ctl('options-set', 'maxFps', '60')
for label, blocks in [('grass-poppy', ['grass_block', 'poppy']),
('leaves-pumpkin', ['spruce_leaves', 'carved_pumpkin'])]:
server.console('kill @e[type=minecraft:enderman]')
ctl('wait', 25)
for x, block in zip([-1.2, 1.2], blocks):
props = ',properties:{facing:"east"}' if block == 'carved_pumpkin' else ''
server.console(f'summon enderman {x} 101 2 {{NoAI:1b,PersistenceRequired:1b,Rotation:[180f,0f],'
f'carriedBlockState:{{id:"minecraft:{block}"{props}}}}}')
ctl('wait', 20)
entities = ctl('entities')
assert len([e for e in entities['entities'] if e['type'] == 'minecraft:enderman']) == 2, entities
assert not any('Failed to decode' in line for line in server.lines), '\n'.join(server.lines[-20:])
(out / f'{label}.json').write_text(json.dumps(entities, indent=2))
ctl('screenshot', out / f'{label}.png')
print(f'Carried-block screenshots: {out}', flush=True)
finally:
if game is not None:
try:
ctl('quit')
game.wait(timeout=15)
except (subprocess.SubprocessError, RuntimeError):
game.terminate()
game.wait(timeout=10)
log.close()
server.stop()
(out / 'server.log').write_text(server.log_path.read_text())
if __name__ == '__main__':
main()
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Compare level-free block-model tints with actual 26.3 BlockTintSources.
Every state of blocks with registered sources, plus untinted carried blocks,
is checked at indices -1, 0, 1 and 2. Geometry and framebuffer are separate checks.
"""
import json
try:
from . import lab, oracle_harness as harness, test_section_mesh_parity as mesh
except ImportError:
import lab, oracle_harness as harness, test_section_mesh_parity as mesh
BLOCKS = """large_fern tall_grass fern short_grass potted_fern bush grass_block
pink_petals wildflowers spruce_leaves birch_leaves oak_leaves jungle_leaves
acacia_leaves dark_oak_leaves vine mangrove_leaves leaf_litter water_cauldron
water bubble_column redstone_wire sugar_cane attached_melon_stem
attached_pumpkin_stem melon_stem pumpkin_stem lily_pad stone poppy red_mushroom
brown_mushroom crimson_fungus warped_roots carved_pumpkin""".split()
def make_input(build):
states = mesh.States(lab.RESOURCES)
ids = sorted({state for name in BLOCKS for state in states.by_name["minecraft:" + name]})
rows = [[state, index] for state in ids for index in [-1, 0, 1, 2]]
path = build / "input.json"
path.write_text(json.dumps(dict(resources=str(lab.RESOURCES), cases=rows)))
return path, len(rows)
def main():
harness.differential("block-tint-parity", "VanillaBlockTintOracle", "block_tint_probe",
make_input, "BlockTintSource.color(state), all states of tinted blocks and untinted carried blocks; per-face tint indices, no level/biome. Not model or framebuffer parity.",
sources=[lab.ROOT / "tools/test_block_tint_parity.py", lab.ROOT / "crates/horizon-client/src/mesher.rs",
lab.ROOT / "crates/horizon-client/src/item_render.rs"])
if __name__ == "__main__":
main()